diff --git a/README.md b/README.md
index 221e41b..30faf35 100644
--- a/README.md
+++ b/README.md
@@ -5,12 +5,10 @@
**Move tables across databases. Schema, data and all.**
SqlJam is a desktop import/export tool for relational and OLAP databases: MySQL, MariaDB, PostgreSQL, Oracle, SQL Server, H2, SQLite, DuckDB, ClickHouse and more.
-It copies tables (with indexes, constraints, sequences and partitions) **directly into another database** or into the same schema as copies, or saves them as a **self-describing export package** of SQL or **Parquet** files that can be imported later.
+It copies tables (with indexes, constraints, sequences and partitions) **directly into another database** or into the same schema as copies, or saves them as a **self-describing export package** of SQL, **Parquet** or **CSV** files that can be imported later. Imports into a database run in **one transaction** which an error rolls back as a whole, and rows are **inserted, updated or skipped by key**.


-
-


@@ -27,17 +25,20 @@ It copies tables (with indexes, constraints, sequences and partitions) **directl
| Different SQL dialects break hand-written migrations | One metadata model, **a dialect per target**: types, identifiers, defaults, identity and literals are translated |
| "Works on my 16, fails on their 9.4" | **Version-aware dialects** (`Oracle11gDialect`, `PostgreSQL9Dialect`, `MySQL56Dialect`, `SQLServer2008Dialect` …) chosen by detected or selected version |
| Tables arrive without keys, indexes or sequences | Copies **primary keys, unique/regular indexes, foreign keys, comments, sequences, identity values and partitions** |
-| Huge `data.sql` files nobody can open | **Split data files** by size (default 10 MB): `orders.sql`, `orders_2.sql` …, one file per table or a single `data.sql` |
+| Huge `data.sql` files nobody can open | **Split data files** by size: `orders.sql`, `orders_2.sql` … and `orders.csv`, `orders_2.csv` … at 10 MB, `orders.parquet`, `orders_2.parquet` … at 256 MB, imported in the order of the parts |
| BLOB/CLOB literals explode scripts and hit 4000-byte limits | **LOBs are stored as files** (`lob/
/000001_.blob`) and restored by primary key |
| Scripts lose context ("which DB? which version? complete?") | **`manifest.json`** records source, target, options, files (with SHA-256) and row counts, export and import are paired |
| Timestamps shift, unsigned values overflow, `bit` becomes garbage | Portable value pipeline: `LocalDateTime`, unsigned widening, bit strings, money, UUID, JSON, XML, arrays |
| Re-runs fail half way | Idempotent DDL (drop/create, `IF NOT EXISTS`, sequence restart), FKs created after data |
| Long jobs are a black box | **Percentage progress**, per-table progress, error log and cancel in the UI |
| Analytics teams want Parquet, not SQL | **Parquet packages** (ZSTD, Snappy, GZIP) through an embedded DuckDB, and **Parquet files of other tools** loaded into any table |
+| Spreadsheets and text tools want CSV | **CSV packages** with your delimiter, quote, NULL text, header, encoding (UTF-8 with BOM for Excel, GBK and more) and date format, read back with the exact column types kept in `manifest.json`, and **CSV files of other tools** with detected delimiter and types |
+| An import went wrong, now what? | **One transaction** by default: an error rolls back the whole import, or **commit every N rows** like other import tools. **Skip rows with errors** goes on and lists every skipped row in the log, which you can filter and save. **Confirm before commit** lets you review a successful import in one transaction, then **Commit** or **Roll Back** |
+| Some rows exist already | **Update or insert by key** updates the rows of the same primary key, or of the columns you choose, and inserts the others. **Skip existing by key** keeps them and inserts the new rows only. Insert is the default |
| Warehouses live apart from OLTP databases | **DuckDB and ClickHouse data sources**, grouped as OLAP databases next to the relational ones |
| Only part of a table is needed | **Data viewer query**: columns, WHERE, GROUP BY and ORDER BY, then export the rows of the query. Missing target tables are created with the selected columns |
| A copy next to the original | **Copy in the same schema** with a table name such as `{table}_copy`. Keys, indexes and constraints get their own names and the source tables are protected |
-| What is inside this package? | **Package viewer**: files with size and modified time, previews of manifest, SQL, LOB and Parquet files |
+| What is inside this package? | **Package viewer**: files with size and modified time, previews of manifest, SQL, LOB, Parquet and CSV files |
| Custom and modern types | PostgreSQL enums and domains, MySQL, MariaDB and Oracle vectors, DuckDB unions and fixed arrays, ClickHouse variants, maps and tuples |
---
@@ -55,9 +56,9 @@ flowchart LR
D --> DS[DdlScripter]
DS --> I[ImportExporter] --> TD[(Target DB)]
DS --> E[ScriptExporter] --> P[/Export package schema.sql · data*.sql · lob/ · manifest.json/]
- DS --> PE[ParquetExporter DuckDB workspace] --> PP[/Parquet package schema.sql · data/*.parquet · manifest.json/]
+ DS --> PE[ParquetExporter · CsvExporter DuckDB workspace] --> PP[/Parquet or CSV package schema.sql · data/*.parquet or *.csv · manifest.json/]
P --> SI[ScriptImporter] --> TD
- PP --> PI[ParquetImporter] --> TD
+ PP --> PI[ParquetImporter · CsvImporter] --> TD
```
| Component | Role |
@@ -66,6 +67,8 @@ flowchart LR
| `ScriptExporter` / `ImportExporter` | Facades: export to a package / import into a database |
| `ScriptImporter` | Imports an export package (validates `manifest.json`, runs files in order, restores LOBs) |
| `ParquetExporter` / `ParquetImporter` | Parquet packages and Parquet files of other tools, written and read by an embedded DuckDB workspace |
+| `CsvExporter` / `CsvImporter` | CSV packages (column types in `manifest.json`) and CSV files of other tools, through the same DuckDB workspace |
+| `ManualTransaction` | Runs an import in one transaction which the user commits or rolls back. DDL which would commit the rows (MySQL, MariaDB, Oracle, H2) waits for the commit |
| `AbstractFileImporter` | Picks the importer of a package by the data format in its `manifest.json` |
| `TableQuery` | Columns, WHERE, GROUP BY and ORDER BY of the data viewer, used by exports per table |
| `Dialect` + `impexp.db.*` | Per-database SQL generation. Old versions are subclasses |
@@ -133,7 +136,7 @@ SqlJam starts with a splash screen while it loads the configuration, the data so
1. Create a **data source** on the login page and click **Connect**. The first start brings an **Example Shop (H2)** data source with a few e-commerce tables to try everything right away. **Example** on the login page or **Help → Restore Example Database** brings it back at any time.
2. Right-click a database or schema → **Export…**
-3. Pick tables, choose **SQL scripts** or **Parquet** (folder) or **Database** (another data source, or a new one from the **+** button next to it), press **Start**.
+3. Pick tables, choose **SQL scripts**, **Parquet** or **CSV** (folder) or **Database** (another data source, or a new one from the **+** button next to it), press **Start**. An import into a database runs in one transaction or commits every N rows (N is typed next to it), check **Skip rows with errors** to go on after bad rows and **Confirm before commit** to review it first, and choose **Update or insert by key** or **Skip existing by key** for rows which exist already.
4. Open a table, narrow its rows on the **Data** tab with columns, WHERE, GROUP BY and ORDER BY, then **Export…** just those rows.
5. Right-click a connected data source → **Disconnect** when you are done, its table tabs close with it. **Exit** sits at the right end of the toolbar. **Help → About SqlJam** shows the version, the home page, the repository and the author, and the GitHub icon at the bottom right opens the repository.
@@ -162,7 +165,7 @@ export/
└── manifest.json # source, target, options, files (sha256), row counts
```
-A Parquet package has the same layout with `data/.parquet` instead of SQL data and LOB files. LOB values stay inside the Parquet columns and `manifest.json` records `"dataFormat": "PARQUET"`, so an import picks the right importer by itself.
+A Parquet package has the same layout with `data/.parquet` instead of SQL data and LOB files, a CSV package with `data/.csv` and a header row. LOB values stay inside the Parquet or CSV columns, `manifest.json` records `"dataFormat": "PARQUET"` or `"CSV"` (with the type of every CSV column), so an import picks the right importer by itself and reads the values back exactly.
---
@@ -192,6 +195,12 @@ ParquetExporter parquet = new ParquetExporter(new File("export-parquet"));
parquet.setTargetDbType(DbType.POSTGRESQL); // schema.sql and constraints.sql for PostgreSQL
parquet.setCompression("ZSTD");
parquet.exportDdlAndData();
+
+// The same tables as a CSV package: data/orders.csv, data/orders_2.csv ...
+CsvExporter csv = new CsvExporter(new File("export-csv"));
+csv.setDelimiter(";");
+csv.setMaxFileSize(10 * 1024 * 1024);
+csv.exportDdlAndData();
```
**Output**: `export/schema.sql`, `export/data/orders.sql`, `export/data/orders_2.sql`, `export/lob/…`, `export/manifest.json`:
@@ -258,11 +267,18 @@ try (Connection connection = DriverManager.getConnection(
}
```
-**Output**: `ScriptImporter` refuses packages whose status is not `COMPLETED`, whose target type differs, or whose files fail the SHA-256 check. `AbstractFileImporter.forDirectory(dir, connection, dbType)` returns the importer of the package (SQL or Parquet), and Parquet files of other tools are loaded by `ParquetImporter`:
+**Output**: `ScriptImporter` refuses packages whose status is not `COMPLETED`, whose target type differs, or whose files fail the SHA-256 check. `AbstractFileImporter.forDirectory(dir, connection, dbType)` returns the importer of the package (SQL, Parquet or CSV), and files of other tools are loaded by `ParquetImporter` or `CsvImporter`. A `ManualTransaction` keeps the import open until you commit or roll back:
```java
ParquetImporter importer = new ParquetImporter(connection, DbType.POSTGRESQL);
long rows = importer.importFiles(List.of(new File("sales-2024.parquet")), "sales", ParquetImporter.Mode.CREATE);
+
+try (ManualTransaction transaction = new ManualTransaction(connection, DbType.POSTGRESQL.isTransactionalDdl())) {
+ AbstractFileImporter packageImporter = AbstractFileImporter.forDirectory(dir, transaction.getConnection(),
+ DbType.POSTGRESQL);
+ packageImporter.importDirectory(dir);
+ transaction.commit(); // or transaction.rollback()
+}
```
### 4. Type translation (cross-database)
@@ -290,6 +306,9 @@ long rows = importer.importFiles(List.of(new File("sales-2024.parquet")), "sales
| Use **one file per table** for large exports | Re-run or inspect a single table easily |
| Import packages with SqlJam | Manifest validation, ordered files, LOB restore and FKs last |
| Use **Parquet** for analytics and lakes | Compact columnar files any engine reads, the schema still travels in `schema.sql` |
+| Use **CSV** for spreadsheets and text tools | Opens anywhere, and SqlJam still restores the exact types from `manifest.json` |
+| Keep **one transaction** for imports | An error rolls back everything, **Confirm before commit** shows the log and the row counts before the commit |
+| Load again with **Update or insert by key** | Kept tables are updated by their primary keys, nothing is dropped |
| Copy with a **target table name** | `{table}_copy` keeps the originals, a copy in the same schema without a new name is refused |
---
@@ -318,7 +337,10 @@ All settings live in `sqljam.properties`, placed next to the runnable jar in `bi
| `sqljam.export.page-size` | `5000` | Rows read per page when exporting, tuned by benchmark |
| `sqljam.export.lob-page-size` | `100` | Rows per page for tables with LOB columns, keeps memory low |
| `sqljam.import.batch-size` | `1000` | INSERT statements per batch when importing a package (Oracle uses PL/SQL blocks) |
-| `sqljam.export.max-file-size` | `10485760` | Max bytes of a data file (0 = unlimited) |
+| `sqljam.export.max-file-size` | `10485760` | Max bytes of a SQL or CSV data file before it is split (0 = unlimited) |
+| `sqljam.export.parquet-max-file-size` | `268435456` | Max bytes of a Parquet file before the table is split, Parquet reads best in large files |
+| `sqljam.export.csv-delimiter` | `,` | Delimiter of CSV files: `,` `;` `\|` or `\t` |
+| `sqljam.import.confirm-commit.reminder-minutes` | `10` | An import waiting for commit reminds you after these minutes, its transaction holds locks and log space (0 = off) |
| `sqljam.export.data-file-strategy` | `SINGLE_FILE` | `SINGLE_FILE` or `FILE_PER_TABLE` |
| `sqljam.export.lob-separated` | `true` | Write LOBs as files |
| `sqljam.pool.maximum-size` | `10` | HikariCP max pool size |
@@ -344,9 +366,12 @@ Export options (UI / `ExportConfiguration`):
| `tableRecreated` | `true` | Drop and recreate target tables |
| `idReused` | `false` (UI: `true`) | Copy identity values and reset sequences |
| `indexIncluded` / `foreignKeyIncluded` / `commentIncluded` / `sequenceIncluded` | `true` | Objects to copy |
-| `failFast` | `true` | Stop on the first data error |
+| `failFast` | `true` | Stop on the first data error, `false` skips the rows with errors and reports each of them |
| `identifierCase` | `AUTO` | `AUTO`, `KEEP`, `LOWER`, `UPPER` |
-| `dataFormat` / `compression` | `SQL` / `ZSTD` | `SQL` or `PARQUET` packages, Parquet compression `ZSTD`, `SNAPPY`, `GZIP`, `UNCOMPRESSED` |
+| `dataFormat` / `compression` | `SQL` / `ZSTD` | `SQL`, `PARQUET` or `CSV` packages, Parquet compression `ZSTD`, `SNAPPY`, `GZIP`, `UNCOMPRESSED` |
+| `csvDelimiter` | `,` | Delimiter of CSV packages |
+| `writeMode` / `keyColumnNames` | `INSERT` | `UPSERT` updates the rows of the same key (the primary key or chosen columns) and inserts the others, `SKIP_EXISTING` keeps them and inserts the others |
+| `CsvOptions` | comma, `"`, UTF-8 | Delimiter, quote, NULL text, header, encoding and date format of CSV files |
| `tableNamePattern` | | Target table name, `{table}` is the source name, e.g. `{table}_copy` |
| `tableQueries` | | Columns, WHERE and ORDER BY per table, e.g. rows narrowed in the data viewer |
@@ -380,9 +405,9 @@ Environment: Apple M2 Max, 32 GB, JDK 17. MySQL/PostgreSQL local, Oracle/SQL Ser
| Topic | Link |
|---|---|
| Project site | [paganini2008.github.io/sqljam](https://paganini2008.github.io/sqljam/) |
-| Blog (English / 中文 / Medium) | [docs/blogger](docs/blogger) |
+| Blog | [docs/blogger/sqljam-en.md](docs/blogger/sqljam-en.md) |
| Change logs | [change_logs](change_logs) |
-| Core API | `com.github.sqljam.impexp` (`Exporter`, `ScriptExporter`, `ImportExporter`, `ScriptImporter`, `ParquetExporter`, `ParquetImporter`) |
+| Core API | `com.github.sqljam.impexp` (`Exporter`, `ScriptExporter`, `ImportExporter`, `ScriptImporter`, `ParquetExporter`, `ParquetImporter`, `CsvExporter`, `CsvImporter`, `ManualTransaction`) |
**FAQ**
@@ -391,6 +416,11 @@ Environment: Apple M2 Max, 32 GB, JDK 17. MySQL/PostgreSQL local, Oracle/SQL Ser
| Can I export only some rows or columns? | Yes. Narrow the rows on the **Data** tab (columns, WHERE, ORDER BY) and press **Export…**, or set `tableQueries`. A missing target table is created with the selected columns |
| Can I copy a table inside the same database? | Yes. Choose the same data source and schema as the target and a table name such as `{table}_copy` |
| How are LOBs stored in Parquet? | Inside the Parquet columns (strings and binaries), no extra LOB files |
+| How exact is CSV? | Exact. NULL is an empty field, an empty string is `""`, binaries are escaped, and every column is read back with its type from `manifest.json` |
+| How are rows matched when they are updated or skipped? | By the primary key, or by the columns chosen in **Key Columns**. Choose the columns of a unique index of the target table: MySQL and MariaDB match rows by their unique indexes, PostgreSQL, SQLite and DuckDB require such an index. A table without primary key and chosen columns is inserted |
+| Do names have to match in case? | No. An existing target table and its columns are matched without case, e.g. `OrderId` of the source writes the column `orderid` of PostgreSQL or `ORDERID` of Oracle |
+| Which rows were skipped? | Each skipped row is in the log of the import with its key and its error. **Errors only** shows them, **Save Log** writes the whole log to a file |
+| What does a roll back undo? | Everything on PostgreSQL, SQL Server, SQLite and DuckDB, tables included. MySQL, MariaDB, Oracle and H2 commit DDL at once, there the created tables stay empty and keys and identity values are set after the commit |
| Can I export only the structure? | Yes. Rows are optional. Pick content **Structure** (`ExportMode.DDL`) for a package or a direct import. Tables, keys, indexes, comments, sequences and partitions are created without rows |
| Can I generate scripts for an older server? | Yes, choose the target version (e.g. Oracle `11.2`, SQL Server `2008`) and a compatible dialect is used |
| Can I keep the password out of disk? | Uncheck *Remember password* on the login page, it stays in memory for the session |
@@ -403,7 +433,7 @@ Environment: Apple M2 Max, 32 GB, JDK 17. MySQL/PostgreSQL local, Oracle/SQL Ser
| Step | |
|---|---|
| Issues | [github.com/paganini2008/sqljam/issues](https://github.com/paganini2008/sqljam/issues) |
-| Pull requests | Fork → branch → `mvn verify` (≥ 90% coverage gate) → PR |
+| Pull requests | Fork → branch → `mvn verify` → PR |
| Code style | Match existing code: `getXxxStatement` naming, Apache license header, class header javadoc, 4-space indent, no Spring |
| New database or version | Add `XxxDialect` / `XxxMetaDataOperations` in `impexp.db`, register in `DbType`, add a fixture in `src/test/resources//` |
diff --git a/docs/assets/export-wizard.png b/docs/assets/export-wizard.png
index b05e75a..5a87250 100644
Binary files a/docs/assets/export-wizard.png and b/docs/assets/export-wizard.png differ
diff --git a/docs/assets/import-package.png b/docs/assets/import-package.png
index a29e796..fe00ccf 100644
Binary files a/docs/assets/import-package.png and b/docs/assets/import-package.png differ
diff --git a/docs/assets/import-parquet.png b/docs/assets/import-parquet.png
index 476e159..dceddb0 100644
Binary files a/docs/assets/import-parquet.png and b/docs/assets/import-parquet.png differ
diff --git a/docs/assets/package-viewer.png b/docs/assets/package-viewer.png
index f262b2b..1869ea0 100644
Binary files a/docs/assets/package-viewer.png and b/docs/assets/package-viewer.png differ
diff --git a/docs/assets/sqljam-tutorial.mp4 b/docs/assets/sqljam-tutorial.mp4
index 8c5bdb0..684d37c 100644
Binary files a/docs/assets/sqljam-tutorial.mp4 and b/docs/assets/sqljam-tutorial.mp4 differ
diff --git a/docs/blogger/sqljam-en.md b/docs/blogger/sqljam-en.md
index 28daa11..80c3500 100644
--- a/docs/blogger/sqljam-en.md
+++ b/docs/blogger/sqljam-en.md
@@ -7,7 +7,7 @@
**SqlJam**: *Move tables across databases. Schema, data and all.*
A Java 17 + JavaFX desktop tool that copies tables between relational and OLAP databases (**MySQL, MariaDB, PostgreSQL, Oracle, SQL Server, H2, SQLite, DuckDB, ClickHouse** and more) in any direction:
-**import directly** into a target database or into the same schema as copies, or save a self-describing **export package** of SQL or **Parquet** files and import it later.
+**import directly** into a target database or into the same schema as copies, or save a self-describing **export package** of SQL, **Parquet** or **CSV** files and import it later. Imports into a database run in **one transaction** which an error rolls back, and rows are **inserted, updated or skipped by key**.

@@ -22,10 +22,13 @@ SqlJam reads **one metadata tree** and generates SQL with a **dialect chosen by
| Dialect differences | Types, identifiers, defaults, identity and literals translated by the dialect of each target |
| Old versions | Version subclasses: `Oracle11gDialect`, `PostgreSQL9Dialect`, `MySQL56Dialect`, `SQLServer2008Dialect` … |
| Missing keys & indexes | Primary keys, unique/regular indexes, foreign keys, comments, sequences, partitions |
-| Huge scripts | Data files split at 10 MB: `orders.sql`, `orders_2.sql` … |
+| Huge scripts | Data files split by size: SQL and CSV at 10 MB (`orders.sql`, `orders_2.sql` …), Parquet at 256 MB (`orders.parquet`, `orders_2.parquet` …), imported part by part in order |
| LOBs | Stored as files, restored by primary key |
| Scripts without context | `manifest.json`: source, target, options, SHA-256 per file, row counts |
| Analytics needs Parquet | Parquet packages through an embedded DuckDB, Parquet files of other tools loaded into any table |
+| Spreadsheets need CSV | CSV packages with your delimiter, quote, NULL text, header, encoding (UTF-8 with BOM, GBK and more) and date format, exact types kept in `manifest.json`, CSV files of other tools loaded too |
+| An import went wrong | **One transaction** by default, rolled back on any error, or **commit every N rows**. **Skip rows with errors** goes on and logs each skipped row. **Confirm before commit** reviews a successful import first |
+| Rows exist already | **Update or insert by key** or **skip existing by key**: the primary key or the columns of a unique index match the rows, insert is the default |
| Warehouses apart from OLTP | DuckDB and ClickHouse data sources, grouped as OLAP databases |
| Only part of a table | Data viewer query (columns, WHERE, GROUP BY, ORDER BY) and export of the rows of the query |
| A copy next to the original | Copies in the same schema with `{table}_copy`, keys and indexes renamed, source tables protected |
@@ -107,14 +110,17 @@ flowchart LR
D --> I[Import directly] --> TD[(Target DB)]
D --> E[Export package schema.sql · data*.sql · lob/ · manifest.json]
E --> SI[ScriptImporter] --> TD
- D --> P[Parquet package DuckDB workspace · data/*.parquet]
- P --> PI[ParquetImporter] --> TD
+ D --> P[Parquet or CSV package DuckDB workspace · data/*.parquet or *.csv]
+ P --> PI[ParquetImporter · CsvImporter] --> TD
```
- **Read metadata once**: `XxxMetaDataOperations` completes comments, identity, generated columns, partitions and sequences per database.
- **Generate for the target**: `DbType.createDialect(major, minor)` picks the version subclass.
- **Stream rows**: count rows (percentage progress) → page by primary key → normalize values → batch insert or SQL literals.
-- **Parquet**: rows pass an embedded DuckDB workspace, `COPY ... TO 'x.parquet'` writes them and `read_parquet` loads them back.
+- **Parquet and CSV**: rows pass an embedded DuckDB workspace, `COPY ... TO` writes them, `read_parquet` or `read_csv` with the column types of `manifest.json` loads them back.
+- **Transactions**: an import runs in one transaction, committed when it succeeds or after your confirmation. MySQL, MariaDB, Oracle and H2 commit around DDL, so keys and identity values are set after the commit.
+- **Upserts and skips**: `ON CONFLICT`, `ON DUPLICATE KEY UPDATE` or `MERGE` of each database, kept tables, indexes and keys are skipped.
+- **Skipped errors**: a failed page is written row by row under savepoints, each bad row is logged with its key and the others are kept.
## 6. Code Examples
@@ -150,6 +156,10 @@ ParquetExporter parquet = new ParquetExporter(new File("export-parquet"));
parquet.setTargetDbType(DbType.POSTGRESQL);
parquet.setCompression("ZSTD");
parquet.exportDdlAndData(); // schema.sql, data/orders.parquet, constraints.sql, manifest.json
+
+CsvExporter csv = new CsvExporter(new File("export-csv"));
+csv.setDelimiter(";");
+csv.exportDdlAndData(); // data/orders.csv, data/orders_2.csv ...
```
### Example 2: Oracle → SQL Server directly (schema created)
@@ -196,7 +206,10 @@ All settings live in `bin/sqljam.properties` next to the runnable jar. Settings
| Property | Default | Description |
|---|---|---|
-| `sqljam.export.max-file-size` | `10485760` | Max bytes of a data file |
+| `sqljam.export.max-file-size` | `10485760` | Max bytes of a SQL or CSV data file |
+| `sqljam.export.parquet-max-file-size` | `268435456` | Max bytes of a Parquet file, Parquet reads best in large files |
+| `sqljam.export.csv-delimiter` | `,` | Delimiter of CSV files |
+| `sqljam.import.confirm-commit.reminder-minutes` | `10` | Reminder for an import still waiting for commit |
| `sqljam.export.data-file-strategy` | `SINGLE_FILE` | Single file or one file per table |
| `sqljam.export.lob-separated` | `true` | Write LOBs as files |
| `sqljam.export.page-size` | `5000` | Rows per page, tuned by benchmark |
@@ -221,7 +234,7 @@ All settings live in `bin/sqljam.properties` next to the runnable jar. Settings
Environment: Apple M2 Max, 32 GB, JDK 17. MySQL/PostgreSQL local, Oracle/SQL Server in Docker. Default settings.
-**Quality**: 578 tests (full cross-database import matrix with every column type of every database, export package and Parquet round trips, copies in the same schema, old-version SQL executed on real servers, UI and boundary tests), 91% line coverage.
+**Quality**: a full cross-database import matrix with every column type of every database, SQL, Parquet and CSV package round trips, commit and roll back verified on every database, copies in the same schema, old-version SQL executed on real servers, UI and boundary tests.
## 9. Design Trade-offs
@@ -230,7 +243,8 @@ Environment: Apple M2 Max, 32 GB, JDK 17. MySQL/PostgreSQL local, Oracle/SQL Ser
- **Tables copied one by one**: predictable load on production servers.
- **Plain SQL in packages**: readable, editable and portable. Direct import uses batched inserts for speed.
- **Version-specific SQL**: older servers get the syntax they understand instead of a lowest common denominator.
-- **Parquet through DuckDB**: one embedded engine writes and reads Parquet, no Hadoop libraries.
+- **Parquet and CSV through DuckDB**: one embedded engine writes and reads both, no Hadoop libraries.
+- **One transaction by default**: an import is all or nothing, confirm before commit when you want to look first.
- **Grouped rows are for viewing**: exports carry real table rows, a GROUP BY result is a view of them.
- **Copies need their own names**: a copy in the same schema takes a table name such as `{table}_copy`, so the originals are never replaced.
@@ -240,9 +254,9 @@ Environment: Apple M2 Max, 32 GB, JDK 17. MySQL/PostgreSQL local, Oracle/SQL Ser
2. Dialects are chosen by **type + version**. Old versions are subclasses.
3. Tables, indexes, constraints, sequences, partitions and comments move together.
4. Export package = SQL + LOB files + `manifest.json`. Export and import are paired.
-5. Data files split at 10 MB by default. LOBs stored separately.
+5. Data files split by size (SQL and CSV 10 MB, Parquet 256 MB) and imported in order. LOBs stored separately.
6. Timestamps, unsigned numbers, bits, money, UUID and JSON survive the trip.
7. Pooled connections and batch inserts: 40k to 95k rows/s for direct imports.
8. A mainstream dark UI with 7 themes, percentage progress and cancel.
-9. Parquet packages and Parquet files of other tools, plus DuckDB and ClickHouse as OLAP data sources.
+9. Parquet and CSV packages and files of other tools, DuckDB and ClickHouse as OLAP data sources, imports in one transaction and rows updated by key.
10. Query the rows of a table, export just those rows, or copy tables inside the same schema.
diff --git a/docs/blogger/sqljam-medium.md b/docs/blogger/sqljam-medium.md
index ad023d6..6fa5f85 100644
--- a/docs/blogger/sqljam-medium.md
+++ b/docs/blogger/sqljam-medium.md
@@ -6,7 +6,7 @@ Tables, indexes, constraints, sequences, partitions and rows, in one go.
## Overview
-**SqlJam** is a Java 17 + JavaFX desktop tool that copies tables between relational and OLAP databases (**MySQL, MariaDB, PostgreSQL, Oracle, SQL Server, H2, SQLite, DuckDB, ClickHouse** and more) in any direction. Import directly into a target database or into the same schema as copies, or save a self-describing **export package** of SQL or **Parquet** files and import it later.
+**SqlJam** is a Java 17 + JavaFX desktop tool that copies tables between relational and OLAP databases (**MySQL, MariaDB, PostgreSQL, Oracle, SQL Server, H2, SQLite, DuckDB, ClickHouse** and more) in any direction. Import directly into a target database or into the same schema as copies, or save a self-describing **export package** of SQL, **Parquet** or **CSV** files and import it later. Imports into a database run in **one transaction** which an error rolls back, and rows are **inserted, updated or skipped by key**.
Source: https://github.com/paganini2008/sqljam
@@ -19,10 +19,13 @@ SqlJam reads one metadata tree and generates SQL with a dialect chosen by target
- **Dialect differences** → types, identifiers, defaults, identity and literals translated by the dialect of each target
- **Old versions** → version subclasses: Oracle11gDialect, PostgreSQL9Dialect, MySQL56Dialect, SQLServer2008Dialect
- **Missing keys and indexes** → primary keys, indexes, foreign keys, comments, sequences and partitions are copied
-- **Huge scripts** → data files split at 10 MB: orders.sql, orders_2.sql …
+- **Huge scripts** → data files split by size: SQL and CSV at 10 MB (orders.sql, orders_2.sql …), Parquet at 256 MB, imported part by part in order
- **LOBs** → stored as files and restored by primary key
- **Scripts without context** → manifest.json with source, target, options, SHA-256 per file and row counts
- **Analytics needs Parquet** → Parquet packages through an embedded DuckDB, Parquet files of other tools loaded into any table
+- **Spreadsheets need CSV** → CSV packages with your delimiter, quote, NULL text, header, encoding and date format, exact types kept in manifest.json, CSV files of other tools loaded too
+- **An import went wrong** → one transaction by default, rolled back on any error, or commit every N rows. Skip rows with errors goes on and logs each skipped row. Confirm before commit reviews a successful import first
+- **Rows exist already** → update or insert by key, or skip existing by key: the primary key or the columns of a unique index match the rows
- **Warehouses apart from OLTP** → DuckDB and ClickHouse data sources, grouped as OLAP databases
- **Only part of a table** → a data viewer query (columns, WHERE, GROUP BY, ORDER BY) and export of its rows
- **A copy next to the original** → copies in the same schema with {table}_copy, keys and indexes renamed
@@ -97,7 +100,10 @@ Databases:
- **Read metadata once**: per-database operations complete comments, identity, generated columns, partitions and sequences.
- **Generate for the target**: DbType.createDialect(major, minor) picks the version subclass.
- **Stream rows**: count rows for percentage progress, page by primary key, normalize values, then batch insert or write SQL literals.
-- **Parquet**: rows pass an embedded DuckDB workspace, COPY TO writes Parquet files and read_parquet loads them back.
+- **Parquet and CSV**: rows pass an embedded DuckDB workspace, COPY TO writes the files, read_parquet or read_csv with the column types of manifest.json loads them back.
+- **Transactions**: an import runs in one transaction, committed when it succeeds or after your confirmation. MySQL, MariaDB, Oracle and H2 commit around DDL, so keys and identity values are set after the commit.
+- **Upserts and skips**: ON CONFLICT, ON DUPLICATE KEY UPDATE or MERGE of each database, kept tables, indexes and keys are skipped.
+- **Skipped errors**: a failed page is written row by row under savepoints, each bad row is logged with its key and the others are kept.
## Code Examples
@@ -136,6 +142,10 @@ ParquetExporter parquet = new ParquetExporter(new File("export-parquet"));
parquet.setTargetDbType(DbType.POSTGRESQL);
parquet.setCompression("ZSTD");
parquet.exportDdlAndData();
+
+CsvExporter csv = new CsvExporter(new File("export-csv"));
+csv.setDelimiter(";");
+csv.exportDdlAndData(); // data/orders.csv, data/orders_2.csv ...
```
### Example 2: Oracle → SQL Server directly
@@ -180,7 +190,10 @@ Type translation examples:
All settings live in bin/sqljam.properties next to the runnable jar. Settings changed in the UI are saved to ~/.sqljam/sqljam.properties and win over the file.
-- **sqljam.export.max-file-size** = 10485760, max bytes of a data file
+- **sqljam.export.max-file-size** = 10485760, max bytes of a SQL or CSV data file
+- **sqljam.export.parquet-max-file-size** = 268435456, max bytes of a Parquet file
+- **sqljam.export.csv-delimiter** = comma, delimiter of CSV files
+- **sqljam.import.confirm-commit.reminder-minutes** = 10, reminder for an import still waiting for commit
- **sqljam.export.data-file-strategy** = SINGLE_FILE, or FILE_PER_TABLE
- **sqljam.export.lob-separated** = true, write LOBs as files
- **sqljam.export.page-size** = 5000, rows per page, tuned by benchmark
@@ -202,7 +215,7 @@ All settings live in bin/sqljam.properties next to the runnable jar. Settings ch
- H2 → SQL Server 2022: 5.0 s, 40.1k rows/s
- Export package → PostgreSQL: 4.5 s, 44.5k rows/s
-Quality: 578 tests (full cross-database import matrix with every column type of every database, export package and Parquet round trips, copies in the same schema, old-version SQL executed on real servers, UI and boundary tests), 91% line coverage.
+Quality: a full cross-database import matrix with every column type of every database, SQL, Parquet and CSV package round trips, commit and roll back verified on every database, copies in the same schema, old-version SQL executed on real servers, UI and boundary tests.
## Design Trade-offs
@@ -211,7 +224,8 @@ Quality: 578 tests (full cross-database import matrix with every column type of
- **Tables copied one by one**: predictable load on production servers.
- **Plain SQL in packages**: readable and portable. Direct import uses batched inserts for speed.
- **Version-specific SQL**: older servers get the syntax they understand.
-- **Parquet through DuckDB**: one embedded engine writes and reads Parquet, no Hadoop libraries.
+- **Parquet and CSV through DuckDB**: one embedded engine writes and reads both, no Hadoop libraries.
+- **One transaction by default**: an import is all or nothing, confirm before commit when you want to look first.
- **Grouped rows are for viewing**: exports carry real table rows.
- **Copies need their own names**: a copy in the same schema takes a name such as {table}_copy.
@@ -221,11 +235,11 @@ Quality: 578 tests (full cross-database import matrix with every column type of
2. Dialects are chosen by type + version. Old versions are subclasses.
3. Tables, indexes, constraints, sequences, partitions and comments move together.
4. Export package = SQL + LOB files + manifest.json. Export and import are paired.
-5. Data files split at 10 MB by default. LOBs stored separately.
+5. Data files split by size (SQL and CSV 10 MB, Parquet 256 MB) and imported in order. LOBs stored separately.
6. Timestamps, unsigned numbers, bits, money, UUID and JSON survive the trip.
7. Pooled connections and batch inserts: 40k to 95k rows/s for direct imports.
8. A mainstream dark UI with 7 themes, percentage progress and cancel.
-9. Parquet packages and Parquet files of other tools, plus DuckDB and ClickHouse as OLAP data sources.
+9. Parquet and CSV packages and files of other tools, DuckDB and ClickHouse as OLAP data sources, imports committed manually or rolled back as a whole.
10. Query the rows of a table, export just those rows, or copy tables inside the same schema.
GitHub: https://github.com/paganini2008/sqljam
diff --git a/docs/blogger/sqljam-zh.md b/docs/blogger/sqljam-zh.md
index 4a07049..962584a 100644
--- a/docs/blogger/sqljam-zh.md
+++ b/docs/blogger/sqljam-zh.md
@@ -7,7 +7,7 @@
**SqlJam**:*Move tables across databases. Schema, data and all.*
一个 Java 17 + JavaFX 的桌面导入导出工具,支持关系型与 OLAP 数据库(**MySQL、MariaDB、PostgreSQL、Oracle、SQL Server、H2、SQLite、DuckDB、ClickHouse** 等)任意互导:
-可以**直接导入目标库**,也可以在同一个 schema 里复制表,或者导出为带描述文件的 **SQL / Parquet 导出包**,之后再成对导入。
+可以**直接导入目标库**,也可以在同一个 schema 里复制表,或者导出为带描述文件的 **SQL / Parquet / CSV 导出包**,之后再成对导入。导入目标库默认在**一个事务**里完成,出错整体回滚,数据可以**按主键更新、插入或跳过**。

@@ -22,10 +22,13 @@ SqlJam 用**一棵元数据树 + 按目标库和版本选择的方言**,把这
| 方言差异 | 按目标方言统一翻译类型 / 标识符 / 默认值 / 自增 / 字面量 |
| 老版本 | `Oracle11gDialect`、`PostgreSQL9Dialect`、`MySQL56Dialect`、`SQLServer2008Dialect` 等版本子类 |
| 丢键丢索引 | 主键、唯一/普通索引、外键、注释、序列、分区一起迁移 |
-| 脚本太大 | 数据文件按 10 MB 切分:`orders.sql`、`orders_2.sql`… |
+| 脚本太大 | 数据文件按大小切分:SQL 与 CSV 按 10 MB 切成 `orders.sql`、`orders_2.sql`…,Parquet 按 256 MB 切成 `orders.parquet`、`orders_2.parquet`…,导入时按序号依次导入 |
| LOB 难处理 | LOB 单独存文件,按主键回填 |
| 脚本没有上下文 | `manifest.json`:源、目标、选项、文件 SHA-256、行数 |
| 分析要 Parquet | 内置 DuckDB 生成 Parquet 导出包,也能把其他工具产出的 Parquet 文件加载进任意表 |
+| 表格工具要 CSV | CSV 导出包可选分隔符、引号、NULL 表示、表头、编码(Excel 用的 UTF-8 BOM、GBK 等)和日期格式,`manifest.json` 记录每列类型,导回时精确还原,也能加载其他工具的 CSV 文件 |
+| 导错了怎么办 | 默认**一个事务**:出错整体回滚,也可以**每 N 行提交一次**。勾选**跳过出错的行**则继续导入,每条跳过的记录都写进日志,可筛选、可保存。勾选**提交前确认**,导入成功后先看结果再 **Commit** 或 **Roll Back** |
+| 目标已有部分数据 | **按主键更新或插入**:主键(或自选的唯一索引列)相同的行更新,其余插入。**按主键跳过已存在的行**:已有的行保持不变,只插入新行。默认是插入 |
| 数仓和业务库割裂 | DuckDB、ClickHouse 作为 OLAP 数据源,和关系型数据库分组展示 |
| 只要表的一部分 | 数据页查询(列、WHERE、GROUP BY、ORDER BY),只导出查询结果的行 |
| 原地复制一份表 | 同一 schema 内按 `{table}_copy` 复制,键、索引、约束自动改名,原表受保护 |
@@ -107,14 +110,17 @@ flowchart LR
D --> I[直接导入] --> TD[(目标库)]
D --> E[导出包 schema.sql · data*.sql · lob/ · manifest.json]
E --> SI[ScriptImporter] --> TD
- D --> P[Parquet 导出包 DuckDB 中转 · data/*.parquet]
- P --> PI[ParquetImporter] --> TD
+ D --> P[Parquet / CSV 导出包 DuckDB 中转 · data/*.parquet 或 *.csv]
+ P --> PI[ParquetImporter · CsvImporter] --> TD
```
- **读一次元数据**:`XxxMetaDataOperations` 按库补全注释、自增、生成列、分区、序列。
- **按目标生成 SQL**:`DbType.createDialect(major, minor)` 选出版本子类。
- **数据流**:先统计行数(百分比进度)→ 按主键分页读取 → 值归一化 → 批量写入或生成字面量。
-- **Parquet**:数据经过内置的 DuckDB 中转,`COPY ... TO 'x.parquet'` 写出,`read_parquet` 读回。
+- **Parquet 与 CSV**:数据经过内置的 DuckDB 中转,`COPY ... TO` 写出,`read_parquet` 或按 `manifest.json` 中列类型的 `read_csv` 读回。
+- **事务**:导入在一个事务里完成,成功自动提交或经你确认后提交。MySQL、MariaDB、Oracle、H2 执行 DDL 会隐式提交,外键和自增值等语句延后到提交之后执行。
+- **按主键更新或跳过**:使用各库的 `ON CONFLICT`、`ON DUPLICATE KEY UPDATE` 或 `MERGE`,已存在的表、索引、键自动跳过。
+- **跳过出错的行**:出错的一页在 savepoint 保护下逐行重写,每条坏行连同主键和错误写进日志,其余行照常保留。
## 6. Code Examples
@@ -150,6 +156,10 @@ ParquetExporter parquet = new ParquetExporter(new File("export-parquet"));
parquet.setTargetDbType(DbType.POSTGRESQL);
parquet.setCompression("ZSTD");
parquet.exportDdlAndData(); // schema.sql、data/orders.parquet、constraints.sql、manifest.json
+
+CsvExporter csv = new CsvExporter(new File("export-csv"));
+csv.setDelimiter(";");
+csv.exportDdlAndData(); // data/orders.csv、data/orders_2.csv ...
```
### Example 2:Oracle 直接导入 SQL Server(自动建 schema)
@@ -196,7 +206,10 @@ target.setTableNamePattern("{table}_copy"); // 在 EMPLOYEES 旁边生成
| Property | Default | Description |
|---|---|---|
-| `sqljam.export.max-file-size` | `10485760` | 单个数据文件上限(字节) |
+| `sqljam.export.max-file-size` | `10485760` | SQL、CSV 单个数据文件上限(字节) |
+| `sqljam.export.parquet-max-file-size` | `268435456` | Parquet 单个文件上限(字节),Parquet 文件大一些读得更快 |
+| `sqljam.export.csv-delimiter` | `,` | CSV 分隔符 |
+| `sqljam.import.confirm-commit.reminder-minutes` | `10` | 等待提交超过这么多分钟时提醒 |
| `sqljam.export.data-file-strategy` | `SINGLE_FILE` | 单文件 / 每表一个文件 |
| `sqljam.export.lob-separated` | `true` | LOB 单独存文件 |
| `sqljam.export.page-size` | `5000` | 每页读取行数(实测最优) |
@@ -221,7 +234,7 @@ target.setTableNamePattern("{table}_copy"); // 在 EMPLOYEES 旁边生成
环境:Apple M2 Max / 32 GB / JDK 17。MySQL、PostgreSQL 本机,Oracle、SQL Server 在 Docker。默认配置。
-**质量**:578 个测试(含覆盖每种数据库全部字段类型的跨库互导矩阵、导出包与 Parquet 回放、同 schema 复制、旧版本语法在真实库上执行、界面功能与边界测试),行覆盖率 91%。
+**质量**:覆盖每种数据库全部字段类型的跨库互导矩阵,SQL、Parquet、CSV 导出包回放,事务提交与回滚、按主键更新或插入在每种数据库上验证,同 schema 复制,旧版本语法在真实库上执行,界面功能与边界测试。
## 9. 设计取舍(Design Trade-offs)
@@ -230,7 +243,8 @@ target.setTableNamePattern("{table}_copy"); // 在 EMPLOYEES 旁边生成
- **逐表复制**:对生产库的压力可预期。
- **导出包是纯 SQL**:可读、可改、可移植。直接导入走批量写入,追求速度。
- **按版本生成 SQL**:老版本数据库拿到的是它能理解的语法,而不是最低公分母。
-- **Parquet 交给 DuckDB**:一个内置引擎负责读写 Parquet,不引入 Hadoop 依赖。
+- **Parquet 与 CSV 交给 DuckDB**:一个内置引擎负责读写,不引入 Hadoop 依赖。
+- **默认一个事务**:导入要么全成要么全不成,想先看结果就勾选提交前确认。
- **分组结果用于浏览**:导出的始终是表的真实行,GROUP BY 的结果是这些行的视图。
- **复制要有新表名**:同一 schema 内复制需要 `{table}_copy` 这样的表名,原表永远不会被覆盖。
@@ -240,9 +254,9 @@ target.setTableNamePattern("{table}_copy"); // 在 EMPLOYEES 旁边生成
2. 方言按**类型 + 版本**选择,老版本差异用子类表达。
3. 表、索引、约束、序列、分区、注释一起迁移。
4. 导出包 = SQL + LOB 文件 + `manifest.json`,导出导入成对。
-5. 数据文件默认 10 MB 切分,LOB 单独存放。
+5. 数据文件按大小切分(SQL、CSV 10 MB,Parquet 256 MB),按序导入,LOB 单独存放。
6. 时间、无符号、bit、money、UUID、JSON 等值跨库不失真。
7. 连接池 + 批量写入,直接导入 4 万 ~ 9.5 万行/秒。
8. 暗色主流界面,7 种主题,百分比进度与取消。
-9. Parquet 导出包与外部 Parquet 文件加载,DuckDB、ClickHouse 作为 OLAP 数据源。
+9. Parquet、CSV 导出包与外部文件加载,DuckDB、ClickHouse 作为 OLAP 数据源,导入默认一个事务、出错整体回滚,支持按主键更新或插入。
10. 表数据可查询,只导出查询结果,也能在同一 schema 内复制表。
diff --git a/docs/index.html b/docs/index.html
index cee8247..e3a9966 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -4,7 +4,7 @@
SqlJam · Move tables across databases
-
+
@@ -185,7 +185,7 @@
SqlJam
Move tables across databases. Schema, data and all.
-
A desktop import/export tool that copies tables, indexes, constraints, sequences, partitions and rows between relational and OLAP databases, directly, as copies in the same schema, or through a self-describing SQL or Parquet export package you can import later.
+
A desktop import/export tool that copies tables, indexes, constraints, sequences, partitions and rows between relational and OLAP databases, directly, as copies in the same schema, or through a self-describing SQL, Parquet or CSV export package you can import later. Imports into a database run in one transaction which an error rolls back, and rows are inserted, updated or skipped by key.
MySQL MariaDB PostgreSQL Oracle
SQL Server H2 SQLite
@@ -227,11 +227,11 @@
Everything a table needs on the other side
Version-aware Dialects picked by detected or selected server version.
Keys & constraints Primary keys, indexes, foreign keys, comments and sequences.
Partitions PostgreSQL, MySQL, Oracle and SQL Server partitioning preserved.
-
Export packages SQL files, LOB files and manifest.json, split at 10 MB.
-
Verified imports Status, target type and SHA-256 checked before running.
+
Export packages SQL, Parquet or CSV files, LOB files and manifest.json, split by size and imported in order.
+
One transaction Checked by status, type and SHA-256, all or nothing or bad rows skipped and logged, confirmed before the commit if you want.
Progress you can see Overall percentage, per-table progress, error log and cancel.
Dark by default A modern JavaFX UI with 7 switchable themes.
-
Parquet packages Columnar export packages and Parquet files of other tools, through an embedded DuckDB.
+
Parquet & CSV Columnar or text packages and files of other tools, exact types, through an embedded DuckDB.
OLAP sources DuckDB and ClickHouse next to the relational databases.
Query & export Columns, WHERE, GROUP BY and ORDER BY in the data viewer, then export just those rows.
Copies in place Copy tables inside the same schema with names like {table}_copy.
@@ -249,7 +249,7 @@
Read once, generate for any target
1 Metadata : per-database operations read tables, keys, indexes, comments, partitions and sequences.
2 Dialect : DbType.createDialect(major, minor) picks e.g. Oracle11gDialect.
-
3 Rows : counted, paged by primary key, normalized and written in batches, or written to Parquet by an embedded DuckDB.
+
3 Rows : counted, paged by primary key, normalized and written in batches, or written to Parquet or CSV by an embedded DuckDB.
@@ -301,7 +301,7 @@
Ways to move a table
Import directly
Import a package
Query & export
-
Parquet
+
Parquet & CSV
@@ -331,9 +331,11 @@
Ways to move a table
-
Export packages of Parquet files, look inside them with sizes, times and previews, and load Parquet files of other tools into any table.
+
Export packages of Parquet or CSV files, look inside them with sizes, times and previews, and load Parquet or CSV files of other tools into any table.
parquet.setCompression("ZSTD" );
parquet.exportDdlAndData(); // data/<table>.parquet
+csv.setDelimiter(";" );
+csv.exportDdlAndData(); // data/<table>.csv, <table>_2.csv ...
importer.importFiles(files, "sales" , ParquetImporter.Mode.CREATE);
@@ -347,8 +349,8 @@ Tens of thousands of rows per second
95.3k rows/s · H2 → Oracle
100% cross-database pairs pass
-
578 automated tests
-
91% line coverage
+
SQL · Parquet · CSV export package formats
+
Upsert update or skip rows by key
@@ -377,9 +379,9 @@
Learn more
Getting Started Build, run and export your first tables.
Read →
Examples Export packages, direct imports and type translation.
Read →
Configuration Every key of sqljam.properties and export options.
Read →
-
API Reference Exporter, ScriptExporter, ImportExporter, ScriptImporter, ParquetExporter, ParquetImporter.
Browse →
+
API Reference Exporter, ScriptExporter, ImportExporter, ScriptImporter, ParquetExporter, ParquetImporter, CsvExporter, CsvImporter, ManualTransaction.
Browse →
Architecture Metadata tree, dialects and version subclasses.
Read →
-
Blog English, 中文 and a Medium edition.
Read →
+
Blog How SqlJam works, with examples, configuration and performance.
Read →
diff --git a/src/main/java/com/github/sqljam/face/model/AppSettings.java b/src/main/java/com/github/sqljam/face/model/AppSettings.java
index 13d972a..bde2635 100644
--- a/src/main/java/com/github/sqljam/face/model/AppSettings.java
+++ b/src/main/java/com/github/sqljam/face/model/AppSettings.java
@@ -38,6 +38,22 @@ public class AppSettings {
private int transferPageSize = Exporter.DEFAULT_PAGE_SIZE;
private int lobPageSize = Exporter.DEFAULT_LOB_PAGE_SIZE;
private long maxDataFileSize = 10L * 1024 * 1024;
+ /**
+ * Parquet files are compressed and read best in large files, they are split at a larger size than text files
+ */
+ private long parquetMaxFileSize = 256L * 1024 * 1024;
+ /**
+ * Delimiter of exported CSV files
+ */
+ private String csvDelimiter = ",";
+ /**
+ * Imports into a database run in one transaction which an error rolls back, the default
+ */
+ private boolean oneTransaction = true;
+ /**
+ * Minutes after which an import waiting for commit reminds the user, 0 for no reminder
+ */
+ private int confirmCommitReminderMinutes = 10;
private double windowWidth = 1280;
private double windowHeight = 800;
}
diff --git a/src/main/java/com/github/sqljam/face/model/TransferRequest.java b/src/main/java/com/github/sqljam/face/model/TransferRequest.java
index 1bfc4b0..b64fd89 100644
--- a/src/main/java/com/github/sqljam/face/model/TransferRequest.java
+++ b/src/main/java/com/github/sqljam/face/model/TransferRequest.java
@@ -21,6 +21,7 @@
import java.util.List;
import java.util.Map;
+import com.github.sqljam.impexp.CsvOptions;
import com.github.sqljam.impexp.DataFileStrategy;
import com.github.sqljam.impexp.DataFormat;
import com.github.sqljam.impexp.DbType;
@@ -30,6 +31,7 @@
import com.github.sqljam.impexp.ParquetExporter;
import com.github.sqljam.impexp.ScriptExportHandler;
import com.github.sqljam.impexp.TableQuery;
+import com.github.sqljam.impexp.WriteMode;
import lombok.Getter;
import lombok.Setter;
@@ -94,12 +96,40 @@ public enum Target {
* Compression of Parquet files
*/
private String compression = ParquetExporter.DEFAULT_COMPRESSION;
+ /**
+ * Delimiter, quote, text of NULL, header, encoding and date format of CSV files
+ */
+ private CsvOptions csvOptions = new CsvOptions();
+
+ public String getCsvDelimiter() {
+ return csvOptions.getDelimiter();
+ }
+
+ public void setCsvDelimiter(String csvDelimiter) {
+ csvOptions.setDelimiter(csvDelimiter);
+ }
// Database target
private ConnectionProfile targetProfile;
private String targetCatalog;
private String targetSchema;
private boolean targetSchemaCreated = true;
+ /**
+ * The import into the target database runs in one transaction which the user commits or rolls back
+ */
+ private boolean oneTransaction = true;
+ /**
+ * A successful import in one transaction waits for the user to commit or roll back
+ */
+ private boolean confirmCommit;
+ /**
+ * Rows are inserted, or upserted by key into kept tables
+ */
+ private WriteMode writeMode = WriteMode.INSERT;
+ /**
+ * Chosen key columns of upserts by table, the primary key of other tables
+ */
+ private Map> keyColumnNames = new HashMap<>();
/**
* Name pattern of target tables, {table} is the source table name, e.g. {table}_copy copies tables in the same
* schema. Target tables have the same names if it is blank.
diff --git a/src/main/java/com/github/sqljam/face/service/SettingsStore.java b/src/main/java/com/github/sqljam/face/service/SettingsStore.java
index 8881b06..e5d6d5c 100644
--- a/src/main/java/com/github/sqljam/face/service/SettingsStore.java
+++ b/src/main/java/com/github/sqljam/face/service/SettingsStore.java
@@ -45,6 +45,12 @@ public SettingsStore(Config config) {
settings.setTransferPageSize(config.getInt("sqljam.export.page-size", settings.getTransferPageSize()));
settings.setLobPageSize(config.getInt("sqljam.export.lob-page-size", settings.getLobPageSize()));
settings.setMaxDataFileSize(config.getLong("sqljam.export.max-file-size", settings.getMaxDataFileSize()));
+ settings.setParquetMaxFileSize(config.getLong("sqljam.export.parquet-max-file-size",
+ settings.getParquetMaxFileSize()));
+ settings.setCsvDelimiter(StringUtils.defaultIfEmpty(config.getString("sqljam.export.csv-delimiter",
+ settings.getCsvDelimiter()), settings.getCsvDelimiter()));
+ settings.setConfirmCommitReminderMinutes(config.getInt("sqljam.import.confirm-commit.reminder-minutes",
+ settings.getConfirmCommitReminderMinutes()));
settings.setLastExportDirectory(StringUtils.trimToNull(config.getString("sqljam.export.last-directory",
null)));
settings.setLastImportDirectory(StringUtils.trimToNull(config.getString("sqljam.import.last-directory",
@@ -66,6 +72,8 @@ public synchronized void save() throws IOException {
config.set("sqljam.ui.data.page-size", settings.getDataPageSize());
config.set("sqljam.export.page-size", settings.getTransferPageSize());
config.set("sqljam.export.max-file-size", settings.getMaxDataFileSize());
+ config.set("sqljam.export.parquet-max-file-size", settings.getParquetMaxFileSize());
+ config.set("sqljam.export.csv-delimiter", settings.getCsvDelimiter());
config.set("sqljam.export.last-directory", settings.getLastExportDirectory());
config.set("sqljam.import.last-directory", settings.getLastImportDirectory());
config.save();
diff --git a/src/main/java/com/github/sqljam/face/service/TransferService.java b/src/main/java/com/github/sqljam/face/service/TransferService.java
index cf76c59..d3bb3b3 100644
--- a/src/main/java/com/github/sqljam/face/service/TransferService.java
+++ b/src/main/java/com/github/sqljam/face/service/TransferService.java
@@ -28,6 +28,10 @@
import com.github.sqljam.face.model.ConnectionProfile;
import com.github.sqljam.face.model.TransferRequest;
import com.github.sqljam.impexp.AbstractFileImporter;
+import com.github.sqljam.impexp.CsvExporter;
+import com.github.sqljam.impexp.CsvImporter;
+import com.github.sqljam.impexp.CsvOptions;
+import com.github.sqljam.impexp.DataFileImporter;
import com.github.sqljam.impexp.DataFormat;
import com.github.sqljam.impexp.DbType;
import com.github.sqljam.impexp.DuckDBWorkspace;
@@ -39,6 +43,8 @@
import com.github.sqljam.impexp.ParquetImporter;
import com.github.sqljam.impexp.ScriptExporter;
import com.github.sqljam.impexp.SqlScriptRunner;
+import com.github.sqljam.jdbc.ManualTransaction;
+import com.github.sqljam.jdbc.SharedConnectionFactory;
/**
* @Description: TransferService runs exports to sql scripts, imports into other databases and imports of script
@@ -50,10 +56,60 @@
public class TransferService {
public void transfer(TransferRequest request, ExportListener listener) throws Exception {
+ transfer(request, null, listener);
+ }
+
+ /**
+ * Exports into files, or imports into the target database, in the transaction if it is given
+ *
+ * @param transaction transaction of the target database opened by {@link #beginTransaction}, null to commit
+ * page by page
+ */
+ public void transfer(TransferRequest request, ManualTransaction transaction, ExportListener listener)
+ throws Exception {
if (request.getTarget() == TransferRequest.Target.SCRIPT) {
exportScripts(request, listener);
} else {
- importDatabase(request, listener);
+ importDatabase(request, transaction, listener);
+ }
+ }
+
+ /**
+ * Opens a transaction of the target database for an import which the user commits or rolls back
+ */
+ public ManualTransaction beginTransaction(ConnectionProfile target, String catalog, String schema)
+ throws SQLException {
+ if (!target.getDbType().isTransactionSupported()) {
+ throw new IllegalArgumentException(target.getDbType().getDisplayName() + " has no transactions.");
+ }
+ Connection connection = connect(target, catalog, schema);
+ try {
+ return new ManualTransaction(connection, target.getDbType().isTransactionalDdl());
+ } catch (SQLException | RuntimeException e) {
+ closeQuietly(connection);
+ throw e;
+ }
+ }
+
+ /**
+ * Connection of the target database in the catalog and schema
+ */
+ private static Connection connect(ConnectionProfile target, String catalog, String schema) throws SQLException {
+ String url = target.getJdbcUrl(StringUtils.defaultIfBlank(catalog, null));
+ Connection connection = StringUtils.isBlank(target.getUsername()) ? DriverManager.getConnection(url)
+ : DriverManager.getConnection(url, target.getUsername(), target.getPassword());
+ try {
+ if (StringUtils.isNotBlank(schema) && target.getDbType().isCanSetSchema()) {
+ connection.setSchema(schema);
+ }
+ if (StringUtils.isNotBlank(catalog) && (target.getDbType() == DbType.MYSQL
+ || target.getDbType() == DbType.MARIADB)) {
+ connection.setCatalog(catalog);
+ }
+ return connection;
+ } catch (SQLException | RuntimeException e) {
+ closeQuietly(connection);
+ throw e;
}
}
@@ -74,6 +130,8 @@ private void configureSource(Exporter.ExportConfiguration configuration, Transfe
configuration.setLobPageSize(request.getLobPageSize());
configuration.setConnectionPoolEnabled(true);
configuration.setTableQueries(new HashMap<>(request.getTableQueries()));
+ configuration.setWriteMode(request.getWriteMode());
+ configuration.setKeyColumnNames(new HashMap<>(request.getKeyColumnNames()));
}
private void exportScripts(TransferRequest request, ExportListener listener) throws Exception {
@@ -84,6 +142,10 @@ private void exportScripts(TransferRequest request, ExportListener listener) thr
exportParquet(request, listener);
return;
}
+ if (request.getDataFormat() == DataFormat.CSV) {
+ exportCsv(request, listener);
+ return;
+ }
ScriptExporter scriptExporter = new ScriptExporter(request.getOutputDirectory(),
request.getDataFileStrategy(), request.getMaxFileSize());
configureSource(scriptExporter.getConfiguration(), request);
@@ -109,6 +171,7 @@ private void exportParquet(TransferRequest request, ExportListener listener) thr
parquetExporter.setTargetSchemaName(request.getScriptSchema());
parquetExporter.setIdentifierCase(request.getIdentifierCase());
parquetExporter.setCompression(request.getCompression());
+ parquetExporter.setMaxFileSize(request.getMaxFileSize());
int[] version = parseVersion(request.getScriptDbVersion());
if (version != null) {
parquetExporter.setTargetVersion(version[0], version[1]);
@@ -117,31 +180,85 @@ private void exportParquet(TransferRequest request, ExportListener listener) thr
parquetExporter.export(request.getExportMode());
}
+ /**
+ * Export package with CSV files: schema.sql and constraints.sql for the target database type, the types of
+ * the columns are kept in manifest.json
+ */
+ private void exportCsv(TransferRequest request, ExportListener listener) throws Exception {
+ CsvExporter csvExporter = new CsvExporter(request.getOutputDirectory());
+ configureSource(csvExporter.getConfiguration(), request);
+ csvExporter.setTargetDbType(request.getScriptDbType());
+ csvExporter.setTargetSchemaName(request.getScriptSchema());
+ csvExporter.setIdentifierCase(request.getIdentifierCase());
+ csvExporter.setOptions(request.getCsvOptions());
+ csvExporter.setMaxFileSize(request.getMaxFileSize());
+ int[] version = parseVersion(request.getScriptDbVersion());
+ if (version != null) {
+ csvExporter.setTargetVersion(version[0], version[1]);
+ }
+ csvExporter.setExportListener(listener);
+ csvExporter.export(request.getExportMode());
+ }
+
/**
* Imports Parquet files of other tools into a table: a new table created from the Parquet schema, or rows
* appended to an existing table
*/
- public ParquetImporter importParquet(ConnectionProfile target, String catalog, String schema, List files,
- String tableName, ParquetImporter.Mode mode, ExportListener listener)
+ public DataFileImporter importParquet(ConnectionProfile target, String catalog, String schema, List files,
+ String tableName, DataFileImporter.Mode mode, ExportListener listener)
throws Exception {
- String url = target.getJdbcUrl(StringUtils.defaultIfBlank(catalog, null));
- try (Connection connection = StringUtils.isBlank(target.getUsername()) ? DriverManager.getConnection(url)
- : DriverManager.getConnection(url, target.getUsername(), target.getPassword())) {
- if (StringUtils.isNotBlank(schema) && target.getDbType().isCanSetSchema()) {
- connection.setSchema(schema);
- }
- if (StringUtils.isNotBlank(catalog) && (target.getDbType() == DbType.MYSQL
- || target.getDbType() == DbType.MARIADB)) {
- connection.setCatalog(catalog);
+ return importFiles(target, catalog, schema, files, tableName, mode, null, listener);
+ }
+
+ /**
+ * Imports Parquet or CSV files of other tools into a table (CSV when all files are .csv, .tsv or .txt): a new
+ * table created from the columns of the files, or rows appended to an existing table
+ *
+ * @param transaction transaction opened by {@link #beginTransaction}, null to commit page by page
+ */
+ public DataFileImporter importFiles(ConnectionProfile target, String catalog, String schema, List files,
+ String tableName, DataFileImporter.Mode mode, ManualTransaction transaction,
+ ExportListener listener) throws Exception {
+ return importFiles(target, catalog, schema, files, tableName, mode, Collections.emptyList(), null, false, 0,
+ transaction, listener);
+ }
+
+ /**
+ * Imports Parquet or CSV files of other tools into a table, rows are matched by the key columns
+ *
+ * @param keyColumns key columns of upserts and skips, empty for the primary key of the table
+ * @param csvOptions options of CSV files, null to detect them
+ * @param skipErrors whether rows with errors are skipped, otherwise the first error stops the import
+ * @param commitRows rows per commit without transaction, 0 for the default page size
+ */
+ public DataFileImporter importFiles(ConnectionProfile target, String catalog, String schema, List files,
+ String tableName, DataFileImporter.Mode mode, List keyColumns,
+ CsvOptions csvOptions, boolean skipErrors, int commitRows,
+ ManualTransaction transaction, ExportListener listener) throws Exception {
+ Connection connection = transaction != null ? transaction.getConnection() : connect(target, catalog, schema);
+ try {
+ DataFileImporter importer;
+ if (files.stream().allMatch(DuckDBWorkspace::isCsv)) {
+ CsvImporter csvImporter = new CsvImporter(connection, target.getDbType());
+ csvImporter.setOptions(csvOptions);
+ importer = csvImporter;
+ } else {
+ importer = new ParquetImporter(connection, target.getDbType());
}
- ParquetImporter importer = new ParquetImporter(connection, target.getDbType());
if (StringUtils.isNotBlank(schema) && target.getDbType().isSchemaSupported()) {
importer.setTargetSchemaName(schema);
}
- importer.setPageSize(Config.getInstance().getInt("sqljam.export.page-size", Exporter.DEFAULT_PAGE_SIZE));
+ importer.setPageSize(commitRows > 0 ? commitRows : Config.getInstance().getInt("sqljam.export.page-size",
+ Exporter.DEFAULT_PAGE_SIZE));
+ importer.setKeyColumns(keyColumns);
+ importer.setStopOnError(!skipErrors);
importer.setExportListener(listener);
importer.importFiles(files, tableName, mode);
return importer;
+ } finally {
+ if (transaction == null) {
+ closeQuietly(connection);
+ }
}
}
@@ -156,10 +273,18 @@ public ParquetPreview previewParquet(List files) throws Exception {
* Columns, rows and the first rows (values as text) of Parquet files
*/
public ParquetPreview previewParquet(List files, int rowLimit) throws Exception {
+ return previewParquet(files, rowLimit, null);
+ }
+
+ /**
+ * Columns, rows and first rows of Parquet or CSV files, CSV files are read with the options (null to detect)
+ */
+ public ParquetPreview previewParquet(List files, int rowLimit, CsvOptions csvOptions) throws Exception {
try (DuckDBWorkspace workspace = new DuckDBWorkspace(null)) {
- List> firstRows = rowLimit > 0 ? workspace.readParquetRows(files, rowLimit)
+ List> firstRows = rowLimit > 0 ? workspace.readRows(files, rowLimit, csvOptions)
: Collections.emptyList();
- return new ParquetPreview(workspace.describeParquet(files), workspace.countParquetRows(files), firstRows);
+ return new ParquetPreview(workspace.describeFiles(files, csvOptions), workspace.countFileRows(files,
+ csvOptions), firstRows);
}
}
@@ -197,7 +322,8 @@ public long getRows() {
}
}
- private void importDatabase(TransferRequest request, ExportListener listener) throws Exception {
+ private void importDatabase(TransferRequest request, ManualTransaction transaction, ExportListener listener)
+ throws Exception {
ConnectionProfile target = request.getTargetProfile();
if (target == null) {
throw new IllegalArgumentException("Target database must be required.");
@@ -213,35 +339,52 @@ private void importDatabase(TransferRequest request, ExportListener listener) th
importConfiguration.setTargetSchemaName(request.getTargetSchema());
importConfiguration.setTargetSchemaCreated(request.isTargetSchemaCreated());
importConfiguration.setTableNamePattern(request.getTableNamePattern());
- importConfiguration.setConnectionPoolEnabled(true);
+ importConfiguration.setConnectionPoolEnabled(transaction == null);
+ if (transaction != null) {
+ // All pages and tables in the one transaction
+ importExporter.setTargetConnectionFactory(new SharedConnectionFactory(transaction.getConnection()));
+ }
importExporter.setIdentifierCase(request.getIdentifierCase());
importExporter.setExportListener(listener);
importExporter.export(request.getExportMode());
}
/**
- * Imports an export package (sql scripts or Parquet files) into the target database
+ * Imports an export package (sql scripts, Parquet or CSV files) into the target database
*/
public AbstractFileImporter importScripts(ConnectionProfile target, String catalog, String schema, File directory,
- boolean stopOnError, ExportListener listener) throws Exception {
- String url = target.getJdbcUrl(StringUtils.defaultIfBlank(catalog, null));
- try (Connection connection = StringUtils.isBlank(target.getUsername()) ? DriverManager.getConnection(url)
- : DriverManager.getConnection(url, target.getUsername(), target.getPassword())) {
- if (StringUtils.isNotBlank(schema) && target.getDbType().isCanSetSchema()) {
- connection.setSchema(schema);
- }
- if (StringUtils.isNotBlank(catalog) && (target.getDbType() == DbType.MYSQL
- || target.getDbType() == DbType.MARIADB)) {
- connection.setCatalog(catalog);
- }
+ boolean stopOnError, ExportListener listener) throws Exception {
+ return importScripts(target, catalog, schema, directory, stopOnError, 0, null, listener);
+ }
+
+ /**
+ * Imports an export package into the target database, in the transaction if it is given
+ *
+ * @param commitRows rows (statements of scripts) per commit without transaction, 0 for the default batch
+ * and page sizes
+ * @param transaction transaction opened by {@link #beginTransaction}, null to commit batch by batch
+ */
+ public AbstractFileImporter importScripts(ConnectionProfile target, String catalog, String schema, File directory,
+ boolean stopOnError, int commitRows, ManualTransaction transaction,
+ ExportListener listener) throws Exception {
+ Connection connection = transaction != null ? transaction.getConnection() : connect(target, catalog, schema);
+ try {
AbstractFileImporter importer = AbstractFileImporter.forDirectory(directory, connection,
target.getDbType());
importer.setStopOnError(stopOnError);
- importer.setBatchSize(Config.getInstance().getInt("sqljam.import.batch-size",
- SqlScriptRunner.DEFAULT_BATCH_SIZE));
+ importer.setBatchSize(commitRows > 0 ? commitRows : Config.getInstance().getInt(
+ "sqljam.import.batch-size", SqlScriptRunner.DEFAULT_BATCH_SIZE));
+ // Data files are committed page by page
+ if (commitRows > 0 && importer instanceof DataFileImporter) {
+ ((DataFileImporter) importer).setPageSize(commitRows);
+ }
importer.setExportListener(listener);
importer.importDirectory(directory);
return importer;
+ } finally {
+ if (transaction == null) {
+ closeQuietly(connection);
+ }
}
}
diff --git a/src/main/java/com/github/sqljam/face/view/CsvOptionsPane.java b/src/main/java/com/github/sqljam/face/view/CsvOptionsPane.java
new file mode 100644
index 0000000..e655768
--- /dev/null
+++ b/src/main/java/com/github/sqljam/face/view/CsvOptionsPane.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright 2023-2026 Fred Feng
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.github.sqljam.face.view;
+
+import java.util.List;
+import java.util.function.Function;
+
+import org.apache.commons.lang3.StringUtils;
+import com.github.sqljam.impexp.CsvExporter;
+import com.github.sqljam.impexp.CsvOptions;
+import javafx.scene.Node;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.ComboBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.TextField;
+import javafx.scene.layout.GridPane;
+import javafx.util.StringConverter;
+
+/**
+ * @Description: CsvOptionsPane edits the options of CSV files: delimiter, quote character, text of NULL, header row,
+ * encoding and date format. Options of files of other tools may be detected: an empty delimiter and an
+ * empty date format are detected from the files.
+ * @Author: Fred Feng
+ * @Date: 26/03/2023
+ * @Version 1.0.0
+ */
+final class CsvOptionsPane {
+
+ private final ComboBox delimiterCombo = new ComboBox<>();
+ private final ComboBox quoteCombo = new ComboBox<>();
+ private final TextField nullField = new TextField();
+ private final CheckBox headerCheck = new CheckBox(Messages.get("csv.header"));
+ private final ComboBox encodingCombo = new ComboBox<>();
+ private final ComboBox dateFormatCombo = new ComboBox<>();
+ private final GridPane node = new GridPane();
+ private final boolean detected;
+
+ /**
+ * @param detected whether the delimiter and the date format of files of other tools may be detected
+ */
+ CsvOptionsPane(CsvOptions options, boolean detected) {
+ this.detected = detected;
+ delimiterCombo.setId("csvDelimiterCombo");
+ quoteCombo.setId("csvQuoteCombo");
+ nullField.setId("csvNullField");
+ headerCheck.setId("csvHeaderCheck");
+ encodingCombo.setId("csvEncodingCombo");
+ dateFormatCombo.setId("csvDateFormatCombo");
+ if (detected) {
+ delimiterCombo.getItems().add("");
+ }
+ delimiterCombo.getItems().addAll(CsvExporter.DELIMITERS);
+ delimiterCombo.setConverter(converter(CsvOptionsPane::delimiterName));
+ quoteCombo.getItems().addAll(CsvOptions.QUOTES);
+ quoteCombo.setConverter(converter(quote -> "\"".equals(quote) ? Messages.get("csv.quote.double")
+ : Messages.get("csv.quote.single")));
+ nullField.setPromptText(Messages.get("csv.null.prompt"));
+ encodingCombo.getItems().addAll(CsvOptions.ENCODINGS);
+ dateFormatCombo.getItems().addAll(CsvOptions.DATE_FORMATS);
+ dateFormatCombo.setConverter(converter(format -> StringUtils.isEmpty(format) ? Messages.get(detected
+ ? "csv.dateFormat.detect" : "csv.dateFormat.iso") : format.replace("%Y", "yyyy").replace("%m", "MM")
+ .replace("%d", "dd")));
+ for (ComboBox combo : List.of(delimiterCombo, quoteCombo, encodingCombo, dateFormatCombo)) {
+ combo.setMaxWidth(Double.MAX_VALUE);
+ }
+ node.setHgap(10);
+ node.setVgap(8);
+ node.addRow(0, new Label(Messages.get("csv.delimiter")), delimiterCombo, new Label(Messages.get("csv.quote")),
+ quoteCombo);
+ node.addRow(1, new Label(Messages.get("csv.null")), nullField, new Label(Messages.get("csv.encoding")),
+ encodingCombo);
+ node.addRow(2, new Label(Messages.get("csv.dateFormat")), dateFormatCombo, headerCheck);
+ setOptions(options);
+ }
+
+ private static StringConverter converter(Function name) {
+ return new StringConverter<>() {
+ @Override
+ public String toString(String value) {
+ return value == null ? "" : name.apply(value);
+ }
+
+ @Override
+ public String fromString(String text) {
+ return text;
+ }
+ };
+ }
+
+ /**
+ * Name of a delimiter: a tab has no visible character, an empty delimiter is detected
+ */
+ static String delimiterName(String delimiter) {
+ if (delimiter == null) {
+ return "";
+ }
+ switch (delimiter) {
+ case "":
+ return Messages.get("csv.delimiter.detect");
+ case ",":
+ return Messages.get("export.format.delimiter.comma");
+ case ";":
+ return Messages.get("export.format.delimiter.semicolon");
+ case "\t":
+ return Messages.get("export.format.delimiter.tab");
+ case "|":
+ return Messages.get("export.format.delimiter.pipe");
+ default:
+ return delimiter;
+ }
+ }
+
+ void setOptions(CsvOptions options) {
+ CsvOptions value = options != null ? options : new CsvOptions();
+ delimiterCombo.setValue(delimiterCombo.getItems().contains(value.getDelimiter()) ? value.getDelimiter()
+ : detected ? "" : ",");
+ quoteCombo.setValue(quoteCombo.getItems().contains(value.getQuote()) ? value.getQuote() : "\"");
+ nullField.setText(value.getNullString());
+ headerCheck.setSelected(value.isHeader());
+ encodingCombo.setValue(encodingCombo.getItems().contains(value.getEncoding()) ? value.getEncoding()
+ : "UTF-8");
+ dateFormatCombo.setValue(dateFormatCombo.getItems().contains(value.getDateFormat()) ? value.getDateFormat()
+ : "");
+ }
+
+ CsvOptions getOptions() {
+ CsvOptions options = new CsvOptions();
+ options.setDelimiter(StringUtils.defaultString(delimiterCombo.getValue()));
+ options.setQuote(StringUtils.defaultIfEmpty(quoteCombo.getValue(), "\""));
+ options.setNullString(StringUtils.defaultString(nullField.getText()));
+ options.setHeader(headerCheck.isSelected());
+ options.setEncoding(StringUtils.defaultIfEmpty(encodingCombo.getValue(), "UTF-8"));
+ options.setDateFormat(StringUtils.defaultString(dateFormatCombo.getValue()));
+ return options;
+ }
+
+ /**
+ * Runs the action when an option is changed, e.g. to preview the files again
+ */
+ void setOnChange(Runnable action) {
+ delimiterCombo.valueProperty().addListener((obs, oldValue, value) -> action.run());
+ quoteCombo.valueProperty().addListener((obs, oldValue, value) -> action.run());
+ nullField.textProperty().addListener((obs, oldValue, value) -> action.run());
+ headerCheck.selectedProperty().addListener((obs, oldValue, value) -> action.run());
+ encodingCombo.valueProperty().addListener((obs, oldValue, value) -> action.run());
+ dateFormatCombo.valueProperty().addListener((obs, oldValue, value) -> action.run());
+ }
+
+ Node getNode() {
+ return node;
+ }
+}
diff --git a/src/main/java/com/github/sqljam/face/view/ExportDialog.java b/src/main/java/com/github/sqljam/face/view/ExportDialog.java
index c5ac061..e288dfb 100644
--- a/src/main/java/com/github/sqljam/face/view/ExportDialog.java
+++ b/src/main/java/com/github/sqljam/face/view/ExportDialog.java
@@ -18,6 +18,7 @@
import java.io.File;
import java.util.ArrayList;
import java.util.EnumMap;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -29,6 +30,9 @@
import com.github.sqljam.face.model.TransferRequest;
import com.github.sqljam.face.service.DatabaseSession;
import com.github.sqljam.impexp.DataFileStrategy;
+import com.github.sqljam.impexp.CsvExporter;
+import com.github.sqljam.impexp.CsvOptions;
+import com.github.sqljam.impexp.DuckDBWorkspace;
import com.github.sqljam.impexp.DataFormat;
import com.github.sqljam.impexp.DbType;
import com.github.sqljam.impexp.Dialect;
@@ -37,6 +41,7 @@
import com.github.sqljam.impexp.IdentifierCase;
import com.github.sqljam.impexp.ParquetExporter;
import com.github.sqljam.impexp.TableQuery;
+import com.github.sqljam.impexp.WriteMode;
import javafx.beans.InvalidationListener;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
@@ -60,8 +65,10 @@
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.control.TextField;
import javafx.scene.control.ToggleGroup;
+import javafx.scene.control.Tooltip;
import javafx.scene.control.cell.CheckBoxListCell;
import javafx.scene.layout.ColumnConstraints;
+import javafx.scene.layout.FlowPane;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
@@ -130,7 +137,23 @@ public class ExportDialog extends Dialog {
private final ToggleGroup formatGroup = new ToggleGroup();
private final RadioButton sqlFormatRadio = new RadioButton(Messages.get("export.format.sql"));
private final RadioButton parquetFormatRadio = new RadioButton(Messages.get("export.format.parquet"));
+ private final RadioButton csvFormatRadio = new RadioButton(Messages.get("export.format.csv"));
private final ComboBox compressionCombo = new ComboBox<>();
+ private CsvOptionsPane csvOptionsPane;
+ private TransactionOption transactionOption;
+ private final RadioButton insertRadio = new RadioButton(Messages.get("export.writeMode.insert"));
+ private final RadioButton upsertRadio = new RadioButton(Messages.get("export.writeMode.upsert"));
+ private final RadioButton skipExistingRadio = new RadioButton(Messages.get("export.writeMode.skip"));
+ private final Button keyColumnsButton = new Button(Messages.get("export.writeMode.keys"));
+ /**
+ * Chosen key columns of upserts by table, the primary key of the other tables
+ */
+ private Map> keyColumnNames = new HashMap<>();
+ /**
+ * Max file size of the shown format in MB: sql scripts and CSV files share one, Parquet has its own
+ */
+ private long textMaxFileSize;
+ private long parquetMaxFileSize;
private final Label layoutHint = new Label();
private final ComboBox targetCombo = new ComboBox<>();
private final ComboBox targetCatalogCombo = new ComboBox<>();
@@ -194,6 +217,7 @@ public ExportDialog(Window owner, AppContext context, DbNode node, TableQuery qu
sqlFormatRadio.setId("sqlFormatRadio");
parquetFormatRadio.setId("parquetFormatRadio");
compressionCombo.setId("compressionCombo");
+ csvFormatRadio.setId("csvFormatRadio");
layoutHint.setId("layoutHint");
targetCombo.setId("targetCombo");
targetCatalogCombo.setId("targetCatalogCombo");
@@ -235,6 +259,9 @@ public ExportDialog(Window owner, AppContext context, DbNode node, TableQuery qu
});
setResultConverter(buttonType -> buttonType == ButtonType.OK ? buildRequest() : null);
registerValidation();
+ targetGroup.selectedToggleProperty().addListener((obs, oldToggle, toggle) -> updateWriteModes());
+ targetCombo.valueProperty().addListener((obs, oldProfile, profile) -> updateWriteModes());
+ scriptDbTypeCombo.valueProperty().addListener((obs, oldType, type) -> updateWriteModes());
preselect(node);
if (tableQuery != null) {
queryLabel.setText(Messages.format("export.query", queryTableName, tableQuery));
@@ -403,7 +430,29 @@ public IdentifierCase fromString(String text) {
checks.addRow(2, failFastCheck);
GridPane grid = grid();
grid.addRow(0, new Label(Messages.get("export.option.pageSize")), pageSizeSpinner);
+ insertRadio.setId("insertRadio");
+ upsertRadio.setId("upsertRadio");
+ skipExistingRadio.setId("skipExistingRadio");
+ keyColumnsButton.setId("keyColumnsButton");
+ ToggleGroup writeModeGroup = new ToggleGroup();
+ insertRadio.setToggleGroup(writeModeGroup);
+ upsertRadio.setToggleGroup(writeModeGroup);
+ skipExistingRadio.setToggleGroup(writeModeGroup);
+ insertRadio.setSelected(true);
+ keyColumnsButton.disableProperty().bind(insertRadio.selectedProperty());
+ keyColumnsButton.setOnAction(event -> chooseKeyColumns());
+ insertRadio.selectedProperty().addListener((obs, oldValue, insert) -> {
+ // Rows of kept tables are matched by key, tables are not dropped
+ if (!insert) {
+ recreateCheck.setSelected(false);
+ }
+ recreateCheck.setDisable(!insert);
+ });
+ // Wraps in narrow windows
+ FlowPane writeModeBox = new FlowPane(16, 6, insertRadio, upsertRadio, skipExistingRadio, keyColumnsButton);
+ writeModeBox.setAlignment(Pos.CENTER_LEFT);
grid.addRow(1, new Label(Messages.get("export.option.identifierCase")), identifierCaseCombo);
+ grid.addRow(2, new Label(Messages.get("export.writeMode")), writeModeBox);
return new VBox(12, checks, grid);
}
@@ -434,20 +483,44 @@ private Node createTargetPane() {
singleFileRadio.setSelected(true);
maxFileSizeSpinner.setEditable(true);
long maxFileSize = context.getSettings().getMaxDataFileSize();
- maxFileSizeSpinner.getValueFactory().setValue(maxFileSize > 0 ? (int) Math.max(1, maxFileSize / MB) : 10);
+ textMaxFileSize = maxFileSize > 0 ? Math.max(1, maxFileSize / MB) : 0;
+ long parquetSize = context.getSettings().getParquetMaxFileSize();
+ parquetMaxFileSize = parquetSize > 0 ? Math.max(1, parquetSize / MB) : 0;
+ maxFileSizeSpinner.getValueFactory().setValue((int) textMaxFileSize);
lobSeparatedCheck.setSelected(true);
sqlFormatRadio.setToggleGroup(formatGroup);
parquetFormatRadio.setToggleGroup(formatGroup);
+ csvFormatRadio.setToggleGroup(formatGroup);
sqlFormatRadio.setSelected(true);
compressionCombo.getItems().setAll(ParquetExporter.COMPRESSIONS);
compressionCombo.setValue(ParquetExporter.DEFAULT_COMPRESSION);
- // Options of sql scripts do not apply to Parquet files
+ // Options of CSV files, the delimiter of the last export
+ CsvOptions csvOptions = new CsvOptions();
+ csvOptions.setDelimiter(context.getSettings().getCsvDelimiter());
+ csvOptionsPane = new CsvOptionsPane(csvOptions, false);
+ // Options of sql scripts do not apply to data files, each data file format has its own options
compressionCombo.disableProperty().bind(parquetFormatRadio.selectedProperty().not());
- singleFileRadio.disableProperty().bind(parquetFormatRadio.selectedProperty());
- perTableRadio.disableProperty().bind(parquetFormatRadio.selectedProperty());
- maxFileSizeSpinner.disableProperty().bind(parquetFormatRadio.selectedProperty());
- lobSeparatedCheck.disableProperty().bind(parquetFormatRadio.selectedProperty());
- formatGroup.selectedToggleProperty().addListener((obs, oldToggle, toggle) -> updateLayoutHint());
+ csvOptionsPane.getNode().disableProperty().bind(csvFormatRadio.selectedProperty().not());
+ singleFileRadio.disableProperty().bind(sqlFormatRadio.selectedProperty().not());
+ perTableRadio.disableProperty().bind(sqlFormatRadio.selectedProperty().not());
+ lobSeparatedCheck.disableProperty().bind(sqlFormatRadio.selectedProperty().not());
+ formatGroup.selectedToggleProperty().addListener((obs, oldToggle, toggle) -> {
+ // Parquet files are split at their own size, sql scripts and CSV files share one
+ boolean wasParquet = oldToggle == parquetFormatRadio;
+ boolean parquet = toggle == parquetFormatRadio;
+ if (wasParquet != parquet) {
+ commitSpinner(maxFileSizeSpinner);
+ long current = maxFileSizeSpinner.getValue();
+ if (wasParquet) {
+ parquetMaxFileSize = current;
+ } else {
+ textMaxFileSize = current;
+ }
+ maxFileSizeSpinner.getValueFactory().setValue((int) (parquet ? parquetMaxFileSize
+ : textMaxFileSize));
+ }
+ updateLayoutHint();
+ });
layoutHint.getStyleClass().addAll("muted", "mono");
layoutHint.setWrapText(true);
strategyGroup.selectedToggleProperty().addListener((obs, oldToggle, toggle) -> updateLayoutHint());
@@ -461,8 +534,9 @@ private Node createTargetPane() {
scriptGrid.addRow(row++, new Label(Messages.get("export.script.version")), scriptVersionCombo);
scriptGrid.addRow(row++, new Label(Messages.get("export.script.schema")), scriptSchemaField);
scriptGrid.addRow(row++, new Label(Messages.get("export.format")), new HBox(16, sqlFormatRadio,
- parquetFormatRadio));
+ parquetFormatRadio, csvFormatRadio));
scriptGrid.addRow(row++, new Label(Messages.get("export.format.compression")), compressionCombo);
+ scriptGrid.addRow(row++, new Label(Messages.get("csv.options")), csvOptionsPane.getNode());
scriptGrid.addRow(row++, new Label(Messages.get("export.script.strategy")), new HBox(16, singleFileRadio,
perTableRadio));
scriptGrid.addRow(row++, new Label(Messages.get("export.script.maxFileSize")), new HBox(8,
@@ -502,6 +576,20 @@ private Node createTargetPane() {
tableNameHint.setWrapText(true);
databaseGrid.addRow(4, new Label(Messages.get("export.database.tableName")), tableNamePatternField);
databaseGrid.add(tableNameHint, 1, 5);
+ transactionOption = new TransactionOption(context, targetCombo.valueProperty(), pageSizeSpinner.getValue());
+ // Errors of an import into a database are skipped or not by the transaction option
+ failFastCheck.visibleProperty().bind(scriptRadio.selectedProperty());
+ failFastCheck.managedProperty().bind(scriptRadio.selectedProperty());
+ // Rows are committed page by page: the rows per commit and the page size are the same value
+ pageSizeSpinner.getValueFactory().valueProperty().bindBidirectional(transactionOption.commitRowsProperty());
+ // The rows per commit follow the page size while it is typed, the values follow when it is committed
+ pageSizeSpinner.getEditor().textProperty().addListener((obs, oldText, text) -> {
+ String rows = StringUtils.trim(text);
+ if (StringUtils.isNumeric(rows) && !rows.isEmpty() && rows.length() < 9) {
+ transactionOption.showCommitRows(Integer.parseInt(rows));
+ }
+ });
+ databaseGrid.addRow(6, new Label(Messages.get("import.transaction")), transactionOption.getNode());
VBox databasePane = new VBox(databaseGrid);
databasePane.getStyleClass().add("target-pane");
@@ -512,13 +600,23 @@ private Node createTargetPane() {
return new VBox(10, new HBox(16, scriptRadio, databaseRadio), scriptPane, databasePane);
}
+ /**
+ * Name of a CSV delimiter, a tab has no visible character
+ */
+ static String delimiterName(String delimiter) {
+ return CsvOptionsPane.delimiterName(delimiter);
+ }
+
private void updateLayoutHint() {
- if (parquetFormatRadio.isSelected()) {
- layoutHint.setText("schema.sql\ndata/.parquet\nconstraints.sql\nmanifest.json");
+ boolean split = maxFileSizeSpinner.getValue() != null && maxFileSizeSpinner.getValue() > 0;
+ if (!sqlFormatRadio.isSelected()) {
+ String extension = parquetFormatRadio.isSelected() ? ParquetExporter.PARQUET_EXTENSION
+ : CsvExporter.CSV_EXTENSION;
+ layoutHint.setText("schema.sql\ndata/" + extension + (split ? ", _2" + extension + " ..."
+ : "") + "\nconstraints.sql\nmanifest.json");
return;
}
boolean perTable = perTableRadio.isSelected();
- boolean split = maxFileSizeSpinner.getValue() != null && maxFileSizeSpinner.getValue() > 0;
StringBuilder hint = new StringBuilder();
hint.append("schema.sql\n");
if (perTable) {
@@ -565,6 +663,41 @@ private void preselect(DbNode node) {
loadCatalogs(profile, node.getCatalog(), node.getSchema(), table);
}
+ /**
+ * Chooses key columns of the selected tables (all tables of the schema if none is selected)
+ */
+ private void chooseKeyColumns() {
+ ConnectionProfile source = sourceCombo.getValue();
+ if (source == null) {
+ return;
+ }
+ List tables = tableChoices.stream().filter(choice -> choice.selected.get())
+ .map(choice -> choice.table.getName()).collect(Collectors.toList());
+ if (tables.isEmpty()) {
+ tables = tableChoices.stream().map(choice -> choice.table.getName()).collect(Collectors.toList());
+ }
+ new KeyColumnsDialog(getDialogPane().getScene().getWindow(), getSession(source), catalogCombo.isDisabled()
+ ? null : catalogCombo.getValue(), schemaCombo.isDisabled() ? null : schemaCombo.getValue(), tables,
+ keyColumnNames).showAndWait().ifPresent(keys -> keyColumnNames = new HashMap<>(keys));
+ }
+
+ /**
+ * Rows are not matched by key for targets which can not do it, e.g. ClickHouse
+ */
+ private void updateWriteModes() {
+ DbType target = databaseRadio.isSelected() ? (targetCombo.getValue() != null ? targetCombo.getValue()
+ .getDbType() : null) : scriptDbTypeCombo.getValue();
+ boolean supported = target == null || target.createDialect().isUpsertSupported();
+ if (!supported) {
+ insertRadio.setSelected(true);
+ }
+ for (RadioButton radio : new RadioButton[]{upsertRadio, skipExistingRadio}) {
+ radio.setDisable(!supported);
+ radio.setTooltip(supported ? null : new Tooltip(Messages.format("export.writeMode.unsupported",
+ target.getDisplayName())));
+ }
+ }
+
private DatabaseSession getSession(ConnectionProfile profile) {
return context.getSessionManager().getSession(profile);
}
@@ -868,10 +1001,19 @@ TransferRequest buildRequest() {
: DataFileStrategy.SINGLE_FILE);
request.setMaxFileSize(maxFileSizeSpinner.getValue() * MB);
request.setLobSeparated(lobSeparatedCheck.isSelected());
- request.setDataFormat(parquetFormatRadio.isSelected() ? DataFormat.PARQUET : DataFormat.SQL);
+ request.setDataFormat(parquetFormatRadio.isSelected() ? DataFormat.PARQUET : csvFormatRadio.isSelected()
+ ? DataFormat.CSV : DataFormat.SQL);
request.setCompression(compressionCombo.getValue());
+ request.setCsvOptions(csvOptionsPane.getOptions());
context.getSettings().setLastExportDirectory(directory.getAbsolutePath());
- context.getSettings().setMaxDataFileSize(maxFileSizeSpinner.getValue() * MB);
+ if (parquetFormatRadio.isSelected()) {
+ context.getSettings().setParquetMaxFileSize(maxFileSizeSpinner.getValue() * MB);
+ } else {
+ context.getSettings().setMaxDataFileSize(maxFileSizeSpinner.getValue() * MB);
+ }
+ if (csvFormatRadio.isSelected()) {
+ context.getSettings().setCsvDelimiter(request.getCsvDelimiter());
+ }
} else {
request.setTarget(TransferRequest.Target.DATABASE);
request.setTargetProfile(targetCombo.getValue());
@@ -880,7 +1022,18 @@ TransferRequest buildRequest() {
targetSchemaCombo.getEditor().getText()));
request.setTargetSchemaCreated(createSchemaCheck.isSelected());
request.setTableNamePattern(StringUtils.trimToNull(tableNamePatternField.getText()));
+ request.setOneTransaction(transactionOption.isOneTransaction());
+ request.setConfirmCommit(transactionOption.isConfirmCommit());
+ request.setFailFast(!transactionOption.isSkipErrors());
+ if (!transactionOption.isOneTransaction()) {
+ // Rows per commit typed in the option, within the page sizes
+ request.setPageSize(Math.max(getMin(pageSizeSpinner), Math.min(getMax(pageSizeSpinner),
+ transactionOption.getCommitRows())));
+ }
}
+ request.setWriteMode(upsertRadio.isSelected() ? WriteMode.UPSERT : skipExistingRadio.isSelected()
+ ? WriteMode.SKIP_EXISTING : WriteMode.INSERT);
+ request.setKeyColumnNames(insertRadio.isSelected() ? new HashMap<>() : new HashMap<>(keyColumnNames));
// The query of the data viewer narrows its table
if (tableQuery != null && selected.contains(queryTableName)) {
request.getTableQueries().put(queryTableName, tableQuery);
diff --git a/src/main/java/com/github/sqljam/face/view/Icons.java b/src/main/java/com/github/sqljam/face/view/Icons.java
index bdc8e67..36a67d1 100644
--- a/src/main/java/com/github/sqljam/face/view/Icons.java
+++ b/src/main/java/com/github/sqljam/face/view/Icons.java
@@ -54,6 +54,7 @@ public final class Icons {
public static final String GITHUB = "fth-github";
public static final String DISCONNECT = "fth-power";
public static final String EXIT = "fth-log-out";
+ public static final String SAVE = "fth-save";
private Icons() {
}
diff --git a/src/main/java/com/github/sqljam/face/view/ImportPackageDialog.java b/src/main/java/com/github/sqljam/face/view/ImportPackageDialog.java
index f6ab515..badec75 100644
--- a/src/main/java/com/github/sqljam/face/view/ImportPackageDialog.java
+++ b/src/main/java/com/github/sqljam/face/view/ImportPackageDialog.java
@@ -22,17 +22,20 @@
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
+import com.github.sqljam.config.Config;
import com.github.sqljam.face.model.ConnectionProfile;
+import com.github.sqljam.impexp.CsvExporter;
import com.github.sqljam.impexp.DataFormat;
import com.github.sqljam.impexp.DbType;
import com.github.sqljam.impexp.ExportManifest;
import com.github.sqljam.impexp.ParquetExporter;
+import com.github.sqljam.impexp.SqlScriptRunner;
+import com.github.sqljam.impexp.WriteMode;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.event.ActionEvent;
import javafx.geometry.Insets;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
-import javafx.scene.control.CheckBox;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
@@ -65,13 +68,14 @@ public class ImportPackageDialog extends Dialog tableView = new TableView<>();
private final VBox summaryPane;
private final ComboBox targetCombo = new ComboBox<>();
private final ComboBox catalogCombo = new ComboBox<>();
private final ComboBox schemaCombo = new ComboBox<>();
- private final CheckBox stopOnErrorCheck = new CheckBox(Messages.get("import.stopOnError"));
+ private final TransactionOption transactionOption;
private final Label warningLabel = new Label();
private final Button startButton;
private ExportManifest manifest;
@@ -89,15 +93,31 @@ public static class ImportRequest {
private final String schema;
private final boolean stopOnError;
private final ExportManifest manifest;
+ /**
+ * The import runs in one transaction which the user commits or rolls back
+ */
+ private final boolean oneTransaction;
+ /**
+ * A successful import in one transaction waits for the user to commit or roll back
+ */
+ private final boolean confirmCommit;
+ /**
+ * Rows (statements of scripts) per commit when the import does not run in one transaction, 0 for the
+ * default batch and page sizes
+ */
+ private final int commitRows;
ImportRequest(File directory, ConnectionProfile target, String catalog, String schema, boolean stopOnError,
- ExportManifest manifest) {
+ ExportManifest manifest, boolean oneTransaction, boolean confirmCommit, int commitRows) {
this.directory = directory;
this.target = target;
this.catalog = catalog;
this.schema = schema;
this.stopOnError = stopOnError;
this.manifest = manifest;
+ this.oneTransaction = oneTransaction;
+ this.confirmCommit = confirmCommit;
+ this.commitRows = commitRows;
}
}
@@ -116,12 +136,12 @@ public ImportPackageDialog(Window owner, AppContext context, DbNode node) {
targetValue.setId("targetValue");
filesValue.setId("filesValue");
formatValue.setId("formatValue");
+ writeModeValue.setId("writeModeValue");
viewButton.setId("viewPackageButton");
tableView.setId("packageTables");
targetCombo.setId("targetCombo");
catalogCombo.setId("catalogCombo");
schemaCombo.setId("schemaCombo");
- stopOnErrorCheck.setId("stopOnErrorCheck");
warningLabel.setId("warningLabel");
directoryField.setOnAction(event -> loadManifest());
directoryField.focusedProperty().addListener((obs, wasFocused, focused) -> {
@@ -148,6 +168,7 @@ public ImportPackageDialog(Window owner, AppContext context, DbNode node) {
summaryGrid.addRow(3, new Label(Messages.get("import.manifest.target")), targetValue);
summaryGrid.addRow(4, new Label(Messages.get("import.manifest.files")), filesValue);
summaryGrid.addRow(5, new Label(Messages.get("export.format")), formatValue);
+ summaryGrid.addRow(6, new Label(Messages.get("export.writeMode")), writeModeValue);
setupTableView();
Label summaryTitle = new Label(Messages.get("import.section.package"));
summaryTitle.getStyleClass().add("section-title");
@@ -160,7 +181,6 @@ public ImportPackageDialog(Window owner, AppContext context, DbNode node) {
schemaCombo.setMaxWidth(Double.MAX_VALUE);
schemaCombo.setEditable(true);
schemaCombo.setPromptText(Messages.get("import.schema.prompt"));
- stopOnErrorCheck.setSelected(true);
targetCombo.valueProperty().addListener((obs, oldProfile, profile) -> {
loadCatalogs(profile);
validateTarget();
@@ -180,7 +200,10 @@ public ImportPackageDialog(Window owner, AppContext context, DbNode node) {
}));
targetGrid.addRow(1, new Label(Messages.get("export.database.catalog")), catalogCombo);
targetGrid.addRow(2, new Label(Messages.get("export.database.schema")), schemaCombo);
- targetGrid.add(stopOnErrorCheck, 1, 3);
+ // Scripts are committed batch by batch, data files page by page
+ transactionOption = new TransactionOption(context, targetCombo.valueProperty(), Config.getInstance().getInt(
+ "sqljam.import.batch-size", SqlScriptRunner.DEFAULT_BATCH_SIZE));
+ targetGrid.addRow(3, new Label(Messages.get("import.transaction")), transactionOption.getNode());
Label targetTitle = new Label(Messages.get("import.section.target"));
targetTitle.getStyleClass().add("section-title");
VBox targetPane = new VBox(8, targetTitle, targetGrid);
@@ -316,15 +339,16 @@ static boolean isScriptDirectory(File directory) {
}
/**
- * Directory with Parquet files (directly or in data/) but without manifest.json
+ * Directory with Parquet or CSV files (directly or in data/) but without manifest.json
*/
static boolean isParquetDirectory(File directory) {
- return hasParquetFiles(directory) || hasParquetFiles(new File(directory, "data"));
+ return hasDataFiles(directory) || hasDataFiles(new File(directory, "data"));
}
- private static boolean hasParquetFiles(File directory) {
+ private static boolean hasDataFiles(File directory) {
File[] files = directory.listFiles((parent, name) -> name.toLowerCase(Locale.ENGLISH)
- .endsWith(ParquetExporter.PARQUET_EXTENSION));
+ .endsWith(ParquetExporter.PARQUET_EXTENSION) || name.toLowerCase(Locale.ENGLISH)
+ .endsWith(CsvExporter.CSV_EXTENSION));
return files != null && files.length > 0;
}
@@ -339,6 +363,7 @@ private void showSummary(ExportManifest manifest, boolean valid) {
targetValue.setText("-");
filesValue.setText("-");
formatValue.setText(valid ? Messages.get("export.format.sql") : "-");
+ writeModeValue.setText(valid ? Messages.get("export.writeMode.insert") : "-");
filterTargets();
return;
}
@@ -352,8 +377,12 @@ private void showSummary(ExportManifest manifest, boolean valid) {
long totalSize = manifest.getFiles().stream().mapToLong(ExportManifest.FileEntry::getSize).sum();
filesValue.setText(Messages.format("import.manifest.filesValue", manifest.getFiles().size(),
FileUtils.byteCountToDisplaySize(totalSize)));
+ // Rows of the same key are updated or skipped by packages of upserts or skips
+ WriteMode writeMode = WriteMode.of(manifest.getOptions().get("writeMode"));
+ writeModeValue.setText(Messages.get(writeMode == WriteMode.UPSERT ? "export.writeMode.upsert"
+ : writeMode == WriteMode.SKIP_EXISTING ? "export.writeMode.skip" : "export.writeMode.insert"));
formatValue.setText(Messages.get(manifest.getDataFormat() == DataFormat.PARQUET ? "export.format.parquet"
- : "export.format.sql"));
+ : manifest.getDataFormat() == DataFormat.CSV ? "export.format.csv" : "export.format.sql"));
tableView.getItems().setAll(manifest.getTables());
filterTargets();
}
@@ -484,6 +513,8 @@ ImportRequest buildRequest() {
context.saveSettings();
return new ImportRequest(directory, targetCombo.getValue(), catalogCombo.isDisabled() ? null
: catalogCombo.getValue(), schemaCombo.isDisabled() ? null
- : StringUtils.trimToNull(schemaCombo.getEditor().getText()), stopOnErrorCheck.isSelected(), manifest);
+ : StringUtils.trimToNull(schemaCombo.getEditor().getText()), !transactionOption.isSkipErrors(), manifest,
+ transactionOption.isOneTransaction(), transactionOption.isConfirmCommit(),
+ transactionOption.isOneTransaction() ? 0 : transactionOption.getCommitRows());
}
}
diff --git a/src/main/java/com/github/sqljam/face/view/ImportParquetDialog.java b/src/main/java/com/github/sqljam/face/view/ImportParquetDialog.java
index ffa5644..504e1f2 100644
--- a/src/main/java/com/github/sqljam/face/view/ImportParquetDialog.java
+++ b/src/main/java/com/github/sqljam/face/view/ImportParquetDialog.java
@@ -25,6 +25,11 @@
import com.github.sqljam.face.model.ConnectionProfile;
import com.github.sqljam.face.service.TransferService;
import com.github.sqljam.impexp.ParquetExporter;
+import javafx.scene.control.CheckBox;
+import com.github.sqljam.impexp.DuckDBWorkspace;
+import com.github.sqljam.impexp.CsvSplitter;
+import com.github.sqljam.impexp.CsvExporter;
+import com.github.sqljam.impexp.CsvOptions;
import com.github.sqljam.impexp.ParquetImporter;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.event.ActionEvent;
@@ -71,7 +76,19 @@ public class ImportParquetDialog extends Dialog targetCombo = new ComboBox<>();
+ private final TransactionOption transactionOption;
private final ComboBox catalogCombo = new ComboBox<>();
private final ComboBox schemaCombo = new ComboBox<>();
private final Label warningLabel = new Label();
@@ -88,21 +105,61 @@ public static class ParquetRequest {
private final List files;
private final String tableName;
private final ParquetImporter.Mode mode;
+ /**
+ * Key columns of upserts, empty for the primary key of the table
+ */
+ private final List keyColumns;
+ /**
+ * Options of CSV files, null for Parquet files
+ */
+ private final CsvOptions csvOptions;
private final ConnectionProfile target;
private final String catalog;
private final String schema;
+ /**
+ * The import runs in one transaction which the user commits or rolls back
+ */
+ private final boolean oneTransaction;
+ /**
+ * A successful import in one transaction waits for the user to commit or roll back
+ */
+ private final boolean confirmCommit;
+ /**
+ * Rows with errors are skipped, otherwise an error rolls back one transaction or stops the import
+ */
+ private final boolean skipErrors;
+ /**
+ * Rows per commit when the import does not run in one transaction, 0 for the default page size
+ */
+ private final int commitRows;
- ParquetRequest(List files, String tableName, ParquetImporter.Mode mode, ConnectionProfile target,
- String catalog, String schema) {
+ ParquetRequest(List files, String tableName, ParquetImporter.Mode mode, List keyColumns,
+ CsvOptions csvOptions, ConnectionProfile target, String catalog, String schema,
+ boolean oneTransaction, boolean confirmCommit, boolean skipErrors, int commitRows) {
this.files = files;
this.tableName = tableName;
this.mode = mode;
+ this.keyColumns = keyColumns;
+ this.csvOptions = csvOptions;
this.target = target;
this.catalog = catalog;
this.schema = schema;
+ this.oneTransaction = oneTransaction;
+ this.confirmCommit = confirmCommit;
+ this.skipErrors = skipErrors;
+ this.commitRows = commitRows;
}
}
+ /**
+ * Options of CSV files of other tools: the delimiter and the dates are detected
+ */
+ private static CsvOptions detectedCsvOptions() {
+ CsvOptions options = new CsvOptions();
+ options.setDelimiter("");
+ return options;
+ }
+
public ImportParquetDialog(Window owner, AppContext context, DbNode node) {
this.context = context;
Dialogs.initOwner(this, owner);
@@ -117,6 +174,9 @@ public ImportParquetDialog(Window owner, AppContext context, DbNode node) {
tableField.setId("tableField");
createRadio.setId("createRadio");
appendRadio.setId("appendRadio");
+ upsertRadio.setId("upsertRadio");
+ skipExistingRadio.setId("skipExistingRadio");
+ keyColumnsField.setId("keyColumnsField");
targetCombo.setId("targetCombo");
catalogCombo.setId("catalogCombo");
schemaCombo.setId("schemaCombo");
@@ -162,13 +222,26 @@ protected void updateItem(File file, boolean empty) {
previewTable.setPlaceholder(new Label(Messages.get("package.noRows")));
Label filesTitle = new Label(Messages.get("parquet.section.files"));
filesTitle.getStyleClass().add("section-title");
- VBox filesPane = new VBox(8, filesTitle, fileBox, previewLabel, previewTable);
+ Label csvTitle = new Label(Messages.get("csv.options"));
+ csvTitle.getStyleClass().add("muted");
+ csvOptionsBox = new VBox(6, csvTitle, csvOptionsPane.getNode());
+ csvOptionsBox.setId("csvOptionsBox");
+ csvOptionsBox.setVisible(false);
+ csvOptionsBox.setManaged(false);
+ csvOptionsPane.setOnChange(this::loadPreview);
+ VBox filesPane = new VBox(8, filesTitle, fileBox, csvOptionsBox, previewLabel, previewTable);
filesPane.getStyleClass().add("form-section");
ToggleGroup modeGroup = new ToggleGroup();
createRadio.setToggleGroup(modeGroup);
appendRadio.setToggleGroup(modeGroup);
+ upsertRadio.setToggleGroup(modeGroup);
+ skipExistingRadio.setToggleGroup(modeGroup);
createRadio.setSelected(true);
+ keyColumnsField.setPromptText(Messages.get("parquet.keys.prompt"));
+ keyColumnsField.setTooltip(new Tooltip(Messages.get("parquet.keys.tooltip")));
+ keyColumnsField.disableProperty().bind(upsertRadio.selectedProperty().not().and(skipExistingRadio
+ .selectedProperty().not()));
DbTypeCells.setupProfileCombo(targetCombo);
targetCombo.setMaxWidth(Double.MAX_VALUE);
catalogCombo.setMaxWidth(Double.MAX_VALUE);
@@ -196,7 +269,20 @@ protected void updateItem(File file, boolean empty) {
targetGrid.addRow(1, new Label(Messages.get("export.database.catalog")), catalogCombo);
targetGrid.addRow(2, new Label(Messages.get("export.database.schema")), schemaCombo);
targetGrid.addRow(3, new Label(Messages.get("parquet.table")), tableField);
- targetGrid.addRow(4, new Label(Messages.get("parquet.mode")), new HBox(16, createRadio, appendRadio));
+ targetGrid.addRow(4, new Label(Messages.get("parquet.mode")), new VBox(6, createRadio, appendRadio,
+ upsertRadio, skipExistingRadio, keyColumnsField));
+ // ClickHouse can not match rows by key
+ targetCombo.valueProperty().addListener((obs, oldProfile, profile) -> {
+ boolean supported = profile == null || profile.getDbType().createDialect().isUpsertSupported();
+ if (!supported && (upsertRadio.isSelected() || skipExistingRadio.isSelected())) {
+ appendRadio.setSelected(true);
+ }
+ upsertRadio.setDisable(!supported);
+ skipExistingRadio.setDisable(!supported);
+ });
+ transactionOption = new TransactionOption(context, targetCombo.valueProperty(), context.getSettings()
+ .getTransferPageSize());
+ targetGrid.addRow(5, new Label(Messages.get("import.transaction")), transactionOption.getNode());
Label targetTitle = new Label(Messages.get("import.section.target"));
targetTitle.getStyleClass().add("section-title");
VBox targetPane = new VBox(8, targetTitle, targetGrid);
@@ -244,8 +330,12 @@ private static GridPane grid() {
private void chooseFiles() {
FileChooser chooser = new FileChooser();
chooser.setTitle(Messages.get("parquet.files.add"));
- chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(Messages.get("parquet.files.filter"),
- "*" + ParquetExporter.PARQUET_EXTENSION));
+ chooser.getExtensionFilters().addAll(new FileChooser.ExtensionFilter(Messages.get("parquet.files.filter"),
+ "*" + ParquetExporter.PARQUET_EXTENSION, "*" + CsvExporter.CSV_EXTENSION, "*.tsv", "*.txt"),
+ new FileChooser.ExtensionFilter(Messages.get("parquet.files.filter.parquet"),
+ "*" + ParquetExporter.PARQUET_EXTENSION),
+ new FileChooser.ExtensionFilter(Messages.get("parquet.files.filter.csv"),
+ "*" + CsvExporter.CSV_EXTENSION, "*.tsv", "*.txt"));
String lastDirectory = context.getSettings().getLastImportDirectory();
if (StringUtils.isNotBlank(lastDirectory) && new File(lastDirectory).isDirectory()) {
chooser.setInitialDirectory(new File(lastDirectory));
@@ -259,7 +349,8 @@ private void chooseFiles() {
}
/**
- * Adds Parquet files of a table, the table name is the base name of the first file by default
+ * Adds Parquet or CSV files of a table, the table name is the base name of the first file by default. Files
+ * are listed in the order of importing: orders.csv, orders_2.csv ... orders_n.csv
*/
public void addFiles(List files) {
for (File file : files) {
@@ -267,6 +358,7 @@ public void addFiles(List files) {
fileList.getItems().add(file);
}
}
+ fileList.getItems().setAll(CsvSplitter.sortParts(fileList.getItems()));
if (StringUtils.isBlank(tableField.getText()) && !fileList.getItems().isEmpty()) {
tableField.setText(getTableName(fileList.getItems().get(0)));
}
@@ -300,11 +392,24 @@ private void loadPreview() {
validate();
return;
}
+ // Files of one table are all Parquet or all CSV
+ long csvFiles = files.stream().filter(DuckDBWorkspace::isCsv).count();
+ boolean csv = csvFiles > 0 && csvFiles == files.size();
+ csvOptionsBox.setVisible(csv);
+ csvOptionsBox.setManaged(csv);
+ if (csvFiles > 0 && csvFiles < files.size()) {
+ showPreview(null);
+ validate();
+ warningLabel.setText(Messages.get("parquet.error.mixed"));
+ return;
+ }
previewLabel.setText(Messages.get("package.loading"));
previewTable.getColumns().clear();
previewTable.getItems().clear();
validate();
- TaskRunner.run(() -> context.getTransferService().previewParquet(files, PREVIEW_ROWS), preview -> {
+ CsvOptions csvOptions = csv ? csvOptionsPane.getOptions() : null;
+ TaskRunner.run(() -> context.getTransferService().previewParquet(files, PREVIEW_ROWS, csvOptions),
+ preview -> {
if (version == previewVersion) {
filesValid = true;
showPreview(preview);
@@ -401,8 +506,14 @@ private void loadSchemas(ConnectionProfile profile, String catalog) {
ParquetRequest buildRequest() {
return new ParquetRequest(new ArrayList<>(fileList.getItems()), tableField.getText().trim(),
- appendRadio.isSelected() ? ParquetImporter.Mode.APPEND : ParquetImporter.Mode.CREATE,
+ upsertRadio.isSelected() ? ParquetImporter.Mode.UPSERT : skipExistingRadio.isSelected()
+ ? ParquetImporter.Mode.SKIP_EXISTING : appendRadio.isSelected()
+ ? ParquetImporter.Mode.APPEND : ParquetImporter.Mode.CREATE, KeyColumnsDialog.parse(
+ keyColumnsField.getText()), csvOptionsBox.isVisible() ? csvOptionsPane.getOptions() : null,
targetCombo.getValue(), catalogCombo.isDisabled() ? null : catalogCombo.getValue(),
- schemaCombo.isDisabled() ? null : StringUtils.trimToNull(schemaCombo.getEditor().getText()));
+ schemaCombo.isDisabled() ? null : StringUtils.trimToNull(schemaCombo.getEditor().getText()),
+ transactionOption.isOneTransaction(), transactionOption.isConfirmCommit(),
+ transactionOption.isSkipErrors(), transactionOption.isOneTransaction() ? 0
+ : transactionOption.getCommitRows());
}
}
diff --git a/src/main/java/com/github/sqljam/face/view/KeyColumnsDialog.java b/src/main/java/com/github/sqljam/face/view/KeyColumnsDialog.java
new file mode 100644
index 0000000..d4134c7
--- /dev/null
+++ b/src/main/java/com/github/sqljam/face/view/KeyColumnsDialog.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2023-2026 Fred Feng
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.github.sqljam.face.view;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.apache.commons.lang3.StringUtils;
+import com.github.sqljam.face.model.ColumnInfo;
+import com.github.sqljam.face.service.DatabaseSession;
+import javafx.event.ActionEvent;
+import javafx.geometry.Insets;
+import javafx.scene.control.Button;
+import javafx.scene.control.ButtonType;
+import javafx.scene.control.Dialog;
+import javafx.scene.control.Label;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.TextField;
+import javafx.scene.layout.ColumnConstraints;
+import javafx.scene.layout.GridPane;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.VBox;
+import javafx.stage.Window;
+
+/**
+ * @Description: KeyColumnsDialog chooses the columns which identify a row of each table when rows are upserted. An
+ * empty field is the primary key of the table, a table without primary key and chosen columns is
+ * inserted. Chosen columns must be columns of the table.
+ * @Author: Fred Feng
+ * @Date: 26/03/2023
+ * @Version 1.0.0
+ */
+public class KeyColumnsDialog extends Dialog>> {
+
+ private final Map fields = new LinkedHashMap<>();
+ private final Map> columnsByTable = new LinkedHashMap<>();
+ private final Label errorLabel = new Label();
+ private final Button okButton;
+
+ /**
+ * @param chosen key columns chosen before, by table name
+ */
+ public KeyColumnsDialog(Window owner, DatabaseSession session, String catalog, String schema, List tables,
+ Map> chosen) {
+ Dialogs.initOwner(this, owner);
+ Branding.applyIcons(this);
+ setTitle(Messages.get("keys.title"));
+ setHeaderText(Messages.get("keys.header"));
+ setResizable(true);
+ GridPane grid = new GridPane();
+ grid.setId("keyColumnsGrid");
+ grid.setHgap(12);
+ grid.setVgap(8);
+ ColumnConstraints tableColumn = new ColumnConstraints();
+ tableColumn.setMinWidth(160);
+ ColumnConstraints fieldColumn = new ColumnConstraints();
+ fieldColumn.setHgrow(Priority.ALWAYS);
+ grid.getColumnConstraints().addAll(tableColumn, fieldColumn);
+ int row = 0;
+ for (String table : tables) {
+ TextField field = new TextField(String.join(", ", chosen.getOrDefault(table, List.of())));
+ field.setId("keyColumns-" + table);
+ field.setPromptText(Messages.get("keys.loading"));
+ field.textProperty().addListener((obs, oldText, text) -> validate());
+ fields.put(table, field);
+ grid.addRow(row++, new Label(table), field);
+ }
+ ScrollPane scrollPane = new ScrollPane(grid);
+ scrollPane.setFitToWidth(true);
+ scrollPane.setPrefSize(560, Math.min(420, 60 + tables.size() * 40));
+ errorLabel.setId("keyColumnsError");
+ errorLabel.getStyleClass().add("status-error");
+ errorLabel.setWrapText(true);
+ Label hint = new Label(Messages.get("keys.hint"));
+ hint.getStyleClass().add("muted");
+ hint.setWrapText(true);
+ // Chosen columns should be unique in the target table
+ Label uniqueHint = new Label(Messages.get("keys.uniqueHint"), Icons.of(Icons.INFO));
+ uniqueHint.setId("keyColumnsUniqueHint");
+ uniqueHint.getStyleClass().add("muted");
+ uniqueHint.setWrapText(true);
+ VBox content = new VBox(10, hint, uniqueHint, scrollPane, errorLabel);
+ content.setPadding(new Insets(4));
+ getDialogPane().setContent(content);
+ getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
+ okButton = (Button) getDialogPane().lookupButton(ButtonType.OK);
+ okButton.addEventFilter(ActionEvent.ACTION, event -> {
+ if (validate() != null) {
+ event.consume();
+ }
+ });
+ setResultConverter(type -> type == ButtonType.OK ? getKeyColumns() : null);
+ // Primary keys and columns of the tables, the primary key is the placeholder of an empty field
+ TaskRunner.run(() -> {
+ Map> columns = new LinkedHashMap<>();
+ for (String table : tables) {
+ columns.put(table, session.getColumns(catalog, schema, table));
+ }
+ return columns;
+ }, columns -> columns.forEach((table, infos) -> {
+ columnsByTable.put(table, infos.stream().map(ColumnInfo::getName).collect(Collectors.toList()));
+ String primaryKey = infos.stream().filter(ColumnInfo::isPrimaryKey).map(ColumnInfo::getName)
+ .collect(Collectors.joining(", "));
+ fields.get(table).setPromptText(primaryKey.isEmpty() ? Messages.get("keys.none")
+ : Messages.format("keys.primaryKey", primaryKey));
+ validate();
+ }), e -> errorLabel.setText(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())));
+ }
+
+ /**
+ * The first error of the chosen columns, null if all of them are columns of their tables
+ */
+ String validate() {
+ String error = null;
+ for (Map.Entry entry : fields.entrySet()) {
+ List columns = columnsByTable.get(entry.getKey());
+ if (columns == null) {
+ continue;
+ }
+ for (String key : parse(entry.getValue().getText())) {
+ if (columns.stream().noneMatch(key::equalsIgnoreCase)) {
+ error = Messages.format("keys.error.column", key, entry.getKey());
+ break;
+ }
+ }
+ if (error != null) {
+ break;
+ }
+ }
+ errorLabel.setText(error);
+ okButton.setDisable(error != null);
+ return error;
+ }
+
+ /**
+ * Chosen key columns by table, tables of an empty field use their primary keys
+ */
+ Map> getKeyColumns() {
+ Map> keys = new LinkedHashMap<>();
+ fields.forEach((table, field) -> {
+ List columns = parse(field.getText());
+ if (!columns.isEmpty()) {
+ keys.put(table, columns);
+ }
+ });
+ return keys;
+ }
+
+ static List parse(String text) {
+ return new ArrayList<>(Arrays.stream(StringUtils.defaultString(text).split(",")).map(String::trim)
+ .filter(StringUtils::isNotEmpty).collect(Collectors.toList()));
+ }
+}
diff --git a/src/main/java/com/github/sqljam/face/view/MainView.java b/src/main/java/com/github/sqljam/face/view/MainView.java
index 3bfa556..09a486a 100644
--- a/src/main/java/com/github/sqljam/face/view/MainView.java
+++ b/src/main/java/com/github/sqljam/face/view/MainView.java
@@ -17,14 +17,24 @@
import java.io.File;
import java.io.IOException;
+import java.util.List;
import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import com.github.sqljam.config.Config;
import com.github.sqljam.face.model.ConnectionProfile;
+import com.github.sqljam.face.model.TableInfo;
import com.github.sqljam.face.model.TransferRequest;
+import com.github.sqljam.face.service.DatabaseSession;
import com.github.sqljam.face.service.TransferService;
import com.github.sqljam.impexp.AbstractFileImporter;
-import com.github.sqljam.impexp.ParquetImporter;
+import com.github.sqljam.impexp.DataFileImporter;
+import com.github.sqljam.impexp.DataFormat;
+import com.github.sqljam.impexp.ExportManifest;
+import com.github.sqljam.impexp.SqlScriptRunner;
import com.github.sqljam.impexp.TableQuery;
+import com.github.sqljam.jdbc.ManualTransaction;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.geometry.Insets;
@@ -330,25 +340,35 @@ private void showExportDialog(DbNode node, TableQuery query) {
Optional request = dialog.showAndWait();
// Data sources created as the target in the dialog
connectionTree.addNewConnections();
- request.ifPresent(transferRequest -> {
- int totalTables = transferRequest.getTables().isEmpty() ? dialog.getListedTableCount()
- : transferRequest.getTables().size();
- String title = transferRequest.getTarget() == TransferRequest.Target.SCRIPT
- ? Messages.get("progress.exportTitle") : Messages.format("progress.importTitle",
- transferRequest.getTargetProfile().getName());
- ProgressDialog progress = new ProgressDialog(getWindow(), context, title, totalTables);
- if (transferRequest.getTarget() == TransferRequest.Target.SCRIPT) {
- progress.setOutputDirectory(transferRequest.getOutputDirectory());
+ request.ifPresent(transferRequest -> startTransfer(transferRequest, dialog.getListedTableCount()));
+ }
+
+ private void startTransfer(TransferRequest transferRequest, int listedTableCount) {
+ int totalTables = transferRequest.getTables().isEmpty() ? listedTableCount
+ : transferRequest.getTables().size();
+ String title = transferRequest.getTarget() == TransferRequest.Target.SCRIPT
+ ? Messages.get("progress.exportTitle") : Messages.format("progress.importTitle",
+ transferRequest.getTargetProfile().getName());
+ ProgressDialog progress = new ProgressDialog(getWindow(), context, title, totalTables);
+ if (transferRequest.getTarget() == TransferRequest.Target.SCRIPT) {
+ progress.setOutputDirectory(transferRequest.getOutputDirectory());
+ }
+ TransferService transferService = context.getTransferService();
+ progress.setSkipErrors(!transferRequest.isFailFast());
+ // One transaction is committed when the import is complete, or by the user when it is confirmed
+ AtomicReference transaction = new AtomicReference<>();
+ progress.run(() -> {
+ if (transferRequest.getTarget() == TransferRequest.Target.DATABASE
+ && transferRequest.isOneTransaction()) {
+ transaction.set(transferService.beginTransaction(transferRequest.getTargetProfile(),
+ transferRequest.getTargetCatalog(), null));
+ }
+ transferService.transfer(transferRequest, transaction.get(), progress.getListener());
+ return null;
+ }, null, transaction::get, transferRequest.isConfirmCommit(), () -> {
+ if (transferRequest.getTarget() == TransferRequest.Target.DATABASE) {
+ context.getSessionManager().closeSession(transferRequest.getTargetProfile().getId());
}
- TransferService transferService = context.getTransferService();
- progress.run(() -> {
- transferService.transfer(transferRequest, progress.getListener());
- return null;
- }, null, () -> {
- if (transferRequest.getTarget() == TransferRequest.Target.DATABASE) {
- context.getSessionManager().closeSession(transferRequest.getTargetProfile().getId());
- }
- });
});
}
@@ -357,34 +377,54 @@ private void showImportDialog(DbNode node) {
Optional request = new ImportPackageDialog(getWindow(), context, node)
.showAndWait();
connectionTree.addNewConnections();
- request.ifPresent(importRequest -> {
- int totalTables = importRequest.getManifest() != null ? importRequest.getManifest().getTables().size()
- : 0;
- ProgressDialog progress = new ProgressDialog(getWindow(), context, Messages.format(
- "progress.importTitle", importRequest.getTarget().getName()), totalTables);
- progress.run(() -> context.getTransferService().importScripts(importRequest.getTarget(),
- importRequest.getCatalog(), importRequest.getSchema(), importRequest.getDirectory(),
- importRequest.isStopOnError(), progress.getListener()), (AbstractFileImporter importer) ->
- Messages.format("import.summary", importer.getExecutedCount(), importer.getLobCount(),
- importer.getFailedCount()), () -> context.getSessionManager()
- .closeSession(importRequest.getTarget().getId()));
- });
+ request.ifPresent(this::startImport);
+ }
+
+ private void startImport(ImportPackageDialog.ImportRequest importRequest) {
+ int totalTables = importRequest.getManifest() != null ? importRequest.getManifest().getTables().size()
+ : 0;
+ ProgressDialog progress = new ProgressDialog(getWindow(), context, Messages.format(
+ "progress.importTitle", importRequest.getTarget().getName()), totalTables);
+ progress.setSkipErrors(!importRequest.isStopOnError());
+ AtomicReference transaction = new AtomicReference<>();
+ progress.run(() -> {
+ if (importRequest.isOneTransaction()) {
+ transaction.set(context.getTransferService().beginTransaction(importRequest.getTarget(),
+ importRequest.getCatalog(), importRequest.getSchema()));
+ }
+ return context.getTransferService().importScripts(importRequest.getTarget(), importRequest
+ .getCatalog(), importRequest.getSchema(), importRequest.getDirectory(), importRequest
+ .isStopOnError(), importRequest.getCommitRows(), transaction.get(), progress.getListener());
+ }, (AbstractFileImporter importer) -> Messages.format("import.summary", importer.getExecutedCount(),
+ importer.getLobCount(), importer.getFailedCount()), transaction::get, importRequest.isConfirmCommit(),
+ () -> context.getSessionManager().closeSession(importRequest.getTarget().getId()));
}
private void showImportParquetDialog(DbNode node) {
Optional request = new ImportParquetDialog(getWindow(), context, node)
.showAndWait();
connectionTree.addNewConnections();
- request.ifPresent(parquetRequest -> {
- ProgressDialog progress = new ProgressDialog(getWindow(), context, Messages.format(
- "progress.importTitle", parquetRequest.getTarget().getName()), 1);
- progress.run(() -> context.getTransferService().importParquet(parquetRequest.getTarget(),
- parquetRequest.getCatalog(), parquetRequest.getSchema(), parquetRequest.getFiles(),
- parquetRequest.getTableName(), parquetRequest.getMode(), progress.getListener()),
- (ParquetImporter importer) -> Messages.format("parquet.summary", importer.getImportedRows(),
- parquetRequest.getTableName()), () -> context.getSessionManager()
- .closeSession(parquetRequest.getTarget().getId()));
- });
+ request.ifPresent(this::startFileImport);
+ }
+
+ private void startFileImport(ImportParquetDialog.ParquetRequest parquetRequest) {
+ ProgressDialog progress = new ProgressDialog(getWindow(), context, Messages.format(
+ "progress.importTitle", parquetRequest.getTarget().getName()), 1);
+ progress.setSkipErrors(parquetRequest.isSkipErrors());
+ AtomicReference transaction = new AtomicReference<>();
+ progress.run(() -> {
+ if (parquetRequest.isOneTransaction()) {
+ transaction.set(context.getTransferService().beginTransaction(parquetRequest.getTarget(),
+ parquetRequest.getCatalog(), parquetRequest.getSchema()));
+ }
+ return context.getTransferService().importFiles(parquetRequest.getTarget(), parquetRequest
+ .getCatalog(), parquetRequest.getSchema(), parquetRequest.getFiles(), parquetRequest
+ .getTableName(), parquetRequest.getMode(), parquetRequest.getKeyColumns(), parquetRequest
+ .getCsvOptions(), parquetRequest.isSkipErrors(), parquetRequest.getCommitRows(), transaction.get(),
+ progress.getListener());
+ }, (DataFileImporter importer) -> Messages.format("parquet.summary", importer.getImportedRows(),
+ parquetRequest.getTableName()), transaction::get, parquetRequest.isConfirmCommit(),
+ () -> context.getSessionManager().closeSession(parquetRequest.getTarget().getId()));
}
/**
diff --git a/src/main/java/com/github/sqljam/face/view/ProgressDialog.java b/src/main/java/com/github/sqljam/face/view/ProgressDialog.java
index 495b755..a7ca42d 100644
--- a/src/main/java/com/github/sqljam/face/view/ProgressDialog.java
+++ b/src/main/java/com/github/sqljam/face/view/ProgressDialog.java
@@ -16,20 +16,38 @@
package com.github.sqljam.face.view;
import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
+import java.util.function.Supplier;
import org.apache.commons.lang3.StringUtils;
import com.github.sqljam.impexp.ExportCancelledException;
import com.github.sqljam.impexp.ExportListener;
+import com.github.sqljam.jdbc.ManualTransaction;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.collections.transformation.FilteredList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
+import javafx.scene.control.Alert;
import javafx.scene.control.Button;
+import javafx.scene.control.ButtonBar;
+import javafx.scene.control.ButtonType;
+import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
@@ -40,6 +58,7 @@
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
+import javafx.stage.FileChooser;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.Window;
@@ -57,6 +76,11 @@
public class ProgressDialog {
private static final int MAX_LOG_ENTRIES = 5000;
+ /**
+ * Entries kept for Save Log, e.g. every skipped row of a large import
+ */
+ private static final int MAX_SAVED_LOG_ENTRIES = 1_000_000;
+ private static final DateTimeFormatter LOG_TIME = DateTimeFormatter.ofPattern("HH:mm:ss");
private final AppContext context;
private final Stage stage = new Stage();
@@ -68,9 +92,17 @@ public class ProgressDialog {
private final ProgressBar tableBar = new ProgressBar(0);
private final Label elapsedLabel = new Label();
private final ListView logList = new ListView<>();
+ private final ObservableList logEntries = FXCollections.observableArrayList();
+ private final FilteredList shownLogEntries = new FilteredList<>(logEntries);
+ private final List savedLogEntries = new ArrayList<>();
+ private long droppedLogEntries;
+ private final CheckBox errorsOnlyCheck = new CheckBox(Messages.get("progress.errorsOnly"));
+ private final Button saveLogButton = new Button(Messages.get("progress.saveLog"), Icons.of(Icons.SAVE));
private final Button cancelButton = new Button(Messages.get("button.cancel"), Icons.of(Icons.CANCEL));
private final Button openFolderButton = new Button(Messages.get("progress.openFolder"), Icons.of(Icons.OPEN));
private final Button viewPackageButton = new Button(Messages.get("package.view"), Icons.of(Icons.TABLE));
+ private final Button commitButton = new Button(Messages.get("progress.commit"), Icons.of(Icons.START));
+ private final Button rollbackButton = new Button(Messages.get("progress.rollback"), Icons.of(Icons.CANCEL));
private final Label manifestLabel = new Label();
private final AtomicBoolean cancelled = new AtomicBoolean();
private final int totalTables;
@@ -80,9 +112,30 @@ public class ProgressDialog {
private Timeline timer;
private File outputDirectory;
private boolean finished;
+ /**
+ * Transaction of an import waiting for the decision of the user: commit or roll back
+ */
+ private ManualTransaction pendingTransaction;
+ private Runnable pendingCompleted;
+ /**
+ * Reminds the user of an import waiting for commit, its transaction holds locks, undo and log space
+ */
+ private Timeline reminder;
+ private Duration reminderInterval;
+ private long pendingSince;
+ private boolean reminded;
+ /**
+ * The transaction is committed or rolled back without asking the user
+ */
+ private boolean automatic;
+ /**
+ * Errors of rows which were skipped do not roll back the transaction
+ */
+ private boolean skipErrors;
private static class LogEntry {
+ private final LocalTime time = LocalTime.now();
private final String message;
private final boolean error;
@@ -90,6 +143,11 @@ private static class LogEntry {
this.message = message;
this.error = error;
}
+
+ @Override
+ public String toString() {
+ return LOG_TIME.format(time) + (error ? " ERROR " : " INFO ") + message;
+ }
}
public ProgressDialog(Window owner, AppContext context, String title, int totalTables) {
@@ -107,10 +165,14 @@ public ProgressDialog(Window owner, AppContext context, String title, int totalT
tableBar.setId("tableBar");
elapsedLabel.setId("elapsedLabel");
logList.setId("logList");
+ errorsOnlyCheck.setId("errorsOnlyCheck");
+ saveLogButton.setId("saveLogButton");
cancelButton.setId("cancelButton");
openFolderButton.setId("openFolderButton");
viewPackageButton.setId("viewPackageButton");
manifestLabel.setId("manifestLabel");
+ commitButton.setId("commitButton");
+ rollbackButton.setId("rollbackButton");
statusLabel.getStyleClass().add("progress-status");
statusLabel.setText(Messages.get("progress.running"));
@@ -140,6 +202,12 @@ protected void updateItem(LogEntry item, boolean empty) {
}
});
logList.getStyleClass().addAll("log-list", Styles.DENSE);
+ logList.setItems(shownLogEntries);
+ // Errors and skipped rows only
+ errorsOnlyCheck.selectedProperty().addListener((obs, oldValue, errorsOnly) -> shownLogEntries.setPredicate(
+ errorsOnly ? entry -> entry.error : null));
+ saveLogButton.setMinWidth(Region.USE_PREF_SIZE);
+ saveLogButton.setOnAction(event -> saveLog());
VBox.setVgrow(logList, Priority.ALWAYS);
cancelButton.setOnAction(event -> {
@@ -173,9 +241,18 @@ protected void updateItem(LogEntry item, boolean empty) {
openFolderButton.setMinWidth(Region.USE_PREF_SIZE);
viewPackageButton.setMinWidth(Region.USE_PREF_SIZE);
cancelButton.setMinWidth(Region.USE_PREF_SIZE);
+ commitButton.getStyleClass().add(Styles.ACCENT);
+ for (Button button : new Button[]{commitButton, rollbackButton}) {
+ button.setMinWidth(Region.USE_PREF_SIZE);
+ button.setVisible(false);
+ button.setManaged(false);
+ }
+ commitButton.setOnAction(event -> decide(true));
+ rollbackButton.setOnAction(event -> decide(false));
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
- HBox buttons = new HBox(8, spacer, viewPackageButton, openFolderButton, cancelButton);
+ HBox buttons = new HBox(8, errorsOnlyCheck, saveLogButton, spacer, viewPackageButton, openFolderButton,
+ rollbackButton, commitButton, cancelButton);
buttons.setAlignment(Pos.CENTER_RIGHT);
Region headerSpacer = new Region();
@@ -196,7 +273,14 @@ protected void updateItem(LogEntry item, boolean empty) {
}
stage.setScene(scene);
stage.setOnCloseRequest(event -> {
- if (!finished) {
+ if (pendingTransaction != null) {
+ // The window stays until the import is rolled back
+ event.consume();
+ if (Dialogs.confirm(stage, Messages.get("progress.decide.title"),
+ Messages.get("progress.decide.close"))) {
+ decide(false, true);
+ }
+ } else if (!finished) {
cancelled.set(true);
}
});
@@ -210,7 +294,7 @@ Stage getStage() {
* Error entries of the log
*/
long getErrorLogCount() {
- return logList.getItems().stream().filter(entry -> entry.error).count();
+ return logEntries.stream().filter(entry -> entry.error).count();
}
boolean isFinished() {
@@ -312,11 +396,54 @@ private void addLog(String message, boolean error) {
if (StringUtils.isBlank(message)) {
return;
}
- logList.getItems().add(new LogEntry(message, error));
- if (logList.getItems().size() > MAX_LOG_ENTRIES) {
- logList.getItems().remove(0, logList.getItems().size() - MAX_LOG_ENTRIES);
+ LogEntry entry = new LogEntry(message, error);
+ logEntries.add(entry);
+ if (logEntries.size() > MAX_LOG_ENTRIES) {
+ logEntries.remove(0, logEntries.size() - MAX_LOG_ENTRIES);
+ }
+ if (savedLogEntries.size() < MAX_SAVED_LOG_ENTRIES) {
+ savedLogEntries.add(entry);
+ } else {
+ droppedLogEntries++;
+ }
+ if (!shownLogEntries.isEmpty()) {
+ logList.scrollTo(shownLogEntries.size() - 1);
+ }
+ }
+
+ /**
+ * Saves the whole log, the window shows its last entries only
+ */
+ private void saveLog() {
+ FileChooser chooser = new FileChooser();
+ chooser.setTitle(Messages.get("progress.saveLog"));
+ chooser.setInitialFileName("sqljam-" + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(
+ LocalDateTime.now()) + ".log");
+ chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(Messages.get("progress.logFiles"),
+ "*.log", "*.txt"));
+ File file = chooser.showSaveDialog(stage);
+ if (file != null) {
+ try {
+ writeLog(file);
+ addLog(Messages.format("progress.logSaved", file.getAbsolutePath()), false);
+ } catch (IOException e) {
+ Dialogs.showError(stage, Messages.get("progress.saveLog"), e);
+ }
+ }
+ }
+
+ /**
+ * Writes the log as text, one entry a line with its time and level
+ */
+ void writeLog(File file) throws IOException {
+ List lines = new ArrayList<>();
+ lines.add(stage.getTitle());
+ lines.add(Messages.format("progress.statusLine", statusLabel.getText(), errorCount));
+ savedLogEntries.forEach(entry -> lines.add(entry.toString()));
+ if (droppedLogEntries > 0) {
+ lines.add(Messages.format("progress.logDropped", droppedLogEntries));
}
- logList.scrollTo(logList.getItems().size() - 1);
+ Files.write(file.toPath(), lines, StandardCharsets.UTF_8);
}
/**
@@ -325,6 +452,26 @@ private void addLog(String message, boolean error) {
* @param summary summary text of the job result when completed, may be null
*/
public void run(Callable job, Function summary, Runnable onCompleted) {
+ run(job, summary, null, onCompleted);
+ }
+
+ /**
+ * Shows the dialog and runs an import in one transaction which waits for the user to commit or roll back
+ */
+ public void run(Callable job, Function summary, Supplier transaction,
+ Runnable onCompleted) {
+ run(job, summary, transaction, true, onCompleted);
+ }
+
+ /**
+ * Shows the dialog and runs an import in background. An import in one transaction is committed when it
+ * succeeds and rolled back when it fails or has errors, with confirm the user commits or rolls back.
+ *
+ * @param transaction the transaction opened by the job, null or a supplier of null for commits every N rows
+ * @param confirm whether a successful import waits for the user to commit or roll back
+ */
+ public void run(Callable job, Function summary, Supplier transaction,
+ boolean confirm, Runnable onCompleted) {
startTime = System.currentTimeMillis();
timer = new Timeline(new KeyFrame(Duration.seconds(1), event -> updateElapsed()));
timer.setCycleCount(Timeline.INDEFINITE);
@@ -335,14 +482,30 @@ public void run(Callable job, Function summary, Runnable onCom
TaskRunner.execute(() -> {
try {
T result = job.call();
- Platform.runLater(() -> finish(errorCount == 0 ? Messages.get("progress.completed")
- : Messages.format("progress.completedWithErrors", errorCount), false, false,
- summary != null ? summary.apply(result) : null, onCompleted));
+ ManualTransaction pending = transaction != null ? transaction.get() : null;
+ Platform.runLater(() -> {
+ String text = summary != null ? summary.apply(result) : null;
+ if (pending != null && confirm) {
+ awaitDecision(pending, text, onCompleted);
+ } else if (pending != null) {
+ completeTransaction(pending, text, onCompleted);
+ } else {
+ finish(errorCount == 0 ? Messages.get("progress.completed")
+ : Messages.format("progress.completedWithErrors", errorCount), false, false, text,
+ onCompleted);
+ }
+ });
} catch (Throwable e) {
boolean cancel = isCancellation(e);
if (!cancel && log.isErrorEnabled()) {
log.error(e.getMessage(), e);
}
+ // Nothing of a failed or cancelled import is kept
+ ManualTransaction pending = transaction != null ? transaction.get() : null;
+ if (pending != null) {
+ pending.close();
+ Platform.runLater(() -> addLog(Messages.get("progress.rolledBack"), false));
+ }
Platform.runLater(() -> {
if (!cancel) {
errorCount++;
@@ -355,6 +518,215 @@ public void run(Callable job, Function summary, Runnable onCom
});
}
+ /**
+ * The import is written in a transaction: the user is asked to commit or roll back. An import with errors is
+ * rolled back, nothing of it is kept, unless the rows with errors were skipped.
+ */
+ private void awaitDecision(ManualTransaction transaction, String summary, Runnable onCompleted) {
+ if (StringUtils.isNotBlank(summary)) {
+ addLog(summary, false);
+ }
+ pendingTransaction = transaction;
+ pendingCompleted = onCompleted;
+ if (transaction.isRolledBack() || (errorCount > 0 && !skipErrors)) {
+ decide(false, false);
+ return;
+ }
+ timer.stop();
+ updateElapsed();
+ completedTables = Math.max(completedTables, totalTables);
+ updateOverall();
+ updatePercent(1, 1);
+ if (tableBar.getProgress() < 0) {
+ tableBar.setProgress(1);
+ }
+ statusLabel.setText(Messages.get("progress.pending"));
+ statusLabel.getStyleClass().add("status-pending");
+ addLog(Messages.get("progress.pending.message"), false);
+ if (!transaction.isTransactionalDdl()) {
+ addLog(Messages.get("progress.pending.ddl"), false);
+ }
+ if (!transaction.getDeferredStatements().isEmpty()) {
+ addLog(Messages.format("progress.pending.deferred", transaction.getDeferredStatements().size()), false);
+ }
+ cancelButton.setVisible(false);
+ cancelButton.setManaged(false);
+ for (Button button : new Button[]{commitButton, rollbackButton}) {
+ button.setVisible(true);
+ button.setManaged(true);
+ }
+ startReminder();
+ Platform.runLater(this::askDecision);
+ }
+
+ /**
+ * Whether the import skips rows with errors: its transaction is committed (or confirmed) with the errors
+ */
+ public void setSkipErrors(boolean skipErrors) {
+ this.skipErrors = skipErrors;
+ }
+
+ /**
+ * Interval of the reminders, sqljam.import.confirm-commit.reminder-minutes by default
+ */
+ void setReminderInterval(Duration reminderInterval) {
+ this.reminderInterval = reminderInterval;
+ }
+
+ private void startReminder() {
+ pendingSince = System.currentTimeMillis();
+ Duration interval = reminderInterval != null ? reminderInterval
+ : Duration.minutes(context.getSettings().getConfirmCommitReminderMinutes());
+ if (interval.lessThanOrEqualTo(Duration.ZERO)) {
+ return;
+ }
+ reminder = new Timeline(new KeyFrame(interval, event -> remind()));
+ reminder.setCycleCount(Timeline.INDEFINITE);
+ reminder.play();
+ }
+
+ /**
+ * Each reminder is logged, the first one is also shown without blocking the buttons of the window
+ */
+ private void remind() {
+ if (pendingTransaction == null) {
+ return;
+ }
+ long minutes = Math.round((System.currentTimeMillis() - pendingSince) / 60000.0);
+ addLog(Messages.format("progress.reminder.log", minutes), false);
+ statusLabel.setText(Messages.get("progress.pending") + " (" + minutes + " min)");
+ if (!reminded) {
+ reminded = true;
+ Alert alert = new Alert(Alert.AlertType.WARNING, Messages.format("progress.reminder.message", minutes));
+ Dialogs.initOwner(alert, stage);
+ Branding.applyIcons(alert);
+ alert.setTitle(Messages.get("progress.reminder.title"));
+ alert.setHeaderText(Messages.get("progress.reminder.title"));
+ alert.getDialogPane().setId("reminderPane");
+ alert.show();
+ }
+ }
+
+ /**
+ * One transaction without confirm: committed when the import succeeds, rolled back when it has errors which
+ * were not skipped
+ */
+ private void completeTransaction(ManualTransaction transaction, String summary, Runnable onCompleted) {
+ if (StringUtils.isNotBlank(summary)) {
+ addLog(summary, false);
+ }
+ pendingTransaction = transaction;
+ pendingCompleted = onCompleted;
+ automatic = true;
+ decide(!transaction.isRolledBack() && (errorCount == 0 || skipErrors), false);
+ }
+
+ /**
+ * Asks the user to commit or roll back, closing the question keeps both buttons in the window
+ */
+ private void askDecision() {
+ if (pendingTransaction == null || !stage.isShowing()) {
+ return;
+ }
+ ButtonType commit = new ButtonType(Messages.get("progress.commit"), ButtonBar.ButtonData.OK_DONE);
+ ButtonType rollback = new ButtonType(Messages.get("progress.rollback"), ButtonBar.ButtonData.OTHER);
+ Alert alert = new Alert(Alert.AlertType.CONFIRMATION, Messages.format("progress.decide.message",
+ stage.getTitle()), commit, rollback, ButtonType.CANCEL);
+ Dialogs.initOwner(alert, stage);
+ Branding.applyIcons(alert);
+ alert.setTitle(Messages.get("progress.decide.title"));
+ alert.setHeaderText(Messages.get("progress.decide.title"));
+ alert.getDialogPane().setId("decisionPane");
+ ((Button) alert.getDialogPane().lookupButton(ButtonType.CANCEL)).setText(Messages.get("progress.decide.later"));
+ alert.showAndWait().ifPresent(type -> {
+ if (type == commit) {
+ decide(true);
+ } else if (type == rollback) {
+ decide(false);
+ }
+ });
+ }
+
+ private void decide(boolean commit) {
+ decide(commit, false);
+ }
+
+ /**
+ * Commits or rolls back the pending transaction in background, the window is closed afterwards if asked
+ */
+ private void decide(boolean commit, boolean close) {
+ ManualTransaction transaction = pendingTransaction;
+ if (transaction == null || transaction.isFinished()) {
+ return;
+ }
+ commitButton.setDisable(true);
+ rollbackButton.setDisable(true);
+ statusLabel.setText(Messages.get(commit ? "progress.committing" : "progress.rollingBack"));
+ // Skipped rows do not roll back the import, the user may still do it
+ boolean errors = transaction.isRolledBack() || (errorCount > 0 && !skipErrors);
+ int skippedErrors = errorCount;
+ TaskRunner.run(() -> {
+ try {
+ if (commit) {
+ return transaction.commit();
+ }
+ transaction.rollback();
+ return Collections.emptyList();
+ } finally {
+ transaction.close();
+ }
+ }, failures -> {
+ failures.forEach(failure -> addLog(failure, true));
+ errorCount += failures.size();
+ String status;
+ if (!commit) {
+ status = Messages.get(errors ? "progress.rolledBackErrors" : "progress.rolledBack");
+ } else if (!failures.isEmpty()) {
+ status = Messages.format("progress.committedWithErrors", failures.size());
+ } else if (skippedErrors > 0) {
+ // Rows with errors were skipped, the others are committed
+ status = Messages.format(automatic ? "progress.completedWithErrors" : "progress.committedSkipped",
+ skippedErrors);
+ } else {
+ status = Messages.get(automatic ? "progress.completed" : "progress.committed");
+ }
+ completeDecision(status, !commit, close);
+ }, e -> {
+ errorCount++;
+ addLog(StringUtils.defaultIfBlank(rootMessage(e), e.getClass().getName()), true);
+ completeDecision(Messages.get("progress.failed"), true, close);
+ });
+ }
+
+ private void completeDecision(String status, boolean failed, boolean close) {
+ if (reminder != null) {
+ reminder.stop();
+ reminder = null;
+ }
+ Runnable onCompleted = pendingCompleted;
+ pendingTransaction = null;
+ pendingCompleted = null;
+ statusLabel.getStyleClass().remove("status-pending");
+ for (Button button : new Button[]{commitButton, rollbackButton}) {
+ button.setVisible(false);
+ button.setManaged(false);
+ }
+ cancelButton.setVisible(true);
+ cancelButton.setManaged(true);
+ // A roll back by the user is not an error of the import
+ finish(status, failed, failed && errorCount == 0, null, onCompleted);
+ if (close) {
+ stage.close();
+ }
+ }
+
+ /**
+ * Whether the import waits for the user to commit or roll back
+ */
+ boolean isPending() {
+ return pendingTransaction != null;
+ }
+
private boolean isCancellation(Throwable e) {
for (Throwable cause = e; cause != null; cause = cause.getCause()) {
if (cause instanceof ExportCancelledException) {
@@ -379,6 +751,9 @@ private void finish(String status, boolean failed, boolean cancelled, String sum
statusLabel.setText(status);
statusLabel.getStyleClass().add(failed ? "status-error" : "status-success");
if (!failed) {
+ // Scripts of a package are imported file by file, every table is complete at the end
+ completedTables = Math.max(completedTables, totalTables);
+ updateOverall();
updatePercent(1, 1);
} else if (overallBar.getProgress() < 0) {
overallBar.setProgress(0);
diff --git a/src/main/java/com/github/sqljam/face/view/TransactionOption.java b/src/main/java/com/github/sqljam/face/view/TransactionOption.java
new file mode 100644
index 0000000..b0a002d
--- /dev/null
+++ b/src/main/java/com/github/sqljam/face/view/TransactionOption.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2023-2026 Fred Feng
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.github.sqljam.face.view;
+
+import org.apache.commons.lang3.StringUtils;
+import com.github.sqljam.face.model.ConnectionProfile;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.value.ObservableValue;
+import javafx.geometry.Pos;
+import javafx.scene.Node;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.RadioButton;
+import javafx.scene.control.Spinner;
+import javafx.scene.control.ToggleGroup;
+import javafx.scene.control.Tooltip;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.VBox;
+
+/**
+ * @Description: TransactionOption is the transaction of an import into a database, like the import wizards of other
+ * tools: one transaction which is rolled back on an error (the default), or a commit every N rows,
+ * N is typed in the option.
+ * Confirm before commit asks the user to commit or roll back a successful import in one transaction,
+ * commits every N rows are not confirmed. Skip errors logs and skips the rows with errors, otherwise an
+ * error rolls back one transaction or stops the commits every N rows. Databases without transactions
+ * (ClickHouse) commit every row as it is written.
+ * @Author: Fred Feng
+ * @Date: 26/03/2023
+ * @Version 1.0.0
+ */
+final class TransactionOption {
+
+ static final int MAX_COMMIT_ROWS = 10_000_000;
+
+ private final RadioButton oneTransactionRadio = new RadioButton(Messages.get("import.transaction.one"));
+ private final RadioButton commitEveryRadio = new RadioButton(Messages.get("import.transaction.every"));
+ private final Spinner commitRowsSpinner = new Spinner<>(1, MAX_COMMIT_ROWS, 1, 1000);
+ private final CheckBox confirmCheck = new CheckBox(Messages.get("import.transaction.confirm"));
+ private final CheckBox skipErrorsCheck = new CheckBox(Messages.get("import.transaction.skipErrors"));
+ private final Label unsupportedLabel = new Label();
+ private final VBox node;
+ private boolean oneTransactionChosen;
+
+ /**
+ * @param target target data source of the dialog
+ * @param commitRows rows per commit of the import by default, the page or batch size
+ */
+ TransactionOption(AppContext context, ObservableValue target, long commitRows) {
+ oneTransactionRadio.setId("oneTransactionRadio");
+ commitEveryRadio.setId("commitEveryRadio");
+ commitRowsSpinner.setId("commitRowsSpinner");
+ commitRowsSpinner.setEditable(true);
+ commitRowsSpinner.setPrefWidth(110);
+ confirmCheck.setId("confirmCommitCheck");
+ skipErrorsCheck.setId("skipErrorsCheck");
+ unsupportedLabel.setId("transactionUnsupportedLabel");
+ ToggleGroup group = new ToggleGroup();
+ oneTransactionRadio.setToggleGroup(group);
+ commitEveryRadio.setToggleGroup(group);
+ oneTransactionRadio.setTooltip(new Tooltip(Messages.get("import.transaction.one.tooltip")));
+ confirmCheck.setTooltip(new Tooltip(Messages.get("import.transaction.confirm.tooltip")));
+ skipErrorsCheck.setTooltip(new Tooltip(Messages.get("import.transaction.skipErrors.tooltip")));
+ setCommitRows(commitRows);
+ oneTransactionChosen = context.getSettings().isOneTransaction();
+ (oneTransactionChosen ? oneTransactionRadio : commitEveryRadio).setSelected(true);
+ unsupportedLabel.getStyleClass().add("muted");
+ unsupportedLabel.setWrapText(true);
+ group.selectedToggleProperty().addListener((obs, oldToggle, toggle) -> {
+ if (!oneTransactionRadio.isDisabled()) {
+ oneTransactionChosen = toggle == oneTransactionRadio;
+ }
+ update();
+ });
+ target.addListener((obs, oldProfile, profile) -> applyTarget(profile));
+ Label rowsLabel = new Label(Messages.get("import.transaction.rows"));
+ HBox commitEveryBox = new HBox(6, commitEveryRadio, commitRowsSpinner, rowsLabel);
+ commitEveryBox.setAlignment(Pos.CENTER_LEFT);
+ // One choice a line, the labels stay readable in narrow dialogs
+ node = new VBox(6, oneTransactionRadio, commitEveryBox, confirmCheck, skipErrorsCheck, unsupportedLabel);
+ applyTarget(target.getValue());
+ }
+
+ /**
+ * Rows per commit of the option, e.g. after the page size is changed
+ */
+ void setCommitRows(long commitRows) {
+ commitRowsSpinner.getValueFactory().setValue((int) Math.max(1, Math.min(MAX_COMMIT_ROWS, commitRows)));
+ }
+
+ /**
+ * Shows rows per commit typed elsewhere, e.g. the page size, without changing the value yet
+ */
+ void showCommitRows(int commitRows) {
+ commitRowsSpinner.getEditor().setText(String.valueOf(commitRows));
+ }
+
+ /**
+ * Rows per commit, also while they are typed
+ */
+ int getCommitRows() {
+ String text = StringUtils.trim(commitRowsSpinner.getEditor().getText());
+ if (StringUtils.isNumeric(text) && !text.isEmpty() && text.length() < 9) {
+ return Math.max(1, Math.min(MAX_COMMIT_ROWS, Integer.parseInt(text)));
+ }
+ return commitRowsSpinner.getValue();
+ }
+
+ /**
+ * Value of the rows per commit, e.g. bound to the page size of an export
+ */
+ ObjectProperty commitRowsProperty() {
+ return commitRowsSpinner.getValueFactory().valueProperty();
+ }
+
+ private void applyTarget(ConnectionProfile profile) {
+ boolean supported = profile == null || profile.getDbType().isTransactionSupported();
+ oneTransactionRadio.setDisable(!supported);
+ commitEveryRadio.setDisable(!supported);
+ (supported && oneTransactionChosen ? oneTransactionRadio : commitEveryRadio).setSelected(true);
+ unsupportedLabel.setText(supported ? null : Messages.format("import.transaction.unsupported",
+ profile.getDbType().getDisplayName()));
+ unsupportedLabel.setVisible(!supported);
+ unsupportedLabel.setManaged(!supported);
+ update();
+ }
+
+ private void update() {
+ boolean oneTransaction = isOneTransaction();
+ confirmCheck.setDisable(!oneTransaction);
+ commitRowsSpinner.setDisable(oneTransaction || commitEveryRadio.isDisabled());
+ // Commits every N rows are not confirmed
+ if (!oneTransaction) {
+ confirmCheck.setSelected(false);
+ }
+ }
+
+ Node getNode() {
+ return node;
+ }
+
+ /**
+ * Whether the import runs in one transaction, rolled back on an error
+ */
+ boolean isOneTransaction() {
+ return oneTransactionRadio.isSelected() && !oneTransactionRadio.isDisabled();
+ }
+
+ /**
+ * Whether the user commits or rolls back a successful import in one transaction
+ */
+ boolean isConfirmCommit() {
+ return isOneTransaction() && confirmCheck.isSelected();
+ }
+
+ /**
+ * Whether rows with errors are skipped, otherwise an error rolls back one transaction or stops the import
+ */
+ boolean isSkipErrors() {
+ return skipErrorsCheck.isSelected();
+ }
+}
diff --git a/src/main/java/com/github/sqljam/impexp/AbstractFileImporter.java b/src/main/java/com/github/sqljam/impexp/AbstractFileImporter.java
index 69b7434..d5e9474 100644
--- a/src/main/java/com/github/sqljam/impexp/AbstractFileImporter.java
+++ b/src/main/java/com/github/sqljam/impexp/AbstractFileImporter.java
@@ -36,6 +36,10 @@ public abstract class AbstractFileImporter {
protected final Connection connection;
protected final DbType dbType;
protected boolean stopOnError = true;
+ /**
+ * Whether objects of scripts may exist: packages of tables which are not dropped
+ */
+ protected boolean existingSkipped;
protected int batchSize = SqlScriptRunner.DEFAULT_BATCH_SIZE;
protected ExportListener exportListener = ExportListener.NONE;
protected File workspaceDirectory;
@@ -54,7 +58,8 @@ protected AbstractFileImporter(Connection connection, DbType dbType) {
/**
* Importer of an export directory by the data format of its manifest: Parquet packages are imported by
- * {@link ParquetImporter}, sql scripts (with or without manifest) by {@link ScriptImporter}
+ * {@link ParquetImporter}, CSV packages by {@link CsvImporter}, sql scripts (with or without manifest) by
+ * {@link ScriptImporter}
*/
public static AbstractFileImporter forDirectory(File dir, Connection connection, DbType dbType)
throws IOException {
@@ -62,6 +67,9 @@ public static AbstractFileImporter forDirectory(File dir, Connection connection,
if (manifest != null && manifest.getDataFormat() == DataFormat.PARQUET) {
return new ParquetImporter(connection, dbType);
}
+ if (manifest != null && manifest.getDataFormat() == DataFormat.CSV) {
+ return new CsvImporter(connection, dbType);
+ }
return new ScriptImporter(connection, dbType);
}
@@ -118,6 +126,8 @@ public void importDirectory(File dir) throws IOException, SQLException {
try {
validateManifest(dir, manifest);
startProgress(getManifestBytes(dir, manifest));
+ // Tables which are not dropped exist, their tables, indexes and constraints are skipped
+ existingSkipped = Boolean.FALSE.equals(manifest.getOptions().get("tableRecreated"));
importManifest(dir, manifest);
successful = true;
} finally {
@@ -210,6 +220,9 @@ protected void runScript(File file) throws IOException, SQLException {
exportListener.onMessage("Executing script: " + file.getName());
SqlScriptRunner runner = new SqlScriptRunner(connection);
runner.setStopOnError(stopOnError);
+ if (existingSkipped) {
+ runner.setSkippedError(dbType.createDialect()::isAlreadyExistsError);
+ }
runner.setBatchSize(batchSize);
long fileBytes = file.length();
runner.setExportListener(new ForwardingListener(exportListener) {
diff --git a/src/main/java/com/github/sqljam/impexp/CsvExporter.java b/src/main/java/com/github/sqljam/impexp/CsvExporter.java
new file mode 100644
index 0000000..646412a
--- /dev/null
+++ b/src/main/java/com/github/sqljam/impexp/CsvExporter.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2023-2026 Fred Feng
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.github.sqljam.impexp;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * @Description: CsvExporter exports tables as an export package with CSV files: schema.sql and constraints.sql for
+ * the target database (the same as SQL packages), data/<table>.csv with a header row for rows and
+ * manifest.json. CSV files have no types, manifest.json keeps the type of each column, so that
+ * {@link CsvImporter} reads the values back exactly. NULL is an empty field, an empty string is "".
+ * @Author: Fred Feng
+ * @Date: 26/03/2023
+ * @Version 1.0.0
+ */
+public final class CsvExporter extends DataFileExporter {
+
+ public static final String CSV_EXTENSION = ".csv";
+ /**
+ * Delimiters offered by the user interface, tab separated files are opened by spreadsheets as well
+ */
+ public static final String[] DELIMITERS = {",", ";", "\t", "|"};
+
+ private CsvOptions options = new CsvOptions();
+ private long maxFileSize;
+
+ public CsvExporter(File root) {
+ super(root);
+ }
+
+ /**
+ * Maximum size of a CSV file in bytes, 0 for no limit. Larger files are split like data files of sql scripts:
+ * orders.csv, orders_2.csv, orders_3.csv ... each with the header row.
+ */
+ public void setMaxFileSize(long maxFileSize) {
+ this.maxFileSize = Math.max(0, maxFileSize);
+ }
+
+ public long getMaxFileSize() {
+ return maxFileSize;
+ }
+
+ /**
+ * Delimiter of the fields, a comma by default
+ */
+ public void setDelimiter(String delimiter) {
+ options.setDelimiter(StringUtils.defaultIfEmpty(delimiter, DuckDBWorkspace.DEFAULT_DELIMITER));
+ }
+
+ public String getDelimiter() {
+ return options.getDelimiter();
+ }
+
+ /**
+ * Delimiter, quote, text of NULL, header, encoding and date format of the files
+ */
+ public void setOptions(CsvOptions options) {
+ this.options = options != null ? options : new CsvOptions();
+ }
+
+ public CsvOptions getOptions() {
+ return options;
+ }
+
+ @Override
+ protected DataFormat getDataFormat() {
+ return DataFormat.CSV;
+ }
+
+ @Override
+ protected String getExtension() {
+ return CSV_EXTENSION;
+ }
+
+ @Override
+ protected ExportManifest.FileType getFileType() {
+ return ExportManifest.FileType.CSV;
+ }
+
+ @Override
+ protected List writeFile(DuckDBWorkspace workspace, String schema, String table, File file,
+ Map metadata) throws Exception {
+ workspace.writeCsv(schema, table, file, options);
+ // Parts are split as UTF-8 and converted to the encoding afterwards
+ List parts = CsvSplitter.split(file, maxFileSize, options.getQuote().charAt(0), options.isHeader());
+ if (!options.isUtf8()) {
+ for (File part : parts) {
+ CsvSplitter.transcode(part, StandardCharsets.UTF_8, part, options.getCharset(), options.isBom());
+ }
+ }
+ return parts;
+ }
+
+ @Override
+ protected void describeTable(ExportManifest.TableEntry tableEntry, DuckDBWorkspace workspace, String schema,
+ String table) throws Exception {
+ tableEntry.getColumns().clear();
+ for (String[] column : workspace.describeTable(schema, table)) {
+ tableEntry.getColumns().add(new ExportManifest.ColumnEntry(column[0], column[1]));
+ }
+ }
+
+ @Override
+ protected void putOptions(Map options) {
+ options.putAll(this.options.toMap());
+ options.put("maxFileSize", maxFileSize);
+ }
+}
diff --git a/src/main/java/com/github/sqljam/impexp/CsvImporter.java b/src/main/java/com/github/sqljam/impexp/CsvImporter.java
new file mode 100644
index 0000000..3351cfe
--- /dev/null
+++ b/src/main/java/com/github/sqljam/impexp/CsvImporter.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2023-2026 Fred Feng
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.github.sqljam.impexp;
+
+import java.io.File;
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.List;
+
+/**
+ * @Description: CsvImporter imports CSV files with a header row into a database by a connection: packages exported
+ * by {@link CsvExporter}, whose values are read with the column types of manifest.json, or CSV files
+ * of other tools, whose delimiter and column types are detected, into a new table or appended to an
+ * existing table
+ * @Author: Fred Feng
+ * @Date: 26/03/2023
+ * @Version 1.0.0
+ */
+public class CsvImporter extends DataFileImporter {
+
+ /**
+ * Options of the files, null to detect them (files of other tools)
+ */
+ private CsvOptions options;
+
+ public CsvImporter(Connection connection, DbType dbType) {
+ super(connection, dbType);
+ }
+
+ /**
+ * Delimiter of the fields, detected from the files by default
+ */
+ public void setDelimiter(String delimiter) {
+ if (options == null) {
+ options = new CsvOptions();
+ }
+ options.setDelimiter(delimiter);
+ }
+
+ /**
+ * Options of files of other tools, an empty delimiter is detected
+ */
+ public void setOptions(CsvOptions options) {
+ this.options = options;
+ }
+
+ @Override
+ protected DataFormat getDataFormat() {
+ return DataFormat.CSV;
+ }
+
+ @Override
+ protected ExportManifest.FileType getFileType() {
+ return ExportManifest.FileType.CSV;
+ }
+
+ /**
+ * Rows of the files read with their options, e.g. an encoding or no header
+ */
+ @Override
+ protected long countRows(DuckDBWorkspace workspace, List files) throws SQLException, IOException {
+ return workspace.countFileRows(files, options);
+ }
+
+ @Override
+ protected void prepare(ExportManifest manifest) {
+ options = CsvOptions.fromMap(manifest.getOptions());
+ }
+
+ @Override
+ protected void load(DuckDBWorkspace workspace, String schema, String table, List files,
+ List identityColumns, ExportManifest.TableEntry tableEntry) throws SQLException,
+ IOException {
+ workspace.loadCsv(schema, table, files, tableEntry != null ? tableEntry.getColumns() : null, identityColumns,
+ options);
+ }
+}
diff --git a/src/main/java/com/github/sqljam/impexp/CsvOptions.java b/src/main/java/com/github/sqljam/impexp/CsvOptions.java
new file mode 100644
index 0000000..79cc4eb
--- /dev/null
+++ b/src/main/java/com/github/sqljam/impexp/CsvOptions.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2023-2026 Fred Feng
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.github.sqljam.impexp;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.commons.lang3.StringUtils;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @Description: CsvOptions of CSV files like the CSV settings of spreadsheets: delimiter, quote character (doubled
+ * inside quoted fields), text of NULL, header row, encoding (UTF-8 with BOM is opened by Excel) and
+ * formats of dates and timestamps. The defaults (comma, double quote, an empty field for NULL, header,
+ * UTF-8 and ISO dates) keep every value exactly, other formats of timestamps may drop fractions of
+ * seconds. The options of an export are kept in manifest.json and used by its import.
+ * @Author: Fred Feng
+ * @Date: 26/03/2023
+ * @Version 1.0.0
+ */
+@Getter
+@Setter
+public class CsvOptions {
+
+ public static final String UTF_8_BOM = "UTF-8 (BOM)";
+ /**
+ * Encodings offered by the user interface, any encoding of Java can be used
+ */
+ public static final String[] ENCODINGS = {"UTF-8", UTF_8_BOM, "GBK", "GB18030", "UTF-16", "ISO-8859-1"};
+ public static final String[] QUOTES = {"\"", "'"};
+ /**
+ * Date formats offered by the user interface (strftime of DuckDB), empty for ISO dates
+ */
+ public static final String[] DATE_FORMATS = {"", "%Y/%m/%d", "%d.%m.%Y", "%m/%d/%Y", "%d/%m/%Y"};
+
+ private String delimiter = DuckDBWorkspace.DEFAULT_DELIMITER;
+ private String quote = "\"";
+ /**
+ * Text of NULL, an empty field by default (an empty string is quoted)
+ */
+ private String nullString = "";
+ private boolean header = true;
+ private String encoding = "UTF-8";
+ /**
+ * Format of dates, empty for ISO dates (2024-01-31), timestamps use it with the time (%H:%M:%S)
+ */
+ private String dateFormat = "";
+
+ /**
+ * Charset of the encoding, UTF-8 with BOM is UTF-8
+ */
+ public Charset getCharset() {
+ return isBom() ? StandardCharsets.UTF_8 : Charset.forName(StringUtils.defaultIfBlank(encoding, "UTF-8"));
+ }
+
+ public boolean isBom() {
+ return UTF_8_BOM.equals(encoding);
+ }
+
+ /**
+ * Whether DuckDB reads and writes the files directly, other encodings are converted
+ */
+ public boolean isUtf8() {
+ return getCharset().equals(StandardCharsets.UTF_8) && !isBom();
+ }
+
+ /**
+ * Format of timestamps: the date format with the time, empty for ISO timestamps
+ */
+ public String getTimestampFormat() {
+ return StringUtils.isEmpty(dateFormat) ? "" : dateFormat + " %H:%M:%S";
+ }
+
+ /**
+ * Options of manifest.json
+ */
+ public Map toMap() {
+ Map map = new LinkedHashMap<>();
+ map.put("delimiter", delimiter);
+ map.put("quote", quote);
+ map.put("nullString", nullString);
+ map.put("header", header);
+ map.put("encoding", encoding);
+ map.put("dateFormat", dateFormat);
+ return map;
+ }
+
+ /**
+ * Options of an export package, missing options of older packages keep their defaults
+ */
+ public static CsvOptions fromMap(Map map) {
+ CsvOptions options = new CsvOptions();
+ if (map.get("delimiter") != null) {
+ options.setDelimiter(map.get("delimiter").toString());
+ }
+ if (map.get("quote") != null) {
+ options.setQuote(map.get("quote").toString());
+ }
+ if (map.get("nullString") != null) {
+ options.setNullString(map.get("nullString").toString());
+ }
+ if (map.get("header") != null) {
+ options.setHeader(Boolean.parseBoolean(map.get("header").toString()));
+ }
+ if (map.get("encoding") != null) {
+ options.setEncoding(map.get("encoding").toString());
+ }
+ if (map.get("dateFormat") != null) {
+ options.setDateFormat(map.get("dateFormat").toString());
+ }
+ return options;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other instanceof CsvOptions && toMap().equals(((CsvOptions) other).toMap());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(toMap());
+ }
+}
diff --git a/src/main/java/com/github/sqljam/impexp/CsvSplitter.java b/src/main/java/com/github/sqljam/impexp/CsvSplitter.java
new file mode 100644
index 0000000..2079790
--- /dev/null
+++ b/src/main/java/com/github/sqljam/impexp/CsvSplitter.java
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2023-2026 Fred Feng
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.github.sqljam.impexp;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Reader;
+import java.io.Writer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.Charset;
+import java.nio.charset.CodingErrorAction;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.io.FilenameUtils;
+
+/**
+ * @Description: CsvSplitter splits a large CSV file into parts of a maximum size, named like data files of sql
+ * scripts: orders.csv, orders_2.csv, orders_3.csv ... Every part starts with the header row and ends
+ * with a complete record, a line break inside a quoted field does not end a record. Quotes and line
+ * breaks are single bytes in UTF-8, so the file is split as bytes.
+ * @Author: Fred Feng
+ * @Date: 26/03/2023
+ * @Version 1.0.0
+ */
+public final class CsvSplitter {
+
+ /**
+ * Order of importing: the file itself, then its parts by number (orders.csv, orders_2.csv ... orders_10.csv),
+ * files of different names by name
+ */
+ public static final Comparator PART_ORDER = Comparator.comparing((File file) -> baseOf(file))
+ .thenComparingInt(CsvSplitter::partOf).thenComparing(File::getName);
+ private static final Pattern PART = Pattern.compile("^(.*)_(\\d+)$");
+
+ private static final int LINE_FEED = '\n';
+
+ private CsvSplitter() {
+ }
+
+ /**
+ * Splits the file if it is larger than the maximum size, the file keeps the first part
+ *
+ * @param maxFileSize maximum size of a part in bytes, 0 or less keeps the file as it is
+ * @return the file and its further parts
+ */
+ public static List split(File file, long maxFileSize) throws IOException {
+ return split(file, maxFileSize, '"', true);
+ }
+
+ /**
+ * Splits the file if it is larger than the maximum size, the file keeps the first part
+ *
+ * @param quote quote character of the fields, a line break inside quotes does not end a record
+ * @param header whether every part starts with the header row of the file
+ */
+ public static List split(File file, long maxFileSize, char quote, boolean header) throws IOException {
+ List parts = new ArrayList<>();
+ parts.add(file);
+ if (maxFileSize <= 0 || file.length() <= maxFileSize) {
+ return parts;
+ }
+ File source = new File(file.getParentFile(), file.getName() + ".split");
+ Files.move(file.toPath(), source.toPath());
+ String baseName = FilenameUtils.getBaseName(file.getName());
+ String extension = "." + FilenameUtils.getExtension(file.getName());
+ try (InputStream in = new BufferedInputStream(Files.newInputStream(source.toPath()), 1 << 16)) {
+ byte[] headerRow = header ? readRecord(in, quote) : new byte[0];
+ if (headerRow == null) {
+ headerRow = new byte[0];
+ }
+ OutputStream out = open(file, headerRow);
+ long size = headerRow.length;
+ boolean empty = true;
+ try {
+ byte[] record;
+ while ((record = readRecord(in, quote)) != null) {
+ // A part holds at least one record, also when the record is larger than the maximum size
+ if (!empty && size + record.length > maxFileSize) {
+ out.close();
+ File part = new File(file.getParentFile(), baseName + "_" + (parts.size() + 1) + extension);
+ parts.add(part);
+ out = open(part, headerRow);
+ size = headerRow.length;
+ }
+ out.write(record);
+ size += record.length;
+ empty = false;
+ }
+ } finally {
+ out.close();
+ }
+ } finally {
+ Files.deleteIfExists(source.toPath());
+ }
+ return parts;
+ }
+
+ /**
+ * Files in the order of importing
+ */
+ public static List sortParts(List files) {
+ List sorted = new ArrayList<>(files);
+ sorted.sort(PART_ORDER);
+ return sorted;
+ }
+
+ private static String baseOf(File file) {
+ String baseName = FilenameUtils.getBaseName(file.getName());
+ Matcher matcher = PART.matcher(baseName);
+ return matcher.matches() ? matcher.group(1) : baseName;
+ }
+
+ /**
+ * Number of the part, 1 for the file without a number
+ */
+ private static int partOf(File file) {
+ Matcher matcher = PART.matcher(FilenameUtils.getBaseName(file.getName()));
+ if (matcher.matches()) {
+ try {
+ return Integer.parseInt(matcher.group(2));
+ } catch (NumberFormatException e) {
+ return Integer.MAX_VALUE;
+ }
+ }
+ return 1;
+ }
+
+ /**
+ * Converts a text file between encodings, a byte order mark of UTF-8 is removed from the input and written to
+ * the output if asked (Excel recognizes UTF-8 by it). Characters which the encoding can not hold, or bytes which
+ * are not of the source encoding, stop the conversion instead of being replaced.
+ */
+ public static void transcode(File source, Charset from, File target, Charset to, boolean bom) throws IOException {
+ File output = source.equals(target) ? new File(target.getPath() + ".encoding") : target;
+ try (Reader reader = new BufferedReader(new InputStreamReader(Files.newInputStream(source.toPath()),
+ from.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(
+ CodingErrorAction.REPORT)), 1 << 16);
+ Writer writer = new BufferedWriter(new OutputStreamWriter(Files.newOutputStream(output.toPath()),
+ to.newEncoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(
+ CodingErrorAction.REPORT)), 1 << 16)) {
+ if (bom) {
+ writer.write('\uFEFF');
+ }
+ copy(reader, writer);
+ } catch (CharacterCodingException e) {
+ Files.deleteIfExists(output.toPath());
+ throw new ImpExpException(String.format("%s can not be converted from %s to %s, some characters are "
+ + "not of these encodings, please choose UTF-8", source.getName(), from.name(), to.name()), e);
+ }
+ if (output != target) {
+ Files.move(output.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ /**
+ * Copies the text, a byte order mark at the start of the input is dropped
+ */
+ private static void copy(Reader reader, Writer writer) throws IOException {
+ char[] buffer = new char[1 << 15];
+ boolean first = true;
+ int n;
+ while ((n = reader.read(buffer)) != -1) {
+ int offset = first && n > 0 && buffer[0] == '\uFEFF' ? 1 : 0;
+ first = false;
+ writer.write(buffer, offset, n - offset);
+ }
+ }
+
+ private static OutputStream open(File file, byte[] header) throws IOException {
+ OutputStream out = new BufferedOutputStream(Files.newOutputStream(file.toPath()), 1 << 16);
+ out.write(header);
+ return out;
+ }
+
+ /**
+ * Reads a record with its line break, null at the end of the input
+ */
+ private static byte[] readRecord(InputStream in, char quote) throws IOException {
+ ByteArrayOutputStream record = new ByteArrayOutputStream(256);
+ boolean quoted = false;
+ int b;
+ while ((b = in.read()) != -1) {
+ record.write(b);
+ if (b == quote) {
+ // An escaped quote ("") toggles twice
+ quoted = !quoted;
+ } else if (b == LINE_FEED && !quoted) {
+ return record.toByteArray();
+ }
+ }
+ return record.size() > 0 ? record.toByteArray() : null;
+ }
+}
diff --git a/src/main/java/com/github/sqljam/impexp/DataFileExporter.java b/src/main/java/com/github/sqljam/impexp/DataFileExporter.java
new file mode 100644
index 0000000..76c5873
--- /dev/null
+++ b/src/main/java/com/github/sqljam/impexp/DataFileExporter.java
@@ -0,0 +1,438 @@
+/*
+ * Copyright 2023-2026 Fred Feng
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.github.sqljam.impexp;
+
+import java.io.File;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.sql.DatabaseMetaData;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.commons.lang3.StringUtils;
+import com.github.sqljam.impexp.db.DuckDBDialect;
+import com.github.sqljam.impexp.db.DuckDBMetaDataOperations;
+import com.github.sqljam.jdbc.SharedConnectionFactory;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * @Description: DataFileExporter exports tables as an export package with data files: schema.sql and
+ * constraints.sql for the target database (the same as SQL packages), one data file per table in
+ * data/ and manifest.json. Rows are copied into a temporary DuckDB workspace first (values which the
+ * file format can not hold exactly are kept as text), then written by DuckDB in the format of the
+ * subclass: {@link ParquetExporter} or {@link CsvExporter}.
+ * @Author: Fred Feng
+ * @Date: 26/03/2023
+ * @Version 1.0.0
+ */
+@Slf4j
+public abstract class DataFileExporter {
+
+ private final File root;
+ private final Exporter.ExportConfiguration configuration = new Exporter.ExportConfiguration();
+ private ExportListener exportListener = ExportListener.NONE;
+ private DbType targetDbType;
+ private IdentifierCase identifierCase;
+ private String targetCatalogName;
+ private String targetSchemaName;
+ private int targetMajorVersion = -1;
+ private int targetMinorVersion = -1;
+ private final List writtenFiles = new ArrayList<>();
+
+ protected DataFileExporter(File root) {
+ this.root = root;
+ configuration.setShowCreateUserSql(false);
+ configuration.setShowCreateCatalogSql(false);
+ configuration.setShowCreateSchemaSql(false);
+ }
+
+ /**
+ * Source database and options, the same as {@link ScriptExporter#getConfiguration()}
+ */
+ public Exporter.ExportConfiguration getConfiguration() {
+ return configuration;
+ }
+
+ public void setExportListener(ExportListener exportListener) {
+ this.exportListener = exportListener != null ? exportListener : ExportListener.NONE;
+ }
+
+ /**
+ * Database type of schema.sql and constraints.sql. Default is the same as source database.
+ */
+ public void setTargetDbType(DbType targetDbType) {
+ this.targetDbType = targetDbType;
+ }
+
+ public void setIdentifierCase(IdentifierCase identifierCase) {
+ this.identifierCase = identifierCase;
+ }
+
+ public void setTargetCatalogName(String targetCatalogName) {
+ this.targetCatalogName = targetCatalogName;
+ }
+
+ public void setTargetSchemaName(String targetSchemaName) {
+ this.targetSchemaName = targetSchemaName;
+ }
+
+ public void setTargetVersion(int majorVersion, int minorVersion) {
+ this.targetMajorVersion = majorVersion;
+ this.targetMinorVersion = minorVersion;
+ }
+
+ /**
+ * Format of the data files recorded in manifest.json
+ */
+ protected abstract DataFormat getDataFormat();
+
+ /**
+ * Extension of the data files, e.g. .parquet
+ */
+ protected abstract String getExtension();
+
+ protected abstract ExportManifest.FileType getFileType();
+
+ /**
+ * Writes rows of the workspace table into the data file, a format may split large files into parts
+ *
+ * @param metadata key value metadata of the file, formats without metadata ignore it
+ * @return the written files, the file itself and its further parts
+ */
+ protected abstract List writeFile(DuckDBWorkspace workspace, String schema, String table, File file,
+ Map metadata) throws Exception;
+
+ /**
+ * Options of the format in manifest.json, e.g. the compression of Parquet files
+ */
+ protected abstract void putOptions(Map options);
+
+ /**
+ * Describes the written table in its manifest entry, e.g. column types of CSV files
+ */
+ protected void describeTable(ExportManifest.TableEntry tableEntry, DuckDBWorkspace workspace, String schema,
+ String table) throws Exception {
+ }
+
+ public File getRoot() {
+ return root;
+ }
+
+ public List getWrittenFiles() {
+ return new ArrayList<>(writtenFiles);
+ }
+
+ public void exportDdlAndData() throws Exception {
+ export(ExportMode.DDL_DATA);
+ }
+
+ public void exportDdl() throws Exception {
+ export(ExportMode.DDL);
+ }
+
+ public void exportData() throws Exception {
+ export(ExportMode.DATA);
+ }
+
+ public void export(ExportMode exportMode) throws Exception {
+ writtenFiles.clear();
+ if (configuration.getDbType() == null) {
+ configuration.setDbType(DbType.forUrl(configuration.getUrl()));
+ }
+ boolean successful = false;
+ exportListener.onStart(exportMode);
+ PhaseListener phaseListener = new PhaseListener(exportListener);
+ try {
+ ExportManifest manifest;
+ if (exportMode != ExportMode.DATA) {
+ exportSchema(phaseListener);
+ manifest = ExportManifest.read(root);
+ } else {
+ manifest = null;
+ }
+ if (manifest == null) {
+ manifest = new ExportManifest();
+ manifest.getSource().setDbType(configuration.getDbType());
+ manifest.getTarget().setDbType(targetDbType != null ? targetDbType : configuration.getDbType());
+ }
+ List dataFiles = new ArrayList<>();
+ if (exportMode != ExportMode.DDL) {
+ exportRows(phaseListener, manifest, dataFiles);
+ }
+ writeManifest(manifest, exportMode, dataFiles);
+ successful = !phaseListener.isFailed();
+ } catch (ExportCancelledException e) {
+ throw e;
+ } catch (Exception e) {
+ exportListener.onError(e.getMessage(), e);
+ throw e;
+ } finally {
+ exportListener.onEnd(successful);
+ }
+ }
+
+ /**
+ * schema.sql and constraints.sql by the script exporter
+ */
+ private void exportSchema(PhaseListener listener) throws Exception {
+ exportListener.onMessage("Export schema");
+ ScriptExporter scriptExporter = new ScriptExporter(root, DataFileStrategy.SINGLE_FILE, 0);
+ copyConfiguration(configuration, scriptExporter.getConfiguration());
+ scriptExporter.setTargetDbType(targetDbType);
+ scriptExporter.setIdentifierCase(identifierCase);
+ scriptExporter.setTargetCatalogName(targetCatalogName);
+ scriptExporter.setTargetSchemaName(targetSchemaName);
+ if (targetMajorVersion >= 0) {
+ scriptExporter.setTargetVersion(targetMajorVersion, targetMinorVersion);
+ }
+ scriptExporter.setConstraintsSeparated(true);
+ scriptExporter.setExportListener(listener.forPhase(false));
+ scriptExporter.exportDdl();
+ writtenFiles.addAll(scriptExporter.getWrittenFiles());
+ }
+
+ /**
+ * Rows are copied into the workspace and written as data files, one file per table
+ */
+ private void exportRows(PhaseListener listener, ExportManifest manifest,
+ List dataFiles) throws Exception {
+ try (DuckDBWorkspace workspace = new DuckDBWorkspace(root)) {
+ exportListener.onMessage("Copy rows into workspace: " + workspace.getFile());
+ ImportExporter importExporter = new ImportExporter();
+ copyConfiguration(configuration, importExporter.getExportConfiguration());
+ Exporter.ExportConfiguration source = importExporter.getExportConfiguration();
+ source.setIdReused(true);
+ source.setTableRecreated(true);
+ source.setIndexIncluded(false);
+ source.setForeignKeyIncluded(false);
+ source.setCommentIncluded(false);
+ // Defaults of the same database type may use sequences
+ source.setSequenceIncluded(true);
+ ImportExportHandler.ImportConfiguration target = importExporter.getImportConfiguration();
+ target.setDbType(DbType.DUCKDB);
+ target.setUrl(workspace.getUrl());
+ target.setConnectionPoolEnabled(false);
+ DuckDBDialect dialect = new DuckDBDialect();
+ dialect.setExactValues(true);
+ dialect.setExactTargetDbType(targetDbType != null ? targetDbType : configuration.getDbType());
+ // Table names of the source are kept
+ dialect.setIdentifierCase(IdentifierCase.KEEP);
+ importExporter.setTargetDialect(dialect);
+ importExporter.setTargetConnectionFactory(new SharedConnectionFactory(workspace.getConnection()));
+ importExporter.setExportListener(listener.forPhase(true));
+ importExporter.exportDdlAndData();
+ if (listener.isFailed()) {
+ return;
+ }
+
+ List tables = workspace.getTables();
+ boolean schemaQualified = tables.stream().map(t -> t[0]).distinct().count() > 1;
+ DuckDBMetaDataOperations operations = new DuckDBMetaDataOperations();
+ DatabaseMetaData metaData = workspace.getConnection().getMetaData();
+ for (String[] table : tables) {
+ if (exportListener.isCancelled()) {
+ throw new ExportCancelledException();
+ }
+ String schema = table[0];
+ String tableName = table[1];
+ String fileName = (schemaQualified ? LobManifestWriter.toFileName(schema) + "." : "")
+ + LobManifestWriter.toFileName(tableName) + getExtension();
+ File file = new File(new File(root, ScriptExportHandler.DATA_DIR_NAME), fileName);
+ Map fileMetadata = new LinkedHashMap<>();
+ fileMetadata.put("sqljam_table", tableName);
+ if (sourceSchema(schema) != null) {
+ fileMetadata.put("sqljam_schema", sourceSchema(schema));
+ }
+ fileMetadata.put("sqljam_source", String.valueOf(configuration.getDbType()));
+ List parts = writeFile(workspace, schema, tableName, file, fileMetadata);
+ writtenFiles.addAll(parts);
+ long rows = workspace.countRows(schema, tableName);
+ exportListener.onMessage(String.format("Write %d rows of %s to %s", rows, tableName,
+ parts.size() > 1 ? parts.size() + " files: " + file : file));
+
+ ExportManifest.TableEntry tableEntry = findTableEntry(manifest, sourceSchema(schema), tableName)
+ .orElseGet(() -> {
+ ExportManifest.TableEntry entry = new ExportManifest.TableEntry();
+ entry.setSchema(sourceSchema(schema));
+ entry.setName(tableName);
+ manifest.getTables().add(entry);
+ return entry;
+ });
+ tableEntry.setRows(rows);
+ tableEntry.getDataFiles().clear();
+ for (File part : parts) {
+ String path = ScriptExportHandler.DATA_DIR_NAME + "/" + part.getName();
+ tableEntry.getDataFiles().add(path);
+ dataFiles.add(new ExportManifest.FileEntry(path, getFileType()));
+ }
+ describeTable(tableEntry, workspace, schema, tableName);
+ // Chosen key columns of upserts, the primary key of the table otherwise
+ tableEntry.getKeyColumns().clear();
+ configuration.getKeyColumnNames().entrySet().stream().filter(entry -> entry.getKey()
+ .equalsIgnoreCase(tableName)).findFirst().ifPresent(entry -> tableEntry.getKeyColumns()
+ .addAll(entry.getValue()));
+ tableEntry.getIdentityColumns().clear();
+ for (Map column : operations.getColumnInfos(metaData, null, schema, tableName)) {
+ if ("YES".equalsIgnoreCase((String) column.get("IS_AUTOINCREMENT"))) {
+ tableEntry.getIdentityColumns().add((String) column.get("COLUMN_NAME"));
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Schema of the source table, tables of sources without schemas are in the default schema of the workspace
+ */
+ private String sourceSchema(String workspaceSchema) {
+ return configuration.getDbType() != null && !configuration.getDbType().isSchemaSupported()
+ && DuckDBWorkspace.DEFAULT_SCHEMA.equals(workspaceSchema) ? null : workspaceSchema;
+ }
+
+ private static Optional findTableEntry(ExportManifest manifest, String schema,
+ String table) {
+ return manifest.getTables().stream()
+ .filter(entry -> table.equals(entry.getName())
+ && (schema == null || entry.getSchema() == null || schema.equals(entry.getSchema())))
+ .findFirst();
+ }
+
+ /**
+ * Files in the order of importing: schema.sql, data files, constraints.sql
+ */
+ private void writeManifest(ExportManifest manifest, ExportMode exportMode,
+ List dataFiles) throws Exception {
+ manifest.setDataFormat(getDataFormat());
+ manifest.setExportMode(exportMode);
+ manifest.setStatus(ExportManifest.Status.COMPLETED);
+ putOptions(manifest.getOptions());
+ List files = new ArrayList<>();
+ for (ExportManifest.FileEntry entry : manifest.getFiles()) {
+ if (entry.getType() == ExportManifest.FileType.SCHEMA) {
+ files.add(entry);
+ }
+ }
+ files.addAll(dataFiles);
+ for (ExportManifest.FileEntry entry : manifest.getFiles()) {
+ if (entry.getType() == ExportManifest.FileType.CONSTRAINTS) {
+ files.add(entry);
+ }
+ }
+ manifest.setFiles(files);
+ manifest.write(root);
+ File manifestFile = new File(root, ExportManifest.FILE_NAME);
+ if (!writtenFiles.contains(manifestFile)) {
+ writtenFiles.add(manifestFile);
+ }
+ exportListener.onMessage("Write manifest: " + manifestFile);
+ }
+
+ /**
+ * Copies source settings and options, dialects are created by each exporter
+ */
+ static void copyConfiguration(Exporter.ExportConfiguration from, Exporter.ExportConfiguration to) {
+ for (Field field : Exporter.ExportConfiguration.class.getDeclaredFields()) {
+ if (Modifier.isStatic(field.getModifiers()) || "dialect".equals(field.getName())
+ || "sourceDialect".equals(field.getName())) {
+ continue;
+ }
+ try {
+ field.setAccessible(true);
+ field.set(to, field.get(from));
+ } catch (IllegalAccessException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+ }
+
+ /**
+ * Forwards events of the schema phase and the rows phase as one export: messages and errors of both phases,
+ * progress of the rows phase only, start and end are reported by the data file exporter
+ */
+ static class PhaseListener {
+
+ private final ExportListener delegate;
+ private boolean failed;
+
+ PhaseListener(ExportListener delegate) {
+ this.delegate = delegate;
+ }
+
+ boolean isFailed() {
+ return failed;
+ }
+
+ ExportListener forPhase(boolean progress) {
+ return new ExportListener() {
+
+ @Override
+ public void onTableStart(String catalog, String schema, String table, long totalRows) {
+ if (progress) {
+ delegate.onTableStart(catalog, schema, table, totalRows);
+ }
+ }
+
+ @Override
+ public void onTableProgress(String catalog, String schema, String table, long rows,
+ long totalRows) {
+ if (progress) {
+ delegate.onTableProgress(catalog, schema, table, rows, totalRows);
+ }
+ }
+
+ @Override
+ public void onTableEnd(String catalog, String schema, String table, long rows) {
+ if (progress) {
+ delegate.onTableEnd(catalog, schema, table, rows);
+ }
+ }
+
+ @Override
+ public void onProgress(long processed, long total) {
+ if (progress) {
+ delegate.onProgress(processed, total);
+ }
+ }
+
+ @Override
+ public void onMessage(String message) {
+ delegate.onMessage(message);
+ }
+
+ @Override
+ public void onError(String message, Throwable e) {
+ failed = true;
+ delegate.onError(message, e);
+ }
+
+ @Override
+ public void onEnd(boolean successful) {
+ if (!successful) {
+ failed = true;
+ }
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return delegate.isCancelled();
+ }
+ };
+ }
+ }
+}
diff --git a/src/main/java/com/github/sqljam/impexp/DataFileImporter.java b/src/main/java/com/github/sqljam/impexp/DataFileImporter.java
new file mode 100644
index 0000000..f2d054b
--- /dev/null
+++ b/src/main/java/com/github/sqljam/impexp/DataFileImporter.java
@@ -0,0 +1,515 @@
+/*
+ * Copyright 2023-2026 Fred Feng
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.github.sqljam.impexp;
+
+import java.io.File;
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.Validate;
+import com.github.sqljam.jdbc.SharedConnectionFactory;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * @Description: DataFileImporter imports data files into a database by a connection. Packages exported by
+ * {@link DataFileExporter} are imported in the order of manifest.json: schema.sql, data files into
+ * the created tables (identity values are kept and identities are reset), constraints.sql. Files of
+ * other tools are imported into a new table created from the columns of the files, or appended to an
+ * existing table. Files are read into a temporary DuckDB workspace, then rows are copied by
+ * {@link ImportExporter} with the dialect of the target database. The format is read by the
+ * subclass: {@link ParquetImporter} or {@link CsvImporter}.
+ * @Author: Fred Feng
+ * @Date: 26/03/2023
+ * @Version 1.0.0
+ */
+@Slf4j
+public abstract class DataFileImporter extends AbstractFileImporter {
+
+ /**
+ * CREATE drops and creates the table from the columns of the files, APPEND inserts rows into the existing table,
+ * UPSERT updates the rows of the same key in the existing table and inserts the others, SKIP_EXISTING inserts
+ * the rows whose key is missing and keeps the existing rows
+ */
+ public enum Mode {
+ CREATE,
+ APPEND,
+ UPSERT,
+ SKIP_EXISTING
+ }
+
+ private String targetCatalogName;
+ private String targetSchemaName;
+ private IdentifierCase identifierCase;
+ private int targetMajorVersion = -1;
+ private int targetMinorVersion = -1;
+ private int pageSize = Exporter.DEFAULT_PAGE_SIZE;
+ private long importedRows;
+ /**
+ * Key columns of upserts of files, the primary key of the target table by default
+ */
+ private List keyColumns = Collections.emptyList();
+
+ protected DataFileImporter(Connection connection, DbType dbType) {
+ super(connection, dbType);
+ }
+
+ /**
+ * Format of the packages imported by this importer
+ */
+ protected abstract DataFormat getDataFormat();
+
+ protected abstract ExportManifest.FileType getFileType();
+
+ /**
+ * Loads the files into a table of the workspace
+ *
+ * @param tableEntry the table of the package, null for files of other tools
+ */
+ protected abstract void load(DuckDBWorkspace workspace, String schema, String table, List files,
+ List identityColumns, ExportManifest.TableEntry tableEntry)
+ throws SQLException, IOException;
+
+ /**
+ * Rows of files of other tools, e.g. for the progress
+ */
+ protected long countRows(DuckDBWorkspace workspace, List files) throws SQLException, IOException {
+ return workspace.countFileRows(files);
+ }
+
+ /**
+ * Reads options of the package before its files are imported, e.g. the delimiter of CSV files
+ */
+ protected void prepare(ExportManifest manifest) {
+ }
+
+ /**
+ * Catalog of the table, the catalog of the connection by default
+ */
+ public void setTargetCatalogName(String targetCatalogName) {
+ this.targetCatalogName = targetCatalogName;
+ }
+
+ /**
+ * Schema of the table, the schema of the connection by default
+ */
+ public void setTargetSchemaName(String targetSchemaName) {
+ this.targetSchemaName = targetSchemaName;
+ }
+
+ public void setIdentifierCase(IdentifierCase identifierCase) {
+ this.identifierCase = identifierCase;
+ }
+
+ public void setTargetVersion(int majorVersion, int minorVersion) {
+ this.targetMajorVersion = majorVersion;
+ this.targetMinorVersion = minorVersion;
+ }
+
+ public void setPageSize(int pageSize) {
+ this.pageSize = pageSize;
+ }
+
+ /**
+ * Key columns of upserts of files, empty for the primary key of the target table
+ */
+ public void setKeyColumns(List keyColumns) {
+ this.keyColumns = keyColumns != null ? keyColumns : Collections.emptyList();
+ }
+
+ /**
+ * Rows imported from data files
+ */
+ public long getImportedRows() {
+ return importedRows;
+ }
+
+ /**
+ * Imports data files of other tools into a table
+ *
+ * @return number of imported rows
+ */
+ public long importFiles(List files, String tableName, Mode mode) throws Exception {
+ Validate.notEmpty(files, "Data files must be required");
+ files = CsvSplitter.sortParts(files);
+ Validate.notBlank(tableName, "Table name must be required");
+ importedRows = 0;
+ boolean successful = false;
+ exportListener.onStart(mode == Mode.CREATE ? ExportMode.DDL_DATA : ExportMode.DATA);
+ try (DuckDBWorkspace workspace = new DuckDBWorkspace(workspaceDirectory)) {
+ long total = countRows(workspace, files);
+ exportListener.onMessage(String.format("Import %d rows of %d %s files into %s", total, files.size(),
+ getDataFormat(), tableName));
+ importTable(workspace, DuckDBWorkspace.DEFAULT_SCHEMA, tableName, files, Collections.emptyList(), null,
+ mode, null, null, new ProgressListener(exportListener, 0, total));
+ executedCount = (int) Math.min(Integer.MAX_VALUE, importedRows);
+ successful = true;
+ } finally {
+ exportListener.onEnd(successful);
+ }
+ return importedRows;
+ }
+
+ /**
+ * schema.sql, data files (all together, in the order of foreign keys) and constraints.sql
+ */
+ @Override
+ protected void importManifest(File dir, ExportManifest manifest) throws IOException, SQLException {
+ if (manifest.getDataFormat() != getDataFormat()) {
+ throw new ImpExpException(String.format("Packages of %s files are not imported by %s",
+ manifest.getDataFormat(), getClass().getSimpleName()));
+ }
+ prepare(manifest);
+ importedRows = 0;
+ boolean dataImported = false;
+ for (ExportManifest.FileEntry fileEntry : manifest.getFiles()) {
+ File file = new File(dir, fileEntry.getPath());
+ if (fileEntry.getType() == getFileType()) {
+ if (!dataImported) {
+ importPackageFiles(dir, manifest);
+ dataImported = true;
+ }
+ } else {
+ runScript(file);
+ }
+ }
+ }
+
+ private void importPackageFiles(File dir, ExportManifest manifest) throws SQLException {
+ long dataBytes = manifest.getFiles().stream()
+ .filter(entry -> entry.getType() == getFileType())
+ .mapToLong(entry -> new File(dir, entry.getPath()).length()).sum();
+ long startBytes = completedBytes;
+ ExportListener bytesListener = new ForwardingListener(exportListener) {
+
+ // Tables of the package are completed one by one in the progress of the import
+ @Override
+ public void onTableStart(String catalogName, String schemaName, String tableName, long totalRows) {
+ exportListener.onTableStart(catalogName, schemaName, tableName, totalRows);
+ }
+
+ @Override
+ public void onTableProgress(String catalogName, String schemaName, String tableName, long processedRows,
+ long totalRows) {
+ exportListener.onTableProgress(catalogName, schemaName, tableName, processedRows, totalRows);
+ }
+
+ @Override
+ public void onTableEnd(String catalogName, String schemaName, String tableName, long processedRows) {
+ exportListener.onTableEnd(catalogName, schemaName, tableName, processedRows);
+ }
+
+ @Override
+ public void onProgress(long processed, long total) {
+ // Rows are converted to bytes of data files
+ long bytes = total > 0 ? dataBytes * processed / total : 0;
+ exportListener.onProgress(Math.min(startBytes + bytes, totalBytes), totalBytes);
+ }
+
+ @Override
+ public void onError(String message, Throwable e) {
+ failedCount++;
+ exportListener.onError(message, e);
+ }
+ };
+ List tables = manifest.getTables().stream()
+ .filter(table -> !table.getDataFiles().isEmpty()).collect(Collectors.toList());
+ IdentifierCase manifestCase = getIdentifierCase(manifest);
+ if (identifierCase == null) {
+ identifierCase = manifestCase;
+ }
+ try {
+ if (createDialect().isForeignKeyOrderRequired()) {
+ tables = sortByForeignKeys(tables, manifest);
+ }
+ long total = tables.stream().mapToLong(ExportManifest.TableEntry::getRows).sum();
+ long completed = 0;
+ try (DuckDBWorkspace workspace = new DuckDBWorkspace(workspaceDirectory)) {
+ for (ExportManifest.TableEntry table : tables) {
+ if (exportListener.isCancelled()) {
+ throw new ExportCancelledException();
+ }
+ // Parts in order: orders.csv, orders_2.csv ... orders_n.csv
+ List files = CsvSplitter.sortParts(table.getDataFiles().stream()
+ .map(path -> new File(dir, path)).collect(Collectors.toList()));
+ String schema = StringUtils.defaultIfBlank(table.getSchema(), DuckDBWorkspace.DEFAULT_SCHEMA);
+ exportListener.onMessage(String.format("Import %d rows of %s", table.getRows(), table.getName()));
+ // Packages of upserts update the rows of the same key, others keep them
+ WriteMode writeMode = WriteMode.of(manifest.getOptions().get("writeMode"));
+ Mode mode = writeMode.isKeyed() ? Mode.valueOf(writeMode.name()) : Mode.APPEND;
+ importTable(workspace, schema, table.getName(), files, table.getIdentityColumns(), table,
+ mode, manifest.getTarget().getCatalog(), manifest.getTarget().getSchema(),
+ new ProgressListener(bytesListener, completed, total));
+ completed += table.getRows();
+ }
+ }
+ } catch (ImpExpException | SQLException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ImpExpException(e.getMessage(), e);
+ }
+ executedCount += (int) Math.min(Integer.MAX_VALUE, importedRows);
+ completedBytes = startBytes;
+ completeBytes(dataBytes);
+ }
+
+ private void importTable(DuckDBWorkspace workspace, String schema, String tableName, List files,
+ List identityColumns, ExportManifest.TableEntry tableEntry, Mode mode,
+ String packageCatalog, String packageSchema, ProgressListener listener) throws Exception {
+ load(workspace, schema, tableName, files, identityColumns, tableEntry);
+ String catalog = StringUtils.defaultIfBlank(targetCatalogName, packageCatalog);
+ String targetSchema = StringUtils.defaultIfBlank(targetSchemaName, packageSchema);
+ if (mode != Mode.CREATE) {
+ // Values of generated and row version columns are maintained by the target database
+ List generated = getGeneratedColumns(tableName, catalog, targetSchema);
+ List dropped = new ArrayList<>();
+ for (String[] column : workspace.describeTable(schema, tableName)) {
+ if (generated.stream().anyMatch(name -> name.equalsIgnoreCase(column[0]))) {
+ dropped.add(column[0]);
+ }
+ }
+ workspace.dropColumns(schema, tableName, dropped);
+ }
+ ImportExporter importExporter = new ImportExporter();
+ Exporter.ExportConfiguration source = importExporter.getExportConfiguration();
+ source.setDbType(DbType.DUCKDB);
+ source.setUrl(workspace.getUrl());
+ source.setConnectionPoolEnabled(false);
+ source.setIncludedSchemaNames(new String[]{schema});
+ source.setIncludedTableNames(new String[]{tableName});
+ source.setPageSize(pageSize);
+ source.setIdReused(true);
+ source.setTableRecreated(mode == Mode.CREATE);
+ source.setIndexIncluded(false);
+ source.setForeignKeyIncluded(false);
+ source.setCommentIncluded(false);
+ source.setSequenceIncluded(false);
+ // Rows with errors are skipped when the import goes on after errors
+ source.setFailFast(stopOnError);
+ if (mode == Mode.UPSERT || mode == Mode.SKIP_EXISTING) {
+ source.setWriteMode(WriteMode.valueOf(mode.name()));
+ List keys = tableEntry != null ? tableEntry.getKeyColumns() : keyColumns;
+ if (!keys.isEmpty()) {
+ source.getKeyColumnNames().put(tableName, keys);
+ }
+ }
+ ImportExportHandler.ImportConfiguration target = importExporter.getImportConfiguration();
+ target.setDbType(dbType);
+ target.setTargetCatalogName(catalog);
+ target.setTargetSchemaName(targetSchema);
+ importExporter.setTargetConnectionFactory(new SharedConnectionFactory(connection));
+ if (identifierCase != null) {
+ importExporter.setIdentifierCase(identifierCase);
+ }
+ if (targetMajorVersion >= 0) {
+ importExporter.setTargetVersion(targetMajorVersion, targetMinorVersion);
+ }
+ // Tables go into the target schema or the default schema, not into a schema named as the workspace one
+ importExporter.setSourceSchemaPreserved(false);
+ importExporter.setExportListener(listener);
+ // The workspace is read by connections of the importer
+ workspace.closeConnection();
+ importExporter.export(mode == Mode.CREATE ? ExportMode.DDL_DATA : ExportMode.DATA);
+ // Errors which were skipped are reported already, the import goes on
+ if (listener.getError() != null && stopOnError) {
+ throw new ImpExpException(listener.getError(), listener.getCause());
+ }
+ importedRows += listener.getRows();
+ }
+
+ /**
+ * Dialect folding names as the importer does: the workspace (DuckDB) is the source
+ */
+ private Dialect createDialect() {
+ Dialect dialect = dbType.createDialect();
+ dialect.setSourceDbType(DbType.DUCKDB);
+ if (identifierCase != null) {
+ dialect.setIdentifierCase(identifierCase);
+ }
+ return dialect;
+ }
+
+ private static IdentifierCase getIdentifierCase(ExportManifest manifest) {
+ Object option = manifest.getOptions().get("identifierCase");
+ if (option == null) {
+ return null;
+ }
+ try {
+ return IdentifierCase.valueOf(option.toString());
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Generated (computed) and row version columns of the existing target table
+ */
+ private List getGeneratedColumns(String tableName, String catalog, String schema) throws SQLException {
+ List columns = new ArrayList<>();
+ MetaDataOperations operations = dbType.createMetaDataOperations();
+ String tableSchema = StringUtils.defaultIfBlank(schema, connection.getSchema());
+ String tableCatalog = dbType.isCatalogSupported() && dbType != DbType.ORACLE
+ ? StringUtils.defaultIfBlank(catalog, connection.getCatalog()) : null;
+ List> columnInfos;
+ try {
+ columnInfos = operations.getColumnInfos(connection.getMetaData(), tableCatalog, tableSchema,
+ createDialect().foldIdentifier(tableName));
+ } catch (ImpExpException e) {
+ // The table does not exist, the importer reports it
+ columnInfos = Collections.emptyList();
+ }
+ for (Map column : columnInfos) {
+ if ("YES".equalsIgnoreCase((String) column.get("IS_GENERATEDCOLUMN"))
+ || Boolean.TRUE.equals(column.get("IS_ROWVERSION"))) {
+ columns.add((String) column.get("COLUMN_NAME"));
+ }
+ }
+ return columns;
+ }
+
+ /**
+ * Referenced tables are imported first, foreign keys are read from the tables created by schema.sql
+ */
+ private List sortByForeignKeys(List tables,
+ ExportManifest manifest) throws SQLException {
+ Dialect dialect = createDialect();
+ Map byName = new LinkedHashMap<>();
+ for (ExportManifest.TableEntry table : tables) {
+ byName.put(dialect.foldIdentifier(table.getName()).toLowerCase(), table);
+ }
+ DatabaseMetaData metaData = connection.getMetaData();
+ MetaDataOperations operations = dbType.createMetaDataOperations();
+ String schema = StringUtils.defaultIfBlank(targetSchemaName,
+ StringUtils.defaultIfBlank(manifest.getTarget().getSchema(), connection.getSchema()));
+ Map> dependencies = new LinkedHashMap<>();
+ for (Map.Entry entry : byName.entrySet()) {
+ Set referenced = new LinkedHashSet<>();
+ for (Map keyInfo : operations.getImportedKeyInfos(metaData, null, schema,
+ dialect.foldIdentifier(entry.getValue().getName()))) {
+ String refTable = StringUtils.lowerCase((String) keyInfo.get("PKTABLE_NAME"));
+ if (refTable != null && !refTable.equals(entry.getKey()) && byName.containsKey(refTable)) {
+ referenced.add(refTable);
+ }
+ }
+ dependencies.put(entry.getKey(), referenced);
+ }
+ List sorted = new ArrayList<>();
+ Set visited = new HashSet<>();
+ for (String name : byName.keySet()) {
+ addInOrder(name, byName, dependencies, visited, new HashSet<>(), sorted);
+ }
+ return sorted;
+ }
+
+ private static void addInOrder(String name, Map tables,
+ Map> dependencies, Set visited, Set visiting,
+ List sorted) {
+ if (visited.contains(name) || !visiting.add(name)) {
+ return;
+ }
+ for (String referenced : dependencies.getOrDefault(name, Collections.emptySet())) {
+ addInOrder(referenced, tables, dependencies, visited, visiting, sorted);
+ }
+ visited.add(name);
+ sorted.add(tables.get(name));
+ }
+
+ /**
+ * Forwards events of a table import: progress of the table is added to rows of tables imported before, start
+ * and end of the inner import are not forwarded, the first error is kept
+ */
+ static class ProgressListener implements ExportListener {
+
+ private final ExportListener delegate;
+ private final long completed;
+ private final long total;
+ private long rows;
+ private String error;
+ private Throwable cause;
+
+ ProgressListener(ExportListener delegate, long completed, long total) {
+ this.delegate = delegate;
+ this.completed = completed;
+ this.total = total;
+ }
+
+ long getRows() {
+ return rows;
+ }
+
+ String getError() {
+ return error;
+ }
+
+ Throwable getCause() {
+ return cause;
+ }
+
+ @Override
+ public void onTableStart(String catalogName, String schemaName, String tableName, long totalRows) {
+ delegate.onTableStart(catalogName, schemaName, tableName, totalRows);
+ }
+
+ @Override
+ public void onTableProgress(String catalogName, String schemaName, String tableName, long processedRows,
+ long totalRows) {
+ delegate.onTableProgress(catalogName, schemaName, tableName, processedRows, totalRows);
+ }
+
+ @Override
+ public void onTableEnd(String catalogName, String schemaName, String tableName, long processedRows) {
+ rows += processedRows;
+ delegate.onTableEnd(catalogName, schemaName, tableName, processedRows);
+ }
+
+ @Override
+ public void onProgress(long processed, long tableTotal) {
+ if (total > 0) {
+ delegate.onProgress(Math.min(completed + processed, total), total);
+ }
+ }
+
+ @Override
+ public void onMessage(String message) {
+ delegate.onMessage(message);
+ }
+
+ @Override
+ public void onError(String message, Throwable e) {
+ if (error == null) {
+ error = message;
+ cause = e;
+ }
+ delegate.onError(message, e);
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return delegate.isCancelled();
+ }
+ }
+}
diff --git a/src/main/java/com/github/sqljam/impexp/DataFormat.java b/src/main/java/com/github/sqljam/impexp/DataFormat.java
index 22bcf3f..64e1ec4 100644
--- a/src/main/java/com/github/sqljam/impexp/DataFormat.java
+++ b/src/main/java/com/github/sqljam/impexp/DataFormat.java
@@ -16,8 +16,9 @@
package com.github.sqljam.impexp;
/**
- * @Description: DataFormat of rows in an export package: SQL insert statements, or Parquet files (one file per
- * table) for analytical tools
+ * @Description: DataFormat of rows in an export package: SQL insert statements, Parquet files (one file per
+ * table) for analytical tools, or CSV files (one file per table with a header row) for spreadsheets
+ * and text tools
* @Author: Fred Feng
* @Date: 26/03/2023
* @Version 1.0.0
@@ -26,5 +27,7 @@ public enum DataFormat {
SQL,
- PARQUET
+ PARQUET,
+
+ CSV
}
diff --git a/src/main/java/com/github/sqljam/impexp/DbType.java b/src/main/java/com/github/sqljam/impexp/DbType.java
index ad82908..8e354a6 100644
--- a/src/main/java/com/github/sqljam/impexp/DbType.java
+++ b/src/main/java/com/github/sqljam/impexp/DbType.java
@@ -323,6 +323,29 @@ public boolean isFileBased() {
return false;
}
+ /**
+ * Whether the database has transactions, an import can be committed manually or rolled back then
+ */
+ public boolean isTransactionSupported() {
+ return this != CLICKHOUSE;
+ }
+
+ /**
+ * Whether DDL statements are part of a transaction and are rolled back with it. MySQL, MariaDB, Oracle and H2
+ * commit the transaction before and after a DDL statement.
+ */
+ public boolean isTransactionalDdl() {
+ switch (this) {
+ case POSTGRESQL:
+ case SQLSERVER:
+ case SQLITE:
+ case DUCKDB:
+ return true;
+ default:
+ return false;
+ }
+ }
+
public String getValidationQuery() {
return "SELECT 1";
}
diff --git a/src/main/java/com/github/sqljam/impexp/Dialect.java b/src/main/java/com/github/sqljam/impexp/Dialect.java
index 0595f5a..931640a 100644
--- a/src/main/java/com/github/sqljam/impexp/Dialect.java
+++ b/src/main/java/com/github/sqljam/impexp/Dialect.java
@@ -16,6 +16,7 @@
package com.github.sqljam.impexp;
import java.math.BigDecimal;
+import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Time;
@@ -31,6 +32,7 @@
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
@@ -38,6 +40,8 @@
import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import java.util.TreeMap;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -98,6 +102,10 @@ public abstract class Dialect {
*/
private String tableNamePattern;
private IdentifierCase identifierCase = IdentifierCase.AUTO;
+ /**
+ * Names of existing target tables and columns, they are used as they are
+ */
+ private final Set exactIdentifiers = ConcurrentHashMap.newKeySet();
/**
* When set, table names in DDL/DML generated by this dialect are qualified with these names
*/
@@ -331,6 +339,7 @@ public Dialect forVersion(int majorVersion, int minorVersion) {
dialect.setTargetCatalogName(targetCatalogName);
dialect.setTargetSchemaName(targetSchemaName);
dialect.setSourceSchemaPreserved(sourceSchemaPreserved);
+ dialect.addExactIdentifiers(exactIdentifiers);
return dialect;
}
@@ -374,10 +383,19 @@ public boolean isReservedWord(String word) {
}
/**
- * Converts identifier case of a source name to this database's convention
+ * Names of an existing target table and its columns: they are used as they are, e.g. a column "OrderId" of
+ * PostgreSQL written from a source column orderid
+ */
+ public void addExactIdentifiers(Collection names) {
+ names.stream().filter(StringUtils::isNotBlank).forEach(exactIdentifiers::add);
+ }
+
+ /**
+ * Converts identifier case of a source name to this database's convention, names of existing target objects
+ * are kept
*/
public String foldIdentifier(String name) {
- if (StringUtils.isBlank(name)) {
+ if (StringUtils.isBlank(name) || exactIdentifiers.contains(name)) {
return name;
}
switch (identifierCase) {
@@ -1128,6 +1146,102 @@ public String getInsertTableStatement(String catalog, String schema, String tabl
values);
}
+ /**
+ * Whether rows can be inserted or updated by their key in one statement
+ */
+ public boolean isUpsertSupported() {
+ return true;
+ }
+
+ /**
+ * Inserts the row, or updates the row of the same key: the columns which are not keys are updated. Values are
+ * parameters (?) or literals of scripts.
+ *
+ * @param keyColumnNames columns which identify a row, e.g. the primary key
+ * @param updateColumnNames columns updated when the row exists, a row of keys only is inserted if missing
+ * @param values values of the columns, parameters or literals
+ */
+ public String getUpsertStatement(String catalog, String schema, String tableName, String[] columnNames,
+ String[] keyColumnNames, String[] updateColumnNames, Object[] values) {
+ String insert = String.format("INSERT INTO %s(%s) VALUES (%s)", getQualifiedTableName(catalog, schema,
+ tableName), getIdentifiers(columnNames), StringUtils.join(values, ","));
+ if (updateColumnNames.length == 0) {
+ return String.format("%s ON CONFLICT (%s) DO NOTHING", insert, getIdentifiers(keyColumnNames));
+ }
+ String updates = Arrays.stream(updateColumnNames).map(name -> getIdentifier(name) + " = EXCLUDED."
+ + getIdentifier(name)).collect(Collectors.joining(", "));
+ return String.format("%s ON CONFLICT (%s) DO UPDATE SET %s", insert, getIdentifiers(keyColumnNames), updates);
+ }
+
+ /**
+ * Upsert statement with parameters for each column
+ */
+ public String getUpsertStatement(String catalog, String schema, String tableName, String[] columnNames,
+ String[] keyColumnNames, String[] updateColumnNames) {
+ return getUpsertStatement(catalog, schema, tableName, columnNames, keyColumnNames, updateColumnNames,
+ Arrays.stream(columnNames).map(name -> "?").toArray());
+ }
+
+ /**
+ * Whether upserts may update indexed columns, an update of an indexed column of DuckDB is a delete and an
+ * insert which fails for keys and foreign keys
+ */
+ public boolean isIndexedColumnUpdatable() {
+ return true;
+ }
+
+ /**
+ * Columns of the primary key of an existing table in the order of the key, empty if it has none
+ */
+ public List getPrimaryKeyColumns(Connection connection, String catalog, String schema, String tableName)
+ throws SQLException {
+ Map ordered = new TreeMap<>();
+ try (ResultSet rs = connection.getMetaData().getPrimaryKeys(catalog, schema, tableName)) {
+ while (rs.next()) {
+ ordered.put(rs.getInt("KEY_SEQ"), rs.getString("COLUMN_NAME"));
+ }
+ }
+ return new ArrayList<>(ordered.values());
+ }
+
+ /**
+ * Columns of indexes and keys of an existing table, lower case
+ */
+ public Set getIndexedColumns(Connection connection, String catalog, String schema, String tableName)
+ throws SQLException {
+ Set columns = new HashSet<>();
+ try (ResultSet rs = connection.getMetaData().getIndexInfo(catalog, schema, tableName, false, true)) {
+ while (rs.next()) {
+ if (rs.getString("COLUMN_NAME") != null) {
+ columns.add(rs.getString("COLUMN_NAME").toLowerCase(Locale.ENGLISH));
+ }
+ }
+ }
+ return columns;
+ }
+
+ /**
+ * Whether the error means that the created object exists already: a table, an index, a key or a constraint.
+ * Imports which do not drop tables skip such statements of existing tables.
+ */
+ public boolean isAlreadyExistsError(SQLException e) {
+ for (Throwable cause = e; cause != null; cause = cause.getCause()) {
+ if (cause instanceof SQLException) {
+ String state = ((SQLException) cause).getSQLState();
+ // Duplicate table and duplicate object of SQL standard and PostgreSQL, table or index of ODBC
+ if ("42P07".equals(state) || "42710".equals(state) || "42S01".equals(state)
+ || "42S11".equals(state)) {
+ return true;
+ }
+ }
+ String message = StringUtils.lowerCase(cause.getMessage(), Locale.ENGLISH);
+ if (message != null && message.contains("already exists")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Statements executed before inserting rows with explicit identity values (e.g. SET IDENTITY_INSERT ON)
*/
diff --git a/src/main/java/com/github/sqljam/impexp/DuckDBWorkspace.java b/src/main/java/com/github/sqljam/impexp/DuckDBWorkspace.java
index 077782c..883e87a 100644
--- a/src/main/java/com/github/sqljam/impexp/DuckDBWorkspace.java
+++ b/src/main/java/com/github/sqljam/impexp/DuckDBWorkspace.java
@@ -18,6 +18,7 @@
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.sql.Connection;
import java.sql.DriverManager;
@@ -36,9 +37,10 @@
import lombok.extern.slf4j.Slf4j;
/**
- * @Description: DuckDBWorkspace is a temporary DuckDB database used to write and read Parquet files. Rows of other
- * databases are copied into the workspace and written by COPY TO, Parquet files are loaded into the
- * workspace by read_parquet and copied into other databases. The database file is deleted on close.
+ * @Description: DuckDBWorkspace is a temporary DuckDB database used to write and read Parquet and CSV files. Rows
+ * of other databases are copied into the workspace and written by COPY TO, files are loaded into the
+ * workspace by read_parquet or read_csv and copied into other databases. The database file is deleted
+ * on close.
* @Author: Fred Feng
* @Date: 26/03/2023
* @Version 1.0.0
@@ -47,6 +49,7 @@
public class DuckDBWorkspace implements Closeable {
public static final String DEFAULT_SCHEMA = "main";
+ public static final String DEFAULT_DELIMITER = ",";
private final File directory;
private final File file;
@@ -104,6 +107,17 @@ public long countRows(String schema, String table) throws SQLException {
*/
public void writeParquet(String schema, String table, File parquetFile, String compression,
Map metadata) throws SQLException, IOException {
+ writeParquet(schema, table, parquetFile, compression, metadata, 0, 0);
+ }
+
+ /**
+ * Writes a range of rows in the order of insertion into a Parquet file, e.g. a part of a large table
+ *
+ * @param offset first row of the range
+ * @param limit rows of the range, 0 or less for all rows
+ */
+ public void writeParquet(String schema, String table, File parquetFile, String compression,
+ Map metadata, long offset, long limit) throws SQLException, IOException {
FileUtils.forceMkdirParent(parquetFile);
String columnSql = "SELECT column_name, data_type FROM duckdb_columns() WHERE database_name = current_database()"
+ " AND schema_name = ? AND table_name = ? ORDER BY column_index";
@@ -124,8 +138,10 @@ public void writeParquet(String schema, String table, File parquetFile, String c
.map(e -> e.getKey() + ": " + literal(e.getValue())).collect(Collectors.joining(", ")))
.append("}");
}
- String sql = String.format("COPY (SELECT %s FROM %s) TO %s (%s)", String.join(", ", columns),
- qualify(schema, table), literal(parquetFile.getAbsolutePath()), options);
+ String range = limit > 0 ? String.format(" ORDER BY rowid LIMIT %d OFFSET %d", limit, Math.max(offset, 0))
+ : "";
+ String sql = String.format("COPY (SELECT %s FROM %s%s) TO %s (%s)", String.join(", ", columns),
+ qualify(schema, table), range, literal(parquetFile.getAbsolutePath()), options);
execute(sql);
}
@@ -135,11 +151,16 @@ public void writeParquet(String schema, String table, File parquetFile, String c
*/
public void loadParquet(String schema, String table, List parquetFiles, List identityColumns)
throws SQLException {
- String files = parquetFiles.stream().map(f -> literal(f.getAbsolutePath()))
- .collect(Collectors.joining(", ", "[", "]"));
execute(String.format("CREATE SCHEMA IF NOT EXISTS %s", quote(schema)));
execute(String.format("CREATE OR REPLACE TABLE %s AS SELECT * FROM read_parquet(%s)", qualify(schema, table),
- files));
+ fileList(parquetFiles)));
+ addIdentities(schema, table, identityColumns);
+ }
+
+ /**
+ * Identity columns get a sequence and a nextval default, so that they are recognized as identity columns
+ */
+ private void addIdentities(String schema, String table, List identityColumns) throws SQLException {
if (identityColumns != null) {
for (String column : identityColumns) {
String sequence = qualify(schema, table + "_" + column + "_seq");
@@ -150,6 +171,104 @@ public void loadParquet(String schema, String table, List parquetFiles, Li
}
}
+ /**
+ * Writes rows of the table into a CSV file in UTF-8 with the delimiter, quote, text of NULL, header and date
+ * format of the options. An empty string is quoted to keep it apart from NULL, binary values are written as
+ * escaped text (\x00). Other encodings are converted after writing.
+ */
+ public void writeCsv(String schema, String table, File csvFile, CsvOptions options) throws SQLException,
+ IOException {
+ FileUtils.forceMkdirParent(csvFile);
+ StringBuilder sql = new StringBuilder("COPY ").append(qualify(schema, table)).append(" TO ")
+ .append(literal(csvFile.getAbsolutePath())).append(" (FORMAT csv, HEADER ").append(options.isHeader())
+ .append(", DELIMITER ").append(literal(StringUtils.defaultIfEmpty(options.getDelimiter(),
+ DEFAULT_DELIMITER))).append(", QUOTE ").append(literal(options.getQuote()))
+ .append(", ESCAPE ").append(literal(options.getQuote())).append(", NULLSTR ")
+ .append(literal(options.getNullString()));
+ if (StringUtils.isNotEmpty(options.getDateFormat())) {
+ sql.append(", DATEFORMAT ").append(literal(options.getDateFormat())).append(", TIMESTAMPFORMAT ")
+ .append(literal(options.getTimestampFormat()));
+ }
+ execute(sql.append(")").toString());
+ }
+
+ /**
+ * Loads CSV files into a table of the workspace. With the columns of the package the values are read with
+ * their types (exactly as exported), without them the types are detected from the files.
+ *
+ * @param columns columns and types of the files, null to detect them
+ * @param options options of the files, files of other encodings are converted to UTF-8 first
+ */
+ public void loadCsv(String schema, String table, List csvFiles, List columns,
+ List identityColumns, CsvOptions options) throws SQLException, IOException {
+ execute(String.format("CREATE SCHEMA IF NOT EXISTS %s", quote(schema)));
+ execute(String.format("CREATE OR REPLACE TABLE %s AS SELECT * FROM %s", qualify(schema, table),
+ csvReader(toUtf8(csvFiles, options), columns, options)));
+ addIdentities(schema, table, identityColumns);
+ }
+
+ /**
+ * Files in UTF-8: files of other encodings are converted into the workspace, the BOM of UTF-8 is removed
+ */
+ private List toUtf8(List csvFiles, CsvOptions options) throws IOException {
+ if (options == null || options.isUtf8()) {
+ return csvFiles;
+ }
+ List converted = new ArrayList<>();
+ for (File file : csvFiles) {
+ File target = new File(directory, "utf8-" + (converted.size() + 1) + "-" + file.getName());
+ CsvSplitter.transcode(file, options.getCharset(), target, StandardCharsets.UTF_8, false);
+ converted.add(target);
+ }
+ return converted;
+ }
+
+ /**
+ * read_csv of the files: a quoted empty field is an empty string and the text of NULL is NULL. Options of
+ * files of other tools which are not given are detected.
+ */
+ private static String csvReader(List csvFiles, List columns,
+ CsvOptions options) {
+ StringBuilder reader = new StringBuilder("read_csv(").append(fileList(csvFiles))
+ .append(", allow_quoted_nulls = false");
+ if (options != null) {
+ reader.append(", header = ").append(options.isHeader()).append(", quote = ").append(literal(options
+ .getQuote())).append(", escape = ").append(literal(options.getQuote())).append(", nullstr = ")
+ .append(literal(options.getNullString()));
+ if (StringUtils.isNotEmpty(options.getDelimiter())) {
+ reader.append(", delim = ").append(literal(options.getDelimiter()));
+ }
+ if (StringUtils.isNotEmpty(options.getDateFormat())) {
+ reader.append(", dateformat = ").append(literal(options.getDateFormat())).append(", timestampformat = ")
+ .append(literal(options.getTimestampFormat()));
+ }
+ } else {
+ reader.append(", header = true");
+ }
+ if (columns != null && !columns.isEmpty()) {
+ reader.append(", auto_detect = false, columns = {").append(columns.stream().map(column -> literal(column
+ .getName()) + ": " + literal(column.getType())).collect(Collectors.joining(", "))).append("}");
+ }
+ return reader.append(")").toString();
+ }
+
+ /**
+ * Reader of the files by their extension: read_csv for .csv, .tsv and .txt files, read_parquet for others
+ */
+ private String reader(List files, CsvOptions options) throws IOException {
+ return files.stream().allMatch(DuckDBWorkspace::isCsv) ? csvReader(toUtf8(files, options), null, options)
+ : String.format("read_parquet(%s)", fileList(files));
+ }
+
+ public static boolean isCsv(File file) {
+ String name = file.getName().toLowerCase(Locale.ENGLISH);
+ return name.endsWith(".csv") || name.endsWith(".tsv") || name.endsWith(".txt");
+ }
+
+ private static String fileList(List files) {
+ return files.stream().map(f -> literal(f.getAbsolutePath())).collect(Collectors.joining(", ", "[", "]"));
+ }
+
/**
* Drops columns of a workspace table, e.g. columns which are generated by the target database
*/
@@ -174,14 +293,20 @@ public List describeTable(String schema, String table) throws SQLExcep
}
/**
- * Columns of Parquet files: name and DuckDB type
+ * Columns of Parquet or CSV files: name and DuckDB type, types of CSV files are detected
+ */
+ public List describeFiles(List dataFiles) throws SQLException, IOException {
+ return describeFiles(dataFiles, null);
+ }
+
+ /**
+ * Columns of Parquet or CSV files, CSV files are read with the options (null to detect them)
*/
- public List describeParquet(List parquetFiles) throws SQLException {
- String files = parquetFiles.stream().map(f -> literal(f.getAbsolutePath()))
- .collect(Collectors.joining(", ", "[", "]"));
+ public List describeFiles(List dataFiles, CsvOptions options) throws SQLException, IOException {
List columns = new ArrayList<>();
try (Statement statement = getConnection().createStatement();
- ResultSet rs = statement.executeQuery(String.format("DESCRIBE SELECT * FROM read_parquet(%s)", files))) {
+ ResultSet rs = statement.executeQuery(String.format("DESCRIBE SELECT * FROM %s", reader(dataFiles,
+ options)))) {
while (rs.next()) {
columns.add(new String[]{rs.getString("column_name"), rs.getString("column_type")});
}
@@ -190,19 +315,22 @@ public List describeParquet(List parquetFiles) throws SQLExcepti
}
/**
- * First rows of Parquet files, values as text
+ * First rows of Parquet or CSV files, values as text
*/
- public List> readParquetRows(List parquetFiles, int limit) throws SQLException {
- String files = parquetFiles.stream().map(f -> literal(f.getAbsolutePath()))
- .collect(Collectors.joining(", ", "[", "]"));
- List columns = describeParquet(parquetFiles).stream()
+ public List> readRows(List dataFiles, int limit) throws SQLException, IOException {
+ return readRows(dataFiles, limit, null);
+ }
+
+ public List> readRows(List dataFiles, int limit, CsvOptions options) throws SQLException,
+ IOException {
+ List columns = describeFiles(dataFiles, options).stream()
.map(column -> String.format("CAST(%s AS VARCHAR)", quote(column[0])))
.collect(Collectors.toList());
List> rows = new ArrayList<>();
if (columns.isEmpty()) {
return rows;
}
- String sql = String.format("SELECT %s FROM read_parquet(%s) LIMIT %d", String.join(", ", columns), files,
+ String sql = String.format("SELECT %s FROM %s LIMIT %d", String.join(", ", columns), reader(dataFiles, options),
Math.max(limit, 0));
try (Statement statement = getConnection().createStatement(); ResultSet rs = statement.executeQuery(sql)) {
int count = rs.getMetaData().getColumnCount();
@@ -217,11 +345,13 @@ public List> readParquetRows(List parquetFiles, int limit) th
return rows;
}
- public long countParquetRows(List parquetFiles) throws SQLException {
- String files = parquetFiles.stream().map(f -> literal(f.getAbsolutePath()))
- .collect(Collectors.joining(", ", "[", "]"));
+ public long countFileRows(List dataFiles) throws SQLException, IOException {
+ return countFileRows(dataFiles, null);
+ }
+
+ public long countFileRows(List dataFiles, CsvOptions options) throws SQLException, IOException {
Map row = JdbcUtils.fetchOne(getConnection(),
- String.format("SELECT count(*) AS n FROM read_parquet(%s)", files), new Object[0]);
+ String.format("SELECT count(*) AS n FROM %s", reader(dataFiles, options)), new Object[0]);
Object count = row != null ? row.get("n") : null;
return count instanceof Number ? ((Number) count).longValue() : 0;
}
diff --git a/src/main/java/com/github/sqljam/impexp/ExportManifest.java b/src/main/java/com/github/sqljam/impexp/ExportManifest.java
index 901fca4..5f7870e 100644
--- a/src/main/java/com/github/sqljam/impexp/ExportManifest.java
+++ b/src/main/java/com/github/sqljam/impexp/ExportManifest.java
@@ -32,7 +32,10 @@
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import lombok.AllArgsConstructor;
import lombok.Getter;
+import lombok.NoArgsConstructor;
import lombok.Setter;
/**
@@ -72,7 +75,12 @@ public enum FileType {
/**
* Rows of a table in a Parquet file
*/
- PARQUET
+ PARQUET,
+
+ /**
+ * Rows of a table in a CSV file with a header row
+ */
+ CSV
}
private String format = FORMAT;
@@ -132,12 +140,38 @@ public static class TableEntry {
private String schema;
private String name;
private long rows;
+ /**
+ * Data files in the order of importing, e.g. orders.csv, orders_2.csv
+ */
+ @JsonDeserialize(as = LinkedHashSet.class)
private Set dataFiles = new LinkedHashSet<>();
private long lobFiles;
/**
* Identity columns of the table (Parquet packages), their values are kept and sequences are reset
*/
private List identityColumns = new ArrayList<>();
+ /**
+ * Columns of the data files and their types (CSV packages), CSV files have no types, the rows are read
+ * back with these types, so that values are imported exactly
+ */
+ private List columns = new ArrayList<>();
+ /**
+ * Key columns of upserts, empty for the primary key of the table or for inserts
+ */
+ private List keyColumns = new ArrayList<>();
+ }
+
+ @Getter
+ @Setter
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class ColumnEntry {
+
+ private String name;
+ /**
+ * Type of the column in the DuckDB workspace, e.g. DECIMAL(38,10)
+ */
+ private String type;
}
private static ObjectMapper createObjectMapper() {
diff --git a/src/main/java/com/github/sqljam/impexp/Exporter.java b/src/main/java/com/github/sqljam/impexp/Exporter.java
index fad6d91..384544b 100644
--- a/src/main/java/com/github/sqljam/impexp/Exporter.java
+++ b/src/main/java/com/github/sqljam/impexp/Exporter.java
@@ -19,6 +19,7 @@
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.ArrayUtils;
@@ -114,6 +115,15 @@ public static class ExportConfiguration {
* Queries narrowing the rows and columns of tables by table name, e.g. rows filtered in the data viewer
*/
private Map tableQueries = new HashMap<>();
+ /**
+ * Rows are inserted into the target, or upserted: the rows of the same key are updated
+ */
+ private WriteMode writeMode = WriteMode.INSERT;
+ /**
+ * Key columns of upserts by table name, the primary key of the table by default. A table without key is
+ * inserted.
+ */
+ private Map> keyColumnNames = new HashMap<>();
/**
* Query of a table, null if all rows and columns are exported. Grouped rows are not rows of the table.
diff --git a/src/main/java/com/github/sqljam/impexp/ImportExportHandler.java b/src/main/java/com/github/sqljam/impexp/ImportExportHandler.java
index 8684143..0753326 100644
--- a/src/main/java/com/github/sqljam/impexp/ImportExportHandler.java
+++ b/src/main/java/com/github/sqljam/impexp/ImportExportHandler.java
@@ -20,18 +20,23 @@
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
+import java.sql.Savepoint;
import java.sql.Statement;
import java.sql.Types;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ArrayUtils;
@@ -40,6 +45,7 @@
import com.github.sqljam.impexp.DdlScripter.Schema;
import com.github.sqljam.jdbc.ConnectionFactory;
import com.github.sqljam.jdbc.JdbcUtils;
+import com.github.sqljam.jdbc.ManualTransaction;
import com.github.sqljam.jdbc.SimpleConnectionFactory;
import com.github.sqljam.page.EachPage;
import lombok.Getter;
@@ -90,6 +96,24 @@ public static class ImportConfiguration {
* tables in the same schema. Target tables have the same names by default.
*/
private String tableNamePattern;
+ /**
+ * Rows are inserted, upserted (the rows of the same key are updated) or inserted unless their key exists
+ */
+ private WriteMode writeMode = WriteMode.INSERT;
+ /**
+ * Key columns of upserts by source table name, the primary key of the table by default
+ */
+ private Map> keyColumnNames = new HashMap<>();
+ /**
+ * Whether target tables are dropped and created, existing tables, indexes and constraints are skipped when
+ * they are kept
+ */
+ private boolean tableRecreated = true;
+ /**
+ * Rows with errors are skipped: a failed page is written row by row and the failed rows are reported.
+ * Otherwise the error ends the page.
+ */
+ private boolean skipErrors;
}
private ConnectionFactory connectionFactory;
@@ -97,6 +121,9 @@ public static class ImportConfiguration {
private ExportListener exportListener = ExportListener.NONE;
private final IdentityValueTracker identityValueTracker = new IdentityValueTracker();
private final Map> targetColumnTypes = new ConcurrentHashMap<>();
+ private final Map keyColumns = new ConcurrentHashMap<>();
+ private final Map skippedRows = new ConcurrentHashMap<>();
+ private final Map> targetNames = new ConcurrentHashMap<>();
@Override
public void setExportListener(ExportListener exportListener) {
@@ -260,10 +287,17 @@ private void executeStatements(String catalogName, String schemaName, List targetTypes = Collections.emptyMap();
- String insertSql = dialect.getInsertTableStatement(catalogName, schemaName, targetTableName, columns);
+ boolean upsert = configuration.getWriteMode().isKeyed();
+ String insertSql = upsert ? null : dialect.getInsertTableStatement(catalogName, schemaName, targetTableName,
+ columns);
Connection connection = null;
boolean autoCommit = true;
boolean transactional = false;
@@ -342,42 +425,84 @@ public void exportData(String catalogName,
connection = getConnection(catalogName, schemaName);
autoCommit = connection.getAutoCommit();
transactional = JdbcUtils.beginTransaction(connection);
+ // An existing table is written by its own names, names which differ in case only are matched
+ Map existingNames = getTargetNames(connection, schemaName, targetTableName);
+ if (!existingNames.isEmpty()) {
+ dialect.addExactIdentifiers(existingNames.values());
+ targetTableName = existingNames.get("");
+ for (int i = 0; i < columns.length; i++) {
+ columns[i] = existingNames.getOrDefault(columns[i].toLowerCase(Locale.ENGLISH), columns[i]);
+ }
+ if (!upsert) {
+ insertSql = dialect.getInsertTableStatement(catalogName, schemaName, targetTableName, columns);
+ }
+ }
if (dialect.isTargetTypeConversionRequired()) {
targetTypes = getTargetColumnTypes(connection, targetTableName);
}
+ String[] keys = upsert ? getKeyColumnNames(connection, schemaName, tableName, targetTableName,
+ tableMetaData, columns) : null;
+ if (keys != null && keys.length == 0) {
+ // A table without key is inserted
+ upsert = false;
+ insertSql = dialect.getInsertTableStatement(catalogName, schemaName, targetTableName, columns);
+ }
+ if (upsert) {
+ Set fixed = new HashSet<>();
+ Arrays.stream(keys).forEach(name -> fixed.add(name.toLowerCase(Locale.ENGLISH)));
+ // Identity values of existing rows are kept
+ identityColumnNames.forEach(name -> fixed.add(name.toLowerCase(Locale.ENGLISH)));
+ if (!dialect.isIndexedColumnUpdatable()) {
+ fixed.addAll(dialect.getIndexedColumns(connection, getTargetCatalog(connection),
+ getTargetSchema(connection, schemaName), dialect.foldIdentifier(targetTableName)));
+ tableMetaData.getIndexMetaDatas().forEach(index -> fixed.add(index.getColumnName().toLowerCase(
+ Locale.ENGLISH)));
+ }
+ // Existing rows are kept when they are skipped
+ String[] updates = configuration.getWriteMode() == WriteMode.SKIP_EXISTING ? new String[0]
+ : Arrays.stream(columns).filter(name -> !fixed.contains(name.toLowerCase(Locale.ENGLISH)))
+ .toArray(String[]::new);
+ insertSql = dialect.getUpsertStatement(catalogName, schemaName, targetTableName, columns, keys,
+ updates);
+ }
executeQuietly(connection, dialect.getStatementBeforeInsert(catalogName, schemaName, targetTableName,
identityIncluded));
int rows = 0;
+ Set arrayColumns = upsert && dialect.isArrayParameterSupported() ? getTargetArrayColumns(
+ connection, schemaName, targetTableName) : Collections.emptySet();
try (PreparedStatement ps = connection.prepareStatement(insertSql)) {
Map arrayParameters = new HashMap<>();
- for (Map row : rowList) {
- int index = 0;
- for (Object value : row.values()) {
- if (value == null) {
- ps.setNull(index + 1, nullTypes[index]);
- } else {
- if (isArrayText(value) && dialect.isArrayParameterSupported()
- && isArrayParameter(ps, index + 1, arrayParameters)) {
- // Arrays kept as text (Parquet packages) are bound as arrays
- value = parseArrayText(value.toString());
- }
- if (emptyLobTypes[index] == Types.BLOB && value instanceof byte[]
- && ((byte[]) value).length == 0) {
- ps.setBlob(index + 1, connection.createBlob());
- } else if (emptyLobTypes[index] == Types.CLOB && "".equals(value)) {
- ps.setClob(index + 1, connection.createClob());
- } else {
- ps.setObject(index + 1, targetTypes.isEmpty() ? value
- : dialect.getTargetJdbcValue(value, targetTypes.get(columns[index]
- .toLowerCase(Locale.ENGLISH))));
- }
+ final Map types = targetTypes;
+ final Connection target = connection;
+ RowBinder binder = (statement, row) -> bindRow(target, statement, row, columns, nullTypes,
+ emptyLobTypes, types, arrayColumns, arrayParameters);
+ Savepoint savepoint = configuration.isSkipErrors() && transactional ? setSavepointQuietly(connection)
+ : null;
+ try {
+ for (Map row : rowList) {
+ binder.bind(ps, row);
+ ps.addBatch();
+ }
+ int[] counts = ps.executeBatch();
+ releaseQuietly(connection, savepoint);
+ if (upsert) {
+ // Updated rows count twice in MySQL, every row is written once
+ rows = rowList.size();
+ } else {
+ for (int n : counts) {
+ rows += n > 0 ? n : (n == Statement.SUCCESS_NO_INFO ? 1 : 0);
}
- index++;
}
- ps.addBatch();
- }
- for (int n : ps.executeBatch()) {
- rows += n > 0 ? n : (n == Statement.SUCCESS_NO_INFO ? 1 : 0);
+ } catch (SQLException e) {
+ if (!configuration.isSkipErrors()) {
+ throw e;
+ }
+ // Rows are named by their keys in the log, or by their first columns
+ List named = keys != null && keys.length > 0 ? Arrays.asList(keys)
+ : tableMetaData.getPrimaryKeyColumnNames();
+ rows = writeRowByRow(connection, ps, insertSql, savepoint, transactional, rowList, binder, tableName,
+ named.isEmpty() ? Arrays.asList(columns).subList(0, Math.min(3, columns.length))
+ : named, e);
}
}
executeQuietly(connection, dialect.getStatementAfterInsert(catalogName, schemaName, targetTableName,
@@ -415,6 +540,162 @@ && isArrayParameter(ps, index + 1, arrayParameters)) {
}
}
+ /**
+ * Binds the values of a row to the parameters of the statement
+ */
+ @FunctionalInterface
+ private interface RowBinder {
+ void bind(PreparedStatement ps, Map row) throws SQLException;
+ }
+
+ private void bindRow(Connection connection, PreparedStatement ps, Map row, String[] columns,
+ int[] nullTypes, int[] emptyLobTypes, Map targetTypes,
+ Set arrayColumns, Map arrayParameters) throws SQLException {
+ int index = 0;
+ for (Object value : row.values()) {
+ if (value == null) {
+ ps.setNull(index + 1, nullTypes[index]);
+ } else {
+ if (isArrayText(value) && dialect.isArrayParameterSupported()
+ && (arrayColumns.contains(columns[index].toLowerCase(Locale.ENGLISH))
+ || isArrayParameter(ps, index + 1, arrayParameters))) {
+ // Arrays kept as text (Parquet packages) are bound as arrays
+ value = parseArrayText(value.toString());
+ }
+ if (emptyLobTypes[index] == Types.BLOB && value instanceof byte[] && ((byte[]) value).length == 0) {
+ ps.setBlob(index + 1, connection.createBlob());
+ } else if (emptyLobTypes[index] == Types.CLOB && "".equals(value)) {
+ ps.setClob(index + 1, connection.createClob());
+ } else {
+ ps.setObject(index + 1, targetTypes.isEmpty() ? value : dialect.getTargetJdbcValue(value,
+ targetTypes.get(columns[index].toLowerCase(Locale.ENGLISH))));
+ }
+ }
+ index++;
+ }
+ }
+
+ /**
+ * Writes the rows of a failed page one by one and skips the rows with errors, each skipped row is reported
+ * with its key and its error. Each row has its own savepoint, or its own commit when the database has no
+ * savepoints. A transaction of the whole import without savepoints (DuckDB) can not go on after an error, the
+ * error is thrown.
+ *
+ * @param namedColumns columns which name a row in the log, e.g. its key
+ * @return rows written
+ */
+ private int writeRowByRow(Connection connection, PreparedStatement ps, String sql, Savepoint pageSavepoint,
+ boolean transactional, List> rowList, RowBinder binder,
+ String tableName, List namedColumns, SQLException batchError)
+ throws SQLException {
+ try {
+ ps.clearBatch();
+ } catch (SQLException ignored) {
+ // The batch ended with the error
+ }
+ boolean savepoints = pageSavepoint != null;
+ if (transactional) {
+ if (savepoints) {
+ connection.rollback(pageSavepoint);
+ } else if (ManualTransaction.isManaged(connection)) {
+ // The transaction can not go on after the error, the whole import is rolled back
+ connection.rollback();
+ throw new SQLException(String.format("Rows of table '%s' with errors can not be skipped in one"
+ + " transaction without savepoints, the import is rolled back: %s", tableName,
+ batchError.getMessage()), batchError.getSQLState(), batchError.getErrorCode(), batchError);
+ } else {
+ // Rows of this page are written again, rows of former pages are committed already
+ connection.rollback();
+ }
+ }
+ int rows = 0;
+ PreparedStatement statement = ps;
+ try {
+ for (Map row : rowList) {
+ // DuckDB closes a statement when it fails
+ if (statement.isClosed()) {
+ statement = connection.prepareStatement(sql);
+ }
+ if (writeRow(connection, statement, row, binder, savepoints, transactional, tableName,
+ namedColumns)) {
+ rows++;
+ }
+ }
+ } finally {
+ if (statement != ps) {
+ JdbcUtils.closeQuietly(statement);
+ }
+ }
+ return rows;
+ }
+
+ /**
+ * Writes a row in its own savepoint or commit, a row with an error is rolled back and reported
+ *
+ * @return whether the row is written
+ */
+ private boolean writeRow(Connection connection, PreparedStatement statement, Map row,
+ RowBinder binder, boolean savepoints, boolean transactional, String tableName,
+ List namedColumns) throws SQLException {
+ Savepoint savepoint = savepoints ? connection.setSavepoint() : null;
+ try {
+ binder.bind(statement, row);
+ statement.executeUpdate();
+ if (savepoints) {
+ releaseQuietly(connection, savepoint);
+ } else if (transactional) {
+ connection.commit();
+ }
+ return true;
+ } catch (SQLException e) {
+ if (savepoints) {
+ connection.rollback(savepoint);
+ } else if (transactional) {
+ connection.rollback();
+ }
+ skippedRows.computeIfAbsent(tableName, name -> new AtomicLong()).incrementAndGet();
+ String message = String.format("Skipped row of table '%s' (%s): %s", tableName,
+ describeRow(row, namedColumns), e.getMessage());
+ if (log.isWarnEnabled()) {
+ log.warn(message);
+ }
+ exportListener.onError(message, e);
+ return false;
+ }
+ }
+
+ /**
+ * Rows of the table which were skipped because of errors since the last call
+ */
+ public long takeSkippedRows(String tableName) {
+ AtomicLong rows = skippedRows.remove(tableName);
+ return rows != null ? rows.get() : 0;
+ }
+
+ /**
+ * Values of the named columns of a row, e.g. ID=5, long values are abbreviated
+ */
+ static String describeRow(Map row, List namedColumns) {
+ List values = new ArrayList<>();
+ for (String name : namedColumns) {
+ Object value = row.entrySet().stream().filter(entry -> entry.getKey().equalsIgnoreCase(name))
+ .map(Map.Entry::getValue).findFirst().orElse(null);
+ String text = value instanceof byte[] ? "<" + ((byte[]) value).length + " bytes>"
+ : String.valueOf(value);
+ values.add(name + "=" + StringUtils.abbreviate(text, 50));
+ }
+ return String.join(", ", values);
+ }
+
+ private static Savepoint setSavepointQuietly(Connection connection) {
+ try {
+ return connection.setSavepoint();
+ } catch (SQLException e) {
+ // Databases without savepoints, e.g. DuckDB
+ return null;
+ }
+ }
+
private void resetIdentityValues(String catalogName, String schemaName, String tableName,
TableMetaData tableMetaData) throws SQLException {
Map maxValues = identityValueTracker.remove(tableMetaData);
@@ -424,8 +705,12 @@ private void resetIdentityValues(String catalogName, String schemaName, String t
List sqls = new ArrayList<>();
for (Map.Entry entry : maxValues.entrySet()) {
Dialect targetDialect = tableMetaData.getDialect();
- String sql = targetDialect.getResetIdentityStatement(catalogName, schemaName,
- targetDialect.getTargetTableName(tableName), entry.getKey(), entry.getValue() + 1);
+ // The existing table by its own name
+ String targetTableName = targetDialect.getTargetTableName(tableName);
+ Map existingNames = targetNames.getOrDefault(targetTableName, Collections.emptyMap());
+ String sql = targetDialect.getResetIdentityStatement(catalogName, schemaName, existingNames.getOrDefault(
+ "", targetTableName), existingNames.getOrDefault(entry.getKey().toLowerCase(Locale.ENGLISH),
+ entry.getKey()), entry.getValue() + 1);
if (StringUtils.isNotBlank(sql)) {
sqls.add(sql);
}
@@ -458,6 +743,135 @@ private void executeQuietly(Connection connection, String[] sqls) {
/**
* Type names of the columns of a target table by lower case column name, read once per table
*/
+ /**
+ * Key columns of upserts: the chosen columns, or the primary key of the source table or of the existing target
+ * table (e.g. rows of files). Indexes are not guessed, a table without key is inserted.
+ *
+ * @return key columns as named in the imported columns, empty if the table has no key
+ */
+ private String[] getKeyColumnNames(Connection connection, String schemaName, String tableName,
+ String targetTableName, TableMetaData tableMetaData, String[] columns)
+ throws SQLException {
+ String[] cached = keyColumns.get(tableName);
+ if (cached != null) {
+ return cached;
+ }
+ List keys = configuration.getKeyColumnNames().entrySet().stream()
+ .filter(entry -> entry.getKey().equalsIgnoreCase(tableName)).map(Map.Entry::getValue).findFirst()
+ .orElse(null);
+ if (CollectionUtils.isEmpty(keys)) {
+ keys = tableMetaData.getPrimaryKeyColumnNames();
+ }
+ if (CollectionUtils.isEmpty(keys)) {
+ keys = dialect.getPrimaryKeyColumns(connection, getTargetCatalog(connection), getTargetSchema(connection,
+ schemaName), dialect.foldIdentifier(targetTableName));
+ }
+ if (CollectionUtils.isEmpty(keys)) {
+ exportListener.onMessage(String.format("Table %s has no primary key and no chosen key columns, its rows "
+ + "are inserted", tableName));
+ keyColumns.put(tableName, new String[0]);
+ return keyColumns.get(tableName);
+ }
+ String[] resolved = new String[keys.size()];
+ for (int i = 0; i < resolved.length; i++) {
+ String key = keys.get(i);
+ resolved[i] = Arrays.stream(columns).filter(column -> column.equalsIgnoreCase(key)).findFirst()
+ .orElseThrow(() -> new ImpExpException(String.format("Key column %s of table %s is not imported",
+ key, tableName)));
+ }
+ keyColumns.put(tableName, resolved);
+ return resolved;
+ }
+
+ /**
+ * Names of an existing target table and its columns by their lower case names, the table name under the empty
+ * key. Empty if the table does not exist or its names can not be read, the names of the import are used then.
+ */
+ private Map getTargetNames(Connection connection, String schemaName, String targetTableName) {
+ Map names = targetNames.get(targetTableName);
+ if (names != null) {
+ return names;
+ }
+ names = new HashMap<>();
+ try {
+ DatabaseMetaData metaData = connection.getMetaData();
+ String catalog = getTargetCatalog(connection);
+ String schema = getTargetSchema(connection, schemaName);
+ String table = findTargetTable(metaData, catalog, schema, targetTableName);
+ if (table != null) {
+ try (ResultSet rs = metaData.getColumns(catalog, schema, table, null)) {
+ while (rs.next()) {
+ String column = rs.getString("COLUMN_NAME");
+ names.putIfAbsent(column.toLowerCase(Locale.ENGLISH), column);
+ }
+ }
+ if (!names.isEmpty()) {
+ names.put("", table);
+ }
+ }
+ } catch (SQLException e) {
+ if (log.isWarnEnabled()) {
+ log.warn("Unable to read names of target table {}: {}", targetTableName, e.getMessage());
+ }
+ names.clear();
+ }
+ targetNames.put(targetTableName, names);
+ return names;
+ }
+
+ /**
+ * Name of the existing target table: the name of the import, its folded name, or a name which differs in case
+ * only (e.g. Orders of MySQL on Linux for orders)
+ */
+ private String findTargetTable(DatabaseMetaData metaData, String catalog, String schema, String tableName)
+ throws SQLException {
+ for (String candidate : new String[]{dialect.foldIdentifier(tableName), tableName, null}) {
+ try (ResultSet rs = metaData.getTables(catalog, schema, candidate, null)) {
+ while (rs.next()) {
+ String name = rs.getString("TABLE_NAME");
+ if (name != null && name.equalsIgnoreCase(tableName)) {
+ return name;
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Catalog of the target table: the target catalog or the catalog of the connection
+ */
+ private String getTargetCatalog(Connection connection) throws SQLException {
+ return StringUtils.defaultIfBlank(configuration.getTargetCatalogName(), connection.getCatalog());
+ }
+
+ /**
+ * Schema of the target table as the import resolves it, the schema of the connection (e.g. dbo of SQL Server)
+ * is only the default
+ */
+ private String getTargetSchema(Connection connection, String schemaName) throws SQLException {
+ String schema = dialect != null ? dialect.getTargetSchemaName(schemaName) : configuration.getTargetSchemaName();
+ return StringUtils.defaultIfBlank(schema, connection.getSchema());
+ }
+
+ /**
+ * Array columns of the target table, for statements whose parameters have no types (e.g. MERGE of H2)
+ */
+ private Set getTargetArrayColumns(Connection connection, String schemaName, String targetTableName)
+ throws SQLException {
+ Set columns = new HashSet<>();
+ try (ResultSet rs = connection.getMetaData().getColumns(getTargetCatalog(connection),
+ getTargetSchema(connection, schemaName), dialect.foldIdentifier(targetTableName), null)) {
+ while (rs.next()) {
+ String typeName = StringUtils.defaultString(rs.getString("TYPE_NAME")).toUpperCase(Locale.ENGLISH);
+ if (rs.getInt("DATA_TYPE") == Types.ARRAY || typeName.endsWith("ARRAY") || typeName.endsWith("[]")) {
+ columns.add(rs.getString("COLUMN_NAME").toLowerCase(Locale.ENGLISH));
+ }
+ }
+ }
+ return columns;
+ }
+
private Map getTargetColumnTypes(Connection connection, String targetTableName)
throws SQLException {
Map types = targetColumnTypes.get(targetTableName);
diff --git a/src/main/java/com/github/sqljam/impexp/ImportExporter.java b/src/main/java/com/github/sqljam/impexp/ImportExporter.java
index 92d69d9..3013ca1 100644
--- a/src/main/java/com/github/sqljam/impexp/ImportExporter.java
+++ b/src/main/java/com/github/sqljam/impexp/ImportExporter.java
@@ -54,8 +54,73 @@ public void setTargetConnectionFactory(ConnectionFactory connectionFactory) {
importingExportHandler.setConnectionFactory(connectionFactory);
}
+ /**
+ * Rows of a table are reported without the rows which were skipped because of errors
+ */
public void setExportListener(ExportListener exportListener) {
- exporter.setExportListener(exportListener);
+ exporter.setExportListener(exportListener == null ? null : new WrittenRowsListener(exportListener,
+ importingExportHandler));
+ }
+
+ /**
+ * Reports the written rows of a table at its end: the read rows without the skipped rows
+ */
+ private static class WrittenRowsListener implements ExportListener {
+
+ private final ExportListener delegate;
+ private final ImportExportHandler handler;
+
+ WrittenRowsListener(ExportListener delegate, ImportExportHandler handler) {
+ this.delegate = delegate;
+ this.handler = handler;
+ }
+
+ @Override
+ public void onStart(ExportMode exportMode) {
+ delegate.onStart(exportMode);
+ }
+
+ @Override
+ public void onTableStart(String catalogName, String schemaName, String tableName, long totalRows) {
+ delegate.onTableStart(catalogName, schemaName, tableName, totalRows);
+ }
+
+ @Override
+ public void onTableProgress(String catalogName, String schemaName, String tableName, long processedRows,
+ long totalRows) {
+ delegate.onTableProgress(catalogName, schemaName, tableName, processedRows, totalRows);
+ }
+
+ @Override
+ public void onTableEnd(String catalogName, String schemaName, String tableName, long processedRows) {
+ delegate.onTableEnd(catalogName, schemaName, tableName, Math.max(0, processedRows
+ - handler.takeSkippedRows(tableName)));
+ }
+
+ @Override
+ public void onProgress(long processed, long total) {
+ delegate.onProgress(processed, total);
+ }
+
+ @Override
+ public void onMessage(String message) {
+ delegate.onMessage(message);
+ }
+
+ @Override
+ public void onError(String message, Throwable e) {
+ delegate.onError(message, e);
+ }
+
+ @Override
+ public void onEnd(boolean successful) {
+ delegate.onEnd(successful);
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return delegate.isCancelled();
+ }
}
/**
@@ -130,6 +195,19 @@ private void prepare() {
dialect = dialect.forVersion(targetMajorVersion, Math.max(targetMinorVersion, 0));
}
exportConfiguration.setDialect(dialect);
+ importConfiguration.setTableRecreated(exportConfiguration.isTableRecreated());
+ if (exportConfiguration.getWriteMode().isKeyed()) {
+ importConfiguration.setWriteMode(exportConfiguration.getWriteMode());
+ }
+ exportConfiguration.getKeyColumnNames().forEach(importConfiguration.getKeyColumnNames()::putIfAbsent);
+ // An import which goes on after errors skips the rows with errors
+ if (!exportConfiguration.isFailFast()) {
+ importConfiguration.setSkipErrors(true);
+ }
+ if (importConfiguration.getWriteMode().isKeyed() && !dialect.isUpsertSupported()) {
+ throw new ImpExpException(importConfiguration.getDbType().getDisplayName()
+ + " can not match rows by their key, please insert the rows");
+ }
// Creating user, database and schema is not the business of importing
exportConfiguration.setShowCreateUserSql(false);
exportConfiguration.setShowCreateCatalogSql(false);
diff --git a/src/main/java/com/github/sqljam/impexp/ParquetExporter.java b/src/main/java/com/github/sqljam/impexp/ParquetExporter.java
index fdab76a..7c68f8d 100644
--- a/src/main/java/com/github/sqljam/impexp/ParquetExporter.java
+++ b/src/main/java/com/github/sqljam/impexp/ParquetExporter.java
@@ -16,89 +16,51 @@
package com.github.sqljam.impexp;
import java.io.File;
-import java.lang.reflect.Field;
-import java.lang.reflect.Modifier;
-import java.sql.DatabaseMetaData;
import java.util.ArrayList;
-import java.util.LinkedHashMap;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
-import java.util.Optional;
+import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
-import com.github.sqljam.impexp.db.DuckDBDialect;
-import com.github.sqljam.impexp.db.DuckDBMetaDataOperations;
-import com.github.sqljam.jdbc.SharedConnectionFactory;
-import lombok.extern.slf4j.Slf4j;
/**
* @Description: ParquetExporter exports tables as an export package with Parquet files: schema.sql and
* constraints.sql for the target database (the same as SQL packages), data/<table>.parquet for
- * rows and manifest.json. Rows are copied into a temporary DuckDB workspace first (values which
- * Parquet can not hold exactly are kept as text), then written by DuckDB.
+ * rows and manifest.json. Values which Parquet can not hold exactly are kept as text.
* @Author: Fred Feng
* @Date: 26/03/2023
* @Version 1.0.0
*/
-@Slf4j
-public final class ParquetExporter {
+public final class ParquetExporter extends DataFileExporter {
public static final String PARQUET_EXTENSION = ".parquet";
public static final String DEFAULT_COMPRESSION = "ZSTD";
public static final String[] COMPRESSIONS = {"ZSTD", "SNAPPY", "GZIP", "UNCOMPRESSED"};
- private final File root;
- private final Exporter.ExportConfiguration configuration = new Exporter.ExportConfiguration();
- private ExportListener exportListener = ExportListener.NONE;
- private DbType targetDbType;
- private IdentifierCase identifierCase;
- private String targetCatalogName;
- private String targetSchemaName;
- private int targetMajorVersion = -1;
- private int targetMinorVersion = -1;
+ /**
+ * Parts are written a little smaller than the maximum size, the size of compressed rows varies
+ */
+ private static final double PART_FILL = 0.9;
+
private String compression = DEFAULT_COMPRESSION;
- private final List writtenFiles = new ArrayList<>();
+ private long maxFileSize;
public ParquetExporter(File root) {
- this.root = root;
- configuration.setShowCreateUserSql(false);
- configuration.setShowCreateCatalogSql(false);
- configuration.setShowCreateSchemaSql(false);
+ super(root);
}
/**
- * Source database and options, the same as {@link ScriptExporter#getConfiguration()}
+ * Maximum size of a Parquet file in bytes, 0 for no limit. Larger tables are split into parts of about this
+ * size like data files of sql scripts: orders.parquet, orders_2.parquet ... each part holds the next rows in
+ * the order of the table.
*/
- public Exporter.ExportConfiguration getConfiguration() {
- return configuration;
+ public void setMaxFileSize(long maxFileSize) {
+ this.maxFileSize = Math.max(0, maxFileSize);
}
- public void setExportListener(ExportListener exportListener) {
- this.exportListener = exportListener != null ? exportListener : ExportListener.NONE;
- }
-
- /**
- * Database type of schema.sql and constraints.sql. Default is the same as source database.
- */
- public void setTargetDbType(DbType targetDbType) {
- this.targetDbType = targetDbType;
- }
-
- public void setIdentifierCase(IdentifierCase identifierCase) {
- this.identifierCase = identifierCase;
- }
-
- public void setTargetCatalogName(String targetCatalogName) {
- this.targetCatalogName = targetCatalogName;
- }
-
- public void setTargetSchemaName(String targetSchemaName) {
- this.targetSchemaName = targetSchemaName;
- }
-
- public void setTargetVersion(int majorVersion, int minorVersion) {
- this.targetMajorVersion = majorVersion;
- this.targetMinorVersion = minorVersion;
+ public long getMaxFileSize() {
+ return maxFileSize;
}
/**
@@ -112,300 +74,45 @@ public String getCompression() {
return compression;
}
- public File getRoot() {
- return root;
- }
-
- public List getWrittenFiles() {
- return new ArrayList<>(writtenFiles);
- }
-
- public void exportDdlAndData() throws Exception {
- export(ExportMode.DDL_DATA);
- }
-
- public void exportDdl() throws Exception {
- export(ExportMode.DDL);
- }
-
- public void exportData() throws Exception {
- export(ExportMode.DATA);
- }
-
- public void export(ExportMode exportMode) throws Exception {
- writtenFiles.clear();
- if (configuration.getDbType() == null) {
- configuration.setDbType(DbType.forUrl(configuration.getUrl()));
- }
- boolean successful = false;
- exportListener.onStart(exportMode);
- PhaseListener phaseListener = new PhaseListener(exportListener);
- try {
- ExportManifest manifest;
- if (exportMode != ExportMode.DATA) {
- exportSchema(phaseListener);
- manifest = ExportManifest.read(root);
- } else {
- manifest = null;
- }
- if (manifest == null) {
- manifest = new ExportManifest();
- manifest.getSource().setDbType(configuration.getDbType());
- manifest.getTarget().setDbType(targetDbType != null ? targetDbType : configuration.getDbType());
- }
- List parquetFiles = new ArrayList<>();
- if (exportMode != ExportMode.DDL) {
- exportRows(phaseListener, manifest, parquetFiles);
- }
- writeManifest(manifest, exportMode, parquetFiles);
- successful = !phaseListener.isFailed();
- } catch (ExportCancelledException e) {
- throw e;
- } catch (Exception e) {
- exportListener.onError(e.getMessage(), e);
- throw e;
- } finally {
- exportListener.onEnd(successful);
- }
- }
-
- /**
- * schema.sql and constraints.sql by the script exporter
- */
- private void exportSchema(PhaseListener listener) throws Exception {
- exportListener.onMessage("Export schema");
- ScriptExporter scriptExporter = new ScriptExporter(root, DataFileStrategy.SINGLE_FILE, 0);
- copyConfiguration(configuration, scriptExporter.getConfiguration());
- scriptExporter.setTargetDbType(targetDbType);
- scriptExporter.setIdentifierCase(identifierCase);
- scriptExporter.setTargetCatalogName(targetCatalogName);
- scriptExporter.setTargetSchemaName(targetSchemaName);
- if (targetMajorVersion >= 0) {
- scriptExporter.setTargetVersion(targetMajorVersion, targetMinorVersion);
- }
- scriptExporter.setConstraintsSeparated(true);
- scriptExporter.setExportListener(listener.forPhase(false));
- scriptExporter.exportDdl();
- writtenFiles.addAll(scriptExporter.getWrittenFiles());
- }
-
- /**
- * Rows are copied into the workspace and written as Parquet files, one file per table
- */
- private void exportRows(PhaseListener listener, ExportManifest manifest,
- List parquetFiles) throws Exception {
- try (DuckDBWorkspace workspace = new DuckDBWorkspace(root)) {
- exportListener.onMessage("Copy rows into workspace: " + workspace.getFile());
- ImportExporter importExporter = new ImportExporter();
- copyConfiguration(configuration, importExporter.getExportConfiguration());
- Exporter.ExportConfiguration source = importExporter.getExportConfiguration();
- source.setIdReused(true);
- source.setTableRecreated(true);
- source.setIndexIncluded(false);
- source.setForeignKeyIncluded(false);
- source.setCommentIncluded(false);
- // Defaults of the same database type may use sequences
- source.setSequenceIncluded(true);
- ImportExportHandler.ImportConfiguration target = importExporter.getImportConfiguration();
- target.setDbType(DbType.DUCKDB);
- target.setUrl(workspace.getUrl());
- target.setConnectionPoolEnabled(false);
- DuckDBDialect dialect = new DuckDBDialect();
- dialect.setExactValues(true);
- dialect.setExactTargetDbType(targetDbType != null ? targetDbType : configuration.getDbType());
- // Table names of the source are kept
- dialect.setIdentifierCase(IdentifierCase.KEEP);
- importExporter.setTargetDialect(dialect);
- importExporter.setTargetConnectionFactory(new SharedConnectionFactory(workspace.getConnection()));
- importExporter.setExportListener(listener.forPhase(true));
- importExporter.exportDdlAndData();
- if (listener.isFailed()) {
- return;
- }
-
- List tables = workspace.getTables();
- boolean schemaQualified = tables.stream().map(t -> t[0]).distinct().count() > 1;
- DuckDBMetaDataOperations operations = new DuckDBMetaDataOperations();
- DatabaseMetaData metaData = workspace.getConnection().getMetaData();
- for (String[] table : tables) {
- if (exportListener.isCancelled()) {
- throw new ExportCancelledException();
- }
- String schema = table[0];
- String tableName = table[1];
- String fileName = (schemaQualified ? LobManifestWriter.toFileName(schema) + "." : "")
- + LobManifestWriter.toFileName(tableName) + PARQUET_EXTENSION;
- File file = new File(new File(root, ScriptExportHandler.DATA_DIR_NAME), fileName);
- Map fileMetadata = new LinkedHashMap<>();
- fileMetadata.put("sqljam_table", tableName);
- if (sourceSchema(schema) != null) {
- fileMetadata.put("sqljam_schema", sourceSchema(schema));
- }
- fileMetadata.put("sqljam_source", String.valueOf(configuration.getDbType()));
- workspace.writeParquet(schema, tableName, file, compression, fileMetadata);
- writtenFiles.add(file);
- long rows = workspace.countRows(schema, tableName);
- exportListener.onMessage(String.format("Write %d rows of %s to %s", rows, tableName, file));
-
- ExportManifest.TableEntry tableEntry = findTableEntry(manifest, sourceSchema(schema), tableName)
- .orElseGet(() -> {
- ExportManifest.TableEntry entry = new ExportManifest.TableEntry();
- entry.setSchema(sourceSchema(schema));
- entry.setName(tableName);
- manifest.getTables().add(entry);
- return entry;
- });
- tableEntry.setRows(rows);
- String path = ScriptExportHandler.DATA_DIR_NAME + "/" + fileName;
- tableEntry.getDataFiles().clear();
- tableEntry.getDataFiles().add(path);
- tableEntry.getIdentityColumns().clear();
- for (Map column : operations.getColumnInfos(metaData, null, schema, tableName)) {
- if ("YES".equalsIgnoreCase((String) column.get("IS_AUTOINCREMENT"))) {
- tableEntry.getIdentityColumns().add((String) column.get("COLUMN_NAME"));
- }
- }
- parquetFiles.add(new ExportManifest.FileEntry(path, ExportManifest.FileType.PARQUET));
- }
- }
+ @Override
+ protected DataFormat getDataFormat() {
+ return DataFormat.PARQUET;
}
- /**
- * Schema of the source table, tables of sources without schemas are in the default schema of the workspace
- */
- private String sourceSchema(String workspaceSchema) {
- return configuration.getDbType() != null && !configuration.getDbType().isSchemaSupported()
- && DuckDBWorkspace.DEFAULT_SCHEMA.equals(workspaceSchema) ? null : workspaceSchema;
+ @Override
+ protected String getExtension() {
+ return PARQUET_EXTENSION;
}
- private static Optional findTableEntry(ExportManifest manifest, String schema,
- String table) {
- return manifest.getTables().stream()
- .filter(entry -> table.equals(entry.getName())
- && (schema == null || entry.getSchema() == null || schema.equals(entry.getSchema())))
- .findFirst();
+ @Override
+ protected ExportManifest.FileType getFileType() {
+ return ExportManifest.FileType.PARQUET;
}
- /**
- * Files in the order of importing: schema.sql, Parquet files, constraints.sql
- */
- private void writeManifest(ExportManifest manifest, ExportMode exportMode,
- List parquetFiles) throws Exception {
- manifest.setDataFormat(DataFormat.PARQUET);
- manifest.setExportMode(exportMode);
- manifest.setStatus(ExportManifest.Status.COMPLETED);
- manifest.getOptions().put("compression", compression);
- List