Skip to content

Commit

Permalink
feat(api): enhance /search filtering and aggregations
Browse files Browse the repository at this point in the history
Add support for filter operations (include/exclude), OR logic via array
values, dynamic aggregation fields and sub-aggregations in the search API.
Allow switching between default and coredev indices. This consolidates
search functionality across products by making the Bitcoin Search API
more flexible while maintaining backward compatibility.
  • Loading branch information
kouloumos committed Dec 20, 2024
1 parent bf233c4 commit 2c4e9b8
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 71 deletions.
4 changes: 1 addition & 3 deletions src/components/sidebarFacet/Facet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,7 @@ const Facet = ({ field, isFilterable, label, view, callback }: FacetProps) => {
} = useSearchQuery();
// temporary conditional
const fieldAggregate: FacetAggregateBucket =
field === "domain"
? data?.aggregations?.["domains"]?.["buckets"] ?? []
: data?.aggregations?.[field]?.["buckets"] ?? [];
data?.aggregations?.[field]?.["buckets"] ?? [];
const { getFilter, addFilter, removeFilter } = useURLManager();

const selectedList = getFilter(field);
Expand Down
8 changes: 4 additions & 4 deletions src/components/sidebarFacet/FilterMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Facet } from "@/types";
import { Facet, FacetKeys } from "@/types";
import Image from "next/image";
import React from "react";
import SidebarSection from "./SidebarSection";
Expand Down Expand Up @@ -70,13 +70,13 @@ const AppliedFilters = ({ filters }: { filters: Facet[] }) => {
role="button"
onClick={() =>
removeFilter({
filterType: filter.field,
filterValue: filter.value,
filterType: filter.field as FacetKeys,
filterValue: filter.value as string,
})
}
>
<span className="capitalize text-sm font-semibold 2xl:text-sm">
{getFilterValueDisplay(filter.value, filter.field)}
{getFilterValueDisplay(filter.value as string, filter.field)}
</span>
<Image
src={isDark ? DarkCrossIcon : CrossIcon}
Expand Down
22 changes: 15 additions & 7 deletions src/pages/api/elasticSearchProxy/search.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { client } from "@/config/elasticsearch";
import { buildQuery } from "@/utils/server/apiFunctions";
// import ElasticsearchAPIConnector from "@elastic/search-ui-elasticsearch-connector";

export default async function handler(
req: NextApiRequest,
Expand All @@ -14,11 +13,19 @@ export default async function handler(
});
}

let queryString = req.body.queryString as string;
let size = req.body.size;
let page = req.body.page;
let filterFields = req.body.filterFields;
let sortFields = req.body.sortFields;
const {
queryString,
size,
page,
filterFields,
sortFields,
aggregationFields,
index = "default",
} = req.body;

// Select index based on parameter
const selectedIndex =
index === "coredev" ? process.env.COREDEV_INDEX : process.env.INDEX;

