Skip to main content

oracledb_protocol/
dpl.rs

1//! Direct Path Load (DPL) protocol support.
2//!
3//! Implements the three TTC functions used by python-oracledb's
4//! `Connection.direct_path_load()`:
5//!
6//! * function 128 — direct path prepare (send table/column names, receive
7//!   server-side column metadata and a direct path cursor id),
8//! * function 129 — direct path load stream (column-array piece stream),
9//! * function 130 — direct path op (FINISH commits, ABORT discards).
10//!
11//! The builders/parsers mirror `impl/thin/messages/direct_path_*.pyx` of the
12//! python-oracledb v4.0.1 reference and are validated against golden wire
13//! captures in `tests/golden/`. The batch state machine mirrors
14//! `impl/base/batch_load_manager.pyx`.
15
16use crate::thin::{
17    encode_binary_double, encode_binary_float, encode_number_text, encode_oracle_date,
18    encode_oracle_timestamp, encode_oracle_timestamp_tz, parse_column_metadata,
19    parse_server_error_info, skip_server_side_piggyback, ClientCapabilities, ColumnMetadata,
20    CS_FORM_IMPLICIT, CS_FORM_NCHAR, ORA_TYPE_NUM_BINARY_DOUBLE, ORA_TYPE_NUM_BINARY_FLOAT,
21    ORA_TYPE_NUM_BINARY_INTEGER, ORA_TYPE_NUM_BLOB, ORA_TYPE_NUM_BOOLEAN, ORA_TYPE_NUM_CHAR,
22    ORA_TYPE_NUM_CLOB, ORA_TYPE_NUM_DATE, ORA_TYPE_NUM_LONG, ORA_TYPE_NUM_LONG_RAW,
23    ORA_TYPE_NUM_NUMBER, ORA_TYPE_NUM_RAW, ORA_TYPE_NUM_TIMESTAMP, ORA_TYPE_NUM_TIMESTAMP_LTZ,
24    ORA_TYPE_NUM_TIMESTAMP_TZ, ORA_TYPE_NUM_VARCHAR, TNS_MSG_TYPE_END_OF_RESPONSE,
25    TNS_MSG_TYPE_ERROR, TNS_MSG_TYPE_PARAMETER, TNS_MSG_TYPE_SERVER_SIDE_PIGGYBACK,
26    TNS_MSG_TYPE_STATUS,
27};
28use crate::wire::{BoundedReader, ProtocolLimits, TtcReader, TtcWriter};
29use crate::{ProtocolError, Result};
30
31pub const TNS_FUNC_DIRECT_PATH_PREPARE: u8 = 128;
32pub const TNS_FUNC_DIRECT_PATH_LOAD_STREAM: u8 = 129;
33pub const TNS_FUNC_DIRECT_PATH_OP: u8 = 130;
34
35pub const TNS_DP_INTERFACE_VERSION: u32 = 400;
36pub const TNS_DP_STREAM_VERSION: u32 = 400;
37
38pub const TNS_DPP_OP_CODE_LOAD: u32 = 1;
39
40pub const TNS_DP_OP_ABORT: u32 = 1;
41pub const TNS_DP_OP_FINISH: u32 = 2;
42
43const TNS_DPP_IN_INDEX_INTERFACE_VERSION: usize = 0;
44const TNS_DPP_IN_INDEX_STREAM_VERSION: usize = 1;
45const TNS_DPP_IN_INDEX_LOCK_WAIT: usize = 14;
46const TNS_DPP_KW_INDEX_OBJECT_NAME: u16 = 1;
47const TNS_DPP_KW_INDEX_SCHEMA_NAME: u16 = 3;
48const TNS_DPP_KW_INDEX_COLUMN_NAME: u16 = 4;
49const TNS_DPP_KW_INDEX_NFOBJ_OID_POS: usize = 11;
50const TNS_DPP_OUT_INDEX_CURSOR: usize = 3;
51// The reference sizes the input array at TNS_DPP_IN_MAX_PARAMS (36) but only
52// transmits the first 15 entries: `_initialize_hook` seeds indices 16/17 with
53// 0xffff *without* updating `in_values_length`, so they are never sent.
54const TNS_DPP_IN_VALUES_SENT: usize = TNS_DPP_IN_INDEX_LOCK_WAIT + 1;
55
56pub const TNS_DPLS_ROW_HEADER_FAST_PIECE: u8 = 0x10;
57pub const TNS_DPLS_ROW_HEADER_FAST_ROW: u8 = 0x20;
58pub const TNS_DPLS_ROW_HEADER_FIRST: u8 = 0x08;
59pub const TNS_DPLS_ROW_HEADER_LAST: u8 = 0x04;
60pub const TNS_DPLS_ROW_HEADER_SPLIT_WITH_PREV: u8 = 0x02;
61pub const TNS_DPLS_ROW_HEADER_SPLIT_WITH_NEXT: u8 = 0x01;
62
63pub const TNS_DPLS_MAX_MESSAGE_SIZE: u64 = 1_073_728_895;
64pub const TNS_DPLS_MAX_SHORT_LENGTH: usize = 0xfa;
65pub const TNS_DPLS_MAX_PIECE_SIZE: usize = 0xfff0;
66
67const TNS_DPLS_LONG_LENGTH_INDICATOR: u8 = 0xfe;
68const TNS_NULL_LENGTH_INDICATOR: u8 = 0xff;
69
70/// Builds the payload for TTC function 128 (direct path prepare).
71///
72/// Mirrors `DirectPathPrepareMessage._write_message`.
73pub fn build_direct_path_prepare_payload(
74    schema_name: &str,
75    table_name: &str,
76    column_names: &[String],
77    seq_num: u8,
78) -> Result<Vec<u8>> {
79    build_direct_path_prepare_payload_with_version(
80        schema_name,
81        table_name,
82        column_names,
83        seq_num,
84        crate::thin::TNS_CCAP_FIELD_VERSION_23_1_EXT_1,
85    )
86}
87
88/// Version-aware [`build_direct_path_prepare_payload`]: gates the ub8 TTC
89/// token on `ttc_field_version` (see `build_direct_path_op_payload_with_version`
90/// for the ORA-03147 pre-23ai rationale). Bead rust-oracledb-dpl23.
91pub fn build_direct_path_prepare_payload_with_version(
92    schema_name: &str,
93    table_name: &str,
94    column_names: &[String],
95    seq_num: u8,
96    ttc_field_version: u8,
97) -> Result<Vec<u8>> {
98    let keyword_parameters_length =
99        u32::try_from(column_names.len() + 2).map_err(|_| ProtocolError::InvalidPacketLength {
100            length: column_names.len(),
101            minimum: 0,
102        })?;
103
104    let mut in_values = [0u32; TNS_DPP_IN_VALUES_SENT];
105    in_values[TNS_DPP_IN_INDEX_INTERFACE_VERSION] = TNS_DP_INTERFACE_VERSION;
106    in_values[TNS_DPP_IN_INDEX_STREAM_VERSION] = TNS_DP_STREAM_VERSION;
107    in_values[TNS_DPP_KW_INDEX_NFOBJ_OID_POS] = 0xffff;
108    in_values[TNS_DPP_IN_INDEX_LOCK_WAIT] = 1;
109
110    let mut writer = TtcWriter::new();
111    writer.write_function_header(TNS_FUNC_DIRECT_PATH_PREPARE, seq_num, ttc_field_version);
112    writer.write_ub4(TNS_DPP_OP_CODE_LOAD);
113    writer.write_u8(1); // keyword parameters (pointer)
114    writer.write_ub4(keyword_parameters_length);
115    writer.write_u8(1); // input array (pointer)
116    writer.write_ub2(TNS_DPP_IN_VALUES_SENT as u16);
117    writer.write_u8(1); // metadata (pointer)
118    writer.write_u8(1); // metadata length (pointer)
119    writer.write_u8(1); // parameters (pointer)
120    writer.write_u8(1); // parameters length (pointer)
121    writer.write_u8(1); // output array (pointer)
122    writer.write_u8(1); // output array length (pointer)
123    write_keyword_param(&mut writer, TNS_DPP_KW_INDEX_SCHEMA_NAME, schema_name)?;
124    write_keyword_param(&mut writer, TNS_DPP_KW_INDEX_OBJECT_NAME, table_name)?;
125    for name in column_names {
126        write_keyword_param(&mut writer, TNS_DPP_KW_INDEX_COLUMN_NAME, name)?;
127    }
128    for value in in_values {
129        writer.write_ub4(value);
130    }
131    Ok(writer.into_bytes())
132}
133
134fn write_keyword_param(writer: &mut TtcWriter, index: u16, value: &str) -> Result<()> {
135    let bytes = value.as_bytes();
136    let len = u16::try_from(bytes.len()).map_err(|_| ProtocolError::InvalidPacketLength {
137        length: bytes.len(),
138        minimum: 0,
139    })?;
140    writer.write_ub2(0); // text length
141    writer.write_ub2(len);
142    writer.write_bytes_with_length(bytes)?;
143    writer.write_ub2(index);
144    Ok(())
145}
146
147#[derive(Clone, Debug, Default, Eq, PartialEq)]
148pub struct DirectPathPrepareResult {
149    pub column_metadata: Vec<ColumnMetadata>,
150    pub cursor_id: u16,
151    pub out_values: Vec<u32>,
152}
153
154/// Parses the response to TTC function 128 (direct path prepare).
155///
156/// `capabilities.charset_id` drives the CLOB metadata override (charset ids
157/// of 800 and above are multi-byte, in which case implicit-charset CLOBs
158/// switch to the NCHAR form). Mirrors the reference's
159/// `DirectPathPrepareMessage._process_metadata`/`_process_return_parameters`.
160pub fn parse_direct_path_prepare_response(
161    payload: &[u8],
162    capabilities: ClientCapabilities,
163) -> Result<DirectPathPrepareResult> {
164    parse_direct_path_prepare_response_with_limits(payload, capabilities, ProtocolLimits::DEFAULT)
165}
166
167pub fn parse_direct_path_prepare_response_with_limits(
168    payload: &[u8],
169    capabilities: ClientCapabilities,
170    limits: ProtocolLimits,
171) -> Result<DirectPathPrepareResult> {
172    let mut reader = TtcReader::with_limits(payload, limits)?;
173    let mut result: Option<DirectPathPrepareResult> = None;
174    while reader.remaining() > 0 {
175        let message_type = reader.read_u8()?;
176        match message_type {
177            0 => {}
178            TNS_MSG_TYPE_PARAMETER => {
179                result = Some(parse_prepare_return_parameters(&mut reader, capabilities)?);
180            }
181            TNS_MSG_TYPE_STATUS => {
182                let _call_status = reader.read_ub4()?;
183                let _seq = reader.read_ub2()?;
184            }
185            TNS_MSG_TYPE_SERVER_SIDE_PIGGYBACK => {
186                let _ = skip_server_side_piggyback(&mut reader)?;
187            }
188            TNS_MSG_TYPE_END_OF_RESPONSE => break,
189            TNS_MSG_TYPE_ERROR => {
190                let info = parse_server_error_info(&mut reader, capabilities.ttc_field_version)?;
191                if info.number != 0 {
192                    return Err(ProtocolError::ServerError(info.message));
193                }
194            }
195            _ => {
196                return Err(ProtocolError::UnknownMessageType {
197                    message_type,
198                    position: reader.position().saturating_sub(1),
199                })
200            }
201        }
202    }
203    result.ok_or(ProtocolError::TtcDecode(
204        "direct path prepare response did not contain return parameters",
205    ))
206}
207
208fn parse_prepare_return_parameters(
209    reader: &mut TtcReader<'_>,
210    capabilities: ClientCapabilities,
211) -> Result<DirectPathPrepareResult> {
212    let num_columns = reader.read_ub4()?;
213    reader.limits().check_columns(num_columns as usize)?;
214    // Each column reads a multi-field metadata record (>=1 byte), so bound the
215    // reservation by the buffer (BoundedReader) instead of an arbitrary cap;
216    // parse_column_metadata still fails closed on truncation.
217    let mut column_metadata: Vec<ColumnMetadata> =
218        reader.with_capacity_limited(num_columns as usize, 1, ProtocolLimits::check_columns)?;
219    for _ in 0..num_columns {
220        let mut metadata = parse_column_metadata(reader, capabilities)?;
221        apply_direct_path_metadata_overrides(&mut metadata, capabilities.charset_id);
222        column_metadata.push(metadata);
223    }
224    let num_params = reader.read_ub2()?;
225    if num_params != 0 {
226        return Err(ProtocolError::TtcDecode(
227            "unexpected parameters in direct path prepare response",
228        ));
229    }
230    let out_values_length = reader.read_ub2()?;
231    reader
232        .limits()
233        .check_length_prefixed_elements(usize::from(out_values_length))?;
234    // Each out value is a ub4 (>=1 byte on the wire); bound by the buffer.
235    let mut out_values: Vec<u32> = reader.with_capacity_limited(
236        usize::from(out_values_length),
237        1,
238        ProtocolLimits::check_length_prefixed_elements,
239    )?;
240    for _ in 0..out_values_length {
241        out_values.push(reader.read_ub4()?);
242    }
243    let cursor_id =
244        out_values
245            .get(TNS_DPP_OUT_INDEX_CURSOR)
246            .copied()
247            .ok_or(ProtocolError::TtcDecode(
248                "direct path prepare response missing cursor id",
249            ))?;
250    let cursor_id = u16::try_from(cursor_id)
251        .map_err(|_| ProtocolError::TtcDecode("direct path cursor id out of range"))?;
252    Ok(DirectPathPrepareResult {
253        column_metadata,
254        cursor_id,
255        out_values,
256    })
257}
258
259/// CLOB/NCLOB and BLOB columns are always streamed as LONG/LONG RAW during a
260/// direct path load. Implicit-charset CLOBs switch to the NCHAR form when the
261/// database charset is multi-byte (charset ids >= 800).
262fn apply_direct_path_metadata_overrides(metadata: &mut ColumnMetadata, charset_id: u16) {
263    if metadata.ora_type_num == ORA_TYPE_NUM_CLOB {
264        if metadata.csfrm == CS_FORM_IMPLICIT && charset_id >= 800 {
265            metadata.csfrm = CS_FORM_NCHAR;
266        }
267        metadata.ora_type_num = ORA_TYPE_NUM_LONG;
268    } else if metadata.ora_type_num == ORA_TYPE_NUM_BLOB {
269        metadata.ora_type_num = ORA_TYPE_NUM_LONG_RAW;
270        metadata.csfrm = 0;
271    }
272}
273
274/// Builds the payload for TTC function 130 (direct path op).
275///
276/// Mirrors `DirectPathOpMessage._write_message`. `op_code` is
277/// [`TNS_DP_OP_FINISH`] (commits the load) or [`TNS_DP_OP_ABORT`].
278pub fn build_direct_path_op_payload(cursor_id: u16, op_code: u32, seq_num: u8) -> Vec<u8> {
279    build_direct_path_op_payload_with_version(
280        cursor_id,
281        op_code,
282        seq_num,
283        crate::thin::TNS_CCAP_FIELD_VERSION_23_1_EXT_1,
284    )
285}
286
287/// Version-aware [`build_direct_path_op_payload`]: the ub8 TTC token in the
288/// function header is written only when `ttc_field_version >=
289/// TNS_CCAP_FIELD_VERSION_23_1_EXT_1`. A pre-23ai server misparses the stray
290/// token, shifting the message so a later mandatory field is read past the end
291/// (`ORA-03147: missing mandatory TTC field`; observed live on Oracle XE
292/// 18c/21c). Bead rust-oracledb-dpl23.
293pub fn build_direct_path_op_payload_with_version(
294    cursor_id: u16,
295    op_code: u32,
296    seq_num: u8,
297    ttc_field_version: u8,
298) -> Vec<u8> {
299    let mut writer = TtcWriter::new();
300    writer.write_function_header(TNS_FUNC_DIRECT_PATH_OP, seq_num, ttc_field_version);
301    writer.write_ub4(op_code);
302    writer.write_ub2(cursor_id);
303    writer.write_u8(0); // pointer (input values)
304    writer.write_ub4(0); // number of input values
305    writer.write_u8(1); // pointer (output values)
306    writer.write_u8(1); // pointer (output values length)
307    writer.into_bytes()
308}
309
310/// Parses the response to TTC functions 129 and 130 (both return the same
311/// shape: a ub2 count of out values that are each skipped).
312pub fn parse_direct_path_simple_response(
313    payload: &[u8],
314    capabilities: ClientCapabilities,
315) -> Result<()> {
316    parse_direct_path_simple_response_with_limits(payload, capabilities, ProtocolLimits::DEFAULT)
317}
318
319pub fn parse_direct_path_simple_response_with_limits(
320    payload: &[u8],
321    capabilities: ClientCapabilities,
322    limits: ProtocolLimits,
323) -> Result<()> {
324    let mut reader = TtcReader::with_limits(payload, limits)?;
325    while reader.remaining() > 0 {
326        let message_type = reader.read_u8()?;
327        match message_type {
328            0 => {}
329            TNS_MSG_TYPE_PARAMETER => {
330                let num_out_values = reader.read_ub2()?;
331                for _ in 0..num_out_values {
332                    let _value = reader.read_ub4()?;
333                }
334            }
335            TNS_MSG_TYPE_STATUS => {
336                let _call_status = reader.read_ub4()?;
337                let _seq = reader.read_ub2()?;
338            }
339            TNS_MSG_TYPE_SERVER_SIDE_PIGGYBACK => {
340                let _ = skip_server_side_piggyback(&mut reader)?;
341            }
342            TNS_MSG_TYPE_END_OF_RESPONSE => break,
343            TNS_MSG_TYPE_ERROR => {
344                let info = parse_server_error_info(&mut reader, capabilities.ttc_field_version)?;
345                if info.number != 0 {
346                    return Err(ProtocolError::ServerError(info.message));
347                }
348            }
349            _ => {
350                return Err(ProtocolError::UnknownMessageType {
351                    message_type,
352                    position: reader.position().saturating_sub(1),
353                })
354            }
355        }
356    }
357    Ok(())
358}
359
360pub use parse_direct_path_simple_response as parse_direct_path_load_stream_response;
361pub use parse_direct_path_simple_response as parse_direct_path_op_response;
362pub use parse_direct_path_simple_response_with_limits as parse_direct_path_load_stream_response_with_limits;
363pub use parse_direct_path_simple_response_with_limits as parse_direct_path_op_response_with_limits;
364
365/// One column value of a direct path load row, already converted to the
366/// Oracle-facing intermediate form (mirrors the reference's `OracleData`).
367///
368/// `Bytes` carries the on-the-wire byte payload for VARCHAR/CHAR/LONG (text
369/// already encoded per the column's charset form) and RAW/LONG RAW columns.
370#[derive(Clone, Debug, PartialEq)]
371pub enum DirectPathColumnValue {
372    Null,
373    Bytes(Vec<u8>),
374    Number(String),
375    BinaryDouble(f64),
376    BinaryFloat(f32),
377    DateTime {
378        year: i32,
379        month: u8,
380        day: u8,
381        hour: u8,
382        minute: u8,
383        second: u8,
384        nanosecond: u32,
385    },
386    Boolean(bool),
387}
388
389/// A finalized direct path piece, ready to be written to a load stream
390/// message.
391#[derive(Clone, Debug, Eq, PartialEq)]
392pub struct DirectPathPiece {
393    flags: u8,
394    num_segments: u8,
395    data: Vec<u8>,
396}
397
398impl DirectPathPiece {
399    pub fn flags(&self) -> u8 {
400        self.flags
401    }
402
403    pub fn num_segments(&self) -> u8 {
404        self.num_segments
405    }
406
407    pub fn data(&self) -> &[u8] {
408        &self.data
409    }
410
411    fn is_fast_row(&self) -> bool {
412        self.flags & TNS_DPLS_ROW_HEADER_FAST_ROW != 0
413    }
414
415    fn header_length(&self) -> u64 {
416        if self.is_fast_row() {
417            4
418        } else {
419            2
420        }
421    }
422
423    fn write_to(&self, writer: &mut TtcWriter) -> Result<()> {
424        writer.write_u8(self.flags);
425        if self.is_fast_row() {
426            let total = self.data.len() as u64 + self.header_length();
427            let total = u16::try_from(total).map_err(|_| {
428                ProtocolError::TtcDecode("direct path fast piece exceeds 16-bit length")
429            })?;
430            writer.write_u16be(total);
431        }
432        writer.write_u8(self.num_segments);
433        writer.write_raw(&self.data);
434        Ok(())
435    }
436}
437
438#[derive(Clone, Copy, Debug, Default)]
439struct PieceState {
440    is_first: bool,
441    is_last: bool,
442    is_split_with_prev: bool,
443    is_split_with_next: bool,
444    is_fast: bool,
445    num_segments: u16,
446}
447
448/// Streaming encoder for the direct path column-array piece format.
449///
450/// Port of the reference `PieceBuffer` (direct_path_load_stream.pyx). Usage:
451/// `start_row()` / `add_column_value(..)` per column / `finish_row()`, then
452/// [`DirectPathPieceBuffer::finish`].
453#[derive(Debug, Default)]
454pub(crate) struct DirectPathPieceBuffer {
455    pieces: Vec<DirectPathPiece>,
456    total_piece_length: u64,
457    data: Vec<u8>,
458    current: Option<PieceState>,
459}
460
461impl DirectPathPieceBuffer {
462    pub fn new() -> Self {
463        Self::default()
464    }
465
466    pub fn start_row(&mut self) -> Result<()> {
467        if self.current.is_some() {
468            return Err(ProtocolError::TtcDecode(
469                "direct path row started before previous row was finished",
470            ));
471        }
472        self.current = Some(PieceState {
473            is_first: true,
474            is_fast: true,
475            ..PieceState::default()
476        });
477        Ok(())
478    }
479
480    pub fn finish_row(&mut self) -> Result<()> {
481        let Some(state) = self.current.as_mut() else {
482            return Err(ProtocolError::TtcDecode(
483                "direct path row finished without being started",
484            ));
485        };
486        state.is_last = true;
487        self.finalize_piece()?;
488        self.current = None;
489        Ok(())
490    }
491
492    pub fn add_column_value(
493        &mut self,
494        metadata: &ColumnMetadata,
495        value: &DirectPathColumnValue,
496        row_num: u64,
497    ) -> Result<()> {
498        let Some(state) = self.current.as_mut() else {
499            return Err(ProtocolError::TtcDecode(
500                "direct path column value added outside of a row",
501            ));
502        };
503
504        // at most 255 segments per piece
505        if state.num_segments == 255 {
506            self.finalize_piece()?;
507            self.current = Some(PieceState::default());
508        }
509
510        if !is_fast_dbtype(metadata) {
511            if let Some(state) = self.current.as_mut() {
512                state.is_fast = false;
513            }
514        }
515
516        match value {
517            DirectPathColumnValue::Null => {
518                if !metadata.nulls_allowed {
519                    return Err(ProtocolError::NullsNotAllowed {
520                        column_name: metadata.name.clone(),
521                        row_num,
522                    });
523                }
524                self.write_u8_in_piece(TNS_NULL_LENGTH_INDICATOR)?;
525                self.bump_segments();
526                Ok(())
527            }
528            DirectPathColumnValue::Bytes(bytes) => {
529                if !matches!(
530                    metadata.ora_type_num,
531                    ORA_TYPE_NUM_VARCHAR
532                        | ORA_TYPE_NUM_CHAR
533                        | ORA_TYPE_NUM_LONG
534                        | ORA_TYPE_NUM_RAW
535                        | ORA_TYPE_NUM_LONG_RAW
536                ) {
537                    return Err(ProtocolError::TtcDecode(
538                        "direct path byte value sent for non-character column",
539                    ));
540                }
541                if metadata.max_size > 0 && bytes.len() as u64 > u64::from(metadata.max_size) {
542                    return Err(ProtocolError::ValueTooLarge {
543                        actual_size: bytes.len(),
544                        max_size: metadata.max_size,
545                        column_name: metadata.name.clone(),
546                        row_num,
547                    });
548                }
549                self.write_raw_bytes_and_length(bytes)
550            }
551            DirectPathColumnValue::Number(text) => {
552                if !matches!(
553                    metadata.ora_type_num,
554                    ORA_TYPE_NUM_NUMBER | ORA_TYPE_NUM_BINARY_INTEGER
555                ) {
556                    return Err(ProtocolError::TtcDecode(
557                        "direct path number value sent for non-number column",
558                    ));
559                }
560                let encoded = encode_number_text(text)?;
561                self.write_raw_bytes_and_length(&encoded)
562            }
563            DirectPathColumnValue::BinaryDouble(value) => {
564                if metadata.ora_type_num != ORA_TYPE_NUM_BINARY_DOUBLE {
565                    return Err(ProtocolError::TtcDecode(
566                        "direct path binary double sent for other column type",
567                    ));
568                }
569                let encoded = encode_binary_double(*value);
570                self.write_raw_bytes_and_length(&encoded)
571            }
572            DirectPathColumnValue::BinaryFloat(value) => {
573                if metadata.ora_type_num != ORA_TYPE_NUM_BINARY_FLOAT {
574                    return Err(ProtocolError::TtcDecode(
575                        "direct path binary float sent for other column type",
576                    ));
577                }
578                let encoded = encode_binary_float(*value);
579                self.write_raw_bytes_and_length(&encoded)
580            }
581            DirectPathColumnValue::DateTime {
582                year,
583                month,
584                day,
585                hour,
586                minute,
587                second,
588                nanosecond,
589            } => {
590                let encoded = match metadata.ora_type_num {
591                    ORA_TYPE_NUM_DATE => {
592                        if *nanosecond != 0 {
593                            return Err(ProtocolError::TtcDecode(
594                                "direct path DATE value has fractional seconds",
595                            ));
596                        }
597                        encode_oracle_date(*year, *month, *day, *hour, *minute, *second)?.to_vec()
598                    }
599                    // the protocol requires a timestamp with zero fractional
600                    // seconds to be transmitted as a 7-byte date
601                    ORA_TYPE_NUM_TIMESTAMP | ORA_TYPE_NUM_TIMESTAMP_LTZ => encode_oracle_timestamp(
602                        *year,
603                        *month,
604                        *day,
605                        *hour,
606                        *minute,
607                        *second,
608                        *nanosecond,
609                    )?,
610                    ORA_TYPE_NUM_TIMESTAMP_TZ => encode_oracle_timestamp_tz(
611                        *year,
612                        *month,
613                        *day,
614                        *hour,
615                        *minute,
616                        *second,
617                        *nanosecond,
618                    )?,
619                    _ => {
620                        return Err(ProtocolError::TtcDecode(
621                            "direct path datetime sent for non-datetime column",
622                        ))
623                    }
624                };
625                self.write_raw_bytes_and_length(&encoded)
626            }
627            DirectPathColumnValue::Boolean(value) => {
628                if metadata.ora_type_num != ORA_TYPE_NUM_BOOLEAN {
629                    return Err(ProtocolError::TtcDecode(
630                        "direct path boolean sent for non-boolean column",
631                    ));
632                }
633                let encoded: &[u8] = if *value { &[1, 1] } else { &[0] };
634                self.write_raw_bytes_and_length(encoded)
635            }
636        }
637    }
638
639    /// Finalizes the stream and returns the pieces plus the total piece
640    /// length (piece data plus piece headers) for the load stream message.
641    pub fn finish(self) -> Result<(Vec<DirectPathPiece>, u32)> {
642        if self.current.is_some() {
643            return Err(ProtocolError::TtcDecode(
644                "direct path stream finished mid-row",
645            ));
646        }
647        let total = u32::try_from(self.total_piece_length)
648            .map_err(|_| ProtocolError::DirectPathLoadTooMuchData)?;
649        Ok((self.pieces, total))
650    }
651
652    fn bump_segments(&mut self) {
653        if let Some(state) = self.current.as_mut() {
654            state.num_segments = state.num_segments.saturating_add(1);
655        }
656    }
657
658    fn space_left(&self) -> usize {
659        TNS_DPLS_MAX_PIECE_SIZE.saturating_sub(self.data.len())
660    }
661
662    fn write_u8_in_piece(&mut self, value: u8) -> Result<()> {
663        if self.space_left() < 1 {
664            self.finalize_piece()?;
665            self.current = Some(PieceState::default());
666        }
667        self.data.push(value);
668        Ok(())
669    }
670
671    /// Mirrors `PieceBuffer._write_raw_bytes_and_length`: short values
672    /// (<= 0xfa bytes) are written as `u8 length + data`; longer values are
673    /// written as one or more `0xfe + u16be length + data` chunks that may
674    /// split across pieces with the SPLIT_WITH_PREV/NEXT flags.
675    fn write_raw_bytes_and_length(&mut self, bytes: &[u8]) -> Result<()> {
676        if bytes.len() <= TNS_DPLS_MAX_SHORT_LENGTH {
677            if bytes.len() + 1 > self.space_left() {
678                self.finalize_piece()?;
679                self.current = Some(PieceState::default());
680            }
681            self.data.push(bytes.len() as u8);
682            self.data.extend_from_slice(bytes);
683            self.bump_segments();
684            return Ok(());
685        }
686
687        let mut remaining = bytes;
688        while remaining.len() + 3 > self.space_left() {
689            // Fail-closed divergence from the reference: if fewer than four
690            // bytes remain in the piece the reference would emit a corrupt
691            // zero/negative-length chunk; start a fresh piece instead.
692            if self.space_left() < 4 {
693                self.finalize_piece()?;
694                self.current = Some(PieceState::default());
695                continue;
696            }
697            let chunk_len = self.space_left() - 3;
698            let (chunk, rest) = remaining.split_at(chunk_len.min(remaining.len()));
699            self.data.push(TNS_DPLS_LONG_LENGTH_INDICATOR);
700            self.data
701                .extend_from_slice(&(chunk.len() as u16).to_be_bytes());
702            self.data.extend_from_slice(chunk);
703            remaining = rest;
704            if let Some(state) = self.current.as_mut() {
705                state.is_split_with_next = true;
706            }
707            self.bump_segments();
708            self.finalize_piece()?;
709            self.current = Some(PieceState {
710                is_split_with_prev: !remaining.is_empty(),
711                ..PieceState::default()
712            });
713        }
714        if !remaining.is_empty() {
715            self.bump_segments();
716            self.data.push(TNS_DPLS_LONG_LENGTH_INDICATOR);
717            self.data
718                .extend_from_slice(&(remaining.len() as u16).to_be_bytes());
719            self.data.extend_from_slice(remaining);
720        }
721        Ok(())
722    }
723
724    fn finalize_piece(&mut self) -> Result<()> {
725        let Some(state) = self.current.take() else {
726            return Err(ProtocolError::TtcDecode(
727                "direct path piece finalized without an active piece",
728            ));
729        };
730        let mut flags = 0u8;
731        if state.is_first {
732            flags |= TNS_DPLS_ROW_HEADER_FIRST;
733        } else if state.is_split_with_prev {
734            flags |= TNS_DPLS_ROW_HEADER_SPLIT_WITH_PREV;
735        }
736        if state.is_last {
737            flags |= TNS_DPLS_ROW_HEADER_LAST;
738        } else if state.is_split_with_next {
739            flags |= TNS_DPLS_ROW_HEADER_SPLIT_WITH_NEXT;
740        }
741        let is_fast_row = state.is_first && state.is_last && state.is_fast;
742        if is_fast_row {
743            flags |= TNS_DPLS_ROW_HEADER_FAST_ROW | TNS_DPLS_ROW_HEADER_FAST_PIECE;
744        }
745        let num_segments = u8::try_from(state.num_segments)
746            .map_err(|_| ProtocolError::TtcDecode("direct path piece segment count overflow"))?;
747        let piece = DirectPathPiece {
748            flags,
749            num_segments,
750            data: std::mem::take(&mut self.data),
751        };
752        let new_length = self.total_piece_length + piece.data.len() as u64 + piece.header_length();
753        if new_length > TNS_DPLS_MAX_MESSAGE_SIZE {
754            return Err(ProtocolError::DirectPathLoadTooMuchData);
755        }
756        self.total_piece_length = new_length;
757        self.pieces.push(piece);
758        // callers decide what the next piece (if any) looks like
759        Ok(())
760    }
761}
762
763/// Fast direct path types per the reference `DbType._is_fast` flags. LONG and
764/// LONG RAW (and thus inlined CLOB/BLOB) are not fast.
765fn is_fast_dbtype(metadata: &ColumnMetadata) -> bool {
766    matches!(
767        metadata.ora_type_num,
768        ORA_TYPE_NUM_VARCHAR
769            | ORA_TYPE_NUM_NUMBER
770            | ORA_TYPE_NUM_BINARY_INTEGER
771            | ORA_TYPE_NUM_CHAR
772            | ORA_TYPE_NUM_DATE
773            | ORA_TYPE_NUM_RAW
774            | ORA_TYPE_NUM_BINARY_FLOAT
775            | ORA_TYPE_NUM_BINARY_DOUBLE
776            | ORA_TYPE_NUM_BOOLEAN
777            | ORA_TYPE_NUM_TIMESTAMP
778            | ORA_TYPE_NUM_TIMESTAMP_TZ
779            | ORA_TYPE_NUM_TIMESTAMP_LTZ
780    )
781}
782
783/// Result of encoding one batch of rows into the piece stream format.
784#[derive(Clone, Debug, Eq, PartialEq)]
785pub struct DirectPathStream {
786    pub(crate) pieces: Vec<DirectPathPiece>,
787    pub(crate) total_piece_length: u32,
788}
789
790/// Encodes a batch of rows into direct path pieces.
791///
792/// `first_row_num` is the 1-based number of the first row in this batch for
793/// error reporting; the reference keeps a running row counter across batches
794/// of a single `direct_path_load` call.
795pub fn encode_direct_path_rows(
796    column_metadata: &[ColumnMetadata],
797    rows: &[Vec<DirectPathColumnValue>],
798    first_row_num: u64,
799) -> Result<DirectPathStream> {
800    let mut buffer = DirectPathPieceBuffer::new();
801    for (row_index, row) in rows.iter().enumerate() {
802        if row.len() != column_metadata.len() {
803            return Err(ProtocolError::TtcDecode(
804                "direct path row width does not match column metadata",
805            ));
806        }
807        let row_num = first_row_num + row_index as u64;
808        buffer.start_row()?;
809        for (metadata, value) in column_metadata.iter().zip(row) {
810            buffer.add_column_value(metadata, value, row_num)?;
811        }
812        buffer.finish_row()?;
813    }
814    let (pieces, total_piece_length) = buffer.finish()?;
815    Ok(DirectPathStream {
816        pieces,
817        total_piece_length,
818    })
819}
820
821/// Builds the payload for TTC function 129 (direct path load stream).
822///
823/// Mirrors `DirectPathLoadStreamMessage._write_message`.
824pub fn build_direct_path_load_stream_payload(
825    cursor_id: u16,
826    stream: &DirectPathStream,
827    seq_num: u8,
828) -> Result<Vec<u8>> {
829    build_direct_path_load_stream_payload_with_version(
830        cursor_id,
831        stream,
832        seq_num,
833        crate::thin::TNS_CCAP_FIELD_VERSION_23_1_EXT_1,
834    )
835}
836
837/// Version-aware [`build_direct_path_load_stream_payload`]: gates the ub8 TTC
838/// token on `ttc_field_version` (see `build_direct_path_op_payload_with_version`
839/// for the ORA-03147 pre-23ai rationale). Bead rust-oracledb-dpl23.
840pub fn build_direct_path_load_stream_payload_with_version(
841    cursor_id: u16,
842    stream: &DirectPathStream,
843    seq_num: u8,
844    ttc_field_version: u8,
845) -> Result<Vec<u8>> {
846    let mut writer = TtcWriter::new();
847    writer.write_function_header(TNS_FUNC_DIRECT_PATH_LOAD_STREAM, seq_num, ttc_field_version);
848    writer.write_ub2(cursor_id);
849    writer.write_u8(1); // pointer (buffer)
850    writer.write_ub4(stream.total_piece_length);
851    writer.write_ub4(TNS_DP_STREAM_VERSION);
852    writer.write_u8(0); // pointer (input values)
853    writer.write_ub4(0); // number of input values
854    writer.write_u8(1); // pointer (output values)
855    writer.write_u8(1); // pointer (output values length)
856    for piece in &stream.pieces {
857        piece.write_to(&mut writer)?;
858    }
859    Ok(writer.into_bytes())
860}
861
862/// Batch/chunk state machine shared by `executemany` ingestion and direct
863/// path load. Port of `BatchLoadManager`/`DataFrameBatchLoadManager`
864/// (impl/base/batch_load_manager.pyx).
865///
866/// The data source is modelled as a list of chunks (an Arrow chunked array
867/// has one entry per chunk; a plain list of rows is a single chunk). Batches
868/// never span chunk boundaries; `message_offset` is the row offset *within
869/// the current chunk* that must accompany the execute/load message.
870#[derive(Clone, Debug, Eq, PartialEq)]
871pub struct BatchLoadState {
872    chunk_lengths: Vec<u64>,
873    batch_size: u32,
874    chunk_index: usize,
875    offset: u64,
876    message_offset: u64,
877    num_rows: u32,
878}
879
880impl BatchLoadState {
881    pub fn new(chunk_lengths: Vec<u64>, batch_size: u32) -> Result<Self> {
882        if batch_size == 0 {
883            return Err(ProtocolError::TtcDecode(
884                "batch_size must be a positive integer",
885            ));
886        }
887        let mut state = Self {
888            chunk_lengths,
889            batch_size,
890            chunk_index: 0,
891            offset: 0,
892            message_offset: 0,
893            num_rows: 0,
894        };
895        state.advance_batch();
896        Ok(state)
897    }
898
899    /// Creates the state machine for a single-chunk source of `total_rows`
900    /// rows (a plain list of rows).
901    pub fn for_rows(total_rows: u64, batch_size: u32) -> Result<Self> {
902        Self::new(vec![total_rows], batch_size)
903    }
904
905    /// Number of rows in the current batch; zero means the load is complete.
906    pub fn num_rows(&self) -> u32 {
907        self.num_rows
908    }
909
910    /// Row offset of the current batch within the current chunk.
911    pub fn offset(&self) -> u64 {
912        self.offset
913    }
914
915    /// Offset to send with the execute/load message (row offset within the
916    /// current chunk at the time the batch was formed).
917    pub fn message_offset(&self) -> u64 {
918        self.message_offset
919    }
920
921    /// Index of the chunk the current batch draws from.
922    pub fn chunk_index(&self) -> usize {
923        self.chunk_index
924    }
925
926    pub fn is_done(&self) -> bool {
927        self.num_rows == 0
928    }
929
930    /// Advances to the next batch (mirrors `BatchLoadManager.next_batch`).
931    pub fn next_batch(&mut self) {
932        self.offset += u64::from(self.num_rows);
933        self.advance_batch();
934    }
935
936    fn rows_in_current_chunk(&self) -> u64 {
937        self.chunk_lengths
938            .get(self.chunk_index)
939            .copied()
940            .unwrap_or(0)
941    }
942
943    fn calculate_num_rows_in_batch(&mut self) {
944        let remaining = self.rows_in_current_chunk().saturating_sub(self.offset);
945        self.num_rows = u32::try_from(remaining.min(u64::from(self.batch_size))).unwrap_or(0);
946    }
947
948    fn advance_batch(&mut self) {
949        self.message_offset = self.offset;
950        self.calculate_num_rows_in_batch();
951        if self.num_rows == 0 {
952            self.advance_chunk();
953        }
954    }
955
956    fn advance_chunk(&mut self) {
957        while self.chunk_index + 1 < self.chunk_lengths.len() {
958            self.offset = 0;
959            self.message_offset = 0;
960            self.chunk_index += 1;
961            self.calculate_num_rows_in_batch();
962            if self.num_rows > 0 {
963                break;
964            }
965        }
966    }
967}
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972
973    // BoundedReader invariant (l2p), direct-path columns family: a PARAMETER
974    // message declaring a huge num_columns (ub4 ~620M) with no column-metadata
975    // bytes following must fail closed via with_capacity_bounded + the per-
976    // column parse, not reserve one ColumnMetadata per declared column. (This
977    // replaces the old arbitrary `.min(1024)` cap with a buffer-anchored bound.)
978    #[test]
979    fn direct_path_oversized_column_count_fails_closed_not_oom() {
980        // type=8 PARAMETER; num_columns ub4 (len byte 4) = 0x25000000, then EOF.
981        let payload = [TNS_MSG_TYPE_PARAMETER, 4, 0x25, 0x00, 0x00, 0x00];
982        let err = parse_direct_path_prepare_response(&payload, ClientCapabilities::default())
983            .expect_err("oversized direct-path column count must fail closed");
984        assert!(
985            matches!(
986                err,
987                ProtocolError::TtcDecode(_) | ProtocolError::ResourceLimit { .. }
988            ),
989            "got {err:?}"
990        );
991    }
992
993    #[test]
994    fn direct_path_prepare_respects_protocol_column_limit() {
995        let payload = [TNS_MSG_TYPE_PARAMETER, 1, 2];
996        let limits = ProtocolLimits {
997            max_columns: 1,
998            ..ProtocolLimits::DEFAULT
999        };
1000        let err = parse_direct_path_prepare_response_with_limits(
1001            &payload,
1002            ClientCapabilities::default(),
1003            limits,
1004        )
1005        .expect_err("column count above policy must fail");
1006        assert!(
1007            matches!(
1008                err,
1009                ProtocolError::ResourceLimit {
1010                    limit: "columns",
1011                    observed: 2,
1012                    maximum: 1,
1013                }
1014            ),
1015            "got {err:?}"
1016        );
1017    }
1018
1019    fn column(name: &str, ora_type_num: u8, max_size: u32, nulls_allowed: bool) -> ColumnMetadata {
1020        ColumnMetadata {
1021            name: name.to_string(),
1022            ora_type_num,
1023            csfrm: if matches!(
1024                ora_type_num,
1025                ORA_TYPE_NUM_VARCHAR | ORA_TYPE_NUM_CHAR | ORA_TYPE_NUM_LONG
1026            ) {
1027                CS_FORM_IMPLICIT
1028            } else {
1029                0
1030            },
1031            precision: 0,
1032            scale: 0,
1033            buffer_size: max_size,
1034            max_size,
1035            nulls_allowed,
1036            is_json: false,
1037            is_oson: false,
1038            object_schema: None,
1039            object_type_name: None,
1040            is_array: false,
1041            vector_dimensions: None,
1042            vector_format: 0,
1043            vector_flags: 0,
1044            ..Default::default()
1045        }
1046    }
1047
1048    #[test]
1049    fn prepare_payload_matches_reference_layout() {
1050        let payload = build_direct_path_prepare_payload(
1051            "pythontest",
1052            "dpl_golden",
1053            &["id".to_string(), "name".to_string()],
1054            10,
1055        )
1056        .expect("payload should build");
1057        // header: msg type, function code, seq, token
1058        assert_eq!(&payload[..4], &[3, 128, 10, 0]);
1059        let mut expected = vec![
1060            1, 1, // ub4 op code LOAD
1061            1, // kw pointer
1062            1, 4, // ub4 kw length = 2 columns + 2
1063            1, // input array pointer
1064            1, 15, // ub2 in values length
1065            1, 1, 1, 1, 1, 1, // six pointers
1066        ];
1067        // schema name
1068        expected.extend_from_slice(&[0, 1, 10]);
1069        expected.extend_from_slice(&[10]);
1070        expected.extend_from_slice(b"pythontest");
1071        expected.extend_from_slice(&[1, 3]);
1072        // table name
1073        expected.extend_from_slice(&[0, 1, 10]);
1074        expected.extend_from_slice(&[10]);
1075        expected.extend_from_slice(b"dpl_golden");
1076        expected.extend_from_slice(&[1, 1]);
1077        // column names
1078        expected.extend_from_slice(&[0, 1, 2, 2]);
1079        expected.extend_from_slice(b"id");
1080        expected.extend_from_slice(&[1, 4]);
1081        expected.extend_from_slice(&[0, 1, 4, 4]);
1082        expected.extend_from_slice(b"name");
1083        expected.extend_from_slice(&[1, 4]);
1084        // in values: 400, 400, 9 zeros, 0xffff, 0, 0, 1
1085        expected.extend_from_slice(&[2, 0x01, 0x90, 2, 0x01, 0x90]);
1086        expected.extend_from_slice(&[0; 9]);
1087        expected.extend_from_slice(&[2, 0xff, 0xff, 0, 0, 1, 1]);
1088        assert_eq!(&payload[4..], expected.as_slice());
1089    }
1090
1091    #[test]
1092    fn op_payload_matches_reference_layout() {
1093        let payload = build_direct_path_op_payload(1, TNS_DP_OP_FINISH, 12);
1094        assert_eq!(
1095            payload,
1096            vec![3, 130, 12, 0, 1, 2, 1, 1, 0, 0, 1, 1],
1097            "fn code, seq, token, ub4 op, ub2 cursor, ptr 0, ub4 0, ptr 1, ptr 1"
1098        );
1099    }
1100
1101    #[test]
1102    fn single_fast_row_produces_one_fast_piece() {
1103        let columns = vec![
1104            column("ID", ORA_TYPE_NUM_NUMBER, 0, false),
1105            column("NAME", ORA_TYPE_NUM_VARCHAR, 100, false),
1106        ];
1107        let rows = vec![vec![
1108            DirectPathColumnValue::Number("1".into()),
1109            DirectPathColumnValue::Bytes(b"alpha".to_vec()),
1110        ]];
1111        let stream = encode_direct_path_rows(&columns, &rows, 1).expect("stream should encode");
1112        assert_eq!(stream.pieces.len(), 1);
1113        let piece = &stream.pieces[0];
1114        assert_eq!(
1115            piece.flags(),
1116            TNS_DPLS_ROW_HEADER_FIRST
1117                | TNS_DPLS_ROW_HEADER_LAST
1118                | TNS_DPLS_ROW_HEADER_FAST_ROW
1119                | TNS_DPLS_ROW_HEADER_FAST_PIECE
1120        );
1121        assert_eq!(piece.num_segments(), 2);
1122        // number 1 encodes as c1 02; "alpha" as length + bytes
1123        assert_eq!(
1124            piece.data(),
1125            &[2, 0xc1, 0x02, 5, b'a', b'l', b'p', b'h', b'a']
1126        );
1127        // total = data + 4-byte fast header
1128        assert_eq!(stream.total_piece_length, piece.data().len() as u32 + 4);
1129    }
1130
1131    #[test]
1132    fn long_column_clears_fast_flag() {
1133        let columns = vec![column("WIDE", ORA_TYPE_NUM_LONG, 0, false)];
1134        let rows = vec![vec![DirectPathColumnValue::Bytes(vec![b'x'; 10])]];
1135        let stream = encode_direct_path_rows(&columns, &rows, 1).expect("stream should encode");
1136        assert_eq!(stream.pieces.len(), 1);
1137        assert_eq!(
1138            stream.pieces[0].flags(),
1139            TNS_DPLS_ROW_HEADER_FIRST | TNS_DPLS_ROW_HEADER_LAST
1140        );
1141        // 1 length byte + 10 data bytes + 2-byte slow header
1142        assert_eq!(stream.total_piece_length, 11 + 2);
1143    }
1144
1145    #[test]
1146    fn null_values_encode_as_null_indicator() {
1147        let columns = vec![column("SALARY", ORA_TYPE_NUM_NUMBER, 0, true)];
1148        let rows = vec![vec![DirectPathColumnValue::Null]];
1149        let stream = encode_direct_path_rows(&columns, &rows, 1).expect("stream should encode");
1150        assert_eq!(stream.pieces[0].data(), &[0xff]);
1151        assert_eq!(stream.pieces[0].num_segments(), 1);
1152    }
1153
1154    #[test]
1155    fn null_into_not_null_column_raises_dpy_8001() {
1156        let columns = vec![column("NAME", ORA_TYPE_NUM_VARCHAR, 100, false)];
1157        let rows = vec![vec![DirectPathColumnValue::Null]];
1158        let err = encode_direct_path_rows(&columns, &rows, 1).expect_err("nulls must be rejected");
1159        assert!(
1160            err.to_string().starts_with("DPY-8001:"),
1161            "unexpected error: {err}"
1162        );
1163        assert!(err.to_string().contains("\"NAME\""), "{err}");
1164        assert!(err.to_string().contains("row 1"), "{err}");
1165    }
1166
1167    #[test]
1168    fn oversized_value_raises_dpy_8000() {
1169        let columns = vec![column("NAME", ORA_TYPE_NUM_VARCHAR, 4, false)];
1170        let rows = vec![vec![DirectPathColumnValue::Bytes(b"toolong".to_vec())]];
1171        let err = encode_direct_path_rows(&columns, &rows, 3).expect_err("size must be enforced");
1172        assert!(
1173            err.to_string().starts_with("DPY-8000:"),
1174            "unexpected error: {err}"
1175        );
1176        assert!(err.to_string().contains("row 3"), "{err}");
1177    }
1178
1179    #[test]
1180    fn long_values_use_fe_chunked_segments() {
1181        // 600 bytes > 0xfa, must use the 0xfe + u16be length form
1182        let columns = vec![column("WIDE", ORA_TYPE_NUM_VARCHAR, 1000, false)];
1183        let value = vec![b'q'; 600];
1184        let rows = vec![vec![DirectPathColumnValue::Bytes(value.clone())]];
1185        let stream = encode_direct_path_rows(&columns, &rows, 1).expect("stream should encode");
1186        assert_eq!(stream.pieces.len(), 1);
1187        let piece = &stream.pieces[0];
1188        assert_eq!(piece.num_segments(), 1);
1189        let mut expected = vec![0xfe, 0x02, 0x58];
1190        expected.extend_from_slice(&value);
1191        assert_eq!(piece.data(), expected.as_slice());
1192    }
1193
1194    #[test]
1195    fn values_larger_than_piece_split_across_pieces_with_split_flags() {
1196        let columns = vec![column("WIDE", ORA_TYPE_NUM_LONG, 0, false)];
1197        let total = TNS_DPLS_MAX_PIECE_SIZE + 100;
1198        let rows = vec![vec![DirectPathColumnValue::Bytes(vec![b'z'; total])]];
1199        let stream = encode_direct_path_rows(&columns, &rows, 1).expect("stream should encode");
1200        assert_eq!(stream.pieces.len(), 2);
1201        let first = &stream.pieces[0];
1202        let second = &stream.pieces[1];
1203        assert_eq!(
1204            first.flags(),
1205            TNS_DPLS_ROW_HEADER_FIRST | TNS_DPLS_ROW_HEADER_SPLIT_WITH_NEXT
1206        );
1207        assert_eq!(
1208            second.flags(),
1209            TNS_DPLS_ROW_HEADER_SPLIT_WITH_PREV | TNS_DPLS_ROW_HEADER_LAST
1210        );
1211        // first piece is filled to the brim: 3-byte chunk header + payload
1212        assert_eq!(first.data().len(), TNS_DPLS_MAX_PIECE_SIZE);
1213        assert_eq!(first.data()[0], 0xfe);
1214        let first_chunk = usize::from(u16::from_be_bytes([first.data()[1], first.data()[2]]));
1215        assert_eq!(first_chunk, TNS_DPLS_MAX_PIECE_SIZE - 3);
1216        let second_chunk = usize::from(u16::from_be_bytes([second.data()[1], second.data()[2]]));
1217        assert_eq!(first_chunk + second_chunk, total);
1218        assert_eq!(
1219            stream.total_piece_length as usize,
1220            first.data().len() + second.data().len() + 2 + 2
1221        );
1222    }
1223
1224    #[test]
1225    fn segment_count_caps_at_255_per_piece() {
1226        let columns: Vec<ColumnMetadata> = (0..300)
1227            .map(|i| column(&format!("C{i}"), ORA_TYPE_NUM_NUMBER, 0, true))
1228            .collect();
1229        let row: Vec<DirectPathColumnValue> =
1230            (0..300).map(|_| DirectPathColumnValue::Null).collect();
1231        let stream = encode_direct_path_rows(&columns, &[row], 1).expect("stream should encode");
1232        assert_eq!(stream.pieces.len(), 2);
1233        assert_eq!(stream.pieces[0].num_segments(), 255);
1234        assert_eq!(stream.pieces[1].num_segments(), 45);
1235        // continuation piece created by the segment cap carries neither FIRST
1236        // nor SPLIT_WITH_PREV (mirrors the reference)
1237        assert_eq!(stream.pieces[0].flags(), TNS_DPLS_ROW_HEADER_FIRST);
1238        assert_eq!(stream.pieces[1].flags(), TNS_DPLS_ROW_HEADER_LAST);
1239    }
1240
1241    #[test]
1242    fn timestamp_with_zero_fraction_collapses_to_seven_bytes() {
1243        let columns = vec![column("TS", ORA_TYPE_NUM_TIMESTAMP, 0, true)];
1244        let rows = vec![vec![DirectPathColumnValue::DateTime {
1245            year: 2024,
1246            month: 1,
1247            day: 2,
1248            hour: 3,
1249            minute: 4,
1250            second: 5,
1251            nanosecond: 0,
1252        }]];
1253        let stream = encode_direct_path_rows(&columns, &rows, 1).expect("stream should encode");
1254        assert_eq!(
1255            stream.pieces[0].data(),
1256            &[7, 120, 124, 1, 2, 4, 5, 6],
1257            "7-byte date form expected when fractional seconds are zero"
1258        );
1259    }
1260
1261    #[test]
1262    fn boolean_values_encode_per_reference() {
1263        let columns = vec![column("FLAG", ORA_TYPE_NUM_BOOLEAN, 0, true)];
1264        let rows = vec![
1265            vec![DirectPathColumnValue::Boolean(true)],
1266            vec![DirectPathColumnValue::Boolean(false)],
1267        ];
1268        let stream = encode_direct_path_rows(&columns, &rows, 1).expect("stream should encode");
1269        assert_eq!(stream.pieces[0].data(), &[2, 1, 1]);
1270        assert_eq!(stream.pieces[1].data(), &[1, 0]);
1271    }
1272
1273    #[test]
1274    fn row_width_mismatch_is_rejected() {
1275        let columns = vec![
1276            column("A", ORA_TYPE_NUM_NUMBER, 0, true),
1277            column("B", ORA_TYPE_NUM_NUMBER, 0, true),
1278        ];
1279        let rows = vec![vec![DirectPathColumnValue::Null]];
1280        assert!(encode_direct_path_rows(&columns, &rows, 1).is_err());
1281    }
1282
1283    #[test]
1284    fn metadata_overrides_inline_lobs() {
1285        let mut clob = column("DOC", ORA_TYPE_NUM_CLOB, 0, true);
1286        clob.csfrm = CS_FORM_IMPLICIT;
1287        apply_direct_path_metadata_overrides(&mut clob, 873);
1288        assert_eq!(clob.ora_type_num, ORA_TYPE_NUM_LONG);
1289        assert_eq!(clob.csfrm, CS_FORM_NCHAR, "multi-byte charset uses NCHAR");
1290
1291        let mut clob = column("DOC", ORA_TYPE_NUM_CLOB, 0, true);
1292        clob.csfrm = CS_FORM_IMPLICIT;
1293        apply_direct_path_metadata_overrides(&mut clob, 178);
1294        assert_eq!(
1295            clob.csfrm, CS_FORM_IMPLICIT,
1296            "single-byte charset keeps form"
1297        );
1298
1299        let mut blob = column("BIN", ORA_TYPE_NUM_BLOB, 0, true);
1300        apply_direct_path_metadata_overrides(&mut blob, 873);
1301        assert_eq!(blob.ora_type_num, ORA_TYPE_NUM_LONG_RAW);
1302        assert_eq!(blob.csfrm, 0);
1303    }
1304
1305    #[test]
1306    fn batch_state_single_chunk_splits_by_batch_size() {
1307        let mut state = BatchLoadState::for_rows(5, 2).expect("state should build");
1308        assert_eq!(
1309            (state.num_rows(), state.offset(), state.message_offset()),
1310            (2, 0, 0)
1311        );
1312        state.next_batch();
1313        assert_eq!(
1314            (state.num_rows(), state.offset(), state.message_offset()),
1315            (2, 2, 2)
1316        );
1317        state.next_batch();
1318        assert_eq!(
1319            (state.num_rows(), state.offset(), state.message_offset()),
1320            (1, 4, 4)
1321        );
1322        state.next_batch();
1323        assert!(state.is_done());
1324    }
1325
1326    #[test]
1327    fn batch_state_never_spans_chunks() {
1328        // chunks of 3 and 2 rows with batch size 2: batches are 2, 1, 2
1329        let mut state = BatchLoadState::new(vec![3, 2], 2).expect("state should build");
1330        assert_eq!(
1331            (
1332                state.chunk_index(),
1333                state.num_rows(),
1334                state.message_offset()
1335            ),
1336            (0, 2, 0)
1337        );
1338        state.next_batch();
1339        assert_eq!(
1340            (
1341                state.chunk_index(),
1342                state.num_rows(),
1343                state.message_offset()
1344            ),
1345            (0, 1, 2)
1346        );
1347        state.next_batch();
1348        assert_eq!(
1349            (
1350                state.chunk_index(),
1351                state.num_rows(),
1352                state.message_offset()
1353            ),
1354            (1, 2, 0)
1355        );
1356        state.next_batch();
1357        assert!(state.is_done());
1358    }
1359
1360    #[test]
1361    fn batch_state_skips_empty_chunks() {
1362        let mut state = BatchLoadState::new(vec![0, 0, 3], 10).expect("state should build");
1363        assert_eq!((state.chunk_index(), state.num_rows()), (2, 3));
1364        state.next_batch();
1365        assert!(state.is_done());
1366    }
1367
1368    #[test]
1369    fn batch_state_rejects_zero_batch_size() {
1370        assert!(BatchLoadState::for_rows(5, 0).is_err());
1371    }
1372
1373    #[test]
1374    fn batch_state_empty_source_is_done_immediately() {
1375        let state = BatchLoadState::for_rows(0, 10).expect("state should build");
1376        assert!(state.is_done());
1377    }
1378
1379    #[test]
1380    fn load_stream_payload_header_matches_reference_layout() {
1381        let columns = vec![column("ID", ORA_TYPE_NUM_NUMBER, 0, false)];
1382        let rows = vec![vec![DirectPathColumnValue::Number("1".into())]];
1383        let stream = encode_direct_path_rows(&columns, &rows, 1).expect("stream should encode");
1384        let payload =
1385            build_direct_path_load_stream_payload(1, &stream, 11).expect("payload should build");
1386        let mut expected = vec![
1387            3, 129, 11, // fn code + seq
1388            0,  // token
1389            1, 1, // ub2 cursor id
1390            1, // buffer pointer
1391            1, 7, // ub4 total piece length (3 data + 4 header)
1392            2, 0x01, 0x90, // ub4 stream version 400
1393            0,    // input values pointer
1394            0,    // ub4 input values count
1395            1, 1, // output pointers
1396            0x3c, 0, 7, 1, // piece: flags, u16be total, num segments
1397            2, 0xc1, 0x02, // number 1
1398        ];
1399        assert_eq!(payload, std::mem::take(&mut expected));
1400    }
1401}