Skip to content

Commit 51e2e56

Browse files
joaoh82claude
andauthored
cleanup(engine): make process_command stdout-clean (drop REPL-only prints) (#76)
The engine's `process_command` had three direct stdout writes that made sense in a REPL but corrupted any other channel an embedder might be using stdout for: src/sql/mod.rs:150 — let _ = table.print_table_schema(); (CREATE) src/sql/mod.rs:208 — db_table.print_table_data(); (INSERT) src/sql/mod.rs:224 — print!("{rendered}"); (SELECT) Plus a leftover debug print in src/sql/parser/create.rs:190 dumping unhandled `sqlparser::ast::TableConstraint` debug reprs to stdout. This is the issue PR #73 papered over with the dup2(2,1) dance in sqlrite-mcp/src/stdio_redirect.rs. That fix is still in place (belt-and-suspenders against future regressions), but the engine now behaves correctly without it — the SDKs (Python, Node, Go, WASM, FFI), the Tauri desktop, and any future embedder all benefit. ## How Refactored process_command to return a richer struct, with a backwards-compat wrapper: pub struct CommandOutput { pub status: String, // "INSERT Statement executed.", etc. pub rendered: Option<String>, // SELECT prettytable; None otherwise } pub fn process_command(query, db) -> Result<String> // backwards-compat: returns just .status pub fn process_command_with_render(query, db) -> Result<CommandOutput> // new: rich return, never writes to stdout The REPL (`src/main.rs`) calls `_with_render` and prints rendered above status — same UX as before. The .ask meta-command does the same and concatenates the two pieces into the single String it bubbles up to the REPL's outer dispatch loop. The CREATE-TABLE schema dump and INSERT row dump aren't preserved — they were small UX niceties that interactive REPL users rarely need (the schema you just CREATEd, the row you just INSERTed). The INSERT case was actively bad UX on any non-trivial table — it dumped the entire table after every insert. Both gone. `sqlrite::process_command` keeps its existing signature for backwards compatibility — every existing call site (Connection::execute, the SDK FFI shims, the .ask handler's inline runner, the engine's own tests) keeps working unchanged. ## Verified - `cargo build --workspace` clean - `cargo test --workspace --exclude sqlrite-{python,nodejs,desktop}` — 330+ tests across engine + sqlrite-mcp + sqlrite-ask + sqlrite-ffi, all passing (engine's 251-test integration suite covers the process_command paths most directly affected) - REPL smoke: CREATE / INSERT / SELECT round-trip prints rendered table above status, no surprises - MCP smoke: same round-trip via JSON-RPC over stdio — `cat 2>/dev/null` filtered stderr is now empty (was prettytable noise before; stdio_redirect was the only thing keeping it off the protocol channel) ## Docs - `docs/architecture.md` — updates the engine module map entries for src/lib.rs and src/sql/mod.rs to mention the new types + the "never writes to stdout" guarantee - `docs/sql-engine.md` — entry-point section now shows both functions + the CommandOutput shape; explains the rendered-vs-status split - `docs/mcp.md` — security-notes section reframes stdio_redirect as defense-in-depth now that the engine is clean - `sqlrite-mcp/src/stdio_redirect.rs` — module-level comment rewritten to call out the engine fix + the three concrete future-regression scenarios the redirect protects against Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 91ca631 commit 51e2e56

9 files changed

Lines changed: 155 additions & 42 deletions

File tree

