1use std::fmt;
4
5use snafu::Snafu;
6
7use crate::{DiagnosticSet, 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, Snafu)]
68#[non_exhaustive]
69pub enum Error {
70 #[snafu(display("write phase {phase} failed"))]
72 WritePhaseContext {
73 phase: WritePhase,
75 source: Box<Error>,
77 },
78
79 #[snafu(display("invalid compatibility level {level}"))]
81 InvalidCompatibilityLevel {
82 level: u16,
84 },
85
86 #[snafu(display("invalid identifier: {reason}"))]
88 InvalidIdentifier {
89 reason: String,
91 },
92
93 #[snafu(display("write planning failed with {} diagnostic(s)", diagnostics.len()))]
95 Planning {
96 diagnostics: DiagnosticSet,
98 },
99
100 #[snafu(display(
102 "value conversion failed with {} diagnostic(s)",
103 diagnostics.len()
104 ))]
105 ValueConversion {
106 diagnostics: DiagnosticSet,
108 },
109
110 #[snafu(display(
112 "direct encoding failed with {} diagnostic(s)",
113 diagnostics.len()
114 ))]
115 DirectEncoding {
116 diagnostics: DiagnosticSet,
118 },
119
120 #[snafu(display("write backend {backend:?} is unavailable: {reason}"))]
122 BackendUnavailable {
123 backend: WriteBackend,
125 reason: String,
127 },
128
129 #[snafu(display("SQL Server connection string is invalid"))]
131 InvalidConnectionString,
132
133 #[snafu(display("TCP connection to SQL Server failed: {source}"))]
135 ConnectionTcpConnect {
136 source: std::io::Error,
138 },
139
140 #[snafu(display("SQL Server client setup failed: {source}"))]
142 ConnectionClientSetup {
143 source: tiberius::error::Error,
145 },
146
147 #[snafu(display("SQL Server table existence query failed: {source}"))]
149 TableExistsQuery {
150 source: tiberius::error::Error,
152 },
153
154 #[snafu(display("SQL Server table existence query returned an unexpected result: {reason}"))]
156 TableExistsUnexpectedResult {
157 reason: String,
159 },
160
161 #[snafu(display("SQL Server target row count query failed: {source}"))]
163 TargetRowCountQuery {
164 source: tiberius::error::Error,
166 },
167
168 #[snafu(display("SQL Server target row count query returned an unexpected result: {reason}"))]
170 TargetRowCountUnexpectedResult {
171 reason: String,
173 },
174
175 #[snafu(display("SQL Server statement execution failed: {source}"))]
177 SqlExecution {
178 source: tiberius::error::Error,
180 },
181
182 #[snafu(display("tiberius operation failed: {source}"))]
184 Tiberius {
185 source: tiberius::error::Error,
187 },
188}
189
190impl Error {
191 #[must_use]
193 pub fn with_write_phase(self, phase: WritePhase) -> Self {
194 Self::WritePhaseContext {
195 phase,
196 source: Box::new(self),
197 }
198 }
199
200 pub const fn write_phase(&self) -> Option<WritePhase> {
202 match self {
203 Self::WritePhaseContext { phase, .. } => Some(*phase),
204 Self::Planning { .. } => Some(WritePhase::SchemaPlanning),
205 Self::ValueConversion { .. } => Some(WritePhase::ValueConversion),
206 Self::DirectEncoding { .. } => Some(WritePhase::DirectEncoding),
207 _ => None,
208 }
209 }
210
211 pub fn without_write_phase(&self) -> &Self {
213 match self {
214 Self::WritePhaseContext { source, .. } => source.without_write_phase(),
215 _ => self,
216 }
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use std::{borrow::Cow, error::Error as StdError};
223
224 use crate::{
225 Diagnostic, DiagnosticCode, DiagnosticSet, Error, WritePhase,
226 observability::{
227 BATCH_SCHEMA_VALIDATION_PHASE, BATCH_WRITE_PHASE, DIRECT_ENCODING_PHASE,
228 FINALIZE_PHASE, FINISH_PHASE, PACKET_WRITE_PHASE, SCHEMA_PLANNING_PHASE,
229 TARGET_METADATA_VALIDATION_PHASE, VALUE_CONVERSION_PHASE, WRITER_INITIALIZATION_PHASE,
230 },
231 };
232
233 #[test]
234 fn planning_error_display_includes_diagnostic_count() {
235 let diagnostics = DiagnosticSet::from(vec![Diagnostic::error(
236 DiagnosticCode::UnsupportedArrowType,
237 "unsupported",
238 )]);
239 let err = Error::Planning { diagnostics };
240
241 assert_eq!(
242 err.to_string(),
243 "write planning failed with 1 diagnostic(s)"
244 );
245 }
246
247 #[test]
248 fn value_conversion_error_display_includes_diagnostic_count() {
249 let diagnostics = DiagnosticSet::from(vec![Diagnostic::error(
250 DiagnosticCode::ValueConversionUnsupported,
251 "unsupported conversion",
252 )]);
253 let err = Error::ValueConversion { diagnostics };
254
255 assert_eq!(
256 err.to_string(),
257 "value conversion failed with 1 diagnostic(s)"
258 );
259 }
260
261 #[test]
262 fn direct_encoding_error_display_includes_diagnostic_count() {
263 let diagnostics = DiagnosticSet::from(vec![Diagnostic::error(
264 DiagnosticCode::DirectEncodingInvalidPayload,
265 "invalid payload",
266 )]);
267 let err = Error::DirectEncoding { diagnostics };
268
269 assert_eq!(
270 err.to_string(),
271 "direct encoding failed with 1 diagnostic(s)"
272 );
273 }
274
275 #[test]
276 fn write_phase_names_match_tracing_vocabulary() {
277 assert_eq!(WritePhase::SchemaPlanning.as_str(), SCHEMA_PLANNING_PHASE);
278 assert_eq!(
279 WritePhase::WriterInitialization.as_str(),
280 WRITER_INITIALIZATION_PHASE
281 );
282 assert_eq!(
283 WritePhase::TargetMetadataValidation.as_str(),
284 TARGET_METADATA_VALIDATION_PHASE
285 );
286 assert_eq!(WritePhase::BatchWrite.as_str(), BATCH_WRITE_PHASE);
287 assert_eq!(
288 WritePhase::BatchSchemaValidation.as_str(),
289 BATCH_SCHEMA_VALIDATION_PHASE
290 );
291 assert_eq!(WritePhase::ValueConversion.as_str(), VALUE_CONVERSION_PHASE);
292 assert_eq!(WritePhase::DirectEncoding.as_str(), DIRECT_ENCODING_PHASE);
293 assert_eq!(WritePhase::PacketWrite.as_str(), PACKET_WRITE_PHASE);
294 assert_eq!(WritePhase::Finish.as_str(), FINISH_PHASE);
295 assert_eq!(WritePhase::Finalize.as_str(), FINALIZE_PHASE);
296 }
297
298 #[test]
299 fn write_phase_context_classifies_without_display_parsing() {
300 let err = Error::Tiberius {
301 source: tiberius::error::Error::BulkInput(Cow::Borrowed("packet failed")),
302 }
303 .with_write_phase(WritePhase::PacketWrite);
304
305 assert_eq!(err.write_phase(), Some(WritePhase::PacketWrite));
306 assert_eq!(err.to_string(), "write phase packet_write failed");
307 assert!(matches!(err.without_write_phase(), Error::Tiberius { .. }));
308 }
309
310 #[test]
311 fn write_phase_context_display_does_not_include_source_text() {
312 let err = Error::Tiberius {
313 source: tiberius::error::Error::BulkInput(Cow::Borrowed(
314 "server=tcp:sql.example.com;User ID=sa;password=secret",
315 )),
316 }
317 .with_write_phase(WritePhase::Finalize);
318
319 assert_eq!(err.to_string(), "write phase finalize failed");
320 assert!(!err.to_string().contains("password=secret"));
321 assert!(!err.to_string().contains("User ID=sa"));
322 assert!(!err.to_string().contains("sql.example.com"));
323 }
324
325 #[test]
326 fn unwrapped_diagnostic_errors_keep_default_phase_classification() {
327 let planning = Error::Planning {
328 diagnostics: DiagnosticSet::from(vec![Diagnostic::error(
329 DiagnosticCode::UnsupportedArrowType,
330 "unsupported",
331 )]),
332 };
333 let value_conversion = Error::ValueConversion {
334 diagnostics: DiagnosticSet::from(vec![Diagnostic::error(
335 DiagnosticCode::NonFiniteFloat,
336 "not finite",
337 )]),
338 };
339 let direct_encoding = Error::DirectEncoding {
340 diagnostics: DiagnosticSet::from(vec![Diagnostic::error(
341 DiagnosticCode::DirectEncodingInvalidPayload,
342 "invalid",
343 )]),
344 };
345
346 assert_eq!(planning.write_phase(), Some(WritePhase::SchemaPlanning));
347 assert_eq!(
348 value_conversion.write_phase(),
349 Some(WritePhase::ValueConversion)
350 );
351 assert_eq!(
352 direct_encoding.write_phase(),
353 Some(WritePhase::DirectEncoding)
354 );
355 }
356
357 #[test]
358 fn backend_unavailable_error_display_includes_backend() {
359 let err = Error::BackendUnavailable {
360 backend: crate::WriteBackend::DirectRawBulk,
361 reason: "not implemented".to_owned(),
362 };
363
364 assert_eq!(
365 err.to_string(),
366 "write backend DirectRawBulk is unavailable: not implemented"
367 );
368 }
369
370 #[test]
371 fn invalid_connection_string_error_display_is_redacted() {
372 let err = Error::InvalidConnectionString;
373
374 assert_eq!(err.to_string(), "SQL Server connection string is invalid");
375 }
376
377 #[test]
378 fn tiberius_error_display_includes_source_error() {
379 let err = Error::Tiberius {
380 source: tiberius::error::Error::Protocol(Cow::Borrowed("invalid token")),
381 };
382
383 assert_eq!(
384 err.to_string(),
385 "tiberius operation failed: Protocol error: invalid token"
386 );
387 }
388
389 #[test]
390 fn tiberius_error_preserves_source_error() {
391 let err = Error::Tiberius {
392 source: tiberius::error::Error::BulkInput(Cow::Borrowed("row payload is malformed")),
393 };
394
395 let source = StdError::source(&err).expect("tiberius source should be preserved");
396 assert_eq!(
397 source.to_string(),
398 "BULK UPLOAD input failure: row payload is malformed"
399 );
400 }
401}