Merge pull request #68 from yavon007/main

feat: improve column type precision and index metadata across all databases
This commit is contained in:
skyler 2026-05-03 21:17:36 +08:00 committed by GitHub
commit ea0dce23e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 706 additions and 96 deletions

View File

@ -51,6 +51,7 @@ fn duckdb_query_columns(con: &duckdb::Connection, table: &str) -> Result<Vec<db:
extra: None, comment: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
})
}).map_err(|e| e.to_string())?;
Ok(rows.filter_map(|r| r.ok()).collect())
@ -443,7 +444,13 @@ async fn pg_ddl(pool: &sqlx::postgres::PgPool, schema: &str, table: &str) -> Res
if idx.is_primary { continue; }
let unique = if idx.is_unique { "UNIQUE " } else { "" };
let cols = idx.columns.iter().map(|c| format!("\"{c}\"")).collect::<Vec<_>>().join(", ");
ddl.push_str(&format!("\nCREATE {unique}INDEX \"{}\" ON \"{schema}\".\"{table}\" ({cols});", idx.name));
let using = idx.index_type.as_deref().map(|t| format!(" USING {t}")).unwrap_or_default();
let include = idx.included_columns.as_deref().filter(|c| !c.is_empty()).map(|cols| format!(" INCLUDE ({})", cols.iter().map(|c| format!("\"{c}\"")).collect::<Vec<_>>().join(", "))).unwrap_or_default();
let filter = idx.filter.as_deref().map(|f| format!(" WHERE {f}")).unwrap_or_default();
ddl.push_str(&format!("\nCREATE {unique}INDEX \"{}\" ON \"{schema}\".\"{table}\"{using} ({cols}){include}{filter};", idx.name));
if let Some(ref c) = idx.comment {
ddl.push_str(&format!("\nCOMMENT ON INDEX \"{schema}\".\"{}\" IS '{}';", idx.name, c.replace('\'', "''")));
}
}
Ok(ddl)
}
@ -474,8 +481,11 @@ async fn build_sqlserver_ddl(client: &mut db::sqlserver::SqlServerClient, schema
for idx in &indexes {
if idx.is_primary { continue; }
let unique = if idx.is_unique { "UNIQUE " } else { "" };
let idx_type = idx.index_type.as_deref().map(|t| format!("{t} ")).unwrap_or_default();
let cols = idx.columns.iter().map(|c| format!("[{c}]")).collect::<Vec<_>>().join(", ");
ddl.push_str(&format!("\nCREATE {unique}INDEX [{}] ON [{schema}].[{table}] ({cols});", idx.name));
let include = idx.included_columns.as_deref().filter(|c| !c.is_empty()).map(|cols| format!(" INCLUDE ({})", cols.iter().map(|c| format!("[{c}]")).collect::<Vec<_>>().join(", "))).unwrap_or_default();
let filter = idx.filter.as_deref().map(|f| format!(" WHERE {f}")).unwrap_or_default();
ddl.push_str(&format!("\nCREATE {unique}{idx_type}INDEX [{}] ON [{schema}].[{table}] ({cols}){include}{filter};", idx.name));
}
Ok(ddl)
}

View File

@ -442,6 +442,7 @@ async fn get_columns_for_transfer(
comment: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
})
}).map_err(|e| e.to_string())?;
Ok(rows.filter_map(|r| r.ok()).collect())

View File

@ -129,6 +129,7 @@ pub async fn get_columns(client: &ChClient, database: &str, table: &str) -> Resu
extra: None, comment: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
}
}).collect())
}

View File

