Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/indexes/search-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,18 @@ export class SearchIndex {
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) {
Expand Down
270 changes: 263 additions & 7 deletions src/query/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
*/

import type { FilterExpression } from './filter.js';
import { QueryValidationError, SchemaValidationError } from '../errors.js';
import { normalizeVectorDataType } from '../redis/utils.js';
import { VectorDataType } from '../schema/types.js';

/**
* A filter clause supplied to a query — either a pre-built {@link FilterExpression}
Expand All @@ -21,29 +24,282 @@ export function renderFilter(filter: FilterInput | undefined): string {
}

/**
* Base interface for all query types
* Sort direction for Redis search results.
*/
export interface BaseQuery {
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[];

/** 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.
*/
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;

/** 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);
}
}

/** Filter expression used by the query. */
get filter(): FilterInput | undefined {
return this._filter;
}

/** 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;
}

/** Offset for pagination. */
get offset(): number | undefined {
return this._offset;
}

/** Number of results to return. */
get limit(): number | undefined {
return this._limit;
}

/** Sort fields collected for query execution. */
get sortFields(): SortField[] {
return this._sortFields.map((field) => ({ ...field }));
}

/** 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 {
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, direction });
return this;
}

/** Build the Redis query string. */
abstract buildQuery(): string;

/** Build the query parameters for Redis */
buildParams(): Record<string, unknown>;
buildParams(): Record<string, unknown> {
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');
}

if (!config.vectorField || config.vectorField.trim() === '') {
throw new QueryValidationError('vectorField is required');
}

this._vector = [...config.vector];
this._vectorField = config.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];
}

/** 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) => {
validateNonEmptyString(value, label);
return value;
});
}

function validateNonEmptyString(value: string, label: string): void {
if (typeof value !== 'string' || value.trim() === '') {
throw new QueryValidationError(`${label} cannot be empty`);
}
}

function validateOffset(offset: number): void {
if (!Number.isInteger(offset) || offset < 0) {
throw new QueryValidationError('offset must be a non-negative integer');
}
}

function validateLimit(limit: number): void {
if (!Number.isInteger(limit) || limit <= 0) {
throw new QueryValidationError('limit must be a positive integer');
}
}

/**
Expand Down
21 changes: 11 additions & 10 deletions src/query/count.ts
Original file line number Diff line number Diff line change
@@ -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}.
Expand All @@ -22,21 +22,22 @@ 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 offset(): number {
return 0;
}

get limit(): number {
return 0;
}

buildParams(): Record<string, unknown> {
return {};
buildQuery(): string {
return renderFilter(this.filter);
}
}
25 changes: 10 additions & 15 deletions src/query/filter-query.ts
Original file line number Diff line number Diff line change
@@ -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}.
Expand Down Expand Up @@ -35,26 +35,21 @@ 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);
}

buildParams(): Record<string, unknown> {
return {};
}
}
Loading
Loading