feat(db): set_object_fields with registry validation and audited diffs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+179
-3
@@ -2,13 +2,13 @@
|
|||||||
//! on the caller's connection, so the change and its audit entry commit together.
|
//! on the caller's connection, so the change and its audit entry commit together.
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
AuditAction, AuditActor, CatalogueObject, FieldChange, NewAuditEvent, ObjectId, ObjectInput,
|
AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, NewAuditEvent, ObjectId,
|
||||||
Visibility,
|
ObjectInput, Visibility,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
use crate::audit;
|
use crate::{audit, authority, fields, vocab};
|
||||||
|
|
||||||
/// The entity_type recorded in the audit log for catalogue objects.
|
/// The entity_type recorded in the audit log for catalogue objects.
|
||||||
const ENTITY_TYPE: &str = "object";
|
const ENTITY_TYPE: &str = "object";
|
||||||
@@ -260,3 +260,179 @@ pub async fn delete_object(
|
|||||||
|
|
||||||
Ok(true)
|
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<String, Value>,
|
||||||
|
) -> 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<uuid::Uuid, FieldError> {
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.and_then(|s| s.parse::<uuid::Uuid>().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<FieldChange> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<LocalizedLabel> {
|
||||||
|
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 { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user