diff --git a/backend-rs/src/db.rs b/backend-rs/src/db.rs index 6ce9808..7ad6033 100644 --- a/backend-rs/src/db.rs +++ b/backend-rs/src/db.rs @@ -36,5 +36,48 @@ pub async fn connect(database_url: &str) -> anyhow::Result { } } + ensure_schema(&conn).await?; + Ok(conn) } + +/// Idempotent, additive schema patches the Rust backend owns now that Django is +/// retired. SQLite has no `ADD COLUMN IF NOT EXISTS`, so we probe `PRAGMA +/// table_info` first and only ALTER when the column is missing. +async fn ensure_schema(conn: &DatabaseConnection) -> anyhow::Result<()> { + add_column_if_missing( + conn, + "api_flavor", + "aliases", + "ALTER TABLE api_flavor ADD COLUMN aliases TEXT", + ) + .await?; + Ok(()) +} + +async fn add_column_if_missing( + conn: &DatabaseConnection, + table: &str, + column: &str, + alter_sql: &str, +) -> anyhow::Result<()> { + let rows = conn + .query_all(Statement::from_string( + DatabaseBackend::Sqlite, + format!("PRAGMA table_info({table})"), + )) + .await?; + let exists = rows + .iter() + .any(|r| r.try_get::("", "name").as_deref() == Ok(column)); + if exists { + return Ok(()); + } + conn.execute(Statement::from_string( + DatabaseBackend::Sqlite, + alter_sql.to_owned(), + )) + .await?; + tracing::info!("schema: added {table}.{column}"); + Ok(()) +} diff --git a/backend-rs/src/entities/flavor.rs b/backend-rs/src/entities/flavor.rs index 640b874..1c42de1 100644 --- a/backend-rs/src/entities/flavor.rs +++ b/backend-rs/src/entities/flavor.rs @@ -22,6 +22,13 @@ pub struct Model { pub is_available: bool, pub is_legacy: bool, pub external_id: Option, + /// Alternate names this flavor is known by — populated when another flavor + /// (typically a legacy stand-in) is merged into this one. JSON list of + /// strings, stored as TEXT. Lets `seed_legacy` avoid re-creating a merged-away + /// legacy flavor and lets search match its old name. Added by the guarded + /// startup migration in `db.rs`; may be NULL on rows predating it. + #[sea_orm(column_type = "Json", nullable)] + pub aliases: Option, /// Django stores naive UTC text ("YYYY-MM-DD HH:MM:SS.ffffff"); we treat it /// as UTC when formatting to DRF's ISO-8601 `...Z` output. pub created_at: ChronoDateTime, diff --git a/backend-rs/src/jobs/seed_legacy.rs b/backend-rs/src/jobs/seed_legacy.rs index e859350..1e6d094 100644 --- a/backend-rs/src/jobs/seed_legacy.rs +++ b/backend-rs/src/jobs/seed_legacy.rs @@ -131,6 +131,18 @@ impl BackgroundJob for SeedLegacy { } } + // Don't recreate a legacy flavor that has since been merged into a + // (Shopify) flavor — the survivor carries this name as an alias. + if existing.is_none() { + if let Some(owner) = alias_owner(db, name, cat.id).await? { + log.push(format!( + "Skipping '{name}' (superseded by '{}')", + owner.name + )); + continue; + } + } + // Resolve image first (so we can set local paths on insert). let mut local_paths: Option> = None; if let Some(url) = image_url.filter(|s| !s.is_empty()) { @@ -208,6 +220,37 @@ fn pystr(v: Option<&Value>) -> String { } } +/// A flavor in `category_id` that lists `name` (case-insensitive) among its +/// merge aliases, if any. Used to suppress recreating a merged-away legacy row. +async fn alias_owner( + db: &DatabaseConnection, + name: &str, + category_id: i32, +) -> anyhow::Result> { + let nl = name.to_lowercase(); + let candidates = Flavor::find() + .filter(flavor::Column::CategoryId.eq(category_id)) + .filter(flavor::Column::Aliases.is_not_null()) + .all(db) + .await?; + Ok(candidates.into_iter().find(|f| { + json_str_list(&f.aliases) + .iter() + .any(|a| a.to_lowercase() == nl) + })) +} + +/// Django JSONField (list) stored as TEXT → Vec. +fn json_str_list(v: &Option) -> Vec { + match v { + Some(Value::Array(arr)) => arr + .iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect(), + _ => Vec::new(), + } +} + async fn goc_category( db: &DatabaseConnection, name: &str, diff --git a/backend-rs/src/merge.rs b/backend-rs/src/merge.rs index 3b09ea2..afac84c 100644 --- a/backend-rs/src/merge.rs +++ b/backend-rs/src/merge.rs @@ -87,14 +87,46 @@ pub async fn merge_flavors( None }; + // Record the removed flavor's name (and any aliases it already carried) onto + // the survivor, so `seed_legacy` won't recreate the merged-away row and search + // still resolves the old name. Deduped case-insensitively; never alias the + // kept flavor's own name. + let original = json_str_list(&keep.aliases); + let mut aliases = original.clone(); + let mut seen: std::collections::HashSet = + aliases.iter().map(|a| a.to_lowercase()).collect(); + seen.insert(keep.name.to_lowercase()); + for cand in std::iter::once(remove.name.clone()).chain(json_str_list(&remove.aliases)) { + if seen.insert(cand.to_lowercase()) { + aliases.push(cand); + } + } + let aliases_changed = aliases.len() != original.len(); + Flavor::delete_by_id(remove.id).exec(&txn).await?; - if let Some(eid) = inherited { + if inherited.is_some() || aliases_changed { let mut am: flavor::ActiveModel = keep.clone().into(); - am.external_id = Set(Some(eid)); + if let Some(eid) = inherited { + am.external_id = Set(Some(eid)); + } + if aliases_changed { + am.aliases = Set(Some(serde_json::json!(aliases))); + } am.update(&txn).await?; } txn.commit().await?; Ok(()) } + +/// Django JSONField (list) stored as TEXT → Vec, tolerating null/non-array. +fn json_str_list(v: &Option) -> Vec { + match v { + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect(), + _ => Vec::new(), + } +} diff --git a/backend-rs/src/routes/flavors.rs b/backend-rs/src/routes/flavors.rs index 39fdeb7..db86b4f 100644 --- a/backend-rs/src/routes/flavors.rs +++ b/backend-rs/src/routes/flavors.rs @@ -80,7 +80,8 @@ async fn list( let like = format!("%{w}%"); cond = cond .add(flavor::Column::Name.like(&like)) - .add(flavor::Column::Description.like(&like)); + .add(flavor::Column::Description.like(&like)) + .add(flavor::Column::Aliases.like(&like)); has = true; } if has { @@ -229,7 +230,8 @@ async fn search( let like = format!("%{w}%"); cond = cond .add(flavor::Column::Name.like(&like)) - .add(flavor::Column::Description.like(&like)); + .add(flavor::Column::Description.like(&like)) + .add(flavor::Column::Aliases.like(&like)); } q = q.filter(cond); }