fix(ts-oss/chroma): stop dropping filter conditions in where-clause translation (#6521)
This commit is contained in:
@@ -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" } });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user