From 5a2201d76ba2adba7129e53bfeb81634fc8a6ed4 Mon Sep 17 00:00:00 2001 From: Kartik Date: Tue, 26 May 2026 23:03:47 +0530 Subject: [PATCH] fix(pgvector): boolean filter casing, LIKE escaping, and TS scalar coercion (#5264) --- mem0-ts/src/oss/src/vector_stores/pgvector.ts | 22 +++++++++++--- .../src/oss/tests/pgvector.filters.test.ts | 29 +++++++++++++++++-- mem0/vector_stores/pgvector.py | 10 +++++-- tests/vector_stores/test_pgvector.py | 24 +++++++++++++-- 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/mem0-ts/src/oss/src/vector_stores/pgvector.ts b/mem0-ts/src/oss/src/vector_stores/pgvector.ts index 102bde205..aba0060f6 100644 --- a/mem0-ts/src/oss/src/vector_stores/pgvector.ts +++ b/mem0-ts/src/oss/src/vector_stores/pgvector.ts @@ -47,8 +47,14 @@ const OPERATOR_SQL_MAP: Record = template: "NOT (payload->>'%KEY%' = ANY($%IDX%::text[]))", numeric: false, }, - contains: { template: "payload->>'%KEY%' LIKE $%IDX%", numeric: false }, - icontains: { template: "payload->>'%KEY%' ILIKE $%IDX%", numeric: false }, + contains: { + template: "payload->>'%KEY%' LIKE $%IDX% ESCAPE '\\'", + numeric: false, + }, + icontains: { + template: "payload->>'%KEY%' ILIKE $%IDX% ESCAPE '\\'", + numeric: false, + }, }; export function buildFilterConditions( @@ -119,7 +125,11 @@ export function buildFilterConditions( if (op === "in" || op === "nin") { values.push((opValue as any[]).map(String)); } else if (op === "contains" || op === "icontains") { - values.push(`%${opValue}%`); + const escaped = String(opValue) + .replace(/\\/g, "\\\\") + .replace(/%/g, "\\%") + .replace(/_/g, "\\_"); + values.push(`%${escaped}%`); } else if (mapping.numeric) { values.push(Number(opValue)); } else { @@ -133,7 +143,11 @@ export function buildFilterConditions( paramIndex++; } else { conditions.push(`payload->>'${safeKey}' = $${paramIndex}`); - values.push(value); + if (typeof value === "boolean") { + values.push(JSON.stringify(value)); + } else { + values.push(String(value)); + } paramIndex++; } } diff --git a/mem0-ts/src/oss/tests/pgvector.filters.test.ts b/mem0-ts/src/oss/tests/pgvector.filters.test.ts index 19799623d..6b6523be0 100644 --- a/mem0-ts/src/oss/tests/pgvector.filters.test.ts +++ b/mem0-ts/src/oss/tests/pgvector.filters.test.ts @@ -120,17 +120,27 @@ describe("buildFilterConditions", () => { test("contains operator", () => { const result = buildFilterConditions({ name: { contains: "alice" } }, 1); expect(result.conditions).toHaveLength(1); - expect(result.conditions[0]).toContain("LIKE $1"); + expect(result.conditions[0]).toContain("LIKE $1 ESCAPE"); expect(result.values).toEqual(["%alice%"]); }); test("icontains operator", () => { const result = buildFilterConditions({ name: { icontains: "Alice" } }, 1); expect(result.conditions).toHaveLength(1); - expect(result.conditions[0]).toContain("ILIKE $1"); + expect(result.conditions[0]).toContain("ILIKE $1 ESCAPE"); expect(result.values).toEqual(["%Alice%"]); }); + test("contains escapes LIKE wildcards", () => { + const result = buildFilterConditions({ name: { contains: "50%_off" } }, 1); + expect(result.values).toEqual(["%50\\%\\_off%"]); + }); + + test("icontains escapes LIKE wildcards", () => { + const result = buildFilterConditions({ promo: { icontains: "a%b_c" } }, 1); + expect(result.values).toEqual(["%a\\%b\\_c%"]); + }); + test("wildcard value", () => { const result = buildFilterConditions({ metadata_key: "*" }, 1); expect(result.conditions).toHaveLength(1); @@ -216,4 +226,19 @@ describe("buildFilterConditions", () => { expect(result.conditions[2]).toContain("$5"); expect(result.paramIndex).toBe(6); }); + + test("boolean true uses JSON casing", () => { + const result = buildFilterConditions({ is_active: true }, 1); + expect(result.values).toEqual(["true"]); + }); + + test("boolean false uses JSON casing", () => { + const result = buildFilterConditions({ is_active: false }, 1); + expect(result.values).toEqual(["false"]); + }); + + test("numeric scalar becomes string", () => { + const result = buildFilterConditions({ priority: 42 }, 1); + expect(result.values).toEqual(["42"]); + }); }); diff --git a/mem0/vector_stores/pgvector.py b/mem0/vector_stores/pgvector.py index 2889548ae..86dcd5667 100644 --- a/mem0/vector_stores/pgvector.py +++ b/mem0/vector_stores/pgvector.py @@ -91,8 +91,9 @@ def _build_filter_conditions(filters): conditions.append(template) params.extend([key, str_list]) elif op in ("contains", "icontains"): - conditions.append(template) - params.extend([key, f"%{op_value}%"]) + escaped = str(op_value).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + conditions.append(template + " ESCAPE '\\'") + params.extend([key, f"%{escaped}%"]) else: conditions.append(template) if is_numeric: @@ -104,7 +105,10 @@ def _build_filter_conditions(filters): params.extend([key, [str(v) for v in value]]) else: conditions.append("payload->>%s = %s") - params.extend([key, str(value)]) + if isinstance(value, bool): + params.extend([key, json.dumps(value)]) + else: + params.extend([key, str(value)]) return conditions, params diff --git a/tests/vector_stores/test_pgvector.py b/tests/vector_stores/test_pgvector.py index 6896dd4a1..c610ffc17 100644 --- a/tests/vector_stores/test_pgvector.py +++ b/tests/vector_stores/test_pgvector.py @@ -2317,15 +2317,23 @@ class TestBuildFilterConditions(unittest.TestCase): def test_contains_operator(self): conditions, params = _build_filter_conditions({"name": {"contains": "alice"}}) self.assertEqual(len(conditions), 1) - self.assertIn("payload->>%s LIKE %s", conditions[0]) + self.assertIn("LIKE %s ESCAPE", conditions[0]) self.assertEqual(params, ["name", "%alice%"]) def test_icontains_operator(self): conditions, params = _build_filter_conditions({"name": {"icontains": "Alice"}}) self.assertEqual(len(conditions), 1) - self.assertIn("payload->>%s ILIKE %s", conditions[0]) + self.assertIn("ILIKE %s ESCAPE", conditions[0]) self.assertEqual(params, ["name", "%Alice%"]) + def test_contains_escapes_wildcards(self): + conditions, params = _build_filter_conditions({"name": {"contains": "50%_off"}}) + self.assertEqual(params, ["name", "%50\\%\\_off%"]) + + def test_icontains_escapes_wildcards(self): + conditions, params = _build_filter_conditions({"promo": {"icontains": "a%b_c"}}) + self.assertEqual(params, ["promo", "%a\\%b\\_c%"]) + def test_wildcard(self): conditions, params = _build_filter_conditions({"metadata_key": "*"}) self.assertEqual(len(conditions), 1) @@ -2388,3 +2396,15 @@ class TestBuildFilterConditions(unittest.TestCase): def test_in_with_numeric_values(self): conditions, params = _build_filter_conditions({"priority": {"in": [1, 2, 3]}}) self.assertEqual(params, ["priority", ["1", "2", "3"]]) + + def test_boolean_true_uses_json_casing(self): + conditions, params = _build_filter_conditions({"is_active": True}) + self.assertEqual(params, ["is_active", "true"]) + + def test_boolean_false_uses_json_casing(self): + conditions, params = _build_filter_conditions({"is_active": False}) + self.assertEqual(params, ["is_active", "false"]) + + def test_numeric_scalar_becomes_string(self): + conditions, params = _build_filter_conditions({"priority": 42}) + self.assertEqual(params, ["priority", "42"])