feat(grid): align query result editing
This commit is contained in:
parent
a630d27cc8
commit
5c4c87a042
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue