Skip to main content

linkrs_query/executor/streaming/operators/
copy.rs

1//! COPY FROM execution: parallel CSV import into vertices or edges.
2//!
3//! Pipeline: read CSV records → map columns (header names or positional) →
4//! parse values in parallel per batch (`rayon`) → batch-insert through the
5//! storage writer. Column mapping is computed once up front so the
6//! per-record hot path is a direct index lookup.
7
8use std::collections::HashMap;
9use std::fs::File;
10use std::io::BufReader;
11use std::sync::Arc;
12
13use parking_lot::RwLock;
14use rayon::prelude::*;
15
16use crate::executor::streaming::operators::spec::CopyTarget;
17use crate::executor::streaming::runtime::ExecutionRuntime;
18use crate::storage::{QueryStorage, StorageWriter};
19use linkrs_core::error::QueryError;
20use linkrs_core::types::storage_ids::VertexId;
21use linkrs_core::vertex_edge_path::{Edge, Tag, Vertex};
22use linkrs_core::Value;
23
24/// Default batch size when the statement does not specify one.
25const DEFAULT_BATCH_SIZE: usize = 1000;
26
27/// Execute a COPY FROM statement and return the number of rows inserted.
28#[allow(clippy::too_many_arguments)]
29pub fn execute_copy_from(
30    storage_lock: &Arc<RwLock<dyn QueryStorage>>,
31    space_name: &str,
32    target: &CopyTarget,
33    file_paths: &[String],
34    by_column: bool,
35    header: bool,
36    delimiter: u8,
37    batch_size: usize,
38    runtime: Option<Arc<ExecutionRuntime>>,
39) -> Result<u64, QueryError> {
40    if file_paths.is_empty() {
41        return Err(QueryError::execution(
42            "COPY FROM requires at least one file".to_string(),
43        ));
44    }
45    let batch_sz = if batch_size == 0 {
46        DEFAULT_BATCH_SIZE
47    } else {
48        batch_size
49    };
50
51    match target {
52        CopyTarget::Vertex(tag) => {
53            let schema_props = schema_property_names_vertex(storage_lock, space_name, tag, header)?;
54            run_import(
55                storage_lock,
56                space_name,
57                file_paths,
58                by_column,
59                header,
60                delimiter,
61                batch_sz,
62                runtime,
63                &ImportPlan::Vertices {
64                    tag: tag.clone(),
65                    schema_props,
66                },
67            )
68        }
69        CopyTarget::Edge(edge_type) => {
70            let schema_props =
71                schema_property_names_edge(storage_lock, space_name, edge_type, header)?;
72            run_import(
73                storage_lock,
74                space_name,
75                file_paths,
76                by_column,
77                header,
78                delimiter,
79                batch_sz,
80                runtime,
81                &ImportPlan::Edges {
82                    edge_type: edge_type.clone(),
83                    schema_props,
84                },
85            )
86        }
87    }
88}
89
90/// Property names to assign when the CSV has no header row: taken from the
91/// tag / edge-type schema in declaration order.
92fn schema_property_names_vertex(
93    storage_lock: &Arc<RwLock<dyn QueryStorage>>,
94    space_name: &str,
95    tag: &str,
96    header: bool,
97) -> Result<Option<Vec<String>>, QueryError> {
98    if header {
99        return Ok(None);
100    }
101    let read = storage_lock.read();
102    match read.get_tag(space_name, tag) {
103        Ok(Some(info)) => Ok(Some(
104            info.properties.iter().map(|p| p.name.clone()).collect(),
105        )),
106        Ok(None) => Err(QueryError::execution(format!("Tag '{tag}' not found"))),
107        Err(e) => Err(QueryError::execution(e.to_string())),
108    }
109}
110
111fn schema_property_names_edge(
112    storage_lock: &Arc<RwLock<dyn QueryStorage>>,
113    space_name: &str,
114    edge_type: &str,
115    header: bool,
116) -> Result<Option<Vec<String>>, QueryError> {
117    if header {
118        return Ok(None);
119    }
120    let read = storage_lock.read();
121    match read.get_edge_type(space_name, edge_type) {
122        Ok(Some(info)) => Ok(Some(
123            info.properties.iter().map(|p| p.name.clone()).collect(),
124        )),
125        Ok(None) => Err(QueryError::execution(format!(
126            "Edge type '{edge_type}' not found"
127        ))),
128        Err(e) => Err(QueryError::execution(e.to_string())),
129    }
130}
131
132/// Fully resolved import plan: how to turn each CSV record into an entity.
133enum ImportPlan {
134    Vertices {
135        tag: String,
136        /// Schema property names for the no-header case.
137        schema_props: Option<Vec<String>>,
138    },
139    Edges {
140        edge_type: String,
141        schema_props: Option<Vec<String>>,
142    },
143}
144
145#[allow(clippy::too_many_arguments)]
146fn run_import(
147    storage_lock: &Arc<RwLock<dyn QueryStorage>>,
148    space_name: &str,
149    file_paths: &[String],
150    by_column: bool,
151    header: bool,
152    delimiter: u8,
153    batch_size: usize,
154    runtime: Option<Arc<ExecutionRuntime>>,
155    plan: &ImportPlan,
156) -> Result<u64, QueryError> {
157    let mut merged = MergedCsvSource::open(file_paths, by_column, header, delimiter)?;
158
159    // Resolve the column layout once: key column(s) plus (record index,
160    // property name) pairs for every property column.
161    let mapping = match plan {
162        ImportPlan::Vertices { schema_props, .. } => build_column_mapping(
163            merged.headers(),
164            header,
165            schema_props.as_deref(),
166            KeyLayout::Vertex,
167        )?,
168        ImportPlan::Edges { schema_props, .. } => build_column_mapping(
169            merged.headers(),
170            header,
171            schema_props.as_deref(),
172            KeyLayout::Edge,
173        )?,
174    };
175
176    // All batches share one auto-commit group window: a single write gate,
177    // a shared undo log, and one commit point at the end, so a failure in
178    // any batch rolls back the whole import.
179    // The group window and bound writers use interior locking, so a read
180    // guard is sufficient for the whole import.
181    let guard = storage_lock.read();
182    let storage: &dyn QueryStorage = &*guard;
183    let window = storage.begin_auto_commit_group().map_err(|e| {
184        QueryError::execution(format!("COPY FROM failed to open write window: {e}"))
185    })?;
186
187    let result = (|| -> Result<u64, QueryError> {
188        let mut total: u64 = 0;
189        let mut skipped = 0u64;
190
191        while !merged.is_eof() {
192            if let Some(rt) = &runtime {
193                rt.ensure_not_cancelled()
194                    .map_err(|e| QueryError::execution(e.to_string()))?;
195            }
196            let records = merged.next_batch(batch_size, &mapping, &mut skipped)?;
197            if records.is_empty() {
198                continue;
199            }
200
201            let inserted = {
202                let mut writer = storage.bind_auto_commit_writer(&window).map_err(|e| {
203                    QueryError::execution(format!("COPY FROM failed to bind writer: {e}"))
204                })?;
205                match plan {
206                    ImportPlan::Vertices { tag, .. } => {
207                        flush_vertices(&mut *writer, space_name, tag, &mapping, &records)?
208                    }
209                    ImportPlan::Edges { edge_type, .. } => {
210                        flush_edges(&mut *writer, space_name, edge_type, &mapping, &records)?
211                    }
212                }
213            };
214            total += inserted;
215        }
216
217        if skipped > 0 {
218            log::warn!(
219                "COPY FROM '{}': skipped {skipped} malformed record(s) \
220                 (wrong field count)",
221                file_paths.join(", ")
222            );
223        }
224        Ok(total)
225    })();
226
227    match result {
228        Ok(total) => {
229            storage
230                .finalize_auto_commit_group(&window)
231                .map_err(|e| QueryError::execution(format!("COPY FROM commit failed: {e}")))?;
232            Ok(total)
233        }
234        Err(error) => {
235            if let Err(rollback_error) = storage.rollback_auto_commit_group(&window) {
236                log::error!("COPY FROM rollback failed: {rollback_error}");
237            }
238            Err(error)
239        }
240    }
241}
242
243/// Which key column(s) the record carries before its property columns.
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245enum KeyLayout {
246    /// `vid` plus property columns.
247    Vertex,
248    /// `src` + `dst` plus property columns.
249    Edge,
250}
251
252fn flush_vertices(
253    writer: &mut dyn StorageWriter,
254    space_name: &str,
255    tag: &str,
256    mapping: &ColumnMapping,
257    records: &[Vec<String>],
258) -> Result<u64, QueryError> {
259    let vertices: Vec<Vertex> = records
260        .par_iter()
261        .map(|rec| {
262            let vid_str = rec[mapping.vid_index].trim();
263            let vid = parse_vid(vid_str)?;
264            let props = collect_properties(mapping, rec);
265            Ok(Vertex::new(vid, Tag::new(tag.to_string(), props)))
266        })
267        .collect::<Result<Vec<_>, QueryError>>()?;
268    let count = vertices.len() as u64;
269    StorageWriter::batch_insert_vertices(writer, space_name, vertices)
270        .map_err(|e| QueryError::execution(e.to_string()))?;
271    Ok(count)
272}
273
274fn flush_edges(
275    writer: &mut dyn StorageWriter,
276    space_name: &str,
277    edge_type: &str,
278    mapping: &ColumnMapping,
279    records: &[Vec<String>],
280) -> Result<u64, QueryError> {
281    let edges: Vec<Edge> = records
282        .par_iter()
283        .map(|rec| {
284            let src = parse_vid(rec[mapping.src_index].trim())?;
285            let dst = parse_vid(rec[mapping.dst_index].trim())?;
286            let props = collect_properties(mapping, rec);
287            Ok(Edge {
288                src,
289                dst,
290                edge_type: edge_type.to_string(),
291                ranking: 0,
292                props,
293            })
294        })
295        .collect::<Result<Vec<_>, QueryError>>()?;
296    let count = edges.len() as u64;
297    StorageWriter::batch_insert_edges(writer, space_name, edges)
298        .map_err(|e| QueryError::execution(e.to_string()))?;
299    Ok(count)
300}
301
302fn collect_properties(mapping: &ColumnMapping, rec: &[String]) -> HashMap<Arc<str>, Value> {
303    let mut props = HashMap::with_capacity(mapping.property_indices.len());
304    for &(idx, ref name) in &mapping.property_indices {
305        props.insert(name.as_str().into(), parse_copy_value(rec[idx].trim()));
306    }
307    props
308}
309
310/// Parse a vertex id from a COPY cell. Numeric cells map to the integer id
311/// domain; anything else becomes a string id. Empty cells are an error.
312fn parse_vid(s: &str) -> Result<VertexId, QueryError> {
313    if s.is_empty() {
314        return Err(QueryError::execution(
315            "COPY FROM: empty vertex id cell".to_string(),
316        ));
317    }
318    if let Ok(i) = s.parse::<i64>() {
319        VertexId::try_from_int64(i).map_err(|e| QueryError::execution(e.to_string()))
320    } else {
321        VertexId::try_from_string(s).map_err(|e| QueryError::execution(e.to_string()))
322    }
323}
324
325// ---------------------------------------------------------------------------
326// CSV source and column mapping
327// ---------------------------------------------------------------------------
328
329/// Column layout resolved once per import: where the key column(s) live and
330/// which record indices map to which property names.
331#[derive(Debug, Clone)]
332struct ColumnMapping {
333    /// Record index of the vertex id (vertex imports).
334    vid_index: usize,
335    /// Record index of the source vertex id (edge imports).
336    src_index: usize,
337    /// Record index of the destination vertex id (edge imports).
338    dst_index: usize,
339    /// `(record index, property name)` for every property column, in order.
340    property_indices: Vec<(usize, String)>,
341    /// Field count a well-formed record must have.
342    expected_width: usize,
343}
344
345/// Locate a column by case-insensitive name among several aliases.
346fn find_column(headers: &[String], aliases: &[&str]) -> Option<usize> {
347    headers
348        .iter()
349        .position(|h| aliases.iter().any(|a| h.eq_ignore_ascii_case(a)))
350}
351
352/// Resolve the column mapping for one import.
353///
354/// Header imports locate keys by name (`vid`/`id`/`_id`/`vertex_id`,
355/// `src`/`_src`/`source`, `dst`/`_dst`/`destination`/`dest`) and treat every
356/// remaining column as a property named after its header. Positional imports
357/// (no header) use the first column(s) as keys and assign schema property
358/// names in declaration order.
359fn build_column_mapping(
360    headers: &[String],
361    has_header: bool,
362    schema_props: Option<&[String]>,
363    layout: KeyLayout,
364) -> Result<ColumnMapping, QueryError> {
365    if !has_header {
366        // Positional layout: key column(s) first, then properties in schema order.
367        let schema_props = schema_props.ok_or_else(|| {
368            QueryError::execution(
369                "COPY FROM without HEADER requires the target tag / edge type to \
370                 declare properties"
371                    .to_string(),
372            )
373        })?;
374        let key_count = match layout {
375            KeyLayout::Vertex => 1usize,
376            KeyLayout::Edge => 2,
377        };
378        let property_indices: Vec<(usize, String)> = schema_props
379            .iter()
380            .enumerate()
381            .map(|(i, name)| (key_count + i, name.clone()))
382            .collect();
383        return Ok(ColumnMapping {
384            vid_index: 0,
385            src_index: 0,
386            dst_index: 1,
387            property_indices,
388            expected_width: key_count + schema_props.len(),
389        });
390    }
391
392    match layout {
393        KeyLayout::Vertex => {
394            let vid_index =
395                find_column(headers, &["vid", "id", "_id", "vertex_id"]).ok_or_else(|| {
396                    QueryError::execution(
397                        "COPY FROM: header row has no vertex id column \
398                         (expected one of: vid, id, _id, vertex_id)"
399                            .to_string(),
400                    )
401                })?;
402            let property_indices = excluded_pairs(headers, &[vid_index]);
403            Ok(ColumnMapping {
404                vid_index,
405                src_index: vid_index,
406                dst_index: vid_index,
407                property_indices,
408                expected_width: headers.len(),
409            })
410        }
411        KeyLayout::Edge => {
412            let src_hit = find_column(headers, &["src", "_src", "source"]);
413            let dst_hit = find_column(headers, &["dst", "_dst", "destination", "dest"]);
414            let (src_index, dst_index) = match (src_hit, dst_hit) {
415                (Some(s), Some(d)) => {
416                    if s == d {
417                        return Err(QueryError::execution(
418                            "COPY FROM: src and dst resolve to the same column".to_string(),
419                        ));
420                    }
421                    (s, d)
422                }
423                // Neither named: fall back to the conventional leading pair.
424                (None, None) => (0, 1),
425                // Exactly one named: refusing to guess prevents silently
426                // binding an endpoint to an unrelated property column.
427                (Some(_), None) => {
428                    return Err(QueryError::execution(
429                        "COPY FROM: header row names a source column but no destination \
430                         column (expected one of: dst, _dst, destination, dest)"
431                            .to_string(),
432                    ))
433                }
434                (None, Some(_)) => {
435                    return Err(QueryError::execution(
436                        "COPY FROM: header row names a destination column but no source \
437                         column (expected one of: src, _src, source)"
438                            .to_string(),
439                    ))
440                }
441            };
442            let property_indices = excluded_pairs(headers, &[src_index, dst_index]);
443            Ok(ColumnMapping {
444                vid_index: src_index,
445                src_index,
446                dst_index,
447                property_indices,
448                expected_width: headers.len(),
449            })
450        }
451    }
452}
453
454/// `(index, name)` pairs for every column except the excluded key columns.
455fn excluded_pairs(headers: &[String], exclude: &[usize]) -> Vec<(usize, String)> {
456    headers
457        .iter()
458        .enumerate()
459        .filter(|(i, _)| !exclude.contains(i))
460        .map(|(i, h)| (i, h.clone()))
461        .collect()
462}
463
464/// Buffered CSV record source with batch reads.
465struct CsvSource {
466    headers: Vec<String>,
467    reader: csv::StringRecordsIntoIter<std::io::BufReader<File>>,
468    finished: bool,
469}
470
471impl CsvSource {
472    fn open(file_path: &str, header: bool, delimiter: u8) -> Result<Self, QueryError> {
473        let file = File::open(file_path).map_err(|e| {
474            QueryError::execution(format!("COPY FROM failed to open '{file_path}': {e}"))
475        })?;
476        let mut reader = csv::ReaderBuilder::new()
477            .has_headers(header)
478            .delimiter(delimiter)
479            .trim(csv::Trim::All)
480            .flexible(true)
481            .from_reader(BufReader::new(file));
482        let headers: Vec<String> = if header {
483            reader
484                .headers()
485                .map_err(|e| QueryError::execution(format!("COPY CSV header error: {e}")))?
486                .iter()
487                .map(|s| s.to_string())
488                .collect()
489        } else {
490            Vec::new()
491        };
492        Ok(Self {
493            headers,
494            reader: reader.into_records(),
495            finished: false,
496        })
497    }
498
499    /// Header names when the import declared `HEADER`; empty otherwise.
500    fn headers(&self) -> &[String] {
501        &self.headers
502    }
503
504    fn is_eof(&self) -> bool {
505        self.finished
506    }
507
508    /// Pull up to `batch_size` records as plain string rows.
509    ///
510    /// Records whose field count differs from the expected width are counted
511    /// as malformed and skipped (reported once at the end of the import).
512    fn next_batch(
513        &mut self,
514        batch_size: usize,
515        mapping: &ColumnMapping,
516        skipped: &mut u64,
517    ) -> Result<Vec<Vec<String>>, QueryError> {
518        let mut out: Vec<Vec<String>> = Vec::with_capacity(batch_size);
519        while out.len() < batch_size {
520            let Some(record) = self.next_record()? else {
521                break;
522            };
523            if record.len() != mapping.expected_width {
524                *skipped += 1;
525                continue;
526            }
527            out.push(record.iter().map(|s| s.to_string()).collect());
528        }
529        Ok(out)
530    }
531
532    fn next_record(&mut self) -> Result<Option<csv::StringRecord>, QueryError> {
533        if self.finished {
534            return Ok(None);
535        }
536        match self.reader.next() {
537            Some(Ok(rec)) => Ok(Some(rec)),
538            Some(Err(e)) => Err(QueryError::execution(format!("COPY CSV read error: {e}"))),
539            None => {
540                self.finished = true;
541                Ok(None)
542            }
543        }
544    }
545}
546
547/// Multi-file CSV source supporting row concatenation (default) and
548/// column-wise merge (`BY COLUMN`).
549enum MergedCsvSource {
550    /// Files concatenated row-wise: same schema, rows appended in order.
551    Rows {
552        headers: Vec<String>,
553        sources: Vec<CsvSource>,
554        current: usize,
555    },
556    /// Files merged column-wise: headers concatenated, rows zipped by
557    /// position. All files must carry the same data row count.
558    Columns {
559        headers: Vec<String>,
560        rows: Vec<Vec<String>>,
561        pos: usize,
562    },
563}
564
565impl MergedCsvSource {
566    fn open(
567        file_paths: &[String],
568        by_column: bool,
569        header: bool,
570        delimiter: u8,
571    ) -> Result<Self, QueryError> {
572        if file_paths.is_empty() {
573            return Err(QueryError::execution(
574                "COPY FROM requires at least one file".to_string(),
575            ));
576        }
577        if !by_column {
578            let mut sources = Vec::with_capacity(file_paths.len());
579            for path in file_paths {
580                sources.push(CsvSource::open(path, header, delimiter)?);
581            }
582            let headers = sources
583                .first()
584                .map(|s| s.headers().to_vec())
585                .unwrap_or_default();
586            if header {
587                for (idx, source) in sources.iter().enumerate().skip(1) {
588                    if source.headers() != headers.as_slice() {
589                        return Err(QueryError::execution(format!(
590                            "COPY FROM: header mismatch between '{}' and '{}'",
591                            file_paths[0], file_paths[idx]
592                        )));
593                    }
594                }
595            }
596            return Ok(Self::Rows {
597                headers,
598                sources,
599                current: 0,
600            });
601        }
602
603        // Column-wise merge: load every file, then zip rows by position.
604        let mut per_file_headers: Vec<Vec<String>> = Vec::with_capacity(file_paths.len());
605        let mut per_file_rows: Vec<Vec<Vec<String>>> = Vec::with_capacity(file_paths.len());
606        for path in file_paths {
607            let mut source = CsvSource::open(path, header, delimiter)?;
608            let file_headers = source.headers().to_vec();
609            let file_width = file_headers.len();
610            let mut rows: Vec<Vec<String>> = Vec::new();
611            while let Some(record) = source.next_record()? {
612                rows.push(record.iter().map(|s| s.to_string()).collect());
613            }
614            if header {
615                for (idx, row) in rows.iter().enumerate() {
616                    if row.len() != file_width {
617                        return Err(QueryError::execution(format!(
618                            "COPY FROM BY COLUMN: file '{path}' row {} has {} fields, expected {file_width}",
619                            idx + 1,
620                            row.len()
621                        )));
622                    }
623                }
624            }
625            per_file_headers.push(file_headers);
626            per_file_rows.push(rows);
627        }
628        let row_count = per_file_rows.first().map(|r| r.len()).unwrap_or(0);
629        for (idx, rows) in per_file_rows.iter().enumerate().skip(1) {
630            if rows.len() != row_count {
631                return Err(QueryError::execution(format!(
632                    "COPY FROM BY COLUMN: file '{}' has {} rows, expected {row_count} (from '{}')",
633                    file_paths[idx],
634                    rows.len(),
635                    file_paths[0]
636                )));
637            }
638        }
639        let headers: Vec<String> = per_file_headers.into_iter().flatten().collect();
640        let mut rows: Vec<Vec<String>> = Vec::with_capacity(row_count);
641        for row_idx in 0..row_count {
642            let mut merged: Vec<String> = Vec::new();
643            for file_rows in &per_file_rows {
644                merged.extend(file_rows[row_idx].iter().cloned());
645            }
646            rows.push(merged);
647        }
648        Ok(Self::Columns {
649            headers,
650            rows,
651            pos: 0,
652        })
653    }
654
655    fn headers(&self) -> &[String] {
656        match self {
657            Self::Rows { headers, .. } => headers,
658            Self::Columns { headers, .. } => headers,
659        }
660    }
661
662    fn is_eof(&self) -> bool {
663        match self {
664            Self::Rows {
665                sources, current, ..
666            } => *current >= sources.len(),
667            Self::Columns { rows, pos, .. } => *pos >= rows.len(),
668        }
669    }
670
671    fn next_batch(
672        &mut self,
673        batch_size: usize,
674        mapping: &ColumnMapping,
675        skipped: &mut u64,
676    ) -> Result<Vec<Vec<String>>, QueryError> {
677        match self {
678            Self::Rows {
679                sources, current, ..
680            } => {
681                let mut out: Vec<Vec<String>> = Vec::with_capacity(batch_size);
682                while out.len() < batch_size && *current < sources.len() {
683                    let batch =
684                        sources[*current].next_batch(batch_size - out.len(), mapping, skipped)?;
685                    if batch.is_empty() {
686                        if sources[*current].is_eof() {
687                            *current += 1;
688                            continue;
689                        }
690                        break;
691                    }
692                    out.extend(batch);
693                }
694                Ok(out)
695            }
696            Self::Columns { rows, pos, .. } => {
697                let mut out: Vec<Vec<String>> = Vec::with_capacity(batch_size);
698                while out.len() < batch_size && *pos < rows.len() {
699                    let row = &rows[*pos];
700                    *pos += 1;
701                    if row.len() != mapping.expected_width {
702                        *skipped += 1;
703                        continue;
704                    }
705                    out.push(row.clone());
706                }
707                Ok(out)
708            }
709        }
710    }
711}
712
713/// Parse a raw CSV cell into a typed value.
714///
715/// Inference order: empty → NULL, boolean literals, integers, floats, then
716/// string. The storage layer normalizes values against the tag / edge-type
717/// schema on insert.
718fn parse_copy_value(s: &str) -> Value {
719    if s.is_empty() {
720        return Value::Null(linkrs_core::value::NullType::Null);
721    }
722    if s.eq_ignore_ascii_case("true") {
723        return Value::Bool(true);
724    }
725    if s.eq_ignore_ascii_case("false") {
726        return Value::Bool(false);
727    }
728    if s.eq_ignore_ascii_case("null") {
729        return Value::Null(linkrs_core::value::NullType::Null);
730    }
731    if let Ok(i) = s.parse::<i64>() {
732        return Value::BigInt(i);
733    }
734    if let Ok(f) = s.parse::<f64>() {
735        return Value::Double(f);
736    }
737    Value::string(s)
738}
739
740// ── COPY TO: CSV export ──────────────────────────────────────────────────────
741
742/// Execute a COPY TO statement and return the number of rows exported.
743///
744/// Vertices are written as `vid` followed by the tag's schema property
745/// columns; edges as `src`, `dst` followed by the edge-type property columns.
746/// NULL values become empty cells.
747pub fn execute_copy_to(
748    storage_lock: &Arc<RwLock<dyn QueryStorage>>,
749    space_name: &str,
750    target: &CopyTarget,
751    file_path: &str,
752    header: bool,
753    delimiter: u8,
754) -> Result<u64, QueryError> {
755    let delim = char::from(delimiter);
756    let file = File::create(file_path)
757        .map_err(|e| QueryError::execution(format!("COPY TO: cannot create '{file_path}': {e}")))?;
758    let mut writer = std::io::BufWriter::new(file);
759
760    match target {
761        CopyTarget::Vertex(tag) => {
762            let prop_names: Vec<String> = {
763                let read = storage_lock.read();
764                match read.get_tag(space_name, tag) {
765                    Ok(Some(info)) => info.properties.iter().map(|p| p.name.clone()).collect(),
766                    Ok(None) => {
767                        return Err(QueryError::execution(format!("Tag '{tag}' not found")))
768                    }
769                    Err(e) => return Err(QueryError::execution(e.to_string())),
770                }
771            };
772            let vertices = {
773                let read = storage_lock.read();
774                read.scan_vertices_by_tag(space_name, tag)
775                    .map_err(|e| QueryError::execution(e.to_string()))?
776            };
777            if header {
778                let mut head = vec!["vid".to_string()];
779                head.extend(prop_names.iter().cloned());
780                write_csv_line(&mut writer, &head, delim)?;
781            }
782            let mut count = 0u64;
783            for vertex in &vertices {
784                let props = vertex.properties();
785                let mut cells = vec![csv_cell(&Value::from(vertex.vid), delim)];
786                cells.extend(prop_names.iter().map(|name| {
787                    props
788                        .get(name.as_str())
789                        .map_or_else(String::new, |v| csv_cell(v, delim))
790                }));
791                write_csv_line(&mut writer, &cells, delim)?;
792                count += 1;
793            }
794            Ok(count)
795        }
796        CopyTarget::Edge(edge_type) => {
797            let prop_names: Vec<String> = {
798                let read = storage_lock.read();
799                match read.get_edge_type(space_name, edge_type) {
800                    Ok(Some(info)) => info.properties.iter().map(|p| p.name.clone()).collect(),
801                    Ok(None) => {
802                        return Err(QueryError::execution(format!(
803                            "Edge type '{edge_type}' not found"
804                        )))
805                    }
806                    Err(e) => return Err(QueryError::execution(e.to_string())),
807                }
808            };
809            let edges = {
810                let read = storage_lock.read();
811                read.scan_edges_by_type(space_name, edge_type)
812                    .map_err(|e| QueryError::execution(e.to_string()))?
813            };
814            if header {
815                let mut head = vec!["src".to_string(), "dst".to_string()];
816                head.extend(prop_names.iter().cloned());
817                write_csv_line(&mut writer, &head, delim)?;
818            }
819            let mut count = 0u64;
820            for edge in &edges {
821                let mut cells = vec![
822                    csv_cell(&Value::from(*edge.src()), delim),
823                    csv_cell(&Value::from(*edge.dst()), delim),
824                ];
825                cells.extend(prop_names.iter().map(|name| {
826                    edge.get_property(name.as_str())
827                        .map_or_else(String::new, |v| csv_cell(v, delim))
828                }));
829                write_csv_line(&mut writer, &cells, delim)?;
830                count += 1;
831            }
832            Ok(count)
833        }
834    }
835}
836
837/// Format one value as a CSV cell: empty for NULL, quoted when the rendered
838/// text contains the delimiter, a quote, or a newline.
839fn csv_cell(value: &Value, delimiter: char) -> String {
840    let text = match value {
841        Value::Null(_) => String::new(),
842        Value::String(s) => s.to_string(),
843        Value::FixedString(s) => s.to_string(),
844        other => format!("{other}"),
845    };
846    if text.contains(delimiter) || text.contains('"') || text.contains('\n') || text.contains('\r')
847    {
848        format!("\"{}\"", text.replace('"', "\"\""))
849    } else {
850        text
851    }
852}
853
854fn write_csv_line<W: std::io::Write>(
855    writer: &mut W,
856    cells: &[String],
857    delimiter: char,
858) -> Result<(), QueryError> {
859    let mut line = String::with_capacity(32 * cells.len());
860    for (i, cell) in cells.iter().enumerate() {
861        if i > 0 {
862            line.push(delimiter);
863        }
864        line.push_str(cell);
865    }
866    line.push('\n');
867    writer
868        .write_all(line.as_bytes())
869        .map_err(|e| QueryError::execution(format!("COPY TO: write failed: {e}")))
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875
876    fn headers(names: &[&str]) -> Vec<String> {
877        names.iter().map(|s| s.to_string()).collect()
878    }
879
880    #[test]
881    fn vertex_header_mapping_locates_vid_and_properties() {
882        let h = headers(&["name", "vid", "age"]);
883        let m = build_column_mapping(&h, true, None, KeyLayout::Vertex).expect("mapping");
884        assert_eq!(m.vid_index, 1);
885        assert_eq!(
886            m.property_indices,
887            vec![(0, "name".to_string()), (2, "age".to_string())]
888        );
889        assert_eq!(m.expected_width, 3);
890    }
891
892    #[test]
893    fn vertex_header_without_id_column_errors() {
894        let h = headers(&["name", "age"]);
895        assert!(build_column_mapping(&h, true, None, KeyLayout::Vertex).is_err());
896    }
897
898    #[test]
899    fn edge_header_named_endpoints() {
900        let h = headers(&["since", "dst", "src"]);
901        let m = build_column_mapping(&h, true, None, KeyLayout::Edge).expect("mapping");
902        assert_eq!(m.src_index, 2);
903        assert_eq!(m.dst_index, 1);
904        assert_eq!(m.property_indices, vec![(0, "since".to_string())]);
905    }
906
907    #[test]
908    fn edge_header_only_src_named_errors() {
909        let h = headers(&["src", "since"]);
910        assert!(build_column_mapping(&h, true, None, KeyLayout::Edge).is_err());
911    }
912
913    #[test]
914    fn edge_header_no_named_endpoints_falls_back_positional() {
915        let h = headers(&["a", "b", "since"]);
916        let m = build_column_mapping(&h, true, None, KeyLayout::Edge).expect("mapping");
917        assert_eq!(m.src_index, 0);
918        assert_eq!(m.dst_index, 1);
919        assert_eq!(m.property_indices, vec![(2, "since".to_string())]);
920    }
921
922    #[test]
923    fn positional_layout_uses_schema_order() {
924        let schema = vec!["name".to_string(), "age".to_string()];
925        let m =
926            build_column_mapping(&[], false, Some(&schema), KeyLayout::Vertex).expect("mapping");
927        assert_eq!(m.vid_index, 0);
928        assert_eq!(
929            m.property_indices,
930            vec![(1, "name".to_string()), (2, "age".to_string())]
931        );
932        assert_eq!(m.expected_width, 3);
933    }
934
935    #[test]
936    fn parse_value_inference() {
937        assert_eq!(
938            parse_copy_value(""),
939            Value::Null(linkrs_core::value::NullType::Null)
940        );
941        assert_eq!(parse_copy_value("true"), Value::Bool(true));
942        assert_eq!(parse_copy_value("42"), Value::BigInt(42));
943        assert_eq!(parse_copy_value("4.5"), Value::Double(4.5));
944        assert_eq!(parse_copy_value("hello"), Value::string("hello"));
945    }
946}