fix(oracle): 修复表数据加载与编辑
This commit is contained in:
parent
79277c660f
commit
cab0e79dad
|
|
@ -332,10 +332,12 @@ pub async fn list_triggers(conn: &OracleClient, schema: &str, table: &str) -> Re
|
|||
|
||||
pub async fn execute_query_with_schema(conn: &OracleClient, schema: &str, sql: &str) -> Result<QueryResult, String> {
|
||||
let set_schema = format!("ALTER SESSION SET CURRENT_SCHEMA = \"{}\"", schema);
|
||||
log::info!("[oracle][set-schema:start] schema={schema}");
|
||||
conn.execute(&set_schema, &[]).await.map_err(|e| {
|
||||
log::error!("[oracle] set current_schema failed: {e}");
|
||||
e.to_string()
|
||||
})?;
|
||||
log::info!("[oracle][set-schema:done] schema={schema}");
|
||||
execute_query(conn, sql).await
|
||||
}
|
||||
|
||||
|
|
@ -343,17 +345,31 @@ pub async fn execute_query(conn: &OracleClient, sql: &str) -> Result<QueryResult
|
|||
let start = Instant::now();
|
||||
let sql = sql.trim().trim_end_matches(';');
|
||||
let explicit_limit = explicit_select_row_limit(sql);
|
||||
log::info!("[oracle][execute:start] explicit_limit={:?} sql={}", explicit_limit, sql);
|
||||
|
||||
// Rewrite FETCH FIRST N ROWS ONLY → ROWNUM for Oracle 11g compatibility.
|
||||
let sql = rewrite_fetch_first(sql);
|
||||
log::info!("[oracle][execute:rewritten] sql={}", sql.as_ref());
|
||||
|
||||
if starts_with_executable_sql_keyword(sql.as_ref(), &["SELECT", "WITH", "SHOW", "DESCRIBE", "EXPLAIN"]) {
|
||||
let capped_sql = cap_select_rows(sql.as_ref());
|
||||
let query_limit = explicit_limit.unwrap_or(ORACLE_QUERY_LIMIT).min(ORACLE_QUERY_LIMIT);
|
||||
log::info!(
|
||||
"[oracle][query_with_limit:start] query_limit={} fetch_size=500 sql={}",
|
||||
query_limit,
|
||||
capped_sql.as_ref()
|
||||
);
|
||||
let result = conn.query_with_limit(capped_sql.as_ref(), &[], query_limit, 500).await.map_err(|e| {
|
||||
log::error!("[oracle] execute_query SELECT failed: {e}");
|
||||
e.to_string()
|
||||
})?;
|
||||
log::info!(
|
||||
"[oracle][query_with_limit:done] column_count={} row_count={} has_more_rows={} elapsed_ms={}",
|
||||
result.columns.len(),
|
||||
result.rows.len(),
|
||||
result.has_more_rows,
|
||||
start.elapsed().as_millis()
|
||||
);
|
||||
let columns: Vec<String> = result.columns.iter().map(|c| c.name.clone()).collect();
|
||||
let mut rows: Vec<Vec<serde_json::Value>> = result
|
||||
.rows
|
||||
|
|
@ -369,11 +385,24 @@ pub async fn execute_query(conn: &OracleClient, sql: &str) -> Result<QueryResult
|
|||
rows.truncate(crate::query::MAX_ROWS);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[oracle][execute:done] column_count={} row_count={} truncated={} elapsed_ms={}",
|
||||
columns.len(),
|
||||
rows.len(),
|
||||
truncated,
|
||||
start.elapsed().as_millis()
|
||||
);
|
||||
Ok(QueryResult { columns, rows, affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), truncated })
|
||||
} else {
|
||||
log::info!("[oracle][execute-non-select:start] sql={}", sql.as_ref());
|
||||
match conn.execute(sql.as_ref(), &[]).await {
|
||||
Ok(result) => {
|
||||
let _ = conn.commit().await;
|
||||
log::info!(
|
||||
"[oracle][execute-non-select:done] affected_rows={} elapsed_ms={}",
|
||||
result.rows_affected,
|
||||
start.elapsed().as_millis()
|
||||
);
|
||||
Ok(QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
|
|
@ -384,8 +413,9 @@ pub async fn execute_query(conn: &OracleClient, sql: &str) -> Result<QueryResult
|
|||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
log::error!("[oracle][execute-non-select:error] {msg}");
|
||||
if msg.contains("Server rejected") || msg.contains("closed the connection") {
|
||||
Err("Operation failed (connection closed) — possibly a constraint violation (foreign key, unique, or check constraint).".to_string())
|
||||
Err(format!("Operation failed (connection closed). Original driver error: {msg}"))
|
||||
} else {
|
||||
Err(msg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ pub async fn do_execute(
|
|||
let client = pool.client();
|
||||
let schema = schema.map(|s| s.to_string());
|
||||
drop(connections);
|
||||
log::info!("[query][oracle:lock:start] schema={:?} sql={}", schema, sql);
|
||||
let client = match cancel_token.as_ref() {
|
||||
Some(token) => tokio::select! {
|
||||
biased;
|
||||
|
|
@ -213,6 +214,7 @@ pub async fn do_execute(
|
|||
},
|
||||
None => client.lock().await,
|
||||
};
|
||||
log::info!("[query][oracle:lock:done] schema={:?}", schema);
|
||||
if let Some(schema) = schema {
|
||||
wait_for_query(cancel_token, db::oracle_driver::execute_query_with_schema(&*client, &schema, sql))
|
||||
.await
|
||||
|
|
@ -635,9 +637,11 @@ async fn exec_tx_none_inner(
|
|||
) -> Result<db::QueryResult, String> {
|
||||
let mut total_affected: u64 = 0;
|
||||
for (i, sql) in statements.iter().enumerate() {
|
||||
log::info!("[query][tx-none:statement:start] index={} sql={}", i + 1, sql);
|
||||
match do_execute(state, pool_key, sql, schema, None).await {
|
||||
Ok(result) => {
|
||||
total_affected += result.affected_rows;
|
||||
log::info!("[query][tx-none:statement:done] index={} affected_rows={}", i + 1, result.affected_rows);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Statement {} failed (no transaction support): {}", i + 1, e);
|
||||
|
|
|
|||
|
|
@ -37,8 +37,29 @@ pub async fn execute_multi(
|
|||
let registered_query =
|
||||
execution_id.as_ref().filter(|id| !id.trim().is_empty()).map(|id| state.running_queries.register(id.clone()));
|
||||
let cancel_token = registered_query.as_ref().map(|query| query.token());
|
||||
let trace_id = execution_id.as_deref().unwrap_or("no-execution-id");
|
||||
log::info!(
|
||||
"[query][execute_multi:start] trace_id={} connection_id={} database={} schema={:?} sql={}",
|
||||
trace_id,
|
||||
connection_id,
|
||||
database,
|
||||
schema,
|
||||
sql
|
||||
);
|
||||
|
||||
dbx_core::query::execute_multi_core(&state, &connection_id, &database, &sql, schema.as_deref(), cancel_token).await
|
||||
let result =
|
||||
dbx_core::query::execute_multi_core(&state, &connection_id, &database, &sql, schema.as_deref(), cancel_token)
|
||||
.await;
|
||||
match &result {
|
||||
Ok(results) => log::info!(
|
||||
"[query][execute_multi:done] trace_id={} result_count={} row_counts={:?}",
|
||||
trace_id,
|
||||
results.len(),
|
||||
results.iter().map(|result| result.rows.len()).collect::<Vec<_>>()
|
||||
),
|
||||
Err(error) => log::error!("[query][execute_multi:error] trace_id={} error={}", trace_id, error),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildDataGridSaveStatements } from "../src/lib/dataGridSql.ts";
|
||||
import {
|
||||
buildDataGridSaveStatements,
|
||||
dataGridSaveExecutionSchema,
|
||||
validateDataGridSave,
|
||||
} from "../src/lib/dataGridSql.ts";
|
||||
|
||||
test("builds SQL Server grid save statements with schema and bracket quoting", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
|
|
@ -12,7 +16,15 @@ test("builds SQL Server grid save statements with schema and bracket quoting", (
|
|||
},
|
||||
columns: ["role id", "state", "updated at"],
|
||||
rows: [[42, "old", "2026-05-03"]],
|
||||
dirtyRows: [[0, [[1, "ready"], [2, "2026-05-04"]]]],
|
||||
dirtyRows: [
|
||||
[
|
||||
0,
|
||||
[
|
||||
[1, "ready"],
|
||||
[2, "2026-05-04"],
|
||||
],
|
||||
],
|
||||
],
|
||||
deletedRows: [0],
|
||||
newRows: [[43, "new", "2026-05-05"]],
|
||||
});
|
||||
|
|
@ -23,3 +35,51 @@ test("builds SQL Server grid save statements with schema and bracket quoting", (
|
|||
"INSERT INTO [game].[player states] ([role id], [state], [updated at]) VALUES (43, N'new', N'2026-05-05');",
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses Oracle ROWID as a synthetic key without writing it as a normal column", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "oracle",
|
||||
tableMeta: {
|
||||
schema: "DBXTEST",
|
||||
tableName: "DBX_LOAD_TABLE_006",
|
||||
primaryKeys: ["__DBX_ROWID"],
|
||||
},
|
||||
columns: ["__DBX_ROWID", "ID", "CITY", "NOTE"],
|
||||
rows: [["AAATiBAABAAABrXAAA", 1, "上海", "old"]],
|
||||
dirtyRows: [[0, [[2, "北京"]]]],
|
||||
deletedRows: [0],
|
||||
newRows: [[null, 2, "广州", "new"]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
`UPDATE "DBXTEST"."DBX_LOAD_TABLE_006" SET "CITY" = '北京' WHERE ROWIDTOCHAR(ROWID) = 'AAATiBAABAAABrXAAA';`,
|
||||
`DELETE FROM "DBXTEST"."DBX_LOAD_TABLE_006" WHERE ROWIDTOCHAR(ROWID) = 'AAATiBAABAAABrXAAA';`,
|
||||
`INSERT INTO "DBXTEST"."DBX_LOAD_TABLE_006" ("ID", "CITY", "NOTE") VALUES (2, '广州', 'new');`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips current_schema setup for Oracle data grid saves", () => {
|
||||
assert.equal(
|
||||
dataGridSaveExecutionSchema("oracle", { schema: "DBXTEST", tableName: "T", primaryKeys: [] }),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
dataGridSaveExecutionSchema("postgres", { schema: "public", tableName: "T", primaryKeys: [] }),
|
||||
"public",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects NULL writes to non-null table columns", () => {
|
||||
const error = validateDataGridSave({
|
||||
columns: ["ID", "CREATED_AT", "CITY"],
|
||||
columnInfo: [
|
||||
{ name: "ID", is_nullable: false, is_primary_key: true },
|
||||
{ name: "CREATED_AT", is_nullable: false, is_primary_key: false },
|
||||
{ name: "CITY", is_nullable: true, is_primary_key: false },
|
||||
],
|
||||
dirtyRows: [[0, [[1, null]]]],
|
||||
newRows: [[2, null, "上海"]],
|
||||
});
|
||||
|
||||
assert.equal(error, 'Column "CREATED_AT" does not allow NULL.');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useQueryStore } from "../src/stores/queryStore.ts";
|
||||
|
||||
test("setErrorResult stops loading and shows the error result", () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("conn-1", "db", "users", "data");
|
||||
|
||||
store.setExecuting(tabId, true);
|
||||
store.setErrorResult(tabId, new Error("metadata failed"));
|
||||
|
||||
const tab = store.tabs.find((item) => item.id === tabId);
|
||||
assert.equal(tab?.isExecuting, false);
|
||||
assert.equal(tab?.isCancelling, false);
|
||||
assert.equal(tab?.executionId, undefined);
|
||||
assert.deepEqual(tab?.result?.columns, ["Error"]);
|
||||
assert.deepEqual(tab?.result?.rows, [["Error: metadata failed"]]);
|
||||
});
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
DBX_ROWID_COLUMN,
|
||||
editablePrimaryKeys,
|
||||
isHiddenGridColumn,
|
||||
usesSyntheticRowIdKey,
|
||||
} from "../src/lib/tableEditing.ts";
|
||||
import type { ColumnInfo } from "../src/types/database.ts";
|
||||
|
||||
function column(name: string, isPrimaryKey = false): ColumnInfo {
|
||||
return {
|
||||
name,
|
||||
data_type: "VARCHAR2",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: isPrimaryKey,
|
||||
extra: null,
|
||||
};
|
||||
}
|
||||
|
||||
test("uses ROWID as Oracle editable key when a table has no primary key", () => {
|
||||
assert.deepEqual(editablePrimaryKeys("oracle", [column("ID"), column("CITY")]), [DBX_ROWID_COLUMN]);
|
||||
});
|
||||
|
||||
test("keeps declared primary keys ahead of Oracle ROWID fallback", () => {
|
||||
assert.deepEqual(editablePrimaryKeys("oracle", [column("ID", true), column("CITY")]), ["ID"]);
|
||||
});
|
||||
|
||||
test("does not synthesize ROWID for non-Oracle keyless tables", () => {
|
||||
assert.deepEqual(editablePrimaryKeys("mysql", [column("ID"), column("CITY")]), []);
|
||||
});
|
||||
|
||||
test("detects the synthetic Oracle ROWID key case", () => {
|
||||
assert.equal(usesSyntheticRowIdKey("oracle", [DBX_ROWID_COLUMN]), true);
|
||||
assert.equal(usesSyntheticRowIdKey("oracle", [DBX_ROWID_COLUMN.toLowerCase()]), true);
|
||||
assert.equal(usesSyntheticRowIdKey("postgres", [DBX_ROWID_COLUMN]), false);
|
||||
assert.equal(usesSyntheticRowIdKey("oracle", ["ID"]), false);
|
||||
});
|
||||
|
||||
test("hides only the synthetic Oracle ROWID grid column", () => {
|
||||
assert.equal(isHiddenGridColumn("oracle", DBX_ROWID_COLUMN, [DBX_ROWID_COLUMN]), true);
|
||||
assert.equal(isHiddenGridColumn("oracle", "ROWID", [DBX_ROWID_COLUMN]), false);
|
||||
assert.equal(isHiddenGridColumn("mysql", DBX_ROWID_COLUMN, [DBX_ROWID_COLUMN]), false);
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { DBX_ROWID_COLUMN } from "../src/lib/tableEditing.ts";
|
||||
import { buildTableSelectSql } from "../src/lib/tableSelectSql.ts";
|
||||
|
||||
test("builds a MySQL table WHERE query from search input", () => {
|
||||
|
|
@ -37,7 +38,10 @@ test("builds SQL Server first page query with schema-aware brackets", () => {
|
|||
primaryKeys: ["id"],
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT * FROM [dbo].[accounts] WHERE (id = 1) ORDER BY [id] ASC OFFSET 0 ROWS FETCH NEXT 25 ROWS ONLY");
|
||||
assert.equal(
|
||||
sql,
|
||||
"SELECT * FROM [dbo].[accounts] WHERE (id = 1) ORDER BY [id] ASC OFFSET 0 ROWS FETCH NEXT 25 ROWS ONLY",
|
||||
);
|
||||
});
|
||||
|
||||
test("builds SQL Server later pages with OFFSET and FETCH", () => {
|
||||
|
|
@ -65,3 +69,19 @@ test("builds SQL Server pages with fallback order columns when there is no prima
|
|||
|
||||
assert.equal(sql, "SELECT * FROM [dbo].[logs] ORDER BY [created_at] ASC OFFSET 50 ROWS FETCH NEXT 50 ROWS ONLY");
|
||||
});
|
||||
|
||||
test("builds Oracle table data queries with ROWID for keyless editing", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "oracle",
|
||||
schema: "DBXTEST",
|
||||
tableName: "DBX_LOAD_TABLE_006",
|
||||
primaryKeys: [DBX_ROWID_COLUMN],
|
||||
includeRowId: true,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
`SELECT ROWIDTOCHAR(t.ROWID) AS "__DBX_ROWID", t.* FROM "DBXTEST"."DBX_LOAD_TABLE_006" t ORDER BY t.ROWID ASC FETCH FIRST 100 ROWS ONLY`,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue