Skip to main content

arrow_tiberius/write/
writer.rs

1//! Baseline bulk writer public API skeleton.
2
3use std::borrow::Cow;
4
5use arrow_array::RecordBatch;
6use futures_util::io::{AsyncRead, AsyncWrite};
7
8use crate::observability::{
9    DIRECT_ENCODING_PHASE, TARGET_METADATA_VALIDATION_PHASE, WRITER_INITIALIZATION_PHASE,
10    writer::{BatchWriteTrace, DirectRawBatchObserver, FinishTrace, WriterInitializationTrace},
11};
12use crate::{
13    Diagnostic, DiagnosticCode, DiagnosticSet, FieldRef, PlanOptions, Result, SchemaMapping,
14    TableName, WritePhase,
15};
16
17use super::{
18    SchemaCheck,
19    direct::{
20        DirectEncoder, MeasuredDirectBatch, MeasuredRowRange,
21        plan::{DirectColumnEncoding, DirectColumnPlan, DirectEncoderPlan},
22    },
23    profile,
24    record_batch::RecordBatchView,
25    token_row::tiberius_row_owned,
26};
27use crate::conversion::arrow_to_mssql::{
28    fixed_size_binary::FixedSizeBinaryArrowToMssql, primitive::PrimitiveArrowToMssql,
29    temporal::TemporalArrowToMssql, variable_width::VariableWidthArrowToMssql,
30};
31
32const DIRECT_RAW_MAX_PAYLOAD_BYTES: usize = 8 * 1024 * 1024;
33
34/// Write backend selection.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
36pub enum WriteBackend {
37    /// Select the best available backend for the current crate build and plan.
38    #[default]
39    Auto,
40    /// Use Tiberius' row-oriented `TokenRow` bulk-load path.
41    BaselineTokenRow,
42    /// Use direct bulk-row payload encoding through Tiberius' framed sink.
43    DirectFramedBulk,
44    /// Use the raw bulk-row payload path exposed by the Tiberius fork.
45    DirectRawBulk,
46}
47
48/// Execution-time write options.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
50pub struct WriteOptions {
51    /// Requested write backend.
52    pub backend: WriteBackend,
53    /// Batch schema validation policy.
54    pub schema_check: SchemaCheck,
55    /// Planning/runtime conversion policies used by policy-dependent write conversions.
56    pub plan_options: PlanOptions,
57}
58
59/// Cumulative write statistics.
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
61pub struct WriteStats {
62    /// Number of rows accepted by the writer.
63    pub rows_written: u64,
64    /// Number of batches accepted by the writer.
65    pub batches_written: u64,
66}
67
68#[derive(Debug)]
69struct WriterState {
70    backend: WriteBackend,
71    direct_encoder: Option<DirectEncoder>,
72    schema_check: SchemaCheck,
73    plan_options: PlanOptions,
74    mappings: Vec<SchemaMapping>,
75    stats: WriteStats,
76}
77
78impl WriterState {
79    fn new(
80        requested_backend: WriteBackend,
81        schema_check: SchemaCheck,
82        plan_options: PlanOptions,
83        mappings: Vec<SchemaMapping>,
84    ) -> Result<Self> {
85        let backend = resolve_backend(requested_backend)?;
86        let direct_encoder = match backend {
87            WriteBackend::DirectFramedBulk | WriteBackend::DirectRawBulk => {
88                Some(DirectEncoder::new_with_options(&mappings, plan_options)?)
89            }
90            WriteBackend::Auto | WriteBackend::BaselineTokenRow => None,
91        };
92
93        Ok(Self {
94            backend,
95            direct_encoder,
96            schema_check,
97            plan_options,
98            mappings,
99            stats: WriteStats::default(),
100        })
101    }
102
103    fn backend(&self) -> WriteBackend {
104        self.backend
105    }
106
107    fn direct_encoder(&self) -> Option<&DirectEncoder> {
108        self.direct_encoder.as_ref()
109    }
110
111    fn mappings(&self) -> &[SchemaMapping] {
112        &self.mappings
113    }
114
115    fn schema_check(&self) -> SchemaCheck {
116        self.schema_check
117    }
118
119    fn plan_options(&self) -> &PlanOptions {
120        &self.plan_options
121    }
122
123    fn stats(&self) -> WriteStats {
124        self.stats
125    }
126
127    fn record_accepted_batch(&mut self, rows: u64) -> WriteStats {
128        self.stats.rows_written = self.stats.rows_written.saturating_add(rows);
129        self.stats.batches_written = self.stats.batches_written.saturating_add(1);
130        self.stats
131    }
132}
133
134/// SQL Server bulk writer for Arrow record batches.
135#[derive(Debug)]
136pub struct BulkWriter<'client, S>
137where
138    S: AsyncRead + AsyncWrite + Unpin + Send,
139{
140    state: WriterState,
141    request: tiberius::BulkLoadRequest<'client, S>,
142}
143
144impl<'client, S> BulkWriter<'client, S>
145where
146    S: AsyncRead + AsyncWrite + Unpin + Send,
147{
148    /// Starts a bulk writer for a planned SQL Server table target.
149    pub async fn new(
150        client: &'client mut tiberius::Client<S>,
151        table: TableName,
152        mappings: Vec<SchemaMapping>,
153        options: WriteOptions,
154    ) -> Result<Self> {
155        let mut trace = WriterInitializationTrace::new(&table, options.backend, mappings.len());
156        trace.emit_started();
157
158        let state = match WriterState::new(
159            options.backend,
160            options.schema_check,
161            options.plan_options,
162            mappings,
163        ) {
164            Ok(state) => state,
165            Err(err) => {
166                trace.emit_failed(WRITER_INITIALIZATION_PHASE, &err);
167                return Err(err.with_write_phase(WritePhase::WriterInitialization));
168            }
169        };
170        trace.record_resolved_backend(state.backend());
171        trace.record_direct_target_validation_required(matches!(
172            state.backend(),
173            WriteBackend::DirectFramedBulk | WriteBackend::DirectRawBulk
174        ));
175
176        let mut request = match state.backend() {
177            WriteBackend::BaselineTokenRow
178            | WriteBackend::DirectFramedBulk
179            | WriteBackend::DirectRawBulk => {
180                let table_sql = bulk_insert_table_sql(&table);
181                let columns = match client
182                    .bulk_insert_columns(&table_sql)
183                    .await
184                    .map_err(|source| crate::Error::Tiberius { source })
185                {
186                    Ok(columns) => columns,
187                    Err(err) => {
188                        trace.emit_failed(TARGET_METADATA_VALIDATION_PHASE, &err);
189                        return Err(err.with_write_phase(WritePhase::TargetMetadataValidation));
190                    }
191                };
192                trace.emit_target_metadata_validation_started();
193                if let Err(err) = validate_bulk_target_columns(columns.iter(), state.mappings()) {
194                    trace.emit_failed(TARGET_METADATA_VALIDATION_PHASE, &err);
195                    return Err(err.with_write_phase(WritePhase::TargetMetadataValidation));
196                }
197                if matches!(
198                    state.backend(),
199                    WriteBackend::DirectFramedBulk | WriteBackend::DirectRawBulk
200                ) {
201                    let encoder = match state.direct_encoder().ok_or_else(|| {
202                        crate::Error::BackendUnavailable {
203                            backend: state.backend(),
204                            reason: "direct bulk encoder is not available for this writer"
205                                .to_owned(),
206                        }
207                    }) {
208                        Ok(encoder) => encoder,
209                        Err(err) => {
210                            trace.emit_failed(TARGET_METADATA_VALIDATION_PHASE, &err);
211                            return Err(err.with_write_phase(WritePhase::TargetMetadataValidation));
212                        }
213                    };
214                    if let Err(err) =
215                        validate_direct_bulk_target_column_types(columns.iter(), encoder.plan())
216                    {
217                        trace.emit_failed(TARGET_METADATA_VALIDATION_PHASE, &err);
218                        return Err(err.with_write_phase(WritePhase::TargetMetadataValidation));
219                    }
220                }
221                trace.emit_target_metadata_validation_completed();
222                match client
223                    .bulk_insert_with_columns(&table_sql, columns)
224                    .await
225                    .map_err(|source| crate::Error::Tiberius { source })
226                {
227                    Ok(request) => request,
228                    Err(err) => {
229                        trace.emit_failed(WRITER_INITIALIZATION_PHASE, &err);
230                        return Err(err.with_write_phase(WritePhase::WriterInitialization));
231                    }
232                }
233            }
234            WriteBackend::Auto => {
235                let err = execution_unavailable(state.backend());
236                trace.emit_failed(WRITER_INITIALIZATION_PHASE, &err);
237                return Err(err.with_write_phase(WritePhase::WriterInitialization));
238            }
239        };
240
241        if state.backend() == WriteBackend::DirectRawBulk {
242            request.enable_direct_packet_writes();
243        }
244
245        trace.emit_completed();
246
247        Ok(Self { state, request })
248    }
249
250    /// Writes one Arrow record batch.
251    pub async fn write_batch(&mut self, batch: &RecordBatch) -> Result<WriteStats> {
252        match self.state.backend() {
253            WriteBackend::BaselineTokenRow => {
254                write_traced_batch_to_sink(&mut self.state, &mut self.request, batch).await
255            }
256            WriteBackend::DirectFramedBulk | WriteBackend::DirectRawBulk => {
257                write_traced_direct_batch_to_sink(&mut self.state, &mut self.request, batch).await
258            }
259            WriteBackend::Auto => Err(execution_unavailable(WriteBackend::Auto)),
260        }
261    }
262
263    /// Finalizes the bulk writer and returns cumulative write statistics.
264    pub async fn finish(self) -> Result<WriteStats> {
265        let Self { state, request } = self;
266        finish_writer_to_sink(state, request).await
267    }
268}
269
270async fn finish_writer_to_sink<Sink>(state: WriterState, sink: Sink) -> Result<WriteStats>
271where
272    Sink: FinishSink,
273{
274    let trace = FinishTrace::new(state.backend(), state.stats());
275    trace.emit_started();
276    let stats = state.stats();
277
278    if let Err(err) = sink.finalize_bulk_load().await {
279        trace.emit_failed(&err);
280        return Err(err.with_write_phase(WritePhase::Finalize));
281    }
282
283    trace.emit_completed();
284    Ok(stats)
285}
286
287trait FinishSink {
288    async fn finalize_bulk_load(self) -> Result<()>;
289}
290
291impl<S> FinishSink for tiberius::BulkLoadRequest<'_, S>
292where
293    S: AsyncRead + AsyncWrite + Unpin + Send,
294{
295    async fn finalize_bulk_load(self) -> Result<()> {
296        #[cfg(feature = "bench-profile")]
297        {
298            let (_result, stats) = self
299                .finalize_with_stats()
300                .await
301                .map_err(|source| crate::Error::Tiberius { source })?;
302            profile::record_bulk_load_stats(stats);
303        }
304
305        #[cfg(not(feature = "bench-profile"))]
306        self.finalize()
307            .await
308            .map_err(|source| crate::Error::Tiberius { source })?;
309
310        Ok(())
311    }
312}
313
314fn bulk_insert_table_sql(table: &TableName) -> String {
315    table.quoted_sql()
316}
317
318fn record_batch_view<'a>(
319    batch: &'a RecordBatch,
320    mappings: &'a [SchemaMapping],
321    schema_check: SchemaCheck,
322    plan_options: &PlanOptions,
323) -> Result<RecordBatchView<'a>> {
324    match schema_check {
325        SchemaCheck::Strict => RecordBatchView::new_with_options(batch, mappings, plan_options),
326    }
327}
328
329fn validate_batch_rows(view: &RecordBatchView<'_>) -> Result<()> {
330    for row_index in 0..view.row_count() {
331        let _cells = view.mssql_row(row_index)?;
332    }
333
334    Ok(())
335}
336
337fn validate_bulk_target_columns<Column>(
338    columns: impl ExactSizeIterator<Item = Column>,
339    mappings: &[SchemaMapping],
340) -> Result<()>
341where
342    Column: BulkTargetColumnMetadata,
343{
344    let column_count = columns.len();
345    let mut diagnostics = DiagnosticSet::new();
346
347    if column_count != mappings.len() {
348        diagnostics.push(Diagnostic::error(
349            DiagnosticCode::SchemaMismatch,
350            format!(
351                "bulk target has {column_count} updateable column(s) but mappings contain {} column(s)",
352                mappings.len()
353            ),
354        ));
355    }
356
357    for (position, (column, mapping)) in columns.zip(mappings).enumerate() {
358        validate_bulk_target_column(position, column, mapping, &mut diagnostics);
359    }
360
361    if diagnostics.has_errors() {
362        return Err(crate::Error::ValueConversion { diagnostics });
363    }
364
365    Ok(())
366}
367
368fn validate_bulk_target_column(
369    position: usize,
370    column: impl BulkTargetColumnMetadata,
371    mapping: &SchemaMapping,
372    diagnostics: &mut DiagnosticSet,
373) {
374    if column.ordinal() != position {
375        diagnostics.push(bulk_target_column_diagnostic(
376            mapping,
377            format!(
378                "bulk target column ordinal {} does not match mapping position {position}",
379                column.ordinal()
380            ),
381        ));
382    }
383
384    if column.name() != mapping.mssql().name().as_str() {
385        diagnostics.push(bulk_target_column_diagnostic(
386            mapping,
387            format!(
388                "bulk target column name {} does not match planned MSSQL column name {}",
389                column.name(),
390                mapping.mssql().name().as_str()
391            ),
392        ));
393    }
394
395    if column.is_nullable() != mapping.mssql().nullable() {
396        diagnostics.push(bulk_target_column_diagnostic(
397            mapping,
398            format!(
399                "bulk target column nullability {} does not match planned MSSQL column nullability {}",
400                column.is_nullable(),
401                mapping.mssql().nullable()
402            ),
403        ));
404    }
405}
406
407fn validate_direct_bulk_target_column_types<Column>(
408    columns: impl ExactSizeIterator<Item = Column>,
409    plan: &DirectEncoderPlan,
410) -> Result<()>
411where
412    Column: BulkTargetColumnMetadata,
413{
414    let column_count = columns.len();
415    let mut diagnostics = DiagnosticSet::new();
416
417    if column_count != plan.column_count() {
418        diagnostics.push(Diagnostic::error(
419            DiagnosticCode::SchemaMismatch,
420            format!(
421                "bulk target has {column_count} updateable column(s) but direct plan contains {} column(s)",
422                plan.column_count()
423            ),
424        ));
425    }
426
427    for (column, plan_column) in columns.zip(plan.columns()) {
428        validate_direct_bulk_target_column_type(column, plan_column, &mut diagnostics);
429    }
430
431    if diagnostics.has_errors() {
432        return Err(crate::Error::ValueConversion { diagnostics });
433    }
434
435    Ok(())
436}
437
438fn validate_direct_bulk_target_column_type(
439    column: impl BulkTargetColumnMetadata,
440    plan_column: &DirectColumnPlan,
441    diagnostics: &mut DiagnosticSet,
442) {
443    let Some(expected) = expected_direct_bulk_column_type(plan_column) else {
444        diagnostics.push(
445            Diagnostic::error(
446                DiagnosticCode::DirectEncodingUnsupportedMapping,
447                format!(
448                    "direct target type validation is not implemented for {:?}",
449                    plan_column.encoding()
450                ),
451            )
452            .with_field(FieldRef::new(
453                plan_column.source_index(),
454                plan_column.source_name(),
455            )),
456        );
457        return;
458    };
459    let actual = column.column_type();
460
461    if actual != expected
462        && !matches!(
463            (actual, expected),
464            (
465                tiberius::ColumnType::Datetime,
466                tiberius::ColumnType::Datetimen
467            )
468        )
469    {
470        diagnostics.push(
471            Diagnostic::error(
472                DiagnosticCode::SchemaMismatch,
473                format!(
474                    "bulk target column type {actual:?} does not match direct encoder type {expected:?}"
475                ),
476            )
477            .with_field(FieldRef::new(
478                plan_column.source_index(),
479                plan_column.source_name(),
480            )),
481        );
482    }
483
484    if let Some((expected_precision, expected_scale)) =
485        expected_direct_decimal_precision_scale(plan_column)
486    {
487        match column.decimal_precision_scale() {
488            Some((actual_precision, actual_scale))
489                if actual_precision == expected_precision && actual_scale == expected_scale => {}
490            Some((actual_precision, actual_scale)) => diagnostics.push(
491                Diagnostic::error(
492                    DiagnosticCode::SchemaMismatch,
493                    format!(
494                        "bulk target decimal precision/scale ({actual_precision},{actual_scale}) does not match direct encoder precision/scale ({expected_precision},{expected_scale})"
495                    ),
496                )
497                .with_field(FieldRef::new(
498                    plan_column.source_index(),
499                    plan_column.source_name(),
500                )),
501            ),
502            None => diagnostics.push(
503                Diagnostic::error(
504                    DiagnosticCode::SchemaMismatch,
505                    "bulk target decimal precision/scale metadata is not available",
506                )
507                .with_field(FieldRef::new(
508                    plan_column.source_index(),
509                    plan_column.source_name(),
510                )),
511            ),
512        }
513    }
514}
515
516fn expected_direct_bulk_column_type(column: &DirectColumnPlan) -> Option<tiberius::ColumnType> {
517    match column.encoding() {
518        DirectColumnEncoding::Primitive(PrimitiveArrowToMssql::BooleanToBit) => {
519            if column.nullable() {
520                Some(tiberius::ColumnType::Bitn)
521            } else {
522                Some(tiberius::ColumnType::Bit)
523            }
524        }
525        DirectColumnEncoding::Primitive(PrimitiveArrowToMssql::UInt8ToTinyInt) => {
526            Some(tiberius::ColumnType::Int1)
527        }
528        DirectColumnEncoding::Primitive(
529            PrimitiveArrowToMssql::Int8ToSmallInt | PrimitiveArrowToMssql::Int16ToSmallInt,
530        ) => Some(tiberius::ColumnType::Int2),
531        DirectColumnEncoding::Primitive(PrimitiveArrowToMssql::Int32ToInt) => {
532            Some(tiberius::ColumnType::Int4)
533        }
534        DirectColumnEncoding::Primitive(PrimitiveArrowToMssql::UInt16ToInt) => {
535            Some(tiberius::ColumnType::Int4)
536        }
537        DirectColumnEncoding::Primitive(PrimitiveArrowToMssql::Int64ToBigInt) => {
538            Some(tiberius::ColumnType::Int8)
539        }
540        DirectColumnEncoding::Primitive(PrimitiveArrowToMssql::UInt32ToBigInt) => {
541            Some(tiberius::ColumnType::Int8)
542        }
543        DirectColumnEncoding::Primitive(PrimitiveArrowToMssql::UInt64ToCheckedBigInt) => {
544            Some(tiberius::ColumnType::Int8)
545        }
546        DirectColumnEncoding::Primitive(
547            PrimitiveArrowToMssql::Float16ToReal | PrimitiveArrowToMssql::Float32ToReal,
548        ) => Some(tiberius::ColumnType::Float4),
549        DirectColumnEncoding::Primitive(PrimitiveArrowToMssql::Float64ToFloat) => {
550            Some(tiberius::ColumnType::Float8)
551        }
552        DirectColumnEncoding::UInt64Decimal20_0 | DirectColumnEncoding::Decimal(_) => {
553            Some(tiberius::ColumnType::Decimaln)
554        }
555        DirectColumnEncoding::VariableWidth(VariableWidthArrowToMssql::StringToNVarChar {
556            ..
557        }) => Some(tiberius::ColumnType::NVarchar),
558        DirectColumnEncoding::VariableWidth(VariableWidthArrowToMssql::BytesToVarBinary {
559            ..
560        }) => Some(tiberius::ColumnType::BigVarBin),
561        DirectColumnEncoding::FixedSizeBinary(
562            FixedSizeBinaryArrowToMssql::FixedSizeBinaryToBinary { .. },
563        ) => Some(tiberius::ColumnType::BigBinary),
564        DirectColumnEncoding::Temporal(TemporalArrowToMssql::Date32ToDate) => {
565            Some(tiberius::ColumnType::Daten)
566        }
567        DirectColumnEncoding::Temporal(TemporalArrowToMssql::Date64ToDateTime2) => {
568            Some(tiberius::ColumnType::Datetime2)
569        }
570        DirectColumnEncoding::Temporal(
571            TemporalArrowToMssql::TimestampSecondToDateTime2
572            | TemporalArrowToMssql::TimestampMillisecondToDateTime2
573            | TemporalArrowToMssql::TimestampMicrosecondToDateTime2
574            | TemporalArrowToMssql::TimestampNanosecondToDateTime2
575            | TemporalArrowToMssql::TimestampSecondTzToDateTime2
576            | TemporalArrowToMssql::TimestampMillisecondTzToDateTime2
577            | TemporalArrowToMssql::TimestampMicrosecondTzToDateTime2
578            | TemporalArrowToMssql::TimestampNanosecondTzToDateTime2,
579        ) => Some(tiberius::ColumnType::Datetime2),
580        DirectColumnEncoding::Temporal(
581            TemporalArrowToMssql::TimestampSecondToDateTime
582            | TemporalArrowToMssql::TimestampMillisecondToDateTime
583            | TemporalArrowToMssql::TimestampMicrosecondToDateTime
584            | TemporalArrowToMssql::TimestampNanosecondToDateTime
585            | TemporalArrowToMssql::TimestampSecondTzToDateTime
586            | TemporalArrowToMssql::TimestampMillisecondTzToDateTime
587            | TemporalArrowToMssql::TimestampMicrosecondTzToDateTime
588            | TemporalArrowToMssql::TimestampNanosecondTzToDateTime,
589        ) => Some(tiberius::ColumnType::Datetimen),
590        DirectColumnEncoding::Temporal(
591            TemporalArrowToMssql::Time32SecondToTime
592            | TemporalArrowToMssql::Time32MillisecondToTime
593            | TemporalArrowToMssql::Time64MicrosecondToTime
594            | TemporalArrowToMssql::Time64NanosecondToTime,
595        ) => Some(tiberius::ColumnType::Timen),
596        DirectColumnEncoding::Temporal(
597            TemporalArrowToMssql::TimestampSecondTzToDateTimeOffset
598            | TemporalArrowToMssql::TimestampMillisecondTzToDateTimeOffset
599            | TemporalArrowToMssql::TimestampMicrosecondTzToDateTimeOffset
600            | TemporalArrowToMssql::TimestampNanosecondTzToDateTimeOffset,
601        ) => Some(tiberius::ColumnType::DatetimeOffsetn),
602    }
603}
604
605fn expected_direct_decimal_precision_scale(column: &DirectColumnPlan) -> Option<(u8, u8)> {
606    match column.encoding() {
607        DirectColumnEncoding::UInt64Decimal20_0 => Some((20, 0)),
608        DirectColumnEncoding::Decimal(classification) => Some((
609            classification.target_precision(),
610            classification.target_scale(),
611        )),
612        _ => None,
613    }
614}
615
616fn bulk_target_column_diagnostic(
617    mapping: &SchemaMapping,
618    message: impl Into<String>,
619) -> Diagnostic {
620    Diagnostic::error(DiagnosticCode::SchemaMismatch, message).with_field(FieldRef::new(
621        mapping.arrow().index(),
622        mapping.arrow().name(),
623    ))
624}
625
626trait BulkTargetColumnMetadata {
627    fn ordinal(&self) -> usize;
628
629    fn name(&self) -> &str;
630
631    fn is_nullable(&self) -> bool;
632
633    fn column_type(&self) -> tiberius::ColumnType;
634
635    fn decimal_precision_scale(&self) -> Option<(u8, u8)> {
636        None
637    }
638}
639
640impl<T> BulkTargetColumnMetadata for &T
641where
642    T: BulkTargetColumnMetadata + ?Sized,
643{
644    fn ordinal(&self) -> usize {
645        (*self).ordinal()
646    }
647
648    fn name(&self) -> &str {
649        (*self).name()
650    }
651
652    fn is_nullable(&self) -> bool {
653        (*self).is_nullable()
654    }
655
656    fn column_type(&self) -> tiberius::ColumnType {
657        (*self).column_type()
658    }
659
660    fn decimal_precision_scale(&self) -> Option<(u8, u8)> {
661        (*self).decimal_precision_scale()
662    }
663}
664
665impl BulkTargetColumnMetadata for tiberius::BulkLoadColumn<'_> {
666    fn ordinal(&self) -> usize {
667        self.ordinal()
668    }
669
670    fn name(&self) -> &str {
671        self.name()
672    }
673
674    fn is_nullable(&self) -> bool {
675        self.is_nullable()
676    }
677
678    fn column_type(&self) -> tiberius::ColumnType {
679        self.column_type()
680    }
681
682    fn decimal_precision_scale(&self) -> Option<(u8, u8)> {
683        match self.type_info() {
684            tiberius::TypeInfo::VarLenSizedPrecision {
685                ty: tiberius::VarLenType::Decimaln | tiberius::VarLenType::Numericn,
686                precision,
687                scale,
688                ..
689            } => Some((*precision, *scale)),
690            _ => None,
691        }
692    }
693}
694
695/// Writes one baseline token-row batch without crate-owned batch lifecycle traces.
696async fn write_batch_to_sink<Sink>(
697    state: &mut WriterState,
698    sink: &mut Sink,
699    batch: &RecordBatch,
700) -> Result<WriteStats>
701where
702    Sink: TokenRowSink,
703{
704    let view = match record_batch_view(
705        batch,
706        state.mappings(),
707        state.schema_check(),
708        state.plan_options(),
709    ) {
710        Ok(view) => view,
711        Err(err) => return Err(err.with_write_phase(WritePhase::BatchSchemaValidation)),
712    };
713    if let Err(err) = validate_batch_rows(&view) {
714        return Err(err.with_write_phase(WritePhase::ValueConversion));
715    }
716    let rows_written = usize_to_u64_saturating(view.row_count());
717
718    for row_index in 0..view.row_count() {
719        let row = match tiberius_row_owned(&view, row_index) {
720            Ok(row) => row,
721            Err(err) => return Err(err.with_write_phase(WritePhase::ValueConversion)),
722        };
723        if let Err(err) = sink.send_token_row(row).await {
724            return Err(err.with_write_phase(WritePhase::PacketWrite));
725        }
726    }
727
728    let stats = state.record_accepted_batch(rows_written);
729    Ok(stats)
730}
731
732/// Adds the crate-owned batch lifecycle span around the baseline token-row write path.
733async fn write_traced_batch_to_sink<Sink>(
734    state: &mut WriterState,
735    sink: &mut Sink,
736    batch: &RecordBatch,
737) -> Result<WriteStats>
738where
739    Sink: TokenRowSink,
740{
741    let trace = BatchWriteTrace::new(state.backend(), state.stats(), batch);
742    trace
743        .trace_result(write_batch_to_sink(state, sink, batch))
744        .await
745}
746
747trait TokenRowSink {
748    async fn send_token_row(&mut self, row: tiberius::TokenRow<'static>) -> Result<()>;
749}
750
751impl<S> TokenRowSink for tiberius::BulkLoadRequest<'_, S>
752where
753    S: AsyncRead + AsyncWrite + Unpin + Send,
754{
755    async fn send_token_row(&mut self, row: tiberius::TokenRow<'static>) -> Result<()> {
756        self.send(row)
757            .await
758            .map_err(|source| crate::Error::Tiberius { source })
759    }
760}
761
762/// Test-only direct write entry point with direct raw detail telemetry disabled.
763#[cfg(test)]
764async fn write_direct_batch_to_sink<Sink>(
765    state: &mut WriterState,
766    sink: &mut Sink,
767    batch: &RecordBatch,
768) -> Result<WriteStats>
769where
770    Sink: RawRowsSink,
771{
772    write_direct_batch_to_sink_with_observer(state, sink, batch, DirectRawBatchObserver::disabled())
773        .await
774}
775
776/// Adds the crate-owned batch lifecycle span and direct raw detail telemetry.
777async fn write_traced_direct_batch_to_sink<Sink>(
778    state: &mut WriterState,
779    sink: &mut Sink,
780    batch: &RecordBatch,
781) -> Result<WriteStats>
782where
783    Sink: RawRowsSink,
784{
785    let trace = BatchWriteTrace::new(state.backend(), state.stats(), batch);
786    let direct_observer = DirectRawBatchObserver::enabled(state.backend());
787    trace
788        .trace_result(write_direct_batch_to_sink_with_observer(
789            state,
790            sink,
791            batch,
792            direct_observer,
793        ))
794        .await
795}
796
797/// Shared direct write implementation.
798///
799/// The `direct_observer` records direct raw detail events when enabled. Batch
800/// lifecycle tracing is owned by `write_traced_direct_batch_to_sink`.
801async fn write_direct_batch_to_sink_with_observer<Sink>(
802    state: &mut WriterState,
803    sink: &mut Sink,
804    batch: &RecordBatch,
805    direct_observer: DirectRawBatchObserver,
806) -> Result<WriteStats>
807where
808    Sink: RawRowsSink,
809{
810    let encoder = state
811        .direct_encoder()
812        .ok_or_else(|| crate::Error::BackendUnavailable {
813            backend: state.backend(),
814            reason: "direct bulk encoder is not available for this writer".to_owned(),
815        })
816        .map_err(|err| err.with_write_phase(WritePhase::DirectEncoding))?;
817
818    let measure_start = std::time::Instant::now();
819    let measured = encoder.measure_batch(batch);
820    let measured =
821        match profile::record_elapsed(measure_start, profile::record_measure_batch, measured) {
822            Ok(measured) => measured,
823            Err(err) => {
824                let phase = write_phase_for_batch_error(&err);
825                direct_observer.record_failed(phase.as_str(), batch, None, &err);
826                return Err(err.with_write_phase(phase));
827            }
828        };
829    direct_observer.record_measured(&measured, measure_start.elapsed());
830    let rows_written = usize_to_u64_saturating(measured.row_count());
831
832    let split_start = std::time::Instant::now();
833    let ranges = measured.row_ranges(DIRECT_RAW_MAX_PAYLOAD_BYTES);
834    let ranges = match profile::record_elapsed(split_start, profile::record_row_range_split, ranges)
835    {
836        Ok(ranges) => ranges,
837        Err(err) => {
838            direct_observer.record_failed(DIRECT_ENCODING_PHASE, batch, None, &err);
839            return Err(err.with_write_phase(WritePhase::DirectEncoding));
840        }
841    };
842    direct_observer.record_ranges_planned(&measured, &ranges, split_start.elapsed());
843
844    for range in ranges {
845        if let Err(err) = sink
846            .send_measured_raw_rows(encoder, batch, &measured, range, direct_observer)
847            .await
848        {
849            let phase = write_phase_for_batch_error(&err);
850            direct_observer.record_failed(phase.as_str(), batch, Some(range), &err);
851            return Err(err.with_write_phase(phase));
852        }
853    }
854
855    profile::record_accepted_batch(measured.row_count());
856    let stats = state.record_accepted_batch(rows_written);
857    Ok(stats)
858}
859
860trait RawRowsSink {
861    async fn send_measured_raw_rows(
862        &mut self,
863        encoder: &DirectEncoder,
864        batch: &RecordBatch,
865        measured: &MeasuredDirectBatch,
866        range: MeasuredRowRange,
867        direct_observer: DirectRawBatchObserver,
868    ) -> Result<()>;
869}
870
871impl<S> RawRowsSink for tiberius::BulkLoadRequest<'_, S>
872where
873    S: AsyncRead + AsyncWrite + Unpin + Send,
874{
875    async fn send_measured_raw_rows(
876        &mut self,
877        encoder: &DirectEncoder,
878        batch: &RecordBatch,
879        measured: &MeasuredDirectBatch,
880        range: MeasuredRowRange,
881        direct_observer: DirectRawBatchObserver,
882    ) -> Result<()> {
883        let encoded_bytes = measured.range_payload_len(range.start, range.len)?;
884        profile::record_row_range(encoded_bytes);
885
886        if !encoder.has_variable_width_column() {
887            let encode_start = std::time::Instant::now();
888            let payload =
889                encoder.encode_measured_batch_range(batch, measured, range.start, range.len)?;
890            profile::record_append_encode(encode_start.elapsed());
891
892            let send_start = std::time::Instant::now();
893            let send_result = self
894                .send_raw_rows_payload_checked(payload.bytes(), payload.row_token_offsets())
895                .await
896                .map_err(|source| crate::Error::Tiberius { source });
897            profile::record_send_total(send_start.elapsed());
898            if send_result.is_ok() {
899                direct_observer.record_packet_write_completed(
900                    range,
901                    payload.row_count(),
902                    payload.bytes().len(),
903                    send_start.elapsed(),
904                );
905            }
906            return send_result;
907        }
908
909        let mut encode_error = None;
910        let send_start = std::time::Instant::now();
911        let send_result = self
912            .send_raw_rows_with(|buf| {
913                let encode_start = std::time::Instant::now();
914                let encoded = encoder.encode_measured_batch_range_into(
915                    batch,
916                    measured,
917                    range.start,
918                    range.len,
919                    buf,
920                );
921                profile::record_append_encode(encode_start.elapsed());
922
923                match encoded {
924                    Ok(append) => Ok(append),
925                    Err(err) => {
926                        encode_error = Some(err);
927                        Err(tiberius::error::Error::BulkInput(Cow::Borrowed(
928                            "direct raw row encoding failed",
929                        )))
930                    }
931                }
932            })
933            .await;
934        profile::record_send_total(send_start.elapsed());
935
936        if let Some(err) = encode_error {
937            return Err(err);
938        }
939
940        let send_result = send_result.map_err(|source| crate::Error::Tiberius { source });
941        if send_result.is_ok() {
942            direct_observer.record_packet_write_completed(
943                range,
944                range.len,
945                encoded_bytes,
946                send_start.elapsed(),
947            );
948        }
949
950        send_result
951    }
952}
953
954fn resolve_backend(requested_backend: WriteBackend) -> Result<WriteBackend> {
955    match requested_backend {
956        WriteBackend::Auto | WriteBackend::DirectRawBulk => Ok(WriteBackend::DirectRawBulk),
957        WriteBackend::BaselineTokenRow => Ok(WriteBackend::BaselineTokenRow),
958        WriteBackend::DirectFramedBulk => Ok(WriteBackend::DirectFramedBulk),
959    }
960}
961
962fn execution_unavailable(backend: WriteBackend) -> crate::Error {
963    crate::Error::BackendUnavailable {
964        backend,
965        reason: "bulk writer execution is not implemented yet".to_owned(),
966    }
967}
968
969fn write_phase_for_batch_error(error: &crate::Error) -> WritePhase {
970    match error {
971        crate::Error::WritePhaseContext { phase, .. } => *phase,
972        crate::Error::ValueConversion { diagnostics }
973            if diagnostics
974                .all()
975                .iter()
976                .all(|diagnostic| diagnostic.code() == DiagnosticCode::SchemaMismatch) =>
977        {
978            WritePhase::BatchSchemaValidation
979        }
980        crate::Error::ValueConversion { .. } => WritePhase::ValueConversion,
981        crate::Error::DirectEncoding { .. } | crate::Error::BackendUnavailable { .. } => {
982            WritePhase::DirectEncoding
983        }
984        crate::Error::Tiberius { .. } => WritePhase::PacketWrite,
985        _ => WritePhase::BatchWrite,
986    }
987}
988
989fn usize_to_u64_saturating(value: usize) -> u64 {
990    u64::try_from(value).unwrap_or(u64::MAX)
991}
992
993#[cfg(test)]
994mod tests {
995    use std::{
996        borrow::Cow,
997        future::Future,
998        pin::Pin,
999        sync::{Arc, Mutex, MutexGuard},
1000        task::{Context, Poll, Waker},
1001    };
1002
1003    use arrow_array::{
1004        BinaryArray, Float64Array, Int32Array, RecordBatch, TimestampMicrosecondArray, UInt64Array,
1005    };
1006    use arrow_schema::{DataType, Field, Schema, TimeUnit};
1007    use futures_util::io::{AsyncRead, AsyncWrite};
1008
1009    use super::{
1010        BulkTargetColumnMetadata, DIRECT_RAW_MAX_PAYLOAD_BYTES, DirectEncoder, MeasuredDirectBatch,
1011        MeasuredRowRange, RawRowsSink, TokenRowSink, WriteBackend, WriteOptions, WriteStats,
1012        WriterState, bulk_insert_table_sql, record_batch_view, resolve_backend, tiberius_row_owned,
1013        validate_batch_rows, validate_bulk_target_columns,
1014        validate_direct_bulk_target_column_types, write_batch_to_sink, write_direct_batch_to_sink,
1015    };
1016    use crate::observability::writer::DirectRawBatchObserver;
1017    use crate::{
1018        ArrowFieldRef, DiagnosticCode, Error, Identifier, MssqlColumn, MssqlType, MssqlTypeLength,
1019        PlanOptions, SchemaCheck, SchemaMapping, TableName, TimestampPolicy, WritePhase,
1020    };
1021
1022    static DIRECT_RAW_TRACE_TEST_LOCK: Mutex<()> = Mutex::new(());
1023
1024    fn direct_raw_trace_test_guard() -> MutexGuard<'static, ()> {
1025        match DIRECT_RAW_TRACE_TEST_LOCK.lock() {
1026            Ok(guard) => guard,
1027            Err(poisoned) => poisoned.into_inner(),
1028        }
1029    }
1030
1031    #[test]
1032    fn write_backend_defaults_to_auto() {
1033        assert_eq!(WriteBackend::default(), WriteBackend::Auto);
1034    }
1035
1036    #[test]
1037    fn write_options_default_to_auto_backend_and_strict_schema_check() {
1038        let options = WriteOptions::default();
1039
1040        assert_eq!(options.backend, WriteBackend::Auto);
1041        assert_eq!(options.schema_check, SchemaCheck::Strict);
1042        assert_eq!(options.plan_options, PlanOptions::default());
1043    }
1044
1045    #[test]
1046    fn write_options_preserve_explicit_backend_selection() {
1047        for backend in [
1048            WriteBackend::Auto,
1049            WriteBackend::BaselineTokenRow,
1050            WriteBackend::DirectFramedBulk,
1051            WriteBackend::DirectRawBulk,
1052        ] {
1053            let options = WriteOptions {
1054                backend,
1055                schema_check: SchemaCheck::Strict,
1056                ..WriteOptions::default()
1057            };
1058
1059            assert_eq!(options.backend, backend);
1060            assert_eq!(options.schema_check, SchemaCheck::Strict);
1061        }
1062    }
1063
1064    #[test]
1065    fn write_stats_default_to_zero() {
1066        let stats = WriteStats::default();
1067
1068        assert_eq!(stats.rows_written, 0);
1069        assert_eq!(stats.batches_written, 0);
1070    }
1071
1072    #[test]
1073    fn auto_backend_resolves_to_direct_raw_bulk() {
1074        assert_eq!(
1075            resolve_backend(WriteBackend::Auto).unwrap(),
1076            WriteBackend::DirectRawBulk
1077        );
1078    }
1079
1080    #[test]
1081    fn explicit_backends_resolve_to_requested_backend() {
1082        assert_eq!(
1083            resolve_backend(WriteBackend::BaselineTokenRow).unwrap(),
1084            WriteBackend::BaselineTokenRow
1085        );
1086        assert_eq!(
1087            resolve_backend(WriteBackend::DirectFramedBulk).unwrap(),
1088            WriteBackend::DirectFramedBulk
1089        );
1090        assert_eq!(
1091            resolve_backend(WriteBackend::DirectRawBulk).unwrap(),
1092            WriteBackend::DirectRawBulk
1093        );
1094    }
1095
1096    #[test]
1097    fn writer_state_starts_with_resolved_backend_mappings_and_zero_stats() {
1098        let mappings = vec![mapping("id")];
1099
1100        let state = WriterState::new(
1101            WriteBackend::Auto,
1102            SchemaCheck::Strict,
1103            PlanOptions::default(),
1104            mappings.clone(),
1105        )
1106        .unwrap();
1107
1108        assert_eq!(state.backend(), WriteBackend::DirectRawBulk);
1109        assert!(state.direct_encoder().is_some());
1110        assert_eq!(state.schema_check(), SchemaCheck::Strict);
1111        assert_eq!(state.mappings(), mappings.as_slice());
1112        assert_eq!(state.stats(), WriteStats::default());
1113    }
1114
1115    #[test]
1116    fn direct_writer_state_builds_encoder_for_supported_mappings() {
1117        let mappings = vec![
1118            mapping("id32"),
1119            SchemaMapping::new(
1120                ArrowFieldRef::new(1, "id64".to_owned(), false, DataType::Int64),
1121                MssqlColumn::new(Identifier::new("id64").unwrap(), MssqlType::BigInt, false),
1122            ),
1123            float_mapping_at(2, "score"),
1124            SchemaMapping::new(
1125                ArrowFieldRef::new(3, "name".to_owned(), true, DataType::Utf8),
1126                MssqlColumn::new(
1127                    Identifier::new("name").unwrap(),
1128                    MssqlType::NVarChar(crate::MssqlTypeLength::Max),
1129                    true,
1130                ),
1131            ),
1132        ];
1133
1134        for backend in [WriteBackend::DirectFramedBulk, WriteBackend::DirectRawBulk] {
1135            let state = WriterState::new(
1136                backend,
1137                SchemaCheck::Strict,
1138                PlanOptions::default(),
1139                mappings.clone(),
1140            )
1141            .unwrap();
1142
1143            assert_eq!(state.backend(), backend);
1144            assert!(state.direct_encoder().is_some());
1145        }
1146    }
1147
1148    #[test]
1149    fn direct_writer_state_rejects_unsupported_mappings() {
1150        let mappings = vec![SchemaMapping::new(
1151            ArrowFieldRef::new(
1152                0,
1153                "list_value".to_owned(),
1154                true,
1155                DataType::List(Arc::new(Field::new("item", DataType::Int32, true))),
1156            ),
1157            MssqlColumn::new(
1158                Identifier::new("list_value").unwrap(),
1159                MssqlType::NVarChar(MssqlTypeLength::Max),
1160                true,
1161            ),
1162        )];
1163
1164        let err = WriterState::new(
1165            WriteBackend::DirectRawBulk,
1166            SchemaCheck::Strict,
1167            PlanOptions::default(),
1168            mappings,
1169        )
1170        .unwrap_err();
1171
1172        let Error::DirectEncoding { diagnostics } = err else {
1173            panic!("expected direct encoding error");
1174        };
1175        assert_eq!(diagnostics.len(), 1);
1176        assert_eq!(
1177            diagnostics.all()[0].code(),
1178            DiagnosticCode::DirectEncodingUnsupportedMapping
1179        );
1180    }
1181
1182    #[test]
1183    fn writer_state_accumulates_accepted_batch_stats() {
1184        let mut state = WriterState::new(
1185            WriteBackend::BaselineTokenRow,
1186            SchemaCheck::Strict,
1187            PlanOptions::default(),
1188            Vec::new(),
1189        )
1190        .unwrap();
1191
1192        assert_eq!(
1193            state.record_accepted_batch(0),
1194            WriteStats {
1195                rows_written: 0,
1196                batches_written: 1
1197            }
1198        );
1199        assert_eq!(
1200            state.record_accepted_batch(3),
1201            WriteStats {
1202                rows_written: 3,
1203                batches_written: 2
1204            }
1205        );
1206        assert_eq!(
1207            state.record_accepted_batch(5),
1208            WriteStats {
1209                rows_written: 8,
1210                batches_written: 3
1211            }
1212        );
1213    }
1214
1215    #[test]
1216    fn bulk_insert_table_sql_uses_quoted_table_name() {
1217        let table = TableName::new("dbo]x", "target.table").unwrap();
1218
1219        assert_eq!(bulk_insert_table_sql(&table), "[dbo]]x].[target.table]");
1220    }
1221
1222    #[test]
1223    fn strict_batch_validation_accepts_supported_rows_without_owning_payloads() {
1224        let batch = int32_batch("id", &[1, 2]);
1225        let mappings = [mapping("id")];
1226        let view = record_batch_view(
1227            &batch,
1228            &mappings,
1229            SchemaCheck::Strict,
1230            &PlanOptions::default(),
1231        )
1232        .unwrap();
1233
1234        validate_batch_rows(&view).unwrap();
1235
1236        let row = tiberius_row_owned(&view, 1).unwrap();
1237        assert_eq!(row.get(0), Some(&tiberius::ColumnData::I32(Some(2))));
1238    }
1239
1240    #[test]
1241    fn strict_batch_view_rejects_runtime_schema_mismatch_before_send() {
1242        let batch = int32_batch("renamed_id", &[1]);
1243        let err = record_batch_view(
1244            &batch,
1245            &[mapping("id")],
1246            SchemaCheck::Strict,
1247            &PlanOptions::default(),
1248        )
1249        .unwrap_err();
1250
1251        let Error::ValueConversion { diagnostics } = err else {
1252            panic!("expected value conversion error");
1253        };
1254        assert_eq!(diagnostics.len(), 1);
1255        let diagnostic = &diagnostics.all()[0];
1256        assert_eq!(diagnostic.code(), DiagnosticCode::SchemaMismatch);
1257        assert_eq!(diagnostic.field().map(|field| field.name()), Some("id"));
1258    }
1259
1260    #[test]
1261    fn strict_batch_validation_rejects_bad_later_row_before_any_send() {
1262        let schema = Arc::new(Schema::new(vec![Field::new(
1263            "amount",
1264            DataType::Float64,
1265            false,
1266        )]));
1267        let batch = RecordBatch::try_new(
1268            schema,
1269            vec![Arc::new(Float64Array::from(vec![
1270                Some(1.0),
1271                Some(f64::NAN),
1272            ]))],
1273        )
1274        .unwrap();
1275        let mappings = [SchemaMapping::new(
1276            ArrowFieldRef::new(0, "amount".to_owned(), false, DataType::Float64),
1277            MssqlColumn::new(
1278                Identifier::new("amount").unwrap(),
1279                MssqlType::Float { precision: 53 },
1280                false,
1281            ),
1282        )];
1283
1284        let view = record_batch_view(
1285            &batch,
1286            &mappings,
1287            SchemaCheck::Strict,
1288            &PlanOptions::default(),
1289        )
1290        .unwrap();
1291        let err = validate_batch_rows(&view).unwrap_err();
1292
1293        let Error::ValueConversion { diagnostics } = err else {
1294            panic!("expected value conversion error");
1295        };
1296        assert_eq!(diagnostics.len(), 1);
1297        let diagnostic = &diagnostics.all()[0];
1298        assert_eq!(diagnostic.code(), DiagnosticCode::NonFiniteFloat);
1299        assert_eq!(diagnostic.row(), Some(1));
1300    }
1301
1302    #[test]
1303    fn bulk_target_column_validation_accepts_matching_metadata() {
1304        let mappings = vec![mapping("id")];
1305        let columns = vec![bulk_target_column(0, "id", false)];
1306
1307        validate_bulk_target_columns(columns.into_iter(), &mappings).unwrap();
1308    }
1309
1310    #[test]
1311    fn bulk_target_column_validation_rejects_missing_target_columns() {
1312        let mappings = vec![mapping("id")];
1313        let columns = Vec::<FakeBulkTargetColumn>::new();
1314
1315        let err = validate_bulk_target_columns(columns.into_iter(), &mappings).unwrap_err();
1316
1317        let Error::ValueConversion { diagnostics } = err else {
1318            panic!("expected value conversion error");
1319        };
1320        assert_eq!(diagnostics.len(), 1);
1321        assert_eq!(diagnostics.all()[0].code(), DiagnosticCode::SchemaMismatch);
1322        assert_eq!(
1323            diagnostics.all()[0].message(),
1324            "bulk target has 0 updateable column(s) but mappings contain 1 column(s)"
1325        );
1326    }
1327
1328    #[test]
1329    fn bulk_target_column_validation_rejects_ordinal_name_and_nullability_drift() {
1330        let mappings = vec![mapping("id")];
1331        let columns = vec![bulk_target_column(7, "id]; DROP TABLE target;--", true)];
1332
1333        let err = validate_bulk_target_columns(columns.into_iter(), &mappings).unwrap_err();
1334
1335        let Error::ValueConversion { diagnostics } = err else {
1336            panic!("expected value conversion error");
1337        };
1338        assert_eq!(diagnostics.len(), 3);
1339        assert!(
1340            diagnostics
1341                .all()
1342                .iter()
1343                .all(|diagnostic| diagnostic.code() == DiagnosticCode::SchemaMismatch)
1344        );
1345        assert!(
1346            diagnostics
1347                .all()
1348                .iter()
1349                .all(|diagnostic| diagnostic.field().map(|field| field.name()) == Some("id"))
1350        );
1351        assert!(
1352            diagnostics
1353                .all()
1354                .iter()
1355                .any(|diagnostic| diagnostic.message().contains("ordinal 7"))
1356        );
1357        assert!(
1358            diagnostics
1359                .all()
1360                .iter()
1361                .any(|diagnostic| diagnostic.message().contains("DROP TABLE"))
1362        );
1363        assert!(
1364            diagnostics
1365                .all()
1366                .iter()
1367                .any(|diagnostic| diagnostic.message().contains("nullability true"))
1368        );
1369    }
1370
1371    #[test]
1372    fn direct_bulk_target_type_validation_accepts_matching_primitive_metadata() {
1373        let mappings = vec![mapping("id")];
1374        let state = WriterState::new(
1375            WriteBackend::DirectRawBulk,
1376            SchemaCheck::Strict,
1377            PlanOptions::default(),
1378            mappings,
1379        )
1380        .unwrap();
1381        let columns = vec![bulk_target_column_with_type(
1382            0,
1383            "id",
1384            false,
1385            tiberius::ColumnType::Int4,
1386        )];
1387
1388        validate_direct_bulk_target_column_types(
1389            columns.into_iter(),
1390            state.direct_encoder().unwrap().plan(),
1391        )
1392        .unwrap();
1393    }
1394
1395    #[test]
1396    fn direct_bulk_target_type_validation_accepts_issue_75_integer_metadata() {
1397        let mappings = vec![
1398            schema_mapping_at(0, "tiny", DataType::UInt8, MssqlType::TinyInt, false),
1399            schema_mapping_at(1, "signed_tiny", DataType::Int8, MssqlType::SmallInt, false),
1400            schema_mapping_at(2, "small", DataType::Int16, MssqlType::SmallInt, false),
1401            schema_mapping_at(
1402                3,
1403                "unsigned_medium",
1404                DataType::UInt16,
1405                MssqlType::Int,
1406                false,
1407            ),
1408            schema_mapping_at(
1409                4,
1410                "unsigned_total",
1411                DataType::UInt32,
1412                MssqlType::BigInt,
1413                false,
1414            ),
1415        ];
1416        let state = WriterState::new(
1417            WriteBackend::DirectRawBulk,
1418            SchemaCheck::Strict,
1419            PlanOptions::default(),
1420            mappings,
1421        )
1422        .unwrap();
1423        let columns = vec![
1424            bulk_target_column_with_type(0, "tiny", false, tiberius::ColumnType::Int1),
1425            bulk_target_column_with_type(1, "signed_tiny", false, tiberius::ColumnType::Int2),
1426            bulk_target_column_with_type(2, "small", false, tiberius::ColumnType::Int2),
1427            bulk_target_column_with_type(3, "unsigned_medium", false, tiberius::ColumnType::Int4),
1428            bulk_target_column_with_type(4, "unsigned_total", false, tiberius::ColumnType::Int8),
1429        ];
1430
1431        validate_direct_bulk_target_column_types(
1432            columns.into_iter(),
1433            state.direct_encoder().unwrap().plan(),
1434        )
1435        .unwrap();
1436    }
1437
1438    #[test]
1439    fn direct_bulk_target_type_validation_accepts_issue_75_float32_metadata() {
1440        let mappings = vec![schema_mapping_at(
1441            0,
1442            "real_value",
1443            DataType::Float32,
1444            MssqlType::Real,
1445            false,
1446        )];
1447        let state = WriterState::new(
1448            WriteBackend::DirectRawBulk,
1449            SchemaCheck::Strict,
1450            PlanOptions::default(),
1451            mappings,
1452        )
1453        .unwrap();
1454        let columns = vec![bulk_target_column_with_type(
1455            0,
1456            "real_value",
1457            false,
1458            tiberius::ColumnType::Float4,
1459        )];
1460
1461        validate_direct_bulk_target_column_types(
1462            columns.into_iter(),
1463            state.direct_encoder().unwrap().plan(),
1464        )
1465        .unwrap();
1466    }
1467
1468    #[test]
1469    fn direct_bulk_target_type_validation_accepts_uint64_policy_metadata() {
1470        let mappings = vec![
1471            schema_mapping_at(0, "checked", DataType::UInt64, MssqlType::BigInt, false),
1472            schema_mapping_at(
1473                1,
1474                "decimal",
1475                DataType::UInt64,
1476                MssqlType::Decimal {
1477                    precision: 20,
1478                    scale: 0,
1479                },
1480                false,
1481            ),
1482        ];
1483        let state = WriterState::new(
1484            WriteBackend::DirectRawBulk,
1485            SchemaCheck::Strict,
1486            PlanOptions::default(),
1487            mappings,
1488        )
1489        .unwrap();
1490        let columns = vec![
1491            bulk_target_column_with_type(0, "checked", false, tiberius::ColumnType::Int8),
1492            bulk_target_decimal_column(1, "decimal", false, 20, 0),
1493        ];
1494
1495        validate_direct_bulk_target_column_types(
1496            columns.into_iter(),
1497            state.direct_encoder().unwrap().plan(),
1498        )
1499        .unwrap();
1500    }
1501
1502    #[test]
1503    fn direct_bulk_target_type_validation_rejects_uint64_decimal_precision_drift() {
1504        let mappings = vec![schema_mapping_at(
1505            0,
1506            "decimal",
1507            DataType::UInt64,
1508            MssqlType::Decimal {
1509                precision: 20,
1510                scale: 0,
1511            },
1512            false,
1513        )];
1514        let state = WriterState::new(
1515            WriteBackend::DirectRawBulk,
1516            SchemaCheck::Strict,
1517            PlanOptions::default(),
1518            mappings,
1519        )
1520        .unwrap();
1521        let columns = vec![bulk_target_decimal_column(0, "decimal", false, 19, 0)];
1522
1523        let err = validate_direct_bulk_target_column_types(
1524            columns.into_iter(),
1525            state.direct_encoder().unwrap().plan(),
1526        )
1527        .unwrap_err();
1528
1529        let Error::ValueConversion { diagnostics } = err else {
1530            panic!("expected value conversion error");
1531        };
1532        assert_eq!(diagnostics.len(), 1);
1533        let diagnostic = &diagnostics.all()[0];
1534        assert_eq!(diagnostic.code(), DiagnosticCode::SchemaMismatch);
1535        assert!(diagnostic.message().contains("precision/scale (19,0)"));
1536        assert_eq!(
1537            diagnostic
1538                .field()
1539                .map(|field| (field.index(), field.name())),
1540            Some((0, "decimal"))
1541        );
1542    }
1543
1544    #[test]
1545    fn direct_bulk_target_type_validation_accepts_matching_variable_width_metadata() {
1546        let mappings = vec![utf8_mapping_at(0, "name"), binary_mapping_at(1, "payload")];
1547        let state = WriterState::new(
1548            WriteBackend::DirectRawBulk,
1549            SchemaCheck::Strict,
1550            PlanOptions::default(),
1551            mappings,
1552        )
1553        .unwrap();
1554        let columns = vec![
1555            bulk_target_column_with_type(0, "name", false, tiberius::ColumnType::NVarchar),
1556            bulk_target_column_with_type(1, "payload", false, tiberius::ColumnType::BigVarBin),
1557        ];
1558
1559        validate_direct_bulk_target_column_types(
1560            columns.into_iter(),
1561            state.direct_encoder().unwrap().plan(),
1562        )
1563        .unwrap();
1564    }
1565
1566    #[test]
1567    fn direct_bulk_target_type_validation_accepts_matching_large_variable_width_metadata() {
1568        let mappings = vec![
1569            schema_mapping_at(
1570                0,
1571                "large_name",
1572                DataType::LargeUtf8,
1573                MssqlType::NVarChar(MssqlTypeLength::Max),
1574                false,
1575            ),
1576            schema_mapping_at(
1577                1,
1578                "large_payload",
1579                DataType::LargeBinary,
1580                MssqlType::VarBinary(MssqlTypeLength::Max),
1581                false,
1582            ),
1583        ];
1584        let state = WriterState::new(
1585            WriteBackend::DirectRawBulk,
1586            SchemaCheck::Strict,
1587            PlanOptions::default(),
1588            mappings,
1589        )
1590        .unwrap();
1591        let columns = vec![
1592            bulk_target_column_with_type(0, "large_name", false, tiberius::ColumnType::NVarchar),
1593            bulk_target_column_with_type(
1594                1,
1595                "large_payload",
1596                false,
1597                tiberius::ColumnType::BigVarBin,
1598            ),
1599        ];
1600
1601        validate_direct_bulk_target_column_types(
1602            columns.into_iter(),
1603            state.direct_encoder().unwrap().plan(),
1604        )
1605        .unwrap();
1606    }
1607
1608    #[test]
1609    fn direct_bulk_target_type_validation_accepts_fixed_size_binary_metadata() {
1610        let mappings = vec![fixed_size_binary_mapping_at(0, "digest", 32)];
1611        let state = WriterState::new(
1612            WriteBackend::DirectRawBulk,
1613            SchemaCheck::Strict,
1614            PlanOptions::default(),
1615            mappings,
1616        )
1617        .unwrap();
1618        let columns = vec![bulk_target_column_with_type(
1619            0,
1620            "digest",
1621            false,
1622            tiberius::ColumnType::BigBinary,
1623        )];
1624
1625        validate_direct_bulk_target_column_types(
1626            columns.into_iter(),
1627            state.direct_encoder().unwrap().plan(),
1628        )
1629        .unwrap();
1630    }
1631
1632    #[test]
1633    fn direct_bulk_target_type_validation_rejects_fixed_size_binary_as_varbinary() {
1634        let mappings = vec![fixed_size_binary_mapping_at(0, "digest", 32)];
1635        let state = WriterState::new(
1636            WriteBackend::DirectRawBulk,
1637            SchemaCheck::Strict,
1638            PlanOptions::default(),
1639            mappings,
1640        )
1641        .unwrap();
1642        let columns = vec![bulk_target_column_with_type(
1643            0,
1644            "digest",
1645            false,
1646            tiberius::ColumnType::BigVarBin,
1647        )];
1648
1649        let err = validate_direct_bulk_target_column_types(
1650            columns.into_iter(),
1651            state.direct_encoder().unwrap().plan(),
1652        )
1653        .unwrap_err();
1654
1655        let Error::ValueConversion { diagnostics } = err else {
1656            panic!("expected value conversion error");
1657        };
1658        assert_eq!(diagnostics.len(), 1);
1659        let diagnostic = &diagnostics.all()[0];
1660        assert_eq!(diagnostic.code(), DiagnosticCode::SchemaMismatch);
1661        assert_eq!(diagnostic.field().map(|field| field.name()), Some("digest"));
1662        assert!(diagnostic.message().contains(
1663            "bulk target column type BigVarBin does not match direct encoder type BigBinary"
1664        ));
1665    }
1666
1667    #[test]
1668    fn direct_bulk_target_type_validation_accepts_date_metadata() {
1669        let mappings = vec![
1670            SchemaMapping::new(
1671                ArrowFieldRef::new(0, "created_on".to_owned(), true, DataType::Date32),
1672                MssqlColumn::new(
1673                    Identifier::new("created_on").unwrap(),
1674                    MssqlType::Date,
1675                    true,
1676                ),
1677            ),
1678            SchemaMapping::new(
1679                ArrowFieldRef::new(1, "created_at".to_owned(), true, DataType::Date64),
1680                MssqlColumn::new(
1681                    Identifier::new("created_at").unwrap(),
1682                    MssqlType::DateTime2 { precision: 3 },
1683                    true,
1684                ),
1685            ),
1686        ];
1687        let state = WriterState::new(
1688            WriteBackend::DirectRawBulk,
1689            SchemaCheck::Strict,
1690            PlanOptions::default(),
1691            mappings,
1692        )
1693        .unwrap();
1694        let columns = vec![
1695            bulk_target_column_with_type(0, "created_on", true, tiberius::ColumnType::Daten),
1696            bulk_target_column_with_type(1, "created_at", true, tiberius::ColumnType::Datetime2),
1697        ];
1698
1699        validate_direct_bulk_target_column_types(
1700            columns.into_iter(),
1701            state.direct_encoder().unwrap().plan(),
1702        )
1703        .unwrap();
1704    }
1705
1706    #[test]
1707    fn direct_bulk_target_type_validation_accepts_datetime_metadata() {
1708        let mappings = vec![SchemaMapping::new(
1709            ArrowFieldRef::new(
1710                0,
1711                "created_at".to_owned(),
1712                false,
1713                DataType::Timestamp(TimeUnit::Microsecond, None),
1714            ),
1715            MssqlColumn::new(
1716                Identifier::new("created_at").unwrap(),
1717                MssqlType::DateTime,
1718                false,
1719            ),
1720        )];
1721        let state = WriterState::new(
1722            WriteBackend::DirectRawBulk,
1723            SchemaCheck::Strict,
1724            PlanOptions::default(),
1725            mappings,
1726        )
1727        .unwrap();
1728        for column_type in [
1729            tiberius::ColumnType::Datetime,
1730            tiberius::ColumnType::Datetimen,
1731        ] {
1732            let columns = vec![bulk_target_column_with_type(
1733                0,
1734                "created_at",
1735                false,
1736                column_type,
1737            )];
1738
1739            validate_direct_bulk_target_column_types(
1740                columns.into_iter(),
1741                state.direct_encoder().unwrap().plan(),
1742            )
1743            .unwrap();
1744        }
1745    }
1746
1747    #[test]
1748    fn direct_bulk_target_type_validation_rejects_variable_width_type_swap() {
1749        let mappings = vec![utf8_mapping_at(0, "name"), binary_mapping_at(1, "payload")];
1750        let state = WriterState::new(
1751            WriteBackend::DirectRawBulk,
1752            SchemaCheck::Strict,
1753            PlanOptions::default(),
1754            mappings,
1755        )
1756        .unwrap();
1757        let columns = vec![
1758            bulk_target_column_with_type(0, "name", false, tiberius::ColumnType::BigVarBin),
1759            bulk_target_column_with_type(1, "payload", false, tiberius::ColumnType::NVarchar),
1760        ];
1761
1762        let err = validate_direct_bulk_target_column_types(
1763            columns.into_iter(),
1764            state.direct_encoder().unwrap().plan(),
1765        )
1766        .unwrap_err();
1767
1768        let Error::ValueConversion { diagnostics } = err else {
1769            panic!("expected value conversion error");
1770        };
1771        assert_eq!(diagnostics.len(), 2);
1772        assert!(
1773            diagnostics
1774                .all()
1775                .iter()
1776                .any(|diagnostic| diagnostic.message().contains("NVarchar"))
1777        );
1778        assert!(
1779            diagnostics
1780                .all()
1781                .iter()
1782                .any(|diagnostic| diagnostic.message().contains("BigVarBin"))
1783        );
1784    }
1785
1786    #[test]
1787    fn direct_bulk_target_type_validation_rejects_same_name_with_wrong_type() {
1788        let mappings = vec![mapping("id")];
1789        let state = WriterState::new(
1790            WriteBackend::DirectRawBulk,
1791            SchemaCheck::Strict,
1792            PlanOptions::default(),
1793            mappings,
1794        )
1795        .unwrap();
1796        let columns = vec![bulk_target_column_with_type(
1797            0,
1798            "id",
1799            false,
1800            tiberius::ColumnType::Int8,
1801        )];
1802
1803        let err = validate_direct_bulk_target_column_types(
1804            columns.into_iter(),
1805            state.direct_encoder().unwrap().plan(),
1806        )
1807        .unwrap_err();
1808
1809        let Error::ValueConversion { diagnostics } = err else {
1810            panic!("expected value conversion error");
1811        };
1812        assert_eq!(diagnostics.len(), 1);
1813        let diagnostic = &diagnostics.all()[0];
1814        assert_eq!(diagnostic.code(), DiagnosticCode::SchemaMismatch);
1815        assert_eq!(diagnostic.field().map(|field| field.name()), Some("id"));
1816        assert!(
1817            diagnostic
1818                .message()
1819                .contains("bulk target column type Int8 does not match direct encoder type Int4")
1820        );
1821    }
1822
1823    #[test]
1824    fn write_batch_to_sink_accepts_empty_matching_batch() {
1825        let mappings = vec![mapping("id")];
1826        let mut state = WriterState::new(
1827            WriteBackend::BaselineTokenRow,
1828            SchemaCheck::Strict,
1829            PlanOptions::default(),
1830            mappings,
1831        )
1832        .unwrap();
1833        let mut sink = RecordingSink::default();
1834        let batch = int32_batch("id", &[]);
1835
1836        let stats = poll_ready(write_batch_to_sink(&mut state, &mut sink, &batch)).unwrap();
1837
1838        assert_eq!(
1839            stats,
1840            WriteStats {
1841                rows_written: 0,
1842                batches_written: 1
1843            }
1844        );
1845        assert!(sink.rows.is_empty());
1846    }
1847
1848    #[test]
1849    fn write_batch_to_sink_accumulates_multi_batch_stats() {
1850        let mappings = vec![mapping("id")];
1851        let mut state = WriterState::new(
1852            WriteBackend::BaselineTokenRow,
1853            SchemaCheck::Strict,
1854            PlanOptions::default(),
1855            mappings,
1856        )
1857        .unwrap();
1858        let mut sink = RecordingSink::default();
1859
1860        let first = poll_ready(write_batch_to_sink(
1861            &mut state,
1862            &mut sink,
1863            &int32_batch("id", &[10, 20]),
1864        ))
1865        .unwrap();
1866        let second = poll_ready(write_batch_to_sink(
1867            &mut state,
1868            &mut sink,
1869            &int32_batch("id", &[30]),
1870        ))
1871        .unwrap();
1872
1873        assert_eq!(
1874            first,
1875            WriteStats {
1876                rows_written: 2,
1877                batches_written: 1
1878            }
1879        );
1880        assert_eq!(
1881            second,
1882            WriteStats {
1883                rows_written: 3,
1884                batches_written: 2
1885            }
1886        );
1887        assert_eq!(sink.rows.len(), 3);
1888        assert_eq!(
1889            sink.rows[2].get(0),
1890            Some(&tiberius::ColumnData::I32(Some(30)))
1891        );
1892    }
1893
1894    #[test]
1895    fn write_batch_to_sink_sends_timestamp_datetime_cells() {
1896        let mappings = vec![SchemaMapping::new(
1897            ArrowFieldRef::new(
1898                0,
1899                "created_at".to_owned(),
1900                true,
1901                DataType::Timestamp(TimeUnit::Microsecond, None),
1902            ),
1903            MssqlColumn::new(
1904                Identifier::new("created_at").unwrap(),
1905                MssqlType::DateTime,
1906                true,
1907            ),
1908        )];
1909        let options = PlanOptions {
1910            timestamp_policy: TimestampPolicy::DateTime,
1911            ..PlanOptions::default()
1912        };
1913        let mut state = WriterState::new(
1914            WriteBackend::BaselineTokenRow,
1915            SchemaCheck::Strict,
1916            options,
1917            mappings,
1918        )
1919        .unwrap();
1920        let mut sink = RecordingSink::default();
1921        let batch =
1922            timestamp_microsecond_batch("created_at", &[Some(1_700), Some(86_399_999_000), None]);
1923
1924        let stats = poll_ready(write_batch_to_sink(&mut state, &mut sink, &batch)).unwrap();
1925
1926        assert_eq!(
1927            stats,
1928            WriteStats {
1929                rows_written: 3,
1930                batches_written: 1
1931            }
1932        );
1933        assert_eq!(sink.rows.len(), 3);
1934        assert_eq!(
1935            sink.rows[0].get(0),
1936            Some(&tiberius::ColumnData::DateTime(Some(
1937                tiberius::time::DateTime::new(25_567, 1)
1938            )))
1939        );
1940        assert_eq!(
1941            sink.rows[1].get(0),
1942            Some(&tiberius::ColumnData::DateTime(Some(
1943                tiberius::time::DateTime::new(25_568, 0)
1944            )))
1945        );
1946        assert_eq!(
1947            sink.rows[2].get(0),
1948            Some(&tiberius::ColumnData::DateTime(None))
1949        );
1950    }
1951
1952    #[test]
1953    fn write_batch_to_sink_conversion_failure_sends_nothing_and_keeps_stats() {
1954        let mappings = vec![float_mapping("amount")];
1955        let mut state = WriterState::new(
1956            WriteBackend::BaselineTokenRow,
1957            SchemaCheck::Strict,
1958            PlanOptions::default(),
1959            mappings,
1960        )
1961        .unwrap();
1962        let mut sink = RecordingSink::default();
1963        let batch = float64_batch("amount", &[Some(1.0), Some(f64::NAN)]);
1964
1965        let err = poll_ready(write_batch_to_sink(&mut state, &mut sink, &batch)).unwrap_err();
1966
1967        assert_write_phase(&err, WritePhase::ValueConversion);
1968        let Error::ValueConversion { diagnostics } = inner_error(&err) else {
1969            panic!("expected value conversion error");
1970        };
1971        assert_eq!(diagnostics.all()[0].code(), DiagnosticCode::NonFiniteFloat);
1972        assert_eq!(diagnostics.all()[0].row(), Some(1));
1973        assert!(sink.rows.is_empty());
1974        assert_eq!(state.stats(), WriteStats::default());
1975    }
1976
1977    #[test]
1978    fn write_batch_to_sink_send_failure_preserves_error_and_keeps_stats() {
1979        let mappings = vec![mapping("id")];
1980        let mut state = WriterState::new(
1981            WriteBackend::BaselineTokenRow,
1982            SchemaCheck::Strict,
1983            PlanOptions::default(),
1984            mappings,
1985        )
1986        .unwrap();
1987        let mut sink = RecordingSink {
1988            fail_on_send: Some(1),
1989            rows: Vec::new(),
1990        };
1991        let batch = int32_batch("id", &[1, 2, 3]);
1992
1993        let err = poll_ready(write_batch_to_sink(&mut state, &mut sink, &batch)).unwrap_err();
1994
1995        assert_write_phase(&err, WritePhase::PacketWrite);
1996        let Error::Tiberius { source } = inner_error(&err) else {
1997            panic!("expected tiberius error");
1998        };
1999        assert_eq!(
2000            source.to_string(),
2001            "BULK UPLOAD input failure: fake send failure"
2002        );
2003        assert_eq!(sink.rows.len(), 1);
2004        assert_eq!(state.stats(), WriteStats::default());
2005    }
2006
2007    #[test]
2008    fn write_direct_batch_to_sink_sends_one_checked_payload_per_batch() {
2009        let _trace_guard = direct_raw_trace_test_guard();
2010        let mappings = vec![mapping("id")];
2011        let mut state = WriterState::new(
2012            WriteBackend::DirectRawBulk,
2013            SchemaCheck::Strict,
2014            PlanOptions::default(),
2015            mappings,
2016        )
2017        .unwrap();
2018        let mut sink = RecordingRawSink::default();
2019        let batch = int32_batch("id", &[10, 20]);
2020
2021        let stats = poll_ready(write_direct_batch_to_sink(&mut state, &mut sink, &batch)).unwrap();
2022
2023        assert_eq!(
2024            stats,
2025            WriteStats {
2026                rows_written: 2,
2027                batches_written: 1
2028            }
2029        );
2030        assert_eq!(sink.payloads.len(), 1);
2031        assert_eq!(sink.payloads[0].row_token_offsets, vec![0, 5]);
2032        assert_eq!(
2033            sink.payloads[0].bytes,
2034            vec![0xD1, 10, 0, 0, 0, 0xD1, 20, 0, 0, 0]
2035        );
2036    }
2037
2038    #[test]
2039    fn write_direct_batch_to_sink_accumulates_multi_batch_stats() {
2040        let _trace_guard = direct_raw_trace_test_guard();
2041        let mappings = vec![mapping("id")];
2042        let mut state = WriterState::new(
2043            WriteBackend::DirectRawBulk,
2044            SchemaCheck::Strict,
2045            PlanOptions::default(),
2046            mappings,
2047        )
2048        .unwrap();
2049        let mut sink = RecordingRawSink::default();
2050
2051        let first = poll_ready(write_direct_batch_to_sink(
2052            &mut state,
2053            &mut sink,
2054            &int32_batch("id", &[10, 20]),
2055        ))
2056        .unwrap();
2057        let second = poll_ready(write_direct_batch_to_sink(
2058            &mut state,
2059            &mut sink,
2060            &int32_batch("id", &[30]),
2061        ))
2062        .unwrap();
2063
2064        assert_eq!(
2065            first,
2066            WriteStats {
2067                rows_written: 2,
2068                batches_written: 1
2069            }
2070        );
2071        assert_eq!(
2072            second,
2073            WriteStats {
2074                rows_written: 3,
2075                batches_written: 2
2076            }
2077        );
2078        assert_eq!(sink.payloads.len(), 2);
2079        assert_eq!(sink.payloads[1].bytes, vec![0xD1, 30, 0, 0, 0]);
2080    }
2081
2082    #[test]
2083    fn write_direct_batch_to_sink_chunks_measured_payloads_by_byte_limit() {
2084        let _trace_guard = direct_raw_trace_test_guard();
2085        let mappings = vec![binary_mapping_at(0, "payload")];
2086        let mut state = WriterState::new(
2087            WriteBackend::DirectRawBulk,
2088            SchemaCheck::Strict,
2089            PlanOptions::default(),
2090            mappings,
2091        )
2092        .unwrap();
2093        let mut sink = RecordingRawSink::default();
2094        let row_bytes = vec![0x5a; DIRECT_RAW_MAX_PAYLOAD_BYTES / 2 + 1];
2095        let batch = binary_batch("payload", &[row_bytes.as_slice(), row_bytes.as_slice()]);
2096
2097        let stats = poll_ready(write_direct_batch_to_sink(&mut state, &mut sink, &batch)).unwrap();
2098
2099        assert_eq!(
2100            stats,
2101            WriteStats {
2102                rows_written: 2,
2103                batches_written: 1
2104            }
2105        );
2106        assert_eq!(sink.payloads.len(), 2);
2107        assert_eq!(sink.payloads[0].row_token_offsets, [0]);
2108        assert_eq!(sink.payloads[1].row_token_offsets, [0]);
2109    }
2110
2111    #[test]
2112    fn write_direct_batch_to_sink_skips_send_for_empty_batch_but_records_stats() {
2113        let _trace_guard = direct_raw_trace_test_guard();
2114        let mappings = vec![mapping("id")];
2115        let mut state = WriterState::new(
2116            WriteBackend::DirectRawBulk,
2117            SchemaCheck::Strict,
2118            PlanOptions::default(),
2119            mappings,
2120        )
2121        .unwrap();
2122        let mut sink = RecordingRawSink::default();
2123        let batch = int32_batch("id", &[]);
2124
2125        let stats = poll_ready(write_direct_batch_to_sink(&mut state, &mut sink, &batch)).unwrap();
2126
2127        assert_eq!(
2128            stats,
2129            WriteStats {
2130                rows_written: 0,
2131                batches_written: 1
2132            }
2133        );
2134        assert!(sink.payloads.is_empty());
2135    }
2136
2137    #[test]
2138    fn write_direct_batch_to_sink_rejects_bad_later_row_before_send() {
2139        let _trace_guard = direct_raw_trace_test_guard();
2140        let mappings = vec![float_mapping("amount")];
2141        let mut state = WriterState::new(
2142            WriteBackend::DirectRawBulk,
2143            SchemaCheck::Strict,
2144            PlanOptions::default(),
2145            mappings,
2146        )
2147        .unwrap();
2148        let mut sink = RecordingRawSink::default();
2149        let batch = float64_batch("amount", &[Some(1.0), Some(f64::NAN)]);
2150
2151        let err =
2152            poll_ready(write_direct_batch_to_sink(&mut state, &mut sink, &batch)).unwrap_err();
2153
2154        assert_write_phase(&err, WritePhase::ValueConversion);
2155        let Error::ValueConversion { diagnostics } = inner_error(&err) else {
2156            panic!("expected value conversion error");
2157        };
2158        assert_eq!(diagnostics.all()[0].code(), DiagnosticCode::NonFiniteFloat);
2159        assert_eq!(diagnostics.all()[0].row(), Some(1));
2160        assert!(sink.payloads.is_empty());
2161        assert_eq!(state.stats(), WriteStats::default());
2162    }
2163
2164    #[test]
2165    fn write_direct_batch_to_sink_rejects_uint64_bigint_overflow_before_any_range_send() {
2166        let _trace_guard = direct_raw_trace_test_guard();
2167        let mappings = vec![schema_mapping_at(
2168            0,
2169            "u64_value",
2170            DataType::UInt64,
2171            MssqlType::BigInt,
2172            false,
2173        )];
2174        let mut state = WriterState::new(
2175            WriteBackend::DirectRawBulk,
2176            SchemaCheck::Strict,
2177            PlanOptions::default(),
2178            mappings,
2179        )
2180        .unwrap();
2181        let mut sink = RecordingRawSink::default();
2182        let row_count = DIRECT_RAW_MAX_PAYLOAD_BYTES / 9 + 2;
2183        let mut values = vec![1_u64; row_count];
2184        values[row_count - 1] = i64::MAX as u64 + 1;
2185        let batch = uint64_batch("u64_value", &values);
2186
2187        let err =
2188            poll_ready(write_direct_batch_to_sink(&mut state, &mut sink, &batch)).unwrap_err();
2189
2190        assert_write_phase(&err, WritePhase::ValueConversion);
2191        let Error::ValueConversion { diagnostics } = inner_error(&err) else {
2192            panic!("expected value conversion error");
2193        };
2194        assert_eq!(
2195            diagnostics.all()[0].code(),
2196            DiagnosticCode::IntegerOutOfRange
2197        );
2198        assert_eq!(diagnostics.all()[0].row(), Some(row_count - 1));
2199        assert!(sink.payloads.is_empty());
2200        assert_eq!(state.stats(), WriteStats::default());
2201    }
2202
2203    #[test]
2204    fn write_direct_batch_to_sink_rejects_runtime_type_mismatch_before_send() {
2205        let _trace_guard = direct_raw_trace_test_guard();
2206        let mappings = vec![mapping("id")];
2207        let mut state = WriterState::new(
2208            WriteBackend::DirectRawBulk,
2209            SchemaCheck::Strict,
2210            PlanOptions::default(),
2211            mappings,
2212        )
2213        .unwrap();
2214        let mut sink = RecordingRawSink::default();
2215        let batch = RecordBatch::try_new(
2216            Arc::new(Schema::new(vec![Field::new(
2217                "id",
2218                DataType::Float64,
2219                false,
2220            )])),
2221            vec![Arc::new(Float64Array::from(vec![1.0]))],
2222        )
2223        .unwrap();
2224
2225        let err =
2226            poll_ready(write_direct_batch_to_sink(&mut state, &mut sink, &batch)).unwrap_err();
2227
2228        assert_write_phase(&err, WritePhase::BatchSchemaValidation);
2229        let Error::ValueConversion { diagnostics } = inner_error(&err) else {
2230            panic!("expected value conversion error");
2231        };
2232        assert_eq!(diagnostics.all()[0].code(), DiagnosticCode::SchemaMismatch);
2233        assert!(
2234            diagnostics.all()[0]
2235                .message()
2236                .contains("runtime Arrow type Float64")
2237        );
2238        assert!(sink.payloads.is_empty());
2239        assert_eq!(state.stats(), WriteStats::default());
2240    }
2241
2242    #[test]
2243    fn write_direct_batch_to_sink_send_failure_preserves_error_and_keeps_stats() {
2244        let _trace_guard = direct_raw_trace_test_guard();
2245        let mappings = vec![mapping("id")];
2246        let mut state = WriterState::new(
2247            WriteBackend::DirectRawBulk,
2248            SchemaCheck::Strict,
2249            PlanOptions::default(),
2250            mappings,
2251        )
2252        .unwrap();
2253        let mut sink = RecordingRawSink {
2254            fail_on_send: true,
2255            payloads: Vec::new(),
2256        };
2257        let batch = int32_batch("id", &[1, 2, 3]);
2258
2259        let err =
2260            poll_ready(write_direct_batch_to_sink(&mut state, &mut sink, &batch)).unwrap_err();
2261
2262        assert_write_phase(&err, WritePhase::PacketWrite);
2263        let Error::Tiberius { source } = inner_error(&err) else {
2264            panic!("expected tiberius error");
2265        };
2266        assert_eq!(
2267            source.to_string(),
2268            "BULK UPLOAD input failure: fake raw send failure"
2269        );
2270        assert!(sink.payloads.is_empty());
2271        assert_eq!(state.stats(), WriteStats::default());
2272    }
2273
2274    #[test]
2275    fn writer_types_are_exported_from_crate_root() {
2276        assert_eq!(crate::WriteBackend::default(), WriteBackend::Auto);
2277        assert_eq!(crate::WriteOptions::default(), WriteOptions::default());
2278        assert_eq!(crate::WriteStats::default(), WriteStats::default());
2279        assert_eq!(crate::WritePhase::PacketWrite.as_str(), "packet_write");
2280        let _ = std::any::type_name::<crate::BulkWriter<'static, DummyStream>>();
2281    }
2282
2283    #[test]
2284    fn tiberius_alias_exposes_client_type() {
2285        let name = std::any::type_name::<tiberius::Client<DummyStream>>();
2286
2287        assert!(name.contains("tiberius"));
2288    }
2289
2290    fn assert_write_phase(error: &Error, expected: WritePhase) {
2291        assert_eq!(error.write_phase(), Some(expected));
2292    }
2293
2294    fn inner_error(error: &Error) -> &Error {
2295        error.without_write_phase()
2296    }
2297
2298    fn mapping(name: &str) -> SchemaMapping {
2299        SchemaMapping::new(
2300            ArrowFieldRef::new(0, name.to_owned(), false, DataType::Int32),
2301            MssqlColumn::new(Identifier::new(name).unwrap(), MssqlType::Int, false),
2302        )
2303    }
2304
2305    fn schema_mapping_at(
2306        index: usize,
2307        name: &str,
2308        arrow_type: DataType,
2309        mssql_type: MssqlType,
2310        nullable: bool,
2311    ) -> SchemaMapping {
2312        SchemaMapping::new(
2313            ArrowFieldRef::new(index, name.to_owned(), nullable, arrow_type),
2314            MssqlColumn::new(Identifier::new(name).unwrap(), mssql_type, nullable),
2315        )
2316    }
2317
2318    fn float_mapping(name: &str) -> SchemaMapping {
2319        float_mapping_at(0, name)
2320    }
2321
2322    fn float_mapping_at(index: usize, name: &str) -> SchemaMapping {
2323        SchemaMapping::new(
2324            ArrowFieldRef::new(index, name.to_owned(), false, DataType::Float64),
2325            MssqlColumn::new(
2326                Identifier::new(name).unwrap(),
2327                MssqlType::Float { precision: 53 },
2328                false,
2329            ),
2330        )
2331    }
2332
2333    fn utf8_mapping_at(index: usize, name: &str) -> SchemaMapping {
2334        SchemaMapping::new(
2335            ArrowFieldRef::new(index, name.to_owned(), false, DataType::Utf8),
2336            MssqlColumn::new(
2337                Identifier::new(name).unwrap(),
2338                MssqlType::NVarChar(MssqlTypeLength::Max),
2339                false,
2340            ),
2341        )
2342    }
2343
2344    fn binary_mapping_at(index: usize, name: &str) -> SchemaMapping {
2345        SchemaMapping::new(
2346            ArrowFieldRef::new(index, name.to_owned(), false, DataType::Binary),
2347            MssqlColumn::new(
2348                Identifier::new(name).unwrap(),
2349                MssqlType::VarBinary(MssqlTypeLength::Max),
2350                false,
2351            ),
2352        )
2353    }
2354
2355    fn fixed_size_binary_mapping_at(index: usize, name: &str, length: usize) -> SchemaMapping {
2356        SchemaMapping::new(
2357            ArrowFieldRef::new(
2358                index,
2359                name.to_owned(),
2360                false,
2361                DataType::FixedSizeBinary(i32::try_from(length).unwrap()),
2362            ),
2363            MssqlColumn::new(
2364                Identifier::new(name).unwrap(),
2365                MssqlType::Binary(length),
2366                false,
2367            ),
2368        )
2369    }
2370
2371    fn int32_batch(name: &str, values: &[i32]) -> RecordBatch {
2372        let schema = Arc::new(Schema::new(vec![Field::new(name, DataType::Int32, false)]));
2373        let array = Arc::new(Int32Array::from(values.to_vec()));
2374
2375        RecordBatch::try_new(schema, vec![array]).unwrap()
2376    }
2377
2378    fn uint64_batch(name: &str, values: &[u64]) -> RecordBatch {
2379        let schema = Arc::new(Schema::new(vec![Field::new(name, DataType::UInt64, false)]));
2380        let array = Arc::new(UInt64Array::from(values.to_vec()));
2381
2382        RecordBatch::try_new(schema, vec![array]).unwrap()
2383    }
2384
2385    fn binary_batch(name: &str, values: &[&[u8]]) -> RecordBatch {
2386        let schema = Arc::new(Schema::new(vec![Field::new(name, DataType::Binary, false)]));
2387        let array = Arc::new(BinaryArray::from_iter_values(values.iter().copied()));
2388
2389        RecordBatch::try_new(schema, vec![array]).unwrap()
2390    }
2391
2392    fn timestamp_microsecond_batch(name: &str, values: &[Option<i64>]) -> RecordBatch {
2393        let schema = Arc::new(Schema::new(vec![Field::new(
2394            name,
2395            DataType::Timestamp(TimeUnit::Microsecond, None),
2396            true,
2397        )]));
2398        let array = Arc::new(TimestampMicrosecondArray::from(values.to_vec()));
2399
2400        RecordBatch::try_new(schema, vec![array]).unwrap()
2401    }
2402
2403    fn bulk_target_column(ordinal: usize, name: &str, nullable: bool) -> FakeBulkTargetColumn {
2404        bulk_target_column_with_type(ordinal, name, nullable, tiberius::ColumnType::Int4)
2405    }
2406
2407    fn bulk_target_column_with_type(
2408        ordinal: usize,
2409        name: &str,
2410        nullable: bool,
2411        column_type: tiberius::ColumnType,
2412    ) -> FakeBulkTargetColumn {
2413        FakeBulkTargetColumn {
2414            ordinal,
2415            name: name.to_owned(),
2416            nullable,
2417            column_type,
2418            decimal_precision_scale: None,
2419        }
2420    }
2421
2422    fn bulk_target_decimal_column(
2423        ordinal: usize,
2424        name: &str,
2425        nullable: bool,
2426        precision: u8,
2427        scale: u8,
2428    ) -> FakeBulkTargetColumn {
2429        FakeBulkTargetColumn {
2430            ordinal,
2431            name: name.to_owned(),
2432            nullable,
2433            column_type: tiberius::ColumnType::Decimaln,
2434            decimal_precision_scale: Some((precision, scale)),
2435        }
2436    }
2437
2438    fn float64_batch(name: &str, values: &[Option<f64>]) -> RecordBatch {
2439        let schema = Arc::new(Schema::new(vec![Field::new(
2440            name,
2441            DataType::Float64,
2442            false,
2443        )]));
2444        let array = Arc::new(Float64Array::from(values.to_vec()));
2445
2446        RecordBatch::try_new(schema, vec![array]).unwrap()
2447    }
2448
2449    fn poll_ready<F>(future: F) -> F::Output
2450    where
2451        F: Future,
2452    {
2453        let mut context = Context::from_waker(Waker::noop());
2454        let mut future = Box::pin(future);
2455
2456        match future.as_mut().poll(&mut context) {
2457            Poll::Ready(output) => output,
2458            Poll::Pending => panic!("future unexpectedly returned pending"),
2459        }
2460    }
2461
2462    #[derive(Debug, Default)]
2463    struct RecordingSink {
2464        fail_on_send: Option<usize>,
2465        rows: Vec<tiberius::TokenRow<'static>>,
2466    }
2467
2468    #[derive(Debug, Default)]
2469    struct RecordingRawSink {
2470        fail_on_send: bool,
2471        payloads: Vec<RecordedRawPayload>,
2472    }
2473
2474    #[derive(Debug, PartialEq, Eq)]
2475    struct RecordedRawPayload {
2476        bytes: Vec<u8>,
2477        row_token_offsets: Vec<usize>,
2478    }
2479
2480    impl RawRowsSink for RecordingRawSink {
2481        async fn send_measured_raw_rows(
2482            &mut self,
2483            encoder: &DirectEncoder,
2484            batch: &RecordBatch,
2485            measured: &MeasuredDirectBatch,
2486            range: MeasuredRowRange,
2487            direct_observer: DirectRawBatchObserver,
2488        ) -> crate::Result<()> {
2489            let payload =
2490                encoder.encode_measured_batch_range(batch, measured, range.start, range.len)?;
2491
2492            if self.fail_on_send {
2493                return Err(Error::Tiberius {
2494                    source: tiberius::error::Error::BulkInput(Cow::Borrowed(
2495                        "fake raw send failure",
2496                    )),
2497                });
2498            }
2499
2500            self.payloads.push(RecordedRawPayload {
2501                bytes: payload.bytes().to_vec(),
2502                row_token_offsets: payload.row_token_offsets().to_vec(),
2503            });
2504            direct_observer.record_packet_write_completed(
2505                range,
2506                payload.row_count(),
2507                payload.bytes().len(),
2508                std::time::Duration::ZERO,
2509            );
2510            Ok(())
2511        }
2512    }
2513
2514    impl TokenRowSink for RecordingSink {
2515        async fn send_token_row(&mut self, row: tiberius::TokenRow<'static>) -> crate::Result<()> {
2516            if self.fail_on_send == Some(self.rows.len()) {
2517                return Err(Error::Tiberius {
2518                    source: tiberius::error::Error::BulkInput(Cow::Borrowed("fake send failure")),
2519                });
2520            }
2521
2522            self.rows.push(row);
2523            Ok(())
2524        }
2525    }
2526
2527    #[derive(Debug)]
2528    struct FakeBulkTargetColumn {
2529        ordinal: usize,
2530        name: String,
2531        nullable: bool,
2532        column_type: tiberius::ColumnType,
2533        decimal_precision_scale: Option<(u8, u8)>,
2534    }
2535
2536    impl BulkTargetColumnMetadata for FakeBulkTargetColumn {
2537        fn ordinal(&self) -> usize {
2538            self.ordinal
2539        }
2540
2541        fn name(&self) -> &str {
2542            &self.name
2543        }
2544
2545        fn is_nullable(&self) -> bool {
2546            self.nullable
2547        }
2548
2549        fn column_type(&self) -> tiberius::ColumnType {
2550            self.column_type
2551        }
2552
2553        fn decimal_precision_scale(&self) -> Option<(u8, u8)> {
2554            self.decimal_precision_scale
2555        }
2556    }
2557
2558    #[derive(Debug)]
2559    struct DummyStream;
2560
2561    impl AsyncRead for DummyStream {
2562        fn poll_read(
2563            self: Pin<&mut Self>,
2564            _cx: &mut Context<'_>,
2565            _buf: &mut [u8],
2566        ) -> Poll<std::io::Result<usize>> {
2567            Poll::Ready(Ok(0))
2568        }
2569    }
2570
2571    impl AsyncWrite for DummyStream {
2572        fn poll_write(
2573            self: Pin<&mut Self>,
2574            _cx: &mut Context<'_>,
2575            buf: &[u8],
2576        ) -> Poll<std::io::Result<usize>> {
2577            Poll::Ready(Ok(buf.len()))
2578        }
2579
2580        fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
2581            Poll::Ready(Ok(()))
2582        }
2583
2584        fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
2585            Poll::Ready(Ok(()))
2586        }
2587    }
2588}