Skip to content

Commit

Permalink
Merge pull request #242 from tharropoulos/search-params
Browse files Browse the repository at this point in the history
feat: Add search parameter normalization utilities and type safety
  • Loading branch information
jasonbosco authored Nov 11, 2024
2 parents 2e31446 + 47eccb2 commit 51a3b89
Show file tree
Hide file tree
Showing 9 changed files with 549 additions and 33 deletions.
48 changes: 48 additions & 0 deletions src/Typesense/Documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,54 @@ type DropTokensMode =
| "both_sides:3";

type OperationMode = "off" | "always" | "fallback";

export type UnionArrayKeys<T> = {
[K in keyof T]: T[K] extends undefined
? never
: NonNullable<T[K]> extends infer R
? R extends R[]
? never
: R extends (infer U)[] | infer U
? U[] extends R
? K
: never
: never
: never;
}[keyof T] &
keyof T;

export type UnionArraySearchParams = UnionArrayKeys<SearchParams>;

export type ArraybleParams = {
readonly [K in UnionArraySearchParams]: string;
};

export type ExtractBaseTypes<T> = {
[K in keyof T]: K extends UnionArrayKeys<T>
? T[K] extends (infer U)[] | infer U
? U
: T[K]
: T[K];
};

export const arrayableParams: ArraybleParams = {
query_by: "query_by",
query_by_weights: "query_by_weights",
facet_by: "facet_by",
group_by: "group_by",
include_fields: "include_fields",
exclude_fields: "exclude_fields",
highlight_fields: "highlight_fields",
highlight_full_fields: "highlight_full_fields",
pinned_hits: "pinned_hits",
hidden_hits: "hidden_hits",
infix: "infix",
override_tags: "override_tags",
num_typos: "num_typos",
prefix: "prefix",
sort_by: "sort_by",
};

export interface SearchParams {
// From https://typesense.org/docs/latest/api/documents.html#arguments
q?: string;
Expand Down
4 changes: 3 additions & 1 deletion src/Typesense/Keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createHmac } from "crypto";
import ApiCall from "./ApiCall";
import { KeyCreateSchema, KeySchema } from "./Key";
import { SearchParams } from "./Documents";
import { normalizeArrayableParams } from "./Utils";

const RESOURCEPATH = "/keys";

Expand Down Expand Up @@ -34,7 +35,8 @@ export default class Keys {
): string {
// Note: only a key generated with the `documents:search` action will be
// accepted by the server, when usined with the search endpoint.
const paramsJSON = JSON.stringify(parameters);
const normalizedParams = normalizeArrayableParams(parameters);
const paramsJSON = JSON.stringify(normalizedParams);
const digest = Buffer.from(
createHmac("sha256", searchKey).update(paramsJSON).digest("base64")
);
Expand Down
19 changes: 16 additions & 3 deletions src/Typesense/MultiSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
SearchParamsWithPreset,
SearchResponse,
} from "./Documents";
import { normalizeArrayableParams } from "./Utils";

const RESOURCEPATH = "/multi_search";

Expand Down Expand Up @@ -63,13 +64,25 @@ export default class MultiSearch {
if (this.configuration.useServerSideSearchCache === true) {
additionalQueryParams["use_cache"] = true;
}
const queryParams = Object.assign({}, commonParams, additionalQueryParams);

const queryParams = { ...commonParams, ...additionalQueryParams };

const normalizedSearchRequests = {
searches: searchRequests.searches.map(normalizeArrayableParams),
};

const normalizedQueryParams = normalizeArrayableParams(queryParams);

return this.requestWithCache.perform(
this.apiCall,
this.apiCall.post,
[RESOURCEPATH, searchRequests, queryParams, additionalHeaders],
{ cacheResponseForSeconds: cacheSearchResultsForSeconds }
[
RESOURCEPATH,
normalizedSearchRequests,
normalizedQueryParams,
additionalHeaders,
],
{ cacheResponseForSeconds: cacheSearchResultsForSeconds },
) as Promise<MultiSearchResponse<T>>;
}
}
18 changes: 16 additions & 2 deletions src/Typesense/Presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import ApiCall from "./ApiCall";
import { PresetSchema } from "./Preset";
import { SearchParams } from "./Documents";
import { MultiSearchRequestsSchema } from "./MultiSearch";
import { normalizeArrayableParams } from "./Utils";

const RESOURCEPATH = "/presets";

