diff --git a/mem0-ts/src/oss/src/vector_stores/redis.ts b/mem0-ts/src/oss/src/vector_stores/redis.ts index c5d8394f7..8c274f7d5 100644 --- a/mem0-ts/src/oss/src/vector_stores/redis.ts +++ b/mem0-ts/src/oss/src/vector_stores/redis.ts @@ -133,6 +133,18 @@ function toSnakeCase(obj: Record): Record { ); } +// Build a RediSearch pre-filter expression from filters. Returns "*" (match +// all) when there are no usable conditions — including an empty filters object +// or one whose values are all null/undefined — since an empty expression is an +// invalid RediSearch query (e.g. ` =>[KNN ...]`). +export function buildRedisFilterExpr(filters?: SearchFilters): string { + if (!filters) return "*"; + const conditions = Object.entries(toSnakeCase(filters)) + .filter(([, value]) => value !== null && value !== undefined) + .map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`); + return conditions.length > 0 ? conditions.join(" ") : "*"; +} + // Utility function to convert object keys to camelCase function toCamelCase(obj: Record): Record { if (typeof obj !== "object" || obj === null) return obj; @@ -406,13 +418,7 @@ export class RedisDB implements VectorStore { filters?: SearchFilters, ): Promise { await this.initialize(); - const snakeFilters = filters ? toSnakeCase(filters) : undefined; - const filterExpr = snakeFilters - ? Object.entries(snakeFilters) - .filter(([_, value]) => value !== null && value !== undefined) - .map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`) - .join(" ") - : "*"; + const filterExpr = buildRedisFilterExpr(filters); const queryVector = new Float32Array(query).buffer; @@ -655,13 +661,7 @@ export class RedisDB implements VectorStore { topK: number = 100, ): Promise<[VectorStoreResult[], number]> { await this.initialize(); - const snakeFilters = filters ? toSnakeCase(filters) : undefined; - const filterExpr = snakeFilters - ? Object.entries(snakeFilters) - .filter(([_, value]) => value !== null && value !== undefined) - .map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`) - .join(" ") - : "*"; + const filterExpr = buildRedisFilterExpr(filters); const searchOptions = { SORTBY: "created_at", diff --git a/mem0-ts/src/oss/tests/redis.filter-expr.test.ts b/mem0-ts/src/oss/tests/redis.filter-expr.test.ts new file mode 100644 index 000000000..43ed6c63b --- /dev/null +++ b/mem0-ts/src/oss/tests/redis.filter-expr.test.ts @@ -0,0 +1,35 @@ +import { buildRedisFilterExpr } from "../src/vector_stores/redis"; + +describe("buildRedisFilterExpr", () => { + it("returns '*' when filters is undefined", () => { + expect(buildRedisFilterExpr(undefined)).toBe("*"); + }); + + it("returns '*' for an empty filters object", () => { + // Regression: previously produced "" -> invalid RediSearch query + // (` =>[KNN ...]`). + expect(buildRedisFilterExpr({})).toBe("*"); + }); + + it("returns '*' when every filter value is null/undefined", () => { + expect( + buildRedisFilterExpr({ user_id: null as any, agent_id: undefined }), + ).toBe("*"); + }); + + it("builds a tag clause for a single filter", () => { + expect(buildRedisFilterExpr({ user_id: "alice" })).toBe("@user_id:{alice}"); + }); + + it("snake-cases keys and joins multiple clauses", () => { + expect(buildRedisFilterExpr({ userId: "alice", runId: "r1" })).toBe( + "@user_id:{alice} @run_id:{r1}", + ); + }); + + it("drops null/undefined values but keeps the rest", () => { + expect( + buildRedisFilterExpr({ user_id: "alice", agent_id: null as any }), + ).toBe("@user_id:{alice}"); + }); +});