UNPKG

rawsql-ts

Version:

[beta]High-performance SQL parser and AST analyzer written in TypeScript. Provides fast parsing and advanced transformation capabilities.

363 lines 14.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.PositionAwareParser = void 0; const SelectQueryParser_1 = require("../parsers/SelectQueryParser"); const LexemeCursor_1 = require("./LexemeCursor"); const TextPositionUtils_1 = require("./TextPositionUtils"); /** * Position-aware SQL parser with error recovery for IntelliSense * * Extends the standard parser to handle incomplete SQL and provide context * for IntelliSense scenarios where users are actively typing. * * @example * ```typescript * // Parse incomplete SQL with error recovery * const sql = "SELECT user.name FROM users user WHERE user."; * const result = PositionAwareParser.parseToPosition(sql, sql.length, { * errorRecovery: true, * insertMissingTokens: true * }); * * console.log(result.tokenBeforeCursor?.value); // "." * console.log(result.success); // true (with recovery) * ``` */ class PositionAwareParser { /** * Parse SQL text up to a specific position with error recovery * * @param sql - SQL text to parse * @param cursorPosition - Character position to parse up to (0-based) or line/column * @param options - Parsing options including error recovery * @returns Parse result with position-specific information */ static parseToPosition(sql, cursorPosition, options = {}) { const charPosition = typeof cursorPosition === 'number' ? cursorPosition : TextPositionUtils_1.TextPositionUtils.lineColumnToCharOffset(sql, cursorPosition); if (charPosition === -1) { return { success: false, error: 'Invalid cursor position', stoppedAtCursor: false }; } try { // First, try normal parsing const normalResult = this.tryNormalParse(sql, charPosition, options); if (normalResult.success) { return normalResult; } // If normal parsing fails and error recovery is enabled, try recovery if (options.errorRecovery) { return this.tryErrorRecovery(sql, charPosition, options); } return normalResult; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), stoppedAtCursor: false }; } } /** * Parse current query from multi-query text at cursor position * * @param sql - Complete SQL text (may contain multiple statements) * @param cursorPosition - Cursor position * @param options - Parsing options * @returns Parse result for the current query only */ static parseCurrentQuery(sql, cursorPosition, options = {}) { const charPosition = typeof cursorPosition === 'number' ? cursorPosition : TextPositionUtils_1.TextPositionUtils.lineColumnToCharOffset(sql, cursorPosition); if (charPosition === -1) { return { success: false, error: 'Invalid cursor position', stoppedAtCursor: false }; } // Split SQL by semicolons and find the query containing the cursor const queryBoundaries = this.findQueryBoundaries(sql); const currentQuery = this.findQueryAtPosition(queryBoundaries, charPosition); if (!currentQuery) { return { success: false, error: 'No query found at cursor position', stoppedAtCursor: false }; } // Parse just the current query const relativePosition = charPosition - currentQuery.start; const querySQL = sql.substring(currentQuery.start, currentQuery.end); return this.parseToPosition(querySQL, relativePosition, options); } static tryNormalParse(sql, cursorPosition, options) { // Check for invalid cursor position first if (cursorPosition < 0 || cursorPosition > sql.length) { return { success: false, error: 'Invalid cursor position', stoppedAtCursor: false }; } // Check if SQL appears incomplete (ends with dot, comma, etc.) const trimmedSql = sql.trim(); const incompletePatterns = ['.', ',', 'SELECT', 'FROM', 'WHERE', 'JOIN', 'ON', 'GROUP BY', 'ORDER BY']; const appearsIncomplete = incompletePatterns.some(pattern => trimmedSql.toLowerCase().endsWith(pattern.toLowerCase())); // Try to parse the complete SQL const analysisResult = SelectQueryParser_1.SelectQueryParser.analyze(sql); // If parsing failed OR SQL appears incomplete, return failure to trigger error recovery if (!analysisResult.success || appearsIncomplete) { return { ...analysisResult, success: false }; } // Get tokens and find cursor token const allTokens = this.getAllTokens(sql); const cursorToken = this.findTokenAtPosition(allTokens, cursorPosition); const beforeCursor = this.findTokenBeforePosition(allTokens, cursorPosition); return { ...analysisResult, parsedTokens: allTokens, tokenBeforeCursor: beforeCursor, stoppedAtCursor: cursorPosition < sql.length, // True if cursor is before end of SQL recoveryAttempts: 0 // Normal parse, no recovery needed }; } static tryErrorRecovery(sql, cursorPosition, options) { const maxAttempts = options.maxRecoveryAttempts || 5; let attempts = 0; // Error recovery strategies in order of preference const strategies = [ () => this.recoverWithTokenInsertion(sql, cursorPosition, options), () => this.recoverWithTruncation(sql, cursorPosition, options), () => this.recoverWithCompletion(sql, cursorPosition, options), () => this.recoverWithMinimalSQL(sql, cursorPosition, options) ]; for (const strategy of strategies) { if (attempts >= maxAttempts) break; attempts++; try { const result = strategy(); if (result.success) { result.recoveryAttempts = attempts; return result; } } catch (error) { continue; // Try next strategy } } // All recovery attempts failed return { success: false, error: 'All error recovery attempts failed', recoveryAttempts: attempts, stoppedAtCursor: false }; } static recoverWithTokenInsertion(sql, cursorPosition, options) { if (!options.insertMissingTokens) { throw new Error('Token insertion disabled'); } // Common patterns to fix const fixes = [ { pattern: /SELECT\s*$/i, replacement: 'SELECT 1 ' }, { pattern: /FROM\s*$/i, replacement: 'FROM dual ' }, { pattern: /WHERE\s*$/i, replacement: 'WHERE 1=1 ' }, { pattern: /JOIN\s*$/i, replacement: 'JOIN dual ON 1=1 ' }, { pattern: /ON\s*$/i, replacement: 'ON 1=1 ' }, { pattern: /GROUP\s+BY\s*$/i, replacement: 'GROUP BY 1 ' }, { pattern: /ORDER\s+BY\s*$/i, replacement: 'ORDER BY 1 ' } ]; let fixedSQL = sql; for (const fix of fixes) { if (fix.pattern.test(sql)) { fixedSQL = sql.replace(fix.pattern, fix.replacement); break; } } if (fixedSQL === sql) { throw new Error('No applicable token insertion found'); } const result = SelectQueryParser_1.SelectQueryParser.analyze(fixedSQL); const tokens = this.getAllTokens(sql); // Use original SQL for tokens return { ...result, parsedTokens: tokens, tokenBeforeCursor: this.findTokenBeforePosition(tokens, cursorPosition), stoppedAtCursor: true, recoveryAttempts: 1 }; } static recoverWithTruncation(sql, cursorPosition, options) { // Try truncating at cursor position and adding minimal completion const truncated = sql.substring(0, cursorPosition); const completions = [ '', // Try as-is first ' 1', // Add simple expression ' FROM dual', // Add FROM clause ' WHERE 1=1' // Add WHERE clause ]; for (const completion of completions) { try { const testSQL = truncated + completion; const result = SelectQueryParser_1.SelectQueryParser.analyze(testSQL); if (result.success) { const tokens = this.getAllTokens(sql); return { ...result, parsedTokens: tokens.filter(t => t.position && t.position.startPosition <= cursorPosition), tokenBeforeCursor: this.findTokenBeforePosition(tokens, cursorPosition), stoppedAtCursor: true, recoveryAttempts: 1 }; } } catch (error) { continue; } } throw new Error('Truncation recovery failed'); } static recoverWithCompletion(sql, cursorPosition, options) { // Try completing common incomplete patterns const beforeCursor = sql.substring(0, cursorPosition); const afterCursor = sql.substring(cursorPosition); const completions = [ { pattern: /\.\s*$/, completion: 'id' }, // Complete column reference { pattern: /\w+\s*$/, completion: '' }, // Complete identifier { pattern: /,\s*$/, completion: '1' }, // Complete list item { pattern: /\(\s*$/, completion: '1)' } // Complete parentheses ]; for (const comp of completions) { if (comp.pattern.test(beforeCursor)) { const testSQL = beforeCursor + comp.completion + afterCursor; try { const result = SelectQueryParser_1.SelectQueryParser.analyze(testSQL); if (result.success) { const tokens = this.getAllTokens(sql); return { ...result, parsedTokens: tokens, tokenBeforeCursor: this.findTokenBeforePosition(tokens, cursorPosition), stoppedAtCursor: true, recoveryAttempts: 1 }; } } catch (error) { continue; } } } throw new Error('Completion recovery failed'); } static recoverWithMinimalSQL(sql, cursorPosition, options) { // Generate minimal valid SQL that preserves structure up to cursor const minimalSQL = 'SELECT 1 FROM dual WHERE 1=1'; try { const result = SelectQueryParser_1.SelectQueryParser.analyze(minimalSQL); const tokens = this.getAllTokens(sql); return { success: true, query: result.query, parsedTokens: tokens.filter(t => t.position && t.position.startPosition <= cursorPosition), tokenBeforeCursor: this.findTokenBeforePosition(tokens, cursorPosition), stoppedAtCursor: true, partialAST: result.query, recoveryAttempts: 1 }; } catch (error) { throw new Error('Minimal SQL recovery failed'); } } static getAllTokens(sql) { try { // Use LexemeCursor which includes position information return LexemeCursor_1.LexemeCursor.getAllLexemesWithPosition(sql); } catch (error) { return []; } } static findTokenAtPosition(tokens, position) { return tokens.find(token => token.position && position >= token.position.startPosition && position < token.position.endPosition); } static findTokenBeforePosition(tokens, position) { // Find the last token that ends at or before the position let beforeToken; for (const token of tokens) { if (token.position) { if (token.position.endPosition <= position) { beforeToken = token; } else if (token.position.startPosition < position) { // Current position is within this token, so previous token is the one before break; } else { // We've passed the cursor position break; } } } return beforeToken; } static findQueryBoundaries(sql) { const boundaries = []; let currentStart = 0; let inString = false; let stringChar = ''; let inComment = false; for (let i = 0; i < sql.length; i++) { const char = sql[i]; const nextChar = i < sql.length - 1 ? sql[i + 1] : ''; // Handle string literals if (!inComment && (char === "'" || char === '"')) { if (!inString) { inString = true; stringChar = char; } else if (char === stringChar) { inString = false; stringChar = ''; } continue; } // Handle comments if (!inString && char === '-' && nextChar === '-') { inComment = true; i++; // Skip next char continue; } if (inComment && char === '\n') { inComment = false; continue; } // Handle semicolons (query boundaries) if (!inString && !inComment && char === ';') { boundaries.push({ start: currentStart, end: i }); currentStart = i + 1; } } // Add final query if no trailing semicolon if (currentStart < sql.length) { boundaries.push({ start: currentStart, end: sql.length }); } return boundaries; } static findQueryAtPosition(boundaries, position) { return boundaries.find(boundary => position >= boundary.start && position <= boundary.end); } } exports.PositionAwareParser = PositionAwareParser; //# sourceMappingURL=PositionAwareParser.js.map