From 837ea07cf3865225833fa7d8bfce67ee50c83fca Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 15 Sep 2026 01:13:34 +0800 Subject: [PATCH] fix(mongodb): support numeric contains filters --- .../app/documentStoreProvider.filter.spec.ts | 40 +++++++++++++++++++ .../src/lib/app/documentStoreProvider.ts | 19 +++++++++ 2 files changed, 59 insertions(+) diff --git a/apps/desktop/src/lib/__tests__/app/documentStoreProvider.filter.spec.ts b/apps/desktop/src/lib/__tests__/app/documentStoreProvider.filter.spec.ts index c961522538..58775ae634 100644 --- a/apps/desktop/src/lib/__tests__/app/documentStoreProvider.filter.spec.ts +++ b/apps/desktop/src/lib/__tests__/app/documentStoreProvider.filter.spec.ts @@ -33,6 +33,46 @@ describe("document store structured filters", () => { expect(() => buildDocumentFilterCondition({ ...baseRule, rawValue: "not-a-number", valueType: "number" }, { kind: "mongodb" })).toThrow("Invalid MongoDB number filter value"); }); + it("matches numeric MongoDB fields by their string representation", () => { + expect(buildDocumentFilterCondition({ id: "contains", fieldName: "age", mode: "like", rawValue: "2", conjunction: "AND" }, { kind: "mongodb", sampleValue: 28 })).toEqual({ + $expr: { + $regexMatch: { + input: { $convert: { input: "$age", to: "string", onError: "", onNull: "" } }, + regex: "2", + options: "i", + }, + }, + }); + expect(buildDocumentFilterCondition({ id: "contains", fieldName: "age", mode: "like", rawValue: "2", valueType: "number", conjunction: "AND" }, { kind: "mongodb" })).toEqual({ + $expr: { + $regexMatch: { + input: { $convert: { input: "$age", to: "string", onError: "", onNull: "" } }, + regex: "2", + options: "i", + }, + }, + }); + expect(buildDocumentFilterCondition({ id: "not-contains", fieldName: "age", mode: "not-like", rawValue: "2", valueType: "number", conjunction: "AND" }, { kind: "mongodb" })).toEqual({ + $expr: { + $not: [ + { + $regexMatch: { + input: { $convert: { input: "$age", to: "string", onError: "", onNull: "" } }, + regex: "2", + options: "i", + }, + }, + ], + }, + }); + }); + + it("keeps MongoDB string contains filters on the field regex path", () => { + expect(buildDocumentFilterCondition({ id: "contains", fieldName: "name", mode: "like", rawValue: "张", conjunction: "AND" }, { kind: "mongodb", sampleValue: "张三" })).toEqual({ + name: { $regex: "张", $options: "i" }, + }); + }); + it("keeps the MongoDB _id sample for automatic type inference", () => { const tree = documentFieldPathTreeFromDocuments([{ _id: "001", name: "Alice" }]); diff --git a/apps/desktop/src/lib/app/documentStoreProvider.ts b/apps/desktop/src/lib/app/documentStoreProvider.ts index 1a053889c4..35d3e447f9 100644 --- a/apps/desktop/src/lib/app/documentStoreProvider.ts +++ b/apps/desktop/src/lib/app/documentStoreProvider.ts @@ -501,9 +501,15 @@ export function buildDocumentFilterCondition(rule: DocumentFilterRule, options: return { [rule.fieldName]: { $ne: value } }; case "like": if (options.kind === "dynamodb") return { [rule.fieldName]: { $contains: value } }; + if (options.kind === "mongodb" && mongoFilterValueIsNumeric(rule.valueType, options.sampleValue)) { + return mongoNumericContainsCondition(rule.fieldName, textValue); + } return { [rule.fieldName]: { $regex: escapeRegexLiteral(textValue), $options: "i" } }; case "not-like": if (options.kind === "dynamodb") return { [rule.fieldName]: { $notContains: value } }; + if (options.kind === "mongodb" && mongoFilterValueIsNumeric(rule.valueType, options.sampleValue)) { + return mongoNumericContainsCondition(rule.fieldName, textValue, true); + } return { [rule.fieldName]: { $not: { $regex: escapeRegexLiteral(textValue), $options: "i" } } }; case "greater-than": return { [rule.fieldName]: { $gt: value } }; @@ -524,6 +530,19 @@ function escapeRegexLiteral(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function mongoFilterValueIsNumeric(valueType?: DocumentFilterValueType, sampleValue?: unknown): boolean { + if (valueType === "number" || valueType === "int32" || valueType === "int64" || valueType === "decimal128") return true; + if (typeof sampleValue === "number") return true; + if (!isPlainRecord(sampleValue)) return false; + const keys = Object.keys(sampleValue); + return keys.length === 1 && ["$numberInt", "$numberLong", "$numberDouble", "$numberDecimal"].includes(keys[0]); +} + +function mongoNumericContainsCondition(fieldName: string, textValue: string, negate = false): Record { + const regexMatch = { $regexMatch: { input: { $convert: { input: `$${fieldName}`, to: "string", onError: "", onNull: "" } }, regex: escapeRegexLiteral(textValue), options: "i" } }; + return negate ? { $expr: { $not: [regexMatch] } } : { $expr: regexMatch }; +} + export function combineDocumentFilterConditions(conditions: Record[], rules: Pick[], arrayObjectParents: Array = []): Record | null { if (conditions.length === 0) return null; const grouped = groupLeadingArrayObjectConditions(conditions, rules, arrayObjectParents);