Files
dbx/src/lib/tableStructureEditorSql.ts
T
yavon007 a59494e4c4 fix: guard index column splits, restrict INCLUDE/WHERE by db, unify index type dropdowns
- Filter empty strings from STRING_AGG/LISTAGG splits in SQL Server, Oracle, MySQL
- Emit INCLUDE clause only for PostgreSQL and SQL Server (not MySQL/SQLite)
- Emit WHERE clause only for PostgreSQL, SQL Server, SQLite (not MySQL)
- Replace index type free-text input with dropdown for all 5 database types
- Fix hardcoded column count in resize handle (use indexColLabels.length - 1)
- Fix pg_get_indexdef type mismatch: cast k.n from bigint to integer
2026-05-03 20:33:09 +08:00

302 lines
12 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 };
}