Skip to main content

grafeo_common/utils/
gqlstatus.rs

1//! GQLSTATUS diagnostic codes per ISO/IEC 39075:2024, sec 23.
2//!
3//! Every query result carries a [`GqlStatus`] code (5-character string like `"00000"`)
4//! and, on errors, an optional [`DiagnosticRecord`] with operation context.
5
6use std::fmt;
7
8/// A GQLSTATUS code: 2-character class + 3-character subclass.
9///
10/// Standard classes:
11/// - `00` successful completion
12/// - `01` warning
13/// - `02` no data
14/// - `22` data exception
15/// - `25` invalid transaction state
16/// - `40` transaction rollback
17/// - `42` syntax error or access rule violation
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub struct GqlStatus {
20    code: [u8; 5],
21}
22
23impl GqlStatus {
24    // ── Successful completion (class 00) ──────────────────────────────
25
26    /// `00000` - successful completion, no subclass.
27    pub const SUCCESS: Self = Self::from_bytes(*b"00000");
28
29    /// `00001` - successful completion, omitted result.
30    pub const SUCCESS_OMITTED_RESULT: Self = Self::from_bytes(*b"00001");
31
32    // ── Warning (class 01) ────────────────────────────────────────────
33
34    /// `01000` - warning, no subclass.
35    pub const WARNING: Self = Self::from_bytes(*b"01000");
36
37    /// `01004` - warning: string data, right truncation.
38    pub const WARNING_STRING_TRUNCATION: Self = Self::from_bytes(*b"01004");
39
40    /// `01G03` - warning: graph does not exist.
41    pub const WARNING_GRAPH_NOT_EXIST: Self = Self::from_bytes(*b"01G03");
42
43    /// `01G04` - warning: graph type does not exist.
44    pub const WARNING_GRAPH_TYPE_NOT_EXIST: Self = Self::from_bytes(*b"01G04");
45
46    /// `01G11` - warning: null value eliminated in set function.
47    pub const WARNING_NULL_ELIMINATED: Self = Self::from_bytes(*b"01G11");
48
49    // ── No data (class 02) ────────────────────────────────────────────
50
51    /// `02000` - no data.
52    pub const NO_DATA: Self = Self::from_bytes(*b"02000");
53
54    // ── Data exception (class 22) ─────────────────────────────────────
55
56    /// `22000` - data exception, no subclass.
57    pub const DATA_EXCEPTION: Self = Self::from_bytes(*b"22000");
58
59    /// `22001` - string data, right truncation.
60    pub const DATA_STRING_TRUNCATION: Self = Self::from_bytes(*b"22001");
61
62    /// `22003` - numeric value out of range.
63    pub const DATA_NUMERIC_OUT_OF_RANGE: Self = Self::from_bytes(*b"22003");
64
65    /// `22004` - null value not allowed.
66    pub const DATA_NULL_NOT_ALLOWED: Self = Self::from_bytes(*b"22004");
67
68    /// `22012` - division by zero.
69    pub const DATA_DIVISION_BY_ZERO: Self = Self::from_bytes(*b"22012");
70
71    /// `22G02` - negative limit value.
72    pub const DATA_NEGATIVE_LIMIT: Self = Self::from_bytes(*b"22G02");
73
74    /// `22G03` - invalid value type.
75    pub const DATA_INVALID_VALUE_TYPE: Self = Self::from_bytes(*b"22G03");
76
77    /// `22G04` - values not comparable.
78    pub const DATA_VALUES_NOT_COMPARABLE: Self = Self::from_bytes(*b"22G04");
79
80    // ── Invalid transaction state (class 25) ──────────────────────────
81
82    /// `25000` - invalid transaction state, no subclass.
83    pub const INVALID_TX_STATE: Self = Self::from_bytes(*b"25000");
84
85    /// `25G01` - active GQL-transaction.
86    pub const INVALID_TX_ACTIVE: Self = Self::from_bytes(*b"25G01");
87
88    /// `25G03` - read-only GQL-transaction.
89    pub const INVALID_TX_READ_ONLY: Self = Self::from_bytes(*b"25G03");
90
91    // ── Invalid transaction termination (class 2D) ────────────────────
92
93    /// `2D000` - invalid transaction termination.
94    pub const INVALID_TX_TERMINATION: Self = Self::from_bytes(*b"2D000");
95
96    // ── Transaction rollback (class 40) ───────────────────────────────
97
98    /// `40000` - transaction rollback, no subclass.
99    pub const TX_ROLLBACK: Self = Self::from_bytes(*b"40000");
100
101    /// `40003` - statement completion unknown.
102    pub const TX_COMPLETION_UNKNOWN: Self = Self::from_bytes(*b"40003");
103
104    // ── Syntax error or access rule violation (class 42) ──────────────
105
106    /// `42000` - syntax error or access rule violation, no subclass.
107    pub const SYNTAX_ERROR: Self = Self::from_bytes(*b"42000");
108
109    /// `42001` - invalid syntax.
110    pub const SYNTAX_INVALID: Self = Self::from_bytes(*b"42001");
111
112    /// `42002` - invalid reference.
113    pub const SYNTAX_INVALID_REFERENCE: Self = Self::from_bytes(*b"42002");
114
115    // ── Dependent object error (class G1) ─────────────────────────────
116
117    /// `G1000` - dependent object error, no subclass.
118    pub const DEPENDENT_OBJECT_ERROR: Self = Self::from_bytes(*b"G1000");
119
120    // ── Graph type violation (class G2) ───────────────────────────────
121
122    /// `G2000` - graph type violation.
123    pub const GRAPH_TYPE_VIOLATION: Self = Self::from_bytes(*b"G2000");
124
125    // ── Constructors ──────────────────────────────────────────────────
126
127    /// Creates a `GqlStatus` from a 5-byte array. Panics if bytes are not ASCII.
128    #[must_use]
129    const fn from_bytes(bytes: [u8; 5]) -> Self {
130        Self { code: bytes }
131    }
132
133    /// Creates a `GqlStatus` from a 5-character string slice.
134    ///
135    /// Returns `None` if the string is not exactly 5 ASCII characters.
136    #[must_use]
137    pub fn from_str(s: &str) -> Option<Self> {
138        let bytes = s.as_bytes();
139        if bytes.len() != 5 {
140            return None;
141        }
142        if !bytes.iter().all(|b| b.is_ascii_alphanumeric()) {
143            return None;
144        }
145        Some(Self {
146            code: [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4]],
147        })
148    }
149
150    /// Returns the 5-character GQLSTATUS code as a string slice.
151    #[must_use]
152    pub fn as_str(&self) -> &str {
153        // Safety: all codes are constructed from ASCII bytes
154        std::str::from_utf8(&self.code).unwrap_or("?????")
155    }
156
157    /// Returns the 2-character class code (e.g., `"00"`, `"42"`).
158    #[must_use]
159    pub fn class_code(&self) -> &str {
160        &self.as_str()[..2]
161    }
162
163    /// Returns the 3-character subclass code (e.g., `"000"`, `"001"`).
164    #[must_use]
165    pub fn subclass_code(&self) -> &str {
166        &self.as_str()[2..]
167    }
168
169    /// True if this is a successful completion (class `00`).
170    #[must_use]
171    pub fn is_success(&self) -> bool {
172        self.code[0] == b'0' && self.code[1] == b'0'
173    }
174
175    /// True if this is a warning (class `01`).
176    #[must_use]
177    pub fn is_warning(&self) -> bool {
178        self.code[0] == b'0' && self.code[1] == b'1'
179    }
180
181    /// True if this is a no-data condition (class `02`).
182    #[must_use]
183    pub fn is_no_data(&self) -> bool {
184        self.code[0] == b'0' && self.code[1] == b'2'
185    }
186
187    /// True if this is an exception condition (not success, warning, or no-data).
188    #[must_use]
189    pub fn is_exception(&self) -> bool {
190        !self.is_success() && !self.is_warning() && !self.is_no_data()
191    }
192}
193
194impl fmt::Display for GqlStatus {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        f.write_str(self.as_str())
197    }
198}
199
200/// Maps a Grafeo [`super::error::Error`] to a GQLSTATUS code.
201impl From<&super::error::Error> for GqlStatus {
202    fn from(err: &super::error::Error) -> Self {
203        use super::error::{Error, QueryErrorKind, TransactionError};
204
205        match err {
206            Error::Query(q) => match q.kind {
207                QueryErrorKind::Lexer | QueryErrorKind::Syntax => GqlStatus::SYNTAX_INVALID,
208                QueryErrorKind::Semantic => GqlStatus::SYNTAX_INVALID_REFERENCE,
209                QueryErrorKind::Optimization => GqlStatus::SYNTAX_ERROR,
210                QueryErrorKind::Execution => GqlStatus::DATA_EXCEPTION,
211            },
212            Error::Transaction(t) => match t {
213                TransactionError::ReadOnly => GqlStatus::INVALID_TX_READ_ONLY,
214                TransactionError::InvalidState(_) => GqlStatus::INVALID_TX_STATE,
215                TransactionError::Aborted
216                | TransactionError::Conflict
217                | TransactionError::WriteConflict(_) => GqlStatus::TX_ROLLBACK,
218                TransactionError::SerializationFailure(_) => GqlStatus::TX_ROLLBACK,
219                TransactionError::Deadlock => GqlStatus::TX_ROLLBACK,
220                TransactionError::Timeout => GqlStatus::INVALID_TX_STATE,
221            },
222            Error::TypeMismatch { .. } => GqlStatus::DATA_INVALID_VALUE_TYPE,
223            Error::InvalidValue(_) => GqlStatus::DATA_EXCEPTION,
224            Error::NodeNotFound(_) | Error::EdgeNotFound(_) => GqlStatus::NO_DATA,
225            Error::PropertyNotFound(_) | Error::LabelNotFound(_) => {
226                GqlStatus::SYNTAX_INVALID_REFERENCE
227            }
228            Error::Storage(_) => GqlStatus::DATA_EXCEPTION,
229            Error::Serialization(_) => GqlStatus::DATA_EXCEPTION,
230            Error::Io(_) => GqlStatus::DATA_EXCEPTION,
231            Error::Internal(_) => GqlStatus::DATA_EXCEPTION,
232        }
233    }
234}
235
236/// Diagnostic record attached to error conditions (ISO sec 23.2).
237///
238/// Contains contextual information about the operation that raised the condition.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct DiagnosticRecord {
241    /// Identifier of the operation executed (e.g., `"MATCH STATEMENT"`).
242    pub operation: String,
243    /// Numeric operation code per Table 9 of the spec.
244    pub operation_code: i32,
245    /// Current working schema reference, if any.
246    pub current_schema: Option<String>,
247    /// Invalid reference identifier (only for GQLSTATUS `42002`).
248    pub invalid_reference: Option<String>,
249}
250
251impl DiagnosticRecord {
252    /// Creates a diagnostic record for a query operation.
253    #[must_use]
254    pub fn for_query(operation: impl Into<String>, operation_code: i32) -> Self {
255        Self {
256            operation: operation.into(),
257            operation_code,
258            current_schema: None,
259            invalid_reference: None,
260        }
261    }
262}
263
264/// Operation codes from ISO Table 9.
265pub mod operation_codes {
266    /// SESSION SET SCHEMA.
267    pub const SESSION_SET_SCHEMA: i32 = 1;
268    /// SESSION SET GRAPH.
269    pub const SESSION_SET_GRAPH: i32 = 2;
270    /// SESSION SET TIME ZONE.
271    pub const SESSION_SET_TIME_ZONE: i32 = 3;
272    /// SESSION RESET.
273    pub const SESSION_RESET: i32 = 7;
274    /// SESSION CLOSE.
275    pub const SESSION_CLOSE: i32 = 8;
276    /// START TRANSACTION.
277    pub const START_TRANSACTION: i32 = 50;
278    /// ROLLBACK.
279    pub const ROLLBACK: i32 = 51;
280    /// COMMIT.
281    pub const COMMIT: i32 = 52;
282    /// CREATE SCHEMA.
283    pub const CREATE_SCHEMA: i32 = 100;
284    /// DROP SCHEMA.
285    pub const DROP_SCHEMA: i32 = 101;
286    /// CREATE GRAPH.
287    pub const CREATE_GRAPH: i32 = 200;
288    /// DROP GRAPH.
289    pub const DROP_GRAPH: i32 = 201;
290    /// CREATE GRAPH TYPE.
291    pub const CREATE_GRAPH_TYPE: i32 = 300;
292    /// DROP GRAPH TYPE.
293    pub const DROP_GRAPH_TYPE: i32 = 301;
294    /// INSERT.
295    pub const INSERT: i32 = 500;
296    /// SET.
297    pub const SET: i32 = 501;
298    /// REMOVE.
299    pub const REMOVE: i32 = 502;
300    /// DELETE.
301    pub const DELETE: i32 = 503;
302    /// MATCH.
303    pub const MATCH: i32 = 600;
304    /// FILTER.
305    pub const FILTER: i32 = 601;
306    /// LET.
307    pub const LET: i32 = 602;
308    /// FOR.
309    pub const FOR: i32 = 603;
310    /// ORDER BY / LIMIT / SKIP.
311    pub const ORDER_BY_AND_PAGE: i32 = 604;
312    /// RETURN.
313    pub const RETURN: i32 = 605;
314    /// SELECT.
315    pub const SELECT: i32 = 606;
316    /// CALL procedure.
317    pub const CALL_PROCEDURE: i32 = 800;
318    /// Unrecognized operation.
319    pub const UNRECOGNIZED: i32 = 0;
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_gqlstatus_constants() {
328        assert_eq!(GqlStatus::SUCCESS.as_str(), "00000");
329        assert_eq!(GqlStatus::NO_DATA.as_str(), "02000");
330        assert_eq!(GqlStatus::SYNTAX_INVALID.as_str(), "42001");
331        assert_eq!(GqlStatus::TX_ROLLBACK.as_str(), "40000");
332    }
333
334    #[test]
335    fn test_gqlstatus_classification() {
336        assert!(GqlStatus::SUCCESS.is_success());
337        assert!(!GqlStatus::SUCCESS.is_warning());
338        assert!(!GqlStatus::SUCCESS.is_exception());
339
340        assert!(GqlStatus::WARNING.is_warning());
341        assert!(!GqlStatus::WARNING.is_success());
342        assert!(!GqlStatus::WARNING.is_exception());
343
344        assert!(GqlStatus::NO_DATA.is_no_data());
345        assert!(!GqlStatus::NO_DATA.is_exception());
346
347        assert!(GqlStatus::SYNTAX_ERROR.is_exception());
348        assert!(GqlStatus::DATA_EXCEPTION.is_exception());
349        assert!(GqlStatus::TX_ROLLBACK.is_exception());
350    }
351
352    #[test]
353    fn test_gqlstatus_class_subclass() {
354        assert_eq!(GqlStatus::SUCCESS.class_code(), "00");
355        assert_eq!(GqlStatus::SUCCESS.subclass_code(), "000");
356
357        assert_eq!(GqlStatus::SYNTAX_INVALID.class_code(), "42");
358        assert_eq!(GqlStatus::SYNTAX_INVALID.subclass_code(), "001");
359
360        assert_eq!(GqlStatus::DATA_DIVISION_BY_ZERO.class_code(), "22");
361        assert_eq!(GqlStatus::DATA_DIVISION_BY_ZERO.subclass_code(), "012");
362    }
363
364    #[test]
365    fn test_gqlstatus_from_str() {
366        assert_eq!(GqlStatus::from_str("00000"), Some(GqlStatus::SUCCESS));
367        assert_eq!(GqlStatus::from_str("0000"), None); // too short
368        assert_eq!(GqlStatus::from_str("000000"), None); // too long
369        assert_eq!(GqlStatus::from_str("00 00"), None); // has space
370    }
371
372    #[test]
373    fn test_gqlstatus_display() {
374        assert_eq!(format!("{}", GqlStatus::SUCCESS), "00000");
375        assert_eq!(format!("{}", GqlStatus::SYNTAX_INVALID), "42001");
376    }
377
378    #[test]
379    fn test_error_to_gqlstatus() {
380        use super::super::error::{Error, QueryError, QueryErrorKind, TransactionError};
381
382        let syntax_err = Error::Query(QueryError::new(QueryErrorKind::Syntax, "bad syntax"));
383        assert_eq!(GqlStatus::from(&syntax_err), GqlStatus::SYNTAX_INVALID);
384
385        let semantic_err = Error::Query(QueryError::new(QueryErrorKind::Semantic, "unknown label"));
386        assert_eq!(
387            GqlStatus::from(&semantic_err),
388            GqlStatus::SYNTAX_INVALID_REFERENCE
389        );
390
391        let tx_err = Error::Transaction(TransactionError::ReadOnly);
392        assert_eq!(GqlStatus::from(&tx_err), GqlStatus::INVALID_TX_READ_ONLY);
393
394        let conflict = Error::Transaction(TransactionError::Conflict);
395        assert_eq!(GqlStatus::from(&conflict), GqlStatus::TX_ROLLBACK);
396
397        let type_err = Error::TypeMismatch {
398            expected: "INT64".into(),
399            found: "STRING".into(),
400        };
401        assert_eq!(
402            GqlStatus::from(&type_err),
403            GqlStatus::DATA_INVALID_VALUE_TYPE
404        );
405    }
406
407    #[test]
408    fn test_diagnostic_record() {
409        let record = DiagnosticRecord::for_query("MATCH STATEMENT", operation_codes::MATCH);
410        assert_eq!(record.operation, "MATCH STATEMENT");
411        assert_eq!(record.operation_code, 600);
412        assert!(record.current_schema.is_none());
413        assert!(record.invalid_reference.is_none());
414    }
415}