Skip to content
Merged
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
98 changes: 97 additions & 1 deletion src/sql-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 35 additions & 2 deletions tests/sql-converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &amp; EXCA XCMG');"

const result = convertMariaDbToSqlite(input)

expect(result.sql.trim()).toBe(
'INSERT INTO "expenses" ("description") VALUES (\'PENGANTARAN SOLAR EXCA SANY 01 &amp; 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 &amp; 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 &amp; EXCA XCMG');",
)
})

Expand Down Expand Up @@ -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 &amp; 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 &amp; EXCA XCMG');",
)
})

test('preserves literal backticks inside JSON string values', () => {
const input = `INSERT INTO \`activity_logs\` (\`model_value_changed\`) VALUES ('{\\"note\\":\\"\`\\",\\"uuid\\":\\"019cab37\\"}');`

Expand Down