From 6e45baa8d4733791689bfae81187273a07a973af Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 08:55:50 +0200 Subject: [PATCH] feat(db): add authority repository with multilingual labels Co-Authored-By: Claude Sonnet 4.6 --- crates/db/src/authority.rs | 118 +++++++++++++++++++++++++++++++++++ crates/db/src/lib.rs | 1 + crates/db/tests/authority.rs | 102 ++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 crates/db/src/authority.rs create mode 100644 crates/db/tests/authority.rs diff --git a/crates/db/src/authority.rs b/crates/db/src/authority.rs new file mode 100644 index 0000000..da0320d --- /dev/null +++ b/crates/db/src/authority.rs @@ -0,0 +1,118 @@ +//! Authority records (person / organisation / place). + +use domain::{Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel, NewAuthority}; +use sqlx::Row; + +const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', al.lang, 'label', al.label) \ + ORDER BY al.lang) FILTER (WHERE al.authority_id IS NOT NULL), '[]'::json)"; + +/// Insert an authority and its labels. Multiple statements — pass a transaction +/// connection (`&mut *tx`) for atomicity. +pub async fn create_authority( + conn: &mut sqlx::PgConnection, + new: &NewAuthority, +) -> Result { + let id = AuthorityId::new(); + + sqlx::query("INSERT INTO authority (id, kind, external_uri) VALUES ($1, $2, $3)") + .bind(id.to_uuid()) + .bind(new.kind.as_str()) + .bind(new.external_uri.as_deref()) + .execute(&mut *conn) + .await?; + + for label in &new.labels { + sqlx::query("INSERT INTO authority_label (authority_id, lang, label) VALUES ($1, $2, $3)") + .bind(id.to_uuid()) + .bind(&label.lang) + .bind(&label.label) + .execute(&mut *conn) + .await?; + } + + Ok(id) +} + +/// Fetch one authority (with its labels). +pub async fn authority_by_id<'e, E>( + executor: E, + id: AuthorityId, +) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!( + "SELECT a.id, a.kind, a.external_uri, {LABELS_JSON} AS labels \ + FROM authority a LEFT JOIN authority_label al ON al.authority_id = a.id \ + WHERE a.id = $1 GROUP BY a.id" + ); + + let row = sqlx::query(&sql) + .bind(id.to_uuid()) + .fetch_optional(executor) + .await?; + + row.map(map_authority).transpose() +} + +/// List authorities of a given kind (with labels), ordered by id. +pub async fn list_by_kind<'e, E>( + executor: E, + kind: AuthorityKind, +) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!( + "SELECT a.id, a.kind, a.external_uri, {LABELS_JSON} AS labels \ + FROM authority a LEFT JOIN authority_label al ON al.authority_id = a.id \ + WHERE a.kind = $1 GROUP BY a.id ORDER BY a.id" + ); + + let rows = sqlx::query(&sql) + .bind(kind.as_str()) + .fetch_all(executor) + .await?; + + rows.into_iter().map(map_authority).collect() +} + +/// Resolve an authority to an [`AuthorityRef`] (carrying its kind). +pub async fn resolve_authority<'e, E>( + executor: E, + id: AuthorityId, +) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let kind: Option = sqlx::query_scalar("SELECT kind FROM authority WHERE id = $1") + .bind(id.to_uuid()) + .fetch_optional(executor) + .await?; + + match kind { + Some(k) => { + let kind = AuthorityKind::from_db(&k).ok_or_else(|| { + sqlx::Error::Decode(format!("unknown authority kind: {k}").into()) + })?; + + Ok(Some(AuthorityRef::new(id, kind))) + } + None => Ok(None), + } +} + +fn map_authority(row: sqlx::postgres::PgRow) -> Result { + let kind_str: String = row.try_get("kind")?; + let kind = AuthorityKind::from_db(&kind_str) + .ok_or_else(|| sqlx::Error::Decode(format!("unknown authority kind: {kind_str}").into()))?; + + let labels: sqlx::types::Json> = row.try_get("labels")?; + + Ok(Authority { + id: AuthorityId::from_uuid(row.try_get("id")?), + kind, + external_uri: row.try_get("external_uri")?, + labels: labels.0, + }) +} diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 16a3399..1eea258 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -1,6 +1,7 @@ //! Database access. All SQL lives in this crate. pub mod audit; +pub mod authority; pub mod vocab; use sqlx::postgres::{PgPool, PgPoolOptions}; diff --git a/crates/db/tests/authority.rs b/crates/db/tests/authority.rs new file mode 100644 index 0000000..d73b97f --- /dev/null +++ b/crates/db/tests/authority.rs @@ -0,0 +1,102 @@ +use db::{Db, authority}; +use domain::{AuthorityKind, LocalizedLabel, NewAuthority}; +use sqlx::PgPool; + +fn new_person(name_sv: &str, name_en: &str) -> NewAuthority { + NewAuthority { + kind: AuthorityKind::Person, + external_uri: None, + labels: vec![ + LocalizedLabel { + lang: "sv".into(), + label: name_sv.into(), + }, + LocalizedLabel { + lang: "en".into(), + label: name_en.into(), + }, + ], + } +} + +#[sqlx::test] +async fn authority_round_trips_with_labels(pool: PgPool) { + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().await.unwrap(); + let id = authority::create_authority(&mut *tx, &new_person("Carl Larsson", "Carl Larsson")) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let got = authority::authority_by_id(db.pool(), id) + .await + .unwrap() + .unwrap(); + assert_eq!(got.id, id); + assert_eq!(got.kind, AuthorityKind::Person); + assert_eq!(got.labels.len(), 2); + assert_eq!( + domain::pick_label(&got.labels, "sv", "en"), + Some("Carl Larsson") + ); +} + +#[sqlx::test] +async fn list_by_kind_filters(pool: PgPool) { + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().await.unwrap(); + authority::create_authority(&mut *tx, &new_person("A", "A")) + .await + .unwrap(); + authority::create_authority( + &mut *tx, + &NewAuthority { + kind: AuthorityKind::Place, + external_uri: None, + labels: vec![LocalizedLabel { + lang: "en".into(), + label: "Stockholm".into(), + }], + }, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let people = authority::list_by_kind(db.pool(), AuthorityKind::Person) + .await + .unwrap(); + assert_eq!(people.len(), 1); + assert_eq!(people[0].kind, AuthorityKind::Person); + + let places = authority::list_by_kind(db.pool(), AuthorityKind::Place) + .await + .unwrap(); + assert_eq!(places.len(), 1); + assert_eq!(places[0].kind, AuthorityKind::Place); +} + +#[sqlx::test] +async fn resolve_authority_returns_kind(pool: PgPool) { + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().await.unwrap(); + let id = authority::create_authority(&mut *tx, &new_person("X", "X")) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let r = authority::resolve_authority(db.pool(), id) + .await + .unwrap() + .unwrap(); + assert_eq!(r.authority_id(), id); + assert_eq!(r.kind(), AuthorityKind::Person); + + let missing = authority::resolve_authority(db.pool(), domain::AuthorityId::new()) + .await + .unwrap(); + assert!(missing.is_none()); +}