From 50c1b0ddc559ed149812662dd1fb3212613fec28 Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:24:29 +0800 Subject: [PATCH 1/2] fix(sql): track token consumption in the graph DSL A mistyped clause keyword (`DEPTS` for `DEPTH`) defaulted the clause and left its value unread, so the statement answered a different question than it asked, with no error. The clause readers were independent forward scans that ignored every token between a keyword and its value. A cursor now claims the tokens each clause consumes, and the dispatcher refuses the first token no clause claimed, naming it. All statement variants are covered; `MATCH` statements are untouched. --- nodedb-sql/src/ddl_ast/graph_parse/cursor.rs | 279 ++++++++++++++++++ nodedb-sql/src/ddl_ast/graph_parse/entry.rs | 46 ++- nodedb-sql/src/ddl_ast/graph_parse/helpers.rs | 76 +---- nodedb-sql/src/ddl_ast/graph_parse/mod.rs | 1 + .../src/ddl_ast/graph_parse/variants.rs | 223 +++++++++----- nodedb/tests/wire/cases/graph_dsl_handlers.rs | 36 +++ 6 files changed, 508 insertions(+), 153 deletions(-) create mode 100644 nodedb-sql/src/ddl_ast/graph_parse/cursor.rs diff --git a/nodedb-sql/src/ddl_ast/graph_parse/cursor.rs b/nodedb-sql/src/ddl_ast/graph_parse/cursor.rs new file mode 100644 index 000000000..3b6bcb775 --- /dev/null +++ b/nodedb-sql/src/ddl_ast/graph_parse/cursor.rs @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Consume-tracking cursor over a graph DSL token list. +//! +//! The module's parse is seek-based: a clause reader finds its keyword +//! wherever it appears and ignores every token between. That is fine while +//! every token belongs to some clause. It is wrong the moment a token belongs +//! to none: `GRAPH TRAVERSE FROM 1 DEPTS 3 IN g` finds no `DEPTH`, defaults +//! the depth, and leaves `DEPTS 3` unread — the statement answers a different +//! question than it asked, with no error. +//! +//! The cursor records which tokens each clause consumed. After the statement +//! is built, [`Cursor::finish`] refuses the first token no clause claimed. +//! A typo is then a parse error that names the token, and a new clause cannot +//! be added without deciding what it consumes. + +use super::tokenizer::Tok; +use crate::error::SqlError; + +/// A token list plus the set of tokens a clause has claimed. +pub(super) struct Cursor<'a> { + toks: Vec>, + used: Vec, +} + +impl<'a> Cursor<'a> { + /// Build a cursor over `toks`. The first `prefix_len` tokens are the + /// command words (`GRAPH TRAVERSE`), which the dispatcher matched and no + /// clause will claim. + pub(super) fn new(toks: Vec>, prefix_len: usize) -> Self { + let mut used = vec![false; toks.len()]; + for slot in used.iter_mut().take(prefix_len.min(toks.len())) { + *slot = true; + } + Self { toks, used } + } + + fn is_keyword(tok: &Tok<'_>, keyword: &str) -> bool { + matches!(tok, Tok::Word(w) if w.eq_ignore_ascii_case(keyword)) + } + + /// Position of `keyword`, claiming it. A keyword already claimed by an + /// earlier clause still matches: `IN` in one statement is one clause, but + /// the seek-based readers may be called in any order. + fn find(&mut self, keyword: &str) -> Option { + let pos = self + .toks + .iter() + .position(|tok| Self::is_keyword(tok, keyword))?; + self.used[pos] = true; + Some(pos) + } + + /// Claim the value token at `pos` when it is a word or a quoted literal. + /// An object literal is claimed by the callers that accept one. + fn claim_text(&mut self, pos: usize) -> Option { + let value = match self.toks.get(pos)? { + Tok::Quoted(s) => s.clone().into_owned(), + Tok::Word(w) => (*w).to_string(), + Tok::Object(_) => return None, + }; + self.used[pos] = true; + Some(value) + } + + /// The word or quoted literal after `keyword`. + pub(super) fn quoted_after(&mut self, keyword: &str) -> Option { + let pos = self.find(keyword)?; + self.claim_text(pos + 1) + } + + /// Every consecutive word or quoted literal after `keyword`, up to the + /// first token that is neither. Used by `AS