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