use keelson_core::Mod;
use keelson_sqlcheck::{Dialect, live, normalize};
use keelson_sqlite as sqlite;
use keelson_sqlite::{
Chain, Expr, Query, Value, arg, delete, insert, quote, select, update, window,
};
struct Piece<Q> {
name: &'static str,
apply: Box<dyn Fn(&mut Q)>,
frag: &'static str,
vals: &'static [i32],
requires: &'static [&'static str],
conflicts: &'static [&'static str],
}
impl<Q> Piece<Q> {
fn new(name: &'static str, frag: &'static str, apply: impl Fn(&mut Q) + 'static) -> Piece<Q> {
Piece {
name,
apply: Box::new(apply),
frag,
vals: &[],
requires: &[],
conflicts: &[],
}
}
fn vals(mut self, vals: &'static [i32]) -> Piece<Q> {
self.vals = vals;
self
}
fn requires(mut self, requires: &'static [&'static str]) -> Piece<Q> {
self.requires = requires;
self
}
fn conflicts(mut self, conflicts: &'static [&'static str]) -> Piece<Q> {
self.conflicts = conflicts;
self
}
}
fn none<Q>() -> Piece<Q> {
Piece::new("", "", |_| {})
}
fn number_placeholders(sql: &str) -> String {
let mut out = String::with_capacity(sql.len());
let mut n = 0;
for ch in sql.chars() {
if ch == '#' {
n += 1;
out.push_str(&format!("?{n}"));
} else {
out.push(ch);
}
}
out
}
fn assemble(frags: &[&str]) -> String {
let mut out = String::new();
for frag in frags {
if frag.is_empty() {
continue;
}
if !out.is_empty() && !frag.starts_with(',') {
out.push(' ');
}
out.push_str(frag);
}
out
}
fn run<Q: Query + Clone>(what: &str, new: impl Fn() -> Q, dims: &[Vec<Piece<Q>>]) -> usize {
let all_names: Vec<&str> = dims
.iter()
.flatten()
.map(|p| p.name)
.filter(|n| !n.is_empty())
.collect();
for p in dims.iter().flatten() {
for r in p.requires.iter().chain(p.conflicts) {
assert!(
all_names.contains(r),
"{what}: piece {:?} references unknown piece {r:?}",
p.name
);
}
}
let total: usize = dims.iter().map(Vec::len).product();
let mut cases = 0;
let mut failures: Vec<String> = Vec::new();
for mut ix in 0..total {
let selected: Vec<&Piece<Q>> = dims
.iter()
.map(|dim| {
let p = &dim[ix % dim.len()];
ix /= dim.len();
p
})
.collect();
let present: Vec<&str> = selected.iter().map(|p| p.name).collect();
let legal = selected.iter().all(|p| {
p.requires.iter().all(|r| present.contains(r))
&& !p.conflicts.iter().any(|c| present.contains(c))
});
if !legal {
continue;
}
cases += 1;
let combo = || {
present
.iter()
.filter(|n| !n.is_empty())
.copied()
.collect::<Vec<_>>()
.join("+")
};
let mut q = new();
for p in &selected {
(p.apply)(&mut q);
}
let frags: Vec<&str> = selected.iter().map(|p| p.frag).collect();
let expected = number_placeholders(&assemble(&frags));
let expected_args: Vec<Value> = selected
.iter()
.flat_map(|p| p.vals.iter().map(|&v| Value::I32(v)))
.collect();
let (sql, args) = match q.build() {
Ok(built) => built,
Err(e) => {
failures.push(format!("[{}] did not build: {e}", combo()));
continue;
}
};
if let Err(e) = keelson_sqlcheck::check(Dialect::Sqlite, &sql) {
failures.push(format!("[{}] grammar rejected: {e}\n sql: {sql}", combo()));
continue;
}
if let Err(e) = live::check_sqlite(&sql) {
failures.push(format!("[{}] engine rejected: {e}\n sql: {sql}", combo()));
continue;
}
if normalize(&sql) != normalize(&expected) {
failures.push(format!(
"[{}] not the statement meant\n expected: {}\n actual: {}",
combo(),
normalize(&expected),
normalize(&sql)
));
continue;
}
if args != expected_args {
failures.push(format!(
"[{}] arguments out of order\n expected: {expected_args:?}\n actual: {args:?}",
combo()
));
continue;
}
let again = q.build().expect("second build of an already-built query");
let cloned = q.clone().build().expect("build of a clone");
if again.0 != sql || again.1 != args || cloned.0 != sql || cloned.1 != args {
failures.push(format!("[{}] rendering is not deterministic", combo()));
}
}
if !failures.is_empty() {
let shown = failures.iter().take(15).cloned().collect::<Vec<_>>();
panic!(
"{what}: {} of {cases} combinations failed\n{}",
failures.len(),
shown.join("\n")
);
}
println!("{what}: {cases} combinations checked");
cases
}
fn toggle<Q>(piece: Piece<Q>) -> Vec<Piece<Q>> {
vec![none(), piece]
}
#[test]
fn select_every_clause_against_every_other() {
let with = toggle(
Piece::new(
"with",
r#"WITH "recent" AS (SELECT "id" FROM "posts" WHERE ("views" > #))"#,
|q| {
select::with(
"recent",
sqlite::select((
select::columns(quote("id")),
select::from(quote("posts")),
select::where_(quote("views").gt(arg(101i32))),
)),
)
.apply(q)
},
)
.vals(&[101]),
);
let head = vec![Piece::new("", "SELECT", |_| {})];
let distinct = toggle(Piece::new("distinct", "DISTINCT", |q| {
select::distinct().apply(q)
}));
let columns = vec![Piece::new("", r#""name""#, |q| {
select::columns(quote("name")).apply(q)
})];
let from = vec![Piece::new("", r#"FROM "users""#, |q| {
select::from(quote("users")).apply(q)
})];
let mut join = vec![
none(),
Piece::new(
"inner_join",
r#"INNER JOIN "posts" ON ("posts"."user_id" = "users"."id")"#,
|q| {
select::inner_join(quote("posts"))
.on_eq(quote(("posts", "user_id")), quote(("users", "id")))
.apply(q)
},
),
];
if cfg!(feature = "exhaustive") {
join.push(Piece::new(
"left_join",
r#"LEFT JOIN "posts" ON ("posts"."user_id" = "users"."id")"#,
|q| {
select::left_join(quote("posts"))
.on_eq(quote(("posts", "user_id")), quote(("users", "id")))
.apply(q)
},
));
join.push(Piece::new(
"cross_join",
r#"CROSS JOIN "posts" ON ("posts"."user_id" = "users"."id")"#,
|q| {
select::cross_join(quote("posts"))
.on_eq(quote(("posts", "user_id")), quote(("users", "id")))
.apply(q)
},
));
join.push(Piece::new(
"full_join",
r#"FULL JOIN "posts" USING ("id")"#,
|q| select::full_join(quote("posts")).using(["id"]).apply(q),
));
}
let where_ = toggle(
Piece::new("where", r#"WHERE ("age" >= #)"#, |q| {
select::where_(quote("age").gte(arg(21i32))).apply(q)
})
.vals(&[21]),
);
let group_by = toggle(Piece::new("group_by", r#"GROUP BY "name""#, |q| {
select::group_by(quote("name")).apply(q)
}));
let having = toggle(
Piece::new("having", r#"HAVING (count(*) > #)"#, |q| {
select::having(Expr::func("count", "*").gt(arg(3i32))).apply(q)
})
.vals(&[3])
.requires(&["group_by"]),
);
let window_ = toggle(Piece::new(
"window",
r#"WINDOW "w" AS (PARTITION BY "name")"#,
|q| select::window("w", window::partition_by(quote("name"))).apply(q),
));
fn second_core() -> sqlite::SelectQuery {
sqlite::select((
select::columns(quote("title")),
select::from(quote("posts")),
))
}
let mut compound = vec![
none(),
Piece::new("union", r#"UNION SELECT "title" FROM "posts""#, |q| {
select::union(second_core()).apply(q)
}),
];
if cfg!(feature = "exhaustive") {
compound.push(Piece::new(
"union_all",
r#"UNION ALL SELECT "title" FROM "posts""#,
|q| select::union_all(second_core()).apply(q),
));
compound.push(Piece::new(
"intersect",
r#"INTERSECT SELECT "title" FROM "posts""#,
|q| select::intersect(second_core()).apply(q),
));
compound.push(Piece::new(
"except",
r#"EXCEPT SELECT "title" FROM "posts""#,
|q| select::except(second_core()).apply(q),
));
}
let mut order_by = vec![
none(),
Piece::new("order_by", r#"ORDER BY "name" DESC"#, |q| {
select::order_by(quote("name")).desc().apply(q)
}),
];
if cfg!(feature = "exhaustive") {
order_by.push(Piece::new(
"order_by_decorated",
r#"ORDER BY "name" COLLATE "NOCASE" DESC NULLS LAST"#,
|q| {
select::order_by(quote("name"))
.collate("NOCASE")
.desc()
.nulls_last()
.apply(q)
},
));
}
let limit =
toggle(Piece::new("limit", "LIMIT #", |q| select::limit(arg(10i32)).apply(q)).vals(&[10]));
let offset = toggle(
Piece::new("offset", "OFFSET #", |q| select::offset(arg(5i32)).apply(q))
.vals(&[5])
.requires(&["limit"]),
);
let cases = run(
"sqlite SELECT",
|| sqlite::select(()),
&[
with, head, distinct, columns, from, join, where_, group_by, having, window_, compound,
order_by, limit, offset,
],
);
assert!(cases >= 1152, "the cross product shrank: {cases}");
}
#[test]
fn insert_every_clause_against_every_other() {
let with = toggle(
Piece::new(
"with",
r#"WITH "recent" AS (SELECT "id" FROM "posts" WHERE ("views" > #))"#,
|q| {
insert::with(
"recent",
sqlite::select((
select::columns(quote("id")),
select::from(quote("posts")),
select::where_(quote("views").gt(arg(101i32))),
)),
)
.apply(q)
},
)
.vals(&[101]),
);
let head = vec![
Piece::new("", "INSERT", |_| {}),
Piece::new("or_rollback", "INSERT OR ROLLBACK", |q| {
insert::or_rollback().apply(q)
}),
Piece::new("or_abort", "INSERT OR ABORT", |q| {
insert::or_abort().apply(q)
}),
Piece::new("or_replace", "INSERT OR REPLACE", |q| {
insert::or_replace().apply(q)
}),
Piece::new("or_fail", "INSERT OR FAIL", |q| insert::or_fail().apply(q)),
Piece::new("or_ignore", "INSERT OR IGNORE", |q| {
insert::or_ignore().apply(q)
}),
];
let source = vec![
Piece::new("values", r#"INTO "tags" ("name") VALUES (#)"#, |q| {
insert::into(quote("tags")).columns(["name"]).apply(q);
insert::values(arg(7i32)).apply(q);
})
.vals(&[7]),
Piece::new(
"values_rows",
r#"INTO "tags" ("name") VALUES (#), (#)"#,
|q| {
insert::into(quote("tags")).columns(["name"]).apply(q);
insert::values(arg(7i32)).apply(q);
insert::values(arg(8i32)).apply(q);
},
)
.vals(&[7, 8]),
Piece::new(
"select_source",
r#"INTO "tags" ("name") SELECT "name" FROM "users" WHERE ("is_active" = #)"#,
|q| {
insert::into(quote("tags")).columns(["name"]).apply(q);
insert::query(sqlite::select((
select::columns(quote("name")),
select::from(quote("users")),
select::where_(quote("is_active").eq(arg(1i32))),
)))
.apply(q);
},
)
.vals(&[1]),
Piece::new("default_values", r#"INTO "users" DEFAULT VALUES"#, |q| {
insert::into(quote("users")).apply(q)
}),
];
let upsert_conflicts: &[&str] = &["default_values"];
let upsert = vec![
none(),
Piece::new(
"do_nothing",
r#"ON CONFLICT ("name") DO NOTHING"#,
|q| {
insert::on_conflict(quote("name")).do_nothing().apply(q);
},
)
.conflicts(upsert_conflicts),
Piece::new("do_nothing_any", "ON CONFLICT DO NOTHING", |q| {
insert::on_conflict(()).do_nothing().apply(q);
})
.conflicts(upsert_conflicts),
Piece::new(
"do_update",
r#"ON CONFLICT ("name") DO UPDATE SET "name" = excluded."name""#,
|q| {
insert::on_conflict(quote("name"))
.do_update(insert::set_excluded(["name"]))
.apply(q);
},
)
.conflicts(upsert_conflicts),
Piece::new(
"do_update_where",
r#"ON CONFLICT ("name") DO UPDATE SET "name" = excluded."name" WHERE ("tags"."id" > #)"#,
|q| {
insert::on_conflict(quote("name"))
.do_update((
insert::set_excluded(["name"]),
insert::where_(quote(("tags", "id")).gt(arg(50i32))),
))
.apply(q);
},
)
.vals(&[50])
.conflicts(upsert_conflicts),
Piece::new(
"two_upserts",
r#"ON CONFLICT ("name") DO UPDATE SET "name" = excluded."name" ON CONFLICT DO NOTHING"#,
|q| {
insert::on_conflict(quote("name"))
.do_update(insert::set_excluded(["name"]))
.apply(q);
insert::on_conflict(()).do_nothing().apply(q);
},
)
.conflicts(upsert_conflicts),
];
let returning = toggle(Piece::new("returning", r#"RETURNING "id""#, |q| {
insert::returning(quote("id")).apply(q)
}));
let cases = run(
"sqlite INSERT",
|| sqlite::insert(()),
&[with, head, source, upsert, returning],
);
assert!(cases >= 300, "the cross product shrank: {cases}");
}
#[test]
fn update_every_clause_against_every_other() {
let with = toggle(
Piece::new(
"with",
r#"WITH "recent" AS (SELECT "id" FROM "posts" WHERE ("views" > #))"#,
|q| {
update::with(
"recent",
sqlite::select((
select::columns(quote("id")),
select::from(quote("posts")),
select::where_(quote("views").gt(arg(101i32))),
)),
)
.apply(q)
},
)
.vals(&[101]),
);
let head = vec![
Piece::new("", "UPDATE", |_| {}),
Piece::new("or_rollback", "UPDATE OR ROLLBACK", |q| {
update::or_rollback().apply(q)
}),
Piece::new("or_abort", "UPDATE OR ABORT", |q| {
update::or_abort().apply(q)
}),
Piece::new("or_replace", "UPDATE OR REPLACE", |q| {
update::or_replace().apply(q)
}),
Piece::new("or_fail", "UPDATE OR FAIL", |q| update::or_fail().apply(q)),
Piece::new("or_ignore", "UPDATE OR IGNORE", |q| {
update::or_ignore().apply(q)
}),
];
let target = vec![
Piece::new("target", r#""tags""#, |q| {
update::table(quote("tags")).apply(q)
}),
Piece::new("target_alias", r#""tags" AS "t""#, |q| {
update::table(quote("tags")).as_("t").apply(q)
}),
Piece::new(
"target_indexed",
r#""tags" INDEXED BY "sqlite_autoindex_tags_1""#,
|q| {
update::table(quote("tags"))
.indexed_by("sqlite_autoindex_tags_1")
.apply(q)
},
),
Piece::new("target_not_indexed", r#""tags" NOT INDEXED"#, |q| {
update::table(quote("tags")).not_indexed().apply(q)
}),
];
let set = vec![
Piece::new("", r#"SET "name" = #"#, |q| {
update::set_col("name").to_arg(7i32).apply(q)
})
.vals(&[7]),
];
let from = toggle(Piece::new("from", r#"FROM "posts" AS "p""#, |q| {
update::from(quote("posts")).as_("p").apply(q)
}));
let where_ = toggle(
Piece::new("where", r#"WHERE ("name" > #)"#, |q| {
update::where_(quote("name").gt(arg(21i32))).apply(q)
})
.vals(&[21]),
);
let returning = toggle(Piece::new("returning", r#"RETURNING "name""#, |q| {
update::returning(quote("name")).apply(q)
}));
let cases = run(
"sqlite UPDATE",
|| sqlite::update(()),
&[with, head, target, set, from, where_, returning],
);
assert!(cases >= 384, "the cross product shrank: {cases}");
}
#[test]
fn delete_every_clause_against_every_other() {
let with = toggle(
Piece::new(
"with",
r#"WITH "recent" AS (SELECT "id" FROM "posts" WHERE ("views" > #))"#,
|q| {
delete::with(
"recent",
sqlite::select((
select::columns(quote("id")),
select::from(quote("posts")),
select::where_(quote("views").gt(arg(101i32))),
)),
)
.apply(q)
},
)
.vals(&[101]),
);
let head = vec![Piece::new("", "DELETE", |_| {})];
let target = vec![
Piece::new("target", r#"FROM "tags""#, |q| {
delete::from(quote("tags")).apply(q)
}),
Piece::new("target_alias", r#"FROM "tags" AS "t""#, |q| {
delete::from(quote("tags")).as_("t").apply(q)
}),
Piece::new(
"target_indexed",
r#"FROM "tags" INDEXED BY "sqlite_autoindex_tags_1""#,
|q| {
delete::from(quote("tags"))
.indexed_by("sqlite_autoindex_tags_1")
.apply(q)
},
),
Piece::new("target_not_indexed", r#"FROM "tags" NOT INDEXED"#, |q| {
delete::from(quote("tags")).not_indexed().apply(q)
}),
];
let where_ = toggle(
Piece::new("where", r#"WHERE ("name" > #)"#, |q| {
delete::where_(quote("name").gt(arg(21i32))).apply(q)
})
.vals(&[21]),
);
let returning = toggle(Piece::new("returning", r#"RETURNING "name""#, |q| {
delete::returning(quote("name")).apply(q)
}));
let cases = run(
"sqlite DELETE",
|| sqlite::delete(()),
&[with, head, target, where_, returning],
);
assert!(cases >= 32, "the cross product shrank: {cases}");
}
#[test]
fn extra_from_items_without_a_leading_item_are_a_build_error() {
let q = sqlite::select((
select::columns(quote("id")),
select::from_also(quote("users")),
));
let err = q.build().unwrap_err();
assert!(
matches!(&err, sqlite::Error::Incomplete(what) if what.contains("FROM")),
"got: {err}"
);
let q = sqlite::update((
update::table(quote("posts")),
update::set_col("views").to(arg(1i32)),
update::from_also(quote("users")),
));
let err = q.build().unwrap_err();
assert!(
matches!(&err, sqlite::Error::Incomplete(what) if what.contains("FROM")),
"got: {err}"
);
let q = sqlite::select((
select::columns(quote("id")),
select::from_also(quote("users")).join(select::inner_join(quote("posts")).using(["id"])),
));
let err = q.build().unwrap_err();
assert!(
matches!(&err, sqlite::Error::Incomplete(what) if what.contains("FROM")),
"got: {err}"
);
}