feat(structure): support column reordering

This commit is contained in:
t8y2 committed 2026-05-22 16:43:14 +08:00
1 parent 2cade12b54
commit b0a4061484
11 files changed
+332 -88

No files matched your search

@@ -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(
<th class="min-w-36 border-b border-r px-2 py-2 text-left">
{{ t("structureEditor.comment") }}
</th>
<th class="w-24 border-b px-2 py-2 text-left">
<th class="w-40 border-b px-2 py-2 text-left">
{{ t("structureEditor.actions") }}
</th>
</tr>
@@ -544,21 +560,45 @@ watch(
</div>
</td>
<td class="border-b px-2 py-1.5">
<Button
v-if="column.original"
variant="ghost"
size="sm"
class="h-7 gap-1"
:disabled="!canDropColumn(column)"
@click="toggleDropColumn(column)"
>
<Trash2 class="h-3.5 w-3.5" />
{{ column.markedForDrop ? t("structureEditor.restore") : t("structureEditor.drop") }}
</Button>
<Button v-else variant="ghost" size="sm" class="h-7 gap-1" @click="removeNewColumn(column)">
<X class="h-3.5 w-3.5" />
{{ t("structureEditor.remove") }}
</Button>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="!canMoveColumn(index, -1)"
:title="t('structureEditor.moveColumnUp')"
:aria-label="t('structureEditor.moveColumnUp')"
@click="moveColumn(index, -1)"
>
<ChevronUp class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="!canMoveColumn(index, 1)"
:title="t('structureEditor.moveColumnDown')"
:aria-label="t('structureEditor.moveColumnDown')"
@click="moveColumn(index, 1)"
>
<ChevronDown class="h-3.5 w-3.5" />
</Button>
<Button
v-if="column.original"
variant="ghost"
size="sm"
class="h-7 gap-1"
:disabled="!canDropColumn(column)"
@click="toggleDropColumn(column)"
>
<Trash2 class="h-3.5 w-3.5" />
{{ column.markedForDrop ? t("structureEditor.restore") : t("structureEditor.drop") }}
</Button>
<Button v-else variant="ghost" size="sm" class="h-7 gap-1" @click="removeNewColumn(column)">
<X class="h-3.5 w-3.5" />
{{ t("structureEditor.remove") }}
</Button>
</div>
</td>
</tr>
</tbody>
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -827,6 +827,8 @@ export default {
drop: "删除",
restore: "恢复",
remove: "移除",
moveColumnUp: "上移字段",
moveColumnDown: "下移字段",
yes: "是",
no: "否",
emptyReadonly: "暂无记录",
@@ -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,
});
+79 -12
View File
@@ -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 {
@@ -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,
}));
}