Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 117 additions & 53 deletions dist/typesense-instantsearch-adapter.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/typesense-instantsearch-adapter.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/typesense-instantsearch-adapter.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/typesense-instantsearch-adapter.min.js.map

Large diffs are not rendered by default.

168 changes: 116 additions & 52 deletions lib/SearchRequestAdapter.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion lib/SearchRequestAdapter.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"typesenseServer:1": "docker run -i -p 7108:7108 -p 7107:7107 -v/tmp/.typesense-server-data-node-2/:/data -v`pwd`/typesense-server-nodes:/typesense-server-nodes typesense/typesense:0.19.0 --data-dir /data --api-key=xyz --listen-port 7108 --peering-port 7107 --enable-cors --nodes=/typesense-server-nodes",
"typesenseServer:2": "docker run -i -p 9108:9108 -p 9107:9107 -v/tmp/.typesense-server-data-node-3/:/data -v`pwd`/typesense-server-nodes:/typesense-server-nodes typesense/typesense:0.19.0 --data-dir /data --api-key=xyz --listen-port 9108 --peering-port 9107 --enable-cors --nodes=/typesense-server-nodes",
"testground": "yarn link && cd test/support/testground && yarn link typesense-instantsearch-adapter && yarn install && yarn start",
"indexTestData": "node test/support/populateProductsIndex.js && node test/support/populateBrandsIndex.js && node test/support/populateRecipesIndex.js && node test/support/populateAirportsIndex.js"
"indexTestData": "node test/support/populateProductsIndex.js && node test/support/populateBrandsIndex.js && node test/support/populateRecipesIndex.js && node test/support/populateAirportsIndex.js && node test/support/populateProductPricesIndex.js"
},
"author": {
"name": "Typesense, Inc.",
Expand Down
108 changes: 89 additions & 19 deletions src/SearchRequestAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export class SearchRequestAdapter {
return new RegExp("(.*?)(<=|>=|>|<|=)(.*)");
}

static get JOINED_RELATION_FILTER_REGEX() {
return new RegExp("^(\\$[^(]+)\\(([^)]+)\\)$");
}

constructor(instantsearchRequests, typesenseClient, configuration) {
this.instantsearchRequests = instantsearchRequests;
this.typesenseClient = typesenseClient;
Expand All @@ -32,6 +36,30 @@ export class SearchRequestAdapter {
}
}

_buildFacetFilterString({ fieldName, fieldValues, isExcluded, collectionName }) {
// Check if this is a joined relation filter (e.g., "$refCollection(retailer)")
const joinedRelationMatch = fieldName.match(this.constructor.JOINED_RELATION_FILTER_REGEX);

const operator = isExcluded
? this._shouldUseExactMatchForField(fieldName, collectionName)
? ":!="
: ":!"
: this._shouldUseExactMatchForField(fieldName, collectionName)
? ":="
: ":";

if (joinedRelationMatch && joinedRelationMatch.length >= 3) {
// This is a joined relation filter
const collection = joinedRelationMatch[1]; // e.g., "$refCollection"
const fieldPath = joinedRelationMatch[2]; // e.g., "retailer"
// For joined relations, the filter should be: $collection(field:=[value1,value2])
return `${collection}(${fieldPath}${operator}[${fieldValues.map((v) => this._escapeFacetValue(v)).join(",")}])`;
} else {
// Regular field filter (non-joined)
return `${fieldName}${operator}[${fieldValues.map((v) => this._escapeFacetValue(v)).join(",")}]`;
}
}

_adaptFacetFilters(facetFilters, collectionName) {
let adaptedResult = "";

Expand Down Expand Up @@ -108,15 +136,23 @@ export class SearchRequestAdapter {

const typesenseFilterStringComponents = [];
if (includedFieldValues.length > 0) {
const operator = this._shouldUseExactMatchForField(fieldName, collectionName) ? ":=" : ":";
typesenseFilterStringComponents.push(
`${fieldName}${operator}[${includedFieldValues.map((v) => this._escapeFacetValue(v)).join(",")}]`,
this._buildFacetFilterString({
fieldName,
fieldValues: includedFieldValues,
isExcluded: false,
collectionName,
}),
);
}
if (excludedFieldValues.length > 0) {
const operator = this._shouldUseExactMatchForField(fieldName, collectionName) ? ":!=" : ":!";
typesenseFilterStringComponents.push(
`${fieldName}${operator}[${excludedFieldValues.map((v) => this._escapeFacetValue(v)).join(",")}]`,
this._buildFacetFilterString({
fieldName,
fieldValues: excludedFieldValues,
isExcluded: true,
collectionName,
}),
);
}

Expand All @@ -132,11 +168,19 @@ export class SearchRequestAdapter {
const { fieldName, fieldValue } = this._parseFacetFilter(item);
let typesenseFilterString;
if (fieldValue.startsWith("-") && !this._isNumber(fieldValue)) {
const operator = this._shouldUseExactMatchForField(fieldName, collectionName) ? ":!=" : ":!";
typesenseFilterString = `${fieldName}${operator}[${this._escapeFacetValue(fieldValue.substring(1))}]`;
typesenseFilterString = this._buildFacetFilterString({
fieldName,
fieldValues: [fieldValue.substring(1)],
isExcluded: true,
collectionName,
});
} else {
const operator = this._shouldUseExactMatchForField(fieldName, collectionName) ? ":=" : ":";
typesenseFilterString = `${fieldName}${operator}[${this._escapeFacetValue(fieldValue)}]`;
typesenseFilterString = this._buildFacetFilterString({
fieldName,
fieldValues: [fieldValue],
isExcluded: false,
collectionName,
});
}

return typesenseFilterString;
Expand Down Expand Up @@ -247,18 +291,44 @@ export class SearchRequestAdapter {
// "field1:=[634..289] && field2:<=5 && field3:>=3"
const adaptedFilters = [];
Object.keys(filtersHash).forEach((field) => {
if (filtersHash[field]["<="] != null && filtersHash[field][">="] != null) {
adaptedFilters.push(`${field}:=[${filtersHash[field][">="]}..${filtersHash[field]["<="]}]`);
} else if (filtersHash[field]["<="] != null) {
adaptedFilters.push(`${field}:<=${filtersHash[field]["<="]}`);
} else if (filtersHash[field][">="] != null) {
adaptedFilters.push(`${field}:>=${filtersHash[field][">="]}`);
} else if (filtersHash[field]["="] != null) {
adaptedFilters.push(`${field}:=${filtersHash[field]["="]}`);
// Check if this is a joined relation filter (e.g., "$refCollection(price.current)")
const joinedRelationMatch = field.match(this.constructor.JOINED_RELATION_FILTER_REGEX);

if (joinedRelationMatch && joinedRelationMatch.length >= 3) {
// This is a joined relation filter
const collection = joinedRelationMatch[1]; // e.g., "$refCollection"
const fieldPath = joinedRelationMatch[2]; // e.g., "price.current"

if (filtersHash[field]["<="] != null && filtersHash[field][">="] != null) {
adaptedFilters.push(
`${collection}(${fieldPath}:=[${filtersHash[field][">="]}..${filtersHash[field]["<="]}])`,
);
} else if (filtersHash[field]["<="] != null) {
adaptedFilters.push(`${collection}(${fieldPath}:<=${filtersHash[field]["<="]})`);
} else if (filtersHash[field][">="] != null) {
adaptedFilters.push(`${collection}(${fieldPath}:>=${filtersHash[field][">="]})`);
} else if (filtersHash[field]["="] != null) {
adaptedFilters.push(`${collection}(${fieldPath}:=${filtersHash[field]["="]})`);
} else {
console.warn(
`[Typesense-Instantsearch-Adapter] Unsupported operator found ${JSON.stringify(filtersHash[field])}`,
);
}
} else {
console.warn(
`[Typesense-Instantsearch-Adapter] Unsupported operator found ${JSON.stringify(filtersHash[field])}`,
);
// Regular field filter (non-joined)
if (filtersHash[field]["<="] != null && filtersHash[field][">="] != null) {
adaptedFilters.push(`${field}:=[${filtersHash[field][">="]}..${filtersHash[field]["<="]}]`);
} else if (filtersHash[field]["<="] != null) {
adaptedFilters.push(`${field}:<=${filtersHash[field]["<="]}`);
} else if (filtersHash[field][">="] != null) {
adaptedFilters.push(`${field}:>=${filtersHash[field][">="]}`);
} else if (filtersHash[field]["="] != null) {
adaptedFilters.push(`${field}:=${filtersHash[field]["="]}`);
} else {
console.warn(
`[Typesense-Instantsearch-Adapter] Unsupported operator found ${JSON.stringify(filtersHash[field])}`,
);
}
}
});

Expand Down
112 changes: 112 additions & 0 deletions test/SearchRequestAdpater.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,44 @@ describe("SearchRequestAdapter", () => {
);
});
});

describe("when using joined relation filters", () => {
it("adapts joined relation numeric filters with range", () => {
const subject = new SearchRequestAdapter([], null, {});

const result = subject._adaptNumericFilters([
"$product_prices(price.current)<=2684",
"$product_prices(price.current)>=100",
]);
expect(result).toEqual("$product_prices(price.current:=[100..2684])");
});

it("adapts joined relation numeric filters with single operator", () => {
const subject = new SearchRequestAdapter([], null, {});

const result = subject._adaptNumericFilters(["$product_prices(price.current)>=100"]);
expect(result).toEqual("$product_prices(price.current:>=100)");
});

it("adapts mixed joined and regular numeric filters", () => {
const subject = new SearchRequestAdapter([], null, {});

const result = subject._adaptNumericFilters([
"field1<=634",
"field1>=289",
"$product_prices(price.current)<=2684",
"$product_prices(price.current)>=100",
]);
expect(result).toEqual("field1:=[289..634] && $product_prices(price.current:=[100..2684])");
});

it("adapts joined relation filters with equality operator", () => {
const subject = new SearchRequestAdapter([], null, {});

const result = subject._adaptNumericFilters(["$product_prices(quantity)=5"]);
expect(result).toEqual("$product_prices(quantity:=5)");
});
});
});

describe("._adaptFacetFilters", () => {
Expand Down Expand Up @@ -203,6 +241,80 @@ describe("SearchRequestAdapter", () => {
);
});
});