@ -33,6 +33,7 @@ pub struct ColumnInfo {
pub comment: Option<String>,
pub numeric_precision: Option<i32>,
pub numeric_scale: Option<i32>,
pub character_maximum_length: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -51,6 +52,10 @@ pub struct IndexInfo {
pub columns: Vec<String>,
pub is_unique: bool,
pub is_primary: bool,
pub filter: Option<String>,
pub index_type: Option<String>,
pub included_columns: Option<Vec<String>>,
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -215,9 +215,9 @@ pub async fn get_columns(
table: &str,
) -> Result<Vec<ColumnInfo>, String> {
let sql = format!(
"SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.EXTRA, c.COLUMN_COMMENT, \
"SELECT c.COLUMN_NAME, c.COLUMN_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.EXTRA, c.COLUMN_COMMENT, \
CASE WHEN kcu.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK, \
c.NUMERIC_PRECISION, c.NUMERIC_SCALE \
c.NUMERIC_PRECISION, c.NUMERIC_SCALE, c.CHARACTER_MAXIMUM_LENGTH \
FROM information_schema.COLUMNS c \
LEFT JOIN information_schema.KEY_COLUMN_USAGE kcu \
ON c.TABLE_SCHEMA = kcu.TABLE_SCHEMA \
@ -238,7 +238,7 @@ pub async fn get_columns(
.iter()
.map(|row| ColumnInfo {
name: get_str_by_name(row, "COLUMN_NAME"),
data_type: get_str_by_name(row, "DATA_TYPE"),
data_type: get_str_by_name(row, "COLUMN_TYPE"),
is_nullable: get_str_by_name(row, "IS_NULLABLE") == "YES",
column_default: get_opt_str(row, "COLUMN_DEFAULT"),
is_primary_key: row.get::<i32, _>("IS_PK") == 1,
@ -246,6 +246,7 @@ pub async fn get_columns(
comment: get_opt_str(row, "COLUMN_COMMENT").filter(|s| !s.is_empty()),
numeric_precision: get_opt_i32(row, "NUMERIC_PRECISION"),
numeric_scale: get_opt_i32(row, "NUMERIC_SCALE"),
character_maximum_length: get_opt_i32(row, "CHARACTER_MAXIMUM_LENGTH"),
})
.collect())
}
@ -331,10 +332,11 @@ pub async fn execute_query(pool: &MySqlPool, sql: &str, bare: bool) -> Result<Qu
pub async fn list_indexes(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
let sql = format!(
"SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns, \
NOT NON_UNIQUE AS is_unique, INDEX_NAME = 'PRIMARY' AS is_primary \
MIN(NON_UNIQUE) = 0 AS is_unique, INDEX_NAME = 'PRIMARY' AS is_primary, \
INDEX_TYPE \
FROM information_schema.STATISTICS \
WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} \
GROUP BY INDEX_NAME, NON_UNIQUE \
GROUP BY INDEX_NAME, INDEX_TYPE \
ORDER BY INDEX_NAME",
quote_value(database),
quote_value(table),
@ -350,9 +352,13 @@ pub async fn list_indexes(pool: &MySqlPool, database: &str, table: &str) -> Resu
let cols_str = get_str_by_name(row, "columns");
IndexInfo {
name: get_str_by_name(row, "INDEX_NAME"),
columns: cols_str.split(',').map(|s| s.to_string()).collect(),
columns: cols_str.split(',').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect(),
is_unique: row.get::<bool, _>("is_unique"),
is_primary: row.get::<bool, _>("is_primary"),
filter: None,
index_type: Some(get_str_by_name(row, "INDEX_TYPE")),
included_columns: None,
comment: None,
}
})
.collect())

View File

@ -81,7 +81,7 @@ pub async fn get_columns(conn: &OracleClient, schema: &str, table: &str) -> Resu
let col_result = conn.query(
&format!(
"SELECT COLUMN_NAME, DATA_TYPE, NULLABLE, DATA_PRECISION, DATA_SCALE \
"SELECT COLUMN_NAME, DATA_TYPE, NULLABLE, DATA_PRECISION, DATA_SCALE, DATA_LENGTH, CHAR_LENGTH \
FROM ALL_TAB_COLUMNS \
WHERE OWNER = '{s}' AND TABLE_NAME = '{t}' \
ORDER BY COLUMN_ID"
@ -91,15 +91,40 @@ pub async fn get_columns(conn: &OracleClient, schema: &str, table: &str) -> Resu
Ok(col_result.rows.iter().map(|row| {
let name = row.get_string(0).unwrap_or("").to_string();
let base = row.get_string(1).unwrap_or("").to_string();
let data_len = row.get_i64(5).map(|v| v as i32);
let char_len = row.get_i64(6).map(|v| v as i32);
let num_prec = row.get_i64(3).map(|v| v as i32);
let num_scale = row.get_i64(4).map(|v| v as i32);
let data_type = match base.to_uppercase().as_str() {
"VARCHAR2" | "NVARCHAR2" | "CHAR" | "NCHAR" => {
let len = char_len.or(data_len);
match len {
Some(n) => format!("{base}({n})"),
None => base,
}
}
"NUMBER" => match (num_prec, num_scale) {
(Some(p), Some(s)) if s > 0 => format!("NUMBER({p},{s})"),
(Some(p), _) if p > 0 => format!("NUMBER({p})"),
_ => "NUMBER".to_string(),
},
"RAW" => match data_len {
Some(n) => format!("RAW({n})"),
None => "RAW".to_string(),
},
_ => base,
};
ColumnInfo {
is_primary_key: pk_names.contains(&name),
name,
data_type: row.get_string(1).unwrap_or("").to_string(),
data_type,
is_nullable: row.get_string(2).unwrap_or("N") == "Y",
column_default: None,
extra: None, comment: None,
numeric_precision: row.get_i64(3).map(|v| v as i32),
numeric_scale: row.get_i64(4).map(|v| v as i32),
numeric_precision: num_prec,
numeric_scale: num_scale,
character_maximum_length: char_len,
}
}).collect())
}
@ -109,13 +134,14 @@ pub async fn list_indexes(conn: &OracleClient, schema: &str, table: &str) -> Res
"SELECT i.INDEX_NAME, \
LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS columns, \
i.UNIQUENESS, \
CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK \
CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK, \
i.INDEX_TYPE \
FROM ALL_INDEXES i \
JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.TABLE_OWNER = ic.TABLE_OWNER \
JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.OWNER = ic.INDEX_OWNER AND i.TABLE_OWNER = ic.TABLE_OWNER \
LEFT JOIN ALL_CONSTRAINTS c ON i.INDEX_NAME = c.INDEX_NAME AND i.TABLE_OWNER = c.OWNER \
AND c.CONSTRAINT_TYPE = 'P' \
WHERE i.TABLE_OWNER = '{s}' AND i.TABLE_NAME = '{t}' \
GROUP BY i.INDEX_NAME, i.UNIQUENESS, c.CONSTRAINT_TYPE \
GROUP BY i.INDEX_NAME, i.UNIQUENESS, c.CONSTRAINT_TYPE, i.INDEX_TYPE \
ORDER BY i.INDEX_NAME",
s = schema.replace('\'', "''"), t = table.replace('\'', "''")
);
@ -124,9 +150,13 @@ pub async fn list_indexes(conn: &OracleClient, schema: &str, table: &str) -> Res
let cols_str = row.get_string(1).unwrap_or("");
IndexInfo {
name: row.get_string(0).unwrap_or("").to_string(),
columns: cols_str.split(',').map(|s| s.to_string()).collect(),
columns: cols_str.split(',').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect(),
is_unique: row.get_string(2).unwrap_or("") == "UNIQUE",
is_primary: row.get_i64(3).unwrap_or(0) == 1,
filter: None,
index_type: row.get_string(4).map(|s| s.to_string()),
included_columns: None,
comment: None,
}
}).collect())
}

View File

