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        // Some row-returning statements cannot be wrapped in a cursor —
460        // notably `INSERT/UPDATE/DELETE ... RETURNING`, which `prepare`
461        // reports as having columns yet `DECLARE CURSOR FOR` rejects with
462        // SQLSTATE 42601. The DECLARE fails at parse time, so the statement
463        // itself never ran: re-run it directly via `simple_query` and
464        // return its rows as a single, non-paginated page.
465        if e.code() == Some(&tokio_postgres::error::SqlState::SYNTAX_ERROR) {
466            let outcome = run_multi_statement(client, sql, page_size).await?;
467            return Ok((outcome, None));
468        }
469        return Err(PgError::Driver(e));
470    }
471
472    // Fetch the first page.
473    let fetch_sql = format!("FETCH FORWARD {} FROM {}", page_size, cursor_id);
474    let stream = match client.simple_query(&fetch_sql).await {
475        Ok(s) => s,
476        Err(e) => {
477            let _ = client.batch_execute("ROLLBACK").await;
478            return Err(PgError::Driver(e));
479        }
480    };
481
482    let (rows, fetched) = collect_rows(stream, columns.len());
483
484    // CommandComplete reports how many rows the FETCH returned. If
485    // it's strictly less than the requested page_size, we know the
486    // cursor has exhausted; close immediately.
487    if fetched < page_size {
488        let _ = client
489            .batch_execute(&format!("CLOSE {}; COMMIT", cursor_id))
490            .await;
491        return Ok((
492            ExecutionOutcome {
493                columns,
494                rows,
495                rows_affected: Some(fetched as u64),
496                cursor_id: None,
497            },
498            None,
499        ));
500    }
501
502    // Cursor remains open server-side. Caller will fetch_page or
503    // close_query.
504    let active = ActiveCursor {
505        cursor_id: cursor_id.clone(),
506        column_count: columns.len(),
507    };
508    Ok((
509        ExecutionOutcome {
510            columns,
511            rows,
512            rows_affected: Some(fetched as u64),
513            cursor_id: Some(cursor_id),
514        },
515        Some(active),
516    ))
517}
518
519/// Fetch the next page from an active cursor.
520pub async fn fetch_page(
521    client: &Client,
522    cursor: &ActiveCursor,
523    count: usize,
524) -> Result<PageResult, PgError> {
525    let sql = format!("FETCH FORWARD {} FROM {}", count, cursor.cursor_id);
526    let stream = client.simple_query(&sql).await.map_err(PgError::Driver)?;
527    let (rows, fetched) = collect_rows(stream, cursor.column_count);
528    Ok(PageResult {
529        rows,
530        has_more: fetched == count,
531    })
532}
533
534/// Close an active cursor and end its transaction. Best effort — if
535/// the connection is already broken we don't surface an error to
536/// callers, since "the result is gone" is the expected interpretation.
537pub async fn close_query(client: &Client, cursor: &ActiveCursor) {
538    let sql = format!("CLOSE {}; COMMIT", cursor.cursor_id);
539    if let Err(e) = client.batch_execute(&sql).await {
540        tracing::debug!(
541            cursor = %cursor.cursor_id,
542            error = %e,
543            "cursor close failed; session likely already broken"
544        );
545        // Try to leave the session usable for the next query.
546        let _ = client.batch_execute("ROLLBACK").await;
547    }
548}
549
550/// Drain a `simple_query` stream into row vectors. Returns the rows
551/// plus the count reported by the server's `CommandComplete` (which
552/// is the canonical "did we get a full page?" signal).
553fn collect_rows(
554    stream: Vec<SimpleQueryMessage>,
555    width: usize,
556) -> (Vec<Vec<Option<String>>>, usize) {
557    let mut rows: Vec<Vec<Option<String>>> = Vec::new();
558    let mut fetched = 0usize;
559    for msg in stream {
560        match msg {
561            SimpleQueryMessage::Row(row) => {
562                let mut values: Vec<Option<String>> = Vec::with_capacity(width);
563                for idx in 0..width {
564                    values.push(row.get(idx).map(str::to_string));
565                }
566                rows.push(values);
567            }
568            SimpleQueryMessage::CommandComplete(n) => {
569                fetched = n as usize;
570            }
571            _ => {}
572        }
573    }
574    (rows, fetched)
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use proptest::prelude::*;
581
582    #[test]
583    fn execution_outcome_round_trips() {
584        let out = ExecutionOutcome {
585            columns: vec![ColumnMeta {
586                name: "id".to_string(),
587                type_oid: 23, // int4
588                type_name: "int4".to_string(),
589            }],
590            rows: vec![vec![Some("1".to_string())], vec![None]],
591            rows_affected: Some(2),
592            cursor_id: Some("c_abc".to_string()),
593        };
594        let json = serde_json::to_string(&out).expect("serialize");
595        let back: ExecutionOutcome = serde_json::from_str(&json).expect("deserialize");
596        assert_eq!(back.columns.len(), 1);
597        assert_eq!(back.columns[0].type_oid, 23);
598        assert_eq!(back.columns[0].type_name, "int4");
599        assert_eq!(back.rows.len(), 2);
600        assert_eq!(back.cursor_id.as_deref(), Some("c_abc"));
601    }
602
603    #[test]
604    fn page_result_round_trips() {
605        let p = PageResult {
606            rows: vec![vec![Some("x".into())]],
607            has_more: false,
608        };
609        let json = serde_json::to_string(&p).expect("serialize");
610        let back: PageResult = serde_json::from_str(&json).expect("deserialize");
611        assert_eq!(back.rows.len(), 1);
612        assert!(!back.has_more);
613    }
614
615    #[test]
616    fn multi_statement_detector_handles_common_cases() {
617        // Single statement, no semicolons.
618        assert!(!is_multi_statement("SELECT 1"));
619        // Single statement with trailing semicolon.
620        assert!(!is_multi_statement("SELECT 1;"));
621        assert!(!is_multi_statement("SELECT 1;\n"));
622        assert!(!is_multi_statement("SELECT 1;\n  \n"));
623        // Multi-statement.
624        assert!(is_multi_statement("SET x = 1; SELECT 1"));
625        assert!(is_multi_statement("BEGIN; UPDATE t SET v=1; COMMIT;"));
626    }
627
628    #[test]
629    fn multi_statement_detector_ignores_semicolons_in_strings() {
630        // Single quote literals.
631        assert!(!is_multi_statement("SELECT 'hello; world'"));
632        assert!(!is_multi_statement("INSERT INTO t VALUES ('a;b;c')"));
633        // Escaped single quote inside literal.
634        assert!(!is_multi_statement("SELECT 'it''s; fine'"));
635        // Real split despite earlier in-string semicolon.
636        assert!(is_multi_statement("SELECT 'a;b'; SELECT 1"));
637    }
638
639    #[test]
640    fn multi_statement_detector_ignores_semicolons_in_identifiers() {
641        // Quoted identifier containing a semicolon — unusual but valid.
642        assert!(!is_multi_statement("SELECT \"weird;name\" FROM t"));
643        assert!(is_multi_statement("SELECT \"col\"; SELECT 1"));
644    }
645
646    #[test]
647    fn multi_statement_detector_ignores_semicolons_in_comments() {
648        // Line comments.
649        assert!(!is_multi_statement("SELECT 1 -- ; comment\n"));
650        // Block comments.
651        assert!(!is_multi_statement("SELECT 1 /* ; */ FROM t"));
652        assert!(!is_multi_statement("/* ; */ SELECT 1"));
653        assert!(!is_multi_statement("SELECT 1; -- trailing comment\n"));
654        // Real split despite earlier in-comment semicolon.
655        assert!(is_multi_statement("SELECT 1 -- ;\n; SELECT 2"));
656    }
657
658    #[test]
659    fn multi_statement_detector_ignores_semicolons_in_dollar_quotes() {
660        assert!(!is_multi_statement("SELECT $$hello; world$$"));
661        assert!(!is_multi_statement("SELECT $tag$hello; world$tag$"));
662        assert!(is_multi_statement("SELECT $$hello; world$$; SELECT 1"));
663    }
664
665    #[test]
666    fn smart_split_returns_preamble_and_main() {
667        let (pre, main) = split_at_last_statement("SET x = 1; SELECT * FROM t").expect("split");
668        assert_eq!(pre, "SET x = 1;");
669        assert_eq!(main, "SELECT * FROM t");
670    }
671
672    #[test]
673    fn smart_split_handles_multiple_preamble_statements() {
674        let (pre, main) = split_at_last_statement("SET x = 1; SET y = 2; SELECT 1").expect("split");
675        assert_eq!(pre, "SET x = 1; SET y = 2;");
676        assert_eq!(main, "SELECT 1");
677    }
678
679    #[test]
680    fn smart_split_returns_none_when_no_main_statement_after_delimiter() {
681        // Trailing semicolon with nothing real after — there's no
682        // separable "main"; caller falls back to bulk multi-statement.
683        assert!(split_at_last_statement("SET x = 1; SELECT 1;").is_none());
684        assert!(split_at_last_statement("SET x = 1;").is_none());
685        // Comment-only tail.
686        assert!(split_at_last_statement("SET x = 1; -- trailing\n").is_none());
687    }
688
689    #[test]
690    fn smart_split_ignores_in_string_semicolons() {
691        let (pre, main) = split_at_last_statement("SET x = 'a;b'; SELECT 1").expect("split");
692        assert_eq!(pre, "SET x = 'a;b';");
693        assert_eq!(main, "SELECT 1");
694    }
695
696    #[test]
697    fn smart_split_ignores_dollar_quoted_semicolons() {
698        let (pre, main) =
699            split_at_last_statement("SELECT $body$a;b$body$; SELECT 1").expect("split");
700        assert_eq!(pre, "SELECT $body$a;b$body$;");
701        assert_eq!(main, "SELECT 1");
702
703        let function = "CREATE FUNCTION f() RETURNS int AS $$ BEGIN; RETURN 1; END; $$ LANGUAGE plpgsql; SELECT f()";
704        let (pre, main) = split_at_last_statement(function).expect("split");
705        assert_eq!(
706            pre,
707            "CREATE FUNCTION f() RETURNS int AS $$ BEGIN; RETURN 1; END; $$ LANGUAGE plpgsql;"
708        );
709        assert_eq!(main, "SELECT f()");
710    }
711
712    #[test]
713    fn smart_split_returns_none_for_single_statement() {
714        assert!(split_at_last_statement("SELECT 1").is_none());
715        assert!(split_at_last_statement("SELECT 1;").is_none());
716    }
717
718    proptest! {
719        #[test]
720        fn dollar_quoted_semicolons_do_not_create_false_splits(body in "[A-Za-z0-9_ ;,()\\n]{0,128}") {
721            let sql = format!("SELECT $$ {body} $$");
722            prop_assert!(!is_multi_statement(&sql));
723            prop_assert!(split_at_last_statement(&sql).is_none());
724        }
725
726        #[test]
727        fn trailing_comment_after_semicolon_is_still_single_statement(comment in "[A-Za-z0-9_ ;,()]{0,128}") {
728            let sql = format!("SELECT 1; -- {comment}");
729            prop_assert!(!is_multi_statement(&sql));
730            prop_assert!(split_at_last_statement(&sql).is_none());
731        }
732    }
733}