Skip to main content

dbmcp_postgres/
types.rs

1//! PostgreSQL-specific MCP tool request types.
2//!
3//! Shared `listTriggers` types (`ListTriggersRequest`, `ListTriggersResponse`)
4//! and the shared brief/detailed payload (`ListEntries`, `ListTablesResponse`)
5//! live in the `dbmcp-server` crate; they are re-exported here so call sites
6//! can keep importing them from `crate::types`.
7
8use dbmcp_server::pagination::Cursor;
9use indexmap::IndexMap;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14pub use dbmcp_server::types::{
15    ListEntries, ListFunctionsResponse, ListProceduresResponse, ListTablesResponse, ListTriggersRequest,
16    ListTriggersResponse, ListViewsResponse,
17};
18
19/// Request for the `dropTable` tool.
20#[derive(Debug, Default, Deserialize, JsonSchema)]
21#[serde(rename_all = "camelCase")]
22pub struct DropTableRequest {
23    /// Database containing the table. Defaults to the active database.
24    #[serde(default)]
25    pub database: Option<String>,
26    /// Name of the table to drop. Must be non-empty.
27    pub table: String,
28    /// If true, use CASCADE to also drop dependent foreign key constraints. Defaults to false.
29    #[serde(default)]
30    pub cascade: bool,
31}
32
33/// Request for the Postgres `listTables` tool — extends the shared shape with `search` and `detailed`.
34#[derive(Debug, Default, Deserialize, JsonSchema)]
35#[serde(rename_all = "camelCase")]
36pub struct ListTablesRequest {
37    /// Database to list tables from. Defaults to the active database.
38    #[serde(default)]
39    pub database: Option<String>,
40    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
41    #[serde(default)]
42    pub cursor: Option<Cursor>,
43    /// Optional case-insensitive filter on table names. The input is used within an `ILIKE`
44    /// clause: `%` matches any sequence of characters and `_` matches any single character.
45    #[serde(default)]
46    pub search: Option<String>,
47    /// When `true`, each returned entry is a full metadata object (columns,
48    /// constraints, indexes, triggers, owner, comment, kind); when `false` or
49    /// omitted, each entry is the bare table-name string.
50    #[serde(default)]
51    pub detailed: bool,
52}
53
54/// Request for the Postgres `listFunctions` tool — extends the shared shape with `search` and `detailed`.
55#[derive(Debug, Default, Deserialize, JsonSchema)]
56#[serde(rename_all = "camelCase")]
57pub struct ListFunctionsRequest {
58    /// Database to list functions from. Defaults to the active database.
59    #[serde(default)]
60    pub database: Option<String>,
61    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
62    #[serde(default)]
63    pub cursor: Option<Cursor>,
64    /// Optional case-insensitive filter on function names. The input is used within an `ILIKE`
65    /// clause: `%` matches any sequence of characters and `_` matches any single character.
66    #[serde(default)]
67    pub search: Option<String>,
68    /// When `true`, each returned entry is a full metadata object (schema, name,
69    /// language, arguments, returnType, volatility, strict, security,
70    /// parallelSafety, owner, description, definition); when `false` or omitted,
71    /// each entry is the bare function-name string.
72    #[serde(default)]
73    pub detailed: bool,
74}
75
76/// Request for the Postgres `listViews` tool — extends the shared shape with `search` and `detailed`.
77#[derive(Debug, Default, Deserialize, JsonSchema)]
78#[serde(rename_all = "camelCase")]
79pub struct ListViewsRequest {
80    /// Database to list views from. Defaults to the active database.
81    #[serde(default)]
82    pub database: Option<String>,
83    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
84    #[serde(default)]
85    pub cursor: Option<Cursor>,
86    /// Optional case-insensitive filter on view names. The input is used within an `ILIKE`
87    /// clause: `%` matches any sequence of characters and `_` matches any single character.
88    #[serde(default)]
89    pub search: Option<String>,
90    /// When `true`, each returned entry is a full metadata object (schema, owner,
91    /// description, definition); when `false` or omitted, each entry is the bare
92    /// view-name string.
93    #[serde(default)]
94    pub detailed: bool,
95}
96
97/// Request for the Postgres `listMaterializedViews` tool — extends the shared shape with `search` and `detailed`.
98#[derive(Debug, Default, Deserialize, JsonSchema)]
99#[serde(rename_all = "camelCase")]
100pub struct ListMaterializedViewsRequest {
101    /// Database to list materialized views from. Defaults to the active database.
102    #[serde(default)]
103    pub database: Option<String>,
104    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
105    #[serde(default)]
106    pub cursor: Option<Cursor>,
107    /// Optional case-insensitive filter on matview names. The input is used within an `ILIKE`
108    /// clause: `%` matches any sequence of characters and `_` matches any single character.
109    #[serde(default)]
110    pub search: Option<String>,
111    /// When `true`, each returned entry is a full metadata object (schema, owner,
112    /// description, definition, populated, indexed); when `false` or omitted, each
113    /// entry is the bare matview-name string.
114    #[serde(default)]
115    pub detailed: bool,
116}
117
118/// Response for the `listMaterializedViews` tool.
119#[derive(Debug, Serialize, JsonSchema)]
120#[serde(rename_all = "camelCase")]
121pub struct ListMaterializedViewsResponse {
122    /// Page of matching materialized views. Shape depends on the request's `detailed` flag.
123    pub materialized_views: ListEntries,
124    /// Opaque cursor pointing to the next page. Absent when this is the final page.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub next_cursor: Option<Cursor>,
127}
128
129impl ListMaterializedViewsResponse {
130    /// Builds a brief-mode response from a page of bare matview names.
131    #[must_use]
132    pub fn brief(materialized_views: Vec<String>, next_cursor: Option<Cursor>) -> Self {
133        Self {
134            materialized_views: ListEntries::Brief(materialized_views),
135            next_cursor,
136        }
137    }
138
139    /// Builds a detailed-mode response from a page of name → metadata entries.
140    #[must_use]
141    pub fn detailed(materialized_views: IndexMap<String, Value>, next_cursor: Option<Cursor>) -> Self {
142        Self {
143            materialized_views: ListEntries::Detailed(materialized_views),
144            next_cursor,
145        }
146    }
147}
148
149/// Request for the Postgres `listProcedures` tool — extends the shared shape with `search` and `detailed`.
150#[derive(Debug, Default, Deserialize, JsonSchema)]
151#[serde(rename_all = "camelCase")]
152pub struct ListProceduresRequest {
153    /// Database to list procedures from. Defaults to the active database.
154    #[serde(default)]
155    pub database: Option<String>,
156    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
157    #[serde(default)]
158    pub cursor: Option<Cursor>,
159    /// Optional case-insensitive filter on procedure names. The input is used within an `ILIKE`
160    /// clause: `%` matches any sequence of characters and `_` matches any single character.
161    #[serde(default)]
162    pub search: Option<String>,
163    /// When `true`, each returned entry is a full metadata object (schema, name,
164    /// language, arguments, security, owner, description, definition); when `false`
165    /// or omitted, each entry is the bare procedure-name string.
166    #[serde(default)]
167    pub detailed: bool,
168}
169
170#[cfg(test)]
171mod tests {
172    use indexmap::IndexMap;
173    use serde_json::json;
174
175    use super::{
176        ListEntries, ListFunctionsRequest, ListMaterializedViewsRequest, ListMaterializedViewsResponse,
177        ListProceduresRequest, ListTablesRequest, ListViewsRequest,
178    };
179
180    #[test]
181    fn list_tables_request_defaults_to_brief_mode_without_search() {
182        let req: ListTablesRequest = serde_json::from_str("{}").expect("empty object should parse");
183        assert!(req.search.is_none());
184        assert!(!req.detailed, "detailed must default to false");
185    }
186
187    #[test]
188    fn list_tables_request_accepts_search_and_detailed() {
189        let req: ListTablesRequest = serde_json::from_str(r#"{"search": "order", "detailed": true}"#).expect("parse");
190        assert_eq!(req.search.as_deref(), Some("order"));
191        assert!(req.detailed);
192    }
193
194    #[test]
195    fn list_functions_request_defaults_to_brief_mode_without_search() {
196        let req: ListFunctionsRequest = serde_json::from_str("{}").expect("empty object should parse");
197        assert!(req.search.is_none());
198        assert!(!req.detailed, "detailed must default to false");
199    }
200
201    #[test]
202    fn list_functions_request_accepts_search_and_detailed() {
203        let req: ListFunctionsRequest =
204            serde_json::from_str(r#"{"search": "order", "detailed": true}"#).expect("parse");
205        assert_eq!(req.search.as_deref(), Some("order"));
206        assert!(req.detailed);
207    }
208
209    #[test]
210    fn list_procedures_request_defaults_to_brief_mode_without_search() {
211        let req: ListProceduresRequest = serde_json::from_str("{}").expect("empty object should parse");
212        assert!(req.search.is_none());
213        assert!(!req.detailed, "detailed must default to false");
214    }
215
216    #[test]
217    fn list_procedures_request_accepts_search_and_detailed() {
218        let req: ListProceduresRequest =
219            serde_json::from_str(r#"{"search": "archive", "detailed": true}"#).expect("parse");
220        assert_eq!(req.search.as_deref(), Some("archive"));
221        assert!(req.detailed);
222    }
223
224    #[test]
225    fn list_views_request_defaults_to_brief_mode_without_search() {
226        let req: ListViewsRequest = serde_json::from_str("{}").expect("empty object should parse");
227        assert!(req.search.is_none());
228        assert!(!req.detailed, "detailed must default to false");
229    }
230
231    #[test]
232    fn list_views_request_accepts_search_and_detailed() {
233        let req: ListViewsRequest = serde_json::from_str(r#"{"search": "active", "detailed": true}"#).expect("parse");
234        assert_eq!(req.search.as_deref(), Some("active"));
235        assert!(req.detailed);
236    }
237
238    #[test]
239    fn list_materialized_views_request_defaults_to_brief_mode_without_search() {
240        let req: ListMaterializedViewsRequest = serde_json::from_str("{}").expect("empty object should parse");
241        assert!(req.search.is_none());
242        assert!(!req.detailed, "detailed must default to false");
243    }
244
245    #[test]
246    fn list_materialized_views_request_accepts_search_and_detailed() {
247        let req: ListMaterializedViewsRequest =
248            serde_json::from_str(r#"{"search": "orders", "detailed": true}"#).expect("parse");
249        assert_eq!(req.search.as_deref(), Some("orders"));
250        assert!(req.detailed);
251    }
252
253    #[test]
254    fn list_materialized_views_response_brief_constructor_wraps_vec() {
255        let response = ListMaterializedViewsResponse::brief(vec!["mv_recent_orders".into()], None);
256        assert!(matches!(response.materialized_views, ListEntries::Brief(ref v) if v == &["mv_recent_orders"]));
257        assert!(response.next_cursor.is_none());
258    }
259
260    #[test]
261    fn list_materialized_views_response_detailed_constructor_wraps_indexmap() {
262        let map = IndexMap::from([("mv_recent_orders".into(), json!({"populated": true}))]);
263        let response = ListMaterializedViewsResponse::detailed(map, None);
264        assert!(matches!(response.materialized_views, ListEntries::Detailed(_)));
265    }
266
267    #[test]
268    fn list_materialized_views_response_brief_matches_legacy_wire_shape() {
269        let response = ListMaterializedViewsResponse::brief(vec!["mv_recent_orders".into()], None);
270        assert_eq!(
271            serde_json::to_value(&response).unwrap(),
272            json!({"materializedViews": ["mv_recent_orders"]})
273        );
274    }
275}