Skip to main content

ignite_client/
binary.rs

1//! Ergonomic layer over the binary-object codec (see `crate::protocol::binary`):
2//! the [`WriteBinary`]/[`ReadBinary`] traits for whole objects, the
3//! [`FieldWrite`]/[`FieldRead`] traits for individual fields, and newtypes for
4//! Ignite wire types with no direct Rust equivalent (CHAR, DATE, TIME,
5//! TIMESTAMP).
6//!
7//! This module is also the public `binary` facade: it re-exports the
8//! lower-level codec types ([`BinaryObject`], [`BinaryObjectBuilder`],
9//! [`BinaryObjectReader`]), the metadata model ([`BinaryType`],
10//! [`BinaryFieldMeta`], [`BinarySchemaMeta`]), and the id helpers
11//! ([`type_id`], [`field_id`], [`schema_id`]) alongside the traits and
12//! newtypes defined here.
13
14pub use crate::protocol::binary::metadata::{BinaryFieldMeta, BinarySchemaMeta, BinaryType};
15pub use crate::protocol::binary::reader::BinaryObjectReader;
16pub use crate::protocol::binary::value::BinaryObject;
17pub use crate::protocol::binary::writer::BinaryObjectBuilder;
18pub use crate::protocol::binary::{field_id, schema_id, type_id};
19pub use crate::protocol::types::type_code;
20pub use ignite_client_derive::IgniteBinary;
21
22use bigdecimal::BigDecimal;
23use uuid::Uuid;
24
25use crate::Result;
26use crate::protocol::error::{ProtocolError, value_type_name};
27use crate::protocol::types::IgniteValue;
28
29// ─── Newtypes ─────────────────────────────────────────────────────────────────
30//
31// Ignite has four wire types with no direct Rust equivalent (or that would be
32// ambiguous against an existing Rust type). Each wraps the field's exact wire
33// representation so `FieldWrite`/`FieldRead` can round-trip it unambiguously.
34
35/// Ignite CHAR (type code 7): a single UTF-16 code unit. Distinct from
36/// `String` (VARCHAR, type code 9) at the wire level.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
38pub struct IgniteChar(pub u16);
39
40/// Ignite DATE (type code 11): milliseconds since the Unix epoch.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
42pub struct IgniteDate(pub i64);
43
44/// Ignite TIME (type code 36): nanoseconds since midnight.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub struct IgniteTime(pub i64);
47
48/// Ignite TIMESTAMP (type code 33): `(milliseconds_since_epoch,
49/// nanosecond_fraction)`.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
51pub struct IgniteTimestamp(pub i64, pub i32);
52
53// ─── Traits ─────────────────────────────────────────────────────────────────
54
55/// Serialise `Self` as a whole Ignite binary (complex) object.
56pub trait WriteBinary {
57    /// The fully-qualified Ignite type name (used to derive the type id).
58    fn type_name() -> &'static str;
59
60    /// Write every field of `self` into `b`, returning the builder for
61    /// chaining.
62    fn write(&self, b: BinaryObjectBuilder) -> BinaryObjectBuilder;
63
64    /// Build a complete [`BinaryObject`] from `self`.
65    fn to_binary(&self) -> BinaryObject {
66        self.write(BinaryObjectBuilder::new(Self::type_name()))
67            .build()
68    }
69
70    /// The [`BinaryType`] metadata describing `Self`'s schema, suitable for
71    /// registration via `OP_BINARY_TYPE_PUT`.
72    fn binary_type() -> BinaryType;
73}
74
75/// Serialise a single field's value into a [`BinaryObjectBuilder`].
76pub trait FieldWrite {
77    /// Write `self` as field `name`, returning the builder for chaining.
78    fn write_field(&self, b: BinaryObjectBuilder, name: &str) -> BinaryObjectBuilder;
79
80    /// The Ignite wire type code for this field's value.
81    fn field_type_code() -> i32;
82}
83
84/// Deserialise a whole Ignite binary (complex) object into `Self`.
85pub trait ReadBinary: Sized {
86    fn read(r: &BinaryObjectReader) -> Result<Self>;
87}
88
89/// Deserialise a single named field from a [`BinaryObjectReader`].
90pub trait FieldRead: Sized {
91    fn read_field(r: &BinaryObjectReader, name: &str) -> Result<Self>;
92}
93
94// ─── Nested-object schema resolution ────────────────────────────────────────
95//
96// A nested `Object` field (a `#[derive(IgniteBinary)]`'d struct used as a
97// field of another) may itself be compact-footer-encoded on the wire — real
98// Ignite peers (e.g. the Java thin client) use compact footers throughout,
99// not just at the top level. Decoding a compact footer needs that nested
100// type's own schema (`field_ids` in declaration order), which can only be
101// discovered from the object's own bytes (its type id lives inside them) —
102// there is no way to know it ahead of time the way `IgniteCache::get_binary`
103// knows the *top-level* type before it has any bytes at all.
104//
105// `FieldRead::read_field` (generated by `#[derive(IgniteBinary)]` for nested
106// struct fields) is a plain synchronous function with no access to a
107// `ChannelRegistry` to fetch that schema over the network, and changing its
108// signature to thread one through would break every derived type's trait
109// impl. Instead, `get_binary` walks the whole object graph up front
110// (`prefetch_nested_schemas`), fetches every nested type's schema it finds,
111// and installs the result here via [`with_nested_schemas`] before running
112// the synchronous decode. Nested `FieldRead` impls then resolve compact
113// footers via [`nested_object_reader`] instead of calling
114// `BinaryObjectReader::new` directly.
115
116thread_local! {
117    static NESTED_SCHEMAS: std::cell::RefCell<std::collections::HashMap<(i32, i32), Vec<i32>>> =
118        std::cell::RefCell::new(std::collections::HashMap::new());
119}
120
121/// Makes `schemas` (keyed by `(type_id, schema_id)`, mapping to that
122/// schema's field ids in declaration order) available to
123/// [`nested_object_reader`] for the duration of `f`, then clears it.
124///
125/// `f` must be purely synchronous (no `.await`): this is thread-local
126/// state, so it only reliably survives a stretch of code that never yields
127/// back to the async runtime (which could resume the task on a different
128/// worker thread). `IgniteCache::get_binary` upholds this by populating the
129/// schemas from a prior `.await`-ing walk of the object graph, then calling
130/// this with just the synchronous `V::read(&reader)` decode as `f`.
131pub(crate) fn with_nested_schemas<F, R>(
132    schemas: std::collections::HashMap<(i32, i32), Vec<i32>>,
133    f: F,
134) -> R
135where
136    F: FnOnce() -> R,
137{
138    NESTED_SCHEMAS.with(|cell| *cell.borrow_mut() = schemas);
139    let result = f();
140    NESTED_SCHEMAS.with(|cell| cell.borrow_mut().clear());
141    result
142}
143
144/// Builds a [`BinaryObjectReader`] for a nested-object field's raw bytes
145/// (an [`IgniteValue::Object`] payload). Used by `#[derive(IgniteBinary)]`'s
146/// generated nested-field `FieldRead` impls instead of calling
147/// `BinaryObjectReader::new` directly, so a compact-footer nested object can
148/// resolve its schema from the map installed by `with_nested_schemas`
149/// rather than failing outright.
150///
151/// Tries a non-compact parse first (the common case for hand-built test
152/// frames, and cheap to rule out); only consults the nested-schema map if
153/// the frame turns out to need one.
154pub fn nested_object_reader(bytes: bytes::Bytes) -> Result<BinaryObjectReader> {
155    match BinaryObjectReader::new(bytes.clone()) {
156        Ok(r) => Ok(r),
157        Err(ProtocolError::CompactFooterNeedsSchema) => {
158            let mut hb = bytes.clone();
159            let header = crate::protocol::binary::header::BinaryHeader::read(&mut hb)?;
160            let key = (header.type_id, header.schema_id);
161            let schema = NESTED_SCHEMAS
162                .with(|cell| cell.borrow().get(&key).cloned())
163                .ok_or(ProtocolError::CompactFooterNeedsSchema)?;
164            Ok(BinaryObjectReader::with_schema(bytes, &schema)?)
165        }
166        Err(e) => Err(e.into()),
167    }
168}
169
170// ─── Leaf FieldWrite/FieldRead impls ────────────────────────────────────────
171//
172// `FieldRead::read_field` reads via `r.field(name)?`, matching the expected
173// `IgniteValue` variant. A present-but-wrong-variant field is
174// `ProtocolError::TypeMismatch`; a missing field is `ProtocolError::UnexpectedNull`
175// (callers who want a missing field to mean "no value" should read `Option<T>`
176// instead — see below).
177
178/// Implements `FieldWrite`/`FieldRead` for a `Copy` leaf type that maps
179/// directly onto a single-field `IgniteValue` variant.
180macro_rules! leaf_field {
181    ($rust_ty:ty, $variant:ident, $code:path, $expected:literal) => {
182        impl FieldWrite for $rust_ty {
183            fn write_field(&self, b: BinaryObjectBuilder, name: &str) -> BinaryObjectBuilder {
184                b.set_value(name, &IgniteValue::$variant(*self))
185            }
186
187            fn field_type_code() -> i32 {
188                $code as i32
189            }
190        }
191
192        impl FieldRead for $rust_ty {
193            fn read_field(r: &BinaryObjectReader, name: &str) -> Result<Self> {
194                match r.field(name)? {
195                    Some(IgniteValue::$variant(v)) => Ok(v),
196                    Some(other) => Err(ProtocolError::TypeMismatch {
197                        expected: $expected,
198                        got: value_type_name(&other),
199                    }
200                    .into()),
201                    None => Err(ProtocolError::UnexpectedNull.into()),
202                }
203            }
204        }
205    };
206}
207
208leaf_field!(bool, Bool, type_code::BOOL, "Bool");
209leaf_field!(i8, Byte, type_code::BYTE, "Byte");
210leaf_field!(i16, Short, type_code::SHORT, "Short");
211leaf_field!(i32, Int, type_code::INT, "Int");
212leaf_field!(i64, Long, type_code::LONG, "Long");
213leaf_field!(f32, Float, type_code::FLOAT, "Float");
214leaf_field!(f64, Double, type_code::DOUBLE, "Double");
215
216/// Implements `FieldWrite`/`FieldRead` for a single-field newtype wrapping an
217/// `IgniteValue` variant's inner type.
218macro_rules! newtype_field {
219    ($newtype:ty, $variant:ident, $code:path, $expected:literal) => {
220        impl FieldWrite for $newtype {
221            fn write_field(&self, b: BinaryObjectBuilder, name: &str) -> BinaryObjectBuilder {
222                b.set_value(name, &IgniteValue::$variant(self.0))
223            }
224
225            fn field_type_code() -> i32 {
226                $code as i32
227            }
228        }
229
230        impl FieldRead for $newtype {
231            fn read_field(r: &BinaryObjectReader, name: &str) -> Result<Self> {
232                match r.field(name)? {
233                    Some(IgniteValue::$variant(v)) => Ok(Self(v)),
234                    Some(other) => Err(ProtocolError::TypeMismatch {
235                        expected: $expected,
236                        got: value_type_name(&other),
237                    }
238                    .into()),
239                    None => Err(ProtocolError::UnexpectedNull.into()),
240                }
241            }
242        }
243    };
244}
245
246newtype_field!(IgniteChar, Char, type_code::CHAR, "Char");
247newtype_field!(IgniteDate, Date, type_code::DATE, "Date");
248newtype_field!(IgniteTime, Time, type_code::TIME, "Time");
249
250// IgniteTimestamp wraps two fields, so it needs a hand-written impl rather
251// than the single-field newtype macro.
252impl FieldWrite for IgniteTimestamp {
253    fn write_field(&self, b: BinaryObjectBuilder, name: &str) -> BinaryObjectBuilder {
254        b.set_value(name, &IgniteValue::Timestamp(self.0, self.1))
255    }
256
257    fn field_type_code() -> i32 {
258        type_code::TIMESTAMP as i32
259    }
260}
261
262impl FieldRead for IgniteTimestamp {
263    fn read_field(r: &BinaryObjectReader, name: &str) -> Result<Self> {
264        match r.field(name)? {
265            Some(IgniteValue::Timestamp(ms, ns)) => Ok(Self(ms, ns)),
266            Some(other) => Err(ProtocolError::TypeMismatch {
267                expected: "Timestamp",
268                got: value_type_name(&other),
269            }
270            .into()),
271            None => Err(ProtocolError::UnexpectedNull.into()),
272        }
273    }
274}
275
276impl FieldWrite for String {
277    fn write_field(&self, b: BinaryObjectBuilder, name: &str) -> BinaryObjectBuilder {
278        b.set_value(name, &IgniteValue::String(self.clone()))
279    }
280
281    fn field_type_code() -> i32 {
282        type_code::STRING as i32
283    }
284}
285
286impl FieldRead for String {
287    fn read_field(r: &BinaryObjectReader, name: &str) -> Result<Self> {
288        match r.field(name)? {
289            Some(IgniteValue::String(v)) => Ok(v),
290            Some(other) => Err(ProtocolError::TypeMismatch {
291                expected: "String",
292                got: value_type_name(&other),
293            }
294            .into()),
295            None => Err(ProtocolError::UnexpectedNull.into()),
296        }
297    }
298}
299
300impl FieldWrite for Uuid {
301    fn write_field(&self, b: BinaryObjectBuilder, name: &str) -> BinaryObjectBuilder {
302        b.set_value(name, &IgniteValue::Uuid(*self))
303    }
304
305    fn field_type_code() -> i32 {
306        type_code::UUID as i32
307    }
308}
309
310impl FieldRead for Uuid {
311    fn read_field(r: &BinaryObjectReader, name: &str) -> Result<Self> {
312        match r.field(name)? {
313            Some(IgniteValue::Uuid(v)) => Ok(v),
314            Some(other) => Err(ProtocolError::TypeMismatch {
315                expected: "Uuid",
316                got: value_type_name(&other),
317            }
318            .into()),
319            None => Err(ProtocolError::UnexpectedNull.into()),
320        }
321    }
322}
323
324impl FieldWrite for BigDecimal {
325    fn write_field(&self, b: BinaryObjectBuilder, name: &str) -> BinaryObjectBuilder {
326        b.set_value(name, &IgniteValue::Decimal(self.clone()))
327    }
328
329    fn field_type_code() -> i32 {
330        type_code::DECIMAL as i32
331    }
332}
333
334impl FieldRead for BigDecimal {
335    fn read_field(r: &BinaryObjectReader, name: &str) -> Result<Self> {
336        match r.field(name)? {
337            Some(IgniteValue::Decimal(v)) => Ok(v),
338            Some(other) => Err(ProtocolError::TypeMismatch {
339                expected: "Decimal",
340                got: value_type_name(&other),
341            }
342            .into()),
343            None => Err(ProtocolError::UnexpectedNull.into()),
344        }
345    }
346}
347
348/// `None` writes as `IgniteValue::Null` (the field is present but null,
349/// rather than omitted) so the schema always includes it. On read, both a
350/// null value and a missing field map to `None`.
351impl<T: FieldWrite> FieldWrite for Option<T> {
352    fn write_field(&self, b: BinaryObjectBuilder, name: &str) -> BinaryObjectBuilder {
353        match self {
354            Some(v) => v.write_field(b, name),
355            None => b.set_value(name, &IgniteValue::Null),
356        }
357    }
358
359    fn field_type_code() -> i32 {
360        T::field_type_code()
361    }
362}
363
364impl<T: FieldRead> FieldRead for Option<T> {
365    fn read_field(r: &BinaryObjectReader, name: &str) -> Result<Self> {
366        match r.field(name)? {
367            None | Some(IgniteValue::Null) => Ok(None),
368            Some(_) => Ok(Some(T::read_field(r, name)?)),
369        }
370    }
371}
372
373// ─── Collection / array / map FieldWrite / FieldRead impls ─────────────────
374//
375// Java's `AllTypes` has both `int[]` and `List<Integer>`, which are BOTH
376// `Vec<i32>` in Rust — an ambiguity resolved by giving each Ignite wire shape
377// a distinct Rust type: `Vec<i32>` maps to the Ignite primitive INT_ARRAY,
378// while [`IgniteList<i32>`] maps to a Java `Collection` (`List`/`Set`).
379//
380// As with the leaf impls above, a present-but-wrong-variant field is a
381// `TypeMismatch`, and a missing field is `UnexpectedNull` (these are all
382// required-field reads; wrap in `Option<T>` for an optional field).
383
384impl FieldWrite for Vec<i32> {
385    fn write_field(&self, b: BinaryObjectBuilder, name: &str) -> BinaryObjectBuilder {
386        b.set_value(name, &IgniteValue::IntArray(self.clone()))
387    }
388
389    fn field_type_code() -> i32 {
390        type_code::INT_ARRAY as i32
391    }
392}
393
394impl FieldRead for Vec<i32> {
395    fn read_field(r: &BinaryObjectReader, name: &str) -> Result<Self> {
396        match r.field(name)? {
397            Some(IgniteValue::IntArray(v)) => Ok(v),
398            Some(other) => Err(ProtocolError::TypeMismatch {
399                expected: "IntArray",
400                got: value_type_name(&other),
401            }
402            .into()),
403            None => Err(ProtocolError::UnexpectedNull.into()),
404        }
405    }
406}
407
408/// Maps onto Ignite's STRING_ARRAY, which is nullable-element
409/// (`Vec<Option<String>>` at the [`IgniteValue`] level). Writing produces an
410/// array with every element present (`Some`); reading a null element is a
411/// `TypeMismatch` (expected `"String"`, got `"Null"`) since `Vec<String>` has
412/// no representation for an absent element — use `Vec<Option<String>>`
413/// directly (not yet implemented) if nulls must round-trip.
414impl FieldWrite for Vec<String> {
415    fn write_field(&self, b: BinaryObjectBuilder, name: &str) -> BinaryObjectBuilder {
416        let arr: Vec<Option<String>> = self.iter().cloned().map(Some).collect();
417        b.set_value(name, &IgniteValue::StringArray(arr))
418    }
419
420    fn field_type_code() -> i32 {
421        type_code::STRING_ARRAY as i32
422    }
423}
424
425impl FieldRead for Vec<String> {
426    fn read_field(r: &BinaryObjectReader, name: &str) -> Result<Self> {
427        match r.field(name)? {
428            Some(IgniteValue::StringArray(v)) => v
429                .into_iter()
430                .map(|e| {
431                    e.ok_or_else(|| {
432                        ProtocolError::TypeMismatch {
433                            expected: "String",
434                            got: "Null",
435                        }
436                        .into()
437                    })
438                })
439                .collect(),
440            Some(other) => Err(ProtocolError::TypeMismatch {
441                expected: "StringArray",
442                got: value_type_name(&other),
443            }
444            .into()),
445            None => Err(ProtocolError::UnexpectedNull.into()),
446        }
447    }
448}
449
450impl FieldWrite for std::collections::HashMap<String, i32> {
451    fn write_field(&self, b: BinaryObjectBuilder, name: &str) -> BinaryObjectBuilder {
452        let pairs: Vec<(IgniteValue, IgniteValue)> = self
453            .iter()
454            .map(|(k, v)| (IgniteValue::String(k.clone()), IgniteValue::Int(*v)))
455            .collect();
456        // Map type 1 = HASH_MAP; iteration order is unspecified for a Rust
457        // `HashMap` anyway, so this only affects the raw type byte, not
458        // round-trip correctness.
459        b.set_value(name, &IgniteValue::Map(1, pairs))
460    }
461
462    fn field_type_code() -> i32 {
463        type_code::MAP as i32
464    }
465}
466
467impl FieldRead for std::collections::HashMap<String, i32> {
468    fn read_field(r: &BinaryObjectReader, name: &str) -> Result<Self> {
469        match r.field(name)? {
470            Some(IgniteValue::Map(_, pairs)) => pairs
471                .into_iter()
472                .map(|(k, v)| {
473                    let key = match k {
474                        IgniteValue::String(s) => s,
475                        other => {
476                            return Err(ProtocolError::TypeMismatch {
477                                expected: "String",
478                                got: value_type_name(&other),
479                            }
480                            .into());
481                        }
482                    };
483                    let val = match v {
484                        IgniteValue::Int(i) => i,
485                        other => {
486                            return Err(ProtocolError::TypeMismatch {
487                                expected: "Int",
488                                got: value_type_name(&other),
489                            }
490                            .into());
491                        }
492                    };
493                    Ok((key, val))
494                })
495                .collect(),
496            Some(other) => Err(ProtocolError::TypeMismatch {
497                expected: "Map",
498                got: value_type_name(&other),
499            }
500            .into()),
501            None => Err(ProtocolError::UnexpectedNull.into()),
502        }
503    }
504}
505
506/// A Java `Collection` (`List`/`Set`), distinct from the Ignite primitive
507/// array types. Java's `AllTypes` has both `int[]` (→ `Vec<i32>` /
508/// `IntArray`) and `List<Integer>` (→ `IgniteList<i32>` / `Collection`) —
509/// both would be `Vec<i32>` in Rust without this newtype, making the mapping
510/// ambiguous.
511///
512/// `FieldWrite`/`FieldRead` are implemented concretely for `IgniteList<i32>`
513/// rather than generically over `T: FieldWrite`/`FieldRead`, to avoid the
514/// complexity of a generic-over-element-trait impl for v1; extend with more
515/// concrete element types (or generalise) as the gate requires them.
516#[derive(Debug, Clone, PartialEq)]
517pub struct IgniteList<T>(pub Vec<T>);
518
519impl FieldWrite for IgniteList<i32> {
520    fn write_field(&self, b: BinaryObjectBuilder, name: &str) -> BinaryObjectBuilder {
521        let vals: Vec<IgniteValue> = self.0.iter().map(|x| IgniteValue::Int(*x)).collect();
522        // Collection type 2 = ARR_LIST.
523        b.set_value(name, &IgniteValue::Collection(2, vals))
524    }
525
526    fn field_type_code() -> i32 {
527        type_code::COLLECTION as i32
528    }
529}
530
531impl FieldRead for IgniteList<i32> {
532    fn read_field(r: &BinaryObjectReader, name: &str) -> Result<Self> {
533        match r.field(name)? {
534            Some(IgniteValue::Collection(_, vals)) => {
535                let items: Vec<i32> = vals
536                    .into_iter()
537                    .map(|v| match v {
538                        IgniteValue::Int(i) => Ok(i),
539                        other => Err(ProtocolError::TypeMismatch {
540                            expected: "Int",
541                            got: value_type_name(&other),
542                        }
543                        .into()),
544                    })
545                    .collect::<Result<Vec<i32>>>()?;
546                Ok(IgniteList(items))
547            }
548            Some(other) => Err(ProtocolError::TypeMismatch {
549                expected: "Collection",
550                got: value_type_name(&other),
551            }
552            .into()),
553            None => Err(ProtocolError::UnexpectedNull.into()),
554        }
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use crate::protocol::binary::{reader::BinaryObjectReader, writer::BinaryObjectBuilder};
562
563    #[test]
564    fn field_write_read_i32_and_string() {
565        let b = BinaryObjectBuilder::new("t.T");
566        let b = 42i32.write_field(b, "n");
567        let b = "hi".to_string().write_field(b, "s");
568        let obj = b.build();
569        let r = BinaryObjectReader::new(obj.bytes).unwrap();
570        assert_eq!(i32::read_field(&r, "n").unwrap(), 42);
571        assert_eq!(String::read_field(&r, "s").unwrap(), "hi");
572    }
573
574    #[test]
575    fn field_write_read_option_none() {
576        let b = Option::<i32>::None.write_field(BinaryObjectBuilder::new("t.T"), "n");
577        let r = BinaryObjectReader::new(b.build().bytes).unwrap();
578        assert_eq!(Option::<i32>::read_field(&r, "n").unwrap(), None);
579    }
580
581    #[test]
582    fn field_write_read_option_some() {
583        let b = Some(7i32).write_field(BinaryObjectBuilder::new("t.T"), "n");
584        let r = BinaryObjectReader::new(b.build().bytes).unwrap();
585        assert_eq!(Option::<i32>::read_field(&r, "n").unwrap(), Some(7));
586    }
587
588    #[test]
589    fn field_read_option_missing_field_is_none() {
590        // Field "n" is never written; reading it as Option must yield None
591        // (not an error) — distinct from the explicit-Null case above, which
592        // exercises `r.field(name)` returning `Some(IgniteValue::Null)`
593        // rather than `None`.
594        let obj = BinaryObjectBuilder::new("t.T").set_i32("other", 1).build();
595        let r = BinaryObjectReader::new(obj.bytes).unwrap();
596        assert_eq!(Option::<i32>::read_field(&r, "n").unwrap(), None);
597    }
598
599    #[test]
600    fn field_write_read_all_numeric_leaves() {
601        let b = BinaryObjectBuilder::new("t.T");
602        let b = true.write_field(b, "bo");
603        let b = 1i8.write_field(b, "i8");
604        let b = 2i16.write_field(b, "i16");
605        let b = 3i32.write_field(b, "i32");
606        let b = 4i64.write_field(b, "i64");
607        let b = 5.5f32.write_field(b, "f32");
608        let b = 6.5f64.write_field(b, "f64");
609        let obj = b.build();
610        let r = BinaryObjectReader::new(obj.bytes).unwrap();
611        assert_eq!(bool::read_field(&r, "bo").unwrap(), true);
612        assert_eq!(i8::read_field(&r, "i8").unwrap(), 1);
613        assert_eq!(i16::read_field(&r, "i16").unwrap(), 2);
614        assert_eq!(i32::read_field(&r, "i32").unwrap(), 3);
615        assert_eq!(i64::read_field(&r, "i64").unwrap(), 4);
616        assert_eq!(f32::read_field(&r, "f32").unwrap(), 5.5);
617        assert_eq!(f64::read_field(&r, "f64").unwrap(), 6.5);
618    }
619
620    #[test]
621    fn field_write_read_newtypes() {
622        let b = BinaryObjectBuilder::new("t.T");
623        let b = IgniteChar(65).write_field(b, "c");
624        let b = IgniteDate(1_705_276_800_000).write_field(b, "d");
625        let b = IgniteTime(1_234_567_890).write_field(b, "t");
626        let b = IgniteTimestamp(1_700_000_000_000, 123).write_field(b, "ts");
627        let obj = b.build();
628        let r = BinaryObjectReader::new(obj.bytes).unwrap();
629        assert_eq!(IgniteChar::read_field(&r, "c").unwrap(), IgniteChar(65));
630        assert_eq!(
631            IgniteDate::read_field(&r, "d").unwrap(),
632            IgniteDate(1_705_276_800_000)
633        );
634        assert_eq!(
635            IgniteTime::read_field(&r, "t").unwrap(),
636            IgniteTime(1_234_567_890)
637        );
638        assert_eq!(
639            IgniteTimestamp::read_field(&r, "ts").unwrap(),
640            IgniteTimestamp(1_700_000_000_000, 123)
641        );
642    }
643
644    #[test]
645    fn field_write_read_uuid_and_decimal() {
646        use std::str::FromStr;
647
648        let u = Uuid::new_v4();
649        let d = BigDecimal::from_str("12.34").unwrap();
650        let b = BinaryObjectBuilder::new("t.T");
651        let b = u.write_field(b, "u");
652        let b = d.write_field(b, "d");
653        let obj = b.build();
654        let r = BinaryObjectReader::new(obj.bytes).unwrap();
655        assert_eq!(Uuid::read_field(&r, "u").unwrap(), u);
656        assert_eq!(
657            BigDecimal::read_field(&r, "d").unwrap().normalized(),
658            d.normalized()
659        );
660    }
661
662    #[test]
663    fn field_read_missing_required_field_errors() {
664        let obj = BinaryObjectBuilder::new("t.T").build();
665        let r = BinaryObjectReader::new(obj.bytes).unwrap();
666        let err = i32::read_field(&r, "missing").unwrap_err();
667        assert!(matches!(
668            err,
669            crate::IgniteError::Protocol(ProtocolError::UnexpectedNull)
670        ));
671    }
672
673    #[test]
674    fn field_read_wrong_variant_is_type_mismatch() {
675        let obj = BinaryObjectBuilder::new("t.T").set_i32("n", 1).build();
676        let r = BinaryObjectReader::new(obj.bytes).unwrap();
677        let err = String::read_field(&r, "n").unwrap_err();
678        assert!(matches!(
679            err,
680            crate::IgniteError::Protocol(ProtocolError::TypeMismatch {
681                expected: "String",
682                got: "Int"
683            })
684        ));
685    }
686
687    // ─── Task 18a: collection/array/map field impls ────────────────────────
688
689    #[test]
690    fn field_write_read_vec_i32() {
691        let b = vec![1i32, -2, 3].write_field(BinaryObjectBuilder::new("t.T"), "a");
692        let r = BinaryObjectReader::new(b.build().bytes).unwrap();
693        assert_eq!(<Vec<i32>>::read_field(&r, "a").unwrap(), vec![1, -2, 3]);
694        assert_eq!(<Vec<i32>>::field_type_code(), type_code::INT_ARRAY as i32);
695    }
696
697    #[test]
698    fn field_write_read_vec_i32_empty() {
699        let b = Vec::<i32>::new().write_field(BinaryObjectBuilder::new("t.T"), "a");
700        let r = BinaryObjectReader::new(b.build().bytes).unwrap();
701        assert_eq!(<Vec<i32>>::read_field(&r, "a").unwrap(), Vec::<i32>::new());
702    }
703
704    #[test]
705    fn field_write_read_vec_string() {
706        let v = vec!["foo".to_string(), "bar".to_string()];
707        let b = v.write_field(BinaryObjectBuilder::new("t.T"), "s");
708        let r = BinaryObjectReader::new(b.build().bytes).unwrap();
709        assert_eq!(<Vec<String>>::read_field(&r, "s").unwrap(), v);
710        assert_eq!(
711            <Vec<String>>::field_type_code(),
712            type_code::STRING_ARRAY as i32
713        );
714    }
715
716    #[test]
717    fn field_read_vec_string_null_element_errors() {
718        // A StringArray containing a null element cannot round-trip into
719        // Vec<String> (which has no room for absent elements) — this must
720        // surface as a TypeMismatch rather than silently dropping/panicking.
721        let obj = BinaryObjectBuilder::new("t.T")
722            .set_string_array("s", &[Some("a".to_string()), None])
723            .build();
724        let r = BinaryObjectReader::new(obj.bytes).unwrap();
725        let err = <Vec<String>>::read_field(&r, "s").unwrap_err();
726        assert!(matches!(
727            err,
728            crate::IgniteError::Protocol(ProtocolError::TypeMismatch {
729                expected: "String",
730                got: "Null"
731            })
732        ));
733    }
734
735    #[test]
736    fn field_write_read_hashmap_string_i32() {
737        use std::collections::HashMap;
738
739        let mut m = HashMap::new();
740        m.insert("one".to_string(), 1i32);
741        m.insert("two".to_string(), 2i32);
742        let b = m.write_field(BinaryObjectBuilder::new("t.T"), "m");
743        let r = BinaryObjectReader::new(b.build().bytes).unwrap();
744        assert_eq!(<HashMap<String, i32>>::read_field(&r, "m").unwrap(), m);
745        assert_eq!(
746            <HashMap<String, i32>>::field_type_code(),
747            type_code::MAP as i32
748        );
749    }
750
751    #[test]
752    fn field_write_read_ignite_list_i32() {
753        let list = IgniteList(vec![10i32, 20, 30]);
754        let b = list.write_field(BinaryObjectBuilder::new("t.T"), "l");
755        let r = BinaryObjectReader::new(b.build().bytes).unwrap();
756        assert_eq!(IgniteList::<i32>::read_field(&r, "l").unwrap(), list);
757        assert_eq!(
758            IgniteList::<i32>::field_type_code(),
759            type_code::COLLECTION as i32
760        );
761    }
762
763    #[test]
764    fn field_read_vec_i32_missing_field_errors() {
765        let obj = BinaryObjectBuilder::new("t.T").build();
766        let r = BinaryObjectReader::new(obj.bytes).unwrap();
767        let err = <Vec<i32>>::read_field(&r, "missing").unwrap_err();
768        assert!(matches!(
769            err,
770            crate::IgniteError::Protocol(ProtocolError::UnexpectedNull)
771        ));
772    }
773
774    #[test]
775    fn field_read_hashmap_wrong_variant_is_type_mismatch() {
776        let obj = BinaryObjectBuilder::new("t.T").set_i32("m", 1).build();
777        let r = BinaryObjectReader::new(obj.bytes).unwrap();
778        use std::collections::HashMap;
779        let err = <HashMap<String, i32>>::read_field(&r, "m").unwrap_err();
780        assert!(matches!(
781            err,
782            crate::IgniteError::Protocol(ProtocolError::TypeMismatch {
783                expected: "Map",
784                got: "Int"
785            })
786        ));
787    }
788}