mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(mongodb): add range and list operators to document filters
This commit is contained in:
@@ -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 |
Reference in New Issue
Block a user