use super::comments::build_sqlserver_index_comment_sql_for_profile; use super::dialect::{capabilities_for, database_label, database_type_for_dialect, dialect_label, StructureDialect}; use super::types::{EditableStructureIndex, IndexInfo, TableStructureSqlOptions}; use super::util::{clean, qualified_table, quote_ident, quote_new_ident, quote_string}; use crate::models::connection::DatabaseType; pub(super) fn build_index_sql(options: &TableStructureSqlOptions, warnings: &mut Vec) -> Vec { let capabilities; let dialect; if options.is_gaussdb_m_mode { let caps = super::dialect::gaussdb_m_capabilities(); capabilities = caps; dialect = StructureDialect::GaussdbM; } else { let caps = capabilities_for(options.database_type, options.driver_profile.as_deref()); capabilities = caps; dialect = caps.dialect; } let table = qualified_table(dialect, options.schema.as_deref(), &options.table_name); let database_label = database_label(options.database_type); let mut statements = Vec::new(); for index in &options.indexes { if index.marked_for_drop { let Some(original) = &index.original else { continue; }; if !capabilities.drop_index { warnings.push(format!("Dropping indexes is not supported for {database_label} from this editor.")); continue; } if original.is_primary { warnings.push(format!("Primary index \"{}\" cannot be dropped from this editor.", original.name)); continue; } if is_dameng_constraint_backed(options.database_type, original) { statements.push(build_dameng_drop_constraint_sql(dialect, &table, &original.name)); continue; } statements.push(build_drop_index_sql( options.database_type, dialect, &table, options.schema.as_deref(), &original.name, )); continue; } if let Some(original) = &index.original { if !has_existing_index_change(index) { continue; } if !capabilities.rebuild_index || !capabilities.drop_index || !capabilities.create_index { warnings .push(format!("Editing existing indexes is not supported for {database_label} from this editor.")); continue; } if original.is_primary { warnings.push(format!("Primary index \"{}\" cannot be edited from this editor.", original.name)); continue; } if is_dameng_constraint_backed(options.database_type, original) { statements.extend(build_dameng_constraint_index_edit( options, dialect, &table, index, original, capabilities.index_concurrent, warnings, )); continue; } let or_replace = options.database_type == Some(DatabaseType::Dameng) && clean(&index.name) == clean(&original.name); if !or_replace { statements.push(build_drop_index_sql( options.database_type, dialect, &table, options.schema.as_deref(), &original.name, )); } statements.extend(build_create_index_statements( options.database_type, dialect, &table, index, warnings, options.schema.as_deref(), &options.table_name, or_replace, capabilities.index_concurrent, false, options.driver_profile.as_deref(), )); continue; } if !capabilities.create_index { warnings.push(format!("Creating indexes is not supported for {database_label} from this editor.")); continue; } statements.extend(build_create_index_statements( options.database_type, dialect, &table, index, warnings, options.schema.as_deref(), &options.table_name, false, capabilities.index_concurrent, false, options.driver_profile.as_deref(), )); } statements } /// Dameng builds the index behind a PRIMARY KEY / UNIQUE constraint as a "virtual" index /// owned by that constraint. Index-level DDL against such an index — `DROP INDEX` as much as /// `CREATE OR REPLACE INDEX` — is rejected with a misleading "no permission to drop index" /// error even for the owning user (#7959); it can only be changed through /// `ALTER TABLE ... ADD/DROP CONSTRAINT`, the same way Dameng primary keys are already /// handled in `columns.rs`. /// /// A "real" unique index (`CREATE UNIQUE INDEX`, which is also what this editor emits for a /// newly created unique index) has no constraint behind it, is not reported as /// constraint-backed by introspection, and keeps the index-level path — constraint DDL would /// fail on it with "constraint does not exist". fn is_dameng_constraint_backed(database_type: Option, original: &IndexInfo) -> bool { database_type == Some(DatabaseType::Dameng) && original.constraint_backed } fn build_dameng_drop_constraint_sql(dialect: StructureDialect, table: &str, constraint_name: &str) -> String { format!("ALTER TABLE {table} DROP CONSTRAINT {};", quote_ident(dialect, constraint_name)) } /// Rewrites an edit of a constraint-backed Dameng index as constraint DDL: drop the /// constraint, then re-add it as `UNIQUE` (still unique) or replace it with an ordinary /// index (downgraded to a plain index, where index-level DDL is fine again because the /// constraint is gone). fn build_dameng_constraint_index_edit( options: &TableStructureSqlOptions, dialect: StructureDialect, table: &str, index: &EditableStructureIndex, original: &IndexInfo, concurrently_supported: bool, warnings: &mut Vec, ) -> Vec { let name = clean(&index.name); let columns: Vec = index.columns.iter().map(|column| clean(column)).filter(|column| !column.is_empty()).collect(); // Same guard as `build_create_index_statements`: an empty name or an empty column list // cannot produce a valid replacement (`validate_draft` already reports it as a warning). // Emitting only the DROP would silently delete the constraint, so skip the edit entirely. if name.is_empty() || columns.is_empty() { return Vec::new(); } let mut statements = vec![build_dameng_drop_constraint_sql(dialect, table, &original.name)]; if !index.is_unique { statements.extend(build_create_index_statements( options.database_type, dialect, table, index, warnings, options.schema.as_deref(), &options.table_name, false, concurrently_supported, false, options.driver_profile.as_deref(), )); return statements; } // `build_create_index_statements` would honor BITMAP for Dameng, but a unique constraint // always builds a normal index behind itself, so the type cannot be carried over. if normalized_index_type(index) == "BITMAP" { warnings.push(format!( "Index type BITMAP is ignored for unique index \"{name}\": Dameng enforces it with a unique constraint, whose index cannot be a bitmap index." )); } let cols = columns.iter().map(|column| quote_ident(dialect, column)).collect::>().join(", "); statements.push(format!("ALTER TABLE {table} ADD CONSTRAINT {} UNIQUE ({cols});", quote_ident(dialect, &name))); statements } pub fn has_existing_index_change(index: &EditableStructureIndex) -> bool { let Some(original) = &index.original else { return false; }; clean(&index.name) != clean(&original.name) || index_list_changed(&index.columns, Some(&original.columns)) || index.is_unique != original.is_unique || normalized_index_type(index) != clean(original.index_type.as_deref().unwrap_or("")).to_ascii_uppercase() || index_list_changed(&index.included_columns, original.included_columns.as_ref()) || clean(&index.filter) != clean(original.filter.as_deref().unwrap_or("")) || clean(&index.comment) != clean(original.comment.as_deref().unwrap_or("")) || index_opclasses_changed( &index.column_opclasses, &original.column_opclasses, &index.columns, &original.columns, ) } /// Compare opclass arrays positionally, skipping expression columns. /// When the column list has changed we already detect that above; here we only /// compare opclasses for columns that exist in both the edited and original lists. fn index_opclasses_changed( next: &[Option], previous: &[Option], next_cols: &[String], prev_cols: &[String], ) -> bool { // If the edited side has explicit opclasses, compare positionally. if !next.is_empty() { // Length mismatch (shouldn't happen when columns are in lockstep, but // defend against it): any difference in length is a change. if next.len() != previous.len() { return true; } return next.iter().zip(previous.iter()).any(|(n, p)| { let n_flat = n.as_deref().map(clean).filter(|v| !v.is_empty()); let p_flat = p.as_deref().map(clean).filter(|v| !v.is_empty()); n_flat != p_flat }); } // If the edited side has no opclasses, check whether the original side // had any non-default opclasses for columns still present. if previous.iter().any(|o| o.as_deref().map(|v| !clean(v).is_empty()).unwrap_or(false)) { let prev_set: std::collections::HashSet<_> = prev_cols.iter().map(|c| clean(c)).collect(); return next_cols.iter().zip(previous.iter()).any(|(nc, po)| { po.as_deref().map(|v| !clean(v).is_empty()).unwrap_or(false) && prev_set.contains(&clean(nc)) }); } false } pub(super) fn index_list_changed(next: &[String], previous: Option<&Vec>) -> bool { let next_clean: Vec<_> = next.iter().map(|value| clean(value)).filter(|value| !value.is_empty()).collect(); let previous_clean: Vec<_> = previous.unwrap_or(&Vec::new()).iter().map(|value| clean(value)).filter(|value| !value.is_empty()).collect(); next_clean.len() != previous_clean.len() || next_clean.iter().enumerate().any(|(index, value)| previous_clean.get(index) != Some(value)) } pub(super) fn normalized_index_type(index: &EditableStructureIndex) -> String { clean(&index.index_type).to_ascii_uppercase() } pub(super) fn mysql_index_parts(index_type: &str) -> (String, String) { match index_type.to_ascii_uppercase().as_str() { "FULLTEXT" | "SPATIAL" => (format!("{} ", index_type.to_ascii_uppercase()), String::new()), "RTREE" => ("SPATIAL ".to_string(), String::new()), "BTREE" | "HASH" => (String::new(), format!(" USING {}", index_type.to_ascii_uppercase())), _ => (String::new(), String::new()), } } fn gaussdbm_index_parts(index_type: &str) -> (String, String) { match index_type.to_ascii_uppercase().as_str() { "BTREE" | "UBTREE" => (String::new(), " USING UBTREE".to_string()), "HASH" => (String::new(), " USING HASH".to_string()), _ => (String::new(), String::new()), } } fn gaussdbm_index_column_sql(column: &str, is_expression: bool) -> String { let trimmed = column.trim(); if is_expression { return trimmed.to_string(); } if let Some((name, suffix)) = trimmed.rsplit_once('(') { if let Some(length) = suffix.strip_suffix(')') { if !name.trim().is_empty() && !length.is_empty() && length.chars().all(|ch| ch.is_ascii_digit()) { return format!("{}({length})", quote_ident(StructureDialect::GaussdbM, name.trim())); } } } quote_ident(StructureDialect::GaussdbM, trimmed) } fn mysql_index_column_sql(column: &str) -> String { let trimmed = column.trim(); // Keep the wrapped expression from MySQL metadata instead of quoting it as a column identifier. if trimmed.starts_with("((") && trimmed.ends_with("))") { trimmed.to_string() } else { quote_ident(StructureDialect::Mysql, column) } } fn postgres_index_column_sql(column: &str, is_expression: bool, opclass: Option<&str>) -> String { // The base key text: a real column is quoted as an identifier; an expression/functional // key part arrives as raw expression text (from the per-column `pg_get_indexdef`, which // omits the opclass — see `list_indexes_with_sql`), so quoting the whole thing as an // identifier would turn it into a nonexistent column reference (#6295). let base = if is_expression { column.trim().to_string() } else { quote_ident(StructureDialect::Postgres, column.trim()) }; // The opclass is read separately from `pg_index.indclass` for every key position // (including expression keys) and appended uniformly — it never lives inside the // expression text, so there is no duplication risk. match opclass.filter(|o| !o.is_empty()) { Some(opc) => format!("{} {}", base, opc), None => base, } } /// Per-key expression provenance for `columns` (the edited/current key list), computed once for /// the whole index. The editor only lets users toggle real table columns onto an index (see /// TableStructureEditor.vue's column checkbox list) — there is no free-text expression input — /// so an edited key part is only ever an expression when it corresponds to one from the original /// introspected snapshot. /// /// Matches each edited key part against the first *not yet claimed* original key part with the /// same text, rather than the first textual match overall: a real column whose name happens to /// equal another key's expression text (or two key parts with identical text) can otherwise steal /// the wrong original's provenance. Consuming each original slot at most once keeps provenance /// tied to its true ordinal position instead of being re-derived by scanning for a text match /// (#6312 review). A key without explicit provenance is treated as a real column: the editor can /// only add table columns, and guessing from the identifier text breaks valid quoted names. fn key_expression_flags(index: &EditableStructureIndex, columns: &[String]) -> Vec { let original = match &index.original { Some(original) if !original.key_is_expression.is_empty() => original, _ => return vec![false; columns.len()], }; let mut consumed = vec![false; original.columns.len()]; columns .iter() .map(|column| { let claimed = original .columns .iter() .enumerate() .find(|(i, original_column)| !consumed[*i] && *original_column == column); match claimed { Some((i, _)) => { consumed[i] = true; original.key_is_expression.get(i).copied().unwrap_or(false) } None => false, } }) .collect() } /// Per-key operator class provenance for `columns` (the edited/current key list). /// /// Honors explicit per-key opclasses (`index.column_opclasses`) positionally when /// they are aligned to `columns` (non-empty, same length) and represent a genuine /// edit: either there is no `original` (a newly created index, where the UI /// authors `column_opclasses` in lockstep with `columns`), or the edited /// opclasses differ from the round-tripped `original.column_opclasses`. A stale /// copy left behind by a column toggle/reorder — still equal to the original — /// falls through to the order-independent text-matching below, so opclasses are /// never misassigned positionally before the editor maintains `column_opclasses` /// in lockstep with `columns`. Once that lockstep is in place the guard always /// passes and the honor branch is authoritative. fn key_opclasses(index: &EditableStructureIndex, columns: &[String]) -> Vec> { if !index.column_opclasses.is_empty() && index.column_opclasses.len() == columns.len() && index.original.as_ref().is_none_or(|original| original.column_opclasses != index.column_opclasses) { return index.column_opclasses.to_vec(); } let original = match &index.original { Some(original) if !original.column_opclasses.is_empty() => original, _ => return vec![None; columns.len()], }; let mut consumed = vec![false; original.columns.len()]; columns .iter() .map(|column| { let claimed = original .columns .iter() .enumerate() .find(|(i, original_column)| !consumed[*i] && *original_column == column); match claimed { Some((i, _)) => { consumed[i] = true; original.column_opclasses.get(i).cloned().flatten() } None => None, } }) .collect() } pub(super) fn build_drop_index_sql( database_type: Option, dialect: StructureDialect, table: &str, schema: Option<&str>, index_name: &str, ) -> String { if database_type == Some(DatabaseType::Iris) { return format!("DROP INDEX {} ON TABLE {table};", quote_ident(dialect, index_name)); } if matches!(dialect, StructureDialect::Mysql | StructureDialect::SqlServer) { return format!("DROP INDEX {} ON {table};", quote_ident(dialect, index_name)); } if matches!( dialect, StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar | StructureDialect::Informix | StructureDialect::Sqlite ) && schema.is_some_and(|schema| !schema.trim().is_empty()) { return format!("DROP INDEX {}.{};", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, index_name)); } format!("DROP INDEX {};", quote_ident(dialect, index_name)) } #[expect( clippy::too_many_arguments, reason = "The existing index builder API passes independent structure-edit options" )] pub(super) fn build_create_index_statements( database_type: Option, dialect: StructureDialect, table: &str, index: &EditableStructureIndex, warnings: &mut Vec, schema: Option<&str>, table_name: &str, or_replace: bool, concurrently_supported: bool, for_new_table: bool, driver_profile: Option<&str>, ) -> Vec { let capabilities = capabilities_for(database_type_for_dialect(dialect), None); let name = clean(&index.name); let columns: Vec = index.columns.iter().map(|column| clean(column)).filter(|column| !column.is_empty()).collect(); if name.is_empty() || columns.is_empty() { return Vec::new(); } // `CREATE INDEX CONCURRENTLY` is PostgreSQL-only: the flag is honored only when // the caller's real database type advertises the capability, so a stale or // forged `concurrently: true` from another engine is ignored and the original // SQL is generated unchanged. Unsupported concurrent requests (existing index, // partitioned parent) are rejected up front by `validate_concurrent_index_scope` // before any statement is built — this function never downgrades them. let concurrently = index.concurrently && concurrently_supported && dialect == StructureDialect::Postgres; let unique = if index.is_unique { "UNIQUE " } else { "" }; let replace = if or_replace { "OR REPLACE " } else { "" }; let key_is_expression = key_expression_flags(index, &columns); let key_opclasses = key_opclasses(index, &columns); let cols = columns .iter() .enumerate() .map(|(i, column)| { if dialect == StructureDialect::Mysql { mysql_index_column_sql(column) } else if dialect == StructureDialect::GaussdbM { gaussdbm_index_column_sql(column, key_is_expression[i]) } else if dialect == StructureDialect::Postgres { postgres_index_column_sql(column, key_is_expression[i], key_opclasses[i].as_deref()) } else if for_new_table { quote_new_ident(database_type, dialect, column) } else { quote_ident(dialect, column) } }) .collect::>() .join(", "); let idx_type = normalized_index_type(index); let mut type_prefix = String::new(); let mut using_clause = String::new(); if !idx_type.is_empty() && capabilities.index_type { match dialect { StructureDialect::Postgres => using_clause = format!(" USING {idx_type}"), StructureDialect::SqlServer => type_prefix = format!("{idx_type} "), StructureDialect::Mysql => { let (prefix, using) = mysql_index_parts(&idx_type); type_prefix = prefix; using_clause = using; } StructureDialect::Oracle | StructureDialect::Dameng if idx_type == "BITMAP" => { type_prefix = "BITMAP ".to_string() } StructureDialect::GaussdbM => { let (prefix, using) = gaussdbm_index_parts(&idx_type); type_prefix = prefix; using_clause = using; } _ => {} } } let included_columns: Vec = index.included_columns.iter().map(|column| clean(column)).filter(|column| !column.is_empty()).collect(); let include_clause = if !included_columns.is_empty() && capabilities.index_include && matches!(dialect, StructureDialect::Postgres | StructureDialect::SqlServer) { format!( " INCLUDE ({})", included_columns .iter() .map(|column| { if for_new_table { quote_new_ident(database_type, dialect, column) } else { quote_ident(dialect, column) } }) .collect::>() .join(", ") ) } else { String::new() }; let comment = clean(&index.comment); let comment_clause = if !comment.is_empty() && capabilities.index_comment && matches!(dialect, StructureDialect::Mysql | StructureDialect::GaussdbM) { format!(" COMMENT {}", quote_string(&comment)) } else { String::new() }; let filter = clean(&index.filter); let supports_where = capabilities.index_filter && matches!(dialect, StructureDialect::Postgres | StructureDialect::SqlServer | StructureDialect::Sqlite); let where_clause = if !filter.is_empty() && supports_where { format!(" WHERE {filter}") } else { String::new() }; let quoted_index_name = if for_new_table { quote_new_ident(database_type, dialect, &name) } else { quote_ident(dialect, &name) }; let index_name = if dialect == StructureDialect::Sqlite && schema.is_some_and(|schema| !schema.trim().is_empty()) { format!("{}.{}", quote_ident(dialect, schema.unwrap()), quoted_index_name) } else { quoted_index_name }; // SQLite assigns an index to the database named by the qualified index. // Its CREATE INDEX grammar does not allow a qualified table name. let create_table = if dialect == StructureDialect::Sqlite { quote_ident(dialect, table_name) } else { table.to_string() }; let create_sql = if dialect == StructureDialect::Postgres { let concurrent_clause = if concurrently { "CONCURRENTLY " } else { "" }; format!( "CREATE {replace}{unique}INDEX {concurrent_clause}{index_name} ON {create_table}{using_clause} ({cols}){include_clause}{where_clause};" ) } else { format!( "CREATE {replace}{unique}{type_prefix}INDEX {index_name}{using_clause} ON {create_table} ({cols}){include_clause}{where_clause}{comment_clause};" ) }; let mut statements = vec![create_sql]; if !comment.is_empty() && capabilities.index_comment && dialect == StructureDialect::Postgres { statements.push(format!("COMMENT ON INDEX {} IS {};", quote_ident(dialect, &name), quote_string(&comment))); } else if !comment.is_empty() && capabilities.index_comment && dialect == StructureDialect::SqlServer { statements.extend(build_sqlserver_index_comment_sql_for_profile( table, schema, table_name, &name, &comment, driver_profile, )); } else if !comment.is_empty() && capabilities.index_comment && matches!(dialect, StructureDialect::Mysql | StructureDialect::GaussdbM) { // Comment is embedded inline in the CREATE INDEX statement above } else if !comment.is_empty() && capabilities.index_comment { warnings.push(format!("Index comments are not supported for {} from this editor.", dialect_label(dialect))); } statements }