Skip to main content

memvid_core/table/
storage.rs

1// Safe unwrap/expect: JSON value access with fallback defaults.
2#![allow(clippy::unwrap_used, clippy::expect_used)]
3//! MV2 storage integration for extracted tables.
4//!
5//! This module handles storing and retrieving tables from MV2 files
6//! using the existing frame and track infrastructure.
7
8use std::collections::BTreeMap;
9
10use serde_json::json;
11
12use super::types::{ExtractedTable, TableQuality, TableSummary};
13use crate::VecEmbedder;
14use crate::error::{MemvidError, Result};
15use crate::memvid::Memvid;
16use crate::types::embedding_identity::{
17    EmbeddingIdentity, MEMVID_EMBEDDING_DIMENSION_KEY, MEMVID_EMBEDDING_MODEL_KEY,
18    MEMVID_EMBEDDING_NORMALIZED_KEY, MEMVID_EMBEDDING_PROVIDER_KEY,
19};
20use crate::types::{FrameId, PutOptions};
21
22/// Track name used for table frames.
23pub const TABLE_TRACK: &str = "tables";
24
25/// Kind value for table metadata frames.
26pub const TABLE_META_KIND: &str = "table_meta";
27
28/// Kind value for table row frames.
29pub const TABLE_ROW_KIND: &str = "table_row";
30
31/// Store an extracted table in the MV2 file.
32///
33/// Creates two types of frames:
34/// 1. A `table_meta` frame containing table metadata and structure
35/// 2. Multiple `table_row` frames containing individual row data
36///
37/// # Arguments
38/// * `mem` - The Memvid instance to store in
39/// * `table` - The extracted table to store
40/// * `embed_rows` - Whether to generate embeddings for row frames
41///
42/// # Returns
43/// A tuple of (`meta_frame_id`, `row_frame_ids`)
44pub fn store_table(
45    mem: &mut Memvid,
46    table: &ExtractedTable,
47    embed_rows: bool,
48) -> Result<(FrameId, Vec<FrameId>)> {
49    store_table_impl(mem, table, embed_rows, None, None)
50}
51
52/// Store an extracted table in the MV2 file, embedding rows when an embedder is provided.
53///
54/// This is an ingestion-helper API for frontends (CLI / bindings). memvid-core does not ship
55/// with a built-in text embedding runtime; callers must provide an embedder if they want row
56/// embeddings (for semantic search).
57pub fn store_table_with_embedder(
58    mem: &mut Memvid,
59    table: &ExtractedTable,
60    embed_rows: bool,
61    embedder: Option<&dyn VecEmbedder>,
62    embedding_identity: Option<&EmbeddingIdentity>,
63) -> Result<(FrameId, Vec<FrameId>)> {
64    store_table_impl(mem, table, embed_rows, embedder, embedding_identity)
65}
66
67fn store_table_impl(
68    mem: &mut Memvid,
69    table: &ExtractedTable,
70    embed_rows: bool,
71    embedder: Option<&dyn VecEmbedder>,
72    embedding_identity: Option<&EmbeddingIdentity>,
73) -> Result<(FrameId, Vec<FrameId>)> {
74    let table_id = &table.table_id;
75
76    // 1. Create table_meta frame
77    let meta_payload = serde_json::to_vec(&json!({
78        "table_id": table_id,
79        "source_file": table.source_file,
80        "source_uri": table.source_uri,
81        "page_start": table.page_start,
82        "page_end": table.page_end,
83        "headers": table.headers,
84        "n_rows": table.n_rows,
85        "n_cols": table.n_cols,
86        "quality": table.quality.to_string(),
87        "detection_mode": table.detection_mode.to_string(),
88        "confidence_score": table.confidence_score,
89        "warnings": table.warnings,
90        "extraction_ms": table.extraction_ms,
91    }))
92    .map_err(|e| MemvidError::TableExtraction {
93        reason: format!("failed to serialize table metadata: {e}"),
94    })?;
95
96    let mut meta_extra: BTreeMap<String, String> = BTreeMap::new();
97    meta_extra.insert("table_id".to_string(), table_id.clone());
98    meta_extra.insert("n_rows".to_string(), table.n_rows.to_string());
99    meta_extra.insert("n_cols".to_string(), table.n_cols.to_string());
100    meta_extra.insert("page_start".to_string(), table.page_start.to_string());
101    meta_extra.insert("page_end".to_string(), table.page_end.to_string());
102    meta_extra.insert("quality".to_string(), table.quality.to_string());
103    meta_extra.insert(
104        "detection_mode".to_string(),
105        table.detection_mode.to_string(),
106    );
107
108    // Serialize headers for searchability
109    if let Ok(headers_json) = serde_json::to_string(&table.headers) {
110        meta_extra.insert("headers_json".to_string(), headers_json);
111    }
112
113    let meta_options = PutOptions {
114        timestamp: None,
115        track: Some(TABLE_TRACK.to_string()),
116        kind: Some(TABLE_META_KIND.to_string()),
117        uri: Some(format!("mv2://tables/{table_id}")),
118        title: Some(format!(
119            "Table from {} (pages {}-{})",
120            table.source_file, table.page_start, table.page_end
121        )),
122        metadata: None,
123        search_text: Some(table.to_search_text()),
124        tags: vec![
125            "table".to_string(),
126            table.source_file.clone(),
127            format!("{}_quality", table.quality),
128        ],
129        labels: vec![format!("{}_detected", table.detection_mode)],
130        extra_metadata: meta_extra,
131        enable_embedding: false, // Don't embed metadata frame
132        auto_tag: false,
133        extract_dates: false,
134        extract_triplets: false, // Table metadata doesn't need triplet extraction
135        parent_id: None,
136        role: crate::FrameRole::default(),
137        no_raw: false,
138        source_path: None,
139        dedup: false,
140        instant_index: false,    // Tables are batch operations, commit at end
141        extraction_budget_ms: 0, // No budget for table metadata
142    };
143
144    let meta_frame_id = mem.next_frame_id();
145    mem.put_bytes_with_options(&meta_payload, meta_options)?;
146
147    // 2. Create table_row frames
148    let mut row_frame_ids = Vec::with_capacity(table.rows.len());
149
150    for row in &table.rows {
151        // Skip header rows for storage (info is in headers field)
152        if row.is_header_row {
153            continue;
154        }
155
156        // Build cell map: header -> value
157        let cell_map: serde_json::Map<String, serde_json::Value> = table
158            .headers
159            .iter()
160            .enumerate()
161            .filter_map(|(i, header)| {
162                row.cells
163                    .get(i)
164                    .map(|cell| (header.clone(), serde_json::Value::String(cell.text.clone())))
165            })
166            .collect();
167
168        let row_payload = serde_json::to_vec(&json!({
169            "table_id": table_id,
170            "row_index": row.row_index,
171            "page": row.page,
172            "cells": cell_map,
173        }))
174        .map_err(|e| MemvidError::TableExtraction {
175            reason: format!("failed to serialize row data: {e}"),
176        })?;
177
178        // Generate searchable text from row
179        let search_text: String = row
180            .cells
181            .iter()
182            .map(|c| c.text.as_str())
183            .collect::<Vec<_>>()
184            .join(" ");
185
186        let mut row_extra: BTreeMap<String, String> = BTreeMap::new();
187        row_extra.insert("table_id".to_string(), table_id.clone());
188        row_extra.insert("row_index".to_string(), row.row_index.to_string());
189        row_extra.insert("page".to_string(), row.page.to_string());
190        row_extra.insert("parent_frame".to_string(), meta_frame_id.to_string());
191
192        let mut row_options = PutOptions {
193            timestamp: None,
194            track: Some(TABLE_TRACK.to_string()),
195            kind: Some(TABLE_ROW_KIND.to_string()),
196            uri: Some(format!("mv2://tables/{}/row/{}", table_id, row.row_index)),
197            title: None,
198            metadata: None,
199            search_text: Some(search_text),
200            tags: vec!["table_row".to_string(), table_id.clone()],
201            labels: Vec::new(),
202            extra_metadata: row_extra,
203            enable_embedding: embed_rows,
204            auto_tag: false,
205            extract_dates: true,     // Extract dates from cell values
206            extract_triplets: false, // Table rows don't need triplet extraction
207            parent_id: None,
208            role: crate::FrameRole::default(),
209            no_raw: false,
210            source_path: None,
211            dedup: false,
212            instant_index: false, // Tables are batch operations, commit at end
213            extraction_budget_ms: 0, // No budget for table rows
214        };
215
216        let should_embed = embed_rows && embedder.is_some();
217        if should_embed {
218            let embedder = embedder.expect("checked above");
219            let text = row_options.search_text.as_deref().unwrap_or_default();
220            let embedding = embedder.embed_query(text)?;
221
222            if let Some(identity) = embedding_identity {
223                if let Some(provider) = identity.provider.as_deref() {
224                    row_options.extra_metadata.insert(
225                        MEMVID_EMBEDDING_PROVIDER_KEY.to_string(),
226                        provider.to_string(),
227                    );
228                }
229                if let Some(model) = identity.model.as_deref() {
230                    row_options
231                        .extra_metadata
232                        .insert(MEMVID_EMBEDDING_MODEL_KEY.to_string(), model.to_string());
233                }
234                if let Some(dimension) = identity.dimension {
235                    row_options.extra_metadata.insert(
236                        MEMVID_EMBEDDING_DIMENSION_KEY.to_string(),
237                        dimension.to_string(),
238                    );
239                } else {
240                    row_options.extra_metadata.insert(
241                        MEMVID_EMBEDDING_DIMENSION_KEY.to_string(),
242                        embedding.len().to_string(),
243                    );
244                }
245                if let Some(normalized) = identity.normalized {
246                    row_options.extra_metadata.insert(
247                        MEMVID_EMBEDDING_NORMALIZED_KEY.to_string(),
248                        normalized.to_string(),
249                    );
250                }
251            } else {
252                row_options.extra_metadata.insert(
253                    MEMVID_EMBEDDING_DIMENSION_KEY.to_string(),
254                    embedding.len().to_string(),
255                );
256            }
257
258            let row_frame_id = mem.next_frame_id();
259            mem.put_with_embedding_and_options(&row_payload, embedding, row_options)?;
260            row_frame_ids.push(row_frame_id);
261        } else {
262            let row_frame_id = mem.next_frame_id();
263            mem.put_bytes_with_options(&row_payload, row_options)?;
264            row_frame_ids.push(row_frame_id);
265        }
266    }
267
268    Ok((meta_frame_id, row_frame_ids))
269}
270
271/// List all tables stored in an MV2 file.
272///
273/// # Arguments
274/// * `mem` - The Memvid instance to read from (mutable due to internal caching)
275///
276/// # Returns
277/// Vector of table summaries
278pub fn list_tables(mem: &mut Memvid) -> Result<Vec<TableSummary>> {
279    // First, collect the frame IDs that are table_meta frames
280    let meta_frame_ids: Vec<FrameId> = mem
281        .toc
282        .frames
283        .iter()
284        .enumerate()
285        .filter(|(_, frame)| frame.kind.as_deref() == Some(TABLE_META_KIND))
286        .map(|(id, _)| id as FrameId)
287        .collect();
288
289    let mut summaries = Vec::new();
290
291    // Now iterate over the collected frame IDs
292    for frame_id in meta_frame_ids {
293        // Read frame payload
294        let payload_bytes = mem.frame_canonical_payload(frame_id)?;
295        let payload = String::from_utf8_lossy(&payload_bytes);
296        let meta: serde_json::Value =
297            serde_json::from_str(&payload).map_err(|e| MemvidError::TableExtraction {
298                reason: format!("failed to parse table metadata: {e}"),
299            })?;
300
301        let table_id = meta["table_id"].as_str().unwrap_or("unknown").to_string();
302        let source_file = meta["source_file"]
303            .as_str()
304            .unwrap_or("unknown")
305            .to_string();
306        let page_start = meta["page_start"].as_u64().unwrap_or(0);
307        let page_end = meta["page_end"].as_u64().unwrap_or(0);
308        // Safe: table dimensions fit throughout supported platforms
309        #[allow(clippy::cast_possible_truncation)]
310        let n_rows = meta["n_rows"].as_u64().unwrap_or(0) as usize;
311        #[allow(clippy::cast_possible_truncation)]
312        let n_cols = meta["n_cols"].as_u64().unwrap_or(0) as usize;
313        let quality = meta["quality"].as_str().unwrap_or("unknown").to_string();
314        let headers = meta["headers"]
315            .as_array()
316            .map(|arr| {
317                arr.iter()
318                    .filter_map(|v| v.as_str())
319                    .map(String::from)
320                    .collect()
321            })
322            .unwrap_or_default();
323
324        summaries.push(TableSummary {
325            table_id,
326            source_file,
327            page_start: u32::try_from(page_start).unwrap_or(0),
328            page_end: u32::try_from(page_end).unwrap_or(0),
329            n_rows,
330            n_cols,
331            quality: quality.parse().unwrap_or(TableQuality::Medium),
332            headers,
333            frame_id,
334        });
335    }
336
337    Ok(summaries)
338}
339
340/// Get a table by its ID.
341///
342/// # Arguments
343/// * `mem` - The Memvid instance to read from (mutable due to internal caching)
344/// * `table_id` - The table ID to look up
345///
346/// # Returns
347/// The reconstructed `ExtractedTable` if found
348pub fn get_table(mem: &mut Memvid, table_id: &str) -> Result<Option<ExtractedTable>> {
349    // First, find the meta frame ID by scanning frames
350    let meta_frame_id: Option<FrameId> = mem
351        .toc
352        .frames
353        .iter()
354        .enumerate()
355        .find(|(_, f)| {
356            f.kind.as_deref() == Some(TABLE_META_KIND)
357                && f.extra_metadata
358                    .get("table_id")
359                    .is_some_and(|id| id == table_id)
360        })
361        .map(|(id, _)| id as FrameId);
362
363    let meta_frame_id = match meta_frame_id {
364        Some(id) => id,
365        None => return Ok(None),
366    };
367
368    // Read metadata
369    let payload_bytes = mem.frame_canonical_payload(meta_frame_id)?;
370    let payload = String::from_utf8_lossy(&payload_bytes);
371    let meta: serde_json::Value =
372        serde_json::from_str(&payload).map_err(|e| MemvidError::TableExtraction {
373            reason: format!("failed to parse table metadata: {e}"),
374        })?;
375
376    // Reconstruct table
377    let mut table = ExtractedTable::new(
378        meta["table_id"].as_str().unwrap_or(""),
379        meta["source_file"].as_str().unwrap_or(""),
380    );
381
382    table.source_uri = meta["source_uri"].as_str().map(String::from);
383    table.page_start = u32::try_from(meta["page_start"].as_u64().unwrap_or(1)).unwrap_or(1);
384    #[allow(clippy::cast_possible_truncation)]
385    {
386        table.page_end = meta["page_end"].as_u64().unwrap_or(1) as u32;
387    }
388    #[allow(clippy::cast_possible_truncation)]
389    {
390        table.n_cols = meta["n_cols"].as_u64().unwrap_or(0) as usize;
391        table.n_rows = meta["n_rows"].as_u64().unwrap_or(0) as usize;
392    }
393    #[allow(clippy::cast_possible_truncation)]
394    {
395        table.confidence_score = meta["confidence_score"].as_f64().unwrap_or(0.5) as f32;
396    }
397    table.extraction_ms = meta["extraction_ms"].as_u64().unwrap_or(0);
398
399    table.headers = meta["headers"]
400        .as_array()
401        .map(|arr| {
402            arr.iter()
403                .filter_map(|v| v.as_str())
404                .map(String::from)
405                .collect()
406        })
407        .unwrap_or_default();
408
409    table.warnings = meta["warnings"]
410        .as_array()
411        .map(|arr| {
412            arr.iter()
413                .filter_map(|v| v.as_str())
414                .map(String::from)
415                .collect()
416        })
417        .unwrap_or_default();
418
419    table.quality = meta["quality"]
420        .as_str()
421        .and_then(|s| s.parse().ok())
422        .unwrap_or(TableQuality::Medium);
423
424    // Find row frame IDs and their row indices (collect both to avoid borrow issues)
425    let mut row_frame_ids: Vec<(FrameId, usize)> = mem
426        .toc
427        .frames
428        .iter()
429        .enumerate()
430        .filter(|(_, f)| {
431            f.kind.as_deref() == Some(TABLE_ROW_KIND)
432                && f.extra_metadata
433                    .get("table_id")
434                    .is_some_and(|id| id == table_id)
435        })
436        .map(|(id, f)| {
437            let row_index = f
438                .extra_metadata
439                .get("row_index")
440                .and_then(|s| s.parse::<usize>().ok())
441                .unwrap_or(0);
442            (id as FrameId, row_index)
443        })
444        .collect();
445
446    // Sort by row_index
447    row_frame_ids.sort_by_key(|(_, row_index)| *row_index);
448
449    // Now read each row frame
450    for (frame_id, _) in row_frame_ids {
451        let row_payload_bytes = mem.frame_canonical_payload(frame_id)?;
452        let row_payload = String::from_utf8_lossy(&row_payload_bytes);
453        let row_data: serde_json::Value =
454            serde_json::from_str(&row_payload).map_err(|e| MemvidError::TableExtraction {
455                reason: format!("failed to parse row data: {e}"),
456            })?;
457
458        #[allow(clippy::cast_possible_truncation)]
459        let row_index = row_data["row_index"].as_u64().unwrap_or(0) as usize;
460        #[allow(clippy::cast_possible_truncation)]
461        let page = row_data["page"].as_u64().unwrap_or(1) as u32;
462
463        let cells: Vec<super::types::TableCell> =
464            if let Some(cell_map) = row_data["cells"].as_object() {
465                table
466                    .headers
467                    .iter()
468                    .enumerate()
469                    .map(|(col_idx, header)| {
470                        let text = cell_map
471                            .get(header)
472                            .and_then(|v| v.as_str())
473                            .unwrap_or("")
474                            .to_string();
475                        super::types::TableCell::new(text, col_idx)
476                    })
477                    .collect()
478            } else {
479                Vec::new()
480            };
481
482        table
483            .rows
484            .push(super::types::TableRow::new(row_index, page, cells));
485    }
486
487    Ok(Some(table))
488}
489
490/// Export a table to CSV format.
491///
492/// # Arguments
493/// * `table` - The table to export
494///
495/// # Returns
496/// CSV formatted string
497#[must_use]
498pub fn export_to_csv(table: &ExtractedTable) -> String {
499    let mut output = String::new();
500
501    // Write headers
502    if !table.headers.is_empty() {
503        let header_line: Vec<String> = table.headers.iter().map(|h| escape_csv_field(h)).collect();
504        output.push_str(&header_line.join(","));
505        output.push('\n');
506    }
507
508    // Write data rows
509    for row in &table.rows {
510        if row.is_header_row {
511            continue;
512        }
513
514        let row_line: Vec<String> = row
515            .cells
516            .iter()
517            .map(|c| escape_csv_field(&c.text))
518            .collect();
519        output.push_str(&row_line.join(","));
520        output.push('\n');
521    }
522
523    output
524}
525
526/// Escape a field for CSV output.
527fn escape_csv_field(field: &str) -> String {
528    if field.contains(',') || field.contains('"') || field.contains('\n') {
529        format!("\"{}\"", field.replace('"', "\"\""))
530    } else {
531        field.to_string()
532    }
533}
534
535/// Export a table to JSON format.
536///
537/// # Arguments
538/// * `table` - The table to export
539/// * `as_records` - If true, export as array of records; if false, as columns
540///
541/// # Returns
542/// JSON formatted string
543pub fn export_to_json(table: &ExtractedTable, as_records: bool) -> Result<String> {
544    if as_records {
545        // Array of {header: value} objects
546        let records: Vec<serde_json::Value> = table
547            .data_rows()
548            .iter()
549            .map(|row| {
550                let mut obj = serde_json::Map::new();
551                for (i, header) in table.headers.iter().enumerate() {
552                    let value = row.cells.get(i).map(|c| c.text.clone()).unwrap_or_default();
553                    obj.insert(header.clone(), serde_json::Value::String(value));
554                }
555                serde_json::Value::Object(obj)
556            })
557            .collect();
558
559        serde_json::to_string_pretty(&records).map_err(|e| MemvidError::TableExtraction {
560            reason: format!("failed to serialize to JSON: {e}"),
561        })
562    } else {
563        // Full table structure
564        serde_json::to_string_pretty(table).map_err(|e| MemvidError::TableExtraction {
565            reason: format!("failed to serialize to JSON: {e}"),
566        })
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use crate::table::types::{DetectionMode, TableCell, TableRow};
574
575    fn make_test_table() -> ExtractedTable {
576        let mut table = ExtractedTable::new("test_001", "test.pdf");
577        table.headers = vec!["Name".to_string(), "Age".to_string(), "City".to_string()];
578        table.n_cols = 3;
579        table.page_start = 1;
580        table.page_end = 1;
581        table.detection_mode = DetectionMode::Lattice;
582        table.quality = TableQuality::High;
583
584        // Add header row
585        let header_cells = vec![
586            TableCell::new("Name", 0).as_header(),
587            TableCell::new("Age", 1).as_header(),
588            TableCell::new("City", 2).as_header(),
589        ];
590        table
591            .rows
592            .push(TableRow::new(0, 1, header_cells).as_header());
593
594        // Add data rows
595        table.rows.push(TableRow::new(
596            1,
597            1,
598            vec![
599                TableCell::new("Alice", 0),
600                TableCell::new("30", 1),
601                TableCell::new("New York", 2),
602            ],
603        ));
604        table.rows.push(TableRow::new(
605            2,
606            1,
607            vec![
608                TableCell::new("Bob", 0),
609                TableCell::new("25", 1),
610                TableCell::new("Los Angeles", 2),
611            ],
612        ));
613
614        table.n_rows = 2;
615        table
616    }
617
618    #[test]
619    fn test_export_to_csv() {
620        let table = make_test_table();
621        let csv = export_to_csv(&table);
622
623        assert!(csv.contains("Name,Age,City"));
624        assert!(csv.contains("Alice,30,New York"));
625        assert!(csv.contains("Bob,25,Los Angeles"));
626    }
627
628    #[test]
629    fn test_csv_escaping() {
630        assert_eq!(escape_csv_field("simple"), "simple");
631        assert_eq!(escape_csv_field("with,comma"), "\"with,comma\"");
632        assert_eq!(escape_csv_field("with\"quote"), "\"with\"\"quote\"");
633        assert_eq!(escape_csv_field("with\nnewline"), "\"with\nnewline\"");
634    }
635
636    #[test]
637    fn test_export_to_json_records() {
638        let table = make_test_table();
639        let json = export_to_json(&table, true).unwrap();
640
641        let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
642        assert_eq!(parsed.len(), 2);
643        assert_eq!(parsed[0]["Name"], "Alice");
644        assert_eq!(parsed[0]["Age"], "30");
645    }
646
647    #[test]
648    fn test_export_to_json_full() {
649        let table = make_test_table();
650        let json = export_to_json(&table, false).unwrap();
651
652        let parsed: ExtractedTable = serde_json::from_str(&json).unwrap();
653        assert_eq!(parsed.table_id, "test_001");
654        assert_eq!(parsed.headers.len(), 3);
655    }
656}