Skip to main content

dbmcp_postgres/tools/
list_materialized_views.rs

1//! MCP tool: `listMaterializedViews`.
2
3use dbmcp_server::pagination::{Cursor, Pager};
4
5use super::prelude::*;
6use crate::types::{ListEntriesResponse, ListMaterializedViewsRequest};
7
8const NAME: &str = "listMaterializedViews";
9const TITLE: &str = "List Materialized Views";
10const DESCRIPTION_PINNED: &str = include_str!("../../assets/tools/list_materialized_views/pinned.md");
11const DESCRIPTION_UNPINNED: &str = include_str!("../../assets/tools/list_materialized_views/unpinned.md");
12
13fn annotations() -> ToolAnnotations {
14    ToolAnnotations::new()
15        .read_only(true)
16        .destructive(false)
17        .idempotent(true)
18        .open_world(false)
19}
20
21/// Marker type for the `listMaterializedViews` MCP tool (pinned variant — no `database` field).
22pub(crate) struct PinnedListMaterializedViewsTool;
23
24impl ToolBase for PinnedListMaterializedViewsTool {
25    type Parameter = ListMaterializedViewsRequest;
26    type Output = ListEntriesResponse;
27    type Error = ErrorData;
28
29    fn name() -> Cow<'static, str> {
30        NAME.into()
31    }
32
33    fn title() -> Option<String> {
34        Some(TITLE.into())
35    }
36
37    fn description() -> Option<Cow<'static, str>> {
38        Some(DESCRIPTION_PINNED.into())
39    }
40
41    fn annotations() -> Option<ToolAnnotations> {
42        Some(annotations())
43    }
44
45    fn input_schema() -> Option<Arc<JsonObject>> {
46        Some(input_schema::<Self::Parameter>(true))
47    }
48
49    fn output_schema() -> Option<Arc<JsonObject>> {
50        Some(output_schema::<Self::Output>())
51    }
52}
53
54impl AsyncTool<PostgresHandler> for PinnedListMaterializedViewsTool {
55    async fn invoke(handler: &PostgresHandler, params: Self::Parameter) -> Result<Self::Output, Self::Error> {
56        handler
57            .list_materialized_views(None, params.cursor, params.search, params.detailed)
58            .await
59    }
60}
61
62/// Marker type for the `listMaterializedViews` MCP tool (unpinned variant — carries `database`).
63pub(crate) struct UnpinnedListMaterializedViewsTool;
64
65impl ToolBase for UnpinnedListMaterializedViewsTool {
66    type Parameter = ListMaterializedViewsRequest;
67    type Output = ListEntriesResponse;
68    type Error = ErrorData;
69
70    fn name() -> Cow<'static, str> {
71        NAME.into()
72    }
73
74    fn title() -> Option<String> {
75        Some(TITLE.into())
76    }
77
78    fn description() -> Option<Cow<'static, str>> {
79        Some(DESCRIPTION_UNPINNED.into())
80    }
81
82    fn annotations() -> Option<ToolAnnotations> {
83        Some(annotations())
84    }
85
86    fn input_schema() -> Option<Arc<JsonObject>> {
87        Some(input_schema::<Self::Parameter>(false))
88    }
89
90    fn output_schema() -> Option<Arc<JsonObject>> {
91        Some(output_schema::<Self::Output>())
92    }
93}
94
95impl AsyncTool<PostgresHandler> for UnpinnedListMaterializedViewsTool {
96    async fn invoke(handler: &PostgresHandler, params: Self::Parameter) -> Result<Self::Output, Self::Error> {
97        handler
98            .list_materialized_views(params.database, params.cursor, params.search, params.detailed)
99            .await
100    }
101}
102
103/// Brief-mode SQL: `pg_matviews` scan with `ILIKE` filter on matview name.
104///
105/// `pg_matviews` already filters to `pg_class.relkind = 'm'`, and
106/// `schemaname = 'public'` keeps system-schema matviews out. The
107/// `($1::text IS NULL OR ...)` trinary lets one statement cover both filtered
108/// and unfiltered cases. Matview names are unique per schema (Postgres enforces
109/// this via `pg_class`'s unique index on `(relname, relnamespace)`), so
110/// `matviewname` alone is a stable sort key — no tiebreaker needed.
111const BRIEF_SQL: &str = r"
112    SELECT matviewname
113    FROM pg_matviews
114    WHERE schemaname = 'public'
115      AND ($1::text IS NULL OR matviewname ILIKE '%' || $1 || '%')
116    ORDER BY matviewname
117    LIMIT $2 OFFSET $3";
118
119/// Detailed-mode SQL: per-matview `json_build_object` projection.
120///
121/// `pg_matviews` already filters to `relkind = 'm'`. The `pg_namespace` +
122/// `pg_class` joins anchor the relation OID needed by `obj_description`.
123/// Postgres defers SELECT-list evaluation past `LIMIT`, so `obj_description`
124/// only runs for the page's rows — never the full schema.
125/// `pg_matviews.matviewowner` is already a role name, so no `pg_roles` join is
126/// needed. `populated` and `indexed` are projected directly from
127/// `pg_matviews.ispopulated` / `pg_matviews.hasindexes`. Matview names are
128/// unique per schema, so `matviewname` alone is a stable sort key.
129const DETAILED_SQL: &str = r"
130    SELECT
131        mv.matviewname AS name,
132        json_build_object(
133            'schema',      mv.schemaname,
134            'owner',       mv.matviewowner,
135            'description', pg_catalog.obj_description(c.oid, 'pg_class'),
136            'definition',  mv.definition,
137            'populated',   mv.ispopulated,
138            'indexed',     mv.hasindexes
139        ) AS entry
140    FROM pg_matviews mv
141    JOIN pg_namespace n ON n.nspname = mv.schemaname
142    JOIN pg_class     c ON c.relname = mv.matviewname AND c.relnamespace = n.oid
143    WHERE mv.schemaname = 'public'
144      AND ($1::text IS NULL OR mv.matviewname ILIKE '%' || $1 || '%')
145    ORDER BY mv.matviewname
146    LIMIT $2 OFFSET $3";
147
148impl PostgresHandler {
149    /// Lists one page of user-defined materialized views, optionally filtered and/or detailed.
150    ///
151    /// # Errors
152    ///
153    /// Returns an internal-error [`ErrorData`] if `database` is invalid
154    /// or the underlying query fails.
155    pub async fn list_materialized_views(
156        &self,
157        database: Option<String>,
158        cursor: Option<Cursor>,
159        search: Option<String>,
160        detailed: bool,
161    ) -> Result<ListEntriesResponse, ErrorData> {
162        let database = database.as_deref().map(str::trim).filter(|s| !s.is_empty());
163        let pattern = search.as_deref().map(str::trim).filter(|s| !s.is_empty());
164        let pager = Pager::new(cursor, self.config.page_size);
165
166        if detailed {
167            let rows: Vec<(String, sqlx::types::Json<serde_json::Value>)> = self
168                .connection
169                .fetch(
170                    sqlx::query(DETAILED_SQL)
171                        .bind(pattern)
172                        .bind(pager.limit())
173                        .bind(pager.offset()),
174                    database,
175                )
176                .await?;
177            let (rows, next_cursor) = pager.paginate(rows);
178            return Ok(ListEntriesResponse::detailed(
179                rows.into_iter().map(|(key, json)| (key, json.0)).collect(),
180                next_cursor,
181            ));
182        }
183
184        let rows: Vec<String> = self
185            .connection
186            .fetch_scalar(
187                sqlx::query(BRIEF_SQL)
188                    .bind(pattern)
189                    .bind(pager.limit())
190                    .bind(pager.offset()),
191                database,
192            )
193            .await?;
194        let (materialized_views, next_cursor) = pager.paginate(rows);
195        Ok(ListEntriesResponse::brief(materialized_views, next_cursor))
196    }
197}