use serde_json::Value;
use super::error::{Result, SodaError};
pub fn qbe_to_where_clause(filter: &Value, content_col: &str) -> Result<String> {
let obj = filter
.as_object()
.ok_or_else(|| SodaError::Qbe("filter must be a JSON object".to_string()))?;
let mut clauses = Vec::new();
for (key, val) in obj {
if key == "$orderby" {
continue;
}
clauses.push(translate_top_level(key, val, content_col)?);
}
if clauses.is_empty() {
return Ok("1=1".to_string());
}
Ok(clauses.join(" AND "))
}
pub fn extract_orderby(filter: &Value, content_col: &str) -> Result<Option<String>> {
let Some(spec) = filter.get("$orderby") else {
return Ok(None);
};
let arr = spec.as_array().ok_or_else(|| {
SodaError::NotSupported("$orderby object form is not supported in thin mode".to_string())
})?;
let mut parts = Vec::new();
for entry in arr {
let path = entry
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| SodaError::Qbe("$orderby entry requires a string 'path'".to_string()))?;
let order = entry.get("order").and_then(Value::as_str).unwrap_or("asc");
let dir = match order.to_ascii_lowercase().as_str() {
"asc" => "ASC",
"desc" => "DESC",
other => {
return Err(SodaError::Qbe(format!("invalid $orderby order: {other}")));
}
};
let path_expr = field_to_json_path(path);
parts.push(format!(
"JSON_VALUE({content_col}, '{}') {dir}",
escape_sql_literal(&path_expr)
));
}
if parts.is_empty() {
return Ok(None);
}
Ok(Some(parts.join(", ")))
}
fn translate_top_level(key: &str, val: &Value, content_col: &str) -> Result<String> {
match key {
"$and" => combine_logical(val, content_col, "AND"),
"$or" => combine_logical(val, content_col, "OR"),
"$nor" => {
let inner = combine_logical(val, content_col, "OR")?;
Ok(format!("NOT ({inner})"))
}
_ if key.starts_with('$') => Err(SodaError::NotSupported(format!(
"top-level operator {key} is not supported"
))),
_ => translate_field_predicate(key, val, content_col),
}
}
fn combine_logical(val: &Value, content_col: &str, joiner: &str) -> Result<String> {
let arr = val
.as_array()
.ok_or_else(|| SodaError::Qbe(format!("${} requires an array", joiner.to_lowercase())))?;
if arr.is_empty() {
return Ok("1=1".to_string());
}
let mut parts = Vec::new();
for item in arr {
parts.push(qbe_to_where_clause(item, content_col)?);
}
Ok(format!("({})", parts.join(&format!(") {joiner} ("))))
}
fn translate_field_predicate(field: &str, val: &Value, content_col: &str) -> Result<String> {
let path = field_to_json_path(field);
match val {
Value::Object(ops) => {
let mut parts = Vec::new();
for (op, operand) in ops {
parts.push(translate_operator(&path, op, operand, content_col)?);
}
if parts.is_empty() {
return Ok(json_exists(content_col, &path, ""));
}
Ok(join_and(parts))
}
_ => {
let lit = scalar_to_path_literal(val)?;
Ok(json_exists(content_col, &path, &format!("?(@ == {lit})")))
}
}
}
fn translate_operator(path: &str, op: &str, operand: &Value, content_col: &str) -> Result<String> {
let cmp = |sym: &str| -> Result<String> {
let lit = scalar_to_path_literal(operand)?;
Ok(json_exists(content_col, path, &format!("?(@ {sym} {lit})")))
};
match op {
"$eq" => cmp("=="),
"$ne" => {
let lit = scalar_to_path_literal(operand)?;
Ok(json_exists(content_col, path, &format!("?(@ != {lit})")))
}
"$gt" => cmp(">"),
"$gte" => cmp(">="),
"$lt" => cmp("<"),
"$lte" => cmp("<="),
"$like" => {
let s = operand_as_str(operand, "$like")?;
Ok(json_exists(
content_col,
path,
&format!("?(@ like \"{}\")", escape_path_double_quoted(&s)),
))
}
"$regex" => {
let s = operand_as_str(operand, "$regex")?;
Ok(json_exists(
content_col,
path,
&format!("?(@ like_regex \"{}\")", escape_path_double_quoted(&s)),
))
}
"$startsWith" => {
let s = operand_as_str(operand, "$startsWith")?;
Ok(json_exists(
content_col,
path,
&format!("?(@ starts with \"{}\")", escape_path_double_quoted(&s)),
))
}
"$hasSubstring" | "$instr" | "$contains" => {
let s = operand_as_str(operand, op)?;
Ok(json_exists(
content_col,
path,
&format!(
"?(@ like_regex \"{}\")",
escape_path_double_quoted(®ex_escape(&s))
),
))
}
"$upper" => fold_case(path, operand, content_col, "upper"),
"$lower" => fold_case(path, operand, content_col, "lower"),
"$type" => {
let s = operand_as_str(operand, "$type")?;
Ok(json_exists(
content_col,
path,
&format!("?(@.type() == \"{}\")", escape_path_double_quoted(&s)),
))
}
"$exists" => {
let exists = operand.as_bool().unwrap_or(true);
let inner = json_exists(content_col, path, "");
if exists {
Ok(inner)
} else {
Ok(format!("NOT ({inner})"))
}
}
"$not" => {
let inner = translate_not(path, operand, content_col)?;
Ok(format!("NOT ({inner})"))
}
"$date" => translate_date(path, operand, content_col),
other => Err(SodaError::NotSupported(format!(
"QBE operator {other} is not supported"
))),
}
}
fn translate_not(path: &str, operand: &Value, content_col: &str) -> Result<String> {
match operand {
Value::Object(ops) => {
let mut parts = Vec::new();
for (op, inner) in ops {
parts.push(translate_operator(path, op, inner, content_col)?);
}
Ok(join_and(parts))
}
_ => {
let lit = scalar_to_path_literal(operand)?;
Ok(json_exists(content_col, path, &format!("?(@ == {lit})")))
}
}
}
fn fold_case(path: &str, operand: &Value, content_col: &str, func: &str) -> Result<String> {
let ops = operand
.as_object()
.ok_or_else(|| SodaError::Qbe(format!("${func} requires an operator object")))?;
let mut parts = Vec::new();
for (op, inner) in ops {
let s = operand_as_str(inner, op)?;
let folded = if func == "upper" {
s.to_uppercase()
} else {
s.to_lowercase()
};
let pred = match op.as_str() {
"$eq" => format!(
"?(@.{func}() == \"{}\")",
escape_path_double_quoted(&folded)
),
"$startsWith" => format!(
"?(@.{func}() starts with \"{}\")",
escape_path_double_quoted(&folded)
),
"$like" => format!(
"?(@.{func}() like \"{}\")",
escape_path_double_quoted(&folded)
),
"$hasSubstring" | "$instr" | "$contains" => format!(
"?(@.{func}() like_regex \"{}\")",
escape_path_double_quoted(®ex_escape(&folded))
),
other => {
return Err(SodaError::NotSupported(format!(
"${func} with inner operator {other} is not supported"
)));
}
};
parts.push(json_exists(content_col, path, &pred));
}
Ok(join_and(parts))
}
fn join_and(parts: Vec<String>) -> String {
if parts.len() == 1 {
parts.into_iter().next().unwrap_or_default()
} else {
format!("({})", parts.join(" AND "))
}
}
fn translate_date(path: &str, operand: &Value, content_col: &str) -> Result<String> {
let pred = match operand {
Value::String(s) => {
format!("?(@ == \"{}\")", escape_path_double_quoted(s))
}
Value::Object(ops) => {
let mut sub = Vec::new();
for (op, inner) in ops {
let s = operand_as_str(inner, op)?;
let sym = match op.as_str() {
"$eq" => "==",
"$gt" => ">",
"$gte" => ">=",
"$lt" => "<",
"$lte" => "<=",
"$ne" => "!=",
other => {
return Err(SodaError::NotSupported(format!(
"$date with inner operator {other} is not supported"
)));
}
};
sub.push(format!("@ {sym} \"{}\"", escape_path_double_quoted(&s)));
}
format!("?({})", sub.join(" && "))
}
_ => {
return Err(SodaError::Qbe(
"$date operand must be a string or operator object".to_string(),
));
}
};
Ok(json_exists(content_col, path, &pred))
}
fn json_exists(content_col: &str, path: &str, pred: &str) -> String {
let full = format!("{path}{pred}");
format!(
"JSON_EXISTS({content_col}, '{}')",
escape_sql_literal(&full)
)
}
fn field_to_json_path(field: &str) -> String {
if field.starts_with('$') {
return field.to_string();
}
let mut path = String::from("$");
for segment in field.split('.') {
path.push('.');
path.push_str(segment);
}
path
}
fn scalar_to_path_literal(v: &Value) -> Result<String> {
match v {
Value::String(s) => Ok(format!("\"{}\"", escape_path_double_quoted(s))),
Value::Number(n) => Ok(n.to_string()),
Value::Bool(b) => Ok(b.to_string()),
Value::Null => Ok("null".to_string()),
_ => Err(SodaError::Qbe(
"comparison operand must be a scalar (string, number, bool, null)".to_string(),
)),
}
}
fn operand_as_str(v: &Value, op: &str) -> Result<String> {
match v {
Value::String(s) => Ok(s.clone()),
Value::Number(n) => Ok(n.to_string()),
Value::Bool(b) => Ok(b.to_string()),
_ => Err(SodaError::Qbe(format!(
"{op} operand must be a string or number"
))),
}
}
fn escape_sql_literal(s: &str) -> String {
s.replace('\'', "''")
}
fn escape_path_double_quoted(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
_ => out.push(ch),
}
}
out
}
fn regex_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if "\\^$.|?*+()[]{}".contains(ch) {
out.push('\\');
}
out.push(ch);
}
out
}
#[allow(dead_code)]
pub fn escape_json_path_string(s: &str) -> String {
escape_path_double_quoted(s)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn where_of(filter: serde_json::Value) -> String {
qbe_to_where_clause(&filter, "DATA").expect("translate")
}
#[test]
fn empty_filter_is_true() {
assert_eq!(where_of(json!({})), "1=1");
}
#[test]
fn bare_scalar_is_implicit_eq() {
let w = where_of(json!({"name": "John"}));
assert_eq!(w, r#"JSON_EXISTS(DATA, '$.name?(@ == "John")')"#);
}
#[test]
fn eq_operator() {
let w = where_of(json!({"age": {"$eq": 22}}));
assert_eq!(w, "JSON_EXISTS(DATA, '$.age?(@ == 22)')");
}
#[test]
fn gt_numeric() {
let w = where_of(json!({"age": {"$gt": 18}}));
assert_eq!(w, "JSON_EXISTS(DATA, '$.age?(@ > 18)')");
}
#[test]
fn lt_numeric() {
let w = where_of(json!({"age": {"$lt": 25}}));
assert_eq!(w, "JSON_EXISTS(DATA, '$.age?(@ < 25)')");
}
#[test]
fn like_operator() {
let w = where_of(json!({"name": {"$like": "J%n"}}));
assert_eq!(w, r#"JSON_EXISTS(DATA, '$.name?(@ like "J%n")')"#);
}
#[test]
fn regex_operator() {
let w = where_of(json!({"name": {"$regex": ".*[ho]n"}}));
assert_eq!(w, r#"JSON_EXISTS(DATA, '$.name?(@ like_regex ".*[ho]n")')"#);
}
#[test]
fn nested_dotted_path() {
let w = where_of(json!({"locations.city": {"$regex": "^Ban.*"}}));
assert_eq!(
w,
r#"JSON_EXISTS(DATA, '$.locations.city?(@ like_regex "^Ban.*")')"#
);
}
#[test]
fn starts_with() {
let w = where_of(json!({"name": {"$startsWith": "John"}}));
assert_eq!(w, r#"JSON_EXISTS(DATA, '$.name?(@ starts with "John")')"#);
}
#[test]
fn has_substring_is_regex_escaped() {
let w = where_of(json!({"name": {"$hasSubstring": "John"}}));
assert_eq!(w, r#"JSON_EXISTS(DATA, '$.name?(@ like_regex "John")')"#);
}
#[test]
fn instr_alias() {
let w = where_of(json!({"name": {"$instr": "John"}}));
assert_eq!(w, r#"JSON_EXISTS(DATA, '$.name?(@ like_regex "John")')"#);
}
#[test]
fn contains_alias() {
let w = where_of(json!({"name": {"$contains": "John"}}));
assert_eq!(w, r#"JSON_EXISTS(DATA, '$.name?(@ like_regex "John")')"#);
}
#[test]
fn upper_starts_with() {
let w = where_of(json!({"name": {"$upper": {"$startsWith": "jo"}}}));
assert_eq!(
w,
r#"JSON_EXISTS(DATA, '$.name?(@.upper() starts with "JO")')"#
);
}
#[test]
fn type_array() {
let w = where_of(json!({"locations": {"$type": "array"}}));
assert_eq!(
w,
r#"JSON_EXISTS(DATA, '$.locations?(@.type() == "array")')"#
);
}
#[test]
fn not_eq() {
let w = where_of(json!({"age": {"$not": {"$eq": 22}}}));
assert_eq!(w, "NOT (JSON_EXISTS(DATA, '$.age?(@ == 22)'))");
}
#[test]
fn not_range_multiple_ops() {
let w = where_of(json!({"age": {"$not": {"$lt": 30, "$gt": 10}}}));
assert!(w.starts_with("NOT ("));
assert!(w.contains("@ < 30"));
assert!(w.contains("@ > 10"));
}
#[test]
fn or_with_array_wildcard() {
let w = where_of(json!({
"$or": [
{"age": {"$gt": 50}},
{"locations[*].city": {"$like": "%Ban%"}}
]
}));
assert_eq!(
w,
r#"(JSON_EXISTS(DATA, '$.age?(@ > 50)')) OR (JSON_EXISTS(DATA, '$.locations[*].city?(@ like "%Ban%")'))"#
);
}
#[test]
fn and_with_range_index() {
let w = where_of(json!({
"$and": [
{"age": {"$gt": 40}},
{"locations[0 to 1].city": {"$like": "%aras"}}
]
}));
assert_eq!(
w,
r#"(JSON_EXISTS(DATA, '$.age?(@ > 40)')) AND (JSON_EXISTS(DATA, '$.locations[0 to 1].city?(@ like "%aras")'))"#
);
}
#[test]
fn date_equality() {
let w = where_of(json!({"birthday": {"$date": "2000-12-15"}}));
assert_eq!(w, r#"JSON_EXISTS(DATA, '$.birthday?(@ == "2000-12-15")')"#);
}
#[test]
fn date_gt() {
let w = where_of(json!({"birthday": {"$date": {"$gt": "2000-01-01"}}}));
assert_eq!(w, r#"JSON_EXISTS(DATA, '$.birthday?(@ > "2000-01-01")')"#);
}
#[test]
fn unsupported_operator_errors() {
let err = qbe_to_where_clause(&json!({"x": {"$nearby": 1}}), "DATA").unwrap_err();
assert!(matches!(err, SodaError::NotSupported(_)));
}
#[test]
fn sql_injection_in_string_is_escaped() {
let w = where_of(json!({"name": "O'Brien"}));
assert!(w.contains("O''Brien"), "got: {w}");
}
#[test]
fn orderby_extracted() {
let filter = json!({"$orderby": [{"path": "name", "order": "desc"}]});
let ob = extract_orderby(&filter, "DATA").unwrap().unwrap();
assert!(ob.contains("DESC"));
assert!(ob.contains("JSON_VALUE(DATA"));
assert_eq!(where_of(filter), "1=1");
}
}