diff --git a/Cargo.lock b/Cargo.lock index 57be369d7c3..00f5345aa76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10227,6 +10227,7 @@ dependencies = [ "datafusion-sqllogictest", "futures", "indicatif", + "regex", "rstest", "sqllogictest", "thiserror 2.0.18", diff --git a/vortex-sqllogictest/Cargo.toml b/vortex-sqllogictest/Cargo.toml index 757abb49689..499702454f3 100644 --- a/vortex-sqllogictest/Cargo.toml +++ b/vortex-sqllogictest/Cargo.toml @@ -22,6 +22,7 @@ datafusion = { workspace = true } datafusion-sqllogictest = { workspace = true } futures.workspace = true indicatif.workspace = true +regex = { workspace = true } rstest = { workspace = true } sqllogictest = "0.29.1" thiserror = { workspace = true } diff --git a/vortex-sqllogictest/src/duckdb.rs b/vortex-sqllogictest/src/duckdb.rs index 182110e1f45..821eaeeed6b 100644 --- a/vortex-sqllogictest/src/duckdb.rs +++ b/vortex-sqllogictest/src/duckdb.rs @@ -10,11 +10,13 @@ use async_trait::async_trait; use bigdecimal::BigDecimal; use datafusion_sqllogictest::DFColumnType; use indicatif::ProgressBar; +use regex::RegexBuilder; use sqllogictest::DBOutput; use sqllogictest::Normalizer; use sqllogictest::runner::AsyncDB; use vortex::error::VortexError; use vortex::error::VortexExpect; +use vortex::error::vortex_err; use vortex_duckdb::duckdb::Connection; use vortex_duckdb::duckdb::Database; use vortex_duckdb::duckdb::ExtractedValue; @@ -99,20 +101,48 @@ impl DuckDB { } } +// Regex matching for output inspired by Duckdb's .test files. Rows joined +// by newline are matched against positive or negative match. +const REGEX_MARKER: &str = ":"; +const NEG_REGEX_MARKER: &str = ":"; + +fn regex_matches(pattern: &str, text: &str) -> bool { + RegexBuilder::new(pattern) + .dot_matches_new_line(true) + .build() + .map_err(|e| vortex_err!("invalid regex pattern: {e}")) + .vortex_expect("invalid pattern") + .is_match(text) +} + pub fn duckdb_validator( normalizer: Normalizer, actual: &[Vec], expected: &[String], ) -> bool { - let actual = actual.iter().flat_map(|strings| { - strings - .join(" ") - .trim_end() - .split('\n') - .map(|line| line.trim_end().to_string()) - .collect::>() - }); - Iterator::eq(actual, expected.iter().map(normalizer)) + let actual = actual + .iter() + .flat_map(|strings| { + strings + .join(" ") + .trim_end() + .split('\n') + .map(|line| line.trim_end().to_string()) + .collect::>() + }) + .collect::>(); + let expected = expected.iter().map(normalizer).collect::>(); + + if let [line] = expected.as_slice() { + if let Some(pattern) = line.strip_prefix(NEG_REGEX_MARKER) { + return !regex_matches(pattern, &actual.join("\n")); + } + if let Some(pattern) = line.strip_prefix(REGEX_MARKER) { + return regex_matches(pattern, &actual.join("\n")); + } + } + + Iterator::eq(actual.iter(), expected.iter()) } #[async_trait] @@ -274,11 +304,78 @@ mod tests { use vortex_duckdb::duckdb::Value; use super::ValueDisplayAdapter; + use super::duckdb_validator; + use super::regex_matches; fn display(value: Value) -> String { ValueDisplayAdapter(value).to_string() } + #[rstest] + #[case("foo", "foo", true)] + #[case("foo", "xfooy", true)] + #[case("foo", "bar", false)] + #[case("^foo$", "foo", true)] + #[case("^foo$", "xfoo", false)] + #[case("a.*b", "axxxb", true)] + #[case("a.*b", "a\nxx\nb", true)] + #[case("needle", "line1\nneedle here\nline3", true)] + #[case("missing", "line1\nline2\nline3", false)] + fn test_regex_matches(#[case] pattern: &str, #[case] text: &str, #[case] expected: bool) { + assert_eq!(regex_matches(pattern, text), expected); + } + + // clippy: Signature must match sqllogictest::Normalizer + #[expect(clippy::ptr_arg)] + fn identity(s: &String) -> String { + s.clone() + } + + #[test] + fn validator_regex_directive_spans_multiple_lines() { + let actual = vec![vec![ + "physical_plan".to_string(), + "[\n {\n \"SELECT projections\": \"str\"\n }\n]".to_string(), + ]]; + + assert!(duckdb_validator( + identity, + &actual, + &[":SELECT projections".to_string()] + )); + assert!(!duckdb_validator( + identity, + &actual, + &[":NOT PRESENT".to_string()] + )); + + assert!(duckdb_validator( + identity, + &actual, + &[":NOT PRESENT".to_string()] + )); + assert!(!duckdb_validator( + identity, + &actual, + &[":SELECT projections".to_string()] + )); + } + + #[test] + fn validator_without_marker_requires_exact_match() { + let actual = vec![vec!["5".to_string()], vec!["3".to_string()]]; + assert!(duckdb_validator( + identity, + &actual, + &["5".to_string(), "3".to_string()] + )); + assert!(!duckdb_validator( + identity, + &actual, + &["5".to_string(), "2".to_string()] + )); + } + #[test] fn test_null() { assert_eq!(display(Value::sql_null()), "NULL");