Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

144 changes: 143 additions & 1 deletion src/composables/useSqlStatements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions tests/composables/useSqlStatements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Loading