describe("when using joined relation filters", () => {
it("adapts joined relation filters for single values", () => {
const subject = new SearchRequestAdapter([], null, {});

const result = subject._adaptFacetFilters(
["$product_prices(retailer):value1", "$product_prices(status):active"],
"collection1",
);
expect(result).toEqual("$product_prices(retailer:=[`value1`]) && $product_prices(status:=[`active`])");
});

it("adapts joined relation filters for array values (OR)", () => {
const subject = new SearchRequestAdapter([], null, {});

const result = subject._adaptFacetFilters(
[["$product_prices(retailer):value1", "$product_prices(retailer):value2"], "$product_prices(status):active"],
"collection1",
);
expect(result).toEqual("$product_prices(retailer:=[`value1`,`value2`]) && $product_prices(status:=[`active`])");
});

it("adapts joined relation filters with excluded values", () => {
const subject = new SearchRequestAdapter([], null, {});

const result = subject._adaptFacetFilters(
["$product_prices(retailer):-value1", "$product_prices(status):active"],
"collection1",
);
expect(result).toEqual("$product_prices(retailer:!=[`value1`]) && $product_prices(status:=[`active`])");
});

it("adapts joined relation filters with both included and excluded values", () => {
const subject = new SearchRequestAdapter([], null, {});

const result = subject._adaptFacetFilters(
[
[
"$product_prices(retailer):value1",
"$product_prices(retailer):value2",
"$product_prices(retailer):-value3",
],
],
"collection1",
);
expect(result).toEqual(
"$product_prices(retailer:=[`value1`,`value2`]) && $product_prices(retailer:!=[`value3`])",
);
});

it("adapts joined relation filters with exactMatch disabled", () => {
const subject = new SearchRequestAdapter([], null, {
filterByOptions: {
"$product_prices(retailer)": { exactMatch: false },
},
});

const result = subject._adaptFacetFilters(
[["$product_prices(retailer):value1", "$product_prices(retailer):value2"]],
"collection1",
);
expect(result).toEqual("$product_prices(retailer:[`value1`,`value2`])");
});

it("adapts joined relation filters with nested field paths", () => {
const subject = new SearchRequestAdapter([], null, {});

const result = subject._adaptFacetFilters(
["$product_prices(price.current):100", "$product_prices(price.original):200"],
"collection1",
);
expect(result).toEqual("$product_prices(price.current:=[100]) && $product_prices(price.original:=[200])");
});
});
});

