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/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/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/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/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 976d434..101500d 100644 --- a/full/src/index.ts +++ b/full/src/index.ts @@ -16,6 +16,103 @@ 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; + 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 { 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; + 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 lines.join('\n'); +} + // @ts-ignore import PgQueryModule from './libpg-query.js'; // @ts-ignore @@ -26,6 +123,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 +133,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 +185,60 @@ function ptrToString(ptr: number): string { } export const parse = awaitInit(async (query: string): Promise => { + // Input validation + if (query === null || query === undefined) { + throw new Error('Query cannot be null or undefined'); + } + + if (query === '') { + 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); + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new Error('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 > 0 ? cursorpos - 1 : 0, // Convert to 0-based + fileName: filename, + functionName: funcname, + lineNumber: lineno > 0 ? lineno : undefined + }); } - return JSON.parse(resultStr); + if (!parseTreePtr) { + throw new Error('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 +340,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 Error('Query cannot be null or undefined'); + } + + if (query === '') { + 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); + resultPtr = wasmModule._wasm_parse_query_raw(queryPtr); + if (!resultPtr) { + throw new Error('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 > 0 ? cursorpos - 1 : 0, // Convert to 0-based + fileName: filename, + functionName: funcname, + lineNumber: lineno > 0 ? lineno : undefined + }); } - return JSON.parse(resultStr); + if (!parseTreePtr) { + throw new Error('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/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/package.json b/package.json index e5a75f6..253bf61 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", @@ -20,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", @@ -30,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/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 0761be7..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", @@ -47,9 +47,7 @@ "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/parser.test.js" + "test": "node --test test/parsing.test.js test/errors.test.js" }, "keywords": [ "postgresql", diff --git a/parser/scripts/prepare.js b/parser/scripts/prepare.js index 5d037b2..1bffbce 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'); @@ -8,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/parser/templates/index.cjs.template b/parser/templates/index.cjs.template index 7f595cb..ff18356 100644 --- a/parser/templates/index.cjs.template +++ b/parser/templates/index.cjs.template @@ -26,39 +26,29 @@ 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}`); } } 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); } 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}`); } } - 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..0074727 100644 --- a/parser/templates/index.js.template +++ b/parser/templates/index.js.template @@ -26,39 +26,29 @@ 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}`); } } 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); } 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}`); } } - 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/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/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: 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..a2a6fb7 --- /dev/null +++ b/scripts/copy-templates.js @@ -0,0 +1,130 @@ +#!/usr/bin/env node + +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 + * + * To make changes, edit the files in the templates/ directory and run: + * npm run copy:templates + */ + +`; + +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 + +`; + +// 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 = [ + { 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 } +]; + +function copyTemplates() { + const templatesDir = path.join(__dirname, '..', 'templates'); + const versionsDir = path.join(__dirname, '..', 'versions'); + + // Process each version + for (const [version, config] of Object.entries(VERSION_CONFIGS)) { + const versionDir = path.join(versionsDir, version); + console.log(`\nProcessing version ${version}...`); + + // 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}`); + } + + // 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('\nāœ… Template copying completed!'); +} + +// Run the script +copyTemplates(); \ 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.template b/templates/Makefile.template new file mode 100644 index 0000000..65e6f56 --- /dev/null +++ b/templates/Makefile.template @@ -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 := {{VERSION_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_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..e507db5 --- /dev/null +++ b/templates/README.md @@ -0,0 +1,57 @@ +# Template Files + +This directory contains template files that are shared across all PostgreSQL versions in the `versions/` directory. + +## Files + +- `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 + +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 emscripten patch for version 13) + +## Placeholders + +The following placeholders are used in template files: + +- `{{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 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" +} +``` + +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 + +- 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 +- Generated files will have a header warning about manual modifications \ No newline at end of file diff --git a/templates/index.ts b/templates/index.ts new file mode 100644 index 0000000..0f0e768 --- /dev/null +++ b/templates/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) +} + +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'; + 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 new SqlError(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 new SqlError(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/libpg-query.d.ts b/templates/libpg-query.d.ts new file mode 100644 index 0000000..396fef8 --- /dev/null +++ b/templates/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/wasm_wrapper.c b/templates/wasm_wrapper.c new file mode 100644 index 0000000..59c5a97 --- /dev/null +++ b/templates/wasm_wrapper.c @@ -0,0 +1,40 @@ +#include +#include +#include +#include + +static int validate_input(const char* input) { + return input != NULL && strlen(input) > 0; +} + +static void* safe_malloc(size_t size) { + void* ptr = malloc(size); + if (!ptr && size > 0) { + return NULL; + } + return ptr; +} + +// Raw struct access functions for parse +EMSCRIPTEN_KEEPALIVE +PgQueryParseResult* wasm_parse_query_raw(const char* input) { + if (!validate_input(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 bcee4c4..316b104 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 @@ -60,8 +65,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_raw','_wasm_free_parse_result']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ -sMODULARIZE=1 \ @@ -89,4 +94,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/package.json b/versions/13/package.json index 678835e..1d3f824 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", @@ -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/13/src/index.ts b/versions/13/src/index.ts index ef2d047..27eeb49 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 @@ -5,6 +12,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) +} + +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'; + 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 +177,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 new SqlError(message, errorDetails); } - return JSON.parse(resultStr); - } finally { + + 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); } } }); 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'); + } + + // 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 new SqlError(message, errorDetails); } - return JSON.parse(resultStr); - } finally { + + 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/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 bf297c6..3815168 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 @@ -7,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) { @@ -24,26 +22,26 @@ static void* safe_malloc(size_t size) { return ptr; } +// Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE -char* wasm_parse_query(const char* input) { +PgQueryParseResult* wasm_parse_query_raw(const char* input) { if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); + return NULL; } - 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"); + PgQueryParseResult* result = (PgQueryParseResult*)safe_malloc(sizeof(PgQueryParseResult)); + if (!result) { + return NULL; } - char* parse_tree = safe_strdup(result.parse_tree); - pg_query_free_parse_result(result); - return parse_tree; + *result = pg_query_parse(input); + return result; } EMSCRIPTEN_KEEPALIVE -void wasm_free_string(char* str) { - free(str); +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..1c296d0 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 @@ -57,8 +62,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_raw','_wasm_free_parse_result']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ -sMODULARIZE=1 \ @@ -86,4 +91,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/package.json b/versions/14/package.json index 23c8f18..e9ae3d1 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", @@ -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/src/index.ts b/versions/14/src/index.ts index ef2d047..27eeb49 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 @@ -5,6 +12,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) +} + +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'; + 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 +177,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 new SqlError(message, errorDetails); } - return JSON.parse(resultStr); - } finally { + + 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); } } }); 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'); + } + + // 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 new SqlError(message, errorDetails); } - return JSON.parse(resultStr); - } finally { + + 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/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 bf297c6..3815168 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 @@ -7,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) { @@ -24,26 +22,26 @@ static void* safe_malloc(size_t size) { return ptr; } +// Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE -char* wasm_parse_query(const char* input) { +PgQueryParseResult* wasm_parse_query_raw(const char* input) { if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); + return NULL; } - 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"); + PgQueryParseResult* result = (PgQueryParseResult*)safe_malloc(sizeof(PgQueryParseResult)); + if (!result) { + return NULL; } - char* parse_tree = safe_strdup(result.parse_tree); - pg_query_free_parse_result(result); - return parse_tree; + *result = pg_query_parse(input); + return result; } EMSCRIPTEN_KEEPALIVE -void wasm_free_string(char* str) { - free(str); +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..709b004 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 @@ -57,8 +62,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_raw','_wasm_free_parse_result']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ -sMODULARIZE=1 \ @@ -86,4 +91,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/package.json b/versions/15/package.json index 041949a..5f2e0e8 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", @@ -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/src/index.ts b/versions/15/src/index.ts index ef2d047..27eeb49 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 @@ -5,6 +12,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) +} + +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'; + 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 +177,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 new SqlError(message, errorDetails); } - return JSON.parse(resultStr); - } finally { + + 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); } } }); 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'); + } + + // 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 new SqlError(message, errorDetails); } - return JSON.parse(resultStr); - } finally { + + 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/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 bf297c6..3815168 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 @@ -7,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) { @@ -24,26 +22,26 @@ static void* safe_malloc(size_t size) { return ptr; } +// Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE -char* wasm_parse_query(const char* input) { +PgQueryParseResult* wasm_parse_query_raw(const char* input) { if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); + return NULL; } - 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"); + PgQueryParseResult* result = (PgQueryParseResult*)safe_malloc(sizeof(PgQueryParseResult)); + if (!result) { + return NULL; } - char* parse_tree = safe_strdup(result.parse_tree); - pg_query_free_parse_result(result); - return parse_tree; + *result = pg_query_parse(input); + return result; } EMSCRIPTEN_KEEPALIVE -void wasm_free_string(char* str) { - free(str); +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..7985521 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 @@ -57,8 +62,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_raw','_wasm_free_parse_result']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ -sMODULARIZE=1 \ @@ -86,4 +91,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/package.json b/versions/16/package.json index 0b1d956..232b720 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", @@ -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/src/index.ts b/versions/16/src/index.ts index ef2d047..27eeb49 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 @@ -5,6 +12,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) +} + +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'; + 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 +177,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 new SqlError(message, errorDetails); } - return JSON.parse(resultStr); - } finally { + + 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); } } }); 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'); + } + + // 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 new SqlError(message, errorDetails); } - return JSON.parse(resultStr); - } finally { + + 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/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 bf297c6..3815168 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 @@ -7,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) { @@ -24,26 +22,26 @@ static void* safe_malloc(size_t size) { return ptr; } +// Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE -char* wasm_parse_query(const char* input) { +PgQueryParseResult* wasm_parse_query_raw(const char* input) { if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); + return NULL; } - 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"); + PgQueryParseResult* result = (PgQueryParseResult*)safe_malloc(sizeof(PgQueryParseResult)); + if (!result) { + return NULL; } - char* parse_tree = safe_strdup(result.parse_tree); - pg_query_free_parse_result(result); - return parse_tree; + *result = pg_query_parse(input); + return result; } EMSCRIPTEN_KEEPALIVE -void wasm_free_string(char* str) { - free(str); +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 diff --git a/versions/17/Makefile b/versions/17/Makefile index ac31dd9..4083593 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 @@ -57,8 +62,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_raw','_wasm_free_parse_result']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node" \ -sMODULARIZE=1 \ @@ -86,4 +91,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/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/package.json b/versions/17/package.json index a26d43d..0bb7ad4 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", @@ -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/src/index.ts b/versions/17/src/index.ts index ef2d047..27eeb49 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 @@ -5,6 +12,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) +} + +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'; + 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 +177,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 new SqlError(message, errorDetails); } - return JSON.parse(resultStr); - } finally { + + 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); } } }); 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'); + } + + // 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 new SqlError(message, errorDetails); } - return JSON.parse(resultStr); - } finally { + + 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/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 bf297c6..3815168 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 @@ -7,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) { @@ -24,26 +22,26 @@ static void* safe_malloc(size_t size) { return ptr; } +// Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE -char* wasm_parse_query(const char* input) { +PgQueryParseResult* wasm_parse_query_raw(const char* input) { if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); + return NULL; } - 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"); + PgQueryParseResult* result = (PgQueryParseResult*)safe_malloc(sizeof(PgQueryParseResult)); + if (!result) { + return NULL; } - char* parse_tree = safe_strdup(result.parse_tree); - pg_query_free_parse_result(result); - return parse_tree; + *result = pg_query_parse(input); + return result; } EMSCRIPTEN_KEEPALIVE -void wasm_free_string(char* str) { - free(str); +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/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