@ -154,21 +154,29 @@ pub async fn get_columns(
table: &str,
) -> Result<Vec<ColumnInfo>, String> {
let rows: Vec<PgRow> = sqlx::query(
"SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, \
CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN true ELSE false END AS is_pk, \
col_description((c.table_schema || '.' || c.table_name)::regclass, c.ordinal_position) AS column_comment, \
c.numeric_precision, c.numeric_scale \
FROM information_schema.columns c \
LEFT JOIN information_schema.key_column_usage kcu \
ON c.table_schema = kcu.table_schema \
AND c.table_name = kcu.table_name \
AND c.column_name = kcu.column_name \
LEFT JOIN information_schema.table_constraints tc \
ON kcu.constraint_name = tc.constraint_name \
AND kcu.table_schema = tc.table_schema \
AND tc.constraint_type = 'PRIMARY KEY' \
WHERE c.table_schema = $1 AND c.table_name = $2 \
ORDER BY c.ordinal_position",
"SELECT a.attname AS column_name, \
format_type(a.atttypid, a.atttypmod) AS full_type, \
NOT a.attnotnull AS is_nullable, \
pg_get_expr(ad.adbin, ad.adrelid) AS column_default, \
EXISTS ( \
SELECT 1 FROM pg_constraint co \
JOIN pg_index i ON i.indrelid = co.conrelid AND co.conindid = i.indexrelid \
WHERE co.conrelid = a.attrelid AND co.contype = 'p' \
AND a.attnum = ANY(i.indkey) \
) AS is_pk, \
col_description(a.attrelid, a.attnum) AS column_comment, \
CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 \
THEN ((a.atttypmod - 4) >> 16) & 65535 ELSE NULL END AS numeric_precision, \
CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 \
THEN (a.atttypmod - 4) & 65535 ELSE NULL END AS numeric_scale, \
CASE WHEN t.typname IN ('varchar', 'bpchar') AND a.atttypmod > 0 \
THEN a.atttypmod - 4 ELSE NULL END AS character_maximum_length \
FROM pg_attribute a \
JOIN pg_type t ON t.oid = a.atttypid \
LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum \
WHERE a.attrelid = ($1 || '.' || $2)::regclass \
AND a.attnum > 0 AND NOT a.attisdropped \
ORDER BY a.attnum",
)
.bind(schema)
.bind(table)
@ -178,16 +186,20 @@ pub async fn get_columns(
Ok(rows
.iter()
.map(|row| ColumnInfo {
name: row.get::<String, _>("column_name"),
data_type: row.get::<String, _>("data_type"),
is_nullable: row.get::<String, _>("is_nullable") == "YES",
column_default: row.get::<Option<String>, _>("column_default"),
is_primary_key: row.get::<bool, _>("is_pk"),
extra: None,
comment: row.get::<Option<String>, _>("column_comment"),
numeric_precision: row.get::<Option<i32>, _>("numeric_precision"),
numeric_scale: row.get::<Option<i32>, _>("numeric_scale"),
.map(|row| {
let full_type = row.get::<Option<String>, _>("full_type").unwrap_or_default();
ColumnInfo {
name: row.get::<String, _>("column_name"),
data_type: full_type,
is_nullable: row.get::<bool, _>("is_nullable"),
column_default: row.get::<Option<String>, _>("column_default"),
is_primary_key: row.get::<bool, _>("is_pk"),
extra: None,
comment: row.get::<Option<String>, _>("column_comment"),
numeric_precision: row.get::<Option<i32>, _>("numeric_precision"),
numeric_scale: row.get::<Option<i32>, _>("numeric_scale"),
character_maximum_length: row.get::<Option<i32>, _>("character_maximum_length"),
}
})
.collect())
}
@ -202,19 +214,26 @@ pub async fn execute_query(pool: &PgPool, sql: &str) -> Result<QueryResult, Stri
|| trimmed.starts_with("WITH")
|| trimmed.starts_with("TABLE")
{
let desc = pool.describe(sql).await.map_err(|e| e.to_string())?;
let columns: Vec<String> = desc.columns().iter().map(|c| c.name().to_string()).collect();
let column_types: Vec<String> = desc
.columns()
.iter()
.map(|c| c.type_info().name().to_string())
.collect();
let rows: Vec<PgRow> = sqlx::query(sql)
.persistent(false)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let (columns, column_types): (Vec<String>, Vec<String>) = if let Some(first) = rows.first() {
let cols = first.columns();
(
cols.iter().map(|c| c.name().to_string()).collect(),
cols.iter().map(|c| c.type_info().name().to_string()).collect(),
)
} else {
let desc = pool.describe(sql).await.map_err(|e| e.to_string())?;
(
desc.columns().iter().map(|c| c.name().to_string()).collect(),
desc.columns().iter().map(|c| c.type_info().name().to_string()).collect(),
)
};
let result_rows: Vec<Vec<serde_json::Value>> = rows
.iter()
.map(|row| {
@ -256,17 +275,23 @@ pub async fn execute_query(pool: &PgPool, sql: &str) -> Result<QueryResult, Stri
pub async fn list_indexes(pool: &PgPool, schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
let rows: Vec<PgRow> = sqlx::query(
"SELECT i.relname AS index_name, \
array_agg(a.attname ORDER BY k.n) AS columns, \
array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, true)) ORDER BY k.n) AS columns, \
ix.indisunique AS is_unique, \
ix.indisprimary AS is_primary \
ix.indisprimary AS is_primary, \
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
am.amname AS index_type, \
ix.indnkeyatts AS nkeyatts, \
ix.indkey AS indkey, \
obj_description(i.oid, 'pg_class') AS index_comment \
FROM pg_index ix \
JOIN pg_class t ON t.oid = ix.indrelid \
JOIN pg_class i ON i.oid = ix.indexrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
JOIN pg_am am ON am.oid = i.relam \
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true \
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum \
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \
WHERE n.nspname = $1 AND t.relname = $2 \
GROUP BY i.relname, ix.indisunique, ix.indisprimary \
GROUP BY i.relname, i.oid, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indnkeyatts, ix.indkey \
ORDER BY i.relname",
)
.bind(schema)
@ -277,11 +302,21 @@ pub async fn list_indexes(pool: &PgPool, schema: &str, table: &str) -> Result<Ve
Ok(rows
.iter()
.map(|row| IndexInfo {
name: row.get::<String, _>("index_name"),
columns: row.get::<Vec<String>, _>("columns"),
is_unique: row.get::<bool, _>("is_unique"),
is_primary: row.get::<bool, _>("is_primary"),
.map(|row| {
let all_cols: Vec<String> = row.get::<Vec<String>, _>("columns");
let nkeyatts = row.get::<Option<i16>, _>("nkeyatts").unwrap_or(all_cols.len() as i16) as usize;
let key_cols = all_cols[..nkeyatts].to_vec();
let included = if nkeyatts < all_cols.len() { all_cols[nkeyatts..].to_vec() } else { vec![] };
IndexInfo {
name: row.get::<String, _>("index_name"),
columns: key_cols,
is_unique: row.get::<bool, _>("is_unique"),
is_primary: row.get::<bool, _>("is_primary"),
filter: row.get::<Option<String>, _>("filter_expr"),
index_type: row.get::<Option<String>, _>("index_type"),
included_columns: if included.is_empty() { None } else { Some(included) },
comment: row.get::<Option<String>, _>("index_comment"),
}
})
.collect())
}

View File

@ -55,12 +55,14 @@ pub async fn get_columns(pool: &SqlitePool, _schema: &str, table: &str) -> Resul
extra: None, comment: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
})
.collect())
}
pub async fn list_indexes(pool: &SqlitePool, _schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
let idx_rows: Vec<SqliteRow> = sqlx::query(&format!("PRAGMA index_list(\"{}\")", table))
let safe_table = table.replace('"', "\"\"");
let idx_rows: Vec<SqliteRow> = sqlx::query(&format!("PRAGMA index_list(\"{safe_table}\")"))
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
@ -69,8 +71,11 @@ pub async fn list_indexes(pool: &SqlitePool, _schema: &str, table: &str) -> Resu
for idx_row in &idx_rows {
let name: String = idx_row.get("name");
let is_unique: bool = idx_row.get::<i32, _>("unique") != 0;
let origin: String = idx_row.get::<String, _>("origin");
let is_primary = origin == "pk";
let col_rows: Vec<SqliteRow> = sqlx::query(&format!("PRAGMA index_info(\"{}\")", name))
let safe_name = name.replace('"', "\"\"");
let col_rows: Vec<SqliteRow> = sqlx::query(&format!("PRAGMA index_info(\"{safe_name}\")"))
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
@ -81,7 +86,11 @@ pub async fn list_indexes(pool: &SqlitePool, _schema: &str, table: &str) -> Resu
name,
columns,
is_unique,
is_primary: false,
is_primary,
filter: None,
index_type: None,
included_columns: None,
comment: None,
});
}
Ok(indexes)

View File

@ -97,7 +97,7 @@ pub async fn get_columns(client: &mut SqlServerClient, schema: &str, table: &str
let sql = format!(
"SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, \
CASE WHEN kcu.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK, \
c.NUMERIC_PRECISION, c.NUMERIC_SCALE \
c.NUMERIC_PRECISION, c.NUMERIC_SCALE, c.CHARACTER_MAXIMUM_LENGTH, c.DATETIME_PRECISION \
FROM INFORMATION_SCHEMA.COLUMNS c \
LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu \
ON c.TABLE_SCHEMA = kcu.TABLE_SCHEMA AND c.TABLE_NAME = kcu.TABLE_NAME AND c.COLUMN_NAME = kcu.COLUMN_NAME \
@ -109,28 +109,67 @@ pub async fn get_columns(client: &mut SqlServerClient, schema: &str, table: &str
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| {
let base = row.get::<&str, _>(1).unwrap_or("").to_string();
let max_len = row.get::<i32, _>(7);
let dt_prec = row.get::<i32, _>(8);
let num_prec = row.get::<i32, _>(5);
let num_scale = row.get::<i32, _>(6);
let data_type = match base.to_lowercase().as_str() {
"varchar" => match max_len {
Some(-1) => "varchar(max)".to_string(),
Some(n) => format!("varchar({n})"),
None => "varchar".to_string(),
},
"nvarchar" => match max_len {
Some(-1) => "nvarchar(max)".to_string(),
Some(n) => format!("nvarchar({n})"),
None => "nvarchar".to_string(),
},
"varbinary" => match max_len {
Some(-1) => "varbinary(max)".to_string(),
Some(n) if n > 0 => format!("varbinary({n})"),
_ => "varbinary".to_string(),
},
"char" | "nchar" | "binary" => match max_len {
Some(n) if n > 0 => format!("{base}({n})"),
_ => base,
}
"decimal" | "numeric" => match (num_prec, num_scale) {
(Some(p), Some(s)) => format!("{base}({p},{s})"),
_ => base,
},
"datetime2" | "datetimeoffset" | "time" => match dt_prec {
Some(p) => format!("{base}({p})"),
_ => base,
},
_ => base,
};
ColumnInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
data_type: row.get::<&str, _>(1).unwrap_or("").to_string(),
data_type,
is_nullable: row.get::<&str, _>(2).unwrap_or("NO") == "YES",
column_default: row.get::<&str, _>(3).map(|s| s.to_string()),
is_primary_key: row.get::<i32, _>(4).unwrap_or(0) == 1,
extra: None, comment: None,
numeric_precision: row.get::<i32, _>(5),
numeric_scale: row.get::<i32, _>(6),
numeric_precision: num_prec,
numeric_scale: num_scale,
character_maximum_length: max_len,
}
}).collect())
}
pub async fn list_indexes(client: &mut SqlServerClient, schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
let sql = format!(
"SELECT i.name, STRING_AGG(c.name, ',') WITHIN GROUP (ORDER BY ic.key_ordinal) AS columns, \
i.is_unique, i.is_primary_key \
"SELECT i.name, \
STRING_AGG(CASE WHEN ic.is_included_column = 0 THEN c.name END, ',') WITHIN GROUP (ORDER BY ic.key_ordinal) AS columns, \
i.is_unique, i.is_primary_key, i.type_desc, \
STRING_AGG(CASE WHEN ic.is_included_column = 1 THEN c.name END, ',') AS included_cols, \
i.filter_definition \
FROM sys.indexes i \
JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id \
JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id \
WHERE i.object_id = OBJECT_ID('{s}.{t}') AND i.name IS NOT NULL \
GROUP BY i.name, i.is_unique, i.is_primary_key \
GROUP BY i.name, i.is_unique, i.is_primary_key, i.type_desc, i.filter_definition \
ORDER BY i.name",
s = schema.replace('\'', "''"), t = table.replace('\'', "''")
);
@ -138,11 +177,16 @@ pub async fn list_indexes(client: &mut SqlServerClient, schema: &str, table: &st
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| {
let cols_str = row.get::<&str, _>(1).unwrap_or("");
let inc_str = row.get::<&str, _>(5).unwrap_or("");
IndexInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
columns: cols_str.split(',').map(|s| s.to_string()).collect(),
columns: cols_str.split(',').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect(),
is_unique: row.get::<bool, _>(2).unwrap_or(false),
is_primary: row.get::<bool, _>(3).unwrap_or(false),
filter: row.get::<&str, _>(6).map(|s| s.to_string()),
index_type: row.get::<&str, _>(4).map(|s| s.to_string()),
included_columns: if inc_str.is_empty() { None } else { Some(inc_str.split(',').map(|s| s.to_string()).collect()) },
comment: None,
}
}).collect())
}

