Skip to main content

mnemo_pgwire/
parser.rs

1//! Minimal SQL parser for pgwire queries.
2//!
3//! Parses a limited SQL subset and maps to Mnemo operations.
4//! This is not a full SQL parser — it handles the common patterns
5//! that clients will use to interact with the memories table.
6
7/// Parsed SQL statement mapped to a Mnemo operation.
8#[derive(Debug, Clone, PartialEq)]
9pub enum ParsedStatement {
10    /// SELECT query on the memories table.
11    Select(SelectQuery),
12    /// INSERT into the memories table.
13    Insert(InsertQuery),
14    /// DELETE from the memories table.
15    Delete(DeleteQuery),
16    /// Unrecognized or unsupported statement.
17    Unsupported(String),
18}
19
20/// A parsed SELECT statement.
21#[derive(Debug, Clone, PartialEq)]
22pub struct SelectQuery {
23    /// WHERE agent_id = '...'
24    pub agent_id: Option<String>,
25    /// WHERE content LIKE '%...%' or free-text query
26    pub query_text: Option<String>,
27    /// LIMIT clause
28    pub limit: usize,
29    /// OFFSET clause
30    pub offset: usize,
31    /// v0.4.8 — opt-in orientation-cache hint, set when the query
32    /// contains the SQL comment directive `/*+ orientation_cache */`.
33    /// When `true` the pgwire server attaches a default
34    /// `OrientationCacheConfig` to the underlying `RecallRequest`.
35    pub orientation_cache: bool,
36    /// v0.5.1 — opt-in active-reconstruction hint, set when the query
37    /// contains the SQL comment directive `/*+ reconstruct */`. When
38    /// `true` the pgwire server sets `strategy = "reconstruct"` on the
39    /// underlying `RecallRequest` (MRAgent, arXiv:2606.06036), so the
40    /// returned rows are reconstruction-strategy hits. The structured
41    /// belief-state node is surfaced on the MCP/REST/gRPC protocols.
42    pub reconstruct: bool,
43}
44
45/// A parsed INSERT statement.
46#[derive(Debug, Clone, PartialEq)]
47pub struct InsertQuery {
48    pub content: String,
49    pub agent_id: Option<String>,
50    pub importance: Option<f32>,
51    pub memory_type: Option<String>,
52    pub tags: Vec<String>,
53}
54
55/// A parsed DELETE statement.
56#[derive(Debug, Clone, PartialEq)]
57pub struct DeleteQuery {
58    /// WHERE id = '...'
59    pub memory_id: Option<String>,
60    /// WHERE agent_id = '...'
61    pub agent_id: Option<String>,
62}
63
64/// Parse a SQL string into a `ParsedStatement`.
65///
66/// Supports:
67/// - `SELECT * FROM memories [WHERE ...] [LIMIT n] [OFFSET n]`
68/// - `INSERT INTO memories (col, ...) VALUES (val, ...)`
69/// - `DELETE FROM memories WHERE id = '...'`
70pub fn parse_sql(sql: &str) -> ParsedStatement {
71    let trimmed = sql.trim().trim_end_matches(';');
72    let upper = trimmed.to_uppercase();
73
74    if upper.starts_with("SELECT") {
75        parse_select(trimmed)
76    } else if upper.starts_with("INSERT") {
77        parse_insert(trimmed)
78    } else if upper.starts_with("DELETE") {
79        parse_delete(trimmed)
80    } else {
81        ParsedStatement::Unsupported(trimmed.to_string())
82    }
83}
84
85fn parse_select(sql: &str) -> ParsedStatement {
86    let upper = sql.to_uppercase();
87    let mut query = SelectQuery {
88        agent_id: None,
89        query_text: None,
90        limit: 50,
91        offset: 0,
92        orientation_cache: upper.contains("/*+ ORIENTATION_CACHE")
93            || upper.contains("/*+ORIENTATION_CACHE"),
94        reconstruct: upper.contains("/*+ RECONSTRUCT") || upper.contains("/*+RECONSTRUCT"),
95    };
96
97    // Extract LIMIT
98    if let Some(pos) = upper.find("LIMIT") {
99        let after = &sql[pos + 5..].trim();
100        if let Some(num_str) = after.split_whitespace().next()
101            && let Ok(n) = num_str.parse::<usize>()
102        {
103            query.limit = n;
104        }
105    }
106
107    // Extract OFFSET
108    if let Some(pos) = upper.find("OFFSET") {
109        let after = &sql[pos + 6..].trim();
110        if let Some(num_str) = after.split_whitespace().next()
111            && let Ok(n) = num_str.parse::<usize>()
112        {
113            query.offset = n;
114        }
115    }
116
117    // Extract WHERE agent_id = '...'
118    if let Some(agent_id) = extract_string_condition(&upper, sql, "AGENT_ID") {
119        query.agent_id = Some(agent_id);
120    }
121
122    // Extract WHERE content LIKE '%...%'
123    if let Some(pos) = upper.find("CONTENT LIKE") {
124        let after = &sql[pos + 12..].trim();
125        if let Some(value) = extract_quoted_value(after) {
126            // Strip % wildcards
127            let clean = value.trim_matches('%').to_string();
128            if !clean.is_empty() {
129                query.query_text = Some(clean);
130            }
131        }
132    }
133
134    ParsedStatement::Select(query)
135}
136
137fn parse_insert(sql: &str) -> ParsedStatement {
138    // Extract column names and values from INSERT INTO memories (cols) VALUES (vals)
139    let upper = sql.to_uppercase();
140
141    let cols_start = match upper.find('(') {
142        Some(p) => p,
143        None => return ParsedStatement::Unsupported(sql.to_string()),
144    };
145    let cols_end = match upper[cols_start..].find(')') {
146        Some(p) => cols_start + p,
147        None => return ParsedStatement::Unsupported(sql.to_string()),
148    };
149
150    let values_marker = match upper[cols_end..].find("VALUES") {
151        Some(p) => cols_end + p,
152        None => return ParsedStatement::Unsupported(sql.to_string()),
153    };
154
155    let vals_start = match upper[values_marker..].find('(') {
156        Some(p) => values_marker + p,
157        None => return ParsedStatement::Unsupported(sql.to_string()),
158    };
159    let vals_end = match sql[vals_start..].rfind(')') {
160        Some(p) => vals_start + p,
161        None => return ParsedStatement::Unsupported(sql.to_string()),
162    };
163
164    let columns: Vec<String> = sql[cols_start + 1..cols_end]
165        .split(',')
166        .map(|c| c.trim().to_uppercase())
167        .collect();
168
169    let values: Vec<String> = split_sql_values(&sql[vals_start + 1..vals_end]);
170
171    let mut insert = InsertQuery {
172        content: String::new(),
173        agent_id: None,
174        importance: None,
175        memory_type: None,
176        tags: vec![],
177    };
178
179    for (i, col) in columns.iter().enumerate() {
180        if i >= values.len() {
181            break;
182        }
183        let val = unquote(&values[i]);
184        match col.as_str() {
185            "CONTENT" => insert.content = val,
186            "AGENT_ID" => insert.agent_id = Some(val),
187            "IMPORTANCE" => insert.importance = val.parse().ok(),
188            "MEMORY_TYPE" => insert.memory_type = Some(val),
189            _ => {}
190        }
191    }
192
193    if insert.content.is_empty() {
194        return ParsedStatement::Unsupported(sql.to_string());
195    }
196
197    ParsedStatement::Insert(insert)
198}
199
200fn parse_delete(sql: &str) -> ParsedStatement {
201    let upper = sql.to_uppercase();
202    let mut delete = DeleteQuery {
203        memory_id: None,
204        agent_id: None,
205    };
206
207    if let Some(id) = extract_string_condition(&upper, sql, "ID") {
208        delete.memory_id = Some(id);
209    }
210    if let Some(agent_id) = extract_string_condition(&upper, sql, "AGENT_ID") {
211        delete.agent_id = Some(agent_id);
212    }
213
214    ParsedStatement::Delete(delete)
215}
216
217/// Extract a string value from `WHERE column = 'value'` pattern.
218fn extract_string_condition(upper: &str, original: &str, column: &str) -> Option<String> {
219    let pattern = format!("{column} =");
220    if let Some(pos) = upper.find(&pattern) {
221        let after = &original[pos + pattern.len()..].trim_start();
222        return extract_quoted_value(after);
223    }
224    None
225}
226
227/// Extract a single-quoted string value.
228fn extract_quoted_value(s: &str) -> Option<String> {
229    let s = s.trim();
230    if let Some(stripped) = s.strip_prefix('\'')
231        && let Some(end) = stripped.find('\'')
232    {
233        return Some(stripped[..end].to_string());
234    }
235    None
236}
237
238/// Split SQL values, respecting quoted strings.
239fn split_sql_values(s: &str) -> Vec<String> {
240    let mut values = vec![];
241    let mut current = String::new();
242    let mut in_quote = false;
243
244    for ch in s.chars() {
245        match ch {
246            '\'' if !in_quote => {
247                in_quote = true;
248                current.push(ch);
249            }
250            '\'' if in_quote => {
251                in_quote = false;
252                current.push(ch);
253            }
254            ',' if !in_quote => {
255                values.push(current.trim().to_string());
256                current.clear();
257            }
258            _ => current.push(ch),
259        }
260    }
261
262    let trimmed = current.trim().to_string();
263    if !trimmed.is_empty() {
264        values.push(trimmed);
265    }
266    values
267}
268
269/// Remove surrounding quotes from a value string.
270fn unquote(s: &str) -> String {
271    let trimmed = s.trim();
272    if (trimmed.starts_with('\'') && trimmed.ends_with('\''))
273        || (trimmed.starts_with('"') && trimmed.ends_with('"'))
274    {
275        trimmed[1..trimmed.len() - 1].to_string()
276    } else {
277        trimmed.to_string()
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_parse_select_basic() {
287        let stmt = parse_sql("SELECT * FROM memories LIMIT 10");
288        match stmt {
289            ParsedStatement::Select(q) => {
290                assert_eq!(q.limit, 10);
291                assert_eq!(q.offset, 0);
292                assert!(q.agent_id.is_none());
293            }
294            other => panic!("Expected Select, got {:?}", other),
295        }
296    }
297
298    #[test]
299    fn test_parse_select_with_where() {
300        let stmt = parse_sql("SELECT * FROM memories WHERE agent_id = 'bot-1' LIMIT 5");
301        match stmt {
302            ParsedStatement::Select(q) => {
303                assert_eq!(q.agent_id.as_deref(), Some("bot-1"));
304                assert_eq!(q.limit, 5);
305            }
306            other => panic!("Expected Select, got {:?}", other),
307        }
308    }
309
310    #[test]
311    fn test_parse_select_with_like() {
312        let stmt = parse_sql("SELECT * FROM memories WHERE content LIKE '%hello%' LIMIT 20");
313        match stmt {
314            ParsedStatement::Select(q) => {
315                assert_eq!(q.query_text.as_deref(), Some("hello"));
316                assert_eq!(q.limit, 20);
317            }
318            other => panic!("Expected Select, got {:?}", other),
319        }
320    }
321
322    #[test]
323    fn test_parse_insert() {
324        let stmt =
325            parse_sql("INSERT INTO memories (content, importance) VALUES ('test memory', 0.8)");
326        match stmt {
327            ParsedStatement::Insert(q) => {
328                assert_eq!(q.content, "test memory");
329                assert_eq!(q.importance, Some(0.8));
330            }
331            other => panic!("Expected Insert, got {:?}", other),
332        }
333    }
334
335    #[test]
336    fn test_parse_insert_with_agent() {
337        let stmt = parse_sql(
338            "INSERT INTO memories (content, agent_id, memory_type) VALUES ('data', 'agent-1', 'episodic')",
339        );
340        match stmt {
341            ParsedStatement::Insert(q) => {
342                assert_eq!(q.content, "data");
343                assert_eq!(q.agent_id.as_deref(), Some("agent-1"));
344                assert_eq!(q.memory_type.as_deref(), Some("episodic"));
345            }
346            other => panic!("Expected Insert, got {:?}", other),
347        }
348    }
349
350    #[test]
351    fn test_parse_delete() {
352        let stmt =
353            parse_sql("DELETE FROM memories WHERE id = '550e8400-e29b-41d4-a716-446655440000'");
354        match stmt {
355            ParsedStatement::Delete(q) => {
356                assert_eq!(
357                    q.memory_id.as_deref(),
358                    Some("550e8400-e29b-41d4-a716-446655440000")
359                );
360            }
361            other => panic!("Expected Delete, got {:?}", other),
362        }
363    }
364
365    #[test]
366    fn test_parse_unsupported() {
367        let stmt = parse_sql("DROP TABLE memories");
368        assert!(matches!(stmt, ParsedStatement::Unsupported(_)));
369    }
370
371    #[test]
372    fn test_parse_select_with_offset() {
373        let stmt = parse_sql("SELECT * FROM memories LIMIT 10 OFFSET 20");
374        match stmt {
375            ParsedStatement::Select(q) => {
376                assert_eq!(q.limit, 10);
377                assert_eq!(q.offset, 20);
378            }
379            other => panic!("Expected Select, got {:?}", other),
380        }
381    }
382}