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