feat(domain): id macro + vocabulary/authority/label value types

This commit is contained in:
2026-06-02 08:38:39 +02:00
parent 42e0a5f5f1
commit 8cf737d8a9
5 changed files with 260 additions and 44 deletions
+86
View File
@@ -0,0 +1,86 @@
use serde::{Deserialize, Serialize};
use crate::{AuthorityId, LocalizedLabel};
/// The kind of authority record.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthorityKind {
Person,
Organisation,
Place,
}
impl AuthorityKind {
pub fn as_str(&self) -> &'static str {
match self {
AuthorityKind::Person => "person",
AuthorityKind::Organisation => "organisation",
AuthorityKind::Place => "place",
}
}
pub fn from_db(s: &str) -> Option<Self> {
match s {
"person" => Some(AuthorityKind::Person),
"organisation" => Some(AuthorityKind::Organisation),
"place" => Some(AuthorityKind::Place),
_ => None,
}
}
}
/// An authority record (person / organisation / place), with multilingual labels.
#[derive(Debug, Clone, PartialEq)]
pub struct Authority {
pub id: AuthorityId,
pub kind: AuthorityKind,
pub external_uri: Option<String>,
pub labels: Vec<LocalizedLabel>,
}
/// An authority to be created.
#[derive(Debug, Clone, PartialEq)]
pub struct NewAuthority {
pub kind: AuthorityKind,
pub external_uri: Option<String>,
pub labels: Vec<LocalizedLabel>,
}
/// A reference to an authority confirmed to exist (carries its kind).
///
/// Obtain via `db::authority::resolve_authority`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthorityRef {
authority_id: AuthorityId,
kind: AuthorityKind,
}
impl AuthorityRef {
pub fn new(authority_id: AuthorityId, kind: AuthorityKind) -> Self {
Self { authority_id, kind }
}
pub fn authority_id(&self) -> AuthorityId {
self.authority_id
}
pub fn kind(&self) -> AuthorityKind {
self.kind
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_round_trips_via_db_string() {
for k in [
AuthorityKind::Person,
AuthorityKind::Organisation,
AuthorityKind::Place,
] {
assert_eq!(AuthorityKind::from_db(k.as_str()), Some(k));
}
assert_eq!(AuthorityKind::from_db("ufo"), None);
}
}
+52 -28
View File
@@ -1,54 +1,70 @@
use std::fmt;
use std::str::FromStr;
//! Strongly-typed identifiers.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Define a UUID newtype identifier with the standard constructors and conversions.
macro_rules! id_newtype {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct $name(uuid::Uuid);
/// Identifier for an organization (tenant).
///
/// A newtype over [`Uuid`] so it can never be confused with another entity's id.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct OrgId(Uuid);
impl OrgId {
impl $name {
/// Generate a fresh random id.
#[must_use = "generating an OrgId and discarding it is almost certainly a mistake"]
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
Self(uuid::Uuid::new_v4())
}
/// Wrap an existing [`Uuid`].
pub fn from_uuid(uuid: Uuid) -> Self {
/// Wrap an existing [`uuid::Uuid`].
pub fn from_uuid(uuid: uuid::Uuid) -> Self {
Self(uuid)
}
/// Return the underlying [`Uuid`].
pub fn to_uuid(&self) -> Uuid {
/// The underlying [`uuid::Uuid`].
pub fn to_uuid(&self) -> uuid::Uuid {
self.0
}
}
}
impl Default for OrgId {
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for OrgId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl FromStr for OrgId {
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}
impl std::str::FromStr for $name {
type Err = uuid::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(Uuid::parse_str(s)?))
Ok(Self(uuid::Uuid::parse_str(s)?))
}
}
};
}
id_newtype!(
/// Identifier for an organization (tenant).
OrgId
);
id_newtype!(
/// Identifier for a controlled vocabulary (term source).
VocabularyId
);
id_newtype!(
/// Identifier for a term within a vocabulary.
TermId
);
id_newtype!(
/// Identifier for an authority record (person, organisation, or place).
AuthorityId
);
#[cfg(test)]
mod tests {
use super::*;
@@ -64,4 +80,12 @@ mod tests {
fn rejects_invalid_uuid() {
assert!("not-a-uuid".parse::<OrgId>().is_err());
}
#[test]
fn distinct_id_types_parse_independently() {
let text = "550e8400-e29b-41d4-a716-446655440000";
assert_eq!(text.parse::<VocabularyId>().unwrap().to_string(), text);
assert_eq!(text.parse::<TermId>().unwrap().to_string(), text);
assert_eq!(text.parse::<AuthorityId>().unwrap().to_string(), text);
}
}
+48
View File
@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
/// A label in a specific language (BCP-47 tag, e.g. "sv", "en").
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedLabel {
pub lang: String,
pub label: String,
}
/// Pick the best label for `lang`, falling back to `fallback`, then the first.
pub fn pick_label<'a>(labels: &'a [LocalizedLabel], lang: &str, fallback: &str) -> Option<&'a str> {
labels
.iter()
.find(|l| l.lang == lang)
.or_else(|| labels.iter().find(|l| l.lang == fallback))
.or_else(|| labels.first())
.map(|l| l.label.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Vec<LocalizedLabel> {
vec![
LocalizedLabel {
lang: "sv".into(),
label: "trä".into(),
},
LocalizedLabel {
lang: "en".into(),
label: "wood".into(),
},
]
}
#[test]
fn prefers_requested_language() {
assert_eq!(pick_label(&sample(), "sv", "en"), Some("trä"));
}
#[test]
fn falls_back_then_first() {
assert_eq!(pick_label(&sample(), "de", "en"), Some("wood"));
assert_eq!(pick_label(&sample(), "de", "fr"), Some("trä"));
assert_eq!(pick_label(&[], "sv", "en"), None);
}
}
+7 -1
View File
@@ -1,7 +1,13 @@
//! Core domain types and invariants. No I/O dependencies.
mod audit;
mod authority;
mod id;
mod label;
mod vocabulary;
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
pub use id::OrgId;
pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
pub use id::{AuthorityId, OrgId, TermId, VocabularyId};
pub use label::{LocalizedLabel, pick_label};
pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary};
+52
View File
@@ -0,0 +1,52 @@
use serde::{Deserialize, Serialize};
use crate::{LocalizedLabel, TermId, VocabularyId};
/// A controlled vocabulary (term source), e.g. "material" or "object_name".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Vocabulary {
pub id: VocabularyId,
pub key: String,
}
/// A term within a vocabulary, with its multilingual labels.
#[derive(Debug, Clone, PartialEq)]
pub struct Term {
pub id: TermId,
pub vocabulary_id: VocabularyId,
pub external_uri: Option<String>,
pub labels: Vec<LocalizedLabel>,
}
/// A term to be created.
#[derive(Debug, Clone, PartialEq)]
pub struct NewTerm {
pub vocabulary_id: VocabularyId,
pub external_uri: Option<String>,
pub labels: Vec<LocalizedLabel>,
}
/// A reference to a term confirmed to exist in a given vocabulary.
///
/// Obtain via `db::vocab::resolve_term`; do not construct ad hoc for
/// values that haven't been resolved.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct TermRef {
term_id: TermId,
vocabulary_id: VocabularyId,
}
impl TermRef {
pub fn new(term_id: TermId, vocabulary_id: VocabularyId) -> Self {
Self {
term_id,
vocabulary_id,
}
}
pub fn term_id(&self) -> TermId {
self.term_id
}
pub fn vocabulary_id(&self) -> VocabularyId {
self.vocabulary_id
}
}