From 82f1f77bf63333413f40ed29811d0c15a843b76c Mon Sep 17 00:00:00 2001 From: Davide Cazzetta Date: Sat, 20 Jun 2026 17:41:30 +0200 Subject: [PATCH] feat(views): add PostgreSQL materialized views support --- src-tauri/src/commands.rs | 140 ++++++++++++++++ src-tauri/src/drivers/driver_trait.rs | 44 +++++ src-tauri/src/drivers/mysql/mod.rs | 1 + src-tauri/src/drivers/postgres/mod.rs | 152 +++++++++++++++++- src-tauri/src/drivers/sqlite/mod.rs | 1 + src-tauri/src/lib.rs | 4 + src/components/layout/ExplorerSidebar.tsx | 61 +++++++ .../layout/sidebar/SidebarSchemaItem.tsx | 27 ++++ .../layout/sidebar/SidebarViewItem.tsx | 93 +++++++++-- src/contexts/DatabaseContext.ts | 1 + src/contexts/DatabaseProvider.tsx | 12 +- src/i18n/locales/en.json | 8 +- src/types/plugins.ts | 2 + .../layout/sidebar/SidebarViewItem.test.tsx | 50 ++++++ 14 files changed, 582 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cc353745..e4bb5cf0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3703,6 +3703,146 @@ pub async fn get_view_columns( result } +#[tauri::command] +pub async fn get_materialized_views( + app: AppHandle, + connection_id: String, + schema: Option, +) -> Result, String> { + log::info!("Fetching materialized views for connection: {}", connection_id); + + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv.get_materialized_views(¶ms, schema.as_deref()).await; + + match &result { + Ok(views) => log::info!( + "Retrieved {} materialized views from {}", + views.len(), + params.database + ), + Err(e) => log::error!( + "Failed to get materialized views from {}: {}", + params.database, + e + ), + } + + result +} + +#[tauri::command] +pub async fn get_materialized_view_columns( + app: AppHandle, + connection_id: String, + view_name: String, + schema: Option, +) -> Result, String> { + log::info!( + "Fetching materialized view columns for: {} on connection: {}", + view_name, + connection_id + ); + + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv + .get_materialized_view_columns(¶ms, &view_name, schema.as_deref()) + .await; + + match &result { + Ok(columns) => log::info!( + "Retrieved {} columns for materialized view {}", + columns.len(), + view_name + ), + Err(e) => log::error!( + "Failed to get materialized view columns for {}: {}", + view_name, + e + ), + } + + result +} + +#[tauri::command] +pub async fn get_materialized_view_definition( + app: AppHandle, + connection_id: String, + view_name: String, + schema: Option, +) -> Result { + log::info!( + "Fetching materialized view definition for: {} on connection: {}", + view_name, + connection_id + ); + + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv + .get_materialized_view_definition(¶ms, &view_name, schema.as_deref()) + .await; + + match &result { + Ok(_) => log::info!( + "Successfully retrieved materialized view definition for {}", + view_name + ), + Err(e) => log::error!( + "Failed to get materialized view definition for {}: {}", + view_name, + e + ), + } + + result +} + +#[tauri::command] +pub async fn refresh_materialized_view( + app: AppHandle, + connection_id: String, + view_name: String, + schema: Option, +) -> Result<(), String> { + log::info!( + "Refreshing materialized view: {} on connection: {}", + view_name, + connection_id + ); + + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv + .refresh_materialized_view(¶ms, &view_name, schema.as_deref()) + .await; + + match &result { + Ok(_) => log::info!("Successfully refreshed materialized view: {}", view_name), + Err(e) => log::error!("Failed to refresh materialized view {}: {}", view_name, e), + } + + result +} + #[tauri::command] pub async fn get_triggers( app: AppHandle, diff --git a/src-tauri/src/drivers/driver_trait.rs b/src-tauri/src/drivers/driver_trait.rs index f92245a3..8c2b67eb 100644 --- a/src-tauri/src/drivers/driver_trait.rs +++ b/src-tauri/src/drivers/driver_trait.rs @@ -56,6 +56,10 @@ pub struct DriverCapabilities { pub schemas: bool, /// Supports views. pub views: bool, + /// Supports materialized views (e.g. PostgreSQL). Gates the + /// "Materialized Views" tree group in the UI. Defaults to `false`. + #[serde(default)] + pub materialized_views: bool, /// Supports stored procedures and functions. pub routines: bool, /// File-based database (e.g. SQLite); no host/port required. @@ -336,6 +340,46 @@ pub trait DatabaseDriver: Send + Sync { schema: Option<&str>, ) -> Result<(), String>; + // --- Materialized views ------------------------------------------------- + // Default impls return empty / unsupported so drivers without materialized + // views (MySQL, SQLite, plugins) need no changes; the UI hides the group + // unless `DriverCapabilities::materialized_views` is set. + + async fn get_materialized_views( + &self, + _params: &ConnectionParams, + _schema: Option<&str>, + ) -> Result, String> { + Ok(Vec::new()) + } + + async fn get_materialized_view_columns( + &self, + _params: &ConnectionParams, + _view_name: &str, + _schema: Option<&str>, + ) -> Result, String> { + Ok(Vec::new()) + } + + async fn get_materialized_view_definition( + &self, + _params: &ConnectionParams, + _view_name: &str, + _schema: Option<&str>, + ) -> Result { + Err("Materialized views are not supported by this driver".to_string()) + } + + async fn refresh_materialized_view( + &self, + _params: &ConnectionParams, + _view_name: &str, + _schema: Option<&str>, + ) -> Result<(), String> { + Err("Materialized views are not supported by this driver".to_string()) + } + // --- Routines ----------------------------------------------------------- async fn get_routines( diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index a01e5591..7029fbbc 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -1208,6 +1208,7 @@ impl MysqlDriver { capabilities: DriverCapabilities { schemas: false, views: true, + materialized_views: false, routines: true, file_based: false, folder_based: false, diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index 50ba7480..4c1511a0 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -388,7 +388,7 @@ pub async fn get_indexes( JOIN pg_class i ON i.oid = ix.indexrelid JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) WHERE - t.relkind = 'r' + t.relkind IN ('r', 'm') AND n.nspname = $1 AND t.relname = $2 ORDER BY @@ -1113,6 +1113,120 @@ pub async fn get_view_columns( .collect()) } +pub async fn get_materialized_views( + params: &ConnectionParams, + schema: &str, +) -> Result, String> { + log::debug!( + "PostgreSQL: Fetching materialized views for database: {} schema: {}", + params.database, + schema + ); + let pool = get_postgres_pool(params).await?; + let rows = query_all( + &pool, + "SELECT matviewname as name FROM pg_matviews WHERE schemaname = $1 ORDER BY matviewname ASC", + &[&schema], + ) + .await?; + + let views: Vec = rows + .iter() + .map(|r| ViewInfo { + name: r.try_get("name").unwrap_or_default(), + definition: None, + }) + .collect(); + Ok(views) +} + +/// Materialized views are not exposed via `information_schema.columns`, so their +/// columns must be read from the system catalog (`pg_attribute`/`pg_class`). +pub async fn get_materialized_view_columns( + params: &ConnectionParams, + view_name: &str, + schema: &str, +) -> Result, String> { + let pool = get_postgres_pool(params).await?; + let query = r#" + SELECT + a.attname AS column_name, + format_type(a.atttypid, a.atttypmod) AS data_type, + a.attnotnull AS not_null + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind = 'm' + AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum + "#; + + let rows = query_all(&pool, &query, &[&schema, &view_name]).await?; + + Ok(rows + .iter() + .map(|r| TableColumn { + name: r.try_get("column_name").unwrap_or_default(), + data_type: r.try_get("data_type").unwrap_or_default(), + is_pk: false, + is_nullable: !r.try_get::<_, bool>("not_null").unwrap_or(false), + is_auto_increment: false, + default_value: None, + character_maximum_length: None, + }) + .collect()) +} + +pub async fn get_materialized_view_definition( + params: &ConnectionParams, + view_name: &str, + schema: &str, +) -> Result { + let pool = get_postgres_pool(params).await?; + let qualified = format!( + "\"{}\".\"{}\"", + escape_identifier(schema), + escape_identifier(view_name) + ); + + let client = pool.get().await.map_err(|e| e.to_string())?; + + let row = client + .query_one( + "SELECT pg_get_viewdef($1::regclass, true) as definition", + &[&qualified], + ) + .await + .map_err(|e| format!("Failed to get materialized view definition: {}", e))?; + + let definition: String = row.try_get("definition").unwrap_or_default(); + Ok(format!( + "CREATE MATERIALIZED VIEW {} AS\n{}", + qualified, definition + )) +} + +pub async fn refresh_materialized_view( + params: &ConnectionParams, + view_name: &str, + schema: &str, +) -> Result<(), String> { + let pool = get_postgres_pool(params).await?; + let query = format!( + "REFRESH MATERIALIZED VIEW \"{}\".\"{}\"", + escape_identifier(schema), + escape_identifier(view_name) + ); + + let client = pool.get().await.map_err(|e| e.to_string())?; + client + .execute(&query, &[]) + .await + .map_err(|e| format!("Failed to refresh materialized view: {}", e))?; + + Ok(()) +} + pub async fn get_routines( params: &ConnectionParams, schema: &str, @@ -1353,6 +1467,7 @@ impl PostgresDriver { capabilities: DriverCapabilities { schemas: true, views: true, + materialized_views: true, routines: true, file_based: false, folder_based: false, @@ -1545,6 +1660,41 @@ impl DatabaseDriver for PostgresDriver { drop_view(params, view_name, self.resolve_schema(schema)).await } + async fn get_materialized_views( + &self, + params: &crate::models::ConnectionParams, + schema: Option<&str>, + ) -> Result, String> { + get_materialized_views(params, self.resolve_schema(schema)).await + } + + async fn get_materialized_view_columns( + &self, + params: &crate::models::ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result, String> { + get_materialized_view_columns(params, view_name, self.resolve_schema(schema)).await + } + + async fn get_materialized_view_definition( + &self, + params: &crate::models::ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result { + get_materialized_view_definition(params, view_name, self.resolve_schema(schema)).await + } + + async fn refresh_materialized_view( + &self, + params: &crate::models::ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result<(), String> { + refresh_materialized_view(params, view_name, self.resolve_schema(schema)).await + } + async fn get_routines( &self, params: &crate::models::ConnectionParams, diff --git a/src-tauri/src/drivers/sqlite/mod.rs b/src-tauri/src/drivers/sqlite/mod.rs index 2d0d405c..61bb8cc0 100644 --- a/src-tauri/src/drivers/sqlite/mod.rs +++ b/src-tauri/src/drivers/sqlite/mod.rs @@ -878,6 +878,7 @@ impl SqliteDriver { capabilities: DriverCapabilities { schemas: false, views: true, + materialized_views: false, routines: false, file_based: true, folder_based: false, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e42c1003..7a4e8a91 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -318,6 +318,10 @@ pub fn run() { commands::alter_view, commands::drop_view, commands::get_view_columns, + commands::get_materialized_views, + commands::get_materialized_view_columns, + commands::get_materialized_view_definition, + commands::refresh_materialized_view, commands::set_window_title, commands::open_er_diagram_window, explain_import::load_explain_from_file, diff --git a/src/components/layout/ExplorerSidebar.tsx b/src/components/layout/ExplorerSidebar.tsx index 63b29787..0103147f 100644 --- a/src/components/layout/ExplorerSidebar.tsx +++ b/src/components/layout/ExplorerSidebar.tsx @@ -1990,6 +1990,67 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar }, ]; })() + : contextMenu.type === "materialized_view" + ? (() => { + const mvCtxSchema = contextMenu.data && "schema" in contextMenu.data ? contextMenu.data.schema : undefined; + return [ + { + label: t("sidebar.showData"), + icon: PlaySquare, + action: () => { + const quotedView = quoteTableRef(contextMenu.id, activeDriver, mvCtxSchema); + runQuery(`SELECT * FROM ${quotedView}`, undefined, contextMenu.id); + }, + }, + { + label: t("sidebar.countRows"), + icon: Hash, + action: () => { + const quotedView = quoteTableRef(contextMenu.id, activeDriver, mvCtxSchema); + runQuery(`SELECT COUNT(*) as count FROM ${quotedView}`); + }, + }, + { + label: t("sidebar.refreshMaterializedView"), + icon: RefreshCw, + action: async () => { + try { + await invoke("refresh_materialized_view", { + connectionId: activeConnectionId, + viewName: contextMenu.id, + ...(mvCtxSchema ? { schema: mvCtxSchema } : {}), + }); + showAlert(t("views.refreshSuccess", { view: contextMenu.id }), { kind: "info" }); + } catch (e) { + console.error(e); + showAlert(t("views.refreshError") + String(e), { kind: "error" }); + } + }, + }, + { + label: t("sidebar.showDefinition"), + icon: FileText, + action: async () => { + try { + const definition = await invoke("get_materialized_view_definition", { + connectionId: activeConnectionId, + viewName: contextMenu.id, + ...(mvCtxSchema ? { schema: mvCtxSchema } : {}), + }); + runQuery(definition, `${contextMenu.id} Definition`, undefined, true, mvCtxSchema, true); + } catch (e) { + console.error(e); + showAlert(t("views.failGetDefinition") + String(e), { kind: "error" }); + } + }, + }, + { + label: t("sidebar.copyName"), + icon: Copy, + action: () => navigator.clipboard.writeText(contextMenu.id), + }, + ]; + })() : contextMenu.type === "routine" ? [ { diff --git a/src/components/layout/sidebar/SidebarSchemaItem.tsx b/src/components/layout/sidebar/SidebarSchemaItem.tsx index d461ff71..10b1976c 100644 --- a/src/components/layout/sidebar/SidebarSchemaItem.tsx +++ b/src/components/layout/sidebar/SidebarSchemaItem.tsx @@ -92,6 +92,7 @@ export const SidebarSchemaItem = ({ const [prevActiveSchema, setPrevActiveSchema] = useState(activeSchema); const [tablesOpen, setTablesOpen] = useState(true); const [viewsOpen, setViewsOpen] = useState(true); + const [materializedViewsOpen, setMaterializedViewsOpen] = useState(true); const [routinesOpen, setRoutinesOpen] = useState(false); const [triggersOpen, setTriggersOpen] = useState(false); const [functionsOpen, setFunctionsOpen] = useState(true); @@ -112,6 +113,7 @@ export const SidebarSchemaItem = ({ ? tables.filter((t) => t.name.toLowerCase().includes(tableFilter.toLowerCase())) : tables; const views = schemaData?.views ?? []; + const materializedViews = schemaData?.materializedViews ?? []; const routines = schemaData?.routines ?? []; const triggers = schemaData?.triggers ?? []; const filteredTriggers = triggerFilter @@ -305,6 +307,31 @@ export const SidebarSchemaItem = ({ )} + {materializedViews.length > 0 && ( + setMaterializedViewsOpen(!materializedViewsOpen)} + > +
+ {materializedViews.map((view) => ( + onViewDoubleClick(name, schemaName)} + onContextMenu={onContextMenu} + connectionId={connectionId} + driver={driver} + schema={schemaName} + materialized + /> + ))} +
+
+ )} + {/* Triggers */} {showTriggers && ( { const { t } = useTranslation(); + const ViewIcon = materialized ? Layers : Eye; const [isExpanded, setIsExpanded] = useState(false); const [columns, setColumns] = useState([]); + const [indexes, setIndexes] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [expandIndexes, setExpandIndexes] = useState(false); const refreshColumns = React.useCallback(async () => { if (!connectionId) return; setIsLoading(true); try { - const cols = await invoke("get_view_columns", { - connectionId, - viewName: view.name, - ...(schema ? { schema } : {}), - }); + const [cols, idxs] = await Promise.all([ + invoke( + materialized ? "get_materialized_view_columns" : "get_view_columns", + { + connectionId, + viewName: view.name, + ...(schema ? { schema } : {}), + }, + ), + // Materialized views can carry indexes (regular views cannot). + materialized + ? invoke("get_indexes", { + connectionId, + tableName: view.name, + ...(schema ? { schema } : {}), + }) + : Promise.resolve([] as Index[]), + ]); setColumns(cols); + setIndexes(idxs); } catch (err) { console.error("Failed to load view columns:", err); } finally { setIsLoading(false); } - }, [connectionId, view.name, schema]); + }, [connectionId, view.name, schema, materialized]); useEffect(() => { if (isExpanded) { @@ -77,9 +98,19 @@ export const SidebarViewItem = ({ const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - onContextMenu(e, "view", view.name, view.name, { tableName: view.name, schema }); + onContextMenu(e, materialized ? "materialized_view" : "view", view.name, view.name, { tableName: view.name, schema }); }; + // API returns one row per index column; group them by index name. + const groupedIndexes = React.useMemo(() => { + const groups: Record = {}; + indexes.forEach((idx) => { + if (!groups[idx.name]) groups[idx.name] = { ...idx, columns: [] }; + groups[idx.name].columns.push(idx.column_name); + }); + return Object.values(groups); + }, [indexes]); + return (
{isExpanded ? : } - ))}
+ {materialized && ( +
+
{ + e.stopPropagation(); + setExpandIndexes(!expandIndexes); + }} + > + + {t("sidebar.indexes")} + + {groupedIndexes.length} + +
+ {expandIndexes && ( +
+ {groupedIndexes.map((idx) => ( +
+ + + {idx.name}{" "} + + ({idx.columns.join(", ")}) + + + {idx.is_unique && ( + + UNIQUE + + )} +
+ ))} +
+ )} +
+ )}
)} diff --git a/src/contexts/DatabaseContext.ts b/src/contexts/DatabaseContext.ts index c4235b3c..b0c8991e 100644 --- a/src/contexts/DatabaseContext.ts +++ b/src/contexts/DatabaseContext.ts @@ -72,6 +72,7 @@ export interface ConnectionsFile { export interface SchemaData { tables: TableInfo[]; views: ViewInfo[]; + materializedViews?: ViewInfo[]; routines: RoutineInfo[]; triggers: TriggerInfo[]; isLoading: boolean; diff --git a/src/contexts/DatabaseProvider.tsx b/src/contexts/DatabaseProvider.tsx index a6620839..b32135f3 100644 --- a/src/contexts/DatabaseProvider.tsx +++ b/src/contexts/DatabaseProvider.tsx @@ -190,9 +190,10 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { }); try { - const [tablesResult, viewsResult, routinesResult, triggersResult] = await Promise.all([ + const [tablesResult, viewsResult, materializedViewsResult, routinesResult, triggersResult] = await Promise.all([ invoke('get_tables', { connectionId: connId, schema }), invoke('get_views', { connectionId: connId, schema }), + invoke('get_materialized_views', { connectionId: connId, schema }).catch(() => [] as ViewInfo[]), invoke('get_routines', { connectionId: connId, schema }), invoke('get_triggers', { connectionId: connId, schema }).catch(() => [] as TriggerInfo[]), ]); @@ -205,6 +206,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { [schema]: { tables: tablesResult, views: viewsResult, + materializedViews: materializedViewsResult, routines: routinesResult, triggers: triggersResult, isLoading: false, @@ -245,9 +247,10 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { }); try { - const [tablesResult, viewsResult, routinesResult, triggersResult] = await Promise.all([ + const [tablesResult, viewsResult, materializedViewsResult, routinesResult, triggersResult] = await Promise.all([ invoke('get_tables', { connectionId: connId, schema }), invoke('get_views', { connectionId: connId, schema }), + invoke('get_materialized_views', { connectionId: connId, schema }).catch(() => [] as ViewInfo[]), invoke('get_routines', { connectionId: connId, schema }), invoke('get_triggers', { connectionId: connId, schema }).catch(() => [] as TriggerInfo[]), ]); @@ -260,6 +263,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { [schema]: { tables: tablesResult, views: viewsResult, + materializedViews: materializedViewsResult, routines: routinesResult, triggers: triggersResult, isLoading: false, @@ -595,9 +599,10 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { // Ignore - no saved preference exists yet } - const [tablesResult, viewsResult, routinesResult, triggersResult] = await Promise.all([ + const [tablesResult, viewsResult, materializedViewsResult, routinesResult, triggersResult] = await Promise.all([ invoke('get_tables', { connectionId, schema: preferredSchema }), invoke('get_views', { connectionId, schema: preferredSchema }), + invoke('get_materialized_views', { connectionId, schema: preferredSchema }).catch(() => [] as ViewInfo[]), invoke('get_routines', { connectionId, schema: preferredSchema }), invoke('get_triggers', { connectionId, schema: preferredSchema }).catch(() => [] as TriggerInfo[]), ]); @@ -610,6 +615,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { [preferredSchema]: { tables: tablesResult, views: viewsResult, + materializedViews: materializedViewsResult, routines: routinesResult, triggers: triggersResult, isLoading: false, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 47b30cab..da8448be 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -52,6 +52,9 @@ "createView": "Create New View", "views": "Views", "noViews": "No views found", + "materializedViews": "Materialized Views", + "refreshMaterializedView": "Refresh", + "showDefinition": "Show Definition", "editView": "Edit View", "viewDefinition": "View Definition", "dropView": "Drop View", @@ -1196,7 +1199,10 @@ "createSuccess": "View created successfully", "alterSuccess": "View updated successfully", "saveError": "Failed to save view: ", - "confirmAlter": "Are you sure you want to modify view \"{{view}}\"?" + "confirmAlter": "Are you sure you want to modify view \"{{view}}\"?", + "refreshSuccess": "Materialized view \"{{view}}\" refreshed", + "refreshError": "Failed to refresh materialized view: ", + "failGetDefinition": "Failed to get definition: " }, "triggers": { "createTrigger": "Create Trigger", diff --git a/src/types/plugins.ts b/src/types/plugins.ts index ca13ad89..9873a2e1 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -31,6 +31,8 @@ export interface DriverCapabilities { readonly?: boolean; /** Supports listing and managing database triggers. Defaults to false. */ triggers?: boolean; + /** Supports materialized views (e.g. PostgreSQL). Gates the "Materialized Views" tree group. Defaults to false. */ + materialized_views?: boolean; /** Shows the SSL/TLS configuration tab (mode + CA/client cert/key) in the connection modal. * Built-in network drivers (postgres, mysql) set this; plugins opt in via their manifest. Defaults to false. */ supports_ssl?: boolean; diff --git a/tests/components/layout/sidebar/SidebarViewItem.test.tsx b/tests/components/layout/sidebar/SidebarViewItem.test.tsx index a9a90385..125600eb 100644 --- a/tests/components/layout/sidebar/SidebarViewItem.test.tsx +++ b/tests/components/layout/sidebar/SidebarViewItem.test.tsx @@ -168,4 +168,54 @@ describe("SidebarViewItem", () => { consoleSpy.mockRestore(); }); + + describe("when materialized", () => { + const mockMaterializedInvoke = (cmd: string) => { + if (cmd === "get_materialized_view_columns") return Promise.resolve(mockColumns); + if (cmd === "get_indexes") return Promise.resolve([]); + return Promise.reject(new Error(`Unexpected command: ${cmd}`)); + }; + + it("fetches columns via get_materialized_view_columns", async () => { + vi.mocked(invoke).mockImplementation(mockMaterializedInvoke); + + render(); + fireEvent.click(screen.getByRole("button")); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("get_materialized_view_columns", { + connectionId: "conn-123", + viewName: "active_users", + }); + }); + }); + + it("also fetches indexes via get_indexes", async () => { + vi.mocked(invoke).mockImplementation(mockMaterializedInvoke); + + render(); + fireEvent.click(screen.getByRole("button")); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("get_indexes", { + connectionId: "conn-123", + tableName: "active_users", + }); + }); + }); + + it("emits a materialized_view context menu type", () => { + const onContextMenu = vi.fn(); + render(); + + fireEvent.contextMenu(screen.getByText("active_users")); + expect(onContextMenu).toHaveBeenCalledWith( + expect.anything(), + "materialized_view", + "active_users", + "active_users", + expect.objectContaining({ tableName: "active_users" }), + ); + }); + }); });