fix(starrocks): expose materialized views in sidebar

This commit is contained in:
如梦技术 2026-07-27 16:57:24 +08:00 committed by GitHub
parent bd693d7b98
commit 38c2bced92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 262 additions and 24 deletions

View File

@ -6,6 +6,20 @@ describe("databaseObjectCapabilities", () => {
expect(sidebarObjectKindsForDatabase("dameng")).toContain("MATERIALIZED_VIEW");
});
it("exposes materialized views for StarRocks only", () => {
// StarRocks has a dedicated MV listing/classification path in
// crates/dbx-core/src/db/mysql.rs (`list_starrocks_tables` +
// `classify_starrocks_materialized_views`).
expect(sidebarObjectKindsForDatabase("starrocks")).toContain("MATERIALIZED_VIEW");
// Doris uses the generic SHOW TABLES listing path with no MV classifier,
// so advertising MV in the sidebar would have nothing to route to.
// Keep Doris on TABLE_VIEW_OBJECTS until a Doris-specific listing path
// lands.
expect(sidebarObjectKindsForDatabase("doris")).not.toContain("MATERIALIZED_VIEW");
expect(sidebarObjectKindsForDatabase("doris")).toEqual(expect.arrayContaining(["TABLE", "VIEW"]));
});
it("normalizes space separated materialized view types", () => {
expect(normalizeSidebarObjectKind("MATERIALIZED VIEW")).toBe("MATERIALIZED_VIEW");
});

View File

