From c5085c7db25cc4f5c95a685837a496184af6b0c2 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 13:31:36 +0000 Subject: [PATCH 1/2] fix MySQL SET expression parser gaps --- include/sql_parser/emitter.h | 93 +++++++++++++++++- include/sql_parser/expression_parser.h | 130 +++++++++++++++++++++++-- include/sql_parser/keywords_mysql.h | 7 ++ include/sql_parser/set_parser.h | 36 ++++++- include/sql_parser/token.h | 5 +- include/sql_parser/tokenizer.h | 2 + src/sql_parser/parser.cpp | 1 + tests/test_emitter.cpp | 75 ++++++++++++++ tests/test_set.cpp | 81 +++++++++++++++ 9 files changed, 416 insertions(+), 14 deletions(-) diff --git a/include/sql_parser/emitter.h b/include/sql_parser/emitter.h index 9e72447..8263fff 100644 --- a/include/sql_parser/emitter.h +++ b/include/sql_parser/emitter.h @@ -122,7 +122,7 @@ class Emitter { case NodeType::NODE_ARRAY_CONSTRUCTOR: emit_array_constructor(node); break; case NodeType::NODE_ARRAY_SUBSCRIPT: emit_array_subscript(node); break; case NodeType::NODE_FIELD_ACCESS: emit_field_access(node); break; - case NodeType::NODE_SUBQUERY: emit_value(node); break; + case NodeType::NODE_SUBQUERY: emit_subquery(node); break; // ---- Leaf nodes (emit value directly) ---- case NodeType::NODE_PLACEHOLDER: @@ -1061,10 +1061,28 @@ class Emitter { } void emit_unary_op(const AstNode* node) { + const AstNode* child = node->first_child; + if (is_not_operator(node) && child) { + if (child->type == NodeType::NODE_IN_LIST) { + emit_not_in_list(child); + return; + } + if (child->type == NodeType::NODE_BETWEEN) { + emit_not_between(child); + return; + } + if (child->type == NodeType::NODE_BINARY_OP && + (child->value().equals_ci("LIKE", 4) || + child->value().equals_ci("REGEXP", 6))) { + emit_not_binary_op(child); + return; + } + } + emit_value(node); // Add space for keyword operators like NOT, no space for - or + if (node->value_len > 1) sb_.append_char(' '); - if (node->first_child) emit_node(node->first_child); + if (child) emit_node(child); } void emit_function_call(const AstNode* node) { @@ -1102,13 +1120,61 @@ class Emitter { void emit_in_list(const AstNode* node) { const AstNode* expr = node->first_child; + const AstNode* first_val = expr ? expr->next_sibling : nullptr; if (expr) emit_node(expr); + if (first_val && first_val->type == NodeType::NODE_SUBQUERY && + !first_val->next_sibling) { + sb_.append(" IN "); + emit_node(first_val); + return; + } sb_.append(" IN ("); if (mode_ == EmitMode::DIGEST) { sb_.append_char('?'); } else { bool first = true; - for (const AstNode* val = expr ? expr->next_sibling : nullptr; val; val = val->next_sibling) { + for (const AstNode* val = first_val; val; val = val->next_sibling) { + if (!first) sb_.append(", "); + first = false; + emit_node(val); + } + } + sb_.append_char(')'); + } + + void emit_subquery(const AstNode* node) { + if (node->flags == 1) { + sb_.append("EXISTS "); + } + sb_.append_char('('); + if (node->first_child) { + emit_node(node->first_child); + } else { + emit_value(node); + } + sb_.append_char(')'); + } + + bool is_not_operator(const AstNode* node) const { + return node && node->value().equals_ci("NOT", 3); + } + + void emit_not_in_list(const AstNode* node) { + const AstNode* expr = node->first_child; + const AstNode* first_val = expr ? expr->next_sibling : nullptr; + if (expr) emit_node(expr); + if (first_val && first_val->type == NodeType::NODE_SUBQUERY && + !first_val->next_sibling) { + sb_.append(" NOT IN "); + emit_node(first_val); + return; + } + sb_.append(" NOT IN ("); + if (mode_ == EmitMode::DIGEST) { + sb_.append_char('?'); + } else { + bool first = true; + for (const AstNode* val = first_val; val; val = val->next_sibling) { if (!first) sb_.append(", "); first = false; emit_node(val); @@ -1117,6 +1183,27 @@ class Emitter { sb_.append_char(')'); } + void emit_not_between(const AstNode* node) { + const AstNode* expr = node->first_child; + const AstNode* low = expr ? expr->next_sibling : nullptr; + const AstNode* high = low ? low->next_sibling : nullptr; + if (expr) emit_node(expr); + sb_.append(" NOT BETWEEN "); + if (low) emit_node(low); + sb_.append(" AND "); + if (high) emit_node(high); + } + + void emit_not_binary_op(const AstNode* node) { + const AstNode* left = node->first_child; + const AstNode* right = left ? left->next_sibling : nullptr; + if (left) emit_node(left); + sb_.append(" NOT "); + emit_value(node); + sb_.append_char(' '); + if (right) emit_node(right); + } + void emit_case_when(const AstNode* node) { sb_.append("CASE "); for (const AstNode* child = node->first_child; child; child = child->next_sibling) { diff --git a/include/sql_parser/expression_parser.h b/include/sql_parser/expression_parser.h index 8eeb50e..f6e412c 100644 --- a/include/sql_parser/expression_parser.h +++ b/include/sql_parser/expression_parser.h @@ -13,9 +13,14 @@ namespace sql_parser { enum class Precedence : uint8_t { NONE = 0, OR, // OR + XOR, // XOR AND, // AND NOT, // NOT (prefix) COMPARISON, // =, <, >, <=, >=, !=, <>, IS, LIKE, IN, BETWEEN + BIT_OR, // | + BIT_XOR, // ^ + BIT_AND, // & + SHIFT, // <<, >> ADDITION, // +, - MULTIPLICATION,// *, /, % UNARY, // - (prefix), NOT @@ -77,7 +82,12 @@ class ExpressionParser { if (tok_.peek().type == TokenType::TK_RPAREN) tok_.skip(); } else { // Legacy: skip to matching paren - skip_to_matching_paren(); + const char* start = tok_.peek().text.ptr; + const char* end = skip_to_matching_paren(); + if (start && end && end >= start) { + node->set_value(StringRef{start, + static_cast(end - start)}); + } } return node; } @@ -112,6 +122,12 @@ class ExpressionParser { tok_.skip(); return make_node(arena_, NodeType::NODE_IDENTIFIER, t.text); } + case TokenType::TK_INTERVAL: { + return parse_interval_literal(t); + } + case TokenType::TK_ALL: { + return parse_quantified_subquery(t); + } case TokenType::TK_ASTERISK: { tok_.skip(); return make_node(arena_, NodeType::NODE_ASTERISK, t.text); @@ -336,6 +352,7 @@ class ExpressionParser { static Precedence infix_precedence(TokenType type) { switch (type) { case TokenType::TK_OR: return Precedence::OR; + case TokenType::TK_XOR: return Precedence::XOR; case TokenType::TK_AND: return Precedence::AND; case TokenType::TK_NOT: return Precedence::COMPARISON; // NOT IN/BETWEEN/LIKE case TokenType::TK_EQUAL: @@ -344,15 +361,25 @@ class ExpressionParser { case TokenType::TK_GREATER: case TokenType::TK_LESS_EQUAL: case TokenType::TK_GREATER_EQUAL: + case TokenType::TK_REGEXP: + case TokenType::TK_SOUNDS: + case TokenType::TK_MEMBER: case TokenType::TK_LIKE: return Precedence::COMPARISON; case TokenType::TK_IS: return Precedence::COMPARISON; case TokenType::TK_IN: return Precedence::COMPARISON; case TokenType::TK_BETWEEN: return Precedence::COMPARISON; + case TokenType::TK_PIPE: return Precedence::BIT_OR; + case TokenType::TK_CARET: return Precedence::BIT_XOR; + case TokenType::TK_AMPERSAND: return Precedence::BIT_AND; + case TokenType::TK_SHIFT_LEFT: + case TokenType::TK_SHIFT_RIGHT: return Precedence::SHIFT; case TokenType::TK_PLUS: case TokenType::TK_MINUS: return Precedence::ADDITION; case TokenType::TK_ASTERISK: case TokenType::TK_SLASH: - case TokenType::TK_PERCENT: return Precedence::MULTIPLICATION; + case TokenType::TK_PERCENT: + case TokenType::TK_DIV: + case TokenType::TK_MOD: return Precedence::MULTIPLICATION; case TokenType::TK_DOUBLE_PIPE: return Precedence::ADDITION; // string concat default: return Precedence::NONE; } @@ -363,7 +390,7 @@ class ExpressionParser { switch (op.type) { case TokenType::TK_NOT: { - // NOT IN / NOT BETWEEN / NOT LIKE — compound negated infix + // NOT IN / NOT BETWEEN / NOT LIKE / NOT REGEXP — compound negated infix Token actual_op = tok_.peek(); if (actual_op.type == TokenType::TK_IN) { tok_.skip(); @@ -380,7 +407,8 @@ class ExpressionParser { not_node->add_child(between_node); return not_node; } - if (actual_op.type == TokenType::TK_LIKE) { + if (actual_op.type == TokenType::TK_LIKE || + actual_op.type == TokenType::TK_REGEXP) { tok_.skip(); AstNode* right = parse(prec); AstNode* like_node = make_node(arena_, NodeType::NODE_BINARY_OP, actual_op.text); @@ -422,6 +450,10 @@ class ExpressionParser { return parse_in(left); case TokenType::TK_BETWEEN: return parse_between(left); + case TokenType::TK_SOUNDS: + return parse_sounds_like(left, op, prec); + case TokenType::TK_MEMBER: + return parse_member_of(left, op, prec); default: { // Standard binary operator AstNode* right = parse(prec); @@ -459,6 +491,54 @@ class ExpressionParser { return node; } + AstNode* parse_sounds_like(AstNode* left, const Token& sounds, Precedence prec) { + Token like = tok_.peek(); + StringRef op_text = sounds.text; + if (like.type == TokenType::TK_LIKE) { + tok_.skip(); + op_text = StringRef{sounds.text.ptr, + static_cast((like.text.ptr + like.text.len) - sounds.text.ptr)}; + } + AstNode* right = parse(prec); + AstNode* node = make_node(arena_, NodeType::NODE_BINARY_OP, op_text); + node->add_child(left); + if (right) node->add_child(right); + return node; + } + + AstNode* parse_member_of(AstNode* left, const Token& member, Precedence prec) { + Token of = tok_.peek(); + StringRef op_text = member.text; + if (of.type == TokenType::TK_OF) { + tok_.skip(); + op_text = StringRef{member.text.ptr, + static_cast((of.text.ptr + of.text.len) - member.text.ptr)}; + } + + AstNode* right = nullptr; + if (tok_.peek().type == TokenType::TK_LPAREN) { + tok_.skip(); + AstNode* tuple = make_node(arena_, NodeType::NODE_TUPLE); + if (tok_.peek().type != TokenType::TK_RPAREN) { + while (true) { + AstNode* elem = parse(); + if (elem) tuple->add_child(elem); + if (tok_.peek().type == TokenType::TK_COMMA) tok_.skip(); + else break; + } + } + if (tok_.peek().type == TokenType::TK_RPAREN) tok_.skip(); + right = tuple; + } else { + right = parse(prec); + } + + AstNode* node = make_node(arena_, NodeType::NODE_BINARY_OP, op_text); + node->add_child(left); + if (right) node->add_child(right); + return node; + } + // BETWEEN low AND high AstNode* parse_between(AstNode* left) { AstNode* node = make_node(arena_, NodeType::NODE_BETWEEN); @@ -473,6 +553,38 @@ class ExpressionParser { return node; } + AstNode* parse_interval_literal(const Token& interval) { + tok_.skip(); + Token amount = tok_.next_token(); + if (amount.type == TokenType::TK_EOF || amount.type == TokenType::TK_ERROR) { + return make_node(arena_, NodeType::NODE_IDENTIFIER, interval.text); + } + Token unit = tok_.next_token(); + if (unit.type == TokenType::TK_EOF || unit.type == TokenType::TK_ERROR) { + StringRef span{interval.text.ptr, + static_cast((amount.text.ptr + amount.text.len) - interval.text.ptr)}; + return make_node(arena_, NodeType::NODE_IDENTIFIER, span); + } + StringRef span{interval.text.ptr, + static_cast((unit.text.ptr + unit.text.len) - interval.text.ptr)}; + return make_node(arena_, NodeType::NODE_IDENTIFIER, span); + } + + AstNode* parse_quantified_subquery(const Token& quantifier) { + tok_.skip(); + if (tok_.peek().type == TokenType::TK_LPAREN) { + tok_.skip(); + if (tok_.peek().type == TokenType::TK_SELECT) { + const char* close = skip_to_matching_paren(); + const char* end = close ? close + 1 : tok_.input_end(); + return make_node(arena_, NodeType::NODE_IDENTIFIER, + StringRef{quantifier.text.ptr, + static_cast(end - quantifier.text.ptr)}); + } + } + return make_node(arena_, NodeType::NODE_IDENTIFIER, quantifier.text); + } + // CASE [expr] WHEN ... THEN ... [ELSE ...] END AstNode* parse_case() { AstNode* node = make_node(arena_, NodeType::NODE_CASE_WHEN); @@ -607,14 +719,18 @@ class ExpressionParser { } // Skip tokens until matching closing paren (handles nesting) - void skip_to_matching_paren() { + const char* skip_to_matching_paren() { int depth = 1; while (depth > 0) { Token t = tok_.next_token(); if (t.type == TokenType::TK_LPAREN) ++depth; - else if (t.type == TokenType::TK_RPAREN) --depth; + else if (t.type == TokenType::TK_RPAREN) { + --depth; + if (depth == 0) return t.text.ptr; + } else if (t.type == TokenType::TK_EOF) break; } + return tok_.input_end(); } // Some keywords can appear as identifiers in expression context @@ -698,6 +814,8 @@ class ExpressionParser { case TokenType::TK_PARTITION: case TokenType::TK_RECURSIVE: case TokenType::TK_REPLACE: + case TokenType::TK_DIV: + case TokenType::TK_MOD: return true; default: return false; diff --git a/include/sql_parser/keywords_mysql.h b/include/sql_parser/keywords_mysql.h index 159a622..a9c700b 100644 --- a/include/sql_parser/keywords_mysql.h +++ b/include/sql_parser/keywords_mysql.h @@ -53,6 +53,7 @@ inline constexpr KeywordEntry KEYWORDS[] = { {"DESC", 4, TokenType::TK_DESC}, {"DESCRIBE", 8, TokenType::TK_DESCRIBE}, {"DISTINCT", 8, TokenType::TK_DISTINCT}, + {"DIV", 3, TokenType::TK_DIV}, {"DO", 2, TokenType::TK_DO}, {"DROP", 4, TokenType::TK_DROP}, {"DUMPFILE", 8, TokenType::TK_DUMPFILE}, @@ -85,6 +86,7 @@ inline constexpr KeywordEntry KEYWORDS[] = { {"INFILE", 6, TokenType::TK_INFILE}, {"INNER", 5, TokenType::TK_INNER}, {"INSERT", 6, TokenType::TK_INSERT}, + {"INTERVAL", 8, TokenType::TK_INTERVAL}, {"INTERSECT", 9, TokenType::TK_INTERSECT}, {"INTO", 4, TokenType::TK_INTO}, {"IS", 2, TokenType::TK_IS}, @@ -105,7 +107,9 @@ inline constexpr KeywordEntry KEYWORDS[] = { {"LOCKED", 6, TokenType::TK_LOCKED}, {"LOW_PRIORITY", 12, TokenType::TK_LOW_PRIORITY}, {"MAX", 3, TokenType::TK_MAX}, + {"MEMBER", 6, TokenType::TK_MEMBER}, {"MIN", 3, TokenType::TK_MIN}, + {"MOD", 3, TokenType::TK_MOD}, {"NAMES", 5, TokenType::TK_NAMES}, {"NATURAL", 7, TokenType::TK_NATURAL}, {"NOT", 3, TokenType::TK_NOT}, @@ -135,6 +139,7 @@ inline constexpr KeywordEntry KEYWORDS[] = { {"RESET", 5, TokenType::TK_RESET}, {"RETURNING", 9, TokenType::TK_RETURNING}, {"REVOKE", 6, TokenType::TK_REVOKE}, + {"REGEXP", 6, TokenType::TK_REGEXP}, {"RIGHT", 5, TokenType::TK_RIGHT}, {"ROLLBACK", 8, TokenType::TK_ROLLBACK}, {"ROW", 3, TokenType::TK_ROW}, @@ -150,6 +155,7 @@ inline constexpr KeywordEntry KEYWORDS[] = { {"SHARE", 5, TokenType::TK_SHARE}, {"SHOW", 4, TokenType::TK_SHOW}, {"SKIP", 4, TokenType::TK_SKIP}, + {"SOUNDS", 6, TokenType::TK_SOUNDS}, {"SQL_CALC_FOUND_ROWS", 19, TokenType::TK_SQL_CALC_FOUND_ROWS}, {"START", 5, TokenType::TK_START}, {"STARTING", 8, TokenType::TK_STARTING}, @@ -177,6 +183,7 @@ inline constexpr KeywordEntry KEYWORDS[] = { {"WHERE", 5, TokenType::TK_WHERE}, {"WITH", 4, TokenType::TK_WITH}, {"WRITE", 5, TokenType::TK_WRITE}, + {"XOR", 3, TokenType::TK_XOR}, }; inline constexpr size_t KEYWORD_COUNT = sizeof(KEYWORDS) / sizeof(KEYWORDS[0]); diff --git a/include/sql_parser/set_parser.h b/include/sql_parser/set_parser.h index 0d382ba..c734c52 100644 --- a/include/sql_parser/set_parser.h +++ b/include/sql_parser/set_parser.h @@ -18,6 +18,10 @@ class SetParser { SetParser(Tokenizer& tokenizer, Arena& arena) : tok_(tokenizer), arena_(arena), expr_parser_(tokenizer, arena) {} + void set_subquery_callback(SubqueryParseCallback cb) { + expr_parser_.set_subquery_callback(cb); + } + // Parse a SET statement (SET keyword already consumed by classifier). // Returns the root NODE_SET_STMT node, or nullptr on failure. AstNode* parse() { @@ -79,7 +83,7 @@ class SetParser { return root; } - if (next.type == TokenType::TK_GLOBAL || next.type == TokenType::TK_SESSION) { + if (is_set_assignment_scope_(next)) { Token scope_tok = tok_.next_token(); if (tok_.peek().type == TokenType::TK_TRANSACTION) { tok_.skip(); @@ -434,6 +438,10 @@ class SetParser { AstNode* parse_comma_item() { Token peek = tok_.peek(); + if (is_set_assignment_scope_(peek)) { + Token scope_tok = tok_.next_token(); + return parse_variable_assignment(&scope_tok); + } if (peek.type == TokenType::TK_NAMES) { tok_.skip(); return parse_set_names(); @@ -529,7 +537,7 @@ class SetParser { } // Parse a single variable assignment: [scope] target = expr - // scope_token is non-null if GLOBAL/SESSION/LOCAL was already consumed + // scope_token is non-null if a SET-assignment scope was already consumed. AstNode* parse_variable_assignment(const Token* scope_token) { AstNode* assignment = make_node(arena_, NodeType::NODE_VAR_ASSIGNMENT); if (!assignment) return nullptr; @@ -552,8 +560,15 @@ class SetParser { // `@name` form in the arena instead, dropping the delimiters. tok_.skip(); Token name = tok_.next_token(); - target->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, - build_scoped_identifier_("@", name))); + if (tok_.peek().type == TokenType::TK_DOT) { + tok_.skip(); + Token actual_name = tok_.next_token(); + target->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, + build_scoped_dotted_identifier_("@", name, actual_name))); + } else { + target->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, + build_scoped_identifier_("@", name))); + } } else if (var.type == TokenType::TK_DOUBLE_AT) { // System variable @@[scope.]name -- same delimiter handling // concern as the @name branch above. Allocate `@@name` (or @@ -632,6 +647,19 @@ class SetParser { return assignment; } + + static bool is_set_assignment_scope_(const Token& tok) { + if (tok.type == TokenType::TK_GLOBAL || tok.type == TokenType::TK_SESSION) { + return true; + } + if constexpr (D == Dialect::MySQL) { + return tok.type == TokenType::TK_LOCAL || + tok.type == TokenType::TK_PERSIST || + (tok.type == TokenType::TK_IDENTIFIER && + tok.text.equals_ci("PERSIST_ONLY", 12)); + } + return false; + } }; } // namespace sql_parser diff --git a/include/sql_parser/token.h b/include/sql_parser/token.h index b10742c..2643a97 100644 --- a/include/sql_parser/token.h +++ b/include/sql_parser/token.h @@ -34,6 +34,8 @@ enum class TokenType : uint16_t { TK_AMPERSAND, TK_PIPE, TK_CARET, + TK_SHIFT_LEFT, + TK_SHIFT_RIGHT, TK_TILDE, TK_EXCLAIM, TK_COLON, @@ -50,7 +52,8 @@ enum class TokenType : uint16_t { TK_JOIN, TK_INNER, TK_LEFT, TK_RIGHT, TK_FULL, TK_OUTER, TK_CROSS, TK_NATURAL, TK_ORDER, TK_BY, TK_GROUP, TK_HAVING, TK_LIMIT, TK_OFFSET, TK_FETCH, TK_ASC, TK_DESC, TK_DISTINCT, TK_ALL, - TK_AND, TK_OR, TK_NOT, TK_IS, TK_NULL, TK_IN, TK_BETWEEN, TK_LIKE, TK_EXISTS, + TK_AND, TK_OR, TK_XOR, TK_NOT, TK_IS, TK_NULL, TK_IN, TK_BETWEEN, TK_LIKE, TK_EXISTS, + TK_DIV, TK_MOD, TK_REGEXP, TK_SOUNDS, TK_MEMBER, TK_INTERVAL, TK_CASE, TK_WHEN, TK_THEN, TK_ELSE, TK_END, TK_TRUE, TK_FALSE, TK_NAMES, TK_CHARACTER, TK_CHARSET, TK_COLLATE, TK_GLOBAL, TK_SESSION, TK_LOCAL, TK_PERSIST, TK_DEFAULT, TK_TRANSACTION, TK_ISOLATION, TK_LEVEL, diff --git a/include/sql_parser/tokenizer.h b/include/sql_parser/tokenizer.h index 2b27a14..e49328a 100644 --- a/include/sql_parser/tokenizer.h +++ b/include/sql_parser/tokenizer.h @@ -374,6 +374,8 @@ class Tokenizer { if (cursor_ + 1 < end_) { char c2 = peek_char(1); + if (c == '<' && c2 == '<') { auto s = cursor_; cursor_ += 2; return make_token(TokenType::TK_SHIFT_LEFT, s, 2); } + if (c == '>' && c2 == '>') { auto s = cursor_; cursor_ += 2; return make_token(TokenType::TK_SHIFT_RIGHT, s, 2); } if (c == '<' && c2 == '=') { auto s = cursor_; cursor_ += 2; return make_token(TokenType::TK_LESS_EQUAL, s, 2); } if (c == '>' && c2 == '=') { auto s = cursor_; cursor_ += 2; return make_token(TokenType::TK_GREATER_EQUAL, s, 2); } if (c == '!' && c2 == '=') { auto s = cursor_; cursor_ += 2; return make_token(TokenType::TK_NOT_EQUAL, s, 2); } diff --git a/src/sql_parser/parser.cpp b/src/sql_parser/parser.cpp index a8d196e..ae9c383 100644 --- a/src/sql_parser/parser.cpp +++ b/src/sql_parser/parser.cpp @@ -293,6 +293,7 @@ ParseResult Parser::parse_set() { r.stmt_type = StmtType::SET; SetParser set_parser(tokenizer_, arena_); + set_parser.set_subquery_callback(&parse_subquery_select); AstNode* ast = set_parser.parse(); if (ast && ast->first_child) { diff --git a/tests/test_emitter.cpp b/tests/test_emitter.cpp index 5428b92..f0a8774 100644 --- a/tests/test_emitter.cpp +++ b/tests/test_emitter.cpp @@ -62,6 +62,21 @@ TEST_F(MySQLEmitterTest, SetSessionVariable) { EXPECT_EQ(out, "SET SESSION wait_timeout = 600"); } +TEST_F(MySQLEmitterTest, SetPersistVariable) { + std::string out = round_trip("SET PERSIST max_allowed_packet = 1073741824"); + EXPECT_EQ(out, "SET PERSIST max_allowed_packet = 1073741824"); +} + +TEST_F(MySQLEmitterTest, SetPersistOnlyVariable) { + std::string out = round_trip("SET PERSIST_ONLY sql_mode = 'STRICT_TRANS_TABLES'"); + EXPECT_EQ(out, "SET PERSIST_ONLY sql_mode = 'STRICT_TRANS_TABLES'"); +} + +TEST_F(MySQLEmitterTest, SetLocalVariable) { + std::string out = round_trip("SET LOCAL wait_timeout = 10"); + EXPECT_EQ(out, "SET LOCAL wait_timeout = 10"); +} + TEST_F(MySQLEmitterTest, SetDoubleAtVariable) { std::string out = round_trip("SET @@session.wait_timeout = 600"); EXPECT_EQ(out, "SET @@session.wait_timeout = 600"); @@ -72,6 +87,16 @@ TEST_F(MySQLEmitterTest, SetUserVariable) { EXPECT_EQ(out, "SET @my_var = 42"); } +TEST_F(MySQLEmitterTest, SetDottedUserVariable) { + std::string out = round_trip("SET @user.var = 7"); + EXPECT_EQ(out, "SET @user.var = 7"); +} + +TEST_F(MySQLEmitterTest, SetScopedCommaItemAfterUserVariable) { + std::string out = round_trip("SET @'mix' := 1, LOCAL wait_timeout := 20"); + EXPECT_EQ(out, "SET @mix = 1, LOCAL wait_timeout = 20"); +} + TEST_F(MySQLEmitterTest, SetTransaction) { std::string out = round_trip("SET TRANSACTION READ ONLY"); EXPECT_EQ(out, "SET TRANSACTION READ ONLY"); @@ -90,6 +115,56 @@ TEST_F(MySQLEmitterTest, SetFunctionRHS) { EXPECT_EQ(out, "SET sql_mode = CONCAT(@@sql_mode, ',STRICT_TRANS_TABLES')"); } +TEST_F(MySQLEmitterTest, SetRhsMysqlExpressionOperatorsRoundTrip) { + struct Case { + const char* sql; + const char* description; + }; + + const Case cases[] = { + {"SET @generic_var = TRUE XOR FALSE", "xor"}, + {"SET @generic_var = 5 | 2", "bit or"}, + {"SET @generic_var = 5 & 2", "bit and"}, + {"SET @generic_var = 5 << 1", "shift left"}, + {"SET @generic_var = 10 >> 1", "shift right"}, + {"SET @generic_var = 5 ^ 2", "bit xor"}, + {"SET @generic_var = 10 DIV 3", "div"}, + {"SET @generic_var = 10 MOD 3", "mod"}, + {"SET @generic_var = 'abcde' REGEXP '^a.c'", "regexp"}, + {"SET @generic_var = 'xyz123' NOT REGEXP '[0-9]$'", "not regexp"}, + {"SET @generic_var = 'b' MEMBER OF ('[\"a\", \"b\", \"c\"]')", "member of"}, + {"SET @generic_var = 'knight' SOUNDS LIKE 'night'", "sounds like"}, + {"SET @generic_var = NOW() + INTERVAL 1 DAY", "plus interval"}, + {"SET @generic_var = '2025-12-25' - INTERVAL 2 MONTH", "minus interval"}, + }; + + for (const auto& tc : cases) { + std::string out = round_trip(tc.sql); + EXPECT_EQ(out, std::string(tc.sql)) + << "Round-trip mismatch: " << tc.description; + } +} + +TEST_F(MySQLEmitterTest, SetRhsSubqueryExpressionsRoundTrip) { + struct Case { + const char* sql; + const char* description; + }; + + const Case cases[] = { + {"SET @generic_var = current_user_id IN (SELECT user_id FROM course_enrollments WHERE course_id = 789)", "in subquery"}, + {"SET @generic_var = my_value > ALL (SELECT limit_value FROM active_limits WHERE group_id = 'A')", "all subquery"}, + {"SET @generic_var = 'PROD123' NOT IN (SELECT product_sku FROM discontinued_products WHERE reason_code = 'OBSOLETE')", "not in subquery"}, + {"SET @generic_var = (SELECT SUM(amount) FROM sales WHERE sale_date = CURDATE())", "scalar subquery"}, + }; + + for (const auto& tc : cases) { + std::string out = round_trip(tc.sql); + EXPECT_EQ(out, std::string(tc.sql)) + << "Round-trip mismatch: " << tc.description; + } +} + // ========== SELECT round-trips ========== TEST_F(MySQLEmitterTest, SelectLiteral) { diff --git a/tests/test_set.cpp b/tests/test_set.cpp index e19a8cc..f4a2541 100644 --- a/tests/test_set.cpp +++ b/tests/test_set.cpp @@ -1,6 +1,7 @@ #include #include "sql_parser/parser.h" #include +#include using namespace sql_parser; @@ -336,6 +337,11 @@ class MySQLSetTest : public ::testing::Test { for (const AstNode* c = node->first_child; c; c = c->next_sibling) ++n; return n; } + + std::string value(const AstNode* node) { + if (!node || !node->value_ptr) return {}; + return std::string(node->value_ptr, node->value_len); + } }; TEST_F(MySQLSetTest, SetSimpleVariable) { @@ -374,6 +380,51 @@ TEST_F(MySQLSetTest, SetGlobalVariable) { EXPECT_EQ(target->type, NodeType::NODE_VAR_TARGET); } +TEST_F(MySQLSetTest, SetPersistScopeVariable) { + const char* sql = "SET PERSIST max_allowed_packet = 1073741824"; + auto r = parser.parse(sql, strlen(sql)); + EXPECT_EQ(r.status, ParseResult::OK); + ASSERT_NE(r.ast, nullptr); + ASSERT_NE(r.ast->first_child, nullptr); + + AstNode* target = r.ast->first_child->first_child; + ASSERT_NE(target, nullptr); + ASSERT_NE(target->first_child, nullptr); + EXPECT_EQ(value(target->first_child), "PERSIST"); + ASSERT_NE(target->first_child->next_sibling, nullptr); + EXPECT_EQ(value(target->first_child->next_sibling), "max_allowed_packet"); +} + +TEST_F(MySQLSetTest, SetPersistOnlyScopeVariable) { + const char* sql = "SET PERSIST_ONLY sql_mode = 'STRICT_TRANS_TABLES'"; + auto r = parser.parse(sql, strlen(sql)); + EXPECT_EQ(r.status, ParseResult::OK); + ASSERT_NE(r.ast, nullptr); + ASSERT_NE(r.ast->first_child, nullptr); + + AstNode* target = r.ast->first_child->first_child; + ASSERT_NE(target, nullptr); + ASSERT_NE(target->first_child, nullptr); + EXPECT_EQ(value(target->first_child), "PERSIST_ONLY"); + ASSERT_NE(target->first_child->next_sibling, nullptr); + EXPECT_EQ(value(target->first_child->next_sibling), "sql_mode"); +} + +TEST_F(MySQLSetTest, SetLocalScopeVariable) { + const char* sql = "SET LOCAL wait_timeout = 10"; + auto r = parser.parse(sql, strlen(sql)); + EXPECT_EQ(r.status, ParseResult::OK); + ASSERT_NE(r.ast, nullptr); + ASSERT_NE(r.ast->first_child, nullptr); + + AstNode* target = r.ast->first_child->first_child; + ASSERT_NE(target, nullptr); + ASSERT_NE(target->first_child, nullptr); + EXPECT_EQ(value(target->first_child), "LOCAL"); + ASSERT_NE(target->first_child->next_sibling, nullptr); + EXPECT_EQ(value(target->first_child->next_sibling), "wait_timeout"); +} + TEST_F(MySQLSetTest, SetSessionVariable) { auto r = parser.parse("SET SESSION wait_timeout = 600", 30); EXPECT_EQ(r.status, ParseResult::OK); @@ -392,6 +443,36 @@ TEST_F(MySQLSetTest, SetUserVariable) { ASSERT_NE(r.ast, nullptr); } +TEST_F(MySQLSetTest, SetDottedUserVariable) { + const char* sql = "SET @user.var := 7"; + auto r = parser.parse(sql, strlen(sql)); + EXPECT_EQ(r.status, ParseResult::OK); + ASSERT_NE(r.ast, nullptr); + ASSERT_NE(r.ast->first_child, nullptr); + + AstNode* target = r.ast->first_child->first_child; + ASSERT_NE(target, nullptr); + ASSERT_NE(target->first_child, nullptr); + EXPECT_EQ(value(target->first_child), "@user.var"); +} + +TEST_F(MySQLSetTest, SetScopedCommaItemAfterUserVariable) { + const char* sql = "SET @'mix' := 1, LOCAL wait_timeout := 20"; + auto r = parser.parse(sql, strlen(sql)); + EXPECT_EQ(r.status, ParseResult::OK); + ASSERT_NE(r.ast, nullptr); + EXPECT_EQ(child_count(r.ast), 2); + + AstNode* second_assignment = r.ast->first_child->next_sibling; + ASSERT_NE(second_assignment, nullptr); + AstNode* target = second_assignment->first_child; + ASSERT_NE(target, nullptr); + ASSERT_NE(target->first_child, nullptr); + EXPECT_EQ(value(target->first_child), "LOCAL"); + ASSERT_NE(target->first_child->next_sibling, nullptr); + EXPECT_EQ(value(target->first_child->next_sibling), "wait_timeout"); +} + TEST_F(MySQLSetTest, SetNames) { auto r = parser.parse("SET NAMES utf8mb4", 17); EXPECT_EQ(r.status, ParseResult::OK); From 4c74bee0f83acbcf84e284d056d6e1c5ef84edf3 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 14:23:59 +0000 Subject: [PATCH 2/2] fix clang build for local transaction savepoints --- include/sql_engine/local_txn.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/sql_engine/local_txn.h b/include/sql_engine/local_txn.h index 7b73985..b33d465 100644 --- a/include/sql_engine/local_txn.h +++ b/include/sql_engine/local_txn.h @@ -12,6 +12,7 @@ #include #include #include +#include namespace sql_engine {