Skip to content

Commit 4a861e0

Browse files
committed
feat: implement deep parsing for BEGIN and START TRANSACTION
Signed-off-by: Snehil Shah <snehilshah.989@gmail.com>
1 parent 5f1607e commit 4a861e0

6 files changed

Lines changed: 530 additions & 10 deletions

File tree

include/sql_parser/common.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ static constexpr uint16_t FLAG_SET_OP_ALL = 0x01;
6666
// which matters for SHOW search_path / SHOW <var> canonical re-emission.
6767
static constexpr uint16_t FLAG_IDENT_DELIMITED = 0x01;
6868

69+
// -- Flags for a NODE_TRANSACTION_STMT mode child --
70+
// Set when the mode is an isolation level, so the emitter re-inserts the
71+
// ISOLATION LEVEL keywords the parser consumed.
72+
static constexpr uint16_t FLAG_TXN_MODE_ISOLATION = 0x01;
73+
6974
// -- Statement type (always set, even for PARTIAL/ERROR) --
7075

7176
enum class StmtType : uint8_t {
@@ -237,6 +242,9 @@ enum class NodeType : uint16_t {
237242
NODE_USER_VARIABLE,
238243
NODE_LITERAL_HEX,
239244
NODE_LITERAL_BIT,
245+
246+
// TRANSACTION
247+
NODE_TRANSACTION_STMT,
240248
};
241249

242250
} // namespace sql_parser

