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(©_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(©_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}