diff --git a/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts index 097f767b2..f9c7a78cb 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts @@ -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"); }); diff --git a/apps/desktop/src/lib/database/databaseObjectCapabilities.ts b/apps/desktop/src/lib/database/databaseObjectCapabilities.ts index 3b0f3357f..bac265fa7 100644 --- a/apps/desktop/src/lib/database/databaseObjectCapabilities.ts +++ b/apps/desktop/src/lib/database/databaseObjectCapabilities.ts @@ -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([ ["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], diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index f1883d27c..fae4ab470 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -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, 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, materialized_view_names: Result, 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 = 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![("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![ + ("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![ diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index e37e72602..d70306499 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -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 { - 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 { + use mysql_async::prelude::*; + let result = conn.query_iter(sql).await.map_err(|e| e.to_string())?; let rows: Vec = 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::(index) + row.get_opt::(ddl_column_index) .and_then(|result| result.ok()) .or_else(|| { - row.get_opt::, usize>(index) + row.get_opt::, usize>(ddl_column_index) .and_then(|result| result.ok()) .map(|b| String::from_utf8_lossy(&b).to_string()) })