Skip to content

Commit a1cc479

Browse files
authored
Merge pull request #180 from mk3008/feat_schema_collector
feat: add SchemaCollector.analyze method for non-throwing schema analysis
2 parents 0220408 + 7802625 commit a1cc479

File tree

7 files changed

+495
-6
lines changed

7 files changed

+495
-6
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rawsql-ts",
3-
"version": "0.11.22-beta",
3+
"version": "0.11.23-beta",
44
"description": "[beta]High-performance SQL parser and AST analyzer written in TypeScript. Provides fast parsing and advanced transformation capabilities.",
55
"main": "dist/src/index.js",
66
"module": "dist/esm/index.js",

packages/core/src/index.ts

Lines changed: 2 additions & 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

@@ -52,6 +53,7 @@ export * from './transformers/UpstreamSelectQueryFinder';
5253
export * from './transformers/TypeTransformationPostProcessor';
5354

5455
export * from './transformers/SchemaCollector';
56+
export { TableSchema, SchemaAnalysisResult } from './transformers/SchemaCollector';
5557
export * from './transformers/QueryFlowDiagramGenerator';
5658
export * from './transformers/SqlParamInjector';
5759
export * from './transformers/SqlSortInjector';

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.

packages/core/src/transformers/SchemaCollector.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ export class TableSchema {
1919
}
2020
}
2121

22+
export interface SchemaAnalysisResult {
23+
success: boolean;
24+
schemas: TableSchema[];
25+
unresolvedColumns: string[];
26+
error?: string;
27+
}
28+
2229
/**
2330
* A visitor that collects schema information (table names and column names) from a SQL query structure.
2431
*/
@@ -29,6 +36,11 @@ export class SchemaCollector implements SqlComponentVisitor<void> {
2936
private visitedNodes: Set<SqlComponent> = new Set();
3037
private commonTables: CommonTable[] = [];
3138
private running = false;
39+
40+
// For analyze method
41+
private unresolvedColumns: string[] = [];
42+
private analysisError: string | undefined = undefined;
43+
private isAnalyzeMode = false;
3244

3345
constructor(
3446
private tableColumnResolver: TableColumnResolver | null = null,
@@ -53,6 +65,34 @@ export class SchemaCollector implements SqlComponentVisitor<void> {
5365
return this.tableSchemas;
5466
}
5567

68+
/**
69+
* Analyzes schema information from a SQL query structure without throwing errors.
70+
* Returns a result object containing successfully resolved schemas, unresolved columns,
71+
* and error information if any issues were encountered.
72+
*
73+
* @param arg The SQL query structure to analyze.
74+
* @returns Analysis result containing schemas, unresolved columns, and success status.
75+
*/
76+
public analyze(arg: SqlComponent): SchemaAnalysisResult {
77+
// Set analyze mode flag
78+
this.isAnalyzeMode = true;
79+
80+
try {
81+
this.visit(arg);
82+
83+
// If we got here without errors, it's a success
84+
return {
85+
success: this.unresolvedColumns.length === 0 && !this.analysisError,
86+
schemas: this.tableSchemas,
87+
unresolvedColumns: this.unresolvedColumns,
88+
error: this.analysisError
89+
};
90+
} finally {
91+
// Reset analyze mode flag
92+
this.isAnalyzeMode = false;
93+
}
94+
}
95+
5696
/**
5797
* Main entry point for the visitor pattern.
5898
* Implements the shallow visit pattern to distinguish between root and recursive visits.
@@ -122,6 +162,8 @@ export class SchemaCollector implements SqlComponentVisitor<void> {
122162
this.tableSchemas = [];
123163
this.visitedNodes = new Set();
124164
this.commonTables = [];
165+
this.unresolvedColumns = [];
166+
this.analysisError = undefined;
125167
}
126168

127169
/**
@@ -235,11 +277,18 @@ export class SchemaCollector implements SqlComponentVisitor<void> {
235277
}));
236278
}
237279

238-
// Throw an error if there are columns without table names in queries with joins
280+
// Handle columns without table names in queries with joins
239281
if (query.fromClause.joins !== null && query.fromClause.joins.length > 0) {
240282
const columnsWithoutTable = queryColumns.filter((columnRef) => columnRef.table === "").map((columnRef) => columnRef.column);
241283
if (columnsWithoutTable.length > 0) {
242-
throw new Error(`Column reference(s) without table name found in query: ${columnsWithoutTable.join(', ')}`);
284+
if (this.isAnalyzeMode) {
285+
// In analyze mode, collect unresolved columns
286+
this.unresolvedColumns.push(...columnsWithoutTable);
287+
this.analysisError = `Column reference(s) without table name found in query: ${columnsWithoutTable.join(', ')}`;
288+
} else {
289+
// In collect mode, throw error as before
290+
throw new Error(`Column reference(s) without table name found in query: ${columnsWithoutTable.join(', ')}`);
291+
}
243292
}
244293
}
245294

@@ -296,12 +345,25 @@ export class SchemaCollector implements SqlComponentVisitor<void> {
296345
.filter((columnRef) => columnRef.column === "*")
297346
.length > 0;
298347

299-
// Throw error if wildcard is found and allowWildcardWithoutResolver is false (default behavior)
348+
// Handle error if wildcard is found and allowWildcardWithoutResolver is false (default behavior)
300349
if (hasWildcard && !this.allowWildcardWithoutResolver) {
301350
const errorMessage = tableName
302351
? `Wildcard (*) is used. A TableColumnResolver is required to resolve wildcards. Target table: ${tableName}`
303352
: "Wildcard (*) is used. A TableColumnResolver is required to resolve wildcards.";
304-
throw new Error(errorMessage);
353+
354+
if (this.isAnalyzeMode) {
355+
// In analyze mode, record the error but continue processing
356+
this.analysisError = errorMessage;
357+
// Add wildcard columns to unresolved list
358+
const wildcardColumns = queryColumns
359+
.filter((columnRef) => columnRef.table === tableAlias || (includeUnnamed && columnRef.table === ""))
360+
.filter((columnRef) => columnRef.column === "*")
361+
.map((columnRef) => columnRef.table ? `${columnRef.table}.*` : "*");
362+
this.unresolvedColumns.push(...wildcardColumns);
363+
} else {
364+
// In collect mode, throw error as before
365+
throw new Error(errorMessage);
366+
}
305367
}
306368
}
307369

0 commit comments

Comments
 (0)