Skip to main content

oracledb_protocol/thin/
bind.rs

1#![forbid(unsafe_code)]
2
3use super::*;
4
5impl BindValue {
6    pub(crate) fn is_output_only(&self) -> bool {
7        matches!(self, BindValue::Output { .. })
8            || matches!(self, BindValue::ReturnOutput { .. })
9            || matches!(self, BindValue::ObjectOutput { .. })
10            || matches!(self, BindValue::Array { values, .. } if values.is_empty())
11    }
12
13    pub(crate) fn is_return_output(&self) -> bool {
14        matches!(self, BindValue::ReturnOutput { .. })
15            || matches!(
16                self,
17                BindValue::ObjectOutput {
18                    is_return: true,
19                    ..
20                }
21            )
22    }
23}
24
25pub(crate) fn write_bind_metadata_with_type(
26    writer: &mut TtcWriter,
27    value: &BindValue,
28    ora_type_num: u8,
29    csfrm: u8,
30    buffer_size: u32,
31    ttc_field_version: u8,
32) -> Result<()> {
33    let (flags, max_elements) = match value {
34        BindValue::Array { max_elements, .. } => {
35            (TNS_BIND_USE_INDICATORS | TNS_BIND_ARRAY, *max_elements)
36        }
37        _ => (TNS_BIND_USE_INDICATORS, 0),
38    };
39    // JSON binds advertise a TNS_JSON_MAX_LENGTH prefetch buffer (reference
40    // base.pyx:1398-1400) so a returned/out OSON image streams inline.
41    let buffer_size = if ora_type_num == ORA_TYPE_NUM_JSON {
42        TNS_JSON_MAX_LENGTH
43    } else {
44        buffer_size
45    };
46    writer.write_u8(ora_type_num);
47    writer.write_u8(flags);
48    writer.write_u8(0);
49    writer.write_u8(0);
50    writer.write_ub4(buffer_size);
51    writer.write_ub4(max_elements);
52    let cont_flags = if matches!(
53        ora_type_num,
54        ORA_TYPE_NUM_CLOB | ORA_TYPE_NUM_BLOB | ORA_TYPE_NUM_VECTOR | ORA_TYPE_NUM_JSON
55    ) {
56        TNS_LOB_PREFETCH_FLAG
57    } else {
58        0
59    };
60    writer.write_ub8(cont_flags);
61    if let BindValue::ObjectOutput { oid, version, .. }
62    | BindValue::ObjectInput { oid, version, .. } = value
63    {
64        writer.write_bytes_with_two_lengths(Some(oid))?;
65        writer.write_ub4(*version);
66    } else {
67        writer.write_ub4(0);
68        writer.write_ub2(0);
69    }
70    if csfrm != 0 {
71        writer.write_ub2(TNS_CHARSET_UTF8);
72    } else {
73        writer.write_ub2(0);
74    }
75    writer.write_u8(csfrm);
76    // max chars (LOB prefetch length): VECTOR advertises TNS_VECTOR_MAX_LENGTH
77    // so the server prefetches the image inline (reference base.pyx)
78    let lob_prefetch_length = match ora_type_num {
79        ORA_TYPE_NUM_VECTOR => TNS_VECTOR_MAX_LENGTH,
80        ORA_TYPE_NUM_JSON => TNS_JSON_MAX_LENGTH,
81        _ => 0,
82    };
83    writer.write_ub4(lob_prefetch_length);
84    // oaccolid is gated on the negotiated field version >= 12.2 (reference
85    // messages/base.pyx:1429). A pre-12.2 server (ttc field version 7, Oracle
86    // 12.1 — above our accept floor of 315) does not read it, so writing it
87    // unconditionally adds a stray ub4 per bind column and corrupts the bind
88    // metadata. The symmetric read path already gates the same skip (see
89    // fetch.rs `>= TNS_CCAP_FIELD_VERSION_12_2`); the write side had missed it.
90    // Our live matrix floor is 18c (field version 11), where the branch always
91    // fired, so the miss stayed invisible.
92    if version_gates::carries_oaccolid(ttc_field_version) {
93        writer.write_ub4(0); // oaccolid
94    }
95    Ok(())
96}
97
98pub fn bind_value_type_info(value: &BindValue) -> Option<BindTypeInfo> {
99    let (ora_type_num, csfrm, buffer_size) = match value {
100        BindValue::Null => return None,
101        // An IN OUT bind carries an input value (whose Oracle type/charset form
102        // is the bind's type) but must reserve enough buffer for the value the
103        // server writes back, so its effective buffer is the larger of the
104        // input's natural size and the caller's requested output size. A NULL
105        // input carries no type; fall back to VARCHAR so the OUT slot is still
106        // typed and sized.
107        BindValue::InOut {
108            value,
109            out_buffer_size,
110        } => {
111            let (ora_type_num, csfrm, input_size) = match bind_value_type_info(value) {
112                Some(info) => (info.ora_type_num, info.csfrm, info.buffer_size),
113                None => (ORA_TYPE_NUM_VARCHAR, CS_FORM_IMPLICIT, 0),
114            };
115            return Some(BindTypeInfo {
116                ora_type_num,
117                csfrm,
118                buffer_size: input_size.max(*out_buffer_size).max(1),
119            });
120        }
121        BindValue::TypedNull {
122            ora_type_num,
123            csfrm,
124            buffer_size,
125        }
126        | BindValue::Output {
127            ora_type_num,
128            csfrm,
129            buffer_size,
130        }
131        | BindValue::ReturnOutput {
132            ora_type_num,
133            csfrm,
134            buffer_size,
135        } => (*ora_type_num, *csfrm, (*buffer_size).max(1)),
136        BindValue::ObjectOutput { buffer_size, .. }
137        | BindValue::ObjectInput { buffer_size, .. } => {
138            (ORA_TYPE_NUM_OBJECT, 0, (*buffer_size).max(1))
139        }
140        // values larger than 32767 bytes keep the VARCHAR/RAW bind type with
141        // a large buffer size; the chunked length encoding carries the data
142        // (reference always derives VARCHAR/RAW from str/bytes values —
143        // metadata.pyx from_value — and never switches the bind type to LONG,
144        // which the server rejects for PL/SQL LOB parameters with ORA-01460)
145        BindValue::Text(value) => {
146            let buffer_size = u32::try_from(value.chars().count())
147                .unwrap_or(u32::MAX)
148                .saturating_mul(4)
149                .max(1);
150            (ORA_TYPE_NUM_VARCHAR, CS_FORM_IMPLICIT, buffer_size)
151        }
152        BindValue::Raw(value) => {
153            let buffer_size = u32::try_from(value.len()).unwrap_or(u32::MAX).max(1);
154            (ORA_TYPE_NUM_RAW, 0, buffer_size)
155        }
156        BindValue::Lob {
157            ora_type_num,
158            csfrm,
159            ..
160        } => (*ora_type_num, *csfrm, 1),
161        BindValue::Number(_) => (ORA_TYPE_NUM_NUMBER, 0, ORA_TYPE_SIZE_NUMBER),
162        BindValue::BinaryInteger(_) => (ORA_TYPE_NUM_BINARY_INTEGER, 0, ORA_TYPE_SIZE_NUMBER),
163        BindValue::Boolean(_) => (ORA_TYPE_NUM_BOOLEAN, 0, ORA_TYPE_SIZE_BOOLEAN),
164        BindValue::BinaryDouble(_) => (ORA_TYPE_NUM_BINARY_DOUBLE, 0, ORA_TYPE_SIZE_BINARY_DOUBLE),
165        BindValue::BinaryFloat(_) => (ORA_TYPE_NUM_BINARY_FLOAT, 0, ORA_TYPE_SIZE_BINARY_FLOAT),
166        BindValue::IntervalDS { .. } => (ORA_TYPE_NUM_INTERVAL_DS, 0, ORA_TYPE_SIZE_INTERVAL_DS),
167        BindValue::IntervalYM { .. } => (ORA_TYPE_NUM_INTERVAL_YM, 0, ORA_TYPE_SIZE_INTERVAL_YM),
168        BindValue::DateTime { .. } => (ORA_TYPE_NUM_DATE, 0, ORA_TYPE_SIZE_DATE),
169        BindValue::Timestamp { ora_type_num, .. } => (
170            *ora_type_num,
171            0,
172            if *ora_type_num == ORA_TYPE_NUM_TIMESTAMP_TZ {
173                ORA_TYPE_SIZE_TIMESTAMP_TZ
174            } else {
175                ORA_TYPE_SIZE_TIMESTAMP
176            },
177        ),
178        BindValue::TimestampTz { .. } => (ORA_TYPE_NUM_TIMESTAMP_TZ, 0, ORA_TYPE_SIZE_TIMESTAMP_TZ),
179        BindValue::Array {
180            ora_type_num,
181            csfrm,
182            buffer_size,
183            ..
184        } => (*ora_type_num, *csfrm, (*buffer_size).max(1)),
185        // reference base.pyx _write_column_metadata: VECTOR binds advertise a
186        // TNS_VECTOR_MAX_LENGTH prefetch buffer and the LOB-prefetch cont flag
187        BindValue::Vector(_) => (ORA_TYPE_NUM_VECTOR, 0, TNS_VECTOR_MAX_LENGTH),
188        // JSON binds: the reference DB_TYPE_JSON var has buffer_size_factor 0, so
189        // its metadata buffer_size is small and the OSON value is written inline
190        // (not deferred to the "long" bind section). The TNS_JSON_MAX_LENGTH
191        // prefetch buffer is applied only in the wire metadata writer, not here,
192        // so the long/non-long bind-data ordering matches the reference.
193        BindValue::Json(_) => (ORA_TYPE_NUM_JSON, 0, 1),
194        BindValue::Cursor { .. } => (ORA_TYPE_NUM_CURSOR, 0, 4),
195    };
196    Some(BindTypeInfo {
197        ora_type_num,
198        csfrm,
199        buffer_size,
200    })
201}
202
203pub fn define_metadata_from_bind(source: &ColumnMetadata, value: &BindValue) -> ColumnMetadata {
204    let Some(mut info) = bind_value_type_info(value) else {
205        return source.clone();
206    };
207    if source.ora_type_num == ORA_TYPE_NUM_CLOB
208        && matches!(
209            info.ora_type_num,
210            ORA_TYPE_NUM_CHAR | ORA_TYPE_NUM_LONG | ORA_TYPE_NUM_VARCHAR
211        )
212    {
213        info.ora_type_num = ORA_TYPE_NUM_LONG;
214        if source.csfrm != 0 {
215            info.csfrm = source.csfrm;
216        }
217    }
218    let mut metadata = source.clone();
219    metadata.ora_type_num = info.ora_type_num;
220    metadata.csfrm = info.csfrm;
221    if info.ora_type_num == ORA_TYPE_NUM_LONG {
222        metadata.buffer_size = TNS_MAX_LONG_LENGTH;
223        metadata.max_size = 0;
224    } else {
225        metadata.buffer_size = info.buffer_size.max(1);
226        metadata.max_size = info.buffer_size.max(1);
227    }
228    metadata
229}
230
231/// When the same query is re-executed after a column's data type changed to
232/// CLOB/BLOB but the previous execution fetched the column as a char/raw
233/// type, the server streams the data as LONG/LONG RAW (same as a define of
234/// CLOB/BLOB as string/bytes); the fetch metadata must follow (reference
235/// impl/thin/messages/base.pyx:820-845 `_adjust_metadata`). Returns `true`
236/// when the metadata was adjusted.
237pub fn adjust_refetch_metadata(previous: &ColumnMetadata, current: &mut ColumnMetadata) -> bool {
238    if current.ora_type_num == ORA_TYPE_NUM_CLOB
239        && matches!(
240            previous.ora_type_num,
241            ORA_TYPE_NUM_CHAR | ORA_TYPE_NUM_LONG | ORA_TYPE_NUM_VARCHAR
242        )
243    {
244        current.ora_type_num = ORA_TYPE_NUM_LONG;
245        current.csfrm = previous.csfrm;
246        current.buffer_size = TNS_MAX_LONG_LENGTH;
247        current.max_size = 0;
248        return true;
249    }
250    if current.ora_type_num == ORA_TYPE_NUM_BLOB
251        && matches!(
252            previous.ora_type_num,
253            ORA_TYPE_NUM_RAW | ORA_TYPE_NUM_LONG_RAW
254        )
255    {
256        current.ora_type_num = ORA_TYPE_NUM_LONG_RAW;
257        current.csfrm = 0;
258        current.buffer_size = TNS_MAX_LONG_LENGTH;
259        current.max_size = 0;
260        return true;
261    }
262    false
263}
264
265pub fn output_bind(value: BindValue) -> BindValue {
266    match value {
267        BindValue::ObjectOutput {
268            schema,
269            type_name,
270            oid,
271            version,
272            buffer_size,
273            ..
274        } => BindValue::ObjectOutput {
275            schema,
276            type_name,
277            oid,
278            version,
279            buffer_size: buffer_size.max(1),
280            is_return: false,
281        },
282        value => {
283            let info = bind_value_type_info(&value).unwrap_or(BindTypeInfo {
284                ora_type_num: ORA_TYPE_NUM_VARCHAR,
285                csfrm: CS_FORM_IMPLICIT,
286                buffer_size: 1,
287            });
288            BindValue::Output {
289                ora_type_num: info.ora_type_num,
290                csfrm: info.csfrm,
291                buffer_size: info.buffer_size,
292            }
293        }
294    }
295}
296
297pub fn returning_output_bind(value: BindValue) -> BindValue {
298    match value {
299        BindValue::ObjectOutput {
300            schema,
301            type_name,
302            oid,
303            version,
304            buffer_size,
305            ..
306        } => BindValue::ObjectOutput {
307            schema,
308            type_name,
309            oid,
310            version,
311            buffer_size: buffer_size.max(1),
312            is_return: true,
313        },
314        value => {
315            let info = bind_value_type_info(&value).unwrap_or(BindTypeInfo {
316                ora_type_num: ORA_TYPE_NUM_VARCHAR,
317                csfrm: CS_FORM_IMPLICIT,
318                buffer_size: 1,
319            });
320            BindValue::ReturnOutput {
321                ora_type_num: info.ora_type_num,
322                csfrm: info.csfrm,
323                buffer_size: info.buffer_size,
324            }
325        }
326    }
327}
328
329pub fn cursor_bind_template() -> BindValue {
330    BindValue::TypedNull {
331        ora_type_num: ORA_TYPE_NUM_CURSOR,
332        csfrm: 0,
333        buffer_size: 4,
334    }
335}
336
337pub fn is_cursor_bind_template(value: &BindValue) -> bool {
338    matches!(
339        value,
340        BindValue::TypedNull {
341            ora_type_num: ORA_TYPE_NUM_CURSOR,
342            ..
343        }
344    )
345}
346
347pub fn public_dbtype_name_from_type_name(type_name: &str) -> &'static str {
348    match type_name {
349        "NUMBER" | "DB_TYPE_NUMBER" | "int" | "float" | "Decimal" => "DB_TYPE_NUMBER",
350        "NATIVE_INT" | "DB_TYPE_BINARY_INTEGER" => "DB_TYPE_BINARY_INTEGER",
351        "NATIVE_FLOAT" | "DB_TYPE_BINARY_DOUBLE" => "DB_TYPE_BINARY_DOUBLE",
352        "DB_TYPE_BINARY_FLOAT" | "BINARY_FLOAT" => "DB_TYPE_BINARY_FLOAT",
353        "DB_TYPE_BOOLEAN" | "BOOLEAN" | "bool" => "DB_TYPE_BOOLEAN",
354        "DB_TYPE_INTERVAL_DS" | "INTERVAL DAY TO SECOND" | "timedelta" => "DB_TYPE_INTERVAL_DS",
355        "DB_TYPE_INTERVAL_YM" | "INTERVAL YEAR TO MONTH" | "IntervalYM" => "DB_TYPE_INTERVAL_YM",
356        "DB_TYPE_BFILE" | "BFILE" => "DB_TYPE_BFILE",
357        "DB_TYPE_JSON" | "JSON" => "DB_TYPE_JSON",
358        "STRING" | "DB_TYPE_VARCHAR" | "str" => "DB_TYPE_VARCHAR",
359        "DB_TYPE_CHAR" => "DB_TYPE_CHAR",
360        "DB_TYPE_NCHAR" => "DB_TYPE_NCHAR",
361        "DB_TYPE_NVARCHAR" => "DB_TYPE_NVARCHAR",
362        "DB_TYPE_CLOB" | "CLOB" => "DB_TYPE_CLOB",
363        "DB_TYPE_NCLOB" | "NCLOB" => "DB_TYPE_NCLOB",
364        "DB_TYPE_BLOB" | "BLOB" => "DB_TYPE_BLOB",
365        "DB_TYPE_LONG" | "LONG" | "LONG_STRING" => "DB_TYPE_LONG",
366        "DB_TYPE_LONG_NVARCHAR" | "LONG NVARCHAR" => "DB_TYPE_LONG_NVARCHAR",
367        "DB_TYPE_LONG_RAW" | "LONG RAW" | "LONG_BINARY" => "DB_TYPE_LONG_RAW",
368        "DB_TYPE_RAW" | "BINARY" | "bytes" => "DB_TYPE_RAW",
369        "ROWID" | "DB_TYPE_ROWID" => "DB_TYPE_ROWID",
370        "DB_TYPE_UROWID" => "DB_TYPE_UROWID",
371        "DATETIME" | "DB_TYPE_DATE" | "date" | "datetime" => "DB_TYPE_DATE",
372        "DB_TYPE_TIMESTAMP" | "TIMESTAMP" => "DB_TYPE_TIMESTAMP",
373        "DB_TYPE_TIMESTAMP_LTZ" | "TIMESTAMP WITH LOCAL TIME ZONE" => "DB_TYPE_TIMESTAMP_LTZ",
374        "DB_TYPE_TIMESTAMP_TZ" | "TIMESTAMP WITH TIME ZONE" => "DB_TYPE_TIMESTAMP_TZ",
375        "DB_TYPE_CURSOR" | "CURSOR" => "DB_TYPE_CURSOR",
376        "DB_TYPE_VECTOR" | "VECTOR" => "DB_TYPE_VECTOR",
377        _ => "DB_TYPE_VARCHAR",
378    }
379}
380
381pub fn column_metadata_is_xmltype(metadata: &ColumnMetadata) -> bool {
382    metadata
383        .object_schema
384        .as_deref()
385        .is_some_and(|schema| schema.eq_ignore_ascii_case("SYS"))
386        && metadata
387            .object_type_name
388            .as_deref()
389            .is_some_and(|name| name.eq_ignore_ascii_case("XMLTYPE"))
390}
391
392pub fn public_dbtype_name_from_column_metadata(metadata: &ColumnMetadata) -> &'static str {
393    if column_metadata_is_xmltype(metadata) {
394        return "DB_TYPE_XMLTYPE";
395    }
396    match (metadata.ora_type_num, metadata.csfrm) {
397        (ORA_TYPE_NUM_LONG, CS_FORM_NCHAR) => "DB_TYPE_LONG_NVARCHAR",
398        (ORA_TYPE_NUM_LONG, _) => "DB_TYPE_LONG",
399        (ORA_TYPE_NUM_LONG_RAW, _) => "DB_TYPE_LONG_RAW",
400        (ORA_TYPE_NUM_VARCHAR, CS_FORM_NCHAR) => "DB_TYPE_NVARCHAR",
401        (ORA_TYPE_NUM_CHAR, CS_FORM_NCHAR) => "DB_TYPE_NCHAR",
402        (ORA_TYPE_NUM_CHAR, _) => "DB_TYPE_CHAR",
403        (ORA_TYPE_NUM_VARCHAR, _) => "DB_TYPE_VARCHAR",
404        (ORA_TYPE_NUM_RAW, _) => "DB_TYPE_RAW",
405        (ORA_TYPE_NUM_ROWID, _) => "DB_TYPE_ROWID",
406        (ORA_TYPE_NUM_UROWID, _) => "DB_TYPE_UROWID",
407        (ORA_TYPE_NUM_BINARY_DOUBLE, _) => "DB_TYPE_BINARY_DOUBLE",
408        (ORA_TYPE_NUM_BINARY_FLOAT, _) => "DB_TYPE_BINARY_FLOAT",
409        (ORA_TYPE_NUM_BINARY_INTEGER, _) => "DB_TYPE_BINARY_INTEGER",
410        (ORA_TYPE_NUM_NUMBER, _) => "DB_TYPE_NUMBER",
411        (ORA_TYPE_NUM_CURSOR, _) => "DB_TYPE_CURSOR",
412        (ORA_TYPE_NUM_OBJECT, _) => "DB_TYPE_OBJECT",
413        (ORA_TYPE_NUM_CLOB, CS_FORM_NCHAR) => "DB_TYPE_NCLOB",
414        (ORA_TYPE_NUM_CLOB, _) => "DB_TYPE_CLOB",
415        (ORA_TYPE_NUM_BLOB, _) => "DB_TYPE_BLOB",
416        (ORA_TYPE_NUM_BFILE, _) => "DB_TYPE_BFILE",
417        (ORA_TYPE_NUM_DATE, _) => "DB_TYPE_DATE",
418        (ORA_TYPE_NUM_TIMESTAMP, _) => "DB_TYPE_TIMESTAMP",
419        (ORA_TYPE_NUM_TIMESTAMP_LTZ, _) => "DB_TYPE_TIMESTAMP_LTZ",
420        (ORA_TYPE_NUM_TIMESTAMP_TZ, _) => "DB_TYPE_TIMESTAMP_TZ",
421        (ORA_TYPE_NUM_INTERVAL_DS, _) => "DB_TYPE_INTERVAL_DS",
422        (ORA_TYPE_NUM_INTERVAL_YM, _) => "DB_TYPE_INTERVAL_YM",
423        (ORA_TYPE_NUM_BOOLEAN, _) => "DB_TYPE_BOOLEAN",
424        (ORA_TYPE_NUM_VECTOR, _) => "DB_TYPE_VECTOR",
425        (ORA_TYPE_NUM_JSON, _) => "DB_TYPE_JSON",
426        _ => "DB_TYPE_VARCHAR",
427    }
428}
429
430/// Mirrors the reference `DbType.default_size` / `_buffer_size_factor` table
431/// (reference impl/base/types.pyx:120-440). Returns
432/// `(default_size, buffer_size_factor)` for a public database type name.
433pub fn public_dbtype_size_info(dbtype_name: &str) -> (u32, u32) {
434    match dbtype_name {
435        "DB_TYPE_BFILE" => (0, 4000),
436        "DB_TYPE_BINARY_DOUBLE" => (0, ORA_TYPE_SIZE_BINARY_DOUBLE),
437        "DB_TYPE_BINARY_FLOAT" => (0, ORA_TYPE_SIZE_BINARY_FLOAT),
438        "DB_TYPE_BINARY_INTEGER" | "DB_TYPE_NUMBER" => (0, ORA_TYPE_SIZE_NUMBER),
439        "DB_TYPE_BLOB" | "DB_TYPE_CLOB" | "DB_TYPE_NCLOB" => (0, 112),
440        "DB_TYPE_BOOLEAN" => (0, ORA_TYPE_SIZE_BOOLEAN),
441        "DB_TYPE_CHAR" | "DB_TYPE_NCHAR" => (2000, 4),
442        "DB_TYPE_CURSOR" => (0, 4),
443        "DB_TYPE_DATE" => (0, ORA_TYPE_SIZE_DATE),
444        "DB_TYPE_INTERVAL_DS" => (0, ORA_TYPE_SIZE_INTERVAL_DS),
445        "DB_TYPE_INTERVAL_YM" => (0, 5),
446        "DB_TYPE_LONG" | "DB_TYPE_LONG_NVARCHAR" | "DB_TYPE_LONG_RAW" => (0, TNS_MAX_LONG_LENGTH),
447        "DB_TYPE_NVARCHAR" | "DB_TYPE_VARCHAR" => (4000, 4),
448        "DB_TYPE_RAW" => (4000, 1),
449        "DB_TYPE_ROWID" => (0, ORA_TYPE_SIZE_ROWID),
450        "DB_TYPE_TIMESTAMP" | "DB_TYPE_TIMESTAMP_LTZ" => (0, ORA_TYPE_SIZE_TIMESTAMP),
451        "DB_TYPE_TIMESTAMP_TZ" => (0, ORA_TYPE_SIZE_TIMESTAMP_TZ),
452        "DB_TYPE_JSON" | "DB_TYPE_VECTOR" => (0, 1),
453        _ => (0, 0),
454    }
455}
456
457/// Mirrors the reference fetch-conversion legality matrix
458/// (reference impl/base/var.pyx:113-248 `_check_fetch_conversion`). Given the
459/// metadata of the column being fetched and the Oracle type requested by an
460/// output type handler variable, returns the metadata that should be used for
461/// the wire define. Conversions that only affect the Python materialization
462/// keep the original wire metadata; LOB and JSON sources adjust the define so
463/// the server sends inline data. Unsupported pairs return `None` and the
464/// caller is expected to raise `DPY-4007`.
465pub fn check_fetch_conversion(
466    source: &ColumnMetadata,
467    to_ora_type_num: u8,
468    to_csfrm: u8,
469) -> Option<ColumnMetadata> {
470    const CHAR_TYPES: [u8; 3] = [ORA_TYPE_NUM_CHAR, ORA_TYPE_NUM_LONG, ORA_TYPE_NUM_VARCHAR];
471    let from = source.ora_type_num;
472    let to = to_ora_type_num;
473    if from == to {
474        return Some(source.clone());
475    }
476    let supported = match from {
477        ORA_TYPE_NUM_BINARY_DOUBLE | ORA_TYPE_NUM_BINARY_FLOAT => {
478            matches!(
479                to,
480                ORA_TYPE_NUM_BINARY_INTEGER
481                    | ORA_TYPE_NUM_BINARY_DOUBLE
482                    | ORA_TYPE_NUM_BINARY_FLOAT
483                    | ORA_TYPE_NUM_NUMBER
484            ) || CHAR_TYPES.contains(&to)
485        }
486        ORA_TYPE_NUM_BINARY_INTEGER => to == ORA_TYPE_NUM_NUMBER || CHAR_TYPES.contains(&to),
487        ORA_TYPE_NUM_BLOB => {
488            if matches!(to, ORA_TYPE_NUM_RAW | ORA_TYPE_NUM_LONG_RAW) {
489                let mut metadata = source.clone();
490                metadata.ora_type_num = ORA_TYPE_NUM_LONG_RAW;
491                metadata.csfrm = 0;
492                metadata.buffer_size = TNS_MAX_LONG_LENGTH;
493                metadata.max_size = 0;
494                return Some(metadata);
495            }
496            false
497        }
498        ORA_TYPE_NUM_CHAR | ORA_TYPE_NUM_LONG | ORA_TYPE_NUM_VARCHAR => {
499            matches!(
500                to,
501                ORA_TYPE_NUM_BINARY_DOUBLE
502                    | ORA_TYPE_NUM_BINARY_FLOAT
503                    | ORA_TYPE_NUM_NUMBER
504                    | ORA_TYPE_NUM_BINARY_INTEGER
505            ) || CHAR_TYPES.contains(&to)
506        }
507        ORA_TYPE_NUM_CLOB => {
508            if CHAR_TYPES.contains(&to) {
509                let mut metadata = source.clone();
510                metadata.ora_type_num = ORA_TYPE_NUM_LONG;
511                metadata.buffer_size = TNS_MAX_LONG_LENGTH;
512                metadata.max_size = 0;
513                return Some(metadata);
514            }
515            false
516        }
517        ORA_TYPE_NUM_DATE
518        | ORA_TYPE_NUM_TIMESTAMP
519        | ORA_TYPE_NUM_TIMESTAMP_LTZ
520        | ORA_TYPE_NUM_TIMESTAMP_TZ => {
521            matches!(
522                to,
523                ORA_TYPE_NUM_DATE
524                    | ORA_TYPE_NUM_TIMESTAMP
525                    | ORA_TYPE_NUM_TIMESTAMP_LTZ
526                    | ORA_TYPE_NUM_TIMESTAMP_TZ
527            ) || CHAR_TYPES.contains(&to)
528        }
529        ORA_TYPE_NUM_INTERVAL_DS | ORA_TYPE_NUM_INTERVAL_YM | ORA_TYPE_NUM_ROWID => {
530            CHAR_TYPES.contains(&to)
531        }
532        ORA_TYPE_NUM_NUMBER => {
533            matches!(
534                to,
535                ORA_TYPE_NUM_BINARY_INTEGER
536                    | ORA_TYPE_NUM_BINARY_DOUBLE
537                    | ORA_TYPE_NUM_BINARY_FLOAT
538            ) || CHAR_TYPES.contains(&to)
539        }
540        ORA_TYPE_NUM_JSON => {
541            // Native JSON (DB_TYPE_JSON) fetched as a character type via an
542            // output type handler. The reference defines the column to the
543            // server as VARCHAR but decodes the returned bytes as LONG: "the
544            // server won't accept LONG being defined but even so it still sends
545            // back LONG data" (reference impl/base/var.pyx:208-215, where
546            // `_fetch_metadata.dbtype = DB_TYPE_LONG` and `return
547            // DB_TYPE_VARCHAR`).
548            //
549            // Our wire define writer keys the VARCHAR ora_type_num off this
550            // metadata, so the server accepts the define and streams the OSON
551            // image inline as text. The returned data is then decoded through
552            // the same `read_bytes` path used for VARCHAR/CHAR/LONG (all three
553            // share identical framing in `parse_column_value`), and the
554            // non-zero LONG-sized `buffer_size` set here keeps the
555            // null-by-describe shortcut from firing — exactly the effect the
556            // reference obtains by decoding as LONG. The handler's outconverter
557            // (e.g. `json.loads`) then materializes the Python value.
558            if matches!(to, ORA_TYPE_NUM_CHAR | ORA_TYPE_NUM_VARCHAR) {
559                let mut metadata = source.clone();
560                metadata.ora_type_num = ORA_TYPE_NUM_VARCHAR;
561                metadata.csfrm = CS_FORM_IMPLICIT;
562                metadata.buffer_size = TNS_MAX_LONG_LENGTH;
563                metadata.max_size = 0;
564                return Some(metadata);
565            }
566            // JSON fetched as RAW/bytes decodes the OSON image bytes directly
567            // (reference var.pyx:216-218 sets `_fetch_metadata.dbtype =
568            // DB_TYPE_RAW`).
569            if to == ORA_TYPE_NUM_RAW {
570                let mut metadata = source.clone();
571                metadata.ora_type_num = ORA_TYPE_NUM_RAW;
572                metadata.csfrm = 0;
573                metadata.buffer_size = TNS_MAX_LONG_LENGTH;
574                metadata.max_size = 0;
575                return Some(metadata);
576            }
577            false
578        }
579        ORA_TYPE_NUM_VECTOR => {
580            // VECTOR fetched as a character type streams its JSON text via a
581            // LONG wire define; VECTOR fetched as a CLOB streams via a CLOB
582            // locator (reference var.pyx:234-243).
583            if CHAR_TYPES.contains(&to) {
584                let mut metadata = source.clone();
585                metadata.ora_type_num = ORA_TYPE_NUM_LONG;
586                metadata.csfrm = CS_FORM_IMPLICIT;
587                metadata.buffer_size = TNS_MAX_LONG_LENGTH;
588                metadata.max_size = 0;
589                return Some(metadata);
590            }
591            if to == ORA_TYPE_NUM_CLOB {
592                let mut metadata = source.clone();
593                metadata.ora_type_num = ORA_TYPE_NUM_CLOB;
594                return Some(metadata);
595            }
596            false
597        }
598        _ => false,
599    };
600    let _ = to_csfrm;
601    if supported {
602        Some(source.clone())
603    } else {
604        None
605    }
606}
607
608#[derive(Clone, Copy, Debug, Eq, PartialEq)]
609enum BuiltinDescriptorFamily {
610    Timestamp,
611    TimestampTz,
612    TimestampLtz,
613    IntervalDs,
614    IntervalYm,
615}
616
617/// Recognizes only built-in descriptor names returned by Oracle's type catalog.
618///
619/// `ALL_TYPE_ATTRS` returns the short `WITH TZ` and `WITH LOCAL TZ` spellings on
620/// the 23ai release lane. PL/SQL metadata can spell the base type as
621/// `TIMESTAMP(6)`. Fold ASCII case and whitespace, but do not use a prefix
622/// match: an arbitrary ADT called `TIMESTAMP_AUDIT` must remain an ADT.
623fn builtin_descriptor_family(type_name: &str) -> Option<BuiltinDescriptorFamily> {
624    if let Some(timestamp_suffix) = timestamp_descriptor_suffix(type_name) {
625        let mut words = timestamp_suffix.split_ascii_whitespace();
626        let second = words.next();
627        return match second {
628            None => Some(BuiltinDescriptorFamily::Timestamp),
629            Some(with) if with.eq_ignore_ascii_case("WITH") => match words.next() {
630                Some(tz) if tz.eq_ignore_ascii_case("TZ") && words.next().is_none() => {
631                    Some(BuiltinDescriptorFamily::TimestampTz)
632                }
633                Some(time) if time.eq_ignore_ascii_case("TIME") => (words
634                    .next()
635                    .is_some_and(|zone| zone.eq_ignore_ascii_case("ZONE"))
636                    && words.next().is_none())
637                .then_some(BuiltinDescriptorFamily::TimestampTz),
638                Some(local) if local.eq_ignore_ascii_case("LOCAL") => match words.next() {
639                    Some(tz) if tz.eq_ignore_ascii_case("TZ") && words.next().is_none() => {
640                        Some(BuiltinDescriptorFamily::TimestampLtz)
641                    }
642                    Some(time) if time.eq_ignore_ascii_case("TIME") => (words
643                        .next()
644                        .is_some_and(|zone| zone.eq_ignore_ascii_case("ZONE"))
645                        && words.next().is_none())
646                    .then_some(BuiltinDescriptorFamily::TimestampLtz),
647                    _ => None,
648                },
649                _ => None,
650            },
651            _ => None,
652        };
653    }
654
655    let mut words = type_name.split_ascii_whitespace();
656    let first = words.next()?;
657    if first.eq_ignore_ascii_case("INTERVAL") {
658        return match (words.next(), words.next(), words.next(), words.next()) {
659            (Some(day), Some(to), Some(second), None)
660                if day.eq_ignore_ascii_case("DAY")
661                    && to.eq_ignore_ascii_case("TO")
662                    && second.eq_ignore_ascii_case("SECOND") =>
663            {
664                Some(BuiltinDescriptorFamily::IntervalDs)
665            }
666            (Some(year), Some(to), Some(month), None)
667                if year.eq_ignore_ascii_case("YEAR")
668                    && to.eq_ignore_ascii_case("TO")
669                    && month.eq_ignore_ascii_case("MONTH") =>
670            {
671                Some(BuiltinDescriptorFamily::IntervalYm)
672            }
673            _ => None,
674        };
675    }
676
677    None
678}
679
680/// Returns the words after a strict `TIMESTAMP` base type, accepting at most
681/// one Oracle fractional-seconds precision suffix (`(0)` through `(9)`).
682fn timestamp_descriptor_suffix(type_name: &str) -> Option<&str> {
683    let type_name = type_name.trim_matches(|ch: char| ch.is_ascii_whitespace());
684    let (timestamp, mut suffix) = type_name
685        .get(.."TIMESTAMP".len())
686        .zip(type_name.get("TIMESTAMP".len()..))?;
687    if !timestamp.eq_ignore_ascii_case("TIMESTAMP") {
688        return None;
689    }
690
691    let has_word_separator = suffix
692        .as_bytes()
693        .first()
694        .is_some_and(|byte| byte.is_ascii_whitespace());
695    suffix = suffix.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
696    if suffix.is_empty() {
697        return Some("");
698    }
699    if !suffix.starts_with('(') {
700        return has_word_separator.then_some(suffix);
701    }
702    suffix = &suffix[1..];
703    suffix = suffix.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
704    let precision = *suffix.as_bytes().first()?;
705    if !precision.is_ascii_digit() {
706        return None;
707    }
708    suffix = suffix[1..].trim_start_matches(|ch: char| ch.is_ascii_whitespace());
709    suffix = suffix.strip_prefix(')')?;
710    if suffix.is_empty() {
711        return Some("");
712    }
713    suffix
714        .as_bytes()
715        .first()
716        .is_some_and(|byte| byte.is_ascii_whitespace())
717        .then(|| suffix.trim_matches(|ch: char| ch.is_ascii_whitespace()))
718}
719
720pub fn public_dbtype_name_from_oracle_type_name(type_name: &str) -> &'static str {
721    if let Some(family) = builtin_descriptor_family(type_name) {
722        return match family {
723            BuiltinDescriptorFamily::Timestamp => "DB_TYPE_TIMESTAMP",
724            BuiltinDescriptorFamily::TimestampTz => "DB_TYPE_TIMESTAMP_TZ",
725            BuiltinDescriptorFamily::TimestampLtz => "DB_TYPE_TIMESTAMP_LTZ",
726            BuiltinDescriptorFamily::IntervalDs => "DB_TYPE_INTERVAL_DS",
727            BuiltinDescriptorFamily::IntervalYm => "DB_TYPE_INTERVAL_YM",
728        };
729    }
730
731    let upper = type_name.to_ascii_uppercase();
732    match upper.as_str() {
733        "CHAR" => "DB_TYPE_CHAR",
734        "NCHAR" => "DB_TYPE_NCHAR",
735        "VARCHAR2" | "VARCHAR" => "DB_TYPE_VARCHAR",
736        "NVARCHAR2" | "NVARCHAR" => "DB_TYPE_NVARCHAR",
737        "RAW" => "DB_TYPE_RAW",
738        "DATE" => "DB_TYPE_DATE",
739        "CLOB" => "DB_TYPE_CLOB",
740        "NCLOB" => "DB_TYPE_NCLOB",
741        "BLOB" => "DB_TYPE_BLOB",
742        "XMLTYPE" => "DB_TYPE_XMLTYPE",
743        "BINARY_FLOAT" => "DB_TYPE_BINARY_FLOAT",
744        "BINARY_DOUBLE" => "DB_TYPE_BINARY_DOUBLE",
745        "NUMBER" | "INTEGER" | "SMALLINT" | "REAL" | "DOUBLE PRECISION" | "FLOAT" => {
746            "DB_TYPE_NUMBER"
747        }
748        // PL/SQL scalar attribute/element type names returned verbatim by the
749        // type catalog. Without these arms they would fall through to the ADT
750        // fallback below and be misclassified as nested objects (reference
751        // impl/base/types.pyx:154-175,451-455 db_type_by_ora_name).
752        "BOOLEAN" | "PL/SQL BOOLEAN" => "DB_TYPE_BOOLEAN",
753        "BINARY_INTEGER" | "PLS_INTEGER" | "PL/SQL BINARY INTEGER" | "PL/SQL PLS INTEGER" => {
754            "DB_TYPE_BINARY_INTEGER"
755        }
756        "LONG" => "DB_TYPE_LONG",
757        "LONG RAW" => "DB_TYPE_LONG_RAW",
758        "ROWID" => "DB_TYPE_ROWID",
759        "UROWID" => "DB_TYPE_UROWID",
760        "BFILE" => "DB_TYPE_BFILE",
761        "JSON" => "DB_TYPE_JSON",
762        "VECTOR" => "DB_TYPE_VECTOR",
763        // An unknown name IS a nested object type (mirrors reference
764        // _create_attr only calling get_type_for_info when type_owner is set).
765        _ => "DB_TYPE_OBJECT",
766    }
767}
768
769pub fn dbobject_attr_precision_scale(
770    type_name: &str,
771    precision: Option<i8>,
772    scale: Option<i8>,
773) -> (i8, i8) {
774    if let Some(family) = builtin_descriptor_family(type_name) {
775        return match family {
776            BuiltinDescriptorFamily::Timestamp
777            | BuiltinDescriptorFamily::TimestampTz
778            | BuiltinDescriptorFamily::TimestampLtz => (precision.unwrap_or(0), scale.unwrap_or(6)),
779            BuiltinDescriptorFamily::IntervalDs => (precision.unwrap_or(2), scale.unwrap_or(6)),
780            BuiltinDescriptorFamily::IntervalYm => (precision.unwrap_or(2), scale.unwrap_or(0)),
781        };
782    }
783
784    match type_name.to_ascii_uppercase().as_str() {
785        "NUMBER" => (
786            precision.unwrap_or(if scale == Some(0) { 38 } else { 0 }),
787            scale.unwrap_or(-127),
788        ),
789        "INTEGER" | "SMALLINT" => (precision.unwrap_or(38), scale.unwrap_or(0)),
790        "REAL" => (precision.unwrap_or(63), scale.unwrap_or(-127)),
791        "DOUBLE PRECISION" | "FLOAT" => (precision.unwrap_or(126), scale.unwrap_or(-127)),
792        // The other built-in dictionary types intentionally retain (0, 0):
793        // character/raw/LOB, DATE, binary float/double, PL/SQL scalar, LONG,
794        // ROWID, BFILE, JSON, and VECTOR attributes do not use this helper's
795        // precision/scale defaults. `other_builtin_descriptor_types_keep_zero`
796        // enumerates that invariant; unknown names remain true ADTs.
797        _ => (0, 0),
798    }
799}
800
801/// Projects descriptor precision/scale into python-oracledb's public
802/// `DbObjectAttr` contract.
803///
804/// Oracle supplies temporal descriptor defaults (for example, `(0, 6)` for a
805/// bare `TIMESTAMP`), and [`dbobject_attr_precision_scale`] preserves those raw
806/// values. The Python public API exposes precision and scale only for numeric
807/// attributes, so its temporal attributes must remain `(0, 0)` internally in
808/// order for the wrapper to present `None` for both fields.
809pub fn dbobject_attr_public_precision_scale(
810    type_name: &str,
811    precision: Option<i8>,
812    scale: Option<i8>,
813) -> (i8, i8) {
814    let precision_scale = dbobject_attr_precision_scale(type_name, precision, scale);
815    if builtin_descriptor_family(type_name).is_some() {
816        (0, 0)
817    } else {
818        precision_scale
819    }
820}
821
822pub fn dbobject_attr_max_size(type_name: &str, length: Option<u32>) -> u32 {
823    let length = length.unwrap_or(0);
824    match type_name.to_ascii_uppercase().as_str() {
825        "NCHAR" | "NVARCHAR2" | "NVARCHAR" => length.saturating_mul(2),
826        _ => length,
827    }
828}
829
830pub fn dbobject_rowtype_attr_max_size(
831    type_name: &str,
832    data_length: Option<u32>,
833    char_length: Option<u32>,
834) -> u32 {
835    match type_name.to_ascii_uppercase().as_str() {
836        "CHAR" | "VARCHAR" | "VARCHAR2" | "RAW" => data_length.unwrap_or(0),
837        "NCHAR" | "NVARCHAR" | "NVARCHAR2" => dbobject_attr_max_size(
838            type_name,
839            char_length.filter(|length| *length > 0).or(data_length),
840        ),
841        _ => 0,
842    }
843}
844
845pub fn public_dbtype_name_from_bind(value: &BindValue) -> &'static str {
846    match value {
847        BindValue::TypedNull {
848            ora_type_num,
849            csfrm,
850            ..
851        }
852        | BindValue::Output {
853            ora_type_num,
854            csfrm,
855            ..
856        }
857        | BindValue::ReturnOutput {
858            ora_type_num,
859            csfrm,
860            ..
861        }
862        | BindValue::Array {
863            ora_type_num,
864            csfrm,
865            ..
866        } => public_dbtype_name_from_type_info(*ora_type_num, *csfrm),
867        BindValue::ObjectOutput { .. } | BindValue::ObjectInput { .. } => "DB_TYPE_OBJECT",
868        // An IN OUT bind's public type is that of its input value.
869        BindValue::InOut { value, .. } => public_dbtype_name_from_bind(value),
870        BindValue::Text(_) => "DB_TYPE_VARCHAR",
871        BindValue::Raw(_) => "DB_TYPE_RAW",
872        BindValue::Lob {
873            ora_type_num,
874            csfrm,
875            ..
876        } => match (*ora_type_num, *csfrm) {
877            (ORA_TYPE_NUM_BLOB, _) => "DB_TYPE_BLOB",
878            (ORA_TYPE_NUM_CLOB, CS_FORM_NCHAR) => "DB_TYPE_NCLOB",
879            (ORA_TYPE_NUM_CLOB, _) => "DB_TYPE_CLOB",
880            _ => "DB_TYPE_CLOB",
881        },
882        BindValue::Number(_) => "DB_TYPE_NUMBER",
883        BindValue::BinaryInteger(_) => "DB_TYPE_BINARY_INTEGER",
884        BindValue::BinaryDouble(_) => "DB_TYPE_BINARY_DOUBLE",
885        BindValue::BinaryFloat(_) => "DB_TYPE_BINARY_FLOAT",
886        BindValue::Boolean(_) => "DB_TYPE_BOOLEAN",
887        BindValue::IntervalDS { .. } => "DB_TYPE_INTERVAL_DS",
888        BindValue::IntervalYM { .. } => "DB_TYPE_INTERVAL_YM",
889        BindValue::DateTime { .. } => "DB_TYPE_DATE",
890        BindValue::Timestamp { ora_type_num, .. } => match *ora_type_num {
891            ORA_TYPE_NUM_TIMESTAMP_LTZ => "DB_TYPE_TIMESTAMP_LTZ",
892            ORA_TYPE_NUM_TIMESTAMP_TZ => "DB_TYPE_TIMESTAMP_TZ",
893            _ => "DB_TYPE_TIMESTAMP",
894        },
895        BindValue::TimestampTz { .. } => "DB_TYPE_TIMESTAMP_TZ",
896        BindValue::Vector(_) => "DB_TYPE_VECTOR",
897        BindValue::Json(_) => "DB_TYPE_JSON",
898        BindValue::Cursor { .. } => "DB_TYPE_CURSOR",
899        BindValue::Null => "DB_TYPE_VARCHAR",
900    }
901}
902
903pub fn bind_template_from_type_name(type_name: &str, size: u32) -> BindValue {
904    let text_buffer_size = if size == 0 { 4000 } else { size.max(1) };
905    let nchar_buffer_size = text_buffer_size.saturating_mul(4);
906    match type_name {
907        "NUMBER" | "DB_TYPE_NUMBER" | "int" | "float" | "Decimal" => BindValue::TypedNull {
908            ora_type_num: ORA_TYPE_NUM_NUMBER,
909            csfrm: 0,
910            buffer_size: ORA_TYPE_SIZE_NUMBER,
911        },
912        "NATIVE_INT" | "DB_TYPE_BINARY_INTEGER" => BindValue::TypedNull {
913            ora_type_num: ORA_TYPE_NUM_BINARY_INTEGER,
914            csfrm: 0,
915            buffer_size: ORA_TYPE_SIZE_NUMBER,
916        },
917        "NATIVE_FLOAT" | "DB_TYPE_BINARY_DOUBLE" => BindValue::TypedNull {
918            ora_type_num: ORA_TYPE_NUM_BINARY_DOUBLE,
919            csfrm: 0,
920            buffer_size: ORA_TYPE_SIZE_BINARY_DOUBLE,
921        },
922        "DB_TYPE_BINARY_FLOAT" | "BINARY_FLOAT" => BindValue::TypedNull {
923            ora_type_num: ORA_TYPE_NUM_BINARY_FLOAT,
924            csfrm: 0,
925            buffer_size: ORA_TYPE_SIZE_BINARY_FLOAT,
926        },
927        "DB_TYPE_BOOLEAN" | "BOOLEAN" | "bool" => BindValue::TypedNull {
928            ora_type_num: ORA_TYPE_NUM_BOOLEAN,
929            csfrm: 0,
930            buffer_size: ORA_TYPE_SIZE_BOOLEAN,
931        },
932        "DB_TYPE_INTERVAL_DS" | "INTERVAL DAY TO SECOND" | "timedelta" => BindValue::TypedNull {
933            ora_type_num: ORA_TYPE_NUM_INTERVAL_DS,
934            csfrm: 0,
935            buffer_size: ORA_TYPE_SIZE_INTERVAL_DS,
936        },
937        "DB_TYPE_INTERVAL_YM" | "INTERVAL YEAR TO MONTH" | "IntervalYM" => BindValue::TypedNull {
938            ora_type_num: ORA_TYPE_NUM_INTERVAL_YM,
939            csfrm: 0,
940            buffer_size: ORA_TYPE_SIZE_INTERVAL_YM,
941        },
942        "STRING" | "DB_TYPE_VARCHAR" | "DB_TYPE_CHAR" | "str" => BindValue::TypedNull {
943            ora_type_num: ORA_TYPE_NUM_VARCHAR,
944            csfrm: CS_FORM_IMPLICIT,
945            buffer_size: text_buffer_size,
946        },
947        "DB_TYPE_NCHAR" | "DB_TYPE_NVARCHAR" => BindValue::TypedNull {
948            ora_type_num: ORA_TYPE_NUM_VARCHAR,
949            csfrm: CS_FORM_NCHAR,
950            buffer_size: nchar_buffer_size,
951        },
952        "DB_TYPE_CLOB" | "CLOB" => BindValue::TypedNull {
953            ora_type_num: ORA_TYPE_NUM_LONG,
954            csfrm: CS_FORM_IMPLICIT,
955            buffer_size: TNS_MAX_LONG_LENGTH,
956        },
957        "DB_TYPE_NCLOB" | "NCLOB" => BindValue::TypedNull {
958            ora_type_num: ORA_TYPE_NUM_LONG,
959            csfrm: CS_FORM_NCHAR,
960            buffer_size: TNS_MAX_LONG_LENGTH,
961        },
962        "DB_TYPE_BLOB" | "BLOB" => BindValue::TypedNull {
963            ora_type_num: ORA_TYPE_NUM_LONG_RAW,
964            csfrm: 0,
965            buffer_size: TNS_MAX_LONG_LENGTH,
966        },
967        "DB_TYPE_LONG" | "LONG" | "LONG_STRING" => BindValue::TypedNull {
968            ora_type_num: ORA_TYPE_NUM_LONG,
969            csfrm: CS_FORM_IMPLICIT,
970            buffer_size: TNS_MAX_LONG_LENGTH,
971        },
972        "DB_TYPE_LONG_NVARCHAR" | "LONG NVARCHAR" => BindValue::TypedNull {
973            ora_type_num: ORA_TYPE_NUM_LONG,
974            csfrm: CS_FORM_NCHAR,
975            buffer_size: TNS_MAX_LONG_LENGTH,
976        },
977        "DB_TYPE_LONG_RAW" | "LONG RAW" | "LONG_BINARY" => BindValue::TypedNull {
978            ora_type_num: ORA_TYPE_NUM_LONG_RAW,
979            csfrm: 0,
980            buffer_size: TNS_MAX_LONG_LENGTH,
981        },
982        "DB_TYPE_RAW" | "BINARY" | "bytes" => BindValue::TypedNull {
983            ora_type_num: ORA_TYPE_NUM_RAW,
984            csfrm: 0,
985            buffer_size: size.max(1).max(4000),
986        },
987        "ROWID" | "DB_TYPE_ROWID" | "DB_TYPE_UROWID" => BindValue::TypedNull {
988            ora_type_num: ORA_TYPE_NUM_VARCHAR,
989            csfrm: CS_FORM_IMPLICIT,
990            buffer_size: 5267,
991        },
992        "DATETIME" | "DB_TYPE_DATE" | "date" | "datetime" => BindValue::TypedNull {
993            ora_type_num: ORA_TYPE_NUM_DATE,
994            csfrm: 0,
995            buffer_size: ORA_TYPE_SIZE_DATE,
996        },
997        "DB_TYPE_TIMESTAMP" | "TIMESTAMP" => BindValue::TypedNull {
998            ora_type_num: ORA_TYPE_NUM_TIMESTAMP,
999            csfrm: 0,
1000            buffer_size: ORA_TYPE_SIZE_TIMESTAMP,
1001        },
1002        "DB_TYPE_TIMESTAMP_LTZ" | "TIMESTAMP WITH LOCAL TIME ZONE" => BindValue::TypedNull {
1003            ora_type_num: ORA_TYPE_NUM_TIMESTAMP_LTZ,
1004            csfrm: 0,
1005            buffer_size: ORA_TYPE_SIZE_TIMESTAMP,
1006        },
1007        "DB_TYPE_TIMESTAMP_TZ" | "TIMESTAMP WITH TIME ZONE" => BindValue::TypedNull {
1008            ora_type_num: ORA_TYPE_NUM_TIMESTAMP_TZ,
1009            csfrm: 0,
1010            buffer_size: ORA_TYPE_SIZE_TIMESTAMP_TZ,
1011        },
1012        "DB_TYPE_CURSOR" | "CURSOR" => cursor_bind_template(),
1013        "DB_TYPE_VECTOR" | "VECTOR" => BindValue::TypedNull {
1014            ora_type_num: ORA_TYPE_NUM_VECTOR,
1015            csfrm: 0,
1016            buffer_size: TNS_VECTOR_MAX_LENGTH,
1017        },
1018        "DB_TYPE_JSON" | "JSON" => BindValue::TypedNull {
1019            ora_type_num: ORA_TYPE_NUM_JSON,
1020            csfrm: 0,
1021            buffer_size: TNS_VECTOR_MAX_LENGTH,
1022        },
1023        _ => BindValue::Null,
1024    }
1025}
1026
1027pub fn dbobject_element_bind_type_info(dbtype_name: &str, max_size: u32) -> BindTypeInfo {
1028    let buffer_size = max_size.max(1);
1029    let (ora_type_num, csfrm, buffer_size) = match dbtype_name {
1030        "DB_TYPE_NUMBER" => (ORA_TYPE_NUM_NUMBER, 0, ORA_TYPE_SIZE_NUMBER),
1031        "DB_TYPE_RAW" | "DB_TYPE_BLOB" => (ORA_TYPE_NUM_RAW, 0, buffer_size.max(4000)),
1032        "DB_TYPE_NCHAR" | "DB_TYPE_NVARCHAR" | "DB_TYPE_NCLOB" => {
1033            (ORA_TYPE_NUM_VARCHAR, CS_FORM_NCHAR, buffer_size.max(4000))
1034        }
1035        "DB_TYPE_DATE" => (ORA_TYPE_NUM_DATE, 0, ORA_TYPE_SIZE_DATE),
1036        "DB_TYPE_TIMESTAMP" => (ORA_TYPE_NUM_TIMESTAMP, 0, ORA_TYPE_SIZE_TIMESTAMP),
1037        "DB_TYPE_TIMESTAMP_LTZ" => (ORA_TYPE_NUM_TIMESTAMP_LTZ, 0, ORA_TYPE_SIZE_TIMESTAMP),
1038        "DB_TYPE_TIMESTAMP_TZ" => (ORA_TYPE_NUM_TIMESTAMP_TZ, 0, ORA_TYPE_SIZE_TIMESTAMP_TZ),
1039        _ => (
1040            ORA_TYPE_NUM_VARCHAR,
1041            CS_FORM_IMPLICIT,
1042            buffer_size.max(4000),
1043        ),
1044    };
1045    BindTypeInfo {
1046        ora_type_num,
1047        csfrm,
1048        buffer_size,
1049    }
1050}
1051
1052pub(crate) fn public_dbtype_name_from_type_info(ora_type_num: u8, csfrm: u8) -> &'static str {
1053    match (ora_type_num, csfrm) {
1054        (ORA_TYPE_NUM_BINARY_DOUBLE, _) => "DB_TYPE_BINARY_DOUBLE",
1055        (ORA_TYPE_NUM_BINARY_FLOAT, _) => "DB_TYPE_BINARY_FLOAT",
1056        (ORA_TYPE_NUM_INTERVAL_DS, _) => "DB_TYPE_INTERVAL_DS",
1057        (ORA_TYPE_NUM_INTERVAL_YM, _) => "DB_TYPE_INTERVAL_YM",
1058        (ORA_TYPE_NUM_BOOLEAN, _) => "DB_TYPE_BOOLEAN",
1059        (ORA_TYPE_NUM_BINARY_INTEGER, _) => "DB_TYPE_BINARY_INTEGER",
1060        (ORA_TYPE_NUM_NUMBER, _) => "DB_TYPE_NUMBER",
1061        (ORA_TYPE_NUM_CHAR, CS_FORM_NCHAR) | (ORA_TYPE_NUM_VARCHAR, CS_FORM_NCHAR) => {
1062            "DB_TYPE_NVARCHAR"
1063        }
1064        (ORA_TYPE_NUM_CHAR, _) => "DB_TYPE_CHAR",
1065        (ORA_TYPE_NUM_VARCHAR, _) => "DB_TYPE_VARCHAR",
1066        (ORA_TYPE_NUM_LONG, CS_FORM_NCHAR) => "DB_TYPE_LONG_NVARCHAR",
1067        (ORA_TYPE_NUM_LONG, _) => "DB_TYPE_LONG",
1068        (ORA_TYPE_NUM_LONG_RAW, _) => "DB_TYPE_LONG_RAW",
1069        (ORA_TYPE_NUM_RAW, _) => "DB_TYPE_RAW",
1070        (ORA_TYPE_NUM_DATE, _) => "DB_TYPE_DATE",
1071        (ORA_TYPE_NUM_TIMESTAMP, _) => "DB_TYPE_TIMESTAMP",
1072        (ORA_TYPE_NUM_TIMESTAMP_LTZ, _) => "DB_TYPE_TIMESTAMP_LTZ",
1073        (ORA_TYPE_NUM_TIMESTAMP_TZ, _) => "DB_TYPE_TIMESTAMP_TZ",
1074        (ORA_TYPE_NUM_CURSOR, _) => "DB_TYPE_CURSOR",
1075        (ORA_TYPE_NUM_OBJECT, _) => "DB_TYPE_OBJECT",
1076        (ORA_TYPE_NUM_VECTOR, _) => "DB_TYPE_VECTOR",
1077        (ORA_TYPE_NUM_JSON, _) => "DB_TYPE_JSON",
1078        _ => "DB_TYPE_VARCHAR",
1079    }
1080}
1081
1082pub(crate) fn bind_metadata(value: &BindValue) -> (u8, u8, u32) {
1083    bind_value_type_info(value)
1084        .map(|info| (info.ora_type_num, info.csfrm, info.buffer_size))
1085        .unwrap_or((ORA_TYPE_NUM_VARCHAR, CS_FORM_IMPLICIT, 1))
1086}
1087
1088pub(crate) fn write_bind_value(writer: &mut TtcWriter, value: &BindValue, csfrm: u8) -> Result<()> {
1089    match value {
1090        BindValue::TypedNull {
1091            ora_type_num: ORA_TYPE_NUM_CURSOR,
1092            ..
1093        } => {
1094            writer.write_u8(1);
1095            writer.write_u8(0);
1096            Ok(())
1097        }
1098        // A NULL BOOLEAN bind is encoded as the two raw bytes
1099        // [TNS_ESCAPE_CHAR, 1], not the usual single 0 null indicator; sending
1100        // a plain 0 makes the server reject a PL/SQL BOOLEAN parameter with
1101        // PLS-00306 (reference messages/base.pyx _write_bind_params_column).
1102        BindValue::TypedNull {
1103            ora_type_num: ORA_TYPE_NUM_BOOLEAN,
1104            ..
1105        } => {
1106            writer.write_u8(TNS_ESCAPE_CHAR);
1107            writer.write_u8(1);
1108            Ok(())
1109        }
1110        BindValue::Null | BindValue::TypedNull { .. } => {
1111            writer.write_u8(0);
1112            Ok(())
1113        }
1114        BindValue::Output { .. } | BindValue::ReturnOutput { .. } => {
1115            writer.write_u8(0);
1116            Ok(())
1117        }
1118        // An IN OUT bind sends its input value bytes (never a null indicator);
1119        // the read-back is driven by the server's IO-vector direction, not by
1120        // anything written here. Recurse with the resolved csfrm (which the
1121        // metadata derived from this same inner value).
1122        BindValue::InOut { value, .. } => write_bind_value(writer, value, csfrm),
1123        BindValue::ObjectOutput { .. } => {
1124            // NULL object image (empty OUT bind): reference messages/base.pyx
1125            // 1462-1468.
1126            writer.write_ub4(0);
1127            writer.write_ub4(0);
1128            writer.write_ub4(0);
1129            writer.write_ub2(0);
1130            writer.write_ub4(0);
1131            writer.write_ub4(TNS_OBJ_TOP_LEVEL);
1132            Ok(())
1133        }
1134        BindValue::ObjectInput { oid, image, .. } => write_dbobject_bind(writer, oid, image),
1135        BindValue::Text(value) => {
1136            // The common implicit/single-byte-charset case writes the &str bytes
1137            // straight through (no throwaway Vec); only NCHAR re-encodes to UTF-16
1138            // and needs the owned buffer. Byte-identical to encode_text_value,
1139            // which for the non-NCHAR path is exactly `value.as_bytes().to_vec()`.
1140            if csfrm == CS_FORM_NCHAR {
1141                let bytes = encode_text_value(value, csfrm);
1142                writer.write_bytes_with_length(&bytes)
1143            } else {
1144                writer.write_bytes_with_length(value.as_bytes())
1145            }
1146        }
1147        BindValue::Raw(value) => writer.write_bytes_with_length(value),
1148        BindValue::Lob { locator, .. } => writer.write_bytes_with_two_lengths(Some(locator)),
1149        BindValue::Number(value) | BindValue::BinaryInteger(value) => {
1150            let bytes = encode_number_text(value)?;
1151            writer.write_bytes_with_length(&bytes)
1152        }
1153        // reference encode_boolean (impl/base/encoders.pyx:99-111): true is
1154        // the two bytes [1, 1]; false is the single byte [0]
1155        BindValue::Boolean(value) => {
1156            let bytes: &[u8] = if *value { &[1, 1] } else { &[0] };
1157            writer.write_bytes_with_length(bytes)
1158        }
1159        BindValue::BinaryDouble(value) => {
1160            let bytes = encode_binary_double(*value);
1161            writer.write_bytes_with_length(&bytes)
1162        }
1163        BindValue::BinaryFloat(value) => {
1164            let bytes = encode_binary_float(*value as f32);
1165            writer.write_bytes_with_length(&bytes)
1166        }
1167        BindValue::IntervalDS {
1168            days,
1169            seconds,
1170            microseconds,
1171        } => {
1172            let nanoseconds = microseconds
1173                .checked_mul(1000)
1174                .ok_or(ProtocolError::TtcDecode(
1175                    "INTERVAL DS fractional seconds out of range",
1176                ))?;
1177            let bytes = encode_interval_ds(*days, *seconds, nanoseconds)?;
1178            writer.write_bytes_with_length(&bytes)
1179        }
1180        BindValue::IntervalYM { years, months } => {
1181            let bytes = encode_interval_ym(*years, *months)?;
1182            writer.write_bytes_with_length(&bytes)
1183        }
1184        BindValue::DateTime {
1185            year,
1186            month,
1187            day,
1188            hour,
1189            minute,
1190            second,
1191        } => {
1192            let bytes = encode_oracle_date(*year, *month, *day, *hour, *minute, *second)?;
1193            writer.write_bytes_with_length(&bytes)
1194        }
1195        BindValue::Timestamp {
1196            year,
1197            month,
1198            day,
1199            hour,
1200            minute,
1201            second,
1202            nanosecond,
1203            ora_type_num,
1204        } => {
1205            let bytes = if matches!(*ora_type_num, ORA_TYPE_NUM_TIMESTAMP_TZ) {
1206                encode_oracle_timestamp_tz(
1207                    *year,
1208                    *month,
1209                    *day,
1210                    *hour,
1211                    *minute,
1212                    *second,
1213                    *nanosecond,
1214                )?
1215            } else {
1216                encode_oracle_timestamp(*year, *month, *day, *hour, *minute, *second, *nanosecond)?
1217            };
1218            writer.write_bytes_with_length(&bytes)
1219        }
1220        BindValue::TimestampTz {
1221            year,
1222            month,
1223            day,
1224            hour,
1225            minute,
1226            second,
1227            nanosecond,
1228            offset_minutes,
1229        } => {
1230            let bytes = encode_oracle_timestamp_tz_with_offset(
1231                *year,
1232                *month,
1233                *day,
1234                *hour,
1235                *minute,
1236                *second,
1237                *nanosecond,
1238                *offset_minutes,
1239            )?;
1240            writer.write_bytes_with_length(&bytes)
1241        }
1242        BindValue::Array {
1243            values,
1244            csfrm: array_csfrm,
1245            ..
1246        } => {
1247            writer.write_ub4(u32::try_from(values.len()).map_err(|_| {
1248                ProtocolError::InvalidPacketLength {
1249                    length: values.len(),
1250                    minimum: 0,
1251                }
1252            })?);
1253            for value in values {
1254                match value {
1255                    Some(value) => write_bind_value(writer, value, *array_csfrm)?,
1256                    None => writer.write_u8(0),
1257                }
1258            }
1259            Ok(())
1260        }
1261        // reference WriteBuffer.write_vector: a QLocator carrying the image
1262        // length, then the image bytes-with-length
1263        BindValue::Vector(vector) => {
1264            let image = crate::vector::encode_vector_checked(vector)?;
1265            crate::vector::write_vector_image(writer, &image)
1266        }
1267        // reference WriteBuffer.write_oson: a QLocator carrying the OSON image
1268        // length, then the image bytes-with-length (same framing as VECTOR).
1269        BindValue::Json(image) => crate::vector::write_vector_image(writer, image),
1270        BindValue::Cursor { cursor_id } => {
1271            if *cursor_id == 0 {
1272                writer.write_u8(1);
1273                writer.write_u8(0);
1274            } else {
1275                writer.write_ub4(1);
1276                writer.write_ub4(*cursor_id);
1277            }
1278            Ok(())
1279        }
1280    }
1281}
1282
1283pub(crate) fn encode_text_value(value: &str, csfrm: u8) -> Vec<u8> {
1284    if csfrm == CS_FORM_NCHAR {
1285        let mut bytes = Vec::with_capacity(value.len().saturating_mul(2));
1286        for unit in value.encode_utf16() {
1287            bytes.extend_from_slice(&unit.to_be_bytes());
1288        }
1289        bytes
1290    } else {
1291        value.as_bytes().to_vec()
1292    }
1293}
1294
1295#[cfg(test)]
1296mod bind_tests {
1297    use super::*;
1298
1299    const ORACLE_23AI_DESCRIPTOR_FIXTURE: &str =
1300        include_str!("../../tests/golden/oracle_23ai_all_type_attrs_tstz.txt");
1301
1302    fn written_bytes(value: &BindValue, csfrm: u8) -> Vec<u8> {
1303        let mut writer = TtcWriter::new();
1304        write_bind_value(&mut writer, value, csfrm).expect("write bind value");
1305        writer.into_bytes()
1306    }
1307
1308    #[test]
1309    fn captured_23ai_descriptor_fixture_normalizes_precision_scale() {
1310        let mut captured_rows = 0;
1311        for line in ORACLE_23AI_DESCRIPTOR_FIXTURE
1312            .lines()
1313            .filter(|line| line.starts_with("ATTR|"))
1314        {
1315            captured_rows += 1;
1316            let fields: Vec<_> = line.split('|').collect();
1317            assert_eq!(fields.len(), 8, "fixture row shape: {line}");
1318            assert_eq!(fields[1], "SYS", "fixture owner: {line}");
1319            assert_eq!(fields[2], "C23G_TSTZ_TYPE", "fixture type: {line}");
1320
1321            let precision = (fields[6] != "NULL")
1322                .then(|| fields[6].parse::<i8>().expect("fixture precision is an i8"));
1323            let scale = (fields[7] != "NULL")
1324                .then(|| fields[7].parse::<i8>().expect("fixture scale is an i8"));
1325            let (expected_dbtype, expected_precision_scale) = match fields[4] {
1326                "TS_DEFAULT" => ("DB_TYPE_TIMESTAMP", (0, 6)),
1327                "TS_3" => ("DB_TYPE_TIMESTAMP", (0, 3)),
1328                "TSTZ_DEFAULT" => ("DB_TYPE_TIMESTAMP_TZ", (0, 6)),
1329                "TSTZ_3" => ("DB_TYPE_TIMESTAMP_TZ", (0, 3)),
1330                "TSLTZ_DEFAULT" => ("DB_TYPE_TIMESTAMP_LTZ", (0, 6)),
1331                "TSLTZ_3" => ("DB_TYPE_TIMESTAMP_LTZ", (0, 3)),
1332                "IDS_DEFAULT" => ("DB_TYPE_INTERVAL_DS", (2, 6)),
1333                "IDS_9_3" => ("DB_TYPE_INTERVAL_DS", (9, 3)),
1334                "IYM_DEFAULT" => ("DB_TYPE_INTERVAL_YM", (2, 0)),
1335                "IYM_9" => ("DB_TYPE_INTERVAL_YM", (9, 0)),
1336                name => panic!("unexpected captured attribute {name}"),
1337            };
1338
1339            assert_eq!(
1340                public_dbtype_name_from_oracle_type_name(fields[5]),
1341                expected_dbtype,
1342                "captured raw type name: {line}"
1343            );
1344            assert_eq!(
1345                dbobject_attr_precision_scale(fields[5], precision, scale),
1346                expected_precision_scale,
1347                "captured raw precision/scale: {line}"
1348            );
1349        }
1350        assert_eq!(captured_rows, 10, "captured descriptor row count");
1351    }
1352
1353    #[test]
1354    fn descriptor_normalizer_folds_only_builtin_grammar() {
1355        assert_eq!(
1356            builtin_descriptor_family(" \ttImEsTaMp  WITH\nLOCAL\tTZ  "),
1357            Some(BuiltinDescriptorFamily::TimestampLtz)
1358        );
1359        assert_eq!(
1360            builtin_descriptor_family(" timestamp ( 6 ) with time zone "),
1361            Some(BuiltinDescriptorFamily::TimestampTz)
1362        );
1363        assert_eq!(
1364            public_dbtype_name_from_oracle_type_name("TIMESTAMP(6)"),
1365            "DB_TYPE_TIMESTAMP"
1366        );
1367        assert_eq!(
1368            builtin_descriptor_family("INTERVAL  YEAR\tTO MONTH"),
1369            Some(BuiltinDescriptorFamily::IntervalYm)
1370        );
1371        for name in [
1372            "TIMESTAMP_AUDIT",
1373            "TIMESTAMP(10)",
1374            "TIMESTAMP(6)WITH TZ",
1375            "TIMESTAMP WITH TZ EXTRA",
1376            "INTERVAL DAY TO SECOND EXTRA",
1377        ] {
1378            assert_eq!(builtin_descriptor_family(name), None, "{name}");
1379            assert_eq!(
1380                public_dbtype_name_from_oracle_type_name(name),
1381                "DB_TYPE_OBJECT",
1382                "{name} remains an ADT"
1383            );
1384            assert_eq!(dbobject_attr_precision_scale(name, None, None), (0, 0));
1385        }
1386    }
1387
1388    #[test]
1389    fn public_metadata_hides_temporal_descriptor_defaults() {
1390        for name in [
1391            "TIMESTAMP",
1392            "TIMESTAMP(6)",
1393            "TIMESTAMP WITH TZ",
1394            "TIMESTAMP WITH LOCAL TZ",
1395            "INTERVAL DAY TO SECOND",
1396            "INTERVAL YEAR TO MONTH",
1397        ] {
1398            assert_ne!(
1399                dbobject_attr_precision_scale(name, None, None),
1400                (0, 0),
1401                "raw descriptor metadata for {name}"
1402            );
1403            assert_eq!(
1404                dbobject_attr_public_precision_scale(name, None, None),
1405                (0, 0),
1406                "DbObjectAttr exposes None precision/scale for {name}"
1407            );
1408        }
1409    }
1410
1411    #[test]
1412    fn number_descriptor_precision_scale_is_unchanged() {
1413        assert_eq!(
1414            dbobject_attr_precision_scale("NUMBER", None, None),
1415            (0, -127),
1416            "unconstrained NUMBER keeps its existing defaults"
1417        );
1418        assert_eq!(
1419            dbobject_attr_precision_scale("NUMBER", None, Some(0)),
1420            (38, 0),
1421            "scale-zero NUMBER keeps its existing precision default"
1422        );
1423        assert_eq!(
1424            dbobject_attr_precision_scale("NUMBER", Some(9), Some(3)),
1425            (9, 3),
1426            "explicit NUMBER metadata is preserved"
1427        );
1428    }
1429
1430    #[test]
1431    fn other_builtin_descriptor_types_keep_zero_precision_scale() {
1432        for name in [
1433            "CHAR",
1434            "NCHAR",
1435            "VARCHAR2",
1436            "VARCHAR",
1437            "NVARCHAR2",
1438            "NVARCHAR",
1439            "RAW",
1440            "DATE",
1441            "CLOB",
1442            "NCLOB",
1443            "BLOB",
1444            "XMLTYPE",
1445            "BINARY_FLOAT",
1446            "BINARY_DOUBLE",
1447            "BOOLEAN",
1448            "PL/SQL BOOLEAN",
1449            "BINARY_INTEGER",
1450            "PLS_INTEGER",
1451            "PL/SQL BINARY INTEGER",
1452            "PL/SQL PLS INTEGER",
1453            "LONG",
1454            "LONG RAW",
1455            "ROWID",
1456            "UROWID",
1457            "BFILE",
1458            "JSON",
1459            "VECTOR",
1460        ] {
1461            assert_eq!(
1462                dbobject_attr_precision_scale(name, Some(9), Some(3)),
1463                (0, 0),
1464                "{name} intentionally has no precision/scale default"
1465            );
1466        }
1467    }
1468
1469    // An IN OUT NUMBER bind sends its input value (not a null indicator) and its
1470    // metadata reserves an output slot. NUMBER is a fixed 22-byte type, so the
1471    // input and output sizes coincide; the point here is that the value bytes
1472    // are on the wire exactly as a plain IN bind writes them.
1473    #[test]
1474    fn in_out_number_writes_input_and_reserves_output_slot() {
1475        let inout = BindValue::InOut {
1476            value: Box::new(BindValue::Number("21".to_string())),
1477            out_buffer_size: ORA_TYPE_SIZE_NUMBER,
1478        };
1479        // Not an output-only placeholder: the input value is sent, never a null.
1480        assert!(!inout.is_output_only(), "IN OUT carries an input value");
1481        assert!(!inout.is_return_output(), "IN OUT is not a function RETURN");
1482
1483        let info = bind_value_type_info(&inout).expect("IN OUT NUMBER has type info");
1484        assert_eq!(info.ora_type_num, ORA_TYPE_NUM_NUMBER);
1485        assert_eq!(info.csfrm, 0);
1486        assert_eq!(info.buffer_size, ORA_TYPE_SIZE_NUMBER);
1487
1488        // The bytes on the wire are byte-identical to a plain IN NUMBER(21):
1489        // an IN OUT bind is encoded as an input bind.
1490        assert_eq!(
1491            written_bytes(&inout, 0),
1492            written_bytes(&BindValue::Number("21".to_string()), 0),
1493            "IN OUT sends the input value, same bytes as a plain IN bind"
1494        );
1495    }
1496
1497    // An IN OUT VARCHAR must size its bind buffer for the *returned* value, which
1498    // is typically larger than the (possibly short) input. The metadata takes the
1499    // max of the input's natural size and the requested output size; the value
1500    // bytes are still just the input text.
1501    #[test]
1502    fn in_out_varchar_sizes_output_slot_and_writes_input_text() {
1503        // input "ab" natural size = 2 chars * 4 = 8; the OUT slot wants 200.
1504        let inout = BindValue::InOut {
1505            value: Box::new(BindValue::Text("ab".to_string())),
1506            out_buffer_size: 200,
1507        };
1508        let info = bind_value_type_info(&inout).expect("IN OUT VARCHAR has type info");
1509        assert_eq!(info.ora_type_num, ORA_TYPE_NUM_VARCHAR);
1510        assert_eq!(info.csfrm, CS_FORM_IMPLICIT);
1511        assert_eq!(
1512            info.buffer_size, 200,
1513            "the OUT slot is sized for the returned value, not the short input"
1514        );
1515
1516        // The written bytes are exactly the input text (same as a plain IN Text).
1517        assert_eq!(
1518            written_bytes(&inout, CS_FORM_IMPLICIT),
1519            written_bytes(&BindValue::Text("ab".to_string()), CS_FORM_IMPLICIT),
1520        );
1521
1522        // When the input is the larger side, its natural size wins.
1523        let big_input = BindValue::InOut {
1524            value: Box::new(BindValue::Text("abcdefghij".to_string())), // 10 * 4 = 40
1525            out_buffer_size: 4,
1526        };
1527        assert_eq!(
1528            bind_value_type_info(&big_input).unwrap().buffer_size,
1529            40,
1530            "buffer is max(input natural size, requested output size)"
1531        );
1532    }
1533
1534    // The read-back metadata a server response is decoded against
1535    // (`bind_column_metadata`) must carry the IN OUT bind's Oracle type and the
1536    // reserved output buffer, so the returned value parses correctly.
1537    #[test]
1538    fn in_out_read_back_metadata_matches_output_slot() {
1539        let inout = BindValue::InOut {
1540            value: Box::new(BindValue::Text("ab".to_string())),
1541            out_buffer_size: 200,
1542        };
1543        let column = crate::thin::bind_column_metadata(&inout);
1544        assert_eq!(column.ora_type_num, ORA_TYPE_NUM_VARCHAR);
1545        assert_eq!(column.csfrm, CS_FORM_IMPLICIT);
1546        assert_eq!(column.buffer_size, 200);
1547        assert_eq!(column.max_size, 200);
1548        assert!(!column.is_array);
1549    }
1550}