1use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19use std::time::Duration;
20
21use async_trait::async_trait;
22use clap::Parser;
23use common_error::ext::BoxedError;
24use common_telemetry::info;
25use snafu::{OptionExt, ResultExt};
26
27use crate::Tool;
28use crate::common::ObjectStoreConfig;
29use crate::data::export_v2::data::{build_copy_source, execute_copy_database_from};
30use crate::data::export_v2::manifest::{ChunkMeta, ChunkStatus, DataFormat};
31use crate::data::import_v2::coordinator::{
32 ImportResumeConfig, ImportTaskExecutor, build_import_tasks, chunk_has_schema_files,
33 import_with_resume_session_with_progress, prepare_import_resume,
34};
35use crate::data::import_v2::error::{
36 ChunkImportFailedSnafu, EmptyChunkManifestSnafu, ImportStatePathUnavailableSnafu,
37 IncompleteSnapshotSnafu, MissingChunkDataSnafu, Result, SchemaNotInSnapshotSnafu,
38 SnapshotStorageSnafu,
39};
40use crate::data::import_v2::executor::{DdlExecutor, DdlStatement};
41use crate::data::import_v2::state::{ImportTaskKey, default_state_path};
42use crate::data::path::{data_dir_for_schema_chunk, ddl_path_for_schema};
43use crate::data::progress::{ProgressMode, build_progress_reporter};
44use crate::data::snapshot_storage::{OpenDalStorage, SnapshotStorage, validate_uri};
45use crate::database::{DatabaseClient, parse_proxy_opts};
46
47#[derive(Debug, Parser)]
49pub struct ImportV2Command {
50 #[clap(long)]
52 addr: String,
53
54 #[clap(long)]
56 from: String,
57
58 #[clap(long, default_value = "greptime")]
60 catalog: String,
61
62 #[clap(long, value_delimiter = ',')]
65 schemas: Vec<String>,
66
67 #[clap(long)]
69 dry_run: bool,
70
71 #[clap(long, value_enum, default_value_t = ProgressMode::Auto)]
73 progress: ProgressMode,
74
75 #[clap(long, default_value = "1", value_parser = parse_task_parallelism)]
77 task_parallelism: usize,
78
79 #[clap(long)]
83 state_path: Option<PathBuf>,
84
85 #[clap(long)]
87 auth_basic: Option<String>,
88
89 #[clap(long, value_parser = humantime::parse_duration)]
91 timeout: Option<Duration>,
92
93 #[clap(long)]
98 proxy: Option<String>,
99
100 #[clap(long)]
104 no_proxy: bool,
105
106 #[clap(flatten)]
108 storage: ObjectStoreConfig,
109}
110
111impl ImportV2Command {
112 pub async fn build(&self) -> std::result::Result<Box<dyn Tool>, BoxedError> {
113 validate_uri(&self.from)
115 .context(SnapshotStorageSnafu)
116 .map_err(BoxedError::new)?;
117
118 let schemas = if self.schemas.is_empty() {
120 None
121 } else {
122 Some(self.schemas.clone())
123 };
124
125 let storage = OpenDalStorage::from_uri(&self.from, &self.storage)
127 .context(SnapshotStorageSnafu)
128 .map_err(BoxedError::new)?;
129
130 let proxy = parse_proxy_opts(self.proxy.clone(), self.no_proxy)?;
132 let database_client = DatabaseClient::new(
133 self.addr.clone(),
134 self.catalog.clone(),
135 self.auth_basic.clone(),
136 self.timeout.unwrap_or(Duration::from_secs(60)),
137 proxy,
138 self.no_proxy,
139 );
140
141 Ok(Box::new(Import {
142 catalog: self.catalog.clone(),
143 schemas,
144 dry_run: self.dry_run,
145 progress: self.progress,
146 task_parallelism: self.task_parallelism,
147 state_path: self.state_path.clone(),
148 snapshot_uri: self.from.clone(),
149 storage_config: self.storage.clone(),
150 storage: Box::new(storage),
151 database_client,
152 }))
153 }
154}
155
156fn resolve_state_path(
160 override_path: Option<&Path>,
161 snapshot_id: &str,
162 target_addr: &str,
163 catalog: &str,
164 schemas: &[String],
165) -> Result<PathBuf> {
166 if let Some(path) = override_path {
167 return Ok(path.to_path_buf());
168 }
169 default_state_path(snapshot_id, target_addr, catalog, schemas).context(
170 ImportStatePathUnavailableSnafu {
171 snapshot_id: snapshot_id.to_string(),
172 },
173 )
174}
175
176fn parse_task_parallelism(value: &str) -> std::result::Result<usize, String> {
177 let parallelism = value
178 .parse::<usize>()
179 .map_err(|_| "task parallelism must be an integer between 1 and 64".to_string())?;
180 if (1..=64).contains(¶llelism) {
181 Ok(parallelism)
182 } else {
183 Err("task parallelism must be between 1 and 64".to_string())
184 }
185}
186
187pub struct Import {
189 catalog: String,
190 schemas: Option<Vec<String>>,
191 dry_run: bool,
192 progress: ProgressMode,
193 task_parallelism: usize,
194 state_path: Option<PathBuf>,
195 snapshot_uri: String,
196 storage_config: ObjectStoreConfig,
197 storage: Box<dyn SnapshotStorage>,
198 database_client: DatabaseClient,
199}
200
201#[async_trait]
202impl Tool for Import {
203 async fn do_work(&self) -> std::result::Result<(), BoxedError> {
204 self.run().await.map_err(BoxedError::new)
205 }
206}
207
208impl Import {
209 async fn run(&self) -> Result<()> {
210 let manifest = self
212 .storage
213 .read_manifest()
214 .await
215 .context(SnapshotStorageSnafu)?;
216
217 info!(
218 "Loading snapshot: {} (version: {}, schema_only: {})",
219 manifest.snapshot_id, manifest.version, manifest.schema_only
220 );
221
222 manifest.validate_layout().map_err(|reason| {
223 crate::data::import_v2::error::InvalidPackedSnapshotSnafu { reason }.build()
224 })?;
225 let capabilities = if manifest.is_packed() {
226 let capabilities = self
227 .database_client
228 .capabilities()
229 .await
230 .context(crate::data::import_v2::error::DatabaseSnafu)?;
231 capabilities
232 .require("metric_packed_import")
233 .context(crate::data::import_v2::error::DatabaseSnafu)?;
234 Some(capabilities)
235 } else {
236 None
237 };
238
239 info!("Snapshot contains {} schema(s)", manifest.schemas.len());
240
241 let schemas_to_import = match &self.schemas {
243 Some(filter) => canonicalize_schema_filter(filter, &manifest.schemas)?,
244 None => manifest.schemas.clone(),
245 };
246
247 info!("Importing schemas: {:?}", schemas_to_import);
248
249 let ddl_statements = self.read_ddl_statements(&schemas_to_import).await?;
251
252 info!("Generated {} DDL statements", ddl_statements.len());
253
254 let data_tasks = if !manifest.schema_only && !manifest.chunks.is_empty() {
255 validate_data_snapshot(self.storage.as_ref(), &manifest.chunks, &schemas_to_import)
256 .await?;
257 build_import_tasks(&manifest.chunks, &schemas_to_import)
258 } else {
259 Vec::new()
260 };
261
262 if manifest.is_packed() {
263 crate::data::import_v2::packed::validate_snapshot(
264 self.storage.as_ref(),
265 &manifest,
266 &schemas_to_import,
267 )
268 .await?;
269 }
270
271 if self.dry_run {
273 info!("Dry-run mode - DDL statements to execute:");
274 println!();
275 for (i, stmt) in ddl_statements.iter().enumerate() {
276 println!("-- Statement {}", i + 1);
277 println!("{};", stmt.sql);
278 println!();
279 }
280 if !manifest.schema_only && !manifest.chunks.is_empty() {
281 for line in format_data_import_plan(&manifest.chunks, &schemas_to_import) {
282 println!("{line}");
283 }
284 println!();
285 }
286 return Ok(());
287 }
288
289 let capabilities = match capabilities {
290 Some(capabilities) => capabilities,
291 None => self
292 .database_client
293 .capabilities()
294 .await
295 .context(crate::data::import_v2::error::DatabaseSnafu)?,
296 };
297
298 let mut resume_session = if !data_tasks.is_empty() {
299 let state_path = resolve_state_path(
300 self.state_path.as_deref(),
301 &manifest.snapshot_id.to_string(),
302 self.database_client.addr(),
303 &self.catalog,
304 &schemas_to_import,
305 )?;
306 Some(
307 prepare_import_resume(ImportResumeConfig {
308 snapshot_id: manifest.snapshot_id.to_string(),
309 target_addr: self.database_client.addr().to_string(),
310 catalog: self.catalog.clone(),
311 schemas: schemas_to_import.clone(),
312 state_path,
313 tasks: data_tasks,
314 task_parallelism: self.task_parallelism,
315 })
316 .await?,
317 )
318 } else {
319 None
320 };
321
322 let skip_ddl = resume_session
323 .as_ref()
324 .map(|session| session.should_skip_ddl())
325 .unwrap_or(false);
326
327 let ddl_executed = if skip_ddl {
329 info!(
330 "Existing import state has DDL marked completed; skipping DDL execution and resuming data import"
331 );
332 false
333 } else {
334 let executor = DdlExecutor::new(&self.database_client);
335 executor
336 .execute_strict(&ddl_statements, capabilities.supports("metric_batch_ddl"))
337 .await?;
338 if let Some(session) = resume_session.as_mut() {
339 session.mark_ddl_completed().await?;
340 }
341 true
342 };
343
344 if let Some(resume_session) = resume_session {
345 let executor = CopyDatabaseImportTaskExecutor {
346 import: self,
347 format: manifest.format,
348 packed: manifest.is_packed(),
349 };
350 let progress = build_progress_reporter(self.progress);
351 import_with_resume_session_with_progress(resume_session, &executor, progress.as_ref())
352 .await?;
353 }
354
355 if ddl_executed {
356 info!(
357 "Import completed: {} DDL statements executed",
358 ddl_statements.len()
359 );
360 } else {
361 info!("Import completed: DDL execution skipped");
362 }
363
364 Ok(())
365 }
366
367 async fn read_ddl_statements(&self, schemas: &[String]) -> Result<Vec<DdlStatement>> {
368 let mut statements = Vec::new();
369 for schema in schemas {
370 let path = ddl_path_for_schema(schema);
371 let content = self
372 .storage
373 .read_text(&path)
374 .await
375 .context(SnapshotStorageSnafu)?;
376 statements.extend(
377 iter_ddl_statements(&content).map(|sql| ddl_statement_for_schema(schema, sql)),
378 );
379 }
380
381 Ok(statements)
382 }
383}
384
385struct CopyDatabaseImportTaskExecutor<'a> {
386 import: &'a Import,
387 format: DataFormat,
388 packed: bool,
389}
390
391#[async_trait]
392impl ImportTaskExecutor for CopyDatabaseImportTaskExecutor<'_> {
393 async fn import_task(&self, task: &ImportTaskKey) -> Result<()> {
394 let source = build_copy_source(
395 &self.import.snapshot_uri,
396 &self.import.storage_config,
397 &task.schema,
398 task.chunk_id,
399 )
400 .context(ChunkImportFailedSnafu {
401 chunk_id: task.chunk_id,
402 schema: task.schema.clone(),
403 })?;
404
405 execute_copy_database_from(
406 &self.import.database_client,
407 &self.import.catalog,
408 &task.schema,
409 &source,
410 self.format,
411 self.packed,
412 )
413 .await
414 .context(ChunkImportFailedSnafu {
415 chunk_id: task.chunk_id,
416 schema: task.schema.clone(),
417 })
418 }
419}
420
421#[cfg(test)]
422fn parse_ddl_statements(content: &str) -> Vec<String> {
423 iter_ddl_statements(content).collect()
424}
425
426pub(crate) fn iter_ddl_statements(content: &str) -> impl Iterator<Item = String> + '_ {
427 let mut chars = content.chars().peekable();
428 let mut in_single_quote = false;
429 let mut in_double_quote = false;
430 let mut in_line_comment = false;
431 let mut in_block_comment = false;
432
433 std::iter::from_fn(move || {
434 let mut current = String::new();
435 while let Some(ch) = chars.next() {
436 if in_line_comment {
437 if ch == '\n' {
438 in_line_comment = false;
439 current.push('\n');
440 }
441 continue;
442 }
443
444 if in_block_comment {
445 if ch == '*' && chars.peek() == Some(&'/') {
446 chars.next();
447 in_block_comment = false;
448 }
449 continue;
450 }
451
452 if in_single_quote {
453 current.push(ch);
454 if ch == '\'' {
455 if chars.peek() == Some(&'\'') {
456 current.push(chars.next().expect("peeked quote must exist"));
457 } else {
458 in_single_quote = false;
459 }
460 }
461 continue;
462 }
463
464 if in_double_quote {
465 current.push(ch);
466 if ch == '"' {
467 if chars.peek() == Some(&'"') {
468 current.push(chars.next().expect("peeked quote must exist"));
469 } else {
470 in_double_quote = false;
471 }
472 }
473 continue;
474 }
475
476 match ch {
477 '-' if chars.peek() == Some(&'-') => {
478 chars.next();
479 in_line_comment = true;
480 }
481 '/' if chars.peek() == Some(&'*') => {
482 chars.next();
483 in_block_comment = true;
484 }
485 '\'' => {
486 in_single_quote = true;
487 current.push(ch);
488 }
489 '"' => {
490 in_double_quote = true;
491 current.push(ch);
492 }
493 ';' => {
494 let statement = current.trim();
495 if !statement.is_empty() {
496 return Some(statement.to_string());
497 }
498 current.clear();
499 }
500 _ => current.push(ch),
501 }
502 }
503
504 let statement = current.trim();
505 (!statement.is_empty()).then(|| statement.to_string())
506 })
507}
508
509fn ddl_statement_for_schema(schema: &str, sql: String) -> DdlStatement {
510 if is_schema_scoped_statement(&sql) {
511 DdlStatement::with_execution_schema(sql, schema.to_string())
512 } else {
513 DdlStatement::new(sql)
514 }
515}
516
517fn is_schema_scoped_statement(sql: &str) -> bool {
518 let trimmed = sql.trim_start();
519 if !starts_with_keyword(trimmed, "CREATE") {
520 return false;
521 }
522
523 let Some(rest) = trimmed.get("CREATE".len()..) else {
524 return false;
525 };
526 let mut rest = rest.trim_start();
527 if starts_with_keyword(rest, "OR") {
528 let Some(next) = rest.get("OR".len()..) else {
529 return false;
530 };
531 rest = next.trim_start();
532 if !starts_with_keyword(rest, "REPLACE") {
533 return false;
534 }
535 let Some(next) = rest.get("REPLACE".len()..) else {
536 return false;
537 };
538 rest = next.trim_start();
539 }
540
541 if starts_with_keyword(rest, "EXTERNAL") {
542 let Some(next) = rest.get("EXTERNAL".len()..) else {
543 return false;
544 };
545 rest = next.trim_start();
546 }
547
548 starts_with_keyword(rest, "TABLE") || starts_with_keyword(rest, "VIEW")
549}
550
551fn starts_with_keyword(input: &str, keyword: &str) -> bool {
552 input
553 .get(0..keyword.len())
554 .map(|s| s.eq_ignore_ascii_case(keyword))
555 .unwrap_or(false)
556 && input
557 .as_bytes()
558 .get(keyword.len())
559 .map(|b| !b.is_ascii_alphanumeric() && *b != b'_')
560 .unwrap_or(true)
561}
562
563fn canonicalize_schema_filter(
564 filter: &[String],
565 manifest_schemas: &[String],
566) -> Result<Vec<String>> {
567 let mut canonicalized = Vec::new();
568 let mut seen = HashSet::new();
569
570 for schema in filter {
571 let canonical = manifest_schemas
572 .iter()
573 .find(|candidate| candidate.eq_ignore_ascii_case(schema))
574 .cloned()
575 .ok_or_else(|| {
576 SchemaNotInSnapshotSnafu {
577 schema: schema.clone(),
578 }
579 .build()
580 })?;
581
582 if seen.insert(canonical.to_ascii_lowercase()) {
583 canonicalized.push(canonical);
584 }
585 }
586
587 Ok(canonicalized)
588}
589
590fn validate_chunk_statuses(chunks: &[ChunkMeta]) -> Result<()> {
591 let invalid_chunk = chunks
592 .iter()
593 .find(|chunk| !matches!(chunk.status, ChunkStatus::Completed | ChunkStatus::Skipped));
594
595 if let Some(chunk) = invalid_chunk {
596 return IncompleteSnapshotSnafu {
597 chunk_id: chunk.id,
598 status: chunk.status,
599 }
600 .fail();
601 }
602
603 Ok(())
604}
605
606fn format_data_import_plan(chunks: &[ChunkMeta], schemas: &[String]) -> Vec<String> {
607 let mut lines = vec!["-- Data import plan:".to_string()];
608 for chunk in chunks {
609 lines.push(format!("-- Chunk {}: {:?}", chunk.id, chunk.status));
610 for schema in schemas {
611 if chunk_has_schema_files(chunk, schema) {
612 lines.push(format!("-- {} -> COPY DATABASE FROM", schema));
613 }
614 }
615 }
616 lines
617}
618
619async fn validate_data_snapshot(
620 storage: &dyn SnapshotStorage,
621 chunks: &[ChunkMeta],
622 schemas: &[String],
623) -> Result<()> {
624 validate_chunk_statuses(chunks)?;
625 let actual_prefixes = collect_chunk_data_prefixes(storage).await?;
626
627 for chunk in chunks {
628 if chunk.status == ChunkStatus::Skipped {
629 continue;
630 }
631 if chunk.files.is_empty() {
632 return EmptyChunkManifestSnafu { chunk_id: chunk.id }.fail();
633 }
634 for schema in schemas {
635 validate_chunk_schema_files(chunk, schema, &actual_prefixes)?;
636 }
637 }
638
639 Ok(())
640}
641
642async fn collect_chunk_data_prefixes(storage: &dyn SnapshotStorage) -> Result<HashSet<String>> {
643 let files = storage
644 .list_files_recursive("data/")
645 .await
646 .context(SnapshotStorageSnafu)?;
647 let mut prefixes = HashSet::new();
648
649 for path in files {
650 let normalized = path.trim_start_matches('/');
651 let mut parts = normalized.splitn(4, '/');
652 let Some(root) = parts.next() else {
653 continue;
654 };
655 let Some(schema) = parts.next() else {
656 continue;
657 };
658 let Some(chunk_id) = parts.next() else {
659 continue;
660 };
661 if root != "data" {
662 continue;
663 }
664 prefixes.insert(format!("data/{schema}/{chunk_id}/"));
665 }
666
667 Ok(prefixes)
668}
669
670fn validate_chunk_schema_files(
671 chunk: &ChunkMeta,
672 schema: &str,
673 actual_prefixes: &HashSet<String>,
674) -> Result<bool> {
675 if !chunk_has_schema_files(chunk, schema) {
676 return Ok(false);
677 }
678
679 let prefix = data_dir_for_schema_chunk(schema, chunk.id);
680 if !actual_prefixes.contains(&prefix) {
681 return MissingChunkDataSnafu {
682 chunk_id: chunk.id,
683 schema: schema.to_string(),
684 path: prefix,
685 }
686 .fail();
687 }
688
689 Ok(true)
690}
691
692#[cfg(test)]
693mod tests {
694 use std::collections::{HashMap, HashSet};
695
696 use async_trait::async_trait;
697
698 use super::*;
699 use crate::data::export_v2::manifest::{ChunkMeta, ChunkStatus, Manifest, TimeRange};
700 use crate::data::export_v2::schema::SchemaSnapshot;
701 use crate::data::snapshot_storage::SnapshotStorage;
702
703 struct StubStorage {
704 manifest: Manifest,
705 files_by_prefix: HashMap<String, Vec<String>>,
706 }
707
708 #[async_trait]
709 impl SnapshotStorage for StubStorage {
710 async fn exists(&self) -> crate::data::export_v2::error::Result<bool> {
711 Ok(true)
712 }
713
714 async fn read_manifest(&self) -> crate::data::export_v2::error::Result<Manifest> {
715 Ok(self.manifest.clone())
716 }
717
718 async fn file_size(
719 &self,
720 _path: &str,
721 ) -> crate::data::export_v2::error::Result<Option<u64>> {
722 unimplemented!("not needed in import_v2::command tests")
723 }
724
725 async fn write_manifest(
726 &self,
727 _manifest: &Manifest,
728 ) -> crate::data::export_v2::error::Result<()> {
729 unimplemented!("not needed in import_v2::command tests")
730 }
731
732 async fn read_text(&self, _path: &str) -> crate::data::export_v2::error::Result<String> {
733 unimplemented!("not needed in import_v2::command tests")
734 }
735
736 async fn write_text(
737 &self,
738 _path: &str,
739 _content: &str,
740 ) -> crate::data::export_v2::error::Result<()> {
741 unimplemented!("not needed in import_v2::command tests")
742 }
743
744 async fn write_schema(
745 &self,
746 _snapshot: &SchemaSnapshot,
747 ) -> crate::data::export_v2::error::Result<()> {
748 unimplemented!("not needed in import_v2::command tests")
749 }
750
751 async fn create_dir_all(&self, _path: &str) -> crate::data::export_v2::error::Result<()> {
752 unimplemented!("not needed in import_v2::command tests")
753 }
754
755 async fn list_files_recursive(
756 &self,
757 prefix: &str,
758 ) -> crate::data::export_v2::error::Result<Vec<String>> {
759 Ok(self
760 .files_by_prefix
761 .iter()
762 .filter(|(candidate, _)| candidate.starts_with(prefix))
763 .flat_map(|(_, files)| files.clone())
764 .collect())
765 }
766
767 async fn delete_snapshot(&self) -> crate::data::export_v2::error::Result<()> {
768 unimplemented!("not needed in import_v2::command tests")
769 }
770 }
771
772 #[tokio::test]
773 async fn packed_capability_failure_precedes_ddl_reads_and_state_writes() {
774 use tokio::io::{AsyncReadExt, AsyncWriteExt};
775 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
776 let address = listener.local_addr().unwrap();
777 let server = tokio::spawn(async move {
778 let (mut socket, _) = listener.accept().await.unwrap();
779 let mut request = vec![0; 4096];
780 let n = socket.read(&mut request).await.unwrap();
781 assert!(String::from_utf8_lossy(&request[..n]).starts_with("GET /v1/capabilities "));
782 socket
783 .write_all(
784 b"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}",
785 )
786 .await
787 .unwrap();
788 });
789 let dir = tempfile::tempdir().unwrap();
790 let state_path = dir.path().join("state.json");
791 let mut manifest = Manifest::new_schema_only("greptime".into(), vec!["public".into()]);
792 manifest.version = 2;
793 manifest.data_layout = Some("metric-parquet-packs".into());
794 let import = Import {
795 catalog: "greptime".into(),
796 schemas: None,
797 dry_run: false,
798 progress: ProgressMode::default(),
799 task_parallelism: 1,
800 state_path: Some(state_path.clone()),
801 snapshot_uri: "file:///unused".into(),
802 storage_config: Default::default(),
803 storage: Box::new(StubStorage {
804 manifest,
805 files_by_prefix: Default::default(),
806 }),
807 database_client: DatabaseClient::new(
808 address.to_string(),
809 "greptime".into(),
810 None,
811 std::time::Duration::from_secs(5),
812 None,
813 true,
814 ),
815 };
816 assert!(import.run().await.is_err());
817 assert!(!state_path.exists());
818 server.await.unwrap();
819 }
820
821 fn parse_command(extra: &[&str]) -> ImportV2Command {
822 let mut args = vec![
823 "import-v2",
824 "--addr",
825 "127.0.0.1:4000",
826 "--from",
827 "file:///tmp/snapshot",
828 ];
829 args.extend_from_slice(extra);
830 ImportV2Command::try_parse_from(args).expect("command should parse")
831 }
832
833 #[test]
834 fn test_progress_mode_defaults_to_auto() {
835 assert_eq!(parse_command(&[]).progress, ProgressMode::Auto);
836 }
837
838 #[test]
839 fn test_progress_mode_parses_explicit_values() {
840 assert_eq!(
841 parse_command(&["--progress", "always"]).progress,
842 ProgressMode::Always
843 );
844 assert_eq!(
845 parse_command(&["--progress", "never"]).progress,
846 ProgressMode::Never
847 );
848 assert_eq!(
849 parse_command(&["--progress", "auto"]).progress,
850 ProgressMode::Auto
851 );
852 }
853
854 #[test]
855 fn test_progress_mode_rejects_unknown_value() {
856 assert!(
857 ImportV2Command::try_parse_from([
858 "import-v2",
859 "--addr",
860 "127.0.0.1:4000",
861 "--from",
862 "file:///tmp/snapshot",
863 "--progress",
864 "bogus",
865 ])
866 .is_err()
867 );
868 }
869
870 #[test]
871 fn test_task_parallelism_defaults_to_one() {
872 assert_eq!(parse_command(&[]).task_parallelism, 1);
873 }
874
875 #[test]
876 fn test_task_parallelism_parses_valid_values() {
877 assert_eq!(
878 parse_command(&["--task-parallelism", "2"]).task_parallelism,
879 2
880 );
881 assert_eq!(
882 parse_command(&["--task-parallelism", "64"]).task_parallelism,
883 64
884 );
885 }
886
887 #[test]
888 fn test_state_path_defaults_to_none() {
889 assert_eq!(parse_command(&[]).state_path, None);
890 }
891
892 #[test]
893 fn test_state_path_parses_explicit_value() {
894 assert_eq!(
895 parse_command(&["--state-path", "/tmp/import_state.json"]).state_path,
896 Some(PathBuf::from("/tmp/import_state.json"))
897 );
898 }
899
900 #[test]
901 fn test_resolve_state_path_prefers_override() {
902 let override_path = PathBuf::from("/tmp/custom_import_state.json");
903 let resolved = resolve_state_path(
904 Some(override_path.as_path()),
905 "snapshot-1",
906 "127.0.0.1:4000",
907 "greptime",
908 &["public".to_string()],
909 )
910 .unwrap();
911 assert_eq!(resolved, override_path);
912 }
913
914 #[test]
915 fn test_resolve_state_path_uses_default_when_absent() {
916 let resolved = resolve_state_path(
917 None,
918 "snapshot-1",
919 "127.0.0.1:4000",
920 "greptime",
921 &["public".to_string()],
922 )
923 .unwrap();
924 let expected = default_state_path(
925 "snapshot-1",
926 "127.0.0.1:4000",
927 "greptime",
928 &["public".to_string()],
929 )
930 .unwrap();
931 assert_eq!(resolved, expected);
932 }
933
934 #[test]
935 fn test_task_parallelism_rejects_invalid_values() {
936 for value in ["0", "65", "abc"] {
937 assert!(
938 ImportV2Command::try_parse_from([
939 "import-v2",
940 "--addr",
941 "127.0.0.1:4000",
942 "--from",
943 "file:///tmp/snapshot",
944 "--task-parallelism",
945 value,
946 ])
947 .is_err(),
948 "value {value} should be rejected"
949 );
950 }
951 }
952
953 #[test]
954 fn test_parse_ddl_statements() {
955 let content = r#"
956-- Schema: public
957CREATE DATABASE public;
958CREATE TABLE t (ts TIMESTAMP TIME INDEX, host STRING, PRIMARY KEY (host)) ENGINE=mito;
959
960-- comment
961CREATE VIEW v AS SELECT * FROM t;
962"#;
963 let statements = parse_ddl_statements(content);
964 assert_eq!(statements.len(), 3);
965 assert!(statements[0].starts_with("CREATE DATABASE public"));
966 assert!(statements[1].starts_with("CREATE TABLE t"));
967 assert!(statements[2].starts_with("CREATE VIEW v"));
968 }
969
970 #[test]
971 fn test_parse_ddl_statements_preserves_semicolons_in_string_literals() {
972 let content = r#"
973CREATE TABLE t (
974 host STRING DEFAULT 'a;b'
975);
976CREATE VIEW v AS SELECT ';' AS marker;
977"#;
978
979 let statements = parse_ddl_statements(content);
980
981 assert_eq!(statements.len(), 2);
982 assert!(statements[0].contains("'a;b'"));
983 assert!(statements[1].contains("';' AS marker"));
984 }
985
986 #[test]
987 fn test_parse_ddl_statements_handles_comments_without_splitting() {
988 let content = r#"
989-- leading comment
990CREATE TABLE t (ts TIMESTAMP TIME INDEX); /* block; comment */
991CREATE VIEW v AS SELECT 1;
992"#;
993
994 let statements = parse_ddl_statements(content);
995
996 assert_eq!(statements.len(), 2);
997 assert!(statements[0].starts_with("CREATE TABLE t"));
998 assert!(statements[1].starts_with("CREATE VIEW v"));
999 }
1000
1001 #[test]
1002 fn test_canonicalize_schema_filter_uses_manifest_casing() {
1003 let filter = vec!["TEST_DB".to_string(), "PUBLIC".to_string()];
1004 let manifest_schemas = vec!["test_db".to_string(), "public".to_string()];
1005
1006 let canonicalized = canonicalize_schema_filter(&filter, &manifest_schemas).unwrap();
1007
1008 assert_eq!(canonicalized, vec!["test_db", "public"]);
1009 }
1010
1011 #[test]
1012 fn test_canonicalize_schema_filter_dedupes_case_insensitive_matches() {
1013 let filter = vec![
1014 "TEST_DB".to_string(),
1015 "test_db".to_string(),
1016 "PUBLIC".to_string(),
1017 "public".to_string(),
1018 ];
1019 let manifest_schemas = vec!["test_db".to_string(), "public".to_string()];
1020
1021 let canonicalized = canonicalize_schema_filter(&filter, &manifest_schemas).unwrap();
1022
1023 assert_eq!(canonicalized, vec!["test_db", "public"]);
1024 }
1025
1026 #[test]
1027 fn test_canonicalize_schema_filter_rejects_missing_schema() {
1028 let filter = vec!["missing".to_string()];
1029 let manifest_schemas = vec!["test_db".to_string()];
1030
1031 let error = canonicalize_schema_filter(&filter, &manifest_schemas)
1032 .expect_err("missing schema should fail")
1033 .to_string();
1034
1035 assert!(error.contains("missing"));
1036 }
1037
1038 #[test]
1039 fn test_ddl_statement_for_schema_create_table_uses_execution_schema() {
1040 let stmt = ddl_statement_for_schema(
1041 "test_db",
1042 "CREATE TABLE metrics (ts TIMESTAMP TIME INDEX) ENGINE=mito".to_string(),
1043 );
1044 assert_eq!(stmt.execution_schema.as_deref(), Some("test_db"));
1045 }
1046
1047 #[test]
1048 fn test_ddl_statement_for_schema_create_view_uses_execution_schema() {
1049 let stmt = ddl_statement_for_schema(
1050 "test_db",
1051 "CREATE VIEW metrics_view AS SELECT * FROM metrics".to_string(),
1052 );
1053 assert_eq!(stmt.execution_schema.as_deref(), Some("test_db"));
1054 }
1055
1056 #[test]
1057 fn test_ddl_statement_for_schema_create_or_replace_view_uses_execution_schema() {
1058 let stmt = ddl_statement_for_schema(
1059 "test_db",
1060 "CREATE OR REPLACE VIEW metrics_view AS SELECT * FROM metrics".to_string(),
1061 );
1062 assert_eq!(stmt.execution_schema.as_deref(), Some("test_db"));
1063 }
1064
1065 #[test]
1066 fn test_ddl_statement_for_schema_create_external_table_uses_execution_schema() {
1067 let stmt = ddl_statement_for_schema(
1068 "test_db",
1069 "CREATE EXTERNAL TABLE IF NOT EXISTS ext_metrics (ts TIMESTAMP TIME INDEX) ENGINE=file"
1070 .to_string(),
1071 );
1072 assert_eq!(stmt.execution_schema.as_deref(), Some("test_db"));
1073 }
1074
1075 #[test]
1076 fn test_ddl_statement_for_schema_create_database_uses_public_context() {
1077 let stmt = ddl_statement_for_schema("test_db", "CREATE DATABASE test_db".to_string());
1078 assert_eq!(stmt.execution_schema, None);
1079 }
1080
1081 #[test]
1082 fn test_starts_with_keyword_requires_word_boundary() {
1083 assert!(starts_with_keyword("CREATE TABLE t", "CREATE"));
1084 assert!(!starts_with_keyword("CREATED TABLE t", "CREATE"));
1085 assert!(!starts_with_keyword("TABLESPACE foo", "TABLE"));
1086 }
1087
1088 #[test]
1089 fn test_validate_chunk_statuses_rejects_failed_chunk() {
1090 let mut failed = ChunkMeta::new(3, TimeRange::unbounded());
1091 failed.status = ChunkStatus::Failed;
1092
1093 let error = validate_chunk_statuses(&[failed]).expect_err("failed chunk should error");
1094 assert!(error.to_string().contains("Incomplete snapshot"));
1095 }
1096
1097 #[test]
1098 fn test_validate_chunk_statuses_accepts_completed_and_skipped_chunks() {
1099 let mut completed = ChunkMeta::new(1, TimeRange::unbounded());
1100 completed.status = ChunkStatus::Completed;
1101 let skipped = ChunkMeta::skipped(2, TimeRange::unbounded());
1102
1103 assert!(validate_chunk_statuses(&[completed, skipped]).is_ok());
1104 }
1105
1106 #[test]
1107 fn test_chunk_has_schema_files_matches_encoded_schema_prefix() {
1108 let mut chunk = ChunkMeta::new(7, TimeRange::unbounded());
1109 chunk.files = vec![
1110 "data/public/7/a.parquet".to_string(),
1111 "data/%E6%B5%8B%E8%AF%95/7/b.parquet".to_string(),
1112 ];
1113
1114 assert!(chunk_has_schema_files(&chunk, "public"));
1115 assert!(chunk_has_schema_files(&chunk, "测试"));
1116 assert!(!chunk_has_schema_files(&chunk, "metrics"));
1117 }
1118
1119 #[test]
1120 fn test_format_data_import_plan_includes_matching_schemas_only() {
1121 let mut completed = ChunkMeta::new(1, TimeRange::unbounded());
1122 completed.status = ChunkStatus::Completed;
1123 completed.files = vec![
1124 "data/public/1/a.parquet".to_string(),
1125 "data/%E6%B5%8B%E8%AF%95/1/b.parquet".to_string(),
1126 ];
1127 let skipped = ChunkMeta::skipped(2, TimeRange::unbounded());
1128
1129 let lines = format_data_import_plan(
1130 &[completed, skipped],
1131 &[
1132 "public".to_string(),
1133 "测试".to_string(),
1134 "metrics".to_string(),
1135 ],
1136 );
1137
1138 assert_eq!(lines[0], "-- Data import plan:");
1139 assert!(lines.contains(&"-- Chunk 1: Completed".to_string()));
1140 assert!(lines.contains(&"-- public -> COPY DATABASE FROM".to_string()));
1141 assert!(lines.contains(&"-- 测试 -> COPY DATABASE FROM".to_string()));
1142 assert!(!lines.contains(&"-- metrics -> COPY DATABASE FROM".to_string()));
1143 assert!(lines.contains(&"-- Chunk 2: Skipped".to_string()));
1144 }
1145
1146 #[tokio::test]
1147 async fn test_collect_chunk_data_prefixes_indexes_present_prefixes() {
1148 let storage = StubStorage {
1149 manifest: Manifest::new_schema_only("greptime".to_string(), vec!["public".to_string()]),
1150 files_by_prefix: HashMap::from([
1151 (
1152 "data/public/7/".to_string(),
1153 vec!["data/public/7/a.parquet".to_string()],
1154 ),
1155 (
1156 "data/%E6%B5%8B%E8%AF%95/9/".to_string(),
1157 vec!["data/%E6%B5%8B%E8%AF%95/9/b.parquet".to_string()],
1158 ),
1159 ]),
1160 };
1161
1162 let prefixes = collect_chunk_data_prefixes(&storage).await.unwrap();
1163
1164 assert!(prefixes.contains("data/public/7/"));
1165 assert!(prefixes.contains("data/%E6%B5%8B%E8%AF%95/9/"));
1166 }
1167
1168 #[test]
1169 fn test_validate_chunk_schema_files_accepts_present_prefix() {
1170 let mut chunk = ChunkMeta::new(7, TimeRange::unbounded());
1171 chunk.files = vec!["data/public/7/a.parquet".to_string()];
1172 let actual_prefixes = HashSet::from(["data/public/7/".to_string()]);
1173
1174 assert!(validate_chunk_schema_files(&chunk, "public", &actual_prefixes).unwrap());
1175 }
1176
1177 #[test]
1178 fn test_validate_chunk_schema_files_rejects_missing_prefix() {
1179 let mut chunk = ChunkMeta::new(7, TimeRange::unbounded());
1180 chunk.files = vec!["data/public/7/a.parquet".to_string()];
1181
1182 let error = validate_chunk_schema_files(&chunk, "public", &HashSet::new())
1183 .expect_err("missing chunk prefix should fail")
1184 .to_string();
1185 assert!(error.contains("marked completed but no files were found"));
1186 }
1187
1188 #[test]
1189 fn test_validate_chunk_schema_files_skips_absent_schema() {
1190 let mut chunk = ChunkMeta::new(7, TimeRange::unbounded());
1191 chunk.files = vec!["data/public/7/a.parquet".to_string()];
1192
1193 assert!(!validate_chunk_schema_files(&chunk, "metrics", &HashSet::new()).unwrap());
1194 }
1195
1196 #[tokio::test]
1197 async fn test_validate_data_snapshot_rejects_failed_chunk_before_dry_run() {
1198 let mut failed = ChunkMeta::new(3, TimeRange::unbounded());
1199 failed.status = ChunkStatus::Failed;
1200
1201 let storage = StubStorage {
1202 manifest: Manifest::new_schema_only("greptime".to_string(), vec!["public".to_string()]),
1203 files_by_prefix: HashMap::new(),
1204 };
1205
1206 let error = validate_data_snapshot(&storage, &[failed], &["public".to_string()])
1207 .await
1208 .expect_err("failed chunk should reject dry-run validation")
1209 .to_string();
1210 assert!(error.contains("Incomplete snapshot"));
1211 }
1212
1213 #[tokio::test]
1214 async fn test_validate_data_snapshot_rejects_missing_chunk_prefix_before_dry_run() {
1215 let mut completed = ChunkMeta::new(7, TimeRange::unbounded());
1216 completed.status = ChunkStatus::Completed;
1217 completed.files = vec!["data/public/7/a.parquet".to_string()];
1218
1219 let storage = StubStorage {
1220 manifest: Manifest::new_schema_only("greptime".to_string(), vec!["public".to_string()]),
1221 files_by_prefix: HashMap::new(),
1222 };
1223
1224 let error = validate_data_snapshot(&storage, &[completed], &["public".to_string()])
1225 .await
1226 .expect_err("missing chunk prefix should reject dry-run validation")
1227 .to_string();
1228 assert!(error.contains("marked completed but no files were found"));
1229 }
1230
1231 #[tokio::test]
1232 async fn test_validate_data_snapshot_rejects_completed_chunk_with_empty_manifest() {
1233 let mut completed = ChunkMeta::new(7, TimeRange::unbounded());
1234 completed.status = ChunkStatus::Completed;
1235
1236 let storage = StubStorage {
1237 manifest: Manifest::new_schema_only("greptime".to_string(), vec!["public".to_string()]),
1238 files_by_prefix: HashMap::new(),
1239 };
1240
1241 let error = validate_data_snapshot(&storage, &[completed], &["public".to_string()])
1242 .await
1243 .expect_err("empty completed chunk should reject validation")
1244 .to_string();
1245 assert!(error.contains("file manifest is empty"));
1246 }
1247}