describe(".adaptFacetBy", () => {
Expand Down
90 changes: 90 additions & 0 deletions test/joins.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
describe("Joins", () => {
beforeAll(require("./support/beforeAll"), 60 * 1000);

beforeEach(async () => {
return page.goto("http://localhost:3000/joins.html");
}, 30 * 1000);

describe("when rendering the page", () => {
it("renders all products initially", async () => {
await expect(page).toMatchElement("#stats", {
text: "10 results found",
});
});
});

describe("when filtering by retailer (join facet)", () => {
it("filters products by Amazon retailer", async () => {
await expect(page).toClick("#retailer-list input[type=checkbox][value=Amazon]");
await expect(page).toMatchElement("#stats", {
text: "10 results found",
});
});

it("filters products by BestBuy retailer", async () => {
await expect(page).toClick("#retailer-list input[type=checkbox][value=BestBuy]");
await expect(page).toMatchElement("#stats", {
text: "5 results found",
});
});

it("filters products by Walmart retailer", async () => {
await expect(page).toClick("#retailer-list input[type=checkbox][value=Walmart]");
await expect(page).toMatchElement("#stats", {
text: "7 results found",
});
});

it("filters products by Target retailer", async () => {
await expect(page).toClick("#retailer-list input[type=checkbox][value=Target]");
await expect(page).toMatchElement("#stats", {
text: "6 results found",
});
});

it("filters products by multiple retailers", async () => {
await expect(page).toClick("#retailer-list input[type=checkbox][value=Amazon]");
await expect(page).toClick("#retailer-list input[type=checkbox][value=BestBuy]");
await expect(page).toMatchElement("#stats", {
text: "10 results found",
});
});
});

describe("when filtering by price range (join numeric)", () => {
it("filters products by price range using range input", async () => {
await expect(page).toFill("#price-range-input input[type=number]:first-of-type", "50");
await expect(page).toFill("#price-range-input input[type=number]:last-of-type", "100");
await page.keyboard.press("Enter");

await page.waitForSelector("#stats", { visible: true });

const statsText = await page.$eval("#stats", (el) => el.textContent);
expect(statsText).toMatch(/\d+ results? found/);
});
});

describe("when combining filters", () => {
it("filters products by retailer and price range", async () => {
await expect(page).toClick("#retailer-list input[type=checkbox][value=Amazon]");
await expect(page).toFill("#price-range-input input[type=number]:first-of-type", "20");
await expect(page).toFill("#price-range-input input[type=number]:last-of-type", "50");
await page.keyboard.press("Enter");

await page.waitForSelector("#stats", { visible: true });

const statsText = await page.$eval("#stats", (el) => el.textContent);
expect(statsText).toMatch(/\d+ results? found/);
});
});

describe("when clearing refinements", () => {
it("clears all filters and shows all products", async () => {
await expect(page).toClick("#retailer-list input[type=checkbox][value=Amazon]");
await expect(page).toClick("#clear-refinements button");
await expect(page).toMatchElement("#stats", {
text: "10 results found",
});
});
});
});
3 changes: 2 additions & 1 deletion test/support/beforeAll.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ module.exports = async () => {
await require("./populateProductsIndex");
await require("./populateBrandsIndex");
await require("./populateRecipesIndex");
return require("./populateAirportsIndex");
await require("./populateAirportsIndex");
return require("./populateProductPricesIndex");
};
Loading