fix(oracle): 修复表数据加载与编辑

This commit is contained in:
t8y2
2026-05-11 16:35:59 +08:00
parent 79277c660f
commit cab0e79dad
17 changed files with 610 additions and 90 deletions
+109 -50
View File
@@ -60,6 +60,7 @@ import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import type { QueryResult, ColumnInfo, DatabaseType } from "@/types/database";
import * as api from "@/lib/api";
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
import { isHiddenGridColumn, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { formatGridSqlLiteral } from "@/lib/dataGridSql";
import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus";
@@ -726,11 +727,25 @@ const isApplyingWhere = ref(false);
const rowStatusFilter = ref<RowStatusFilter>("all");
const gridRef = ref<HTMLDivElement>();
const headerRef = ref<HTMLDivElement>();
const visibleColumnIndexes = computed(() =>
props.result.columns
.map((column, index) => ({ column, index }))
.filter(({ column }) => !isHiddenGridColumn(props.databaseType, column, props.tableMeta?.primaryKeys ?? []))
.map(({ index }) => index),
);
const visibleColumns = computed(() => visibleColumnIndexes.value.map((index) => props.result.columns[index]));
const visibleRows = computed(() =>
props.result.rows.map((row) => visibleColumnIndexes.value.map((index) => row[index])),
);
const firstVisibleColumnIndex = computed(() => visibleColumnIndexes.value[0] ?? 0);
function actualColumnIndex(visibleColumnIndex: number): number {
return visibleColumnIndexes.value[visibleColumnIndex] ?? visibleColumnIndex;
}
// --- Column resize composable ---
const { initColumnWidths, onResizeStart, autoFitColumn, columnVars, getIsResizing } = useDataGridColumnResize({
columns: computed(() => props.result.columns),
rows: computed(() => props.result.rows),
columns: visibleColumns,
rows: visibleRows,
gridRef,
});
function syncHeaderScroll(e: Event) {
@@ -740,7 +755,7 @@ function syncHeaderScroll(e: Event) {
}
initColumnWidths();
watch(() => props.result.columns.length, initColumnWidths);
watch(() => visibleColumns.value.length, initColumnWidths);
watch(
() => props.result,
() => {
@@ -755,6 +770,9 @@ const currentPage = ref(1);
const isFullPage = computed(() => props.result.rows.length >= pageSize.value);
const isResultsContext = computed(() => props.context === "results");
const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql && !isResultsContext.value);
const tableUsesSyntheticRowId = computed(() =>
usesSyntheticRowIdKey(props.databaseType, props.tableMeta?.primaryKeys ?? []),
);
const clientSearchText = computed(() => (searchText.value.trim() ? searchText.value : ""));
watch(clientSearchText, (value) => {
clearTimeout(_searchTimer);
@@ -826,6 +844,7 @@ const editor = useDataGridEditor({
whereFilterInput,
orderByInput,
rowStatusFilter,
initialEditColumn: firstVisibleColumnIndex,
getRowItem,
emit,
});
@@ -936,6 +955,27 @@ function getRowItem(rowId: number): RowItem | undefined {
return displayItems.value.find((item) => item.id === rowId);
}
function visibleRowData(row: CellValue[]): CellValue[] {
return visibleColumnIndexes.value.map((index) => row[index]);
}
function visibleDirtyColumns(row: boolean[]): boolean[] {
return visibleColumnIndexes.value.map((index) => row[index] ?? false);
}
const visibleDisplayItems = computed<RowItem[]>(() =>
displayItems.value.map((item) => ({
...item,
data: visibleRowData(item.data),
isDirtyCol: visibleDirtyColumns(item.isDirtyCol),
})),
);
const exportContextCell = computed(() => {
if (!contextCell.value) return null;
const visibleCol = visibleColumnIndexes.value.indexOf(contextCell.value.col);
return { ...contextCell.value, col: visibleCol };
});
const deleteRowDetails = computed(() =>
props.tableMeta?.tableName
? t("dangerDialog.deleteRowDetails", { table: props.tableMeta.tableName })
@@ -961,8 +1001,8 @@ const isErrorResult = computed(
const errorMessage = computed(() => (isErrorResult.value ? String(props.result.rows[0]?.[0] ?? "") : ""));
// --- Selection composable ---
const selection = useDataGridSelection({
columns: computed(() => props.result.columns),
displayItems,
columns: visibleColumns,
displayItems: visibleDisplayItems,
editingCell,
showTranspose,
transposeRowIndex,
@@ -1197,6 +1237,7 @@ async function applyOrderBySearch() {
orderBy: orderByClause,
limit: pageSize.value,
whereInput: whereFilterInput.value.trim() || undefined,
includeRowId: tableUsesSyntheticRowId.value,
});
await props.onExecuteSql(sql);
} catch (e: any) {
@@ -1222,6 +1263,7 @@ async function applyWhereFilter() {
(sortCol.value ? `${quoteIdent(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined),
limit: pageSize.value,
whereInput: whereFilterInput.value.trim() || undefined,
includeRowId: tableUsesSyntheticRowId.value,
});
await props.onExecuteSql(sql);
} catch (e: any) {
@@ -1286,8 +1328,8 @@ const {
exportXlsx,
copySql,
} = useDataGridExport({
columns: computed(() => props.result.columns),
displayItems,
columns: visibleColumns,
displayItems: visibleDisplayItems,
sql: computed(() => props.sql),
tableMeta: computed(() =>
props.tableMeta ? { schema: props.tableMeta.schema, tableName: props.tableMeta.tableName } : undefined,
@@ -1295,8 +1337,8 @@ const {
databaseType: computed(() => props.databaseType),
hasCellSelection,
selectedCells,
contextCell,
getRowItem,
contextCell: exportContextCell,
getRowItem: (rowId: number) => visibleDisplayItems.value.find((item) => item.id === rowId),
formatCell,
quoteIdent,
escapeVal,
@@ -1334,9 +1376,9 @@ async function pasteClipboardIntoSelection() {
const item = displayItems.value[start.rowIndex + rowOffset];
if (!item) return;
row.forEach((value, colOffset) => {
const col = start.colIndex + colOffset;
if (col >= props.result.columns.length) return;
applyCellValue(item.id, col, value);
const visibleCol = start.colIndex + colOffset;
if (visibleCol >= visibleColumns.value.length) return;
applyCellValue(item.id, actualColumnIndex(visibleCol), value);
});
});
toast(t("grid.pasted"));
@@ -1349,8 +1391,8 @@ function cutSelection() {
for (let rowIndex = range.startRow; rowIndex <= range.endRow; rowIndex++) {
const item = displayItems.value[rowIndex];
if (!item) continue;
for (let col = range.startCol; col <= range.endCol; col++) {
applyCellValue(item.id, col, null);
for (let visibleCol = range.startCol; visibleCol <= range.endCol; visibleCol++) {
applyCellValue(item.id, actualColumnIndex(visibleCol), null);
}
}
}
@@ -1398,13 +1440,16 @@ const transposeData = computed(() => {
if (transposeRowIndex.value === null) return null;
const item = displayItems.value[transposeRowIndex.value];
if (!item) return null;
return props.result.columns.map((col, i) => ({
column: col,
type: columnTypeMap.value.get(col) || "",
value: item.data[i],
display: formatCell(item.data[i]),
isNull: item.data[i] === null,
}));
return visibleColumnIndexes.value.map((columnIndex) => {
const col = props.result.columns[columnIndex];
return {
column: col,
type: columnTypeMap.value.get(col) || "",
value: item.data[columnIndex],
display: formatCell(item.data[columnIndex]),
isNull: item.data[columnIndex] === null,
};
});
});
function openTranspose(rowIndex: number) {
@@ -1438,10 +1483,10 @@ watch(
);
// --- Context menu handlers ---
function onCellContext(rowId: number, rowIndex: number, colIdx: number) {
function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleColIdx: number) {
contextCell.value = { rowId, rowIndex, col: colIdx };
if (!cellIsSelected(rowIndex, colIdx)) {
selectSingleCell(rowIndex, colIdx);
if (!cellIsSelected(rowIndex, visibleColIdx)) {
selectSingleCell(rowIndex, visibleColIdx);
}
}
@@ -1855,44 +1900,58 @@ defineExpose({
>
#
</div>
<Tooltip v-for="(col, colIdx) in result.columns" :key="`${col}-${colIdx}`">
<Tooltip v-for="(col, colIdx) in visibleColumns" :key="`${col}-${actualColumnIndex(colIdx)}`">
<TooltipTrigger as-child>
<div
class="shrink-0 px-2 py-1.5 border-r border-border whitespace-nowrap hover:bg-accent/60 select-none relative overflow-hidden"
:style="{ width: `var(--col-w-${colIdx})` }"
>
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
<span class="min-w-0 flex-1 truncate cursor-pointer" @click="toggleSort(col, colIdx)">{{
col
}}</span>
<span
class="min-w-0 flex-1 truncate cursor-pointer"
@click="toggleSort(col, actualColumnIndex(colIdx))"
>
{{ col }}
</span>
<button
type="button"
class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
:class="
sortCol === col && sortColIndex === colIdx ? 'text-primary opacity-100' : 'opacity-80'
sortCol === col && sortColIndex === actualColumnIndex(colIdx)
? 'text-primary opacity-100'
: 'opacity-80'
"
:title="t('grid.sort')"
@click.stop="toggleSort(col, colIdx)"
@click.stop="toggleSort(col, actualColumnIndex(colIdx))"
>
<ArrowUp
v-if="sortCol === col && sortColIndex === colIdx && sortDir === 'asc'"
v-if="sortCol === col && sortColIndex === actualColumnIndex(colIdx) && sortDir === 'asc'"
class="h-3 w-3 shrink-0"
/>
<ArrowDown
v-else-if="sortCol === col && sortColIndex === colIdx && sortDir === 'desc'"
v-else-if="
sortCol === col && sortColIndex === actualColumnIndex(colIdx) && sortDir === 'desc'
"
class="h-3 w-3 shrink-0"
/>
<ArrowUpDown v-else class="h-3 w-3 shrink-0" />
</button>
<Popover
:open="localFilterOpenColumn === colIdx"
@update:open="(value: boolean) => (value ? openLocalFilter(colIdx) : closeLocalFilter())"
:open="localFilterOpenColumn === actualColumnIndex(colIdx)"
@update:open="
(value: boolean) =>
value ? openLocalFilter(actualColumnIndex(colIdx)) : closeLocalFilter()
"
>
<PopoverTrigger as-child>
<button
type="button"
class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
:class="localFilterActive(colIdx) ? 'text-primary opacity-100' : 'opacity-80'"
:class="
localFilterActive(actualColumnIndex(colIdx))
? 'text-primary opacity-100'
: 'opacity-80'
"
:title="t('grid.localFilter')"
@click.stop
>
@@ -1993,7 +2052,7 @@ defineExpose({
variant="ghost"
size="sm"
class="h-7 px-2 text-xs text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-700 dark:hover:bg-slate-100 dark:hover:text-slate-900"
@click="clearLocalFilter(colIdx)"
@click="clearLocalFilter(actualColumnIndex(colIdx))"
>
{{ t("grid.clearFilter") }}
</Button>
@@ -2099,24 +2158,24 @@ defineExpose({
{{ index + 1 }}
</div>
<div
v-for="(cell, colIdx) in item.data"
:key="colIdx"
v-for="(actualColIdx, visibleColIdx) in visibleColumnIndexes"
:key="actualColIdx"
class="group/cell shrink-0 px-3 py-1 border-r border-border whitespace-nowrap overflow-hidden text-ellipsis relative select-none"
:style="{ width: `var(--col-w-${colIdx})` }"
:style="{ width: `var(--col-w-${visibleColIdx})` }"
:class="{
'text-muted-foreground italic': isNull(cell),
'bg-yellow-500/10': item.isDirtyCol[colIdx],
'cell-selected': cellIsSelected(index, colIdx),
'tabular-nums': typeof cell === 'number',
'text-muted-foreground italic': isNull(item.data[actualColIdx]),
'bg-yellow-500/10': item.isDirtyCol[actualColIdx],
'cell-selected': cellIsSelected(index, visibleColIdx),
'tabular-nums': typeof item.data[actualColIdx] === 'number',
'cursor-text hover:bg-accent/50': editable && !item.isDeleted,
'line-through': item.isDeleted,
}"
@mousedown="beginCellSelection(index, colIdx, $event)"
@mouseenter="extendCellSelection(index, colIdx)"
@dblclick="editable && !item.isDeleted && startEdit(item.id, colIdx)"
@contextmenu="onCellContext(item.id, index, colIdx)"
@mousedown="beginCellSelection(index, visibleColIdx, $event)"
@mouseenter="extendCellSelection(index, visibleColIdx)"
@dblclick="editable && !item.isDeleted && startEdit(item.id, actualColIdx)"
@contextmenu="onCellContext(item.id, index, actualColIdx, visibleColIdx)"
>
<template v-if="editingCell?.rowId === item.id && editingCell?.col === colIdx">
<template v-if="editingCell?.rowId === item.id && editingCell?.col === actualColIdx">
<input
v-model="editValue"
autocapitalize="off"
@@ -2129,12 +2188,12 @@ defineExpose({
/>
</template>
<template v-else>
{{ formatCell(cell) }}
{{ formatCell(item.data[actualColIdx]) }}
<button
class="absolute right-0.5 top-0.5 hidden h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground group-hover/cell:flex"
:title="t('grid.cellDetails')"
@mousedown.stop
@click.stop="showCellDetails(index, colIdx)"
@click.stop="showCellDetails(index, actualColIdx)"
>
<Info class="h-3 w-3" />
</button>
+52 -16
View File
@@ -66,7 +66,12 @@ import {
buildExportPageSql,
type ExportedTableSql,
} from "@/lib/databaseExport";
import { qualifiedTableName as buildQualifiedTableName, quoteTableIdentifier } from "@/lib/tableSelectSql";
import {
buildTableSelectSql,
qualifiedTableName as buildQualifiedTableName,
quoteTableIdentifier,
} from "@/lib/tableSelectSql";
import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import {
SQL_FILE_UNSUPPORTED_TYPES,
DIAGRAM_SUPPORTED_TYPES,
@@ -346,30 +351,58 @@ async function openData() {
const node = props.node;
if (!(node.type === "table" || node.type === "view") || !node.connectionId || !node.database) return;
const config = connectionStore.getConfig(node.connectionId);
const traceId = uuid().slice(0, 8);
const startedAt = performance.now();
const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`;
console.info("[DBX][openData:start]", {
traceId,
type: node.type,
connectionId: node.connectionId,
database: node.database,
schema: node.schema,
table: node.label,
dbType: config?.db_type,
});
const tabId = queryStore.createTab(node.connectionId, node.database, node.label, "data");
console.info("[DBX][openData:tab-created]", { traceId, tabId, elapsed: elapsed() });
queryStore.setExecuting(tabId, true);
try {
console.info("[DBX][openData:ensure-connected:start]", { traceId, elapsed: elapsed() });
await connectionStore.ensureConnected(node.connectionId);
console.info("[DBX][openData:ensure-connected:done]", { traceId, elapsed: elapsed() });
if (!config) throw new Error("Connection config not found");
const qualifiedName =
isSchemaAware(config.db_type) && node.schema
? `${quoteIdent(node.schema)}.${quoteIdent(node.label)}`
: quoteIdent(node.label);
const querySchema = node.schema || node.database;
console.info("[DBX][openData:get-columns:start]", {
traceId,
database: node.database,
schema: querySchema,
table: node.label,
elapsed: elapsed(),
});
const columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
const pks = columns.filter((c) => c.is_primary_key).map((c) => c.name);
const order = pks.length ? ` ORDER BY ${pks.map((pk) => `${quoteIdent(pk)} ASC`).join(", ")}` : "";
let sql: string;
if (usesFetchFirst(config.db_type)) {
sql = `SELECT * FROM ${qualifiedName}${order} FETCH FIRST 100 ROWS ONLY`;
} else if (config.db_type === "sqlserver") {
sql = `SELECT TOP 100 * FROM ${qualifiedName}${order}`;
} else {
sql = `SELECT * FROM ${qualifiedName}${order} LIMIT 100;`;
}
console.info("[DBX][openData:get-columns:done]", {
traceId,
columnCount: columns.length,
primaryKeys: columns.filter((column) => column.is_primary_key).map((column) => column.name),
elapsed: elapsed(),
});
const pks = editablePrimaryKeys(config.db_type, columns);
const sql = buildTableSelectSql({
databaseType: config.db_type,
schema: node.schema,
tableName: node.label,
primaryKeys: pks,
includeRowId: usesSyntheticRowIdKey(config.db_type, pks),
});
console.info("[DBX][openData:sql-built]", {
traceId,
primaryKeys: pks,
includeRowId: usesSyntheticRowIdKey(config.db_type, pks),
sql,
elapsed: elapsed(),
});
queryStore.updateSql(tabId, sql);
queryStore.setTableMeta(tabId, {
schema: node.schema,
@@ -378,8 +411,11 @@ async function openData() {
primaryKeys: pks,
});
console.info("[DBX][openData:execute:start]", { traceId, tabId, elapsed: elapsed() });
await queryStore.executeTabSql(tabId, sql);
console.info("[DBX][openData:execute:done]", { traceId, tabId, elapsed: elapsed() });
} catch (e: any) {
console.error("[DBX][openData:error]", { traceId, elapsed: elapsed(), error: e });
queryStore.setErrorResult(tabId, e);
}
}
+9 -2
View File
@@ -3,6 +3,7 @@ import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { buildSortedQuerySql } from "@/lib/queryResultSort";
import type { QueryTab } from "@/types/database";
import { useToast } from "@/composables/useToast";
@@ -23,16 +24,22 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
options: { orderBy?: string; limit?: number; offset?: number; whereInput?: string } = {},
): string {
const config = connectionStore.getConfig(tab.connectionId);
const primaryKeys = tab.tableMeta ? editablePrimaryKeys(config?.db_type, tab.tableMeta.columns) : [];
if (tab.tableMeta && primaryKeys.join("\0") !== tab.tableMeta.primaryKeys.join("\0")) {
tab.tableMeta.primaryKeys = primaryKeys;
}
const fallbackOrderColumns =
config?.db_type === "sqlserver" && !tab.tableMeta?.primaryKeys?.length
config?.db_type === "sqlserver" && !primaryKeys.length
? tab.tableMeta?.columns.slice(0, 1).map((column) => column.name)
: undefined;
const useRowId = usesSyntheticRowIdKey(config?.db_type, primaryKeys);
return buildTableSelectSql({
databaseType: config?.db_type,
schema: tab.tableMeta?.schema,
tableName: tab.tableMeta?.tableName ?? "",
primaryKeys: tab.tableMeta?.primaryKeys,
primaryKeys,
fallbackOrderColumns,
includeRowId: useRowId,
...options,
});
}
+38 -3
View File
@@ -1,6 +1,11 @@
import { ref, computed, nextTick, type ComputedRef, type Ref } from "vue";
import * as api from "@/lib/api";
import { buildDataGridRollbackStatements, buildDataGridSaveStatements } from "@/lib/dataGridSql";
import {
buildDataGridRollbackStatements,
buildDataGridSaveStatements,
dataGridSaveExecutionSchema,
validateDataGridSave,
} from "@/lib/dataGridSql";
import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/gridRowStatus";
import { useConnectionStore } from "@/stores/connectionStore";
import { useHistoryStore } from "@/stores/historyStore";
@@ -59,6 +64,7 @@ export interface UseDataGridEditorOptions {
whereFilterInput: Ref<string>;
orderByInput: Ref<string>;
rowStatusFilter: Ref<RowStatusFilter>;
initialEditColumn?: ComputedRef<number>;
getRowItem: (rowId: number) => RowItem | undefined;
emit: {
(event: "reload", sql?: string, searchText?: string, whereInput?: string, orderBy?: string): void;
@@ -83,6 +89,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
whereFilterInput,
orderByInput,
rowStatusFilter,
initialEditColumn,
getRowItem,
emit,
} = options;
@@ -342,7 +349,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
nextTick(() => {
const el = getScrollerElement();
if (el) el.scrollTop = el.scrollHeight;
startEdit(rowId, 0);
startEdit(rowId, initialEditColumn?.value ?? 0);
});
}
@@ -484,6 +491,21 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
const stmtOptions = saveStatementOptions();
const validationError = stmtOptions
? validateDataGridSave({
databaseType: databaseType.value,
columns: stmtOptions.columns,
columnInfo: tableMeta.value?.columns,
dirtyRows: stmtOptions.dirtyRows,
newRows: stmtOptions.newRows,
})
: undefined;
if (validationError) {
saveError.value = validationError;
isSaving.value = false;
return;
}
const stmts = stmtOptions ? buildDataGridSaveStatements(stmtOptions) : [];
if (stmts.length === 0) {
isSaving.value = false;
@@ -492,10 +514,23 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const rollbackStmts = stmtOptions ? buildDataGridRollbackStatements(stmtOptions) : [];
const start = Date.now();
let apiResult: { affected_rows?: number } | undefined;
console.info("[DBX][dataGrid:save-statements]", {
databaseType: databaseType.value,
table: tableMeta.value
? [tableMeta.value.schema, tableMeta.value.tableName].filter(Boolean).join(".")
: undefined,
statements: stmts,
rollbackStatements: rollbackStmts,
});
if (useTransaction.value && connectionId.value && database.value) {
try {
apiResult = await api.executeInTransaction(connectionId.value, database.value, stmts, tableMeta.value?.schema);
apiResult = await api.executeInTransaction(
connectionId.value,
database.value,
stmts,
dataGridSaveExecutionSchema(databaseType.value, tableMeta.value),
);
} catch (e: any) {
saveError.value = String(e.message || e);
isSaving.value = false;
+3 -1
View File
@@ -32,7 +32,9 @@ export interface UseDataGridExportOptions {
databaseType: ComputedRef<string | undefined>;
hasCellSelection: ComputedRef<boolean>;
selectedCells: ComputedRef<SelectionData>;
contextCell: Ref<{ rowId: number; rowIndex: number; col: number } | null>;
contextCell:
| Ref<{ rowId: number; rowIndex: number; col: number } | null>
| ComputedRef<{ rowId: number; rowIndex: number; col: number } | null>;
getRowItem: (rowId: number) => RowItem | undefined;
formatCell: (value: CellValue) => string;
quoteIdent: (name: string) => string;
+17 -2
View File
@@ -1,5 +1,6 @@
import * as api from "@/lib/api";
import { buildTableSelectSql } from "@/lib/tableSelectSql";
import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
@@ -44,12 +45,26 @@ async function openTableTarget(target: NavigationTarget) {
const [columnsResult, dataResult] = await Promise.allSettled([columnsPromise, dataPromise]);
if (columnsResult.status === "fulfilled") {
const columns = columnsResult.value;
const primaryKeys = editablePrimaryKeys(config.db_type, columns);
const useRowId = usesSyntheticRowIdKey(config.db_type, primaryKeys);
queryStore.setTableMeta(tabId, {
schema: target.schema,
tableName: target.tableName,
columns,
primaryKeys: columns.filter((c) => c.is_primary_key).map((c) => c.name),
primaryKeys,
});
if (useRowId) {
const newSql = buildTableSelectSql({
databaseType: config.db_type,
schema: target.schema,
tableName: target.tableName,
whereInput: target.whereInput,
primaryKeys,
includeRowId: true,
});
queryStore.updateSql(tabId, newSql);
await queryStore.executeTabSql(tabId, newSql);
}
}
if (dataResult.status === "rejected") throw dataResult.reason;
if (columnsResult.status === "rejected")
@@ -107,7 +122,7 @@ export function useNavigationTargets(dialogs: {
queryStore.setTableMeta(activeTab.id, {
...activeTab.tableMeta,
columns,
primaryKeys: columns.filter((c) => c.is_primary_key).map((c) => c.name),
primaryKeys: editablePrimaryKeys(connectionStore.getConfig(activeTab.connectionId)?.db_type, columns),
});
await reloadData();
} catch (e: any) {
+102 -9
View File
@@ -1,4 +1,5 @@
import type { DatabaseType } from "@/types/database";
import { DBX_ROWID_COLUMN } from "./tableEditing.ts";
import { qualifiedTableName, quoteTableIdentifier } from "./tableSelectSql.ts";
export type GridCellValue = string | number | boolean | null;
@@ -9,6 +10,11 @@ export interface DataGridTableMeta {
primaryKeys: string[];
}
export interface DataGridColumnInfo {
name: string;
is_nullable: boolean;
}
export interface DataGridSaveStatementOptions {
databaseType?: DatabaseType;
tableMeta: DataGridTableMeta;
@@ -19,6 +25,42 @@ export interface DataGridSaveStatementOptions {
newRows: GridCellValue[][];
}
export interface DataGridSaveValidationOptions {
databaseType?: DatabaseType;
columns: string[];
columnInfo?: DataGridColumnInfo[];
dirtyRows: Array<[number, Array<[number, GridCellValue]>]>;
newRows: GridCellValue[][];
}
export function validateDataGridSave(options: DataGridSaveValidationOptions): string | undefined {
const notNullColumns = new Set(
(options.columnInfo ?? [])
.filter((column) => !column.is_nullable && !isOracleRowId(options.databaseType, column.name))
.map((column) => normalizeColumnName(column.name)),
);
if (notNullColumns.size === 0) return undefined;
for (const [, changes] of options.dirtyRows) {
for (const [columnIndex, value] of changes) {
const column = options.columns[columnIndex];
if (isNullWriteToNotNullColumn(options.databaseType, notNullColumns, column, value)) {
return nullWriteError(column);
}
}
}
for (const row of options.newRows) {
for (const [columnIndex, column] of options.columns.entries()) {
if (isNullWriteToNotNullColumn(options.databaseType, notNullColumns, column, row[columnIndex])) {
return nullWriteError(column);
}
}
}
return undefined;
}
export function buildDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] {
const table = qualifiedTableName({
databaseType: options.databaseType,
@@ -31,11 +73,13 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption
const row = options.rows[rowIndex];
if (!row) continue;
const sets = changes
.filter(([columnIndex]) => !isOracleRowId(options.databaseType, options.columns[columnIndex]))
.map(
([columnIndex, value]) =>
`${quoteIdent(options.databaseType, options.columns[columnIndex])} = ${formatGridSqlLiteral(value, options.databaseType)}`,
)
.join(", ");
if (!sets) continue;
const where = buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, options.columns, row);
statements.push(`UPDATE ${table} SET ${sets} WHERE ${where};`);
}
@@ -48,8 +92,11 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption
}
for (const row of options.newRows) {
const columns = options.columns.map((column) => quoteIdent(options.databaseType, column)).join(", ");
const values = row.map((v) => formatGridSqlLiteral(v, options.databaseType)).join(", ");
const insertPairs = options.columns
.map((column, index) => ({ column, value: row[index] }))
.filter((pair) => !isOracleRowId(options.databaseType, pair.column));
const columns = insertPairs.map((pair) => quoteIdent(options.databaseType, pair.column)).join(", ");
const values = insertPairs.map((pair) => formatGridSqlLiteral(pair.value, options.databaseType)).join(", ");
statements.push(`INSERT INTO ${table} (${columns}) VALUES (${values});`);
}
@@ -72,8 +119,11 @@ export function buildDataGridRollbackStatements(options: DataGridSaveStatementOp
for (const rowIndex of options.deletedRows) {
const row = options.rows[rowIndex];
if (!row) continue;
const columns = options.columns.map((column) => quoteIdent(options.databaseType, column)).join(", ");
const values = row.map((v) => formatGridSqlLiteral(v, options.databaseType)).join(", ");
const insertPairs = options.columns
.map((column, index) => ({ column, value: row[index] }))
.filter((pair) => !isOracleRowId(options.databaseType, pair.column));
const columns = insertPairs.map((pair) => quoteIdent(options.databaseType, pair.column)).join(", ");
const values = insertPairs.map((pair) => formatGridSqlLiteral(pair.value, options.databaseType)).join(", ");
statements.push(`INSERT INTO ${table} (${columns}) VALUES (${values});`);
}
@@ -84,15 +134,19 @@ export function buildDataGridRollbackStatements(options: DataGridSaveStatementOp
for (const [columnIndex, value] of changes) {
afterRow[columnIndex] = value;
}
const sets = changes
const writableChanges = changes.filter(
([columnIndex]) => !isOracleRowId(options.databaseType, options.columns[columnIndex]),
);
const sets = writableChanges
.map(
([columnIndex]) =>
`${quoteIdent(options.databaseType, options.columns[columnIndex])} = ${formatGridSqlLiteral(row[columnIndex], options.databaseType)}`,
)
.join(", ");
if (!sets) continue;
const where = [
buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, options.columns, afterRow),
...changes.map(([columnIndex, value]) =>
...writableChanges.map(([columnIndex, value]) =>
buildColumnPredicate(options.databaseType, options.columns[columnIndex], value),
),
]
@@ -104,6 +158,14 @@ export function buildDataGridRollbackStatements(options: DataGridSaveStatementOp
return statements;
}
export function dataGridSaveExecutionSchema(
databaseType: DatabaseType | undefined,
tableMeta: DataGridTableMeta | undefined,
): string | undefined {
if (databaseType === "oracle") return undefined;
return tableMeta?.schema;
}
export function formatGridSqlLiteral(value: GridCellValue, databaseType?: DatabaseType): string {
if (value === null || value === undefined) return "NULL";
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
@@ -123,21 +185,52 @@ function buildPrimaryKeyWhere(
return primaryKeys
.map((primaryKey) => {
const value = row[columns.indexOf(primaryKey)];
return `${quoteIdent(databaseType, primaryKey)} = ${formatGridSqlLiteral(value, databaseType)}`;
return `${predicateIdent(databaseType, primaryKey)} = ${formatGridSqlLiteral(value, databaseType)}`;
})
.join(" AND ");
}
function buildRowWhere(databaseType: DatabaseType | undefined, columns: string[], row: GridCellValue[]): string {
return columns.map((column, index) => buildColumnPredicate(databaseType, column, row[index])).join(" AND ");
return columns
.map((column, index) =>
isOracleRowId(databaseType, column) ? "" : buildColumnPredicate(databaseType, column, row[index]),
)
.filter(Boolean)
.join(" AND ");
}
function buildColumnPredicate(databaseType: DatabaseType | undefined, column: string, value: GridCellValue): string {
const ident = quoteIdent(databaseType, column);
const ident = predicateIdent(databaseType, column);
if (value === null || value === undefined) return `${ident} IS NULL`;
return `${ident} = ${formatGridSqlLiteral(value, databaseType)}`;
}
function isOracleRowId(databaseType: DatabaseType | undefined, name: string | undefined): boolean {
return databaseType === "oracle" && name?.toUpperCase() === DBX_ROWID_COLUMN;
}
function isNullWriteToNotNullColumn(
databaseType: DatabaseType | undefined,
notNullColumns: Set<string>,
column: string | undefined,
value: GridCellValue,
): boolean {
if (!column || isOracleRowId(databaseType, column)) return false;
return value === null && notNullColumns.has(normalizeColumnName(column));
}
function normalizeColumnName(name: string): string {
return name.toUpperCase();
}
function nullWriteError(column: string): string {
return `Column "${column}" does not allow NULL.`;
}
function predicateIdent(databaseType: DatabaseType | undefined, name: string): string {
return isOracleRowId(databaseType, name) ? "ROWIDTOCHAR(ROWID)" : quoteIdent(databaseType, name);
}
function quoteIdent(databaseType: DatabaseType | undefined, name: string): string {
return quoteTableIdentifier(databaseType, name);
}
+21
View File
@@ -0,0 +1,21 @@
import type { ColumnInfo, DatabaseType } from "@/types/database";
export const DBX_ROWID_COLUMN = "__DBX_ROWID";
export function editablePrimaryKeys(databaseType: DatabaseType | undefined, columns: ColumnInfo[]): string[] {
const primaryKeys = columns.filter((column) => column.is_primary_key).map((column) => column.name);
if (databaseType === "oracle" && primaryKeys.length === 0) return [DBX_ROWID_COLUMN];
return primaryKeys;
}
export function usesSyntheticRowIdKey(databaseType: DatabaseType | undefined, primaryKeys: string[]): boolean {
return databaseType === "oracle" && primaryKeys.length === 1 && primaryKeys[0].toUpperCase() === DBX_ROWID_COLUMN;
}
export function isHiddenGridColumn(
databaseType: DatabaseType | undefined,
column: string,
primaryKeys: string[],
): boolean {
return usesSyntheticRowIdKey(databaseType, primaryKeys) && column.toUpperCase() === DBX_ROWID_COLUMN;
}
+18 -2
View File
@@ -1,5 +1,6 @@
import type { DatabaseType } from "../types/database.ts";
import { isSchemaAware, usesFetchFirst } from "./databaseCapabilities.ts";
import { DBX_ROWID_COLUMN } from "./tableEditing.ts";
export interface BuildTableSelectSqlOptions {
databaseType?: DatabaseType;
@@ -11,6 +12,7 @@ export interface BuildTableSelectSqlOptions {
limit?: number;
offset?: number;
whereInput?: string;
includeRowId?: boolean;
}
export function quoteTableIdentifier(databaseType: DatabaseType | undefined, name: string): string {
@@ -19,6 +21,15 @@ export function quoteTableIdentifier(databaseType: DatabaseType | undefined, nam
return `"${name.replace(/"/g, '""')}"`;
}
function isOracleRowId(databaseType: DatabaseType | undefined, name: string): boolean {
return databaseType === "oracle" && name.toUpperCase() === DBX_ROWID_COLUMN;
}
function quoteOrderIdentifier(databaseType: DatabaseType | undefined, name: string, tableAlias?: string): string {
if (isOracleRowId(databaseType, name)) return tableAlias ? `${tableAlias}.ROWID` : "ROWID";
return quoteTableIdentifier(databaseType, name);
}
export function qualifiedTableName(
options: Pick<BuildTableSelectSqlOptions, "databaseType" | "schema" | "tableName">,
): string {
@@ -40,17 +51,22 @@ export function buildTableSelectSql(options: BuildTableSelectSqlOptions): string
const table = qualifiedTableName(options);
const predicate = normalizeWhereInput(options.whereInput);
const where = predicate ? ` WHERE (${predicate})` : "";
const rowIdAlias = options.includeRowId && databaseType === "oracle" ? "t" : undefined;
const defaultOrderBy = options.primaryKeys?.length
? options.primaryKeys.map((pk) => `${quoteTableIdentifier(databaseType, pk)} ASC`).join(", ")
? options.primaryKeys.map((pk) => `${quoteOrderIdentifier(databaseType, pk, rowIdAlias)} ASC`).join(", ")
: options.fallbackOrderColumns?.length
? options.fallbackOrderColumns.map((column) => `${quoteTableIdentifier(databaseType, column)} ASC`).join(", ")
: undefined;
const orderBy = options.orderBy ?? defaultOrderBy;
const order = orderBy ? ` ORDER BY ${orderBy}` : "";
const selectColumns =
options.includeRowId && databaseType === "oracle" ? `ROWIDTOCHAR(t.ROWID) AS "${DBX_ROWID_COLUMN}", t.*` : "*";
const tableAlias = options.includeRowId && usesFetchFirst(databaseType) ? `${table} t` : table;
if (usesFetchFirst(databaseType)) {
const offset = options.offset ? ` OFFSET ${options.offset} ROWS` : "";
return `SELECT * FROM ${table}${where}${order}${offset} FETCH FIRST ${limit} ROWS ONLY`;
return `SELECT ${selectColumns} FROM ${tableAlias}${where}${order}${offset} FETCH FIRST ${limit} ROWS ONLY`;
}
if (databaseType === "sqlserver") {
+36
View File
@@ -368,12 +368,32 @@ export const useQueryStore = defineStore("query", () => {
if (!tab || !sql.trim()) return;
const executionId = uuid();
const traceId = executionId.slice(0, 8);
const startedAt = performance.now();
const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`;
tab.isExecuting = true;
tab.isCancelling = false;
tab.executionId = executionId;
tab.lastExecutedSql = sql;
console.info("[DBX][executeTabSql:start]", {
traceId,
tabId: id,
mode: tab.mode,
connectionId: tab.connectionId,
database: tab.database,
schema: tab.schema,
sql,
});
try {
console.info("[DBX][executeTabSql:execute-multi:start]", { traceId, elapsed: elapsed() });
const results = await api.executeMulti(tab.connectionId, tab.database, sql, tab.schema, executionId);
console.info("[DBX][executeTabSql:execute-multi:done]", {
traceId,
resultCount: results.length,
rowCounts: results.map((result) => result.rows.length),
columnCounts: results.map((result) => result.columns.length),
elapsed: elapsed(),
});
const current = tabs.value.find((t) => t.id === id);
if (current?.executionId === executionId) {
if (results.length > 1) {
@@ -387,9 +407,18 @@ export const useQueryStore = defineStore("query", () => {
}
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
console.info("[DBX][executeTabSql:metadata:start]", { traceId, elapsed: elapsed() });
await analyzeQueryMetadata(current, current.resultBaseSql);
console.info("[DBX][executeTabSql:metadata:done]", { traceId, elapsed: elapsed() });
} else {
console.warn("[DBX][executeTabSql:stale-result]", {
traceId,
currentExecutionId: current?.executionId,
elapsed: elapsed(),
});
}
} catch (e: any) {
console.error("[DBX][executeTabSql:error]", { traceId, elapsed: elapsed(), error: e });
const current = tabs.value.find((t) => t.id === id);
if (current?.executionId === executionId) {
current.result = toErrorResult(e);
@@ -406,6 +435,13 @@ export const useQueryStore = defineStore("query", () => {
current.isExecuting = false;
current.isCancelling = false;
current.executionId = undefined;
console.info("[DBX][executeTabSql:finish]", { traceId, elapsed: elapsed() });
} else {
console.warn("[DBX][executeTabSql:finish-stale]", {
traceId,
currentExecutionId: current?.executionId,
elapsed: elapsed(),
});
}
}
trimResultCache();