From 7c822dceb3d4513057d25a0640c0a90a3de88e2b Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Tue, 1 Apr 2025 09:45:59 +0300 Subject: [PATCH 1/8] Progress --- src/batch.ts | 2 +- src/enqueued-task.ts | 2 +- src/errors/meilisearch-api-error.ts | 2 +- src/http-requests.ts | 2 +- src/index.ts | 2 +- src/indexes.ts | 2 +- src/meilisearch.ts | 4 +- src/task.ts | 4 +- src/token.ts | 2 +- src/types/index.ts | 4 + src/types/resources.ts | 11 ++ src/types/search-parameters.ts | 11 ++ src/types/search-response.ts | 94 +++++++++ src/{ => types}/types.ts | 275 +------------------------- tests/displayed_attributes.test.ts | 2 +- tests/distinct_attribute.test.ts | 2 +- tests/documents.test.ts | 2 +- tests/dump.test.ts | 2 +- tests/embedders.test.ts | 2 +- tests/facet_search_settings.test.ts | 2 +- tests/faceting.test.ts | 2 +- tests/filterable_attributes.test.ts | 2 +- tests/get_search.test.ts | 2 +- tests/index.test.ts | 2 +- tests/keys.test.ts | 2 +- tests/localized_attributes.test.ts | 2 +- tests/pagination.test.ts | 2 +- tests/prefix_search_settings.test.ts | 2 +- tests/ranking_rules.test.ts | 2 +- tests/raw_document.test.ts | 2 +- tests/search.test.ts | 4 +- tests/search_cutoff_ms.test.ts | 2 +- tests/searchable_attributes.test.ts | 2 +- tests/settings.test.ts | 2 +- tests/snapshots.test.ts | 2 +- tests/sortable_attributes.test.ts | 2 +- tests/stop_words.test.ts | 2 +- tests/synonyms.test.ts | 2 +- tests/task.test.ts | 2 +- tests/typed_search.test.ts | 2 +- tests/typo_tolerance.test.ts | 2 +- tests/utils/meilisearch-test-utils.ts | 2 +- 42 files changed, 165 insertions(+), 310 deletions(-) create mode 100644 src/types/index.ts create mode 100644 src/types/resources.ts create mode 100644 src/types/search-parameters.ts create mode 100644 src/types/search-response.ts rename src/{ => types}/types.ts (85%) diff --git a/src/batch.ts b/src/batch.ts index 32babced7..211674f3e 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -4,7 +4,7 @@ import type { BatchesQuery, BatchesResults, BatchesResultsObject, -} from "./types.js"; +} from "./types/index.js"; import { HttpRequests } from "./http-requests.js"; class Batch { diff --git a/src/enqueued-task.ts b/src/enqueued-task.ts index 9665cadbd..dce0512d5 100644 --- a/src/enqueued-task.ts +++ b/src/enqueued-task.ts @@ -1,4 +1,4 @@ -import type { EnqueuedTaskObject } from "./types.js"; +import type { EnqueuedTaskObject } from "./types/index.js"; class EnqueuedTask { taskUid: EnqueuedTaskObject["taskUid"]; diff --git a/src/errors/meilisearch-api-error.ts b/src/errors/meilisearch-api-error.ts index 6d74e764e..aeb1dd0cb 100644 --- a/src/errors/meilisearch-api-error.ts +++ b/src/errors/meilisearch-api-error.ts @@ -1,4 +1,4 @@ -import type { MeiliSearchErrorResponse } from "../types.js"; +import type { MeiliSearchErrorResponse } from "../types/index.js"; import { MeiliSearchError } from "./meilisearch-error.js"; export class MeiliSearchApiError extends MeiliSearchError { diff --git a/src/http-requests.ts b/src/http-requests.ts index 4e3d9ce33..9648f1c37 100644 --- a/src/http-requests.ts +++ b/src/http-requests.ts @@ -5,7 +5,7 @@ import type { MainRequestOptions, URLSearchParamsRecord, MeiliSearchErrorResponse, -} from "./types.js"; +} from "./types/index.js"; import { PACKAGE_VERSION } from "./package-version.js"; import { MeiliSearchError, diff --git a/src/index.ts b/src/index.ts index e559e1906..0783e1311 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export * from "./types.js"; +export * from "./types/index.js"; export * from "./errors/index.js"; export * from "./indexes.js"; export * from "./enqueued-task.js"; diff --git a/src/indexes.ts b/src/indexes.ts index 7a044f609..a8fccf822 100644 --- a/src/indexes.ts +++ b/src/indexes.ts @@ -53,7 +53,7 @@ import type { ExtraRequestInit, PrefixSearch, RecordAny, -} from "./types.js"; +} from "./types/index.js"; import { HttpRequests } from "./http-requests.js"; import { Task, TaskClient } from "./task.js"; import { EnqueuedTask } from "./enqueued-task.js"; diff --git a/src/meilisearch.ts b/src/meilisearch.ts index f5c6a5784..a78f013da 100644 --- a/src/meilisearch.ts +++ b/src/meilisearch.ts @@ -30,8 +30,8 @@ import type { MultiSearchResponseOrSearchResponse, Network, RecordAny, -} from "./types.js"; -import { ErrorStatusCode } from "./types.js"; +} from "./types/index.js"; +import { ErrorStatusCode } from "./types/index.js"; import { HttpRequests } from "./http-requests.js"; import { TaskClient } from "./task.js"; import { EnqueuedTask } from "./enqueued-task.js"; diff --git a/src/task.ts b/src/task.ts index 912e1795a..012069868 100644 --- a/src/task.ts +++ b/src/task.ts @@ -9,8 +9,8 @@ import type { TasksResultsObject, DeleteTasksQuery, EnqueuedTaskObject, -} from "./types.js"; -import { TaskStatus } from "./types.js"; +} from "./types/index.js"; +import { TaskStatus } from "./types/index.js"; import { HttpRequests } from "./http-requests.js"; import { sleep } from "./utils.js"; import { EnqueuedTask } from "./enqueued-task.js"; diff --git a/src/token.ts b/src/token.ts index e511df250..511b3fcec 100644 --- a/src/token.ts +++ b/src/token.ts @@ -3,7 +3,7 @@ import type { TenantTokenGeneratorOptions, TenantTokenHeader, TokenClaims, -} from "./types.js"; +} from "./types/index.js"; function getOptionsWithDefaults(options: TenantTokenGeneratorOptions) { const { diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 000000000..079b9548d --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,4 @@ +export * from "./resources.js"; +export * from "./search-parameters.js"; +export * from "./search-response.js"; +export * from "./types.js"; diff --git a/src/types/resources.ts b/src/types/resources.ts new file mode 100644 index 000000000..17a71afc4 --- /dev/null +++ b/src/types/resources.ts @@ -0,0 +1,11 @@ +export type Pagination = { + offset?: number; + limit?: number; +}; + +export type ResourceQuery = Pagination & {}; + +export type ResourceResults = Pagination & { + results: T; + total: number; +}; diff --git a/src/types/search-parameters.ts b/src/types/search-parameters.ts new file mode 100644 index 000000000..585a78fdc --- /dev/null +++ b/src/types/search-parameters.ts @@ -0,0 +1,11 @@ +// export type Federation = { +// limit?: number; +// offset?: number; +// facetsByIndex?: ; +// mergeFacets?: ; +// }; + +// export type FederatedSearch = { +// queries: ; +// federation: ; +// }; \ No newline at end of file diff --git a/src/types/search-response.ts b/src/types/search-response.ts new file mode 100644 index 000000000..a9b8053a0 --- /dev/null +++ b/src/types/search-response.ts @@ -0,0 +1,94 @@ +import type { RecordAny } from "./types.js"; + +/** @see `milli::search::new::matches::MatchBounds` */ +export type MatchBounds = { start: number; length: number; indices: number[] }; + +/** @see `meilisearch::search::MatchesPosition` */ +export type MatchesPosition = Record; + +/** @see `meilisearch::search::SearchHit` */ +export type SearchHit = T & { + _formatted?: T; + _matchesPosition?: MatchesPosition; + _rankingScore?: number; + _rankingScoreDetails?: RecordAny; +}; + +export type FederationDetails = { + indexUid: string; + queriesPosition: number; + weightedRankingScore: number; +}; + +export type FederatedSearchHit = + SearchHit & { _federation: FederationDetails }; + +/** @see `meilisearch::search::HitsInfo` */ +type Pagination = { + hitsPerPage: number; + page: number; + totalPages: number; + totalHits: number; +}; + +/** @see `meilisearch::search::HitsInfo` */ +type OffsetLimit = { + limit: number; + offset: number; + estimatedTotalHits: number; +}; + +/** @see `meilisearch::search::FacetStats` */ +export type FacetStats = { + min: number; + max: number; +}; + +type SearchResultCore = { + query: string; + processingTimeMs: number; + facetDistribution?: Record>; + facetStats?: Record; + semanticHitCount?: number; +}; + +export type SearchResultWithPagination = + SearchResultCore & { hits: SearchHit[] } & Pagination; +export type SearchResultWithOffsetLimit = + SearchResultCore & { hits: SearchHit[] } & OffsetLimit; + +/** @see `meilisearch::search::SearchResult` */ +export type SearchResult = + | SearchResultWithPagination + | SearchResultWithOffsetLimit; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/multi_search#federated-multi-search-requests} + * + * @see `meilisearch::search::federated::types::FederatedSearchResult` + */ +export type FederatedSearchResult = + SearchResultCore & { hits: FederatedSearchHit[] } & OffsetLimit; + +type SearchResultIndex = { indexUid: string }; + +export type SearchResultWithIndexAndPagination< + T extends RecordAny = RecordAny, +> = SearchResultIndex & SearchResultWithPagination; +export type SearchResultWithIndexAndOffsetLimit< + T extends RecordAny = RecordAny, +> = SearchResultIndex & SearchResultWithOffsetLimit; + +/** @see `meilisearch::search::SearchResultWithIndex` */ +export type SearchResultWithIndex = + | SearchResultWithIndexAndPagination + | SearchResultWithIndexAndOffsetLimit; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/multi_search#non-federated-multi-search-requests} + * + * @see `meilisearch::routes::multi_search::SearchResults` + */ +export type SearchResults = { + results: SearchResultWithIndex[]; +}; diff --git a/src/types.ts b/src/types/types.ts similarity index 85% rename from src/types.ts rename to src/types/types.ts index af3350f5e..901391fd5 100644 --- a/src/types.ts +++ b/src/types/types.ts @@ -4,8 +4,11 @@ // Definitions: https://github.com/meilisearch/meilisearch-js // TypeScript Version: ^3.8.3 -import { Task } from "./task.js"; -import { Batch } from "./batch.js"; +import { Task } from "../task.js"; +import { Batch } from "../batch.js"; +import type { ResourceQuery, ResourceResults } from "./resources.js"; +import type { Filter, Locale, SearchParams } from "./search-parameters.js"; +import type { FieldDistribution } from "./search-response.js"; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type RecordAny = Record; @@ -114,22 +117,6 @@ export type MainRequestOptions = { */ export type RequestOptions = Omit; -/// -/// Resources -/// - -export type Pagination = { - offset?: number; - limit?: number; -}; - -export type ResourceQuery = Pagination & {}; - -export type ResourceResults = Pagination & { - results: T; - total: number; -}; - /// /// Indexes /// @@ -153,33 +140,6 @@ export type IndexesResults = ResourceResults & {}; * SEARCH PARAMETERS */ -export const MatchingStrategies = { - ALL: "all", - LAST: "last", - FREQUENCY: "frequency", -} as const; - -export type MatchingStrategies = - (typeof MatchingStrategies)[keyof typeof MatchingStrategies]; - -export type Filter = string | (string | string[])[]; - -export type Query = { - q?: string | null; -}; - -export type Highlight = { - attributesToHighlight?: string[]; - highlightPreTag?: string; - highlightPostTag?: string; -}; - -export type Crop = { - attributesToCrop?: string[]; - cropLength?: number; - cropMarker?: string; -}; - // `facetName` becomes mandatory when using `searchForFacetValues` export type SearchForFacetValuesParams = Omit & { facetName: string; @@ -196,87 +156,6 @@ export type SearchForFacetValuesResponse = { processingTimeMs: number; }; -export type HybridSearch = { - embedder: string; - semanticRatio?: number; -}; - -// https://www.meilisearch.com/docs/reference/api/settings#localized-attributes -export type Locale = string; - -export type SearchParams = Query & - Pagination & - Highlight & - Crop & { - filter?: Filter; - sort?: string[]; - facets?: string[]; - attributesToRetrieve?: string[]; - showMatchesPosition?: boolean; - matchingStrategy?: MatchingStrategies; - hitsPerPage?: number; - page?: number; - facetName?: string; - facetQuery?: string; - vector?: number[] | null; - showRankingScore?: boolean; - showRankingScoreDetails?: boolean; - rankingScoreThreshold?: number; - attributesToSearchOn?: string[] | null; - hybrid?: HybridSearch; - distinct?: string; - retrieveVectors?: boolean; - locales?: Locale[]; - }; - -// Search parameters for searches made with the GET method -// Are different than the parameters for the POST method -export type SearchRequestGET = Pagination & - Query & - Omit & - Omit & { - filter?: string; - sort?: string; - facets?: string; - attributesToRetrieve?: string; - attributesToHighlight?: string; - attributesToCrop?: string; - showMatchesPosition?: boolean; - vector?: string | null; - attributesToSearchOn?: string | null; - hybridEmbedder?: string; - hybridSemanticRatio?: number; - rankingScoreThreshold?: number; - distinct?: string; - retrieveVectors?: boolean; - locales?: Locale[]; - }; - -export type MergeFacets = { - maxValuesPerFacet?: number | null; -}; - -export type FederationOptions = { weight: number; remote?: string }; -export type MultiSearchFederation = { - limit?: number; - offset?: number; - facetsByIndex?: Record; - mergeFacets?: MergeFacets | null; -}; - -export type MultiSearchQuery = SearchParams & { indexUid: string }; -export type MultiSearchQueryWithFederation = MultiSearchQuery & { - federationOptions?: FederationOptions; -}; - -export type MultiSearchParams = { - queries: MultiSearchQuery[]; -}; -export type FederatedMultiSearchParams = { - federation: MultiSearchFederation; - queries: MultiSearchQueryWithFederation[]; -}; - /** * {@link https://www.meilisearch.com/docs/reference/api/network#the-remote-object} * @@ -297,150 +176,6 @@ export type Network = { remotes: Record; }; -export type CategoriesDistribution = { - [category: string]: number; -}; - -export type Facet = string; -export type FacetDistribution = Record; -export type MatchesPosition = Partial< - Record ->; - -export type RankingScoreDetails = { - words?: { - order: number; - matchingWords: number; - maxMatchingWords: number; - score: number; - }; - typo?: { - order: number; - typoCount: number; - maxTypoCount: number; - score: number; - }; - proximity?: { - order: number; - score: number; - }; - attribute?: { - order: number; - attributes_ranking_order: number; - attributes_query_word_order: number; - score: number; - }; - exactness?: { - order: number; - matchType: string; - score: number; - }; - [key: string]: RecordAny | undefined; -}; - -export type FederationDetails = { - indexUid: string; - queriesPosition: number; - weightedRankingScore: number; -}; - -export type Hit = T & { - _formatted?: Partial; - _matchesPosition?: MatchesPosition; - _rankingScore?: number; - _rankingScoreDetails?: RankingScoreDetails; - _federation?: FederationDetails; -}; - -export type Hits = Hit[]; - -export type FacetStat = { min: number; max: number }; -export type FacetStats = Record; - -export type FacetsByIndex = Record< - string, - { - distribution: FacetDistribution; - stats: FacetStats; - } ->; - -export type SearchResponse< - T = RecordAny, - S extends SearchParams | undefined = undefined, -> = { - hits: Hits; - processingTimeMs: number; - query: string; - facetDistribution?: FacetDistribution; - facetStats?: FacetStats; - facetsByIndex?: FacetsByIndex; -} & (undefined extends S - ? Partial - : true extends IsFinitePagination> - ? FinitePagination - : InfinitePagination); - -type FinitePagination = { - totalHits: number; - hitsPerPage: number; - page: number; - totalPages: number; -}; -type InfinitePagination = { - offset: number; - limit: number; - estimatedTotalHits: number; -}; - -type IsFinitePagination = Or< - HasHitsPerPage, - HasPage ->; - -type Or = true extends A - ? true - : true extends B - ? true - : false; - -type HasHitsPerPage = undefined extends S["hitsPerPage"] - ? false - : true; - -type HasPage = undefined extends S["page"] - ? false - : true; - -export type MultiSearchResult = SearchResponse & { indexUid: string }; - -export type MultiSearchResponse = { - results: MultiSearchResult[]; -}; - -export type MultiSearchResponseOrSearchResponse< - T1 extends FederatedMultiSearchParams | MultiSearchParams, - T2 extends RecordAny = RecordAny, -> = T1 extends FederatedMultiSearchParams - ? SearchResponse - : MultiSearchResponse; - -export type FieldDistribution = { - [field: string]: number; -}; - -export type SearchSimilarDocumentsParams = { - id: string | number; - offset?: number; - limit?: number; - filter?: Filter; - embedder?: string; - attributesToRetrieve?: string[]; - showRankingScore?: boolean; - showRankingScoreDetails?: boolean; - rankingScoreThreshold?: number; -}; - /* ** Documents */ diff --git a/tests/displayed_attributes.test.ts b/tests/displayed_attributes.test.ts index d3937ff75..39e399997 100644 --- a/tests/displayed_attributes.test.ts +++ b/tests/displayed_attributes.test.ts @@ -1,5 +1,5 @@ import { afterAll, expect, test, describe, beforeEach } from "vitest"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/distinct_attribute.test.ts b/tests/distinct_attribute.test.ts index ca9cf12d5..427558f2d 100644 --- a/tests/distinct_attribute.test.ts +++ b/tests/distinct_attribute.test.ts @@ -1,5 +1,5 @@ import { afterAll, expect, test, describe, beforeEach } from "vitest"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/documents.test.ts b/tests/documents.test.ts index 5f7b62b1e..001d41e3a 100644 --- a/tests/documents.test.ts +++ b/tests/documents.test.ts @@ -4,7 +4,7 @@ import { TaskStatus, TaskTypes, type ResourceResults, -} from "../src/types.js"; +} from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/dump.test.ts b/tests/dump.test.ts index 07085f7bd..c2258fecb 100644 --- a/tests/dump.test.ts +++ b/tests/dump.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe, beforeEach } from "vitest"; -import { ErrorStatusCode, TaskStatus } from "../src/types.js"; +import { ErrorStatusCode, TaskStatus } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/embedders.test.ts b/tests/embedders.test.ts index 13ac89140..6533a4a4b 100644 --- a/tests/embedders.test.ts +++ b/tests/embedders.test.ts @@ -1,6 +1,6 @@ import { afterAll, expect, test, describe, beforeEach } from "vitest"; import { EnqueuedTask } from "../src/enqueued-task.js"; -import type { Embedders } from "../src/types.js"; +import type { Embedders } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/facet_search_settings.test.ts b/tests/facet_search_settings.test.ts index 9b3d1e115..779fef062 100644 --- a/tests/facet_search_settings.test.ts +++ b/tests/facet_search_settings.test.ts @@ -1,5 +1,5 @@ import { afterAll, expect, test, describe, beforeEach } from "vitest"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/faceting.test.ts b/tests/faceting.test.ts index 0aa7f0633..73d3ea7db 100644 --- a/tests/faceting.test.ts +++ b/tests/faceting.test.ts @@ -6,7 +6,7 @@ import { afterAll, beforeAll, } from "vitest"; -import { ErrorStatusCode, type Faceting } from "../src/types.js"; +import { ErrorStatusCode, type Faceting } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/filterable_attributes.test.ts b/tests/filterable_attributes.test.ts index f951e96f7..d2540623e 100644 --- a/tests/filterable_attributes.test.ts +++ b/tests/filterable_attributes.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe, beforeEach, afterAll } from "vitest"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/get_search.test.ts b/tests/get_search.test.ts index fd9243a34..17543797a 100644 --- a/tests/get_search.test.ts +++ b/tests/get_search.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe, afterAll, beforeAll } from "vitest"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { EnqueuedTask } from "../src/enqueued-task.js"; import { clearAllIndexes, diff --git a/tests/index.test.ts b/tests/index.test.ts index 7e9d70af5..b57401dcd 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe, beforeEach, afterAll } from "vitest"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/keys.test.ts b/tests/keys.test.ts index e054ad18f..3a83e4d88 100644 --- a/tests/keys.test.ts +++ b/tests/keys.test.ts @@ -1,6 +1,6 @@ import { expect, test, describe, beforeEach, afterAll } from "vitest"; import { MeiliSearch } from "../src/index.js"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/localized_attributes.test.ts b/tests/localized_attributes.test.ts index 164f35dc8..259408e0e 100644 --- a/tests/localized_attributes.test.ts +++ b/tests/localized_attributes.test.ts @@ -6,7 +6,7 @@ import { expect, test, } from "vitest"; -import { ErrorStatusCode, type LocalizedAttributes } from "../src/types.js"; +import { ErrorStatusCode, type LocalizedAttributes } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/pagination.test.ts b/tests/pagination.test.ts index 48e3dfda2..ea0547a55 100644 --- a/tests/pagination.test.ts +++ b/tests/pagination.test.ts @@ -6,7 +6,7 @@ afterAll, beforeAll, } from "vitest"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/prefix_search_settings.test.ts b/tests/prefix_search_settings.test.ts index cf6fdd7e2..5e5cc7e68 100644 --- a/tests/prefix_search_settings.test.ts +++ b/tests/prefix_search_settings.test.ts @@ -1,5 +1,5 @@ import { afterAll, expect, test, describe, beforeEach } from "vitest"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/ranking_rules.test.ts b/tests/ranking_rules.test.ts index 42f4951f3..6db04d1e0 100644 --- a/tests/ranking_rules.test.ts +++ b/tests/ranking_rules.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe, beforeEach, afterAll } from "vitest"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { EnqueuedTask } from "../src/enqueued-task.js"; import { clearAllIndexes, diff --git a/tests/raw_document.test.ts b/tests/raw_document.test.ts index 96c80286a..bb11d67c1 100644 --- a/tests/raw_document.test.ts +++ b/tests/raw_document.test.ts @@ -4,7 +4,7 @@ import { config, getClient, } from "./utils/meilisearch-test-utils.js"; -import { TaskStatus, ContentTypeEnum } from "../src/types.js"; +import { TaskStatus, ContentTypeEnum } from "../src/types/index.js"; beforeEach(async () => { await clearAllIndexes(config); diff --git a/tests/search.test.ts b/tests/search.test.ts index 9fb0e7512..9e03c8858 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -7,11 +7,11 @@ import { beforeAll, vi, } from "vitest"; -import { ErrorStatusCode, MatchingStrategies } from "../src/types.js"; +import { ErrorStatusCode, MatchingStrategies } from "../src/types/index.js"; import type { FederatedMultiSearchParams, MultiSearchParams, -} from "../src/types.js"; +} from "../src/types/index.js"; import { EnqueuedTask } from "../src/enqueued-task.js"; import { clearAllIndexes, diff --git a/tests/search_cutoff_ms.test.ts b/tests/search_cutoff_ms.test.ts index 7f8d6f477..5cb52ce3e 100644 --- a/tests/search_cutoff_ms.test.ts +++ b/tests/search_cutoff_ms.test.ts @@ -6,7 +6,7 @@ import { expect, test, } from "vitest"; -import { ErrorStatusCode, type SearchCutoffMs } from "../src/types.js"; +import { ErrorStatusCode, type SearchCutoffMs } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/searchable_attributes.test.ts b/tests/searchable_attributes.test.ts index f250e0a8d..c88e3e50f 100644 --- a/tests/searchable_attributes.test.ts +++ b/tests/searchable_attributes.test.ts @@ -6,7 +6,7 @@ import { expect, test, } from "vitest"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 2296f4ced..670fa07e8 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, describe, expect, test } from "vitest"; -import { ErrorStatusCode, type Settings } from "../src/types.js"; +import { ErrorStatusCode, type Settings } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/snapshots.test.ts b/tests/snapshots.test.ts index be952d541..8561af3cb 100644 --- a/tests/snapshots.test.ts +++ b/tests/snapshots.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from "vitest"; -import { ErrorStatusCode, TaskStatus } from "../src/types.js"; +import { ErrorStatusCode, TaskStatus } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/sortable_attributes.test.ts b/tests/sortable_attributes.test.ts index 90a306a9b..babdf695f 100644 --- a/tests/sortable_attributes.test.ts +++ b/tests/sortable_attributes.test.ts @@ -6,7 +6,7 @@ import { expect, test, } from "vitest"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/stop_words.test.ts b/tests/stop_words.test.ts index bbec0e38c..2aeb0c9a7 100644 --- a/tests/stop_words.test.ts +++ b/tests/stop_words.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, describe, expect, test } from "vitest"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { EnqueuedTask } from "../src/enqueued-task.js"; import { clearAllIndexes, diff --git a/tests/synonyms.test.ts b/tests/synonyms.test.ts index d8ad653be..1a97df758 100644 --- a/tests/synonyms.test.ts +++ b/tests/synonyms.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, describe, expect, test } from "vitest"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/task.test.ts b/tests/task.test.ts index 84f3a20d8..88bc61628 100644 --- a/tests/task.test.ts +++ b/tests/task.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, describe, expect, test } from "vitest"; -import { ErrorStatusCode, TaskTypes, TaskStatus } from "../src/types.js"; +import { ErrorStatusCode, TaskTypes, TaskStatus } from "../src/types/index.js"; import { sleep } from "../src/utils.js"; import { clearAllIndexes, diff --git a/tests/typed_search.test.ts b/tests/typed_search.test.ts index 06467cfb2..cf23a168a 100644 --- a/tests/typed_search.test.ts +++ b/tests/typed_search.test.ts @@ -6,7 +6,7 @@ import { expect, test, } from "vitest"; -import { ErrorStatusCode, type SearchResponse } from "../src/types.js"; +import { ErrorStatusCode, type SearchResponse } from "../src/types/index.js"; import { EnqueuedTask } from "../src/enqueued-task.js"; import { clearAllIndexes, diff --git a/tests/typo_tolerance.test.ts b/tests/typo_tolerance.test.ts index 06ad08f50..8a3dc77fa 100644 --- a/tests/typo_tolerance.test.ts +++ b/tests/typo_tolerance.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, describe, expect, test } from "vitest"; -import { ErrorStatusCode } from "../src/types.js"; +import { ErrorStatusCode } from "../src/types/index.js"; import { clearAllIndexes, config, diff --git a/tests/utils/meilisearch-test-utils.ts b/tests/utils/meilisearch-test-utils.ts index 344d85175..46c8c5b59 100644 --- a/tests/utils/meilisearch-test-utils.ts +++ b/tests/utils/meilisearch-test-utils.ts @@ -1,6 +1,6 @@ import { assert as vitestAssert } from "vitest"; import { MeiliSearch, Index } from "../../src/index.js"; -import type { Config } from "../../src/types.js"; +import type { Config } from "../../src/types/index.js"; // testing const MASTER_KEY = "masterKey"; From 73e8c8aff1432764e2be6ba331753f8edaea22bc Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Thu, 3 Apr 2025 14:43:31 +0300 Subject: [PATCH 2/8] Progress --- src/http-requests.ts | 9 +- src/indexes.ts | 147 ++++++++-------------- src/types/http-requests.ts | 95 ++++++++++++++ src/types/index.ts | 4 + src/types/network.ts | 19 +++ src/types/search-parameters.ts | 193 ++++++++++++++++++++++++++-- src/types/search-response.ts | 31 ++++- src/types/shared.ts | 10 ++ src/types/token.ts | 71 +++++++++++ src/types/types.ts | 222 +-------------------------------- src/utils.ts | 21 +++- 11 files changed, 482 insertions(+), 340 deletions(-) create mode 100644 src/types/http-requests.ts create mode 100644 src/types/network.ts create mode 100644 src/types/shared.ts create mode 100644 src/types/token.ts diff --git a/src/http-requests.ts b/src/http-requests.ts index 9648f1c37..8d70230f5 100644 --- a/src/http-requests.ts +++ b/src/http-requests.ts @@ -21,14 +21,7 @@ function appendRecordToURLSearchParams( ): void { for (const [key, val] of Object.entries(recordToAppend)) { if (val != null) { - searchParams.set( - key, - Array.isArray(val) - ? val.join() - : val instanceof Date - ? val.toISOString() - : String(val), - ); + searchParams.set(key, Array.isArray(val) ? val.join() : String(val)); } } } diff --git a/src/indexes.ts b/src/indexes.ts index a8fccf822..d969ae518 100644 --- a/src/indexes.ts +++ b/src/indexes.ts @@ -5,13 +5,8 @@ * Copyright: 2019, MeiliSearch */ -import { MeiliSearchError } from "./errors/index.js"; import type { Config, - SearchResponse, - SearchParams, - Filter, - SearchRequestGET, IndexObject, IndexOptions, IndexStats, @@ -38,25 +33,29 @@ import type { ContentType, DocumentsIds, DocumentsDeletionQuery, - SearchForFacetValuesParams, - SearchForFacetValuesResponse, SeparatorTokens, NonSeparatorTokens, Dictionary, ProximityPrecision, Embedders, SearchCutoffMs, - SearchSimilarDocumentsParams, LocalizedAttributes, UpdateDocumentsByFunctionOptions, EnqueuedTaskObject, ExtraRequestInit, PrefixSearch, RecordAny, + SearchQuery, + SearchResult, + FacetSearchQuery, + FacetSearchResult, + SimilarQuery, + SimilarResult, } from "./types/index.js"; import { HttpRequests } from "./http-requests.js"; import { Task, TaskClient } from "./task.js"; import { EnqueuedTask } from "./enqueued-task.js"; +import { stringifyRecordKeyValues } from "./utils.js"; class Index { uid: string; @@ -82,103 +81,63 @@ class Index { /// SEARCH /// - /** - * Search for documents into an index - * - * @param query - Query string - * @param options - Search options - * @param config - Additional request configuration options - * @returns Promise containing the search response - */ - async search( - query?: string | null, - options?: S, - extraRequestInit?: ExtraRequestInit, - ): Promise> { - return await this.httpRequest.post>({ + /** {@link https://www.meilisearch.com/docs/reference/api/search} */ + async search( + searchQuery: SearchQuery, + init?: ExtraRequestInit, + ): Promise> { + return await this.httpRequest.post({ path: `indexes/${this.uid}/search`, - body: { q: query, ...options }, - extraRequestInit, - }); - } - - /** - * Search for documents into an index using the GET method - * - * @param query - Query string - * @param options - Search options - * @param config - Additional request configuration options - * @returns Promise containing the search response - */ - async searchGet< - D extends RecordAny = T, - S extends SearchParams = SearchParams, - >( - query?: string | null, - options?: S, - extraRequestInit?: ExtraRequestInit, - ): Promise> { - // TODO: Make this a type thing instead of a runtime thing - const parseFilter = (filter?: Filter): string | undefined => { - if (typeof filter === "string") return filter; - else if (Array.isArray(filter)) - throw new MeiliSearchError( - "The filter query parameter should be in string format when using searchGet", - ); - else return undefined; - }; - - const getParams: SearchRequestGET = { - q: query, - ...options, - filter: parseFilter(options?.filter), - sort: options?.sort?.join(","), - facets: options?.facets?.join(","), - attributesToRetrieve: options?.attributesToRetrieve?.join(","), - attributesToCrop: options?.attributesToCrop?.join(","), - attributesToHighlight: options?.attributesToHighlight?.join(","), - vector: options?.vector?.join(","), - attributesToSearchOn: options?.attributesToSearchOn?.join(","), - }; - - return await this.httpRequest.get>({ + body: searchQuery, + extraRequestInit: init, + }); + } + + /** {@link https://www.meilisearch.com/docs/reference/api/search#search-in-an-index-with-get} */ + async searchGet( + searchQuery: SearchQuery, + init?: ExtraRequestInit, + ): Promise> { + return await this.httpRequest.get({ path: `indexes/${this.uid}/search`, - params: getParams, - extraRequestInit, + params: stringifyRecordKeyValues(searchQuery, ["filter", "hybrid"]), + extraRequestInit: init, }); } - /** - * Search for facet values - * - * @param params - Parameters used to search on the facets - * @param config - Additional request configuration options - * @returns Promise containing the search response - */ + /** {@link https://www.meilisearch.com/docs/reference/api/facet_search#facet-search} */ async searchForFacetValues( - params: SearchForFacetValuesParams, - extraRequestInit?: ExtraRequestInit, - ): Promise { - return await this.httpRequest.post({ + facetSearchQuery: FacetSearchQuery, + init?: ExtraRequestInit, + ): Promise { + return await this.httpRequest.post({ path: `indexes/${this.uid}/facet-search`, - body: params, - extraRequestInit, + body: facetSearchQuery, + extraRequestInit: init, }); } - /** - * Search for similar documents - * - * @param params - Parameters used to search for similar documents - * @returns Promise containing the search response - */ - async searchSimilarDocuments< - D extends RecordAny = T, - S extends SearchParams = SearchParams, - >(params: SearchSimilarDocumentsParams): Promise> { - return await this.httpRequest.post>({ + /** {@link https://www.meilisearch.com/docs/reference/api/similar} */ + async searchSimilarDocuments( + similarQuery: SimilarQuery, + init?: ExtraRequestInit, + ): Promise { + return await this.httpRequest.post({ path: `indexes/${this.uid}/similar`, - body: params, + body: similarQuery, + extraRequestInit: init, + }); + } + + /** {@link https://www.meilisearch.com/docs/reference/api/similar#get-similar-documents-with-get} */ + async searchSimilarDocumentsGet( + similarQuery: SimilarQuery, + init?: ExtraRequestInit, + ): Promise { + return await this.httpRequest.get({ + path: `indexes/${this.uid}/similar`, + params: stringifyRecordKeyValues(similarQuery, ["filter"]), + extraRequestInit: init, }); } diff --git a/src/types/http-requests.ts b/src/types/http-requests.ts new file mode 100644 index 000000000..0820cade8 --- /dev/null +++ b/src/types/http-requests.ts @@ -0,0 +1,95 @@ +/** + * Shape of allowed record object that can be appended to a + * {@link URLSearchParams}. + */ +export type URLSearchParamsRecord = Record< + string, + string | string[] | number | number[] | boolean | null | undefined +>; + +/** + * {@link RequestInit} without {@link RequestInit.body} and + * {@link RequestInit.method} properties. + */ +export type ExtraRequestInit = Omit; + +/** Same as {@link ExtraRequestInit} but without {@link ExtraRequestInit.signal}. */ +export type BaseRequestInit = Omit; + +/** + * Same as {@link BaseRequestInit} but with its headers property forced as a + * {@link Headers} object. + */ +export type HttpRequestsRequestInit = Omit & { + headers: Headers; +}; + +/** Main configuration object for the meilisearch client. */ +export type Config = { + /** + * The base URL for reaching a meilisearch instance. + * + * @remarks + * Protocol and trailing slash can be omitted. + */ + host: string; + /** + * API key for interacting with a meilisearch instance. + * + * @see {@link https://www.meilisearch.com/docs/learn/security/basic_security} + */ + apiKey?: string; + /** + * Custom strings that will be concatted to the "X-Meilisearch-Client" header + * on each request. + */ + clientAgents?: string[]; + /** Base request options that may override the default ones. */ + requestInit?: BaseRequestInit; + /** + * Custom function that can be provided in place of {@link fetch}. + * + * @remarks + * API response errors will have to be handled manually with this as well. + * @deprecated This will be removed in a future version. See + * {@link https://github.com/meilisearch/meilisearch-js/issues/1824 | issue}. + */ + httpClient?: (...args: Parameters) => Promise; + /** Timeout in milliseconds for each HTTP request. */ + timeout?: number; +}; + +/** Main options of a request. */ +export type MainRequestOptions = { + /** The path or subpath of the URL to make a request to. */ + path: string; + /** The REST method of the request. */ + method?: string; + /** The search parameters of the URL. */ + params?: URLSearchParamsRecord; + /** + * {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type | Content-Type} + * passed to request {@link Headers}. + */ + contentType?: string; + /** + * The body of the request. + * + * @remarks + * This only really supports string for now (any other type gets stringified) + * but it could support more in the future. + * {@link https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#body} + */ + body?: string | boolean | number | object | null; + /** + * An extra, more limited {@link RequestInit}, that may override some of the + * options. + */ + extraRequestInit?: ExtraRequestInit; +}; + +/** + * {@link MainRequestOptions} without {@link MainRequestOptions.method}, for + * method functions. + */ +export type RequestOptions = Omit; diff --git a/src/types/index.ts b/src/types/index.ts index 079b9548d..7de051d9a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,8 @@ +export * from "./http-requests.js"; +export * from "./network.js"; export * from "./resources.js"; export * from "./search-parameters.js"; export * from "./search-response.js"; +export * from "./shared.js"; +export * from "./token.js"; export * from "./types.js"; diff --git a/src/types/network.ts b/src/types/network.ts new file mode 100644 index 000000000..ef4a2020e --- /dev/null +++ b/src/types/network.ts @@ -0,0 +1,19 @@ +/** + * {@link https://www.meilisearch.com/docs/reference/api/network#the-remote-object} + * + * @see `meilisearch_types::features::Remote` at {@link https://github.com/meilisearch/meilisearch} + */ +export type Remote = { + url: string; + searchApiKey: string | null; +}; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/network#the-network-object} + * + * @see `meilisearch_types::features::Network` at {@link https://github.com/meilisearch/meilisearch} + */ +export type Network = { + self: string | null; + remotes: Record; +}; diff --git a/src/types/search-parameters.ts b/src/types/search-parameters.ts index 585a78fdc..5ccfe16e0 100644 --- a/src/types/search-parameters.ts +++ b/src/types/search-parameters.ts @@ -1,11 +1,182 @@ -// export type Federation = { -// limit?: number; -// offset?: number; -// facetsByIndex?: ; -// mergeFacets?: ; -// }; - -// export type FederatedSearch = { -// queries: ; -// federation: ; -// }; \ No newline at end of file +import type { NonNullKeys, RequiredKeys } from "./shared.js"; + +/** @see `meilisearch::search::HybridQuery` */ +export type HybridQuery = { + semanticRatio?: number; + embedder: string; +}; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/search#matching-strategy} + * + * @see `meilisearch::search::MatchingStrategy` + */ +export type MatchingStrategy = "last" | "all" | "frequency"; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/multi_search#federationoptions} + * + * @see `meilisearch::search::federated::types::FederationOptions` + */ +export type FederationOptions = { + weight?: number; + /** @experimental */ + remote?: string | null; + queryPosition?: number | null; +}; + +type OffsetLimit = { + /** {@link https://www.meilisearch.com/docs/reference/api/search#offset} */ + offset?: number | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#limit} */ + limit?: number | null; +}; + +type Pagination = { + /** {@link https://www.meilisearch.com/docs/reference/api/search#page} */ + page?: number | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#number-of-results-per-page} */ + hitsPerPage?: number | null; +}; + +type FirstPartOfFacetAndSearchQuerySegment = { + /** {@link https://www.meilisearch.com/docs/reference/api/search#filter} */ + filter?: string | (string | string[])[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#ranking-score-threshold} */ + rankingScoreThreshold?: number | null; +}; + +type FacetAndSearchQuerySegment = { + /** {@link https://www.meilisearch.com/docs/reference/api/search#query-q} */ + q?: string | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#vector} */ + vector?: number[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#hybrid-search} */ + hybrid?: HybridQuery | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#matching-strategy} */ + matchingStrategy?: MatchingStrategy; + /** {@link https://www.meilisearch.com/docs/reference/api/search#customize-attributes-to-search-on-at-search-time} */ + attributesToSearchOn?: string[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#query-locales} */ + locales?: string[] | null; +} & FirstPartOfFacetAndSearchQuerySegment; + +type FirstPartOfSearchQueryCore = { + /** {@link https://www.meilisearch.com/docs/reference/api/search#attributes-to-retrieve} */ + attributesToRetrieve?: string[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#display-_vectors-in-response} */ + retrieveVectors?: boolean; + /** {@link https://www.meilisearch.com/docs/reference/api/search#ranking-score} */ + showRankingScore?: boolean; + /** {@link https://www.meilisearch.com/docs/reference/api/search#ranking-score-details} */ + showRankingScoreDetails?: boolean; +}; + +type SearchQueryCore = { + /** {@link https://www.meilisearch.com/docs/reference/api/search#attributes-to-crop} */ + attributesToCrop?: string[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#crop-length} */ + cropLength?: number; + /** {@link https://www.meilisearch.com/docs/reference/api/search#attributes-to-highlight} */ + attributesToHighlight?: string[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#show-matches-position} */ + showMatchesPosition?: boolean; + /** {@link https://www.meilisearch.com/docs/reference/api/search#sort} */ + sort?: string[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#distinct-attributes-at-search-time} */ + distinct?: string | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#facets} */ + facets?: string[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#highlight-tags} */ + highlightPreTag?: string; + /** {@link https://www.meilisearch.com/docs/reference/api/search#highlight-tags} */ + highlightPostTag?: string; + /** {@link https://www.meilisearch.com/docs/reference/api/search#crop-marker} */ + cropMarker?: string; +} & FacetAndSearchQuerySegment & + FirstPartOfSearchQueryCore; + +export type SearchQueryWithOffsetLimit = SearchQueryCore & OffsetLimit; +type SearchQueryWithPagination = SearchQueryCore & Pagination; + +/** + * @remarks + * While `page` is an `Option` with a default value of `None` in the + * source code in `meilisearch::search::SearchQuery` (which serializes to + * `number | null | undefined`), we're enforcing it in this case to guarantee a + * response shape with pagination. + */ +export type SearchQueryWithRequiredPagination = RequiredKeys< + SearchQueryWithPagination, + "page" +>; + +/** @see `meilisearch::search::SearchQuery` */ +export type SearchQuery = + | SearchQueryWithOffsetLimit + | SearchQueryWithPagination; + +type SearchQueryWithIndexCore = SearchQueryCore & { indexUid: string }; + +export type SearchQueryWithIndexAndFederation = { + /** {@link https://www.meilisearch.com/docs/reference/api/multi_search#federationoptions} */ + federationOptions?: FederationOptions | null; +} & SearchQueryWithIndexCore; + +export type SearchQueryWithIndexAndOffsetLimit = SearchQueryWithIndexCore & + OffsetLimit; +export type SearchQueryWithIndexAndPagination = SearchQueryWithIndexCore & + Pagination; + +/** @see `meilisearch::search::SearchQueryWithIndex` */ +export type SearchQueryWithIndex = + | SearchQueryWithIndexAndOffsetLimit + | SearchQueryWithIndexAndPagination; + +/** @see `meilisearch::search::federated::types::MergeFacets` */ +export type MergeFacets = { maxValuesPerFacet?: number | null }; + +/** @see `meilisearch::search::federated::types::Federation` */ +export type Federation = { + limit?: number; + offset?: number; + facetsByIndex?: Record; + mergeFacets?: MergeFacets | null; +}; + +/** @see `meilisearch::search::federated::types::FederatedSearch` */ +export type FederatedSearch = { + queries: SearchQueryWithIndexAndFederation[]; + federation?: Federation | null; +}; + +/** @see `meilisearch::search::federated::types::FederatedSearch` */ +export type MultiSearch = { queries: SearchQueryWithIndex[] }; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/facet_search#body} + * + * @see `meilisearch::routes::indexes::facet_search::FacetSearchQuery` + */ +export type FacetSearchQuery = { + facetQuery?: string | null; + facetName: string; + /** + * @remarks + * Undocumented. + */ + exhaustiveFacetCount?: boolean | null; +} & FacetAndSearchQuerySegment; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/similar#body} + * + * @see `meilisearch::search::SimilarQuery` + */ +export type SimilarQuery = { + id: string | number; + /** {@link https://www.meilisearch.com/docs/reference/api/search#hybrid-search} */ + embedder: string; +} & FirstPartOfFacetAndSearchQuerySegment & + FirstPartOfSearchQueryCore & + NonNullKeys; diff --git a/src/types/search-response.ts b/src/types/search-response.ts index a9b8053a0..1e081cf62 100644 --- a/src/types/search-response.ts +++ b/src/types/search-response.ts @@ -1,4 +1,4 @@ -import type { RecordAny } from "./types.js"; +import type { RecordAny } from "./shared.js"; /** @see `milli::search::new::matches::MatchBounds` */ export type MatchBounds = { start: number; length: number; indices: number[] }; @@ -44,13 +44,14 @@ export type FacetStats = { max: number; }; +type ProcessingTime = { processingTimeMs: number }; + type SearchResultCore = { query: string; - processingTimeMs: number; facetDistribution?: Record>; facetStats?: Record; semanticHitCount?: number; -}; +} & ProcessingTime; export type SearchResultWithPagination = SearchResultCore & { hits: SearchHit[] } & Pagination; @@ -59,8 +60,8 @@ export type SearchResultWithOffsetLimit = /** @see `meilisearch::search::SearchResult` */ export type SearchResult = - | SearchResultWithPagination - | SearchResultWithOffsetLimit; + | SearchResultWithOffsetLimit + | SearchResultWithPagination; /** * {@link https://www.meilisearch.com/docs/reference/api/multi_search#federated-multi-search-requests} @@ -92,3 +93,23 @@ export type SearchResultWithIndex = export type SearchResults = { results: SearchResultWithIndex[]; }; + +/** @see `milli::search::facet::search::FacetValueHit` */ +export type FacetValueHit = { value: string; count: number }; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/facet_search#response} + * + * @see `meilisearch::search::FacetSearchResult` + */ +export type FacetSearchResult = { + facetHits: FacetValueHit[]; + facetQuery: string | null; +} & ProcessingTime; + +/** @see `meilisearch::search::SimilarResult` */ +export type SimilarResult = { + hits: SearchHit[]; + id: string; +} & ProcessingTime & + OffsetLimit; diff --git a/src/types/shared.ts b/src/types/shared.ts new file mode 100644 index 000000000..745469a58 --- /dev/null +++ b/src/types/shared.ts @@ -0,0 +1,10 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RecordAny = Record; + +export type NonNullKeys = { + [TKey in U]: Exclude; +} & { [TKey in Exclude]: T[TKey] }; + +export type RequiredKeys = { + [TKey in U]-?: Exclude; +} & { [TKey in Exclude]: T[TKey] }; diff --git a/src/types/token.ts b/src/types/token.ts new file mode 100644 index 000000000..b1efb1718 --- /dev/null +++ b/src/types/token.ts @@ -0,0 +1,71 @@ +/** @see {@link TokenSearchRules} */ +export type TokenIndexRules = { filter?: Filter }; + +/** + * {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference#search-rules} + * + * @remarks + * Not well documented. + * @see `meilisearch_auth::SearchRules` at {@link https://github.com/meilisearch/meilisearch} + */ +export type TokenSearchRules = + | Record + | string[]; + +/** Options object for tenant token generation. */ +export type TenantTokenGeneratorOptions = { + /** API key used to sign the token. */ + apiKey: string; + /** + * The uid of the api key used as issuer of the token. + * + * @see {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference#api-key-uid} + */ + apiKeyUid: string; + /** + * Search rules that are applied to every search. + * + * @defaultValue `["*"]` + */ + searchRules?: TokenSearchRules; + /** + * {@link https://en.wikipedia.org/wiki/Unix_time | UNIX timestamp} or + * {@link Date} object at which the token expires. + * + * @see {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference#expiry-date} + */ + expiresAt?: number | Date; + /** + * Encryption algorithm used to sign the JWT. Supported values by Meilisearch + * are HS256, HS384, HS512. (HS[number] means HMAC using SHA-[number]) + * + * @defaultValue `"HS256"` + * @see {@link https://www.meilisearch.com/docs/learn/security/generate_tenant_token_scratch#prepare-token-header} + */ + algorithm?: `HS${256 | 384 | 512}`; + /** + * By default if a non-safe environment is detected, an error is thrown. + * Setting this to `true` skips environment detection. This is intended for + * server-side environments where detection fails or usage in a browser is + * intentional (Use at your own risk). + * + * @defaultValue `false` + */ + force?: boolean; +}; + +/** + * @see {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference | Tenant token payload reference} + * @see {@link https://github.com/meilisearch/meilisearch/blob/b21d7aedf9096539041362d438e973a18170f3fc/crates/meilisearch/src/extractors/authentication/mod.rs#L334-L340 | GitHub source code} + */ +export type TokenClaims = { + searchRules: TokenSearchRules; + exp?: number; + apiKeyUid: string; +}; + +/** JSON Web Token header. */ +export type TenantTokenHeader = { + alg: NonNullable; + typ: "JWT"; +}; diff --git a/src/types/types.ts b/src/types/types.ts index 901391fd5..38d4ea697 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -7,115 +7,7 @@ import { Task } from "../task.js"; import { Batch } from "../batch.js"; import type { ResourceQuery, ResourceResults } from "./resources.js"; -import type { Filter, Locale, SearchParams } from "./search-parameters.js"; -import type { FieldDistribution } from "./search-response.js"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type RecordAny = Record; - -/** - * Shape of allowed record object that can be appended to a - * {@link URLSearchParams}. - */ -export type URLSearchParamsRecord = Record< - string, - | string - | string[] - | (string | string[])[] - | number - | number[] - | boolean - | Date - | null - | undefined ->; - -/** - * {@link RequestInit} without {@link RequestInit.body} and - * {@link RequestInit.method} properties. - */ -export type ExtraRequestInit = Omit; - -/** Same as {@link ExtraRequestInit} but without {@link ExtraRequestInit.signal}. */ -export type BaseRequestInit = Omit; - -/** - * Same as {@link BaseRequestInit} but with its headers property forced as a - * {@link Headers} object. - */ -export type HttpRequestsRequestInit = Omit & { - headers: Headers; -}; - -/** Main configuration object for the meilisearch client. */ -export type Config = { - /** - * The base URL for reaching a meilisearch instance. - * - * @remarks - * Protocol and trailing slash can be omitted. - */ - host: string; - /** - * API key for interacting with a meilisearch instance. - * - * @see {@link https://www.meilisearch.com/docs/learn/security/basic_security} - */ - apiKey?: string; - /** - * Custom strings that will be concatted to the "X-Meilisearch-Client" header - * on each request. - */ - clientAgents?: string[]; - /** Base request options that may override the default ones. */ - requestInit?: BaseRequestInit; - /** - * Custom function that can be provided in place of {@link fetch}. - * - * @remarks - * API response errors will have to be handled manually with this as well. - * @deprecated This will be removed in a future version. See - * {@link https://github.com/meilisearch/meilisearch-js/issues/1824 | issue}. - */ - httpClient?: (...args: Parameters) => Promise; - /** Timeout in milliseconds for each HTTP request. */ - timeout?: number; -}; - -/** Main options of a request. */ -export type MainRequestOptions = { - /** The path or subpath of the URL to make a request to. */ - path: string; - /** The REST method of the request. */ - method?: string; - /** The search parameters of the URL. */ - params?: URLSearchParamsRecord; - /** - * {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type | Content-Type} - * passed to request {@link Headers}. - */ - contentType?: string; - /** - * The body of the request. - * - * @remarks - * This only really supports string for now (any other type gets stringified) - * but it could support more in the future. - * {@link https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#body} - */ - body?: string | boolean | number | object | null; - /** - * An extra, more limited {@link RequestInit}, that may override some of the - * options. - */ - extraRequestInit?: ExtraRequestInit; -}; - -/** - * {@link MainRequestOptions} without {@link MainRequestOptions.method}, for - * method functions. - */ -export type RequestOptions = Omit; +import type { RecordAny } from "./shared.js"; /// /// Indexes @@ -136,46 +28,6 @@ export type IndexesQuery = ResourceQuery & {}; export type IndexesResults = ResourceResults & {}; -/* - * SEARCH PARAMETERS - */ - -// `facetName` becomes mandatory when using `searchForFacetValues` -export type SearchForFacetValuesParams = Omit & { - facetName: string; -}; - -export type FacetHit = { - value: string; - count: number; -}; - -export type SearchForFacetValuesResponse = { - facetHits: FacetHit[]; - facetQuery: string | null; - processingTimeMs: number; -}; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/network#the-remote-object} - * - * @see `meilisearch_types::features::Remote` at {@link https://github.com/meilisearch/meilisearch} - */ -export type Remote = { - url: string; - searchApiKey: string | null; -}; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/network#the-network-object} - * - * @see `meilisearch_types::features::Network` at {@link https://github.com/meilisearch/meilisearch} - */ -export type Network = { - self: string | null; - remotes: Record; -}; - /* ** Documents */ @@ -1074,75 +926,3 @@ export const ErrorStatusCode = { export type ErrorStatusCode = (typeof ErrorStatusCode)[keyof typeof ErrorStatusCode]; - -/** @see {@link TokenSearchRules} */ -export type TokenIndexRules = { filter?: Filter }; - -/** - * {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference#search-rules} - * - * @remarks - * Not well documented. - * @see `meilisearch_auth::SearchRules` at {@link https://github.com/meilisearch/meilisearch} - */ -export type TokenSearchRules = - | Record - | string[]; - -/** Options object for tenant token generation. */ -export type TenantTokenGeneratorOptions = { - /** API key used to sign the token. */ - apiKey: string; - /** - * The uid of the api key used as issuer of the token. - * - * @see {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference#api-key-uid} - */ - apiKeyUid: string; - /** - * Search rules that are applied to every search. - * - * @defaultValue `["*"]` - */ - searchRules?: TokenSearchRules; - /** - * {@link https://en.wikipedia.org/wiki/Unix_time | UNIX timestamp} or - * {@link Date} object at which the token expires. - * - * @see {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference#expiry-date} - */ - expiresAt?: number | Date; - /** - * Encryption algorithm used to sign the JWT. Supported values by Meilisearch - * are HS256, HS384, HS512. (HS[number] means HMAC using SHA-[number]) - * - * @defaultValue `"HS256"` - * @see {@link https://www.meilisearch.com/docs/learn/security/generate_tenant_token_scratch#prepare-token-header} - */ - algorithm?: `HS${256 | 384 | 512}`; - /** - * By default if a non-safe environment is detected, an error is thrown. - * Setting this to `true` skips environment detection. This is intended for - * server-side environments where detection fails or usage in a browser is - * intentional (Use at your own risk). - * - * @defaultValue `false` - */ - force?: boolean; -}; - -/** - * @see {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference | Tenant token payload reference} - * @see {@link https://github.com/meilisearch/meilisearch/blob/b21d7aedf9096539041362d438e973a18170f3fc/crates/meilisearch/src/extractors/authentication/mod.rs#L334-L340 | GitHub source code} - */ -export type TokenClaims = { - searchRules: TokenSearchRules; - exp?: number; - apiKeyUid: string; -}; - -/** JSON Web Token header. */ -export type TenantTokenHeader = { - alg: NonNullable; - typ: "JWT"; -}; diff --git a/src/utils.ts b/src/utils.ts index e748e1f10..8b5bee804 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -16,4 +16,23 @@ function addTrailingSlash(url: string): string { return url; } -export { sleep, addProtocolIfNotPresent, addTrailingSlash }; +function stringifyRecordKeyValues< + T extends Record, + const U extends (keyof T)[], +>(v: T, keys: U) { + return Object.fromEntries( + Object.entries(v).map(([key, val]) => [ + key, + keys.includes(key) ? JSON.stringify(val) : val, + ]), + ) as { [TKey in Exclude]: T[TKey] } & { + [TKey in U[number]]: string; + }; +} + +export { + sleep, + addProtocolIfNotPresent, + addTrailingSlash, + stringifyRecordKeyValues, +}; From 2629d5f01e4b6c8cdf9320532c297037ecfe5fef Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Sat, 5 Apr 2025 14:50:34 +0300 Subject: [PATCH 3/8] Progress --- src/indexes.ts | 15 +++--- src/meilisearch.ts | 92 ++++++++++------------------------ src/types/functions.ts | 31 ++++++++++++ src/types/index.ts | 3 +- src/types/keys.ts | 0 src/types/resources.ts | 11 ---- src/types/search-parameters.ts | 6 ++- src/types/search-response.ts | 4 ++ 8 files changed, 76 insertions(+), 86 deletions(-) create mode 100644 src/types/functions.ts create mode 100644 src/types/keys.ts delete mode 100644 src/types/resources.ts diff --git a/src/indexes.ts b/src/indexes.ts index b3cfbc613..d311627bf 100644 --- a/src/indexes.ts +++ b/src/indexes.ts @@ -25,7 +25,6 @@ import type { TypoTolerance, PaginationSettings, Faceting, - ResourceResults, RawDocumentAdditionOptions, ContentType, DocumentsIds, @@ -42,12 +41,12 @@ import type { PrefixSearch, RecordAny, SearchQuery, - SearchResult, FacetSearchQuery, FacetSearchResult, SimilarQuery, SimilarResult, EnqueuedTaskPromise, + ConditionalSearchResult, } from "./types/index.js"; import { HttpRequests } from "./http-requests.js"; import { @@ -87,10 +86,10 @@ export class Index { /// /** {@link https://www.meilisearch.com/docs/reference/api/search} */ - async search( - searchQuery: SearchQuery, + async search( + searchQuery: U, init?: ExtraRequestInit, - ): Promise> { + ): Promise> { return await this.httpRequest.post({ path: `indexes/${this.uid}/search`, body: searchQuery, @@ -99,10 +98,10 @@ export class Index { } /** {@link https://www.meilisearch.com/docs/reference/api/search#search-in-an-index-with-get} */ - async searchGet( - searchQuery: SearchQuery, + async searchGet( + searchQuery: U, init?: ExtraRequestInit, - ): Promise> { + ): Promise> { return await this.httpRequest.get({ path: `indexes/${this.uid}/search`, params: stringifyRecordKeyValues(searchQuery, ["filter", "hybrid"]), diff --git a/src/meilisearch.ts b/src/meilisearch.ts index 25f1c4e8d..399225785 100644 --- a/src/meilisearch.ts +++ b/src/meilisearch.ts @@ -7,7 +7,6 @@ import { Index } from "./indexes.js"; import type { - KeyCreation, Config, IndexOptions, IndexObject, @@ -15,19 +14,19 @@ import type { Health, Stats, Version, - KeyUpdate, IndexesQuery, IndexesResults, - KeysQuery, - KeysResults, IndexSwap, - MultiSearchParams, - FederatedMultiSearchParams, - MultiSearchResponseOrSearchResponse, EnqueuedTaskPromise, ExtraRequestInit, Network, RecordAny, + MultiSearchOrFederatedSearch, + SearchResultsOrFederatedSearchResult, + KeysQuery, + KeysResults, + KeyCreation, + KeyUpdate, } from "./types/index.js"; import { ErrorStatusCode } from "./types/index.js"; import { HttpRequests } from "./http-requests.js"; @@ -38,6 +37,7 @@ import { } from "./task.js"; import { BatchClient } from "./batch.js"; import type { MeiliSearchApiError } from "./errors/index.js"; +import type { FederatedSearchFn, MultiSearchFn } from "./types/functions.js"; export class MeiliSearch { config: Config; @@ -213,66 +213,28 @@ export class MeiliSearch { /// Multi Search /// - /** - * Perform multiple search queries. - * - * It is possible to make multiple search queries on the same index or on - * different ones. With network feature enabled, you can also search across - * remote instances. - * - * @example - * - * ```ts - * client.multiSearch({ - * queries: [ - * { indexUid: "movies", q: "wonder" }, - * { indexUid: "books", q: "flower" }, - * ], - * }); - * - * // Federated search with remote instance (requires network feature enabled) - * client.multiSearch({ - * federation: {}, - * queries: [ - * { - * indexUid: "movies", - * q: "wonder", - * federationOptions: { - * remote: "meilisearch instance name", - * }, - * }, - * { - * indexUid: "movies", - * q: "wonder", - * federationOptions: { - * remote: "meilisearch instance name", - * }, - * }, - * ], - * }); - * ``` - * - * @param queries - Search queries - * @param extraRequestInit - Additional request configuration options - * @returns Promise containing the search responses - * @see {@link https://www.meilisearch.com/docs/learn/multi_search/implement_sharding#perform-a-search} - */ - async multiSearch< - T1 extends MultiSearchParams | FederatedMultiSearchParams, - T2 extends RecordAny = RecordAny, - >( - queries: T1, - extraRequestInit?: ExtraRequestInit, - ): Promise> { - return await this.httpRequest.post< - MultiSearchResponseOrSearchResponse - >({ - path: "multi-search", - body: queries, - extraRequestInit, - }); + async #multiSearch( + queries: MultiSearchOrFederatedSearch, + init?: ExtraRequestInit, + ) { + return await this.httpRequest.post>( + { + path: "multi-search", + body: queries, + extraRequestInit: init, + }, + ); } + // TODO: There's still the problem of generic T not accepting multiple indexes + // TODO: Could make this more generic, by mapping query parameters to responses, + // but since we have a required generic as well, it's not possible + /** {@link https://www.meilisearch.com/docs/reference/api/multi_search} */ + readonly multiSearch = this.#multiSearch.bind(this) as MultiSearchFn; + + /** {@link https://www.meilisearch.com/docs/reference/api/multi_search} */ + readonly federatedSearch = this.#multiSearch.bind(this) as FederatedSearchFn; + /// /// Network /// diff --git a/src/types/functions.ts b/src/types/functions.ts new file mode 100644 index 000000000..65c630fde --- /dev/null +++ b/src/types/functions.ts @@ -0,0 +1,31 @@ +import type { ExtraRequestInit } from "./http-requests.js"; +import type { + FederatedSearch, + MultiSearch, + Pagination, + SearchQuery, +} from "./search-parameters.js"; +import type { + FederatedSearchResult, + SearchResults, + SearchResultWithOffsetLimit, + SearchResultWithPagination, +} from "./search-response.js"; +import type { RecordAny } from "./shared.js"; + +export type MultiSearchFn = ( + queries: MultiSearch, + init?: ExtraRequestInit, +) => Promise>; + +export type FederatedSearchFn = ( + queries: FederatedSearch, + init?: ExtraRequestInit, +) => Promise>; + +export type ConditionalSearchResult< + T extends SearchQuery, + U extends RecordAny = RecordAny, +> = T extends Pagination + ? SearchResultWithPagination + : SearchResultWithOffsetLimit; diff --git a/src/types/index.ts b/src/types/index.ts index 4e9032414..52b2ab72b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,9 +1,10 @@ export * from "./http-requests.js"; export * from "./network.js"; -export * from "./resources.js"; export * from "./search-parameters.js"; export * from "./search-response.js"; export * from "./shared.js"; export * from "./task-and-batch.js"; export * from "./token.js"; export * from "./types.js"; +export * from "./functions.js"; +export * from "./keys.js"; diff --git a/src/types/keys.ts b/src/types/keys.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/types/resources.ts b/src/types/resources.ts deleted file mode 100644 index 17a71afc4..000000000 --- a/src/types/resources.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type Pagination = { - offset?: number; - limit?: number; -}; - -export type ResourceQuery = Pagination & {}; - -export type ResourceResults = Pagination & { - results: T; - total: number; -}; diff --git a/src/types/search-parameters.ts b/src/types/search-parameters.ts index 5ccfe16e0..892301cf4 100644 --- a/src/types/search-parameters.ts +++ b/src/types/search-parameters.ts @@ -32,7 +32,7 @@ type OffsetLimit = { limit?: number | null; }; -type Pagination = { +export type Pagination = { /** {@link https://www.meilisearch.com/docs/reference/api/search#page} */ page?: number | null; /** {@link https://www.meilisearch.com/docs/reference/api/search#number-of-results-per-page} */ @@ -111,6 +111,8 @@ export type SearchQueryWithRequiredPagination = RequiredKeys< "page" >; +export type RequiredPagination = RequiredKeys; + /** @see `meilisearch::search::SearchQuery` */ export type SearchQuery = | SearchQueryWithOffsetLimit @@ -153,6 +155,8 @@ export type FederatedSearch = { /** @see `meilisearch::search::federated::types::FederatedSearch` */ export type MultiSearch = { queries: SearchQueryWithIndex[] }; +export type MultiSearchOrFederatedSearch = MultiSearch | FederatedSearch; + /** * {@link https://www.meilisearch.com/docs/reference/api/facet_search#body} * diff --git a/src/types/search-response.ts b/src/types/search-response.ts index 1e081cf62..203c9dc35 100644 --- a/src/types/search-response.ts +++ b/src/types/search-response.ts @@ -94,6 +94,10 @@ export type SearchResults = { results: SearchResultWithIndex[]; }; +export type SearchResultsOrFederatedSearchResult< + T extends RecordAny = RecordAny, +> = SearchResults | FederatedSearchResult; + /** @see `milli::search::facet::search::FacetValueHit` */ export type FacetValueHit = { value: string; count: number }; From 464413698b4920fe39d3e290f908c79095be2055 Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:55:06 +0300 Subject: [PATCH 4/8] Progress --- playgrounds/javascript/src/meilisearch.ts | 3 +- src/indexes.ts | 5 +-- src/meilisearch.ts | 39 ++++++++++++----------- src/types/functions.ts | 20 +----------- src/types/index.ts | 1 - src/types/keys.ts | 0 src/types/search-parameters.ts | 14 +++++--- src/types/search-response.ts | 23 ++++++++----- src/types/shared.ts | 10 ++---- src/types/types.ts | 34 ++++++++++++++++++-- tests/embedders.test.ts | 6 ++-- tests/env/typescript-node/src/index.ts | 25 +++++---------- tests/search.test.ts | 30 +++-------------- tests/token.test.ts | 4 +-- 14 files changed, 101 insertions(+), 113 deletions(-) delete mode 100644 src/types/keys.ts diff --git a/playgrounds/javascript/src/meilisearch.ts b/playgrounds/javascript/src/meilisearch.ts index 0f1818486..84a1577b4 100644 --- a/playgrounds/javascript/src/meilisearch.ts +++ b/playgrounds/javascript/src/meilisearch.ts @@ -38,8 +38,7 @@ export async function getAllHits(element: HTMLDivElement): Promise { export async function getSearchResponse(element: HTMLDivElement) { const params: Parameters = [ - "philoudelphia", - { attributesToHighlight: ["title"] }, + { q: "philoudelphia", attributesToHighlight: ["title"] }, ]; const response = await client.index(indexUid).search(...params); diff --git a/src/indexes.ts b/src/indexes.ts index d311627bf..0e90993dc 100644 --- a/src/indexes.ts +++ b/src/indexes.ts @@ -47,6 +47,7 @@ import type { SimilarResult, EnqueuedTaskPromise, ConditionalSearchResult, + ResourceResults, } from "./types/index.js"; import { HttpRequests } from "./http-requests.js"; import { @@ -87,7 +88,7 @@ export class Index { /** {@link https://www.meilisearch.com/docs/reference/api/search} */ async search( - searchQuery: U, + searchQuery?: U, init?: ExtraRequestInit, ): Promise> { return await this.httpRequest.post({ @@ -271,7 +272,7 @@ export class Index { : // Else use `GET /documents` method await this.httpRequest.get>({ path: relativeBaseURL, - params, + params: params as Omit, }); } diff --git a/src/meilisearch.ts b/src/meilisearch.ts index 399225785..95150f923 100644 --- a/src/meilisearch.ts +++ b/src/meilisearch.ts @@ -27,6 +27,10 @@ import type { KeysResults, KeyCreation, KeyUpdate, + FederatedSearchResult, + FederatedSearch, + MultiSearch, + SearchResults, } from "./types/index.js"; import { ErrorStatusCode } from "./types/index.js"; import { HttpRequests } from "./http-requests.js"; @@ -37,7 +41,6 @@ import { } from "./task.js"; import { BatchClient } from "./batch.js"; import type { MeiliSearchApiError } from "./errors/index.js"; -import type { FederatedSearchFn, MultiSearchFn } from "./types/functions.js"; export class MeiliSearch { config: Config; @@ -213,28 +216,26 @@ export class MeiliSearch { /// Multi Search /// - async #multiSearch( + /** {@link https://www.meilisearch.com/docs/reference/api/multi_search} */ + multiSearch( + queries: MultiSearch, + init?: ExtraRequestInit, + ): Promise; + multiSearch( + queries: FederatedSearch, + init?: ExtraRequestInit, + ): Promise; + async multiSearch( queries: MultiSearchOrFederatedSearch, init?: ExtraRequestInit, - ) { - return await this.httpRequest.post>( - { - path: "multi-search", - body: queries, - extraRequestInit: init, - }, - ); + ): Promise { + return await this.httpRequest.post({ + path: "multi-search", + body: queries, + extraRequestInit: init, + }); } - // TODO: There's still the problem of generic T not accepting multiple indexes - // TODO: Could make this more generic, by mapping query parameters to responses, - // but since we have a required generic as well, it's not possible - /** {@link https://www.meilisearch.com/docs/reference/api/multi_search} */ - readonly multiSearch = this.#multiSearch.bind(this) as MultiSearchFn; - - /** {@link https://www.meilisearch.com/docs/reference/api/multi_search} */ - readonly federatedSearch = this.#multiSearch.bind(this) as FederatedSearchFn; - /// /// Network /// diff --git a/src/types/functions.ts b/src/types/functions.ts index 65c630fde..b801e4df3 100644 --- a/src/types/functions.ts +++ b/src/types/functions.ts @@ -1,28 +1,10 @@ -import type { ExtraRequestInit } from "./http-requests.js"; +import type { Pagination, SearchQuery } from "./search-parameters.js"; import type { - FederatedSearch, - MultiSearch, - Pagination, - SearchQuery, -} from "./search-parameters.js"; -import type { - FederatedSearchResult, - SearchResults, SearchResultWithOffsetLimit, SearchResultWithPagination, } from "./search-response.js"; import type { RecordAny } from "./shared.js"; -export type MultiSearchFn = ( - queries: MultiSearch, - init?: ExtraRequestInit, -) => Promise>; - -export type FederatedSearchFn = ( - queries: FederatedSearch, - init?: ExtraRequestInit, -) => Promise>; - export type ConditionalSearchResult< T extends SearchQuery, U extends RecordAny = RecordAny, diff --git a/src/types/index.ts b/src/types/index.ts index 52b2ab72b..d8c7ecf1c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,4 +7,3 @@ export * from "./task-and-batch.js"; export * from "./token.js"; export * from "./types.js"; export * from "./functions.js"; -export * from "./keys.js"; diff --git a/src/types/keys.ts b/src/types/keys.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/types/search-parameters.ts b/src/types/search-parameters.ts index 892301cf4..c83f93255 100644 --- a/src/types/search-parameters.ts +++ b/src/types/search-parameters.ts @@ -1,4 +1,5 @@ -import type { NonNullKeys, RequiredKeys } from "./shared.js"; +import type { NonNullKeys, PascalToCamelCase, RequiredKeys } from "./shared.js"; +import type { Locale } from "./types.js"; /** @see `meilisearch::search::HybridQuery` */ export type HybridQuery = { @@ -11,7 +12,7 @@ export type HybridQuery = { * * @see `meilisearch::search::MatchingStrategy` */ -export type MatchingStrategy = "last" | "all" | "frequency"; +export type MatchingStrategy = PascalToCamelCase<"Last" | "All" | "Frequency">; /** * {@link https://www.meilisearch.com/docs/reference/api/multi_search#federationoptions} @@ -39,9 +40,12 @@ export type Pagination = { hitsPerPage?: number | null; }; +/** {@link https://www.meilisearch.com/docs/learn/filtering_and_sorting/filter_expression_reference} */ +export type FilterExpression = string | (string | string[])[]; + type FirstPartOfFacetAndSearchQuerySegment = { /** {@link https://www.meilisearch.com/docs/reference/api/search#filter} */ - filter?: string | (string | string[])[] | null; + filter?: FilterExpression | null; /** {@link https://www.meilisearch.com/docs/reference/api/search#ranking-score-threshold} */ rankingScoreThreshold?: number | null; }; @@ -58,7 +62,7 @@ type FacetAndSearchQuerySegment = { /** {@link https://www.meilisearch.com/docs/reference/api/search#customize-attributes-to-search-on-at-search-time} */ attributesToSearchOn?: string[] | null; /** {@link https://www.meilisearch.com/docs/reference/api/search#query-locales} */ - locales?: string[] | null; + locales?: Locale[] | null; } & FirstPartOfFacetAndSearchQuerySegment; type FirstPartOfSearchQueryCore = { @@ -111,7 +115,7 @@ export type SearchQueryWithRequiredPagination = RequiredKeys< "page" >; -export type RequiredPagination = RequiredKeys; +// export type RequiredPagination = RequiredKeys; /** @see `meilisearch::search::SearchQuery` */ export type SearchQuery = diff --git a/src/types/search-response.ts b/src/types/search-response.ts index 203c9dc35..96feb5182 100644 --- a/src/types/search-response.ts +++ b/src/types/search-response.ts @@ -1,4 +1,5 @@ import type { RecordAny } from "./shared.js"; +import type { FieldDistribution } from "./types.js"; /** @see `milli::search::new::matches::MatchBounds` */ export type MatchBounds = { start: number; length: number; indices: number[] }; @@ -14,6 +15,11 @@ export type SearchHit = T & { _rankingScoreDetails?: RecordAny; }; +/** + * @remarks + * This is an untyped structure in the source code. + * @see `meilisearch::search::federated::perform::SearchByIndex::execute` + */ export type FederationDetails = { indexUid: string; queriesPosition: number; @@ -48,7 +54,7 @@ type ProcessingTime = { processingTimeMs: number }; type SearchResultCore = { query: string; - facetDistribution?: Record>; + facetDistribution?: Record; facetStats?: Record; semanticHitCount?: number; } & ProcessingTime; @@ -68,8 +74,9 @@ export type SearchResult = * * @see `meilisearch::search::federated::types::FederatedSearchResult` */ -export type FederatedSearchResult = - SearchResultCore & { hits: FederatedSearchHit[] } & OffsetLimit; +export type FederatedSearchResult = SearchResultCore & { + hits: FederatedSearchHit>[]; +} & OffsetLimit; type SearchResultIndex = { indexUid: string }; @@ -90,13 +97,13 @@ export type SearchResultWithIndex = * * @see `meilisearch::routes::multi_search::SearchResults` */ -export type SearchResults = { - results: SearchResultWithIndex[]; +export type SearchResults = { + results: SearchResultWithIndex>[]; }; -export type SearchResultsOrFederatedSearchResult< - T extends RecordAny = RecordAny, -> = SearchResults | FederatedSearchResult; +export type SearchResultsOrFederatedSearchResult = + | SearchResults + | FederatedSearchResult; /** @see `milli::search::facet::search::FacetValueHit` */ export type FacetValueHit = { value: string; count: number }; diff --git a/src/types/shared.ts b/src/types/shared.ts index 49195921e..2565e0fb7 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -17,11 +17,5 @@ export type CursorResults = { total: number; }; -export type NonNullableDeepRecordValues = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [P in keyof T]: T[P] extends any[] - ? Array> - : T[P] extends RecordAny - ? NonNullableDeepRecordValues - : NonNullable; -}; +// taken from https://stackoverflow.com/a/65642944 +export type PascalToCamelCase = Uncapitalize; diff --git a/src/types/types.ts b/src/types/types.ts index 33cccaff9..a4bd38ac4 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -4,9 +4,25 @@ // Definitions: https://github.com/meilisearch/meilisearch-js // TypeScript Version: ^5.8.2 -import type { ResourceQuery, ResourceResults } from "./resources.js"; +import type { FilterExpression } from "./search-parameters.js"; import type { RecordAny } from "./shared.js"; +/// +/// Resources +/// + +type Pagination = { + offset?: number; + limit?: number; +}; + +export type ResourceQuery = Pagination & {}; + +export type ResourceResults = Pagination & { + results: T; + total: number; +}; + /// /// Indexes /// @@ -55,7 +71,7 @@ export type RawDocumentAdditionOptions = DocumentOptions & { export type DocumentsQuery = ResourceQuery & { fields?: Fields; - filter?: Filter; + filter?: FilterExpression; limit?: number; offset?: number; retrieveVectors?: boolean; @@ -66,7 +82,7 @@ export type DocumentQuery = { }; export type DocumentsDeletionQuery = { - filter: Filter; + filter: FilterExpression; }; export type DocumentsIds = string[] | number[]; @@ -186,6 +202,13 @@ export type PaginationSettings = { export type SearchCutoffMs = number | null; +/** + * {@link https://www.meilisearch.com/docs/reference/api/settings#locales} + * + * @see `meilisearch_types::locales::Locale` + */ +export type Locale = string; + export type LocalizedAttribute = { attributePatterns: string[]; locales: Locale[]; @@ -227,6 +250,11 @@ export type Settings = { prefixSearch?: "indexingTime" | "disabled"; }; +/** + * @see `fieldDistribution` at {@link https://www.meilisearch.com/docs/reference/api/stats#stats-object} + * @see `milli::FieldDistribution` */ +export type FieldDistribution = Record; + /* *** HEALTH */ diff --git a/tests/embedders.test.ts b/tests/embedders.test.ts index b41c60eb2..78202438e 100644 --- a/tests/embedders.test.ts +++ b/tests/embedders.test.ts @@ -257,7 +257,8 @@ describe.each([{ permission: "Master" }, { permission: "Admin" }])( }) .waitTask(); - const response = await client.index(index.uid).search("", { + const response = await client.index(index.uid).search({ + q: "", vector: [1], hybrid: { embedder: "default", @@ -286,7 +287,8 @@ describe.each([{ permission: "Master" }, { permission: "Admin" }])( }) .waitTask(); - const response = await client.index(index.uid).searchGet("", { + const response = await client.index(index.uid).searchGet({ + q: "", vector: [1], hybridEmbedder: "default", hybridSemanticRatio: 1.0, diff --git a/tests/env/typescript-node/src/index.ts b/tests/env/typescript-node/src/index.ts index 495723c13..4b965da8a 100644 --- a/tests/env/typescript-node/src/index.ts +++ b/tests/env/typescript-node/src/index.ts @@ -1,13 +1,6 @@ import { MeiliSearch, } from '../../../../src/index.js' -import type { - IndexObject, - SearchResponse, - Hits, - Hit, - SearchParams, -} from '../../../../src/index.js' import { generateTenantToken } from '../../../../src/token.js' const config = { @@ -31,29 +24,27 @@ const indexUid = "movies" await client.deleteIndex(indexUid).waitTask() await client.createIndex(indexUid).waitTask() - const index = client.index(indexUid) + const index = client.index(indexUid) const indexes = await client.getRawIndexes() - indexes.results.map((index: IndexObject) => { + indexes.results.map((index) => { console.log(index.uid) // console.log(index.something) -> ERROR }) - const searchParams: SearchParams = { + const searchParams = { + q: 'avenger', limit: 5, attributesToRetrieve: ['title', 'genre'], attributesToHighlight: ['title'], // test: true -> ERROR Test does not exist on type SearchParams } - indexes.results.map((index: IndexObject) => index.uid) - const res: SearchResponse = await index.search( - 'avenger', - searchParams - ) + indexes.results.map((index) => index.uid) + const res = await index.search(searchParams) // both work - const { hits }: { hits: Hits } = res + const { hits } = res - hits.map((hit: Hit) => { + hits.map((hit) => { console.log(hit?.genre) console.log(hit.title) // console.log(hit._formatted.title) -> ERROR, _formatted could be undefined diff --git a/tests/search.test.ts b/tests/search.test.ts index 76611c04a..14db45ecc 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -7,11 +7,7 @@ import { beforeAll, vi, } from "vitest"; -import { ErrorStatusCode, MatchingStrategies } from "../src/types/index.js"; -import type { - FederatedMultiSearchParams, - MultiSearchParams, -} from "../src/types/index.js"; +import { ErrorStatusCode, type MatchingStrategy } from "../src/index.js"; import { clearAllIndexes, config, @@ -174,14 +170,7 @@ describe.each([ test(`${permission} key: Multi index search with one query`, async () => { const client = await getClient(permission); - type MyIndex = { - id: 1; - }; - - const response = await client.multiSearch< - MultiSearchParams, - MyIndex & Books - >({ + const response = await client.multiSearch({ queries: [{ indexUid: index.uid, q: "prince" }], }); @@ -193,10 +182,7 @@ describe.each([ test(`${permission} key: Multi index search with federation`, async () => { const client = await getClient(permission); - const response1 = await client.multiSearch< - FederatedMultiSearchParams, - Books | { id: number; asd: string } - >({ + const response1 = await client.multiSearch({ federation: {}, queries: [ { indexUid: index.uid, q: "456", attributesToSearchOn: ["id"] }, @@ -259,10 +245,7 @@ describe.each([ const searchClient = await getClient(permission); - const response = await searchClient.multiSearch< - FederatedMultiSearchParams, - Books | { id: number; asd: string } - >({ + const response = await searchClient.multiSearch({ federation: {}, queries: [ { @@ -304,10 +287,7 @@ describe.each([ await masterClient.index("movies").addDocuments(movies).waitTask(); // Make a multi search on both indexes with facetsByIndex - const response = await client.multiSearch< - FederatedMultiSearchParams, - Books | Movies - >({ + const response = await client.multiSearch({ federation: { limit: 20, offset: 0, diff --git a/tests/token.test.ts b/tests/token.test.ts index 349c95037..bd813985a 100644 --- a/tests/token.test.ts +++ b/tests/token.test.ts @@ -332,7 +332,7 @@ describe.each([{ permission: "Admin" }])( // search await expect( - searchClient.index(UID).search("pride"), + searchClient.index(UID).search({ q: "pride" }), ).rejects.toHaveProperty("cause.code", "invalid_api_key"); }); @@ -350,7 +350,7 @@ describe.each([{ permission: "Admin" }])( // search await expect( - searchClient.index(UID).search("pride"), + searchClient.index(UID).search({ q: "pride" }), ).rejects.toHaveProperty("cause.code", "invalid_api_key"); }); From 44bd9098da0c7d463160ea94f0c021c89c1f7ea8 Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Fri, 18 Apr 2025 15:06:21 +0300 Subject: [PATCH 5/8] Progress --- eslint.config.js | 6 + src/indexes.ts | 41 +- src/meilisearch.ts | 49 +- src/types/experimental-features.ts | 14 + src/types/index.ts | 1 + src/types/search-parameters.ts | 59 +- src/types/search-response.ts | 48 +- src/types/shared.ts | 12 +- src/utils.ts | 6 +- tests/__snapshots__/search.test.ts.snap | 100 -- tests/embedders.test.ts | 119 -- tests/facet_search.test.ts | 105 -- tests/get_search.test.ts | 580 ------- tests/search.test.ts | 1862 +++++------------------ tests/typed_search.test.ts | 511 ------- tests/utils/meilisearch-test-utils.ts | 8 + tests/utils/search.ts | 147 ++ tests/utils/test-data/films.ts | 887 +++++++++++ 18 files changed, 1660 insertions(+), 2895 deletions(-) create mode 100644 src/types/experimental-features.ts delete mode 100644 tests/__snapshots__/search.test.ts.snap delete mode 100644 tests/facet_search.test.ts delete mode 100644 tests/get_search.test.ts delete mode 100644 tests/typed_search.test.ts create mode 100644 tests/utils/search.ts create mode 100644 tests/utils/test-data/films.ts diff --git a/eslint.config.js b/eslint.config.js index 16cbcb8ec..9be8b70e6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,6 +41,12 @@ export default tseslint.config([ { files: ["tests/**/*.test.ts"], extends: [vitest.configs.recommended], + rules: { + "vitest/expect-expect": [ + "error", + { assertFunctionNames: ["t.expect", "expect", "assert"] }, + ], + }, }, // Disable any style linting, as prettier takes care of that separately prettier, diff --git a/src/indexes.ts b/src/indexes.ts index 0e90993dc..2030dc45a 100644 --- a/src/indexes.ts +++ b/src/indexes.ts @@ -46,8 +46,15 @@ import type { SimilarQuery, SimilarResult, EnqueuedTaskPromise, - ConditionalSearchResult, ResourceResults, + SearchQueryWithOffsetLimit, + SearchResultWithOffsetLimit, + SearchQueryWithRequiredPagination, + SearchResultWithPagination, + SearchResult, + SearchQueryGet, + SearchQueryWithRequiredPaginationGet, + SearchQueryWithOffsetLimitGet, } from "./types/index.js"; import { HttpRequests } from "./http-requests.js"; import { @@ -86,11 +93,21 @@ export class Index { /// SEARCH /// + // TODO: If no params are provided, it's set to pagination | offset limit + // If empty object param is provided it's set to pagination /** {@link https://www.meilisearch.com/docs/reference/api/search} */ - async search( - searchQuery?: U, + search( + searchQuery?: SearchQueryWithOffsetLimit, init?: ExtraRequestInit, - ): Promise> { + ): Promise>; + search( + searchQuery?: SearchQueryWithRequiredPagination, + init?: ExtraRequestInit, + ): Promise>; + async search( + searchQuery?: SearchQuery, + init?: ExtraRequestInit, + ): Promise> { return await this.httpRequest.post({ path: `indexes/${this.uid}/search`, body: searchQuery, @@ -99,13 +116,21 @@ export class Index { } /** {@link https://www.meilisearch.com/docs/reference/api/search#search-in-an-index-with-get} */ - async searchGet( - searchQuery: U, + searchGet( + searchQuery?: SearchQueryWithOffsetLimitGet, + init?: ExtraRequestInit, + ): Promise>; + searchGet( + searchQuery?: SearchQueryWithRequiredPaginationGet, + init?: ExtraRequestInit, + ): Promise>; + async searchGet( + searchQuery?: SearchQueryGet, init?: ExtraRequestInit, - ): Promise> { + ): Promise> { return await this.httpRequest.get({ path: `indexes/${this.uid}/search`, - params: stringifyRecordKeyValues(searchQuery, ["filter", "hybrid"]), + params: stringifyRecordKeyValues(searchQuery, ["filter"]), extraRequestInit: init, }); } diff --git a/src/meilisearch.ts b/src/meilisearch.ts index 95150f923..91c1b5cf1 100644 --- a/src/meilisearch.ts +++ b/src/meilisearch.ts @@ -31,6 +31,7 @@ import type { FederatedSearch, MultiSearch, SearchResults, + RuntimeTogglableFeatures, } from "./types/index.js"; import { ErrorStatusCode } from "./types/index.js"; import { HttpRequests } from "./http-requests.js"; @@ -216,26 +217,29 @@ export class MeiliSearch { /// Multi Search /// - /** {@link https://www.meilisearch.com/docs/reference/api/multi_search} */ - multiSearch( - queries: MultiSearch, - init?: ExtraRequestInit, - ): Promise; - multiSearch( - queries: FederatedSearch, - init?: ExtraRequestInit, - ): Promise; - async multiSearch( - queries: MultiSearchOrFederatedSearch, + async #multiSearch( + body: MultiSearchOrFederatedSearch, init?: ExtraRequestInit, ): Promise { return await this.httpRequest.post({ path: "multi-search", - body: queries, + body, extraRequestInit: init, }); } + /** {@link https://www.meilisearch.com/docs/reference/api/multi_search} */ + readonly multiSearch = this.#multiSearch.bind(this) as ( + multiSearch: MultiSearch, + init?: ExtraRequestInit, + ) => Promise; + + /** {@link https://www.meilisearch.com/docs/reference/api/multi_search} */ + readonly federatedMultiSearch = this.#multiSearch.bind(this) as ( + federatedSearch: FederatedSearch, + init?: ExtraRequestInit, + ) => Promise; + /// /// Network /// @@ -417,4 +421,25 @@ export class MeiliSearch { path: "snapshots", }); } + + /// + /// EXPERIMENTAL-FEATURES + /// + + /** {@link https://www.meilisearch.com/docs/reference/api/experimental_features#get-all-experimental-features} */ + async getExperimentalFeatures(): Promise { + return await this.httpRequest.get({ + path: "experimental-features", + }); + } + + /** {@link https://www.meilisearch.com/docs/reference/api/experimental_features#configure-experimental-features} */ + async updateExperimentalFeatures( + runtimeTogglableFeatures: RuntimeTogglableFeatures, + ): Promise { + return await this.httpRequest.patch({ + path: "experimental-features", + body: runtimeTogglableFeatures, + }); + } } diff --git a/src/types/experimental-features.ts b/src/types/experimental-features.ts new file mode 100644 index 000000000..c81720d16 --- /dev/null +++ b/src/types/experimental-features.ts @@ -0,0 +1,14 @@ +/** + * {@link https://www.meilisearch.com/docs/reference/api/experimental_features#experimental-features-object} + * + * @see `meilisearch::routes::features::RuntimeTogglableFeatures` + */ +export type RuntimeTogglableFeatures = { + metrics?: boolean | null; + logsRoute?: boolean | null; + editDocumentsByFunction?: boolean | null; + containsFilter?: boolean | null; + network?: boolean | null; + getTaskDocumentsRoute?: boolean | null; + compositeEmbedders?: boolean | null; +}; diff --git a/src/types/index.ts b/src/types/index.ts index d8c7ecf1c..d263ac6bf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ +export * from "./experimental-features.js"; export * from "./http-requests.js"; export * from "./network.js"; export * from "./search-parameters.js"; diff --git a/src/types/search-parameters.ts b/src/types/search-parameters.ts index c83f93255..73d01cc55 100644 --- a/src/types/search-parameters.ts +++ b/src/types/search-parameters.ts @@ -1,4 +1,9 @@ -import type { NonNullKeys, PascalToCamelCase, RequiredKeys } from "./shared.js"; +import type { + NonNullKeys, + PascalToCamelCase, + RequiredKeys, + SafeOmit, +} from "./shared.js"; import type { Locale } from "./types.js"; /** @see `meilisearch::search::HybridQuery` */ @@ -23,6 +28,10 @@ export type FederationOptions = { weight?: number; /** @experimental */ remote?: string | null; + /** + * @privateRemarks + * Undocumented. + */ queryPosition?: number | null; }; @@ -40,7 +49,13 @@ export type Pagination = { hitsPerPage?: number | null; }; -/** {@link https://www.meilisearch.com/docs/learn/filtering_and_sorting/filter_expression_reference} */ +/** + * {@link https://www.meilisearch.com/docs/learn/filtering_and_sorting/filter_expression_reference} + * + * @privateRemarks + * It is not strongly typed in any way in the source code. + * @see `milli::search::facet::filter::Filter::from_json` + */ export type FilterExpression = string | (string | string[])[]; type FirstPartOfFacetAndSearchQuerySegment = { @@ -59,6 +74,8 @@ type FacetAndSearchQuerySegment = { hybrid?: HybridQuery | null; /** {@link https://www.meilisearch.com/docs/reference/api/search#matching-strategy} */ matchingStrategy?: MatchingStrategy; + // TODO: Could use generic record to type these kinds of settings, but is it worth it? + // https://stackoverflow.com/a/76547796 /** {@link https://www.meilisearch.com/docs/reference/api/search#customize-attributes-to-search-on-at-search-time} */ attributesToSearchOn?: string[] | null; /** {@link https://www.meilisearch.com/docs/reference/api/search#query-locales} */ @@ -100,8 +117,17 @@ type SearchQueryCore = { } & FacetAndSearchQuerySegment & FirstPartOfSearchQueryCore; +type IntoGet = SafeOmit & { + [TKey in keyof HybridQuery as `hybrid${Capitalize}`]?: + | HybridQuery[TKey] + | null; +}; + export type SearchQueryWithOffsetLimit = SearchQueryCore & OffsetLimit; +export type SearchQueryWithOffsetLimitGet = IntoGet; + type SearchQueryWithPagination = SearchQueryCore & Pagination; +export type SearchQueryWithPaginationGet = IntoGet; /** * @remarks @@ -114,14 +140,19 @@ export type SearchQueryWithRequiredPagination = RequiredKeys< SearchQueryWithPagination, "page" >; - -// export type RequiredPagination = RequiredKeys; +export type SearchQueryWithRequiredPaginationGet = + IntoGet; /** @see `meilisearch::search::SearchQuery` */ export type SearchQuery = | SearchQueryWithOffsetLimit | SearchQueryWithPagination; +/** @see `meilisearch::routes::indexes::search::SearchQueryGet` */ +export type SearchQueryGet = + | SearchQueryWithOffsetLimitGet + | SearchQueryWithPaginationGet; + type SearchQueryWithIndexCore = SearchQueryCore & { indexUid: string }; export type SearchQueryWithIndexAndFederation = { @@ -150,15 +181,24 @@ export type Federation = { mergeFacets?: MergeFacets | null; }; -/** @see `meilisearch::search::federated::types::FederatedSearch` */ +/** + * {@link https://www.meilisearch.com/docs/reference/api/multi_search#body} + * + * @see `meilisearch::search::federated::types::FederatedSearch` + */ export type FederatedSearch = { queries: SearchQueryWithIndexAndFederation[]; - federation?: Federation | null; + federation: Federation; }; -/** @see `meilisearch::search::federated::types::FederatedSearch` */ +/** + * {@link https://www.meilisearch.com/docs/reference/api/multi_search#body} + * + * @see `meilisearch::search::federated::types::FederatedSearch` + */ export type MultiSearch = { queries: SearchQueryWithIndex[] }; +/** {@link https://www.meilisearch.com/docs/reference/api/multi_search#body} */ export type MultiSearchOrFederatedSearch = MultiSearch | FederatedSearch; /** @@ -169,10 +209,6 @@ export type MultiSearchOrFederatedSearch = MultiSearch | FederatedSearch; export type FacetSearchQuery = { facetQuery?: string | null; facetName: string; - /** - * @remarks - * Undocumented. - */ exhaustiveFacetCount?: boolean | null; } & FacetAndSearchQuerySegment; @@ -183,7 +219,6 @@ export type FacetSearchQuery = { */ export type SimilarQuery = { id: string | number; - /** {@link https://www.meilisearch.com/docs/reference/api/search#hybrid-search} */ embedder: string; } & FirstPartOfFacetAndSearchQuerySegment & FirstPartOfSearchQueryCore & diff --git a/src/types/search-response.ts b/src/types/search-response.ts index 96feb5182..f43587ef3 100644 --- a/src/types/search-response.ts +++ b/src/types/search-response.ts @@ -1,28 +1,51 @@ -import type { RecordAny } from "./shared.js"; -import type { FieldDistribution } from "./types.js"; +import type { RecordAny, DeepStringRecord } from "./shared.js"; +import type { FieldDistribution, MeiliSearchErrorResponse } from "./types.js"; + +// TODO: Maybe more links /** @see `milli::search::new::matches::MatchBounds` */ -export type MatchBounds = { start: number; length: number; indices: number[] }; +export type MatchBounds = { start: number; length: number; indices?: number[] }; /** @see `meilisearch::search::MatchesPosition` */ export type MatchesPosition = Record; +/** + * {@link https://www.meilisearch.com/docs/reference/api/search#ranking-score-details-object} + * + * @privateRemarks + * This could be typed more accurately, but both the source code and + * documentation is a little confusing. + * @see `milli::score_details::ScoreDetails::to_json_map` + */ +export type ScoreDetails = RecordAny; + +/** @see `milli::vector::parsed_vectors::ExplicitVectors` */ +export type ExplicitVectors = { + embeddings: number[] | number[][]; + regenerate: boolean; +}; + /** @see `meilisearch::search::SearchHit` */ export type SearchHit = T & { - _formatted?: T; + _formatted?: DeepStringRecord; _matchesPosition?: MatchesPosition; _rankingScore?: number; - _rankingScoreDetails?: RecordAny; + _rankingScoreDetails?: ScoreDetails; + /** @see `meilisearch::search::insert_geo_distance` */ + _geoDistance?: number; + /** @see `meilisearch::search::HitMaker::make_hit` */ + _vectors?: Record; }; /** - * @remarks + * @privateRemarks * This is an untyped structure in the source code. * @see `meilisearch::search::federated::perform::SearchByIndex::execute` */ export type FederationDetails = { indexUid: string; queriesPosition: number; + remote?: string; weightedRankingScore: number; }; @@ -53,6 +76,7 @@ export type FacetStats = { type ProcessingTime = { processingTimeMs: number }; type SearchResultCore = { + // TODO: this is not present on federated result query: string; facetDistribution?: Record; facetStats?: Record; @@ -69,6 +93,15 @@ export type SearchResult = | SearchResultWithOffsetLimit | SearchResultWithPagination; +/** @see `meilisearch::search::ComputedFacets` */ +export type ComputedFacets = { + distribution: Record>; + stats: Record; +}; + +/** @see `meilisearch::search::federated::types::FederatedFacets` */ +export type FederatedFacets = Record; + /** * {@link https://www.meilisearch.com/docs/reference/api/multi_search#federated-multi-search-requests} * @@ -76,6 +109,8 @@ export type SearchResult = */ export type FederatedSearchResult = SearchResultCore & { hits: FederatedSearchHit>[]; + facetsByIndex: FederatedFacets; + remoteErrors?: Record; } & OffsetLimit; type SearchResultIndex = { indexUid: string }; @@ -101,6 +136,7 @@ export type SearchResults = { results: SearchResultWithIndex>[]; }; +/** {@link https://www.meilisearch.com/docs/reference/api/multi_search#response} */ export type SearchResultsOrFederatedSearchResult = | SearchResults | FederatedSearchResult; diff --git a/src/types/shared.ts b/src/types/shared.ts index 2565e0fb7..fe03ceeaa 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -5,9 +5,9 @@ export type NonNullKeys = { [TKey in U]: Exclude; } & { [TKey in Exclude]: T[TKey] }; -export type RequiredKeys = { +export type RequiredKeys = { [TKey in U]-?: Exclude; -} & { [TKey in Exclude]: T[TKey] }; +} & Omit; export type CursorResults = { results: T[]; @@ -19,3 +19,11 @@ export type CursorResults = { // taken from https://stackoverflow.com/a/65642944 export type PascalToCamelCase = Uncapitalize; + +export type DeepStringRecord = { + [TKey in keyof T]: T[TKey] extends object + ? DeepStringRecord + : string; +}; + +export type SafeOmit = Omit; diff --git a/src/utils.ts b/src/utils.ts index 8b5bee804..4a5ddce64 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -19,7 +19,11 @@ function addTrailingSlash(url: string): string { function stringifyRecordKeyValues< T extends Record, const U extends (keyof T)[], ->(v: T, keys: U) { +>(v: T | undefined, keys: U) { + if (v === undefined) { + return; + } + return Object.fromEntries( Object.entries(v).map(([key, val]) => [ key, diff --git a/tests/__snapshots__/search.test.ts.snap b/tests/__snapshots__/search.test.ts.snap deleted file mode 100644 index 90e7c1c42..000000000 --- a/tests/__snapshots__/search.test.ts.snap +++ /dev/null @@ -1,100 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Test on POST search > Admin key: search on attributesToSearchOn set to null 1`] = ` -{ - "estimatedTotalHits": 2, - "hits": [ - { - "author": "Antoine de Saint-Exupéry", - "comment": "A french book about a prince that walks on little cute planets", - "genre": [ - "adventure", - ], - "id": 456, - "isNull": null, - "isTrue": true, - "title": "Le Petit Prince", - }, - { - "author": "J.K. Rowling", - "comment": "The best book", - "genre": [ - "fantasy", - "adventure", - ], - "id": 4, - "title": "Harry Potter and the Half-Blood Prince", - }, - ], - "limit": 20, - "offset": 0, - "processingTimeMs": 0, - "query": "prince", -} -`; - -exports[`Test on POST search > Master key: search on attributesToSearchOn set to null 1`] = ` -{ - "estimatedTotalHits": 2, - "hits": [ - { - "author": "Antoine de Saint-Exupéry", - "comment": "A french book about a prince that walks on little cute planets", - "genre": [ - "adventure", - ], - "id": 456, - "isNull": null, - "isTrue": true, - "title": "Le Petit Prince", - }, - { - "author": "J.K. Rowling", - "comment": "The best book", - "genre": [ - "fantasy", - "adventure", - ], - "id": 4, - "title": "Harry Potter and the Half-Blood Prince", - }, - ], - "limit": 20, - "offset": 0, - "processingTimeMs": 0, - "query": "prince", -} -`; - -exports[`Test on POST search > Search key: search on attributesToSearchOn set to null 1`] = ` -{ - "estimatedTotalHits": 2, - "hits": [ - { - "author": "Antoine de Saint-Exupéry", - "comment": "A french book about a prince that walks on little cute planets", - "genre": [ - "adventure", - ], - "id": 456, - "isNull": null, - "isTrue": true, - "title": "Le Petit Prince", - }, - { - "author": "J.K. Rowling", - "comment": "The best book", - "genre": [ - "fantasy", - "adventure", - ], - "id": 4, - "title": "Harry Potter and the Half-Blood Prince", - }, - ], - "limit": 20, - "offset": 0, - "processingTimeMs": 0, - "query": "prince", -} -`; diff --git a/tests/embedders.test.ts b/tests/embedders.test.ts index 78202438e..56a03620c 100644 --- a/tests/embedders.test.ts +++ b/tests/embedders.test.ts @@ -12,39 +12,6 @@ const index = { uid: "movies_test", }; -const datasetSimilarSearch = [ - { - title: "Shazam!", - release_year: 2019, - id: "287947", - _vectors: { manual: [0.8, 0.4, -0.5] }, - }, - { - title: "Captain Marvel", - release_year: 2019, - id: "299537", - _vectors: { manual: [0.6, 0.8, -0.2] }, - }, - { - title: "Escape Room", - release_year: 2019, - id: "522681", - _vectors: { manual: [0.1, 0.6, 0.8] }, - }, - { - title: "How to Train Your Dragon: The Hidden World", - release_year: 2019, - id: "166428", - _vectors: { manual: [0.7, 0.7, -0.4] }, - }, - { - title: "All Quiet on the Western Front", - release_year: 1930, - id: "143", - _vectors: { manual: [-0.5, 0.3, 0.85] }, - }, -]; - afterAll(() => { return clearAllIndexes(config); }); @@ -243,92 +210,6 @@ describe.each([{ permission: "Master" }, { permission: "Admin" }])( expect(response).toEqual({}); }); - - test(`${permission} key: search (POST) with vectors`, async () => { - const client = await getClient(permission); - - await client - .index(index.uid) - .updateEmbedders({ - default: { - source: "userProvided", - dimensions: 1, - }, - }) - .waitTask(); - - const response = await client.index(index.uid).search({ - q: "", - vector: [1], - hybrid: { - embedder: "default", - semanticRatio: 1.0, - }, - }); - - expect(response).toHaveProperty("hits"); - expect(response).toHaveProperty("semanticHitCount"); - // Those fields are no longer returned by the search response - // We want to ensure that they don't appear in it anymore - expect(response).not.toHaveProperty("vector"); - expect(response).not.toHaveProperty("_semanticScore"); - }); - - test(`${permission} key: search (GET) with vectors`, async () => { - const client = await getClient(permission); - - await client - .index(index.uid) - .updateEmbedders({ - default: { - source: "userProvided", - dimensions: 1, - }, - }) - .waitTask(); - - const response = await client.index(index.uid).searchGet({ - q: "", - vector: [1], - hybridEmbedder: "default", - hybridSemanticRatio: 1.0, - }); - - expect(response).toHaveProperty("hits"); - expect(response).toHaveProperty("semanticHitCount"); - // Those fields are no longer returned by the search response - // We want to ensure that they don't appear in it anymore - expect(response).not.toHaveProperty("vector"); - expect(response).not.toHaveProperty("_semanticScore"); - }); - - test(`${permission} key: search for similar documents`, async () => { - const client = await getClient(permission); - - const newEmbedder: Embedders = { - manual: { - source: "userProvided", - dimensions: 3, - }, - }; - await client.index(index.uid).updateEmbedders(newEmbedder).waitTask(); - - await client - .index(index.uid) - .addDocuments(datasetSimilarSearch) - .waitTask(); - - const response = await client.index(index.uid).searchSimilarDocuments({ - embedder: "manual", - id: "143", - }); - - expect(response).toHaveProperty("hits"); - expect(response.hits.length).toEqual(4); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 20); - expect(response).toHaveProperty("estimatedTotalHits", 4); - }); }, ); diff --git a/tests/facet_search.test.ts b/tests/facet_search.test.ts deleted file mode 100644 index 53e9b1f34..000000000 --- a/tests/facet_search.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { expect, test, describe, beforeAll, afterAll } from "vitest"; -import { - clearAllIndexes, - config, - getClient, -} from "./utils/meilisearch-test-utils.js"; - -const index = { - uid: "movies_test", -}; - -const dataset = [ - { - id: 123, - title: "Pride and Prejudice", - genres: ["romance", "action"], - }, - { - id: 456, - title: "Le Petit Prince", - genres: ["adventure", "comedy"], - }, - { - id: 2, - title: "Le Rouge et le Noir", - genres: "romance", - }, - { - id: 1, - title: "Alice In Wonderland", - genres: ["adventure"], - }, -]; - -describe.each([ - { permission: "Master" }, - { permission: "Admin" }, - { permission: "Search" }, -])("Test on POST search", ({ permission }) => { - beforeAll(async () => { - await clearAllIndexes(config); - const client = await getClient("Master"); - const newFilterableAttributes = ["genres", "title"]; - await client.createIndex(index.uid); - await client.index(index.uid).updateSettings({ - filterableAttributes: newFilterableAttributes, - }); - await client.index(index.uid).addDocuments(dataset).waitTask(); - }); - - test(`${permission} key: basic facet value search`, async () => { - const client = await getClient(permission); - - const params = { - facetQuery: "a", - facetName: "genres", - }; - const response = await client.index(index.uid).searchForFacetValues(params); - - expect(response).toMatchSnapshot(); - }); - - test(`${permission} key: facet value search with no facet query`, async () => { - const client = await getClient(permission); - - const params = { - facetName: "genres", - }; - const response = await client.index(index.uid).searchForFacetValues(params); - - expect(response).toMatchSnapshot(); - }); - - test(`${permission} key: facet value search with filter`, async () => { - const client = await getClient(permission); - - const params = { - facetName: "genres", - facetQuery: "a", - filter: ["genres = action"], - }; - - const response = await client.index(index.uid).searchForFacetValues(params); - - expect(response).toMatchSnapshot(); - }); - - test(`${permission} key: facet value search with search query`, async () => { - const client = await getClient(permission); - - const params = { - facetName: "genres", - facetQuery: "a", - q: "Alice", - }; - const response = await client.index(index.uid).searchForFacetValues(params); - - // @TODO: This is flaky, processingTimeMs is not guaranteed - expect(response).toMatchSnapshot(); - }); -}); - -afterAll(() => { - return clearAllIndexes(config); -}); diff --git a/tests/get_search.test.ts b/tests/get_search.test.ts deleted file mode 100644 index 3a6771b1d..000000000 --- a/tests/get_search.test.ts +++ /dev/null @@ -1,580 +0,0 @@ -import { expect, test, describe, afterAll, beforeAll } from "vitest"; -import { ErrorStatusCode } from "../src/types/index.js"; -import { - clearAllIndexes, - config, - BAD_HOST, - MeiliSearch, - getClient, -} from "./utils/meilisearch-test-utils.js"; - -const index = { - uid: "movies_test", -}; -const emptyIndex = { - uid: "empty_test", -}; - -const dataset = [ - { - id: 123, - title: "Pride and Prejudice", - author: "Jane Austen", - comment: "A great book", - genre: ["romance"], - }, - { - id: 456, - title: "Le Petit Prince", - author: "Antoine de Saint-Exupéry", - comment: "A french book about a prince that walks on little cute planets", - genre: ["adventure"], - }, - { - id: 2, - title: "Le Rouge et le Noir", - author: "Stendhal", - comment: "Another french book", - genre: ["romance"], - }, - { - id: 1, - title: "Alice In Wonderland", - author: "Lewis Carroll", - comment: "A weird book", - genre: ["adventure"], - }, - { - id: 1344, - title: "The Hobbit", - author: "J.R.R. Tolkien", - comment: "An awesome book", - genre: ["fantasy", "adventure"], - }, - { - id: 4, - title: "Harry Potter and the Half-Blood Prince", - author: "J.K. Rowling", - comment: "The best book", - genre: ["fantasy", "adventure"], - }, - { - id: 5, - title: "Harry Potter and the Deathly Hallows", - author: "J.K. Rowling", - genre: ["fantasy", "adventure"], - }, - { - id: 42, - title: "The Hitchhiker's Guide to the Galaxy", - author: "Douglas Adams", - genre: ["sci fi", "comedy"], - }, -]; - -afterAll(() => { - return clearAllIndexes(config); -}); - -describe.each([ - { permission: "Master" }, - { permission: "Admin" }, - { permission: "Search" }, -])("Test on GET search", ({ permission }) => { - beforeAll(async () => { - await clearAllIndexes(config); - const client = await getClient("Master"); - await client.createIndex(index.uid).waitTask(); - await client.createIndex(emptyIndex.uid).waitTask(); - - const newFilterableAttributes = ["genre", "title", "id", "author"]; - await client - .index(index.uid) - .updateSettings({ - filterableAttributes: newFilterableAttributes, - sortableAttributes: ["id"], - }) - .waitTask(); - - await client.index(index.uid).addDocuments(dataset).waitTask(); - }); - - test(`${permission} key: Basic search`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).searchGet("prince", {}); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("limit", 20); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: search with options`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .searchGet("prince", { limit: 1 }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 1); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: search with sortable`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("", { sort: ["id:asc"] }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - const hit = response.hits[0]; - expect(hit.id).toEqual(1); - }); - - test(`${permission} key: search with array options`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - attributesToRetrieve: ["*"], - }); - const hit = response.hits[0]; - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("query", "prince"); - expect(Object.keys(hit).join(",")).toEqual( - Object.keys(dataset[1]).join(","), - ); - }); - - test(`${permission} key: search on attributesToSearchOn`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).searchGet("prince", { - attributesToSearchOn: ["id"], - }); - - expect(response.hits.length).toEqual(0); - }); - - test(`${permission} key: search on attributesToSearchOn set to null`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).searchGet("prince", { - attributesToSearchOn: null, - }); - - expect(response).toMatchSnapshot(); - }); - - test(`${permission} key: search with options`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .searchGet("prince", { limit: 1 }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 1); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: search with limit and offset`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - limit: 1, - offset: 1, - }); - expect(response).toHaveProperty("hits", [ - { - id: 4, - title: "Harry Potter and the Half-Blood Prince", - author: "J.K. Rowling", - comment: "The best book", - genre: ["fantasy", "adventure"], - }, - ]); - expect(response).toHaveProperty("offset", 1); - expect(response).toHaveProperty("limit", 1); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: search with matches parameter and small croplength`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - filter: 'title = "Le Petit Prince"', - attributesToCrop: ["*"], - cropLength: 5, - showMatchesPosition: true, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits[0]).toHaveProperty("_matchesPosition", { - comment: [{ start: 22, length: 6 }], - title: [{ start: 9, length: 6 }], - }); - }); - - test(`${permission} key: search with all options but not all fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["id", "title"], - attributesToCrop: ["*"], - cropLength: 6, - attributesToHighlight: ["*"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 5); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits[0]._formatted).toHaveProperty("title"); - expect(response.hits[0]._formatted).toHaveProperty("id"); - expect(response.hits[0]).not.toHaveProperty("comment"); - expect(response.hits[0]).not.toHaveProperty("description"); - expect(response.hits.length).toEqual(1); - expect(response.hits[0]).toHaveProperty("_formatted", expect.any(Object)); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]).toHaveProperty( - "_matchesPosition", - expect.any(Object), - ); - }); - - test(`${permission} key: search on default cropping parameters`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - attributesToCrop: ["*"], - cropLength: 6, - }); - - expect(response.hits[0]._formatted).toHaveProperty( - "comment", - "…book about a prince that walks…", - ); - }); - - test(`${permission} key: search on customized cropMarker`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - attributesToCrop: ["*"], - cropLength: 6, - cropMarker: "(ꈍᴗꈍ)", - }); - - expect(response.hits[0]._formatted).toHaveProperty( - "comment", - "(ꈍᴗꈍ)book about a prince that walks(ꈍᴗꈍ)", - ); - }); - - test(`${permission} key: search on customized highlight tags`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - attributesToHighlight: ["*"], - highlightPreTag: "(⊃。•́‿•̀。)⊃ ", - highlightPostTag: " ⊂(´• ω •`⊂)", - }); - - expect(response.hits[0]._formatted).toHaveProperty( - "comment", - "A french book about a (⊃。•́‿•̀。)⊃ prince ⊂(´• ω •`⊂) that walks on little cute planets", - ); - }); - - test(`${permission} key: search with all options and all fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["*"], - attributesToCrop: ["*"], - cropLength: 6, - attributesToHighlight: ["*"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 5); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - expect(response.hits[0]).toHaveProperty("_formatted", expect.any(Object)); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]).toHaveProperty( - "_matchesPosition", - expect.any(Object), - ); - }); - - test(`${permission} key: search with all options but specific fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["id", "title"], - attributesToCrop: ["id", "title"], - cropLength: 6, - attributesToHighlight: ["id", "title"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 5); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - expect(response.hits[0]).toHaveProperty("id", 456); - expect(response.hits[0]).toHaveProperty("title", "Le Petit Prince"); - expect(response.hits[0]).not.toHaveProperty("comment"); - expect(response.hits[0]).toHaveProperty("_formatted", expect.any(Object)); - expect(response.hits[0]).not.toHaveProperty( - "description", - expect.any(Object), - ); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]._formatted).not.toHaveProperty("comment"); - expect(response.hits[0]).toHaveProperty( - "_matchesPosition", - expect.any(Object), - ); - }); - - test(`${permission} key: search with filter and facetDistribution`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("a", { - filter: "genre = romance", - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { romance: 2 }, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: search with filter on number`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("a", { - filter: "id < 0", - facets: ["genre"], - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(0); - }); - - test(`${permission} key: search with filter with spaces`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("h", { - filter: 'genre = "sci fi"', - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: search with multiple filter`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("a", { - filter: "genre = romance AND (genre = romance OR genre = romance)", - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { romance: 2 }, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: search with multiple filter and undefined query (placeholder)`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet(undefined, { - filter: "genre = fantasy", - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { adventure: 3, fantasy: 3 }, - }); - expect(response.hits.length).toEqual(3); - }); - - test(`${permission} key: search with multiple filter and null query (placeholder)`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet(null, { - filter: "genre = fantasy", - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { - adventure: 3, - fantasy: 3, - }, - }); - expect(response.hits.length).toEqual(3); - expect(response.estimatedTotalHits).toEqual(3); - }); - - test(`${permission} key: search with multiple filter and empty string query (placeholder)`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("", { - filter: "genre = fantasy", - facets: ["genre"], - }); - - expect(response).toHaveProperty("facetDistribution", { - genre: { adventure: 3, fantasy: 3 }, - }); - expect(response.hits.length).toEqual(3); - }); - - test(`${permission} key: Try to search with wrong format filter`, async () => { - const client = await getClient(permission); - await expect( - client.index(index.uid).searchGet("prince", { - filter: ["hello"], - }), - ).rejects.toHaveProperty( - "message", - "The filter query parameter should be in string format when using searchGet", - ); - }); - - test(`${permission} key: search without vectors`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", {}); - - expect(response).not.toHaveProperty("semanticHitCount"); - }); - - test(`${permission} key: search with rankingScoreThreshold filter`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).searchGet("prince", { - showRankingScore: true, - rankingScoreThreshold: 0.8, - }); - - const hit = response.hits[0]; - - expect(response).toHaveProperty("hits", expect.any(Array)); - expect(response).toHaveProperty("query", "prince"); - expect(hit).toHaveProperty("_rankingScore"); - expect(hit["_rankingScore"]).toBeGreaterThanOrEqual(0.8); - - const response2 = await client.index(index.uid).search("prince", { - showRankingScore: true, - rankingScoreThreshold: 0.9, - }); - - expect(response2.hits.length).toBeLessThanOrEqual(0); - }); - - test(`${permission} key: search with distinct`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("", { distinct: "author" }); - - expect(response.hits.length).toEqual(7); - }); - - test(`${permission} key: search with retrieveVectors to true`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).searchGet("prince", { - retrieveVectors: true, - }); - - expect(response).toHaveProperty("hits", expect.any(Array)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits[0]).toHaveProperty("_vectors"); - }); - - test(`${permission} key: search without retrieveVectors`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).searchGet("prince"); - - expect(response).toHaveProperty("hits", expect.any(Array)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits[0]).not.toHaveProperty("_vectors"); - }); - - test(`${permission} key: matches position contain indices`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("fantasy", { - showMatchesPosition: true, - }); - expect(response.hits[0]._matchesPosition).toEqual({ - genre: [{ start: 0, length: 7, indices: [0] }], - }); - }); - - // This test deletes the index, so following tests may fail if they need an existing index - test(`${permission} key: Try to search on deleted index and fail`, async () => { - const client = await getClient(permission); - const masterClient = await getClient("Master"); - await masterClient.index(index.uid).delete().waitTask(); - await expect( - client.index(index.uid).searchGet("prince"), - ).rejects.toHaveProperty("cause.code", ErrorStatusCode.INDEX_NOT_FOUND); - }); -}); - -describe.each([ - { host: BAD_HOST, trailing: false }, - { host: `${BAD_HOST}/api`, trailing: false }, - { host: `${BAD_HOST}/trailing/`, trailing: true }, -])("Tests on url construction", ({ host, trailing }) => { - test(`get search route`, async () => { - const route = `indexes/${index.uid}/search`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.index(index.uid).searchGet()).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); - - test(`post search route`, async () => { - const route = `indexes/${index.uid}/search`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.index(index.uid).searchGet()).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); -}); diff --git a/tests/search.test.ts b/tests/search.test.ts index 14db45ecc..0e5058b4d 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -1,1507 +1,491 @@ +import { afterAll, beforeAll, test, describe } from "vitest"; +import { assert, HOST, MASTER_KEY } from "./utils/meilisearch-test-utils.js"; +import { type Film, FILMS } from "./utils/test-data/films.js"; +import type { + ExplicitVectors, + Federation, + FilterExpression, + MatchingStrategy, + SearchHit, + SearchQuery, + SearchQueryWithIndexAndFederation, + SearchResultWithOffsetLimit, + SearchResultWithPagination, +} from "../src/index.js"; import { - expect, - test, - describe, - beforeEach, - afterAll, - beforeAll, - vi, -} from "vitest"; -import { ErrorStatusCode, type MatchingStrategy } from "../src/index.js"; -import { - clearAllIndexes, - config, - BAD_HOST, - MeiliSearch, - getClient, - datasetWithNests, - getKey, - HOST, - assert, -} from "./utils/meilisearch-test-utils.js"; -import { MeiliSearchRequestError } from "../src/index.js"; - -const index = { - uid: "books", -}; -const emptyIndex = { - uid: "empty_test", -}; - -type Books = { - id: number; - title: string; - comment: string; - genre: string; -}; - -const dataset = [ - { - id: 123, - title: "Pride and Prejudice", - author: "Jane Austen", - comment: "A great book", - genre: ["romance"], - }, - { - id: 456, - title: "Le Petit Prince", - author: "Antoine de Saint-Exupéry", - comment: "A french book about a prince that walks on little cute planets", - genre: ["adventure"], - isNull: null, - isTrue: true, - }, - { - id: 2, - title: "Le Rouge et le Noir", - author: "Stendhal", - comment: "Another french book", - genre: ["romance"], - }, - { - id: 1, - title: "Alice In Wonderland", - author: "Lewis Carroll", - comment: "A weird book", - genre: ["adventure"], - }, - { - id: 1344, - title: "The Hobbit", - author: "J.R.R. Tolkien", - comment: "An awesome book", - genre: ["fantasy", "adventure"], - }, - { - id: 4, - title: "Harry Potter and the Half-Blood Prince", - author: "J.K. Rowling", - comment: "The best book", - genre: ["fantasy", "adventure"], - }, - { - id: 5, - title: "Harry Potter and the Deathly Hallows", - author: "J.K. Rowling", - genre: ["fantasy", "adventure"], - }, - { - id: 42, - title: "The Hitchhiker's Guide to the Galaxy", - author: "Douglas Adams", - genre: ["sci fi", "comedy"], - }, -]; - -type Movies = { - id: number; - title: string; -}; - -const movies = [ - { - id: 1, - title: "Pride and Prejudice", - }, - { - id: 2, - title: "The Hobbit: An Unexpected Journey", - }, - { - id: 3, - title: "Harry Potter and the Half-Blood Prince", - }, -]; - -describe.each([ - { permission: "Master" }, - { permission: "Admin" }, - { permission: "Search" }, -])("Test on POST search", ({ permission }) => { - beforeAll(async () => { - await clearAllIndexes(config); - const client = await getClient("Master"); - await client.createIndex(index.uid); - await client.createIndex(emptyIndex.uid); - - const newFilterableAttributes = ["genre", "title", "id", "author"]; - await client - .index(index.uid) - .updateSettings({ - filterableAttributes: newFilterableAttributes, - sortableAttributes: ["id"], - }) - .waitTask(); - - await client.index(index.uid).addDocuments(dataset).waitTask(); - }); - - test(`${permission} key: Multi index search no queries`, async () => { - const client = await getClient(permission); - const response = await client.multiSearch({ - queries: [], - }); - - expect(response.results.length).toEqual(0); - }); - - test(`${permission} key: Multi index search with one query`, async () => { - const client = await getClient(permission); - const response = await client.multiSearch({ - queries: [{ indexUid: index.uid, q: "prince" }], - }); - - expect(response.results[0].hits.length).toEqual(2); - }); - - test(`${permission} key: Multi index search with multiple queries`, async () => { - const client = await getClient(permission); - const response = await client.multiSearch({ - queries: [ - { indexUid: index.uid, q: "something" }, - { indexUid: emptyIndex.uid, q: "something" }, - ], - }); - - expect(response.results.length).toEqual(2); - }); - - test(`${permission} key: Multi index search with one query`, async () => { - const client = await getClient(permission); - - const response = await client.multiSearch({ - queries: [{ indexUid: index.uid, q: "prince" }], - }); - - expect(response.results[0].hits.length).toEqual(2); - expect(response.results[0].hits[0].id).toEqual(456); - expect(response.results[0].hits[0].title).toEqual("Le Petit Prince"); - }); - - test(`${permission} key: Multi index search with federation`, async () => { - const client = await getClient(permission); - - const response1 = await client.multiSearch({ - federation: {}, - queries: [ - { indexUid: index.uid, q: "456", attributesToSearchOn: ["id"] }, - { - indexUid: index.uid, - q: "1344", - federationOptions: { weight: 0.9 }, - attributesToSearchOn: ["id"], - }, - ], - }); - - expect(response1).toHaveProperty("hits"); - expect(Array.isArray(response1.hits)).toBe(true); - expect(response1.hits.length).toEqual(2); - expect(response1.hits[0].id).toEqual(456); - - const response2 = await client.multiSearch({ - federation: {}, - queries: [ - { - indexUid: index.uid, - q: "456", - federationOptions: { weight: 0.9 }, - attributesToSearchOn: ["id"], - }, - { indexUid: index.uid, q: "1344", attributesToSearchOn: ["id"] }, - ], - }); - - expect(response2).toHaveProperty("hits"); - expect(Array.isArray(response2.hits)).toBe(true); - expect(response2.hits.length).toEqual(2); - expect(response2.hits[0].id).toEqual(1344); - }); - - test(`${permission} key: Multi index search with federation and remote`, async () => { - const adminKey = await getKey("Admin"); - - // first enable the network endpoint. - await fetch(`${HOST}/experimental-features`, { - body: JSON.stringify({ network: true }), - headers: { - Authorization: `Bearer ${adminKey}`, - "Content-Type": "application/json", - }, - method: "PATCH", - }); - - const masterClient = await getClient("Master"); - - const searchKey = await getKey("Search"); - - // set the remote name and instances - const instanceName = "instance_1"; - await masterClient.updateNetwork({ - self: instanceName, - remotes: { [instanceName]: { url: HOST, searchApiKey: searchKey } }, - }); - - const searchClient = await getClient(permission); - - const response = await searchClient.multiSearch({ - federation: {}, - queries: [ - { - indexUid: index.uid, - q: "456", - attributesToSearchOn: ["id"], - federationOptions: { weight: 1, remote: instanceName }, - }, - { - indexUid: index.uid, - q: "1344", - federationOptions: { weight: 0.9, remote: instanceName }, - attributesToSearchOn: ["id"], - }, + assertFacetDistributionAndStatsAreCorrect, + client, + federatedMultiSearch, + index, + INDEX_UID, + multiSearch, + search, + searchGet, + searchSimilarDocuments, + searchSimilarDocumentsGet, +} from "./utils/search.js"; + +beforeAll(async () => { + // TODO: If this disables the rest of the experimental features, that might be a problem, consider https://vitest.dev/config/#globalsetup + await client.updateExperimentalFeatures({ network: true }); + await client.updateNetwork({ + self: INDEX_UID, + remotes: { [INDEX_UID]: { url: HOST, searchApiKey: MASTER_KEY } }, + }); + + await index + .updateSettings({ + filterableAttributes: [ + "id", + "genres", + "popularity", + "release_date", + "_geo", ], - }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(2); - expect(response.hits[0].id).toEqual(456); - expect(response.hits[0]._federation).toHaveProperty("remote", instanceName); - }); - - test(`${permission} key: Multi search with facetsByIndex`, async () => { - const client = await getClient(permission); - const masterClient = await getClient("Master"); - - // Setup to have a new "movies" index - await masterClient.createIndex("movies"); - const newFilterableAttributes = ["title", "id"]; - await masterClient - .index("movies") - .updateSettings({ - filterableAttributes: newFilterableAttributes, - sortableAttributes: ["id"], - }) - .waitTask(); - await masterClient.index("movies").addDocuments(movies).waitTask(); - - // Make a multi search on both indexes with facetsByIndex - const response = await client.multiSearch({ - federation: { - limit: 20, - offset: 0, - facetsByIndex: { - movies: ["title", "id"], - books: ["title"], + sortableAttributes: ["popularity"], + embedders: { + default: { + source: "userProvided", + dimensions: 1, }, }, - queries: [ - { - q: "Hobbit", - indexUid: "movies", - }, - { - q: "Hobbit", - indexUid: "books", - }, - ], + }) + .waitTask() + .then(({ status, error }) => { + assert.isNull(error); + assert.strictEqual(status, "succeeded"); }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(2); - - expect(response).toHaveProperty("facetsByIndex"); - expect(response.facetsByIndex).toHaveProperty("movies"); - expect(response.facetsByIndex).toHaveProperty("books"); - - // Test search response on "movies" index - expect(response.facetsByIndex?.movies).toEqual({ - distribution: { - title: { - "The Hobbit: An Unexpected Journey": 1, - }, - id: { - "2": 1, - }, - }, - stats: { - id: { - min: 2, - max: 2, - }, - }, + await index + .addDocuments(FILMS) + .waitTask() + .then(({ status, error }) => { + assert.isNull(error); + assert.strictEqual(status, "succeeded"); }); +}); - // Test search response on "books" index - expect(response.facetsByIndex?.books).toEqual({ - distribution: { - title: { - "The Hobbit": 1, - }, - }, - stats: {}, - }); - }); - - test(`${permission} key: Multi search with mergeFacets`, async () => { - const client = await getClient(permission); - const masterClient = await getClient("Master"); - - // Setup to have a new "movies" index - await masterClient.createIndex("movies"); - const newFilterableAttributes = ["title", "id"]; - await masterClient - .index("movies") - .updateSettings({ - filterableAttributes: newFilterableAttributes, - sortableAttributes: ["id"], - }) - .waitTask(); - await masterClient.index("movies").addDocuments(movies).waitTask(); - - // Make a multi search on both indexes with mergeFacets - const response = await client.multiSearch< - FederatedMultiSearchParams, - Books | Movies - >({ - federation: { - limit: 20, - offset: 0, - facetsByIndex: { - movies: ["title", "id"], - books: ["title"], - }, - mergeFacets: { - maxValuesPerFacet: 10, - }, - }, - queries: [ - { - q: "Hobbit", - indexUid: "movies", - }, - { - q: "Hobbit", - indexUid: "books", - }, - ], - }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(2); - - expect(response).toHaveProperty("facetDistribution"); - expect(response).toHaveProperty("facetStats"); - - expect(response.facetDistribution).toEqual({ - title: { - "The Hobbit": 1, - "The Hobbit: An Unexpected Journey": 1, - }, - id: { - "2": 1, - }, - }); - - expect(response.facetStats).toEqual({ - id: { - min: 2, - max: 2, - }, - }); - }); - - test(`${permission} key: Basic search`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", {}); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("limit", 20); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.facetStats).toBeUndefined(); - expect(response.hits.length).toEqual(2); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.hitsPerPage).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.page).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.totalPages).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.totalHits).toBeUndefined(); - }); - - test(`${permission} key: Basic phrase search with matchingStrategy at ALL`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search('"french book" about', { - matchingStrategy: MatchingStrategies.ALL, - }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 20); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: Basic phrase search with matchingStrategy at LAST`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("french book", { matchingStrategy: MatchingStrategies.LAST }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 20); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: Basic phrase search with matchingStrategy at FREQUENCY`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("french book", { - matchingStrategy: MatchingStrategies.FREQUENCY, - }); - - expect(response).toHaveProperty("hits", expect.any(Array)); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 20); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: Search with query in searchParams overwriting query`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("other", { q: "prince" }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("limit", 20); - expect(response).toHaveProperty("offset", 0); - expect(response.estimatedTotalHits).toBeDefined(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: Search with query in searchParams overwriting null query`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search(null, { q: "prince" }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("limit", 20); - expect(response).toHaveProperty("offset", 0); - expect(response.estimatedTotalHits).toBeDefined(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: Basic phrase search`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search('"french book" about', {}); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("limit", 20); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", '"french book" about'); - - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: search with options`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("prince", { limit: 1 }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 1); - expect(response.estimatedTotalHits).toEqual(2); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: search with sortable`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("", { sort: ["id:asc"] }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - const hit = response.hits[0]; - expect(hit.id).toEqual(1); - }); - - test(`${permission} key: search with _showRankingScore enabled`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince", { - showRankingScore: true, - }); - - const hit = response.hits[0]; - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("query", "prince"); - expect(hit).toHaveProperty("_rankingScore"); - }); - - test(`${permission} key: search with showRankingScoreDetails`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince", { - showRankingScoreDetails: true, - }); - - const hit = response.hits[0]; - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("query", "prince"); - expect(hit).toHaveProperty("_rankingScoreDetails"); - expect(Object.keys(hit._rankingScoreDetails || {})).toEqual([ - "words", - "typo", - "proximity", - "attribute", - "exactness", - ]); - }); - - test(`${permission} key: search with rankingScoreThreshold filter`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince", { - showRankingScore: true, - rankingScoreThreshold: 0.8, - }); - - const hit = response.hits[0]; - - expect(response).toHaveProperty("hits", expect.any(Array)); - expect(response).toHaveProperty("query", "prince"); - expect(hit).toHaveProperty("_rankingScore"); - expect(hit["_rankingScore"]).toBeGreaterThanOrEqual(0.8); - - const response2 = await client.index(index.uid).search("prince", { - showRankingScore: true, - rankingScoreThreshold: 0.9, - }); - - expect(response2.hits.length).toBeLessThanOrEqual(0); - }); - - test(`${permission} key: search with array options`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince", { - attributesToRetrieve: ["*"], - }); - const hit = response.hits[0]; - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("query", "prince"); - expect(Object.keys(hit).join(",")).toEqual( - Object.keys(dataset[1]).join(","), - ); - }); - - test(`${permission} key: search on attributesToSearchOn`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince", { - attributesToSearchOn: ["id"], - }); - - expect(response.hits.length).toEqual(0); - }); - - test(`${permission} key: search on attributesToSearchOn set to null`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince", { - attributesToSearchOn: null, +afterAll(async () => { + await index + .delete() + .waitTask() + .then(({ status, error }) => { + assert.isNull(error); + assert.strictEqual(status, "succeeded"); }); + await client.updateNetwork({}); +}); - expect(response).toMatchSnapshot(); - }); +describe.concurrent("`q` param", () => { + const params: SearchQuery = { q: "earth" }; - test(`${permission} key: search with array options`, async () => { - const client = await getClient(permission); + test.for([search, searchGet, multiSearch])( + "with $name", + async ({ searchMethod }) => { + const { query, processingTimeMs, hits } = await searchMethod(params); - const response = await client.index(index.uid).search("prince", { - attributesToRetrieve: ["*"], - }); - const hit = response.hits[0]; + assert.typeOf(processingTimeMs, "number"); + assert.strictEqual(query, params.q); + assert.sameMembers( + hits.map((v) => v.id), + [95, 348, 607, 608], + ); + }, + ); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("query", "prince"); - expect(Object.keys(hit).join(",")).toEqual( - Object.keys(dataset[1]).join(","), + // Does this affect the search result? + test(`with ${index.searchForFacetValues.name}`, async () => { + await assert.resolves( + index.searchForFacetValues({ facetName: "popularity", ...params }), ); }); +}); - test(`${permission} key: search with options`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("prince", { limit: 1 }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 1); - expect(response.estimatedTotalHits).toEqual(2); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: search with limit and offset`, async () => { - const client = await getClient(permission); +describe.concurrent("`limit` and `offset` params", () => { + const params = { limit: 1, offset: 1 }; - const response = await client.index(index.uid).search("prince", { - limit: 1, - offset: 1, - }); + test.for([ + search, + searchGet, + multiSearch, + federatedMultiSearch, + searchSimilarDocuments, + searchSimilarDocumentsGet, + ])("with $name", async ({ searchMethod }) => { + const { limit, offset, estimatedTotalHits } = (await searchMethod( + params, + )) as SearchResultWithOffsetLimit; - expect(response).toHaveProperty("hits", [ - { - id: 4, - author: "J.K. Rowling", - title: "Harry Potter and the Half-Blood Prince", - comment: "The best book", - genre: ["fantasy", "adventure"], - }, - ]); - expect(response).toHaveProperty("offset", 1); - expect(response).toHaveProperty("limit", 1); - expect(response.estimatedTotalHits).toEqual(2); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.hitsPerPage).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.page).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.totalPages).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.totalHits).toBeUndefined(); + assert.typeOf(estimatedTotalHits, "number"); + assert.deepEqual({ limit, offset }, params); }); +}); - test(`${permission} key: search with matches parameter and small croplength`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - filter: 'title = "Le Petit Prince"', - attributesToCrop: ["*"], - cropLength: 5, - showMatchesPosition: true, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits[0]).toHaveProperty("_matchesPosition", { - comment: [{ start: 22, length: 6 }], - title: [{ start: 9, length: 6 }], - }); - }); +describe.concurrent("pagination params", () => { + const params = { page: 1, hitsPerPage: 1 }; + + test.for([search, searchGet, multiSearch])( + "with $name", + async ({ searchMethod }) => { + const { page, hitsPerPage, totalHits, totalPages } = (await searchMethod( + params, + )) as SearchResultWithPagination; + + assert.typeOf(totalHits, "number"); + assert.typeOf(totalPages, "number"); + assert.deepEqual({ page, hitsPerPage }, params); + }, + ); +}); - test(`${permission} key: search with all options but not all fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["id", "title"], - attributesToCrop: ["*"], - cropLength: 6, - attributesToHighlight: ["*"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 5); - expect(response.estimatedTotalHits).toEqual(1); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits[0]._formatted).toHaveProperty("title"); - expect(response.hits[0]._formatted).toHaveProperty("id"); - expect(response.hits[0]).not.toHaveProperty("comment"); - expect(response.hits[0]).not.toHaveProperty("description"); - expect(response.hits.length).toEqual(1); - expect(response.hits[0]).toHaveProperty("_formatted", expect.any(Object)); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]).toHaveProperty( - "_matchesPosition", - expect.any(Object), +describe.concurrent("params affecting only `hits` or `hit._formatted`", () => { + const params: SearchQuery = { + attributesToRetrieve: ["overview", "popularity"], + attributesToSearchOn: ["overview"], + attributesToCrop: ["overview"], + cropLength: 1, + cropMarker: "♥", + attributesToHighlight: ["overview"], + highlightPreTag: "☻", + highlightPostTag: "☺", + distinct: "popularity", + sort: ["popularity:asc"], + locales: ["eng"], + // needed for some formatting to fully take effect + q: "earth", + // to limit number of results + limit: 2, + }; + + test.for([search, searchGet, multiSearch, federatedMultiSearch])( + "with $name", + async ({ searchMethod }) => { + const { hits } = await searchMethod(params); + + for (const hit of hits) { + assert.lengthOf(Object.keys(hit), 3); + assert.typeOf(hit.overview, "string"); + assert.typeOf(hit.popularity, "number"); + + assert.isDefined(hit._formatted); + assert.lengthOf(Object.keys(hit._formatted), 2); + const { overview, popularity } = hit._formatted; + + assert.includeDeepMembers( + [ + { overview: "♥☻Earth☺♥", popularity: "34.945" }, + { overview: "♥☻earth☺♥", popularity: "41.947" }, + ], + [{ overview, popularity }], + ); + } + }, + ); + + // Does this affect the search result? + test(`with ${index.searchForFacetValues.name}`, async () => { + const { attributesToSearchOn, locales } = params; + + await assert.resolves( + index.searchForFacetValues({ + facetName: "popularity", + attributesToSearchOn, + locales, + }), ); }); - test(`${permission} key: search on default cropping parameters`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - attributesToCrop: ["*"], - cropLength: 6, - }); + test.for([searchSimilarDocuments, searchSimilarDocumentsGet])( + "with $name", + async ({ searchMethod }) => { + const { attributesToRetrieve } = params; - expect(response.hits[0]._formatted).toHaveProperty( - "comment", - "…book about a prince that walks…", - ); - }); - - test(`${permission} key: search on customized cropMarker`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - attributesToCrop: ["*"], - cropLength: 6, - cropMarker: "(ꈍᴗꈍ)", - }); + const { hits } = await searchMethod({ attributesToRetrieve }); - expect(response.hits[0]._formatted).toHaveProperty( - "comment", - "(ꈍᴗꈍ)book about a prince that walks(ꈍᴗꈍ)", - ); - }); + for (const hit of hits) { + assert.lengthOf(Object.keys(hit), 2); + assert.typeOf(hit.overview, "string"); + assert.typeOf(hit.popularity, "number"); + } + }, + ); +}); - test(`${permission} key: search on customized highlight tags`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - attributesToHighlight: ["*"], - highlightPreTag: "(⊃。•́‿•̀。)⊃ ", - highlightPostTag: " ⊂(´• ω •`⊂)", - }); +describe.concurrent("`facets` param", () => { + const params: SearchQuery = { + facets: ["genres", "popularity"], + // to limit number of results + filter: "popularity < 17", + }; + + test.for([search, searchGet, multiSearch, federatedMultiSearch])( + "with $name", + async ({ searchMethod }) => { + const { facetDistribution, facetStats } = await searchMethod(params); + assertFacetDistributionAndStatsAreCorrect(facetDistribution, facetStats); + }, + ); +}); - expect(response.hits[0]._formatted).toHaveProperty( - "comment", - "A french book about a (⊃。•́‿•̀。)⊃ prince ⊂(´• ω •`⊂) that walks on little cute planets", - ); +describe.concurrent("ranking score params", () => { + const params = { + showRankingScore: true, + showRankingScoreDetails: true, + rankingScoreThreshold: 0.5, + }; + + test.for([ + search, + searchGet, + multiSearch, + federatedMultiSearch, + searchSimilarDocuments, + searchSimilarDocumentsGet, + ])("with $name", async ({ searchMethod }) => { + const { hits } = await searchMethod(params); + + for (const { _rankingScore, _rankingScoreDetails } of hits) { + assert.typeOf(_rankingScore, "number"); + // TODO: This could be more thoroughly tested and typed + assert.typeOf(_rankingScoreDetails, "object"); + } }); - test(`${permission} key: search with all options and all fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["*"], - attributesToCrop: ["*"], - cropLength: 6, - attributesToHighlight: ["*"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 5); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - expect(response.hits[0]).toHaveProperty("_formatted", expect.any(Object)); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]).toHaveProperty( - "_matchesPosition", - expect.any(Object), - ); - }); + // Does this affect the search result? + test(`with ${index.searchForFacetValues.name}`, async () => { + const { rankingScoreThreshold } = params; - test(`${permission} key: search with all options but specific fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["id", "title"], - attributesToCrop: ["id", "title"], - cropLength: 6, - attributesToHighlight: ["id", "title"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 5); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - expect(response.hits[0]._formatted?.id).toEqual("456"); - expect(response.hits[0]).toHaveProperty("title", "Le Petit Prince"); - expect(response.hits[0]).not.toHaveProperty("comment"); - expect(response.hits[0]).toHaveProperty("_formatted", expect.any(Object)); - expect(response.hits[0]).not.toHaveProperty( - "description", - expect.any(Object), + await assert.resolves( + index.searchForFacetValues({ + facetName: "genres", + rankingScoreThreshold, + }), ); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]._formatted).not.toHaveProperty("comment"); - expect(response.hits[0]).toHaveProperty( - "_matchesPosition", - expect.any(Object), - ); - }); - - test(`${permission} key: Search with specific fields in attributesToHighlight and check for types of number fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - attributesToHighlight: ["title"], - }); - expect(response.hits[0]._formatted?.id).toEqual("456"); - expect(response.hits[0]._formatted?.isNull).toEqual(null); - expect(response.hits[0]._formatted?.isTrue).toEqual(true); - }); - - test(`${permission} key: Search with specific fields in attributesToHighlight and check for types of number fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - attributesToHighlight: ["title", "id"], - }); - expect(response.hits[0]._formatted?.id).toEqual("456"); - expect(response.hits[0]._formatted?.isNull).toEqual(null); - expect(response.hits[0]._formatted?.isTrue).toEqual(true); - }); - - test(`${permission} key: search with filter and facetDistribution`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("a", { - filter: ["genre = romance"], - facets: ["genre", "id"], - }); - - expect(response).toHaveProperty("facetDistribution", { - genre: { romance: 2 }, - id: { "123": 1, "2": 1 }, - }); - - expect(response.facetStats).toEqual({ id: { min: 2, max: 123 } }); - expect(response.facetStats?.["id"]?.max).toBe(123); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: search with filter on number`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("a", { - filter: "id < 0", - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(0); - }); - - test(`${permission} key: search with filter with spaces`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("h", { - filter: ['genre = "sci fi"'], - }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: search with multiple filter`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("a", { - filter: ["genre = romance", ["genre = romance", "genre = romance"]], - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { romance: 2 }, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: search with multiple filter and undefined query (placeholder)`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search(undefined, { - filter: ["genre = fantasy"], - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { adventure: 3, fantasy: 3 }, - }); - expect(response.hits.length).toEqual(3); - }); - - test(`${permission} key: search with multiple filter and null query (placeholder)`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search(null, { - filter: ["genre = fantasy"], - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { adventure: 3, fantasy: 3 }, - }); - expect(response.hits.length).toEqual(3); - }); - - test(`${permission} key: search with multiple filter and empty string query (placeholder)`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("", { - filter: ["genre = fantasy"], - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { adventure: 3, fantasy: 3 }, - }); - expect(response.hits.length).toEqual(3); - }); - - test(`${permission} key: search with pagination parameters: hitsPerPage and page`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 1, - page: 1, - }); - - expect(response.hits.length).toEqual(1); - expect(response.totalPages).toEqual(8); - expect(response.hitsPerPage).toEqual(1); - expect(response.page).toEqual(1); - expect(response.totalHits).toEqual(8); - }); - - test(`${permission} key: search with pagination parameters: hitsPerPage at 0 and page at 1`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 0, - page: 1, - }); - - expect(response.hits.length).toEqual(0); - expect(response.hitsPerPage).toEqual(0); - expect(response.page).toEqual(1); - expect(response.totalPages).toEqual(0); - expect(response.totalHits).toEqual(8); - }); - - test(`${permission} key: search with pagination parameters: hitsPerPage at 0`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 0, - }); - - expect(response.hits.length).toEqual(0); - expect(response.hitsPerPage).toEqual(0); - expect(response.page).toEqual(1); - expect(response.totalPages).toEqual(0); - expect(response.totalHits).toEqual(8); - // @ts-expect-error Not present in the SearchResponse type because `page` and/or `hitsPerPage` is provided in the search params. - expect(response.limit).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because `page` and/or `hitsPerPage` is provided in the search params. - expect(response.offset).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because `page` and/or `hitsPerPage` is provided in the search params. - expect(response.estimatedTotalHits).toBeUndefined(); - }); - - test(`${permission} key: search with pagination parameters: hitsPerPage at 1 and page at 0`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 1, - page: 0, - }); - - expect(response.hits.length).toEqual(0); - expect(response.hitsPerPage).toEqual(1); - expect(response.page).toEqual(0); - expect(response.totalPages).toEqual(8); - expect(response.totalHits).toEqual(8); - }); - - test(`${permission} key: search with pagination parameters: page at 0`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - page: 0, - }); - - expect(response.hits.length).toEqual(0); - expect(response.hitsPerPage).toEqual(20); - expect(response.page).toEqual(0); - expect(response.totalPages).toEqual(1); - expect(response.totalHits).toEqual(8); - }); - - test(`${permission} key: search with pagination parameters: hitsPerPage at 0 and page at 0`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 0, - page: 0, - }); - - expect(response.hits.length).toEqual(0); - - // @ts-expect-error Property not existing on type - expect(response.limit).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.offset).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.estimatedTotalHits).toBeUndefined(); - - expect(response.hitsPerPage).toEqual(0); - expect(response.page).toEqual(0); - expect(response.totalPages).toEqual(0); - expect(response.totalHits).toEqual(8); - }); - - test(`${permission} key: search with pagination parameters hitsPerPage/page and offset/limit`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 1, - page: 1, - offset: 1, - limit: 1, - }); - - expect(response.hits.length).toEqual(1); - // @ts-expect-error Property not existing on type - expect(response.limit).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.offset).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.estimatedTotalHits).toBeUndefined(); - expect(response.hitsPerPage).toEqual(1); - expect(response.page).toEqual(1); - expect(response.totalPages).toEqual(8); - expect(response.totalHits).toEqual(8); - }); - - test(`${permission} key: search with pagination parameters hitsPerPage/page and offset`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 1, - page: 1, - offset: 1, - }); - - expect(response.hits.length).toEqual(1); - // @ts-expect-error Property not existing on type - expect(response.limit).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.offset).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.estimatedTotalHits).toBeUndefined(); - expect(response.hitsPerPage).toEqual(1); - expect(response.page).toEqual(1); - expect(response.totalHits).toEqual(8); - expect(response.totalPages).toEqual(8); - }); - - test(`${permission} key: search with pagination parameters hitsPerPage/page and limit`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 1, - page: 1, - limit: 1, - }); - - expect(response.hits.length).toEqual(1); - // @ts-expect-error Property not existing on type - expect(response.limit).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.offset).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.estimatedTotalHits).toBeUndefined(); - expect(response.page).toEqual(1); - expect(response.hitsPerPage).toEqual(1); - expect(response.totalPages).toEqual(8); - expect(response.totalHits).toEqual(8); - }); - - test(`${permission} key: search on index with no documents and no primary key`, async () => { - const client = await getClient(permission); - const response = await client.index(emptyIndex.uid).search("prince", {}); - - expect(response).toHaveProperty("hits", []); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(0); - }); - - test(`${permission} key: search without vectors`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", {}); - - expect(response).not.toHaveProperty("semanticHitCount"); - }); - - test(`${permission} key: search with distinct`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("", { distinct: "author" }); - - expect(response.hits.length).toEqual(7); - }); - - test(`${permission} key: search with retrieveVectors to true`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince", { - retrieveVectors: true, - }); - - expect(response).toHaveProperty("hits", expect.any(Array)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits[0]).toHaveProperty("_vectors"); - }); - - test(`${permission} key: search without retrieveVectors`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince"); - - expect(response).toHaveProperty("hits", expect.any(Array)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits[0]).not.toHaveProperty("_vectors"); - }); - - test(`${permission} key: Search with locales`, async () => { - const client = await getClient(permission); - const masterClient = await getClient("Master"); - - await masterClient - .index(index.uid) - .updateLocalizedAttributes([ - { attributePatterns: ["title", "comment"], locales: ["fra", "eng"] }, - ]) - .waitTask(); - - const searchResponse = await client.index(index.uid).search("french", { - locales: ["fra", "eng"], - }); - - expect(searchResponse.hits.length).toEqual(2); - }); - - test(`${permission} key: matches position contain indices`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("fantasy", { - showMatchesPosition: true, - }); - expect(response.hits[0]._matchesPosition).toEqual({ - genre: [{ start: 0, length: 7, indices: [0] }], - }); - }); - - // This test deletes the index, so following tests may fail if they need an existing index - test(`${permission} key: Try to search on deleted index and fail`, async () => { - const client = await getClient(permission); - const masterClient = await getClient("Master"); - await masterClient.index(index.uid).delete().waitTask(); - - await expect( - client.index(index.uid).search("prince", {}), - ).rejects.toHaveProperty("cause.code", ErrorStatusCode.INDEX_NOT_FOUND); }); }); -describe.each([{ permission: "No" }])( - "Test failing test on search", - ({ permission }) => { - beforeAll(async () => { - const client = await getClient("Master"); - await client.createIndex(index.uid).waitTask(); - }); - - test(`${permission} key: Try Basic search and be denied`, async () => { - const client = await getClient(permission); - await expect( - client.index(index.uid).search("prince"), - ).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, +describe.concurrent("`showMatchesPosition` param", () => { + const params: SearchQuery = { + showMatchesPosition: true, + // required to get matches + q: "apple", + // to limit number of results + limit: 1, + }; + + test.for([search, searchGet, multiSearch, federatedMultiSearch])( + "with $name", + async ({ searchMethod }) => { + const { hits } = await searchMethod(params); + + assert.deepEqual( + hits.map((hit) => hit._matchesPosition), + [ + { + "providers.buy.name": [{ indices: [0], length: 5, start: 0 }], + "providers.rent.name": [{ indices: [0], length: 5, start: 0 }], + }, + ], ); - }); + }, + ); +}); + +const possibleMatchingStrategies = Object.keys({ + last: null, + all: null, + frequency: null, + // record because cannot convert union to tuple (https://github.com/microsoft/TypeScript/issues/42857) +} satisfies { [TKey in MatchingStrategy]: null }) as MatchingStrategy[]; + +describe.concurrent.for(possibleMatchingStrategies)( + "`matchingStrategy` = `%s` param", + (matchingStrategy) => { + test.for([search, searchGet, multiSearch, federatedMultiSearch])( + "with $name", + async ({ searchMethod }) => { + await assert.resolves(searchMethod({ matchingStrategy })); + }, + ); - test(`${permission} key: Try multi search and be denied`, async () => { - const client = await getClient(permission); - await expect(client.multiSearch({ queries: [] })).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, + // Does this affect the search result? + test(`with ${index.searchForFacetValues.name}`, async () => { + await assert.resolves( + index.searchForFacetValues({ + facetName: "genres", + matchingStrategy, + }), ); }); }, ); -describe.each([{ permission: "Master" }])( - "Tests on documents with nested objects", - ({ permission }) => { - beforeEach(async () => { - await clearAllIndexes(config); - const client = await getClient("Master"); - await client.createIndex(index.uid); - - await client.index(index.uid).addDocuments(datasetWithNests).waitTask(); - }); - - test(`${permission} key: search on nested content with no parameters`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("An awesome", {}); - - expect(response.hits[0]).toEqual({ - id: 5, - title: "The Hobbit", - info: { - comment: "An awesome book", - reviewNb: 900, - }, - }); - }); - - test(`${permission} key: search on nested content with searchable on specific nested field`, async () => { - const client = await getClient(permission); - await client - .index(index.uid) - .updateSettings({ - searchableAttributes: ["title", "info.comment"], - }) - .waitTask(); +// TODO: Another filter with geo filtering +const filterExpressions: [name: string, filter: FilterExpression][] = [ + ["string", `release_date < ${Date.parse("1998-01-01")} AND id = 607`], + ["array", [[`release_date < ${Date.parse("1998-01-01")}`], ["id = 607"]]], +]; - const response = await client.index(index.uid).search("An awesome", {}); +describe.concurrent.for(filterExpressions)( + "%s `filter` param", + ([, filter]) => { + test.for([search, searchGet, multiSearch, federatedMultiSearch])( + "with $name", + async ({ searchMethod }) => { + const { hits } = await searchMethod({ filter }); + + assert.deepEqual( + hits.map((v) => v.id), + [607], + ); + }, + ); - expect(response.hits[0]).toEqual({ - id: 5, - title: "The Hobbit", - info: { - comment: "An awesome book", - reviewNb: 900, - }, - }); + test(`with ${index.searchForFacetValues.name}`, async () => { + await assert.resolves( + index.searchForFacetValues({ + facetName: "genres", + filter, + }), + ); }); - test(`${permission} key: search on nested content with sort`, async () => { - const client = await getClient(permission); - await client - .index(index.uid) - .updateSettings({ - searchableAttributes: ["title", "info.comment"], - sortableAttributes: ["info.reviewNb"], - }) - .waitTask(); - - const response = await client.index(index.uid).search("", { - sort: ["info.reviewNb:desc"], - }); - - expect(response.hits[0]).toEqual({ - id: 6, - title: "Harry Potter and the Half-Blood Prince", - info: { - comment: "The best book", - reviewNb: 1000, - }, - }); - }); + test.for([searchSimilarDocuments, searchSimilarDocumentsGet])( + "with $name", + async ({ searchMethod }) => { + await assert.resolves(searchMethod({ filter })); + }, + ); }, ); -describe.each([ - { permission: "Master" }, - { permission: "Admin" }, - { permission: "Search" }, -])("Test on abortable search", ({ permission }) => { - beforeAll(async () => { - const client = await getClient("Master"); - await clearAllIndexes(config); - await client.createIndex(index.uid).waitTask(); - }); +describe.concurrent("`filter` param with geo", () => { + const filter = "_geoRadius(45.472735, 9.184019, 2000)"; - test(`${permission} key: search on index and abort`, async () => { - const controller = new AbortController(); - const client = await getClient(permission); - const searchPromise = client - .index(index.uid) - .search("unreachable", {}, { signal: controller.signal }); + test.for([search, searchGet, multiSearch, federatedMultiSearch])( + "with $name", + async ({ searchMethod }) => { + const { hits } = await searchMethod({ filter }); - controller.abort(); - - searchPromise.catch((error) => { - expect(error).toHaveProperty( - "cause.message", - "This operation was aborted", + assert.sameMembers( + hits.map((v) => v.id), + [95, 97], ); - }); - }); - - test(`${permission} key: search on index multiple times, and abort only one request`, async () => { - const ind = (await getClient(permission)).index(index.uid); - const controllerA = new AbortController(); - const controllerB = new AbortController(); - const controllerC = new AbortController(); - const searchQuery = "prince"; - - const searchAPromise = ind.search( - searchQuery, - {}, - { signal: controllerA.signal }, - ); - - const searchBPromise = ind.search( - searchQuery, - {}, - { signal: controllerB.signal }, - ); - - const searchCPromise = ind.search( - searchQuery, - {}, - { signal: controllerC.signal }, - ); - - const searchDPromise = ind.search(searchQuery, {}); - - controllerB.abort(); - - const [a, b, c, d] = await Promise.allSettled([ - searchAPromise, - searchBPromise, - searchCPromise, - searchDPromise, - ]); - - expect(a).toHaveProperty("value.query", searchQuery); - expect(b).toHaveProperty( - "reason.cause.message", - "This operation was aborted", + }, + ); + + test(`with ${index.searchForFacetValues.name}`, async () => { + await assert.resolves( + index.searchForFacetValues({ + facetName: "genres", + filter, + }), ); - expect(d).toHaveProperty("value.query", searchQuery); - expect(c).toHaveProperty("value.query", searchQuery); }); - test(`${permission} key: search should be aborted when reaching timeout`, async () => { - const key = await getKey(permission); - const client = new MeiliSearch({ - ...config, - apiKey: key, - timeout: 1, - }); - - const error = await assert.rejects( - client.health(), - MeiliSearchRequestError, - ); + test.for([searchSimilarDocuments, searchSimilarDocumentsGet])( + "with $name", + async ({ searchMethod }) => { + await assert.resolves(searchMethod({ filter })); + }, + ); +}); - assert.strictEqual( - (error.cause as Error)?.message, - "request timed out after 1ms", - ); - }); +test.concurrent(`${federatedMultiSearch.name} method`, async () => { + const queries: SearchQueryWithIndexAndFederation[] = [ + { + indexUid: INDEX_UID, + q: "earth", + federationOptions: { + weight: 1, + remote: INDEX_UID, + queryPosition: 1, + }, + }, + ]; + const federation: Federation = { + facetsByIndex: { [INDEX_UID]: ["genres", "popularity"] }, + }; - test(`${permission} key: search should be aborted on abort signal`, async () => { - const key = await getKey(permission); - const client = new MeiliSearch({ - ...config, - apiKey: key, - timeout: 1_000, + const { hits, facetsByIndex, remoteErrors } = + await client.federatedMultiSearch({ + queries, + federation, }); - const someErrorObj = {}; - - const ac = new AbortController(); - ac.abort(someErrorObj); - const error = await assert.rejects( - client.multiSearch( - { queries: [{ indexUid: "doesn't matter" }] }, - { signal: ac.signal }, - ), - MeiliSearchRequestError, - ); - assert.strictEqual(error.cause, someErrorObj); + for (const { + _federation: { weightedRankingScore, ..._federation }, + } of hits) { + assert.typeOf(weightedRankingScore, "number"); + assert.deepEqual(_federation, { + indexUid: INDEX_UID, + queriesPosition: 1, + remote: INDEX_UID, + }); + } - // and now with a delayed abort, for this we have to stub fetch - vi.stubGlobal( - "fetch", - (_: unknown, requestInit?: RequestInit) => - new Promise((_, reject) => - requestInit?.signal?.addEventListener("abort", () => - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(requestInit.signal?.reason), - ), - ), - ); + assert.sameMembers(Object.keys(facetsByIndex), [INDEX_UID]); + const { distribution, stats } = facetsByIndex[INDEX_UID]; + assertFacetDistributionAndStatsAreCorrect(distribution, stats); - try { - const ac = new AbortController(); + // TODO: Maybe could get an error response for this, to validate it against + assert.deepEqual(remoteErrors, {}); - const promise = client.multiSearch( - { queries: [{ indexUid: "doesn't matter" }] }, - { signal: ac.signal }, - ); - setTimeout(() => ac.abort(someErrorObj), 1); - const error = await assert.rejects(promise, MeiliSearchRequestError); - assert.strictEqual(error.cause, someErrorObj); - } finally { - vi.unstubAllGlobals(); - } + const { facetDistribution, facetStats } = await client.federatedMultiSearch({ + queries, + federation: { ...federation, mergeFacets: { maxValuesPerFacet: 100 } }, }); + + assert.deepEqual(facetDistribution, distribution); + assert.deepEqual(facetStats, stats); }); -describe.each([ - { host: BAD_HOST, trailing: false }, - { host: `${BAD_HOST}/api`, trailing: false }, - { host: `${BAD_HOST}/trailing/`, trailing: true }, -])("Tests on url construction", ({ host, trailing }) => { - test(`get search route`, async () => { - const route = `indexes/${index.uid}/search`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.index(index.uid).search()).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); +// TODO: +function assertSomething(hits: SearchHit[]) { + for (const { _vectors } of hits) { + assert.lengthOf(Object.keys(_vectors), 1); + const { + default: { embeddings, ...restOfObj }, + } = _vectors as Record; + + for (const embedding of embeddings) { + assert(Array.isArray(embedding)); + for (const embeddingElement of embedding) { + assert.typeOf(embeddingElement, "number"); + } + } - test(`post search route`, async () => { - const route = `indexes/${index.uid}/search`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.index(index.uid).search()).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); + assert.deepEqual(restOfObj, { regenerate: false }); + } +} + +describe.concurrent("embedding related params", () => { + const params: SearchQuery = { + q: "", + vector: [1], + hybrid: { semanticRatio: 1.0, embedder: "default" }, + retrieveVectors: true, + }; + + test.for([search, searchGet, multiSearch, federatedMultiSearch])( + "with $name", + async ({ searchMethod }) => { + const { semanticHitCount, hits } = await searchMethod(params); + + assert.typeOf(semanticHitCount, "number"); + assertSomething(hits); + }, + ); + + test.for([searchSimilarDocuments, searchSimilarDocumentsGet])( + "with $name", + async ({ searchMethod }) => { + const { retrieveVectors } = params; + const { hits } = await searchMethod({ retrieveVectors }); + + assertSomething(hits); + }, + ); }); -afterAll(() => { - return clearAllIndexes(config); -}); +test.todo("abortable http request"); diff --git a/tests/typed_search.test.ts b/tests/typed_search.test.ts deleted file mode 100644 index a34381989..000000000 --- a/tests/typed_search.test.ts +++ /dev/null @@ -1,511 +0,0 @@ -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "vitest"; -import { ErrorStatusCode, type SearchResponse } from "../src/types/index.js"; -import { - clearAllIndexes, - config, - BAD_HOST, - MeiliSearch, - getClient, - datasetWithNests, -} from "./utils/meilisearch-test-utils.js"; - -const index = { - uid: "movies_test", -}; -const emptyIndex = { - uid: "empty_test", -}; - -interface Movie { - id: number; - title: string; - comment?: string; - genre?: string[]; - isNull?: null; - isTrue?: boolean; -} - -interface NestedDocument { - id: number; - title: string; - info: { - comment?: string; - reviewNb?: number; - }; -} - -const dataset = [ - { - id: 123, - title: "Pride and Prejudice", - author: "Jane Austen", - comment: "A great book", - genre: ["romance"], - }, - { - id: 456, - title: "Le Petit Prince", - author: "Antoine de Saint-Exupéry", - comment: "A french book about a prince that walks on little cute planets", - genre: ["adventure"], - isNull: null, - isTrue: true, - }, - { - id: 2, - title: "Le Rouge et le Noir", - author: "Stendhal", - comment: "Another french book", - genre: ["romance"], - }, - { - id: 1, - title: "Alice In Wonderland", - author: "Lewis Carroll", - comment: "A weird book", - genre: ["adventure"], - }, - { - id: 1344, - title: "The Hobbit", - author: "J.R.R. Tolkien", - comment: "An awesome book", - genre: ["fantasy", "adventure"], - }, - { - id: 4, - title: "Harry Potter and the Half-Blood Prince", - author: "J.K. Rowling", - comment: "The best book", - genre: ["fantasy", "adventure"], - }, - { - id: 5, - title: "Harry Potter and the Deathly Hallows", - author: "J.K. Rowling", - genre: ["fantasy", "adventure"], - }, - { - id: 42, - title: "The Hitchhiker's Guide to the Galaxy", - author: "Douglas Adams", - genre: ["sci fi", "comedy"], - }, -]; - -afterAll(() => { - return clearAllIndexes(config); -}); - -describe.each([ - { permission: "Master" }, - { permission: "Admin" }, - { permission: "Search" }, -])("Test on search", ({ permission }) => { - beforeAll(async () => { - const client = await getClient("Master"); - await clearAllIndexes(config); - - await client.createIndex(index.uid).waitTask(); - await client.createIndex(emptyIndex.uid).waitTask(); - - const newFilterableAttributes = ["genre", "title"]; - await client - .index(index.uid) - .updateFilterableAttributes(newFilterableAttributes) - .waitTask(); - - await client.index(index.uid).addDocuments(dataset).waitTask(); - }); - - test(`${permission} key: Basic search`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", {}); - expect(response.hits.length === 2).toBeTruthy(); - expect(response.limit === 20).toBeTruthy(); - expect(response.offset === 0).toBeTruthy(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response.query === "prince").toBeTruthy(); - }); - - test(`${permission} key: Search with query in searchParams`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("other", { q: "prince" }); // ensures `q` is a valid field in SearchParams type - - expect(response).toHaveProperty("query", "prince"); - }); - - test(`${permission} key: Search with options`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("prince", { limit: 1 }); - expect(response.hits.length === 1).toBeTruthy(); - expect(response.offset === 0).toBeTruthy(); - expect(response.limit === 1).toBeTruthy(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response.query === "prince").toBeTruthy(); - }); - - test(`${permission} key: Search with limit and offset`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - limit: 1, - offset: 1, - }); - expect(response.hits.length === 1).toBeTruthy(); - expect(response.offset === 1).toBeTruthy(); - // expect(response.bloub).toEqual(0) -> ERROR, bloub does not exist on type Response - expect(response.limit === 1).toBeTruthy(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response.query === "prince").toBeTruthy(); - expect(response.hits[0].id).toEqual(4); - expect(response.hits[0].title).toEqual( - "Harry Potter and the Half-Blood Prince", - ); - expect(response.hits[0].comment).toEqual("The best book"); - expect(response.hits[0].genre).toEqual(["fantasy", "adventure"]); - expect(response.query === "prince").toBeTruthy(); - }); - - test(`${permission} key: Search with matches parameter and small croplength`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - filter: 'title = "Le Petit Prince"', - attributesToCrop: ["*"], - cropLength: 5, - showMatchesPosition: true, - }); - expect(response.hits.length === 1).toBeTruthy(); - expect(response.hits[0]?._matchesPosition?.comment).toEqual([ - { start: 22, length: 6 }, - ]); - expect(response.hits[0]?._matchesPosition?.title).toEqual([ - { start: 9, length: 6 }, - ]); - }); - - test(`${permission} key: Search with all options but not all fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["id", "title"], - attributesToCrop: ["*"], - cropLength: 6, - attributesToHighlight: ["*"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response.hits.length === 1).toBeTruthy(); - expect(response.offset === 0).toBeTruthy(); - expect(response.limit === 5).toBeTruthy(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response.query === "prince").toBeTruthy(); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]._formatted?.id).toEqual("456"); - expect(response.hits[0]).not.toHaveProperty("comment"); - expect(response.hits[0]).not.toHaveProperty("description"); - expect(response.hits[0]._formatted).toHaveProperty("comment"); - expect(response.hits[0]._formatted).not.toHaveProperty("description"); - expect(response.hits.length === 1).toBeTruthy(); - expect(response.hits[0]).toHaveProperty( - "_matchesPosition", - expect.any(Object), - ); - }); - - test(`${permission} key: Search with all options and all fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["*"], - attributesToCrop: ["*"], - cropLength: 6, - attributesToHighlight: ["*"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response.hits.length === 1).toBeTruthy(); - expect(response.offset === 0).toBeTruthy(); - expect(response.limit === 5).toBeTruthy(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response.query === "prince").toBeTruthy(); - expect(response.hits[0]?.title === "Le Petit Prince").toBeTruthy(); - expect( - response.hits[0]?._matchesPosition?.title?.[0]?.start === 9, - ).toBeTruthy(); - expect( - response.hits[0]?._matchesPosition?.title?.[0]?.length === 6, - ).toBeTruthy(); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - }); - - test(`${permission} key: Search with all options but specific fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["id", "title"], - attributesToCrop: ["id", "title"], - cropLength: 6, - attributesToHighlight: ["id", "title"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response.hits.length === 1).toBeTruthy(); - expect(response.offset === 0).toBeTruthy(); - expect(response.limit === 5).toBeTruthy(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response.query === "prince").toBeTruthy(); - - expect(response.hits[0].id).toEqual(456); - expect(response.hits[0].title).toEqual("Le Petit Prince"); - // ERROR Property 'comment' does not exist on type 'Hit>'. - // expect(response.hits[0].comment).toEqual('comment') - - expect(response.hits[0]?.title === "Le Petit Prince").toBeTruthy(); - expect(response.hits[0]?._matchesPosition?.title).toEqual([ - { start: 9, length: 6 }, - ]); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]).not.toHaveProperty( - "description", - expect.any(Object), - ); - expect(response.hits[0]._formatted).not.toHaveProperty("comment"); - }); - - test(`${permission} key: Search with specific fields in attributesToHighlight and check for types of number fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - attributesToHighlight: ["title"], - }); - expect(response.hits[0]._formatted?.id).toEqual("456"); - expect(response.hits[0]._formatted?.isNull).toEqual(null); - expect(response.hits[0]._formatted?.isTrue).toEqual(true); - }); - - test(`${permission} key: Search with specific fields in attributesToHighlight and check for types of number fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - attributesToHighlight: ["title", "id"], - }); - expect(response.hits[0]._formatted?.id).toEqual("456"); - expect(response.hits[0]._formatted?.isNull).toEqual(null); - expect(response.hits[0]._formatted?.isTrue).toEqual(true); - }); - - test(`${permission} key: Search with filter and facets`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("a", { - filter: ["genre=romance"], - facets: ["genre"], - }); - expect(response.facetDistribution?.genre?.romance === 2).toBeTruthy(); - expect(response.hits.length === 2).toBeTruthy(); - }); - - test(`${permission} key: Search with filter with spaces`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("h", { - filter: ['genre="sci fi"'], - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length === 1).toBeTruthy(); - }); - - test(`${permission} key: Search with multiple filter`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("a", { - filter: ["genre=romance", ["genre=romance", "genre=romance"]], - facets: ["genre"], - }); - expect(response.facetDistribution?.genre?.romance === 2).toBeTruthy(); - expect(response.hits.length === 2).toBeTruthy(); - }); - - test(`${permission} key: Search with multiple filter and placeholder search using undefined`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search(undefined, { - filter: ["genre = fantasy"], - facets: ["genre"], - }); - expect(response.facetDistribution?.genre?.fantasy === 3).toBeTruthy(); - expect(response.hits.length === 3).toBeTruthy(); - }); - - test(`${permission} key: Search with multiple filter and placeholder search using NULL`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search(null, { - filter: ["genre = fantasy"], - facets: ["genre"], - }); - expect(response.facetDistribution?.genre?.fantasy === 3).toBeTruthy(); - expect(response.hits.length === 3).toBeTruthy(); - }); - - test(`${permission} key: Search on index with no documents and no primary key`, async () => { - const client = await getClient(permission); - const response = await client.index(emptyIndex.uid).search("prince", {}); - - expect(response.hits.length === 0).toBeTruthy(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response.query === "prince").toBeTruthy(); - }); - - test(`${permission} key: search with pagination parameters hitsPerPage/page and offset`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 1, - page: 1, - limit: 1, - }); - - expect(response.hits.length).toEqual(1); - expect(response.hitsPerPage === 1).toBeTruthy(); - expect(response.page === 1).toBeTruthy(); - expect(response.totalPages === 8).toBeTruthy(); - expect(response.totalHits === 8).toBeTruthy(); - }); - - test(`${permission} key: Try to Search on deleted index and fail`, async () => { - const client = await getClient(permission); - const masterClient = await getClient("Master"); - await masterClient.index(index.uid).delete().waitTask(); - - await expect( - client.index(index.uid).search("prince"), - ).rejects.toHaveProperty("cause.code", ErrorStatusCode.INDEX_NOT_FOUND); - }); -}); - -describe.each([{ permission: "Master" }])( - "Tests on documents with nested objects", - ({ permission }) => { - beforeEach(async () => { - await clearAllIndexes(config); - const client = await getClient("Master"); - await client.createIndex(index.uid); - - await client.index(index.uid).addDocuments(datasetWithNests).waitTask(); - }); - - test(`${permission} key: search on nested content with no parameters`, async () => { - const client = await getClient(permission); - const response: SearchResponse = await client - .index(index.uid) - .search("An awesome", {}); - - expect(response.hits[0].info?.comment === "An awesome book").toBeTruthy(); - expect(response.hits[0].info?.reviewNb === 900).toBeTruthy(); - }); - - test(`${permission} key: search on nested content with searchable on specific nested field`, async () => { - const client = await getClient(permission); - await client - .index(index.uid) - .updateSettings({ - searchableAttributes: ["title", "info.comment"], - }) - .waitTask(); - - const response: SearchResponse = await client - .index(index.uid) - .search("An awesome", {}); - - expect(response.hits[0].info?.comment === "An awesome book").toBeTruthy(); - expect(response.hits[0].info?.reviewNb === 900).toBeTruthy(); - }); - - test(`${permission} key: search on nested content with sort`, async () => { - const client = await getClient(permission); - await client - .index(index.uid) - .updateSettings({ - searchableAttributes: ["title", "info.comment"], - sortableAttributes: ["info.reviewNb"], - }) - .waitTask(); - - const response: SearchResponse = await client - .index(index.uid) - .search("", { - sort: ["info.reviewNb:desc"], - }); - - expect(response.hits[0].info?.comment === "The best book").toBeTruthy(); - expect(response.hits[0].info?.reviewNb === 1000).toBeTruthy(); - }); - }, -); - -describe.each([{ permission: "No" }])( - "Test failing test on search", - ({ permission }) => { - beforeEach(async () => { - await clearAllIndexes(config); - }); - - test(`${permission} key: Try Basic search and be denied`, async () => { - const client = await getClient(permission); - await expect( - client.index(index.uid).search("prince"), - ).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, - ); - }); - }, -); - -describe.each([ - { host: BAD_HOST, trailing: false }, - { host: `${BAD_HOST}/api`, trailing: false }, - { host: `${BAD_HOST}/trailing/`, trailing: true }, -])("Tests on url construction", ({ host, trailing }) => { - test(`get search route`, async () => { - const route = `indexes/${index.uid}/search`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect( - client.index(index.uid).search(), - ).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); - - test(`post search route`, async () => { - const route = `indexes/${index.uid}/search`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect( - client.index(index.uid).search(), - ).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); -}); diff --git a/tests/utils/meilisearch-test-utils.ts b/tests/utils/meilisearch-test-utils.ts index 39fc66b2b..a0ebff79e 100644 --- a/tests/utils/meilisearch-test-utils.ts +++ b/tests/utils/meilisearch-test-utils.ts @@ -93,6 +93,7 @@ function decode64(buff: string) { } const NOT_RESOLVED = Symbol(""); +const RESOLVED = Symbol(""); const source = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -127,6 +128,13 @@ const source = { "expected value to not resolve", ); }, + async resolves(promise: Promise): Promise { + try { + await promise; + } catch (error) { + vitestAssert.fail(error, RESOLVED, "expected value to not reject"); + } + }, }; export const assert: typeof vitestAssert & typeof source = Object.assign( vitestAssert, diff --git a/tests/utils/search.ts b/tests/utils/search.ts new file mode 100644 index 000000000..cd689f0eb --- /dev/null +++ b/tests/utils/search.ts @@ -0,0 +1,147 @@ +import { assert, getClient } from "./meilisearch-test-utils.js"; +import type { Film } from "./test-data/films.js"; +import type { + FacetStats, + MeiliSearch, + SearchHit, + SearchQuery, + SearchQueryWithOffsetLimit, + SearchResult, +} from "../../src/index.js"; + +export const INDEX_UID = "b568151e-ddb8-455e-85b9-871e8d57c6b7"; +export const client = await getClient("Master"); +export const index = client.index(INDEX_UID); + +export const [ + search, + searchGet, + multiSearch, + federatedMultiSearch, + searchSimilarDocuments, + searchSimilarDocumentsGet, +] = [ + { name: index.search.name, searchMethod: index.search.bind(index) }, + { + name: index.searchGet.name, + searchMethod: (searchQuery) => { + const { hybrid, ...rest } = searchQuery; + + return index.searchGet( + hybrid == null + ? rest + : { + ...rest, + hybridSemanticRatio: hybrid.semanticRatio, + hybridEmbedder: hybrid.embedder, + }, + ); + }, + }, + { + name: "multiSearch" satisfies keyof Pick, + searchMethod: async (searchQuery) => { + const { results } = await client.multiSearch({ + queries: [{ indexUid: INDEX_UID, ...searchQuery }], + }); + + assert.lengthOf(results, 1); + const [{ indexUid, hits, ...result }] = results; + assert.strictEqual(indexUid, INDEX_UID); + + return { ...result, hits: hits as SearchHit[] }; + }, + }, + { + name: "federatedMultiSearch" satisfies keyof Pick< + MeiliSearch, + "federatedMultiSearch" + >, + searchMethod: async (searchQuery) => { + const { offset, limit, facets, ...restOfSearchQuery } = + searchQuery as SearchQueryWithOffsetLimit; + + const { hits, ...result } = await client.federatedMultiSearch({ + queries: [{ indexUid: INDEX_UID, ...restOfSearchQuery }], + federation: { + offset: offset ?? undefined, + limit: limit ?? undefined, + facetsByIndex: { [INDEX_UID]: facets ?? null }, + mergeFacets: {}, + }, + }); + + return { + ...result, + hits: hits.map(({ _federation, ...hit }) => hit as SearchHit), + }; + }, + }, + { + name: index.searchSimilarDocuments.name, + searchMethod: async ({ + offset, + limit, + ...searchQuery + }: SearchQueryWithOffsetLimit) => { + const { hits, ...result } = await index.searchSimilarDocuments({ + id: 607, + embedder: "default", + offset: offset ?? undefined, + limit: limit ?? undefined, + ...searchQuery, + }); + + return { + ...result, + query: searchQuery.q ?? "", + hits: hits as SearchHit[], + }; + }, + }, + { + name: index.searchSimilarDocumentsGet.name, + searchMethod: async ({ + offset, + limit, + ...searchQuery + }: SearchQueryWithOffsetLimit) => { + const { hits, ...result } = await index.searchSimilarDocumentsGet({ + id: 607, + embedder: "default", + offset: offset ?? undefined, + limit: limit ?? undefined, + ...searchQuery, + }); + + return { + ...result, + query: searchQuery.q ?? "", + hits: hits as SearchHit[], + }; + }, + }, +] as const satisfies { + name: string; + searchMethod: (searchQuery: SearchQuery) => Promise>; +}[]; + +export function assertFacetDistributionAndStatsAreCorrect( + distribution?: Record>, + stats?: Record, +) { + assert.isDefined(distribution); + for (const indDist of Object.values(distribution)) { + for (const val of Object.values(indDist)) { + assert.typeOf(val, "number"); + } + } + + assert.isDefined(stats); + for (const val of Object.values(stats)) { + assert.sameMembers(Object.keys(val), ["min", "max"]); + const { min, max } = val; + assert.typeOf(min, "number"); + assert.typeOf(max, "number"); + } +} diff --git a/tests/utils/test-data/films.ts b/tests/utils/test-data/films.ts new file mode 100644 index 000000000..e8d1a434e --- /dev/null +++ b/tests/utils/test-data/films.ts @@ -0,0 +1,887 @@ +export type Film = { + id: number; + title: string; + overview: string; + popularity: number; + release_date: number; + poster_path: string; + providers: Record; + genres: string[]; + language: string; + // TODO: Should type these too in lib? + _geo?: { lat: number; lng: number }; + _vectors: Record; +}; + +export const FILMS: Film[] = [ + { + id: 95, + title: "Armageddon", + overview: + "When an asteroid threatens to collide with Earth, NASA honcho Dan Truman determines the only way to stop it is to drill into its surface and detonate a nuclear bomb. This leads him to renowned driller Harry Stamper, who agrees to helm the dangerous space mission provided he can bring along his own hotshot crew. Among them is the cocksure A.J. who Harry thinks isn't good enough for his daughter, until the mission proves otherwise.", + popularity: 34.945, + release_date: Date.parse("1998-07-01"), + poster_path: + "https://image.tmdb.org/t/p/w780/eTM3qtGhDU8cvjpoa6KEt5E2auU.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "fuboTV", + logo: "/2wPRZit7b8u79GsqTdygmGL6kBW.jpg", + }, + ], + }, + genres: ["Action", "Thriller", "Science Fiction", "Adventure"], + language: "en", + _geo: { lat: 45.4777599, lng: 9.1967508 }, + _vectors: { default: [0.8] }, + }, + { + id: 98, + title: "Gladiator", + overview: + "In the year 180, the death of emperor Marcus Aurelius throws the Roman Empire into chaos. Maximus is one of the Roman army's most capable and trusted generals and a key advisor to the emperor. As Marcus' devious son Commodus ascends to the throne, Maximus is set to be executed. He escapes, but is captured by slave traders. Renamed Spaniard and forced to become a gladiator, Maximus must battle to the death with other men for the amusement of paying audiences.", + popularity: 51.818, + release_date: Date.parse("2000-05-01"), + poster_path: + "https://image.tmdb.org/t/p/w780/rotQFyaeNQivUJOm3J3M7YqPNMx.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "Netflix", + logo: "/9A1JSVmSxsyaBK4SUFsYVqbAYfW.jpg", + }, + ], + }, + genres: ["Action", "Drama", "Adventure"], + language: "en", + _geo: { lat: 48.8826517, lng: 2.3352748 }, + _vectors: { default: [0.6] }, + }, + { + id: 106, + title: "Predator", + overview: + "A team of commandos on a mission in a Central American jungle find themselves hunted by an extraterrestrial warrior.", + popularity: 44.021, + release_date: Date.parse("1987-06-12"), + poster_path: + "https://image.tmdb.org/t/p/w780/iieEddHTv5zzTyr7OnN5ULOu7bI.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Science Fiction", "Action", "Adventure", "Thriller"], + language: "en", + _vectors: { default: [0.1] }, + }, + { + id: 97, + title: "Tron", + overview: + "As Kevin Flynn searches for proof that he invented a hit video game, he is 'digitized' by a laser and finds himself inside 'The Grid', where programs suffer under the tyrannical rule of the Master Control Program (MCP). With the help of a security program called 'TRON', Flynn seeks to free The Grid from the MCP.", + popularity: 16.007, + release_date: Date.parse("1982-07-09"), + poster_path: + "https://image.tmdb.org/t/p/w780/zwSFEczP7AzqugAHHIX3zHniT0t.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Science Fiction", "Action", "Adventure"], + language: "en", + _geo: { lat: 45.4632046, lng: 9.1719421 }, + _vectors: { default: [0.7] }, + }, + { + id: 107, + title: "Snatch", + overview: + "Unscrupulous boxing promoters, violent bookmakers, a Russian gangster, incompetent amateur robbers and supposedly Jewish jewelers fight to track down a priceless stolen diamond.", + popularity: 18.364, + release_date: Date.parse("2000-09-01"), + poster_path: + "https://image.tmdb.org/t/p/w780/56mOJth6DJ6JhgoE2jtpilVqJO.jpg", + + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + + genres: ["Crime", "Comedy"], + language: "en", + _vectors: { default: [-0.5] }, + }, + { + id: 104, + title: "Run Lola Run", + overview: + "Lola receives a phone call from her boyfriend Manni. He lost 100,000 DM in a subway train that belongs to a very bad guy. She has 20 minutes to raise this amount and meet Manni. Otherwise, he will rob a store to get the money. Three different alternatives may happen depending on some minor event along Lola's run.", + popularity: 13.53, + release_date: Date.parse("1998-03-03"), + + poster_path: + "https://image.tmdb.org/t/p/w780/yBt6rkxRTP15nyOZOJt9pOgXDW0.jpg", + + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Action", "Drama", "Thriller"], + language: "en", + _vectors: { default: null }, + }, + { + id: 348, + title: "Alien", + overview: + "During its return to the earth, commercial spaceship Nostromo intercepts a distress signal from a distant planet. When a three-member team of the crew discovers a chamber containing thousands of eggs on the planet, a creature inside one of the eggs attacks an explorer. The entire crew is unaware of the impending nightmare set to descend upon them when the alien parasite planted inside its unfortunate host is birthed.", + popularity: 41.947, + release_date: Date.parse("1979-05-25"), + poster_path: + "https://image.tmdb.org/t/p/w780/vfrQk5IPloGg1v9Rzbh2Eg3VGyM.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "Amazon Prime Video", + logo: "/68MNrwlkpF7WnmNPXLah69CR5cb.jpg", + }, + ], + }, + genres: ["Horror", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 402, + title: "Basic Instinct", + overview: + "A violent police detective investigates a brutal murder that might involve a manipulative and seductive novelist.", + popularity: 23.737, + release_date: Date.parse("1992-03-20"), + poster_path: + "https://image.tmdb.org/t/p/w780/76Ts0yoHk8kVQj9MMnoMixhRWoh.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "HBO Max", + logo: "/aS2zvJWn9mwiCOeaaCkIh4wleZS.jpg", + }, + ], + }, + genres: ["Thriller", "Mystery"], + language: "en", + _vectors: { default: null }, + }, + { + id: 170, + title: "28 Days Later", + overview: + "Twenty-eight days after a killer virus was accidentally unleashed from a British research facility, a small group of London survivors are caught in a desperate struggle to protect themselves from the infected. Carried by animals and humans, the virus turns those it infects into homicidal maniacs -- and it's absolutely impossible to contain.", + popularity: 40.725, + release_date: Date.parse("2002-10-31"), + poster_path: + "https://image.tmdb.org/t/p/w780/w4SL5hv0qOanrN7GjwNgtjF1RtD.jpg", + + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "HBO Max", + logo: "/aS2zvJWn9mwiCOeaaCkIh4wleZS.jpg", + }, + ], + }, + genres: ["Horror", "Thriller", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 508, + title: "Love Actually", + overview: + "'Love Actually' follows the lives of eight very different couples dealing with their love lives, in various loosely and interrelated tales, all set during a frantic month before Christmas in London, England.", + popularity: 21.26, + release_date: Date.parse("2003-09-07"), + poster_path: + "https://image.tmdb.org/t/p/w780/7QPeVsr9rcFU9Gl90yg0gTOTpVv.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "IMDB TV Amazon Channel", + logo: "/nd4NLxYeSv2TQ3HFzsecbAuSq1C.jpg", + }, + ], + }, + genres: ["Comedy", "Romance", "Drama"], + language: "en", + _vectors: { default: null }, + }, + { + id: 200, + title: "Star Trek: Insurrection", + overview: + 'When an alien race and factions within Starfleet attempt to take over a planet that has "regenerative" properties, it falls upon Captain Picard and the crew of the Enterprise to defend the planet\'s people as well as the very ideals upon which the Federation itself was founded.', + popularity: 17.696, + release_date: Date.parse("1998-12-11"), + poster_path: + "https://image.tmdb.org/t/p/w780/9pbc44kltJhArUNyrdQcantMEvH.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Science Fiction", "Action", "Adventure", "Thriller"], + language: "en", + _vectors: { default: null }, + }, + { + id: 155, + title: "The Dark Knight", + overview: + "Batman raises the stakes in his war on crime. With the help of Lt. Jim Gordon and District Attorney Harvey Dent, Batman sets out to dismantle the remaining criminal organizations that plague the streets. The partnership proves to be effective, but they soon find themselves prey to a reign of chaos unleashed by a rising criminal mastermind known to the terrified citizens of Gotham as the Joker.", + popularity: 68.919, + release_date: Date.parse("2008-07-14"), + poster_path: + "https://image.tmdb.org/t/p/w780/qJ2tW6WMUDux911r6m7haRef0WH.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "HBO Max", + logo: "/aS2zvJWn9mwiCOeaaCkIh4wleZS.jpg", + }, + ], + }, + genres: ["Drama", "Action", "Crime", "Thriller"], + language: "en", + _vectors: { default: null }, + }, + { + id: 217, + title: "Indiana Jones and the Kingdom of the Crystal Skull", + overview: + "Set during the Cold War, the Soviets—led by sword-wielding Irina Spalko—are in search of a crystal skull which has supernatural powers related to a mystical Lost City of Gold. Indy is coerced to head to Peru at the behest of a young man whose friend—and Indy's colleague—Professor Oxley has been captured for his knowledge of the skull's whereabouts.", + popularity: 27.343, + release_date: Date.parse("2008-05-21"), + poster_path: + "https://image.tmdb.org/t/p/w780/56As6XEM1flWvprX4LgkPl8ii4K.jpg", + + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Adventure", "Action"], + language: "en", + _vectors: { default: null }, + }, + { + id: 185, + title: "A Clockwork Orange", + overview: + "In a near-future Britain, young Alexander DeLarge and his pals get their kicks beating and raping anyone they please. When not destroying the lives of others, Alex swoons to the music of Beethoven. The state, eager to crack down on juvenile crime, gives an incarcerated Alex the option to undergo an invasive procedure that'll rob him of all personal agency. In a time when conscience is a commodity, can Alex change his tune?", + popularity: 33.915, + release_date: Date.parse("1971-12-19"), + poster_path: + "https://image.tmdb.org/t/p/w780/4sHeTAp65WrSSuc05nRBKddhBxO.jpg", + providers: { + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + + genres: ["Science Fiction", "Drama"], + language: "en", + _vectors: { default: null }, + }, + { + id: 162, + title: "Edward Scissorhands", + overview: + "A small suburban town receives a visit from a castaway unfinished science experiment named Edward.", + popularity: 41.017, + release_date: Date.parse("1990-12-05"), + poster_path: + "https://image.tmdb.org/t/p/w780/1RFIbuW9Z3eN9Oxw2KaQG5DfLmD.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Fantasy", "Drama", "Romance"], + language: "en", + _vectors: { default: null }, + }, + { + id: 364, + title: "Batman Returns", + overview: + "While Batman deals with a deformed man calling himself the Penguin, an employee of a corrupt businessman transforms into the Catwoman.", + popularity: 27.257, + release_date: Date.parse("1992-06-19"), + poster_path: + "https://image.tmdb.org/t/p/w780/jKBjeXM7iBBV9UkUcOXx3m7FSHY.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "HBO Max", + logo: "/aS2zvJWn9mwiCOeaaCkIh4wleZS.jpg", + }, + ], + }, + genres: ["Action", "Fantasy"], + language: "en", + _vectors: { default: null }, + }, + { + id: 196, + title: "Back to the Future Part III", + overview: + "The final installment of the Back to the Future trilogy finds Marty digging the trusty DeLorean out of a mineshaft and looking for Doc in the Wild West of 1885. But when their time machine breaks down, the travelers are stranded in a land of spurs. More problems arise when Doc falls for pretty schoolteacher Clara Clayton, and Marty tangles with Buford Tannen.", + popularity: 27.714, + release_date: Date.parse("1990-05-25"), + poster_path: + "https://image.tmdb.org/t/p/w780/crzoVQnMzIrRfHtQw0tLBirNfVg.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Adventure", "Comedy", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 180, + title: "Minority Report", + overview: + "John Anderton is a top 'Precrime' cop in the late-21st century, when technology can predict crimes before they're committed. But Anderton becomes the quarry when another investigator targets him for a murder charge.", + popularity: 20.598, + release_date: Date.parse("2002-06-20"), + poster_path: + "https://image.tmdb.org/t/p/w780/ccqpHq5tk5W4ymbSbuoy4uYOxFI.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [{ name: "Hulu", logo: "/giwM8XX4V2AQb9vsoN7yti82tKK.jpg" }], + }, + genres: ["Action", "Thriller", "Science Fiction", "Mystery"], + language: "en", + _vectors: { default: null }, + }, + { + id: 114, + title: "Pretty Woman", + overview: + "When a millionaire wheeler-dealer enters a business contract with a Hollywood hooker Vivian Ward, he loses his heart in the bargain.", + popularity: 35.235, + release_date: Date.parse("1990-03-23"), + poster_path: + "https://image.tmdb.org/t/p/w780/hMVMMy1yDUvdufpTl8J8KKNYaZX.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Google Play Movies", + logo: "/p3Z12gKq2qvJaUOMeKNU2mzKVI9.jpg", + }, + { + name: "AMC on Demand", + logo: "/p1e92kLeYHalxC9GClqNJ75lBDG.jpg", + }, + ], + flatrate: [ + { + name: "fuboTV", + logo: "/2wPRZit7b8u79GsqTdygmGL6kBW.jpg", + }, + ], + }, + genres: ["Romance", "Comedy"], + language: "en", + _vectors: { default: null }, + }, + { + id: 65, + title: "8 Mile", + overview: + 'The setting is Detroit in 1995. The city is divided by 8 Mile, a road that splits the town in half along racial lines. A young white rapper, Jimmy "B-Rabbit" Smith Jr. summons strength within himself to cross over these arbitrary boundaries to fulfill his dream of success in hip hop. With his pal Future and the three one third in place, all he has to do is not choke.', + popularity: 30.162, + release_date: Date.parse("2002-11-08"), + poster_path: + "https://image.tmdb.org/t/p/w780/7BmQj8qE1FLuLTf7Xjf9sdIHzoa.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "HBO Max", + logo: "/aS2zvJWn9mwiCOeaaCkIh4wleZS.jpg", + }, + ], + }, + genres: ["Music", "Drama"], + language: "en", + _vectors: { default: null }, + }, + { + id: 165, + title: "Back to the Future Part II", + overview: + "Marty and Doc are at it again in this wacky sequel to the 1985 blockbuster as the time-traveling duo head to 2015 to nip some McFly family woes in the bud. But things go awry thanks to bully Biff Tannen and a pesky sports almanac. In a last-ditch attempt to set things straight, Marty finds himself bound for 1955 and face to face with his teenage parents -- again.", + popularity: 26.354, + release_date: Date.parse("1989-11-22"), + poster_path: + "https://image.tmdb.org/t/p/w780/hQq8xZe5uLjFzSBt4LanNP7SQjl.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "AMC Plus", + logo: "/9DrynzGqT6DNLoc4BB3rlcxFRn.jpg", + }, + ], + }, + genres: ["Adventure", "Comedy", "Family", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 197, + title: "Braveheart", + overview: + "Enraged at the slaughter of Murron, his new bride and childhood love, Scottish warrior William Wallace slays a platoon of the local English lord's soldiers. This leads the village to revolt and, eventually, the entire country to rise up against English rule.", + popularity: 36.408, + release_date: Date.parse("1995-03-14"), + poster_path: + "https://image.tmdb.org/t/p/w780/uE2Q9RaNBdJbAV1N67LhwCYluK0.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Action", "Drama", "History", "War"], + language: "en", + _vectors: { default: null }, + }, + { + id: 607, + title: "Men in Black", + overview: + "After a police chase with an otherworldly being, a New York City cop is recruited as an agent in a top-secret organization established to monitor and police alien activity on Earth: the Men in Black. Agent Kay and new recruit Agent Jay find themselves in the middle of a deadly plot by an intergalactic terrorist who has arrived on Earth to assassinate two ambassadors from opposing galaxies.", + popularity: 35.303, + release_date: Date.parse("1997-07-02"), + poster_path: + "https://image.tmdb.org/t/p/w780/uLOmOF5IzWoyrgIF5MfUnh5pa1X.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Action", "Adventure", "Comedy", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 24, + title: "Kill Bill: Vol. 1", + overview: + "An assassin is shot by her ruthless employer, Bill, and other members of their assassination circle – but she lives to plot her vengeance.", + popularity: 36.782, + release_date: Date.parse("2003-10-10"), + poster_path: + "https://image.tmdb.org/t/p/w780/v7TaX8kXMXs5yFFGR41guUDNcnB.jpg", + providers: { + buy: [ + { + name: "Google Play Movies", + logo: "/p3Z12gKq2qvJaUOMeKNU2mzKVI9.jpg", + }, + ], + }, + genres: ["Action", "Crime"], + language: "en", + _vectors: { default: null }, + }, + { + id: 393, + title: "Kill Bill: Vol. 2", + overview: + "The Bride unwaveringly continues on her roaring rampage of revenge against the band of assassins who had tried to kill her and her unborn child. She visits each of her former associates one-by-one, checking off the victims on her Death List Five until there's nothing left to do … but kill Bill.", + popularity: 32.364, + release_date: Date.parse("2004-04-16"), + poster_path: + "https://image.tmdb.org/t/p/w780/2yhg0mZQMhDyvUQ4rG1IZ4oIA8L.jpg", + providers: { + buy: [ + { + name: "Google Play Movies", + logo: "/p3Z12gKq2qvJaUOMeKNU2mzKVI9.jpg", + }, + ], + }, + genres: ["Action", "Crime", "Thriller"], + language: "en", + _vectors: { default: null }, + }, + { + id: 606, + title: "Out of Africa", + overview: + "Out of Africa tells the story of the life of Danish author Karen Blixen, who at the beginning of the 20th century moved to Africa to build a new life for herself. The film is based on the autobiographical novel by Karen Blixen from 1937.", + popularity: 15.148, + release_date: Date.parse("1985-12-20"), + poster_path: + "https://image.tmdb.org/t/p/w780/3eLAm1kuVD5QZCOydbiu7j6GAbw.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["History", "Romance", "Drama"], + language: "en", + _vectors: { default: null }, + }, + { + id: 608, + title: "Men in Black II", + overview: + "Kay and Jay reunite to provide our best, last and only line of defense against a sinister seductress who levels the toughest challenge yet to the MIB's untarnished mission statement – protecting Earth from the scum of the universe. It's been four years since the alien-seeking agents averted an intergalactic disaster of epic proportions. Now it's a race against the clock as Jay must convince Kay – who not only has absolutely no memory of his time spent with the MIB, but is also the only living person left with the expertise to save the galaxy – to reunite with the MIB before the earth submits to ultimate destruction.", + popularity: 31.8, + release_date: Date.parse("2002-07-03"), + poster_path: + "https://image.tmdb.org/t/p/w780/enA22EPyzc2WQ1VVyY7zxresQQr.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Action", "Adventure", "Comedy", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 115, + title: "The Big Lebowski", + overview: + "Jeffrey 'The Dude' Lebowski, a Los Angeles slacker who only wants to bowl and drink White Russians, is mistaken for another Jeffrey Lebowski, a wheelchair-bound millionaire, and finds himself dragged into a strange series of events involving nihilists, adult film producers, ferrets, errant toes, and large sums of money.", + popularity: 20.46, + release_date: Date.parse("1998-03-06"), + poster_path: + "https://image.tmdb.org/t/p/w780/84zhHB9wreAwPxGXys8CxSk9ARr.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Comedy", "Crime"], + language: "en", + _vectors: { default: null }, + }, + { + id: 389, + title: "12 Angry Men", + overview: + "The defense and the prosecution have rested and the jury is filing into the jury room to decide if a young Spanish-American is guilty or innocent of murdering his father. What begins as an open and shut case soon becomes a mini-drama of each of the jurors' prejudices and preconceptions about the trial, the accused, and each other.", + popularity: 18.549, + release_date: Date.parse("1957-04-10"), + poster_path: + "https://image.tmdb.org/t/p/w780/e02s4wmTAExkKg0yF4dEG98ZRpK.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Drama"], + language: "en", + _vectors: { default: null }, + }, + { + id: 11, + title: "Star Wars", + overview: + "Princess Leia is captured and held hostage by the evil Imperial forces in their effort to take over the galactic Empire. Venturesome Luke Skywalker and dashing captain Han Solo team together with the loveable robot duo R2-D2 and C-3PO to rescue the beautiful princess and restore peace and justice in the Empire.", + popularity: 68.013, + release_date: Date.parse("1977-05-25"), + poster_path: + "https://image.tmdb.org/t/p/w780/6FfCtAuVAW8XJjZ7eWeLibRLWTw.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Adventure", "Action", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 161, + title: "Ocean's Eleven", + overview: + "Less than 24 hours into his parole, charismatic thief Danny Ocean is already rolling out his next plan: In one night, Danny's hand-picked crew of specialists will attempt to steal more than $150 million from three Las Vegas casinos. But to score the cash, Danny risks his chances of reconciling with ex-wife, Tess.", + popularity: 24.355, + release_date: Date.parse("2001-12-07"), + poster_path: + "https://image.tmdb.org/t/p/w780/v5D7K4EHuQHFSjveq8LGxdSfrGS.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Thriller", "Crime"], + language: "en", + _vectors: { default: null }, + }, + { + id: 18, + title: "The Fifth Element", + overview: + "In 2257, a taxi driver is unintentionally given the task of saving a young girl who is part of the key that will ensure the survival of humanity.", + popularity: 37.356, + release_date: Date.parse("1997-05-02"), + poster_path: + "https://image.tmdb.org/t/p/w780/fPtlCO1yQtnoLHOwKtWz7db6RGU.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Adventure", "Fantasy", "Action", "Thriller", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 138, + title: "Dracula", + overview: + "British estate agent Renfield travels to Transylvania to meet with the mysterious Count Dracula, who is interested in leasing a castle in London and is, unbeknownst to Renfield, a vampire. After Dracula enslaves Renfield and drives him to insanity, the pair sail to London together, and as Dracula begins preying on London socialites, the two become the subject of study for a supernaturalist professor, Abraham Van Helsing.", + popularity: 13.227, + release_date: Date.parse("1931-02-12"), + poster_path: + "https://image.tmdb.org/t/p/w780/ueVSPt7vAba0XScHWTDWS5tNxYX.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Horror", "Drama", "Fantasy"], + language: "en", + _vectors: { default: null }, + }, +]; From 46c1567a0a24778eba8cb204dd54273293ceddb0 Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Fri, 25 Apr 2025 14:46:59 +0300 Subject: [PATCH 6/8] Fix tests and types --- playgrounds/javascript/src/meilisearch.ts | 17 ++++++++++------- src/indexes.ts | 4 +--- tests/search.test.ts | 14 ++++++++------ tests/utils/meilisearch-test-utils.ts | 11 ++++++++++- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/playgrounds/javascript/src/meilisearch.ts b/playgrounds/javascript/src/meilisearch.ts index 84a1577b4..97b1e73ee 100644 --- a/playgrounds/javascript/src/meilisearch.ts +++ b/playgrounds/javascript/src/meilisearch.ts @@ -1,4 +1,7 @@ -import { Index, Meilisearch } from "../../../src/index.js"; +import { + Meilisearch, + type SearchQueryWithOffsetLimit, +} from "../../../src/index.js"; const client = new Meilisearch({ host: "http://127.0.0.1:7700", @@ -37,13 +40,13 @@ export async function getAllHits(element: HTMLDivElement): Promise { } export async function getSearchResponse(element: HTMLDivElement) { - const params: Parameters = [ - { q: "philoudelphia", attributesToHighlight: ["title"] }, - ]; - - const response = await client.index(indexUid).search(...params); + const searchQuery: SearchQueryWithOffsetLimit = { + q: "philoudelphia", + attributesToHighlight: ["title"], + }; + const response = await client.index(indexUid).search(searchQuery); element.innerText = - `PARAMETERS: ${JSON.stringify(params, null, 4)}` + + `PARAMETERS: ${JSON.stringify(searchQuery, null, 4)}` + `\nRESPONSE: ${JSON.stringify(response, null, 4)}`; } diff --git a/src/indexes.ts b/src/indexes.ts index a54b2a2a5..0aff3c68e 100644 --- a/src/indexes.ts +++ b/src/indexes.ts @@ -93,8 +93,6 @@ export class Index { /// SEARCH /// - // TODO: If no params are provided, it's set to pagination | offset limit - // If empty object param is provided it's set to pagination /** {@link https://www.meilisearch.com/docs/reference/api/search} */ search( searchQuery?: SearchQueryWithOffsetLimit, @@ -105,7 +103,7 @@ export class Index { init?: ExtraRequestInit, ): Promise>; async search( - searchQuery?: SearchQuery, + searchQuery: SearchQuery = {}, init?: ExtraRequestInit, ): Promise> { return await this.httpRequest.post({ diff --git a/tests/search.test.ts b/tests/search.test.ts index 0e5058b4d..cbce615b6 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -1,5 +1,10 @@ import { afterAll, beforeAll, test, describe } from "vitest"; -import { assert, HOST, MASTER_KEY } from "./utils/meilisearch-test-utils.js"; +import { + assert, + HOST, + MASTER_KEY, + ObjectKeys, +} from "./utils/meilisearch-test-utils.js"; import { type Film, FILMS } from "./utils/test-data/films.js"; import type { ExplicitVectors, @@ -291,12 +296,11 @@ describe.concurrent("`showMatchesPosition` param", () => { ); }); -const possibleMatchingStrategies = Object.keys({ +const possibleMatchingStrategies = ObjectKeys({ last: null, all: null, frequency: null, - // record because cannot convert union to tuple (https://github.com/microsoft/TypeScript/issues/42857) -} satisfies { [TKey in MatchingStrategy]: null }) as MatchingStrategy[]; +}); describe.concurrent.for(possibleMatchingStrategies)( "`matchingStrategy` = `%s` param", @@ -487,5 +491,3 @@ describe.concurrent("embedding related params", () => { }, ); }); - -test.todo("abortable http request"); diff --git a/tests/utils/meilisearch-test-utils.ts b/tests/utils/meilisearch-test-utils.ts index 92b254b92..793efc8dd 100644 --- a/tests/utils/meilisearch-test-utils.ts +++ b/tests/utils/meilisearch-test-utils.ts @@ -1,6 +1,6 @@ import { assert as vitestAssert } from "vitest"; import { MeiliSearch, Index } from "../../src/index.js"; -import type { Config } from "../../src/types/index.js"; +import type { Config, Task } from "../../src/types/index.js"; // testing const MASTER_KEY = "masterKey"; @@ -135,6 +135,10 @@ const source = { vitestAssert.fail(error, RESOLVED, "expected value to not reject"); } }, + isTaskSuccessful(task: Task) { + vitestAssert.isNull(task.error); + vitestAssert.strictEqual(task.status, "succeeded"); + }, }; export const assert: typeof vitestAssert & typeof source = Object.assign( vitestAssert, @@ -252,7 +256,12 @@ export type Book = { author: string; }; +function ObjectKeys(o: { [TKey in T]: null }): T[] { + return Object.keys(o) as T[]; +} + export { + ObjectKeys, clearAllIndexes, config, masterClient, From 842b4a81efeb4a104af9388df5636c86987e4687 Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Fri, 25 Apr 2025 14:53:25 +0300 Subject: [PATCH 7/8] Fix test --- tests/client.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/client.test.ts b/tests/client.test.ts index c1b5b5926..b533b64c5 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -38,8 +38,17 @@ const index2 = { uid: "movies_test2", }; -afterAll(() => { - return clearAllIndexes(config); +beforeAll(async () => { + await ( + await getClient("Master") + ).updateExperimentalFeatures({ network: true }); +}); + +afterAll(async () => { + await ( + await getClient("Master") + ).updateExperimentalFeatures({ network: false }); + await clearAllIndexes(config); }); describe.each([ From 088bd36b88d4ed714e3d35dd929c023f0c2c23c6 Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Fri, 25 Apr 2025 15:21:49 +0300 Subject: [PATCH 8/8] Fix test, update docs --- .code-samples.meilisearch.yaml | 145 ++++++++++++++++++----------- README.md | 40 ++++---- tests/env/node/getting_started.cjs | 17 ++-- tests/env/node/search_example.cjs | 3 +- 4 files changed, 118 insertions(+), 87 deletions(-) diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 69ab03cc6..bc3ca5244 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -14,13 +14,15 @@ date_guide_index_1: |- date_guide_filterable_attributes_1: |- client.index('games').updateFilterableAttributes(['release_timestamp']) date_guide_filter_1: |- - client.index('games').search('', { + client.index('games').search({ + q: '', filter: 'release_timestamp >= 1514761200 AND release_timestamp < 1672527600' }) date_guide_sortable_attributes_1: |- client.index('games').updateSortableAttributes(['release_timestamp']) date_guide_sort_1: |- - client.index('games').search('', { + client.index('games').search({ + q: '', sort: ['release_timestamp:desc'], }) get_one_index_1: |- @@ -78,9 +80,9 @@ delete_documents_by_filter_1: |- filter: 'genres = action OR genres = adventure' }) search_post_1: |- - client.index('movies').search('American ninja') + client.index('movies').search({ q: 'American ninja' }) search_get_1: |- - client.index('movies').searchGet('American ninja') + client.index('movies').searchGet({ q: 'American ninja' }) multi_search_1: |- client.multiSearch({ queries: [ { @@ -302,81 +304,99 @@ field_properties_guide_displayed_1: |- ] ) filtering_guide_1: |- - client.index('movie_ratings').search('Avengers', { + client.index('movie_ratings').search({ + q: 'Avengers', filter: 'release_date > 795484800' }) filtering_guide_2: |- - client.index('movie_ratings').search('Batman', { + client.index('movie_ratings').search({ + q: 'Batman', filter: 'release_date > 795484800 AND (director = "Tim Burton" OR director = "Christopher Nolan")' }) filtering_guide_3: |- - client.index('movie_ratings').search('Planet of the Apes', { + client.index('movie_ratings').search({ + q: 'Planet of the Apes', filter: "release_date > 1577884550 AND (NOT director = \"Tim Burton\")" }) filtering_guide_nested_1: |- - client.index('movie_ratings').search('thriller', { + client.index('movie_ratings').search({ + q: 'thriller', filter: 'rating.users >= 90' }) search_parameter_guide_query_1: |- - client.index('movies').search('shifu') + client.index('movies').search({ q: 'shifu' }) search_parameter_guide_offset_1: |- - client.index('movies').search('shifu', { + client.index('movies').search({ + q: 'shifu', offset: 1 }) search_parameter_guide_limit_1: |- - client.index('movies').search('shifu', { + client.index('movies').search({ + q: 'shifu', limit: 2 }) search_parameter_guide_retrieve_1: |- - client.index('movies').search('shifu', { + client.index('movies').search({ + q: 'shifu', attributesToRetrieve: ['overview', 'title'] }) search_parameter_guide_crop_1: |- - client.index('movies').search('shifu', { + client.index('movies').search({ + q: 'shifu', attributesToCrop: ['overview'], cropLength: 5 }) search_parameter_guide_crop_marker_1: |- - client.index('movies').search('shifu', { + client.index('movies').search({ + q: 'shifu', attributesToCrop: ['overview'], cropMarker: '[…]' }) search_parameter_guide_highlight_1: |- - client.index('movies').search('winter feast', { + client.index('movies').search({ + q: 'winter feast', attributesToHighlight: ['overview'] }) search_parameter_guide_highlight_tag_1: |- - client.index('movies').search('winter feast', { + client.index('movies').search({ + q: 'winter feast', attributesToHighlight: ['overview'], highlightPreTag: '', highlightPostTag: '' }) search_parameter_guide_show_matches_position_1: |- - client.index('movies').search('winter feast', { + client.index('movies').search({ + q: 'winter feast', showMatchesPosition: true }) search_parameter_guide_matching_strategy_1: |- - client.index('movies').search('big fat liar', { + client.index('movies').search({ + q: 'big fat liar', matchingStrategy: 'last' }) search_parameter_guide_matching_strategy_2: |- - client.index('movies').search('big fat liar', { + client.index('movies').search({ + q: 'big fat liar', matchingStrategy: 'all' }) search_parameter_guide_hitsperpage_1: |- - client.index('movies').search('', { + client.index('movies').search({ + q: '', hitsPerPage: 15 }) search_parameter_guide_page_1: |- - client.index('movies').search('', { + client.index('movies').search({ + q: '', page: 2 }) search_parameter_guide_show_ranking_score_1: |- - client.index('movies').search('dragon', { + client.index('movies').search({ + q: 'dragon', showRankingScore: true }) search_parameter_guide_attributes_to_search_on_1: |- - client.index('movies').search('adventure', { + client.index('movies').search({ + q: 'adventure', attributesToSearchOn: ['overview'] }) typo_tolerance_guide_1: |- @@ -454,7 +474,7 @@ getting_started_add_documents_md: |- [About this SDK](https://github.com/meilisearch/meilisearch-js/) getting_started_search_md: |- ```js - client.index('movies').search('botman').then((res) => console.log(res)) + client.index('movies').search({ q: 'botman' }).then((res) => console.log(res)) ``` [About this SDK](https://github.com/meilisearch/meilisearch-js/) @@ -498,11 +518,14 @@ getting_started_configure_settings: |- sortableAttributes: ['mass', '_geo'] }) getting_started_geo_radius: |- - client.index('meteorites').search('', { filter: '_geoRadius(46.9480, 7.4474, 210000)' }) + client.index('meteorites').search({ + q: '', filter: '_geoRadius(46.9480, 7.4474, 210000)' }) getting_started_geo_point: |- - client.index('meteorites').search('', { sort: ['_geoPoint(48.8583701, 2.2922926):asc'] }) + client.index('meteorites').search({ + q: '', sort: ['_geoPoint(48.8583701, 2.2922926):asc'] }) getting_started_sorting: |- - client.index('meteorites').search('', { + client.index('meteorites').search({ + q: '', sort: ['mass:asc'], filter: 'mass < 200' }) @@ -520,7 +543,8 @@ getting_started_typo_tolerance: |- } }) getting_started_filtering: |- - client.index('meteorites').search('', { filter: 'mass < 200' }) + client.index('meteorites').search({ + q: '', filter: 'mass < 200' }) getting_started_pagination: |- client.index('movies').updatePagination({ maxTotalHits: 500 }) get_filterable_attributes_1: |- @@ -547,20 +571,21 @@ filtering_update_settings_1: |- ]) faceted_search_walkthrough_filter_1: |- client.index('movies') - .search('thriller', { + .search({ + q: 'thriller', filter: [['genres = Horror', 'genres = Mystery'], 'director = "Jordan Peele"'] }) faceted_search_update_settings_1: |- client.index('movie_ratings').updateFilterableAttributes(['genres', 'rating', 'language']) faceted_search_1: |- - client.index('books').search('classic', { facets: ['genres', 'rating', 'language'] }) + client.index('books').search({ q: 'classic', facets: ['genres', 'rating', 'language'] }) post_dump_1: |- client.createDump() create_snapshot_1: |- client.createSnapshot() phrase_search_1: |- client.index('movies') - .search('"african american" horror') + .search({ q: '"african american" horror' }) sorting_guide_update_sortable_attributes_1: |- client.index('books').updateSortableAttributes([ 'author', @@ -576,15 +601,18 @@ sorting_guide_update_ranking_rules_1: |- 'exactness' ]) sorting_guide_sort_parameter_1: |- - client.index('books').search('science fiction', { + client.index('books').search({ + q: 'science fiction', sort: ['price:asc'], }) sorting_guide_sort_parameter_2: |- - client.index('books').search('butler', { + client.index('books').search({ + q: 'butler', sort: ['author:desc'], }) sorting_guide_sort_nested_1: |- - client.index('books').search('science fiction', { + client.index('books').search({ + q: 'science fiction', 'sort': ['rating.users:asc'], }) get_sortable_attributes_1: |- @@ -622,7 +650,8 @@ update_dictionary_1: |- reset_dictionary_1: |- client.index('books').resetDictionary() search_parameter_guide_sort_1: |- - client.index('books').search('science fiction', { + client.index('books').search({ + q: 'science fiction', sort: ['price:asc'], }) get_separator_tokens_1: |- @@ -650,22 +679,25 @@ update_search_cutoff_1: |- reset_search_cutoff_1: |- client.index('movies').resetSearchCutoffMs() search_parameter_guide_facet_stats_1: |- - client.index('movie_ratings').search('Batman', { facets: ['genres', 'rating'] }) + client.index('movie_ratings').search({ q: 'Batman', facets: ['genres', 'rating'] }) geosearch_guide_filter_settings_1: |- client.index('restaurants') .updateFilterableAttributes([ '_geo' ]) geosearch_guide_filter_usage_1: |- - client.index('restaurants').search('', { + client.index('restaurants').search({ + q: '', filter: ['_geoRadius(45.472735, 9.184019, 2000)'], }) geosearch_guide_filter_usage_2: |- - client.index('restaurants').search('', { + client.index('restaurants').search({ + q: '', filter: ['_geoRadius(45.472735, 9.184019, 2000) AND type = pizza'], }) geosearch_guide_filter_usage_3: |- - client.index('restaurants').search('', { + client.index('restaurants').search({ + q: '', filter: ['_geoBoundingBox([45.494181, 9.214024], [45.449484, 9.179175])'], }) geosearch_guide_sort_settings_1: |- @@ -673,11 +705,13 @@ geosearch_guide_sort_settings_1: |- '_geo' ]) geosearch_guide_sort_usage_1: |- - client.index('restaurants').search('', { + client.index('restaurants').search({ + q: '', sort: ['_geoPoint(48.8561446, 2.2978204):asc'], }) geosearch_guide_sort_usage_2: |- - client.index('restaurants').search('', { + client.index('restaurants').search({ + q: '', sort: ['_geoPoint(48.8561446, 2.2978204):asc', 'rating:desc'], }) security_guide_search_key_1: |- @@ -720,7 +754,7 @@ tenant_token_guide_generate_sdk_1: |- const token = await generateTenantToken({ apiKey, apiKeyUid, searchRules, expiresAt }) tenant_token_guide_search_sdk_1: |- const frontEndClient = new MeiliSearch({ host: 'http://localhost:7700', apiKey: token }) - frontEndClient.index('patient_medical_records').search('blood test') + frontEndClient.index('patient_medical_records').search({ q: 'blood test' }) landing_getting_started_1: |- const client = new MeiliSearch('http://localhost:7700', 'masterKey') @@ -734,7 +768,7 @@ landing_getting_started_1: |- ]) // be aware this client is using the masterKey, it should not be used in front end - const search = await index.search('philodelphia') + const search = await index.search({ q: 'philodelphia' }) console.log(search) facet_search_1: |- client.index('books').searchForFacetValues({ @@ -754,22 +788,24 @@ facet_search_3: |- facetName: 'genres' }) search_parameter_guide_show_ranking_score_details_1: |- - client.index('movies').search('dragon', { showRankingScoreDetails: true }) + client.index('movies').search({ q: 'dragon', showRankingScoreDetails: true }) negative_search_1: |- - client.index('movies').search('-escape') + client.index('movies').search({ q: '-escape' }) negative_search_2: |- - client.index('movies').search('-"escape"') + client.index('movies').search({ q: '-"escape"' }) search_parameter_reference_ranking_score_threshold_1: |- - client.index('INDEX_NAME').search('badman', { rankingScoreThreshold: 0.2 }) + client.index('INDEX_NAME').search({ q: 'badman', rankingScoreThreshold: 0.2 }) search_parameter_reference_retrieve_vectors_1: |- - client.index('INDEX_NAME').search('kitchen utensils', { + client.index('INDEX_NAME').search({ + q: 'kitchen utensils', retrieveVectors: true, hybrid: { embedder: 'EMBEDDER_NAME' } }) search_parameter_guide_hybrid_1: |- - client.index('INDEX_NAME').search('kitchen utensils', { + client.index('INDEX_NAME').search({ + q: 'kitchen utensils', hybrid: { semanticRatio: 0.9, embedder: 'EMBEDDER_NAME' @@ -778,17 +814,18 @@ search_parameter_guide_hybrid_1: |- get_similar_post_1: |- client.index('INDEX_NAME').searchSimilarDocuments({ id: 'TARGET_DOCUMENT_ID', embedder: 'default' }) search_parameter_guide_matching_strategy_3: |- - client.index('movies').search('white shirt', { + client.index('movies').search({ + q: 'white shirt', matchingStrategy: 'frequency' }) search_parameter_reference_distinct_1: |- - client.index('INDEX_NAME').search('QUERY TERMS', { distinct: 'ATTRIBUTE_A' }) + client.index('INDEX_NAME').search({ q: 'QUERY TERMS', distinct: 'ATTRIBUTE_A' }) distinct_attribute_guide_filterable_1: |- client.index('products').updateFilterableAttributes(['product_id', 'sku', 'url']) distinct_attribute_guide_distinct_parameter_1: |- - client.index('products').search('white shirt', { distinct: 'sku' }) + client.index('products').search({ q: 'white shirt', distinct: 'sku' }) multi_search_federated_1: |- - client.multiSearch({ + client.federatedMultiSearch({ federation: {}, queries: [ { @@ -802,7 +839,7 @@ multi_search_federated_1: |- ] }) search_parameter_reference_locales_1: |- - client.index('INDEX_NAME').search('QUERY TEXT IN JAPANESE', { locales: ['jpn'] }) + client.index('INDEX_NAME').search({ q: 'QUERY TEXT IN JAPANESE', locales: ['jpn'] }) get_localized_attribute_settings_1: |- client.index('INDEX_NAME').getLocalizedAttributes() update_localized_attribute_settings_1: |- diff --git a/README.md b/README.md index 3d5e3441f..76645b233 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Tasks such as document addition always return a unique identifier. You can use t ```javascript // Meilisearch is typo-tolerant: -const search = await index.search('philoudelphia') +const search = await index.search({ q: 'philoudelphia' }) console.log(search) ``` @@ -227,12 +227,10 @@ Output: `meilisearch-js` supports all [search parameters](https://www.meilisearch.com/docs/reference/api/search#search-parameters) described in our main documentation website. ```javascript -await index.search( - 'wonder', - { - attributesToHighlight: ['*'] - } -) +await index.search({ + q: 'wonder', + attributesToHighlight: ['*'] +}) ``` ```json @@ -275,12 +273,10 @@ Note that Meilisearch rebuilds your index whenever you update `filterableAttribu After you configured `filterableAttributes`, you can use the [`filter` search parameter](https://www.meilisearch.com/docs/reference/api/search#filter) to refine your search: ```js -await index.search( - 'wonder', - { - filter: ['id > 1 AND genres = Action'] - } -) +await index.search({ + q: 'wonder', + filter: ['id > 1 AND genres = Action'] +}) ``` ```json @@ -305,13 +301,11 @@ await index.search( Placeholder search makes it possible to receive hits based on your parameters without having any query (`q`). For example, in a movies database you can run an empty query to receive all results filtered by `genre`. ```javascript -await index.search( - '', - { - filter: ['genres = fantasy'], - facets: ['genres'] - } -) +await index.search({ + q: '', + filter: ['genres = fantasy'], + facets: ['genres'] +}) ``` ```json @@ -353,7 +347,7 @@ You can abort a pending search request by providing an [AbortSignal](https://dev const controller = new AbortController() index - .search('wonder', {}, { + .search({ q: 'wonder' }, { signal: controller.signal, }) .then((response) => { @@ -431,13 +425,13 @@ We welcome all contributions, big and small! If you want to know more about this #### [Make a search request](https://www.meilisearch.com/docs/reference/api/search) ```ts -client.index('xxx').search(query: string, options: SearchParams = {}, config?: Partial): Promise> +client.index('xxx').search(options: SearchParams = {}, config?: Partial): Promise> ``` #### [Make a search request using the GET method (slower than the search method)](https://www.meilisearch.com/docs/reference/api/search#search-in-an-index-with-get-route) ```ts -client.index('xxx').searchGet(query: string, options: SearchParams = {}, config?: Partial): Promise> +client.index('xxx').searchGet(options: SearchParams = {}, config?: Partial): Promise> ``` ### Multi Search diff --git a/tests/env/node/getting_started.cjs b/tests/env/node/getting_started.cjs index f16d357c6..f24ebf3f1 100644 --- a/tests/env/node/getting_started.cjs +++ b/tests/env/node/getting_started.cjs @@ -27,19 +27,18 @@ const { MeiliSearch } = require('../../../dist/cjs/index.cjs') await index.addDocuments(dataset).waitTask() - const search = await index.search('philoudelphia') + const search = await index.search({ q: 'philoudelphia' }) console.log({ search, hit: search.hits }) - const filteredSearch = await index.search('Wonder', { + const filteredSearch = await index.search({ + q: 'Wonder', attributesToHighlight: ['*'], filter: 'id >= 1' }) console.log({ filteredSearch, hit: filteredSearch.hits[0] }) - const facetedSearch = await index.search( - '', - { - filter: ['genres = action'], - facets: ['genres'] - } - ) + const facetedSearch = await index.search({ + q: '', + filter: ['genres = action'], + facets: ['genres'] + }) console.log(JSON.stringify(facetedSearch)) })() diff --git a/tests/env/node/search_example.cjs b/tests/env/node/search_example.cjs index ed84b227c..aa008c195 100644 --- a/tests/env/node/search_example.cjs +++ b/tests/env/node/search_example.cjs @@ -24,7 +24,8 @@ const addDataset = async () => { ;(async () => { await addDataset() const index = await client.index('movies') - const resp = await index.search('Avengers', { + const resp = await index.search({ + q: 'Avengers', limit: 1, attributesToHighlight: ['title'], })