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