App Types on Composites
SBE composite types like Decimal { mantissa, exponent } have a fixed wire
layout. Mapping them to Rust domain types is done at configuration time — no
hand-rolled per-field converters needed.
Option A — generic converter (with_conversion): the generated codec
emits price_from<T: TryToSbe<Decimal>>() and price_as<T: TryFromSbe<Decimal>>().
You implement the trait for any app type. One wire type, many app types.
Option B — concrete mapping (with_domain_type): the generated codec
emits price(rust_decimal::Decimal)? and price()? -> rust_decimal::Decimal.
Exactly one Rust type per wire type. The trait impls are generated for you.
Option C — generated mapping (with_enum_mapping, with_composite_mapping):
for an enum or composite whose application counterpart has the same shape,
ergo-sbe writes the TryFromSbe / TryToSbe impls too. See below.
Full comparison with code examples: with_conversion vs with_domain_type.
Generated mappings
An application enum that mirrors an SBE enum, and a struct that mirrors a composite, need no hand-written conversion:
GenerationConfig::new(module)
.with_enum_mapping(
EnumMapping::new("Side", "app::Side")
.rename("Buy", "Bid")
.rename("Sell", "Ask"),
)
.with_composite_mapping(CompositeMapping::new("Price", "app::Px"))
.with_composite_mapping(CompositeMapping::new("Leg", "app::Leg").rename("qty", "quantity"))
pub mod app {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side { Bid, Ask, SellShort }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Px { pub mantissa: i64, pub exponent: i8 }
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Leg {
pub price: Px,
pub side: Side,
pub quantity: u32,
pub min_qty: Option<u32>,
pub ratio: Option<f64>,
pub flags: super::Flags,
pub venue: [u8; 4],
}
}
(From sbe/tests/type_mapping_test.rs, which generates, compiles and runs
these against the plain codec.)
Each mapping also acts as a with_domain_type for its type, so every field of
that type takes the application type: dec.side()? -> app::Side,
enc.leg(leg)?, and the application type in a domain DTO (a DTO field of an
enum named BooleanType, or with semanticType="Boolean", stays bool, as
under with_domain_type). A presence="constant" field of a mapped enum has
the same domain getter beside its *_wire getter, and no setter. The wire
bytes are the ones the plain codec writes.
A mapped setter’s error is sbe_rt::MappingError, which converts into
sbe_rt::EncodeError, so enc.leg(leg)? works inside an encode or send
closure. A domain DTO’s encode and encode_from report the same refusal as
EncodeError::Conversion, with the field’s path and the index of its entry
in each group on the way (fills[1].leg) and the MappingError itself.
Enum (EnumMapping). A schema variant maps to the application variant of
the same generated name (sellShort → SellShort) unless renamed. Both
impls are exhaustive matches, so an application enum that lacks a schema
variant, or has one the schema lacks, does not compile.
Composite (CompositeMapping). TryFromSbe is a struct literal built
from the composite’s getters, TryToSbe calls the composite’s new; a
member fills the field of the same snake-case name unless renamed. Constant
members have no field (they are not on the wire). An optional scalar member is
an Option field: None writes the member’s null value, and Some of the
null value — which would read back as None — is refused. A member whose enum
or composite has its own generated mapping uses that mapping’s application
type (price: app::Px, side: app::Side above); every other member keeps its
generated wire type. A struct missing a member’s field, or with a field no
member fills, does not compile.
NullVal and unknown values
NullVal has no application value, so decoding it is an explicit
sbe_rt::MappingError::NullEnum, never a default variant. The generated
decoder folds every discriminant the schema does not declare into NullVal
before the mapping sees it, so an unknown value is the same error: the mapping
cannot tell it from the schema null, and only the decoder’s raw_<field>()
still reads the wire byte. With with_null_as_option on the enum, both read as
Ok(None) instead:
let side_at = dm::OrderEncoder::HEADER_LENGTH + 8;
assert_eq!(buf[side_at], 5, "sellShort's discriminant");
for raw in [0u8, 9] {
// 0 is NullVal; 9 is undeclared and folds into NullVal on decode.
buf[side_at] = raw;
let dec = dm::OrderDecoder::decode(&buf, 0)?;
assert_eq!(dec.side_wire(), dm::Side::NullVal);
assert_eq!(dec.side(), Err(dm::sbe_rt::MappingError::NullEnum { enum_name: "Side" }));
assert!(dm::OrderDomain::try_from_slice_with_header(&buf[..len], 0).is_err());
// with_null_as_option: absent instead of an error — for the
// undeclared 9 too, the folding limitation.
let dec = dmo::OrderDecoder::decode(&buf, 0)?;
assert_eq!(dec.side(), Ok(None));
}
buf[side_at] = 2;
assert_eq!(dmo::OrderDecoder::decode(&buf, 0)?.side(), Ok(Some(dmo::app::Side::Ask)));
On a presence="optional" field, NullVal is not an error but the field
being absent, so a mapped enum that such a field uses needs
with_null_as_option: without it, generation fails with
GenerateError::InvalidConfiguration naming the field, instead of every
valid frame that leaves the field out failing to decode.
Optional composites with a null image
A composite member itself can carry presence="optional" with a schema
nullValue — e.g. a PriceNull9-style Decimal whose mantissa is optional
while exponent stays constant. That member decodes as Option<i64>
(checked against the wire null sentinel), not a bare i64. This is distinct
from a composite field gated by sinceVersion (which the whole composite
accessor wraps in Option<Decoder>), and from a composite with no nullValue
anywhere (which has no null image to check, so it decodes as a plain value —
see with_conversion vs with_domain_type).
Because the wire null sentinel is not a valid rust_decimal::Decimal, the
with_domain_type accessor fails closed with a typed error rather than
silently decoding the sentinel as a huge/wrong number:
use rust_decimal::Decimal;
// `price: None` writes the schema null image (mantissa = nullValue).
let mut buf = [0u8; QuoteEncoder::compute_length_with_header()];
let len = QuoteEncoder::wrap_and_apply_header(&mut buf, 0)
.fixed(&QuoteFixedFields { price: None, qty: 7 })
.encoded_length_with_header();
let dec = QuoteDecoder::try_decode(&buf[..len], 0)?;
assert_eq!(dec.price_value().mantissa(), None);
assert!(dec.price().is_err(), "null mantissa is not a valid Decimal");
// A real price round-trips through the null-aware member accessor.
let len2 = QuoteEncoder::wrap_and_apply_header(&mut buf, 0)
.fixed(&QuoteFixedFields { price: None, qty: 7 })
.price(Decimal::new(12345, 9))?
.encoded_length_with_header();
let dec2 = QuoteDecoder::try_decode(&buf[..len2], 0)?;
assert_eq!(dec2.price_value().mantissa(), Some(12345));
assert_eq!(dec2.price()?, Decimal::new(12345, 9));
(From optional_composite_member_null_image_roundtrip in
sbe/tests/baseline_test.rs — a real generated-and-compiled test, not a
standalone snippet.)