Merge pull request #68 from yavon007/main
feat: improve column type precision and index metadata across all databases
This commit is contained in:
commit
ea0dce23e2
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -335,7 +335,12 @@ export default {
|
|||
actions: "操作",
|
||||
indexName: "索引名",
|
||||
indexColumns: "字段列表",
|
||||
indexColumnsPlaceholder: "选择列...",
|
||||
unique: "唯一",
|
||||
indexType: "类型",
|
||||
includedColumns: "包含列",
|
||||
includedColumnsPlaceholder: "选择列...",
|
||||
filter: "过滤条件",
|
||||
primary: "主键",
|
||||
drop: "删除",
|
||||
restore: "恢复",
|
||||
|
|
|
|||
|
|
@ -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};`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(", ");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, []);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue