diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 5370f8281..6c590ff31 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -53,6 +53,14 @@ get_documents_post_1: |- fields: ['title', 'genres', 'rating', 'language'], limit: 3 }) +get_documents_sort_1: |- + client.index('movies').getDocuments({ + sort: ['release_date:desc'] + }) +get_documents_sort_multiple_1: |- + client.index('movies').getDocuments({ + sort: ['rating:desc', 'release_date:asc'] + }) add_or_replace_documents_1: |- client.index('movies').addDocuments([{ id: 287947, diff --git a/src/indexes.ts b/src/indexes.ts index 22ee033b5..01a21b8f2 100644 --- a/src/indexes.ts +++ b/src/indexes.ts @@ -298,24 +298,35 @@ export class Index { * Get documents of an index. * * @param params - Parameters to browse the documents. Parameters can contain - * the `filter` field only available in Meilisearch v1.2 and newer + * the `filter` field only available in Meilisearch v1.2 and newer, and the + * `sort` field available in Meilisearch v1.16 and newer * @returns Promise containing the returned documents */ async getDocuments( params?: DocumentsQuery, ): Promise> { const relativeBaseURL = `indexes/${this.uid}/documents`; + // Create a shallow copy so we can safely normalize parameters + const normalizedParams = params ? { ...params } : undefined; + // Omit empty sort arrays to avoid server-side validation errors + if ( + normalizedParams && + Array.isArray(normalizedParams.sort) && + normalizedParams.sort.length === 0 + ) { + delete (normalizedParams as { sort?: string[] }).sort; + } - return params?.filter !== undefined + return normalizedParams?.filter !== undefined ? // In case `filter` is provided, use `POST /documents/fetch` await this.httpRequest.post>({ path: `${relativeBaseURL}/fetch`, - body: params, + body: normalizedParams, }) : // Else use `GET /documents` method await this.httpRequest.get>({ path: relativeBaseURL, - params, + params: normalizedParams, }); } diff --git a/src/types/types.ts b/src/types/types.ts index 3d57fb6d9..a58990193 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -141,6 +141,7 @@ export type ResultsWrapper = { export type IndexOptions = { primaryKey?: string; + uid?: string; }; export type IndexObject = { @@ -408,6 +409,7 @@ export type SearchResponse< facetDistribution?: FacetDistribution; facetStats?: FacetStats; facetsByIndex?: FacetsByIndex; + queryVector?: number[]; } & (undefined extends S ? Partial : true extends IsFinitePagination> @@ -508,6 +510,12 @@ export type DocumentsQuery = ResourceQuery & { limit?: number; offset?: number; retrieveVectors?: boolean; + /** + * Array of strings containing the attributes to sort on. Each string should + * be in the format "attribute:direction" where direction is either "asc" or + * "desc". Example: ["price:asc", "rating:desc"] + */ + sort?: string[]; }; export type DocumentQuery = { diff --git a/tests/client.test.ts b/tests/client.test.ts index a426d5515..2b3bdac5e 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -566,6 +566,30 @@ describe.each([{ permission: "Master" }, { permission: "Admin" }])( ErrorStatusCode.INVALID_SWAP_DUPLICATE_INDEX_FOUND, ); }); + + test(`${permission} key: Swap two indexes with rename`, async () => { + const client = await getClient(permission); + const originalUid1 = index.uid; + const originalUid2 = index2.uid; + + await client + .index(originalUid1) + .addDocuments([{ id: 1, title: "index_1" }]) + .waitTask(); + await client + .index(originalUid2) + .addDocuments([{ id: 1, title: "index_2" }]) + .waitTask(); + + const swaps: IndexSwap[] = [ + { indexes: [originalUid1, originalUid2], rename: true }, + ]; + + const resolvedTask = await client.swapIndexes(swaps).waitTask(); + + expect(resolvedTask.type).toEqual("indexSwap"); + expect(resolvedTask.details?.swaps).toEqual(swaps); + }); }); describe("Test on base routes", () => { @@ -603,7 +627,7 @@ describe.each([{ permission: "Master" }, { permission: "Admin" }])( const response: Stats = await client.getStats(); expect(response).toHaveProperty("databaseSize", expect.any(Number)); expect(response).toHaveProperty("usedDatabaseSize", expect.any(Number)); - expect(response).toHaveProperty("lastUpdate"); // TODO: Could be null, find out why + expect(response).toHaveProperty("lastUpdate"); expect(response).toHaveProperty("indexes", expect.any(Object)); }); }); diff --git a/tests/documents.test.ts b/tests/documents.test.ts index e9c958761..87f1fa9d9 100644 --- a/tests/documents.test.ts +++ b/tests/documents.test.ts @@ -258,6 +258,89 @@ describe("Documents tests", () => { expect(documentsGet.results[0]).not.toHaveProperty("_vectors"); }); + test(`${permission} key: Get documents with sorting by single field`, async () => { + const client = await getClient(permission); + + await client + .index(indexPk.uid) + .updateSortableAttributes(["id"]) + .waitTask(); + + await client.index(indexPk.uid).addDocuments(dataset).waitTask(); + + const documents = await client.index(indexPk.uid).getDocuments({ + sort: ["id:asc"], + }); + + expect(documents.results.length).toEqual(dataset.length); + // Verify documents are sorted by id in ascending order + const ids = documents.results.map((doc) => doc.id); + const sortedIds = [...ids].sort((a, b) => a - b); + expect(ids).toEqual(sortedIds); + }); + + test(`${permission} key: Get documents with sorting by multiple fields`, async () => { + const client = await getClient(permission); + + await client + .index(indexPk.uid) + .updateSortableAttributes(["id", "title"]) + .waitTask(); + + const customDocs: Book[] = [ + { id: 1, title: "Orders", genre: ["Drama"], author: "Author A" }, + { id: 2, title: "Orders", genre: ["Drama"], author: "Author B" }, + { id: 3, title: "Payments", genre: ["Drama"], author: "Author C" }, + ]; + + await client.index(indexPk.uid).addDocuments(customDocs).waitTask(); + + const documents = await client.index(indexPk.uid).getDocuments({ + sort: ["title:asc", "id:desc"], + }); + + expect(documents.results.length).toEqual(customDocs.length); + const results = documents.results.map((doc) => ({ + title: doc.title, + id: doc.id, + })); + expect(results).toEqual([ + { title: "Orders", id: 2 }, + { title: "Orders", id: 1 }, + { title: "Payments", id: 3 }, + ]); + }); + + test(`${permission} key: Get documents with empty sort array`, async () => { + const client = await getClient(permission); + + await client + .index(indexPk.uid) + .updateSortableAttributes(["id"]) + .waitTask(); + + await client.index(indexPk.uid).addDocuments(dataset).waitTask(); + + const documents = await client.index(indexPk.uid).getDocuments({ + sort: [], + }); + + expect(documents.results.length).toEqual(dataset.length); + // Should return documents in default order (no specific sorting) + }); + + test(`${permission} key: Get documents with sorting should trigger error for non-sortable attribute`, async () => { + const client = await getClient(permission); + + await client.index(indexPk.uid).addDocuments(dataset).waitTask(); + + await assert.rejects( + client.index(indexPk.uid).getDocuments({ sort: ["title:asc"] }), + Error, + /Attribute `title` is not sortable/, + ); + }); + test(`${permission} key: Replace documents from index that has NO primary key`, async () => { const client = await getClient(permission); await client.index(indexNoPk.uid).addDocuments(dataset).waitTask(); diff --git a/tests/get_search.test.ts b/tests/get_search.test.ts index 3a6771b1d..a6bfbd661 100644 --- a/tests/get_search.test.ts +++ b/tests/get_search.test.ts @@ -542,7 +542,6 @@ describe.each([ }); }); - // 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"); diff --git a/tests/index.test.ts b/tests/index.test.ts index faea55881..add24e670 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -292,6 +292,27 @@ describe.each([{ permission: "Master" }, { permission: "Admin" }])( expect(index).toHaveProperty("primaryKey", "newPrimaryKey"); }); + test(`${permission} key: rename index using update method`, async () => { + const client = await getClient(permission); + const originalUid = indexNoPk.uid; + const newUid = "renamed_index"; + + await client.createIndex(originalUid).waitTask(); + await client + .updateIndex(originalUid, { + uid: newUid, + }) + .waitTask(); + + await expect(client.getIndex(originalUid)).rejects.toHaveProperty( + "cause.code", + ErrorStatusCode.INDEX_NOT_FOUND, + ); + + const renamed = await client.getIndex(newUid); + expect(renamed).toHaveProperty("uid", newUid); + }); + test(`${permission} key: delete index`, async () => { const client = await getClient(permission); await client.createIndex(indexNoPk.uid).waitTask(); diff --git a/tests/utils/meilisearch-test-utils.ts b/tests/utils/meilisearch-test-utils.ts index 234e502d5..55d663e11 100644 --- a/tests/utils/meilisearch-test-utils.ts +++ b/tests/utils/meilisearch-test-utils.ts @@ -16,7 +16,7 @@ const BAD_HOST = "http://127.0.0.1:7701"; const config: Config = { host: HOST, apiKey: MASTER_KEY, - defaultWaitOptions: { interval: 10 }, + defaultWaitOptions: { interval: 10, timeout: 60_000 }, }; const badHostClient = new MeiliSearch({ host: BAD_HOST, @@ -25,12 +25,12 @@ const badHostClient = new MeiliSearch({ const masterClient = new MeiliSearch({ host: HOST, apiKey: MASTER_KEY, - defaultWaitOptions: { interval: 10 }, + defaultWaitOptions: { interval: 10, timeout: 60_000 }, }); const anonymousClient = new MeiliSearch({ host: HOST, - defaultWaitOptions: { interval: 10 }, + defaultWaitOptions: { interval: 10, timeout: 60_000 }, }); async function getKey(permission: string): Promise { @@ -70,7 +70,7 @@ async function getClient(permission: string): Promise { const searchClient = new MeiliSearch({ host: HOST, apiKey: searchKey, - defaultWaitOptions: { interval: 10 }, + defaultWaitOptions: { interval: 10, timeout: 60_000 }, }); return searchClient; } @@ -80,7 +80,7 @@ async function getClient(permission: string): Promise { const adminClient = new MeiliSearch({ host: HOST, apiKey: adminKey, - defaultWaitOptions: { interval: 10 }, + defaultWaitOptions: { interval: 10, timeout: 60_000 }, }); return adminClient; }