Skip to content

Types

Field Manual Section 5 - Payload Specs

Tank brings a full type arsenal to the field. The Entity derive macro identifies the type you're using by inspecting its final path segment (the trailer). For example, std::collections::VecDeque, collections::VecDeque, or simply VecDeque all resolve to the same type: Value::List.

Tank maps ordinary Rust types (check the table below) to the closest column types each driver supports, falling back to generic representations when appropriate. Below is the standard mapping of Rust types to each driver's column type. The symbol ❌ indicates no native support at this time. Collection types may be emulated in some drivers using generic JSON or text representations.

Column Types

RustPostgresSQLiteMySQL/MariaDBDuckDBMongoDBScyllaDB/CassandraValkey/Redis
boolBOOLEANINTEGERBOOLEANBOOLEANBooleanBOOLEANString
i8, NonZeroI8SMALLINTINTEGERTINYINTTINYINTInt32TINYINTString
i16, NonZeroI16SMALLINTINTEGERSMALLINTSMALLINTInt32SMALLINTString
i32, NonZeroI32INTEGERINTEGERINTEGERINTEGERInt32INTString
i64, NonZeroI64BIGINTINTEGERBIGINTBIGINTInt64BIGINTString
i128, NonZeroI128NUMERIC(39)NUMERIC(39)HUGEINTVARINTString
u8, NonZeroU8SMALLINTINTEGERTINYINT UNSIGNEDUTINYINTInt32SMALLINTString
u16, NonZeroU16INTEGERINTEGERSMALLINT UNSIGNEDUSMALLINTInt32INTString
u32, NonZeroU32BIGINTINTEGERINTEGER UNSIGNEDUINTEGERInt64BIGINTString
u64, NonZeroU64NUMERIC(19)INTEGERBIGINT UNSIGNEDUBIGINTInt64VARINTString
u128, NonZeroU128NUMERIC(39)NUMERIC(39) UNSIGNEDUHUGEINTVARINTString
isize, NonZeroIsizeBIGINTINTEGERBIGINTBIGINTInt64BIGINTString
usize, NonZeroUsizeNUMERIC(19)INTEGERBIGINT UNSIGNEDUBIGINTInt64VARINTString
f32REALREALFLOATFLOATDoubleFLOATString
f64DOUBLEREALDOUBLEDOUBLEDoubleDOUBLEString
rust_decimal::DecimalNUMERICREALDECIMALDECIMALDoubleDECIMALString
tank::FixedDecimal<W,S>NUMERIC(W,S)REALDECIMAL(W,S)DECIMAL(W,S)DoubleDECIMALString
charCHAR(1)TEXTCHAR(1)CHAR(1)StringASCIIString
StringTEXTTEXTTEXT, VARCHAR(60) if pkTEXTStringTEXTString
Box<[u8]>BYTEABLOBBLOBBLOBBinaryBLOBString
time::DateDATETEXT ⚠️DATEDATEDateDATEString
time::TimeTIMETEXT ⚠️TIME(6)TIMEString ⚠️TIMEString
time::PrimitiveDateTimeTIMESTAMPTEXT ⚠️DATETIMETIMESTAMPDateTimeTIMESTAMPString
time::UtcDateTimeTIMESTAMPTEXT ⚠️DATETIMETIMESTAMPDateTimeTIMESTAMPString
time::OffsetDateTimeTIMESTAMPTZTEXT ⚠️DATETIMETIMESTAMPTZDateTimeTIMESTAMPString
std::time::DurationINTERVALTIME(6)INTERVALDURATIONString
time::DurationINTERVALTIME(6)INTERVALDURATIONString
tank::IntervalINTERVALTIME(6)INTERVALDURATIONString
uuid::UuidUUIDTEXTCHAR(36)UUIDUuidUUIDString
[T; N]T[N]JSON ⚠️T[N]ArrayVECTOR<T,N>List
VecDeque<T>T[]JSON ⚠️T[]ArrayLIST<T>List
LinkedList<T>T[]JSON ⚠️T[]ArrayLIST<T>List
Vec<T>T[]JSON ⚠️T[]ArrayLIST<T>List
HashMap<K,V>JSON ⚠️MAP(K,V)DocumentMAP<K,V>Hash
BTreeMap<K,V>JSON ⚠️MAP(K,V)DocumentMAP<K,V>Hash

WARNING

When a type falls back to a generic representation (like TEXT or JSON), Tank encodes it predictably such that equality and ordering comparisons (where meaningful) behave as expected. Advanced indexing or operator support may vary by driver.

