Skip to main content

tds_protocol/
tvp.rs

1//! Table-Valued Parameter (TVP) wire format encoding.
2//!
3//! This module provides TDS protocol-level encoding for Table-Valued Parameters.
4//! TVPs allow passing collections of structured data to SQL Server stored procedures.
5//!
6//! ## Wire Format
7//!
8//! TVPs are encoded as type `0xF3` with this structure:
9//!
10//! ```text
11//! TVP_TYPE_INFO = TVPTYPE TVP_TYPENAME TVP_COLMETADATA TVP_END_TOKEN *TVP_ROW TVP_END_TOKEN
12//!
13//! TVPTYPE = %xF3
14//! TVP_TYPENAME = DbName OwningSchema TypeName (all B_VARCHAR)
15//! TVP_COLMETADATA = TVP_NULL_TOKEN / (Count TvpColumnMetaData*)
16//! TVP_NULL_TOKEN = %xFFFF
17//! TvpColumnMetaData = UserType Flags TYPE_INFO ColName
18//! TVP_ROW = TVP_ROW_TOKEN AllColumnData
19//! TVP_ROW_TOKEN = %x01
20//! TVP_END_TOKEN = %x00
21//! ```
22//!
23//! ## Important Constraints
24//!
25//! - `DbName` MUST be a zero-length string (empty)
26//! - `ColName` MUST be a zero-length string in each column definition
27//! - TVPs can only be used as input parameters (not output)
28//! - Requires TDS 7.3 or later
29//!
30//! ## References
31//!
32//! - [MS-TDS 2.2.6.9](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/c264db71-c1ec-4fe8-b5ef-19d54b1e6566)
33
34use bytes::{BufMut, BytesMut};
35
36use crate::codec::write_utf16_string;
37use crate::prelude::*;
38
39/// TVP type identifier in TDS.
40pub const TVP_TYPE_ID: u8 = 0xF3;
41
42/// Token indicating end of TVP metadata or rows.
43pub const TVP_END_TOKEN: u8 = 0x00;
44
45/// Token indicating a TVP row follows.
46pub const TVP_ROW_TOKEN: u8 = 0x01;
47
48/// Token indicating no columns (NULL TVP metadata).
49pub const TVP_NULL_TOKEN: u16 = 0xFFFF;
50
51/// Default collation for string types in TVPs.
52///
53/// This is Latin1_General_CI_AS equivalent.
54pub const DEFAULT_COLLATION: [u8; 5] = [0x09, 0x04, 0xD0, 0x00, 0x34];
55
56/// TVP column type for wire encoding.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum TvpWireType {
60    /// BIT type.
61    Bit,
62    /// Integer type with size (1, 2, 4, or 8 bytes).
63    Int {
64        /// Size in bytes.
65        size: u8,
66    },
67    /// Floating point type with size (4 or 8 bytes).
68    Float {
69        /// Size in bytes.
70        size: u8,
71    },
72    /// Decimal/Numeric type.
73    Decimal {
74        /// Maximum number of digits.
75        precision: u8,
76        /// Number of digits after decimal point.
77        scale: u8,
78    },
79    /// Unicode string (NVARCHAR).
80    NVarChar {
81        /// Maximum length in bytes. Use 0xFFFF for MAX.
82        max_length: u16,
83    },
84    /// ASCII string (VARCHAR).
85    VarChar {
86        /// Maximum length in bytes. Use 0xFFFF for MAX.
87        max_length: u16,
88    },
89    /// Binary data (VARBINARY).
90    VarBinary {
91        /// Maximum length in bytes. Use 0xFFFF for MAX.
92        max_length: u16,
93    },
94    /// UNIQUEIDENTIFIER (UUID).
95    Guid,
96    /// DATE type.
97    Date,
98    /// TIME type with scale.
99    Time {
100        /// Fractional seconds precision (0-7).
101        scale: u8,
102    },
103    /// DATETIME2 type with scale.
104    DateTime2 {
105        /// Fractional seconds precision (0-7).
106        scale: u8,
107    },
108    /// DATETIMEOFFSET type with scale.
109    DateTimeOffset {
110        /// Fractional seconds precision (0-7).
111        scale: u8,
112    },
113    /// MONEY type (8-byte scaled integer, scale 4 implicit, via MONEYN / 0x6E).
114    Money,
115    /// SMALLMONEY type (4-byte scaled integer, scale 4 implicit, via MONEYN / 0x6E).
116    SmallMoney,
117    /// Legacy DATETIME type (8 bytes: days since 1900 + 1/300s ticks, via DATETIMEN / 0x6F).
118    DateTime,
119    /// SMALLDATETIME type (4 bytes: days since 1900 + minutes, via DATETIMEN / 0x6F).
120    SmallDateTime,
121    /// XML type.
122    Xml,
123}
124
125impl TvpWireType {
126    /// Get the TDS type ID.
127    #[must_use]
128    pub const fn type_id(&self) -> u8 {
129        match self {
130            Self::Bit => 0x68,                            // BITNTYPE
131            Self::Int { .. } => 0x26,                     // INTNTYPE
132            Self::Float { .. } => 0x6D,                   // FLTNTYPE
133            Self::Decimal { .. } => 0x6C,                 // DECIMALNTYPE
134            Self::NVarChar { .. } => 0xE7,                // NVARCHARTYPE
135            Self::VarChar { .. } => 0xA7,                 // BIGVARCHARTYPE
136            Self::VarBinary { .. } => 0xA5,               // BIGVARBINTYPE
137            Self::Guid => 0x24,                           // GUIDTYPE
138            Self::Date => 0x28,                           // DATETYPE
139            Self::Time { .. } => 0x29,                    // TIMETYPE
140            Self::DateTime2 { .. } => 0x2A,               // DATETIME2TYPE
141            Self::DateTimeOffset { .. } => 0x2B,          // DATETIMEOFFSETTYPE
142            Self::Money | Self::SmallMoney => 0x6E,       // MONEYNTYPE
143            Self::DateTime | Self::SmallDateTime => 0x6F, // DATETIMNTYPE
144            Self::Xml => 0xF1,                            // XMLTYPE
145        }
146    }
147
148    /// Encode the TYPE_INFO for this column type.
149    ///
150    /// String columns are declared with the default Latin1_General_CI_AS
151    /// collation; use [`encode_type_info_with_collation`](Self::encode_type_info_with_collation)
152    /// to declare the server's actual collation.
153    pub fn encode_type_info(&self, buf: &mut BytesMut) {
154        self.encode_type_info_with_collation(buf, None);
155    }
156
157    /// Encode the TYPE_INFO for this column type, declaring `collation` for
158    /// string columns.
159    ///
160    /// The collation declared here tells the server which codepage VARCHAR
161    /// cell bytes are in. It must match the encoding used for the cell data
162    /// (see `encode_tvp_varchar_with_collation`). `None` falls back to
163    /// [`DEFAULT_COLLATION`] (Latin1_General_CI_AS / Windows-1252).
164    pub fn encode_type_info_with_collation(
165        &self,
166        buf: &mut BytesMut,
167        collation: Option<&crate::token::Collation>,
168    ) {
169        buf.put_u8(self.type_id());
170
171        let collation_bytes = collation.map_or(DEFAULT_COLLATION, |c| c.to_bytes());
172
173        match self {
174            Self::Bit => {
175                buf.put_u8(1); // Max length
176            }
177            Self::Int { size } | Self::Float { size } => {
178                buf.put_u8(*size);
179            }
180            Self::Decimal { precision, scale } => {
181                buf.put_u8(17); // Max length for decimal
182                buf.put_u8(*precision);
183                buf.put_u8(*scale);
184            }
185            Self::NVarChar { max_length } => {
186                buf.put_u16_le(*max_length);
187                buf.put_slice(&collation_bytes);
188            }
189            Self::VarChar { max_length } => {
190                buf.put_u16_le(*max_length);
191                buf.put_slice(&collation_bytes);
192            }
193            Self::VarBinary { max_length } => {
194                buf.put_u16_le(*max_length);
195            }
196            Self::Guid => {
197                buf.put_u8(16); // Fixed 16 bytes
198            }
199            Self::Date => {
200                // No additional info needed
201            }
202            Self::Time { scale } | Self::DateTime2 { scale } | Self::DateTimeOffset { scale } => {
203                buf.put_u8(*scale);
204            }
205            Self::Money | Self::DateTime => {
206                buf.put_u8(8); // Fixed 8 bytes
207            }
208            Self::SmallMoney | Self::SmallDateTime => {
209                buf.put_u8(4); // Fixed 4 bytes
210            }
211            Self::Xml => {
212                // XML schema info - we use no schema
213                buf.put_u8(0); // No schema collection
214            }
215        }
216    }
217}
218
219/// Column flags for TVP columns.
220#[derive(Debug, Clone, Copy, Default)]
221#[non_exhaustive]
222pub struct TvpColumnFlags {
223    /// Column is nullable.
224    pub nullable: bool,
225}
226
227impl TvpColumnFlags {
228    /// Create a new set of column flags.
229    #[must_use]
230    pub const fn new(nullable: bool) -> Self {
231        Self { nullable }
232    }
233
234    /// Encode flags to 2-byte value.
235    #[must_use]
236    pub const fn to_bits(&self) -> u16 {
237        let mut flags = 0u16;
238        if self.nullable {
239            flags |= 0x0001;
240        }
241        flags
242    }
243}
244
245/// TVP column definition for wire encoding.
246#[derive(Debug, Clone)]
247#[non_exhaustive]
248pub struct TvpColumnDef {
249    /// Column type.
250    pub wire_type: TvpWireType,
251    /// Column flags.
252    pub flags: TvpColumnFlags,
253}
254
255impl TvpColumnDef {
256    /// Create a new TVP column definition.
257    #[must_use]
258    pub const fn new(wire_type: TvpWireType) -> Self {
259        Self {
260            wire_type,
261            flags: TvpColumnFlags { nullable: false },
262        }
263    }
264
265    /// Create a nullable TVP column definition.
266    #[must_use]
267    pub const fn nullable(wire_type: TvpWireType) -> Self {
268        Self {
269            wire_type,
270            flags: TvpColumnFlags { nullable: true },
271        }
272    }
273
274    /// Encode the column metadata.
275    ///
276    /// Format: UserType (4) + Flags (2) + TYPE_INFO + ColName (B_VARCHAR, must be empty)
277    pub fn encode(&self, buf: &mut BytesMut) {
278        self.encode_with_collation(buf, None);
279    }
280
281    /// Encode the column metadata, declaring `collation` for string columns.
282    pub fn encode_with_collation(
283        &self,
284        buf: &mut BytesMut,
285        collation: Option<&crate::token::Collation>,
286    ) {
287        // UserType (always 0 for TVP columns)
288        buf.put_u32_le(0);
289
290        // Flags
291        buf.put_u16_le(self.flags.to_bits());
292
293        // TYPE_INFO
294        self.wire_type
295            .encode_type_info_with_collation(buf, collation);
296
297        // ColName - MUST be zero-length per MS-TDS spec
298        buf.put_u8(0);
299    }
300}
301
302/// TVP value encoder.
303///
304/// This provides the complete TVP encoding logic for RPC parameters.
305#[derive(Debug)]
306pub struct TvpEncoder<'a> {
307    /// Database schema (e.g., "dbo"). Empty for default.
308    pub schema: &'a str,
309    /// Type name as defined in the database.
310    pub type_name: &'a str,
311    /// Column definitions.
312    pub columns: &'a [TvpColumnDef],
313}
314
315impl<'a> TvpEncoder<'a> {
316    /// Create a new TVP encoder.
317    #[must_use]
318    pub const fn new(schema: &'a str, type_name: &'a str, columns: &'a [TvpColumnDef]) -> Self {
319        Self {
320            schema,
321            type_name,
322            columns,
323        }
324    }
325
326    /// Encode the complete TVP type info and metadata.
327    ///
328    /// This encodes:
329    /// - TVP type ID (0xF3)
330    /// - TVP_TYPENAME (DbName, OwningSchema, TypeName)
331    /// - TVP_COLMETADATA
332    /// - TVP_END_TOKEN (marks end of column metadata)
333    ///
334    /// After calling this, use [`Self::encode_row`] for each row, then
335    /// [`Self::encode_end`] to finish.
336    ///
337    /// String columns are declared with the default Latin1_General_CI_AS
338    /// collation; use [`Self::encode_metadata_with_collation`] to declare the
339    /// server's actual collation.
340    pub fn encode_metadata(&self, buf: &mut BytesMut) {
341        self.encode_metadata_with_collation(buf, None);
342    }
343
344    /// Encode the complete TVP type info and metadata, declaring `collation`
345    /// for string columns.
346    ///
347    /// Pass the collation captured from the login `ENVCHANGE` so VARCHAR cell
348    /// bytes (encoded with the same collation) are interpreted in the right
349    /// codepage by the server.
350    pub fn encode_metadata_with_collation(
351        &self,
352        buf: &mut BytesMut,
353        collation: Option<&crate::token::Collation>,
354    ) {
355        // TVP type ID
356        buf.put_u8(TVP_TYPE_ID);
357
358        // TVP_TYPENAME
359        // DbName - MUST be empty per MS-TDS spec
360        buf.put_u8(0);
361
362        // OwningSchema (B_VARCHAR)
363        let schema_len = self.schema.encode_utf16().count() as u8;
364        buf.put_u8(schema_len);
365        if schema_len > 0 {
366            write_utf16_string(buf, self.schema);
367        }
368
369        // TypeName (B_VARCHAR)
370        let type_len = self.type_name.encode_utf16().count() as u8;
371        buf.put_u8(type_len);
372        if type_len > 0 {
373            write_utf16_string(buf, self.type_name);
374        }
375
376        // TVP_COLMETADATA
377        if self.columns.is_empty() {
378            // No columns - use null token
379            buf.put_u16_le(TVP_NULL_TOKEN);
380        } else {
381            // Column count (2 bytes)
382            buf.put_u16_le(self.columns.len() as u16);
383
384            // Encode each column
385            for col in self.columns {
386                col.encode_with_collation(buf, collation);
387            }
388        }
389
390        // Optional: TVP_ORDER_UNIQUE and TVP_COLUMN_ORDERING could go here
391        // We don't use them for now
392
393        // TVP_END_TOKEN marks end of metadata
394        buf.put_u8(TVP_END_TOKEN);
395    }
396
397    /// Encode a TVP row.
398    ///
399    /// # Arguments
400    ///
401    /// * `encode_values` - A closure that encodes the column values into the buffer.
402    ///   Each value should be encoded according to its type (similar to RPC param encoding).
403    pub fn encode_row<F>(&self, buf: &mut BytesMut, encode_values: F)
404    where
405        F: FnOnce(&mut BytesMut),
406    {
407        // TVP_ROW_TOKEN
408        buf.put_u8(TVP_ROW_TOKEN);
409
410        // AllColumnData - caller provides the value encoding
411        encode_values(buf);
412    }
413
414    /// Encode the TVP end marker.
415    ///
416    /// This must be called after all rows have been encoded.
417    pub fn encode_end(&self, buf: &mut BytesMut) {
418        buf.put_u8(TVP_END_TOKEN);
419    }
420}
421
422/// Low-level per-type TVP value encoders shared across the workspace crates.
423///
424/// Internal plumbing reached cross-crate only via [`crate::__private`]; not
425/// public API and exempt from semver guarantees (see #242). The public
426/// [`TvpEncoder`]/[`TvpColumnDef`] types above are the user-facing API.
427pub(crate) mod sealed {
428    use super::*;
429
430    /// Encode a NULL value for a TVP column.
431    ///
432    /// Different types use different NULL indicators.
433    pub fn encode_tvp_null(wire_type: &TvpWireType, buf: &mut BytesMut) {
434        match wire_type {
435            TvpWireType::NVarChar { max_length } | TvpWireType::VarChar { max_length } => {
436                if *max_length == 0xFFFF {
437                    // MAX type uses PLP NULL
438                    buf.put_u64_le(0xFFFFFFFFFFFFFFFF);
439                } else {
440                    // Regular type uses 0xFFFF
441                    buf.put_u16_le(0xFFFF);
442                }
443            }
444            TvpWireType::VarBinary { max_length } => {
445                if *max_length == 0xFFFF {
446                    buf.put_u64_le(0xFFFFFFFFFFFFFFFF);
447                } else {
448                    buf.put_u16_le(0xFFFF);
449                }
450            }
451            TvpWireType::Xml => {
452                // XML uses PLP NULL
453                buf.put_u64_le(0xFFFFFFFFFFFFFFFF);
454            }
455            _ => {
456                // Most types use 0 length
457                buf.put_u8(0);
458            }
459        }
460    }
461
462    /// Encode a BIT value for TVP.
463    pub fn encode_tvp_bit(value: bool, buf: &mut BytesMut) {
464        buf.put_u8(1); // Length
465        buf.put_u8(if value { 1 } else { 0 });
466    }
467
468    /// Encode an integer value for TVP.
469    ///
470    /// # Panics
471    ///
472    /// Panics if `size` is not 1, 2, 4, or 8. Callers must use sizes derived
473    /// from `TvpWireType::Int { size }` which are always valid.
474    pub fn encode_tvp_int(value: i64, size: u8, buf: &mut BytesMut) {
475        buf.put_u8(size); // Length
476        match size {
477            1 => buf.put_i8(value as i8),
478            2 => buf.put_i16_le(value as i16),
479            4 => buf.put_i32_le(value as i32),
480            8 => buf.put_i64_le(value),
481            _ => unreachable!(
482                "encode_tvp_int called with invalid size {size}; expected 1, 2, 4, or 8"
483            ),
484        }
485    }
486
487    /// Encode a float value for TVP.
488    ///
489    /// # Panics
490    ///
491    /// Panics if `size` is not 4 or 8. Callers must use sizes derived
492    /// from `TvpWireType::Float { size }` which are always valid.
493    pub fn encode_tvp_float(value: f64, size: u8, buf: &mut BytesMut) {
494        buf.put_u8(size); // Length
495        match size {
496            4 => buf.put_f32_le(value as f32),
497            8 => buf.put_f64_le(value),
498            _ => unreachable!("encode_tvp_float called with invalid size {size}; expected 4 or 8"),
499        }
500    }
501
502    /// Encode a NVARCHAR value for TVP.
503    pub fn encode_tvp_nvarchar(value: &str, max_length: u16, buf: &mut BytesMut) {
504        let utf16: Vec<u16> = value.encode_utf16().collect();
505        let byte_len = utf16.len() * 2;
506
507        if max_length == 0xFFFF {
508            // MAX type - use PLP format
509            buf.put_u64_le(byte_len as u64); // Total length
510            buf.put_u32_le(byte_len as u32); // Chunk length
511            for code_unit in utf16 {
512                buf.put_u16_le(code_unit);
513            }
514            buf.put_u32_le(0); // Terminator
515        } else {
516            // Regular type
517            buf.put_u16_le(byte_len as u16);
518            for code_unit in utf16 {
519                buf.put_u16_le(code_unit);
520            }
521        }
522    }
523
524    /// Encode a VARCHAR value for TVP using single-byte codepage encoding.
525    ///
526    /// SQL Server stores VARCHAR data as single-byte characters using the column collation
527    /// code page. Passing UTF-16 bytes (as NVARCHAR) into a VARCHAR column corrupts every
528    /// character: "abc" would be stored as "a\0b\0c\0".
529    ///
530    /// Encodes using Windows-1252 (Latin1_General_CI_AS), matching [`DEFAULT_COLLATION`]
531    /// declared in TVP column metadata. Use
532    /// [`encode_tvp_varchar_with_collation`] to encode for the server's actual
533    /// collation.
534    pub fn encode_tvp_varchar(value: &str, max_length: u16, buf: &mut BytesMut) {
535        encode_tvp_varchar_with_collation(value, max_length, None, buf);
536    }
537
538    /// Encode a VARCHAR value for TVP using the codepage of `collation`.
539    ///
540    /// The collation must match the one declared in the TVP column metadata
541    /// (see [`TvpWireType::encode_type_info_with_collation`]) so the server
542    /// interprets the cell bytes in the codepage they were encoded with.
543    /// Characters not representable in the codepage are replaced with `?`.
544    pub fn encode_tvp_varchar_with_collation(
545        value: &str,
546        max_length: u16,
547        collation: Option<&crate::token::Collation>,
548        buf: &mut BytesMut,
549    ) {
550        let encoded = crate::collation::encode_str_for_collation(value, collation);
551        let byte_len = encoded.len();
552
553        if max_length == 0xFFFF {
554            // MAX type - use PLP format
555            buf.put_u64_le(byte_len as u64); // Total length
556            buf.put_u32_le(byte_len as u32); // Chunk length
557            buf.put_slice(&encoded);
558            buf.put_u32_le(0); // Terminator
559        } else {
560            // Regular type
561            buf.put_u16_le(byte_len as u16);
562            buf.put_slice(&encoded);
563        }
564    }
565
566    /// Encode a VARBINARY value for TVP.
567    pub fn encode_tvp_varbinary(value: &[u8], max_length: u16, buf: &mut BytesMut) {
568        if max_length == 0xFFFF {
569            // MAX type - use PLP format
570            buf.put_u64_le(value.len() as u64);
571            buf.put_u32_le(value.len() as u32);
572            buf.put_slice(value);
573            buf.put_u32_le(0); // Terminator
574        } else {
575            buf.put_u16_le(value.len() as u16);
576            buf.put_slice(value);
577        }
578    }
579
580    /// Encode a UNIQUEIDENTIFIER value for TVP.
581    ///
582    /// SQL Server uses mixed-endian format for UUIDs.
583    pub fn encode_tvp_guid(uuid_bytes: &[u8; 16], buf: &mut BytesMut) {
584        buf.put_u8(16); // Length
585
586        // Mixed-endian: first 3 groups little-endian, last 2 groups big-endian
587        buf.put_u8(uuid_bytes[3]);
588        buf.put_u8(uuid_bytes[2]);
589        buf.put_u8(uuid_bytes[1]);
590        buf.put_u8(uuid_bytes[0]);
591
592        buf.put_u8(uuid_bytes[5]);
593        buf.put_u8(uuid_bytes[4]);
594
595        buf.put_u8(uuid_bytes[7]);
596        buf.put_u8(uuid_bytes[6]);
597
598        buf.put_slice(&uuid_bytes[8..16]);
599    }
600
601    /// Encode a DATE value for TVP (days since 0001-01-01).
602    pub fn encode_tvp_date(days: u32, buf: &mut BytesMut) {
603        // DATE is 3 bytes
604        buf.put_u8((days & 0xFF) as u8);
605        buf.put_u8(((days >> 8) & 0xFF) as u8);
606        buf.put_u8(((days >> 16) & 0xFF) as u8);
607    }
608
609    /// Encode a TIME value for TVP.
610    ///
611    /// Time is encoded as 100-nanosecond intervals since midnight.
612    pub fn encode_tvp_time(intervals: u64, scale: u8, buf: &mut BytesMut) {
613        // Length depends on scale
614        let len = match scale {
615            0..=2 => 3,
616            3..=4 => 4,
617            5..=7 => 5,
618            _ => 5,
619        };
620        buf.put_u8(len);
621
622        for i in 0..len {
623            buf.put_u8((intervals >> (8 * i)) as u8);
624        }
625    }
626
627    /// Encode a DATETIME2 value for TVP.
628    ///
629    /// DATETIME2 is TIME followed by DATE.
630    pub fn encode_tvp_datetime2(time_intervals: u64, days: u32, scale: u8, buf: &mut BytesMut) {
631        // Length depends on scale (time bytes + 3 date bytes)
632        let time_len = match scale {
633            0..=2 => 3,
634            3..=4 => 4,
635            5..=7 => 5,
636            _ => 5,
637        };
638        buf.put_u8(time_len + 3);
639
640        // Time component
641        for i in 0..time_len {
642            buf.put_u8((time_intervals >> (8 * i)) as u8);
643        }
644
645        // Date component
646        buf.put_u8((days & 0xFF) as u8);
647        buf.put_u8(((days >> 8) & 0xFF) as u8);
648        buf.put_u8(((days >> 16) & 0xFF) as u8);
649    }
650
651    /// Encode a DATETIMEOFFSET value for TVP.
652    ///
653    /// DATETIMEOFFSET is TIME followed by DATE followed by timezone offset.
654    ///
655    /// # Arguments
656    ///
657    /// * `time_intervals` - Time in 100-nanosecond intervals since midnight
658    /// * `days` - Days since year 1 (0001-01-01)
659    /// * `offset_minutes` - Timezone offset in minutes (e.g., -480 for UTC-8, 330 for UTC+5:30)
660    /// * `scale` - Fractional seconds precision (0-7)
661    pub fn encode_tvp_datetimeoffset(
662        time_intervals: u64,
663        days: u32,
664        offset_minutes: i16,
665        scale: u8,
666        buf: &mut BytesMut,
667    ) {
668        // Length depends on scale (time bytes + 3 date bytes + 2 offset bytes)
669        let time_len = match scale {
670            0..=2 => 3,
671            3..=4 => 4,
672            5..=7 => 5,
673            _ => 5,
674        };
675        buf.put_u8(time_len + 3 + 2); // time + date + offset
676
677        // Time component
678        for i in 0..time_len {
679            buf.put_u8((time_intervals >> (8 * i)) as u8);
680        }
681
682        // Date component
683        buf.put_u8((days & 0xFF) as u8);
684        buf.put_u8(((days >> 8) & 0xFF) as u8);
685        buf.put_u8(((days >> 16) & 0xFF) as u8);
686
687        // Timezone offset in minutes (signed 16-bit little-endian)
688        buf.put_i16_le(offset_minutes);
689    }
690
691    /// Encode a DECIMAL value for TVP.
692    ///
693    /// # Arguments
694    ///
695    /// * `sign` - 0 for negative, 1 for positive
696    /// * `mantissa` - The absolute value as a 128-bit integer
697    pub fn encode_tvp_decimal(sign: u8, mantissa: u128, buf: &mut BytesMut) {
698        buf.put_u8(17); // Length: 1 byte sign + 16 bytes mantissa
699        buf.put_u8(sign);
700        buf.put_u128_le(mantissa);
701    }
702
703    /// Encode a MONEY value for TVP (8 bytes).
704    ///
705    /// The MONEY wire format is a 64-bit signed integer scaled by 10_000, written
706    /// as the high 32 bits little-endian followed by the low 32 bits little-endian
707    /// (MS-TDS §2.2.5.5.1.2). `scaled` is the already-scaled cents value — callers
708    /// that hold a `Decimal` should multiply by 10_000 and truncate to `i64` before
709    /// calling this (see `mssql_types::encode::encode_money`).
710    pub fn encode_tvp_money(scaled: i64, buf: &mut BytesMut) {
711        buf.put_u8(8); // Length
712        let high = (scaled >> 32) as i32;
713        let low = (scaled & 0xFFFF_FFFF) as u32;
714        buf.put_i32_le(high);
715        buf.put_u32_le(low);
716    }
717
718    /// Encode a SMALLMONEY value for TVP (4 bytes).
719    ///
720    /// `scaled` is the 32-bit signed integer scaled by 10_000, written
721    /// little-endian.
722    pub fn encode_tvp_smallmoney(scaled: i32, buf: &mut BytesMut) {
723        buf.put_u8(4); // Length
724        buf.put_i32_le(scaled);
725    }
726
727    /// Encode a legacy DATETIME value for TVP (8 bytes).
728    ///
729    /// DATETIME wire format: days since 1900-01-01 (i32 LE) + time units since
730    /// midnight (u32 LE) where each unit is 1/300 of a second.
731    pub fn encode_tvp_datetime(days: i32, ticks: u32, buf: &mut BytesMut) {
732        buf.put_u8(8); // Length
733        buf.put_i32_le(days);
734        buf.put_u32_le(ticks);
735    }
736
737    /// Encode a SMALLDATETIME value for TVP (4 bytes).
738    ///
739    /// SMALLDATETIME wire format: days since 1900-01-01 (u16 LE) + minutes since
740    /// midnight (u16 LE). Sub-minute precision is discarded by the caller.
741    pub fn encode_tvp_smalldatetime(days: u16, minutes: u16, buf: &mut BytesMut) {
742        buf.put_u8(4); // Length
743        buf.put_u16_le(days);
744        buf.put_u16_le(minutes);
745    }
746}
747
748#[cfg(test)]
749#[allow(clippy::unwrap_used, clippy::expect_used)]
750mod tests {
751    use super::sealed::*;
752    use super::*;
753
754    #[test]
755    fn test_tvp_metadata_encoding() {
756        let columns = vec![TvpColumnDef::new(TvpWireType::Int { size: 4 })];
757
758        let encoder = TvpEncoder::new("dbo", "UserIdList", &columns);
759        let mut buf = BytesMut::new();
760
761        encoder.encode_metadata(&mut buf);
762
763        // Should start with TVP type ID
764        assert_eq!(buf[0], TVP_TYPE_ID);
765
766        // DbName should be empty (length 0)
767        assert_eq!(buf[1], 0);
768    }
769
770    #[test]
771    fn test_tvp_column_def_encoding() {
772        let col = TvpColumnDef::nullable(TvpWireType::Int { size: 4 });
773        let mut buf = BytesMut::new();
774
775        col.encode(&mut buf);
776
777        // UserType (4) + Flags (2) + TypeId (1) + MaxLen (1) + ColName (1)
778        assert!(buf.len() >= 9);
779
780        // UserType should be 0
781        assert_eq!(&buf[0..4], &[0, 0, 0, 0]);
782
783        // Flags should have nullable bit set
784        assert_eq!(buf[4], 0x01);
785        assert_eq!(buf[5], 0x00);
786    }
787
788    #[test]
789    fn test_tvp_nvarchar_encoding() {
790        let mut buf = BytesMut::new();
791        encode_tvp_nvarchar("test", 100, &mut buf);
792
793        // Length prefix (2) + UTF-16 data (4 chars * 2 bytes)
794        assert_eq!(buf.len(), 2 + 8);
795        assert_eq!(buf[0], 8); // Byte length
796        assert_eq!(buf[1], 0);
797    }
798
799    #[test]
800    fn test_tvp_int_encoding() {
801        let mut buf = BytesMut::new();
802        encode_tvp_int(42, 4, &mut buf);
803
804        // Length (1) + value (4)
805        assert_eq!(buf.len(), 5);
806        assert_eq!(buf[0], 4);
807        assert_eq!(buf[1], 42);
808    }
809
810    #[test]
811    fn test_tvp_money_encoding_matches_rpc_layout() {
812        // $12.3400 → 123_400 cents (10_000 per unit)
813        let mut buf = BytesMut::new();
814        encode_tvp_money(123_400, &mut buf);
815
816        assert_eq!(buf.len(), 9, "length byte + 8-byte payload");
817        assert_eq!(buf[0], 8, "MONEYN length byte is 8 for MONEY");
818        // High 32 bits LE then low 32 bits LE, per MS-TDS §2.2.5.5.1.2.
819        assert_eq!(&buf[1..5], &[0, 0, 0, 0], "high word zero for small value");
820        assert_eq!(&buf[5..9], &123_400i32.to_le_bytes());
821    }
822
823    #[test]
824    fn test_tvp_money_encoding_negative_value() {
825        // -$1.2300 → -12_300 cents
826        let mut buf = BytesMut::new();
827        encode_tvp_money(-12_300, &mut buf);
828
829        assert_eq!(buf.len(), 9);
830        assert_eq!(buf[0], 8);
831        // Verify the whole 8-byte payload reconstructs to -12_300
832        let high = i32::from_le_bytes(buf[1..5].try_into().unwrap());
833        let low = u32::from_le_bytes(buf[5..9].try_into().unwrap());
834        let reconstructed = ((high as i64) << 32) | (low as i64 & 0xFFFF_FFFF);
835        assert_eq!(reconstructed, -12_300i64);
836    }
837
838    #[test]
839    fn test_tvp_money_encoding_max_value() {
840        // MONEY max: ~922_337_203_685_477.5807 → i64::MAX cents
841        let mut buf = BytesMut::new();
842        encode_tvp_money(i64::MAX, &mut buf);
843
844        assert_eq!(buf.len(), 9);
845        let high = i32::from_le_bytes(buf[1..5].try_into().unwrap());
846        let low = u32::from_le_bytes(buf[5..9].try_into().unwrap());
847        let reconstructed = ((high as i64) << 32) | (low as i64 & 0xFFFF_FFFF);
848        assert_eq!(reconstructed, i64::MAX);
849    }
850
851    #[test]
852    fn test_tvp_smallmoney_encoding() {
853        // $1.2345 → 12_345 cents (10_000 per unit)
854        let mut buf = BytesMut::new();
855        encode_tvp_smallmoney(12_345, &mut buf);
856
857        assert_eq!(buf.len(), 5, "length byte + 4-byte payload");
858        assert_eq!(buf[0], 4);
859        assert_eq!(&buf[1..5], &12_345i32.to_le_bytes());
860    }
861
862    #[test]
863    fn test_tvp_smallmoney_encoding_negative() {
864        let mut buf = BytesMut::new();
865        encode_tvp_smallmoney(-1, &mut buf);
866
867        assert_eq!(buf.len(), 5);
868        assert_eq!(buf[0], 4);
869        assert_eq!(
870            i32::from_le_bytes(buf[1..5].try_into().unwrap()),
871            -1,
872            "SMALLMONEY wraps as signed 32-bit LE"
873        );
874    }
875
876    #[test]
877    fn test_tvp_datetime_encoding() {
878        // 2020-01-01 00:00:00 (41_275 days since 1900-01-01, 0 ticks)
879        let mut buf = BytesMut::new();
880        encode_tvp_datetime(41_275, 0, &mut buf);
881
882        assert_eq!(buf.len(), 9, "length byte + 8-byte payload");
883        assert_eq!(buf[0], 8);
884        assert_eq!(&buf[1..5], &41_275i32.to_le_bytes());
885        assert_eq!(&buf[5..9], &0u32.to_le_bytes());
886    }
887
888    #[test]
889    fn test_tvp_datetime_encoding_pre_1900() {
890        // 1899-12-31 = -1 days since 1900-01-01
891        let mut buf = BytesMut::new();
892        encode_tvp_datetime(-1, 0, &mut buf);
893
894        assert_eq!(buf.len(), 9);
895        assert_eq!(
896            i32::from_le_bytes(buf[1..5].try_into().unwrap()),
897            -1,
898            "pre-1900 DATETIME uses negative days"
899        );
900    }
901
902    #[test]
903    fn test_tvp_smalldatetime_encoding() {
904        // 2020-01-01 00:00:00 = 43_830 days since 1900-01-01, 0 minutes
905        let mut buf = BytesMut::new();
906        encode_tvp_smalldatetime(43_830, 0, &mut buf);
907
908        assert_eq!(buf.len(), 5, "length byte + 4-byte payload");
909        assert_eq!(buf[0], 4);
910        assert_eq!(&buf[1..3], &43_830u16.to_le_bytes());
911        assert_eq!(&buf[3..5], &0u16.to_le_bytes());
912    }
913
914    #[test]
915    fn test_tvp_money_type_info_encoding() {
916        let mut buf = BytesMut::new();
917        TvpWireType::Money.encode_type_info(&mut buf);
918        assert_eq!(
919            &buf[..],
920            &[0x6E, 8],
921            "MONEY = MONEYN type_id with max_length 8"
922        );
923    }
924
925    #[test]
926    fn test_tvp_smallmoney_type_info_encoding() {
927        let mut buf = BytesMut::new();
928        TvpWireType::SmallMoney.encode_type_info(&mut buf);
929        assert_eq!(
930            &buf[..],
931            &[0x6E, 4],
932            "SMALLMONEY = MONEYN type_id with max_length 4"
933        );
934    }
935
936    #[test]
937    fn test_tvp_datetime_type_info_encoding() {
938        let mut buf = BytesMut::new();
939        TvpWireType::DateTime.encode_type_info(&mut buf);
940        assert_eq!(
941            &buf[..],
942            &[0x6F, 8],
943            "DATETIME = DATETIMEN type_id with max_length 8"
944        );
945    }
946
947    #[test]
948    fn test_tvp_smalldatetime_type_info_encoding() {
949        let mut buf = BytesMut::new();
950        TvpWireType::SmallDateTime.encode_type_info(&mut buf);
951        assert_eq!(
952            &buf[..],
953            &[0x6F, 4],
954            "SMALLDATETIME = DATETIMEN type_id with max_length 4"
955        );
956    }
957
958    #[test]
959    fn test_tvp_null_for_money_is_length_zero() {
960        let mut buf = BytesMut::new();
961        encode_tvp_null(&TvpWireType::Money, &mut buf);
962        assert_eq!(&buf[..], &[0], "MONEYN NULL is a single length-zero byte");
963
964        let mut buf = BytesMut::new();
965        encode_tvp_null(&TvpWireType::SmallDateTime, &mut buf);
966        assert_eq!(
967            &buf[..],
968            &[0],
969            "DATETIMEN NULL is a single length-zero byte"
970        );
971    }
972
973    /// VARCHAR TVP columns must declare the provided collation in
974    /// TYPE_INFO instead of the hardcoded Latin1 default, and cell data
975    /// must be transcoded with that collation's codepage. Mismatched
976    /// declaration/encoding silently corrupts non-Latin1 data (the same
977    /// defect class as the v0.10 plain-param collation fix).
978    #[test]
979    #[cfg(feature = "encoding")]
980    fn test_tvp_varchar_with_collation_declares_and_encodes() {
981        let chinese = crate::token::Collation {
982            lcid: 0x0804, // Chinese_PRC → GB18030 / CP936
983            sort_id: 0,
984        };
985
986        // TYPE_INFO declares the caller's collation bytes.
987        let mut buf = BytesMut::new();
988        TvpWireType::VarChar { max_length: 100 }
989            .encode_type_info_with_collation(&mut buf, Some(&chinese));
990        assert_eq!(buf[0], 0xA7, "BIGVARCHARTYPE");
991        assert_eq!(&buf[1..3], &100u16.to_le_bytes());
992        assert_eq!(&buf[3..8], &chinese.to_bytes());
993
994        // Without a collation the default Latin1 bytes are declared.
995        let mut buf = BytesMut::new();
996        TvpWireType::VarChar { max_length: 100 }.encode_type_info(&mut buf);
997        assert_eq!(&buf[3..8], &DEFAULT_COLLATION);
998
999        // Cell data is transcoded with the declared collation's codepage.
1000        let mut buf = BytesMut::new();
1001        encode_tvp_varchar_with_collation("你好", 100, Some(&chinese), &mut buf);
1002        let (expected, _, _) = encoding_rs::GB18030.encode("你好");
1003        assert_eq!(&buf[..2], &(expected.len() as u16).to_le_bytes());
1004        assert_eq!(&buf[2..], &expected[..]);
1005    }
1006}