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::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/// Controls progress reporting for import-v2.
48///
49/// `auto` shows an interactive bar only on a TTY and falls back to lightweight
50/// log progress otherwise; `always` always emits progress, using the bar on a
51/// TTY and lightweight logs otherwise; `never` is silent.
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
53#[value(rename_all = "lowercase")]
54pub(crate) enum ProgressMode {
55    /// Show an interactive bar on a TTY, otherwise log progress.
56    #[default]
57    Auto,
58    /// Always emit progress: a bar on TTY, lightweight logs otherwise.
59    Always,
60    /// Never emit progress.
61    Never,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65enum ProgressOutputKind {
66    Bar,
67    Log,
68    Silent,
69}
70
71/// Selects the progress output style for `mode` given whether stderr is a TTY
72/// and whether the terminal environment is suitable for progress-bar control
73/// sequences.
74///
75/// The interactive `indicatif` stderr target hides itself when redirected or
76/// when the terminal is not user-attended, so forced progress falls back to
77/// [`LogProgress`] instead of a hidden bar in those cases.
78fn 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/// Import from a snapshot.
110#[derive(Debug, Parser)]
111pub struct ImportV2Command {
112    /// Server address to connect (e.g., 127.0.0.1:4000).
113    #[clap(long)]
114    addr: String,
115
116    /// Source snapshot location (e.g., s3://bucket/path, file:///tmp/backup).
117    #[clap(long)]
118    from: String,
119
120    /// Target catalog name.
121    #[clap(long, default_value = "greptime")]
122    catalog: String,
123
124    /// Schema list to import (default: all in snapshot).
125    /// Can be specified multiple times or comma-separated.
126    #[clap(long, value_delimiter = ',')]
127    schemas: Vec<String>,
128
129    /// Verify without importing (dry-run).
130    #[clap(long)]
131    dry_run: bool,
132
133    /// Progress reporting mode.
134    #[clap(long, value_enum, default_value_t = ProgressMode::Auto)]
135    progress: ProgressMode,
136
137    /// Basic authentication (user:password).
138    #[clap(long)]
139    auth_basic: Option<String>,
140
141    /// Request timeout.
142    #[clap(long, value_parser = humantime::parse_duration)]
143    timeout: Option<Duration>,
144
145    /// Proxy server address.
146    ///
147    /// If set, it overrides the system proxy unless `--no-proxy` is specified.
148    /// If neither `--proxy` nor `--no-proxy` is set, system proxy (env) may be used.
149    #[clap(long)]
150    proxy: Option<String>,
151
152    /// Disable all proxy usage (ignores `--proxy` and system proxy).
153    ///
154    /// When set and `--proxy` is not provided, this explicitly disables system proxy.
155    #[clap(long)]
156    no_proxy: bool,
157
158    /// Object store configuration for remote storage backends.
159    #[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 format
166        validate_uri(&self.from)
167            .context(SnapshotStorageSnafu)
168            .map_err(BoxedError::new)?;
169
170        // Parse schemas (empty vec means all schemas)
171        let schemas = if self.schemas.is_empty() {
172            None
173        } else {
174            Some(self.schemas.clone())
175        };
176
177        // Build storage
178        let storage = OpenDalStorage::from_uri(&self.from, &self.storage)
179            .context(SnapshotStorageSnafu)
180            .map_err(BoxedError::new)?;
181
182        // Build database client
183        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
206/// Import tool implementation.
207pub 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        // 1. Read manifest
228        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        // Check version compatibility
240        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        // 2. Determine schemas to import
251        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        // 3. Read DDL statements
259        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        // 4. Dry-run mode: print DDL and exit
272        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        // 5. Execute DDL unless a previous run already completed it.
320        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    /// Builds the progress reporter for this run. `never` is silent; otherwise
376    /// an interactive bar is used on TTY stderr, falling back to lightweight log
377    /// progress when stderr is redirected.
378    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        // auto follows the TTY for the bar and falls back to log progress;
803        // always emits progress even when stderr is redirected; never is silent.
804        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}