feat(grid): add conditional bulk updates for MySQL

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-21 01:04:16 +08:00
1 parent 10d1509d92
commit a7edb8f8db
29 files changed
+1813 -85

No files matched your search

+274 -22
View File
@@ -66,6 +66,7 @@ import DataGridToolbar from "@/components/grid/DataGridToolbar.vue";
import DataGridExtractorDialog from "@/components/grid/DataGridExtractorDialog.vue";
import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue";
import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
import DataGridFilterWorkbench from "@/components/grid/DataGridFilterWorkbench.vue";
import DataGridTextFilterWorkbench from "@/components/grid/DataGridTextFilterWorkbench.vue";
import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
@@ -101,7 +102,7 @@ import {
isTdengineExistingRowReadonlyColumn,
usesSyntheticRowIdKey,
} from "@/lib/table/tableEditing";
import { buildDataGridColumnDistinctValuesSql, buildDataGridContextFilterCondition, buildDataGridCountSql, buildHiveTablePropertiesSql, type DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import { buildDataGridColumnDistinctValuesSql, buildDataGridConditionalUpdateSql, buildDataGridContextFilterCondition, buildDataGridCountSql, buildHiveTablePropertiesSql, type DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import {
buildVisibleTransposeRows,
averageTransposeRecordWidth,
@@ -836,6 +837,17 @@ function onGridContextMenuClose() {
const bulkEditDialogOpen = ref(false);
const bulkEditValue = ref("");
const bulkEditScope = ref<"selection" | "condition">("selection");
const conditionalBulkEditConditionSource = ref<"current" | "builder" | "sql">("builder");
const conditionalBulkEditCurrentWhereInput = ref("");
const conditionalBulkEditWhereInput = ref("");
const conditionalBulkEditMatchCount = ref<number | undefined>(undefined);
const conditionalBulkEditMatchCountLoading = ref(false);
const conditionalBulkEditMatchCountError = ref("");
const conditionalBulkEditMatchCountStale = ref(false);
const conditionalBulkEditConfirmOpen = ref(false);
const conditionalBulkEditStatement = ref("");
let conditionalBulkEditMatchCountRequestId = 0;
const inlineBulkEditActive = ref(false);
const copyColumnNamesDialogOpen = ref(false);
const copyColumnNamesDialogColumns = ref<string[]>([]);
@@ -966,27 +978,70 @@ const filterBuilderColumns = computed(() => props.tableMeta?.columns ?? []);
const filterBuilderColumnOptions = computed(() => filterBuilderColumns.value.map((column) => column.name));
const structuredFilterCacheKey = computed(() => props.cacheKey || [props.connectionId ?? "", props.database ?? "", props.context ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? ""].join("\u0001"));
const structuredFilterScopeKey = computed(() => [props.connectionId ?? "", props.database ?? "", props.schema ?? "", props.context ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", filterBuilderColumnOptions.value.join("\0")].join("\u0001"));
function isStructuredFilterRuleComplete(rule: StructuredFilterRule): boolean {
return filterModeIsSupportedForDatabase(rule.mode, resolvedDatabaseType.value) && filterModeHasCompleteValue(rule.mode, rule.rawValue, rule.rawEndValue);
}
async function buildStructuredFilterCondition(rule: StructuredFilterRule): Promise<string | undefined> {
const columnInfo = filterBuilderColumns.value.find((column) => column.name === rule.columnName);
const usesList = filterModeUsesList(rule.mode);
const usesRange = filterModeUsesRange(rule.mode);
return (
(await buildDataGridContextFilterCondition({
databaseType: resolvedDatabaseType.value,
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
columnName: rule.columnName,
columnInfo,
mode: rule.mode,
value: !usesList && filterModeNeedsValue(rule.mode) ? parseFilterValue(rule.rawValue, columnInfo, resolvedDatabaseType.value) : null,
values: usesList ? parseFilterValues(rule.rawValue, columnInfo, resolvedDatabaseType.value) : undefined,
endValue: usesRange ? parseFilterValue(rule.rawEndValue, columnInfo, resolvedDatabaseType.value) : undefined,
})) ?? undefined
);
}
const filterBuilder = useDataGridFilterBuilder({
columns: filterBuilderColumnOptions,
createId: uuid,
isComplete: (rule) => filterModeIsSupportedForDatabase(rule.mode, resolvedDatabaseType.value) && filterModeHasCompleteValue(rule.mode, rule.rawValue, rule.rawEndValue),
buildCondition: async (rule) => {
const columnInfo = filterBuilderColumns.value.find((column) => column.name === rule.columnName);
const usesList = filterModeUsesList(rule.mode);
const usesRange = filterModeUsesRange(rule.mode);
return (
(await buildDataGridContextFilterCondition({
databaseType: resolvedDatabaseType.value,
columnName: rule.columnName,
columnInfo,
mode: rule.mode,
value: !usesList && filterModeNeedsValue(rule.mode) ? parseFilterValue(rule.rawValue, columnInfo, resolvedDatabaseType.value) : null,
values: usesList ? parseFilterValues(rule.rawValue, columnInfo, resolvedDatabaseType.value) : undefined,
endValue: usesRange ? parseFilterValue(rule.rawEndValue, columnInfo, resolvedDatabaseType.value) : undefined,
})) ?? undefined
);
},
isComplete: isStructuredFilterRuleComplete,
buildCondition: buildStructuredFilterCondition,
});
const conditionalBulkEditFilterBuilder = useDataGridFilterBuilder({
columns: filterBuilderColumnOptions,
createId: uuid,
isComplete: isStructuredFilterRuleComplete,
buildCondition: buildStructuredFilterCondition,
});
const conditionalBulkEditRules = conditionalBulkEditFilterBuilder.rules;
const conditionalBulkEditColumnSearch = conditionalBulkEditFilterBuilder.columnSearch;
const filteredConditionalBulkEditColumns = conditionalBulkEditFilterBuilder.filteredColumns;
function addConditionalBulkEditRule() {
if (!conditionalBulkEditRules.value.length) {
conditionalBulkEditFilterBuilder.ensureRule();
return;
}
conditionalBulkEditFilterBuilder.addRule();
}
function resetConditionalBulkEditMatchCountPreview() {
conditionalBulkEditMatchCountRequestId++;
conditionalBulkEditMatchCount.value = undefined;
conditionalBulkEditMatchCountLoading.value = false;
conditionalBulkEditMatchCountError.value = "";
conditionalBulkEditMatchCountStale.value = false;
}
function invalidateConditionalBulkEditMatchCountPreview() {
conditionalBulkEditMatchCountRequestId++;
if (conditionalBulkEditMatchCount.value !== undefined || conditionalBulkEditMatchCountLoading.value) conditionalBulkEditMatchCountStale.value = true;
conditionalBulkEditMatchCount.value = undefined;
conditionalBulkEditMatchCountLoading.value = false;
conditionalBulkEditMatchCountError.value = "";
}
watch([conditionalBulkEditConditionSource, conditionalBulkEditCurrentWhereInput, conditionalBulkEditWhereInput, conditionalBulkEditRules], invalidateConditionalBulkEditMatchCountPreview, { deep: true });
const structuredFilterRules = filterBuilder.rules;
const filterBuilderOpen = filterBuilder.open;
const filterBuilderColumnSearch = filterBuilder.columnSearch;
@@ -3571,6 +3626,8 @@ const {
requestDeleteRows,
cloneRows: cloneEditorRows,
saveChanges,
executeConditionalUpdate,
cancelConditionalUpdate,
discardChanges,
canUndoPendingChange,
canRedoPendingChange,
@@ -3579,6 +3636,9 @@ const {
rowDataWithChanges,
ensureQuickEntryDraftRow,
isSavingNewRow,
isConditionalUpdateActive,
conditionalUpdateExecution,
coerceCellValue,
canEditColumn,
resetGridVerticalScroll,
getResetScrollAfterResult,
@@ -3696,7 +3756,7 @@ const showDataGridTopbar = computed(
);
function canEditRowItem(item: RowItem | undefined): boolean {
return !!props.editable && !!item && !item.isDeleted && (item.isNew || item.isDraft || canEditExistingRows.value);
return !isConditionalUpdateActive.value && !!props.editable && !!item && !item.isDeleted && (item.isNew || item.isDraft || canEditExistingRows.value);
}
function canEditCellItem(item: RowItem | undefined, columnIndex: number): boolean {
@@ -7632,17 +7692,159 @@ function setSelectionNull() {
function openBulkEditDialog() {
if (!props.editable || !selectionHasEditableCells()) return;
const currentWhere = currentWhereInput()?.trim() ?? "";
bulkEditValue.value = "";
bulkEditScope.value = "selection";
conditionalBulkEditCurrentWhereInput.value = currentWhere;
conditionalBulkEditWhereInput.value = currentWhere;
conditionalBulkEditConditionSource.value = currentWhere ? "current" : "builder";
conditionalBulkEditFilterBuilder.reset();
resetConditionalBulkEditMatchCountPreview();
bulkEditDialogOpen.value = true;
}
function applyBulkEditValue() {
interface ConditionalBulkEditTarget {
columnIndex: number;
columnName: string;
}
const conditionalBulkEditTarget = computed<ConditionalBulkEditTarget | null>(() => {
if (props.context !== "table-data" || resolvedDatabaseType.value !== "mysql" || !props.connectionId || !props.tableMeta || props.customSaveHandler || hasPendingChanges.value) return null;
let visibleColumnIndex: number | undefined;
if (selectedRange.value) {
if (selectedRange.value.startCol !== selectedRange.value.endCol) return null;
visibleColumnIndex = selectedRange.value.startCol;
} else if (hasColumnSelection.value) {
const selectedColumns = selectedVisibleColumnIndexes();
if (selectedColumns.length !== 1) return null;
visibleColumnIndex = selectedColumns[0];
}
if (visibleColumnIndex === undefined) return null;
const columnIndex = actualColumnIndex(visibleColumnIndex);
const sourceColumn = props.sourceColumns ? props.sourceColumns[columnIndex] : props.result.columns[columnIndex];
if (!sourceColumn) return null;
const columnInfo = props.tableMeta.columns.find((column) => column.name.toLowerCase() === sourceColumn.toLowerCase());
if (!columnInfo) return null;
if (columnInfo.is_primary_key || props.tableMeta.primaryKeys.some((primaryKey) => primaryKey.toLowerCase() === columnInfo.name.toLowerCase())) return null;
const extra = columnInfo.extra?.toLowerCase() ?? "";
if (extra.includes("generated always as") && !extra.includes("identity")) return null;
if (!selectionHasEditableCells()) return null;
const hasExistingTarget = editableSelectionCells().some(({ item }) => !item.isNew && !item.isDraft);
if (!hasExistingTarget) return null;
return { columnIndex, columnName: columnInfo.name };
});
async function currentConditionalBulkEditWhere(): Promise<string> {
switch (conditionalBulkEditConditionSource.value) {
case "current":
return conditionalBulkEditCurrentWhereInput.value.trim();
case "builder":
return conditionalBulkEditFilterBuilder.buildWhere();
case "sql":
return conditionalBulkEditWhereInput.value.trim();
}
}
async function previewConditionalBulkEditMatchCount() {
if (!props.connectionId || !props.tableMeta) return;
const requestId = ++conditionalBulkEditMatchCountRequestId;
conditionalBulkEditMatchCount.value = undefined;
conditionalBulkEditMatchCountError.value = "";
conditionalBulkEditMatchCountStale.value = false;
conditionalBulkEditMatchCountLoading.value = true;
try {
const whereInput = await currentConditionalBulkEditWhere();
if (!whereInput) {
conditionalBulkEditMatchCountError.value = t("grid.conditionalBulkEditConditionRequired");
return;
}
const sql = await buildDataGridCountSql({
databaseType: resolvedDatabaseType.value,
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
catalog: props.tableMeta.catalog,
database: props.tableMeta.database,
schema: props.tableMeta.schema,
tableName: props.tableMeta.tableName,
whereInput,
});
const result = await api.executeQuery(props.connectionId, props.executionDatabase ?? props.database ?? "", sql, undefined, undefined, dataGridCountQueryOptions(connectionStore.getConfig(props.connectionId), settingsStore.editorSettings.globalQueryTimeoutSecs));
const count = Number(result.rows?.[0]?.[0] ?? 0);
if (!Number.isFinite(count) || count < 0) throw new Error("Invalid row count returned by the database");
if (requestId !== conditionalBulkEditMatchCountRequestId) return;
conditionalBulkEditMatchCount.value = count;
} catch (error: any) {
if (requestId !== conditionalBulkEditMatchCountRequestId) return;
conditionalBulkEditMatchCountError.value = t("grid.conditionalBulkEditPreviewCountFailed", { message: error?.message || String(error) });
} finally {
if (requestId === conditionalBulkEditMatchCountRequestId) conditionalBulkEditMatchCountLoading.value = false;
}
}
async function applyBulkEditValue() {
if (bulkEditScope.value === "condition") {
const target = conditionalBulkEditTarget.value;
if (!target || !props.tableMeta) {
toast(t("grid.conditionalBulkEditUnavailable"), 5000);
return;
}
try {
const whereInput = await currentConditionalBulkEditWhere();
if (!whereInput) {
toast(t("grid.conditionalBulkEditConditionRequired"), 5000);
return;
}
const value = bulkEditValue.value === "" ? null : coerceCellValue(bulkEditValue.value, undefined, target.columnIndex);
const statement = await buildDataGridConditionalUpdateSql({
databaseType: resolvedDatabaseType.value,
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
tableMeta: props.tableMeta,
columnName: target.columnName,
value,
whereInput,
});
if (!statement) {
toast(t("grid.conditionalBulkEditUnavailable"), 5000);
return;
}
conditionalBulkEditStatement.value = statement;
bulkEditDialogOpen.value = false;
conditionalBulkEditConfirmOpen.value = true;
} catch (error: any) {
toast(t("grid.conditionalBulkEditPrepareFailed", { message: error?.message || String(error) }), 5000);
}
return;
}
// Empty input sets the selected cells to SQL NULL (the placeholder hints "Value, or NULL").
const value = bulkEditValue.value === "" ? null : bulkEditValue.value;
if (!fillSelectionWithValue(value)) return;
bulkEditDialogOpen.value = false;
}
async function confirmConditionalBulkEdit() {
const statement = conditionalBulkEditStatement.value;
if (!statement) return;
conditionalBulkEditConfirmOpen.value = false;
const result = await executeConditionalUpdate(statement);
if (!result) {
if (saveError.value) toast(saveError.value, 5000);
return;
}
conditionalBulkEditStatement.value = "";
if (typeof result.affectedRows === "number") {
toast(t("grid.conditionalBulkEditAppliedCount", { count: result.affectedRows }));
} else {
toast(t("grid.conditionalBulkEditApplied"));
}
}
async function cancelRunningConditionalBulkEdit() {
const cancelled = await cancelConditionalUpdate();
if (!cancelled && saveError.value) toast(saveError.value, 5000);
}
interface EditableSelectionCell {
item: RowItem;
visibleCol: number;
@@ -10731,7 +10933,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
ref="gridRef"
data-grid-root
data-grid-active="true"
class="h-full flex flex-col overflow-hidden outline-none"
class="relative h-full flex flex-col overflow-hidden outline-none"
:class="{
'data-grid--editing-cell': !!editingCell,
'data-grid--dark': isDark,
@@ -12594,7 +12796,45 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
:copy-column-detail-column-name="copyColumnDetailColumnName"
/>
<DataGridBulkEditDialog v-if="bulkEditDialogMounted" v-model:open="bulkEditDialogOpen" v-model:value="bulkEditValue" :selected-cell-count="selectedCellCount" @apply="applyBulkEditValue" />
<DataGridBulkEditDialog
v-if="bulkEditDialogMounted"
v-model:open="bulkEditDialogOpen"
v-model:value="bulkEditValue"
v-model:scope="bulkEditScope"
v-model:condition-source="conditionalBulkEditConditionSource"
v-model:where-input="conditionalBulkEditWhereInput"
:selected-cell-count="selectedCellCount"
:conditional-available="!!conditionalBulkEditTarget"
:conditional-column="conditionalBulkEditTarget?.columnName"
:current-where="conditionalBulkEditCurrentWhereInput"
:conditional-count="conditionalBulkEditMatchCount"
:conditional-count-loading="conditionalBulkEditMatchCountLoading"
:conditional-count-error="conditionalBulkEditMatchCountError"
:conditional-count-stale="conditionalBulkEditMatchCountStale"
@apply="applyBulkEditValue"
@preview-count="previewConditionalBulkEditMatchCount"
>
<template #condition-builder>
<div class="space-y-2">
<DataGridFilterBuilder
:rules="conditionalBulkEditRules"
:columns="filterBuilderColumnOptions"
:filtered-columns="filteredConditionalBulkEditColumns"
:mode-options="filterModeOptions"
:column-search="conditionalBulkEditColumnSearch"
:show-header="false"
:show-footer="false"
layout="panel"
@add="addConditionalBulkEditRule"
@remove="conditionalBulkEditFilterBuilder.removeRule($event)"
@move="(id, targetIndex) => conditionalBulkEditFilterBuilder.moveRule(id, targetIndex)"
@update-rule="(id, patch) => conditionalBulkEditFilterBuilder.updateRule(id, patch)"
@update:column-search="conditionalBulkEditColumnSearch = $event"
/>
<Button variant="outline" size="sm" :disabled="!filterBuilderColumnOptions.length" @click="addConditionalBulkEditRule">{{ t("grid.filterBuilderAddRule") }}</Button>
</div>
</template>
</DataGridBulkEditDialog>
<DataGridInsertRowsDialog v-if="insertRowsDialogMounted" v-model:open="insertRowsDialogOpen" :can-place-at-selection="canPlaceInsertAtSelection" :initial-position="insertPosition" @insert="insertRows" />
<DataGridExtractorDialog v-model:open="extractorConfigOpen" :preference="selectedCopyPreference" :options="settingsStore.editorSettings.dataGridExtractorOptions" :items="copyPreferenceMenuItems" :preview="previewWithPreference" @save="saveExtractorConfiguration" />
@@ -12627,6 +12867,18 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
<SqlPreviewPanel :sql="previewSqlText" :loading="isPreviewLoading" :can-undo="canUndoPendingChange" :can-redo="canRedoPendingChange" @undo="undoGridChange" @redo="redoGridChange" @close="closeSqlPreview" />
</div>
<DangerConfirmDialog
v-model:open="conditionalBulkEditConfirmOpen"
:title="t('grid.conditionalBulkEditConfirmTitle')"
:message="t('grid.conditionalBulkEditConfirmMessage')"
:sql="conditionalBulkEditStatement"
:confirm-label="t('grid.conditionalBulkEditConfirm')"
:close-on-confirm="false"
@confirm="confirmConditionalBulkEdit"
/>
<div v-if="isConditionalUpdateActive" class="absolute inset-0 z-50 flex items-center justify-center bg-background/75 backdrop-blur-[1px]">
<QueryLoadingState class="p-6" :show-cancel="true" :cancelling="conditionalUpdateExecution?.cancelling" @cancel="cancelRunningConditionalBulkEdit" />
</div>
<DangerConfirmDialog
v-model:open="showDeleteRowConfirm"
:message="
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -6,18 +7,143 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
const value = defineModel<string>("value", { default: "" });
defineProps<{ selectedCellCount: number }>();
const emit = defineEmits<{ apply: [] }>();
const scope = defineModel<"selection" | "condition">("scope", { default: "selection" });
const conditionSource = defineModel<"current" | "builder" | "sql">("conditionSource", { default: "builder" });
const whereInput = defineModel<string>("whereInput", { default: "" });
defineProps<{
selectedCellCount: number;
conditionalAvailable?: boolean;
conditionalColumn?: string;
currentWhere?: string;
conditionalCount?: number;
conditionalCountLoading?: boolean;
conditionalCountError?: string;
conditionalCountStale?: boolean;
}>();
const emit = defineEmits<{ apply: []; previewCount: [] }>();
const dragOffset = ref({ x: 0, y: 0 });
const isDragging = ref(false);
const dragStartPosition = ref({ x: 0, y: 0 });
const dragStartOffset = ref({ x: 0, y: 0 });
const activePointerId = ref<number | null>(null);
const dialogContentStyle = computed(() => {
if (isDragging.value || dragOffset.value.x !== 0 || dragOffset.value.y !== 0) {
return {
transform: `translate(${dragOffset.value.x}px, ${dragOffset.value.y}px)`,
transition: isDragging.value ? "none" : "transform 0.15s ease-out",
};
}
return {};
});
function resetDialogDragOffset() {
dragOffset.value = { x: 0, y: 0 };
isDragging.value = false;
activePointerId.value = null;
}
function startDialogDrag(event: PointerEvent) {
if (event.button !== undefined && event.button !== 0) return;
isDragging.value = true;
activePointerId.value = event.pointerId;
dragStartPosition.value = { x: event.clientX, y: event.clientY };
dragStartOffset.value = { ...dragOffset.value };
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function moveDialogDrag(event: PointerEvent) {
if (!isDragging.value || event.pointerId !== activePointerId.value) return;
dragOffset.value = {
x: dragStartOffset.value.x + event.clientX - dragStartPosition.value.x,
y: dragStartOffset.value.y + event.clientY - dragStartPosition.value.y,
};
}
function endDialogDrag(event: PointerEvent) {
if (!isDragging.value || event.pointerId !== activePointerId.value) return;
isDragging.value = false;
activePointerId.value = null;
try {
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
} catch {
// Pointer capture may already have been released by the browser.
}
}
watch(open, (isOpen) => {
if (isOpen) {
void nextTick(resetDialogDragOffset);
} else {
resetDialogDragOffset();
}
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader
<DialogContent :style="dialogContentStyle" class="sm:max-w-[680px]">
<DialogHeader class="cursor-move select-none" @pointerdown="startDialogDrag" @pointermove="moveDialogDrag" @pointerup="endDialogDrag" @pointercancel="endDialogDrag"
><DialogTitle>{{ t("grid.bulkEditTitle") }}</DialogTitle></DialogHeader
>
<div class="space-y-2">
<p class="text-sm text-muted-foreground">{{ t("grid.bulkEditDescription", { count: selectedCellCount }) }}</p>
<div v-if="conditionalAvailable" class="grid gap-2 pb-1">
<button type="button" class="rounded-md border px-3 py-2 text-left transition-colors" :class="scope === 'selection' ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'" @click="scope = 'selection'">
<span class="block text-sm font-medium">{{ t("grid.bulkEditSelectionScope") }}</span>
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t("grid.bulkEditSelectionScopeDescription", { count: selectedCellCount }) }}</span>
</button>
<button type="button" class="rounded-md border px-3 py-2 text-left transition-colors" :class="scope === 'condition' ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'" @click="scope = 'condition'">
<span class="block text-sm font-medium">{{ t("grid.bulkEditConditionalScope") }}</span>
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t("grid.bulkEditConditionalScopeDescription", { column: conditionalColumn }) }}</span>
</button>
</div>
<p v-else class="text-sm text-muted-foreground">{{ t("grid.bulkEditDescription", { count: selectedCellCount }) }}</p>
<div v-if="scope === 'condition'" class="space-y-2">
<div class="text-sm font-medium">{{ t("grid.bulkEditConditionalConditionSource") }}</div>
<div class="grid gap-2" :class="currentWhere ? 'sm:grid-cols-3' : 'sm:grid-cols-2'">
<button v-if="currentWhere" type="button" class="rounded-md border px-3 py-2 text-left transition-colors" :class="conditionSource === 'current' ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'" @click="conditionSource = 'current'">
<span class="block text-sm font-medium">{{ t("grid.bulkEditConditionalUseCurrentFilter") }}</span>
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t("grid.bulkEditConditionalUseCurrentFilterDescription") }}</span>
</button>
<button type="button" class="rounded-md border px-3 py-2 text-left transition-colors" :class="conditionSource === 'builder' ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'" @click="conditionSource = 'builder'">
<span class="block text-sm font-medium">{{ t("grid.bulkEditConditionalBuildFilter") }}</span>
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t("grid.bulkEditConditionalBuildFilterDescription") }}</span>
</button>
<button type="button" class="rounded-md border px-3 py-2 text-left transition-colors" :class="conditionSource === 'sql' ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'" @click="conditionSource = 'sql'">
<span class="block text-sm font-medium">{{ t("grid.bulkEditConditionalAdvancedWhere") }}</span>
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t("grid.bulkEditConditionalAdvancedWhereDescription") }}</span>
</button>
</div>
<div v-if="conditionSource === 'current' && currentWhere" class="rounded-md bg-muted/60 px-2.5 py-2">
<code class="block max-h-20 overflow-auto whitespace-pre-wrap break-all text-xs">{{ currentWhere }}</code>
</div>
<div v-else-if="conditionSource === 'builder'" class="max-h-72 overflow-auto rounded-md border bg-muted/10 p-2">
<slot name="condition-builder" />
</div>
<template v-else>
<label class="text-sm font-medium" for="bulk-edit-where-input">{{ t("grid.bulkEditConditionalWhere") }}</label>
<textarea
id="bulk-edit-where-input"
v-model="whereInput"
autocapitalize="off"
autocomplete="off"
autocorrect="off"
spellcheck="false"
rows="3"
class="dbx-data-grid-value-font min-h-20 w-full min-w-0 resize-y rounded-[6px] border border-input bg-transparent px-2.5 py-1.5 text-base outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm"
:placeholder="t('grid.bulkEditConditionalWherePlaceholder')"
/>
<p class="text-xs text-muted-foreground">{{ t("grid.bulkEditConditionalWhereDescription") }}</p>
</template>
<div class="flex flex-wrap items-center gap-2 pt-1">
<Button variant="outline" size="sm" :disabled="conditionalCountLoading" @click="emit('previewCount')">
{{ conditionalCountLoading ? t("grid.conditionalBulkEditPreviewingCount") : t("grid.conditionalBulkEditPreviewCount") }}
</Button>
<span v-if="conditionalCount !== undefined" class="text-sm font-medium text-foreground">{{ t("grid.conditionalBulkEditPreviewCountResult", { count: conditionalCount }) }}</span>
<span v-else-if="conditionalCountStale" class="text-xs text-muted-foreground">{{ t("grid.conditionalBulkEditPreviewCountStale") }}</span>
</div>
<p v-if="conditionalCountError" class="text-xs text-destructive">{{ conditionalCountError }}</p>
</div>
<textarea
v-model="value"
autocapitalize="off"
@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
getConfig: vi.fn(),
prepareDataGridSave: vi.fn(),
executeBatch: vi.fn(),
executeConditionalUpdate: vi.fn(),
cancelConditionalUpdate: vi.fn(),
executeInTransaction: vi.fn(),
addHistory: vi.fn(),
}));
@@ -14,6 +16,8 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/lib/backend/api", () => ({
prepareDataGridSave: mocks.prepareDataGridSave,
executeBatch: mocks.executeBatch,
executeConditionalUpdate: mocks.executeConditionalUpdate,
cancelConditionalUpdate: mocks.cancelConditionalUpdate,
executeInTransaction: mocks.executeInTransaction,
}));
vi.mock("@/stores/connectionStore", () => ({
@@ -366,6 +370,8 @@ describe("useDataGridEditor saveChanges reload", () => {
beforeEach(() => {
mocks.prepareDataGridSave.mockReset();
mocks.executeBatch.mockReset();
mocks.executeConditionalUpdate.mockReset();
mocks.cancelConditionalUpdate.mockReset();
mocks.executeInTransaction.mockReset();
mocks.addHistory.mockReset();
mocks.getConfig.mockReset();
@@ -428,6 +434,132 @@ describe("useDataGridEditor saveChanges reload", () => {
expect(emit).toHaveBeenCalledWith("reload", undefined, "", undefined, undefined, 100, 0);
});
it("executes a conditional update immediately, records affected rows, and reloads", async () => {
mocks.getConfig.mockReturnValue({ id: "connection-1", name: "Local MySQL", db_type: "mysql" });
mocks.executeConditionalUpdate.mockResolvedValue({ affected_rows: 7 });
const prepareFullReload = vi.fn();
const { editor, emit } = createSaveTestEditor({ prepareFullReload });
const statement = "UPDATE `app`.`orders_test` SET `status` = 'shipped' WHERE (`status` = 'pending');";
await expect(editor.executeConditionalUpdate(statement)).resolves.toEqual({ affectedRows: 7 });
expect(mocks.executeConditionalUpdate).toHaveBeenCalledWith("connection-1", "app", statement, undefined, expect.any(String));
expect(mocks.addHistory).toHaveBeenCalledWith(
expect.objectContaining({
connection_id: "connection-1",
connection_name: "Local MySQL",
sql: statement,
success: true,
operation: "UPDATE",
affected_rows: 7,
}),
);
expect(JSON.parse(mocks.addHistory.mock.calls[0][0].details_json)).toMatchObject({ conditional_update: true, statement_count: 1 });
expect(prepareFullReload).toHaveBeenCalledTimes(1);
expect(emit).toHaveBeenCalledWith("reload", undefined, "", undefined, undefined, 100, 0);
});
it("unlocks, records, and reloads after a terminal conditional update failure", async () => {
const sqlError = Object.assign(new Error("Duplicate entry '1' for key 'PRIMARY'"), {
backendError: {
version: 1,
code: "DBX-JDBC-4001",
messageKey: "backendErrors.jdbc.sqlFailed",
messageParams: { stage: "execute" },
source: "jdbc_agent",
operationOutcome: "unknown",
diagnostics: { category: "sql", stage: "execute" },
},
});
mocks.executeConditionalUpdate.mockRejectedValue(sqlError);
const prepareFullReload = vi.fn();
const { editor, emit } = createSaveTestEditor({ prepareFullReload });
const statement = "UPDATE `app`.`orders_test` SET `id` = 1 WHERE (`status` = 'pending');";
await expect(editor.executeConditionalUpdate(statement)).resolves.toBeNull();
expect(editor.isConditionalUpdateActive.value).toBe(false);
expect(editor.saveError.value).toBe("Duplicate entry '1' for key 'PRIMARY'");
expect(mocks.cancelConditionalUpdate).not.toHaveBeenCalled();
expect(mocks.addHistory).toHaveBeenCalledWith(
expect.objectContaining({
sql: statement,
success: false,
error: "Duplicate entry '1' for key 'PRIMARY'",
affected_rows: undefined,
}),
);
expect(JSON.parse(mocks.addHistory.mock.calls[0][0].details_json)).toMatchObject({ conditional_update: true, execution_outcome: "failed" });
expect(prepareFullReload).toHaveBeenCalledTimes(1);
expect(emit).toHaveBeenCalledWith("reload", undefined, "", undefined, undefined, 100, 0);
});
it("refuses a conditional update while row edits are pending", async () => {
const { editor, emit } = createSaveTestEditor();
editor.dirtyRows.value = new Map([[0, new Map([[1, "shipped"]])]]);
await expect(editor.executeConditionalUpdate("UPDATE orders_test SET status = 'shipped' WHERE id > 0;")).resolves.toBeNull();
expect(mocks.executeConditionalUpdate).not.toHaveBeenCalled();
expect(mocks.addHistory).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
expect(editor.saveError.value).not.toBe("");
});
it("keeps a timed-out conditional update cancellable without recording a failed update", async () => {
mocks.executeConditionalUpdate.mockRejectedValue(new Error("Query timed out after 30 seconds"));
mocks.cancelConditionalUpdate.mockResolvedValue({ requested: true, terminal: true });
const { editor, emit } = createSaveTestEditor();
await expect(editor.executeConditionalUpdate("UPDATE orders_test SET status = 'shipped' WHERE id > 0;")).resolves.toBeNull();
expect(editor.isConditionalUpdateActive.value).toBe(true);
expect(mocks.addHistory).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
await expect(editor.cancelConditionalUpdate()).resolves.toBe(true);
expect(editor.isConditionalUpdateActive.value).toBe(false);
expect(mocks.cancelConditionalUpdate).toHaveBeenCalledWith(expect.any(String));
expect(emit).toHaveBeenCalledWith("reload", undefined, "", undefined, undefined, 100, 0);
});
it("keeps a timed-out conditional update locked until cancellation reports a terminal state", async () => {
vi.useFakeTimers();
mocks.executeConditionalUpdate.mockRejectedValue(new Error("Query timed out after 30 seconds"));
mocks.cancelConditionalUpdate.mockResolvedValue({ requested: true, terminal: false });
const { editor, emit } = createSaveTestEditor();
try {
await expect(editor.executeConditionalUpdate("UPDATE orders_test SET status = 'shipped' WHERE id > 0;")).resolves.toBeNull();
await expect(editor.cancelConditionalUpdate()).resolves.toBe(false);
expect(editor.isConditionalUpdateActive.value).toBe(true);
expect(emit).not.toHaveBeenCalled();
} finally {
vi.clearAllTimers();
vi.useRealTimers();
}
});
it("reloads a timed-out conditional update after a later terminal confirmation", async () => {
vi.useFakeTimers();
mocks.executeConditionalUpdate.mockRejectedValue(new Error("Query timed out after 30 seconds"));
mocks.cancelConditionalUpdate.mockResolvedValueOnce({ requested: true, terminal: false }).mockResolvedValueOnce({ requested: false, terminal: true });
const { editor, emit } = createSaveTestEditor();
try {
await expect(editor.executeConditionalUpdate("UPDATE orders_test SET status = 'shipped' WHERE id > 0;")).resolves.toBeNull();
await expect(editor.cancelConditionalUpdate()).resolves.toBe(false);
await vi.advanceTimersByTimeAsync(1_000);
expect(editor.isConditionalUpdateActive.value).toBe(false);
expect(mocks.cancelConditionalUpdate).toHaveBeenCalledTimes(2);
expect(emit).toHaveBeenCalledWith("reload", undefined, "", undefined, undefined, 100, 0);
} finally {
vi.clearAllTimers();
vi.useRealTimers();
}
});
it("does not reload when there are no pending changes to save", async () => {
const { editor, emit } = createSaveTestEditor();
@@ -1,4 +1,4 @@
import { ref, shallowRef, computed, nextTick, watch, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, toRaw, type ComputedRef, type Ref } from "vue";
import { ref, shallowRef, triggerRef, computed, nextTick, watch, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, toRaw, type ComputedRef, type Ref } from "vue";
import * as api from "@/lib/backend/api";
import type { CellValue } from "@/lib/dataGrid/cellValue";
import { coerceDataGridCellValue, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion";
@@ -14,6 +14,8 @@ import { assessProductionSql, productionContextForDatabase } from "@/lib/databas
import type { ColumnInfo, DatabaseType } from "@/types/database";
import { DBX_NEO4J_ELEMENT_ID_COLUMN, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { normalizeBackendError } from "@/lib/backend/errorUtils";
import { uuid } from "@/lib/common/utils";
import i18n from "@/i18n";
interface RowItem {
@@ -32,6 +34,16 @@ export const DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID = Number.MIN_SAFE_INTEGER;
export const DATA_GRID_MAX_BATCH_INSERT_ROWS = 1000;
type RowKind = "none" | "existing" | "new" | "draft";
type ConditionalUpdateOutcome = "not-started" | "running" | "completed" | "failed" | "unknown";
interface ConditionalUpdateExecution {
executionId: string;
dispatched: boolean;
cancelRequested: boolean;
cancelling: boolean;
terminalCheckScheduled: boolean;
outcome: ConditionalUpdateOutcome;
}
export type DataGridAppendPastedRowsResult = { ok: true; rowCount: number } | { ok: false; reason: "not-editable" | "invalid-target" | "target-not-empty" | "empty-paste" | "readonly-column" };
@@ -321,6 +333,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const transactionActive = ref(false);
const isSaving = ref(false);
const saveError = ref("");
const conditionalUpdateExecution = shallowRef<ConditionalUpdateExecution>();
const isConditionalUpdateActive = computed(() => conditionalUpdateExecution.value !== undefined);
const hasBackendSaveTarget = computed(() => !!connectionId.value && !!tableMeta.value);
const useTransaction = computed(() => editable.value && supportsDataGridTransaction(resolvedDatabaseType.value) && (!!customSaveHandler?.value || hasBackendSaveTarget.value));
@@ -613,7 +627,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
function canEditColumn(columnIndex: number): boolean {
const sources = sourceColumns.value;
return (!sources || sources[columnIndex] !== undefined) && !readonlyColumnIndexes.value?.has(columnIndex);
return !isConditionalUpdateActive.value && (!sources || sources[columnIndex] !== undefined) && !readonlyColumnIndexes.value?.has(columnIndex);
}
// --- Row data helpers ---
@@ -1005,6 +1019,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function addRow() {
if (isConditionalUpdateActive.value) return;
pushUndoSnapshot();
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
newRows.value.push(result.value.columns.map(() => null));
@@ -1033,6 +1048,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
// positions). Returns the first inserted row's id, or undefined when the
// count was rejected.
function addRows(count: number, placement: GridNewRowPlacement | null = null): number | undefined {
if (isConditionalUpdateActive.value) return undefined;
if (!Number.isInteger(count) || count <= 0) return undefined;
const clampedCount = Math.min(count, DATA_GRID_MAX_BATCH_INSERT_ROWS);
const firstNewIndex = newRows.value.length;
@@ -1172,6 +1188,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function cloneRows(rowIds: number[], resolvedValues?: ReadonlyMap<number, ReadonlyMap<number, CellValue>>) {
if (isConditionalUpdateActive.value) return;
const rowsToClone = rowIds.map((rowId) => getRowItem(rowId)).filter(Boolean) as RowItem[];
if (rowsToClone.length === 0) return;
pushUndoSnapshot();
@@ -1190,6 +1207,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function applyDeleteRows(rowIds: number[]) {
if (isConditionalUpdateActive.value) return;
const items = rowIds.map((rowId) => getRowItem(rowId)).filter((item): item is RowItem => !!item);
if (items.length === 0) return;
@@ -1275,6 +1293,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
);
function restoreRow(rowId: number) {
if (isConditionalUpdateActive.value) return;
const item = getRowItem(rowId);
if (item?.sourceIndex !== undefined && deletedRows.value.has(item.sourceIndex)) {
pushUndoSnapshot();
@@ -1285,6 +1304,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function restoreRows(rowIds: number[]) {
if (isConditionalUpdateActive.value) return;
const sourceIndexes = rowIds.map((rowId) => getRowItem(rowId)?.sourceIndex).filter((sourceIndex): sourceIndex is number => sourceIndex !== undefined && deletedRows.value.has(sourceIndex));
if (sourceIndexes.length === 0) return;
pushUndoSnapshot();
@@ -1458,11 +1478,173 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
return message;
}
async function recordConditionalUpdateHistory(statement: string, elapsed: number, historyResult: { affectedRows?: number; success?: boolean; error?: string } = {}) {
if (!connectionId.value || !tableMeta.value) return;
const connectionName = connectionStore.getConfig(connectionId.value)?.name || "";
const success = historyResult.success ?? true;
await historyStore.add({
connection_id: connectionId.value,
connection_name: connectionName,
database: database.value ?? "",
sql: statement,
execution_time_ms: elapsed,
success,
error: success ? undefined : historyResult.error,
activity_kind: "data_change",
operation: "UPDATE",
target: tableHistoryTarget(),
affected_rows: success ? historyResult.affectedRows : undefined,
details_json: JSON.stringify({
schema: tableMeta.value.schema,
table: tableMeta.value.tableName,
conditional_update: true,
statement_count: 1,
execution_outcome: success ? "completed" : "failed",
error: success ? undefined : historyResult.error,
}),
});
}
function isConditionalUpdateTerminalFailure(error: unknown) {
const backendError = normalizeBackendError(error);
return backendError?.operationOutcome === "not_started" || backendError?.code === "DBX-JDBC-4001" || backendError?.diagnostics?.category === "sql";
}
function reloadCurrentData() {
options.prepareFullReload?.();
options.emit("reload", sql.value, searchText.value, options.currentWhereInput.value, orderByInput.value.trim() || undefined, pageSize.value, (currentPage.value - 1) * pageSize.value);
}
function completeConditionalUpdate(execution: ConditionalUpdateExecution) {
if (conditionalUpdateExecution.value !== execution) return false;
execution.outcome = "completed";
conditionalUpdateExecution.value = undefined;
isSaving.value = false;
reloadCurrentData();
return true;
}
function scheduleConditionalUpdateTerminalCheck(execution: ConditionalUpdateExecution) {
if (execution.terminalCheckScheduled) return;
execution.terminalCheckScheduled = true;
setTimeout(() => {
execution.terminalCheckScheduled = false;
if (conditionalUpdateExecution.value !== execution) return;
void api
.cancelConditionalUpdate(execution.executionId)
.then((cancellation) => {
if (cancellation.terminal) {
completeConditionalUpdate(execution);
} else {
scheduleConditionalUpdateTerminalCheck(execution);
}
})
.catch(() => scheduleConditionalUpdateTerminalCheck(execution));
}, 1_000);
}
async function executeConditionalUpdate(statement: string): Promise<{ affectedRows?: number } | null> {
if (isSaving.value || !connectionId.value || !tableMeta.value) return null;
if (editingCell.value) commitEdit();
if (hasPendingChanges.value) {
saveError.value = i18n.global.t("grid.conditionalBulkEditPendingChanges");
return null;
}
saveError.value = "";
const connection = connectionStore.getConfig(connectionId.value);
const productionAssessment = assessProductionSql(statement, connection, database.value);
if (productionAssessment.active && productionAssessment.isMutation) {
const confirmed = await productionSafetyStore.requestConfirmation({
sql: statement,
connectionName: connection?.name,
database: database.value,
productionDatabases: productionAssessment.databases,
source: "Data editor",
});
if (!confirmed) return null;
}
const execution: ConditionalUpdateExecution = {
executionId: uuid(),
dispatched: false,
cancelRequested: false,
cancelling: false,
terminalCheckScheduled: false,
outcome: "not-started",
};
conditionalUpdateExecution.value = execution;
isSaving.value = true;
const startedAt = Date.now();
try {
if (execution.cancelRequested) return null;
execution.dispatched = true;
execution.outcome = "running";
const result = await api.executeConditionalUpdate(connectionId.value, database.value ?? "", statement, tableMeta.value.schema, execution.executionId);
if (conditionalUpdateExecution.value !== execution) return null;
execution.outcome = "completed";
try {
await recordConditionalUpdateHistory(statement, Date.now() - startedAt, { affectedRows: result.affected_rows });
} catch (historyError) {
console.warn("[DBX] failed to record conditional data grid update history", historyError);
}
reloadCurrentData();
return { affectedRows: result?.affected_rows };
} catch (error) {
if (conditionalUpdateExecution.value !== execution) return null;
const message = normalizeDataGridSaveError(databaseType.value, error);
saveError.value = message;
if (isConditionalUpdateTerminalFailure(error)) {
execution.outcome = "failed";
try {
await recordConditionalUpdateHistory(statement, Date.now() - startedAt, { success: false, error: message });
} catch (historyError) {
console.warn("[DBX] failed to record conditional data grid update history", historyError);
}
reloadCurrentData();
} else {
// A dispatch may reach the database even when the client times out or
// loses its transport. Keep the cancel path alive until the outcome is known.
execution.outcome = normalizeBackendError(error)?.operationOutcome === "not_started" ? "not-started" : "unknown";
}
return null;
} finally {
if (conditionalUpdateExecution.value === execution && execution.outcome !== "unknown") {
conditionalUpdateExecution.value = undefined;
isSaving.value = false;
}
}
}
async function cancelConditionalUpdate(): Promise<boolean> {
const execution = conditionalUpdateExecution.value;
if (!execution || execution.cancelling) return false;
execution.cancelRequested = true;
if (!execution.dispatched) {
execution.outcome = "not-started";
conditionalUpdateExecution.value = undefined;
isSaving.value = false;
return true;
}
execution.cancelling = true;
triggerRef(conditionalUpdateExecution);
try {
const cancellation = await api.cancelConditionalUpdate(execution.executionId);
if (conditionalUpdateExecution.value !== execution) return false;
if (!cancellation.terminal) {
scheduleConditionalUpdateTerminalCheck(execution);
return false;
}
return completeConditionalUpdate(execution);
} catch (error) {
saveError.value = normalizeDataGridSaveError(databaseType.value, error);
return false;
} finally {
execution.cancelling = false;
if (conditionalUpdateExecution.value === execution) triggerRef(conditionalUpdateExecution);
}
}
async function saveChanges(saveOptions: SaveChangesOptions = {}) {
if (isSaving.value) {
if (saveOptions.autoSave) pendingAutoSaveRequested = true;
@@ -1627,6 +1809,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function discardChanges() {
if (isConditionalUpdateActive.value) return;
dirtyRows.value = new Map();
newRows.value = [];
newRowMeta.value = [];
@@ -1776,6 +1959,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
transactionActive,
isSaving,
saveError,
isConditionalUpdateActive,
conditionalUpdateExecution,
useTransaction,
beginBatch,
commitBatch,
@@ -1805,6 +1990,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
restoreRows,
deleteSelectedRow,
saveChanges,
executeConditionalUpdate,
cancelConditionalUpdate,
discardChanges,
canUndoPendingChange,
canRedoPendingChange,
+28
View File
@@ -1846,6 +1846,34 @@ export default {
bulkEditDescription: "Set {count} selected cell(s) to this value.",
bulkEditValuePlaceholder: "Value, or NULL",
applyBulkEdit: "Apply",
bulkEditSelectionScope: "Selected cells only",
bulkEditSelectionScopeDescription: "Stage changes for {count} selected cell(s).",
bulkEditConditionalScope: "All rows matching a condition",
bulkEditConditionalScopeDescription: "Update {column} for every database row matching a condition you provide.",
bulkEditConditionalConditionSource: "Matching condition",
bulkEditConditionalUseCurrentFilter: "Use current table filter",
bulkEditConditionalUseCurrentFilterDescription: "Keep the database condition currently applied to this table.",
bulkEditConditionalBuildFilter: "Set matching conditions",
bulkEditConditionalBuildFilterDescription: "Choose columns, operators, and values without writing SQL.",
bulkEditConditionalAdvancedWhere: "Advanced: WHERE SQL",
bulkEditConditionalAdvancedWhereDescription: "Write a custom SQL condition.",
conditionalBulkEditPreviewCount: "Preview affected rows",
conditionalBulkEditPreviewingCount: "Previewing…",
conditionalBulkEditPreviewCountResult: "Estimated {count} affected row(s)",
conditionalBulkEditPreviewCountStale: "Conditions changed. Preview again.",
conditionalBulkEditPreviewCountFailed: "Could not preview affected rows: {message}",
bulkEditConditionalWhere: "WHERE condition (required)",
bulkEditConditionalWherePlaceholder: "For example: tenant_id = 1 AND status = 'pending'",
bulkEditConditionalWhereDescription: "Enter a WHERE clause or only its condition. The active table filter is prefilled when available.",
conditionalBulkEditUnavailable: "Conditional bulk edit is no longer available for this selection or filter.",
conditionalBulkEditConditionRequired: "Set at least one matching condition before updating all matching rows.",
conditionalBulkEditPrepareFailed: "Failed to prepare the conditional update: {message}",
conditionalBulkEditConfirmTitle: "Confirm conditional bulk update",
conditionalBulkEditConfirmMessage: "This executes immediately against every row matching the condition at execution time. Unloaded rows are included, and DBX cannot generate a reliable rollback for them.",
conditionalBulkEditConfirm: "Execute update",
conditionalBulkEditAppliedCount: "Updated {count} matching row(s)",
conditionalBulkEditApplied: "Updated matching rows",
conditionalBulkEditPendingChanges: "Save or discard the current pending changes before updating all rows matching a condition.",
generateValue: "Generate Value",
generateEmptyString: "Empty String",
generateNull: "NULL",
+28
View File
@@ -1713,6 +1713,34 @@ export default withEnglishFallback({
bulkEditDescription: "Establecer {count} celda(s) seleccionada(s) en este valor.",
bulkEditValuePlaceholder: "Valor, o NULL",
applyBulkEdit: "Aplicar",
bulkEditSelectionScope: "Solo celdas seleccionadas",
bulkEditSelectionScopeDescription: "Preparar cambios para {count} celda(s) seleccionada(s).",
bulkEditConditionalScope: "Todas las filas que coinciden con una condición",
bulkEditConditionalScopeDescription: "Actualizar {column} en cada fila de la base de datos que coincida con una condición indicada.",
bulkEditConditionalConditionSource: "Condición de coincidencia",
bulkEditConditionalUseCurrentFilter: "Usar el filtro actual de la tabla",
bulkEditConditionalUseCurrentFilterDescription: "Conserva la condición de base de datos aplicada a esta tabla.",
bulkEditConditionalBuildFilter: "Definir condiciones de coincidencia",
bulkEditConditionalBuildFilterDescription: "Elija columnas, operadores y valores sin escribir SQL.",
bulkEditConditionalAdvancedWhere: "Avanzado: SQL WHERE",
bulkEditConditionalAdvancedWhereDescription: "Escriba una condición SQL personalizada.",
conditionalBulkEditPreviewCount: "Vista previa de filas afectadas",
conditionalBulkEditPreviewingCount: "Generando vista previa…",
conditionalBulkEditPreviewCountResult: "Se estima que afectará a {count} fila(s)",
conditionalBulkEditPreviewCountStale: "Las condiciones cambiaron. Genere la vista previa de nuevo.",
conditionalBulkEditPreviewCountFailed: "No se pudo obtener la vista previa de filas afectadas: {message}",
bulkEditConditionalWhere: "Condición WHERE (obligatoria)",
bulkEditConditionalWherePlaceholder: "Por ejemplo: tenant_id = 1 AND status = 'pending'",
bulkEditConditionalWhereDescription: "Introduzca una cláusula WHERE o solo su condición. El filtro activo se completa cuando está disponible.",
conditionalBulkEditUnavailable: "La edición masiva condicional ya no está disponible para esta selección o filtro.",
conditionalBulkEditConditionRequired: "Defina al menos una condición de coincidencia antes de actualizar todas las filas coincidentes.",
conditionalBulkEditPrepareFailed: "No se pudo preparar la actualización condicional: {message}",
conditionalBulkEditConfirmTitle: "Confirmar actualización masiva condicional",
conditionalBulkEditConfirmMessage: "Esta operación actualiza inmediatamente todas las filas que coincidan al ejecutarse, incluidas las no cargadas. DBX no puede generar una reversión fiable para ellas.",
conditionalBulkEditConfirm: "Ejecutar actualización",
conditionalBulkEditAppliedCount: "Se actualizaron {count} fila(s) coincidente(s)",
conditionalBulkEditApplied: "Se actualizaron las filas coincidentes",
conditionalBulkEditPendingChanges: "Guarda o descarta los cambios pendientes antes de actualizar todas las filas que coincidan con una condición.",
generateValue: "Generar valor",
generateEmptyString: "Cadena vacía",
generateNull: "NULL",
+28
View File
@@ -1711,6 +1711,34 @@ export default withEnglishFallback({
bulkEditDescription: "Imposta le {count} celle selezionate su questo valore.",
bulkEditValuePlaceholder: "Valore, o NULL",
applyBulkEdit: "Applica",
bulkEditSelectionScope: "Solo celle selezionate",
bulkEditSelectionScopeDescription: "Prepara le modifiche per {count} celle selezionate.",
bulkEditConditionalScope: "Tutte le righe che corrispondono a una condizione",
bulkEditConditionalScopeDescription: "Aggiorna {column} in ogni riga del database corrispondente a una condizione specificata.",
bulkEditConditionalConditionSource: "Condizione di corrispondenza",
bulkEditConditionalUseCurrentFilter: "Usa il filtro tabella corrente",
bulkEditConditionalUseCurrentFilterDescription: "Mantiene la condizione del database attualmente applicata a questa tabella.",
bulkEditConditionalBuildFilter: "Imposta condizioni di corrispondenza",
bulkEditConditionalBuildFilterDescription: "Scegli colonne, operatori e valori senza scrivere SQL.",
bulkEditConditionalAdvancedWhere: "Avanzato: SQL WHERE",
bulkEditConditionalAdvancedWhereDescription: "Scrivi una condizione SQL personalizzata.",
conditionalBulkEditPreviewCount: "Anteprima righe interessate",
conditionalBulkEditPreviewingCount: "Anteprima in corso…",
conditionalBulkEditPreviewCountResult: "Stimate {count} righe interessate",
conditionalBulkEditPreviewCountStale: "Le condizioni sono cambiate. Aggiorna l'anteprima.",
conditionalBulkEditPreviewCountFailed: "Impossibile visualizzare l'anteprima delle righe interessate: {message}",
bulkEditConditionalWhere: "Condizione WHERE (obbligatoria)",
bulkEditConditionalWherePlaceholder: "Ad esempio: tenant_id = 1 AND status = 'pending'",
bulkEditConditionalWhereDescription: "Inserisci una clausola WHERE o solo la condizione. Il filtro attivo viene precompilato quando disponibile.",
conditionalBulkEditUnavailable: "La modifica condizionale di massa non è più disponibile per questa selezione o filtro.",
conditionalBulkEditConditionRequired: "Imposta almeno una condizione di corrispondenza prima di aggiornare tutte le righe corrispondenti.",
conditionalBulkEditPrepareFailed: "Impossibile preparare l'aggiornamento condizionale: {message}",
conditionalBulkEditConfirmTitle: "Conferma aggiornamento condizionale di massa",
conditionalBulkEditConfirmMessage: "L'operazione aggiorna immediatamente tutte le righe corrispondenti al momento dell'esecuzione, incluse quelle non caricate. DBX non può generare un rollback affidabile.",
conditionalBulkEditConfirm: "Esegui aggiornamento",
conditionalBulkEditAppliedCount: "Aggiornate {count} righe corrispondenti",
conditionalBulkEditApplied: "Aggiornate le righe corrispondenti",
conditionalBulkEditPendingChanges: "Salva o annulla le modifiche in sospeso prima di aggiornare tutte le righe corrispondenti a una condizione.",
generateValue: "Genera valore",
generateEmptyString: "Stringa vuota",
generateNull: "NULL",
+28
View File
@@ -1729,6 +1729,34 @@ export default withEnglishFallback({
bulkEditDescription: "選択した{count}セルにこの値を設定します。",
bulkEditValuePlaceholder: "値、またはNULL",
applyBulkEdit: "適用",
bulkEditSelectionScope: "選択したセルのみ",
bulkEditSelectionScopeDescription: "選択した {count} 個のセルを保留中の変更に追加します。",
bulkEditConditionalScope: "条件に一致するすべての行",
bulkEditConditionalScopeDescription: "入力した条件に一致するデータベース内のすべての行の {column} を更新します。",
bulkEditConditionalConditionSource: "一致条件",
bulkEditConditionalUseCurrentFilter: "現在のテーブルフィルターを使用",
bulkEditConditionalUseCurrentFilterDescription: "このテーブルに現在適用されているデータベース条件を使います。",
bulkEditConditionalBuildFilter: "一致条件を設定",
bulkEditConditionalBuildFilterDescription: "SQL を書かずに列、演算子、値を選択します。",
bulkEditConditionalAdvancedWhere: "詳細: WHERE SQL",
bulkEditConditionalAdvancedWhereDescription: "カスタム SQL 条件を記述します。",
conditionalBulkEditPreviewCount: "影響行数をプレビュー",
conditionalBulkEditPreviewingCount: "プレビュー中…",
conditionalBulkEditPreviewCountResult: "推定 {count} 行に影響します",
conditionalBulkEditPreviewCountStale: "条件が変更されました。もう一度プレビューしてください。",
conditionalBulkEditPreviewCountFailed: "影響行数をプレビューできませんでした: {message}",
bulkEditConditionalWhere: "WHERE 条件(必須)",
bulkEditConditionalWherePlaceholder: "例: tenant_id = 1 AND status = 'pending'",
bulkEditConditionalWhereDescription: "WHERE 句または条件のみを入力してください。利用可能な場合は、現在の表フィルターがあらかじめ入力されます。",
conditionalBulkEditUnavailable: "この選択範囲またはフィルターでは条件付き一括編集を利用できなくなりました。",
conditionalBulkEditConditionRequired: "一致するすべての行を更新する前に、少なくとも 1 つの一致条件を設定してください。",
conditionalBulkEditPrepareFailed: "条件付き更新を準備できませんでした: {message}",
conditionalBulkEditConfirmTitle: "条件付き一括更新の確認",
conditionalBulkEditConfirmMessage: "実行時に条件へ一致するすべての行(未読込の行を含む)を直ちに更新します。DBX は信頼できるロールバック SQL を生成できません。",
conditionalBulkEditConfirm: "更新を実行",
conditionalBulkEditAppliedCount: "一致する {count} 行を更新しました",
conditionalBulkEditApplied: "一致する行を更新しました",
conditionalBulkEditPendingChanges: "条件に一致するすべての行を更新する前に、保留中の変更を保存または破棄してください。",
generateValue: "値を生成",
generateEmptyString: "空文字列",
generateNull: "NULL",
+28
View File
@@ -1713,6 +1713,34 @@ export default withEnglishFallback({
bulkEditDescription: "선택한 {count}개 셀을 이 값으로 설정합니다.",
bulkEditValuePlaceholder: "값 또는 NULL",
applyBulkEdit: "적용",
bulkEditSelectionScope: "선택한 셀만",
bulkEditSelectionScopeDescription: "선택한 셀 {count}개를 보류 중인 변경 사항에 추가합니다.",
bulkEditConditionalScope: "조건과 일치하는 모든 행",
bulkEditConditionalScopeDescription: "입력한 조건과 일치하는 데이터베이스의 모든 행에서 {column}을 업데이트합니다.",
bulkEditConditionalConditionSource: "일치 조건",
bulkEditConditionalUseCurrentFilter: "현재 테이블 필터 사용",
bulkEditConditionalUseCurrentFilterDescription: "이 테이블에 현재 적용된 데이터베이스 조건을 사용합니다.",
bulkEditConditionalBuildFilter: "일치 조건 설정",
bulkEditConditionalBuildFilterDescription: "SQL을 작성하지 않고 열, 연산자, 값을 선택합니다.",
bulkEditConditionalAdvancedWhere: "고급: WHERE SQL",
bulkEditConditionalAdvancedWhereDescription: "사용자 지정 SQL 조건을 작성합니다.",
conditionalBulkEditPreviewCount: "영향 받는 행 미리 보기",
conditionalBulkEditPreviewingCount: "미리 보는 중…",
conditionalBulkEditPreviewCountResult: "예상 영향 행 수: {count}",
conditionalBulkEditPreviewCountStale: "조건이 변경되었습니다. 다시 미리 보세요.",
conditionalBulkEditPreviewCountFailed: "영향 받는 행을 미리 볼 수 없습니다: {message}",
bulkEditConditionalWhere: "WHERE 조건(필수)",
bulkEditConditionalWherePlaceholder: "예: tenant_id = 1 AND status = 'pending'",
bulkEditConditionalWhereDescription: "WHERE 절 또는 조건만 입력하세요. 현재 테이블 필터가 있으면 미리 채워집니다.",
conditionalBulkEditUnavailable: "현재 선택 또는 필터에서는 조건부 일괄 편집을 더 이상 사용할 수 없습니다.",
conditionalBulkEditConditionRequired: "일치하는 모든 행을 업데이트하기 전에 일치 조건을 하나 이상 설정하세요.",
conditionalBulkEditPrepareFailed: "조건부 업데이트를 준비하지 못했습니다: {message}",
conditionalBulkEditConfirmTitle: "조건부 일괄 업데이트 확인",
conditionalBulkEditConfirmMessage: "실행 시 조건과 일치하는 모든 행을 즉시 업데이트하며 로드되지 않은 행도 포함됩니다. DBX는 신뢰할 수 있는 롤백 SQL을 생성할 수 없습니다.",
conditionalBulkEditConfirm: "업데이트 실행",
conditionalBulkEditAppliedCount: "일치하는 행 {count}개를 업데이트했습니다",
conditionalBulkEditApplied: "일치하는 행을 업데이트했습니다",
conditionalBulkEditPendingChanges: "조건과 일치하는 모든 행을 업데이트하기 전에 보류 중인 변경 사항을 저장하거나 취소하세요.",
generateValue: "값 생성",
generateEmptyString: "빈 문자열",
generateNull: "NULL",
+28
View File
@@ -1713,6 +1713,34 @@ export default withEnglishFallback({
bulkEditDescription: "Definir {count} célula(s) selecionada(s) para este valor.",
bulkEditValuePlaceholder: "Valor, ou NULL",
applyBulkEdit: "Aplicar",
bulkEditSelectionScope: "Somente células selecionadas",
bulkEditSelectionScopeDescription: "Preparar alterações para {count} célula(s) selecionada(s).",
bulkEditConditionalScope: "Todas as linhas correspondentes a uma condição",
bulkEditConditionalScopeDescription: "Atualizar {column} em cada linha do banco correspondente a uma condição informada.",
bulkEditConditionalConditionSource: "Condição de correspondência",
bulkEditConditionalUseCurrentFilter: "Usar filtro atual da tabela",
bulkEditConditionalUseCurrentFilterDescription: "Mantém a condição do banco aplicada atualmente a esta tabela.",
bulkEditConditionalBuildFilter: "Definir condições de correspondência",
bulkEditConditionalBuildFilterDescription: "Escolha colunas, operadores e valores sem escrever SQL.",
bulkEditConditionalAdvancedWhere: "Avançado: SQL WHERE",
bulkEditConditionalAdvancedWhereDescription: "Escreva uma condição SQL personalizada.",
conditionalBulkEditPreviewCount: "Visualizar linhas afetadas",
conditionalBulkEditPreviewingCount: "Gerando visualização…",
conditionalBulkEditPreviewCountResult: "Estimativa de {count} linha(s) afetada(s)",
conditionalBulkEditPreviewCountStale: "As condições foram alteradas. Gere a visualização novamente.",
conditionalBulkEditPreviewCountFailed: "Não foi possível visualizar as linhas afetadas: {message}",
bulkEditConditionalWhere: "Condição WHERE (obrigatória)",
bulkEditConditionalWherePlaceholder: "Por exemplo: tenant_id = 1 AND status = 'pending'",
bulkEditConditionalWhereDescription: "Informe uma cláusula WHERE ou apenas a condição. O filtro ativo é preenchido quando disponível.",
conditionalBulkEditUnavailable: "A edição condicional em massa não está mais disponível para esta seleção ou filtro.",
conditionalBulkEditConditionRequired: "Defina pelo menos uma condição de correspondência antes de atualizar todas as linhas correspondentes.",
conditionalBulkEditPrepareFailed: "Falha ao preparar a atualização condicional: {message}",
conditionalBulkEditConfirmTitle: "Confirmar atualização condicional em massa",
conditionalBulkEditConfirmMessage: "Esta operação atualiza imediatamente todas as linhas correspondentes no momento da execução, incluindo linhas não carregadas. O DBX não pode gerar uma reversão confiável.",
conditionalBulkEditConfirm: "Executar atualização",
conditionalBulkEditAppliedCount: "{count} linha(s) correspondente(s) atualizada(s)",
conditionalBulkEditApplied: "Linhas correspondentes atualizadas",
conditionalBulkEditPendingChanges: "Salve ou descarte as alterações pendentes antes de atualizar todas as linhas correspondentes a uma condição.",
generateValue: "Gerar valor",
generateEmptyString: "String vazia",
generateNull: "NULL",
+28
View File
@@ -1771,6 +1771,34 @@ export default withEnglishFallback({
bulkEditDescription: "将已选 {count} 个单元格设置为这个值。",
bulkEditValuePlaceholder: "输入值,或 NULL",
applyBulkEdit: "应用",
bulkEditSelectionScope: "仅修改所选单元格",
bulkEditSelectionScopeDescription: "将 {count} 个所选单元格加入待保存修改。",
bulkEditConditionalScope: "修改条件匹配的全部行",
bulkEditConditionalScopeDescription: "更新数据库中符合所填条件的全部行里的 {column}。",
bulkEditConditionalConditionSource: "匹配条件",
bulkEditConditionalUseCurrentFilter: "使用当前表格筛选",
bulkEditConditionalUseCurrentFilterDescription: "沿用当前已应用到数据库的筛选条件。",
bulkEditConditionalBuildFilter: "设置匹配条件",
bulkEditConditionalBuildFilterDescription: "通过选择字段、运算符和值来设置,无需编写 SQL。",
bulkEditConditionalAdvancedWhere: "高级:WHERE SQL",
bulkEditConditionalAdvancedWhereDescription: "手动编写自定义 SQL 条件。",
conditionalBulkEditPreviewCount: "预览影响行数",
conditionalBulkEditPreviewingCount: "正在预览…",
conditionalBulkEditPreviewCountResult: "预计影响 {count} 行",
conditionalBulkEditPreviewCountStale: "条件已变更,请重新预览。",
conditionalBulkEditPreviewCountFailed: "无法预览影响行数:{message}",
bulkEditConditionalWhere: "WHERE 条件(必填)",
bulkEditConditionalWherePlaceholder: "例如:tenant_id = 1 AND status = 'pending'",
bulkEditConditionalWhereDescription: "可输入完整 WHERE 子句或仅输入条件;若表格已有筛选条件,会自动预填。",
conditionalBulkEditUnavailable: "当前选区或筛选条件已不再支持按条件批量修改。",
conditionalBulkEditConditionRequired: "请先设置至少一个匹配条件,再更新全部匹配行。",
conditionalBulkEditPrepareFailed: "生成条件更新 SQL 失败:{message}",
conditionalBulkEditConfirmTitle: "确认按条件批量更新",
conditionalBulkEditConfirmMessage: "该操作会立即更新执行时条件匹配的全部行,包括尚未加载的行;DBX 无法为这些行生成可靠的回滚 SQL。",
conditionalBulkEditConfirm: "执行更新",
conditionalBulkEditAppliedCount: "已更新 {count} 行匹配数据",
conditionalBulkEditApplied: "已更新条件匹配的数据",
conditionalBulkEditPendingChanges: "请先保存或放弃当前待保存修改,再按条件更新全部匹配行。",
generateValue: "生成值",
generateEmptyString: "空字符串",
generateNull: "NULL",
+28
View File
@@ -1713,6 +1713,34 @@ export default withEnglishFallback({
bulkEditDescription: "將已選 {count} 個儲存格設定為這個值。",
bulkEditValuePlaceholder: "輸入值,或 NULL",
applyBulkEdit: "套用",
bulkEditSelectionScope: "僅修改所選儲存格",
bulkEditSelectionScopeDescription: "將 {count} 個所選儲存格加入待儲存修改。",
bulkEditConditionalScope: "修改條件符合的全部資料列",
bulkEditConditionalScopeDescription: "更新資料庫中符合所填條件的全部資料列中的 {column}。",
bulkEditConditionalConditionSource: "符合條件",
bulkEditConditionalUseCurrentFilter: "使用目前表格篩選",
bulkEditConditionalUseCurrentFilterDescription: "沿用目前已套用到資料庫的篩選條件。",
bulkEditConditionalBuildFilter: "設定符合條件",
bulkEditConditionalBuildFilterDescription: "選擇欄位、運算子和值來設定,無須編寫 SQL。",
bulkEditConditionalAdvancedWhere: "進階:WHERE SQL",
bulkEditConditionalAdvancedWhereDescription: "手動編寫自訂 SQL 條件。",
conditionalBulkEditPreviewCount: "預覽影響資料列數",
conditionalBulkEditPreviewingCount: "正在預覽…",
conditionalBulkEditPreviewCountResult: "預計影響 {count} 筆資料列",
conditionalBulkEditPreviewCountStale: "條件已變更,請重新預覽。",
conditionalBulkEditPreviewCountFailed: "無法預覽影響資料列數:{message}",
bulkEditConditionalWhere: "WHERE 條件(必填)",
bulkEditConditionalWherePlaceholder: "例如:tenant_id = 1 AND status = 'pending'",
bulkEditConditionalWhereDescription: "可輸入完整 WHERE 子句或僅輸入條件;如有目前的表格篩選條件,會自動預填。",
conditionalBulkEditUnavailable: "目前選取範圍或篩選條件已不再支援條件式批次修改。",
conditionalBulkEditConditionRequired: "請先設定至少一個符合條件,再更新所有符合的資料列。",
conditionalBulkEditPrepareFailed: "產生條件更新 SQL 失敗:{message}",
conditionalBulkEditConfirmTitle: "確認條件式批次更新",
conditionalBulkEditConfirmMessage: "此操作會立即更新執行時符合條件的全部資料列,包括尚未載入的資料列;DBX 無法為這些資料列產生可靠的回復 SQL。",
conditionalBulkEditConfirm: "執行更新",
conditionalBulkEditAppliedCount: "已更新 {count} 筆符合的資料列",
conditionalBulkEditApplied: "已更新符合條件的資料列",
conditionalBulkEditPendingChanges: "請先儲存或放棄目前待儲存修改,再更新符合條件的全部資料列。",
generateValue: "產生值",
generateEmptyString: "空字串",
generateNull: "NULL",
@@ -66,6 +66,44 @@ describe("coerceDataGridCellValue", () => {
expect(coerceDataGridCellValue({ ...options, preserveEmptyString: true })).toBe("");
});
it.each([null, false, true])("coerces MySQL TINYINT(1) from metadata when the sampled value is %p", (oldValue) => {
expect(
coerceDataGridCellValue({
value: "true",
oldValue,
databaseType: "mysql",
columnInfo: { data_type: "TINYINT(1)" },
}),
).toBe(true);
});
it.each([
["missing numeric metadata", 1, undefined, "2", 2],
["empty numeric metadata", 1, { data_type: "" }, "2", 2],
["missing boolean metadata", false, undefined, "true", true],
["empty boolean metadata", true, { data_type: "" }, "0", false],
])("falls back to the sampled value type for %s", (_name, oldValue, columnInfo, value, expected) => {
expect(
coerceDataGridCellValue({
value,
oldValue,
databaseType: "mysql",
columnInfo,
}),
).toBe(expected);
});
it("prefers column metadata over the sampled value type", () => {
expect(
coerceDataGridCellValue({
value: "2",
oldValue: 1,
databaseType: "mysql",
columnInfo: { data_type: "varchar(255)" },
}),
).toBe("2");
});
it.each([
["numeric non-nullable", 42, "integer", false],
["boolean nullable", true, "boolean", true],
+3
View File
@@ -197,6 +197,7 @@ export const exportDocsHtml = forward("exportDocsHtml");
// Query
export const executeQuery = forward("executeQuery");
export const executeConditionalUpdate = forward("executeConditionalUpdate");
export const executeMulti = forward("executeMulti");
export const executeMultiWithProgress = forward("executeMultiWithProgress");
export const executeBatch = forward("executeBatch");
@@ -208,6 +209,7 @@ export const executeInManualTransaction = forward("executeInManualTransaction");
export const commitManualTransaction = forward("commitManualTransaction");
export const rollbackManualTransaction = forward("rollbackManualTransaction");
export const cancelQuery = forward("cancelQuery");
export const cancelConditionalUpdate = forward("cancelConditionalUpdate");
export const closeQuerySession = forward("closeQuerySession");
export const closeClientConnectionSession = forward("closeClientConnectionSession");
export const analyzeSqlReferences = forward("analyzeSqlReferences");
@@ -258,6 +260,7 @@ export const buildDataGridColumnValueFilterCondition = forward("buildDataGridCol
export const buildDataGridColumnValuesFilterCondition = forward("buildDataGridColumnValuesFilterCondition");
export const buildDataGridColumnDistinctValuesSql = forward("buildDataGridColumnDistinctValuesSql");
export const buildDataGridCountSql = forward("buildDataGridCountSql");
export const buildDataGridConditionalUpdateSql = forward("buildDataGridConditionalUpdateSql");
export const buildHiveTablePropertiesSql = forward("buildHiveTablePropertiesSql");
export const buildExportInsertStatements = forward("buildExportInsertStatements");
export const buildExportSqlInsert = forward("buildExportSqlInsert");
+43
View File
@@ -149,6 +149,7 @@ import type {
DataGridColumnValueFilterConditionOptions,
DataGridColumnValuesFilterConditionOptions,
DataGridContextFilterConditionOptions,
DataGridConditionalUpdateSqlOptions,
DataGridCountSqlOptions,
DataGridCopyInsertStatementOptions,
DataGridCopyUpdateStatementOptions,
@@ -1028,6 +1029,34 @@ export async function executeQuery(
});
}
export async function executeConditionalUpdate(
connectionId: string,
database: string,
sql: string,
schema?: string,
executionId?: string,
options?: {
maxRows?: number;
catalog?: string;
fetchSize?: number;
pageSize?: number;
rowOffset?: number;
resultSessionId?: string;
clientSessionId?: string;
timeoutSecs?: number;
executionMode?: "simple" | "postgres_read_only_transaction";
},
): Promise<QueryResult> {
return post("/api/query/execute-conditional-update", {
connectionId,
database,
sql,
schema,
executionId,
...options,
});
}
export async function executeMulti(
connectionId: string,
database: string,
@@ -1200,6 +1229,15 @@ export async function cancelQuery(executionId: string): Promise<boolean> {
return typeof result === "boolean" ? result : result.cancelled === true;
}
export interface ConditionalUpdateCancellationResult {
requested: boolean;
terminal: boolean;
}
export async function cancelConditionalUpdate(executionId: string): Promise<ConditionalUpdateCancellationResult> {
return post("/api/query/cancel-conditional-update", { executionId });
}
export async function analyzeSqlReferences(sql: string, dialect?: string): Promise<SqlReferenceAnalysis> {
return post("/api/query/analyze-sql-references", { sql, dialect });
}
@@ -1431,6 +1469,11 @@ export async function buildDataGridCountSql(options: DataGridCountSqlOptions): P
return post("/api/query/build-data-grid-count-sql", { options });
}
export async function buildDataGridConditionalUpdateSql(options: DataGridConditionalUpdateSqlOptions): Promise<string | undefined> {
const result = await post<string | null>("/api/query/build-data-grid-conditional-update-sql", { options });
return result ?? undefined;
}
export async function buildHiveTablePropertiesSql(options: HiveTablePropertiesSqlOptions): Promise<string> {
return post("/api/query/build-hive-table-properties-sql", { options });
}
+47
View File
@@ -73,6 +73,7 @@ import type {
DataGridColumnValueFilterConditionOptions,
DataGridColumnValuesFilterConditionOptions,
DataGridContextFilterConditionOptions,
DataGridConditionalUpdateSqlOptions,
DataGridCountSqlOptions,
DataGridCopyInsertStatementOptions,
DataGridCopyUpdateStatementOptions,
@@ -1173,6 +1174,38 @@ export async function executeQuery(
}
}
export async function executeConditionalUpdate(
connectionId: string,
database: string,
sql: string,
schema?: string,
executionId?: string,
options?: {
maxRows?: number;
catalog?: string;
fetchSize?: number;
pageSize?: number;
rowOffset?: number;
resultSessionId?: string;
clientSessionId?: string;
timeoutSecs?: number;
executionMode?: "simple" | "postgres_read_only_transaction";
},
): Promise<QueryResult> {
try {
return await invoke("execute_conditional_update", {
connectionId,
database,
sql,
schema,
executionId,
...options,
});
} catch (error) {
throw new BackendErrorException(error);
}
}
export async function executeMulti(
connectionId: string,
database: string,
@@ -1285,6 +1318,15 @@ export async function cancelQuery(executionId: string): Promise<boolean> {
return invoke("cancel_query", { executionId });
}
export interface ConditionalUpdateCancellationResult {
requested: boolean;
terminal: boolean;
}
export async function cancelConditionalUpdate(executionId: string): Promise<ConditionalUpdateCancellationResult> {
return invoke("cancel_conditional_update", { executionId });
}
export async function closeQuerySession(connectionId: string, database: string, sessionId: string, clientSessionId?: string, catalog?: string): Promise<boolean> {
return invoke("close_query_session", {
connectionId,
@@ -1586,6 +1628,11 @@ export async function buildDataGridCountSql(options: DataGridCountSqlOptions): P
return invoke("build_data_grid_count_sql", { options });
}
export async function buildDataGridConditionalUpdateSql(options: DataGridConditionalUpdateSqlOptions): Promise<string | undefined> {
const result = await invoke<string | null>("build_data_grid_conditional_update_sql", { options });
return result ?? undefined;
}
export async function buildHiveTablePropertiesSql(options: HiveTablePropertiesSqlOptions): Promise<string> {
return invoke("build_hive_table_properties_sql", { options });
}
@@ -1,6 +1,7 @@
import type { GridCellValue } from "@/lib/dataGrid/dataGridSql";
import type { DatabaseType, ColumnInfo } from "@/types/database";
import { isNumericColumnType } from "@/lib/dataGrid/dataGridColumnType";
import { isBooleanColumnType } from "@/lib/dataGrid/dataGridBooleanColumn";
export interface CoerceDataGridCellValueOptions {
value: string;
@@ -22,25 +23,40 @@ export function coerceDataGridCellValue(options: CoerceDataGridCellValueOptions)
// Number() return NaN and the literal fail to convert on the server. Strip
// only unambiguous groupings and keep the normalized text for the precision
// checks below, so exact values survive as text.
const numericText = normalizeGroupedNumberText(value, options.columnInfo);
if (typeof oldValue === "number") {
const useSampledValueType = normalizeDataType(options.columnInfo?.data_type) === "";
const numericInput = isNumericColumnType(options.columnInfo?.data_type) || (useSampledValueType && typeof oldValue === "number");
const numericText = normalizeGroupedNumberText(value, options.columnInfo, oldValue);
if (isBooleanInputColumn(options) || (useSampledValueType && typeof oldValue === "boolean")) {
const booleanValue = parseBooleanInput(numericText);
if (booleanValue !== undefined) return booleanValue;
}
if (numericInput) {
const num = Number(numericText);
if (!Number.isNaN(num)) {
if (shouldPreserveNumericText(options, num, numericText)) {
// Keep precision-sensitive numeric edits as text; JS Number rounds 64-bit integers.
const text = numericText.trim();
if (text === String(oldValue)) return oldValue;
if (oldValue !== undefined && text === String(oldValue)) return oldValue;
return text;
}
return num;
}
}
if (typeof oldValue === "boolean") {
return numericText === "true" || numericText === "1";
}
return normalizeSmartQuotedJsonInput(numericText);
}
function isBooleanInputColumn(options: CoerceDataGridCellValueOptions): boolean {
if (isBooleanColumnType(options.columnInfo?.data_type, options.databaseType)) return true;
return options.databaseType === "mysql" && options.columnInfo?.data_type.trim().toLowerCase() === "tinyint(1)";
}
function parseBooleanInput(value: string): boolean | undefined {
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1") return true;
if (normalized === "false" || normalized === "0") return false;
return undefined;
}
export function dataGridCellEditorText(options: { value: GridCellValue | undefined; databaseType: DatabaseType | undefined; columnInfo: Pick<ColumnInfo, "data_type"> | undefined }): string {
const value = options.value ?? null;
if (value === null) return "";
@@ -106,8 +122,9 @@ function shouldPreserveNumericText(options: CoerceDataGridCellValueOptions, pars
return shouldPreserveNumericTextForType(options.columnInfo?.data_type, text, parsedNumber);
}
function normalizeGroupedNumberText(value: string, columnInfo: Pick<ColumnInfo, "data_type"> | undefined): string {
if (!isNumericColumnType(columnInfo?.data_type)) return value;
function normalizeGroupedNumberText(value: string, columnInfo: Pick<ColumnInfo, "data_type"> | undefined, oldValue: GridCellValue | undefined): string {
const useSampledNumberType = normalizeDataType(columnInfo?.data_type) === "" && typeof oldValue === "number";
if (!isNumericColumnType(columnInfo?.data_type) && !useSampledNumberType) return value;
return stripUnambiguousThousandSeparators(value);
}
@@ -115,6 +115,15 @@ export interface DataGridCountSqlOptions {
countHint?: string;
}
export interface DataGridConditionalUpdateSqlOptions {
databaseType?: DatabaseType;
identifierQuote?: string;
tableMeta: DataGridTableMeta;
columnName: string;
value: GridCellValue;
whereInput: string;
}
export interface HiveTablePropertiesSqlOptions {
schema?: string;
tableName: string;
@@ -149,6 +158,10 @@ export function buildDataGridCountSql(options: DataGridCountSqlOptions): Promise
return api.buildDataGridCountSql(options);
}
export function buildDataGridConditionalUpdateSql(options: DataGridConditionalUpdateSqlOptions): Promise<string | undefined> {
return api.buildDataGridConditionalUpdateSql(options);
}
export function buildHiveTablePropertiesSql(options: HiveTablePropertiesSqlOptions): Promise<string> {
return api.buildHiveTablePropertiesSql(options);
}