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
1007 /// Golden wire-byte tripwire (#297). These sealed `encode_tvp_*` encoders
1008 /// emit TDS wire bytes but live off the public surface (`__private`), so
1009 /// `cargo-public-api` and semver-checks cannot see a signature or behavior
1010 /// change. Locking exact output bytes here turns any such change into a
1011 /// fast unit-test failure (a signature change fails to compile; a behavior
1012 /// change fails an assertion) instead of surfacing only in the live suite.
1013 #[test]
1014 fn golden_tvp_wire_bytes() {
1015 fn bytes(f: impl FnOnce(&mut BytesMut)) -> Vec<u8> {
1016 let mut buf = BytesMut::new();
1017 f(&mut buf);
1018 buf.to_vec()
1019 }
1020
1021 // BIT: 1-byte length + value.
1022 assert_eq!(bytes(|b| encode_tvp_bit(true, b)), [0x01, 0x01]);
1023 assert_eq!(bytes(|b| encode_tvp_bit(false, b)), [0x01, 0x00]);
1024
1025 // FLOAT: length + IEEE-754 little-endian. 1.0_f64 / 1.0_f32.
1026 assert_eq!(
1027 bytes(|b| encode_tvp_float(1.0, 8, b)),
1028 [0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x3F]
1029 );
1030 assert_eq!(
1031 bytes(|b| encode_tvp_float(1.0, 4, b)),
1032 [0x04, 0x00, 0x00, 0x80, 0x3F]
1033 );
1034
1035 // VARBINARY (non-MAX): 2-byte length + raw bytes.
1036 assert_eq!(
1037 bytes(|b| encode_tvp_varbinary(&[0xDE, 0xAD, 0xBE, 0xEF], 8000, b)),
1038 [0x04, 0x00, 0xDE, 0xAD, 0xBE, 0xEF]
1039 );
1040
1041 // GUID: 1-byte length + mixed-endian (first 3 groups LE, last 8 as-is).
1042 let guid: [u8; 16] = core::array::from_fn(|i| i as u8);
1043 assert_eq!(
1044 bytes(|b| encode_tvp_guid(&guid, b)),
1045 [16, 3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15]
1046 );
1047
1048 // DATE: 3-byte little-endian day count (no length prefix for TVP date).
1049 assert_eq!(
1050 bytes(|b| encode_tvp_date(0x0001_0203, b)),
1051 [0x03, 0x02, 0x01]
1052 );
1053
1054 // TIME (scale 7 → 5 bytes): little-endian interval count.
1055 assert_eq!(
1056 bytes(|b| encode_tvp_time(0x01_0203_0405, 7, b)),
1057 [0x05, 0x05, 0x04, 0x03, 0x02, 0x01]
1058 );
1059
1060 // DATETIME2 (scale 7): length(8) + 5 time bytes + 3 date bytes.
1061 assert_eq!(
1062 bytes(|b| encode_tvp_datetime2(0x01_0203_0405, 0x0001_0203, 7, b)),
1063 [0x08, 0x05, 0x04, 0x03, 0x02, 0x01, 0x03, 0x02, 0x01]
1064 );
1065
1066 // DATETIMEOFFSET (scale 7): length(10) + 5 time + 3 date + 2 offset (LE).
1067 assert_eq!(
1068 bytes(|b| encode_tvp_datetimeoffset(0x01_0203_0405, 0x0001_0203, 120, 7, b)),
1069 [
1070 0x0A, 0x05, 0x04, 0x03, 0x02, 0x01, 0x03, 0x02, 0x01, 0x78, 0x00
1071 ]
1072 );
1073
1074 // DECIMAL: length(17) + sign + 16-byte little-endian mantissa.
1075 assert_eq!(
1076 bytes(|b| encode_tvp_decimal(1, 12345, b)),
1077 [17, 1, 0x39, 0x30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1078 );
1079 }
1080}