diff --git a/src/components/sidebar/TreeItem.vue b/src/components/sidebar/TreeItem.vue index 181ba86c00..9033152838 100644 --- a/src/components/sidebar/TreeItem.vue +++ b/src/components/sidebar/TreeItem.vue @@ -33,6 +33,10 @@ import { Search, FolderInput, FolderPlus, + Eraser, + Scissors, + CopyPlus, + Plus, } from "lucide-vue-next"; import { ContextMenu, @@ -58,6 +62,7 @@ import { } from "@/lib/databaseExport"; import { qualifiedTableName as buildQualifiedTableName, quoteTableIdentifier } from "@/lib/tableSelectSql"; import { treeNodeRowAction } from "@/lib/treeNodeClick"; +import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue"; @@ -343,6 +348,148 @@ function copyName() { navigator.clipboard.writeText(props.node.label); } +// --- Table Management Operations --- +const showDropTableConfirm = ref(false); +const showEmptyTableConfirm = ref(false); +const showTruncateTableConfirm = ref(false); +const showDuplicateDialog = ref(false); +const duplicateTableName = ref(""); + +const isTableNotView = computed(() => props.node.type === "table"); + +const supportsTruncate = computed(() => { + const dbType = currentDatabaseType(); + return dbType !== "sqlite" && dbType !== "duckdb"; +}); + +const canCreateTable = computed(() => { + const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; + return ( + (props.node.type === "database" || props.node.type === "schema") && + !!props.node.database && + !!config && + tableStructureSupportedTypes.has(config.db_type) + ); +}); + +function buildDropTableSql(): string { + return `DROP TABLE ${qualifiedTableName(props.node.label, props.node.schema)};`; +} + +function buildEmptyTableSql(): string { + return `DELETE FROM ${qualifiedTableName(props.node.label, props.node.schema)};`; +} + +function buildTruncateTableSql(): string { + const dbType = currentDatabaseType(); + const name = qualifiedTableName(props.node.label, props.node.schema); + if (dbType === "sqlite" || dbType === "duckdb") return `DELETE FROM ${name};`; + return `TRUNCATE TABLE ${name};`; +} + +function dropTable() { + showDropTableConfirm.value = true; +} + +async function confirmDropTable() { + const node = props.node; + if (!node.connectionId || !node.database) return; + try { + await connectionStore.ensureConnected(node.connectionId); + await api.executeQuery(node.connectionId, node.database, buildDropTableSql(), node.schema); + toast(t("contextMenu.dropTableSuccess", { name: node.label }), 3000); + if (node.schema) { + await connectionStore.loadTables(node.connectionId, node.database, node.schema); + } else { + await connectionStore.loadTables(node.connectionId, node.database); + } + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + } +} + +function emptyTable() { + showEmptyTableConfirm.value = true; +} + +async function confirmEmptyTable() { + const node = props.node; + if (!node.connectionId || !node.database) return; + try { + await connectionStore.ensureConnected(node.connectionId); + await api.executeQuery(node.connectionId, node.database, buildEmptyTableSql(), node.schema); + toast(t("contextMenu.emptyTableSuccess", { name: node.label }), 3000); + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + } +} + +function truncateTable() { + showTruncateTableConfirm.value = true; +} + +async function confirmTruncateTable() { + const node = props.node; + if (!node.connectionId || !node.database) return; + try { + await connectionStore.ensureConnected(node.connectionId); + await api.executeQuery(node.connectionId, node.database, buildTruncateTableSql(), node.schema); + toast(t("contextMenu.truncateTableSuccess", { name: node.label }), 3000); + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + } +} + +function duplicateStructure() { + duplicateTableName.value = `${props.node.label}_copy`; + showDuplicateDialog.value = true; +} + +async function confirmDuplicateStructure() { + const node = props.node; + const newName = duplicateTableName.value.trim(); + if (!newName || !node.connectionId || !node.database) return; + showDuplicateDialog.value = false; + try { + await connectionStore.ensureConnected(node.connectionId); + const dbType = currentDatabaseType(); + const source = qualifiedTableName(node.label, node.schema); + const target = qualifiedTableName(newName, node.schema); + let sql: string; + if (dbType === "mysql") { + sql = `CREATE TABLE ${target} LIKE ${source};`; + } else if (dbType === "postgres" || dbType === "redshift") { + sql = `CREATE TABLE ${target} (LIKE ${source} INCLUDING ALL);`; + } else if (dbType === "sqlserver") { + sql = `SELECT TOP 0 * INTO ${target} FROM ${source};`; + } else if (dbType === "oracle") { + sql = `CREATE TABLE ${target} AS SELECT * FROM ${source} WHERE 1=0`; + } else { + sql = `CREATE TABLE ${target} AS SELECT * FROM ${source} WHERE 0;`; + } + await api.executeQuery(node.connectionId, node.database, sql, node.schema); + toast(t("contextMenu.duplicateStructureSuccess", { name: newName }), 3000); + if (node.schema) { + await connectionStore.loadTables(node.connectionId, node.database, node.schema); + } else { + await connectionStore.loadTables(node.connectionId, node.database); + } + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + } +} + +function createTable() { + const node = props.node; + if (!node.connectionId || !node.database) return; + connectionStore.structureEditorSource = { + connectionId: node.connectionId, + database: node.database, + schema: node.schema, + tableName: "", + }; +} + async function collectDatabaseExportTables(): Promise> { const node = props.node; if (!node.connectionId || !node.database) return []; @@ -1033,6 +1180,9 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr {{ t("contextMenu.newQuery") }} + + {{ t("contextMenu.createTable") }} + {{ t("sqlFile.title") }} @@ -1089,6 +1239,22 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr {{ t("contextMenu.exportStructure") }} + {{ t("contextMenu.refreshChildren") }} @@ -1167,4 +1333,50 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr + + + + + + + + + + + {{ t("contextMenu.duplicateNameTitle") }} + + + + + + + + diff --git a/src/components/structure/TableStructureEditorDialog.vue b/src/components/structure/TableStructureEditorDialog.vue index 5187d847d2..a1995f7d65 100644 --- a/src/components/structure/TableStructureEditorDialog.vue +++ b/src/components/structure/TableStructureEditorDialog.vue @@ -32,6 +32,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { useToast } from "@/composables/useToast"; import { buildTableStructureChangeSql, + buildCreateTableSql, type EditableStructureColumn, type EditableStructureIndex, } from "@/lib/tableStructureEditorSql"; @@ -107,19 +108,32 @@ const indexColLabels = computed(() => [ t("structureEditor.actions"), ]); const targetSchema = computed(() => props.prefillSchema || props.prefillDatabase || ""); -const targetLabel = computed(() => - [connection.value?.name, props.prefillDatabase, props.prefillSchema, props.prefillTable].filter(Boolean).join(" / "), -); +const isCreateMode = computed(() => !props.prefillTable); +const newTableName = ref(""); +const targetLabel = computed(() => { + const parts = [connection.value?.name, props.prefillDatabase, props.prefillSchema]; + if (!isCreateMode.value) parts.push(props.prefillTable); + return parts.filter(Boolean).join(" / "); +}); -const changeSql = computed(() => - buildTableStructureChangeSql({ +const changeSql = computed(() => { + if (isCreateMode.value) { + return buildCreateTableSql({ + databaseType: databaseType.value, + schema: props.prefillSchema, + tableName: newTableName.value, + columns: columns.value, + indexes: indexes.value, + }); + } + return buildTableStructureChangeSql({ databaseType: databaseType.value, schema: props.prefillSchema, tableName: props.prefillTable || "", columns: columns.value, indexes: indexes.value, - }), -); + }); +}); const pendingStatements = computed(() => changeSql.value.statements); const warnings = computed(() => changeSql.value.warnings); const canApply = computed( @@ -129,7 +143,7 @@ const canApply = computed( pendingStatements.value.length > 0 && warnings.value.length === 0 && !!props.prefillConnectionId && - !!props.prefillTable, + (isCreateMode.value ? !!newTableName.value.trim() : !!props.prefillTable), ); function resetState() { @@ -141,6 +155,7 @@ function resetState() { indexes.value = []; foreignKeys.value = []; triggers.value = []; + newTableName.value = ""; } async function loadStructure() { @@ -246,7 +261,11 @@ async function applyChanges() { await api.executeBatch(props.prefillConnectionId, props.prefillDatabase, pendingStatements.value); toast(t("structureEditor.saved"), 2500); emit("saved"); - await loadStructure(); + if (isCreateMode.value) { + open.value = false; + } else { + await loadStructure(); + } } catch (e: any) { errorMessage.value = e?.message || String(e); } finally { @@ -268,7 +287,7 @@ watch(open, (value) => { - {{ t("structureEditor.title") }} + {{ isCreateMode ? t("structureEditor.createTitle") : t("structureEditor.title") }} @@ -277,12 +296,28 @@ watch(open, (value) => { {{ targetLabel || t("editor.noDatabase") }} {{ connection?.driver_label || databaseType }} - +
+ + +
+
{{ t("common.loading") }} diff --git a/src/composables/useNavigationTargets.ts b/src/composables/useNavigationTargets.ts index 417419da85..86823c457b 100644 --- a/src/composables/useNavigationTargets.ts +++ b/src/composables/useNavigationTargets.ts @@ -52,7 +52,11 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value: boolean }; showDatabaseSearchDialog: { value: boolean }; structurePrefillTable: { value: string }; + structurePrefillConnectionId?: { value: string }; + structurePrefillDatabase?: { value: string }; + structurePrefillSchema?: { value: string }; }) { + const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); async function openLineageTarget(target: NavigationTarget) { @@ -69,6 +73,17 @@ export function useNavigationTargets(dialogs: { reloadData: () => Promise, toast: (msg: string, duration?: number) => void, ) { + if (!dialogs.structurePrefillTable.value) { + const connId = dialogs.structurePrefillConnectionId?.value; + const db = dialogs.structurePrefillDatabase?.value; + const schema = dialogs.structurePrefillSchema?.value; + if (connId && db) { + try { + await connectionStore.loadTables(connId, db, schema || undefined); + } catch {} + } + return; + } const activeTab = queryStore.tabs.find((t) => t.id === queryStore.activeTabId); if (activeTab?.mode === "data" && activeTab.tableMeta?.tableName === dialogs.structurePrefillTable.value) { try { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7002d0edf0..e96a1d4b53 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -416,6 +416,25 @@ export default { importData: "Import Data", exportData: "Export Data", exportStructure: "Export Structure", + createTable: "Create Table", + dropTable: "Drop Table", + emptyTable: "Empty Table", + truncateTable: "Truncate Table", + duplicateStructure: "Duplicate Structure", + confirmDropTableTitle: "Drop Table", + confirmDropTableMessage: + 'Are you sure you want to drop "{name}"? This will permanently delete the table and all its data.', + confirmEmptyTableTitle: "Empty Table", + confirmEmptyTableMessage: 'Are you sure you want to delete all data from "{name}"?', + confirmTruncateTableTitle: "Truncate Table", + confirmTruncateTableMessage: 'Are you sure you want to truncate "{name}"? This will remove all rows.', + dropTableSuccess: 'Table "{name}" dropped', + emptyTableSuccess: 'All data deleted from "{name}"', + truncateTableSuccess: 'Table "{name}" truncated', + duplicateStructureSuccess: 'Table structure duplicated as "{name}"', + tableOperationFailed: "Operation failed: {message}", + duplicateNameTitle: "Duplicate Structure", + duplicateNamePlaceholder: "New table name", }, tree: { columns: "Columns", @@ -425,6 +444,8 @@ export default { }, structureEditor: { title: "Edit Table Structure", + createTitle: "Create Table", + tableName: "Table Name", refresh: "Refresh Structure", columns: "Columns", indexes: "Indexes", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 1d29414ec8..f3d0f2ae4b 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -408,6 +408,24 @@ export default { importData: "导入数据", exportData: "导出数据", exportStructure: "导出表结构", + createTable: "新建表", + dropTable: "删除表", + emptyTable: "清空数据", + truncateTable: "截断表", + duplicateStructure: "复制表结构", + confirmDropTableTitle: "删除表", + confirmDropTableMessage: "确定要删除「{name}」吗?这将永久删除表及其所有数据。", + confirmEmptyTableTitle: "清空数据", + confirmEmptyTableMessage: "确定要删除「{name}」中的所有数据吗?", + confirmTruncateTableTitle: "截断表", + confirmTruncateTableMessage: "确定要截断「{name}」吗?这将删除所有行。", + dropTableSuccess: "表「{name}」已删除", + emptyTableSuccess: "已清空「{name}」的所有数据", + truncateTableSuccess: "表「{name}」已截断", + duplicateStructureSuccess: "已复制表结构为「{name}」", + tableOperationFailed: "操作失败:{message}", + duplicateNameTitle: "复制表结构", + duplicateNamePlaceholder: "新表名", }, tree: { columns: "字段", @@ -417,6 +435,8 @@ export default { }, structureEditor: { title: "编辑表结构", + createTitle: "新建表", + tableName: "表名", refresh: "刷新结构", columns: "字段", indexes: "索引", diff --git a/src/lib/tableStructureEditorSql.ts b/src/lib/tableStructureEditorSql.ts index 05a2187f9d..faea367c37 100644 --- a/src/lib/tableStructureEditorSql.ts +++ b/src/lib/tableStructureEditorSql.ts @@ -297,3 +297,76 @@ export function buildTableStructureChangeSql(options: BuildTableStructureChangeS return { statements, warnings }; } + +export function buildCreateTableSql(options: BuildTableStructureChangeSqlOptions): TableStructureChangeSql { + const warnings: string[] = []; + + if (!clean(options.tableName)) { + warnings.push("Table name is required."); + } + + const activeColumns = options.columns.filter((c) => !c.markedForDrop); + if (activeColumns.length === 0) { + warnings.push("At least one column is required."); + } + + const names = new Set(); + for (const col of activeColumns) { + if (!clean(col.name)) warnings.push("Column name cannot be empty."); + if (!clean(col.dataType)) warnings.push(`Column "${col.name || "(new)"}" type cannot be empty.`); + const key = clean(col.name).toLowerCase(); + if (key && names.has(key)) warnings.push(`Column "${col.name}" is duplicated.`); + if (key) names.add(key); + } + + if (warnings.length > 0) return { statements: [], warnings }; + + const databaseType = options.databaseType; + const table = qualifiedTable(databaseType, options.schema, options.tableName); + const statements: string[] = []; + + const pkColumns = activeColumns.filter((c) => c.isPrimaryKey); + const colDefs = activeColumns.map((col) => { + const parts = [quoteIdent(databaseType, col.name), col.dataType.trim()]; + if (!col.isNullable && !col.isPrimaryKey) parts.push("NOT NULL"); + const defaultValue = normalizeDefault(col.defaultValue); + if (defaultValue) parts.push(`DEFAULT ${defaultValue}`); + if (databaseType === "mysql" && clean(col.comment)) { + parts.push(`COMMENT ${quoteString(clean(col.comment))}`); + } + return parts.join(" "); + }); + + if (pkColumns.length > 0) { + const pkList = pkColumns.map((c) => quoteIdent(databaseType, c.name)).join(", "); + colDefs.push(`PRIMARY KEY (${pkList})`); + } + + statements.push(`CREATE TABLE ${table} (\n ${colDefs.join(",\n ")}\n);`); + + if (databaseType === "postgres") { + for (const col of activeColumns) { + if (clean(col.comment)) { + statements.push( + `COMMENT ON COLUMN ${table}.${quoteIdent(databaseType, col.name)} IS ${quoteString(clean(col.comment))};`, + ); + } + } + } + + for (const index of options.indexes.filter((idx) => !idx.markedForDrop && !idx.isPrimary)) { + const name = clean(index.name); + const columns = index.columns.map(clean).filter(Boolean); + if (!name || columns.length === 0) continue; + const unique = index.isUnique ? "UNIQUE " : ""; + const cols = columns.map((c) => quoteIdent(databaseType, c)).join(", "); + const idxType = clean(index.indexType); + const usingClause = idxType && databaseType === "postgres" ? ` USING ${idxType}` : ""; + const typePrefix = idxType && databaseType === "sqlserver" ? `${idxType} ` : ""; + statements.push( + `CREATE ${unique}${typePrefix}INDEX ${quoteIdent(databaseType, name)} ON ${table}${usingClause} (${cols});`, + ); + } + + return { statements, warnings }; +}