diff --git a/apps/desktop/src/components/structure/TableStructureEditorDialog.vue b/apps/desktop/src/components/structure/TableStructureEditorDialog.vue index 8e8665f155..0c42bf8889 100644 --- a/apps/desktop/src/components/structure/TableStructureEditorDialog.vue +++ b/apps/desktop/src/components/structure/TableStructureEditorDialog.vue @@ -11,6 +11,7 @@ import { AlertTriangle, Check, ChevronDown, + ChevronUp, Database, KeyRound, Loader2, @@ -224,6 +225,21 @@ function removeNewColumn(column: EditableStructureColumn) { columns.value = columns.value.filter((item) => item.id !== column.id); } +function canMoveColumn(index: number, direction: -1 | 1): boolean { + const targetIndex = index + direction; + if (targetIndex < 0 || targetIndex >= columns.value.length) return false; + if (columns.value[index]?.markedForDrop || columns.value[targetIndex]?.markedForDrop) return false; + return isCreateMode.value || structureCapabilities.value.reorderColumn; +} + +function moveColumn(index: number, direction: -1 | 1) { + if (!canMoveColumn(index, direction)) return; + const targetIndex = index + direction; + const [column] = columns.value.splice(index, 1); + if (!column) return; + columns.value.splice(targetIndex, 0, column); +} + function toggleDropColumn(column: EditableStructureColumn) { if (!canDropColumn(column)) return; column.markedForDrop = !column.markedForDrop; @@ -455,7 +471,7 @@ watch( {{ t("structureEditor.comment") }} - + {{ t("structureEditor.actions") }} @@ -544,21 +560,45 @@ watch( - - +
+ + + + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index ac59ccab1e..ba56f4ab30 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -846,6 +846,8 @@ export default { drop: "Drop", restore: "Restore", remove: "Remove", + moveColumnUp: "Move column up", + moveColumnDown: "Move column down", yes: "Yes", no: "No", emptyReadonly: "No records", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 8ebcf6b2f4..b27dbcc6b7 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -743,6 +743,8 @@ export default { drop: "Eliminar", restore: "Restaurar", remove: "Quitar", + moveColumnUp: "Mover columna arriba", + moveColumnDown: "Mover columna abajo", yes: "Sí", no: "No", emptyReadonly: "Sin registros", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index cdf2b01e0b..87cae4dfa1 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -827,6 +827,8 @@ export default { drop: "删除", restore: "恢复", remove: "移除", + moveColumnUp: "上移字段", + moveColumnDown: "下移字段", yes: "是", no: "否", emptyReadonly: "暂无记录", diff --git a/apps/desktop/src/lib/tableStructureCapabilities.ts b/apps/desktop/src/lib/tableStructureCapabilities.ts index c29423c0bd..c6b974f9ce 100644 --- a/apps/desktop/src/lib/tableStructureCapabilities.ts +++ b/apps/desktop/src/lib/tableStructureCapabilities.ts @@ -21,6 +21,7 @@ export interface TableStructureCapabilities { alterType: boolean; alterNullability: boolean; alterDefault: boolean; + reorderColumn: boolean; comment: boolean; createIndex: boolean; dropIndex: boolean; @@ -41,6 +42,7 @@ const unsupportedCapabilities: TableStructureCapabilities = { alterType: false, alterNullability: false, alterDefault: false, + reorderColumn: false, comment: false, createIndex: false, dropIndex: false, @@ -65,6 +67,7 @@ const mysqlCapabilities = capabilities({ alterType: true, alterNullability: true, alterDefault: true, + reorderColumn: true, comment: true, createIndex: true, dropIndex: true, @@ -182,6 +185,7 @@ const clickhouseCapabilities = capabilities({ alterType: true, alterNullability: true, alterDefault: true, + reorderColumn: true, comment: true, }); diff --git a/apps/desktop/src/lib/tableStructureEditorSql.ts b/apps/desktop/src/lib/tableStructureEditorSql.ts index 1ca0be0133..71115d84e4 100644 --- a/apps/desktop/src/lib/tableStructureEditorSql.ts +++ b/apps/desktop/src/lib/tableStructureEditorSql.ts @@ -10,6 +10,7 @@ export interface EditableStructureColumn { comment: string; isPrimaryKey: boolean; original?: ColumnInfo; + originalPosition?: number; markedForDrop: boolean; } @@ -130,12 +131,17 @@ function hasExistingColumnAttributeChange(column: EditableStructureColumn): bool ); } -function buildAddColumnSql(databaseType: StructureSqlFlavor, table: string, column: EditableStructureColumn): string[] { +function buildAddColumnSql( + databaseType: StructureSqlFlavor, + table: string, + column: EditableStructureColumn, + positionClause = "", +): string[] { const addKeyword = databaseType === "sqlserver" ? "ADD" : "ADD COLUMN"; const definition = columnDefinition(databaseType, column); const statements = isOracleLike(databaseType) ? [`ALTER TABLE ${table} ADD (${definition});`] - : [`ALTER TABLE ${table} ${addKeyword} ${definition};`]; + : [`ALTER TABLE ${table} ${addKeyword} ${definition}${positionClause};`]; if ((databaseType === "postgres" || isOracleLike(databaseType)) && clean(column.comment)) { statements.push( `COMMENT ON COLUMN ${table}.${quoteIdent(databaseType, column.name)} IS ${quoteString(clean(column.comment))};`, @@ -185,13 +191,13 @@ function buildOracleLikeExistingColumnSql( return statements; } -function buildMysqlExistingColumnSql(table: string, column: EditableStructureColumn): string[] { +function buildMysqlExistingColumnSql(table: string, column: EditableStructureColumn, positionClause = ""): 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};`]; + return [`ALTER TABLE ${table} ${operation}${positionClause};`]; } function buildPostgresExistingColumnSql(table: string, column: EditableStructureColumn): string[] { @@ -259,7 +265,11 @@ function buildH2ExistingColumnSql(table: string, column: EditableStructureColumn return statements; } -function buildClickHouseExistingColumnSql(table: string, column: EditableStructureColumn): string[] { +function buildClickHouseExistingColumnSql( + table: string, + column: EditableStructureColumn, + positionClause = "", +): string[] { const original = column.original; if (!original) return []; @@ -278,20 +288,28 @@ function buildClickHouseExistingColumnSql(table: string, column: EditableStructu const defaultValue = normalizeDefault(column.defaultValue); if (defaultValue) { statements.push( - `ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)} DEFAULT ${defaultValue};`, + `ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)} DEFAULT ${defaultValue}${positionClause};`, ); } else if (originalDefault(column)) { statements.push(`ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} REMOVE DEFAULT;`); if (clickHouseColumnType(column) !== original.data_type.trim()) { statements.push( - `ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)};`, + `ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)}${positionClause};`, + ); + } else if (positionClause) { + statements.push( + `ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)}${positionClause};`, ); } } else { statements.push( - `ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)};`, + `ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)}${positionClause};`, ); } + } else if (positionClause) { + statements.push( + `ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)}${positionClause};`, + ); } if (clean(column.comment) !== originalComment(column)) { statements.push( @@ -322,6 +340,41 @@ function buildSqliteExistingColumnSql(table: string, column: EditableStructureCo return statements; } +function columnPositionClause( + dialect: TableStructureDialect, + columns: EditableStructureColumn[], + index: number, +): string { + if (dialect !== "mysql" && dialect !== "clickhouse") return ""; + if (index <= 0) return " FIRST"; + return ` AFTER ${quoteIdent(dialect, columns[index - 1]?.name ?? "")}`; +} + +function originalPreviousColumnName( + columns: EditableStructureColumn[], + column: EditableStructureColumn, +): string | null { + if (!column.original || typeof column.originalPosition !== "number") return null; + const originalColumns = columns + .filter((item) => item.original && typeof item.originalPosition === "number") + .sort((a, b) => (a.originalPosition ?? 0) - (b.originalPosition ?? 0)); + const index = originalColumns.findIndex((item) => item.id === column.id); + if (index < 0) return null; + return index === 0 ? null : (originalColumns[index - 1]?.original?.name ?? null); +} + +function currentPreviousOriginalColumnName(columns: EditableStructureColumn[], index: number): string | null { + if (index <= 0) return null; + const previous = columns[index - 1]; + return previous?.original?.name ?? previous?.name ?? null; +} + +function mysqlColumnPositionChanged(columns: EditableStructureColumn[], index: number): boolean { + const column = columns[index]; + if (!column?.original || typeof column.originalPosition !== "number") return false; + return currentPreviousOriginalColumnName(columns, index) !== originalPreviousColumnName(columns, column); +} + function buildColumnSql(options: BuildTableStructureChangeSqlOptions, warnings: string[]): string[] { const databaseType = options.databaseType; const capabilities = getTableStructureCapabilities(databaseType); @@ -329,6 +382,8 @@ function buildColumnSql(options: BuildTableStructureChangeSqlOptions, warnings: const table = qualifiedTable(dialect, options.schema, options.tableName); const statements: string[] = []; const databaseLabel = databaseType ?? "this database"; + const activeColumns = options.columns.filter((column) => !column.markedForDrop); + const hasOriginalColumnPositions = activeColumns.some((column) => typeof column.originalPosition === "number"); for (const column of options.columns) { if (column.markedForDrop) { @@ -345,16 +400,24 @@ function buildColumnSql(options: BuildTableStructureChangeSqlOptions, warnings: continue; } + const activeIndex = activeColumns.indexOf(column); + const positionClause = hasOriginalColumnPositions ? columnPositionClause(dialect, activeColumns, activeIndex) : ""; + const hasPositionChange = + hasOriginalColumnPositions && + (dialect === "mysql" || dialect === "clickhouse") && + !!column.original && + mysqlColumnPositionChanged(activeColumns, activeIndex); + if (!column.original) { if (!capabilities.addColumn) { warnings.push(`Adding columns is not supported for ${databaseLabel} from this editor.`); continue; } - statements.push(...buildAddColumnSql(dialect, table, column)); + statements.push(...buildAddColumnSql(dialect, table, column, positionClause)); continue; } - if (!hasExistingColumnAttributeChange(column)) continue; + if (!hasExistingColumnAttributeChange(column) && !hasPositionChange) continue; const original = column.original; const hasRename = column.name !== original.name; const hasAttributeChange = @@ -362,6 +425,9 @@ function buildColumnSql(options: BuildTableStructureChangeSqlOptions, warnings: column.isNullable !== original.is_nullable || normalizeDefault(column.defaultValue) !== originalDefault(column) || clean(column.comment) !== originalComment(column); + if (hasPositionChange && !capabilities.reorderColumn) { + warnings.push(`Reordering columns is not supported for ${databaseLabel} from this editor.`); + } if (hasRename && !capabilities.renameColumn) { warnings.push(`Renaming columns is not supported for ${databaseLabel} from this editor.`); } @@ -369,13 +435,14 @@ function buildColumnSql(options: BuildTableStructureChangeSqlOptions, warnings: warnings.push(`Editing existing columns is not supported for ${databaseLabel} yet.`); } if ( + (hasPositionChange && !capabilities.reorderColumn) || (hasRename && !capabilities.renameColumn) || (hasAttributeChange && !capabilities.alterExistingColumn && dialect !== "sqlite") ) { continue; } if (dialect === "mysql") { - statements.push(...buildMysqlExistingColumnSql(table, column)); + statements.push(...buildMysqlExistingColumnSql(table, column, hasPositionChange ? positionClause : "")); } else if (dialect === "postgres") { statements.push(...buildPostgresExistingColumnSql(table, column)); } else if (dialect === "oracle") { @@ -383,7 +450,7 @@ function buildColumnSql(options: BuildTableStructureChangeSqlOptions, warnings: } else if (dialect === "h2") { statements.push(...buildH2ExistingColumnSql(table, column)); } else if (dialect === "clickhouse") { - statements.push(...buildClickHouseExistingColumnSql(table, column)); + statements.push(...buildClickHouseExistingColumnSql(table, column, hasPositionChange ? positionClause : "")); } else if (dialect === "sqlite") { statements.push(...buildSqliteExistingColumnSql(table, column, warnings)); } else { diff --git a/apps/desktop/src/lib/tableStructureEditorState.ts b/apps/desktop/src/lib/tableStructureEditorState.ts index 717e63c8ce..011b9707dc 100644 --- a/apps/desktop/src/lib/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/tableStructureEditorState.ts @@ -2,7 +2,7 @@ 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) => ({ + return columns.map((column, index) => ({ id: `existing:${column.name}`, name: column.name, dataType: column.data_type, @@ -11,6 +11,7 @@ export function createColumnDrafts(columns: ColumnInfo[]): EditableStructureColu comment: column.comment ?? "", isPrimaryKey: column.is_primary_key, original: column, + originalPosition: index, markedForDrop: false, })); } diff --git a/packages/app-tests/tableStructureCapabilities.test.ts b/packages/app-tests/tableStructureCapabilities.test.ts index 93c20797dd..560c2caf14 100644 --- a/packages/app-tests/tableStructureCapabilities.test.ts +++ b/packages/app-tests/tableStructureCapabilities.test.ts @@ -31,6 +31,7 @@ test("mysql-like databases expose index rebuild and type capabilities", () => { assert.equal(caps.dropIndex, true); assert.equal(caps.rebuildIndex, true); assert.equal(caps.indexType, true); + assert.equal(caps.reorderColumn, true); assert.equal(canEditTableStructure(dbType), true); } }); @@ -79,6 +80,7 @@ test("limited analytic engines can open the editor for supported operations only assert.equal(clickhouse.alterType, true); assert.equal(clickhouse.alterNullability, true); assert.equal(clickhouse.alterDefault, true); + assert.equal(clickhouse.reorderColumn, true); assert.equal(clickhouse.comment, true); assert.equal(clickhouse.createIndex, false); assert.equal(clickhouse.rebuildIndex, false); diff --git a/packages/app-tests/tableStructureEditorPresentation.test.ts b/packages/app-tests/tableStructureEditorPresentation.test.ts index 419c83a615..3af2505a03 100644 --- a/packages/app-tests/tableStructureEditorPresentation.test.ts +++ b/packages/app-tests/tableStructureEditorPresentation.test.ts @@ -42,3 +42,11 @@ test("structure editor gates controls through table structure capabilities", () assert.match(source, /structureCapabilities\.value\.indexInclude/); assert.match(source, /structureCapabilities\.value\.indexFilter/); }); + +test("structure editor exposes column order controls", () => { + assert.match(source, /function moveColumn/); + assert.match(source, /@click="moveColumn\(index, -1\)"/); + assert.match(source, /@click="moveColumn\(index, 1\)"/); + assert.match(source, /t\(['"]structureEditor\.moveColumnUp['"]\)/); + assert.match(source, /t\(['"]structureEditor\.moveColumnDown['"]\)/); +}); diff --git a/packages/app-tests/tableStructureEditorSql.test.ts b/packages/app-tests/tableStructureEditorSql.test.ts index 628b17655c..a70ba41594 100644 --- a/packages/app-tests/tableStructureEditorSql.test.ts +++ b/packages/app-tests/tableStructureEditorSql.test.ts @@ -17,6 +17,7 @@ function column(overrides: Partial): EditableStructureC comment: overrides.comment ?? "", isPrimaryKey: overrides.isPrimaryKey ?? false, original: overrides.original, + originalPosition: overrides.originalPosition, markedForDrop: overrides.markedForDrop ?? false, }; } @@ -95,6 +96,66 @@ test("builds MySQL column and index change statements", () => { ]); }); +test("builds MySQL column reorder statements", () => { + const result = buildTableStructureChangeSql({ + databaseType: "mysql", + tableName: "users", + columns: [ + column({ + id: "id", + name: "id", + dataType: "int", + isNullable: false, + isPrimaryKey: true, + originalPosition: 0, + original: { + name: "id", + data_type: "int", + is_nullable: false, + column_default: null, + is_primary_key: true, + extra: null, + }, + }), + column({ + id: "email", + name: "email", + dataType: "varchar(255)", + originalPosition: 2, + original: { + name: "email", + data_type: "varchar(255)", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + }, + }), + column({ + id: "name", + name: "display_name", + dataType: "varchar(120)", + originalPosition: 1, + original: { + name: "name", + data_type: "varchar(80)", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + }, + }), + ], + indexes: [], + }); + + assert.deepEqual(result.warnings, []); + assert.deepEqual(result.statements, [ + "ALTER TABLE `users` MODIFY COLUMN `email` varchar(255) AFTER `id`;", + "ALTER TABLE `users` CHANGE COLUMN `name` `display_name` varchar(120) AFTER `email`;", + ]); +}); + test("builds PostgreSQL rename, type, default, comment, and index statements", () => { const result = buildTableStructureChangeSql({ databaseType: "postgres", @@ -823,6 +884,52 @@ test("builds ClickHouse nullable and comment column changes", () => { ]); }); +test("builds ClickHouse column reorder statements", () => { + const result = buildTableStructureChangeSql({ + databaseType: "clickhouse", + tableName: "events", + columns: [ + column({ + id: "event_type", + name: "event_type", + dataType: "String", + isNullable: false, + originalPosition: 1, + original: { + name: "event_type", + data_type: "String", + is_nullable: false, + column_default: null, + is_primary_key: false, + extra: null, + }, + }), + column({ + id: "ts", + name: "ts", + dataType: "DateTime", + isNullable: false, + originalPosition: 0, + original: { + name: "ts", + data_type: "DateTime", + is_nullable: false, + column_default: null, + is_primary_key: false, + extra: null, + }, + }), + ], + indexes: [], + }); + + assert.deepEqual(result.warnings, []); + assert.deepEqual(result.statements, [ + 'ALTER TABLE "events" MODIFY COLUMN "event_type" String FIRST;', + 'ALTER TABLE "events" MODIFY COLUMN "ts" DateTime AFTER "event_type";', + ]); +}); + test("builds H2 schema-qualified existing column statements", () => { const result = buildTableStructureChangeSql({ databaseType: "h2", diff --git a/packages/app-tests/tableStructureEditorState.test.ts b/packages/app-tests/tableStructureEditorState.test.ts index 5cac0133d9..c7e4da084c 100644 --- a/packages/app-tests/tableStructureEditorState.test.ts +++ b/packages/app-tests/tableStructureEditorState.test.ts @@ -37,70 +37,79 @@ const indexes: IndexInfo[] = [ 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", - }, - ]); + 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, + originalPosition: draft.originalPosition, + markedForDrop: draft.markedForDrop, + originalName: draft.original?.name, + })), + [ + { + id: "existing:id", + name: "id", + dataType: "bigint", + isNullable: false, + defaultValue: "", + comment: "identifier", + isPrimaryKey: true, + originalPosition: 0, + markedForDrop: false, + originalName: "id", + }, + { + id: "existing:name", + name: "name", + dataType: "varchar(120)", + isNullable: true, + defaultValue: "'guest'", + comment: "", + isPrimaryKey: false, + originalPosition: 1, + 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( + 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.equal(toColumnNames(["id", "name"]), "id, name"); });