Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion sqlness/src/interceptor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ use crate::{
error::SqlnessError,
interceptor::{
arg::ArgInterceptorFactory, env::EnvInterceptorFactory, replace::ReplaceInterceptorFactory,
sort_result::SortResultInterceptorFactory, template::TemplateInterceptorFactory,
skip::SkipInterceptorFactory, sort_result::SortResultInterceptorFactory,
template::TemplateInterceptorFactory,
},
};

pub mod arg;
pub mod env;
pub mod replace;
pub mod skip;
pub mod sleep;
pub mod sort_result;
pub mod template;
Expand Down Expand Up @@ -113,6 +115,10 @@ fn builtin_interceptors() -> HashMap<String, InterceptorFactoryRef> {
sleep::PREFIX.to_string(),
Arc::new(sleep::SleepInterceptorFactory {}) as _,
),
(
skip::PREFIX.to_string(),
Arc::new(SkipInterceptorFactory {}) as _,
),
]
.into_iter()
.map(|(prefix, factory)| (prefix.to_string(), factory))
Expand Down
69 changes: 69 additions & 0 deletions sqlness/src/interceptor/skip.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright 2024 CeresDB Project Authors. Licensed under Apache-2.0.

use crate::error::Result;
use crate::interceptor::{Interceptor, InterceptorFactory, InterceptorRef};

pub struct SkipInterceptorFactory;

pub const PREFIX: &str = "SKIP";
pub const SKIP_MARKER_PREFIX: &str = "-- SQLNESS_SKIP:";

/// Skip interceptor that generates SKIP markers in result output.
///
/// Grammar:
/// ``` text
/// -- SQLNESS SKIP <reason>
/// ```
///
/// Example:
/// ``` sql
/// -- SQLNESS SKIP version 0.14.0 < required 0.15.0
/// SELECT * FROM new_feature_table;
/// ```
///
/// The query will not be executed, and the SKIP marker will be written to the result file.
#[derive(Debug)]
pub struct SkipInterceptor {
reason: String,
}

impl Interceptor for SkipInterceptor {
fn before_execute(&self, execute_query: &mut Vec<String>, _context: &mut crate::QueryContext) {
execute_query.clear();
}

fn after_execute(&self, result: &mut String) {
*result = format!("{} {}", SKIP_MARKER_PREFIX, self.reason);
}
}
Comment on lines +30 to +38
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The current implementation of SkipInterceptor is unfortunately flawed due to the execution flow in TestCase::execute (in sqlness/src/case.rs).

By calling execute_query.clear() in before_execute, you prevent any SQL from being run. However, this also means the loop over queries in TestCase::execute is skipped, and consequently, after_execute_intercept (which calls after_execute) is never called. As a result, the skip marker is never written to the result file.

The unit test for this interceptor doesn't catch this because it calls before_execute and after_execute in isolation, not through the TestCase::execute flow.

To fix this, the logic in TestCase::execute needs to be adjusted to handle cases where the query becomes empty after before_execute_intercept. For example, it could call after_execute_intercept even if there's no SQL to run.

Here's a possible modification for sqlness/src/case.rs:

// in sqlness/src/case.rs
pub(crate) async fn execute<W>(&mut self, db: &dyn Database, writer: &mut W) -> Result<()>
where
    W: Write,
{
    let context = self.before_execute_intercept().await;
    // ... (writing comments and display query)

    let sql = self.concat_query_lines();
    if sql.trim().is_empty() {
        // If query is empty (e.g. by interceptor), we still need to run after_execute
        let mut result = String::new();
        self.after_execute_intercept(&mut result).await;
        if !result.is_empty() {
            self.write_result(writer, result)?;
        }
    } else {
        for sql in sql.split(crate::interceptor::template::DELIMITER) {
            if !sql.trim().is_empty() {
                let sql = if sql.ends_with(QUERY_DELIMITER) {
                    sql.to_string()
                } else {
                    format!("{sql};")
                };
                let mut result = db.query(context.clone(), sql).await.to_string();
                self.after_execute_intercept(&mut result).await;
                self.write_result(writer, result)?;
            }
        }
    }

    Ok(())
}

Since case.rs is not in this PR's diff, I cannot provide a direct code suggestion for that file. However, the SkipInterceptor as it is will not work.


