UNPKG

@fondation-io/fast-db-batch-search-client

Version:

TypeScript client for Fast-DB batch search API with support for fuzzy search

365 lines (335 loc) 9.64 kB
import axios, { AxiosInstance, AxiosError } from 'axios'; import { QueryRequest, QueryResponse, SearchResult, BatchSearchOptions, GroupedResults, DataFrameResponse, JoinSearchParams, } from './types'; /** * Client for Fast-DB Batch Search API */ export class BatchSearchClient { private axios: AxiosInstance; private includeMetrics: boolean; constructor(options: BatchSearchOptions = {}) { const { baseUrl = 'http://localhost:8080', timeout = 30000, includeMetrics = true } = options; this.axios = axios.create({ baseURL: baseUrl, timeout: timeout, headers: { 'Content-Type': 'application/json', }, }); this.includeMetrics = includeMetrics; } /** * Execute a batch search query * @param table The table/collection to search in * @param nodeField The field containing node information * @param nodeQuery The node to search for * @param targetField The field containing target information * @param targetQueries Array of target queries to search for * @param projection Fields to return in results * @param fuzzy Whether to use fuzzy search (default: true) * @param resultsPerQuery Maximum results per target query (default: 10) */ async batchSearch( table: string, nodeField: string, nodeQuery: string, targetField: string, targetQueries: string[], projection: string[] = ['*'], fuzzy: boolean = true, resultsPerQuery: number = 10 ): Promise<{ results: SearchResult[]; grouped: GroupedResults; metrics?: Record<string, unknown>; totalResults: number; }> { const query: QueryRequest = { query: { $select: projection, $from: table, $where: { $batch: { $node_field: nodeField, $node_query: nodeQuery, $target_field: targetField, $target_queries: targetQueries, $fuzzy: fuzzy, $results_per_query: resultsPerQuery, }, }, }, include_metrics: this.includeMetrics, }; try { const startTime = Date.now(); const response = await this.axios.post<QueryResponse>('/query', query); const elapsedTime = Date.now() - startTime; if (!response.data.success) { throw new Error(response.data.error || 'Query failed'); } const data = response.data.data; if (!data) { return { results: [], grouped: {}, metrics: response.data.metrics as Record<string, unknown> | undefined, totalResults: 0, }; } // Convert DataFrame response to array of objects const results = this.dataFrameToObjects(data); // Group results by search_group_hash const grouped = this.groupResultsByHash(results); return { results, grouped, metrics: { ...(response.data.metrics || {}), client_elapsed_ms: elapsedTime, } as Record<string, unknown>, totalResults: results.length, }; } catch (error) { const axiosError = error as AxiosError<{ error?: string }>; if (axiosError.response) { throw new Error( `API Error: ${axiosError.response.data?.error || axiosError.response.statusText}` ); } else if (axiosError.request) { throw new Error('Network error: No response from server'); } else { throw error; } } } /** * Convert DataFrame response to array of objects */ private dataFrameToObjects(data: DataFrameResponse): SearchResult[] { const { columns, rows } = data; return rows.map((row) => { const obj: SearchResult = { search_group_hash: '' }; columns.forEach((col, index) => { obj[col] = row[index] as string | number | boolean | null; }); return obj; }); } /** * Group results by search_group_hash */ private groupResultsByHash(results: SearchResult[]): GroupedResults { const groups: GroupedResults = {}; for (const result of results) { const hash = result.search_group_hash || 'unknown'; if (!groups[hash]) { groups[hash] = []; } groups[hash].push(result); } return groups; } /** * Execute a batch search query with joins across multiple tables * @param params Join search parameters */ async batchSearchWithJoins(params: JoinSearchParams): Promise<{ results: SearchResult[]; grouped: GroupedResults; metrics?: Record<string, unknown>; totalResults: number; }> { const query: QueryRequest = { query: { $select: params.projection || ['*'], $from: params.tables, $join: params.joins, $where: { $batch: { $node_field: params.nodeField, $node_query: params.nodeQuery, $target_field: params.targetField, $target_queries: params.targetQueries, $fuzzy: params.fuzzy !== false, $results_per_query: params.resultsPerQuery || 10, }, }, $orderBy: params.orderBy, $limit: params.limit, }, include_metrics: this.includeMetrics, }; try { const startTime = Date.now(); const response = await this.axios.post<QueryResponse>('/query', query); const elapsedTime = Date.now() - startTime; if (!response.data.success) { throw new Error(response.data.error || 'Query failed'); } const data = response.data.data; if (!data) { return { results: [], grouped: {}, metrics: response.data.metrics as Record<string, unknown> | undefined, totalResults: 0, }; } // Convert DataFrame response to array of objects const results = this.dataFrameToObjects(data); // Group results by search_group_hash const grouped = this.groupResultsByHash(results); return { results, grouped, metrics: { ...(response.data.metrics || {}), client_elapsed_ms: elapsedTime, } as Record<string, unknown>, totalResults: results.length, }; } catch (error) { const axiosError = error as AxiosError<{ error?: string }>; if (axiosError.response) { throw new Error( `API Error: ${axiosError.response.data?.error || axiosError.response.statusText}` ); } else if (axiosError.request) { throw new Error('Network error: No response from server'); } else { throw error; } } } /** * Convenience method for searching book series by author * @deprecated Use searchRelatedItems instead */ async searchBookSeries( author: string, seriesTitles: string[], maxPerTitle: number = 3 ): Promise<{ results: SearchResult[]; grouped: GroupedResults; metrics?: Record<string, unknown>; totalResults: number; }> { return this.batchSearch( 'books', 'auteurs', author, 'titre', seriesTitles, ['titre', 'auteurs'], true, maxPerTitle ); } /** * Generic method for searching related items by a common node */ async searchRelatedItems( table: string, nodeField: string, nodeValue: string, targetField: string, targetValues: string[], projection?: string[], maxPerTarget: number = 3 ): Promise<{ results: SearchResult[]; grouped: GroupedResults; metrics?: Record<string, unknown>; totalResults: number; }> { return this.batchSearch( table, nodeField, nodeValue, targetField, targetValues, projection || ['*'], true, maxPerTarget ); } /** * Convenience method for searching albums by artist using joins */ async searchAlbumsByArtist( artist: string, albumTitles: string[], maxPerAlbum: number = 3 ): Promise<{ results: SearchResult[]; grouped: GroupedResults; metrics?: Record<string, unknown>; totalResults: number; }> { return this.batchSearchWithJoins({ tables: ['id_artists', 'album_artist', 'albums'], joins: [ { $type: 'inner', $left: 'id_artists', $right: 'album_artist', $on: ['id_artists.id', 'album_artist.artist_id'], }, { $type: 'inner', $left: 'album_artist', $right: 'albums', $on: ['album_artist.cb', 'albums.cb'], }, ], nodeField: 'id_artists.artiste', nodeQuery: artist, targetField: 'albums.album', targetQueries: albumTitles, projection: { artist_name: 'artiste', album_title: 'album', release_year: 'street_date', }, fuzzy: true, resultsPerQuery: maxPerAlbum, }); } /** * Get search statistics from grouped results */ getSearchStats(grouped: GroupedResults): { totalGroups: number; groupSizes: { [hash: string]: number }; averageGroupSize: number; emptyGroups: number; } { const groupSizes: { [hash: string]: number } = {}; let totalItems = 0; let emptyGroups = 0; for (const [hash, items] of Object.entries(grouped)) { groupSizes[hash] = items.length; totalItems += items.length; if (items.length === 0) { emptyGroups++; } } const totalGroups = Object.keys(grouped).length; const averageGroupSize = totalGroups > 0 ? totalItems / totalGroups : 0; return { totalGroups, groupSizes, averageGroupSize, emptyGroups, }; } }