Skip to main content

dbmcp_postgres/
types.rs

1//! PostgreSQL-specific MCP tool request types.
2//!
3//! Shared `listTriggers` types (`UnpinnedListTriggersRequest`, `PinnedListTriggersRequest`,
4//! `ListTriggersResponse`) and the shared brief/detailed payload (`ListEntries`,
5//! `ListTablesResponse`) live in the `dbmcp-server` crate; they are re-exported
6//! here so call sites 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, ListTriggersResponse,
16    ListViewsResponse, PinnedListTriggersRequest, UnpinnedListTriggersRequest,
17};
18
19/// Request for the `dropTable` tool.
20#[derive(Debug, Default, Deserialize, JsonSchema)]
21#[schemars(rename = "DropTableRequest")]
22pub struct PinnedDropTableRequest {
23    /// Name of the table to drop. Must be non-empty.
24    pub table: String,
25    /// If true, use CASCADE to also drop dependent foreign key constraints. Defaults to false.
26    #[serde(default)]
27    pub cascade: bool,
28}
29
30/// Request for the `dropTable` tool.
31#[derive(Debug, Default, Deserialize, JsonSchema)]
32#[schemars(rename = "DropTableRequest")]
33pub struct UnpinnedDropTableRequest {
34    #[serde(flatten)]
35    pub inner: PinnedDropTableRequest,
36    /// Database containing the table. Defaults to the active database.
37    #[serde(default)]
38    pub database: Option<String>,
39}
40
41/// Request for the Postgres `listTables` tool — extends the shared shape with `search` and `detailed`.
42#[derive(Debug, Default, Deserialize, JsonSchema)]
43#[schemars(rename = "ListTablesRequest")]
44pub struct PinnedListTablesRequest {
45    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
46    #[serde(default)]
47    pub cursor: Option<Cursor>,
48    /// Optional case-insensitive filter on table names. The input is used within an `ILIKE`
49    /// clause: `%` matches any sequence of characters and `_` matches any single character.
50    #[serde(default)]
51    pub search: Option<String>,
52    /// When `true`, each returned entry is a full metadata object (columns,
53    /// constraints, indexes, triggers, owner, comment, kind); when `false` or
54    /// omitted, each entry is the bare table-name string.
55    #[serde(default)]
56    pub detailed: bool,
57}
58
59/// Request for the Postgres `listTables` tool — extends the shared shape with `search` and `detailed`.
60#[derive(Debug, Default, Deserialize, JsonSchema)]
61#[schemars(rename = "ListTablesRequest")]
62pub struct UnpinnedListTablesRequest {
63    #[serde(flatten)]
64    pub inner: PinnedListTablesRequest,
65    /// Database to list tables from. Defaults to the active database.
66    #[serde(default)]
67    pub database: Option<String>,
68}
69
70/// Request for the Postgres `listFunctions` tool — extends the shared shape with `search` and `detailed`.
71#[derive(Debug, Default, Deserialize, JsonSchema)]
72#[schemars(rename = "ListFunctionsRequest")]
73pub struct PinnedListFunctionsRequest {
74    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
75    #[serde(default)]
76    pub cursor: Option<Cursor>,
77    /// Optional case-insensitive filter on function names. The input is used within an `ILIKE`
78    /// clause: `%` matches any sequence of characters and `_` matches any single character.
79    #[serde(default)]
80    pub search: Option<String>,
81    /// When `true`, each returned entry is a full metadata object (schema, name,
82    /// language, arguments, returnType, volatility, strict, security,
83    /// parallelSafety, owner, description, definition); when `false` or omitted,
84    /// each entry is the bare function-name string.
85    #[serde(default)]
86    pub detailed: bool,
87}
88
89/// Request for the Postgres `listFunctions` tool — extends the shared shape with `search` and `detailed`.
90#[derive(Debug, Default, Deserialize, JsonSchema)]
91#[schemars(rename = "ListFunctionsRequest")]
92pub struct UnpinnedListFunctionsRequest {
93    #[serde(flatten)]
94    pub inner: PinnedListFunctionsRequest,
95    /// Database to list functions from. Defaults to the active database.
96    #[serde(default)]
97    pub database: Option<String>,
98}
99
100/// Request for the Postgres `listViews` tool — extends the shared shape with `search` and `detailed`.
101#[derive(Debug, Default, Deserialize, JsonSchema)]
102#[schemars(rename = "ListViewsRequest")]
103pub struct PinnedListViewsRequest {
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 view 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); when `false` or omitted, each entry is the bare
113    /// view-name string.
114    #[serde(default)]
115    pub detailed: bool,
116}
117
118/// Request for the Postgres `listViews` tool — extends the shared shape with `search` and `detailed`.
119#[derive(Debug, Default, Deserialize, JsonSchema)]
120#[schemars(rename = "ListViewsRequest")]
121pub struct UnpinnedListViewsRequest {
122    #[serde(flatten)]
123    pub inner: PinnedListViewsRequest,
124    /// Database to list views from. Defaults to the active database.
125    #[serde(default)]
126    pub database: Option<String>,
127}
128
129/// Request for the Postgres `listMaterializedViews` tool — extends the shared shape with `search` and `detailed`.
130#[derive(Debug, Default, Deserialize, JsonSchema)]
131#[schemars(rename = "ListMaterializedViewsRequest")]
132pub struct PinnedListMaterializedViewsRequest {
133    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
134    #[serde(default)]
135    pub cursor: Option<Cursor>,
136    /// Optional case-insensitive filter on matview names. The input is used within an `ILIKE`
137    /// clause: `%` matches any sequence of characters and `_` matches any single character.
138    #[serde(default)]
139    pub search: Option<String>,
140    /// When `true`, each returned entry is a full metadata object (schema, owner,
141    /// description, definition, populated, indexed); when `false` or omitted, each
142    /// entry is the bare matview-name string.
143    #[serde(default)]
144    pub detailed: bool,
145}
146
147/// Request for the Postgres `listMaterializedViews` tool — extends the shared shape with `search` and `detailed`.
148#[derive(Debug, Default, Deserialize, JsonSchema)]
149#[schemars(rename = "ListMaterializedViewsRequest")]
150pub struct UnpinnedListMaterializedViewsRequest {
151    #[serde(flatten)]
152    pub inner: PinnedListMaterializedViewsRequest,
153    /// Database to list materialized views from. Defaults to the active database.
154    #[serde(default)]
155    pub database: Option<String>,
156}
157
158/// Response for the `listMaterializedViews` tool.
159#[derive(Debug, Serialize, JsonSchema)]
160pub struct ListMaterializedViewsResponse {
161    /// Page of matching materialized views. Shape depends on the request's `detailed` flag.
162    #[serde(rename = "materializedViews")]
163    pub materialized_views: ListEntries,
164    /// Opaque cursor pointing to the next page. Absent when this is the final page.
165    #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
166    pub next_cursor: Option<Cursor>,
167}
168
169impl ListMaterializedViewsResponse {
170    /// Builds a brief-mode response from a page of bare matview names.
171    #[must_use]
172    pub fn brief(materialized_views: Vec<String>, next_cursor: Option<Cursor>) -> Self {
173        Self {
174            materialized_views: ListEntries::Brief(materialized_views),
175            next_cursor,
176        }
177    }
178
179    /// Builds a detailed-mode response from a page of name → metadata entries.
180    #[must_use]
181    pub fn detailed(materialized_views: IndexMap<String, Value>, next_cursor: Option<Cursor>) -> Self {
182        Self {
183            materialized_views: ListEntries::Detailed(materialized_views),
184            next_cursor,
185        }
186    }
187}
188
189/// Request for the Postgres `listProcedures` tool — extends the shared shape with `search` and `detailed`.
190#[derive(Debug, Default, Deserialize, JsonSchema)]
191#[schemars(rename = "ListProceduresRequest")]
192pub struct PinnedListProceduresRequest {
193    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
194    #[serde(default)]
195    pub cursor: Option<Cursor>,
196    /// Optional case-insensitive filter on procedure names. The input is used within an `ILIKE`
197    /// clause: `%` matches any sequence of characters and `_` matches any single character.
198    #[serde(default)]
199    pub search: Option<String>,
200    /// When `true`, each returned entry is a full metadata object (schema, name,
201    /// language, arguments, security, owner, description, definition); when `false`
202    /// or omitted, each entry is the bare procedure-name string.
203    #[serde(default)]
204    pub detailed: bool,
205}
206
207/// Request for the Postgres `listProcedures` tool — extends the shared shape with `search` and `detailed`.
208#[derive(Debug, Default, Deserialize, JsonSchema)]
209#[schemars(rename = "ListProceduresRequest")]
210pub struct UnpinnedListProceduresRequest {
211    #[serde(flatten)]
212    pub inner: PinnedListProceduresRequest,
213    /// Database to list procedures from. Defaults to the active database.
214    #[serde(default)]
215    pub database: Option<String>,
216}
217
218#[cfg(test)]
219mod tests {
220    use indexmap::IndexMap;
221    use serde_json::json;
222
223    use super::{
224        ListEntries, ListMaterializedViewsResponse, PinnedListFunctionsRequest, PinnedListMaterializedViewsRequest,
225        PinnedListProceduresRequest, PinnedListTablesRequest, PinnedListViewsRequest, UnpinnedListFunctionsRequest,
226        UnpinnedListMaterializedViewsRequest, UnpinnedListProceduresRequest, UnpinnedListTablesRequest,
227        UnpinnedListViewsRequest,
228    };
229
230    #[test]
231    fn unpinned_list_tables_request_defaults_to_brief_mode_without_search() {
232        let req: PinnedListTablesRequest = serde_json::from_str("{}").expect("empty object should parse");
233        assert!(req.search.is_none());
234        assert!(!req.detailed, "detailed must default to false");
235    }
236
237    #[test]
238    fn unpinned_list_tables_request_accepts_search_and_detailed() {
239        let req: PinnedListTablesRequest =
240            serde_json::from_str(r#"{"search": "order", "detailed": true}"#).expect("parse");
241        assert_eq!(req.search.as_deref(), Some("order"));
242        assert!(req.detailed);
243    }
244
245    #[test]
246    fn pinned_list_tables_request_accepts_database_and_inner_fields() {
247        let req: UnpinnedListTablesRequest =
248            serde_json::from_str(r#"{"database": "mydb", "search": "order"}"#).expect("parse");
249        assert_eq!(req.database.as_deref(), Some("mydb"));
250        assert_eq!(req.inner.search.as_deref(), Some("order"));
251    }
252
253    #[test]
254    fn unpinned_list_functions_request_defaults_to_brief_mode_without_search() {
255        let req: PinnedListFunctionsRequest = serde_json::from_str("{}").expect("empty object should parse");
256        assert!(req.search.is_none());
257        assert!(!req.detailed, "detailed must default to false");
258    }
259
260    #[test]
261    fn unpinned_list_functions_request_accepts_search_and_detailed() {
262        let req: PinnedListFunctionsRequest =
263            serde_json::from_str(r#"{"search": "order", "detailed": true}"#).expect("parse");
264        assert_eq!(req.search.as_deref(), Some("order"));
265        assert!(req.detailed);
266    }
267
268    #[test]
269    fn pinned_list_functions_request_accepts_database() {
270        let req: UnpinnedListFunctionsRequest =
271            serde_json::from_str(r#"{"database": "mydb", "search": "calc"}"#).expect("parse");
272        assert_eq!(req.database.as_deref(), Some("mydb"));
273        assert_eq!(req.inner.search.as_deref(), Some("calc"));
274    }
275
276    #[test]
277    fn unpinned_list_procedures_request_defaults_to_brief_mode_without_search() {
278        let req: PinnedListProceduresRequest = serde_json::from_str("{}").expect("empty object should parse");
279        assert!(req.search.is_none());
280        assert!(!req.detailed, "detailed must default to false");
281    }
282
283    #[test]
284    fn unpinned_list_procedures_request_accepts_search_and_detailed() {
285        let req: PinnedListProceduresRequest =
286            serde_json::from_str(r#"{"search": "archive", "detailed": true}"#).expect("parse");
287        assert_eq!(req.search.as_deref(), Some("archive"));
288        assert!(req.detailed);
289    }
290
291    #[test]
292    fn pinned_list_procedures_request_accepts_database() {
293        let req: UnpinnedListProceduresRequest = serde_json::from_str(r#"{"database": "mydb"}"#).expect("parse");
294        assert_eq!(req.database.as_deref(), Some("mydb"));
295    }
296
297    #[test]
298    fn unpinned_list_views_request_defaults_to_brief_mode_without_search() {
299        let req: PinnedListViewsRequest = serde_json::from_str("{}").expect("empty object should parse");
300        assert!(req.search.is_none());
301        assert!(!req.detailed, "detailed must default to false");
302    }
303
304    #[test]
305    fn unpinned_list_views_request_accepts_search_and_detailed() {
306        let req: PinnedListViewsRequest =
307            serde_json::from_str(r#"{"search": "active", "detailed": true}"#).expect("parse");
308        assert_eq!(req.search.as_deref(), Some("active"));
309        assert!(req.detailed);
310    }
311
312    #[test]
313    fn pinned_list_views_request_accepts_database() {
314        let req: UnpinnedListViewsRequest = serde_json::from_str(r#"{"database": "mydb"}"#).expect("parse");
315        assert_eq!(req.database.as_deref(), Some("mydb"));
316    }
317
318    #[test]
319    fn unpinned_list_materialized_views_request_defaults_to_brief_mode_without_search() {
320        let req: PinnedListMaterializedViewsRequest = serde_json::from_str("{}").expect("empty object should parse");
321        assert!(req.search.is_none());
322        assert!(!req.detailed, "detailed must default to false");
323    }
324
325    #[test]
326    fn unpinned_list_materialized_views_request_accepts_search_and_detailed() {
327        let req: PinnedListMaterializedViewsRequest =
328            serde_json::from_str(r#"{"search": "orders", "detailed": true}"#).expect("parse");
329        assert_eq!(req.search.as_deref(), Some("orders"));
330        assert!(req.detailed);
331    }
332
333    #[test]
334    fn pinned_list_materialized_views_request_accepts_database() {
335        let req: UnpinnedListMaterializedViewsRequest = serde_json::from_str(r#"{"database": "mydb"}"#).expect("parse");
336        assert_eq!(req.database.as_deref(), Some("mydb"));
337    }
338
339    #[test]
340    fn list_materialized_views_response_brief_constructor_wraps_vec() {
341        let response = ListMaterializedViewsResponse::brief(vec!["mv_recent_orders".into()], None);
342        assert!(matches!(response.materialized_views, ListEntries::Brief(ref v) if v == &["mv_recent_orders"]));
343        assert!(response.next_cursor.is_none());
344    }
345
346    #[test]
347    fn list_materialized_views_response_detailed_constructor_wraps_indexmap() {
348        let map = IndexMap::from([("mv_recent_orders".into(), json!({"populated": true}))]);
349        let response = ListMaterializedViewsResponse::detailed(map, None);
350        assert!(matches!(response.materialized_views, ListEntries::Detailed(_)));
351    }
352
353    #[test]
354    fn list_materialized_views_response_brief_matches_legacy_wire_shape() {
355        let response = ListMaterializedViewsResponse::brief(vec!["mv_recent_orders".into()], None);
356        assert_eq!(
357            serde_json::to_value(&response).unwrap(),
358            json!({"materializedViews": ["mv_recent_orders"]})
359        );
360    }
361}