impl InterceptorFactory for SkipInterceptorFactory {
fn try_new(&self, ctx: &str) -> Result<InterceptorRef> {
Ok(Box::new(SkipInterceptor {
reason: ctx.to_string(),
}))
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::QueryContext;

#[test]
fn test_skip_interceptor() {
let interceptor = SkipInterceptorFactory
.try_new("version 0.14.0 < required 0.15.0")
.unwrap();

let mut query = vec!["SELECT * FROM new_feature_table;".to_string()];
let mut context = QueryContext::default();

interceptor.before_execute(&mut query, &mut context);
assert!(query.is_empty());

let mut result = "some result".to_string();
interceptor.after_execute(&mut result);
assert_eq!(result, "-- SQLNESS_SKIP: version 0.14.0 < required 0.15.0");
}
}
1 change: 1 addition & 0 deletions sqlness/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,4 @@ pub use database::Database;
pub use environment::EnvController;
pub use error::SqlnessError;
pub use runner::Runner;
pub use runner::SKIP_MARKER_PREFIX;
183 changes: 176 additions & 7 deletions sqlness/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ use crate::case::TestCase;
use crate::error::{Result, SqlnessError};
use crate::{config::Config, environment::EnvController};

/// Prefix for SKIP markers in result files.
/// When comparing expected vs actual results, differences that consist solely
/// of SKIP markers are treated as "no difference" (test passes).
pub const SKIP_MARKER_PREFIX: &str = "-- SQLNESS_SKIP:";

/// The entrypoint of this crate.
///
/// To run your integration test cases, simply [`new`] a `Runner` and [`run`] it.
Expand Down Expand Up @@ -217,13 +222,22 @@ impl<E: EnvController> Runner<E> {
case.execute(db, &mut new_result).await?;
let elapsed = timer.elapsed();

// Truncate and write new result back
// Compare old and new result
let new_result = String::from_utf8(new_result.into_inner()).expect("not utf8 string");
let diff_only_skip_markers = self.are_all_changes_skip_markers_from_strings(&old_result, &new_result);

// If diff is only SKIP markers, write back old_result to keep result file clean
let result_to_write = if diff_only_skip_markers {
&old_result
} else {
&new_result
};

// Truncate and write result back
result_file.set_len(0)?;
result_file.rewind()?;
result_file.write_all(new_result.get_ref())?;
result_file.write_all(result_to_write.as_bytes())?;
Comment on lines +225 to +239
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The current logic for writing the result file when a test is skipped can lead to misleading results. If a test case produced a different result previously (e.g., a failing result, or a correct result from a passing test), and is now skipped, the old result will be written back to the .result file, but the test will be marked as passed. This hides the fact that the test was skipped and can make it seem like the old result is still being produced.

The result file should always reflect the actual outcome of the test run. In the case of a skipped test, it should contain the skip marker. The compare function already correctly handles ignoring skip markers for determining pass/fail status.

I suggest always writing the new_result to the file, which simplifies the logic and ensures correctness.

        let new_result = String::from_utf8(new_result.into_inner()).expect("not utf8 string");

        // Truncate and write result back
        result_file.set_len(0)?;
        result_file.rewind()?;
        result_file.write_all(new_result.as_bytes())?;


// Compare old and new result
let new_result = String::from_utf8(new_result.into_inner()).expect("not utf8 string");
if let Some(diff) = self.compare(&old_result, &new_result) {
println!("Result unexpected, path:{case_path:?}");
println!("{diff}");
Expand Down Expand Up @@ -282,12 +296,167 @@ impl<E: EnvController> Runner<E> {
/// Compare result, return None if them are the same, else return diff changes
fn compare(&self, expected: &str, actual: &str) -> Option<String> {
let diff = diff_lines(expected, actual);
let diff = diff.diff();
let is_different = diff.iter().any(|d| !matches!(d, DiffOp::Equal(_)));
let diff_ops = diff.diff();

// Check if all differences are only SKIP markers
if self.are_all_changes_skip_markers(&diff_ops) {
return None; // Treat as no difference
}

let is_different = diff_ops.iter().any(|d| !matches!(d, DiffOp::Equal(_)));
if is_different {
return Some(format!("{}", SliceChangeset { diff }));
return Some(format!("{}", SliceChangeset { diff: diff_ops }));
}

None
}

/// Checks if a line is a SKIP marker
fn is_skip_marker(line: &str) -> bool {
line.trim().starts_with(SKIP_MARKER_PREFIX)
}

/// Checks if all differences are only SKIP markers
///
/// Returns true if:
/// - No differences between expected and actual, OR
/// - All inserted/deleted/replaced lines are SKIP markers
fn are_all_changes_skip_markers<'a>(
&self,
diff_ops: &'a [DiffOp<'a, &'a str>],
) -> bool {
for op in diff_ops {
match op {
DiffOp::Equal(_) => continue,
DiffOp::Insert(lines) | DiffOp::Remove(lines) => {
if !lines.iter().all(|line| Self::is_skip_marker(line)) {
return false;
}
}
DiffOp::Replace(_old_lines, new_lines) => {
if !new_lines.iter().all(|line| Self::is_skip_marker(line)) {
return false;
}
}
}
}

true
}

/// Convenience method to check if two strings differ only by SKIP markers
fn are_all_changes_skip_markers_from_strings(&self, expected: &str, actual: &str) -> bool {
let diff = diff_lines(expected, actual);
self.are_all_changes_skip_markers(&diff.diff())
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
use crate::environment::EnvController;
use async_trait::async_trait;
use std::fmt::Display;
use std::path::Path;

struct MockDatabase;

#[async_trait]
impl Database for MockDatabase {
async fn query(
&self,
_ctx: crate::case::QueryContext,
_query: String,
) -> Box<dyn Display> {
Box::new("mock result")
}
}

struct MockEnvController;

#[async_trait]
impl EnvController for MockEnvController {
type DB = MockDatabase;

async fn start(&self, _env: &str, _config: Option<&Path>) -> Self::DB {
MockDatabase
}

async fn stop(&self, _env: &str, _database: Self::DB) {}
}

fn create_test_runner() -> Runner<MockEnvController> {
let config = Config {
case_dir: ".".to_string(),
test_case_extension: "sql".to_string(),
result_extension: "result".to_string(),
interceptor_prefix: "-- SQLNESS".to_string(),
env_config_file: "config.toml".to_string(),
fail_fast: true,
test_filter: ".*".to_string(),
env_filter: ".*".to_string(),
follow_links: true,
interceptor_registry: crate::interceptor::Registry::default(),
};
Runner::new(config, MockEnvController)
}

#[test]
fn test_is_skip_marker() {
assert!(Runner::<MockEnvController>::is_skip_marker(
"-- SQLNESS_SKIP: version 0.14.0 < required 0.15.0"
));
assert!(Runner::<MockEnvController>::is_skip_marker(
" -- SQLNESS_SKIP: some reason"
));
assert!(!Runner::<MockEnvController>::is_skip_marker(
"-- SQLNESS TEMPLATE {}"
));
assert!(!Runner::<MockEnvController>::is_skip_marker("SELECT 1;"));
}

#[test]
fn test_are_all_changes_skip_markers_no_changes() {
let runner = create_test_runner();
let diff = diff_lines("SELECT 1;\n1", "SELECT 1;\n1");
assert!(runner.are_all_changes_skip_markers(&diff.diff()));
}

#[test]
fn test_are_all_changes_skip_markers_only_skip_added() {
let runner = create_test_runner();
let diff = diff_lines(
"SELECT 1;\n1",
"SELECT 1;\n1\n-- SQLNESS_SKIP: version 0.14.0 < required 0.15.0",
);
assert!(runner.are_all_changes_skip_markers(&diff.diff()));
}

#[test]
fn test_are_all_changes_skip_markers_skip_replacing_content() {
let runner = create_test_runner();
let diff = diff_lines(
"SELECT 1;\n1",
"SELECT 1;\n-- SQLNESS_SKIP: version 0.14.0 < required 0.15.0",
);
assert!(runner.are_all_changes_skip_markers(&diff.diff()));
}

#[test]
fn test_are_all_changes_skip_markers_real_difference() {
let runner = create_test_runner();
let diff = diff_lines("SELECT 1;\n1", "SELECT 1;\n2");
assert!(!runner.are_all_changes_skip_markers(&diff.diff()));
}

#[test]
fn test_are_all_changes_skip_markers_mixed_changes() {
let runner = create_test_runner();
let diff = diff_lines(
"SELECT 1;\n1",
"SELECT 1;\n2\n-- SQLNESS_SKIP: version 0.14.0 < required 0.15.0",
);
assert!(!runner.are_all_changes_skip_markers(&diff.diff()));
}
}