feat: add index type, included columns, filter, and comment support

- Add filter, index_type, included_columns, and comment fields to IndexInfo
- PostgreSQL: query pg_am for index type, use indnkeyatts to split key/included columns, retrieve index comments via obj_description()
- SQL Server: add type_desc, filter_definition, included columns via is_included_column
- MySQL: add INDEX_TYPE from information_schema.STATISTICS
- Oracle: add INDEX_TYPE from ALL_INDEXES
- DDL generation: add USING (PostgreSQL), type prefix + INCLUDE + WHERE (SQL Server), COMMENT ON INDEX (PostgreSQL)
- Frontend: update types, state mapping, SQL generation, and i18n translations
This commit is contained in:
yavon007 2026-05-03 19:49:50 +08:00
parent b88e1b8ccb
commit 4e1b35186b
15 changed files with 114 additions and 29 deletions

View File

@ -444,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)
}
@ -475,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

@ -52,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

@ -332,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, \
MIN(NON_UNIQUE) = 0 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 \
GROUP BY INDEX_NAME, INDEX_TYPE \
ORDER BY INDEX_NAME",
quote_value(database),
quote_value(table),
@ -354,6 +355,10 @@ pub async fn list_indexes(pool: &MySqlPool, database: &str, table: &str) -> Resu
columns: cols_str.split(',').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

@ -134,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.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('\'', "''")
);
@ -152,6 +153,10 @@ pub async fn list_indexes(conn: &OracleClient, schema: &str, table: &str) -> Res
columns: cols_str.split(',').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

@ -275,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, 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)
@ -296,11 +302,20 @@ 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 included = if nkeyatts < all_cols.len() { all_cols[nkeyatts..].to_vec() } else { vec![] };
IndexInfo {
name: row.get::<String, _>("index_name"),
columns: all_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

@ -87,6 +87,10 @@ pub async fn list_indexes(pool: &SqlitePool, _schema: &str, table: &str) -> Resu
columns,
is_unique,
is_primary,
filter: None,
index_type: None,
included_columns: None,
comment: None,
});
}
Ok(indexes)

View File

@ -155,13 +155,16 @@ pub async fn get_columns(client: &mut SqlServerClient, schema: &str, table: &str
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('\'', "''")
);
@ -169,11 +172,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(),
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

@ -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,13 @@ 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 ? ` INCLUDE (${incCols.map((c) => quoteId(c, dbType)).join(", ")})` : "";
const filter = idx.source.filter ? ` 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,18 @@ 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 ? ` INCLUDE (${incCols.map((c) => quoteIdent(databaseType, c)).join(", ")})` : "";
const filter = clean(index.filter);
const whereClause = filter ? ` 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,
};

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");
});