diff --git a/src/index.ts b/src/index.ts index ba09224..fcbe31a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,13 +48,19 @@ export { AggregationQuery, Reducers } from './query/aggregation.js'; export type { AggregateCommand, SortSpec, LoadField, Reducer } from './query/aggregation.js'; export { Tag, Num, Text, Geo, GeoRadius, Timestamp, FilterExpression } from './query/filter.js'; export type { Inclusive, GeoUnit } from './query/filter.js'; +export { BaseQuery, BaseVectorQuery } from './query/base.js'; export type { - BaseQuery, + BaseQueryConfig, + BaseVectorQueryConfig, + ReturnFieldsOptions, SearchResult, SearchDocument, QueryOptions, FilterInput, HybridSearchResult, + SortByOptions, + SortDirection, + SortField, } from './query/base.js'; // Error exports diff --git a/src/indexes/search-index.ts b/src/indexes/search-index.ts index 88933fb..2eefd17 100644 --- a/src/indexes/search-index.ts +++ b/src/indexes/search-index.ts @@ -532,7 +532,9 @@ export class SearchIndex { * Execute a search query against the index * * @param query - Query object (VectorQuery, FilterQuery, etc.) - * @param options - Optional query execution options + * @param options - Optional query execution options. If both query-level + * `query.sortBy(...)` and `options.sortBy` are supplied, `options.sortBy` + * takes precedence to preserve the existing `search()` API. * @returns Search results with documents and scores * * @example @@ -600,21 +602,31 @@ export class SearchIndex { } // Add pagination - if (query.limit !== undefined || query.offset !== undefined) { - const offset = query.offset ?? 0; - const limit = query.limit ?? 10; + const queryLimit = query.getLimit(); + const queryOffset = query.getOffset(); + if (queryLimit !== undefined || queryOffset !== undefined) { + const offset = queryOffset ?? 0; + const limit = queryLimit ?? 10; searchOptions.LIMIT = { from: offset, size: limit }; } - // Add sorting if specified + // Add sorting if specified on the query. FT.SEARCH accepts one + // SORTBY clause, so use the first collected sort field. + if (query.sortFields.length > 0) { + const [sortField] = query.sortFields; + searchOptions.SORTBY = { + BY: sortField.field, + DIRECTION: sortField.direction, + }; + } + + // Add sorting if specified in execution options. These options + // preserve the historical API and override query-level sorting. if (options?.sortBy) { - searchOptions.SORTBY = options.sortBy; - if (options.sortOrder) { - searchOptions.SORTBY = { - BY: options.sortBy, - DIRECTION: options.sortOrder, - }; - } + searchOptions.SORTBY = { + BY: options.sortBy, + ...(options.sortOrder ? { DIRECTION: options.sortOrder } : {}), + }; } // CountQuery (and any other consumer) can opt into NOCONTENT to diff --git a/src/query/aggregation.ts b/src/query/aggregation.ts index 63b3dae..c9b91d0 100644 --- a/src/query/aggregation.ts +++ b/src/query/aggregation.ts @@ -11,7 +11,7 @@ import type { FtAggregateOptions } from '@redis/search/dist/lib/commands/AGGREGATE.js'; import { QueryValidationError } from '../errors.js'; -import { renderFilter, type FilterInput } from './base.js'; +import { BaseQuery, renderFilter, type FilterInput, type SortByOptions } from './base.js'; /** Output of {@link AggregationQuery.toCommand}. */ export interface AggregateCommand { @@ -224,8 +224,7 @@ function assertPositiveInteger(value: number | undefined, label: string): void { * const { total, results } = await index.aggregate(q); * ``` */ -export class AggregationQuery { - private readonly _query: string; +export class AggregationQuery extends BaseQuery { private readonly steps: Step[] = []; private _load?: LoadField[]; private _params?: Record; @@ -240,7 +239,7 @@ export class AggregationQuery { * the rest of the query DSL uses. */ constructor(query?: FilterInput) { - this._query = renderFilter(query); + super({ filter: query }); } /** @@ -279,8 +278,16 @@ export class AggregationQuery { } /** SORTBY one or more fields. Bare strings sort ASC. */ - sortBy(by: SortSpec | SortSpec[], max?: number): this { - const list = Array.isArray(by) ? by : [by]; + sortBy(field: string, options?: SortByOptions): this; + sortBy(by: SortSpec | SortSpec[], max?: number): this; + sortBy(by: SortSpec | SortSpec[], maxOrOptions?: number | SortByOptions): this { + const list = + typeof maxOrOptions === 'object' && !Array.isArray(by) && typeof by === 'string' + ? [{ field: by, direction: maxOrOptions.direction }] + : Array.isArray(by) + ? by + : [by]; + const max = typeof maxOrOptions === 'number' ? maxOrOptions : undefined; if (list.length === 0) { throw new QueryValidationError('sortBy requires at least one field'); } @@ -378,7 +385,12 @@ export class AggregationQuery { /** The rendered query string this aggregation will use. */ get query(): string { - return this._query; + return this.buildQuery(); + } + + /** Build the FT.AGGREGATE query string. */ + buildQuery(): string { + return renderFilter(this.queryFilter); } /** Build the structured options for `client.ft.aggregate(indexName, query, options)`. */ @@ -395,12 +407,13 @@ export class AggregationQuery { options.PARAMS = this._params; } - if (this._load && this._load.length > 0) { + const loadFields = [...(this.returnFields ?? []), ...(this._load ?? [])]; + if (loadFields.length > 0) { // The Redis client types LOAD entries with template-literal types // (`@${string}` / `$.${string}`). We've already enforced that // contract via prefixFieldRef, but TS can't see through the // string return — cast at the boundary. - options.LOAD = this._load.map((f) => + options.LOAD = loadFields.map((f) => typeof f === 'string' ? prefixFieldRef(f) : f.as !== undefined @@ -415,7 +428,7 @@ export class AggregationQuery { ) as FtAggregateOptions['STEPS']; } - return { query: this._query, options }; + return { query: this.buildQuery(), options }; } private renderStep(step: Step): unknown { diff --git a/src/query/base.ts b/src/query/base.ts index 8130b22..425cc38 100644 --- a/src/query/base.ts +++ b/src/query/base.ts @@ -3,14 +3,20 @@ * Query builders for Redis search operations. */ +import { QueryValidationError, SchemaValidationError } from '../errors.js'; +import { normalizeVectorDataType } from '../redis/utils.js'; +import { VectorDataType } from '../schema/types.js'; import type { FilterExpression } from './filter.js'; /** - * A filter clause supplied to a query — either a pre-built {@link FilterExpression} + * A filter clause supplied to a query - either a pre-built {@link FilterExpression} * or a raw Redis Search filter string (e.g. `'@category:{electronics}'`). */ export type FilterInput = string | FilterExpression; +/** Backward-compatible alias for shared query filters. */ +export type QueryFilter = FilterInput; + /** * Render a {@link FilterInput} to its Redis Search string form, treating * `undefined` and the wildcard expression as "no filter" (`*`). @@ -21,17 +27,64 @@ export function renderFilter(filter: FilterInput | undefined): string { } /** - * Base interface for all query types + * Sort direction for Redis search results. */ -export interface BaseQuery { - /** Fields to return in results */ +export type SortDirection = 'ASC' | 'DESC'; + +/** + * A normalized sort field specification. + */ +export interface SortField { + field: string; + direction: SortDirection; +} + +/** + * Common query configuration shared by all FT.SEARCH query types. + */ +export interface BaseQueryConfig { + /** Filter expression used as the base Redis query string. */ + filter?: FilterInput; + + /** Fields to return in results. */ returnFields?: string[]; - /** Number of results to return */ + /** Offset for pagination. */ + offset?: number; + + /** Number of results to return. */ limit?: number; +} - /** Offset for pagination */ - offset?: number; +/** + * Options for configuring returned fields. + */ +export interface ReturnFieldsOptions { + /** Fields that should not be decoded by higher-level result processors. */ + skipDecode?: string | string[]; +} + +/** + * Options for sorting query results. + */ +export interface SortByOptions { + /** Sort direction. Defaults to ASC. */ + direction?: SortDirection; +} + +/** + * Base abstract class for all FT.SEARCH query types. + * + * This class owns shared query state. Subclasses remain responsible for + * building the final Redis query string for their specific query mode. + */ +export abstract class BaseQuery { + private _filter?: FilterInput; + private _returnFields?: string[]; + private _skipDecodeFields?: string[]; + private _offset?: number; + private _limit?: number; + private readonly _sortFields: SortField[] = []; /** When true, ask Redis to return only document ids/counts (FT.SEARCH NOCONTENT). */ noContent?: boolean; @@ -39,35 +92,270 @@ export interface BaseQuery { /** Optional RediSearch scorer to apply when ranking text results. */ textScorer?: string; - /** Build the Redis query string */ - buildQuery(): string; + protected constructor(config: BaseQueryConfig = {}) { + if (config.filter !== undefined) { + this.setFilter(config.filter); + } + if (config.returnFields !== undefined) { + this.setReturnFields(config.returnFields); + } + if (config.offset !== undefined || config.limit !== undefined) { + this.setPagingFromConfig(config.offset, config.limit); + } + } + + /** Fields to return in results. */ + get returnFields(): string[] | undefined { + return this._returnFields ? [...this._returnFields] : undefined; + } + + /** Fields that should not be decoded by higher-level result processors. */ + get skipDecodeFields(): string[] | undefined { + return this._skipDecodeFields ? [...this._skipDecodeFields] : undefined; + } + + /** Sort fields collected for query execution. */ + get sortFields(): SortField[] { + return this._sortFields.map((field) => ({ ...field })); + } + + /** Offset for execution code that works with the abstract query base. */ + getOffset(): number | undefined { + return this._offset; + } + + /** Limit for execution code that works with the abstract query base. */ + getLimit(): number | undefined { + return this._limit; + } + + /** Filter expression used by subclasses when rendering query strings. */ + protected get queryFilter(): FilterInput | undefined { + return this._filter; + } + + /** Offset value exposed by concrete query classes. */ + protected get queryOffset(): number | undefined { + return this._offset; + } + + /** Limit value exposed by concrete query classes. */ + protected get queryLimit(): number | undefined { + return this._limit; + } + + /** Set or clear the query filter. */ + setFilter(filter?: FilterInput | null): this { + if (filter === undefined || filter === null) { + this._filter = undefined; + return this; + } + + const rendered = renderFilter(filter); + if (rendered.trim() === '') { + throw new QueryValidationError('filter cannot be empty'); + } + this._filter = filter; + return this; + } + + /** Set or clear return fields. */ + setReturnFields(fields?: string[], options: ReturnFieldsOptions = {}): this { + if (fields === undefined) { + this._returnFields = undefined; + this._skipDecodeFields = undefined; + return this; + } + + this._returnFields = validateStringList(fields, 'returnFields'); + + if (options.skipDecode !== undefined) { + const skipDecode = Array.isArray(options.skipDecode) + ? options.skipDecode + : [options.skipDecode]; + this._skipDecodeFields = validateStringList(skipDecode, 'skipDecode'); + } else { + this._skipDecodeFields = undefined; + } + + return this; + } + + /** Set pagination values. */ + paging(offset: number, limit: number): this { + validateOffset(offset); + validateLimit(limit); + this._offset = offset; + this._limit = limit; + return this; + } + + /** Add a sort field. */ + sortBy(field: string, options: SortByOptions = {}): this { + const normalizedField = validateNonEmptyString(field, 'sort field'); + const direction = options.direction ?? 'ASC'; + if (direction !== 'ASC' && direction !== 'DESC') { + throw new QueryValidationError('sort direction must be either ASC or DESC'); + } + this._sortFields.push({ field: normalizedField, direction }); + return this; + } + + /** Build the Redis query string. */ + abstract buildQuery(): string; + + /** Build the query parameters for Redis. */ + buildParams(): Record { + return {}; + } + + private setPagingFromConfig(offset?: number, limit?: number): void { + if (offset !== undefined) { + validateOffset(offset); + this._offset = offset; + } + if (limit !== undefined) { + validateLimit(limit); + this._limit = limit; + } + } +} + +/** + * Common vector query configuration. + */ +export interface BaseVectorQueryConfig extends BaseQueryConfig { + /** Vector to search with. */ + vector: number[]; + + /** Name of the vector field in the index. */ + vectorField: string; + + /** Vector datatype to use when serializing the query vector. */ + datatype?: VectorDataType | string; + + /** Whether to normalize distances during result processing. */ + normalizeDistance?: boolean; +} + +/** + * Base abstract class for vector-backed query types. + */ +export abstract class BaseVectorQuery extends BaseQuery { + private readonly _vector: number[]; + private readonly _vectorField: string; + private readonly _datatype: VectorDataType; + private readonly _normalizeDistance: boolean; + + protected constructor(config: BaseVectorQueryConfig) { + super(config); + + if (!config.vector || config.vector.length === 0) { + throw new QueryValidationError('Vector cannot be empty'); + } + + const vectorField = typeof config.vectorField === 'string' ? config.vectorField.trim() : ''; + if (!vectorField) { + throw new QueryValidationError('vectorField is required'); + } + + this._vector = [...config.vector]; + this._vectorField = vectorField; + this._normalizeDistance = config.normalizeDistance ?? false; + + try { + this._datatype = normalizeVectorDataType(config.datatype); + } catch (error) { + if (error instanceof SchemaValidationError) { + throw new QueryValidationError(error.message); + } + throw error; + } + } + + /** Vector to search with. */ + get vector(): number[] { + return [...this._vector]; + } + + /** Filter expression used by the query. */ + get filter(): FilterInput | undefined { + return this.queryFilter; + } + + /** Offset for pagination. */ + get offset(): number | undefined { + return this.queryOffset; + } + + /** Number of results to return. */ + get limit(): number | undefined { + return this.queryLimit; + } + + /** Name of the vector field in the index. */ + get vectorField(): string { + return this._vectorField; + } + + /** Vector datatype used when serializing the query vector. */ + get datatype(): VectorDataType { + return this._datatype; + } + + /** Whether to normalize distances during result processing. */ + get normalizeDistance(): boolean { + return this._normalizeDistance; + } +} + +function validateStringList(values: string[], label: string): string[] { + return values.map((value) => { + return validateNonEmptyString(value, label); + }); +} + +function validateNonEmptyString(value: string, label: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new QueryValidationError(`${label} cannot be empty`); + } + return value.trim(); +} + +function validateOffset(offset: number): void { + if (!Number.isInteger(offset) || offset < 0) { + throw new QueryValidationError('offset must be a non-negative integer'); + } +} - /** Build the query parameters for Redis */ - buildParams(): Record; +function validateLimit(limit: number): void { + if (!Number.isInteger(limit) || limit <= 0) { + throw new QueryValidationError('limit must be a positive integer'); + } } /** - * Search result document with optional score + * Search result document with optional score. */ export interface SearchDocument> { - /** Document fields */ + /** Document fields. */ value: T; - /** Relevance score (for vector search) */ + /** Relevance score (for vector search). */ score?: number; - /** Document ID */ + /** Document ID. */ id: string; } /** - * Search result structure + * Search result structure. */ export interface SearchResult> { - /** Total number of results */ + /** Total number of results. */ total: number; - /** Retrieved documents */ + /** Retrieved documents. */ documents: SearchDocument[]; } @@ -85,15 +373,15 @@ export interface HybridSearchResult> extends SearchR } /** - * Options for query execution + * Options for query execution. */ export interface QueryOptions { - /** Redis DIALECT version (default: 2 for KNN) */ + /** Redis DIALECT version (default: 2 for KNN). */ dialect?: number; - /** Sort by field */ + /** Sort by field. */ sortBy?: string; - /** Sort order */ + /** Sort order. */ sortOrder?: 'ASC' | 'DESC'; } diff --git a/src/query/count.ts b/src/query/count.ts index 580f48a..7fb371a 100644 --- a/src/query/count.ts +++ b/src/query/count.ts @@ -1,4 +1,4 @@ -import { renderFilter, type BaseQuery, type FilterInput } from './base.js'; +import { BaseQuery, renderFilter, type FilterInput } from './base.js'; /** * Configuration for {@link CountQuery}. @@ -22,21 +22,34 @@ export interface CountQueryConfig { * const total = (await index.search(new CountQuery({ filter: Tag('brand').eq('nike') }))).total; * ``` */ -export class CountQuery implements BaseQuery { - public readonly filter?: FilterInput; - public readonly offset = 0; - public readonly limit = 0; +export class CountQuery extends BaseQuery { public readonly noContent = true; constructor(config: CountQueryConfig = {}) { - this.filter = config.filter; + super({ filter: config.filter }); } - buildQuery(): string { - return renderFilter(this.filter); + get filter(): FilterInput | undefined { + return this.queryFilter; + } + + get offset(): number { + return 0; + } + + get limit(): number { + return 0; } - buildParams(): Record { - return {}; + getOffset(): number { + return 0; + } + + getLimit(): number { + return 0; + } + + buildQuery(): string { + return renderFilter(this.filter); } } diff --git a/src/query/filter-query.ts b/src/query/filter-query.ts index 8314740..f0a63c5 100644 --- a/src/query/filter-query.ts +++ b/src/query/filter-query.ts @@ -1,4 +1,4 @@ -import { renderFilter, type BaseQuery, type FilterInput } from './base.js'; +import { BaseQuery, renderFilter, type FilterInput } from './base.js'; /** * Configuration for {@link FilterQuery}. @@ -35,26 +35,33 @@ export interface FilterQueryConfig { * const results = await index.search(q); * ``` */ -export class FilterQuery implements BaseQuery { - public readonly filter?: FilterInput; - public readonly returnFields?: string[]; +export class FilterQuery extends BaseQuery { public readonly numResults: number; - public readonly offset?: number; - public readonly limit?: number; constructor(config: FilterQueryConfig = {}) { - this.filter = config.filter; - this.returnFields = config.returnFields; - this.numResults = config.numResults ?? 10; - this.offset = config.offset; - this.limit = config.limit ?? this.numResults; + const numResults = config.numResults ?? 10; + super({ + filter: config.filter, + returnFields: config.returnFields, + offset: config.offset, + limit: config.limit ?? numResults, + }); + this.numResults = numResults; } - buildQuery(): string { - return renderFilter(this.filter); + get filter(): FilterInput | undefined { + return this.queryFilter; + } + + get offset(): number | undefined { + return this.queryOffset; } - buildParams(): Record { - return {}; + get limit(): number | undefined { + return this.queryLimit; + } + + buildQuery(): string { + return renderFilter(this.filter); } } diff --git a/src/query/hybrid.ts b/src/query/hybrid.ts index 7f20f39..42759c7 100644 --- a/src/query/hybrid.ts +++ b/src/query/hybrid.ts @@ -15,11 +15,15 @@ */ import type { FtHybridOptions } from '@redis/search/dist/lib/commands/HYBRID.js'; -import { QueryValidationError, SchemaValidationError } from '../errors.js'; -import { encodeVectorBuffer, normalizeVectorDataType } from '../redis/utils.js'; -import { VectorDataType } from '../schema/types.js'; +import { QueryValidationError } from '../errors.js'; +import { encodeVectorBuffer } from '../redis/utils.js'; import { TokenEscaper } from '../utils/token-escaper.js'; -import { renderFilter, type FilterInput } from './base.js'; +import { + BaseVectorQuery, + renderFilter, + type BaseVectorQueryConfig, + type FilterInput, +} from './base.js'; import type { TextScorer } from './text.js'; const escaper = new TokenEscaper(); @@ -48,8 +52,10 @@ export type HybridCombine = | { type: 'RRF'; constant?: number; window?: number } | { type: 'LINEAR'; alpha?: number; beta?: number; window?: number }; +type HybridSortField = { field: string; direction?: 'ASC' | 'DESC' }; + /** Configuration for {@link HybridQuery}. */ -export interface HybridQueryConfig { +export interface HybridQueryConfig extends BaseVectorQueryConfig { /** * Text query body. * @@ -64,12 +70,6 @@ export interface HybridQueryConfig { /** Indexed text field name. When supplied, triggers tokenization. */ textFieldName?: string; - /** Query vector. */ - vector: number[]; - - /** Indexed vector field name. */ - vectorField: string; - /** * Vector retrieval method. Defaults to `{ type: 'KNN', k: 10 }`. */ @@ -116,24 +116,15 @@ export interface HybridQueryConfig { */ combinedScoreAlias?: string; - /** Fields to load into the result payload (FT.HYBRID `LOAD`). */ - returnFields?: string[]; - /** Number of results to return. Defaults to 10. */ numResults?: number; - /** Pagination offset. */ - offset?: number; - /** Sort specification (FT.HYBRID `SORTBY`). */ sortBy?: Array<{ field: string; direction?: 'ASC' | 'DESC' }>; /** Disable result sorting (FT.HYBRID `NOSORT`). */ noSort?: boolean; - /** Vector encoding type. Defaults to `FLOAT32`. */ - datatype?: VectorDataType | string; - /** Server-side query timeout in milliseconds. */ timeout?: number; } @@ -154,17 +145,18 @@ const VECTOR_PARAM_NAME = 'vector'; * doesn't belong in a LOAD/SORTBY slot or a typo of `@name`/`$.name`. */ function prefixFieldRef(name: string): string { - if (name.startsWith('@')) return name; - if (name.startsWith('$')) { - if (!name.startsWith('$.')) { + const field = name.trim(); + if (field.startsWith('@')) return field; + if (field.startsWith('$')) { + if (!field.startsWith('$.')) { throw new QueryValidationError( - `Field reference '${name}' looks like a parameter ref or typo; ` + - `use '@${name.slice(1)}' for an index field or '$.${name.slice(1)}' for a JSONPath` + `Field reference '${field}' looks like a parameter ref or typo; ` + + `use '@${field.slice(1)}' for an index field or '$.${field.slice(1)}' for a JSONPath` ); } - return name; + return field; } - return `@${name}`; + return `@${field}`; } function assertNonEmptyString(value: string | undefined, label: string): void { @@ -197,16 +189,6 @@ function assertUnitInterval(value: number | undefined, label: string): void { } } -function validateFields(fields: string[] | undefined, label: string): string[] | undefined { - if (fields === undefined) return undefined; - return fields.map((field) => { - assertNonEmptyString(field, label); - // Trim during normalization so padded inputs like `' price '` don't - // leak into LOAD/SORTBY clauses as `@ price `, which Redis rejects. - return field.trim(); - }); -} - function validateSortBy( sortBy: Array<{ field: string; direction?: 'ASC' | 'DESC' }> | undefined ): Array<{ field: string; direction?: 'ASC' | 'DESC' }> | undefined { @@ -242,39 +224,27 @@ function validateSortBy( * const results = await index.hybridSearch(q); * ``` */ -export class HybridQuery { +export class HybridQuery extends BaseVectorQuery { public readonly text: string; public readonly textFieldName?: string; - public readonly vector: number[]; - public readonly vectorField: string; public readonly vectorMethod: | { type: 'KNN'; k: number; efRuntime?: number } | { type: 'RANGE'; radius: number; epsilon?: number }; - public readonly vsimFilter?: FilterInput; public readonly postFilter?: string; public readonly textScorer?: TextScorer; public readonly combine?: HybridCombine; public readonly textScoreAlias?: string; public readonly vectorScoreAlias?: string; public readonly combinedScoreAlias: string; - public readonly returnFields?: string[]; public readonly numResults: number; - public readonly offset: number; - public readonly sortBy?: Array<{ field: string; direction?: 'ASC' | 'DESC' }>; public readonly noSort?: boolean; - public readonly datatype: VectorDataType; public readonly timeout?: number; + private readonly _hybridSortBy?: HybridSortField[]; constructor(config: HybridQueryConfig) { if (!config.text || config.text.trim() === '') { throw new QueryValidationError('text cannot be empty'); } - if (!config.vector || config.vector.length === 0) { - throw new QueryValidationError('vector cannot be empty'); - } - if (!config.vectorField || config.vectorField.trim() === '') { - throw new QueryValidationError('vectorField is required'); - } assertNonEmptyString(config.textFieldName, 'textFieldName'); assertNonEmptyString(config.postFilter, 'postFilter'); assertNonEmptyString(config.textScoreAlias, 'textScoreAlias'); @@ -303,7 +273,6 @@ export class HybridQuery { assertPositiveInteger(config.numResults ?? 10, 'numResults'); assertNonNegativeInteger(config.offset ?? 0, 'offset'); assertPositiveInteger(config.timeout, 'timeout'); - const returnFields = validateFields(config.returnFields, 'returnFields'); const sortBy = validateSortBy(config.sortBy); if (config.noSort && sortBy && sortBy.length > 0) { throw new QueryValidationError('noSort cannot be used with sortBy'); @@ -321,38 +290,42 @@ export class HybridQuery { } } - try { - this.datatype = normalizeVectorDataType(config.datatype); - } catch (error) { - if (error instanceof SchemaValidationError) { - throw new QueryValidationError(error.message); - } - throw error; - } + const vsimFilter = config.vsimFilter ?? config.filter; + super({ + ...config, + filter: vsimFilter, + offset: config.offset ?? 0, + limit: config.numResults ?? 10, + }); this.text = config.text; this.textFieldName = config.textFieldName; - this.vector = [...config.vector]; - this.vectorField = config.vectorField; this.vectorMethod = method.type === 'KNN' ? { type: 'KNN', k: method.k ?? 10, efRuntime: method.efRuntime } : method; - this.vsimFilter = config.vsimFilter; this.postFilter = config.postFilter; this.textScorer = config.textScorer; this.combine = config.combine; this.textScoreAlias = config.textScoreAlias; this.vectorScoreAlias = config.vectorScoreAlias; this.combinedScoreAlias = config.combinedScoreAlias ?? 'hybrid_score'; - this.returnFields = returnFields; this.numResults = config.numResults ?? 10; - this.offset = config.offset ?? 0; - this.sortBy = sortBy; + this._hybridSortBy = sortBy; this.noSort = config.noSort; this.timeout = config.timeout; } + /** VSIM filter used to pre-filter vector candidates. */ + get vsimFilter(): FilterInput | undefined { + return this.filter; + } + + /** The SEARCH clause query body. */ + buildQuery(): string { + return this.renderTextBody(); + } + /** * Convert this query into the structured options expected by * `client.ft.hybrid(indexName, options)`. @@ -362,7 +335,7 @@ export class HybridQuery { SEARCH: this.buildSearchClause(), VSIM: this.buildVsimClause(), PARAMS: { [VECTOR_PARAM_NAME]: encodeVectorBuffer(this.vector, this.datatype) }, - LIMIT: { offset: this.offset, count: this.numResults }, + LIMIT: { offset: this.offset ?? 0, count: this.limit ?? this.numResults }, }; // Always yield a known combined score alias so result mapping is stable @@ -380,11 +353,16 @@ export class HybridQuery { } options.LOAD = [...loadFields]; - if (this.sortBy && this.sortBy.length > 0) { + const sortFields = this.getHybridSortFields(); + if (this.noSort && sortFields.length > 0) { + throw new QueryValidationError('noSort cannot be used with sortBy'); + } + + if (sortFields.length > 0) { // SORTBY uses the same field-reference convention as LOAD — // `@field` for hash fields, `$.path` for JSON paths. options.SORTBY = { - fields: this.sortBy.map((s) => ({ + fields: sortFields.map((s) => ({ field: prefixFieldRef(s.field as string), ...(s.direction ? { direction: s.direction } : {}), })), @@ -436,6 +414,10 @@ export class HybridQuery { return vsim; } + private getHybridSortFields(): HybridSortField[] { + return [...(this._hybridSortBy ?? []), ...this.sortFields]; + } + private encodeVectorMethod(): NonNullable { const m = this.vectorMethod; if (m.type === 'KNN') { diff --git a/src/query/range.ts b/src/query/range.ts index 8c96193..0ae9c34 100644 --- a/src/query/range.ts +++ b/src/query/range.ts @@ -1,19 +1,18 @@ -import { renderFilter, type BaseQuery, type FilterInput } from './base.js'; +import { + BaseVectorQuery, + renderFilter, + type BaseVectorQueryConfig, + type FilterInput, +} from './base.js'; import { VectorDataType, VectorDistanceMetric } from '../schema/types.js'; -import { QueryValidationError, SchemaValidationError } from '../errors.js'; -import { encodeVectorBuffer, normalizeVectorDataType } from '../redis/utils.js'; +import { QueryValidationError } from '../errors.js'; +import { encodeVectorBuffer } from '../redis/utils.js'; import type { HybridPolicy } from './vector.js'; /** * Configuration for {@link VectorRangeQuery}. */ -export interface VectorRangeQueryConfig { - /** Query vector. */ - vector: number[]; - - /** Vector field name to search against. */ - vectorField: string; - +export interface VectorRangeQueryConfig extends BaseVectorQueryConfig { /** * Maximum allowed vector distance for results. * @@ -76,29 +75,15 @@ export interface VectorRangeQueryConfig { * const results = await index.search(q); * ``` */ -export class VectorRangeQuery implements BaseQuery { - public readonly vector: number[]; - public readonly vectorField: string; +export class VectorRangeQuery extends BaseVectorQuery { public readonly distanceThreshold: number; - public readonly filter?: FilterInput; - public readonly returnFields?: string[]; public readonly distanceMetric: VectorDistanceMetric; - public readonly datatype: VectorDataType; - public readonly offset?: number; - public readonly limit?: number; public readonly scoreAlias: string; public readonly hybridPolicy?: HybridPolicy; public readonly batchSize?: number; - public readonly normalizeDistance: boolean; constructor(config: VectorRangeQueryConfig) { - if (!config.vector || config.vector.length === 0) { - throw new QueryValidationError('Vector cannot be empty'); - } - - if (!config.vectorField) { - throw new QueryValidationError('vectorField is required'); - } + super(config); if (config.distanceThreshold !== undefined && config.distanceThreshold < 0) { throw new QueryValidationError('distanceThreshold must be non-negative'); @@ -119,26 +104,11 @@ export class VectorRangeQuery implements BaseQuery { } } - this.vector = config.vector; - this.vectorField = config.vectorField; this.distanceThreshold = config.distanceThreshold ?? 0.2; - this.filter = config.filter; - this.returnFields = config.returnFields; this.distanceMetric = config.distanceMetric ?? VectorDistanceMetric.COSINE; - try { - this.datatype = normalizeVectorDataType(config.datatype); - } catch (error) { - if (error instanceof SchemaValidationError) { - throw new QueryValidationError(error.message); - } - throw error; - } - this.offset = config.offset; - this.limit = config.limit; this.scoreAlias = config.scoreAlias ?? 'vector_distance'; this.hybridPolicy = config.hybridPolicy; this.batchSize = config.batchSize; - this.normalizeDistance = config.normalizeDistance ?? false; } buildQuery(): string { @@ -153,6 +123,7 @@ export class VectorRangeQuery implements BaseQuery { buildParams(): Record { const params: Record = { + ...super.buildParams(), vector: encodeVectorBuffer(this.vector, this.datatype), distance_threshold: this.distanceThreshold, }; diff --git a/src/query/text.ts b/src/query/text.ts index e07982d..8c1216a 100644 --- a/src/query/text.ts +++ b/src/query/text.ts @@ -1,4 +1,4 @@ -import { renderFilter, type BaseQuery, type FilterInput } from './base.js'; +import { BaseQuery, renderFilter, type FilterInput } from './base.js'; import { TokenEscaper } from '../utils/token-escaper.js'; import { QueryValidationError } from '../errors.js'; import { resolveStopwords, type StopwordsInput } from '../utils/stopwords/resolve.js'; @@ -91,15 +91,11 @@ export interface TextQueryConfig { * const results = await index.search(q); * ``` */ -export class TextQuery implements BaseQuery { +export class TextQuery extends BaseQuery { public readonly text: string; public readonly textFieldName: string; public readonly textScorer: TextScorer; - public readonly filter?: FilterInput; - public readonly returnFields?: string[]; public readonly numResults: number; - public readonly offset?: number; - public readonly limit?: number; public readonly stopwords: ReadonlySet | null; constructor(config: TextQueryConfig) { @@ -107,17 +103,32 @@ export class TextQuery implements BaseQuery { throw new QueryValidationError('textFieldName is required'); } + const numResults = config.numResults ?? 10; + super({ + filter: config.filter, + returnFields: config.returnFields, + offset: config.offset, + limit: config.limit ?? numResults, + }); this.text = config.text; this.textFieldName = config.textFieldName; this.textScorer = config.textScorer ?? 'BM25STD'; - this.filter = config.filter; - this.returnFields = config.returnFields; - this.numResults = config.numResults ?? 10; - this.offset = config.offset; - this.limit = config.limit ?? this.numResults; + this.numResults = numResults; this.stopwords = resolveStopwords(config.stopwords); } + get filter(): FilterInput | undefined { + return this.queryFilter; + } + + get offset(): number | undefined { + return this.queryOffset; + } + + get limit(): number | undefined { + return this.queryLimit; + } + buildQuery(): string { const stopwordSet = this.stopwords; const tokens: string[] = []; diff --git a/src/query/vector.ts b/src/query/vector.ts index cac8f24..6f0d07b 100644 --- a/src/query/vector.ts +++ b/src/query/vector.ts @@ -1,7 +1,7 @@ -import { renderFilter, type BaseQuery, type FilterInput } from './base.js'; -import { VectorDataType, VectorDistanceMetric } from '../schema/types.js'; -import { QueryValidationError, SchemaValidationError } from '../errors.js'; -import { encodeVectorBuffer, normalizeVectorDataType } from '../redis/utils.js'; +import { BaseVectorQuery, renderFilter, type BaseVectorQueryConfig } from './base.js'; +import { VectorDistanceMetric } from '../schema/types.js'; +import { QueryValidationError } from '../errors.js'; +import { encodeVectorBuffer } from '../redis/utils.js'; /** * Hybrid policy options for vector search with filters @@ -16,41 +16,13 @@ export type UseSearchHistory = 'OFF' | 'ON' | 'AUTO'; /** * Configuration for VectorQuery */ -export interface VectorQueryConfig { - /** Vector to search with */ - vector: number[]; - - /** Name of the vector field in the index */ - vectorField: string; - +export interface VectorQueryConfig extends BaseVectorQueryConfig { /** Number of results to return */ numResults?: number; - /** - * Filter expression — either a {@link FilterExpression} from the filter - * DSL, or a raw Redis Search filter string (e.g. `'@category:{electronics}'`). - */ - filter?: FilterInput; - - /** Fields to return in results */ - returnFields?: string[]; - /** Distance metric to use */ distanceMetric?: VectorDistanceMetric; - /** - * Vector datatype to use when serializing the query vector. - * Must match the datatype declared on the schema's vector field. - * @default 'FLOAT32' - */ - datatype?: VectorDataType | string; - - /** Pagination offset */ - offset?: number; - - /** Pagination limit */ - limit?: number; - /** * Alias for the score field in results * @default 'vector_distance' @@ -177,35 +149,19 @@ export interface VectorQueryConfig { * const results = await index.search(query); * ``` */ -export class VectorQuery implements BaseQuery { - public readonly vector: number[]; - public readonly vectorField: string; +export class VectorQuery extends BaseVectorQuery { public readonly numResults: number; - public readonly filter?: FilterInput; - public readonly returnFields?: string[]; public readonly distanceMetric: VectorDistanceMetric; - public readonly datatype: VectorDataType; - public readonly offset?: number; - public readonly limit?: number; public readonly scoreAlias: string; public readonly efRuntime?: number; public readonly hybridPolicy?: HybridPolicy; public readonly batchSize?: number; - public readonly normalizeDistance: boolean; public readonly searchWindowSize?: number; public readonly useSearchHistory?: UseSearchHistory; public readonly searchBufferCapacity?: number; constructor(config: VectorQueryConfig) { - // Validate vector - if (!config.vector || config.vector.length === 0) { - throw new QueryValidationError('Vector cannot be empty'); - } - - // Validate vectorField - if (!config.vectorField) { - throw new QueryValidationError('vectorField is required'); - } + super(config); // Validate HNSW parameters if (config.efRuntime !== undefined && config.efRuntime <= 0) { @@ -252,27 +208,12 @@ export class VectorQuery implements BaseQuery { throw new QueryValidationError('searchBufferCapacity must be positive'); } - this.vector = config.vector; - this.vectorField = config.vectorField; this.numResults = config.numResults ?? 10; - this.filter = config.filter; - this.returnFields = config.returnFields; this.distanceMetric = config.distanceMetric ?? VectorDistanceMetric.COSINE; - try { - this.datatype = normalizeVectorDataType(config.datatype); - } catch (error) { - if (error instanceof SchemaValidationError) { - throw new QueryValidationError(error.message); - } - throw error; - } - this.offset = config.offset; - this.limit = config.limit; this.scoreAlias = config.scoreAlias ?? 'vector_distance'; this.efRuntime = config.efRuntime; this.hybridPolicy = config.hybridPolicy; this.batchSize = config.batchSize; - this.normalizeDistance = config.normalizeDistance ?? false; this.searchWindowSize = config.searchWindowSize; this.useSearchHistory = config.useSearchHistory; this.searchBufferCapacity = config.searchBufferCapacity; @@ -328,6 +269,7 @@ export class VectorQuery implements BaseQuery { const vectorBuffer = encodeVectorBuffer(this.vector, this.datatype); const params: Record = { + ...super.buildParams(), vector: vectorBuffer, }; diff --git a/tests/integration/vector-search.test.ts b/tests/integration/vector-search.test.ts index 647dacc..dcdb4c7 100644 --- a/tests/integration/vector-search.test.ts +++ b/tests/integration/vector-search.test.ts @@ -3,12 +3,45 @@ import { createClient, type RedisClientType } from 'redis'; import { IndexSchema } from '../../src/schema/schema.js'; import { SearchIndex } from '../../src/indexes/search-index.js'; import { VectorQuery } from '../../src/query/vector.js'; -import { HuggingFaceVectorizer } from '../../src/vectorizers/index.js'; + +const VECTOR_DIMS = 4; + +function unit(vector: number[]): number[] { + const magnitude = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)); + return vector.map((value) => value / magnitude); +} + +const TEST_EMBEDDINGS = new Map([ + ['laptop computer for programming', unit([1, 0, 0, 0])], + ['wireless mouse', unit([0.7, 0.7, 0, 0])], + ['mechanical keyboard', unit([0.85, 0.53, 0, 0])], + ['office desk chair', unit([0, 0, 0.6, 0.8])], + ['standing desk', unit([0, 0, 1, 0])], + ['monitor screen 27 inch', unit([0.9, 0.44, 0, 0])], + ['gaming laptop', unit([1, 0, 0, 0])], + ['wireless gaming mouse', unit([0.72, 0.69, 0, 0])], + ['office desk', unit([0, 0, 1, 0])], + ['desktop computer', unit([0.995, 0.1, 0, 0])], + ['computer', unit([0.995, 0.1, 0, 0])], + ['desk', unit([0, 0, 1, 0])], + ['electronics', unit([0.9, 0.44, 0, 0])], + ['laptop computer', unit([0.995, 0.1, 0, 0])], + ['phone', unit([0.65, 0.76, 0, 0])], + ['laptop', unit([1, 0, 0, 0])], + ['product', unit([0.5, 0.5, 0.5, 0.5])], + ['gaming', unit([1, 0.05, 0, 0])], +]); + +class TestVectorizer { + async embed(text: string): Promise { + return [...(TEST_EMBEDDINGS.get(text.toLowerCase()) ?? unit([0.5, 0.5, 0.5, 0.5]))]; + } +} describe('Vector Search Integration', () => { let client: RedisClientType; let index: SearchIndex; - let vectorizer: HuggingFaceVectorizer; + let vectorizer: TestVectorizer; let products: any[]; // Shared test data beforeAll(async () => { @@ -18,10 +51,7 @@ describe('Vector Search Integration', () => { }); await client.connect(); - // Create vectorizer - vectorizer = new HuggingFaceVectorizer({ - model: 'Xenova/all-MiniLM-L6-v2', - }); + vectorizer = new TestVectorizer(); // Create schema for product search const schema = IndexSchema.fromObject({ @@ -38,7 +68,7 @@ describe('Vector Search Integration', () => { name: 'embedding', type: 'vector', attrs: { - dims: 384, + dims: VECTOR_DIMS, algorithm: 'hnsw', distanceMetric: 'cosine', }, @@ -289,7 +319,7 @@ describe('Vector Search Integration', () => { name: 'embedding', type: 'vector', attrs: { - dims: 384, + dims: VECTOR_DIMS, algorithm: 'flat', distanceMetric: 'ip', }, @@ -472,7 +502,7 @@ describe('Vector Search Integration', () => { describe('Vector Search with JSON Storage Integration', () => { let client: RedisClientType; let index: SearchIndex; - let vectorizer: HuggingFaceVectorizer; + let vectorizer: TestVectorizer; beforeAll(async () => { // Connect to Redis @@ -481,10 +511,7 @@ describe('Vector Search with JSON Storage Integration', () => { }); await client.connect(); - // Create vectorizer - vectorizer = new HuggingFaceVectorizer({ - model: 'Xenova/all-MiniLM-L6-v2', - }); + vectorizer = new TestVectorizer(); // Create schema for product search with JSON storage const schema = IndexSchema.fromObject({ @@ -503,7 +530,7 @@ describe('Vector Search with JSON Storage Integration', () => { type: 'vector', attrs: { as: 'embedding', - dims: 384, + dims: VECTOR_DIMS, algorithm: 'hnsw', distanceMetric: 'cosine', }, @@ -582,8 +609,11 @@ describe('Vector Search with JSON Storage Integration', () => { const results = await index.search(query); expect(results.total).toBeGreaterThan(0); - expect(results.documents[0].value.title).toContain('Laptop'); - expect(results.documents[0].score).toBeDefined(); + const laptopResult = results.documents.find((doc) => + String(doc.value.title).includes('Laptop') + ); + expect(laptopResult).toBeDefined(); + expect(laptopResult?.score).toBeDefined(); }); it('should filter by nested JSON fields', async () => { diff --git a/tests/unit/indexes/search-index.test.ts b/tests/unit/indexes/search-index.test.ts index ee65813..2e13aaf 100644 --- a/tests/unit/indexes/search-index.test.ts +++ b/tests/unit/indexes/search-index.test.ts @@ -7,6 +7,7 @@ import type { RedisClientType } from 'redis'; import { RedisVLError, SchemaValidationError } from '../../../src/errors.js'; import { VectorQuery } from '../../../src/query/vector.js'; import { TextQuery } from '../../../src/query/text.js'; +import { FilterQuery } from '../../../src/query/filter-query.js'; import { AggregationQuery, Reducers } from '../../../src/query/aggregation.js'; describe('SearchIndex', () => { @@ -1381,6 +1382,76 @@ describe('SearchIndex', () => { }) ); }); + + it('should pass chainable query sort fields to FT.SEARCH', async () => { + const ftSearch = mockClient.ft.search as MockedFunction; + ftSearch.mockResolvedValue({ + total: 0, + documents: [], + } as Awaited>); + + const index = new SearchIndex(schema, mockClient); + await index.search(new FilterQuery().sortBy('title', { direction: 'DESC' })); + + expect(ftSearch).toHaveBeenCalledWith( + 'redisvl-test-index', + '*', + expect.objectContaining({ + SORTBY: { + BY: 'title', + DIRECTION: 'DESC', + }, + }) + ); + }); + + it('should let options.sortBy override query sort fields', async () => { + const ftSearch = mockClient.ft.search as MockedFunction; + ftSearch.mockResolvedValue({ + total: 0, + documents: [], + } as Awaited>); + + const index = new SearchIndex(schema, mockClient); + await index.search(new FilterQuery().sortBy('title'), { + sortBy: 'id', + sortOrder: 'DESC', + }); + + expect(ftSearch).toHaveBeenCalledWith( + 'redisvl-test-index', + '*', + expect.objectContaining({ + SORTBY: { + BY: 'id', + DIRECTION: 'DESC', + }, + }) + ); + }); + + it('should pass options.sortBy as an object when no sort order is supplied', async () => { + const ftSearch = mockClient.ft.search as MockedFunction; + ftSearch.mockResolvedValue({ + total: 0, + documents: [], + } as Awaited>); + + const index = new SearchIndex(schema, mockClient); + await index.search(new FilterQuery(), { + sortBy: 'id', + }); + + expect(ftSearch).toHaveBeenCalledWith( + 'redisvl-test-index', + '*', + expect.objectContaining({ + SORTBY: { + BY: 'id', + }, + }) + ); + }); }); describe('aggregate', () => { diff --git a/tests/unit/query/aggregation.test.ts b/tests/unit/query/aggregation.test.ts index d5946f1..b5244cb 100644 --- a/tests/unit/query/aggregation.test.ts +++ b/tests/unit/query/aggregation.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { BaseQuery } from '../../../src/query/base.js'; import { AggregationQuery, Reducers } from '../../../src/query/aggregation.js'; import { Tag, Num } from '../../../src/query/filter.js'; import { QueryValidationError } from '../../../src/errors.js'; @@ -7,6 +8,7 @@ describe('AggregationQuery', () => { describe('query string', () => { it('defaults to wildcard when no filter is supplied', () => { const q = new AggregationQuery(); + expect(q).toBeInstanceOf(BaseQuery); expect(q.toCommand().query).toBe('*'); }); @@ -235,6 +237,11 @@ describe('AggregationQuery', () => { const q = new AggregationQuery().load('a').load(['b', 'c']); expect(q.toCommand().options.LOAD).toEqual(['@a', '@b', '@c']); }); + + it('maps inherited return fields to LOAD', () => { + const q = new AggregationQuery().setReturnFields(['title', 'price']); + expect(q.toCommand().options.LOAD).toEqual(['@title', '@price']); + }); }); describe('top-level options', () => { diff --git a/tests/unit/query/base.test.ts b/tests/unit/query/base.test.ts new file mode 100644 index 0000000..a768a3f --- /dev/null +++ b/tests/unit/query/base.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; +import { + BaseQuery, + BaseVectorQuery, + renderFilter, + type BaseQueryConfig, + type BaseVectorQueryConfig, + type FilterInput, +} from '../../../src/query/base.js'; +import { Tag } from '../../../src/query/filter.js'; +import { QueryValidationError } from '../../../src/errors.js'; +import { VectorDataType } from '../../../src/schema/types.js'; + +class TestQuery extends BaseQuery { + constructor(config: BaseQueryConfig = {}) { + super(config); + } + + get filter(): FilterInput | undefined { + return this.queryFilter; + } + + get offset(): number | undefined { + return this.queryOffset; + } + + get limit(): number | undefined { + return this.queryLimit; + } + + buildQuery(): string { + return renderFilter(this.filter); + } +} + +class TestVectorQuery extends BaseVectorQuery { + constructor(config: BaseVectorQueryConfig) { + super(config); + } + + buildQuery(): string { + return `${renderFilter(this.filter)} @${this.vectorField}`; + } +} + +describe('BaseQuery', () => { + it('should initialize with common query config', () => { + const query = new TestQuery({ + filter: '@category:{books}', + returnFields: ['title', 'price'], + offset: 10, + limit: 5, + }); + + expect(query.filter).toBe('@category:{books}'); + expect(query.returnFields).toEqual(['title', 'price']); + expect(query.offset).toBe(10); + expect(query.limit).toBe(5); + }); + + it('should default buildParams to an empty object', () => { + expect(new TestQuery().buildParams()).toEqual({}); + }); + + it('should set filters from strings and FilterExpression objects', () => { + const query = new TestQuery().setFilter(Tag('brand').eq('redis')); + + expect(query.buildQuery()).toBe('@brand:{redis}'); + + query.setFilter('@brand:{valkey}'); + expect(query.filter).toBe('@brand:{valkey}'); + + query.setFilter(null); + expect(query.filter).toBeUndefined(); + }); + + it('should reject empty filter strings', () => { + expect(() => new TestQuery({ filter: '' })).toThrow(QueryValidationError); + expect(() => new TestQuery().setFilter(' ')).toThrow(QueryValidationError); + }); + + it('should set return fields and skip-decode fields', () => { + const query = new TestQuery().setReturnFields(['title', 'embedding'], { + skipDecode: 'embedding', + }); + + expect(query.returnFields).toEqual(['title', 'embedding']); + expect(query.skipDecodeFields).toEqual(['embedding']); + + query.setReturnFields(['title'], { skipDecode: ['raw', 'blob'] }); + expect(query.returnFields).toEqual(['title']); + expect(query.skipDecodeFields).toEqual(['raw', 'blob']); + }); + + it('should defensively copy return and skip-decode fields', () => { + const fields = ['title']; + const skipDecode = ['embedding']; + const query = new TestQuery().setReturnFields(fields, { skipDecode }); + + fields.push('price'); + skipDecode.push('blob'); + + expect(query.returnFields).toEqual(['title']); + expect(query.skipDecodeFields).toEqual(['embedding']); + }); + + it('should clear return fields and skip-decode fields', () => { + const query = new TestQuery() + .setReturnFields(['title'], { skipDecode: 'embedding' }) + .setReturnFields(); + + expect(query.returnFields).toBeUndefined(); + expect(query.skipDecodeFields).toBeUndefined(); + }); + + it('should trim return fields and skip-decode fields before storing them', () => { + const query = new TestQuery().setReturnFields([' title ', ' $.path '], { + skipDecode: [' embedding '], + }); + + expect(query.returnFields).toEqual(['title', '$.path']); + expect(query.skipDecodeFields).toEqual(['embedding']); + }); + + it('should reject invalid return and skip-decode fields', () => { + expect(() => new TestQuery().setReturnFields(['title', ''])).toThrow(QueryValidationError); + expect(() => + new TestQuery().setReturnFields(['title'], { skipDecode: ['embedding', ''] }) + ).toThrow(QueryValidationError); + }); + + it('should set paging values', () => { + const query = new TestQuery().paging(20, 10); + + expect(query.offset).toBe(20); + expect(query.limit).toBe(10); + }); + + it('should reject invalid paging values', () => { + expect(() => new TestQuery().paging(-1, 10)).toThrow(QueryValidationError); + expect(() => new TestQuery().paging(0, 0)).toThrow(QueryValidationError); + expect(() => new TestQuery({ offset: -1 })).toThrow(QueryValidationError); + expect(() => new TestQuery({ limit: 0 })).toThrow(QueryValidationError); + }); + + it('should collect sort fields', () => { + const query = new TestQuery().sortBy('price').sortBy(' created_at ', { + direction: 'DESC', + }); + + expect(query.sortFields).toEqual([ + { field: 'price', direction: 'ASC' }, + { field: 'created_at', direction: 'DESC' }, + ]); + }); + + it('should reject invalid sort fields', () => { + expect(() => new TestQuery().sortBy('')).toThrow(QueryValidationError); + expect(() => new TestQuery().sortBy('price', { direction: 'DOWN' as any })).toThrow( + QueryValidationError + ); + }); +}); + +describe('BaseVectorQuery', () => { + it('should initialize vector state and common query state', () => { + const query = new TestVectorQuery({ + vector: [0.1, 0.2, 0.3], + vectorField: ' embedding ', + datatype: VectorDataType.FLOAT64, + normalizeDistance: true, + filter: '@category:{books}', + returnFields: ['title'], + }); + + expect(query).toBeInstanceOf(BaseQuery); + expect(query.vector).toEqual([0.1, 0.2, 0.3]); + expect(query.vectorField).toBe('embedding'); + expect(query.datatype).toBe(VectorDataType.FLOAT64); + expect(query.normalizeDistance).toBe(true); + expect(query.filter).toBe('@category:{books}'); + expect(query.returnFields).toEqual(['title']); + }); + + it('should reject invalid vector state', () => { + expect(() => new TestVectorQuery({ vector: [], vectorField: 'embedding' })).toThrow( + QueryValidationError + ); + expect(() => new TestVectorQuery({ vector: [0.1], vectorField: '' })).toThrow( + QueryValidationError + ); + expect( + () => + new TestVectorQuery({ + vector: [0.1], + vectorField: 'embedding', + datatype: 'float25', + }) + ).toThrow(QueryValidationError); + }); +}); diff --git a/tests/unit/query/count.test.ts b/tests/unit/query/count.test.ts index b28f711..02bc8fd 100644 --- a/tests/unit/query/count.test.ts +++ b/tests/unit/query/count.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect } from 'vitest'; +import { BaseQuery } from '../../../src/query/base.js'; import { CountQuery } from '../../../src/query/count.js'; import { Tag } from '../../../src/query/filter.js'; describe('CountQuery', () => { it('renders a wildcard query when no filter is supplied', () => { const q = new CountQuery(); + expect(q).toBeInstanceOf(BaseQuery); expect(q.buildQuery()).toBe('*'); }); @@ -25,6 +27,8 @@ describe('CountQuery', () => { const q = new CountQuery({ filter: Tag('brand').eq('nike') }); expect(q.offset).toBe(0); expect(q.limit).toBe(0); + expect(q.getOffset()).toBe(0); + expect(q.getLimit()).toBe(0); }); it('exposes a noContent flag so callers can wire up FT.SEARCH NOCONTENT', () => { diff --git a/tests/unit/query/filter-query.test.ts b/tests/unit/query/filter-query.test.ts index 3ee2bbd..ba137da 100644 --- a/tests/unit/query/filter-query.test.ts +++ b/tests/unit/query/filter-query.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect } from 'vitest'; +import { BaseQuery } from '../../../src/query/base.js'; import { FilterQuery } from '../../../src/query/filter-query.js'; import { Tag, Num } from '../../../src/query/filter.js'; describe('FilterQuery', () => { it('renders a wildcard query when no filter is supplied', () => { const q = new FilterQuery(); + expect(q).toBeInstanceOf(BaseQuery); expect(q.buildQuery()).toBe('*'); }); diff --git a/tests/unit/query/hybrid.test.ts b/tests/unit/query/hybrid.test.ts index 95b690a..e241d9f 100644 --- a/tests/unit/query/hybrid.test.ts +++ b/tests/unit/query/hybrid.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { BaseQuery, BaseVectorQuery } from '../../../src/query/base.js'; import { HybridQuery } from '../../../src/query/hybrid.js'; import { Tag, Num } from '../../../src/query/filter.js'; import { QueryValidationError } from '../../../src/errors.js'; @@ -19,6 +20,17 @@ describe('HybridQuery', () => { ).toThrow(QueryValidationError); }); + it('extends the shared query base classes', () => { + const q = new HybridQuery({ + text: 'foo', + vector: VECTOR, + vectorField: 'embedding', + }); + + expect(q).toBeInstanceOf(BaseVectorQuery); + expect(q).toBeInstanceOf(BaseQuery); + }); + it('throws when vectorField is missing', () => { expect( () => @@ -515,6 +527,17 @@ describe('HybridQuery', () => { expect(options.LIMIT).toEqual({ offset: 5, count: 25 }); }); + it('lets chainable paging override the emitted LIMIT count', () => { + const q = new HybridQuery({ + text: 'foo', + vector: VECTOR, + vectorField: 'embedding', + numResults: 25, + }).paging(10, 5); + const { options } = q.toCommand(); + expect(options.LIMIT).toEqual({ offset: 10, count: 5 }); + }); + it('defaults LIMIT to offset=0, count=10', () => { const q = new HybridQuery({ text: 'foo', diff --git a/tests/unit/query/range.test.ts b/tests/unit/query/range.test.ts index 11a4fca..64035f4 100644 --- a/tests/unit/query/range.test.ts +++ b/tests/unit/query/range.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { BaseQuery, BaseVectorQuery } from '../../../src/query/base.js'; import { VectorRangeQuery } from '../../../src/query/range.js'; import { Tag } from '../../../src/query/filter.js'; import { QueryValidationError } from '../../../src/errors.js'; @@ -22,6 +23,8 @@ describe('VectorRangeQuery', () => { it('defaults distanceThreshold to 0.2', () => { const q = new VectorRangeQuery({ vector: vec, vectorField: 'embedding' }); + expect(q).toBeInstanceOf(BaseVectorQuery); + expect(q).toBeInstanceOf(BaseQuery); expect(q.distanceThreshold).toBe(0.2); }); diff --git a/tests/unit/query/text.test.ts b/tests/unit/query/text.test.ts index 79fb626..4a9d163 100644 --- a/tests/unit/query/text.test.ts +++ b/tests/unit/query/text.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { BaseQuery } from '../../../src/query/base.js'; import { TextQuery } from '../../../src/query/text.js'; import { Tag } from '../../../src/query/filter.js'; import { QueryValidationError } from '../../../src/errors.js'; @@ -12,6 +13,7 @@ describe('TextQuery', () => { // or whitespace-only input renders as @field:() and surfaces at // index.search() time as a ResponseError from Redis. const q = new TextQuery({ text: '', textFieldName: 'description' }); + expect(q).toBeInstanceOf(BaseQuery); expect(q.buildQuery()).toBe('@description:()'); }); diff --git a/tests/unit/query/vector.test.ts b/tests/unit/query/vector.test.ts index c6d7c80..96fa6b8 100644 --- a/tests/unit/query/vector.test.ts +++ b/tests/unit/query/vector.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; +import { BaseQuery, BaseVectorQuery } from '../../../src/query/base.js'; import { VectorQuery } from '../../../src/query/vector.js'; import { QueryValidationError } from '../../../src/errors.js'; import { VectorDataType, VectorDistanceMetric } from '../../../src/schema/types.js'; @@ -14,6 +15,8 @@ describe('VectorQuery', () => { }); expect(query).toBeInstanceOf(VectorQuery); + expect(query).toBeInstanceOf(BaseVectorQuery); + expect(query).toBeInstanceOf(BaseQuery); expect(query.numResults).toBe(10); expect(query.vectorField).toBe('embedding'); expect(query.returnFields).toEqual(['title', 'score']); @@ -176,6 +179,16 @@ describe('VectorQuery', () => { expect(query.offset).toBe(20); expect(query.limit).toBe(10); }); + + it('should support chainable paging updates', () => { + const query = new VectorQuery({ + vector: [0.1, 0.2, 0.3], + vectorField: 'embedding', + }).paging(30, 15); + + expect(query.offset).toBe(30); + expect(query.limit).toBe(15); + }); }); describe('returnFields', () => { @@ -197,6 +210,30 @@ describe('VectorQuery', () => { expect(query.returnFields).toBeUndefined(); }); + + it('should support chainable return field updates with skip-decode fields', () => { + const query = new VectorQuery({ + vector: [0.1, 0.2, 0.3], + vectorField: 'embedding', + }).setReturnFields(['title', 'embedding'], { skipDecode: 'embedding' }); + + expect(query.returnFields).toEqual(['title', 'embedding']); + expect(query.skipDecodeFields).toEqual(['embedding']); + }); + }); + + describe('filter updates', () => { + it('should support chainable filter updates', () => { + const query = new VectorQuery({ + vector: [0.1, 0.2, 0.3], + vectorField: 'embedding', + }).setFilter('@category:{books}'); + + expect(query.filter).toBe('@category:{books}'); + expect(query.buildQuery()).toBe( + '(@category:{books})=>[KNN 10 @embedding $vector AS vector_distance]' + ); + }); }); describe('Distance Normalization', () => {