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