Skip to main content

cli/data/import_v2/
command.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Import V2 CLI command.
16
17use 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/// Import from a snapshot.
47#[derive(Debug, Parser)]
48pub struct ImportV2Command {
49    /// Server address to connect (e.g., 127.0.0.1:4000).
50    #[clap(long)]
51    addr: String,
52
53    /// Source snapshot location (e.g., s3://bucket/path, file:///tmp/backup).
54    #[clap(long)]
55    from: String,
56
57    /// Target catalog name.
58    #[clap(long, default_value = "greptime")]
59    catalog: String,
60
61    /// Schema list to import (default: all in snapshot).
62    /// Can be specified multiple times or comma-separated.
63    #[clap(long, value_delimiter = ',')]
64    schemas: Vec<String>,
65
66    /// Verify without importing (dry-run).
67    #[clap(long)]
68    dry_run: bool,
69
70    /// Progress reporting mode.
71    #[clap(long, value_enum, default_value_t = ProgressMode::Auto)]
72    progress: ProgressMode,
73
74    /// Basic authentication (user:password).
75    #[clap(long)]
76    auth_basic: Option<String>,
77
78    /// Request timeout.
79    #[clap(long, value_parser = humantime::parse_duration)]
80    timeout: Option<Duration>,
81
82    /// Proxy server address.
83    ///
84    /// If set, it overrides the system proxy unless `--no-proxy` is specified.
85    /// If neither `--proxy` nor `--no-proxy` is set, system proxy (env) may be used.
86    #[clap(long)]
87    proxy: Option<String>,
88
89    /// Disable all proxy usage (ignores `--proxy` and system proxy).
90    ///
91    /// When set and `--proxy` is not provided, this explicitly disables system proxy.
92    #[clap(long)]
93    no_proxy: bool,
94
95    /// Object store configuration for remote storage backends.
96    #[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 format
103        validate_uri(&self.from)
104            .context(SnapshotStorageSnafu)
105            .map_err(BoxedError::new)?;
106
107        // Parse schemas (empty vec means all schemas)
108        let schemas = if self.schemas.is_empty() {
109            None
110        } else {
111            Some(self.schemas.clone())
112        };
113
114        // Build storage
115        let storage = OpenDalStorage::from_uri(&self.from, &self.storage)
116            .context(SnapshotStorageSnafu)
117            .map_err(BoxedError::new)?;
118
119        // Build database client
120        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
143/// Import tool implementation.
144pub 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        // 1. Read manifest
165        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        // Check version compatibility
177        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        // 2. Determine schemas to import
188        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        // 3. Read DDL statements
196        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        // 4. Dry-run mode: print DDL and exit
209        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        // 5. Execute DDL unless a previous run already completed it.
257        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}