feat(db): add catalogue object update/delete with audited field diffs
update_object records only changed fields as audit diffs and skips the audit entry for no-op updates; delete_object records a Deleted entry. Both operations are atomic on the caller's connection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -157,8 +157,6 @@ fn creation_changes(input: &ObjectInput) -> Vec<FieldChange> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Audit changes between two field sets: only the fields whose value changed.
|
/// Audit changes between two field sets: only the fields whose value changed.
|
||||||
/// (Used by `update_object` in the next task.)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn update_changes(old: &ObjectInput, new: &ObjectInput) -> Vec<FieldChange> {
|
fn update_changes(old: &ObjectInput, new: &ObjectInput) -> Vec<FieldChange> {
|
||||||
field_values(old)
|
field_values(old)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -176,3 +174,86 @@ fn update_changes(old: &ObjectInput, new: &ObjectInput) -> Vec<FieldChange> {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update an object and record an `updated` audit entry with field-level diffs,
|
||||||
|
/// both on `conn`. Returns `false` if the object does not exist. A no-op update
|
||||||
|
/// (no fields changed) records no audit entry.
|
||||||
|
pub async fn update_object(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
|
id: ObjectId,
|
||||||
|
input: &ObjectInput,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let Some(old) = object_by_id(&mut *conn, id).await? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE object SET \
|
||||||
|
object_number = $2, object_name = $3, number_of_objects = $4, \
|
||||||
|
brief_description = $5, current_location = $6, current_owner = $7, \
|
||||||
|
recorder = $8, recording_date = $9, visibility = $10, updated_at = now() \
|
||||||
|
WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.to_uuid())
|
||||||
|
.bind(&input.object_number)
|
||||||
|
.bind(&input.object_name)
|
||||||
|
.bind(input.number_of_objects)
|
||||||
|
.bind(input.brief_description.as_deref())
|
||||||
|
.bind(input.current_location.as_deref())
|
||||||
|
.bind(input.current_owner.as_deref())
|
||||||
|
.bind(input.recorder.as_deref())
|
||||||
|
.bind(input.recording_date)
|
||||||
|
.bind(input.visibility.as_str())
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let changes = update_changes(&old.to_input(), input);
|
||||||
|
|
||||||
|
if !changes.is_empty() {
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Updated,
|
||||||
|
entity_type: ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: id.to_uuid(),
|
||||||
|
changes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an object and record a `deleted` audit entry, both on `conn`.
|
||||||
|
/// Returns `false` if the object did not exist.
|
||||||
|
pub async fn delete_object(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
|
id: ObjectId,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
if object_by_id(&mut *conn, id).await?.is_none() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM object WHERE id = $1")
|
||||||
|
.bind(id.to_uuid())
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Deleted,
|
||||||
|
entity_type: ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: id.to_uuid(),
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
use db::{Db, audit, catalog};
|
||||||
|
use domain::{AuditAction, AuditActor, ObjectInput, Visibility};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
fn base() -> ObjectInput {
|
||||||
|
ObjectInput {
|
||||||
|
object_number: "LM-1".into(),
|
||||||
|
object_name: "vase".into(),
|
||||||
|
number_of_objects: 1,
|
||||||
|
brief_description: None,
|
||||||
|
current_location: Some("shelf A1".into()),
|
||||||
|
current_owner: None,
|
||||||
|
recorder: None,
|
||||||
|
recording_date: None,
|
||||||
|
visibility: Visibility::Draft,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn update_changes_are_audited_as_diffs(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let id = catalog::create_object(&mut tx, AuditActor::System, &base())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let mut changed = base();
|
||||||
|
changed.object_name = "roman vase".into();
|
||||||
|
changed.visibility = Visibility::Public;
|
||||||
|
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let updated = catalog::update_object(&mut tx, AuditActor::System, id, &changed)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
assert!(updated);
|
||||||
|
|
||||||
|
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(obj.object_name, "roman vase");
|
||||||
|
assert_eq!(obj.visibility, Visibility::Public);
|
||||||
|
|
||||||
|
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(history.len(), 2); // created + updated
|
||||||
|
let update = &history[1];
|
||||||
|
assert_eq!(update.action, AuditAction::Updated);
|
||||||
|
let mut fields: Vec<&str> = update.changes.iter().map(|c| c.field.as_str()).collect();
|
||||||
|
fields.sort_unstable();
|
||||||
|
assert_eq!(fields, vec!["object_name", "visibility"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn no_op_update_records_no_audit(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let id = catalog::create_object(&mut tx, AuditActor::System, &base())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let updated = catalog::update_object(&mut tx, AuditActor::System, id, &base())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
assert!(updated);
|
||||||
|
|
||||||
|
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
history.len(),
|
||||||
|
1,
|
||||||
|
"a no-op update must not add an audit entry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn update_missing_returns_false(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let updated = catalog::update_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
domain::ObjectId::new(),
|
||||||
|
&base(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
assert!(!updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn delete_removes_and_audits(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let id = catalog::create_object(&mut tx, AuditActor::System, &base())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let deleted = catalog::delete_object(&mut tx, AuditActor::System, id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
assert!(deleted);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
catalog::object_by_id(db.pool(), id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(history.last().unwrap().action, AuditAction::Deleted);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user