Skip to content
This repository was archived by the owner on Jun 25, 2026. It is now read-only.

Commit 201349a

Browse files
committed
GH-284 Handle recovery for statements with invalid leading tokens.
1 parent 1ee56b7 commit 201349a

7 files changed

Lines changed: 151 additions & 15 deletions

File tree

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Fix `sqlparse` dropping statements with an invalid leading token under CrateDB 6.3.2's optional-`statement` grammar; they are now recovered with their exception attached, for both the Python and JavaScript targets (#284)
6+
37
## 2026/05/22 v0.0.18
48
- Fix error matching issue when missing EOF
59
- Set CrateDB version to 6.3.2

cratedb_sqlparse_js/cratedb_sqlparse/AstBuilder.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ export class AstBuilder extends SqlBaseParserVisitor {
4242
*/
4343
enrich(stmt) {
4444
this.stmt = stmt
45+
if (this.stmt.ctx === null || this.stmt.ctx === undefined) {
46+
return // synthesized statement: no tree to walk
47+
}
4548
this.visit(this.stmt.ctx)
4649
}
4750

cratedb_sqlparse_js/cratedb_sqlparse/parser.js

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,23 @@ export class Statement {
162162
* @member {Metadata} metadata
163163
* @member {string} type - The type of query, example: 'SELECT'
164164
* @member {string} tree
165-
* @param {object} ctx
165+
* @param {object} ctx - null when the statement is synthesized from a parse error
166166
* @param {ParseError} exception
167167
*/
168168
constructor(ctx, exception) {
169+
this.ctx = ctx || null;
170+
this.exception = exception || null;
171+
this.metadata = new Metadata();
172+
173+
if (this.ctx === null) {
174+
// Synthesized from a parse error: no tree, so fall back to the offending fragment.
175+
this.query = this.exception ? this.exception.query : "";
176+
this.originalQuery = this.exception ? this.exception.query : null;
177+
this.tree = null;
178+
this.type = null;
179+
return;
180+
}
181+
169182
this.query = ctx.parser.getTokenStream().getText(
170183
new Interval(
171184
ctx.start.tokenIndex,
@@ -175,9 +188,6 @@ export class Statement {
175188
this.originalQuery = ctx.parser.getTokenStream().getText();
176189
this.tree = ctx.toStringTree(null, ctx.parser);
177190
this.type = ctx.start.text;
178-
this.ctx = ctx;
179-
this.exception = exception || null;
180-
this.metadata = new Metadata();
181191
}
182192
}
183193

@@ -199,6 +209,22 @@ function findSuitableError(statement, errors) {
199209
}
200210
}
201211

212+
/**
213+
* Text after the first top-level `;`, or null if there is none. Scans tokens, so a `;` inside a
214+
* string or comment is not treated as a separator.
215+
*
216+
* @param {CommonTokenStream} tokenStream
217+
* @returns {string|null}
218+
*/
219+
function queryTailAfterFirstStatement(tokenStream) {
220+
for (const token of tokenStream.tokens) {
221+
if (token.type === SqlBaseLexer.SEMICOLON) {
222+
return token.source[1].strdata.substring(token.stop + 1)
223+
}
224+
}
225+
return null
226+
}
227+
202228
/**
203229
*
204230
* @param {string} query
@@ -254,6 +280,19 @@ export function sqlparse(query, raise_exception = false) {
254280
console.error("Could not match errors to queries, too much ambiguity, please report it opening an issue with the query.")
255281
}
256282

283+
// Since CrateDB 6.3.2 made the leading `statement` optional, a statement whose first token is
284+
// invalid produces no StatementContext and collapses the parse to []. Rebuild from the
285+
// collected error, then recurse on the tail after the first `;` (GH-284). Fully-collapsed case
286+
// only; a bad non-leading statement still derails its followers (GH-28).
287+
if (!raise_exception && statementsContext.length === 0 && errorListener.errors.length > 0) {
288+
const recovered = [new Statement(null, errorListener.errors[0])]
289+
const tail = queryTailAfterFirstStatement(stream)
290+
if (tail !== null && tail.trim()) {
291+
recovered.push(...sqlparse(tail))
292+
}
293+
return recovered
294+
}
295+
257296
const stmtEnricher = new AstBuilder()
258297

259298
for (const stmt of statements) {

cratedb_sqlparse_js/tests/exceptions.test.js

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ test('Exception message is correct', () => {
6868
SELECT A, B, C, D FROM tbl1;
6969
SELECT D, A FROM tbl1 WHERE;`
7070
)
71-
const expectedMessage = "[line 2:8 mismatched input 'SELEC' expecting {'SELECT', 'DEALLOCATE', 'FETCH', 'END', 'WITH', 'CREATE', 'ALTER', 'KILL', 'CLOSE', 'BEGIN', 'START', 'COMMIT', 'ANALYZE', 'DISCARD', 'EXPLAIN', 'SHOW', 'OPTIMIZE', 'REFRESH', 'RESTORE', 'DROP', 'INSERT', 'VALUES', 'DELETE', 'UPDATE', 'SET', 'RESET', 'COPY', 'GRANT', 'DENY', 'REVOKE', 'DECLARE'}]"
71+
const expectedMessage = "[line 2:8 mismatched input 'SELEC' expecting {<EOF>, 'SELECT', 'DEALLOCATE', 'FETCH', 'END', 'WITH', 'CREATE', 'ALTER', 'KILL', 'CLOSE', 'BEGIN', 'START', 'COMMIT', 'ANALYZE', 'DISCARD', 'EXPLAIN', 'SHOW', 'OPTIMIZE', 'REFRESH', 'RESTORE', 'DROP', 'INSERT', 'VALUES', 'DELETE', 'UPDATE', 'SET', 'RESET', 'COPY', 'GRANT', 'DENY', 'REVOKE', 'DECLARE', ';'}]"
7272
const expectedMessageVerbose = " \n" +
7373
" SELEC 1;\n" +
7474
" ^^^^^\n" +
@@ -88,7 +88,7 @@ test('White or special characters should not avoid exception catching', () => {
8888
`SELECT 1\t limit `,
8989
`SELECT 1 limit `
9090
]
91-
for (const stmt in stmts) {
91+
for (const stmt of stmts) {
9292
let r = sqlparse(stmt)
9393
expect(r[0].exception).toBeDefined();
9494
}
@@ -112,7 +112,7 @@ test('Special characters should not avoid exception catching', () => {
112112
`SELECT 1\t limit `,
113113
`SELECT 1 limit `
114114
]
115-
for (const stmt in stmts) {
115+
for (const stmt of stmts) {
116116
let r = sqlparse(stmt)
117117
expect(r[0].exception).not.toBeNull();
118118
}
@@ -171,4 +171,30 @@ test('Missing EOF should not block error catching', () => {
171171
expect(r[0].exception).toBeNull()
172172
expect(r[1].exception).not.toBeNull()
173173
}
174+
})
175+
176+
test('Invalid leading token is recovered', () => {
177+
// An invalid leading token must still yield a Statement carrying the exception (GH-284).
178+
let r = sqlparse("SELCT 2")
179+
expect(r).length(1)
180+
expect(r[0].exception).not.toBeNull()
181+
expect(typeof r[0].query).toBe("string") // synthesized statement: attribute access must not throw
182+
expect(r[0].type).toBeNull()
183+
184+
// A bad leading statement must not swallow the valid ones after it.
185+
r = sqlparse("SELCT 1; SELECT 2; SELECT 3 FROM tbl WHERE;")
186+
expect(r).length(3)
187+
expect(r[0].exception).not.toBeNull()
188+
expect(r[1].exception).toBeNull()
189+
expect(r[2].exception).not.toBeNull()
190+
})
191+
192+
// GH-28 tripwire: a bad non-leading statement still derails the statements after it. `test.fails`
193+
// flips to a failure when GH-28 is fixed — drop the marker then.
194+
test.fails('Invalid token mid-stream derails following statements (GH-28)', () => {
195+
const r = sqlparse("SELECT 1; SELEC 2; SELECT 3")
196+
expect(r).length(3)
197+
expect(r[0].exception).toBeNull()
198+
expect(r[1].exception).not.toBeNull()
199+
expect(r[2].exception).toBeNull()
174200
})

cratedb_sqlparse_py/cratedb_sqlparse/AstBuilder.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ def stmt(self, value):
2929

3030
def enrich(self, stmt) -> None:
3131
self.stmt = stmt
32+
if self.stmt.ctx is None: # synthesized statement: no tree to walk
33+
return
3234
self.visit(self.stmt.ctx)
3335

3436
def visitTableName(self, ctx: SqlBaseParser.TableNameContext):

cratedb_sqlparse_py/cratedb_sqlparse/parser.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# ruff: noqa: A005 # Module `parser` shadows a Python standard-library module
2+
import typing as t
23
from typing import List
34

45
from antlr4 import CommonTokenStream, InputStream, RecognitionException, Token
@@ -145,7 +146,8 @@ def syntaxError(self, recognizer, offendingSymbol, line, column, msg, e):
145146

146147
class Statement:
147148
"""
148-
Represents a CrateDB SQL statement.
149+
Represents a CrateDB SQL statement. `ctx` is None when the statement was synthesized from a
150+
parse error instead of a parse tree (see the recovery path in `sqlparse`).
149151
"""
150152

151153
def __init__(self, ctx: SqlBaseParser.StatementContext, exception: ParsingException = None):
@@ -158,27 +160,35 @@ def tree(self):
158160
"""
159161
Returns the String representation of the Tree in LISP format.
160162
"""
163+
if self.ctx is None:
164+
return None
161165
return self.ctx.toStringTree(recog=self.ctx.parser)
162166

163167
@property
164168
def original_query(self):
165169
"""
166170
The original query that was fed into `sqlparse`, including semicolons and comments.
167171
"""
172+
if self.ctx is None:
173+
return self.exception.query if self.exception else None
168174
return self.ctx.parser.getTokenStream().getText()
169175

170176
@property
171177
def query(self) -> str:
172178
"""
173179
Returns the query, comments and ';' are not included.
174180
"""
181+
if self.ctx is None:
182+
return self.exception.query if self.exception else ""
175183
return self.ctx.parser.getTokenStream().getText(start=self.ctx.start, stop=self.ctx.stop)
176184

177185
@property
178186
def type(self):
179187
"""
180188
Returns the type of the Statement, i.e.: 'SELECT, 'UPDATE', 'COPY TO'...
181189
"""
190+
if self.ctx is None:
191+
return None
182192
return self.ctx.start.text
183193

184194
def __repr__(self):
@@ -199,6 +209,17 @@ def find_suitable_error(statement, errors):
199209
errors.pop(errors.index(error))
200210

201211

212+
def query_tail_after_first_statement(token_stream) -> t.Optional[str]:
213+
"""
214+
Return the text after the first top-level `;`, or None if there is none. Scans tokens, so a
215+
`;` inside a string or comment is not treated as a separator.
216+
"""
217+
for token in token_stream.tokens:
218+
if token.type == SqlBaseLexer.SEMICOLON:
219+
return token.source[1].strdata[token.stop + 1 :]
220+
return None
221+
222+
202223
def sqlparse(query: str, raise_exception: bool = False) -> List[Statement]:
203224
"""
204225
Parses a string into SQL `Statement`.
@@ -257,6 +278,17 @@ def sqlparse(query: str, raise_exception: bool = False) -> List[Statement]:
257278
# triggered and not be an actual error.
258279
pass
259280

281+
# Since CrateDB 6.3.2 made the leading `statement` optional, a statement whose first token is
282+
# invalid produces no StatementContext and collapses the parse to []. Rebuild from the
283+
# collected error, then recurse on the tail after the first `;` (GH-284). Fully-collapsed case
284+
# only; a bad non-leading statement still derails its followers (GH-28).
285+
if not raise_exception and not statements_context and error_listener.errors:
286+
recovered = [Statement(None, exception=error_listener.errors[0])]
287+
tail = query_tail_after_first_statement(stream)
288+
if tail is not None and tail.strip():
289+
recovered.extend(sqlparse(tail))
290+
return recovered
291+
260292
# We extract the metadata and enrich every Statement's `metadata`.
261293
stmt_enricher = AstBuilder()
262294

cratedb_sqlparse_py/tests/test_exceptions.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ def test_exception_message():
99
SELECT A, B, C, D FROM tbl1;
1010
SELECT D, A FROM tbl1 WHERE;
1111
""")
12-
expected_message = "InputMismatchException[line 2:9 mismatched input 'SELEC' expecting {'SELECT', 'DEALLOCATE', 'FETCH', 'END', 'WITH', 'CREATE', 'ALTER', 'KILL', 'CLOSE', 'BEGIN', 'START', 'COMMIT', 'ANALYZE', 'DISCARD', 'EXPLAIN', 'SHOW', 'OPTIMIZE', 'REFRESH', 'RESTORE', 'DROP', 'INSERT', 'VALUES', 'DELETE', 'UPDATE', 'SET', 'RESET', 'COPY', 'GRANT', 'DENY', 'REVOKE', 'DECLARE'}]" # noqa
12+
expected_message = "InputMismatchException[line 2:9 mismatched input 'SELEC' expecting {<EOF>, 'SELECT', 'DEALLOCATE', 'FETCH', 'END', 'WITH', 'CREATE', 'ALTER', 'KILL', 'CLOSE', 'BEGIN', 'START', 'COMMIT', 'ANALYZE', 'DISCARD', 'EXPLAIN', 'SHOW', 'OPTIMIZE', 'REFRESH', 'RESTORE', 'DROP', 'INSERT', 'VALUES', 'DELETE', 'UPDATE', 'SET', 'RESET', 'COPY', 'GRANT', 'DENY', 'REVOKE', 'DECLARE', ';'}]" # noqa
1313
expected_message_2 = "\n SELEC 1;\n ^^^^^\n SELECT A, B, C, D FROM tbl1;\n SELECT D, A FROM tbl1 WHERE;\n " # noqa
1414
assert r[0].exception.error_message == expected_message
1515
assert r[0].exception.original_query_with_error_marked == expected_message_2
@@ -93,12 +93,12 @@ def test_sqlparse_catches_exception():
9393
"""
9494
from cratedb_sqlparse import sqlparse
9595

96-
stmts = """
97-
SELECT 1\n limit,
98-
SELECT 1\r limit,
99-
SELECT 1\t limit,
100-
SELECT 1 limit
101-
"""
96+
stmts = [
97+
"SELECT 1\n limit",
98+
"SELECT 1\r limit",
99+
"SELECT 1\t limit",
100+
"SELECT 1 limit",
101+
]
102102
for stmt in stmts:
103103
assert sqlparse(stmt)[0].exception
104104

@@ -183,3 +183,33 @@ def test_sqlparse_match_exception_missing_eof():
183183
assert len(r) == 2
184184
assert not r[0].exception
185185
assert r[1].exception
186+
187+
188+
def test_sqlparse_recovers_invalid_leading_token():
189+
"""An invalid leading token must still yield a Statement carrying the exception (GH-284)."""
190+
from cratedb_sqlparse import sqlparse
191+
192+
r = sqlparse("SELCT 2")
193+
assert len(r) == 1
194+
assert r[0].exception is not None
195+
assert isinstance(r[0].query, str) # synthesized statement: attribute access must not raise
196+
assert r[0].type is None
197+
198+
# A bad leading statement must not swallow the valid ones after it.
199+
r = sqlparse("SELCT 1; SELECT 2; SELECT 3 FROM tbl WHERE;")
200+
assert len(r) == 3
201+
assert r[0].exception is not None
202+
assert r[1].exception is None
203+
assert r[2].exception is not None
204+
205+
206+
@pytest.mark.xfail(reason="GH-28: a bad non-leading statement still derails the statements after it")
207+
def test_sqlparse_invalid_token_mid_stream_derails_followers():
208+
"""Strict-xfail tripwire for GH-28; drop the marker when it starts passing."""
209+
from cratedb_sqlparse import sqlparse
210+
211+
r = sqlparse("SELECT 1; SELEC 2; SELECT 3")
212+
assert len(r) == 3
213+
assert r[0].exception is None
214+
assert r[1].exception is not None
215+
assert r[2].exception is None

0 commit comments

Comments
 (0)