feat(mongodb): distinguish missing fields from BSON null in grid
This commit is contained in:
16 files changed
+542
-69
No files matched your search
@@ -78,6 +78,7 @@ import {
|
||||
buildMongoUpdateDocument,
|
||||
formatMongoShellLiteral,
|
||||
mongoDocumentDisplayValue,
|
||||
mongoDocumentGridValue,
|
||||
mongoDocumentGridColumnTypes,
|
||||
mongoDocumentIdForGrid,
|
||||
parseMongoDocumentInputValue,
|
||||
@@ -414,7 +415,12 @@ function documentGridColumns(documentsToRender: JsonRecord[]): string[] {
|
||||
|
||||
function documentGridRow(doc: JsonRecord, columns: string[], kind: DocumentStoreKind): QueryResult["rows"][number] {
|
||||
return columns.map((column) => {
|
||||
const value = mongoDocumentDisplayValue(doc[column]);
|
||||
const rawValue = doc[column];
|
||||
// MongoDB distinguishes a missing field from an explicit BSON null. Keep a
|
||||
// missing field visually blank; the NULL grid sentinel is reserved for an
|
||||
// existing field whose BSON value is null.
|
||||
if (kind === "mongodb" && rawValue === undefined) return "";
|
||||
const value = kind === "mongodb" ? mongoDocumentGridValue(rawValue) : mongoDocumentDisplayValue(rawValue);
|
||||
if (value === undefined || value === null) return null;
|
||||
if (column === "_id") return kind === "mongodb" ? mongoDocumentIdForGrid(value) : documentStoreValueForGrid(value, kind);
|
||||
if (typeof value === "object") return documentStoreValueForGrid(value, kind);
|
||||
@@ -2418,6 +2424,7 @@ defineExpose({ focusSearch });
|
||||
context="results"
|
||||
page-size-preference="table-open"
|
||||
:database-type="props.databaseType"
|
||||
:mongo-collection-grid="documentStoreProvider.kind === 'mongodb'"
|
||||
:mongo-update-target="mongoUpdateTarget"
|
||||
:editable="documentStoreEditable"
|
||||
:custom-save-handler="customSaveHandler"
|
||||
|
||||
@@ -98,6 +98,7 @@ import type { BuildSingleColumnAlterSqlOptions } from "@/lib/table/tableStructur
|
||||
import { buildTableSelectSql, qualifyTableReferencesInSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { generateCellValues, type CellValueGenerationKind } from "@/lib/dataGrid/cellValueGeneration";
|
||||
import { MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridDisplayText, mongoDocumentGridEditorText, mongoDocumentGridExternalValue, mongoDocumentGridInputValue } from "@/lib/mongo/mongoDocumentValues";
|
||||
import { compactHeaderColumnType, formatMetadataColumnTypeLabel, isNumericColumnType, resolveDataGridTypeVisualKind, resolveHeaderColumnType, resolveResultColumnType } from "@/lib/dataGrid/dataGridColumnType";
|
||||
import { dataGridCellTextClass, dataGridTypeVisualClass } from "@/lib/dataGrid/dataGridCellTextVisual";
|
||||
import { DATA_GRID_TYPE_COLOR_KEYS, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
|
||||
@@ -161,7 +162,7 @@ import {
|
||||
type BinaryCellDownloadMode,
|
||||
} from "@/lib/dataGrid/binaryCellDownload";
|
||||
import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer";
|
||||
import { canFormatCellDetailJson, cellDetailEditorText, defaultCellDetailTab, isGeometryColumnType, linkedCellDetailTarget, looksLikeJsonContainerText, visibleCellDetailTabs, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation";
|
||||
import { canFormatCellDetailJson, defaultCellDetailTab, isGeometryColumnType, linkedCellDetailTarget, looksLikeJsonContainerText, visibleCellDetailTabs, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation";
|
||||
import {
|
||||
buildDataGridCellDetail,
|
||||
buildDataGridColumnDetail,
|
||||
@@ -516,6 +517,8 @@ interface DataGridProps {
|
||||
manualTransactionSessionId?: string;
|
||||
onManualTransactionMutation?: () => void;
|
||||
mongoUpdateTarget?: MongoCopyUpdateTarget;
|
||||
/** Enables MongoDB collection-grid presentation for BSON null values. */
|
||||
mongoCollectionGrid?: boolean;
|
||||
queryEditabilityReason?: QueryEditabilityReason;
|
||||
allowInsertRows?: boolean;
|
||||
allowDeleteRows?: boolean;
|
||||
@@ -3547,6 +3550,8 @@ const editor = useDataGridEditor({
|
||||
dataGridQuickEntryEnabled: computed(() => settingsStore.editorSettings.dataGridQuickEntry),
|
||||
confirmDangerousRowDeletion: computed(() => settingsStore.editorSettings.confirmDangerousSqlExecution),
|
||||
initialEditColumn: firstVisibleColumnIndex,
|
||||
cellEditorText: cellEditorTextForValue,
|
||||
normalizeEditorInput: (value) => (props.mongoCollectionGrid ? mongoDocumentGridInputValue(value) : value),
|
||||
getRowItem,
|
||||
pageSize,
|
||||
currentPage,
|
||||
@@ -3823,6 +3828,10 @@ function cellEditContentNeedsExpandedEditor(options: { displayText: string; edit
|
||||
}
|
||||
|
||||
function cellEditorTextForValue(value: CellValue | undefined, columnIndex: number): string {
|
||||
if (props.mongoCollectionGrid) {
|
||||
const documentGridText = mongoDocumentGridEditorText(value);
|
||||
if (documentGridText !== undefined) return documentGridText;
|
||||
}
|
||||
return dataGridCellEditorText({
|
||||
value: value ?? null,
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
@@ -5448,6 +5457,16 @@ const contextCellDetail = computed(() => {
|
||||
if (!cell || cell.col < 0) return null;
|
||||
return cellDetailFor(cell.rowIndex, cell.col);
|
||||
});
|
||||
// The MongoDB collection grid stores an internal sentinel for explicit BSON
|
||||
// null; detail panes must render display text instead of leaking that marker.
|
||||
function gridDetailRawValue(value: CellValue): string {
|
||||
return props.mongoCollectionGrid ? (mongoDocumentGridDisplayText(value) ?? displayCellValue(value)) : displayCellValue(value);
|
||||
}
|
||||
|
||||
function gridDetailIsNullValue(value: CellValue): boolean {
|
||||
return value === null || (props.mongoCollectionGrid === true && value === MONGO_DOCUMENT_GRID_NULL);
|
||||
}
|
||||
|
||||
function cellDetailFor(rowIndex: number, columnIndex: number): DataGridCellDetail | null {
|
||||
const item = displayItemAt(rowIndex);
|
||||
if (!item) return null;
|
||||
@@ -5461,6 +5480,8 @@ function cellDetailFor(rowIndex: number, columnIndex: number): DataGridCellDetai
|
||||
resultColumnTypes: props.result.column_types,
|
||||
commentByColumn: columnCommentMap.value,
|
||||
displayValue: (value, index) => formatCellCached(value, index),
|
||||
rawValue: gridDetailRawValue,
|
||||
isNullValue: gridDetailIsNullValue,
|
||||
isEditable: canEditGridCellDetail({
|
||||
canEditCell: canEditCellItem(item, columnIndex),
|
||||
isDraft: !!item.isDraft,
|
||||
@@ -5534,6 +5555,8 @@ const rowDetail = computed(() => {
|
||||
resultColumnTypes: props.result.column_types,
|
||||
commentByColumn: columnCommentMap.value,
|
||||
displayValue: (value, index) => formatCellCached(value, index),
|
||||
rawValue: gridDetailRawValue,
|
||||
isNullValue: gridDetailIsNullValue,
|
||||
isEditableColumn: (columnIndex) =>
|
||||
canEditGridCellDetail({
|
||||
canEditCell: canEditCellItem(item, columnIndex),
|
||||
@@ -5565,6 +5588,8 @@ const columnDetail = computed(() => {
|
||||
resultColumnTypes: props.result.column_types,
|
||||
commentByColumn: columnCommentMap.value,
|
||||
displayValue: (value, index) => formatCellCached(value, index),
|
||||
rawValue: gridDetailRawValue,
|
||||
isNullValue: gridDetailIsNullValue,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5764,6 +5789,9 @@ const detailEdit = useDataGridCellDetailEdit({
|
||||
databaseType: resolvedDatabaseType,
|
||||
resultRows: computed(() => props.result.rows),
|
||||
getColumnInfo: (columnIndex) => tableColumnForGridColumn(columnIndex) ?? resultColumnInfoForGridColumn(columnIndex),
|
||||
cellEditorText: cellEditorTextForValue,
|
||||
normalizeEditorInput: (value) => (props.mongoCollectionGrid ? mongoDocumentGridInputValue(value) : value),
|
||||
nullValue: () => (props.mongoCollectionGrid ? MONGO_DOCUMENT_GRID_NULL : null),
|
||||
getRowItem,
|
||||
hydrateLargeValueCell,
|
||||
applyCellValue,
|
||||
@@ -6082,6 +6110,10 @@ function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string
|
||||
}
|
||||
|
||||
function formatCell(value: CellValue, columnIndex?: number, originalBytes?: number, limitDisplay = true): string {
|
||||
if (props.mongoCollectionGrid) {
|
||||
const documentGridText = mongoDocumentGridDisplayText(value);
|
||||
if (documentGridText !== undefined) return documentGridText;
|
||||
}
|
||||
const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex];
|
||||
if (formatter?.kind === "foreign-key-display" && columnIndex !== undefined) {
|
||||
const display = formatForeignKeyCellDisplay(value, columnIndex);
|
||||
@@ -7294,6 +7326,8 @@ const {
|
||||
columnComments: visibleColumnComments,
|
||||
allColumnComments,
|
||||
displayValue: formatCellCached,
|
||||
cellClipboardText: (value) => (props.mongoCollectionGrid ? mongoDocumentGridEditorText(value) : undefined),
|
||||
externalCellValue: (value) => (props.mongoCollectionGrid ? mongoDocumentGridExternalValue(value) : value),
|
||||
mongoDocuments: computed(() => props.result.mongo_copy_documents ?? props.result.mongo_documents),
|
||||
spatialColumns: computed(() => props.result.spatial_columns),
|
||||
spatialValues: computed(() => props.result.spatial_values),
|
||||
@@ -8144,7 +8178,7 @@ function applyGeneratedSelectionValue(kind: CellValueGenerationKind, startValue
|
||||
beginBatch();
|
||||
try {
|
||||
cells.forEach((cell, index) => {
|
||||
applied = applyVisibleSelectedCellValue(cell.item, cell.visibleCol, values[index] ?? null, allowDraftSelectionValue, { preserveEmptyString: kind === "empty" }) || applied;
|
||||
applied = applyVisibleSelectedCellValue(cell.item, cell.visibleCol, generatedGridValue(kind, values[index] ?? null), allowDraftSelectionValue, { preserveEmptyString: kind === "empty" }) || applied;
|
||||
});
|
||||
} finally {
|
||||
commitBatch();
|
||||
@@ -8159,17 +8193,24 @@ function applyGeneratedSelectionValue(kind: CellValueGenerationKind, startValue
|
||||
function applyGeneratedDetailValue(kind: CellValueGenerationKind, startValue = 1n): boolean {
|
||||
const detail = activeCellDetail.value;
|
||||
if (!detail?.isEditable) return false;
|
||||
const value = generateCellValues(kind, 1, { startValue })[0] ?? null;
|
||||
const value = generatedGridValue(kind, generateCellValues(kind, 1, { startValue })[0] ?? null);
|
||||
applyCellValue(detail.rowId, detail.colIndex, value, {
|
||||
preserveEmptyString: kind === "empty",
|
||||
});
|
||||
detailEditValue.value = cellDetailEditorText(value);
|
||||
detailEditValue.value = cellEditorTextForValue(value, detail.colIndex);
|
||||
syncEditorFromDetailEdit();
|
||||
isEditingDetail.value = activeCellDetailTab.value === "valueEditor";
|
||||
detailCell.value = { ...detailCell.value! };
|
||||
return true;
|
||||
}
|
||||
|
||||
function generatedGridValue(kind: CellValueGenerationKind, value: string | null): string | null {
|
||||
// MongoDB collection grids reserve an empty cell for a missing field. Their
|
||||
// private null marker becomes $set: null in the document save layer.
|
||||
if (kind === "null" && props.mongoCollectionGrid) return MONGO_DOCUMENT_GRID_NULL;
|
||||
return value;
|
||||
}
|
||||
|
||||
function openGenerateIncrementDialog(target: "selection" | "detail") {
|
||||
if (target === "selection" && (!props.editable || !selectionHasEditableCells())) return;
|
||||
if (target === "detail" && !activeCellDetail.value?.isEditable) return;
|
||||
@@ -8822,6 +8863,10 @@ async function onGridKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
|
||||
function detailClipboardText(detail: DataGridCellDetail): string {
|
||||
if (props.mongoCollectionGrid) {
|
||||
const documentGridText = mongoDocumentGridEditorText(detail.value);
|
||||
if (documentGridText !== undefined) return documentGridText;
|
||||
}
|
||||
if (detail.value === null) return "";
|
||||
const binaryText = binaryCellClipboardText(detail.value, detail.type, resolvedDatabaseType.value);
|
||||
if (binaryText !== null) return binaryText;
|
||||
@@ -8829,6 +8874,13 @@ function detailClipboardText(detail: DataGridCellDetail): string {
|
||||
return displayCellValue(detail.value);
|
||||
}
|
||||
|
||||
// Row/column detail copy payloads must carry external values: the collection
|
||||
// grid's BSON null marker is restored to a real null instead of leaking the
|
||||
// internal sentinel into clipboard JSON/TSV.
|
||||
function gridDetailExternalValue(value: CellValue): CellValue {
|
||||
return props.mongoCollectionGrid ? mongoDocumentGridExternalValue(value) : value;
|
||||
}
|
||||
|
||||
async function copyDetailValue() {
|
||||
const initialDetail = activeCellDetail.value;
|
||||
if (!initialDetail || !(await hydrateLargeValueCell(initialDetail.rowId, initialDetail.colIndex))) return;
|
||||
@@ -9040,7 +9092,7 @@ async function copyRowDetailJson() {
|
||||
try {
|
||||
const detail = await resolvedRowDetailForCopy();
|
||||
if (!detail) return;
|
||||
copyText(dataGridRowDetailJson(detail, undefined, resolvedDatabaseType.value));
|
||||
copyText(dataGridRowDetailJson(detail, undefined, resolvedDatabaseType.value, gridDetailExternalValue));
|
||||
} catch (error) {
|
||||
reportLargeValueLoadError(error);
|
||||
}
|
||||
@@ -9050,7 +9102,7 @@ async function copyRowDetailTsv() {
|
||||
try {
|
||||
const detail = await resolvedRowDetailForCopy();
|
||||
if (!detail) return;
|
||||
copyText(dataGridRowDetailTsv(detail, resolvedDatabaseType.value));
|
||||
copyText(dataGridRowDetailTsv(detail, resolvedDatabaseType.value, gridDetailExternalValue));
|
||||
} catch (error) {
|
||||
reportLargeValueLoadError(error);
|
||||
}
|
||||
@@ -9067,7 +9119,7 @@ async function copyColumnDetailJson() {
|
||||
try {
|
||||
const detail = await resolvedColumnDetailForCopy();
|
||||
if (!detail) return;
|
||||
copyText(dataGridColumnDetailJson(detail, resolvedDatabaseType.value));
|
||||
copyText(dataGridColumnDetailJson(detail, resolvedDatabaseType.value, gridDetailExternalValue));
|
||||
} catch (error) {
|
||||
reportLargeValueLoadError(error);
|
||||
}
|
||||
@@ -9077,7 +9129,7 @@ async function copyColumnDetailTsv() {
|
||||
try {
|
||||
const detail = await resolvedColumnDetailForCopy();
|
||||
if (!detail) return;
|
||||
copyText(dataGridColumnDetailTsv(detail, resolvedDatabaseType.value));
|
||||
copyText(dataGridColumnDetailTsv(detail, resolvedDatabaseType.value, gridDetailExternalValue));
|
||||
} catch (error) {
|
||||
reportLargeValueLoadError(error);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ defineExpose({ openSearch });
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.nullValue") }}</div>
|
||||
<div>{{ detail.value === null ? "true" : "false" }}</div>
|
||||
<div>{{ (detail.isNull ?? detail.value === null) ? "true" : "false" }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.valueLength") }}</div>
|
||||
@@ -124,7 +124,7 @@ defineExpose({ openSearch });
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.nullValue") }}</div>
|
||||
<div>{{ detail.value === null ? "true" : "false" }}</div>
|
||||
<div>{{ (detail.isNull ?? detail.value === null) ? "true" : "false" }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.valueLength") }}</div>
|
||||
@@ -202,7 +202,7 @@ defineExpose({ openSearch });
|
||||
><Button variant="outline" size="sm" class="h-6 text-xs" @click="emit('cancel')">{{ t("dangerDialog.cancel") }}</Button>
|
||||
</div>
|
||||
<div class="min-w-0 flex flex-wrap gap-1" :class="panelIsBottom ? 'ml-auto justify-end' : 'flex-col'">
|
||||
<Button v-if="detail.isEditable && detail.value !== null" variant="ghost" size="sm" class="h-6 justify-start text-xs" @click="emit('setNull')"><X class="w-3 h-3 mr-2" />{{ t("grid.setNull") }}</Button
|
||||
<Button v-if="detail.isEditable && !(detail.isNull ?? detail.value === null)" variant="ghost" size="sm" class="h-6 justify-start text-xs" @click="emit('setNull')"><X class="w-3 h-3 mr-2" />{{ t("grid.setNull") }}</Button
|
||||
><Button variant="ghost" size="sm" class="h-6 justify-start text-xs" @click="emit('copyColumnName')"><Copy class="w-3 h-3 mr-2" />{{ t("grid.copyColumnName") }}</Button
|
||||
><Button variant="ghost" size="sm" class="h-6 justify-start text-xs" :disabled="!canCopySqlCondition()" @click="emit('copySqlCondition')"><Code2 class="w-3 h-3 mr-2" />{{ t("grid.copySqlCondition") }}</Button>
|
||||
</div>
|
||||
|
||||
@@ -95,11 +95,11 @@ function onColumnDetailKeydown(event: KeyboardEvent) {
|
||||
<div v-if="field.comment" class="mt-1 text-[11px] text-muted-foreground whitespace-pre-wrap">{{ field.comment }}</div>
|
||||
</td>
|
||||
<td class="w-full max-w-0 px-3 py-2">
|
||||
<div class="mb-1 text-[11px] text-muted-foreground">{{ field.value === null ? t("grid.nullValue") : t("grid.valueLength") }}: {{ field.value === null ? "true" : field.length }}</div>
|
||||
<div class="mb-1 text-[11px] text-muted-foreground">{{ (field.isNull ?? field.value === null) ? t("grid.nullValue") : t("grid.valueLength") }}: {{ (field.isNull ?? field.value === null) ? "true" : field.length }}</div>
|
||||
<a v-if="field.imagePreviewUrl" :href="field.imagePreviewUrl" role="button" class="mb-2 block max-h-48 overflow-hidden rounded border bg-muted/20" @click.prevent="openImagePreview(field.imagePreviewUrl, field.column)"
|
||||
><img :src="field.imagePreviewUrl" :alt="field.column" loading="lazy" decoding="async" referrerpolicy="no-referrer" class="max-h-48 w-full object-contain"
|
||||
/></a>
|
||||
<pre class="dbx-data-grid-value-font max-h-44 overflow-auto rounded border bg-muted/20 p-2 text-xs whitespace-pre-wrap break-words" :class="{ 'italic text-muted-foreground': field.value === null }">{{ field.rawValuePreview }}</pre>
|
||||
<pre class="dbx-data-grid-value-font max-h-44 overflow-auto rounded border bg-muted/20 p-2 text-xs whitespace-pre-wrap break-words" :class="{ 'italic text-muted-foreground': field.isNull ?? field.value === null }">{{ field.rawValuePreview }}</pre>
|
||||
<div v-if="field.isValuePreviewTruncated" class="mt-1 text-[11px] text-muted-foreground">{{ t("grid.largeValuePreviewHint", { count: field.rawValuePreview.length }) }}</div>
|
||||
<div v-if="field.formattedJson" class="mt-2 space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
|
||||
@@ -165,11 +165,11 @@ function onColumnDetailKeydown(event: KeyboardEvent) {
|
||||
<tr v-for="field in filteredColumnFields" :key="`${field.rowId}:${field.colIndex}`" class="border-b align-top last:border-b-0">
|
||||
<td class="px-3 py-2 tabular-nums">{{ field.rowNumber }}</td>
|
||||
<td class="w-full max-w-0 px-3 py-2">
|
||||
<div class="mb-1 text-[11px] text-muted-foreground">{{ field.value === null ? t("grid.nullValue") : t("grid.valueLength") }}: {{ field.value === null ? "true" : field.length }}</div>
|
||||
<div class="mb-1 text-[11px] text-muted-foreground">{{ (field.isNull ?? field.value === null) ? t("grid.nullValue") : t("grid.valueLength") }}: {{ (field.isNull ?? field.value === null) ? "true" : field.length }}</div>
|
||||
<a v-if="field.imagePreviewUrl" :href="field.imagePreviewUrl" role="button" class="mb-2 block max-h-40 overflow-hidden rounded border bg-muted/20" @click.prevent="openImagePreview(field.imagePreviewUrl, field.column)"
|
||||
><img :src="field.imagePreviewUrl" :alt="field.column" loading="lazy" decoding="async" referrerpolicy="no-referrer" class="max-h-40 w-full object-contain"
|
||||
/></a>
|
||||
<pre class="dbx-data-grid-value-font max-h-36 overflow-auto rounded border bg-muted/20 p-2 text-xs whitespace-pre-wrap break-words" :class="{ 'italic text-muted-foreground': field.value === null }">{{ field.rawValuePreview }}</pre>
|
||||
<pre class="dbx-data-grid-value-font max-h-36 overflow-auto rounded border bg-muted/20 p-2 text-xs whitespace-pre-wrap break-words" :class="{ 'italic text-muted-foreground': field.isNull ?? field.value === null }">{{ field.rawValuePreview }}</pre>
|
||||
<div v-if="field.isValuePreviewTruncated" class="mt-1 text-[11px] text-muted-foreground">{{ t("grid.largeValuePreviewHint", { count: field.rawValuePreview.length }) }}</div>
|
||||
<div v-if="field.formattedJson" class="mt-2 space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { computed, ref } from "vue";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useDataGridCellDetailEdit } from "@/composables/useDataGridCellDetailEdit";
|
||||
import { MONGO_DOCUMENT_GRID_NULL } from "@/lib/mongo/mongoDocumentValues";
|
||||
import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
|
||||
|
||||
function detail(value: string): DataGridCellDetail {
|
||||
return {
|
||||
rowNumber: 1,
|
||||
rowId: 3,
|
||||
colIndex: 1,
|
||||
column: "value",
|
||||
type: "",
|
||||
comment: "",
|
||||
value,
|
||||
rawValue: value,
|
||||
rawValuePreview: value,
|
||||
displayValue: "NULL",
|
||||
displayValuePreview: "NULL",
|
||||
isValuePreviewTruncated: false,
|
||||
imagePreviewUrl: null,
|
||||
length: value.length,
|
||||
formattedJson: "",
|
||||
isEditable: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useDataGridCellDetailEdit", () => {
|
||||
it("preserves the Mongo collection null marker in detail editing and set-null", async () => {
|
||||
const activeDetail = ref<DataGridCellDetail | null>(detail(MONGO_DOCUMENT_GRID_NULL));
|
||||
const applyCellValue = vi.fn();
|
||||
const editorText = (value: string | number | boolean | null) => (value === MONGO_DOCUMENT_GRID_NULL ? "NULL" : String(value ?? ""));
|
||||
const editor = useDataGridCellDetailEdit({
|
||||
activeDetail: computed(() => activeDetail.value),
|
||||
activeTab: ref("details"),
|
||||
jsonFormatted: computed(() => false),
|
||||
databaseType: computed(() => "mongodb"),
|
||||
resultRows: computed(() => [["id", MONGO_DOCUMENT_GRID_NULL]]),
|
||||
getColumnInfo: () => undefined,
|
||||
cellEditorText: editorText,
|
||||
nullValue: () => MONGO_DOCUMENT_GRID_NULL,
|
||||
getRowItem: () => ({ sourceIndex: 0, isNew: false, isDeleted: false }),
|
||||
hydrateLargeValueCell: async () => true,
|
||||
applyCellValue,
|
||||
restoreCellValue: vi.fn(),
|
||||
syncEditor: vi.fn(),
|
||||
refreshDetail: vi.fn(),
|
||||
warnFormattedJsonEdit: vi.fn(),
|
||||
});
|
||||
|
||||
await editor.startDetailEdit();
|
||||
expect(editor.detailEditValue.value).toBe("NULL");
|
||||
|
||||
editor.commitDetailEdit();
|
||||
expect(applyCellValue).toHaveBeenLastCalledWith(3, 1, MONGO_DOCUMENT_GRID_NULL);
|
||||
|
||||
editor.setDetailNull();
|
||||
expect(applyCellValue).toHaveBeenLastCalledWith(3, 1, MONGO_DOCUMENT_GRID_NULL);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
import { computed, nextTick, ref, type Ref } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearDataGridPendingSnapshot, DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, useDataGridEditor } from "@/composables/useDataGridEditor";
|
||||
import { clearDataGridClipboardCopy, parseDataGridClipboard, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard";
|
||||
import { buildMongoUpdateDocument, MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridInputValue, mongoDocumentGridValue } from "@/lib/mongo/mongoDocumentValues";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -46,6 +48,7 @@ function createEditorWithResult(
|
||||
existingRows: CellValue[][] = [],
|
||||
onCellValueChanged?: (rowId: number, columnIndex: number) => void,
|
||||
tableColumns?: Array<{ name: string; data_type: string; extra?: string; column_default?: string }>,
|
||||
mongoCollectionGrid = false,
|
||||
) {
|
||||
let editor: ReturnType<typeof useDataGridEditor>;
|
||||
const result = ref<{ columns: string[]; rows: CellValue[][] }>({
|
||||
@@ -56,7 +59,8 @@ function createEditorWithResult(
|
||||
editor = useDataGridEditor({
|
||||
result: computed(() => result.value),
|
||||
editable: computed(() => true),
|
||||
databaseType: computed(() => "postgres"),
|
||||
databaseType: computed(() => (mongoCollectionGrid ? "mongodb" : "postgres")),
|
||||
normalizeEditorInput: mongoCollectionGrid ? mongoDocumentGridInputValue : undefined,
|
||||
connectionId: computed(() => "connection-1"),
|
||||
database: computed(() => "app"),
|
||||
tableMeta: computed(() => ({
|
||||
@@ -1188,3 +1192,21 @@ describe("useDataGridEditor cell edit focus", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mongo collection-grid clipboard round-trip", () => {
|
||||
it.each([null, MONGO_DOCUMENT_GRID_NULL, "\u0000dbx:mongo-document-grid:string:literal", "NULL"])("preserves BSON value %j through paste and save", (bsonValue) => {
|
||||
const encoded = mongoDocumentGridValue(bsonValue) as string;
|
||||
const editor = createEditor(undefined, true, undefined, undefined, [["before", "", ""]], undefined, undefined, true);
|
||||
try {
|
||||
// The OS clipboard is display text; the internal matrix retains encoding.
|
||||
rememberDataGridClipboardCopy("copied text", [[encoded]]);
|
||||
const pasted = parseDataGridClipboard("copied text")[0]![0]!;
|
||||
editor.applyCellValue(0, 0, pasted);
|
||||
const changes = editor.dirtyRows.value.get(0)!;
|
||||
expect(changes.get(0)).toBe(encoded);
|
||||
expect(buildMongoUpdateDocument(changes, ["value"], { value: "before" })).toEqual({ $set: { value: bsonValue } });
|
||||
} finally {
|
||||
clearDataGridClipboardCopy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDataGridExport, type UseDataGridExportOptions } from "@/composables/useDataGridExport";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
@@ -7,9 +8,10 @@ import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import type { DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
|
||||
import type { CellSelectionMatrix, SelectionData } from "@/lib/dataGrid/gridSelection";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import { extractDataGridSelection, exportQueryResultJson } from "@/lib/backend/api";
|
||||
import { extractDataGridSelection, exportQueryResultCsv, exportQueryResultJson } from "@/lib/backend/api";
|
||||
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
import { clearDataGridClipboardCopy, parseDataGridClipboard } from "@/lib/dataGrid/dataGridClipboard";
|
||||
import { MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridExternalValue } from "@/lib/mongo/mongoDocumentValues";
|
||||
|
||||
const toast = vi.fn();
|
||||
|
||||
@@ -46,6 +48,7 @@ vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
return {
|
||||
...original,
|
||||
extractDataGridSelection: vi.fn(),
|
||||
exportQueryResultCsv: vi.fn(),
|
||||
exportQueryResultJson: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -72,6 +75,7 @@ function createMongoExportState(options: {
|
||||
contextColumn?: number;
|
||||
syntheticContext?: boolean;
|
||||
fullExportResult?: UseDataGridExportOptions["fullExportResult"];
|
||||
externalCellValue?: UseDataGridExportOptions["externalCellValue"];
|
||||
}) {
|
||||
const items = options.items ?? [options.item];
|
||||
const selectedRowIds = options.selectedRowIds ?? new Set<number>();
|
||||
@@ -102,6 +106,7 @@ function createMongoExportState(options: {
|
||||
selectedRowIds: ref(selectedRowIds),
|
||||
hasRowSelection: computed(() => selectedRowIds.size > 0),
|
||||
fullExportResult: options.fullExportResult,
|
||||
externalCellValue: options.externalCellValue,
|
||||
};
|
||||
return useDataGridExport(state);
|
||||
}
|
||||
@@ -1513,6 +1518,74 @@ describe("useDataGridExport prepared row statements", () => {
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores Mongo collection-grid values before bulk copy, extractors, and CSV export", async () => {
|
||||
const item = { ...row(["1", MONGO_DOCUMENT_GRID_NULL]), sourceIndex: 0 };
|
||||
const state = createMongoExportState({
|
||||
columns: ["_id", "nullable"],
|
||||
item,
|
||||
mongoDocuments: [{ _id: "1", nullable: null }],
|
||||
externalCellValue: (value) => mongoDocumentGridExternalValue(value) as CellValue,
|
||||
selectedCellMatrix: {
|
||||
rowIndexes: [0],
|
||||
columnIndexes: [1],
|
||||
columns: ["nullable"],
|
||||
rows: [[MONGO_DOCUMENT_GRID_NULL]],
|
||||
},
|
||||
});
|
||||
|
||||
await state.copyAll();
|
||||
expect(copyToClipboard).toHaveBeenCalledWith("_id\tnullable\n1\t");
|
||||
|
||||
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "", mimeType: "text/tab-separated-values", fileExtension: "tsv", rowCount: 1, columnCount: 1 });
|
||||
await expect(state.copyWithExtractor("tsv")).resolves.toBe(true);
|
||||
expect(extractDataGridSelection).toHaveBeenCalledWith(expect.objectContaining({ rows: [[null]] }));
|
||||
|
||||
setActivePinia(createPinia());
|
||||
await state.exportCurrentPageCsv();
|
||||
expect(exportQueryResultCsv).toHaveBeenCalledWith(expect.any(String), ["_id", "nullable"], [["1", null]], expect.anything());
|
||||
|
||||
const reservedString = MONGO_DOCUMENT_GRID_NULL;
|
||||
const fullExportState = createMongoExportState({
|
||||
columns: ["_id", "value"],
|
||||
item: { ...row(["1", mongoDocumentGridExternalValue(reservedString)]), sourceIndex: 0 },
|
||||
mongoDocuments: [{ _id: "1", value: reservedString }],
|
||||
externalCellValue: (value) => mongoDocumentGridExternalValue(value) as CellValue,
|
||||
fullExportResult: async () => ({
|
||||
columns: ["_id", "value"],
|
||||
column_types: ["", ""],
|
||||
rows: [["1", reservedString]],
|
||||
mongo_documents: [{ _id: "1", value: reservedString }],
|
||||
affected_rows: 1,
|
||||
execution_time_ms: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
await fullExportState.exportCsv();
|
||||
expect(exportQueryResultCsv).toHaveBeenLastCalledWith(expect.any(String), ["_id", "value"], [["1", reservedString]], expect.anything());
|
||||
});
|
||||
|
||||
it("exports missing Mongo fields as null while retaining explicit empty strings", async () => {
|
||||
const columns = ["_id", "missing", "nullable", "empty"];
|
||||
const document = { _id: "1", nullable: null, empty: "" };
|
||||
const item = { ...row(["1", "", MONGO_DOCUMENT_GRID_NULL, ""]), sourceIndex: 0 };
|
||||
const state = createMongoExportState({
|
||||
columns,
|
||||
item,
|
||||
mongoDocuments: [document],
|
||||
externalCellValue: mongoDocumentGridExternalValue,
|
||||
fullExportResult: async () => ({
|
||||
columns,
|
||||
column_types: ["", "", "", ""],
|
||||
rows: [item.data],
|
||||
mongo_copy_documents: [document],
|
||||
affected_rows: 1,
|
||||
execution_time_ms: 1,
|
||||
}),
|
||||
});
|
||||
await state.exportJson();
|
||||
expect(exportQueryResultJson).toHaveBeenCalledWith(expect.any(String), columns, [["1", null, null, ""]]);
|
||||
});
|
||||
|
||||
it("preserves Mongo Extended JSON objects and dates in JSON exports", async () => {
|
||||
const columns = ["_id", "valueMap", "createdTime"];
|
||||
const mongoCopyDocument = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, ref, watch, type ComputedRef, type Ref } from "vue";
|
||||
import { cellDetailEditorText, canFormatCellDetailJson, compactJsonText, formatJsonText, looksLikeJsonContainerText, valueEditorActions, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation";
|
||||
import { canFormatCellDetailJson, compactJsonText, formatJsonText, looksLikeJsonContainerText, valueEditorActions, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation";
|
||||
import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
|
||||
import { dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
@@ -19,6 +19,9 @@ export interface UseDataGridCellDetailEditOptions {
|
||||
databaseType: ComputedRef<DatabaseType | undefined>;
|
||||
resultRows: ComputedRef<readonly (readonly CellValue[])[]>;
|
||||
getColumnInfo: (columnIndex: number) => ColumnInfo | Pick<ColumnInfo, "data_type"> | undefined;
|
||||
cellEditorText?: (value: CellValue, columnIndex: number) => string;
|
||||
normalizeEditorInput?: (value: string, columnIndex: number) => string;
|
||||
nullValue?: (columnIndex: number) => string | null;
|
||||
getRowItem: (rowId: number) => DetailEditRowItem | undefined;
|
||||
hydrateLargeValueCell: (rowId: number, columnIndex: number) => Promise<boolean>;
|
||||
applyCellValue: (rowId: number, columnIndex: number, value: string | null) => void;
|
||||
@@ -63,6 +66,7 @@ export function useDataGridCellDetailEdit(options: UseDataGridCellDetailEditOpti
|
||||
});
|
||||
|
||||
function editorText(value: CellValue, columnIndex: number): string {
|
||||
if (options.cellEditorText) return options.cellEditorText(value, columnIndex);
|
||||
return dataGridCellEditorText({
|
||||
value,
|
||||
databaseType: options.databaseType.value,
|
||||
@@ -131,7 +135,8 @@ export function useDataGridCellDetailEdit(options: UseDataGridCellDetailEditOpti
|
||||
isEditingDetail.value = false;
|
||||
const item = options.getRowItem(detail.rowId);
|
||||
if (!item || item.isDeleted) return;
|
||||
options.applyCellValue(detail.rowId, detail.colIndex, detailEditValue.value);
|
||||
const value = detailEditValue.value === detailEditOriginalValue.value && (typeof detail.value === "string" || detail.value === null) ? detail.value : (options.normalizeEditorInput?.(detailEditValue.value, detail.colIndex) ?? detailEditValue.value);
|
||||
options.applyCellValue(detail.rowId, detail.colIndex, value);
|
||||
detailEditOriginalValue.value = detailEditValue.value;
|
||||
allowActiveCellDetailResync = true;
|
||||
options.refreshDetail();
|
||||
@@ -175,8 +180,11 @@ export function useDataGridCellDetailEdit(options: UseDataGridCellDetailEditOpti
|
||||
}
|
||||
|
||||
function setValueEditorNull() {
|
||||
const detail = options.activeDetail.value;
|
||||
if (!detail || !detail.isEditable) return;
|
||||
const value = options.nullValue?.(detail.colIndex) ?? null;
|
||||
setDetailNull();
|
||||
detailEditValue.value = cellDetailEditorText(null);
|
||||
detailEditValue.value = editorText(value, detail.colIndex);
|
||||
detailEditOriginalValue.value = detailEditValue.value;
|
||||
syncEditorFromDetailEdit();
|
||||
isEditingDetail.value = options.activeTab.value === "valueEditor";
|
||||
@@ -211,7 +219,7 @@ export function useDataGridCellDetailEdit(options: UseDataGridCellDetailEditOpti
|
||||
if (!detail || !detail.isEditable) return;
|
||||
const item = options.getRowItem(detail.rowId);
|
||||
if (!item || item.isDeleted) return;
|
||||
options.applyCellValue(detail.rowId, detail.colIndex, null);
|
||||
options.applyCellValue(detail.rowId, detail.colIndex, options.nullValue?.(detail.colIndex) ?? null);
|
||||
resetDetailEdit();
|
||||
options.refreshDetail();
|
||||
}
|
||||
|
||||
@@ -119,6 +119,10 @@ export interface UseDataGridEditorOptions {
|
||||
dataGridQuickEntryEnabled?: ComputedRef<boolean>;
|
||||
confirmDangerousRowDeletion?: ComputedRef<boolean>;
|
||||
initialEditColumn?: ComputedRef<number>;
|
||||
/** Converts a grid value to the text presented by the cell editor. */
|
||||
cellEditorText?: (value: CellValue, columnIndex: number) => string;
|
||||
/** Normalizes user-entered editor text before cell type coercion. */
|
||||
normalizeEditorInput?: (value: string, columnIndex: number) => string;
|
||||
getRowItem: (rowId: number) => RowItem | undefined;
|
||||
pageSize: Ref<number>;
|
||||
currentPage: Ref<number>;
|
||||
@@ -241,6 +245,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
dataGridQuickEntryEnabled = computed(() => false),
|
||||
confirmDangerousRowDeletion = computed(() => true),
|
||||
initialEditColumn,
|
||||
cellEditorText,
|
||||
normalizeEditorInput,
|
||||
getRowItem,
|
||||
pageSize,
|
||||
currentPage,
|
||||
@@ -640,7 +646,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
|
||||
function coerceCellValue(value: string, oldValue: CellValue | undefined, columnIndex: number, options: ApplyCellValueOptions = {}): CellValue {
|
||||
return coerceDataGridCellValue({
|
||||
value,
|
||||
value: normalizeEditorInput?.(value, columnIndex) ?? value,
|
||||
oldValue,
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
columnInfo: tableColumnForGridColumn(columnIndex),
|
||||
@@ -650,11 +656,13 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
}
|
||||
|
||||
function coerceCommittedCellValue(value: string, currentValue: CellValue | undefined, oldValue: CellValue | undefined, columnIndex: number): CellValue {
|
||||
const editorText = dataGridCellEditorText({
|
||||
value: currentValue,
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
columnInfo: tableColumnForGridColumn(columnIndex),
|
||||
});
|
||||
const editorText =
|
||||
cellEditorText?.(currentValue ?? null, columnIndex) ??
|
||||
dataGridCellEditorText({
|
||||
value: currentValue,
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
columnInfo: tableColumnForGridColumn(columnIndex),
|
||||
});
|
||||
// Keep the original CellValue when the editor text was not changed. This
|
||||
// avoids turning a displayed value such as number 1 into string "1" when
|
||||
// result and table metadata use different representations.
|
||||
@@ -819,11 +827,13 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
suppressNextBlurCommit = false;
|
||||
editingCell.value = { rowId, col: colIdx };
|
||||
const val = item?.data[colIdx] ?? null;
|
||||
editValue.value = dataGridCellEditorText({
|
||||
value: val,
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
columnInfo: tableColumnForGridColumn(colIdx),
|
||||
});
|
||||
editValue.value =
|
||||
cellEditorText?.(val, colIdx) ??
|
||||
dataGridCellEditorText({
|
||||
value: val,
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
columnInfo: tableColumnForGridColumn(colIdx),
|
||||
});
|
||||
focusEditInput(selectOnFocus);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ export interface UseDataGridExportOptions {
|
||||
mongoUpdateTarget?: ComputedRef<MongoCopyUpdateTarget | undefined>;
|
||||
databaseType: ComputedRef<DatabaseType | undefined>;
|
||||
displayValue?: (value: CellValue, columnIndex: number) => string;
|
||||
cellClipboardText?: (value: CellValue, columnIndex: number) => string | undefined;
|
||||
externalCellValue?: (value: CellValue, columnIndex: number) => CellValue;
|
||||
identifierQuote?: ComputedRef<string | undefined>;
|
||||
connectionId: ComputedRef<string | undefined>;
|
||||
database: ComputedRef<string | undefined>;
|
||||
@@ -355,7 +357,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
const document = documents[rowIndex];
|
||||
if (!document || typeof document !== "object" || Array.isArray(document)) return row;
|
||||
const source = document as Record<string, unknown>;
|
||||
return columnsToExport.map((column, columnIndex) => (Object.prototype.hasOwnProperty.call(source, column) ? (source[column] as CellValue) : (row[columnIndex] ?? null)));
|
||||
return columnsToExport.map((column) => (Object.prototype.hasOwnProperty.call(source, column) ? (source[column] as CellValue) : null));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -366,6 +368,11 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
function externalizeRows(rows: CellValue[][]): CellValue[][] {
|
||||
if (!options.externalCellValue) return rows;
|
||||
return rows.map((row) => row.map((value, columnIndex) => options.externalCellValue!(value, columnIndex)));
|
||||
}
|
||||
|
||||
async function resultToExport(
|
||||
rowIds?: number[],
|
||||
onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void,
|
||||
@@ -386,7 +393,14 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
if (result) {
|
||||
const columnComments = buildXlsxHeaderOverrides(result.columns, commentsForExportColumns(result.columns), headerMode);
|
||||
return {
|
||||
...applyGlobalDateTimeExportFormat({ columns: result.columns, columnTypes: result.column_types ?? [], rows: preserveMongoExtendedJson ? mongoDocumentRowsForJson(result.columns, result.rows, result.mongo_copy_documents) : result.rows }, formatDateTime && !preserveMongoExtendedJson),
|
||||
...applyGlobalDateTimeExportFormat(
|
||||
// fullExportResult returns source rows, not the collection grid's
|
||||
// marker-encoded rows. Applying externalCellValue here would
|
||||
// mistake a real BSON string in the reserved namespace for a grid
|
||||
// marker and corrupt the exported value.
|
||||
{ columns: result.columns, columnTypes: result.column_types ?? [], rows: preserveMongoExtendedJson ? mongoDocumentRowsForJson(result.columns, result.rows, result.mongo_copy_documents) : result.rows },
|
||||
formatDateTime && !preserveMongoExtendedJson,
|
||||
),
|
||||
columnComments,
|
||||
spatialColumns: result.spatial_columns,
|
||||
spatialValues: result.spatial_values,
|
||||
@@ -403,7 +417,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
const columnComments = buildXlsxHeaderOverrides(normalized.columns, normalized.columnComments, headerMode);
|
||||
return {
|
||||
...applyGlobalDateTimeExportFormat(
|
||||
{ columns: normalized.columns, columnTypes: normalized.columnTypes, rows: preserveMongoExtendedJson ? mongoDocumentRowsForJson(normalized.columns, normalized.rows, normalized.mongoCopyDocuments) : normalized.rows },
|
||||
{ columns: normalized.columns, columnTypes: normalized.columnTypes, rows: preserveMongoExtendedJson ? mongoDocumentRowsForJson(normalized.columns, normalized.rows, normalized.mongoCopyDocuments) : externalizeRows(normalized.rows) },
|
||||
formatDateTime && !preserveMongoExtendedJson,
|
||||
),
|
||||
columnComments,
|
||||
@@ -421,7 +435,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
{
|
||||
columns: columns.value,
|
||||
columnTypes: (columnTypes.value ?? []).map((type) => type ?? ""),
|
||||
rows: preserveMongoExtendedJson && databaseType.value === "mongodb" ? mongoLocalRowsForJson(exportItems) : exportItems.map((item) => item.data),
|
||||
rows: preserveMongoExtendedJson && databaseType.value === "mongodb" ? mongoLocalRowsForJson(exportItems) : externalizeRows(exportItems.map((item) => item.data)),
|
||||
},
|
||||
formatDateTime && !preserveMongoExtendedJson,
|
||||
),
|
||||
@@ -537,6 +551,11 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
return binaryCellClipboardText(value, columnTypes.value?.[columnIndex], databaseType.value) ?? value;
|
||||
}
|
||||
|
||||
function externalCellValue(value: CellValue, columnIndex: number): CellValue {
|
||||
const externalValue = options.externalCellValue?.(value, columnIndex);
|
||||
return externalValue === undefined ? value : externalValue;
|
||||
}
|
||||
|
||||
function rowToJsonObject(item: RowItem): Record<string, unknown> {
|
||||
if (options.databaseType.value === "mongodb" && item.sourceIndex !== undefined) {
|
||||
const original = options.mongoDocuments?.value?.[item.sourceIndex];
|
||||
@@ -545,7 +564,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
}
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.value.forEach((col, i) => {
|
||||
const value = binaryClipboardCellValue(item.data[i], i);
|
||||
const value = externalCellValue(binaryClipboardCellValue(item.data[i], i), i);
|
||||
if (typeof value === "string" && columnTypes.value?.[i]?.trim().toLowerCase() === "json") {
|
||||
try {
|
||||
obj[col] = JSON.parse(value);
|
||||
@@ -577,7 +596,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
const [resolvedItem] = await resolveVisibleRowValues([item], [sourceIndex]);
|
||||
const val = resolvedItem?.data[contextCell.value.col] ?? null;
|
||||
// 外部剪贴板呈现文本型 MySQL VARBINARY(NULL 也按空串输出);内部网格副本仍保留原 hex,保证回粘无损。
|
||||
const rawValue = clipboardCellValue(binaryClipboardCellValue(val, contextCell.value.col));
|
||||
const rawValue = options.cellClipboardText?.(val, sourceIndex) ?? clipboardCellValue(binaryClipboardCellValue(val, contextCell.value.col));
|
||||
const copyValue = options.databaseType.value === "oracle" && isTemporalColumnType(options.columnTypes.value?.[contextCell.value.col]) ? (options.displayValue?.(val, sourceIndex) ?? rawValue) : rawValue;
|
||||
await copyText(copyValue, { rows: [[val]] });
|
||||
}
|
||||
@@ -716,6 +735,10 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
buildMongoInsert: buildMongoExtractorInsert,
|
||||
buildMongoUpdate: buildMongoExtractorUpdate,
|
||||
canBuildMongoUpdate: canBuildMongoExtractorUpdate,
|
||||
externalCellValue: (value, columnIndex) => {
|
||||
const externalValue = options.externalCellValue?.(value as CellValue, columnIndex);
|
||||
return externalValue === undefined ? value : externalValue;
|
||||
},
|
||||
contextCell,
|
||||
contextSelectionIsSynthetic,
|
||||
});
|
||||
@@ -723,7 +746,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
async function copyAll() {
|
||||
const rows = (await resolveVisibleRowValues(displayItems.value.filter((item) => !item.isDraft))).map((item) => item.data);
|
||||
// formatTsv 引用处理解码后文本中可能出现的制表符/换行;内部副本仍用原始 rows(hex)保证回粘无损。
|
||||
const decodedRows = rows.map((row) => row.map((cell, index) => binaryClipboardCellValue(cell, index)));
|
||||
const decodedRows = rows.map((row) => row.map((cell, index) => externalCellValue(binaryClipboardCellValue(cell, index), index)));
|
||||
await copyText(formatTsv(columns.value, decodedRows), { rows, header: columns.value });
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ interface UseDataGridExtractorOptions {
|
||||
contextCell: ComputedRef<{ rowId: number; rowIndex: number; col: number } | null> | Ref<{ rowId: number; rowIndex: number; col: number } | null>;
|
||||
contextSelectionIsSynthetic: ComputedRef<boolean> | Ref<boolean>;
|
||||
copyText: (text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; header?: readonly unknown[] }) => Promise<boolean>;
|
||||
externalCellValue?: (value: unknown, columnIndex: number) => unknown;
|
||||
canCopySqlInsert: (request: DataGridExtractRequest) => boolean;
|
||||
buildMongoInsert: (extractorOptions: DataGridExtractorOptions, rowLimit?: number) => Promise<string | undefined>;
|
||||
buildMongoUpdate?: (request: DataGridExtractRequest, rowLimit?: number) => Promise<string | undefined>;
|
||||
@@ -83,12 +84,14 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
||||
|
||||
// 除 SQL 外的剪贴板展示格式都可把文本型 MySQL VARBINARY 从 `0x<hex>` 还原为原始字符串。
|
||||
// SQL 必须继续持有 hex 才能保证 round-trip;rawRows 则始终保存原值,供 DBX 内部网格回粘使用。
|
||||
function extractorCellValue(value: unknown, columnType: string | undefined, normalizeValues: boolean, presentBinaryText: boolean): unknown {
|
||||
function extractorCellValue(value: unknown, columnType: string | undefined, normalizeValues: boolean, presentBinaryText: boolean, columnIndex: number): unknown {
|
||||
if (presentBinaryText) {
|
||||
const text = binaryCellClipboardText(value, columnType, options.databaseType.value);
|
||||
if (text !== null) return text;
|
||||
}
|
||||
return normalizeValues ? normalizeCellValue(value, columnType) : value;
|
||||
const normalized = normalizeValues ? normalizeCellValue(value, columnType) : value;
|
||||
const externalValue = options.externalCellValue?.(normalized, columnIndex);
|
||||
return externalValue === undefined ? normalized : externalValue;
|
||||
}
|
||||
|
||||
function selectionData(): SelectionData | null {
|
||||
@@ -210,7 +213,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
||||
const normalizeValues = descriptor.category === "json" || descriptor.category === "sql";
|
||||
const presentBinaryText = descriptor.category !== "sql";
|
||||
const rawRows = sourceRows.map((row) => requiredSourceIndexes.map((sourceIndex) => row[sourceIndex]));
|
||||
const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, columnTypes[index], normalizeValues, presentBinaryText)));
|
||||
const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, columnTypes[index], normalizeValues, presentBinaryText, index)));
|
||||
const tableMeta =
|
||||
descriptor.category === "sql"
|
||||
? compactTableMeta(
|
||||
@@ -269,7 +272,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
||||
return sourceIndex !== undefined && values.has(sourceIndex) ? values.get(sourceIndex) : value;
|
||||
});
|
||||
});
|
||||
const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, limitedSource.columnTypes[index], limitedSource.normalizeValues, limitedSource.presentBinaryText)));
|
||||
const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, limitedSource.columnTypes[index], limitedSource.normalizeValues, limitedSource.presentBinaryText, index)));
|
||||
const resolvedRequest = { ...request, rows };
|
||||
requestSources.set(resolvedRequest, { ...limitedSource, rawRows });
|
||||
return resolvedRequest;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridDisplayText, mongoDocumentGridExternalValue, mongoDocumentGridValue } from "@/lib/mongo/mongoDocumentValues";
|
||||
import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDeleteRowConfirmDetails, dataGridColumnDetailTsv, dataGridRowDetailTsv, type DataGridColumnDetail, type DataGridRowDetail } from "../dataGridDetail";
|
||||
import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDetail, buildDeleteRowConfirmDetails, dataGridColumnDetailJson, dataGridColumnDetailTsv, dataGridRowDetailJson, dataGridRowDetailTsv, type DataGridColumnDetail, type DataGridRowDetail } from "../dataGridDetail";
|
||||
|
||||
type TestRow = string[];
|
||||
|
||||
@@ -142,3 +144,79 @@ describe("data grid detail TSV", () => {
|
||||
expect(dataGridColumnDetailTsv(columnDetail)).toBe("\nNULL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mongo collection cell detail presentation", () => {
|
||||
it.each([null, "NULL", MONGO_DOCUMENT_GRID_NULL])("presents BSON value %j without leaking grid encoding", (bsonValue) => {
|
||||
const value = mongoDocumentGridValue(bsonValue) as string;
|
||||
const text = mongoDocumentGridDisplayText(value) ?? displayCellValue(value);
|
||||
const detail = buildDataGridCellDetail({
|
||||
rowIndex: 0,
|
||||
rowId: 0,
|
||||
row: [value],
|
||||
columns: ["value"],
|
||||
columnIndex: 0,
|
||||
displayValue: () => text,
|
||||
rawValue: () => text,
|
||||
isNullValue: (cell) => cell === MONGO_DOCUMENT_GRID_NULL,
|
||||
isEditable: true,
|
||||
});
|
||||
expect(detail).toMatchObject({ value, rawValue: text, rawValuePreview: text, isNull: bsonValue === null, length: bsonValue === null ? 0 : text.length });
|
||||
expect(detail!.rawValue).not.toContain("\u0000");
|
||||
});
|
||||
|
||||
it("presents the BSON null marker as NULL text in row and column detail fields", () => {
|
||||
const value = mongoDocumentGridValue(null) as string;
|
||||
const rawValue = (cell: CellValue) => mongoDocumentGridDisplayText(cell) ?? displayCellValue(cell);
|
||||
const isNullValue = (cell: CellValue) => cell === MONGO_DOCUMENT_GRID_NULL;
|
||||
const rowDetail = buildDataGridRowDetail({
|
||||
rowIndex: 0,
|
||||
rowId: 1,
|
||||
row: [value],
|
||||
columns: ["value"],
|
||||
columnIndexes: [0],
|
||||
displayValue: rawValue,
|
||||
rawValue,
|
||||
isNullValue,
|
||||
});
|
||||
const columnDetail = buildDataGridColumnDetail({
|
||||
rows: [{ rowIndex: 0, rowId: 1, row: [value] }],
|
||||
columns: ["value"],
|
||||
columnIndex: 0,
|
||||
displayValue: rawValue,
|
||||
rawValue,
|
||||
isNullValue,
|
||||
});
|
||||
|
||||
for (const field of [...rowDetail.fields, ...columnDetail!.fields]) {
|
||||
expect(field).toMatchObject({ value, rawValue: "NULL", rawValuePreview: "NULL", isNull: true, length: 0 });
|
||||
expect(field.rawValuePreview).not.toContain("\u0000");
|
||||
}
|
||||
});
|
||||
|
||||
it("restores external BSON null values in row and column detail copy payloads", () => {
|
||||
const value = mongoDocumentGridValue(null) as string;
|
||||
const rowDetail = buildDataGridRowDetail({
|
||||
rowIndex: 0,
|
||||
rowId: 1,
|
||||
row: [value],
|
||||
columns: ["value"],
|
||||
columnIndexes: [0],
|
||||
displayValue: (cell) => displayCellValue(cell),
|
||||
});
|
||||
const columnDetail = buildDataGridColumnDetail({
|
||||
rows: [{ rowIndex: 0, rowId: 1, row: [value] }],
|
||||
columns: ["value"],
|
||||
columnIndex: 0,
|
||||
displayValue: (cell) => displayCellValue(cell),
|
||||
});
|
||||
|
||||
const rowJson = dataGridRowDetailJson(rowDetail, undefined, undefined, mongoDocumentGridExternalValue);
|
||||
const columnJson = dataGridColumnDetailJson(columnDetail!, undefined, mongoDocumentGridExternalValue);
|
||||
expect(rowJson).toBe('{\n "value": null\n}');
|
||||
expect(columnJson).toBe('[\n {\n "row": 1,\n "value": null\n }\n]');
|
||||
expect(rowJson).not.toContain("\u0000");
|
||||
expect(columnJson).not.toContain("\u0000");
|
||||
expect(dataGridRowDetailTsv(rowDetail, undefined, mongoDocumentGridExternalValue)).toBe("");
|
||||
expect(dataGridColumnDetailTsv(columnDetail!, undefined, mongoDocumentGridExternalValue)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ export interface DataGridCellDetail {
|
||||
isValuePreviewTruncated: boolean;
|
||||
imagePreviewUrl: string | null;
|
||||
length: number;
|
||||
isNull?: boolean;
|
||||
formattedJson: string;
|
||||
isEditable: boolean;
|
||||
}
|
||||
@@ -50,6 +51,8 @@ export interface BuildDataGridCellDetailOptions {
|
||||
commentByColumn?: ReadonlyMap<string, string>;
|
||||
displayValue: (value: CellValue, columnIndex: number) => string;
|
||||
isEditable: boolean;
|
||||
rawValue?: (value: CellValue, columnIndex: number) => string;
|
||||
isNullValue?: (value: CellValue) => boolean;
|
||||
databaseType?: DatabaseType;
|
||||
includeBinaryImagePreview?: boolean;
|
||||
isValuePreviewTruncated?: boolean;
|
||||
@@ -65,6 +68,8 @@ export interface BuildDataGridRowDetailOptions {
|
||||
resultColumnTypes?: readonly string[];
|
||||
commentByColumn?: ReadonlyMap<string, string>;
|
||||
displayValue: (value: CellValue, columnIndex: number) => string;
|
||||
rawValue?: (value: CellValue, columnIndex: number) => string;
|
||||
isNullValue?: (value: CellValue) => boolean;
|
||||
isEditableColumn?: (columnIndex: number) => boolean;
|
||||
isValuePreviewTruncated?: (columnIndex: number) => boolean;
|
||||
}
|
||||
@@ -85,6 +90,8 @@ export interface BuildDataGridColumnDetailOptions {
|
||||
resultColumnTypes?: readonly string[];
|
||||
commentByColumn?: ReadonlyMap<string, string>;
|
||||
displayValue: (value: CellValue, columnIndex: number) => string;
|
||||
rawValue?: (value: CellValue, columnIndex: number) => string;
|
||||
isNullValue?: (value: CellValue) => boolean;
|
||||
}
|
||||
|
||||
export function buildDataGridCellDetail(options: BuildDataGridCellDetailOptions): DataGridCellDetail | null {
|
||||
@@ -92,7 +99,8 @@ export function buildDataGridCellDetail(options: BuildDataGridCellDetailOptions)
|
||||
if (column === undefined) return null;
|
||||
|
||||
const value = options.row[options.columnIndex] ?? null;
|
||||
const rawValue = displayCellValue(value);
|
||||
const rawValue = options.rawValue?.(value, options.columnIndex) ?? displayCellValue(value);
|
||||
const isNull = options.isNullValue?.(value) ?? value === null;
|
||||
const displayValue = options.displayValue(value, options.columnIndex);
|
||||
const formattedJson = typeof value === "string" && looksLikeJsonContainer(value) ? (formatJsonText(value) ?? "") : "";
|
||||
const rawValuePreview = previewText(rawValue);
|
||||
@@ -116,7 +124,8 @@ export function buildDataGridCellDetail(options: BuildDataGridCellDetailOptions)
|
||||
binary: options.includeBinaryImagePreview !== false,
|
||||
databaseType: options.databaseType,
|
||||
}),
|
||||
length: value === null ? 0 : String(value).length,
|
||||
length: isNull ? 0 : rawValue.length,
|
||||
isNull,
|
||||
formattedJson,
|
||||
isEditable: options.isEditable,
|
||||
};
|
||||
@@ -138,6 +147,8 @@ export function buildDataGridColumnDetail(options: BuildDataGridColumnDetailOpti
|
||||
resultColumnTypes: options.resultColumnTypes,
|
||||
commentByColumn: options.commentByColumn,
|
||||
displayValue: options.displayValue,
|
||||
rawValue: options.rawValue,
|
||||
isNullValue: options.isNullValue,
|
||||
isEditable: row.isEditable ?? false,
|
||||
includeBinaryImagePreview: false,
|
||||
isValuePreviewTruncated: row.isValuePreviewTruncated,
|
||||
@@ -167,6 +178,8 @@ export function buildDataGridRowDetail(options: BuildDataGridRowDetailOptions):
|
||||
resultColumnTypes: options.resultColumnTypes,
|
||||
commentByColumn: options.commentByColumn,
|
||||
displayValue: options.displayValue,
|
||||
rawValue: options.rawValue,
|
||||
isNullValue: options.isNullValue,
|
||||
isEditable: options.isEditableColumn?.(columnIndex) ?? false,
|
||||
includeBinaryImagePreview: false,
|
||||
isValuePreviewTruncated: options.isValuePreviewTruncated?.(columnIndex),
|
||||
@@ -187,11 +200,20 @@ function detailColumnType(typeByColumn: ReadonlyMap<string, string> | undefined,
|
||||
return resultColumnTypes?.[columnIndex]?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function dataGridRowDetailJson(detail: DataGridRowDetail, originalDocument?: unknown, databaseType?: DatabaseType): string {
|
||||
/** Restores a grid-internal marker value (e.g. the Mongo BSON null sentinel) before it leaves the grid. */
|
||||
export type DataGridDetailExternalValue = (value: CellValue) => CellValue;
|
||||
|
||||
function externalDetailFieldValue(value: CellValue, externalValue: DataGridDetailExternalValue | undefined): CellValue {
|
||||
const external = externalValue?.(value);
|
||||
return external === undefined ? value : external;
|
||||
}
|
||||
|
||||
export function dataGridRowDetailJson(detail: DataGridRowDetail, originalDocument?: unknown, databaseType?: DatabaseType, externalValue?: DataGridDetailExternalValue): string {
|
||||
if (originalDocument !== undefined) return JSON.stringify(jsonDetailDisplayValue(originalDocument), null, 2);
|
||||
const row: Record<string, CellValue> = {};
|
||||
detail.fields.forEach((field) => {
|
||||
row[field.column] = binaryCellClipboardText(field.value, field.type, databaseType) ?? field.value;
|
||||
const value = externalDetailFieldValue(field.value, externalValue);
|
||||
row[field.column] = binaryCellClipboardText(value, field.type, databaseType) ?? value;
|
||||
});
|
||||
return JSON.stringify(jsonDetailDisplayValue(row), null, 2);
|
||||
}
|
||||
@@ -215,23 +237,36 @@ export function jsonDetailDisplayValue(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function dataGridRowDetailTsv(detail: DataGridRowDetail, databaseType?: DatabaseType): string {
|
||||
return detail.fields.map((field) => clipboardCellValue(binaryCellClipboardText(field.value, field.type, databaseType) ?? field.value)).join("\t");
|
||||
export function dataGridRowDetailTsv(detail: DataGridRowDetail, databaseType?: DatabaseType, externalValue?: DataGridDetailExternalValue): string {
|
||||
return detail.fields
|
||||
.map((field) => {
|
||||
const value = externalDetailFieldValue(field.value, externalValue);
|
||||
return clipboardCellValue(binaryCellClipboardText(value, field.type, databaseType) ?? value);
|
||||
})
|
||||
.join("\t");
|
||||
}
|
||||
|
||||
export function dataGridColumnDetailJson(detail: DataGridColumnDetail, databaseType?: DatabaseType): string {
|
||||
export function dataGridColumnDetailJson(detail: DataGridColumnDetail, databaseType?: DatabaseType, externalValue?: DataGridDetailExternalValue): string {
|
||||
return JSON.stringify(
|
||||
detail.fields.map((field) => ({
|
||||
row: field.rowNumber,
|
||||
value: binaryCellClipboardText(field.value, field.type, databaseType) ?? field.value,
|
||||
})),
|
||||
detail.fields.map((field) => {
|
||||
const value = externalDetailFieldValue(field.value, externalValue);
|
||||
return {
|
||||
row: field.rowNumber,
|
||||
value: binaryCellClipboardText(value, field.type, databaseType) ?? value,
|
||||
};
|
||||
}),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function dataGridColumnDetailTsv(detail: DataGridColumnDetail, databaseType?: DatabaseType): string {
|
||||
return detail.fields.map((field) => clipboardCellValue(binaryCellClipboardText(field.value, field.type, databaseType) ?? field.value)).join("\n");
|
||||
export function dataGridColumnDetailTsv(detail: DataGridColumnDetail, databaseType?: DatabaseType, externalValue?: DataGridDetailExternalValue): string {
|
||||
return detail.fields
|
||||
.map((field) => {
|
||||
const value = externalDetailFieldValue(field.value, externalValue);
|
||||
return clipboardCellValue(binaryCellClipboardText(value, field.type, databaseType) ?? value);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export interface BuildDeleteRowConfirmDetailsOptions<TRow> {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
|
||||
export type MongoInputValue = string | number | boolean | null;
|
||||
|
||||
const MONGO_SHELL_DATE_PATTERN = /^(?:ISODate|new Date)\(\s*(["'])(.+)\1\s*\)$/;
|
||||
const MONGO_SHELL_NUMBER_LONG_PATTERN = /^NumberLong\(\s*(["'])(-?\d+)\1\s*\)$/;
|
||||
const MONGO_OBJECT_ID_PATTERN = /^[a-fA-F0-9]{24}$/;
|
||||
const MONGO_INTEGER_PATTERN = /^-?\d+$/;
|
||||
// These values are internal to the MongoDB collection grid. BSON strings may
|
||||
// contain any UTF-8 text, so strings in this reserved namespace are escaped
|
||||
// before entering the grid and restored before being saved.
|
||||
const MONGO_DOCUMENT_GRID_PREFIX = "\u0000dbx:mongo-document-grid:";
|
||||
const MONGO_DOCUMENT_GRID_ESCAPED_STRING_PREFIX = `${MONGO_DOCUMENT_GRID_PREFIX}string:`;
|
||||
export const MONGO_DOCUMENT_GRID_NULL = `${MONGO_DOCUMENT_GRID_PREFIX}null`;
|
||||
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
const MIN_BSON_INT64 = -9223372036854775808n;
|
||||
const MAX_BSON_INT64 = 9223372036854775807n;
|
||||
@@ -81,7 +89,6 @@ export function parseMongoDocumentInputValue(raw: MongoInputValue): unknown {
|
||||
}
|
||||
|
||||
export function mongoDocumentDisplayValue(value: unknown): unknown {
|
||||
if (value === null) return "NULL";
|
||||
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)})`;
|
||||
@@ -89,9 +96,59 @@ export function mongoDocumentDisplayValue(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps BSON values into the flat collection-grid representation. The grid
|
||||
* needs an internal sentinel for an explicit BSON null because an empty cell
|
||||
* represents a field that does not exist. The grid formatter renders that
|
||||
* sentinel as NULL, while a literal "NULL" remains ordinary string data.
|
||||
*/
|
||||
export function mongoDocumentGridValue(value: unknown): unknown {
|
||||
if (value === null) return MONGO_DOCUMENT_GRID_NULL;
|
||||
if (typeof value === "string" && value.startsWith(MONGO_DOCUMENT_GRID_PREFIX)) return `${MONGO_DOCUMENT_GRID_ESCAPED_STRING_PREFIX}${value}`;
|
||||
return mongoDocumentDisplayValue(value);
|
||||
}
|
||||
|
||||
function mongoDocumentGridEscapedString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.startsWith(MONGO_DOCUMENT_GRID_ESCAPED_STRING_PREFIX) ? value.slice(MONGO_DOCUMENT_GRID_ESCAPED_STRING_PREFIX.length) : undefined;
|
||||
}
|
||||
|
||||
/** Returns the text presented in a collection-grid editor, when customized. */
|
||||
export function mongoDocumentGridEditorText(value: unknown): string | undefined {
|
||||
if (value === MONGO_DOCUMENT_GRID_NULL) return "NULL";
|
||||
return mongoDocumentGridEscapedString(value);
|
||||
}
|
||||
|
||||
/** Returns the custom display text required by collection-grid BSON values. */
|
||||
export function mongoDocumentGridDisplayText(value: unknown): string | undefined {
|
||||
if (value === MONGO_DOCUMENT_GRID_NULL) return "NULL";
|
||||
const escapedString = mongoDocumentGridEscapedString(value);
|
||||
if (escapedString !== undefined) return JSON.stringify(escapedString);
|
||||
return value === "NULL" ? JSON.stringify(value) : undefined;
|
||||
}
|
||||
|
||||
/** Restores a collection-grid value before it leaves the grid externally. */
|
||||
export function mongoDocumentGridExternalValue(value: CellValue): CellValue {
|
||||
if (value === MONGO_DOCUMENT_GRID_NULL) return null;
|
||||
const escapedString = mongoDocumentGridEscapedString(value);
|
||||
return escapedString === undefined ? value : escapedString;
|
||||
}
|
||||
|
||||
/** Preserves encoded grid clipboard values and escapes other reserved input. */
|
||||
export function mongoDocumentGridInputValue(value: string): string {
|
||||
// Internal copy/paste already carries encoded values; escaping again would
|
||||
// save BSON null as a literal sentinel string.
|
||||
if (value === MONGO_DOCUMENT_GRID_NULL || value.startsWith(MONGO_DOCUMENT_GRID_ESCAPED_STRING_PREFIX)) return value;
|
||||
return value.startsWith(MONGO_DOCUMENT_GRID_PREFIX) ? `${MONGO_DOCUMENT_GRID_ESCAPED_STRING_PREFIX}${value}` : value;
|
||||
}
|
||||
|
||||
function parseMongoExistingFieldInputValue(raw: Exclude<MongoInputValue, null>, originalValue: unknown): unknown {
|
||||
// Objects and arrays are serialized into grid text too, so the raw document
|
||||
// is the only reliable way to distinguish them from JSON-shaped BSON strings.
|
||||
// The collection grid's private sentinel represents an explicit BSON null.
|
||||
// A literal "NULL" remains a normal string, including in Mongo query results.
|
||||
if (raw === MONGO_DOCUMENT_GRID_NULL) return null;
|
||||
const escapedString = mongoDocumentGridEscapedString(raw);
|
||||
if (escapedString !== undefined) return escapedString;
|
||||
if (typeof originalValue === "string") {
|
||||
return typeof raw === "string" ? raw : String(raw);
|
||||
}
|
||||
@@ -202,7 +259,7 @@ export function buildMongoInsertDocument(row: MongoInputValue[], columns: string
|
||||
if (!col || col === "_id") continue;
|
||||
const val = row[ci];
|
||||
if (val === null) continue;
|
||||
doc[col] = parseMongoDocumentInputValue(val);
|
||||
doc[col] = val === MONGO_DOCUMENT_GRID_NULL ? null : (mongoDocumentGridEscapedString(val) ?? parseMongoDocumentInputValue(val));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
@@ -218,7 +275,7 @@ export function buildMongoCopyInsertDocument(row: MongoInputValue[], columns: st
|
||||
doc[col] = { $oid: val };
|
||||
continue;
|
||||
}
|
||||
doc[col] = parseMongoDocumentInputValue(val);
|
||||
doc[col] = val === MONGO_DOCUMENT_GRID_NULL ? null : (mongoDocumentGridEscapedString(val) ?? parseMongoDocumentInputValue(val));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
@@ -235,7 +292,9 @@ export function buildMongoCopyDocumentFromOriginal(original: unknown, row: Mongo
|
||||
// Display strings are ambiguous, so only explicitly edited cells may replace original BSON values.
|
||||
if (dirtyColumns[columnIndex]) {
|
||||
const value = row[columnIndex];
|
||||
if (value !== null) document[column] = parseMongoDocumentInputValue(value);
|
||||
if (value !== null) {
|
||||
document[column] = value === MONGO_DOCUMENT_GRID_NULL ? null : (mongoDocumentGridEscapedString(value) ?? parseMongoDocumentInputValue(value));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(source, column)) document[column] = source[column];
|
||||
|
||||
Reference in new issue
Block a user