From c388ac1704b8a3036ae596a998eccb4d28f75898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=8D=95?= Date: Tue, 28 Apr 2026 18:02:52 +0800 Subject: [PATCH 1/5] Revert multi-row insert splitting logic Removes the logic that split multi-row INSERT statements into separate statements. This logic was intended to handle cases where semicolons might appear within quoted strings, but it incorrectly split valid multi-row inserts. The tests have been updated to reflect the expected behavior of preserving multi-row inserts as single statements, even when they contain semicolons within quoted values. --- src/sql-converter.ts | 98 +------------------------------------ tests/sql-converter.test.ts | 20 ++++---- 2 files changed, 10 insertions(+), 108 deletions(-) diff --git a/src/sql-converter.ts b/src/sql-converter.ts index c702355..3256c28 100644 --- a/src/sql-converter.ts +++ b/src/sql-converter.ts @@ -203,16 +203,7 @@ function convertStatement( return convertCreateTable(trimmed, context) } - const converted = convertGeneralStatement(trimmed) - - if ( - /^INSERT\b/i.test(converted) && - hasSemicolonInsideSingleQuotedString(converted) - ) { - return splitMultiRowInsert(converted) - } - - return converted + return convertGeneralStatement(trimmed) } function convertCreateTable( @@ -485,93 +476,6 @@ 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 b68f0b5..fce23ac 100644 --- a/tests/sql-converter.test.ts +++ b/tests/sql-converter.test.ts @@ -40,14 +40,14 @@ describe('convertMariaDbToSqlite', () => { expect(result.warnings).toHaveLength(1) }) - test('splits multi-row inserts with semicolons inside strings', () => { + test('preserves 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\');\nINSERT INTO "notes" ("body") VALUES (\'three\');', + 'INSERT INTO "notes" ("body") VALUES (\'one; two\'), (\'three\');', ) }) @@ -62,18 +62,18 @@ describe('convertMariaDbToSqlite', () => { ) }) - test('splits risky multi-row inserts with apostrophes before semicolon values', () => { + test('preserves 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');", + "INSERT INTO \"rent_item_rents\" VALUES ('BIKIN JALAN DI KEBUN PAK MU''MIN'),('PENGANTARAN SOLAR EXCA SANY 01 & EXCA XCMG');", ) }) - test('splits multi-row inserts when semicolon appears many rows after apostrophe escaping', () => { + test('preserves multi-row inserts when semicolon appears many rows after apostrophe escaping', () => { const middleRows = Array.from( { length: 150 }, (_, index) => `('middle row ${index + 1}')`, @@ -85,14 +85,12 @@ describe('convertMariaDbToSqlite', () => { ].join(',') const result = convertMariaDbToSqlite(`${input};`) - const statements = result.sql.trim().split('\n') - expect(statements).toHaveLength(152) - expect(statements[0]).toBe( - "INSERT INTO \"rent_item_rents\" VALUES ('BIKIN JALAN DI KEBUN PAK MU''MIN');", + expect(result.sql.trim()).toContain( + "INSERT INTO \"rent_item_rents\" VALUES ('BIKIN JALAN DI KEBUN PAK MU''MIN'),('middle row 1')", ) - expect(statements[151]).toBe( - 'INSERT INTO "rent_item_rents" VALUES (\'PENGANTARAN SOLAR EXCA SANY 01 & EXCA XCMG\');', + expect(result.sql.trim()).toContain( + "('middle row 150'),('PENGANTARAN SOLAR EXCA SANY 01 & EXCA XCMG');", ) }) From e2bc919a509bd8e6917be15830de5f255bc1761e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=8D=95?= Date: Tue, 28 Apr 2026 18:03:04 +0800 Subject: [PATCH 2/5] Adds SQL conversion example section Introduces a new section to showcase an example of converting MariaDB/MySQL dump SQL to SQLite SQL. This section visually demonstrates the converter's capability by displaying side-by-side code snippets of the input and output SQL. The styling for this new section has also been added to ensure proper presentation. --- src/app.tsx | 40 +++++++++++++++++++++++++++++++++ src/styles.css | 61 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/app.tsx b/src/app.tsx index 4e7ff46..30ceee4 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -355,6 +355,46 @@ export default function App() { +
+
+

Example conversion

+

MariaDB dump SQL into SQLite SQL

+

+ The converter keeps your data and rewrites common dump + syntax so SQLite can import it. +

+
+ +
+
+

MariaDB / MySQL input

+
+                            {`CREATE TABLE \`users\` (
+  \`id\` bigint unsigned NOT NULL AUTO_INCREMENT,
+  \`name\` varchar(255) NOT NULL,
+  PRIMARY KEY (\`id\`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO \`users\` VALUES
+(1,'O\\'Connor');`}
+                        
+
+ +
+

SQLite output

+
+                            {`CREATE TABLE "users" (
+  "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+  "name" TEXT NOT NULL
+);
+
+INSERT INTO "users" VALUES
+(1,'O''Connor');`}
+                        
+
+
+
+ {warnings.length > 0 ? (
Date: Tue, 28 Apr 2026 18:03:47 +0800 Subject: [PATCH 3/5] Bump version to 0.0.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6770de1..3c7513e 100644 --- a/package.json +++ b/package.json @@ -43,5 +43,5 @@ "lint": "knip && biome check --write . && tsc --noEmit" }, "type": "module", - "version": "0.0.2" + "version": "0.0.3" } From 7a51a46d5df8b14e3c16889c1363c0bf55b971a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=8D=95?= Date: Tue, 28 Apr 2026 18:14:59 +0800 Subject: [PATCH 4/5] Refactor README for clarity and detail Updates the README to provide a more comprehensive explanation of the SQL Converter's purpose, features, and limitations. - Enhances the "Why This Exists" section to better articulate the need for the tool. - Clarifies privacy aspects, emphasizing local execution and data security. - Updates browser requirements and usage instructions. - Provides a clearer example of input and output SQL. - Expands the "Supported Conversion Scope" and "Known Limits" sections for better user understanding. - Restructures local development, import, and reporting issue sections. --- README.md | 162 +++++++++++++++++++++++++++++------------------------- 1 file changed, 87 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 6299b49..c879dac 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,79 @@ # SQL Converter -SQL Converter is a browser-side tool for converting MariaDB/MySQL `.sql` dump files into SQLite-compatible SQL. +SQL Converter converts MariaDB/MySQL `.sql` dump files into SQLite-compatible SQL in the browser. Live app: https://dominosaurs.github.io/sql-converter/ -## What It Does +## ✨ Why This Exists -- Converts MariaDB/MySQL dump syntax into SQLite-oriented SQL. -- Streams large input files directly from the browser. -- Writes the converted output to a local file without uploading database data. -- Handles common dump features such as data type conversion, quoted identifiers, auto-increment primary keys, indexes, foreign keys, JSON checks, dump directives, and MySQL string escapes. +MariaDB and MySQL dumps often contain syntax that SQLite cannot import directly: backtick identifiers, `AUTO_INCREMENT`, engine options, charset/collation options, MariaDB dump directives, and MySQL-style escaped strings. -## Browser Requirement +SQL Converter rewrites the common parts of those dumps so the output can be imported into SQLite with fewer manual edits. -Large-file conversion uses the File System Access API so the app can stream output directly to disk. +## 🔒 Privacy -Use a browser that supports `showSaveFilePicker`, such as: +Conversion runs locally in your browser. + +- The input dump is read from your disk. +- The converted file is written back to your disk. +- The app does not upload your database dump to a server. + +## 🌐 Browser Requirement + +Large-file conversion uses the File System Access API so output can be streamed directly to disk. + +Recommended browsers: - Chrome - Edge -Firefox and Safari may not support the required direct-to-disk streaming flow. +Firefox and Safari may not support the required `showSaveFilePicker` API for large-file direct-to-disk conversion. + +## 🚀 Usage + +1. Open https://dominosaurs.github.io/sql-converter/ +2. Select a MariaDB/MySQL `.sql` dump file. +3. Choose where to save the converted SQLite SQL file. +4. Wait for conversion to finish. +5. Import the generated `.sqlite.sql` file with your SQLite client. + +## 🔁 Example + +MariaDB/MySQL dump input: + +```sql +CREATE TABLE `users` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO `users` VALUES +(1,'O\'Connor'); +``` -## Supported Conversion Scope +SQLite output: -The converter is designed for common `mysqldump` / MariaDB dump files. +```sql +CREATE TABLE "users" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL +); + +INSERT INTO "users" VALUES +(1,'O''Connor'); +``` + +## ✅ Supported Conversion Scope + +The converter is designed for common `mysqldump`, MariaDB dump, and phpMyAdmin-style SQL exports. Currently handled: - `CREATE TABLE` - `DROP TABLE` - `INSERT` +- Backtick identifiers to SQLite double-quoted identifiers - MariaDB/MySQL integer, decimal, text, blob, date/time, enum/set, and JSON-like column types - `AUTO_INCREMENT` to SQLite `INTEGER PRIMARY KEY AUTOINCREMENT` - Table-level primary keys @@ -42,87 +85,56 @@ Currently handled: - MySQL escaped string values in inserts - MySQL hex literals like `0xDEADBEEF` -Known limits: +Generated string values use standard SQLite single-quoted strings: -- This is not a full SQL parser. -- Stored procedures, triggers, views, generated columns, and complex vendor-specific SQL may need more handling. -- Memory use is bounded by chunks and completed statements, but a single extremely large SQL statement can still be expensive. +```sql +'O''Connor' +``` -## Usage +Multi-row inserts are preserved for performance. -1. Open https://dominosaurs.github.io/sql-converter/ -2. Select a MariaDB/MySQL `.sql` dump file. -3. Choose the output file location when prompted. -4. Wait for conversion to finish. -5. Import the generated `.sqlite.sql` file with your SQLite client. +## ⚠️ Known Limits -## Local Development +This is a practical dump converter, not a full SQL parser or validator. -This project uses Bun. +Known limits: -```bash -bun install -bun run dev -``` +- Stored procedures are not supported. +- Triggers and views may need manual review. +- Generated columns may need manual review. +- Vendor-specific functions may need manual review. +- A single extremely large SQL statement can still be expensive, even though file reading/writing is streamed. +- SQLite client/importer behavior varies. Some GUI tools may have script execution bugs even when the generated SQL is valid SQLite. -Run checks: +## 📥 Import Notes -```bash -bun run lint -bun test -bun run build -``` +If your SQLite GUI client fails to import a generated file, test the failing statement with another importer before assuming the SQL is invalid. -Preview production build: +Recommended SQLite CLI import: ```bash -bun run build -bun run preview +sqlite3 output.sqlite < converted.sqlite.sql ``` -## Scripts +Or inside SQLite: -- `bun run dev` starts the Vite dev server. -- `bun run build` type-checks and builds the app. -- `bun run lint` runs Knip, Biome, and TypeScript checks. -- `bun run format` applies Biome formatting/fixes. -- `bun test` runs converter tests. -- `bun run preview` previews the production build. +```sql +.read converted.sqlite.sql +``` -## Deployment +## 🛠️ Local Development -The app is configured for GitHub Pages at: +This project uses Bun. -```text -https://dominosaurs.github.io/sql-converter/ +```bash +bun install +bun run dev ``` -Vite uses: +Run checks: -```ts -base: '/sql-converter/' +```bash +bun run lint +bun test +bun run build ``` - -GitHub Actions workflows: - -- `.github/workflows/ci.yml` runs lint, tests, and build. -- `.github/workflows/deploy-pages.yml` builds and deploys `dist` to GitHub Pages. - -In GitHub repository settings, configure Pages source as **GitHub Actions**. - -## Reporting Issues - -If a dump fails to import after conversion, open an issue: - -https://github.com/dominosaurs/sql-converter/issues - -Include: - -- The SQLite error message. -- The source SQL statement that caused it. -- The converted output statement. -- Whether the dump came from MariaDB, MySQL, phpMyAdmin, or another tool. - -## License - -MIT From 86daa1c2673509845fada1c54c2b81debca5e23e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=8D=95?= Date: Tue, 28 Apr 2026 18:20:58 +0800 Subject: [PATCH 5/5] Detect and disable unsupported browsers Detects if the browser supports the File System Access API, which is required for streaming large files directly to disk. If the browser is not supported, the UI is updated to reflect this limitation. The file drop area and convert button are disabled, and the status message is updated to inform the user. This prevents users from attempting conversions that are known to fail in their current browser, improving the user experience and preventing errors. --- src/app.tsx | 67 +++++++++++++++++++++++++++++++++++++++----------- src/styles.css | 5 ++++ 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index 30ceee4..1855564 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -36,22 +36,35 @@ type ConversionState = | 'error' | 'cancelled' +const DEFAULT_STATUS = + 'Choose a MariaDB .sql dump, then convert it to a SQLite import file.' +const UNSUPPORTED_BROWSER_STATUS = + 'This browser cannot stream directly to disk. Use Chrome or Edge before selecting a large SQL file.' + export default function App() { const inputFileRef = useRef(null) const abortControllerRef = useRef(null) + const isBrowserSupported = supportsSaveFilePicker() const [warnings, setWarnings] = useState([]) const [progress, setProgress] = useState(null) const [selectedFileName, setSelectedFileName] = useState('') const [selectedFileSize, setSelectedFileSize] = useState(0) const [outputFileName, setOutputFileName] = useState('') - const [status, setStatus] = useState( - 'Choose a MariaDB .sql dump, then convert it to a SQLite import file.', + const [status, setStatus] = useState(() => + isBrowserSupported ? DEFAULT_STATUS : UNSUPPORTED_BROWSER_STATUS, + ) + const [conversionState, setConversionState] = useState( + () => (isBrowserSupported ? 'idle' : 'error'), ) - const [conversionState, setConversionState] = - useState('idle') const [isConverting, setIsConverting] = useState(false) const handleConvertClick = () => { + if (!isBrowserSupported) { + setStatus(UNSUPPORTED_BROWSER_STATUS) + setConversionState('error') + return + } + const file = inputFileRef.current?.files?.[0] if (!file) { @@ -63,6 +76,12 @@ export default function App() { } const handleFileChange = (file: File | undefined) => { + if (!isBrowserSupported) { + setStatus(UNSUPPORTED_BROWSER_STATUS) + setConversionState('error') + return + } + setSelectedFileName(file?.name ?? '') setSelectedFileSize(file?.size ?? 0) @@ -78,9 +97,7 @@ export default function App() { .showSaveFilePicker if (!saveFilePicker) { - setStatus( - 'This browser cannot stream directly to disk. Use Chrome or Edge for large 1GB files.', - ) + setStatus(UNSUPPORTED_BROWSER_STATUS) setConversionState('error') return } @@ -168,9 +185,9 @@ export default function App() { setSelectedFileSize(0) setOutputFileName('') setStatus( - 'Choose a MariaDB .sql dump, then convert it to a SQLite import file.', + isBrowserSupported ? DEFAULT_STATUS : UNSUPPORTED_BROWSER_STATUS, ) - setConversionState('idle') + setConversionState(isBrowserSupported ? 'idle' : 'error') } const isFinished = @@ -212,10 +229,12 @@ export default function App() {

-