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>
|
||||
|
||||
Reference in new issue
Block a user