@ -9,6 +9,7 @@ export interface DatabaseObjectCapabilities {
}
const TABLE_VIEW_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW"];
const TABLE_VIEW_MV_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW"];
const ROUTINE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION"];
@ -41,8 +42,13 @@ const DATABASE_TYPE_OBJECTS = new Map<DatabaseType, SidebarObjectKind[]>([
["cloudflare-d1", TABLE_VIEW_OBJECTS],
["duckdb", TABLE_VIEW_OBJECTS],
["clickhouse", TABLE_VIEW_OBJECTS],
// Doris: backend listing path still uses the generic SHOW TABLES path (see
// `list_tables_once` for `PoolKind::Mysql` in crates/dbx-core/src/schema.rs)
// and lacks a MV classifier. Keep Doris on TABLE_VIEW_OBJECTS until a
// Doris-specific MV listing/classification lands, otherwise the UI advertises
// MV support that the backend cannot route.
["doris", TABLE_VIEW_OBJECTS],
["starrocks", TABLE_VIEW_OBJECTS],
["starrocks", TABLE_VIEW_MV_OBJECTS],
["hive", TABLE_VIEW_OBJECTS],
["spark", TABLE_VIEW_OBJECTS],
["trino", TABLE_VIEW_OBJECTS],

View File

@ -2411,6 +2411,29 @@ fn starrocks_materialized_views_sql(database: &str) -> String {
)
}
/// Fallback DDL source for StarRocks materialized views when `SHOW CREATE
/// MATERIALIZED VIEW` fails (e.g. on versions predating starrocks/starrocks#73396,
/// merged 2026-05-19, which reject the statement for sync MVs with "Table not
/// found" because sync MVs are not registered as separate Tables).
///
/// `information_schema.materialized_views` is documented as the authoritative
/// list of all materialized views, with a column distinguishing SYNC from
/// ASYNC. See
/// https://docs.starrocks.io/docs/sql-reference/information_schema/materialized_views/.
///
/// Made `pub(super)` so the dispatch site in `schema::mysql_object_source` can
/// rely on it without rewriting the escape convention.
pub(crate) fn mysql_materialized_view_definition_sql(database: &str, name: &str) -> String {
format!(
"SELECT MATERIALIZED_VIEW_DEFINITION \
FROM information_schema.materialized_views \
WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} \
LIMIT 1",
quote_value(database),
quote_value(name)
)
}
async fn list_starrocks_materialized_view_names(pool: &MySqlPool, database: &str) -> Result<HashSet<String>, String> {
let sql = starrocks_materialized_views_sql(database);
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
@ -2425,8 +2448,8 @@ async fn list_starrocks_materialized_view_names(pool: &MySqlPool, database: &str
.collect())
}
fn classify_starrocks_materialized_views(
tables: &mut [TableInfo],
fn merge_starrocks_materialized_views(
tables: &mut Vec<TableInfo>,
materialized_view_names: Result<HashSet<String>, String>,
database: &str,
) {
@ -2440,11 +2463,38 @@ fn classify_starrocks_materialized_views(
}
};
for table in tables {
if table.table_type.eq_ignore_ascii_case("VIEW") && materialized_view_names.contains(&table.name) {
// Snapshot the names already returned by SHOW FULL TABLES so the second pass can
// append MVs that are absent from SHOW FULL TABLES without duplicating rows.
let known_names: HashSet<String> = tables.iter().map(|table| table.name.clone()).collect();
// Step 1 — reclassify: rows whose name appears in `information_schema.materialized_views`
// are MVs even when SHOW FULL TABLES labeled them as VIEW (sync MVs) or BASE TABLE
// (async MVs). See https://docs.starrocks.io/docs/sql-reference/information_schema/materialized_views/
// for the authoritative distinction between the two MV kinds.
for table in tables.iter_mut() {
if materialized_view_names.contains(&table.name) {
table.table_type = "MATERIALIZED_VIEW".to_string();
}
}
// Step 2 — union: on StarRocks versions predating starrocks/starrocks#73396 (merged
// 2026-05-19), sync MVs "are not registered as separate Tables" so SHOW FULL TABLES
// omits them entirely. Append those rows from the system view so they appear in the
// sidebar and the DDL source path has something to resolve. Sort names so that
// the resulting table order is deterministic across runs.
let mut materialized_view_names_sorted: Vec<&String> = materialized_view_names.iter().collect();
materialized_view_names_sorted.sort();
for name in materialized_view_names_sorted {
if !known_names.contains(name.as_str()) {
tables.push(TableInfo {
name: name.clone(),
table_type: "MATERIALIZED_VIEW".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
});
}
}
}
async fn list_starrocks_tables_with_status(
@ -2456,7 +2506,7 @@ async fn list_starrocks_tables_with_status(
list_starrocks_materialized_view_names(pool, database)
);
let (mut tables, status) = tables?;
classify_starrocks_materialized_views(&mut tables, materialized_view_names, database);
merge_starrocks_materialized_views(&mut tables, materialized_view_names, database);
Ok((tables, status))
}
@ -4399,7 +4449,7 @@ mod tests {
];
let materialized_views = HashSet::from(["orders_mv".to_string(), "orders_mv".to_string()]);
classify_starrocks_materialized_views(&mut tables, Ok(materialized_views), "analytics");
merge_starrocks_materialized_views(&mut tables, Ok(materialized_views), "analytics");
assert_eq!(tables.len(), 3);
assert_eq!(
@ -4408,6 +4458,37 @@ mod tests {
);
}
#[test]
fn starrocks_async_materialized_views_reported_as_base_table_are_reclassified() {
// Async materialized views (StarRocks >= 2.5) appear as `BASE TABLE` in
// `SHOW FULL TABLES`. Classification must trust the
// `information_schema.materialized_views` source.
let mut tables = vec![
TableInfo {
name: "orders".to_string(),
table_type: "BASE TABLE".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
},
TableInfo {
name: "orders_async_mv".to_string(),
table_type: "BASE TABLE".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
},
];
let materialized_views = HashSet::from(["orders_async_mv".to_string()]);
merge_starrocks_materialized_views(&mut tables, Ok(materialized_views), "analytics");
assert_eq!(
tables.iter().map(|table| (table.name.as_str(), table.table_type.as_str())).collect::<Vec<_>>(),
vec![("orders", "BASE TABLE"), ("orders_async_mv", "MATERIALIZED_VIEW")]
);
}
#[test]
fn starrocks_materialized_view_lookup_failure_keeps_base_types() {
let mut tables = vec![TableInfo {
@ -4418,11 +4499,43 @@ mod tests {
parent_name: None,
}];
classify_starrocks_materialized_views(&mut tables, Err("permission denied".to_string()), "analytics");
merge_starrocks_materialized_views(&mut tables, Err("permission denied".to_string()), "analytics");
assert_eq!(tables[0].table_type, "VIEW");
}
#[test]
fn starrocks_sync_mv_absent_from_show_full_tables_is_appended_from_information_schema() {
// StarRocks versions predating starrocks/starrocks#73396 (merged
// 2026-05-19) report sync MVs as "not registered as separate Tables",
// so SHOW FULL TABLES omits them. The merger must union names from
// information_schema.materialized_views so the sidebar and DDL path
// still resolve them.
let mut tables = vec![TableInfo {
name: "orders".to_string(),
table_type: "BASE TABLE".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
}];
let materialized_views = HashSet::from([
"orders_mv".to_string(), // already present (reclassify path)
"daily_orders_mv".to_string(), // absent from SHOW FULL TABLES (union path)
]);
merge_starrocks_materialized_views(&mut tables, Ok(materialized_views), "analytics");
assert_eq!(tables.len(), 3);
assert_eq!(
tables.iter().map(|table| (table.name.as_str(), table.table_type.as_str())).collect::<Vec<_>>(),
vec![
("orders", "BASE TABLE"),
("daily_orders_mv", "MATERIALIZED_VIEW"),
("orders_mv", "MATERIALIZED_VIEW"),
]
);
}
#[test]
fn starrocks_materialized_view_query_is_scoped_to_database() {
let sql = starrocks_materialized_views_sql("tenant's analytics");
@ -4433,6 +4546,24 @@ mod tests {
);
}
#[test]
fn mysql_materialized_view_definition_fallback_is_scoped_to_db_and_name() {
// StarRocks predating PR 73396 (merged 2026-05-19) rejects
// `SHOW CREATE MATERIALIZED VIEW` for sync MVs with "Table not found"
// because sync MVs are not registered as separate Tables. The fallback
// path queries information_schema.materialized_views directly. The
// regression guards the SQL shape and the value escaping used by that
// fallback so the wire format isn't accidentally regressed.
assert_eq!(
mysql_materialized_view_definition_sql("shop", "daily_sales_mv"),
"SELECT MATERIALIZED_VIEW_DEFINITION FROM information_schema.materialized_views WHERE TABLE_SCHEMA = 'shop' AND TABLE_NAME = 'daily_sales_mv' LIMIT 1"
);
assert_eq!(
mysql_materialized_view_definition_sql("tenant's analytics", "weird'name"),
"SELECT MATERIALIZED_VIEW_DEFINITION FROM information_schema.materialized_views WHERE TABLE_SCHEMA = 'tenant\\'s analytics' AND TABLE_NAME = 'weird\\'name' LIMIT 1"
);
}
#[test]
fn starrocks_object_conversion_preserves_table_view_and_materialized_view_types() {
let tables = vec![

View File

@ -2848,14 +2848,15 @@ mod tests {
dameng_object_statistics_rows_only_sql, dameng_object_statistics_user_segments_sql, deduplicate_column_infos,
filter_mysql_system_databases_for_config, filter_object_infos, filter_table_infos, filter_visible_schema_names,
gbase8a_object_statistics_sql, is_agent_postgres_metadata_fallback_config, is_retryable_metadata_error,
mysql_object_source_sql, mysql_table_metadata_catalog, normalize_information_schema_table_type,
oracle_columns_from_query_result, oracle_columns_sql, oracle_object_statistics_dba_segments_sql,
oracle_object_statistics_from_query_result, oracle_object_statistics_rows_only_sql,
oracle_object_statistics_sql, oracle_object_statistics_user_segments_sql,
oracle_table_comment_from_query_result, oracle_table_comment_sql, oracle_table_comments_from_query_result,
oracle_table_comments_sql, presto_like_columns_from_query_result, presto_like_information_schema_columns_sql,
presto_like_information_schema_tables_sql, presto_like_tables_from_query_result,
should_query_oracle_columns_via_sql_first, table_name_filter_matches, visible_schema_filter, TableNameFilter,
mysql_object_source_ddl_column_index, mysql_object_source_sql, mysql_table_metadata_catalog,
normalize_information_schema_table_type, oracle_columns_from_query_result, oracle_columns_sql,
oracle_object_statistics_dba_segments_sql, oracle_object_statistics_from_query_result,
oracle_object_statistics_rows_only_sql, oracle_object_statistics_sql,
oracle_object_statistics_user_segments_sql, oracle_table_comment_from_query_result, oracle_table_comment_sql,
oracle_table_comments_from_query_result, oracle_table_comments_sql, presto_like_columns_from_query_result,
presto_like_information_schema_columns_sql, presto_like_information_schema_tables_sql,
presto_like_tables_from_query_result, should_query_oracle_columns_via_sql_first, table_name_filter_matches,
visible_schema_filter, TableNameFilter,
};
#[cfg(feature = "duckdb-bundled")]
use super::{
@ -2962,6 +2963,35 @@ mod tests {
);
}
#[test]
fn mysql_object_source_sql_emits_show_create_materialized_view() {
// Regression for the review comment: Doris / StarRocks ride on the MySQL
// protocol, so the MV branch of mysql_object_source_sql must produce a
// real statement (used at crates/dbx-core/src/schema.rs:5395-5404 by
// get_table_ddl_core). Returning an empty string silently broke the UI.
assert_eq!(
mysql_object_source_sql("shop", "daily_sales_mv", &db::ObjectSourceKind::MaterializedView),
"SHOW CREATE MATERIALIZED VIEW `shop`.`daily_sales_mv`"
);
assert_eq!(
mysql_object_source_sql("", "daily_sales_mv", &db::ObjectSourceKind::MaterializedView),
"SHOW CREATE MATERIALIZED VIEW `daily_sales_mv`"
);
}
#[test]
fn mysql_object_source_ddl_column_index_matches_dialect_layout() {
// VIEW and Doris/StarRocks MaterializedView return (Name, DDL).
// PROCEDURE / FUNCTION return (Name, sql_mode, DDL, …).
// Reading the wrong index returns the empty/no-op and surfaces as
// "Failed to read object source" — regression-guarded here so we
// don't have to spin up a real StarRocks to catch it.
assert_eq!(mysql_object_source_ddl_column_index(&db::ObjectSourceKind::View), 1);
assert_eq!(mysql_object_source_ddl_column_index(&db::ObjectSourceKind::MaterializedView), 1);
assert_eq!(mysql_object_source_ddl_column_index(&db::ObjectSourceKind::Procedure), 2);
assert_eq!(mysql_object_source_ddl_column_index(&db::ObjectSourceKind::Function), 2);
}
#[test]
fn metadata_retry_recovers_missing_pool_only_as_transient_state() {
assert!(is_retryable_metadata_error("Pool not found"));
@ -6193,8 +6223,40 @@ pub fn mysql_object_source_sql(database: &str, name: &str, kind: &db::ObjectSour
| db::ObjectSourceKind::Package
| db::ObjectSourceKind::PackageBody
| db::ObjectSourceKind::Type
| db::ObjectSourceKind::TypeBody
| db::ObjectSourceKind::MaterializedView => String::new(),
| db::ObjectSourceKind::TypeBody => String::new(),
// Doris and StarRocks expose materialized views via `SHOW CREATE MATERIALIZED VIEW`.
// MySQL itself never reaches this arm in normal use: the desktop capabilities map at
// apps/desktop/src/lib/database/databaseObjectCapabilities.ts has no "mysql" entry,
// so the UI never sends MaterializedView for a real MySQL connection. If something
// else forces the kind through, MySQL 8.x will surface a syntax error instead of
// silently returning empty, which is the desired fail-loud behaviour.
db::ObjectSourceKind::MaterializedView => {
format!("SHOW CREATE MATERIALIZED VIEW {qualified_name}")
}
}
}
/// Column index of the DDL text in the row returned by the statements generated
/// by [`mysql_object_source_sql`].
///
/// The shape of the result is dialect-dependent:
/// - `SHOW CREATE VIEW`, Doris/StarRocks `SHOW CREATE MATERIALIZED VIEW` →
/// `(Name, DDL)` → DDL at index `1`.
/// - `SHOW CREATE PROCEDURE`, `SHOW CREATE FUNCTION` →
/// `(Name, sql_mode, DDL, …)` → DDL at index `2`.
///
/// Encoded as a function so the index can be unit-tested without a live DB.
pub(crate) fn mysql_object_source_ddl_column_index(kind: &db::ObjectSourceKind) -> usize {
match kind {
db::ObjectSourceKind::View | db::ObjectSourceKind::MaterializedView => 1,
db::ObjectSourceKind::Procedure
| db::ObjectSourceKind::Function
| db::ObjectSourceKind::Trigger
| db::ObjectSourceKind::Sequence
| db::ObjectSourceKind::Package
| db::ObjectSourceKind::PackageBody
| db::ObjectSourceKind::Type
| db::ObjectSourceKind::TypeBody => 2,
}
}
@ -6224,17 +6286,42 @@ async fn mysql_object_source(
name: &str,
kind: &db::ObjectSourceKind,
) -> Result<String, String> {
use mysql_async::prelude::*;
let sql = mysql_object_source_sql(database, name, kind);
let primary_sql = mysql_object_source_sql(database, name, kind);
let primary_column_index = mysql_object_source_ddl_column_index(kind);
let mut conn = db::mysql::get_conn_with_timeout(pool, db::connection_timeout()).await?;
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
match read_mysql_object_source_row(&mut conn, &primary_sql, primary_column_index).await {
Ok(source) => Ok(source),
Err(primary_err) if matches!(kind, db::ObjectSourceKind::MaterializedView) => {
// StarRocks predating PR 73396 rejects SHOW CREATE MATERIALIZED VIEW for
// sync MVs. Fall back to the persistent definition exposed by
// information_schema.materialized_views. The fallback returns a single
// column (MATERIALIZED_VIEW_DEFINITION) so the column index is always 0.
let fallback_sql = db::mysql::mysql_materialized_view_definition_sql(database, name);
read_mysql_object_source_row(&mut conn, &fallback_sql, 0).await.map_err(|fallback_err| {
format!(
"SHOW CREATE MATERIALIZED VIEW failed ({primary_err}); \
fallback query against information_schema.materialized_views failed ({fallback_err})"
)
})
}
Err(e) => Err(e),
}
}
async fn read_mysql_object_source_row(
conn: &mut mysql_async::Conn,
sql: &str,
ddl_column_index: usize,
) -> Result<String, String> {
use mysql_async::prelude::*;
let result = conn.query_iter(sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
let row = rows.first().ok_or("Object source not found")?;
let index = if matches!(kind, db::ObjectSourceKind::View) { 1 } else { 2 };
row.get_opt::<String, usize>(index)
row.get_opt::<String, usize>(ddl_column_index)
.and_then(|result| result.ok())
.or_else(|| {
row.get_opt::<Vec<u8>, usize>(index)
row.get_opt::<Vec<u8>, usize>(ddl_column_index)
.and_then(|result| result.ok())
.map(|b| String::from_utf8_lossy(&b).to_string())
})