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, 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
37pub enum WriteBackend {
38 #[default]
40 Auto,
41 BaselineTokenRow,
43 DirectFramedBulk,
45 DirectRawBulk,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
51pub struct WriteOptions {
52 pub backend: WriteBackend,
54 pub schema_check: SchemaCheck,
56}
57
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
60pub struct WriteStats {
61 pub rows_written: u64,
63 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#[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 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 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 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
692async 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
729async 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#[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
773async 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
794async 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}