Skip to main content

ssh_commander_pg/
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 the pool's per-connection lease
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::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    top_level_semicolons(sql)
99        .into_iter()
100        .any(|idx| !is_effectively_empty(&sql[idx + 1..]))
101}
102
103/// Split a multi-statement script into `(preamble, main)` where
104/// `main` is the last top-level statement and `preamble` is
105/// everything before it. Returns `None` when no clean split is
106/// available (script is single-statement or the trailing piece is
107/// blank/comment-only after the last delimiter).
108///
109/// The point of the split is to let the caller run `preamble` via
110/// `batch_execute` (which preserves the SET/SHOW/etc effects) and
111/// then run `main` through the cursor path so pagination works on
112/// the SELECT that the user actually cares about.
113pub(crate) fn split_at_last_statement(sql: &str) -> Option<(&str, &str)> {
114    let split = top_level_semicolons(sql).into_iter().last()?;
115    let main = &sql[split + 1..];
116    if is_effectively_empty(main) {
117        // Trailing semicolon with nothing real after — possibly just
118        // whitespace, a line comment, or a block comment. Caller
119        // falls back to the bulk multi-statement path.
120        return None;
121    }
122    let main = main.trim();
123    let preamble = &sql[..split + 1]; // include the delimiter
124    // Refuse to split if the "main" itself contains an unguarded `;`
125    // — defensive: the main piece must be a single statement so the
126    // cursor path's `prepare` accepts it.
127    if is_multi_statement(main) {
128        return None;
129    }
130    Some((preamble, main))
131}
132
133fn top_level_semicolons(sql: &str) -> Vec<usize> {
134    enum LexState<'a> {
135        Normal,
136        SingleQuote,
137        DoubleQuote,
138        LineComment,
139        BlockComment,
140        DollarQuote(&'a str),
141    }
142
143    let bytes = sql.as_bytes();
144    let mut positions = Vec::new();
145    let mut i = 0usize;
146    let mut state = LexState::Normal;
147    while i < bytes.len() {
148        let c = bytes[i];
149        match state {
150            LexState::Normal => match c {
151                b'\'' => state = LexState::SingleQuote,
152                b'"' => state = LexState::DoubleQuote,
153                b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => {
154                    state = LexState::LineComment;
155                    i += 1;
156                }
157                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
158                    state = LexState::BlockComment;
159                    i += 1;
160                }
161                b'$' => {
162                    if let Some(delimiter) = dollar_quote_delimiter_at(sql, i) {
163                        state = LexState::DollarQuote(delimiter);
164                        i += delimiter.len() - 1;
165                    }
166                }
167                b';' => positions.push(i),
168                _ => {}
169            },
170            LexState::SingleQuote => {
171                if c == b'\'' {
172                    if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
173                        i += 1;
174                    } else {
175                        state = LexState::Normal;
176                    }
177                }
178            }
179            LexState::DoubleQuote => {
180                if c == b'"' {
181                    if i + 1 < bytes.len() && bytes[i + 1] == b'"' {
182                        i += 1;
183                    } else {
184                        state = LexState::Normal;
185                    }
186                }
187            }
188            LexState::LineComment => {
189                if c == b'\n' {
190                    state = LexState::Normal;
191                }
192            }
193            LexState::BlockComment => {
194                if c == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
195                    state = LexState::Normal;
196                    i += 1;
197                }
198            }
199            LexState::DollarQuote(delimiter) => {
200                if sql[i..].starts_with(delimiter) {
201                    state = LexState::Normal;
202                    i += delimiter.len() - 1;
203                }
204            }
205        }
206        i += 1;
207    }
208    positions
209}
210
211fn dollar_quote_delimiter_at(sql: &str, start: usize) -> Option<&str> {
212    let bytes = sql.as_bytes();
213    if bytes.get(start) != Some(&b'$') {
214        return None;
215    }
216    let mut end = start + 1;
217    match bytes.get(end) {
218        Some(b'$') => return Some(&sql[start..=end]),
219        Some(b) if b.is_ascii_alphabetic() || *b == b'_' => end += 1,
220        _ => return None,
221    }
222    while let Some(b) = bytes.get(end)
223        && (b.is_ascii_alphanumeric() || *b == b'_')
224    {
225        end += 1;
226    }
227    if bytes.get(end) == Some(&b'$') {
228        Some(&sql[start..=end])
229    } else {
230        None
231    }
232}
233
234/// Whether `sql` is whitespace + comments only — i.e. has no real
235/// SQL token. Used by the smart splitter to detect "trailing noise"
236/// (a comment after the final `;`) and treat it as no-main-statement.
237fn is_effectively_empty(sql: &str) -> bool {
238    enum LexState {
239        Normal,
240        LineComment,
241        BlockComment,
242    }
243    let bytes = sql.as_bytes();
244    let mut i = 0usize;
245    let mut state = LexState::Normal;
246    while i < bytes.len() {
247        let c = bytes[i];
248        match state {
249            LexState::Normal => match c {
250                b' ' | b'\t' | b'\n' | b'\r' => {}
251                b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => {
252                    state = LexState::LineComment;
253                    i += 1;
254                }
255                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
256                    state = LexState::BlockComment;
257                    i += 1;
258                }
259                _ => return false,
260            },
261            LexState::LineComment => {
262                if c == b'\n' {
263                    state = LexState::Normal;
264                }
265            }
266            LexState::BlockComment => {
267                if c == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
268                    state = LexState::Normal;
269                    i += 1;
270                }
271            }
272        }
273        i += 1;
274    }
275    true
276}
277
278/// Run a multi-statement script via `simple_query` and return the
279/// last row-returning result. No cursor pagination here — Postgres
280/// implicit transactions don't compose cleanly with cursor declarations
281/// inside user-supplied script bodies, and `prepare` rejects
282/// multi-statement input. Result is capped at `page_size` rows; the
283/// UI shows "more rows discarded" naturally because `cursor_id` is
284/// `None` (no "Load more" affordance offered).
285async fn run_multi_statement(
286    client: &Client,
287    sql: &str,
288    page_size: usize,
289) -> Result<ExecutionOutcome, PgError> {
290    let stream = client.simple_query(sql).await.map_err(PgError::Driver)?;
291
292    let mut current_columns: Vec<ColumnMeta> = vec![];
293    let mut current_rows: Vec<Vec<Option<String>>> = vec![];
294    let mut last_command_complete: Option<u64> = None;
295
296    for msg in stream {
297        match msg {
298            SimpleQueryMessage::RowDescription(desc) => {
299                // A new result block starts here. Discard any
300                // previously-collected rows so we end up keeping
301                // the LAST row-returning statement's output —
302                // matches DataGrip-style behavior where the user
303                // sees the result of `SET …; SELECT …`.
304                //
305                // OIDs aren't exposed via simple_query; type names
306                // are blank. The grid falls back to default
307                // alignment / formatting.
308                current_columns = desc
309                    .iter()
310                    .map(|c| ColumnMeta {
311                        name: c.name().to_string(),
312                        type_oid: 0,
313                        type_name: String::new(),
314                    })
315                    .collect();
316                current_rows.clear();
317            }
318            SimpleQueryMessage::Row(row) => {
319                if current_rows.len() >= page_size {
320                    // Continue draining the stream so the connection
321                    // doesn't end up with buffered server messages,
322                    // but ignore the surplus rows.
323                    continue;
324                }
325                let width = current_columns.len();
326                let mut cells = Vec::with_capacity(width);
327                for idx in 0..width {
328                    cells.push(row.get(idx).map(str::to_string));
329                }
330                current_rows.push(cells);
331            }
332            SimpleQueryMessage::CommandComplete(n) => {
333                last_command_complete = Some(n);
334            }
335            _ => {}
336        }
337    }
338
339    Ok(ExecutionOutcome {
340        columns: current_columns,
341        rows: current_rows,
342        rows_affected: last_command_complete,
343        cursor_id: None,
344    })
345}
346
347/// Open a new query. Closes any previously-active cursor (and its
348/// transaction) before starting. Returns the first page synchronously
349/// along with a cursor id when more rows remain.
350///
351/// `page_size` is the soft window for the first page. Non-row-returning
352/// statements (DDL, INSERT without RETURNING) skip the cursor path
353/// entirely — they run via `batch_execute` and report rows_affected.
354/// Multi-statement scripts (`SET …; SELECT …` etc) run via
355/// `simple_query`; the last row-returning statement's result is
356/// surfaced and cursor pagination isn't offered.
357pub async fn open_query(
358    client: &Client,
359    sql: &str,
360    page_size: usize,
361    previous: Option<ActiveCursor>,
362) -> Result<(ExecutionOutcome, Option<ActiveCursor>), PgError> {
363    // Best-effort cleanup of any existing cursor. If the previous
364    // transaction is already in a bad state (rollback pending), this
365    // may fail — we log and continue. The new BEGIN below will reset
366    // session state by aborting the abandoned transaction.
367    if let Some(prev) = previous.as_ref() {
368        let cleanup = format!("CLOSE {}; COMMIT", prev.cursor_id);
369        if let Err(e) = client.batch_execute(&cleanup).await {
370            tracing::debug!(
371                cursor = %prev.cursor_id,
372                error = %e,
373                "previous cursor cleanup failed; continuing with ROLLBACK"
374            );
375            // Force the session out of any half-open transaction state.
376            let _ = client.batch_execute("ROLLBACK").await;
377        }
378    }
379
380    // Multi-statement scripts can't be `prepare`d (the extended
381    // protocol's Parse message rejects multi-command input).
382    //
383    // Common pattern: `SET statement_timeout = …; SELECT …` — the
384    // user wants pagination on the SELECT, and the SET configures
385    // the session for that single execution. Smart-split runs the
386    // preamble via `batch_execute` (state persists on the wire),
387    // then routes the main statement through the cursor path
388    // exactly as a single-statement query would. If the split
389    // doesn't produce a clean main statement we fall back to the
390    // bulk path.
391    if is_multi_statement(sql) {
392        if let Some((preamble, main)) = split_at_last_statement(sql) {
393            // Preamble runs first. If it errors we surface the error
394            // — the user's `SET` failing matters and shouldn't be
395            // silently swallowed by then running the SELECT.
396            client
397                .batch_execute(preamble)
398                .await
399                .map_err(PgError::Driver)?;
400            // Recurse with the single-statement main. `previous` was
401            // already cleaned up at the top of this function, so
402            // pass `None` to avoid a redundant cleanup attempt.
403            return Box::pin(open_query(client, main, page_size, None)).await;
404        }
405        let outcome = run_multi_statement(client, sql, page_size).await?;
406        return Ok((outcome, None));
407    }
408
409    // Sniff whether the user statement is row-returning by trying a
410    // cursor declaration. If it isn't, Postgres returns SQLSTATE
411    // 42601 ("syntax error at or near 'INSERT'") or similar — but
412    // more reliably, we check the prepared statement's columns.
413    //
414    // Easier: optimistically run as a cursor. If `DECLARE` fails with
415    // `34000` (cursor on a query that returns no result set), or the
416    // server replies `0A000` ("DECLARE CURSOR can only be used in
417    // transaction blocks" — won't happen since we BEGIN first), fall
418    // back to plain `batch_execute`.
419    //
420    // Prepared-statement introspection is the cleanest detector:
421    let stmt = client.prepare(sql).await.map_err(PgError::Driver)?;
422    let columns: Vec<ColumnMeta> = stmt
423        .columns()
424        .iter()
425        .map(|c| ColumnMeta {
426            name: c.name().to_string(),
427            type_oid: c.type_().oid(),
428            type_name: c.type_().name().to_string(),
429        })
430        .collect();
431
432    if columns.is_empty() {
433        // Non-row-returning. Run directly; no cursor needed.
434        let stream = client.simple_query(sql).await.map_err(PgError::Driver)?;
435        let rows_affected = stream.into_iter().find_map(|m| match m {
436            SimpleQueryMessage::CommandComplete(n) => Some(n),
437            _ => None,
438        });
439        return Ok((
440            ExecutionOutcome {
441                columns: vec![],
442                rows: vec![],
443                rows_affected,
444                cursor_id: None,
445            },
446            None,
447        ));
448    }
449
450    let cursor_id = format!("c_{}", Uuid::new_v4().simple());
451
452    // Open the transaction and cursor in one round trip. The user's
453    // SQL is interpolated verbatim — sanitization isn't ours to do
454    // (this is a power-user surface where users type any SQL).
455    let begin = format!("BEGIN; DECLARE {} NO SCROLL CURSOR FOR {}", cursor_id, sql);
456    if let Err(e) = client.batch_execute(&begin).await {
457        // Make sure we don't leak a half-open transaction.
458        let _ = client.batch_execute("ROLLBACK").await;
459        return Err(PgError::Driver(e));
460    }
461
462    // Fetch the first page.
463    let fetch_sql = format!("FETCH FORWARD {} FROM {}", page_size, cursor_id);
464    let stream = match client.simple_query(&fetch_sql).await {
465        Ok(s) => s,
466        Err(e) => {
467            let _ = client.batch_execute("ROLLBACK").await;
468            return Err(PgError::Driver(e));
469        }
470    };
471
472    let (rows, fetched) = collect_rows(stream, columns.len());
473
474    // CommandComplete reports how many rows the FETCH returned. If
475    // it's strictly less than the requested page_size, we know the
476    // cursor has exhausted; close immediately.
477    if fetched < page_size {
478        let _ = client
479            .batch_execute(&format!("CLOSE {}; COMMIT", cursor_id))
480            .await;
481        return Ok((
482            ExecutionOutcome {
483                columns,
484                rows,
485                rows_affected: Some(fetched as u64),
486                cursor_id: None,
487            },
488            None,
489        ));
490    }
491
492    // Cursor remains open server-side. Caller will fetch_page or
493    // close_query.
494    let active = ActiveCursor {
495        cursor_id: cursor_id.clone(),
496        column_count: columns.len(),
497    };
498    Ok((
499        ExecutionOutcome {
500            columns,
501            rows,
502            rows_affected: Some(fetched as u64),
503            cursor_id: Some(cursor_id),
504        },
505        Some(active),
506    ))
507}
508
509/// Fetch the next page from an active cursor.
510pub async fn fetch_page(
511    client: &Client,
512    cursor: &ActiveCursor,
513    count: usize,
514) -> Result<PageResult, PgError> {
515    let sql = format!("FETCH FORWARD {} FROM {}", count, cursor.cursor_id);
516    let stream = client.simple_query(&sql).await.map_err(PgError::Driver)?;
517    let (rows, fetched) = collect_rows(stream, cursor.column_count);
518    Ok(PageResult {
519        rows,
520        has_more: fetched == count,
521    })
522}
523
524/// Close an active cursor and end its transaction. Best effort — if
525/// the connection is already broken we don't surface an error to
526/// callers, since "the result is gone" is the expected interpretation.
527pub async fn close_query(client: &Client, cursor: &ActiveCursor) {
528    let sql = format!("CLOSE {}; COMMIT", cursor.cursor_id);
529    if let Err(e) = client.batch_execute(&sql).await {
530        tracing::debug!(
531            cursor = %cursor.cursor_id,
532            error = %e,
533            "cursor close failed; session likely already broken"
534        );
535        // Try to leave the session usable for the next query.
536        let _ = client.batch_execute("ROLLBACK").await;
537    }
538}
539
540/// Drain a `simple_query` stream into row vectors. Returns the rows
541/// plus the count reported by the server's `CommandComplete` (which
542/// is the canonical "did we get a full page?" signal).
543fn collect_rows(
544    stream: Vec<SimpleQueryMessage>,
545    width: usize,
546) -> (Vec<Vec<Option<String>>>, usize) {
547    let mut rows: Vec<Vec<Option<String>>> = Vec::new();
548    let mut fetched = 0usize;
549    for msg in stream {
550        match msg {
551            SimpleQueryMessage::Row(row) => {
552                let mut values: Vec<Option<String>> = Vec::with_capacity(width);
553                for idx in 0..width {
554                    values.push(row.get(idx).map(str::to_string));
555                }
556                rows.push(values);
557            }
558            SimpleQueryMessage::CommandComplete(n) => {
559                fetched = n as usize;
560            }
561            _ => {}
562        }
563    }
564    (rows, fetched)
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use proptest::prelude::*;
571
572    #[test]
573    fn execution_outcome_round_trips() {
574        let out = ExecutionOutcome {
575            columns: vec![ColumnMeta {
576                name: "id".to_string(),
577                type_oid: 23, // int4
578                type_name: "int4".to_string(),
579            }],
580            rows: vec![vec![Some("1".to_string())], vec![None]],
581            rows_affected: Some(2),
582            cursor_id: Some("c_abc".to_string()),
583        };
584        let json = serde_json::to_string(&out).expect("serialize");
585        let back: ExecutionOutcome = serde_json::from_str(&json).expect("deserialize");
586        assert_eq!(back.columns.len(), 1);
587        assert_eq!(back.columns[0].type_oid, 23);
588        assert_eq!(back.columns[0].type_name, "int4");
589        assert_eq!(back.rows.len(), 2);
590        assert_eq!(back.cursor_id.as_deref(), Some("c_abc"));
591    }
592
593    #[test]
594    fn page_result_round_trips() {
595        let p = PageResult {
596            rows: vec![vec![Some("x".into())]],
597            has_more: false,
598        };
599        let json = serde_json::to_string(&p).expect("serialize");
600        let back: PageResult = serde_json::from_str(&json).expect("deserialize");
601        assert_eq!(back.rows.len(), 1);
602        assert!(!back.has_more);
603    }
604
605    #[test]
606    fn multi_statement_detector_handles_common_cases() {
607        // Single statement, no semicolons.
608        assert!(!is_multi_statement("SELECT 1"));
609        // Single statement with trailing semicolon.
610        assert!(!is_multi_statement("SELECT 1;"));
611        assert!(!is_multi_statement("SELECT 1;\n"));
612        assert!(!is_multi_statement("SELECT 1;\n  \n"));
613        // Multi-statement.
614        assert!(is_multi_statement("SET x = 1; SELECT 1"));
615        assert!(is_multi_statement("BEGIN; UPDATE t SET v=1; COMMIT;"));
616    }
617
618    #[test]
619    fn multi_statement_detector_ignores_semicolons_in_strings() {
620        // Single quote literals.
621        assert!(!is_multi_statement("SELECT 'hello; world'"));
622        assert!(!is_multi_statement("INSERT INTO t VALUES ('a;b;c')"));
623        // Escaped single quote inside literal.
624        assert!(!is_multi_statement("SELECT 'it''s; fine'"));
625        // Real split despite earlier in-string semicolon.
626        assert!(is_multi_statement("SELECT 'a;b'; SELECT 1"));
627    }
628
629    #[test]
630    fn multi_statement_detector_ignores_semicolons_in_identifiers() {
631        // Quoted identifier containing a semicolon — unusual but valid.
632        assert!(!is_multi_statement("SELECT \"weird;name\" FROM t"));
633        assert!(is_multi_statement("SELECT \"col\"; SELECT 1"));
634    }
635
636    #[test]
637    fn multi_statement_detector_ignores_semicolons_in_comments() {
638        // Line comments.
639        assert!(!is_multi_statement("SELECT 1 -- ; comment\n"));
640        // Block comments.
641        assert!(!is_multi_statement("SELECT 1 /* ; */ FROM t"));
642        assert!(!is_multi_statement("/* ; */ SELECT 1"));
643        assert!(!is_multi_statement("SELECT 1; -- trailing comment\n"));
644        // Real split despite earlier in-comment semicolon.
645        assert!(is_multi_statement("SELECT 1 -- ;\n; SELECT 2"));
646    }
647
648    #[test]
649    fn multi_statement_detector_ignores_semicolons_in_dollar_quotes() {
650        assert!(!is_multi_statement("SELECT $$hello; world$$"));
651        assert!(!is_multi_statement("SELECT $tag$hello; world$tag$"));
652        assert!(is_multi_statement("SELECT $$hello; world$$; SELECT 1"));
653    }
654
655    #[test]
656    fn smart_split_returns_preamble_and_main() {
657        let (pre, main) = split_at_last_statement("SET x = 1; SELECT * FROM t").expect("split");
658        assert_eq!(pre, "SET x = 1;");
659        assert_eq!(main, "SELECT * FROM t");
660    }
661
662    #[test]
663    fn smart_split_handles_multiple_preamble_statements() {
664        let (pre, main) = split_at_last_statement("SET x = 1; SET y = 2; SELECT 1").expect("split");
665        assert_eq!(pre, "SET x = 1; SET y = 2;");
666        assert_eq!(main, "SELECT 1");
667    }
668
669    #[test]
670    fn smart_split_returns_none_when_no_main_statement_after_delimiter() {
671        // Trailing semicolon with nothing real after — there's no
672        // separable "main"; caller falls back to bulk multi-statement.
673        assert!(split_at_last_statement("SET x = 1; SELECT 1;").is_none());
674        assert!(split_at_last_statement("SET x = 1;").is_none());
675        // Comment-only tail.
676        assert!(split_at_last_statement("SET x = 1; -- trailing\n").is_none());
677    }
678
679    #[test]
680    fn smart_split_ignores_in_string_semicolons() {
681        let (pre, main) = split_at_last_statement("SET x = 'a;b'; SELECT 1").expect("split");
682        assert_eq!(pre, "SET x = 'a;b';");
683        assert_eq!(main, "SELECT 1");
684    }
685
686    #[test]
687    fn smart_split_ignores_dollar_quoted_semicolons() {
688        let (pre, main) =
689            split_at_last_statement("SELECT $body$a;b$body$; SELECT 1").expect("split");
690        assert_eq!(pre, "SELECT $body$a;b$body$;");
691        assert_eq!(main, "SELECT 1");
692
693        let function = "CREATE FUNCTION f() RETURNS int AS $$ BEGIN; RETURN 1; END; $$ LANGUAGE plpgsql; SELECT f()";
694        let (pre, main) = split_at_last_statement(function).expect("split");
695        assert_eq!(
696            pre,
697            "CREATE FUNCTION f() RETURNS int AS $$ BEGIN; RETURN 1; END; $$ LANGUAGE plpgsql;"
698        );
699        assert_eq!(main, "SELECT f()");
700    }
701
702    #[test]
703    fn smart_split_returns_none_for_single_statement() {
704        assert!(split_at_last_statement("SELECT 1").is_none());
705        assert!(split_at_last_statement("SELECT 1;").is_none());
706    }
707
708    proptest! {
709        #[test]
710        fn dollar_quoted_semicolons_do_not_create_false_splits(body in "[A-Za-z0-9_ ;,()\\n]{0,128}") {
711            let sql = format!("SELECT $$ {body} $$");
712            prop_assert!(!is_multi_statement(&sql));
713            prop_assert!(split_at_last_statement(&sql).is_none());
714        }
715
716        #[test]
717        fn trailing_comment_after_semicolon_is_still_single_statement(comment in "[A-Za-z0-9_ ;,()]{0,128}") {
718            let sql = format!("SELECT 1; -- {comment}");
719            prop_assert!(!is_multi_statement(&sql));
720            prop_assert!(split_at_last_statement(&sql).is_none());
721        }
722    }
723}