Wed 10 July 2024
UUIDv7 TypeIDs in Diesel
In a project I'm putting together, I made the choice to utilize UUIDs as identifiers early on. PostgreSQL has strong support for them, and if you're using UUIDv7, you avoid some of the issues that existed with earlier versions regarding indexing and sortability. In representing these IDs, it's common nowadays to want to utilize "Stripe-style" identifiers, something akin to:
// {slug}_{base32encodedUUID}
user_1j2d6y6h2f29awcs1fz7axq8s
On the Postgres side, as of writing this you'll need to add and load an extension for generating UUIDv7 identifiers - or you can search around for a (potentially slower) PSQL creation function.
TypeIDs in Diesel
When representing these identifiers in diesel backed types, I wanted a type that wrapped uuid::Uuid
and was specific to the type and necessary {slug}_
prefix. I came up with the following solution:
/// A generic struct that we can implement the prefix over.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UserIDPrefix;
impl TypedIDPrefix for UserIDPrefix {
/// The prefix that this should use for public representation.
const PREFIX: &'static str = "user";
}
/// Export the complete type for use everywhere else.
pub type UserID = TypedID<UserIDPrefix>;
Your model or struct might then look as simple as:
#[derive(Clone, Identifiable, Queryable, Selectable)]
#[diesel(table_name = crate::schema::users)]
pub struct User {
pub id: UserID,
pub name: String,
pub email: String,
// etc...
}
TypedID
itself is a type that transparently handles the following scenarios:
- Loading UUIDs from Postgres via Diesel
- Writing the UUID back to Postgres, or using it in queries
- Serializing the value in "public" form, with the
{slug_ base32encodedUUID}
format - Deserializing the value from the "public" form into the underlying
uuid::Uuid
.
With the serde implementations, you can also use this type in HTTP handlers when using frameworks like axum.
Note that this also uses the
fast32
crate for base32 conversion. You can, of course, substitute whatever you'd like here.
use std::fmt;
use std::hash::Hash;
use std::io::Write;
use std::marker::PhantomData;
use std::ops::Deref;
use diesel::deserialize::FromSql;
use diesel::pg::{Pg, PgValue};
use diesel::serialize::{IsNull, Output, ToSql};
use diesel::sql_types::Uuid as PgUuid;
use diesel::*;
use fast32::base32::CROCKFORD_LOWER;
use serde::{Serialize, Serializer};
use serde::de::{self, Deserialize, Deserializer, Unexpected};
use uuid::Uuid;
/// A trait for denoting the prefix that a `TypedID<>`
/// should use when displaying/formatting.
pub trait TypedIDPrefix: Clone + Copy + fmt::Debug {
/// The prefix to use in IDs, 4 characters or less.
///
/// (Ideally 4 characters)
const PREFIX: &'static str;
}
/// A `TypedID` is made up of two parts:
///
/// - A `prefix`, a 4-character `&'static str` that's prepended
/// - A (base32 encoded) `uuid` (v7) that forms the back half of the ID
///
/// The ID effectively ends up looking like (e.g):
///
/// ```no_run
/// user_3LKQhvGUcADgqoEM3bh6psl
/// ```
#[derive(Clone, Copy, Debug, PartialEq, FromSqlRow, AsExpression, Eq)]
#[diesel(sql_type = PgUuid)]
pub struct TypedID<PrefixType>(PhantomData<PrefixType>, Uuid);
impl<PrefixType> fmt::Display for TypedID<PrefixType>
where
PrefixType: TypedIDPrefix,
{
/// Formats the value.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}_{}", PrefixType::PREFIX, CROCKFORD_LOWER.encode_uuid(self.1))
}
}
impl<PrefixType> Hash for TypedID<PrefixType>
where
PrefixType: TypedIDPrefix,
{
/// Enables using this type as a hashable value.
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.0.hash(PrefixType::PREFIX);
self.1.hash(state);
}
}
impl<PrefixType> Serialize for TypedID<PrefixType>
where
PrefixType: TypedIDPrefix,
{
/// Serializes the identifier as a String, in the form of
/// `{prefix}_{base32encodeduuidv7}`.
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let id = self.to_string();
serializer.serialize_str(&id)
}
}
impl<'de, PrefixType> Deserialize<'de> for TypedID<PrefixType>
where
PrefixType: TypedIDPrefix,
{
/// Attempts to deserialize the value. This checks the prefix on
/// an ID string before attempting to deserialize the actual UUID portion.
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
// @TODO: Could this ever be &str?
let value: String = Deserialize::deserialize(deserializer)?;
for (index, part) in value.split("_").enumerate() {
if index == 0 {
if !part.starts_with(PrefixType::PREFIX) {
return Err(de::Error::invalid_value(
Unexpected::Str(&value),
&PrefixType::PREFIX
));
}
continue;
}
return match CROCKFORD_LOWER.decode_uuid_str(part) {
Ok(id) => Ok(Self(PhantomData, id)),
Err(error) => Err(de::Error::custom(error))
};
}
Err(de::Error::invalid_value(Unexpected::Str(&value), &PrefixType::PREFIX))
}
}
impl<PrefixType> ToSql<PgUuid, Pg> for TypedID<PrefixType>
where
PrefixType: TypedIDPrefix,
{
/// Writes the underlying uuid bytes to the output.
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
out.write_all(self.1.as_bytes())
.map(|_| IsNull::No)
.map_err(Into::into)
}
}
impl<PrefixType> FromSql<PgUuid, Pg> for TypedID<PrefixType>
where
PrefixType: TypedIDPrefix,
{
/// Convert from the SQL byte payload into our custom type.
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
match Uuid::from_slice(bytes.as_bytes()) {
Ok(id) => Ok(Self(PhantomData, id)),
// For whatever reason, Diesel is able to just call `Into::into`
// for this error - but doing that here fails?
//
// @TODO: Probably look into this at some point.
Err(error) => Err(error.to_string().into())
}
}
}
impl<PrefixType> Deref for TypedID<PrefixType>
where
PrefixType: TypedIDPrefix,
{
type Target = Uuid;
fn deref(&self) -> &Self::Target {
&self.1
}
}
impl<PrefixType> TypedID<PrefixType>
where
PrefixType: TypedIDPrefix,
{
/// Allocates and returns a new `String` in the
/// `{prefix}_{base32encodeduuidv7}` format.
pub fn to_string(&self) -> String {
format!("{}", self)
}
/// Returns a copy of the underlying `Uuid`.
pub fn uuid(&self) -> Uuid {
self.1
}
}