From ba3ae6c90e4d5380eba224a93a9fe7ac3c3335c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=8D=95?= Date: Tue, 28 Apr 2026 03:55:23 +0800 Subject: [PATCH] Splits multi-row inserts with semicolons Adds logic to split multi-row INSERT statements when a semicolon appears within a single-quoted string value. This change ensures that semicolons within string literals do not incorrectly terminate the SQL statement, allowing for proper conversion of multi-row inserts. It also refactors how apostrophes are escaped within insert statements to use standard SQLite escaping. --- src/sql-converter.ts | 98 ++++++++++++++++++++++++++++++++++++- tests/sql-converter.test.ts | 37 +++++++++++++- 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/src/sql-converter.ts b/src/sql-converter.ts index 3256c28..c702355 100644 --- a/src/sql-converter.ts +++ b/src/sql-converter.ts @@ -203,7 +203,16 @@ function convertStatement( return convertCreateTable(trimmed, context) } - return convertGeneralStatement(trimmed) + const converted = convertGeneralStatement(trimmed) + + if ( + /^INSERT\b/i.test(converted) && + hasSemicolonInsideSingleQuotedString(converted) + ) { + return splitMultiRowInsert(converted) + } + + return converted } function convertCreateTable( @@ -476,6 +485,93 @@ function convertGeneralStatement(statement: string): string { .trim() } +function hasSemicolonInsideSingleQuotedString(statement: string): boolean { + let inSingleQuote = false + + for (let index = 0; index < statement.length; index += 1) { + const char = statement[index] + const next = statement[index + 1] + + if (!inSingleQuote) { + if (char === "'") { + inSingleQuote = true + } + continue + } + + if (char === "'" && next === "'") { + index += 1 + continue + } + + if (char === "'") { + inSingleQuote = false + continue + } + + if (char === ';') { + return true + } + } + + return false +} + +function splitMultiRowInsert(statement: string): string { + const valuesKeywordIndex = findValuesKeywordOutsideQuotes(statement) + + if (valuesKeywordIndex === -1) { + return statement + } + + const prefix = statement + .slice(0, valuesKeywordIndex + 'VALUES'.length) + .trimEnd() + const values = statement.slice(valuesKeywordIndex + 'VALUES'.length).trim() + const valuesWithoutSemicolon = values.endsWith(';') + ? values.slice(0, -1) + : values + const rows = splitCommaSeparated(valuesWithoutSemicolon) + + if (rows.length <= 1 || !rows.every(row => row.startsWith('('))) { + return statement + } + + return rows.map(row => `${prefix} ${row}`).join(';\n') +} + +function findValuesKeywordOutsideQuotes(statement: string): number { + let quote: "'" | '"' | '`' | null = null + + for (let index = 0; index < statement.length; index += 1) { + const char = statement[index] + const next = statement[index + 1] + + if (quote) { + if (char === "'" && quote === "'" && next === "'") { + index += 1 + continue + } + + if (char === quote) { + quote = null + } + continue + } + + if (char === "'" || char === '"' || char === '`') { + quote = char + continue + } + + if (/^VALUES\b/i.test(statement.slice(index))) { + return index + } + } + + return -1 +} + function extractIndexes( definitions: string[], tableName: string, diff --git a/tests/sql-converter.test.ts b/tests/sql-converter.test.ts index 59607d3..14dfae3 100644 --- a/tests/sql-converter.test.ts +++ b/tests/sql-converter.test.ts @@ -40,14 +40,36 @@ describe('convertMariaDbToSqlite', () => { expect(result.warnings).toHaveLength(1) }) - test('preserves inserts with semicolons inside strings', () => { + test('splits multi-row inserts with semicolons inside strings', () => { const input = "INSERT INTO `notes` (`body`) VALUES ('one; two'), ('three');" const result = convertMariaDbToSqlite(input) expect(result.sql.trim()).toBe( - 'INSERT INTO "notes" ("body") VALUES (\'one; two\'), (\'three\');', + 'INSERT INTO "notes" ("body") VALUES (\'one; two\');\nINSERT INTO "notes" ("body") VALUES (\'three\');', + ) + }) + + test('preserves single-row inserts with html entity semicolons', () => { + const input = + "INSERT INTO `expenses` (`description`) VALUES ('PENGANTARAN SOLAR EXCA SANY 01 & EXCA XCMG');" + + const result = convertMariaDbToSqlite(input) + + expect(result.sql.trim()).toBe( + 'INSERT INTO "expenses" ("description") VALUES (\'PENGANTARAN SOLAR EXCA SANY 01 & EXCA XCMG\');', + ) + }) + + test('splits risky multi-row inserts with apostrophes before semicolon values', () => { + const input = + "INSERT INTO `rent_item_rents` VALUES ('BIKIN JALAN DI KEBUN PAK MU\\'MIN'),('PENGANTARAN SOLAR EXCA SANY 01 & EXCA XCMG');" + + const result = convertMariaDbToSqlite(input) + + expect(result.sql.trim()).toBe( + "INSERT INTO \"rent_item_rents\" VALUES ('BIKIN JALAN DI KEBUN PAK MU''MIN');\nINSERT INTO \"rent_item_rents\" VALUES ('PENGANTARAN SOLAR EXCA SANY 01 & EXCA XCMG');", ) }) @@ -190,6 +212,17 @@ describe('convertMariaDbToSqlite', () => { expect(result.sql).not.toContain("\\'") }) + test('uses standard sqlite quote escaping for apostrophes in insert values', () => { + const input = + "INSERT INTO `rent_item_rents` VALUES ('BIKIN JALAN DI KEBUN PAK MU\\'MIN','PENGANTARAN SOLAR EXCA SANY 01 & EXCA XCMG');" + + const result = convertMariaDbToSqlite(input) + + expect(result.sql.trim()).toBe( + "INSERT INTO \"rent_item_rents\" VALUES ('BIKIN JALAN DI KEBUN PAK MU''MIN','PENGANTARAN SOLAR EXCA SANY 01 & EXCA XCMG');", + ) + }) + test('preserves literal backticks inside JSON string values', () => { const input = `INSERT INTO \`activity_logs\` (\`model_value_changed\`) VALUES ('{\\"note\\":\\"\`\\",\\"uuid\\":\\"019cab37\\"}');`