Skip to main content

pmcp_server_toolkit/sql/
mod.rs

1// Net-new code for Phase 83 TKIT-10 (trait stub) + Phase 84 (full trait + per-backend impls).
2// Phase 83 INTENTIONALLY MINIMIZED the trait surface to dialect() + schema_text()
3// (per review R2, BOTH reviewers HIGH severity) so execute() + placeholder
4// translation could be validated against real backends first. Phase 84 (CONN-01)
5// now ships the full 3-method trait surface validated against Postgres / MySQL /
6// Athena / SQLite.
7
8//! SQL connector trait (3-method surface) + dialect enum.
9//!
10//! Phase 83 shipped a minimized 2-method MVP (`dialect()` + `schema_text()`) to
11//! AVOID OSSIFYING the public API before any real connector validated the
12//! contract. Phase 84 (CONN-01) ships the full 3-method trait surface, adding
13//! [`SqlConnector::execute`] now that the per-backend connectors validate the
14//! shape:
15//! - `execute(sql, &[(String, Value)]) -> Result<Vec<Value>, ConnectorError>`
16//!   returns one JSON object per row — the exact shape MCP transport needs at
17//!   the `tools/call` → `structuredContent` boundary (D-01).
18//! - Parameters are a slice of named `(name, value)` pairs so the caller
19//!   controls bind order and they round-trip cleanly through `serde_json` (D-03).
20//!
21//! Streaming and transactions remain deferred to a future semver-additive
22//! release (D-02) — see [`SqlConnector`] for the evolution plan.
23//!
24//! Phase 83's TKIT-10 prompt assembly calls [`SqlConnector::schema_text`] only,
25//! never `execute()`, so the additional method does not change that surface.
26
27// Why: dialect display names ("PostgreSQL", "MySQL") are proper nouns that
28// clippy::doc_markdown otherwise flags as needing back-ticks.
29#![allow(clippy::doc_markdown)]
30
31use async_trait::async_trait;
32use thiserror::Error;
33
34/// Dialect-aware placeholder translation (CONN-03).
35///
36/// Public surface lives at `pmcp_server_toolkit::sql::translate_placeholders`
37/// per D-05 — a free helper, not a trait method.
38pub mod translate;
39pub use translate::{translate_placeholders, TranslatedSql};
40
41/// First-class SQLite connector (CONN-08), gated behind the `sqlite` feature.
42///
43/// Ships `SqliteConnector` — a real `rusqlite`-backed [`SqlConnector`] impl —
44/// alongside the test-only `pub(crate) MockSqlConnector` fixture (Open Question
45/// #3): the two coexist, the mock is NOT removed.
46#[cfg(feature = "sqlite")]
47pub mod sqlite;
48#[cfg(feature = "sqlite")]
49pub use sqlite::SqliteConnector;
50
51/// Three-method SQL connector trait — Phase 84 ships the full trait surface.
52///
53/// Phase 83 shipped a 2-method MVP (`dialect()` + `schema_text()`); Phase 84
54/// (CONN-01) lands `execute()` between them now that the per-backend connectors
55/// validate the row/error/parameter shape. The trait is the stable contract the
56/// per-backend crates (`pmcp-toolkit-postgres`, `pmcp-toolkit-mysql`,
57/// `pmcp-toolkit-athena`, plus the `sqlite` feature `SqliteConnector`) implement.
58///
59/// # Semver-evolution plan
60///
61/// This trait WILL grow additively in a future minor release with:
62/// - `execute_stream(sql, params) -> impl Stream<Item = Result<Value>>`, shipped
63///   with a default body backed by `execute(...).map(stream::iter)` so it is
64///   semver-compatible on a `Send + Sync + 'static` trait — for the
65///   large-result-scan case (e.g. an Athena warehousing tool). Deferred per D-02
66///   because no v2.2 reference scenario needs it.
67/// - Transaction support as a separate `SqlTransactional` trait extension, when
68///   a real consumer needs it. Deferred per D-02 — the v2.2 reference scenarios
69///   are read-only and Athena has no real transaction model.
70///
71/// The variants on [`Dialect`] and [`ConnectorError`] are `#[non_exhaustive]`
72/// so they can be extended additively without a semver break.
73///
74/// # Example
75///
76/// A minimal connector implementing all three methods. The example defines a
77/// LOCAL dummy struct — it deliberately does NOT reference any downstream
78/// per-backend crate, because those depend on `pmcp-server-toolkit` and would
79/// create a circular doctest dependency (REVIEWS H6).
80///
81/// ```no_run
82/// use pmcp_server_toolkit::sql::{SqlConnector, Dialect, ConnectorError};
83/// use async_trait::async_trait;
84/// use serde_json::Value;
85///
86/// struct Dummy;
87///
88/// #[async_trait]
89/// impl SqlConnector for Dummy {
90///     fn dialect(&self) -> Dialect { Dialect::Sqlite }
91///     async fn execute(&self, _sql: &str, _params: &[(String, Value)])
92///         -> Result<Vec<Value>, ConnectorError> {
93///         Ok(vec![])
94///     }
95///     async fn schema_text(&self) -> Result<String, ConnectorError> {
96///         Ok(String::new())
97///     }
98/// }
99/// ```
100#[async_trait]
101pub trait SqlConnector: Send + Sync + 'static {
102    /// Identify the dialect for prompt assembly + placeholder translation.
103    fn dialect(&self) -> Dialect;
104
105    /// Execute a query and return one [`serde_json::Value`] per result row.
106    ///
107    /// `sql` is the canonical statement (placeholders in the toolkit's `:name`
108    /// form); `params` is a slice of named `(name, value)` pairs the caller
109    /// controls the order of (D-03). Per-backend impls translate placeholders
110    /// to their dialect via [`translate_placeholders`] and bind from `params`,
111    /// then convert driver-native rows into JSON objects (D-01).
112    ///
113    /// Each returned `Value` is typically a JSON object keyed by column name —
114    /// the exact shape MCP transport needs to populate the `tools/call`
115    /// response's `structuredContent` field (D-06).
116    ///
117    /// # Errors
118    ///
119    /// Returns a [`ConnectorError`] when the backend cannot connect
120    /// ([`ConnectorError::Connection`]), the driver fails
121    /// ([`ConnectorError::Driver`]), the query is rejected
122    /// ([`ConnectorError::Query`]), or a parameter cannot be bound
123    /// ([`ConnectorError::ParameterBind`]).
124    async fn execute(
125        &self,
126        sql: &str,
127        params: &[(String, serde_json::Value)],
128    ) -> Result<Vec<serde_json::Value>, ConnectorError>;
129
130    /// Render the backend's schema as DDL or equivalent text for inclusion in
131    /// the code-mode prompt. Phase 84 impls drive this from `information_schema`,
132    /// the Glue catalog, or `sqlite_master` per dialect.
133    ///
134    /// Implementations should keep output BOUNDED — token-budget the schema
135    /// before returning. The toolkit does not truncate (T-83-07-03).
136    ///
137    /// # Errors
138    ///
139    /// Returns a [`ConnectorError`] when the backend cannot enumerate its
140    /// schema (I/O failure, permission denied, missing catalog, etc.).
141    async fn schema_text(&self) -> Result<String, ConnectorError>;
142}
143
144/// Supported SQL dialects (4-variant per spike 005).
145///
146/// `#[non_exhaustive]` permits additive evolution to `Oracle` / `SqlServer` /
147/// `DuckDb` / `ClickHouse` in later phases without semver break.
148///
149/// # Example
150///
151/// ```
152/// use pmcp_server_toolkit::sql::Dialect;
153///
154/// assert_eq!(Dialect::Postgres.name(), "PostgreSQL");
155/// assert!(Dialect::Sqlite.placeholder_guidance().contains(":name"));
156/// ```
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
158#[non_exhaustive]
159pub enum Dialect {
160    /// PostgreSQL — positional `$1`, `$2`, ... placeholders.
161    Postgres,
162    /// MySQL — positional `?` placeholders.
163    MySql,
164    /// Amazon Athena (Presto/Trino) — positional `?` placeholders.
165    Athena,
166    /// SQLite — named `:name` or positional `?` placeholders.
167    Sqlite,
168}
169
170impl Dialect {
171    /// Stable, human-readable name for prompts and logs.
172    ///
173    /// # Example
174    ///
175    /// ```
176    /// use pmcp_server_toolkit::sql::Dialect;
177    /// assert_eq!(Dialect::MySql.name(), "MySQL");
178    /// ```
179    #[must_use]
180    pub const fn name(self) -> &'static str {
181        match self {
182            Self::Postgres => "PostgreSQL",
183            Self::MySql => "MySQL",
184            Self::Athena => "Amazon Athena (Presto/Trino)",
185            Self::Sqlite => "SQLite",
186        }
187    }
188
189    /// One-line guidance string for the code-mode prompt body explaining the
190    /// dialect's placeholder convention. Used by `assemble_code_mode_prompt`
191    /// even though Phase 83 does not ship `translate_placeholders` — the LLM
192    /// still benefits from knowing the eventual binding shape.
193    ///
194    /// # Example
195    ///
196    /// ```
197    /// use pmcp_server_toolkit::sql::Dialect;
198    /// assert!(Dialect::Postgres.placeholder_guidance().contains("$1"));
199    /// ```
200    #[must_use]
201    pub const fn placeholder_guidance(self) -> &'static str {
202        match self {
203            Self::Postgres => "Use $1, $2, $3, ... for positional parameters.",
204            Self::MySql => "Use ? for positional parameters in argument order.",
205            Self::Athena => "Use ? for positional parameters in argument order.",
206            Self::Sqlite => "Use :name for named parameters or ? for positional.",
207        }
208    }
209}
210
211/// Errors a [`SqlConnector`] impl may surface from [`SqlConnector::schema_text`]
212/// or [`SqlConnector::execute`].
213///
214/// The enum is `#[non_exhaustive]`, so Phase 84 (CONN-01) adds the execute-time
215/// variants (`Driver`, `Query`, `ParameterBind`, `Connection`) additively
216/// without a semver break, and later phases can add more failure modes the same
217/// way.
218#[derive(Debug, Error)]
219#[non_exhaustive]
220pub enum ConnectorError {
221    /// Underlying transport / driver I/O error.
222    #[error("connector I/O error: {0}")]
223    Io(String),
224
225    /// Failed to enumerate or render the schema text.
226    #[error("schema fetch failed: {0}")]
227    Schema(String),
228
229    /// A connector was asked to handle work for the wrong dialect (e.g. a
230    /// query labelled Postgres routed to a MySQL connector).
231    #[error("dialect mismatch: query used {used:?} but connector is {actual:?}")]
232    DialectMismatch {
233        /// Dialect declared by the caller / query.
234        used: Dialect,
235        /// Dialect actually served by this connector.
236        actual: Dialect,
237    },
238
239    /// The underlying driver reported a failure (e.g. a `tokio-postgres`,
240    /// `sqlx`, `aws-sdk-athena`, or `rusqlite` error) that is not a query or
241    /// connection problem on its own.
242    #[error("driver error: {0}")]
243    Driver(String),
244
245    /// The backend rejected the query (syntax error, unknown table/column,
246    /// permission denied on the statement, etc.).
247    #[error("query error: {0}")]
248    Query(String),
249
250    /// A named parameter from the caller's `&[(String, Value)]` slice could not
251    /// be bound to the translated statement (type mismatch, missing binding,
252    /// unsupported value shape, etc.).
253    #[error("parameter bind failed for '{name}': {reason}")]
254    ParameterBind {
255        /// Name of the parameter that failed to bind.
256        name: String,
257        /// Human-readable reason the bind failed.
258        reason: String,
259    },
260
261    /// The connector could not establish or maintain a connection to the
262    /// backend.
263    ///
264    /// # Security
265    ///
266    /// Implementors MUST redact credentials (passwords, AWS keys) before
267    /// constructing this variant — the inner `String` reaches MCP clients via
268    /// `Display`. NEVER pass a raw `DATABASE_URL` or `AWS_*` value here; strip
269    /// or mask the secret first (T-84-01-01).
270    #[error("connection error: {0}")]
271    Connection(String),
272}
273
274/// Crate-internal mock connector for testing TKIT-10 prompt assembly without
275/// requiring a real driver. Phase 84's real impls subsume this for production.
276///
277/// Gated `cfg(any(test, feature = "sqlite"))` so Plan 08's smoke test can
278/// reach it under `--features sqlite`. Carries `#[allow(dead_code)]` because
279/// under `--features sqlite` (without `cfg(test)`) there are no in-crate
280/// callers — only Plan 08's smoke test references it from outside.
281#[cfg(any(test, feature = "sqlite"))]
282#[allow(dead_code)]
283pub(crate) struct MockSqlConnector {
284    /// Dialect the mock claims to serve.
285    pub dialect: Dialect,
286    /// Canned schema text returned by `schema_text()`.
287    pub schema: String,
288}
289
290#[cfg(any(test, feature = "sqlite"))]
291#[async_trait]
292impl SqlConnector for MockSqlConnector {
293    fn dialect(&self) -> Dialect {
294        self.dialect
295    }
296
297    /// Fixture-only: `MockSqlConnector` exists for TKIT-10 prompt-assembly
298    /// tests that exercise only `dialect()` + `schema_text()`. It is not a real
299    /// driver — use `SqliteConnector` (Plan 84-04) for real query execution.
300    async fn execute(
301        &self,
302        _sql: &str,
303        _params: &[(String, serde_json::Value)],
304    ) -> Result<Vec<serde_json::Value>, ConnectorError> {
305        Err(ConnectorError::Driver(
306            "MockSqlConnector::execute is fixture-only; use SqliteConnector for real execution"
307                .into(),
308        ))
309    }
310
311    async fn schema_text(&self) -> Result<String, ConnectorError> {
312        Ok(self.schema.clone())
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use proptest::prelude::*;
320
321    #[test]
322    fn dialect_name_stable_for_all_variants() {
323        for d in [
324            Dialect::Postgres,
325            Dialect::MySql,
326            Dialect::Athena,
327            Dialect::Sqlite,
328        ] {
329            assert!(
330                !d.name().is_empty(),
331                "Dialect::name must be non-empty for {d:?}"
332            );
333        }
334    }
335
336    #[test]
337    fn dialect_placeholder_guidance_stable_for_all_variants() {
338        for d in [
339            Dialect::Postgres,
340            Dialect::MySql,
341            Dialect::Athena,
342            Dialect::Sqlite,
343        ] {
344            assert!(
345                !d.placeholder_guidance().is_empty(),
346                "guidance must be non-empty for {d:?}"
347            );
348        }
349    }
350
351    proptest! {
352        /// TEST-02: dialect guidance is total (every input dialect returns non-empty).
353        /// Slim version of the Phase 83 dialect-aware property test; the full
354        /// `translate_placeholders` property test lives in Phase 84 per review R2.
355        #[test]
356        fn every_dialect_has_guidance(idx in 0usize..4) {
357            let d = match idx {
358                0 => Dialect::Postgres,
359                1 => Dialect::MySql,
360                2 => Dialect::Athena,
361                _ => Dialect::Sqlite,
362            };
363            prop_assert!(!d.placeholder_guidance().is_empty());
364            prop_assert!(!d.name().is_empty());
365        }
366    }
367}
368
369/// Compile-only assertions that the now-3-method `SqlConnector` trait object
370/// is still object-safe and `Send + Sync + 'static` — the bound per-backend
371/// crates and the toolkit's `Arc<dyn SqlConnector>` plumbing rely on.
372#[cfg(test)]
373mod execute_signature_tests {
374    use super::SqlConnector;
375
376    fn assert_send_sync<T: Send + Sync + 'static>() {}
377
378    #[test]
379    fn connector_trait_object_is_send_sync_static() {
380        assert_send_sync::<Box<dyn SqlConnector>>();
381    }
382}
383
384/// Unit tests for the execute-time `ConnectorError` variants (CONN-01 / Task 2).
385///
386/// Verifies the `thiserror` `Display` format and confirms the `Connection`
387/// variant is not designed as a credential-leak channel (T-84-01-01). Real
388/// redaction lives in the per-backend connectors (Plans 05/06/07); this guard
389/// proves the variant itself does not synthesize credential strings.
390#[cfg(test)]
391mod connector_error_tests {
392    use super::ConnectorError;
393
394    #[test]
395    fn test_display_format_driver() {
396        assert_eq!(
397            format!("{}", ConnectorError::Driver("oops".into())),
398            "driver error: oops"
399        );
400    }
401
402    #[test]
403    fn test_display_format_parameter_bind() {
404        assert_eq!(
405            format!(
406                "{}",
407                ConnectorError::ParameterBind {
408                    name: "id".into(),
409                    reason: "expected int, got string".into(),
410                }
411            ),
412            "parameter bind failed for 'id': expected int, got string"
413        );
414    }
415
416    #[test]
417    fn test_connection_display_does_not_echo_password() {
418        let err = ConnectorError::Connection("connection refused".into());
419        let rendered = format!("{err}");
420        for forbidden in ["password", "AWS_SECRET_ACCESS_KEY", "DATABASE_URL"] {
421            assert!(
422                !rendered.contains(forbidden),
423                "Connection Display must not synthesize the credential token {forbidden:?}; got {rendered:?}"
424            );
425        }
426    }
427}