feat(grid): align query result editing

This commit is contained in:
t8y2 2026-06-23 17:07:30 +08:00
parent a630d27cc8
commit 5c4c87a042
8 changed files with 98 additions and 20 deletions

View File

@ -87,7 +87,7 @@ import type { BuildSingleColumnAlterSqlOptions } from "@/lib/tableStructureEdito
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
import { uuid } from "@/lib/utils";
import { resolveHeaderColumnType } from "@/lib/dataGridColumnType";
import { canEditExistingTableRows, hiveTablePropertiesIndicateTransactional, isHiddenGridColumn, isTdengineExistingRowReadonlyColumn, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { canEditExistingTableRows, canUseKeylessRowPredicate, hiveTablePropertiesIndicateTransactional, isHiddenGridColumn, isTdengineExistingRowReadonlyColumn, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { buildDataGridContextFilterCondition, buildDataGridCountSql, buildHiveTablePropertiesSql, type DataGridContextFilterMode } from "@/lib/dataGridSql";
import {
buildVisibleTransposeRows,
@ -2345,6 +2345,7 @@ const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownTotalRowC
const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value);
const canCalculateTotalRowCount = computed(() => !isResultsContext.value && !!props.connectionId && (!!props.tableMeta || !!props.countSql));
const showQueryEditReadyBadge = computed(() => isResultsContext.value && hasData.value && !!props.editable && !!props.tableMeta);
const showKeylessEditWarning = computed(() => !!props.editable && !!props.tableMeta && canUseKeylessRowPredicate(props.databaseType, props.tableMeta.primaryKeys ?? []));
const canShowWhereSearch = computed(() => !!props.onExecuteSql && !isResultsContext.value);
const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql && !isResultsContext.value);
type DataGridTableMeta = NonNullable<typeof props.tableMeta>;
@ -6720,6 +6721,17 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
{{ t("grid.queryEditReadyHint", { table: tableMeta?.tableName }) }}
</TooltipContent>
</Tooltip>
<Tooltip v-if="showKeylessEditWarning">
<TooltipTrigger as-child>
<div class="flex h-5 items-center gap-1 rounded border border-amber-500/30 bg-amber-500/10 px-1.5 text-xs font-medium text-amber-700 dark:text-amber-300">
<KeyRound class="h-3 w-3" />
{{ t("grid.keylessEditWarning") }}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-sm">
{{ t("grid.keylessEditWarningHint") }}
</TooltipContent>
</Tooltip>
<Button v-if="props.context !== 'results'" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :disabled="isSaving" @click="onToolbarRefresh">
<Loader2 v-if="loading" class="w-3 h-3 mr-1 animate-spin" />
<RefreshCcw v-else class="w-3 h-3 mr-1" />

View File

@ -71,7 +71,7 @@ import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTem
import { connectionFilePath, defaultSqliteBackupFileName, isMemorySqlitePath, sqliteBackupSourcePath } from "@/lib/connectionFile";
import { revealPathInFileManager } from "@/lib/tauri";
import { clearActiveTableReferencePayload, createTableReferencePayload, createTableReferenceDropEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/queryEditorTableDrop";
import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { supportsDatabaseCreation, supportsDatabaseSearch, supportsFieldLineage, supportsObjectBrowserTreeNode, supportsSchemaDiagram, supportsSqlFileExecution, supportsTableImport, supportsTableTruncate, supportsTableStructureEditing, usesTreeSchemaMode } from "@/lib/databaseCapabilities";
import { copyNameForTreeNode, objectSourceKindForTreeNode, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/treeNodeClick";
import { formatSqlInsert } from "@/lib/exportFormats";
@ -936,6 +936,7 @@ async function openData() {
});
try {
const nextColumns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
const indexes = await api.listIndexes(node.connectionId, node.database, querySchema, node.label).catch(() => []);
if (!isCurrentDataTab()) {
console.info("[DBX][openData:metadata:stale]", {
traceId,
@ -945,7 +946,7 @@ async function openData() {
});
return;
}
const nextPrimaryKeys = editablePrimaryKeys(effectiveDbType, nextColumns, tableType);
const nextPrimaryKeys = editableRowIdentifierColumns(effectiveDbType, nextColumns, indexes, tableType);
queryStore.setTableMeta(tabId, {
schema: tableSchema,
tableName: node.label,

View File

@ -4,7 +4,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
import * as api from "@/lib/api";
import type { QueryTab } from "@/types/database";
@ -30,10 +30,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
const config = connectionStore.getConfig(tab.connectionId);
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
const tableMeta = tableMetaForDataTab(tab);
const primaryKeys = tab.tableMeta ? editablePrimaryKeys(effectiveDbType, tab.tableMeta.columns, tab.tableMeta.tableType) : (tableMeta?.primaryKeys ?? []);
if (tab.tableMeta && primaryKeys.join("\0") !== tab.tableMeta.primaryKeys.join("\0")) {
tab.tableMeta.primaryKeys = primaryKeys;
}
const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : (tableMeta?.primaryKeys ?? []);
const useRowId = usesSyntheticRowIdKey(effectiveDbType, primaryKeys);
return buildTableSelectSql({
databaseType: effectiveDbType,
@ -59,8 +56,9 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
const querySchema = connectionObjectTreeQuerySchema(config, tab.database, tableMeta.schema);
console.info("[DBX][reloadData:metadata:get-columns:start]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), schema: querySchema, table: tableMeta.tableName });
const columns = await api.getColumns(tab.connectionId, tab.database, querySchema, tableMeta.tableName);
const indexes = await api.listIndexes(tab.connectionId, tab.database, querySchema, tableMeta.tableName).catch(() => []);
console.info("[DBX][reloadData:metadata:get-columns:done]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), columnCount: columns.length });
const primaryKeys = editablePrimaryKeys(effectiveDatabaseTypeForConnection(config), columns, tableMeta.tableType);
const primaryKeys = editableRowIdentifierColumns(effectiveDatabaseTypeForConnection(config), columns, indexes, tableMeta.tableType);
queryStore.setTableMeta(tab.id, {
schema: tableMeta.schema,
tableName: tableMeta.tableName,

View File

@ -1,7 +1,7 @@
import * as api from "@/lib/api";
import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { buildTableSelectSql } from "@/lib/tableSelectSql";
import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
@ -55,7 +55,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
const querySchema = connectionObjectTreeQuerySchema(config, target.database, target.schema);
if (config.db_type === "neo4j") {
const columns = await api.getColumns(target.connectionId, target.database, querySchema, target.tableName);
const primaryKeys = editablePrimaryKeys(effectiveDbType, columns);
const primaryKeys = editableRowIdentifierColumns(effectiveDbType, columns);
const sql = await buildTableSelectSql({
databaseType: effectiveDbType,
schema: target.schema,
@ -96,7 +96,8 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
const [columnsResult, dataResult] = await Promise.allSettled([columnsPromise, dataPromise]);
if (columnsResult.status === "fulfilled") {
const columns = columnsResult.value;
const primaryKeys = editablePrimaryKeys(effectiveDbType, columns);
const indexes = await api.listIndexes(target.connectionId, target.database, querySchema, target.tableName).catch(() => []);
const primaryKeys = editableRowIdentifierColumns(effectiveDbType, columns, indexes);
const useRowId = usesSyntheticRowIdKey(effectiveDbType, primaryKeys);
queryStore.setTableMeta(tabId, {
schema: target.schema,
@ -160,10 +161,11 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value:
const connection = connectionStore.getConfig(tab.connectionId);
const metadataSchema = connectionObjectTreeQuerySchema(connection, tab.database, tab.tableMeta?.schema);
const columns = await api.getColumns(tab.connectionId, tab.database, metadataSchema, tab.tableMeta!.tableName);
const indexes = await api.listIndexes(tab.connectionId, tab.database, metadataSchema, tab.tableMeta!.tableName).catch(() => []);
queryStore.setTableMeta(tab.id, {
...tab.tableMeta!,
columns,
primaryKeys: editablePrimaryKeys(effectiveDatabaseTypeForConnection(connection), columns, tab.tableMeta!.tableType),
primaryKeys: editableRowIdentifierColumns(effectiveDatabaseTypeForConnection(connection), columns, indexes, tab.tableMeta!.tableType),
});
if (tab.id === queryStore.activeTabId) await reloadData();
} catch (e: any) {

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { DBX_ROWID_COLUMN, editablePrimaryKeys, isTableDataEditable } from "@/lib/tableEditing";
import type { ColumnInfo } from "@/types/database";
import { DBX_ROWID_COLUMN, canUseKeylessRowPredicate, editablePrimaryKeys, editableRowIdentifierColumns, isTableDataEditable } from "@/lib/tableEditing";
import type { ColumnInfo, IndexInfo } from "@/types/database";
function column(name: string, isPrimaryKey = false): ColumnInfo {
return {
@ -13,6 +13,16 @@ function column(name: string, isPrimaryKey = false): ColumnInfo {
};
}
function index(columns: string[], isUnique = true, filter: string | null = null): IndexInfo {
return {
name: columns.join("_"),
columns,
is_unique: isUnique,
is_primary: false,
filter,
};
}
describe("tableEditing", () => {
it("does not synthesize Oracle ROWID for views", () => {
expect(editablePrimaryKeys("oracle", [column("ID"), column("NAME")], "VIEW")).toEqual([]);
@ -22,4 +32,17 @@ describe("tableEditing", () => {
it("treats view data tabs as readonly", () => {
expect(isTableDataEditable("oracle", [DBX_ROWID_COLUMN], "VIEW")).toBe(false);
});
it("allows keyless row predicates only for databases that support them", () => {
expect(canUseKeylessRowPredicate("postgres", [])).toBe(true);
expect(canUseKeylessRowPredicate("mysql", [])).toBe(true);
expect(canUseKeylessRowPredicate("jdbc", [])).toBe(false);
expect(canUseKeylessRowPredicate("postgres", ["id"])).toBe(false);
});
it("uses unique indexes as row identifiers when primary keys are absent", () => {
expect(editableRowIdentifierColumns("postgres", [column("email"), column("name")], [index(["email", "name"]), index(["email"])])).toEqual(["email"]);
expect(editableRowIdentifierColumns("postgres", [column("email"), column("name")], [index(["email"], true, "email IS NOT NULL")])).toEqual([]);
expect(editableRowIdentifierColumns("postgres", [column("id", true), column("email")], [index(["email"])])).toEqual(["id"]);
});
});

View File

@ -1,4 +1,4 @@
import type { ColumnInfo, DatabaseType } from "@/types/database";
import type { ColumnInfo, DatabaseType, IndexInfo } from "@/types/database";
import { getDatabaseCapability } from "./databaseCapabilities";
export const DBX_ROWID_COLUMN = "__DBX_ROWID";
@ -19,6 +19,13 @@ export function editablePrimaryKeys(databaseType: DatabaseType | undefined, colu
return primaryKeys;
}
export function editableRowIdentifierColumns(databaseType: DatabaseType | undefined, columns: ColumnInfo[], indexes?: IndexInfo[], tableType?: string): string[] {
const primaryKeys = editablePrimaryKeys(databaseType, columns, tableType);
if (primaryKeys.length > 0) return primaryKeys;
const uniqueIndex = indexes?.filter((index) => !index.filter && index.columns.length > 0 && (index.is_primary || index.is_unique)).sort((left, right) => Number(right.is_primary) - Number(left.is_primary) || left.columns.length - right.columns.length)[0];
return uniqueIndex?.columns ?? [];
}
export function isTableDataEditable(databaseType: DatabaseType | undefined, primaryKeys: string[], tableType?: string): boolean {
if (isViewTableType(tableType)) return false;
const cap = getDatabaseCapability(databaseType).tableData;
@ -35,6 +42,10 @@ export function usesKeylessRowPredicate(databaseType: DatabaseType | undefined):
return !!getDatabaseCapability(databaseType).tableData.keylessRowPredicate;
}
export function canUseKeylessRowPredicate(databaseType: DatabaseType | undefined, primaryKeys: readonly string[]): boolean {
return primaryKeys.length === 0 && usesKeylessRowPredicate(databaseType);
}
export function canEditExistingTableRows(databaseType: DatabaseType | undefined, hiveTableTransactional?: boolean, primaryKeys?: string[]): boolean {
const tableData = getDatabaseCapability(databaseType).tableData;
if (tableData.readonly) return false;

View File

@ -28,7 +28,7 @@ import { redisCommandResultToQueryResult } from "@/lib/redisQueryResult";
import { nextRedisCommandDb } from "@/lib/redisCommandSession";
import { isRedisMutatingCommand } from "@/lib/redisCommandTable";
import { supportsDatabaseFeature } from "@/lib/databaseCapabilities";
import { editablePrimaryKeys } from "@/lib/tableEditing";
import { canUseKeylessRowPredicate, editableRowIdentifierColumns } from "@/lib/tableEditing";
import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/tableDataExport";
import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
import { quoteTableIdentifier } from "@/lib/tableSelectSql";
@ -1125,7 +1125,8 @@ export const useQueryStore = defineStore("query", () => {
columnCount: columns.length,
elapsed: elapsed?.(),
});
const primaryKeys = editablePrimaryKeys(dbType as DatabaseType, columns);
const indexes = await api.listIndexes(tab.connectionId, tab.database, metadataSchema, metadataTableName).catch(() => []);
const primaryKeys = editableRowIdentifierColumns(dbType as DatabaseType, columns, indexes);
const tableMeta = {
schema: metadataSchema || undefined,
tableName: metadataTableName,
@ -1133,7 +1134,7 @@ export const useQueryStore = defineStore("query", () => {
primaryKeys,
};
if (primaryKeys.length === 0) {
if (primaryKeys.length === 0 && !canUseKeylessRowPredicate(dbType as DatabaseType, primaryKeys)) {
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
@ -2039,7 +2040,7 @@ export const useQueryStore = defineStore("query", () => {
const totalRows = typeof tab.resultTotalRowCount === "number" ? tab.resultTotalRowCount : null;
const pageLimit = TABLE_DATA_EXPORT_PAGE_SIZE;
const effectiveDbType = effectiveDatabaseTypeForConnection(conn);
const primaryKeys = tab.tableMeta ? editablePrimaryKeys(effectiveDbType, tab.tableMeta.columns, tab.tableMeta.tableType) : tableMeta.primaryKeys;
const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : tableMeta.primaryKeys;
const sortOrder = tab.resultSortColumn && tab.resultSortDirection ? `${quoteTableIdentifier(effectiveDbType, tab.resultSortColumn)} ${tab.resultSortDirection.toUpperCase()}` : undefined;
const orderBy = tab.orderByInput?.trim() || sortOrder;
const queryTimeoutSecs = queryTimeoutSecsForConnection(conn);

View File

@ -1397,6 +1397,8 @@ fn uses_keyless_row_predicate(database_type: Option<DatabaseType>) -> bool {
| DatabaseType::ManticoreSearch
| DatabaseType::Postgres
| DatabaseType::Sqlite
| DatabaseType::Rqlite
| DatabaseType::Turso
| DatabaseType::DuckDb
| DatabaseType::SqlServer
| DatabaseType::Oracle
@ -1428,6 +1430,7 @@ fn uses_keyless_row_predicate(database_type: Option<DatabaseType>) -> bool {
| DatabaseType::Informix
| DatabaseType::Bigquery
| DatabaseType::Sundb
| DatabaseType::Databend
| DatabaseType::Hive
| DatabaseType::Iris
)
@ -1656,6 +1659,33 @@ mod tests {
);
}
#[test]
fn prepares_databend_keyless_save_statements_with_row_predicate() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Databend),
table_meta: DataGridTableMeta {
schema: Some("default".to_string()),
table_name: "people".to_string(),
primary_keys: vec![],
columns: Some(vec![column("id", "int", true, None), column("name", "string", true, None)]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("Ada")]],
dirty_rows: vec![(0, vec![(1, json!("Linus"))])],
deleted_rows: vec![0],
new_rows: vec![],
});
assert_eq!(
result.statements,
vec![
"UPDATE `default`.`people` SET `name` = 'Linus' WHERE `id` = 1 AND `name` = 'Ada';",
"DELETE FROM `default`.`people` WHERE `id` = 1 AND `name` = 'Ada';",
]
);
}
#[test]
fn skips_expression_only_source_columns() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {