fix(postgres): keep index operator classes in read and generation

Closes #7166
This commit is contained in:
Tara authored and GitHub committed 2026-09-04 14:48:41 +08:00
1 parent 09f8d095d2
commit 01623ab38d
31 files changed
+854 -51

No files matched your search

@@ -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![],
});
}
@@ -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)
@@ -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![],
}
}
+6
View File
@@ -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()];
+1
View File
@@ -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
};
@@ -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<IndexInfo> {
included_columns: None,
comment: mysql_quoted_string_argument(after_name, "COMMENT"),
key_is_expression: Vec::new(),
column_opclasses: vec![],
})
}
+1
View File
@@ -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())
+96 -20
View File
@@ -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<String> = row.try_get::<_, Vec<String>>(2).unwrap_or_default();
let nkeyatts = row.try_get::<_, Option<i16>>(7).ok().flatten().unwrap_or(all_cols.len() as i16) as usize;
let all_opclasses: Vec<Option<String>> = row.try_get::<_, Vec<Option<String>>>(3).unwrap_or_default();
let nkeyatts = row.try_get::<_, Option<i16>>(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<bool> = row.try_get::<_, Vec<bool>>(10).unwrap_or_default();
let all_is_expr: Vec<bool> = row.try_get::<_, Vec<bool>>(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<String>>(5).ok().flatten(),
index_type: row.try_get::<_, Option<String>>(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<String>>(6).ok().flatten(),
index_type: row.try_get::<_, Option<String>>(7).ok().flatten(),
included_columns: if included.is_empty() { None } else { Some(included) },
comment: row.try_get::<_, Option<String>>(9).ok().flatten(),
comment: row.try_get::<_, Option<String>>(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<String> = row.try_get::<_, Vec<String>>(1).unwrap_or_default();
let nkeyatts = row.try_get::<_, Option<i16>>(6).ok().flatten().unwrap_or(all_cols.len() as i16) as usize;
let all_opclasses: Vec<Option<String>> = row.try_get::<_, Vec<Option<String>>>(2).unwrap_or_default();
let nkeyatts = row.try_get::<_, Option<i16>>(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<bool> = row.try_get::<_, Vec<bool>>(9).unwrap_or_default();
let all_is_expr: Vec<bool> = row.try_get::<_, Vec<bool>>(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<String>>(4).ok().flatten(),
index_type: row.try_get::<_, Option<String>>(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<String>>(5).ok().flatten(),
index_type: row.try_get::<_, Option<String>>(6).ok().flatten(),
included_columns: if included.is_empty() { None } else { Some(included) },
comment: row.try_get::<_, Option<String>>(8).ok().flatten(),
comment: row.try_get::<_, Option<String>>(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"));
+1
View File
@@ -177,6 +177,7 @@ pub async fn list_indexes(pool: &Pool, _schema: &str, table: &str) -> Result<Vec
included_columns: None,
comment: None,
key_is_expression: Vec::new(),
column_opclasses: vec![],
}
})
.collect())
+1
View File
@@ -180,6 +180,7 @@ pub async fn list_indexes(client: &RqliteClient, _schema: &str, table: &str) ->
included_columns: None,
comment: None,
key_is_expression: Vec::new(),
column_opclasses: vec![],
});
}
+2
View File
@@ -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)
+1
View File
@@ -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())
+1
View File
@@ -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![],
});
}
+5
View File
@@ -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"]));
+1
View File
@@ -194,6 +194,7 @@ mod tests {
included_columns: None,
comment: None,
key_is_expression: Vec::new(),
column_opclasses: vec![],
}
}
+56 -1
View File
@@ -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::<Vec<_>>().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::<Vec<_>>()
.join(", ");
let using = idx.index_type.as_deref().map(|t| format!(" USING {t}")).unwrap_or_default();
let include = idx
.included_columns
+85
View File
@@ -2866,6 +2866,14 @@ pub fn diff_indexes(source: &[IndexInfo], target: &[IndexInfo]) -> Vec<IndexDiff
if source_included.is_empty() { "none".to_string() } else { source_included.join(", ") }
));
}
if source_index.column_opclasses != target_index.column_opclasses {
let fmt_opclass = |o: &Option<String>| -> 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::<Vec<_>>().join(", ");
let tgt_str = target_index.column_opclasses.iter().map(fmt_opclass).collect::<Vec<_>>().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![],
@@ -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<String>],
previous: &[Option<String>],
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<String>>) -> 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<Option<String>> {
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<DatabaseType>,
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 {
@@ -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,
});
@@ -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::<String>::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}");
}
@@ -94,6 +94,11 @@ pub struct EditableStructureIndex {
pub index_type: String,
#[serde(default)]
pub included_columns: Vec<String>,
/// 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<Option<String>>,
#[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<bool>,
/// Parallel to `columns`: operator class name for each key column, if non-default.
#[serde(default)]
pub column_opclasses: Vec<Option<String>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -43,6 +43,7 @@ pub(super) fn validate_draft(options: &TableStructureSqlOptions) -> Vec<String>
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<String>) {
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<String, &str> = 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
));
}
}
}
}
+147 -19
View File
@@ -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::<Vec<_>>().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::<Vec<_>>()
.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(
+5
View File
@@ -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<bool>,
/// 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<Option<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -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,