From f09d1b0973dd16d4cad795e332d56c24873fe423 Mon Sep 17 00:00:00 2001 From: SuLea-IT <105108570+SuLea-IT@users.noreply.github.com> Date: Sat, 2 May 2026 19:34:35 +0800 Subject: [PATCH] feat: add table structure editor --- src/App.vue | 47 ++ src/components/sidebar/TreeItem.vue | 21 +- .../structure/TableStructureEditorDialog.vue | 406 ++++++++++++++++++ src/i18n/locales/en.ts | 32 ++ src/i18n/locales/zh-CN.ts | 32 ++ src/lib/tableStructureEditorSql.ts | 285 ++++++++++++ src/lib/tableStructureEditorState.ts | 39 ++ src/stores/connectionStore.ts | 2 + tests/tableStructureEditorSql.test.ts | 183 ++++++++ tests/tableStructureEditorState.test.ts | 106 +++++ 10 files changed, 1152 insertions(+), 1 deletion(-) create mode 100644 src/components/structure/TableStructureEditorDialog.vue create mode 100644 src/lib/tableStructureEditorSql.ts create mode 100644 src/lib/tableStructureEditorState.ts create mode 100644 tests/tableStructureEditorSql.test.ts create mode 100644 tests/tableStructureEditorState.test.ts diff --git a/src/App.vue b/src/App.vue index c97c8257c4..dde9ffa070 100644 --- a/src/App.vue +++ b/src/App.vue @@ -39,6 +39,7 @@ import SchemaDiffDialog from "@/components/diff/SchemaDiffDialog.vue"; import SqlFileExecutionDialog from "@/components/sql-file/SqlFileExecutionDialog.vue"; import SchemaDiagramDialog from "@/components/diagram/SchemaDiagramDialog.vue"; import TableImportDialog from "@/components/import/TableImportDialog.vue"; +import TableStructureEditorDialog from "@/components/structure/TableStructureEditorDialog.vue"; import type { ConnectionConfig } from "@/types/database"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; @@ -124,6 +125,7 @@ const showSchemaDiffDialog = ref(false); const showSqlFileDialog = ref(false); const showDiagramDialog = ref(false); const showTableImportDialog = ref(false); +const showStructureEditorDialog = ref(false); const transferPrefillConnectionId = ref(""); const transferPrefillDatabase = ref(""); const schemaDiffPrefillConnectionId = ref(""); @@ -138,6 +140,10 @@ const tableImportPrefillConnectionId = ref(""); const tableImportPrefillDatabase = ref(""); const tableImportPrefillSchema = ref(""); const tableImportPrefillTable = ref(""); +const structurePrefillConnectionId = ref(""); +const structurePrefillDatabase = ref(""); +const structurePrefillSchema = ref(""); +const structurePrefillTable = ref(""); const databaseOptions = ref>({}); const loadingDatabaseOptions = ref>({}); const checkingUpdates = ref(false); @@ -214,6 +220,39 @@ watch(() => connectionStore.tableImportSource, (v) => { } }); +watch(() => connectionStore.structureEditorSource, (v) => { + if (v) { + structurePrefillConnectionId.value = v.connectionId; + structurePrefillDatabase.value = v.database; + structurePrefillSchema.value = v.schema ?? ""; + structurePrefillTable.value = v.tableName; + showStructureEditorDialog.value = true; + connectionStore.structureEditorSource = null; + } +}); + +async function onStructureEditorSaved() { + const tab = activeTab.value; + if (tab?.mode === "data" && tab.tableMeta?.tableName === structurePrefillTable.value) { + try { + const columns = await api.getColumns( + tab.connectionId, + tab.database, + tab.tableMeta.schema || tab.database, + tab.tableMeta.tableName, + ); + queryStore.setTableMeta(tab.id, { + ...tab.tableMeta, + columns, + primaryKeys: columns.filter((column) => column.is_primary_key).map((column) => column.name), + }); + await onReloadData(); + } catch (e: any) { + toast(e?.message || String(e), 5000); + } + } +} + function onConnectionConnectStarted(name: string) { toast(t("connection.connecting", { name }), 30000); } @@ -1281,6 +1320,14 @@ async function setupFileDrop() { :prefill-schema="tableImportPrefillSchema" :prefill-table="tableImportPrefillTable" /> + diff --git a/src/components/sidebar/TreeItem.vue b/src/components/sidebar/TreeItem.vue index f81a4c8357..2c9428863c 100644 --- a/src/components/sidebar/TreeItem.vue +++ b/src/components/sidebar/TreeItem.vue @@ -5,7 +5,7 @@ import { Database, Table, Columns3, Eye, ChevronRight, ChevronDown, Loader2, FolderOpen, Trash2, TerminalSquare, RefreshCw, Copy, TableProperties, Key, Link, Zap, ListTree, Pencil, Plug, Unplug, - Pin, ArrowRightLeft, Download, FileCode, Network, FileUp, + Pin, ArrowRightLeft, Download, FileCode, Network, FileUp, PencilRuler, } from "lucide-vue-next"; import { ContextMenu, ContextMenuContent, ContextMenuItem, @@ -44,6 +44,7 @@ const props = defineProps<{ const sqlFileUnsupportedTypes = new Set(["redis", "mongodb", "elasticsearch"]); const diagramSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"]); const tableImportSupportedTypes = new Set(["mysql", "postgres", "sqlite", "duckdb", "clickhouse", "sqlserver", "oracle", "doris", "starrocks", "redshift"]); +const tableStructureSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver"]); const isExportingDatabase = ref(false); function currentDatabaseType(): DatabaseType | undefined { @@ -525,6 +526,17 @@ function openTableImport() { }; } +function openStructureEditor() { + const node = props.node; + if (node.type !== "table" || !node.connectionId || !node.database) return; + connectionStore.structureEditorSource = { + connectionId: node.connectionId, + database: node.database, + schema: node.schema, + tableName: node.label, + }; +} + const canExpand = !leafTypes.has(props.node.type); const canPin = computed(() => pinnableTypes.has(props.node.type)); const canOpenSqlFileExecution = computed(() => { @@ -539,6 +551,10 @@ const canOpenTableImport = computed(() => { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; return props.node.type === "table" && !!props.node.database && !!config && tableImportSupportedTypes.has(config.db_type); }); +const canOpenStructureEditor = computed(() => { + const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; + return props.node.type === "table" && !!props.node.database && !!config && tableStructureSupportedTypes.has(config.db_type); +}); const isPinned = computed(() => props.node.pinned || connectionStore.isTreeNodePinned(props.node.id)); const hasTypeMenu = computed(() => { const t = props.node.type; @@ -695,6 +711,9 @@ async function showMore() { {{ t('contextMenu.viewData') }} + + {{ t('contextMenu.editStructure') }} + {{ t('contextMenu.newQuery') }} diff --git a/src/components/structure/TableStructureEditorDialog.vue b/src/components/structure/TableStructureEditorDialog.vue new file mode 100644 index 0000000000..7cb3e046f3 --- /dev/null +++ b/src/components/structure/TableStructureEditorDialog.vue @@ -0,0 +1,406 @@ + + + diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b6e7e74f23..298c5ca612 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -216,6 +216,7 @@ export default { editConnection: "Edit Connection", newQuery: "New Query", viewData: "View Data", + editStructure: "Edit Structure", refreshChildren: "Refresh", pin: "Pin", unpin: "Unpin", @@ -236,6 +237,37 @@ export default { foreignKeys: "Foreign Keys", triggers: "Triggers", }, + structureEditor: { + title: "Edit Table Structure", + refresh: "Refresh Structure", + columns: "Columns", + indexes: "Indexes", + foreignKeys: "Foreign Keys", + triggers: "Triggers", + addColumn: "Add Column", + addIndex: "Add Index", + columnName: "Column", + dataType: "Type", + nullable: "Nullable", + defaultValue: "Default", + comment: "Comment", + actions: "Actions", + indexName: "Index", + indexColumns: "Columns", + unique: "Unique", + primary: "Primary", + drop: "Drop", + restore: "Restore", + remove: "Remove", + yes: "Yes", + no: "No", + emptyReadonly: "No records", + sqlPreview: "SQL Preview", + noChanges: "No changes", + apply: "Apply Changes", + ready: "Ready", + saved: "Table structure updated", + }, diagram: { title: "Relationship Diagram", open: "View Diagram", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index e7c0ae1fdc..a1302c613a 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -216,6 +216,7 @@ export default { editConnection: "编辑连接", newQuery: "新建查询", viewData: "查看数据", + editStructure: "编辑表结构", refreshChildren: "刷新", pin: "置顶", unpin: "取消置顶", @@ -236,6 +237,37 @@ export default { foreignKeys: "外键", triggers: "触发器", }, + structureEditor: { + title: "编辑表结构", + refresh: "刷新结构", + columns: "字段", + indexes: "索引", + foreignKeys: "外键", + triggers: "触发器", + addColumn: "新增字段", + addIndex: "新增索引", + columnName: "字段名", + dataType: "类型", + nullable: "可为空", + defaultValue: "默认值", + comment: "注释", + actions: "操作", + indexName: "索引名", + indexColumns: "字段列表", + unique: "唯一", + primary: "主键", + drop: "删除", + restore: "恢复", + remove: "移除", + yes: "是", + no: "否", + emptyReadonly: "暂无记录", + sqlPreview: "SQL 预览", + noChanges: "暂无变更", + apply: "应用变更", + ready: "可执行", + saved: "表结构已更新", + }, diagram: { title: "关系图", open: "查看关系图", diff --git a/src/lib/tableStructureEditorSql.ts b/src/lib/tableStructureEditorSql.ts new file mode 100644 index 0000000000..9a79637161 --- /dev/null +++ b/src/lib/tableStructureEditorSql.ts @@ -0,0 +1,285 @@ +import type { ColumnInfo, DatabaseType, IndexInfo } from "../types/database.ts"; + +export interface EditableStructureColumn { + id: string; + name: string; + dataType: string; + isNullable: boolean; + defaultValue: string; + comment: string; + isPrimaryKey: boolean; + original?: ColumnInfo; + markedForDrop: boolean; +} + +export interface EditableStructureIndex { + id: string; + name: string; + columns: string[]; + isUnique: boolean; + isPrimary: boolean; + original?: IndexInfo; + markedForDrop: boolean; +} + +export interface BuildTableStructureChangeSqlOptions { + databaseType?: DatabaseType; + schema?: string; + tableName: string; + columns: EditableStructureColumn[]; + indexes: EditableStructureIndex[]; +} + +export interface TableStructureChangeSql { + statements: string[]; + warnings: string[]; +} + +function quoteIdent(databaseType: DatabaseType | undefined, name: string): string { + if (databaseType === "mysql") return `\`${name.replace(/`/g, "``")}\``; + if (databaseType === "sqlserver") return `[${name.replace(/\]/g, "]]")}]`; + return `"${name.replace(/"/g, '""')}"`; +} + +function qualifiedTable(databaseType: DatabaseType | undefined, schema: string | undefined, tableName: string): string { + if ((databaseType === "postgres" || databaseType === "oracle" || databaseType === "sqlserver") && schema) { + return `${quoteIdent(databaseType, schema)}.${quoteIdent(databaseType, tableName)}`; + } + return quoteIdent(databaseType, tableName); +} + +function quoteString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function clean(value: string | null | undefined): string { + return (value ?? "").trim(); +} + +function normalizeDefault(value: string | null | undefined): string { + const trimmed = clean(value); + return trimmed.toLowerCase() === "null" ? "" : trimmed; +} + +function columnDefinition(databaseType: DatabaseType | undefined, column: EditableStructureColumn): string { + const parts = [ + quoteIdent(databaseType, column.name), + column.dataType.trim(), + ]; + if (!column.isNullable) parts.push("NOT NULL"); + const defaultValue = normalizeDefault(column.defaultValue); + if (defaultValue) parts.push(`DEFAULT ${defaultValue}`); + if (databaseType === "mysql" && clean(column.comment)) { + parts.push(`COMMENT ${quoteString(clean(column.comment))}`); + } + return parts.join(" "); +} + +function originalDefault(column: EditableStructureColumn): string { + return normalizeDefault(column.original?.column_default); +} + +function originalComment(column: EditableStructureColumn): string { + return clean(column.original?.comment); +} + +function hasExistingColumnAttributeChange(column: EditableStructureColumn): boolean { + const original = column.original; + if (!original) return false; + return ( + column.name !== original.name || + column.dataType.trim() !== original.data_type.trim() || + column.isNullable !== original.is_nullable || + normalizeDefault(column.defaultValue) !== originalDefault(column) || + clean(column.comment) !== originalComment(column) + ); +} + +function buildAddColumnSql( + databaseType: DatabaseType | undefined, + table: string, + column: EditableStructureColumn, +): string[] { + const addKeyword = databaseType === "sqlserver" ? "ADD" : "ADD COLUMN"; + const statements = [ + `ALTER TABLE ${table} ${addKeyword} ${columnDefinition(databaseType, column)};`, + ]; + if (databaseType === "postgres" && clean(column.comment)) { + statements.push(`COMMENT ON COLUMN ${table}.${quoteIdent(databaseType, column.name)} IS ${quoteString(clean(column.comment))};`); + } + return statements; +} + +function buildMysqlExistingColumnSql(table: string, column: EditableStructureColumn): string[] { + const originalName = column.original?.name ?? column.name; + const operation = column.name === originalName + ? `MODIFY COLUMN ${columnDefinition("mysql", column)}` + : `CHANGE COLUMN ${quoteIdent("mysql", originalName)} ${columnDefinition("mysql", column)}`; + return [`ALTER TABLE ${table} ${operation};`]; +} + +function buildPostgresExistingColumnSql(table: string, column: EditableStructureColumn): string[] { + const original = column.original; + if (!original) return []; + + const statements: string[] = []; + const currentName = column.name; + if (column.name !== original.name) { + statements.push(`ALTER TABLE ${table} RENAME COLUMN ${quoteIdent("postgres", original.name)} TO ${quoteIdent("postgres", column.name)};`); + } + if (column.dataType.trim() !== original.data_type.trim()) { + statements.push(`ALTER TABLE ${table} ALTER COLUMN ${quoteIdent("postgres", currentName)} TYPE ${column.dataType.trim()};`); + } + if (column.isNullable !== original.is_nullable) { + const action = column.isNullable ? "DROP NOT NULL" : "SET NOT NULL"; + statements.push(`ALTER TABLE ${table} ALTER COLUMN ${quoteIdent("postgres", currentName)} ${action};`); + } + if (normalizeDefault(column.defaultValue) !== originalDefault(column)) { + const defaultValue = normalizeDefault(column.defaultValue); + const action = defaultValue ? `SET DEFAULT ${defaultValue}` : "DROP DEFAULT"; + statements.push(`ALTER TABLE ${table} ALTER COLUMN ${quoteIdent("postgres", currentName)} ${action};`); + } + if (clean(column.comment) !== originalComment(column)) { + const commentValue = clean(column.comment) ? quoteString(clean(column.comment)) : "NULL"; + statements.push(`COMMENT ON COLUMN ${table}.${quoteIdent("postgres", currentName)} IS ${commentValue};`); + } + return statements; +} + +function buildSqliteExistingColumnSql( + table: string, + column: EditableStructureColumn, + warnings: string[], +): string[] { + const original = column.original; + if (!original) return []; + + const statements: string[] = []; + const unsupportedChange = ( + column.dataType.trim() !== original.data_type.trim() || + column.isNullable !== original.is_nullable || + normalizeDefault(column.defaultValue) !== originalDefault(column) || + clean(column.comment) !== originalComment(column) + ); + if (column.name !== original.name) { + statements.push(`ALTER TABLE ${table} RENAME COLUMN ${quoteIdent("sqlite", original.name)} TO ${quoteIdent("sqlite", column.name)};`); + } + if (unsupportedChange) { + warnings.push(`SQLite cannot safely alter existing column "${original.name}" without rebuilding the table.`); + } + return statements; +} + +function buildColumnSql( + options: BuildTableStructureChangeSqlOptions, + warnings: string[], +): string[] { + const databaseType = options.databaseType; + const table = qualifiedTable(databaseType, options.schema, options.tableName); + const statements: string[] = []; + + for (const column of options.columns) { + if (column.markedForDrop) { + if (!column.original) continue; + if (column.original.is_primary_key) { + warnings.push(`Primary key column "${column.original.name}" cannot be dropped from this editor.`); + continue; + } + statements.push(`ALTER TABLE ${table} DROP COLUMN ${quoteIdent(databaseType, column.original.name)};`); + continue; + } + + if (!column.original) { + statements.push(...buildAddColumnSql(databaseType, table, column)); + continue; + } + + if (!hasExistingColumnAttributeChange(column)) continue; + if (databaseType === "mysql") { + statements.push(...buildMysqlExistingColumnSql(table, column)); + } else if (databaseType === "postgres") { + statements.push(...buildPostgresExistingColumnSql(table, column)); + } else if (databaseType === "sqlite") { + statements.push(...buildSqliteExistingColumnSql(table, column, warnings)); + } else { + warnings.push(`Editing existing columns is not supported for ${databaseType ?? "this database"} yet.`); + } + } + + return statements; +} + +function buildDropIndexSql( + databaseType: DatabaseType | undefined, + table: string, + schema: string | undefined, + indexName: string, +): string { + if (databaseType === "mysql") return `DROP INDEX ${quoteIdent(databaseType, indexName)} ON ${table};`; + if (databaseType === "sqlserver") return `DROP INDEX ${quoteIdent(databaseType, indexName)} ON ${table};`; + if ((databaseType === "postgres" || databaseType === "oracle") && schema) { + return `DROP INDEX ${quoteIdent(databaseType, schema)}.${quoteIdent(databaseType, indexName)};`; + } + return `DROP INDEX ${quoteIdent(databaseType, indexName)};`; +} + +function buildIndexSql(options: BuildTableStructureChangeSqlOptions, warnings: string[]): string[] { + const databaseType = options.databaseType; + const table = qualifiedTable(databaseType, options.schema, options.tableName); + const statements: string[] = []; + + for (const index of options.indexes) { + if (index.markedForDrop) { + if (!index.original) continue; + if (index.original.is_primary) { + warnings.push(`Primary index "${index.original.name}" cannot be dropped from this editor.`); + continue; + } + statements.push(buildDropIndexSql(databaseType, table, options.schema, index.original.name)); + continue; + } + + if (index.original) continue; + 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((column) => quoteIdent(databaseType, column)).join(", "); + statements.push(`CREATE ${unique}INDEX ${quoteIdent(databaseType, name)} ON ${table} (${cols});`); + } + + return statements; +} + +function validateDraft(options: BuildTableStructureChangeSqlOptions): string[] { + const warnings: string[] = []; + const activeColumns = options.columns.filter((column) => !column.markedForDrop); + const names = new Set(); + + for (const column of activeColumns) { + if (!clean(column.name)) warnings.push("Column name cannot be empty."); + if (!clean(column.dataType)) warnings.push(`Column "${column.name || "(new)"}" type cannot be empty.`); + const key = clean(column.name).toLowerCase(); + if (key && names.has(key)) warnings.push(`Column "${column.name}" is duplicated.`); + if (key) names.add(key); + } + + for (const index of options.indexes.filter((idx) => !idx.markedForDrop && !idx.original)) { + if (!clean(index.name)) warnings.push("Index name cannot be empty."); + if (index.columns.map(clean).filter(Boolean).length === 0) { + warnings.push(`Index "${index.name || "(new)"}" needs at least one column.`); + } + } + + return warnings; +} + +export function buildTableStructureChangeSql(options: BuildTableStructureChangeSqlOptions): TableStructureChangeSql { + const warnings = validateDraft(options); + const statements = [ + ...buildColumnSql(options, warnings), + ...buildIndexSql(options, warnings), + ]; + + return { statements, warnings }; +} diff --git a/src/lib/tableStructureEditorState.ts b/src/lib/tableStructureEditorState.ts new file mode 100644 index 0000000000..a5f73f4ed1 --- /dev/null +++ b/src/lib/tableStructureEditorState.ts @@ -0,0 +1,39 @@ +import type { ColumnInfo, IndexInfo } from "../types/database.ts"; +import type { EditableStructureColumn, EditableStructureIndex } from "./tableStructureEditorSql.ts"; + +export function createColumnDrafts(columns: ColumnInfo[]): EditableStructureColumn[] { + return columns.map((column) => ({ + id: `existing:${column.name}`, + name: column.name, + dataType: column.data_type, + isNullable: column.is_nullable, + defaultValue: column.column_default ?? "", + comment: column.comment ?? "", + isPrimaryKey: column.is_primary_key, + original: column, + markedForDrop: false, + })); +} + +export function createIndexDrafts(indexes: IndexInfo[]): EditableStructureIndex[] { + return indexes.map((index) => ({ + id: `existing:${index.name}`, + name: index.name, + columns: [...index.columns], + isUnique: index.is_unique, + isPrimary: index.is_primary, + original: index, + markedForDrop: false, + })); +} + +export function splitIndexColumns(value: string): string[] { + return value + .split(/[,\s]+/g) + .map((part) => part.trim()) + .filter(Boolean); +} + +export function toColumnNames(columns: string[]): string { + return columns.join(", "); +} diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index 9da7ec1fb1..c63527510f 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -21,6 +21,7 @@ export const useConnectionStore = defineStore("connection", () => { const sqlFileSource = ref<{ connectionId: string; database: string } | null>(null); const diagramSource = ref<{ connectionId: string; database: string; schema?: string; tableName?: string } | null>(null); const tableImportSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string } | null>(null); + const structureEditorSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string } | null>(null); function startEditing(id: string) { editingConnectionId.value = id; @@ -659,5 +660,6 @@ export const useConnectionStore = defineStore("connection", () => { sqlFileSource, diagramSource, tableImportSource, + structureEditorSource, }; }); diff --git a/tests/tableStructureEditorSql.test.ts b/tests/tableStructureEditorSql.test.ts new file mode 100644 index 0000000000..ee8ae087b9 --- /dev/null +++ b/tests/tableStructureEditorSql.test.ts @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildTableStructureChangeSql, + type EditableStructureColumn, + type EditableStructureIndex, +} from "../src/lib/tableStructureEditorSql.ts"; + +function column(overrides: Partial): EditableStructureColumn { + return { + id: overrides.id ?? "col", + name: overrides.name ?? "name", + dataType: overrides.dataType ?? "varchar(255)", + isNullable: overrides.isNullable ?? true, + defaultValue: overrides.defaultValue ?? "", + comment: overrides.comment ?? "", + isPrimaryKey: overrides.isPrimaryKey ?? false, + original: overrides.original, + markedForDrop: overrides.markedForDrop ?? false, + }; +} + +function index(overrides: Partial): EditableStructureIndex { + return { + id: overrides.id ?? "idx", + name: overrides.name ?? "idx_name", + columns: overrides.columns ?? ["name"], + isUnique: overrides.isUnique ?? false, + isPrimary: overrides.isPrimary ?? false, + original: overrides.original, + markedForDrop: overrides.markedForDrop ?? false, + }; +} + +test("builds MySQL column and index change statements", () => { + const result = buildTableStructureChangeSql({ + databaseType: "mysql", + tableName: "users", + columns: [ + column({ + id: "name", + name: "display_name", + dataType: "varchar(120)", + isNullable: false, + defaultValue: "'guest'", + comment: "Shown name", + original: { + name: "name", + data_type: "varchar(80)", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + comment: "", + }, + }), + column({ id: "email", name: "email", dataType: "varchar(255)", isNullable: false }), + column({ + id: "legacy", + name: "legacy", + markedForDrop: true, + original: { + name: "legacy", + data_type: "text", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + }, + }), + ], + indexes: [ + index({ + id: "old", + name: "idx_old", + markedForDrop: true, + original: { name: "idx_old", columns: ["name"], is_unique: false, is_primary: false }, + }), + index({ id: "email_idx", name: "uniq_users_email", columns: ["email"], isUnique: true }), + ], + }); + + assert.deepEqual(result.warnings, []); + assert.deepEqual(result.statements, [ + "ALTER TABLE `users` CHANGE COLUMN `name` `display_name` varchar(120) NOT NULL DEFAULT 'guest' COMMENT 'Shown name';", + "ALTER TABLE `users` ADD COLUMN `email` varchar(255) NOT NULL;", + "ALTER TABLE `users` DROP COLUMN `legacy`;", + "DROP INDEX `idx_old` ON `users`;", + "CREATE UNIQUE INDEX `uniq_users_email` ON `users` (`email`);", + ]); +}); + +test("builds PostgreSQL rename, type, default, comment, and index statements", () => { + const result = buildTableStructureChangeSql({ + databaseType: "postgres", + schema: "public", + tableName: "accounts", + columns: [ + column({ + id: "status", + name: "account_status", + dataType: "text", + isNullable: false, + defaultValue: "'active'", + comment: "Current status", + original: { + name: "status", + data_type: "varchar", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + comment: "", + }, + }), + ], + indexes: [ + index({ id: "new", name: "idx_accounts_status", columns: ["account_status"] }), + ], + }); + + assert.deepEqual(result.warnings, []); + assert.deepEqual(result.statements, [ + "ALTER TABLE \"public\".\"accounts\" RENAME COLUMN \"status\" TO \"account_status\";", + "ALTER TABLE \"public\".\"accounts\" ALTER COLUMN \"account_status\" TYPE text;", + "ALTER TABLE \"public\".\"accounts\" ALTER COLUMN \"account_status\" SET NOT NULL;", + "ALTER TABLE \"public\".\"accounts\" ALTER COLUMN \"account_status\" SET DEFAULT 'active';", + "COMMENT ON COLUMN \"public\".\"accounts\".\"account_status\" IS 'Current status';", + "CREATE INDEX \"idx_accounts_status\" ON \"public\".\"accounts\" (\"account_status\");", + ]); +}); + +test("warns when SQLite cannot safely alter existing column attributes", () => { + const result = buildTableStructureChangeSql({ + databaseType: "sqlite", + tableName: "notes", + columns: [ + column({ + id: "title", + name: "title", + dataType: "text", + isNullable: false, + original: { + name: "title", + data_type: "varchar(100)", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + }, + }), + column({ id: "body", name: "body", dataType: "text", isNullable: true }), + ], + indexes: [], + }); + + assert.deepEqual(result.statements, [ + "ALTER TABLE \"notes\" ADD COLUMN \"body\" text;", + ]); + assert.deepEqual(result.warnings, [ + "SQLite cannot safely alter existing column \"title\" without rebuilding the table.", + ]); +}); + +test("quotes SQL Server table, column, and index names with brackets", () => { + const result = buildTableStructureChangeSql({ + databaseType: "sqlserver", + schema: "dbo", + tableName: "users", + columns: [ + column({ id: "email", name: "email", dataType: "nvarchar(255)", isNullable: false }), + ], + indexes: [ + index({ id: "idx", name: "idx_users_email", columns: ["email"] }), + ], + }); + + assert.deepEqual(result.warnings, []); + assert.deepEqual(result.statements, [ + "ALTER TABLE [dbo].[users] ADD [email] nvarchar(255) NOT NULL;", + "CREATE INDEX [idx_users_email] ON [dbo].[users] ([email]);", + ]); +}); diff --git a/tests/tableStructureEditorState.test.ts b/tests/tableStructureEditorState.test.ts new file mode 100644 index 0000000000..2b4ec35b3c --- /dev/null +++ b/tests/tableStructureEditorState.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createColumnDrafts, + createIndexDrafts, + splitIndexColumns, + toColumnNames, +} from "../src/lib/tableStructureEditorState.ts"; +import type { ColumnInfo, IndexInfo } from "../src/types/database.ts"; + +const columns: ColumnInfo[] = [ + { + name: "id", + data_type: "bigint", + is_nullable: false, + column_default: null, + is_primary_key: true, + extra: "auto_increment", + comment: "identifier", + }, + { + name: "name", + data_type: "varchar(120)", + is_nullable: true, + column_default: "'guest'", + is_primary_key: false, + extra: null, + comment: null, + }, +]; + +const indexes: IndexInfo[] = [ + { name: "PRIMARY", columns: ["id"], is_unique: true, is_primary: true }, + { name: "idx_name", columns: ["name"], is_unique: false, is_primary: false }, +]; + +test("creates editable column drafts from column metadata", () => { + const drafts = createColumnDrafts(columns); + + assert.deepEqual(drafts.map((draft) => ({ + id: draft.id, + name: draft.name, + dataType: draft.dataType, + isNullable: draft.isNullable, + defaultValue: draft.defaultValue, + comment: draft.comment, + isPrimaryKey: draft.isPrimaryKey, + markedForDrop: draft.markedForDrop, + originalName: draft.original?.name, + })), [ + { + id: "existing:id", + name: "id", + dataType: "bigint", + isNullable: false, + defaultValue: "", + comment: "identifier", + isPrimaryKey: true, + markedForDrop: false, + originalName: "id", + }, + { + id: "existing:name", + name: "name", + dataType: "varchar(120)", + isNullable: true, + defaultValue: "'guest'", + comment: "", + isPrimaryKey: false, + markedForDrop: false, + originalName: "name", + }, + ]); +}); + +test("creates editable index drafts and splits pasted column lists", () => { + const drafts = createIndexDrafts(indexes); + + assert.deepEqual(drafts.map((draft) => ({ + id: draft.id, + name: draft.name, + columns: draft.columns, + isUnique: draft.isUnique, + isPrimary: draft.isPrimary, + originalName: draft.original?.name, + })), [ + { + id: "existing:PRIMARY", + name: "PRIMARY", + columns: ["id"], + isUnique: true, + isPrimary: true, + originalName: "PRIMARY", + }, + { + id: "existing:idx_name", + name: "idx_name", + columns: ["name"], + isUnique: false, + isPrimary: false, + originalName: "idx_name", + }, + ]); + assert.deepEqual(splitIndexColumns("id, name email"), ["id", "name", "email"]); + assert.equal(toColumnNames(["id", "name"]), "id, name"); +});