diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index bdd07e27c3..b9176f3492 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -1,8 +1,8 @@ @@ -1248,7 +1452,7 @@ function resetTableSearchSplitWidth() { > void }"> - + - updateDocumentFilterRule(rule.id, { fieldName: String(value) })"> + setDocumentFilterFieldPopoverOpen(rule.id, open)"> + + + {{ documentFilterFieldLabel(rule.fieldName) || t("grid.filterBuilderColumn") }} + + + + + {{ t("grid.filterBuilderColumn") }} + + + + + + + + + + + + {{ documentFilterFieldSearchActive(rule.id) ? field.displayPath : field.label }} + {{ documentFilterFieldKindLabel(field.kind) }} + + + + {{ t("grid.noSearchResults") }} + + + + + + updateDocumentFilterRule(rule.id, { fieldName: String(value) })"> {{ rule.fieldName }}{{ elasticsearchFieldTypes.get(rule.fieldName) ? ` (${elasticsearchFieldTypes.get(rule.fieldName)})` : "" }} @@ -1404,11 +1645,26 @@ function resetTableSearchSplitWidth() { - {{ documentStoreProvider.filterInputLabel }} - + {{ documentStoreProvider.filterInputLabel }} + + + + - - {{ documentStoreProvider.sortInputLabel }} - + + {{ documentStoreProvider.sortInputLabel }} + + + + + + + + + + {{ documentSearchStatus }} + + + + + + + + + + + {{ t("mongo.jsonReplaceHint") }} @@ -1503,7 +1791,7 @@ function resetTableSearchSplitWidth() { - + @@ -1520,6 +1808,15 @@ function resetTableSearchSplitWidth() { diff --git a/apps/desktop/src/components/document/JsonEditNode.vue b/apps/desktop/src/components/document/JsonEditNode.vue index 74de4bc007..4fc714ff63 100644 --- a/apps/desktop/src/components/document/JsonEditNode.vue +++ b/apps/desktop/src/components/document/JsonEditNode.vue @@ -4,6 +4,7 @@ import { uuid } from "@/lib/common/utils"; import { useI18n } from "vue-i18n"; import { Plus, Trash2 } from "@lucide/vue"; import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import type { EditNode, EditNodeKind } from "@/types/editor"; @@ -30,6 +31,7 @@ const { t } = useI18n(); const isContainer = computed(() => props.node.kind !== "value"); const showChildDeleteConfirm = ref(false); const pendingChildDeleteIdx = ref(null); +const emptyArrayItemType = ref<"object" | "string" | "number" | "boolean" | "null" | "array">("object"); const childKeyWidth = computed(() => { const longest = props.node.children.reduce((max, child) => { @@ -66,11 +68,11 @@ function fieldValueTone(value: string): string { return "is-string"; } -function createBlankNode(keyName: string, readonlyKey: boolean): EditNode { +function createBlankNode(keyName: string, readonlyKey: boolean, kind: EditNodeKind = "value"): EditNode { return { key: uuid(), keyName, - kind: "value", + kind, valueText: "", readonlyKey, readonlyValue: false, @@ -78,9 +80,32 @@ function createBlankNode(keyName: string, readonlyKey: boolean): EditNode { }; } +function createEmptyArrayItem(keyName: string): EditNode { + switch (emptyArrayItemType.value) { + case "object": + case "array": + return createBlankNode(keyName, true, emptyArrayItemType.value); + case "string": + return { ...createBlankNode(keyName, true), valueText: '""' }; + case "number": + return { ...createBlankNode(keyName, true), valueText: "0" }; + case "boolean": + return { ...createBlankNode(keyName, true), valueText: "false" }; + case "null": + return { ...createBlankNode(keyName, true), valueText: "null" }; + } +} + function addChild() { if (props.node.kind === "array") { - props.node.children.push(createBlankNode(String(props.node.children.length), true)); + const keyName = String(props.node.children.length); + if (props.node.children.some((child) => child.kind === "object")) { + props.node.children.push(createBlankNode(keyName, true, "object")); + } else if (props.node.children.length === 0) { + props.node.children.push(createEmptyArrayItem(keyName)); + } else { + props.node.children.push(createBlankNode(keyName, true)); + } return; } props.node.children.push(createBlankNode("", false)); @@ -139,7 +164,23 @@ function confirmRemoveChild() { - {{ t("mongo.addField") }} + + + + + + + Object + {{ t("sqlParameters.kind.string") }} + {{ t("sqlParameters.kind.number") }} + {{ t("sqlParameters.kind.boolean") }} + {{ t("sqlParameters.kind.null") }} + Array + + + {{ t("mongo.addField") }} + + {{ t("mongo.addField") }} {{ node.kind === "array" ? "]" : "}" }}, @@ -307,6 +348,17 @@ function confirmRemoveChild() { font-family: ui-sans-serif, system-ui, sans-serif; } +.json-edit-add-row { + display: flex; + align-items: center; + gap: 6px; + margin: 6px 0 6px 2ch; +} + +.json-edit-add-row .json-edit-add { + margin: 0; +} + :global(.dark) .json-edit-key { color: #c4b5fd; } diff --git a/apps/desktop/src/components/grid/JsonValueTable.vue b/apps/desktop/src/components/grid/JsonValueTable.vue new file mode 100644 index 0000000000..dd6a33cd28 --- /dev/null +++ b/apps/desktop/src/components/grid/JsonValueTable.vue @@ -0,0 +1,86 @@ + + + + + + + + {{ containerLabel }} + + + + + {{ key }} + + + + + + + + {{ index }} + + + + + + {{ value === null ? "null" : String(value) }} + + + diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 53a8e22f5f..a4ad4f02b6 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -207,6 +207,7 @@ const dataGridSearchMode = computed(() => settingsStore.editorSettings.dataGridS const columnWidthDensity = computed(() => settingsStore.editorSettings.columnWidthDensity); const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize); const redisKeyBrowserRef = ref(); +const documentBrowserRef = ref(); const etcdKeyBrowserRef = ref(); const zookeeperKeyBrowserRef = ref(); @@ -680,6 +681,7 @@ function onHandleCloseColumnPanel() { } function focusSearch(): boolean { + if (props.activeTab.mode === "mongo") return documentBrowserRef.value?.focusSearch() ?? false; if (props.activeTab.mode === "redis") return redisKeyBrowserRef.value?.focusSearch() ?? false; if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.focusSearch() ?? false; if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.focusSearch() ?? false; @@ -1547,7 +1549,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand - + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index eff5fe1721..748e6ea5e8 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1062,6 +1062,7 @@ export default { emptyStringValue: "Empty string", noComment: "No comment", formattedJson: "Formatted JSON", + formatted: "Formatted", formattedValue: "Formatted Value", detailSearchPlaceholder: "Search field or value…", detailSearchNoMatch: "No matches", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 4ca23a2ab1..db92f054d0 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1004,6 +1004,7 @@ export default withEnglishFallback({ nullValue: "NULL", noComment: "Sin comentario", formattedJson: "JSON formateado", + formatted: "Formateado", formattedValue: "Valor formateado", detailSearchPlaceholder: "Buscar campo o valor…", detailSearchNoMatch: "Sin coincidencias", @@ -2462,6 +2463,11 @@ export default withEnglishFallback({ tableView: "Vista de tabla", filterPlaceholder: "Filtro...", sortPlaceholder: "Orden...", + fieldEditor: "Campo", + nativeJson: "JSON nativo", + nativeJsonHint: "Para los valores BSON, use JSON extendido de MongoDB, por ejemplo $oid, $date, $numberLong.", + invalidNativeJson: "Formato JSON nativo no válido: {error}", + nativeDocumentRequired: "El nivel superior del JSON nativo debe ser un objeto documento.", }, vector: { collectionFallback: "colección", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index d435467b4d..dec4d76de6 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1002,6 +1002,7 @@ export default withEnglishFallback({ nullValue: "NULL", noComment: "Nessun commento", formattedJson: "JSON Formattato", + formatted: "Formattato", formattedValue: "Valore Formattato", detailSearchPlaceholder: "Cerca campo o valore…", detailSearchNoMatch: "Nessun risultato", @@ -2460,6 +2461,11 @@ export default withEnglishFallback({ tableView: "Visualizzazione Tabella", filterPlaceholder: "Filtra...", sortPlaceholder: "Ordina...", + fieldEditor: "campo", + nativeJson: "JSON nativo", + nativeJsonHint: "Per i valori BSON, utilizzare MongoDB Extended JSON, ad esempio $oid, $date, $numberLong.", + invalidNativeJson: "Formato JSON nativo non valido: {error}", + nativeDocumentRequired: "Il livello superiore del JSON nativo deve essere un oggetto documento.", }, vector: { collectionFallback: "collezione", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 8b9a505fb8..fd8b7a45fe 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -999,6 +999,7 @@ export default withEnglishFallback({ nullValue: "NULL", noComment: "コメントなし", formattedJson: "フォーマット済みJSON", + formatted: "整形済み", formattedValue: "フォーマット済み値", detailSearchPlaceholder: "フィールドまたは値を検索…", detailSearchNoMatch: "一致なし", @@ -2461,6 +2462,11 @@ export default withEnglishFallback({ tableView: "テーブルビュー", filterPlaceholder: "フィルター...", sortPlaceholder: "並び替え...", + fieldEditor: "フィールド", + nativeJson: "ネイティブJSON", + nativeJsonHint: "BSON 値には MongoDB Extended JSON を使用してください。例: $oid、$date、$numberLong。", + invalidNativeJson: "ネイティブJSONの形式が無効です: {error}", + nativeDocumentRequired: "ネイティブJSONの最上位はドキュメントオブジェクトである必要があります。", }, vector: { collectionFallback: "コレクション", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 740499f67a..61e843c944 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1004,6 +1004,7 @@ export default withEnglishFallback({ nullValue: "NULL", noComment: "Sem comentário", formattedJson: "JSON Formatado", + formatted: "Formatado", formattedValue: "Valor Formatado", detailSearchPlaceholder: "Buscar campo ou valor…", detailSearchNoMatch: "Nenhuma correspondência", @@ -2462,6 +2463,11 @@ export default withEnglishFallback({ tableView: "Visão de tabela", filterPlaceholder: "Filtrar...", sortPlaceholder: "Ordenar...", + fieldEditor: "Campo", + nativeJson: "JSON nativo", + nativeJsonHint: "Para valores BSON, use MongoDB Extended JSON, por exemplo $oid, $date, $numberLong.", + invalidNativeJson: "Formato de JSON nativo inválido: {error}", + nativeDocumentRequired: "O objeto de nível superior do JSON nativo deve ser um documento.", }, vector: { collectionFallback: "coleção", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 0d01710dc3..db0f7a3bba 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1064,6 +1064,7 @@ export default withEnglishFallback({ emptyStringValue: "空字符串", noComment: "暂无注释", formattedJson: "格式化 JSON", + formatted: "格式化", formattedValue: "格式化值", detailSearchPlaceholder: "搜索字段名或值…", detailSearchNoMatch: "无匹配项", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index c416c875ff..1f53c6be7f 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1004,6 +1004,7 @@ export default withEnglishFallback({ nullValue: "NULL", noComment: "暫無註解", formattedJson: "格式化 JSON", + formatted: "格式化", formattedValue: "格式化值", detailSearchPlaceholder: "搜尋欄位名或值…", detailSearchNoMatch: "無相符項目", @@ -2279,6 +2280,11 @@ export default withEnglishFallback({ tableView: "表格檢視", filterPlaceholder: "過濾條件……", sortPlaceholder: "排序條件……", + fieldEditor: "欄位", + nativeJson: "原生 JSON", + nativeJsonHint: "BSON 值請使用 MongoDB Extended JSON,例如 $oid、$date、$numberLong。", + invalidNativeJson: "原生 JSON 格式無效:{error}", + nativeDocumentRequired: "原生 JSON 的最上層必須是文件物件。", }, vector: { collectionFallback: "集合", diff --git a/apps/desktop/src/lib/app/documentStoreProvider.ts b/apps/desktop/src/lib/app/documentStoreProvider.ts index d9406f1cb3..de858c89f9 100644 --- a/apps/desktop/src/lib/app/documentStoreProvider.ts +++ b/apps/desktop/src/lib/app/documentStoreProvider.ts @@ -18,6 +18,19 @@ export type DocumentFilterRule = { elasticsearchQueryType?: ElasticsearchQueryType; }; +export type DocumentFieldPathKind = "scalar" | "object" | "array" | "array-object" | "mixed"; + +export type DocumentFieldPathNode = { + key: string; + path: string; + label: string; + displayPath: string; + kind: DocumentFieldPathKind; + selectable: boolean; + sampleValue?: unknown; + children: DocumentFieldPathNode[]; +}; + export type DocumentStoreQueryPreviewOptions = { collection: string; filterJson?: string; @@ -119,6 +132,152 @@ export function documentFilterModeNeedsValue(mode: DocumentFilterMode): boolean return mode !== "is-null" && mode !== "is-not-null"; } +type DocumentFieldPathAccumulatorNode = { + key: string; + path: string; + kind: DocumentFieldPathKind; + sampleValue?: unknown; + children: DocumentFieldPathAccumulatorNode[]; + childByKey: Map; +}; + +export function documentFieldPathOptionsFromDocuments(documents: readonly Record[]): string[] { + return flattenDocumentFieldPathTree(documentFieldPathTreeFromDocuments(documents)).map((node) => node.path); +} + +export function documentFieldPathTreeFromDocuments(documents: readonly Record[]): DocumentFieldPathNode[] { + if (documents.length === 0) return []; + const rootNodes: DocumentFieldPathAccumulatorNode[] = []; + const rootByKey = new Map(); + ensureDocumentFieldPathNode(rootNodes, rootByKey, "_id", "_id", "scalar"); + + for (const doc of documents) { + for (const [key, value] of Object.entries(doc)) { + if (key === "_id") continue; + collectDocumentFieldPathNode(rootNodes, rootByKey, key, value); + } + } + return finalizeDocumentFieldPathNodes(rootNodes); +} + +export function flattenDocumentFieldPathTree(nodes: readonly DocumentFieldPathNode[]): DocumentFieldPathNode[] { + const flattened: DocumentFieldPathNode[] = []; + for (const node of nodes) { + flattened.push(node); + flattened.push(...flattenDocumentFieldPathTree(node.children)); + } + return flattened; +} + +export function searchDocumentFieldPathTree(nodes: readonly DocumentFieldPathNode[], query: string): DocumentFieldPathNode[] { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return flattenDocumentFieldPathTree(nodes); + return flattenDocumentFieldPathTree(nodes).filter((node) => { + return node.path.toLowerCase().includes(normalizedQuery) || node.displayPath.toLowerCase().includes(normalizedQuery) || node.label.toLowerCase().includes(normalizedQuery); + }); +} + +export function arrayObjectAncestorPathForDocumentField(nodes: readonly DocumentFieldPathNode[], path: string): string | null { + for (const node of nodes) { + if (node.path === path) return null; + if (path.startsWith(`${node.path}.`)) { + if (node.kind === "array-object") return node.path; + return arrayObjectAncestorPathForDocumentField(node.children, path); + } + } + return null; +} + +function collectDocumentFieldPathNode(nodes: DocumentFieldPathAccumulatorNode[], byKey: Map, path: string, value: unknown, depth = 0): void { + const key = path.split(".").pop() || path; + const node = ensureDocumentFieldPathNode(nodes, byKey, key, path, documentFieldPathKindFromValue(value)); + if (node.sampleValue === undefined) node.sampleValue = value; + if (depth >= 6) return; + if (isBsonExtendedJsonWrapper(value)) return; + if (Array.isArray(value)) { + collectArrayDocumentFieldPathNodes(node, value, depth + 1); + return; + } + if (isPlainRecord(value)) collectNestedDocumentFieldPathNodes(node, value, depth + 1); +} + +function collectArrayDocumentFieldPathNodes(parent: DocumentFieldPathAccumulatorNode, values: readonly unknown[], depth: number): void { + if (depth > 6) return; + for (const value of values) { + if (Array.isArray(value)) collectArrayDocumentFieldPathNodes(parent, value, depth + 1); + else if (isPlainRecord(value) && !isBsonExtendedJsonWrapper(value)) collectNestedDocumentFieldPathNodes(parent, value, depth); + } +} + +function collectNestedDocumentFieldPathNodes(parent: DocumentFieldPathAccumulatorNode, value: Record, depth: number): void { + for (const [key, nestedValue] of Object.entries(value)) { + collectDocumentFieldPathNode(parent.children, parent.childByKey, `${parent.path}.${key}`, nestedValue, depth); + } +} + +function ensureDocumentFieldPathNode(nodes: DocumentFieldPathAccumulatorNode[], byKey: Map, key: string, path: string, kind: DocumentFieldPathKind): DocumentFieldPathAccumulatorNode { + const existing = byKey.get(key); + if (existing) { + existing.kind = mergeDocumentFieldPathKind(existing.kind, kind); + return existing; + } + const node: DocumentFieldPathAccumulatorNode = { + key, + path, + kind, + children: [], + childByKey: new Map(), + }; + byKey.set(key, node); + nodes.push(node); + return node; +} + +function documentFieldPathKindFromValue(value: unknown): DocumentFieldPathKind { + if (isBsonExtendedJsonWrapper(value)) return "scalar"; + if (Array.isArray(value)) return arrayContainsPlainRecord(value) ? "array-object" : "array"; + if (isPlainRecord(value)) return "object"; + return "scalar"; +} + +const BSON_EXTENDED_JSON_SINGLE_KEY_WRAPPERS = new Set(["$oid", "$numberInt", "$numberLong", "$numberDouble", "$numberDecimal", "$date", "$timestamp", "$binary", "$regularExpression", "$code", "$symbol", "$undefined", "$minKey", "$maxKey", "$dbPointer"]); + +function isBsonExtendedJsonWrapper(value: unknown): value is Record { + if (!isPlainRecord(value)) return false; + const keys = Object.keys(value); + if (keys.length === 0) return false; + if (keys.length === 1) return BSON_EXTENDED_JSON_SINGLE_KEY_WRAPPERS.has(keys[0]); + return (keys.length === 2 && keys.includes("$binary") && keys.includes("$type")) || (keys.length === 2 && keys.includes("$code") && keys.includes("$scope")) || (keys.length === 2 && keys.includes("$dbPointer") && keys.includes("$ref")); +} + +function arrayContainsPlainRecord(values: readonly unknown[]): boolean { + return values.some((value) => (isPlainRecord(value) && !isBsonExtendedJsonWrapper(value)) || (Array.isArray(value) && arrayContainsPlainRecord(value))); +} + +function mergeDocumentFieldPathKind(current: DocumentFieldPathKind, next: DocumentFieldPathKind): DocumentFieldPathKind { + if (current === next) return current; + if (current === "mixed" || next === "mixed") return "mixed"; + if ((current === "array-object" && next === "array") || (current === "array" && next === "array-object")) return "array-object"; + return "mixed"; +} + +function finalizeDocumentFieldPathNodes(nodes: readonly DocumentFieldPathAccumulatorNode[], parentDisplaySegments: readonly string[] = []): DocumentFieldPathNode[] { + return nodes.map((node) => { + const label = node.kind === "array" || node.kind === "array-object" ? `${node.key}[]` : node.key; + const displaySegments = [...parentDisplaySegments, label]; + return { + key: node.key, + path: node.path, + label, + displayPath: displaySegments.join(" > "), + kind: node.kind, + selectable: true, + sampleValue: node.sampleValue, + children: finalizeDocumentFieldPathNodes(node.children, displaySegments), + }; + }); +} + export function elasticsearchQueryTypeNeedsValue(queryType: ElasticsearchQueryType | undefined): boolean { return queryType !== "exists"; } @@ -183,6 +342,7 @@ export function elasticsearchStructuredFilter(query: Record | n type DocumentFilterParseOptions = { kind?: DocumentStoreKind; + sampleValue?: unknown; }; export function buildDocumentFilterCondition(rule: DocumentFilterRule, options: DocumentFilterParseOptions = {}): Record | null { @@ -196,9 +356,9 @@ export function buildDocumentFilterCondition(rule: DocumentFilterRule, options: case "not-equals": return { [rule.fieldName]: { $ne: value } }; case "like": - return { [rule.fieldName]: { $regex: textValue, $options: "i" } }; + return { [rule.fieldName]: { $regex: escapeRegexLiteral(textValue), $options: "i" } }; case "not-like": - return { [rule.fieldName]: { $not: { $regex: textValue, $options: "i" } } }; + return { [rule.fieldName]: { $not: { $regex: escapeRegexLiteral(textValue), $options: "i" } } }; case "greater-than": return { [rule.fieldName]: { $gt: value } }; case "less-than": @@ -210,16 +370,70 @@ export function buildDocumentFilterCondition(rule: DocumentFilterRule, options: } } -export function combineDocumentFilterConditions(conditions: Record[], rules: Pick[]): Record | null { +function escapeRegexLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function combineDocumentFilterConditions(conditions: Record[], rules: Pick[], arrayObjectParents: Array = []): Record | null { if (conditions.length === 0) return null; - let result = conditions[0]; - for (let i = 1; i < conditions.length; i++) { - const operator = rules[i]?.conjunction === "OR" ? "$or" : "$and"; - result = { [operator]: [result, conditions[i]] }; + const grouped = groupLeadingArrayObjectConditions(conditions, rules, arrayObjectParents); + let result = grouped.conditions[0]; + for (let i = 1; i < grouped.conditions.length; i++) { + const operator = grouped.rules[i]?.conjunction === "OR" ? "$or" : "$and"; + result = { [operator]: [result, grouped.conditions[i]] }; } return result; } +function groupLeadingArrayObjectConditions(conditions: Record[], rules: Pick[], arrayObjectParents: Array): { conditions: Record[]; rules: Pick[] } { + const groupedConditions: Record[] = []; + const groupedRules: Pick[] = []; + let index = 0; + + // A later AND after an OR is part of the left-associated expression produced below, + // so only the prefix before the first OR can be safely collapsed into $elemMatch. + while (index < conditions.length && (index === 0 || rules[index]?.conjunction !== "OR")) { + const parent = arrayObjectParents[index]; + if (!parent) { + groupedConditions.push(conditions[index]); + groupedRules.push(rules[index]); + index++; + continue; + } + + const elementConditions: Record[] = []; + let end = index; + while (end < conditions.length && (end === index || rules[end]?.conjunction === "AND") && arrayObjectParents[end] === parent) { + const relative = relativeArrayObjectCondition(conditions[end], parent); + if (!relative) break; + elementConditions.push(relative); + end++; + } + if (elementConditions.length < 2) { + groupedConditions.push(conditions[index]); + groupedRules.push(rules[index]); + index++; + continue; + } + groupedConditions.push({ [parent]: { $elemMatch: { $and: elementConditions } } }); + groupedRules.push(rules[index]); + index = end; + } + + return { + conditions: [...groupedConditions, ...conditions.slice(index)], + rules: [...groupedRules, ...rules.slice(index)], + }; +} + +function relativeArrayObjectCondition(condition: Record, parent: string): Record | null { + const entries = Object.entries(condition); + if (entries.length !== 1) return null; + const [field, value] = entries[0]; + const prefix = `${parent}.`; + return field.startsWith(prefix) ? { [field.slice(prefix.length)]: value } : null; +} + const MAX_SAFE_BIGINT = 9007199254740991n; const MIN_BSON_INT64 = -9223372036854775808n; const MAX_BSON_INT64 = 9223372036854775807n; @@ -333,16 +547,51 @@ export function currentDocumentSortJson(input: string): string | undefined { return Object.keys(sort).length ? JSON.stringify(sort) : undefined; } +export function formatDocumentQueryInput(input: string, kind?: DocumentStoreKind): string { + const parsed = parseDocumentFilterInput(input, { kind }); + return JSON.stringify(parsed, null, 2); +} + function parseDocumentFilterValue(raw: string, options: DocumentFilterParseOptions = {}): unknown { const trimmed = raw.trim(); if (!trimmed) return ""; try { return parseJsonPreservingLargeIntegers(trimmed, options); } catch { - return trimmed; + return mongoTypedFilterValue(trimmed, options); } } +function mongoTypedFilterValue(raw: string, options: DocumentFilterParseOptions): unknown { + if (options.kind !== "mongodb") return raw; + const sampleValue = mongoTypedFilterSample(options.sampleValue); + if (!sampleValue) return raw; + if (typeof sampleValue.$oid === "string") return { $oid: raw }; + if ("$date" in sampleValue) return { $date: raw }; + if (typeof sampleValue.$numberLong === "string" && /^-?\d+$/.test(raw)) return { $numberLong: raw }; + return raw; +} + +function mongoTypedFilterSample(sampleValue: unknown): Record | null { + if (isPlainRecord(sampleValue)) return sampleValue; + if (!Array.isArray(sampleValue)) return null; + + const samples = sampleValue.filter((value) => value !== null && value !== undefined); + if (!samples.length || samples.some((value) => !isPlainRecord(value))) return null; + const records = samples as Record[]; + const sampleKind = mongoTypedFilterSampleKind(records[0]); + // Mixed arrays are ambiguous, so infer a BSON type only from homogeneous scalar wrappers. + if (!sampleKind || records.some((value) => mongoTypedFilterSampleKind(value) !== sampleKind)) return null; + return records[0]; +} + +function mongoTypedFilterSampleKind(sampleValue: Record): "$oid" | "$date" | "$numberLong" | null { + if (typeof sampleValue.$oid === "string") return "$oid"; + if ("$date" in sampleValue) return "$date"; + if (typeof sampleValue.$numberLong === "string") return "$numberLong"; + return null; +} + export function elasticsearchSearchBodyFromDocumentQuery(options: Pick): Record { const body: Record = { from: options.skip, diff --git a/apps/desktop/src/lib/dataGrid/dataGridDetail.ts b/apps/desktop/src/lib/dataGrid/dataGridDetail.ts index 81148d5d60..427173fdd0 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridDetail.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridDetail.ts @@ -176,12 +176,32 @@ function detailColumnType(typeByColumn: ReadonlyMap | undefined, return resultColumnTypes?.[columnIndex]?.trim() ?? ""; } -export function dataGridRowDetailJson(detail: DataGridRowDetail): string { +export function dataGridRowDetailJson(detail: DataGridRowDetail, originalDocument?: unknown): string { + if (originalDocument !== undefined) return JSON.stringify(jsonDetailDisplayValue(originalDocument), null, 2); const row: Record = {}; detail.fields.forEach((field) => { row[field.column] = field.value; }); - return JSON.stringify(row, null, 2); + return JSON.stringify(jsonDetailDisplayValue(row), null, 2); +} + +export function jsonDetailDisplayValue(value: unknown): unknown { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return jsonDetailDisplayValue(JSON.parse(trimmed)); + } catch { + return value; + } + } + return value; + } + if (Array.isArray(value)) return value.map(jsonDetailDisplayValue); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value as Record).map(([key, item]) => [key, jsonDetailDisplayValue(item)])); + } + return value; } export function dataGridRowDetailTsv(detail: DataGridRowDetail): string { diff --git a/apps/desktop/src/lib/document/documentJsonSearch.ts b/apps/desktop/src/lib/document/documentJsonSearch.ts new file mode 100644 index 0000000000..0d685095f1 --- /dev/null +++ b/apps/desktop/src/lib/document/documentJsonSearch.ts @@ -0,0 +1,98 @@ +export interface DocumentTextMatch { + start: number; + end: number; +} + +type JsonTokenRange = DocumentTextMatch & { className: string }; + +const JSON_TOKEN_PATTERN = /("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g; + +export function findDocumentTextMatches(text: string, query: string): DocumentTextMatch[] { + if (!query) return []; + const haystack = text.toLowerCase(); + const needle = query.toLowerCase(); + const matches: DocumentTextMatch[] = []; + let start = 0; + + while (start <= haystack.length - needle.length) { + const index = haystack.indexOf(needle, start); + if (index < 0) break; + matches.push({ start: index, end: index + needle.length }); + start = index + Math.max(needle.length, 1); + } + + return matches; +} + +export function renderDocumentJsonHtml(json: string, query = "", activeMatchIndex = 0): string { + const tokenRanges = jsonTokenRanges(json); + const searchRanges = findDocumentTextMatches(json, query); + const boundaries = new Set([0, json.length]); + + for (const range of tokenRanges) { + boundaries.add(range.start); + boundaries.add(range.end); + } + for (const range of searchRanges) { + boundaries.add(range.start); + boundaries.add(range.end); + } + + const points = [...boundaries].sort((left, right) => left - right); + let tokenIndex = 0; + let searchIndex = 0; + let html = ""; + + for (let index = 0; index < points.length - 1; index += 1) { + const start = points[index]; + const end = points[index + 1]; + if (end <= start) continue; + + while (tokenRanges[tokenIndex]?.end <= start) tokenIndex += 1; + while (searchRanges[searchIndex]?.end <= start) searchIndex += 1; + + const token = tokenRanges[tokenIndex]; + const search = searchRanges[searchIndex]; + let segment = escapeHtml(json.slice(start, end)); + + if (token && token.start <= start && token.end >= end) { + segment = `${segment}`; + } + if (search && search.start <= start && search.end >= end) { + const activeClass = searchIndex === activeMatchIndex ? " document-search-match-active" : ""; + const activeAttribute = searchIndex === activeMatchIndex ? ' data-document-search-active="true"' : ""; + segment = `${segment}`; + } + + html += segment; + } + + return html; +} + +function jsonTokenRanges(json: string): JsonTokenRange[] { + const ranges: JsonTokenRange[] = []; + const pattern = new RegExp(JSON_TOKEN_PATTERN.source, "g"); + let match: RegExpExecArray | null; + + while ((match = pattern.exec(json))) { + ranges.push({ + start: match.index, + end: match.index + match[0].length, + className: jsonTokenClass(match[0]), + }); + } + + return ranges; +} + +function jsonTokenClass(token: string): string { + if (token.startsWith('"')) return token.endsWith(":") ? "json-key" : "json-string"; + if (token === "true" || token === "false") return "json-boolean"; + if (token === "null") return "json-null"; + return "json-number"; +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">"); +} diff --git a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts index 755e829e0a..4e0bb893f4 100644 --- a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts +++ b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts @@ -1,6 +1,7 @@ export type MongoInputValue = string | number | boolean | null; const MONGO_SHELL_DATE_PATTERN = /^(?:ISODate|new Date)\(\s*(["'])(.+)\1\s*\)$/; +const MONGO_SHELL_NUMBER_LONG_PATTERN = /^NumberLong\(\s*(["'])(-?\d+)\1\s*\)$/; const LEGACY_MONGO_DATE_DISPLAY_PATTERN = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?$/; const MONGO_OBJECT_ID_PATTERN = /^[a-fA-F0-9]{24}$/; const MONGO_INTEGER_PATTERN = /^-?\d+$/; @@ -24,6 +25,8 @@ export function parseMongoDocumentInputValue(raw: MongoInputValue): unknown { const shellDate = mongoShellDateToExtendedJson(trimmed); if (shellDate !== trimmed) return shellDate; + const shellNumberLong = mongoShellNumberLongToExtendedJson(trimmed); + if (shellNumberLong !== trimmed) return shellNumberLong; const legacyDate = legacyMongoDateDisplayToExtendedJson(trimmed); if (legacyDate) return legacyDate; @@ -42,6 +45,14 @@ export function parseMongoDocumentInputValue(raw: MongoInputValue): unknown { return raw; } +export function mongoDocumentDisplayValue(value: unknown): unknown { + if (value && typeof value === "object" && !Array.isArray(value)) { + const object = value as Record; + if (Object.keys(object).length === 1 && typeof object.$numberLong === "string") return `NumberLong(${JSON.stringify(object.$numberLong)})`; + } + return value; +} + function parseMongoExistingFieldInputValue(raw: Exclude, originalValue: unknown): unknown { // Objects and arrays are serialized into grid text too, so the raw document // is the only reliable way to distinguish them from JSON-shaped BSON strings. @@ -63,6 +74,11 @@ function legacyMongoDateDisplayToExtendedJson(value: string): { $date: string } return { $date: `${date}T${time}.${millis.padEnd(3, "0")}Z` }; } +function mongoShellNumberLongToExtendedJson(value: string): unknown { + const match = value.match(MONGO_SHELL_NUMBER_LONG_PATTERN); + return match ? { $numberLong: match[2] } : value; +} + export function buildMongoUpdateDocument(changes: Map, columns: string[], originalDocument?: unknown): Record { const setFields: Record = {}; const unsetFields: Record = {}; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.mq.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.mq.spec.ts index 49a5077c40..d8fb2743e1 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.mq.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.mq.spec.ts @@ -103,7 +103,7 @@ describe("connectionStore MQ sidebar tree", () => { { id: "mq-1:mq-tenant:tenant-a", label: "tenant-a", type: "mq-tenant", tenant: "tenant-a" }, ]); expect(node.isExpanded).toBe(true); - }); + }, 10_000); it("adds a Kafka topics child with a topics initial tab", async () => { vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); diff --git a/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts index 57c47d2196..28868d822e 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts @@ -38,7 +38,7 @@ describe("queryStore database open state", () => { store.closeTab(tabId); expect(store.isDatabaseOpen("pg-1", "analytics")).toBe(false); - }); + }, 10_000); it("keeps object browser viewport per tab and clears it on schema change", async () => { const { useQueryStore } = await import("@/stores/queryStore"); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 3e9f31a3f0..7d105866a2 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1,6 +1,6 @@ import { defineStore } from "pinia"; import { uuid } from "@/lib/common/utils"; -import { markRaw, ref, watch, computed } from "vue"; +import { computed, markRaw, onScopeDispose, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; import type { ConnectionConfig, DatabaseType, ObjectBrowserViewport, QueryResult, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database"; import { orderPinnedFirst } from "@/lib/app/pinnedItems"; @@ -999,6 +999,11 @@ export const useQueryStore = defineStore("query", () => { { flush: "post" }, ); + onScopeDispose(() => { + if (persistTimer) clearTimeout(persistTimer); + persistTimer = null; + }); + // Immediately flush any pending debounced persist so the on-disk content // reflects the latest in-memory tabs without waiting for the 300ms debounce. // Lets callers (e.g. tests that reload the store) read back persisted state diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 1e9ee2e48e..2d5fafb732 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -678,7 +678,7 @@ pub async fn count_documents( } } -/// Find MongoDB documents as relaxed Extended JSON for MongoDB transfer paths. +/// Find MongoDB documents in a browser-friendly representation. #[allow(clippy::too_many_arguments)] pub async fn find_documents_extended_json( client: &Client, @@ -728,7 +728,7 @@ pub async fn find_documents_extended_json( let mut documents = Vec::new(); while cursor.advance().await.map_err(|e| e.to_string())? { let doc = cursor.deserialize_current().map_err(|e| e.to_string())?; - documents.push(Bson::Document(doc).into_relaxed_extjson()); + documents.push(bson_to_browser_json(&Bson::Document(doc))); } Ok(MongoDocumentResult { extended_documents: Some(documents.clone()), documents, raw_documents: None, total }) @@ -1077,7 +1077,7 @@ pub async fn update_document( ) -> Result { let value: serde_json::Value = serde_json::from_str(doc_json).map_err(|e| format!("Invalid JSON: {e}"))?; let col = client.database(database).collection::(collection); - let update_doc = json_object_to_document(&value).map_err(|e| format!("Invalid document: {e}"))?; + let update_doc = json_object_to_document_for_update(&value, None).map_err(|e| format!("Invalid document: {e}"))?; if is_update_operator_document(&update_doc) { for filter in document_id_filters(id) { let result = col.update_one(filter, update_doc.clone()).await.map_err(|e| e.to_string())?; @@ -1090,7 +1090,7 @@ pub async fn update_document( for filter in document_id_filters(id) { let current = col.find_one(filter.clone()).await.map_err(|e| e.to_string())?; - let mut new_doc = json_object_to_document_preserving_existing(&value, current.as_ref()) + let mut new_doc = json_object_to_document_for_update(&value, current.as_ref()) .map_err(|e| format!("Invalid document: {e}"))?; new_doc.remove("_id"); let result = col.replace_one(filter, new_doc.clone()).await.map_err(|e| e.to_string())?; @@ -1468,6 +1468,20 @@ fn bson_document_field_to_json(key: &str, bson: &Bson) -> serde_json::Value { bson_to_json(bson) } +fn bson_to_browser_json(bson: &Bson) -> serde_json::Value { + match bson { + Bson::Int64(value) if !(-9_007_199_254_740_991..=9_007_199_254_740_991).contains(value) => { + serde_json::json!({ "$numberLong": value.to_string() }) + } + Bson::ObjectId(oid) => serde_json::json!({ "$oid": oid.to_hex() }), + Bson::Array(values) => serde_json::Value::Array(values.iter().map(bson_to_browser_json).collect()), + Bson::Document(document) => serde_json::Value::Object( + document.iter().map(|(key, value)| (key.clone(), bson_to_browser_json(value))).collect(), + ), + _ => bson_to_json(bson), + } +} + /// Convert a `serde_json::Value` (JSON object) to a BSON `Document`, /// handling MongoDB extended JSON conventions such as `{"$oid":"..."}`. pub fn json_object_to_document(value: &serde_json::Value) -> Result { @@ -1504,21 +1518,27 @@ fn json_object_to_document_preserving_existing( value: &serde_json::Value, existing: Option<&Document>, ) -> Result { - match (value, existing) { - (serde_json::Value::Object(obj), Some(existing)) => Ok(obj + match value { + serde_json::Value::Object(obj) => obj .iter() .map(|(key, value)| { - let bson = existing - .get(key) - .map(|existing_bson| json_value_to_bson_preserving_existing(value, existing_bson)) - .unwrap_or_else(|| json_value_to_bson(value)); - (key.clone(), bson) + json_value_to_bson_preserving_existing(value, existing.and_then(|document| document.get(key))) + .map(|bson| (key.clone(), bson)) }) - .collect()), + .collect(), _ => json_object_to_document(value), } } +/// Parse document-editor input value by value so mixed browser representations +/// retain their BSON types. +fn json_object_to_document_for_update( + value: &serde_json::Value, + existing: Option<&Document>, +) -> Result { + json_object_to_document_preserving_existing(value, existing) +} + pub fn json_filter_to_document(value: &serde_json::Value) -> Result { match json_filter_value_to_bson(value, None) { Bson::Document(doc) => Ok(doc), @@ -1526,36 +1546,49 @@ pub fn json_filter_to_document(value: &serde_json::Value) -> Result Bson { - if &bson_to_json(existing) == value { - return existing.clone(); +fn json_value_to_bson_preserving_existing(value: &serde_json::Value, existing: Option<&Bson>) -> Result { + if let Some(existing) = existing { + if &bson_to_json(existing) == value { + return Ok(existing.clone()); + } } - match (value, existing) { - (serde_json::Value::Array(values), Bson::Array(existing_values)) => Bson::Array( + match value { + serde_json::Value::Array(values) => { + let existing_values = match existing { + Some(Bson::Array(values)) => Some(values), + _ => None, + }; values .iter() .enumerate() .map(|(index, item)| { - existing_values - .get(index) - .map(|existing_item| json_value_to_bson_preserving_existing(item, existing_item)) - .unwrap_or_else(|| json_value_to_bson(item)) + json_value_to_bson_preserving_existing(item, existing_values.and_then(|values| values.get(index))) }) - .collect(), - ), - (serde_json::Value::Object(obj), Bson::Document(existing_doc)) => Bson::Document( + .collect::, _>>() + .map(Bson::Array) + } + serde_json::Value::Object(obj) => { + if let Some(value) = parse_extended_json_value(obj)? { + return Ok(value); + } + + let existing_document = match existing { + Some(Bson::Document(document)) => Some(document), + _ => None, + }; obj.iter() .map(|(key, item)| { - let bson = existing_doc - .get(key) - .map(|existing_item| json_value_to_bson_preserving_existing(item, existing_item)) - .unwrap_or_else(|| json_value_to_bson(item)); - (key.clone(), bson) + json_value_to_bson_preserving_existing( + item, + existing_document.and_then(|document| document.get(key)), + ) + .map(|bson| (key.clone(), bson)) }) - .collect(), - ), - _ => json_value_to_bson(value), + .collect::>() + .map(Bson::Document) + } + _ => Ok(json_value_to_bson(value)), } } @@ -1577,19 +1610,8 @@ fn json_value_to_bson(value: &serde_json::Value) -> Bson { } serde_json::Value::Array(arr) => Bson::Array(arr.iter().map(json_value_to_bson).collect()), serde_json::Value::Object(obj) => { - // Extended JSON: {"$oid":"..."} → BSON ObjectId - if obj.len() == 1 { - if let Some(serde_json::Value::String(hex)) = obj.get("$oid") { - if let Ok(oid) = ObjectId::parse_str(hex) { - return Bson::ObjectId(oid); - } - } - if let Some(value) = parse_extended_json_int64(obj) { - return Bson::Int64(value); - } - if let Some(date) = parse_extended_json_date(obj) { - return Bson::DateTime(date); - } + if let Ok(Some(value)) = parse_extended_json_value(obj) { + return value; } let doc: Document = obj.iter().map(|(k, v)| (k.clone(), json_value_to_bson(v))).collect(); Bson::Document(doc) @@ -1597,6 +1619,40 @@ fn json_value_to_bson(value: &serde_json::Value) -> Bson { } } +fn parse_extended_json_value(obj: &serde_json::Map) -> Result, String> { + let is_wrapper = match obj.len() { + 1 => obj.keys().next().is_some_and(|key| { + matches!( + key.as_str(), + "$oid" + | "$date" + | "$numberInt" + | "$numberLong" + | "$numberDouble" + | "$numberDecimal" + | "$binary" + | "$regularExpression" + | "$timestamp" + | "$minKey" + | "$maxKey" + | "$undefined" + | "$symbol" + | "$code" + | "$dbPointer" + | "$uuid" + ) + }), + 2 => obj.contains_key("$code") && obj.contains_key("$scope"), + _ => false, + }; + + if !is_wrapper { + return Ok(None); + } + + Bson::try_from(serde_json::Value::Object(obj.clone())).map(Some).map_err(|error| error.to_string()) +} + fn parse_mongo_shell_date(value: &str) -> Option { let trimmed = value.trim(); if let Some(inner) = trimmed.strip_prefix("ISODate(").or_else(|| trimmed.strip_prefix("new Date(")) { @@ -2150,7 +2206,124 @@ mod tests { } #[test] - fn bson_to_extended_json_preserves_nested_object_ids() { + fn browser_json_keeps_normal_scalars_readable_and_unsafe_int64_typed() { + let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap(); + let value = bson_to_browser_json(&Bson::Document(doc! { + "int32": Bson::Int32(42), "double": Bson::Double(3.5), "date": Bson::DateTime(date), + "unsafe": Bson::Int64(2_326_645_729_978_441_729), + })); + assert_eq!(value["int32"], serde_json::json!(42)); + assert_eq!(value["double"], serde_json::json!(3.5)); + assert_eq!(value["date"], serde_json::json!("ISODate(\"2026-06-10T13:59:31.287Z\")")); + assert_eq!(value["unsafe"], serde_json::json!({ "$numberLong": "2326645729978441729" })); + } + + #[test] + fn browser_json_round_trips_normal_scalars_and_unsafe_int64_on_edit() { + let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap(); + let existing = doc! { + "name": "before", + "int32": Bson::Int32(42), + "double": Bson::Double(3.5), + "date": Bson::DateTime(date), + "unsafe": Bson::Int64(2_326_645_729_978_441_729), + }; + let mut edited = bson_to_browser_json(&Bson::Document(existing.clone())); + edited["name"] = serde_json::json!("after"); + let round_tripped = json_object_to_document_for_update(&edited, Some(&existing)).unwrap(); + + assert_eq!(round_tripped.get_str("name").unwrap(), "after"); + assert!(matches!(round_tripped.get("int32"), Some(Bson::Int32(42)))); + assert!(matches!(round_tripped.get("double"), Some(Bson::Double(value)) if *value == 3.5)); + assert!(matches!(round_tripped.get("date"), Some(Bson::DateTime(value)) if *value == date)); + assert!(matches!(round_tripped.get("unsafe"), Some(Bson::Int64(2_326_645_729_978_441_729)))); + } + + #[test] + fn browser_json_round_trips_mixed_bson_types_when_editing_an_unrelated_field() { + let object_id = ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap(); + let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap(); + let existing = doc! { + "_id": Bson::ObjectId(object_id), + "name": "before", + "date": Bson::DateTime(date), + "unsafe": Bson::Int64(2_326_645_729_978_441_729), + }; + let mut edited = bson_to_browser_json(&Bson::Document(existing.clone())); + assert_eq!(edited["_id"], serde_json::json!({ "$oid": "507f1f77bcf86cd799439011" })); + assert_eq!(edited["date"], serde_json::json!("ISODate(\"2026-06-10T13:59:31.287Z\")")); + assert_eq!(edited["unsafe"], serde_json::json!({ "$numberLong": "2326645729978441729" })); + + edited["name"] = serde_json::json!("after"); + let round_tripped = json_object_to_document_for_update(&edited, None).unwrap(); + + assert_eq!(round_tripped.get_str("name").unwrap(), "after"); + assert!(matches!( + round_tripped.get("_id"), + Some(Bson::ObjectId(value)) if *value == object_id + )); + assert!(matches!(round_tripped.get("date"), Some(Bson::DateTime(value)) if *value == date)); + assert!(matches!(round_tripped.get("unsafe"), Some(Bson::Int64(2_326_645_729_978_441_729)))); + } + + #[test] + fn document_update_rejects_invalid_extended_json_wrapper() { + let edited = serde_json::json!({ + "unsafe": { "$numberLong": "not-an-integer" }, + }); + + assert!(json_object_to_document_for_update(&edited, None).is_err()); + } + + #[test] + fn document_update_parses_wrapper_when_replacing_an_existing_document_value() { + let object_id = ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap(); + let existing = doc! { + "value": { "kind": "plain" }, + }; + let edited = serde_json::json!({ + "value": { "$oid": "507f1f77bcf86cd799439011" }, + }); + + let updated = json_object_to_document_for_update(&edited, Some(&existing)).unwrap(); + + assert!(matches!(updated.get("value"), Some(Bson::ObjectId(value)) if *value == object_id)); + } + + #[test] + fn document_update_decodes_uuid_wrapper_per_value() { + let uuid = serde_json::json!({ "$uuid": "00112233-4455-6677-8899-aabbccddeeff" }); + let expected = Bson::try_from(uuid.clone()).unwrap(); + let edited = serde_json::json!({ + "name": "after", + "uuid": uuid, + }); + + let updated = json_object_to_document_for_update(&edited, None).unwrap(); + + assert_eq!(updated.get("uuid"), Some(&expected)); + } + + #[test] + fn browser_json_filters_decode_normal_scalars_and_unsafe_int64() { + let filter = serde_json::json!({ + "int32": 42, + "double": 3.5, + "date": "ISODate(\"2026-06-10T13:59:31.287Z\")", + "unsafe": { "$numberLong": "2326645729978441729" }, + }); + let document = json_filter_to_document(&filter).unwrap(); + + assert!(matches!(document.get("int32"), Some(Bson::Int64(42)))); + assert!(matches!(document.get("double"), Some(Bson::Double(value)) if *value == 3.5)); + assert!( + matches!(document.get("date"), Some(Bson::DateTime(value)) if value.timestamp_millis() == 1_781_099_971_287) + ); + assert!(matches!(document.get("unsafe"), Some(Bson::Int64(2_326_645_729_978_441_729)))); + } + + #[test] + fn canonical_extended_json_preserves_nested_object_ids() { let oid = ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap(); let nested_oid = ObjectId::parse_str("507f191e810c19729de860ea").unwrap(); let value = Bson::Document(doc! { @@ -2158,7 +2331,7 @@ mod tests { "owner": { "id": Bson::ObjectId(nested_oid) }, "tags": [Bson::ObjectId(nested_oid)], }) - .into_relaxed_extjson(); + .into_canonical_extjson(); assert_eq!( value, @@ -2170,6 +2343,19 @@ mod tests { ); } + #[test] + fn canonical_extended_json_round_trips_unsafe_int64() { + let original = doc! { + "counter": Bson::Int64(2_326_645_729_978_441_729), + }; + let extended_json = Bson::Document(original.clone()).into_canonical_extjson(); + + assert_eq!(extended_json, serde_json::json!({ "counter": { "$numberLong": "2326645729978441729" } })); + + let round_tripped = json_object_to_document_extended_json(&extended_json).unwrap(); + assert_eq!(round_tripped, original); + } + #[test] fn index_info_from_model_maps_mongodb_index_metadata() { let model = IndexModel::builder() diff --git a/crates/dbx-core/src/document_ops.rs b/crates/dbx-core/src/document_ops.rs index cf23c30aa4..1a4b9450cc 100644 --- a/crates/dbx-core/src/document_ops.rs +++ b/crates/dbx-core/src/document_ops.rs @@ -367,7 +367,12 @@ pub async fn find_documents_core( let connections = state.connections.read().await; match connections.get(connection_id).ok_or("Not found")? { PoolKind::MongoDb(client) => { - mongo_driver::find_documents(client, database, collection, skip, limit, filter, projection, sort).await + // Document browser responses must retain BSON type metadata so nested filters + // can round-trip ObjectId, Date, and int64 values through Extended JSON. + mongo_driver::find_documents_extended_json( + client, database, collection, skip, limit, filter, projection, sort, + ) + .await } PoolKind::Elasticsearch(client) => { let client = client.clone(); @@ -393,12 +398,23 @@ pub async fn find_documents_core( if let Some(projection) = projection { params["projection"] = serde_json::json!(projection); } - client.mongo_find_documents(params).await + match client.mongo_find_documents_extended_json(params.clone()).await { + Ok(result) => Ok(result), + Err(error) if is_unknown_agent_method_error(&error, "find_documents_extended_json") => { + client.mongo_find_documents(params).await + } + Err(error) => Err(error), + } } _ => Err("Not a MongoDB/Elasticsearch/vector connection".to_string()), } } +fn is_unknown_agent_method_error(error: &str, method: &str) -> bool { + let lower = error.to_ascii_lowercase(); + lower.contains(method) && (lower.contains("unknown method") || lower.contains("method not found")) +} + pub async fn insert_document_core( state: &AppState, connection_id: &str, diff --git a/package.json b/package.json index 3e879a2867..359b5f5a5d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev:tauri": "tauri dev", "dev:web": "vite --config apps/desktop/vite.config.ts --port 5173 --mode web", "dev:backend": "node scripts/dev-backend.mjs", + "dev:full:win": ".\\scripts\\dev-full.bat", "build:packages": "pnpm --filter @dbx-app/mongo-shell build && pnpm --filter @dbx-app/node-core build && pnpm --filter @dbx-app/cli build && pnpm --filter @dbx-app/mcp-server build", "test:packages": "pnpm --filter @dbx-app/node-core test && pnpm --filter @dbx-app/cli test && pnpm --filter @dbx-app/mcp-server test", "pack:packages": "rm -rf /tmp/dbx-pack-check && mkdir -p /tmp/dbx-pack-check && pnpm --filter @dbx-app/node-core pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/cli pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/mcp-server pack --pack-destination /tmp/dbx-pack-check", diff --git a/packages/app-tests/dataGridDetail.test.ts b/packages/app-tests/dataGridDetail.test.ts index a143250a1c..4885600e40 100644 --- a/packages/app-tests/dataGridDetail.test.ts +++ b/packages/app-tests/dataGridDetail.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { buildDataGridCellDetail, buildDataGridColumnDetail, dataGridColumnDetailJson, dataGridColumnDetailTsv, buildDataGridRowDetail, dataGridRowDetailJson, dataGridRowDetailTsv, filterDataGridDetailFields, type DataGridCellDetail } from "../../apps/desktop/src/lib/dataGrid/dataGridDetail.ts"; +import { buildDataGridCellDetail, buildDataGridColumnDetail, dataGridColumnDetailJson, dataGridColumnDetailTsv, buildDataGridRowDetail, dataGridRowDetailJson, dataGridRowDetailTsv, filterDataGridDetailFields, jsonDetailDisplayValue, type DataGridCellDetail } from "../../apps/desktop/src/lib/dataGrid/dataGridDetail.ts"; import type { CellValue } from "../../apps/desktop/src/lib/dataGrid/cellValue.ts"; test("buildDataGridCellDetail returns null for an invalid column", () => { @@ -202,9 +202,34 @@ test("dataGridRowDetailJson and dataGridRowDetailTsv format copy payloads", () = }); assert.equal(dataGridRowDetailJson(detail), '{\n "id": 1,\n "name": "Ada",\n "nickname": null\n}'); + assert.equal(dataGridRowDetailJson(detail, { id: 1, profile: { city: "Shanghai" } }), '{\n "id": 1,\n "profile": {\n "city": "Shanghai"\n }\n}'); assert.equal(dataGridRowDetailTsv(detail), "1\tAda\tNULL"); }); +test("dataGridRowDetailJson uses the original MongoDB document for nested values", () => { + const document = { + _id: { $oid: "507f1f77bcf86cd799439011" }, + profile: { createdAt: { $date: "2025-01-02T03:04:05.000Z" }, labels: ["vip"] }, + }; + const detail = buildDataGridRowDetail({ + rowIndex: 0, + rowId: 1, + row: ['{"$oid":"507f1f77bcf86cd799439011"}', '{"createdAt":{"$date":"2025-01-02T03:04:05.000Z"},"labels":["vip"]}'], + columns: ["_id", "profile"], + columnIndexes: [0, 1], + displayValue: (value) => String(value), + }); + + assert.equal(dataGridRowDetailJson(detail, document), JSON.stringify(document, null, 2)); + assert.doesNotMatch(dataGridRowDetailJson(detail, document), /\\"\\$oid\\"/); +}); + +test("jsonDetailDisplayValue expands JSON stored in MongoDB string fields", () => { + assert.deepEqual( + jsonDetailDisplayValue({ imparts: '{"insImpart":[{"impartId":"40-936","impartAnswer":"N,,"}]}' }), + { imparts: { insImpart: [{ impartId: "40-936", impartAnswer: "N,," }] } }, + ); +}); test("buildDataGridColumnDetail maps a whole column across rows", () => { const typeByColumn = new Map([["name", "varchar"]]); const commentByColumn = new Map([["name", "display name"]]); diff --git a/packages/app-tests/documentJsonSearch.test.ts b/packages/app-tests/documentJsonSearch.test.ts new file mode 100644 index 0000000000..826e6b0e2d --- /dev/null +++ b/packages/app-tests/documentJsonSearch.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { findDocumentTextMatches, renderDocumentJsonHtml } from "../../apps/desktop/src/lib/document/documentJsonSearch.ts"; + +test("findDocumentTextMatches finds case-insensitive document matches", () => { + assert.deepEqual(findDocumentTextMatches('"City": "Shanghai", "backupCity": "shanghai"', "SHANGHAI"), [ + { start: 9, end: 17 }, + { start: 35, end: 43 }, + ]); +}); + +test("renderDocumentJsonHtml keeps syntax highlighting and marks the active match", () => { + const html = renderDocumentJsonHtml('{"profile":{"city":"Shanghai"}}', "city", 0); + + assert.match(html, /json-key/); + assert.match(html, /document-search-match-active/); + assert.match(html, /data-document-search-active="true"/); + assert.match(html, />city); +}); + +test("renderDocumentJsonHtml escapes document content", () => { + const html = renderDocumentJsonHtml('{"value":"