1use 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
24const DEFAULT_BATCH_SIZE: usize = 1000;
26
27#[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
90fn 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
132enum ImportPlan {
134 Vertices {
135 tag: String,
136 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245enum KeyLayout {
246 Vertex,
248 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
310fn 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#[derive(Debug, Clone)]
332struct ColumnMapping {
333 vid_index: usize,
335 src_index: usize,
337 dst_index: usize,
339 property_indices: Vec<(usize, String)>,
341 expected_width: usize,
343}
344
345fn 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
352fn 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 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 (None, None) => (0, 1),
425 (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
454fn 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
464struct 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 fn headers(&self) -> &[String] {
501 &self.headers
502 }
503
504 fn is_eof(&self) -> bool {
505 self.finished
506 }
507
508 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
547enum MergedCsvSource {
550 Rows {
552 headers: Vec<String>,
553 sources: Vec<CsvSource>,
554 current: usize,
555 },
556 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 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
713fn 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
740pub 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
837fn 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}