feat(mongodb): add range and list operators to document filters

This commit is contained in:
zipg
2026-09-18 22:47:51 +08:00
committed by GitHub
parent ef086865dd
commit 6d8e0d886a
7 changed files with 242 additions and 9 deletions
@@ -39,6 +39,8 @@ import {
searchDocumentFieldPathTree,
documentFilterModeNeedsValue,
documentFilterModeOptionsFor,
documentFilterModeUsesList,
documentFilterModeUsesRange,
documentFilterValueTypeOptions,
documentStoreProviderFor,
elasticsearchBoolClauseOptions,
@@ -893,6 +895,7 @@ function updateDocumentFilterRule(ruleId: string, patch: Partial<DocumentFilterR
} else {
if (patch.fieldName !== undefined && patch.fieldName !== rule.fieldName) next.valueType = "auto";
if (!documentFilterModeNeedsValue(next.mode)) next.rawValue = "";
if (!documentFilterModeUsesRange(next.mode)) next.rawEndValue = "";
}
return next;
});
@@ -3092,8 +3095,39 @@ defineExpose({ focusSearch });
</SelectContent>
</Select>
<div v-if="documentStoreProvider.kind !== 'elasticsearch' && documentFilterModeUsesRange(rule.mode)" class="flex min-w-0 items-center gap-1.5">
<Input
:model-value="rule.rawValue"
class="h-8 min-w-0 flex-1 text-xs"
:placeholder="t('grid.filterBuilderRangeStart')"
@update:model-value="(value) => updateDocumentFilterRule(rule.id, { rawValue: String(value ?? '') })"
@compositionend="endDocumentFilterImeComposition(`value-start:${rule.id}`)"
@compositionstart="startDocumentFilterImeComposition(`value-start:${rule.id}`)"
@keydown="handleDocumentFilterValueKeydown($event, rule.id)"
/>
<span class="shrink-0 text-[10px] text-muted-foreground">—</span>
<Input
:model-value="rule.rawEndValue"
class="h-8 min-w-0 flex-1 text-xs"
:placeholder="t('grid.filterBuilderRangeEnd')"
@update:model-value="(value) => updateDocumentFilterRule(rule.id, { rawEndValue: String(value ?? '') })"
@compositionend="endDocumentFilterImeComposition(`value-end:${rule.id}`)"
@compositionstart="startDocumentFilterImeComposition(`value-end:${rule.id}`)"
@keydown="handleDocumentFilterValueKeydown($event, rule.id)"
/>
</div>
<textarea
v-else-if="documentStoreProvider.kind !== 'elasticsearch' && documentFilterModeUsesList(rule.mode)"
:value="rule.rawValue"
rows="2"
class="min-h-8 w-full min-w-0 resize-y rounded-md border bg-background px-2 py-1 text-xs outline-none"
:placeholder="t('grid.filterBuilderValues')"
@input="updateDocumentFilterRule(rule.id, { rawValue: ($event.target as HTMLTextAreaElement).value })"
@keydown.ctrl.enter.prevent="applyDocumentStructuredFilters"
@keydown.meta.enter.prevent="applyDocumentStructuredFilters"
/>
<Input
v-if="documentStoreProvider.kind === 'elasticsearch' ? elasticsearchQueryTypeNeedsValue(rule.elasticsearchQueryType) : documentFilterModeNeedsValue(rule.mode)"
v-else-if="documentStoreProvider.kind === 'elasticsearch' ? elasticsearchQueryTypeNeedsValue(rule.elasticsearchQueryType) : documentFilterModeNeedsValue(rule.mode)"
:model-value="rule.rawValue"
class="h-8 min-w-0 text-xs"
:placeholder="t('grid.filterBuilderValue')"
@@ -8,7 +8,18 @@ import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/composables/useToast";
import { buildDocumentFilterCondition, currentDocumentFilterJson, currentDocumentSortJson, documentFilterModeNeedsValue, documentFilterModeOptions, documentStoreProviderFor, type DocumentFilterMode, type DocumentFilterRule } from "@/lib/app/documentStoreProvider";
import {
buildDocumentFilterCondition,
currentDocumentFilterJson,
currentDocumentSortJson,
documentFilterModeNeedsValue,
documentFilterModeOptions,
documentFilterModeUsesList,
documentFilterModeUsesRange,
documentStoreProviderFor,
type DocumentFilterMode,
type DocumentFilterRule,
} from "@/lib/app/documentStoreProvider";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import * as api from "@/lib/backend/api";
import { uuid } from "@/lib/common/utils";
@@ -147,6 +158,7 @@ function updateFilterRule(ruleId: string, patch: Partial<DocumentFilterRule>) {
if (rule.id !== ruleId) return rule;
const next = { ...rule, ...patch };
if (!documentFilterModeNeedsValue(next.mode)) next.rawValue = "";
if (!documentFilterModeUsesRange(next.mode)) next.rawEndValue = "";
return next;
});
}
@@ -548,8 +560,23 @@ watch(
</SelectContent>
</Select>
<div v-if="documentFilterModeUsesRange(rule.mode)" class="flex min-w-0 items-center gap-1.5">
<Input :model-value="rule.rawValue" class="h-8 min-w-0 flex-1 text-xs" :placeholder="t('grid.filterBuilderRangeStart')" @update:model-value="(value) => updateFilterRule(rule.id, { rawValue: String(value ?? '') })" @keydown.enter.prevent="applyStructuredFilters" />
<span class="shrink-0 text-[10px] text-muted-foreground">—</span>
<Input :model-value="rule.rawEndValue" class="h-8 min-w-0 flex-1 text-xs" :placeholder="t('grid.filterBuilderRangeEnd')" @update:model-value="(value) => updateFilterRule(rule.id, { rawEndValue: String(value ?? '') })" @keydown.enter.prevent="applyStructuredFilters" />
</div>
<textarea
v-else-if="documentFilterModeUsesList(rule.mode)"
:value="rule.rawValue"
rows="2"
class="min-h-8 w-full min-w-0 resize-y rounded-md border bg-background px-2 py-1 text-xs outline-none"
:placeholder="t('grid.filterBuilderValues')"
@input="updateFilterRule(rule.id, { rawValue: ($event.target as HTMLTextAreaElement).value })"
@keydown.ctrl.enter.prevent="applyStructuredFilters"
@keydown.meta.enter.prevent="applyStructuredFilters"
/>
<Input
v-if="documentFilterModeNeedsValue(rule.mode)"
v-else-if="documentFilterModeNeedsValue(rule.mode)"
:model-value="rule.rawValue"
class="h-8 min-w-0 text-xs"
:placeholder="t('grid.filterBuilderValue')"
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildDocumentFilterCondition, documentFieldPathTreeFromDocuments, documentFilterModeOptions, documentFilterValueTypeOptions } from "@/lib/app/documentStoreProvider";
import { buildDocumentFilterCondition, documentFieldPathTreeFromDocuments, documentFilterModeOptions, documentFilterModeOptionsFor, documentFilterValueTypeOptions } from "@/lib/app/documentStoreProvider";
describe("document store structured filters", () => {
it("offers and builds inclusive comparison filters", () => {
@@ -78,4 +78,64 @@ describe("document store structured filters", () => {
expect(tree[0]).toMatchObject({ path: "_id", sampleValue: "001" });
});
it("offers the PostgreSQL-style operators for MongoDB only", () => {
const mongoModes = documentFilterModeOptionsFor("mongodb").map((option) => option.value);
expect(mongoModes).toEqual(expect.arrayContaining(["begins-with", "ends-with", "in", "not-in", "between", "not-between"]));
for (const kind of ["elasticsearch", "dynamodb", "meilisearch"] as const) {
const modes = documentFilterModeOptionsFor(kind).map((option) => option.value);
for (const mode of ["begins-with", "ends-with", "in", "not-in", "between", "not-between"] as const) {
expect(modes).not.toContain(mode);
}
}
expect(buildDocumentFilterCondition({ id: "starts", fieldName: "name", mode: "begins-with", rawValue: "张", conjunction: "AND" }, { kind: "elasticsearch", sampleValue: "张三" })).toBeNull();
});
it("builds anchored prefix and suffix filters", () => {
expect(buildDocumentFilterCondition({ id: "starts", fieldName: "name", mode: "begins-with", rawValue: "张", conjunction: "AND" }, { kind: "mongodb", sampleValue: "张三" })).toEqual({
name: { $regex: "^张", $options: "i" },
});
expect(buildDocumentFilterCondition({ id: "ends", fieldName: "name", mode: "ends-with", rawValue: "三", conjunction: "AND" }, { kind: "mongodb", sampleValue: "张三" })).toEqual({
name: { $regex: "三$", $options: "i" },
});
expect(buildDocumentFilterCondition({ id: "starts", fieldName: "name", mode: "begins-with", rawValue: "a.b", conjunction: "AND" }, { kind: "mongodb", sampleValue: "alice" })).toEqual({
name: { $regex: "^a\\.b", $options: "i" },
});
});
it("matches numeric MongoDB fields for prefix and suffix filters", () => {
expect(buildDocumentFilterCondition({ id: "starts", fieldName: "age", mode: "begins-with", rawValue: "2", conjunction: "AND" }, { kind: "mongodb", sampleValue: 28 })).toEqual({
$expr: {
$regexMatch: {
input: { $convert: { input: "$age", to: "string", onError: "", onNull: "" } },
regex: "^2",
options: "i",
},
},
});
});
it("builds in and not-in filters from comma or newline separated values", () => {
expect(buildDocumentFilterCondition({ id: "in", fieldName: "name", mode: "in", rawValue: "张三, 李四\n王五", conjunction: "AND" }, { kind: "mongodb", sampleValue: "张三" })).toEqual({
name: { $in: ["张三", "李四", "王五"] },
});
expect(buildDocumentFilterCondition({ id: "not-in", fieldName: "age", mode: "not-in", rawValue: "18, 30", valueType: "number", conjunction: "AND" }, { kind: "mongodb" })).toEqual({
age: { $nin: [18, 30] },
});
expect(buildDocumentFilterCondition({ id: "in", fieldName: "name", mode: "in", rawValue: "'a,b'", conjunction: "AND" }, { kind: "mongodb", sampleValue: "a" })).toEqual({
name: { $in: ["a,b"] },
});
expect(buildDocumentFilterCondition({ id: "in", fieldName: "name", mode: "in", rawValue: " , \n ", conjunction: "AND" }, { kind: "mongodb" })).toBeNull();
});
it("builds between and not-between filters", () => {
expect(buildDocumentFilterCondition({ id: "between", fieldName: "age", mode: "between", rawValue: "18", rawEndValue: "30", valueType: "number", conjunction: "AND" }, { kind: "mongodb" })).toEqual({
age: { $gte: 18, $lte: 30 },
});
expect(buildDocumentFilterCondition({ id: "not-between", fieldName: "age", mode: "not-between", rawValue: "18", rawEndValue: "30", valueType: "number", conjunction: "AND" }, { kind: "mongodb" })).toEqual({
$or: [{ age: { $lt: 18 } }, { age: { $gt: 30 } }],
});
expect(buildDocumentFilterCondition({ id: "between", fieldName: "age", mode: "between", rawValue: "18", rawEndValue: "", conjunction: "AND" }, { kind: "mongodb" })).toBeNull();
expect(buildDocumentFilterCondition({ id: "between", fieldName: "age", mode: "between", rawValue: "18", rawEndValue: "30", conjunction: "AND" }, { kind: "mysql" as never })).toBeNull();
});
});
@@ -5,7 +5,7 @@ import { quoteUnquotedObjectKeys } from "@/lib/mongo/mongoShellCommand";
import { formatMongoShellLiteral } from "@/lib/mongo/mongoDocumentValues";
export type DocumentStoreKind = "mongodb" | "dynamodb" | "elasticsearch" | "meilisearch";
export type DocumentFilterMode = "equals" | "not-equals" | "like" | "not-like" | "greater-than" | "greater-than-or-equal" | "less-than" | "less-than-or-equal" | "is-null" | "is-not-null";
export type DocumentFilterMode = "equals" | "not-equals" | "like" | "not-like" | "begins-with" | "ends-with" | "greater-than" | "greater-than-or-equal" | "less-than" | "less-than-or-equal" | "in" | "not-in" | "between" | "not-between" | "is-null" | "is-not-null";
export type DocumentFilterValueType = "auto" | "string" | "number" | "boolean" | "object-id" | "date" | "int32" | "int64" | "decimal128" | "json";
export type ElasticsearchBoolClause = "filter" | "must" | "should" | "must_not";
export type ElasticsearchQueryType = "term" | "terms" | "match" | "match_phrase" | "wildcard" | "range_gt" | "range_gte" | "range_lt" | "range_lte" | "exists";
@@ -15,6 +15,7 @@ export type DocumentFilterRule = {
fieldName: string;
mode: DocumentFilterMode;
rawValue: string;
rawEndValue?: string;
conjunction: "AND" | "OR";
valueType?: DocumentFilterValueType;
elasticsearchClause?: ElasticsearchBoolClause;
@@ -56,16 +57,31 @@ export const documentFilterModeOptions: Array<{ value: DocumentFilterMode; label
{ value: "not-equals", labelKey: "grid.filterBuilderNotEquals" },
{ value: "like", labelKey: "grid.filterBuilderContains" },
{ value: "not-like", labelKey: "grid.filterBuilderNotContains" },
{ value: "begins-with", labelKey: "grid.filterBuilderBeginsWith" },
{ value: "ends-with", labelKey: "grid.filterBuilderEndsWith" },
{ value: "greater-than", labelKey: "grid.filterBuilderGreaterThan" },
{ value: "greater-than-or-equal", labelKey: "grid.filterBuilderGreaterThanOrEqual" },
{ value: "less-than", labelKey: "grid.filterBuilderLessThan" },
{ value: "less-than-or-equal", labelKey: "grid.filterBuilderLessThanOrEqual" },
{ value: "in", labelKey: "grid.filterBuilderIn" },
{ value: "not-in", labelKey: "grid.filterBuilderNotIn" },
{ value: "between", labelKey: "grid.filterBuilderBetween" },
{ value: "not-between", labelKey: "grid.filterBuilderNotBetween" },
{ value: "is-null", labelKey: "grid.filterBuilderIsNull" },
{ value: "is-not-null", labelKey: "grid.filterBuilderIsNotNull" },
];
/**
* Operators that are only expressible in MongoDB filter documents. The other
* document stores keep their previous operator set so their query builders are
* not offered predicates they cannot express.
*/
const MONGO_ONLY_DOCUMENT_FILTER_MODES = new Set<DocumentFilterMode>(["begins-with", "ends-with", "in", "not-in", "between", "not-between"]);
export function documentFilterModeOptionsFor(kind: DocumentStoreKind): Array<{ value: DocumentFilterMode; labelKey: string }> {
return kind === "meilisearch" ? documentFilterModeOptions.filter((option) => option.value !== "like" && option.value !== "not-like") : documentFilterModeOptions;
if (kind === "meilisearch") return documentFilterModeOptions.filter((option) => option.value !== "like" && option.value !== "not-like" && !MONGO_ONLY_DOCUMENT_FILTER_MODES.has(option.value));
if (kind === "mongodb") return documentFilterModeOptions;
return documentFilterModeOptions.filter((option) => !MONGO_ONLY_DOCUMENT_FILTER_MODES.has(option.value));
}
export const documentFilterValueTypeOptions: Array<{ value: DocumentFilterValueType; labelKey: string }> = [
@@ -190,6 +206,7 @@ export function defaultDocumentFilterRule(id: string, fieldName = ""): DocumentF
fieldName,
mode: "equals",
rawValue: "",
rawEndValue: "",
conjunction: "AND",
valueType: "auto",
elasticsearchClause: "filter",
@@ -201,6 +218,16 @@ export function documentFilterModeNeedsValue(mode: DocumentFilterMode): boolean
return mode !== "is-null" && mode !== "is-not-null";
}
/** List predicates take a comma/newline separated value list. */
export function documentFilterModeUsesList(mode: DocumentFilterMode): boolean {
return mode === "in" || mode === "not-in";
}
/** Range predicates take a start and an end value. */
export function documentFilterModeUsesRange(mode: DocumentFilterMode): boolean {
return mode === "between" || mode === "not-between";
}
type DocumentFieldPathAccumulatorNode = {
key: string;
path: string;
@@ -492,8 +519,12 @@ type DocumentFilterParseOptions = {
export function buildDocumentFilterCondition(rule: DocumentFilterRule, options: DocumentFilterParseOptions = {}): Record<string, unknown> | null {
if (!rule.fieldName) return null;
if (documentFilterModeNeedsValue(rule.mode) && !rule.rawValue.trim()) return null;
const value = documentFilterModeNeedsValue(rule.mode) ? parseDocumentFilterValue(rule.rawValue, { ...options, valueType: rule.valueType }) : null;
const textValue = documentFilterModeNeedsValue(rule.mode) ? String(parseDocumentFilterValue(rule.rawValue)) : "";
if (documentFilterModeUsesRange(rule.mode) && !(rule.rawEndValue ?? "").trim()) return null;
// 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;
const textValue = parsesSingleValue ? String(parseDocumentFilterValue(rule.rawValue)) : "";
switch (rule.mode) {
case "equals":
return { [rule.fieldName]: value };
@@ -511,6 +542,13 @@ export function buildDocumentFilterCondition(rule: DocumentFilterRule, options:
return mongoNumericContainsCondition(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);
return { [rule.fieldName]: { $regex: pattern, $options: "i" } };
}
case "greater-than":
return { [rule.fieldName]: { $gt: value } };
case "greater-than-or-equal":
@@ -519,6 +557,27 @@ export function buildDocumentFilterCondition(rule: DocumentFilterRule, options:
return { [rule.fieldName]: { $lt: value } };
case "less-than-or-equal":
return { [rule.fieldName]: { $lte: value } };
case "in":
case "not-in": {
if (!mongoDocumentFilterKind(options)) return null;
const values = parseDocumentFilterValueList(rule.rawValue, { ...options, valueType: rule.valueType });
if (values.length === 0) return null;
return { [rule.fieldName]: rule.mode === "in" ? { $in: values } : { $nin: values } };
}
case "between": {
if (!mongoDocumentFilterKind(options)) return null;
const start = parseDocumentFilterValue(rule.rawValue, { ...options, valueType: rule.valueType });
const end = parseDocumentFilterValue(rule.rawEndValue ?? "", { ...options, valueType: rule.valueType });
return { [rule.fieldName]: { $gte: start, $lte: end } };
}
case "not-between": {
if (!mongoDocumentFilterKind(options)) return null;
// "Outside range" is the complement of [start, end]; `$not` cannot wrap a
// multi-operator predicate, so the two open-ended bounds are OR-ed.
const start = parseDocumentFilterValue(rule.rawValue, { ...options, valueType: rule.valueType });
const end = parseDocumentFilterValue(rule.rawEndValue ?? "", { ...options, valueType: rule.valueType });
return { $or: [{ [rule.fieldName]: { $lt: start } }, { [rule.fieldName]: { $gt: end } }] };
}
case "is-null":
return { [rule.fieldName]: null };
case "is-not-null":
@@ -530,6 +589,55 @@ function escapeRegexLiteral(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function mongoDocumentFilterKind(options: DocumentFilterParseOptions): boolean {
return options.kind === undefined || options.kind === "mongodb";
}
/** Splits a comma/newline separated filter value list, keeping quoted values intact. */
function parseDocumentFilterValueList(raw: string, options: DocumentFilterParseOptions): unknown[] {
const tokens: string[] = [];
let current = "";
let quote: "'" | '"' | null = null;
for (let index = 0; index < raw.length; index += 1) {
const char = raw[index] ?? "";
const next = raw[index + 1];
if (quote) {
current += char;
if (char === quote) {
if (next === quote) {
current += next;
index += 1;
} else {
quote = null;
}
}
continue;
}
if ((char === "'" || char === '"') && current.trim().length === 0) {
quote = char;
current += char;
continue;
}
if (char === "," || char === "\n" || char === "\r") {
tokens.push(current);
current = "";
continue;
}
current += char;
}
tokens.push(current);
return tokens
.map((token) => token.trim())
.filter((token) => token.length > 0)
.map((token) => parseDocumentFilterValue(unquoteSingleQuotedListToken(token), options));
}
/** Mongo shell style single-quoted list values are unquoted; double quotes stay for the JSON parser. */
function unquoteSingleQuotedListToken(token: string): string {
if (token.length >= 2 && token.startsWith("'") && token.endsWith("'")) return token.slice(1, -1).replace(/''/g, "'");
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;
@@ -539,7 +647,11 @@ function mongoFilterValueIsNumeric(valueType?: DocumentFilterValueType, sampleVa
}
function mongoNumericContainsCondition(fieldName: string, textValue: string, negate = false): Record<string, unknown> {
const regexMatch = { $regexMatch: { input: { $convert: { input: `$${fieldName}`, to: "string", onError: "", onNull: "" } }, regex: escapeRegexLiteral(textValue), options: "i" } };
return mongoNumericRegexCondition(fieldName, escapeRegexLiteral(textValue), negate);
}
function mongoNumericRegexCondition(fieldName: string, regex: string, negate = false): Record<string, unknown> {
const regexMatch = { $regexMatch: { input: { $convert: { input: `$${fieldName}`, to: "string", onError: "", onNull: "" } }, regex, options: "i" } };
return negate ? { $expr: { $not: [regexMatch] } } : { $expr: regexMatch };
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB