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 d2f5b16dee..1b01c32043 100644 --- a/apps/desktop/src/lib/__tests__/app/documentStoreProvider.filter.spec.ts +++ b/apps/desktop/src/lib/__tests__/app/documentStoreProvider.filter.spec.ts @@ -73,6 +73,68 @@ describe("document store structured filters", () => { }); }); + it("matches substrings that are not valid values of the column's own type", () => { + // Regression for #10056: the typed parse ran before the operator was known, so searching a + // JSON/number/date/boolean column for text threw and the Apply click did nothing. + const samples: Array<[string, unknown]> = [ + ["json object", { a: 1, b: "abc" }], + ["number", 42], + ["int64", { $numberLong: "42" }], + ["date", { $date: "2026-01-01T00:00:00Z" }], + ["boolean", true], + ]; + for (const [label, sampleValue] of samples) { + for (const mode of ["like", "not-like", "begins-with", "ends-with"] as const) { + const rule = { id: mode, fieldName: "MessageText", mode, rawValue: "abc", conjunction: "AND" as const }; + expect(() => buildDocumentFilterCondition(rule, { kind: "mongodb", sampleValue }), `${mode} on ${label}`).not.toThrow(); + expect(buildDocumentFilterCondition(rule, { kind: "mongodb", sampleValue }), `${mode} on ${label}`).not.toBeNull(); + } + } + + // Non-string columns are matched through the $convert coercion form: plain $regex only + // matches fields that hold a string, so it silently filtered such columns to 0 rows. + expect(buildDocumentFilterCondition({ id: "contains", fieldName: "MessageText", mode: "like", rawValue: "abc", conjunction: "AND" }, { kind: "mongodb", sampleValue: { a: 1 } })).toEqual({ + $expr: { + $regexMatch: { + input: { $convert: { input: "$MessageText", to: "string", onError: "", onNull: "" } }, + regex: "abc", + options: "i", + }, + }, + }); + expect(buildDocumentFilterCondition({ id: "starts", fieldName: "MessageText", mode: "begins-with", rawValue: "abc", conjunction: "AND" }, { kind: "mongodb", sampleValue: { $date: "2026-01-01T00:00:00Z" } })).toEqual({ + $expr: { + $regexMatch: { + input: { $convert: { input: "$MessageText", to: "string", onError: "", onNull: "" } }, + regex: "^abc", + options: "i", + }, + }, + }); + expect(buildDocumentFilterCondition({ id: "not-contains", fieldName: "MessageText", mode: "not-like", rawValue: "abc", conjunction: "AND" }, { kind: "mongodb", sampleValue: true })).toEqual({ + $expr: { + $not: [ + { + $regexMatch: { + input: { $convert: { input: "$MessageText", to: "string", onError: "", onNull: "" } }, + regex: "abc", + options: "i", + }, + }, + ], + }, + }); + // Solr keeps the plain $regex path even for non-string samples: its driver translates + // anchored $regex into fq clauses but cannot translate $expr. + expect(buildDocumentFilterCondition({ id: "starts", fieldName: "MessageText", mode: "begins-with", rawValue: "abc", conjunction: "AND" }, { kind: "solr", sampleValue: 42 })).toEqual({ + MessageText: { $regex: "^abc", $options: "i" }, + }); + + // Operators that genuinely need a typed value still reject text that is not one. + expect(() => buildDocumentFilterCondition({ id: "eq", fieldName: "age", mode: "equals", rawValue: "abc", conjunction: "AND" }, { kind: "mongodb", sampleValue: 28 })).toThrow(/number/); + expect(() => buildDocumentFilterCondition({ id: "gt", fieldName: "age", mode: "greater-than", rawValue: "abc", conjunction: "AND" }, { kind: "mongodb", sampleValue: 28 })).toThrow(/number/); + }); + 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 d06cb233f6..bd1b942536 100644 --- a/apps/desktop/src/lib/app/documentStoreProvider.ts +++ b/apps/desktop/src/lib/app/documentStoreProvider.ts @@ -542,40 +542,45 @@ export function buildDocumentFilterCondition(rule: DocumentFilterRule, options: // Range and list predicates parse their raw input inside their own case, so the // single-value parse must not run for them (it would reject "18, 30" as a number). const parsesSingleValue = documentFilterModeNeedsValue(rule.mode) && !documentFilterModeUsesList(rule.mode) && !documentFilterModeUsesRange(rule.mode); - const value = parsesSingleValue ? parseDocumentFilterValue(rule.rawValue, { ...options, valueType: rule.valueType }) : null; + // Substring predicates match against the text the user typed, so the typed parse stays lazy: + // coercing "abc" to the column's own type (number, date, boolean, or a JSON object) throws, + // and that error reached the filter bar as an Apply click that appeared to do nothing. + const typedValue = () => (parsesSingleValue ? parseDocumentFilterValue(rule.rawValue, { ...options, valueType: rule.valueType }) : null); const textValue = parsesSingleValue ? String(parseDocumentFilterValue(rule.rawValue)) : ""; switch (rule.mode) { case "equals": - return { [rule.fieldName]: value }; + return { [rule.fieldName]: typedValue() }; case "not-equals": - return { [rule.fieldName]: { $ne: value } }; + return { [rule.fieldName]: { $ne: typedValue() } }; 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); + if (options.kind === "dynamodb") return { [rule.fieldName]: { $contains: typedValue() } }; + if (options.kind === "mongodb" && mongoFilterValueNeedsTextCoercion(rule.valueType, options.sampleValue)) { + return mongoTextCoercionContainsCondition(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); + if (options.kind === "dynamodb") return { [rule.fieldName]: { $notContains: typedValue() } }; + if (options.kind === "mongodb" && mongoFilterValueNeedsTextCoercion(rule.valueType, options.sampleValue)) { + return mongoTextCoercionContainsCondition(rule.fieldName, textValue, true); } return { [rule.fieldName]: { $not: { $regex: escapeRegexLiteral(textValue), $options: "i" } } }; case "begins-with": case "ends-with": { if (!mongoDocumentFilterKind(options)) return null; const pattern = `${rule.mode === "begins-with" ? "^" : ""}${escapeRegexLiteral(textValue)}${rule.mode === "ends-with" ? "$" : ""}`; - if (mongoFilterValueIsNumeric(rule.valueType, options.sampleValue)) return mongoNumericRegexCondition(rule.fieldName, pattern); + // The coercion form is MongoDB-only: Solr shares this filter shape but translates + // anchored $regex into fq wildcards and cannot translate $expr. + if (options.kind === "mongodb" && mongoFilterValueNeedsTextCoercion(rule.valueType, options.sampleValue)) return mongoTextCoercionRegexCondition(rule.fieldName, pattern); return { [rule.fieldName]: { $regex: pattern, $options: "i" } }; } case "greater-than": - return { [rule.fieldName]: { $gt: value } }; + return { [rule.fieldName]: { $gt: typedValue() } }; case "greater-than-or-equal": - return { [rule.fieldName]: { $gte: value } }; + return { [rule.fieldName]: { $gte: typedValue() } }; case "less-than": - return { [rule.fieldName]: { $lt: value } }; + return { [rule.fieldName]: { $lt: typedValue() } }; case "less-than-or-equal": - return { [rule.fieldName]: { $lte: value } }; + return { [rule.fieldName]: { $lte: typedValue() } }; case "in": case "not-in": { if (!mongoDocumentFilterKind(options)) return null; @@ -660,19 +665,21 @@ function unquoteSingleQuotedListToken(token: string): string { return token; } -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]); +/** Substring filters need the text-coercion form whenever the column does not hold plain + * strings: MongoDB `$regex` only matches a field whose value is itself a string (or a string + * element of an array), so numbers, dates, booleans, ObjectIds and embedded documents would + * otherwise silently match nothing. */ +function mongoFilterValueNeedsTextCoercion(valueType?: DocumentFilterValueType, sampleValue?: unknown): boolean { + if (valueType && valueType !== "auto" && valueType !== "string") return true; + const inferred = inferMongoFilterValueType(sampleValue); + return inferred !== null && inferred !== "string"; } -function mongoNumericContainsCondition(fieldName: string, textValue: string, negate = false): Record { - return mongoNumericRegexCondition(fieldName, escapeRegexLiteral(textValue), negate); +function mongoTextCoercionContainsCondition(fieldName: string, textValue: string, negate = false): Record { + return mongoTextCoercionRegexCondition(fieldName, escapeRegexLiteral(textValue), negate); } -function mongoNumericRegexCondition(fieldName: string, regex: string, negate = false): Record { +function mongoTextCoercionRegexCondition(fieldName: string, regex: string, negate = false): Record { const regexMatch = { $regexMatch: { input: { $convert: { input: `$${fieldName}`, to: "string", onError: "", onNull: "" } }, regex, options: "i" } }; return negate ? { $expr: { $not: [regexMatch] } } : { $expr: regexMatch }; }