From 51f78d5de4a7f502a08dce061cc3644c838fd7c8 Mon Sep 17 00:00:00 2001 From: blankll Date: Tue, 28 Jul 2026 18:30:10 +0800 Subject: [PATCH 01/10] feat: soft-split SQL statements at 2+ blank lines when ; is missing When statements are separated by 2+ blank lines followed by a SQL keyword (SELECT, INSERT, CREATE, etc.), automatically split them into separate statements even without a ; delimiter. This recovers gracefully from forgotten semicolons between queries. --- src/composables/useSqlStatements.ts | 109 ++++++++++++++++++++- tests/composables/useSqlStatements.test.ts | 73 ++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/src/composables/useSqlStatements.ts b/src/composables/useSqlStatements.ts index 321e2e7..6c79691 100644 --- a/src/composables/useSqlStatements.ts +++ b/src/composables/useSqlStatements.ts @@ -265,8 +265,115 @@ 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 + const splitLineIndices: number[] = [] + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim() + + if (trimmed.length === 0) { + if (seenContent) + blankRun++ + } + else { + if (!seenContent) { + seenContent = true + } + else if (blankRun >= 2) { + const firstWord = trimmed.split(/\s+/)[0]?.toUpperCase() + if (firstWord && SOFT_STATEMENT_KEYWORDS.has(firstWord)) + splitLineIndices.push(i) + } + blankRun = 0 + } + } + + if (splitLineIndices.length === 0) { + result.push(range) + continue + } + + let prevIdx = 0 + for (const splitIdx of splitLineIndices) { + const segStart = range.startOffset + lineOffsets[prevIdx] + const segEnd = range.startOffset + lineOffsets[splitIdx] + // Trim trailing whitespace so the preceding range ends at its last content + const segText = content.slice(segStart, segEnd).trimEnd() + if (segText.length > 0) + result.push(buildRange(content, segStart, segStart + segText.length)) + prevIdx = splitIdx + } + // Trim leading whitespace on the final segment + const lastStart = range.startOffset + lineOffsets[prevIdx] + const lastText = content.slice(lastStart, range.endOffset).trimStart() + if (lastText.length > 0) { + const trimmed = lastText.length - lastText.trimStart().length + result.push(buildRange(content, lastStart + trimmed, 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..d4c4985 100644 --- a/tests/composables/useSqlStatements.test.ts +++ b/tests/composables/useSqlStatements.test.ts @@ -162,6 +162,79 @@ 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('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 = [ From e97e6aad29ccef0023005b46ff0139e1a0a1652a Mon Sep 17 00:00:00 2001 From: blankll Date: Tue, 28 Jul 2026 22:32:11 +0800 Subject: [PATCH 02/10] feat: soft-split SQL statements at 2+ blank lines when ; is missing When statements are separated by 2+ blank lines followed by a SQL keyword, auto-split into separate statements even without ; delimiter. Also handles comment lines between blank lines, and suppresses false splits for CTE main queries (WITH ... AS (...) \n\n\n SELECT ...). --- src/composables/useSqlStatements.ts | 89 +++++++++++++++------- tests/composables/useSqlStatements.test.ts | 50 ++++++++++++ 2 files changed, 112 insertions(+), 27 deletions(-) diff --git a/src/composables/useSqlStatements.ts b/src/composables/useSqlStatements.ts index 6c79691..2ff5181 100644 --- a/src/composables/useSqlStatements.ts +++ b/src/composables/useSqlStatements.ts @@ -322,49 +322,84 @@ function splitRangesAtBlankLines(ranges: StatementRange[], content: string): Sta let blankRun = 0 let seenContent = false - const splitLineIndices: number[] = [] + 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 } - else { - if (!seenContent) { - seenContent = true - } - else if (blankRun >= 2) { - const firstWord = trimmed.split(/\s+/)[0]?.toUpperCase() - if (firstWord && SOFT_STATEMENT_KEYWORDS.has(firstWord)) - splitLineIndices.push(i) - } - blankRun = 0 + + // 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 (splitLineIndices.length === 0) { + if (splits.length === 0) { result.push(range) continue } - let prevIdx = 0 - for (const splitIdx of splitLineIndices) { - const segStart = range.startOffset + lineOffsets[prevIdx] - const segEnd = range.startOffset + lineOffsets[splitIdx] - // Trim trailing whitespace so the preceding range ends at its last content - const segText = content.slice(segStart, segEnd).trimEnd() - if (segText.length > 0) - result.push(buildRange(content, segStart, segStart + segText.length)) - prevIdx = splitIdx + // 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] } - // Trim leading whitespace on the final segment - const lastStart = range.startOffset + lineOffsets[prevIdx] - const lastText = content.slice(lastStart, range.endOffset).trimStart() - if (lastText.length > 0) { - const trimmed = lastText.length - lastText.trimStart().length - result.push(buildRange(content, lastStart + trimmed, range.endOffset)) + + // 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)) } } diff --git a/tests/composables/useSqlStatements.test.ts b/tests/composables/useSqlStatements.test.ts index d4c4985..dd734d1 100644 --- a/tests/composables/useSqlStatements.test.ts +++ b/tests/composables/useSqlStatements.test.ts @@ -215,6 +215,56 @@ describe('parseSqlStatements', () => { 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) From 7fc892ee37d3f4cec38184a320ed30336951fac5 Mon Sep 17 00:00:00 2001 From: blankll Date: Tue, 28 Jul 2026 22:37:25 +0800 Subject: [PATCH 03/10] chore: sync version --- src-tauri/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 9bd125061a55905c51dbe5fd47c83933a0107a1a Mon Sep 17 00:00:00 2001 From: blankll Date: Wed, 29 Jul 2026 15:49:11 +0800 Subject: [PATCH 04/10] fix: make table view refresh button visible with text label --- src/components/database-browser/DataTableView.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/database-browser/DataTableView.vue b/src/components/database-browser/DataTableView.vue index 6dd8ee2..6f7d16d 100644 --- a/src/components/database-browser/DataTableView.vue +++ b/src/components/database-browser/DataTableView.vue @@ -902,14 +902,15 @@ watch( From 571d87e15a497fbe7c1668bc8b6e93d44b6bbd51 Mon Sep 17 00:00:00 2001 From: blankll Date: Wed, 29 Jul 2026 15:59:01 +0800 Subject: [PATCH 06/10] fix: change refresh button to outline variant so it's visible --- src/components/database-browser/DataTableView.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/database-browser/DataTableView.vue b/src/components/database-browser/DataTableView.vue index 8c4c187..4a2a335 100644 --- a/src/components/database-browser/DataTableView.vue +++ b/src/components/database-browser/DataTableView.vue @@ -902,7 +902,7 @@ watch( + -
-
- {{ t('components.dataTableView.columns') }} -
-
- -
+ +
+ + +
+
+ {{ t('components.dataTableView.columns') }} +
+
+
- +