mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-29 16:18:55 +00:00
373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
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;
|
|
filter: string;
|
|
indexType: string;
|
|
includedColumns: string[];
|
|
comment: string;
|
|
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(", ");
|
|
const idxType = clean(index.indexType);
|
|
const usingClause = idxType && databaseType === "postgres" ? ` USING ${idxType}` : "";
|
|
const typePrefix = idxType && databaseType === "sqlserver" ? `${idxType} ` : "";
|
|
const incCols = index.includedColumns.map(clean).filter(Boolean);
|
|
const includeClause =
|
|
incCols.length > 0 && (databaseType === "postgres" || databaseType === "sqlserver")
|
|
? ` INCLUDE (${incCols.map((c) => quoteIdent(databaseType, c)).join(", ")})`
|
|
: "";
|
|
const filter = clean(index.filter);
|
|
const supportsWhere = databaseType === "postgres" || databaseType === "sqlserver" || databaseType === "sqlite";
|
|
const whereClause = filter && supportsWhere ? ` WHERE ${filter}` : "";
|
|
statements.push(
|
|
`CREATE ${unique}${typePrefix}INDEX ${quoteIdent(databaseType, name)} ON ${table}${usingClause} (${cols})${includeClause}${whereClause};`,
|
|
);
|
|
const comment = clean(index.comment);
|
|
if (comment && databaseType === "postgres") {
|
|
statements.push(`COMMENT ON INDEX ${quoteIdent(databaseType, name)} IS ${quoteString(comment)};`);
|
|
}
|
|
}
|
|
|
|
return statements;
|
|
}
|
|
|
|
function validateDraft(options: BuildTableStructureChangeSqlOptions): string[] {
|
|
const warnings: string[] = [];
|
|
const activeColumns = options.columns.filter((column) => !column.markedForDrop);
|
|
const names = new Set<string>();
|
|
|
|
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 };
|
|
}
|
|
|
|
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<string>();
|
|
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 };
|
|
}
|