The special isize/usize types map to the native pointer-width integer (64-bit on 64-bit targets, 32-bit on 32-bit targets). For cross-database portability prefer explicit i64/u64 unless you truly need platform width.

Wrapper Types

Built-in wrappers you can use directly in entities, the SQL type is inferred from the inner type:

  • Option<T>: Nullable column.
  • Box<T>
  • Cell<T>
  • RefCell<T>
  • RwLock<T>
  • Arc<T>
  • Rc<T>

Custom Types

To handle custom types you just need to implement tank::AsValue. It will be your conversion contract: it turns your Rust type into a tank::Value that can be sent to the database, and turns a tank::Value back into the original when decoding rows. Once implemented, you can use the type directly as an Entity field.

Example: Custom Struct

Here’s a host:port example that encodes a network address as a string that must be stored in a single column:

rust
use tank::{AsValue, Error, Result, Value};

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostPort {
    pub host: String,
    pub port: u16,
}

impl AsValue for HostPort {
    fn as_empty_value() -> Value {
        Value::Varchar(None)
    }

    fn as_value(self) -> Value {
        Value::Varchar(Some(format!("{}:{}", self.host, self.port).into()))
    }

    fn try_from_value(value: Value) -> Result<Self>
    where
        Self: Sized,
    {
        // Always call try_as before checking the received value
        match value.try_as(&Value::Varchar(None)) {
            Ok(Value::Varchar(Some(v))) => {
                let context = || Error::msg(format!("Failed to parse HostPort from value `{v}`"));
                let (host, port) = v.split_once(':').ok_or_else(context)?;
                Ok(Self {
                    host: host.to_string(),
                    port: port
                        .parse::<u16>()
                        .map_err(|e| Error::new(e).context(context()))?,
                })
            }
            _ => Err(Error::msg(
                "Could not convert value into HostPort (expected Value::Varchar)",
            )),
        }
    }
}

Example: Conversion Type

When the custom type lives outside your crate or when you want to change the serialization logic for known types, you can use a conversion wrapper type that implements AsValue.

rust
use anyhow::Context;
use reqwest::Method;
use std::{any, str::FromStr};

#[derive(Clone, PartialEq, Eq, tank::Entity, Debug)]
pub struct Request {
    #[tank(primary_key)]
    pub id: i64,
    pub target: String,
    #[tank(conversion_type = MethodWrap)]
    pub method: Method,
    pub begin_timestamp_ms: i64,
    pub end_timestamp_ms: Option<i64>,
}
#[derive(Clone, PartialEq, Eq, tank::Entity, Debug)]
pub struct RequestLimit {
    #[tank(primary_key)]
    pub id: i64,
    pub target_pattern: String,
    pub requests: u32,
    #[tank(conversion_type = MethodWrap)]
    pub method: Option<Method>,
    pub interval_ms: Option<u32>,
}

// Declare a local wrapper making it possible to implement `tank::AsValue`
pub struct MethodWrap(Option<Method>);
impl tank::AsValue for MethodWrap {
    fn as_empty_value() -> tank::Value {
        tank::Value::Varchar(None)
    }

    fn as_value(self) -> tank::Value {
        self.0.map(|v| v.to_string()).as_value()
    }

    fn try_from_value(value: tank::Value) -> tank::Result<Self>
    where
        Self: Sized,
    {
        if value.is_null() {
            return Ok(Self(None));
        }
        // Always call try_as before checking the received value
        match value.try_as(&tank::Value::Varchar(None)) {
            Ok(tank::Value::Varchar(Some(v), ..)) => {
                let method = Method::from_str(&v).with_context(|| {
                    format!("Could not convert {v:?} into {}", any::type_name::<Method>())
                })?;

                Ok(method.into())
            }
            _ => Err(tank::Error::msg(format!(
                "Could not convert value into {}",
                any::type_name::<Method>()
            ))),
        }
    }
}

// Implement conversion logic for each type this method has to convert
impl From<Method> for MethodWrap {
    fn from(value: Method) -> Self {
        Self(Some(value))
    }
}
impl From<MethodWrap> for Method {
    fn from(value: MethodWrap) -> Self {
        value
            .0
            .expect("Unexpected error: no value stored in this MethodWrap object")
    }
}
impl From<Option<Method>> for MethodWrap {
    fn from(value: Option<Method>) -> Self {
        Self(value)
    }
}
impl From<MethodWrap> for Option<Method> {
    fn from(value: MethodWrap) -> Self {
        value.0
    }
}

TIP

Keep the encoding stable. Your as_value output becomes the output format for that field.

With this arsenal, your entities hit every target, every time.

Released under the Apache-2.0 license.