Skip to main content

mssql_types/
encode.rs

1//! TDS binary encoding for SQL values.
2//!
3//! This module provides encoding of Rust values into TDS wire format
4//! for transmission to SQL Server.
5
6// Allow expect() for chrono date construction with known-valid constant dates
7#![allow(clippy::expect_used)]
8
9use bytes::{BufMut, BytesMut};
10
11use crate::error::TypeError;
12use crate::value::SqlValue;
13
14/// Trait for encoding values to TDS binary format.
15pub trait TdsEncode {
16    /// Encode this value into the buffer in TDS format.
17    fn encode(&self, buf: &mut BytesMut) -> Result<(), TypeError>;
18
19    /// Get the TDS type ID for this value.
20    fn type_id(&self) -> u8;
21}
22
23impl TdsEncode for SqlValue {
24    fn encode(&self, buf: &mut BytesMut) -> Result<(), TypeError> {
25        match self {
26            SqlValue::Null => {
27                // NULL is represented by length indicator in most contexts
28                // For INTNTYPE, length 0 means NULL
29                Ok(())
30            }
31            SqlValue::Bool(v) => {
32                buf.put_u8(if *v { 1 } else { 0 });
33                Ok(())
34            }
35            SqlValue::TinyInt(v) => {
36                buf.put_u8(*v);
37                Ok(())
38            }
39            SqlValue::SmallInt(v) => {
40                buf.put_i16_le(*v);
41                Ok(())
42            }
43            SqlValue::Int(v) => {
44                buf.put_i32_le(*v);
45                Ok(())
46            }
47            SqlValue::BigInt(v) => {
48                buf.put_i64_le(*v);
49                Ok(())
50            }
51            SqlValue::Float(v) => {
52                buf.put_f32_le(*v);
53                Ok(())
54            }
55            SqlValue::Double(v) => {
56                buf.put_f64_le(*v);
57                Ok(())
58            }
59            SqlValue::String(s) => {
60                // Encode as UTF-16LE for NVARCHAR
61                encode_utf16_string(s, buf);
62                Ok(())
63            }
64            SqlValue::Binary(b) => {
65                // Length-prefixed binary data
66                if b.len() > u16::MAX as usize {
67                    return Err(TypeError::BufferTooSmall {
68                        needed: b.len(),
69                        available: u16::MAX as usize,
70                    });
71                }
72                buf.put_u16_le(b.len() as u16);
73                buf.put_slice(b);
74                Ok(())
75            }
76            #[cfg(feature = "decimal")]
77            SqlValue::Decimal(d) => {
78                encode_decimal(*d, buf);
79                Ok(())
80            }
81            #[cfg(feature = "decimal")]
82            SqlValue::Money(d) => encode_money(*d, buf),
83            #[cfg(feature = "decimal")]
84            SqlValue::SmallMoney(d) => encode_smallmoney(*d, buf),
85            #[cfg(feature = "uuid")]
86            SqlValue::Uuid(u) => {
87                encode_uuid(*u, buf);
88                Ok(())
89            }
90            #[cfg(feature = "chrono")]
91            SqlValue::Date(d) => encode_date(*d, buf),
92            #[cfg(feature = "chrono")]
93            SqlValue::Time(t) => {
94                encode_time(*t, buf);
95                Ok(())
96            }
97            #[cfg(feature = "chrono")]
98            SqlValue::DateTime(dt) => encode_datetime2(*dt, buf),
99            #[cfg(feature = "chrono")]
100            SqlValue::SmallDateTime(dt) => encode_smalldatetime(*dt, buf),
101            #[cfg(feature = "chrono")]
102            SqlValue::DateTimeOffset(dto) => encode_datetimeoffset(*dto, buf),
103            #[cfg(feature = "json")]
104            SqlValue::Json(j) => {
105                // JSON is sent as NVARCHAR string
106                let s = j.to_string();
107                encode_utf16_string(&s, buf);
108                Ok(())
109            }
110            SqlValue::Xml(x) => {
111                // XML is sent as UTF-16LE string
112                encode_utf16_string(x, buf);
113                Ok(())
114            }
115            SqlValue::Tvp(_) => {
116                // TVP encoding is handled at the RPC parameter level, not here.
117                // This method is for encoding the value data portion; TVPs have
118                // their own complex encoding structure that includes metadata.
119                // See tds-protocol crate for full TVP encoding.
120                Err(TypeError::UnsupportedConversion {
121                    from: "TvpData".to_string(),
122                    to: "raw bytes (use RPC parameter encoding)",
123                })
124            }
125        }
126    }
127
128    fn type_id(&self) -> u8 {
129        match self {
130            SqlValue::Null => 0x1F,        // NULLTYPE
131            SqlValue::Bool(_) => 0x32,     // BITTYPE
132            SqlValue::TinyInt(_) => 0x30,  // INT1TYPE
133            SqlValue::SmallInt(_) => 0x34, // INT2TYPE
134            SqlValue::Int(_) => 0x38,      // INT4TYPE
135            SqlValue::BigInt(_) => 0x7F,   // INT8TYPE
136            SqlValue::Float(_) => 0x3B,    // FLT4TYPE
137            SqlValue::Double(_) => 0x3E,   // FLT8TYPE
138            SqlValue::String(_) => 0xE7,   // NVARCHARTYPE
139            SqlValue::Binary(_) => 0xA5,   // BIGVARBINTYPE
140            #[cfg(feature = "decimal")]
141            SqlValue::Decimal(_) => 0x6C, // DECIMALTYPE
142            #[cfg(feature = "decimal")]
143            SqlValue::Money(_) => 0x6E, // MONEYNTYPE (8-byte payload)
144            #[cfg(feature = "decimal")]
145            SqlValue::SmallMoney(_) => 0x6E, // MONEYNTYPE (4-byte payload)
146            #[cfg(feature = "uuid")]
147            SqlValue::Uuid(_) => 0x24, // GUIDTYPE
148            #[cfg(feature = "chrono")]
149            SqlValue::Date(_) => 0x28, // DATETYPE
150            #[cfg(feature = "chrono")]
151            SqlValue::Time(_) => 0x29, // TIMETYPE
152            #[cfg(feature = "chrono")]
153            SqlValue::DateTime(_) => 0x2A, // DATETIME2TYPE
154            #[cfg(feature = "chrono")]
155            SqlValue::SmallDateTime(_) => 0x6F, // DATETIMENTYPE (4-byte payload)
156            #[cfg(feature = "chrono")]
157            SqlValue::DateTimeOffset(_) => 0x2B, // DATETIMEOFFSETTYPE
158            #[cfg(feature = "json")]
159            SqlValue::Json(_) => 0xE7, // NVARCHARTYPE (JSON as string)
160            SqlValue::Xml(_) => 0xF1,      // XMLTYPE
161            SqlValue::Tvp(_) => 0xF3,      // TVPTYPE
162        }
163    }
164}
165
166/// Encode a string as UTF-16LE with length prefix.
167pub fn encode_utf16_string(s: &str, buf: &mut BytesMut) {
168    let utf16: Vec<u16> = s.encode_utf16().collect();
169    let byte_len = utf16.len() * 2;
170
171    // Write byte length (not char length)
172    buf.put_u16_le(byte_len as u16);
173
174    // Write UTF-16LE bytes
175    for code_unit in utf16 {
176        buf.put_u16_le(code_unit);
177    }
178}
179
180/// Low-level TDS wire encoders shared across the workspace crates.
181///
182/// Internal plumbing reached cross-crate only via [`crate::__private`]; not
183/// public API and exempt from semver guarantees (see #242).
184pub(crate) mod sealed {
185    // Every encoder here is `#[cfg(feature = ...)]`-gated (uuid / decimal /
186    // chrono), so this module — and the glob imports of it — can be empty under
187    // `--no-default-features`.
188    #[allow(unused_imports)]
189    use super::*;
190
191    /// Encode a UUID in SQL Server's mixed-endian format.
192    ///
193    /// SQL Server stores UUIDs in a unique byte order:
194    /// - First 4 bytes: little-endian
195    /// - Next 2 bytes: little-endian
196    /// - Next 2 bytes: little-endian
197    /// - Last 8 bytes: big-endian (as-is)
198    #[cfg(feature = "uuid")]
199    pub fn encode_uuid(uuid: uuid::Uuid, buf: &mut BytesMut) {
200        let bytes = uuid.as_bytes();
201
202        // First group (4 bytes) - reverse for little-endian
203        buf.put_u8(bytes[3]);
204        buf.put_u8(bytes[2]);
205        buf.put_u8(bytes[1]);
206        buf.put_u8(bytes[0]);
207
208        // Second group (2 bytes) - reverse for little-endian
209        buf.put_u8(bytes[5]);
210        buf.put_u8(bytes[4]);
211
212        // Third group (2 bytes) - reverse for little-endian
213        buf.put_u8(bytes[7]);
214        buf.put_u8(bytes[6]);
215
216        // Last 8 bytes - big-endian (keep as-is)
217        buf.put_slice(&bytes[8..16]);
218    }
219
220    /// Encode a decimal value.
221    ///
222    /// TDS DECIMAL format:
223    /// - 1 byte: sign (0 = negative, 1 = positive)
224    /// - Remaining bytes: absolute value in little-endian
225    #[cfg(feature = "decimal")]
226    pub fn encode_decimal(decimal: rust_decimal::Decimal, buf: &mut BytesMut) {
227        let sign = if decimal.is_sign_negative() { 0u8 } else { 1u8 };
228        buf.put_u8(sign);
229
230        // Get the mantissa and encode as 128-bit integer
231        let mantissa = decimal.mantissa().unsigned_abs();
232        buf.put_u128_le(mantissa);
233    }
234
235    /// Rescale a decimal to MONEY's 4-decimal fixed-point representation.
236    ///
237    /// Returns the signed 128-bit integer representing the value multiplied by
238    /// 10_000. Excess precision past 4 decimal places is truncated toward zero.
239    #[cfg(feature = "decimal")]
240    fn decimal_to_money_cents(value: rust_decimal::Decimal) -> Result<i128, TypeError> {
241        let mantissa: i128 = value.mantissa();
242        let scale: u32 = value.scale();
243        if scale <= 4 {
244            let factor = 10_i128.pow(4 - scale);
245            mantissa.checked_mul(factor).ok_or(TypeError::OutOfRange {
246                target_type: "MONEY",
247            })
248        } else {
249            let factor = 10_i128.pow(scale - 4);
250            Ok(mantissa / factor)
251        }
252    }
253
254    /// Convert a decimal to the scaled i64 used on the MONEY wire.
255    ///
256    /// This is the shared pre-encoding step for both RPC parameter encoding and
257    /// TVP column encoding — each path knows how to frame the payload, but they
258    /// agree on how to derive the scaled integer from the decimal.
259    #[cfg(feature = "decimal")]
260    pub fn decimal_to_money_cents_i64(value: rust_decimal::Decimal) -> Result<i64, TypeError> {
261        let cents_i128 = decimal_to_money_cents(value)?;
262        i64::try_from(cents_i128).map_err(|_| TypeError::OutOfRange {
263            target_type: "MONEY",
264        })
265    }
266
267    /// Convert a decimal to the scaled i32 used on the SMALLMONEY wire.
268    #[cfg(feature = "decimal")]
269    pub fn decimal_to_smallmoney_cents_i32(value: rust_decimal::Decimal) -> Result<i32, TypeError> {
270        let cents_i128 = decimal_to_money_cents(value)?;
271        i32::try_from(cents_i128).map_err(|_| TypeError::OutOfRange {
272            target_type: "SMALLMONEY",
273        })
274    }
275
276    /// Encode a decimal as MONEY (8 bytes): the signed 64-bit scaled integer is
277    /// written as the high 32 bits LE followed by the low 32 bits LE, per
278    /// MS-TDS §2.2.5.5.1.2.
279    #[cfg(feature = "decimal")]
280    pub fn encode_money(value: rust_decimal::Decimal, buf: &mut BytesMut) -> Result<(), TypeError> {
281        let cents = decimal_to_money_cents_i64(value)?;
282        let high = (cents >> 32) as i32;
283        let low = (cents & 0xFFFF_FFFF) as u32;
284        buf.put_i32_le(high);
285        buf.put_u32_le(low);
286        Ok(())
287    }
288
289    /// Encode a decimal as SMALLMONEY (4 bytes): the signed 32-bit scaled integer
290    /// is written little-endian.
291    #[cfg(feature = "decimal")]
292    pub fn encode_smallmoney(
293        value: rust_decimal::Decimal,
294        buf: &mut BytesMut,
295    ) -> Result<(), TypeError> {
296        let cents = decimal_to_smallmoney_cents_i32(value)?;
297        buf.put_i32_le(cents);
298        Ok(())
299    }
300
301    /// Convert a NaiveDateTime to the DATETIME wire representation.
302    ///
303    /// Returns `(days_since_1900_i32, ticks_u32)` where each tick is 1/300 second.
304    /// This is the shared pre-encoding step for both RPC parameter encoding and
305    /// TVP column encoding.
306    #[cfg(feature = "chrono")]
307    pub fn datetime_to_legacy_days_ticks(dt: chrono::NaiveDateTime) -> (i32, u32) {
308        use chrono::Timelike;
309        let epoch = chrono::NaiveDate::from_ymd_opt(1900, 1, 1).expect("epoch 1900-01-01 is valid");
310        let days = (dt.date() - epoch).num_days() as i32;
311
312        let since_midnight = dt.time().num_seconds_from_midnight() as u64 * 1000
313            + u64::from(dt.time().nanosecond()) / 1_000_000;
314        // Convert ms → 1/300s ticks: ticks = round(ms * 300 / 1000) = round(ms * 3 / 10)
315        let ticks = ((since_midnight * 3 + 5) / 10) as u32;
316        (days, ticks)
317    }
318
319    /// Encode a DATETIME value (8 bytes): days since 1900 (`i32` LE) + time units
320    /// since midnight (`u32` LE) where each unit is 1/300 of a second.
321    #[cfg(feature = "chrono")]
322    pub fn encode_datetime_legacy(dt: chrono::NaiveDateTime, buf: &mut BytesMut) {
323        let (days, ticks) = datetime_to_legacy_days_ticks(dt);
324        buf.put_i32_le(days);
325        buf.put_u32_le(ticks);
326    }
327
328    /// Convert a NaiveDateTime to the SMALLDATETIME wire representation.
329    ///
330    /// Returns `(days_since_1900_u16, minutes_since_midnight_u16)`. Seconds are
331    /// rounded to the nearest minute (30s rounds up per SQL Server semantics); when
332    /// that rounding lands on or past 24:00 the carry propagates into the next day
333    /// — e.g. 23:59:45 → next day 00:00 — so the result stays within SQL Server's
334    /// valid minute range of 0..1439. Returns `Err` if the resulting date is
335    /// outside the SMALLDATETIME range (1900-01-01 through 2079-06-06).
336    #[cfg(feature = "chrono")]
337    pub fn datetime_to_smalldatetime_days_minutes(
338        dt: chrono::NaiveDateTime,
339    ) -> Result<(u16, u16), TypeError> {
340        use chrono::Timelike;
341        let epoch = chrono::NaiveDate::from_ymd_opt(1900, 1, 1).expect("epoch 1900-01-01 is valid");
342
343        let total_seconds = dt.time().hour() * 3600 + dt.time().minute() * 60 + dt.time().second();
344        let minutes_raw = (total_seconds + 30) / 60;
345        // Carry over into the next day when seconds round up past 24:00 so that
346        // the returned minute count stays within SQL Server's valid 0..1439 range.
347        // SQL Server itself does the same thing when casting 23:59:45 to
348        // SMALLDATETIME — sending minutes=1440 directly on the wire is rejected
349        // as "invalid instance of data type smalldatetime".
350        let (day_carry, minutes) = if minutes_raw >= 1440 {
351            (1i64, 0u16)
352        } else {
353            (0i64, minutes_raw as u16)
354        };
355
356        let days_i64 = (dt.date() - epoch).num_days() + day_carry;
357        let days: u16 = u16::try_from(days_i64).map_err(|_| {
358        TypeError::InvalidDateTime(format!(
359            "SMALLDATETIME year must be 1900-2079, got date with {days_i64} days since 1900-01-01"
360        ))
361    })?;
362
363        Ok((days, minutes))
364    }
365
366    /// Encode a SMALLDATETIME value (4 bytes): days since 1900 (`u16` LE) +
367    /// minutes since midnight (`u16` LE). Seconds are rounded to the nearest
368    /// minute (30s rounds up per SQL Server semantics).
369    ///
370    /// # Errors
371    ///
372    /// Returns an error if the date is outside the SMALLDATETIME range
373    /// (1900-01-01 through 2079-06-06).
374    #[cfg(feature = "chrono")]
375    pub fn encode_smalldatetime(
376        dt: chrono::NaiveDateTime,
377        buf: &mut BytesMut,
378    ) -> Result<(), TypeError> {
379        let (days, minutes) = datetime_to_smalldatetime_days_minutes(dt)?;
380        buf.put_u16_le(days);
381        buf.put_u16_le(minutes);
382        Ok(())
383    }
384
385    /// Encode a DATE value.
386    ///
387    /// TDS DATE is the number of days since 0001-01-01.
388    ///
389    /// # Errors
390    ///
391    /// Returns an error if the date is outside SQL Server's DATE range
392    /// (0001-01-01 through 9999-12-31). chrono permits dates beyond both ends,
393    /// which previously wrapped silently into garbage wire values.
394    #[cfg(feature = "chrono")]
395    pub fn encode_date(date: chrono::NaiveDate, buf: &mut BytesMut) -> Result<(), TypeError> {
396        /// Days from 0001-01-01 to 9999-12-31, the last representable DATE.
397        const MAX_DAYS: i64 = 3_652_058;
398
399        let base = chrono::NaiveDate::from_ymd_opt(1, 1, 1).expect("valid date");
400        let days = date.signed_duration_since(base).num_days();
401        if !(0..=MAX_DAYS).contains(&days) {
402            return Err(TypeError::InvalidDateTime(format!(
403                "DATE must be between 0001-01-01 and 9999-12-31, got {date}"
404            )));
405        }
406        let days = days as u32;
407
408        // DATE is encoded as 3 bytes (little-endian)
409        buf.put_u8((days & 0xFF) as u8);
410        buf.put_u8(((days >> 8) & 0xFF) as u8);
411        buf.put_u8(((days >> 16) & 0xFF) as u8);
412        Ok(())
413    }
414
415    /// Encode a TIME value.
416    ///
417    /// TDS TIME is encoded as 100-nanosecond intervals since midnight.
418    #[cfg(feature = "chrono")]
419    pub fn encode_time(time: chrono::NaiveTime, buf: &mut BytesMut) {
420        use chrono::Timelike;
421
422        // Calculate 100-ns intervals since midnight
423        // Scale = 7 (100-nanosecond precision)
424        let nanos =
425            time.num_seconds_from_midnight() as u64 * 1_000_000_000 + time.nanosecond() as u64;
426        let intervals = nanos / 100;
427
428        // TIME with scale 7 uses 5 bytes
429        buf.put_u8((intervals & 0xFF) as u8);
430        buf.put_u8(((intervals >> 8) & 0xFF) as u8);
431        buf.put_u8(((intervals >> 16) & 0xFF) as u8);
432        buf.put_u8(((intervals >> 24) & 0xFF) as u8);
433        buf.put_u8(((intervals >> 32) & 0xFF) as u8);
434    }
435
436    /// Encode a DATETIME2 value.
437    ///
438    /// DATETIME2 is encoded as TIME followed by DATE.
439    ///
440    /// # Errors
441    ///
442    /// Returns an error if the date portion is outside the DATE range; see
443    /// [`encode_date`].
444    #[cfg(feature = "chrono")]
445    pub fn encode_datetime2(
446        datetime: chrono::NaiveDateTime,
447        buf: &mut BytesMut,
448    ) -> Result<(), TypeError> {
449        encode_time(datetime.time(), buf);
450        encode_date(datetime.date(), buf)
451    }
452
453    /// Encode a DATETIMEOFFSET value.
454    ///
455    /// DATETIMEOFFSET is encoded as TIME + DATE + offset (in minutes). Per
456    /// MS-TDS §2.2.5.5.1.9 the date/time portion is the **UTC** instant, not the
457    /// local wall-clock; the offset is carried separately.
458    #[cfg(feature = "chrono")]
459    pub fn encode_datetimeoffset(
460        datetime: chrono::DateTime<chrono::FixedOffset>,
461        buf: &mut BytesMut,
462    ) -> Result<(), TypeError> {
463        use chrono::Offset;
464
465        // Encode the UTC date/time components
466        let utc = datetime.naive_utc();
467        encode_time(utc.time(), buf);
468        encode_date(utc.date(), buf)?;
469
470        // Encode timezone offset in minutes (signed 16-bit)
471        let offset_seconds = datetime.offset().fix().local_minus_utc();
472        let offset_minutes = (offset_seconds / 60) as i16;
473        buf.put_i16_le(offset_minutes);
474        Ok(())
475    }
476}
477
478// Re-export the sealed encoders into the module scope so intra-crate callers
479// (the `TdsEncode` impl above, sibling modules) keep using `crate::encode::*`.
480// Empty under `--no-default-features` (all encoders are feature-gated).
481#[allow(unused_imports)]
482pub(crate) use sealed::*;
483
484#[cfg(test)]
485#[allow(clippy::unwrap_used)]
486mod tests {
487    use super::*;
488
489    /// JSON values are sent on the wire as NVARCHAR — the serialized JSON
490    /// string encoded as UTF-16LE. This encode path had no asserting test.
491    #[cfg(feature = "json")]
492    #[test]
493    fn test_json_encodes_as_nvarchar() {
494        let value = serde_json::json!({"name": "Ada", "id": 42});
495
496        let mut got = BytesMut::new();
497        SqlValue::Json(value.clone()).encode(&mut got).unwrap();
498
499        let mut want = BytesMut::new();
500        encode_utf16_string(&value.to_string(), &mut want);
501        assert_eq!(got, want);
502    }
503
504    /// Issue #152 regression: the DATETIMEOFFSET wire date/time portion is
505    /// the UTC instant per MS-TDS §2.2.5.5.1.9. 12:00 at +02:00 must encode
506    /// a 10:00 time portion. The previous encoder wrote the local wall-clock,
507    /// shifting every non-zero-offset value when read by other drivers or
508    /// compared server-side.
509    #[cfg(feature = "chrono")]
510    #[test]
511    fn test_datetimeoffset_encodes_utc_instant() {
512        use chrono::TimeZone;
513
514        let offset = chrono::FixedOffset::east_opt(2 * 3600).unwrap();
515        let dto = offset.with_ymd_and_hms(2024, 3, 15, 12, 0, 0).unwrap();
516
517        let mut buf = BytesMut::new();
518        encode_datetimeoffset(dto, &mut buf).unwrap();
519        assert_eq!(buf.len(), 10); // 5 time + 3 date + 2 offset
520
521        // Time portion: 100ns intervals since midnight of the UTC instant.
522        let mut intervals: u64 = 0;
523        for i in 0..5 {
524            intervals |= u64::from(buf[i]) << (8 * i);
525        }
526        assert_eq!(intervals, 10 * 3600 * 10_000_000, "time must be 10:00 UTC");
527
528        // Date portion: days since 0001-01-01 of the UTC date.
529        let days = u32::from(buf[5]) | (u32::from(buf[6]) << 8) | (u32::from(buf[7]) << 16);
530        let base = chrono::NaiveDate::from_ymd_opt(1, 1, 1).unwrap();
531        let expected_days =
532            (chrono::NaiveDate::from_ymd_opt(2024, 3, 15).unwrap() - base).num_days() as u32;
533        assert_eq!(days, expected_days);
534
535        // Offset: +120 minutes, unchanged.
536        assert_eq!(i16::from_le_bytes([buf[8], buf[9]]), 120);
537    }
538
539    #[test]
540    fn test_encode_int() {
541        let mut buf = BytesMut::new();
542        SqlValue::Int(42).encode(&mut buf).unwrap();
543        assert_eq!(&buf[..], &[42, 0, 0, 0]);
544    }
545
546    #[test]
547    fn test_encode_bigint() {
548        let mut buf = BytesMut::new();
549        SqlValue::BigInt(0x0102030405060708)
550            .encode(&mut buf)
551            .unwrap();
552        assert_eq!(&buf[..], &[0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01]);
553    }
554
555    #[test]
556    fn test_encode_utf16_string() {
557        let mut buf = BytesMut::new();
558        encode_utf16_string("AB", &mut buf);
559        // Length (4 bytes for 2 UTF-16 code units) + "AB" in UTF-16LE
560        assert_eq!(&buf[..], &[4, 0, 0x41, 0, 0x42, 0]);
561    }
562
563    #[cfg(feature = "uuid")]
564    #[test]
565    fn test_encode_uuid() {
566        let mut buf = BytesMut::new();
567        let uuid = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
568        encode_uuid(uuid, &mut buf);
569        // SQL Server mixed-endian format
570        assert_eq!(
571            &buf[..],
572            &[
573                0x78, 0x56, 0x34, 0x12, // First group reversed
574                0x34, 0x12, // Second group reversed
575                0x78, 0x56, // Third group reversed
576                0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78 // Last 8 bytes as-is
577            ]
578        );
579    }
580
581    #[cfg(feature = "chrono")]
582    /// Issue #167 regression: chrono dates outside SQL Server's DATE range
583    /// (0001-01-01..9999-12-31) silently wrapped `num_days() as u32` into
584    /// garbage wire values. They must error; the boundaries must encode.
585    #[test]
586    fn test_encode_date_range_enforced() {
587        let mut buf = BytesMut::new();
588
589        // Both boundaries encode.
590        let min = chrono::NaiveDate::from_ymd_opt(1, 1, 1).unwrap();
591        encode_date(min, &mut buf).unwrap();
592        assert_eq!(&buf[buf.len() - 3..], &[0, 0, 0]);
593        let max = chrono::NaiveDate::from_ymd_opt(9999, 12, 31).unwrap();
594        encode_date(max, &mut buf).unwrap();
595        // 3_652_058 = 0x37B9DA little-endian
596        assert_eq!(&buf[buf.len() - 3..], &[0xDA, 0xB9, 0x37]);
597
598        // One day past either boundary errors instead of wrapping.
599        let before = chrono::NaiveDate::from_ymd_opt(0, 12, 31).unwrap();
600        assert!(matches!(
601            encode_date(before, &mut buf),
602            Err(TypeError::InvalidDateTime(_))
603        ));
604        let after = chrono::NaiveDate::from_ymd_opt(10000, 1, 1).unwrap();
605        assert!(matches!(
606            encode_date(after, &mut buf),
607            Err(TypeError::InvalidDateTime(_))
608        ));
609
610        // The composite encoders propagate the range error.
611        let dt = before.and_hms_opt(12, 0, 0).unwrap();
612        assert!(encode_datetime2(dt, &mut buf).is_err());
613    }
614
615    #[cfg(feature = "chrono")]
616    #[test]
617    fn test_encode_date() {
618        let mut buf = BytesMut::new();
619        let date = chrono::NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
620        encode_date(date, &mut buf).unwrap();
621        // Should be 3 bytes representing days since 0001-01-01
622        assert_eq!(buf.len(), 3);
623    }
624}