use crate::{SqlError, Translation};
use quarb::reflect::QueryArbor;
use quarb::{AstAdapter, NodeId, Value};
pub fn export(quarb: &str) -> Result<Translation, SqlError> {
refuse_marker(quarb)?;
let arbor =
QueryArbor::parse(quarb).map_err(|e| SqlError::Syntax(format!("parsing Quarb: {e}")))?;
refuse_groups(&arbor)?;
let mut ex = Exporter {
arbor,
notes: Vec::new(),
strict: false,
dialect: None,
from_table: String::new(),
join_on_cols: Vec::new(),
join_table: None,
column_types: ColumnTypes::default(),
from_sql: String::new(),
join_sql: None,
in_on: false,
aggregate: false,
keyed_hops: false,
};
let query = ex.query()?;
Ok(Translation {
query,
notes: ex.notes,
})
}
fn refuse_marker(quarb: &str) -> Result<(), SqlError> {
for marker in ["__LEFT__", "__AGG__"] {
if quarb.contains(marker) {
return Err(SqlError::Unsupported(format!(
"query text contains the reserved marker \"{marker}\""
)));
}
}
Ok(())
}
pub struct Pushdown {
pub sql: String,
pub order_table: Option<String>,
pub join_left: Option<(String, Vec<String>)>,
pub column_types: ColumnTypes,
pub tables: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ColumnTypes {
pub numeric: Vec<String>,
pub text: Vec<String>,
pub json: Vec<String>,
}
impl ColumnTypes {
pub fn is_empty(&self) -> bool {
self.numeric.is_empty() && self.text.is_empty() && self.json.is_empty()
}
pub fn unmet(&self, catalog: &[(String, bool, bool)]) -> Vec<String> {
let met = |column: &str, pick: fn(&(String, bool, bool)) -> bool, want: bool| {
let mut found = catalog
.iter()
.filter(|(name, ..)| name.eq_ignore_ascii_case(column))
.peekable();
found.peek().is_some() && found.all(|entry| pick(entry) == want)
};
let numeric = |e: &(String, bool, bool)| e.1;
let json = |e: &(String, bool, bool)| e.2;
let mut out: Vec<String> = Vec::new();
for (columns, pick, want) in [
(
&self.numeric,
numeric as fn(&(String, bool, bool)) -> bool,
true,
),
(&self.text, numeric, false),
(&self.json, json, true),
] {
for c in columns {
if !met(c, pick, want) && !out.contains(c) {
out.push(c.clone());
}
}
}
out
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Dialect {
Postgres,
MySql,
Sqlite,
Mssql,
Oracle,
}
pub fn pushdown(quarb: &str, dialect: Option<Dialect>) -> Option<Pushdown> {
pushdown_explained(quarb, dialect).ok()
}
pub fn pushdown_explained(quarb: &str, dialect: Option<Dialect>) -> Result<Pushdown, SqlError> {
refuse_marker(quarb)?;
let arbor =
QueryArbor::parse(quarb).map_err(|e| SqlError::Syntax(format!("parsing Quarb: {e}")))?;
refuse_groups(&arbor)?;
let mut ex = Exporter {
arbor,
notes: Vec::new(),
strict: true,
dialect,
from_table: String::new(),
join_on_cols: Vec::new(),
join_table: None,
column_types: ColumnTypes::default(),
from_sql: String::new(),
join_sql: None,
in_on: false,
aggregate: false,
keyed_hops: false,
};
let sql = ex.query()?;
let order_table = if ex.aggregate {
None
} else {
Some(ex.from_table.clone())
};
let join_left = ex.join_table.clone().map(|t| (t, ex.join_on_cols.clone()));
let mut tables = vec![ex.from_table.clone()];
tables.extend(ex.join_table.clone());
Ok(Pushdown {
sql,
order_table,
join_left,
column_types: ex.column_types.clone(),
tables,
})
}
fn json_extract(dialect: Dialect, qual: Option<&str>, col: &str, path: &[String]) -> String {
let qcol = match qual {
Some(q) => format!("{q}.{col}"),
None => col.to_string(),
};
match dialect {
Dialect::Postgres => format!("({qcol}::jsonb #>> '{{{}}}')", path.join(",")),
Dialect::MySql => {
format!("JSON_UNQUOTE(JSON_EXTRACT({qcol}, '$.{}'))", path.join("."))
}
Dialect::Sqlite => format!("json_extract({qcol}, '$.{}')", path.join(".")),
Dialect::Mssql | Dialect::Oracle => format!("JSON_VALUE({qcol}, '$.{}')", path.join(".")),
}
}
fn json_valid_guard(dialect: Dialect, qual: Option<&str>, col: &str, cond: String) -> String {
let qcol = match qual {
Some(q) => format!("{q}.{col}"),
None => col.to_string(),
};
match dialect {
Dialect::Sqlite => format!("(json_valid({qcol}) AND {cond})"),
Dialect::MySql => format!("(JSON_VALID({qcol}) AND {cond})"),
Dialect::Mssql => format!("(ISJSON({qcol}) = 1 AND {cond})"),
Dialect::Postgres | Dialect::Oracle => format!("({cond})"),
}
}
fn json_type_probe(
dialect: Dialect,
qual: Option<&str>,
col: &str,
path: &[String],
) -> Option<String> {
let qcol = match qual {
Some(q) => format!("{q}.{col}"),
None => col.to_string(),
};
match dialect {
Dialect::Sqlite => Some(format!("json_type({qcol}, '$.{}')", path.join("."))),
Dialect::Postgres => Some(format!(
"jsonb_typeof({qcol}::jsonb #> '{{{}}}')",
path.join(",")
)),
Dialect::MySql | Dialect::Mssql | Dialect::Oracle => None,
}
}
fn flip_op(op: &str) -> String {
match op {
"<" => ">".into(),
"<=" => ">=".into(),
">" => "<".into(),
">=" => "<=".into(),
other => other.into(),
}
}
const SQL_KEYWORDS: &[&str] = &[
"all",
"and",
"as",
"asc",
"by",
"case",
"cross",
"desc",
"distinct",
"else",
"end",
"except",
"exists",
"from",
"group",
"having",
"in",
"index",
"inner",
"intersect",
"into",
"is",
"join",
"left",
"like",
"limit",
"not",
"null",
"offset",
"on",
"or",
"order",
"outer",
"right",
"select",
"set",
"table",
"then",
"union",
"unique",
"update",
"using",
"values",
"when",
"where",
];
fn is_plain_ident(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !SQL_KEYWORDS.contains(&name.to_ascii_lowercase().as_str())
}
#[derive(Default)]
struct Select {
select: Vec<String>,
distinct: bool,
from: String,
join: Option<(String, String)>, join_outer: bool,
wheres: Vec<String>,
group_by: Option<String>,
having: Option<String>,
order_by: Option<(String, bool)>, limit: Option<String>,
}
impl Select {
fn render(&self) -> String {
let mut out = String::from("SELECT ");
if self.distinct {
out.push_str("DISTINCT ");
}
if self.select.is_empty() {
out.push('*');
} else {
out.push_str(&self.select.join(", "));
}
out.push_str(&format!(" FROM {}", self.from));
if let Some((t, on)) = &self.join {
let kind = if self.join_outer { "LEFT JOIN" } else { "JOIN" };
out.push_str(&format!(" {kind} {t} ON {on}"));
}
if !self.wheres.is_empty() {
out.push_str(&format!(" WHERE {}", self.wheres.join(" AND ")));
}
if let Some(g) = &self.group_by {
out.push_str(&format!(" GROUP BY {g}"));
}
if let Some(h) = &self.having {
out.push_str(&format!(" HAVING {h}"));
}
if let Some((e, desc)) = &self.order_by {
out.push_str(&format!(" ORDER BY {e}"));
if *desc {
out.push_str(" DESC");
}
}
if let Some(n) = &self.limit {
out.push_str(&format!(" LIMIT {n}"));
}
out
}
}
pub struct Partial {
pub table: String,
pub where_sql: String,
pub column_types: ColumnTypes,
}
pub fn partial_pushdown(quarb: &str, dialect: Option<Dialect>) -> Option<Partial> {
partial_pushdown_explained(quarb, dialect).ok()
}
pub fn partial_pushdown_explained(
quarb: &str,
dialect: Option<Dialect>,
) -> Result<Partial, SqlError> {
partial_pushdown_with(quarb, dialect, false)
}
pub fn partial_pushdown_keyed(quarb: &str, dialect: Option<Dialect>) -> Result<Partial, SqlError> {
partial_pushdown_with(quarb, dialect, true)
}
fn partial_pushdown_with(
quarb: &str,
dialect: Option<Dialect>,
keyed_hops: bool,
) -> Result<Partial, SqlError> {
refuse_marker(quarb)?;
let arbor =
QueryArbor::parse(quarb).map_err(|e| SqlError::Syntax(format!("parsing Quarb: {e}")))?;
refuse_groups(&arbor)?;
let mut ex = Exporter {
arbor,
notes: Vec::new(),
strict: true,
dialect,
from_table: String::new(),
join_on_cols: Vec::new(),
join_table: None,
column_types: ColumnTypes::default(),
from_sql: String::new(),
join_sql: None,
in_on: false,
aggregate: false,
keyed_hops,
};
ex.partial()
}
fn refuse_groups(arbor: &QueryArbor) -> Result<(), SqlError> {
let mut stack = vec![arbor.root()];
while let Some(n) = stack.pop() {
if arbor.name(n).as_deref() == Some("group") {
return Err(SqlError::Unsupported(
"path patterns (groups and quantifiers)".into(),
));
}
stack.extend(arbor.children(n));
}
Ok(())
}
struct Exporter {
arbor: QueryArbor,
notes: Vec<String>,
strict: bool,
dialect: Option<Dialect>,
from_table: String,
join_on_cols: Vec<String>,
join_table: Option<String>,
column_types: ColumnTypes,
from_sql: String,
join_sql: Option<String>,
in_on: bool,
aggregate: bool,
keyed_hops: bool,
}
impl Exporter {
fn kids(&self, n: NodeId, kind: &str) -> Vec<NodeId> {
self.arbor
.children(n)
.into_iter()
.filter(|&c| self.arbor.name(c).as_deref() == Some(kind))
.collect()
}
fn kid(&self, n: NodeId, kind: &str) -> Option<NodeId> {
self.kids(n, kind).into_iter().next()
}
fn prop(&self, n: NodeId, key: &str) -> Option<Value> {
self.arbor.property(n, key)
}
fn prop_s(&self, n: NodeId, key: &str) -> String {
self.prop(n, key).map(|v| v.to_string()).unwrap_or_default()
}
fn kind(&self, n: NodeId) -> String {
self.arbor.name(n).unwrap_or_default()
}
fn is_null_literal(&self, n: NodeId) -> bool {
self.kind(n) == "literal" && self.prop_s(n, "type") == "null"
}
fn partial(&mut self) -> Result<Partial, SqlError> {
let root = self.arbor.root();
let q = self
.kid(root, "query")
.ok_or_else(|| SqlError::Unsupported("empty query".into()))?;
let branches = self.kids(q, "branch");
if branches.len() != 1 {
return Err(SqlError::Unsupported("a branch union".into()));
}
let (table, preds) = self.table_branch_lead(branches[0], self.keyed_hops)?;
let all = self.walk_all(root);
let mut table_mentions = 0;
for n in &all {
match self.kind(*n).as_str() {
"step" => {
let axis = self.prop_s(*n, "axis");
if matches!(axis.as_str(), "->" | "<-" | "--" | "-->" | "<--" | "=>") {
let scope = self.prop_s(*n, "scope");
let lists_other_table = scope
.strip_prefix('/')
.and_then(|rest| rest.split(['/', '[', '<']).next())
.is_some_and(|first| {
!first.is_empty()
&& first != table
&& first.chars().all(|c| c.is_alphanumeric() || c == '_')
});
let safe = self.keyed_hops
&& if scope.is_empty() {
axis != "=>"
} else {
lists_other_table
};
if !safe {
return Err(SqlError::Unsupported(
"crosslink/resolution axes could reach \
the filtered table"
.into(),
));
}
}
if self.prop_s(*n, "matcher") == table {
table_mentions += 1;
}
}
"projection" if self.prop_s(*n, "kind") != "property" => {
return Err(SqlError::Unsupported(
"metadata would observe the filtering \
(:::n-rows, :index)"
.into(),
));
}
_ => {}
}
}
if table_mentions != 1 {
return Err(SqlError::Unsupported(
"the table is reached more than once".into(),
));
}
let mut conds = Vec::new();
for p in preds {
if self.prop_s(p, "kind") != "expr" {
break;
}
match self.predicate_cond(p, None) {
Ok(c) => conds.push(c),
Err(_) => {
if let Some(c) = self.prefilter_cond(p) {
conds.push(c);
}
}
}
}
if conds.is_empty() {
return Err(SqlError::Unsupported(
"no leading expression predicates to push".into(),
));
}
Ok(Partial {
table,
where_sql: conds.join(" AND "),
column_types: self.column_types.clone(),
})
}
fn prefilter_cond(&mut self, p: NodeId) -> Option<String> {
let parts: Vec<String> = self
.arbor
.children(p)
.into_iter()
.filter_map(|c| {
self.pred_expr(c, None)
.ok()
.or_else(|| self.prefilter_expr(c))
})
.collect();
if parts.is_empty() {
None
} else {
Some(parts.join(" AND "))
}
}
fn prefilter_expr(&mut self, e: NodeId) -> Option<String> {
let dialect = self.dialect?;
match self.kind(e).as_str() {
"path" => {
let (col, path) = self.json_path_loose(e)?;
self.note_json_column(e, &col);
let probe = json_type_probe(dialect, None, &col, &path)?;
Some(json_valid_guard(
dialect,
None,
&col,
format!("{probe} IS NOT NULL"),
))
}
"compare" => {
let op = self.prop_s(e, "op");
let kids = self.arbor.children(e);
if kids.len() != 2 {
return None;
}
for (pi, li) in [(0usize, 1usize), (1, 0)] {
let Some((col, path)) = self.json_path(kids[pi]) else {
continue;
};
if self.kind(kids[li]) != "literal" {
continue;
}
let ty = self.prop_s(kids[li], "type");
if ty == "null" {
return None;
}
let raw = self
.prop(kids[li], "value")
.map(|v| v.to_string())
.unwrap_or_default();
let op = if pi == 0 { op.clone() } else { flip_op(&op) };
self.note_json_column(kids[pi], &col);
return self.json_prefilter(dialect, &col, &path, &op, &ty, &raw);
}
None
}
_ => None,
}
}
fn json_prefilter(
&self,
d: Dialect,
col: &str,
path: &[String],
op: &str,
lit_ty: &str,
raw: &str,
) -> Option<String> {
if !matches!(op, "=" | "!=" | "<" | "<=" | ">" | ">=") {
return None;
}
if op == "!=" {
let probe = json_type_probe(d, None, col, path)?;
return Some(json_valid_guard(
d,
None,
col,
format!("{probe} IS NOT NULL"),
));
}
let numeric_lit = raw.trim().parse::<f64>().is_ok_and(|f| f.is_finite())
&& (lit_ty != "text" || !raw.trim().is_empty());
if numeric_lit {
let num: f64 = raw.trim().parse().ok()?;
if num.abs() >= 9_007_199_254_740_992.0 {
return None;
}
let lit = raw.trim();
match d {
Dialect::Sqlite => {
let ex = json_extract(d, None, col, path);
let ty = json_type_probe(d, None, col, path)?;
Some(json_valid_guard(
d,
None,
col,
format!("({ex} {op} {lit} OR {ty} = 'text')"),
))
}
Dialect::Postgres => {
let text = json_extract(d, None, col, path);
let ty = json_type_probe(d, None, col, path)?;
Some(format!(
"(CASE WHEN {ty} = 'number' THEN ({text})::float8 {op} {lit} \
WHEN {ty} = 'string' THEN true ELSE false END)"
))
}
Dialect::MySql | Dialect::Mssql | Dialect::Oracle => None,
}
} else if lit_ty == "text" {
if raw.contains('\'') || raw.contains('\\') {
return None;
}
match d {
Dialect::Sqlite => {
let ex = json_extract(d, None, col, path);
Some(json_valid_guard(d, None, col, format!("{ex} {op} '{raw}'")))
}
_ => None,
}
} else {
None
}
}
fn note_json_column(&mut self, o: NodeId, col: &str) {
let declared_on_hop = self.kids(o, "step").first().is_some_and(|s| {
self.kids(*s, "trait").iter().any(|t| {
matches!(self.prop(*t, "alts"), Some(Value::List(alts))
if alts.iter().any(|a| matches!(a, Value::Str(x) if x == "json")))
})
});
if !declared_on_hop && !self.column_types.json.iter().any(|c| c == col) {
self.column_types.json.push(col.to_string());
}
}
fn json_path_loose(&self, o: NodeId) -> Option<(String, Vec<String>)> {
if self.json_path(o).is_some() {
return self.json_path(o);
}
if self.kind(o) != "path" || self.kid(o, "projection").is_some() {
return None;
}
let steps = self.kids(o, "step");
if steps.len() < 2 {
return None;
}
let mut names = Vec::with_capacity(steps.len());
for s in &steps {
if self.prop_s(*s, "axis") != "/"
|| self.prop_s(*s, "matcher-kind") != "name"
|| self.kid(*s, "predicate").is_some()
{
return None;
}
let name = self.prop_s(*s, "matcher");
let plain = !name.is_empty()
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !plain {
return None;
}
names.push(name);
}
let col = names.remove(0);
Some((col, names))
}
fn walk_all(&self, n: NodeId) -> Vec<NodeId> {
let mut out = vec![n];
let mut i = 0;
while i < out.len() {
out.extend(self.arbor.children(out[i]));
i += 1;
}
out
}
fn query(&mut self) -> Result<String, SqlError> {
let root = self.arbor.root();
let q = self
.kid(root, "query")
.ok_or_else(|| SqlError::Unsupported("empty query".into()))?;
let mut sel = Select::default();
let corrs = self.kids(q, "query");
let branches = self.kids(q, "branch");
if branches.len() != 1 {
return Err(SqlError::Unsupported(
"a branch union (SQL has UNION, but result shapes differ; export one branch)"
.into(),
));
}
if corrs.len() > 1 {
return Err(SqlError::Unsupported(
"more than one correlation context".into(),
));
}
if let Some(corr) = corrs.first() {
let (ltable, lpreds) = self.table_branch(branches[0])?;
let (rtable, rpreds) = self.table_branch(self.kids(*corr, "branch")[0])?;
let lsql = self.sql_ident(<able, "table name")?;
let rsql = self.sql_ident(&rtable, "table name")?;
self.from_sql = lsql.clone();
self.join_sql = Some(rsql.clone());
let driver_conds: Vec<NodeId> = lpreds;
let mut on = Vec::new();
let mut wheres = Vec::new();
for p in rpreds {
self.split_join_pred(p, &rsql, &mut on, &mut wheres)?;
}
let outer = matches!(self.prop(*corr, "outer"), Some(Value::Bool(true)));
if outer {
on.append(&mut wheres);
}
sel.join_outer = outer;
if on.is_empty() {
return Err(SqlError::Unsupported(
"a correlation without a '$$' equality (no JOIN condition)".into(),
));
}
for p in driver_conds {
let cond = self.predicate_cond(p, Some(&lsql.clone()))?;
wheres.push(cond);
}
self.notes.push(
"JOIN: Quarb's binding is existential (one row per FROM row, \
bound to its first witness); SQL multiplies rows when \
several joined rows match"
.to_string(),
);
sel.from = lsql.clone();
self.from_table = ltable;
self.join_table = Some(rtable);
sel.join = Some((rsql.clone(), on.join(" AND ")));
sel.wheres = wheres;
if let Some(proj) = self.kid(branches[0], "projection") {
let col = self.projection_col(proj)?;
let col = self.sql_ident(&col, "column name")?;
sel.select.push(format!("{lsql}.{col}"));
}
if self
.kid(self.kids(*corr, "branch")[0], "projection")
.is_some()
{
return Err(SqlError::Unsupported(
"a projection on the joined expression (project the witness \
in the pipeline: '$*1::col')"
.into(),
));
}
self.pipeline(q, &mut sel, Some((&lsql, &rsql)))?;
} else {
let (table, preds) = self.table_branch(branches[0])?;
sel.from = self.sql_ident(&table, "table name")?;
self.from_table = table;
for p in preds {
let cond = self.predicate_cond(p, None)?;
sel.wheres.push(cond);
}
if let Some(proj) = self.kid(branches[0], "projection") {
let col = self.projection_col(proj)?;
sel.select.push(self.sql_ident(&col, "column name")?);
}
self.pipeline(q, &mut sel, None)?;
}
Ok(sel.render())
}
fn table_branch(&mut self, b: NodeId) -> Result<(String, Vec<NodeId>), SqlError> {
self.table_branch_lead(b, false)
}
fn table_branch_lead(
&mut self,
b: NodeId,
more: bool,
) -> Result<(String, Vec<NodeId>), SqlError> {
let steps = self.kids(b, "step");
if steps.len() < 2 || (steps.len() > 2 && !more) {
return Err(SqlError::Unsupported(
"navigation beyond /table/* (SQL sees tables and rows)".into(),
));
}
let (t, rows) = (steps[0], steps[1]);
if self.prop_s(t, "axis") != "/"
|| self.prop_s(t, "matcher-kind") != "name"
|| self.prop_s(rows, "axis") != "/"
|| self.prop_s(rows, "matcher-kind") != "any"
{
return Err(SqlError::Unsupported(
"navigation beyond /table/* (SQL sees tables and rows)".into(),
));
}
if self.kid(t, "predicate").is_some() {
return Err(SqlError::Unsupported("a predicate on the table hop".into()));
}
Ok((self.prop_s(t, "matcher"), self.kids(rows, "predicate")))
}
fn predicate_cond(&mut self, p: NodeId, qual: Option<&str>) -> Result<String, SqlError> {
if self.prop_s(p, "kind") != "expr" {
return Err(SqlError::Unsupported(
"a positional predicate on rows (SQL rows are unordered; ORDER BY + LIMIT)".into(),
));
}
let parts: Vec<String> = self
.arbor
.children(p)
.into_iter()
.map(|c| self.pred_expr(c, qual))
.collect::<Result<_, _>>()?;
Ok(parts.join(" AND "))
}
fn pred_expr(&mut self, e: NodeId, qual: Option<&str>) -> Result<String, SqlError> {
match self.kind(e).as_str() {
"and" | "or" => {
let op = self.kind(e).to_uppercase();
let kids: Vec<String> = self
.arbor
.children(e)
.into_iter()
.map(|c| self.pred_expr(c, qual))
.collect::<Result<_, _>>()?;
Ok(format!("({})", kids.join(&format!(" {op} "))))
}
"not" => {
if self.strict {
return Err(SqlError::Semantics(
"SQL NOT propagates UNKNOWN, dropping the NULL rows \
Quarb keeps"
.into(),
));
}
let inner: Vec<String> = self
.arbor
.children(e)
.into_iter()
.map(|c| self.pred_expr(c, qual))
.collect::<Result<_, _>>()?;
Ok(format!("NOT ({})", inner.join(" AND ")))
}
"parens" => {
let inner: Vec<String> = self
.arbor
.children(e)
.into_iter()
.map(|c| self.pred_expr(c, qual))
.collect::<Result<_, _>>()?;
Ok(format!("({})", inner.join(" AND ")))
}
"compare" => {
let op = self.prop_s(e, "op");
let kids = self.arbor.children(e);
if matches!(op.as_str(), "=" | "!=")
&& (self.is_null_literal(kids[0]) || self.is_null_literal(kids[1]))
{
let other = if self.is_null_literal(kids[0]) {
kids[1]
} else {
kids[0]
};
let col = self.operand(other, qual)?;
return Ok(if op == "=" {
format!("{col} IS NULL")
} else {
format!("{col} IS NOT NULL")
});
}
if op == "="
&& let Some(dialect) = self.dialect
{
for (pi, li) in [(0usize, 1usize), (1, 0)] {
if self.is_text_literal(kids[li])
&& let Some((col, path)) = self.json_path(kids[pi])
{
self.note_json_column(kids[pi], &col);
let raw = self
.prop(kids[li], "value")
.map(|v| v.to_string())
.unwrap_or_default();
if raw.trim().parse::<f64>().is_ok() {
return Err(SqlError::Semantics(
"a numeric-looking text literal against a \
JSON path compares numerically in Quarb \
(value coercion); no SQL extractor \
matches that"
.into(),
));
}
let lit = self.operand(kids[li], qual)?;
let extract = json_extract(dialect, qual, &col, &path);
return Ok(json_valid_guard(
dialect,
qual,
&col,
format!("{extract} = {lit}"),
));
}
}
}
if self.dialect.is_some() {
for side in [kids[0], kids[1]] {
if self.json_path(side).is_some() || self.json_path_loose(side).is_some() {
return Err(SqlError::Semantics(
"a JSON-path comparison pushes whole only as \
fixed-path string equality (Quarb coerces \
numeric-looking values; no extractor matches \
that); this shape rides the partial prefilter"
.into(),
));
}
}
}
if self.strict {
for (ci, li) in [(0usize, 1usize), (1, 0)] {
let Some(column) = self.column_operand(kids[ci]) else {
continue;
};
if self.kind(kids[li]) != "literal" {
continue;
}
let numeric = self
.prop(kids[li], "value")
.is_some_and(|v| Value::Str(v.to_string()).numeric_reading().is_some());
let list = match (self.prop_s(kids[li], "type").as_str(), numeric) {
("null", _) => continue,
("text", true) => {
return Err(SqlError::Semantics(
"a numeric-looking text literal compares \
numerically in Quarb (value coercion); SQL \
compares it as text"
.into(),
));
}
("text", false) => &mut self.column_types.text,
(_, true) => &mut self.column_types.numeric,
(_, false) => continue,
};
if !list.contains(&column) {
list.push(column);
}
}
}
let l = self.operand(kids[0], qual)?;
if self.kind(kids[1]) == "pattern" {
if op.as_str() != "=" {
return Err(SqlError::Unsupported(format!(
"a pattern literal on '{op}' (only '=' pushes)"
)));
}
if self.strict && self.dialect != Some(Dialect::Postgres) {
return Err(SqlError::Semantics(
"LIKE case folding differs per engine \
(provably case-sensitive only on PostgreSQL)"
.into(),
));
}
if !self.strict {
self.notes.push(
"pattern → LIKE: Quarb's pattern test is \
case-sensitive; LIKE folds case on \
SQLite/MySQL but not PostgreSQL"
.to_string(),
);
}
let mut like = String::new();
for kid in self.arbor.children(kids[1]) {
match self.kind(kid).as_str() {
"star" => like.push('%'),
_ => {
let raw = self
.prop(kid, "value")
.map(|v| v.to_string())
.unwrap_or_default();
like.push_str(
&raw.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
.replace('\'', "''"),
);
}
}
}
return Ok(format!("{l} LIKE '{like}' ESCAPE '\\'"));
}
let r = self.operand(kids[1], qual)?;
Ok(match op.as_str() {
"=" => format!("{l} = {r}"),
"!=" => {
if self.strict {
return Err(SqlError::Semantics(
"'!=' drops the NULL rows Quarb keeps \
(SQL '<>' is UNKNOWN for NULL; use '!= null' \
for IS NOT NULL)"
.into(),
));
}
self.notes.push(
"'!=' → '<>': Quarb keeps rows whose column is NULL; \
SQL's '<>' drops them (use '!= null' for IS NOT NULL)"
.to_string(),
);
format!("{l} <> {r}")
}
"<" | "<=" | ">" | ">=" => format!("{l} {op} {r}"),
"*=" => {
if self.strict && self.dialect != Some(Dialect::Postgres) {
return Err(SqlError::Semantics(
"LIKE case folding differs per engine \
(provably case-sensitive only on PostgreSQL)"
.into(),
));
}
if !self.is_text_literal(kids[1]) {
return Err(SqlError::Unsupported(
"'*=' with a non-literal pattern".into(),
));
}
self.notes.push(
"*= → LIKE: Quarb's substring test is case-sensitive; LIKE \
folds case on SQLite/MySQL but not PostgreSQL"
.to_string(),
);
let raw = self
.prop(kids[1], "value")
.map(|v| v.to_string())
.unwrap_or_default();
let pat = raw
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
.replace('\'', "''");
format!("{l} LIKE '%{pat}%' ESCAPE '\\'")
}
"==" | "!==" | "=~" | "!~" => {
return Err(SqlError::Semantics(
"regex matching means a different REGEXP engine per \
backend; regexes run engine-side"
.into(),
));
}
other => {
return Err(SqlError::Unsupported(format!("the '{other}' comparison")));
}
})
}
_ => {
if self.strict {
return Err(SqlError::Semantics(
"truthiness diverges (0 and '' are falsy in Quarb)".into(),
));
}
self.notes.push(
"truthiness: '[::c]' exports as IS NOT NULL, but Quarb also treats \
0 and '' as falsy"
.to_string(),
);
Ok(format!("{} IS NOT NULL", self.operand(e, qual)?))
}
}
}
fn operand(&mut self, o: NodeId, qual: Option<&str>) -> Result<String, SqlError> {
match self.kind(o).as_str() {
"literal" => {
let v = self.prop(o, "value").unwrap_or(Value::Null);
match self.prop_s(o, "type").as_str() {
"text" => {
let s = v.to_string();
if self.strict && (s.contains('\'') || s.contains('\\')) {
return Err(SqlError::Semantics(
"a text literal with a quote or backslash \
has no escaping portable across SQL dialects"
.into(),
));
}
Ok(format!("'{}'", s.replace('\'', "''")))
}
"null" => Ok("NULL".to_string()),
_ => Ok(v.to_string()),
}
}
"path" => {
if self.kid(o, "step").is_some() {
let s = self.kids(o, "step")[0];
if self.prop_s(s, "axis") == "-->" {
return Err(SqlError::Unsupported(
"a '-->' resolution chain: the foreign-key targets live in \
the schema, not the query — spell the join with '<=>' to \
export it"
.into(),
));
}
return Err(SqlError::Unsupported(
"navigation inside a predicate (SQL rows are flat)".into(),
));
}
let p = self
.kid(o, "projection")
.ok_or_else(|| SqlError::Unsupported("an empty path operand".into()))?;
let col = self.projection_col(p)?;
let col = self.sql_ident(&col, "column name")?;
Ok(match qual {
Some(q) => format!("{q}.{col}"),
None => col,
})
}
"context" => {
let p = self
.kid(o, "projection")
.ok_or_else(|| SqlError::Unsupported("a bare '$*' reference".into()))?;
let col = self.projection_col(p)?;
let col = self.sql_ident(&col, "column name")?;
if matches!(self.prop(o, "index"), Some(Value::Int(0))) {
if !self.in_on {
return Err(SqlError::Unsupported(
"a '$$0' reference outside the join's ON".into(),
));
}
if self.kid(o, "step").is_some() {
return Err(SqlError::Unsupported(
"a '$$0' reference beyond a plain column ($$0::col)".into(),
));
}
return Ok(format!("{}.{col}", self.from_sql.clone()));
}
if self.in_on {
return Err(SqlError::Unsupported(
"a '$*' reference inside the ON (the driver is '$$0')".into(),
));
}
let Some(join) = self.join_sql.clone() else {
return Err(SqlError::Unsupported(
"a '$*' reference outside a correlation join".into(),
));
};
match self.prop(o, "index") {
Some(Value::Int(1)) => Ok(format!("{join}.{col}")),
None => Err(SqlError::Unsupported("a bare '$*' reference".into())),
Some(v) => Err(SqlError::Unsupported(format!(
"pushdown: $*{v} beyond a two-branch correlation"
))),
}
}
"invoking" => {
Err(SqlError::Unsupported(
"a '$$__' reference (the driver inside the ON is '$$0')".into(),
))
}
"arith" => {
let op = self.prop_s(o, "op");
let kids = self.arbor.children(o);
let l = self.operand(kids[0], qual)?;
let r = self.operand(kids[1], qual)?;
Ok(match op.as_str() {
"+" | "-" | "*" => format!("({l} {op} {r})"),
"div" => format!("({l} / {r})"),
"mod" => format!("({l} % {r})"),
other => return Err(SqlError::Unsupported(format!("'{other}' arithmetic"))),
})
}
other => Err(SqlError::Unsupported(format!(
"the '{other}' operand (registers, topics, and captures are Quarb-side state)"
))),
}
}
fn projection_col(&mut self, p: NodeId) -> Result<String, SqlError> {
match self.prop_s(p, "kind").as_str() {
"property" => match self.prop(p, "key") {
Some(k) => Ok(k.to_string()),
None => Err(SqlError::Unsupported(
"the bare '::' projection (name the column)".into(),
)),
},
other => Err(SqlError::Unsupported(format!(
"the {other} metadata projection"
))),
}
}
fn assume_numeric(&mut self, column: Option<String>) {
if !self.strict {
return;
}
if let Some(c) = column
&& !self.column_types.numeric.contains(&c)
{
self.column_types.numeric.push(c);
}
}
fn column_operand(&self, o: NodeId) -> Option<String> {
match self.kind(o).as_str() {
"path" if self.kid(o, "step").is_none() => {}
"context" => {}
_ => return None,
}
let p = self.kid(o, "projection")?;
if self.prop_s(p, "kind") != "property" {
return None;
}
self.prop(p, "key").map(|k| k.to_string())
}
fn is_text_literal(&self, o: NodeId) -> bool {
self.kind(o) == "literal" && self.prop_s(o, "type") == "text"
}
fn json_path(&self, o: NodeId) -> Option<(String, Vec<String>)> {
if self.kind(o) != "path" {
return None;
}
let steps = self.kids(o, "step");
if steps.len() < 2 {
return None;
}
let proj = self.kid(o, "projection")?;
if self.prop_s(proj, "kind") != "property" || self.prop(proj, "key").is_some() {
return None;
}
let mut names = Vec::with_capacity(steps.len());
for s in &steps {
if self.prop_s(*s, "axis") != "/"
|| self.prop_s(*s, "matcher-kind") != "name"
|| self.kid(*s, "predicate").is_some()
{
return None;
}
let name = self.prop_s(*s, "matcher");
let plain = !name.is_empty()
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !plain {
return None;
}
names.push(name);
}
let col = names.remove(0);
Some((col, names))
}
fn alias(&mut self, name: &str) -> Result<String, SqlError> {
if is_plain_ident(name) {
return Ok(name.to_string());
}
if self.strict {
return Err(SqlError::Unsupported(format!(
"pushdown: field name {name:?} needs SQL quoting \
(dialects disagree); rename the field"
)));
}
self.notes
.push(format!("field name {name:?} double-quoted (ANSI)"));
Ok(format!("\"{}\"", name.replace('"', "\"\"")))
}
fn sql_ident(&mut self, name: &str, what: &str) -> Result<String, SqlError> {
if is_plain_ident(name) {
return Ok(name.to_string());
}
if self.strict {
return Err(SqlError::Unsupported(format!(
"pushdown: {what} {name:?} is not a plain SQL identifier"
)));
}
self.notes
.push(format!("{what} {name:?} double-quoted (ANSI)"));
Ok(format!("\"{}\"", name.replace('"', "\"\"")))
}
fn split_join_pred(
&mut self,
p: NodeId,
rtable: &str,
on: &mut Vec<String>,
wheres: &mut Vec<String>,
) -> Result<(), SqlError> {
if self.prop_s(p, "kind") != "expr" {
return Err(SqlError::Unsupported(
"a positional predicate on the joined side".into(),
));
}
fn conjuncts(ex: &Exporter, e: NodeId, out: &mut Vec<NodeId>) {
if ex.kind(e) == "and" {
for c in ex.arbor.children(e) {
conjuncts(ex, c, out);
}
} else {
out.push(e);
}
}
let mut parts = Vec::new();
for c in self.arbor.children(p) {
conjuncts(self, c, &mut parts);
}
for e in parts {
let uses_driver = self.subtree_has_driver(e);
self.in_on = true;
let cond = self.pred_expr(e, Some(rtable));
self.in_on = false;
let cond = cond?;
if uses_driver && self.kind(e) == "compare" && self.prop_s(e, "op") == "=" {
self.collect_join_cols(e)?;
on.push(cond);
} else {
wheres.push(cond);
}
}
Ok(())
}
fn collect_join_cols(&mut self, e: NodeId) -> Result<(), SqlError> {
if self.is_driver(e) {
return Ok(());
}
if self.kind(e) == "path"
&& self.kid(e, "step").is_none()
&& let Some(p) = self.kid(e, "projection")
{
let col = self.projection_col(p)?;
self.join_on_cols.push(col);
}
for c in self.arbor.children(e) {
self.collect_join_cols(c)?;
}
Ok(())
}
fn is_driver(&self, n: NodeId) -> bool {
self.kind(n) == "context" && matches!(self.prop(n, "index"), Some(Value::Int(0)))
}
fn subtree_has_driver(&self, n: NodeId) -> bool {
self.is_driver(n)
|| self
.arbor
.children(n)
.into_iter()
.any(|c| self.subtree_has_driver(c))
}
fn pipeline(
&mut self,
q: NodeId,
sel: &mut Select,
join: Option<(&str, &str)>,
) -> Result<(), SqlError> {
let Some(pipe) = self.kid(q, "pipeline") else {
return Ok(());
};
let commands: Vec<NodeId> = self.arbor.children(pipe);
let mut i = 0;
let mut pending_col: Option<String> = None;
let mut pending_name: Option<String> = None;
let mut grouped: Option<(String, String, String)> = None;
while i < commands.len() {
let s = commands[i];
match self.kind(s).as_str() {
"expr" => {
let kids = self.arbor.children(s);
pending_col = Some(self.operand(kids[0], join.map(|(l, _)| l))?);
pending_name = self.column_operand(kids[0]);
}
"cmd" => {
let name = self.prop_s(s, "name");
match name.as_str() {
"rec" | "record" => {
sel.select = self.record_fields(s, join)?;
}
"count" | "sum" | "mean" | "avg" | "min" | "max" => {
let (key, _, _) = grouped.as_ref().ok_or_else(|| {
SqlError::Unsupported(format!(
"'| {name}' outside a group (use '@| {name}')"
))
})?;
let col = if name == "count" {
pending_col = None;
None
} else {
self.assume_numeric(pending_name.take());
pending_col.take()
};
let agg = sql_agg(&name, col)?;
grouped = Some((key.clone(), agg.clone(), agg));
}
other => {
if matches!(other, "sort" | "sort_by" | "unique" | "top" | "bottom") {
return Err(SqlError::Semantics(format!(
"'{other}' means the backend's collation and \
duplicate semantics; one engine-side ordering \
keeps it identical everywhere"
)));
}
return Err(SqlError::Unsupported(format!(
"the '{other}' pipeline command"
)));
}
}
}
"push" => {
if let Some((k, agg, _)) = grouped.take() {
let alias = self.prop_s(s, "name");
grouped = Some((k, agg, alias));
} else {
return Err(SqlError::Unsupported(
"a register push (Quarb-side state)".into(),
));
}
}
"filter" => {
if grouped.is_some() {
let cond = self.having_cond(s)?;
sel.having = Some(cond);
} else {
return Err(SqlError::Unsupported(
"a mid-pipeline filter (put it in the row predicate)".into(),
));
}
}
"recall" => {
if self.prop_s(s, "ref") != "%." {
return Err(SqlError::Unsupported(
"a register recall (Quarb-side state)".into(),
));
}
let (key, agg, alias) = grouped.clone().ok_or_else(|| {
SqlError::Unsupported("'%.', with nothing grouped".into())
})?;
let alias = self.alias(&alias)?;
sel.select = vec![key.clone(), format!("{agg} AS {alias}")];
if let Some(h) = sel.having.take() {
sel.having = Some(h.replace("__AGG__", &agg));
}
}
"agg" => {
let name = self.prop_s(s, "name");
match name.as_str() {
"count" | "sum" | "mean" | "avg" | "min" | "max" => {
let col = if name == "count" {
pending_col = None;
None
} else {
self.assume_numeric(pending_name.take());
pending_col.take()
};
sel.select = vec![sql_agg(&name, col)?];
self.aggregate = true;
}
"group" => {
if self.strict {
return Err(SqlError::Semantics(
"GROUP BY result order is unordered in SQL".into(),
));
}
self.notes.push(
"GROUP BY: SQL keeps a NULL-key group; Quarb's group \
drops null keys"
.to_string(),
);
let key = self.group_key(s, join)?;
sel.group_by = Some(key.clone());
grouped = Some((key, String::new(), String::new()));
}
"sort_by" => {
if self.strict {
return Err(SqlError::Semantics(
"ORDER BY collations differ per engine".into(),
));
}
if sel.limit.is_some() {
return Err(SqlError::Unsupported(
"a sort after a positional selection (SQL orders \
before LIMIT)"
.into(),
));
}
if sel.order_by.is_some() {
return Err(SqlError::Unsupported(
"a second sort (SQL has a single ORDER BY)".into(),
));
}
let kids = self.arbor.children(s);
let e = self.operand(kids[0], join.map(|(l, _)| l))?;
sel.order_by = Some((e, false));
}
"reverse" => match &mut sel.order_by {
Some((_, desc)) => *desc = true,
None => {
return Err(SqlError::Unsupported(
"reverse without an ORDER BY (rows are unordered)".into(),
));
}
},
"top" => {
if self.strict {
return Err(SqlError::Semantics(
"ORDER BY collations differ per engine".into(),
));
}
if sel.limit.is_some() {
return Err(SqlError::Unsupported(
"'top' after a positional selection (SQL orders \
before LIMIT)"
.into(),
));
}
if sel.order_by.is_some() {
return Err(SqlError::Unsupported(
"a second sort (SQL has a single ORDER BY)".into(),
));
}
let kids = self.arbor.children(s);
let n = self.prop_s(kids[0], "value");
let e = self.operand(kids[1], join.map(|(l, _)| l))?;
sel.order_by = Some((e, true));
sel.limit = Some(n);
}
"unique" => {
if self.strict {
return Err(SqlError::Unsupported(
"pushdown: DISTINCT result order is unordered in SQL".into(),
));
}
if sel.limit.is_some() {
return Err(SqlError::Unsupported(
"'unique' after a positional selection (SQL applies \
DISTINCT before LIMIT)"
.into(),
));
}
if let Some(c) = pending_col.take() {
sel.select = vec![c];
}
sel.distinct = true;
}
other => {
return Err(SqlError::Unsupported(format!("the '{other}' aggregate")));
}
}
}
"select" => {
if self.strict {
return Err(SqlError::Unsupported(
"pushdown: LIMIT without a guaranteed order".into(),
));
}
if sel.limit.is_some() {
return Err(SqlError::Unsupported(
"a second positional selection".into(),
));
}
let p = self.arbor.children(s)[0];
match (self.prop_s(p, "kind").as_str(), self.prop(p, "to")) {
("range", Some(Value::Int(n)))
if self.prop(p, "from").is_none() && n > 0 =>
{
sel.limit = Some(n.to_string());
}
_ => {
return Err(SqlError::Unsupported(
"positional selection beyond '@| [..n]'".into(),
));
}
}
}
other => {
return Err(SqlError::Unsupported(format!(
"the '{other}' command (windows, subcontexts, and registers are \
Quarb-side state)"
)));
}
}
i += 1;
}
if let Some(c) = pending_col
&& sel.select.is_empty()
{
sel.select = vec![c];
}
Ok(())
}
fn group_key(&mut self, s: NodeId, join: Option<(&str, &str)>) -> Result<String, SqlError> {
let kids = self.arbor.children(s);
let key = kids
.iter()
.rev()
.find(|&&k| self.kind(k) != "literal")
.ok_or_else(|| SqlError::Unsupported("a literal group key".into()))?;
self.operand(*key, join.map(|(l, _)| l))
}
fn having_cond(&mut self, s: NodeId) -> Result<String, SqlError> {
let kids = self.arbor.children(s);
if kids.len() != 1 || self.kind(kids[0]) != "compare" {
return Err(SqlError::Unsupported(
"HAVING translates for a single comparison".into(),
));
}
let e = kids[0];
let op = self.prop_s(e, "op");
let cmp_kids = self.arbor.children(e);
let l = match self.kind(cmp_kids[0]).as_str() {
"topic" | "recall" => "__AGG__".to_string(),
_ => {
return Err(SqlError::Unsupported(
"HAVING compares the aggregate ($_ or its register)".into(),
));
}
};
let r = self.operand(cmp_kids[1], None)?;
Ok(format!("{l} {op} {r}"))
}
fn record_fields(
&mut self,
s: NodeId,
join: Option<(&str, &str)>,
) -> Result<Vec<String>, SqlError> {
let kids = self.arbor.children(s);
let mut fields = Vec::new();
let mut i = 0;
while i < kids.len() {
let k = kids[i];
if self.kind(k) == "literal" {
let name = self.prop_s(k, "value");
let value = self.operand(kids[i + 1], join.map(|(l, _)| l))?;
let name = self.alias(&name)?;
fields.push(format!("{value} AS {name}"));
i += 2;
} else {
let value = self.operand(k, join.map(|(l, _)| l))?;
fields.push(value);
i += 1;
}
}
Ok(fields)
}
}
fn sql_agg(name: &str, col: Option<String>) -> Result<String, SqlError> {
let f = match name {
"count" => "COUNT",
"sum" => "SUM",
"mean" | "avg" => "AVG",
"min" => "MIN",
"max" => "MAX",
_ => unreachable!("checked by caller"),
};
match col {
Some(c) => Ok(format!("{f}({c})")),
None if f == "COUNT" => Ok("COUNT(*)".to_string()),
None => Err(SqlError::Unsupported(format!(
"'{name}' over row nodes (project a column first: '| ::col @| {name}')"
))),
}
}
#[cfg(test)]
mod null_and_literal_tests {
use super::{Dialect, export, partial_pushdown, pushdown};
const GROUPED: &str = " | ::x @| group(\"g\", ::x) | count | .n | %.";
#[test]
fn pattern_literal_pushes_as_like() {
let q = "/users/*[::name == 'app-'*]::id";
let p = pushdown(q, Some(Dialect::Postgres)).unwrap();
assert_eq!(
p.sql,
"SELECT id FROM users WHERE name LIKE 'app-%' ESCAPE '\\'"
);
assert!(pushdown(q, Some(Dialect::Sqlite)).is_none());
assert!(pushdown(q, Some(Dialect::MySql)).is_none());
let p = pushdown("/users/*[::name == *'web'*]::id", Some(Dialect::Postgres)).unwrap();
assert!(p.sql.contains("LIKE '%web%'"), "{}", p.sql);
let p = pushdown(
"/users/*[::name == *'a'*'.gz']::id",
Some(Dialect::Postgres),
)
.unwrap();
assert!(p.sql.contains("LIKE '%a%.gz'"), "{}", p.sql);
let p = pushdown("/users/*[::name == *'50%_x'*]::id", Some(Dialect::Postgres)).unwrap();
assert!(p.sql.contains("LIKE '%50\\%\\_x%'"), "{}", p.sql);
assert!(pushdown("/users/*[::name !== *'web'*]::id", Some(Dialect::Postgres)).is_none());
let p = pushdown("/users/*[::name *= 'web']::id", Some(Dialect::Postgres)).unwrap();
assert!(p.sql.contains("LIKE '%web%'"), "{}", p.sql);
assert!(pushdown("/users/*[::name *= 'web']::id", Some(Dialect::Sqlite)).is_none());
}
#[test]
fn json_column_path_pushdown_per_dialect() {
let q = "/orders/*[/data/meta/tier:: = 'gold']::id";
let sql = |d| pushdown(q, Some(d)).unwrap().sql;
assert_eq!(
sql(Dialect::Postgres),
"SELECT id FROM orders WHERE ((data::jsonb #>> '{meta,tier}') = 'gold')"
);
assert_eq!(
sql(Dialect::MySql),
"SELECT id FROM orders WHERE (JSON_VALID(data) AND \
JSON_UNQUOTE(JSON_EXTRACT(data, '$.meta.tier')) = 'gold')"
);
assert_eq!(
sql(Dialect::Sqlite),
"SELECT id FROM orders WHERE (json_valid(data) AND \
json_extract(data, '$.meta.tier') = 'gold')"
);
assert_eq!(
sql(Dialect::Mssql),
"SELECT id FROM orders WHERE (ISJSON(data) = 1 AND \
JSON_VALUE(data, '$.meta.tier') = 'gold')"
);
assert_eq!(
sql(Dialect::Oracle),
"SELECT id FROM orders WHERE (JSON_VALUE(data, '$.meta.tier') = 'gold')"
);
assert!(pushdown(q, None).is_none());
}
#[test]
fn json_pushdown_only_string_equality() {
let d = Some(Dialect::Sqlite);
assert!(pushdown("/o/*[/data/n:: > 2]::id", d).is_none());
assert!(pushdown("/o/*[/data/tier:: != 'gold']::id", d).is_none());
assert!(pushdown("/o/*[/data/items/*/sku:: = 'A1']::id", d).is_none());
}
#[test]
fn json_pushdown_refuses_numeric_looking_literal() {
let q = "/orders/*[/data/total:: = '150']::id";
assert!(pushdown(q, Some(Dialect::Sqlite)).is_none());
assert!(pushdown(q, Some(Dialect::Postgres)).is_none());
}
#[test]
fn partial_json_prefilters_sqlite() {
let p = partial_pushdown(
"/orders/*[/payload/customer/geo/city:: = 'Lyon']::id",
Some(Dialect::Sqlite),
)
.unwrap();
assert_eq!(p.table, "orders");
assert_eq!(
p.where_sql,
"(json_valid(payload) AND \
json_extract(payload, '$.customer.geo.city') = 'Lyon')"
);
let p = partial_pushdown(
"/orders/*[/payload/total:: > 150]::id",
Some(Dialect::Sqlite),
)
.unwrap();
assert_eq!(
p.where_sql,
"(json_valid(payload) AND (json_extract(payload, '$.total') > 150 \
OR json_type(payload, '$.total') = 'text'))"
);
let p = partial_pushdown("/orders/*[/payload/gift]::id", Some(Dialect::Sqlite)).unwrap();
assert_eq!(
p.where_sql,
"(json_valid(payload) AND json_type(payload, '$.gift') IS NOT NULL)"
);
let p = partial_pushdown(
"/orders/*[/payload/status:: != 'x']::id",
Some(Dialect::Sqlite),
)
.unwrap();
assert_eq!(
p.where_sql,
"(json_valid(payload) AND json_type(payload, '$.status') IS NOT NULL)"
);
let p = partial_pushdown(
"/orders/*[150 < /payload/total::]::id",
Some(Dialect::Sqlite),
)
.unwrap();
assert!(p.where_sql.contains("> 150"), "{}", p.where_sql);
assert!(partial_pushdown("/orders/*[/payload/gift]::id", None).is_none());
}
#[test]
fn partial_json_prefilters_postgres() {
let p = partial_pushdown(
"/orders/*[/payload/total:: > 150]::id",
Some(Dialect::Postgres),
)
.unwrap();
assert_eq!(
p.where_sql,
"(CASE WHEN jsonb_typeof(payload::jsonb #> '{total}') = 'number' \
THEN ((payload::jsonb #>> '{total}'))::float8 > 150 \
WHEN jsonb_typeof(payload::jsonb #> '{total}') = 'string' \
THEN true ELSE false END)"
);
let p = partial_pushdown("/orders/*[/payload/gift]::id", Some(Dialect::Postgres)).unwrap();
assert_eq!(
p.where_sql,
"(jsonb_typeof(payload::jsonb #> '{gift}') IS NOT NULL)"
);
}
#[test]
fn partial_skips_untranslatable_keeps_prefix() {
let p = partial_pushdown(
"/orders/*[::status = 'shipped'][/payload/line_items/*/sku:: = 'X']::id",
Some(Dialect::Sqlite),
)
.unwrap();
assert_eq!(p.where_sql, "status = 'shipped'");
}
#[test]
fn null_literal_compares_use_is_null() {
assert_eq!(
export("/t/*[::x = null] | ::x").unwrap().query,
"SELECT x FROM t WHERE x IS NULL"
);
assert_eq!(
export("/t/*[::x != null] | ::x").unwrap().query,
"SELECT x FROM t WHERE x IS NOT NULL"
);
assert_eq!(
pushdown("/t/*[::x = null] | ::x", None).unwrap().sql,
"SELECT x FROM t WHERE x IS NULL"
);
assert_eq!(
pushdown("/t/*[::x != null] | ::x", None).unwrap().sql,
"SELECT x FROM t WHERE x IS NOT NULL"
);
}
#[test]
fn ne_and_not_refuse_pushdown_but_display_diverges() {
assert!(pushdown("/t/*[::x != 5] | ::x", None).is_none());
assert!(pushdown("/t/*[!::x = 5] | ::x", None).is_none());
assert!(partial_pushdown(&format!("/t/*[::x != 5]{GROUPED}"), None).is_none());
assert!(partial_pushdown(&format!("/t/*[!::x = 5]{GROUPED}"), None).is_none());
let t = export("/t/*[::x != 5] | ::x").unwrap();
assert_eq!(t.query, "SELECT x FROM t WHERE x <> 5");
assert!(t.notes.iter().any(|n| n.contains("NULL")));
}
#[test]
fn unescapable_text_literal_refuses_pushdown() {
assert!(pushdown("/files/*[::path = \"C:\\temp\"] | ::path", None).is_none());
assert!(pushdown("/t/*[::name = \"it's\"] | ::name", None).is_none());
assert!(partial_pushdown(&format!("/t/*[::name = \"it's\"]{GROUPED}"), None).is_none());
assert_eq!(
pushdown("/t/*[::name = \"rare\"] | ::name", None)
.unwrap()
.sql,
"SELECT name FROM t WHERE name = 'rare'"
);
}
}