From ed37f6c32e04abd015e21269c9f3985c5bd7cd07 Mon Sep 17 00:00:00 2001 From: Andrew Gingrich Date: Fri, 15 May 2026 15:13:04 -0600 Subject: [PATCH 1/7] feat(query): add AggregationQuery for FT.AGGREGATE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last major gap on the query surface: a general-purpose AggregationQuery + SearchIndex.aggregate() pair that wraps FT.AGGREGATE. Mirrors Python redisvl's AggregationQuery (base class only — the hybrid text+vector subclass stays out of scope since HybridQuery already covers FT.HYBRID). API shape: - Fluent builder: groupBy / apply / sortBy / limit / filter / load / params / dialect / timeout / verbatim / addScores. Steps render in the order they're called, mirroring the FT.AGGREGATE pipeline. - Reducers namespace: count, countDistinct, countDistinctish, sum, min, max, avg, stddev, quantile, toList, firstValue, randomSample — each takes an optional `as` alias. - Constructor accepts FilterInput (string | FilterExpression) for the pre-aggregation filter, matching the rest of the DSL. - Bare field names are auto-prefixed with `@`; explicit `@`/`$.` refs pass through unchanged. Result shape returned by index.aggregate() is { total, results } where each row is Record. Numeric casting is left to the caller — Redis hands back string values over the wire. Tests: 25 unit tests covering option shape across every step kind + validation edge cases, plus 5 integration tests against a real Redis exercising GROUPBY/REDUCE, pre-aggregation filtering, APPLY+SORTBY+LIMIT, post-aggregation FILTER, and PARAMS binding. Docs: new website/docs/user-guide/aggregation.md walkthrough. Closes #15 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/indexes/search-index.ts | 48 +++ src/query/aggregation.ts | 451 +++++++++++++++++++++++++ src/query/index.ts | 1 + tests/integration/aggregation.test.ts | 132 ++++++++ tests/unit/query/aggregation.test.ts | 222 ++++++++++++ website/docs/user-guide/aggregation.md | 167 +++++++++ website/sidebars.ts | 1 + 7 files changed, 1022 insertions(+) create mode 100644 src/query/aggregation.ts create mode 100644 tests/integration/aggregation.test.ts create mode 100644 tests/unit/query/aggregation.test.ts create mode 100644 website/docs/user-guide/aggregation.md diff --git a/src/indexes/search-index.ts b/src/indexes/search-index.ts index c10a883..81d255c 100644 --- a/src/indexes/search-index.ts +++ b/src/indexes/search-index.ts @@ -12,6 +12,7 @@ import { RedisVLError, SchemaValidationError } from '../errors.js'; import type { BaseQuery, SearchResult, SearchDocument, QueryOptions } from '../query/base.js'; import { VectorQuery } from '../query/vector.js'; import { VectorRangeQuery } from '../query/range.js'; +import type { AggregationQuery } from '../query/aggregation.js'; import { DISTANCE_NORMALIZERS } from '../utils/distance.js'; import type { VectorFieldAttrs } from '../schema/fields.js'; @@ -655,4 +656,51 @@ export class SearchIndex { ); } } + + /** + * Execute an {@link AggregationQuery} (`FT.AGGREGATE`) against this index. + * + * Returns the raw aggregate result: a `total` row count and a list of + * rows, where each row is a `Record` of field name to + * value. GROUPBY/REDUCE/APPLY aliases appear as keys on each row. + * + * @example + * ```typescript + * import { AggregationQuery, Reducers } from 'redisvl'; + * + * const q = new AggregationQuery('@category:{electronics}') + * .groupBy('@brand', Reducers.sum('price', 'revenue')) + * .sortBy([{ field: 'revenue', direction: 'DESC' }]) + * .limit(0, 5); + * + * const { total, results } = await index.aggregate(q); + * for (const row of results) console.log(row.brand, row.revenue); + * ``` + */ + async aggregate( + query: AggregationQuery + ): Promise<{ total: number; results: Array> }> { + try { + const { query: queryString, options } = query.toCommand(); + const reply = await this.client.ft.aggregate(this.name, queryString, options); + + // node-redis returns each row as a MapReply that, on RESP2, decodes + // into a plain object. Normalize to Record so the + // public shape doesn't leak the client's internal types. + const results: Array> = reply.results.map((row) => { + const out: Record = {}; + for (const [k, v] of Object.entries(row as Record)) { + out[k] = typeof v === 'string' ? v : String(v); + } + return out; + }); + + return { total: reply.total, results }; + } catch (error) { + throw new RedisVLError( + `Failed to execute aggregate query: ${error instanceof Error ? error.message : String(error)}`, + { cause: error instanceof Error ? error : undefined } + ); + } + } } diff --git a/src/query/aggregation.ts b/src/query/aggregation.ts new file mode 100644 index 0000000..b46e736 --- /dev/null +++ b/src/query/aggregation.ts @@ -0,0 +1,451 @@ +/** + * General-purpose aggregation query that builds an `FT.AGGREGATE` call. + * + * Mirrors Python redisvl's `AggregationQuery` — a thin, fluent builder over + * the FT.AGGREGATE pipeline (GROUPBY → REDUCE → APPLY → SORTBY → LIMIT → + * FILTER). The hybrid (text + vector) aggregation variant is intentionally + * out of scope here; that surface is already covered by {@link HybridQuery} + * via `FT.HYBRID`. + * + * @see https://redis.io/docs/latest/commands/ft.aggregate/ + */ + +import type { FtAggregateOptions } from '@redis/search/dist/lib/commands/AGGREGATE.js'; +import { QueryValidationError } from '../errors.js'; +import { renderFilter, type FilterInput } from './base.js'; + +/** Output of {@link AggregationQuery.toCommand}. */ +export interface AggregateCommand { + /** The query string (filter) — the second argument to `ft.aggregate`. */ + query: string; + /** Structured options passed to `client.ft.aggregate(indexName, query, options)`. */ + options: FtAggregateOptions; +} + +/** + * Sort specification entry for {@link AggregationQuery.sortBy}. + * + * Bare strings are treated as ascending sort on that field. To override + * direction use the object form. + */ +export type SortSpec = string | { field: string; direction?: 'ASC' | 'DESC' }; + +/** + * Field reference used with {@link AggregationQuery.load}. + * + * Strings are passed through (`@field` / `$.path` conventions apply); the + * object form supports `AS` aliasing. + */ +export type LoadField = string | { identifier: string; as?: string }; + +/** Concrete REDUCE clause that {@link AggregationQuery.groupBy} accepts. */ +export type Reducer = + | { type: 'COUNT'; as?: string } + | { type: 'COUNT_DISTINCT'; property: string; as?: string } + | { type: 'COUNT_DISTINCTISH'; property: string; as?: string } + | { type: 'SUM'; property: string; as?: string } + | { type: 'MIN'; property: string; as?: string } + | { type: 'MAX'; property: string; as?: string } + | { type: 'AVG'; property: string; as?: string } + | { type: 'STDDEV'; property: string; as?: string } + | { type: 'QUANTILE'; property: string; quantile: number; as?: string } + | { type: 'TOLIST'; property: string; as?: string } + | { + type: 'FIRST_VALUE'; + property: string; + by?: string | { property: string; direction?: 'ASC' | 'DESC' }; + as?: string; + } + | { type: 'RANDOM_SAMPLE'; property: string; sampleSize: number; as?: string }; + +/** + * Factory namespace mirroring Python `redis.commands.search.reducers`. + * + * Each factory returns a plain {@link Reducer} object you can pass to + * {@link AggregationQuery.groupBy}. Use the optional second `as` argument to + * give the reducer's output column a name. + * + * @example + * ```typescript + * import { AggregationQuery, Reducers } from 'redisvl'; + * + * const q = new AggregationQuery('@category:{electronics}') + * .groupBy('@brand', [ + * Reducers.count('total'), + * Reducers.avg('price', 'avg_price'), + * ]); + * ``` + */ +export const Reducers = { + count(as?: string): Reducer { + return { type: 'COUNT', as }; + }, + countDistinct(property: string, as?: string): Reducer { + return { type: 'COUNT_DISTINCT', property, as }; + }, + countDistinctish(property: string, as?: string): Reducer { + return { type: 'COUNT_DISTINCTISH', property, as }; + }, + sum(property: string, as?: string): Reducer { + return { type: 'SUM', property, as }; + }, + min(property: string, as?: string): Reducer { + return { type: 'MIN', property, as }; + }, + max(property: string, as?: string): Reducer { + return { type: 'MAX', property, as }; + }, + avg(property: string, as?: string): Reducer { + return { type: 'AVG', property, as }; + }, + stddev(property: string, as?: string): Reducer { + return { type: 'STDDEV', property, as }; + }, + quantile(property: string, quantile: number, as?: string): Reducer { + if (!Number.isFinite(quantile) || quantile < 0 || quantile > 1) { + throw new QueryValidationError('quantile must be in [0, 1]'); + } + return { type: 'QUANTILE', property, quantile, as }; + }, + toList(property: string, as?: string): Reducer { + return { type: 'TOLIST', property, as }; + }, + firstValue( + property: string, + by?: string | { property: string; direction?: 'ASC' | 'DESC' }, + as?: string + ): Reducer { + return { type: 'FIRST_VALUE', property, by, as }; + }, + randomSample(property: string, sampleSize: number, as?: string): Reducer { + if (!Number.isInteger(sampleSize) || sampleSize <= 0) { + throw new QueryValidationError('randomSample sampleSize must be a positive integer'); + } + return { type: 'RANDOM_SAMPLE', property, sampleSize, as }; + }, +} as const; + +interface GroupByStep { + kind: 'GROUPBY'; + properties: string[]; + reducers: Reducer[]; +} +interface ApplyStep { + kind: 'APPLY'; + expression: string; + as: string; +} +interface SortByStep { + kind: 'SORTBY'; + by: SortSpec[]; + max?: number; +} +interface LimitStep { + kind: 'LIMIT'; + offset: number; + count: number; +} +interface FilterStep { + kind: 'FILTER'; + expression: string; +} +type Step = GroupByStep | ApplyStep | SortByStep | LimitStep | FilterStep; + +function prefixFieldRef(name: string): string { + return name.startsWith('@') || name.startsWith('$') ? name : `@${name}`; +} + +function assertNonEmpty(value: string | undefined, label: string): void { + if (value === undefined || value.trim() === '') { + throw new QueryValidationError(`${label} cannot be empty`); + } +} + +function assertNonNegativeInteger(value: number | undefined, label: string): void { + if (value !== undefined && (!Number.isInteger(value) || value < 0)) { + throw new QueryValidationError(`${label} must be a non-negative integer`); + } +} + +function assertPositiveInteger(value: number | undefined, label: string): void { + if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { + throw new QueryValidationError(`${label} must be a positive integer`); + } +} + +/** + * Fluent builder for `FT.AGGREGATE`. + * + * Steps are recorded in the order you call them — `.groupBy()` then + * `.apply()` is *not* the same as `.apply()` then `.groupBy()`, because each + * stage operates on the output of the previous one. This mirrors how + * Redis itself pipelines an aggregate request. + * + * @example + * ```typescript + * import { AggregationQuery, Reducers, Tag } from 'redisvl'; + * + * const q = new AggregationQuery(Tag('category').eq('electronics')) + * .groupBy('@brand', [Reducers.sum('price', 'revenue'), Reducers.count('orders')]) + * .apply('@revenue / @orders', 'avg_order_value') + * .sortBy([{ field: 'revenue', direction: 'DESC' }]) + * .limit(0, 10); + * + * const { total, results } = await index.aggregate(q); + * ``` + */ +export class AggregationQuery { + private readonly _query: string; + private readonly steps: Step[] = []; + private _load?: LoadField[]; + private _params?: Record; + private _dialect?: number; + private _timeout?: number; + private _verbatim = false; + private _addScores = false; + + /** + * @param query Either a {@link FilterExpression}, a raw filter string, or + * omitted/`'*'` for no filtering. The same {@link FilterInput} contract + * the rest of the query DSL uses. + */ + constructor(query?: FilterInput) { + this._query = renderFilter(query); + } + + /** GROUPBY with one or more reducers. Properties are auto-prefixed with `@`. */ + groupBy(properties: string | string[], reducers: Reducer | Reducer[] = []): this { + const props = Array.isArray(properties) ? properties : [properties]; + if (props.length === 0) { + throw new QueryValidationError('groupBy requires at least one property'); + } + for (const p of props) assertNonEmpty(p, 'groupBy property'); + const reducerList = Array.isArray(reducers) ? reducers : [reducers]; + this.steps.push({ + kind: 'GROUPBY', + properties: props.map(prefixFieldRef), + reducers: reducerList, + }); + return this; + } + + /** APPLY expression AS alias. Both arguments are required. */ + apply(expression: string, as: string): this { + assertNonEmpty(expression, 'apply expression'); + assertNonEmpty(as, 'apply alias'); + this.steps.push({ kind: 'APPLY', expression, as }); + return this; + } + + /** SORTBY one or more fields. Bare strings sort ASC. */ + sortBy(by: SortSpec | SortSpec[], max?: number): this { + const list = Array.isArray(by) ? by : [by]; + if (list.length === 0) { + throw new QueryValidationError('sortBy requires at least one field'); + } + for (const entry of list) { + const field = typeof entry === 'string' ? entry : entry.field; + assertNonEmpty(field, 'sortBy field'); + if (typeof entry !== 'string' && entry.direction !== undefined) { + if (entry.direction !== 'ASC' && entry.direction !== 'DESC') { + throw new QueryValidationError('sortBy direction must be ASC or DESC'); + } + } + } + assertPositiveInteger(max, 'sortBy max'); + this.steps.push({ kind: 'SORTBY', by: list, max }); + return this; + } + + /** LIMIT offset, count. */ + limit(offset: number, count: number): this { + assertNonNegativeInteger(offset, 'limit offset'); + assertPositiveInteger(count, 'limit count'); + this.steps.push({ kind: 'LIMIT', offset, count }); + return this; + } + + /** + * FILTER applied at this point in the pipeline, using the FT.AGGREGATE + * expression dialect (e.g. `'@revenue > 1000'`). Distinct from the + * constructor's query string, which uses the FT.SEARCH filter dialect. + */ + filter(expression: string): this { + assertNonEmpty(expression, 'filter expression'); + this.steps.push({ kind: 'FILTER', expression }); + return this; + } + + /** + * LOAD specific fields from the source documents. + * + * Each entry can be a bare field name (auto-prefixed with `@`) or an + * `{ identifier, as }` pair for aliasing. + */ + load(fields: LoadField | LoadField[]): this { + const list = Array.isArray(fields) ? fields : [fields]; + for (const f of list) { + if (typeof f === 'string') { + assertNonEmpty(f, 'load field'); + } else { + assertNonEmpty(f.identifier, 'load identifier'); + if (f.as !== undefined) assertNonEmpty(f.as, 'load alias'); + } + } + this._load = [...(this._load ?? []), ...list]; + return this; + } + + /** Bind PARAMS for `$param` references in the query string. */ + params(params: Record): this { + this._params = { ...(this._params ?? {}), ...params }; + return this; + } + + /** Set the DIALECT. Defaults to the server's current default when omitted. */ + dialect(dialect: number): this { + if (!Number.isInteger(dialect) || dialect <= 0) { + throw new QueryValidationError('dialect must be a positive integer'); + } + this._dialect = dialect; + return this; + } + + /** Server-side query TIMEOUT in milliseconds. */ + timeout(ms: number): this { + assertPositiveInteger(ms, 'timeout'); + this._timeout = ms; + return this; + } + + /** Disable stemming for the query string (VERBATIM). */ + verbatim(): this { + this._verbatim = true; + return this; + } + + /** Include the document's `@__score` in the output (ADDSCORES). */ + addScores(): this { + this._addScores = true; + return this; + } + + /** The rendered query string this aggregation will use. */ + get query(): string { + return this._query; + } + + /** Build the structured options for `client.ft.aggregate(indexName, query, options)`. */ + toCommand(): AggregateCommand { + const options: FtAggregateOptions = {}; + + if (this._verbatim) options.VERBATIM = true; + if (this._addScores) options.ADDSCORES = true; + if (this._timeout !== undefined) options.TIMEOUT = this._timeout; + if (this._dialect !== undefined) options.DIALECT = this._dialect; + if (this._params !== undefined) options.PARAMS = this._params; + + if (this._load && this._load.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) => + typeof f === 'string' + ? prefixFieldRef(f) + : f.as !== undefined + ? { identifier: prefixFieldRef(f.identifier), AS: f.as } + : prefixFieldRef(f.identifier) + ) as FtAggregateOptions['LOAD']; + } + + if (this.steps.length > 0) { + options.STEPS = this.steps.map((step) => + this.renderStep(step) + ) as FtAggregateOptions['STEPS']; + } + + return { query: this._query, options }; + } + + private renderStep(step: Step): unknown { + switch (step.kind) { + case 'GROUPBY': + return { + type: 'GROUPBY', + properties: step.properties, + REDUCE: step.reducers.map((r) => this.renderReducer(r)), + }; + case 'APPLY': + return { type: 'APPLY', expression: step.expression, AS: step.as }; + case 'SORTBY': { + const out: { type: 'SORTBY'; BY: unknown; MAX?: number } = { + type: 'SORTBY', + BY: step.by.map((entry) => { + const field = typeof entry === 'string' ? entry : entry.field; + const ref = prefixFieldRef(field); + const direction = typeof entry === 'string' ? undefined : entry.direction; + return direction ? { BY: ref, DIRECTION: direction } : ref; + }), + }; + if (step.max !== undefined) out.MAX = step.max; + return out; + } + case 'LIMIT': + return { type: 'LIMIT', from: step.offset, size: step.count }; + case 'FILTER': + return { type: 'FILTER', expression: step.expression }; + } + } + + private renderReducer(r: Reducer): unknown { + const base: { type: string; AS?: string } = { type: r.type }; + if (r.as !== undefined) base.AS = r.as; + switch (r.type) { + case 'COUNT': + return base; + case 'COUNT_DISTINCT': + case 'COUNT_DISTINCTISH': + case 'SUM': + case 'MIN': + case 'MAX': + case 'AVG': + case 'STDDEV': + case 'TOLIST': + return { ...base, property: prefixFieldRef(r.property) }; + case 'QUANTILE': + return { + ...base, + property: prefixFieldRef(r.property), + quantile: r.quantile, + }; + case 'FIRST_VALUE': + return { + ...base, + property: prefixFieldRef(r.property), + ...(r.by !== undefined + ? { + BY: + typeof r.by === 'string' + ? prefixFieldRef(r.by) + : { + property: prefixFieldRef(r.by.property), + ...(r.by.direction + ? { direction: r.by.direction } + : {}), + }, + } + : {}), + }; + case 'RANDOM_SAMPLE': + return { + ...base, + property: prefixFieldRef(r.property), + sampleSize: r.sampleSize, + }; + } + } +} + +// Used in JSDoc @example blocks above. +export type { FilterInput }; diff --git a/src/query/index.ts b/src/query/index.ts index 8ba8c64..7ee8020 100644 --- a/src/query/index.ts +++ b/src/query/index.ts @@ -10,3 +10,4 @@ export * from './filter-query.js'; export * from './count.js'; export * from './range.js'; export * from './text.js'; +export * from './aggregation.js'; diff --git a/tests/integration/aggregation.test.ts b/tests/integration/aggregation.test.ts new file mode 100644 index 0000000..b16c1a9 --- /dev/null +++ b/tests/integration/aggregation.test.ts @@ -0,0 +1,132 @@ +/** + * Integration coverage for AggregationQuery / SearchIndex.aggregate. + * + * Seeds a small product fixture so GROUPBY / SUM / AVG / APPLY / SORTBY / + * FILTER all produce predictable values. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createClient, type RedisClientType } from 'redis'; +import { IndexSchema } from '../../src/schema/schema.js'; +import { SearchIndex } from '../../src/indexes/search-index.js'; +import { AggregationQuery, Reducers } from '../../src/query/aggregation.js'; +import { Tag } from '../../src/query/filter.js'; + +interface Product extends Record { + id: string; + brand: string; + category: string; + price: number; + quantity: number; +} + +describe('AggregationQuery integration', () => { + let client: RedisClientType; + let index: SearchIndex; + + beforeAll(async () => { + client = createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379', + }); + await client.connect(); + + const schema = IndexSchema.fromObject({ + index: { + name: 'redisvl-test-aggregate', + prefix: 'rvl-test-agg', + storageType: 'hash', + }, + fields: [ + { name: 'brand', type: 'tag' }, + { name: 'category', type: 'tag' }, + { name: 'price', type: 'numeric' }, + { name: 'quantity', type: 'numeric' }, + ], + }); + + index = new SearchIndex(schema, client); + await index.create({ overwrite: true, drop: true }); + + const products: Product[] = [ + { id: '1', brand: 'acme', category: 'electronics', price: 1200, quantity: 2 }, + { id: '2', brand: 'acme', category: 'electronics', price: 25, quantity: 10 }, + { id: '3', brand: 'omega', category: 'electronics', price: 150, quantity: 4 }, + { id: '4', brand: 'ergo', category: 'furniture', price: 300, quantity: 3 }, + { id: '5', brand: 'ergo', category: 'furniture', price: 500, quantity: 1 }, + { id: '6', brand: 'acme', category: 'electronics', price: 400, quantity: 5 }, + ]; + + await index.load(products, { idField: 'id' }); + await new Promise((r) => setTimeout(r, 100)); + }); + + afterAll(async () => { + await index?.delete({ drop: true }).catch(() => {}); + await client?.quit(); + }); + + it('groups by brand with COUNT and SUM reducers', async () => { + const q = new AggregationQuery() + .groupBy('brand', [Reducers.count('total'), Reducers.sum('price', 'revenue')]) + .sortBy([{ field: 'brand', direction: 'ASC' }]); + + const { total, results } = await index.aggregate(q); + + expect(total).toBeGreaterThan(0); + const byBrand = Object.fromEntries(results.map((r) => [r.brand, r])); + expect(byBrand['acme']).toMatchObject({ total: '3', revenue: '1625' }); + expect(byBrand['omega']).toMatchObject({ total: '1', revenue: '150' }); + expect(byBrand['ergo']).toMatchObject({ total: '2', revenue: '800' }); + }); + + it('honors the constructor query string as a pre-aggregation filter', async () => { + const q = new AggregationQuery(Tag('category').eq('electronics')).groupBy( + 'brand', + Reducers.count('total') + ); + + const { results } = await index.aggregate(q); + const brands = new Set(results.map((r) => r.brand)); + expect(brands.has('acme')).toBe(true); + expect(brands.has('omega')).toBe(true); + expect(brands.has('ergo')).toBe(false); + }); + + it('supports APPLY to derive a field, plus SORTBY + LIMIT', async () => { + const q = new AggregationQuery() + .groupBy('brand', [Reducers.sum('price', 'revenue'), Reducers.sum('quantity', 'units')]) + .apply('@revenue / @units', 'avg_unit_price') + .sortBy([{ field: 'avg_unit_price', direction: 'DESC' }]) + .limit(0, 1); + + const { results } = await index.aggregate(q); + expect(results).toHaveLength(1); + // acme: revenue=1625 / units=17 ≈ 95.6 — highest avg unit price. + expect(results[0].brand).toBe('acme'); + expect(Number(results[0].avg_unit_price)).toBeGreaterThan(0); + }); + + it('applies post-aggregation FILTER (FT.AGGREGATE expression dialect)', async () => { + const q = new AggregationQuery() + .groupBy('brand', Reducers.sum('price', 'revenue')) + .filter('@revenue > 200'); + + const { results } = await index.aggregate(q); + const brands = new Set(results.map((r) => r.brand)); + // acme (1625) and ergo (800) pass; omega (150) is filtered out. + expect(brands.has('acme')).toBe(true); + expect(brands.has('ergo')).toBe(true); + expect(brands.has('omega')).toBe(false); + }); + + it('binds PARAMS for parameterized filter strings', async () => { + const q = new AggregationQuery('@brand:{$brandName}') + .params({ brandName: 'omega' }) + .dialect(2) + .groupBy('brand', Reducers.count('total')); + + const { results } = await index.aggregate(q); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ brand: 'omega', total: '1' }); + }); +}); diff --git a/tests/unit/query/aggregation.test.ts b/tests/unit/query/aggregation.test.ts new file mode 100644 index 0000000..fe7c1d3 --- /dev/null +++ b/tests/unit/query/aggregation.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect } from 'vitest'; +import { AggregationQuery, Reducers } from '../../../src/query/aggregation.js'; +import { Tag, Num } from '../../../src/query/filter.js'; +import { QueryValidationError } from '../../../src/errors.js'; + +describe('AggregationQuery', () => { + describe('query string', () => { + it('defaults to wildcard when no filter is supplied', () => { + const q = new AggregationQuery(); + expect(q.toCommand().query).toBe('*'); + }); + + it('accepts a string filter', () => { + const q = new AggregationQuery('@brand:{nike}'); + expect(q.toCommand().query).toBe('@brand:{nike}'); + }); + + it('accepts a FilterExpression', () => { + const q = new AggregationQuery(Tag('brand').eq('nike')); + expect(q.toCommand().query).toBe('@brand:{nike}'); + }); + }); + + describe('groupBy + reducers', () => { + it('renders a single-property GROUPBY with a COUNT reducer', () => { + const q = new AggregationQuery().groupBy('brand', Reducers.count('total')); + const { options } = q.toCommand(); + expect(options.STEPS).toEqual([ + { + type: 'GROUPBY', + properties: ['@brand'], + REDUCE: [{ type: 'COUNT', AS: 'total' }], + }, + ]); + }); + + it('accepts multiple properties and reducers as arrays', () => { + const q = new AggregationQuery().groupBy( + ['brand', 'category'], + [Reducers.sum('price', 'revenue'), Reducers.avg('price', 'avg_price')] + ); + const { options } = q.toCommand(); + expect(options.STEPS).toEqual([ + { + type: 'GROUPBY', + properties: ['@brand', '@category'], + REDUCE: [ + { type: 'SUM', AS: 'revenue', property: '@price' }, + { type: 'AVG', AS: 'avg_price', property: '@price' }, + ], + }, + ]); + }); + + it('preserves explicit @ and $ prefixes on properties', () => { + const q = new AggregationQuery().groupBy(['@brand', '$.category'], []); + const { options } = q.toCommand(); + expect((options.STEPS![0] as { properties: string[] }).properties).toEqual([ + '@brand', + '$.category', + ]); + }); + + it('renders QUANTILE with its quantile arg', () => { + const q = new AggregationQuery().groupBy( + 'brand', + Reducers.quantile('price', 0.95, 'p95') + ); + const reducer = ( + q.toCommand().options.STEPS![0] as unknown as { + REDUCE: Array>; + } + ).REDUCE[0]; + expect(reducer).toEqual({ + type: 'QUANTILE', + AS: 'p95', + property: '@price', + quantile: 0.95, + }); + }); + + it('renders FIRST_VALUE with BY direction', () => { + const q = new AggregationQuery().groupBy( + 'brand', + Reducers.firstValue('name', { property: 'price', direction: 'DESC' }, 'top') + ); + const reducer = ( + q.toCommand().options.STEPS![0] as unknown as { + REDUCE: Array>; + } + ).REDUCE[0]; + expect(reducer).toMatchObject({ + type: 'FIRST_VALUE', + AS: 'top', + property: '@name', + BY: { property: '@price', direction: 'DESC' }, + }); + }); + + it('rejects an empty property list', () => { + expect(() => new AggregationQuery().groupBy([])).toThrow(QueryValidationError); + }); + + it('rejects QUANTILE outside [0, 1]', () => { + expect(() => Reducers.quantile('price', 1.5)).toThrow(QueryValidationError); + }); + }); + + describe('apply / sortBy / limit / filter', () => { + it('renders APPLY with expression and alias', () => { + const q = new AggregationQuery().apply('@price * @quantity', 'total'); + expect(q.toCommand().options.STEPS).toEqual([ + { type: 'APPLY', expression: '@price * @quantity', AS: 'total' }, + ]); + }); + + it('renders bare-string SORTBY as ascending field reference', () => { + const q = new AggregationQuery().sortBy('revenue'); + expect(q.toCommand().options.STEPS).toEqual([{ type: 'SORTBY', BY: ['@revenue'] }]); + }); + + it('renders directional SORTBY entries', () => { + const q = new AggregationQuery().sortBy( + [ + { field: 'revenue', direction: 'DESC' }, + { field: 'brand', direction: 'ASC' }, + ], + 5 + ); + expect(q.toCommand().options.STEPS).toEqual([ + { + type: 'SORTBY', + BY: [ + { BY: '@revenue', DIRECTION: 'DESC' }, + { BY: '@brand', DIRECTION: 'ASC' }, + ], + MAX: 5, + }, + ]); + }); + + it('renders LIMIT', () => { + const q = new AggregationQuery().limit(20, 10); + expect(q.toCommand().options.STEPS).toEqual([{ type: 'LIMIT', from: 20, size: 10 }]); + }); + + it('renders post-aggregation FILTER (FT.AGGREGATE expression dialect)', () => { + const q = new AggregationQuery().filter('@revenue > 1000'); + expect(q.toCommand().options.STEPS).toEqual([ + { type: 'FILTER', expression: '@revenue > 1000' }, + ]); + }); + + it('rejects negative LIMIT offset', () => { + expect(() => new AggregationQuery().limit(-1, 10)).toThrow(QueryValidationError); + }); + + it('rejects zero LIMIT count', () => { + expect(() => new AggregationQuery().limit(0, 0)).toThrow(QueryValidationError); + }); + + it('rejects non-ASC/DESC sort directions', () => { + expect(() => + new AggregationQuery().sortBy([{ field: 'x', direction: 'BOGUS' as 'ASC' }]) + ).toThrow(QueryValidationError); + }); + }); + + describe('step ordering', () => { + it('preserves the order in which builder methods were called', () => { + const q = new AggregationQuery() + .groupBy('brand', Reducers.sum('price', 'revenue')) + .apply('@revenue / 100', 'revenue_hundreds') + .filter('@revenue > 0') + .sortBy([{ field: 'revenue', direction: 'DESC' }]) + .limit(0, 5); + const kinds = q.toCommand().options.STEPS!.map((s) => (s as { type: string }).type); + expect(kinds).toEqual(['GROUPBY', 'APPLY', 'FILTER', 'SORTBY', 'LIMIT']); + }); + }); + + describe('LOAD', () => { + it('renders bare LOAD field with @ prefix', () => { + const q = new AggregationQuery().load('title'); + expect(q.toCommand().options.LOAD).toEqual(['@title']); + }); + + it('renders LOAD with identifier + AS', () => { + const q = new AggregationQuery().load({ identifier: 'title', as: 't' }); + expect(q.toCommand().options.LOAD).toEqual([{ identifier: '@title', AS: 't' }]); + }); + + it('appends across multiple load() calls', () => { + const q = new AggregationQuery().load('a').load(['b', 'c']); + expect(q.toCommand().options.LOAD).toEqual(['@a', '@b', '@c']); + }); + }); + + describe('top-level options', () => { + it('threads PARAMS through and lets the query reference $params', () => { + const q = new AggregationQuery(Num('price').gt(0).and(Tag('brand').eq('nike'))) + .params({ minRev: 1000 }) + .filter('@revenue > $minRev'); + const { options } = q.toCommand(); + expect(options.PARAMS).toEqual({ minRev: 1000 }); + }); + + it('threads DIALECT and TIMEOUT and verbatim/addScores flags', () => { + const q = new AggregationQuery().dialect(2).timeout(500).verbatim().addScores(); + const { options } = q.toCommand(); + expect(options.DIALECT).toBe(2); + expect(options.TIMEOUT).toBe(500); + expect(options.VERBATIM).toBe(true); + expect(options.ADDSCORES).toBe(true); + }); + + it('omits unset options', () => { + const { options } = new AggregationQuery().toCommand(); + expect(options).toEqual({}); + }); + }); +}); diff --git a/website/docs/user-guide/aggregation.md b/website/docs/user-guide/aggregation.md new file mode 100644 index 0000000..f9d13c4 --- /dev/null +++ b/website/docs/user-guide/aggregation.md @@ -0,0 +1,167 @@ +--- +sidebar_position: 6 +--- + +# Aggregation queries + +`AggregationQuery` builds an [`FT.AGGREGATE`](https://redis.io/docs/latest/commands/ft.aggregate/) call against an index. Unlike `FT.SEARCH` (which retrieves documents), `FT.AGGREGATE` runs a pipeline — group rows, reduce them, derive new fields, sort, page — and returns computed rows rather than source documents. + +If you've used Python `redisvl`, this mirrors `AggregationQuery` over `AggregateRequest`. The hybrid (text + vector) flavour lives in a separate class, [`HybridQuery`](./hybrid-search.md). + +## When to use it + +Reach for `AggregationQuery` when you want to answer questions _about_ groups of documents rather than fetch the documents themselves: + +- "How many products per brand?" +- "Average price by category for items in stock?" +- "Top 10 brands by revenue this month?" +- "p95 latency per region?" + +## The pipeline + +A FT.AGGREGATE call is a chain of steps applied in order: + +1. **Query string** — selects which documents enter the pipeline (FT.SEARCH filter dialect). +2. **GROUPBY + REDUCE** — collapses rows into groups, applying reducers like `COUNT`, `SUM`, `AVG`. +3. **APPLY** — derives new fields from existing ones (`@revenue / @orders AS avg`). +4. **FILTER** — drops rows that don't satisfy an expression (FT.AGGREGATE expression dialect — `@revenue > 1000`). +5. **SORTBY** — orders rows. +6. **LIMIT** — paginates. + +`AggregationQuery` records the steps in the order you call the corresponding methods, so `.groupBy().apply()` is not the same as `.apply().groupBy()`. + +## A first query + +```typescript +import { + SearchIndex, + IndexSchema, + AggregationQuery, + Reducers, +} from 'redisvl'; +import { createClient } from 'redis'; + +const client = createClient(); +await client.connect(); + +const schema = IndexSchema.fromObject({ + index: { name: 'orders', prefix: 'order', storageType: 'hash' }, + fields: [ + { name: 'brand', type: 'tag' }, + { name: 'category', type: 'tag' }, + { name: 'price', type: 'numeric' }, + { name: 'quantity', type: 'numeric' }, + ], +}); +const index = new SearchIndex(schema, client); + +const q = new AggregationQuery() + .groupBy('brand', [ + Reducers.count('orders'), + Reducers.sum('price', 'revenue'), + ]) + .sortBy([{ field: 'revenue', direction: 'DESC' }]) + .limit(0, 5); + +const { total, results } = await index.aggregate(q); +for (const row of results) { + console.log(row.brand, row.orders, row.revenue); +} +``` + +Each row is a `Record` keyed by the reducer/apply alias (or the GROUPBY field). All values come back as strings — cast to numbers in user code if you need them. + +## Filtering rows into the pipeline + +The constructor takes the same `FilterInput` the rest of the query DSL uses — either a raw string or a `FilterExpression`: + +```typescript +import { AggregationQuery, Reducers, Tag, Num } from 'redisvl'; + +const q = new AggregationQuery( + Tag('category').eq('electronics').and(Num('price').gt(0)) +).groupBy('brand', Reducers.count('orders')); +``` + +This is the FT.SEARCH filter dialect. It's distinct from the post-aggregation `.filter()` step, which uses the FT.AGGREGATE expression dialect. + +## Reducers + +`Reducers` is a namespace of factory functions mirroring [redis-py's reducer module](https://redis.io/docs/latest/commands/ft.aggregate/#group-by-reducers). All accept an optional final `as` argument that aliases the reducer's output column. + +| Factory | Renders to | Notes | +| ---------------------------------- | ----------------- | ---------------------------------- | +| `Reducers.count(as?)` | `COUNT` | No property required. | +| `Reducers.countDistinct(p, as?)` | `COUNT_DISTINCT` | Exact distinct count. | +| `Reducers.countDistinctish(p, as?)`| `COUNT_DISTINCTISH` | HyperLogLog approximation. | +| `Reducers.sum(p, as?)` | `SUM` | | +| `Reducers.min(p, as?)` | `MIN` | | +| `Reducers.max(p, as?)` | `MAX` | | +| `Reducers.avg(p, as?)` | `AVG` | | +| `Reducers.stddev(p, as?)` | `STDDEV` | | +| `Reducers.quantile(p, q, as?)` | `QUANTILE` | `q` is a number in `[0, 1]`. | +| `Reducers.toList(p, as?)` | `TOLIST` | All unique values in the group. | +| `Reducers.firstValue(p, by?, as?)` | `FIRST_VALUE` | Optional ordering expression. | +| `Reducers.randomSample(p, n, as?)` | `RANDOM_SAMPLE` | `n` is a positive integer. | + +```typescript +import { Reducers } from 'redisvl'; + +new AggregationQuery().groupBy('brand', [ + Reducers.count('orders'), + Reducers.avg('price', 'avg_price'), + Reducers.quantile('price', 0.95, 'p95_price'), + Reducers.firstValue('name', { property: 'price', direction: 'DESC' }, 'top_product'), +]); +``` + +## APPLY and FILTER + +`APPLY` derives a new field that downstream steps can refer to. `FILTER` drops rows; it uses the FT.AGGREGATE expression dialect (`@field `, not the `{}`/`[]` syntax of FT.SEARCH): + +```typescript +new AggregationQuery() + .groupBy('brand', [ + Reducers.sum('price', 'revenue'), + Reducers.sum('quantity', 'units'), + ]) + .apply('@revenue / @units', 'avg_unit_price') + .filter('@avg_unit_price > 50') + .sortBy([{ field: 'avg_unit_price', direction: 'DESC' }]) + .limit(0, 10); +``` + +## Parameterized queries + +Use `.params()` to bind values referenced as `$name` in the query string: + +```typescript +const q = new AggregationQuery('@brand:{$brandName}') + .params({ brandName: 'acme' }) + .dialect(2) + .groupBy('brand', Reducers.count('orders')); +``` + +## Other knobs + +- `.load([...])` — load specific source-document fields into the pipeline as `@field` (or `{identifier, as}` for aliasing). +- `.dialect(n)` — set DIALECT. +- `.timeout(ms)` — server-side query timeout. +- `.verbatim()` — disable stemming for the query string. +- `.addScores()` — include `@__score` in each row. + +## Result shape + +```typescript +const { total, results } = await index.aggregate(q); +// total: number — the row count Redis reports after aggregation +// results: Array> — one entry per emitted row +``` + +If you need numeric types, cast at the call site (`Number(row.revenue)`). Aggregation reducers preserve numeric precision on the server side; the wire format simply hands them back as strings. + +## See also + +- [Filters and queries](./filters-and-queries.md) — building the filter passed to the constructor. +- [Hybrid search](./hybrid-search.md) — text + vector fusion via `FT.HYBRID`. +- [`FT.AGGREGATE` reference](https://redis.io/docs/latest/commands/ft.aggregate/) — full command syntax. diff --git a/website/sidebars.ts b/website/sidebars.ts index da8e58d..5ba1caa 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -23,6 +23,7 @@ const sidebars: SidebarsConfig = { 'user-guide/search-index', 'user-guide/filters-and-queries', 'user-guide/advanced-vector-search', + 'user-guide/aggregation', 'user-guide/vectorizers', ], }, From 22a6e3ac5cdf2a5411ac593f577e95aca9962a2d Mon Sep 17 00:00:00 2001 From: Andrew Gingrich Date: Fri, 15 May 2026 15:22:28 -0600 Subject: [PATCH 2/7] test(aggregation): correct avg_unit_price expectation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ergo has the highest avg unit price (800/4 = 200), not acme (1625/17 ≈ 95.6). The reducer math was right; the test fixture arithmetic was wrong. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/integration/aggregation.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/integration/aggregation.test.ts b/tests/integration/aggregation.test.ts index b16c1a9..abab628 100644 --- a/tests/integration/aggregation.test.ts +++ b/tests/integration/aggregation.test.ts @@ -101,9 +101,10 @@ describe('AggregationQuery integration', () => { const { results } = await index.aggregate(q); expect(results).toHaveLength(1); - // acme: revenue=1625 / units=17 ≈ 95.6 — highest avg unit price. - expect(results[0].brand).toBe('acme'); - expect(Number(results[0].avg_unit_price)).toBeGreaterThan(0); + // ergo: revenue=800 / units=4 = 200 — highest avg unit price. + // (acme is 1625/17 ≈ 95.6, omega is 150/4 = 37.5.) + expect(results[0].brand).toBe('ergo'); + expect(Number(results[0].avg_unit_price)).toBe(200); }); it('applies post-aggregation FILTER (FT.AGGREGATE expression dialect)', async () => { From eb231f41865358c572fdb75a52fc47c6a72009eb Mon Sep 17 00:00:00 2001 From: Andrew Gingrich Date: Fri, 15 May 2026 16:01:54 -0600 Subject: [PATCH 3/7] fix(aggregation): export from root, handle Map rows, skip empty PARAMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-export AggregationQuery/Reducers (and supporting types) from src/index.ts so they're reachable from the package root — without this the docs' `import { AggregationQuery } from 'redisvl'` would fail. - aggregate(): handle rows returned as Map instances (RESP3 / MAP type-mapping), which Object.entries() silently turns into {}. - AggregationQuery.toCommand(): skip PARAMS when the map is empty, matching the FT.SEARCH path — Redis rejects `PARAMS 0`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.ts | 2 ++ src/indexes/search-index.ts | 14 ++++++++++---- src/query/aggregation.ts | 6 +++++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index b15103c..34a9b57 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,8 @@ export { CountQuery } from './query/count.js'; export type { CountQueryConfig } from './query/count.js'; export { TextQuery } from './query/text.js'; export type { TextQueryConfig, TextScorer } from './query/text.js'; +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 type { diff --git a/src/indexes/search-index.ts b/src/indexes/search-index.ts index 81d255c..bc7067d 100644 --- a/src/indexes/search-index.ts +++ b/src/indexes/search-index.ts @@ -684,12 +684,18 @@ export class SearchIndex { const { query: queryString, options } = query.toCommand(); const reply = await this.client.ft.aggregate(this.name, queryString, options); - // node-redis returns each row as a MapReply that, on RESP2, decodes - // into a plain object. Normalize to Record so the - // public shape doesn't leak the client's internal types. + // node-redis returns each row as a MapReply — on RESP2 that's a + // plain object, but when the client is configured with the MAP + // type-mapping (or on RESP3) it's an actual Map. Handle both, and + // coerce non-string values via String() so the public shape stays + // Record regardless of the client's wire mode. const results: Array> = reply.results.map((row) => { const out: Record = {}; - for (const [k, v] of Object.entries(row as Record)) { + const entries: Iterable<[string, unknown]> = + row instanceof Map + ? (row.entries() as Iterable<[string, unknown]>) + : Object.entries(row as Record); + for (const [k, v] of entries) { out[k] = typeof v === 'string' ? v : String(v); } return out; diff --git a/src/query/aggregation.ts b/src/query/aggregation.ts index b46e736..a826ffc 100644 --- a/src/query/aggregation.ts +++ b/src/query/aggregation.ts @@ -343,7 +343,11 @@ export class AggregationQuery { if (this._addScores) options.ADDSCORES = true; if (this._timeout !== undefined) options.TIMEOUT = this._timeout; if (this._dialect !== undefined) options.DIALECT = this._dialect; - if (this._params !== undefined) options.PARAMS = this._params; + // Only set PARAMS when non-empty — node-redis serializes `{}` as + // `PARAMS 0`, which Redis rejects. + if (this._params !== undefined && Object.keys(this._params).length > 0) { + options.PARAMS = this._params; + } if (this._load && this._load.length > 0) { // The Redis client types LOAD entries with template-literal types From 6845e695d4bc21a14ca6a2d7175ec2be06181379 Mon Sep 17 00:00:00 2001 From: Andrew Gingrich Date: Mon, 18 May 2026 14:45:29 -0600 Subject: [PATCH 4/7] fix(aggregation): preserve TOLIST arrays and allow GROUPBY 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two surface-bug fixes raised in review: - `SearchIndex.aggregate()` widens the row value type to `string | string[]` and preserves arrays from list reducers like `TOLIST` verbatim instead of stringifying them through `String(v)` (which silently flattened `['a','b']` into `'a,b'` and made the result ambiguous). - `AggregationQuery.groupBy([])` now renders `GROUPBY 0` for whole- result reducers (e.g. average price across the entire match set). node-redis already supports this by omitting the `properties` key, so the validation just needed to stop rejecting the empty-array shape — and we still reject `groupBy([])` with zero reducers since that's never meaningful. Adds unit + integration coverage for both paths. Co-Authored-By: Claude Opus 4.7 --- src/indexes/search-index.ts | 27 ++++++--- src/query/aggregation.ts | 26 ++++++--- tests/integration/aggregation.test.ts | 31 +++++++++++ tests/unit/indexes/search-index.test.ts | 74 +++++++++++++++++++++++++ tests/unit/query/aggregation.test.ts | 14 ++++- 5 files changed, 155 insertions(+), 17 deletions(-) diff --git a/src/indexes/search-index.ts b/src/indexes/search-index.ts index bc7067d..b5c91f5 100644 --- a/src/indexes/search-index.ts +++ b/src/indexes/search-index.ts @@ -661,8 +661,12 @@ export class SearchIndex { * Execute an {@link AggregationQuery} (`FT.AGGREGATE`) against this index. * * Returns the raw aggregate result: a `total` row count and a list of - * rows, where each row is a `Record` of field name to - * value. GROUPBY/REDUCE/APPLY aliases appear as keys on each row. + * rows, where each row is a `Record` of field + * name to value. GROUPBY/REDUCE/APPLY aliases appear as keys on each row. + * + * Scalar reducers (`COUNT`, `SUM`, `AVG`, …) yield strings — numeric + * casting is the caller's job. List reducers (`TOLIST`) yield + * `string[]`, preserving the array structure Redis returns on the wire. * * @example * ```typescript @@ -679,24 +683,29 @@ export class SearchIndex { */ async aggregate( query: AggregationQuery - ): Promise<{ total: number; results: Array> }> { + ): Promise<{ total: number; results: Array> }> { try { const { query: queryString, options } = query.toCommand(); const reply = await this.client.ft.aggregate(this.name, queryString, options); // node-redis returns each row as a MapReply — on RESP2 that's a // plain object, but when the client is configured with the MAP - // type-mapping (or on RESP3) it's an actual Map. Handle both, and - // coerce non-string values via String() so the public shape stays - // Record regardless of the client's wire mode. - const results: Array> = reply.results.map((row) => { - const out: Record = {}; + // type-mapping (or on RESP3) it's an actual Map. Handle both. + // Preserve array values verbatim (TOLIST returns string[]); coerce + // scalar non-strings via String() so numeric reducers come back + // as strings consistent with the FT.AGGREGATE wire format. + const results: Array> = reply.results.map((row) => { + const out: Record = {}; const entries: Iterable<[string, unknown]> = row instanceof Map ? (row.entries() as Iterable<[string, unknown]>) : Object.entries(row as Record); for (const [k, v] of entries) { - out[k] = typeof v === 'string' ? v : String(v); + if (Array.isArray(v)) { + out[k] = v.map((item) => (typeof item === 'string' ? item : String(item))); + } else { + out[k] = typeof v === 'string' ? v : String(v); + } } return out; }); diff --git a/src/query/aggregation.ts b/src/query/aggregation.ts index a826ffc..83b4ec9 100644 --- a/src/query/aggregation.ts +++ b/src/query/aggregation.ts @@ -213,14 +213,22 @@ export class AggregationQuery { this._query = renderFilter(query); } - /** GROUPBY with one or more reducers. Properties are auto-prefixed with `@`. */ + /** + * GROUPBY with one or more reducers. Properties are auto-prefixed with `@`. + * + * Pass an empty array to render `GROUPBY 0` for global reducers that + * aggregate across the whole result set (e.g. average price over every + * matching document, with no grouping field). + */ groupBy(properties: string | string[], reducers: Reducer | Reducer[] = []): this { const props = Array.isArray(properties) ? properties : [properties]; - if (props.length === 0) { - throw new QueryValidationError('groupBy requires at least one property'); - } for (const p of props) assertNonEmpty(p, 'groupBy property'); const reducerList = Array.isArray(reducers) ? reducers : [reducers]; + if (props.length === 0 && reducerList.length === 0) { + throw new QueryValidationError( + 'groupBy with no properties requires at least one reducer' + ); + } this.steps.push({ kind: 'GROUPBY', properties: props.map(prefixFieldRef), @@ -374,12 +382,16 @@ export class AggregationQuery { private renderStep(step: Step): unknown { switch (step.kind) { - case 'GROUPBY': - return { + case 'GROUPBY': { + // node-redis renders `GROUPBY 0` when `properties` is falsy. + // Omit it entirely for empty groups so global reducers work. + const out: { type: 'GROUPBY'; properties?: string[]; REDUCE: unknown[] } = { type: 'GROUPBY', - properties: step.properties, REDUCE: step.reducers.map((r) => this.renderReducer(r)), }; + if (step.properties.length > 0) out.properties = step.properties; + return out; + } case 'APPLY': return { type: 'APPLY', expression: step.expression, AS: step.as }; case 'SORTBY': { diff --git a/tests/integration/aggregation.test.ts b/tests/integration/aggregation.test.ts index abab628..5abe292 100644 --- a/tests/integration/aggregation.test.ts +++ b/tests/integration/aggregation.test.ts @@ -130,4 +130,35 @@ describe('AggregationQuery integration', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ brand: 'omega', total: '1' }); }); + + it('preserves TOLIST array values', async () => { + const q = new AggregationQuery() + .groupBy('category', Reducers.toList('brand', 'brands')) + .sortBy([{ field: 'category', direction: 'ASC' }]); + + const { results } = await index.aggregate(q); + const byCategory = Object.fromEntries(results.map((r) => [r.category as string, r])); + + const electronicsBrands = byCategory['electronics'].brands; + expect(Array.isArray(electronicsBrands)).toBe(true); + // Order isn't guaranteed by Redis — assert as a set. + expect(new Set(electronicsBrands as string[])).toEqual(new Set(['acme', 'omega'])); + + const furnitureBrands = byCategory['furniture'].brands; + expect(Array.isArray(furnitureBrands)).toBe(true); + expect(new Set(furnitureBrands as string[])).toEqual(new Set(['ergo'])); + }); + + it('supports GROUPBY 0 for global reducers (whole-result aggregation)', async () => { + const q = new AggregationQuery().groupBy( + [], + [Reducers.sum('price', 'total_revenue'), Reducers.count('order_count')] + ); + + const { results } = await index.aggregate(q); + expect(results).toHaveLength(1); + // 1200 + 25 + 150 + 300 + 500 + 400 = 2575 across all 6 rows. + expect(Number(results[0].total_revenue)).toBe(2575); + expect(results[0].order_count).toBe('6'); + }); }); diff --git a/tests/unit/indexes/search-index.test.ts b/tests/unit/indexes/search-index.test.ts index 4e3c845..337d1d2 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 { AggregationQuery, Reducers } from '../../../src/query/aggregation.js'; describe('SearchIndex', () => { let schema: IndexSchema; @@ -43,6 +44,7 @@ describe('SearchIndex', () => { dropIndex: vi.fn(), info: vi.fn(), search: vi.fn(), + aggregate: vi.fn(), }, multi: vi.fn().mockReturnValue(mockPipeline), withTypeMapping: vi.fn().mockReturnThis(), @@ -1363,4 +1365,76 @@ describe('SearchIndex', () => { ); }); }); + + describe('aggregate', () => { + type AggregateFunction = RedisClientType['ft']['aggregate']; + type AggregateReply = Awaited>; + + it('preserves array values from TOLIST instead of stringifying them', async () => { + const ftAggregate = mockClient.ft.aggregate as MockedFunction; + ftAggregate.mockResolvedValue({ + total: 1, + results: [ + { + brand: 'acme', + skus: ['a', 'b', 'c'], + revenue: 1625, + }, + ], + } as unknown as AggregateReply); + + const index = new SearchIndex(schema, mockClient); + const { results } = await index.aggregate( + new AggregationQuery().groupBy('brand', [ + Reducers.toList('sku', 'skus'), + Reducers.sum('price', 'revenue'), + ]) + ); + + expect(results).toHaveLength(1); + expect(results[0].brand).toBe('acme'); + expect(results[0].skus).toEqual(['a', 'b', 'c']); + expect(results[0].revenue).toBe('1625'); + }); + + it('handles Map-shaped rows (RESP3 / MAP type-mapping)', async () => { + const ftAggregate = mockClient.ft.aggregate as MockedFunction; + const row = new Map([ + ['brand', 'omega'], + ['skus', ['x', 'y']], + ['total', 4], + ]); + ftAggregate.mockResolvedValue({ + total: 1, + results: [row], + } as unknown as AggregateReply); + + const index = new SearchIndex(schema, mockClient); + const { results } = await index.aggregate( + new AggregationQuery().groupBy('brand', Reducers.toList('sku', 'skus')) + ); + + expect(results[0].skus).toEqual(['x', 'y']); + expect(results[0].total).toBe('4'); + }); + + it('forwards GROUPBY 0 (no properties) to ft.aggregate for global reducers', async () => { + const ftAggregate = mockClient.ft.aggregate as MockedFunction; + ftAggregate.mockResolvedValue({ + total: 1, + results: [{ avg_price: '275' }], + } as unknown as AggregateReply); + + const index = new SearchIndex(schema, mockClient); + const { results } = await index.aggregate( + new AggregationQuery().groupBy([], Reducers.avg('price', 'avg_price')) + ); + + expect(results[0].avg_price).toBe('275'); + const [, , options] = ftAggregate.mock.calls[0]; + const step = (options!.STEPS as unknown as Array>)[0]; + expect('properties' in step).toBe(false); + expect(step.type).toBe('GROUPBY'); + }); + }); }); diff --git a/tests/unit/query/aggregation.test.ts b/tests/unit/query/aggregation.test.ts index fe7c1d3..48fc08e 100644 --- a/tests/unit/query/aggregation.test.ts +++ b/tests/unit/query/aggregation.test.ts @@ -97,7 +97,19 @@ describe('AggregationQuery', () => { }); }); - it('rejects an empty property list', () => { + it('renders GROUPBY 0 (omitted properties) for global reducers', () => { + const q = new AggregationQuery().groupBy([], Reducers.avg('price', 'avg_price')); + const step = q.toCommand().options.STEPS![0] as unknown as Record; + // node-redis renders `GROUPBY 0` when `properties` is falsy/missing, + // so the omission of `properties` is what we're asserting here. + expect(step).toEqual({ + type: 'GROUPBY', + REDUCE: [{ type: 'AVG', AS: 'avg_price', property: '@price' }], + }); + expect('properties' in step).toBe(false); + }); + + it('rejects groupBy([]) with no reducers', () => { expect(() => new AggregationQuery().groupBy([])).toThrow(QueryValidationError); }); From e3f0509b5ff7bd9940a14f4dd96a2f5643d8dcb0 Mon Sep 17 00:00:00 2001 From: Andrew Gingrich Date: Mon, 18 May 2026 14:53:30 -0600 Subject: [PATCH 5/7] docs(aggregate): clarify Map-row comment, drop stale RESP3 reference RESP=3 is rejected at SearchIndex construction (see PR #29), so the Map-row branch in aggregate() is purely about the RESP=2 Map type- mapping opt-in, not about RESP3. Co-Authored-By: Claude Opus 4.7 --- src/indexes/search-index.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/indexes/search-index.ts b/src/indexes/search-index.ts index b5c91f5..b0e9c96 100644 --- a/src/indexes/search-index.ts +++ b/src/indexes/search-index.ts @@ -688,12 +688,13 @@ export class SearchIndex { const { query: queryString, options } = query.toCommand(); const reply = await this.client.ft.aggregate(this.name, queryString, options); - // node-redis returns each row as a MapReply — on RESP2 that's a - // plain object, but when the client is configured with the MAP - // type-mapping (or on RESP3) it's an actual Map. Handle both. - // Preserve array values verbatim (TOLIST returns string[]); coerce - // scalar non-strings via String() so numeric reducers come back - // as strings consistent with the FT.AGGREGATE wire format. + // node-redis returns each row as a MapReply, which resolves to a + // plain object by default or to a real `Map` when the caller has + // opted into Map type-mapping via `client.withTypeMapping(...)`. + // Handle both shapes. Preserve array values verbatim (TOLIST + // returns string[]); coerce scalar non-strings via String() so + // numeric reducers come back as strings consistent with the + // FT.AGGREGATE wire format. const results: Array> = reply.results.map((row) => { const out: Record = {}; const entries: Iterable<[string, unknown]> = From 80ebeadfbbd12e4bc851afcc43d236c48d434a01 Mon Sep 17 00:00:00 2001 From: Andrew Gingrich Date: Mon, 18 May 2026 16:39:40 -0600 Subject: [PATCH 6/7] fix(aggregation): apply Copilot/evargas-redis review blockers - Drop dead HybridQuery references from the AggregationQuery module doc and the aggregation docs page so the surface no longer points at code or pages that don't exist. - Replace the bogus `AggregateValue` placeholder in `aggregate()` JSDoc with the actual `Record` shape. - Reject `groupBy(prop, [])`: a property list without reducers is invalid in FT.AGGREGATE, mirroring the existing GROUPBY-0 check. - Switch `Reducers.firstValue` to the options-object form (`{ by?, as? }`) for readability; update tests and docs. - Default `DIALECT` to 2 in `toCommand()` so `$param` substitution works without callers having to remember `.dialect(2)`; explicit overrides still win. - Allow `limit(0, 0)` for count-only queries; the previous positive-integer guard rejected a valid Redis form. - Fix the package name in JSDoc and docs imports: `redisvl` -> `redis-vl` so copy/paste examples actually resolve. Co-Authored-By: Claude Opus 4.7 --- src/indexes/search-index.ts | 4 +-- src/query/aggregation.ts | 33 ++++++++++++------- src/query/count.ts | 2 +- src/query/filter-query.ts | 2 +- src/query/range.ts | 2 +- src/query/text.ts | 2 +- tests/integration/aggregation.test.ts | 1 - tests/unit/query/aggregation.test.ts | 30 +++++++++++++---- website/docs/user-guide/aggregation.md | 16 +++++---- .../docs/user-guide/filters-and-queries.md | 20 +++++------ 10 files changed, 70 insertions(+), 42 deletions(-) diff --git a/src/indexes/search-index.ts b/src/indexes/search-index.ts index b0e9c96..cba79cc 100644 --- a/src/indexes/search-index.ts +++ b/src/indexes/search-index.ts @@ -661,7 +661,7 @@ export class SearchIndex { * Execute an {@link AggregationQuery} (`FT.AGGREGATE`) against this index. * * Returns the raw aggregate result: a `total` row count and a list of - * rows, where each row is a `Record` of field + * rows, where each row is a `Record` of field * name to value. GROUPBY/REDUCE/APPLY aliases appear as keys on each row. * * Scalar reducers (`COUNT`, `SUM`, `AVG`, …) yield strings — numeric @@ -670,7 +670,7 @@ export class SearchIndex { * * @example * ```typescript - * import { AggregationQuery, Reducers } from 'redisvl'; + * import { AggregationQuery, Reducers } from 'redis-vl'; * * const q = new AggregationQuery('@category:{electronics}') * .groupBy('@brand', Reducers.sum('price', 'revenue')) diff --git a/src/query/aggregation.ts b/src/query/aggregation.ts index 83b4ec9..4943a79 100644 --- a/src/query/aggregation.ts +++ b/src/query/aggregation.ts @@ -3,9 +3,8 @@ * * Mirrors Python redisvl's `AggregationQuery` — a thin, fluent builder over * the FT.AGGREGATE pipeline (GROUPBY → REDUCE → APPLY → SORTBY → LIMIT → - * FILTER). The hybrid (text + vector) aggregation variant is intentionally - * out of scope here; that surface is already covered by {@link HybridQuery} - * via `FT.HYBRID`. + * FILTER). Hybrid (text + vector) aggregation is out of scope for this + * class. * * @see https://redis.io/docs/latest/commands/ft.aggregate/ */ @@ -67,7 +66,7 @@ export type Reducer = * * @example * ```typescript - * import { AggregationQuery, Reducers } from 'redisvl'; + * import { AggregationQuery, Reducers } from 'redis-vl'; * * const q = new AggregationQuery('@category:{electronics}') * .groupBy('@brand', [ @@ -112,10 +111,12 @@ export const Reducers = { }, firstValue( property: string, - by?: string | { property: string; direction?: 'ASC' | 'DESC' }, - as?: string + options?: { + by?: string | { property: string; direction?: 'ASC' | 'DESC' }; + as?: string; + } ): Reducer { - return { type: 'FIRST_VALUE', property, by, as }; + return { type: 'FIRST_VALUE', property, by: options?.by, as: options?.as }; }, randomSample(property: string, sampleSize: number, as?: string): Reducer { if (!Number.isInteger(sampleSize) || sampleSize <= 0) { @@ -183,7 +184,7 @@ function assertPositiveInteger(value: number | undefined, label: string): void { * * @example * ```typescript - * import { AggregationQuery, Reducers, Tag } from 'redisvl'; + * import { AggregationQuery, Reducers, Tag } from 'redis-vl'; * * const q = new AggregationQuery(Tag('category').eq('electronics')) * .groupBy('@brand', [Reducers.sum('price', 'revenue'), Reducers.count('orders')]) @@ -229,6 +230,9 @@ export class AggregationQuery { 'groupBy with no properties requires at least one reducer' ); } + if (props.length > 0 && reducerList.length === 0) { + throw new QueryValidationError('groupBy with properties requires at least one reducer'); + } this.steps.push({ kind: 'GROUPBY', properties: props.map(prefixFieldRef), @@ -265,10 +269,15 @@ export class AggregationQuery { return this; } - /** LIMIT offset, count. */ + /** + * LIMIT offset, count. + * + * `count = 0` is a valid Redis form that returns only the count/metadata + * with no rows. + */ limit(offset: number, count: number): this { assertNonNegativeInteger(offset, 'limit offset'); - assertPositiveInteger(count, 'limit count'); + assertNonNegativeInteger(count, 'limit count'); this.steps.push({ kind: 'LIMIT', offset, count }); return this; } @@ -310,7 +319,7 @@ export class AggregationQuery { return this; } - /** Set the DIALECT. Defaults to the server's current default when omitted. */ + /** Set the DIALECT. Defaults to 2 (required for parameterized `$param` references). */ dialect(dialect: number): this { if (!Number.isInteger(dialect) || dialect <= 0) { throw new QueryValidationError('dialect must be a positive integer'); @@ -350,7 +359,7 @@ export class AggregationQuery { if (this._verbatim) options.VERBATIM = true; if (this._addScores) options.ADDSCORES = true; if (this._timeout !== undefined) options.TIMEOUT = this._timeout; - if (this._dialect !== undefined) options.DIALECT = this._dialect; + options.DIALECT = this._dialect ?? 2; // Only set PARAMS when non-empty — node-redis serializes `{}` as // `PARAMS 0`, which Redis rejects. if (this._params !== undefined && Object.keys(this._params).length > 0) { diff --git a/src/query/count.ts b/src/query/count.ts index c60b44c..580f48a 100644 --- a/src/query/count.ts +++ b/src/query/count.ts @@ -17,7 +17,7 @@ export interface CountQueryConfig { * * @example * ```typescript - * import { CountQuery, Tag } from 'redisvl'; + * import { CountQuery, Tag } from 'redis-vl'; * * const total = (await index.search(new CountQuery({ filter: Tag('brand').eq('nike') }))).total; * ``` diff --git a/src/query/filter-query.ts b/src/query/filter-query.ts index 847c8fc..8314740 100644 --- a/src/query/filter-query.ts +++ b/src/query/filter-query.ts @@ -26,7 +26,7 @@ export interface FilterQueryConfig { * * @example * ```typescript - * import { FilterQuery, Tag, Num } from 'redisvl'; + * import { FilterQuery, Tag, Num } from 'redis-vl'; * * const q = new FilterQuery({ * filter: Tag('brand').eq('nike').and(Num('price').lt(100)), diff --git a/src/query/range.ts b/src/query/range.ts index 14ceddd..8c96193 100644 --- a/src/query/range.ts +++ b/src/query/range.ts @@ -65,7 +65,7 @@ export interface VectorRangeQueryConfig { * * @example * ```typescript - * import { VectorRangeQuery, Tag } from 'redisvl'; + * import { VectorRangeQuery, Tag } from 'redis-vl'; * * const q = new VectorRangeQuery({ * vector: embedding, diff --git a/src/query/text.ts b/src/query/text.ts index bece785..1c4f7b9 100644 --- a/src/query/text.ts +++ b/src/query/text.ts @@ -56,7 +56,7 @@ export interface TextQueryConfig { * * @example * ```typescript - * import { TextQuery, Tag } from 'redisvl'; + * import { TextQuery, Tag } from 'redis-vl'; * * const q = new TextQuery({ * text: 'machine learning', diff --git a/tests/integration/aggregation.test.ts b/tests/integration/aggregation.test.ts index 5abe292..ec09dfb 100644 --- a/tests/integration/aggregation.test.ts +++ b/tests/integration/aggregation.test.ts @@ -123,7 +123,6 @@ describe('AggregationQuery integration', () => { it('binds PARAMS for parameterized filter strings', async () => { const q = new AggregationQuery('@brand:{$brandName}') .params({ brandName: 'omega' }) - .dialect(2) .groupBy('brand', Reducers.count('total')); const { results } = await index.aggregate(q); diff --git a/tests/unit/query/aggregation.test.ts b/tests/unit/query/aggregation.test.ts index 48fc08e..42ee645 100644 --- a/tests/unit/query/aggregation.test.ts +++ b/tests/unit/query/aggregation.test.ts @@ -53,7 +53,7 @@ describe('AggregationQuery', () => { }); it('preserves explicit @ and $ prefixes on properties', () => { - const q = new AggregationQuery().groupBy(['@brand', '$.category'], []); + const q = new AggregationQuery().groupBy(['@brand', '$.category'], Reducers.count()); const { options } = q.toCommand(); expect((options.STEPS![0] as { properties: string[] }).properties).toEqual([ '@brand', @@ -82,7 +82,10 @@ describe('AggregationQuery', () => { it('renders FIRST_VALUE with BY direction', () => { const q = new AggregationQuery().groupBy( 'brand', - Reducers.firstValue('name', { property: 'price', direction: 'DESC' }, 'top') + Reducers.firstValue('name', { + by: { property: 'price', direction: 'DESC' }, + as: 'top', + }) ); const reducer = ( q.toCommand().options.STEPS![0] as unknown as { @@ -113,6 +116,10 @@ describe('AggregationQuery', () => { expect(() => new AggregationQuery().groupBy([])).toThrow(QueryValidationError); }); + it('rejects groupBy(prop) with no reducers', () => { + expect(() => new AggregationQuery().groupBy('brand')).toThrow(QueryValidationError); + }); + it('rejects QUANTILE outside [0, 1]', () => { expect(() => Reducers.quantile('price', 1.5)).toThrow(QueryValidationError); }); @@ -167,8 +174,9 @@ describe('AggregationQuery', () => { expect(() => new AggregationQuery().limit(-1, 10)).toThrow(QueryValidationError); }); - it('rejects zero LIMIT count', () => { - expect(() => new AggregationQuery().limit(0, 0)).toThrow(QueryValidationError); + it('allows LIMIT 0 0 for count-only queries', () => { + const q = new AggregationQuery().limit(0, 0); + expect(q.toCommand().options.STEPS).toEqual([{ type: 'LIMIT', from: 0, size: 0 }]); }); it('rejects non-ASC/DESC sort directions', () => { @@ -226,9 +234,19 @@ describe('AggregationQuery', () => { expect(options.ADDSCORES).toBe(true); }); - it('omits unset options', () => { + it('applies only DIALECT default when no options are set', () => { + const { options } = new AggregationQuery().toCommand(); + expect(options).toEqual({ DIALECT: 2 }); + }); + + it('defaults DIALECT to 2 when not set', () => { const { options } = new AggregationQuery().toCommand(); - expect(options).toEqual({}); + expect(options.DIALECT).toBe(2); + }); + + it('lets an explicit .dialect() override the default', () => { + const { options } = new AggregationQuery().dialect(3).toCommand(); + expect(options.DIALECT).toBe(3); }); }); }); diff --git a/website/docs/user-guide/aggregation.md b/website/docs/user-guide/aggregation.md index f9d13c4..efb7e53 100644 --- a/website/docs/user-guide/aggregation.md +++ b/website/docs/user-guide/aggregation.md @@ -6,7 +6,7 @@ sidebar_position: 6 `AggregationQuery` builds an [`FT.AGGREGATE`](https://redis.io/docs/latest/commands/ft.aggregate/) call against an index. Unlike `FT.SEARCH` (which retrieves documents), `FT.AGGREGATE` runs a pipeline — group rows, reduce them, derive new fields, sort, page — and returns computed rows rather than source documents. -If you've used Python `redisvl`, this mirrors `AggregationQuery` over `AggregateRequest`. The hybrid (text + vector) flavour lives in a separate class, [`HybridQuery`](./hybrid-search.md). +If you've used Python `redisvl`, this mirrors `AggregationQuery` over `AggregateRequest`. Hybrid (text + vector) aggregation is out of scope for this class. ## When to use it @@ -38,7 +38,7 @@ import { IndexSchema, AggregationQuery, Reducers, -} from 'redisvl'; +} from 'redis-vl'; import { createClient } from 'redis'; const client = createClient(); @@ -76,7 +76,7 @@ Each row is a `Record` keyed by the reducer/apply alias (or the The constructor takes the same `FilterInput` the rest of the query DSL uses — either a raw string or a `FilterExpression`: ```typescript -import { AggregationQuery, Reducers, Tag, Num } from 'redisvl'; +import { AggregationQuery, Reducers, Tag, Num } from 'redis-vl'; const q = new AggregationQuery( Tag('category').eq('electronics').and(Num('price').gt(0)) @@ -101,17 +101,20 @@ This is the FT.SEARCH filter dialect. It's distinct from the post-aggregation `. | `Reducers.stddev(p, as?)` | `STDDEV` | | | `Reducers.quantile(p, q, as?)` | `QUANTILE` | `q` is a number in `[0, 1]`. | | `Reducers.toList(p, as?)` | `TOLIST` | All unique values in the group. | -| `Reducers.firstValue(p, by?, as?)` | `FIRST_VALUE` | Optional ordering expression. | +| `Reducers.firstValue(p, options?)` | `FIRST_VALUE` | `options.by` orders ties; `options.as` aliases output. | | `Reducers.randomSample(p, n, as?)` | `RANDOM_SAMPLE` | `n` is a positive integer. | ```typescript -import { Reducers } from 'redisvl'; +import { Reducers } from 'redis-vl'; new AggregationQuery().groupBy('brand', [ Reducers.count('orders'), Reducers.avg('price', 'avg_price'), Reducers.quantile('price', 0.95, 'p95_price'), - Reducers.firstValue('name', { property: 'price', direction: 'DESC' }, 'top_product'), + Reducers.firstValue('name', { + by: { property: 'price', direction: 'DESC' }, + as: 'top_product', + }), ]); ``` @@ -163,5 +166,4 @@ If you need numeric types, cast at the call site (`Number(row.revenue)`). Aggreg ## See also - [Filters and queries](./filters-and-queries.md) — building the filter passed to the constructor. -- [Hybrid search](./hybrid-search.md) — text + vector fusion via `FT.HYBRID`. - [`FT.AGGREGATE` reference](https://redis.io/docs/latest/commands/ft.aggregate/) — full command syntax. diff --git a/website/docs/user-guide/filters-and-queries.md b/website/docs/user-guide/filters-and-queries.md index 99a817c..84edc19 100644 --- a/website/docs/user-guide/filters-and-queries.md +++ b/website/docs/user-guide/filters-and-queries.md @@ -13,7 +13,7 @@ Together, they cover the cases where pure KNN vector search isn't what you want The DSL builds typed, escaped Redis Search filter expressions through method chaining. Every operator returns a `FilterExpression`, which can be passed directly to a query or composed further with `.and()` / `.or()`. ```typescript -import { Tag, Num, Text, Geo, GeoRadius, Timestamp } from 'redisvl'; +import { Tag, Num, Text, Geo, GeoRadius, Timestamp } from 'redis-vl'; const filter = Tag('brand') .eq('nike') @@ -75,7 +75,7 @@ Text('description').isMissing(); ```typescript -import { Geo, GeoRadius } from 'redisvl'; +import { Geo, GeoRadius } from 'redis-vl'; const sf = new GeoRadius(-122.4194, 37.7749, 5, 'km'); // lon, lat, radius, unit @@ -89,7 +89,7 @@ Units: `'m'`, `'km'`, `'mi'`, `'ft'`. ```typescript -import { Timestamp } from 'redisvl'; +import { Timestamp } from 'redis-vl'; // Accepts Date, ISO string, or Unix seconds (number) Timestamp('created_at').eq(new Date('2024-01-15T12:00:00Z')); @@ -112,7 +112,7 @@ Timestamp('created_at').between(new Date('2024-01-01'), new Date('2024-12-31')); Every operator returns a `FilterExpression`. Combine expressions with `.and()` and `.or()` — they're regular methods, evaluated in chain order: ```typescript -import { Tag, Num } from 'redisvl'; +import { Tag, Num } from 'redis-vl'; Tag('brand') .eq('nike') @@ -140,7 +140,7 @@ All four query types are passed to `index.search()` and return a `SearchResult Date: Mon, 18 May 2026 16:49:59 -0600 Subject: [PATCH 7/7] fix(aggregation): tighten field refs, allow sortBy max=0, widen row docs - prefixFieldRef: reject bare `$name` references as likely typos; only `$.path` (JSONPath) is accepted as already-prefixed. - sortBy(max): allow `0` for symmetry with `limit(0, 0)`. - Update user-guide row-shape docs to `Record` to reflect that `Reducers.toList` returns `string[]`. Co-Authored-By: Claude Opus 4.7 --- src/query/aggregation.ts | 37 +++++++++++++++++++++++--- tests/unit/query/aggregation.test.ts | 21 +++++++++++++++ website/docs/user-guide/aggregation.md | 6 ++--- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/query/aggregation.ts b/src/query/aggregation.ts index 4943a79..63b3dae 100644 --- a/src/query/aggregation.ts +++ b/src/query/aggregation.ts @@ -32,8 +32,16 @@ export type SortSpec = string | { field: string; direction?: 'ASC' | 'DESC' }; /** * Field reference used with {@link AggregationQuery.load}. * - * Strings are passed through (`@field` / `$.path` conventions apply); the - * object form supports `AS` aliasing. + * Accepted shapes for string identifiers (and any other field reference the + * builder takes): + * - bare name (e.g. `'brand'`) — auto-prefixed to `@brand` + * - `@name` — passed through as an index field reference + * - `$.path` — passed through as a JSONPath reference + * + * A bare `$name` (no dot) is rejected as a likely typo or stray PARAMS-style + * reference; use `@name` or `$.name` explicitly. + * + * The object form additionally supports `AS` aliasing via `{ identifier, as }`. */ export type LoadField = string | { identifier: string; as?: string }; @@ -152,8 +160,29 @@ interface FilterStep { } type Step = GroupByStep | ApplyStep | SortByStep | LimitStep | FilterStep; +/** + * Normalize a field reference for FT.AGGREGATE pipelines. + * + * Accepted shapes: + * - bare name (`'brand'`) → `@brand` + * - `@name` → as-is (index field reference) + * - `$.path` → as-is (JSONPath reference) + * + * A bare `$name` (no dot) is rejected: it's either a PARAMS-style reference + * that doesn't belong in a field slot or a typo of `@name`/`$.name`. + */ function prefixFieldRef(name: string): string { - return name.startsWith('@') || name.startsWith('$') ? name : `@${name}`; + if (name.startsWith('@')) return name; + if (name.startsWith('$')) { + if (!name.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` + ); + } + return name; + } + return `@${name}`; } function assertNonEmpty(value: string | undefined, label: string): void { @@ -264,7 +293,7 @@ export class AggregationQuery { } } } - assertPositiveInteger(max, 'sortBy max'); + assertNonNegativeInteger(max, 'sortBy max'); this.steps.push({ kind: 'SORTBY', by: list, max }); return this; } diff --git a/tests/unit/query/aggregation.test.ts b/tests/unit/query/aggregation.test.ts index 42ee645..d5946f1 100644 --- a/tests/unit/query/aggregation.test.ts +++ b/tests/unit/query/aggregation.test.ts @@ -61,6 +61,20 @@ describe('AggregationQuery', () => { ]); }); + it('accepts $.path JSONPath references on groupBy properties', () => { + const q = new AggregationQuery().groupBy('$.category', Reducers.count()); + const { options } = q.toCommand(); + expect((options.STEPS![0] as { properties: string[] }).properties).toEqual([ + '$.category', + ]); + }); + + it('rejects bare $name field references as likely typos', () => { + expect(() => new AggregationQuery().groupBy('$brand', Reducers.count())).toThrow( + QueryValidationError + ); + }); + it('renders QUANTILE with its quantile arg', () => { const q = new AggregationQuery().groupBy( 'brand', @@ -179,6 +193,13 @@ describe('AggregationQuery', () => { expect(q.toCommand().options.STEPS).toEqual([{ type: 'LIMIT', from: 0, size: 0 }]); }); + it('allows sortBy max=0 (no row cap)', () => { + const q = new AggregationQuery().sortBy('revenue', 0); + expect(q.toCommand().options.STEPS).toEqual([ + { type: 'SORTBY', BY: ['@revenue'], MAX: 0 }, + ]); + }); + it('rejects non-ASC/DESC sort directions', () => { expect(() => new AggregationQuery().sortBy([{ field: 'x', direction: 'BOGUS' as 'ASC' }]) diff --git a/website/docs/user-guide/aggregation.md b/website/docs/user-guide/aggregation.md index efb7e53..3d9b384 100644 --- a/website/docs/user-guide/aggregation.md +++ b/website/docs/user-guide/aggregation.md @@ -69,7 +69,7 @@ for (const row of results) { } ``` -Each row is a `Record` keyed by the reducer/apply alias (or the GROUPBY field). All values come back as strings — cast to numbers in user code if you need them. +Each row is a `Record` keyed by the reducer/apply alias (or the GROUPBY field). Most reducers return strings — cast to numbers in user code (`Number(row.revenue)`) when you need numeric types. `Reducers.toList` is the exception: it returns `string[]` for that column. ## Filtering rows into the pipeline @@ -158,10 +158,10 @@ const q = new AggregationQuery('@brand:{$brandName}') ```typescript const { total, results } = await index.aggregate(q); // total: number — the row count Redis reports after aggregation -// results: Array> — one entry per emitted row +// results: Array> — one entry per emitted row ``` -If you need numeric types, cast at the call site (`Number(row.revenue)`). Aggregation reducers preserve numeric precision on the server side; the wire format simply hands them back as strings. +Most reducer columns are strings. `Reducers.toList` (TOLIST) is the exception — it returns `string[]` for that column. If you need numeric types, cast at the call site (`Number(row.revenue)`). Aggregation reducers preserve numeric precision on the server side; the wire format simply hands them back as strings. ## See also