feat(grid): improve production watermark and sql formatting

This commit is contained in:
二丫讲梵
2026-10-01 01:04:20 +08:00
committed by GitHub
parent 2fa5b9298a
commit 7721019218
10 changed files with 525 additions and 335 deletions
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const { locale } = useI18n();
const watermarkText = computed(() => (locale.value.startsWith("zh") ? "生产环境" : "PROD"));
</script>
<template>
<div class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
<span v-for="index in 4" :key="index" class="production-watermark__label whitespace-nowrap font-mono text-6xl font-extrabold text-red-700/[0.12] dark:text-red-200/[0.1]">{{ watermarkText }}</span>
</div>
</template>
<style scoped>
.production-watermark {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 3rem;
overflow: hidden;
padding: 3rem 2.5rem;
}
.production-watermark__label {
align-self: center;
justify-self: center;
transform: rotate(-22deg);
}
@media (max-width: 700px) {
.production-watermark {
grid-template-columns: 1fr;
gap: 1.5rem;
padding-inline: 1rem;
}
}
</style>
+284 -273
View File
@@ -66,6 +66,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { Input } from "@/components/ui/input";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import DataGridBusyOverlay from "@/components/grid/DataGridBusyOverlay.vue";
import ProductionWatermark from "@/components/common/ProductionWatermark.vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import LightDropdownMenu from "@/components/ui/LightDropdownMenu.vue";
import LightTooltip from "@/components/ui/LightTooltip.vue";
@@ -249,6 +250,7 @@ import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarSta
import { buildDataGridSavedRowRefreshPlan, dataGridSavedRowRefreshPatches } from "@/lib/dataGrid/dataGridSavedRowRefresh";
import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis";
import { sqlWithoutCommentsForCopy } from "@/lib/sql/sqlWithoutCommentsForCopy";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { EDITOR_FONT_FAMILY_CSS_VAR } from "@/lib/editor/editorThemes";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import {
@@ -755,6 +757,7 @@ const columnTypeMap = computed(() => {
});
const resolvedConnectionConfig = computed(() => connectionStore.getConfig(props.connectionId ?? ""));
const resolvedDatabaseType = computed(() => props.databaseType ?? effectiveDatabaseTypeForConnection(resolvedConnectionConfig.value));
const productionContext = computed(() => productionContextForDatabase(resolvedConnectionConfig.value, props.database));
// The collection grid and Mongo query-result grids share the document-grid value
// encoding (BSON null sentinel + JSON-prefixed containers), so every display,
// editor and clipboard path must decode it whenever MongoDB values are on screen.
@@ -12757,294 +12760,301 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<X class="w-3 h-3" />
</Button>
</div>
<RecycleScroller
ref="transposeScrollRef"
class="transpose-grid-scroller flex-1 min-h-0 overflow-auto overscroll-none bg-background"
:class="{ 'is-scrolling': isScrolling }"
:style="{
'--transpose-total-w': `${transposeTotalWidth}px`,
'--transpose-field-w': `${transposePinnedWidth}px`,
}"
:items="transposeRows"
:item-size="transposeRowHeight"
:buffer="400"
key-field="id"
@scroll="onTransposeScroll"
@resize="updateTransposeViewport"
>
<template #before>
<div class="data-grid-transpose-header data-grid-header-shell sticky top-0 z-20 flex h-7 border-b border-border font-semibold text-muted-foreground" :style="{ width: `${transposeTotalWidth}px` }">
<div class="data-grid-header-cell sticky left-0 z-30 shrink-0 border-r border-border px-3 py-1.5 truncate relative" :style="{ width: `${transposePinnedWidth}px` }">
{{ t("grid.columnName") }}
<div class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="onTransposePinnedResizeStart" />
</div>
<div class="shrink-0" :style="{ width: `${transposeBeforeSpacerWidth}px` }" />
<div
v-for="recordIndex in activeTransposeRecordIndexes"
:key="`transpose-head-${recordIndex}`"
data-grid-transpose-record-header
:data-grid-transpose-record-index="recordIndex"
class="shrink-0 border-r border-border px-2 py-1.5 text-left tabular-nums relative"
:class="{
'transpose-record-header-selected text-primary font-semibold': transposeRecordUsesFramedHeader(recordIndex),
'transpose-record-header-active text-primary': transposeRecordUsesActiveHighlight(recordIndex) && !transposeRecordUsesFramedHeader(recordIndex),
'data-grid-header-cell': !transposeRecordUsesActiveHighlight(recordIndex) && !transposeRecordUsesFramedHeader(recordIndex),
'crosshair-column': !!crosshairTarget?.rowCrosshair && crosshairTarget.rowIndex === recordIndex && !transposeRecordUsesFramedHeader(recordIndex) && !transposeRecordUsesActiveHighlight(recordIndex),
}"
:style="{
width: `${getTransposeRecordWidth(recordIndex)}px`,
}"
@click="selectTransposeRecord(recordIndex, $event)"
@contextmenu="selectTransposeRecord(recordIndex, $event)"
>
{{ rowNumberText(displayItems[recordIndex]) }}
<div class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="onTransposeRecordResizeStart(recordIndex, $event)" @dblclick.stop="autoFitTransposeRecord(recordIndex)" />
</div>
<div class="shrink-0" :style="{ width: `${transposeAfterSpacerWidth}px` }" />
</div>
</template>
<template #default="{ item, index }">
<div
class="data-grid-transpose-row flex border-b border-border/60"
:style="{
height: `${transposeRowHeight}px`,
width: `${transposeTotalWidth}px`,
}"
>
<LightTooltip :text="transposeFieldTitle(item)" side="right" :side-offset="6" :delay="250" :open-on-focus="false" surface="popover">
<div
data-native-clipboard
:data-grid-transpose-column-index="visibleColumnIndexes[index]"
class="sticky left-0 z-10 flex shrink-0 flex-col items-start justify-center overflow-hidden border-r border-border bg-background px-3 py-0"
:class="{
'ring-2 ring-inset ring-primary': highlightedColumnIndex === visibleColumnIndexes[index],
'bg-yellow-200/60 dark:bg-yellow-500/20': transposeHeaderIsSearchMatch(visibleColumnIndexes[index]),
'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': transposeHeaderIsCurrentMatch(visibleColumnIndexes[index]),
}"
:style="{ width: `${transposePinnedWidth}px` }"
>
<span class="flex w-full min-w-0 items-center gap-1 overflow-hidden pr-5">
<KeyRound v-if="transposeColumnIndexKind(item.column) === 'primary'" data-grid-transpose-index-indicator class="h-3 w-3 shrink-0" :class="columnIndexColorClass('primary')" :title="transposeColumnIndexText('primary')" />
<Hash v-else-if="transposeColumnIndexKind(item.column)" data-grid-transpose-index-indicator class="h-3 w-3 shrink-0" :class="columnIndexColorClass(transposeColumnIndexKind(item.column)!)" :title="transposeColumnIndexText(transposeColumnIndexKind(item.column)!)" />
<span class="min-w-0 flex-1 truncate font-medium leading-4">{{ item.column }}</span>
</span>
<LightDropdownMenu
v-if="headerColumnSortable(visibleColumnIndexes[index])"
:items="sortMenuItems(item.column, visibleColumnIndexes[index])"
:open="headerSortMenuOpenColumn === visibleColumnIndexes[index]"
:selected-value="selectedSortMenuValue(item.column, visibleColumnIndexes[index])"
check-position="none"
align="end"
content-class="w-max min-w-28 p-0.5"
item-class="gap-1 rounded-none px-1.5 py-0.5 text-xs"
item-icon-class="h-3 w-3"
:match-trigger-width="false"
@update:open="(value: boolean) => (headerSortMenuOpenColumn = value ? visibleColumnIndexes[index] : null)"
@select="(value: string) => selectHeaderSort(value, item.column, visibleColumnIndexes[index])"
>
<template #trigger="{ open, toggle }">
<button
data-grid-transpose-sort
type="button"
class="absolute right-1 top-1 flex h-4 w-4 shrink-0 items-center justify-center rounded"
:class="columnIsSorted(item.column, visibleColumnIndexes[index]) ? 'bg-primary text-primary-foreground opacity-100 shadow-sm hover:bg-primary/90' : 'text-muted-foreground opacity-80 hover:bg-accent hover:text-foreground'"
:title="t('grid.sort')"
:aria-label="`${t('grid.sort')}: ${item.column}`"
:aria-expanded="open"
@mousedown.stop
@click.stop="toggle"
>
<ArrowUp v-if="columnIsSorted(item.column, visibleColumnIndexes[index]) && sortDir === 'asc'" class="h-3 w-3 shrink-0" />
<ArrowDown v-else-if="columnIsSorted(item.column, visibleColumnIndexes[index]) && sortDir === 'desc'" class="h-3 w-3 shrink-0" />
<ArrowUpDown v-else class="h-3 w-3 shrink-0" />
</button>
</template>
</LightDropdownMenu>
<template v-if="showTransposeFieldMetadata && showColumnTypesInHeader && item.type">
<span data-grid-transpose-type-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3 select-none" :class="typeColorClass(item.type)" :title="item.type">
{{ item.type }}
</span>
</template>
<template v-if="showTransposeFieldMetadata && showColumnCommentsInHeader && item.comment">
<span data-grid-transpose-comment-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3 text-muted-foreground select-none" :title="item.comment">
{{ item.comment }}
</span>
</template>
<div class="relative min-h-0 flex-1 overflow-hidden">
<ProductionWatermark v-if="productionContext.active" />
<RecycleScroller
ref="transposeScrollRef"
class="transpose-grid-scroller h-full overflow-auto overscroll-none bg-background"
:class="{ 'is-scrolling': isScrolling }"
:style="{
'--transpose-total-w': `${transposeTotalWidth}px`,
'--transpose-field-w': `${transposePinnedWidth}px`,
}"
:items="transposeRows"
:item-size="transposeRowHeight"
:buffer="400"
key-field="id"
@scroll="onTransposeScroll"
@resize="updateTransposeViewport"
>
<template #before>
<div class="data-grid-transpose-header data-grid-header-shell sticky top-0 z-20 flex h-7 border-b border-border font-semibold text-muted-foreground" :style="{ width: `${transposeTotalWidth}px` }">
<div class="data-grid-header-cell sticky left-0 z-30 shrink-0 border-r border-border px-3 py-1.5 truncate relative" :style="{ width: `${transposePinnedWidth}px` }">
{{ t("grid.columnName") }}
<div class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="onTransposePinnedResizeStart" />
</div>
<template #content>
<div
data-grid-transpose-field-tooltip
class="grid max-h-[min(20rem,calc(100vh-1rem))] w-[min(24rem,calc(100vw-1rem))] grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1.5 overflow-auto rounded-md border border-border bg-popover px-3 py-2 text-left text-popover-foreground shadow-md"
>
<span class="text-muted-foreground">{{ t("grid.columnName") }}</span>
<span class="min-w-0 break-all font-mono select-text">{{ item.column }}</span>
<template v-if="showTransposeFieldMetadata && showColumnTypesInHeader && item.type">
<span class="text-muted-foreground">{{ t("grid.columnType") }}</span>
<span class="min-w-0 break-all font-mono select-text" :class="typeColorClass(item.type)">{{ item.type }}</span>
</template>
<template v-if="showTransposeFieldMetadata && showColumnCommentsInHeader && item.comment">
<span class="text-muted-foreground">{{ t("grid.columnComment") }}</span>
<span class="min-w-0 whitespace-pre-wrap break-words select-text">{{ item.comment }}</span>
</template>
</div>
</template>
</LightTooltip>
<div class="shrink-0" :style="{ width: `${transposeBeforeSpacerWidth}px` }" />
<div class="shrink-0" :style="{ width: `${transposeBeforeSpacerWidth}px` }" />
<div
v-for="recordIndex in activeTransposeRecordIndexes"
:key="`transpose-head-${recordIndex}`"
data-grid-transpose-record-header
:data-grid-transpose-record-index="recordIndex"
class="shrink-0 border-r border-border px-2 py-1.5 text-left tabular-nums relative"
:class="{
'transpose-record-header-selected text-primary font-semibold': transposeRecordUsesFramedHeader(recordIndex),
'transpose-record-header-active text-primary': transposeRecordUsesActiveHighlight(recordIndex) && !transposeRecordUsesFramedHeader(recordIndex),
'data-grid-header-cell': !transposeRecordUsesActiveHighlight(recordIndex) && !transposeRecordUsesFramedHeader(recordIndex),
'crosshair-column': !!crosshairTarget?.rowCrosshair && crosshairTarget.rowIndex === recordIndex && !transposeRecordUsesFramedHeader(recordIndex) && !transposeRecordUsesActiveHighlight(recordIndex),
}"
:style="{
width: `${getTransposeRecordWidth(recordIndex)}px`,
}"
@click="selectTransposeRecord(recordIndex, $event)"
@contextmenu="selectTransposeRecord(recordIndex, $event)"
>
{{ rowNumberText(displayItems[recordIndex]) }}
<div class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="onTransposeRecordResizeStart(recordIndex, $event)" @dblclick.stop="autoFitTransposeRecord(recordIndex)" />
</div>
<div class="shrink-0" :style="{ width: `${transposeAfterSpacerWidth}px` }" />
</div>
</template>
<template #default="{ item, index }">
<div
v-for="cell in item.values"
:key="`${item.id}:${cell.recordIndex}`"
data-grid-transpose-cell
:data-grid-transpose-record-index="cell.recordIndex"
class="relative flex shrink-0 items-center border-r border-border/70 px-2 py-0"
:class="[
transposeCellTextColorClass(cell.recordIndex, cell.valueIndex),
{
'overflow-visible z-20 border-r-transparent': transposeCellEditorActive(cell.recordIndex, cell.valueIndex),
'overflow-hidden text-ellipsis whitespace-nowrap': !transposeCellEditorActive(cell.recordIndex, cell.valueIndex),
'cell-selected': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cell-selected-dirty': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cell-selected--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cell-selected-dirty--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'row-cell-selected': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'row-cell-selected-dirty': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'crosshair-row':
!!crosshairTarget?.columnCrosshair && cell.valueIndex === crosshairTarget.actualColIdx && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
'bg-primary/15': transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
'bg-yellow-500/10 cell-dirty': displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(cell.recordIndex, cell.valueIndex),
'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': cellIsCurrentMatch(cell.recordIndex, cell.valueIndex),
'cursor-text': !isScrolling,
'hover:bg-gray-200 hover:text-foreground dark:hover:bg-gray-800':
!isScrolling && canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
},
]"
class="data-grid-transpose-row flex border-b border-border/60"
:style="{
width: `${getTransposeRecordWidth(cell.recordIndex)}px`,
height: `${transposeRowHeight}px`,
width: `${transposeTotalWidth}px`,
}"
:title="cell.display"
@mousedown="prepareTransposeCellMouseDown(cell.recordIndex, cell.valueIndex)"
@click="selectTransposeCell(cell.recordIndex, cell.valueIndex, $event)"
@mouseenter="onTransposeCellMouseenter(cell.recordIndex, cell.valueIndex)"
@mouseleave="onCellMouseleave(cell.recordIndex, cell.valueIndex)"
@contextmenu="onTransposeCellContext(cell.recordIndex, cell.valueIndex, $event)"
@dblclick.stop="onTransposeCellDblClick(cell.recordIndex, cell.valueIndex, cell.display, $event)"
>
<template v-if="readonlyTextCellMatches(displayItems[cell.recordIndex]?.id, cell.valueIndex)">
<DataGridReadonlyTextSelection :value="readonlyTextCell!.value" :expanded="readonlyTextCell!.expanded" @close="closeReadonlyCellTextSelection" @escape="escapeReadonlyCellTextSelection" />
</template>
<template v-else-if="editingCell?.rowId === displayItems[cell.recordIndex]?.id && editingCell?.col === cell.valueIndex">
<TemporalCellEditor
v-if="temporalEditorConfigForColumn(cell.valueIndex)"
v-model="editValue"
:kind="temporalEditorConfigForColumn(cell.valueIndex)!.kind"
:fraction-precision="temporalEditorConfigForColumn(cell.valueIndex)!.fractionPrecision"
:normalize-value="(value) => normalizeTemporalCellEditorValue(value, cell.valueIndex)"
cell-layout="transpose"
@cancel="cancelEdit"
@commit="commitGridEdit"
@save="onTemporalCellEditorSave"
/>
<EnumCellEditor
v-else-if="isBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex)"
v-model="booleanEditorModelValue"
:values="BOOLEAN_CELL_EDITOR_VALUES"
:nullable="isBooleanGridColumnNullable(cell.valueIndex)"
:initial-null="isGridCellInitialNull(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
cell-layout="transpose"
@cancel="cancelEdit"
@commit="commitBooleanGridEdit"
/>
<EnumCellEditor
v-else-if="isEnumGridColumn(cell.valueIndex)"
v-model="editValue"
:values="enumValuesForGridColumn(cell.valueIndex)"
:nullable="isEnumGridColumnNullable(cell.valueIndex)"
:initial-null="isGridCellInitialNull(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
cell-layout="transpose"
@cancel="cancelEdit"
@commit="commitGridEdit"
/>
<textarea
v-else-if="cellUsesExpandedEditor(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
v-model="editValue"
data-expanded-cell-editor="true"
rows="1"
:inputmode="cellEditInputModeForColumn(cell.valueIndex)"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="cell-edit-input cell-edit-input--expanded absolute left-0 top-0 min-h-full bg-background px-1.5 py-1 leading-[18px] outline-none z-10"
@blur="commitEditFromCellBlur"
@click.stop
@focus="onCellEditTextareaInput"
@input="onCellEditTextareaInput"
@keydown.stop="onCellEditKeydown"
@paste.stop="onCellEditTextareaPaste"
@wheel.stop
/>
<input
v-else
v-model="editValue"
:inputmode="cellEditInputModeForColumn(cell.valueIndex)"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-1.5 py-0 leading-[26px] outline-none z-10"
@blur="commitEditFromCellBlur"
@click.stop
@input="onCellEditTextareaInput"
@keydown.stop="onCellEditKeydown"
@paste.stop="onCellEditTextareaPaste"
/>
</template>
<template v-else>
<template v-if="newRowCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex)">
<span class="text-muted-foreground/70 italic">{{ firstLineCellDisplayValue(newRowCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex) ?? "", flatteningMultiLineEnabled) }}</span>
</template>
<template v-else>{{ gridCellDisplayValue(cell.display, flatteningMultiLineEnabled, showWhitespaceEnabled) }}</template>
<div v-if="cellDetailButtonVisible(cell.recordIndex, cell.valueIndex)" class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<LightTooltip :text="transposeFieldTitle(item)" side="right" :side-offset="6" :delay="250" :open-on-focus="false" surface="popover">
<div
data-native-clipboard
:data-grid-transpose-column-index="visibleColumnIndexes[index]"
class="sticky left-0 z-10 flex shrink-0 flex-col items-start justify-center overflow-hidden border-r border-border bg-background px-3 py-0"
:class="{
'ring-2 ring-inset ring-primary': highlightedColumnIndex === visibleColumnIndexes[index],
'bg-yellow-200/60 dark:bg-yellow-500/20': transposeHeaderIsSearchMatch(visibleColumnIndexes[index]),
'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': transposeHeaderIsCurrentMatch(visibleColumnIndexes[index]),
}"
:style="{ width: `${transposePinnedWidth}px` }"
>
<span class="flex w-full min-w-0 items-center gap-1 overflow-hidden pr-5">
<KeyRound v-if="transposeColumnIndexKind(item.column) === 'primary'" data-grid-transpose-index-indicator class="h-3 w-3 shrink-0" :class="columnIndexColorClass('primary')" :title="transposeColumnIndexText('primary')" />
<Hash v-else-if="transposeColumnIndexKind(item.column)" data-grid-transpose-index-indicator class="h-3 w-3 shrink-0" :class="columnIndexColorClass(transposeColumnIndexKind(item.column)!)" :title="transposeColumnIndexText(transposeColumnIndexKind(item.column)!)" />
<span class="min-w-0 flex-1 truncate font-medium leading-4">{{ item.column }}</span>
</span>
<LightDropdownMenu
v-if="canQuickDownloadCellValue(cell.recordIndex, cell.valueIndex)"
:items="binaryCellDownloadMenuItems"
:open="quickDownloadMenuOpenFor(cell.recordIndex, cell.valueIndex)"
v-if="headerColumnSortable(visibleColumnIndexes[index])"
:items="sortMenuItems(item.column, visibleColumnIndexes[index])"
:open="headerSortMenuOpenColumn === visibleColumnIndexes[index]"
:selected-value="selectedSortMenuValue(item.column, visibleColumnIndexes[index])"
check-position="none"
align="end"
content-class="w-44"
content-class="w-max min-w-28 p-0.5"
item-class="gap-1 rounded-none px-1.5 py-0.5 text-xs"
item-icon-class="h-3 w-3"
:match-trigger-width="false"
@update:open="(value: boolean) => handleQuickDownloadMenuOpenChange(value, cell.recordIndex, cell.valueIndex)"
@select="(mode: string) => downloadCellBinaryValue(cell.recordIndex, cell.valueIndex, mode as BinaryCellDownloadMode)"
@update:open="(value: boolean) => (headerSortMenuOpenColumn = value ? visibleColumnIndexes[index] : null)"
@select="(value: string) => selectHeaderSort(value, item.column, visibleColumnIndexes[index])"
>
<template #trigger="{ open, toggle }">
<button class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground" :title="t('grid.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
<Download class="h-3 w-3" />
<button
data-grid-transpose-sort
type="button"
class="absolute right-1 top-1 flex h-4 w-4 shrink-0 items-center justify-center rounded"
:class="columnIsSorted(item.column, visibleColumnIndexes[index]) ? 'bg-primary text-primary-foreground opacity-100 shadow-sm hover:bg-primary/90' : 'text-muted-foreground opacity-80 hover:bg-accent hover:text-foreground'"
:title="t('grid.sort')"
:aria-label="`${t('grid.sort')}: ${item.column}`"
:aria-expanded="open"
@mousedown.stop
@click.stop="toggle"
>
<ArrowUp v-if="columnIsSorted(item.column, visibleColumnIndexes[index]) && sortDir === 'asc'" class="h-3 w-3 shrink-0" />
<ArrowDown v-else-if="columnIsSorted(item.column, visibleColumnIndexes[index]) && sortDir === 'desc'" class="h-3 w-3 shrink-0" />
<ArrowUpDown v-else class="h-3 w-3 shrink-0" />
</button>
</template>
</LightDropdownMenu>
<button
v-if="canOpenCellExternalUrl(cell.recordIndex, cell.valueIndex)"
class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
:title="t('grid.openUrl')"
:aria-label="t('grid.openUrl')"
@mousedown.stop
@click.stop="openCellExternalUrl(cell.recordIndex, cell.valueIndex)"
>
<ExternalLink class="h-3 w-3" />
</button>
<button
v-if="cellDetailButtonEnabled"
class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
:title="t('grid.cellDetails')"
@mousedown.stop
@click.stop="showTransposeCellDetails(cell.recordIndex, cell.valueIndex)"
>
<Info class="h-3 w-3" />
</button>
<template v-if="showTransposeFieldMetadata && showColumnTypesInHeader && item.type">
<span data-grid-transpose-type-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3 select-none" :class="typeColorClass(item.type)" :title="item.type">
{{ item.type }}
</span>
</template>
<template v-if="showTransposeFieldMetadata && showColumnCommentsInHeader && item.comment">
<span data-grid-transpose-comment-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3 text-muted-foreground select-none" :title="item.comment">
{{ item.comment }}
</span>
</template>
</div>
</template>
<template #content>
<div
data-grid-transpose-field-tooltip
class="grid max-h-[min(20rem,calc(100vh-1rem))] w-[min(24rem,calc(100vw-1rem))] grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1.5 overflow-auto rounded-md border border-border bg-popover px-3 py-2 text-left text-popover-foreground shadow-md"
>
<span class="text-muted-foreground">{{ t("grid.columnName") }}</span>
<span class="min-w-0 break-all font-mono select-text">{{ item.column }}</span>
<template v-if="showTransposeFieldMetadata && showColumnTypesInHeader && item.type">
<span class="text-muted-foreground">{{ t("grid.columnType") }}</span>
<span class="min-w-0 break-all font-mono select-text" :class="typeColorClass(item.type)">{{ item.type }}</span>
</template>
<template v-if="showTransposeFieldMetadata && showColumnCommentsInHeader && item.comment">
<span class="text-muted-foreground">{{ t("grid.columnComment") }}</span>
<span class="min-w-0 whitespace-pre-wrap break-words select-text">{{ item.comment }}</span>
</template>
</div>
</template>
</LightTooltip>
<div class="shrink-0" :style="{ width: `${transposeBeforeSpacerWidth}px` }" />
<div
v-for="cell in item.values"
:key="`${item.id}:${cell.recordIndex}`"
data-grid-transpose-cell
:data-grid-transpose-record-index="cell.recordIndex"
class="relative flex shrink-0 items-center border-r border-border/70 px-2 py-0"
:class="[
transposeCellTextColorClass(cell.recordIndex, cell.valueIndex),
{
'overflow-visible z-20 border-r-transparent': transposeCellEditorActive(cell.recordIndex, cell.valueIndex),
'overflow-hidden text-ellipsis whitespace-nowrap': !transposeCellEditorActive(cell.recordIndex, cell.valueIndex),
'cell-selected': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cell-selected-dirty': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cell-selected--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cell-selected-dirty--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'row-cell-selected': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'row-cell-selected-dirty': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'crosshair-row':
!!crosshairTarget?.columnCrosshair &&
cell.valueIndex === crosshairTarget.actualColIdx &&
!transposeRecordUsesSelectionVisual(cell.recordIndex) &&
!displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] &&
!transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
'bg-primary/15': transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
'bg-yellow-500/10 cell-dirty': displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(cell.recordIndex, cell.valueIndex),
'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': cellIsCurrentMatch(cell.recordIndex, cell.valueIndex),
'cursor-text': !isScrolling,
'hover:bg-gray-200 hover:text-foreground dark:hover:bg-gray-800':
!isScrolling && canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
},
]"
:style="{
width: `${getTransposeRecordWidth(cell.recordIndex)}px`,
}"
:title="cell.display"
@mousedown="prepareTransposeCellMouseDown(cell.recordIndex, cell.valueIndex)"
@click="selectTransposeCell(cell.recordIndex, cell.valueIndex, $event)"
@mouseenter="onTransposeCellMouseenter(cell.recordIndex, cell.valueIndex)"
@mouseleave="onCellMouseleave(cell.recordIndex, cell.valueIndex)"
@contextmenu="onTransposeCellContext(cell.recordIndex, cell.valueIndex, $event)"
@dblclick.stop="onTransposeCellDblClick(cell.recordIndex, cell.valueIndex, cell.display, $event)"
>
<template v-if="readonlyTextCellMatches(displayItems[cell.recordIndex]?.id, cell.valueIndex)">
<DataGridReadonlyTextSelection :value="readonlyTextCell!.value" :expanded="readonlyTextCell!.expanded" @close="closeReadonlyCellTextSelection" @escape="escapeReadonlyCellTextSelection" />
</template>
<template v-else-if="editingCell?.rowId === displayItems[cell.recordIndex]?.id && editingCell?.col === cell.valueIndex">
<TemporalCellEditor
v-if="temporalEditorConfigForColumn(cell.valueIndex)"
v-model="editValue"
:kind="temporalEditorConfigForColumn(cell.valueIndex)!.kind"
:fraction-precision="temporalEditorConfigForColumn(cell.valueIndex)!.fractionPrecision"
:normalize-value="(value) => normalizeTemporalCellEditorValue(value, cell.valueIndex)"
cell-layout="transpose"
@cancel="cancelEdit"
@commit="commitGridEdit"
@save="onTemporalCellEditorSave"
/>
<EnumCellEditor
v-else-if="isBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex)"
v-model="booleanEditorModelValue"
:values="BOOLEAN_CELL_EDITOR_VALUES"
:nullable="isBooleanGridColumnNullable(cell.valueIndex)"
:initial-null="isGridCellInitialNull(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
cell-layout="transpose"
@cancel="cancelEdit"
@commit="commitBooleanGridEdit"
/>
<EnumCellEditor
v-else-if="isEnumGridColumn(cell.valueIndex)"
v-model="editValue"
:values="enumValuesForGridColumn(cell.valueIndex)"
:nullable="isEnumGridColumnNullable(cell.valueIndex)"
:initial-null="isGridCellInitialNull(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
cell-layout="transpose"
@cancel="cancelEdit"
@commit="commitGridEdit"
/>
<textarea
v-else-if="cellUsesExpandedEditor(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
v-model="editValue"
data-expanded-cell-editor="true"
rows="1"
:inputmode="cellEditInputModeForColumn(cell.valueIndex)"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="cell-edit-input cell-edit-input--expanded absolute left-0 top-0 min-h-full bg-background px-1.5 py-1 leading-[18px] outline-none z-10"
@blur="commitEditFromCellBlur"
@click.stop
@focus="onCellEditTextareaInput"
@input="onCellEditTextareaInput"
@keydown.stop="onCellEditKeydown"
@paste.stop="onCellEditTextareaPaste"
@wheel.stop
/>
<input
v-else
v-model="editValue"
:inputmode="cellEditInputModeForColumn(cell.valueIndex)"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-1.5 py-0 leading-[26px] outline-none z-10"
@blur="commitEditFromCellBlur"
@click.stop
@input="onCellEditTextareaInput"
@keydown.stop="onCellEditKeydown"
@paste.stop="onCellEditTextareaPaste"
/>
</template>
<template v-else>
<template v-if="newRowCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex)">
<span class="text-muted-foreground/70 italic">{{ firstLineCellDisplayValue(newRowCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex) ?? "", flatteningMultiLineEnabled) }}</span>
</template>
<template v-else>{{ gridCellDisplayValue(cell.display, flatteningMultiLineEnabled, showWhitespaceEnabled) }}</template>
<div v-if="cellDetailButtonVisible(cell.recordIndex, cell.valueIndex)" class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<LightDropdownMenu
v-if="canQuickDownloadCellValue(cell.recordIndex, cell.valueIndex)"
:items="binaryCellDownloadMenuItems"
:open="quickDownloadMenuOpenFor(cell.recordIndex, cell.valueIndex)"
align="end"
content-class="w-44"
:match-trigger-width="false"
@update:open="(value: boolean) => handleQuickDownloadMenuOpenChange(value, cell.recordIndex, cell.valueIndex)"
@select="(mode: string) => downloadCellBinaryValue(cell.recordIndex, cell.valueIndex, mode as BinaryCellDownloadMode)"
>
<template #trigger="{ open, toggle }">
<button class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground" :title="t('grid.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
<Download class="h-3 w-3" />
</button>
</template>
</LightDropdownMenu>
<button
v-if="canOpenCellExternalUrl(cell.recordIndex, cell.valueIndex)"
class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
:title="t('grid.openUrl')"
:aria-label="t('grid.openUrl')"
@mousedown.stop
@click.stop="openCellExternalUrl(cell.recordIndex, cell.valueIndex)"
>
<ExternalLink class="h-3 w-3" />
</button>
<button
v-if="cellDetailButtonEnabled"
class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
:title="t('grid.cellDetails')"
@mousedown.stop
@click.stop="showTransposeCellDetails(cell.recordIndex, cell.valueIndex)"
>
<Info class="h-3 w-3" />
</button>
</div>
</template>
</div>
<div class="shrink-0" :style="{ width: `${transposeAfterSpacerWidth}px` }" />
</div>
<div class="shrink-0" :style="{ width: `${transposeAfterSpacerWidth}px` }" />
</div>
</template>
</RecycleScroller>
</template>
</RecycleScroller>
</div>
</div>
<template v-else>
<!-- Sticky header -->
@@ -13562,6 +13572,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
`top`/`inset` offsets are measured from the body instead of from the
grid's outer relative container (which starts at the header row). -->
<div class="relative min-h-0 flex-1 flex flex-col overflow-hidden">
<ProductionWatermark v-if="productionContext.active" />
<div v-if="!hasVisibleRows" class="relative min-h-0 flex-1">
<div class="data-grid-scroller h-full overflow-x-auto overflow-y-hidden overscroll-none" :class="{ 'is-scrolling': isScrolling }" @scroll="onScrollerScroll" @wheel="onDomGridWheel">
<div class="h-full min-h-[220px]" :style="{ width: 'max(100%, var(--total-w))' }" />
@@ -450,6 +450,8 @@ function errorMessage(value: unknown): string {
:result="gridResult"
context="results"
database-type="hbase"
:connection-id="props.connectionId"
:database="props.namespace"
:editable="!readOnly"
:custom-save-handler="customSaveHandler"
:allow-insert-rows="false"
@@ -68,6 +68,7 @@ import QueryMessagesView from "@/components/layout/QueryMessagesView.vue";
import QueryResultToolbarActions from "@/components/layout/QueryResultToolbarActions.vue";
import ResultSetNavigator from "@/components/layout/ResultSetNavigator.vue";
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
import ProductionWatermark from "@/components/common/ProductionWatermark.vue";
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
import DataGridFontFamilyControl from "@/components/grid/DataGridFontFamilyControl.vue";
import DataGridColumnWidthModeControl from "@/components/grid/DataGridColumnWidthModeControl.vue";
@@ -252,7 +253,7 @@ const props = defineProps<
const emit = defineEmits<ContentAreaSurfaceEmits>();
const { t, locale } = useI18n();
const { t } = useI18n();
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
/** Clear a consumed editor reveal request so a later normal tab re-visit doesn't re-jump. */
@@ -383,7 +384,6 @@ const activeSqlStatementParameterOptions = computed(() =>
sqlStatementParameterOptionsForCompatibility(activeEffectiveDatabaseType.value, activeEffectiveDatabaseType.value === "opengauss" ? connectionStore.databaseCompatibilityMode(activeResultConnectionId.value, activeResultDatabase.value) : undefined),
);
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
const productionWatermarkText = computed(() => (locale.value.startsWith("zh") ? "生产环境" : "PROD"));
const productionSessionDetail = computed(() => {
if (!activeProductionContext.value.active) return "";
if (activeProductionContext.value.reason === "connection") return t("production.connection");
@@ -1684,9 +1684,7 @@ defineExpose({
{{ t("contextMenu.viewData") }}
</Button>
</div>
<div v-if="activeProductionContext.active" class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
<span v-for="index in 4" :key="index" class="production-watermark__label whitespace-nowrap font-mono text-6xl font-extrabold text-red-700/[0.12] dark:text-red-200/[0.1]">{{ productionWatermarkText }}</span>
</div>
<ProductionWatermark v-if="activeProductionContext.active" />
<!-- issue #9035:源码 tab 先出现再加载。pending 期间不挂载编辑器
(还没有内容可编辑,也省下一次 Monaco 初始化),失败则就地重试。
issue #9387:DDL 新标签同样先出 tab 再加载,失败就地显示错误。 -->
@@ -3197,28 +3195,6 @@ defineExpose({
flex: 0 0 3px;
}
.production-watermark {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 3rem;
overflow: hidden;
padding: 3rem 2.5rem;
}
.production-watermark__label {
align-self: center;
justify-self: center;
transform: rotate(-22deg);
}
@media (max-width: 700px) {
.production-watermark {
grid-template-columns: 1fr;
gap: 1.5rem;
padding-inline: 1rem;
}
}
.result-tab-scroll::-webkit-scrollbar {
display: none;
}
@@ -549,7 +549,7 @@ onBeforeUnmount(() => {
<ErrorBanner v-if="error" :message="error" copy-mode="label" dismissible @dismiss="error = ''" />
<div v-else-if="statusMessage" class="border-b bg-emerald-50 px-3 py-1.5 text-xs text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300">{{ statusMessage }}</div>
<QueryLoadingState v-if="loading && result.columns.length === 0" class="h-full" label-key="editor.fetching" :elapsed-seconds="elapsedSeconds" show-cancel :cancel-disabled="!executionId || cancelling" :cancelling="cancelling" @cancel="cancelRequest" />
<DataGrid v-else class="h-full" :result="result" context="results" :sql="requestText" :loading="loading" @reload="refreshResult" />
<DataGrid v-else class="h-full" :result="result" context="results" :sql="requestText" :connection-id="props.connectionId" :database="props.database" :database-type="props.databaseType" :loading="loading" @reload="refreshResult" />
</div>
</div>
</div>
+12 -6
View File
@@ -146,14 +146,17 @@ describe("sqlFormatter", () => {
expect(formatted).toBe('CREATE VIEW "TEMP_TEST_VIEW" AS\nSELECT trunc(sysdate) AS dates,\n -- 测试\n (SELECT sysdate FROM dual t) AS nows\nFROM dual;');
});
it("collapses a short OceanBase Oracle view DDL onto one line (issue #7540)", async () => {
// The default style joins a statement that fits the line width onto one
// line, so a short view body comes back with its keywords cased rather
// than spread over several.
it("keeps a short OceanBase Oracle view projection readable (issue #7540)", async () => {
// A view body still keeps each projected field on its own line while its
// surrounding clauses retain the default layout.
const singleLine = `create or replace view "APP"."ACTIVE_USERS" as select ID, NAME from USERS where STATUS = 'ACTIVE'`;
const formatted = await formatSqlForDisplay(singleLine, sqlFormatDialectForDbType("oceanbase-oracle"));
expect(formatted).toBe(`CREATE OR REPLACE VIEW "APP"."ACTIVE_USERS" AS SELECT ID, NAME FROM USERS WHERE STATUS = 'ACTIVE'`);
expect(formatted).toBe(`CREATE OR REPLACE VIEW "APP"."ACTIVE_USERS" AS
SELECT ID,
NAME
FROM USERS
WHERE STATUS = 'ACTIVE'`);
expect(formatted).toContain("'ACTIVE'");
expect(formatted).toContain('"ACTIVE_USERS"');
});
@@ -339,7 +342,10 @@ OR inside$$ as note
// Alias casing is the tokenizer's business, not the keyword casing's: the
// date part stays an identifier, so `keywordCase: "upper"` leaves it alone.
expect(formatted).toBe(`SELECT ${identifier}, t.${identifier} FROM material ${identifier} LIMIT 100;`);
expect(formatted).toBe(`SELECT ${identifier},
t.${identifier}
FROM material ${identifier}
LIMIT 100;`);
expect(formatted).toContain("SELECT");
expect(formatted).toContain("FROM");
expect(formatted).toContain("LIMIT");
+47 -16
View File
@@ -31,10 +31,8 @@ describe("sql layout", () => {
// The subquery's clauses align under the `(` that opens it, and the closing
// `)` stays on the last clause's line.
//
// The subquery's two select elements could be split; the screenshots in
// #827 and #4850 disagree on that point (both are two-element lists, one
// split, one not), so the keeping rule here is the one #4850's rendering
// matches.
// Expanded subqueries use the same one-field-per-line rule as the outer
// SELECT, while their clauses remain aligned under the opening parenthesis.
expect(await format(sql)).toBe(
lines(
"SELECT loc_id,",
@@ -42,7 +40,8 @@ describe("sql layout", () => {
" loc_type,",
" delivery_emp_num,",
" update_time",
"FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY loc_id ORDER BY update_time DESC) AS rn",
"FROM (SELECT *,",
" ROW_NUMBER() OVER (PARTITION BY loc_id ORDER BY update_time DESC) AS rn",
" FROM responsible_area_info",
" WHERE DATE_FORMAT(update_time, '%Y-%m') = DATE_FORMAT(NOW(), '%Y-%m')) t",
"WHERE rn = 1;",
@@ -62,9 +61,12 @@ describe("sql layout", () => {
// subquery expands rather than pushing `ON` onto a line of its own.
expect(await format(sql)).toBe(
lines(
"SELECT HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID, HIMMS_XDSBDJ.YNKE, himms_zwxsysj_sysj(V_HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID) AS SJSYSJ",
"SELECT HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID,",
" HIMMS_XDSBDJ.YNKE,",
" himms_zwxsysj_sysj(V_HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID) AS SJSYSJ",
"FROM HIMMS_XDSBDJ",
" LEFT JOIN (SELECT himms_xdsbdj_id, MAX(SJ) SJ",
" LEFT JOIN (SELECT himms_xdsbdj_id,",
" MAX(SJ) SJ",
" FROM himms_zwxqdjc",
" GROUP BY himms_xdsbdj_id) SUB_QDJC ON HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID = SUB_QDJC.himms_xdsbdj_id",
"WHERE HIMMS_XDSBDJ.tybz IS NULL;",
@@ -121,7 +123,7 @@ describe("sql layout", () => {
// `logicalOperatorNewline: "before"` starts each continuation line with the
// operator, aligned under the condition's first operand like any other
// clause item.
expect(await format(sql)).toBe(lines("SELECT a, b", "FROM t", "WHERE alpha = 1", " AND beta = 2", " AND gamma = 3", " AND delta = 4", " AND epsilon = 5", " AND zeta = 6", " AND eta = 7", " AND theta = 8;"));
expect(await format(sql)).toBe(lines("SELECT a,", " b", "FROM t", "WHERE alpha = 1", " AND beta = 2", " AND gamma = 3", " AND delta = 4", " AND epsilon = 5", " AND zeta = 6", " AND eta = 7", " AND theta = 8;"));
});
it("puts a wrapped condition's operators where the setting asks", async () => {
@@ -130,7 +132,7 @@ describe("sql layout", () => {
// `after` ends the previous line with the operator instead.
expect(await format(sql, { logicalOperatorNewline: "after" })).toContain("WHERE alpha = 1 AND\n beta = 2 AND");
// `none` keeps the condition on the clause's line, however long it gets.
expect(await format(sql, { logicalOperatorNewline: "none" })).toBe(lines("SELECT a, b", "FROM t", "WHERE alpha = 1 AND beta = 2 AND gamma = 3 AND delta = 4 AND epsilon = 5 AND zeta = 6 AND eta = 7 AND theta = 8;"));
expect(await format(sql, { logicalOperatorNewline: "none" })).toBe(lines("SELECT a,", " b", "FROM t", "WHERE alpha = 1 AND beta = 2 AND gamma = 3 AND delta = 4 AND epsilon = 5 AND zeta = 6 AND eta = 7 AND theta = 8;"));
});
it("expands a parenthesized list that does not fit, aligned under its first item", async () => {
@@ -167,13 +169,40 @@ describe("sql layout", () => {
it("collapses each clause independently", async () => {
const sql = "SELECT (SELECT count(*) FROM orders o WHERE o.customer_id = c.id AND o.status = 'paid') AS order_count, c.name FROM customers c;";
// The scalar subquery fits, the select list does not: only the second one
// breaks.
expect(await format(sql)).toBe(lines("SELECT (SELECT count(*) FROM orders o WHERE o.customer_id = c.id AND o.status = 'paid') AS order_count, c.name", "FROM customers c;"));
// The scalar subquery stays compact within its own SELECT field.
expect(await format(sql)).toBe(lines("SELECT (SELECT count(*) FROM orders o WHERE o.customer_id = c.id AND o.status = 'paid') AS order_count,", " c.name", "FROM customers c;"));
});
it("never joins a CASE onto one line", async () => {
expect(await format("SELECT CASE WHEN a = 1 THEN 'one' WHEN a = 2 THEN 'two' ELSE 'other' END AS label FROM t;")).toBe(lines("SELECT CASE", " WHEN a = 1 THEN 'one'", " WHEN a = 2 THEN 'two'", " ELSE 'other'", " END AS label", "FROM t;"));
it("keeps a CASE expression compact (#10783)", async () => {
expect(await format("SELECT CASE WHEN a = 1 THEN 'one' WHEN a = 2 THEN 'two' ELSE 'other' END AS label FROM t;")).toBe("SELECT CASE WHEN a = 1 THEN 'one' WHEN a = 2 THEN 'two' ELSE 'other' END AS label FROM t;");
});
it("puts each SELECT field on its own line even in a short query (#10783)", async () => {
expect(await format("SELECT id, CASE WHEN a = 1 THEN 'one' ELSE 'other' END AS label, coalesce(a, b) FROM t;")).toBe(lines("SELECT id,", " CASE WHEN a = 1 THEN 'one' ELSE 'other' END AS label,", " coalesce(a, b)", "FROM t;"));
});
it("puts fields on separate lines when SELECT has a modifier (#10783)", async () => {
expect(await format("SELECT DISTINCT a, b FROM t;")).toBe(lines("SELECT DISTINCT a,", " b", "FROM t;"));
expect(await format("SELECT DISTINCT ON (key) a, b FROM t;")).toBe(lines("SELECT DISTINCT ON (KEY) a,", " b", "FROM t;"));
});
it("keeps nested and simple CASE expressions compact", async () => {
const sql = "SELECT SUM(CASE WHEN a = 1 AND b = 2 THEN 1 ELSE 0 END) AS total, CASE a WHEN 1 THEN CASE b WHEN 2 THEN 'x y' END ELSE 'other' END AS label FROM t;";
const expected = lines("SELECT SUM(CASE WHEN a = 1 AND b = 2 THEN 1 ELSE 0 END) AS total,", " CASE a WHEN 1 THEN CASE b WHEN 2 THEN 'x y' END ELSE 'other' END AS label", "FROM t;");
expect(await format(sql)).toBe(expected);
expect(await format(expected)).toBe(expected);
});
it("preserves comments and width wrapping inside CASE expressions", async () => {
const commented = await format(lines("SELECT id, CASE WHEN a = 1 THEN 'one' -- retain this", "ELSE 'other' END AS label FROM t;"));
expect(commented).toContain("-- retain this\n");
expect(commented).toContain("ELSE 'other'");
expect(await format(commented)).toBe(commented);
const longCase = await format(`SELECT id, CASE WHEN a = 1 THEN '${"x".repeat(100)}' ELSE 'other' END AS label FROM t;`);
expect(longCase).toContain("CASE\n");
expect(longCase).toContain("END AS label");
expect(await format(longCase)).toBe(longCase);
});
it("gives a set operation its own line", async () => {
@@ -187,7 +216,7 @@ describe("sql layout", () => {
});
it("keeps PostgreSQL casts tight", async () => {
expect(await format("select coalesce(a, b)::text, (x + y)::int from t;", {}, "postgres")).toBe("SELECT coalesce(a, b)::text, (x + y)::int FROM t;");
expect(await format("select coalesce(a, b)::text, (x + y)::int from t;", {}, "postgres")).toBe(lines("SELECT coalesce(a, b)::text,", " (x + y)::int", "FROM t;"));
});
it("keeps the comma of a MySQL LIMIT offset", async () => {
@@ -243,7 +272,9 @@ describe("sql layout", () => {
// keyword alone deleted every branch after the first one.
expect(await format(sql)).toBe(
lines(
"SELECT a.id, a.starttime, t.name AS taskName",
"SELECT a.id,",
" a.starttime,",
" t.name AS taskName",
"FROM ((SELECT l.id, l.starttime FROM `log_1_4` l WHERE l.taskid = 'abc' ORDER BY l.starttime DESC LIMIT 0, 10)",
" UNION ALL",
" (SELECT l.id, l.starttime FROM `log_2_4` l WHERE l.taskid = 'abc' ORDER BY l.starttime DESC LIMIT 0, 10)",
+100 -2
View File
@@ -1,4 +1,4 @@
import type { AstNode, ClauseNode, KeywordNode, LimitClauseNode, ParenthesisNode, SetOperationNode } from "sql-formatter/dist/esm/parser/ast.js";
import type { AstNode, CaseExpressionNode, ClauseNode, KeywordNode, LimitClauseNode, ParenthesisNode, SetOperationNode } from "sql-formatter/dist/esm/parser/ast.js";
import type { SqlLayoutRenderers } from "./internals";
/**
@@ -242,6 +242,79 @@ export function keywordText(text: string, ctx: SqlLayoutContext): string {
}
}
/** Whether an AST value contains comments that must retain their own lines. */
function containsComments(value: unknown, seen = new Set<object>()): boolean {
if (!value || typeof value !== "object") return false;
const object = value as object;
if (seen.has(object)) return false;
seen.add(object);
const record = value as Record<string, unknown>;
if (record.type === "line_comment" || record.type === "block_comment" || record.type === "disable_comment") return true;
if (Array.isArray(record.leadingComments) && record.leadingComments.length > 0) return true;
if (Array.isArray(record.trailingComments) && record.trailingComments.length > 0) return true;
return Object.entries(record).some(([key, child]) => key !== "leadingComments" && key !== "trailingComments" && containsComments(child, seen));
}
/**
* Renders a CASE expression as one expression when every branch fits. The
* regular sql-formatter inline renderer deliberately expands CASE branches;
* keeping the expression together makes a CASE projection obey the same
* one-field-per-line rule as every other SELECT item.
*/
function renderCompactCaseExpression(ctx: SqlLayoutContext, node: CaseExpressionNode, width: number): string | null {
if (containsComments(node)) return null;
const parts: string[] = [keywordText(node.caseKw.text, ctx)];
const append = (text: string | null): boolean => {
if (!text) return false;
parts.push(text);
return parts.join(" ").length <= width;
};
if (node.expr.length > 0 && !append(renderInline(collapsedContext(ctx), node.expr, width))) return null;
for (const clause of node.clauses) {
if (clause.type === "case_when") {
if (!append(keywordText(clause.whenKw.text, ctx))) return null;
if (!append(renderInline(collapsedContext(ctx), clause.condition, width))) return null;
if (!append(keywordText(clause.thenKw.text, ctx))) return null;
if (!append(renderInline(collapsedContext(ctx), clause.result, width))) return null;
} else {
if (!append(keywordText(clause.elseKw.text, ctx))) return null;
if (!append(renderInline(collapsedContext(ctx), clause.result, width))) return null;
}
}
if (!append(keywordText(node.endKw.text, ctx))) return null;
return parts.join(" ");
}
/** Keeps CASE expressions inside functions/parentheses compact as well. */
function compactNestedCases(ctx: SqlLayoutContext, node: AstNode, width: number): AstNode {
switch (node.type) {
case "case_expression": {
const text = renderCompactCaseExpression(ctx, node, width);
// This already-formatted expression is opaque to the upstream renderer;
// the original AST remains available for the multiline fallback.
return text ? ({ type: "literal", text } as AstNode) : node;
}
case "parenthesis":
return { ...node, children: node.children.map((child) => compactNestedCases(ctx, child, width)) };
case "function_call":
case "parameterized_data_type":
case "array_subscript":
return { ...node, parenthesis: compactNestedCases(ctx, node.parenthesis, width) as ParenthesisNode };
case "property_access":
return { ...node, object: compactNestedCases(ctx, node.object, width) };
case "between_predicate":
return { ...node, expr1: node.expr1.map((child) => compactNestedCases(ctx, child, width)), expr2: node.expr2.map((child) => compactNestedCases(ctx, child, width)) };
default:
return node;
}
}
export function splitByComma(children: AstNode[]): AstNode[][] {
const groups: AstNode[][] = [];
let current: AstNode[] = [];
@@ -257,6 +330,11 @@ export function splitByComma(children: AstNode[]): AstNode[][] {
return groups;
}
/** Whether a clause name is SELECT, including modifiers such as DISTINCT or ALL. */
export function isProjectionClauseName(name: string): boolean {
return /^SELECT(?:\s|$)/i.test(name.trim());
}
/**
* Keywords that introduce an object name rather than an expression operand, so
* a parenthesis directly after that name is a definition list, not a call:
@@ -343,7 +421,27 @@ export function renderInline(ctx: SqlLayoutContext, nodes: AstNode[], width: num
if (group) return group;
}
const direct = ctx.renderers.inline(nodes, width);
const caseAt = nodes.findIndex((node) => node.type === "case_expression");
if (caseAt >= 0) {
const prefix = caseAt === 0 ? "" : renderInline(collapsedContext(ctx), nodes.slice(0, caseAt), width);
if (prefix === null) return null;
const caseText = renderCompactCaseExpression(ctx, nodes[caseAt] as CaseExpressionNode, width - prefix.length - (prefix ? 1 : 0));
if (!caseText) return null;
let text = prefix ? `${prefix}${separatorBefore(nodes[caseAt], ctx)}${caseText}` : caseText;
const tailNodes = nodes.slice(caseAt + 1);
if (tailNodes.length > 0) {
const tail = renderInline(collapsedContext(ctx), tailNodes, width - text.length - 1);
if (!tail) return null;
text += `${separatorBefore(tailNodes[0], ctx)}${tail}`;
}
return text.length <= width ? text : null;
}
const direct = ctx.renderers.inline(
nodes.map((node) => compactNestedCases(ctx, node, width)),
width,
);
if (direct) return direct;
const joinsOperators = ctx.joinLogicalOperators ?? ctx.options.logicalOperatorNewline === "none";
+30 -8
View File
@@ -1,6 +1,25 @@
import type { AstNode, ClauseNode, LimitClauseNode, ParenthesisNode, SetOperationNode, StatementNode } from "sql-formatter/dist/esm/parser/ast.js";
import { createTableLayout, isCreateTable } from "./ddl";
import { clauseChildContext, collapsedContext, emitText, endsWithLineComment, isBodyNode, isCallParen, isJoinKeyword, isLineComment, isLogicalOperator, isParenthesis, keywordText, limitChildren, renderInline, splitByComma, splitLogicalOperands, Writer, type SqlLayoutContext } from "./primitives";
import {
clauseChildContext,
collapsedContext,
emitText,
endsWithLineComment,
isBodyNode,
isCallParen,
isJoinKeyword,
isLineComment,
isLogicalOperator,
isParenthesis,
isProjectionClauseName,
keywordText,
limitChildren,
renderInline,
splitByComma,
splitLogicalOperands,
Writer,
type SqlLayoutContext,
} from "./primitives";
export type { SqlLayoutContext, SqlLayoutOptions } from "./primitives";
@@ -13,9 +32,10 @@ export type { SqlLayoutContext, SqlLayoutOptions } from "./primitives";
* them.
* - A clause's first item stays on the keyword's line, and its remaining items
* align under the first item's column.
* - Short element lists stay collapsed.
* - A statement whose clauses all fit the line width is emitted as a single
* line.
* - SELECT fields each get their own line; other short element lists stay
* collapsed.
* - A statement with a single SELECT field whose clauses all fit the line
* width is emitted as a single line.
* - Join keywords are indented relative to their FROM clause.
*
* Alignment is *column-relative*, not indent-level-relative: a subquery's
@@ -30,19 +50,20 @@ export type { SqlLayoutContext, SqlLayoutOptions } from "./primitives";
/**
* Emits one clause: its keyword, then its items — the first kept on the keyword's
* line and the rest aligned under it — unless the whole list is short enough to
* stay on one line.
* line and the rest aligned under it. SELECT fields always split; other lists
* stay on one line when they are short enough.
*/
function printClause(writer: Writer, clause: ClauseNode, baseColumn: number, ctx: SqlLayoutContext): void {
const keyword = keywordText(clause.nameKw.text, ctx);
const items = splitByComma(clause.children);
const itemColumn = baseColumn + keyword.length + 1;
const projection = isProjectionClauseName(clause.nameKw.text);
// `FROM` is the one clause DBX lets users push its first source off of, so it
// skips both the collapse and the "element on the keyword's line" rule.
const sourceOnOwnLine = clause.nameKw.text.toUpperCase() === "FROM" && !ctx.options.fromClauseSourceOnSameLine;
const childCtx = clauseChildContext(ctx, clause);
if (!sourceOnOwnLine && items.length <= ctx.options.keepElementsOnOneLine) {
if (!sourceOnOwnLine && !(projection && items.length > 1) && items.length <= ctx.options.keepElementsOnOneLine) {
const flat = renderInline(childCtx, clause.children, ctx.options.lineWidth - writer.column - keyword.length - 1);
if (flat) {
writer.write(`${keyword} ${flat}`);
@@ -282,7 +303,8 @@ function writeBodyNode(writer: Writer, node: AstNode, baseColumn: number, ctx: S
/** Emits a whole statement, one clause per line unless the statement collapses. */
function printStatement(statement: StatementNode, ctx: SqlLayoutContext): string | null {
const collapsed = renderInline(collapsedContext(ctx), statement.children, ctx.options.lineWidth);
const hasMultiItemProjection = statement.children.some((node) => node.type === "clause" && isProjectionClauseName(node.nameKw.text) && splitByComma(node.children).length > 1);
const collapsed = hasMultiItemProjection ? null : renderInline(collapsedContext(ctx), statement.children, ctx.options.lineWidth);
if (collapsed) return statement.hasSemicolon ? `${collapsed};` : collapsed;
if (isCreateTable(statement)) return createTableLayout(statement, ctx);
+9 -2
View File
@@ -29,10 +29,17 @@ test("formats SQL with uppercase keywords and readable line breaks by default",
assert.match(formatted, /\nORDER BY\b/);
});
test("collapses a statement that fits on one line", async () => {
test("puts each SELECT field on its own line even when the statement fits on one line", async () => {
const formatted = await formatSqlText("select id, name from users where active = 1 order by name", "postgres");
assert.equal(formatted, "SELECT id, name FROM users WHERE active = 1 ORDER BY name");
assert.equal(formatted, "SELECT id,\n name\nFROM users\nWHERE active = 1\nORDER BY name");
assert.equal(await formatSqlText(formatted, "postgres"), formatted);
});
test("collapses a single-field statement that fits on one line", async () => {
const formatted = await formatSqlText("select id from users where active = 1 order by id", "postgres");
assert.equal(formatted, "SELECT id FROM users WHERE active = 1 ORDER BY id");
});
test("formats SQL with custom keyword case and indentation settings", async () => {