From b2cc460b1f5f622e3bc420c232a6266d84c398fd Mon Sep 17 00:00:00 2001 From: Myles Scolnick Date: Mon, 4 Aug 2025 09:25:25 -0400 Subject: [PATCH] improvement: lazy-load sql parser --- src/sql/__tests__/parser.test.ts | 24 ++-- src/sql/__tests__/structure-analyzer.test.ts | 116 +++++++++---------- src/sql/diagnostics.ts | 4 +- src/sql/parser.ts | 23 ++-- src/sql/structure-analyzer.ts | 20 ++-- src/sql/structure-extension.ts | 6 +- src/utils.ts | 12 ++ 7 files changed, 112 insertions(+), 93 deletions(-) create mode 100644 src/utils.ts diff --git a/src/sql/__tests__/parser.test.ts b/src/sql/__tests__/parser.test.ts index 7afcd49..98f5b9c 100644 --- a/src/sql/__tests__/parser.test.ts +++ b/src/sql/__tests__/parser.test.ts @@ -5,16 +5,16 @@ describe("SqlParser", () => { const parser = new SqlParser(); describe("parse", () => { - it("should parse valid SQL successfully", () => { + it("should parse valid SQL successfully", async () => { const sql = "SELECT * FROM users WHERE id = 1"; - const result = parser.parse(sql); + const result = await parser.parse(sql); expect(result.success).toBe(true); expect(result.errors).toHaveLength(0); expect(result.ast).toBeDefined(); }); - it("should handle complex queries", () => { + it("should handle complex queries", async () => { const sql = ` SELECT u.name, p.title FROM users u @@ -22,15 +22,15 @@ describe("SqlParser", () => { WHERE u.active = true ORDER BY p.created_at DESC `; - const result = parser.parse(sql); + const result = await parser.parse(sql); expect(result.success).toBe(true); expect(result.errors).toHaveLength(0); }); - it("should return errors for invalid SQL", () => { + it("should return errors for invalid SQL", async () => { const sql = "SELECT * FROM"; - const result = parser.parse(sql); + const result = await parser.parse(sql); expect(result.success).toBe(false); expect(result.errors).toHaveLength(1); @@ -38,9 +38,9 @@ describe("SqlParser", () => { expect(result.errors[0].message).toBeTruthy(); }); - it("should return errors for syntax errors", () => { + it("should return errors for syntax errors", async () => { const sql = "SELECT * FORM users"; - const result = parser.parse(sql); + const result = await parser.parse(sql); expect(result.success).toBe(false); expect(result.errors).toHaveLength(1); @@ -48,16 +48,16 @@ describe("SqlParser", () => { }); describe("validateSql", () => { - it("should return empty array for valid SQL", () => { + it("should return empty array for valid SQL", async () => { const sql = "SELECT 1"; - const errors = parser.validateSql(sql); + const errors = await parser.validateSql(sql); expect(errors).toHaveLength(0); }); - it("should return errors for invalid SQL", () => { + it("should return errors for invalid SQL", async () => { const sql = "SELECT * FROM WHERE"; - const errors = parser.validateSql(sql); + const errors = await parser.validateSql(sql); expect(errors.length).toBeGreaterThan(0); expect(errors[0]).toHaveProperty("message"); diff --git a/src/sql/__tests__/structure-analyzer.test.ts b/src/sql/__tests__/structure-analyzer.test.ts index 9337bc5..d08942e 100644 --- a/src/sql/__tests__/structure-analyzer.test.ts +++ b/src/sql/__tests__/structure-analyzer.test.ts @@ -15,9 +15,9 @@ describe("SqlStructureAnalyzer", () => { }; describe("analyzeDocument", () => { - it("should identify single SQL statement", () => { + it("should identify single SQL statement", async () => { state = createState("SELECT * FROM users WHERE id = 1;"); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].content).toBe("SELECT * FROM users WHERE id = 1"); @@ -27,13 +27,13 @@ describe("SqlStructureAnalyzer", () => { expect(statements[0].lineTo).toBe(1); }); - it("should identify multiple SQL statements", () => { + it("should identify multiple SQL statements", async () => { state = createState(` SELECT * FROM users; INSERT INTO users (name) VALUES ('John'); DELETE FROM users WHERE id = 1; `); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(3); expect(statements[0].type).toBe("select"); @@ -41,18 +41,18 @@ describe("SqlStructureAnalyzer", () => { expect(statements[2].type).toBe("delete"); }); - it("should handle statements without semicolons", () => { + it("should handle statements without semicolons", async () => { state = createState("SELECT * FROM users WHERE id = 1"); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].content).toBe("SELECT * FROM users WHERE id = 1"); expect(statements[0].type).toBe("select"); }); - it("should handle semicolons in string literals", () => { + it("should handle semicolons in string literals", async () => { state = createState(`SELECT 'Hello; World' FROM users; UPDATE users SET name = 'Test;';`); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(2); expect(statements[0].content).toBe("SELECT 'Hello; World' FROM users"); @@ -72,27 +72,27 @@ describe("SqlStructureAnalyzer", () => { { sql: "SHOW TABLES", expected: "other" }, ]; - testCases.forEach(({ sql, expected }) => { + testCases.forEach(async ({ sql, expected }) => { state = createState(`${sql};`); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements[0].type).toBe(expected); }); }); - it("should handle invalid SQL statements", () => { + it("should handle invalid SQL statements", async () => { state = createState("SELECT * FROM;"); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].isValid).toBe(false); }); - it("should cache results for identical content", () => { + it("should cache results for identical content", async () => { const content = "SELECT * FROM users;"; state = createState(content); - const statements1 = analyzer.analyzeDocument(state); - const statements2 = analyzer.analyzeDocument(state); + const statements1 = await analyzer.analyzeDocument(state); + const statements2 = await analyzer.analyzeDocument(state); expect(statements1).toBe(statements2); // Should be the same reference (cached) }); @@ -105,19 +105,19 @@ INSERT INTO users (name) VALUES ('John'); DELETE FROM users WHERE id = 1;`); }); - it("should return correct statement for cursor position", () => { - const statement1 = analyzer.getStatementAtPosition(state, 5); // Inside first SELECT - const statement2 = analyzer.getStatementAtPosition(state, 25); // Inside INSERT - const statement3 = analyzer.getStatementAtPosition(state, 80); // Inside DELETE + it("should return correct statement for cursor position", async () => { + const statement1 = await analyzer.getStatementAtPosition(state, 5); // Inside first SELECT + const statement2 = await analyzer.getStatementAtPosition(state, 25); // Inside INSERT + const statement3 = await analyzer.getStatementAtPosition(state, 80); // Inside DELETE expect(statement1?.type).toBe("select"); expect(statement2?.type).toBe("insert"); expect(statement3?.type).toBe("delete"); }); - it("should return null for position outside any statement", () => { + it("should return null for position outside any statement", async () => { state = createState(" \n\n "); - const statement = analyzer.getStatementAtPosition(state, 2); + const statement = await analyzer.getStatementAtPosition(state, 2); expect(statement).toBeNull(); }); }); @@ -129,43 +129,43 @@ INSERT INTO users (name) VALUES ('John'); DELETE FROM users WHERE id = 1;`); }); - it("should return statements that intersect with range", () => { - const statements = analyzer.getStatementsInRange(state, 0, 50); + it("should return statements that intersect with range", async () => { + const statements = await analyzer.getStatementsInRange(state, 0, 50); expect(statements).toHaveLength(2); // SELECT and INSERT expect(statements[0].type).toBe("select"); expect(statements[1].type).toBe("insert"); }); - it("should return single statement when range is within one statement", () => { - const statements = analyzer.getStatementsInRange(state, 5, 10); + it("should return single statement when range is within one statement", async () => { + const statements = await analyzer.getStatementsInRange(state, 5, 10); expect(statements).toHaveLength(1); expect(statements[0].type).toBe("select"); }); - it("should return all statements when range covers entire document", () => { - const statements = analyzer.getStatementsInRange(state, 0, state.doc.toString().length); + it("should return all statements when range covers entire document", async () => { + const statements = await analyzer.getStatementsInRange(state, 0, state.doc.toString().length); expect(statements).toHaveLength(3); }); }); describe("clearCache", () => { - it("should clear internal cache", () => { + it("should clear internal cache", async () => { state = createState("SELECT * FROM users;"); // Populate cache - analyzer.analyzeDocument(state); + await analyzer.analyzeDocument(state); // Clear cache analyzer.clearCache(); // Should re-analyze (though we can't directly test this, we ensure no errors) - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); }); }); describe("multiline statements", () => { - it("should handle statements spanning multiple lines", () => { + it("should handle statements spanning multiple lines", async () => { state = createState(`SELECT u.id, u.name, u.email @@ -173,7 +173,7 @@ FROM users u WHERE u.active = true AND u.created_at > '2023-01-01';`); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].lineFrom).toBe(1); expect(statements[0].lineTo).toBe(6); @@ -183,40 +183,40 @@ WHERE u.active = true describe("comment handling", () => { describe("single-line comments (--)", () => { - it("should strip single-line comments from statement content", () => { + it("should strip single-line comments from statement content", async () => { state = createState("SELECT * FROM users -- this is a comment\nWHERE id = 1;"); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].content).toBe("SELECT * FROM users \nWHERE id = 1"); expect(statements[0].type).toBe("select"); }); - it("should handle single-line comment at end of statement", () => { + it("should handle single-line comment at end of statement", async () => { state = createState("SELECT * FROM users WHERE id = 1; -- comment"); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].content).toBe("SELECT * FROM users WHERE id = 1"); }); - it("should handle statement that is entirely a comment", () => { + it("should handle statement that is entirely a comment", async () => { state = createState("-- This is just a comment\nSELECT * FROM users;"); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].content).toBe("SELECT * FROM users"); expect(statements[0].type).toBe("select"); }); - it("should handle multiple single-line comments", () => { + it("should handle multiple single-line comments", async () => { state = createState(` -- First comment SELECT * FROM users -- inline comment -- Another comment WHERE id = 1; -- final comment `); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].content.trim()).toBe( @@ -226,30 +226,30 @@ WHERE u.active = true }); describe("multi-line comments (/* */)", () => { - it("should strip multi-line comments from statement content", () => { + it("should strip multi-line comments from statement content", async () => { state = createState("SELECT * FROM users /* this is a comment */ WHERE id = 1;"); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].content).toBe("SELECT * FROM users WHERE id = 1"); expect(statements[0].type).toBe("select"); }); - it("should handle multi-line comments spanning multiple lines", () => { + it("should handle multi-line comments spanning multiple lines", async () => { state = createState(`SELECT * FROM users /* This is a multi-line comment that spans several lines */ WHERE id = 1;`); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].content).toBe("SELECT * FROM users WHERE id = 1"); }); - it("should handle nested-like comment patterns", () => { + it("should handle nested-like comment patterns", async () => { state = createState("SELECT * FROM users /* comment /* nested */ text */ WHERE id = 1;"); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].content).toBe("SELECT * FROM users text */ WHERE id = 1"); @@ -257,27 +257,27 @@ WHERE u.active = true }); describe("comments in string literals", () => { - it("should not strip comment patterns inside string literals", () => { + it("should not strip comment patterns inside string literals", async () => { state = createState("SELECT 'This -- is not a comment' FROM users;"); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].content).toBe("SELECT 'This -- is not a comment' FROM users"); }); - it("should not strip multi-line comment patterns inside string literals", () => { + it("should not strip multi-line comment patterns inside string literals", async () => { state = createState("SELECT 'This /* is not */ a comment' FROM users;"); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].content).toBe("SELECT 'This /* is not */ a comment' FROM users"); }); - it("should handle mixed real comments and string literal comment patterns", () => { + it("should handle mixed real comments and string literal comment patterns", async () => { state = createState( "SELECT 'Text -- not comment' FROM users -- real comment\nWHERE id = 1;", ); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].content).toBe( @@ -287,13 +287,13 @@ WHERE u.active = true }); describe("mixed comments and statements", () => { - it("should handle statements separated by comments", () => { + it("should handle statements separated by comments", async () => { state = createState(` SELECT * FROM users; -- First query /* Comment between queries */ INSERT INTO users (name) VALUES ('John'); -- Second query `); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(2); expect(statements[0].content.trim()).toBe("SELECT * FROM users"); @@ -302,27 +302,27 @@ WHERE u.active = true expect(statements[1].type).toBe("insert"); }); - it("should not create statements from comment-only content", () => { + it("should not create statements from comment-only content", async () => { state = createState(` -- Just a comment /* Another comment */ SELECT * FROM users; -- Final comment `); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(1); expect(statements[0].type).toBe("select"); }); - it("should handle semicolons in comments", () => { + it("should handle semicolons in comments", async () => { state = createState(` SELECT * FROM users; -- Comment with; semicolon /* Multi-line comment with; semicolon; on multiple; lines */ INSERT INTO logs VALUES (1); `); - const statements = analyzer.analyzeDocument(state); + const statements = await analyzer.analyzeDocument(state); expect(statements).toHaveLength(2); expect(statements[0].type).toBe("select"); diff --git a/src/sql/diagnostics.ts b/src/sql/diagnostics.ts index 93c2465..2174fdb 100644 --- a/src/sql/diagnostics.ts +++ b/src/sql/diagnostics.ts @@ -52,7 +52,7 @@ export function sqlLinter(config: SqlLinterConfig = {}) { const parser = config.parser || new SqlParser(); return linter( - (view: EditorView): Diagnostic[] => { + async (view: EditorView): Promise => { const doc = view.state.doc; const sql = doc.toString(); @@ -60,7 +60,7 @@ export function sqlLinter(config: SqlLinterConfig = {}) { return []; } - const errors = parser.validateSql(sql); + const errors = await parser.validateSql(sql); return errors.map((error) => convertToCodeMirrorDiagnostic(error, doc)); }, diff --git a/src/sql/parser.ts b/src/sql/parser.ts index d2f51d6..7645453 100644 --- a/src/sql/parser.ts +++ b/src/sql/parser.ts @@ -1,4 +1,4 @@ -import { Parser } from "node-sql-parser"; +import { lazy } from "../utils.js"; /** * Represents a SQL parsing error with location information @@ -31,15 +31,18 @@ export interface SqlParseResult { * and validation capabilities for CodeMirror integration. */ export class SqlParser { - private parser: Parser; + /** + * Lazy import of the node-sql-parser package and create a new Parser instance. + */ + private getParser = lazy(async () => { + const { Parser } = await import("node-sql-parser"); + return new Parser(); + }); - constructor() { - this.parser = new Parser(); - } - - parse(sql: string): SqlParseResult { + async parse(sql: string): Promise { try { - const ast = this.parser.astify(sql); + const parser = await this.getParser(); + const ast = parser.astify(sql); return { success: true, @@ -99,8 +102,8 @@ export class SqlParser { .trim(); } - validateSql(sql: string): SqlParseError[] { - const result = this.parse(sql); + async validateSql(sql: string): Promise { + const result = await this.parse(sql); return result.errors; } } diff --git a/src/sql/structure-analyzer.ts b/src/sql/structure-analyzer.ts index f99afba..d46bfb3 100644 --- a/src/sql/structure-analyzer.ts +++ b/src/sql/structure-analyzer.ts @@ -36,7 +36,7 @@ export class SqlStructureAnalyzer { /** * Analyzes the document and extracts all SQL statements */ - analyzeDocument(state: EditorState): SqlStatement[] { + async analyzeDocument(state: EditorState): Promise { const content = state.doc.toString(); const cacheKey = this.generateCacheKey(content); @@ -45,7 +45,7 @@ export class SqlStructureAnalyzer { return existingValue; } - const statements = this.extractStatements(content, state); + const statements = await this.extractStatements(content, state); this.cache.set(cacheKey, statements); // Keep cache size reasonable @@ -62,22 +62,26 @@ export class SqlStructureAnalyzer { /** * Gets the SQL statement at a specific cursor position */ - getStatementAtPosition(state: EditorState, position: number): SqlStatement | null { - const statements = this.analyzeDocument(state); + async getStatementAtPosition(state: EditorState, position: number): Promise { + const statements = await this.analyzeDocument(state); return statements.find((stmt) => position >= stmt.from && position <= stmt.to) || null; } /** * Gets all SQL statements that intersect with a selection range */ - getStatementsInRange(state: EditorState, from: number, to: number): SqlStatement[] { - const statements = this.analyzeDocument(state); + async getStatementsInRange( + state: EditorState, + from: number, + to: number, + ): Promise { + const statements = await this.analyzeDocument(state); return statements.filter( (stmt) => stmt.from <= to && stmt.to >= from, // Statements that overlap with the range ); } - private extractStatements(content: string, state: EditorState): SqlStatement[] { + private async extractStatements(content: string, state: EditorState): Promise { const statements: SqlStatement[] = []; // Split content by semicolons to find potential statement boundaries @@ -107,7 +111,7 @@ export class SqlStructureAnalyzer { } // Parse the statement to determine validity and type (use stripped content) - const parseResult = this.parser.parse(strippedContent); + const parseResult = await this.parser.parse(strippedContent); const type = this.determineStatementType(strippedContent); // Remove trailing semicolon from content for cleaner display diff --git a/src/sql/structure-extension.ts b/src/sql/structure-extension.ts index 1ab1458..ae43444 100644 --- a/src/sql/structure-extension.ts +++ b/src/sql/structure-extension.ts @@ -163,7 +163,7 @@ function createSqlGutterMarkers( } function createUpdateListener(analyzer: SqlStructureAnalyzer): Extension { - return EditorView.updateListener.of((update: ViewUpdate) => { + return EditorView.updateListener.of(async (update: ViewUpdate) => { // Update on document changes, selection changes, or focus changes if (!update.docChanged && !update.selectionSet && !update.focusChanged) { return; @@ -174,8 +174,8 @@ function createUpdateListener(analyzer: SqlStructureAnalyzer): Extension { const cursorPosition = main.head; // Analyze the document for SQL statements - const allStatements = analyzer.analyzeDocument(state); - const currentStatement = analyzer.getStatementAtPosition(state, cursorPosition); + const allStatements = await analyzer.analyzeDocument(state); + const currentStatement = await analyzer.getStatementAtPosition(state, cursorPosition); const newState: SqlGutterState = { currentStatement, diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..4fd0882 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,12 @@ +/** + * Lazy evaluation of a function that returns a promise. + */ +export const lazy = (fn: () => Promise) => { + let value: Promise | undefined; + return async () => { + if (!value) { + value = fn(); + } + return await value; + }; +};