const from = page * size;
let searchQuery = buildQuery({
Expand All @@ -27,12 +34,13 @@ export default async function handler(
sortFields,
from,
size,
aggregationFields,
});

try {
// Call the search method
const result = await client.search({
index: process.env.INDEX,
index: selectedIndex,
...searchQuery,
});

Expand Down
15 changes: 14 additions & 1 deletion src/service/api/search/searchCall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,25 @@ export const buildQueryCall: BuildQuery = async (
{ queryString, size, page, filterFields, sortFields },
url
) => {
const appFilterFields = [
...filterFields,
// Application-specific filters
{ field: "type", value: "combined-summary", operation: "exclude" },
];

const aggregations = [
{ field: "authors" },
{ field: "domain" },
{ field: "tags" },
];

const body = {
queryString,
size,
page,
filterFields,
filterFields: appFilterFields,
sortFields,
aggregationFields: aggregations,
};

const jsonBody = JSON.stringify(body);
Expand Down
34 changes: 26 additions & 8 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import {
AggregationsAggregate,
AggregationsAggregationContainer,
SearchResponse,
} from "@elastic/elasticsearch/lib/api/types";
const AUTHOR = "authors" as const;
const DOMAIN = "domain" as const;
const TAGS = "tags" as const;

export type FacetKeys = typeof AUTHOR | typeof DOMAIN | typeof TAGS;
export const FACETS = {
AUTHOR: "authors",
DOMAIN: "domain",
TAGS: "tags",
} as const;

export type FacetKeys = (typeof FACETS)[keyof typeof FACETS];

export type FilterOperation = "include" | "exclude";

export type Facet = {
field: FacetKeys;
value: string;
field: string;
value: string | string[];
operation?: FilterOperation;
};

const bodyType = {
Expand All @@ -20,14 +27,25 @@ const bodyType = {
"combined-summary": "combined-summary",
} as const;

export type SortOption = "asc" | "desc";
type SortOption = {
field: string;
value: "asc" | "desc";
};

export interface AggregationField {
field: string;
size?: number;
subAggregations?: Record<string, AggregationsAggregationContainer>; // Raw ES aggregation config
}

export type SearchQuery = {
queryString: string;
size: number;
page: number;
filterFields: Facet[];
sortFields: any[];
sortFields: SortOption[];
aggregationFields?: AggregationField[];
index?: string;
};

export type EsSearchResult = {
Expand Down
118 changes: 70 additions & 48 deletions src/utils/server/apiFunctions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type {
QueryDslQueryContainer,
SearchRequest,
} from "@elastic/elasticsearch/lib/api/types";

import { aggregatorSize } from "@/config/config";
import type { Facet, SearchQuery } from "@/types";
import type { AggregationField, Facet, SearchQuery } from "@/types";

const FIELDS_TO_SEARCH = ["authors", "title", "body"];

Expand All @@ -18,75 +23,61 @@ export const buildQuery = ({
from,
filterFields,
sortFields,
aggregationFields = [],
}: BuildQueryForElaSticClient) => {
// Initialize the base structure of the Elasticsearch query
let baseQuery = {
let baseQuery: SearchRequest = {
query: {
bool: {
must: [],
should: [],
filter: [],
must_not: [
{
term: {
"type.keyword": "combined-summary",
},
},
],
must_not: [],
},
},
sort: [],
aggs: {
authors: {
terms: {
field: "authors.keyword",
size: aggregatorSize,
},
},
domains: {
terms: {
field: "domain.keyword",
size: aggregatorSize,
},
},
tags: {
terms: {
field: "tags.keyword",
size: aggregatorSize,
},
},
},
aggs: {},
size, // Number of search results to return
from, // Offset for pagination (calculated from page number)
_source: {
excludes: ["summary_vector_embeddings"],
},
};

// Construct and add the full-text search clause
let shouldClause = buildShouldQueryClause(queryString);
if (!queryString) {
baseQuery.query.bool.should.push(shouldClause);
} else {
baseQuery.query.bool.must.push(shouldClause);
// Construct and add the full-text search query if provided
if (queryString) {
(baseQuery.query.bool.must as QueryDslQueryContainer[]).push(
buildShouldQueryClause(queryString)
);
}

// Add filter clauses for each specified filter field
if (filterFields && filterFields.length) {
for (let facet of filterFields) {
let mustClause = buildFilterQueryClause(facet);
baseQuery.query.bool.must.push(mustClause);
// Handle filters with exclusions and array values
if (filterFields?.length) {
for (const filter of filterFields) {
const filterClause = buildFilterQueryClause(filter);

if (filter.operation === "exclude") {
(baseQuery.query.bool.must_not as QueryDslQueryContainer[]).push(
filterClause
);
} else {
(baseQuery.query.bool.must as QueryDslQueryContainer[]).push(
filterClause
);
}
}
}

// Add sorting clauses for each specified sort field
if (sortFields && sortFields.length) {
for (let field of sortFields) {
const sortClause = buildSortClause(field);
baseQuery.sort.push(sortClause);
(baseQuery.sort as QueryDslQueryContainer[]).push(sortClause);
}
}

// Add aggregations
baseQuery.aggs = buildAggregations(aggregationFields);
return baseQuery;
};

Expand All @@ -103,14 +94,23 @@ const buildShouldQueryClause = (queryString: string) => {
};

// Helper to build filter query clauses based on facets
const buildFilterQueryClause = ({ field, value }: Facet) => {
let filterQueryClause = {
term: {
[`${field}.keyword`]: { value },
},
};
const buildFilterQueryClause = (filter: Facet): QueryDslQueryContainer => {
if (Array.isArray(filter.value)) {
// Handle OR logic for array values
return {
bool: {
should: filter.value.map((value) => ({
term: { [`${filter.field}.keyword`]: value },
})),
minimum_should_match: 1,
},
};
}

return filterQueryClause;
// Handle non-array values
return {
term: { [`${filter.field}.keyword`]: filter.value },
};
};

// Helper to build sort clauses for sorting results
Expand All @@ -119,3 +119,25 @@ const buildSortClause = ({ field, value }: { field: any; value: any }) => {
[field]: value,
};
};

// Helper to build aggregations
const buildAggregations = (fields: AggregationField[]) => {
const aggs = {};

fields.forEach((aggregation) => {
// Create the base terms aggregation
aggs[aggregation.field] = {
terms: {
field: `${aggregation.field}.keyword`,
size: aggregation.size || aggregatorSize,
},
};

// If there are sub-aggregations, add them directly to the terms agg
if (aggregation.subAggregations) {
aggs[aggregation.field].aggs = aggregation.subAggregations;
}
});

return aggs;
};

0 comments on commit 2c4e9b8

Please sign in to comment.