mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(mongodb): improve document quick filters
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
import { computed, ref, nextTick, watch, onMounted, onBeforeUnmount } from "vue";
|
||||||
import { uuid } from "@/lib/common/utils";
|
import { uuid } from "@/lib/common/utils";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { RefreshCw, Trash2, Plus, Save, ChevronLeft, ChevronRight, Table2, Braces, X, Columns3, Check, Search, Wrench, Filter } from "@lucide/vue";
|
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Table2, Braces, X, Columns3, Check, Search, Wrench, Filter } from "@lucide/vue";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -17,12 +17,17 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
|||||||
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
|
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
|
||||||
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
|
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
|
||||||
import {
|
import {
|
||||||
|
arrayObjectAncestorPathForDocumentField,
|
||||||
buildDocumentFilterCondition,
|
buildDocumentFilterCondition,
|
||||||
buildElasticsearchQueryFromRules,
|
buildElasticsearchQueryFromRules,
|
||||||
combineDocumentFilterConditions,
|
combineDocumentFilterConditions,
|
||||||
currentDocumentFilterJson,
|
currentDocumentFilterJson,
|
||||||
currentDocumentSortJson,
|
currentDocumentSortJson,
|
||||||
defaultDocumentFilterRule,
|
defaultDocumentFilterRule,
|
||||||
|
documentFieldPathOptionsFromDocuments,
|
||||||
|
documentFieldPathTreeFromDocuments,
|
||||||
|
flattenDocumentFieldPathTree,
|
||||||
|
searchDocumentFieldPathTree,
|
||||||
documentFilterModeNeedsValue,
|
documentFilterModeNeedsValue,
|
||||||
documentFilterModeOptions,
|
documentFilterModeOptions,
|
||||||
documentStoreProviderFor,
|
documentStoreProviderFor,
|
||||||
@@ -30,6 +35,8 @@ import {
|
|||||||
elasticsearchQueryTypeNeedsValue,
|
elasticsearchQueryTypeNeedsValue,
|
||||||
elasticsearchQueryTypeOptions,
|
elasticsearchQueryTypeOptions,
|
||||||
elasticsearchStructuredFilter,
|
elasticsearchStructuredFilter,
|
||||||
|
formatDocumentQueryInput,
|
||||||
|
type DocumentFieldPathNode,
|
||||||
type DocumentFilterMode,
|
type DocumentFilterMode,
|
||||||
type DocumentFilterRule,
|
type DocumentFilterRule,
|
||||||
type ElasticsearchBoolClause,
|
type ElasticsearchBoolClause,
|
||||||
@@ -49,8 +56,9 @@ import {
|
|||||||
import { applyDocumentStoreIdentityPlan, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave";
|
import { applyDocumentStoreIdentityPlan, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave";
|
||||||
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
|
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
|
||||||
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
||||||
import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentIdForGrid, parseMongoDocumentInputValue, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
|
import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentDisplayValue, mongoDocumentIdForGrid, parseMongoDocumentInputValue, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
|
||||||
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
||||||
|
import { findDocumentTextMatches, renderDocumentJsonHtml } from "@/lib/document/documentJsonSearch";
|
||||||
import { useSettingsStore } from "@/stores/settingsStore";
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
import JsonEditNode from "./JsonEditNode.vue";
|
import JsonEditNode from "./JsonEditNode.vue";
|
||||||
import type { EditNode } from "@/types/editor";
|
import type { EditNode } from "@/types/editor";
|
||||||
@@ -98,7 +106,16 @@ const viewMode = computed<ViewMode>({
|
|||||||
});
|
});
|
||||||
const filterInput = ref("");
|
const filterInput = ref("");
|
||||||
const sortInput = ref("");
|
const sortInput = ref("");
|
||||||
|
const filterInputRef = ref<HTMLTextAreaElement>();
|
||||||
|
const sortInputRef = ref<HTMLTextAreaElement>();
|
||||||
const dataGridRef = ref<InstanceType<typeof DataGrid>>();
|
const dataGridRef = ref<InstanceType<typeof DataGrid>>();
|
||||||
|
const documentViewerRef = ref<HTMLElement>();
|
||||||
|
const documentSearchInputRef = ref<HTMLInputElement>();
|
||||||
|
const documentSearchOpen = ref(false);
|
||||||
|
const documentSearchQuery = ref("");
|
||||||
|
const documentSearchMatchIndex = ref(0);
|
||||||
|
const documentSearchHasNavigated = ref(false);
|
||||||
|
const documentViewerSearchActive = ref(false);
|
||||||
const columnVisibilitySearch = ref("");
|
const columnVisibilitySearch = ref("");
|
||||||
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
|
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
|
||||||
const tableSearchSplitContainerRef = ref<HTMLDivElement>();
|
const tableSearchSplitContainerRef = ref<HTMLDivElement>();
|
||||||
@@ -125,6 +142,7 @@ type LocalFilterSummary = {
|
|||||||
values: string[];
|
values: string[];
|
||||||
hiddenValueCount: number;
|
hiddenValueCount: number;
|
||||||
};
|
};
|
||||||
|
type DocumentFilterFieldTreeRow = DocumentFieldPathNode & { depth: number };
|
||||||
type DocumentGridChanges = {
|
type DocumentGridChanges = {
|
||||||
dirtyRows: Map<number, Map<number, MongoInputValue>>;
|
dirtyRows: Map<number, Map<number, MongoInputValue>>;
|
||||||
deletedRows: Set<number>;
|
deletedRows: Set<number>;
|
||||||
@@ -133,6 +151,8 @@ type DocumentGridChanges = {
|
|||||||
rows: MongoInputValue[][];
|
rows: MongoInputValue[][];
|
||||||
};
|
};
|
||||||
const documentFilterBuilderOpen = ref(false);
|
const documentFilterBuilderOpen = ref(false);
|
||||||
|
const documentFilterFieldPopoverOpen = ref<Record<string, boolean>>({});
|
||||||
|
const documentFilterFieldSearch = ref<Record<string, string>>({});
|
||||||
const documentFilterRules = ref<DocumentFilterRule[]>([]);
|
const documentFilterRules = ref<DocumentFilterRule[]>([]);
|
||||||
const appliedDocumentFilter = ref<Record<string, unknown> | null>(null);
|
const appliedDocumentFilter = ref<Record<string, unknown> | null>(null);
|
||||||
const elasticsearchMappingFields = ref<ColumnInfo[]>([]);
|
const elasticsearchMappingFields = ref<ColumnInfo[]>([]);
|
||||||
@@ -143,6 +163,16 @@ const selectedDoc = computed(() => {
|
|||||||
if (selectedIdx.value === null) return null;
|
if (selectedIdx.value === null) return null;
|
||||||
return documents.value[selectedIdx.value] ?? null;
|
return documents.value[selectedIdx.value] ?? null;
|
||||||
});
|
});
|
||||||
|
const documentSearchText = computed(() => editJson.value);
|
||||||
|
const documentSearchMatches = computed(() => findDocumentTextMatches(documentSearchText.value, documentSearchQuery.value));
|
||||||
|
const documentSearchActiveIndex = computed(() => {
|
||||||
|
if (documentSearchMatches.value.length === 0) return 0;
|
||||||
|
return Math.min(documentSearchMatchIndex.value, documentSearchMatches.value.length - 1);
|
||||||
|
});
|
||||||
|
const documentSearchStatus = computed(() => {
|
||||||
|
const count = documentSearchMatches.value.length;
|
||||||
|
return count > 0 ? `${documentSearchActiveIndex.value + 1}/${count}` : "0/0";
|
||||||
|
});
|
||||||
|
|
||||||
const editKeyWidth = computed(() => {
|
const editKeyWidth = computed(() => {
|
||||||
const longest = editFields.value.reduce((max, field) => {
|
const longest = editFields.value.reduce((max, field) => {
|
||||||
@@ -186,7 +216,7 @@ const gridResult = computed<QueryResult>(() => {
|
|||||||
|
|
||||||
const rows = docs.map((doc) =>
|
const rows = docs.map((doc) =>
|
||||||
columns.map((col) => {
|
columns.map((col) => {
|
||||||
const val = doc[col];
|
const val = mongoDocumentDisplayValue(doc[col]);
|
||||||
if (val === undefined || val === null) return null;
|
if (val === undefined || val === null) return null;
|
||||||
if (col === "_id") return documentStoreProvider.value.kind === "elasticsearch" ? documentStoreValueForGrid(val, "elasticsearch") : mongoDocumentIdForGrid(val);
|
if (col === "_id") return documentStoreProvider.value.kind === "elasticsearch" ? documentStoreValueForGrid(val, "elasticsearch") : mongoDocumentIdForGrid(val);
|
||||||
if (typeof val === "object") return documentStoreValueForGrid(val, documentStoreProvider.value.kind);
|
if (typeof val === "object") return documentStoreValueForGrid(val, documentStoreProvider.value.kind);
|
||||||
@@ -197,11 +227,30 @@ const gridResult = computed<QueryResult>(() => {
|
|||||||
|
|
||||||
return { columns, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false };
|
return { columns, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false };
|
||||||
});
|
});
|
||||||
const documentFilterFieldOptions = computed(() => {
|
const expandedDocumentFilterFieldPaths = ref<Set<string>>(new Set());
|
||||||
if (documentStoreProvider.value.kind !== "elasticsearch") return gridResult.value.columns;
|
const documentFilterFieldTree = computed<DocumentFieldPathNode[]>(() => {
|
||||||
const names = [...elasticsearchMappingFields.value.map((field) => field.name), ...gridResult.value.columns, "_id", "_routing"];
|
const tree = documentFieldPathTreeFromDocuments(documents.value);
|
||||||
return [...new Set(names.filter(Boolean))];
|
if (tree.length > 0) return tree;
|
||||||
|
return gridResult.value.columns.map((column) => ({
|
||||||
|
key: column,
|
||||||
|
path: column,
|
||||||
|
label: column,
|
||||||
|
displayPath: column,
|
||||||
|
kind: "scalar",
|
||||||
|
selectable: true,
|
||||||
|
children: [],
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
const documentFilterFieldOptions = computed(() => {
|
||||||
|
if (documentStoreProvider.value.kind === "elasticsearch") {
|
||||||
|
const names = [...elasticsearchMappingFields.value.map((field) => field.name), ...gridResult.value.columns, "_id", "_routing"];
|
||||||
|
return [...new Set(names.filter(Boolean))];
|
||||||
|
}
|
||||||
|
const nestedFields = documentFieldPathOptionsFromDocuments(documents.value);
|
||||||
|
return nestedFields.length > 0 ? nestedFields : gridResult.value.columns;
|
||||||
|
});
|
||||||
|
const documentFilterFieldRows = computed<DocumentFilterFieldTreeRow[]>(() => visibleDocumentFilterFieldRows(documentFilterFieldTree.value));
|
||||||
|
const documentFilterFieldByPath = computed(() => new Map(flattenDocumentFieldPathTree(documentFilterFieldTree.value).map((node) => [node.path, node])));
|
||||||
const elasticsearchFieldTypes = computed(() => new Map(elasticsearchMappingFields.value.map((field) => [field.name, field.data_type])));
|
const elasticsearchFieldTypes = computed(() => new Map(elasticsearchMappingFields.value.map((field) => [field.name, field.data_type])));
|
||||||
const documentStructuredFilterCount = computed(() => {
|
const documentStructuredFilterCount = computed(() => {
|
||||||
if (!appliedDocumentFilter.value) return 0;
|
if (!appliedDocumentFilter.value) return 0;
|
||||||
@@ -238,8 +287,63 @@ function addDocumentFilterRule() {
|
|||||||
documentFilterRules.value = [...documentFilterRules.value, createDocumentFilterRule()];
|
documentFilterRules.value = [...documentFilterRules.value, createDocumentFilterRule()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visibleDocumentFilterFieldRows(nodes: readonly DocumentFieldPathNode[], depth = 0): DocumentFilterFieldTreeRow[] {
|
||||||
|
const rows: DocumentFilterFieldTreeRow[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
rows.push({ ...node, depth });
|
||||||
|
if (node.children.length > 0 && expandedDocumentFilterFieldPaths.value.has(node.path)) {
|
||||||
|
rows.push(...visibleDocumentFilterFieldRows(node.children, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDocumentFilterFieldExpanded(path: string) {
|
||||||
|
const next = new Set(expandedDocumentFilterFieldPaths.value);
|
||||||
|
if (next.has(path)) next.delete(path);
|
||||||
|
else next.add(path);
|
||||||
|
expandedDocumentFilterFieldPaths.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDocumentFilterFieldPopoverOpen(ruleId: string, open: boolean) {
|
||||||
|
const next = { ...documentFilterFieldPopoverOpen.value };
|
||||||
|
if (open) next[ruleId] = true;
|
||||||
|
else delete next[ruleId];
|
||||||
|
documentFilterFieldPopoverOpen.value = next;
|
||||||
|
if (!open) {
|
||||||
|
const search = { ...documentFilterFieldSearch.value };
|
||||||
|
delete search[ruleId];
|
||||||
|
documentFilterFieldSearch.value = search;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentFilterFieldSearchActive(ruleId: string): boolean {
|
||||||
|
return !!documentFilterFieldSearch.value[ruleId]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentFilterFieldRowsForRule(ruleId: string): DocumentFilterFieldTreeRow[] {
|
||||||
|
const query = documentFilterFieldSearch.value[ruleId] ?? "";
|
||||||
|
if (!query.trim()) return documentFilterFieldRows.value;
|
||||||
|
return searchDocumentFieldPathTree(documentFilterFieldTree.value, query).map((node) => ({ ...node, depth: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDocumentFilterField(ruleId: string, fieldName: string) {
|
||||||
|
updateDocumentFilterRule(ruleId, { fieldName });
|
||||||
|
setDocumentFilterFieldPopoverOpen(ruleId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentFilterFieldLabel(path: string): string {
|
||||||
|
return documentFilterFieldByPath.value.get(path)?.displayPath ?? path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentFilterFieldKindLabel(kind: DocumentFieldPathNode["kind"]): string {
|
||||||
|
if (kind === "array-object") return "array object";
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
|
||||||
function removeDocumentFilterRule(ruleId: string) {
|
function removeDocumentFilterRule(ruleId: string) {
|
||||||
documentFilterRules.value = documentFilterRules.value.filter((rule) => rule.id !== ruleId);
|
documentFilterRules.value = documentFilterRules.value.filter((rule) => rule.id !== ruleId);
|
||||||
|
setDocumentFilterFieldPopoverOpen(ruleId, false);
|
||||||
if (documentFilterRules.value.length === 0) appliedDocumentFilter.value = null;
|
if (documentFilterRules.value.length === 0) appliedDocumentFilter.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +380,8 @@ function elasticsearchQueryTypeLabel(queryType: ElasticsearchQueryType): string
|
|||||||
|
|
||||||
function resetDocumentFilterBuilder() {
|
function resetDocumentFilterBuilder() {
|
||||||
appliedDocumentFilter.value = null;
|
appliedDocumentFilter.value = null;
|
||||||
|
documentFilterFieldPopoverOpen.value = {};
|
||||||
|
documentFilterFieldSearch.value = {};
|
||||||
documentFilterRules.value = documentFilterFieldOptions.value.length > 0 ? [createDocumentFilterRule()] : [];
|
documentFilterRules.value = documentFilterFieldOptions.value.length > 0 ? [createDocumentFilterRule()] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +389,41 @@ function currentDocumentFilter(): string | undefined {
|
|||||||
return currentDocumentFilterJson(filterInput.value, appliedDocumentFilter.value, documentStoreProvider.value.kind);
|
return currentDocumentFilterJson(filterInput.value, appliedDocumentFilter.value, documentStoreProvider.value.kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resizeDocumentQueryInput(el: HTMLTextAreaElement | undefined) {
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${Math.min(Math.max(el.scrollHeight, 20), 120)}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeDocumentQueryInputs() {
|
||||||
|
resizeDocumentQueryInput(filterInputRef.value);
|
||||||
|
resizeDocumentQueryInput(sortInputRef.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFilterInput() {
|
||||||
|
try {
|
||||||
|
filterInput.value = formatDocumentQueryInput(filterInput.value, documentStoreProvider.value.kind);
|
||||||
|
error.value = "";
|
||||||
|
void nextTick(resizeDocumentQueryInputs);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSortInput() {
|
||||||
|
try {
|
||||||
|
sortInput.value = formatDocumentQueryInput(sortInput.value);
|
||||||
|
error.value = "";
|
||||||
|
void nextTick(resizeDocumentQueryInputs);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([filterInput, sortInput], () => {
|
||||||
|
void nextTick(resizeDocumentQueryInputs);
|
||||||
|
});
|
||||||
|
|
||||||
const documentQueryPreview = computed(() => {
|
const documentQueryPreview = computed(() => {
|
||||||
let filter = "{}";
|
let filter = "{}";
|
||||||
try {
|
try {
|
||||||
@@ -309,12 +450,16 @@ async function applyDocumentStructuredFilters() {
|
|||||||
const items = documentFilterRules.value
|
const items = documentFilterRules.value
|
||||||
.map((rule) => ({
|
.map((rule) => ({
|
||||||
rule,
|
rule,
|
||||||
condition: buildDocumentFilterCondition(rule, { kind: documentStoreProvider.value.kind }),
|
condition: buildDocumentFilterCondition(rule, {
|
||||||
|
kind: documentStoreProvider.value.kind,
|
||||||
|
sampleValue: documentFilterFieldByPath.value.get(rule.fieldName)?.sampleValue,
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
.filter((item): item is { rule: DocumentFilterRule; condition: Record<string, unknown> } => !!item.condition);
|
.filter((item): item is { rule: DocumentFilterRule; condition: Record<string, unknown> } => !!item.condition);
|
||||||
const structured = combineDocumentFilterConditions(
|
const structured = combineDocumentFilterConditions(
|
||||||
items.map((item) => item.condition),
|
items.map((item) => item.condition),
|
||||||
items.map((item) => item.rule),
|
items.map((item) => item.rule),
|
||||||
|
items.map((item) => arrayObjectAncestorPathForDocumentField(documentFilterFieldTree.value, item.rule.fieldName)),
|
||||||
);
|
);
|
||||||
appliedDocumentFilter.value = structured;
|
appliedDocumentFilter.value = structured;
|
||||||
documentFilterBuilderOpen.value = false;
|
documentFilterBuilderOpen.value = false;
|
||||||
@@ -780,6 +925,7 @@ function cancelEdit() {
|
|||||||
editJson.value = stringifyDocumentStoreValue(selectedDoc.value, documentStoreProvider.value.kind, 2);
|
editJson.value = stringifyDocumentStoreValue(selectedDoc.value, documentStoreProvider.value.kind, 2);
|
||||||
}
|
}
|
||||||
editFields.value = [];
|
editFields.value = [];
|
||||||
|
documentEditMode.value = "fields";
|
||||||
error.value = "";
|
error.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,6 +993,7 @@ function requestRemoveField(idx: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatForEdit(value: unknown): string {
|
function formatForEdit(value: unknown): string {
|
||||||
|
value = mongoDocumentDisplayValue(value);
|
||||||
if (value === undefined) return "";
|
if (value === undefined) return "";
|
||||||
if (value === null) return "null";
|
if (value === null) return "null";
|
||||||
if (typeof value === "string") return JSON.stringify(value);
|
if (typeof value === "string") return JSON.stringify(value);
|
||||||
@@ -1048,18 +1195,71 @@ function docPreview(doc: JsonRecord): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function highlightedJson(json: string): string {
|
function highlightedJson(json: string): string {
|
||||||
const escaped = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
return renderDocumentJsonHtml(json, documentSearchOpen.value ? documentSearchQuery.value : "", documentSearchActiveIndex.value);
|
||||||
|
}
|
||||||
|
|
||||||
return escaped.replace(/("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, (match) => {
|
function handleDocumentBrowserPointerDown(event: PointerEvent) {
|
||||||
let cls = "json-number";
|
const target = event.target;
|
||||||
if (match.startsWith('"')) cls = match.endsWith(":") ? "json-key" : "json-string";
|
documentViewerSearchActive.value = target instanceof Element && !!target.closest("[data-document-json-viewer], [data-document-search]");
|
||||||
else if (match === "true" || match === "false") cls = "json-boolean";
|
}
|
||||||
else if (match === "null") cls = "json-null";
|
|
||||||
return `<span class="${cls}">${match}</span>`;
|
function focusSearch(): boolean {
|
||||||
|
if (viewMode.value !== "document" || !documentViewerSearchActive.value) return false;
|
||||||
|
if (isEditing.value) return false;
|
||||||
|
if (!isNew.value && selectedIdx.value === null) return false;
|
||||||
|
documentSearchOpen.value = true;
|
||||||
|
documentSearchHasNavigated.value = false;
|
||||||
|
void nextTick(() => {
|
||||||
|
documentSearchInputRef.value?.focus();
|
||||||
|
documentSearchInputRef.value?.select();
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDocumentSearch() {
|
||||||
|
documentSearchOpen.value = false;
|
||||||
|
void nextTick(() => {
|
||||||
|
documentViewerRef.value?.focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveDocumentSearchMatch(delta: -1 | 1) {
|
||||||
|
const count = documentSearchMatches.value.length;
|
||||||
|
if (count === 0) return;
|
||||||
|
documentSearchMatchIndex.value = (documentSearchActiveIndex.value + delta + count) % count;
|
||||||
|
documentSearchHasNavigated.value = true;
|
||||||
|
void scrollDocumentSearchMatchIntoView();
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateDocumentSearchMatch(delta: -1 | 1) {
|
||||||
|
if (documentSearchMatches.value.length === 0) return;
|
||||||
|
if (!documentSearchHasNavigated.value) {
|
||||||
|
documentSearchHasNavigated.value = true;
|
||||||
|
void scrollDocumentSearchMatchIntoView();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moveDocumentSearchMatch(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollDocumentSearchMatchIntoView() {
|
||||||
|
await nextTick();
|
||||||
|
documentViewerRef.value?.querySelector<HTMLElement>('[data-document-search-active="true"]')?.scrollIntoView({ block: "center", inline: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([documentSearchQuery, documentSearchText], () => {
|
||||||
|
documentSearchMatchIndex.value = 0;
|
||||||
|
documentSearchHasNavigated.value = false;
|
||||||
|
void scrollDocumentSearchMatchIntoView();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([viewMode, isEditing, selectedIdx], ([mode, editing, index]) => {
|
||||||
|
if (mode === "document" && !editing && index !== null) return;
|
||||||
|
documentViewerSearchActive.value = false;
|
||||||
|
documentSearchOpen.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
window.addEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
||||||
try {
|
try {
|
||||||
await connectionStore.ensureConnected(props.connectionId);
|
await connectionStore.ensureConnected(props.connectionId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1069,8 +1269,10 @@ onMounted(async () => {
|
|||||||
// first page of documents when the mapping endpoint is slow.
|
// first page of documents when the mapping endpoint is slow.
|
||||||
void loadElasticsearchMappingFields();
|
void loadElasticsearchMappingFields();
|
||||||
void load();
|
void load();
|
||||||
|
void nextTick(resizeDocumentQueryInputs);
|
||||||
});
|
});
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
||||||
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
|
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
|
||||||
stopDocumentLoadingTimer();
|
stopDocumentLoadingTimer();
|
||||||
endTableSearchSplitResize();
|
endTableSearchSplitResize();
|
||||||
@@ -1117,6 +1319,8 @@ function resetTableSearchSplitWidth() {
|
|||||||
const containerWidth = tableSearchSplitContainerWidth();
|
const containerWidth = tableSearchSplitContainerWidth();
|
||||||
tableFindPaneWidth.value = containerWidth > 0 ? clampSearchSplitWidth({ containerWidth }) : null;
|
tableFindPaneWidth.value = containerWidth > 0 ? clampSearchSplitWidth({ containerWidth }) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({ focusSearch });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -1248,7 +1452,7 @@ function resetTableSearchSplitWidth() {
|
|||||||
>
|
>
|
||||||
<template #search-bar="{ localFilterCount, hasLocalColumnFilters, localFilterSummaries, clearLocalFilter }: { localFilterCount: number; hasLocalColumnFilters: boolean; localFilterSummaries: LocalFilterSummary[]; clearLocalFilter: (columnIndex?: number) => void }">
|
<template #search-bar="{ localFilterCount, hasLocalColumnFilters, localFilterSummaries, clearLocalFilter }: { localFilterCount: number; hasLocalColumnFilters: boolean; localFilterSummaries: LocalFilterSummary[]; clearLocalFilter: (columnIndex?: number) => void }">
|
||||||
<div ref="tableSearchSplitContainerRef" class="flex flex-1 min-w-0">
|
<div ref="tableSearchSplitContainerRef" class="flex flex-1 min-w-0">
|
||||||
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0" :style="tableFindPaneStyle">
|
<div class="flex flex-1 items-start gap-1 px-2 py-0.5 min-w-0" :style="tableFindPaneStyle">
|
||||||
<Popover v-model:open="documentFilterBuilderOpen">
|
<Popover v-model:open="documentFilterBuilderOpen">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<button
|
<button
|
||||||
@@ -1331,7 +1535,44 @@ function resetTableSearchSplitWidth() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select :model-value="rule.fieldName" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { fieldName: String(value) })">
|
<Popover v-if="documentStoreProvider.kind !== 'elasticsearch'" :open="!!documentFilterFieldPopoverOpen[rule.id]" @update:open="(open) => setDocumentFilterFieldPopoverOpen(rule.id, open)">
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<button type="button" class="flex h-8 w-full min-w-0 items-center justify-between gap-1 rounded-md border bg-background px-2 text-left text-xs hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
|
||||||
|
<span class="min-w-0 truncate font-mono" :title="documentFilterFieldLabel(rule.fieldName)">{{ documentFilterFieldLabel(rule.fieldName) || t("grid.filterBuilderColumn") }}</span>
|
||||||
|
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" class="w-72 max-w-[calc(100vw-32px)] gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-lg" @click.stop @keydown.stop>
|
||||||
|
<div class="border-b bg-muted/40 px-2 py-1.5 text-xs font-medium text-foreground">{{ t("grid.filterBuilderColumn") }}</div>
|
||||||
|
<div class="relative border-b p-2">
|
||||||
|
<Search class="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input v-model="documentFilterFieldSearch[rule.id]" autofocus class="h-7 pl-7 text-xs" :placeholder="t('grid.filterBuilderSearchColumns')" />
|
||||||
|
</div>
|
||||||
|
<div class="max-h-72 overflow-auto py-1">
|
||||||
|
<div v-for="field in documentFilterFieldRowsForRule(rule.id)" :key="field.path" class="flex items-center gap-1 px-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-7 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
:class="{ invisible: field.children.length === 0 || documentFilterFieldSearchActive(rule.id) }"
|
||||||
|
:style="{ marginLeft: `${field.depth * 14}px` }"
|
||||||
|
@click.stop="toggleDocumentFilterFieldExpanded(field.path)"
|
||||||
|
>
|
||||||
|
<ChevronRight v-if="!expandedDocumentFilterFieldPaths.has(field.path)" class="h-3.5 w-3.5" />
|
||||||
|
<ChevronDown v-else class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button type="button" class="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 text-left text-xs hover:bg-accent" :class="rule.fieldName === field.path ? 'bg-accent text-foreground' : ''" @click="selectDocumentFilterField(rule.id, field.path)">
|
||||||
|
<span class="min-w-0 flex-1 truncate font-mono" :title="field.displayPath">{{ documentFilterFieldSearchActive(rule.id) ? field.displayPath : field.label }}</span>
|
||||||
|
<span v-if="field.kind !== 'scalar'" class="shrink-0 rounded border px-1 py-0 text-[10px] leading-4 text-muted-foreground">{{ documentFilterFieldKindLabel(field.kind) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="documentFilterFieldRowsForRule(rule.id).length === 0" class="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
{{ t("grid.noSearchResults") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Select v-if="documentStoreProvider.kind === 'elasticsearch'" :model-value="rule.fieldName" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { fieldName: String(value) })">
|
||||||
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
|
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
|
||||||
<SelectValue :placeholder="t('grid.filterBuilderColumn')"> {{ rule.fieldName }}{{ elasticsearchFieldTypes.get(rule.fieldName) ? ` (${elasticsearchFieldTypes.get(rule.fieldName)})` : "" }} </SelectValue>
|
<SelectValue :placeholder="t('grid.filterBuilderColumn')"> {{ rule.fieldName }}{{ elasticsearchFieldTypes.get(rule.fieldName) ? ` (${elasticsearchFieldTypes.get(rule.fieldName)})` : "" }} </SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -1404,11 +1645,26 @@ function resetTableSearchSplitWidth() {
|
|||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0">{{ documentStoreProvider.filterInputLabel }}</span>
|
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0 pt-0.5">{{ documentStoreProvider.filterInputLabel }}</span>
|
||||||
<input v-model="filterInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono" placeholder="{}" @keydown.enter="applyFilter" />
|
<textarea
|
||||||
|
ref="filterInputRef"
|
||||||
|
v-model="filterInput"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
rows="1"
|
||||||
|
class="document-query-input flex-1 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono"
|
||||||
|
placeholder="{}"
|
||||||
|
@keydown.ctrl.enter.prevent="applyFilter"
|
||||||
|
@keydown.meta.enter.prevent="applyFilter"
|
||||||
|
/>
|
||||||
|
<button v-if="filterInput.trim()" type="button" class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5" title="Format JSON" aria-label="Format JSON" @click="formatFilterInput">
|
||||||
|
<Braces class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="filterInput.trim()"
|
v-if="filterInput.trim()"
|
||||||
class="text-muted-foreground hover:text-foreground shrink-0"
|
type="button"
|
||||||
|
class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5"
|
||||||
@click="
|
@click="
|
||||||
filterInput = '';
|
filterInput = '';
|
||||||
applyFilter();
|
applyFilter();
|
||||||
@@ -1426,12 +1682,27 @@ function resetTableSearchSplitWidth() {
|
|||||||
>
|
>
|
||||||
<span class="h-5 w-px bg-border group-hover:bg-primary/60" />
|
<span class="h-5 w-px bg-border group-hover:bg-primary/60" />
|
||||||
</button>
|
</button>
|
||||||
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0">
|
<div class="flex flex-1 items-start gap-1 px-2 py-0.5 min-w-0">
|
||||||
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0">{{ documentStoreProvider.sortInputLabel }}</span>
|
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0 pt-0.5">{{ documentStoreProvider.sortInputLabel }}</span>
|
||||||
<input v-model="sortInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono" placeholder="{}" @keydown.enter="applyFilter" />
|
<textarea
|
||||||
|
ref="sortInputRef"
|
||||||
|
v-model="sortInput"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
rows="1"
|
||||||
|
class="document-query-input flex-1 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono"
|
||||||
|
placeholder="{}"
|
||||||
|
@keydown.ctrl.enter.prevent="applyFilter"
|
||||||
|
@keydown.meta.enter.prevent="applyFilter"
|
||||||
|
/>
|
||||||
|
<button v-if="sortInput.trim()" type="button" class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5" title="Format JSON" aria-label="Format JSON" @click="formatSortInput">
|
||||||
|
<Braces class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="sortInput.trim()"
|
v-if="sortInput.trim()"
|
||||||
class="text-muted-foreground hover:text-foreground shrink-0"
|
type="button"
|
||||||
|
class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5"
|
||||||
@click="
|
@click="
|
||||||
sortInput = '';
|
sortInput = '';
|
||||||
applyFilter();
|
applyFilter();
|
||||||
@@ -1482,6 +1753,23 @@ function resetTableSearchSplitWidth() {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="documentSearchOpen && (!isEditing || documentEditMode === 'json')" data-document-search class="flex h-9 shrink-0 items-center justify-end gap-1 border-b bg-background px-2">
|
||||||
|
<div class="relative w-56 max-w-[45%] min-w-32">
|
||||||
|
<Search class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input ref="documentSearchInputRef" v-model="documentSearchQuery" class="h-7 pl-7 pr-2 text-xs" :placeholder="t('editor.search.find')" @keydown.enter.prevent="activateDocumentSearchMatch($event.shiftKey ? -1 : 1)" @keydown.escape.prevent="closeDocumentSearch" />
|
||||||
|
</div>
|
||||||
|
<span class="w-12 text-center text-[11px] tabular-nums text-muted-foreground">{{ documentSearchStatus }}</span>
|
||||||
|
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('editor.search.prevMatch')" :disabled="documentSearchMatches.length === 0" @click="moveDocumentSearchMatch(-1)">
|
||||||
|
<ChevronUp class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('editor.search.nextMatch')" :disabled="documentSearchMatches.length === 0" @click="moveDocumentSearchMatch(1)">
|
||||||
|
<ChevronDown class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" class="h-7 w-7" @click="closeDocumentSearch">
|
||||||
|
<X class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="isEditing && documentEditMode === 'json' && !isNew" class="px-4 py-1.5 text-[11px] text-muted-foreground border-b bg-muted/20 shrink-0">
|
<div v-if="isEditing && documentEditMode === 'json' && !isNew" class="px-4 py-1.5 text-[11px] text-muted-foreground border-b bg-muted/20 shrink-0">
|
||||||
{{ t("mongo.jsonReplaceHint") }}
|
{{ t("mongo.jsonReplaceHint") }}
|
||||||
</div>
|
</div>
|
||||||
@@ -1503,7 +1791,7 @@ function resetTableSearchSplitWidth() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex-1 overflow-auto bg-muted/10">
|
<div v-else ref="documentViewerRef" data-document-json-viewer tabindex="-1" class="flex-1 overflow-auto bg-muted/10 outline-none">
|
||||||
<pre class="json-viewer min-w-fit p-5" :style="documentFontStyle" v-html="highlightedJson(editJson)" />
|
<pre class="json-viewer min-w-fit p-5" :style="documentFontStyle" v-html="highlightedJson(editJson)" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1520,6 +1808,15 @@ function resetTableSearchSplitWidth() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.document-query-input {
|
||||||
|
min-height: 20px;
|
||||||
|
max-height: 120px;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
resize: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.json-viewer {
|
.json-viewer {
|
||||||
font-family: var(--dbx-editor-font-family);
|
font-family: var(--dbx-editor-font-family);
|
||||||
font-size: var(--dbx-editor-font-size);
|
font-size: var(--dbx-editor-font-size);
|
||||||
@@ -1548,6 +1845,27 @@ function resetTableSearchSplitWidth() {
|
|||||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.native-document-editor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
resize: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
padding: 14px 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
tab-size: 2;
|
||||||
|
outline: none;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-document-editor:focus {
|
||||||
|
border-color: var(--ring);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in oklab, var(--ring) 28%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.json-key) {
|
:deep(.json-key) {
|
||||||
color: #7c3aed;
|
color: #7c3aed;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1590,4 +1908,26 @@ function resetTableSearchSplitWidth() {
|
|||||||
:global(.dark) :deep(.json-null) {
|
:global(.dark) :deep(.json-null) {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.document-search-match) {
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #fde68a;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.document-search-match-active) {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #111827;
|
||||||
|
outline: 1px solid #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) :deep(.document-search-match) {
|
||||||
|
background: #854d0e;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) :deep(.document-search-match-active) {
|
||||||
|
background: #fbbf24;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { uuid } from "@/lib/common/utils";
|
|||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { Plus, Trash2 } from "@lucide/vue";
|
import { Plus, Trash2 } from "@lucide/vue";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||||
import type { EditNode, EditNodeKind } from "@/types/editor";
|
import type { EditNode, EditNodeKind } from "@/types/editor";
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ const { t } = useI18n();
|
|||||||
const isContainer = computed(() => props.node.kind !== "value");
|
const isContainer = computed(() => props.node.kind !== "value");
|
||||||
const showChildDeleteConfirm = ref(false);
|
const showChildDeleteConfirm = ref(false);
|
||||||
const pendingChildDeleteIdx = ref<number | null>(null);
|
const pendingChildDeleteIdx = ref<number | null>(null);
|
||||||
|
const emptyArrayItemType = ref<"object" | "string" | "number" | "boolean" | "null" | "array">("object");
|
||||||
|
|
||||||
const childKeyWidth = computed(() => {
|
const childKeyWidth = computed(() => {
|
||||||
const longest = props.node.children.reduce((max, child) => {
|
const longest = props.node.children.reduce((max, child) => {
|
||||||
@@ -66,11 +68,11 @@ function fieldValueTone(value: string): string {
|
|||||||
return "is-string";
|
return "is-string";
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBlankNode(keyName: string, readonlyKey: boolean): EditNode {
|
function createBlankNode(keyName: string, readonlyKey: boolean, kind: EditNodeKind = "value"): EditNode {
|
||||||
return {
|
return {
|
||||||
key: uuid(),
|
key: uuid(),
|
||||||
keyName,
|
keyName,
|
||||||
kind: "value",
|
kind,
|
||||||
valueText: "",
|
valueText: "",
|
||||||
readonlyKey,
|
readonlyKey,
|
||||||
readonlyValue: false,
|
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() {
|
function addChild() {
|
||||||
if (props.node.kind === "array") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
props.node.children.push(createBlankNode("", false));
|
props.node.children.push(createBlankNode("", false));
|
||||||
@@ -139,7 +164,23 @@ function confirmRemoveChild() {
|
|||||||
<div v-if="isContainer" class="json-edit-children" :style="{ '--mongo-key-width': childKeyWidth }">
|
<div v-if="isContainer" class="json-edit-children" :style="{ '--mongo-key-width': childKeyWidth }">
|
||||||
<JsonEditNode v-for="(child, idx) in node.children" :key="child.key" :node="child" :parent-kind="node.kind" :removable="!child.readonlyValue || node.kind === 'array'" @remove="requestRemoveChild(idx)" />
|
<JsonEditNode v-for="(child, idx) in node.children" :key="child.key" :node="child" :parent-kind="node.kind" :removable="!child.readonlyValue || node.kind === 'array'" @remove="requestRemoveChild(idx)" />
|
||||||
|
|
||||||
<Button variant="ghost" size="sm" class="json-edit-add" @click="addChild"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
<div v-if="node.kind === 'array' && node.children.length === 0" class="json-edit-add-row">
|
||||||
|
<Select v-model="emptyArrayItemType">
|
||||||
|
<SelectTrigger class="h-8 w-28 text-xs" :aria-label="t('mongo.addField')">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent align="start">
|
||||||
|
<SelectItem value="object">Object</SelectItem>
|
||||||
|
<SelectItem value="string">{{ t("sqlParameters.kind.string") }}</SelectItem>
|
||||||
|
<SelectItem value="number">{{ t("sqlParameters.kind.number") }}</SelectItem>
|
||||||
|
<SelectItem value="boolean">{{ t("sqlParameters.kind.boolean") }}</SelectItem>
|
||||||
|
<SelectItem value="null">{{ t("sqlParameters.kind.null") }}</SelectItem>
|
||||||
|
<SelectItem value="array">Array</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="ghost" size="sm" class="json-edit-add" @click="addChild"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
||||||
|
</div>
|
||||||
|
<Button v-else variant="ghost" size="sm" class="json-edit-add" @click="addChild"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
||||||
|
|
||||||
<div class="json-edit-close">{{ node.kind === "array" ? "]" : "}" }}<span class="json-edit-comma">,</span></div>
|
<div class="json-edit-close">{{ node.kind === "array" ? "]" : "}" }}<span class="json-edit-comma">,</span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,6 +348,17 @@ function confirmRemoveChild() {
|
|||||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
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 {
|
:global(.dark) .json-edit-key {
|
||||||
color: #c4b5fd;
|
color: #c4b5fd;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronDown, ChevronRight } from "@lucide/vue";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
defineOptions({ name: "JsonValueTable" });
|
||||||
|
|
||||||
|
const props = defineProps<{ value: unknown }>();
|
||||||
|
const expanded = ref(true);
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isContainer = computed(() => isRecord(props.value) || Array.isArray(props.value));
|
||||||
|
const containerLabel = computed(() => (Array.isArray(props.value) ? `Array (${props.value.length})` : `Object (${Object.keys(props.value as Record<string, unknown>).length})`));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="isContainer" class="json-value-container">
|
||||||
|
<button type="button" class="json-value-toggle" :aria-expanded="expanded" @click="expanded = !expanded">
|
||||||
|
<ChevronDown v-if="expanded" class="h-3.5 w-3.5" />
|
||||||
|
<ChevronRight v-else class="h-3.5 w-3.5" />
|
||||||
|
<span>{{ containerLabel }}</span>
|
||||||
|
</button>
|
||||||
|
<table v-show="expanded && isRecord(value)" class="json-value-table">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(item, key) in value as Record<string, unknown>" :key="key">
|
||||||
|
<th>{{ key }}</th>
|
||||||
|
<td><JsonValueTable :value="item" /></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table v-show="expanded && Array.isArray(value)" class="json-value-table">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(item, index) in value as unknown[]" :key="index">
|
||||||
|
<th>{{ index }}</th>
|
||||||
|
<td><JsonValueTable :value="item" /></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<span v-else class="break-words">{{ value === null ? "null" : String(value) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.json-value-table {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: var(--dbx-editor-font-family);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.json-value-table th,
|
||||||
|
.json-value-table td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.json-value-table th {
|
||||||
|
width: 1%;
|
||||||
|
max-width: 20rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.json-value-table td {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.json-value-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.json-value-toggle:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -207,6 +207,7 @@ const dataGridSearchMode = computed(() => settingsStore.editorSettings.dataGridS
|
|||||||
const columnWidthDensity = computed(() => settingsStore.editorSettings.columnWidthDensity);
|
const columnWidthDensity = computed(() => settingsStore.editorSettings.columnWidthDensity);
|
||||||
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
|
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
|
||||||
const redisKeyBrowserRef = ref<SearchableBrowserHandle>();
|
const redisKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||||||
|
const documentBrowserRef = ref<SearchableBrowserHandle>();
|
||||||
|
|
||||||
const etcdKeyBrowserRef = ref<SearchableBrowserHandle>();
|
const etcdKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||||||
const zookeeperKeyBrowserRef = ref<SearchableBrowserHandle>();
|
const zookeeperKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||||||
@@ -680,6 +681,7 @@ function onHandleCloseColumnPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function focusSearch(): boolean {
|
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 === "redis") return redisKeyBrowserRef.value?.focusSearch() ?? false;
|
||||||
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.focusSearch() ?? false;
|
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.focusSearch() ?? false;
|
||||||
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.focusSearch() ?? false;
|
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.focusSearch() ?? false;
|
||||||
@@ -1547,7 +1549,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
|||||||
<!-- Document mode: MongoDB collections and Elasticsearch indices -->
|
<!-- Document mode: MongoDB collections and Elasticsearch indices -->
|
||||||
<template v-else-if="activeTab.mode === 'mongo'">
|
<template v-else-if="activeTab.mode === 'mongo'">
|
||||||
<div class="flex-1 min-h-0">
|
<div class="flex-1 min-h-0">
|
||||||
<DocumentBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" />
|
<DocumentBrowser ref="documentBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1062,6 +1062,7 @@ export default {
|
|||||||
emptyStringValue: "Empty string",
|
emptyStringValue: "Empty string",
|
||||||
noComment: "No comment",
|
noComment: "No comment",
|
||||||
formattedJson: "Formatted JSON",
|
formattedJson: "Formatted JSON",
|
||||||
|
formatted: "Formatted",
|
||||||
formattedValue: "Formatted Value",
|
formattedValue: "Formatted Value",
|
||||||
detailSearchPlaceholder: "Search field or value…",
|
detailSearchPlaceholder: "Search field or value…",
|
||||||
detailSearchNoMatch: "No matches",
|
detailSearchNoMatch: "No matches",
|
||||||
|
|||||||
@@ -1004,6 +1004,7 @@ export default withEnglishFallback({
|
|||||||
nullValue: "NULL",
|
nullValue: "NULL",
|
||||||
noComment: "Sin comentario",
|
noComment: "Sin comentario",
|
||||||
formattedJson: "JSON formateado",
|
formattedJson: "JSON formateado",
|
||||||
|
formatted: "Formateado",
|
||||||
formattedValue: "Valor formateado",
|
formattedValue: "Valor formateado",
|
||||||
detailSearchPlaceholder: "Buscar campo o valor…",
|
detailSearchPlaceholder: "Buscar campo o valor…",
|
||||||
detailSearchNoMatch: "Sin coincidencias",
|
detailSearchNoMatch: "Sin coincidencias",
|
||||||
@@ -2462,6 +2463,11 @@ export default withEnglishFallback({
|
|||||||
tableView: "Vista de tabla",
|
tableView: "Vista de tabla",
|
||||||
filterPlaceholder: "Filtro...",
|
filterPlaceholder: "Filtro...",
|
||||||
sortPlaceholder: "Orden...",
|
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: {
|
vector: {
|
||||||
collectionFallback: "colección",
|
collectionFallback: "colección",
|
||||||
|
|||||||
@@ -1002,6 +1002,7 @@ export default withEnglishFallback({
|
|||||||
nullValue: "NULL",
|
nullValue: "NULL",
|
||||||
noComment: "Nessun commento",
|
noComment: "Nessun commento",
|
||||||
formattedJson: "JSON Formattato",
|
formattedJson: "JSON Formattato",
|
||||||
|
formatted: "Formattato",
|
||||||
formattedValue: "Valore Formattato",
|
formattedValue: "Valore Formattato",
|
||||||
detailSearchPlaceholder: "Cerca campo o valore…",
|
detailSearchPlaceholder: "Cerca campo o valore…",
|
||||||
detailSearchNoMatch: "Nessun risultato",
|
detailSearchNoMatch: "Nessun risultato",
|
||||||
@@ -2460,6 +2461,11 @@ export default withEnglishFallback({
|
|||||||
tableView: "Visualizzazione Tabella",
|
tableView: "Visualizzazione Tabella",
|
||||||
filterPlaceholder: "Filtra...",
|
filterPlaceholder: "Filtra...",
|
||||||
sortPlaceholder: "Ordina...",
|
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: {
|
vector: {
|
||||||
collectionFallback: "collezione",
|
collectionFallback: "collezione",
|
||||||
|
|||||||
@@ -999,6 +999,7 @@ export default withEnglishFallback({
|
|||||||
nullValue: "NULL",
|
nullValue: "NULL",
|
||||||
noComment: "コメントなし",
|
noComment: "コメントなし",
|
||||||
formattedJson: "フォーマット済みJSON",
|
formattedJson: "フォーマット済みJSON",
|
||||||
|
formatted: "整形済み",
|
||||||
formattedValue: "フォーマット済み値",
|
formattedValue: "フォーマット済み値",
|
||||||
detailSearchPlaceholder: "フィールドまたは値を検索…",
|
detailSearchPlaceholder: "フィールドまたは値を検索…",
|
||||||
detailSearchNoMatch: "一致なし",
|
detailSearchNoMatch: "一致なし",
|
||||||
@@ -2461,6 +2462,11 @@ export default withEnglishFallback({
|
|||||||
tableView: "テーブルビュー",
|
tableView: "テーブルビュー",
|
||||||
filterPlaceholder: "フィルター...",
|
filterPlaceholder: "フィルター...",
|
||||||
sortPlaceholder: "並び替え...",
|
sortPlaceholder: "並び替え...",
|
||||||
|
fieldEditor: "フィールド",
|
||||||
|
nativeJson: "ネイティブJSON",
|
||||||
|
nativeJsonHint: "BSON 値には MongoDB Extended JSON を使用してください。例: $oid、$date、$numberLong。",
|
||||||
|
invalidNativeJson: "ネイティブJSONの形式が無効です: {error}",
|
||||||
|
nativeDocumentRequired: "ネイティブJSONの最上位はドキュメントオブジェクトである必要があります。",
|
||||||
},
|
},
|
||||||
vector: {
|
vector: {
|
||||||
collectionFallback: "コレクション",
|
collectionFallback: "コレクション",
|
||||||
|
|||||||
@@ -1004,6 +1004,7 @@ export default withEnglishFallback({
|
|||||||
nullValue: "NULL",
|
nullValue: "NULL",
|
||||||
noComment: "Sem comentário",
|
noComment: "Sem comentário",
|
||||||
formattedJson: "JSON Formatado",
|
formattedJson: "JSON Formatado",
|
||||||
|
formatted: "Formatado",
|
||||||
formattedValue: "Valor Formatado",
|
formattedValue: "Valor Formatado",
|
||||||
detailSearchPlaceholder: "Buscar campo ou valor…",
|
detailSearchPlaceholder: "Buscar campo ou valor…",
|
||||||
detailSearchNoMatch: "Nenhuma correspondência",
|
detailSearchNoMatch: "Nenhuma correspondência",
|
||||||
@@ -2462,6 +2463,11 @@ export default withEnglishFallback({
|
|||||||
tableView: "Visão de tabela",
|
tableView: "Visão de tabela",
|
||||||
filterPlaceholder: "Filtrar...",
|
filterPlaceholder: "Filtrar...",
|
||||||
sortPlaceholder: "Ordenar...",
|
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: {
|
vector: {
|
||||||
collectionFallback: "coleção",
|
collectionFallback: "coleção",
|
||||||
|
|||||||
@@ -1064,6 +1064,7 @@ export default withEnglishFallback({
|
|||||||
emptyStringValue: "空字符串",
|
emptyStringValue: "空字符串",
|
||||||
noComment: "暂无注释",
|
noComment: "暂无注释",
|
||||||
formattedJson: "格式化 JSON",
|
formattedJson: "格式化 JSON",
|
||||||
|
formatted: "格式化",
|
||||||
formattedValue: "格式化值",
|
formattedValue: "格式化值",
|
||||||
detailSearchPlaceholder: "搜索字段名或值…",
|
detailSearchPlaceholder: "搜索字段名或值…",
|
||||||
detailSearchNoMatch: "无匹配项",
|
detailSearchNoMatch: "无匹配项",
|
||||||
|
|||||||
@@ -1004,6 +1004,7 @@ export default withEnglishFallback({
|
|||||||
nullValue: "NULL",
|
nullValue: "NULL",
|
||||||
noComment: "暫無註解",
|
noComment: "暫無註解",
|
||||||
formattedJson: "格式化 JSON",
|
formattedJson: "格式化 JSON",
|
||||||
|
formatted: "格式化",
|
||||||
formattedValue: "格式化值",
|
formattedValue: "格式化值",
|
||||||
detailSearchPlaceholder: "搜尋欄位名或值…",
|
detailSearchPlaceholder: "搜尋欄位名或值…",
|
||||||
detailSearchNoMatch: "無相符項目",
|
detailSearchNoMatch: "無相符項目",
|
||||||
@@ -2279,6 +2280,11 @@ export default withEnglishFallback({
|
|||||||
tableView: "表格檢視",
|
tableView: "表格檢視",
|
||||||
filterPlaceholder: "過濾條件……",
|
filterPlaceholder: "過濾條件……",
|
||||||
sortPlaceholder: "排序條件……",
|
sortPlaceholder: "排序條件……",
|
||||||
|
fieldEditor: "欄位",
|
||||||
|
nativeJson: "原生 JSON",
|
||||||
|
nativeJsonHint: "BSON 值請使用 MongoDB Extended JSON,例如 $oid、$date、$numberLong。",
|
||||||
|
invalidNativeJson: "原生 JSON 格式無效:{error}",
|
||||||
|
nativeDocumentRequired: "原生 JSON 的最上層必須是文件物件。",
|
||||||
},
|
},
|
||||||
vector: {
|
vector: {
|
||||||
collectionFallback: "集合",
|
collectionFallback: "集合",
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ export type DocumentFilterRule = {
|
|||||||
elasticsearchQueryType?: ElasticsearchQueryType;
|
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 = {
|
export type DocumentStoreQueryPreviewOptions = {
|
||||||
collection: string;
|
collection: string;
|
||||||
filterJson?: string;
|
filterJson?: string;
|
||||||
@@ -119,6 +132,152 @@ export function documentFilterModeNeedsValue(mode: DocumentFilterMode): boolean
|
|||||||
return mode !== "is-null" && mode !== "is-not-null";
|
return mode !== "is-null" && mode !== "is-not-null";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DocumentFieldPathAccumulatorNode = {
|
||||||
|
key: string;
|
||||||
|
path: string;
|
||||||
|
kind: DocumentFieldPathKind;
|
||||||
|
sampleValue?: unknown;
|
||||||
|
children: DocumentFieldPathAccumulatorNode[];
|
||||||
|
childByKey: Map<string, DocumentFieldPathAccumulatorNode>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function documentFieldPathOptionsFromDocuments(documents: readonly Record<string, unknown>[]): string[] {
|
||||||
|
return flattenDocumentFieldPathTree(documentFieldPathTreeFromDocuments(documents)).map((node) => node.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function documentFieldPathTreeFromDocuments(documents: readonly Record<string, unknown>[]): DocumentFieldPathNode[] {
|
||||||
|
if (documents.length === 0) return [];
|
||||||
|
const rootNodes: DocumentFieldPathAccumulatorNode[] = [];
|
||||||
|
const rootByKey = new Map<string, DocumentFieldPathAccumulatorNode>();
|
||||||
|
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<string, DocumentFieldPathAccumulatorNode>, 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<string, unknown>, 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<string, DocumentFieldPathAccumulatorNode>, 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<string, unknown> {
|
||||||
|
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 {
|
export function elasticsearchQueryTypeNeedsValue(queryType: ElasticsearchQueryType | undefined): boolean {
|
||||||
return queryType !== "exists";
|
return queryType !== "exists";
|
||||||
}
|
}
|
||||||
@@ -183,6 +342,7 @@ export function elasticsearchStructuredFilter(query: Record<string, unknown> | n
|
|||||||
|
|
||||||
type DocumentFilterParseOptions = {
|
type DocumentFilterParseOptions = {
|
||||||
kind?: DocumentStoreKind;
|
kind?: DocumentStoreKind;
|
||||||
|
sampleValue?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildDocumentFilterCondition(rule: DocumentFilterRule, options: DocumentFilterParseOptions = {}): Record<string, unknown> | null {
|
export function buildDocumentFilterCondition(rule: DocumentFilterRule, options: DocumentFilterParseOptions = {}): Record<string, unknown> | null {
|
||||||
@@ -196,9 +356,9 @@ export function buildDocumentFilterCondition(rule: DocumentFilterRule, options:
|
|||||||
case "not-equals":
|
case "not-equals":
|
||||||
return { [rule.fieldName]: { $ne: value } };
|
return { [rule.fieldName]: { $ne: value } };
|
||||||
case "like":
|
case "like":
|
||||||
return { [rule.fieldName]: { $regex: textValue, $options: "i" } };
|
return { [rule.fieldName]: { $regex: escapeRegexLiteral(textValue), $options: "i" } };
|
||||||
case "not-like":
|
case "not-like":
|
||||||
return { [rule.fieldName]: { $not: { $regex: textValue, $options: "i" } } };
|
return { [rule.fieldName]: { $not: { $regex: escapeRegexLiteral(textValue), $options: "i" } } };
|
||||||
case "greater-than":
|
case "greater-than":
|
||||||
return { [rule.fieldName]: { $gt: value } };
|
return { [rule.fieldName]: { $gt: value } };
|
||||||
case "less-than":
|
case "less-than":
|
||||||
@@ -210,16 +370,70 @@ export function buildDocumentFilterCondition(rule: DocumentFilterRule, options:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function combineDocumentFilterConditions(conditions: Record<string, unknown>[], rules: Pick<DocumentFilterRule, "conjunction">[]): Record<string, unknown> | null {
|
function escapeRegexLiteral(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combineDocumentFilterConditions(conditions: Record<string, unknown>[], rules: Pick<DocumentFilterRule, "conjunction">[], arrayObjectParents: Array<string | null> = []): Record<string, unknown> | null {
|
||||||
if (conditions.length === 0) return null;
|
if (conditions.length === 0) return null;
|
||||||
let result = conditions[0];
|
const grouped = groupLeadingArrayObjectConditions(conditions, rules, arrayObjectParents);
|
||||||
for (let i = 1; i < conditions.length; i++) {
|
let result = grouped.conditions[0];
|
||||||
const operator = rules[i]?.conjunction === "OR" ? "$or" : "$and";
|
for (let i = 1; i < grouped.conditions.length; i++) {
|
||||||
result = { [operator]: [result, conditions[i]] };
|
const operator = grouped.rules[i]?.conjunction === "OR" ? "$or" : "$and";
|
||||||
|
result = { [operator]: [result, grouped.conditions[i]] };
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupLeadingArrayObjectConditions(conditions: Record<string, unknown>[], rules: Pick<DocumentFilterRule, "conjunction">[], arrayObjectParents: Array<string | null>): { conditions: Record<string, unknown>[]; rules: Pick<DocumentFilterRule, "conjunction">[] } {
|
||||||
|
const groupedConditions: Record<string, unknown>[] = [];
|
||||||
|
const groupedRules: Pick<DocumentFilterRule, "conjunction">[] = [];
|
||||||
|
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<string, unknown>[] = [];
|
||||||
|
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<string, unknown>, parent: string): Record<string, unknown> | 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 MAX_SAFE_BIGINT = 9007199254740991n;
|
||||||
const MIN_BSON_INT64 = -9223372036854775808n;
|
const MIN_BSON_INT64 = -9223372036854775808n;
|
||||||
const MAX_BSON_INT64 = 9223372036854775807n;
|
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;
|
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 {
|
function parseDocumentFilterValue(raw: string, options: DocumentFilterParseOptions = {}): unknown {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return "";
|
if (!trimmed) return "";
|
||||||
try {
|
try {
|
||||||
return parseJsonPreservingLargeIntegers(trimmed, options);
|
return parseJsonPreservingLargeIntegers(trimmed, options);
|
||||||
} catch {
|
} 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<string, unknown> | 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<string, unknown>[];
|
||||||
|
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<string, unknown>): "$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<DocumentStoreQueryPreviewOptions, "filterJson" | "sortJson" | "skip" | "limit">): Record<string, unknown> {
|
export function elasticsearchSearchBodyFromDocumentQuery(options: Pick<DocumentStoreQueryPreviewOptions, "filterJson" | "sortJson" | "skip" | "limit">): Record<string, unknown> {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
from: options.skip,
|
from: options.skip,
|
||||||
|
|||||||
@@ -176,12 +176,32 @@ function detailColumnType(typeByColumn: ReadonlyMap<string, string> | undefined,
|
|||||||
return resultColumnTypes?.[columnIndex]?.trim() ?? "";
|
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<string, CellValue> = {};
|
const row: Record<string, CellValue> = {};
|
||||||
detail.fields.forEach((field) => {
|
detail.fields.forEach((field) => {
|
||||||
row[field.column] = field.value;
|
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<string, unknown>).map(([key, item]) => [key, jsonDetailDisplayValue(item)]));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dataGridRowDetailTsv(detail: DataGridRowDetail): string {
|
export function dataGridRowDetailTsv(detail: DataGridRowDetail): string {
|
||||||
|
|||||||
@@ -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<number>([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 = `<span class="${token.className}">${segment}</span>`;
|
||||||
|
}
|
||||||
|
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 = `<mark class="document-search-match${activeClass}" data-document-search-match="${searchIndex}"${activeAttribute}>${segment}</mark>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export type MongoInputValue = string | number | boolean | null;
|
export type MongoInputValue = string | number | boolean | null;
|
||||||
|
|
||||||
const MONGO_SHELL_DATE_PATTERN = /^(?:ISODate|new Date)\(\s*(["'])(.+)\1\s*\)$/;
|
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 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_OBJECT_ID_PATTERN = /^[a-fA-F0-9]{24}$/;
|
||||||
const MONGO_INTEGER_PATTERN = /^-?\d+$/;
|
const MONGO_INTEGER_PATTERN = /^-?\d+$/;
|
||||||
@@ -24,6 +25,8 @@ export function parseMongoDocumentInputValue(raw: MongoInputValue): unknown {
|
|||||||
|
|
||||||
const shellDate = mongoShellDateToExtendedJson(trimmed);
|
const shellDate = mongoShellDateToExtendedJson(trimmed);
|
||||||
if (shellDate !== trimmed) return shellDate;
|
if (shellDate !== trimmed) return shellDate;
|
||||||
|
const shellNumberLong = mongoShellNumberLongToExtendedJson(trimmed);
|
||||||
|
if (shellNumberLong !== trimmed) return shellNumberLong;
|
||||||
|
|
||||||
const legacyDate = legacyMongoDateDisplayToExtendedJson(trimmed);
|
const legacyDate = legacyMongoDateDisplayToExtendedJson(trimmed);
|
||||||
if (legacyDate) return legacyDate;
|
if (legacyDate) return legacyDate;
|
||||||
@@ -42,6 +45,14 @@ export function parseMongoDocumentInputValue(raw: MongoInputValue): unknown {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mongoDocumentDisplayValue(value: unknown): unknown {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
const object = value as Record<string, unknown>;
|
||||||
|
if (Object.keys(object).length === 1 && typeof object.$numberLong === "string") return `NumberLong(${JSON.stringify(object.$numberLong)})`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function parseMongoExistingFieldInputValue(raw: Exclude<MongoInputValue, null>, originalValue: unknown): unknown {
|
function parseMongoExistingFieldInputValue(raw: Exclude<MongoInputValue, null>, originalValue: unknown): unknown {
|
||||||
// Objects and arrays are serialized into grid text too, so the raw document
|
// 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.
|
// 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` };
|
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<number, MongoInputValue>, columns: string[], originalDocument?: unknown): Record<string, unknown> {
|
export function buildMongoUpdateDocument(changes: Map<number, MongoInputValue>, columns: string[], originalDocument?: unknown): Record<string, unknown> {
|
||||||
const setFields: Record<string, unknown> = {};
|
const setFields: Record<string, unknown> = {};
|
||||||
const unsetFields: Record<string, unknown> = {};
|
const unsetFields: Record<string, unknown> = {};
|
||||||
|
|||||||
@@ -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" },
|
{ id: "mq-1:mq-tenant:tenant-a", label: "tenant-a", type: "mq-tenant", tenant: "tenant-a" },
|
||||||
]);
|
]);
|
||||||
expect(node.isExpanded).toBe(true);
|
expect(node.isExpanded).toBe(true);
|
||||||
});
|
}, 10_000);
|
||||||
|
|
||||||
it("adds a Kafka topics child with a topics initial tab", async () => {
|
it("adds a Kafka topics child with a topics initial tab", async () => {
|
||||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe("queryStore database open state", () => {
|
|||||||
store.closeTab(tabId);
|
store.closeTab(tabId);
|
||||||
|
|
||||||
expect(store.isDatabaseOpen("pg-1", "analytics")).toBe(false);
|
expect(store.isDatabaseOpen("pg-1", "analytics")).toBe(false);
|
||||||
});
|
}, 10_000);
|
||||||
|
|
||||||
it("keeps object browser viewport per tab and clears it on schema change", async () => {
|
it("keeps object browser viewport per tab and clears it on schema change", async () => {
|
||||||
const { useQueryStore } = await import("@/stores/queryStore");
|
const { useQueryStore } = await import("@/stores/queryStore");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { uuid } from "@/lib/common/utils";
|
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 { useI18n } from "vue-i18n";
|
||||||
import type { ConnectionConfig, DatabaseType, ObjectBrowserViewport, QueryResult, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
|
import type { ConnectionConfig, DatabaseType, ObjectBrowserViewport, QueryResult, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
|
||||||
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
|
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
|
||||||
@@ -999,6 +999,11 @@ export const useQueryStore = defineStore("query", () => {
|
|||||||
{ flush: "post" },
|
{ flush: "post" },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onScopeDispose(() => {
|
||||||
|
if (persistTimer) clearTimeout(persistTimer);
|
||||||
|
persistTimer = null;
|
||||||
|
});
|
||||||
|
|
||||||
// Immediately flush any pending debounced persist so the on-disk content
|
// Immediately flush any pending debounced persist so the on-disk content
|
||||||
// reflects the latest in-memory tabs without waiting for the 300ms debounce.
|
// 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
|
// Lets callers (e.g. tests that reload the store) read back persisted state
|
||||||
|
|||||||
@@ -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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn find_documents_extended_json(
|
pub async fn find_documents_extended_json(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -728,7 +728,7 @@ pub async fn find_documents_extended_json(
|
|||||||
let mut documents = Vec::new();
|
let mut documents = Vec::new();
|
||||||
while cursor.advance().await.map_err(|e| e.to_string())? {
|
while cursor.advance().await.map_err(|e| e.to_string())? {
|
||||||
let doc = cursor.deserialize_current().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 })
|
Ok(MongoDocumentResult { extended_documents: Some(documents.clone()), documents, raw_documents: None, total })
|
||||||
@@ -1077,7 +1077,7 @@ pub async fn update_document(
|
|||||||
) -> Result<u64, String> {
|
) -> Result<u64, String> {
|
||||||
let value: serde_json::Value = serde_json::from_str(doc_json).map_err(|e| format!("Invalid JSON: {e}"))?;
|
let value: serde_json::Value = serde_json::from_str(doc_json).map_err(|e| format!("Invalid JSON: {e}"))?;
|
||||||
let col = client.database(database).collection::<Document>(collection);
|
let col = client.database(database).collection::<Document>(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) {
|
if is_update_operator_document(&update_doc) {
|
||||||
for filter in document_id_filters(id) {
|
for filter in document_id_filters(id) {
|
||||||
let result = col.update_one(filter, update_doc.clone()).await.map_err(|e| e.to_string())?;
|
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) {
|
for filter in document_id_filters(id) {
|
||||||
let current = col.find_one(filter.clone()).await.map_err(|e| e.to_string())?;
|
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}"))?;
|
.map_err(|e| format!("Invalid document: {e}"))?;
|
||||||
new_doc.remove("_id");
|
new_doc.remove("_id");
|
||||||
let result = col.replace_one(filter, new_doc.clone()).await.map_err(|e| e.to_string())?;
|
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)
|
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`,
|
/// Convert a `serde_json::Value` (JSON object) to a BSON `Document`,
|
||||||
/// handling MongoDB extended JSON conventions such as `{"$oid":"..."}`.
|
/// handling MongoDB extended JSON conventions such as `{"$oid":"..."}`.
|
||||||
pub fn json_object_to_document(value: &serde_json::Value) -> Result<Document, String> {
|
pub fn json_object_to_document(value: &serde_json::Value) -> Result<Document, String> {
|
||||||
@@ -1504,21 +1518,27 @@ fn json_object_to_document_preserving_existing(
|
|||||||
value: &serde_json::Value,
|
value: &serde_json::Value,
|
||||||
existing: Option<&Document>,
|
existing: Option<&Document>,
|
||||||
) -> Result<Document, String> {
|
) -> Result<Document, String> {
|
||||||
match (value, existing) {
|
match value {
|
||||||
(serde_json::Value::Object(obj), Some(existing)) => Ok(obj
|
serde_json::Value::Object(obj) => obj
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(key, value)| {
|
.map(|(key, value)| {
|
||||||
let bson = existing
|
json_value_to_bson_preserving_existing(value, existing.and_then(|document| document.get(key)))
|
||||||
.get(key)
|
.map(|bson| (key.clone(), bson))
|
||||||
.map(|existing_bson| json_value_to_bson_preserving_existing(value, existing_bson))
|
|
||||||
.unwrap_or_else(|| json_value_to_bson(value));
|
|
||||||
(key.clone(), bson)
|
|
||||||
})
|
})
|
||||||
.collect()),
|
.collect(),
|
||||||
_ => json_object_to_document(value),
|
_ => 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<Document, String> {
|
||||||
|
json_object_to_document_preserving_existing(value, existing)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn json_filter_to_document(value: &serde_json::Value) -> Result<Document, String> {
|
pub fn json_filter_to_document(value: &serde_json::Value) -> Result<Document, String> {
|
||||||
match json_filter_value_to_bson(value, None) {
|
match json_filter_value_to_bson(value, None) {
|
||||||
Bson::Document(doc) => Ok(doc),
|
Bson::Document(doc) => Ok(doc),
|
||||||
@@ -1526,36 +1546,49 @@ pub fn json_filter_to_document(value: &serde_json::Value) -> Result<Document, St
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn json_value_to_bson_preserving_existing(value: &serde_json::Value, existing: &Bson) -> Bson {
|
fn json_value_to_bson_preserving_existing(value: &serde_json::Value, existing: Option<&Bson>) -> Result<Bson, String> {
|
||||||
if &bson_to_json(existing) == value {
|
if let Some(existing) = existing {
|
||||||
return existing.clone();
|
if &bson_to_json(existing) == value {
|
||||||
|
return Ok(existing.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match (value, existing) {
|
match value {
|
||||||
(serde_json::Value::Array(values), Bson::Array(existing_values)) => Bson::Array(
|
serde_json::Value::Array(values) => {
|
||||||
|
let existing_values = match existing {
|
||||||
|
Some(Bson::Array(values)) => Some(values),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
values
|
values
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, item)| {
|
.map(|(index, item)| {
|
||||||
existing_values
|
json_value_to_bson_preserving_existing(item, existing_values.and_then(|values| values.get(index)))
|
||||||
.get(index)
|
|
||||||
.map(|existing_item| json_value_to_bson_preserving_existing(item, existing_item))
|
|
||||||
.unwrap_or_else(|| json_value_to_bson(item))
|
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect::<Result<Vec<_>, _>>()
|
||||||
),
|
.map(Bson::Array)
|
||||||
(serde_json::Value::Object(obj), Bson::Document(existing_doc)) => Bson::Document(
|
}
|
||||||
|
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()
|
obj.iter()
|
||||||
.map(|(key, item)| {
|
.map(|(key, item)| {
|
||||||
let bson = existing_doc
|
json_value_to_bson_preserving_existing(
|
||||||
.get(key)
|
item,
|
||||||
.map(|existing_item| json_value_to_bson_preserving_existing(item, existing_item))
|
existing_document.and_then(|document| document.get(key)),
|
||||||
.unwrap_or_else(|| json_value_to_bson(item));
|
)
|
||||||
(key.clone(), bson)
|
.map(|bson| (key.clone(), bson))
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect::<Result<Document, _>>()
|
||||||
),
|
.map(Bson::Document)
|
||||||
_ => json_value_to_bson(value),
|
}
|
||||||
|
_ => 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::Array(arr) => Bson::Array(arr.iter().map(json_value_to_bson).collect()),
|
||||||
serde_json::Value::Object(obj) => {
|
serde_json::Value::Object(obj) => {
|
||||||
// Extended JSON: {"$oid":"..."} → BSON ObjectId
|
if let Ok(Some(value)) = parse_extended_json_value(obj) {
|
||||||
if obj.len() == 1 {
|
return value;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let doc: Document = obj.iter().map(|(k, v)| (k.clone(), json_value_to_bson(v))).collect();
|
let doc: Document = obj.iter().map(|(k, v)| (k.clone(), json_value_to_bson(v))).collect();
|
||||||
Bson::Document(doc)
|
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<String, serde_json::Value>) -> Result<Option<Bson>, 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<DateTime> {
|
fn parse_mongo_shell_date(value: &str) -> Option<DateTime> {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if let Some(inner) = trimmed.strip_prefix("ISODate(").or_else(|| trimmed.strip_prefix("new Date(")) {
|
if let Some(inner) = trimmed.strip_prefix("ISODate(").or_else(|| trimmed.strip_prefix("new Date(")) {
|
||||||
@@ -2150,7 +2206,124 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 oid = ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap();
|
||||||
let nested_oid = ObjectId::parse_str("507f191e810c19729de860ea").unwrap();
|
let nested_oid = ObjectId::parse_str("507f191e810c19729de860ea").unwrap();
|
||||||
let value = Bson::Document(doc! {
|
let value = Bson::Document(doc! {
|
||||||
@@ -2158,7 +2331,7 @@ mod tests {
|
|||||||
"owner": { "id": Bson::ObjectId(nested_oid) },
|
"owner": { "id": Bson::ObjectId(nested_oid) },
|
||||||
"tags": [Bson::ObjectId(nested_oid)],
|
"tags": [Bson::ObjectId(nested_oid)],
|
||||||
})
|
})
|
||||||
.into_relaxed_extjson();
|
.into_canonical_extjson();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
value,
|
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]
|
#[test]
|
||||||
fn index_info_from_model_maps_mongodb_index_metadata() {
|
fn index_info_from_model_maps_mongodb_index_metadata() {
|
||||||
let model = IndexModel::builder()
|
let model = IndexModel::builder()
|
||||||
|
|||||||
@@ -367,7 +367,12 @@ pub async fn find_documents_core(
|
|||||||
let connections = state.connections.read().await;
|
let connections = state.connections.read().await;
|
||||||
match connections.get(connection_id).ok_or("Not found")? {
|
match connections.get(connection_id).ok_or("Not found")? {
|
||||||
PoolKind::MongoDb(client) => {
|
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) => {
|
PoolKind::Elasticsearch(client) => {
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
@@ -393,12 +398,23 @@ pub async fn find_documents_core(
|
|||||||
if let Some(projection) = projection {
|
if let Some(projection) = projection {
|
||||||
params["projection"] = serde_json::json!(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()),
|
_ => 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(
|
pub async fn insert_document_core(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"dev:tauri": "tauri dev",
|
"dev:tauri": "tauri dev",
|
||||||
"dev:web": "vite --config apps/desktop/vite.config.ts --port 5173 --mode web",
|
"dev:web": "vite --config apps/desktop/vite.config.ts --port 5173 --mode web",
|
||||||
"dev:backend": "node scripts/dev-backend.mjs",
|
"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",
|
"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",
|
"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",
|
"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",
|
||||||
|
|||||||
+26
-1
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "vitest";
|
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";
|
import type { CellValue } from "../../apps/desktop/src/lib/dataGrid/cellValue.ts";
|
||||||
|
|
||||||
test("buildDataGridCellDetail returns null for an invalid column", () => {
|
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), '{\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");
|
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", () => {
|
test("buildDataGridColumnDetail maps a whole column across rows", () => {
|
||||||
const typeByColumn = new Map([["name", "varchar"]]);
|
const typeByColumn = new Map([["name", "varchar"]]);
|
||||||
const commentByColumn = new Map([["name", "display name"]]);
|
const commentByColumn = new Map([["name", "display name"]]);
|
||||||
|
|||||||
+27
@@ -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":"<script>"}');
|
||||||
|
|
||||||
|
assert.equal(html.includes("<script>"), false);
|
||||||
|
assert.equal(html.includes("<script>"), true);
|
||||||
|
});
|
||||||
+160
-2
@@ -1,14 +1,20 @@
|
|||||||
import { strict as assert } from "node:assert";
|
import { strict as assert } from "node:assert";
|
||||||
import { test } from "vitest";
|
import { test } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
arrayObjectAncestorPathForDocumentField,
|
||||||
buildDocumentFilterCondition,
|
buildDocumentFilterCondition,
|
||||||
buildElasticsearchQueryFromRules,
|
buildElasticsearchQueryFromRules,
|
||||||
combineDocumentFilterConditions,
|
combineDocumentFilterConditions,
|
||||||
currentDocumentFilterJson,
|
currentDocumentFilterJson,
|
||||||
|
documentFieldPathOptionsFromDocuments,
|
||||||
|
documentFieldPathTreeFromDocuments,
|
||||||
documentStoreProviderFor,
|
documentStoreProviderFor,
|
||||||
elasticsearchQueryTypeOptions,
|
elasticsearchQueryTypeOptions,
|
||||||
elasticsearchSearchBodyFromDocumentQuery,
|
elasticsearchSearchBodyFromDocumentQuery,
|
||||||
elasticsearchStructuredFilter,
|
elasticsearchStructuredFilter,
|
||||||
|
flattenDocumentFieldPathTree,
|
||||||
|
formatDocumentQueryInput,
|
||||||
|
searchDocumentFieldPathTree,
|
||||||
type DocumentFilterRule,
|
type DocumentFilterRule,
|
||||||
} from "../../apps/desktop/src/lib/app/documentStoreProvider.ts";
|
} from "../../apps/desktop/src/lib/app/documentStoreProvider.ts";
|
||||||
|
|
||||||
@@ -47,12 +53,164 @@ test("providers build store-specific query previews", () => {
|
|||||||
|
|
||||||
test("builds reusable document filter conditions", () => {
|
test("builds reusable document filter conditions", () => {
|
||||||
assert.deepEqual(buildDocumentFilterCondition(rule({})), { city: "长治" });
|
assert.deepEqual(buildDocumentFilterCondition(rule({})), { city: "长治" });
|
||||||
assert.deepEqual(buildDocumentFilterCondition(rule({ mode: "not-like", rawValue: "test" })), {
|
assert.deepEqual(buildDocumentFilterCondition(rule({ mode: "like", rawValue: "a.b[0]*" })), {
|
||||||
city: { $not: { $regex: "test", $options: "i" } },
|
city: { $regex: "a\\.b\\[0\\]\\*", $options: "i" },
|
||||||
|
});
|
||||||
|
assert.deepEqual(buildDocumentFilterCondition(rule({ mode: "not-like", rawValue: "test?" })), {
|
||||||
|
city: { $not: { $regex: "test\\?", $options: "i" } },
|
||||||
});
|
});
|
||||||
assert.deepEqual(buildDocumentFilterCondition(rule({ mode: "is-not-null", rawValue: "" })), { city: { $ne: null } });
|
assert.deepEqual(buildDocumentFilterCondition(rule({ mode: "is-not-null", rawValue: "" })), { city: { $ne: null } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("preserves Extended JSON types for MongoDB structured filters", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildDocumentFilterCondition(rule({ fieldName: "profile.ownerId", rawValue: "507f1f77bcf86cd799439011" }), {
|
||||||
|
kind: "mongodb",
|
||||||
|
sampleValue: { $oid: "507f1f77bcf86cd799439011" },
|
||||||
|
}),
|
||||||
|
{ "profile.ownerId": { $oid: "507f1f77bcf86cd799439011" } },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
buildDocumentFilterCondition(rule({ fieldName: "profile.createdAt", rawValue: "2026-07-13T00:00:00Z" }), {
|
||||||
|
kind: "mongodb",
|
||||||
|
sampleValue: { $date: "2025-01-01T00:00:00Z" },
|
||||||
|
}),
|
||||||
|
{ "profile.createdAt": { $date: "2026-07-13T00:00:00Z" } },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
buildDocumentFilterCondition(rule({ fieldName: "ownerIds", rawValue: "507f1f77bcf86cd799439013" }), {
|
||||||
|
kind: "mongodb",
|
||||||
|
sampleValue: [{ $oid: "507f1f77bcf86cd799439011" }, null, { $oid: "507f1f77bcf86cd799439012" }],
|
||||||
|
}),
|
||||||
|
{ ownerIds: { $oid: "507f1f77bcf86cd799439013" } },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
buildDocumentFilterCondition(rule({ fieldName: "eventDates", rawValue: "2026-07-13T00:00:00Z" }), {
|
||||||
|
kind: "mongodb",
|
||||||
|
sampleValue: [{ $date: "2025-01-01T00:00:00Z" }, { $date: { $numberLong: "1752364800000" } }],
|
||||||
|
}),
|
||||||
|
{ eventDates: { $date: "2026-07-13T00:00:00Z" } },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
buildDocumentFilterCondition(rule({ fieldName: "mixed", rawValue: "507f1f77bcf86cd799439013" }), {
|
||||||
|
kind: "mongodb",
|
||||||
|
sampleValue: [{ $oid: "507f1f77bcf86cd799439011" }, "plain-string"],
|
||||||
|
}),
|
||||||
|
{ mixed: "507f1f77bcf86cd799439013" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts nested document field paths for structured filters", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
documentFieldPathOptionsFromDocuments([
|
||||||
|
{ _id: "1", profile: { city: "上海", address: { zip: 200000 } }, tags: ["a"] },
|
||||||
|
{ _id: "2", status: "active", profile: { city: "北京" }, audit: [{ by: "ops" }] },
|
||||||
|
]),
|
||||||
|
["_id", "profile", "profile.city", "profile.address", "profile.address.zip", "tags", "status", "audit", "audit.by"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats BSON Extended JSON wrappers as scalar document fields", () => {
|
||||||
|
const tree = documentFieldPathTreeFromDocuments([
|
||||||
|
{
|
||||||
|
_id: { $oid: "507f1f77bcf86cd799439011" },
|
||||||
|
ownerId: { $oid: "507f1f77bcf86cd799439012" },
|
||||||
|
createdAt: { $date: { $numberLong: "1752364800000" } },
|
||||||
|
sequence: { $numberLong: "9007199254740993" },
|
||||||
|
encoded: { $binary: { base64: "AQID", subType: "00" }, $type: "00" },
|
||||||
|
profile: { name: "Ada" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const flattened = flattenDocumentFieldPathTree(tree);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
flattened.map((node) => node.path),
|
||||||
|
["_id", "ownerId", "createdAt", "sequence", "encoded", "profile", "profile.name"],
|
||||||
|
);
|
||||||
|
assert.equal(flattened.find((node) => node.path === "ownerId")?.kind, "scalar");
|
||||||
|
assert.equal(flattened.find((node) => node.path === "createdAt")?.kind, "scalar");
|
||||||
|
assert.equal(flattened.find((node) => node.path === "sequence")?.kind, "scalar");
|
||||||
|
assert.equal(flattened.find((node) => node.path === "encoded")?.kind, "scalar");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds hierarchical document field path tree for array objects", () => {
|
||||||
|
const tree = documentFieldPathTreeFromDocuments([{ _id: "1", orders: [{ sku: "A", qty: 2 }], batches: [[{ lot: "L-1" }]], tags: ["a"], profile: { address: { zip: 1 } } }]);
|
||||||
|
const flattened = flattenDocumentFieldPathTree(tree);
|
||||||
|
const orders = tree.find((node) => node.path === "orders");
|
||||||
|
const batches = tree.find((node) => node.path === "batches");
|
||||||
|
const sku = flattened.find((node) => node.path === "orders.sku");
|
||||||
|
|
||||||
|
assert.equal(orders?.kind, "array-object");
|
||||||
|
assert.equal(batches?.kind, "array-object");
|
||||||
|
assert.equal(orders?.label, "orders[]");
|
||||||
|
assert.equal(sku?.path, "orders.sku");
|
||||||
|
assert.equal(sku?.displayPath, "orders[] > sku");
|
||||||
|
assert.deepEqual(sku?.sampleValue, "A");
|
||||||
|
assert.equal(arrayObjectAncestorPathForDocumentField(tree, "orders.sku"), "orders");
|
||||||
|
assert.equal(arrayObjectAncestorPathForDocumentField(tree, "profile.address.zip"), null);
|
||||||
|
assert.deepEqual(
|
||||||
|
flattened.map((node) => node.path),
|
||||||
|
["_id", "orders", "orders.sku", "orders.qty", "batches", "batches.lot", "tags", "profile", "profile.address", "profile.address.zip"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("excludes BSON Extended JSON wrappers from array document field paths", () => {
|
||||||
|
const tree = documentFieldPathTreeFromDocuments([
|
||||||
|
{
|
||||||
|
_id: "1",
|
||||||
|
timestamps: [{ $date: { $numberLong: "1752364800000" } }, { $date: { $numberLong: "1752451200000" } }],
|
||||||
|
ownerIds: [{ $oid: "507f1f77bcf86cd799439011" }],
|
||||||
|
sequences: [{ $numberLong: "9007199254740993" }],
|
||||||
|
nestedDates: [[{ $date: { $numberLong: "1752364800000" } }]],
|
||||||
|
events: [{ at: { $date: { $numberLong: "1752364800000" } }, by: "ops" }, { $date: { $numberLong: "1752451200000" } }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const flattened = flattenDocumentFieldPathTree(tree);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
flattened.map((node) => node.path),
|
||||||
|
["_id", "timestamps", "ownerIds", "sequences", "nestedDates", "events", "events.at", "events.by"],
|
||||||
|
);
|
||||||
|
assert.equal(flattened.find((node) => node.path === "timestamps")?.kind, "array");
|
||||||
|
assert.equal(flattened.find((node) => node.path === "ownerIds")?.kind, "array");
|
||||||
|
assert.equal(flattened.find((node) => node.path === "sequences")?.kind, "array");
|
||||||
|
assert.equal(flattened.find((node) => node.path === "nestedDates")?.kind, "array");
|
||||||
|
assert.equal(flattened.find((node) => node.path === "events")?.kind, "array-object");
|
||||||
|
assert.equal(flattened.find((node) => node.path === "events.at")?.kind, "scalar");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searches nested document field paths", () => {
|
||||||
|
const tree = documentFieldPathTreeFromDocuments([{ profile: { address: { zip: 200000 }, city: "Shanghai" }, orders: [{ sku: "A" }] }]);
|
||||||
|
assert.deepEqual(
|
||||||
|
searchDocumentFieldPathTree(tree, "address").map((node) => node.path),
|
||||||
|
["profile.address", "profile.address.zip"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
searchDocumentFieldPathTree(tree, "orders[] > sku").map((node) => node.path),
|
||||||
|
["orders.sku"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses elemMatch only for AND conditions on the same array object", () => {
|
||||||
|
const conditions = [{ "orders.sku": "A" }, { "orders.qty": 2 }];
|
||||||
|
const rules = [rule({ fieldName: "orders.sku" }), rule({ fieldName: "orders.qty", rawValue: "2", conjunction: "AND" })];
|
||||||
|
assert.deepEqual(combineDocumentFilterConditions(conditions, rules, ["orders", "orders"]), {
|
||||||
|
orders: { $elemMatch: { $and: [{ sku: "A" }, { qty: 2 }] } },
|
||||||
|
});
|
||||||
|
assert.deepEqual(combineDocumentFilterConditions([{ status: "active" }, ...conditions], [rule({ fieldName: "status" }), ...rules], [null, "orders", "orders"]), {
|
||||||
|
$and: [{ status: "active" }, { orders: { $elemMatch: { $and: [{ sku: "A" }, { qty: 2 }] } } }],
|
||||||
|
});
|
||||||
|
assert.deepEqual(combineDocumentFilterConditions(conditions, rules, ["orders", "lineItems"]), {
|
||||||
|
$and: conditions,
|
||||||
|
});
|
||||||
|
assert.deepEqual(combineDocumentFilterConditions(conditions, [rules[0], rule({ fieldName: "orders.qty", rawValue: "2", conjunction: "OR" })], ["orders", "orders"]), {
|
||||||
|
$or: conditions,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats document query object input", () => {
|
||||||
|
assert.equal(formatDocumentQueryInput('{profile:{city:"上海"},status:"active"}', "mongodb"), ["{", ' "profile": {', ' "city": "上海"', " },", ' "status": "active"', "}"].join("\n"));
|
||||||
|
});
|
||||||
test("preserves MongoDB int64 document filter values", () => {
|
test("preserves MongoDB int64 document filter values", () => {
|
||||||
const id = "2048938405781032962";
|
const id = "2048938405781032962";
|
||||||
const firstUnsafeInteger = "9007199254740993";
|
const firstUnsafeInteger = "9007199254740993";
|
||||||
|
|||||||
+43
-18
@@ -1,6 +1,17 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "vitest";
|
import { test } from "vitest";
|
||||||
import { applyMongoGridChangesToDocument, buildMongoCopyDocumentFromOriginal, buildMongoCopyInsertDocument, buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentIdForGrid, parseMongoDocumentInputValue, serializeMongoDocumentId } from "../../apps/desktop/src/lib/mongo/mongoDocumentValues.ts";
|
import {
|
||||||
|
applyMongoGridChangesToDocument,
|
||||||
|
buildMongoCopyDocumentFromOriginal,
|
||||||
|
buildMongoCopyInsertDocument,
|
||||||
|
buildMongoInsertDocument,
|
||||||
|
buildMongoUpdateDocument,
|
||||||
|
formatMongoShellLiteral,
|
||||||
|
mongoDocumentDisplayValue,
|
||||||
|
mongoDocumentIdForGrid,
|
||||||
|
parseMongoDocumentInputValue,
|
||||||
|
serializeMongoDocumentId,
|
||||||
|
} from "../../apps/desktop/src/lib/mongo/mongoDocumentValues.ts";
|
||||||
|
|
||||||
test("parses Mongo shell ISODate literals as extended JSON dates", () => {
|
test("parses Mongo shell ISODate literals as extended JSON dates", () => {
|
||||||
assert.deepEqual(parseMongoDocumentInputValue('ISODate("2026-06-10T13:59:31.287Z")'), {
|
assert.deepEqual(parseMongoDocumentInputValue('ISODate("2026-06-10T13:59:31.287Z")'), {
|
||||||
@@ -187,26 +198,20 @@ test("projects original Mongo values without guessing types", () => {
|
|||||||
hidden: "not selected",
|
hidden: "not selected",
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(buildMongoCopyDocumentFromOriginal(original, ["ignored", "ignored", "ignored", "ignored", "ignored"], ["numericText", "booleanText", "jsonText", "dateText", "profile"], [false, false, false, false, false]), {
|
||||||
buildMongoCopyDocumentFromOriginal(original, ["ignored", "ignored", "ignored", "ignored", "ignored"], ["numericText", "booleanText", "jsonText", "dateText", "profile"], [false, false, false, false, false]),
|
numericText: "123",
|
||||||
{
|
booleanText: "true",
|
||||||
numericText: "123",
|
jsonText: '{"kind":"literal"}',
|
||||||
booleanText: "true",
|
dateText: "2024-01-01 00:00:00",
|
||||||
jsonText: '{"kind":"literal"}',
|
profile: { role: "admin" },
|
||||||
dateText: "2024-01-01 00:00:00",
|
});
|
||||||
profile: { role: "admin" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("applies only explicit Mongo grid edits to copied original documents", () => {
|
test("applies only explicit Mongo grid edits to copied original documents", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(buildMongoCopyDocumentFromOriginal({ _id: "1", count: "123", profile: { role: "admin" } }, ["1", "456", '{"role":"maintainer"}'], ["_id", "count", "profile"], [false, true, false], { excludePrimaryKeys: true }), {
|
||||||
buildMongoCopyDocumentFromOriginal({ _id: "1", count: "123", profile: { role: "admin" } }, ["1", "456", '{"role":"maintainer"}'], ["_id", "count", "profile"], [false, true, false], { excludePrimaryKeys: true }),
|
count: 456,
|
||||||
{
|
profile: { role: "admin" },
|
||||||
count: 456,
|
});
|
||||||
profile: { role: "admin" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("formats extended JSON dates as Mongo shell ISODate literals", () => {
|
test("formats extended JSON dates as Mongo shell ISODate literals", () => {
|
||||||
@@ -241,3 +246,23 @@ test("serializes typed Mongo document ids while keeping their grid display compa
|
|||||||
test("formats extended JSON int64 values as Mongo shell NumberLong literals", () => {
|
test("formats extended JSON int64 values as Mongo shell NumberLong literals", () => {
|
||||||
assert.equal(formatMongoShellLiteral({ snowflake: { $numberLong: "9007199254740993" } }), '{"snowflake":NumberLong("9007199254740993")}');
|
assert.equal(formatMongoShellLiteral({ snowflake: { $numberLong: "9007199254740993" } }), '{"snowflake":NumberLong("9007199254740993")}');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps normal Mongo values readable and unsafe Int64 editable", () => {
|
||||||
|
assert.equal(mongoDocumentDisplayValue(42), 42);
|
||||||
|
assert.equal(mongoDocumentDisplayValue(3.5), 3.5);
|
||||||
|
assert.equal(mongoDocumentDisplayValue('ISODate("2026-07-14T00:00:00Z")'), 'ISODate("2026-07-14T00:00:00Z")');
|
||||||
|
assert.equal(mongoDocumentDisplayValue({ $numberLong: "9007199254740993" }), 'NumberLong("9007199254740993")');
|
||||||
|
assert.deepEqual(parseMongoDocumentInputValue('NumberLong("9007199254740993")'), { $numberLong: "9007199254740993" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds edits for Int32, Double, Date, and unsafe Int64", () => {
|
||||||
|
const changes = new Map<number, string | number | boolean | null>([
|
||||||
|
[1, 42],
|
||||||
|
[2, 3.5],
|
||||||
|
[3, 'ISODate("2026-07-14T00:00:00Z")'],
|
||||||
|
[4, 'NumberLong("9007199254740993")'],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(buildMongoUpdateDocument(changes, ["_id", "int32", "double", "createdAt", "unsafe"]), {
|
||||||
|
$set: { int32: 42, double: 3.5, createdAt: { $date: "2026-07-14T00:00:00Z" }, unsafe: { $numberLong: "9007199254740993" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+7
-2
@@ -1,6 +1,6 @@
|
|||||||
import { strict as assert } from "node:assert";
|
import { strict as assert } from "node:assert";
|
||||||
import { test } from "vitest";
|
import { afterEach, test } from "vitest";
|
||||||
import { createPinia, setActivePinia } from "pinia";
|
import { createPinia, disposePinia, getActivePinia, setActivePinia } from "pinia";
|
||||||
import { isReactive } from "vue";
|
import { isReactive } from "vue";
|
||||||
import { decodeQueryResultArchive } from "../../apps/desktop/src/lib/query/queryResultArchive.ts";
|
import { decodeQueryResultArchive } from "../../apps/desktop/src/lib/query/queryResultArchive.ts";
|
||||||
import { analyzeEditableQueryEditability } from "../../apps/desktop/src/lib/sql/sqlAnalysis.ts";
|
import { analyzeEditableQueryEditability } from "../../apps/desktop/src/lib/sql/sqlAnalysis.ts";
|
||||||
@@ -11,6 +11,11 @@ import { useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts
|
|||||||
import type { ConnectionConfig } from "../../apps/desktop/src/types/database.ts";
|
import type { ConnectionConfig } from "../../apps/desktop/src/types/database.ts";
|
||||||
import type { QueryResult } from "../../apps/desktop/src/types/database.ts";
|
import type { QueryResult } from "../../apps/desktop/src/types/database.ts";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
const pinia = getActivePinia();
|
||||||
|
if (pinia) disposePinia(pinia);
|
||||||
|
setActivePinia(undefined);
|
||||||
|
});
|
||||||
function installMemoryStorage() {
|
function installMemoryStorage() {
|
||||||
const values = new Map<string, string>();
|
const values = new Map<string, string>();
|
||||||
const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
param(
|
||||||
|
[string]$RustLog = "info",
|
||||||
|
[string]$DbxPassword = "test",
|
||||||
|
[switch]$NoWatch,
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$repoRoot = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")
|
||||||
|
Set-Location -LiteralPath $repoRoot
|
||||||
|
|
||||||
|
$env:RUST_LOG = $RustLog
|
||||||
|
$env:DBX_PASSWORD = $DbxPassword
|
||||||
|
|
||||||
|
Write-Host "[dbx-backend] repo: $repoRoot" -ForegroundColor Cyan
|
||||||
|
Write-Host "[dbx-backend] RUST_LOG=$env:RUST_LOG DBX_PASSWORD=$env:DBX_PASSWORD" -ForegroundColor DarkCyan
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
if ($NoWatch) {
|
||||||
|
Write-Host "[dbx-backend] dry run: cargo run -p dbx-web" -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host "[dbx-backend] dry run: cargo watch -x 'run -p dbx-web' (fallback: cargo run -p dbx-web)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$watchAvailable = $false
|
||||||
|
if (-not $NoWatch) {
|
||||||
|
try {
|
||||||
|
& cargo watch --version *> $null
|
||||||
|
$watchAvailable = ($LASTEXITCODE -eq 0)
|
||||||
|
} catch {
|
||||||
|
$watchAvailable = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($watchAvailable) {
|
||||||
|
Write-Host "[dbx-backend] starting: cargo watch -x 'run -p dbx-web'" -ForegroundColor Green
|
||||||
|
& cargo watch -x "run -p dbx-web"
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $NoWatch) {
|
||||||
|
Write-Warning "cargo-watch is unavailable; falling back to cargo run -p dbx-web. Install with: cargo install cargo-watch"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[dbx-backend] starting: cargo run -p dbx-web" -ForegroundColor Green
|
||||||
|
& cargo run -p dbx-web
|
||||||
|
exit $LASTEXITCODE
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
param(
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$repoRoot = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")
|
||||||
|
Set-Location -LiteralPath $repoRoot
|
||||||
|
|
||||||
|
Write-Host "[dbx-frontend] repo: $repoRoot" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Host "[dbx-frontend] dry run: pnpm.cmd run dev:web" -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[dbx-frontend] starting: pnpm.cmd run dev:web" -ForegroundColor Green
|
||||||
|
& pnpm.cmd run dev:web
|
||||||
|
exit $LASTEXITCODE
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableDelayedExpansion
|
||||||
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
if not exist "%SCRIPT_DIR%dev-full.ps1" (
|
||||||
|
if exist "%CD%\scripts\dev-full.ps1" set "SCRIPT_DIR=%CD%\scripts\"
|
||||||
|
)
|
||||||
|
cd /d "%SCRIPT_DIR%.."
|
||||||
|
if "%~1"=="--" shift
|
||||||
|
set "ARGS="
|
||||||
|
:collect_args
|
||||||
|
if "%~1"=="" goto run_script
|
||||||
|
set "ARGS=!ARGS! "%~1""
|
||||||
|
shift
|
||||||
|
goto collect_args
|
||||||
|
:run_script
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%dev-full.ps1" !ARGS!
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableDelayedExpansion
|
||||||
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
if not exist "%SCRIPT_DIR%dev-full.ps1" (
|
||||||
|
if exist "%CD%\scripts\dev-full.ps1" set "SCRIPT_DIR=%CD%\scripts\"
|
||||||
|
)
|
||||||
|
cd /d "%SCRIPT_DIR%.."
|
||||||
|
if "%~1"=="--" shift
|
||||||
|
set "ARGS="
|
||||||
|
:collect_args
|
||||||
|
if "%~1"=="" goto run_script
|
||||||
|
set "ARGS=!ARGS! "%~1""
|
||||||
|
shift
|
||||||
|
goto collect_args
|
||||||
|
:run_script
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%dev-full.ps1" !ARGS!
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
param(
|
||||||
|
[string]$RustLog = "info",
|
||||||
|
[string]$DbxPassword = "test",
|
||||||
|
[switch]$NoWatch,
|
||||||
|
[switch]$DryRun,
|
||||||
|
[Parameter(ValueFromRemainingArguments = $true)]
|
||||||
|
[string[]]$RemainingArgs
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
for ($i = 0; $i -lt $RemainingArgs.Count; $i++) {
|
||||||
|
switch ($RemainingArgs[$i]) {
|
||||||
|
"--" { }
|
||||||
|
"-DryRun" { $DryRun = $true }
|
||||||
|
"-NoWatch" { $NoWatch = $true }
|
||||||
|
"-RustLog" {
|
||||||
|
$i++
|
||||||
|
if ($i -ge $RemainingArgs.Count) { throw "Missing value for -RustLog" }
|
||||||
|
$RustLog = $RemainingArgs[$i]
|
||||||
|
}
|
||||||
|
"-DbxPassword" {
|
||||||
|
$i++
|
||||||
|
if ($i -ge $RemainingArgs.Count) { throw "Missing value for -DbxPassword" }
|
||||||
|
$DbxPassword = $RemainingArgs[$i]
|
||||||
|
}
|
||||||
|
default { throw "Unknown argument: $($RemainingArgs[$i])" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$repoRoot = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")
|
||||||
|
$backendScript = Join-Path $PSScriptRoot "dev-backend.ps1"
|
||||||
|
$frontendScript = Join-Path $PSScriptRoot "dev-frontend.ps1"
|
||||||
|
|
||||||
|
$shellCommand = Get-Command pwsh.exe -ErrorAction SilentlyContinue
|
||||||
|
if (-not $shellCommand) {
|
||||||
|
$shellCommand = Get-Command powershell.exe -ErrorAction Stop
|
||||||
|
}
|
||||||
|
$shell = $shellCommand.Source
|
||||||
|
|
||||||
|
$backendArgs = @(
|
||||||
|
"-NoExit",
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy", "Bypass",
|
||||||
|
"-File", $backendScript,
|
||||||
|
"-RustLog", $RustLog,
|
||||||
|
"-DbxPassword", $DbxPassword
|
||||||
|
)
|
||||||
|
if ($NoWatch) {
|
||||||
|
$backendArgs += "-NoWatch"
|
||||||
|
}
|
||||||
|
|
||||||
|
$frontendArgs = @(
|
||||||
|
"-NoExit",
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy", "Bypass",
|
||||||
|
"-File", $frontendScript
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Host "[dbx-dev] repo: $repoRoot" -ForegroundColor Cyan
|
||||||
|
Write-Host "[dbx-dev] shell: $shell" -ForegroundColor Cyan
|
||||||
|
Write-Host "[dbx-dev] backend window: $shell $($backendArgs -join ' ')" -ForegroundColor Yellow
|
||||||
|
Write-Host "[dbx-dev] frontend window: $shell $($frontendArgs -join ' ')" -ForegroundColor Yellow
|
||||||
|
& $shell -NoProfile -ExecutionPolicy Bypass -File $backendScript -RustLog $RustLog -DbxPassword $DbxPassword -NoWatch:$NoWatch -DryRun
|
||||||
|
& $shell -NoProfile -ExecutionPolicy Bypass -File $frontendScript -DryRun
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[dbx-dev] starting backend and frontend in separate windows..." -ForegroundColor Green
|
||||||
|
Write-Host "[dbx-dev] backend: RUST_LOG=$RustLog DBX_PASSWORD=$DbxPassword" -ForegroundColor DarkCyan
|
||||||
|
Write-Host "[dbx-dev] frontend: http://127.0.0.1:5173" -ForegroundColor DarkCyan
|
||||||
|
|
||||||
|
Start-Process -FilePath $shell -ArgumentList $backendArgs -WorkingDirectory $repoRoot
|
||||||
|
Start-Process -FilePath $shell -ArgumentList $frontendArgs -WorkingDirectory $repoRoot
|
||||||
@@ -13,5 +13,9 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
include: ["packages/app-tests/*.test.ts", "packages/node-core/tests/*.test.ts", "apps/desktop/src/**/*.spec.ts", "docs/lib/*.test.ts"],
|
include: ["packages/app-tests/*.test.ts", "packages/node-core/tests/*.test.ts", "apps/desktop/src/**/*.spec.ts", "docs/lib/*.test.ts"],
|
||||||
globalSetup: "packages/test-globals.ts",
|
globalSetup: "packages/test-globals.ts",
|
||||||
|
// Large store modules are dynamically imported in many specs. Limiting
|
||||||
|
// concurrency prevents those imports from starving timers and making
|
||||||
|
// otherwise deterministic deferred-promise tests exceed Vitest's timeout.
|
||||||
|
maxWorkers: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user