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