View File

@ -11,12 +11,18 @@ import {
Tabs, TabsContent, TabsList, TabsTrigger,
} from "@/components/ui/tabs";
import {
AlertTriangle, Check, Database, KeyRound, Loader2, Plus, RefreshCw, Save, TableProperties, Trash2, X,
AlertTriangle, Check, ChevronDown, Database, KeyRound, Loader2, Plus, RefreshCw, Save, TableProperties, Trash2, X,
} from "lucide-vue-next";
import {
DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { buildTableStructureChangeSql, type EditableStructureColumn, type EditableStructureIndex } from "@/lib/tableStructureEditorSql";
import { createColumnDrafts, createIndexDrafts, splitIndexColumns, toColumnNames } from "@/lib/tableStructureEditorState";
import { createColumnDrafts, createIndexDrafts, toColumnNames } from "@/lib/tableStructureEditorState";
import type { ForeignKeyInfo, TriggerInfo } from "@/types/database";
import * as api from "@/lib/tauri";
@ -45,10 +51,50 @@ const indexes = ref<EditableStructureIndex[]>([]);
const foreignKeys = ref<ForeignKeyInfo[]>([]);
const triggers = ref<TriggerInfo[]>([]);
const indexColWidths = ref([160, 240, 80, 112, 160, 192, 160, 96]);
const resizing = ref<{ col: number; startX: number; startW: number } | null>(null);
function onIndexColResize(e: MouseEvent, col: number) {
e.preventDefault();
resizing.value = { col, startX: e.clientX, startW: indexColWidths.value[col] };
const onMove = (ev: MouseEvent) => {
if (!resizing.value) return;
const delta = ev.clientX - resizing.value.startX;
indexColWidths.value[col] = Math.max(60, resizing.value.startW + delta);
};
const onUp = () => {
resizing.value = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
const connection = computed(() =>
props.prefillConnectionId ? store.getConfig(props.prefillConnectionId) : undefined
);
const databaseType = computed(() => connection.value?.db_type);
const indexTypesByDb: Record<string, string[]> = {
postgres: ["BTREE", "HASH", "GIST", "SPGIST", "GIN", "BRIN"],
mysql: ["BTREE", "HASH", "FULLTEXT", "SPATIAL", "RTREE"],
sqlserver: ["CLUSTERED", "NONCLUSTERED", "COLUMNSTORE", "NONCLUSTERED COLUMNSTORE", "XML", "SPATIAL"],
oracle: ["NORMAL", "BITMAP", "FUNCTION-BASED NORMAL", "FUNCTION-BASED DOMAIN", "DOMAIN", "CLUSTER"],
sqlite: ["BTREE"],
};
const indexTypeOptions = computed(() => indexTypesByDb[databaseType.value ?? ""] ?? []);
const indexColLabels = computed(() => [
t('structureEditor.indexName'),
t('structureEditor.indexColumns'),
t('structureEditor.unique'),
t('structureEditor.indexType'),
t('structureEditor.includedColumns'),
t('structureEditor.filter'),
t('structureEditor.comment'),
t('structureEditor.actions'),
]);
const targetSchema = computed(() => props.prefillSchema || props.prefillDatabase || "");
const targetLabel = computed(() => [
connection.value?.name,
@ -138,12 +184,35 @@ function addIndex() {
columns: [],
isUnique: false,
isPrimary: false,
filter: "",
indexType: "",
includedColumns: [],
comment: "",
markedForDrop: false,
});
}
function updateIndexColumns(index: EditableStructureIndex, value: string) {
index.columns = splitIndexColumns(value);
const availableColumnNames = computed(() =>
columns.value.filter((c) => !c.markedForDrop).map((c) => c.name).filter(Boolean)
);
const colSearch = ref("");
const filteredColumnNames = computed(() => {
const q = colSearch.value.toLowerCase().trim();
if (!q) return availableColumnNames.value;
return availableColumnNames.value.filter((c) => c.toLowerCase().includes(q));
});
function toggleIndexColumn(index: EditableStructureIndex, col: string) {
const i = index.columns.indexOf(col);
if (i >= 0) index.columns.splice(i, 1);
else index.columns.push(col);
}
function toggleIncludedColumn(index: EditableStructureIndex, col: string) {
const i = index.includedColumns.indexOf(col);
if (i >= 0) index.includedColumns.splice(i, 1);
else index.includedColumns.push(col);
}
function removeNewIndex(index: EditableStructureIndex) {
@ -294,24 +363,52 @@ watch(open, (value) => {
<table class="min-w-full border-separate border-spacing-0 text-xs">
<thead class="sticky top-0 z-10 bg-background">
<tr>
<th class="min-w-40 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.indexName') }}</th>
<th class="min-w-60 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.indexColumns') }}</th>
<th class="w-20 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.unique') }}</th>
<th class="w-24 border-b px-2 py-2 text-left">{{ t('structureEditor.actions') }}</th>
<th
v-for="(label, i) in indexColLabels"
:key="i"
class="relative border-b border-r px-2 py-2 text-left"
:style="{ width: indexColWidths[i] + 'px', minWidth: indexColWidths[i] + 'px' }"
>
{{ label }}
<div
v-if="i < indexColLabels.length - 1"
class="absolute right-0 top-0 z-20 h-full w-1 cursor-col-resize hover:bg-primary/30"
:class="resizing?.col === i ? 'bg-primary/30' : ''"
@mousedown="onIndexColResize($event, i)"
/>
</th>
</tr>
</thead>
<tbody>
<tr v-for="index in indexes" :key="index.id" :class="index.markedForDrop ? 'bg-destructive/5 opacity-60' : ''">
<td class="border-b border-r px-2 py-1.5">
<Input v-model="index.name" class="h-7 min-w-36 text-xs" :disabled="!!index.original || index.markedForDrop" />
<Input v-model="index.name" class="h-7 text-xs" :disabled="!!index.original || index.markedForDrop" />
</td>
<td class="border-b border-r px-2 py-1.5">
<Input
:model-value="toColumnNames(index.columns)"
class="h-7 min-w-56 font-mono text-xs"
:disabled="!!index.original || index.markedForDrop"
@update:model-value="(value: any) => updateIndexColumns(index, String(value))"
/>
<td class="overflow-hidden border-b border-r px-2 py-1.5">
<DropdownMenu v-if="!index.original && !index.markedForDrop">
<DropdownMenuTrigger as-child>
<Button variant="outline" class="h-7 w-full justify-between font-mono text-xs">
<span class="truncate">{{ toColumnNames(index.columns) || t('structureEditor.indexColumnsPlaceholder') }}</span>
<ChevronDown class="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="max-h-60 min-w-48 overflow-y-auto" side="bottom" :side-offset="2" :avoid-collisions="false" @interactOutside="colSearch = ''">
<div class="px-1.5 pb-1 pt-0.5">
<Input v-model="colSearch" class="h-6 text-xs" :placeholder="t('grid.search')" @click.stop />
</div>
<DropdownMenuCheckboxItem
v-for="col in filteredColumnNames"
:key="col"
:checked="index.columns.includes(col)"
:class="index.columns.includes(col) ? 'bg-primary/10' : ''"
@select.prevent
@click="toggleIndexColumn(index, col)"
>
{{ col }}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<span v-else class="font-mono text-xs text-muted-foreground">{{ toColumnNames(index.columns) }}</span>
</td>
<td class="border-b border-r px-2 py-1.5">
<label class="flex items-center gap-1.5">
@ -319,6 +416,72 @@ watch(open, (value) => {
<span>{{ index.isUnique ? t('structureEditor.yes') : t('structureEditor.no') }}</span>
</label>
</td>
<td class="border-b border-r px-2 py-1.5">
<span v-if="index.original" class="text-muted-foreground">{{ index.indexType || 'BTREE' }}</span>
<Select
v-else-if="indexTypeOptions.length > 0"
:model-value="index.indexType || 'BTREE'"
:disabled="index.markedForDrop"
@update:model-value="(v: any) => index.indexType = String(v ?? '')"
>
<SelectTrigger class="h-7 font-mono text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in indexTypeOptions" :key="opt" :value="opt">{{ opt }}</SelectItem>
</SelectContent>
</Select>
<Input
v-else
v-model="index.indexType"
class="h-7 font-mono text-xs"
placeholder="BTREE"
:disabled="index.markedForDrop"
/>
</td>
<td class="overflow-hidden border-b border-r px-2 py-1.5">
<DropdownMenu v-if="!index.original && !index.markedForDrop">
<DropdownMenuTrigger as-child>
<Button variant="outline" class="h-7 w-full justify-between font-mono text-xs">
<span class="truncate">{{ index.includedColumns.join(', ') || t('structureEditor.includedColumnsPlaceholder') }}</span>
<ChevronDown class="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="max-h-60 min-w-48 overflow-y-auto" side="bottom" :side-offset="2" :avoid-collisions="false" @interactOutside="colSearch = ''">
<div class="px-1.5 pb-1 pt-0.5">
<Input v-model="colSearch" class="h-6 text-xs" :placeholder="t('grid.search')" @click.stop />
</div>
<DropdownMenuCheckboxItem
v-for="col in filteredColumnNames"
:key="col"
:checked="index.includedColumns.includes(col)"
:class="index.includedColumns.includes(col) ? 'bg-primary/10' : ''"
@select.prevent
@click="toggleIncludedColumn(index, col)"
>
{{ col }}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<span v-else class="text-muted-foreground text-xs">{{ index.includedColumns.join(', ') }}</span>
</td>
<td class="border-b border-r px-2 py-1.5">
<Input
v-model="index.filter"
class="h-7 font-mono text-xs"
:placeholder="index.original?.filter || ''"
:disabled="!!index.original || index.markedForDrop"
/>
</td>
<td class="border-b border-r px-2 py-1.5">
<span v-if="index.original" class="text-muted-foreground text-xs">{{ index.comment }}</span>
<Input
v-else
v-model="index.comment"
class="h-7 text-xs"
:disabled="index.markedForDrop"
/>
</td>
<td class="border-b px-2 py-1.5">
<Badge v-if="index.isPrimary" variant="outline">{{ t('structureEditor.primary') }}</Badge>
<Button

View File

@ -335,7 +335,12 @@ export default {
actions: "Actions",
indexName: "Index",
indexColumns: "Columns",
indexColumnsPlaceholder: "Select columns...",
unique: "Unique",
indexType: "Type",
includedColumns: "Included",
includedColumnsPlaceholder: "Select columns...",
filter: "Filter",
primary: "Primary",
drop: "Drop",
restore: "Restore",

View File

@ -335,7 +335,12 @@ export default {
actions: "操作",
indexName: "索引名",
indexColumns: "字段列表",
indexColumnsPlaceholder: "选择列...",
unique: "唯一",
indexType: "类型",
includedColumns: "包含列",
includedColumnsPlaceholder: "选择列...",
filter: "过滤条件",
primary: "主键",
drop: "删除",
restore: "恢复",

View File

@ -180,7 +180,14 @@ export function generateSyncSql(
if (idx.type === "added" && idx.source) {
const cols = idx.source.columns.map((c) => quoteId(c, dbType)).join(", ");
const unique = idx.source.is_unique ? "UNIQUE " : "";
lines.push(`CREATE ${unique}INDEX ${quoteId(idx.name, dbType)} ON ${qt} (${cols});`);
const idxType = idx.source.index_type ?? "";
const usingClause = idxType && dbType === "postgres" ? ` USING ${idxType}` : "";
const typePrefix = idxType && dbType === "sqlserver" ? `${idxType} ` : "";
const incCols = idx.source.included_columns ?? [];
const includeClause = incCols.length > 0 && (dbType === "postgres" || dbType === "sqlserver") ? ` INCLUDE (${incCols.map((c) => quoteId(c, dbType)).join(", ")})` : "";
const supportsWhere = dbType === "postgres" || dbType === "sqlserver" || dbType === "sqlite";
const filter = idx.source.filter && supportsWhere ? ` WHERE ${idx.source.filter}` : "";
lines.push(`CREATE ${unique}${typePrefix}INDEX ${quoteId(idx.name, dbType)} ON ${qt}${usingClause} (${cols})${includeClause}${filter};`);
} else if (idx.type === "removed") {
if (isMySQL) {
lines.push(`DROP INDEX ${quoteId(idx.name, dbType)} ON ${qt};`);

View File

@ -18,6 +18,10 @@ export interface EditableStructureIndex {
columns: string[];
isUnique: boolean;
isPrimary: boolean;
filter: string;
indexType: string;
includedColumns: string[];
comment: string;
original?: IndexInfo;
markedForDrop: boolean;
}
@ -245,7 +249,19 @@ function buildIndexSql(options: BuildTableStructureChangeSqlOptions, warnings: s
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});`);
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;

View File

@ -22,18 +22,15 @@ export function createIndexDrafts(indexes: IndexInfo[]): EditableStructureIndex[
columns: [...index.columns],
isUnique: index.is_unique,
isPrimary: index.is_primary,
filter: index.filter ?? "",
indexType: index.index_type ?? "",
includedColumns: index.included_columns ? [...index.included_columns] : [],
comment: index.comment ?? "",
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(", ");
}

View File

@ -44,6 +44,7 @@ export interface ColumnInfo {
comment?: string | null;
numeric_precision?: number | null;
numeric_scale?: number | null;
character_maximum_length?: number | null;
}
export interface IndexInfo {
@ -51,6 +52,10 @@ export interface IndexInfo {
columns: string[];
is_unique: boolean;
is_primary: boolean;
filter?: string | null;
index_type?: string | null;
included_columns?: string[] | null;
comment?: string | null;
}
export interface ForeignKeyInfo {

View File

@ -27,6 +27,10 @@ function index(overrides: Partial<EditableStructureIndex>): EditableStructureInd
columns: overrides.columns ?? ["name"],
isUnique: overrides.isUnique ?? false,
isPrimary: overrides.isPrimary ?? false,
filter: overrides.filter ?? "",
indexType: overrides.indexType ?? "",
includedColumns: overrides.includedColumns ?? [],
comment: overrides.comment ?? "",
original: overrides.original,
markedForDrop: overrides.markedForDrop ?? false,
};
@ -181,3 +185,272 @@ test("quotes SQL Server table, column, and index names with brackets", () => {
"CREATE INDEX [idx_users_email] ON [dbo].[users] ([email]);",
]);
});
test("PostgreSQL index with INCLUDE clause", () => {
const result = buildTableStructureChangeSql({
databaseType: "postgres",
schema: "public",
tableName: "orders",
columns: [],
indexes: [
index({
id: "new",
name: "idx_orders_status",
columns: ["status"],
includedColumns: ["total", "created_at"],
}),
],
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(result.statements, [
'CREATE INDEX "idx_orders_status" ON "public"."orders" ("status") INCLUDE ("total", "created_at");',
]);
});
test("PostgreSQL index with USING clause (index type)", () => {
const result = buildTableStructureChangeSql({
databaseType: "postgres",
schema: "public",
tableName: "docs",
columns: [],
indexes: [
index({
id: "new",
name: "idx_docs_body",
columns: ["body"],
indexType: "GIN",
}),
],
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(result.statements, [
'CREATE INDEX "idx_docs_body" ON "public"."docs" USING GIN ("body");',
]);
});
test("PostgreSQL index with WHERE filter", () => {
const result = buildTableStructureChangeSql({
databaseType: "postgres",
schema: "public",
tableName: "users",
columns: [],
indexes: [
index({
id: "new",
name: "idx_users_active",
columns: ["email"],
filter: "deleted_at IS NULL",
}),
],
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(result.statements, [
'CREATE INDEX "idx_users_active" ON "public"."users" ("email") WHERE deleted_at IS NULL;',
]);
});
test("PostgreSQL index with COMMENT", () => {
const result = buildTableStructureChangeSql({
databaseType: "postgres",
schema: "public",
tableName: "users",
columns: [],
indexes: [
index({
id: "new",
name: "idx_users_email",
columns: ["email"],
comment: "Fast lookup by email",
}),
],
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(result.statements, [
'CREATE INDEX "idx_users_email" ON "public"."users" ("email");',
"COMMENT ON INDEX \"idx_users_email\" IS 'Fast lookup by email';",
]);
});
test("PostgreSQL index with single quote in comment is escaped", () => {
const result = buildTableStructureChangeSql({
databaseType: "postgres",
schema: "public",
tableName: "users",
columns: [],
indexes: [
index({
id: "new",
name: "idx_users_email",
columns: ["email"],
comment: "User's primary email",
}),
],
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(result.statements, [
'CREATE INDEX "idx_users_email" ON "public"."users" ("email");',
"COMMENT ON INDEX \"idx_users_email\" IS 'User''s primary email';",
]);
});
test("PostgreSQL index with all options combined (unique + type + include + filter + comment)", () => {
const result = buildTableStructureChangeSql({
databaseType: "postgres",
schema: "public",
tableName: "orders",
columns: [],
indexes: [
index({
id: "new",
name: "idx_orders_covering",
columns: ["user_id", "status"],
isUnique: true,
indexType: "BTREE",
includedColumns: ["total"],
filter: "status = 'active'",
comment: "Covering index for active orders",
}),
],
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(result.statements, [
'CREATE UNIQUE INDEX "idx_orders_covering" ON "public"."orders" USING BTREE ("user_id", "status") INCLUDE ("total") WHERE status = \'active\';',
"COMMENT ON INDEX \"idx_orders_covering\" IS 'Covering index for active orders';",
]);
});
test("SQL Server index with type prefix", () => {
const result = buildTableStructureChangeSql({
databaseType: "sqlserver",
schema: "dbo",
tableName: "logs",
columns: [],
indexes: [
index({
id: "new",
name: "idx_logs_message",
columns: ["message"],
indexType: "CLUSTERED",
}),
],
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(result.statements, [
"CREATE CLUSTERED INDEX [idx_logs_message] ON [dbo].[logs] ([message]);",
]);
});
test("SQL Server index with INCLUDE clause", () => {
const result = buildTableStructureChangeSql({
databaseType: "sqlserver",
schema: "dbo",
tableName: "orders",
columns: [],
indexes: [
index({
id: "new",
name: "idx_orders_status",
columns: ["status"],
includedColumns: ["total", "created_at"],
}),
],
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(result.statements, [
"CREATE INDEX [idx_orders_status] ON [dbo].[orders] ([status]) INCLUDE ([total], [created_at]);",
]);
});
test("SQL Server index with type + include combined", () => {
const result = buildTableStructureChangeSql({
databaseType: "sqlserver",
schema: "dbo",
tableName: "orders",
columns: [],
indexes: [
index({
id: "new",
name: "idx_orders_covering",
columns: ["user_id"],
indexType: "NONCLUSTERED",
includedColumns: ["total", "status"],
}),
],
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(result.statements, [
"CREATE NONCLUSTERED INDEX [idx_orders_covering] ON [dbo].[orders] ([user_id]) INCLUDE ([total], [status]);",
]);
});
test("MySQL index omits USING, type prefix, INCLUDE, and WHERE (unsupported)", () => {
const result = buildTableStructureChangeSql({
databaseType: "mysql",
tableName: "orders",
columns: [],
indexes: [
index({
id: "new",
name: "idx_orders_status",
columns: ["status"],
indexType: "BTREE",
includedColumns: ["total"],
filter: "deleted = 0",
comment: "Some comment",
}),
],
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(result.statements, [
"CREATE INDEX `idx_orders_status` ON `orders` (`status`);",
]);
});
test("SQLite index with filter (partial index)", () => {
const result = buildTableStructureChangeSql({
databaseType: "sqlite",
tableName: "users",
columns: [],
indexes: [
index({
id: "new",
name: "idx_users_active",
columns: ["email"],
filter: "deleted_at IS NULL",
}),
],
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(result.statements, [
'CREATE INDEX "idx_users_active" ON "users" ("email") WHERE deleted_at IS NULL;',
]);
});
test("index with empty name and columns produces warnings and no statements", () => {
const result = buildTableStructureChangeSql({
databaseType: "postgres",
schema: "public",
tableName: "users",
columns: [],
indexes: [
index({ id: "empty", name: "", columns: [] }),
],
});
assert.deepEqual(result.warnings, [
'Index name cannot be empty.',
'Index "(new)" needs at least one column.',
]);
assert.deepEqual(result.statements, []);
});

View File

@ -3,7 +3,6 @@ import test from "node:test";
import {
createColumnDrafts,
createIndexDrafts,
splitIndexColumns,
toColumnNames,
} from "../src/lib/tableStructureEditorState.ts";
import type { ColumnInfo, IndexInfo } from "../src/types/database.ts";
@ -101,6 +100,5 @@ test("creates editable index drafts and splits pasted column lists", () => {
originalName: "idx_name",
},
]);
assert.deepEqual(splitIndexColumns("id, name email"), ["id", "name", "email"]);
assert.equal(toColumnNames(["id", "name"]), "id, name");
});