include/sql_parser/emitter.h

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ class Emitter {
100100
case NodeType::NODE_LOAD_DATA_STMT: emit_load_data_stmt(node); break;
101101
case NodeType::NODE_LOAD_DATA_OPTIONS: /* emitted inline */ break;
102102

103+
// ---- TRANSACTION ----
104+
case NodeType::NODE_TRANSACTION_STMT: emit_transaction_stmt(node); break;
105+
103106
// ---- UPDATE statement ----
104107
case NodeType::NODE_UPDATE_STMT: emit_update_stmt(node); break;
105108
case NodeType::NODE_UPDATE_SET_CLAUSE: emit_update_set_clause(node); break;
@@ -1046,6 +1049,24 @@ class Emitter {
10461049
if (has_cols) sb_.append_char(')');
10471050
}
10481051

1052+
// ---- TRANSACTION ----
1053+
1054+
void emit_transaction_stmt(const AstNode* node) {
1055+
emit_value(node); // introducing keywords (BEGIN, BEGIN TRANSACTION, START TRANSACTION)
1056+
1057+
if (node->first_child) sb_.append_char(' ');
1058+
bool first = true;
1059+
for (const AstNode* child = node->first_child; child; child = child->next_sibling) {
1060+
if (!first) sb_.append(", ");
1061+
first = false;
1062+
// The parser strips ISOLATION LEVEL and flags the child; restore it
1063+
if (child->flags & FLAG_TXN_MODE_ISOLATION) {
1064+
sb_.append("ISOLATION LEVEL ");
1065+
}
1066+
emit_node(child);
1067+
}
1068+
}
1069+
10491070
// ---- Compound query ----
10501071

10511072
void emit_compound_query(const AstNode* node) {

include/sql_parser/parser.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class Parser {
6262
ParseResult parse_call();
6363
ParseResult parse_do();
6464
ParseResult parse_load_data();
65+
ParseResult parse_transaction(const Token& first);
6566

6667
// Tier 2 extractors
6768
ParseResult extract_insert(const Token& first);
@@ -88,6 +89,12 @@ class Parser {
8889

8990
// Scan forward to semicolon or EOF, set result.remaining
9091
void scan_to_end(ParseResult& result);
92+
93+
// Parse the transaction modes after BEGIN / START TRANSACTION, set result.ast.
94+
// 'introducer' is the canonical spelling of the keywords that opened the
95+
// statement, an unrecognized mode ends the loop and is left to scan_to_end().
96+
void parse_transaction_modes(ParseResult& result, StringRef introducer,
97+
bool allow_modes);
9198
};
9299

93100
} // namespace sql_parser

src/sql_parser/parser.cpp

Lines changed: 151 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ ParseResult Parser<D>::classify_and_dispatch() {
6464
case TokenType::TK_DELETE: return parse_delete();
6565
case TokenType::TK_REPLACE: return parse_insert(true);
6666
case TokenType::TK_BEGIN:
67-
case TokenType::TK_START:
67+
case TokenType::TK_START: return parse_transaction(first);
6868
case TokenType::TK_COMMIT:
6969
case TokenType::TK_ROLLBACK:
7070
case TokenType::TK_SAVEPOINT:return extract_transaction(first);
@@ -910,6 +910,40 @@ ParseResult Parser<D>::parse_load_data() {
910910
return r;
911911
}
912912

913+
// ---- TRANSACTION ----
914+
915+
template <Dialect D>
916+
ParseResult Parser<D>::parse_transaction(const Token& first) {
917+
ParseResult r;
918+
bool is_begin = (first.type == TokenType::TK_BEGIN);
919+
r.stmt_type = is_begin ? StmtType::BEGIN : StmtType::START_TRANSACTION;
920+
921+
StringRef introducer = is_begin ? StringRef{"BEGIN", 5}
922+
: StringRef{"START TRANSACTION", 17};
923+
Token next = tokenizer_.peek();
924+
if (next.type == TokenType::TK_TRANSACTION) {
925+
if (!is_begin) {
926+
tokenizer_.skip();
927+
} else if constexpr (D == Dialect::PostgreSQL) {
928+
// MySQL's BEGIN takes WORK but not TRANSACTION.
929+
tokenizer_.skip();
930+
introducer = StringRef{"BEGIN TRANSACTION", 17};
931+
}
932+
} else if (is_begin && next.type == TokenType::TK_IDENTIFIER &&
933+
next.text.equals_ci("WORK", 4)) {
934+
// WORK is a noise word after BEGIN in both dialects; no form of its own.
935+
tokenizer_.skip();
936+
}
937+
938+
// MySQL carries modes on START TRANSACTION only; its BEGIN takes none.
939+
bool allow_modes = (D == Dialect::PostgreSQL) || !is_begin;
940+
941+
r.status = ParseResult::OK;
942+
parse_transaction_modes(r, introducer, allow_modes);
943+
scan_to_end(r);
944+
return r;
945+
}
946+
913947
// ---- Helpers ----
914948

915949
template <Dialect D>
@@ -972,6 +1006,122 @@ void Parser<D>::scan_to_end(ParseResult& result) {
9721006
}
9731007
}
9741008

1009+
template <Dialect D>
1010+
void Parser<D>::parse_transaction_modes(ParseResult& result, StringRef introducer,
1011+
bool allow_modes) {
1012+
AstNode* root = make_node(arena_, NodeType::NODE_TRANSACTION_STMT, introducer);
1013+
if (!root) { result.status = ParseResult::ERROR; return; }
1014+
1015+
while (allow_modes) {
1016+
Token t = tokenizer_.peek();
1017+
1018+
if (t.type == TokenType::TK_ISOLATION) {
1019+
// MySQL sets the isolation level with SET TRANSACTION, not here.
1020+
if constexpr (D == Dialect::MySQL) break;
1021+
tokenizer_.skip();
1022+
if (tokenizer_.peek().type == TokenType::TK_LEVEL) tokenizer_.skip();
1023+
1024+
Token level = tokenizer_.next_token();
1025+
if (level.type == TokenType::TK_EOF) {
1026+
result.status = ParseResult::PARTIAL;
1027+
break;
1028+
}
1029+
StringRef value = level.text;
1030+
if (level.type == TokenType::TK_SERIALIZABLE) {
1031+
value = StringRef{"SERIALIZABLE", 12};
1032+
} else if (level.type == TokenType::TK_READ ||
1033+
level.type == TokenType::TK_REPEATABLE) {
1034+
// READ COMMITTED / READ UNCOMMITTED / REPEATABLE READ
1035+
Token second = tokenizer_.next_token();
1036+
if (second.type == TokenType::TK_EOF) {
1037+
result.status = ParseResult::PARTIAL;
1038+
break;
1039+
}
1040+
if (second.type == TokenType::TK_COMMITTED) {
1041+
value = StringRef{"READ COMMITTED", 14};
1042+
} else if (second.type == TokenType::TK_UNCOMMITTED) {
1043+
value = StringRef{"READ UNCOMMITTED", 16};
1044+
} else if (second.type == TokenType::TK_READ) {
1045+
value = StringRef{"REPEATABLE READ", 15};
1046+
} else {
1047+
value = StringRef{level.text.ptr,
1048+
static_cast<uint32_t>((second.text.ptr + second.text.len) - level.text.ptr)};
1049+
}
1050+
}
1051+
AstNode* mode = make_node(arena_, NodeType::NODE_IDENTIFIER, value);
1052+
if (mode) mode->flags = FLAG_TXN_MODE_ISOLATION;
1053+
root->add_child(mode);
1054+
} else if (t.type == TokenType::TK_READ) {
1055+
tokenizer_.skip();
1056+
Token rw = tokenizer_.next_token(); // ONLY or WRITE
1057+
if (rw.type == TokenType::TK_EOF) {
1058+
result.status = ParseResult::PARTIAL;
1059+
break;
1060+
}
1061+
StringRef value;
1062+
if (rw.type == TokenType::TK_ONLY) {
1063+
value = StringRef{"READ ONLY", 9};
1064+
} else if (rw.type == TokenType::TK_WRITE) {
1065+
value = StringRef{"READ WRITE", 10};
1066+
} else {
1067+
value = StringRef{t.text.ptr,
1068+
static_cast<uint32_t>((rw.text.ptr + rw.text.len) - t.text.ptr)};
1069+
}
1070+
root->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, value));
1071+
} else if (t.type == TokenType::TK_NOT ||
1072+
(t.type == TokenType::TK_IDENTIFIER &&
1073+
t.text.equals_ci("DEFERRABLE", 10))) {
1074+
// PostgreSQL: [ NOT ] DEFERRABLE. Parsed so it cannot hide a later mode.
1075+
if constexpr (D == Dialect::PostgreSQL) {
1076+
bool is_not = (t.type == TokenType::TK_NOT);
1077+
tokenizer_.skip();
1078+
if (is_not) {
1079+
Token d = tokenizer_.next_token();
1080+
if (d.type != TokenType::TK_IDENTIFIER ||
1081+
!d.text.equals_ci("DEFERRABLE", 10)) {
1082+
result.status = ParseResult::PARTIAL;
1083+
break;
1084+
}
1085+
}
1086+
root->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER,
1087+
is_not ? StringRef{"NOT DEFERRABLE", 14}
1088+
: StringRef{"DEFERRABLE", 10}));
1089+
} else {
1090+
break;
1091+
}
1092+
} else if (t.type == TokenType::TK_WITH) {
1093+
// MySQL: WITH CONSISTENT SNAPSHOT, same reason.
1094+
if constexpr (D == Dialect::MySQL) {
1095+
tokenizer_.skip();
1096+
Token c = tokenizer_.next_token();
1097+
Token sn = tokenizer_.next_token();
1098+
if (c.type != TokenType::TK_IDENTIFIER ||
1099+
!c.text.equals_ci("CONSISTENT", 10) ||
1100+
sn.type != TokenType::TK_IDENTIFIER ||
1101+
!sn.text.equals_ci("SNAPSHOT", 8)) {
1102+
result.status = ParseResult::PARTIAL;
1103+
break;
1104+
}
1105+
root->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER,
1106+
StringRef{"WITH CONSISTENT SNAPSHOT", 24}));
1107+
} else {
1108+
break;
1109+
}
1110+
} else {
1111+
break;
1112+
}
1113+
1114+
// PostgreSQL allows the commas to be omitted; MySQL requires them.
1115+
if (tokenizer_.peek().type == TokenType::TK_COMMA) {
1116+
tokenizer_.skip();
1117+
} else if constexpr (D == Dialect::MySQL) {
1118+
break;
1119+
}
1120+
}
1121+
1122+
result.ast = root;
1123+
}
1124+
9751125
// ---- Tier 2 Extractors ----
9761126

