Skip to main content

arrow_tiberius/
diagnostic.rs

1//! Structured diagnostics for planning and writing.
2
3/// Diagnostic severity.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
5pub enum DiagnosticSeverity {
6    /// The operation can continue, but callers may want to surface the finding.
7    Warning,
8    /// The operation cannot continue successfully.
9    Error,
10}
11
12/// Machine-readable diagnostic code.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14#[non_exhaustive]
15pub enum DiagnosticCode {
16    /// An Arrow type is not supported by the selected operation.
17    UnsupportedArrowType,
18    /// A conversion may lose information and requires explicit policy.
19    LossyConversionRequiresPolicy,
20    /// An explicit conversion policy was applied.
21    PolicyApplied,
22    /// A SQL Server identifier is invalid.
23    IdentifierInvalid,
24    /// A SQL Server identifier exceeds the supported length.
25    IdentifierTooLong,
26    /// A decimal value, precision, or scale is outside the supported range.
27    DecimalOutOfRange,
28    /// An integer value is outside the supported range.
29    IntegerOutOfRange,
30    /// A timestamp value is outside the supported range.
31    TimestampOutOfRange,
32    /// A timestamp timezone cannot be mapped to a deterministic SQL Server value.
33    ///
34    /// This is used for invalid timezone names, invalid fixed offset strings,
35    /// and resolved offsets SQL Server cannot represent.
36    TimezoneUnsupported,
37    /// A runtime batch schema does not match the planned schema.
38    SchemaMismatch,
39    /// A requested write backend is unavailable.
40    BackendUnavailable,
41    /// A mapping depends on explicit user policy.
42    ProfileDependentConversion,
43    /// A selected policy needs observed values or statistics, not just schema.
44    ObservedDataRequired,
45    /// A planned value conversion is not supported by the current converter.
46    ValueConversionUnsupported,
47    /// A runtime value or array type does not match the planned conversion.
48    ValueTypeMismatch,
49    /// A runtime null value was found for a non-nullable target column.
50    NullInNonNullableColumn,
51    /// A floating-point value is not finite.
52    NonFiniteFloat,
53    /// A runtime value exceeds the planned target type length.
54    ValueTooLong,
55    /// A requested row index is outside the runtime batch.
56    RowIndexOutOfBounds,
57    /// Direct raw TDS encoding produced or received invalid payload state.
58    DirectEncodingInvalidPayload,
59    /// A planned mapping is not supported by the direct raw TDS encoder.
60    DirectEncodingUnsupportedMapping,
61    /// Runtime batch shape is not supported by the current direct raw TDS encoder.
62    DirectEncodingUnsupportedBatch,
63}
64
65/// Field location for a diagnostic.
66#[derive(Debug, Clone, PartialEq, Eq, Hash)]
67pub struct FieldRef {
68    index: usize,
69    name: String,
70}
71
72impl FieldRef {
73    /// Creates a field reference.
74    pub fn new(index: usize, name: impl Into<String>) -> Self {
75        Self {
76            index,
77            name: name.into(),
78        }
79    }
80
81    /// Returns the field index.
82    pub const fn index(&self) -> usize {
83        self.index
84    }
85
86    /// Returns the field name.
87    pub fn name(&self) -> &str {
88        &self.name
89    }
90}
91
92/// Structured diagnostic.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Diagnostic {
95    severity: DiagnosticSeverity,
96    code: DiagnosticCode,
97    message: String,
98    field: Option<FieldRef>,
99    row: Option<usize>,
100}
101
102impl Diagnostic {
103    /// Creates a diagnostic.
104    pub fn new(
105        severity: DiagnosticSeverity,
106        code: DiagnosticCode,
107        message: impl Into<String>,
108    ) -> Self {
109        Self {
110            severity,
111            code,
112            message: message.into(),
113            field: None,
114            row: None,
115        }
116    }
117
118    /// Creates a warning diagnostic.
119    pub fn warning(code: DiagnosticCode, message: impl Into<String>) -> Self {
120        Self::new(DiagnosticSeverity::Warning, code, message)
121    }
122
123    /// Creates an error diagnostic.
124    pub fn error(code: DiagnosticCode, message: impl Into<String>) -> Self {
125        Self::new(DiagnosticSeverity::Error, code, message)
126    }
127
128    /// Attaches field location to this diagnostic.
129    #[must_use]
130    pub fn with_field(mut self, field: FieldRef) -> Self {
131        self.field = Some(field);
132        self
133    }
134
135    /// Attaches row location to this diagnostic.
136    #[must_use]
137    pub const fn with_row(mut self, row: usize) -> Self {
138        self.row = Some(row);
139        self
140    }
141
142    /// Returns the diagnostic severity.
143    pub const fn severity(&self) -> DiagnosticSeverity {
144        self.severity
145    }
146
147    /// Returns the diagnostic code.
148    pub const fn code(&self) -> DiagnosticCode {
149        self.code
150    }
151
152    /// Returns the diagnostic message.
153    pub fn message(&self) -> &str {
154        &self.message
155    }
156
157    /// Returns the optional field location.
158    pub fn field(&self) -> Option<&FieldRef> {
159        self.field.as_ref()
160    }
161
162    /// Returns the optional row location.
163    pub const fn row(&self) -> Option<usize> {
164        self.row
165    }
166
167    /// Returns true if this diagnostic is an error.
168    pub const fn is_error(&self) -> bool {
169        matches!(self.severity, DiagnosticSeverity::Error)
170    }
171}
172
173/// Collection of diagnostics.
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub struct DiagnosticSet {
176    diagnostics: Vec<Diagnostic>,
177}
178
179impl DiagnosticSet {
180    /// Creates an empty diagnostic set.
181    pub const fn new() -> Self {
182        Self {
183            diagnostics: Vec::new(),
184        }
185    }
186
187    /// Adds a diagnostic.
188    pub fn push(&mut self, diagnostic: Diagnostic) {
189        self.diagnostics.push(diagnostic);
190    }
191
192    /// Returns all diagnostics.
193    pub fn all(&self) -> &[Diagnostic] {
194        &self.diagnostics
195    }
196
197    /// Returns true if no diagnostics are present.
198    pub fn is_empty(&self) -> bool {
199        self.diagnostics.is_empty()
200    }
201
202    /// Returns true if at least one error diagnostic is present.
203    pub fn has_errors(&self) -> bool {
204        self.diagnostics.iter().any(Diagnostic::is_error)
205    }
206
207    /// Returns the number of diagnostics.
208    pub fn len(&self) -> usize {
209        self.diagnostics.len()
210    }
211}
212
213impl From<Vec<Diagnostic>> for DiagnosticSet {
214    fn from(diagnostics: Vec<Diagnostic>) -> Self {
215        Self { diagnostics }
216    }
217}
218
219impl IntoIterator for DiagnosticSet {
220    type Item = Diagnostic;
221    type IntoIter = std::vec::IntoIter<Diagnostic>;
222
223    fn into_iter(self) -> Self::IntoIter {
224        self.diagnostics.into_iter()
225    }
226}
227
228/// Successful planning result plus diagnostics.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct PlanOutcome<T> {
231    value: T,
232    diagnostics: DiagnosticSet,
233}
234
235impl<T> PlanOutcome<T> {
236    /// Creates a planning outcome.
237    pub const fn new(value: T, diagnostics: DiagnosticSet) -> Self {
238        Self { value, diagnostics }
239    }
240
241    /// Returns the planned value.
242    pub const fn value(&self) -> &T {
243        &self.value
244    }
245
246    /// Returns the diagnostics.
247    pub const fn diagnostics(&self) -> &DiagnosticSet {
248        &self.diagnostics
249    }
250
251    /// Consumes this outcome into its parts.
252    pub fn into_parts(self) -> (T, DiagnosticSet) {
253        (self.value, self.diagnostics)
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::{
260        Diagnostic, DiagnosticCode, DiagnosticSet, DiagnosticSeverity, FieldRef, PlanOutcome,
261    };
262
263    #[test]
264    fn creates_field_diagnostic() {
265        let diagnostic = Diagnostic::warning(DiagnosticCode::PolicyApplied, "policy applied")
266            .with_field(FieldRef::new(2, "amount"));
267
268        assert_eq!(diagnostic.severity(), DiagnosticSeverity::Warning);
269        assert_eq!(diagnostic.code(), DiagnosticCode::PolicyApplied);
270        assert_eq!(diagnostic.message(), "policy applied");
271
272        let field = diagnostic.field().unwrap();
273        assert_eq!(field.index(), 2);
274        assert_eq!(field.name(), "amount");
275        assert_eq!(diagnostic.row(), None);
276    }
277
278    #[test]
279    fn creates_row_and_field_diagnostic() {
280        let diagnostic = Diagnostic::error(
281            DiagnosticCode::NullInNonNullableColumn,
282            "null value cannot be written",
283        )
284        .with_field(FieldRef::new(3, "name"))
285        .with_row(42);
286
287        assert_eq!(diagnostic.severity(), DiagnosticSeverity::Error);
288        assert_eq!(diagnostic.code(), DiagnosticCode::NullInNonNullableColumn);
289        assert_eq!(diagnostic.row(), Some(42));
290
291        let field = diagnostic.field().unwrap();
292        assert_eq!(field.index(), 3);
293        assert_eq!(field.name(), "name");
294    }
295
296    #[test]
297    fn detects_error_diagnostics() {
298        let mut diagnostics = DiagnosticSet::new();
299        diagnostics.push(Diagnostic::warning(
300            DiagnosticCode::PolicyApplied,
301            "policy applied",
302        ));
303
304        assert!(!diagnostics.has_errors());
305
306        diagnostics.push(Diagnostic::error(
307            DiagnosticCode::UnsupportedArrowType,
308            "unsupported",
309        ));
310
311        assert!(diagnostics.has_errors());
312        assert_eq!(diagnostics.len(), 2);
313    }
314
315    #[test]
316    fn empty_diagnostic_set_has_no_errors() {
317        let diagnostics = DiagnosticSet::new();
318
319        assert!(diagnostics.is_empty());
320        assert!(!diagnostics.has_errors());
321        assert_eq!(diagnostics.all(), &[]);
322    }
323
324    #[test]
325    fn converts_from_vec() {
326        let diagnostics = DiagnosticSet::from(vec![Diagnostic::error(
327            DiagnosticCode::IdentifierInvalid,
328            "invalid",
329        )]);
330
331        assert_eq!(diagnostics.len(), 1);
332        assert!(!diagnostics.is_empty());
333    }
334
335    #[test]
336    fn preserves_diagnostic_order_when_consumed() {
337        let diagnostics = DiagnosticSet::from(vec![
338            Diagnostic::warning(DiagnosticCode::PolicyApplied, "first"),
339            Diagnostic::error(DiagnosticCode::SchemaMismatch, "second"),
340        ]);
341
342        let messages = diagnostics
343            .into_iter()
344            .map(|diagnostic| diagnostic.message().to_owned())
345            .collect::<Vec<_>>();
346
347        assert_eq!(messages, ["first", "second"]);
348    }
349
350    #[test]
351    fn plan_outcome_exposes_value_and_diagnostics() {
352        let diagnostics = DiagnosticSet::from(vec![Diagnostic::warning(
353            DiagnosticCode::ProfileDependentConversion,
354            "policy needed",
355        )]);
356        let outcome = PlanOutcome::new("plan", diagnostics);
357
358        assert_eq!(outcome.value(), &"plan");
359        assert_eq!(outcome.diagnostics().len(), 1);
360
361        let (value, diagnostics) = outcome.into_parts();
362        assert_eq!(value, "plan");
363        assert_eq!(diagnostics.len(), 1);
364    }
365}