fix(pgvector): boolean filter casing, LIKE escaping, and TS scalar coercion (#5264)
This commit is contained in:
@@ -47,8 +47,14 @@ const OPERATOR_SQL_MAP: Record<string, { template: string; numeric: boolean }> =
|
|||||||
template: "NOT (payload->>'%KEY%' = ANY($%IDX%::text[]))",
|
template: "NOT (payload->>'%KEY%' = ANY($%IDX%::text[]))",
|
||||||
numeric: false,
|
numeric: false,
|
||||||
},
|
},
|
||||||
contains: { template: "payload->>'%KEY%' LIKE $%IDX%", numeric: false },
|
contains: {
|
||||||
icontains: { template: "payload->>'%KEY%' ILIKE $%IDX%", numeric: false },
|
template: "payload->>'%KEY%' LIKE $%IDX% ESCAPE '\\'",
|
||||||
|
numeric: false,
|
||||||
|
},
|
||||||
|
icontains: {
|
||||||
|
template: "payload->>'%KEY%' ILIKE $%IDX% ESCAPE '\\'",
|
||||||
|
numeric: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildFilterConditions(
|
export function buildFilterConditions(
|
||||||
@@ -119,7 +125,11 @@ export function buildFilterConditions(
|
|||||||
if (op === "in" || op === "nin") {
|
if (op === "in" || op === "nin") {
|
||||||
values.push((opValue as any[]).map(String));
|
values.push((opValue as any[]).map(String));
|
||||||
} else if (op === "contains" || op === "icontains") {
|
} 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) {
|
} else if (mapping.numeric) {
|
||||||
values.push(Number(opValue));
|
values.push(Number(opValue));
|
||||||
} else {
|
} else {
|
||||||
@@ -133,7 +143,11 @@ export function buildFilterConditions(
|
|||||||
paramIndex++;
|
paramIndex++;
|
||||||
} else {
|
} else {
|
||||||
conditions.push(`payload->>'${safeKey}' = $${paramIndex}`);
|
conditions.push(`payload->>'${safeKey}' = $${paramIndex}`);
|
||||||
values.push(value);
|
if (typeof value === "boolean") {
|
||||||
|
values.push(JSON.stringify(value));
|
||||||
|
} else {
|
||||||
|
values.push(String(value));
|
||||||
|
}
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,17 +120,27 @@ describe("buildFilterConditions", () => {
|
|||||||
test("contains operator", () => {
|
test("contains operator", () => {
|
||||||
const result = buildFilterConditions({ name: { contains: "alice" } }, 1);
|
const result = buildFilterConditions({ name: { contains: "alice" } }, 1);
|
||||||
expect(result.conditions).toHaveLength(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%"]);
|
expect(result.values).toEqual(["%alice%"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("icontains operator", () => {
|
test("icontains operator", () => {
|
||||||
const result = buildFilterConditions({ name: { icontains: "Alice" } }, 1);
|
const result = buildFilterConditions({ name: { icontains: "Alice" } }, 1);
|
||||||
expect(result.conditions).toHaveLength(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%"]);
|
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", () => {
|
test("wildcard value", () => {
|
||||||
const result = buildFilterConditions({ metadata_key: "*" }, 1);
|
const result = buildFilterConditions({ metadata_key: "*" }, 1);
|
||||||
expect(result.conditions).toHaveLength(1);
|
expect(result.conditions).toHaveLength(1);
|
||||||
@@ -216,4 +226,19 @@ describe("buildFilterConditions", () => {
|
|||||||
expect(result.conditions[2]).toContain("$5");
|
expect(result.conditions[2]).toContain("$5");
|
||||||
expect(result.paramIndex).toBe(6);
|
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"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,8 +91,9 @@ def _build_filter_conditions(filters):
|
|||||||
conditions.append(template)
|
conditions.append(template)
|
||||||
params.extend([key, str_list])
|
params.extend([key, str_list])
|
||||||
elif op in ("contains", "icontains"):
|
elif op in ("contains", "icontains"):
|
||||||
conditions.append(template)
|
escaped = str(op_value).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
params.extend([key, f"%{op_value}%"])
|
conditions.append(template + " ESCAPE '\\'")
|
||||||
|
params.extend([key, f"%{escaped}%"])
|
||||||
else:
|
else:
|
||||||
conditions.append(template)
|
conditions.append(template)
|
||||||
if is_numeric:
|
if is_numeric:
|
||||||
@@ -104,7 +105,10 @@ def _build_filter_conditions(filters):
|
|||||||
params.extend([key, [str(v) for v in value]])
|
params.extend([key, [str(v) for v in value]])
|
||||||
else:
|
else:
|
||||||
conditions.append("payload->>%s = %s")
|
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
|
return conditions, params
|
||||||
|
|
||||||
|
|||||||
@@ -2317,15 +2317,23 @@ class TestBuildFilterConditions(unittest.TestCase):
|
|||||||
def test_contains_operator(self):
|
def test_contains_operator(self):
|
||||||
conditions, params = _build_filter_conditions({"name": {"contains": "alice"}})
|
conditions, params = _build_filter_conditions({"name": {"contains": "alice"}})
|
||||||
self.assertEqual(len(conditions), 1)
|
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%"])
|
self.assertEqual(params, ["name", "%alice%"])
|
||||||
|
|
||||||
def test_icontains_operator(self):
|
def test_icontains_operator(self):
|
||||||
conditions, params = _build_filter_conditions({"name": {"icontains": "Alice"}})
|
conditions, params = _build_filter_conditions({"name": {"icontains": "Alice"}})
|
||||||
self.assertEqual(len(conditions), 1)
|
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%"])
|
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):
|
def test_wildcard(self):
|
||||||
conditions, params = _build_filter_conditions({"metadata_key": "*"})
|
conditions, params = _build_filter_conditions({"metadata_key": "*"})
|
||||||
self.assertEqual(len(conditions), 1)
|
self.assertEqual(len(conditions), 1)
|
||||||
@@ -2388,3 +2396,15 @@ class TestBuildFilterConditions(unittest.TestCase):
|
|||||||
def test_in_with_numeric_values(self):
|
def test_in_with_numeric_values(self):
|
||||||
conditions, params = _build_filter_conditions({"priority": {"in": [1, 2, 3]}})
|
conditions, params = _build_filter_conditions({"priority": {"in": [1, 2, 3]}})
|
||||||
self.assertEqual(params, ["priority", ["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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user