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                QueryErrorKind::Timeout => GqlStatus::DATA_EXCEPTION,
212            },
213            Error::Transaction(t) => match t {
214                TransactionError::ReadOnly => GqlStatus::INVALID_TX_READ_ONLY,
215                TransactionError::InvalidState(_) => GqlStatus::INVALID_TX_STATE,
216                TransactionError::Aborted
217                | TransactionError::Conflict
218                | TransactionError::WriteConflict(_) => GqlStatus::TX_ROLLBACK,
219                TransactionError::SerializationFailure(_) => GqlStatus::TX_ROLLBACK,
220                TransactionError::Deadlock => GqlStatus::TX_ROLLBACK,
221                TransactionError::Timeout => GqlStatus::INVALID_TX_STATE,
222            },
223            Error::TypeMismatch { .. } => GqlStatus::DATA_INVALID_VALUE_TYPE,
224            Error::InvalidValue(_) => GqlStatus::DATA_EXCEPTION,
225            Error::NodeNotFound(_) | Error::EdgeNotFound(_) => GqlStatus::NO_DATA,
226            Error::PropertyNotFound(_) | Error::LabelNotFound(_) => {
227                GqlStatus::SYNTAX_INVALID_REFERENCE
228            }
229            Error::Storage(_) => GqlStatus::DATA_EXCEPTION,
230            Error::Serialization(_) => GqlStatus::DATA_EXCEPTION,
231            Error::Io(_) => GqlStatus::DATA_EXCEPTION,
232            Error::Internal(_) => GqlStatus::DATA_EXCEPTION,
233        }
234    }
235}
236
237/// Diagnostic record attached to error conditions (ISO sec 23.2).
238///
239/// Contains contextual information about the operation that raised the condition.
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct DiagnosticRecord {
242    /// Identifier of the operation executed (e.g., `"MATCH STATEMENT"`).
243    pub operation: String,
244    /// Numeric operation code per Table 9 of the spec.
245    pub operation_code: i32,
246    /// Current working schema reference, if any.
247    pub current_schema: Option<String>,
248    /// Invalid reference identifier (only for GQLSTATUS `42002`).
249    pub invalid_reference: Option<String>,
250}
251
252impl DiagnosticRecord {
253    /// Creates a diagnostic record for a query operation.
254    #[must_use]
255    pub fn for_query(operation: impl Into<String>, operation_code: i32) -> Self {
256        Self {
257            operation: operation.into(),
258            operation_code,
259            current_schema: None,
260            invalid_reference: None,
261        }
262    }
263}
264
265/// Operation codes from ISO Table 9.
266pub mod operation_codes {
267    /// SESSION SET SCHEMA.
268    pub const SESSION_SET_SCHEMA: i32 = 1;
269    /// SESSION SET GRAPH.
270    pub const SESSION_SET_GRAPH: i32 = 2;
271    /// SESSION SET TIME ZONE.
272    pub const SESSION_SET_TIME_ZONE: i32 = 3;
273    /// SESSION RESET.
274    pub const SESSION_RESET: i32 = 7;
275    /// SESSION CLOSE.
276    pub const SESSION_CLOSE: i32 = 8;
277    /// START TRANSACTION.
278    pub const START_TRANSACTION: i32 = 50;
279    /// ROLLBACK.
280    pub const ROLLBACK: i32 = 51;
281    /// COMMIT.
282    pub const COMMIT: i32 = 52;
283    /// CREATE SCHEMA.
284    pub const CREATE_SCHEMA: i32 = 100;
285    /// DROP SCHEMA.
286    pub const DROP_SCHEMA: i32 = 101;
287    /// CREATE GRAPH.
288    pub const CREATE_GRAPH: i32 = 200;
289    /// DROP GRAPH.
290    pub const DROP_GRAPH: i32 = 201;
291    /// CREATE GRAPH TYPE.
292    pub const CREATE_GRAPH_TYPE: i32 = 300;
293    /// DROP GRAPH TYPE.
294    pub const DROP_GRAPH_TYPE: i32 = 301;
295    /// INSERT.
296    pub const INSERT: i32 = 500;
297    /// SET.
298    pub const SET: i32 = 501;
299    /// REMOVE.
300    pub const REMOVE: i32 = 502;
301    /// DELETE.
302    pub const DELETE: i32 = 503;
303    /// MATCH.
304    pub const MATCH: i32 = 600;
305    /// FILTER.
306    pub const FILTER: i32 = 601;
307    /// LET.
308    pub const LET: i32 = 602;
309    /// FOR.
310    pub const FOR: i32 = 603;
311    /// ORDER BY / LIMIT / SKIP.
312    pub const ORDER_BY_AND_PAGE: i32 = 604;
313    /// RETURN.
314    pub const RETURN: i32 = 605;
315    /// SELECT.
316    pub const SELECT: i32 = 606;
317    /// CALL procedure.
318    pub const CALL_PROCEDURE: i32 = 800;
319    /// Unrecognized operation.
320    pub const UNRECOGNIZED: i32 = 0;
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_gqlstatus_constants() {
329        assert_eq!(GqlStatus::SUCCESS.as_str(), "00000");
330        assert_eq!(GqlStatus::NO_DATA.as_str(), "02000");
331        assert_eq!(GqlStatus::SYNTAX_INVALID.as_str(), "42001");
332        assert_eq!(GqlStatus::TX_ROLLBACK.as_str(), "40000");
333    }
334
335    #[test]
336    fn test_gqlstatus_classification() {
337        assert!(GqlStatus::SUCCESS.is_success());
338        assert!(!GqlStatus::SUCCESS.is_warning());
339        assert!(!GqlStatus::SUCCESS.is_exception());
340
341        assert!(GqlStatus::WARNING.is_warning());
342        assert!(!GqlStatus::WARNING.is_success());
343        assert!(!GqlStatus::WARNING.is_exception());
344
345        assert!(GqlStatus::NO_DATA.is_no_data());
346        assert!(!GqlStatus::NO_DATA.is_exception());
347
348        assert!(GqlStatus::SYNTAX_ERROR.is_exception());
349        assert!(GqlStatus::DATA_EXCEPTION.is_exception());
350        assert!(GqlStatus::TX_ROLLBACK.is_exception());
351    }
352
353    #[test]
354    fn test_gqlstatus_class_subclass() {
355        assert_eq!(GqlStatus::SUCCESS.class_code(), "00");
356        assert_eq!(GqlStatus::SUCCESS.subclass_code(), "000");
357
358        assert_eq!(GqlStatus::SYNTAX_INVALID.class_code(), "42");
359        assert_eq!(GqlStatus::SYNTAX_INVALID.subclass_code(), "001");
360
361        assert_eq!(GqlStatus::DATA_DIVISION_BY_ZERO.class_code(), "22");
362        assert_eq!(GqlStatus::DATA_DIVISION_BY_ZERO.subclass_code(), "012");
363    }
364
365    #[test]
366    fn test_gqlstatus_from_str() {
367        assert_eq!(GqlStatus::from_str("00000"), Some(GqlStatus::SUCCESS));
368        assert_eq!(GqlStatus::from_str("0000"), None); // too short
369        assert_eq!(GqlStatus::from_str("000000"), None); // too long
370        assert_eq!(GqlStatus::from_str("00 00"), None); // has space
371    }
372
373    #[test]
374    fn test_gqlstatus_display() {
375        assert_eq!(format!("{}", GqlStatus::SUCCESS), "00000");
376        assert_eq!(format!("{}", GqlStatus::SYNTAX_INVALID), "42001");
377    }
378
379    #[test]
380    fn test_error_to_gqlstatus() {
381        use super::super::error::{Error, QueryError, QueryErrorKind, TransactionError};
382
383        let syntax_err = Error::Query(QueryError::new(QueryErrorKind::Syntax, "bad syntax"));
384        assert_eq!(GqlStatus::from(&syntax_err), GqlStatus::SYNTAX_INVALID);
385
386        let semantic_err = Error::Query(QueryError::new(QueryErrorKind::Semantic, "unknown label"));
387        assert_eq!(
388            GqlStatus::from(&semantic_err),
389            GqlStatus::SYNTAX_INVALID_REFERENCE
390        );
391
392        let tx_err = Error::Transaction(TransactionError::ReadOnly);
393        assert_eq!(GqlStatus::from(&tx_err), GqlStatus::INVALID_TX_READ_ONLY);
394
395        let conflict = Error::Transaction(TransactionError::Conflict);
396        assert_eq!(GqlStatus::from(&conflict), GqlStatus::TX_ROLLBACK);
397
398        let type_err = Error::TypeMismatch {
399            expected: "INT64".into(),
400            found: "STRING".into(),
401        };
402        assert_eq!(
403            GqlStatus::from(&type_err),
404            GqlStatus::DATA_INVALID_VALUE_TYPE
405        );
406    }
407
408    #[test]
409    fn test_diagnostic_record() {
410        let record = DiagnosticRecord::for_query("MATCH STATEMENT", operation_codes::MATCH);
411        assert_eq!(record.operation, "MATCH STATEMENT");
412        assert_eq!(record.operation_code, 600);
413        assert!(record.current_schema.is_none());
414        assert!(record.invalid_reference.is_none());
415    }
416}