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