ssh_commander_core/postgres/exec.rs
1//! Query execution for the Postgres explorer.
2//!
3//! Sprint 5 strategy: server-side cursors driven by `simple_query`.
4//! The cursor holds the result set on the server; the explorer streams
5//! pages on demand. This replaces the Sprint 3 "fetch everything up to
6//! `max_rows`" approach so users can browse genuinely large tables.
7//!
8//! ## Why simple_query (still)
9//!
10//! Even with cursors, we use `simple_query` for the FETCH itself —
11//! it returns text representations of every value, which gives us
12//! universal type support (bytea, JSON, arrays, ranges, custom enums,
13//! geometry) without per-OID decoding.
14//!
15//! ## Cursor lifecycle
16//!
17//! - `BEGIN; DECLARE c_<uuid> NO SCROLL CURSOR FOR <user_sql>` opens.
18//! - `FETCH FORWARD <n> FROM c_<uuid>` returns the next page; the
19//! server's `CommandComplete(actual)` tells us how many rows came
20//! back, so we know whether more remain (actual == n means there's
21//! probably more; actual < n means the cursor exhausted).
22//! - `CLOSE c_<uuid>; COMMIT` releases the server resources.
23//!
24//! Single-cursor invariant is enforced by [`PgClient`](super::PgClient)
25//! (one transaction per connection on the wire). If a second `execute`
26//! call comes in while a cursor is open, the old one is closed first
27//! — the surfaced `CursorExpired` error tells the previous tab's UI
28//! that "Load more" can no longer fetch.
29
30use serde::{Deserialize, Serialize};
31use tokio_postgres::{Client, SimpleQueryMessage};
32use uuid::Uuid;
33
34use crate::postgres::PgError;
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ColumnMeta {
38 pub name: String,
39 /// Postgres type OID (oid 16 = bool, 23 = int4, 1184 = timestamptz,
40 /// 3802 = jsonb, etc). Stable across server versions, so the UI
41 /// can decide presentation (alignment, formatting) from this
42 /// without a separate type-name lookup. `0` if the source
43 /// statement didn't expose a typed column descriptor (rare —
44 /// only certain dynamic catalog functions).
45 pub type_oid: u32,
46 /// Human-readable type name from `pg_type.typname` (`int4`,
47 /// `timestamptz`, `jsonb`, …). Surfaces in tooltips and gives
48 /// the UI a reasonable fallback label for OIDs the affinity
49 /// decoder doesn't classify.
50 pub type_name: String,
51}
52
53/// Server-side cursor metadata. Held by the client when a query has
54/// remaining rows; consumed (closed) on `close_query` or when the next
55/// `execute` call supersedes it.
56#[derive(Debug, Clone)]
57pub struct ActiveCursor {
58 pub cursor_id: String,
59 pub column_count: usize,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ExecutionOutcome {
64 /// Column metadata. Empty for non-row-returning statements.
65 pub columns: Vec<ColumnMeta>,
66 /// First page of rows. Each inner `Vec` has `columns.len()`
67 /// entries; `None` is SQL NULL.
68 pub rows: Vec<Vec<Option<String>>>,
69 /// `RowsAffected` from the last completed statement, when the
70 /// server reports one.
71 pub rows_affected: Option<u64>,
72 /// `Some(_)` when the query returned a full page and more rows
73 /// remain server-side. The id is opaque to callers and used as
74 /// the handle for [`fetch_page`] / [`close_query`].
75 pub cursor_id: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct PageResult {
80 pub rows: Vec<Vec<Option<String>>>,
81 /// `true` when this page filled to `count` (so more might be
82 /// available). `false` when the cursor exhausted on this fetch.
83 pub has_more: bool,
84}
85
86/// Detect whether `sql` contains more than one statement.
87///
88/// Walks the text with a tiny lexer that tracks string literals
89/// (`'…''…'`), quoted identifiers (`"…""…"`), line comments (`-- …`),
90/// and block comments (`/* … */`). A `;` outside any of those
91/// contexts, with at least one non-whitespace character following,
92/// makes the script "multi-statement".
93///
94/// False positives are possible only for SQL that looks like
95/// `…';' …` inside a quote we mis-tracked — extremely rare and the
96/// fallback (lose column types, no cursor pagination) is non-fatal.
97pub(crate) fn is_multi_statement(sql: &str) -> bool {
98 enum LexState {
99 Normal,
100 SingleQuote,
101 DoubleQuote,
102 LineComment,
103 BlockComment,
104 }
105 let bytes = sql.as_bytes();
106 let mut i = 0usize;
107 let mut state = LexState::Normal;
108 while i < bytes.len() {
109 let c = bytes[i];
110 match state {
111 LexState::Normal => match c {
112 b'\'' => state = LexState::SingleQuote,
113 b'"' => state = LexState::DoubleQuote,
114 b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => {
115 state = LexState::LineComment;
116 i += 1;
117 }
118 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
119 state = LexState::BlockComment;
120 i += 1;
121 }
122 b';' => {
123 let rest = &bytes[i + 1..];
124 if rest.iter().any(|b| !b.is_ascii_whitespace()) {
125 return true;
126 }
127 return false;
128 }
129 _ => {}
130 },
131 LexState::SingleQuote => {
132 if c == b'\'' {
133 if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
134 i += 1; // doubled quote, escaped
135 } else {
136 state = LexState::Normal;
137 }
138 }
139 }
140 LexState::DoubleQuote => {
141 if c == b'"' {
142 if i + 1 < bytes.len() && bytes[i + 1] == b'"' {
143 i += 1; // doubled quote, escaped
144 } else {
145 state = LexState::Normal;
146 }
147 }
148 }
149 LexState::LineComment => {
150 if c == b'\n' {
151 state = LexState::Normal;
152 }
153 }
154 LexState::BlockComment => {
155 if c == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
156 state = LexState::Normal;
157 i += 1;
158 }
159 }
160 }
161 i += 1;
162 }
163 false
164}
165
166/// Split a multi-statement script into `(preamble, main)` where
167/// `main` is the last top-level statement and `preamble` is
168/// everything before it. Returns `None` when no clean split is
169/// available (script is single-statement or the trailing piece is
170/// blank/comment-only after the last delimiter).
171///
172/// The point of the split is to let the caller run `preamble` via
173/// `batch_execute` (which preserves the SET/SHOW/etc effects) and
174/// then run `main` through the cursor path so pagination works on
175/// the SELECT that the user actually cares about.
176pub(crate) fn split_at_last_statement(sql: &str) -> Option<(&str, &str)> {
177 enum LexState {
178 Normal,
179 SingleQuote,
180 DoubleQuote,
181 LineComment,
182 BlockComment,
183 }
184 let bytes = sql.as_bytes();
185 let mut i = 0usize;
186 let mut state = LexState::Normal;
187 let mut last_delim: Option<usize> = None;
188 while i < bytes.len() {
189 let c = bytes[i];
190 match state {
191 LexState::Normal => match c {
192 b'\'' => state = LexState::SingleQuote,
193 b'"' => state = LexState::DoubleQuote,
194 b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => {
195 state = LexState::LineComment;
196 i += 1;
197 }
198 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
199 state = LexState::BlockComment;
200 i += 1;
201 }
202 b';' => last_delim = Some(i),
203 _ => {}
204 },
205 LexState::SingleQuote => {
206 if c == b'\'' {
207 if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
208 i += 1;
209 } else {
210 state = LexState::Normal;
211 }
212 }
213 }
214 LexState::DoubleQuote => {
215 if c == b'"' {
216 if i + 1 < bytes.len() && bytes[i + 1] == b'"' {
217 i += 1;
218 } else {
219 state = LexState::Normal;
220 }
221 }
222 }
223 LexState::LineComment => {
224 if c == b'\n' {
225 state = LexState::Normal;
226 }
227 }
228 LexState::BlockComment => {
229 if c == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
230 state = LexState::Normal;
231 i += 1;
232 }
233 }
234 }
235 i += 1;
236 }
237
238 let split = last_delim?;
239 let main = &sql[split + 1..];
240 if is_effectively_empty(main) {
241 // Trailing semicolon with nothing real after — possibly just
242 // whitespace, a line comment, or a block comment. Caller
243 // falls back to the bulk multi-statement path.
244 return None;
245 }
246 let main = main.trim();
247 let preamble = &sql[..split + 1]; // include the delimiter
248 // Refuse to split if the "main" itself contains an unguarded `;`
249 // — defensive: the main piece must be a single statement so the
250 // cursor path's `prepare` accepts it.
251 if is_multi_statement(main) {
252 return None;
253 }
254 Some((preamble, main))
255}
256
257/// Whether `sql` is whitespace + comments only — i.e. has no real
258/// SQL token. Used by the smart splitter to detect "trailing noise"
259/// (a comment after the final `;`) and treat it as no-main-statement.
260fn is_effectively_empty(sql: &str) -> bool {
261 enum LexState {
262 Normal,
263 LineComment,
264 BlockComment,
265 }
266 let bytes = sql.as_bytes();
267 let mut i = 0usize;
268 let mut state = LexState::Normal;
269 while i < bytes.len() {
270 let c = bytes[i];
271 match state {
272 LexState::Normal => match c {
273 b' ' | b'\t' | b'\n' | b'\r' => {}
274 b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => {
275 state = LexState::LineComment;
276 i += 1;
277 }
278 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
279 state = LexState::BlockComment;
280 i += 1;
281 }
282 _ => return false,
283 },
284 LexState::LineComment => {
285 if c == b'\n' {
286 state = LexState::Normal;
287 }
288 }
289 LexState::BlockComment => {
290 if c == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
291 state = LexState::Normal;
292 i += 1;
293 }
294 }
295 }
296 i += 1;
297 }
298 true
299}
300
301/// Run a multi-statement script via `simple_query` and return the
302/// last row-returning result. No cursor pagination here — Postgres
303/// implicit transactions don't compose cleanly with cursor declarations
304/// inside user-supplied script bodies, and `prepare` rejects
305/// multi-statement input. Result is capped at `page_size` rows; the
306/// UI shows "more rows discarded" naturally because `cursor_id` is
307/// `None` (no "Load more" affordance offered).
308async fn run_multi_statement(
309 client: &Client,
310 sql: &str,
311 page_size: usize,
312) -> Result<ExecutionOutcome, PgError> {
313 let stream = client.simple_query(sql).await.map_err(PgError::Driver)?;
314
315 let mut current_columns: Vec<ColumnMeta> = vec![];
316 let mut current_rows: Vec<Vec<Option<String>>> = vec![];
317 let mut last_command_complete: Option<u64> = None;
318
319 for msg in stream {
320 match msg {
321 SimpleQueryMessage::RowDescription(desc) => {
322 // A new result block starts here. Discard any
323 // previously-collected rows so we end up keeping
324 // the LAST row-returning statement's output —
325 // matches DataGrip-style behavior where the user
326 // sees the result of `SET …; SELECT …`.
327 //
328 // OIDs aren't exposed via simple_query; type names
329 // are blank. The grid falls back to default
330 // alignment / formatting.
331 current_columns = desc
332 .iter()
333 .map(|c| ColumnMeta {
334 name: c.name().to_string(),
335 type_oid: 0,
336 type_name: String::new(),
337 })
338 .collect();
339 current_rows.clear();
340 }
341 SimpleQueryMessage::Row(row) => {
342 if current_rows.len() >= page_size {
343 // Continue draining the stream so the connection
344 // doesn't end up with buffered server messages,
345 // but ignore the surplus rows.
346 continue;
347 }
348 let width = current_columns.len();
349 let mut cells = Vec::with_capacity(width);
350 for idx in 0..width {
351 cells.push(row.get(idx).map(str::to_string));
352 }
353 current_rows.push(cells);
354 }
355 SimpleQueryMessage::CommandComplete(n) => {
356 last_command_complete = Some(n);
357 }
358 _ => {}
359 }
360 }
361
362 Ok(ExecutionOutcome {
363 columns: current_columns,
364 rows: current_rows,
365 rows_affected: last_command_complete,
366 cursor_id: None,
367 })
368}
369
370/// Open a new query. Closes any previously-active cursor (and its
371/// transaction) before starting. Returns the first page synchronously
372/// along with a cursor id when more rows remain.
373///
374/// `page_size` is the soft window for the first page. Non-row-returning
375/// statements (DDL, INSERT without RETURNING) skip the cursor path
376/// entirely — they run via `batch_execute` and report rows_affected.
377/// Multi-statement scripts (`SET …; SELECT …` etc) run via
378/// `simple_query`; the last row-returning statement's result is
379/// surfaced and cursor pagination isn't offered.
380pub async fn open_query(
381 client: &Client,
382 sql: &str,
383 page_size: usize,
384 previous: Option<ActiveCursor>,
385) -> Result<(ExecutionOutcome, Option<ActiveCursor>), PgError> {
386 // Best-effort cleanup of any existing cursor. If the previous
387 // transaction is already in a bad state (rollback pending), this
388 // may fail — we log and continue. The new BEGIN below will reset
389 // session state by aborting the abandoned transaction.
390 if let Some(prev) = previous.as_ref() {
391 let cleanup = format!("CLOSE {}; COMMIT", prev.cursor_id);
392 if let Err(e) = client.batch_execute(&cleanup).await {
393 tracing::debug!(
394 cursor = %prev.cursor_id,
395 error = %e,
396 "previous cursor cleanup failed; continuing with ROLLBACK"
397 );
398 // Force the session out of any half-open transaction state.
399 let _ = client.batch_execute("ROLLBACK").await;
400 }
401 }
402
403 // Multi-statement scripts can't be `prepare`d (the extended
404 // protocol's Parse message rejects multi-command input).
405 //
406 // Common pattern: `SET statement_timeout = …; SELECT …` — the
407 // user wants pagination on the SELECT, and the SET configures
408 // the session for that single execution. Smart-split runs the
409 // preamble via `batch_execute` (state persists on the wire),
410 // then routes the main statement through the cursor path
411 // exactly as a single-statement query would. If the split
412 // doesn't produce a clean main statement we fall back to the
413 // bulk path.
414 if is_multi_statement(sql) {
415 if let Some((preamble, main)) = split_at_last_statement(sql) {
416 // Preamble runs first. If it errors we surface the error
417 // — the user's `SET` failing matters and shouldn't be
418 // silently swallowed by then running the SELECT.
419 client
420 .batch_execute(preamble)
421 .await
422 .map_err(PgError::Driver)?;
423 // Recurse with the single-statement main. `previous` was
424 // already cleaned up at the top of this function, so
425 // pass `None` to avoid a redundant cleanup attempt.
426 return Box::pin(open_query(client, main, page_size, None)).await;
427 }
428 let outcome = run_multi_statement(client, sql, page_size).await?;
429 return Ok((outcome, None));
430 }
431
432 // Sniff whether the user statement is row-returning by trying a
433 // cursor declaration. If it isn't, Postgres returns SQLSTATE
434 // 42601 ("syntax error at or near 'INSERT'") or similar — but
435 // more reliably, we check the prepared statement's columns.
436 //
437 // Easier: optimistically run as a cursor. If `DECLARE` fails with
438 // `34000` (cursor on a query that returns no result set), or the
439 // server replies `0A000` ("DECLARE CURSOR can only be used in
440 // transaction blocks" — won't happen since we BEGIN first), fall
441 // back to plain `batch_execute`.
442 //
443 // Prepared-statement introspection is the cleanest detector:
444 let stmt = client.prepare(sql).await.map_err(PgError::Driver)?;
445 let columns: Vec<ColumnMeta> = stmt
446 .columns()
447 .iter()
448 .map(|c| ColumnMeta {
449 name: c.name().to_string(),
450 type_oid: c.type_().oid(),
451 type_name: c.type_().name().to_string(),
452 })
453 .collect();
454
455 if columns.is_empty() {
456 // Non-row-returning. Run directly; no cursor needed.
457 let stream = client.simple_query(sql).await.map_err(PgError::Driver)?;
458 let rows_affected = stream.into_iter().find_map(|m| match m {
459 SimpleQueryMessage::CommandComplete(n) => Some(n),
460 _ => None,
461 });
462 return Ok((
463 ExecutionOutcome {
464 columns: vec![],
465 rows: vec![],
466 rows_affected,
467 cursor_id: None,
468 },
469 None,
470 ));
471 }
472
473 let cursor_id = format!("c_{}", Uuid::new_v4().simple());
474
475 // Open the transaction and cursor in one round trip. The user's
476 // SQL is interpolated verbatim — sanitization isn't ours to do
477 // (this is a power-user surface where users type any SQL).
478 let begin = format!("BEGIN; DECLARE {} NO SCROLL CURSOR FOR {}", cursor_id, sql);
479 if let Err(e) = client.batch_execute(&begin).await {
480 // Make sure we don't leak a half-open transaction.
481 let _ = client.batch_execute("ROLLBACK").await;
482 return Err(PgError::Driver(e));
483 }
484
485 // Fetch the first page.
486 let fetch_sql = format!("FETCH FORWARD {} FROM {}", page_size, cursor_id);
487 let stream = match client.simple_query(&fetch_sql).await {
488 Ok(s) => s,
489 Err(e) => {
490 let _ = client.batch_execute("ROLLBACK").await;
491 return Err(PgError::Driver(e));
492 }
493 };
494
495 let (rows, fetched) = collect_rows(stream, columns.len());
496
497 // CommandComplete reports how many rows the FETCH returned. If
498 // it's strictly less than the requested page_size, we know the
499 // cursor has exhausted; close immediately.
500 if fetched < page_size {
501 let _ = client
502 .batch_execute(&format!("CLOSE {}; COMMIT", cursor_id))
503 .await;
504 return Ok((
505 ExecutionOutcome {
506 columns,
507 rows,
508 rows_affected: Some(fetched as u64),
509 cursor_id: None,
510 },
511 None,
512 ));
513 }
514
515 // Cursor remains open server-side. Caller will fetch_page or
516 // close_query.
517 let active = ActiveCursor {
518 cursor_id: cursor_id.clone(),
519 column_count: columns.len(),
520 };
521 Ok((
522 ExecutionOutcome {
523 columns,
524 rows,
525 rows_affected: Some(fetched as u64),
526 cursor_id: Some(cursor_id),
527 },
528 Some(active),
529 ))
530}
531
532/// Fetch the next page from an active cursor.
533pub async fn fetch_page(
534 client: &Client,
535 cursor: &ActiveCursor,
536 count: usize,
537) -> Result<PageResult, PgError> {
538 let sql = format!("FETCH FORWARD {} FROM {}", count, cursor.cursor_id);
539 let stream = client.simple_query(&sql).await.map_err(PgError::Driver)?;
540 let (rows, fetched) = collect_rows(stream, cursor.column_count);
541 Ok(PageResult {
542 rows,
543 has_more: fetched == count,
544 })
545}
546
547/// Close an active cursor and end its transaction. Best effort — if
548/// the connection is already broken we don't surface an error to
549/// callers, since "the result is gone" is the expected interpretation.
550pub async fn close_query(client: &Client, cursor: &ActiveCursor) {
551 let sql = format!("CLOSE {}; COMMIT", cursor.cursor_id);
552 if let Err(e) = client.batch_execute(&sql).await {
553 tracing::debug!(
554 cursor = %cursor.cursor_id,
555 error = %e,
556 "cursor close failed; session likely already broken"
557 );
558 // Try to leave the session usable for the next query.
559 let _ = client.batch_execute("ROLLBACK").await;
560 }
561}
562
563/// Drain a `simple_query` stream into row vectors. Returns the rows
564/// plus the count reported by the server's `CommandComplete` (which
565/// is the canonical "did we get a full page?" signal).
566fn collect_rows(
567 stream: Vec<SimpleQueryMessage>,
568 width: usize,
569) -> (Vec<Vec<Option<String>>>, usize) {
570 let mut rows: Vec<Vec<Option<String>>> = Vec::new();
571 let mut fetched = 0usize;
572 for msg in stream {
573 match msg {
574 SimpleQueryMessage::Row(row) => {
575 let mut values: Vec<Option<String>> = Vec::with_capacity(width);
576 for idx in 0..width {
577 values.push(row.get(idx).map(str::to_string));
578 }
579 rows.push(values);
580 }
581 SimpleQueryMessage::CommandComplete(n) => {
582 fetched = n as usize;
583 }
584 _ => {}
585 }
586 }
587 (rows, fetched)
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn execution_outcome_round_trips() {
596 let out = ExecutionOutcome {
597 columns: vec![ColumnMeta {
598 name: "id".to_string(),
599 type_oid: 23, // int4
600 type_name: "int4".to_string(),
601 }],
602 rows: vec![vec![Some("1".to_string())], vec![None]],
603 rows_affected: Some(2),
604 cursor_id: Some("c_abc".to_string()),
605 };
606 let json = serde_json::to_string(&out).expect("serialize");
607 let back: ExecutionOutcome = serde_json::from_str(&json).expect("deserialize");
608 assert_eq!(back.columns.len(), 1);
609 assert_eq!(back.columns[0].type_oid, 23);
610 assert_eq!(back.columns[0].type_name, "int4");
611 assert_eq!(back.rows.len(), 2);
612 assert_eq!(back.cursor_id.as_deref(), Some("c_abc"));
613 }
614
615 #[test]
616 fn page_result_round_trips() {
617 let p = PageResult {
618 rows: vec![vec![Some("x".into())]],
619 has_more: false,
620 };
621 let json = serde_json::to_string(&p).expect("serialize");
622 let back: PageResult = serde_json::from_str(&json).expect("deserialize");
623 assert_eq!(back.rows.len(), 1);
624 assert!(!back.has_more);
625 }
626
627 #[test]
628 fn multi_statement_detector_handles_common_cases() {
629 // Single statement, no semicolons.
630 assert!(!is_multi_statement("SELECT 1"));
631 // Single statement with trailing semicolon.
632 assert!(!is_multi_statement("SELECT 1;"));
633 assert!(!is_multi_statement("SELECT 1;\n"));
634 assert!(!is_multi_statement("SELECT 1;\n \n"));
635 // Multi-statement.
636 assert!(is_multi_statement("SET x = 1; SELECT 1"));
637 assert!(is_multi_statement("BEGIN; UPDATE t SET v=1; COMMIT;"));
638 }
639
640 #[test]
641 fn multi_statement_detector_ignores_semicolons_in_strings() {
642 // Single quote literals.
643 assert!(!is_multi_statement("SELECT 'hello; world'"));
644 assert!(!is_multi_statement("INSERT INTO t VALUES ('a;b;c')"));
645 // Escaped single quote inside literal.
646 assert!(!is_multi_statement("SELECT 'it''s; fine'"));
647 // Real split despite earlier in-string semicolon.
648 assert!(is_multi_statement("SELECT 'a;b'; SELECT 1"));
649 }
650
651 #[test]
652 fn multi_statement_detector_ignores_semicolons_in_identifiers() {
653 // Quoted identifier containing a semicolon — unusual but valid.
654 assert!(!is_multi_statement("SELECT \"weird;name\" FROM t"));
655 assert!(is_multi_statement("SELECT \"col\"; SELECT 1"));
656 }
657
658 #[test]
659 fn multi_statement_detector_ignores_semicolons_in_comments() {
660 // Line comments.
661 assert!(!is_multi_statement("SELECT 1 -- ; comment\n"));
662 // Block comments.
663 assert!(!is_multi_statement("SELECT 1 /* ; */ FROM t"));
664 assert!(!is_multi_statement("/* ; */ SELECT 1"));
665 // Real split despite earlier in-comment semicolon.
666 assert!(is_multi_statement("SELECT 1 -- ;\n; SELECT 2"));
667 }
668
669 #[test]
670 fn smart_split_returns_preamble_and_main() {
671 let (pre, main) = split_at_last_statement("SET x = 1; SELECT * FROM t").expect("split");
672 assert_eq!(pre, "SET x = 1;");
673 assert_eq!(main, "SELECT * FROM t");
674 }
675
676 #[test]
677 fn smart_split_handles_multiple_preamble_statements() {
678 let (pre, main) = split_at_last_statement("SET x = 1; SET y = 2; SELECT 1").expect("split");
679 assert_eq!(pre, "SET x = 1; SET y = 2;");
680 assert_eq!(main, "SELECT 1");
681 }
682
683 #[test]
684 fn smart_split_returns_none_when_no_main_statement_after_delimiter() {
685 // Trailing semicolon with nothing real after — there's no
686 // separable "main"; caller falls back to bulk multi-statement.
687 assert!(split_at_last_statement("SET x = 1; SELECT 1;").is_none());
688 assert!(split_at_last_statement("SET x = 1;").is_none());
689 // Comment-only tail.
690 assert!(split_at_last_statement("SET x = 1; -- trailing\n").is_none());
691 }
692
693 #[test]
694 fn smart_split_ignores_in_string_semicolons() {
695 let (pre, main) = split_at_last_statement("SET x = 'a;b'; SELECT 1").expect("split");
696 assert_eq!(pre, "SET x = 'a;b';");
697 assert_eq!(main, "SELECT 1");
698 }
699
700 #[test]
701 fn smart_split_returns_none_for_single_statement() {
702 assert!(split_at_last_statement("SELECT 1").is_none());
703 assert!(split_at_last_statement("SELECT 1;").is_none());
704 }
705}