#![cfg(feature = "diesel")]
use diesel::debug_query;
use diesel::mysql::Mysql;
use diesel::pg::Pg;
use sqlite_diff_rs::{
ChangeDelete, ChangeSet, ChangeUpdate, DiffOps, Insert, PatchSet, PatchUpdate, SimpleTable,
};
fn render_pg<S, B>(changeset: &ChangeSet<SimpleTable, S, B>) -> String
where
S: AsRef<str> + Clone + core::hash::Hash + Eq + core::fmt::Debug,
B: AsRef<[u8]> + Clone + core::hash::Hash + Eq + core::fmt::Debug,
{
let mut out = String::new();
let mut first = true;
for stmt in changeset.iter() {
if !first {
out.push_str("; ");
}
first = false;
out.push_str(&debug_query::<Pg, _>(&stmt).to_string());
}
out
}
fn render_mysql<S, B>(changeset: &ChangeSet<SimpleTable, S, B>) -> String
where
S: AsRef<str> + Clone + core::hash::Hash + Eq + core::fmt::Debug,
B: AsRef<[u8]> + Clone + core::hash::Hash + Eq + core::fmt::Debug,
{
let mut out = String::new();
let mut first = true;
for stmt in changeset.iter() {
if !first {
out.push_str("; ");
}
first = false;
out.push_str(&debug_query::<Mysql, _>(&stmt).to_string());
}
out
}
#[test]
fn insert_matches_patchset_shape() {
let table = SimpleTable::new("users", &["id", "name"], &[0]);
let changeset = ChangeSet::<SimpleTable, String, Vec<u8>>::new().insert(
Insert::from(table)
.set(0, 1_i64)
.unwrap()
.set(1, "Alice")
.unwrap(),
);
let sql = render_pg(&changeset);
assert!(
sql.starts_with(r#"INSERT INTO "users" ("id", "name") VALUES ($1, $2)"#),
"{sql}"
);
assert!(sql.contains("Alice"), "{sql}");
assert!(
!sql.contains("'Alice'"),
"value leaked into SQL text: {sql}"
);
}
#[test]
fn insert_mysql_backticks() {
let table = SimpleTable::new("users", &["id", "name"], &[0]);
let changeset = ChangeSet::<SimpleTable, String, Vec<u8>>::new().insert(
Insert::from(table)
.set(0, 1_i64)
.unwrap()
.set(1, "Bob")
.unwrap(),
);
let sql = render_mysql(&changeset);
assert!(
sql.starts_with("INSERT INTO `users` (`id`, `name`) VALUES (?, ?)"),
"{sql}"
);
}
#[test]
fn update_writes_only_changed_columns_and_keeps_pk_out_of_set_when_unchanged() {
let table = SimpleTable::new("users", &["id", "name", "email"], &[0]);
let changeset = ChangeSet::<SimpleTable, String, Vec<u8>>::new().update(
ChangeUpdate::<_, String, Vec<u8>>::from(table)
.set(0, 7_i64, 7_i64)
.unwrap()
.set(1, "old", "new")
.unwrap(),
);
let sql = render_pg(&changeset);
assert!(
sql.starts_with(r#"UPDATE "users" SET "name" = $1 WHERE "id" = $2"#),
"{sql}"
);
let set_clause = &sql[..sql.find(" WHERE ").expect("WHERE clause")];
assert!(
!set_clause.contains(r#""id" ="#),
"unchanged PK leaked into SET (would spuriously touch it): {sql}"
);
assert!(
!set_clause.contains(r#""email""#),
"untouched column in SET: {sql}"
);
assert!(sql.contains(r#"binds: ["new", 7]"#), "{sql}");
}
#[test]
fn update_changes_a_single_primary_key() {
let table = SimpleTable::new("users", &["id", "name"], &[0]);
let changeset = ChangeSet::<SimpleTable, String, Vec<u8>>::new().update(
ChangeUpdate::<_, String, Vec<u8>>::from(table)
.set(0, 1_i64, 2_i64)
.unwrap(),
);
let sql = render_pg(&changeset);
assert!(
sql.starts_with(r#"UPDATE "users" SET "id" = $1 WHERE "id" = $2"#),
"{sql}"
);
assert!(sql.contains("binds: [2, 1]"), "{sql}");
}
#[test]
fn update_changes_one_column_of_a_composite_primary_key() {
let table = SimpleTable::new("kv", &["tenant_id", "user_id", "value"], &[0, 1]);
let changeset = ChangeSet::<SimpleTable, String, Vec<u8>>::new().update(
ChangeUpdate::<_, String, Vec<u8>>::from(table)
.set(0, 5_i64, 5_i64)
.unwrap()
.set(1, 2_i64, 9_i64)
.unwrap(),
);
let sql = render_pg(&changeset);
assert!(
sql.starts_with(
r#"UPDATE "kv" SET "user_id" = $1 WHERE "tenant_id" = $2 AND "user_id" = $3"#
),
"{sql}"
);
let set_clause = &sql[..sql.find(" WHERE ").expect("WHERE clause")];
assert!(
!set_clause.contains(r#""tenant_id""#),
"unchanged PK column leaked into SET: {sql}"
);
assert!(sql.contains("binds: [9, 5, 2]"), "{sql}");
}
#[test]
fn update_changes_every_column_of_a_composite_primary_key() {
let table = SimpleTable::new("kv", &["tenant_id", "user_id", "value"], &[0, 1]);
let changeset = ChangeSet::<SimpleTable, String, Vec<u8>>::new().update(
ChangeUpdate::<_, String, Vec<u8>>::from(table)
.set(0, 5_i64, 50_i64)
.unwrap()
.set(1, 2_i64, 9_i64)
.unwrap()
.set(2, "a", "b")
.unwrap(),
);
let sql = render_pg(&changeset);
assert!(
sql.starts_with(
r#"UPDATE "kv" SET "tenant_id" = $1, "user_id" = $2, "value" = $3 WHERE "tenant_id" = $4 AND "user_id" = $5"#
),
"{sql}"
);
assert!(sql.contains(r#"binds: [50, 9, "b", 5, 2]"#), "{sql}");
}
#[test]
fn update_with_no_actual_change_fails_to_render() {
let table = SimpleTable::new("users", &["id", "name"], &[0]);
let changeset = ChangeSet::<SimpleTable, String, Vec<u8>>::new().update(
ChangeUpdate::<_, String, Vec<u8>>::from(table)
.set(0, 1_i64, 1_i64)
.unwrap()
.set(1, "x", "x")
.unwrap(),
);
let op = changeset.iter().next().expect("one op");
let rendered = std::panic::catch_unwind(|| debug_query::<Pg, _>(&op).to_string());
assert!(
rendered.is_err(),
"no-op update should not render: {rendered:?}"
);
}
#[test]
fn delete_matches_primary_key_only() {
let table = SimpleTable::new("users", &["id", "name"], &[0]);
let changeset = ChangeSet::<SimpleTable, String, Vec<u8>>::new().delete(
ChangeDelete::<_, String, Vec<u8>>::from(table)
.set(0, 9_i64)
.unwrap()
.set(1, "Bob")
.unwrap(),
);
let sql = render_pg(&changeset);
assert!(
sql.starts_with(r#"DELETE FROM "users" WHERE "id" = $1"#),
"{sql}"
);
assert!(
!sql.contains(r#""name""#),
"non-PK old value leaked into WHERE: {sql}"
);
assert!(sql.contains("binds: [9]"), "{sql}");
}
#[test]
fn delete_composite_pk_matches_all_key_columns_only() {
let table = SimpleTable::new("kv", &["tenant_id", "user_id", "value"], &[0, 1]);
let changeset = ChangeSet::<SimpleTable, String, Vec<u8>>::new().delete(
ChangeDelete::<_, String, Vec<u8>>::from(table)
.set(0, 5_i64)
.unwrap()
.set(1, 2_i64)
.unwrap()
.set(2, "v")
.unwrap(),
);
let sql = render_mysql(&changeset);
assert!(
sql.starts_with("DELETE FROM `kv` WHERE `tenant_id` = ? AND `user_id` = ?"),
"{sql}"
);
assert!(!sql.contains("`value`"), "non-PK leaked into WHERE: {sql}");
}
#[test]
fn patchset_cannot_change_a_primary_key_but_changeset_can() {
let table = SimpleTable::new("users", &["id", "name"], &[0]);
let patchset = PatchSet::<SimpleTable, String, Vec<u8>>::new().update(
PatchUpdate::<_, String, Vec<u8>>::from(table.clone())
.set(0, 2_i64)
.unwrap(),
);
let patch_op = patchset.iter().next().expect("one op");
let patch_render = std::panic::catch_unwind(|| debug_query::<Pg, _>(&patch_op).to_string());
assert!(
patch_render.is_err(),
"patchset must not render a PK change: {patch_render:?}"
);
let changeset = ChangeSet::<SimpleTable, String, Vec<u8>>::new().update(
ChangeUpdate::<_, String, Vec<u8>>::from(table)
.set(0, 1_i64, 2_i64)
.unwrap(),
);
let sql = render_pg(&changeset);
assert!(
sql.starts_with(r#"UPDATE "users" SET "id" = $1 WHERE "id" = $2"#),
"{sql}"
);
}
use diesel::backend::Backend;
use diesel::result::QueryResult;
use diesel::serialize::ToSql;
use diesel::sql_types::{BigInt, Binary, Double, HasSqlType, Text};
use sqlite_diff_rs::{Adapter, Binder, DefaultBinder, Value};
struct TestAdapter;
impl<DB, S, B> Adapter<DB, S, B> for TestAdapter
where
DB: Backend + HasSqlType<BigInt> + HasSqlType<Double> + HasSqlType<Text> + HasSqlType<Binary>,
i64: ToSql<BigInt, DB>,
f64: ToSql<Double, DB>,
str: ToSql<Text, DB>,
[u8]: ToSql<Binary, DB>,
S: AsRef<str> + Sync,
B: AsRef<[u8]> + Sync,
{
fn column_name(&self, table: &str, column_index: usize) -> &str {
match table {
"kv" => ["tenant_id", "user_id", "value"][column_index],
"orders" => ["region_id", "order_id", "note"][column_index],
other => panic!("test adapter has no column layout for table {other:?}"),
}
}
fn bind<'a>(
&self,
_table: &str,
_column_index: usize,
value: &'a Value<S, B>,
) -> QueryResult<Box<dyn Binder<DB> + Send + 'a>> {
Ok(Box::new(DefaultBinder::from(value)))
}
}
#[test]
fn adapter_composite_pk_change_binds_set_then_where() {
let table = SimpleTable::new("kv", &["tenant_id", "user_id", "value"], &[0, 1]);
let changeset = ChangeSet::<SimpleTable, String, Vec<u8>>::new().update(
ChangeUpdate::<_, String, Vec<u8>>::from(table)
.set(0, 5_i64, 5_i64)
.unwrap()
.set(1, 2_i64, 9_i64)
.unwrap()
.set(2, "a", "b")
.unwrap(),
);
let mut sql = String::new();
for op in changeset
.iter()
.map(|op| op.with_adapter::<Pg, _>(&TestAdapter))
{
sql.push_str(&debug_query::<Pg, _>(&op).to_string());
}
assert!(!sql.contains("CAST"), "found unexpected CAST: {sql}");
assert!(
sql.contains(
r#"UPDATE "kv" SET "user_id" = $1, "value" = $2 WHERE "tenant_id" = $3 AND "user_id" = $4"#
),
"{sql}"
);
assert!(sql.contains(r#"binds: [9, "b", 5, 2]"#), "{sql}");
}
#[test]
fn changeset_reversed_ordinal_pk_change_one_column() {
let table = SimpleTable::new("orders", &["region_id", "order_id", "note"], &[1, 0]);
let changeset = ChangeSet::<SimpleTable, String, Vec<u8>>::new().update(
ChangeUpdate::<_, String, Vec<u8>>::from(table)
.set(0, 5_i64, 5_i64)
.unwrap()
.set(1, 999_i64, 1000_i64)
.unwrap(),
);
let sql = render_pg(&changeset);
assert!(
sql.starts_with(
r#"UPDATE "orders" SET "order_id" = $1 WHERE "order_id" = $2 AND "region_id" = $3"#
),
"{sql}"
);
let set_clause = &sql[..sql.find(" WHERE ").expect("WHERE clause")];
assert!(
!set_clause.contains(r#""region_id""#),
"unchanged PK column leaked into SET: {sql}"
);
assert!(sql.contains("binds: [1000, 999, 5]"), "{sql}");
}
#[test]
fn changeset_reversed_ordinal_pk_change_both_columns() {
let table = SimpleTable::new("orders", &["region_id", "order_id", "note"], &[1, 0]);
let changeset = ChangeSet::<SimpleTable, String, Vec<u8>>::new().update(
ChangeUpdate::<_, String, Vec<u8>>::from(table)
.set(0, 5_i64, 50_i64)
.unwrap()
.set(1, 999_i64, 1000_i64)
.unwrap(),
);
let sql = render_pg(&changeset);
assert!(
sql.starts_with(
r#"UPDATE "orders" SET "region_id" = $1, "order_id" = $2 WHERE "order_id" = $3 AND "region_id" = $4"#
),
"{sql}"
);
assert!(sql.contains("binds: [50, 1000, 999, 5]"), "{sql}");
}
#[test]
fn adapter_reversed_ordinal_pk_change_binds_in_lockstep() {
let table = SimpleTable::new("orders", &["region_id", "order_id", "note"], &[1, 0]);
let changeset = ChangeSet::<SimpleTable, String, Vec<u8>>::new().update(
ChangeUpdate::<_, String, Vec<u8>>::from(table)
.set(0, 5_i64, 50_i64)
.unwrap()
.set(1, 999_i64, 1000_i64)
.unwrap(),
);
let mut sql = String::new();
for op in changeset
.iter()
.map(|op| op.with_adapter::<Pg, _>(&TestAdapter))
{
sql.push_str(&debug_query::<Pg, _>(&op).to_string());
}
assert!(!sql.contains("CAST"), "found unexpected CAST: {sql}");
assert!(
sql.contains(
r#"UPDATE "orders" SET "region_id" = $1, "order_id" = $2 WHERE "order_id" = $3 AND "region_id" = $4"#
),
"{sql}"
);
assert!(sql.contains("binds: [50, 1000, 999, 5]"), "{sql}");
}