diff --git a/.gitignore b/.gitignore index e61e7a7e..24b08eb8 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ dist-ssr # Test coverage coverage/ + +# TypeScript build cache +*.tsbuildinfo +.omo/ diff --git a/docs/TRANSFER_DESIGN.md b/docs/TRANSFER_DESIGN.md deleted file mode 100644 index dee1c1b6..00000000 --- a/docs/TRANSFER_DESIGN.md +++ /dev/null @@ -1,2708 +0,0 @@ -# Transfer Feature Design - -## Overview - -The **Transfer** feature replaces the placeholder "Import / Export" page with a comprehensive data transfer system supporting data export/import, database structure generation, SQL file execution, and cross-engine data migration. - -**Feature Name**: Transfer -**Route**: `/transfer` (replaces `/import-export`) -**Sidebar Label**: Transfer - -### Design Principles - -- **Step-based inline wizards** (not modal dialogs) for each operation -- **Streaming/chunked processing** for large datasets (100k+ rows) -- **Progress reporting** via Tauri events for real-time UI feedback -- **Background task system** — all long-running operations run as background tasks; users can navigate away and return to check progress or restore task state (modeled after dockit's pattern) -- **Best-practice defaults** — format options use sensible defaults (comma delimiter, UTF-8, include header, etc.) instead of exposing every knob to the user; advanced users can expand an optional "Advanced" section if needed -- **Cross-platform** file dialogs using Tauri's native dialog plugin -- **All strings use i18n** for internationalization - ---- - -## Information Architecture - -### Top-Level Tabs - -``` -┌────────────────────────────────────────────────────────────────────────────┐ -│ Transfer [🔔 Tasks] │ -├──────────┬──────────┬─────────────┬──────────────┬─────────────────────────┤ -│ Export │ Import │ Structure │ Migration │ │ -├──────────┴──────────┴─────────────┴──────────────┴─────────────────────────┤ -│ │ -│ (Tab content area — wizard steps render here) │ -│ │ -└────────────────────────────────────────────────────────────────────────────┘ -``` - -The **Tasks** button in the top-right opens a slide-out Task Manager panel showing all running and completed transfer tasks. - -| Tab | Purpose | Steps | -|-----|---------|-------| -| **Export** | Export table data to file | 4 steps | -| **Import** | Import file data into a table | 4 steps | -| **Structure** | Generate DDL / Run SQL files | 2 sub-tabs, 2-4 steps each | -| **Migration** | Cross-engine data migration | 5 steps | - ---- - -## Feature Specifications - -### 1. Data Export - -Export data from a table to a file in various formats. - -#### Supported Formats - -| Format | Extension | Crate | Best-Practice Defaults | -|--------|-----------|-------|------------------------| -| CSV | `.csv` | `csv` | Comma delimiter, double-quote, UTF-8, include header, LF line ending | -| JSONL | `.jsonl` | `serde_json` | One JSON object per line, compact, UTF-8, ISO 8601 dates | -| SQL | `.sql` | (built-in) | Auto-filled target table, batch size 1000, include CREATE TABLE | -| Excel | `.xlsx` | `rust_xlsxwriter` | Include header, auto-fit columns, freeze header row | - -> **Design decision**: Format options use best-practice defaults automatically. No per-format options panels are shown. An optional "Advanced Options" expandable section is available for power users who need to override defaults. - -#### Wizard Steps - -**Step 1 — Source Selection** - -``` -┌─ Step 1: Source ──────────────────────────────────────────────────────────┐ -│ │ -│ Connection: [▼ my-postgres-server ] │ -│ Database: [▼ mydb ] │ -│ Schema: [▼ public ] │ -│ │ -│ Table: [▼ users ] │ -│ Columns: ☑ id ☑ name ☑ email ☐ password ☑ created_at │ -│ [Select All] [Deselect All] │ -│ WHERE: [age > 18 ] (optional) │ -│ ORDER BY: [created_at DESC ] (optional) │ -│ LIMIT: [1000 ] (optional) │ -│ │ -│ [Next →] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -**Step 2 — Format Selection** - -``` -┌─ Step 2: Format ──────────────────────────────────────────────────────────┐ -│ │ -│ Format: ○ CSV ○ JSONL ○ SQL ○ Excel │ -│ │ -│ ┌─ Defaults Applied ────────────────────────────────────────────────┐ │ -│ │ ✓ Comma delimiter, double-quote, UTF-8 encoding │ │ -│ │ ✓ Header row included │ │ -│ │ ✓ LF line endings │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ▸ Advanced Options │ -│ ┌─ (expanded, only when clicked) ───────────────────────────────────┐ │ -│ │ Delimiter: [▼ Comma (,) ] │ │ -│ │ Encoding: [▼ UTF-8 ] │ │ -│ │ ☑ Include header row │ │ -│ │ (format-specific overrides shown based on selection) │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ [← Back] [Next →] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -**Step 3 — Preview** - -``` -┌─ Step 3: Preview ─────────────────────────────────────────────────────────┐ -│ │ -│ Source: my-postgres-server / mydb / public.users │ -│ Format: CSV | Rows: 1,000 (estimated) | Columns: 4 │ -│ │ -│ ┌─ Preview (first 10 rows) ─────────────────────────────────────────┐ │ -│ │ id,name,email,created_at │ │ -│ │ 1,"Alice","alice@example.com","2024-01-15T10:30:00Z" │ │ -│ │ 2,"Bob","bob@example.com","2024-01-16T14:22:00Z" │ │ -│ │ 3,"Charlie","charlie@example.com","2024-01-17T09:15:00Z" │ │ -│ │ ... │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Output File: [/Users/me/exports/users.csv ] [Browse...] │ -│ │ -│ [← Back] [Export →] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -**Step 4 — Execution** - -``` -┌─ Step 4: Exporting ───────────────────────────────────────────────────────┐ -│ │ -│ Status: Exporting... │ -│ │ -│ ████████████████████░░░░░░░░░░ 65% │ -│ │ -│ Rows exported: 650 / 1,000 │ -│ Elapsed: 2.3s │ -│ Estimated remaining: 1.2s │ -│ │ -│ [Run in Background] [Cancel] │ -│ │ -│ ── After completion ── │ -│ │ -│ ✓ Export completed successfully │ -│ File: /Users/me/exports/users.csv (45.2 KB) │ -│ Rows exported: 1,000 │ -│ Duration: 3.5s │ -│ │ -│ [Open File] [Open Folder] [Export Again] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -Clicking **Run in Background** detaches the task from the wizard UI and adds it to the Task Manager. The user can navigate away and return later to check progress. - ---- - -### 2. Data Import - -Import data from a file into a database table. - -#### Wizard Steps - -**Step 1 — File Selection** - -``` -┌─ Step 1: Select File ─────────────────────────────────────────────────────┐ -│ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ │ │ -│ │ ┌──────────┐ │ │ -│ │ │ 📄 ↑ │ Drag & drop a file here │ │ -│ │ └──────────┘ or click to browse │ │ -│ │ │ │ -│ │ Supported: CSV, JSONL, SQL, Excel (.xlsx) │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ── After file selected ── │ -│ │ -│ File: users_export.csv (45.2 KB) │ -│ Detected Format: CSV │ -│ Detected Encoding: UTF-8 │ -│ Rows (estimated): 1,000 │ -│ │ -│ Parse settings auto-detected. Adjust only if needed: │ -│ ▸ Advanced Parse Options │ -│ ┌─ (expanded, only when clicked) ───────────────────────────────────┐ │ -│ │ Delimiter: [▼ Comma (,) ] (auto-detected) │ │ -│ │ Encoding: [▼ UTF-8 ] (auto-detected) │ │ -│ │ ☑ First row is header │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ [Next →] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -**Step 2 — Target & Column Mapping** - -``` -┌─ Step 2: Target & Mapping ────────────────────────────────────────────────┐ -│ │ -│ Connection: [▼ my-postgres-server ] │ -│ Database: [▼ mydb ] │ -│ Schema: [▼ public ] │ -│ Table: [▼ users ] │ -│ ☐ Create table if not exists │ -│ │ -│ ┌─ Column Mapping ──────────────────────────────────────────────────┐ │ -│ │ │ │ -│ │ Source Column → Target Column Type Status │ │ -│ │ ───────────────────────────────────────────────────────────── │ │ -│ │ id → [▼ id ] INTEGER ✓ Mapped │ │ -│ │ name → [▼ name ] VARCHAR(255) ✓ Mapped │ │ -│ │ email → [▼ email ] VARCHAR(255) ✓ Mapped │ │ -│ │ created_at → [▼ created_at ] TIMESTAMP ✓ Mapped │ │ -│ │ phone → [▼ (skip) ] — ⊘ Skipped │ │ -│ │ │ │ -│ │ [Auto-Map by Name] [Clear All] │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ [← Back] [Next →] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -**Step 3 — Options & Preview** - -``` -┌─ Step 3: Options & Preview ───────────────────────────────────────────────┐ -│ │ -│ ┌─ Import Options ──────────────────────────────────────────────────┐ │ -│ │ On Conflict: [▼ Skip duplicates ] │ │ -│ │ ├─ Skip duplicates │ │ -│ │ ├─ Replace existing │ │ -│ │ ├─ Update existing (upsert) │ │ -│ │ └─ Abort on error │ │ -│ │ Batch Size: [5000 ] rows per transaction │ │ -│ │ ☐ Truncate table before import │ │ -│ │ ☐ Dry run (validate without inserting) │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ Data Preview (first 5 rows as mapped) ───────────────────────────┐ │ -│ │ id │ name │ email │ created_at │ │ -│ │ ────┼───────────┼─────────────────────┼────────────────────── │ │ -│ │ 1 │ Alice │ alice@example.com │ 2024-01-15 10:30:00 │ │ -│ │ 2 │ Bob │ bob@example.com │ 2024-01-16 14:22:00 │ │ -│ │ 3 │ Charlie │ charlie@ex... │ 2024-01-17 09:15:00 │ │ -│ │ ... │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ [← Back] [Import →] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -> **Simplification**: "Disable indexes during import" option has been removed — the backend automatically handles index optimization for large imports (>10k rows) when the engine supports it. - -**Step 4 — Execution** - -``` -┌─ Step 4: Importing ───────────────────────────────────────────────────────┐ -│ │ -│ Status: Importing... │ -│ │ -│ ████████████████████░░░░░░░░░░ 65% │ -│ │ -│ Rows imported: 650 / 1,000 │ -│ Rows skipped: 3 (duplicates) │ -│ Errors: 0 │ -│ Elapsed: 4.1s │ -│ │ -│ [Run in Background] [Cancel] │ -│ │ -│ ── After completion ── │ -│ │ -│ ✓ Import completed successfully │ -│ Rows imported: 997 | Skipped: 3 | Errors: 0 │ -│ Duration: 6.2s │ -│ │ -│ ┌─ Error Log (if any) ─────────────────────────────────────────────┐ │ -│ │ Row 45: Duplicate key violation on column 'email' │ │ -│ │ Row 102: NULL value for non-nullable column 'name' │ │ -│ │ Row 339: Data truncation on column 'phone' (max 20 chars) │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ [View Table] [Import Again] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -### 3. Structure - -Two sub-tabs: **Generate DDL** and **Run SQL File**. - -``` -┌─ Structure ───────────────────────────────────────────────────────────────┐ -│ ┌──────────────┬────────────────┐ │ -│ │ Generate DDL │ Run SQL File │ │ -│ └──────────────┴────────────────┘ │ -│ (sub-tab content below) │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -#### 3A. Generate DDL - -Generate DDL (Data Definition Language) scripts from existing database objects. - -**Step 1 — Object Selection** - -``` -┌─ Step 1: Select Objects ──────────────────────────────────────────────────┐ -│ │ -│ Connection: [▼ my-postgres-server ] │ -│ Database: [▼ mydb ] │ -│ Schema: [▼ public ] │ -│ │ -│ ┌─ Objects ─────────────────────────────────────────────────────────┐ │ -│ │ ☑ users TABLE 12 columns 1,200 rows │ │ -│ │ ☑ orders TABLE 8 columns 45,000 rows │ │ -│ │ ☐ order_items TABLE 6 columns 120,000 rows │ │ -│ │ ☐ products TABLE 10 columns 500 rows │ │ -│ │ ☑ user_summary_view VIEW 5 columns — │ │ -│ │ │ │ -│ │ [Select All] [Deselect All] [Tables Only] [Views Only] │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ [Next →] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -**Step 2 — DDL Options** - -``` -┌─ Step 2: DDL Options ─────────────────────────────────────────────────────┐ -│ │ -│ Target Engine: [▼ Same as source (PostgreSQL) ] │ -│ │ -│ ┌─ Include ─────────────────────────────────────────────────────────┐ │ -│ │ ☑ CREATE TABLE statements │ │ -│ │ ☑ Primary keys │ │ -│ │ ☑ Foreign keys │ │ -│ │ ☑ Indexes │ │ -│ │ ☑ Constraints (UNIQUE, CHECK, NOT NULL) │ │ -│ │ ☐ Comments / descriptions │ │ -│ │ ☐ Tablespace / storage options │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ Behavior ────────────────────────────────────────────────────────┐ │ -│ │ ☑ Include DROP IF EXISTS before CREATE │ │ -│ │ ☑ Include IF NOT EXISTS on CREATE │ │ -│ │ ☐ Include INSERT DATA (export structure + data) │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ [← Back] [Next →] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -**Step 3 — Preview & Export** - -``` -┌─ Step 3: Preview & Export ────────────────────────────────────────────────┐ -│ │ -│ Objects: 3 selected | Target: PostgreSQL │ -│ │ -│ ┌─ DDL Preview (Monaco editor, read-only) ──────────────────────────┐ │ -│ │ -- Generated by SQLKit on 2024-03-15 │ │ -│ │ -- Source: my-postgres-server / mydb / public │ │ -│ │ │ │ -│ │ DROP TABLE IF EXISTS "users" CASCADE; │ │ -│ │ CREATE TABLE IF NOT EXISTS "users" ( │ │ -│ │ "id" SERIAL PRIMARY KEY, │ │ -│ │ "name" VARCHAR(255) NOT NULL, │ │ -│ │ "email" VARCHAR(255) NOT NULL UNIQUE, │ │ -│ │ "created_at" TIMESTAMP WITH TIME ZONE DEFAULT NOW() │ │ -│ │ ); │ │ -│ │ │ │ -│ │ CREATE INDEX "idx_users_email" ON "users" ("email"); │ │ -│ │ ... │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ [Copy to Clipboard] [Save to File...] [Execute on Server] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -#### 3B. Run SQL File - -Open and execute SQL files against a database connection. - -**Step 1 — File & Connection** - -``` -┌─ Step 1: Select File & Connection ────────────────────────────────────────┐ -│ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ ┌──────────┐ │ │ -│ │ │ 📄 ↑ │ Drag & drop a .sql file here │ │ -│ │ └──────────┘ or click to browse │ │ -│ │ │ │ -│ │ Supported: .sql files │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -│ │ -│ File: schema_backup.sql (128 KB, 2,400 statements) │ -│ │ -│ Connection: [▼ my-postgres-server ] │ -│ Database: [▼ mydb ] │ -│ │ -│ ┌─ Execution Options ───────────────────────────────────────────────┐ │ -│ │ ☑ Wrap in transaction │ │ -│ │ On Error: [▼ Rollback all ] │ │ -│ │ ├─ Rollback all │ │ -│ │ ├─ Skip and continue │ │ -│ │ └─ Stop execution │ │ -│ │ ☐ Dry run (parse only, don't execute) │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ File Preview (first 50 lines) ───────────────────────────────────┐ │ -│ │ -- Database schema backup │ │ -│ │ CREATE TABLE users ( ... ); │ │ -│ │ CREATE TABLE orders ( ... ); │ │ -│ │ INSERT INTO users VALUES (1, 'Alice', ...); │ │ -│ │ ... │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ [Execute →] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -**Step 2 — Execution & Results** - -``` -┌─ Step 2: Execution ───────────────────────────────────────────────────────┐ -│ │ -│ Status: Executing... │ -│ │ -│ ████████████████████░░░░░░░░░░ 65% │ -│ │ -│ Statements: 1,560 / 2,400 │ -│ Succeeded: 1,558 | Failed: 2 │ -│ Elapsed: 12.4s │ -│ │ -│ [Run in Background] [Cancel] │ -│ │ -│ ── After completion ── │ -│ │ -│ ✓ Execution completed │ -│ Total: 2,400 | Succeeded: 2,398 | Failed: 2 │ -│ Duration: 19.1s │ -│ │ -│ ┌─ Execution Log ──────────────────────────────────────────────────┐ │ -│ │ ✓ Statement 1: CREATE TABLE users — OK │ │ -│ │ ✓ Statement 2: CREATE TABLE orders — OK │ │ -│ │ ✗ Statement 145: INSERT INTO ... — ERROR: duplicate key │ │ -│ │ ✗ Statement 892: ALTER TABLE ... — ERROR: column exists │ │ -│ │ ... │ │ -│ │ [Show Errors Only] [Copy Log] │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ [Run Again] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -### 4. Data Migration - -Cross-engine data migration (e.g., MySQL to PostgreSQL). - -#### Wizard Steps - -**Step 1 — Source Connection** - -``` -┌─ Step 1: Source ──────────────────────────────────────────────────────────┐ -│ │ -│ Source Connection: [▼ my-mysql-server ] │ -│ Database: [▼ production_db ] │ -│ Schema: [▼ (default) ] │ -│ │ -│ Available Tables: │ -│ ☑ users TABLE 12 columns 1,200 rows │ -│ ☑ orders TABLE 8 columns 45,000 rows │ -│ ☑ products TABLE 10 columns 500 rows │ -│ ☐ audit_log TABLE 6 columns 2,000,000 rows │ -│ ☐ temp_data TABLE 3 columns 50 rows │ -│ │ -│ [Select All] [Deselect All] │ -│ │ -│ Selected: 3 tables, ~46,700 rows │ -│ │ -│ [Next →] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -**Step 2 — Target Connection** - -``` -┌─ Step 2: Target ──────────────────────────────────────────────────────────┐ -│ │ -│ Target Connection: [▼ my-postgres-server ] │ -│ Database: [▼ new_production ] │ -│ Schema: [▼ public ] │ -│ │ -│ ☑ Create target tables if not exist │ -│ ☐ Drop target tables before migration │ -│ │ -│ Migration Direction: │ -│ ┌─────────────┐ ┌─────────────┐ │ -│ │ MySQL │ ───→ │ PostgreSQL │ │ -│ │ production │ │ new_prod │ │ -│ │ 3 tables │ │ public │ │ -│ └─────────────┘ └─────────────┘ │ -│ │ -│ [← Back] [Next →] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -**Step 3 — Schema & Type Mapping** - -``` -┌─ Step 3: Schema & Type Mapping ───────────────────────────────────────────┐ -│ │ -│ ┌─ Table: users ────────────────────────────────────────────────────┐ │ -│ │ │ │ -│ │ Source (MySQL) → Target (PostgreSQL) Status │ │ -│ │ ───────────────────────────────────────────────────────────── │ │ -│ │ id INT AUTO_INCREMENT → id SERIAL ✓ Auto │ │ -│ │ name VARCHAR(255) → name VARCHAR(255) ✓ Auto │ │ -│ │ email VARCHAR(255) → email VARCHAR(255) ✓ Auto │ │ -│ │ bio TEXT → bio TEXT ✓ Auto │ │ -│ │ data JSON → data JSONB ⚠ Mapped │ │ -│ │ created DATETIME → created TIMESTAMP ⚠ Mapped │ │ -│ │ active TINYINT(1) → active BOOLEAN ⚠ Mapped │ │ -│ │ │ │ -│ │ [Edit Mapping] [Reset to Auto] │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ Table: orders ───────────────────────────────────────────────────┐ │ -│ │ (similar mapping grid) │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ⚠ 5 columns require type conversion (auto-mapped) │ -│ ✓ 19 columns map directly │ -│ │ -│ [← Back] [Next →] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -**Step 4 — Configure** - -``` -┌─ Step 4: Configure ───────────────────────────────────────────────────────┐ -│ │ -│ ┌─ Migration Options ───────────────────────────────────────────────┐ │ -│ │ Batch Size: [5000 ] rows per batch │ │ -│ │ On Error: [▼ Skip row and continue ] │ │ -│ │ ☑ Migrate indexes │ │ -│ │ ☑ Migrate foreign keys │ │ -│ │ ☑ Migrate constraints │ │ -│ │ ☐ Disable foreign key checks during migration │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ Migration Plan Summary ──────────────────────────────────────────┐ │ -│ │ Source: MySQL (my-mysql-server / production_db) │ │ -│ │ Target: PostgreSQL (my-postgres-server / new_production / public) │ │ -│ │ Tables: 3 │ │ -│ │ Total Rows: ~46,700 │ │ -│ │ Type Conversions: 5 │ │ -│ │ Estimated Time: ~30 seconds │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ [← Back] [Start Migration →] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -**Step 5 — Execution** - -``` -┌─ Step 5: Migrating ───────────────────────────────────────────────────────┐ -│ │ -│ Overall Progress: │ -│ ████████████████░░░░░░░░░░░░░░ 52% │ -│ │ -│ ┌─ Per-Table Progress ──────────────────────────────────────────────┐ │ -│ │ ✓ users 1,200 / 1,200 ████████████████████ 100% 1.2s │ │ -│ │ ● orders 18,500 / 45,000 ████████░░░░░░░░░░░░ 41% 8.3s │ │ -│ │ ○ products 0 / 500 ░░░░░░░░░░░░░░░░░░░░ 0% — │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Phase: Migrating data (2/3 tables) │ -│ Errors: 0 | Elapsed: 9.5s | Remaining: ~8s │ -│ │ -│ [Run in Background] [Cancel] │ -│ │ -│ ── After completion ── │ -│ │ -│ ✓ Migration completed successfully │ -│ │ -│ ┌─ Results ─────────────────────────────────────────────────────────┐ │ -│ │ Table Rows Status Duration │ │ -│ │ ────────────────────────────────────────────── │ │ -│ │ users 1,200 ✓ OK 1.2s │ │ -│ │ orders 45,000 ✓ OK 14.8s │ │ -│ │ products 500 ✓ OK 0.3s │ │ -│ │ ────────────────────────────────────────────── │ │ -│ │ Total 46,700 16.3s │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ [Migrate Again] │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Background Task System - -All long-running transfer operations (export, import, SQL file execution, migration) run as **background tasks**. This allows users to navigate away from the Transfer page and return later to check progress or restore task state. - -**Architecture**: Task management is **frontend-only** (no Rust backend task registry). The Tauri commands block until completion, but the frontend wraps each invocation in an async call tracked by the Pinia store. Progress is synced from Tauri events to the corresponding task entry. - -### Task Lifecycle - -``` -User clicks "Export" / "Import" / etc. - ↓ -Frontend creates BackgroundTask { id, kind, status: 'running', config: snapshot } - ↓ -Frontend calls Tauri command (async, non-blocking from UI perspective) - ↓ -Tauri command emits progress events → store.syncProgressToTask(taskId) - ↓ -User clicks "Run in Background" → store.detachActiveTask(kind) - ↓ -User navigates away (task continues running in store) - ↓ -User opens Task Manager → sees task card with live progress - ↓ -User clicks "Go to task" → router navigates to /transfer?tab=export&taskId=xyz - ↓ -store.openTask(taskId) → restores full form state from task.config - ↓ -Tauri command completes → store.updateTaskStatus(taskId, 'completed') -``` - -### Task Manager Panel - -The Task Manager is a slide-out sidebar panel accessible from the Transfer page header. It shows all running and completed tasks. - -``` -┌─ Task Manager ──────────────────────────────────┐ -│ Tasks (3) [Clear Completed] │ -│ │ -│ ┌─ Export ─────────────────────────────────┐ │ -│ │ 📤 Export users → CSV │ │ -│ │ ● Running │ │ -│ │ ████████████████░░░░ 78% │ │ -│ │ 780 / 1,000 rows │ │ -│ │ Started: 2 min ago │ │ -│ │ [Go to Task →] │ │ -│ └──────────────────────────────────────────┘ │ -│ │ -│ ┌─ Import ─────────────────────────────────┐ │ -│ │ 📥 Import orders.csv │ │ -│ │ ✓ Completed │ │ -│ │ ████████████████████ 100% │ │ -│ │ 45,000 rows | 3 skipped │ │ -│ │ Duration: 12.4s │ │ -│ │ [Dismiss] [Go to Task →] │ │ -│ └──────────────────────────────────────────┘ │ -│ │ -│ ┌─ Migration ──────────────────────────────┐ │ -│ │ 🔄 MySQL → PostgreSQL (3 tables) │ │ -│ │ ✗ Failed │ │ -│ │ ████████████░░░░░░░░ 58% │ │ -│ │ Error: Connection lost to target │ │ -│ │ [Dismiss] [Go to Task →] │ │ -│ └──────────────────────────────────────────┘ │ -│ │ -└──────────────────────────────────────────────────┘ -``` - -**Status colors**: Running = blue, Completed = green, Failed = red, Pending = yellow. - -**Task card actions**: -- **Go to Task**: Navigates to the Transfer page with the task's tab active and restores the full wizard state from the task's config snapshot -- **Dismiss**: Removes the task card (only shown for non-running tasks) -- **Clear Completed**: Removes all completed/failed task cards - -### Task Types - -```typescript -// src/types/transfer.ts (additions) - -export type TaskKind = 'export' | 'import' | 'sqlFile' | 'migration' - -export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' - -export type TaskRuntime = { - complete: number - total: number - skipped: number - errorCount: number -} - -export type ExportTaskConfig = { - connectionId: string - database?: string - schema?: string - table: string - columns: string[] - whereClause?: string - orderBy?: string - limit?: number - format: ExportFormat - outputPath: string -} - -export type ImportTaskConfig = { - connectionId: string - database?: string - schema?: string - table: string - filePath: string - format: ImportFormat - conflictStrategy: ConflictStrategy -} - -export type SqlFileTaskConfig = { - connectionId: string - database?: string - filePath: string - onError: SqlFileErrorStrategy -} - -export type MigrationTaskConfig = { - sourceConnectionId: string - sourceDatabase?: string - targetConnectionId: string - targetDatabase?: string - tables: string[] -} - -export type TaskConfig = ExportTaskConfig | ImportTaskConfig | SqlFileTaskConfig | MigrationTaskConfig - -export type BackgroundTask = { - id: string - kind: TaskKind - status: TaskStatus - progress: { complete: number; total: number } - config: TaskConfig - runtime: TaskRuntime - label: string - startTime: Date - endTime?: Date - error?: string -} -``` - -### Store Task Management Methods - -```typescript -// Added to useTransferStore (see Pinia Store section below) - -// ── Task State ────────────────────────────────── -const runningTasks = ref([]) -const activeExportTaskId = ref(null) -const activeImportTaskId = ref(null) -const activeSqlFileTaskId = ref(null) -const activeMigrationTaskId = ref(null) - -// ── Task Getters ──────────────────────────────── -const taskCount = computed(() => runningTasks.value.length) -const hasRunningTasks = computed(() => - runningTasks.value.some(t => t.status === 'running') -) - -// ── Task Actions ──────────────────────────────── -const addRunningTask = (task: BackgroundTask) => { - runningTasks.value = [...runningTasks.value, task] -} - -const updateTaskRuntime = (taskId: string, runtime: Partial) => { - runningTasks.value = runningTasks.value.map(t => - t.id === taskId - ? { ...t, runtime: { ...t.runtime, ...runtime }, progress: { complete: runtime.complete ?? t.progress.complete, total: runtime.total ?? t.progress.total } } - : t - ) -} - -const updateTaskStatus = (taskId: string, status: TaskStatus, error?: string) => { - runningTasks.value = runningTasks.value.map(t => - t.id === taskId - ? { ...t, status, endTime: status === 'completed' || status === 'failed' ? new Date() : undefined, error } - : t - ) -} - -const removeTask = (taskId: string) => { - runningTasks.value = runningTasks.value.filter(t => t.id !== taskId) -} - -const clearCompletedTasks = () => { - runningTasks.value = runningTasks.value.filter(t => - t.status === 'running' || t.status === 'pending' - ) -} - -const openTask = (taskId: string) => { - const task = runningTasks.value.find(t => t.id === taskId) - if (!task) return - // Restore form state from task.config back to the active wizard - // Implementation depends on task.kind — sets the appropriate step fields - activeTab.value = taskKindToTab(task.kind) - // ... restore config fields to wizard state -} - -const detachActiveTask = (kind: TaskKind) => { - // Clears the active task ID without stopping the operation - // Allows user to navigate away while task continues - switch (kind) { - case 'export': activeExportTaskId.value = null; break - case 'import': activeImportTaskId.value = null; break - case 'sqlFile': activeSqlFileTaskId.value = null; break - case 'migration': activeMigrationTaskId.value = null; break - } -} - -const syncProgressToTask = (taskId: string, progress: TransferProgress) => { - updateTaskRuntime(taskId, { - complete: progress.processedRows, - total: progress.totalRows ?? 0, - skipped: progress.skippedRows, - errorCount: progress.errorCount, - }) -} -``` - -### Task Creation Flow (Export Example) - -```typescript -// In ExportExecuteStep.vue - -import { ulid } from 'ulidx' - -const handleStartExport = async () => { - const taskId = ulid() - const configSnapshot: ExportTaskConfig = { - connectionId: transferStore.exportRequest.connectionId!, - database: transferStore.exportRequest.database, - schema: transferStore.exportRequest.schema, - table: transferStore.exportRequest.source.table, - columns: transferStore.exportRequest.source.columns, - format: transferStore.exportRequest.format!, - outputPath: transferStore.exportRequest.outputPath!, - } - - transferStore.addRunningTask({ - id: taskId, - kind: 'export', - status: 'running', - progress: { complete: 0, total: estimatedRows.value }, - config: configSnapshot, - runtime: { complete: 0, total: estimatedRows.value, skipped: 0, errorCount: 0 }, - label: `Export ${configSnapshot.table} → ${configSnapshot.format.toUpperCase()}`, - startTime: new Date(), - }) - transferStore.activeExportTaskId = taskId - - try { - const result = await executeExport(transferStore.exportRequest as ExportRequest) - transferStore.updateTaskStatus(taskId, 'completed') - transferStore.completeOperation(result) - } catch (err) { - transferStore.updateTaskStatus(taskId, 'failed', String(err)) - } -} -``` - -### Task Manager Navigation - -When the user clicks **"Go to Task"** in the Task Manager, the router navigates with query params: - -```typescript -router.push({ - path: '/transfer', - query: { tab: task.kind, taskId: task.id }, -}) -``` - -The `TransferPage.vue` watches for `taskId` in the route query and calls `transferStore.openTask(taskId)` to restore the wizard to the task's state (showing progress or results). - ---- - -## Cross-Engine Type Mapping Matrix - -The migration feature requires automatic type mapping between database engines. Below is the mapping matrix used for cross-engine translation. - -### PostgreSQL ↔ MySQL - -| PostgreSQL | MySQL | Notes | -|------------|-------|-------| -| `SERIAL` | `INT AUTO_INCREMENT` | Auto-increment PK | -| `BIGSERIAL` | `BIGINT AUTO_INCREMENT` | Large auto-increment PK | -| `SMALLINT` | `SMALLINT` | Direct | -| `INTEGER` | `INT` | Direct | -| `BIGINT` | `BIGINT` | Direct | -| `REAL` | `FLOAT` | Direct | -| `DOUBLE PRECISION` | `DOUBLE` | Direct | -| `NUMERIC(p,s)` | `DECIMAL(p,s)` | Direct | -| `BOOLEAN` | `TINYINT(1)` | MySQL lacks native boolean | -| `VARCHAR(n)` | `VARCHAR(n)` | Direct | -| `TEXT` | `TEXT` / `LONGTEXT` | TEXT if ≤64KB, LONGTEXT otherwise | -| `CHAR(n)` | `CHAR(n)` | Direct | -| `BYTEA` | `LONGBLOB` | Binary data | -| `TIMESTAMP` | `DATETIME` | MySQL DATETIME lacks timezone | -| `TIMESTAMPTZ` | `DATETIME` | Timezone info lost | -| `DATE` | `DATE` | Direct | -| `TIME` | `TIME` | Direct | -| `INTERVAL` | `VARCHAR(255)` | No MySQL equivalent | -| `JSON` | `JSON` | Direct | -| `JSONB` | `JSON` | MySQL lacks binary JSON | -| `UUID` | `CHAR(36)` | MySQL lacks native UUID | -| `INET` | `VARCHAR(45)` | No MySQL equivalent | -| `CIDR` | `VARCHAR(45)` | No MySQL equivalent | -| `MACADDR` | `VARCHAR(17)` | No MySQL equivalent | -| `ARRAY` | `JSON` | MySQL lacks native arrays | -| `POINT` | `POINT` | Spatial type (both support) | - -### PostgreSQL ↔ SQLite - -| PostgreSQL | SQLite | Notes | -|------------|--------|-------| -| `SERIAL` / `BIGSERIAL` | `INTEGER PRIMARY KEY` | SQLite auto-increment via ROWID | -| `SMALLINT` / `INTEGER` / `BIGINT` | `INTEGER` | SQLite has single integer type | -| `REAL` / `DOUBLE PRECISION` | `REAL` | Direct | -| `NUMERIC(p,s)` | `REAL` | SQLite lacks fixed-point | -| `BOOLEAN` | `INTEGER` | 0/1 convention | -| `VARCHAR(n)` / `TEXT` | `TEXT` | SQLite ignores length constraints | -| `BYTEA` | `BLOB` | Direct | -| `TIMESTAMP` / `TIMESTAMPTZ` | `TEXT` | ISO 8601 string | -| `DATE` / `TIME` | `TEXT` | ISO 8601 string | -| `JSON` / `JSONB` | `TEXT` | Plain text storage | -| `UUID` | `TEXT` | 36-char string | - -### PostgreSQL ↔ SQL Server - -| PostgreSQL | SQL Server | Notes | -|------------|------------|-------| -| `SERIAL` | `INT IDENTITY(1,1)` | Auto-increment | -| `BIGSERIAL` | `BIGINT IDENTITY(1,1)` | Large auto-increment | -| `SMALLINT` | `SMALLINT` | Direct | -| `INTEGER` | `INT` | Direct | -| `BIGINT` | `BIGINT` | Direct | -| `REAL` | `REAL` | Direct | -| `DOUBLE PRECISION` | `FLOAT` | Direct | -| `NUMERIC(p,s)` | `DECIMAL(p,s)` | Direct | -| `BOOLEAN` | `BIT` | 0/1 | -| `VARCHAR(n)` | `NVARCHAR(n)` | Unicode by default | -| `TEXT` | `NVARCHAR(MAX)` | Unicode by default | -| `CHAR(n)` | `NCHAR(n)` | Unicode by default | -| `BYTEA` | `VARBINARY(MAX)` | Binary data | -| `TIMESTAMP` | `DATETIME2` | Higher precision | -| `TIMESTAMPTZ` | `DATETIMEOFFSET` | With timezone | -| `DATE` | `DATE` | Direct | -| `TIME` | `TIME` | Direct | -| `JSON` / `JSONB` | `NVARCHAR(MAX)` | SQL Server lacks native JSON type | -| `UUID` | `UNIQUEIDENTIFIER` | Native support | -| `XML` | `XML` | Native support | - -### MySQL ↔ SQL Server - -| MySQL | SQL Server | Notes | -|-------|------------|-------| -| `INT AUTO_INCREMENT` | `INT IDENTITY(1,1)` | Auto-increment | -| `TINYINT(1)` | `BIT` | Boolean | -| `TINYINT` | `TINYINT` | Direct | -| `VARCHAR(n)` | `NVARCHAR(n)` | Unicode | -| `TEXT` | `NVARCHAR(MAX)` | Unicode | -| `LONGTEXT` | `NVARCHAR(MAX)` | Unicode | -| `BLOB` / `LONGBLOB` | `VARBINARY(MAX)` | Binary | -| `DATETIME` | `DATETIME2` | Higher precision | -| `TIMESTAMP` | `DATETIME2` | Meaning differs | -| `JSON` | `NVARCHAR(MAX)` | No native type | -| `ENUM(...)` | `NVARCHAR(255)` + CHECK | No native ENUM | -| `SET(...)` | `NVARCHAR(MAX)` | No native SET | - ---- - -## Backend Architecture - -### New Module Structure - -``` -src-tauri/src/ -├── transfer/ # New module -│ ├── mod.rs # Module exports -│ ├── types.rs # Transfer-specific types -│ ├── defaults.rs # Best-practice default configs per format -│ ├── export.rs # Export logic (CSV, JSONL, SQL, Excel) -│ ├── import.rs # Import logic (parse, validate, insert) -│ ├── ddl.rs # DDL generation trait + implementations -│ ├── migration.rs # Cross-engine migration orchestrator -│ ├── type_mapping.rs # Cross-engine type mapping matrix -│ └── progress.rs # Progress reporting via Tauri events -├── commands/ -│ ├── transfer.rs # New: Tauri commands for transfer -│ └── ... -└── ... -``` - -### Rust Types - -```rust -// transfer/types.rs - -use serde::{Deserialize, Serialize}; - -// ── Export ──────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ExportFormat { - Csv, - Jsonl, - Sql, - Excel, -} - -/// CSV options — all fields have sensible defaults. -/// The frontend sends these only when the user explicitly overrides via "Advanced Options". -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CsvExportOptions { - #[serde(default = "default_comma")] - pub delimiter: char, - #[serde(default = "default_double_quote")] - pub quote_char: char, - #[serde(default = "default_utf8")] - pub encoding: String, - #[serde(default = "default_true")] - pub include_header: bool, - #[serde(default)] - pub quote_all: bool, - #[serde(default = "default_lf")] - pub line_ending: String, -} - -/// JSONL (JSON Lines) options — one JSON object per line, optimized for large datasets. -/// Simpler than JSON: no structure choice, no pretty print (always compact, one line per record). -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct JsonlExportOptions { - #[serde(default = "default_iso8601")] - pub date_format: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SqlExportOptions { - pub target_table: String, - #[serde(default = "default_batch_size")] - pub batch_size: u32, - #[serde(default = "default_true")] - pub include_create_table: bool, - #[serde(default)] - pub include_drop_table: bool, - pub target_engine: Option, -} - -/// Excel options — best-practice defaults, no user customization needed. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ExcelExportOptions { - #[serde(default = "default_sheet_name")] - pub sheet_name: String, - #[serde(default = "default_true")] - pub include_header: bool, - #[serde(default = "default_true")] - pub auto_fit_columns: bool, - #[serde(default = "default_true")] - pub freeze_header: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ExportRequest { - pub connection_id: String, - pub database: Option, - pub schema: Option, - pub source: ExportSource, - pub format: ExportFormat, - pub csv_options: Option, - pub jsonl_options: Option, - pub sql_options: Option, - pub excel_options: Option, - pub output_path: String, -} - -/// Export source is always a table (Custom Query removed for simplicity). -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ExportSource { - pub table: String, - pub columns: Vec, - pub where_clause: Option, - pub order_by: Option, - pub limit: Option, -} - -// ── Import ──────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ImportFormat { - Csv, - Jsonl, - Sql, - Excel, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ColumnMapping { - pub source_column: String, - pub target_column: Option, // None = skip - pub target_type: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ConflictStrategy { - Skip, - Replace, - Upsert, - Abort, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ImportRequest { - pub connection_id: String, - pub database: Option, - pub schema: Option, - pub table: String, - pub file_path: String, - pub format: ImportFormat, - pub column_mappings: Vec, - pub conflict_strategy: ConflictStrategy, - #[serde(default = "default_batch_size")] - pub batch_size: u32, - pub create_table: bool, - pub truncate_before: bool, - pub dry_run: bool, - pub csv_options: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CsvImportOptions { - #[serde(default = "default_comma")] - pub delimiter: char, - #[serde(default = "default_utf8")] - pub encoding: String, - #[serde(default = "default_true")] - pub has_header: bool, -} - -// ── DDL ─────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DdlRequest { - pub connection_id: String, - pub database: Option, - pub schema: Option, - pub tables: Vec, - pub target_engine: Option, - pub options: DdlOptions, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DdlOptions { - pub include_create_table: bool, - pub include_primary_keys: bool, - pub include_foreign_keys: bool, - pub include_indexes: bool, - pub include_constraints: bool, - pub include_comments: bool, - pub include_storage: bool, - pub include_drop_if_exists: bool, - pub include_if_not_exists: bool, - pub include_data: bool, -} - -// ── Run SQL File ────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RunSqlFileRequest { - pub connection_id: String, - pub database: Option, - pub file_path: String, - pub wrap_in_transaction: bool, - pub on_error: SqlFileErrorStrategy, - pub dry_run: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum SqlFileErrorStrategy { - Rollback, - SkipAndContinue, - Stop, -} - -// ── Migration ───────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MigrationMapping { - pub source_column: String, - pub source_type: String, - pub target_column: String, - pub target_type: String, - pub conversion: MigrationConversion, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum MigrationConversion { - Direct, // Types are compatible, no conversion needed - Mapped, // Automatic type mapping applied - Custom, // User-defined mapping -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MigrationTablePlan { - pub source_table: String, - pub target_table: String, - pub column_mappings: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MigrationRequest { - pub source_connection_id: String, - pub source_database: Option, - pub source_schema: Option, - pub target_connection_id: String, - pub target_database: Option, - pub target_schema: Option, - pub table_plans: Vec, - pub batch_size: u32, - pub on_error: MigrationErrorStrategy, - pub create_tables: bool, - pub drop_tables: bool, - pub migrate_indexes: bool, - pub migrate_foreign_keys: bool, - pub migrate_constraints: bool, - pub disable_fk_checks: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum MigrationErrorStrategy { - SkipRow, - SkipTable, - Abort, -} - -// ── Progress ────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TransferProgress { - pub operation: String, // "export" | "import" | "ddl" | "sql_file" | "migration" - pub phase: String, // "preparing" | "processing" | "finalizing" - pub current_table: Option, - pub total_rows: Option, - pub processed_rows: u64, - pub skipped_rows: u64, - pub error_count: u64, - pub percent: f32, // 0.0–100.0 - pub elapsed_ms: u64, - pub estimated_remaining_ms: Option, - pub message: Option, -} - -// ── Results ─────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TransferResult { - pub success: bool, - pub total_rows: u64, - pub processed_rows: u64, - pub skipped_rows: u64, - pub error_count: u64, - pub duration_ms: u64, - pub output_path: Option, - pub output_size_bytes: Option, - pub errors: Vec, - pub table_results: Option>, // For migration -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TableTransferResult { - pub table: String, - pub rows: u64, - pub success: bool, - pub duration_ms: u64, - pub errors: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TransferError { - pub row_number: Option, - pub statement_number: Option, - pub message: String, - pub sql: Option, -} - -// ── Preview / Detection ─────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FileDetectionResult { - pub format: ImportFormat, - pub encoding: String, - pub estimated_rows: Option, - pub file_size_bytes: u64, - pub columns: Vec, - pub csv_delimiter: Option, - pub has_header: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ExportPreview { - pub columns: Vec, - pub sample_rows: Vec>, - pub total_rows_estimate: Option, - pub formatted_preview: String, // First N rows in target format -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TypeMappingSuggestion { - pub source_column: String, - pub source_type: String, - pub target_type: String, - pub conversion: MigrationConversion, - pub warning: Option, // e.g., "Precision loss possible" -} - -// ── Default value functions ─────────────────────────────── - -fn default_comma() -> char { ',' } -fn default_double_quote() -> char { '"' } -fn default_utf8() -> String { "UTF-8".to_string() } -fn default_lf() -> String { "lf".to_string() } -fn default_true() -> bool { true } -fn default_iso8601() -> String { "iso8601".to_string() } -fn default_batch_size() -> u32 { 1000 } -fn default_sheet_name() -> String { "Sheet1".to_string() } -``` - -### Best-Practice Defaults - -```rust -// transfer/defaults.rs - -use super::types::*; - -/// Returns default CSV export options (best-practice). -pub fn csv_defaults() -> CsvExportOptions { - CsvExportOptions { - delimiter: ',', - quote_char: '"', - encoding: "UTF-8".to_string(), - include_header: true, - quote_all: false, - line_ending: "lf".to_string(), - } -} - -/// Returns default JSONL export options (best-practice). -/// JSONL is always compact (one JSON object per line), no structure/pretty-print choice. -pub fn jsonl_defaults() -> JsonlExportOptions { - JsonlExportOptions { - date_format: "iso8601".to_string(), - } -} - -/// Returns default SQL export options (best-practice). -pub fn sql_defaults(table_name: &str) -> SqlExportOptions { - SqlExportOptions { - target_table: table_name.to_string(), - batch_size: 1000, - include_create_table: true, - include_drop_table: false, - target_engine: None, - } -} - -/// Returns default Excel export options (best-practice). -pub fn excel_defaults() -> ExcelExportOptions { - ExcelExportOptions { - sheet_name: "Sheet1".to_string(), - include_header: true, - auto_fit_columns: true, - freeze_header: true, - } -} - -/// Returns default CSV import options (best-practice). -pub fn csv_import_defaults() -> CsvImportOptions { - CsvImportOptions { - delimiter: ',', - encoding: "UTF-8".to_string(), - has_header: true, - } -} -``` - -### DDL Generator Trait - -```rust -// transfer/ddl.rs - -use async_trait::async_trait; -use crate::database::types::ColumnInfo; - -#[async_trait] -pub trait DdlGenerator: Send + Sync { - /// Generate CREATE TABLE statement for the given columns. - fn generate_create_table( - &self, - schema: Option<&str>, - table: &str, - columns: &[ColumnInfo], - options: &DdlOptions, - ) -> String; - - /// Generate DROP TABLE statement. - fn generate_drop_table( - &self, - schema: Option<&str>, - table: &str, - cascade: bool, - ) -> String; - - /// Generate CREATE INDEX statements. - fn generate_indexes( - &self, - schema: Option<&str>, - table: &str, - indexes: &[IndexInfo], - ) -> Vec; - - /// Map a source column type to this engine's equivalent. - fn map_type(&self, source_type: &str, source_engine: &str) -> String; - - /// Generate INSERT statement for a batch of rows. - fn generate_insert( - &self, - schema: Option<&str>, - table: &str, - columns: &[String], - rows: &[Vec], - ) -> String; - - /// Get the engine name (e.g., "PostgreSQL", "MySQL"). - fn engine_name(&self) -> &str; - - /// Quote an identifier for this engine. - fn quote_identifier(&self, name: &str) -> String; -} -``` - -Implementations: -- `PostgresDdlGenerator` -- `MySqlDdlGenerator` -- `SqliteDdlGenerator` -- `SqlServerDdlGenerator` - -### Tauri Commands (12 total) - -```rust -// commands/transfer.rs - -use tauri::State; -use crate::state::AppState; -use crate::transfer::types::*; - -// ── Export Commands ─────────────────────────────────────── - -/// Preview export data (first N rows in target format). -#[tauri::command] -pub async fn preview_export( - request: ExportRequest, - preview_rows: u32, - state: State<'_, AppState>, -) -> Result { ... } - -/// Execute data export to file. -#[tauri::command] -pub async fn execute_export( - request: ExportRequest, - app_handle: tauri::AppHandle, - state: State<'_, AppState>, -) -> Result { ... } - -// ── Import Commands ────────────────────────────────────── - -/// Detect file format, encoding, columns, and delimiter. -#[tauri::command] -pub async fn detect_file( - file_path: String, -) -> Result { ... } - -/// Preview parsed file data (first N rows with column mapping applied). -#[tauri::command] -pub async fn preview_import( - file_path: String, - format: ImportFormat, - csv_options: Option, - preview_rows: u32, -) -> Result { ... } - -/// Execute data import from file into table. -#[tauri::command] -pub async fn execute_import( - request: ImportRequest, - app_handle: tauri::AppHandle, - state: State<'_, AppState>, -) -> Result { ... } - -// ── DDL Commands ───────────────────────────────────────── - -/// Generate DDL for selected objects. -#[tauri::command] -pub async fn generate_ddl( - request: DdlRequest, - state: State<'_, AppState>, -) -> Result { ... } - -/// Execute DDL/SQL against a connection. -#[tauri::command] -pub async fn execute_ddl( - connection_id: String, - database: Option, - sql: String, - state: State<'_, AppState>, -) -> Result { ... } - -// ── Run SQL File Commands ──────────────────────────────── - -/// Parse SQL file and return statement count + preview. -#[tauri::command] -pub async fn parse_sql_file( - file_path: String, -) -> Result { ... } - -/// Execute SQL file against a connection. -#[tauri::command] -pub async fn execute_sql_file( - request: RunSqlFileRequest, - app_handle: tauri::AppHandle, - state: State<'_, AppState>, -) -> Result { ... } - -// ── Migration Commands ─────────────────────────────────── - -/// Suggest type mappings for source→target migration. -#[tauri::command] -pub async fn suggest_type_mappings( - source_connection_id: String, - source_database: Option, - source_schema: Option, - source_tables: Vec, - target_engine: String, - state: State<'_, AppState>, -) -> Result, String> { ... } - -/// Execute cross-engine data migration. -#[tauri::command] -pub async fn execute_migration( - request: MigrationRequest, - app_handle: tauri::AppHandle, - state: State<'_, AppState>, -) -> Result { ... } - -// ── Shared Commands ────────────────────────────────────── - -/// Cancel a running transfer operation. -#[tauri::command] -pub async fn cancel_transfer( - operation_id: String, - state: State<'_, AppState>, -) -> Result<(), String> { ... } -``` - -### Progress Reporting - -Progress is reported via Tauri events so the frontend receives real-time updates without polling. - -```rust -// transfer/progress.rs - -use tauri::Emitter; - -pub const TRANSFER_PROGRESS_EVENT: &str = "transfer-progress"; - -pub fn emit_progress( - app_handle: &tauri::AppHandle, - progress: &TransferProgress, -) { - let _ = app_handle.emit(TRANSFER_PROGRESS_EVENT, progress); -} -``` - -### Recommended Rust Crates - -| Crate | Purpose | Version | -|-------|---------|---------| -| `csv` | CSV reading/writing | Latest | -| `serde_json` | JSONL reading/writing (one JSON object per line) | (already in deps) | -| `calamine` | Excel reading (.xlsx, .xls) | Latest | -| `rust_xlsxwriter` | Excel writing (.xlsx) | Latest | -| `encoding_rs` | Character encoding detection/conversion | Latest | -| `chardetng` | Automatic charset detection | Latest | - -### Streaming / Chunking Strategy - -For large datasets (100k+ rows), all operations use batched processing: - -1. **Export**: Query rows in pages (`LIMIT batch_size OFFSET n`), write each page to the output file incrementally. -2. **Import**: Read the file in chunks (e.g., 5,000 rows per batch), execute batched INSERTs within a transaction per batch. -3. **Migration**: Read from source in batches, type-convert in memory, write to target in batches. Per-table sequential, within-table batched. - -**Default batch size**: 5,000 rows. Configurable by the user. - -**Memory bound**: At most one batch worth of rows is held in memory at any time. - ---- - -## Frontend Architecture - -### Vue Component Hierarchy - -``` -src/ -├── pages/ -│ └── TransferPage.vue # Top-level page (replaces ImportExportPage.vue) -├── components/ -│ └── transfer/ -│ ├── index.ts # Module exports -│ │ -│ ├── TransferTabs.vue # Top-level tab container (Export|Import|Structure|Migration) -│ │ -│ ├── shared/ -│ │ ├── ConnectionSelector.vue # Connection + database + schema dropdowns -│ │ ├── TableSelector.vue # Table list with checkboxes -│ │ ├── ColumnSelector.vue # Column list with checkboxes -│ │ ├── WizardStepper.vue # Step indicator bar -│ │ ├── ProgressPanel.vue # Progress bar + stats + cancel + "Run in Background" -│ │ ├── ResultPanel.vue # Completion summary + error log -│ │ ├── FileDropZone.vue # Drag-and-drop file area -│ │ └── FormatPreview.vue # Preview formatted data (Monaco read-only) -│ │ -│ ├── export/ -│ │ ├── ExportWizard.vue # Export wizard container (4 steps) -│ │ ├── ExportSourceStep.vue # Step 1: Table + columns + filters -│ │ ├── ExportFormatStep.vue # Step 2: Format selection (defaults applied) -│ │ ├── ExportPreviewStep.vue # Step 3: Preview -│ │ └── ExportExecuteStep.vue # Step 4: Execute + results -│ │ -│ ├── import/ -│ │ ├── ImportWizard.vue # Import wizard container (4 steps) -│ │ ├── ImportFileStep.vue # Step 1: File selection (auto-detect) -│ │ ├── ImportMappingStep.vue # Step 2: Target & column mapping -│ │ ├── ImportOptionsStep.vue # Step 3: Options & preview -│ │ └── ImportExecuteStep.vue # Step 4: Execute + results -│ │ -│ ├── structure/ -│ │ ├── StructureTabs.vue # Sub-tab container (Generate DDL | Run SQL File) -│ │ ├── DdlWizard.vue # DDL wizard container (3 steps) -│ │ ├── DdlObjectStep.vue # Step 1: Object selection -│ │ ├── DdlOptionsStep.vue # Step 2: DDL options -│ │ ├── DdlPreviewStep.vue # Step 3: Preview & export -│ │ ├── SqlFileWizard.vue # SQL file wizard (2 steps) -│ │ ├── SqlFileSelectStep.vue # Step 1: File & connection -│ │ └── SqlFileExecuteStep.vue # Step 2: Execution & results -│ │ -│ ├── migration/ -│ │ ├── MigrationWizard.vue # Migration wizard container (5 steps) -│ │ ├── MigrationSourceStep.vue # Step 1: Source connection + tables -│ │ ├── MigrationTargetStep.vue # Step 2: Target connection -│ │ ├── MigrationMappingStep.vue # Step 3: Schema & type mapping -│ │ ├── MigrationConfigStep.vue # Step 4: Options & summary -│ │ └── MigrationExecuteStep.vue # Step 5: Execute + results -│ │ -│ └── tasks/ -│ ├── TaskManagerPanel.vue # Slide-out sidebar (400px) with task list -│ ├── TaskCard.vue # Individual task card with progress + actions -│ └── TaskManagerButton.vue # Header button showing task count badge -│ -├── store/ -│ └── transferStore.ts # Pinia store (wizard state + task management) -│ -├── datasources/ -│ └── transferApi.ts # Tauri invoke wrappers -│ -└── types/ - └── transfer.ts # TypeScript types -``` - -### TypeScript Types - -```typescript -// src/types/transfer.ts - -// ── Export ──────────────────────────────────────────────── - -export type ExportFormat = 'csv' | 'jsonl' | 'sql' | 'excel' - -export type ExportSource = { - table: string - columns: string[] - whereClause?: string - orderBy?: string - limit?: number -} - -export type ExportRequest = { - connectionId: string - database?: string - schema?: string - source: ExportSource - format: ExportFormat - csvOptions?: CsvExportOptions - jsonlOptions?: JsonlExportOptions - sqlOptions?: SqlExportOptions - excelOptions?: ExcelExportOptions - outputPath: string -} - -// Format options — only sent when user explicitly overrides via "Advanced Options" - -export type CsvExportOptions = { - delimiter?: string // default: ',' - quoteChar?: string // default: '"' - encoding?: string // default: 'UTF-8' - includeHeader?: boolean // default: true - quoteAll?: boolean // default: false - lineEnding?: 'lf' | 'crlf' // default: 'lf' -} - -export type JsonlExportOptions = { - dateFormat?: string // default: 'iso8601' -} - -export type SqlExportOptions = { - targetTable: string - batchSize?: number // default: 1000 - includeCreateTable?: boolean // default: true - includeDropTable?: boolean // default: false - targetEngine?: string -} - -export type ExcelExportOptions = { - sheetName?: string // default: 'Sheet1' - includeHeader?: boolean // default: true - autoFitColumns?: boolean // default: true - freezeHeader?: boolean // default: true -} - -// ── Import ──────────────────────────────────────────────── - -export type ImportFormat = 'csv' | 'jsonl' | 'sql' | 'excel' - -export type ColumnMapping = { - sourceColumn: string - targetColumn?: string - targetType?: string -} - -export type ConflictStrategy = 'skip' | 'replace' | 'upsert' | 'abort' - -export type CsvImportOptions = { - delimiter?: string // default: auto-detected or ',' - encoding?: string // default: auto-detected or 'UTF-8' - hasHeader?: boolean // default: true -} - -export type ImportRequest = { - connectionId: string - database?: string - schema?: string - table: string - filePath: string - format: ImportFormat - columnMappings: ColumnMapping[] - conflictStrategy: ConflictStrategy - batchSize: number - createTable: boolean - truncateBefore: boolean - dryRun: boolean - csvOptions?: CsvImportOptions -} - -// ── DDL ─────────────────────────────────────────────────── - -export type DdlOptions = { - includeCreateTable: boolean - includePrimaryKeys: boolean - includeForeignKeys: boolean - includeIndexes: boolean - includeConstraints: boolean - includeComments: boolean - includeStorage: boolean - includeDropIfExists: boolean - includeIfNotExists: boolean - includeData: boolean -} - -export type DdlRequest = { - connectionId: string - database?: string - schema?: string - tables: string[] - targetEngine?: string - options: DdlOptions -} - -// ── Run SQL File ────────────────────────────────────────── - -export type SqlFileErrorStrategy = 'rollback' | 'skipAndContinue' | 'stop' - -export type RunSqlFileRequest = { - connectionId: string - database?: string - filePath: string - wrapInTransaction: boolean - onError: SqlFileErrorStrategy - dryRun: boolean -} - -export type SqlFileInfo = { - filePath: string - fileSizeBytes: number - statementCount: number - previewLines: string[] -} - -// ── Migration ───────────────────────────────────────────── - -export type MigrationConversion = 'direct' | 'mapped' | 'custom' - -export type MigrationMapping = { - sourceColumn: string - sourceType: string - targetColumn: string - targetType: string - conversion: MigrationConversion -} - -export type MigrationTablePlan = { - sourceTable: string - targetTable: string - columnMappings: MigrationMapping[] -} - -export type MigrationErrorStrategy = 'skipRow' | 'skipTable' | 'abort' - -export type MigrationRequest = { - sourceConnectionId: string - sourceDatabase?: string - sourceSchema?: string - targetConnectionId: string - targetDatabase?: string - targetSchema?: string - tablePlans: MigrationTablePlan[] - batchSize: number - onError: MigrationErrorStrategy - createTables: boolean - dropTables: boolean - migrateIndexes: boolean - migrateForeignKeys: boolean - migrateConstraints: boolean - disableFkChecks: boolean -} - -// ── Progress ────────────────────────────────────────────── - -export type TransferProgress = { - operation: string - phase: string - currentTable?: string - totalRows?: number - processedRows: number - skippedRows: number - errorCount: number - percent: number - elapsedMs: number - estimatedRemainingMs?: number - message?: string -} - -// ── Results ─────────────────────────────────────────────── - -export type TransferError = { - rowNumber?: number - statementNumber?: number - message: string - sql?: string -} - -export type TableTransferResult = { - table: string - rows: number - success: boolean - durationMs: number - errors: TransferError[] -} - -export type TransferResult = { - success: boolean - totalRows: number - processedRows: number - skippedRows: number - errorCount: number - durationMs: number - outputPath?: string - outputSizeBytes?: number - errors: TransferError[] - tableResults?: TableTransferResult[] -} - -// ── Detection ───────────────────────────────────────────── - -export type FileDetectionResult = { - format: ImportFormat - encoding: string - estimatedRows?: number - fileSizeBytes: number - columns: string[] - csvDelimiter?: string - hasHeader?: boolean -} - -export type ExportPreview = { - columns: string[] - sampleRows: string[][] - totalRowsEstimate?: number - formattedPreview: string -} - -export type TypeMappingSuggestion = { - sourceColumn: string - sourceType: string - targetType: string - conversion: MigrationConversion - warning?: string -} - -// ── Background Tasks ────────────────────────────────────── - -export type TaskKind = 'export' | 'import' | 'sqlFile' | 'migration' - -export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' - -export type TaskRuntime = { - complete: number - total: number - skipped: number - errorCount: number -} - -export type ExportTaskConfig = { - connectionId: string - database?: string - schema?: string - table: string - columns: string[] - whereClause?: string - orderBy?: string - limit?: number - format: ExportFormat - outputPath: string -} - -export type ImportTaskConfig = { - connectionId: string - database?: string - schema?: string - table: string - filePath: string - format: ImportFormat - conflictStrategy: ConflictStrategy -} - -export type SqlFileTaskConfig = { - connectionId: string - database?: string - filePath: string - onError: SqlFileErrorStrategy -} - -export type MigrationTaskConfig = { - sourceConnectionId: string - sourceDatabase?: string - targetConnectionId: string - targetDatabase?: string - tables: string[] -} - -export type TaskConfig = ExportTaskConfig | ImportTaskConfig | SqlFileTaskConfig | MigrationTaskConfig - -export type BackgroundTask = { - id: string - kind: TaskKind - status: TaskStatus - progress: { complete: number; total: number } - config: TaskConfig - runtime: TaskRuntime - label: string - startTime: Date - endTime?: Date - error?: string -} -``` - -### Pinia Store - -```typescript -// src/store/transferStore.ts - -import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import type { - ExportRequest, - ImportRequest, - MigrationRequest, - TransferProgress, - TransferResult, - BackgroundTask, - TaskKind, - TaskStatus, - TaskRuntime, -} from '@/types/transfer' - -export const useTransferStore = defineStore('transfer', () => { - // ── Active Tab ──────────────────────────────── - const activeTab = ref<'export' | 'import' | 'structure' | 'migration'>('export') - - // ── Progress Tracking ───────────────────────── - const isRunning = ref(false) - const progress = ref(null) - const lastResult = ref(null) - const operationId = ref(null) - - // ── Export State ────────────────────────────── - const exportStep = ref(0) - const exportRequest = ref>({}) - - // ── Import State ────────────────────────────── - const importStep = ref(0) - const importRequest = ref>({}) - - // ── Migration State ─────────────────────────── - const migrationStep = ref(0) - const migrationRequest = ref>({}) - - // ── Background Task State ───────────────────── - const runningTasks = ref([]) - const activeExportTaskId = ref(null) - const activeImportTaskId = ref(null) - const activeSqlFileTaskId = ref(null) - const activeMigrationTaskId = ref(null) - - // ── Computed ────────────────────────────────── - const progressPercent = computed(() => progress.value?.percent ?? 0) - const canCancel = computed(() => isRunning.value && operationId.value !== null) - const taskCount = computed(() => runningTasks.value.length) - const hasRunningTasks = computed(() => - runningTasks.value.some(t => t.status === 'running') - ) - const activeTaskId = computed(() => { - switch (activeTab.value) { - case 'export': return activeExportTaskId.value - case 'import': return activeImportTaskId.value - case 'structure': return activeSqlFileTaskId.value - case 'migration': return activeMigrationTaskId.value - default: return null - } - }) - - // ── Tab Actions ─────────────────────────────── - const setActiveTab = (tab: typeof activeTab.value) => { - activeTab.value = tab - } - - // ── Progress Actions ────────────────────────── - const updateProgress = (p: TransferProgress) => { - progress.value = p - } - - const startOperation = (id: string) => { - operationId.value = id - isRunning.value = true - progress.value = null - lastResult.value = null - } - - const completeOperation = (result: TransferResult) => { - isRunning.value = false - lastResult.value = result - progress.value = null - operationId.value = null - } - - // ── Reset Actions ───────────────────────────── - const resetExport = () => { - exportStep.value = 0 - exportRequest.value = {} - lastResult.value = null - } - - const resetImport = () => { - importStep.value = 0 - importRequest.value = {} - lastResult.value = null - } - - const resetMigration = () => { - migrationStep.value = 0 - migrationRequest.value = {} - lastResult.value = null - } - - // ── Task Management Actions ─────────────────── - - const addRunningTask = (task: BackgroundTask) => { - runningTasks.value = [...runningTasks.value, task] - } - - const updateTaskRuntime = (taskId: string, runtime: Partial) => { - runningTasks.value = runningTasks.value.map(t => - t.id === taskId - ? { - ...t, - runtime: { ...t.runtime, ...runtime }, - progress: { - complete: runtime.complete ?? t.progress.complete, - total: runtime.total ?? t.progress.total, - }, - } - : t - ) - } - - const updateTaskStatus = (taskId: string, status: TaskStatus, error?: string) => { - runningTasks.value = runningTasks.value.map(t => - t.id === taskId - ? { - ...t, - status, - endTime: status === 'completed' || status === 'failed' ? new Date() : undefined, - error, - } - : t - ) - } - - const removeTask = (taskId: string) => { - runningTasks.value = runningTasks.value.filter(t => t.id !== taskId) - } - - const clearCompletedTasks = () => { - runningTasks.value = runningTasks.value.filter(t => - t.status === 'running' || t.status === 'pending' - ) - } - - const syncProgressToTask = (taskId: string, p: TransferProgress) => { - updateTaskRuntime(taskId, { - complete: p.processedRows, - total: p.totalRows ?? 0, - skipped: p.skippedRows, - errorCount: p.errorCount, - }) - } - - const detachActiveTask = (kind: TaskKind) => { - switch (kind) { - case 'export': activeExportTaskId.value = null; break - case 'import': activeImportTaskId.value = null; break - case 'sqlFile': activeSqlFileTaskId.value = null; break - case 'migration': activeMigrationTaskId.value = null; break - } - } - - const openTask = (taskId: string) => { - const task = runningTasks.value.find(t => t.id === taskId) - if (!task) return - - // Map task kind to tab - const tabMap: Record = { - export: 'export', - import: 'import', - sqlFile: 'structure', - migration: 'migration', - } - activeTab.value = tabMap[task.kind] - - // Restore form state from task.config - // The specific restoration logic depends on task.kind - // and populates the corresponding wizard step fields - switch (task.kind) { - case 'export': - activeExportTaskId.value = taskId - // Restore exportRequest from task.config - break - case 'import': - activeImportTaskId.value = taskId - // Restore importRequest from task.config - break - case 'sqlFile': - activeSqlFileTaskId.value = taskId - break - case 'migration': - activeMigrationTaskId.value = taskId - // Restore migrationRequest from task.config - break - } - } - - return { - // Tab - activeTab, - setActiveTab, - - // Progress - isRunning, - progress, - lastResult, - operationId, - progressPercent, - canCancel, - updateProgress, - startOperation, - completeOperation, - - // Wizard state - exportStep, - exportRequest, - importStep, - importRequest, - migrationStep, - migrationRequest, - resetExport, - resetImport, - resetMigration, - - // Task management - runningTasks, - activeExportTaskId, - activeImportTaskId, - activeSqlFileTaskId, - activeMigrationTaskId, - taskCount, - hasRunningTasks, - activeTaskId, - addRunningTask, - updateTaskRuntime, - updateTaskStatus, - removeTask, - clearCompletedTasks, - syncProgressToTask, - detachActiveTask, - openTask, - } -}) -``` - -### Frontend API Wrapper - -```typescript -// src/datasources/transferApi.ts - -import { invoke } from '@tauri-apps/api/core' -import type { - ExportRequest, - ExportPreview, - ImportRequest, - CsvImportOptions, - ImportFormat, - DdlRequest, - RunSqlFileRequest, - SqlFileInfo, - MigrationRequest, - MigrationTablePlan, - FileDetectionResult, - TransferResult, -} from '@/types/transfer' - -// ── Export ──────────────────────────────────────────────── - -export const previewExport = (request: ExportRequest, previewRows = 10) => - invoke('preview_export', { request, previewRows }) - -export const executeExport = (request: ExportRequest) => - invoke('execute_export', { request }) - -// ── Import ──────────────────────────────────────────────── - -export const detectFile = (filePath: string) => - invoke('detect_file', { filePath }) - -export const previewImport = ( - filePath: string, - format: ImportFormat, - csvOptions?: CsvImportOptions, - previewRows = 10, -) => invoke('preview_import', { filePath, format, csvOptions, previewRows }) - -export const executeImport = (request: ImportRequest) => - invoke('execute_import', { request }) - -// ── DDL ─────────────────────────────────────────────────── - -export const generateDdl = (request: DdlRequest) => - invoke('generate_ddl', { request }) - -export const executeDdl = (connectionId: string, database: string | undefined, sql: string) => - invoke('execute_ddl', { connectionId, database, sql }) - -// ── Run SQL File ────────────────────────────────────────── - -export const parseSqlFile = (filePath: string) => - invoke('parse_sql_file', { filePath }) - -export const executeSqlFile = (request: RunSqlFileRequest) => - invoke('execute_sql_file', { request }) - -// ── Migration ───────────────────────────────────────────── - -export const suggestTypeMappings = ( - sourceConnectionId: string, - sourceDatabase: string | undefined, - sourceSchema: string | undefined, - sourceTables: string[], - targetEngine: string, -) => invoke('suggest_type_mappings', { - sourceConnectionId, - sourceDatabase, - sourceSchema, - sourceTables, - targetEngine, -}) - -export const executeMigration = (request: MigrationRequest) => - invoke('execute_migration', { request }) - -// ── Shared ──────────────────────────────────────────────── - -export const cancelTransfer = (operationId: string) => - invoke('cancel_transfer', { operationId }) -``` - -### Progress Event Listener - -```typescript -// Usage in component (e.g., ExportExecuteStep.vue) - -import { listen } from '@tauri-apps/api/event' -import { useTransferStore } from '@/store/transferStore' -import type { TransferProgress } from '@/types/transfer' - -const transferStore = useTransferStore() - -const unlisten = await listen('transfer-progress', event => { - transferStore.updateProgress(event.payload) - - // Sync progress to background task if one is active - const taskId = transferStore.activeExportTaskId - if (taskId) { - transferStore.syncProgressToTask(taskId, event.payload) - } -}) - -// Cleanup on unmount -onUnmounted(() => { - unlisten() -}) -``` - ---- - -## i18n Keys - -```json -{ - "transfer.title": "Transfer", - "transfer.subtitle": "Import, export, and migrate your data", - - "transfer.tabs.export": "Export", - "transfer.tabs.import": "Import", - "transfer.tabs.structure": "Structure", - "transfer.tabs.migration": "Migration", - - "transfer.export.step.source": "Source", - "transfer.export.step.format": "Format", - "transfer.export.step.preview": "Preview", - "transfer.export.step.execute": "Export", - "transfer.export.columns.selectAll": "Select All", - "transfer.export.columns.deselectAll": "Deselect All", - "transfer.export.where": "WHERE clause (optional)", - "transfer.export.orderBy": "ORDER BY (optional)", - "transfer.export.limit": "LIMIT (optional)", - - "transfer.format.csv": "CSV (.csv)", - "transfer.format.jsonl": "JSONL (.jsonl)", - "transfer.format.sql": "SQL (.sql)", - "transfer.format.excel": "Excel (.xlsx)", - "transfer.format.defaults.csv": "Comma delimiter, double-quote, UTF-8, include header, LF line ending", - "transfer.format.defaults.jsonl": "One JSON object per line, compact, UTF-8, ISO 8601 dates", - "transfer.format.defaults.sql": "Auto-filled target table, batch size 1000, include CREATE TABLE", - "transfer.format.defaults.excel": "Include header, auto-fit columns, freeze header row", - "transfer.format.advancedOptions": "Advanced Options", - - "transfer.import.step.file": "Select File", - "transfer.import.step.mapping": "Target & Mapping", - "transfer.import.step.options": "Options & Preview", - "transfer.import.step.execute": "Import", - "transfer.import.dropzone.title": "Drag & drop a file here", - "transfer.import.dropzone.subtitle": "or click to browse", - "transfer.import.dropzone.supported": "Supported: CSV, JSONL, SQL, Excel (.xlsx)", - "transfer.import.detected.format": "Detected Format", - "transfer.import.detected.encoding": "Detected Encoding", - "transfer.import.detected.rows": "Rows (estimated)", - "transfer.import.advancedParseOptions": "Advanced Parse Options", - "transfer.import.createTable": "Create table if not exists", - "transfer.import.autoMap": "Auto-Map by Name", - "transfer.import.clearAll": "Clear All", - "transfer.import.conflict": "On Conflict", - "transfer.import.conflict.skip": "Skip duplicates", - "transfer.import.conflict.replace": "Replace existing", - "transfer.import.conflict.upsert": "Update existing (upsert)", - "transfer.import.conflict.abort": "Abort on error", - "transfer.import.truncateBefore": "Truncate table before import", - "transfer.import.dryRun": "Dry run (validate without inserting)", - - "transfer.structure.tabs.ddl": "Generate DDL", - "transfer.structure.tabs.sqlFile": "Run SQL File", - "transfer.structure.ddl.step.objects": "Select Objects", - "transfer.structure.ddl.step.options": "DDL Options", - "transfer.structure.ddl.step.preview": "Preview & Export", - "transfer.structure.ddl.targetEngine": "Target Engine", - "transfer.structure.ddl.sameAsSource": "Same as source", - "transfer.structure.ddl.includeCreate": "CREATE TABLE statements", - "transfer.structure.ddl.includePk": "Primary keys", - "transfer.structure.ddl.includeFk": "Foreign keys", - "transfer.structure.ddl.includeIndexes": "Indexes", - "transfer.structure.ddl.includeConstraints": "Constraints (UNIQUE, CHECK, NOT NULL)", - "transfer.structure.ddl.includeComments": "Comments / descriptions", - "transfer.structure.ddl.includeStorage": "Tablespace / storage options", - "transfer.structure.ddl.includeDrop": "Include DROP IF EXISTS before CREATE", - "transfer.structure.ddl.includeIfNotExists": "Include IF NOT EXISTS on CREATE", - "transfer.structure.ddl.includeData": "Include INSERT DATA", - "transfer.structure.ddl.copyClipboard": "Copy to Clipboard", - "transfer.structure.ddl.saveFile": "Save to File", - "transfer.structure.ddl.executeServer": "Execute on Server", - "transfer.structure.sqlFile.step.select": "Select File & Connection", - "transfer.structure.sqlFile.step.execute": "Execution", - "transfer.structure.sqlFile.dropzone.title": "Drag & drop a .sql file here", - "transfer.structure.sqlFile.dropzone.supported": "Supported: .sql files", - "transfer.structure.sqlFile.wrapTransaction": "Wrap in transaction", - "transfer.structure.sqlFile.onError": "On Error", - "transfer.structure.sqlFile.onError.rollback": "Rollback all", - "transfer.structure.sqlFile.onError.skip": "Skip and continue", - "transfer.structure.sqlFile.onError.stop": "Stop execution", - "transfer.structure.sqlFile.dryRun": "Dry run (parse only)", - - "transfer.migration.step.source": "Source", - "transfer.migration.step.target": "Target", - "transfer.migration.step.mapping": "Mapping", - "transfer.migration.step.configure": "Configure", - "transfer.migration.step.execute": "Migrate", - "transfer.migration.createTables": "Create target tables if not exist", - "transfer.migration.dropTables": "Drop target tables before migration", - "transfer.migration.batchSize": "Batch Size", - "transfer.migration.onError": "On Error", - "transfer.migration.onError.skipRow": "Skip row and continue", - "transfer.migration.onError.skipTable": "Skip table and continue", - "transfer.migration.onError.abort": "Abort migration", - "transfer.migration.migrateIndexes": "Migrate indexes", - "transfer.migration.migrateFk": "Migrate foreign keys", - "transfer.migration.migrateConstraints": "Migrate constraints", - "transfer.migration.disableFkChecks": "Disable foreign key checks during migration", - "transfer.migration.editMapping": "Edit Mapping", - "transfer.migration.resetAuto": "Reset to Auto", - "transfer.migration.conversion.direct": "Direct", - "transfer.migration.conversion.mapped": "Auto-mapped", - "transfer.migration.conversion.custom": "Custom", - - "transfer.progress.exporting": "Exporting...", - "transfer.progress.importing": "Importing...", - "transfer.progress.migrating": "Migrating...", - "transfer.progress.executing": "Executing...", - "transfer.progress.elapsed": "Elapsed", - "transfer.progress.remaining": "Estimated remaining", - "transfer.progress.rows": "Rows", - "transfer.progress.exported": "exported", - "transfer.progress.imported": "imported", - "transfer.progress.skipped": "skipped", - "transfer.progress.errors": "errors", - "transfer.progress.statements": "Statements", - "transfer.progress.succeeded": "Succeeded", - "transfer.progress.failed": "Failed", - "transfer.progress.cancel": "Cancel", - "transfer.progress.runInBackground": "Run in Background", - - "transfer.result.success": "completed successfully", - "transfer.result.partial": "completed with errors", - "transfer.result.failed": "failed", - "transfer.result.duration": "Duration", - "transfer.result.file": "File", - "transfer.result.openFile": "Open File", - "transfer.result.openFolder": "Open Folder", - "transfer.result.exportAgain": "Export Again", - "transfer.result.importAgain": "Import Again", - "transfer.result.migrateAgain": "Migrate Again", - "transfer.result.runAgain": "Run Again", - "transfer.result.viewTable": "View Table", - "transfer.result.showErrorsOnly": "Show Errors Only", - "transfer.result.copyLog": "Copy Log", - - "transfer.tasks.title": "Tasks", - "transfer.tasks.clearCompleted": "Clear Completed", - "transfer.tasks.goToTask": "Go to Task", - "transfer.tasks.dismiss": "Dismiss", - "transfer.tasks.noTasks": "No transfer tasks", - "transfer.tasks.status.pending": "Pending", - "transfer.tasks.status.running": "Running", - "transfer.tasks.status.completed": "Completed", - "transfer.tasks.status.failed": "Failed", - "transfer.tasks.startedAgo": "Started {time} ago", - - "transfer.common.connection": "Connection", - "transfer.common.database": "Database", - "transfer.common.schema": "Schema", - "transfer.common.table": "Table", - "transfer.common.selectAll": "Select All", - "transfer.common.deselectAll": "Deselect All", - "transfer.common.tablesOnly": "Tables Only", - "transfer.common.viewsOnly": "Views Only", - "transfer.common.back": "Back", - "transfer.common.next": "Next", - "transfer.common.browse": "Browse..." -} -``` - ---- - -## Router & Sidebar Changes - -### Router Update - -```typescript -// src/router/index.ts — change: -// { path: '/import-export', ... } -// to: -{ - path: '/transfer', - name: 'transfer', - component: () => import('@/pages/TransferPage.vue'), -} -``` - -### Sidebar Update - -```typescript -// In AppSidebar.vue — change: -// { label: t('sidebar.importExport'), icon: ArrowLeftRight, to: '/import-export' } -// to: -{ label: t('sidebar.transfer'), icon: ArrowLeftRight, to: '/transfer' } -``` - -### i18n Sidebar Key - -```json -{ - "sidebar.transfer": "Transfer" -} -``` - ---- - -## Implementation Phases - -### Phase 1 — MVP (Data Export + Import + Task System) - -**Scope**: Export and Import tabs with CSV and JSONL support, plus background task infrastructure. - -**Deliverables**: -- `src-tauri/src/transfer/` module: `mod.rs`, `types.rs`, `defaults.rs`, `export.rs`, `import.rs`, `progress.rs` -- `src-tauri/src/commands/transfer.rs`: `preview_export`, `execute_export`, `detect_file`, `preview_import`, `execute_import`, `cancel_transfer` -- `src/pages/TransferPage.vue` replacing `ImportExportPage.vue` -- `src/components/transfer/` — all shared components + export/ + import/ wizards + tasks/ -- `src/store/transferStore.ts` (including full task management) -- `src/datasources/transferApi.ts` -- `src/types/transfer.ts` -- Router + sidebar updates -- i18n keys for export/import/tasks -- Formats: CSV, JSONL only (SQL, Excel deferred) -- Background task system with Task Manager panel - -**Estimated effort**: 3-4 weeks - -### Phase 2 — Full Export/Import + Structure - -**Scope**: Complete format support + Structure tab. - -**Deliverables**: -- SQL, Excel export/import support (using best-practice defaults) -- `src-tauri/src/transfer/ddl.rs` — `DdlGenerator` trait + per-engine implementations -- `src-tauri/src/commands/transfer.rs`: `generate_ddl`, `execute_ddl`, `parse_sql_file`, `execute_sql_file` -- `src/components/transfer/structure/` — DDL wizard + SQL file wizard -- i18n keys for structure tab -- New Cargo dependencies: `rust_xlsxwriter`, `calamine`, `encoding_rs`, `chardetng` - -**Estimated effort**: 2-3 weeks - -### Phase 3 — Cross-Engine Migration - -**Scope**: Migration tab with full cross-engine support. - -**Deliverables**: -- `src-tauri/src/transfer/migration.rs` — migration orchestrator -- `src-tauri/src/transfer/type_mapping.rs` — cross-engine type mapping matrix -- `src-tauri/src/commands/transfer.rs`: `suggest_type_mappings`, `execute_migration` -- `src/components/transfer/migration/` — all 5 migration wizard steps -- i18n keys for migration tab -- Per-table progress tracking with rollback support - -**Estimated effort**: 2-3 weeks - ---- - -## UI Components Used - -All UI components come from the existing shadcn-vue component library in `src/components/ui/`: - -| Component | Usage | -|-----------|-------| -| `Button` | Wizard navigation, actions, "Run in Background" | -| `Card` | Step containers, summary panels, task cards | -| `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` | Top-level tabs, structure sub-tabs | -| `Select` | Connection, database, schema, format dropdowns | -| `Input` | Text inputs (table name, file path, etc.) | -| `Checkbox` | Column selection, boolean options | -| `RadioGroup` | Format selection | -| `Label` | Form labels | -| `Table` | Column mapping grid, results table | -| `Progress` | Progress bar during execution, task cards | -| `Badge` | Status indicators (mapped, skipped, error), task status | -| `Spinner` | Loading states | -| `Dialog` | Confirmation dialogs (cancel, truncate) | -| `AlertDialog` | Destructive action confirmations | -| `Tooltip` | Help text on options | -| `DropdownMenu` | Additional actions menus | -| `Notification` | Success/error toast notifications | -| `Sheet` | Task Manager slide-out panel | -| `Collapsible` | "Advanced Options" expandable sections | - ---- - -## Revision History - -| Date | Change | Reason | -|------|--------|--------| -| v1.0 | Initial design | Comprehensive Transfer feature spec | -| v2.0 | Added Background Task System | Modeled after dockit's frontend-only task management pattern | -| v2.0 | Removed Custom Query export source | Simplification — table-only export covers primary use cases | -| v2.0 | Removed XML options panel | Simplification — XML uses fixed best-practice defaults | -| v2.0 | Simplified Format & Options step | Best-practice defaults applied automatically; "Advanced Options" collapsible for power users | -| v2.0 | Removed "Disable indexes during import" option | Backend handles automatically for large imports | -| v2.0 | Added Task Manager UI | Slide-out panel for tracking background tasks with progress, status, and navigation | -| v2.0 | Added `tasks/` component directory | TaskManagerPanel, TaskCard, TaskManagerButton | -| v2.0 | Updated Pinia store | Added full task lifecycle management (add, update, sync, detach, open, clear) | -| v2.0 | Phase 1 scope expanded | Includes background task infrastructure from the start | -| v3.0 | Removed XML format | Dropped XML from both export and import — reduces complexity and crate dependencies | -| v3.0 | Replaced JSON with JSONL | JSON Lines format (`.jsonl`) for better streaming and size efficiency with large datasets | -| v3.0 | Removed SQL-specific options panel | SQL export uses auto-filled defaults (target table from source, batch size 1000, include CREATE TABLE) — no dedicated UI section | diff --git a/docs/TRANSFER_SCOPE_DESIGN.md b/docs/TRANSFER_SCOPE_DESIGN.md new file mode 100644 index 00000000..774bb866 --- /dev/null +++ b/docs/TRANSFER_SCOPE_DESIGN.md @@ -0,0 +1,797 @@ +# Transfer Module Scope-Based Architecture Design + +> **Status**: Draft for review +> **Date**: 2026-05-28 +> **Author**: Architecture proposal based on user requirements + +## Executive Summary + +This design introduces a **Scope Selector** pattern across all transfer wizards (Export, Import, Migration, Structure) to provide a unified, simplified experience. The scope determines what level of database objects the operation targets: + +- **Server**: Operate on all databases within a connection +- **Database**: Operate on all tables/objects within a specific database +- **Tables**: Operate on specific selected tables (simplified - no column-level selection) + +--- + +## 1. Type Model Changes + +### 1.1 New Shared Enum + +```typescript +// Frontend: src/types/transfer.ts +export type TransferScope = 'server' | 'database' | 'tables' +``` + +```rust +// Backend: src-tauri/src/transfer/types.rs +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub enum TransferScope { + #[default] + Tables, + Database, + Server, +} +``` + +### 1.2 Modified Request Types + +#### ExportRequest + +**Before**: +```typescript +export type ExportRequest = { + connectionId: string + database?: string + schema?: string + source: ExportSource // single table + format: ExportFormat + outputPath: string + // ...options +} +``` + +**After**: +```typescript +export type ExportRequest = { + scope: TransferScope // NEW (default: 'tables') + connectionId: string + database?: string // required for 'database'/'tables' scope + schema?: string + sources: ExportSource[] // CHANGED: array for multi-table + format: ExportFormat + outputPath: string // For 'tables': single file; for 'database/server': directory + // ...options unchanged +} + +export type ExportSource = { + table: string + columns: string[] // When scope='tables': user-selected; else: all columns + // whereClause, orderBy, limit removed for simplicity +} +``` + +#### ImportRequest + +**Before**: +```typescript +export type ImportRequest = { + connectionId: string + database?: string + table: string // single target table + filePath: string + // ... +} +``` + +**After**: +```typescript +export type ImportRequest = { + scope: TransferScope // NEW + connectionId: string + database?: string // required for 'database'/'tables' scope + createDatabaseIfNotExists?: boolean // NEW: for 'database' scope + tables: ImportTarget[] // CHANGED: array for multi-table + filePath: string + // ... +} + +export type ImportTarget = { + sourceTable?: string // From file (for multi-sheet Excel, multi-table SQL) + targetTable: string + columnMappings?: ColumnMapping[] +} +``` + +#### MigrationRequest + +**Before**: +```typescript +export type MigrationRequest = { + sourceConnectionId: string + sourceDatabase?: string + targetConnectionId: string + targetDatabase?: string + tablePlans: MigrationTablePlan[] + // ... +} +``` + +**After**: +```typescript +export type MigrationRequest = { + scope: TransferScope // NEW + sourceConnectionId: string + sourceDatabase?: string // required for 'database'/'tables' scope + targetConnectionId: string + targetDatabase?: string + createTargetDatabaseIfNotExists?: boolean // NEW: for 'database' scope + tablePlans: MigrationTablePlan[] // auto-populated for 'server'/'database' scope + // ... +} +``` + +#### DdlRequest + +**After**: +```typescript +export type DdlRequest = { + scope: TransferScope // NEW + connectionId: string + database?: string + objects: DdlObject[] // auto-populated for 'server'/'database' scope + options: DdlOptions +} +``` + +--- + +## 2. UI Component Changes + +### 2.1 New Component: ScopeSelector + +**Location**: `src/components/transfer/shared/ScopeSelector.vue` + +**Purpose**: Horizontal chip toggle for scope selection + +**Props**: +```typescript +defineProps<{ + scope: TransferScope + disabled?: boolean +}>() + +defineEmits<{ + 'update:scope': [value: TransferScope] +}>() +``` + +**Visual Design**: +``` +┌─────────────────────────────────────────────┐ +│ [Server] [Database] [Tables] │ ← chip buttons +│ gray gray primary │ ← selected styling +└─────────────────────────────────────────────┘ +``` + +**Styling**: +- Horizontal flex container +- Each chip: `px-3 py-1.5 rounded-md text-xs font-medium` +- Selected: `bg-primary/10 text-primary border border-primary/30` +- Unselected: `bg-muted/30 text-muted-foreground hover:bg-muted/50` +- Gap between chips: `gap-1.5` + +### 2.2 Modified Component: TransferStepCard + +**Current header layout** (line 39-48): +``` +[icon] [01] TITLE summary +``` + +**Proposed header layout**: +``` +[icon] [01] TITLE [ScopeSelector] summary +``` + +**New Props**: +```typescript +defineProps<{ + // ... existing props + scope?: TransferScope // NEW + scopeDisabled?: boolean // NEW +}>() + +defineEmits<{ + 'update:scope': [value: TransferScope] // NEW +}>() +``` + +**Header slot integration**: +```vue +
+ + + +
+``` + +--- + +## 3. Wizard Flow Changes + +### 3.1 ExportWizard (Priority 1) + +**Current Steps**: +1. Source (Connection → Database → Schema → Tables → Columns) +2. Format & Output + +**New Steps**: +1. Scope + Source (with scope chips in header) +2. Format & Output + +**Step 1: Scope + Source** + +``` +┌─ Step 1: Source ────────────────────────────────────────┐ +│ [icon] [01] SOURCE [Server][Database][Tables] 3 tables │ +│ │ +│ IF scope === 'server': │ +│ • ConnectionSelector only │ +│ • Badge: "All databases on this server" │ +│ • Summary: Auto-count all tables across all DBs │ +│ │ +│ IF scope === 'database': │ +│ • ConnectionSelector │ +│ • DatabaseSelector (required) │ +│ • Badge: "All tables in {database}" │ +│ • Summary: Auto-count tables in selected DB │ +│ │ +│ IF scope === 'tables': │ +│ • ConnectionSelector │ +│ • DatabaseSelector │ +│ • SchemaSelector (optional) │ +│ • MultiTableSelector (checkbox grid) │ +│ • Summary: "N tables selected" │ +│ │ +│ ❌ REMOVE: TabbedColumnSelector (no column selection) │ +└─────────────────────────────────────────────────────────┘ +``` + +**Step 2: Format & Output** (unchanged structure, but conditional output) + +``` +┌─ Step 2: Format & Output ───────────────────────────────┐ +│ Format selector (CSV/JSONL/Excel/SQL) │ +│ Format-specific options │ +│ │ +│ IF scope === 'tables': │ +│ • Output: Single file path │ +│ │ +│ IF scope === 'database': │ +│ • Output: Directory path │ +│ • Filename pattern: {database}_{table}.{ext} │ +│ │ +│ IF scope === 'server': │ +│ • Output: Directory path │ +│ • Filename pattern: {database}/{table}.{ext} │ +│ • Creates subdirectory per database │ +└─────────────────────────────────────────────────────────┘ +``` + +### 3.2 MigrationWizard (Priority 2) + +**Current Steps**: +1. Source Connection + Table Selection +2. Target Connection + Options +3. Preview & Execute + +**New Steps** (same count, scope in Step 1 header): + +``` +┌─ Step 1: Scope + Source ────────────────────────────────┐ +│ [01] SOURCE [Server][Database][Tables] │ +│ │ +│ IF scope === 'server': │ +│ • Source ConnectionSelector │ +│ • Badge: "All databases will be migrated" │ +│ │ +│ IF scope === 'database': │ +│ • Source ConnectionSelector + Database │ +│ • Badge: "All tables in {database}" │ +│ │ +│ IF scope === 'tables': │ +│ • Current behavior: multi-table checkbox grid │ +└─────────────────────────────────────────────────────────┘ + +┌─ Step 2: Target ────────────────────────────────────────┐ +│ Target ConnectionSelector │ +│ │ +│ IF scope === 'server': │ +│ • No target database selector │ +│ • Option: "Create databases if not exist" │ +│ │ +│ IF scope === 'database': │ +│ • Target database selector │ +│ • Option: "Create database if not exist" ✓ │ +│ │ +│ IF scope === 'tables': │ +│ • Current behavior │ +└─────────────────────────────────────────────────────────┘ + +┌─ Step 3: Preview & Execute ────────────────────────────┐ +│ (unchanged) │ +└─────────────────────────────────────────────────────────┘ +``` + +### 3.3 StructureWizard (Priority 3) + +**GenerateDdl sub-tab**: + +``` +┌─ Step 1: Scope + Source ────────────────────────────────┐ +│ [01] SOURCE [Server][Database][Tables] │ +│ │ +│ IF scope === 'server': │ +│ • ConnectionSelector only │ +│ • Auto-select: all databases, all objects │ +│ │ +│ IF scope === 'database': │ +│ • ConnectionSelector + Database │ +│ • Auto-select: all objects in DB │ +│ │ +│ IF scope === 'tables': │ +│ • Current object checkbox grid │ +└─────────────────────────────────────────────────────────┘ +``` + +**RunSqlFile sub-tab**: + +``` +┌─ Step 1: Scope + Target ────────────────────────────────┐ +│ [01] TARGET [Server][Database][Tables] │ +│ │ +│ IF scope === 'server': │ +│ • ConnectionSelector only │ +│ • SQL can CREATE DATABASE │ +│ │ +│ IF scope === 'database': │ +│ • ConnectionSelector + Database │ +│ • Option: "Create database if not exist" │ +│ │ +│ IF scope === 'tables': │ +│ • ConnectionSelector + Database │ +│ • SQL targets specific tables │ +└─────────────────────────────────────────────────────────┘ +``` + +### 3.4 ImportWizard (Priority 4) + +**Current Steps**: +1. Source File +2. Target & Mapping +3. Options & Execute + +**New Steps** (scope in Step 2 header): + +``` +┌─ Step 1: Source File ───────────────────────────────────┐ +│ (unchanged - file drop, detection, preview) │ +└─────────────────────────────────────────────────────────┘ + +┌─ Step 2: Scope + Target ────────────────────────────────┐ +│ [02] TARGET [Server][Database][Tables] │ +│ │ +│ IF scope === 'server': │ +│ • ConnectionSelector only │ +│ • SQL file can CREATE DATABASE │ +│ • No table mapping UI │ +│ │ +│ IF scope === 'database': │ +│ • ConnectionSelector + Database │ +│ • Checkbox: "Create database if not exists" ✓ │ +│ • Auto-create tables from file structure │ +│ • No manual column mapping │ +│ │ +│ IF scope === 'tables': │ +│ • Current behavior: single table + column mapping │ +└─────────────────────────────────────────────────────────┘ + +┌─ Step 3: Options & Execute ────────────────────────────┐ +│ (unchanged) │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. Backend Command Changes + +### 4.1 execute_export_data + +**Current signature**: +```rust +pub async fn execute_export_data( + request: ExportRequest, + app_state: State<'_, AppState>, + app_handle: AppHandle, +) -> Result +``` + +**Changes needed**: + +1. **Handle `scope` field**: +```rust +match request.scope { + TransferScope::Server => { + // 1. List all databases + let databases = adapter.list_databases()?; + // 2. For each database, list tables + // 3. Export each table to: {output_path}/{database}/{table}.{ext} + } + TransferScope::Database => { + // 1. List all tables in request.database + // 2. Export each table to: {output_path}/{database}_{table}.{ext} + } + TransferScope::Tables => { + // Current behavior: iterate request.sources + // Export each table to: {output_path} + } +} +``` + +2. **Multi-table support**: +```rust +// Change from single source to sources array +for source in request.sources.iter() { + export_table(adapter, source, &request.format, output_path)?; +} +``` + +3. **Progress events**: Include current database/table in progress for server/database scope + +### 4.2 execute_import_data + +**Changes needed**: + +1. **Handle `scope` field**: +```rust +match request.scope { + TransferScope::Server => { + // Execute SQL file directly at connection level + // File may contain CREATE DATABASE statements + } + TransferScope::Database => { + // 1. Check if database exists, create if request.create_database_if_not_exists + // 2. Auto-create tables from file structure (for CSV/Excel) + // 3. Import data + } + TransferScope::Tables => { + // Current behavior + } +} +``` + +### 4.3 execute_migration_data + +**Changes needed**: + +1. **Handle `scope` field**: +```rust +match request.scope { + TransferScope::Server => { + // Migrate all databases from source to target + // Auto-create target databases + } + TransferScope::Database => { + // 1. Check/create target database if request.create_target_database_if_not_exists + // 2. Migrate all tables + } + TransferScope::Tables => { + // Current behavior + } +} +``` + +### 4.4 generate_ddl_for_objects + +**Changes needed**: + +1. **Handle `scope` field**: +```rust +match request.scope { + TransferScope::Server => { + // Generate DDL for all databases, all objects + // Output: multiple DDL files per database + } + TransferScope::Database => { + // Generate DDL for all objects in database + } + TransferScope::Tables => { + // Current behavior + } +} +``` + +--- + +## 5. Visual Wireframe + +### ExportWizard with Scope Selector + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ TRANSFER [Task Manager] │ +│ Data import, export, and migration │ +├─────────────────────────────────────────────────────────────────────┤ +│ [Export] [Import] [Migration] [Structure] │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─ Step 1: Source ───────────────────────────────────────────────┐ │ +│ │ [📊] [01] SOURCE [Server][Database][Tables] 3 tables │ │ +│ │ │ │ +│ │ ┌────────────────────┐ ┌──────────────────────────────────┐ │ │ +│ │ │ Connection │ │ Tables │ │ │ +│ │ │ ┌────────────────┐ │ │ ┌──────────────────────────────┐ │ │ │ +│ │ │ │ localhost:5432 │ │ │ │ ☑ users 1,234 rows │ │ │ │ +│ │ │ └────────────────┘ │ │ │ ☐ products 5,678 rows │ │ │ │ +│ │ │ │ │ │ │ ☐ orders 2,345 rows │ │ │ │ +│ │ │ Database │ │ │ │ ... │ │ │ │ +│ │ │ ┌────────────────┐ │ │ │ └─────────────────────────── │ │ │ │ +│ │ │ │ mydb │ │ │ │ [Select All] [Deselect All] │ │ │ │ +│ │ │ └────────────────┘ │ │ └──────────────────────────────┘ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ Schema (optional) │ │ │ │ │ +│ │ │ ┌────────────────┐ │ │ │ │ │ +│ │ │ │ public │ │ │ │ │ │ +│ │ │ └────────────────┘ │ │ │ │ │ +│ │ └────────────────────┘ │ │ │ │ +│ │ └────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ └────────────────────────────────────────────────────────────────│ │ +│ │ +│ ┌─ Step 2: Format & Output ──────────────────────────────────────┐ │ +│ │ [📄] [02] FORMAT & OUTPUT │ │ +│ │ │ │ +│ │ Format: │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │[CSV] │ │ JSONL │ │ Excel │ │ SQL │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ +│ │ │ │ +│ │ CSV Options: │ │ +│ │ Delimiter: [Comma (,) ▼] ☑ Include header row │ │ +│ │ │ │ +│ │ Output Path: │ │ +│ │ [/path/to/output.csv ] [Browse] │ │ +│ │ │ │ +│ │ ──────────────────────────────────────────────────────────── │ │ +│ │ [▶ Start Export] │ │ +│ │ │ │ +│ │ Summary: 3 tables | 4 cols | CSV │ │ +│ └────────────────────────────────────────────────────────────────│ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 6. Implementation Checklist + +### Phase 1: Types & Components (Frontend + Backend) + +**Frontend**: +1. Add `TransferScope` to `src/types/transfer.ts` +2. Update `ExportRequest` (add `scope`, change `source` → `sources: ExportSource[]`) +3. Update `ImportRequest` (add `scope`, `createDatabaseIfNotExists`) +4. Update `MigrationRequest` (add `scope`, `createTargetDatabaseIfNotExists`) +5. Update `DdlRequest` (add `scope`) + +**Backend**: +1. Add `TransferScope` to `src-tauri/src/transfer/types.rs` +2. Update corresponding Rust request structs +3. Add timestamp generation helper function + +**Shared Components**: +1. Create `ScopeSelector.vue` component (horizontal chip toggle) +2. Modify `TransferStepCard.vue` to accept and render scope selector in header + +### Phase 2: ExportWizard (Frontend) + +1. Add scope state (default: `'tables'`) +2. Pass scope to TransferStepCard Step 1 header +3. Conditionally show/hide selectors based on scope: + - `server`: ConnectionSelector only, show summary badge + - `database`: ConnectionSelector + DatabaseSelector, show summary badge + - `tables`: ConnectionSelector + DatabaseSelector + MultiTableSelector +4. Remove `TabbedColumnSelector` usage +5. Change `source` to `sources` array in store sync +6. Wire `startExport()`: + - For `tables` scope: direct invoke with single/multiple sources + - For `database`/`server` scope: async task creation, poll for status +7. Handle output path: + - `tables` (single): file picker for `.csv/.sql/.xlsx` + - `tables` (multi): file picker for `.zip` + - `database`/`server`: directory picker (create ZIP inside) + +### Phase 3: Export Backend + +1. Update `execute_export_data` command signature +2. Add timestamp generator: `format_datetime(chrono::Local::now())` +3. Implement scope-based iteration: + - `server`: List databases → for each DB → list tables → export to ZIP nested + - `database`: List tables → export each to ZIP + - `tables`: Direct multi-table export to ZIP or single file +4. ZIP creation logic (use `zip` crate): + - Server scope: nested entries `{database}/{table}.{ext}` + - Database scope: flat entries `{table}.{ext}` +5. Progress events: Include `current_database`, `current_table`, `total_tables` +6. Async task pattern: + - For `database`/`server` scope: Return task ID immediately + - Background thread processes export + - Client polls `/task_status/{taskId}` (reuse existing BackgroundTask system) + +### Phase 4: MigrationWizard + Backend + +1. Add scope state to MigrationWizard (default: `'tables'`) +2. Conditionally show selectors: + - `server`: Source + Target ConnectionSelector, no DB selector + - `database`: ConnectionSelector + DatabaseSelector for both source/target + - `tables`: Current behavior +3. Add `createTargetDatabaseIfNotExists` checkbox for `database` scope +4. Update `execute_migration_data`: + - For `server` scope: Iterate all databases + - For `database` scope: Single DB migration (all tables or selected) +5. Handle target database creation + +### Phase 5: StructureWizard + Backend + +1. Add scope to GenerateDdl: + - `server`: Export DDL for all databases (ZIP output) + - `database`: Export DDL for all objects (single SQL or ZIP) + - `tables`: Current behavior (selected objects) +2. Add scope to RunSqlFile: + - `server`: SQL can CREATE DATABASE + - `database`: Add `createDatabaseIfNotExists` checkbox + - `tables`: Current behavior +3. Update backend commands accordingly + +### Phase 6: ImportWizard + Backend + +1. Add scope state to ImportWizard (default: `'tables'`) +2. Scope affects Step 2 (Target & Mapping): + - `server`: ConnectionSelector only, SQL file can CREATE DATABASE + - `database`: ConnectionSelector + DatabaseSelector, add `createDatabaseIfNotExists` checkbox, auto-create table from file + - `tables`: Current behavior (select target table + column mapping) +3. For `database` scope: Single file imports to ONE table (match Chat2DB) +4. Update `execute_import_data` for scope handling + +--- + +## 7. Design Decisions (Based on Chat2DB Research) + +> Reference: Chat2DB GitHub - https://github.com/codePhiliaX/Chat2DB + +### 7.1 Output Naming Convention (Adopt Chat2DB's Pattern) + +| Scope | Single Table | Multiple Tables | +|-------|--------------|-----------------| +| **Tables** | `{tableName}_{timestamp}.{ext}` | `export_{tables}_data_{timestamp}.zip` → `{tableName}.{ext}` inside | +| **Database** | — | `export_{databaseName}_data_{timestamp}.zip` → `{tableName}.{ext}` inside | +| **Server** | — | `export_{connectionName}_data_{timestamp}.zip` → `{database}/{tableName}.{ext}` nested | + +**Timestamp format**: `YYYYMMDDHHmmss` (pure datetime, matches Chat2DB) + +**Examples**: +``` +# Tables scope (single) +users_20240324153045.csv + +# Tables scope (multiple: users, orders) +export_users_orders_data_20240324153045.zip + → users.csv + → orders.csv + +# Database scope (mydb) +export_mydb_data_20240324153045.zip + → users.csv + → orders.csv + → products.csv + +# Server scope (localhost_5432) +export_localhost_5432_data_20240324153045.zip + → mydb/ + → users.csv + → orders.csv + → testdb/ + → test_table.csv +``` + +### 7.2 Import Database Scope (Match Chat2DB's Approach) + +**Decision**: Single file → creates ONE target table (same as Chat2DB) + +- For CSV/JSONL/Excel: User selects target database, file imports to one table +- For SQL files: Backend can auto-detect multiple CREATE TABLE statements and create accordingly +- The "create database if not exists" checkbox applies to the target database selection, not file parsing + +**Why**: Chat2DB has no multi-table import. This simplifies UX and aligns with common patterns. + +### 7.3 Scope Default + +**Decision**: `tables` scope for all wizards (default) + +- Safest (current behavior) +- Most common use case +- Matches Chat2DB's single-table focus + +### 7.4 Scope Persistence + +**Decision**: No persistence needed + +- Scope is per-operation (not remembered) +- Each wizard starts with `tables` scope +- Simpler implementation +- Matches Chat2DB's implicit scope approach (no state) + +### 7.5 Async Task Pattern for Bulk Operations + +**Decision**: Adopt async task pattern for database/server scope (like Chat2DB) + +- Database/server scope exports: Return task ID, poll for status +- Tables scope: Direct execution (smaller scope, faster) +- Use existing `BackgroundTask` system in `transferStore` + +### 7.6 Scope UI Approach (Keep Explicit Picker) + +**Decision**: Keep explicit scope selector (unlike Chat2DB's implicit approach) + +**Why SQLKit differs from Chat2DB**: +- SQLKit has a dedicated Transfer page (not tree-context driven) +- Better discoverability for users unfamiliar with database hierarchy +- Consistent experience across Export/Import/Migration/Structure tabs +- Chat2DB's approach works for tree-based UI; SQLKit's wizard-based UI needs explicit selection + +--- + +## 8. Acceptance Criteria + +- [ ] Scope selector appears in header of Step 1 for all wizards +- [ ] Scope selector uses chip toggle styling (horizontal, 3 options: Server/Database/Tables) +- [ ] `tables` scope is default for all wizards +- [ ] Scope is not persisted (resets to `tables` on wizard open) +- [ ] Selectors conditionally render based on selected scope +- [ ] `tables` scope behaves like current behavior (minus column selection for Export) +- [ ] `database` scope auto-selects all tables, shows count summary badge +- [ ] `server` scope auto-selects all databases, shows count summary badge +- [ ] **Output naming follows Chat2DB pattern**: + - [ ] Single table: `{tableName}_{timestamp}.{ext}` + - [ ] Multiple tables: ZIP with `{tableName}.{ext}` inside + - [ ] Database scope: ZIP with `{tableName}.{ext}` inside + - [ ] Server scope: ZIP nested with `{database}/{tableName}.{ext}` +- [ ] Backend handles all 3 scope levels correctly +- [ ] **Async task pattern** for database/server scope exports: + - [ ] Returns task ID immediately + - [ ] Background thread processes export + - [ ] Client can poll for status via BackgroundTask system +- [ ] Progress events include `current_database`, `current_table`, `total_tables` for bulk scopes +- [ ] Import `database` scope: Single file imports to one table, with `createDatabaseIfNotExists` option +- [ ] Migration `database` scope: Has `createTargetDatabaseIfNotExists` option +- [ ] Structure `database` scope: Auto-selects all objects for DDL generation + +--- + +## Next Steps + +Please review and provide feedback on: +1. Type model changes (section 1) +2. UI wireframe (section 5) +3. Open questions (section 7) +4. Any additional requirements or concerns + +Once approved, implementation will proceed in priority order: +Export → Migration → Structure → Import \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 28e2ec95..a7411918 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@antfu/eslint-config": "^7.0.1", + "@iconify/json": "^2.2.482", "@tauri-apps/cli": "^2", "@types/jest": "^30.0.0", "@unocss/eslint-plugin": "^66.6.0", @@ -1665,6 +1666,17 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify/json": { + "version": "2.2.482", + "resolved": "https://registry.npmjs.org/@iconify/json/-/json-2.2.482.tgz", + "integrity": "sha512-U6tOIrJKIN012Ea2XClJzcD9IsTN3vhd2uaA29bsnYgQR0pi0WBUjczyI8LvfLa8JQu/yng6mS/jwyhmILh8MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@iconify/types": "*", + "pathe": "^2.0.3" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", @@ -5395,9 +5407,9 @@ } }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, "node_modules/dequal": { @@ -9216,9 +9228,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -9778,9 +9790,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -9797,7 +9809,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -11309,9 +11321,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "peer": true, diff --git a/package.json b/package.json index 373d2cbb..3c73436f 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@antfu/eslint-config": "^7.0.1", + "@iconify/json": "^2.2.482", "@tauri-apps/cli": "^2", "@types/jest": "^30.0.0", "@unocss/eslint-plugin": "^66.6.0", diff --git a/src-tauri/src/transfer/export.rs b/src-tauri/src/transfer/export.rs index d5615894..af3f3a23 100644 --- a/src-tauri/src/transfer/export.rs +++ b/src-tauri/src/transfer/export.rs @@ -1,6 +1,7 @@ //! Export implementation for CSV, JSONL, SQL, and Excel formats. +//! Supports scope-based export with folder output for Server, Database, and Tables scopes. -use std::fs::File; +use std::fs::{self, File}; use std::io::{BufWriter, Write}; use std::path::Path; use std::time::Instant; @@ -13,17 +14,180 @@ use super::progress::*; use super::types::*; use crate::database::{DatabaseAdapter, QueryValue}; +const BATCH_SIZE: u64 = 1000; + +// ── Chat2DB-style naming ───────────────────────────────────────── + +/// Format a table filename with Chat2DB-style timestamp suffix. +fn format_chat2db_filename(table: &str, ext: &str) -> String { + let timestamp = chrono::Local::now().format("%Y%m%d%H%M%S"); + format!("{}_{}.{}", table, timestamp, ext) +} + +/// Get the file extension for an export format. +fn format_extension(format: &ExportFormat) -> &'static str { + match format { + ExportFormat::Csv => "csv", + ExportFormat::Jsonl => "jsonl", + ExportFormat::Sql => "sql", + ExportFormat::Excel => "xlsx", + } +} + +// ── Scope-based export entry point ─────────────────────────────── + /// Executes a data export operation. +/// +/// Supports three scopes: +/// - `Tables`: Export specified sources. Single source → single file, multiple → folder with files. +/// - `Database`: List all tables in the database, export each to folder. +/// - `Server`: List all databases + tables, export each with nested folders. pub async fn execute_export( adapter: &A, request: ExportRequest, app_handle: &tauri::AppHandle, ) -> Result { let start_time = Instant::now(); - let _operation_id = uuid::Uuid::new_v4().to_string(); - let columns = request.source.columns.clone(); - let table = request.source.table.clone(); + match &request.scope { + TransferScope::Tables if request.sources.len() <= 1 => { + execute_single_table_export(adapter, request, app_handle, start_time).await + } + TransferScope::Tables => { + // Multiple sources → folder with flat file paths + let sources_with_paths: Vec<(Option, ExportSource)> = request + .sources + .iter() + .map(|s| (None, s.clone())) + .collect(); + execute_folder_export( + adapter, + request, + sources_with_paths, + app_handle, + start_time, + ) + .await + } + TransferScope::Database => { + let db_name = request + .database + .clone() + .ok_or_else(|| "database name is required for Database scope".to_string())?; + + let mut progress = create_progress( + "export", + "discovering", + 0, + None, + start_time.elapsed().as_millis() as u64, + ); + progress.current_database = Some(db_name.clone()); + emit_progress(app_handle, &progress); + + let tables = adapter + .list_tables(Some(&db_name), request.schema.as_deref()) + .await + .map_err(|e| format!("Failed to list tables in '{}': {}", db_name, e))?; + + let mut sources: Vec = Vec::with_capacity(tables.len()); + for table_info in &tables { + let columns = adapter + .list_columns(Some(&db_name), request.schema.as_deref(), &table_info.name) + .await + .map_err(|e| format!("Failed to list columns for '{}': {}", table_info.name, e))?; + sources.push(ExportSource { + table: table_info.name.clone(), + columns: columns.iter().map(|c| c.name.clone()).collect(), + }); + } + + // Database scope uses flat paths ({table}.{ext}) in folder, + // but we still pass the db name for progress tracking. + // The execute_folder_export uses the Option for path nesting, + // so we pass None here for flat entries. + let sources_flat: Vec = sources; + let sources_with_paths: Vec<(Option, ExportSource)> = + sources_flat.into_iter().map(|s| (None, s)).collect(); + + execute_folder_export(adapter, request, sources_with_paths, app_handle, start_time).await + } + TransferScope::Server => { + emit_progress( + app_handle, + &create_progress( + "export", + "discovering", + 0, + None, + start_time.elapsed().as_millis() as u64, + ), + ); + + let databases = adapter + .list_databases() + .await + .map_err(|e| format!("Failed to list databases: {}", e))?; + + let mut sources_with_paths: Vec<(Option, ExportSource)> = Vec::new(); + + for (db_idx, db) in databases.iter().enumerate() { + let mut progress = create_progress( + "export", + "discovering", + db_idx as u64, + Some(databases.len() as u64), + start_time.elapsed().as_millis() as u64, + ); + progress.current_database = Some(db.name.clone()); + emit_progress(app_handle, &progress); + + let tables = adapter + .list_tables(Some(&db.name), request.schema.as_deref()) + .await + .map_err(|e| format!("Failed to list tables in '{}': {}", db.name, e))?; + + for table_info in &tables { + let columns = adapter + .list_columns(Some(&db.name), request.schema.as_deref(), &table_info.name) + .await + .map_err(|e| { + format!( + "Failed to list columns for '{}' in '{}': {}", + table_info.name, db.name, e + ) + })?; + sources_with_paths.push(( + Some(db.name.clone()), + ExportSource { + table: table_info.name.clone(), + columns: columns.iter().map(|c| c.name.clone()).collect(), + }, + )); + } + } + + execute_folder_export(adapter, request, sources_with_paths, app_handle, start_time).await + } + } +} + +// ── Single-table export (original behavior) ───────────────────── + +/// Execute export for a single table to a single file. +async fn execute_single_table_export( + adapter: &A, + request: ExportRequest, + app_handle: &tauri::AppHandle, + start_time: Instant, +) -> Result { + let source = request + .sources + .first() + .ok_or("No export sources specified")? + .clone(); + let columns = source.columns.clone(); + let table = source.table.clone(); let schema = request.schema.clone(); let csv_opts = request @@ -43,9 +207,9 @@ pub async fn execute_export( .clone() .unwrap_or_else(excel_export_defaults); - let base_query = build_export_query(&schema, &table, &columns, &request.source); + let base_query = build_export_query(&schema, &table, &columns); - let count_query = build_count_query(&schema, &table, &request.source.where_clause); + let count_query = build_count_query(&schema, &table); let count_result = adapter .execute_query(&count_query) .await @@ -71,7 +235,6 @@ pub async fn execute_export( let mut processed_rows: u64 = 0; let mut errors: Vec = Vec::new(); - let batch_size = 1000u64; match request.format { ExportFormat::Csv => { @@ -82,7 +245,8 @@ pub async fn execute_export( let mut offset = 0u64; while offset < total_rows { - let query = format!("{} LIMIT {} OFFSET {}", base_query, batch_size, offset); + let query = + format!("{} LIMIT {} OFFSET {}", base_query, BATCH_SIZE, offset); let result = adapter .execute_query(&query) .await @@ -101,7 +265,7 @@ pub async fn execute_export( processed_rows += 1; } - offset += batch_size; + offset += BATCH_SIZE; emit_progress( app_handle, &create_progress( @@ -118,7 +282,8 @@ pub async fn execute_export( ExportFormat::Jsonl => { let mut offset = 0u64; while offset < total_rows { - let query = format!("{} LIMIT {} OFFSET {}", base_query, batch_size, offset); + let query = + format!("{} LIMIT {} OFFSET {}", base_query, BATCH_SIZE, offset); let result = adapter .execute_query(&query) .await @@ -126,7 +291,8 @@ pub async fn execute_export( for row in &result.rows { let json_obj = row_to_json_object(row, &jsonl_opts.date_format); - let json_line = serde_json::to_string(&json_obj).map_err(|e| e.to_string())?; + let json_line = + serde_json::to_string(&json_obj).map_err(|e| e.to_string())?; writer .write_all(json_line.as_bytes()) .map_err(|e| e.to_string())?; @@ -134,7 +300,7 @@ pub async fn execute_export( processed_rows += 1; } - offset += batch_size; + offset += BATCH_SIZE; emit_progress( app_handle, &create_progress( @@ -166,7 +332,8 @@ pub async fn execute_export( let mut offset = 0u64; while offset < total_rows { - let query = format!("{} LIMIT {} OFFSET {}", base_query, batch_size, offset); + let query = + format!("{} LIMIT {} OFFSET {}", base_query, BATCH_SIZE, offset); let result = adapter .execute_query(&query) .await @@ -191,7 +358,7 @@ pub async fn execute_export( } } - offset += batch_size; + offset += BATCH_SIZE; emit_progress( app_handle, &create_progress( @@ -239,7 +406,8 @@ pub async fn execute_export( let mut row_idx = header_row_offset; while offset < total_rows { - let query = format!("{} LIMIT {} OFFSET {}", base_query, batch_size, offset); + let query = + format!("{} LIMIT {} OFFSET {}", base_query, BATCH_SIZE, offset); let result = adapter .execute_query(&query) .await @@ -254,7 +422,7 @@ pub async fn execute_export( processed_rows += 1; } - offset += batch_size; + offset += BATCH_SIZE; emit_progress( app_handle, &create_progress( @@ -312,12 +480,413 @@ pub async fn execute_export( }) } -fn build_export_query( - schema: &Option, - table: &str, - columns: &[String], +// ── Folder-based multi-table export ────────────────────────────── + +/// Execute export for multiple tables into a folder. +/// +/// Each source is accompanied by an optional database name. +/// When `Some(db)`, the file path is `{output_folder}/{db}/{chat2db_name}`. +/// When `None`, the file path is `{output_folder}/{chat2db_name}` (flat). +async fn execute_folder_export( + adapter: &A, + request: ExportRequest, + sources_with_db: Vec<(Option, ExportSource)>, + app_handle: &tauri::AppHandle, + start_time: Instant, +) -> Result { + let csv_opts = request + .csv_options + .clone() + .unwrap_or_else(csv_export_defaults); + let jsonl_opts = request + .jsonl_options + .clone() + .unwrap_or_else(jsonl_export_defaults); + let excel_opts = request + .excel_options + .clone() + .unwrap_or_else(excel_export_defaults); + let ext = format_extension(&request.format); + let schema = request.schema.as_deref(); + + let output_folder = Path::new(&request.output_path); + + // Create the output folder if it doesn't exist + fs::create_dir_all(output_folder) + .map_err(|e| format!("Failed to create output folder: {}", e))?; + + let mut grand_total: u64 = 0; + let mut grand_processed: u64 = 0; + let mut errors: Vec = Vec::new(); + let mut total_size: u64 = 0; + + let total_sources = sources_with_db.len() as u64; + + for (source_idx, (db_name_opt, source)) in sources_with_db.iter().enumerate() { + let table = &source.table; + + // Emit discovering progress for this table + { + let mut progress = create_progress( + "export", + if total_sources > 0 && source_idx == 0 { + "discovering" + } else { + "processing" + }, + source_idx as u64, + Some(total_sources), + start_time.elapsed().as_millis() as u64, + ); + progress.current_database = db_name_opt.clone(); + progress.current_table = Some(table.clone()); + progress.message = Some(format!("Exporting table: {}", table)); + emit_progress(app_handle, &progress); + } + + // Determine file path + let chat2db_name = format_chat2db_filename(table, ext); + let file_path = match db_name_opt { + Some(db) => { + let db_folder = output_folder.join(db); + fs::create_dir_all(&db_folder) + .map_err(|e| format!("Failed to create database folder '{}': {}", db, e))?; + db_folder.join(chat2db_name) + } + None => output_folder.join(chat2db_name), + }; + + // Export this table to the file + // Build the SqlExportOptions for each table (some options reference the table name) + let sql_opts = request + .sql_options + .clone() + .unwrap_or_else(|| sql_export_defaults(table)); + + export_table_to_file( + adapter, + source, + schema, + db_name_opt.as_deref(), + &request.format, + &csv_opts, + &jsonl_opts, + &sql_opts, + &excel_opts, + &file_path, + app_handle, + &mut grand_total, + &mut grand_processed, + &mut errors, + start_time, + ) + .await?; + + total_size += fs::metadata(&file_path).map(|m| m.len()).unwrap_or(0); + } + + emit_progress( + app_handle, + &create_progress( + "export", + "finalizing", + grand_processed, + Some(grand_total), + start_time.elapsed().as_millis() as u64, + ), + ); + + Ok(TransferResult { + success: errors.is_empty(), + total_rows: grand_total, + processed_rows: grand_processed, + skipped_rows: 0, + error_count: errors.len() as u64, + duration_ms: start_time.elapsed().as_millis() as u64, + output_path: Some(request.output_path), + output_size_bytes: Some(total_size), + errors, + }) +} + +/// Export a single table's data directly to a file. +#[allow(clippy::too_many_arguments)] +async fn export_table_to_file( + adapter: &A, source: &ExportSource, -) -> String { + schema: Option<&str>, + database: Option<&str>, + format: &ExportFormat, + csv_opts: &CsvExportOptions, + jsonl_opts: &JsonlExportOptions, + sql_opts: &SqlExportOptions, + excel_opts: &ExcelExportOptions, + file_path: &Path, + app_handle: &tauri::AppHandle, + accumulated_total: &mut u64, + accumulated_processed: &mut u64, + errors: &mut Vec, + start_time: Instant, +) -> Result<(), String> { + let columns = &source.columns; + let table = &source.table; + + let base_query = build_export_query(&schema.map(|s| s.to_string()), table, columns); + let count_query = build_count_query(&schema.map(|s| s.to_string()), table); + + let count_result = adapter + .execute_query(&count_query) + .await + .map_err(|e| e.to_string())?; + let total_rows = count_result + .rows + .first() + .and_then(|row| row.get("count")) + .and_then(|v| match v { + QueryValue::Int(n) => Some(*n as u64), + _ => None, + }) + .unwrap_or(0); + *accumulated_total += total_rows; + + let mut local_processed: u64 = 0; + + let file = File::create(file_path) + .map_err(|e| format!("Failed to create file '{}': {}", file_path.display(), e))?; + let mut writer = BufWriter::new(file); + + match format { + ExportFormat::Csv => { + if csv_opts.include_header { + write_csv_header(&mut writer, columns, csv_opts.delimiter) + .map_err(|e| e.to_string())?; + } + + let mut offset = 0u64; + while offset < total_rows { + let query = + format!("{} LIMIT {} OFFSET {}", base_query, BATCH_SIZE, offset); + let result = adapter + .execute_query(&query) + .await + .map_err(|e| e.to_string())?; + + for row in &result.rows { + write_csv_row(&mut writer, columns, row, csv_opts).map_err(|e| { + errors.push(TransferError { + row_number: Some(*accumulated_processed + local_processed + 1), + statement_number: None, + message: e, + sql: None, + }); + String::new() + })?; + local_processed += 1; + } + + offset += BATCH_SIZE; + let mut progress = create_progress( + "export", + "processing", + *accumulated_processed + local_processed, + Some(*accumulated_total), + start_time.elapsed().as_millis() as u64, + ); + progress.current_database = database.map(|s| s.to_string()); + progress.current_table = Some(table.clone()); + emit_progress(app_handle, &progress); + } + + writer.flush().map_err(|e| format!("Failed to flush CSV file: {}", e))?; + *accumulated_processed += local_processed; + Ok(()) + } + + ExportFormat::Jsonl => { + let mut offset = 0u64; + while offset < total_rows { + let query = + format!("{} LIMIT {} OFFSET {}", base_query, BATCH_SIZE, offset); + let result = adapter + .execute_query(&query) + .await + .map_err(|e| e.to_string())?; + + for row in &result.rows { + let json_obj = row_to_json_object(row, &jsonl_opts.date_format); + let json_line = + serde_json::to_string(&json_obj).map_err(|e| e.to_string())?; + writer + .write_all(json_line.as_bytes()) + .map_err(|e| e.to_string())?; + writer.write_all(b"\n").map_err(|e| e.to_string())?; + local_processed += 1; + } + + offset += BATCH_SIZE; + let mut progress = create_progress( + "export", + "processing", + *accumulated_processed + local_processed, + Some(*accumulated_total), + start_time.elapsed().as_millis() as u64, + ); + progress.current_database = database.map(|s| s.to_string()); + progress.current_table = Some(table.clone()); + emit_progress(app_handle, &progress); + } + + writer.flush().map_err(|e| format!("Failed to flush JSONL file: {}", e))?; + *accumulated_processed += local_processed; + Ok(()) + } + + ExportFormat::Sql => { + let schema_ref = schema.map(|s| s.to_string()); + + if sql_opts.include_create_table { + let table_info = adapter + .get_table_info(database, schema, table) + .await + .map_err(|e| e.to_string())?; + let create_stmt = + generate_create_table_sql(table, &table_info, sql_opts.include_drop_table); + writer + .write_all(create_stmt.as_bytes()) + .map_err(|e| e.to_string())?; + writer.write_all(b"\n\n").map_err(|e| e.to_string())?; + } + + let mut batch_rows: Vec> = Vec::new(); + let mut offset = 0u64; + + while offset < total_rows { + let query = + format!("{} LIMIT {} OFFSET {}", base_query, BATCH_SIZE, offset); + let result = adapter + .execute_query(&query) + .await + .map_err(|e| e.to_string())?; + + for row in &result.rows { + let values: Vec = columns + .iter() + .map(|col| row.get(col).cloned().unwrap_or(QueryValue::Null)) + .collect(); + batch_rows.push(values); + local_processed += 1; + + if batch_rows.len() >= sql_opts.batch_size as usize { + let insert_stmt = + generate_insert_sql(&schema_ref, table, columns, &batch_rows); + writer + .write_all(insert_stmt.as_bytes()) + .map_err(|e| e.to_string())?; + writer.write_all(b"\n").map_err(|e| e.to_string())?; + batch_rows.clear(); + } + } + + offset += BATCH_SIZE; + let mut progress = create_progress( + "export", + "processing", + *accumulated_processed + local_processed, + Some(*accumulated_total), + start_time.elapsed().as_millis() as u64, + ); + progress.current_database = database.map(|s| s.to_string()); + progress.current_table = Some(table.clone()); + emit_progress(app_handle, &progress); + } + + if !batch_rows.is_empty() { + let insert_stmt = generate_insert_sql(&schema_ref, table, columns, &batch_rows); + writer + .write_all(insert_stmt.as_bytes()) + .map_err(|e| e.to_string())?; + } + + writer.flush().map_err(|e| format!("Failed to flush SQL file: {}", e))?; + *accumulated_processed += local_processed; + Ok(()) + } + + ExportFormat::Excel => { + let mut workbook = Workbook::new(); + let worksheet = workbook + .add_worksheet() + .set_name(&excel_opts.sheet_name) + .map_err(|e| e.to_string())?; + + if excel_opts.include_header { + for (col_idx, col_name) in columns.iter().enumerate() { + worksheet + .write_string(0, col_idx as u16, col_name) + .map_err(|e| e.to_string())?; + } + } + + if excel_opts.freeze_header && excel_opts.include_header { + worksheet + .set_freeze_panes(1, 0) + .map_err(|e| e.to_string())?; + } + + let header_row_offset = if excel_opts.include_header { 1 } else { 0 }; + let mut offset = 0u64; + let mut row_idx = header_row_offset; + + while offset < total_rows { + let query = + format!("{} LIMIT {} OFFSET {}", base_query, BATCH_SIZE, offset); + let result = adapter + .execute_query(&query) + .await + .map_err(|e| e.to_string())?; + + for row in &result.rows { + for (col_idx, col_name) in columns.iter().enumerate() { + let value = row.get(col_name).cloned().unwrap_or(QueryValue::Null); + write_excel_cell(worksheet, row_idx, col_idx as u16, &value)?; + } + row_idx += 1; + local_processed += 1; + } + + offset += BATCH_SIZE; + let mut progress = create_progress( + "export", + "processing", + *accumulated_processed + local_processed, + Some(*accumulated_total), + start_time.elapsed().as_millis() as u64, + ); + progress.current_database = database.map(|s| s.to_string()); + progress.current_table = Some(table.clone()); + emit_progress(app_handle, &progress); + } + + if excel_opts.auto_fit_columns { + let max_col = columns.len() as u16; + for col_idx in 0..max_col { + worksheet + .set_column_width(col_idx, 12.0) + .map_err(|e| e.to_string())?; + } + } + + *accumulated_processed += local_processed; + workbook + .save(file_path) + .map_err(|e| format!("Failed to save Excel file '{}': {}", file_path.display(), e)) + } + } +} + +// ── Query builders ─────────────────────────────────────────────── + +fn build_export_query(schema: &Option, table: &str, columns: &[String]) -> String { let schema_prefix = schema .as_ref() .map(|s| format!("\"{}\".", s)) @@ -328,42 +897,24 @@ fn build_export_query( .collect::>() .join(", "); - let mut query = format!("SELECT {} FROM {}\"{}\"", cols, schema_prefix, table); - - if let Some(ref where_clause) = source.where_clause { - query.push_str(&format!(" WHERE {}", where_clause)); - } - - if let Some(ref order_by) = source.order_by { - query.push_str(&format!(" ORDER BY {}", order_by)); - } - - query + format!("SELECT {} FROM {}\"{}\"", cols, schema_prefix, table) } -fn build_count_query( - schema: &Option, - table: &str, - where_clause: &Option, -) -> String { +fn build_count_query(schema: &Option, table: &str) -> String { let schema_prefix = schema .as_ref() .map(|s| format!("\"{}\".", s)) .unwrap_or_default(); - let mut query = format!( + format!( "SELECT COUNT(*) AS count FROM {}\"{}\"", schema_prefix, table - ); - - if let Some(ref where_clause) = where_clause { - query.push_str(&format!(" WHERE {}", where_clause)); - } - - query + ) } -fn write_csv_header( - writer: &mut BufWriter, +// ── CSV helpers ────────────────────────────────────────────────── + +fn write_csv_header( + writer: &mut BufWriter, columns: &[String], delimiter: char, ) -> Result<(), std::io::Error> { @@ -377,8 +928,8 @@ fn write_csv_header( Ok(()) } -fn write_csv_row( - writer: &mut BufWriter, +fn write_csv_row( + writer: &mut BufWriter, columns: &[String], row: &crate::database::QueryRow, opts: &CsvExportOptions, @@ -397,7 +948,8 @@ fn write_csv_row( || s.contains('\n') { format!("\"{}\"", s.replace('"', "\"\"")) - } else { + } + else { s.clone() } } @@ -415,6 +967,8 @@ fn write_csv_row( Ok(()) } +// ── JSONL helpers ──────────────────────────────────────────────── + fn row_to_json_object(row: &crate::database::QueryRow, _date_format: &str) -> JsonValue { let mut obj = serde_json::Map::new(); for (key, value) in row { @@ -434,6 +988,8 @@ fn row_to_json_object(row: &crate::database::QueryRow, _date_format: &str) -> Js JsonValue::Object(obj) } +// ── SQL helpers ────────────────────────────────────────────────── + fn generate_create_table_sql( table: &str, _table_info: &crate::database::TableInfo, @@ -496,17 +1052,24 @@ fn query_value_to_sql_literal(value: &QueryValue) -> String { } } -/// Generates a preview of export data. +// ── Preview ────────────────────────────────────────────────────── + +/// Generates a preview of export data from the first export source. pub async fn preview_export( adapter: &A, request: ExportRequest, preview_rows: u32, ) -> Result { - let columns = request.source.columns.clone(); - let table = request.source.table.clone(); + let source = request + .sources + .first() + .ok_or("No export sources specified")? + .clone(); + let columns = source.columns.clone(); + let table = source.table.clone(); let schema = request.schema.clone(); - let base_query = build_export_query(&schema, &table, &columns, &request.source); + let base_query = build_export_query(&schema, &table, &columns); let query = format!("{} LIMIT {}", base_query, preview_rows); let result = adapter @@ -534,7 +1097,7 @@ pub async fn preview_export( }) .collect(); - let count_query = build_count_query(&schema, &table, &request.source.where_clause); + let count_query = build_count_query(&schema, &table); let count_result = adapter .execute_query(&count_query) .await @@ -598,7 +1161,8 @@ fn format_preview( .map(|v| { if v.is_empty() { "NULL".to_string() - } else { + } + else { format!("'{}'", v) } }) @@ -621,6 +1185,81 @@ fn format_preview( } } +// ── Excel helpers ──────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_chat2db_filename_pattern() { + let filename = format_chat2db_filename("users", "csv"); + // Pattern: users_YYYYMMDDHHMMSS.csv + assert!(filename.starts_with("users_")); + assert!(filename.ends_with(".csv")); + // The middle part should be 14 digits (YYYYMMDDHHMMSS) + let timestamp_part = &filename["users_".len()..filename.len() - ".csv".len()]; + assert_eq!(timestamp_part.len(), 14); + assert!(timestamp_part.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn test_format_chat2db_filename_different_extensions() { + // Should work with any extension + let name_sql = format_chat2db_filename("orders", "sql"); + assert!(name_sql.starts_with("orders_")); + assert!(name_sql.ends_with(".sql")); + + let name_xlsx = format_chat2db_filename("orders", "xlsx"); + assert!(name_xlsx.starts_with("orders_")); + assert!(name_xlsx.ends_with(".xlsx")); + } + + #[test] + fn test_format_chat2db_filename_special_chars() { + // Table names with underscores should still produce valid filenames + let filename = format_chat2db_filename("my_table", "csv"); + assert!(filename.starts_with("my_table_")); + assert!(filename.ends_with(".csv")); + // Should only have one underscore before timestamp + let after_table = &filename["my_table".len()..]; + assert!(after_table.starts_with("_")); + } + + #[test] + fn test_format_extension_csv() { + assert_eq!(format_extension(&ExportFormat::Csv), "csv"); + } + + #[test] + fn test_format_extension_jsonl() { + assert_eq!(format_extension(&ExportFormat::Jsonl), "jsonl"); + } + + #[test] + fn test_format_extension_sql() { + assert_eq!(format_extension(&ExportFormat::Sql), "sql"); + } + + #[test] + fn test_format_extension_excel() { + assert_eq!(format_extension(&ExportFormat::Excel), "xlsx"); + } + + #[test] + fn test_format_extension_all_formats() { + let cases = [ + (ExportFormat::Csv, "csv"), + (ExportFormat::Jsonl, "jsonl"), + (ExportFormat::Sql, "sql"), + (ExportFormat::Excel, "xlsx"), + ]; + for (format, expected) in &cases { + assert_eq!(format_extension(format), *expected); + } + } +} + fn write_excel_cell( worksheet: &mut Worksheet, row: u32, @@ -655,7 +1294,7 @@ fn write_excel_cell( } QueryValue::Bytes(b) => { worksheet - .write_string(row, col, &hex::encode(b)) + .write_string(row, col, hex::encode(b)) .map_err(|e| e.to_string())?; Ok(()) } diff --git a/src-tauri/src/transfer/import.rs b/src-tauri/src/transfer/import.rs index b8055f55..33d10cfb 100644 --- a/src-tauri/src/transfer/import.rs +++ b/src-tauri/src/transfer/import.rs @@ -12,45 +12,198 @@ use super::progress::*; use super::types::*; /// Executes a data import operation. +/// Supports Tables scope (import to specific tables), Database scope (create database if needed), +/// and Server scope (execute SQL directly at connection level). pub async fn execute_import( adapter: &A, request: ImportRequest, app_handle: &tauri::AppHandle, ) -> Result { let start_time = Instant::now(); + let mut total_processed: u64 = 0; + let mut total_skipped: u64 = 0; + let mut total_errors: Vec = Vec::new(); emit_progress( app_handle, &create_progress("import", "preparing", 0, None, 0), ); - let csv_opts = request + match request.scope { + TransferScope::Server => { + // Server scope: execute SQL file directly at connection level + // SQL files may contain CREATE DATABASE statements + if let Some(target) = request.tables.first() { + if target.format == ImportFormat::Sql { + let target_result = + import_sql_at_server_level(adapter, target, app_handle, &start_time) + .await?; + total_processed += target_result.processed_rows; + total_skipped += target_result.skipped_rows; + total_errors.extend(target_result.errors); + } else { + return Err("Server scope import only supports SQL files".to_string()); + } + } + } + TransferScope::Database => { + // Database scope: create database if needed, then import tables + if request.create_database_if_not_exists.unwrap_or(false) { + if let Some(ref db_name) = request.database { + let db_exists = adapter + .list_databases() + .await + .map_err(|e| e.to_string())? + .iter() + .any(|d| &d.name == db_name); + if !db_exists { + adapter + .execute_query(&format!("CREATE DATABASE \"{}\"", db_name)) + .await + .map_err(|e| format!("Failed to create database: {}", e))?; + } + } + } + for target in &request.tables { + let target_result = + import_target(adapter, &request, target, app_handle, &start_time).await?; + total_processed += target_result.processed_rows; + total_skipped += target_result.skipped_rows; + total_errors.extend(target_result.errors); + } + } + TransferScope::Tables => { + // Tables scope: import to specific tables (current behavior) + for target in &request.tables { + let target_result = + import_target(adapter, &request, target, app_handle, &start_time).await?; + total_processed += target_result.processed_rows; + total_skipped += target_result.skipped_rows; + total_errors.extend(target_result.errors); + } + } + } + + emit_progress( + app_handle, + &create_progress( + "import", + "finalizing", + total_processed, + None, + start_time.elapsed().as_millis() as u64, + ), + ); + + Ok(TransferResult { + success: total_errors.is_empty(), + total_rows: total_processed + total_skipped, + processed_rows: total_processed, + skipped_rows: total_skipped, + error_count: total_errors.len() as u64, + duration_ms: start_time.elapsed().as_millis() as u64, + output_path: None, + output_size_bytes: None, + errors: total_errors, + }) +} + +/// Import SQL file at server level (can contain CREATE DATABASE statements). +async fn import_sql_at_server_level( + adapter: &A, + target: &ImportTarget, + app_handle: &tauri::AppHandle, + start_time: &Instant, +) -> Result { + let file_path = Path::new(&target.file_path); + let content = std::fs::read_to_string(file_path) + .map_err(|e| format!("Failed to read SQL file: {}", e))?; + + let mut processed_rows: u64 = 0; + let mut skipped_rows: u64 = 0; + let mut errors: Vec = Vec::new(); + + // Split by semicolon and execute each statement + for (idx, stmt) in content.split(';').filter(|s| !s.trim().is_empty()).enumerate() { + let stmt = stmt.trim(); + if stmt.is_empty() { + continue; + } + let result = adapter.execute_query(stmt).await; + match result { + Ok(_) => { + processed_rows += 1; + } + Err(e) => { + errors.push(TransferError { + row_number: None, + statement_number: Some(idx as u64 + 1), + message: e.to_string(), + sql: Some(stmt.to_string()), + }); + skipped_rows += 1; + } + } + + emit_progress( + app_handle, + &create_progress( + "import", + "processing", + processed_rows, + None, + start_time.elapsed().as_millis() as u64, + ), + ); + } + + Ok(TransferResult { + success: errors.is_empty(), + total_rows: processed_rows + skipped_rows, + processed_rows, + skipped_rows, + error_count: errors.len() as u64, + duration_ms: start_time.elapsed().as_millis() as u64, + output_path: None, + output_size_bytes: None, + errors, + }) +} + +async fn import_target( + adapter: &A, + request: &ImportRequest, + target: &ImportTarget, + app_handle: &tauri::AppHandle, + start_time: &Instant, +) -> Result { + let csv_opts = target .csv_options .clone() .unwrap_or_else(csv_import_defaults); - let file_path = Path::new(&request.file_path); + let file_path = Path::new(&target.file_path); let file = File::open(file_path).map_err(|e| format!("Failed to open file: {}", e))?; let mut processed_rows: u64 = 0; let mut skipped_rows: u64 = 0; let mut errors: Vec = Vec::new(); - match request.format { + match target.format { ImportFormat::Csv => { let reader = BufReader::new(file); let delimiter = csv_opts.delimiter; let mut lines = reader.lines().peekable(); - let header_line = if csv_opts.has_header { + let _header_line = if csv_opts.has_header { lines .next() .transpose() .map_err(|e| format!("Failed to read header: {}", e))? .unwrap_or_default() } else { - request + target .column_mappings .iter() .map(|m| m.source_column.clone()) @@ -59,9 +212,9 @@ pub async fn execute_import( }; let header_columns: Vec = if csv_opts.has_header { - parse_csv_line(&header_line, delimiter) + parse_csv_line(&_header_line, delimiter) } else { - request + target .column_mappings .iter() .map(|m| m.source_column.clone()) @@ -84,11 +237,7 @@ pub async fn execute_import( .iter() .enumerate() .filter_map(|(i, col)| { - let mapping = request - .column_mappings - .iter() - .find(|m| m.source_column == *col); - + let mapping = target.column_mappings.iter().find(|m| m.source_column == *col); if mapping.is_none() || mapping.and_then(|m| m.target_column.as_ref()).is_none() { @@ -103,9 +252,10 @@ pub async fn execute_import( processed_rows += 1; if batch_values.len() >= request.batch_size as usize { - let insert_result = execute_batch_insert( + let insert_result = execute_batch_insert_for_target( adapter, - &request, + request, + target, &batch_values, processed_rows - batch_values.len() as u64, ) @@ -141,9 +291,10 @@ pub async fn execute_import( } if !batch_values.is_empty() { - let insert_result = execute_batch_insert( + let insert_result = execute_batch_insert_for_target( adapter, - &request, + request, + target, &batch_values, processed_rows - batch_values.len() as u64, ) @@ -169,7 +320,7 @@ pub async fn execute_import( ImportFormat::Jsonl => { let reader = BufReader::new(file); let mut batch_values: Vec> = Vec::new(); - let _target_columns: Vec = request + let _target_columns: Vec = target .column_mappings .iter() .filter_map(|m| m.target_column.clone()) @@ -199,7 +350,7 @@ pub async fn execute_import( } let obj = json_obj.as_object().unwrap(); - let values: Vec = request + let values: Vec = target .column_mappings .iter() .filter_map(|m| { @@ -225,9 +376,10 @@ pub async fn execute_import( processed_rows += 1; if batch_values.len() >= request.batch_size as usize { - let insert_result = execute_batch_insert( + let insert_result = execute_batch_insert_for_target( adapter, - &request, + request, + target, &batch_values, processed_rows - batch_values.len() as u64, ) @@ -263,9 +415,10 @@ pub async fn execute_import( } if !batch_values.is_empty() { - execute_batch_insert( + execute_batch_insert_for_target( adapter, - &request, + request, + target, &batch_values, processed_rows - batch_values.len() as u64, ) @@ -349,7 +502,7 @@ pub async fn execute_import( let mut workbook: Xlsx<_> = open_workbook(file_path) .map_err(|e| format!("Failed to open Excel file: {}", e))?; - let sheet_name = request + let sheet_name = target .excel_options .as_ref() .map(|o| o.sheet_name.clone()) @@ -360,7 +513,7 @@ pub async fn execute_import( .ok_or_else(|| format!("Sheet '{}' not found", sheet_name))? .map_err(|e| format!("Failed to read sheet '{}': {:?}", sheet_name, e))?; - let has_header = request + let has_header = target .excel_options .as_ref() .map(|o| o.has_header) @@ -377,7 +530,7 @@ pub async fn execute_import( }) .unwrap_or_default() } else { - request + target .column_mappings .iter() .map(|m| m.source_column.clone()) @@ -391,11 +544,7 @@ pub async fn execute_import( .iter() .enumerate() .filter_map(|(col_idx, col)| { - let mapping = request - .column_mappings - .iter() - .find(|m| m.source_column == *col); - + let mapping = target.column_mappings.iter().find(|m| m.source_column == *col); if mapping.is_none() || mapping.and_then(|m| m.target_column.as_ref()).is_none() { @@ -414,9 +563,10 @@ pub async fn execute_import( processed_rows += 1; if batch_values.len() >= request.batch_size as usize { - let insert_result = execute_batch_insert( + let insert_result = execute_batch_insert_for_target( adapter, - &request, + request, + target, &batch_values, processed_rows - batch_values.len() as u64, ) @@ -452,9 +602,10 @@ pub async fn execute_import( } if !batch_values.is_empty() { - let insert_result = execute_batch_insert( + let insert_result = execute_batch_insert_for_target( adapter, - &request, + request, + target, &batch_values, processed_rows - batch_values.len() as u64, ) @@ -478,17 +629,6 @@ pub async fn execute_import( } } - emit_progress( - app_handle, - &create_progress( - "import", - "finalizing", - processed_rows, - None, - start_time.elapsed().as_millis() as u64, - ), - ); - Ok(TransferResult { success: errors.is_empty(), total_rows: processed_rows + skipped_rows, @@ -522,9 +662,10 @@ fn parse_csv_line(line: &str, delimiter: char) -> Vec { values } -async fn execute_batch_insert( +async fn execute_batch_insert_for_target( adapter: &A, request: &ImportRequest, + target: &ImportTarget, batch: &[Vec], _start_row: u64, ) -> Result { @@ -537,7 +678,7 @@ async fn execute_batch_insert( .as_ref() .map(|s| format!("\"{}\".", s)) .unwrap_or_default(); - let target_columns: Vec = request + let target_columns: Vec = target .column_mappings .iter() .filter_map(|m| m.target_column.clone()) @@ -569,12 +710,11 @@ async fn execute_batch_insert( let sql = format!( "INSERT INTO {}\"{}\" ({}) VALUES {}", schema_prefix, - request.table, + target.table, col_list, values_list.join(", ") ); - // Dry-run: validate INSERT statement was built but skip the write. if request.dry_run { return Ok(batch.len() as u64); } diff --git a/src-tauri/src/transfer/migration.rs b/src-tauri/src/transfer/migration.rs index 209eba4d..4c74f965 100644 --- a/src-tauri/src/transfer/migration.rs +++ b/src-tauri/src/transfer/migration.rs @@ -25,32 +25,258 @@ pub async fn execute_migration( &create_progress("migration", "preparing", 0, None, 0), ); - for (table_idx, table_plan) in request.table_plans.iter().enumerate() { + match request.scope { + TransferScope::Server => { + let source_databases = source_adapter + .list_databases() + .await + .map_err(|e| format!("Failed to list source databases: {}", e))?; + for source_db in source_databases { + if request.create_target_database_if_not_exists.unwrap_or(false) { + let target_databases = target_adapter + .list_databases() + .await + .map_err(|e| e.to_string())?; + if !target_databases.iter().any(|d| d.name == source_db.name) { + target_adapter + .execute_query(&format!("CREATE DATABASE \"{}\"", source_db.name)) + .await + .map_err(|e| format!("Failed to create target database: {}", e))?; + } + } + let tables = source_adapter + .list_tables(Some(&source_db.name), None) + .await + .map_err(|e| e.to_string())?; + for table_info in tables { + let columns = source_adapter + .list_columns(Some(&source_db.name), None, &table_info.name) + .await + .map_err(|e| e.to_string())?; + let mappings: Vec = columns + .iter() + .map(|c| MigrationMapping { + source_column: c.name.clone(), + source_type: c.data_type.clone(), + target_column: c.name.clone(), + target_type: c.data_type.clone(), + conversion: MigrationConversion::Direct, + }) + .collect(); + let table_plan = MigrationTablePlan { + source_table: table_info.name.clone(), + target_table: table_info.name.clone(), + column_mappings: mappings, + }; + let mut req = request.clone(); + req.source_database = Some(source_db.name.clone()); + req.target_database = Some(source_db.name.clone()); + req.table_plans = vec![table_plan]; + migrate_single_table_plan( + source_adapter, + target_adapter, + &req, + app_handle, + start_time, + &mut total_processed, + &mut total_skipped, + &mut total_errors, + ) + .await?; + } + } + } + TransferScope::Database => { + if request.create_target_database_if_not_exists.unwrap_or(false) { + if let Some(ref target_db) = request.target_database { + let target_databases = target_adapter + .list_databases() + .await + .map_err(|e| e.to_string())?; + if !target_databases.iter().any(|d| d.name == *target_db) { + target_adapter + .execute_query(&format!("CREATE DATABASE \"{}\"", target_db)) + .await + .map_err(|e| format!("Failed to create target database: {}", e))?; + } + } + } + if request.table_plans.is_empty() { + let source_db = request.source_database.as_ref().ok_or_else(|| { + "Source database required for Database scope with empty table_plans" + })?; + let tables = source_adapter + .list_tables(Some(source_db), request.source_schema.as_deref()) + .await + .map_err(|e| e.to_string())?; + let mut auto_plans: Vec = Vec::new(); + for table_info in tables { + let columns = source_adapter + .list_columns(Some(source_db), request.source_schema.as_deref(), &table_info.name) + .await + .map_err(|e| e.to_string())?; + let mappings: Vec = columns + .iter() + .map(|c| MigrationMapping { + source_column: c.name.clone(), + source_type: c.data_type.clone(), + target_column: c.name.clone(), + target_type: c.data_type.clone(), + conversion: MigrationConversion::Direct, + }) + .collect(); + auto_plans.push(MigrationTablePlan { + source_table: table_info.name.clone(), + target_table: table_info.name.clone(), + column_mappings: mappings, + }); + } + let mut req = request.clone(); + req.table_plans = auto_plans; + for _ in req.table_plans.iter() { + migrate_single_table_plan( + source_adapter, + target_adapter, + &req, + app_handle, + start_time, + &mut total_processed, + &mut total_skipped, + &mut total_errors, + ) + .await?; + } + } else { + for _ in request.table_plans.iter() { + migrate_single_table_plan( + source_adapter, + target_adapter, + &request, + app_handle, + start_time, + &mut total_processed, + &mut total_skipped, + &mut total_errors, + ) + .await?; + } + } + } + TransferScope::Tables => { + for (table_idx, table_plan) in request.table_plans.iter().enumerate() { + emit_progress( + app_handle, + &TransferProgress { + operation: "migration".to_string(), + phase: "processing".to_string(), + current_database: None, + current_table: Some(table_plan.source_table.clone()), + total_rows: None, + processed_rows: total_processed, + skipped_rows: total_skipped, + error_count: total_errors.len() as u64, + percent: 0.0, + elapsed_ms: start_time.elapsed().as_millis() as u64, + estimated_remaining_ms: None, + message: Some(format!( + "Migrating table {} of {}", + table_idx + 1, + request.table_plans.len() + )), + }, + ); + + let table_result = migrate_table( + source_adapter, + target_adapter, + &request, + table_plan, + app_handle, + start_time, + ) + .await; + + match table_result { + Ok(result) => { + total_processed += result.processed_rows; + total_skipped += result.skipped_rows; + if !result.success { + total_errors.extend(result.errors); + } + } + Err(e) => { + total_errors.push(TransferError { + row_number: None, + statement_number: None, + message: format!("Table {} failed: {}", table_plan.source_table, e), + sql: None, + }); + if request.on_error == MigrationErrorStrategy::Abort { + break; + } + } + } + } + } + } + + emit_progress( + app_handle, + &create_progress( + "migration", + "finalizing", + total_processed, + None, + start_time.elapsed().as_millis() as u64, + ), + ); + + Ok(TransferResult { + success: total_errors.is_empty(), + total_rows: total_processed + total_skipped, + processed_rows: total_processed, + skipped_rows: total_skipped, + error_count: total_errors.len() as u64, + duration_ms: start_time.elapsed().as_millis() as u64, + output_path: None, + output_size_bytes: None, + errors: total_errors, + }) +} + +async fn migrate_single_table_plan( + source_adapter: &A1, + target_adapter: &A2, + request: &MigrationRequest, + app_handle: &tauri::AppHandle, + start_time: Instant, + total_processed: &mut u64, + total_skipped: &mut u64, + total_errors: &mut Vec, +) -> Result<(), String> { + if let Some(table_plan) = request.table_plans.first() { emit_progress( app_handle, &TransferProgress { operation: "migration".to_string(), phase: "processing".to_string(), + current_database: request.source_database.clone(), current_table: Some(table_plan.source_table.clone()), total_rows: None, - processed_rows: total_processed, - skipped_rows: total_skipped, + processed_rows: *total_processed, + skipped_rows: *total_skipped, error_count: total_errors.len() as u64, percent: 0.0, elapsed_ms: start_time.elapsed().as_millis() as u64, estimated_remaining_ms: None, - message: Some(format!( - "Migrating table {} of {}", - table_idx + 1, - request.table_plans.len() - )), + message: Some(format!("Migrating table {}", table_plan.source_table)), }, ); let table_result = migrate_table( source_adapter, target_adapter, - &request, + request, table_plan, app_handle, start_time, @@ -59,8 +285,8 @@ pub async fn execute_migration( match table_result { Ok(result) => { - total_processed += result.processed_rows; - total_skipped += result.skipped_rows; + *total_processed += result.processed_rows; + *total_skipped += result.skipped_rows; if !result.success { total_errors.extend(result.errors); } @@ -72,35 +298,10 @@ pub async fn execute_migration( message: format!("Table {} failed: {}", table_plan.source_table, e), sql: None, }); - if request.on_error == MigrationErrorStrategy::Abort { - break; - } } } } - - emit_progress( - app_handle, - &create_progress( - "migration", - "finalizing", - total_processed, - None, - start_time.elapsed().as_millis() as u64, - ), - ); - - Ok(TransferResult { - success: total_errors.is_empty(), - total_rows: total_processed + total_skipped, - processed_rows: total_processed, - skipped_rows: total_skipped, - error_count: total_errors.len() as u64, - duration_ms: start_time.elapsed().as_millis() as u64, - output_path: None, - output_size_bytes: None, - errors: total_errors, - }) + Ok(()) } async fn migrate_table( @@ -214,6 +415,7 @@ async fn migrate_table( &TransferProgress { operation: "migration".to_string(), phase: "processing".to_string(), + current_database: None, current_table: Some(table_plan.source_table.clone()), total_rows: Some(total_rows), processed_rows: processed_rows, diff --git a/src-tauri/src/transfer/progress.rs b/src-tauri/src/transfer/progress.rs index 0daa875d..bba10d19 100644 --- a/src-tauri/src/transfer/progress.rs +++ b/src-tauri/src/transfer/progress.rs @@ -37,6 +37,7 @@ pub fn create_progress( TransferProgress { operation: operation.to_string(), phase: phase.to_string(), + current_database: None, current_table: None, total_rows, processed_rows, diff --git a/src-tauri/src/transfer/types.rs b/src-tauri/src/transfer/types.rs index 1774076a..b5ebfbad 100644 --- a/src-tauri/src/transfer/types.rs +++ b/src-tauri/src/transfer/types.rs @@ -16,6 +16,16 @@ pub enum ExportFormat { Excel, } +/// Scope of a transfer operation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub enum TransferScope { + Server, + Database, + #[default] + Tables, +} + /// CSV export options with sensible defaults. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -71,25 +81,25 @@ pub struct ExcelExportOptions { pub freeze_header: bool, } -/// Export source is always a table (Custom Query removed for simplicity). +/// Export source is always a table. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExportSource { pub table: String, pub columns: Vec, - pub where_clause: Option, - pub order_by: Option, - pub limit: Option, } /// Export request payload. +/// `output_path`: File path for Tables scope (single file), folder path for Database/Server scope. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExportRequest { pub connection_id: String, pub database: Option, pub schema: Option, - pub source: ExportSource, + #[serde(default)] + pub scope: TransferScope, + pub sources: Vec, pub format: ExportFormat, pub csv_options: Option, pub jsonl_options: Option, @@ -152,6 +162,18 @@ pub struct ExcelImportOptions { pub has_header: bool, } +/// Import target table configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportTarget { + pub table: String, + pub file_path: String, + pub format: ImportFormat, + pub column_mappings: Vec, + pub csv_options: Option, + pub excel_options: Option, +} + /// Import request payload. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -159,10 +181,9 @@ pub struct ImportRequest { pub connection_id: String, pub database: Option, pub schema: Option, - pub table: String, - pub file_path: String, - pub format: ImportFormat, - pub column_mappings: Vec, + #[serde(default)] + pub scope: TransferScope, + pub tables: Vec, #[serde(default)] pub conflict_strategy: ConflictStrategy, #[serde(default = "default_import_batch_size")] @@ -173,8 +194,8 @@ pub struct ImportRequest { pub truncate_before: bool, #[serde(default)] pub dry_run: bool, - pub csv_options: Option, - pub excel_options: Option, + #[serde(default)] + pub create_database_if_not_exists: Option, } // ── Progress & Results ────────────────────────────────────────── @@ -183,14 +204,15 @@ pub struct ImportRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TransferProgress { - pub operation: String, // "export" | "import" | "ddl" | "sql_file" | "migration" - pub phase: String, // "preparing" | "processing" | "finalizing" + pub operation: String, + pub phase: String, + pub current_database: Option, pub current_table: Option, pub total_rows: Option, pub processed_rows: u64, pub skipped_rows: u64, pub error_count: u64, - pub percent: f32, // 0.0–100.0 + pub percent: f32, pub elapsed_ms: u64, pub estimated_remaining_ms: Option, pub message: Option, @@ -312,6 +334,8 @@ pub struct DdlRequest { pub connection_id: String, pub database: Option, pub schema: Option, + #[serde(default)] + pub scope: TransferScope, pub objects: Vec, pub options: DdlOptions, } @@ -383,6 +407,8 @@ pub struct MigrationRequest { pub target_connection_id: String, pub target_database: Option, pub target_schema: Option, + #[serde(default)] + pub scope: TransferScope, pub table_plans: Vec, #[serde(default = "default_migration_batch_size")] pub batch_size: u32, @@ -400,6 +426,8 @@ pub struct MigrationRequest { pub migrate_constraints: bool, #[serde(default)] pub disable_fk_checks: bool, + #[serde(default)] + pub create_target_database_if_not_exists: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] @@ -432,3 +460,108 @@ pub struct MigrationTablePreview { fn default_migration_batch_size() -> u32 { 5000 } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transfer_scope_serde_roundtrip_server() { + let scope = TransferScope::Server; + let json = serde_json::to_string(&scope).unwrap(); + assert_eq!(json, "\"server\""); + let deserialized: TransferScope = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, TransferScope::Server); + } + + #[test] + fn test_transfer_scope_serde_roundtrip_database() { + let scope = TransferScope::Database; + let json = serde_json::to_string(&scope).unwrap(); + assert_eq!(json, "\"database\""); + let deserialized: TransferScope = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, TransferScope::Database); + } + + #[test] + fn test_transfer_scope_serde_roundtrip_tables() { + let scope = TransferScope::Tables; + let json = serde_json::to_string(&scope).unwrap(); + assert_eq!(json, "\"tables\""); + let deserialized: TransferScope = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, TransferScope::Tables); + } + + #[test] + fn test_transfer_scope_default_is_tables() { + let default_scope: TransferScope = Default::default(); + assert_eq!(default_scope, TransferScope::Tables); + } + + #[test] + fn test_transfer_scope_serde_default_on_missing() { + #[derive(Serialize, Deserialize)] + struct Container { + #[serde(default)] + scope: TransferScope, + } + let json = r#"{}"#; + let container: Container = serde_json::from_str(json).unwrap(); + assert_eq!(container.scope, TransferScope::Tables); + } + + #[test] + fn test_export_format_serde_roundtrip() { + let formats = [ + ExportFormat::Csv, + ExportFormat::Jsonl, + ExportFormat::Sql, + ExportFormat::Excel, + ]; + for format in &formats { + let json = serde_json::to_string(format).unwrap(); + let deserialized: ExportFormat = serde_json::from_str(&json).unwrap(); + assert_eq!(&deserialized, format); + } + } + + #[test] + fn test_export_request_serde_roundtrip() { + let request = ExportRequest { + connection_id: "test-conn".into(), + database: Some("test_db".into()), + schema: None, + scope: TransferScope::Tables, + sources: vec![ExportSource { + table: "users".into(), + columns: vec!["id".into(), "name".into()], + }], + format: ExportFormat::Csv, + csv_options: None, + jsonl_options: None, + sql_options: None, + excel_options: None, + output_path: "/tmp/export.csv".into(), + }; + let json = serde_json::to_string(&request).unwrap(); + let deserialized: ExportRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.connection_id, request.connection_id); + assert_eq!(deserialized.database, request.database); + assert_eq!(deserialized.scope, TransferScope::Tables); + assert_eq!(deserialized.sources.len(), 1); + assert_eq!(deserialized.sources[0].table, "users"); + assert_eq!(deserialized.format, ExportFormat::Csv); + } + + #[test] + fn test_export_request_default_scope_is_tables() { + let json = r#"{ + "connectionId": "test", + "sources": [], + "format": "csv", + "outputPath": "/tmp/test.csv" + }"#; + let request: ExportRequest = serde_json::from_str(json).unwrap(); + assert_eq!(request.scope, TransferScope::Tables); + } +} diff --git a/src/components/transfer/export/ExportExecuteStep.vue b/src/components/transfer/export/ExportExecuteStep.vue index 434a827b..393a44c8 100644 --- a/src/components/transfer/export/ExportExecuteStep.vue +++ b/src/components/transfer/export/ExportExecuteStep.vue @@ -1,5 +1,5 @@ diff --git a/src/components/transfer/export/ExportWizard.vue b/src/components/transfer/export/ExportWizard.vue index b69aad9f..46ace815 100644 --- a/src/components/transfer/export/ExportWizard.vue +++ b/src/components/transfer/export/ExportWizard.vue @@ -1,5 +1,5 @@ @@ -165,32 +225,65 @@ function startExport() { min-height="340px" >
- -
+ +
+ + + +
+ + +
+ + +
+ + +
+ + + + {{ scope === 'server' ? 'All databases on this server' : scope === 'database' ? (database ? `All tables in ${database}` : 'Select a database') : (selectedTables.length > 0 ? `${selectedTables.length} tables` : 'Select tables') }} +
- -
+ +
- -
@@ -201,166 +294,146 @@ function startExport() { :step-number="2" icon="i-carbon-document" icon-class="text-blue-600 dark:text-blue-500" - min-height="200px" + min-height="180px" > -
- -
- -
- -
-
- - -
- -
-
-
- - -
-
- - -
-
-
+
+ +
+ +
+ - -
-
- - + +
+
-
- -
-
-
- - -
-
- - -
-
- - + +
+ +
+
+ + +
+
+ + +
-
-
- Sheet names: db-schema-table (each table as a separate sheet) -
-
- -
-
-
- - + +
+
+ + +
-
-
+ + +
+
+ + +
+
- +
-
+
- +
-
-
-
- Select a format to configure options + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
- -
+ +
+
-
-
- -
- -
- - -
- - {{ selectedTables.length }} {{ t('transfer.migration.tablesSelected', 'tables') }} - - - {{ tableColumns.reduce((sum, tc) => sum + tc.selectedColumns.length, 0) }} cols - - - {{ selectedFormat }} - + +
+
+ + {{ scope === 'server' ? 'All databases' : scope === 'database' ? (database || 'Select db') : `${selectedTables.length} tables` }} + + + {{ selectedFormat }} + +
+ +
diff --git a/src/components/transfer/import/ImportExecuteStep.vue b/src/components/transfer/import/ImportExecuteStep.vue index d1979bfd..4b4ae3c5 100644 --- a/src/components/transfer/import/ImportExecuteStep.vue +++ b/src/components/transfer/import/ImportExecuteStep.vue @@ -55,10 +55,12 @@ function handleRunInBackground() { 'import', { connectionId: transferStore.importRequest.connectionId || '', - table: transferStore.importRequest.table || '', + scope: transferStore.importRequest.scope || 'tables', + tables: transferStore.importRequest.tables || [], filePath: transferStore.importRequest.filePath || '', format: transferStore.importRequest.format || 'csv', conflictStrategy: transferStore.importRequest.conflictStrategy, + createDatabaseIfNotExists: transferStore.importRequest.createDatabaseIfNotExists, }, progress.value?.totalRows || 0, ) diff --git a/src/components/transfer/import/ImportMappingStep.vue b/src/components/transfer/import/ImportMappingStep.vue index 61c325ea..f5065543 100644 --- a/src/components/transfer/import/ImportMappingStep.vue +++ b/src/components/transfer/import/ImportMappingStep.vue @@ -1,5 +1,5 @@ - - diff --git a/src/components/transfer/shared/ConnectionSelector.vue b/src/components/transfer/shared/ConnectionSelector.vue index 5fe384a6..d8db5ed5 100644 --- a/src/components/transfer/shared/ConnectionSelector.vue +++ b/src/components/transfer/shared/ConnectionSelector.vue @@ -18,6 +18,7 @@ const props = defineProps<{ connectionId?: string database?: string schema?: string + showDatabase?: boolean showSchema?: boolean }>() @@ -249,7 +250,7 @@ const shouldShowSchema = computed(() => {
-
+