diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8fbb4ac..0689660 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6433,7 +6433,7 @@ dependencies = [ [[package]] name = "sqlkit" -version = "0.8.0" +version = "0.8.1" dependencies = [ "async-trait", "base64 0.22.1", diff --git a/src/composables/useSqlStatements.ts b/src/composables/useSqlStatements.ts index 321e2e7..2ff5181 100644 --- a/src/composables/useSqlStatements.ts +++ b/src/composables/useSqlStatements.ts @@ -265,8 +265,150 @@ function skipLeadingComments(text: string): number { return skipped } +// Keywords that can signal a new statement start after 2+ blank lines +// (when a `;` was forgotten). Covers DML, DDL, TCL, and utility commands. +const SOFT_STATEMENT_KEYWORDS = new Set([ + 'SELECT', + 'CREATE', + 'ALTER', + 'DROP', + 'INSERT', + 'UPDATE', + 'DELETE', + 'TRUNCATE', + 'GRANT', + 'REVOKE', + 'EXPLAIN', + 'SHOW', + 'DESCRIBE', + 'USE', + 'SET', + 'CALL', + 'EXEC', + 'EXECUTE', + 'BEGIN', + 'COMMIT', + 'ROLLBACK', + 'DECLARE', + 'ANALYZE', + 'VACUUM', + 'PRAGMA', + 'REFRESH', + 'COPY', + 'WITH', + 'MERGE', + 'REPLACE', +]) + +/** + * After splitting by `;`, scan each range for 2+ consecutive blank lines + * followed by a SQL keyword. When found, split the range there — this + * recovers from missing `;` between statements separated by blank lines. + */ +function splitRangesAtBlankLines(ranges: StatementRange[], content: string): StatementRange[] { + const result: StatementRange[] = [] + + for (const range of ranges) { + const text = content.slice(range.startOffset, range.endOffset) + const lines = text.split('\n') + + // Pre-compute each line's byte offset within `text` + const lineOffsets: number[] = [] + let off = 0 + for (const line of lines) { + lineOffsets.push(off) + off += line.length + 1 // +1 for \n + } + + let blankRun = 0 + let seenContent = false + let inBlockComment = false + // Track the line index of the last actual SQL content (for split boundaries) + let lastContentLine = -1 + // When the segment starts with `WITH`, suppress soft-split for the next DML + // keyword — it's the main query of a CTE, not a new statement. + let segmentFirstKeyword: string | null = null + const FIRST_DML_KEYWORDS = new Set(['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'MERGE', 'REPLACE']) + // Each split: { sqlLine: the SQL keyword line, prevContentLine: last content before blank run } + const splits: { sqlLine: number, prevContentLine: number }[] = [] + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim() + + // Lines inside a multi-line block comment — skip without resetting blankRun + if (inBlockComment) { + if (trimmed.endsWith('*/')) + inBlockComment = false + continue + } + + if (trimmed.length === 0) { + if (seenContent) + blankRun++ + continue + } + + // Comment-only lines should not reset the blank-run counter, + // so that `SELECT ...\n\n\n-- comment\nSELECT ...` still splits. + if (trimmed.startsWith('--') || trimmed.startsWith('#')) { + continue + } + if (trimmed.startsWith('/*')) { + if (!trimmed.endsWith('*/')) + inBlockComment = true + continue + } + + // Actual SQL content + const firstWord = trimmed.split(/\s+/)[0]?.toUpperCase() + if (!seenContent) { + seenContent = true + segmentFirstKeyword = firstWord ?? null + } + else if (blankRun >= 2 && firstWord && SOFT_STATEMENT_KEYWORDS.has(firstWord)) { + // When the segment starts with WITH, the next DML keyword is the main + // query of the CTE (e.g. `WITH cte AS (...) \n\n\n SELECT ...`). + // Splitting there would orphan the CTE definition. + const isCteMainQuery = segmentFirstKeyword === 'WITH' && firstWord && FIRST_DML_KEYWORDS.has(firstWord) + if (!isCteMainQuery) + splits.push({ sqlLine: i, prevContentLine: lastContentLine }) + } + blankRun = 0 + lastContentLine = i + } + + if (splits.length === 0) { + result.push(range) + continue + } + + // Build sub-ranges. Each preceding segment ends at its last content line + // (excluding trailing blank/comment lines). Each new segment starts at the + // SQL keyword line in `splits[].sqlLine`. + let segStart = range.startOffset + lineOffsets[0] + + for (const { sqlLine, prevContentLine } of splits) { + // End the preceding segment right after `prevContentLine` + const segEnd = range.startOffset + lineOffsets[prevContentLine] + lines[prevContentLine].length + result.push(buildRange(content, segStart, segEnd)) + segStart = range.startOffset + lineOffsets[sqlLine] + } + + // Final segment (trim leading whitespace) + const rawFinal = content.slice(segStart, range.endOffset) + const finalTrimmed = rawFinal.trimStart() + if (finalTrimmed.length > 0) { + const skipped = rawFinal.length - finalTrimmed.length + result.push(buildRange(content, segStart + skipped, range.endOffset)) + } + } + + return result.filter(r => r.text.trim().length > 0) +} + export function parseSqlStatements(content: string): SqlStatement[] { - const ranges = scanStatements(content) + const rawRanges = scanStatements(content) + const ranges = splitRangesAtBlankLines(rawRanges, content) const lines = content.split('\n') return ranges diff --git a/tests/composables/useSqlStatements.test.ts b/tests/composables/useSqlStatements.test.ts index c672b03..dd734d1 100644 --- a/tests/composables/useSqlStatements.test.ts +++ b/tests/composables/useSqlStatements.test.ts @@ -162,6 +162,129 @@ describe('parseSqlStatements', () => { }) }) + describe('soft split at blank lines (missing semicolon)', () => { + it('splits two statements separated by 2 blank lines', () => { + const sql = [ + 'SELECT 1', // line 1 + '', // line 2 (blank) + '', // line 3 (blank) + 'SELECT 2', // line 4 + ].join('\n') + + const result = parseSqlStatements(sql) + expect(result).toHaveLength(2) + expect(result[0].statement).toBe('SELECT 1') + expect(result[1].statement).toBe('SELECT 2') + }) + + it('does not split on only 1 blank line', () => { + const sql = 'SELECT 1\n\nSELECT 2' + const result = parseSqlStatements(sql) + expect(result).toHaveLength(1) + expect(result[0].statement).toContain('SELECT 1') + expect(result[0].statement).toContain('SELECT 2') + }) + + it('splits 3 statements separated by 2 blank lines each', () => { + const sql = [ + 'SELECT 1', // line 1 + '', // line 2 + '', // line 3 + 'SELECT 2', // line 4 + '', // line 5 + '', // line 6 + 'SELECT 3', // line 7 + ].join('\n') + + const result = parseSqlStatements(sql) + expect(result).toHaveLength(3) + expect(result[0].statement).toBe('SELECT 1') + expect(result[1].statement).toBe('SELECT 2') + expect(result[2].statement).toBe('SELECT 3') + }) + + it('does not split when next line after 2 blanks is not a SQL keyword', () => { + const sql = [ + 'SELECT 1', // line 1 + '', // line 2 + '', // line 3 + ' some_column', // line 4 (not a keyword) + ].join('\n') + + const result = parseSqlStatements(sql) + expect(result).toHaveLength(1) + }) + + it('splits when 2 blank lines are followed by a comment then SQL keyword', () => { + const sql = [ + 'SELECT 1', // line 1 + '', // line 2 + '', // line 3 + '', // line 4 + '-- comment', // line 5 + 'SELECT 2', // line 6 + ].join('\n') + + const result = parseSqlStatements(sql) + expect(result).toHaveLength(2) + expect(result[0].statement).toBe('SELECT 1') + expect(result[1].statement).toBe('SELECT 2') + expect(result[1].position.startLineNumber).toBe(6) + }) + + it('does not split CTE with blank lines before main SELECT', () => { + const sql = [ + 'WITH cte AS (', // line 1 + ' SELECT 1', // line 2 + ')', // line 3 + '', // line 4 + '', // line 5 + 'SELECT * FROM cte', // line 6 + ].join('\n') + + const result = parseSqlStatements(sql) + expect(result).toHaveLength(1) + expect(result[0].statement).toContain('WITH cte AS') + expect(result[0].statement).toContain('SELECT * FROM cte') + }) + + it('does not split CTE INSERT with blank lines before INSERT', () => { + const sql = [ + 'WITH cte AS (', // line 1 + ' SELECT 1', // line 2 + ')', // line 3 + '', // line 4 + '', // line 5 + 'INSERT INTO t', // line 6 + 'SELECT * FROM cte', // line 7 + ].join('\n') + + const result = parseSqlStatements(sql) + expect(result).toHaveLength(1) + expect(result[0].statement).toContain('WITH cte AS') + expect(result[0].statement).toContain('INSERT INTO t') + }) + + it('sets correct positions for soft-split statements', () => { + const sql = 'SELECT 1\n\n\nSELECT 2' + const result = parseSqlStatements(sql) + expect(result).toHaveLength(2) + expect(result[0].position.startLineNumber).toBe(1) + expect(result[0].position.endLineNumber).toBe(1) + expect(result[1].position.startLineNumber).toBe(4) + expect(result[1].position.endLineNumber).toBe(4) + }) + + it('handles mixed ; and blank-line splits', () => { + const sql = 'SELECT 1;\nSELECT 2\n\n\nSELECT 3' + const result = parseSqlStatements(sql) + expect(result).toHaveLength(3) + expect(result[0].statement).toBe('SELECT 1') + expect(result[1].statement).toBe('SELECT 2') + expect(result[2].statement).toBe('SELECT 3') + }) + }) + describe('nested parens (subqueries)', () => { it('does not split on SELECT inside a subquery', () => { const sql = [