BREAKING CHANGE(core): Refactor to v3: introduce modular core/domain architecture, plugin system, observability and strict TypeScript configuration; remove legacy classes
This commit is contained in:
629
ts/domain/query/query-builder.ts
Normal file
629
ts/domain/query/query-builder.ts
Normal file
@@ -0,0 +1,629 @@
|
||||
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 { defaultMetrics } from '../../core/observability/metrics.js';
|
||||
import { defaultTracing } 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 = defaultTracing.createSpan('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,
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Record metrics
|
||||
defaultMetrics.requestsTotal.inc({ operation: 'search', index: this.index });
|
||||
defaultMetrics.requestDuration.observe({ operation: 'search', index: this.index }, duration);
|
||||
|
||||
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) {
|
||||
defaultMetrics.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 = defaultTracing.createSpan('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 }),
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user