//! Controlled vocabularies and terms. use domain::{ AuditAction, AuditActor, LocalizedLabel, NewAuditEvent, NewTerm, Term, TermId, TermRef, Vocabulary, VocabularyId, }; use sqlx::Row; use crate::audit; const VOCABULARY_ENTITY_TYPE: &str = "vocabulary"; const TERM_ENTITY_TYPE: &str = "term"; /// Labels aggregated per row as JSON, to read a term and its labels in one query. const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', tl.lang, 'label', tl.label) \ ORDER BY tl.lang) FILTER (WHERE tl.term_id IS NOT NULL), '[]'::json)"; /// Create a vocabulary with the given key and record a `created` audit entry, both on /// `conn` (pass a transaction connection `&mut *tx` so they commit atomically). pub async fn create_vocabulary( conn: &mut sqlx::PgConnection, actor: AuditActor, key: &str, ) -> Result { let id = VocabularyId::new(); sqlx::query("INSERT INTO vocabulary (id, key) VALUES ($1, $2)") .bind(id.to_uuid()) .bind(key) .execute(&mut *conn) .await?; audit::record( &mut *conn, &NewAuditEvent { actor, action: AuditAction::Created, entity_type: VOCABULARY_ENTITY_TYPE.to_owned(), entity_id: id.to_uuid(), changes: Vec::new(), }, ) .await?; Ok(Vocabulary { id, key: key.to_owned(), }) } /// List all vocabularies, ordered by key. pub async fn list_vocabularies<'e, E>(executor: E) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let rows = sqlx::query("SELECT id, key FROM vocabulary ORDER BY key") .fetch_all(executor) .await?; rows.into_iter().map(map_vocabulary).collect() } /// Look up a vocabulary by its key. pub async fn vocabulary_by_key<'e, E>( executor: E, key: &str, ) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let row = sqlx::query("SELECT id, key FROM vocabulary WHERE key = $1") .bind(key) .fetch_optional(executor) .await?; row.map(map_vocabulary).transpose() } /// Insert a term and its labels, then record a `created` audit entry. Multiple /// statements — pass a transaction connection (`&mut *tx`) so everything commits /// atomically. pub async fn add_term( conn: &mut sqlx::PgConnection, actor: AuditActor, new: &NewTerm, ) -> Result { let id = TermId::new(); sqlx::query("INSERT INTO term (id, vocabulary_id, external_uri) VALUES ($1, $2, $3)") .bind(id.to_uuid()) .bind(new.vocabulary_id.to_uuid()) .bind(new.external_uri.as_deref()) .execute(&mut *conn) .await?; for label in &new.labels { sqlx::query("INSERT INTO term_label (term_id, lang, label) VALUES ($1, $2, $3)") .bind(id.to_uuid()) .bind(&label.lang) .bind(&label.label) .execute(&mut *conn) .await?; } audit::record( &mut *conn, &NewAuditEvent { actor, action: AuditAction::Created, entity_type: TERM_ENTITY_TYPE.to_owned(), entity_id: id.to_uuid(), changes: Vec::new(), }, ) .await?; Ok(id) } /// Fetch one term (with its labels). pub async fn term_by_id<'e, E>(executor: E, id: TermId) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let sql = format!( "SELECT t.id, t.vocabulary_id, t.external_uri, {LABELS_JSON} AS labels \ FROM term t LEFT JOIN term_label tl ON tl.term_id = t.id \ WHERE t.id = $1 GROUP BY t.id" ); let row = sqlx::query(&sql) .bind(id.to_uuid()) .fetch_optional(executor) .await?; row.map(map_term).transpose() } /// List all terms in a vocabulary (with labels), ordered by id. pub async fn list_terms<'e, E>( executor: E, vocabulary_id: VocabularyId, ) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let sql = format!( "SELECT t.id, t.vocabulary_id, t.external_uri, {LABELS_JSON} AS labels \ FROM term t LEFT JOIN term_label tl ON tl.term_id = t.id \ WHERE t.vocabulary_id = $1 GROUP BY t.id ORDER BY t.id" ); let rows = sqlx::query(&sql) .bind(vocabulary_id.to_uuid()) .fetch_all(executor) .await?; rows.into_iter().map(map_term).collect() } /// Resolve a term to a [`TermRef`], confirming it belongs to `vocabulary_id`. pub async fn resolve_term<'e, E>( executor: E, vocabulary_id: VocabularyId, term_id: TermId, ) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let found = sqlx::query_scalar::<_, i32>("SELECT 1 FROM term WHERE id = $1 AND vocabulary_id = $2") .bind(term_id.to_uuid()) .bind(vocabulary_id.to_uuid()) .fetch_optional(executor) .await?; Ok(found.map(|_| TermRef::new(term_id, vocabulary_id))) } /// Update a term's `external_uri` and labels (full replace), recording an `updated` /// audit entry. Returns `false` if no such term or the term does not belong to /// `vocabulary_id`. Pass a transaction connection. pub async fn update_term( conn: &mut sqlx::PgConnection, actor: AuditActor, vocabulary_id: VocabularyId, term_id: TermId, external_uri: Option<&str>, labels: &[LocalizedLabel], ) -> Result { let updated = sqlx::query("UPDATE term SET external_uri = $2 WHERE id = $1 AND vocabulary_id = $3") .bind(term_id.to_uuid()) .bind(external_uri) .bind(vocabulary_id.to_uuid()) .execute(&mut *conn) .await? .rows_affected(); if updated == 0 { return Ok(false); } sqlx::query("DELETE FROM term_label WHERE term_id = $1") .bind(term_id.to_uuid()) .execute(&mut *conn) .await?; for label in labels { sqlx::query("INSERT INTO term_label (term_id, lang, label) VALUES ($1, $2, $3)") .bind(term_id.to_uuid()) .bind(&label.lang) .bind(&label.label) .execute(&mut *conn) .await?; } audit::record( &mut *conn, &NewAuditEvent { actor, action: AuditAction::Updated, entity_type: TERM_ENTITY_TYPE.to_owned(), entity_id: term_id.to_uuid(), changes: Vec::new(), }, ) .await?; Ok(true) } /// Count catalogue objects that reference `term_id` through a `term`-typed field. pub async fn count_objects_referencing_term<'e, E>( executor: E, term_id: TermId, ) -> Result where E: sqlx::PgExecutor<'e>, { sqlx::query_scalar( "SELECT count(*) FROM object o WHERE EXISTS ( \ SELECT 1 FROM field_definition fd \ WHERE fd.data_type = 'term' AND o.fields ->> fd.key = $1 )", ) .bind(term_id.to_string()) .fetch_one(executor) .await } /// Delete a term (its labels cascade) unless catalogue objects reference it, recording a /// `deleted` audit entry. Pass a transaction connection. pub async fn delete_term( conn: &mut sqlx::PgConnection, actor: AuditActor, vocabulary_id: VocabularyId, term_id: TermId, ) -> Result { let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM term WHERE id = $1 AND vocabulary_id = $2") .bind(term_id.to_uuid()) .bind(vocabulary_id.to_uuid()) .fetch_optional(&mut *conn) .await?; if exists.is_none() { return Ok(crate::DeleteOutcome::NotFound); } let count = count_objects_referencing_term(&mut *conn, term_id).await?; if count > 0 { return Ok(crate::DeleteOutcome::InUse { count }); } sqlx::query("DELETE FROM term WHERE id = $1") .bind(term_id.to_uuid()) .execute(&mut *conn) .await?; audit::record( &mut *conn, &NewAuditEvent { actor, action: AuditAction::Deleted, entity_type: TERM_ENTITY_TYPE.to_owned(), entity_id: term_id.to_uuid(), changes: Vec::new(), }, ) .await?; Ok(crate::DeleteOutcome::Deleted) } /// Rename a vocabulary's key, recording an `updated` audit entry. Returns `false` if no /// such vocabulary. A unique-key collision surfaces as the underlying sqlx error (23505). pub async fn rename_vocabulary( conn: &mut sqlx::PgConnection, actor: AuditActor, id: VocabularyId, key: &str, ) -> Result { let updated = sqlx::query("UPDATE vocabulary SET key = $2 WHERE id = $1") .bind(id.to_uuid()) .bind(key) .execute(&mut *conn) .await? .rows_affected(); if updated == 0 { return Ok(false); } audit::record( &mut *conn, &NewAuditEvent { actor, action: AuditAction::Updated, entity_type: VOCABULARY_ENTITY_TYPE.to_owned(), entity_id: id.to_uuid(), changes: Vec::new(), }, ) .await?; Ok(true) } /// Delete a vocabulary unless it still has terms or is bound by a field definition /// (both would otherwise hit the FK `RESTRICT`). Records a `deleted` audit entry. pub async fn delete_vocabulary( conn: &mut sqlx::PgConnection, actor: AuditActor, id: VocabularyId, ) -> Result { let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM vocabulary WHERE id = $1") .bind(id.to_uuid()) .fetch_optional(&mut *conn) .await?; if exists.is_none() { return Ok(crate::DeleteOutcome::NotFound); } let count: i64 = sqlx::query_scalar( "SELECT (SELECT count(*) FROM term WHERE vocabulary_id = $1) \ + (SELECT count(*) FROM field_definition WHERE vocabulary_id = $1)", ) .bind(id.to_uuid()) .fetch_one(&mut *conn) .await?; if count > 0 { return Ok(crate::DeleteOutcome::InUse { count }); } sqlx::query("DELETE FROM vocabulary WHERE id = $1") .bind(id.to_uuid()) .execute(&mut *conn) .await?; audit::record( &mut *conn, &NewAuditEvent { actor, action: AuditAction::Deleted, entity_type: VOCABULARY_ENTITY_TYPE.to_owned(), entity_id: id.to_uuid(), changes: Vec::new(), }, ) .await?; Ok(crate::DeleteOutcome::Deleted) } fn map_vocabulary(row: sqlx::postgres::PgRow) -> Result { Ok(Vocabulary { id: VocabularyId::from_uuid(row.try_get("id")?), key: row.try_get("key")?, }) } fn map_term(row: sqlx::postgres::PgRow) -> Result { let labels: sqlx::types::Json> = row.try_get("labels")?; Ok(Term { id: TermId::from_uuid(row.try_get("id")?), vocabulary_id: VocabularyId::from_uuid(row.try_get("vocabulary_id")?), external_uri: row.try_get("external_uri")?, labels: labels.0, }) }