9771127
template <Dialect D>
@@ -1060,15 +1210,6 @@ ParseResult Parser<D>::extract_transaction(const Token& first) {
10601210
r.status = ParseResult::OK;
10611211

10621212
switch (first.type) {
1063-
case TokenType::TK_BEGIN:
1064-
r.stmt_type = StmtType::BEGIN;
1065-
break;
1066-
case TokenType::TK_START:
1067-
r.stmt_type = StmtType::START_TRANSACTION;
1068-
// consume TRANSACTION if present
1069-
if (tokenizer_.peek().type == TokenType::TK_TRANSACTION)
1070-
tokenizer_.skip();
1071-
break;
10721213
case TokenType::TK_COMMIT:
10731214
r.stmt_type = StmtType::COMMIT;
10741215
break;

tests/test_digest.cpp

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,13 +278,32 @@ class PgSQLDigestTest : public ::testing::Test {
278278
protected:
279279
Parser<Dialect::PostgreSQL> parser;
280280

281+
// AST-based digest (parses SQL, invalidates previous arena allocations)
282+
StableDigest digest_ast(const char* sql) {
283+
auto r = parser.parse(sql, strlen(sql));
284+
Digest<Dialect::PostgreSQL> digest(parser.arena());
285+
DigestResult dr;
286+
if (r.ast) {
287+
dr = digest.compute(r.ast);
288+
} else {
289+
dr = digest.compute(sql, strlen(sql));
290+
}
291+
return StableDigest{std::string(dr.normalized.ptr, dr.normalized.len), dr.hash};
292+
}
293+
294+
// Token-level digest (uses arena but does NOT call parse, so arena is stable
295+
// within a single call but may be invalidated by subsequent parse calls)
281296
StableDigest digest_token(const char* sql) {
282297
parser.reset();
283298
Digest<Dialect::PostgreSQL> digest(parser.arena());
284299
auto dr = digest.compute(sql, strlen(sql));
285300
return StableDigest{std::string(dr.normalized.ptr, dr.normalized.len), dr.hash};
286301
}
287302

303+
std::string normalized(const char* sql) {
304+
return digest_ast(sql).normalized;
305+
}
306+
288307
std::string normalized_token(const char* sql) {
289308
return digest_token(sql).normalized;
290309
}
@@ -310,6 +329,20 @@ TEST_F(PgSQLDigestTest, ReturningDigest) {
310329
"INSERT INTO t (a) VALUES (?) RETURNING *");
311330
}
312331

332+
// ========== Transaction modes ==========
333+
334+
TEST_F(PgSQLDigestTest, TransactionCharacteristicsUppercased) {
335+
EXPECT_EQ(normalized("begin read only"), "BEGIN READ ONLY");
336+
EXPECT_EQ(normalized("begin isolation level serializable"),
337+
"BEGIN ISOLATION LEVEL SERIALIZABLE");
338+
}
339+
340+
TEST_F(PgSQLDigestTest, TransactionCasingDoesNotChangeHash) {
341+
auto d1 = digest_ast("BEGIN READ ONLY");
342+
auto d2 = digest_ast("begin read only");
343+
EXPECT_EQ(d1.hash, d2.hash);
344+
}
345+
313346
// ========== Token-level digest for various Tier 2 statements ==========
314347

315348
TEST_F(MySQLDigestTest, TokenLevelGrant) {

0 commit comments

Comments
 (0)