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