Skip to main content

linkrs_api/api_core/
session_command.rs

1//! Shared classification of transaction and session commands.
2//!
3//! One implementation serves both the embedded session and the network
4//! service layer: the keyword gate, the single parse, and the AST match
5//! live here so the two transports cannot drift apart. Execution of the
6//! classified command stays with each host, which owns its own session
7//! state (bindings, variable store, permissions).
8
9use crate::api_core::error::{CoreError, ExtendedErrorCode};
10use linkrs_query::parser::ast::Stmt;
11use linkrs_query::parser::{Parser, ParserResult};
12
13/// Command classes intercepted by the API layer before the query pipeline
14/// sees the statement text. Undefined combinations never reach a host:
15/// classification guarantees the AST variant matches the returned kind.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SessionCommand {
18    Begin,
19    Commit,
20    Rollback,
21    Savepoint,
22    ReleaseSavepoint,
23    AssignVariable,
24    Extension,
25}
26
27/// First parse error of a command-like statement, carrying the location
28/// so each transport can report it in its own error shape.
29#[derive(Debug, Clone)]
30pub struct CommandParseError {
31    pub message: String,
32    pub offset: Option<usize>,
33    pub position: Option<linkrs_core::types::Position>,
34}
35
36impl std::fmt::Display for CommandParseError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        write!(f, "Parse error: {}", self.message)
39    }
40}
41
42/// Malformed command-like statements become syntax errors that keep the
43/// original location, so transports can point at the offending token.
44impl From<CommandParseError> for CoreError {
45    fn from(error: CommandParseError) -> Self {
46        match (error.position, error.offset) {
47            (Some(position), offset) => CoreError::detailed_query_error_with_position(
48                error.message,
49                ExtendedErrorCode::SyntaxError,
50                offset,
51                Some(position),
52            ),
53            (None, Some(offset)) => CoreError::detailed_query_error(
54                error.message,
55                ExtendedErrorCode::SyntaxError,
56                Some(offset),
57            ),
58            (None, None) => CoreError::QueryExecutionFailed(error.message),
59        }
60    }
61}
62
63/// Keyword gate: whether the text opens with a transaction or session
64/// command keyword. Regular statements skip the API-layer parse entirely
65/// and are parsed once inside the query engine.
66pub fn is_command_like(text: &str) -> bool {
67    let upper = text.trim().to_uppercase();
68    upper == "BEGIN"
69        || upper.starts_with("BEGIN ")
70        || upper.starts_with("COMMIT")
71        || upper.starts_with("ROLLBACK")
72        || upper.starts_with("SAVEPOINT")
73        || upper.starts_with("RELEASE SAVEPOINT")
74        || upper == "LET"
75        || upper.starts_with("LET ")
76        || upper.starts_with("LOAD EXTENSION")
77        || upper.starts_with("INSTALL EXTENSION")
78        || upper.starts_with("UPDATE EXTENSION")
79        || upper.starts_with("UNINSTALL EXTENSION")
80}
81
82/// Parse the statement and classify it when it is one of the
83/// transaction / session commands.
84///
85/// Returns `Ok(None)` for regular statements (and for command-like text
86/// that parses into another statement kind, which the pipeline reports).
87/// Returns `Err` with the first specific parse error for malformed
88/// command-like statements.
89pub fn classify_session_command(
90    text: &str,
91) -> Result<Option<(ParserResult, SessionCommand)>, CommandParseError> {
92    if !is_command_like(text) {
93        return Ok(None);
94    }
95    let mut parser = Parser::new(text);
96    match parser.parse() {
97        Ok(result) if !parser.has_errors() => match result.ast.stmt() {
98            Stmt::BeginTransaction(_) => Ok(Some((result, SessionCommand::Begin))),
99            Stmt::CommitTransaction(_) => Ok(Some((result, SessionCommand::Commit))),
100            Stmt::RollbackTransaction(_) => Ok(Some((result, SessionCommand::Rollback))),
101            Stmt::Savepoint(_) => Ok(Some((result, SessionCommand::Savepoint))),
102            Stmt::ReleaseSavepoint(_) => Ok(Some((result, SessionCommand::ReleaseSavepoint))),
103            Stmt::AssignVariable(_) => Ok(Some((result, SessionCommand::AssignVariable))),
104            Stmt::Extension(_) => Ok(Some((result, SessionCommand::Extension))),
105            _ => Ok(None),
106        },
107        Ok(_) => Ok(None),
108        Err(_) => match parser.errors().iter().next() {
109            Some(first) => Err(CommandParseError {
110                message: first.message.to_string(),
111                offset: first.offset,
112                position: first.position.is_valid().then_some(first.position),
113            }),
114            None => Ok(None),
115        },
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn kind_of(text: &str) -> Option<SessionCommand> {
124        classify_session_command(text)
125            .ok()
126            .flatten()
127            .map(|(_, k)| k)
128    }
129
130    #[test]
131    fn classifies_every_transaction_and_session_command() {
132        assert_eq!(kind_of("BEGIN"), Some(SessionCommand::Begin));
133        assert_eq!(kind_of("BEGIN TRANSACTION"), Some(SessionCommand::Begin));
134        assert_eq!(kind_of("BEGIN READ ONLY"), Some(SessionCommand::Begin));
135        assert_eq!(kind_of("COMMIT"), Some(SessionCommand::Commit));
136        assert_eq!(kind_of("ROLLBACK"), Some(SessionCommand::Rollback));
137        assert_eq!(kind_of("ROLLBACK TO sp1"), Some(SessionCommand::Rollback));
138        assert_eq!(kind_of("SAVEPOINT sp1"), Some(SessionCommand::Savepoint));
139        assert_eq!(
140            kind_of("RELEASE SAVEPOINT sp1"),
141            Some(SessionCommand::ReleaseSavepoint)
142        );
143        assert_eq!(kind_of("LET $x = 1"), Some(SessionCommand::AssignVariable));
144        assert_eq!(
145            kind_of("LOAD EXTENSION '/tmp/a.so'"),
146            Some(SessionCommand::Extension)
147        );
148        assert_eq!(
149            kind_of("INSTALL EXTENSION my_udf FROM '/tmp/a.so'"),
150            Some(SessionCommand::Extension)
151        );
152        assert_eq!(
153            kind_of("UNINSTALL EXTENSION my_udf"),
154            Some(SessionCommand::Extension)
155        );
156        assert_eq!(
157            kind_of("UPDATE EXTENSION my_udf"),
158            Some(SessionCommand::Extension)
159        );
160    }
161
162    #[test]
163    fn regular_statements_and_non_commands_pass_through() {
164        assert_eq!(kind_of("MATCH (n) RETURN n"), None);
165        assert_eq!(kind_of("BEGINNING OF TEXT"), None);
166        assert_eq!(kind_of(""), None);
167        // Command keyword that parses into another statement kind.
168        assert_eq!(kind_of("COMMIT; MATCH (n) RETURN n"), None);
169    }
170
171    #[test]
172    fn malformed_command_reports_first_error_with_position() {
173        let error = classify_session_command("BEGIN").expect("bare BEGIN parses");
174        assert_eq!(error.map(|(_, kind)| kind), Some(SessionCommand::Begin));
175
176        let error = classify_session_command("SAVEPOINT").expect_err("savepoint needs a name");
177        assert!(!error.message.is_empty());
178        assert!(error.offset.is_some() || error.position.is_some());
179    }
180}