fix: since/till properly

Signed-off-by: discord9 <discord9@163.com>
This commit is contained in:
discord9
2026-02-24 18:28:42 +08:00
parent 33d06bf477
commit 896344bd76
10 changed files with 387 additions and 197 deletions

View File

@@ -13,6 +13,8 @@ This compatibility test is inspired by [Databend](https://github.com/datafuselab
## Usage
### Legacy script flow
```shell
tests/compat/test-compat.sh <old_ver>
```
@@ -20,6 +22,45 @@ tests/compat/test-compat.sh <old_ver>
E.g. `tests/compat/test-compat.sh 0.6.0` tests if the data written by GreptimeDB **v0.6.0** can be read by **current**
version of GreptimeDB, and vice-versa. By "current", it's meant the fresh binary built by current codes.
### Sqlness compat command (recommended)
```shell
cargo sqlness compat --from=1.0.0-beta.1 --to=current --preserve-state
```
The compat command runs cases in three phases:
1. `1.feature` on `--from`
2. `2.verify` on `--to`
3. `3.cleanup` on `--to`
## Case markers
Compatibility cases can use sqlness markers:
- `-- SQLNESS SINCE <version>`
- `-- SQLNESS TILL <version>`
For `since`/`till` skips, runner rewrites the statement before execution to avoid running skipped SQL.
## Filter behavior
`--test-filter` still accepts sqlness regex. Compat runner also recognizes optional leading path prefixes:
- phase prefix: `1.feature/`, `2.verify/`, `3.cleanup/`
- mode prefix: `standalone/`, `distributed/`
- group prefix: `common/`, `only/`
Examples:
```shell
# run one standalone case path
cargo sqlness compat --from=1.0.0-beta.1 --to=current --test-filter="standalone/common/show-create-table.sql"
# same target with explicit phase prefix
cargo sqlness compat --from=1.0.0-beta.1 --to=current --test-filter="2.verify/distributed/common/show-create-table.sql"
```
## Prerequisites
Current version of GreptimeDB's binaries must reside in `./bins`:

View File

@@ -1,4 +1,4 @@
-- SQLNESS ARG since=0.15.0
-- SQLNESS SINCE 0.15.0
CREATE TABLE granularity_and_false_positive_rate (
ts timestamp time index,
val double
@@ -9,3 +9,23 @@ CREATE TABLE granularity_and_false_positive_rate (
Affected Rows: 0
-- SQLNESS SINCE 99.0.0
SELECT * FROM __sqlness_since_till_should_not_exist__;
-- SQLNESS_SKIP: target version 1.0.0-beta.1 < 99.0.0
-- SQLNESS TILL 0.1.0
SELECT * FROM __sqlness_since_till_should_not_exist__;
-- SQLNESS_SKIP: target version 1.0.0-beta.1 > 0.1.0
-- SQLNESS SINCE 0.1.0
-- SQLNESS TILL 99.0.0
SELECT 1;
+----------+
| Int64(1) |
+----------+
| 1 |
+----------+

View File

@@ -1,4 +1,4 @@
-- SQLNESS ARG since=0.15.0
-- SQLNESS SINCE 0.15.0
CREATE TABLE granularity_and_false_positive_rate (
ts timestamp time index,
val double
@@ -6,3 +6,13 @@ CREATE TABLE granularity_and_false_positive_rate (
"index.granularity" = "8192",
"index.false_positive_rate" = "0.01"
);
-- SQLNESS SINCE 99.0.0
SELECT * FROM __sqlness_since_till_should_not_exist__;
-- SQLNESS TILL 0.1.0
SELECT * FROM __sqlness_since_till_should_not_exist__;
-- SQLNESS SINCE 0.1.0
-- SQLNESS TILL 99.0.0
SELECT 1;

View File

@@ -22,7 +22,6 @@ use sqlness::{ConfigBuilder, Runner};
use crate::cmd::SqlnessConfig;
use crate::env::bare::{Env, ServiceProvider, StoreConfig, WalConfig};
use crate::interceptors::ignore_result;
use crate::{protocol_interceptor, util};
#[derive(ValueEnum, Debug, Clone)]
@@ -123,10 +122,6 @@ impl BareCommand {
protocol_interceptor::PREFIX,
Arc::new(protocol_interceptor::ProtocolInterceptorFactory),
);
interceptor_registry.register(
ignore_result::PREFIX,
Arc::new(ignore_result::IgnoreResultInterceptorFactory),
);
if let Some(d) = &self.config.case_dir {
ensure!(d.is_dir(), "{} is not a directory", d.display());

View File

@@ -21,7 +21,7 @@ use sqlness::{ConfigBuilder, Runner};
use crate::cmd::bare::ServerAddr;
use crate::env::bare::{StoreConfig, WalConfig};
use crate::env::compat::Env;
use crate::interceptors::{ignore_result, since, till};
use crate::interceptors::{since, till};
use crate::version::Version;
use crate::{protocol_interceptor, util};
@@ -81,27 +81,27 @@ impl CompatibilityRunner {
}
pub async fn run(&self) -> anyhow::Result<()> {
self.run_phase(&self.from_version, "1.feature").await?;
self.run_phase(&self.to_version, "2.verify").await?;
self.run_phase(&self.to_version, "3.cleanup").await?;
self.run_phase(&self.from_version, Phase::Feature).await?;
self.run_phase(&self.to_version, Phase::Verify).await?;
self.run_phase(&self.to_version, Phase::Cleanup).await?;
Ok(())
}
async fn run_phase(&self, version: &Version, phase: &str) -> anyhow::Result<()> {
async fn run_phase(&self, version: &Version, phase: Phase) -> anyhow::Result<()> {
if !self.case_dir.exists() {
return Ok(());
}
let (mode_filter, remainder) = Self::split_filter(self.test_filter.as_str());
let case_root = self.case_dir.join(phase);
let case_root = self.case_dir.join(phase.dir_name());
if !case_root.exists() {
return Ok(());
}
let bins_dir = Self::resolve_bins_dir(version).await?;
let mut store_config = self.store_config.clone();
store_config.setup_etcd = phase == "1.feature";
store_config.setup_etcd = phase.should_setup_etcd();
let env = Env::new_bare(
self.data_dir.clone(),
self.server_addr.clone(),
@@ -117,10 +117,6 @@ impl CompatibilityRunner {
protocol_interceptor::PREFIX,
Arc::new(protocol_interceptor::ProtocolInterceptorFactory),
);
interceptor_registry.register(
ignore_result::PREFIX,
Arc::new(ignore_result::IgnoreResultInterceptorFactory),
);
interceptor_registry.register(
since::PREFIX,
Arc::new(since::SinceInterceptorFactory::new(version.clone())),
@@ -138,7 +134,7 @@ impl CompatibilityRunner {
.unwrap_or_else(|| "<build>".to_string());
println!(
"compat phase={} version={} case_dir={} test_filter={} env_filter={} phase_root={} bins_dir={}",
phase,
phase.dir_name(),
version,
self.case_dir.display(),
self.test_filter,
@@ -169,46 +165,76 @@ impl CompatibilityRunner {
return (ModeFilter::Any, String::new());
}
let mut normalized = trimmed;
let normalized = Self::trim_anchors(trimmed);
let (mode, mut rest) = Self::extract_mode_and_path(normalized);
rest = Self::trim_case_group_prefixes(rest);
(mode, rest.to_string())
}
fn trim_anchors(filter: &str) -> &str {
let mut normalized = filter;
if let Some(rest) = normalized.strip_prefix('^') {
normalized = rest;
}
if let Some(rest) = normalized.strip_suffix('$') {
normalized = rest;
}
if let Some(rest) = normalized.strip_prefix("standalone/") {
return (ModeFilter::Standalone, Self::strip_path_prefixes(rest));
}
if let Some(rest) = normalized.strip_prefix("distributed/") {
return (ModeFilter::Distributed, Self::strip_path_prefixes(rest));
}
if normalized == "standalone" {
return (ModeFilter::Standalone, String::new());
}
if normalized == "distributed" {
return (ModeFilter::Distributed, String::new());
}
(ModeFilter::Any, Self::strip_path_prefixes(normalized))
normalized
}
fn strip_path_prefixes(input: &str) -> String {
let mut rest = input;
for prefix in [
"1.feature/",
"2.verify/",
"3.cleanup/",
"standalone/",
"distributed/",
"common/",
"only/",
] {
if let Some(stripped) = rest.strip_prefix(prefix) {
rest = stripped;
fn extract_mode_and_path(filter: &str) -> (ModeFilter, &str) {
if let Some((mode, rest)) = Self::extract_mode_prefix(filter) {
return (mode, rest);
}
if let Some(after_phase) = Self::strip_phase_prefix(filter)
&& let Some((mode, rest)) = Self::extract_mode_prefix(after_phase)
{
return (mode, rest);
}
if let Some(after_phase) = Self::strip_phase_prefix(filter) {
return (ModeFilter::Any, after_phase);
}
(ModeFilter::Any, filter)
}
fn extract_mode_prefix(filter: &str) -> Option<(ModeFilter, &str)> {
if let Some(rest) = filter.strip_prefix("standalone/") {
return Some((ModeFilter::Standalone, rest));
}
if let Some(rest) = filter.strip_prefix("distributed/") {
return Some((ModeFilter::Distributed, rest));
}
if filter == "standalone" {
return Some((ModeFilter::Standalone, ""));
}
if filter == "distributed" {
return Some((ModeFilter::Distributed, ""));
}
None
}
fn strip_phase_prefix(filter: &str) -> Option<&str> {
for phase in ["1.feature/", "2.verify/", "3.cleanup/"] {
if let Some(rest) = filter.strip_prefix(phase) {
return Some(rest);
}
}
rest.to_string()
None
}
fn trim_case_group_prefixes(mut path: &str) -> &str {
for prefix in ["common/", "only/"] {
if let Some(rest) = path.strip_prefix(prefix) {
path = rest;
}
}
path
}
fn env_filter(mode_filter: ModeFilter) -> String {
@@ -251,3 +277,69 @@ enum ModeFilter {
Standalone,
Distributed,
}
#[derive(Copy, Clone)]
enum Phase {
Feature,
Verify,
Cleanup,
}
impl Phase {
fn dir_name(self) -> &'static str {
match self {
Self::Feature => "1.feature",
Self::Verify => "2.verify",
Self::Cleanup => "3.cleanup",
}
}
fn should_setup_etcd(self) -> bool {
matches!(self, Self::Feature)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_filter_mode_only() {
let (mode, remainder) = CompatibilityRunner::split_filter("standalone");
assert!(matches!(mode, ModeFilter::Standalone));
assert!(remainder.is_empty());
}
#[test]
fn test_split_filter_mode_and_case_path() {
let (mode, remainder) = CompatibilityRunner::split_filter("distributed/common/show.sql");
assert!(matches!(mode, ModeFilter::Distributed));
assert_eq!(remainder, "show.sql");
}
#[test]
fn test_split_filter_phase_then_mode() {
let (mode, remainder) =
CompatibilityRunner::split_filter("1.feature/standalone/only/a.sql");
assert!(matches!(mode, ModeFilter::Standalone));
assert_eq!(remainder, "a.sql");
}
#[test]
fn test_split_filter_regex_anchors() {
let (mode, remainder) =
CompatibilityRunner::split_filter("^2.verify/distributed/common/t.sql$");
assert!(matches!(mode, ModeFilter::Distributed));
assert_eq!(remainder, "t.sql");
}
#[test]
fn test_split_filter_passthrough_regex_with_colon() {
let (mode, remainder) = CompatibilityRunner::split_filter(".*:foo/bar");
assert!(matches!(mode, ModeFilter::Any));
assert_eq!(
CompatibilityRunner::build_test_filter(&remainder),
".*:foo/bar"
);
}
}

View File

@@ -1,42 +0,0 @@
// Copyright 2023 Greptime Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use sqlness::SqlnessError;
use sqlness::interceptor::{Interceptor, InterceptorFactory, InterceptorRef};
pub const PREFIX: &str = "IGNORE_RESULT";
/// Interceptor that ignores result matching for a query.
///
/// Usage: `-- SQLNESS IGNORE_RESULT`
///
/// When this interceptor is used, the test runner will only check that the query
/// executes successfully, without comparing the actual output to the expected result.
/// This is useful for operations where the exact output may vary (e.g., timestamps,
/// auto-generated IDs) or when testing that a feature doesn't crash.
pub struct IgnoreResultInterceptor;
impl Interceptor for IgnoreResultInterceptor {
fn after_execute(&self, result: &mut String) {
*result = "-- IGNORE_RESULT: Query executed successfully".to_string();
}
}
pub struct IgnoreResultInterceptorFactory;
impl InterceptorFactory for IgnoreResultInterceptorFactory {
fn try_new(&self, _ctx: &str) -> Result<InterceptorRef, SqlnessError> {
Ok(Box::new(IgnoreResultInterceptor))
}
}

View File

@@ -12,6 +12,5 @@
// See the License for the specific language governing permissions and
// limitations under the License.
pub mod ignore_result;
pub mod since;
pub mod till;

View File

@@ -12,8 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use sqlness::SqlnessError;
use sqlness::interceptor::{Interceptor, InterceptorFactory, InterceptorRef};
use sqlness::{SKIP_MARKER_PREFIX, SqlnessError};
use crate::version::Version;
@@ -36,27 +36,38 @@ impl SinceInterceptor {
target_version,
}
}
fn maybe_rewrite_to_skip_sql(&self, sql: &mut Vec<String>) {
if self.target_version < self.since_version {
let skip_marker = format!("{} {}", SKIP_MARKER_PREFIX, self.skip_reason());
sql.clear();
sql.push(format!("SELECT '{}';", skip_marker));
}
}
fn skip_reason(&self) -> String {
format!(
"target version {} < {}",
self.target_version, self.since_version
)
}
fn normalize_skip_result(&self, result: &mut String) {
if result.contains(SKIP_MARKER_PREFIX) {
*result = format!("{} {}", SKIP_MARKER_PREFIX, self.skip_reason());
}
}
}
impl Interceptor for SinceInterceptor {
fn before_execute(&self, sql: &mut Vec<String>, _ctx: &mut sqlness::QueryContext) {
// Skip execution if target version is less than since version
if self.target_version < self.since_version {
sql.clear();
}
self.maybe_rewrite_to_skip_sql(sql);
}
fn after_execute(&self, result: &mut String) {
result.clear();
*result = format!(
"{} target version {} < {}",
sqlness::SKIP_MARKER_PREFIX,
self.target_version,
self.since_version
);
self.normalize_skip_result(result);
}
}
pub struct SinceInterceptorFactory {
target_version: Version,
}
@@ -80,3 +91,68 @@ impl InterceptorFactory for SinceInterceptorFactory {
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_before_execute_keeps_sql_when_not_skipped() {
let interceptor = SinceInterceptor::new(
Version::parse("0.15.0").unwrap(),
Version::parse("0.15.0").unwrap(),
);
let mut sql = vec!["SELECT 1;".to_string()];
interceptor.maybe_rewrite_to_skip_sql(&mut sql);
assert_eq!(sql, vec!["SELECT 1;"]);
}
#[test]
fn test_before_execute_rewrites_sql_when_skipped() {
let interceptor = SinceInterceptor::new(
Version::parse("0.16.0").unwrap(),
Version::parse("0.15.0").unwrap(),
);
let mut sql = vec!["SELECT 1;".to_string()];
interceptor.maybe_rewrite_to_skip_sql(&mut sql);
assert_eq!(sql.len(), 1);
assert!(sql[0].contains(SKIP_MARKER_PREFIX));
assert!(sql[0].contains("target version 0.15.0 < 0.16.0"));
}
#[test]
fn test_after_execute_normalizes_skip_result() {
let interceptor = SinceInterceptor::new(
Version::parse("0.16.0").unwrap(),
Version::parse("0.15.0").unwrap(),
);
let mut result = format!(
"+----------------+\n| {} target version 0.15.0 < 0.16.0 |\n+----------------+",
SKIP_MARKER_PREFIX
);
interceptor.normalize_skip_result(&mut result);
assert_eq!(
result,
format!("{} target version 0.15.0 < 0.16.0", SKIP_MARKER_PREFIX)
);
}
#[test]
fn test_after_execute_keeps_non_skip_result() {
let interceptor = SinceInterceptor::new(
Version::parse("0.16.0").unwrap(),
Version::parse("0.15.0").unwrap(),
);
let mut result = "Affected Rows: 1".to_string();
interceptor.normalize_skip_result(&mut result);
assert_eq!(result, "Affected Rows: 1");
}
}

View File

@@ -12,8 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use sqlness::SqlnessError;
use sqlness::interceptor::{Interceptor, InterceptorFactory, InterceptorRef};
use sqlness::{SKIP_MARKER_PREFIX, SqlnessError};
use crate::version::Version;
@@ -36,26 +36,38 @@ impl TillInterceptor {
target_version,
}
}
fn maybe_rewrite_to_skip_sql(&self, sql: &mut Vec<String>) {
if self.target_version > self.till_version {
let skip_marker = format!("{} {}", SKIP_MARKER_PREFIX, self.skip_reason());
sql.clear();
sql.push(format!("SELECT '{}';", skip_marker));
}
}
fn skip_reason(&self) -> String {
format!(
"target version {} > {}",
self.target_version, self.till_version
)
}
fn normalize_skip_result(&self, result: &mut String) {
if result.contains(SKIP_MARKER_PREFIX) {
*result = format!("{} {}", SKIP_MARKER_PREFIX, self.skip_reason());
}
}
}
impl Interceptor for TillInterceptor {
fn before_execute(&self, sql: &mut Vec<String>, _ctx: &mut sqlness::QueryContext) {
if self.target_version > self.till_version {
sql.clear();
}
self.maybe_rewrite_to_skip_sql(sql);
}
fn after_execute(&self, result: &mut String) {
result.clear();
*result = format!(
"{} target version {} > {}",
sqlness::SKIP_MARKER_PREFIX,
self.target_version,
self.till_version
);
self.normalize_skip_result(result);
}
}
pub struct TillInterceptorFactory {
target_version: Version,
}
@@ -79,3 +91,68 @@ impl InterceptorFactory for TillInterceptorFactory {
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_before_execute_keeps_sql_when_not_skipped() {
let interceptor = TillInterceptor::new(
Version::parse("0.15.0").unwrap(),
Version::parse("0.15.0").unwrap(),
);
let mut sql = vec!["SELECT 1;".to_string()];
interceptor.maybe_rewrite_to_skip_sql(&mut sql);
assert_eq!(sql, vec!["SELECT 1;"]);
}
#[test]
fn test_before_execute_rewrites_sql_when_skipped() {
let interceptor = TillInterceptor::new(
Version::parse("0.14.0").unwrap(),
Version::parse("0.15.0").unwrap(),
);
let mut sql = vec!["SELECT 1;".to_string()];
interceptor.maybe_rewrite_to_skip_sql(&mut sql);
assert_eq!(sql.len(), 1);
assert!(sql[0].contains(SKIP_MARKER_PREFIX));
assert!(sql[0].contains("target version 0.15.0 > 0.14.0"));
}
#[test]
fn test_after_execute_normalizes_skip_result() {
let interceptor = TillInterceptor::new(
Version::parse("0.14.0").unwrap(),
Version::parse("0.15.0").unwrap(),
);
let mut result = format!(
"+----------------+\n| {} target version 0.15.0 > 0.14.0 |\n+----------------+",
SKIP_MARKER_PREFIX
);
interceptor.normalize_skip_result(&mut result);
assert_eq!(
result,
format!("{} target version 0.15.0 > 0.14.0", SKIP_MARKER_PREFIX)
);
}
#[test]
fn test_after_execute_keeps_non_skip_result() {
let interceptor = TillInterceptor::new(
Version::parse("0.14.0").unwrap(),
Version::parse("0.15.0").unwrap(),
);
let mut result = "Affected Rows: 1".to_string();
interceptor.normalize_skip_result(&mut result);
assert_eq!(result, "Affected Rows: 1");
}
}

View File

@@ -16,7 +16,7 @@ use std::cmp::Ordering;
use std::fmt;
use std::str::FromStr;
use semver::{Prerelease, Version as SemverVersion};
use semver::Version as SemverVersion;
pub type Result<T> = std::result::Result<T, VersionError>;
@@ -81,88 +81,10 @@ impl Version {
let v2 = other.to_semantic();
match (v1, v2) {
(Some(v1), Some(v2)) => Self::compare_semantic(&v1, &v2),
(Some(v1), Some(v2)) => v1.cmp(&v2),
(None, _) | (_, None) => Ordering::Equal,
}
}
fn compare_semantic(v1: &SemverVersion, v2: &SemverVersion) -> Ordering {
match v1
.major
.cmp(&v2.major)
.then_with(|| v1.minor.cmp(&v2.minor))
.then_with(|| v1.patch.cmp(&v2.patch))
{
Ordering::Equal => Self::compare_pre_release(v1, v2),
ordering => ordering,
}
}
fn compare_pre_release(v1: &SemverVersion, v2: &SemverVersion) -> Ordering {
let pre1_empty = v1.pre.is_empty();
let pre2_empty = v2.pre.is_empty();
match (pre1_empty, pre2_empty) {
(true, true) => Ordering::Equal,
(true, false) => Ordering::Greater,
(false, true) => Ordering::Less,
(false, false) => {
let rank1 = Self::pre_release_rank(&v1.pre);
let rank2 = Self::pre_release_rank(&v2.pre);
rank1
.cmp(&rank2)
.then_with(|| Self::compare_pre_release_identifiers(&v1.pre, &v2.pre))
}
}
}
fn compare_pre_release_identifiers(pre1: &Prerelease, pre2: &Prerelease) -> Ordering {
let mut parts1 = pre1.as_str().split('.');
let mut parts2 = pre2.as_str().split('.');
loop {
match (parts1.next(), parts2.next()) {
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
(Some(a), Some(b)) => {
let ordering = Self::compare_pre_release_part(a, b);
if ordering != Ordering::Equal {
return ordering;
}
}
}
}
}
fn compare_pre_release_part(a: &str, b: &str) -> Ordering {
let a_numeric = a.chars().all(|c| c.is_ascii_digit());
let b_numeric = b.chars().all(|c| c.is_ascii_digit());
match (a_numeric, b_numeric) {
(true, true) => {
let a_num = a.parse::<u64>().unwrap_or(u64::MAX);
let b_num = b.parse::<u64>().unwrap_or(u64::MAX);
a_num.cmp(&b_num)
}
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
(false, false) => a.cmp(b),
}
}
fn pre_release_rank(pre: &Prerelease) -> u8 {
let s = pre.as_str();
if s.starts_with("alpha") {
0
} else if s.starts_with("beta") {
1
} else if s.starts_with("rc") {
2
} else {
3
}
}
}
impl PartialOrd for Version {