1use std::fmt::{self, Write as _};
4
5use snafu::Snafu;
6
7use crate::{DiagnosticCode, DiagnosticSet, MssqlVersion, WriteBackend};
8
9pub type Result<T> = std::result::Result<T, Error>;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18#[non_exhaustive]
19pub enum WritePhase {
20 SchemaPlanning,
22 WriterInitialization,
24 TargetMetadataValidation,
26 BatchWrite,
28 BatchSchemaValidation,
30 ValueConversion,
32 DirectEncoding,
34 PacketWrite,
36 Finish,
38 Finalize,
40}
41
42impl WritePhase {
43 pub const fn as_str(self) -> &'static str {
45 match self {
46 Self::SchemaPlanning => "schema_planning",
47 Self::WriterInitialization => "writer_initialization",
48 Self::TargetMetadataValidation => "target_metadata_validation",
49 Self::BatchWrite => "batch_write",
50 Self::BatchSchemaValidation => "batch_schema_validation",
51 Self::ValueConversion => "value_conversion",
52 Self::DirectEncoding => "direct_encoding",
53 Self::PacketWrite => "packet_write",
54 Self::Finish => "finish",
55 Self::Finalize => "finalize",
56 }
57 }
58}
59
60impl fmt::Display for WritePhase {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 f.write_str(self.as_str())
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct ErrorInfo<'a> {
73 phase: Option<WritePhase>,
74 kind: &'static str,
75 summary: &'static str,
76 diagnostics: Option<&'a DiagnosticSet>,
77}
78
79impl<'a> ErrorInfo<'a> {
80 pub const fn phase(&self) -> Option<WritePhase> {
82 self.phase
83 }
84
85 pub const fn kind(&self) -> &'static str {
87 self.kind
88 }
89
90 pub const fn summary(&self) -> &'static str {
92 self.summary
93 }
94
95 pub const fn diagnostics(&self) -> Option<&'a DiagnosticSet> {
97 self.diagnostics
98 }
99
100 pub fn diagnostic_codes(&self) -> String {
102 if let Some(diagnostics) = self.diagnostics {
103 diagnostic_codes(diagnostics)
104 } else if self.kind == "BackendUnavailable" {
105 format!("{:?}", DiagnosticCode::BackendUnavailable)
106 } else {
107 String::new()
108 }
109 }
110}
111
112#[derive(Debug, Snafu)]
114#[non_exhaustive]
115pub enum Error {
116 #[snafu(display("write phase {phase} failed"))]
118 WritePhaseContext {
119 phase: WritePhase,
121 source: Box<Error>,
123 },
124
125 #[snafu(display("invalid compatibility level {level}"))]
127 InvalidCompatibilityLevel {
128 level: u16,
130 },
131
132 #[snafu(display(
135 "{version:?} does not support database compatibility level {compatibility_level}"
136 ))]
137 UnsupportedCompatibilityLevel {
138 version: MssqlVersion,
140 compatibility_level: u16,
142 },
143
144 #[snafu(display("invalid identifier: {reason}"))]
146 InvalidIdentifier {
147 reason: String,
149 },
150
151 #[snafu(display("write planning failed with {} diagnostic(s)", diagnostics.len()))]
153 Planning {
154 diagnostics: DiagnosticSet,
156 },
157
158 #[snafu(display(
160 "value conversion failed with {} diagnostic(s)",
161 diagnostics.len()
162 ))]
163 ValueConversion {
164 diagnostics: DiagnosticSet,
166 },
167
168 #[snafu(display(
170 "direct encoding failed with {} diagnostic(s)",
171 diagnostics.len()
172 ))]
173 DirectEncoding {
174 diagnostics: DiagnosticSet,
176 },
177
178 #[snafu(display("write backend {backend:?} is unavailable: {reason}"))]
180 BackendUnavailable {
181 backend: WriteBackend,
183 reason: String,
185 },
186
187 #[snafu(display("SQL Server connection string is invalid"))]
189 InvalidConnectionString,
190
191 #[snafu(display("TCP connection to SQL Server failed: {source}"))]
193 ConnectionTcpConnect {
194 source: std::io::Error,
196 },
197
198 #[snafu(display("SQL Server client setup failed: {source}"))]
200 ConnectionClientSetup {
201 source: tiberius::error::Error,
203 },
204
205 #[snafu(display("SQL Server table existence query failed: {source}"))]
207 TableExistsQuery {
208 source: tiberius::error::Error,
210 },
211
212 #[snafu(display("SQL Server table existence query returned an unexpected result: {reason}"))]
214 TableExistsUnexpectedResult {
215 reason: String,
217 },
218
219 #[snafu(display("SQL Server target row count query failed: {source}"))]
221 TargetRowCountQuery {
222 source: tiberius::error::Error,
224 },
225
226 #[snafu(display("SQL Server target row count query returned an unexpected result: {reason}"))]
228 TargetRowCountUnexpectedResult {
229 reason: String,
231 },
232
233 #[snafu(display("SQL Server statement execution failed: {source}"))]
235 SqlExecution {
236 source: tiberius::error::Error,
238 },
239
240 #[snafu(display("tiberius operation failed: {source}"))]
242 Tiberius {
243 source: tiberius::error::Error,
245 },
246}
247
248impl Error {
249 #[must_use]
251 pub fn with_write_phase(self, phase: WritePhase) -> Self {
252 Self::WritePhaseContext {
253 phase,
254 source: Box::new(self),
255 }
256 }
257
258 pub const fn write_phase(&self) -> Option<WritePhase> {
260 match self {
261 Self::WritePhaseContext { phase, .. } => Some(*phase),
262 Self::Planning { .. } => Some(WritePhase::SchemaPlanning),
263 Self::ValueConversion { .. } => Some(WritePhase::ValueConversion),
264 Self::DirectEncoding { .. } => Some(WritePhase::DirectEncoding),
265 _ => None,
266 }
267 }
268
269 pub fn without_write_phase(&self) -> &Self {
271 match self {
272 Self::WritePhaseContext { source, .. } => source.without_write_phase(),
273 _ => self,
274 }
275 }
276
277 pub fn safe_error_info(&self) -> ErrorInfo<'_> {
279 let inner = self.without_write_phase();
280 ErrorInfo {
281 phase: self.write_phase(),
282 kind: inner.kind(),
283 summary: inner.safe_summary(),
284 diagnostics: inner.diagnostics(),
285 }
286 }
287
288 fn kind(&self) -> &'static str {
289 match self {
290 Self::WritePhaseContext { source, .. } => source.kind(),
291 Self::InvalidCompatibilityLevel { .. } => "InvalidCompatibilityLevel",
292 Self::UnsupportedCompatibilityLevel { .. } => "UnsupportedCompatibilityLevel",
293 Self::InvalidIdentifier { .. } => "InvalidIdentifier",
294 Self::Planning { .. } => "Planning",
295 Self::ValueConversion { .. } => "ValueConversion",
296 Self::DirectEncoding { .. } => "DirectEncoding",
297 Self::BackendUnavailable { .. } => "BackendUnavailable",
298 Self::InvalidConnectionString => "InvalidConnectionString",
299 Self::ConnectionTcpConnect { .. } => "ConnectionTcpConnect",
300 Self::ConnectionClientSetup { .. } => "ConnectionClientSetup",
301 Self::TableExistsQuery { .. } => "TableExistsQuery",
302 Self::TableExistsUnexpectedResult { .. } => "TableExistsUnexpectedResult",
303 Self::TargetRowCountQuery { .. } => "TargetRowCountQuery",
304 Self::TargetRowCountUnexpectedResult { .. } => "TargetRowCountUnexpectedResult",
305 Self::SqlExecution { .. } => "SqlExecution",
306 Self::Tiberius { .. } => "Tiberius",
307 }
308 }
309
310 fn safe_summary(&self) -> &'static str {
311 match self {
312 Self::WritePhaseContext { source, .. } => source.safe_summary(),
313 Self::InvalidCompatibilityLevel { .. } => "invalid compatibility level",
314 Self::UnsupportedCompatibilityLevel { .. } => {
315 "compatibility level is not supported by SQL Server version"
316 }
317 Self::InvalidIdentifier { .. } => "invalid identifier",
318 Self::Planning { .. } => "planning failed with diagnostics",
319 Self::ValueConversion { .. } => "value conversion failed with diagnostics",
320 Self::DirectEncoding { .. } => "direct encoding failed with diagnostics",
321 Self::BackendUnavailable { .. } => "write backend unavailable",
322 Self::InvalidConnectionString => "invalid connection string",
323 Self::ConnectionTcpConnect { .. } => "TCP connection failed",
324 Self::ConnectionClientSetup { .. } => "SQL Server client setup failed",
325 Self::TableExistsQuery { .. } => "table existence query failed",
326 Self::TableExistsUnexpectedResult { .. } => {
327 "table existence query returned unexpected result"
328 }
329 Self::TargetRowCountQuery { .. } => "target row count query failed",
330 Self::TargetRowCountUnexpectedResult { .. } => {
331 "target row count query returned unexpected result"
332 }
333 Self::SqlExecution { .. } => "SQL statement execution failed",
334 Self::Tiberius { .. } => "tiberius operation failed",
335 }
336 }
337
338 fn diagnostics(&self) -> Option<&DiagnosticSet> {
339 match self {
340 Self::WritePhaseContext { source, .. } => source.diagnostics(),
341 Self::Planning { diagnostics }
342 | Self::ValueConversion { diagnostics }
343 | Self::DirectEncoding { diagnostics } => Some(diagnostics),
344 _ => None,
345 }
346 }
347}
348
349fn diagnostic_codes(diagnostics: &DiagnosticSet) -> String {
350 let mut codes = String::new();
351 for diagnostic in diagnostics.all() {
352 if !codes.is_empty() {
353 codes.push(',');
354 }
355 let _ = write!(codes, "{:?}", diagnostic.code());
356 }
357 codes
358}
359
360#[cfg(test)]
361mod tests {
362 use std::{borrow::Cow, error::Error as StdError};
363
364 use crate::{
365 Diagnostic, DiagnosticCode, DiagnosticSet, Error, WritePhase,
366 observability::{
367 BATCH_SCHEMA_VALIDATION_PHASE, BATCH_WRITE_PHASE, DIRECT_ENCODING_PHASE,
368 FINALIZE_PHASE, FINISH_PHASE, PACKET_WRITE_PHASE, SCHEMA_PLANNING_PHASE,
369 TARGET_METADATA_VALIDATION_PHASE, VALUE_CONVERSION_PHASE, WRITER_INITIALIZATION_PHASE,
370 },
371 };
372
373 #[test]
374 fn planning_error_display_includes_diagnostic_count() {
375 let diagnostics = DiagnosticSet::from(vec![Diagnostic::error(
376 DiagnosticCode::UnsupportedArrowType,
377 "unsupported",
378 )]);
379 let err = Error::Planning { diagnostics };
380
381 assert_eq!(
382 err.to_string(),
383 "write planning failed with 1 diagnostic(s)"
384 );
385 }
386
387 #[test]
388 fn value_conversion_error_display_includes_diagnostic_count() {
389 let diagnostics = DiagnosticSet::from(vec![Diagnostic::error(
390 DiagnosticCode::ValueConversionUnsupported,
391 "unsupported conversion",
392 )]);
393 let err = Error::ValueConversion { diagnostics };
394
395 assert_eq!(
396 err.to_string(),
397 "value conversion failed with 1 diagnostic(s)"
398 );
399 }
400
401 #[test]
402 fn direct_encoding_error_display_includes_diagnostic_count() {
403 let diagnostics = DiagnosticSet::from(vec![Diagnostic::error(
404 DiagnosticCode::DirectEncodingInvalidPayload,
405 "invalid payload",
406 )]);
407 let err = Error::DirectEncoding { diagnostics };
408
409 assert_eq!(
410 err.to_string(),
411 "direct encoding failed with 1 diagnostic(s)"
412 );
413 }
414
415 #[test]
416 fn write_phase_names_match_tracing_vocabulary() {
417 assert_eq!(WritePhase::SchemaPlanning.as_str(), SCHEMA_PLANNING_PHASE);
418 assert_eq!(
419 WritePhase::WriterInitialization.as_str(),
420 WRITER_INITIALIZATION_PHASE
421 );
422 assert_eq!(
423 WritePhase::TargetMetadataValidation.as_str(),
424 TARGET_METADATA_VALIDATION_PHASE
425 );
426 assert_eq!(WritePhase::BatchWrite.as_str(), BATCH_WRITE_PHASE);
427 assert_eq!(
428 WritePhase::BatchSchemaValidation.as_str(),
429 BATCH_SCHEMA_VALIDATION_PHASE
430 );
431 assert_eq!(WritePhase::ValueConversion.as_str(), VALUE_CONVERSION_PHASE);
432 assert_eq!(WritePhase::DirectEncoding.as_str(), DIRECT_ENCODING_PHASE);
433 assert_eq!(WritePhase::PacketWrite.as_str(), PACKET_WRITE_PHASE);
434 assert_eq!(WritePhase::Finish.as_str(), FINISH_PHASE);
435 assert_eq!(WritePhase::Finalize.as_str(), FINALIZE_PHASE);
436 }
437
438 #[test]
439 fn write_phase_context_classifies_without_display_parsing() {
440 let err = Error::Tiberius {
441 source: tiberius::error::Error::BulkInput(Cow::Borrowed("packet failed")),
442 }
443 .with_write_phase(WritePhase::PacketWrite);
444
445 assert_eq!(err.write_phase(), Some(WritePhase::PacketWrite));
446 assert_eq!(err.to_string(), "write phase packet_write failed");
447 assert!(matches!(err.without_write_phase(), Error::Tiberius { .. }));
448 }
449
450 #[test]
451 fn write_phase_context_display_does_not_include_source_text() {
452 let err = Error::Tiberius {
453 source: tiberius::error::Error::BulkInput(Cow::Borrowed(
454 "server=tcp:sql.example.com;User ID=sa;password=secret",
455 )),
456 }
457 .with_write_phase(WritePhase::Finalize);
458
459 assert_eq!(err.to_string(), "write phase finalize failed");
460 assert!(!err.to_string().contains("password=secret"));
461 assert!(!err.to_string().contains("User ID=sa"));
462 assert!(!err.to_string().contains("sql.example.com"));
463 }
464
465 #[test]
466 fn safe_error_info_exposes_finalize_tiberius_cause_without_source_text() {
467 let err = Error::Tiberius {
468 source: tiberius::error::Error::BulkInput(Cow::Borrowed(
469 "server=tcp:sql.example.com;User ID=sa;password=secret",
470 )),
471 }
472 .with_write_phase(WritePhase::Finalize);
473
474 let info = err.safe_error_info();
475
476 assert_eq!(info.phase(), Some(WritePhase::Finalize));
477 assert_eq!(info.kind(), "Tiberius");
478 assert_eq!(info.summary(), "tiberius operation failed");
479 assert!(info.diagnostics().is_none());
480 assert_eq!(info.diagnostic_codes(), "");
481 assert!(!info.summary().contains("password=secret"));
482 assert!(!info.summary().contains("User ID=sa"));
483 assert!(!info.summary().contains("sql.example.com"));
484 }
485
486 #[test]
487 fn safe_error_info_exposes_nested_diagnostics() {
488 let err = Error::DirectEncoding {
489 diagnostics: DiagnosticSet::from(vec![Diagnostic::error(
490 DiagnosticCode::DirectEncodingInvalidPayload,
491 "invalid payload",
492 )]),
493 }
494 .with_write_phase(WritePhase::WriterInitialization);
495
496 let info = err.safe_error_info();
497
498 assert_eq!(info.phase(), Some(WritePhase::WriterInitialization));
499 assert_eq!(info.kind(), "DirectEncoding");
500 assert_eq!(info.summary(), "direct encoding failed with diagnostics");
501 assert_eq!(info.diagnostic_codes(), "DirectEncodingInvalidPayload");
502 assert_eq!(
503 info.diagnostics()
504 .and_then(|diagnostics| diagnostics.all().first())
505 .map(Diagnostic::code),
506 Some(DiagnosticCode::DirectEncodingInvalidPayload)
507 );
508 }
509
510 #[test]
511 fn unwrapped_diagnostic_errors_keep_default_phase_classification() {
512 let planning = Error::Planning {
513 diagnostics: DiagnosticSet::from(vec![Diagnostic::error(
514 DiagnosticCode::UnsupportedArrowType,
515 "unsupported",
516 )]),
517 };
518 let value_conversion = Error::ValueConversion {
519 diagnostics: DiagnosticSet::from(vec![Diagnostic::error(
520 DiagnosticCode::NonFiniteFloat,
521 "not finite",
522 )]),
523 };
524 let direct_encoding = Error::DirectEncoding {
525 diagnostics: DiagnosticSet::from(vec![Diagnostic::error(
526 DiagnosticCode::DirectEncodingInvalidPayload,
527 "invalid",
528 )]),
529 };
530
531 assert_eq!(planning.write_phase(), Some(WritePhase::SchemaPlanning));
532 assert_eq!(
533 value_conversion.write_phase(),
534 Some(WritePhase::ValueConversion)
535 );
536 assert_eq!(
537 direct_encoding.write_phase(),
538 Some(WritePhase::DirectEncoding)
539 );
540 }
541
542 #[test]
543 fn backend_unavailable_error_display_includes_backend() {
544 let err = Error::BackendUnavailable {
545 backend: crate::WriteBackend::DirectRawBulk,
546 reason: "not implemented".to_owned(),
547 };
548
549 assert_eq!(
550 err.to_string(),
551 "write backend DirectRawBulk is unavailable: not implemented"
552 );
553 }
554
555 #[test]
556 fn invalid_connection_string_error_display_is_redacted() {
557 let err = Error::InvalidConnectionString;
558
559 assert_eq!(err.to_string(), "SQL Server connection string is invalid");
560 }
561
562 #[test]
563 fn tiberius_error_display_includes_source_error() {
564 let err = Error::Tiberius {
565 source: tiberius::error::Error::Protocol(Cow::Borrowed("invalid token")),
566 };
567
568 assert_eq!(
569 err.to_string(),
570 "tiberius operation failed: Protocol error: invalid token"
571 );
572 }
573
574 #[test]
575 fn tiberius_error_preserves_source_error() {
576 let err = Error::Tiberius {
577 source: tiberius::error::Error::BulkInput(Cow::Borrowed("row payload is malformed")),
578 };
579
580 let source = StdError::source(&err).expect("tiberius source should be preserved");
581 assert_eq!(
582 source.to_string(),
583 "BULK UPLOAD input failure: row payload is malformed"
584 );
585 }
586}