Using enums with Diesel

Using custom enums with Diesel requires implementation of two traits for the enum: FromSql and ToSql.

  • The FromSql pattern involves matching primitives and then constructing the enum.
  • The ToSql pattern involves matching the enum and then delegating construction to the primitive’s to_sql method.

Why

  • The database columns store raw bytes of data, so if we want to store data there, we need to represent it as raw bytes before writing it.
  • Diesel expects us to show it how we want to convert our enum into the bytes that our database wants.
  • We need to implement the FromSql and ToSql traits because there’s no direct correlation between a Rust enum and a Postgresql type (not even an enum type).
default's profile

While Postgres does have its own version of an enum, the way Rust understands enums and the way Postgresql understands enums is different, so we would need to manually handle conversion regardless.

What’s the alternative?

Converting straight to bytes tends to be complex and error-prone because we’d have to write to some buffers and possibly incorporate some unsafe stuff. As an example you should NOT follow, here’s how we might deal with this without using diesel’s traits:

// An unsafe, error-prone approach without Diesel
impl AccountType {
   fn to_raw_bytes(&self) -> Result<Vec<u8>, &'static str> {
       // Convert enum to i16 first
       let num: i16 = match *self {
           AccountType::Guest => 0,
           AccountType::Member => 1,
           AccountType::Moderator => 2,
           AccountType::Admin => 3,
       };
       
       // Create buffer - i16 needs 2 bytes
       let mut bytes = vec![0u8; 2];
       
       // Handle endianness (are we little or big?)
       // Different DBs might expect different byte orders
       let num_bytes = if cfg!(target_endian = "little") {
           num.to_le_bytes()
       } else {
           num.to_be_bytes()
       };
       
       // Copy bytes to our buffer
       // This could panic if sizes mismatch
       bytes.copy_from_slice(&num_bytes);
       
       // Hope we got alignment right
       // Hope we handled platform differences correctly
       // Hope we didn't mess up the byte ordering
       // ...and many more edge cases to worry about
       Ok(bytes)
   }
}

Using diesel, though, we can just:

impl<DB> ToSql<SmallInt, DB> for AccountType 
where 
    DB: Backend,
    i16: ToSql<SmallInt, DB>,
{
    fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, DB>) -> serialize::Result {
        match *self {
            AccountType::Guest => 0_i16.to_sql(out),
            AccountType::Member => 1_i16.to_sql(out),
            AccountType::Moderator => 2_i16.to_sql(out),
            AccountType::Admin => 3_i16.to_sql(out)
        }
    }
}

This lets us rely on diesel for the byte-level stuff so that we can focus on our domain logic.

explain's profile

Of course, this assumes you want to represent your enums as i16 types like I’m doing here. I like to use i16 for enums with a small set of variants because it’s a bit more memory efficient (and easier for me to intuit about).

Example

Let’s say we have an enum to represent some account’s type:

#[repr(i16)]
#[derive(AsExpression, Debug, Clone, Copy, Serialize, Deserialize, FromSqlRow)]
#[diesel(sql_type = SmallInt)]
pub enum AccountType {
    Guest = 0,
    Member = 1,
    Moderator = 2,
    Admin = 3
}

Here we configure the enum AccountType to be represented as an i16, which plays nicely with Postresql’s native i16 type.

To use this in query and insert statements, we must implement FromSql and ToSql respectively.

Making an enum queryable

To make this enum queryable, we implement FromSql for it. To do that, we’ll match on the i16 primitive coming from the database and return its corresponding enum equivalent:

impl<DB> FromSql<SmallInt, DB> for AccountType 
where 
    DB: Backend,
    i16: FromSql<SmallInt, DB>,
{
    fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result<Self> {
        match i16::from_sql(bytes)? {
            0 => Ok(AccountType::Guest),
            1 => Ok(AccountType::Member), 
            2 => Ok(AccountType::Moderator),
            3 => Ok(AccountType::Admin),
            x => Err(format!("Unrecognized variant {}", x).into())
        }
    }
}

Making an enum insertable

To make this enum insertable, we implement ToSql for it. To do that, we’ll match on the enum and then delegate to the primitive’s to_sql:

impl<DB> ToSql<SmallInt, DB> for AccountType
where
    DB: Backend,
    i16: ToSql<SmallInt, DB>, 
{
    fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, DB>) -> serialize::Result {
        match *self {
            AccountType::Guest => 0_i16.to_sql(out),
            AccountType::Member => 1_i16.to_sql(out),
            AccountType::Moderator => 2_i16.to_sql(out),
            AccountType::Admin => 3_i16.to_sql(out)
        }
    }
}