docs/architecture.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,13 @@ The engine never depends on the SDK crates; the SDK crates each depend on the en
8989
| Module | What it owns |
9090
|---|---|
9191
| [`src/main.rs`](../src/main.rs) | Binary entry: init env_logger, build rustyline editor, run the REPL loop, route input to either the meta or SQL dispatcher |
92-
| [`src/lib.rs`](../src/lib.rs) | Library entry: re-exports `Connection`, `Statement`, `Rows`, `Value`, `Database`, `process_command`, the `ask` module (when feature on), etc. — the stable public surface every SDK binds against |
92+
| [`src/lib.rs`](../src/lib.rs) | Library entry: re-exports `Connection`, `Statement`, `Rows`, `Value`, `Database`, `process_command` / `process_command_with_render` / `CommandOutput`, the `ask` module (when feature on), etc. — the stable public surface every SDK binds against |
9393
| [`src/connection.rs`](../src/connection.rs) | `Connection` / `Statement` / `Rows` / `Row` / `OwnedRow` / `FromValue` — the Phase 5a public API |
9494
| [`src/ask/`](../src/ask/) | Engine integration with `sqlrite-ask`: `ConnectionAskExt`, `ask_with_database`, the `schema::dump_schema_for_database` helper. The `schema` submodule is always available; the rest is gated behind the `ask` feature. Phase 7g.2. |
9595
| [`src/repl/`](../src/repl/) | `REPLHelper` (implements rustyline's `Helper` trait: completer, hinter, highlighter, validator). Also `get_config` and `get_command_type` |
9696
| [`src/meta_command/`](../src/meta_command/) | `MetaCommand` enum, parsing (`.open FOO.db``Open(PathBuf)`, `.ask <Q>``Ask(String)`), and dispatch to persistence + ask helpers |
9797
| [`src/error.rs`](../src/error.rs) | `SQLRiteError` (thiserror-derived), `Result<T>` alias, hand-rolled `PartialEq` that handles `io::Error` |
98-
| [`src/sql/mod.rs`](../src/sql/mod.rs) | `SQLCommand` classifier, `process_command` — the top-level entry that parses a SQL string and routes to the right executor. Also triggers auto-save. |
98+
| [`src/sql/mod.rs`](../src/sql/mod.rs) | `SQLCommand` classifier, `process_command` / `process_command_with_render` — the top-level entries that parse a SQL string and route to the right executor. Also triggers auto-save. **Never writes to stdout** — for SELECT statements, the rendered prettytable comes back inside `CommandOutput.rendered` so the REPL can print it (the engine itself doesn't); the SDK / FFI / MCP callers ignore it. |
9999
| [`src/sql/parser/`](../src/sql/parser/) | Takes a `sqlparser::ast::Statement` and produces internal query structs (`CreateQuery`, `InsertQuery`, `SelectQuery`) with only the fields we actually use |
100100
| [`src/sql/executor.rs`](../src/sql/executor.rs) | `execute_select`, `execute_delete`, `execute_update`, plus the shared expression evaluator `eval_expr` / `eval_predicate`. Also the bounded-heap top-k optimization (Phase 7c) and the HNSW probe shortcut (Phase 7d.2). |
101101
| [`src/sql/db/database.rs`](../src/sql/db/database.rs) | `Database`: table map + optional `source_path` + optional long-lived `Pager` + transaction-snapshot state |

docs/mcp.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ Stderr is reserved for diagnostics (panics, the MCP startup banner). Anything wr
309309

310310
- **Logging hygiene:** the `query`, `execute`, and `ask` tools receive arbitrary user-supplied SQL and questions. The server emits these to stderr only on errors (so the MCP log pane is useful for debugging) — but if you wrap `sqlrite-mcp`'s stderr in your own logging stack, treat the contents as potentially sensitive.
311311

312-
- **Stdout is owned by the protocol.** The binary redirects process fd 1 to fd 2 at startup so any errant `print!` from anywhere in the dep tree goes to stderr instead of corrupting the JSON-RPC channel. See `sqlrite-mcp/src/stdio_redirect.rs` for the dance.
312+
- **Stdout is owned by the protocol.** As of the engine-stdout-pollution cleanup, the SQLRite engine itself doesn't print to stdout — the REPL-convenience prints (CREATE schema, INSERT row dump, SELECT result table) all moved out: SELECT's rendered table comes back inside [`CommandOutput::rendered`](../src/sql/mod.rs) for the REPL to print itself, the others were dropped entirely. The MCP binary additionally redirects process fd 1 fd 2 at startup as **defense in depth** — protects against future regressions if a contributor (or a transitive dep) ever reintroduces a stray `print!`. See `sqlrite-mcp/src/stdio_redirect.rs` for the dance.
313313

314314
---
315315

docs/sql-engine.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,21 @@ Every SQL statement goes through [`process_command`](../src/sql/mod.rs):
88

99
```rust
1010
pub fn process_command(query: &str, db: &mut Database) -> Result<String>
11+
12+
// Richer variant — returns both the status line and (for SELECT) the
13+
// pre-rendered prettytable. The REPL uses this so it can print the
14+
// table above the status; SDK / FFI / MCP callers ignore the rendered
15+
// field and use the typed-row API for actual row access.
16+
pub fn process_command_with_render(query: &str, db: &mut Database)
17+
-> Result<CommandOutput>;
18+
19+
pub struct CommandOutput {
20+
pub status: String,
21+
pub rendered: Option<String>, // Some(_) only for SELECT
22+
}
1123
```
1224

13-
Given a raw line of SQL (e.g. `"SELECT name FROM users WHERE age > 25;"`) and the in-memory database, it returns either a human-readable status message or a typed error.
25+
Given a raw line of SQL (e.g. `"SELECT name FROM users WHERE age > 25;"`) and the in-memory database, both functions return either a human-readable status (and, for SELECT, a rendered prettytable) or a typed error. **Neither writes to stdout** — the REPL is the only consumer that prints anything; everyone else (Tauri desktop, SDKs, the MCP server) just reads the returned struct.
1426

1527
The function's shape:
1628

@@ -19,7 +31,7 @@ The function's shape:
1931
3. Classify as read-only vs. writing.
2032
4. Match on `Statement::*` and dispatch to the appropriate executor.
2133
5. If the statement writes and the DB is file-backed, auto-save.
22-
6. Return the status string.
34+
6. Return `CommandOutput { status, rendered }`. (`process_command` is a backwards-compat wrapper that just returns `.status`.)
2335

2436
## Parsing
2537

sqlrite-mcp/src/stdio_redirect.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,25 @@
88
//! in by accident, a banner from a third-party crate. The MCP client
99
//! sees garbage where it expected JSON and disconnects.
1010
//!
11-
//! The SQLRite engine's REPL-convenience prints inside
12-
//! `process_command` (the `CREATE TABLE` schema dump at sql/mod.rs:150,
13-
//! the `INSERT` row dump at sql/mod.rs:208, the `SELECT` result table
14-
//! at sql/mod.rs:224) are exactly this kind of write. They're great
15-
//! for an interactive REPL; lethal for an MCP server.
11+
//! ## Engine-side fix + this defense-in-depth backstop
12+
//!
13+
//! As of the engine-stdout-pollution cleanup, the SQLRite engine's
14+
//! `process_command` no longer writes to stdout. The historical REPL-
15+
//! convenience prints (CREATE TABLE schema dump, INSERT row dump,
16+
//! SELECT result table) all moved out: the rendered SELECT table now
17+
//! comes back inside [`sqlrite::CommandOutput::rendered`] for the REPL
18+
//! to print itself, and the CREATE / INSERT prints were dropped
19+
//! entirely (the latter was a spammy bug-feature anyway). So in normal
20+
//! operation this redirect catches nothing.
21+
//!
22+
//! We keep the redirect anyway as **defense in depth.** Three concrete
23+
//! futures it protects against: (a) a future engine PR accidentally
24+
//! reintroducing a `println!` that the test suite doesn't catch
25+
//! (engine tests don't assert on stdout); (b) a transitive dep adding
26+
//! a startup banner; (c) a debug print left in mid-development. Any
27+
//! one of those would silently break the MCP server without this
28+
//! safety net. Cost: ~140 LOC + a libc dep that's already a
29+
//! transitive of clap.
1630
//!
1731
//! ## What this module does
1832
//!

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ pub use sql::pager::{
8888
AccessMode, MASTER_TABLE_NAME, open_database, open_database_read_only, open_database_with_mode,
8989
save_database,
9090
};
91-
pub use sql::process_command;
91+
pub use sql::{CommandOutput, process_command, process_command_with_render};
9292

9393
// Re-export sqlparser so downstream crates (the Tauri desktop app, the
9494
// eventual WASM bindings) can reach into the AST without pulling a

src/main.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use std::path::PathBuf;
1010

1111
use meta_command::handle_meta_command;
1212
use repl::{CommandType, REPLHelper, get_command_type, get_config};
13-
use sqlrite::{Database, process_command};
13+
use sqlrite::{Database, process_command_with_render};
1414

1515
use rustyline::Editor;
1616
use rustyline::error::ReadlineError;
@@ -144,10 +144,21 @@ fn main() -> rustyline::Result<()> {
144144
// Parsing user's input and returning and enum of repl::CommandType
145145
match get_command_type(command.trim()) {
146146
CommandType::SQLCommand(_cmd) => {
147-
// process_command takes care of tokenizing, parsing and executing
148-
// the SQL Statement and returning a Result<String, SQLRiteError>
149-
match process_command(&command, &mut db) {
150-
Ok(response) => println!("{response}"),
147+
// `process_command_with_render` returns a CommandOutput
148+
// carrying both the status line and (for SELECT) the
149+
// pre-rendered prettytable. Print rendered first if
150+
// present so the user sees the rows above the
151+
// confirmation. Prior to the engine-stdout-pollution
152+
// cleanup the engine printed the table itself, which
153+
// corrupted any non-REPL stdout channel — now the REPL
154+
// owns the printing.
155+
match process_command_with_render(&command, &mut db) {
156+
Ok(output) => {
157+
if let Some(rendered) = output.rendered.as_deref() {
158+
print!("{rendered}");
159+
}
160+
println!("{}", output.status);
161+
}
151162
Err(err) => eprintln!("An error occured: {err}"),
152163
}
153164
}

src/meta_command/mod.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::repl::REPLHelper;
88
use sqlrite::error::{Result, SQLRiteError};
99
use sqlrite::sql::db::database::Database;
1010
use sqlrite::sql::pager::{open_database, save_database};
11-
use sqlrite::{ask::ask_with_database, process_command};
11+
use sqlrite::{ask::ask_with_database, process_command_with_render};
1212
use sqlrite_ask::AskConfig;
1313

1414
#[derive(Debug, PartialEq)]
@@ -251,11 +251,16 @@ fn handle_ask(
251251
}
252252

253253
// Run the generated SQL through the same pipeline as a typed
254-
// statement. process_command handles both DDL/DML (returns a
255-
// status string like "INSERT Statement executed.") and SELECT
256-
// (returns a rendered table). Either is fine to return up to the
257-
// outer dispatch loop, which just prints what we hand back.
258-
process_command(&resp.sql, db)
254+
// statement. We use the `_with_render` variant so SELECTs come back
255+
// with their rendered prettytable; concatenate it above the status
256+
// line so the REPL's outer dispatch (which just prints whatever
257+
// string we return) shows both. DDL/DML statements return only a
258+
// status — `rendered` is `None` and we skip the prepend.
259+
let output = process_command_with_render(&resp.sql, db)?;
260+
Ok(match output.rendered {
261+
Some(rendered) => format!("{rendered}{}", output.status),
262+
None => output.status,
263+
})
259264
}
260265

261266
#[cfg(test)]

src/sql/mod.rs

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,52 @@ impl SQLCommand {
4141
}
4242
}
4343

44-
/// Performs initial parsing of SQL Statement using sqlparser-rs
44+
/// Output of running one SQL statement through the engine.
45+
///
46+
/// Two fields:
47+
///
48+
/// - `status` is the short human-readable confirmation line every caller
49+
/// wants ("INSERT Statement executed.", "3 rows updated.", "BEGIN", etc.).
50+
/// - `rendered` is the pre-formatted prettytable rendering of a SELECT's
51+
/// result rows. Populated only for `SELECT` statements; `None` for every
52+
/// other statement type. The REPL prints this above the status line so
53+
/// users see both the rows and the confirmation; SDK / FFI / MCP callers
54+
/// ignore it and reach for the typed-row APIs (`Connection::prepare` →
55+
/// `Statement::query` → `Rows`) when they want row data instead.
56+
///
57+
/// Splitting the two means [`process_command_with_render`] can return
58+
/// everything the REPL needs without writing to stdout itself —
59+
/// historically `process_command` would `print!()` the rendered table
60+
/// directly, which corrupted any non-REPL stdout channel (the MCP server's
61+
/// JSON-RPC wire, structured loggers piping engine output, …).
62+
#[derive(Debug, Clone)]
63+
pub struct CommandOutput {
64+
pub status: String,
65+
pub rendered: Option<String>,
66+
}
67+
68+
/// Backwards-compatible wrapper around [`process_command_with_render`] that
69+
/// returns just the status string. Every existing call site (the public
70+
/// `Connection::execute`, the SDK FFI shims, the .ask meta-command's
71+
/// inline runner, the engine's own tests) keeps working unchanged.
72+
///
73+
/// Callers that want the rendered SELECT table (the REPL, future
74+
/// terminal-style consumers) should call [`process_command_with_render`]
75+
/// directly and inspect [`CommandOutput::rendered`].
4576
pub fn process_command(query: &str, db: &mut Database) -> Result<String> {
77+
process_command_with_render(query, db).map(|o| o.status)
78+
}
79+
80+
/// Performs initial parsing of SQL Statement using sqlparser-rs.
81+
///
82+
/// Returns a [`CommandOutput`] carrying both the status string and (for
83+
/// SELECT statements) the pre-rendered prettytable output. **Never writes
84+
/// to stdout.** The REPL is responsible for printing whatever it wants
85+
/// from the returned struct.
86+
pub fn process_command_with_render(query: &str, db: &mut Database) -> Result<CommandOutput> {
4687
let dialect = SQLiteDialect {};
4788
let message: String;
89+
let mut rendered: Option<String> = None;
4890
let mut ast = Parser::parse_sql(&dialect, query).map_err(SQLRiteError::from)?;
4991

5092
if ast.len() > 1 {
@@ -58,7 +100,10 @@ pub fn process_command(query: &str, db: &mut Database) -> Result<String> {
58100
// Return a benign status rather than panicking on `pop().unwrap()`. Callers
59101
// (REPL, Tauri app) treat this as a no-op with no disk write triggered.
60102
let Some(query) = ast.pop() else {
61-
return Ok("No statement to execute.".to_string());
103+
return Ok(CommandOutput {
104+
status: "No statement to execute.".to_string(),
105+
rendered: None,
106+
});
62107
};
63108

64109
// Transaction boundary statements are routed to Database-level
@@ -68,7 +113,10 @@ pub fn process_command(query: &str, db: &mut Database) -> Result<String> {
68113
match &query {
69114
Statement::StartTransaction { .. } => {
70115
db.begin_transaction()?;
71-
return Ok(String::from("BEGIN"));
116+
return Ok(CommandOutput {
117+
status: String::from("BEGIN"),
118+
rendered: None,
119+
});
72120
}
73121
Statement::Commit { .. } => {
74122
if !db.in_transaction() {
@@ -94,11 +142,17 @@ pub fn process_command(query: &str, db: &mut Database) -> Result<String> {
94142
}
95143
}
96144
db.commit_transaction()?;
97-
return Ok(String::from("COMMIT"));
145+
return Ok(CommandOutput {
146+
status: String::from("COMMIT"),
147+
rendered: None,
148+
});
98149
}
99150
Statement::Rollback { .. } => {
100151
db.rollback_transaction()?;
101-
return Ok(String::from("ROLLBACK"));
152+
return Ok(CommandOutput {
153+
status: String::from("ROLLBACK"),
154+
rendered: None,
155+
});
102156
}
103157
_ => {}
104158
}
@@ -147,12 +201,14 @@ pub fn process_command(query: &str, db: &mut Database) -> Result<String> {
147201
}
148202
false => {
149203
let table = Table::new(payload);
150-
let _ = table.print_table_schema();
204+
// Note: we used to call `table.print_table_schema()` here
205+
// for REPL convenience. Removed because it wrote
206+
// directly to stdout, which corrupted any non-REPL
207+
// protocol channel (most painfully the MCP server's
208+
// JSON-RPC wire). The status line below is enough for
209+
// the REPL; users who want to inspect the schema can
210+
// run a follow-up describe / `.tables`-style command.
151211
db.tables.insert(table_name.to_string(), table);
152-
// Iterate over everything.
153-
// for (table_name, _) in &db.tables {
154-
// println!("{}" , table_name);
155-
// }
156212
message = String::from("CREATE TABLE Statement executed.");
157213
}
158214
}
@@ -205,7 +261,12 @@ pub fn process_command(query: &str, db: &mut Database) -> Result<String> {
205261
));
206262
}
207263
}
208-
db_table.print_table_data();
264+
// Note: we used to call `db_table.print_table_data()`
265+
// here, which dumped the *entire* table to stdout
266+
// after every INSERT. Beyond corrupting non-REPL
267+
// stdout channels, that's actively bad UX on any
268+
// table with more than a few rows. Removed in the
269+
// engine-stdout-pollution cleanup.
209270
}
210271
false => {
211272
return Err(SQLRiteError::Internal("Table doesn't exist".to_string()));
@@ -219,9 +280,13 @@ pub fn process_command(query: &str, db: &mut Database) -> Result<String> {
219280
}
220281
Statement::Query(_) => {
221282
let select_query = SelectQuery::new(&query)?;
222-
let (rendered, rows) = executor::execute_select(select_query, db)?;
223-
// Print the result table above the status message so the REPL shows both.
224-
print!("{rendered}");
283+
let (rendered_table, rows) = executor::execute_select(select_query, db)?;
284+
// Stash the rendered prettytable in the output so the REPL
285+
// (or any terminal-style consumer) can print it above the
286+
// status line. SDK / FFI / MCP callers ignore this field.
287+
// The previous implementation `print!("{rendered}")`-ed
288+
// directly to stdout, which broke every non-REPL embedder.
289+
rendered = Some(rendered_table);
225290
message = format!(
226291
"SELECT Statement executed. {rows} row{s} returned.",
227292
s = if rows == 1 { "" } else { "s" }
@@ -267,7 +332,10 @@ pub fn process_command(query: &str, db: &mut Database) -> Result<String> {
267332
pager::save_database(db, &path)?;
268333
}
269334

270-
Ok(message)
335+
Ok(CommandOutput {
336+
status: message,
337+
rendered,
338+
})
271339
}
272340

273341
#[cfg(test)]

src/sql/parser/create.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,14 @@ impl CreateQuery {
184184
is_unique,
185185
});
186186
}
187-
// TODO: Handle constraints,
188-
// Default value and others.
189-
for constraint in constraints {
190-
println!("{constraint:?}");
191-
}
187+
// TODO: handle constraints + default values + check
188+
// constraints + ON DELETE / ON UPDATE referential actions
189+
// properly. They're currently parsed by `sqlparser` and
190+
// dropped on the floor here. (Previously we `println!`-ed
191+
// them to stdout as a debug aid — removed in the
192+
// engine-stdout-pollution cleanup; flip to a `tracing`
193+
// span if we ever want them visible in dev builds.)
194+
let _ = constraints;
192195
Ok(CreateQuery {
193196
table_name: table_name.to_string(),
194197
columns: parsed_columns,

0 commit comments

Comments
 (0)