1use axum::{
10 extract::{Extension, Multipart, State},
11 response::Json as JsonResponse,
12};
13use serde::{Deserialize, Serialize};
14
15use crate::http::{error::HttpError, state::AppState};
16use crate::storage::{
17 StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
18};
19
20#[derive(Debug, Deserialize, utoipa::ToSchema)]
21pub struct ImportForm {
22 pub space: String,
23 pub format: String,
24 pub target_type: String,
25 pub target_name: String,
26 pub batch_size: Option<usize>,
27}
28
29#[derive(Debug, Serialize, utoipa::ToSchema)]
30pub struct ImportResponse {
31 pub success: bool,
32 pub message: String,
33 pub rows_imported: usize,
34 pub rows_failed: usize,
35}
36
37#[derive(Debug, Serialize, utoipa::ToSchema)]
38pub struct ImportStatusResponse {
39 pub job_id: String,
40 pub status: String,
41 pub rows_imported: usize,
42 pub rows_failed: usize,
43 pub started_at: Option<String>,
44 pub completed_at: Option<String>,
45}
46
47fn vertex_statement_from_csv(
50 target_name: &str,
51 headers: &csv::StringRecord,
52 record: &csv::StringRecord,
53) -> Result<String, HttpError> {
54 let fields: Vec<&str> = headers.iter().collect();
55 let values: Vec<String> = record.iter().map(format_value).collect();
56 let vid = record
57 .get(0)
58 .ok_or_else(|| HttpError::BadRequest("CSV vertex rows must start with the VID".into()))?
59 .to_string();
60 Ok(format!(
61 "INSERT VERTEX {} ({}) VALUES \"{}\":({})",
62 target_name,
63 fields.join(", "),
64 vid,
65 values.join(", ")
66 ))
67}
68
69fn edge_statement_from_csv(
73 target_name: &str,
74 headers: &csv::StringRecord,
75 record: &csv::StringRecord,
76) -> Result<String, HttpError> {
77 let src = record.get(0).ok_or_else(|| {
78 HttpError::BadRequest("CSV edge rows must start with the source VID".into())
79 })?;
80 let dst = record
81 .get(1)
82 .ok_or_else(|| HttpError::BadRequest("CSV edge rows must have a destination VID".into()))?;
83 let fields: Vec<&str> = headers.iter().skip(2).collect();
84 let values: Vec<String> = record.iter().skip(2).map(format_value).collect();
85 Ok(format!(
86 "INSERT EDGE {} ({}) VALUES \"{}\"->\"{}\":({})",
87 target_name,
88 fields.join(", "),
89 src,
90 dst,
91 values.join(", ")
92 ))
93}
94
95fn statement_from_json_object(
98 target_type: &str,
99 target_name: &str,
100 obj: &serde_json::Map<String, serde_json::Value>,
101) -> Result<String, HttpError> {
102 match target_type {
103 "tag" | "vertex" => {
104 let vid = obj
105 .get("_id")
106 .and_then(|v| v.as_str())
107 .ok_or_else(|| HttpError::BadRequest("JSON vertex objects must have _id".into()))?;
108 let mut fields = Vec::new();
109 let mut values = Vec::new();
110 for (key, value) in obj {
111 if key == "_id" {
112 continue;
113 }
114 fields.push(key.as_str());
115 values.push(json_value_to_gql(value));
116 }
117 Ok(format!(
118 "INSERT VERTEX {} ({}) VALUES \"{}\":({})",
119 target_name,
120 fields.join(", "),
121 vid,
122 values.join(", ")
123 ))
124 }
125 "edge" => {
126 let src = obj
127 .get("_src")
128 .and_then(|v| v.as_str())
129 .ok_or_else(|| HttpError::BadRequest("JSON edge objects must have _src".into()))?;
130 let dst = obj
131 .get("_dst")
132 .and_then(|v| v.as_str())
133 .ok_or_else(|| HttpError::BadRequest("JSON edge objects must have _dst".into()))?;
134 let mut fields = Vec::new();
135 let mut values = Vec::new();
136 for (key, value) in obj {
137 if key == "_src" || key == "_dst" {
138 continue;
139 }
140 fields.push(key.as_str());
141 values.push(json_value_to_gql(value));
142 }
143 Ok(format!(
144 "INSERT EDGE {} ({}) VALUES \"{}\"->\"{}\":({})",
145 target_name,
146 fields.join(", "),
147 src,
148 dst,
149 values.join(", ")
150 ))
151 }
152 other => Err(HttpError::BadRequest(format!(
153 "Unsupported target_type: {other} (expected 'tag' or 'edge')"
154 ))),
155 }
156}
157
158fn format_value(value: &str) -> String {
159 if value.is_empty() {
160 return "NULL".to_string();
161 }
162 format!("\"{}\"", value.replace('"', "\\\""))
163}
164
165fn json_value_to_gql(value: &serde_json::Value) -> String {
166 match value {
167 serde_json::Value::Null => "NULL".to_string(),
168 serde_json::Value::Bool(b) => b.to_string(),
169 serde_json::Value::Number(n) => n.to_string(),
170 serde_json::Value::String(s) => format!("\"{}\"", s.replace('"', "\\\"")),
171 serde_json::Value::Array(arr) => {
172 let items: Vec<String> = arr.iter().map(json_value_to_gql).collect();
173 format!("[{}]", items.join(", "))
174 }
175 serde_json::Value::Object(_) => {
176 format!("\"{}\"", serde_json::to_string(value).unwrap_or_default())
177 }
178 }
179}
180
181fn parse_statements(
183 format: &str,
184 target_type: &str,
185 target_name: &str,
186 data: &[u8],
187) -> Result<Vec<String>, HttpError> {
188 match format {
189 "csv" => {
190 let mut reader = csv::ReaderBuilder::new()
191 .has_headers(true)
192 .from_reader(data);
193 let headers = reader
194 .headers()
195 .map_err(|e| HttpError::BadRequest(format!("CSV header parse failed: {e}")))?
196 .clone();
197 let mut statements = Vec::new();
198 for record in reader.records() {
199 let record = record
200 .map_err(|e| HttpError::BadRequest(format!("CSV record parse failed: {e}")))?;
201 let statement = match target_type {
202 "edge" => edge_statement_from_csv(target_name, &headers, &record)?,
203 _ => vertex_statement_from_csv(target_name, &headers, &record)?,
204 };
205 statements.push(statement);
206 }
207 Ok(statements)
208 }
209 "json" | "jsonl" => {
210 let text = std::str::from_utf8(data)
211 .map_err(|e| HttpError::BadRequest(format!("File is not valid UTF-8: {e}")))?;
212 let mut statements = Vec::new();
213 if format == "jsonl" {
214 for (idx, line) in text.lines().enumerate() {
215 let line = line.trim();
216 if line.is_empty() {
217 continue;
218 }
219 let obj: serde_json::Map<String, serde_json::Value> =
220 serde_json::from_str(line).map_err(|e| {
221 HttpError::BadRequest(format!(
222 "JSONL line {} parse failed: {e}",
223 idx + 1
224 ))
225 })?;
226 statements.push(statement_from_json_object(target_type, target_name, &obj)?);
227 }
228 } else {
229 let value: serde_json::Value = serde_json::from_str(text)
230 .map_err(|e| HttpError::BadRequest(format!("JSON parse failed: {e}")))?;
231 let objects: Vec<&serde_json::Map<String, serde_json::Value>> = match &value {
232 serde_json::Value::Array(items) => items
233 .iter()
234 .map(|item| {
235 item.as_object().ok_or_else(|| {
236 HttpError::BadRequest(
237 "JSON array items must be objects".to_string(),
238 )
239 })
240 })
241 .collect::<Result<_, _>>()?,
242 serde_json::Value::Object(_) => {
243 vec![value.as_object().expect("checked above")]
244 }
245 _ => {
246 return Err(HttpError::BadRequest(
247 "JSON import expects an object or an array of objects".to_string(),
248 ))
249 }
250 };
251 for obj in objects {
252 statements.push(statement_from_json_object(target_type, target_name, obj)?);
253 }
254 }
255 Ok(statements)
256 }
257 other => Err(HttpError::BadRequest(format!(
258 "Unsupported format: {other} (expected 'csv', 'json' or 'jsonl')"
259 ))),
260 }
261}
262
263#[utoipa::path(
264 post,
265 path = "/v1/import",
266 tag = "Import",
267 responses(
268 (status = 200, body = ImportResponse, description = "File imported"),
269 (status = 500, description = "Internal error")
270 )
271)]
272pub async fn import_file<
273 S: StorageClient
274 + StorageSchemaContextOps
275 + StorageSyncContextOps
276 + StorageOperationContextOps
277 + crate::storage::AutoCommitBatchOps
278 + crate::storage::AutoCommitGroupOps
279 + Clone
280 + Send
281 + Sync
282 + 'static,
283>(
284 State(state): State<AppState<S>>,
285 Extension(session_id): Extension<i64>,
286 mut multipart: Multipart,
287) -> Result<JsonResponse<ImportResponse>, HttpError> {
288 let mut space = String::new();
289 let mut format = "csv".to_string();
290 let mut target_type = "tag".to_string();
291 let mut target_name = String::new();
292 let mut batch_size = Some(1000usize);
293 let mut file_data: Option<Vec<u8>> = None;
294
295 while let Some(field) = multipart
296 .next_field()
297 .await
298 .map_err(|e| HttpError::BadRequest(format!("Multipart error: {e}")))?
299 {
300 let name = field.name().unwrap_or("").to_string();
301
302 match name.as_str() {
303 "space" => {
304 space = field
305 .text()
306 .await
307 .map_err(|e| HttpError::BadRequest(format!("Invalid space field: {e}")))?;
308 }
309 "format" => {
310 format = field
311 .text()
312 .await
313 .map_err(|e| HttpError::BadRequest(format!("Invalid format field: {e}")))?;
314 }
315 "target_type" => {
316 target_type = field.text().await.map_err(|e| {
317 HttpError::BadRequest(format!("Invalid target_type field: {e}"))
318 })?;
319 }
320 "target_name" => {
321 target_name = field.text().await.map_err(|e| {
322 HttpError::BadRequest(format!("Invalid target_name field: {e}"))
323 })?;
324 }
325 "batch_size" => {
326 let bs = field
327 .text()
328 .await
329 .map_err(|e| HttpError::BadRequest(format!("Invalid batch_size field: {e}")))?;
330 batch_size = bs.parse().ok();
331 }
332 "file" => {
333 file_data = Some(
334 field
335 .bytes()
336 .await
337 .map_err(|e| HttpError::BadRequest(format!("Failed to read file: {e}")))?
338 .to_vec(),
339 );
340 }
341 _ => {}
342 }
343 }
344
345 if space.is_empty() {
346 return Err(HttpError::BadRequest("Missing 'space' field".to_string()));
347 }
348 if target_name.is_empty() {
349 return Err(HttpError::BadRequest(
350 "Missing 'target_name' field".to_string(),
351 ));
352 }
353 let file_data =
354 file_data.ok_or_else(|| HttpError::BadRequest("Missing 'file' field".to_string()))?;
355
356 let statements = parse_statements(&format, &target_type, &target_name, &file_data)?;
357 if statements.is_empty() {
358 return Ok(JsonResponse(ImportResponse {
359 success: true,
360 message: "File contained no importable rows".to_string(),
361 rows_imported: 0,
362 rows_failed: 0,
363 }));
364 }
365
366 let group_size = batch_size.unwrap_or(1000).max(1);
367 let outcomes = state
368 .server
369 .get_graph_service()
370 .execute_batch_grouped(session_id, &statements, group_size)
371 .await;
372
373 let mut rows_imported = 0;
374 let mut rows_failed = 0;
375 for outcome in &outcomes {
376 match outcome {
377 Ok(_) => rows_imported += 1,
378 Err(_) => rows_failed += 1,
379 }
380 }
381
382 let success = rows_failed == 0;
383 Ok(JsonResponse(ImportResponse {
384 success,
385 message: if success {
386 format!("Imported {rows_imported} rows into {target_type} '{target_name}'")
387 } else {
388 format!("Imported {rows_imported} rows, {rows_failed} failed (group size {group_size})")
389 },
390 rows_imported,
391 rows_failed,
392 }))
393}
394
395#[utoipa::path(
396 get,
397 path = "/v1/import/{id}",
398 tag = "Import",
399 params(("id" = String, Path, description = "Import job id")),
400 responses(
401 (status = 200, body = ImportStatusResponse, description = "Import job status"),
402 (status = 500, description = "Internal error")
403 )
404)]
405pub async fn import_status<
406 S: StorageClient
407 + StorageSchemaContextOps
408 + StorageSyncContextOps
409 + StorageOperationContextOps
410 + Clone
411 + Send
412 + Sync
413 + 'static,
414>(
415 State(_state): State<AppState<S>>,
416) -> Result<JsonResponse<ImportStatusResponse>, HttpError> {
417 Ok(JsonResponse(ImportStatusResponse {
420 job_id: String::new(),
421 status: "unknown".to_string(),
422 rows_imported: 0,
423 rows_failed: 0,
424 started_at: None,
425 completed_at: None,
426 }))
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 #[test]
434 fn parse_csv_vertex_statements() {
435 let csv = "vid,name,age\n1,Alice,30\n2,Bob,25\n";
436 let statements = parse_statements("csv", "tag", "Person", csv.as_bytes()).unwrap();
437 assert_eq!(statements.len(), 2);
438 assert_eq!(
439 statements[0],
440 "INSERT VERTEX Person (vid, name, age) VALUES \"1\":(\"1\", \"Alice\", \"30\")"
441 );
442 assert_eq!(
443 statements[1],
444 "INSERT VERTEX Person (vid, name, age) VALUES \"2\":(\"2\", \"Bob\", \"25\")"
445 );
446 }
447
448 #[test]
449 fn parse_csv_edge_statements() {
450 let csv = "src,dst,weight\n1,2,0.5\n3,4,1.5\n";
451 let statements = parse_statements("csv", "edge", "Follows", csv.as_bytes()).unwrap();
452 assert_eq!(statements.len(), 2);
453 assert_eq!(
454 statements[0],
455 "INSERT EDGE Follows (weight) VALUES \"1\"->\"2\":(\"0.5\")"
456 );
457 }
458
459 #[test]
460 fn parse_json_array_vertex_statements() {
461 let json = r#"[{"_id":"v1","name":"Alice","age":30},{"_id":"v2","name":"Bob","age":25}]"#;
462 let statements = parse_statements("json", "tag", "Person", json.as_bytes()).unwrap();
463 assert_eq!(statements.len(), 2);
464 assert_eq!(
466 statements[0],
467 "INSERT VERTEX Person (age, name) VALUES \"v1\":(30, \"Alice\")"
468 );
469 }
470
471 #[test]
472 fn parse_jsonl_edge_statements() {
473 let jsonl = "{\"_src\":\"1\",\"_dst\":\"2\",\"weight\":0.5}\n{\"_src\":\"3\",\"_dst\":\"4\",\"weight\":1.5}\n";
474 let statements = parse_statements("jsonl", "edge", "Follows", jsonl.as_bytes()).unwrap();
475 assert_eq!(statements.len(), 2);
476 assert_eq!(
477 statements[0],
478 "INSERT EDGE Follows (weight) VALUES \"1\"->\"2\":(0.5)"
479 );
480 }
481
482 #[test]
483 fn parse_escapes_quotes_in_values() {
484 let csv = "vid,name\n1,\"Al\"\"ice\"\n";
485 let statements = parse_statements("csv", "tag", "Person", csv.as_bytes()).unwrap();
486 assert_eq!(statements.len(), 1);
487 assert!(statements[0].contains("Al\\\"ice"), "{}", statements[0]);
489 }
490
491 #[test]
492 fn parse_rejects_unsupported_format() {
493 assert!(parse_statements("yaml", "tag", "Person", b"a: 1").is_err());
494 }
495
496 #[test]
497 fn parse_rejects_json_edge_without_src() {
498 let json = r#"[{"_dst":"2"}]"#;
499 assert!(parse_statements("json", "edge", "Follows", json.as_bytes()).is_err());
500 }
501
502 #[test]
503 fn parse_empty_csv_yields_no_statements() {
504 let statements = parse_statements("csv", "tag", "Person", b"vid,name\n").unwrap();
505 assert!(statements.is_empty());
506 }
507}