use cql2::{Expr, ToSqlAst, Validator};
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet},
fs,
};
const SUPPORTED: &[&str] = &[
"basic-cql2",
"advanced-comparison-operators",
"case-insensitive-comparison",
"accent-insensitive-comparison",
"basic-spatial-functions",
"basic-spatial-functions-plus",
"spatial-functions",
"temporal-functions",
"array-functions",
"property-property",
"arithmetic",
];
const UNREPRESENTED: [&str; 1] = ["arithmetic"];
fn corpus() -> Vec<(String, String)> {
let text = fs::read_to_string("tests/ats/expressions.txt").expect("ATS corpus is present");
let cases: Vec<(String, String)> = text
.lines()
.filter(|line| !line.starts_with('#') && !line.trim().is_empty())
.filter_map(|line| line.split_once('|'))
.map(|(class, expr)| (class.to_string(), expr.to_string()))
.collect();
assert!(
cases.len() > 100,
"expected the full ATS corpus, found {} expressions",
cases.len()
);
cases
}
#[test]
fn ats_expressions_parse() {
let mut failures = Vec::new();
let mut by_class: BTreeMap<&str, usize> = BTreeMap::new();
let cases = corpus();
for (class, expr) in &cases {
if !SUPPORTED.contains(&class.as_str()) {
continue;
}
*by_class.entry(class.as_str()).or_default() += 1;
if let Err(e) = expr.parse::<Expr>() {
failures.push(format!("[{class}] {expr}\n {e}"));
}
}
assert!(
by_class.len() == SUPPORTED.len() - UNREPRESENTED.len(),
"expected every supported class but {UNREPRESENTED:?} to appear in the corpus, found {}: {by_class:?}",
by_class.len()
);
assert!(
failures.is_empty(),
"{} ATS expressions failed to parse:\n {}",
failures.len(),
failures.join("\n ")
);
}
#[test]
fn ats_expressions_produce_valid_json() {
let validator = Validator::new().expect("validator builds");
let mut failures = Vec::new();
let cases = corpus();
for (class, expr) in &cases {
if !SUPPORTED.contains(&class.as_str()) {
continue;
}
let Ok(parsed) = expr.parse::<Expr>() else {
continue; };
let value = parsed.to_value().expect("expression serializes");
if validator.validate(&value).is_err() {
failures.push(format!("[{class}] {expr}\n {value}"));
}
}
assert!(
failures.is_empty(),
"{} ATS expressions produced JSON the schema rejects:\n {}",
failures.len(),
failures.join("\n ")
);
}
#[test]
fn ats_expressions_round_trip() {
let mut failures = Vec::new();
let cases = corpus();
for (class, expr) in &cases {
if !SUPPORTED.contains(&class.as_str()) {
continue;
}
let Ok(parsed) = expr.parse::<Expr>() else {
continue;
};
let json = parsed.to_json().expect("expression serializes");
for rendered in [
parsed.to_text().expect("expression renders as text"),
json.clone(),
] {
match rendered.parse::<Expr>() {
Ok(reparsed) if reparsed.to_json().ok().as_deref() == Some(&json) => {}
Ok(reparsed) => failures.push(format!(
"[{class}] {expr}\n rendered: {rendered}\n was: {json}\n now: {:?}",
reparsed.to_json()
)),
Err(e) => failures.push(format!(
"[{class}] {expr}\n rendered: {rendered}\n error: {e}"
)),
}
}
}
assert!(
failures.is_empty(),
"{} ATS expressions changed meaning across a round trip:\n {}",
failures.len(),
failures.join("\n ")
);
}
#[test]
fn ats_expressions_render_as_sql() {
let dialect = sqlparser::dialect::PostgreSqlDialect {};
let mut failures = Vec::new();
let cases = corpus();
for (class, expr) in &cases {
if !SUPPORTED.contains(&class.as_str()) {
continue;
}
let Ok(parsed) = expr.parse::<Expr>() else {
continue;
};
match parsed.to_sql() {
Ok(sql) => {
if sqlparser::parser::Parser::new(&dialect)
.try_with_sql(&sql)
.and_then(|mut p| p.parse_expr())
.is_err()
{
failures.push(format!("[{class}] {expr}\n unparseable sql: {sql}"));
}
}
Err(e) => failures.push(format!("[{class}] {expr}\n to_sql failed: {e}")),
}
}
assert!(
failures.is_empty(),
"{} ATS expressions did not render as usable SQL:\n {}",
failures.len(),
failures.join("\n ")
);
}
fn operators(json: &Value, into: &mut BTreeSet<String>) {
match json {
Value::Object(fields) => {
if let Some(Value::String(op)) = fields.get("op") {
let _ = into.insert(op.clone());
}
for value in fields.values() {
operators(value, into);
}
}
Value::Array(items) => items.iter().for_each(|item| operators(item, into)),
_ => {}
}
}
fn operators_in(sources: impl Iterator<Item = String>) -> BTreeSet<String> {
let mut found = BTreeSet::new();
for source in sources {
let expr: Expr = source
.parse()
.unwrap_or_else(|e| panic!("{source} should parse: {e}"));
let json: Value = serde_json::from_str(&expr.to_json().expect("expression serializes"))
.expect("cql2-json is valid JSON");
operators(&json, &mut found);
}
found
}
#[test]
fn every_ats_operator_is_evaluated() {
let from_ats = operators_in(corpus().into_iter().map(|(_, expr)| expr));
assert!(
from_ats.len() > 20,
"expected the ATS corpus to name many operators, found {}",
from_ats.len()
);
let queries = fs::read_to_string("tests/operators_tests.txt").expect("query corpus is present");
let evaluated = operators_in(
queries
.lines()
.map(|line| {
line.split('#')
.next()
.unwrap_or_default()
.trim()
.to_string()
})
.filter(|query| !query.is_empty() && !query.starts_with("//")),
);
let unevaluated: Vec<&String> = from_ats.difference(&evaluated).collect();
assert!(
unevaluated.is_empty(),
"{} operators are checked for syntax but never evaluated: {:?}",
unevaluated.len(),
unevaluated
);
}