|
| 1 | +# Transaction Statement Node — Design Specification |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +Promotes `BEGIN` and `START TRANSACTION` from Tier 2 to Tier 1 and introduces `NODE_TRANSACTION_STMT`, so that the transaction characteristics they carry (`READ ONLY`, `READ WRITE`, `ISOLATION LEVEL ...`) survive the parse. Today no mode reaches the AST, so a consumer has no structured way to tell a read-only transaction from a writable one. |
| 6 | + |
| 7 | +### Goals |
| 8 | + |
| 9 | +- **Every transaction mode reaches the AST:** `READ ONLY` / `READ WRITE` in both dialects, plus PostgreSQL's four isolation levels and `[NOT] DEFERRABLE` and MySQL's `WITH CONSISTENT SNAPSHOT`. A mode no consumer reads is still parsed, so it cannot hide a mode behind it. |
| 10 | +- **`BEGIN` distinguishable from `BEGIN READ ONLY`:** the node is emitted even with no modes, so its absence is not overloaded to mean "no modes". |
| 11 | +- **Digest stability:** modes are stored and re-emitted under their canonical spelling, so the digest text is unchanged for canonically written input, and casing, internal spacing, and PostgreSQL's optional commas all normalize onto that one form instead of producing a digest each. No mode is dropped, so no transaction form shortens its own digest text. |
| 12 | +- **No behavior change for `COMMIT` / `ROLLBACK` / `SAVEPOINT`:** they stay Tier 2. |
| 13 | + |
| 14 | +### Constraints |
| 15 | + |
| 16 | +- **Each dialect accepts only its own grammar:** a mode one dialect does not define is not parsed for it, so the node never asserts semantics for a statement the server would reject. |
| 17 | +- `BEGIN` is a benchmarked statement (README publishes 29 ns); added cost must stay well inside that. |
| 18 | +- `scan_to_end()` must still run, so multi-statement `remaining` handling is untouched. |
| 19 | + |
| 20 | +--- |
| 21 | + |
| 22 | +## Problem |
| 23 | + |
| 24 | +`extract_transaction()` classifies `BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, and `SAVEPOINT` as Tier-2 statements: it sets `stmt_type`, then calls `scan_to_end()` to consume the rest of the input without parsing it. No AST node is produced. |
| 25 | + |
| 26 | +For `COMMIT`, `ROLLBACK`, and `SAVEPOINT` that is sufficient; the statement type carries all the meaning. For the two transaction-*starting* statements it is not, because the modes that follow decide whether the transaction may write (needed for our read-only classification work). No mode is parsed, so the statement type and the AST are the same whether the transaction can write or not. The modes survive only as an unparsed text tail, which also clears `full_input`: |
| 27 | + |
| 28 | +| input | status | stmt_type | ast | full_input | remaining | |
| 29 | +|---|---|---|---|---|---| |
| 30 | +| `BEGIN` | OK | BEGIN | `nullptr` | true | `""` | |
| 31 | +| `BEGIN READ ONLY` | OK | BEGIN | `nullptr` | false | `"READ ONLY"` | |
| 32 | +| `START TRANSACTION READ ONLY` | OK | START_TRANSACTION | `nullptr` | false | `"READ ONLY"` | |
| 33 | + |
| 34 | +`SET TRANSACTION READ ONLY` — the same characteristics on a different statement — *is* parsed, producing `NODE_SET_TRANSACTION` with the mode as an identifier child (`set_parser.h`, `parse_set_transaction()`). The grammar is already implemented, it is simply not reachable from the two statements where it matters most. |
| 35 | + |
| 36 | +### Motivating consumer |
| 37 | + |
| 38 | +ProxySQL routes queries to backend hostgroups. Routing a read-only transaction to a read replica requires knowing, at `BEGIN` time, that the transaction cannot write. A transaction is the unit a pooler would pin to a replica, so this is the case that matters most for that feature. |
| 39 | + |
| 40 | +--- |
| 41 | + |
| 42 | +## Chosen Approach |
| 43 | + |
| 44 | +Promote `BEGIN` and `START TRANSACTION` to Tier 1, with a dedicated `NODE_TRANSACTION_STMT` whose children are the parsed modes as `NODE_IDENTIFIER` nodes. |
| 45 | + |
| 46 | +The node is produced even when no modes are present, so `BEGIN` yields an empty `NODE_TRANSACTION_STMT` rather than `nullptr`. That makes "plain `BEGIN`" and "`BEGIN READ ONLY`" distinguishable, and keeps the node's presence a property of the statement type rather than of its arguments. |
| 47 | + |
| 48 | +### Classifier Updates |
| 49 | + |
| 50 | +The switch in `Parser<D>::classify_and_dispatch()`: |
| 51 | + |
| 52 | +- `TK_BEGIN` → `parse_transaction()` (was `extract_transaction()`) |
| 53 | +- `TK_START` → `parse_transaction()` (was `extract_transaction()`) |
| 54 | +- `TK_COMMIT`, `TK_ROLLBACK`, `TK_SAVEPOINT` → `extract_transaction()` (unchanged) |
| 55 | + |
| 56 | +Those three take no arguments, so Tier 2 remains correct for them — the five only shared a |
| 57 | +function because they shared a *lack* of parsing. |
| 58 | + |
| 59 | +--- |
| 60 | + |
| 61 | +## New NodeType Additions |
| 62 | + |
| 63 | +```cpp |
| 64 | +// TRANSACTION |
| 65 | +NODE_TRANSACTION_STMT, |
| 66 | +``` |
| 67 | + |
| 68 | +Flags for `NODE_TRANSACTION_STMT`: |
| 69 | + |
| 70 | +```cpp |
| 71 | +static constexpr uint16_t FLAG_TXN_BEGIN = 0; |
| 72 | +static constexpr uint16_t FLAG_TXN_BEGIN_TRANSACTION = 1; // PostgreSQL only |
| 73 | +static constexpr uint16_t FLAG_TXN_START_TRANSACTION = 2; |
| 74 | +``` |
| 75 | + |
| 76 | +Flags for a `NODE_TRANSACTION_STMT` mode child: |
| 77 | + |
| 78 | +```cpp |
| 79 | +static constexpr uint16_t FLAG_TXN_MODE_ISOLATION = 0x01; |
| 80 | +``` |
| 81 | + |
| 82 | +The above flag is set on a mode child that is an isolation level, so the emitter re-inserts the `ISOLATION LEVEL` keywords the parser consumed. Without it the emitter would have to guess from the value, which breaks once modes other than the access modes are stored. |
| 83 | + |
| 84 | +## New Token Additions |
| 85 | + |
| 86 | +**None.** Every mode this spec parses is spelled with existing tokens: `TK_READ`, `TK_ONLY`, `TK_WRITE`, `TK_ISOLATION`, `TK_LEVEL`, `TK_SERIALIZABLE`, `TK_REPEATABLE`, `TK_COMMITTED`, `TK_UNCOMMITTED`, `TK_COMMA`, `TK_TRANSACTION`, plus `TK_NOT` for `NOT DEFERRABLE` and `TK_WITH` for `WITH CONSISTENT SNAPSHOT`. |
| 87 | + |
| 88 | +`WORK` has no token either and is matched on identifier text, as `set_parser.h` already does for `CHARACTERISTICS` and `AUTHORIZATION`. It is a pure noise word — PostgreSQL's `opt_transaction` production carries no semantic action, so the server discards it too — which is why it needs no form flag and re-emits as plain `BEGIN`. |
| 89 | + |
| 90 | +`DEFERRABLE`, `CONSISTENT` and `SNAPSHOT` are matched the same way. Unlike `WORK` they are not noise words, so they are stored as mode children and reproduced by the emitter, each is gated by `if constexpr` to the dialect whose grammar has it: `[NOT] DEFERRABLE` for PostgreSQL, `WITH CONSISTENT SNAPSHOT` for MySQL. |
| 91 | + |
| 92 | +--- |
| 93 | + |
| 94 | +## PostgreSQL Syntax |
| 95 | + |
| 96 | +``` |
| 97 | +BEGIN [ WORK | TRANSACTION ] [ transaction_mode [, ...] ] |
| 98 | +START TRANSACTION [ transaction_mode [, ...] ] |
| 99 | +
|
| 100 | +transaction_mode: |
| 101 | + ISOLATION LEVEL { SERIALIZABLE | REPEATABLE READ | READ COMMITTED | READ UNCOMMITTED } |
| 102 | + READ WRITE | READ ONLY |
| 103 | + [ NOT ] DEFERRABLE |
| 104 | +``` |
| 105 | + |
| 106 | +Modes are comma-separated, though PostgreSQL accepts them with the commas omitted, so the parser treats the separator as optional. `WORK` and `TRANSACTION` are noise words after `BEGIN`. |
| 107 | + |
| 108 | +## MySQL Syntax |
| 109 | + |
| 110 | +``` |
| 111 | +BEGIN [ WORK ] |
| 112 | +START TRANSACTION [ transaction_characteristic [, ...] ] |
| 113 | +
|
| 114 | +transaction_characteristic: |
| 115 | + WITH CONSISTENT SNAPSHOT |
| 116 | + READ WRITE | READ ONLY |
| 117 | +``` |
| 118 | + |
| 119 | +MySQL has no `DEFERRABLE`, and sets the isolation level through `SET TRANSACTION` rather than on `START TRANSACTION`. `WITH CONSISTENT SNAPSHOT` is MySQL-only. |
| 120 | + |
| 121 | +One mode loop serves both dialects, but only `READ ONLY` / `READ WRITE` are common to them. Everything else is gated by `if constexpr` to the dialect whose grammar has it: |
| 122 | + |
| 123 | +| construct | PostgreSQL | MySQL | |
| 124 | +|---|---|---| |
| 125 | +| `READ ONLY` / `READ WRITE` | after `BEGIN` or `START TRANSACTION` | after `START TRANSACTION` only | |
| 126 | +| `ISOLATION LEVEL …` | ✅ | ✗ | |
| 127 | +| `[NOT] DEFERRABLE` | ✅ | ✗ | |
| 128 | +| `WITH CONSISTENT SNAPSHOT` | ✗ | ✅ | |
| 129 | +| `TRANSACTION` after `BEGIN` | ✅ | ✗ (`WORK` only) | |
| 130 | +| any mode after bare `BEGIN` | ✅ | ✗ | |
| 131 | +| comma between modes | optional | required | |
| 132 | + |
| 133 | +A construct the dialect does not define terminates the loop and falls to `scan_to_end()`, so it lands in `remaining` rather than becoming a mode child. This follows `set_parser.h`, which gates `SET LOCAL`, `SET ROLE`, `SET CONSTRAINTS`, `SET SCHEMA`, `SET SEED` and `SET TIME ZONE` to PostgreSQL for the same reason: a shared parse would emit a node for syntax the other server rejects. |
| 134 | + |
| 135 | +--- |
| 136 | + |
| 137 | +## AST Structure |
| 138 | + |
| 139 | +``` |
| 140 | +BEGIN READ ONLY (PostgreSQL) |
| 141 | +└── NODE_TRANSACTION_STMT flags = FLAG_TXN_BEGIN |
| 142 | + └── NODE_IDENTIFIER "READ ONLY" |
| 143 | +
|
| 144 | +BEGIN ISOLATION LEVEL READ COMMITTED, READ ONLY (PostgreSQL) |
| 145 | +└── NODE_TRANSACTION_STMT flags = FLAG_TXN_BEGIN |
| 146 | + ├── NODE_IDENTIFIER "READ COMMITTED" flags = FLAG_TXN_MODE_ISOLATION |
| 147 | + └── NODE_IDENTIFIER "READ ONLY" |
| 148 | +
|
| 149 | +BEGIN ISOLATION LEVEL SERIALIZABLE, READ ONLY, DEFERRABLE (PostgreSQL) |
| 150 | +└── NODE_TRANSACTION_STMT flags = FLAG_TXN_BEGIN |
| 151 | + ├── NODE_IDENTIFIER "SERIALIZABLE" flags = FLAG_TXN_MODE_ISOLATION |
| 152 | + ├── NODE_IDENTIFIER "READ ONLY" |
| 153 | + └── NODE_IDENTIFIER "DEFERRABLE" |
| 154 | +
|
| 155 | +START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY (MySQL) |
| 156 | +└── NODE_TRANSACTION_STMT flags = FLAG_TXN_START_TRANSACTION |
| 157 | + ├── NODE_IDENTIFIER "WITH CONSISTENT SNAPSHOT" |
| 158 | + └── NODE_IDENTIFIER "READ ONLY" |
| 159 | +
|
| 160 | +START TRANSACTION READ ONLY (both) |
| 161 | +└── NODE_TRANSACTION_STMT flags = FLAG_TXN_START_TRANSACTION |
| 162 | + └── NODE_IDENTIFIER "READ ONLY" |
| 163 | +
|
| 164 | +START TRANSACTION (both) |
| 165 | +└── NODE_TRANSACTION_STMT flags = FLAG_TXN_START_TRANSACTION |
| 166 | +``` |
| 167 | + |
| 168 | +Mode values are stored under their **canonical spelling**, not as a span of the input, so a consumer can compare a child against `"READ ONLY"` without first normalizing case or internal whitespace. `select_parser.h` already does this for `NOWAIT` and `SKIP LOCKED`. Spanning the source instead would make `READ ONLY` a different string from `READ ONLY`, which every consumer would then have to work around. |
| 169 | + |
| 170 | +`ISOLATION LEVEL` is not stored, the level alone is — the same storage convention as `NODE_SET_TRANSACTION`. That node's emitter recovers the keywords by inference: anything that is not `READ ONLY` or `READ WRITE` is assumed to be an isolation level. This node records it instead, on `FLAG_TXN_MODE_ISOLATION`, because the inference breaks as soon as a mode that is neither is stored, which `[NOT] DEFERRABLE` and `WITH CONSISTENT SNAPSHOT` are. |
| 171 | + |
| 172 | +### Recording the introducing keywords |
| 173 | + |
| 174 | +`stmt_type` distinguishes `BEGIN` from `START TRANSACTION`, but not `BEGIN` from `BEGIN TRANSACTION`. The emitter needs that to round-trip, so it is recorded in the node flags rather than the node value — keywords are then emitted canonically like every other keyword in the emitter, instead of copying the input's casing. This matters for digests: `begin read only` and `BEGIN READ ONLY` must normalize to the same digest text, as they did when the statement had no AST and fell through to the token-level digest path, which uppercases keyword tokens. |
| 175 | + |
| 176 | +--- |
| 177 | + |
| 178 | +## Emitter Extensions |
| 179 | + |
| 180 | +One new method, `emit_transaction_stmt()`, plus its dispatch case. It writes the introducing keywords from `flags`, then each mode. A single mode is emitted without a comma so the common forms round-trip exactly; multiple modes are comma-separated, which PostgreSQL accepts and MySQL requires. No case folding happens here as the parser already stored each mode canonically. |
| 181 | + |
| 182 | +| input | emitted | dialect | |
| 183 | +|---|---|---| |
| 184 | +| `BEGIN` | `BEGIN` | both | |
| 185 | +| `begin read only` | `BEGIN READ ONLY` | PostgreSQL | |
| 186 | +| `BEGIN TRANSACTION READ ONLY` | `BEGIN TRANSACTION READ ONLY` | PostgreSQL | |
| 187 | +| `START TRANSACTION READ WRITE` | `START TRANSACTION READ WRITE` | both | |
| 188 | +| `BEGIN ISOLATION LEVEL SERIALIZABLE` | `BEGIN ISOLATION LEVEL SERIALIZABLE` | PostgreSQL | |
| 189 | + |
| 190 | +--- |
| 191 | + |
| 192 | +## Scope Boundaries |
| 193 | + |
| 194 | +`COMMIT`, `ROLLBACK`, and `SAVEPOINT` keep their Tier-2 treatment and produce no AST. Their statement type is their entire meaning. |
| 195 | + |
| 196 | +`scan_to_end()` still runs after the modes are parsed, so multi-statement handling is unaffected: `BEGIN READ ONLY; SELECT 1` continues to report `remaining = "SELECT 1"`, and `full_input` stays false for it while every single-statement form sets it. |
| 197 | + |
| 198 | +An input that starts a mode without completing it — `BEGIN READ`, `BEGIN ISOLATION LEVEL` — reports `PARTIAL`, matching how the Tier-1 parsers treat unexpected EOF. |
| 199 | + |
| 200 | +--- |
| 201 | + |
| 202 | +## Implementation |
| 203 | + |
| 204 | +1. `NODE_TRANSACTION_STMT` and the `FLAG_TXN_*` constants in `common.h`. |
| 205 | +2. `parse_transaction()` in `parser.cpp`, declared in the Tier-1 block of `parser.h`; `TK_BEGIN` / `TK_START` routed to it from `classify_and_dispatch()` and removed from `extract_transaction()`. |
| 206 | +3. `parse_transaction_modes(ParseResult&, uint16_t)` as the mode loop, following the Tier-1 conventions: `ERROR` if the node cannot be allocated, `PARTIAL` on an incomplete mode. Each mode is stored under its canonical spelling, as `select_parser.h` already does for `NOWAIT` and `SKIP LOCKED`, so a consumer never has to normalize before comparing. |
| 207 | +4. `emit_transaction_stmt()` and its dispatch case in `emitter.h`. |
| 208 | +5. Tests in `tests/test_misc_stmts.cpp`, beside the other Tier-1 statements that live in `parser.cpp`, plus digest coverage in `tests/test_digest.cpp`. |
| 209 | + |
| 210 | +--- |
0 commit comments