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![],
});
}