//! Registry of flexible field definitions. use domain::{ AuditAction, AuditActor, AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType, LocalizedLabel, NewAuditEvent, NewFieldDefinition, VocabularyId, }; use sqlx::Row; use crate::audit; const FIELD_DEFINITION_ENTITY_TYPE: &str = "field_definition"; /// Labels aggregated per row as JSON, to read a definition and its labels in one query. const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', fdl.lang, 'label', fdl.label) \ ORDER BY fdl.lang) FILTER (WHERE fdl.field_definition_id IS NOT NULL), '[]'::json)"; const SELECT_COLUMNS: &str = "fd.id, fd.key, fd.data_type, fd.vocabulary_id, fd.authority_kind, fd.required, fd.group_key"; /// Create a field definition and its labels. Multiple statements — pass a /// transaction connection (`&mut *tx`) for atomicity. pub async fn create_field_definition( conn: &mut sqlx::PgConnection, new: &NewFieldDefinition, ) -> Result { let id = FieldDefinitionId::new(); let (data_type, vocabulary_id, authority_kind) = new.field_type.to_parts(); sqlx::query( "INSERT INTO field_definition \ (id, key, data_type, vocabulary_id, authority_kind, required, group_key) \ VALUES ($1, $2, $3, $4, $5, $6, $7)", ) .bind(id.to_uuid()) .bind(&new.key) .bind(data_type) .bind(vocabulary_id.map(|v| v.to_uuid())) .bind(authority_kind.map(|k| k.as_str())) .bind(new.required) .bind(new.group_key.as_deref()) .execute(&mut *conn) .await?; for label in &new.labels { sqlx::query( "INSERT INTO field_definition_label (field_definition_id, lang, label) \ VALUES ($1, $2, $3)", ) .bind(id.to_uuid()) .bind(&label.lang) .bind(&label.label) .execute(&mut *conn) .await?; } Ok(id) } /// Look up a field definition by its key (with labels). pub async fn field_definition_by_key<'e, E>( executor: E, key: &str, ) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let sql = format!( "SELECT {SELECT_COLUMNS}, {LABELS_JSON} AS labels \ FROM field_definition fd \ LEFT JOIN field_definition_label fdl ON fdl.field_definition_id = fd.id \ WHERE fd.key = $1 GROUP BY fd.id" ); let row = sqlx::query(&sql).bind(key).fetch_optional(executor).await?; row.map(map_field_definition).transpose() } /// List all field definitions (with labels), ordered by key. pub async fn list_field_definitions<'e, E>(executor: E) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let sql = format!( "SELECT {SELECT_COLUMNS}, {LABELS_JSON} AS labels \ FROM field_definition fd \ LEFT JOIN field_definition_label fdl ON fdl.field_definition_id = fd.id \ GROUP BY fd.id ORDER BY fd.key" ); let rows = sqlx::query(&sql).fetch_all(executor).await?; rows.into_iter().map(map_field_definition).collect() } fn map_field_definition(row: sqlx::postgres::PgRow) -> Result { let data_type: String = row.try_get("data_type")?; let vocabulary_id: Option = row.try_get("vocabulary_id")?; let authority_kind: Option = row.try_get("authority_kind")?; let authority_kind = authority_kind .map(|k| { AuthorityKind::from_db(&k) .ok_or_else(|| sqlx::Error::Decode(format!("unknown authority kind: {k}").into())) }) .transpose()?; let field_type = FieldType::from_parts( &data_type, vocabulary_id.map(VocabularyId::from_uuid), authority_kind, ) .ok_or_else(|| { sqlx::Error::Decode(format!("inconsistent field type stored: {data_type}").into()) })?; let labels: sqlx::types::Json> = row.try_get("labels")?; Ok(FieldDefinition { id: FieldDefinitionId::from_uuid(row.try_get("id")?), key: row.try_get("key")?, field_type, required: row.try_get("required")?, group_key: row.try_get("group_key")?, labels: labels.0, }) } /// Update a field definition's mutable attributes (`required`, `group_key`, labels); /// `key`, `data_type`, and binding are immutable and untouched. Records an `updated` /// audit entry. Returns `false` if no such key. Pass a transaction connection. pub async fn update_field_definition( conn: &mut sqlx::PgConnection, actor: AuditActor, key: &str, required: bool, group_key: Option<&str>, labels: &[LocalizedLabel], ) -> Result { let id: Option = sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1") .bind(key) .fetch_optional(&mut *conn) .await?; let Some(id) = id else { return Ok(false) }; sqlx::query("UPDATE field_definition SET required = $2, group_key = $3 WHERE id = $1") .bind(id) .bind(required) .bind(group_key) .execute(&mut *conn) .await?; sqlx::query("DELETE FROM field_definition_label WHERE field_definition_id = $1") .bind(id) .execute(&mut *conn) .await?; for label in labels { sqlx::query( "INSERT INTO field_definition_label (field_definition_id, lang, label) \ VALUES ($1, $2, $3)", ) .bind(id) .bind(&label.lang) .bind(&label.label) .execute(&mut *conn) .await?; } audit::record( &mut *conn, &NewAuditEvent { actor, action: AuditAction::Updated, entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(), entity_id: id, changes: Vec::new(), }, ) .await?; Ok(true) } /// Count catalogue objects that store a value under field `key`. pub async fn count_objects_using_field<'e, E>(executor: E, key: &str) -> Result where E: sqlx::PgExecutor<'e>, { sqlx::query_scalar("SELECT count(*) FROM object WHERE jsonb_exists(fields, $1)") .bind(key) .fetch_one(executor) .await } /// Delete a field definition (labels cascade) unless catalogue objects use its key, /// recording a `deleted` audit entry. Pass a transaction connection. pub async fn delete_field_definition( conn: &mut sqlx::PgConnection, actor: AuditActor, key: &str, ) -> Result { let id: Option = sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1") .bind(key) .fetch_optional(&mut *conn) .await?; let Some(id) = id else { return Ok(crate::DeleteOutcome::NotFound); }; let count = count_objects_using_field(&mut *conn, key).await?; if count > 0 { return Ok(crate::DeleteOutcome::InUse { count }); } sqlx::query("DELETE FROM field_definition WHERE id = $1") .bind(id) .execute(&mut *conn) .await?; audit::record( &mut *conn, &NewAuditEvent { actor, action: AuditAction::Deleted, entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(), entity_id: id, changes: Vec::new(), }, ) .await?; Ok(crate::DeleteOutcome::Deleted) }