diff --git a/agents/common/src/main/java/com/dbx/agent/IndexInfo.java b/agents/common/src/main/java/com/dbx/agent/IndexInfo.java index be171e9960..335775d9b6 100644 --- a/agents/common/src/main/java/com/dbx/agent/IndexInfo.java +++ b/agents/common/src/main/java/com/dbx/agent/IndexInfo.java @@ -18,6 +18,12 @@ public final class IndexInfo { // doesn't track this (provenance unknown for that dialect/path). Mirrors // crate::types::IndexInfo::key_is_expression on the Rust side (#6312 review). private List key_is_expression; + // True when this index is the object behind a table constraint (PRIMARY KEY / UNIQUE) + // instead of a standalone index. Dameng lists both kinds in ALL_INDEXES but only accepts + // index-level DDL for standalone ones, so the SQL builder needs to tell them apart + // (#7959). Mirrors crate::types::IndexInfo::constraint_backed on the Rust side; false + // when the introspection source doesn't report it. + private boolean constraint_backed; public IndexInfo() { this("", Collections.emptyList(), false, false); @@ -98,6 +104,10 @@ public final class IndexInfo { return key_is_expression; } + public boolean getConstraint_backed() { + return constraint_backed; + } + public void setName(String name) { this.name = name; } @@ -134,6 +144,10 @@ public final class IndexInfo { this.key_is_expression = key_is_expression; } + public void setConstraint_backed(boolean constraint_backed) { + this.constraint_backed = constraint_backed; + } + @Override public boolean equals(Object other) { if (this == other) return true; @@ -141,6 +155,7 @@ public final class IndexInfo { IndexInfo that = (IndexInfo) other; return is_unique == that.is_unique && is_primary == that.is_primary + && constraint_backed == that.constraint_backed && Objects.equals(name, that.name) && Objects.equals(columns, that.columns) && Objects.equals(filter, that.filter) @@ -153,7 +168,16 @@ public final class IndexInfo { @Override public int hashCode() { return Objects.hash( - name, columns, is_unique, is_primary, filter, index_type, included_columns, comment, key_is_expression + name, + columns, + is_unique, + is_primary, + filter, + index_type, + included_columns, + comment, + key_is_expression, + constraint_backed ); } @@ -168,6 +192,7 @@ public final class IndexInfo { + ", included_columns=" + included_columns + ", comment=" + comment + ", key_is_expression=" + key_is_expression + + ", constraint_backed=" + constraint_backed + ")"; } } diff --git a/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java b/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java index 02db30b45b..203ee2e127 100644 --- a/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java +++ b/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java @@ -1818,16 +1818,24 @@ public final class DamengAgent extends AbstractJdbcAgent { } return unchecked(() -> { List result = new ArrayList<>(); + // The LEFT JOIN also matches 'U': a UNIQUE constraint owns its backing index the same + // way a primary key does, and neither can be altered with index-level DDL (#7959). + // The constraint type therefore drives two flags — IS_PK (only 'P') and + // CONSTRAINT_BACKED ('P' or 'U'), the same split `independentIndexes` already uses to + // keep constraint-backed indexes out of the generated table DDL. A unique index + // created with CREATE UNIQUE INDEX has no ALL_CONSTRAINTS row, so it stays false and + // keeps the index-level DDL path. String sql = """ SELECT /*+ PARALLEL(1) */ 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, - i.INDEX_TYPE + i.INDEX_TYPE, + CASE WHEN c.CONSTRAINT_TYPE IN ('P', 'U') THEN 1 ELSE 0 END AS CONSTRAINT_BACKED 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' + AND c.CONSTRAINT_TYPE IN ('P', 'U') WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ? GROUP BY i.INDEX_NAME, i.UNIQUENESS, c.CONSTRAINT_TYPE, i.INDEX_TYPE ORDER BY i.INDEX_NAME @@ -1837,7 +1845,7 @@ public final class DamengAgent extends AbstractJdbcAgent { stmt.setString(2, table); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { - result.add(new IndexInfo( + IndexInfo index = new IndexInfo( rs.getString(1), splitNonEmpty(coalesce(rs.getString(2)), ","), "UNIQUE".equals(rs.getString(3)), @@ -1846,7 +1854,9 @@ public final class DamengAgent extends AbstractJdbcAgent { rs.getString(5), null, null - )); + ); + index.setConstraint_backed("1".equals(rs.getString(6))); + result.add(index); } } } diff --git a/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java b/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java index 0cbb92aa96..12d9f0b1af 100644 --- a/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java +++ b/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java @@ -2,6 +2,7 @@ package com.dbx.agent.dameng; import com.dbx.agent.ColumnInfo; import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.IndexInfo; import com.dbx.agent.MetadataListConstraints; import com.dbx.agent.ObjectInfo; import com.dbx.agent.ObjectSource; @@ -81,6 +82,53 @@ class DamengAgentMetadataTest { Assertions.assertTrue(indexesSql.startsWith("SELECT /*+ PARALLEL(1) */"), indexesSql); } + @Test + void identifiesUniqueConstraintBackingIndexesInIndexMetadataQuery() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listIndexes("APP", "USERS"); + + String indexesSql = JdbcMetadataSqlFake.statements.stream() + .filter(sql -> sql.contains("ALL_INDEXES") && sql.contains("ALL_CONSTRAINTS")) + .findFirst() + .orElseThrow(); + Assertions.assertTrue(indexesSql.contains("AND c.CONSTRAINT_TYPE IN ('P', 'U')"), indexesSql); + Assertions.assertTrue(indexesSql.contains("AS CONSTRAINT_BACKED"), indexesSql); + Assertions.assertTrue(indexesSql.contains("CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK"), indexesSql); + } + + @Test + void marksOnlyConstraintBackedIndexesAsConstraintBacked() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, indexMetadataConnection(List.of( + // UNIQUE constraint (虚索引) / standalone CREATE UNIQUE INDEX (实索引) / plain index. + Arrays.asList("UX_USERS_EMAIL", "EMAIL", "UNIQUE", "0", "NORMAL", "1"), + Arrays.asList("UX_USERS_CODE", "CODE", "UNIQUE", "0", "NORMAL", "0"), + Arrays.asList("IDX_USERS_NAME", "NAME", "NONUNIQUE", "0", "NORMAL", "0"), + Arrays.asList("PK_USERS", "ID", "UNIQUE", "1", "NORMAL", "1") + ))); + + List indexes = agent.listIndexes("APP", "USERS"); + + Assertions.assertEquals( + List.of("UX_USERS_EMAIL", "UX_USERS_CODE", "IDX_USERS_NAME", "PK_USERS"), + indexes.stream().map(IndexInfo::getName).toList() + ); + Assertions.assertEquals( + List.of(true, false, false, true), + indexes.stream().map(IndexInfo::getConstraint_backed).toList() + ); + Assertions.assertEquals( + List.of(false, false, false, true), + indexes.stream().map(IndexInfo::getIs_primary).toList() + ); + Assertions.assertEquals( + List.of(true, true, false, true), + indexes.stream().map(IndexInfo::getIs_unique).toList() + ); + } + @Test void usesTableCommentsMetadataQuery() { DamengAgent agent = new DamengAgent(); @@ -1428,6 +1476,24 @@ class DamengAgentMetadataTest { )); } + // Serves every metadata query with the same positional rows; the index metadata query is + // the only one these tests run against it. + private static Connection indexMetadataConnection(List> rows) { + return proxy(Connection.class, (method, args) -> { + String name = method.getName(); + if ("prepareStatement".equals(name)) { + return metadataStatement(rows); + } + if ("close".equals(name)) { + return null; + } + if ("isClosed".equals(name)) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + private static List indexRow(String name, String columns, String uniqueness, String indexType) { return List.of(name, columns, uniqueness, indexType); } diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 438342d1dc..f9a62e030d 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -554,6 +554,12 @@ export interface IndexInfo { key_is_expression?: boolean[] | null; /** Parallel to `columns`: operator class name for each key column (PostgreSQL), if non-default. */ column_opclasses?: (string | null)[] | null; + /** + * True when the index is the object behind a PRIMARY KEY / UNIQUE constraint rather than a + * standalone index. Carried back to the backend inside the index draft's `original` snapshot: + * Dameng only accepts `ALTER TABLE ... ADD/DROP CONSTRAINT` for those indexes. + */ + constraint_backed?: boolean | null; } export interface ReferenceKeyInfo { diff --git a/crates/dbx-core/src/db/clickhouse_driver.rs b/crates/dbx-core/src/db/clickhouse_driver.rs index 1f1f6370d8..74988a641a 100644 --- a/crates/dbx-core/src/db/clickhouse_driver.rs +++ b/crates/dbx-core/src/db/clickhouse_driver.rs @@ -542,6 +542,7 @@ fn clickhouse_index_from_skipping_row(row: &[serde_json::Value]) -> IndexInfo { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, } } @@ -794,6 +795,7 @@ pub async fn list_indexes(client: &ChClient, database: &str, table: &str) -> Res comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); } diff --git a/crates/dbx-core/src/db/cloudflare_d1/mod.rs b/crates/dbx-core/src/db/cloudflare_d1/mod.rs index 26c73e0cb0..9040e33232 100644 --- a/crates/dbx-core/src/db/cloudflare_d1/mod.rs +++ b/crates/dbx-core/src/db/cloudflare_d1/mod.rs @@ -201,6 +201,7 @@ pub async fn list_indexes(client: &CloudflareD1Client, _schema: &str, table: &st comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); } Ok(indexes) diff --git a/crates/dbx-core/src/db/manticoresearch.rs b/crates/dbx-core/src/db/manticoresearch.rs index 99f352f3b6..6f0a57a135 100644 --- a/crates/dbx-core/src/db/manticoresearch.rs +++ b/crates/dbx-core/src/db/manticoresearch.rs @@ -211,6 +211,7 @@ fn index_info_from_row(row: &mysql_async::Row) -> IndexInfo { comment: (!comment_parts.is_empty()).then(|| comment_parts.join(", ")), key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, } } diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 66d3f69266..3e52cc09a8 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -905,6 +905,7 @@ fn index_info_from_model(model: IndexModel) -> IndexInfo { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, } } @@ -3639,6 +3640,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); assert_eq!(spec.name, "email_1"); @@ -3665,6 +3667,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); assert_eq!( @@ -3909,6 +3912,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }, IndexInfo { name: "users_email_unique".to_string(), @@ -3921,6 +3925,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }, IndexInfo { name: "users_status_idx".to_string(), @@ -3933,6 +3938,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }, ]; let after = vec![before[0].clone(), before[2].clone()]; diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index 08ea2e4ca3..05dccf130a 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -5776,6 +5776,7 @@ pub async fn list_indexes(pool: &MySqlPool, database: &str, table: &str) -> Resu comment: get_opt_str(&row, "INDEX_COMMENT").filter(|value| !value.is_empty()), key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); index_position }; diff --git a/crates/dbx-core/src/db/mysql_compatible.rs b/crates/dbx-core/src/db/mysql_compatible.rs index 5c5fa797cb..5dce39ca5b 100644 --- a/crates/dbx-core/src/db/mysql_compatible.rs +++ b/crates/dbx-core/src/db/mysql_compatible.rs @@ -326,6 +326,7 @@ fn table_key_index(name: &str, line: &str, is_unique: bool, is_primary: bool, in comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }) } @@ -347,6 +348,7 @@ fn secondary_index(line: &str) -> Option { comment: mysql_quoted_string_argument(after_name, "COMMENT"), key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }) } diff --git a/crates/dbx-core/src/db/ob_oracle.rs b/crates/dbx-core/src/db/ob_oracle.rs index 87d8d960f3..3fc3e1e427 100644 --- a/crates/dbx-core/src/db/ob_oracle.rs +++ b/crates/dbx-core/src/db/ob_oracle.rs @@ -243,6 +243,7 @@ pub async fn list_indexes(pool: &mysql_async::Pool, schema: &str, table: &str) - comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, } }) .collect()) diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 217b205b34..a5c3780809 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -3848,6 +3848,7 @@ async fn list_indexes_for_relations_with_sql( comment: row.try_get::<_, Option>(10).ok().flatten(), key_is_expression, column_opclasses: key_opclasses, + constraint_backed: false, }); } Ok(result) @@ -7552,6 +7553,7 @@ async fn list_indexes_with_sql( comment: row.try_get::<_, Option>(9).ok().flatten(), key_is_expression, column_opclasses: key_opclasses, + constraint_backed: false, } }) .collect()) diff --git a/crates/dbx-core/src/db/questdb.rs b/crates/dbx-core/src/db/questdb.rs index c378ddf8ed..dfde97ea3c 100644 --- a/crates/dbx-core/src/db/questdb.rs +++ b/crates/dbx-core/src/db/questdb.rs @@ -178,6 +178,7 @@ pub async fn list_indexes(pool: &Pool, _schema: &str, table: &str) -> Result comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); } diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs index 9f6c24f405..352935e903 100644 --- a/crates/dbx-core/src/db/sqlite.rs +++ b/crates/dbx-core/src/db/sqlite.rs @@ -2494,6 +2494,7 @@ pub async fn list_indexes(pool: &SqliteHandle, schema: &str, table: &str) -> Res comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); } return Ok(indexes); @@ -2548,6 +2549,7 @@ pub async fn list_indexes(pool: &SqliteHandle, schema: &str, table: &str) -> Res comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); } Ok(indexes) diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 594f866187..e88bfc4f1b 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -2941,6 +2941,7 @@ pub async fn list_indexes(client: &mut SqlServerClient, schema: &str, table: &st comment: row.get::<&str, _>(7).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()), key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, } }) .collect()) diff --git a/crates/dbx-core/src/db/turso_driver.rs b/crates/dbx-core/src/db/turso_driver.rs index 5bdfd7232f..09fa5d728f 100644 --- a/crates/dbx-core/src/db/turso_driver.rs +++ b/crates/dbx-core/src/db/turso_driver.rs @@ -223,6 +223,7 @@ pub async fn list_indexes(client: &TursoClient, _schema: &str, table: &str) -> R comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); } diff --git a/crates/dbx-core/src/docs/dbml.rs b/crates/dbx-core/src/docs/dbml.rs index e144f0d0ba..23180244e0 100644 --- a/crates/dbx-core/src/docs/dbml.rs +++ b/crates/dbx-core/src/docs/dbml.rs @@ -601,6 +601,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }; let table = doc_table("orders", vec![col("user_id", "integer")], vec![index]); @@ -625,6 +626,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }; let table = doc_table("orders", vec![col("reference", "text")], vec![index]); let mut warnings = Vec::new(); @@ -645,6 +647,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }; let mut id = col("id", "integer"); id.is_primary_key = true; @@ -668,6 +671,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }; let table = doc_table("orders", vec![col("status", "text")], vec![index]); @@ -871,6 +875,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }; let out = to_dbml(&snapshot(vec![doc_table("orders", vec![col("status", "text")], vec![index])], vec!["public"])); diff --git a/crates/dbx-core/src/docs/relations.rs b/crates/dbx-core/src/docs/relations.rs index ac586cdb56..b69c915ed4 100644 --- a/crates/dbx-core/src/docs/relations.rs +++ b/crates/dbx-core/src/docs/relations.rs @@ -195,6 +195,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, } } diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 1d0e57f28a..8902b4aafc 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -3214,6 +3214,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }; let mut filtered = index("uq_active_code", &["active_code"], true, false); filtered.filter = Some("active = true".to_string()); @@ -8241,6 +8242,7 @@ async fn external_driver_gaussdb_m_indexes( comment: current_comment.clone(), key_is_expression: current_is_expression.clone(), column_opclasses: vec![], + constraint_backed: false, }); } // Start new index @@ -8311,6 +8313,7 @@ async fn external_driver_gaussdb_m_indexes( comment: current_comment, key_is_expression: current_is_expression, column_opclasses: vec![], + constraint_backed: false, }); } @@ -10317,6 +10320,7 @@ mod ddl_tests { comment: None, key_is_expression: vec![true], column_opclasses: vec![Some("gin_trgm_ops".to_string())], + constraint_backed: false, }]; let ddl = render_postgres_table_ddl("public", "users", &[id], &indexes, &[], None); @@ -10342,6 +10346,7 @@ mod ddl_tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }]; let partition_info = db::postgres::PostgresTablePartitionInfo { is_partition: true, @@ -10393,6 +10398,7 @@ mod ddl_tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }]; let partition_info = db::postgres::PostgresTablePartitionInfo { is_partition: true, diff --git a/crates/dbx-core/src/schema_diff.rs b/crates/dbx-core/src/schema_diff.rs index f609251c8b..ced2dd9e9b 100644 --- a/crates/dbx-core/src/schema_diff.rs +++ b/crates/dbx-core/src/schema_diff.rs @@ -5777,6 +5777,7 @@ mod tests { comment: overrides.comment, key_is_expression: overrides.key_is_expression, column_opclasses: overrides.column_opclasses, + constraint_backed: false, } } @@ -6770,6 +6771,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }; let diff = TableDiff { diff_type: "modified".into(), @@ -6833,6 +6835,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }; let unchanged_fk = ForeignKeyInfo { name: "fk_events_parent".into(), @@ -7171,6 +7174,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }; let btree_sql = create_index_sql("events", &btree, DatabaseType::SqlServer, Some("dbo")); assert_eq!( @@ -7190,6 +7194,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }; assert_eq!( create_index_sql("events", &columnstore, DatabaseType::SqlServer, Some("dbo")), @@ -7886,6 +7891,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], &[index(IndexInfo { name: "idx_orders_status".to_string(), @@ -7898,6 +7904,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], ); @@ -7919,6 +7926,7 @@ mod tests { comment: None, key_is_expression: vec![false], column_opclasses: vec![Some("gin_trgm_ops".to_string())], + constraint_backed: false, }); let target_index = index(IndexInfo { name: "idx_name_trgm".to_string(), @@ -7931,6 +7939,7 @@ mod tests { comment: None, key_is_expression: vec![false], column_opclasses: vec![None], + constraint_backed: false, }); let diffs = diff_indexes(&[source_index], &[target_index]); @@ -7958,6 +7967,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); let target_index = index(IndexInfo { name: "test_UNIQUE".to_string(), @@ -7970,6 +7980,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); let diffs = diff_indexes(std::slice::from_ref(&source_index), &[target_index]); @@ -8033,6 +8044,7 @@ mod tests { comment: None, key_is_expression: vec![false, false, false, true], column_opclasses: vec![], + constraint_backed: false, }); let sql = generate_schema_sync_sql( @@ -8094,6 +8106,7 @@ mod tests { ], key_is_expression: vec![false, false, false, true], column_opclasses: vec![], + constraint_backed: false, is_unique: true, is_primary: false, filter: None, @@ -8162,6 +8175,7 @@ mod tests { columns: vec!["order item".to_string(), "a(b)".to_string(), "a::b".to_string()], key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, is_unique: false, is_primary: false, filter: None, @@ -8350,6 +8364,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })), target: None, changes: Vec::new(), @@ -8699,6 +8714,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })), target: None, changes: Vec::new(), @@ -9423,6 +9439,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], foreign_keys: vec![foreign_key(ForeignKeyInfo { name: "Order User FK".to_string(), @@ -9487,6 +9504,7 @@ mod tests { comment: Some("status lookup".to_string()), key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], foreign_keys: vec![foreign_key(ForeignKeyInfo { name: "user-fk".to_string(), @@ -9541,6 +9559,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], foreign_keys: vec![foreign_key(ForeignKeyInfo { name: "parent item fk".to_string(), @@ -10616,6 +10635,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })), target: None, changes: vec![], @@ -10659,6 +10679,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })), changes: vec![], }]), @@ -11062,6 +11083,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], &[index(IndexInfo { name: "idx_t".into(), @@ -11074,6 +11096,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], ); assert_eq!(diffs.len(), 1, "index type diff detected"); @@ -11094,6 +11117,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], &[index(IndexInfo { name: "idx_t".into(), @@ -11106,6 +11130,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], ); assert_eq!(diffs[0].changes.iter().filter(|c| c.contains("FULLTEXT")).count(), 1, "fulltext change"); @@ -11126,6 +11151,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], &[index(IndexInfo { name: "idx_t".into(), @@ -11138,6 +11164,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], ); assert_eq!(diffs.len(), 1, "order diff detected"); @@ -11159,6 +11186,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], &[index(IndexInfo { name: "idx_t".into(), @@ -11171,6 +11199,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], ); assert_eq!(diffs.len(), 1, "included columns diff detected"); @@ -11191,6 +11220,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], &[index(IndexInfo { name: "idx_t".into(), @@ -11203,6 +11233,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], ); assert_eq!(diffs.len(), 1, "included added"); @@ -11223,6 +11254,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], &[index(IndexInfo { name: "idx_t".into(), @@ -11235,6 +11267,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })], ); assert_eq!(diffs.len(), 1, "filter diff"); @@ -11257,6 +11290,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }), index(IndexInfo { name: "idx_modified".into(), @@ -11269,6 +11303,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }), ], &[ @@ -11283,6 +11318,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }), index(IndexInfo { name: "idx_modified".into(), @@ -11295,6 +11331,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }), ], ); @@ -11666,6 +11703,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })), target: None, changes: vec![], @@ -11685,6 +11723,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, })), changes: vec![], }, @@ -12082,6 +12121,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }), target: None, changes: vec![], @@ -12336,6 +12376,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }), target: None, changes: vec![], diff --git a/crates/dbx-core/src/table_structure_sql/indexes.rs b/crates/dbx-core/src/table_structure_sql/indexes.rs index 3d79f02491..c175726854 100644 --- a/crates/dbx-core/src/table_structure_sql/indexes.rs +++ b/crates/dbx-core/src/table_structure_sql/indexes.rs @@ -1,6 +1,6 @@ use super::comments::build_sqlserver_index_comment_sql; use super::dialect::{capabilities_for, database_label, database_type_for_dialect, dialect_label, StructureDialect}; -use super::types::{EditableStructureIndex, TableStructureSqlOptions}; +use super::types::{EditableStructureIndex, IndexInfo, TableStructureSqlOptions}; use super::util::{clean, qualified_table, quote_ident, quote_new_ident, quote_string}; use crate::models::connection::DatabaseType; @@ -33,6 +33,10 @@ pub(super) fn build_index_sql(options: &TableStructureSqlOptions, warnings: &mut 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, @@ -56,6 +60,18 @@ pub(super) fn build_index_sql(options: &TableStructureSqlOptions, warnings: &mut 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 { @@ -103,6 +119,77 @@ pub(super) fn build_index_sql(options: &TableStructureSqlOptions, warnings: &mut 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, + )); + 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(super) fn has_existing_index_change(index: &EditableStructureIndex) -> bool { let Some(original) = &index.original else { return false; diff --git a/crates/dbx-core/src/table_structure_sql/sqlite_rebuild.rs b/crates/dbx-core/src/table_structure_sql/sqlite_rebuild.rs index a45a407feb..b03c50dae1 100644 --- a/crates/dbx-core/src/table_structure_sql/sqlite_rebuild.rs +++ b/crates/dbx-core/src/table_structure_sql/sqlite_rebuild.rs @@ -2029,6 +2029,7 @@ mod tests { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }), marked_for_drop: true, }); diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index c23f66e7a6..61bc5b440a 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -143,10 +143,20 @@ fn existing_index(name: &str, columns: &[&str], is_unique: bool) -> EditableStru comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); index } +/// A unique index that is the object *behind* a UNIQUE constraint (Dameng's "virtual" index, +/// `ALL_CONSTRAINTS.CONSTRAINT_TYPE = 'U'`), as opposed to a standalone ("real") unique index +/// created with `CREATE UNIQUE INDEX`, which `existing_index(.., true)` still models. +fn constraint_backed_unique_index(name: &str, columns: &[&str]) -> EditableStructureIndex { + let mut index = existing_index(name, columns, true); + index.original.as_mut().unwrap().constraint_backed = true; + index +} + fn existing_primary_index(name: &str, columns: &[&str]) -> EditableStructureIndex { let mut index = existing_index(name, columns, true); index.is_primary = true; @@ -236,6 +246,7 @@ fn builds_mysql_column_and_index_changes() { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); let mut email_index = index("uniq_users_email", &["email"]); email_index.is_unique = true; @@ -287,16 +298,43 @@ fn dameng_replaces_same_name_index_before_validating_uniqueness() { } #[test] -fn dameng_replaces_same_name_unique_index_with_normal_index() { - let mut changed = existing_index("IDX_USERS_EMAIL", &["EMAIL"], true); - changed.is_unique = false; +fn dameng_unique_index_column_change_drops_and_readds_constraint() { + // #7959: the original index is the one behind a UNIQUE constraint. Editing its columns + // while keeping it unique must go through ALTER TABLE ... DROP/ADD CONSTRAINT, not + // CREATE OR REPLACE UNIQUE INDEX — Dameng rejects that with "no permission to drop index" + // because the index is constraint-owned. + let mut changed = constraint_backed_unique_index("IDX_USERS_EMAIL", &["EMAIL"]); + changed.columns = vec!["EMAIL".to_string(), "TENANT_ID".to_string()]; let result = build_table_structure_change_sql(index_change_options(DatabaseType::Dameng, Some("APP"), changed)); assert_eq!(result.warnings, Vec::::new()); assert_eq!( result.statements, - vec!["CREATE OR REPLACE INDEX \"IDX_USERS_EMAIL\" ON \"APP\".\"USERS\" (\"EMAIL\");"] + vec![ + "ALTER TABLE \"APP\".\"USERS\" DROP CONSTRAINT \"IDX_USERS_EMAIL\";", + "ALTER TABLE \"APP\".\"USERS\" ADD CONSTRAINT \"IDX_USERS_EMAIL\" UNIQUE (\"EMAIL\", \"TENANT_ID\");", + ] + ); +} + +#[test] +fn dameng_replaces_same_name_unique_index_with_normal_index() { + let mut changed = constraint_backed_unique_index("IDX_USERS_EMAIL", &["EMAIL"]); + changed.is_unique = false; + + let result = build_table_structure_change_sql(index_change_options(DatabaseType::Dameng, Some("APP"), changed)); + + // The original index backs a unique constraint (#7959): downgrading it to a plain index + // has to drop that constraint first, then create an ordinary index — `CREATE OR REPLACE + // INDEX` against a constraint-backed index is rejected by Dameng. + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec![ + "ALTER TABLE \"APP\".\"USERS\" DROP CONSTRAINT \"IDX_USERS_EMAIL\";", + "CREATE INDEX \"IDX_USERS_EMAIL\" ON \"APP\".\"USERS\" (\"EMAIL\");", + ] ); } @@ -360,6 +398,122 @@ fn dameng_renamed_index_keeps_drop_then_create_path() { ); } +#[test] +fn dameng_renamed_unique_index_uses_constraint_ddl_not_drop_index() { + // Same root cause as #7959: a renamed constraint-backed index still has to go through + // DROP/ADD CONSTRAINT rather than DROP INDEX, since DROP INDEX against a + // constraint-backed index is rejected by Dameng regardless of whether the name changes. + let mut changed = constraint_backed_unique_index("IDX_USERS_EMAIL", &["EMAIL"]); + changed.name = "IDX_USERS_LOGIN".to_string(); + + let result = build_table_structure_change_sql(index_change_options(DatabaseType::Dameng, Some("APP"), changed)); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec![ + "ALTER TABLE \"APP\".\"USERS\" DROP CONSTRAINT \"IDX_USERS_EMAIL\";", + "ALTER TABLE \"APP\".\"USERS\" ADD CONSTRAINT \"IDX_USERS_LOGIN\" UNIQUE (\"EMAIL\");", + ] + ); +} + +#[test] +fn dameng_standalone_unique_index_keeps_index_level_ddl() { + // A unique index created with CREATE UNIQUE INDEX (Dameng's "real" index — which is also + // what this editor emits for a new unique index) has no constraint behind it: it is absent + // from ALL_CONSTRAINTS, so DROP CONSTRAINT would fail with "constraint does not exist". + // Editing it must stay on the index-level path, otherwise DBX breaks the lifecycle of the + // unique indexes it creates itself. + let mut changed = existing_index("IDX_USERS_EMAIL", &["EMAIL"], true); + changed.columns = vec!["EMAIL".to_string(), "TENANT_ID".to_string()]; + + let result = build_table_structure_change_sql(index_change_options(DatabaseType::Dameng, Some("APP"), changed)); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec!["CREATE OR REPLACE UNIQUE INDEX \"IDX_USERS_EMAIL\" ON \"APP\".\"USERS\" (\"EMAIL\", \"TENANT_ID\");"] + ); + + let mut renamed = existing_index("IDX_USERS_EMAIL", &["EMAIL"], true); + renamed.name = "IDX_USERS_LOGIN".to_string(); + + let renamed_result = + build_table_structure_change_sql(index_change_options(DatabaseType::Dameng, Some("APP"), renamed)); + + assert_eq!(renamed_result.warnings, Vec::::new()); + assert_eq!( + renamed_result.statements, + vec![ + "DROP INDEX \"APP\".\"IDX_USERS_EMAIL\";", + "CREATE UNIQUE INDEX \"IDX_USERS_LOGIN\" ON \"APP\".\"USERS\" (\"EMAIL\");", + ] + ); +} + +#[test] +fn dameng_dropping_constraint_backed_unique_index_drops_the_constraint() { + // Deleting the index of #7959 is broken the same way: DROP INDEX is rejected for a + // constraint-owned index, so the delete has to drop the constraint instead. + let mut dropped = constraint_backed_unique_index("IDX_USERS_EMAIL", &["EMAIL"]); + dropped.marked_for_drop = true; + + let result = build_table_structure_change_sql(index_change_options(DatabaseType::Dameng, Some("APP"), dropped)); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!(result.statements, vec!["ALTER TABLE \"APP\".\"USERS\" DROP CONSTRAINT \"IDX_USERS_EMAIL\";"]); + + // A standalone unique index keeps DROP INDEX. + let mut standalone = existing_index("IDX_USERS_EMAIL", &["EMAIL"], true); + standalone.marked_for_drop = true; + + let standalone_result = + build_table_structure_change_sql(index_change_options(DatabaseType::Dameng, Some("APP"), standalone)); + + assert_eq!(standalone_result.warnings, Vec::::new()); + assert_eq!(standalone_result.statements, vec!["DROP INDEX \"APP\".\"IDX_USERS_EMAIL\";"]); +} + +#[test] +fn dameng_constraint_backed_unique_index_edit_reports_unusable_bitmap_type() { + // BITMAP is honored by CREATE INDEX for Dameng, but a unique constraint always builds a + // normal index behind itself, so the requested type cannot be carried over silently. + let mut changed = constraint_backed_unique_index("IDX_USERS_EMAIL", &["EMAIL"]); + changed.index_type = "bitmap".to_string(); + + let result = build_table_structure_change_sql(index_change_options(DatabaseType::Dameng, Some("APP"), changed)); + + assert_eq!( + result.warnings, + vec![ + "Index type BITMAP is ignored for unique index \"IDX_USERS_EMAIL\": Dameng enforces it with a unique constraint, whose index cannot be a bitmap index." + .to_string() + ] + ); + assert_eq!( + result.statements, + vec![ + "ALTER TABLE \"APP\".\"USERS\" DROP CONSTRAINT \"IDX_USERS_EMAIL\";", + "ALTER TABLE \"APP\".\"USERS\" ADD CONSTRAINT \"IDX_USERS_EMAIL\" UNIQUE (\"EMAIL\");", + ] + ); +} + +#[test] +fn dameng_constraint_backed_unique_index_edit_without_columns_emits_nothing() { + // The empty-column guard of `build_create_index_statements`: dropping the constraint + // without a valid replacement would silently delete it, so the edit is skipped entirely + // and only `validate_draft`'s warning remains. + let mut changed = constraint_backed_unique_index("IDX_USERS_EMAIL", &["EMAIL"]); + changed.columns = vec![" ".to_string()]; + + let result = build_table_structure_change_sql(index_change_options(DatabaseType::Dameng, Some("APP"), changed)); + + assert_eq!(result.warnings, vec!["Index \"IDX_USERS_EMAIL\" needs at least one column.".to_string()]); + assert!(result.statements.is_empty(), "{:?}", result.statements); +} + #[test] fn dameng_primary_and_unchanged_indexes_keep_existing_behavior() { let unchanged = existing_index("IDX_USERS_EMAIL", &["EMAIL"], false); @@ -891,6 +1045,7 @@ fn builds_informix_column_and_index_changes() { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); let mut email_index = index("uniq_users_email", &["email"]); email_index.is_unique = true; @@ -1339,6 +1494,7 @@ fn iris_drop_index_includes_table_name() { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1651,6 +1807,7 @@ fn gbase8a_uses_limited_mysql_ddl() { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -2362,6 +2519,7 @@ fn qualifies_attached_sqlite_table_and_index_changes() { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); let email_index = index("idx_users_email", &["email"]); @@ -6952,6 +7110,7 @@ fn oscar_drop_index_with_schema_qualifier() { comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -7443,6 +7602,7 @@ fn gaussdb_m_existing_index( comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], + constraint_backed: false, }); idx } @@ -7814,6 +7974,7 @@ fn postgres_index_opclass_from_original_when_editable_empty() { comment: None, key_is_expression: vec![false], column_opclasses: vec![Some("gin_trgm_ops".to_string())], + constraint_backed: false, }); let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx)); @@ -7840,6 +8001,7 @@ fn postgres_index_no_rebuild_when_original_has_opclass_and_edit_is_identical() { comment: None, key_is_expression: vec![false], column_opclasses: vec![Some("gin_trgm_ops".to_string())], + constraint_backed: false, }); let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx)); @@ -7865,6 +8027,7 @@ fn postgres_index_rebuild_when_opclass_changed() { comment: None, key_is_expression: vec![false], column_opclasses: vec![None], // was default, now gin_trgm_ops + constraint_backed: false, }); let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx)); @@ -7963,6 +8126,7 @@ fn postgres_expression_index_appends_opclass() { comment: None, key_is_expression: vec![true], column_opclasses: vec![None], + constraint_backed: false, }); let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx)); @@ -8008,6 +8172,7 @@ fn postgres_expression_index_opclass_round_trips_from_indclass() { comment: None, key_is_expression: vec![true], column_opclasses: vec![Some("public.gin_trgm_ops".to_string())], + constraint_backed: false, }); let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx)); diff --git a/crates/dbx-core/src/table_structure_sql/types.rs b/crates/dbx-core/src/table_structure_sql/types.rs index aae0dbb559..4cf96782c4 100644 --- a/crates/dbx-core/src/table_structure_sql/types.rs +++ b/crates/dbx-core/src/table_structure_sql/types.rs @@ -133,6 +133,13 @@ pub struct IndexInfo { /// Parallel to `columns`: operator class name for each key column, if non-default. #[serde(default)] pub column_opclasses: Vec>, + /// Round-tripped from `crate::types::IndexInfo`: `true` when the introspected index is + /// the object behind a PRIMARY KEY / UNIQUE constraint. Dameng only accepts constraint + /// level DDL for those (#7959); a standalone unique index keeps the index-level path. + /// Defaults to `false`, so a payload from an older client (or a source that does not + /// report it) behaves exactly as before. + #[serde(default)] + pub constraint_backed: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 37fbe1cb9f..8caef77219 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -11671,6 +11671,7 @@ mod tests { comment: Some("lookup index".to_string()), key_is_expression: vec![true], column_opclasses: vec![], + constraint_backed: false, }]; let foreign_keys = vec![ db::ForeignKeyInfo { @@ -11724,6 +11725,7 @@ mod tests { comment: None, key_is_expression: vec![false], column_opclasses: vec![Some("gin_trgm_ops".to_string())], + constraint_backed: false, }]; let sql = generate_postgres_index_ddl(&indexes, "users", "public"); @@ -11747,6 +11749,7 @@ mod tests { comment: None, key_is_expression: vec![false, false], column_opclasses: vec![None, None], + constraint_backed: false, }]; let sql = generate_postgres_index_ddl(&indexes, "users", "public"); @@ -11776,6 +11779,7 @@ mod tests { comment: None, key_is_expression: vec![true], column_opclasses: vec![Some("gin_trgm_ops".to_string())], + constraint_backed: false, }]; let sql = generate_postgres_index_ddl(&indexes, "users", "public"); @@ -11800,6 +11804,7 @@ mod tests { comment: None, key_is_expression: vec![false, false], column_opclasses: vec![Some("text_pattern_ops".to_string()), None], + constraint_backed: false, }]; let sql = generate_postgres_index_ddl(&indexes, "users", "public"); @@ -11823,6 +11828,7 @@ mod tests { comment: None, key_is_expression: vec![false], column_opclasses: vec![], + constraint_backed: false, }]; let sql = generate_postgres_index_ddl(&indexes, "users", "public"); diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index 1776c44780..7d8d945cc1 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -461,6 +461,14 @@ pub struct IndexInfo { /// `None` means the default operator class is used (can be omitted in DDL). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub column_opclasses: Vec>, + /// `true` when this index is the object *behind* a table constraint (PRIMARY KEY or + /// UNIQUE) rather than a standalone index. Dameng lists both kinds in `ALL_INDEXES` + /// but only a standalone ("real") index accepts index-level DDL: a constraint-backed + /// ("virtual") one must be changed through `ALTER TABLE ... ADD/DROP CONSTRAINT` + /// (#7959). Defaults to `false` for every introspection source that does not report + /// it, which keeps those indexes on the index-level DDL path. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub constraint_backed: bool, } #[derive(Debug, Clone, Serialize, Deserialize)]