fix(ts-oss/chroma): stop dropping filter conditions in where-clause translation (#6521)

This commit is contained in:
microbluey
2026-07-23 20:07:19 +05:30
committed by GitHub
parent e6281ab724
commit 19c7bb84a2
2 changed files with 103 additions and 54 deletions
+48
View File
@@ -440,4 +440,52 @@ describe("generateWhereClause", () => {
ChromaDB.generateWhereClause({ $not: [{ age: { gt: 18 } }] }), ChromaDB.generateWhereClause({ $not: [{ age: { gt: 18 } }] }),
).toEqual({ age: { $lte: 18 } }); ).toEqual({ age: { $lte: 18 } });
}); });
// Regression tests for the three where-clause translation bugs fixed in
// Python by #6452 and tracked for the TS SDK in #6513. ChromaDB allows
// exactly one operator or field per dict level.
it("keeps both bounds of a same-field range as $and-combined clauses", () => {
expect(ChromaDB.generateWhereClause({ age: { gte: 18, lte: 65 } })).toEqual(
{ $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] },
);
});
it("keeps same-field ranges inside $or branches", () => {
expect(
ChromaDB.generateWhereClause({
$or: [{ age: { gte: 18, lte: 65 } }, { vip: true }],
}),
).toEqual({
$or: [
{ $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] },
{ vip: { $eq: true } },
],
});
});
it("wraps multi-field conditions inside $or in $and", () => {
expect(
ChromaDB.generateWhereClause({
$or: [{ age: { gte: 18 }, vip: true }, { city: "sh" }],
}),
).toEqual({
$or: [
{ $and: [{ age: { $gte: 18 } }, { vip: { $eq: true } }] },
{ city: { $eq: "sh" } },
],
});
});
it("negates $not contains/icontains instead of dropping the clause", () => {
expect(
ChromaDB.generateWhereClause({
$not: [{ title: { contains: "draft" } }],
}),
).toEqual({ title: { $ne: "draft" } });
expect(
ChromaDB.generateWhereClause({
$not: [{ title: { icontains: "draft" } }],
}),
).toEqual({ title: { $ne: "draft" } });
});
}); });
+55 -54
View File
@@ -273,14 +273,14 @@ export class ChromaDB implements VectorStore {
private static convertCondition( private static convertCondition(
key: string, key: string,
value: any, value: any,
): Record<string, any> | null { ): Array<Record<string, any>> {
// Wildcard - ChromaDB has no direct wildcard, so skip this filter. // Wildcard - ChromaDB has no direct wildcard, so skip this filter.
if (value === "*") { if (value === "*") {
return null; return [];
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
return { [key]: { $in: value } }; return [{ [key]: { $in: value } }];
} }
if (value !== null && typeof value === "object") { if (value !== null && typeof value === "object") {
@@ -294,19 +294,31 @@ export class ChromaDB implements VectorStore {
in: "$in", in: "$in",
nin: "$nin", nin: "$nin",
}; };
const condition: Record<string, any> = {}; // ChromaDB allows exactly one operator per field expression, so each
for (const [op, val] of Object.entries(value)) { // operator becomes its own clause (combined with $and by the caller).
if (op in opMap) { // Previously each operator overwrote the last, silently dropping range
condition[key] = { [opMap[op]]: val }; // bounds. contains/icontains and unknown operators fall back to
} else { // equality.
// contains/icontains and unknown operators fall back to equality. return Object.entries(value).map(([op, val]) => ({
condition[key] = { $eq: val }; [key]: { [opMap[op] ?? "$eq"]: val },
} }));
}
return condition;
} }
return { [key]: { $eq: value } }; return [{ [key]: { $eq: value } }];
}
/** Combine clauses under a logical operator, unwrapping singletons. */
private static combineClauses(
clauses: Array<Record<string, any>>,
operator: "$and" | "$or",
): Record<string, any> | null {
if (clauses.length === 0) {
return null;
}
if (clauses.length === 1) {
return clauses[0];
}
return { [operator]: clauses };
} }
/** /**
@@ -337,66 +349,55 @@ export class ChromaDB implements VectorStore {
if (key === "$or" || key === "OR") { if (key === "$or" || key === "OR") {
const orConditions: any[] = []; const orConditions: any[] = [];
for (const condition of value as any[]) { for (const condition of value as any[]) {
const built: Record<string, any> = {}; const subClauses: Array<Record<string, any>> = [];
for (const [subKey, subValue] of Object.entries(condition)) { for (const [subKey, subValue] of Object.entries(condition)) {
const converted = ChromaDB.convertCondition(subKey, subValue); subClauses.push(...ChromaDB.convertCondition(subKey, subValue));
if (converted) Object.assign(built, converted);
} }
if (Object.keys(built).length > 0) orConditions.push(built); // Multi-field conditions must be wrapped in $and — ChromaDB rejects
} // flat objects with more than one field per level.
if (orConditions.length > 1) { const combined = ChromaDB.combineClauses(subClauses, "$and");
processed.push({ $or: orConditions }); if (combined) orConditions.push(combined);
} else if (orConditions.length === 1) {
processed.push(orConditions[0]);
} }
const combinedOr = ChromaDB.combineClauses(orConditions, "$or");
if (combinedOr) processed.push(combinedOr);
} else if (key === "$not" || key === "NOT") { } else if (key === "$not" || key === "NOT") {
// De Morgan: NOT(a AND b) is (NOT a) OR (NOT b), so the negated fields // De Morgan: NOT(a AND b) is (NOT a) OR (NOT b), so the negated fields
// within one condition are combined with $or, and separate conditions // within one condition are combined with $or, and separate conditions
// are combined with $and. This mirrors the Python SDK's ChromaDB port. // are combined with $and. This mirrors the Python SDK's ChromaDB port.
const negatedPerGroup: any[] = []; const negatedPerGroup: any[] = [];
for (const condition of value as any[]) { for (const condition of value as any[]) {
const negatedFields: any[] = []; const negatedFields: Array<Record<string, any>> = [];
for (const [subKey, subValue] of Object.entries(condition)) { for (const [subKey, subValue] of Object.entries(condition)) {
if (subValue !== null && typeof subValue === "object") { if (subValue !== null && typeof subValue === "object") {
for (const [op, val] of Object.entries(subValue as any)) { for (const [op, val] of Object.entries(subValue as any)) {
const neg = negateOp[op]; // Unknown operators mirror the positive-path equality
if (neg) { // fallback as inequality (previously they were silently
const converted = ChromaDB.convertCondition(subKey, { // dropped, which could erase the entire NOT clause).
[neg]: val, const neg = negateOp[op] ?? "ne";
}); negatedFields.push(
if (converted) negatedFields.push(converted); ...ChromaDB.convertCondition(subKey, { [neg]: val }),
} );
} }
} else { } else {
const converted = ChromaDB.convertCondition(subKey, { negatedFields.push(
ne: subValue, ...ChromaDB.convertCondition(subKey, { ne: subValue }),
}); );
if (converted) negatedFields.push(converted);
} }
} }
if (negatedFields.length > 1) { const combined = ChromaDB.combineClauses(negatedFields, "$or");
negatedPerGroup.push({ $or: negatedFields }); if (combined) negatedPerGroup.push(combined);
} else if (negatedFields.length === 1) {
negatedPerGroup.push(negatedFields[0]);
}
}
if (negatedPerGroup.length > 1) {
processed.push({ $and: negatedPerGroup });
} else if (negatedPerGroup.length === 1) {
processed.push(negatedPerGroup[0]);
} }
const combinedNot = ChromaDB.combineClauses(negatedPerGroup, "$and");
if (combinedNot) processed.push(combinedNot);
} else { } else {
const converted = ChromaDB.convertCondition(key, value); const combined = ChromaDB.combineClauses(
if (converted) processed.push(converted); ChromaDB.convertCondition(key, value),
"$and",
);
if (combined) processed.push(combined);
} }
} }
if (processed.length === 0) { return ChromaDB.combineClauses(processed, "$and") ?? undefined;
return undefined;
}
if (processed.length === 1) {
return processed[0];
}
return { $and: processed };
} }
} }