- Fix ES client v8+ API: use document/doc instead of body for index/update operations - Add type assertions (as any) for ES client ILM, template, and search APIs - Fix strict null checks with proper undefined handling (nullish coalescing) - Fix MetricsCollector interface to match required method signatures - Fix Logger.error signature compatibility in plugins - Resolve TermsQuery type index signature conflict - Remove sourceMap from tsconfig (handled by tsbuild with inlineSourceMap)
630 lines
15 KiB
TypeScript
630 lines
15 KiB
TypeScript
import type {
|
|
QueryDSL,
|
|
BoolQuery,
|
|
MatchQuery,
|
|
MatchPhraseQuery,
|
|
MultiMatchQuery,
|
|
TermQuery,
|
|
TermsQuery,
|
|
RangeQuery,
|
|
ExistsQuery,
|
|
PrefixQuery,
|
|
WildcardQuery,
|
|
RegexpQuery,
|
|
FuzzyQuery,
|
|
IdsQuery,
|
|
MatchAllQuery,
|
|
QueryStringQuery,
|
|
SimpleQueryStringQuery,
|
|
SearchOptions,
|
|
SearchResult,
|
|
SortOrder,
|
|
MatchOperator,
|
|
MultiMatchType,
|
|
RangeBounds,
|
|
SortField,
|
|
} from './types.js';
|
|
import type { AggregationBuilder } from './aggregation-builder.js';
|
|
import { createAggregationBuilder } from './aggregation-builder.js';
|
|
import { ElasticsearchConnectionManager } from '../../core/connection/connection-manager.js';
|
|
import { defaultLogger } from '../../core/observability/logger.js';
|
|
import { defaultMetricsCollector } from '../../core/observability/metrics.js';
|
|
import { defaultTracer } from '../../core/observability/tracing.js';
|
|
|
|
/**
|
|
* Fluent query builder for type-safe Elasticsearch queries
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const results = await new QueryBuilder<Product>('products')
|
|
* .match('name', 'laptop')
|
|
* .range('price', { gte: 100, lte: 1000 })
|
|
* .sort('price', 'asc')
|
|
* .size(20)
|
|
* .execute();
|
|
* ```
|
|
*/
|
|
export class QueryBuilder<T = unknown> {
|
|
private index: string;
|
|
private queryDSL: QueryDSL | null = null;
|
|
private boolClauses: {
|
|
must: QueryDSL[];
|
|
should: QueryDSL[];
|
|
must_not: QueryDSL[];
|
|
filter: QueryDSL[];
|
|
} = {
|
|
must: [],
|
|
should: [],
|
|
must_not: [],
|
|
filter: [],
|
|
};
|
|
private minimumShouldMatch?: number | string;
|
|
private sortFields: Array<SortField | string> = [];
|
|
private sourceFields?: string[];
|
|
private excludeSourceFields?: string[];
|
|
private resultSize: number = 10;
|
|
private resultFrom: number = 0;
|
|
private shouldTrackTotalHits: boolean | number = true;
|
|
private searchTimeout?: string;
|
|
private aggregationBuilder?: AggregationBuilder;
|
|
private highlightConfig?: SearchOptions['highlight'];
|
|
|
|
constructor(index: string) {
|
|
this.index = index;
|
|
}
|
|
|
|
/**
|
|
* Create a new query builder instance
|
|
*/
|
|
static create<T>(index: string): QueryBuilder<T> {
|
|
return new QueryBuilder<T>(index);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Query Methods
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Add a match query
|
|
*/
|
|
match(field: string, query: string, options?: { operator?: MatchOperator; fuzziness?: number | 'AUTO'; boost?: number }): this {
|
|
const matchQuery: MatchQuery = {
|
|
match: {
|
|
[field]: {
|
|
query,
|
|
...options,
|
|
},
|
|
},
|
|
};
|
|
this.boolClauses.must.push(matchQuery);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a match phrase query
|
|
*/
|
|
matchPhrase(field: string, query: string, options?: { slop?: number; boost?: number }): this {
|
|
const matchPhraseQuery: MatchPhraseQuery = {
|
|
match_phrase: {
|
|
[field]: {
|
|
query,
|
|
...options,
|
|
},
|
|
},
|
|
};
|
|
this.boolClauses.must.push(matchPhraseQuery);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a multi-match query
|
|
*/
|
|
multiMatch(query: string, fields: string[], options?: { type?: MultiMatchType; operator?: MatchOperator; boost?: number }): this {
|
|
const multiMatchQuery: MultiMatchQuery = {
|
|
multi_match: {
|
|
query,
|
|
fields,
|
|
...options,
|
|
},
|
|
};
|
|
this.boolClauses.must.push(multiMatchQuery);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a term query (exact match)
|
|
*/
|
|
term(field: string, value: string | number | boolean, boost?: number): this {
|
|
const termQuery: TermQuery = {
|
|
term: {
|
|
[field]: {
|
|
value,
|
|
...(boost && { boost }),
|
|
},
|
|
},
|
|
};
|
|
this.boolClauses.filter.push(termQuery);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a terms query (match any of the values)
|
|
*/
|
|
terms(field: string, values: Array<string | number | boolean>, boost?: number): this {
|
|
const termsQuery: TermsQuery = {
|
|
terms: {
|
|
[field]: values,
|
|
...(boost && { boost }),
|
|
},
|
|
};
|
|
this.boolClauses.filter.push(termsQuery);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a range query
|
|
*/
|
|
range(field: string, bounds: RangeBounds, boost?: number): this {
|
|
const rangeQuery: RangeQuery = {
|
|
range: {
|
|
[field]: {
|
|
...bounds,
|
|
...(boost && { boost }),
|
|
},
|
|
},
|
|
};
|
|
this.boolClauses.filter.push(rangeQuery);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add an exists query (field must exist)
|
|
*/
|
|
exists(field: string): this {
|
|
const existsQuery: ExistsQuery = {
|
|
exists: {
|
|
field,
|
|
},
|
|
};
|
|
this.boolClauses.filter.push(existsQuery);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a prefix query
|
|
*/
|
|
prefix(field: string, value: string, boost?: number): this {
|
|
const prefixQuery: PrefixQuery = {
|
|
prefix: {
|
|
[field]: {
|
|
value,
|
|
...(boost && { boost }),
|
|
},
|
|
},
|
|
};
|
|
this.boolClauses.must.push(prefixQuery);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a wildcard query
|
|
*/
|
|
wildcard(field: string, value: string, boost?: number): this {
|
|
const wildcardQuery: WildcardQuery = {
|
|
wildcard: {
|
|
[field]: {
|
|
value,
|
|
...(boost && { boost }),
|
|
},
|
|
},
|
|
};
|
|
this.boolClauses.must.push(wildcardQuery);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a regexp query
|
|
*/
|
|
regexp(field: string, value: string, options?: { flags?: string; boost?: number }): this {
|
|
const regexpQuery: RegexpQuery = {
|
|
regexp: {
|
|
[field]: {
|
|
value,
|
|
...options,
|
|
},
|
|
},
|
|
};
|
|
this.boolClauses.must.push(regexpQuery);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a fuzzy query
|
|
*/
|
|
fuzzy(field: string, value: string, options?: { fuzziness?: number | 'AUTO'; boost?: number }): this {
|
|
const fuzzyQuery: FuzzyQuery = {
|
|
fuzzy: {
|
|
[field]: {
|
|
value,
|
|
...options,
|
|
},
|
|
},
|
|
};
|
|
this.boolClauses.must.push(fuzzyQuery);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add an IDs query
|
|
*/
|
|
ids(values: string[]): this {
|
|
const idsQuery: IdsQuery = {
|
|
ids: {
|
|
values,
|
|
},
|
|
};
|
|
this.boolClauses.filter.push(idsQuery);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a query string query
|
|
*/
|
|
queryString(query: string, options?: { default_field?: string; fields?: string[]; default_operator?: MatchOperator; boost?: number }): this {
|
|
const queryStringQuery: QueryStringQuery = {
|
|
query_string: {
|
|
query,
|
|
...options,
|
|
},
|
|
};
|
|
this.boolClauses.must.push(queryStringQuery);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a simple query string query
|
|
*/
|
|
simpleQueryString(query: string, options?: { fields?: string[]; default_operator?: MatchOperator; boost?: number }): this {
|
|
const simpleQueryStringQuery: SimpleQueryStringQuery = {
|
|
simple_query_string: {
|
|
query,
|
|
...options,
|
|
},
|
|
};
|
|
this.boolClauses.must.push(simpleQueryStringQuery);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Match all documents
|
|
*/
|
|
matchAll(boost?: number): this {
|
|
const matchAllQuery: MatchAllQuery = {
|
|
match_all: {
|
|
...(boost && { boost }),
|
|
},
|
|
};
|
|
this.queryDSL = matchAllQuery;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a custom query to the must clause
|
|
*/
|
|
must(query: QueryDSL): this {
|
|
this.boolClauses.must.push(query);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a custom query to the should clause
|
|
*/
|
|
should(query: QueryDSL): this {
|
|
this.boolClauses.should.push(query);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a custom query to the must_not clause
|
|
*/
|
|
mustNot(query: QueryDSL): this {
|
|
this.boolClauses.must_not.push(query);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a custom query to the filter clause
|
|
*/
|
|
filter(query: QueryDSL): this {
|
|
this.boolClauses.filter.push(query);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set minimum_should_match for boolean queries
|
|
*/
|
|
minimumMatch(value: number | string): this {
|
|
this.minimumShouldMatch = value;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set a custom query DSL (replaces builder queries)
|
|
*/
|
|
customQuery(query: QueryDSL): this {
|
|
this.queryDSL = query;
|
|
return this;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Result Shaping Methods
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Add sorting
|
|
*/
|
|
sort(field: string, order: SortOrder = 'asc'): this {
|
|
this.sortFields.push({ [field]: { order } });
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add custom sort configuration
|
|
*/
|
|
customSort(sort: SortField | string): this {
|
|
this.sortFields.push(sort);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Specify fields to include in results (source filtering)
|
|
*/
|
|
fields(fields: string[]): this {
|
|
this.sourceFields = fields;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Specify fields to exclude from results
|
|
*/
|
|
exclude(fields: string[]): this {
|
|
this.excludeSourceFields = fields;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set number of results to return
|
|
*/
|
|
size(size: number): this {
|
|
this.resultSize = size;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set offset for pagination
|
|
*/
|
|
from(from: number): this {
|
|
this.resultFrom = from;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set pagination (convenience method)
|
|
*/
|
|
paginate(page: number, pageSize: number): this {
|
|
this.resultFrom = (page - 1) * pageSize;
|
|
this.resultSize = pageSize;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set whether to track total hits
|
|
*/
|
|
trackTotalHits(track: boolean | number): this {
|
|
this.shouldTrackTotalHits = track;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set search timeout
|
|
*/
|
|
timeout(timeout: string): this {
|
|
this.searchTimeout = timeout;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Configure highlighting
|
|
*/
|
|
highlight(config: SearchOptions['highlight']): this {
|
|
this.highlightConfig = config;
|
|
return this;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Aggregation Methods
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get aggregation builder
|
|
*/
|
|
aggregations(configure: (builder: AggregationBuilder) => void): this {
|
|
if (!this.aggregationBuilder) {
|
|
this.aggregationBuilder = createAggregationBuilder();
|
|
}
|
|
configure(this.aggregationBuilder);
|
|
return this;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Build & Execute
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Build the final query DSL
|
|
*/
|
|
build(): SearchOptions {
|
|
let finalQuery: QueryDSL | undefined;
|
|
|
|
// If custom query was set, use it
|
|
if (this.queryDSL) {
|
|
finalQuery = this.queryDSL;
|
|
} else {
|
|
// Otherwise, build from bool clauses
|
|
const hasAnyClauses =
|
|
this.boolClauses.must.length > 0 ||
|
|
this.boolClauses.should.length > 0 ||
|
|
this.boolClauses.must_not.length > 0 ||
|
|
this.boolClauses.filter.length > 0;
|
|
|
|
if (hasAnyClauses) {
|
|
const boolQuery: BoolQuery = {
|
|
bool: {},
|
|
};
|
|
|
|
if (this.boolClauses.must.length > 0) {
|
|
boolQuery.bool.must = this.boolClauses.must;
|
|
}
|
|
if (this.boolClauses.should.length > 0) {
|
|
boolQuery.bool.should = this.boolClauses.should;
|
|
}
|
|
if (this.boolClauses.must_not.length > 0) {
|
|
boolQuery.bool.must_not = this.boolClauses.must_not;
|
|
}
|
|
if (this.boolClauses.filter.length > 0) {
|
|
boolQuery.bool.filter = this.boolClauses.filter;
|
|
}
|
|
if (this.minimumShouldMatch !== undefined) {
|
|
boolQuery.bool.minimum_should_match = this.minimumShouldMatch;
|
|
}
|
|
|
|
finalQuery = boolQuery;
|
|
}
|
|
}
|
|
|
|
const searchOptions: SearchOptions = {
|
|
...(finalQuery && { query: finalQuery }),
|
|
...(this.sourceFields && { fields: this.sourceFields }),
|
|
...(this.excludeSourceFields && { excludeFields: this.excludeSourceFields }),
|
|
size: this.resultSize,
|
|
from: this.resultFrom,
|
|
...(this.sortFields.length > 0 && { sort: this.sortFields }),
|
|
trackTotalHits: this.shouldTrackTotalHits,
|
|
...(this.searchTimeout && { timeout: this.searchTimeout }),
|
|
...(this.highlightConfig && { highlight: this.highlightConfig }),
|
|
...(this.aggregationBuilder && { aggregations: this.aggregationBuilder.build() }),
|
|
};
|
|
|
|
return searchOptions;
|
|
}
|
|
|
|
/**
|
|
* Execute the query and return results
|
|
*/
|
|
async execute(): Promise<SearchResult<T>> {
|
|
const span = defaultTracer.startSpan('query.execute', {
|
|
'db.system': 'elasticsearch',
|
|
'db.operation': 'search',
|
|
'db.elasticsearch.index': this.index,
|
|
});
|
|
|
|
try {
|
|
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
|
const searchOptions = this.build();
|
|
|
|
defaultLogger.debug('Executing query', {
|
|
index: this.index,
|
|
query: searchOptions.query,
|
|
size: searchOptions.size,
|
|
from: searchOptions.from,
|
|
});
|
|
|
|
const startTime = Date.now();
|
|
|
|
// Execute search
|
|
const result = await client.search<T>({
|
|
index: this.index,
|
|
...searchOptions,
|
|
} as any);
|
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
// Record metrics
|
|
defaultMetricsCollector.requestsTotal.inc({ operation: 'search', index: this.index });
|
|
defaultMetricsCollector.requestDuration.observe(duration / 1000, { operation: 'search', index: this.index });
|
|
|
|
defaultLogger.info('Query executed successfully', {
|
|
index: this.index,
|
|
took: result.took,
|
|
hits: result.hits.total,
|
|
duration,
|
|
});
|
|
|
|
span.setAttributes({
|
|
'db.elasticsearch.took': result.took,
|
|
'db.elasticsearch.hits': typeof result.hits.total === 'object' ? result.hits.total.value : result.hits.total,
|
|
});
|
|
span.end();
|
|
|
|
return result as SearchResult<T>;
|
|
} catch (error) {
|
|
defaultMetricsCollector.requestErrors.inc({ operation: 'search', index: this.index });
|
|
defaultLogger.error('Query execution failed', { index: this.index, error: error instanceof Error ? error.message : String(error) });
|
|
span.recordException(error as Error);
|
|
span.end();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute query and return only the hits
|
|
*/
|
|
async executeAndGetHits(): Promise<SearchResult<T>['hits']['hits']> {
|
|
const result = await this.execute();
|
|
return result.hits.hits;
|
|
}
|
|
|
|
/**
|
|
* Execute query and return only the source documents
|
|
*/
|
|
async executeAndGetSources(): Promise<T[]> {
|
|
const hits = await this.executeAndGetHits();
|
|
return hits.map((hit) => hit._source);
|
|
}
|
|
|
|
/**
|
|
* Count documents matching the query
|
|
*/
|
|
async count(): Promise<number> {
|
|
const span = defaultTracer.startSpan('query.count', {
|
|
'db.system': 'elasticsearch',
|
|
'db.operation': 'count',
|
|
'db.elasticsearch.index': this.index,
|
|
});
|
|
|
|
try {
|
|
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
|
const searchOptions = this.build();
|
|
|
|
const result = await client.count({
|
|
index: this.index,
|
|
...(searchOptions.query && { query: searchOptions.query }),
|
|
} as any);
|
|
|
|
span.end();
|
|
return result.count;
|
|
} catch (error) {
|
|
span.recordException(error as Error);
|
|
span.end();
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new query builder instance
|
|
*/
|
|
export function createQuery<T>(index: string): QueryBuilder<T> {
|
|
return new QueryBuilder<T>(index);
|
|
}
|