Skip to main content

hyperdb_mcp/
export.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Export query results or whole tables to files.
5//!
6//! All row-oriented formats (CSV, Parquet, Arrow IPC, Iceberg) go through
7//! hyperd's native `COPY (query) TO 'path' WITH (format => '…')` writer.
8//! The MCP only issues a single SQL statement — no Rust-side type
9//! inference, no JSON intermediate, no in-memory buffering of the
10//! output. Hyperd writes the file (or directory) directly to disk and
11//! reports the written row count back as the statement's affected-rows.
12//!
13//! Supported formats:
14//! - **CSV** — `format => 'csv', header => true`.
15//! - **Parquet** — `format => 'parquet'`. Preserves NUMERIC precision,
16//!   DATE/TIMESTAMP types, and column nullability. Order of magnitude
17//!   faster than the previous JSON-mediated path.
18//! - **Arrow IPC Stream** — `format => 'arrowstream'`. Bit-identical to
19//!   what Hyper speaks on the wire for its binary Arrow protocol.
20//! - **Iceberg** — `format => 'iceberg'`. Destination is a *directory*
21//!   (the Iceberg table root with `metadata/` and `data/` subdirs);
22//!   hyperd creates it. Round-trips cleanly with `load_iceberg`.
23//! - **Hyper** — new `.hyper` file populated via `CREATE DATABASE` +
24//!   `ATTACH DATABASE` + `CREATE TABLE AS SELECT`, openable directly in
25//!   Tableau Desktop. (Cannot use plain `std::fs::copy` because on
26//!   Windows `hyperd` holds an exclusive lock on the workspace file.)
27
28use crate::engine::Engine;
29use crate::error::{ErrorCode, McpError};
30use crate::stats::{ExportStats, StatsTimer};
31use hyperdb_api::{escape_sql_path, escape_string_literal};
32use serde_json::{Map, Value};
33
34/// Specifies what to export and where.
35///
36/// For row-oriented formats (`csv`, `parquet`, `arrow_ipc`, `iceberg`)
37/// exactly one of `sql` or `table` must be provided; `sql` takes priority
38/// if both are set. For `hyper` format both are ignored — every user
39/// table in the workspace is copied into a new `.hyper` file.
40#[derive(Debug, Default)]
41pub struct ExportOptions {
42    /// A SELECT query whose results will be exported. Ignored when
43    /// `format = "hyper"`.
44    pub sql: Option<String>,
45    /// Table name — converted to `SELECT * FROM "<table>"` when `sql` is
46    /// None. Ignored when `format = "hyper"`.
47    pub table: Option<String>,
48    /// Destination file path.
49    pub path: String,
50    /// One of `"csv"`, `"parquet"`, `"arrow_ipc"`, `"iceberg"`, or
51    /// `"hyper"`.
52    pub format: String,
53    /// Whether to overwrite an existing file at `path`. When `false` and
54    /// `path` already exists, [`export_to_file`] returns a
55    /// [`ErrorCode::PermissionDenied`] error without touching the file.
56    pub overwrite: bool,
57    /// Extra options passed through verbatim into the `WITH (...)`
58    /// clause of hyperd's `COPY TO`. Keys must match hyperd's own
59    /// option names (e.g. `codec`, `rows_per_row_group`,
60    /// `max_file_size`, `table_scheme`, `delimiter`, `header`). Values
61    /// may be strings, booleans, or numbers; anything else is rejected.
62    /// Ignored for `"hyper"` format (which is not a `COPY` at all).
63    pub format_options: Option<Map<String, Value>>,
64    /// Source database to snapshot when `format = "hyper"`. `None` →
65    /// the primary (ephemeral) workspace; `Some("persistent")` or
66    /// `Some("user_alias")` snapshots that attached database. Ignored
67    /// for the row-oriented formats which already qualify via `sql`
68    /// or via `scoped_search_path`.
69    pub source_db: Option<String>,
70}
71
72/// Returned by [`export_to_file`] with the exported row count and telemetry.
73#[derive(Debug)]
74pub struct ExportResult {
75    pub rows: u64,
76    pub stats: ExportStats,
77}
78
79/// Top-level export dispatcher. Resolves the source SQL, then delegates to
80/// the format-specific exporter.
81///
82/// # Errors
83///
84/// - Returns [`ErrorCode::PermissionDenied`] if `opts.path` already
85///   exists and `opts.overwrite` is `false`.
86/// - Returns [`ErrorCode::SqlError`] when neither `opts.sql` nor
87///   `opts.table` is provided (for row-oriented formats).
88/// - Returns [`ErrorCode::UnsupportedFormat`] when `opts.format` is
89///   not one of `hyper`, `csv`, `parquet`, `arrow_ipc`, or `iceberg`.
90/// - Propagates any format-specific error from the delegated exporter
91///   (SQL execution, I/O, or format-option validation failures).
92pub fn export_to_file(engine: &Engine, opts: &ExportOptions) -> Result<ExportResult, McpError> {
93    let timer = StatsTimer::start();
94
95    // Reject `..` components to prevent traversal attacks via LLM-generated paths.
96    let path_obj = std::path::Path::new(&opts.path);
97    if path_obj
98        .components()
99        .any(|c| matches!(c, std::path::Component::ParentDir))
100    {
101        return Err(McpError::new(
102            ErrorCode::InvalidArgument,
103            format!(
104                "Export path '{}' may not contain '..' components",
105                opts.path
106            ),
107        ));
108    }
109
110    // Refuse to clobber an existing destination when caller opted out of
111    // overwrite. Done up-front (before SQL resolution or format dispatch)
112    // so every format — including the file-copy hyper path and the
113    // directory-based iceberg path — gets the same guarantee.
114    if !opts.overwrite && std::path::Path::new(&opts.path).exists() {
115        return Err(McpError::new(
116            ErrorCode::PermissionDenied,
117            format!(
118                "Refusing to overwrite existing destination: {} (pass overwrite=true to replace it)",
119                opts.path
120            ),
121        ));
122    }
123
124    // `hyper` format is a whole-workspace file copy — neither `sql` nor
125    // `table` is meaningful for it, so branch before the SQL-resolution
126    // check that the row-oriented formats require.
127    if opts.format == "hyper" {
128        return export_hyper(engine, &opts.path, opts.source_db.as_deref(), &timer);
129    }
130
131    let select_sql = match (&opts.sql, &opts.table) {
132        (Some(sql), _) => sql.clone(),
133        (None, Some(table)) => {
134            // Escape embedded double-quotes per SQL identifier rules to prevent
135            // injection via crafted table names from LLM-generated input.
136            format!("SELECT * FROM \"{}\"", table.replace('"', "\"\""))
137        }
138        (None, None) => {
139            return Err(McpError::new(
140                ErrorCode::SqlError,
141                "Either sql or table must be provided",
142            ))
143        }
144    };
145
146    let extra = opts.format_options.as_ref();
147    match opts.format.as_str() {
148        "csv" => export_csv(engine, &select_sql, &opts.path, extra, &timer),
149        "parquet" => export_parquet(engine, &select_sql, &opts.path, extra, &timer),
150        "arrow_ipc" => export_arrow_ipc(engine, &select_sql, &opts.path, extra, &timer),
151        "iceberg" => export_iceberg(
152            engine,
153            &select_sql,
154            &opts.path,
155            opts.overwrite,
156            extra,
157            &timer,
158        ),
159        other => Err(McpError::new(
160            ErrorCode::UnsupportedFormat,
161            format!("Unsupported export format: {other}"),
162        )),
163    }
164}
165
166/// Render an option key like `compression` into `compression` after
167/// validating it's a safe identifier. We pass the whole `WITH (...)`
168/// clause into hyperd as SQL, so an unchecked key like `foo) --` would
169/// let a caller rewrite the statement. Allow only lowercase
170/// letters, digits, and underscores, starting with a letter or
171/// underscore — hyperd's own option names all fit this shape.
172fn validate_option_key(key: &str) -> Result<(), McpError> {
173    let bad = key.is_empty()
174        || !key
175            .bytes()
176            .next()
177            .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
178        || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
179    if bad {
180        return Err(McpError::new(
181            ErrorCode::SchemaMismatch,
182            format!(
183                "format_options key '{key}' must match [A-Za-z_][A-Za-z0-9_]* \
184                 (hyperd COPY option names use that shape)"
185            ),
186        ));
187    }
188    Ok(())
189}
190
191/// Render a single `format_options` value into a SQL literal. Strings
192/// are single-quote-escaped; booleans become `true`/`false`; numbers
193/// (including fractional) are rendered via `Value::to_string`. Null,
194/// nested arrays, and nested objects are rejected with a clear error —
195/// hyperd's COPY options are all simple scalars.
196fn render_option_value(key: &str, value: &Value) -> Result<String, McpError> {
197    match value {
198        Value::String(s) => Ok(escape_string_literal(s)),
199        Value::Bool(b) => Ok(if *b { "true".into() } else { "false".into() }),
200        Value::Number(n) => Ok(n.to_string()),
201        Value::Null | Value::Array(_) | Value::Object(_) => Err(McpError::new(
202            ErrorCode::SchemaMismatch,
203            format!(
204                "format_options['{key}'] must be a string, boolean, or number \
205                 (got {value:?})"
206            ),
207        )),
208    }
209}
210
211/// Merge a format-specific base `WITH (...)` clause (e.g.
212/// `"format => 'parquet'"`) with caller-supplied `format_options`.
213/// Caller options always appear after the base, so if the same key
214/// appears in both the caller's value wins.
215fn render_copy_with_clause(
216    base: &str,
217    extra: Option<&Map<String, Value>>,
218) -> Result<String, McpError> {
219    let mut clause = base.to_string();
220    if let Some(opts) = extra {
221        for (key, value) in opts {
222            validate_option_key(key)?;
223            let rendered_value = render_option_value(key, value)?;
224            clause.push_str(", ");
225            clause.push_str(key);
226            clause.push_str(" => ");
227            clause.push_str(&rendered_value);
228        }
229    }
230    Ok(clause)
231}
232
233/// Shared helper: issue a single `COPY (query) TO 'path' WITH (...)` to
234/// hyperd and return the reported row count + on-disk file size. All
235/// row-oriented exports funnel through this — the format-specific logic
236/// is just the `WITH (...)` clause.
237///
238/// Hyperd handles path I/O itself, so no in-memory buffering and no
239/// Rust-side type mapping is involved. Unlike `CREATE TABLE AS`, `COPY`
240/// reports the written row count directly in `affected_rows`, so no
241/// follow-up `COUNT(*)` is required.
242fn run_copy_to(
243    engine: &Engine,
244    sql: &str,
245    path: &str,
246    base_with: &str,
247    extra_options: Option<&Map<String, Value>>,
248    format_label: &str,
249    timer: &StatsTimer,
250) -> Result<ExportResult, McpError> {
251    let with_clause = render_copy_with_clause(base_with, extra_options)?;
252    let quoted_path = escape_string_literal(path);
253    let copy_sql = format!("COPY ({sql}) TO {quoted_path} WITH ({with_clause})");
254    let row_count = engine.execute_command(&copy_sql)?;
255
256    let file_size = std::fs::metadata(path).map_or(0, |m| m.len());
257
258    Ok(ExportResult {
259        rows: row_count,
260        stats: ExportStats {
261            operation: "export".into(),
262            rows: row_count,
263            elapsed_ms: timer.elapsed_ms(),
264            file_size_bytes: file_size,
265            format: format_label.into(),
266            output_path: path.into(),
267        },
268    })
269}
270
271/// Export as CSV via hyperd's native `COPY ... WITH (format => 'csv',
272/// header => true)`. Hyperd writes the file directly — no in-memory
273/// buffer in Rust. Caller can override or extend via `format_options`
274/// (e.g. `{"header": false, "delimiter": "\t"}`).
275fn export_csv(
276    engine: &Engine,
277    sql: &str,
278    path: &str,
279    format_options: Option<&Map<String, Value>>,
280    timer: &StatsTimer,
281) -> Result<ExportResult, McpError> {
282    run_copy_to(
283        engine,
284        sql,
285        path,
286        "format => 'csv', header => true",
287        format_options,
288        "csv",
289        timer,
290    )
291}
292
293/// Export as Parquet via hyperd's native
294/// `COPY ... WITH (format => 'parquet')`. Types (NUMERIC precision,
295/// DATE, TIMESTAMP, non-null flags, ...) are preserved exactly —
296/// hyperd writes its own Arrow schema from the query's `RowDescription`,
297/// bypassing the JSON round-trip the previous Rust-side pipeline used.
298/// Caller can override via `format_options` (e.g. `{"compression":
299/// "zstd", "rows_per_row_group": 100000}`).
300fn export_parquet(
301    engine: &Engine,
302    sql: &str,
303    path: &str,
304    format_options: Option<&Map<String, Value>>,
305    timer: &StatsTimer,
306) -> Result<ExportResult, McpError> {
307    run_copy_to(
308        engine,
309        sql,
310        path,
311        "format => 'parquet'",
312        format_options,
313        "parquet",
314        timer,
315    )
316}
317
318/// Export as Arrow IPC Stream format via hyperd's native
319/// `COPY ... WITH (format => 'arrowstream')`. This is the same wire
320/// shape Hyper speaks on its binary Arrow query protocol, so the
321/// produced bytes are consumable by any Arrow IPC Stream reader and
322/// round-trip through our `load_file` path (which auto-detects the
323/// sub-format).
324fn export_arrow_ipc(
325    engine: &Engine,
326    sql: &str,
327    path: &str,
328    format_options: Option<&Map<String, Value>>,
329    timer: &StatsTimer,
330) -> Result<ExportResult, McpError> {
331    run_copy_to(
332        engine,
333        sql,
334        path,
335        "format => 'arrowstream'",
336        format_options,
337        "arrow_ipc",
338        timer,
339    )
340}
341
342/// Export query results as an Apache Iceberg table directory using
343/// hyperd's native `COPY (query) TO 'dir' WITH (format => 'iceberg')`.
344///
345/// Hyperd creates the destination directory with a `metadata/` subdir
346/// (snapshot JSONs + manifests) and one or more `data/` parquet files.
347/// The produced layout round-trips cleanly back through `load_iceberg`.
348///
349/// Caller semantics:
350/// - `path` is a *directory* path (not a single file). If a directory
351///   or file already exists there and `overwrite` is true, we remove
352///   it first — hyperd's `COPY TO` refuses to write into an existing
353///   non-empty Iceberg location.
354/// - The SELECT must return at least one row. An empty query succeeds
355///   but produces an empty table metadata file.
356fn export_iceberg(
357    engine: &Engine,
358    sql: &str,
359    path: &str,
360    overwrite: bool,
361    format_options: Option<&Map<String, Value>>,
362    timer: &StatsTimer,
363) -> Result<ExportResult, McpError> {
364    // Clear the destination if it exists. The outer overwrite guard in
365    // `export_to_file` has already rejected the call if `overwrite` is
366    // false and the path exists, so by the time we get here either the
367    // path is empty or we've been told to replace it.
368    let dest = std::path::Path::new(path);
369    if dest.exists() && overwrite {
370        if dest.is_dir() {
371            std::fs::remove_dir_all(dest).map_err(|e| {
372                McpError::new(
373                    ErrorCode::PermissionDenied,
374                    format!("Cannot remove existing Iceberg directory '{path}': {e}"),
375                )
376            })?;
377        } else {
378            std::fs::remove_file(dest).map_err(|e| {
379                McpError::new(
380                    ErrorCode::PermissionDenied,
381                    format!("Cannot remove existing file at '{path}': {e}"),
382                )
383            })?;
384        }
385    }
386
387    let with_clause = render_copy_with_clause("format => 'iceberg'", format_options)?;
388    let quoted_path = escape_string_literal(path);
389    let copy_sql = format!("COPY ({sql}) TO {quoted_path} WITH ({with_clause})");
390
391    let row_count = engine.execute_command(&copy_sql)?;
392
393    // Directory size = sum of all file sizes under `path`. Not strictly
394    // required by callers, but useful for telemetry.
395    let file_size = walk_dir_size(dest).unwrap_or(0);
396
397    Ok(ExportResult {
398        rows: row_count,
399        stats: ExportStats {
400            operation: "export".into(),
401            rows: row_count,
402            elapsed_ms: timer.elapsed_ms(),
403            file_size_bytes: file_size,
404            format: "iceberg".into(),
405            output_path: path.into(),
406        },
407    })
408}
409
410/// Sum the byte sizes of every regular file under `dir`. Used for
411/// export telemetry on directory-based formats (Iceberg). Silent on I/O
412/// errors — telemetry is best-effort.
413fn walk_dir_size(dir: &std::path::Path) -> std::io::Result<u64> {
414    let mut total: u64 = 0;
415    let mut stack = vec![dir.to_path_buf()];
416    while let Some(p) = stack.pop() {
417        for entry in std::fs::read_dir(&p)? {
418            let entry = entry?;
419            let ft = entry.file_type()?;
420            if ft.is_dir() {
421                stack.push(entry.path());
422            } else if ft.is_file() {
423                total = total.saturating_add(entry.metadata().map_or(0, |m| m.len()));
424            }
425        }
426    }
427    Ok(total)
428}
429
430/// Export the workspace tables as a new `.hyper` file. Issues
431/// `CREATE DATABASE` + `ATTACH DATABASE` against the target path and
432/// populates it with one `CREATE TABLE AS SELECT` per user table.
433///
434/// We can't just `std::fs::copy(workspace, target)` because on Windows
435/// hyperd holds the workspace file open with an exclusive lock, and
436/// Windows blocks any concurrent open of a locked file (Unix allows it
437/// via shared handle semantics). Going through hyperd keeps this
438/// cross-platform at the cost of only copying tables — views,
439/// sequences, and other catalog objects in the source are not
440/// reproduced. That's acceptable for the current callers (LLMs
441/// exporting workspace data for Tableau Desktop), but documented here
442/// so a future caller that needs full catalog fidelity knows why.
443fn export_hyper(
444    engine: &Engine,
445    path: &str,
446    source_db: Option<&str>,
447    timer: &StatsTimer,
448) -> Result<ExportResult, McpError> {
449    // The target path is a separate file from the primary workspace,
450    // so OS-level copy/delete on it is fine — the lock conflict only
451    // affects the workspace hyperd has open. Pre-delete on overwrite
452    // because `CREATE DATABASE IF NOT EXISTS` would otherwise silently
453    // attach to the stale contents.
454    if std::path::Path::new(path).exists() {
455        std::fs::remove_file(path).map_err(|e| {
456            McpError::new(
457                ErrorCode::PermissionDenied,
458                format!("Cannot remove existing target '{path}': {e}"),
459            )
460        })?;
461    }
462
463    // Unique alias so we don't collide with a user-issued attach. The
464    // `__export_target_` prefix + PID + nanos makes accidental overlap
465    // exceedingly unlikely and stays within the 63-char identifier cap.
466    let alias = format!(
467        "__export_target_{}_{}",
468        std::process::id(),
469        timer.elapsed_ms(),
470    );
471
472    engine.execute_command(&format!("CREATE DATABASE {}", escape_sql_path(path)))?;
473    engine.execute_command(&format!(
474        "ATTACH DATABASE {} AS \"{}\"",
475        escape_sql_path(path),
476        alias.replace('"', "\"\""),
477    ))?;
478
479    let result = populate_export_target(engine, source_db, &alias);
480
481    // Always detach, even on failure — the attach was scoped to this
482    // call. A failed detach is logged but not surfaced: the caller
483    // cares about the copy outcome, not bookkeeping.
484    if let Err(e) = engine.execute_command(&format!(
485        "DETACH DATABASE \"{}\"",
486        alias.replace('"', "\"\""),
487    )) {
488        tracing::warn!(
489            alias = %alias,
490            err = %e.message,
491            "failed to detach export target after export_hyper",
492        );
493    }
494
495    let rows = result?;
496
497    let file_size = std::fs::metadata(path).map_or(0, |m| m.len());
498
499    Ok(ExportResult {
500        rows,
501        stats: ExportStats {
502            operation: "export".into(),
503            rows,
504            elapsed_ms: timer.elapsed_ms(),
505            file_size_bytes: file_size,
506            format: "hyper".into(),
507            output_path: path.into(),
508        },
509    })
510}
511
512/// Copy every user table from `source_db` (None → primary) into the
513/// database attached as `target_alias`. Returns the total row count
514/// written. Excludes `pg_catalog` / `information_schema` (and Hyper's
515/// own system schemas) so we only touch user data.
516fn populate_export_target(
517    engine: &Engine,
518    source_db: Option<&str>,
519    target_alias: &str,
520) -> Result<u64, McpError> {
521    let escaped_alias = target_alias.replace('"', "\"\"");
522    let source = source_db.map_or_else(|| engine.primary_db_name(), str::to_string);
523    let escaped_source = source.replace('"', "\"\"");
524
525    let schemas = list_user_schemas(engine, &escaped_source)?;
526    let mut total_rows: u64 = 0;
527
528    for schema in &schemas {
529        let escaped_schema = schema.replace('"', "\"\"");
530
531        // `public` exists by default on a fresh database; everything
532        // else has to be created before we can CREATE TABLE into it.
533        if schema != "public" {
534            engine.execute_command(&format!(
535                "CREATE SCHEMA IF NOT EXISTS \"{escaped_alias}\".\"{escaped_schema}\"",
536            ))?;
537        }
538
539        let tables = list_user_tables(engine, &escaped_source, schema)?;
540        for table in &tables {
541            if crate::engine::is_internal_table(table) {
542                continue;
543            }
544            let escaped_table = table.replace('"', "\"\"");
545            let rows_copied = engine.execute_command(&format!(
546                "CREATE TABLE \"{escaped_alias}\".\"{escaped_schema}\".\"{escaped_table}\" AS \
547                 SELECT * FROM \"{escaped_source}\".\"{escaped_schema}\".\"{escaped_table}\"",
548            ))?;
549            total_rows = total_rows.saturating_add(rows_copied);
550        }
551    }
552
553    Ok(total_rows)
554}
555
556fn list_user_schemas(engine: &Engine, escaped_db: &str) -> Result<Vec<String>, McpError> {
557    let sql = format!(
558        "SELECT nspname FROM \"{escaped_db}\".pg_catalog.pg_namespace \
559         WHERE nspname NOT IN ('pg_catalog', 'pg_temp', 'information_schema') \
560         AND nspname NOT LIKE 'pg_%'",
561    );
562    let rows = engine.execute_query_to_json(&sql)?;
563    Ok(rows
564        .iter()
565        .filter_map(|r| {
566            r.get("nspname")
567                .and_then(|v| v.as_str())
568                .map(str::to_string)
569        })
570        .collect())
571}
572
573fn list_user_tables(
574    engine: &Engine,
575    escaped_db: &str,
576    schema: &str,
577) -> Result<Vec<String>, McpError> {
578    let sql = format!(
579        "SELECT tablename FROM \"{escaped_db}\".pg_catalog.pg_tables WHERE schemaname = {}",
580        escape_string_literal(schema),
581    );
582    let rows = engine.execute_query_to_json(&sql)?;
583    Ok(rows
584        .iter()
585        .filter_map(|r| {
586            r.get("tablename")
587                .and_then(|v| v.as_str())
588                .map(str::to_string)
589        })
590        .collect())
591}
592
593#[cfg(test)]
594mod tests {
595    use super::{render_copy_with_clause, validate_option_key};
596    use serde_json::{json, Map, Value};
597
598    #[test]
599    fn render_clause_without_extras_returns_base() {
600        let out = render_copy_with_clause("format => 'parquet'", None).unwrap();
601        assert_eq!(out, "format => 'parquet'");
602    }
603
604    #[test]
605    fn render_clause_appends_extras_after_base() {
606        let mut m = Map::new();
607        m.insert("compression".into(), Value::String("zstd".into()));
608        m.insert("rows_per_row_group".into(), json!(100_000));
609        let out = render_copy_with_clause("format => 'parquet'", Some(&m)).unwrap();
610        // Map iteration is in insertion / BTreeMap order depending on the
611        // serde feature, but both of the original keys must appear and the
612        // base must come first.
613        assert!(out.starts_with("format => 'parquet', "));
614        assert!(out.contains("compression => 'zstd'"));
615        assert!(out.contains("rows_per_row_group => 100000"));
616    }
617
618    #[test]
619    fn render_clause_escapes_string_values() {
620        let mut m = Map::new();
621        m.insert("delimiter".into(), Value::String("it's".into()));
622        let out = render_copy_with_clause("format => 'csv'", Some(&m)).unwrap();
623        assert!(
624            out.contains("delimiter => 'it''s'"),
625            "single quote must be doubled; got: {out}"
626        );
627    }
628
629    #[test]
630    fn render_clause_renders_booleans_and_numbers_raw() {
631        let mut m = Map::new();
632        m.insert("header".into(), Value::Bool(false));
633        m.insert("max_file_size".into(), json!(1048576));
634        m.insert("ratio".into(), json!(0.25));
635        let out = render_copy_with_clause("format => 'csv'", Some(&m)).unwrap();
636        assert!(out.contains("header => false"));
637        assert!(out.contains("max_file_size => 1048576"));
638        assert!(out.contains("ratio => 0.25"));
639    }
640
641    #[test]
642    fn render_clause_rejects_null_array_object_values() {
643        for value in [Value::Null, json!([1, 2]), json!({"nested": 1})] {
644            let mut m = Map::new();
645            m.insert("whatever".into(), value.clone());
646            let err = render_copy_with_clause("format => 'csv'", Some(&m))
647                .expect_err("non-scalar values must be rejected");
648            assert!(err.message.contains("whatever"));
649        }
650    }
651
652    #[test]
653    fn validate_option_key_accepts_reasonable_names() {
654        for k in [
655            "compression",
656            "rows_per_row_group",
657            "header",
658            "h",
659            "_leading_underscore",
660            "table_scheme",
661            "MixedCase", // hyperd canonicalizes, but we don't need to reject
662        ] {
663            validate_option_key(k).unwrap_or_else(|e| panic!("{k} should be valid: {e:?}"));
664        }
665    }
666
667    #[test]
668    fn validate_option_key_rejects_injection_attempts() {
669        for bad in [
670            "",
671            "1starts_with_digit",
672            "has-dash",
673            "has space",
674            "key;DROP",
675            "close)--",
676            "quote'",
677            "unicode\u{00E9}",
678        ] {
679            assert!(
680                validate_option_key(bad).is_err(),
681                "{bad:?} should be rejected"
682            );
683        }
684    }
685}