fixed dublication of flavors

This commit is contained in:
nvrl committed 2026-06-15 17:47:18 +02:00
1 parent 4ec3464d99
commit 905bc00c0e
5 files changed
+131 -4

No files matched your search

+43
View File
@@ -36,5 +36,48 @@ pub async fn connect(database_url: &str) -> anyhow::Result<DatabaseConnection> {
}
}
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::<String>("", "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(())
}
+7
View File
@@ -22,6 +22,13 @@ pub struct Model {
pub is_available: bool,
pub is_legacy: bool,
pub external_id: Option<i64>,
/// 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<Json>,
/// 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,
+43
View File
@@ -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<Vec<String>> = 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<Option<flavor::Model>> {
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<String>.
fn json_str_list(v: &Option<Value>) -> Vec<String> {
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,
+34 -2
View File
@@ -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<String> =
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<String>, tolerating null/non-array.
fn json_str_list(v: &Option<serde_json::Value>) -> Vec<String> {
match v {
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect(),
_ => Vec::new(),
}
}
+4 -2
View File
@@ -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);
}