Skip to content

Commit 7802625

Browse files
rootclaude
authored andcommitted
feat: add SelectQueryParser.analyze method with character-level error positioning
Added analyze() method to SelectQueryParser that provides non-throwing parsing with detailed error information including precise character positions instead of token indices. Features: - ParseAnalysisResult interface with success status, query, and error details - Character-level error positioning for better IDE integration - Support for remaining tokens detection after complete queries - Comprehensive error handling with fallback position calculation - Maintains backward compatibility with existing parse() method The analyze method enables better error reporting and debugging capabilities for SQL parsing, making it easier to integrate with editors and IDEs. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent f4abe53 commit 7802625

File tree

3 files changed

+287
-0
lines changed

3 files changed

+287
-0
lines changed

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Entry point for rawsql-ts package
22
export * from './parsers/SelectQueryParser';
3+
export { ParseAnalysisResult } from './parsers/SelectQueryParser';
34
export * from './parsers/InsertQueryParser';
45
export * from './parsers/WithClauseParser';
56

packages/core/src/parsers/SelectQueryParser.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ import { ValuesQueryParser } from "./ValuesQueryParser";
1515
import { FetchClauseParser } from "./FetchClauseParser";
1616
import { OffsetClauseParser } from "./OffsetClauseParser";
1717

