use quarb_sql::translate;
use rusqlite::Connection;
fn t(sql: &str) -> String {
translate(sql).unwrap().query
}
#[test]
fn translations() {
assert_eq!(
t("SELECT title, price FROM tracks WHERE price < 1 ORDER BY title"),
"/tracks/*[::price < 1] @| sort_by(::title) | rec(::title, ::price)"
);
assert_eq!(t("SELECT COUNT(*) FROM tracks"), "/tracks/* @| count");
assert_eq!(
t("SELECT customer, SUM(qty) AS total FROM invoices GROUP BY customer HAVING total > 1"),
"/invoices/* | ::qty @| group(::customer) | sum | .total | [$_ > 1] | %."
);
assert_eq!(
t(
"SELECT al.title, t.title FROM albums al JOIN tracks t ON t.album_id = al.id \
WHERE t.secs > 400"
),
"/albums/* <=> /tracks/*[::album_id = $$::id and ::secs > 400] \
| rec(::title, \"t.title\", $*1::title)"
);
assert_eq!(
t("SELECT DISTINCT country FROM artists"),
"/artists/* | ::country @| unique"
);
assert_eq!(
t("SELECT title FROM tracks WHERE title LIKE '%o%' AND secs >= 200"),
"/tracks/*[::title =~ /(?i)o/ and ::secs >= 200] | rec(::title)"
);
assert_eq!(
t("SELECT name FROM artists WHERE country IS NOT NULL"),
"/artists/*[::country != null] | rec(::name)"
);
assert_eq!(
t("SELECT name FROM artists WHERE country IS NULL"),
"/artists/*[::country = null] | rec(::name)"
);
assert_eq!(
t("SELECT DISTINCT country FROM artists ORDER BY country LIMIT 3"),
"/artists/* @| sort_by(::country) | ::country @| unique @| [..3]"
);
assert_eq!(
t("SELECT t.title FROM tracks AS t WHERE t.secs > -10"),
"/tracks/*[::secs > -10] | rec(::title)"
);
assert_eq!(
t("SELECT customer, SUM(qty) AS total FROM invoices GROUP BY customer \
HAVING SUM(qty) > 1"),
"/invoices/* | ::qty @| group(::customer) | sum | .total | [$_ > 1] | %."
);
assert_eq!(
t("SELECT country AS c, COUNT(*) FROM artists GROUP BY country"),
"/artists/* @| group(\"c\", ::country) | count | .count | %."
);
assert_eq!(
t("SELECT a FROM t WHERE a = 'say \"hi\" for $5'"),
"/t/*[::a = \"say \\\"hi\\\" for \\$5\"] | rec(::a)"
);
}
#[test]
fn refusals() {
let err = |sql: &str| translate(sql).unwrap_err().to_string();
assert!(err("DELETE FROM tracks").contains("only SELECT"));
assert!(
err("SELECT a FROM t1 JOIN t2 ON t2.x = t1.x JOIN t3 ON t3.y = t2.y")
.contains("more than one JOIN")
);
assert!(err("SELECT title FROM tracks WHERE title LIKE 'a%b'").contains("LIKE pattern"));
assert!(err("SELECT a, COUNT(*) FROM t").contains("mixing aggregates"));
assert!(err("SELECT t.a FROM t LEFT JOIN u ON u.x = t.x").contains("outer JOIN"));
assert!(err("SELECT t.a FROM t RIGHT JOIN u ON u.x = t.x").contains("outer JOIN"));
assert!(err("SELECT t.a FROM t CROSS JOIN u ON u.x = t.x").contains("CROSS"));
assert!(err("SELECT name FROM t HAVING name > 5").contains("HAVING without GROUP BY"));
assert!(err("SELECT SUM(x) FROM t HAVING x > 5").contains("HAVING without GROUP BY"));
assert!(err("SELECT name, COUNT(*) FROM emp GROUP BY dept").contains("not the GROUP BY key"));
assert!(
err("SELECT k, SUM(x) FROM t GROUP BY k HAVING COUNT(*) > 1")
.contains("not in the select list")
);
assert!(err("SELECT k, SUM(x) FROM t GROUP BY k HAVING z > 1").contains("HAVING column"));
assert!(err("SELECT a FROM t WHERE SUM(x) > 5").contains("aggregate in WHERE"));
assert!(err("SELECT DISTINCT COUNT(*) FROM t").contains("DISTINCT"));
assert!(err("SELECT \"a FROM t").contains("unterminated"));
}
#[test]
fn differential_against_sqlite() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
r#"
CREATE TABLE artists (id INTEGER PRIMARY KEY, name TEXT, country TEXT);
CREATE TABLE tracks (id INTEGER PRIMARY KEY, title TEXT, secs INTEGER,
price REAL, artist_id INTEGER REFERENCES artists(id));
INSERT INTO artists VALUES
(1,'Holst','GB'), (2,'Bartok','HU'), (3,'Satie',NULL), (4,'Anon','');
INSERT INTO tracks VALUES
(1,'Mars',430,1.29,1), (2,'Venus',480,0.99,1), (3,'Jupiter',470,1.29,1),
(4,'Bourree',95,0.99,2), (5,'Gymnopedie',210,0.99,3), (6,'Sketch',60,0.5,NULL);
"#,
)
.unwrap();
let adapter = quarb_sqlite::SqliteAdapter::load(&conn).unwrap();
let cases = [
"SELECT title FROM tracks WHERE price < 1 ORDER BY title",
"SELECT name FROM artists WHERE country IS NOT NULL ORDER BY name",
"SELECT name FROM artists WHERE country IS NULL ORDER BY name",
"SELECT name FROM artists WHERE country = '' ORDER BY name",
"SELECT title FROM tracks WHERE title LIKE '%s%' ORDER BY title",
"SELECT DISTINCT price FROM tracks ORDER BY price",
"SELECT DISTINCT price FROM tracks ORDER BY price LIMIT 2",
"SELECT title FROM tracks WHERE secs > 100 AND price >= 1 ORDER BY title",
"SELECT title FROM tracks WHERE secs > -1 AND price < 1 ORDER BY title",
"SELECT name FROM artists WHERE country IN ('GB','HU') ORDER BY name",
"SELECT price, COUNT(*) AS n FROM tracks GROUP BY price ORDER BY price",
];
for sql in cases {
let mut stmt = conn.prepare(sql).unwrap();
let ncols = stmt.column_count();
let sql_rows: Vec<String> = stmt
.query_map([], |r| {
Ok((0..ncols)
.map(|i| {
r.get_ref(i)
.map(|v| match v {
rusqlite::types::ValueRef::Null => String::new(),
rusqlite::types::ValueRef::Integer(n) => n.to_string(),
rusqlite::types::ValueRef::Real(f) => f.to_string(),
rusqlite::types::ValueRef::Text(t) => {
String::from_utf8_lossy(t).into_owned()
}
_ => "<blob>".into(),
})
.unwrap()
})
.collect::<Vec<_>>()
.join("|"))
})
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
let quarb = translate(sql).unwrap().query;
let got = match quarb::run(&quarb, &adapter).unwrap() {
quarb::QueryResult::Values(vs) => vs,
quarb::QueryResult::Nodes(_) => panic!("expected values for {sql}"),
};
let quarb_rows: Vec<String> = got
.iter()
.map(|v| match v {
quarb::Value::Record(fields) => fields
.iter()
.map(|(_, v)| v.to_string())
.collect::<Vec<_>>()
.join("|"),
other => other.to_string(),
})
.collect();
assert_eq!(quarb_rows, sql_rows, "differential mismatch for: {sql}");
}
}
use quarb_sql::export;
fn x(quarb: &str) -> String {
export(quarb).unwrap().query
}
#[test]
fn export_translations() {
assert_eq!(
x("/tracks/*[::price < 1] | rec(::title, ::secs) @| sort_by(::title)"),
"SELECT title, secs FROM tracks WHERE price < 1 ORDER BY title"
);
assert_eq!(x("/tracks/* @| count"), "SELECT COUNT(*) FROM tracks");
assert_eq!(
x("/invoices/* | ::qty @| group(::customer) | sum | .total | [$_ > 1] | %."),
"SELECT customer, SUM(qty) AS total FROM invoices GROUP BY customer \
HAVING SUM(qty) > 1"
);
assert_eq!(
x(
"/albums/* <=> /tracks/*[::album_id = $$::id and ::secs > 400] \
| rec(\"album\", ::title, $*1::title)"
),
"SELECT albums.title AS album, tracks.title FROM albums \
JOIN tracks ON tracks.album_id = albums.id WHERE tracks.secs > 400"
);
assert_eq!(
x("/tracks/* | ::price @| unique"),
"SELECT DISTINCT price FROM tracks"
);
assert_eq!(
x("/tracks/* @| top(2, ::price) | rec(::title, ::price)"),
"SELECT title, price FROM tracks ORDER BY price DESC LIMIT 2"
);
}
#[test]
fn export_refusals() {
let err = |q: &str| export(q).unwrap_err().to_string();
assert!(err("/tracks/* | rec(::album_id~>::title)").contains("schema"));
assert!(err("/tracks/* | ::price @| window(3) | mean").contains("aggregate"));
assert!(err("/a/*[::x =~ /y/]").contains("regex"));
assert!(err("//tracks/*::title").contains("/table/*"));
assert!(err("/t/* @| sum").contains("project a column"));
assert!(err("/t/* @| [..5] @| sort_by(::c) | ::c").contains("before LIMIT"));
assert!(err("/t/* @| [..5] | ::c @| unique").contains("DISTINCT before LIMIT"));
assert!(err("/t/* @| sort_by(::a) @| sort_by(::b) | ::a").contains("second sort"));
assert!(err("/t/*[::a *= ::b] | ::a").contains("non-literal"));
assert!(err("/t/*[$*1::x = 5] | ::a").contains("drives"));
}
#[test]
fn export_details() {
assert_eq!(
x("/albums/* <=> /tracks/*[::album_id = $$::id] | rec($*1::title)"),
"SELECT tracks.title FROM albums JOIN tracks ON tracks.album_id = albums.id"
);
assert_eq!(
x("/t/* | ::c @| group(::k) | count | .n | %."),
"SELECT k, COUNT(*) AS n FROM t GROUP BY k"
);
assert_eq!(
x("/t/* @| sort_by(::c) @| [..5] | ::c"),
"SELECT c FROM t ORDER BY c LIMIT 5"
);
assert_eq!(
x("/t/*[::a *= \"50%\"] | ::a"),
"SELECT a FROM t WHERE a LIKE '%50\\%%' ESCAPE '\\'"
);
}
#[test]
fn identifiers_are_gated() {
let pushdown = |q: &str| quarb_sql::pushdown(q, None);
assert!(pushdown("/t/*[::\"a OR b\" = 5] | ::x").is_none());
assert!(pushdown("/\"t; DROP TABLE u\"/*[::a = 5] | ::x").is_none());
assert!(
pushdown("/albums/* <=> /tracks/*[::aid = $$::\"album-id\"] | rec(\"t\", $*1::title)")
.is_none()
);
let t = export("/t/*[::\"a OR b\" = 5] | ::x").unwrap();
assert_eq!(t.query, "SELECT x FROM t WHERE \"a OR b\" = 5");
assert!(t.notes.iter().any(|n| n.contains("double-quoted")));
}
#[test]
fn refuses_the_internal_agg_marker() {
let q = "/t/* | ::q @| group(::k) | sum | .s | [$_ = \"__AGG__\"] | %.";
assert!(export(q).unwrap_err().to_string().contains("__AGG__"));
assert!(quarb_sql::pushdown(q, None).is_none());
}
#[test]
fn export_differential_against_sqlite() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
r#"
CREATE TABLE tracks (id INTEGER PRIMARY KEY, title TEXT, secs INTEGER, price REAL);
INSERT INTO tracks VALUES
(1,'Mars',430,1.29), (2,'Venus',480,0.99), (3,'Jupiter',470,1.29),
(4,'Bourree',95,0.99), (5,'Gymnopedie',210,0.5);
"#,
)
.unwrap();
let adapter = quarb_sqlite::SqliteAdapter::load(&conn).unwrap();
let cases = [
"/tracks/*[::price < 1] | ::title @| sort_by(::title)",
"/tracks/* @| count",
"/tracks/*[::secs > 100 and ::price >= 1] | ::title @| sort_by(::title)",
"/tracks/* | ::price @| sum",
"/tracks/* @| top(2, ::secs) | rec(::title)",
];
for quarb in cases {
let sql = export(quarb).unwrap().query;
let mut stmt = conn.prepare(&sql).unwrap();
let ncols = stmt.column_count();
let sql_rows: Vec<String> = stmt
.query_map([], |r| {
Ok((0..ncols)
.map(|i| {
r.get_ref(i)
.map(|v| match v {
rusqlite::types::ValueRef::Null => String::new(),
rusqlite::types::ValueRef::Integer(n) => n.to_string(),
rusqlite::types::ValueRef::Real(f) => f.to_string(),
rusqlite::types::ValueRef::Text(t) => {
String::from_utf8_lossy(t).into_owned()
}
_ => "<blob>".into(),
})
.unwrap()
})
.collect::<Vec<_>>()
.join("|"))
})
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
let got = match quarb::run(quarb, &adapter).unwrap() {
quarb::QueryResult::Values(vs) => vs,
quarb::QueryResult::Nodes(_) => panic!("expected values for {quarb}"),
};
let quarb_rows: Vec<String> = got
.iter()
.map(|v| match v {
quarb::Value::Record(fields) => fields
.iter()
.map(|(_, v)| v.to_string())
.collect::<Vec<_>>()
.join("|"),
other => other.to_string(),
})
.collect();
assert_eq!(
quarb_rows, sql_rows,
"export differential mismatch for: {quarb}"
);
}
}
#[test]
fn pushdown_gate() {
let pushdown = |q: &str| quarb_sql::pushdown(q, None);
assert!(pushdown("/tracks/* @| count").is_some());
assert!(pushdown("/tracks/*[::price < 1] | rec(::title)").is_some());
assert!(
pushdown("/albums/* <=> /tracks/*[::album_id = $$::id] | rec(::title, \"track\", $*1::title)")
.is_some()
);
assert!(
pushdown("/tracks/* @| count")
.unwrap()
.order_table
.is_none()
);
assert_eq!(
pushdown("/tracks/*[::price < 1] | rec(::title)")
.unwrap()
.order_table
.as_deref(),
Some("tracks")
);
assert!(pushdown("/tracks/*[::title *= \"o\"] | ::title").is_none()); assert!(pushdown("/tracks/*[::album_id] | ::title").is_none()); assert!(pushdown("/t/* | ::p @| group(::k) | sum | .s | %.").is_none()); assert!(pushdown("/t/* | ::p @| unique").is_none()); assert!(pushdown("/t/* @| sort_by(::a) | ::a").is_none()); assert!(pushdown("/t/* @| [..3]").is_none()); assert!(pushdown("/t/* | ::p @| window(3) | mean").is_none()); }
#[test]
fn partial_pushdown_gate() {
use quarb_sql::partial_pushdown;
let p = partial_pushdown("/events/*[::kind = \"rare\"] | ::amount @| group(\"b\", ::amount idiv 100) | count | .n | %.").unwrap();
assert_eq!(p.table, "events");
assert_eq!(p.where_sql, "kind = 'rare'");
assert!(
partial_pushdown("/t/*[2][::a = 1] | ::a @| group(\"g\", ::a) | count | .n | %.").is_none()
);
assert!(partial_pushdown("/t/*[::a = 1] | .n(^/t/* @| count) | $.n").is_none());
assert!(partial_pushdown("/t/*[::a = 1]::b~>::c").is_none());
assert!(partial_pushdown("/t/*[::a = 1] | ;;;table").is_none());
assert!(
partial_pushdown("/t/*[::a *= \"x\"] | ::a @| group(\"g\", ::a) | count | .n | %.")
.is_none()
);
}
#[test]
fn pushdown_join_soundness_metadata() {
let pushdown = |q: &str| quarb_sql::pushdown(q, None);
let p = pushdown("/albums/* <=> /tracks/*[::album_id = $$::id] | rec(\"a\", ::title)")
.expect("canonical witness join pushes down");
let (table, cols) = p.join_left.expect("join carries its obligation");
assert_eq!(table, "tracks");
assert_eq!(cols, vec!["album_id".to_string()]);
assert!(
pushdown("/tracks/* @| count")
.expect("plain aggregate pushes down")
.join_left
.is_none()
);
}
#[test]
fn pushdown_refuses_keyword_aliases() {
let pushdown_explained = |q: &str| quarb_sql::pushdown_explained(q, None);
let Err(err) = pushdown_explained(
"/albums/* <=> /tracks/*[::album_id = $$::id] | rec(\"order\", $*1::title)",
) else {
panic!("keyword alias must refuse")
};
assert!(format!("{err}").contains("needs SQL quoting"), "{err}");
}
#[test]
fn refuses_the_internal_left_marker() {
use quarb_sql::export;
let pushdown = |q: &str| quarb_sql::pushdown(q, None);
let pushdown_explained = |q: &str| quarb_sql::pushdown_explained(q, None);
let q = "/albums/* <=> /tracks/*[::note = '__LEFT__.id' and ::album_id = $$::id] | rec($*1::title)";
assert!(pushdown(q).is_none());
let Err(err) = pushdown_explained(q) else {
panic!("marker in a literal must refuse")
};
assert!(format!("{err}").contains("__LEFT__"), "{err}");
assert!(export(q).is_err(), "export substitutes too; must refuse");
}
#[test]
fn join_projections_qualify_by_operand_index() {
let pushdown = |q: &str| quarb_sql::pushdown(q, None);
let pushdown_explained = |q: &str| quarb_sql::pushdown_explained(q, None);
let p = pushdown(
"/albums/* <=> /tracks/*[::album_id = $$::id] \
| rec(\"a\", ::title, \"t\", $*1::title)",
)
.expect("two-sided projection pushes down");
assert!(p.sql.contains("albums.title AS a"), "{}", p.sql);
assert!(p.sql.contains("tracks.title AS t"), "{}", p.sql);
assert!(
pushdown_explained(
"/albums/* <=> /tracks/*[::album_id = $$::id] | rec(\"x\", $*2::title)"
)
.is_err()
);
}