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 };
349
350 let env = Env::new(
351 sqlness_home.clone(),
352 ServerAddr::default(),
353 WalConfig::RaftEngine,
354 self.pull_version_on_need,
355 from_bins_dir,
356 store_config,
357 vec![],
358 );
359
360 let mut etcd_guard = if self.setup_etcd {
364 Some(EtcdGuard::new())
365 } else {
366 None
367 };
368
369 println!("Starting old-version distributed cluster with flownode...");
371 let mut db = env.compat_start_distributed(0).await;
372
373 println!("Running setup phase...");
374 for case in &cases {
375 run_compat_phase(&db, case, &interceptor_registry, CompatPhase::Setup)
376 .await
377 .unwrap_or_else(|e| panic!("Setup failed for case '{}': {e}", case.metadata.name));
378 println!(" Setup: {} - OK", case.metadata.name);
379 }
380
381 println!("Restarting cluster with new-version binary on preserved state...");
384 env.compat_restart_all(
385 &db,
386 to_bins_dir.expect("to_bins_dir must be resolved in non-dry-run mode"),
387 )
388 .await;
389
390 println!("Running verify phase...");
392 let mut failed = Vec::new();
393 for case in &cases {
394 match run_compat_phase(&db, case, &interceptor_registry, CompatPhase::Verify).await {
395 Ok(()) => println!(" Verify: {} - PASSED", case.metadata.name),
396 Err(e) => {
397 println!(" Verify: {} - FAILED: {e}", case.metadata.name);
398 failed.push(case.metadata.name.clone());
399 if self.fail_fast {
400 break;
401 }
402 }
403 }
404 }
405
406 db.compat_stop();
408
409 if self.setup_etcd {
412 println!("Stopping etcd");
413 util::stop_rm_etcd();
414 }
415
416 if !self.preserve_state {
417 println!("Removing state in {:?}", sqlness_home);
418 tokio::fs::remove_dir_all(sqlness_home)
419 .await
420 .unwrap_or_else(|e| println!("Warning: failed to clean up temp dir: {e}"));
421 }
422
423 if let Some(mut guard) = etcd_guard.take() {
425 guard.disarm();
426 }
427
428 if failed.is_empty() {
429 println!("\n\x1b[32mAll compat tests passed!\x1b[0m");
430 } else {
431 println!("\n\x1b[31mFailed cases: {}\x1b[0m", failed.join(", "));
432 std::process::exit(1);
434 }
435 }
436}
437
438struct EtcdGuard {
444 active: bool,
445}
446
447impl EtcdGuard {
448 fn new() -> Self {
449 let inspect_status = std::process::Command::new("docker")
450 .args(["container", "inspect", "etcd"])
451 .stdout(std::process::Stdio::null())
452 .stderr(std::process::Stdio::null())
453 .status();
454 if inspect_status.is_ok_and(|status| status.success()) {
455 panic!(
456 "A Docker container named `etcd` already exists. \
457 Remove it before running compat tests so the cleanup guard \
458 cannot delete a container it did not create."
459 );
460 }
461 Self { active: true }
462 }
463
464 fn disarm(&mut self) {
465 self.active = false;
466 }
467}
468
469impl Drop for EtcdGuard {
470 fn drop(&mut self) {
471 if self.active {
472 println!("EtcdGuard: emergency etcd cleanup (panic or early exit)");
473 let _ = std::process::Command::new("docker")
475 .args(["container", "stop", "etcd"])
476 .status();
477 let _ = std::process::Command::new("docker")
478 .args(["container", "rm", "etcd"])
479 .status();
480 }
481 }
482}
483
484#[derive(Clone, Copy, PartialEq, Eq)]
486enum CompatPhase {
487 Setup,
488 Verify,
489}
490
491fn create_interceptor_registry() -> Registry {
493 let mut interceptor_registry: Registry = Default::default();
494 interceptor_registry.register(
495 protocol_interceptor::PREFIX,
496 Arc::new(protocol_interceptor::ProtocolInterceptorFactory),
497 );
498 interceptor_registry
499}
500
501async fn resolve_bins(
506 bins_dir: Option<&PathBuf>,
507 version: Option<&str>,
508 pull_version_on_need: bool,
509) -> PathBuf {
510 let dir = if let Some(dir) = bins_dir {
511 dir.clone()
512 } else if let Some(ver) = version {
513 if ver == "current" {
514 util::get_binary_dir("debug")
515 } else {
516 util::maybe_pull_binary(ver, pull_version_on_need).await;
517 let root = std::path::PathBuf::from(util::get_workspace_root());
518 std::path::PathBuf::from_iter([root, std::path::PathBuf::from(ver)])
519 }
520 } else {
521 util::get_binary_dir("debug")
523 };
524
525 let dir = match dir.canonicalize() {
527 Ok(canon) => canon,
528 Err(e) => panic!(
529 "Cannot resolve binary directory '{}': {e}. \
530 Use --from-bins-dir / --to-bins-dir to specify the correct path, \
531 or --from-version to pull a release.",
532 dir.display()
533 ),
534 };
535
536 if !dir.join(util::PROGRAM).is_file() {
537 panic!(
538 "greptime binary not found in '{}'. \
539 Use --from-bins-dir / --to-bins-dir to specify the correct directory, \
540 or build greptime first (e.g. `cargo build -p greptime`). \
541 Note: if you use a custom target-dir, the binary may be elsewhere; \
542 pass the actual directory with --from-bins-dir or --to-bins-dir.",
543 dir.display()
544 );
545 }
546
547 dir
548}
549
550fn default_compat_case_dir() -> PathBuf {
552 let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
553 path.pop();
556 path.push("compatibility");
557 path.push("cases");
558 path
559}
560
561async fn run_compat_phase(
563 db: &crate::env::bare::GreptimeDB,
564 case: &CompatCase,
565 registry: &Registry,
566 phase: CompatPhase,
567) -> Result<(), String> {
568 let sql_file = match phase {
569 CompatPhase::Setup => case.dir.join("setup.sql"),
570 CompatPhase::Verify => case.dir.join("verify.sql"),
571 };
572
573 let sql_content = std::fs::read_to_string(&sql_file)
574 .map_err(|e| format!("Failed to read {}: {e}", sql_file.display()))?;
575
576 let mut statements = parse_sql_file(&sql_content, registry)?;
577
578 let mut verify_output = String::new();
580
581 for statement in &mut statements {
582 let (display, results) = statement.execute(db, &case.namespace).await?;
583
584 match phase {
585 CompatPhase::Setup => {
586 }
588 CompatPhase::Verify => {
589 verify_output.push_str(&display);
590 for result in results {
591 verify_output.push_str(&result);
592 verify_output.push('\n');
593 verify_output.push('\n');
594 }
595 }
596 }
597 }
598
599 if phase == CompatPhase::Verify {
600 trim_trailing_blank_lines(&mut verify_output);
601
602 let result_path = case.dir.join("verify.result");
603
604 if !result_path.is_file() {
607 std::fs::write(&result_path, &verify_output)
608 .map_err(|e| format!("Failed to create {}: {e}", result_path.display()))?;
609 return Err(format!(
610 "Created missing verify.result for case '{}'; review the generated file, commit it, and rerun",
611 case.metadata.name
612 ));
613 }
614
615 let expected = std::fs::read_to_string(&result_path)
616 .map_err(|e| format!("Failed to read {}: {e}", result_path.display()))?;
617
618 if verify_output != expected {
619 std::fs::write(&result_path, &verify_output)
621 .map_err(|e| format!("Failed to update {}: {e}", result_path.display()))?;
622
623 let diff = simple_diff(&expected, &verify_output);
625 return Err(format!(
626 "Result mismatch for case '{}'.\nDiff:\n{diff}",
627 case.metadata.name
628 ));
629 }
630 }
631
632 Ok(())
633}
634
635fn trim_trailing_blank_lines(output: &mut String) {
638 while output.ends_with("\n\n") {
639 output.pop();
640 }
641}
642
643async fn run_namespace_prelude(
654 db: &crate::env::bare::GreptimeDB,
655 namespace: &str,
656 query_ctx: &QueryContext,
657) -> Result<(), String> {
658 let create_db = format!("CREATE DATABASE IF NOT EXISTS {namespace}");
660 let default_ctx = QueryContext::default();
661 db.compat_query(&create_db, &default_ctx).await?;
662
663 if query_ctx
665 .context
666 .get(PROTOCOL_KEY)
667 .is_some_and(|p| p == POSTGRES)
668 {
669 let set_search_path = format!("SET search_path TO '{namespace}'");
670 db.compat_query(&set_search_path, query_ctx).await?;
671 return Ok(());
672 }
673
674 let use_db = format!("USE {namespace}");
676 db.compat_query(&use_db, query_ctx).await?;
677
678 Ok(())
679}
680
681struct ParsedStatement {
683 comment_lines: Vec<String>,
684 display_query: Vec<String>,
685 execute_query: Vec<String>,
686 interceptors: Vec<InterceptorRef>,
687}
688
689impl ParsedStatement {
690 fn new() -> Self {
691 Self {
692 comment_lines: Vec::new(),
693 display_query: Vec::new(),
694 execute_query: Vec::new(),
695 interceptors: Vec::new(),
696 }
697 }
698
699 fn push_comment(&mut self, line: String) {
700 self.comment_lines.push(line);
701 }
702
703 fn push_interceptor(&mut self, line: &str, registry: &Registry) -> Result<(), String> {
704 let Some((_, remaining)) = line.split_once(INTERCEPTOR_PREFIX) else {
705 return Err(format!(
706 "Missing sqlness interceptor prefix in line: {line}"
707 ));
708 };
709 let interceptor = registry.create(remaining).map_err(|e| e.to_string())?;
710 self.interceptors.push(interceptor);
711 Ok(())
712 }
713
714 fn append_query_line(&mut self, line: &str) {
715 self.display_query.push(line.to_string());
716 self.execute_query.push(line.to_string());
717 }
718
719 fn is_empty(&self) -> bool {
720 self.comment_lines.is_empty()
721 && self.display_query.is_empty()
722 && self.execute_query.is_empty()
723 && self.interceptors.is_empty()
724 }
725
726 fn has_query(&self) -> bool {
727 !self.execute_query.is_empty()
728 }
729
730 fn display_text(&self) -> String {
731 let mut output = String::new();
732 for comment in &self.comment_lines {
733 output.push_str(comment);
734 output.push('\n');
735 }
736 for line in &self.display_query {
737 output.push_str(line);
738 }
739 output.push('\n');
740 output.push('\n');
741 output
742 }
743
744 fn concat_query_lines(&self) -> String {
745 self.execute_query
746 .iter()
747 .fold(String::new(), |query, line| query + line)
748 .trim_start()
749 .to_string()
750 }
751
752 async fn before_execute_intercept(&mut self) -> QueryContext {
753 let mut context = QueryContext::default();
754 for interceptor in &self.interceptors {
755 interceptor
756 .before_execute_async(&mut self.execute_query, &mut context)
757 .await;
758 }
759 context
760 }
761
762 async fn after_execute_intercept(&self, result: &mut String) {
763 for interceptor in &self.interceptors {
764 interceptor.after_execute_async(result).await;
765 }
766 }
767
768 async fn execute(
769 &mut self,
770 db: &crate::env::bare::GreptimeDB,
771 namespace: &str,
772 ) -> Result<(String, Vec<String>), String> {
773 let display = self.display_text();
774 let context = self.before_execute_intercept().await;
775 db.compat_prepare_query_context(&context).await;
776 run_namespace_prelude(db, namespace, &context).await?;
777 let sql = self.concat_query_lines();
778 let mut results = Vec::new();
779
780 for sql in sql.split(TEMPLATE_DELIMITER) {
781 if sql.trim().is_empty() {
782 continue;
783 }
784 let sql = if sql.ends_with(QUERY_DELIMITER) {
785 sql.to_string()
786 } else {
787 format!("{sql};")
788 };
789 let mut result = db.compat_query(&sql, &context).await?;
790 self.after_execute_intercept(&mut result).await;
791 results.push(result);
792 }
793
794 Ok((display, results))
795 }
796}
797
798fn parse_sql_file(content: &str, registry: &Registry) -> Result<Vec<ParsedStatement>, String> {
801 let mut statements = Vec::new();
802 let mut current_stmt = ParsedStatement::new();
803
804 for line in content.lines() {
805 if line.starts_with(COMMENT_PREFIX) {
806 current_stmt.push_comment(line.to_string());
807
808 if line.starts_with(INTERCEPTOR_PREFIX) {
809 current_stmt.push_interceptor(line, registry)?;
810 }
811 continue;
812 }
813
814 if line.is_empty() {
815 continue;
816 }
817
818 current_stmt.append_query_line(line);
819
820 if line.ends_with(QUERY_DELIMITER) {
822 if current_stmt.has_query() {
823 statements.push(current_stmt);
824 }
825 current_stmt = ParsedStatement::new();
826 } else {
827 current_stmt.append_query_line("\n");
828 }
829 }
830
831 if !current_stmt.is_empty() && current_stmt.has_query() {
833 statements.push(current_stmt);
834 }
835
836 if statements.is_empty() {
837 return Err("No SQL statements found in file".to_string());
838 }
839
840 Ok(statements)
841}
842
843fn simple_diff(expected: &str, actual: &str) -> String {
845 let mut diff = String::new();
846 let expected_lines: Vec<&str> = expected.lines().collect();
847 let actual_lines: Vec<&str> = actual.lines().collect();
848 let max_len = expected_lines.len().max(actual_lines.len());
849
850 for i in 0..max_len {
851 let exp = expected_lines.get(i).unwrap_or(&"(missing)");
852 let act = actual_lines.get(i).unwrap_or(&"(missing)");
853 if exp != act {
854 diff.push_str(&format!(" Line {}:\n", i + 1));
855 diff.push_str(&format!(" expected: {exp}\n"));
856 diff.push_str(&format!(" actual: {act}\n"));
857 }
858 }
859
860 if diff.is_empty() {
861 diff.push_str(" (files differ but no line-level diff found — may be whitespace)\n");
862 }
863
864 diff
865}
866
867#[cfg(test)]
868mod tests {
869 use super::trim_trailing_blank_lines;
870
871 #[test]
872 fn test_trim_trailing_blank_lines_preserves_single_final_newline() {
873 let mut output = "SELECT 1;\n\n+---+\n\n".to_string();
874 trim_trailing_blank_lines(&mut output);
875
876 assert_eq!(output, "SELECT 1;\n\n+---+\n");
877 }
878}