18+
export interface ParseAnalysisResult {
19+
success: boolean;
20+
query?: SelectQuery;
21+
error?: string;
22+
errorPosition?: number; // Character position in source text
23+
remainingTokens?: string[];
24+
}
25+
1826
export class SelectQueryParser {
1927
// Parse SQL string to AST (was: parse)
2028
public static parse(query: string): SelectQuery {
@@ -32,6 +40,94 @@ export class SelectQueryParser {
3240
return result.value;
3341
}
3442

43+
/**
44+
* Analyzes SQL string for parsing without throwing errors.
45+
* Returns a result object containing the parsed query on success,
46+
* or error information if parsing fails.
47+
*
48+
* @param query SQL string to analyze
49+
* @returns Analysis result containing query, error information, and success status
50+
*/
51+
/**
52+
* Calculate character position from token index by finding token in original query
53+
*/
54+
private static calculateCharacterPosition(query: string, lexemes: Lexeme[], tokenIndex: number): number {
55+
if (tokenIndex >= lexemes.length) {
56+
return query.length;
57+
}
58+
59+
// If lexeme has position information, use it
60+
const lexeme = lexemes[tokenIndex];
61+
if (lexeme.position?.startPosition !== undefined) {
62+
return lexeme.position.startPosition;
63+
}
64+
65+
// Fallback: search for token in original query
66+
// Build search pattern from tokens up to the target
67+
let searchStart = 0;
68+
for (let i = 0; i < tokenIndex; i++) {
69+
const tokenValue = lexemes[i].value;
70+
const tokenPos = query.indexOf(tokenValue, searchStart);
71+
if (tokenPos !== -1) {
72+
searchStart = tokenPos + tokenValue.length;
73+
}
74+
}
75+
76+
const targetToken = lexemes[tokenIndex].value;
77+
const tokenPos = query.indexOf(targetToken, searchStart);
78+
return tokenPos !== -1 ? tokenPos : searchStart;
79+
}
80+
81+
public static analyze(query: string): ParseAnalysisResult {
82+
let lexemes: Lexeme[] = [];
83+
84+
try {
85+
const tokenizer = new SqlTokenizer(query);
86+
lexemes = tokenizer.readLexmes();
87+
88+
// Parse
89+
const result = this.parseFromLexeme(lexemes, 0);
90+
91+
// Check for remaining tokens
92+
if (result.newIndex < lexemes.length) {
93+
const remainingTokens = lexemes.slice(result.newIndex).map(lex => lex.value);
94+
const errorLexeme = lexemes[result.newIndex];
95+
const errorPosition = this.calculateCharacterPosition(query, lexemes, result.newIndex);
96+
97+
return {
98+
success: false,
99+
query: result.value,
100+
error: `Syntax error: Unexpected token "${errorLexeme.value}" at character position ${errorPosition}. The SELECT query is complete but there are additional tokens.`,
101+
errorPosition: errorPosition,
102+
remainingTokens: remainingTokens
103+
};
104+
}
105+
106+
return {
107+
success: true,
108+
query: result.value
109+
};
110+
} catch (error) {
111+
// Extract position information from error message if available
112+
let errorPosition: number | undefined;
113+
114+
const errorMessage = error instanceof Error ? error.message : String(error);
115+
116+
// Try to extract token index from error message and convert to character position
117+
const positionMatch = errorMessage.match(/position (\d+)/);
118+
if (positionMatch) {
119+
const tokenIndex = parseInt(positionMatch[1], 10);
120+
errorPosition = this.calculateCharacterPosition(query, lexemes, tokenIndex);
121+
}
122+
123+
return {
124+
success: false,
125+
error: errorMessage,
126+
errorPosition: errorPosition
127+
};
128+
}
129+
}
130+
35131
/**
36132
* Asynchronously parse SQL string to AST.
37133
* This method wraps the synchronous parse logic in a Promise for future extensibility.
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { SelectQueryParser } from '../../src/parsers/SelectQueryParser';
3+
4+
describe('SelectQueryParser.analyze', () => {
5+
test('should successfully analyze valid SELECT query', () => {
6+
// Arrange
7+
const sql = `SELECT id, name FROM users WHERE active = true`;
8+
9+
// Act
10+
const result = SelectQueryParser.analyze(sql);
11+
12+
// Assert
13+
expect(result.success).toBe(true);
14+
expect(result.query).toBeDefined();
15+
expect(result.error).toBeUndefined();
16+
expect(result.errorPosition).toBeUndefined();
17+
expect(result.remainingTokens).toBeUndefined();
18+
});
19+
20+
test('should successfully analyze complex query with JOIN', () => {
21+
// Arrange
22+
const sql = `
23+
SELECT u.id, u.name, o.order_id
24+
FROM users u
25+
JOIN orders o ON u.id = o.user_id
26+
WHERE u.active = true
27+
`;
28+
29+
// Act
30+
const result = SelectQueryParser.analyze(sql);
31+
32+
// Assert
33+
expect(result.success).toBe(true);
34+
expect(result.query).toBeDefined();
35+
expect(result.error).toBeUndefined();
36+
});
37+
38+
test('should successfully parse query with alias (not missing FROM)', () => {
39+
// Arrange
40+
const sql = `SELECT id, name users`; // This is valid SQL: SELECT id, name AS users
41+
42+
// Act
43+
const result = SelectQueryParser.analyze(sql);
44+
45+
// Assert
46+
expect(result.success).toBe(true);
47+
expect(result.query).toBeDefined();
48+
expect(result.error).toBeUndefined();
49+
});
50+
51+
test('should successfully parse query with table alias', () => {
52+
// Arrange
53+
const sql = `SELECT id FROM users EXTRA_TOKEN`; // EXTRA_TOKEN is parsed as table alias
54+
55+
// Act
56+
const result = SelectQueryParser.analyze(sql);
57+
58+
// Assert
59+
expect(result.success).toBe(true);
60+
expect(result.query).toBeDefined();
61+
expect(result.error).toBeUndefined();
62+
});
63+
64+
test('should detect error when missing SELECT keyword', () => {
65+
// Arrange
66+
const sql = `UPDATE users SET name = 'test'`; // Wrong query type
67+
68+
// Act
69+
const result = SelectQueryParser.analyze(sql);
70+
71+
// Assert
72+
expect(result.success).toBe(false);
73+
expect(result.error).toBeDefined();
74+
expect(result.error).toContain("Expected 'SELECT' or 'VALUES'");
75+
expect(result.errorPosition).toBe(0); // Character position of 'update'
76+
});
77+
78+
test('should handle empty query', () => {
79+
// Arrange
80+
const sql = ``;
81+
82+
// Act
83+
const result = SelectQueryParser.analyze(sql);
84+
85+
// Assert
86+
expect(result.success).toBe(false);
87+
expect(result.error).toBeDefined();
88+
expect(result.error).toContain('Unexpected end of input');
89+
});
90+
91+
test('should analyze VALUES query successfully', () => {
92+
// Arrange
93+
const sql = `VALUES (1, 'John'), (2, 'Jane')`;
94+
95+
// Act
96+
const result = SelectQueryParser.analyze(sql);
97+
98+
// Assert
99+
expect(result.success).toBe(true);
100+
expect(result.query).toBeDefined();
101+
expect(result.error).toBeUndefined();
102+
});
103+
104+
test('should analyze UNION query successfully', () => {
105+
// Arrange
106+
const sql = `
107+
SELECT id, name FROM users
108+
UNION
109+
SELECT id, title FROM posts
110+
`;
111+
112+
// Act
113+
const result = SelectQueryParser.analyze(sql);
114+
115+
// Assert
116+
expect(result.success).toBe(true);
117+
expect(result.query).toBeDefined();
118+
expect(result.error).toBeUndefined();
119+
});
120+
121+
test('should detect error in UNION query with incomplete second part', () => {
122+
// Arrange
123+
const sql = `SELECT id FROM users UNION`; // Incomplete UNION
124+
125+
// Act
126+
const result = SelectQueryParser.analyze(sql);
127+
128+
// Assert
129+
expect(result.success).toBe(false);
130+
expect(result.error).toBeDefined();
131+
expect(result.error).toContain("Expected a query after 'UNION'");
132+
});
133+
134+
test('should analyze WITH clause query successfully', () => {
135+
// Arrange
136+
const sql = `
137+
WITH user_data AS (SELECT * FROM users)
138+
SELECT id, name FROM user_data
139+
`;
140+
141+
// Act
142+
const result = SelectQueryParser.analyze(sql);
143+
144+
// Assert
145+
expect(result.success).toBe(true);
146+
expect(result.query).toBeDefined();
147+
expect(result.error).toBeUndefined();
148+
});
149+
150+
test('should detect invalid FROM clause syntax', () => {
151+
// Arrange
152+
const sql = `SELECT id FROM FROM users`; // Invalid double FROM
153+
154+
// Act
155+
const result = SelectQueryParser.analyze(sql);
156+
157+
// Assert
158+
expect(result.success).toBe(false);
159+
expect(result.error).toBeDefined();
160+
expect(result.error).toContain('Identifier list is empty');
161+
});
162+
163+
test('should detect incomplete FROM clause', () => {
164+
// Arrange
165+
const sql = `SELECT id FROM`; // Incomplete FROM clause
166+
167+
// Act
168+
const result = SelectQueryParser.analyze(sql);
169+
170+
// Assert
171+
expect(result.success).toBe(false);
172+
expect(result.error).toBeDefined();
173+
expect(result.error).toContain('Unexpected end of input after \'FROM\' keyword');
174+
});
175+
176+
test('should detect remaining tokens after complete query', () => {
177+
// Arrange
178+
const sql = `SELECT * FROM users LIMIT 10 INVALID_KEYWORD`;
179+
180+
// Act
181+
const result = SelectQueryParser.analyze(sql);
182+
183+
// Assert
184+
expect(result.success).toBe(false);
185+
expect(result.query).toBeDefined(); // Partial parsing successful
186+
expect(result.error).toContain('Unexpected token "INVALID_KEYWORD"');
187+
expect(result.errorPosition).toBe(29); // Character position of 'INVALID_KEYWORD'
188+
expect(result.remainingTokens).toEqual(['INVALID_KEYWORD']);
189+
});
190+
});

0 commit comments

Comments
 (0)