From 01623ab38dbdb53180ddc9702883363855cf8a2e Mon Sep 17 00:00:00 2001 From: Tara <39621793+Tara8573@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:48:41 +0800 Subject: [PATCH] fix(postgres): keep index operator classes in read and generation Closes #7166 --- .../components/diagram/DiagramSyncDialog.vue | 9 +- .../structure/TableStructureEditor.vue | 1 + apps/desktop/src/lib/diagram/draft-table.ts | 1 + .../src/lib/table/tableStructureEditorSql.ts | 2 + .../lib/table/tableStructureEditorState.ts | 1 + apps/desktop/src/types/database.ts | 2 + crates/dbx-core/src/db/clickhouse_driver.rs | 2 + crates/dbx-core/src/db/cloudflare_d1/mod.rs | 1 + crates/dbx-core/src/db/manticoresearch.rs | 1 + crates/dbx-core/src/db/mongo_driver.rs | 6 + crates/dbx-core/src/db/mysql.rs | 1 + crates/dbx-core/src/db/mysql_compatible.rs | 2 + crates/dbx-core/src/db/ob_oracle.rs | 1 + crates/dbx-core/src/db/postgres.rs | 116 ++++++-- crates/dbx-core/src/db/questdb.rs | 1 + crates/dbx-core/src/db/rqlite_driver.rs | 1 + crates/dbx-core/src/db/sqlite.rs | 2 + crates/dbx-core/src/db/sqlserver.rs | 1 + crates/dbx-core/src/db/turso_driver.rs | 1 + crates/dbx-core/src/docs/dbml.rs | 5 + crates/dbx-core/src/docs/relations.rs | 1 + crates/dbx-core/src/schema.rs | 57 +++- crates/dbx-core/src/schema_diff.rs | 85 ++++++ .../src/table_structure_sql/indexes.rs | 106 +++++++- .../src/table_structure_sql/sqlite_rebuild.rs | 3 + .../dbx-core/src/table_structure_sql/tests.rs | 251 ++++++++++++++++++ .../dbx-core/src/table_structure_sql/types.rs | 8 + .../src/table_structure_sql/validation.rs | 65 +++++ crates/dbx-core/src/transfer.rs | 166 ++++++++++-- crates/dbx-core/src/types.rs | 5 + .../tests/live_postgres_concurrent_index.rs | 1 + 31 files changed, 854 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/components/diagram/DiagramSyncDialog.vue b/apps/desktop/src/components/diagram/DiagramSyncDialog.vue index e4848d98c3..c59742788b 100644 --- a/apps/desktop/src/components/diagram/DiagramSyncDialog.vue +++ b/apps/desktop/src/components/diagram/DiagramSyncDialog.vue @@ -41,6 +41,11 @@ const structureCapabilities = computed(() => getTableStructureCapabilities(props const validationErrors = ref([]); const sqlText = ref(""); const warnings = ref([]); +// 仅 GIN 缺 opclass 警告阻断同步;其余降级类警告(如 “… not supported … ignored”)保持可继续。 +// Match the warning's stable semantic core ("has no operator class specified") +// rather than its prefix, so rewording the leading "GIN index …" doesn't +// silently disable the gate. +const hasBlockingWarning = computed(() => warnings.value.some((w) => w.includes("has no operator class specified"))); const building = ref(false); const executing = ref(false); const execError = ref(""); @@ -122,7 +127,7 @@ async function copySql() { } async function execute() { - if (!sqlText.value || validationErrors.value.length) return; + if (!sqlText.value || validationErrors.value.length || hasBlockingWarning.value) return; executing.value = true; execError.value = ""; try { @@ -190,7 +195,7 @@ function syncTableLabel(table: DiagramTable): string { - diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 99435e918a..b8dbc70e79 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -3173,6 +3173,7 @@ function addIndex() { includedColumns: [], comment: "", concurrently: false, + columnOpclasses: [], markedForDrop: false, }); void nextTick(() => { diff --git a/apps/desktop/src/lib/diagram/draft-table.ts b/apps/desktop/src/lib/diagram/draft-table.ts index 378913da79..6945a0f325 100644 --- a/apps/desktop/src/lib/diagram/draft-table.ts +++ b/apps/desktop/src/lib/diagram/draft-table.ts @@ -40,6 +40,7 @@ export function createDraftIndex(tableName: string, columns: string[], existingI includedColumns: [], comment: "", concurrently: false, + columnOpclasses: [], markedForDrop: false, }; } diff --git a/apps/desktop/src/lib/table/tableStructureEditorSql.ts b/apps/desktop/src/lib/table/tableStructureEditorSql.ts index 3560e2c50b..18006c7d99 100644 --- a/apps/desktop/src/lib/table/tableStructureEditorSql.ts +++ b/apps/desktop/src/lib/table/tableStructureEditorSql.ts @@ -46,6 +46,8 @@ export interface EditableStructureIndex { comment: string; /** Build the index with PostgreSQL `CREATE INDEX CONCURRENTLY` (PostgreSQL only, default off). */ concurrently?: boolean; + /** Parallel to `columns`: operator class for each key column (PostgreSQL). `null` means default. */ + columnOpclasses?: (string | null)[]; original?: IndexInfo; markedForDrop: boolean; } diff --git a/apps/desktop/src/lib/table/tableStructureEditorState.ts b/apps/desktop/src/lib/table/tableStructureEditorState.ts index 4d5d328f2b..c9d2b45807 100644 --- a/apps/desktop/src/lib/table/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/table/tableStructureEditorState.ts @@ -1029,6 +1029,7 @@ export function createIndexDrafts(indexes: IndexInfo[]): EditableStructureIndex[ indexType: normalizeStructureIndexType(index.index_type), includedColumns: index.included_columns ? [...index.included_columns] : [], comment: index.comment ?? "", + columnOpclasses: index.column_opclasses ? [...index.column_opclasses] : [], original: index, markedForDrop: false, })); diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index fc9590fc0d..438342d1dc 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -552,6 +552,8 @@ export interface IndexInfo { comment?: string | null; /** Parallel to `columns`: true at index i means columns[i] is a raw expression, not a plain column name. */ key_is_expression?: boolean[] | null; + /** Parallel to `columns`: operator class name for each key column (PostgreSQL), if non-default. */ + column_opclasses?: (string | null)[] | 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 7f15078233..1f1f6370d8 100644 --- a/crates/dbx-core/src/db/clickhouse_driver.rs +++ b/crates/dbx-core/src/db/clickhouse_driver.rs @@ -541,6 +541,7 @@ fn clickhouse_index_from_skipping_row(row: &[serde_json::Value]) -> IndexInfo { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], } } @@ -792,6 +793,7 @@ pub async fn list_indexes(client: &ChClient, database: &str, table: &str) -> Res included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); } diff --git a/crates/dbx-core/src/db/cloudflare_d1/mod.rs b/crates/dbx-core/src/db/cloudflare_d1/mod.rs index 8a020f1386..26c73e0cb0 100644 --- a/crates/dbx-core/src/db/cloudflare_d1/mod.rs +++ b/crates/dbx-core/src/db/cloudflare_d1/mod.rs @@ -200,6 +200,7 @@ pub async fn list_indexes(client: &CloudflareD1Client, _schema: &str, table: &st included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); } Ok(indexes) diff --git a/crates/dbx-core/src/db/manticoresearch.rs b/crates/dbx-core/src/db/manticoresearch.rs index a6a26592af..99f352f3b6 100644 --- a/crates/dbx-core/src/db/manticoresearch.rs +++ b/crates/dbx-core/src/db/manticoresearch.rs @@ -210,6 +210,7 @@ fn index_info_from_row(row: &mysql_async::Row) -> IndexInfo { included_columns: None, comment: (!comment_parts.is_empty()).then(|| comment_parts.join(", ")), key_is_expression: Vec::new(), + column_opclasses: vec![], } } diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 96432abcfe..66d3f69266 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -904,6 +904,7 @@ fn index_info_from_model(model: IndexModel) -> IndexInfo { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], } } @@ -3637,6 +3638,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); assert_eq!(spec.name, "email_1"); @@ -3662,6 +3664,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); assert_eq!( @@ -3905,6 +3908,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }, IndexInfo { name: "users_email_unique".to_string(), @@ -3916,6 +3920,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }, IndexInfo { name: "users_status_idx".to_string(), @@ -3927,6 +3932,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }, ]; 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 23e48a1e1b..08ea2e4ca3 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -5775,6 +5775,7 @@ pub async fn list_indexes(pool: &MySqlPool, database: &str, table: &str) -> Resu included_columns: None, comment: get_opt_str(&row, "INDEX_COMMENT").filter(|value| !value.is_empty()), key_is_expression: Vec::new(), + column_opclasses: vec![], }); index_position }; diff --git a/crates/dbx-core/src/db/mysql_compatible.rs b/crates/dbx-core/src/db/mysql_compatible.rs index a669a76986..5c5fa797cb 100644 --- a/crates/dbx-core/src/db/mysql_compatible.rs +++ b/crates/dbx-core/src/db/mysql_compatible.rs @@ -325,6 +325,7 @@ fn table_key_index(name: &str, line: &str, is_unique: bool, is_primary: bool, in included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }) } @@ -345,6 +346,7 @@ fn secondary_index(line: &str) -> Option { included_columns: None, comment: mysql_quoted_string_argument(after_name, "COMMENT"), key_is_expression: Vec::new(), + column_opclasses: vec![], }) } diff --git a/crates/dbx-core/src/db/ob_oracle.rs b/crates/dbx-core/src/db/ob_oracle.rs index 22c1365eed..87d8d960f3 100644 --- a/crates/dbx-core/src/db/ob_oracle.rs +++ b/crates/dbx-core/src/db/ob_oracle.rs @@ -242,6 +242,7 @@ pub async fn list_indexes(pool: &mysql_async::Pool, schema: &str, table: &str) - included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], } }) .collect()) diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index d72e5132bf..217b205b34 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -3824,23 +3824,30 @@ async fn list_indexes_for_relations_with_sql( for row in &rows { let Ok(relid) = row.try_get::<_, i64>(0) else { continue }; let all_cols: Vec = row.try_get::<_, Vec>(2).unwrap_or_default(); - let nkeyatts = row.try_get::<_, Option>(7).ok().flatten().unwrap_or(all_cols.len() as i16) as usize; + let all_opclasses: Vec> = row.try_get::<_, Vec>>(3).unwrap_or_default(); + let nkeyatts = row.try_get::<_, Option>(8).ok().flatten().unwrap_or(all_cols.len() as i16) as usize; let split_at = nkeyatts.min(all_cols.len()); let key_cols = all_cols[..split_at].to_vec(); + let key_opclasses = if all_opclasses.len() == all_cols.len() { + all_opclasses[..split_at].to_vec() + } else { + vec![None; split_at] + }; let included = if split_at < all_cols.len() { all_cols[split_at..].to_vec() } else { vec![] }; - let all_is_expr: Vec = row.try_get::<_, Vec>(10).unwrap_or_default(); + let all_is_expr: Vec = row.try_get::<_, Vec>(11).unwrap_or_default(); let key_is_expression = if all_is_expr.len() == all_cols.len() { all_is_expr[..split_at].to_vec() } else { Vec::new() }; result.entry(relid).or_default().push(IndexInfo { name: pg_row_try_string(row, 1), columns: key_cols, - is_unique: pg_row_try_bool(row, 3).unwrap_or(false), - is_primary: pg_row_try_bool(row, 4).unwrap_or(false), - filter: row.try_get::<_, Option>(5).ok().flatten(), - index_type: row.try_get::<_, Option>(6).ok().flatten(), + is_unique: pg_row_try_bool(row, 4).unwrap_or(false), + is_primary: pg_row_try_bool(row, 5).unwrap_or(false), + filter: row.try_get::<_, Option>(6).ok().flatten(), + index_type: row.try_get::<_, Option>(7).ok().flatten(), included_columns: if included.is_empty() { None } else { Some(included) }, - comment: row.try_get::<_, Option>(9).ok().flatten(), + comment: row.try_get::<_, Option>(10).ok().flatten(), key_is_expression, + column_opclasses: key_opclasses, }); } Ok(result) @@ -3857,7 +3864,8 @@ fn postgres_indexes_for_relations_query_tiers() -> [&'static str; 2] { // line up, and result columns are read positionally. fn postgres_indexes_for_relations_sql() -> &'static str { "SELECT t.oid::bigint AS relid, i.relname AS index_name, \ - array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, true)) ORDER BY k.n) AS columns, \ + array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, false)) ORDER BY k.n) AS columns, \ + array_agg(CASE WHEN oc.opcdefault THEN NULL ELSE quote_ident(opcns.nspname) || '.' || quote_ident(oc.opcname) END ORDER BY k.n) AS column_opclasses, \ (ix.indisunique AND ix.indisvalid) AS is_unique, \ ix.indisprimary AS is_primary, \ pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \ @@ -3870,8 +3878,10 @@ fn postgres_indexes_for_relations_sql() -> &'static str { JOIN pg_class t ON t.oid = ix.indrelid \ JOIN pg_class i ON i.oid = ix.indexrelid \ JOIN pg_am am ON am.oid = i.relam \ - JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true \ + JOIN LATERAL unnest(ix.indkey, ix.indclass) WITH ORDINALITY AS k(attnum, class_oid, n) ON true \ LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \ + LEFT JOIN pg_opclass oc ON oc.oid = k.class_oid \ + LEFT JOIN pg_namespace opcns ON opcns.oid = oc.opcnamespace \ WHERE t.oid = ANY($1::bigint[]) \ GROUP BY t.oid, i.relname, i.oid, ix.indisunique, ix.indisvalid, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indnkeyatts, ix.indkey \ ORDER BY t.oid, i.relname" @@ -3882,7 +3892,7 @@ fn postgres_indexes_for_relations_sql() -> &'static str { fn postgres_indexes_for_relations_compat_sql() -> &'static str { "SELECT t.oid::bigint AS relid, i.relname AS index_name, \ ARRAY( \ - SELECT COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, pos.n, true)) \ + SELECT COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, pos.n, false)) \ FROM generate_series(1, array_length(string_to_array(ix.indkey::text, ' '), 1)) AS pos(n) \ LEFT JOIN pg_attribute a \ ON a.attrelid = t.oid \ @@ -3890,6 +3900,19 @@ fn postgres_indexes_for_relations_compat_sql() -> &'static str { AND a.attnum > 0 \ ORDER BY pos.n \ ) AS columns, \ + ARRAY( \ + SELECT CASE WHEN oc.opcdefault THEN NULL \ + ELSE oc.opcname \ + END \ + FROM generate_series(1, array_length(string_to_array(ix.indkey::text, ' '), 1)) AS pos(n) \ + LEFT JOIN pg_attribute a \ + ON a.attrelid = t.oid \ + AND a.attnum = (string_to_array(ix.indkey::text, ' '))[pos.n]::int2 \ + AND a.attnum > 0 \ + LEFT JOIN pg_opclass oc \ + ON oc.oid = (string_to_array(ix.indclass::text, ' '))[pos.n]::oid \ + ORDER BY pos.n \ + ) AS column_opclasses, \ (ix.indisunique AND ix.indisvalid) AS is_unique, \ ix.indisprimary AS is_primary, \ pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \ @@ -7335,7 +7358,8 @@ async fn execute_query_with_max_rows_inner( // Sibling of `postgres_indexes_for_relations_sql` (~line 3288), for a single // (schema, table) instead of a batch of oids — see the note there. const POSTGRES_INDEXES_SQL: &str = "SELECT i.relname AS index_name, \ - array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, true)) ORDER BY k.n) AS columns, \ + array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, false)) ORDER BY k.n) AS columns, \ + array_agg(CASE WHEN oc.opcdefault THEN NULL ELSE quote_ident(opcns.nspname) || '.' || quote_ident(oc.opcname) END ORDER BY k.n) AS column_opclasses, \ (ix.indisunique AND ix.indisvalid) AS is_unique, \ ix.indisprimary AS is_primary, \ pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \ @@ -7349,8 +7373,10 @@ const POSTGRES_INDEXES_SQL: &str = "SELECT i.relname AS index_name, \ 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 LATERAL unnest(ix.indkey, ix.indclass) WITH ORDINALITY AS k(attnum, class_oid, n) ON true \ LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \ + LEFT JOIN pg_opclass oc ON oc.oid = k.class_oid \ + LEFT JOIN pg_namespace opcns ON opcns.oid = oc.opcnamespace \ WHERE t.oid = (CASE WHEN $1 = '' THEN quote_ident($2) ELSE quote_ident($1) || '.' || quote_ident($2) END)::regclass \ GROUP BY i.relname, i.oid, ix.indisunique, ix.indisvalid, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indnkeyatts, ix.indkey \ ORDER BY i.relname"; @@ -7359,7 +7385,7 @@ const POSTGRES_INDEXES_SQL: &str = "SELECT i.relname AS index_name, \ // 3312) — see the note on `POSTGRES_INDEXES_SQL` above. const POSTGRES_INDEXES_COMPAT_SQL: &str = "SELECT i.relname AS index_name, \ ARRAY( \ - SELECT COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, pos.n, true)) \ + SELECT COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, pos.n, false)) \ FROM generate_series(1, array_length(string_to_array(ix.indkey::text, ' '), 1)) AS pos(n) \ LEFT JOIN pg_attribute a \ ON a.attrelid = t.oid \ @@ -7367,6 +7393,19 @@ const POSTGRES_INDEXES_COMPAT_SQL: &str = "SELECT i.relname AS index_name, \ AND a.attnum > 0 \ ORDER BY pos.n \ ) AS columns, \ + ARRAY( \ + SELECT CASE WHEN oc.opcdefault THEN NULL \ + ELSE oc.opcname \ + END \ + FROM generate_series(1, array_length(string_to_array(ix.indkey::text, ' '), 1)) AS pos(n) \ + LEFT JOIN pg_attribute a \ + ON a.attrelid = t.oid \ + AND a.attnum = (string_to_array(ix.indkey::text, ' '))[pos.n]::int2 \ + AND a.attnum > 0 \ + LEFT JOIN pg_opclass oc \ + ON oc.oid = (string_to_array(ix.indclass::text, ' '))[pos.n]::oid \ + ORDER BY pos.n \ + ) AS column_opclasses, \ (ix.indisunique AND ix.indisvalid) AS is_unique, \ ix.indisprimary AS is_primary, \ pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \ @@ -7465,6 +7504,16 @@ pub(crate) async fn postgres_relation_relkind( row.map(|row| row.try_get::<_, String>(0)).transpose().map_err(pg_error_to_string) } +/// Compute per-column operator classes from `pg_opclass` joined via +/// `pg_index.indclass`. For each key column position, the schema-qualified +/// opclass (`quote_ident(nspname) || '.' || quote_ident(opcname)`) is returned +/// unless the class is the type's default (`oc.opcdefault`), so DDL regeneration +/// resolves the opclass regardless of `search_path`. This applies to every key +/// position, including expression keys (`a.attname IS NULL`): the per-column +/// `pg_get_indexdef(indexrelid, colno, pretty)` call returns only the bare +/// expression text (PostgreSQL sets `attrsOnly = (colno != 0)`, so the +/// opclass/COLLATE/DESC block is skipped — see `ruleutils.c`), so the opclass +/// must be read from `indclass` rather than parsed out of that text. async fn list_indexes_with_sql( client: &deadpool_postgres::Client, sql: &str, @@ -7477,25 +7526,32 @@ async fn list_indexes_with_sql( .iter() .map(|row| { let all_cols: Vec = row.try_get::<_, Vec>(1).unwrap_or_default(); - let nkeyatts = row.try_get::<_, Option>(6).ok().flatten().unwrap_or(all_cols.len() as i16) as usize; + let all_opclasses: Vec> = row.try_get::<_, Vec>>(2).unwrap_or_default(); + let nkeyatts = row.try_get::<_, Option>(7).ok().flatten().unwrap_or(all_cols.len() as i16) as usize; let split_at = nkeyatts.min(all_cols.len()); let key_cols = all_cols[..split_at].to_vec(); + let key_opclasses = if all_opclasses.len() == all_cols.len() { + all_opclasses[..split_at].to_vec() + } else { + vec![None; split_at] + }; let included = if split_at < all_cols.len() { all_cols[split_at..].to_vec() } else { vec![] }; // `a.attname IS NULL` at a given key position means that key part came back from // pg_get_indexdef (a functional/expression key part), not from a real column (#6295). - let all_is_expr: Vec = row.try_get::<_, Vec>(9).unwrap_or_default(); + let all_is_expr: Vec = row.try_get::<_, Vec>(10).unwrap_or_default(); let key_is_expression = if all_is_expr.len() == all_cols.len() { all_is_expr[..split_at].to_vec() } else { Vec::new() }; IndexInfo { name: pg_row_try_string(row, 0), columns: key_cols, - is_unique: pg_row_try_bool(row, 2).unwrap_or(false), - is_primary: pg_row_try_bool(row, 3).unwrap_or(false), - filter: row.try_get::<_, Option>(4).ok().flatten(), - index_type: row.try_get::<_, Option>(5).ok().flatten(), + is_unique: pg_row_try_bool(row, 3).unwrap_or(false), + is_primary: pg_row_try_bool(row, 4).unwrap_or(false), + filter: row.try_get::<_, Option>(5).ok().flatten(), + index_type: row.try_get::<_, Option>(6).ok().flatten(), included_columns: if included.is_empty() { None } else { Some(included) }, - comment: row.try_get::<_, Option>(8).ok().flatten(), + comment: row.try_get::<_, Option>(9).ok().flatten(), key_is_expression, + column_opclasses: key_opclasses, } }) .collect()) @@ -12316,6 +12372,26 @@ mod tests { assert!(POSTGRES_INDEXES_COMPAT_SQL.contains("AS key_is_expression")); } + #[test] + fn postgres_index_metadata_schema_qualifies_opclass() { + // The LATERAL (modern) index-introspection SQL returns each non-default + // opclass schema-qualified (`quote_ident(nspname) || '.' || quote_ident(opcname)`) + // and joins `pg_namespace` on `opcnamespace`, so DDL regeneration resolves + // opclasses (e.g. `gin_trgm_ops` from `pg_trgm`) regardless of `search_path`. + // The compat (pre-LATERAL) fallback keeps bare `oc.opcname` — no regression, + // just no schema-qualification for the rare old-PG × non-default-schema case. + for sql in [POSTGRES_INDEXES_SQL, postgres_indexes_for_relations_sql()] { + assert!( + sql.contains("quote_ident(opcns.nspname) || '.' || quote_ident(oc.opcname)"), + "expected schema-qualified opclass in {sql}" + ); + assert!( + sql.contains("LEFT JOIN pg_namespace opcns ON opcns.oid = oc.opcnamespace"), + "expected opcnamespace join in {sql}" + ); + } + } + #[test] fn postgres_owner_metadata_casts_relkind_to_text() { assert!(POSTGRES_OWNERS_SQL.contains("c.relkind::text AS relkind")); diff --git a/crates/dbx-core/src/db/questdb.rs b/crates/dbx-core/src/db/questdb.rs index 26e8b29b06..c378ddf8ed 100644 --- a/crates/dbx-core/src/db/questdb.rs +++ b/crates/dbx-core/src/db/questdb.rs @@ -177,6 +177,7 @@ pub async fn list_indexes(pool: &Pool, _schema: &str, table: &str) -> Result included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); } diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs index 0da9f54d58..9f6c24f405 100644 --- a/crates/dbx-core/src/db/sqlite.rs +++ b/crates/dbx-core/src/db/sqlite.rs @@ -2493,6 +2493,7 @@ pub async fn list_indexes(pool: &SqliteHandle, schema: &str, table: &str) -> Res included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); } return Ok(indexes); @@ -2546,6 +2547,7 @@ pub async fn list_indexes(pool: &SqliteHandle, schema: &str, table: &str) -> Res included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); } Ok(indexes) diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 6686ccd4be..594f866187 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -2940,6 +2940,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![], } }) .collect()) diff --git a/crates/dbx-core/src/db/turso_driver.rs b/crates/dbx-core/src/db/turso_driver.rs index 15dc832426..5bdfd7232f 100644 --- a/crates/dbx-core/src/db/turso_driver.rs +++ b/crates/dbx-core/src/db/turso_driver.rs @@ -222,6 +222,7 @@ pub async fn list_indexes(client: &TursoClient, _schema: &str, table: &str) -> R included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); } diff --git a/crates/dbx-core/src/docs/dbml.rs b/crates/dbx-core/src/docs/dbml.rs index bc9d7ccb56..e144f0d0ba 100644 --- a/crates/dbx-core/src/docs/dbml.rs +++ b/crates/dbx-core/src/docs/dbml.rs @@ -600,6 +600,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }; let table = doc_table("orders", vec![col("user_id", "integer")], vec![index]); @@ -623,6 +624,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }; let table = doc_table("orders", vec![col("reference", "text")], vec![index]); let mut warnings = Vec::new(); @@ -642,6 +644,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }; let mut id = col("id", "integer"); id.is_primary_key = true; @@ -664,6 +667,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }; let table = doc_table("orders", vec![col("status", "text")], vec![index]); @@ -866,6 +870,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }; 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 c099be4360..ac586cdb56 100644 --- a/crates/dbx-core/src/docs/relations.rs +++ b/crates/dbx-core/src/docs/relations.rs @@ -194,6 +194,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], } } diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index ba878defc7..1358d7ad3a 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -3213,6 +3213,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }; let mut filtered = index("uq_active_code", &["active_code"], true, false); filtered.filter = Some("active = true".to_string()); @@ -8210,6 +8211,7 @@ async fn external_driver_gaussdb_m_indexes( included_columns: None, comment: current_comment.clone(), key_is_expression: current_is_expression.clone(), + column_opclasses: vec![], }); } // Start new index @@ -8279,6 +8281,7 @@ async fn external_driver_gaussdb_m_indexes( included_columns: None, comment: current_comment, key_is_expression: current_is_expression, + column_opclasses: vec![], }); } @@ -10266,6 +10269,35 @@ mod ddl_tests { assert!(!ddl.contains("PARTITION BY")); } + #[test] + fn postgres_table_ddl_appends_opclass_to_expression_index_key() { + // The per-column `pg_get_indexdef(indexrelid, colno, pretty)` returns only the + // bare expression (PostgreSQL sets `attrsOnly = (colno != 0)`, so the opclass + // block is skipped — see `ruleutils.c`). The opclass is read separately from + // `indclass` into `column_opclasses`, so table DDL must append it to an + // expression key just like a real column. + let id = column("id", "integer"); + let indexes = vec![db::IndexInfo { + name: "users_lower_email_trgm_idx".to_string(), + columns: vec!["lower(email)".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: Some("gin".to_string()), + included_columns: None, + comment: None, + key_is_expression: vec![true], + column_opclasses: vec![Some("gin_trgm_ops".to_string())], + }]; + + let ddl = render_postgres_table_ddl("public", "users", &[id], &indexes, &[], None); + + assert!( + ddl.contains("USING gin (lower(email) gin_trgm_ops)"), + "expected expression key with appended opclass, got: {ddl}" + ); + } + #[test] fn postgres_table_ddl_renders_partition_children_and_subpartitions() { let mut id = column("id", "integer"); @@ -10280,6 +10312,7 @@ mod ddl_tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }]; let partition_info = db::postgres::PostgresTablePartitionInfo { is_partition: true, @@ -10330,6 +10363,7 @@ mod ddl_tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }]; let partition_info = db::postgres::PostgresTablePartitionInfo { is_partition: true, @@ -11919,7 +11953,28 @@ fn render_postgres_table_ddl_with_partition_info( continue; } let unique = if idx.is_unique { "UNIQUE " } else { "" }; - let cols = idx.columns.iter().map(|c| pg_ident(c)).collect::>().join(", "); + let cols = idx + .columns + .iter() + .enumerate() + .map(|(i, c)| { + // A real column is quoted via `pg_ident`; an expression/functional key part + // arrives as raw expression text (the per-column `pg_get_indexdef` omits the + // opclass — see `crates/dbx-core/src/db/postgres.rs`), so quoting the whole + // thing as an identifier would turn it into a nonexistent column reference + // (#6295). + let is_expr = idx.key_is_expression.get(i).copied().unwrap_or(false); + let mut col = if is_expr { c.clone() } else { pg_ident(c) }; + // 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. + if let Some(opclass) = idx.column_opclasses.get(i).and_then(|o| o.as_deref()) { + col.push_str(&format!(" {opclass}")); + } + col + }) + .collect::>() + .join(", "); let using = idx.index_type.as_deref().map(|t| format!(" USING {t}")).unwrap_or_default(); let include = idx .included_columns diff --git a/crates/dbx-core/src/schema_diff.rs b/crates/dbx-core/src/schema_diff.rs index a97706ad72..20bbeba03f 100644 --- a/crates/dbx-core/src/schema_diff.rs +++ b/crates/dbx-core/src/schema_diff.rs @@ -2866,6 +2866,14 @@ pub fn diff_indexes(source: &[IndexInfo], target: &[IndexInfo]) -> Vec| -> String { + o.as_deref().filter(|v| !v.is_empty()).unwrap_or("default").to_string() + }; + let src_str = source_index.column_opclasses.iter().map(fmt_opclass).collect::>().join(", "); + let tgt_str = target_index.column_opclasses.iter().map(fmt_opclass).collect::>().join(", "); + changes.push(format!("opclass: {} → {}", tgt_str, src_str)); + } if !changes.is_empty() { diffs.push(IndexDiff { diff_type: "modified".to_string(), @@ -5645,6 +5653,7 @@ mod tests { included_columns: overrides.included_columns, comment: overrides.comment, key_is_expression: overrides.key_is_expression, + column_opclasses: overrides.column_opclasses, } } @@ -6591,6 +6600,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }; let diff = TableDiff { diff_type: "modified".into(), @@ -6652,6 +6662,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }; let unchanged_fk = ForeignKeyInfo { name: "fk_events_parent".into(), @@ -6989,6 +7000,7 @@ mod tests { included_columns: Some(vec!["payload".into()]), comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }; let btree_sql = create_index_sql("events", &btree, DatabaseType::SqlServer, Some("dbo")); assert_eq!( @@ -7007,6 +7019,7 @@ mod tests { included_columns: Some(vec!["status".into(), "payload".into()]), comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }; assert_eq!( create_index_sql("events", &columnstore, DatabaseType::SqlServer, Some("dbo")), @@ -7702,6 +7715,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], &[index(IndexInfo { name: "idx_orders_status".to_string(), @@ -7713,6 +7727,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], ); @@ -7721,6 +7736,44 @@ mod tests { assert_eq!(diffs[0].changes, vec!["unique: YES → NO", "columns: status → status, created_at"]); } + #[test] + fn detects_opclass_change_in_postgres_index() { + let source_index = index(IndexInfo { + name: "idx_name_trgm".to_string(), + columns: vec!["name".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: Some("gin".to_string()), + included_columns: None, + comment: None, + key_is_expression: vec![false], + column_opclasses: vec![Some("gin_trgm_ops".to_string())], + }); + let target_index = index(IndexInfo { + name: "idx_name_trgm".to_string(), + columns: vec!["name".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: Some("gin".to_string()), + included_columns: None, + comment: None, + key_is_expression: vec![false], + column_opclasses: vec![None], + }); + + let diffs = diff_indexes(&[source_index], &[target_index]); + + assert_eq!(diffs.len(), 1); + assert_eq!(diffs[0].diff_type, "modified"); + assert!( + diffs[0].changes.iter().any(|c| c.contains("opclass")), + "Expected opclass change line, got: {:?}", + diffs[0].changes + ); + } + #[test] fn detects_mysql_functional_index_changes_and_preserves_expression_ddl() { let functional_key_part = "((case when (`STATUS` = _utf8mb4'online') then _utf8mb4'online' else NULL end))"; @@ -7734,6 +7787,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); let target_index = index(IndexInfo { name: "test_UNIQUE".to_string(), @@ -7745,6 +7799,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); let diffs = diff_indexes(std::slice::from_ref(&source_index), &[target_index]); @@ -7806,6 +7861,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: vec![false, false, false, true], + column_opclasses: vec![], }); let sql = generate_schema_sync_sql( @@ -7865,6 +7921,7 @@ mod tests { expression_key_part.to_string(), ], key_is_expression: vec![false, false, false, true], + column_opclasses: vec![], is_unique: true, is_primary: false, filter: None, @@ -7931,6 +7988,7 @@ mod tests { name: "idx_weird_columns".to_string(), columns: vec!["order item".to_string(), "a(b)".to_string(), "a::b".to_string()], key_is_expression: Vec::new(), + column_opclasses: vec![], is_unique: false, is_primary: false, filter: None, @@ -8116,6 +8174,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })), target: None, changes: Vec::new(), @@ -8459,6 +8518,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })), target: None, changes: Vec::new(), @@ -9181,6 +9241,7 @@ mod tests { included_columns: Some(vec!["User ID".to_string()]), comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], foreign_keys: vec![foreign_key(ForeignKeyInfo { name: "Order User FK".to_string(), @@ -9244,6 +9305,7 @@ mod tests { included_columns: None, comment: Some("status lookup".to_string()), key_is_expression: Vec::new(), + column_opclasses: vec![], })], foreign_keys: vec![foreign_key(ForeignKeyInfo { name: "user-fk".to_string(), @@ -9297,6 +9359,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], foreign_keys: vec![foreign_key(ForeignKeyInfo { name: "parent item fk".to_string(), @@ -10369,6 +10432,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })), target: None, changes: vec![], @@ -10410,6 +10474,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })), changes: vec![], }]), @@ -10808,6 +10873,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], &[index(IndexInfo { name: "idx_t".into(), @@ -10819,6 +10885,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], ); assert_eq!(diffs.len(), 1, "index type diff detected"); @@ -10838,6 +10905,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], &[index(IndexInfo { name: "idx_t".into(), @@ -10849,6 +10917,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], ); assert_eq!(diffs[0].changes.iter().filter(|c| c.contains("FULLTEXT")).count(), 1, "fulltext change"); @@ -10868,6 +10937,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], &[index(IndexInfo { name: "idx_t".into(), @@ -10879,6 +10949,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], ); assert_eq!(diffs.len(), 1, "order diff detected"); @@ -10899,6 +10970,7 @@ mod tests { included_columns: Some(vec!["b".into(), "c".into()]), comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], &[index(IndexInfo { name: "idx_t".into(), @@ -10910,6 +10982,7 @@ mod tests { included_columns: Some(vec!["b".into()]), comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], ); assert_eq!(diffs.len(), 1, "included columns diff detected"); @@ -10929,6 +11002,7 @@ mod tests { included_columns: Some(vec!["b".into()]), comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], &[index(IndexInfo { name: "idx_t".into(), @@ -10940,6 +11014,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], ); assert_eq!(diffs.len(), 1, "included added"); @@ -10959,6 +11034,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], &[index(IndexInfo { name: "idx_t".into(), @@ -10970,6 +11046,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })], ); assert_eq!(diffs.len(), 1, "filter diff"); @@ -10991,6 +11068,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }), index(IndexInfo { name: "idx_modified".into(), @@ -11002,6 +11080,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }), ], &[ @@ -11015,6 +11094,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }), index(IndexInfo { name: "idx_modified".into(), @@ -11026,6 +11106,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }), ], ); @@ -11394,6 +11475,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })), target: None, changes: vec![], @@ -11412,6 +11494,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], })), changes: vec![], }, @@ -11807,6 +11890,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }), target: None, changes: vec![], @@ -12059,6 +12143,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }), 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 e6d9e7c7b3..15ff0c3640 100644 --- a/crates/dbx-core/src/table_structure_sql/indexes.rs +++ b/crates/dbx-core/src/table_structure_sql/indexes.rs @@ -114,6 +114,45 @@ pub(super) fn has_existing_index_change(index: &EditableStructureIndex) -> bool || 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 { @@ -170,14 +209,19 @@ fn mysql_index_column_sql(column: &str) -> String { } } -fn postgres_index_column_sql(column: &str, is_expression: bool) -> String { - // Expression/functional index key parts arrive as raw expression text, not a plain - // column name; quoting the whole expression as an identifier turns it into a literal - // column reference that doesn't exist (#6295). - if is_expression { - column.trim().to_string() - } else { - quote_ident(StructureDialect::Postgres, 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, } } @@ -219,6 +263,49 @@ fn key_expression_flags(index: &EditableStructureIndex, columns: &[String]) -> V .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, @@ -278,6 +365,7 @@ pub(super) fn build_create_index_statements( 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() @@ -287,7 +375,7 @@ pub(super) fn build_create_index_statements( } 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]) + 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 { 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 b6e6634fed..cdbe077f56 100644 --- a/crates/dbx-core/src/table_structure_sql/sqlite_rebuild.rs +++ b/crates/dbx-core/src/table_structure_sql/sqlite_rebuild.rs @@ -1694,6 +1694,7 @@ mod tests { filter: String::new(), index_type: String::new(), included_columns: Vec::new(), + column_opclasses: Vec::new(), comment: String::new(), concurrently: false, original: None, @@ -2013,6 +2014,7 @@ mod tests { filter: String::new(), index_type: String::new(), included_columns: Vec::new(), + column_opclasses: Vec::new(), comment: String::new(), concurrently: false, original: Some(IndexInfo { @@ -2025,6 +2027,7 @@ mod tests { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }), 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 12aa0687c4..2e39f287a1 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -120,6 +120,7 @@ fn index(name: &str, columns: &[&str]) -> EditableStructureIndex { filter: String::new(), index_type: String::new(), included_columns: Vec::new(), + column_opclasses: Vec::new(), comment: String::new(), concurrently: false, original: None, @@ -140,6 +141,7 @@ fn existing_index(name: &str, columns: &[&str], is_unique: bool) -> EditableStru included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); index } @@ -231,6 +233,7 @@ fn builds_mysql_column_and_index_changes() { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); let mut email_index = index("uniq_users_email", &["email"]); email_index.is_unique = true; @@ -870,6 +873,7 @@ fn builds_informix_column_and_index_changes() { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); let mut email_index = index("uniq_users_email", &["email"]); email_index.is_unique = true; @@ -1305,6 +1309,7 @@ fn iris_drop_index_includes_table_name() { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1609,6 +1614,7 @@ fn gbase8a_uses_limited_mysql_ddl() { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -2226,6 +2232,7 @@ fn qualifies_attached_sqlite_table_and_index_changes() { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); let email_index = index("idx_users_email", &["email"]); @@ -6728,6 +6735,7 @@ fn oscar_drop_index_with_schema_qualifier() { included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -7184,6 +7192,7 @@ fn gaussdb_m_index(name: &str, columns: &[&str]) -> EditableStructureIndex { filter: String::new(), index_type: String::new(), included_columns: Vec::new(), + column_opclasses: Vec::new(), comment: String::new(), concurrently: false, original: None, @@ -7210,6 +7219,7 @@ fn gaussdb_m_existing_index( included_columns: None, comment: None, key_is_expression: Vec::new(), + column_opclasses: vec![], }); idx } @@ -7542,3 +7552,244 @@ fn mysql_add_column_nullable_timestamp_without_default_gets_explicit_null() { assert_eq!(result.warnings, Vec::::new()); assert_eq!(result.statements, vec!["ALTER TABLE `u7_game_order_step` ADD COLUMN `updated_at` timestamp NULL;"]); } + +// ── PostgreSQL operator class tests ── + +#[test] +fn postgres_gin_index_with_explicit_opclass_generates_opclass_in_ddl() { + let mut idx = index("idx_name_trgm", &["name"]); + idx.index_type = "GIN".to_string(); + idx.column_opclasses = vec![Some("gin_trgm_ops".to_string())]; + + let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx)); + + assert!(result.warnings.is_empty()); + let sql = result.statements.join("\n"); + assert!(sql.contains(r#""name" gin_trgm_ops"#), "Expected opclass in DDL, got: {sql}"); + assert!(sql.contains("USING GIN"), "Expected USING GIN, got: {sql}"); +} + +#[test] +fn postgres_index_opclass_from_original_when_editable_empty() { + // When the editable side has no opclass, fall back to original matching. + let mut idx = index("idx_name_trgm", &["name"]); + idx.index_type = "GIN".to_string(); + idx.column_opclasses = vec![]; // empty: fall back to original + idx.original = Some(IndexInfo { + name: "idx_name_trgm".to_string(), + columns: vec!["name".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: Some("gin".to_string()), + included_columns: None, + comment: None, + key_is_expression: vec![false], + column_opclasses: vec![Some("gin_trgm_ops".to_string())], + }); + + let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx)); + + assert!(result.warnings.is_empty()); + let sql = result.statements.join("\n"); + assert!(sql.contains(r#""name" gin_trgm_ops"#), "Expected opclass from original, got: {sql}"); +} + +#[test] +fn postgres_index_no_rebuild_when_original_has_opclass_and_edit_is_identical() { + // When the edited index matches the original (including opclass), no rebuild. + let mut idx = index("idx_name_trgm", &["name"]); + idx.index_type = "GIN".to_string(); + idx.column_opclasses = vec![Some("gin_trgm_ops".to_string())]; + idx.original = Some(IndexInfo { + name: "idx_name_trgm".to_string(), + columns: vec!["name".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: Some("gin".to_string()), + included_columns: None, + comment: None, + key_is_expression: vec![false], + column_opclasses: vec![Some("gin_trgm_ops".to_string())], + }); + + let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx)); + + assert!(result.warnings.is_empty()); + assert!(result.statements.is_empty(), "Expected no rebuild for unchanged index, got: {:?}", result.statements); +} + +#[test] +fn postgres_index_rebuild_when_opclass_changed() { + // When opclass changes, the index should be rebuilt. + let mut idx = index("idx_name_trgm", &["name"]); + idx.index_type = "GIN".to_string(); + idx.column_opclasses = vec![Some("gin_trgm_ops".to_string())]; + idx.original = Some(IndexInfo { + name: "idx_name_trgm".to_string(), + columns: vec!["name".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: Some("gin".to_string()), + included_columns: None, + comment: None, + key_is_expression: vec![false], + column_opclasses: vec![None], // was default, now gin_trgm_ops + }); + + let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx)); + + assert!(result.warnings.is_empty()); + assert!(!result.statements.is_empty(), "Expected rebuild when opclass changes"); + let sql = result.statements.join("\n"); + assert!(sql.contains("DROP INDEX"), "Expected DROP INDEX, got: {sql}"); + assert!(sql.contains("gin_trgm_ops"), "Expected new opclass in DDL, got: {sql}"); +} + +#[test] +fn postgres_gin_varchar_no_opclass_warns() { + let mut idx = index("idx_name_search", &["name"]); + idx.index_type = "GIN".to_string(); + idx.column_opclasses = vec![]; + + let mut options = index_change_options(DatabaseType::Postgres, Some("public"), idx); + // Add a varchar column for the warning to match against. + options.columns.push(EditableStructureColumn { + id: "col_name".to_string(), + name: "name".to_string(), + data_type: "character varying(255)".to_string(), + is_nullable: true, + default_value: String::new(), + comment: String::new(), + is_primary_key: false, + extra: None, + original: None, + original_position: None, + marked_for_drop: false, + character_set: String::new(), + collation: String::new(), + }); + + let result = build_table_structure_change_sql(options); + + assert!( + result.warnings.iter().any(|w| w.contains("operator class") && w.contains("idx_name_search")), + "Expected opclass warning for GIN+varchar, got: {:?}", + result.warnings + ); +} + +#[test] +fn postgres_gin_text_column_with_explicit_opclass_no_warning() { + let mut idx = index("idx_body_search", &["body"]); + idx.index_type = "GIN".to_string(); + idx.column_opclasses = vec![Some("gin_trgm_ops".to_string())]; + + let mut options = index_change_options(DatabaseType::Postgres, Some("public"), idx); + options.columns.push(EditableStructureColumn { + id: "col_body".to_string(), + name: "body".to_string(), + data_type: "text".to_string(), + is_nullable: true, + default_value: String::new(), + comment: String::new(), + is_primary_key: false, + extra: None, + original: None, + original_position: None, + marked_for_drop: false, + character_set: String::new(), + collation: String::new(), + }); + + let result = build_table_structure_change_sql(options); + + assert!( + !result.warnings.iter().any(|w| w.contains("operator class")), + "Expected NO warning when opclass is set, got: {:?}", + result.warnings + ); +} + +#[test] +fn postgres_expression_index_appends_opclass() { + // The per-column `pg_get_indexdef(indexrelid, colno, pretty)` returns only the + // bare expression (PostgreSQL sets `attrsOnly = (colno != 0)`, so the + // opclass/COLLATE/DESC block is skipped — see `ruleutils.c` and the note on + // `list_indexes_with_sql`). The opclass is read separately from `indclass` + // into `column_opclasses`, so the generator must append it to an expression + // key just like a real column. + let mut idx = index("idx_lower_email", &["lower(email)"]); + idx.index_type = "GIN".to_string(); + idx.column_opclasses = vec![Some("gin_trgm_ops".to_string())]; + idx.original = Some(IndexInfo { + name: "idx_lower_email".to_string(), + columns: vec!["lower(email)".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: Some("gin".to_string()), + included_columns: None, + comment: None, + key_is_expression: vec![true], + column_opclasses: vec![None], + }); + + let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx)); + + assert!(result.warnings.is_empty()); + let sql = result.statements.join("\n"); + assert!(sql.contains("lower(email) gin_trgm_ops"), "Expression should have opclass appended, got: {sql}"); +} + +#[test] +fn postgres_jsonb_gin_with_jsonb_path_ops() { + let mut idx = index("idx_metadata", &["metadata"]); + idx.index_type = "GIN".to_string(); + idx.column_opclasses = vec![Some("jsonb_path_ops".to_string())]; + + let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx)); + + assert!(result.warnings.is_empty()); + let sql = result.statements.join("\n"); + assert!(sql.contains(r#""metadata" jsonb_path_ops"#), "Expected jsonb_path_ops in DDL, got: {sql}"); + assert!(sql.contains("USING GIN"), "Expected USING GIN, got: {sql}"); +} + +#[test] +fn postgres_expression_index_opclass_round_trips_from_indclass() { + // Real PostgreSQL introspection: the expression text from + // `pg_get_indexdef(indexrelid, colno, false)` is the BARE expression + // (`attrsOnly = (colno != 0)` drops the opclass block — see `ruleutils.c`). + // The opclass is read separately from `pg_index.indclass` (schema-qualified) + // into `column_opclasses`, so the generator appends it to the bare expression + // rather than expecting it to be embedded in the text. + let mut idx = index("idx_lower_email_trgm", &["lower(email)"]); + idx.index_type = "GIN".to_string(); + idx.column_opclasses = vec![Some("public.gin_trgm_ops".to_string())]; + idx.original = Some(IndexInfo { + name: "idx_lower_email_trgm_old".to_string(), // different name → forces rebuild + columns: vec!["lower(email)".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: Some("gin".to_string()), + included_columns: None, + comment: None, + key_is_expression: vec![true], + column_opclasses: vec![Some("public.gin_trgm_ops".to_string())], + }); + + let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx)); + + assert!(result.warnings.is_empty()); + let sql = result.statements.join("\n"); + assert!( + sql.contains("lower(email) public.gin_trgm_ops"), + "Expected bare expression + schema-qualified opclass, got: {sql}" + ); + // The bare expression text no longer carries the opclass, so it cannot be duplicated. + assert!(!sql.contains("gin_trgm_ops gin_trgm_ops"), "Opclass must not be duplicated, got: {sql}"); +} diff --git a/crates/dbx-core/src/table_structure_sql/types.rs b/crates/dbx-core/src/table_structure_sql/types.rs index 645c287868..1345e0625b 100644 --- a/crates/dbx-core/src/table_structure_sql/types.rs +++ b/crates/dbx-core/src/table_structure_sql/types.rs @@ -94,6 +94,11 @@ pub struct EditableStructureIndex { pub index_type: String, #[serde(default)] pub included_columns: Vec, + /// Parallel to `columns`: operator class for each key column (PostgreSQL). + /// `None` means default operator class. The UI keeps this array in lockstep + /// with `columns`; when empty, opclasses fall back to `original` matching. + #[serde(default)] + pub column_opclasses: Vec>, #[serde(default)] pub comment: String, #[serde(default)] @@ -125,6 +130,9 @@ pub struct IndexInfo { /// (e.g. sourced from `pg_get_indexdef`), not a plain column name. #[serde(default)] pub key_is_expression: Vec, + /// Parallel to `columns`: operator class name for each key column, if non-default. + #[serde(default)] + pub column_opclasses: Vec>, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/dbx-core/src/table_structure_sql/validation.rs b/crates/dbx-core/src/table_structure_sql/validation.rs index c743854062..3de6e0d0ec 100644 --- a/crates/dbx-core/src/table_structure_sql/validation.rs +++ b/crates/dbx-core/src/table_structure_sql/validation.rs @@ -43,6 +43,7 @@ pub(super) fn validate_draft(options: &TableStructureSqlOptions) -> Vec let active_columns: Vec<_> = options.columns.iter().filter(|column| !column.marked_for_drop).collect(); validate_columns(&active_columns, &mut warnings); validate_dameng_identity(options, &active_columns, &mut warnings); + validate_gin_opclass_warnings(options, &mut warnings); for index in options .indexes .iter() @@ -101,3 +102,67 @@ pub(super) fn validate_columns(columns: &[&EditableStructureColumn], warnings: & } } } + +/// PostgreSQL GIN indexes on varchar/text/character columns without an explicit +/// operator class are a common trap: the default `array_ops` class is rarely what +/// the user wants, and `gin_trgm_ops` (from `pg_trgm` extension) or similar +/// classes are almost always needed. This is a warning, not an error, because +/// the default class may be deliberate in some cases. +fn validate_gin_opclass_warnings(options: &TableStructureSqlOptions, warnings: &mut Vec) { + let capabilities = capabilities_for(options.database_type); + if capabilities.dialect != StructureDialect::Postgres { + return; + } + + // Build a lookup: column name (lowercased) → data type for active columns. + let col_types: std::collections::HashMap = options + .columns + .iter() + .filter(|col| !col.marked_for_drop) + .map(|col| (clean(&col.name).to_lowercase(), col.data_type.as_str())) + .collect(); + + let text_like = |dt: &str| -> bool { + let lower = dt.to_lowercase(); + lower == "text" + || lower.starts_with("character varying") + || lower.starts_with("varchar") + || lower.starts_with("character") + || lower == "char" + }; + + for index in options.indexes.iter().filter(|idx| { + !idx.marked_for_drop + && clean(&idx.index_type).eq_ignore_ascii_case("GIN") + && (idx.original.is_none() || has_existing_index_change(idx)) + }) { + for (i, col) in index.columns.iter().enumerate() { + let col_name = clean(col); + if col_name.is_empty() { + continue; + } + // Expression columns are not matched against table column types. + let is_expr = + index.original.as_ref().and_then(|orig| orig.key_is_expression.get(i).copied()).unwrap_or(false); + if is_expr { + continue; + } + // Check if this column has a text-like type. + let Some(dt) = col_types.get(&col_name.to_lowercase()) else { + continue; + }; + if !text_like(dt) { + continue; + } + // Check if opclass is explicitly provided. + let has_opclass = + index.column_opclasses.get(i).and_then(|o| o.as_deref()).map(|v| !clean(v).is_empty()).unwrap_or(false); + if !has_opclass { + warnings.push(format!( + "GIN index \"{}\" on column \"{}\" (type {}) has no operator class specified. Consider adding an operator class, e.g. `gin_trgm_ops` (requires pg_trgm extension) for text search.", + index.name, col_name, dt + )); + } + } + } +} diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index c3ef608c3e..37fbe1cb9f 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -1276,17 +1276,6 @@ fn query_result_has_rows(result: &db::QueryResult) -> bool { !result.rows.is_empty() } -fn is_simple_identifier(value: &str) -> bool { - let mut chars = value.chars(); - let Some(first) = chars.next() else { - return false; - }; - if !(first == '_' || first.is_ascii_alphabetic()) { - return false; - } - chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) -} - fn is_postgres_compat_transfer(source_db: &DatabaseType, target_db: &DatabaseType) -> bool { is_postgres_transfer_dialect(source_db) && is_postgres_transfer_dialect(target_db) } @@ -2014,11 +2003,18 @@ fn sqlserver_row_number_page_sql( ) } -fn postgres_index_column_sql(column: &str) -> String { - if is_simple_identifier(column) { - quote_identifier(column, &DatabaseType::Postgres) - } else { - column.to_string() +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 (the per-column `pg_get_indexdef` omits the + // opclass — see `crates/dbx-core/src/db/postgres.rs`), so quoting the whole thing as + // an identifier would turn it into a nonexistent column reference (#6295). + let base = if is_expression { column.to_string() } else { quote_identifier(column, &DatabaseType::Postgres) }; + // 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, } } @@ -2037,8 +2033,17 @@ fn generate_postgres_index_ddl(indexes: &[db::IndexInfo], table: &str, schema: & .filter(|value| !value.is_empty()) .map(|value| format!(" USING {value}")) .unwrap_or_default(); - let columns = - index.columns.iter().map(|column| postgres_index_column_sql(column)).collect::>().join(", "); + let columns = index + .columns + .iter() + .enumerate() + .map(|(i, column)| { + let is_expr = index.key_is_expression.get(i).copied().unwrap_or(false); + let opclass = index.column_opclasses.get(i).and_then(|o| o.as_deref()); + postgres_index_column_sql(column, is_expr, opclass) + }) + .collect::>() + .join(", "); let include_clause = index .included_columns .as_ref() @@ -11664,7 +11669,8 @@ mod tests { index_type: Some("btree".to_string()), included_columns: Some(vec!["created_at".to_string()]), comment: Some("lookup index".to_string()), - key_is_expression: Vec::new(), + key_is_expression: vec![true], + column_opclasses: vec![], }]; let foreign_keys = vec![ db::ForeignKeyInfo { @@ -11705,6 +11711,128 @@ mod tests { ); } + #[test] + fn postgres_index_ddl_includes_opclass_for_gin_trigram() { + let indexes = vec![db::IndexInfo { + name: "users_name_trgm_idx".to_string(), + columns: vec!["name".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: Some("gin".to_string()), + included_columns: None, + comment: None, + key_is_expression: vec![false], + column_opclasses: vec![Some("gin_trgm_ops".to_string())], + }]; + + let sql = generate_postgres_index_ddl(&indexes, "users", "public"); + + assert_eq!( + sql, + vec!["CREATE INDEX IF NOT EXISTS \"users_name_trgm_idx\" ON \"public\".\"users\" USING gin (\"name\" gin_trgm_ops)".to_string()] + ); + } + + #[test] + fn postgres_index_ddl_no_opclass_when_all_default() { + let indexes = vec![db::IndexInfo { + name: "users_name_idx".to_string(), + columns: vec!["name".to_string(), "status".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: None, + included_columns: None, + comment: None, + key_is_expression: vec![false, false], + column_opclasses: vec![None, None], + }]; + + let sql = generate_postgres_index_ddl(&indexes, "users", "public"); + + assert_eq!( + sql, + vec!["CREATE INDEX IF NOT EXISTS \"users_name_idx\" ON \"public\".\"users\" (\"name\", \"status\")" + .to_string()] + ); + } + + #[test] + fn postgres_index_ddl_expression_appends_opclass() { + // The per-column `pg_get_indexdef(indexrelid, colno, pretty)` returns only the + // bare expression (PostgreSQL sets `attrsOnly = (colno != 0)`, so the opclass + // block is skipped — see `ruleutils.c`). The opclass is read separately from + // `indclass` into `column_opclasses`, so transfer DDL must append it to an + // expression key just like a real column. + let indexes = vec![db::IndexInfo { + name: "users_lower_email_idx".to_string(), + columns: vec!["lower(email)".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: Some("gin".to_string()), + included_columns: None, + comment: None, + key_is_expression: vec![true], + column_opclasses: vec![Some("gin_trgm_ops".to_string())], + }]; + + let sql = generate_postgres_index_ddl(&indexes, "users", "public"); + + assert_eq!( + sql, + vec!["CREATE INDEX IF NOT EXISTS \"users_lower_email_idx\" ON \"public\".\"users\" USING gin (lower(email) gin_trgm_ops)" + .to_string()] + ); + } + + #[test] + fn postgres_index_ddl_mixed_opclass_and_default() { + let indexes = vec![db::IndexInfo { + name: "users_name_status_idx".to_string(), + columns: vec!["name".to_string(), "status".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: Some("btree".to_string()), + included_columns: None, + comment: None, + key_is_expression: vec![false, false], + column_opclasses: vec![Some("text_pattern_ops".to_string()), None], + }]; + + let sql = generate_postgres_index_ddl(&indexes, "users", "public"); + + assert_eq!( + sql, + vec!["CREATE INDEX IF NOT EXISTS \"users_name_status_idx\" ON \"public\".\"users\" USING btree (\"name\" text_pattern_ops, \"status\")".to_string()] + ); + } + + #[test] + fn postgres_index_ddl_empty_column_opclasses_vec_falls_back_to_no_opclass() { + let indexes = vec![db::IndexInfo { + name: "users_name_idx".to_string(), + columns: vec!["name".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: None, + included_columns: None, + comment: None, + key_is_expression: vec![false], + column_opclasses: vec![], + }]; + + let sql = generate_postgres_index_ddl(&indexes, "users", "public"); + + assert_eq!( + sql, + vec!["CREATE INDEX IF NOT EXISTS \"users_name_idx\" ON \"public\".\"users\" (\"name\")".to_string()] + ); + } + #[test] fn postgres_sequence_sync_sql_uses_table_max_values() { let sql = generate_postgres_sequence_sync_sql( diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index c2a713caab..1776c44780 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -456,6 +456,11 @@ pub struct IndexInfo { /// introspection source doesn't track this (provenance unknown for that dialect/path). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub key_is_expression: Vec, + /// Parallel to `columns`: operator class name for each key column, if non-default. + /// For example, a GIN trigram index on a varchar column will have `"gin_trgm_ops"`. + /// `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>, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/dbx-core/tests/live_postgres_concurrent_index.rs b/crates/dbx-core/tests/live_postgres_concurrent_index.rs index 3de24282e5..b908489dc3 100644 --- a/crates/dbx-core/tests/live_postgres_concurrent_index.rs +++ b/crates/dbx-core/tests/live_postgres_concurrent_index.rs @@ -25,6 +25,7 @@ fn concurrent_index(columns: Vec<&str>) -> EditableStructureIndex { filter: String::new(), index_type: String::new(), included_columns: Vec::new(), + column_opclasses: Vec::new(), comment: String::new(), concurrently: true, original: None,