1use std::path::PathBuf;
16use std::sync::Arc;
17
18use clap::Parser;
19use sqlness::QueryContext;
20use sqlness::interceptor::template::DELIMITER as TEMPLATE_DELIMITER;
21use sqlness::interceptor::{InterceptorRef, Registry};
22
23use crate::cmd::bare::ServerAddr;
24use crate::cmd::compat_case::{self, CompatCase, try_infer_version, version_matches_range};
25use crate::env::bare::{Env, StoreConfig, WalConfig};
26use crate::protocol_interceptor::{self, POSTGRES, PROTOCOL_KEY};
27use crate::util;
28
29const COMPAT_TOPOLOGY: &str = "distributed";
30const COMMENT_PREFIX: &str = "--";
31const INTERCEPTOR_PREFIX: &str = "-- SQLNESS";
32const QUERY_DELIMITER: char = ';';
33
34#[derive(Debug, Parser)]
44pub struct CompatCommand {
45 #[clap(long)]
49 from_version: Option<String>,
50
51 #[clap(long)]
53 from_bins_dir: Option<PathBuf>,
54
55 #[clap(long)]
58 to_bins_dir: Option<PathBuf>,
59
60 #[clap(long)]
63 case_dir: Option<PathBuf>,
64
65 #[clap(long, default_value = ".*")]
67 test_filter: String,
68
69 #[clap(long, default_value = "false")]
71 fail_fast: bool,
72
73 #[clap(long, default_value = "false")]
76 preserve_state: bool,
77
78 #[clap(long, default_value = "true")]
80 pull_version_on_need: bool,
81
82 #[clap(long, default_value = "true")]
85 setup_etcd: bool,
86
87 #[clap(long, default_value = "false")]
90 dry_run: bool,
91}
92
93impl CompatCommand {
94 pub async fn run(self) {
95 let dry_run = self.dry_run;
96
97 if !dry_run && !self.setup_etcd {
99 panic!(
100 "compat MVP requires Docker etcd (--setup-etcd=true); external metadata stores are not supported yet"
101 );
102 }
103
104 let case_dir = self.case_dir.unwrap_or_else(default_compat_case_dir);
106
107 if !case_dir.is_dir() {
108 panic!("Case directory not found: {}", case_dir.display());
109 }
110
111 let mut cases = compat_case::discover_cases(&case_dir).unwrap_or_else(|e| panic!("{e}"));
113
114 let filter_re = regex::Regex::new(&self.test_filter)
116 .unwrap_or_else(|e| panic!("Invalid test filter regex '{}': {e}", self.test_filter));
117 cases.retain(|c| filter_re.is_match(&c.metadata.name));
118
119 cases.retain(|c| c.metadata.topologies.iter().any(|t| t == COMPAT_TOPOLOGY));
121
122 if cases.is_empty() {
123 if dry_run {
124 println!(
125 "DRY-RUN: no compat cases found matching filter '{}' and topology '{}'",
126 self.test_filter, COMPAT_TOPOLOGY
127 );
128 } else {
129 println!(
130 "No compat cases found matching filter '{}' and topology '{}'",
131 self.test_filter, COMPAT_TOPOLOGY
132 );
133 }
134 return;
135 }
136
137 compat_case::validate_cases_metadata(&cases).unwrap_or_else(|e| panic!("{e}"));
141
142 compat_case::validate_case_namespaces(&cases).unwrap_or_else(|e| panic!("{e}"));
146
147 let (from_bins_dir, from_version, from_ver_parsed, to_bins_dir, to_version, to_ver_parsed) =
149 if dry_run {
150 let dry_run_from_bins_dir = self
153 .from_bins_dir
154 .clone()
155 .unwrap_or_else(|| util::get_binary_dir("debug"));
156 let from_ver_str = self
157 .from_version
158 .as_deref()
159 .and_then(|v| {
160 if v == "current" {
161 None
162 } else {
163 Some(v.to_string())
164 }
165 })
166 .or_else(|| try_infer_version(&dry_run_from_bins_dir).map(|v| v.to_string()));
167 let from_ver_parsed = from_ver_str
168 .as_deref()
169 .and_then(|s| compat_case::Version::parse(s).ok());
170
171 let dry_run_to_bins_dir = self
172 .to_bins_dir
173 .clone()
174 .unwrap_or_else(|| util::get_binary_dir("debug"));
175 let to_ver_str = try_infer_version(&dry_run_to_bins_dir).map(|v| v.to_string());
176 let to_ver_parsed = to_ver_str
177 .as_deref()
178 .and_then(|s| compat_case::Version::parse(s).ok());
179
180 (
181 Some(dry_run_from_bins_dir),
182 from_ver_str,
183 from_ver_parsed,
184 Some(dry_run_to_bins_dir),
185 to_ver_str,
186 to_ver_parsed,
187 )
188 } else {
189 let from_bins_dir = resolve_bins(
191 self.from_bins_dir.as_ref(),
192 self.from_version.as_deref(),
193 self.pull_version_on_need,
194 )
195 .await;
196
197 let from_version = if let Some(ref ver) = self.from_version {
198 if ver != "current" {
199 Some(ver.clone())
200 } else {
201 try_infer_version(&from_bins_dir).map(|v| v.to_string())
202 }
203 } else {
204 try_infer_version(&from_bins_dir).map(|v| v.to_string())
205 };
206
207 let from_ver_parsed = from_version
208 .as_deref()
209 .and_then(|s| compat_case::Version::parse(s).ok());
210
211 let to_bins_dir =
212 resolve_bins(self.to_bins_dir.as_ref(), None, self.pull_version_on_need).await;
213 let to_version = try_infer_version(&to_bins_dir).map(|v| v.to_string());
214 let to_ver_parsed = to_version
215 .as_deref()
216 .and_then(|s| compat_case::Version::parse(s).ok());
217
218 (
219 Some(from_bins_dir),
220 from_version,
221 from_ver_parsed,
222 Some(to_bins_dir),
223 to_version,
224 to_ver_parsed,
225 )
226 };
227
228 let pre_filter_count = cases.len();
230 cases.retain(|c| {
231 let from_ok = version_matches_range(from_ver_parsed.as_ref(), &c.metadata.from_range);
232 if !from_ok {
233 let from_label = from_ver_parsed
234 .as_ref()
235 .map(|v| v.to_string())
236 .unwrap_or_else(|| "unknown".to_string());
237 println!(
238 "Skipping case '{}': from_range {:?} does not match version '{}'",
239 c.metadata.name, c.metadata.from_range, from_label
240 );
241 }
242 from_ok
243 });
244 cases.retain(|c| {
245 let to_ok = version_matches_range(to_ver_parsed.as_ref(), &c.metadata.to_range);
246 if !to_ok {
247 let to_label = to_ver_parsed
248 .as_ref()
249 .map(|v| v.to_string())
250 .unwrap_or_else(|| "unknown".to_string());
251 println!(
252 "Skipping case '{}': to_range {:?} does not match version '{}'",
253 c.metadata.name, c.metadata.to_range, to_label
254 );
255 }
256 to_ok
257 });
258
259 if pre_filter_count != cases.len() {
260 println!(
261 "Version-range filtering: {} → {} cases",
262 pre_filter_count,
263 cases.len()
264 );
265 }
266
267 if cases.is_empty() {
268 if dry_run {
269 println!("DRY-RUN: no compat cases would run after version-range filtering");
270 } else {
271 println!("No compat cases remaining after version-range filtering");
272 }
273 return;
274 }
275
276 if dry_run {
277 println!("DRY-RUN: would run {} compat case(s)", cases.len());
278 println!(" topology: {}", COMPAT_TOPOLOGY);
279 println!(
280 " from version: {}",
281 from_version.as_deref().unwrap_or(
282 "unknown (use --from-version, --from-bins-dir, or build debug binary)"
283 )
284 );
285 println!(
286 " to version: {}",
287 to_version
288 .as_deref()
289 .unwrap_or("unknown (use --to-bins-dir or build debug binary)")
290 );
291 if pre_filter_count != cases.len() {
292 println!();
293 println!(
294 "Version-range filtering reduced {} → {} cases (see 'Skipping case' messages above)",
295 pre_filter_count,
296 cases.len()
297 );
298 }
299 println!();
300 for c in &cases {
301 println!(" case: {}", c.metadata.name);
302 println!(" namespace: {}", c.namespace);
303 println!(" from_range: {:?}", c.metadata.from_range);
304 println!(" to_range: {:?}", c.metadata.to_range);
305 println!(" features: {:?}", c.metadata.features);
306 }
307 println!();
308 println!("Dry run complete. Remove --dry-run to execute.");
309 return;
310 }
311
312 println!(
313 "Running {} compat case(s) with topology {}:",
314 cases.len(),
315 COMPAT_TOPOLOGY
316 );
317 for c in &cases {
318 println!(
319 " - {} (namespace: {}, topologies: {:?})",
320 c.metadata.name, c.namespace, c.metadata.topologies
321 );
322 }
323
324 let temp_dir = tempfile::Builder::new()
326 .prefix("sqlness-compat")
327 .tempdir()
328 .unwrap();
329 let sqlness_home = temp_dir.keep();
330 unsafe {
331 std::env::set_var("SQLNESS_HOME", sqlness_home.display().to_string());
332 }
333
334 let interceptor_registry = create_interceptor_registry();
336
337 let store_config = StoreConfig {
339 store_addrs: if self.setup_etcd {
340 vec!["127.0.0.1:2379".to_string()]
341 } else {
342 vec![]
343 },
344 setup_etcd: self.setup_etcd,
345 setup_pg: None,
346 setup_mysql: None,
347 enable_flat_format: false,
348 enable_gc: false,
349 };
350
351 let env = Env::new(
352 sqlness_home.clone(),
353 ServerAddr::default(),
354 WalConfig::RaftEngine,
355 self.pull_version_on_need,
356 from_bins_dir,
357 store_config,
358 vec![],
359 );
360
361 let mut etcd_guard = if self.setup_etcd {
365 Some(EtcdGuard::new())
366 } else {
367 None
368 };
369
370 println!("Starting old-version distributed cluster with flownode...");
372 let mut db = env.compat_start_distributed(0).await;
373
374 println!("Running setup phase...");
375 for case in &cases {
376 run_compat_phase(&db, case, &interceptor_registry, CompatPhase::Setup)
377 .await
378 .unwrap_or_else(|e| panic!("Setup failed for case '{}': {e}", case.metadata.name));
379 println!(" Setup: {} - OK", case.metadata.name);
380 }
381
382 println!("Restarting cluster with new-version binary on preserved state...");
385 env.compat_restart_all(
386 &db,
387 to_bins_dir.expect("to_bins_dir must be resolved in non-dry-run mode"),
388 )
389 .await;
390
391 println!("Running verify phase...");
393 let mut failed = Vec::new();
394 for case in &cases {
395 match run_compat_phase(&db, case, &interceptor_registry, CompatPhase::Verify).await {
396 Ok(()) => println!(" Verify: {} - PASSED", case.metadata.name),
397 Err(e) => {
398 println!(" Verify: {} - FAILED: {e}", case.metadata.name);
399 failed.push(case.metadata.name.clone());
400 if self.fail_fast {
401 break;
402 }
403 }
404 }
405 }
406
407 db.compat_stop();
409
410 if self.setup_etcd {
413 println!("Stopping etcd");
414 util::stop_rm_etcd();
415 }
416
417 if !self.preserve_state {
418 println!("Removing state in {:?}", sqlness_home);
419 tokio::fs::remove_dir_all(sqlness_home)
420 .await
421 .unwrap_or_else(|e| println!("Warning: failed to clean up temp dir: {e}"));
422 }
423
424 if let Some(mut guard) = etcd_guard.take() {
426 guard.disarm();
427 }
428
429 if failed.is_empty() {
430 println!("\n\x1b[32mAll compat tests passed!\x1b[0m");
431 } else {
432 println!("\n\x1b[31mFailed cases: {}\x1b[0m", failed.join(", "));
433 std::process::exit(1);
435 }
436 }
437}
438
439struct EtcdGuard {
445 active: bool,
446}
447
448impl EtcdGuard {
449 fn new() -> Self {
450 let inspect_status = std::process::Command::new("docker")
451 .args(["container", "inspect", "etcd"])
452 .stdout(std::process::Stdio::null())
453 .stderr(std::process::Stdio::null())
454 .status();
455 if inspect_status.is_ok_and(|status| status.success()) {
456 panic!(
457 "A Docker container named `etcd` already exists. \
458 Remove it before running compat tests so the cleanup guard \
459 cannot delete a container it did not create."
460 );
461 }
462 Self { active: true }
463 }
464
465 fn disarm(&mut self) {
466 self.active = false;
467 }
468}
469
470impl Drop for EtcdGuard {
471 fn drop(&mut self) {
472 if self.active {
473 println!("EtcdGuard: emergency etcd cleanup (panic or early exit)");
474 let _ = std::process::Command::new("docker")
476 .args(["container", "stop", "etcd"])
477 .status();
478 let _ = std::process::Command::new("docker")
479 .args(["container", "rm", "etcd"])
480 .status();
481 }
482 }
483}
484
485#[derive(Clone, Copy, PartialEq, Eq)]
487enum CompatPhase {
488 Setup,
489 Verify,
490}
491
492fn create_interceptor_registry() -> Registry {
494 let mut interceptor_registry: Registry = Default::default();
495 interceptor_registry.register(
496 protocol_interceptor::PREFIX,
497 Arc::new(protocol_interceptor::ProtocolInterceptorFactory),
498 );
499 interceptor_registry
500}
501
502async fn resolve_bins(
507 bins_dir: Option<&PathBuf>,
508 version: Option<&str>,
509 pull_version_on_need: bool,
510) -> PathBuf {
511 let dir = if let Some(dir) = bins_dir {
512 dir.clone()
513 } else if let Some(ver) = version {
514 if ver == "current" {
515 util::get_binary_dir("debug")
516 } else {
517 util::maybe_pull_binary(ver, pull_version_on_need).await;
518 let root = std::path::PathBuf::from(util::get_workspace_root());
519 std::path::PathBuf::from_iter([root, std::path::PathBuf::from(ver)])
520 }
521 } else {
522 util::get_binary_dir("debug")
524 };
525
526 let dir = match dir.canonicalize() {
528 Ok(canon) => canon,
529 Err(e) => panic!(
530 "Cannot resolve binary directory '{}': {e}. \
531 Use --from-bins-dir / --to-bins-dir to specify the correct path, \
532 or --from-version to pull a release.",
533 dir.display()
534 ),
535 };
536
537 if !dir.join(util::PROGRAM).is_file() {
538 panic!(
539 "greptime binary not found in '{}'. \
540 Use --from-bins-dir / --to-bins-dir to specify the correct directory, \
541 or build greptime first (e.g. `cargo build -p greptime`). \
542 Note: if you use a custom target-dir, the binary may be elsewhere; \
543 pass the actual directory with --from-bins-dir or --to-bins-dir.",
544 dir.display()
545 );
546 }
547
548 dir
549}
550
551fn default_compat_case_dir() -> PathBuf {
553 let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
554 path.pop();
557 path.push("compatibility");
558 path.push("cases");
559 path
560}
561
562async fn run_compat_phase(
564 db: &crate::env::bare::GreptimeDB,
565 case: &CompatCase,
566 registry: &Registry,
567 phase: CompatPhase,
568) -> Result<(), String> {
569 let sql_file = match phase {
570 CompatPhase::Setup => case.dir.join("setup.sql"),
571 CompatPhase::Verify => case.dir.join("verify.sql"),
572 };
573
574 let sql_content = std::fs::read_to_string(&sql_file)
575 .map_err(|e| format!("Failed to read {}: {e}", sql_file.display()))?;
576
577 let mut statements = parse_sql_file(&sql_content, registry)?;
578
579 let mut verify_output = String::new();
581
582 for statement in &mut statements {
583 let (display, results) = statement.execute(db, &case.namespace).await?;
584
585 match phase {
586 CompatPhase::Setup => {
587 }
589 CompatPhase::Verify => {
590 verify_output.push_str(&display);
591 for result in results {
592 verify_output.push_str(&result);
593 verify_output.push('\n');
594 verify_output.push('\n');
595 }
596 }
597 }
598 }
599
600 if phase == CompatPhase::Verify {
601 trim_trailing_blank_lines(&mut verify_output);
602
603 let result_path = case.dir.join("verify.result");
604
605 if !result_path.is_file() {
608 std::fs::write(&result_path, &verify_output)
609 .map_err(|e| format!("Failed to create {}: {e}", result_path.display()))?;
610 return Err(format!(
611 "Created missing verify.result for case '{}'; review the generated file, commit it, and rerun",
612 case.metadata.name
613 ));
614 }
615
616 let expected = std::fs::read_to_string(&result_path)
617 .map_err(|e| format!("Failed to read {}: {e}", result_path.display()))?;
618
619 if verify_output != expected {
620 std::fs::write(&result_path, &verify_output)
622 .map_err(|e| format!("Failed to update {}: {e}", result_path.display()))?;
623
624 let diff = simple_diff(&expected, &verify_output);
626 return Err(format!(
627 "Result mismatch for case '{}'.\nDiff:\n{diff}",
628 case.metadata.name
629 ));
630 }
631 }
632
633 Ok(())
634}
635
636fn trim_trailing_blank_lines(output: &mut String) {
639 while output.ends_with("\n\n") {
640 output.pop();
641 }
642}
643
644async fn run_namespace_prelude(
655 db: &crate::env::bare::GreptimeDB,
656 namespace: &str,
657 query_ctx: &QueryContext,
658) -> Result<(), String> {
659 let create_db = format!("CREATE DATABASE IF NOT EXISTS {namespace}");
661 let default_ctx = QueryContext::default();
662 db.compat_query(&create_db, &default_ctx).await?;
663
664 if query_ctx
666 .context
667 .get(PROTOCOL_KEY)
668 .is_some_and(|p| p == POSTGRES)
669 {
670 let set_search_path = format!("SET search_path TO '{namespace}'");
671 db.compat_query(&set_search_path, query_ctx).await?;
672 return Ok(());
673 }
674
675 let use_db = format!("USE {namespace}");
677 db.compat_query(&use_db, query_ctx).await?;
678
679 Ok(())
680}
681
682struct ParsedStatement {
684 comment_lines: Vec<String>,
685 display_query: Vec<String>,
686 execute_query: Vec<String>,
687 interceptors: Vec<InterceptorRef>,
688}
689
690impl ParsedStatement {
691 fn new() -> Self {
692 Self {
693 comment_lines: Vec::new(),
694 display_query: Vec::new(),
695 execute_query: Vec::new(),
696 interceptors: Vec::new(),
697 }
698 }
699
700 fn push_comment(&mut self, line: String) {
701 self.comment_lines.push(line);
702 }
703
704 fn push_interceptor(&mut self, line: &str, registry: &Registry) -> Result<(), String> {
705 let Some((_, remaining)) = line.split_once(INTERCEPTOR_PREFIX) else {
706 return Err(format!(
707 "Missing sqlness interceptor prefix in line: {line}"
708 ));
709 };
710 let interceptor = registry.create(remaining).map_err(|e| e.to_string())?;
711 self.interceptors.push(interceptor);
712 Ok(())
713 }
714
715 fn append_query_line(&mut self, line: &str) {
716 self.display_query.push(line.to_string());
717 self.execute_query.push(line.to_string());
718 }
719
720 fn is_empty(&self) -> bool {
721 self.comment_lines.is_empty()
722 && self.display_query.is_empty()
723 && self.execute_query.is_empty()
724 && self.interceptors.is_empty()
725 }
726
727 fn has_query(&self) -> bool {
728 !self.execute_query.is_empty()
729 }
730
731 fn display_text(&self) -> String {
732 let mut output = String::new();
733 for comment in &self.comment_lines {
734 output.push_str(comment);
735 output.push('\n');
736 }
737 for line in &self.display_query {
738 output.push_str(line);
739 }
740 output.push('\n');
741 output.push('\n');
742 output
743 }
744
745 fn concat_query_lines(&self) -> String {
746 self.execute_query
747 .iter()
748 .fold(String::new(), |query, line| query + line)
749 .trim_start()
750 .to_string()
751 }
752
753 async fn before_execute_intercept(&mut self) -> QueryContext {
754 let mut context = QueryContext::default();
755 for interceptor in &self.interceptors {
756 interceptor
757 .before_execute_async(&mut self.execute_query, &mut context)
758 .await;
759 }
760 context
761 }
762
763 async fn after_execute_intercept(&self, result: &mut String) {
764 for interceptor in &self.interceptors {
765 interceptor.after_execute_async(result).await;
766 }
767 }
768
769 async fn execute(
770 &mut self,
771 db: &crate::env::bare::GreptimeDB,
772 namespace: &str,
773 ) -> Result<(String, Vec<String>), String> {
774 let display = self.display_text();
775 let context = self.before_execute_intercept().await;
776 db.compat_prepare_query_context(&context).await;
777 run_namespace_prelude(db, namespace, &context).await?;
778 let sql = self.concat_query_lines();
779 let mut results = Vec::new();
780
781 for sql in sql.split(TEMPLATE_DELIMITER) {
782 if sql.trim().is_empty() {
783 continue;
784 }
785 let sql = if sql.ends_with(QUERY_DELIMITER) {
786 sql.to_string()
787 } else {
788 format!("{sql};")
789 };
790 let mut result = db.compat_query(&sql, &context).await?;
791 self.after_execute_intercept(&mut result).await;
792 results.push(result);
793 }
794
795 Ok((display, results))
796 }
797}
798
799fn parse_sql_file(content: &str, registry: &Registry) -> Result<Vec<ParsedStatement>, String> {
802 let mut statements = Vec::new();
803 let mut current_stmt = ParsedStatement::new();
804
805 for line in content.lines() {
806 if line.starts_with(COMMENT_PREFIX) {
807 current_stmt.push_comment(line.to_string());
808
809 if line.starts_with(INTERCEPTOR_PREFIX) {
810 current_stmt.push_interceptor(line, registry)?;
811 }
812 continue;
813 }
814
815 if line.is_empty() {
816 continue;
817 }
818
819 current_stmt.append_query_line(line);
820
821 if line.ends_with(QUERY_DELIMITER) {
823 if current_stmt.has_query() {
824 statements.push(current_stmt);
825 }
826 current_stmt = ParsedStatement::new();
827 } else {
828 current_stmt.append_query_line("\n");
829 }
830 }
831
832 if !current_stmt.is_empty() && current_stmt.has_query() {
834 statements.push(current_stmt);
835 }
836
837 if statements.is_empty() {
838 return Err("No SQL statements found in file".to_string());
839 }
840
841 Ok(statements)
842}
843
844fn simple_diff(expected: &str, actual: &str) -> String {
846 let mut diff = String::new();
847 let expected_lines: Vec<&str> = expected.lines().collect();
848 let actual_lines: Vec<&str> = actual.lines().collect();
849 let max_len = expected_lines.len().max(actual_lines.len());
850
851 for i in 0..max_len {
852 let exp = expected_lines.get(i).unwrap_or(&"(missing)");
853 let act = actual_lines.get(i).unwrap_or(&"(missing)");
854 if exp != act {
855 diff.push_str(&format!(" Line {}:\n", i + 1));
856 diff.push_str(&format!(" expected: {exp}\n"));
857 diff.push_str(&format!(" actual: {act}\n"));
858 }
859 }
860
861 if diff.is_empty() {
862 diff.push_str(" (files differ but no line-level diff found — may be whitespace)\n");
863 }
864
865 diff
866}
867
868#[cfg(test)]
869mod tests {
870 use super::trim_trailing_blank_lines;
871
872 #[test]
873 fn test_trim_trailing_blank_lines_preserves_single_final_newline() {
874 let mut output = "SELECT 1;\n\n+---+\n\n".to_string();
875 trim_trailing_blank_lines(&mut output);
876
877 assert_eq!(output, "SELECT 1;\n\n+---+\n");
878 }
879}