hyperdb_mcp/ingest.rs
1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Ingest inline data (JSON strings, CSV strings) and CSV files into Hyper tables.
5//!
6//! JSON is inserted row-by-row via SQL `INSERT` statements. This is simple and
7//! correct but not the fastest path — for bulk data, prefer file-based ingest
8//! where Hyper's native `COPY FROM` is used.
9//!
10//! CSV ingest always uses `COPY FROM`: inline CSV is spilled to a temp file first,
11//! while file-based CSV is read directly by `hyperd`.
12//!
13//! # Atomicity
14//!
15//! Every ingest function wraps its `INSERT` / `COPY` work inside a single
16//! transaction via [`Engine::execute_in_transaction`]. If any row fails to
17//! insert, all prior inserts from the same call are rolled back, so a failed
18//! ingest leaves zero additional rows behind.
19//!
20//! Note that Hyper auto-commits DDL (`DROP TABLE`, `CREATE TABLE`) regardless
21//! of the surrounding transaction. In `replace` mode, this means the original
22//! table is already gone by the time inserts start — a mid-ingest failure
23//! leaves the new table empty, not the original intact. In `append` mode, no
24//! DDL runs (assuming the table already exists), so rollback is fully atomic.
25
26use crate::engine::Engine;
27use crate::error::{ErrorCode, McpError};
28use crate::schema::{
29 apply_schema_override, infer_csv_schema, infer_json_schema, json_type_name,
30 widen_csv_numeric_columns, ColumnSchema,
31};
32use crate::stats::{IngestStats, StatsTimer};
33use hyperdb_api::AsyncConnection;
34use std::path::{Path, PathBuf};
35
36/// Resolve a path to a form that's safe to embed in a SQL
37/// `COPY FROM` literal.
38///
39/// On macOS the temp dir lives under `/var`, which is a symlink to
40/// `/private/var`; Hyper's `COPY FROM` resolves the symlink and then
41/// opens the file by the resolved path, so passing the unresolved one
42/// would break inline-CSV ingest. `canonicalize()` fixes that.
43///
44/// On Windows two extra steps are needed:
45///
46/// 1. `canonicalize()` returns an extended-length prefix (`\\?\C:\...`)
47/// that Hyper's `COPY FROM` cannot parse — it returns SQLSTATE 55006
48/// "unable to read from external source". Strip the prefix so the
49/// path looks like a plain `C:\...`.
50///
51/// 2. Convert backslashes to forward slashes. `escape_string_literal`
52/// only escapes single quotes, leaving `\U`, `\t`, and other
53/// backslash sequences exposed to `PostgreSQL`'s legacy string-literal
54/// escape rules, which Hyper inherits. Forward slashes are accepted
55/// by the Win32 file APIs and sidestep the escape question entirely.
56///
57/// Any canonicalize failure falls back to the original path — the
58/// common case (absolute, existing file on any platform) still works.
59fn canonicalize_for_copy(path: &Path) -> PathBuf {
60 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
61 #[cfg(windows)]
62 {
63 if let Some(s) = canonical.to_str() {
64 // `\\?\C:\foo\bar.csv` → `C:\foo\bar.csv`. Leave UNC-style
65 // `\\?\UNC\server\share\...` alone — those are only produced
66 // by canonicalize on UNC sources, which Hyper would reject
67 // either way, and stripping blindly would corrupt them.
68 let stripped = match s.strip_prefix(r"\\?\") {
69 Some(rest) if !rest.starts_with("UNC\\") => rest,
70 _ => s,
71 };
72 return PathBuf::from(stripped.replace('\\', "/"));
73 }
74 }
75 canonical
76}
77use serde_json::Value;
78
79/// Maximum bytes read from a CSV/text file for schema inference (64 MB).
80/// The full file is still loaded by `COPY FROM` — this limit only bounds the
81/// in-process memory used for type detection.
82const SCHEMA_INFERENCE_MAX_BYTES: u64 = 64 * 1024 * 1024;
83
84/// Reads at most `max_bytes` from a text file, returning a valid UTF-8 string.
85/// Truncates at the last newline within the byte budget to avoid splitting a row
86/// or multi-byte UTF-8 characters.
87fn read_text_sample(path: impl AsRef<Path>, max_bytes: u64) -> std::io::Result<String> {
88 use std::io::Read;
89 let file = std::fs::File::open(path.as_ref())?;
90 let file_len = file.metadata()?.len();
91 if file_len <= max_bytes {
92 return std::fs::read_to_string(path.as_ref());
93 }
94 let mut reader = std::io::BufReader::new(file);
95 let cap = usize::try_from(max_bytes).unwrap_or(usize::MAX);
96 let mut buf = vec![0u8; cap];
97 reader.read_exact(&mut buf)?;
98 // Truncate at last newline to avoid partial rows
99 if let Some(pos) = buf.iter().rposition(|&b| b == b'\n') {
100 buf.truncate(pos + 1);
101 }
102 // Handle UTF-8 boundary: if truncation split a multi-byte character,
103 // trim trailing incomplete bytes until we have valid UTF-8
104 match String::from_utf8(buf) {
105 Ok(s) => Ok(s),
106 Err(e) => {
107 let valid_up_to = e.utf8_error().valid_up_to();
108 let mut bytes = e.into_bytes();
109 bytes.truncate(valid_up_to);
110 // Truncate at last newline again to ensure we have complete rows
111 if let Some(pos) = bytes.iter().rposition(|&b| b == b'\n') {
112 bytes.truncate(pos + 1);
113 }
114 String::from_utf8(bytes)
115 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
116 }
117 }
118}
119
120/// Controls how data is loaded into a target table.
121#[derive(Debug)]
122pub struct IngestOptions {
123 pub table: String,
124 /// `"replace"` drops the existing table first; `"append"` adds rows to
125 /// it; `"merge"` upserts rows by [`Self::merge_key`].
126 pub mode: String,
127 /// When set, bypasses schema inference and uses these exact column types.
128 pub schema_override: Option<serde_json::Map<String, Value>>,
129 /// When `mode == "merge"`, the column(s) to match on for upsert. Required
130 /// in merge mode; rejected in any other mode (the per-call site validates
131 /// up-front so the lower ingest paths can stay format-agnostic).
132 pub merge_key: Option<Vec<String>>,
133}
134
135/// Returned by every ingest function with the row count, resolved schema,
136/// and performance telemetry.
137#[derive(Debug)]
138pub struct IngestResult {
139 pub rows: u64,
140 pub schema: Vec<ColumnSchema>,
141 pub stats: IngestStats,
142}
143
144/// RAII guard that ensures a temp table is dropped on **every** scope
145/// exit — `Ok`, `Err`, *and* panic-unwind. Used by
146/// [`merge_via_temp_table`] so an orphan `__hyperdb_merge_*` table
147/// can't leak into the workspace, even if a per-format ingest path
148/// panics mid-load.
149///
150/// Disarm by calling [`TempTableGuard::disarm`] when the temp has
151/// been renamed away (so the guard isn't dropping a real
152/// user-facing table by name).
153struct TempTableGuard<'a> {
154 engine: &'a Engine,
155 name: String,
156 armed: bool,
157}
158
159impl<'a> TempTableGuard<'a> {
160 fn new(engine: &'a Engine, name: String) -> Self {
161 Self {
162 engine,
163 name,
164 armed: true,
165 }
166 }
167 fn disarm(&mut self) {
168 self.armed = false;
169 }
170}
171
172impl Drop for TempTableGuard<'_> {
173 fn drop(&mut self) {
174 if !self.armed {
175 return;
176 }
177 // Detect whether the guard is being dropped during normal
178 // unwinding vs. as part of a panic. Including this in the log
179 // lets ops distinguish "expected cleanup" from "we wouldn't
180 // have learned about the temp leak otherwise" — the latter is
181 // the load-bearing case for this guard's existence.
182 let panicking = std::thread::panicking();
183 let drop_sql = format!(
184 "DROP TABLE IF EXISTS \"{}\"",
185 self.name.replace('"', "\"\"")
186 );
187 match self.engine.execute_command(&drop_sql) {
188 Ok(_) => {
189 if panicking {
190 // Successful cleanup during a panic-unwind is
191 // exactly what the guard exists for. Note it at
192 // info so it shows up in normal log review without
193 // requiring debug-level capture.
194 tracing::info!(
195 tmp = %self.name,
196 "merge_via_temp_table: dropped temp table during panic unwind"
197 );
198 }
199 }
200 Err(e) => {
201 // Don't mask the original outcome's error with a
202 // cleanup error — log and continue. The temp table is
203 // benign; the user can drop it manually if it ever
204 // surfaces. `panicking` is included so ops can tell
205 // whether this was a normal-path cleanup failure or
206 // happened while another error was already
207 // propagating.
208 tracing::warn!(
209 tmp = %self.name,
210 panicking,
211 error = %e,
212 "merge_via_temp_table: failed to drop temp table on guard exit"
213 );
214 }
215 }
216 }
217}
218
219/// Implement `mode = "merge"` for any format by reusing that format's
220/// `replace`-mode load to populate a temp table, then upserting the
221/// target by `merge_key` columns.
222///
223/// `replace_load` is the format-specific entry point (e.g. `ingest_json`,
224/// `ingest_parquet_file`) called with a `replace`-mode [`IngestOptions`]
225/// pointing at a temp table. It must return the [`IngestResult`] for
226/// the temp-table load — caller stitches that into the final result.
227///
228/// Algorithm:
229///
230/// 1. Validate `merge_key` is set and non-empty.
231/// 2. Load incoming data into a unique temp table via `replace_load`.
232/// A `TempTableGuard` arms here and unwind-safely drops the temp
233/// on every exit (`Ok` / `Err` / panic). Verify the temp actually
234/// materialized — a no-op load would otherwise produce confusing
235/// downstream errors.
236/// 3. If target table doesn't exist, rename the temp table → target,
237/// disarm the guard (the temp is now the target, do **not** drop
238/// it), and return (degenerates to "create").
239/// 4. Read target + temp column metadata. Validate every merge key
240/// exists in both with `types_compatible` type. Reject any
241/// non-key shared column whose type differs.
242/// 5. Auto-`ALTER TABLE ADD COLUMN` for any column present in temp but
243/// not target (added as nullable). When this fires, set
244/// [`IngestStats::schema_changed`] so the caller can issue a
245/// resource-list-changed notification.
246/// 6. `DELETE FROM target USING temp WHERE <key match>` then
247/// `INSERT INTO target (<temp cols>) SELECT <temp cols> FROM temp`.
248/// Columns the target has but temp doesn't are deliberately omitted
249/// from the projection so they fall through to NULL — standard
250/// PostgreSQL semantics for partial inserts.
251/// 7. The `TempTableGuard` drops the temp on scope exit.
252///
253/// Atomicity: the DELETE+INSERT pair is **not** wrapped in a transaction.
254/// Hyper auto-commits DDL inside transactions (see module-level note),
255/// and the existing `replace` mode already accepts the same race window,
256/// so this matches precedent rather than introducing a new exposure.
257///
258/// # Errors
259///
260/// - [`ErrorCode::InvalidArgument`] when `merge_key` is missing/empty,
261/// or when a key column is missing from target or temp, or when a
262/// column type differs between target and temp on a shared column.
263/// - [`ErrorCode::InternalError`] if `replace_load` returns `Ok` but
264/// doesn't actually create the temp table (a contract violation).
265/// - Propagates errors from `replace_load`, [`Engine::column_metadata`],
266/// [`Engine::alter_table_add_columns`], or the underlying DELETE /
267/// INSERT statements. The temp table is dropped before the error
268/// propagates.
269pub fn merge_via_temp_table<F>(
270 engine: &Engine,
271 opts: &IngestOptions,
272 replace_load: F,
273) -> Result<IngestResult, McpError>
274where
275 F: FnOnce(&IngestOptions) -> Result<IngestResult, McpError>,
276{
277 // Belt-and-suspenders contract check. Every per-format ingest only
278 // calls this helper when `opts.mode == "merge"`; if a future
279 // refactor adds a third mode that also recurses, this debug-only
280 // assertion catches the mistake before it manifests as silent
281 // misbehavior. The closure also passes `mode = "replace"` to the
282 // recursed call, breaking infinite recursion.
283 debug_assert_eq!(
284 opts.mode, "merge",
285 "merge_via_temp_table called with non-merge mode `{}`",
286 opts.mode
287 );
288
289 // Step 1 — validate merge_key.
290 let keys = opts.merge_key.as_ref().ok_or_else(|| {
291 McpError::new(
292 ErrorCode::InvalidArgument,
293 "merge mode requires merge_key (a column name or list of column names)",
294 )
295 })?;
296 if keys.is_empty() || keys.iter().any(String::is_empty) {
297 return Err(McpError::new(
298 ErrorCode::InvalidArgument,
299 "merge_key must be a non-empty list of non-empty column names",
300 ));
301 }
302
303 // Step 2 — load incoming data into a per-call temp table. The name
304 // mixes PID + nanosecond timestamp + a process-local atomic counter.
305 // The counter is what makes this race-free: two parallel merges of
306 // the same target inside one process can land in the same nanosecond
307 // (sub-µs OS timer, or platform-error fallback to `0`), but the
308 // counter is monotonically unique per call. That's enough to keep
309 // each merge's temp table distinct so the `TempTableGuard`s don't
310 // cross-drop one another.
311 use std::sync::atomic::{AtomicU64, Ordering};
312 static MERGE_COUNTER: AtomicU64 = AtomicU64::new(0);
313 let counter = MERGE_COUNTER.fetch_add(1, Ordering::Relaxed);
314 let nanos = std::time::SystemTime::now()
315 .duration_since(std::time::UNIX_EPOCH)
316 .map_or(0, |d| d.as_nanos());
317 // Squash anything that isn't [A-Za-z0-9_] in the target name to `_`
318 // so the auto-generated identifier is always SQL-safe. The temp
319 // name is never user-visible after a successful merge.
320 let safe_target: String = opts
321 .table
322 .chars()
323 .map(|c| {
324 if c.is_ascii_alphanumeric() || c == '_' {
325 c
326 } else {
327 '_'
328 }
329 })
330 .collect();
331 let tmp = format!(
332 "__hyperdb_merge_{safe_target}_{}_{nanos}_{counter}",
333 std::process::id(),
334 );
335
336 let tmp_opts = IngestOptions {
337 table: tmp.clone(),
338 mode: "replace".into(),
339 schema_override: opts.schema_override.clone(),
340 merge_key: None,
341 };
342 let tmp_result = replace_load(&tmp_opts)?;
343
344 // Arm the cleanup guard immediately after the load so any later
345 // failure (or panic) drops the temp table on unwind.
346 let mut guard = TempTableGuard::new(engine, tmp.clone());
347
348 // Belt-and-suspenders check: a per-format `replace_load` that returns
349 // `Ok` without actually creating the table would surface as opaque
350 // "table not found" errors from the catalog read in step 4. Catch
351 // it here with a clear message so the contract violation is named
352 // outright.
353 if !engine.table_exists(&tmp)? {
354 return Err(McpError::new(
355 ErrorCode::InternalError,
356 format!(
357 "merge: temp table '{tmp}' was not produced by the format-specific \
358 replace load — this is a contract violation in the per-format ingest path"
359 ),
360 ));
361 }
362
363 // Step 3 — if target doesn't exist, rename temp → target.
364 if !engine.table_exists(&opts.table)? {
365 let quoted_tmp = format!("\"{}\"", tmp.replace('"', "\"\""));
366 let quoted_tgt = format!("\"{}\"", opts.table.replace('"', "\"\""));
367 engine.execute_command(&format!("ALTER TABLE {quoted_tmp} RENAME TO {quoted_tgt}"))?;
368 // The temp is now the target; the guard must NOT try to drop it.
369 guard.disarm();
370 return Ok(IngestResult {
371 rows: tmp_result.rows,
372 schema: tmp_result.schema,
373 stats: IngestStats {
374 operation: tmp_result.stats.operation,
375 rows: tmp_result.stats.rows,
376 elapsed_ms: tmp_result.stats.elapsed_ms,
377 bytes_read: tmp_result.stats.bytes_read,
378 bytes_stored: tmp_result.stats.bytes_stored,
379 schema_inference_ms: tmp_result.stats.schema_inference_ms,
380 table: opts.table.clone(),
381 file_format: tmp_result.stats.file_format,
382 warning: tmp_result.stats.warning,
383 // Target was just created from scratch — by definition
384 // the resource list "changed" relative to its prior
385 // absence, so signal a notify.
386 schema_changed: true,
387 },
388 });
389 }
390
391 // Step 4 — schema reconciliation.
392 let target_cols = engine.column_metadata(&opts.table)?;
393 let tmp_cols = engine.column_metadata(&tmp)?;
394 // Every key must exist in both, with matching type.
395 for k in keys {
396 let in_target = target_cols.iter().find(|c| c.name == *k);
397 let in_tmp = tmp_cols.iter().find(|c| c.name == *k);
398 match (in_target, in_tmp) {
399 (None, _) => {
400 return Err(McpError::new(
401 ErrorCode::InvalidArgument,
402 format!(
403 "merge_key column '{k}' is not in target table '{}'",
404 opts.table
405 ),
406 ));
407 }
408 (_, None) => {
409 return Err(McpError::new(
410 ErrorCode::InvalidArgument,
411 format!(
412 "merge_key column '{k}' is not in incoming data \
413 (column missing from the file)"
414 ),
415 ));
416 }
417 (Some(t), Some(s)) if !types_compatible(&t.hyper_type, &s.hyper_type) => {
418 return Err(McpError::new(
419 ErrorCode::InvalidArgument,
420 format!(
421 "merge_key column '{k}' type mismatch: target is {} but \
422 incoming is {}. Use mode=replace or apply a schema override.",
423 t.hyper_type, s.hyper_type
424 ),
425 ));
426 }
427 _ => {}
428 }
429 }
430 // Reject type mismatches on any shared non-key column.
431 for tc in &tmp_cols {
432 if let Some(target_c) = target_cols.iter().find(|c| c.name == tc.name) {
433 if !types_compatible(&target_c.hyper_type, &tc.hyper_type) {
434 return Err(McpError::new(
435 ErrorCode::InvalidArgument,
436 format!(
437 "Column '{}' type mismatch: target is {} but incoming is {}. \
438 Use mode=replace or apply a schema override.",
439 tc.name, target_c.hyper_type, tc.hyper_type
440 ),
441 ));
442 }
443 }
444 }
445
446 // Step 5 — auto-ALTER for new columns (in temp, not in target).
447 // `alter_table_add_columns` handles the empty-input case by
448 // returning early without issuing SQL.
449 let new_cols: Vec<ColumnSchema> = tmp_cols
450 .iter()
451 .filter(|c| !target_cols.iter().any(|t| t.name == c.name))
452 .cloned()
453 // ALTER TABLE ADD COLUMN must be nullable; existing rows have no value.
454 .map(|mut c| {
455 c.nullable = true;
456 c
457 })
458 .collect();
459 let schema_changed = !new_cols.is_empty();
460 engine.alter_table_add_columns(&opts.table, &new_cols)?;
461
462 // Step 6 — DELETE matching rows by key, then INSERT all temp rows.
463 let quoted_tgt = format!("\"{}\"", opts.table.replace('"', "\"\""));
464 let quoted_tmp = format!("\"{}\"", tmp.replace('"', "\"\""));
465 let key_eq = keys
466 .iter()
467 .map(|k| {
468 let qk = k.replace('"', "\"\"");
469 format!("t.\"{qk}\" = s.\"{qk}\"")
470 })
471 .collect::<Vec<_>>()
472 .join(" AND ");
473 let delete_sql = format!("DELETE FROM {quoted_tgt} t USING {quoted_tmp} s WHERE {key_eq}");
474 engine.execute_command(&delete_sql)?;
475
476 // The INSERT projection is `tmp_cols` (not `target_cols`). Columns
477 // the target has but the incoming temp lacks are deliberately
478 // omitted — PostgreSQL fills them with NULL, which is the right
479 // widening semantic for an upsert that adds new rows for
480 // unmatched keys.
481 let cols_csv = tmp_cols
482 .iter()
483 .map(|c| format!("\"{}\"", c.name.replace('"', "\"\"")))
484 .collect::<Vec<_>>()
485 .join(", ");
486 let insert_sql =
487 format!("INSERT INTO {quoted_tgt} ({cols_csv}) SELECT {cols_csv} FROM {quoted_tmp}");
488 let inserted = engine.execute_command(&insert_sql)?;
489
490 // Re-read target schema for the result so callers see the post-merge shape.
491 let final_schema = engine.column_metadata(&opts.table)?;
492
493 // The guard drops the temp table when it falls out of scope here.
494 Ok(IngestResult {
495 rows: inserted,
496 schema: final_schema,
497 stats: IngestStats {
498 operation: tmp_result.stats.operation,
499 rows: inserted,
500 elapsed_ms: tmp_result.stats.elapsed_ms,
501 bytes_read: tmp_result.stats.bytes_read,
502 bytes_stored: tmp_result.stats.bytes_stored,
503 schema_inference_ms: tmp_result.stats.schema_inference_ms,
504 table: opts.table.clone(),
505 file_format: tmp_result.stats.file_format,
506 warning: tmp_result.stats.warning,
507 schema_changed,
508 },
509 })
510}
511
512/// Compare two Hyper type strings for merge compatibility.
513///
514/// Raw string equality is wrong: the catalog canonicalizes types
515/// (`INT` → `INTEGER`, `BYTES` → `BYTEA`, `NUMERIC(15,2)` → may
516/// differ in spacing) while the inferred-incoming side carries
517/// whatever the Rust inferrer emitted (`"INT"`, `"DOUBLE
518/// PRECISION"`, `"NUMERIC(15, 2)"`). Comparing through
519/// [`crate::schema::map_hyper_type`] collapses all known aliases
520/// into a single [`SqlType`].
521///
522/// If *either* side fails to parse (returns `None`), we err on the
523/// side of permissive: treat the pair as compatible and let Hyper
524/// itself enforce at INSERT time. This avoids false-rejecting
525/// types the Rust mapper doesn't know about (future Hyper types,
526/// custom Hyper extensions, anything `map_hyper_type` hasn't been
527/// taught about). Mismatches will still surface — just from
528/// `INSERT` rather than from our pre-flight check.
529fn types_compatible(a: &str, b: &str) -> bool {
530 if a == b {
531 return true;
532 }
533 match (
534 crate::schema::map_hyper_type(a),
535 crate::schema::map_hyper_type(b),
536 ) {
537 (Some(sa), Some(sb)) => sa == sb,
538 _ => true,
539 }
540}
541
542/// Ingest an inline JSON array of objects into a Hyper table.
543///
544/// Each object becomes one row. Missing keys are inserted as NULL.
545/// Emits a warning when the input exceeds 50 MB — the caller should
546/// prefer `load_file` with a Parquet or Arrow file for large datasets.
547///
548/// # Errors
549///
550/// - Returns [`ErrorCode::SchemaMismatch`] if `json_str` cannot be
551/// parsed as a JSON array of objects (via [`serde_json::from_str`]).
552/// - Returns [`ErrorCode::EmptyData`] if the parsed array is empty.
553/// - Propagates any schema-inference error from [`infer_json_schema`]
554/// or [`apply_schema_override`].
555/// - Propagates any [`McpError`] from the wrapping transaction —
556/// [`Engine::create_table`], the per-row `INSERT` statements, or
557/// transaction commit/rollback failures.
558pub fn ingest_json(
559 engine: &Engine,
560 json_str: &str,
561 opts: &IngestOptions,
562) -> Result<IngestResult, McpError> {
563 if opts.mode == "merge" {
564 return merge_via_temp_table(engine, opts, |tmp_opts| {
565 ingest_json(engine, json_str, tmp_opts)
566 });
567 }
568 let timer = StatsTimer::start();
569 let bytes_read = json_str.len() as u64;
570
571 // Schema
572 let schema_timer = StatsTimer::start();
573 let inferred = infer_json_schema(json_str)?;
574 let columns = match &opts.schema_override {
575 Some(s) => apply_schema_override(inferred, s)?,
576 None => inferred,
577 };
578 let schema_ms = schema_timer.elapsed_ms();
579
580 // Parse JSON
581 let array: Vec<serde_json::Map<String, Value>> = serde_json::from_str(json_str)
582 .map_err(|e| McpError::new(ErrorCode::SchemaMismatch, format!("Invalid JSON: {e}")))?;
583
584 if array.is_empty() {
585 return Err(McpError::new(ErrorCode::EmptyData, "JSON array is empty"));
586 }
587
588 // All mutations run inside a transaction so a partial failure leaves
589 // zero side effects.
590 let is_replace = opts.mode != "append";
591 let row_count = engine.execute_in_transaction(|engine| {
592 engine.create_table(&opts.table, &columns, is_replace)?;
593 let mut row_count = 0u64;
594 let col_names: Vec<String> = columns.iter().map(|c| format!("\"{}\"", c.name)).collect();
595 for obj in &array {
596 let values: Vec<String> = columns
597 .iter()
598 .map(|col| match obj.get(&col.name) {
599 None | Some(Value::Null) => "NULL".to_string(),
600 Some(Value::Bool(b)) => b.to_string(),
601 Some(Value::Number(n)) => n.to_string(),
602 Some(Value::String(s)) => format!("'{}'", s.replace('\'', "''")),
603 Some(other) => format!("'{}'", other.to_string().replace('\'', "''")),
604 })
605 .collect();
606
607 let sql = format!(
608 "INSERT INTO \"{}\" ({}) VALUES ({})",
609 opts.table,
610 col_names.join(", "),
611 values.join(", ")
612 );
613 engine.execute_command(&sql)?;
614 row_count += 1;
615 }
616 Ok(row_count)
617 })?;
618
619 let elapsed = timer.elapsed_ms();
620 let stats = IngestStats {
621 operation: "load_data".into(),
622 rows: row_count,
623 elapsed_ms: elapsed,
624 bytes_read,
625 bytes_stored: 0,
626 schema_inference_ms: Some(schema_ms),
627 table: opts.table.clone(),
628 file_format: Some("json".into()),
629 warning: if bytes_read > 50_000_000 {
630 Some("Large inline data. Consider using load_file for better performance.".into())
631 } else {
632 None
633 },
634 schema_changed: false,
635 };
636
637 Ok(IngestResult {
638 rows: row_count,
639 schema: columns,
640 stats,
641 })
642}
643
644/// Ingest inline CSV text into a Hyper table.
645///
646/// The CSV is written to a temp file and loaded via `COPY FROM` so that Hyper's
647/// native CSV parser handles escaping, quoting, and bulk loading. The temp file
648/// is cleaned up after the COPY completes.
649///
650/// # Errors
651///
652/// - Returns [`ErrorCode::InternalError`] if the temp CSV file cannot
653/// be written to the system temp directory.
654/// - Propagates any error from [`infer_csv_schema`],
655/// [`widen_csv_numeric_columns`], or [`apply_schema_override`].
656/// - Propagates any transaction error from [`Engine::create_table`]
657/// or the `COPY FROM` statement (SQL errors, schema mismatches,
658/// connection loss).
659pub fn ingest_csv(
660 engine: &Engine,
661 csv_text: &str,
662 opts: &IngestOptions,
663) -> Result<IngestResult, McpError> {
664 if opts.mode == "merge" {
665 return merge_via_temp_table(engine, opts, |tmp_opts| {
666 ingest_csv(engine, csv_text, tmp_opts)
667 });
668 }
669 let timer = StatsTimer::start();
670 let bytes_read = csv_text.len() as u64;
671
672 // Schema: infer from the 1 000-row sample, widen numeric columns against
673 // the full CSV body to catch values hidden after the sample window, then
674 // overlay any user-provided partial override.
675 let schema_timer = StatsTimer::start();
676 let mut inferred = infer_csv_schema(csv_text, true)?;
677 widen_csv_numeric_columns(csv_text.as_bytes(), true, &mut inferred)?;
678 let columns = match &opts.schema_override {
679 Some(s) => apply_schema_override(inferred, s)?,
680 None => inferred,
681 };
682 let schema_ms = schema_timer.elapsed_ms();
683
684 // Write CSV to a temp file before starting the transaction (it's a pure
685 // filesystem operation and doesn't need to be atomic with the DB work).
686 //
687 // The filename mixes PID and a nanosecond timestamp so parallel tests
688 // (and parallel ingest calls from a multi-client MCP) don't race on
689 // the same path and produce `unable to read from external source`
690 // errors when one caller's COPY overlaps another's write/remove.
691 let temp_dir = std::env::temp_dir();
692 let temp_path = temp_dir.join(format!(
693 "hyperdb_mcp_csv_{}_{}.csv",
694 std::process::id(),
695 std::time::SystemTime::now()
696 .duration_since(std::time::UNIX_EPOCH)
697 .map_or(0, |d| d.as_nanos())
698 ));
699 std::fs::write(&temp_path, csv_text).map_err(|e| {
700 McpError::new(
701 ErrorCode::InternalError,
702 format!("Failed to write temp CSV: {e}"),
703 )
704 })?;
705
706 // `NULL ''` makes unquoted empty CSV cells load as SQL NULL, which is
707 // what users expect from the `,,` convention (and what `inspect_file`
708 // already reports in its `null_count` diagnostics). Without this,
709 // Hyper's CSV COPY treats empty cells as literal empty strings —
710 // breaking downstream `WHERE col IS NULL` and failing outright on
711 // numeric columns.
712 let canonical_temp = canonicalize_for_copy(&temp_path);
713 let copy_sql = format!(
714 "COPY \"{}\" FROM {} WITH (FORMAT csv, NULL '', DELIMITER ',', HEADER)",
715 opts.table,
716 hyperdb_api::escape_string_literal(canonical_temp.to_str().unwrap_or(""))
717 );
718
719 // Create table + COPY inside one transaction so that a COPY failure also
720 // unwinds the table creation.
721 let is_replace = opts.mode != "append";
722 let row_count = engine.execute_in_transaction(|engine| {
723 engine.create_table(&opts.table, &columns, is_replace)?;
724 engine.execute_command(©_sql)
725 });
726
727 // Always clean up the temp file, success or failure.
728 let _ = std::fs::remove_file(&temp_path);
729 let row_count = row_count?;
730
731 let elapsed = timer.elapsed_ms();
732 let stats = IngestStats {
733 operation: "load_data".into(),
734 rows: row_count,
735 elapsed_ms: elapsed,
736 bytes_read,
737 bytes_stored: 0,
738 schema_inference_ms: Some(schema_ms),
739 table: opts.table.clone(),
740 file_format: Some("csv".into()),
741 warning: if bytes_read > 50_000_000 {
742 Some("Large inline data. Consider using load_file for better performance.".into())
743 } else {
744 None
745 },
746 schema_changed: false,
747 };
748
749 Ok(IngestResult {
750 rows: row_count,
751 schema: columns,
752 stats,
753 })
754}
755
756/// Ingest a CSV file from disk into a Hyper table.
757///
758/// The file is read once for schema inference (up to 1 000 rows sampled),
759/// then loaded in bulk via `COPY FROM` using the canonicalized path so
760/// `hyperd` can read it directly.
761///
762/// # Errors
763///
764/// - Returns [`ErrorCode::FileNotFound`] if `path` does not exist or
765/// cannot be read.
766/// - Propagates any error from [`infer_csv_schema`],
767/// [`widen_csv_numeric_columns`], or [`apply_schema_override`].
768/// - Propagates any transaction error from [`Engine::create_table`]
769/// or the `COPY FROM` statement.
770pub fn ingest_csv_file(
771 engine: &Engine,
772 path: &str,
773 opts: &IngestOptions,
774) -> Result<IngestResult, McpError> {
775 if opts.mode == "merge" {
776 return merge_via_temp_table(engine, opts, |tmp_opts| {
777 ingest_csv_file(engine, path, tmp_opts)
778 });
779 }
780 let timer = StatsTimer::start();
781
782 let abs_path = std::path::Path::new(path);
783 if !abs_path.exists() {
784 return Err(McpError::new(
785 ErrorCode::FileNotFound,
786 format!("File not found: {path}"),
787 ));
788 }
789
790 let file_size = std::fs::metadata(abs_path).map_or(0, |m| m.len());
791
792 // Read file for schema inference (capped at 64 MB to prevent OOM on huge files)
793 let schema_timer = StatsTimer::start();
794 let sample = read_text_sample(abs_path, SCHEMA_INFERENCE_MAX_BYTES)
795 .map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}")))?;
796 let mut inferred = infer_csv_schema(&sample, true)?;
797 widen_csv_numeric_columns(sample.as_bytes(), true, &mut inferred)?;
798 let columns = match &opts.schema_override {
799 Some(s) => apply_schema_override(inferred, s)?,
800 None => inferred,
801 };
802 let schema_ms = schema_timer.elapsed_ms();
803
804 // COPY FROM the file directly, inside a transaction with CREATE TABLE.
805 let canonical = canonicalize_for_copy(abs_path);
806 // See `ingest_csv` above for the NULL-handling rationale: `NULL ''`
807 // makes unquoted empty cells load as SQL NULL.
808 let copy_sql = format!(
809 "COPY \"{}\" FROM {} WITH (FORMAT csv, NULL '', DELIMITER ',', HEADER)",
810 opts.table,
811 hyperdb_api::escape_string_literal(canonical.to_str().unwrap_or(""))
812 );
813
814 let is_replace = opts.mode != "append";
815 let row_count = engine.execute_in_transaction(|engine| {
816 engine.create_table(&opts.table, &columns, is_replace)?;
817 engine.execute_command(©_sql)
818 })?;
819
820 let elapsed = timer.elapsed_ms();
821 let stats = IngestStats {
822 operation: "load_file".into(),
823 rows: row_count,
824 elapsed_ms: elapsed,
825 bytes_read: file_size,
826 bytes_stored: 0,
827 schema_inference_ms: Some(schema_ms),
828 table: opts.table.clone(),
829 file_format: Some("csv".into()),
830 warning: None,
831 schema_changed: false,
832 };
833
834 Ok(IngestResult {
835 rows: row_count,
836 schema: columns,
837 stats,
838 })
839}
840
841/// Async twin of [`ingest_csv_file`]. Runs schema inference on the
842/// blocking pool, then issues `CREATE TABLE` + `COPY FROM` on the given
843/// async connection inside a single transaction.
844///
845/// # Errors
846///
847/// - Returns [`ErrorCode::FileNotFound`] if `path` does not exist or
848/// cannot be read.
849/// - Returns [`ErrorCode::InternalError`] if the schema-inference task
850/// panics on the blocking pool (surfaced as a join error).
851/// - Propagates any error from schema inference or override
852/// application.
853/// - Propagates any transaction, `CREATE TABLE`, or `COPY FROM` error
854/// from the async connection. A rollback failure after an inner
855/// error is logged but does not override the original error.
856pub async fn ingest_csv_file_async(
857 conn: &AsyncConnection,
858 path: &str,
859 opts: &IngestOptions,
860) -> Result<IngestResult, McpError> {
861 let timer = StatsTimer::start();
862
863 let abs_path = std::path::Path::new(path);
864 if !abs_path.exists() {
865 return Err(McpError::new(
866 ErrorCode::FileNotFound,
867 format!("File not found: {path}"),
868 ));
869 }
870 let file_size = std::fs::metadata(abs_path).map_or(0, |m| m.len());
871
872 // Inference is CPU-bound (parses the whole file to widen numerics)
873 // so it runs on the blocking pool rather than stalling a worker.
874 let schema_timer = StatsTimer::start();
875 let path_owned = path.to_string();
876 let override_owned = opts.schema_override.clone();
877 let columns: Vec<ColumnSchema> = tokio::task::spawn_blocking(move || -> Result<_, McpError> {
878 let sample = read_text_sample(&path_owned, SCHEMA_INFERENCE_MAX_BYTES).map_err(|e| {
879 McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}"))
880 })?;
881 let mut inferred = infer_csv_schema(&sample, true)?;
882 widen_csv_numeric_columns(sample.as_bytes(), true, &mut inferred)?;
883 let columns = match &override_owned {
884 Some(s) => apply_schema_override(inferred, s)?,
885 None => inferred,
886 };
887 Ok(columns)
888 })
889 .await
890 .map_err(|e| McpError::new(ErrorCode::InternalError, format!("Task join error: {e}")))??;
891 let schema_ms = schema_timer.elapsed_ms();
892
893 let canonical = canonicalize_for_copy(abs_path);
894 // `NULL ''` — unquoted empty cells load as SQL NULL (see sync twin).
895 let copy_sql = format!(
896 "COPY \"{}\" FROM {} WITH (FORMAT csv, NULL '', DELIMITER ',', HEADER)",
897 opts.table,
898 hyperdb_api::escape_string_literal(canonical.to_str().unwrap_or(""))
899 );
900
901 let is_replace = opts.mode != "append";
902
903 conn.begin_transaction().await.map_err(McpError::from)?;
904 let inner: Result<u64, McpError> = async {
905 create_table_async(conn, &opts.table, &columns, is_replace).await?;
906 conn.execute_command(©_sql)
907 .await
908 .map_err(McpError::from)
909 }
910 .await;
911 let row_count = match inner {
912 Ok(n) => {
913 conn.commit().await.map_err(McpError::from)?;
914 n
915 }
916 Err(e) => {
917 if let Err(rb) = conn.rollback().await {
918 tracing::warn!("rollback after error failed: {}", rb);
919 }
920 return Err(e);
921 }
922 };
923
924 let elapsed = timer.elapsed_ms();
925 let stats = IngestStats {
926 operation: "load_file".into(),
927 rows: row_count,
928 elapsed_ms: elapsed,
929 bytes_read: file_size,
930 bytes_stored: 0,
931 schema_inference_ms: Some(schema_ms),
932 table: opts.table.clone(),
933 file_format: Some("csv".into()),
934 warning: None,
935 schema_changed: false,
936 };
937
938 Ok(IngestResult {
939 rows: row_count,
940 schema: columns,
941 stats,
942 })
943}
944
945/// Ingest a JSON or JSON-Lines file from disk into a Hyper table.
946///
947/// Auto-detects the payload shape from the first non-whitespace byte:
948///
949/// * `[` → a single JSON array of objects, loaded verbatim via
950/// [`ingest_json`].
951/// * anything else → newline-delimited JSON (`.jsonl` convention): each
952/// non-empty line is parsed as one JSON object, collected into an
953/// array, then passed to [`ingest_json`].
954///
955/// The dispatch is content-based rather than extension-based so `.json`
956/// files that happen to contain JSONL (or vice-versa) still load; the
957/// extension `.jsonl` is accepted but not required. Blank lines and
958/// lines containing only whitespace are skipped.
959///
960/// # Errors
961///
962/// - Returns [`ErrorCode::FileNotFound`] if `path` does not exist or
963/// cannot be read.
964/// - Propagates errors from [`normalize_json_or_jsonl`] (malformed
965/// JSON / JSONL) and from [`ingest_json`] (schema inference,
966/// transaction failures, etc.).
967pub fn ingest_json_file(
968 engine: &Engine,
969 path: &str,
970 opts: &IngestOptions,
971) -> Result<IngestResult, McpError> {
972 let abs_path = std::path::Path::new(path);
973 if !abs_path.exists() {
974 return Err(McpError::new(
975 ErrorCode::FileNotFound,
976 format!("File not found: {path}"),
977 ));
978 }
979
980 let text = std::fs::read_to_string(abs_path)
981 .map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}")))?;
982
983 let json_array_text = normalize_json_or_jsonl(&text)?;
984 let mut result = ingest_json(engine, &json_array_text, opts)?;
985
986 // Re-label the operation + file_format so telemetry reflects the
987 // file-based path; `ingest_json` defaults to inline-mode metadata.
988 result.stats.operation = "load_file".into();
989 // Preserve the actual on-disk size rather than the serialized array,
990 // which may differ slightly after normalization.
991 result.stats.bytes_read = std::fs::metadata(abs_path).map_or(0, |m| m.len());
992 // Report the effective format we dispatched on so LLMs can
993 // distinguish the JSONL path from the array path when debugging.
994 result.stats.file_format = Some(
995 if text.trim_start().starts_with('[') {
996 "json"
997 } else {
998 "jsonl"
999 }
1000 .into(),
1001 );
1002
1003 Ok(result)
1004}
1005
1006/// Async twin of [`ingest_json`]: insert a JSON array of objects on the
1007/// given async connection. See [`ingest_json`] for the JSON-shape
1008/// contract (expects a top-level array; call [`normalize_json_or_jsonl`]
1009/// first if you have JSONL).
1010///
1011/// # Errors
1012///
1013/// - Returns [`ErrorCode::SchemaMismatch`] if `json_str` cannot be
1014/// parsed as a JSON array of objects.
1015/// - Returns [`ErrorCode::EmptyData`] if the parsed array is empty.
1016/// - Propagates any error from [`infer_json_schema`] /
1017/// [`apply_schema_override`].
1018/// - Propagates any transaction or `INSERT`-loop error from the async
1019/// connection. Rollback failures after an inner error are logged
1020/// but do not shadow the original error.
1021pub async fn ingest_json_async(
1022 conn: &AsyncConnection,
1023 json_str: &str,
1024 opts: &IngestOptions,
1025) -> Result<IngestResult, McpError> {
1026 let timer = StatsTimer::start();
1027 let bytes_read = json_str.len() as u64;
1028
1029 let schema_timer = StatsTimer::start();
1030 let inferred = infer_json_schema(json_str)?;
1031 let columns = match &opts.schema_override {
1032 Some(s) => apply_schema_override(inferred, s)?,
1033 None => inferred,
1034 };
1035 let schema_ms = schema_timer.elapsed_ms();
1036
1037 let array: Vec<serde_json::Map<String, Value>> = serde_json::from_str(json_str)
1038 .map_err(|e| McpError::new(ErrorCode::SchemaMismatch, format!("Invalid JSON: {e}")))?;
1039 if array.is_empty() {
1040 return Err(McpError::new(ErrorCode::EmptyData, "JSON array is empty"));
1041 }
1042
1043 let is_replace = opts.mode != "append";
1044
1045 conn.begin_transaction().await.map_err(McpError::from)?;
1046 let inner: Result<u64, McpError> = async {
1047 create_table_async(conn, &opts.table, &columns, is_replace).await?;
1048 let mut row_count = 0u64;
1049 let col_names: Vec<String> = columns.iter().map(|c| format!("\"{}\"", c.name)).collect();
1050 for obj in &array {
1051 let values: Vec<String> = columns
1052 .iter()
1053 .map(|col| match obj.get(&col.name) {
1054 None | Some(Value::Null) => "NULL".to_string(),
1055 Some(Value::Bool(b)) => b.to_string(),
1056 Some(Value::Number(n)) => n.to_string(),
1057 Some(Value::String(s)) => format!("'{}'", s.replace('\'', "''")),
1058 Some(other) => format!("'{}'", other.to_string().replace('\'', "''")),
1059 })
1060 .collect();
1061
1062 let sql = format!(
1063 "INSERT INTO \"{}\" ({}) VALUES ({})",
1064 opts.table,
1065 col_names.join(", "),
1066 values.join(", ")
1067 );
1068 conn.execute_command(&sql).await.map_err(McpError::from)?;
1069 row_count += 1;
1070 }
1071 Ok(row_count)
1072 }
1073 .await;
1074
1075 let row_count = match inner {
1076 Ok(n) => {
1077 conn.commit().await.map_err(McpError::from)?;
1078 n
1079 }
1080 Err(e) => {
1081 if let Err(rb) = conn.rollback().await {
1082 tracing::warn!("rollback after error failed: {}", rb);
1083 }
1084 return Err(e);
1085 }
1086 };
1087
1088 let elapsed = timer.elapsed_ms();
1089 let stats = IngestStats {
1090 operation: "load_data".into(),
1091 rows: row_count,
1092 elapsed_ms: elapsed,
1093 bytes_read,
1094 bytes_stored: 0,
1095 schema_inference_ms: Some(schema_ms),
1096 table: opts.table.clone(),
1097 file_format: Some("json".into()),
1098 warning: if bytes_read > 50_000_000 {
1099 Some("Large inline data. Consider using load_file for better performance.".into())
1100 } else {
1101 None
1102 },
1103 schema_changed: false,
1104 };
1105
1106 Ok(IngestResult {
1107 rows: row_count,
1108 schema: columns,
1109 stats,
1110 })
1111}
1112
1113/// Async twin of [`ingest_json_file`].
1114///
1115/// # Errors
1116///
1117/// - Returns [`ErrorCode::FileNotFound`] if `path` does not exist or
1118/// cannot be read.
1119/// - Propagates errors from [`normalize_json_or_jsonl`] and from
1120/// [`ingest_json_async`].
1121pub async fn ingest_json_file_async(
1122 conn: &AsyncConnection,
1123 path: &str,
1124 opts: &IngestOptions,
1125) -> Result<IngestResult, McpError> {
1126 let abs_path = std::path::Path::new(path);
1127 if !abs_path.exists() {
1128 return Err(McpError::new(
1129 ErrorCode::FileNotFound,
1130 format!("File not found: {path}"),
1131 ));
1132 }
1133
1134 let text = std::fs::read_to_string(abs_path)
1135 .map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}")))?;
1136
1137 let json_array_text = normalize_json_or_jsonl(&text)?;
1138 let mut result = ingest_json_async(conn, &json_array_text, opts).await?;
1139
1140 result.stats.operation = "load_file".into();
1141 result.stats.bytes_read = std::fs::metadata(abs_path).map_or(0, |m| m.len());
1142 result.stats.file_format = Some(
1143 if text.trim_start().starts_with('[') {
1144 "json"
1145 } else {
1146 "jsonl"
1147 }
1148 .into(),
1149 );
1150
1151 Ok(result)
1152}
1153
1154/// Shared helper: `CREATE TABLE` (optionally dropping first) on an async
1155/// connection. Mirrors [`Engine::create_table`] exactly so the async
1156/// ingest paths produce identical tables to the sync ones. Callers that
1157/// need atomicity should wrap this in `begin_transaction` / `commit`.
1158pub(crate) async fn create_table_async(
1159 conn: &AsyncConnection,
1160 table_name: &str,
1161 columns: &[ColumnSchema],
1162 replace: bool,
1163) -> Result<(), McpError> {
1164 if columns.is_empty() {
1165 return Err(McpError::new(
1166 ErrorCode::EmptyData,
1167 "No columns to create table from",
1168 ));
1169 }
1170 for col in columns {
1171 if crate::schema::map_hyper_type(&col.hyper_type).is_none() {
1172 return Err(McpError::new(
1173 ErrorCode::SchemaMismatch,
1174 format!(
1175 "Unknown type '{}' for column '{}'",
1176 col.hyper_type, col.name
1177 ),
1178 ));
1179 }
1180 }
1181
1182 let quoted_table = format!("\"{}\"", table_name.replace('"', "\"\""));
1183 if replace {
1184 conn.execute_command(&format!("DROP TABLE IF EXISTS {quoted_table}"))
1185 .await
1186 .map_err(McpError::from)?;
1187 }
1188
1189 let col_defs: Vec<String> = columns
1190 .iter()
1191 .map(|c| {
1192 let nullable = if c.nullable { "" } else { " NOT NULL" };
1193 format!(
1194 "\"{}\" {}{}",
1195 c.name.replace('"', "\"\""),
1196 c.hyper_type,
1197 nullable
1198 )
1199 })
1200 .collect();
1201 let create_sql = format!(
1202 "CREATE TABLE IF NOT EXISTS {} ({})",
1203 quoted_table,
1204 col_defs.join(", ")
1205 );
1206 conn.execute_command(&create_sql)
1207 .await
1208 .map_err(McpError::from)?;
1209 Ok(())
1210}
1211
1212/// Normalize a raw JSON / JSONL string to the "JSON array of objects"
1213/// representation [`ingest_json`] expects. Returns the original string
1214/// when it already parses as a top-level array; otherwise treats the
1215/// input as JSONL and re-serializes into a JSON array.
1216///
1217/// Public so [`crate::inspect`] can reuse the same JSON/JSONL
1218/// auto-detection for its dry-run inspector.
1219///
1220/// # Errors
1221///
1222/// - Returns [`ErrorCode::SchemaMismatch`] with the offending line
1223/// number when a JSONL line fails to parse as valid JSON.
1224/// - Returns [`ErrorCode::EmptyData`] if the input contains no
1225/// non-blank records.
1226/// - Returns [`ErrorCode::InternalError`] if the aggregated array
1227/// cannot be serialized back to a string (should not happen in
1228/// practice).
1229pub fn normalize_json_or_jsonl(text: &str) -> Result<String, McpError> {
1230 let trimmed = text.trim_start();
1231 if trimmed.starts_with('[') {
1232 return Ok(text.to_string());
1233 }
1234
1235 // JSONL path: one JSON object per non-empty line. We parse each line
1236 // eagerly rather than concatenating then parsing, so malformed lines
1237 // produce a useful per-line error pointing at the exact offender.
1238 let mut objects: Vec<Value> = Vec::new();
1239 for (idx, line) in text.lines().enumerate() {
1240 let line = line.trim();
1241 if line.is_empty() {
1242 continue;
1243 }
1244 let value: Value = serde_json::from_str(line).map_err(|e| {
1245 McpError::new(
1246 ErrorCode::SchemaMismatch,
1247 format!("Invalid JSON on line {}: {e}", idx + 1),
1248 )
1249 })?;
1250 objects.push(value);
1251 }
1252 if objects.is_empty() {
1253 return Err(McpError::new(
1254 ErrorCode::EmptyData,
1255 "JSON/JSONL file contained no records",
1256 ));
1257 }
1258 serde_json::to_string(&Value::Array(objects)).map_err(|e| {
1259 McpError::new(
1260 ErrorCode::InternalError,
1261 format!("Failed to serialize JSONL as array: {e}"),
1262 )
1263 })
1264}
1265
1266/// Navigate a dot-separated path into a JSON value, transparently parsing
1267/// stringified JSON at each step.
1268///
1269/// Path segments are dot-separated (e.g., `content.0.text.query_result.results`).
1270/// Numeric segments index into JSON arrays (`content.0` means `content[0]`).
1271/// When a segment resolves to a JSON string, the function automatically tries
1272/// to parse that string as JSON and continues navigating into the parsed
1273/// result. This handles the common MCP wrapper pattern where response
1274/// payloads are double-encoded as stringified JSON.
1275///
1276/// After all segments are consumed, if the final value is still a string,
1277/// one more parse attempt is made so that a terminal stringified array
1278/// (e.g., `"[{\"a\":1}]"`) is returned as the parsed array.
1279///
1280/// Returns the serialized JSON string of the value at the terminal path
1281/// segment.
1282///
1283/// # Errors
1284///
1285/// Returns [`ErrorCode::SchemaMismatch`] when:
1286/// - `raw_json` is not valid JSON.
1287/// - A stringified JSON value at a traversal point cannot be re-parsed.
1288/// - A numeric segment is out of range for the current array, or the
1289/// current value is not an array.
1290/// - A named segment is missing from the current object, or the current
1291/// value is not an object.
1292/// - The final result cannot be serialized back to a JSON string
1293/// (surfaces as [`ErrorCode::InternalError`]).
1294pub fn extract_json_path(raw_json: &str, path: &str) -> Result<String, McpError> {
1295 let mut current: Value = serde_json::from_str(raw_json).map_err(|e| {
1296 McpError::new(
1297 ErrorCode::SchemaMismatch,
1298 format!("json_extract_path: file is not valid JSON: {e}"),
1299 )
1300 })?;
1301
1302 let segments: Vec<&str> = path.split('.').collect();
1303 let mut traversed: Vec<&str> = Vec::new();
1304
1305 for segment in &segments {
1306 // If the current value is a string, try to parse it as JSON before
1307 // applying this segment. This handles stringified JSON wrappers.
1308 if let Value::String(s) = ¤t {
1309 current = serde_json::from_str(s).map_err(|_| {
1310 McpError::new(
1311 ErrorCode::SchemaMismatch,
1312 format!(
1313 "json_extract_path '{}': at segment '{}' (after '{}'): \
1314 value is a string but not valid JSON",
1315 path,
1316 segment,
1317 traversed.join(".")
1318 ),
1319 )
1320 })?;
1321 }
1322
1323 current = if let Ok(idx) = segment.parse::<usize>() {
1324 // Numeric segment: index into array.
1325 match current {
1326 Value::Array(mut arr) => {
1327 if idx >= arr.len() {
1328 return Err(McpError::new(
1329 ErrorCode::SchemaMismatch,
1330 format!(
1331 "json_extract_path '{}': at segment '{}' (after '{}'): \
1332 array index {} out of bounds (length {})",
1333 path,
1334 segment,
1335 traversed.join("."),
1336 idx,
1337 arr.len()
1338 ),
1339 ));
1340 }
1341 arr.swap_remove(idx)
1342 }
1343 other => {
1344 return Err(McpError::new(
1345 ErrorCode::SchemaMismatch,
1346 format!(
1347 "json_extract_path '{}': at segment '{}' (after '{}'): \
1348 expected array, found {}",
1349 path,
1350 segment,
1351 traversed.join("."),
1352 json_type_name(&other)
1353 ),
1354 ));
1355 }
1356 }
1357 } else {
1358 // String segment: index into object by key.
1359 match current {
1360 Value::Object(mut map) => match map.remove(*segment) {
1361 Some(v) => v,
1362 None => {
1363 return Err(McpError::new(
1364 ErrorCode::SchemaMismatch,
1365 format!(
1366 "json_extract_path '{}': at segment '{}' (after '{}'): \
1367 key not found in object",
1368 path,
1369 segment,
1370 traversed.join(".")
1371 ),
1372 ));
1373 }
1374 },
1375 other => {
1376 return Err(McpError::new(
1377 ErrorCode::SchemaMismatch,
1378 format!(
1379 "json_extract_path '{}': at segment '{}' (after '{}'): \
1380 expected object, found {}",
1381 path,
1382 segment,
1383 traversed.join("."),
1384 json_type_name(&other)
1385 ),
1386 ));
1387 }
1388 }
1389 };
1390
1391 traversed.push(segment);
1392 }
1393
1394 // Terminal auto-parse: if the final value is a string, try to parse it
1395 // as JSON so that e.g. a stringified array becomes the actual array.
1396 if let Value::String(s) = ¤t {
1397 if let Ok(parsed) = serde_json::from_str::<Value>(s) {
1398 current = parsed;
1399 }
1400 }
1401
1402 serde_json::to_string(¤t).map_err(|e| {
1403 McpError::new(
1404 ErrorCode::InternalError,
1405 format!("json_extract_path: failed to serialize extracted value: {e}"),
1406 )
1407 })
1408}
1409
1410/// High-level file-format categories the ingest and inspect layers
1411/// dispatch on. Text-based formats (JSON, CSV) are distinguished by
1412/// peeking at the first non-whitespace byte when the extension is
1413/// unfamiliar, so log-like files with `.log`/`.txt`/no extension still
1414/// reach the right decoder without the caller having to rename them.
1415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1416pub enum InferredFileFormat {
1417 /// Columnar, binary. Matched by `.parquet` / `.pq` extensions.
1418 Parquet,
1419 /// Arrow IPC stream or file. Matched by `.arrow` / `.ipc` /
1420 /// `.feather` extensions.
1421 ArrowIpc,
1422 /// JSON — either a top-level array of objects or newline-delimited
1423 /// JSON. [`ingest_json_file`] auto-detects between the two shapes
1424 /// from the first non-whitespace byte.
1425 Json,
1426 /// Everything else: CSV / TSV / log text that the CSV COPY path
1427 /// can still make sense of.
1428 Csv,
1429}
1430
1431/// Decide how to dispatch an ingest / inspect call for `path`.
1432///
1433/// Extension first (zero-IO, cheap): binary formats (`.parquet`, `.pq`,
1434/// `.arrow`, `.ipc`, `.feather`) always win by extension because the
1435/// file is a binary container whose magic bytes we'd have to unpack
1436/// anyway. Known text extensions (`.json`, `.jsonl`, `.ndjson`) map
1437/// straight to JSON without needing to read the file.
1438///
1439/// Otherwise — for unknown, ambiguous, or missing extensions (`.log`,
1440/// `.txt`, no extension at all) — peek at the first 4 KiB and return
1441/// [`InferredFileFormat::Json`] if the first non-whitespace byte is
1442/// `[` or `{`, else [`InferredFileFormat::Csv`]. This is the path that
1443/// lets hyperd's raw `.log` files load without renaming, since JSONL
1444/// lines always begin with `{`.
1445///
1446/// Returns [`InferredFileFormat::Csv`] if the file can't be opened;
1447/// the subsequent CSV ingest will surface a clearer error than a
1448/// format-detection failure would.
1449#[must_use]
1450pub fn detect_file_format(path: &std::path::Path) -> InferredFileFormat {
1451 let ext = path
1452 .extension()
1453 .and_then(|e| e.to_str())
1454 .unwrap_or("")
1455 .to_lowercase();
1456 match ext.as_str() {
1457 "parquet" | "pq" => return InferredFileFormat::Parquet,
1458 "arrow" | "ipc" | "feather" => return InferredFileFormat::ArrowIpc,
1459 "json" | "jsonl" | "ndjson" => return InferredFileFormat::Json,
1460 _ => {}
1461 }
1462 // Content-sniff fallback for anything else. We only need the first
1463 // non-whitespace byte; 4 KiB comfortably covers any realistic
1464 // leading-whitespace padding or BOM noise.
1465 use std::io::Read;
1466 if let Ok(mut f) = std::fs::File::open(path) {
1467 let mut buf = [0u8; 4096];
1468 if let Ok(n) = f.read(&mut buf) {
1469 for b in &buf[..n] {
1470 match b {
1471 b' ' | b'\t' | b'\n' | b'\r' | 0xEF | 0xBB | 0xBF => {}
1472 b'[' | b'{' => return InferredFileFormat::Json,
1473 _ => return InferredFileFormat::Csv,
1474 }
1475 }
1476 }
1477 }
1478 InferredFileFormat::Csv
1479}
1480
1481#[cfg(test)]
1482mod read_text_sample_tests {
1483 use super::read_text_sample;
1484 use std::io::Write;
1485
1486 fn write_temp(name: &str, contents: &[u8]) -> tempfile::TempPath {
1487 let mut tmp = tempfile::NamedTempFile::new().expect("temp file");
1488 tmp.write_all(contents).unwrap();
1489 tmp.flush().unwrap();
1490 // Use the path directly so the file persists for the read.
1491 let _ = name;
1492 tmp.into_temp_path()
1493 }
1494
1495 #[test]
1496 fn small_file_returns_full_contents() {
1497 let path = write_temp("small.csv", b"a,b,c\n1,2,3\n");
1498 let s = read_text_sample(&path, 1024 * 1024).unwrap();
1499 assert_eq!(s, "a,b,c\n1,2,3\n");
1500 }
1501
1502 #[test]
1503 fn truncates_at_last_newline_within_budget() {
1504 // 100 bytes total, cap at 30 — should truncate at last newline before byte 30.
1505 use std::fmt::Write;
1506 let mut data = String::new();
1507 for i in 0..20 {
1508 writeln!(&mut data, "row-{i}").unwrap();
1509 }
1510 let path = write_temp("rows.csv", data.as_bytes());
1511 let s = read_text_sample(&path, 30).unwrap();
1512 // Result should end with newline (no partial row).
1513 assert!(s.ends_with('\n'));
1514 // Should be at most 30 bytes.
1515 assert!(s.len() <= 30);
1516 }
1517
1518 #[test]
1519 fn handles_utf8_boundary_split() {
1520 // Construct a file where the byte budget falls in the middle of a
1521 // multi-byte UTF-8 character (4-byte emoji).
1522 let prefix = b"row1\n".to_vec();
1523 let mut data = prefix.clone();
1524 // 4-byte emoji: 🔥 = 0xF0 0x9F 0x94 0xA5
1525 data.extend_from_slice("🔥".as_bytes());
1526 // Total: 5 + 4 = 9 bytes
1527 let path = write_temp("emoji.csv", &data);
1528 // Cap at 8 bytes — splits the emoji at byte 8 (after F0 9F 94)
1529 let s = read_text_sample(&path, 8).unwrap();
1530 // Result must be valid UTF-8 (the emoji is dropped).
1531 // Should end with the prefix's newline, not partial bytes.
1532 assert!(s.ends_with('\n'));
1533 assert_eq!(s, "row1\n");
1534 }
1535
1536 #[test]
1537 fn returns_full_buffer_when_no_newline_under_budget() {
1538 // Single CSV row, no terminator, fits under cap.
1539 let path = write_temp("noeol.csv", b"a,b,c");
1540 let s = read_text_sample(&path, 1024).unwrap();
1541 assert_eq!(s, "a,b,c");
1542 }
1543
1544 #[test]
1545 fn handles_no_newline_in_first_max_bytes() {
1546 // File larger than cap, no newlines anywhere — degenerate case.
1547 // We accept this (returns the full max_bytes prefix, possibly truncated
1548 // at last UTF-8 boundary). Schema inference will likely fail downstream,
1549 // but read_text_sample itself must not panic or return garbage.
1550 let data = vec![b'a'; 2048];
1551 let path = write_temp("noeol-big.csv", &data);
1552 let s = read_text_sample(&path, 100).unwrap();
1553 // No newline → keeps all 100 bytes of ASCII 'a'.
1554 assert_eq!(s.len(), 100);
1555 assert!(s.chars().all(|c| c == 'a'));
1556 }
1557}