Skip to main content

hyperdb_compile_check/
dry_run.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! `LIMIT 0` dry-run helper.
5//!
6//! Wraps arbitrary user SQL in a CTE and runs it against the shared
7//! `CompileTimeDb`, returning the `ResultSchema` without touching any rows.
8//!
9//! # Critical: query execution is lazy (Phase 0 S6)
10//!
11//! `Connection::execute_query()` does NOT run the query on the TCP transport.
12//! The query only executes — and server errors / the `RowDescription` (schema)
13//! only arrive — when `Rowset::next_chunk()` first pulls bytes. Therefore:
14//! - `execute_query(sql).is_err()` alone always looks `Ok`.
15//! - This helper calls `next_chunk()` once to force execution, then reads
16//!   `Rowset::schema()`.
17
18use hyperdb_api::{Error, Result, ResultSchema};
19
20use crate::db::CompileTimeDb;
21
22/// Wrap `user_sql` in a `LIMIT 0` CTE and return the projected `ResultSchema`.
23///
24/// Uses the `__hdb_q` CTE prefix to minimize collision with user-supplied CTE
25/// names.
26///
27/// # Errors
28///
29/// Returns the Hyper error on any failure (callers branch on SQLSTATE via
30/// [`crate::error_extract::classify`]).
31pub fn dry_run(db: &mut CompileTimeDb, user_sql: &str) -> Result<ResultSchema> {
32    let wrapped = format!("WITH __hdb_q AS ({user_sql}) SELECT * FROM __hdb_q LIMIT 0");
33    let mut rowset = db.conn.execute_query(&wrapped)?;
34
35    // Force execution (Phase 0 S6): LIMIT 0 returns Ok(None) from next_chunk
36    // but populates the schema cache first.
37    rowset.next_chunk()?;
38
39    rowset
40        .schema()
41        .ok_or_else(|| Error::Protocol("dry-run: schema missing after next_chunk".into()))
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47    use crate::db::get_or_init;
48
49    #[test]
50    #[ignore = "requires HYPERD_PATH; run manually"]
51    fn dry_run_plain_select() {
52        let mut db = get_or_init().lock();
53        db.conn
54            .execute_command("CREATE TABLE IF NOT EXISTS _dr_test (id BIGINT, name TEXT)")
55            .unwrap();
56        let schema = dry_run(&mut db, "SELECT id, name FROM _dr_test").unwrap();
57        let names: Vec<_> = schema
58            .columns()
59            .iter()
60            .map(hyperdb_api::ResultColumn::name)
61            .collect();
62        assert_eq!(names, &["id", "name"]);
63    }
64
65    #[test]
66    #[ignore = "requires HYPERD_PATH; run manually"]
67    fn dry_run_cte_wrapper() {
68        let mut db = get_or_init().lock();
69        db.conn
70            .execute_command("CREATE TABLE IF NOT EXISTS _dr_cte (x INT, y TEXT)")
71            .unwrap();
72        let schema = dry_run(
73            &mut db,
74            "WITH src AS (SELECT x, y FROM _dr_cte) SELECT * FROM src",
75        )
76        .unwrap();
77        assert_eq!(schema.column_count(), 2);
78    }
79
80    #[test]
81    #[ignore = "requires HYPERD_PATH; run manually"]
82    fn dry_run_from_less_expression() {
83        let mut db = get_or_init().lock();
84        let schema = dry_run(&mut db, "SELECT 1 AS a, 'x' AS b").unwrap();
85        let names: Vec<_> = schema
86            .columns()
87            .iter()
88            .map(hyperdb_api::ResultColumn::name)
89            .collect();
90        assert_eq!(names, &["a", "b"]);
91    }
92
93    #[test]
94    #[ignore = "requires HYPERD_PATH; run manually"]
95    fn dry_run_bad_table_returns_error() {
96        let mut db = get_or_init().lock();
97        let err = dry_run(&mut db, "SELECT * FROM _nonexistent_xyz").unwrap_err();
98        assert_eq!(
99            err.sqlstate(),
100            Some("42P01"),
101            "expected undefined_table: {err}"
102        );
103    }
104}