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}