Skip to main content

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}