From 9f1d527b65f2ea1a729d7238de158f5b8d8ec7a9 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 26 Jun 2025 02:38:53 -0700 Subject: [PATCH 01/14] Error Positions --- versions/17/Makefile | 4 +- versions/17/README_ERROR_HANDLING.md | 174 ++++++++++++++++++ versions/17/example-error-format.js | 17 ++ versions/17/src/index.ts | 249 ++++++++++++++++++++++++-- versions/17/src/wasm_wrapper.c | 24 +++ versions/17/test-error-details.js | 109 +++++++++++ versions/17/test-error-handling.js | 69 +++++++ versions/17/test-format-helper.js | 133 ++++++++++++++ versions/17/test-reserved-words.js | 74 ++++++++ versions/17/test-various-positions.js | 127 +++++++++++++ 10 files changed, 964 insertions(+), 16 deletions(-) create mode 100644 versions/17/README_ERROR_HANDLING.md create mode 100644 versions/17/example-error-format.js create mode 100644 versions/17/test-error-details.js create mode 100644 versions/17/test-error-handling.js create mode 100644 versions/17/test-format-helper.js create mode 100644 versions/17/test-reserved-words.js create mode 100644 versions/17/test-various-positions.js diff --git a/versions/17/Makefile b/versions/17/Makefile index ac31dd9..9091db4 100644 --- a/versions/17/Makefile +++ b/versions/17/Makefile @@ -57,8 +57,8 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR) \ -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ - -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string']" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','UTF8ToString','HEAPU8','HEAPU32']" \ + -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','UTF8ToString','getValue','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ -sMODULARIZE=1 \ diff --git a/versions/17/README_ERROR_HANDLING.md b/versions/17/README_ERROR_HANDLING.md new file mode 100644 index 0000000..cfbefed --- /dev/null +++ b/versions/17/README_ERROR_HANDLING.md @@ -0,0 +1,174 @@ +# Enhanced Error Handling in libpg-query-node v17 + +## Overview + +Version 17 includes enhanced error handling that provides detailed information about SQL parsing errors, including exact error positions, source file information, and visual error indicators. + +## Error Details + +When a parsing error occurs, the error object now includes a `sqlDetails` property with the following information: + +```typescript +interface SqlErrorDetails { + message: string; // Full error message + cursorPosition: number; // 0-based position in the query + fileName?: string; // Source file (e.g., 'scan.l', 'gram.y') + functionName?: string; // Internal function name + lineNumber?: number; // Line number in source file + context?: string; // Additional context +} +``` + +## Basic Usage + +```javascript +const { parseSync, loadModule } = require('@libpg-query/v17'); + +await loadModule(); + +try { + const result = parseSync("SELECT * FROM users WHERE id = 'unclosed"); +} catch (error) { + if (error.sqlDetails) { + console.log('Error:', error.message); + console.log('Position:', error.sqlDetails.cursorPosition); + console.log('Source:', error.sqlDetails.fileName); + } +} +``` + +## Error Formatting Helper + +The library includes a built-in `formatSqlError()` function for consistent error formatting: + +```javascript +const { parseSync, loadModule, formatSqlError } = require('@libpg-query/v17'); + +await loadModule(); + +const query = "SELECT * FROM users WHERE id = 'unclosed"; + +try { + parseSync(query); +} catch (error) { + console.log(formatSqlError(error, query)); +} +``` + +Output: +``` +Error: unterminated quoted string at or near "'unclosed" +Position: 31 +Source: file: scan.l, function: scanner_yyerror, line: 1262 +SELECT * FROM users WHERE id = 'unclosed + ^ +``` + +## Formatting Options + +The `formatSqlError()` function accepts options to customize the output: + +```typescript +interface SqlErrorFormatOptions { + showPosition?: boolean; // Show the error position marker (default: true) + showQuery?: boolean; // Show the query text (default: true) + color?: boolean; // Use ANSI colors (default: false) + maxQueryLength?: number; // Max query length to display (default: no limit) +} +``` + +### Examples + +#### With Colors (for terminal output) +```javascript +console.log(formatSqlError(error, query, { color: true })); +``` + +#### Without Position Marker +```javascript +console.log(formatSqlError(error, query, { showPosition: false })); +``` + +#### With Query Truncation (for long queries) +```javascript +console.log(formatSqlError(error, longQuery, { maxQueryLength: 80 })); +``` + +## Type Guard + +Use the `hasSqlDetails()` function to check if an error has SQL details: + +```javascript +const { hasSqlDetails } = require('@libpg-query/v17'); + +try { + parseSync(query); +} catch (error) { + if (hasSqlDetails(error)) { + // TypeScript knows error has sqlDetails property + console.log('Error at position:', error.sqlDetails.cursorPosition); + } +} +``` + +## Error Types + +Errors are classified by their source file: +- **Lexer errors** (`scan.l`): Token recognition errors (invalid characters, unterminated strings) +- **Parser errors** (`gram.y`): Grammar violations (syntax errors, missing keywords) + +## Examples of Common Errors + +### Unterminated String +```sql +SELECT * FROM users WHERE name = 'unclosed +``` +Error: `unterminated quoted string at or near "'unclosed"` + +### Invalid Character +```sql +SELECT * FROM users WHERE id = @ +``` +Error: `syntax error at end of input` + +### Reserved Keyword +```sql +SELECT * FROM table +``` +Error: `syntax error at or near "table"` (use quotes: `"table"`) + +### Missing Keyword +```sql +SELECT * WHERE id = 1 +``` +Error: `syntax error at or near "WHERE"` + +## Backward Compatibility + +The enhanced error handling is fully backward compatible: +- Existing code that catches errors will continue to work +- The `sqlDetails` property is added without modifying the base Error object +- All existing error properties and methods remain unchanged + +## Migration Guide + +To take advantage of the new error handling: + +1. **Check for sqlDetails**: + ```javascript + if (error.sqlDetails) { + // Use enhanced error information + } + ``` + +2. **Use the formatting helper**: + ```javascript + console.log(formatSqlError(error, query)); + ``` + +3. **Type-safe access** (TypeScript): + ```typescript + if (hasSqlDetails(error)) { + // error.sqlDetails is now typed + } + ``` \ No newline at end of file diff --git a/versions/17/example-error-format.js b/versions/17/example-error-format.js new file mode 100644 index 0000000..b71f88d --- /dev/null +++ b/versions/17/example-error-format.js @@ -0,0 +1,17 @@ +const { parseSync, loadModule, formatSqlError } = require('./wasm/index.cjs'); + +async function main() { + await loadModule(); + + const query = "SELECT * FROM users WHERE id = 'unclosed"; + + try { + parseSync(query); + } catch (error) { + // Simple format matching your example + console.log(`Query: ${query}`); + console.log(formatSqlError(error, query)); + } +} + +main().catch(console.error); \ No newline at end of file diff --git a/versions/17/src/index.ts b/versions/17/src/index.ts index ef2d047..60c6d86 100644 --- a/versions/17/src/index.ts +++ b/versions/17/src/index.ts @@ -5,6 +5,125 @@ import PgQueryModule from './libpg-query.js'; let wasmModule: any; +// SQL error details interface +export interface SqlErrorDetails { + message: string; + cursorPosition: number; // 0-based position in the query + fileName?: string; // Source file where error occurred (e.g., 'scan.l', 'gram.y') + functionName?: string; // Internal function name + lineNumber?: number; // Line number in source file + context?: string; // Additional context +} + +// Options for formatting SQL errors +export interface SqlErrorFormatOptions { + showPosition?: boolean; // Show the error position marker (default: true) + showQuery?: boolean; // Show the query text (default: true) + color?: boolean; // Use ANSI colors (default: false) + maxQueryLength?: number; // Max query length to display (default: no limit) +} + +// Helper function to create enhanced error with SQL details +function createSqlError(message: string, details: SqlErrorDetails): Error { + const error = new Error(message); + // Attach error details as properties + Object.defineProperty(error, 'sqlDetails', { + value: details, + enumerable: true, + configurable: true + }); + return error; +} + +// Helper function to classify error source +function getErrorSource(filename: string | null): string { + if (!filename) return 'unknown'; + if (filename === 'scan.l') return 'lexer'; // Lexical analysis errors + if (filename === 'gram.y') return 'parser'; // Grammar/parsing errors + return filename; +} + +// Format SQL error with visual position indicator +export function formatSqlError( + error: Error & { sqlDetails?: SqlErrorDetails }, + query: string, + options: SqlErrorFormatOptions = {} +): string { + const { + showPosition = true, + showQuery = true, + color = false, + maxQueryLength + } = options; + + const lines: string[] = []; + + // ANSI color codes + const red = color ? '\x1b[31m' : ''; + const yellow = color ? '\x1b[33m' : ''; + const reset = color ? '\x1b[0m' : ''; + + // Add error message + lines.push(`${red}Error: ${error.message}${reset}`); + + // Add SQL details if available + if (error.sqlDetails) { + const { cursorPosition, fileName, functionName, lineNumber } = error.sqlDetails; + + if (cursorPosition !== undefined && cursorPosition >= 0) { + lines.push(`Position: ${cursorPosition}`); + } + + if (fileName || functionName || lineNumber) { + const details = []; + if (fileName) details.push(`file: ${fileName}`); + if (functionName) details.push(`function: ${functionName}`); + if (lineNumber) details.push(`line: ${lineNumber}`); + lines.push(`Source: ${details.join(', ')}`); + } + + // Show query with position marker + if (showQuery && showPosition && cursorPosition !== undefined && cursorPosition >= 0) { + let displayQuery = query; + + // Truncate if needed + if (maxQueryLength && query.length > maxQueryLength) { + const start = Math.max(0, cursorPosition - Math.floor(maxQueryLength / 2)); + const end = Math.min(query.length, start + maxQueryLength); + displayQuery = (start > 0 ? '...' : '') + + query.substring(start, end) + + (end < query.length ? '...' : ''); + // Adjust cursor position for truncation + const adjustedPosition = cursorPosition - start + (start > 0 ? 3 : 0); + lines.push(displayQuery); + lines.push(' '.repeat(adjustedPosition) + `${yellow}^${reset}`); + } else { + lines.push(displayQuery); + lines.push(' '.repeat(cursorPosition) + `${yellow}^${reset}`); + } + } + } else if (showQuery) { + // No SQL details, just show the query if requested + let displayQuery = query; + if (maxQueryLength && query.length > maxQueryLength) { + displayQuery = query.substring(0, maxQueryLength) + '...'; + } + lines.push(`Query: ${displayQuery}`); + } + + return lines.join('\n'); +} + +// Check if an error has SQL details +export function hasSqlDetails(error: any): error is Error & { sqlDetails: SqlErrorDetails } { + return error instanceof Error && + 'sqlDetails' in error && + typeof (error as any).sqlDetails === 'object' && + (error as any).sqlDetails !== null && + 'message' in (error as any).sqlDetails && + 'cursorPosition' in (error as any).sqlDetails; +} + const initPromise = PgQueryModule().then((module: any) => { wasmModule = module; }); @@ -51,37 +170,139 @@ function ptrToString(ptr: number): string { } export const parse = awaitInit(async (query: string) => { + // Pre-validation + if (query === null || query === undefined) { + throw new Error('Query cannot be null or undefined'); + } + if (typeof query !== 'string') { + throw new Error(`Query must be a string, got ${typeof query}`); + } + if (query.trim() === '') { + throw new Error('Query cannot be empty'); + } + const queryPtr = stringToPtr(query); let resultPtr = 0; + try { - resultPtr = wasmModule._wasm_parse_query(queryPtr); - const resultStr = ptrToString(resultPtr); - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + // Call the raw function that returns a struct pointer + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new Error('Failed to allocate memory for parse result'); + } + + // Read the PgQueryParseResult struct fields + // struct { char* parse_tree; char* stderr_buffer; PgQueryError* error; } + const parseTreePtr = wasmModule.getValue(resultPtr, 'i32'); // offset 0 + const stderrBufferPtr = wasmModule.getValue(resultPtr + 4, 'i32'); // offset 4 + const errorPtr = wasmModule.getValue(resultPtr + 8, 'i32'); // offset 8 + + // Check for error + if (errorPtr) { + // Read PgQueryError struct fields + // struct { char* message; char* funcname; char* filename; int lineno; int cursorpos; char* context; } + const messagePtr = wasmModule.getValue(errorPtr, 'i32'); // offset 0 + const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); // offset 4 + const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); // offset 8 + const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); // offset 12 + const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); // offset 16 + const contextPtr = wasmModule.getValue(errorPtr + 20, 'i32'); // offset 20 + + const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error'; + const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : null; + + const errorDetails: SqlErrorDetails = { + message: message, + cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based + fileName: filename || undefined, + functionName: funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined, + lineNumber: lineno > 0 ? lineno : undefined, + context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined + }; + + throw createSqlError(message, errorDetails); + } + + if (!parseTreePtr) { + throw new Error('Parse result is null'); } - return JSON.parse(resultStr); - } finally { + + const parseTree = wasmModule.UTF8ToString(parseTreePtr); + return JSON.parse(parseTree); + } + finally { wasmModule._free(queryPtr); if (resultPtr) { - wasmModule._wasm_free_string(resultPtr); + wasmModule._wasm_free_parse_result(resultPtr); } } }); export function parseSync(query: string) { + // Pre-validation + if (query === null || query === undefined) { + throw new Error('Query cannot be null or undefined'); + } + if (typeof query !== 'string') { + throw new Error(`Query must be a string, got ${typeof query}`); + } + if (query.trim() === '') { + throw new Error('Query cannot be empty'); + } + const queryPtr = stringToPtr(query); let resultPtr = 0; + try { - resultPtr = wasmModule._wasm_parse_query(queryPtr); - const resultStr = ptrToString(resultPtr); - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + // Call the raw function that returns a struct pointer + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new Error('Failed to allocate memory for parse result'); } - return JSON.parse(resultStr); - } finally { + + // Read the PgQueryParseResult struct fields + // struct { char* parse_tree; char* stderr_buffer; PgQueryError* error; } + const parseTreePtr = wasmModule.getValue(resultPtr, 'i32'); // offset 0 + const stderrBufferPtr = wasmModule.getValue(resultPtr + 4, 'i32'); // offset 4 + const errorPtr = wasmModule.getValue(resultPtr + 8, 'i32'); // offset 8 + + // Check for error + if (errorPtr) { + // Read PgQueryError struct fields + // struct { char* message; char* funcname; char* filename; int lineno; int cursorpos; char* context; } + const messagePtr = wasmModule.getValue(errorPtr, 'i32'); // offset 0 + const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); // offset 4 + const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); // offset 8 + const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); // offset 12 + const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); // offset 16 + const contextPtr = wasmModule.getValue(errorPtr + 20, 'i32'); // offset 20 + + const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error'; + const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : null; + + const errorDetails: SqlErrorDetails = { + message: message, + cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based + fileName: filename || undefined, + functionName: funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined, + lineNumber: lineno > 0 ? lineno : undefined, + context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined + }; + + throw createSqlError(message, errorDetails); + } + + if (!parseTreePtr) { + throw new Error('Parse result is null'); + } + + const parseTree = wasmModule.UTF8ToString(parseTreePtr); + return JSON.parse(parseTree); + } + finally { wasmModule._free(queryPtr); if (resultPtr) { - wasmModule._wasm_free_string(resultPtr); + wasmModule._wasm_free_parse_result(resultPtr); } } } \ No newline at end of file diff --git a/versions/17/src/wasm_wrapper.c b/versions/17/src/wasm_wrapper.c index bf297c6..b9d958f 100644 --- a/versions/17/src/wasm_wrapper.c +++ b/versions/17/src/wasm_wrapper.c @@ -46,4 +46,28 @@ char* wasm_parse_query(const char* input) { EMSCRIPTEN_KEEPALIVE void wasm_free_string(char* str) { free(str); +} + +// Raw struct access functions for parse +EMSCRIPTEN_KEEPALIVE +PgQueryParseResult* wasm_parse_query_raw(const char* input) { + if (!input) { + return NULL; + } + + PgQueryParseResult* result = (PgQueryParseResult*)safe_malloc(sizeof(PgQueryParseResult)); + if (!result) { + return NULL; + } + + *result = pg_query_parse(input); + return result; +} + +EMSCRIPTEN_KEEPALIVE +void wasm_free_parse_result(PgQueryParseResult* result) { + if (result) { + pg_query_free_parse_result(*result); + free(result); + } } \ No newline at end of file diff --git a/versions/17/test-error-details.js b/versions/17/test-error-details.js new file mode 100644 index 0000000..c12873f --- /dev/null +++ b/versions/17/test-error-details.js @@ -0,0 +1,109 @@ +const { parseSync, loadModule } = require('./wasm/index.cjs'); + +async function runTests() { + await loadModule(); + console.log('Detailed error analysis\n'); + +// Test cases with expected error positions +const testCases = [ + { + query: 'SELECT * FROM table WHERE id = @', + expectedError: '@', + description: 'Invalid character @' + }, + { + query: 'SELECT * FROM WHERE id = 1', + expectedError: 'WHERE', + description: 'Missing table name' + }, + { + query: 'SELECT * FROM table WHERE id = "unclosed string', + expectedError: '"unclosed string', + description: 'Unclosed string' + }, + { + query: 'SELECT FROM users', + expectedError: 'FROM', + description: 'Missing column list' + }, + { + query: 'SELECT * FORM users', + expectedError: 'FORM', + description: 'Typo in FROM' + }, + { + query: 'SELECT * FROM users WHERE', + expectedError: 'end of input', + description: 'Incomplete WHERE clause' + } +]; + +testCases.forEach((testCase, index) => { + console.log(`\nTest ${index + 1}: ${testCase.description}`); + console.log('Query:', testCase.query); + console.log('-'.repeat(60)); + + try { + const result = parseSync(testCase.query); + console.log('✓ Unexpectedly succeeded!'); + } catch (error) { + console.log('Error message:', error.message); + + if (error.sqlDetails) { + const details = error.sqlDetails; + console.log('\nSQL Details:'); + console.log(' Cursor Position:', details.cursorPosition); + console.log(' File Name:', details.fileName); + console.log(' Function Name:', details.functionName); + console.log(' Line Number:', details.lineNumber); + + // Show the error position + if (details.cursorPosition >= 0) { + console.log('\nError location:'); + console.log(' ' + testCase.query); + console.log(' ' + ' '.repeat(details.cursorPosition) + '^'); + + // Extract what's at the error position + const errorToken = testCase.query.substring(details.cursorPosition).split(/\s+/)[0]; + console.log(' Token at position:', errorToken || '(end of input)'); + + // Check if it matches expected + if (testCase.expectedError) { + const matches = error.message.includes(testCase.expectedError); + console.log(' Expected error at:', testCase.expectedError); + console.log(' Match:', matches ? '✓' : '✗'); + } + } + } + } +}); + +// Additional test to understand the pattern +console.log('\n\nPattern Analysis:'); +console.log('================'); + +const queries = [ + 'SELECT', + 'SELECT *', + 'SELECT * FROM', + 'SELECT * FROM t', + 'SELECT * FROM table', + 'SELECT * FROM table WHERE', + 'SELECT * FROM table WHERE id', + 'SELECT * FROM table WHERE id =', + 'SELECT * FROM table WHERE id = 1' +]; + +queries.forEach(query => { + try { + parseSync(query); + console.log(`✓ "${query}" - OK`); + } catch (error) { + const pos = error.sqlDetails?.cursorPosition ?? -1; + const token = pos >= 0 ? query.substring(pos).split(/\s+/)[0] || '(EOF)' : '?'; + console.log(`✗ "${query}" - Error at pos ${pos}: "${token}"`); + } +}); +} + +runTests().catch(console.error); \ No newline at end of file diff --git a/versions/17/test-error-handling.js b/versions/17/test-error-handling.js new file mode 100644 index 0000000..e6f43f8 --- /dev/null +++ b/versions/17/test-error-handling.js @@ -0,0 +1,69 @@ +const { parse } = require('./wasm/index.cjs'); + +async function testErrorHandling() { + console.log('Testing enhanced error handling with SqlErrorDetails\n'); + + // Test cases + const testQueries = [ + // Syntax error (lexer) + 'SELECT * FROM table WHERE id = @', + + // Semantic error (parser) + 'SELECT * FROM WHERE id = 1', + + // Another syntax error + 'SELECT * FROM table WHERE id = "unclosed string', + + // Empty query + '', + + // Null query + null, + + // Valid query (should succeed) + 'SELECT * FROM users WHERE id = 1' + ]; + + for (let index = 0; index < testQueries.length; index++) { + const query = testQueries[index]; + console.log(`\nTest ${index + 1}: ${query === null ? 'null' : query === '' ? '(empty string)' : query}`); + console.log('-'.repeat(60)); + + try { + const result = await parse(query); + console.log('✓ Success: Query parsed successfully'); + console.log(' Result type:', result.stmts[0].stmt.constructor.name); + } catch (error) { + console.log('✗ Error:', error.message); + + // Check if error has sqlDetails + if (error.sqlDetails) { + console.log('\n SQL Error Details:'); + console.log(' Message:', error.sqlDetails.message); + console.log(' Cursor Position:', error.sqlDetails.cursorPosition); + console.log(' File Name:', error.sqlDetails.fileName || '(not available)'); + console.log(' Function Name:', error.sqlDetails.functionName || '(not available)'); + console.log(' Line Number:', error.sqlDetails.lineNumber || '(not available)'); + console.log(' Context:', error.sqlDetails.context || '(not available)'); + + // Show error position in query if available + if (typeof query === 'string' && error.sqlDetails.cursorPosition >= 0) { + console.log('\n Error location:'); + console.log(' ' + query); + console.log(' ' + ' '.repeat(error.sqlDetails.cursorPosition) + '^'); + } + } + } + } + + console.log('\n\nDemonstration complete!'); + console.log('\nKey features:'); + console.log('- Enhanced error details via error.sqlDetails property'); + console.log('- Cursor position (0-based) for precise error location'); + console.log('- Source file information (scan.l for lexer, gram.y for parser)'); + console.log('- Pre-validation for null/empty queries'); + console.log('- Backward compatible - existing code continues to work'); +} + +// Run the async test function +testErrorHandling().catch(console.error); \ No newline at end of file diff --git a/versions/17/test-format-helper.js b/versions/17/test-format-helper.js new file mode 100644 index 0000000..57870f2 --- /dev/null +++ b/versions/17/test-format-helper.js @@ -0,0 +1,133 @@ +const { parseSync, loadModule, formatSqlError, hasSqlDetails } = require('./wasm/index.cjs'); + +async function runTests() { + await loadModule(); + + console.log('Testing native SQL error formatting helper\n'); + console.log('='.repeat(60)); + + const testCases = [ + { + query: "SELECT * FROM users WHERE id = 'unclosed", + desc: 'Unclosed string literal' + }, + { + query: 'SELECT * FROM users WHERE id = @ AND name = "test"', + desc: 'Invalid @ character' + }, + { + query: 'SELECT * FROM users WHERE id IN (1, 2, @, 4)', + desc: 'Invalid @ in IN list' + }, + { + query: 'CREATE TABLE test_table (id INTEGER, name @)', + desc: 'Invalid @ in CREATE TABLE' + }, + { + query: 'SELECT * FROM users WHERE created_at > NOW() AND status = @ ORDER BY id', + desc: 'Long query with error' + } + ]; + + // Test basic formatting + console.log('\n1. Basic Error Formatting (default options)'); + console.log('-'.repeat(60)); + + testCases.forEach((testCase, index) => { + try { + parseSync(testCase.query); + } catch (error) { + if (hasSqlDetails(error)) { + console.log(`\nExample ${index + 1}: ${testCase.desc}`); + console.log(formatSqlError(error, testCase.query)); + console.log(); + } + } + }); + + // Test with colors + console.log('\n2. Error Formatting with Colors'); + console.log('-'.repeat(60)); + + try { + parseSync("SELECT * FROM users WHERE id = 'unclosed"); + } catch (error) { + if (hasSqlDetails(error)) { + console.log(formatSqlError(error, "SELECT * FROM users WHERE id = 'unclosed", { color: true })); + } + } + + // Test without position marker + console.log('\n\n3. Error Formatting without Position Marker'); + console.log('-'.repeat(60)); + + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (error) { + if (hasSqlDetails(error)) { + console.log(formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + showPosition: false + })); + } + } + + // Test without query + console.log('\n\n4. Error Formatting without Query'); + console.log('-'.repeat(60)); + + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (error) { + if (hasSqlDetails(error)) { + console.log(formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + showQuery: false + })); + } + } + + // Test with truncation + console.log('\n\n5. Error Formatting with Query Truncation'); + console.log('-'.repeat(60)); + + const longQuery = 'SELECT id, name, email, phone, address, city, state, zip, country FROM users WHERE status = "active" AND created_at > NOW() - INTERVAL 30 DAY AND email LIKE "%@example.com" AND id = @ ORDER BY created_at DESC LIMIT 100'; + + try { + parseSync(longQuery); + } catch (error) { + if (hasSqlDetails(error)) { + console.log('Full query length:', longQuery.length); + console.log('\nWith maxQueryLength=80:'); + console.log(formatSqlError(error, longQuery, { maxQueryLength: 80 })); + } + } + + // Test hasSqlDetails type guard + console.log('\n\n6. Type Guard Function'); + console.log('-'.repeat(60)); + + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (error) { + console.log('hasSqlDetails(error):', hasSqlDetails(error)); + console.log('error instanceof Error:', error instanceof Error); + console.log('Has sqlDetails property:', 'sqlDetails' in error); + } + + // Regular error without SQL details + try { + throw new Error('Regular error without SQL details'); + } catch (error) { + console.log('\nRegular error:'); + console.log('hasSqlDetails(error):', hasSqlDetails(error)); + } + + console.log('\n\n' + '='.repeat(60)); + console.log('Summary: The formatSqlError() helper is now part of the library!'); + console.log('It provides consistent, customizable error formatting with:'); + console.log('- Visual position indicators'); + console.log('- Optional ANSI colors'); + console.log('- Query truncation for long queries'); + console.log('- Flexible display options'); +} + +runTests().catch(console.error); \ No newline at end of file diff --git a/versions/17/test-reserved-words.js b/versions/17/test-reserved-words.js new file mode 100644 index 0000000..69ec5cb --- /dev/null +++ b/versions/17/test-reserved-words.js @@ -0,0 +1,74 @@ +const { parseSync, loadModule } = require('./wasm/index.cjs'); + +async function runTests() { + await loadModule(); + + console.log('Testing reserved vs non-reserved words\n'); + + // Test with reserved words vs non-reserved + const queries = [ + 'SELECT * FROM table', // 'table' is reserved + 'SELECT * FROM users', // 'users' is not reserved + 'SELECT * FROM "table"', // quoted reserved word should work + 'SELECT * FROM mytable', // not reserved + 'SELECT * FROM user', // 'user' is reserved + 'SELECT * FROM "user"', // quoted reserved + 'SELECT * FROM customer', // not reserved + 'SELECT * FROM order', // 'order' is reserved + 'SELECT * FROM orders', // not reserved + ]; + + console.log('Reserved word tests:'); + console.log('===================\n'); + + queries.forEach(query => { + try { + const result = parseSync(query); + console.log(`✓ OK: ${query}`); + } catch (error) { + const pos = error.sqlDetails?.cursorPosition ?? -1; + console.log(`✗ ERROR: ${query}`); + console.log(` Position: ${pos}, Message: ${error.message}`); + if (pos >= 0) { + console.log(` ${query}`); + console.log(` ${' '.repeat(pos)}^`); + } + } + }); + + // Now test actual syntax errors + console.log('\n\nActual syntax error tests:'); + console.log('=========================\n'); + + const errorQueries = [ + { query: 'SELECT * FROM users WHERE id = @', desc: 'Invalid @ character' }, + { query: 'SELECT * FROM users WHERE id = "unclosed', desc: 'Unclosed string' }, + { query: 'SELECT * FROM users WHERE id = \'unclosed', desc: 'Unclosed single quote' }, + { query: 'SELECT * FROM users WHERE id ==', desc: 'Double equals' }, + { query: 'SELECT * FROM users WHERE id = 1 AND', desc: 'Incomplete AND' }, + { query: 'SELECT * FROM users WHERE id = 1a', desc: 'Invalid number' }, + { query: 'SELECT * FROM users WHERE id = 1 2', desc: 'Two numbers' }, + { query: 'SELECT * FROM users WHERE id = $', desc: 'Invalid $ alone' }, + { query: 'SELECT * FROM users WHERE id = ?', desc: 'Question mark' }, + ]; + + errorQueries.forEach(({ query, desc }) => { + try { + const result = parseSync(query); + console.log(`✓ UNEXPECTED OK: ${desc}`); + console.log(` Query: ${query}`); + } catch (error) { + const pos = error.sqlDetails?.cursorPosition ?? -1; + console.log(`✗ ${desc}`); + console.log(` Query: ${query}`); + console.log(` Error: ${error.message}`); + if (pos >= 0) { + console.log(` Position: ${pos}`); + console.log(` ${query}`); + console.log(` ${' '.repeat(pos)}^`); + } + } + }); +} + +runTests().catch(console.error); \ No newline at end of file diff --git a/versions/17/test-various-positions.js b/versions/17/test-various-positions.js new file mode 100644 index 0000000..ead8ef7 --- /dev/null +++ b/versions/17/test-various-positions.js @@ -0,0 +1,127 @@ +const { parseSync, loadModule } = require('./wasm/index.cjs'); + +async function runTests() { + await loadModule(); + + console.log('Testing errors at various positions in queries\n'); + console.log('='.repeat(60)); + + const testCases = [ + // Errors at different positions + { + query: '@ SELECT * FROM users', + desc: 'Error at position 0' + }, + { + query: 'SELECT @ FROM users', + desc: 'Error after SELECT' + }, + { + query: 'SELECT * FROM users WHERE @ = 1', + desc: 'Error after WHERE' + }, + { + query: 'SELECT * FROM users WHERE id = @ AND name = "test"', + desc: 'Error in middle of WHERE clause' + }, + { + query: 'SELECT * FROM users WHERE id = 1 AND name = @ ORDER BY id', + desc: 'Error before ORDER BY' + }, + { + query: 'SELECT * FROM users WHERE id = 1 ORDER BY @', + desc: 'Error after ORDER BY' + }, + { + query: 'SELECT * FROM users WHERE id = 1 GROUP BY id HAVING @', + desc: 'Error after HAVING' + }, + { + query: 'SELECT id, name, @ FROM users', + desc: 'Error in column list' + }, + { + query: 'SELECT * FROM users u JOIN orders o ON u.id = @ WHERE u.active = true', + desc: 'Error in JOIN condition' + }, + { + query: 'SELECT * FROM users WHERE id IN (1, 2, @, 4)', + desc: 'Error in IN list' + }, + { + query: 'SELECT * FROM users WHERE name LIKE "test@"', + desc: 'Valid @ in string (should succeed)' + }, + { + query: 'INSERT INTO users (id, name) VALUES (1, @)', + desc: 'Error in INSERT VALUES' + }, + { + query: 'UPDATE users SET name = @ WHERE id = 1', + desc: 'Error in UPDATE SET' + }, + { + query: 'DELETE FROM users WHERE id = @ AND name = "test"', + desc: 'Error in DELETE WHERE' + }, + { + query: 'CREATE TABLE test_table (id INTEGER, name @)', + desc: 'Error in CREATE TABLE' + }, + { + query: 'SELECT COUNT(*) FROM users WHERE created_at > @ GROUP BY status', + desc: 'Error in date comparison' + }, + { + query: 'SELECT * FROM users WHERE id = 1; SELECT * FROM orders WHERE user_id = @', + desc: 'Error in second statement' + }, + { + query: 'WITH cte AS (SELECT * FROM users WHERE id = @) SELECT * FROM cte', + desc: 'Error in CTE' + }, + { + query: 'SELECT CASE WHEN id = 1 THEN "one" WHEN id = 2 THEN @ ELSE "other" END FROM users', + desc: 'Error in CASE statement' + }, + { + query: 'SELECT * FROM users WHERE id = 1 /* comment with @ */ AND name = @', + desc: 'Error after comment (@ in comment should be ignored)' + } + ]; + + testCases.forEach((testCase, index) => { + console.log(`\nTest ${index + 1}: ${testCase.desc}`); + console.log('-'.repeat(60)); + console.log(`Query: ${testCase.query}`); + + try { + const result = parseSync(testCase.query); + console.log('✓ SUCCESS - Query parsed without errors'); + } catch (error) { + if (error.sqlDetails) { + const pos = error.sqlDetails.cursorPosition; + console.log(`✗ ERROR: ${error.message}`); + console.log(` Position: ${pos}`); + + // Show error location + console.log(`\n ${testCase.query}`); + console.log(` ${' '.repeat(pos)}^`); + + // Show what's at and around the error position + const before = testCase.query.substring(Math.max(0, pos - 10), pos); + const at = testCase.query.substring(pos, pos + 1) || '(EOF)'; + const after = testCase.query.substring(pos + 1, pos + 11); + + console.log(`\n Context: ...${before}[${at}]${after}...`); + } else { + console.log(`✗ ERROR: ${error.message} (no SQL details)`); + } + } + }); + + console.log('\n\n' + '='.repeat(60)); + console.log('Summary: The cursor position correctly identifies where errors occur throughout the query!'); +} + +runTests().catch(console.error); \ No newline at end of file From 334ea4a3f0fb6804c006b6a4d94651f8502e65e9 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 26 Jun 2025 08:44:20 -0700 Subject: [PATCH 02/14] node --test --- versions/17/example-error-format.js | 17 -- versions/17/test-error-details.js | 109 --------- versions/17/test-error-handling.js | 69 ------ versions/17/test-format-helper.js | 133 ----------- versions/17/test-reserved-words.js | 74 ------ versions/17/test-various-positions.js | 127 ---------- versions/17/test/errors.test.js | 325 ++++++++++++++++++++++++++ 7 files changed, 325 insertions(+), 529 deletions(-) delete mode 100644 versions/17/example-error-format.js delete mode 100644 versions/17/test-error-details.js delete mode 100644 versions/17/test-error-handling.js delete mode 100644 versions/17/test-format-helper.js delete mode 100644 versions/17/test-reserved-words.js delete mode 100644 versions/17/test-various-positions.js create mode 100644 versions/17/test/errors.test.js diff --git a/versions/17/example-error-format.js b/versions/17/example-error-format.js deleted file mode 100644 index b71f88d..0000000 --- a/versions/17/example-error-format.js +++ /dev/null @@ -1,17 +0,0 @@ -const { parseSync, loadModule, formatSqlError } = require('./wasm/index.cjs'); - -async function main() { - await loadModule(); - - const query = "SELECT * FROM users WHERE id = 'unclosed"; - - try { - parseSync(query); - } catch (error) { - // Simple format matching your example - console.log(`Query: ${query}`); - console.log(formatSqlError(error, query)); - } -} - -main().catch(console.error); \ No newline at end of file diff --git a/versions/17/test-error-details.js b/versions/17/test-error-details.js deleted file mode 100644 index c12873f..0000000 --- a/versions/17/test-error-details.js +++ /dev/null @@ -1,109 +0,0 @@ -const { parseSync, loadModule } = require('./wasm/index.cjs'); - -async function runTests() { - await loadModule(); - console.log('Detailed error analysis\n'); - -// Test cases with expected error positions -const testCases = [ - { - query: 'SELECT * FROM table WHERE id = @', - expectedError: '@', - description: 'Invalid character @' - }, - { - query: 'SELECT * FROM WHERE id = 1', - expectedError: 'WHERE', - description: 'Missing table name' - }, - { - query: 'SELECT * FROM table WHERE id = "unclosed string', - expectedError: '"unclosed string', - description: 'Unclosed string' - }, - { - query: 'SELECT FROM users', - expectedError: 'FROM', - description: 'Missing column list' - }, - { - query: 'SELECT * FORM users', - expectedError: 'FORM', - description: 'Typo in FROM' - }, - { - query: 'SELECT * FROM users WHERE', - expectedError: 'end of input', - description: 'Incomplete WHERE clause' - } -]; - -testCases.forEach((testCase, index) => { - console.log(`\nTest ${index + 1}: ${testCase.description}`); - console.log('Query:', testCase.query); - console.log('-'.repeat(60)); - - try { - const result = parseSync(testCase.query); - console.log('✓ Unexpectedly succeeded!'); - } catch (error) { - console.log('Error message:', error.message); - - if (error.sqlDetails) { - const details = error.sqlDetails; - console.log('\nSQL Details:'); - console.log(' Cursor Position:', details.cursorPosition); - console.log(' File Name:', details.fileName); - console.log(' Function Name:', details.functionName); - console.log(' Line Number:', details.lineNumber); - - // Show the error position - if (details.cursorPosition >= 0) { - console.log('\nError location:'); - console.log(' ' + testCase.query); - console.log(' ' + ' '.repeat(details.cursorPosition) + '^'); - - // Extract what's at the error position - const errorToken = testCase.query.substring(details.cursorPosition).split(/\s+/)[0]; - console.log(' Token at position:', errorToken || '(end of input)'); - - // Check if it matches expected - if (testCase.expectedError) { - const matches = error.message.includes(testCase.expectedError); - console.log(' Expected error at:', testCase.expectedError); - console.log(' Match:', matches ? '✓' : '✗'); - } - } - } - } -}); - -// Additional test to understand the pattern -console.log('\n\nPattern Analysis:'); -console.log('================'); - -const queries = [ - 'SELECT', - 'SELECT *', - 'SELECT * FROM', - 'SELECT * FROM t', - 'SELECT * FROM table', - 'SELECT * FROM table WHERE', - 'SELECT * FROM table WHERE id', - 'SELECT * FROM table WHERE id =', - 'SELECT * FROM table WHERE id = 1' -]; - -queries.forEach(query => { - try { - parseSync(query); - console.log(`✓ "${query}" - OK`); - } catch (error) { - const pos = error.sqlDetails?.cursorPosition ?? -1; - const token = pos >= 0 ? query.substring(pos).split(/\s+/)[0] || '(EOF)' : '?'; - console.log(`✗ "${query}" - Error at pos ${pos}: "${token}"`); - } -}); -} - -runTests().catch(console.error); \ No newline at end of file diff --git a/versions/17/test-error-handling.js b/versions/17/test-error-handling.js deleted file mode 100644 index e6f43f8..0000000 --- a/versions/17/test-error-handling.js +++ /dev/null @@ -1,69 +0,0 @@ -const { parse } = require('./wasm/index.cjs'); - -async function testErrorHandling() { - console.log('Testing enhanced error handling with SqlErrorDetails\n'); - - // Test cases - const testQueries = [ - // Syntax error (lexer) - 'SELECT * FROM table WHERE id = @', - - // Semantic error (parser) - 'SELECT * FROM WHERE id = 1', - - // Another syntax error - 'SELECT * FROM table WHERE id = "unclosed string', - - // Empty query - '', - - // Null query - null, - - // Valid query (should succeed) - 'SELECT * FROM users WHERE id = 1' - ]; - - for (let index = 0; index < testQueries.length; index++) { - const query = testQueries[index]; - console.log(`\nTest ${index + 1}: ${query === null ? 'null' : query === '' ? '(empty string)' : query}`); - console.log('-'.repeat(60)); - - try { - const result = await parse(query); - console.log('✓ Success: Query parsed successfully'); - console.log(' Result type:', result.stmts[0].stmt.constructor.name); - } catch (error) { - console.log('✗ Error:', error.message); - - // Check if error has sqlDetails - if (error.sqlDetails) { - console.log('\n SQL Error Details:'); - console.log(' Message:', error.sqlDetails.message); - console.log(' Cursor Position:', error.sqlDetails.cursorPosition); - console.log(' File Name:', error.sqlDetails.fileName || '(not available)'); - console.log(' Function Name:', error.sqlDetails.functionName || '(not available)'); - console.log(' Line Number:', error.sqlDetails.lineNumber || '(not available)'); - console.log(' Context:', error.sqlDetails.context || '(not available)'); - - // Show error position in query if available - if (typeof query === 'string' && error.sqlDetails.cursorPosition >= 0) { - console.log('\n Error location:'); - console.log(' ' + query); - console.log(' ' + ' '.repeat(error.sqlDetails.cursorPosition) + '^'); - } - } - } - } - - console.log('\n\nDemonstration complete!'); - console.log('\nKey features:'); - console.log('- Enhanced error details via error.sqlDetails property'); - console.log('- Cursor position (0-based) for precise error location'); - console.log('- Source file information (scan.l for lexer, gram.y for parser)'); - console.log('- Pre-validation for null/empty queries'); - console.log('- Backward compatible - existing code continues to work'); -} - -// Run the async test function -testErrorHandling().catch(console.error); \ No newline at end of file diff --git a/versions/17/test-format-helper.js b/versions/17/test-format-helper.js deleted file mode 100644 index 57870f2..0000000 --- a/versions/17/test-format-helper.js +++ /dev/null @@ -1,133 +0,0 @@ -const { parseSync, loadModule, formatSqlError, hasSqlDetails } = require('./wasm/index.cjs'); - -async function runTests() { - await loadModule(); - - console.log('Testing native SQL error formatting helper\n'); - console.log('='.repeat(60)); - - const testCases = [ - { - query: "SELECT * FROM users WHERE id = 'unclosed", - desc: 'Unclosed string literal' - }, - { - query: 'SELECT * FROM users WHERE id = @ AND name = "test"', - desc: 'Invalid @ character' - }, - { - query: 'SELECT * FROM users WHERE id IN (1, 2, @, 4)', - desc: 'Invalid @ in IN list' - }, - { - query: 'CREATE TABLE test_table (id INTEGER, name @)', - desc: 'Invalid @ in CREATE TABLE' - }, - { - query: 'SELECT * FROM users WHERE created_at > NOW() AND status = @ ORDER BY id', - desc: 'Long query with error' - } - ]; - - // Test basic formatting - console.log('\n1. Basic Error Formatting (default options)'); - console.log('-'.repeat(60)); - - testCases.forEach((testCase, index) => { - try { - parseSync(testCase.query); - } catch (error) { - if (hasSqlDetails(error)) { - console.log(`\nExample ${index + 1}: ${testCase.desc}`); - console.log(formatSqlError(error, testCase.query)); - console.log(); - } - } - }); - - // Test with colors - console.log('\n2. Error Formatting with Colors'); - console.log('-'.repeat(60)); - - try { - parseSync("SELECT * FROM users WHERE id = 'unclosed"); - } catch (error) { - if (hasSqlDetails(error)) { - console.log(formatSqlError(error, "SELECT * FROM users WHERE id = 'unclosed", { color: true })); - } - } - - // Test without position marker - console.log('\n\n3. Error Formatting without Position Marker'); - console.log('-'.repeat(60)); - - try { - parseSync('SELECT * FROM users WHERE id = @'); - } catch (error) { - if (hasSqlDetails(error)) { - console.log(formatSqlError(error, 'SELECT * FROM users WHERE id = @', { - showPosition: false - })); - } - } - - // Test without query - console.log('\n\n4. Error Formatting without Query'); - console.log('-'.repeat(60)); - - try { - parseSync('SELECT * FROM users WHERE id = @'); - } catch (error) { - if (hasSqlDetails(error)) { - console.log(formatSqlError(error, 'SELECT * FROM users WHERE id = @', { - showQuery: false - })); - } - } - - // Test with truncation - console.log('\n\n5. Error Formatting with Query Truncation'); - console.log('-'.repeat(60)); - - const longQuery = 'SELECT id, name, email, phone, address, city, state, zip, country FROM users WHERE status = "active" AND created_at > NOW() - INTERVAL 30 DAY AND email LIKE "%@example.com" AND id = @ ORDER BY created_at DESC LIMIT 100'; - - try { - parseSync(longQuery); - } catch (error) { - if (hasSqlDetails(error)) { - console.log('Full query length:', longQuery.length); - console.log('\nWith maxQueryLength=80:'); - console.log(formatSqlError(error, longQuery, { maxQueryLength: 80 })); - } - } - - // Test hasSqlDetails type guard - console.log('\n\n6. Type Guard Function'); - console.log('-'.repeat(60)); - - try { - parseSync('SELECT * FROM users WHERE id = @'); - } catch (error) { - console.log('hasSqlDetails(error):', hasSqlDetails(error)); - console.log('error instanceof Error:', error instanceof Error); - console.log('Has sqlDetails property:', 'sqlDetails' in error); - } - - // Regular error without SQL details - try { - throw new Error('Regular error without SQL details'); - } catch (error) { - console.log('\nRegular error:'); - console.log('hasSqlDetails(error):', hasSqlDetails(error)); - } - - console.log('\n\n' + '='.repeat(60)); - console.log('Summary: The formatSqlError() helper is now part of the library!'); - console.log('It provides consistent, customizable error formatting with:'); - console.log('- Visual position indicators'); - console.log('- Optional ANSI colors'); - console.log('- Query truncation for long queries'); - console.log('- Flexible display options'); -} - -runTests().catch(console.error); \ No newline at end of file diff --git a/versions/17/test-reserved-words.js b/versions/17/test-reserved-words.js deleted file mode 100644 index 69ec5cb..0000000 --- a/versions/17/test-reserved-words.js +++ /dev/null @@ -1,74 +0,0 @@ -const { parseSync, loadModule } = require('./wasm/index.cjs'); - -async function runTests() { - await loadModule(); - - console.log('Testing reserved vs non-reserved words\n'); - - // Test with reserved words vs non-reserved - const queries = [ - 'SELECT * FROM table', // 'table' is reserved - 'SELECT * FROM users', // 'users' is not reserved - 'SELECT * FROM "table"', // quoted reserved word should work - 'SELECT * FROM mytable', // not reserved - 'SELECT * FROM user', // 'user' is reserved - 'SELECT * FROM "user"', // quoted reserved - 'SELECT * FROM customer', // not reserved - 'SELECT * FROM order', // 'order' is reserved - 'SELECT * FROM orders', // not reserved - ]; - - console.log('Reserved word tests:'); - console.log('===================\n'); - - queries.forEach(query => { - try { - const result = parseSync(query); - console.log(`✓ OK: ${query}`); - } catch (error) { - const pos = error.sqlDetails?.cursorPosition ?? -1; - console.log(`✗ ERROR: ${query}`); - console.log(` Position: ${pos}, Message: ${error.message}`); - if (pos >= 0) { - console.log(` ${query}`); - console.log(` ${' '.repeat(pos)}^`); - } - } - }); - - // Now test actual syntax errors - console.log('\n\nActual syntax error tests:'); - console.log('=========================\n'); - - const errorQueries = [ - { query: 'SELECT * FROM users WHERE id = @', desc: 'Invalid @ character' }, - { query: 'SELECT * FROM users WHERE id = "unclosed', desc: 'Unclosed string' }, - { query: 'SELECT * FROM users WHERE id = \'unclosed', desc: 'Unclosed single quote' }, - { query: 'SELECT * FROM users WHERE id ==', desc: 'Double equals' }, - { query: 'SELECT * FROM users WHERE id = 1 AND', desc: 'Incomplete AND' }, - { query: 'SELECT * FROM users WHERE id = 1a', desc: 'Invalid number' }, - { query: 'SELECT * FROM users WHERE id = 1 2', desc: 'Two numbers' }, - { query: 'SELECT * FROM users WHERE id = $', desc: 'Invalid $ alone' }, - { query: 'SELECT * FROM users WHERE id = ?', desc: 'Question mark' }, - ]; - - errorQueries.forEach(({ query, desc }) => { - try { - const result = parseSync(query); - console.log(`✓ UNEXPECTED OK: ${desc}`); - console.log(` Query: ${query}`); - } catch (error) { - const pos = error.sqlDetails?.cursorPosition ?? -1; - console.log(`✗ ${desc}`); - console.log(` Query: ${query}`); - console.log(` Error: ${error.message}`); - if (pos >= 0) { - console.log(` Position: ${pos}`); - console.log(` ${query}`); - console.log(` ${' '.repeat(pos)}^`); - } - } - }); -} - -runTests().catch(console.error); \ No newline at end of file diff --git a/versions/17/test-various-positions.js b/versions/17/test-various-positions.js deleted file mode 100644 index ead8ef7..0000000 --- a/versions/17/test-various-positions.js +++ /dev/null @@ -1,127 +0,0 @@ -const { parseSync, loadModule } = require('./wasm/index.cjs'); - -async function runTests() { - await loadModule(); - - console.log('Testing errors at various positions in queries\n'); - console.log('='.repeat(60)); - - const testCases = [ - // Errors at different positions - { - query: '@ SELECT * FROM users', - desc: 'Error at position 0' - }, - { - query: 'SELECT @ FROM users', - desc: 'Error after SELECT' - }, - { - query: 'SELECT * FROM users WHERE @ = 1', - desc: 'Error after WHERE' - }, - { - query: 'SELECT * FROM users WHERE id = @ AND name = "test"', - desc: 'Error in middle of WHERE clause' - }, - { - query: 'SELECT * FROM users WHERE id = 1 AND name = @ ORDER BY id', - desc: 'Error before ORDER BY' - }, - { - query: 'SELECT * FROM users WHERE id = 1 ORDER BY @', - desc: 'Error after ORDER BY' - }, - { - query: 'SELECT * FROM users WHERE id = 1 GROUP BY id HAVING @', - desc: 'Error after HAVING' - }, - { - query: 'SELECT id, name, @ FROM users', - desc: 'Error in column list' - }, - { - query: 'SELECT * FROM users u JOIN orders o ON u.id = @ WHERE u.active = true', - desc: 'Error in JOIN condition' - }, - { - query: 'SELECT * FROM users WHERE id IN (1, 2, @, 4)', - desc: 'Error in IN list' - }, - { - query: 'SELECT * FROM users WHERE name LIKE "test@"', - desc: 'Valid @ in string (should succeed)' - }, - { - query: 'INSERT INTO users (id, name) VALUES (1, @)', - desc: 'Error in INSERT VALUES' - }, - { - query: 'UPDATE users SET name = @ WHERE id = 1', - desc: 'Error in UPDATE SET' - }, - { - query: 'DELETE FROM users WHERE id = @ AND name = "test"', - desc: 'Error in DELETE WHERE' - }, - { - query: 'CREATE TABLE test_table (id INTEGER, name @)', - desc: 'Error in CREATE TABLE' - }, - { - query: 'SELECT COUNT(*) FROM users WHERE created_at > @ GROUP BY status', - desc: 'Error in date comparison' - }, - { - query: 'SELECT * FROM users WHERE id = 1; SELECT * FROM orders WHERE user_id = @', - desc: 'Error in second statement' - }, - { - query: 'WITH cte AS (SELECT * FROM users WHERE id = @) SELECT * FROM cte', - desc: 'Error in CTE' - }, - { - query: 'SELECT CASE WHEN id = 1 THEN "one" WHEN id = 2 THEN @ ELSE "other" END FROM users', - desc: 'Error in CASE statement' - }, - { - query: 'SELECT * FROM users WHERE id = 1 /* comment with @ */ AND name = @', - desc: 'Error after comment (@ in comment should be ignored)' - } - ]; - - testCases.forEach((testCase, index) => { - console.log(`\nTest ${index + 1}: ${testCase.desc}`); - console.log('-'.repeat(60)); - console.log(`Query: ${testCase.query}`); - - try { - const result = parseSync(testCase.query); - console.log('✓ SUCCESS - Query parsed without errors'); - } catch (error) { - if (error.sqlDetails) { - const pos = error.sqlDetails.cursorPosition; - console.log(`✗ ERROR: ${error.message}`); - console.log(` Position: ${pos}`); - - // Show error location - console.log(`\n ${testCase.query}`); - console.log(` ${' '.repeat(pos)}^`); - - // Show what's at and around the error position - const before = testCase.query.substring(Math.max(0, pos - 10), pos); - const at = testCase.query.substring(pos, pos + 1) || '(EOF)'; - const after = testCase.query.substring(pos + 1, pos + 11); - - console.log(`\n Context: ...${before}[${at}]${after}...`); - } else { - console.log(`✗ ERROR: ${error.message} (no SQL details)`); - } - } - }); - - console.log('\n\n' + '='.repeat(60)); - console.log('Summary: The cursor position correctly identifies where errors occur throughout the query!'); -} - -runTests().catch(console.error); \ No newline at end of file diff --git a/versions/17/test/errors.test.js b/versions/17/test/errors.test.js new file mode 100644 index 0000000..f6c0fd6 --- /dev/null +++ b/versions/17/test/errors.test.js @@ -0,0 +1,325 @@ +const { describe, it, before } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseSync, loadModule, formatSqlError, hasSqlDetails } = require('../wasm/index.cjs'); + +describe('Enhanced Error Handling', () => { + before(async () => { + await loadModule(); + }); + + describe('Error Details Structure', () => { + it('should include sqlDetails property on parse errors', () => { + assert.throws(() => { + parseSync('SELECT * FROM users WHERE id = @'); + }); + + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (error) { + assert.ok('sqlDetails' in error); + assert.ok('message' in error.sqlDetails); + assert.ok('cursorPosition' in error.sqlDetails); + assert.ok('fileName' in error.sqlDetails); + assert.ok('functionName' in error.sqlDetails); + assert.ok('lineNumber' in error.sqlDetails); + } + }); + + it('should have correct cursor position (0-based)', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 32); + } + }); + + it('should identify error source file', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.fileName, 'scan.l'); + assert.equal(error.sqlDetails.functionName, 'scanner_yyerror'); + } + }); + }); + + describe('Error Position Accuracy', () => { + const positionTests = [ + { query: '@ SELECT * FROM users', expectedPos: 0, desc: 'error at start' }, + { query: 'SELECT @ FROM users', expectedPos: 9, desc: 'error after SELECT' }, + { query: 'SELECT * FROM users WHERE @ = 1', expectedPos: 28, desc: 'error after WHERE' }, + { query: 'SELECT * FROM users WHERE id = @', expectedPos: 32, desc: 'error at end' }, + { query: 'INSERT INTO users (id, name) VALUES (1, @)', expectedPos: 41, desc: 'error in VALUES' }, + { query: 'UPDATE users SET name = @ WHERE id = 1', expectedPos: 26, desc: 'error in SET' }, + { query: 'CREATE TABLE test (id INT, name @)', expectedPos: 32, desc: 'error in CREATE TABLE' }, + ]; + + positionTests.forEach(({ query, expectedPos, desc }) => { + it(`should correctly identify position for ${desc}`, () => { + try { + parseSync(query); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, expectedPos); + } + }); + }); + }); + + describe('Error Types', () => { + it('should handle unterminated string literals', () => { + try { + parseSync("SELECT * FROM users WHERE name = 'unclosed"); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('unterminated quoted string')); + assert.equal(error.sqlDetails.cursorPosition, 33); + } + }); + + it('should handle unterminated quoted identifiers', () => { + try { + parseSync('SELECT * FROM users WHERE name = "unclosed'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('unterminated quoted identifier')); + assert.equal(error.sqlDetails.cursorPosition, 33); + } + }); + + it('should handle invalid tokens', () => { + try { + parseSync('SELECT * FROM users WHERE id = $'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at or near "$"')); + assert.equal(error.sqlDetails.cursorPosition, 31); + } + }); + + it('should handle reserved keywords', () => { + try { + parseSync('SELECT * FROM table'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at or near "table"')); + assert.equal(error.sqlDetails.cursorPosition, 14); + } + }); + + it('should handle syntax error in WHERE clause', () => { + try { + parseSync('SELECT * FROM users WHERE'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at end of input')); + assert.equal(error.sqlDetails.cursorPosition, 25); + } + }); + }); + + describe('formatSqlError Helper', () => { + it('should format error with position indicator', () => { + try { + parseSync("SELECT * FROM users WHERE id = 'unclosed"); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, "SELECT * FROM users WHERE id = 'unclosed"); + assert.ok(formatted.includes('Error: unterminated quoted string')); + assert.ok(formatted.includes('Position: 31')); + assert.ok(formatted.includes("SELECT * FROM users WHERE id = 'unclosed")); + assert.ok(formatted.includes(' ^')); + } + }); + + it('should respect showPosition option', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + showPosition: false + }); + assert.ok(!formatted.includes('^')); + assert.ok(formatted.includes('Position: 32')); + } + }); + + it('should respect showQuery option', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + showQuery: false + }); + assert.ok(!formatted.includes('SELECT * FROM users')); + assert.ok(formatted.includes('Error:')); + assert.ok(formatted.includes('Position:')); + } + }); + + it('should truncate long queries', () => { + const longQuery = 'SELECT ' + 'a, '.repeat(50) + 'z FROM users WHERE id = @'; + try { + parseSync(longQuery); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, longQuery, { maxQueryLength: 50 }); + assert.ok(formatted.includes('...')); + const lines = formatted.split('\n'); + const queryLine = lines.find(line => line.includes('...')); + assert.ok(queryLine.length <= 56); // 50 + 2*3 for ellipsis + } + }); + + it('should handle color option without breaking output', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + color: true + }); + assert.ok(formatted.includes('Error:')); + assert.ok(formatted.includes('Position:')); + // Should contain ANSI codes but still be readable + const cleanFormatted = formatted.replace(/\x1b\[[0-9;]*m/g, ''); + assert.ok(cleanFormatted.includes('syntax error')); + } + }); + }); + + describe('hasSqlDetails Type Guard', () => { + it('should return true for SQL parse errors', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(hasSqlDetails(error), true); + } + }); + + it('should return false for regular errors', () => { + const regularError = new Error('Regular error'); + assert.equal(hasSqlDetails(regularError), false); + }); + + it('should return false for non-Error objects', () => { + assert.equal(hasSqlDetails('string'), false); + assert.equal(hasSqlDetails(123), false); + assert.equal(hasSqlDetails(null), false); + assert.equal(hasSqlDetails(undefined), false); + assert.equal(hasSqlDetails({}), false); + }); + + it('should return false for Error with incomplete sqlDetails', () => { + const error = new Error('Test'); + error.sqlDetails = { message: 'test' }; // Missing cursorPosition + assert.equal(hasSqlDetails(error), false); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty query', () => { + assert.throws(() => parseSync(''), { + message: 'Query cannot be empty' + }); + }); + + it('should handle null query', () => { + assert.throws(() => parseSync(null), { + message: 'Query cannot be null or undefined' + }); + }); + + it('should handle undefined query', () => { + assert.throws(() => parseSync(undefined), { + message: 'Query cannot be null or undefined' + }); + }); + + it('should handle @ in comments', () => { + const query = 'SELECT * FROM users /* @ in comment */ WHERE id = 1'; + assert.doesNotThrow(() => parseSync(query)); + }); + + it('should handle @ in strings', () => { + const query = 'SELECT * FROM users WHERE email = \'user@example.com\''; + assert.doesNotThrow(() => parseSync(query)); + }); + }); + + describe('Complex Error Scenarios', () => { + it('should handle errors in CASE statements', () => { + try { + parseSync('SELECT CASE WHEN id = 1 THEN "one" WHEN id = 2 THEN @ ELSE "other" END FROM users'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 54); + } + }); + + it('should handle errors in subqueries', () => { + try { + parseSync('SELECT * FROM users WHERE id IN (SELECT @ FROM orders)'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 42); + } + }); + + it('should handle errors in function calls', () => { + try { + parseSync('SELECT COUNT(@) FROM users'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 14); + } + }); + + it('should handle errors in second statement', () => { + try { + parseSync('SELECT * FROM users; SELECT * FROM orders WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 54); + } + }); + + it('should handle errors in CTE', () => { + try { + parseSync('WITH cte AS (SELECT * FROM users WHERE id = @) SELECT * FROM cte'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 45); + } + }); + }); + + describe('Backward Compatibility', () => { + it('should maintain Error instance', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error instanceof Error); + assert.ok(error.message); + assert.ok(error.stack); + } + }); + + it('should work with standard error handling', () => { + let caught = false; + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (e) { + caught = true; + assert.ok(e.message.includes('syntax error')); + } + assert.equal(caught, true); + }); + }); +}); \ No newline at end of file From 787e814c41964716c5440018e3e65d3400d2b47c Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 26 Jun 2025 10:46:19 -0700 Subject: [PATCH 03/14] versions --- full/Makefile | 4 +- full/src/index.ts | 169 +++++++++++++++-- full/src/wasm_wrapper.c | 19 ++ package.json | 3 + versions/13/Makefile | 4 +- versions/13/src/index.ts | 249 ++++++++++++++++++++++-- versions/13/src/wasm_wrapper.c | 24 +++ versions/13/test/errors.test.js | 325 ++++++++++++++++++++++++++++++++ versions/14/Makefile | 4 +- versions/14/src/index.ts | 249 ++++++++++++++++++++++-- versions/14/src/wasm_wrapper.c | 24 +++ versions/14/test/errors.test.js | 325 ++++++++++++++++++++++++++++++++ versions/15/Makefile | 4 +- versions/15/src/index.ts | 249 ++++++++++++++++++++++-- versions/15/src/wasm_wrapper.c | 24 +++ versions/15/test/errors.test.js | 325 ++++++++++++++++++++++++++++++++ versions/16/Makefile | 4 +- versions/16/src/index.ts | 249 ++++++++++++++++++++++-- versions/16/src/wasm_wrapper.c | 24 +++ versions/16/test/errors.test.js | 325 ++++++++++++++++++++++++++++++++ 20 files changed, 2525 insertions(+), 78 deletions(-) create mode 100644 versions/13/test/errors.test.js create mode 100644 versions/14/test/errors.test.js create mode 100644 versions/15/test/errors.test.js create mode 100644 versions/16/test/errors.test.js diff --git a/full/Makefile b/full/Makefile index cd5229b..104f718 100644 --- a/full/Makefile +++ b/full/Makefile @@ -57,8 +57,8 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR) \ -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ - -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_parse_query_protobuf','_wasm_get_protobuf_len','_wasm_deparse_protobuf','_wasm_parse_plpgsql','_wasm_fingerprint','_wasm_normalize_query','_wasm_scan','_wasm_parse_query_detailed','_wasm_free_detailed_result','_wasm_free_string']" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','UTF8ToString','HEAPU8','HEAPU32']" \ + -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_parse_query_protobuf','_wasm_get_protobuf_len','_wasm_deparse_protobuf','_wasm_parse_plpgsql','_wasm_fingerprint','_wasm_normalize_query','_wasm_scan','_wasm_parse_query_detailed','_wasm_free_detailed_result','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','UTF8ToString','getValue','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ -sMODULARIZE=1 \ diff --git a/full/src/index.ts b/full/src/index.ts index 976d434..9eb448a 100644 --- a/full/src/index.ts +++ b/full/src/index.ts @@ -16,6 +16,71 @@ export interface ScanResult { tokens: ScanToken[]; } +export interface SqlErrorDetails { + message: string; + cursorPosition: number; + fileName?: string; + functionName?: string; + lineNumber?: number; + context?: string; +} + +export class SqlError extends Error { + sqlDetails?: SqlErrorDetails; + + constructor(message: string, details?: SqlErrorDetails) { + super(message); + this.name = 'SqlError'; + this.sqlDetails = details; + } +} + +export function hasSqlDetails(error: unknown): error is SqlError { + return error instanceof SqlError && error.sqlDetails !== undefined; +} + +export function formatSqlError(error: SqlError, query?: string, options?: { + showPosition?: boolean; + showSource?: boolean; + useColors?: boolean; +}): string { + const opts = { showPosition: true, showSource: true, useColors: false, ...options }; + let output = `Error: ${error.message}`; + + if (error.sqlDetails) { + const details = error.sqlDetails; + + if (opts.showPosition && details.cursorPosition !== undefined) { + output += `\nPosition: ${details.cursorPosition}`; + } + + if (opts.showSource && (details.fileName || details.functionName || details.lineNumber)) { + output += '\nSource:'; + if (details.fileName) output += ` file: ${details.fileName},`; + if (details.functionName) output += ` function: ${details.functionName},`; + if (details.lineNumber) output += ` line: ${details.lineNumber}`; + } + + if (opts.showPosition && query && details.cursorPosition !== undefined && details.cursorPosition >= 0) { + const lines = query.split('\n'); + let currentPos = 0; + + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i].length + 1; // +1 for newline + if (currentPos + lineLength > details.cursorPosition) { + const posInLine = details.cursorPosition - currentPos; + output += `\n${lines[i]}`; + output += '\n' + ' '.repeat(posInLine) + '^'; + break; + } + currentPos += lineLength; + } + } + } + + return output; +} + // @ts-ignore import PgQueryModule from './libpg-query.js'; // @ts-ignore @@ -26,6 +91,8 @@ interface WasmModule { _free: (ptr: number) => void; _wasm_free_string: (ptr: number) => void; _wasm_parse_query: (queryPtr: number) => number; + _wasm_parse_query_raw: (queryPtr: number) => number; + _wasm_free_parse_result: (ptr: number) => void; _wasm_deparse_protobuf: (dataPtr: number, length: number) => number; _wasm_parse_plpgsql: (queryPtr: number) => number; _wasm_fingerprint: (queryPtr: number) => number; @@ -34,6 +101,7 @@ interface WasmModule { lengthBytesUTF8: (str: string) => number; stringToUTF8: (str: string, ptr: number, len: number) => void; UTF8ToString: (ptr: number) => string; + getValue: (ptr: number, type: string) => number; HEAPU8: Uint8Array; } @@ -85,22 +153,60 @@ function ptrToString(ptr: number): string { } export const parse = awaitInit(async (query: string): Promise => { + // Input validation + if (query === null || query === undefined) { + throw new SqlError('Query cannot be null or undefined'); + } + + if (query === '') { + throw new SqlError('Query cannot be empty'); + } + const queryPtr = stringToPtr(query); let resultPtr = 0; try { - resultPtr = wasmModule._wasm_parse_query(queryPtr); - const resultStr = ptrToString(resultPtr); + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new SqlError('Failed to parse query: memory allocation failed'); + } - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + // Read the PgQueryParseResult struct + const parseTreePtr = wasmModule.getValue(resultPtr, 'i32'); + const stderrBufferPtr = wasmModule.getValue(resultPtr + 4, 'i32'); + const errorPtr = wasmModule.getValue(resultPtr + 8, 'i32'); + + if (errorPtr) { + // Read PgQueryError struct + const messagePtr = wasmModule.getValue(errorPtr, 'i32'); + const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); + const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); + const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); + const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); + + const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error'; + const funcname = funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined; + const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : undefined; + + throw new SqlError(message, { + message, + cursorPosition: cursorpos, + fileName: filename, + functionName: funcname, + lineNumber: lineno > 0 ? lineno : undefined + }); } - return JSON.parse(resultStr); + if (!parseTreePtr) { + throw new SqlError('No parse tree generated'); + } + + const parseTreeStr = wasmModule.UTF8ToString(parseTreePtr); + return JSON.parse(parseTreeStr); } finally { wasmModule._free(queryPtr); if (resultPtr) { - wasmModule._wasm_free_string(resultPtr); + wasmModule._wasm_free_parse_result(resultPtr); } } }); @@ -202,22 +308,61 @@ export function parseSync(query: string): ParseResult { if (!wasmModule) { throw new Error('WASM module not initialized. Call loadModule() first.'); } + + // Input validation + if (query === null || query === undefined) { + throw new SqlError('Query cannot be null or undefined'); + } + + if (query === '') { + throw new SqlError('Query cannot be empty'); + } + const queryPtr = stringToPtr(query); let resultPtr = 0; try { - resultPtr = wasmModule._wasm_parse_query(queryPtr); - const resultStr = ptrToString(resultPtr); + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new SqlError('Failed to parse query: memory allocation failed'); + } - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + // Read the PgQueryParseResult struct + const parseTreePtr = wasmModule.getValue(resultPtr, 'i32'); + const stderrBufferPtr = wasmModule.getValue(resultPtr + 4, 'i32'); + const errorPtr = wasmModule.getValue(resultPtr + 8, 'i32'); + + if (errorPtr) { + // Read PgQueryError struct + const messagePtr = wasmModule.getValue(errorPtr, 'i32'); + const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); + const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); + const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); + const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); + + const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error'; + const funcname = funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined; + const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : undefined; + + throw new SqlError(message, { + message, + cursorPosition: cursorpos, + fileName: filename, + functionName: funcname, + lineNumber: lineno > 0 ? lineno : undefined + }); } - return JSON.parse(resultStr); + if (!parseTreePtr) { + throw new SqlError('No parse tree generated'); + } + + const parseTreeStr = wasmModule.UTF8ToString(parseTreePtr); + return JSON.parse(parseTreeStr); } finally { wasmModule._free(queryPtr); if (resultPtr) { - wasmModule._wasm_free_string(resultPtr); + wasmModule._wasm_free_parse_result(resultPtr); } } } diff --git a/full/src/wasm_wrapper.c b/full/src/wasm_wrapper.c index 9b6db3f..ee3d5f8 100644 --- a/full/src/wasm_wrapper.c +++ b/full/src/wasm_wrapper.c @@ -46,6 +46,25 @@ char* wasm_parse_query(const char* input) { return parse_tree; } +EMSCRIPTEN_KEEPALIVE +PgQueryParseResult* wasm_parse_query_raw(const char* input) { + PgQueryParseResult* result = (PgQueryParseResult*)malloc(sizeof(PgQueryParseResult)); + if (!result) { + return NULL; + } + + *result = pg_query_parse(input); + return result; +} + +EMSCRIPTEN_KEEPALIVE +void wasm_free_parse_result(PgQueryParseResult* result) { + if (result) { + pg_query_free_parse_result(*result); + free(result); + } +} + EMSCRIPTEN_KEEPALIVE char* wasm_deparse_protobuf(const char* protobuf_data, size_t data_len) { if (!protobuf_data || data_len == 0) { diff --git a/package.json b/package.json index e5a75f6..3db737c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "build:all": "pnpm -r build", "test:all": "pnpm -r test", "clean:all": "pnpm -r clean", + "build:versions": "pnpm --filter './versions/*' build", + "test:versions": "pnpm --filter './versions/*' test", + "clean:versions": "pnpm --filter './versions/*' clean", "analyze:sizes": "node scripts/analyze-sizes.js", "fetch:protos": "node scripts/fetch-protos.js", "build:types": "node scripts/build-types.js", diff --git a/versions/13/Makefile b/versions/13/Makefile index bcee4c4..f4a3e50 100644 --- a/versions/13/Makefile +++ b/versions/13/Makefile @@ -60,8 +60,8 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR) \ -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ - -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string']" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','UTF8ToString','HEAPU8','HEAPU32']" \ + -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','UTF8ToString','getValue','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ -sMODULARIZE=1 \ diff --git a/versions/13/src/index.ts b/versions/13/src/index.ts index ef2d047..60c6d86 100644 --- a/versions/13/src/index.ts +++ b/versions/13/src/index.ts @@ -5,6 +5,125 @@ import PgQueryModule from './libpg-query.js'; let wasmModule: any; +// SQL error details interface +export interface SqlErrorDetails { + message: string; + cursorPosition: number; // 0-based position in the query + fileName?: string; // Source file where error occurred (e.g., 'scan.l', 'gram.y') + functionName?: string; // Internal function name + lineNumber?: number; // Line number in source file + context?: string; // Additional context +} + +// Options for formatting SQL errors +export interface SqlErrorFormatOptions { + showPosition?: boolean; // Show the error position marker (default: true) + showQuery?: boolean; // Show the query text (default: true) + color?: boolean; // Use ANSI colors (default: false) + maxQueryLength?: number; // Max query length to display (default: no limit) +} + +// Helper function to create enhanced error with SQL details +function createSqlError(message: string, details: SqlErrorDetails): Error { + const error = new Error(message); + // Attach error details as properties + Object.defineProperty(error, 'sqlDetails', { + value: details, + enumerable: true, + configurable: true + }); + return error; +} + +// Helper function to classify error source +function getErrorSource(filename: string | null): string { + if (!filename) return 'unknown'; + if (filename === 'scan.l') return 'lexer'; // Lexical analysis errors + if (filename === 'gram.y') return 'parser'; // Grammar/parsing errors + return filename; +} + +// Format SQL error with visual position indicator +export function formatSqlError( + error: Error & { sqlDetails?: SqlErrorDetails }, + query: string, + options: SqlErrorFormatOptions = {} +): string { + const { + showPosition = true, + showQuery = true, + color = false, + maxQueryLength + } = options; + + const lines: string[] = []; + + // ANSI color codes + const red = color ? '\x1b[31m' : ''; + const yellow = color ? '\x1b[33m' : ''; + const reset = color ? '\x1b[0m' : ''; + + // Add error message + lines.push(`${red}Error: ${error.message}${reset}`); + + // Add SQL details if available + if (error.sqlDetails) { + const { cursorPosition, fileName, functionName, lineNumber } = error.sqlDetails; + + if (cursorPosition !== undefined && cursorPosition >= 0) { + lines.push(`Position: ${cursorPosition}`); + } + + if (fileName || functionName || lineNumber) { + const details = []; + if (fileName) details.push(`file: ${fileName}`); + if (functionName) details.push(`function: ${functionName}`); + if (lineNumber) details.push(`line: ${lineNumber}`); + lines.push(`Source: ${details.join(', ')}`); + } + + // Show query with position marker + if (showQuery && showPosition && cursorPosition !== undefined && cursorPosition >= 0) { + let displayQuery = query; + + // Truncate if needed + if (maxQueryLength && query.length > maxQueryLength) { + const start = Math.max(0, cursorPosition - Math.floor(maxQueryLength / 2)); + const end = Math.min(query.length, start + maxQueryLength); + displayQuery = (start > 0 ? '...' : '') + + query.substring(start, end) + + (end < query.length ? '...' : ''); + // Adjust cursor position for truncation + const adjustedPosition = cursorPosition - start + (start > 0 ? 3 : 0); + lines.push(displayQuery); + lines.push(' '.repeat(adjustedPosition) + `${yellow}^${reset}`); + } else { + lines.push(displayQuery); + lines.push(' '.repeat(cursorPosition) + `${yellow}^${reset}`); + } + } + } else if (showQuery) { + // No SQL details, just show the query if requested + let displayQuery = query; + if (maxQueryLength && query.length > maxQueryLength) { + displayQuery = query.substring(0, maxQueryLength) + '...'; + } + lines.push(`Query: ${displayQuery}`); + } + + return lines.join('\n'); +} + +// Check if an error has SQL details +export function hasSqlDetails(error: any): error is Error & { sqlDetails: SqlErrorDetails } { + return error instanceof Error && + 'sqlDetails' in error && + typeof (error as any).sqlDetails === 'object' && + (error as any).sqlDetails !== null && + 'message' in (error as any).sqlDetails && + 'cursorPosition' in (error as any).sqlDetails; +} + const initPromise = PgQueryModule().then((module: any) => { wasmModule = module; }); @@ -51,37 +170,139 @@ function ptrToString(ptr: number): string { } export const parse = awaitInit(async (query: string) => { + // Pre-validation + if (query === null || query === undefined) { + throw new Error('Query cannot be null or undefined'); + } + if (typeof query !== 'string') { + throw new Error(`Query must be a string, got ${typeof query}`); + } + if (query.trim() === '') { + throw new Error('Query cannot be empty'); + } + const queryPtr = stringToPtr(query); let resultPtr = 0; + try { - resultPtr = wasmModule._wasm_parse_query(queryPtr); - const resultStr = ptrToString(resultPtr); - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + // Call the raw function that returns a struct pointer + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new Error('Failed to allocate memory for parse result'); + } + + // Read the PgQueryParseResult struct fields + // struct { char* parse_tree; char* stderr_buffer; PgQueryError* error; } + const parseTreePtr = wasmModule.getValue(resultPtr, 'i32'); // offset 0 + const stderrBufferPtr = wasmModule.getValue(resultPtr + 4, 'i32'); // offset 4 + const errorPtr = wasmModule.getValue(resultPtr + 8, 'i32'); // offset 8 + + // Check for error + if (errorPtr) { + // Read PgQueryError struct fields + // struct { char* message; char* funcname; char* filename; int lineno; int cursorpos; char* context; } + const messagePtr = wasmModule.getValue(errorPtr, 'i32'); // offset 0 + const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); // offset 4 + const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); // offset 8 + const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); // offset 12 + const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); // offset 16 + const contextPtr = wasmModule.getValue(errorPtr + 20, 'i32'); // offset 20 + + const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error'; + const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : null; + + const errorDetails: SqlErrorDetails = { + message: message, + cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based + fileName: filename || undefined, + functionName: funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined, + lineNumber: lineno > 0 ? lineno : undefined, + context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined + }; + + throw createSqlError(message, errorDetails); + } + + if (!parseTreePtr) { + throw new Error('Parse result is null'); } - return JSON.parse(resultStr); - } finally { + + const parseTree = wasmModule.UTF8ToString(parseTreePtr); + return JSON.parse(parseTree); + } + finally { wasmModule._free(queryPtr); if (resultPtr) { - wasmModule._wasm_free_string(resultPtr); + wasmModule._wasm_free_parse_result(resultPtr); } } }); export function parseSync(query: string) { + // Pre-validation + if (query === null || query === undefined) { + throw new Error('Query cannot be null or undefined'); + } + if (typeof query !== 'string') { + throw new Error(`Query must be a string, got ${typeof query}`); + } + if (query.trim() === '') { + throw new Error('Query cannot be empty'); + } + const queryPtr = stringToPtr(query); let resultPtr = 0; + try { - resultPtr = wasmModule._wasm_parse_query(queryPtr); - const resultStr = ptrToString(resultPtr); - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + // Call the raw function that returns a struct pointer + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new Error('Failed to allocate memory for parse result'); } - return JSON.parse(resultStr); - } finally { + + // Read the PgQueryParseResult struct fields + // struct { char* parse_tree; char* stderr_buffer; PgQueryError* error; } + const parseTreePtr = wasmModule.getValue(resultPtr, 'i32'); // offset 0 + const stderrBufferPtr = wasmModule.getValue(resultPtr + 4, 'i32'); // offset 4 + const errorPtr = wasmModule.getValue(resultPtr + 8, 'i32'); // offset 8 + + // Check for error + if (errorPtr) { + // Read PgQueryError struct fields + // struct { char* message; char* funcname; char* filename; int lineno; int cursorpos; char* context; } + const messagePtr = wasmModule.getValue(errorPtr, 'i32'); // offset 0 + const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); // offset 4 + const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); // offset 8 + const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); // offset 12 + const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); // offset 16 + const contextPtr = wasmModule.getValue(errorPtr + 20, 'i32'); // offset 20 + + const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error'; + const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : null; + + const errorDetails: SqlErrorDetails = { + message: message, + cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based + fileName: filename || undefined, + functionName: funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined, + lineNumber: lineno > 0 ? lineno : undefined, + context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined + }; + + throw createSqlError(message, errorDetails); + } + + if (!parseTreePtr) { + throw new Error('Parse result is null'); + } + + const parseTree = wasmModule.UTF8ToString(parseTreePtr); + return JSON.parse(parseTree); + } + finally { wasmModule._free(queryPtr); if (resultPtr) { - wasmModule._wasm_free_string(resultPtr); + wasmModule._wasm_free_parse_result(resultPtr); } } } \ No newline at end of file diff --git a/versions/13/src/wasm_wrapper.c b/versions/13/src/wasm_wrapper.c index bf297c6..b9d958f 100644 --- a/versions/13/src/wasm_wrapper.c +++ b/versions/13/src/wasm_wrapper.c @@ -46,4 +46,28 @@ char* wasm_parse_query(const char* input) { EMSCRIPTEN_KEEPALIVE void wasm_free_string(char* str) { free(str); +} + +// Raw struct access functions for parse +EMSCRIPTEN_KEEPALIVE +PgQueryParseResult* wasm_parse_query_raw(const char* input) { + if (!input) { + return NULL; + } + + PgQueryParseResult* result = (PgQueryParseResult*)safe_malloc(sizeof(PgQueryParseResult)); + if (!result) { + return NULL; + } + + *result = pg_query_parse(input); + return result; +} + +EMSCRIPTEN_KEEPALIVE +void wasm_free_parse_result(PgQueryParseResult* result) { + if (result) { + pg_query_free_parse_result(*result); + free(result); + } } \ No newline at end of file diff --git a/versions/13/test/errors.test.js b/versions/13/test/errors.test.js new file mode 100644 index 0000000..f6c0fd6 --- /dev/null +++ b/versions/13/test/errors.test.js @@ -0,0 +1,325 @@ +const { describe, it, before } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseSync, loadModule, formatSqlError, hasSqlDetails } = require('../wasm/index.cjs'); + +describe('Enhanced Error Handling', () => { + before(async () => { + await loadModule(); + }); + + describe('Error Details Structure', () => { + it('should include sqlDetails property on parse errors', () => { + assert.throws(() => { + parseSync('SELECT * FROM users WHERE id = @'); + }); + + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (error) { + assert.ok('sqlDetails' in error); + assert.ok('message' in error.sqlDetails); + assert.ok('cursorPosition' in error.sqlDetails); + assert.ok('fileName' in error.sqlDetails); + assert.ok('functionName' in error.sqlDetails); + assert.ok('lineNumber' in error.sqlDetails); + } + }); + + it('should have correct cursor position (0-based)', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 32); + } + }); + + it('should identify error source file', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.fileName, 'scan.l'); + assert.equal(error.sqlDetails.functionName, 'scanner_yyerror'); + } + }); + }); + + describe('Error Position Accuracy', () => { + const positionTests = [ + { query: '@ SELECT * FROM users', expectedPos: 0, desc: 'error at start' }, + { query: 'SELECT @ FROM users', expectedPos: 9, desc: 'error after SELECT' }, + { query: 'SELECT * FROM users WHERE @ = 1', expectedPos: 28, desc: 'error after WHERE' }, + { query: 'SELECT * FROM users WHERE id = @', expectedPos: 32, desc: 'error at end' }, + { query: 'INSERT INTO users (id, name) VALUES (1, @)', expectedPos: 41, desc: 'error in VALUES' }, + { query: 'UPDATE users SET name = @ WHERE id = 1', expectedPos: 26, desc: 'error in SET' }, + { query: 'CREATE TABLE test (id INT, name @)', expectedPos: 32, desc: 'error in CREATE TABLE' }, + ]; + + positionTests.forEach(({ query, expectedPos, desc }) => { + it(`should correctly identify position for ${desc}`, () => { + try { + parseSync(query); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, expectedPos); + } + }); + }); + }); + + describe('Error Types', () => { + it('should handle unterminated string literals', () => { + try { + parseSync("SELECT * FROM users WHERE name = 'unclosed"); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('unterminated quoted string')); + assert.equal(error.sqlDetails.cursorPosition, 33); + } + }); + + it('should handle unterminated quoted identifiers', () => { + try { + parseSync('SELECT * FROM users WHERE name = "unclosed'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('unterminated quoted identifier')); + assert.equal(error.sqlDetails.cursorPosition, 33); + } + }); + + it('should handle invalid tokens', () => { + try { + parseSync('SELECT * FROM users WHERE id = $'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at or near "$"')); + assert.equal(error.sqlDetails.cursorPosition, 31); + } + }); + + it('should handle reserved keywords', () => { + try { + parseSync('SELECT * FROM table'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at or near "table"')); + assert.equal(error.sqlDetails.cursorPosition, 14); + } + }); + + it('should handle syntax error in WHERE clause', () => { + try { + parseSync('SELECT * FROM users WHERE'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at end of input')); + assert.equal(error.sqlDetails.cursorPosition, 25); + } + }); + }); + + describe('formatSqlError Helper', () => { + it('should format error with position indicator', () => { + try { + parseSync("SELECT * FROM users WHERE id = 'unclosed"); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, "SELECT * FROM users WHERE id = 'unclosed"); + assert.ok(formatted.includes('Error: unterminated quoted string')); + assert.ok(formatted.includes('Position: 31')); + assert.ok(formatted.includes("SELECT * FROM users WHERE id = 'unclosed")); + assert.ok(formatted.includes(' ^')); + } + }); + + it('should respect showPosition option', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + showPosition: false + }); + assert.ok(!formatted.includes('^')); + assert.ok(formatted.includes('Position: 32')); + } + }); + + it('should respect showQuery option', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + showQuery: false + }); + assert.ok(!formatted.includes('SELECT * FROM users')); + assert.ok(formatted.includes('Error:')); + assert.ok(formatted.includes('Position:')); + } + }); + + it('should truncate long queries', () => { + const longQuery = 'SELECT ' + 'a, '.repeat(50) + 'z FROM users WHERE id = @'; + try { + parseSync(longQuery); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, longQuery, { maxQueryLength: 50 }); + assert.ok(formatted.includes('...')); + const lines = formatted.split('\n'); + const queryLine = lines.find(line => line.includes('...')); + assert.ok(queryLine.length <= 56); // 50 + 2*3 for ellipsis + } + }); + + it('should handle color option without breaking output', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + color: true + }); + assert.ok(formatted.includes('Error:')); + assert.ok(formatted.includes('Position:')); + // Should contain ANSI codes but still be readable + const cleanFormatted = formatted.replace(/\x1b\[[0-9;]*m/g, ''); + assert.ok(cleanFormatted.includes('syntax error')); + } + }); + }); + + describe('hasSqlDetails Type Guard', () => { + it('should return true for SQL parse errors', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(hasSqlDetails(error), true); + } + }); + + it('should return false for regular errors', () => { + const regularError = new Error('Regular error'); + assert.equal(hasSqlDetails(regularError), false); + }); + + it('should return false for non-Error objects', () => { + assert.equal(hasSqlDetails('string'), false); + assert.equal(hasSqlDetails(123), false); + assert.equal(hasSqlDetails(null), false); + assert.equal(hasSqlDetails(undefined), false); + assert.equal(hasSqlDetails({}), false); + }); + + it('should return false for Error with incomplete sqlDetails', () => { + const error = new Error('Test'); + error.sqlDetails = { message: 'test' }; // Missing cursorPosition + assert.equal(hasSqlDetails(error), false); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty query', () => { + assert.throws(() => parseSync(''), { + message: 'Query cannot be empty' + }); + }); + + it('should handle null query', () => { + assert.throws(() => parseSync(null), { + message: 'Query cannot be null or undefined' + }); + }); + + it('should handle undefined query', () => { + assert.throws(() => parseSync(undefined), { + message: 'Query cannot be null or undefined' + }); + }); + + it('should handle @ in comments', () => { + const query = 'SELECT * FROM users /* @ in comment */ WHERE id = 1'; + assert.doesNotThrow(() => parseSync(query)); + }); + + it('should handle @ in strings', () => { + const query = 'SELECT * FROM users WHERE email = \'user@example.com\''; + assert.doesNotThrow(() => parseSync(query)); + }); + }); + + describe('Complex Error Scenarios', () => { + it('should handle errors in CASE statements', () => { + try { + parseSync('SELECT CASE WHEN id = 1 THEN "one" WHEN id = 2 THEN @ ELSE "other" END FROM users'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 54); + } + }); + + it('should handle errors in subqueries', () => { + try { + parseSync('SELECT * FROM users WHERE id IN (SELECT @ FROM orders)'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 42); + } + }); + + it('should handle errors in function calls', () => { + try { + parseSync('SELECT COUNT(@) FROM users'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 14); + } + }); + + it('should handle errors in second statement', () => { + try { + parseSync('SELECT * FROM users; SELECT * FROM orders WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 54); + } + }); + + it('should handle errors in CTE', () => { + try { + parseSync('WITH cte AS (SELECT * FROM users WHERE id = @) SELECT * FROM cte'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 45); + } + }); + }); + + describe('Backward Compatibility', () => { + it('should maintain Error instance', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error instanceof Error); + assert.ok(error.message); + assert.ok(error.stack); + } + }); + + it('should work with standard error handling', () => { + let caught = false; + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (e) { + caught = true; + assert.ok(e.message.includes('syntax error')); + } + assert.equal(caught, true); + }); + }); +}); \ No newline at end of file diff --git a/versions/14/Makefile b/versions/14/Makefile index a3267dd..9ec6bcb 100644 --- a/versions/14/Makefile +++ b/versions/14/Makefile @@ -57,8 +57,8 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR) \ -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ - -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string']" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','UTF8ToString','HEAPU8','HEAPU32']" \ + -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ -sMODULARIZE=1 \ diff --git a/versions/14/src/index.ts b/versions/14/src/index.ts index ef2d047..60c6d86 100644 --- a/versions/14/src/index.ts +++ b/versions/14/src/index.ts @@ -5,6 +5,125 @@ import PgQueryModule from './libpg-query.js'; let wasmModule: any; +// SQL error details interface +export interface SqlErrorDetails { + message: string; + cursorPosition: number; // 0-based position in the query + fileName?: string; // Source file where error occurred (e.g., 'scan.l', 'gram.y') + functionName?: string; // Internal function name + lineNumber?: number; // Line number in source file + context?: string; // Additional context +} + +// Options for formatting SQL errors +export interface SqlErrorFormatOptions { + showPosition?: boolean; // Show the error position marker (default: true) + showQuery?: boolean; // Show the query text (default: true) + color?: boolean; // Use ANSI colors (default: false) + maxQueryLength?: number; // Max query length to display (default: no limit) +} + +// Helper function to create enhanced error with SQL details +function createSqlError(message: string, details: SqlErrorDetails): Error { + const error = new Error(message); + // Attach error details as properties + Object.defineProperty(error, 'sqlDetails', { + value: details, + enumerable: true, + configurable: true + }); + return error; +} + +// Helper function to classify error source +function getErrorSource(filename: string | null): string { + if (!filename) return 'unknown'; + if (filename === 'scan.l') return 'lexer'; // Lexical analysis errors + if (filename === 'gram.y') return 'parser'; // Grammar/parsing errors + return filename; +} + +// Format SQL error with visual position indicator +export function formatSqlError( + error: Error & { sqlDetails?: SqlErrorDetails }, + query: string, + options: SqlErrorFormatOptions = {} +): string { + const { + showPosition = true, + showQuery = true, + color = false, + maxQueryLength + } = options; + + const lines: string[] = []; + + // ANSI color codes + const red = color ? '\x1b[31m' : ''; + const yellow = color ? '\x1b[33m' : ''; + const reset = color ? '\x1b[0m' : ''; + + // Add error message + lines.push(`${red}Error: ${error.message}${reset}`); + + // Add SQL details if available + if (error.sqlDetails) { + const { cursorPosition, fileName, functionName, lineNumber } = error.sqlDetails; + + if (cursorPosition !== undefined && cursorPosition >= 0) { + lines.push(`Position: ${cursorPosition}`); + } + + if (fileName || functionName || lineNumber) { + const details = []; + if (fileName) details.push(`file: ${fileName}`); + if (functionName) details.push(`function: ${functionName}`); + if (lineNumber) details.push(`line: ${lineNumber}`); + lines.push(`Source: ${details.join(', ')}`); + } + + // Show query with position marker + if (showQuery && showPosition && cursorPosition !== undefined && cursorPosition >= 0) { + let displayQuery = query; + + // Truncate if needed + if (maxQueryLength && query.length > maxQueryLength) { + const start = Math.max(0, cursorPosition - Math.floor(maxQueryLength / 2)); + const end = Math.min(query.length, start + maxQueryLength); + displayQuery = (start > 0 ? '...' : '') + + query.substring(start, end) + + (end < query.length ? '...' : ''); + // Adjust cursor position for truncation + const adjustedPosition = cursorPosition - start + (start > 0 ? 3 : 0); + lines.push(displayQuery); + lines.push(' '.repeat(adjustedPosition) + `${yellow}^${reset}`); + } else { + lines.push(displayQuery); + lines.push(' '.repeat(cursorPosition) + `${yellow}^${reset}`); + } + } + } else if (showQuery) { + // No SQL details, just show the query if requested + let displayQuery = query; + if (maxQueryLength && query.length > maxQueryLength) { + displayQuery = query.substring(0, maxQueryLength) + '...'; + } + lines.push(`Query: ${displayQuery}`); + } + + return lines.join('\n'); +} + +// Check if an error has SQL details +export function hasSqlDetails(error: any): error is Error & { sqlDetails: SqlErrorDetails } { + return error instanceof Error && + 'sqlDetails' in error && + typeof (error as any).sqlDetails === 'object' && + (error as any).sqlDetails !== null && + 'message' in (error as any).sqlDetails && + 'cursorPosition' in (error as any).sqlDetails; +} + const initPromise = PgQueryModule().then((module: any) => { wasmModule = module; }); @@ -51,37 +170,139 @@ function ptrToString(ptr: number): string { } export const parse = awaitInit(async (query: string) => { + // Pre-validation + if (query === null || query === undefined) { + throw new Error('Query cannot be null or undefined'); + } + if (typeof query !== 'string') { + throw new Error(`Query must be a string, got ${typeof query}`); + } + if (query.trim() === '') { + throw new Error('Query cannot be empty'); + } + const queryPtr = stringToPtr(query); let resultPtr = 0; + try { - resultPtr = wasmModule._wasm_parse_query(queryPtr); - const resultStr = ptrToString(resultPtr); - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + // Call the raw function that returns a struct pointer + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new Error('Failed to allocate memory for parse result'); + } + + // Read the PgQueryParseResult struct fields + // struct { char* parse_tree; char* stderr_buffer; PgQueryError* error; } + const parseTreePtr = wasmModule.getValue(resultPtr, 'i32'); // offset 0 + const stderrBufferPtr = wasmModule.getValue(resultPtr + 4, 'i32'); // offset 4 + const errorPtr = wasmModule.getValue(resultPtr + 8, 'i32'); // offset 8 + + // Check for error + if (errorPtr) { + // Read PgQueryError struct fields + // struct { char* message; char* funcname; char* filename; int lineno; int cursorpos; char* context; } + const messagePtr = wasmModule.getValue(errorPtr, 'i32'); // offset 0 + const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); // offset 4 + const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); // offset 8 + const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); // offset 12 + const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); // offset 16 + const contextPtr = wasmModule.getValue(errorPtr + 20, 'i32'); // offset 20 + + const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error'; + const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : null; + + const errorDetails: SqlErrorDetails = { + message: message, + cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based + fileName: filename || undefined, + functionName: funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined, + lineNumber: lineno > 0 ? lineno : undefined, + context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined + }; + + throw createSqlError(message, errorDetails); + } + + if (!parseTreePtr) { + throw new Error('Parse result is null'); } - return JSON.parse(resultStr); - } finally { + + const parseTree = wasmModule.UTF8ToString(parseTreePtr); + return JSON.parse(parseTree); + } + finally { wasmModule._free(queryPtr); if (resultPtr) { - wasmModule._wasm_free_string(resultPtr); + wasmModule._wasm_free_parse_result(resultPtr); } } }); export function parseSync(query: string) { + // Pre-validation + if (query === null || query === undefined) { + throw new Error('Query cannot be null or undefined'); + } + if (typeof query !== 'string') { + throw new Error(`Query must be a string, got ${typeof query}`); + } + if (query.trim() === '') { + throw new Error('Query cannot be empty'); + } + const queryPtr = stringToPtr(query); let resultPtr = 0; + try { - resultPtr = wasmModule._wasm_parse_query(queryPtr); - const resultStr = ptrToString(resultPtr); - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + // Call the raw function that returns a struct pointer + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new Error('Failed to allocate memory for parse result'); } - return JSON.parse(resultStr); - } finally { + + // Read the PgQueryParseResult struct fields + // struct { char* parse_tree; char* stderr_buffer; PgQueryError* error; } + const parseTreePtr = wasmModule.getValue(resultPtr, 'i32'); // offset 0 + const stderrBufferPtr = wasmModule.getValue(resultPtr + 4, 'i32'); // offset 4 + const errorPtr = wasmModule.getValue(resultPtr + 8, 'i32'); // offset 8 + + // Check for error + if (errorPtr) { + // Read PgQueryError struct fields + // struct { char* message; char* funcname; char* filename; int lineno; int cursorpos; char* context; } + const messagePtr = wasmModule.getValue(errorPtr, 'i32'); // offset 0 + const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); // offset 4 + const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); // offset 8 + const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); // offset 12 + const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); // offset 16 + const contextPtr = wasmModule.getValue(errorPtr + 20, 'i32'); // offset 20 + + const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error'; + const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : null; + + const errorDetails: SqlErrorDetails = { + message: message, + cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based + fileName: filename || undefined, + functionName: funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined, + lineNumber: lineno > 0 ? lineno : undefined, + context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined + }; + + throw createSqlError(message, errorDetails); + } + + if (!parseTreePtr) { + throw new Error('Parse result is null'); + } + + const parseTree = wasmModule.UTF8ToString(parseTreePtr); + return JSON.parse(parseTree); + } + finally { wasmModule._free(queryPtr); if (resultPtr) { - wasmModule._wasm_free_string(resultPtr); + wasmModule._wasm_free_parse_result(resultPtr); } } } \ No newline at end of file diff --git a/versions/14/src/wasm_wrapper.c b/versions/14/src/wasm_wrapper.c index bf297c6..b9d958f 100644 --- a/versions/14/src/wasm_wrapper.c +++ b/versions/14/src/wasm_wrapper.c @@ -46,4 +46,28 @@ char* wasm_parse_query(const char* input) { EMSCRIPTEN_KEEPALIVE void wasm_free_string(char* str) { free(str); +} + +// Raw struct access functions for parse +EMSCRIPTEN_KEEPALIVE +PgQueryParseResult* wasm_parse_query_raw(const char* input) { + if (!input) { + return NULL; + } + + PgQueryParseResult* result = (PgQueryParseResult*)safe_malloc(sizeof(PgQueryParseResult)); + if (!result) { + return NULL; + } + + *result = pg_query_parse(input); + return result; +} + +EMSCRIPTEN_KEEPALIVE +void wasm_free_parse_result(PgQueryParseResult* result) { + if (result) { + pg_query_free_parse_result(*result); + free(result); + } } \ No newline at end of file diff --git a/versions/14/test/errors.test.js b/versions/14/test/errors.test.js new file mode 100644 index 0000000..f6c0fd6 --- /dev/null +++ b/versions/14/test/errors.test.js @@ -0,0 +1,325 @@ +const { describe, it, before } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseSync, loadModule, formatSqlError, hasSqlDetails } = require('../wasm/index.cjs'); + +describe('Enhanced Error Handling', () => { + before(async () => { + await loadModule(); + }); + + describe('Error Details Structure', () => { + it('should include sqlDetails property on parse errors', () => { + assert.throws(() => { + parseSync('SELECT * FROM users WHERE id = @'); + }); + + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (error) { + assert.ok('sqlDetails' in error); + assert.ok('message' in error.sqlDetails); + assert.ok('cursorPosition' in error.sqlDetails); + assert.ok('fileName' in error.sqlDetails); + assert.ok('functionName' in error.sqlDetails); + assert.ok('lineNumber' in error.sqlDetails); + } + }); + + it('should have correct cursor position (0-based)', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 32); + } + }); + + it('should identify error source file', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.fileName, 'scan.l'); + assert.equal(error.sqlDetails.functionName, 'scanner_yyerror'); + } + }); + }); + + describe('Error Position Accuracy', () => { + const positionTests = [ + { query: '@ SELECT * FROM users', expectedPos: 0, desc: 'error at start' }, + { query: 'SELECT @ FROM users', expectedPos: 9, desc: 'error after SELECT' }, + { query: 'SELECT * FROM users WHERE @ = 1', expectedPos: 28, desc: 'error after WHERE' }, + { query: 'SELECT * FROM users WHERE id = @', expectedPos: 32, desc: 'error at end' }, + { query: 'INSERT INTO users (id, name) VALUES (1, @)', expectedPos: 41, desc: 'error in VALUES' }, + { query: 'UPDATE users SET name = @ WHERE id = 1', expectedPos: 26, desc: 'error in SET' }, + { query: 'CREATE TABLE test (id INT, name @)', expectedPos: 32, desc: 'error in CREATE TABLE' }, + ]; + + positionTests.forEach(({ query, expectedPos, desc }) => { + it(`should correctly identify position for ${desc}`, () => { + try { + parseSync(query); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, expectedPos); + } + }); + }); + }); + + describe('Error Types', () => { + it('should handle unterminated string literals', () => { + try { + parseSync("SELECT * FROM users WHERE name = 'unclosed"); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('unterminated quoted string')); + assert.equal(error.sqlDetails.cursorPosition, 33); + } + }); + + it('should handle unterminated quoted identifiers', () => { + try { + parseSync('SELECT * FROM users WHERE name = "unclosed'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('unterminated quoted identifier')); + assert.equal(error.sqlDetails.cursorPosition, 33); + } + }); + + it('should handle invalid tokens', () => { + try { + parseSync('SELECT * FROM users WHERE id = $'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at or near "$"')); + assert.equal(error.sqlDetails.cursorPosition, 31); + } + }); + + it('should handle reserved keywords', () => { + try { + parseSync('SELECT * FROM table'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at or near "table"')); + assert.equal(error.sqlDetails.cursorPosition, 14); + } + }); + + it('should handle syntax error in WHERE clause', () => { + try { + parseSync('SELECT * FROM users WHERE'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at end of input')); + assert.equal(error.sqlDetails.cursorPosition, 25); + } + }); + }); + + describe('formatSqlError Helper', () => { + it('should format error with position indicator', () => { + try { + parseSync("SELECT * FROM users WHERE id = 'unclosed"); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, "SELECT * FROM users WHERE id = 'unclosed"); + assert.ok(formatted.includes('Error: unterminated quoted string')); + assert.ok(formatted.includes('Position: 31')); + assert.ok(formatted.includes("SELECT * FROM users WHERE id = 'unclosed")); + assert.ok(formatted.includes(' ^')); + } + }); + + it('should respect showPosition option', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + showPosition: false + }); + assert.ok(!formatted.includes('^')); + assert.ok(formatted.includes('Position: 32')); + } + }); + + it('should respect showQuery option', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + showQuery: false + }); + assert.ok(!formatted.includes('SELECT * FROM users')); + assert.ok(formatted.includes('Error:')); + assert.ok(formatted.includes('Position:')); + } + }); + + it('should truncate long queries', () => { + const longQuery = 'SELECT ' + 'a, '.repeat(50) + 'z FROM users WHERE id = @'; + try { + parseSync(longQuery); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, longQuery, { maxQueryLength: 50 }); + assert.ok(formatted.includes('...')); + const lines = formatted.split('\n'); + const queryLine = lines.find(line => line.includes('...')); + assert.ok(queryLine.length <= 56); // 50 + 2*3 for ellipsis + } + }); + + it('should handle color option without breaking output', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + color: true + }); + assert.ok(formatted.includes('Error:')); + assert.ok(formatted.includes('Position:')); + // Should contain ANSI codes but still be readable + const cleanFormatted = formatted.replace(/\x1b\[[0-9;]*m/g, ''); + assert.ok(cleanFormatted.includes('syntax error')); + } + }); + }); + + describe('hasSqlDetails Type Guard', () => { + it('should return true for SQL parse errors', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(hasSqlDetails(error), true); + } + }); + + it('should return false for regular errors', () => { + const regularError = new Error('Regular error'); + assert.equal(hasSqlDetails(regularError), false); + }); + + it('should return false for non-Error objects', () => { + assert.equal(hasSqlDetails('string'), false); + assert.equal(hasSqlDetails(123), false); + assert.equal(hasSqlDetails(null), false); + assert.equal(hasSqlDetails(undefined), false); + assert.equal(hasSqlDetails({}), false); + }); + + it('should return false for Error with incomplete sqlDetails', () => { + const error = new Error('Test'); + error.sqlDetails = { message: 'test' }; // Missing cursorPosition + assert.equal(hasSqlDetails(error), false); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty query', () => { + assert.throws(() => parseSync(''), { + message: 'Query cannot be empty' + }); + }); + + it('should handle null query', () => { + assert.throws(() => parseSync(null), { + message: 'Query cannot be null or undefined' + }); + }); + + it('should handle undefined query', () => { + assert.throws(() => parseSync(undefined), { + message: 'Query cannot be null or undefined' + }); + }); + + it('should handle @ in comments', () => { + const query = 'SELECT * FROM users /* @ in comment */ WHERE id = 1'; + assert.doesNotThrow(() => parseSync(query)); + }); + + it('should handle @ in strings', () => { + const query = 'SELECT * FROM users WHERE email = \'user@example.com\''; + assert.doesNotThrow(() => parseSync(query)); + }); + }); + + describe('Complex Error Scenarios', () => { + it('should handle errors in CASE statements', () => { + try { + parseSync('SELECT CASE WHEN id = 1 THEN "one" WHEN id = 2 THEN @ ELSE "other" END FROM users'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 54); + } + }); + + it('should handle errors in subqueries', () => { + try { + parseSync('SELECT * FROM users WHERE id IN (SELECT @ FROM orders)'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 42); + } + }); + + it('should handle errors in function calls', () => { + try { + parseSync('SELECT COUNT(@) FROM users'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 14); + } + }); + + it('should handle errors in second statement', () => { + try { + parseSync('SELECT * FROM users; SELECT * FROM orders WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 54); + } + }); + + it('should handle errors in CTE', () => { + try { + parseSync('WITH cte AS (SELECT * FROM users WHERE id = @) SELECT * FROM cte'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 45); + } + }); + }); + + describe('Backward Compatibility', () => { + it('should maintain Error instance', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error instanceof Error); + assert.ok(error.message); + assert.ok(error.stack); + } + }); + + it('should work with standard error handling', () => { + let caught = false; + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (e) { + caught = true; + assert.ok(e.message.includes('syntax error')); + } + assert.equal(caught, true); + }); + }); +}); \ No newline at end of file diff --git a/versions/15/Makefile b/versions/15/Makefile index 161dd31..41753a1 100644 --- a/versions/15/Makefile +++ b/versions/15/Makefile @@ -57,8 +57,8 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR) \ -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ - -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string']" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','UTF8ToString','HEAPU8','HEAPU32']" \ + -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ -sMODULARIZE=1 \ diff --git a/versions/15/src/index.ts b/versions/15/src/index.ts index ef2d047..60c6d86 100644 --- a/versions/15/src/index.ts +++ b/versions/15/src/index.ts @@ -5,6 +5,125 @@ import PgQueryModule from './libpg-query.js'; let wasmModule: any; +// SQL error details interface +export interface SqlErrorDetails { + message: string; + cursorPosition: number; // 0-based position in the query + fileName?: string; // Source file where error occurred (e.g., 'scan.l', 'gram.y') + functionName?: string; // Internal function name + lineNumber?: number; // Line number in source file + context?: string; // Additional context +} + +// Options for formatting SQL errors +export interface SqlErrorFormatOptions { + showPosition?: boolean; // Show the error position marker (default: true) + showQuery?: boolean; // Show the query text (default: true) + color?: boolean; // Use ANSI colors (default: false) + maxQueryLength?: number; // Max query length to display (default: no limit) +} + +// Helper function to create enhanced error with SQL details +function createSqlError(message: string, details: SqlErrorDetails): Error { + const error = new Error(message); + // Attach error details as properties + Object.defineProperty(error, 'sqlDetails', { + value: details, + enumerable: true, + configurable: true + }); + return error; +} + +// Helper function to classify error source +function getErrorSource(filename: string | null): string { + if (!filename) return 'unknown'; + if (filename === 'scan.l') return 'lexer'; // Lexical analysis errors + if (filename === 'gram.y') return 'parser'; // Grammar/parsing errors + return filename; +} + +// Format SQL error with visual position indicator +export function formatSqlError( + error: Error & { sqlDetails?: SqlErrorDetails }, + query: string, + options: SqlErrorFormatOptions = {} +): string { + const { + showPosition = true, + showQuery = true, + color = false, + maxQueryLength + } = options; + + const lines: string[] = []; + + // ANSI color codes + const red = color ? '\x1b[31m' : ''; + const yellow = color ? '\x1b[33m' : ''; + const reset = color ? '\x1b[0m' : ''; + + // Add error message + lines.push(`${red}Error: ${error.message}${reset}`); + + // Add SQL details if available + if (error.sqlDetails) { + const { cursorPosition, fileName, functionName, lineNumber } = error.sqlDetails; + + if (cursorPosition !== undefined && cursorPosition >= 0) { + lines.push(`Position: ${cursorPosition}`); + } + + if (fileName || functionName || lineNumber) { + const details = []; + if (fileName) details.push(`file: ${fileName}`); + if (functionName) details.push(`function: ${functionName}`); + if (lineNumber) details.push(`line: ${lineNumber}`); + lines.push(`Source: ${details.join(', ')}`); + } + + // Show query with position marker + if (showQuery && showPosition && cursorPosition !== undefined && cursorPosition >= 0) { + let displayQuery = query; + + // Truncate if needed + if (maxQueryLength && query.length > maxQueryLength) { + const start = Math.max(0, cursorPosition - Math.floor(maxQueryLength / 2)); + const end = Math.min(query.length, start + maxQueryLength); + displayQuery = (start > 0 ? '...' : '') + + query.substring(start, end) + + (end < query.length ? '...' : ''); + // Adjust cursor position for truncation + const adjustedPosition = cursorPosition - start + (start > 0 ? 3 : 0); + lines.push(displayQuery); + lines.push(' '.repeat(adjustedPosition) + `${yellow}^${reset}`); + } else { + lines.push(displayQuery); + lines.push(' '.repeat(cursorPosition) + `${yellow}^${reset}`); + } + } + } else if (showQuery) { + // No SQL details, just show the query if requested + let displayQuery = query; + if (maxQueryLength && query.length > maxQueryLength) { + displayQuery = query.substring(0, maxQueryLength) + '...'; + } + lines.push(`Query: ${displayQuery}`); + } + + return lines.join('\n'); +} + +// Check if an error has SQL details +export function hasSqlDetails(error: any): error is Error & { sqlDetails: SqlErrorDetails } { + return error instanceof Error && + 'sqlDetails' in error && + typeof (error as any).sqlDetails === 'object' && + (error as any).sqlDetails !== null && + 'message' in (error as any).sqlDetails && + 'cursorPosition' in (error as any).sqlDetails; +} + const initPromise = PgQueryModule().then((module: any) => { wasmModule = module; }); @@ -51,37 +170,139 @@ function ptrToString(ptr: number): string { } export const parse = awaitInit(async (query: string) => { + // Pre-validation + if (query === null || query === undefined) { + throw new Error('Query cannot be null or undefined'); + } + if (typeof query !== 'string') { + throw new Error(`Query must be a string, got ${typeof query}`); + } + if (query.trim() === '') { + throw new Error('Query cannot be empty'); + } + const queryPtr = stringToPtr(query); let resultPtr = 0; + try { - resultPtr = wasmModule._wasm_parse_query(queryPtr); - const resultStr = ptrToString(resultPtr); - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + // Call the raw function that returns a struct pointer + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new Error('Failed to allocate memory for parse result'); + } + + // Read the PgQueryParseResult struct fields + // struct { char* parse_tree; char* stderr_buffer; PgQueryError* error; } + const parseTreePtr = wasmModule.getValue(resultPtr, 'i32'); // offset 0 + const stderrBufferPtr = wasmModule.getValue(resultPtr + 4, 'i32'); // offset 4 + const errorPtr = wasmModule.getValue(resultPtr + 8, 'i32'); // offset 8 + + // Check for error + if (errorPtr) { + // Read PgQueryError struct fields + // struct { char* message; char* funcname; char* filename; int lineno; int cursorpos; char* context; } + const messagePtr = wasmModule.getValue(errorPtr, 'i32'); // offset 0 + const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); // offset 4 + const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); // offset 8 + const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); // offset 12 + const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); // offset 16 + const contextPtr = wasmModule.getValue(errorPtr + 20, 'i32'); // offset 20 + + const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error'; + const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : null; + + const errorDetails: SqlErrorDetails = { + message: message, + cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based + fileName: filename || undefined, + functionName: funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined, + lineNumber: lineno > 0 ? lineno : undefined, + context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined + }; + + throw createSqlError(message, errorDetails); + } + + if (!parseTreePtr) { + throw new Error('Parse result is null'); } - return JSON.parse(resultStr); - } finally { + + const parseTree = wasmModule.UTF8ToString(parseTreePtr); + return JSON.parse(parseTree); + } + finally { wasmModule._free(queryPtr); if (resultPtr) { - wasmModule._wasm_free_string(resultPtr); + wasmModule._wasm_free_parse_result(resultPtr); } } }); export function parseSync(query: string) { + // Pre-validation + if (query === null || query === undefined) { + throw new Error('Query cannot be null or undefined'); + } + if (typeof query !== 'string') { + throw new Error(`Query must be a string, got ${typeof query}`); + } + if (query.trim() === '') { + throw new Error('Query cannot be empty'); + } + const queryPtr = stringToPtr(query); let resultPtr = 0; + try { - resultPtr = wasmModule._wasm_parse_query(queryPtr); - const resultStr = ptrToString(resultPtr); - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + // Call the raw function that returns a struct pointer + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new Error('Failed to allocate memory for parse result'); } - return JSON.parse(resultStr); - } finally { + + // Read the PgQueryParseResult struct fields + // struct { char* parse_tree; char* stderr_buffer; PgQueryError* error; } + const parseTreePtr = wasmModule.getValue(resultPtr, 'i32'); // offset 0 + const stderrBufferPtr = wasmModule.getValue(resultPtr + 4, 'i32'); // offset 4 + const errorPtr = wasmModule.getValue(resultPtr + 8, 'i32'); // offset 8 + + // Check for error + if (errorPtr) { + // Read PgQueryError struct fields + // struct { char* message; char* funcname; char* filename; int lineno; int cursorpos; char* context; } + const messagePtr = wasmModule.getValue(errorPtr, 'i32'); // offset 0 + const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); // offset 4 + const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); // offset 8 + const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); // offset 12 + const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); // offset 16 + const contextPtr = wasmModule.getValue(errorPtr + 20, 'i32'); // offset 20 + + const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error'; + const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : null; + + const errorDetails: SqlErrorDetails = { + message: message, + cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based + fileName: filename || undefined, + functionName: funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined, + lineNumber: lineno > 0 ? lineno : undefined, + context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined + }; + + throw createSqlError(message, errorDetails); + } + + if (!parseTreePtr) { + throw new Error('Parse result is null'); + } + + const parseTree = wasmModule.UTF8ToString(parseTreePtr); + return JSON.parse(parseTree); + } + finally { wasmModule._free(queryPtr); if (resultPtr) { - wasmModule._wasm_free_string(resultPtr); + wasmModule._wasm_free_parse_result(resultPtr); } } } \ No newline at end of file diff --git a/versions/15/src/wasm_wrapper.c b/versions/15/src/wasm_wrapper.c index bf297c6..b9d958f 100644 --- a/versions/15/src/wasm_wrapper.c +++ b/versions/15/src/wasm_wrapper.c @@ -46,4 +46,28 @@ char* wasm_parse_query(const char* input) { EMSCRIPTEN_KEEPALIVE void wasm_free_string(char* str) { free(str); +} + +// Raw struct access functions for parse +EMSCRIPTEN_KEEPALIVE +PgQueryParseResult* wasm_parse_query_raw(const char* input) { + if (!input) { + return NULL; + } + + PgQueryParseResult* result = (PgQueryParseResult*)safe_malloc(sizeof(PgQueryParseResult)); + if (!result) { + return NULL; + } + + *result = pg_query_parse(input); + return result; +} + +EMSCRIPTEN_KEEPALIVE +void wasm_free_parse_result(PgQueryParseResult* result) { + if (result) { + pg_query_free_parse_result(*result); + free(result); + } } \ No newline at end of file diff --git a/versions/15/test/errors.test.js b/versions/15/test/errors.test.js new file mode 100644 index 0000000..f6c0fd6 --- /dev/null +++ b/versions/15/test/errors.test.js @@ -0,0 +1,325 @@ +const { describe, it, before } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseSync, loadModule, formatSqlError, hasSqlDetails } = require('../wasm/index.cjs'); + +describe('Enhanced Error Handling', () => { + before(async () => { + await loadModule(); + }); + + describe('Error Details Structure', () => { + it('should include sqlDetails property on parse errors', () => { + assert.throws(() => { + parseSync('SELECT * FROM users WHERE id = @'); + }); + + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (error) { + assert.ok('sqlDetails' in error); + assert.ok('message' in error.sqlDetails); + assert.ok('cursorPosition' in error.sqlDetails); + assert.ok('fileName' in error.sqlDetails); + assert.ok('functionName' in error.sqlDetails); + assert.ok('lineNumber' in error.sqlDetails); + } + }); + + it('should have correct cursor position (0-based)', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 32); + } + }); + + it('should identify error source file', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.fileName, 'scan.l'); + assert.equal(error.sqlDetails.functionName, 'scanner_yyerror'); + } + }); + }); + + describe('Error Position Accuracy', () => { + const positionTests = [ + { query: '@ SELECT * FROM users', expectedPos: 0, desc: 'error at start' }, + { query: 'SELECT @ FROM users', expectedPos: 9, desc: 'error after SELECT' }, + { query: 'SELECT * FROM users WHERE @ = 1', expectedPos: 28, desc: 'error after WHERE' }, + { query: 'SELECT * FROM users WHERE id = @', expectedPos: 32, desc: 'error at end' }, + { query: 'INSERT INTO users (id, name) VALUES (1, @)', expectedPos: 41, desc: 'error in VALUES' }, + { query: 'UPDATE users SET name = @ WHERE id = 1', expectedPos: 26, desc: 'error in SET' }, + { query: 'CREATE TABLE test (id INT, name @)', expectedPos: 32, desc: 'error in CREATE TABLE' }, + ]; + + positionTests.forEach(({ query, expectedPos, desc }) => { + it(`should correctly identify position for ${desc}`, () => { + try { + parseSync(query); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, expectedPos); + } + }); + }); + }); + + describe('Error Types', () => { + it('should handle unterminated string literals', () => { + try { + parseSync("SELECT * FROM users WHERE name = 'unclosed"); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('unterminated quoted string')); + assert.equal(error.sqlDetails.cursorPosition, 33); + } + }); + + it('should handle unterminated quoted identifiers', () => { + try { + parseSync('SELECT * FROM users WHERE name = "unclosed'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('unterminated quoted identifier')); + assert.equal(error.sqlDetails.cursorPosition, 33); + } + }); + + it('should handle invalid tokens', () => { + try { + parseSync('SELECT * FROM users WHERE id = $'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at or near "$"')); + assert.equal(error.sqlDetails.cursorPosition, 31); + } + }); + + it('should handle reserved keywords', () => { + try { + parseSync('SELECT * FROM table'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at or near "table"')); + assert.equal(error.sqlDetails.cursorPosition, 14); + } + }); + + it('should handle syntax error in WHERE clause', () => { + try { + parseSync('SELECT * FROM users WHERE'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at end of input')); + assert.equal(error.sqlDetails.cursorPosition, 25); + } + }); + }); + + describe('formatSqlError Helper', () => { + it('should format error with position indicator', () => { + try { + parseSync("SELECT * FROM users WHERE id = 'unclosed"); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, "SELECT * FROM users WHERE id = 'unclosed"); + assert.ok(formatted.includes('Error: unterminated quoted string')); + assert.ok(formatted.includes('Position: 31')); + assert.ok(formatted.includes("SELECT * FROM users WHERE id = 'unclosed")); + assert.ok(formatted.includes(' ^')); + } + }); + + it('should respect showPosition option', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + showPosition: false + }); + assert.ok(!formatted.includes('^')); + assert.ok(formatted.includes('Position: 32')); + } + }); + + it('should respect showQuery option', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + showQuery: false + }); + assert.ok(!formatted.includes('SELECT * FROM users')); + assert.ok(formatted.includes('Error:')); + assert.ok(formatted.includes('Position:')); + } + }); + + it('should truncate long queries', () => { + const longQuery = 'SELECT ' + 'a, '.repeat(50) + 'z FROM users WHERE id = @'; + try { + parseSync(longQuery); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, longQuery, { maxQueryLength: 50 }); + assert.ok(formatted.includes('...')); + const lines = formatted.split('\n'); + const queryLine = lines.find(line => line.includes('...')); + assert.ok(queryLine.length <= 56); // 50 + 2*3 for ellipsis + } + }); + + it('should handle color option without breaking output', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + color: true + }); + assert.ok(formatted.includes('Error:')); + assert.ok(formatted.includes('Position:')); + // Should contain ANSI codes but still be readable + const cleanFormatted = formatted.replace(/\x1b\[[0-9;]*m/g, ''); + assert.ok(cleanFormatted.includes('syntax error')); + } + }); + }); + + describe('hasSqlDetails Type Guard', () => { + it('should return true for SQL parse errors', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(hasSqlDetails(error), true); + } + }); + + it('should return false for regular errors', () => { + const regularError = new Error('Regular error'); + assert.equal(hasSqlDetails(regularError), false); + }); + + it('should return false for non-Error objects', () => { + assert.equal(hasSqlDetails('string'), false); + assert.equal(hasSqlDetails(123), false); + assert.equal(hasSqlDetails(null), false); + assert.equal(hasSqlDetails(undefined), false); + assert.equal(hasSqlDetails({}), false); + }); + + it('should return false for Error with incomplete sqlDetails', () => { + const error = new Error('Test'); + error.sqlDetails = { message: 'test' }; // Missing cursorPosition + assert.equal(hasSqlDetails(error), false); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty query', () => { + assert.throws(() => parseSync(''), { + message: 'Query cannot be empty' + }); + }); + + it('should handle null query', () => { + assert.throws(() => parseSync(null), { + message: 'Query cannot be null or undefined' + }); + }); + + it('should handle undefined query', () => { + assert.throws(() => parseSync(undefined), { + message: 'Query cannot be null or undefined' + }); + }); + + it('should handle @ in comments', () => { + const query = 'SELECT * FROM users /* @ in comment */ WHERE id = 1'; + assert.doesNotThrow(() => parseSync(query)); + }); + + it('should handle @ in strings', () => { + const query = 'SELECT * FROM users WHERE email = \'user@example.com\''; + assert.doesNotThrow(() => parseSync(query)); + }); + }); + + describe('Complex Error Scenarios', () => { + it('should handle errors in CASE statements', () => { + try { + parseSync('SELECT CASE WHEN id = 1 THEN "one" WHEN id = 2 THEN @ ELSE "other" END FROM users'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 54); + } + }); + + it('should handle errors in subqueries', () => { + try { + parseSync('SELECT * FROM users WHERE id IN (SELECT @ FROM orders)'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 42); + } + }); + + it('should handle errors in function calls', () => { + try { + parseSync('SELECT COUNT(@) FROM users'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 14); + } + }); + + it('should handle errors in second statement', () => { + try { + parseSync('SELECT * FROM users; SELECT * FROM orders WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 54); + } + }); + + it('should handle errors in CTE', () => { + try { + parseSync('WITH cte AS (SELECT * FROM users WHERE id = @) SELECT * FROM cte'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 45); + } + }); + }); + + describe('Backward Compatibility', () => { + it('should maintain Error instance', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error instanceof Error); + assert.ok(error.message); + assert.ok(error.stack); + } + }); + + it('should work with standard error handling', () => { + let caught = false; + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (e) { + caught = true; + assert.ok(e.message.includes('syntax error')); + } + assert.equal(caught, true); + }); + }); +}); \ No newline at end of file diff --git a/versions/16/Makefile b/versions/16/Makefile index 9d7bca0..d0c2f5d 100644 --- a/versions/16/Makefile +++ b/versions/16/Makefile @@ -57,8 +57,8 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR) \ -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ - -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string']" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','UTF8ToString','HEAPU8','HEAPU32']" \ + -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ -sMODULARIZE=1 \ diff --git a/versions/16/src/index.ts b/versions/16/src/index.ts index ef2d047..60c6d86 100644 --- a/versions/16/src/index.ts +++ b/versions/16/src/index.ts @@ -5,6 +5,125 @@ import PgQueryModule from './libpg-query.js'; let wasmModule: any; +// SQL error details interface +export interface SqlErrorDetails { + message: string; + cursorPosition: number; // 0-based position in the query + fileName?: string; // Source file where error occurred (e.g., 'scan.l', 'gram.y') + functionName?: string; // Internal function name + lineNumber?: number; // Line number in source file + context?: string; // Additional context +} + +// Options for formatting SQL errors +export interface SqlErrorFormatOptions { + showPosition?: boolean; // Show the error position marker (default: true) + showQuery?: boolean; // Show the query text (default: true) + color?: boolean; // Use ANSI colors (default: false) + maxQueryLength?: number; // Max query length to display (default: no limit) +} + +// Helper function to create enhanced error with SQL details +function createSqlError(message: string, details: SqlErrorDetails): Error { + const error = new Error(message); + // Attach error details as properties + Object.defineProperty(error, 'sqlDetails', { + value: details, + enumerable: true, + configurable: true + }); + return error; +} + +// Helper function to classify error source +function getErrorSource(filename: string | null): string { + if (!filename) return 'unknown'; + if (filename === 'scan.l') return 'lexer'; // Lexical analysis errors + if (filename === 'gram.y') return 'parser'; // Grammar/parsing errors + return filename; +} + +// Format SQL error with visual position indicator +export function formatSqlError( + error: Error & { sqlDetails?: SqlErrorDetails }, + query: string, + options: SqlErrorFormatOptions = {} +): string { + const { + showPosition = true, + showQuery = true, + color = false, + maxQueryLength + } = options; + + const lines: string[] = []; + + // ANSI color codes + const red = color ? '\x1b[31m' : ''; + const yellow = color ? '\x1b[33m' : ''; + const reset = color ? '\x1b[0m' : ''; + + // Add error message + lines.push(`${red}Error: ${error.message}${reset}`); + + // Add SQL details if available + if (error.sqlDetails) { + const { cursorPosition, fileName, functionName, lineNumber } = error.sqlDetails; + + if (cursorPosition !== undefined && cursorPosition >= 0) { + lines.push(`Position: ${cursorPosition}`); + } + + if (fileName || functionName || lineNumber) { + const details = []; + if (fileName) details.push(`file: ${fileName}`); + if (functionName) details.push(`function: ${functionName}`); + if (lineNumber) details.push(`line: ${lineNumber}`); + lines.push(`Source: ${details.join(', ')}`); + } + + // Show query with position marker + if (showQuery && showPosition && cursorPosition !== undefined && cursorPosition >= 0) { + let displayQuery = query; + + // Truncate if needed + if (maxQueryLength && query.length > maxQueryLength) { + const start = Math.max(0, cursorPosition - Math.floor(maxQueryLength / 2)); + const end = Math.min(query.length, start + maxQueryLength); + displayQuery = (start > 0 ? '...' : '') + + query.substring(start, end) + + (end < query.length ? '...' : ''); + // Adjust cursor position for truncation + const adjustedPosition = cursorPosition - start + (start > 0 ? 3 : 0); + lines.push(displayQuery); + lines.push(' '.repeat(adjustedPosition) + `${yellow}^${reset}`); + } else { + lines.push(displayQuery); + lines.push(' '.repeat(cursorPosition) + `${yellow}^${reset}`); + } + } + } else if (showQuery) { + // No SQL details, just show the query if requested + let displayQuery = query; + if (maxQueryLength && query.length > maxQueryLength) { + displayQuery = query.substring(0, maxQueryLength) + '...'; + } + lines.push(`Query: ${displayQuery}`); + } + + return lines.join('\n'); +} + +// Check if an error has SQL details +export function hasSqlDetails(error: any): error is Error & { sqlDetails: SqlErrorDetails } { + return error instanceof Error && + 'sqlDetails' in error && + typeof (error as any).sqlDetails === 'object' && + (error as any).sqlDetails !== null && + 'message' in (error as any).sqlDetails && + 'cursorPosition' in (error as any).sqlDetails; +} + const initPromise = PgQueryModule().then((module: any) => { wasmModule = module; }); @@ -51,37 +170,139 @@ function ptrToString(ptr: number): string { } export const parse = awaitInit(async (query: string) => { + // Pre-validation + if (query === null || query === undefined) { + throw new Error('Query cannot be null or undefined'); + } + if (typeof query !== 'string') { + throw new Error(`Query must be a string, got ${typeof query}`); + } + if (query.trim() === '') { + throw new Error('Query cannot be empty'); + } + const queryPtr = stringToPtr(query); let resultPtr = 0; + try { - resultPtr = wasmModule._wasm_parse_query(queryPtr); - const resultStr = ptrToString(resultPtr); - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + // Call the raw function that returns a struct pointer + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new Error('Failed to allocate memory for parse result'); + } + + // Read the PgQueryParseResult struct fields + // struct { char* parse_tree; char* stderr_buffer; PgQueryError* error; } + const parseTreePtr = wasmModule.getValue(resultPtr, 'i32'); // offset 0 + const stderrBufferPtr = wasmModule.getValue(resultPtr + 4, 'i32'); // offset 4 + const errorPtr = wasmModule.getValue(resultPtr + 8, 'i32'); // offset 8 + + // Check for error + if (errorPtr) { + // Read PgQueryError struct fields + // struct { char* message; char* funcname; char* filename; int lineno; int cursorpos; char* context; } + const messagePtr = wasmModule.getValue(errorPtr, 'i32'); // offset 0 + const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); // offset 4 + const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); // offset 8 + const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); // offset 12 + const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); // offset 16 + const contextPtr = wasmModule.getValue(errorPtr + 20, 'i32'); // offset 20 + + const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error'; + const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : null; + + const errorDetails: SqlErrorDetails = { + message: message, + cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based + fileName: filename || undefined, + functionName: funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined, + lineNumber: lineno > 0 ? lineno : undefined, + context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined + }; + + throw createSqlError(message, errorDetails); + } + + if (!parseTreePtr) { + throw new Error('Parse result is null'); } - return JSON.parse(resultStr); - } finally { + + const parseTree = wasmModule.UTF8ToString(parseTreePtr); + return JSON.parse(parseTree); + } + finally { wasmModule._free(queryPtr); if (resultPtr) { - wasmModule._wasm_free_string(resultPtr); + wasmModule._wasm_free_parse_result(resultPtr); } } }); export function parseSync(query: string) { + // Pre-validation + if (query === null || query === undefined) { + throw new Error('Query cannot be null or undefined'); + } + if (typeof query !== 'string') { + throw new Error(`Query must be a string, got ${typeof query}`); + } + if (query.trim() === '') { + throw new Error('Query cannot be empty'); + } + const queryPtr = stringToPtr(query); let resultPtr = 0; + try { - resultPtr = wasmModule._wasm_parse_query(queryPtr); - const resultStr = ptrToString(resultPtr); - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + // Call the raw function that returns a struct pointer + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new Error('Failed to allocate memory for parse result'); } - return JSON.parse(resultStr); - } finally { + + // Read the PgQueryParseResult struct fields + // struct { char* parse_tree; char* stderr_buffer; PgQueryError* error; } + const parseTreePtr = wasmModule.getValue(resultPtr, 'i32'); // offset 0 + const stderrBufferPtr = wasmModule.getValue(resultPtr + 4, 'i32'); // offset 4 + const errorPtr = wasmModule.getValue(resultPtr + 8, 'i32'); // offset 8 + + // Check for error + if (errorPtr) { + // Read PgQueryError struct fields + // struct { char* message; char* funcname; char* filename; int lineno; int cursorpos; char* context; } + const messagePtr = wasmModule.getValue(errorPtr, 'i32'); // offset 0 + const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); // offset 4 + const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); // offset 8 + const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); // offset 12 + const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); // offset 16 + const contextPtr = wasmModule.getValue(errorPtr + 20, 'i32'); // offset 20 + + const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error'; + const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : null; + + const errorDetails: SqlErrorDetails = { + message: message, + cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based + fileName: filename || undefined, + functionName: funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined, + lineNumber: lineno > 0 ? lineno : undefined, + context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined + }; + + throw createSqlError(message, errorDetails); + } + + if (!parseTreePtr) { + throw new Error('Parse result is null'); + } + + const parseTree = wasmModule.UTF8ToString(parseTreePtr); + return JSON.parse(parseTree); + } + finally { wasmModule._free(queryPtr); if (resultPtr) { - wasmModule._wasm_free_string(resultPtr); + wasmModule._wasm_free_parse_result(resultPtr); } } } \ No newline at end of file diff --git a/versions/16/src/wasm_wrapper.c b/versions/16/src/wasm_wrapper.c index bf297c6..b9d958f 100644 --- a/versions/16/src/wasm_wrapper.c +++ b/versions/16/src/wasm_wrapper.c @@ -46,4 +46,28 @@ char* wasm_parse_query(const char* input) { EMSCRIPTEN_KEEPALIVE void wasm_free_string(char* str) { free(str); +} + +// Raw struct access functions for parse +EMSCRIPTEN_KEEPALIVE +PgQueryParseResult* wasm_parse_query_raw(const char* input) { + if (!input) { + return NULL; + } + + PgQueryParseResult* result = (PgQueryParseResult*)safe_malloc(sizeof(PgQueryParseResult)); + if (!result) { + return NULL; + } + + *result = pg_query_parse(input); + return result; +} + +EMSCRIPTEN_KEEPALIVE +void wasm_free_parse_result(PgQueryParseResult* result) { + if (result) { + pg_query_free_parse_result(*result); + free(result); + } } \ No newline at end of file diff --git a/versions/16/test/errors.test.js b/versions/16/test/errors.test.js new file mode 100644 index 0000000..f6c0fd6 --- /dev/null +++ b/versions/16/test/errors.test.js @@ -0,0 +1,325 @@ +const { describe, it, before } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseSync, loadModule, formatSqlError, hasSqlDetails } = require('../wasm/index.cjs'); + +describe('Enhanced Error Handling', () => { + before(async () => { + await loadModule(); + }); + + describe('Error Details Structure', () => { + it('should include sqlDetails property on parse errors', () => { + assert.throws(() => { + parseSync('SELECT * FROM users WHERE id = @'); + }); + + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (error) { + assert.ok('sqlDetails' in error); + assert.ok('message' in error.sqlDetails); + assert.ok('cursorPosition' in error.sqlDetails); + assert.ok('fileName' in error.sqlDetails); + assert.ok('functionName' in error.sqlDetails); + assert.ok('lineNumber' in error.sqlDetails); + } + }); + + it('should have correct cursor position (0-based)', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 32); + } + }); + + it('should identify error source file', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.fileName, 'scan.l'); + assert.equal(error.sqlDetails.functionName, 'scanner_yyerror'); + } + }); + }); + + describe('Error Position Accuracy', () => { + const positionTests = [ + { query: '@ SELECT * FROM users', expectedPos: 0, desc: 'error at start' }, + { query: 'SELECT @ FROM users', expectedPos: 9, desc: 'error after SELECT' }, + { query: 'SELECT * FROM users WHERE @ = 1', expectedPos: 28, desc: 'error after WHERE' }, + { query: 'SELECT * FROM users WHERE id = @', expectedPos: 32, desc: 'error at end' }, + { query: 'INSERT INTO users (id, name) VALUES (1, @)', expectedPos: 41, desc: 'error in VALUES' }, + { query: 'UPDATE users SET name = @ WHERE id = 1', expectedPos: 26, desc: 'error in SET' }, + { query: 'CREATE TABLE test (id INT, name @)', expectedPos: 32, desc: 'error in CREATE TABLE' }, + ]; + + positionTests.forEach(({ query, expectedPos, desc }) => { + it(`should correctly identify position for ${desc}`, () => { + try { + parseSync(query); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, expectedPos); + } + }); + }); + }); + + describe('Error Types', () => { + it('should handle unterminated string literals', () => { + try { + parseSync("SELECT * FROM users WHERE name = 'unclosed"); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('unterminated quoted string')); + assert.equal(error.sqlDetails.cursorPosition, 33); + } + }); + + it('should handle unterminated quoted identifiers', () => { + try { + parseSync('SELECT * FROM users WHERE name = "unclosed'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('unterminated quoted identifier')); + assert.equal(error.sqlDetails.cursorPosition, 33); + } + }); + + it('should handle invalid tokens', () => { + try { + parseSync('SELECT * FROM users WHERE id = $'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at or near "$"')); + assert.equal(error.sqlDetails.cursorPosition, 31); + } + }); + + it('should handle reserved keywords', () => { + try { + parseSync('SELECT * FROM table'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at or near "table"')); + assert.equal(error.sqlDetails.cursorPosition, 14); + } + }); + + it('should handle syntax error in WHERE clause', () => { + try { + parseSync('SELECT * FROM users WHERE'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at end of input')); + assert.equal(error.sqlDetails.cursorPosition, 25); + } + }); + }); + + describe('formatSqlError Helper', () => { + it('should format error with position indicator', () => { + try { + parseSync("SELECT * FROM users WHERE id = 'unclosed"); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, "SELECT * FROM users WHERE id = 'unclosed"); + assert.ok(formatted.includes('Error: unterminated quoted string')); + assert.ok(formatted.includes('Position: 31')); + assert.ok(formatted.includes("SELECT * FROM users WHERE id = 'unclosed")); + assert.ok(formatted.includes(' ^')); + } + }); + + it('should respect showPosition option', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + showPosition: false + }); + assert.ok(!formatted.includes('^')); + assert.ok(formatted.includes('Position: 32')); + } + }); + + it('should respect showQuery option', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + showQuery: false + }); + assert.ok(!formatted.includes('SELECT * FROM users')); + assert.ok(formatted.includes('Error:')); + assert.ok(formatted.includes('Position:')); + } + }); + + it('should truncate long queries', () => { + const longQuery = 'SELECT ' + 'a, '.repeat(50) + 'z FROM users WHERE id = @'; + try { + parseSync(longQuery); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, longQuery, { maxQueryLength: 50 }); + assert.ok(formatted.includes('...')); + const lines = formatted.split('\n'); + const queryLine = lines.find(line => line.includes('...')); + assert.ok(queryLine.length <= 56); // 50 + 2*3 for ellipsis + } + }); + + it('should handle color option without breaking output', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + color: true + }); + assert.ok(formatted.includes('Error:')); + assert.ok(formatted.includes('Position:')); + // Should contain ANSI codes but still be readable + const cleanFormatted = formatted.replace(/\x1b\[[0-9;]*m/g, ''); + assert.ok(cleanFormatted.includes('syntax error')); + } + }); + }); + + describe('hasSqlDetails Type Guard', () => { + it('should return true for SQL parse errors', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(hasSqlDetails(error), true); + } + }); + + it('should return false for regular errors', () => { + const regularError = new Error('Regular error'); + assert.equal(hasSqlDetails(regularError), false); + }); + + it('should return false for non-Error objects', () => { + assert.equal(hasSqlDetails('string'), false); + assert.equal(hasSqlDetails(123), false); + assert.equal(hasSqlDetails(null), false); + assert.equal(hasSqlDetails(undefined), false); + assert.equal(hasSqlDetails({}), false); + }); + + it('should return false for Error with incomplete sqlDetails', () => { + const error = new Error('Test'); + error.sqlDetails = { message: 'test' }; // Missing cursorPosition + assert.equal(hasSqlDetails(error), false); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty query', () => { + assert.throws(() => parseSync(''), { + message: 'Query cannot be empty' + }); + }); + + it('should handle null query', () => { + assert.throws(() => parseSync(null), { + message: 'Query cannot be null or undefined' + }); + }); + + it('should handle undefined query', () => { + assert.throws(() => parseSync(undefined), { + message: 'Query cannot be null or undefined' + }); + }); + + it('should handle @ in comments', () => { + const query = 'SELECT * FROM users /* @ in comment */ WHERE id = 1'; + assert.doesNotThrow(() => parseSync(query)); + }); + + it('should handle @ in strings', () => { + const query = 'SELECT * FROM users WHERE email = \'user@example.com\''; + assert.doesNotThrow(() => parseSync(query)); + }); + }); + + describe('Complex Error Scenarios', () => { + it('should handle errors in CASE statements', () => { + try { + parseSync('SELECT CASE WHEN id = 1 THEN "one" WHEN id = 2 THEN @ ELSE "other" END FROM users'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 54); + } + }); + + it('should handle errors in subqueries', () => { + try { + parseSync('SELECT * FROM users WHERE id IN (SELECT @ FROM orders)'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 42); + } + }); + + it('should handle errors in function calls', () => { + try { + parseSync('SELECT COUNT(@) FROM users'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 14); + } + }); + + it('should handle errors in second statement', () => { + try { + parseSync('SELECT * FROM users; SELECT * FROM orders WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 54); + } + }); + + it('should handle errors in CTE', () => { + try { + parseSync('WITH cte AS (SELECT * FROM users WHERE id = @) SELECT * FROM cte'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 45); + } + }); + }); + + describe('Backward Compatibility', () => { + it('should maintain Error instance', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error instanceof Error); + assert.ok(error.message); + assert.ok(error.stack); + } + }); + + it('should work with standard error handling', () => { + let caught = false; + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (e) { + caught = true; + assert.ok(e.message.includes('syntax error')); + } + assert.equal(caught, true); + }); + }); +}); \ No newline at end of file From dd1e585fec1100459f968984be9c6b1b59623a95 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 26 Jun 2025 14:22:15 -0700 Subject: [PATCH 04/14] makefile --- versions/13/Makefile | 2 +- versions/17/Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/versions/13/Makefile b/versions/13/Makefile index f4a3e50..e4b51ac 100644 --- a/versions/13/Makefile +++ b/versions/13/Makefile @@ -61,7 +61,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','UTF8ToString','getValue','HEAPU8','HEAPU32']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ -sMODULARIZE=1 \ diff --git a/versions/17/Makefile b/versions/17/Makefile index 9091db4..fd3be5d 100644 --- a/versions/17/Makefile +++ b/versions/17/Makefile @@ -58,7 +58,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','UTF8ToString','getValue','HEAPU8','HEAPU32']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ -sMODULARIZE=1 \ From 8e624a0c91552c45dec52c2abaf1a5ef6256cba0 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 26 Jun 2025 14:55:17 -0700 Subject: [PATCH 05/14] templates --- README.md | 12 ++ package.json | 1 + scripts/README.md | 37 ++++ scripts/copy-templates.js | 180 ++++++++++++++++++ templates/LICENSE | 22 +++ templates/Makefile | 94 ++++++++++ templates/README.md | 38 ++++ templates/src/index.ts | 308 +++++++++++++++++++++++++++++++ templates/src/libpg-query.d.ts | 15 ++ templates/src/wasm_wrapper.c | 73 ++++++++ versions/13/Makefile | 9 +- versions/13/src/index.ts | 7 + versions/13/src/libpg-query.d.ts | 7 + versions/13/src/wasm_wrapper.c | 7 + versions/14/Makefile | 8 +- versions/14/src/index.ts | 7 + versions/14/src/libpg-query.d.ts | 7 + versions/14/src/wasm_wrapper.c | 7 + versions/15/Makefile | 8 +- versions/15/src/index.ts | 7 + versions/15/src/libpg-query.d.ts | 7 + versions/15/src/wasm_wrapper.c | 7 + versions/16/Makefile | 8 +- versions/16/src/index.ts | 7 + versions/16/src/libpg-query.d.ts | 7 + versions/16/src/wasm_wrapper.c | 7 + versions/17/Makefile | 8 +- versions/17/src/index.ts | 7 + versions/17/src/libpg-query.d.ts | 7 + versions/17/src/wasm_wrapper.c | 7 + 30 files changed, 921 insertions(+), 5 deletions(-) create mode 100644 scripts/README.md create mode 100755 scripts/copy-templates.js create mode 100644 templates/LICENSE create mode 100644 templates/Makefile create mode 100644 templates/README.md create mode 100644 templates/src/index.ts create mode 100644 templates/src/libpg-query.d.ts create mode 100644 templates/src/wasm_wrapper.c diff --git a/README.md b/README.md index 40ad479..7a2c871 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,18 @@ pnpm run test - Ensure Emscripten SDK is properly installed and configured - Check that all required build dependencies are available +### Template System + +To avoid duplication across PostgreSQL versions, common files are maintained in the `templates/` directory: +- `LICENSE`, `Makefile`, `src/index.ts`, `src/libpg-query.d.ts`, `src/wasm_wrapper.c` + +To update version-specific files from templates: +```bash +npm run copy:templates +``` + +This ensures consistency while allowing version-specific customizations (e.g., patches for version 13). + ### Build Artifacts The build process generates these files: diff --git a/package.json b/package.json index 3db737c..32dc96d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "publish:enums": "node scripts/publish-enums.js", "publish:versions": "node scripts/publish-versions.js", "update:versions-types": "node scripts/update-versions-types.js", + "copy:templates": "node scripts/copy-templates.js", "build:parser": "pnpm --filter @pgsql/parser build", "build:parser:lts": "PARSER_BUILD_TYPE=lts pnpm --filter @pgsql/parser build", "build:parser:full": "PARSER_BUILD_TYPE=full pnpm --filter @pgsql/parser build", diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..1a5f240 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,37 @@ +# Scripts Directory + +This directory contains various build and maintenance scripts for the libpg-query monorepo. + +## Scripts + +### copy-templates.js + +Copies template files from the `templates/` directory to each PostgreSQL version directory. + +**Usage:** +```bash +npm run copy:templates +``` + +**Features:** +- Processes template placeholders (e.g., `{{LIBPG_QUERY_TAG}}`) +- Handles conditional blocks using mustache-like syntax +- Adds auto-generated headers to source files +- Maintains version-specific configurations + +**Version Configurations:** +- Version 13: Uses emscripten patch (`useEmscriptenPatch: true`) +- Versions 14-17: No special patches + +### Other Scripts + +- `analyze-sizes.js` - Analyzes build artifact sizes +- `fetch-protos.js` - Fetches protocol buffer definitions +- `build-types.js` - Builds TypeScript type definitions +- `prepare-types.js` - Prepares type definitions for publishing +- `build-enums.js` - Builds enum definitions +- `prepare-enums.js` - Prepares enum definitions for publishing +- `publish-types.js` - Publishes @pgsql/types package +- `publish-enums.js` - Publishes @pgsql/enums package +- `publish-versions.js` - Publishes version-specific packages +- `update-versions-types.js` - Updates type dependencies in version packages \ No newline at end of file diff --git a/scripts/copy-templates.js b/scripts/copy-templates.js new file mode 100755 index 0000000..7b51ee5 --- /dev/null +++ b/scripts/copy-templates.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +// Version configurations +const VERSION_CONFIGS = { + '13': { + libpgQueryTag: '13-2.2.0', + useEmscriptenPatch: true + }, + '14': { + libpgQueryTag: '14-3.0.0', + useEmscriptenPatch: false + }, + '15': { + libpgQueryTag: '15-4.2.4', + useEmscriptenPatch: false + }, + '16': { + libpgQueryTag: '16-5.2.0', + useEmscriptenPatch: false + }, + '17': { + libpgQueryTag: '17-6.1.0', + useEmscriptenPatch: false + } +}; + +// Headers for different file types +const HEADERS = { + // JavaScript/TypeScript/C style comment + default: `/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + +`, + // Makefile style comment + makefile: `# DO NOT MODIFY MANUALLY — this is generated from the templates dir +# +# To make changes, edit the files in the templates/ directory and run: +# npm run copy:templates + +` +}; + +// File extensions that should get headers +const HEADER_EXTENSIONS = ['.ts', '.js', '.c']; +const MAKEFILE_NAMES = ['Makefile', 'makefile']; + +/** + * Process template content with simple mustache-like syntax + * @param {string} content - Template content + * @param {object} config - Configuration object + * @returns {string} Processed content + */ +function processTemplate(content, config) { + // Replace simple variables + content = content.replace(/\{\{LIBPG_QUERY_TAG\}\}/g, config.libpgQueryTag); + + // Handle conditional blocks + // {{#USE_EMSCRIPTEN_PATCH}}...{{/USE_EMSCRIPTEN_PATCH}} + const conditionalRegex = /\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g; + + content = content.replace(conditionalRegex, (match, flag, blockContent) => { + if (flag === 'USE_EMSCRIPTEN_PATCH' && config.useEmscriptenPatch) { + return blockContent; + } + return ''; + }); + + return content; +} + +/** + * Add header to file content if applicable + * @param {string} filePath - Path to the file + * @param {string} content - File content + * @returns {string} Content with header if applicable + */ +function addHeaderIfNeeded(filePath, content) { + const basename = path.basename(filePath); + const ext = path.extname(filePath); + + // Check if it's a Makefile + if (MAKEFILE_NAMES.includes(basename)) { + return HEADERS.makefile + content; + } + + // Check if it's a source file that needs a header + if (HEADER_EXTENSIONS.includes(ext)) { + return HEADERS.default + content; + } + + return content; +} + +/** + * Copy a file from template to destination with processing + * @param {string} templatePath - Source template path + * @param {string} destPath - Destination path + * @param {object} config - Version configuration + */ +function copyTemplate(templatePath, destPath, config) { + const content = fs.readFileSync(templatePath, 'utf8'); + const processedContent = processTemplate(content, config); + const finalContent = addHeaderIfNeeded(destPath, processedContent); + + // Ensure destination directory exists + const destDir = path.dirname(destPath); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + fs.writeFileSync(destPath, finalContent); +} + +/** + * Copy all templates for a specific version + * @param {string} version - Version number + * @param {object} config - Version configuration + */ +function copyTemplatesForVersion(version, config) { + const templatesDir = path.join(__dirname, '..', 'templates'); + const versionDir = path.join(__dirname, '..', 'versions', version); + + // Check if version directory exists + if (!fs.existsSync(versionDir)) { + console.warn(`Warning: Directory ${versionDir} does not exist. Skipping...`); + return; + } + + // Files to copy + const filesToCopy = [ + 'LICENSE', + 'Makefile', + 'src/index.ts', + 'src/libpg-query.d.ts', + 'src/wasm_wrapper.c' + ]; + + filesToCopy.forEach(file => { + const templatePath = path.join(templatesDir, file); + const destPath = path.join(versionDir, file); + + if (!fs.existsSync(templatePath)) { + console.error(`Error: Template file ${templatePath} does not exist!`); + return; + } + + copyTemplate(templatePath, destPath, config); + }); + + console.log(`✓ Version ${version} completed`); +} + +/** + * Main function + */ +function main() { + console.log('Copying template files to version directories...\n'); + + // Process each version + Object.entries(VERSION_CONFIGS).forEach(([version, config]) => { + console.log(`Processing version ${version}...`); + copyTemplatesForVersion(version, config); + }); + + console.log('\nAll versions processed successfully!'); +} + +// Run if called directly +if (require.main === module) { + main(); +} + +module.exports = { processTemplate, copyTemplatesForVersion }; \ No newline at end of file diff --git a/templates/LICENSE b/templates/LICENSE new file mode 100644 index 0000000..883d29d --- /dev/null +++ b/templates/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2021 Dan Lynch +Copyright (c) 2025 Interweb, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/templates/Makefile b/templates/Makefile new file mode 100644 index 0000000..da98e72 --- /dev/null +++ b/templates/Makefile @@ -0,0 +1,94 @@ +WASM_OUT_DIR := wasm +WASM_OUT_NAME := libpg-query +WASM_MODULE_NAME := PgQueryModule +LIBPG_QUERY_REPO := https://github.com/pganalyze/libpg_query.git +LIBPG_QUERY_TAG := {{LIBPG_QUERY_TAG}} + +CACHE_DIR := .cache + +OS ?= $(shell uname -s) +ARCH ?= $(shell uname -m) + +ifdef EMSCRIPTEN +PLATFORM := emscripten +else ifeq ($(OS),Darwin) +PLATFORM := darwin +else ifeq ($(OS),Linux) +PLATFORM := linux +else +$(error Unsupported platform: $(OS)) +endif + +ifdef EMSCRIPTEN +ARCH := wasm +endif + +PLATFORM_ARCH := $(PLATFORM)-$(ARCH) +SRC_FILES := src/wasm_wrapper.c +LIBPG_QUERY_DIR := $(CACHE_DIR)/$(PLATFORM_ARCH)/libpg_query/$(LIBPG_QUERY_TAG) +LIBPG_QUERY_ARCHIVE := $(LIBPG_QUERY_DIR)/libpg_query.a +LIBPG_QUERY_HEADER := $(LIBPG_QUERY_DIR)/pg_query.h +CXXFLAGS := -O3 -flto + +ifdef EMSCRIPTEN +OUT_FILES := $(foreach EXT,.js .wasm,$(WASM_OUT_DIR)/$(WASM_OUT_NAME)$(EXT)) +else +$(error Native builds are no longer supported. Use EMSCRIPTEN=1 for WASM builds only.) +endif + +# Clone libpg_query source (lives in CACHE_DIR) +$(LIBPG_QUERY_DIR): + mkdir -p $(CACHE_DIR) + git clone -b $(LIBPG_QUERY_TAG) --single-branch $(LIBPG_QUERY_REPO) $(LIBPG_QUERY_DIR) +{{#USE_EMSCRIPTEN_PATCH}} +ifdef EMSCRIPTEN + cd $(LIBPG_QUERY_DIR); patch -p1 < $(shell pwd)/patches/emscripten_disable_spinlocks.patch +endif +{{/USE_EMSCRIPTEN_PATCH}} + +$(LIBPG_QUERY_HEADER): $(LIBPG_QUERY_DIR) + +# Build libpg_query +$(LIBPG_QUERY_ARCHIVE): $(LIBPG_QUERY_DIR) + cd $(LIBPG_QUERY_DIR); $(MAKE) build + +# Build libpg-query-node WASM module +$(OUT_FILES): $(LIBPG_QUERY_ARCHIVE) $(LIBPG_QUERY_HEADER) $(SRC_FILES) +ifdef EMSCRIPTEN + mkdir -p $(WASM_OUT_DIR) + $(CC) \ + -v \ + $(CXXFLAGS) \ + -I$(LIBPG_QUERY_DIR) \ + -I$(LIBPG_QUERY_DIR)/vendor \ + -L$(LIBPG_QUERY_DIR) \ + -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ + -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ + -sENVIRONMENT="web,node" \ + -sMODULARIZE=1 \ + -sEXPORT_ES6=0 \ + -sALLOW_MEMORY_GROWTH=1 \ + -lpg_query \ + -o $@ \ + $(SRC_FILES) +else +$(error Native builds are no longer supported. Use EMSCRIPTEN=1 for WASM builds only.) +endif + +# Commands +build: $(OUT_FILES) + +build-cache: $(LIBPG_QUERY_ARCHIVE) $(LIBPG_QUERY_HEADER) + +rebuild: clean build + +rebuild-cache: clean-cache build-cache + +clean: + -@ rm -r $(OUT_FILES) > /dev/null 2>&1 + +clean-cache: + -@ rm -rf $(LIBPG_QUERY_DIR) + +.PHONY: build build-cache rebuild rebuild-cache clean clean-cache \ No newline at end of file diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..4dacf85 --- /dev/null +++ b/templates/README.md @@ -0,0 +1,38 @@ +# Template Files + +This directory contains template files that are shared across all PostgreSQL versions in the `versions/` directory. + +## Files + +- `LICENSE` - The MIT license file +- `Makefile` - The build configuration with placeholders for version-specific values +- `src/index.ts` - TypeScript entry point +- `src/libpg-query.d.ts` - TypeScript type definitions +- `src/wasm_wrapper.c` - C wrapper for WASM compilation + +## Usage + +To update the version-specific files from these templates, run: + +```bash +npm run copy:templates +``` + +This script will: +1. Copy all template files to each version directory +2. Replace placeholders with version-specific values +3. Add a header comment to source files indicating they are auto-generated +4. Handle special cases (e.g., the patch command for version 13) + +## Placeholders and Flags + +The following placeholders are used in template files: + +- `{{LIBPG_QUERY_TAG}}` - The libpg_query version tag (e.g., "14-3.0.0") +- `{{#USE_EMSCRIPTEN_PATCH}}...{{/USE_EMSCRIPTEN_PATCH}}` - Conditional block for version-specific patches (currently only used in version 13) + +## Important Notes + +- DO NOT edit files directly in the `versions/*/` directories for these common files +- Always edit the templates and run the copy script +- The script preserves version-specific configurations while maintaining consistency \ No newline at end of file diff --git a/templates/src/index.ts b/templates/src/index.ts new file mode 100644 index 0000000..60c6d86 --- /dev/null +++ b/templates/src/index.ts @@ -0,0 +1,308 @@ +export * from "@pgsql/types"; + +// @ts-ignore +import PgQueryModule from './libpg-query.js'; + +let wasmModule: any; + +// SQL error details interface +export interface SqlErrorDetails { + message: string; + cursorPosition: number; // 0-based position in the query + fileName?: string; // Source file where error occurred (e.g., 'scan.l', 'gram.y') + functionName?: string; // Internal function name + lineNumber?: number; // Line number in source file + context?: string; // Additional context +} + +// Options for formatting SQL errors +export interface SqlErrorFormatOptions { + showPosition?: boolean; // Show the error position marker (default: true) + showQuery?: boolean; // Show the query text (default: true) + color?: boolean; // Use ANSI colors (default: false) + maxQueryLength?: number; // Max query length to display (default: no limit) +} + +// Helper function to create enhanced error with SQL details +function createSqlError(message: string, details: SqlErrorDetails): Error { + const error = new Error(message); + // Attach error details as properties + Object.defineProperty(error, 'sqlDetails', { + value: details, + enumerable: true, + configurable: true + }); + return error; +} + +// Helper function to classify error source +function getErrorSource(filename: string | null): string { + if (!filename) return 'unknown'; + if (filename === 'scan.l') return 'lexer'; // Lexical analysis errors + if (filename === 'gram.y') return 'parser'; // Grammar/parsing errors + return filename; +} + +// Format SQL error with visual position indicator +export function formatSqlError( + error: Error & { sqlDetails?: SqlErrorDetails }, + query: string, + options: SqlErrorFormatOptions = {} +): string { + const { + showPosition = true, + showQuery = true, + color = false, + maxQueryLength + } = options; + + const lines: string[] = []; + + // ANSI color codes + const red = color ? '\x1b[31m' : ''; + const yellow = color ? '\x1b[33m' : ''; + const reset = color ? '\x1b[0m' : ''; + + // Add error message + lines.push(`${red}Error: ${error.message}${reset}`); + + // Add SQL details if available + if (error.sqlDetails) { + const { cursorPosition, fileName, functionName, lineNumber } = error.sqlDetails; + + if (cursorPosition !== undefined && cursorPosition >= 0) { + lines.push(`Position: ${cursorPosition}`); + } + + if (fileName || functionName || lineNumber) { + const details = []; + if (fileName) details.push(`file: ${fileName}`); + if (functionName) details.push(`function: ${functionName}`); + if (lineNumber) details.push(`line: ${lineNumber}`); + lines.push(`Source: ${details.join(', ')}`); + } + + // Show query with position marker + if (showQuery && showPosition && cursorPosition !== undefined && cursorPosition >= 0) { + let displayQuery = query; + + // Truncate if needed + if (maxQueryLength && query.length > maxQueryLength) { + const start = Math.max(0, cursorPosition - Math.floor(maxQueryLength / 2)); + const end = Math.min(query.length, start + maxQueryLength); + displayQuery = (start > 0 ? '...' : '') + + query.substring(start, end) + + (end < query.length ? '...' : ''); + // Adjust cursor position for truncation + const adjustedPosition = cursorPosition - start + (start > 0 ? 3 : 0); + lines.push(displayQuery); + lines.push(' '.repeat(adjustedPosition) + `${yellow}^${reset}`); + } else { + lines.push(displayQuery); + lines.push(' '.repeat(cursorPosition) + `${yellow}^${reset}`); + } + } + } else if (showQuery) { + // No SQL details, just show the query if requested + let displayQuery = query; + if (maxQueryLength && query.length > maxQueryLength) { + displayQuery = query.substring(0, maxQueryLength) + '...'; + } + lines.push(`Query: ${displayQuery}`); + } + + return lines.join('\n'); +} + +// Check if an error has SQL details +export function hasSqlDetails(error: any): error is Error & { sqlDetails: SqlErrorDetails } { + return error instanceof Error && + 'sqlDetails' in error && + typeof (error as any).sqlDetails === 'object' && + (error as any).sqlDetails !== null && + 'message' in (error as any).sqlDetails && + 'cursorPosition' in (error as any).sqlDetails; +} + +const initPromise = PgQueryModule().then((module: any) => { + wasmModule = module; +}); + +function ensureLoaded() { + if (!wasmModule) throw new Error("WASM module not initialized. Call `loadModule()` first."); +} + +export async function loadModule() { + if (!wasmModule) { + await initPromise; + } +} + +function awaitInit any>(fn: T): T { + return (async (...args: Parameters) => { + await initPromise; + return fn(...args); + }) as T; +} + +function stringToPtr(str: string): number { + ensureLoaded(); + if (typeof str !== 'string') { + throw new TypeError(`Expected a string, got ${typeof str}`); + } + const len = wasmModule.lengthBytesUTF8(str) + 1; + const ptr = wasmModule._malloc(len); + try { + wasmModule.stringToUTF8(str, ptr, len); + return ptr; + } catch (error) { + wasmModule._free(ptr); + throw error; + } +} + +function ptrToString(ptr: number): string { + ensureLoaded(); + if (typeof ptr !== 'number') { + throw new TypeError(`Expected a number, got ${typeof ptr}`); + } + return wasmModule.UTF8ToString(ptr); +} + +export const parse = awaitInit(async (query: string) => { + // Pre-validation + if (query === null || query === undefined) { + throw new Error('Query cannot be null or undefined'); + } + if (typeof query !== 'string') { + throw new Error(`Query must be a string, got ${typeof query}`); + } + if (query.trim() === '') { + throw new Error('Query cannot be empty'); + } + + const queryPtr = stringToPtr(query); + let resultPtr = 0; + + try { + // Call the raw function that returns a struct pointer + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new Error('Failed to allocate memory for parse result'); + } + + // Read the PgQueryParseResult struct fields + // struct { char* parse_tree; char* stderr_buffer; PgQueryError* error; } + const parseTreePtr = wasmModule.getValue(resultPtr, 'i32'); // offset 0 + const stderrBufferPtr = wasmModule.getValue(resultPtr + 4, 'i32'); // offset 4 + const errorPtr = wasmModule.getValue(resultPtr + 8, 'i32'); // offset 8 + + // Check for error + if (errorPtr) { + // Read PgQueryError struct fields + // struct { char* message; char* funcname; char* filename; int lineno; int cursorpos; char* context; } + const messagePtr = wasmModule.getValue(errorPtr, 'i32'); // offset 0 + const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); // offset 4 + const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); // offset 8 + const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); // offset 12 + const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); // offset 16 + const contextPtr = wasmModule.getValue(errorPtr + 20, 'i32'); // offset 20 + + const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error'; + const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : null; + + const errorDetails: SqlErrorDetails = { + message: message, + cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based + fileName: filename || undefined, + functionName: funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined, + lineNumber: lineno > 0 ? lineno : undefined, + context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined + }; + + throw createSqlError(message, errorDetails); + } + + if (!parseTreePtr) { + throw new Error('Parse result is null'); + } + + const parseTree = wasmModule.UTF8ToString(parseTreePtr); + return JSON.parse(parseTree); + } + finally { + wasmModule._free(queryPtr); + if (resultPtr) { + wasmModule._wasm_free_parse_result(resultPtr); + } + } +}); + +export function parseSync(query: string) { + // Pre-validation + if (query === null || query === undefined) { + throw new Error('Query cannot be null or undefined'); + } + if (typeof query !== 'string') { + throw new Error(`Query must be a string, got ${typeof query}`); + } + if (query.trim() === '') { + throw new Error('Query cannot be empty'); + } + + const queryPtr = stringToPtr(query); + let resultPtr = 0; + + try { + // Call the raw function that returns a struct pointer + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new Error('Failed to allocate memory for parse result'); + } + + // Read the PgQueryParseResult struct fields + // struct { char* parse_tree; char* stderr_buffer; PgQueryError* error; } + const parseTreePtr = wasmModule.getValue(resultPtr, 'i32'); // offset 0 + const stderrBufferPtr = wasmModule.getValue(resultPtr + 4, 'i32'); // offset 4 + const errorPtr = wasmModule.getValue(resultPtr + 8, 'i32'); // offset 8 + + // Check for error + if (errorPtr) { + // Read PgQueryError struct fields + // struct { char* message; char* funcname; char* filename; int lineno; int cursorpos; char* context; } + const messagePtr = wasmModule.getValue(errorPtr, 'i32'); // offset 0 + const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); // offset 4 + const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); // offset 8 + const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); // offset 12 + const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); // offset 16 + const contextPtr = wasmModule.getValue(errorPtr + 20, 'i32'); // offset 20 + + const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error'; + const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : null; + + const errorDetails: SqlErrorDetails = { + message: message, + cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based + fileName: filename || undefined, + functionName: funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined, + lineNumber: lineno > 0 ? lineno : undefined, + context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined + }; + + throw createSqlError(message, errorDetails); + } + + if (!parseTreePtr) { + throw new Error('Parse result is null'); + } + + const parseTree = wasmModule.UTF8ToString(parseTreePtr); + return JSON.parse(parseTree); + } + finally { + wasmModule._free(queryPtr); + if (resultPtr) { + wasmModule._wasm_free_parse_result(resultPtr); + } + } +} \ No newline at end of file diff --git a/templates/src/libpg-query.d.ts b/templates/src/libpg-query.d.ts new file mode 100644 index 0000000..396fef8 --- /dev/null +++ b/templates/src/libpg-query.d.ts @@ -0,0 +1,15 @@ +declare module './libpg-query.js' { + interface WasmModule { + _malloc: (size: number) => number; + _free: (ptr: number) => void; + _wasm_free_string: (ptr: number) => void; + _wasm_parse_query: (queryPtr: number) => number; + lengthBytesUTF8: (str: string) => number; + stringToUTF8: (str: string, ptr: number, len: number) => void; + UTF8ToString: (ptr: number) => string; + HEAPU8: Uint8Array; + } + + const PgQueryModule: () => Promise; + export default PgQueryModule; +} \ No newline at end of file diff --git a/templates/src/wasm_wrapper.c b/templates/src/wasm_wrapper.c new file mode 100644 index 0000000..b9d958f --- /dev/null +++ b/templates/src/wasm_wrapper.c @@ -0,0 +1,73 @@ +#include +#include +#include +#include + +static int validate_input(const char* input) { + return input != NULL && strlen(input) > 0; +} + +static char* safe_strdup(const char* str) { + if (!str) return NULL; + char* result = strdup(str); + if (!result) { + return NULL; + } + return result; +} + +static void* safe_malloc(size_t size) { + void* ptr = malloc(size); + if (!ptr && size > 0) { + return NULL; + } + return ptr; +} + +EMSCRIPTEN_KEEPALIVE +char* wasm_parse_query(const char* input) { + if (!validate_input(input)) { + return safe_strdup("Invalid input: query cannot be null or empty"); + } + + PgQueryParseResult result = pg_query_parse(input); + + if (result.error) { + char* error_msg = safe_strdup(result.error->message); + pg_query_free_parse_result(result); + return error_msg ? error_msg : safe_strdup("Memory allocation failed"); + } + + char* parse_tree = safe_strdup(result.parse_tree); + pg_query_free_parse_result(result); + return parse_tree; +} + +EMSCRIPTEN_KEEPALIVE +void wasm_free_string(char* str) { + free(str); +} + +// Raw struct access functions for parse +EMSCRIPTEN_KEEPALIVE +PgQueryParseResult* wasm_parse_query_raw(const char* input) { + if (!input) { + return NULL; + } + + PgQueryParseResult* result = (PgQueryParseResult*)safe_malloc(sizeof(PgQueryParseResult)); + if (!result) { + return NULL; + } + + *result = pg_query_parse(input); + return result; +} + +EMSCRIPTEN_KEEPALIVE +void wasm_free_parse_result(PgQueryParseResult* result) { + if (result) { + pg_query_free_parse_result(*result); + free(result); + } +} \ No newline at end of file diff --git a/versions/13/Makefile b/versions/13/Makefile index e4b51ac..43b28f3 100644 --- a/versions/13/Makefile +++ b/versions/13/Makefile @@ -1,3 +1,8 @@ +# DO NOT MODIFY MANUALLY — this is generated from the templates dir +# +# To make changes, edit the files in the templates/ directory and run: +# npm run copy:templates + WASM_OUT_DIR := wasm WASM_OUT_NAME := libpg-query WASM_MODULE_NAME := PgQueryModule @@ -40,9 +45,11 @@ endif $(LIBPG_QUERY_DIR): mkdir -p $(CACHE_DIR) git clone -b $(LIBPG_QUERY_TAG) --single-branch $(LIBPG_QUERY_REPO) $(LIBPG_QUERY_DIR) + ifdef EMSCRIPTEN cd $(LIBPG_QUERY_DIR); patch -p1 < $(shell pwd)/patches/emscripten_disable_spinlocks.patch endif + $(LIBPG_QUERY_HEADER): $(LIBPG_QUERY_DIR) @@ -89,4 +96,4 @@ clean: clean-cache: -@ rm -rf $(LIBPG_QUERY_DIR) -.PHONY: build build-cache rebuild rebuild-cache clean clean-cache +.PHONY: build build-cache rebuild rebuild-cache clean clean-cache \ No newline at end of file diff --git a/versions/13/src/index.ts b/versions/13/src/index.ts index 60c6d86..9d7aeb9 100644 --- a/versions/13/src/index.ts +++ b/versions/13/src/index.ts @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + export * from "@pgsql/types"; // @ts-ignore diff --git a/versions/13/src/libpg-query.d.ts b/versions/13/src/libpg-query.d.ts index 396fef8..2098ee1 100644 --- a/versions/13/src/libpg-query.d.ts +++ b/versions/13/src/libpg-query.d.ts @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + declare module './libpg-query.js' { interface WasmModule { _malloc: (size: number) => number; diff --git a/versions/13/src/wasm_wrapper.c b/versions/13/src/wasm_wrapper.c index b9d958f..b928311 100644 --- a/versions/13/src/wasm_wrapper.c +++ b/versions/13/src/wasm_wrapper.c @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + #include #include #include diff --git a/versions/14/Makefile b/versions/14/Makefile index 9ec6bcb..556b3bf 100644 --- a/versions/14/Makefile +++ b/versions/14/Makefile @@ -1,3 +1,8 @@ +# DO NOT MODIFY MANUALLY — this is generated from the templates dir +# +# To make changes, edit the files in the templates/ directory and run: +# npm run copy:templates + WASM_OUT_DIR := wasm WASM_OUT_NAME := libpg-query WASM_MODULE_NAME := PgQueryModule @@ -40,6 +45,7 @@ endif $(LIBPG_QUERY_DIR): mkdir -p $(CACHE_DIR) git clone -b $(LIBPG_QUERY_TAG) --single-branch $(LIBPG_QUERY_REPO) $(LIBPG_QUERY_DIR) + $(LIBPG_QUERY_HEADER): $(LIBPG_QUERY_DIR) @@ -86,4 +92,4 @@ clean: clean-cache: -@ rm -rf $(LIBPG_QUERY_DIR) -.PHONY: build build-cache rebuild rebuild-cache clean clean-cache +.PHONY: build build-cache rebuild rebuild-cache clean clean-cache \ No newline at end of file diff --git a/versions/14/src/index.ts b/versions/14/src/index.ts index 60c6d86..9d7aeb9 100644 --- a/versions/14/src/index.ts +++ b/versions/14/src/index.ts @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + export * from "@pgsql/types"; // @ts-ignore diff --git a/versions/14/src/libpg-query.d.ts b/versions/14/src/libpg-query.d.ts index 396fef8..2098ee1 100644 --- a/versions/14/src/libpg-query.d.ts +++ b/versions/14/src/libpg-query.d.ts @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + declare module './libpg-query.js' { interface WasmModule { _malloc: (size: number) => number; diff --git a/versions/14/src/wasm_wrapper.c b/versions/14/src/wasm_wrapper.c index b9d958f..b928311 100644 --- a/versions/14/src/wasm_wrapper.c +++ b/versions/14/src/wasm_wrapper.c @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + #include #include #include diff --git a/versions/15/Makefile b/versions/15/Makefile index 41753a1..6036a65 100644 --- a/versions/15/Makefile +++ b/versions/15/Makefile @@ -1,3 +1,8 @@ +# DO NOT MODIFY MANUALLY — this is generated from the templates dir +# +# To make changes, edit the files in the templates/ directory and run: +# npm run copy:templates + WASM_OUT_DIR := wasm WASM_OUT_NAME := libpg-query WASM_MODULE_NAME := PgQueryModule @@ -40,6 +45,7 @@ endif $(LIBPG_QUERY_DIR): mkdir -p $(CACHE_DIR) git clone -b $(LIBPG_QUERY_TAG) --single-branch $(LIBPG_QUERY_REPO) $(LIBPG_QUERY_DIR) + $(LIBPG_QUERY_HEADER): $(LIBPG_QUERY_DIR) @@ -86,4 +92,4 @@ clean: clean-cache: -@ rm -rf $(LIBPG_QUERY_DIR) -.PHONY: build build-cache rebuild rebuild-cache clean clean-cache +.PHONY: build build-cache rebuild rebuild-cache clean clean-cache \ No newline at end of file diff --git a/versions/15/src/index.ts b/versions/15/src/index.ts index 60c6d86..9d7aeb9 100644 --- a/versions/15/src/index.ts +++ b/versions/15/src/index.ts @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + export * from "@pgsql/types"; // @ts-ignore diff --git a/versions/15/src/libpg-query.d.ts b/versions/15/src/libpg-query.d.ts index 396fef8..2098ee1 100644 --- a/versions/15/src/libpg-query.d.ts +++ b/versions/15/src/libpg-query.d.ts @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + declare module './libpg-query.js' { interface WasmModule { _malloc: (size: number) => number; diff --git a/versions/15/src/wasm_wrapper.c b/versions/15/src/wasm_wrapper.c index b9d958f..b928311 100644 --- a/versions/15/src/wasm_wrapper.c +++ b/versions/15/src/wasm_wrapper.c @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + #include #include #include diff --git a/versions/16/Makefile b/versions/16/Makefile index d0c2f5d..9c96418 100644 --- a/versions/16/Makefile +++ b/versions/16/Makefile @@ -1,3 +1,8 @@ +# DO NOT MODIFY MANUALLY — this is generated from the templates dir +# +# To make changes, edit the files in the templates/ directory and run: +# npm run copy:templates + WASM_OUT_DIR := wasm WASM_OUT_NAME := libpg-query WASM_MODULE_NAME := PgQueryModule @@ -40,6 +45,7 @@ endif $(LIBPG_QUERY_DIR): mkdir -p $(CACHE_DIR) git clone -b $(LIBPG_QUERY_TAG) --single-branch $(LIBPG_QUERY_REPO) $(LIBPG_QUERY_DIR) + $(LIBPG_QUERY_HEADER): $(LIBPG_QUERY_DIR) @@ -86,4 +92,4 @@ clean: clean-cache: -@ rm -rf $(LIBPG_QUERY_DIR) -.PHONY: build build-cache rebuild rebuild-cache clean clean-cache +.PHONY: build build-cache rebuild rebuild-cache clean clean-cache \ No newline at end of file diff --git a/versions/16/src/index.ts b/versions/16/src/index.ts index 60c6d86..9d7aeb9 100644 --- a/versions/16/src/index.ts +++ b/versions/16/src/index.ts @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + export * from "@pgsql/types"; // @ts-ignore diff --git a/versions/16/src/libpg-query.d.ts b/versions/16/src/libpg-query.d.ts index 396fef8..2098ee1 100644 --- a/versions/16/src/libpg-query.d.ts +++ b/versions/16/src/libpg-query.d.ts @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + declare module './libpg-query.js' { interface WasmModule { _malloc: (size: number) => number; diff --git a/versions/16/src/wasm_wrapper.c b/versions/16/src/wasm_wrapper.c index b9d958f..b928311 100644 --- a/versions/16/src/wasm_wrapper.c +++ b/versions/16/src/wasm_wrapper.c @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + #include #include #include diff --git a/versions/17/Makefile b/versions/17/Makefile index fd3be5d..e5221e8 100644 --- a/versions/17/Makefile +++ b/versions/17/Makefile @@ -1,3 +1,8 @@ +# DO NOT MODIFY MANUALLY — this is generated from the templates dir +# +# To make changes, edit the files in the templates/ directory and run: +# npm run copy:templates + WASM_OUT_DIR := wasm WASM_OUT_NAME := libpg-query WASM_MODULE_NAME := PgQueryModule @@ -40,6 +45,7 @@ endif $(LIBPG_QUERY_DIR): mkdir -p $(CACHE_DIR) git clone -b $(LIBPG_QUERY_TAG) --single-branch $(LIBPG_QUERY_REPO) $(LIBPG_QUERY_DIR) + $(LIBPG_QUERY_HEADER): $(LIBPG_QUERY_DIR) @@ -86,4 +92,4 @@ clean: clean-cache: -@ rm -rf $(LIBPG_QUERY_DIR) -.PHONY: build build-cache rebuild rebuild-cache clean clean-cache +.PHONY: build build-cache rebuild rebuild-cache clean clean-cache \ No newline at end of file diff --git a/versions/17/src/index.ts b/versions/17/src/index.ts index 60c6d86..9d7aeb9 100644 --- a/versions/17/src/index.ts +++ b/versions/17/src/index.ts @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + export * from "@pgsql/types"; // @ts-ignore diff --git a/versions/17/src/libpg-query.d.ts b/versions/17/src/libpg-query.d.ts index 396fef8..2098ee1 100644 --- a/versions/17/src/libpg-query.d.ts +++ b/versions/17/src/libpg-query.d.ts @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + declare module './libpg-query.js' { interface WasmModule { _malloc: (size: number) => number; diff --git a/versions/17/src/wasm_wrapper.c b/versions/17/src/wasm_wrapper.c index b9d958f..b928311 100644 --- a/versions/17/src/wasm_wrapper.c +++ b/versions/17/src/wasm_wrapper.c @@ -1,3 +1,10 @@ +/** + * DO NOT MODIFY MANUALLY — this is generated from the templates dir + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + #include #include #include From 5b641c1c5bdb9e146b82e2f77e837c00522b1da8 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 26 Jun 2025 15:17:29 -0700 Subject: [PATCH 06/14] updates --- parser/scripts/prepare.js | 1 + parser/templates/index.cjs.template | 20 +--------------- parser/templates/index.d.ts.template | 3 +-- parser/templates/index.js.template | 20 +--------------- templates/Makefile | 2 +- templates/src/wasm_wrapper.c | 35 +--------------------------- versions/13/Makefile | 2 +- versions/13/src/wasm_wrapper.c | 35 +--------------------------- versions/14/Makefile | 2 +- versions/14/src/wasm_wrapper.c | 35 +--------------------------- versions/15/Makefile | 2 +- versions/15/src/wasm_wrapper.c | 35 +--------------------------- versions/16/Makefile | 2 +- versions/16/src/wasm_wrapper.c | 35 +--------------------------- versions/17/Makefile | 2 +- versions/17/src/wasm_wrapper.c | 35 +--------------------------- 16 files changed, 16 insertions(+), 250 deletions(-) diff --git a/parser/scripts/prepare.js b/parser/scripts/prepare.js index 5d037b2..8eeb8c6 100644 --- a/parser/scripts/prepare.js +++ b/parser/scripts/prepare.js @@ -1,3 +1,4 @@ +// run "pnpm build:parser:full" in root const fs = require('fs'); const path = require('path'); diff --git a/parser/templates/index.cjs.template b/parser/templates/index.cjs.template index 7f595cb..25d2df2 100644 --- a/parser/templates/index.cjs.template +++ b/parser/templates/index.cjs.template @@ -32,10 +32,7 @@ class Parser { parseSync(query) { if (!this.parser) { - throw new Error('Parser not loaded. Call parse() first or use parseSync after loading.'); - } - if (!this.parser.parseSync) { - throw new Error(`parseSync not supported in PostgreSQL ${this.version}`); + throw new Error('Parser not loaded. Call loadParser() first or use parseSync after loading.'); } try { return this.parser.parseSync(query); @@ -44,21 +41,6 @@ class Parser { } } - async fingerprint(query) { - await this.loadParser(); - if (this.parser.fingerprint) { - return this.parser.fingerprint(query); - } - throw new Error(`Fingerprint not supported in PostgreSQL ${this.version}`); - } - - async normalize(query) { - await this.loadParser(); - if (this.parser.normalize) { - return this.parser.normalize(query); - } - throw new Error(`Normalize not supported in PostgreSQL ${this.version}`); - } } // Export versions diff --git a/parser/templates/index.d.ts.template b/parser/templates/index.d.ts.template index 39ffd96..c129539 100644 --- a/parser/templates/index.d.ts.template +++ b/parser/templates/index.d.ts.template @@ -15,10 +15,9 @@ export interface ParseResult { export declare class Parser { constructor(version?: ${VERSION_UNION}); + loadParser(): Promise; parse(query: string): Promise; parseSync(query: string): ParseResult; - fingerprint(query: string): Promise; - normalize(query: string): Promise; } export default Parser; diff --git a/parser/templates/index.js.template b/parser/templates/index.js.template index e7ff6fd..cd2700b 100644 --- a/parser/templates/index.js.template +++ b/parser/templates/index.js.template @@ -32,10 +32,7 @@ export class Parser { parseSync(query) { if (!this.parser) { - throw new Error('Parser not loaded. Call parse() first or use parseSync after loading.'); - } - if (!this.parser.parseSync) { - throw new Error(`parseSync not supported in PostgreSQL ${this.version}`); + throw new Error('Parser not loaded. Call loadParser() first or use parseSync after loading.'); } try { return this.parser.parseSync(query); @@ -44,21 +41,6 @@ export class Parser { } } - async fingerprint(query) { - await this.loadParser(); - if (this.parser.fingerprint) { - return this.parser.fingerprint(query); - } - throw new Error(`Fingerprint not supported in PostgreSQL ${this.version}`); - } - - async normalize(query) { - await this.loadParser(); - if (this.parser.normalize) { - return this.parser.normalize(query); - } - throw new Error(`Normalize not supported in PostgreSQL ${this.version}`); - } } // Re-export all versions for direct access diff --git a/templates/Makefile b/templates/Makefile index da98e72..200daf1 100644 --- a/templates/Makefile +++ b/templates/Makefile @@ -62,7 +62,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR) \ -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ - -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ + -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query_raw','_wasm_free_parse_result']" \ -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ diff --git a/templates/src/wasm_wrapper.c b/templates/src/wasm_wrapper.c index b9d958f..59c5a97 100644 --- a/templates/src/wasm_wrapper.c +++ b/templates/src/wasm_wrapper.c @@ -7,15 +7,6 @@ static int validate_input(const char* input) { return input != NULL && strlen(input) > 0; } -static char* safe_strdup(const char* str) { - if (!str) return NULL; - char* result = strdup(str); - if (!result) { - return NULL; - } - return result; -} - static void* safe_malloc(size_t size) { void* ptr = malloc(size); if (!ptr && size > 0) { @@ -24,34 +15,10 @@ static void* safe_malloc(size_t size) { return ptr; } -EMSCRIPTEN_KEEPALIVE -char* wasm_parse_query(const char* input) { - if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); - } - - PgQueryParseResult result = pg_query_parse(input); - - if (result.error) { - char* error_msg = safe_strdup(result.error->message); - pg_query_free_parse_result(result); - return error_msg ? error_msg : safe_strdup("Memory allocation failed"); - } - - char* parse_tree = safe_strdup(result.parse_tree); - pg_query_free_parse_result(result); - return parse_tree; -} - -EMSCRIPTEN_KEEPALIVE -void wasm_free_string(char* str) { - free(str); -} - // Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE PgQueryParseResult* wasm_parse_query_raw(const char* input) { - if (!input) { + if (!validate_input(input)) { return NULL; } diff --git a/versions/13/Makefile b/versions/13/Makefile index 43b28f3..363ae65 100644 --- a/versions/13/Makefile +++ b/versions/13/Makefile @@ -67,7 +67,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR) \ -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ - -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ + -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query_raw','_wasm_free_parse_result']" \ -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ diff --git a/versions/13/src/wasm_wrapper.c b/versions/13/src/wasm_wrapper.c index b928311..3815168 100644 --- a/versions/13/src/wasm_wrapper.c +++ b/versions/13/src/wasm_wrapper.c @@ -14,15 +14,6 @@ static int validate_input(const char* input) { return input != NULL && strlen(input) > 0; } -static char* safe_strdup(const char* str) { - if (!str) return NULL; - char* result = strdup(str); - if (!result) { - return NULL; - } - return result; -} - static void* safe_malloc(size_t size) { void* ptr = malloc(size); if (!ptr && size > 0) { @@ -31,34 +22,10 @@ static void* safe_malloc(size_t size) { return ptr; } -EMSCRIPTEN_KEEPALIVE -char* wasm_parse_query(const char* input) { - if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); - } - - PgQueryParseResult result = pg_query_parse(input); - - if (result.error) { - char* error_msg = safe_strdup(result.error->message); - pg_query_free_parse_result(result); - return error_msg ? error_msg : safe_strdup("Memory allocation failed"); - } - - char* parse_tree = safe_strdup(result.parse_tree); - pg_query_free_parse_result(result); - return parse_tree; -} - -EMSCRIPTEN_KEEPALIVE -void wasm_free_string(char* str) { - free(str); -} - // Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE PgQueryParseResult* wasm_parse_query_raw(const char* input) { - if (!input) { + if (!validate_input(input)) { return NULL; } diff --git a/versions/14/Makefile b/versions/14/Makefile index 556b3bf..67989b6 100644 --- a/versions/14/Makefile +++ b/versions/14/Makefile @@ -63,7 +63,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR) \ -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ - -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ + -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query_raw','_wasm_free_parse_result']" \ -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ diff --git a/versions/14/src/wasm_wrapper.c b/versions/14/src/wasm_wrapper.c index b928311..3815168 100644 --- a/versions/14/src/wasm_wrapper.c +++ b/versions/14/src/wasm_wrapper.c @@ -14,15 +14,6 @@ static int validate_input(const char* input) { return input != NULL && strlen(input) > 0; } -static char* safe_strdup(const char* str) { - if (!str) return NULL; - char* result = strdup(str); - if (!result) { - return NULL; - } - return result; -} - static void* safe_malloc(size_t size) { void* ptr = malloc(size); if (!ptr && size > 0) { @@ -31,34 +22,10 @@ static void* safe_malloc(size_t size) { return ptr; } -EMSCRIPTEN_KEEPALIVE -char* wasm_parse_query(const char* input) { - if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); - } - - PgQueryParseResult result = pg_query_parse(input); - - if (result.error) { - char* error_msg = safe_strdup(result.error->message); - pg_query_free_parse_result(result); - return error_msg ? error_msg : safe_strdup("Memory allocation failed"); - } - - char* parse_tree = safe_strdup(result.parse_tree); - pg_query_free_parse_result(result); - return parse_tree; -} - -EMSCRIPTEN_KEEPALIVE -void wasm_free_string(char* str) { - free(str); -} - // Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE PgQueryParseResult* wasm_parse_query_raw(const char* input) { - if (!input) { + if (!validate_input(input)) { return NULL; } diff --git a/versions/15/Makefile b/versions/15/Makefile index 6036a65..aeb1904 100644 --- a/versions/15/Makefile +++ b/versions/15/Makefile @@ -63,7 +63,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR) \ -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ - -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ + -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query_raw','_wasm_free_parse_result']" \ -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ diff --git a/versions/15/src/wasm_wrapper.c b/versions/15/src/wasm_wrapper.c index b928311..3815168 100644 --- a/versions/15/src/wasm_wrapper.c +++ b/versions/15/src/wasm_wrapper.c @@ -14,15 +14,6 @@ static int validate_input(const char* input) { return input != NULL && strlen(input) > 0; } -static char* safe_strdup(const char* str) { - if (!str) return NULL; - char* result = strdup(str); - if (!result) { - return NULL; - } - return result; -} - static void* safe_malloc(size_t size) { void* ptr = malloc(size); if (!ptr && size > 0) { @@ -31,34 +22,10 @@ static void* safe_malloc(size_t size) { return ptr; } -EMSCRIPTEN_KEEPALIVE -char* wasm_parse_query(const char* input) { - if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); - } - - PgQueryParseResult result = pg_query_parse(input); - - if (result.error) { - char* error_msg = safe_strdup(result.error->message); - pg_query_free_parse_result(result); - return error_msg ? error_msg : safe_strdup("Memory allocation failed"); - } - - char* parse_tree = safe_strdup(result.parse_tree); - pg_query_free_parse_result(result); - return parse_tree; -} - -EMSCRIPTEN_KEEPALIVE -void wasm_free_string(char* str) { - free(str); -} - // Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE PgQueryParseResult* wasm_parse_query_raw(const char* input) { - if (!input) { + if (!validate_input(input)) { return NULL; } diff --git a/versions/16/Makefile b/versions/16/Makefile index 9c96418..5542cd0 100644 --- a/versions/16/Makefile +++ b/versions/16/Makefile @@ -63,7 +63,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR) \ -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ - -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ + -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query_raw','_wasm_free_parse_result']" \ -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ diff --git a/versions/16/src/wasm_wrapper.c b/versions/16/src/wasm_wrapper.c index b928311..3815168 100644 --- a/versions/16/src/wasm_wrapper.c +++ b/versions/16/src/wasm_wrapper.c @@ -14,15 +14,6 @@ static int validate_input(const char* input) { return input != NULL && strlen(input) > 0; } -static char* safe_strdup(const char* str) { - if (!str) return NULL; - char* result = strdup(str); - if (!result) { - return NULL; - } - return result; -} - static void* safe_malloc(size_t size) { void* ptr = malloc(size); if (!ptr && size > 0) { @@ -31,34 +22,10 @@ static void* safe_malloc(size_t size) { return ptr; } -EMSCRIPTEN_KEEPALIVE -char* wasm_parse_query(const char* input) { - if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); - } - - PgQueryParseResult result = pg_query_parse(input); - - if (result.error) { - char* error_msg = safe_strdup(result.error->message); - pg_query_free_parse_result(result); - return error_msg ? error_msg : safe_strdup("Memory allocation failed"); - } - - char* parse_tree = safe_strdup(result.parse_tree); - pg_query_free_parse_result(result); - return parse_tree; -} - -EMSCRIPTEN_KEEPALIVE -void wasm_free_string(char* str) { - free(str); -} - // Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE PgQueryParseResult* wasm_parse_query_raw(const char* input) { - if (!input) { + if (!validate_input(input)) { return NULL; } diff --git a/versions/17/Makefile b/versions/17/Makefile index e5221e8..cc20c5f 100644 --- a/versions/17/Makefile +++ b/versions/17/Makefile @@ -63,7 +63,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR) \ -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ - -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ + -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query_raw','_wasm_free_parse_result']" \ -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ diff --git a/versions/17/src/wasm_wrapper.c b/versions/17/src/wasm_wrapper.c index b928311..3815168 100644 --- a/versions/17/src/wasm_wrapper.c +++ b/versions/17/src/wasm_wrapper.c @@ -14,15 +14,6 @@ static int validate_input(const char* input) { return input != NULL && strlen(input) > 0; } -static char* safe_strdup(const char* str) { - if (!str) return NULL; - char* result = strdup(str); - if (!result) { - return NULL; - } - return result; -} - static void* safe_malloc(size_t size) { void* ptr = malloc(size); if (!ptr && size > 0) { @@ -31,34 +22,10 @@ static void* safe_malloc(size_t size) { return ptr; } -EMSCRIPTEN_KEEPALIVE -char* wasm_parse_query(const char* input) { - if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); - } - - PgQueryParseResult result = pg_query_parse(input); - - if (result.error) { - char* error_msg = safe_strdup(result.error->message); - pg_query_free_parse_result(result); - return error_msg ? error_msg : safe_strdup("Memory allocation failed"); - } - - char* parse_tree = safe_strdup(result.parse_tree); - pg_query_free_parse_result(result); - return parse_tree; -} - -EMSCRIPTEN_KEEPALIVE -void wasm_free_string(char* str) { - free(str); -} - // Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE PgQueryParseResult* wasm_parse_query_raw(const char* input) { - if (!input) { + if (!validate_input(input)) { return NULL; } From 1be82a6f95dfa84665be0f96e98185b9bd42a547 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 26 Jun 2025 17:07:40 -0700 Subject: [PATCH 07/14] more errors --- full/package.json | 2 +- full/src/index.ts | 104 ++++-- full/test/errors.test.js | 325 ++++++++++++++++++ parser/package.json | 2 +- parser/test/errors.test.js | 88 +++++ .../test/{parser.test.js => parsing.test.js} | 0 versions/13/package.json | 2 +- versions/14/package.json | 2 +- versions/15/package.json | 2 +- versions/16/package.json | 2 +- versions/17/package.json | 2 +- 11 files changed, 488 insertions(+), 43 deletions(-) create mode 100644 full/test/errors.test.js create mode 100644 parser/test/errors.test.js rename parser/test/{parser.test.js => parsing.test.js} (100%) diff --git a/full/package.json b/full/package.json index 213ee30..b0023fb 100644 --- a/full/package.json +++ b/full/package.json @@ -22,7 +22,7 @@ "wasm:rebuild": "pnpm wasm:make rebuild", "wasm:clean": "pnpm wasm:make clean", "wasm:clean-cache": "pnpm wasm:make clean-cache", - "test": "node --test test/parsing.test.js test/deparsing.test.js test/fingerprint.test.js test/normalize.test.js test/plpgsql.test.js test/scan.test.js", + "test": "node --test test/parsing.test.js test/deparsing.test.js test/fingerprint.test.js test/normalize.test.js test/plpgsql.test.js test/scan.test.js test/errors.test.js", "yamlize": "node ./scripts/yamlize.js", "protogen": "node ./scripts/protogen.js" }, diff --git a/full/src/index.ts b/full/src/index.ts index 9eb448a..aa353ef 100644 --- a/full/src/index.ts +++ b/full/src/index.ts @@ -39,46 +39,78 @@ export function hasSqlDetails(error: unknown): error is SqlError { return error instanceof SqlError && error.sqlDetails !== undefined; } -export function formatSqlError(error: SqlError, query?: string, options?: { - showPosition?: boolean; - showSource?: boolean; - useColors?: boolean; -}): string { - const opts = { showPosition: true, showSource: true, useColors: false, ...options }; - let output = `Error: ${error.message}`; - +export function formatSqlError( + error: SqlError, + query: string, + options: { + showPosition?: boolean; + showQuery?: boolean; + color?: boolean; + maxQueryLength?: number; + } = {} +): string { + const { + showPosition = true, + showQuery = true, + color = false, + maxQueryLength + } = options; + + const lines: string[] = []; + + // ANSI color codes + const red = color ? '\x1b[31m' : ''; + const yellow = color ? '\x1b[33m' : ''; + const reset = color ? '\x1b[0m' : ''; + + // Add error message + lines.push(`${red}Error: ${error.message}${reset}`); + + // Add SQL details if available if (error.sqlDetails) { - const details = error.sqlDetails; - - if (opts.showPosition && details.cursorPosition !== undefined) { - output += `\nPosition: ${details.cursorPosition}`; + const { cursorPosition, fileName, functionName, lineNumber } = error.sqlDetails; + + if (cursorPosition !== undefined && cursorPosition >= 0) { + lines.push(`Position: ${cursorPosition}`); } - - if (opts.showSource && (details.fileName || details.functionName || details.lineNumber)) { - output += '\nSource:'; - if (details.fileName) output += ` file: ${details.fileName},`; - if (details.functionName) output += ` function: ${details.functionName},`; - if (details.lineNumber) output += ` line: ${details.lineNumber}`; + + if (fileName || functionName || lineNumber) { + const details = []; + if (fileName) details.push(`file: ${fileName}`); + if (functionName) details.push(`function: ${functionName}`); + if (lineNumber) details.push(`line: ${lineNumber}`); + lines.push(`Source: ${details.join(', ')}`); } - - if (opts.showPosition && query && details.cursorPosition !== undefined && details.cursorPosition >= 0) { - const lines = query.split('\n'); - let currentPos = 0; - - for (let i = 0; i < lines.length; i++) { - const lineLength = lines[i].length + 1; // +1 for newline - if (currentPos + lineLength > details.cursorPosition) { - const posInLine = details.cursorPosition - currentPos; - output += `\n${lines[i]}`; - output += '\n' + ' '.repeat(posInLine) + '^'; - break; - } - currentPos += lineLength; + + // Show query with position marker + if (showQuery && showPosition && cursorPosition !== undefined && cursorPosition >= 0) { + let displayQuery = query; + let adjustedPosition = cursorPosition; + + // Truncate if needed + if (maxQueryLength && query.length > maxQueryLength) { + const start = Math.max(0, cursorPosition - Math.floor(maxQueryLength / 2)); + const end = Math.min(query.length, start + maxQueryLength); + displayQuery = (start > 0 ? '...' : '') + + query.substring(start, end) + + (end < query.length ? '...' : ''); + // Adjust cursor position for truncation + adjustedPosition = cursorPosition - start + (start > 0 ? 3 : 0); } + + lines.push(displayQuery); + lines.push(' '.repeat(adjustedPosition) + `${yellow}^${reset}`); + } + } else if (showQuery) { + // No SQL details, just show the query if requested + let displayQuery = query; + if (maxQueryLength && query.length > maxQueryLength) { + displayQuery = query.substring(0, maxQueryLength) + '...'; } + lines.push(`Query: ${displayQuery}`); } - - return output; + + return lines.join('\n'); } // @ts-ignore @@ -190,7 +222,7 @@ export const parse = awaitInit(async (query: string): Promise => { throw new SqlError(message, { message, - cursorPosition: cursorpos, + cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based fileName: filename, functionName: funcname, lineNumber: lineno > 0 ? lineno : undefined @@ -346,7 +378,7 @@ export function parseSync(query: string): ParseResult { throw new SqlError(message, { message, - cursorPosition: cursorpos, + cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based fileName: filename, functionName: funcname, lineNumber: lineno > 0 ? lineno : undefined diff --git a/full/test/errors.test.js b/full/test/errors.test.js new file mode 100644 index 0000000..f6c0fd6 --- /dev/null +++ b/full/test/errors.test.js @@ -0,0 +1,325 @@ +const { describe, it, before } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseSync, loadModule, formatSqlError, hasSqlDetails } = require('../wasm/index.cjs'); + +describe('Enhanced Error Handling', () => { + before(async () => { + await loadModule(); + }); + + describe('Error Details Structure', () => { + it('should include sqlDetails property on parse errors', () => { + assert.throws(() => { + parseSync('SELECT * FROM users WHERE id = @'); + }); + + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (error) { + assert.ok('sqlDetails' in error); + assert.ok('message' in error.sqlDetails); + assert.ok('cursorPosition' in error.sqlDetails); + assert.ok('fileName' in error.sqlDetails); + assert.ok('functionName' in error.sqlDetails); + assert.ok('lineNumber' in error.sqlDetails); + } + }); + + it('should have correct cursor position (0-based)', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 32); + } + }); + + it('should identify error source file', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.fileName, 'scan.l'); + assert.equal(error.sqlDetails.functionName, 'scanner_yyerror'); + } + }); + }); + + describe('Error Position Accuracy', () => { + const positionTests = [ + { query: '@ SELECT * FROM users', expectedPos: 0, desc: 'error at start' }, + { query: 'SELECT @ FROM users', expectedPos: 9, desc: 'error after SELECT' }, + { query: 'SELECT * FROM users WHERE @ = 1', expectedPos: 28, desc: 'error after WHERE' }, + { query: 'SELECT * FROM users WHERE id = @', expectedPos: 32, desc: 'error at end' }, + { query: 'INSERT INTO users (id, name) VALUES (1, @)', expectedPos: 41, desc: 'error in VALUES' }, + { query: 'UPDATE users SET name = @ WHERE id = 1', expectedPos: 26, desc: 'error in SET' }, + { query: 'CREATE TABLE test (id INT, name @)', expectedPos: 32, desc: 'error in CREATE TABLE' }, + ]; + + positionTests.forEach(({ query, expectedPos, desc }) => { + it(`should correctly identify position for ${desc}`, () => { + try { + parseSync(query); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, expectedPos); + } + }); + }); + }); + + describe('Error Types', () => { + it('should handle unterminated string literals', () => { + try { + parseSync("SELECT * FROM users WHERE name = 'unclosed"); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('unterminated quoted string')); + assert.equal(error.sqlDetails.cursorPosition, 33); + } + }); + + it('should handle unterminated quoted identifiers', () => { + try { + parseSync('SELECT * FROM users WHERE name = "unclosed'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('unterminated quoted identifier')); + assert.equal(error.sqlDetails.cursorPosition, 33); + } + }); + + it('should handle invalid tokens', () => { + try { + parseSync('SELECT * FROM users WHERE id = $'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at or near "$"')); + assert.equal(error.sqlDetails.cursorPosition, 31); + } + }); + + it('should handle reserved keywords', () => { + try { + parseSync('SELECT * FROM table'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at or near "table"')); + assert.equal(error.sqlDetails.cursorPosition, 14); + } + }); + + it('should handle syntax error in WHERE clause', () => { + try { + parseSync('SELECT * FROM users WHERE'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error.message.includes('syntax error at end of input')); + assert.equal(error.sqlDetails.cursorPosition, 25); + } + }); + }); + + describe('formatSqlError Helper', () => { + it('should format error with position indicator', () => { + try { + parseSync("SELECT * FROM users WHERE id = 'unclosed"); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, "SELECT * FROM users WHERE id = 'unclosed"); + assert.ok(formatted.includes('Error: unterminated quoted string')); + assert.ok(formatted.includes('Position: 31')); + assert.ok(formatted.includes("SELECT * FROM users WHERE id = 'unclosed")); + assert.ok(formatted.includes(' ^')); + } + }); + + it('should respect showPosition option', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + showPosition: false + }); + assert.ok(!formatted.includes('^')); + assert.ok(formatted.includes('Position: 32')); + } + }); + + it('should respect showQuery option', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + showQuery: false + }); + assert.ok(!formatted.includes('SELECT * FROM users')); + assert.ok(formatted.includes('Error:')); + assert.ok(formatted.includes('Position:')); + } + }); + + it('should truncate long queries', () => { + const longQuery = 'SELECT ' + 'a, '.repeat(50) + 'z FROM users WHERE id = @'; + try { + parseSync(longQuery); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, longQuery, { maxQueryLength: 50 }); + assert.ok(formatted.includes('...')); + const lines = formatted.split('\n'); + const queryLine = lines.find(line => line.includes('...')); + assert.ok(queryLine.length <= 56); // 50 + 2*3 for ellipsis + } + }); + + it('should handle color option without breaking output', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + const formatted = formatSqlError(error, 'SELECT * FROM users WHERE id = @', { + color: true + }); + assert.ok(formatted.includes('Error:')); + assert.ok(formatted.includes('Position:')); + // Should contain ANSI codes but still be readable + const cleanFormatted = formatted.replace(/\x1b\[[0-9;]*m/g, ''); + assert.ok(cleanFormatted.includes('syntax error')); + } + }); + }); + + describe('hasSqlDetails Type Guard', () => { + it('should return true for SQL parse errors', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(hasSqlDetails(error), true); + } + }); + + it('should return false for regular errors', () => { + const regularError = new Error('Regular error'); + assert.equal(hasSqlDetails(regularError), false); + }); + + it('should return false for non-Error objects', () => { + assert.equal(hasSqlDetails('string'), false); + assert.equal(hasSqlDetails(123), false); + assert.equal(hasSqlDetails(null), false); + assert.equal(hasSqlDetails(undefined), false); + assert.equal(hasSqlDetails({}), false); + }); + + it('should return false for Error with incomplete sqlDetails', () => { + const error = new Error('Test'); + error.sqlDetails = { message: 'test' }; // Missing cursorPosition + assert.equal(hasSqlDetails(error), false); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty query', () => { + assert.throws(() => parseSync(''), { + message: 'Query cannot be empty' + }); + }); + + it('should handle null query', () => { + assert.throws(() => parseSync(null), { + message: 'Query cannot be null or undefined' + }); + }); + + it('should handle undefined query', () => { + assert.throws(() => parseSync(undefined), { + message: 'Query cannot be null or undefined' + }); + }); + + it('should handle @ in comments', () => { + const query = 'SELECT * FROM users /* @ in comment */ WHERE id = 1'; + assert.doesNotThrow(() => parseSync(query)); + }); + + it('should handle @ in strings', () => { + const query = 'SELECT * FROM users WHERE email = \'user@example.com\''; + assert.doesNotThrow(() => parseSync(query)); + }); + }); + + describe('Complex Error Scenarios', () => { + it('should handle errors in CASE statements', () => { + try { + parseSync('SELECT CASE WHEN id = 1 THEN "one" WHEN id = 2 THEN @ ELSE "other" END FROM users'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 54); + } + }); + + it('should handle errors in subqueries', () => { + try { + parseSync('SELECT * FROM users WHERE id IN (SELECT @ FROM orders)'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 42); + } + }); + + it('should handle errors in function calls', () => { + try { + parseSync('SELECT COUNT(@) FROM users'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 14); + } + }); + + it('should handle errors in second statement', () => { + try { + parseSync('SELECT * FROM users; SELECT * FROM orders WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 54); + } + }); + + it('should handle errors in CTE', () => { + try { + parseSync('WITH cte AS (SELECT * FROM users WHERE id = @) SELECT * FROM cte'); + assert.fail('Expected error'); + } catch (error) { + assert.equal(error.sqlDetails.cursorPosition, 45); + } + }); + }); + + describe('Backward Compatibility', () => { + it('should maintain Error instance', () => { + try { + parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + assert.ok(error instanceof Error); + assert.ok(error.message); + assert.ok(error.stack); + } + }); + + it('should work with standard error handling', () => { + let caught = false; + try { + parseSync('SELECT * FROM users WHERE id = @'); + } catch (e) { + caught = true; + assert.ok(e.message.includes('syntax error')); + } + assert.equal(caught, true); + }); + }); +}); \ No newline at end of file diff --git a/parser/package.json b/parser/package.json index 0761be7..f619344 100644 --- a/parser/package.json +++ b/parser/package.json @@ -49,7 +49,7 @@ "build:lts": "npm run clean && cross-env PARSER_BUILD_TYPE=lts npm run prepare", "build:latest": "npm run clean && cross-env PARSER_BUILD_TYPE=latest npm run prepare", "build:legacy": "npm run clean && cross-env PARSER_BUILD_TYPE=legacy npm run prepare", - "test": "node --test test/parser.test.js" + "test": "node --test test/parsing.test.js test/errors.test.js" }, "keywords": [ "postgresql", diff --git a/parser/test/errors.test.js b/parser/test/errors.test.js new file mode 100644 index 0000000..c25e209 --- /dev/null +++ b/parser/test/errors.test.js @@ -0,0 +1,88 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { Parser } = require('../wasm/index.cjs'); + +describe('Parser Error Handling', () => { + describe('Error propagation across versions', () => { + const versions = [13, 14, 15, 16, 17]; + const invalidQuery = 'SELECT * FROM users WHERE id = @'; + + for (const version of versions) { + it(`should handle parse errors in PostgreSQL v${version}`, async () => { + const parser = new Parser(version); + + // Test async parse + await assert.rejects( + async () => await parser.parse(invalidQuery), + (error) => { + assert.ok(error instanceof Error); + assert.ok(error.message.includes('syntax error')); + // Check that sqlDetails are preserved + assert.ok('sqlDetails' in error); + assert.ok(error.sqlDetails.cursorPosition >= 0); + return true; + } + ); + + // Load parser for sync test + await parser.loadParser(); + + // Test sync parse + assert.throws( + () => parser.parseSync(invalidQuery), + (error) => { + assert.ok(error instanceof Error); + assert.ok(error.message.includes('syntax error')); + // Check that sqlDetails are preserved + assert.ok('sqlDetails' in error); + assert.ok(error.sqlDetails.cursorPosition >= 0); + return true; + } + ); + }); + } + }); + + describe('Error details preservation', () => { + it('should preserve error details from underlying parser', async () => { + const parser = new Parser(17); + await parser.loadParser(); + + try { + parser.parseSync('SELECT * FROM users WHERE id = @'); + assert.fail('Expected error'); + } catch (error) { + // Check that the error is preserved as-is + assert.ok(error.message.includes('syntax error')); + assert.ok('sqlDetails' in error); + assert.equal(error.sqlDetails.cursorPosition, 32); + assert.equal(error.sqlDetails.fileName, 'scan.l'); + assert.equal(error.sqlDetails.functionName, 'scanner_yyerror'); + } + }); + }); + + describe('Invalid version handling', () => { + it('should throw error for unsupported version', () => { + assert.throws( + () => new Parser(12), + { + message: 'Unsupported PostgreSQL version: 12. Supported versions are 13, 14, 15, 16, 17.' + } + ); + }); + }); + + describe('Parser not loaded error', () => { + it('should throw error when using parseSync without loading', () => { + const parser = new Parser(17); + + assert.throws( + () => parser.parseSync('SELECT 1'), + { + message: 'Parser not loaded. Call loadParser() first or use parseSync after loading.' + } + ); + }); + }); +}); \ No newline at end of file diff --git a/parser/test/parser.test.js b/parser/test/parsing.test.js similarity index 100% rename from parser/test/parser.test.js rename to parser/test/parsing.test.js diff --git a/versions/13/package.json b/versions/13/package.json index 678835e..93ce3f7 100644 --- a/versions/13/package.json +++ b/versions/13/package.json @@ -28,7 +28,7 @@ "wasm:rebuild": "pnpm wasm:make rebuild", "wasm:clean": "pnpm wasm:make clean", "wasm:clean-cache": "pnpm wasm:make clean-cache", - "test": "node --test test/parsing.test.js" + "test": "node --test test/parsing.test.js test/errors.test.js" }, "author": "Dan Lynch (http://github.com/pyramation)", "license": "LICENSE IN LICENSE", diff --git a/versions/14/package.json b/versions/14/package.json index 23c8f18..3747303 100644 --- a/versions/14/package.json +++ b/versions/14/package.json @@ -28,7 +28,7 @@ "wasm:rebuild": "pnpm wasm:make rebuild", "wasm:clean": "pnpm wasm:make clean", "wasm:clean-cache": "pnpm wasm:make clean-cache", - "test": "node --test test/parsing.test.js" + "test": "node --test test/parsing.test.js test/errors.test.js" }, "author": "Dan Lynch (http://github.com/pyramation)", "license": "LICENSE IN LICENSE", diff --git a/versions/15/package.json b/versions/15/package.json index 041949a..936ba89 100644 --- a/versions/15/package.json +++ b/versions/15/package.json @@ -28,7 +28,7 @@ "wasm:rebuild": "pnpm wasm:make rebuild", "wasm:clean": "pnpm wasm:make clean", "wasm:clean-cache": "pnpm wasm:make clean-cache", - "test": "node --test test/parsing.test.js" + "test": "node --test test/parsing.test.js test/errors.test.js" }, "author": "Dan Lynch (http://github.com/pyramation)", "license": "LICENSE IN LICENSE", diff --git a/versions/16/package.json b/versions/16/package.json index 0b1d956..2732d0b 100644 --- a/versions/16/package.json +++ b/versions/16/package.json @@ -28,7 +28,7 @@ "wasm:rebuild": "pnpm wasm:make rebuild", "wasm:clean": "pnpm wasm:make clean", "wasm:clean-cache": "pnpm wasm:make clean-cache", - "test": "node --test test/parsing.test.js" + "test": "node --test test/parsing.test.js test/errors.test.js" }, "author": "Dan Lynch (http://github.com/pyramation)", "license": "LICENSE IN LICENSE", diff --git a/versions/17/package.json b/versions/17/package.json index a26d43d..457dba7 100644 --- a/versions/17/package.json +++ b/versions/17/package.json @@ -28,7 +28,7 @@ "wasm:rebuild": "pnpm wasm:make rebuild", "wasm:clean": "pnpm wasm:make clean", "wasm:clean-cache": "pnpm wasm:make clean-cache", - "test": "node --test test/parsing.test.js" + "test": "node --test test/parsing.test.js test/errors.test.js" }, "author": "Dan Lynch (http://github.com/pyramation)", "license": "LICENSE IN LICENSE", From 972487fca2f7e8d1c26f2a480d4abe2bcb7aaff4 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 26 Jun 2025 19:21:23 -0700 Subject: [PATCH 08/14] errors --- REPO_NOTES.md | 24 ++++++++++++++++++++++++ full/src/index.ts | 16 ++++++++-------- parser/README.md | 16 +++++----------- parser/package.json | 2 -- parser/scripts/prepare.js | 8 ++------ versions/13/src/index.ts | 23 +++++++++++------------ versions/14/src/index.ts | 23 +++++++++++------------ versions/15/src/index.ts | 23 +++++++++++------------ versions/16/src/index.ts | 23 +++++++++++------------ versions/17/src/index.ts | 24 ++++++++++++------------ 10 files changed, 95 insertions(+), 87 deletions(-) create mode 100644 REPO_NOTES.md diff --git a/REPO_NOTES.md b/REPO_NOTES.md new file mode 100644 index 0000000..dd3fa3a --- /dev/null +++ b/REPO_NOTES.md @@ -0,0 +1,24 @@ +⚠️ Due to the managing of many versions, we do have some duplication, please beware! + +There is a templates/ dir to solve some of this. + +## Code Duplication 📋 + +### 1. Identical Test Files +- All `versions/*/test/errors.test.js` files are identical (324 lines each) +- All `versions/*/test/parsing.test.js` files are identical (89 lines each) +- **Recommendation**: Consider using the template approach mentioned by the user + +### 2. Nearly Identical Source Files +- `versions/*/src/index.ts` are nearly identical except for version numbers +- `versions/*/src/wasm_wrapper.c` are identical +- `versions/*/Makefile` differ only in: + - `LIBPG_QUERY_TAG` version + - Version 13 has an extra emscripten patch + +## Consistency Issues 🔧 + +### 1. Version 13 Makefile Difference +- Version 13 applies an extra patch: `emscripten_disable_spinlocks.patch` +- Other versions don't have this patch +- **Status**: Patch file exists and is likely needed for v13 compatibility \ No newline at end of file diff --git a/full/src/index.ts b/full/src/index.ts index aa353ef..101500d 100644 --- a/full/src/index.ts +++ b/full/src/index.ts @@ -187,11 +187,11 @@ function ptrToString(ptr: number): string { export const parse = awaitInit(async (query: string): Promise => { // Input validation if (query === null || query === undefined) { - throw new SqlError('Query cannot be null or undefined'); + throw new Error('Query cannot be null or undefined'); } if (query === '') { - throw new SqlError('Query cannot be empty'); + throw new Error('Query cannot be empty'); } const queryPtr = stringToPtr(query); @@ -200,7 +200,7 @@ export const parse = awaitInit(async (query: string): Promise => { try { resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); if (!resultPtr) { - throw new SqlError('Failed to parse query: memory allocation failed'); + throw new Error('Failed to parse query: memory allocation failed'); } // Read the PgQueryParseResult struct @@ -230,7 +230,7 @@ export const parse = awaitInit(async (query: string): Promise => { } if (!parseTreePtr) { - throw new SqlError('No parse tree generated'); + throw new Error('No parse tree generated'); } const parseTreeStr = wasmModule.UTF8ToString(parseTreePtr); @@ -343,11 +343,11 @@ export function parseSync(query: string): ParseResult { // Input validation if (query === null || query === undefined) { - throw new SqlError('Query cannot be null or undefined'); + throw new Error('Query cannot be null or undefined'); } if (query === '') { - throw new SqlError('Query cannot be empty'); + throw new Error('Query cannot be empty'); } const queryPtr = stringToPtr(query); @@ -356,7 +356,7 @@ export function parseSync(query: string): ParseResult { try { resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); if (!resultPtr) { - throw new SqlError('Failed to parse query: memory allocation failed'); + throw new Error('Failed to parse query: memory allocation failed'); } // Read the PgQueryParseResult struct @@ -386,7 +386,7 @@ export function parseSync(query: string): ParseResult { } if (!parseTreePtr) { - throw new SqlError('No parse tree generated'); + throw new Error('No parse tree generated'); } const parseTreeStr = wasmModule.UTF8ToString(parseTreePtr); diff --git a/parser/README.md b/parser/README.md index 3cac1cf..4940f63 100644 --- a/parser/README.md +++ b/parser/README.md @@ -21,11 +21,8 @@ Multi-version PostgreSQL parser with dynamic version selection. This package pro # Install latest (full build with all versions) npm install @pgsql/parser -# Install LTS version (PostgreSQL 16-17 only) +# Install LTS version (PostgreSQL 15-17 only) npm install @pgsql/parser@lts - -# Install legacy version (PostgreSQL 13-15 only) -npm install @pgsql/parser@legacy ``` ## Usage @@ -116,15 +113,12 @@ Each version export provides: This package supports different build configurations for different use cases: -- **full** (default): All versions (13, 14, 15, 16, 17) -- **lts**: LTS versions only (16, 17) -- **latest**: Latest version only (17) -- **legacy**: Legacy versions (13, 14, 15) +- **full** (default): All versions (13, 14, 15, 16, 17) - Provides maximum compatibility +- **lts**: LTS (Long Term Support) versions only (15, 16, 17) - Recommended for production use with stable PostgreSQL versions When installing from npm, you can choose the appropriate build using tags: -- `npm install @pgsql/parser` - Full build -- `npm install @pgsql/parser@lts` - LTS build -- `npm install @pgsql/parser@legacy` - Legacy build +- `npm install @pgsql/parser` - Full build with all versions +- `npm install @pgsql/parser@lts` - LTS build ## Credits diff --git a/parser/package.json b/parser/package.json index f619344..fdcffe7 100644 --- a/parser/package.json +++ b/parser/package.json @@ -47,8 +47,6 @@ "build": "npm run clean && npm run prepare", "build:full": "npm run clean && cross-env PARSER_BUILD_TYPE=full npm run prepare", "build:lts": "npm run clean && cross-env PARSER_BUILD_TYPE=lts npm run prepare", - "build:latest": "npm run clean && cross-env PARSER_BUILD_TYPE=latest npm run prepare", - "build:legacy": "npm run clean && cross-env PARSER_BUILD_TYPE=legacy npm run prepare", "test": "node --test test/parsing.test.js test/errors.test.js" }, "keywords": [ diff --git a/parser/scripts/prepare.js b/parser/scripts/prepare.js index 8eeb8c6..1bffbce 100644 --- a/parser/scripts/prepare.js +++ b/parser/scripts/prepare.js @@ -9,12 +9,8 @@ const BUILD_CONFIGS = { description: 'Full build with all PostgreSQL versions (13-17)' }, 'lts': { - versions: ['15', '16', '17'], // Current LTS versions - description: 'LTS build with PostgreSQL 16 and 17' - }, - 'legacy': { - versions: ['13', '14', '15'], - description: 'Legacy versions (13-15)' + versions: ['15', '16', '17'], + description: 'LTS (Long Term Support)' } }; diff --git a/versions/13/src/index.ts b/versions/13/src/index.ts index 9d7aeb9..c2007b7 100644 --- a/versions/13/src/index.ts +++ b/versions/13/src/index.ts @@ -30,18 +30,17 @@ export interface SqlErrorFormatOptions { maxQueryLength?: number; // Max query length to display (default: no limit) } -// Helper function to create enhanced error with SQL details -function createSqlError(message: string, details: SqlErrorDetails): Error { - const error = new Error(message); - // Attach error details as properties - Object.defineProperty(error, 'sqlDetails', { - value: details, - enumerable: true, - configurable: true - }); - return error; +export class SqlError extends Error { + sqlDetails?: SqlErrorDetails; + + constructor(message: string, details?: SqlErrorDetails) { + super(message); + this.name = 'SqlError'; + this.sqlDetails = details; + } } + // Helper function to classify error source function getErrorSource(filename: string | null): string { if (!filename) return 'unknown'; @@ -227,7 +226,7 @@ export const parse = awaitInit(async (query: string) => { context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined }; - throw createSqlError(message, errorDetails); + throw new SqlError(message, errorDetails); } if (!parseTreePtr) { @@ -296,7 +295,7 @@ export function parseSync(query: string) { context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined }; - throw createSqlError(message, errorDetails); + throw new SqlError(message, errorDetails); } if (!parseTreePtr) { diff --git a/versions/14/src/index.ts b/versions/14/src/index.ts index 9d7aeb9..c2007b7 100644 --- a/versions/14/src/index.ts +++ b/versions/14/src/index.ts @@ -30,18 +30,17 @@ export interface SqlErrorFormatOptions { maxQueryLength?: number; // Max query length to display (default: no limit) } -// Helper function to create enhanced error with SQL details -function createSqlError(message: string, details: SqlErrorDetails): Error { - const error = new Error(message); - // Attach error details as properties - Object.defineProperty(error, 'sqlDetails', { - value: details, - enumerable: true, - configurable: true - }); - return error; +export class SqlError extends Error { + sqlDetails?: SqlErrorDetails; + + constructor(message: string, details?: SqlErrorDetails) { + super(message); + this.name = 'SqlError'; + this.sqlDetails = details; + } } + // Helper function to classify error source function getErrorSource(filename: string | null): string { if (!filename) return 'unknown'; @@ -227,7 +226,7 @@ export const parse = awaitInit(async (query: string) => { context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined }; - throw createSqlError(message, errorDetails); + throw new SqlError(message, errorDetails); } if (!parseTreePtr) { @@ -296,7 +295,7 @@ export function parseSync(query: string) { context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined }; - throw createSqlError(message, errorDetails); + throw new SqlError(message, errorDetails); } if (!parseTreePtr) { diff --git a/versions/15/src/index.ts b/versions/15/src/index.ts index 9d7aeb9..c2007b7 100644 --- a/versions/15/src/index.ts +++ b/versions/15/src/index.ts @@ -30,18 +30,17 @@ export interface SqlErrorFormatOptions { maxQueryLength?: number; // Max query length to display (default: no limit) } -// Helper function to create enhanced error with SQL details -function createSqlError(message: string, details: SqlErrorDetails): Error { - const error = new Error(message); - // Attach error details as properties - Object.defineProperty(error, 'sqlDetails', { - value: details, - enumerable: true, - configurable: true - }); - return error; +export class SqlError extends Error { + sqlDetails?: SqlErrorDetails; + + constructor(message: string, details?: SqlErrorDetails) { + super(message); + this.name = 'SqlError'; + this.sqlDetails = details; + } } + // Helper function to classify error source function getErrorSource(filename: string | null): string { if (!filename) return 'unknown'; @@ -227,7 +226,7 @@ export const parse = awaitInit(async (query: string) => { context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined }; - throw createSqlError(message, errorDetails); + throw new SqlError(message, errorDetails); } if (!parseTreePtr) { @@ -296,7 +295,7 @@ export function parseSync(query: string) { context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined }; - throw createSqlError(message, errorDetails); + throw new SqlError(message, errorDetails); } if (!parseTreePtr) { diff --git a/versions/16/src/index.ts b/versions/16/src/index.ts index 9d7aeb9..c2007b7 100644 --- a/versions/16/src/index.ts +++ b/versions/16/src/index.ts @@ -30,18 +30,17 @@ export interface SqlErrorFormatOptions { maxQueryLength?: number; // Max query length to display (default: no limit) } -// Helper function to create enhanced error with SQL details -function createSqlError(message: string, details: SqlErrorDetails): Error { - const error = new Error(message); - // Attach error details as properties - Object.defineProperty(error, 'sqlDetails', { - value: details, - enumerable: true, - configurable: true - }); - return error; +export class SqlError extends Error { + sqlDetails?: SqlErrorDetails; + + constructor(message: string, details?: SqlErrorDetails) { + super(message); + this.name = 'SqlError'; + this.sqlDetails = details; + } } + // Helper function to classify error source function getErrorSource(filename: string | null): string { if (!filename) return 'unknown'; @@ -227,7 +226,7 @@ export const parse = awaitInit(async (query: string) => { context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined }; - throw createSqlError(message, errorDetails); + throw new SqlError(message, errorDetails); } if (!parseTreePtr) { @@ -296,7 +295,7 @@ export function parseSync(query: string) { context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined }; - throw createSqlError(message, errorDetails); + throw new SqlError(message, errorDetails); } if (!parseTreePtr) { diff --git a/versions/17/src/index.ts b/versions/17/src/index.ts index 9d7aeb9..27eeb49 100644 --- a/versions/17/src/index.ts +++ b/versions/17/src/index.ts @@ -30,18 +30,18 @@ export interface SqlErrorFormatOptions { maxQueryLength?: number; // Max query length to display (default: no limit) } -// Helper function to create enhanced error with SQL details -function createSqlError(message: string, details: SqlErrorDetails): Error { - const error = new Error(message); - // Attach error details as properties - Object.defineProperty(error, 'sqlDetails', { - value: details, - enumerable: true, - configurable: true - }); - return error; +export class SqlError extends Error { + sqlDetails?: SqlErrorDetails; + + constructor(message: string, details?: SqlErrorDetails) { + super(message); + this.name = 'SqlError'; + this.sqlDetails = details; + } } + + // Helper function to classify error source function getErrorSource(filename: string | null): string { if (!filename) return 'unknown'; @@ -227,7 +227,7 @@ export const parse = awaitInit(async (query: string) => { context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined }; - throw createSqlError(message, errorDetails); + throw new SqlError(message, errorDetails); } if (!parseTreePtr) { @@ -296,7 +296,7 @@ export function parseSync(query: string) { context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined }; - throw createSqlError(message, errorDetails); + throw new SqlError(message, errorDetails); } if (!parseTreePtr) { From d1514579820e042e81b6f32bc668384746f4232b Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 26 Jun 2025 19:35:18 -0700 Subject: [PATCH 09/14] cleanup --- scripts/copy-templates.js | 245 ++++++++-------------- templates/{Makefile => Makefile.template} | 2 +- templates/README.md | 29 ++- templates/{src => }/index.ts | 24 +-- templates/{src => }/libpg-query.d.ts | 0 templates/{src => }/wasm_wrapper.c | 0 versions/13/Makefile | 2 - versions/13/src/index.ts | 1 + versions/14/Makefile | 1 - versions/14/src/index.ts | 1 + versions/15/Makefile | 1 - versions/15/src/index.ts | 1 + versions/16/Makefile | 1 - versions/16/src/index.ts | 1 + versions/17/Makefile | 1 - 15 files changed, 129 insertions(+), 181 deletions(-) rename templates/{Makefile => Makefile.template} (98%) rename templates/{src => }/index.ts (95%) rename templates/{src => }/libpg-query.d.ts (100%) rename templates/{src => }/wasm_wrapper.c (100%) diff --git a/scripts/copy-templates.js b/scripts/copy-templates.js index 7b51ee5..efaeb8a 100755 --- a/scripts/copy-templates.js +++ b/scripts/copy-templates.js @@ -3,178 +3,117 @@ const fs = require('fs'); const path = require('path'); -// Version configurations -const VERSION_CONFIGS = { - '13': { - libpgQueryTag: '13-2.2.0', - useEmscriptenPatch: true - }, - '14': { - libpgQueryTag: '14-3.0.0', - useEmscriptenPatch: false - }, - '15': { - libpgQueryTag: '15-4.2.4', - useEmscriptenPatch: false - }, - '16': { - libpgQueryTag: '16-5.2.0', - useEmscriptenPatch: false - }, - '17': { - libpgQueryTag: '17-6.1.0', - useEmscriptenPatch: false - } -}; - -// Headers for different file types -const HEADERS = { - // JavaScript/TypeScript/C style comment - default: `/** +const HEADER = `/** * DO NOT MODIFY MANUALLY — this is generated from the templates dir * * To make changes, edit the files in the templates/ directory and run: * npm run copy:templates */ -`, - // Makefile style comment - makefile: `# DO NOT MODIFY MANUALLY — this is generated from the templates dir +`; + +const MAKEFILE_HEADER = `# DO NOT MODIFY MANUALLY — this is generated from the templates dir # # To make changes, edit the files in the templates/ directory and run: # npm run copy:templates -` -}; - -// File extensions that should get headers -const HEADER_EXTENSIONS = ['.ts', '.js', '.c']; -const MAKEFILE_NAMES = ['Makefile', 'makefile']; +`; -/** - * Process template content with simple mustache-like syntax - * @param {string} content - Template content - * @param {object} config - Configuration object - * @returns {string} Processed content - */ -function processTemplate(content, config) { - // Replace simple variables - content = content.replace(/\{\{LIBPG_QUERY_TAG\}\}/g, config.libpgQueryTag); - - // Handle conditional blocks - // {{#USE_EMSCRIPTEN_PATCH}}...{{/USE_EMSCRIPTEN_PATCH}} - const conditionalRegex = /\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g; - - content = content.replace(conditionalRegex, (match, flag, blockContent) => { - if (flag === 'USE_EMSCRIPTEN_PATCH' && config.useEmscriptenPatch) { - return blockContent; - } - return ''; - }); - - return content; -} - -/** - * Add header to file content if applicable - * @param {string} filePath - Path to the file - * @param {string} content - File content - * @returns {string} Content with header if applicable - */ -function addHeaderIfNeeded(filePath, content) { - const basename = path.basename(filePath); - const ext = path.extname(filePath); - - // Check if it's a Makefile - if (MAKEFILE_NAMES.includes(basename)) { - return HEADERS.makefile + content; - } - - // Check if it's a source file that needs a header - if (HEADER_EXTENSIONS.includes(ext)) { - return HEADERS.default + content; +// Version-specific configurations +const VERSION_CONFIGS = { + '13': { + tag: '13-2.2.0', + hasEmscriptenPatch: true + }, + '14': { + tag: '14-3.0.0', + hasEmscriptenPatch: false + }, + '15': { + tag: '15-4.2.4', + hasEmscriptenPatch: false + }, + '16': { + tag: '16-5.2.0', + hasEmscriptenPatch: false + }, + '17': { + tag: '17-6.1.0', + hasEmscriptenPatch: false } - - return content; -} +}; -/** - * Copy a file from template to destination with processing - * @param {string} templatePath - Source template path - * @param {string} destPath - Destination path - * @param {object} config - Version configuration - */ -function copyTemplate(templatePath, destPath, config) { - const content = fs.readFileSync(templatePath, 'utf8'); - const processedContent = processTemplate(content, config); - const finalContent = addHeaderIfNeeded(destPath, processedContent); - - // Ensure destination directory exists - const destDir = path.dirname(destPath); - if (!fs.existsSync(destDir)) { - fs.mkdirSync(destDir, { recursive: true }); - } - - fs.writeFileSync(destPath, finalContent); -} +// Files to copy from templates +const TEMPLATE_FILES = [ + { src: 'LICENSE', dest: 'LICENSE', header: false }, + { src: 'wasm_wrapper.c', dest: 'src/wasm_wrapper.c', header: HEADER }, + { src: 'libpg-query.d.ts', dest: 'src/libpg-query.d.ts', header: HEADER }, + { src: 'index.ts', dest: 'src/index.ts', header: HEADER } +]; -/** - * Copy all templates for a specific version - * @param {string} version - Version number - * @param {object} config - Version configuration - */ -function copyTemplatesForVersion(version, config) { +function copyTemplates() { const templatesDir = path.join(__dirname, '..', 'templates'); - const versionDir = path.join(__dirname, '..', 'versions', version); + const versionsDir = path.join(__dirname, '..', 'versions'); - // Check if version directory exists - if (!fs.existsSync(versionDir)) { - console.warn(`Warning: Directory ${versionDir} does not exist. Skipping...`); - return; - } - - // Files to copy - const filesToCopy = [ - 'LICENSE', - 'Makefile', - 'src/index.ts', - 'src/libpg-query.d.ts', - 'src/wasm_wrapper.c' - ]; - - filesToCopy.forEach(file => { - const templatePath = path.join(templatesDir, file); - const destPath = path.join(versionDir, file); + // Process each version + for (const [version, config] of Object.entries(VERSION_CONFIGS)) { + const versionDir = path.join(versionsDir, version); + console.log(`\nProcessing version ${version}...`); - if (!fs.existsSync(templatePath)) { - console.error(`Error: Template file ${templatePath} does not exist!`); - return; + // Copy template files + for (const file of TEMPLATE_FILES) { + const srcPath = path.join(templatesDir, file.src); + const destPath = path.join(versionDir, file.dest); + + // Ensure destination directory exists + const destDir = path.dirname(destPath); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + // Read template content + let content = fs.readFileSync(srcPath, 'utf8'); + + // Add header if specified + if (file.header) { + content = file.header + content; + } + + // Write to destination + fs.writeFileSync(destPath, content); + console.log(` ✓ Copied ${file.src} to ${file.dest}`); } - copyTemplate(templatePath, destPath, config); - }); - - console.log(`✓ Version ${version} completed`); -} - -/** - * Main function - */ -function main() { - console.log('Copying template files to version directories...\n'); - - // Process each version - Object.entries(VERSION_CONFIGS).forEach(([version, config]) => { - console.log(`Processing version ${version}...`); - copyTemplatesForVersion(version, config); - }); + // Process Makefile template + const makefileTemplate = fs.readFileSync(path.join(templatesDir, 'Makefile.template'), 'utf8'); + let makefileContent = makefileTemplate.replace(/{{VERSION_TAG}}/g, config.tag); + + // Handle the USE_EMSCRIPTEN_PATCH placeholder + if (config.hasEmscriptenPatch) { + // For version 13, keep the patch block (remove only the placeholders) + makefileContent = makefileContent.replace( + /{{#USE_EMSCRIPTEN_PATCH}}\n?/g, + '' + ); + makefileContent = makefileContent.replace( + /{{\/USE_EMSCRIPTEN_PATCH}}\n?/g, + '' + ); + } else { + // For other versions, remove the entire block including placeholders + makefileContent = makefileContent.replace( + /{{#USE_EMSCRIPTEN_PATCH}}[\s\S]*?{{\/USE_EMSCRIPTEN_PATCH}}\n?/g, + '' + ); + } + + // Write Makefile with header + fs.writeFileSync(path.join(versionDir, 'Makefile'), MAKEFILE_HEADER + makefileContent); + console.log(` ✓ Generated Makefile with tag ${config.tag}`); + } - console.log('\nAll versions processed successfully!'); -} - -// Run if called directly -if (require.main === module) { - main(); + console.log('\n✅ Template copying completed!'); } -module.exports = { processTemplate, copyTemplatesForVersion }; \ No newline at end of file +// Run the script +copyTemplates(); \ No newline at end of file diff --git a/templates/Makefile b/templates/Makefile.template similarity index 98% rename from templates/Makefile rename to templates/Makefile.template index 200daf1..65e6f56 100644 --- a/templates/Makefile +++ b/templates/Makefile.template @@ -2,7 +2,7 @@ WASM_OUT_DIR := wasm WASM_OUT_NAME := libpg-query WASM_MODULE_NAME := PgQueryModule LIBPG_QUERY_REPO := https://github.com/pganalyze/libpg_query.git -LIBPG_QUERY_TAG := {{LIBPG_QUERY_TAG}} +LIBPG_QUERY_TAG := {{VERSION_TAG}} CACHE_DIR := .cache diff --git a/templates/README.md b/templates/README.md index 4dacf85..9cb3db8 100644 --- a/templates/README.md +++ b/templates/README.md @@ -4,11 +4,11 @@ This directory contains template files that are shared across all PostgreSQL ver ## Files -- `LICENSE` - The MIT license file -- `Makefile` - The build configuration with placeholders for version-specific values -- `src/index.ts` - TypeScript entry point -- `src/libpg-query.d.ts` - TypeScript type definitions -- `src/wasm_wrapper.c` - C wrapper for WASM compilation +- `LICENSE` - The BSD 3-Clause license file +- `Makefile.template` - The build configuration with placeholders for version-specific values +- `index.ts` - TypeScript entry point +- `libpg-query.d.ts` - TypeScript type definitions +- `wasm_wrapper.c` - C wrapper for WASM compilation ## Usage @@ -22,17 +22,28 @@ This script will: 1. Copy all template files to each version directory 2. Replace placeholders with version-specific values 3. Add a header comment to source files indicating they are auto-generated -4. Handle special cases (e.g., the patch command for version 13) +4. Handle special cases (e.g., the emscripten patch for version 13) -## Placeholders and Flags +## Placeholders The following placeholders are used in template files: -- `{{LIBPG_QUERY_TAG}}` - The libpg_query version tag (e.g., "14-3.0.0") +- `{{VERSION_TAG}}` - The libpg_query version tag (e.g., "14-3.0.0") - `{{#USE_EMSCRIPTEN_PATCH}}...{{/USE_EMSCRIPTEN_PATCH}}` - Conditional block for version-specific patches (currently only used in version 13) +## Version-Specific Configurations + +The `scripts/copy-templates.js` script contains version-specific configurations: + +- **Version 13**: Uses tag `13-2.2.0` and requires emscripten patch +- **Version 14**: Uses tag `14-3.0.0` +- **Version 15**: Uses tag `15-4.2.4` +- **Version 16**: Uses tag `16-5.2.0` +- **Version 17**: Uses tag `17-6.1.0` + ## Important Notes - DO NOT edit files directly in the `versions/*/` directories for these common files - Always edit the templates and run the copy script -- The script preserves version-specific configurations while maintaining consistency \ No newline at end of file +- The script preserves version-specific configurations while maintaining consistency +- Generated files will have a header warning about manual modifications \ No newline at end of file diff --git a/templates/src/index.ts b/templates/index.ts similarity index 95% rename from templates/src/index.ts rename to templates/index.ts index 60c6d86..0f0e768 100644 --- a/templates/src/index.ts +++ b/templates/index.ts @@ -23,18 +23,18 @@ export interface SqlErrorFormatOptions { maxQueryLength?: number; // Max query length to display (default: no limit) } -// Helper function to create enhanced error with SQL details -function createSqlError(message: string, details: SqlErrorDetails): Error { - const error = new Error(message); - // Attach error details as properties - Object.defineProperty(error, 'sqlDetails', { - value: details, - enumerable: true, - configurable: true - }); - return error; +export class SqlError extends Error { + sqlDetails?: SqlErrorDetails; + + constructor(message: string, details?: SqlErrorDetails) { + super(message); + this.name = 'SqlError'; + this.sqlDetails = details; + } } + + // Helper function to classify error source function getErrorSource(filename: string | null): string { if (!filename) return 'unknown'; @@ -220,7 +220,7 @@ export const parse = awaitInit(async (query: string) => { context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined }; - throw createSqlError(message, errorDetails); + throw new SqlError(message, errorDetails); } if (!parseTreePtr) { @@ -289,7 +289,7 @@ export function parseSync(query: string) { context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined }; - throw createSqlError(message, errorDetails); + throw new SqlError(message, errorDetails); } if (!parseTreePtr) { diff --git a/templates/src/libpg-query.d.ts b/templates/libpg-query.d.ts similarity index 100% rename from templates/src/libpg-query.d.ts rename to templates/libpg-query.d.ts diff --git a/templates/src/wasm_wrapper.c b/templates/wasm_wrapper.c similarity index 100% rename from templates/src/wasm_wrapper.c rename to templates/wasm_wrapper.c diff --git a/versions/13/Makefile b/versions/13/Makefile index 363ae65..316b104 100644 --- a/versions/13/Makefile +++ b/versions/13/Makefile @@ -45,11 +45,9 @@ endif $(LIBPG_QUERY_DIR): mkdir -p $(CACHE_DIR) git clone -b $(LIBPG_QUERY_TAG) --single-branch $(LIBPG_QUERY_REPO) $(LIBPG_QUERY_DIR) - ifdef EMSCRIPTEN cd $(LIBPG_QUERY_DIR); patch -p1 < $(shell pwd)/patches/emscripten_disable_spinlocks.patch endif - $(LIBPG_QUERY_HEADER): $(LIBPG_QUERY_DIR) diff --git a/versions/13/src/index.ts b/versions/13/src/index.ts index c2007b7..27eeb49 100644 --- a/versions/13/src/index.ts +++ b/versions/13/src/index.ts @@ -41,6 +41,7 @@ export class SqlError extends Error { } + // Helper function to classify error source function getErrorSource(filename: string | null): string { if (!filename) return 'unknown'; diff --git a/versions/14/Makefile b/versions/14/Makefile index 67989b6..1c296d0 100644 --- a/versions/14/Makefile +++ b/versions/14/Makefile @@ -45,7 +45,6 @@ endif $(LIBPG_QUERY_DIR): mkdir -p $(CACHE_DIR) git clone -b $(LIBPG_QUERY_TAG) --single-branch $(LIBPG_QUERY_REPO) $(LIBPG_QUERY_DIR) - $(LIBPG_QUERY_HEADER): $(LIBPG_QUERY_DIR) diff --git a/versions/14/src/index.ts b/versions/14/src/index.ts index c2007b7..27eeb49 100644 --- a/versions/14/src/index.ts +++ b/versions/14/src/index.ts @@ -41,6 +41,7 @@ export class SqlError extends Error { } + // Helper function to classify error source function getErrorSource(filename: string | null): string { if (!filename) return 'unknown'; diff --git a/versions/15/Makefile b/versions/15/Makefile index aeb1904..709b004 100644 --- a/versions/15/Makefile +++ b/versions/15/Makefile @@ -45,7 +45,6 @@ endif $(LIBPG_QUERY_DIR): mkdir -p $(CACHE_DIR) git clone -b $(LIBPG_QUERY_TAG) --single-branch $(LIBPG_QUERY_REPO) $(LIBPG_QUERY_DIR) - $(LIBPG_QUERY_HEADER): $(LIBPG_QUERY_DIR) diff --git a/versions/15/src/index.ts b/versions/15/src/index.ts index c2007b7..27eeb49 100644 --- a/versions/15/src/index.ts +++ b/versions/15/src/index.ts @@ -41,6 +41,7 @@ export class SqlError extends Error { } + // Helper function to classify error source function getErrorSource(filename: string | null): string { if (!filename) return 'unknown'; diff --git a/versions/16/Makefile b/versions/16/Makefile index 5542cd0..7985521 100644 --- a/versions/16/Makefile +++ b/versions/16/Makefile @@ -45,7 +45,6 @@ endif $(LIBPG_QUERY_DIR): mkdir -p $(CACHE_DIR) git clone -b $(LIBPG_QUERY_TAG) --single-branch $(LIBPG_QUERY_REPO) $(LIBPG_QUERY_DIR) - $(LIBPG_QUERY_HEADER): $(LIBPG_QUERY_DIR) diff --git a/versions/16/src/index.ts b/versions/16/src/index.ts index c2007b7..27eeb49 100644 --- a/versions/16/src/index.ts +++ b/versions/16/src/index.ts @@ -41,6 +41,7 @@ export class SqlError extends Error { } + // Helper function to classify error source function getErrorSource(filename: string | null): string { if (!filename) return 'unknown'; diff --git a/versions/17/Makefile b/versions/17/Makefile index cc20c5f..4083593 100644 --- a/versions/17/Makefile +++ b/versions/17/Makefile @@ -45,7 +45,6 @@ endif $(LIBPG_QUERY_DIR): mkdir -p $(CACHE_DIR) git clone -b $(LIBPG_QUERY_TAG) --single-branch $(LIBPG_QUERY_REPO) $(LIBPG_QUERY_DIR) - $(LIBPG_QUERY_HEADER): $(LIBPG_QUERY_DIR) From 7749939fee6a564b5b38d0f2128a74c689a1c52f Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 26 Jun 2025 19:42:27 -0700 Subject: [PATCH 10/14] updates for single source of truth --- package.json | 7 ++--- scripts/copy-templates.js | 57 +++++++++++++++++++++++---------------- templates/README.md | 20 +++++++++----- 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index 32dc96d..253bf61 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,10 @@ "devDependencies": { "@types/node": "^20.0.0", "copyfiles": "^2.4.1", + "glob": "11.0.3", + "pg-proto-parser": "^1.28.2", "rimraf": "^5.0.0", "ts-node": "^10.9.1", - "typescript": "^5.3.3", - "pg-proto-parser": "^1.28.2" + "typescript": "^5.3.3" } -} \ No newline at end of file +} diff --git a/scripts/copy-templates.js b/scripts/copy-templates.js index efaeb8a..a2a6fb7 100755 --- a/scripts/copy-templates.js +++ b/scripts/copy-templates.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); +const glob = require('glob'); const HEADER = `/** * DO NOT MODIFY MANUALLY — this is generated from the templates dir @@ -19,29 +20,39 @@ const MAKEFILE_HEADER = `# DO NOT MODIFY MANUALLY — this is generated from the `; -// Version-specific configurations -const VERSION_CONFIGS = { - '13': { - tag: '13-2.2.0', - hasEmscriptenPatch: true - }, - '14': { - tag: '14-3.0.0', - hasEmscriptenPatch: false - }, - '15': { - tag: '15-4.2.4', - hasEmscriptenPatch: false - }, - '16': { - tag: '16-5.2.0', - hasEmscriptenPatch: false - }, - '17': { - tag: '17-6.1.0', - hasEmscriptenPatch: false - } -}; +// Load version configurations from package.json files +function loadVersionConfigs() { + const configs = {}; + const packageFiles = glob.sync('versions/*/package.json'); + + console.log(`Found ${packageFiles.length} package.json files\n`); + + packageFiles.forEach(packageFile => { + try { + const packageData = JSON.parse(fs.readFileSync(packageFile, 'utf8')); + const version = packageData['x-publish']?.pgVersion; + const libpgQueryTag = packageData['x-publish']?.libpgQueryTag; + + if (version && libpgQueryTag) { + configs[version] = { + tag: libpgQueryTag, + hasEmscriptenPatch: version === '13' // Only version 13 needs the patch + }; + console.log(` Version ${version}: tag ${libpgQueryTag}`); + } else { + console.warn(` Warning: Missing x-publish data in ${packageFile}`); + } + } catch (error) { + console.error(` Error reading ${packageFile}: ${error.message}`); + } + }); + + console.log(''); // Empty line for readability + return configs; +} + +// Load configurations +const VERSION_CONFIGS = loadVersionConfigs(); // Files to copy from templates const TEMPLATE_FILES = [ diff --git a/templates/README.md b/templates/README.md index 9cb3db8..e507db5 100644 --- a/templates/README.md +++ b/templates/README.md @@ -33,13 +33,21 @@ The following placeholders are used in template files: ## Version-Specific Configurations -The `scripts/copy-templates.js` script contains version-specific configurations: +The `scripts/copy-templates.js` script automatically reads version-specific configurations from each version's `package.json` file. It looks for the `x-publish` section: + +```json +"x-publish": { + "publishName": "libpg-query", + "pgVersion": "15", + "distTag": "pg15", + "libpgQueryTag": "15-4.2.4" +} +``` -- **Version 13**: Uses tag `13-2.2.0` and requires emscripten patch -- **Version 14**: Uses tag `14-3.0.0` -- **Version 15**: Uses tag `15-4.2.4` -- **Version 16**: Uses tag `16-5.2.0` -- **Version 17**: Uses tag `17-6.1.0` +The script uses: +- `pgVersion` to identify the PostgreSQL version +- `libpgQueryTag` for the {{VERSION_TAG}} placeholder replacement +- Version 13 automatically gets the emscripten patch applied ## Important Notes From 3bac6b8730e91f22b6ab1674891520009b519dfe Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 26 Jun 2025 19:43:45 -0700 Subject: [PATCH 11/14] lock --- pnpm-lock.yaml | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f66af9..b75221b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: copyfiles: specifier: ^2.4.1 version: 2.4.1 + glob: + specifier: 11.0.3 + version: 11.0.3 pg-proto-parser: specifier: ^1.28.2 version: 1.28.2 @@ -272,6 +275,18 @@ packages: '@jridgewell/trace-mapping': 0.3.9 dev: true + /@isaacs/balanced-match@4.0.1: + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + dev: true + + /@isaacs/brace-expansion@5.0.0: + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + dependencies: + '@isaacs/balanced-match': 4.0.1 + dev: true + /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -785,6 +800,19 @@ packages: path-scurry: 1.11.1 dev: true + /glob@11.0.3: + resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} + engines: {node: 20 || >=22} + hasBin: true + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.1.1 + minimatch: 10.0.3 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.0 + dev: true + /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -878,6 +906,13 @@ packages: '@pkgjs/parseargs': 0.11.0 dev: true + /jackspeak@4.1.1: + resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + engines: {node: 20 || >=22} + dependencies: + '@isaacs/cliui': 8.0.2 + dev: true + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} dev: true @@ -954,6 +989,11 @@ packages: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} dev: true + /lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} + dev: true + /make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} dev: true @@ -990,6 +1030,13 @@ packages: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} dev: true + /minimatch@10.0.3: + resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} + engines: {node: 20 || >=22} + dependencies: + '@isaacs/brace-expansion': 5.0.0 + dev: true + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -1086,6 +1133,14 @@ packages: minipass: 7.1.2 dev: true + /path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + dependencies: + lru-cache: 11.1.0 + minipass: 7.1.2 + dev: true + /pg-proto-parser@1.28.2: resolution: {integrity: sha512-W+IywDGhYnsWf0pADeeXx9ORmAfUUK4Be6thyXO+uPycdG5EqCHG85G9BG7BubIxHomYxk2xJRgpxfTRfJ49fw==} dependencies: From 7cb71bed50058ba878f1639d35ad02908db15cb3 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 26 Jun 2025 19:59:51 -0700 Subject: [PATCH 12/14] version --- versions/13/package.json | 4 ++-- versions/14/package.json | 4 ++-- versions/15/package.json | 4 ++-- versions/16/package.json | 4 ++-- versions/17/package.json | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/versions/13/package.json b/versions/13/package.json index 93ce3f7..3f84d8c 100644 --- a/versions/13/package.json +++ b/versions/13/package.json @@ -1,6 +1,6 @@ { "name": "@libpg-query/v13", - "version": "13.5.4", + "version": "13.5.7", "description": "The real PostgreSQL query parser", "homepage": "https://github.com/launchql/libpg-query-node", "main": "./wasm/index.cjs", @@ -49,4 +49,4 @@ "plpgsql", "database" ] -} \ No newline at end of file +} diff --git a/versions/14/package.json b/versions/14/package.json index 3747303..b9423a1 100644 --- a/versions/14/package.json +++ b/versions/14/package.json @@ -1,6 +1,6 @@ { "name": "@libpg-query/v14", - "version": "14.2.3", + "version": "14.2.5", "description": "The real PostgreSQL query parser", "homepage": "https://github.com/launchql/libpg-query-node", "main": "./wasm/index.cjs", @@ -49,4 +49,4 @@ "plpgsql", "database" ] -} \ No newline at end of file +} diff --git a/versions/15/package.json b/versions/15/package.json index 936ba89..3418efd 100644 --- a/versions/15/package.json +++ b/versions/15/package.json @@ -1,6 +1,6 @@ { "name": "@libpg-query/v15", - "version": "15.4.3", + "version": "15.4.5", "description": "The real PostgreSQL query parser", "homepage": "https://github.com/launchql/libpg-query-node", "main": "./wasm/index.cjs", @@ -49,4 +49,4 @@ "plpgsql", "database" ] -} \ No newline at end of file +} diff --git a/versions/16/package.json b/versions/16/package.json index 2732d0b..f8075cf 100644 --- a/versions/16/package.json +++ b/versions/16/package.json @@ -1,6 +1,6 @@ { "name": "@libpg-query/v16", - "version": "16.5.3", + "version": "16.5.5", "description": "The real PostgreSQL query parser", "homepage": "https://github.com/launchql/libpg-query-node", "main": "./wasm/index.cjs", @@ -49,4 +49,4 @@ "plpgsql", "database" ] -} \ No newline at end of file +} diff --git a/versions/17/package.json b/versions/17/package.json index 457dba7..57e3b9c 100644 --- a/versions/17/package.json +++ b/versions/17/package.json @@ -1,6 +1,6 @@ { "name": "@libpg-query/v17", - "version": "17.5.3", + "version": "17.5.5", "description": "The real PostgreSQL query parser", "homepage": "https://github.com/launchql/libpg-query-node", "main": "./wasm/index.cjs", @@ -49,4 +49,4 @@ "plpgsql", "database" ] -} \ No newline at end of file +} From 12ce446b4cc3914c9dc7e3332a17f827e6c1d8ed Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 26 Jun 2025 20:04:22 -0700 Subject: [PATCH 13/14] workflow post publish --- .github/workflows/ci.yml | 2 +- versions/13/package.json | 2 +- versions/14/package.json | 2 +- versions/15/package.json | 2 +- versions/16/package.json | 2 +- versions/17/package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 394cedc..d0f8af4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: if [ "${{ matrix.package.name }}" = "v13" ]; then # Download prebuilt WASM for v13 since it can't build in CI mkdir -p wasm - curl -o v13.tgz "https://registry.npmjs.org/@libpg-query/v13/-/v13-13.5.2.tgz" + curl -o v13.tgz "https://registry.npmjs.org/@libpg-query/v13/-/v13-13.5.7.tgz" tar -xzf v13.tgz --strip-components=1 package/wasm rm v13.tgz else diff --git a/versions/13/package.json b/versions/13/package.json index 3f84d8c..1d3f824 100644 --- a/versions/13/package.json +++ b/versions/13/package.json @@ -49,4 +49,4 @@ "plpgsql", "database" ] -} +} \ No newline at end of file diff --git a/versions/14/package.json b/versions/14/package.json index b9423a1..e9ae3d1 100644 --- a/versions/14/package.json +++ b/versions/14/package.json @@ -49,4 +49,4 @@ "plpgsql", "database" ] -} +} \ No newline at end of file diff --git a/versions/15/package.json b/versions/15/package.json index 3418efd..5f2e0e8 100644 --- a/versions/15/package.json +++ b/versions/15/package.json @@ -49,4 +49,4 @@ "plpgsql", "database" ] -} +} \ No newline at end of file diff --git a/versions/16/package.json b/versions/16/package.json index f8075cf..232b720 100644 --- a/versions/16/package.json +++ b/versions/16/package.json @@ -49,4 +49,4 @@ "plpgsql", "database" ] -} +} \ No newline at end of file diff --git a/versions/17/package.json b/versions/17/package.json index 57e3b9c..0bb7ad4 100644 --- a/versions/17/package.json +++ b/versions/17/package.json @@ -49,4 +49,4 @@ "plpgsql", "database" ] -} +} \ No newline at end of file From cc39b07c54a231cd14c0332b3a8a5cef2cfaa83d Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 26 Jun 2025 20:19:23 -0700 Subject: [PATCH 14/14] parser errors + test --- parser/package.json | 2 +- parser/templates/index.cjs.template | 8 ++++++++ parser/templates/index.js.template | 8 ++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/parser/package.json b/parser/package.json index fdcffe7..5429b80 100644 --- a/parser/package.json +++ b/parser/package.json @@ -1,6 +1,6 @@ { "name": "@pgsql/parser", - "version": "1.0.4", + "version": "1.0.5", "author": "Dan Lynch ", "description": "Multi-version PostgreSQL parser with dynamic version selection", "main": "./wasm/index.cjs", diff --git a/parser/templates/index.cjs.template b/parser/templates/index.cjs.template index 25d2df2..ff18356 100644 --- a/parser/templates/index.cjs.template +++ b/parser/templates/index.cjs.template @@ -26,6 +26,10 @@ class Parser { try { return this.parser.parse(query); } catch (error) { + // Preserve the original error if it's a SqlError + if (error.name === 'SqlError') { + throw error; + } throw new Error(`Parse error in PostgreSQL ${this.version}: ${error.message}`); } } @@ -37,6 +41,10 @@ class Parser { try { return this.parser.parseSync(query); } catch (error) { + // Preserve the original error if it's a SqlError + if (error.name === 'SqlError') { + throw error; + } throw new Error(`Parse error in PostgreSQL ${this.version}: ${error.message}`); } } diff --git a/parser/templates/index.js.template b/parser/templates/index.js.template index cd2700b..0074727 100644 --- a/parser/templates/index.js.template +++ b/parser/templates/index.js.template @@ -26,6 +26,10 @@ export class Parser { try { return this.parser.parse(query); } catch (error) { + // Preserve the original error if it's a SqlError + if (error.name === 'SqlError') { + throw error; + } throw new Error(`Parse error in PostgreSQL ${this.version}: ${error.message}`); } } @@ -37,6 +41,10 @@ export class Parser { try { return this.parser.parseSync(query); } catch (error) { + // Preserve the original error if it's a SqlError + if (error.name === 'SqlError') { + throw error; + } throw new Error(`Parse error in PostgreSQL ${this.version}: ${error.message}`); } }