From 2b0056c03800f084451a9361758c9cd754f41f49 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 10:59:23 +0200 Subject: [PATCH] feat(db): set_object_fields with registry validation and audited diffs Co-Authored-By: Claude Sonnet 4.6 --- crates/db/src/catalog.rs | 182 ++++++++++++++++++++++++++++++- crates/db/tests/object_fields.rs | 164 ++++++++++++++++++++++++++++ 2 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 crates/db/tests/object_fields.rs diff --git a/crates/db/src/catalog.rs b/crates/db/src/catalog.rs index b9a3394..38ddcab 100644 --- a/crates/db/src/catalog.rs +++ b/crates/db/src/catalog.rs @@ -2,13 +2,13 @@ //! on the caller's connection, so the change and its audit entry commit together. use domain::{ - AuditAction, AuditActor, CatalogueObject, FieldChange, NewAuditEvent, ObjectId, ObjectInput, - Visibility, + AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, NewAuditEvent, ObjectId, + ObjectInput, Visibility, }; use serde_json::{Value, json}; use sqlx::Row; -use crate::audit; +use crate::{audit, authority, fields, vocab}; /// The entity_type recorded in the audit log for catalogue objects. const ENTITY_TYPE: &str = "object"; @@ -260,3 +260,179 @@ pub async fn delete_object( Ok(true) } + +/// Why setting flexible field values failed. +#[derive(Debug, thiserror::Error)] +pub enum FieldError { + #[error("object not found")] + ObjectNotFound, + #[error("unknown field: {0}")] + UnknownField(String), + #[error("field `{field}` expects a {expected} value")] + TypeMismatch { + field: String, + expected: &'static str, + }, + #[error("field `{field}`: value does not resolve to an existing {kind}")] + Unresolved { field: String, kind: &'static str }, + #[error(transparent)] + Db(#[from] sqlx::Error), +} + +/// Replace an object's flexible field values, validating each against the registry +/// (type + term/authority resolution), and audit the per-field diff — all on `conn`. +/// A no-op (identical to the current values) writes nothing and records no audit. +pub async fn set_object_fields( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + object_id: ObjectId, + values: &serde_json::Map, +) -> Result<(), FieldError> { + let Some(old) = object_by_id(&mut *conn, object_id).await? else { + return Err(FieldError::ObjectNotFound); + }; + + for (key, value) in values { + validate_field(&mut *conn, key, value).await?; + } + + let new_fields = Value::Object(values.clone()); + let changes = field_map_changes(&old.fields, &new_fields); + + if changes.is_empty() { + return Ok(()); + } + + sqlx::query("UPDATE object SET fields = $2, updated_at = now() WHERE id = $1") + .bind(object_id.to_uuid()) + .bind(&new_fields) + .execute(&mut *conn) + .await?; + + audit::record( + &mut *conn, + &NewAuditEvent { + actor, + action: AuditAction::Updated, + entity_type: ENTITY_TYPE.to_owned(), + entity_id: object_id.to_uuid(), + changes, + }, + ) + .await?; + + Ok(()) +} + +async fn validate_field( + conn: &mut sqlx::PgConnection, + key: &str, + value: &Value, +) -> Result<(), FieldError> { + let def = fields::field_definition_by_key(&mut *conn, key) + .await? + .ok_or_else(|| FieldError::UnknownField(key.to_owned()))?; + + match def.field_type { + FieldType::Text => require(value.is_string(), key, "text")?, + FieldType::LocalizedText => require( + value + .as_object() + .is_some_and(|o| o.values().all(Value::is_string)), + key, + "localized-text object {lang: string}", + )?, + FieldType::Integer => require(value.is_i64(), key, "integer")?, + FieldType::Date => require(value.is_string(), key, "date string")?, + FieldType::Boolean => require(value.is_boolean(), key, "boolean")?, + FieldType::Term { vocabulary_id } => { + let term_id = parse_uuid(value, key, "term id (uuid string)")?; + + if vocab::resolve_term( + &mut *conn, + vocabulary_id, + domain::TermId::from_uuid(term_id), + ) + .await? + .is_none() + { + return Err(FieldError::Unresolved { + field: key.to_owned(), + kind: "term", + }); + } + } + FieldType::Authority { kind } => { + let authority_id = parse_uuid(value, key, "authority id (uuid string)")?; + + match authority::resolve_authority( + &mut *conn, + domain::AuthorityId::from_uuid(authority_id), + ) + .await? + { + Some(ref_) if kind.is_none_or(|k| ref_.kind() == k) => {} + _ => { + return Err(FieldError::Unresolved { + field: key.to_owned(), + kind: "authority", + }); + } + } + } + } + + Ok(()) +} + +fn require(ok: bool, field: &str, expected: &'static str) -> Result<(), FieldError> { + if ok { + Ok(()) + } else { + Err(FieldError::TypeMismatch { + field: field.to_owned(), + expected, + }) + } +} + +fn parse_uuid( + value: &Value, + field: &str, + expected: &'static str, +) -> Result { + value + .as_str() + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| FieldError::TypeMismatch { + field: field.to_owned(), + expected, + }) +} + +/// Per-key diff between two flexible-field maps. `before`/`after` are `None` when +/// the key is absent on that side (so adds and removes are captured). +fn field_map_changes(old: &Value, new: &Value) -> Vec { + let empty = serde_json::Map::new(); + let old_map = old.as_object().unwrap_or(&empty); + let new_map = new.as_object().unwrap_or(&empty); + + let keys: std::collections::BTreeSet<&String> = old_map.keys().chain(new_map.keys()).collect(); + + keys.into_iter() + .filter_map(|key| { + let before = old_map.get(key).cloned(); + let after = new_map.get(key).cloned(); + + if before != after { + Some(FieldChange { + field: key.clone(), + before, + after, + }) + } else { + None + } + }) + .collect() +} diff --git a/crates/db/tests/object_fields.rs b/crates/db/tests/object_fields.rs new file mode 100644 index 0000000..ac24cea --- /dev/null +++ b/crates/db/tests/object_fields.rs @@ -0,0 +1,164 @@ +use db::catalog::FieldError; +use db::{Db, audit, catalog, fields, vocab}; +use domain::{ + AuditAction, AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, ObjectInput, Visibility, +}; +use sqlx::PgPool; + +fn obj_input() -> ObjectInput { + ObjectInput { + object_number: "LM-1".into(), + object_name: "vase".into(), + number_of_objects: 1, + brief_description: None, + current_location: None, + current_owner: None, + recorder: None, + recording_date: None, + visibility: Visibility::Draft, + } +} + +fn label(text: &str) -> Vec { + vec![LocalizedLabel { + lang: "en".into(), + label: text.into(), + }] +} + +async fn setup_object(db: &Db) -> domain::ObjectId { + let mut tx = db.pool().begin().await.unwrap(); + let id = catalog::create_object(&mut tx, AuditActor::System, &obj_input()) + .await + .unwrap(); + tx.commit().await.unwrap(); + id +} + +async fn define(db: &Db, key: &str, field_type: FieldType) { + let mut tx = db.pool().begin().await.unwrap(); + fields::create_field_definition( + &mut tx, + &NewFieldDefinition { + key: key.into(), + field_type, + required: false, + group_key: None, + labels: label(key), + }, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); +} + +#[sqlx::test] +async fn sets_scalar_fields_and_audits(pool: PgPool) { + let db = Db::from_pool(pool); + let id = setup_object(&db).await; + define(&db, "comments", FieldType::Text).await; + define(&db, "year", FieldType::Integer).await; + define(&db, "on_display", FieldType::Boolean).await; + + let values = serde_json::json!({ "comments": "nice", "year": 1850, "on_display": true }); + + let mut tx = db.pool().begin().await.unwrap(); + catalog::set_object_fields(&mut tx, AuditActor::System, id, values.as_object().unwrap()) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap(); + assert_eq!(obj.fields["comments"], "nice"); + assert_eq!(obj.fields["year"], 1850); + assert_eq!(obj.fields["on_display"], true); + + let history = audit::history_for(db.pool(), "object", id.to_uuid()) + .await + .unwrap(); + assert_eq!(history.last().unwrap().action, AuditAction::Updated); + let changed: Vec<&str> = history + .last() + .unwrap() + .changes + .iter() + .map(|c| c.field.as_str()) + .collect(); + assert!( + changed.contains(&"comments") + && changed.contains(&"year") + && changed.contains(&"on_display") + ); +} + +#[sqlx::test] +async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) { + let db = Db::from_pool(pool); + let id = setup_object(&db).await; + let material = vocab::create_vocabulary(db.pool(), "material") + .await + .unwrap(); + define( + &db, + "material", + FieldType::Term { + vocabulary_id: material.id, + }, + ) + .await; + + let mut tx = db.pool().begin().await.unwrap(); + let wood = vocab::add_term( + &mut tx, + &domain::NewTerm { + vocabulary_id: material.id, + external_uri: None, + labels: label("wood"), + }, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let ok = serde_json::json!({ "material": wood.to_string() }); + let mut tx = db.pool().begin().await.unwrap(); + catalog::set_object_fields(&mut tx, AuditActor::System, id, ok.as_object().unwrap()) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let bad = serde_json::json!({ "material": domain::TermId::new().to_string() }); + let mut tx = db.pool().begin().await.unwrap(); + let err = + catalog::set_object_fields(&mut tx, AuditActor::System, id, bad.as_object().unwrap()).await; + assert!(matches!(err, Err(FieldError::Unresolved { .. }))); +} + +#[sqlx::test] +async fn unknown_field_and_type_mismatch_are_rejected(pool: PgPool) { + let db = Db::from_pool(pool); + let id = setup_object(&db).await; + define(&db, "year", FieldType::Integer).await; + + let unknown = serde_json::json!({ "nope": "x" }); + let mut tx = db.pool().begin().await.unwrap(); + assert!(matches!( + catalog::set_object_fields( + &mut tx, + AuditActor::System, + id, + unknown.as_object().unwrap() + ) + .await, + Err(FieldError::UnknownField(_)) + )); + drop(tx); + + let wrong = serde_json::json!({ "year": "not a number" }); + let mut tx = db.pool().begin().await.unwrap(); + assert!(matches!( + catalog::set_object_fields(&mut tx, AuditActor::System, id, wrong.as_object().unwrap()) + .await, + Err(FieldError::TypeMismatch { .. }) + )); +}