refactor(db): DRY object SELECT columns, consistent date json; test date + all-none round-trip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 09:29:40 +02:00
parent e0c0187f29
commit 616a6f05c6
3 changed files with 54 additions and 17 deletions
+1
View File
@@ -14,3 +14,4 @@ serde_json.workspace = true
[dev-dependencies]
tokio.workspace = true
time.workspace = true
+12 -17
View File
@@ -13,13 +13,9 @@ use crate::audit;
/// The entity_type recorded in the audit log for catalogue objects.
const ENTITY_TYPE: &str = "object";
const SELECT_OBJECT_BY_ID: &str = "SELECT id, object_number, object_name, number_of_objects, brief_description, \
current_location, current_owner, recorder, recording_date, visibility, \
created_at, updated_at FROM object WHERE id = $1";
const SELECT_OBJECTS_ORDERED: &str = "SELECT id, object_number, object_name, number_of_objects, brief_description, \
current_location, current_owner, recorder, recording_date, visibility, \
created_at, updated_at FROM object ORDER BY object_number";
const OBJECT_COLUMNS: &str = "id, object_number, object_name, number_of_objects, \
brief_description, current_location, current_owner, recorder, recording_date, \
visibility, created_at, updated_at";
/// Create an object and record a `created` audit entry, both on `conn`
/// (pass a transaction connection `&mut *tx` so they commit atomically).
@@ -74,7 +70,9 @@ pub async fn object_by_id<'e, E>(
where
E: sqlx::PgExecutor<'e>,
{
let row = sqlx::query(SELECT_OBJECT_BY_ID)
let sql = format!("SELECT {OBJECT_COLUMNS} FROM object WHERE id = $1");
let row = sqlx::query(&sql)
.bind(id.to_uuid())
.fetch_optional(executor)
.await?;
@@ -88,9 +86,9 @@ where
E: sqlx::PgExecutor<'e>,
{
// TODO: add LIMIT/keyset pagination before exposing this via the API.
let rows = sqlx::query(SELECT_OBJECTS_ORDERED)
.fetch_all(executor)
.await?;
let sql = format!("SELECT {OBJECT_COLUMNS} FROM object ORDER BY object_number");
let rows = sqlx::query(&sql).fetch_all(executor).await?;
rows.into_iter().map(map_object).collect()
}
@@ -137,17 +135,14 @@ fn field_values(input: &ObjectInput) -> Vec<(&'static str, Option<Value>)> {
input.current_owner.as_ref().map(|v| json!(v)),
),
("recorder", input.recorder.as_ref().map(|v| json!(v))),
(
"recording_date",
input
.recording_date
.and_then(|d| serde_json::to_value(d).ok()),
),
("recording_date", input.recording_date.map(|d| json!(d))),
("visibility", Some(json!(input.visibility.as_str()))),
]
}
/// Audit changes for a newly created object: every set field as an `after` value.
/// Unset (`None`) optional fields are omitted — absence is conveyed by their not
/// appearing, consistent with `FieldChange`'s `None`-means-no-value convention.
fn creation_changes(input: &ObjectInput) -> Vec<FieldChange> {
field_values(input)
.into_iter()
+41
View File
@@ -75,3 +75,44 @@ async fn object_by_id_missing_is_none(pool: PgPool) {
.is_none()
);
}
#[sqlx::test]
async fn object_with_date_and_all_none_optionals_round_trips(pool: PgPool) {
let db = Db::from_pool(pool);
let date = time::Date::from_calendar_date(2020, time::Month::January, 28).unwrap();
let input = ObjectInput {
object_number: "LM-3".into(),
object_name: "drawing".into(),
number_of_objects: 1,
brief_description: None,
current_location: None,
current_owner: None,
recorder: None,
recording_date: Some(date),
visibility: Visibility::Internal,
};
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut tx, AuditActor::System, &input)
.await
.unwrap();
tx.commit().await.unwrap();
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
assert_eq!(obj.recording_date, Some(date));
assert_eq!(obj.brief_description, None);
assert_eq!(obj.current_location, None);
assert_eq!(obj.current_owner, None);
assert_eq!(obj.recorder, None);
assert_eq!(obj.visibility, Visibility::Internal);
let history = audit::history_for(db.pool(), "object", id.to_uuid())
.await
.unwrap();
assert!(
history[0]
.changes
.iter()
.any(|c| c.field == "recording_date")
);
}