fix(starrocks): expose materialized views in sidebar
This commit is contained in:
parent
bd693d7b98
commit
38c2bced92
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue