feat(grid): add conditional bulk updates for MySQL
This commit is contained in:
29 files changed
+1813
-85
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user