Expand All @@ -18,9 +19,22 @@ export default class Presets {

async upsert(
presetId: string,
params: PresetCreateSchema
params: PresetCreateSchema,
): Promise<PresetSchema> {
return this.apiCall.put<PresetSchema>(this.endpointPath(presetId), params);
if (typeof params.value === "object" && "searches" in params.value) {
const normalizedParams = params.value.searches.map((search) =>
normalizeArrayableParams(search),
);

return this.apiCall.put<PresetSchema>(this.endpointPath(presetId), {
value: { searches: normalizedParams },
});
}
const normalizedParams = normalizeArrayableParams(params.value);

return this.apiCall.put<PresetSchema>(this.endpointPath(presetId), {
value: normalizedParams,
});
}

async retrieve(): Promise<PresetsRetrieveSchema> {
Expand Down
11 changes: 4 additions & 7 deletions src/Typesense/SearchOnlyDocuments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
SearchParamsWithPreset,
SearchResponse,
} from "./Documents";
import { normalizeArrayableParams } from "./Utils";

const RESOURCEPATH = "/documents";

Expand Down Expand Up @@ -40,15 +41,11 @@ export class SearchOnlyDocuments<T extends DocumentSchema>
if (this.configuration.useServerSideSearchCache === true) {
additionalQueryParams["use_cache"] = true;
}
for (const key in searchParameters) {
if (Array.isArray(searchParameters[key])) {
additionalQueryParams[key] = searchParameters[key].join(",");
}
}
const normalizedParams = normalizeArrayableParams(searchParameters);
const queryParams = Object.assign(
{},
searchParameters,
additionalQueryParams
additionalQueryParams,
normalizedParams,
);

return this.requestWithCache.perform(
Expand Down
50 changes: 50 additions & 0 deletions src/Typesense/Utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { arrayableParams } from "./Documents";
import type {
UnionArrayKeys,
ExtractBaseTypes,
SearchParams,
} from "./Documents";

function hasNoArrayValues<T extends SearchParams>(
params: T | ExtractBaseTypes<T>,
): params is ExtractBaseTypes<T> {
return Object.keys(arrayableParams)
.filter((key) => params[key] !== undefined)
.every((key) => isNonArrayValue(params[key]));
}

export function normalizeArrayableParams<T extends SearchParams>(
params: T,
): Prettify<ExtractBaseTypes<T>> {
const result = { ...params };

const transformedValues = Object.keys(arrayableParams)
.filter((key) => Array.isArray(result[key]))
.map((key) => {
result[key] = result[key].join(",");
return key;
});

if (!transformedValues.length && hasNoArrayValues(result)) {
return result;
}

if (!hasNoArrayValues(result)) {
throw new Error(
`Failed to normalize arrayable params: ${JSON.stringify(result)}`,
);
}

return result;
}

function isNonArrayValue<T extends SearchParams, K extends UnionArrayKeys<T>>(
value: T[K] | ExtractBaseTypes<T>[K],
): value is ExtractBaseTypes<T>[K] {
return !Array.isArray(value);
}

type Prettify<T> = {
[K in keyof T]: T[K];
// eslint-disable-next-line @typescript-eslint/ban-types
} & {};
59 changes: 55 additions & 4 deletions test/Typesense/MultiSearch.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,57 @@ describe("MultiSearch", function () {
});

describe(".perform", function () {
it("normalizes the search parameters in both params and body", async function () {
const searches = {
searches: [
{ q: "term1", facet_by: ["field1", "field2"] },
{ q: "term2", facet_by: "field3" },
],
};
const commonParams = {
collection: "docs",
query_by: ["field", "field2"],
};

let capturedParams;
let capturedBody;

mockAxios
.onPost(
apiCall.uriFor("/multi_search", typesense.configuration.nodes[0]),
// Match the exact body structure
{
searches: [
{ q: "term1", facet_by: "field1,field2" },
{ q: "term2", facet_by: "field3" },
],
},
{
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
"X-TYPESENSE-API-KEY": typesense.configuration.apiKey,
},
)
.reply((config) => {
capturedParams = config.params;
capturedBody = JSON.parse(config.data);
return [200, "{}", { "content-type": "application/json" }];
});

await typesense.multiSearch.perform(searches, commonParams);

expect(capturedParams).to.deep.equal({
collection: "docs",
query_by: "field,field2",
});

expect(capturedBody).to.deep.equal({
searches: [
{ q: "term1", facet_by: "field1,field2" },
{ q: "term2", facet_by: "field3" },
],
});
});
it("performs a multi-search", function (done) {
let searches = {
searches: [{ q: "term1" }, { q: "term2" }],
Expand All @@ -48,7 +99,7 @@ describe("MultiSearch", function () {
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
"X-TYPESENSE-API-KEY": typesense.configuration.apiKey,
}
},
)
.reply((config) => {
expect(config.params).to.deep.equal(commonParams);
Expand Down Expand Up @@ -90,7 +141,7 @@ describe("MultiSearch", function () {
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
"X-TYPESENSE-API-KEY": typesense.configuration.apiKey,
}
},
)
.reply((config) => {
expect(config.params).to.deep.equal(commonParams[i]);
Expand Down Expand Up @@ -121,7 +172,7 @@ describe("MultiSearch", function () {
// Now wait 60s and then retry the request, still should be fetched from cache
timekeeper.freeze(currentTime + 60 * 1000);
returnData.push(
await typesense.multiSearch.perform(searchRequests[1], commonParams[1])
await typesense.multiSearch.perform(searchRequests[1], commonParams[1]),
);
expect(returnData[3]).to.deep.equal(stubbedSearchResults[1]);

Expand All @@ -131,7 +182,7 @@ describe("MultiSearch", function () {
// Now wait 2 minutes and then retry the request, it should now make an actual request, since cache is stale
timekeeper.freeze(currentTime + 121 * 1000);
returnData.push(
await typesense.multiSearch.perform(searchRequests[1], commonParams[1])
await typesense.multiSearch.perform(searchRequests[1], commonParams[1]),
);
expect(returnData[4]).to.deep.equal(stubbedSearchResults[1]);

Expand Down
Loading

0 comments on commit 51a3b89

Please sign in to comment.