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
    }
}

Ryan around the Web