use std::collections::{BTreeSet, HashMap};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use anyhow::{Result, anyhow, bail};
use kanade_shared::manifest::{ExplodeColumn, ExplodeSpec, Manifest};
use serde_json::Value as JsonValue;
use sqlx::{AssertSqlSafe, Row, Sqlite, SqliteConnection, SqlitePool, Transaction};
use tracing::{info, warn};
fn ensured_tables() -> &'static Mutex<HashMap<String, String>> {
static CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn validate_ident(ident: &str) -> Result<()> {
if ident.is_empty() || ident.len() > 64 {
bail!("identifier {ident:?} must be 1..=64 chars");
}
let mut chars = ident.chars();
let first = chars.next().unwrap();
if !first.is_ascii_alphabetic() && first != '_' {
bail!("identifier {ident:?} must start with a letter or underscore");
}
for c in chars {
if !(c.is_ascii_alphanumeric() || c == '_') {
bail!("identifier {ident:?} contains invalid character {c:?}");
}
}
Ok(())
}
fn validate_kind(kind: Option<&str>) -> Result<()> {
match kind {
None | Some("text") | Some("integer") | Some("real") => Ok(()),
Some(other) => {
bail!("unsupported explode column kind {other:?}; expected text|integer|real")
}
}
}
fn sql_affinity(kind: Option<&str>) -> &'static str {
match kind {
Some("integer") => "INTEGER",
Some("real") => "REAL",
_ => "TEXT",
}
}
pub fn create_table_sql(spec: &ExplodeSpec) -> Result<String> {
validate_ident(&spec.table)?;
if spec.primary_key.is_empty() {
bail!(
"explode spec for table {:?} needs at least one primary_key column",
spec.table,
);
}
let column_names: BTreeSet<&str> = spec.columns.iter().map(|c| c.field.as_str()).collect();
for pk in &spec.primary_key {
validate_ident(pk)?;
if !column_names.contains(pk.as_str()) {
bail!(
"primary_key entry {pk:?} for table {:?} is not in columns",
spec.table,
);
}
}
let mut sql = format!("CREATE TABLE IF NOT EXISTS \"{}\" (\n", spec.table);
sql.push_str(" pc_id TEXT NOT NULL,\n");
sql.push_str(" job_id TEXT NOT NULL,\n");
sql.push_str(" collected_at TIMESTAMP,\n");
for col in &spec.columns {
validate_ident(&col.field)?;
validate_kind(col.kind.as_deref())?;
sql.push_str(&format!(
" \"{}\" {},\n",
col.field,
sql_affinity(col.kind.as_deref())
));
}
sql.push_str(" PRIMARY KEY (pc_id, job_id");
for pk in &spec.primary_key {
sql.push_str(", \"");
sql.push_str(pk);
sql.push('"');
}
sql.push_str(")\n);");
Ok(sql)
}
pub fn create_index_sqls(spec: &ExplodeSpec) -> Result<Vec<String>> {
let mut out = Vec::new();
for col in &spec.columns {
if !col.index {
continue;
}
validate_ident(&col.field)?;
out.push(format!(
"CREATE INDEX IF NOT EXISTS \"idx_{table}_{col}\" ON \"{table}\"(\"{col}\");",
table = spec.table,
col = col.field,
));
}
Ok(out)
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SchemaChange {
pub added_columns: Vec<String>,
pub rebuilt: bool,
pub rows_copied: i64,
pub rows_lost: i64,
}
impl SchemaChange {
pub fn is_notable(&self) -> bool {
self.rebuilt || !self.added_columns.is_empty()
}
}
struct ExistingColumn {
name: String,
decl_type: String,
pk_seq: i64,
}
async fn existing_columns(
conn: &mut SqliteConnection,
table: &str,
) -> Result<Option<Vec<ExistingColumn>>> {
let exists: Option<(String,)> =
sqlx::query_as("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.bind(table)
.fetch_optional(&mut *conn)
.await
.map_err(|e| anyhow!("check existence of table {table}: {e}"))?;
if exists.is_none() {
return Ok(None);
}
let rows = sqlx::query(AssertSqlSafe(format!("PRAGMA table_info(\"{table}\")")))
.fetch_all(&mut *conn)
.await
.map_err(|e| anyhow!("read table_info for {table}: {e}"))?;
let cols = rows
.into_iter()
.map(|r| ExistingColumn {
name: r.get::<String, _>("name"),
decl_type: r.get::<String, _>("type").to_ascii_uppercase(),
pk_seq: r.get::<i64, _>("pk"),
})
.collect();
Ok(Some(cols))
}
fn expected_columns(spec: &ExplodeSpec) -> Vec<(String, &'static str)> {
let mut out = vec![
("pc_id".to_string(), "TEXT"),
("job_id".to_string(), "TEXT"),
("collected_at".to_string(), "TIMESTAMP"),
];
for col in &spec.columns {
out.push((col.field.clone(), sql_affinity(col.kind.as_deref())));
}
out
}
async fn reconcile_existing_table(
conn: &mut SqliteConnection,
spec: &ExplodeSpec,
existing: &[ExistingColumn],
) -> Result<SchemaChange> {
let expected = expected_columns(spec);
let expected_names: BTreeSet<&str> = expected.iter().map(|(n, _)| n.as_str()).collect();
let existing_by_name: HashMap<&str, &ExistingColumn> =
existing.iter().map(|c| (c.name.as_str(), c)).collect();
let missing: Vec<(String, &'static str)> = expected
.iter()
.filter(|(name, _)| !existing_by_name.contains_key(name.as_str()))
.cloned()
.collect();
let has_extra_columns = existing
.iter()
.any(|c| !expected_names.contains(c.name.as_str()));
let has_type_drift = expected.iter().any(|(name, affinity)| {
existing_by_name
.get(name.as_str())
.is_some_and(|c| c.decl_type != *affinity)
});
let expected_pk: BTreeSet<&str> = std::iter::empty()
.chain(["pc_id", "job_id"])
.chain(spec.primary_key.iter().map(String::as_str))
.collect();
let existing_pk: BTreeSet<&str> = existing
.iter()
.filter(|c| c.pk_seq > 0)
.map(|c| c.name.as_str())
.collect();
let pk_changed = expected_pk != existing_pk;
if pk_changed || has_extra_columns || has_type_drift {
return rebuild_table(conn, spec, existing).await;
}
for (name, affinity) in &missing {
let sql = format!(
"ALTER TABLE \"{}\" ADD COLUMN \"{name}\" {affinity}",
spec.table
);
sqlx::query(AssertSqlSafe(sql))
.execute(&mut *conn)
.await
.map_err(|e| anyhow!("add column {name} to {}: {e}", spec.table))?;
}
Ok(SchemaChange {
added_columns: missing.into_iter().map(|(n, _)| n).collect(),
..Default::default()
})
}
fn migration_temp_table_name() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
format!("_explode_migrate_{nanos:x}_{seq:x}")
}
async fn rebuild_table(
conn: &mut SqliteConnection,
spec: &ExplodeSpec,
existing: &[ExistingColumn],
) -> Result<SchemaChange> {
let expected = expected_columns(spec);
let existing_names: BTreeSet<&str> = existing.iter().map(|c| c.name.as_str()).collect();
let missing: Vec<String> = expected
.iter()
.filter(|(n, _)| !existing_names.contains(n.as_str()))
.map(|(n, _)| n.clone())
.collect();
let common_columns: Vec<&str> = expected
.iter()
.map(|(n, _)| n.as_str())
.filter(|n| existing_names.contains(n))
.collect();
let tmp_table = migration_temp_table_name();
validate_ident(&tmp_table)?;
if existing_columns(&mut *conn, &tmp_table).await?.is_some() {
bail!(
"migration temp table {tmp_table} unexpectedly already exists; \
aborting this migration rather than touching it"
);
}
let mut tmp_spec = spec.clone();
tmp_spec.table = tmp_table.clone();
let create_tmp_sql = create_table_sql(&tmp_spec)?;
sqlx::query(AssertSqlSafe(create_tmp_sql))
.execute(&mut *conn)
.await
.map_err(|e| anyhow!("create migration temp table {tmp_table}: {e}"))?;
let before: (i64,) = sqlx::query_as(AssertSqlSafe(format!(
"SELECT COUNT(*) FROM \"{}\"",
spec.table
)))
.fetch_one(&mut *conn)
.await
.map_err(|e| anyhow!("count rows in {}: {e}", spec.table))?;
let rows_copied = if common_columns.is_empty() {
0
} else {
let col_list = common_columns
.iter()
.map(|c| format!("\"{c}\""))
.collect::<Vec<_>>()
.join(", ");
let copy_sql = format!(
"INSERT OR IGNORE INTO \"{tmp_table}\" ({col_list}) SELECT {col_list} FROM \"{}\"",
spec.table,
);
sqlx::query(AssertSqlSafe(copy_sql))
.execute(&mut *conn)
.await
.map_err(|e| anyhow!("copy rows into migration temp table {tmp_table}: {e}"))?
.rows_affected() as i64
};
sqlx::query(AssertSqlSafe(format!("DROP TABLE \"{}\"", spec.table)))
.execute(&mut *conn)
.await
.map_err(|e| anyhow!("drop old table {}: {e}", spec.table))?;
sqlx::query(AssertSqlSafe(format!(
"ALTER TABLE \"{tmp_table}\" RENAME TO \"{}\"",
spec.table
)))
.execute(&mut *conn)
.await
.map_err(|e| anyhow!("rename migration temp table into {}: {e}", spec.table))?;
let rows_lost = (before.0 - rows_copied).max(0);
if rows_lost > 0 {
warn!(
table = %spec.table,
rows_lost,
"explode: schema rebuild dropped rows that collided under the new primary key; \
they will reappear after their PC's next exec",
);
}
Ok(SchemaChange {
added_columns: missing,
rebuilt: true,
rows_copied,
rows_lost,
})
}
async fn reconcile_table(conn: &mut SqliteConnection, spec: &ExplodeSpec) -> Result<SchemaChange> {
let table_sql = create_table_sql(spec)?;
let change = match existing_columns(&mut *conn, &spec.table).await? {
None => {
sqlx::query(AssertSqlSafe(table_sql))
.execute(&mut *conn)
.await
.map_err(|e| anyhow!("create table {}: {e}", spec.table))?;
SchemaChange::default()
}
Some(cols) => reconcile_existing_table(conn, spec, &cols).await?,
};
for index_sql in create_index_sqls(spec)? {
sqlx::query(AssertSqlSafe(index_sql))
.execute(&mut *conn)
.await
.map_err(|e| anyhow!("create index for {}: {e}", spec.table))?;
}
Ok(change)
}
pub async fn ensure_table(pool: &SqlitePool, spec: &ExplodeSpec) -> Result<SchemaChange> {
let mut tx: Transaction<'_, Sqlite> = pool.begin().await?;
let change = reconcile_table(&mut tx, spec).await?;
tx.commit().await?;
Ok(change)
}
pub async fn ensure_tables_atomic(
pool: &SqlitePool,
specs: &[ExplodeSpec],
) -> Result<Vec<SchemaChange>> {
let mut tx: Transaction<'_, Sqlite> = pool.begin().await?;
let mut changes = Vec::with_capacity(specs.len());
for spec in specs {
changes.push(reconcile_table(&mut tx, spec).await?);
}
tx.commit().await?;
Ok(changes)
}
pub async fn ensure_table_cached(pool: &SqlitePool, spec: &ExplodeSpec) -> Result<SchemaChange> {
let expected_sql = create_table_sql(spec)?;
{
let cache = ensured_tables().lock().expect("ensured_tables mutex");
if cache.get(&spec.table) == Some(&expected_sql) {
return Ok(SchemaChange::default());
}
}
let change = ensure_table(pool, spec).await?;
let mut cache = ensured_tables().lock().expect("ensured_tables mutex");
cache.insert(spec.table.clone(), expected_sql);
Ok(change)
}
pub async fn ensure_tables_for_jobs(
pool: &SqlitePool,
manifests: impl IntoIterator<Item = Manifest>,
) -> Result<()> {
for manifest in manifests {
let Some(inv) = manifest.inventory.as_ref() else {
continue;
};
let Some(specs) = inv.explode.as_ref() else {
continue;
};
for spec in specs {
match ensure_table_cached(pool, spec).await {
Ok(change) if change.is_notable() => info!(
job_id = %manifest.id,
table = %spec.table,
rebuilt = change.rebuilt,
added_columns = ?change.added_columns,
rows_lost = change.rows_lost,
"explode: derived table schema migrated at startup",
),
Ok(_) => info!(
job_id = %manifest.id,
table = %spec.table,
"explode: derived table ready",
),
Err(e) => warn!(
error = %e,
job_id = %manifest.id,
table = %spec.table,
"explode: derived table creation failed (skipped)",
),
}
}
}
Ok(())
}
pub async fn replace_rows(
pool: &SqlitePool,
spec: &ExplodeSpec,
pc_id: &str,
job_id: &str,
collected_at: Option<chrono::DateTime<chrono::Utc>>,
payload: &JsonValue,
) -> Result<usize> {
validate_ident(&spec.table)?;
for col in &spec.columns {
validate_ident(&col.field)?;
}
let arr: &[JsonValue] = payload
.get(&spec.field)
.and_then(|v| v.as_array())
.map(Vec::as_slice)
.unwrap_or(&[]);
if arr.is_empty() {
let existing: (i64,) = sqlx::query_as(AssertSqlSafe(format!(
"SELECT COUNT(*) FROM \"{}\" WHERE pc_id = ? AND job_id = ?",
spec.table
)))
.bind(pc_id)
.bind(job_id)
.fetch_one(pool)
.await
.map_err(|e| anyhow!("count prior rows in {}: {e}", spec.table))?;
if existing.0 == 0 {
return Ok(0);
}
}
let mut tx: Transaction<'_, Sqlite> = pool.begin().await?;
if spec.track_history {
let events = super::history::diff_explode_rows(&mut tx, spec, pc_id, job_id, arr).await?;
if !events.is_empty() {
super::history::write_events(&mut tx, pc_id, job_id, &events).await?;
}
}
sqlx::query(AssertSqlSafe(format!(
"DELETE FROM \"{}\" WHERE pc_id = ? AND job_id = ?",
spec.table
)))
.bind(pc_id)
.bind(job_id)
.execute(&mut *tx)
.await
.map_err(|e| anyhow!("delete prior rows in {}: {e}", spec.table))?;
let quoted_columns: Vec<String> = spec
.columns
.iter()
.map(|c| format!("\"{}\"", c.field))
.collect();
let placeholders = std::iter::repeat_n("?", spec.columns.len() + 3)
.collect::<Vec<_>>()
.join(", ");
let insert_sql = format!(
"INSERT INTO \"{}\" (pc_id, job_id, collected_at, {}) VALUES ({})",
spec.table,
quoted_columns.join(", "),
placeholders,
);
let mut inserted = 0;
for element in arr {
let mut q = sqlx::query(AssertSqlSafe(insert_sql.as_str()))
.bind(pc_id)
.bind(job_id)
.bind(collected_at);
for col in &spec.columns {
q = bind_column(q, col, element);
}
match q.execute(&mut *tx).await {
Ok(_) => inserted += 1,
Err(e) => warn!(
error = %e,
table = %spec.table,
pc_id,
job_id,
"explode: skip row (insert failed; likely PK collision within payload)",
),
}
}
tx.commit().await?;
Ok(inserted)
}
fn bind_column<'q>(
q: sqlx::query::Query<'q, Sqlite, sqlx::sqlite::SqliteArguments>,
col: &ExplodeColumn,
element: &'q JsonValue,
) -> sqlx::query::Query<'q, Sqlite, sqlx::sqlite::SqliteArguments> {
let v = element.get(&col.field);
match (col.kind.as_deref(), v) {
(_, None) | (_, Some(JsonValue::Null)) => q.bind(Option::<String>::None),
(Some("integer"), Some(JsonValue::Number(n))) => q.bind(n.as_i64()),
(Some("real"), Some(JsonValue::Number(n))) => q.bind(n.as_f64()),
(_, Some(JsonValue::String(s))) => q.bind(Some(s.clone())),
(_, Some(JsonValue::Number(n))) => q.bind(Some(n.to_string())),
(_, Some(JsonValue::Bool(b))) => q.bind(Some(b.to_string())),
(_, Some(other)) => q.bind(Some(other.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_ident_accepts_normal_names() {
for ok in ["apps", "inventory_sw_apps", "_underscore", "abc123"] {
assert!(validate_ident(ok).is_ok(), "{ok} should pass");
}
}
#[test]
fn validate_ident_rejects_attacks() {
for bad in [
"",
"123leading",
"with space",
"drop;",
"with-dash",
"apps]",
"ねこ",
] {
assert!(validate_ident(bad).is_err(), "{bad:?} should be rejected");
}
}
fn sample_apps_spec() -> ExplodeSpec {
ExplodeSpec {
field: "apps".into(),
table: "inventory_sw_apps".into(),
primary_key: vec!["name".into(), "source".into()],
track_history: false,
columns: vec![
ExplodeColumn {
field: "source".into(),
kind: Some("text".into()),
index: false,
},
ExplodeColumn {
field: "name".into(),
kind: None,
index: true,
},
ExplodeColumn {
field: "version".into(),
kind: None,
index: false,
},
ExplodeColumn {
field: "publisher".into(),
kind: None,
index: false,
},
],
}
}
#[test]
fn create_table_sql_shape() {
let sql = create_table_sql(&sample_apps_spec()).unwrap();
assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"inventory_sw_apps\""));
assert!(sql.contains("pc_id TEXT NOT NULL"));
assert!(sql.contains("\"name\" TEXT"));
assert!(sql.contains("PRIMARY KEY (pc_id, job_id, \"name\", \"source\")"));
}
#[test]
fn create_table_sql_rejects_unknown_primary_key() {
let mut bad = sample_apps_spec();
bad.primary_key = vec!["nonexistent".into()];
let err = create_table_sql(&bad).unwrap_err().to_string();
assert!(err.contains("nonexistent"), "{err}");
}
#[test]
fn create_index_sqls_only_for_marked_columns() {
let sqls = create_index_sqls(&sample_apps_spec()).unwrap();
assert_eq!(sqls.len(), 1);
assert!(sqls[0].contains("\"idx_inventory_sw_apps_name\""));
assert!(sqls[0].contains("ON \"inventory_sw_apps\"(\"name\")"));
}
#[tokio::test]
async fn ensure_table_and_replace_rows_roundtrip() {
use sqlx::sqlite::SqlitePoolOptions;
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
let spec = sample_apps_spec();
ensure_table(&pool, &spec).await.unwrap();
let payload = serde_json::json!({
"apps": [
{"source": "wow6432", "name": "Chrome", "version": "120.0.6099.71", "publisher": "Google"},
{"source": "x64", "name": "Chrome", "version": "120.0.6099.71", "publisher": "Google"},
{"source": "x64", "name": "Firefox", "version": "122.0", "publisher": "Mozilla"},
]
});
let n = replace_rows(&pool, &spec, "pc-01", "inventory-sw", None, &payload)
.await
.unwrap();
assert_eq!(n, 3);
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM inventory_sw_apps")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 3);
let payload2 = serde_json::json!({
"apps": [
{"source": "x64", "name": "Edge", "version": "121.0", "publisher": "Microsoft"},
]
});
let n = replace_rows(&pool, &spec, "pc-01", "inventory-sw", None, &payload2)
.await
.unwrap();
assert_eq!(n, 1);
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM inventory_sw_apps")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 1, "old rows replaced, not appended");
let pc2_payload = serde_json::json!({
"apps": [
{"source": "x64", "name": "Chrome", "version": "99.0.4844.51", "publisher": "Google"},
]
});
replace_rows(&pool, &spec, "pc-02", "inventory-sw", None, &pc2_payload)
.await
.unwrap();
let chrome_pcs: Vec<(String, String)> = sqlx::query_as(
"SELECT pc_id, version FROM inventory_sw_apps WHERE name = ? ORDER BY pc_id",
)
.bind("Chrome")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(chrome_pcs.len(), 1, "pc-01 no longer has Chrome");
assert_eq!(chrome_pcs[0].0, "pc-02");
assert_eq!(chrome_pcs[0].1, "99.0.4844.51");
}
#[tokio::test]
async fn replace_rows_with_missing_field_clears_pc_state() {
use sqlx::sqlite::SqlitePoolOptions;
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
let spec = sample_apps_spec();
ensure_table(&pool, &spec).await.unwrap();
let prior = serde_json::json!({
"apps": [{"source": "x64", "name": "Chrome", "version": "120", "publisher": "Google"}]
});
replace_rows(&pool, &spec, "pc-01", "inventory-sw", None, &prior)
.await
.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM inventory_sw_apps")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 1);
let no_apps_field = serde_json::json!({ "hostname": "pc-01" });
let n = replace_rows(&pool, &spec, "pc-01", "inventory-sw", None, &no_apps_field)
.await
.unwrap();
assert_eq!(n, 0);
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM inventory_sw_apps")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 0, "stale rows cleared even when field absent");
}
#[tokio::test]
async fn missing_field_records_removed_not_phantom_added() {
use sqlx::sqlite::SqlitePoolOptions;
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
let mut spec = sample_apps_spec();
spec.track_history = true;
ensure_table(&pool, &spec).await.unwrap();
let payload = serde_json::json!({
"apps": [
{"source": "x64", "name": "Chrome", "version": "120", "publisher": "Google"},
{"source": "x64", "name": "Firefox", "version": "122", "publisher": "Mozilla"},
]
});
replace_rows(&pool, &spec, "pc-01", "inventory-sw", None, &payload)
.await
.unwrap();
let no_field = serde_json::json!({ "hostname": "pc-01" });
let n = replace_rows(&pool, &spec, "pc-01", "inventory-sw", None, &no_field)
.await
.unwrap();
assert_eq!(n, 0);
let removed: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM inventory_history WHERE change_kind = 'removed'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
removed.0, 2,
"disappearance must be recorded as `removed`, not a silent wipe (#929)"
);
replace_rows(&pool, &spec, "pc-01", "inventory-sw", None, &payload)
.await
.unwrap();
let added: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM inventory_history WHERE change_kind = 'added'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(added.0, 4);
}
#[tokio::test]
async fn missing_field_on_fresh_pc_is_a_noop() {
use sqlx::sqlite::SqlitePoolOptions;
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
let mut spec = sample_apps_spec();
spec.track_history = true;
ensure_table(&pool, &spec).await.unwrap();
let no_field = serde_json::json!({ "hostname": "pc-01" });
let n = replace_rows(&pool, &spec, "pc-01", "inventory-sw", None, &no_field)
.await
.unwrap();
assert_eq!(n, 0);
let events: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM inventory_history")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(events.0, 0, "no rows existed, so nothing to remove");
}
fn items_spec_v1() -> ExplodeSpec {
ExplodeSpec {
field: "items".into(),
table: "example_items".into(),
primary_key: vec!["item_id".into()],
track_history: false,
columns: vec![
ExplodeColumn {
field: "item_id".into(),
kind: Some("text".into()),
index: false,
},
ExplodeColumn {
field: "name".into(),
kind: Some("text".into()),
index: false,
},
],
}
}
fn items_spec_v2() -> ExplodeSpec {
ExplodeSpec {
field: "items".into(),
table: "example_items".into(),
primary_key: vec!["name".into()],
track_history: false,
columns: vec![
ExplodeColumn {
field: "item_id".into(),
kind: Some("text".into()),
index: false,
},
ExplodeColumn {
field: "name".into(),
kind: Some("text".into()),
index: false,
},
ExplodeColumn {
field: "kind".into(),
kind: Some("text".into()),
index: false,
},
],
}
}
#[tokio::test]
async fn ensure_table_migrates_primary_key_and_new_column() {
use sqlx::sqlite::SqlitePoolOptions;
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
let v1 = items_spec_v1();
ensure_table(&pool, &v1).await.unwrap();
let payload_v1 = serde_json::json!({
"items": [{"item_id": "i-1", "name": "Widget"}]
});
let n = replace_rows(&pool, &v1, "pc-01", "job-items", None, &payload_v1)
.await
.unwrap();
assert_eq!(n, 1);
let v2 = items_spec_v2();
let change = ensure_table(&pool, &v2).await.unwrap();
assert!(change.rebuilt, "primary_key change must trigger a rebuild");
assert_eq!(
change.rows_copied, 1,
"the existing row survives the rebuild"
);
assert_eq!(change.rows_lost, 0);
let ddl: (String,) = sqlx::query_as(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'example_items'",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(
ddl.0.contains("PRIMARY KEY (pc_id, job_id, \"name\")"),
"on-disk schema must reflect the new primary_key: {}",
ddl.0
);
assert!(
ddl.0.contains("\"kind\" TEXT"),
"on-disk schema must carry the new column: {}",
ddl.0
);
let payload_v2 = serde_json::json!({
"items": [{"item_id": "i-1", "name": "Widget", "kind": "hardware"}]
});
let n = replace_rows(&pool, &v2, "pc-01", "job-items", None, &payload_v2)
.await
.unwrap();
assert_eq!(n, 1, "post-migration exec must actually insert a row");
let row: (String, String, String) =
sqlx::query_as("SELECT item_id, name, kind FROM example_items WHERE pc_id = 'pc-01'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
row,
(
"i-1".to_string(),
"Widget".to_string(),
"hardware".to_string()
)
);
}
#[tokio::test]
async fn ensure_table_adds_column_without_rebuild_when_pk_unchanged() {
use sqlx::sqlite::SqlitePoolOptions;
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
let v1 = items_spec_v1();
ensure_table(&pool, &v1).await.unwrap();
replace_rows(
&pool,
&v1,
"pc-01",
"job-items",
None,
&serde_json::json!({"items": [{"item_id": "i-1", "name": "Widget"}]}),
)
.await
.unwrap();
let mut v1_plus_kind = v1.clone();
v1_plus_kind.columns.push(ExplodeColumn {
field: "kind".into(),
kind: Some("text".into()),
index: false,
});
let change = ensure_table(&pool, &v1_plus_kind).await.unwrap();
assert!(
!change.rebuilt,
"same primary_key + additive column must not rebuild"
);
assert_eq!(change.added_columns, vec!["kind".to_string()]);
let row: (String, String, Option<String>) =
sqlx::query_as("SELECT item_id, name, kind FROM example_items WHERE pc_id = 'pc-01'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.0, "i-1");
assert_eq!(row.1, "Widget");
assert_eq!(row.2, None);
}
#[tokio::test]
async fn ensure_table_noop_when_spec_unchanged() {
use sqlx::sqlite::SqlitePoolOptions;
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
let spec = items_spec_v1();
ensure_table(&pool, &spec).await.unwrap();
let change = ensure_table(&pool, &spec).await.unwrap();
assert!(!change.rebuilt);
assert!(change.added_columns.is_empty());
}
#[tokio::test]
async fn ensure_table_cached_detects_spec_change_and_reconciles() {
use sqlx::sqlite::SqlitePoolOptions;
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
let v1 = items_spec_v1();
ensure_table_cached(&pool, &v1).await.unwrap();
let noop = ensure_table_cached(&pool, &v1).await.unwrap();
assert!(!noop.rebuilt && noop.added_columns.is_empty());
replace_rows(
&pool,
&v1,
"pc-01",
"job-items",
None,
&serde_json::json!({"items": [{"item_id": "i-1", "name": "Widget"}]}),
)
.await
.unwrap();
let v2 = items_spec_v2();
let change = ensure_table_cached(&pool, &v2).await.unwrap();
assert!(
change.rebuilt,
"spec-fingerprinted cache must detect the primary_key change and rebuild"
);
assert_eq!(change.rows_copied, 1);
}
#[tokio::test]
async fn rebuild_does_not_touch_a_table_named_like_the_old_temp_convention() {
use sqlx::sqlite::SqlitePoolOptions;
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
let decoy = ExplodeSpec {
field: "decoy".into(),
table: "example_items__migrate".into(),
primary_key: vec!["k".into()],
track_history: false,
columns: vec![ExplodeColumn {
field: "k".into(),
kind: Some("text".into()),
index: false,
}],
};
ensure_table(&pool, &decoy).await.unwrap();
replace_rows(
&pool,
&decoy,
"pc-99",
"job-decoy",
None,
&serde_json::json!({"decoy": [{"k": "precious"}]}),
)
.await
.unwrap();
let v1 = items_spec_v1();
ensure_table(&pool, &v1).await.unwrap();
let v2 = items_spec_v2();
ensure_table(&pool, &v2).await.unwrap();
let decoy_rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM example_items__migrate")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(decoy_rows.0, 1, "unrelated table's row must survive");
let k: (String,) = sqlx::query_as("SELECT k FROM example_items__migrate")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(k.0, "precious");
}
#[tokio::test]
async fn ensure_tables_atomic_rolls_back_every_spec_when_one_fails() {
use sqlx::sqlite::SqlitePoolOptions;
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
let a_v1 = items_spec_v1();
ensure_table(&pool, &a_v1).await.unwrap();
replace_rows(
&pool,
&a_v1,
"pc-01",
"job-items",
None,
&serde_json::json!({"items": [{"item_id": "i-1", "name": "Widget"}]}),
)
.await
.unwrap();
let a_v2 = items_spec_v2();
let mut b_bad = items_spec_v1();
b_bad.table = "example_other".into();
b_bad.primary_key = vec!["does_not_exist".into()];
let err = ensure_tables_atomic(&pool, &[a_v2, b_bad])
.await
.expect_err("second spec is invalid; the whole batch must fail");
assert!(err.to_string().contains("does_not_exist"));
let ddl: (String,) = sqlx::query_as(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'example_items'",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(
ddl.0.contains("PRIMARY KEY (pc_id, job_id, \"item_id\")"),
"spec A's rebuild must have rolled back when spec B failed: {}",
ddl.0
);
assert!(
!ddl.0.contains("\"kind\""),
"spec A must not carry v2's new column after rollback: {}",
ddl.0
);
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM example_items")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows.0, 1);
let b_exists: Option<(String,)> = sqlx::query_as(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'example_other'",
)
.fetch_optional(&pool)
.await
.unwrap();
assert!(b_exists.is_none(), "spec B's table must not exist");
}
}