diff --git a/README.md b/README.md index bdc6029..221e41b 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ **Move tables across databases. Schema, data and all.** -SqlJam is a desktop import/export tool for MySQL, PostgreSQL, Oracle, SQL Server, H2, SQLite and more. -It copies tables (with indexes, constraints, sequences and partitions) **directly into another database**, or saves them as a **self-describing SQL export package** that can be imported later. +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. ![Java](https://img.shields.io/badge/Java-17-ED8B00?logo=openjdk&logoColor=white) ![JavaFX](https://img.shields.io/badge/JavaFX-17-1f6feb) -![Tests](https://img.shields.io/badge/tests-303%20passed-3fb950) +![Tests](https://img.shields.io/badge/tests-578%20passed-3fb950) ![Coverage](https://img.shields.io/badge/coverage-91%25%20lines-3fb950) ![License](https://img.shields.io/badge/license-Apache%202.0-blue) ![Cross-database](https://img.shields.io/badge/cross--database%20matrix-passing-3fb950) @@ -33,6 +33,12 @@ It copies tables (with indexes, constraints, sequences and partitions) **directl | 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 | +| 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 | +| Custom and modern types | PostgreSQL enums and domains, MySQL, MariaDB and Oracle vectors, DuckDB unions and fixed arrays, ClickHouse variants, maps and tuples | --- @@ -49,7 +55,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/] P --> SI[ScriptImporter] --> TD + PP --> PI[ParquetImporter] --> TD ``` | Component | Role | @@ -57,6 +65,9 @@ flowchart LR | `Exporter` | Reads metadata, counts rows, pages rows (ordered by primary key) and feeds an `ExportHandler` | | `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 | +| `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 | | `MetaDataOperations` + `impexp.db.*` | Per-database metadata fixes (comments, identity, generated columns, partitions, sequences) | | `face.*` | JavaFX UI (AtlantaFX themes, Ikonli icons) | @@ -80,8 +91,11 @@ Only **Java 17+** is needed to run SqlJam. JavaFX and all JDBC drivers are insid | PostgreSQL | 16 | 9.x (`PostgreSQL9/10Dialect`) | | Oracle | 23ai Free | 11g (`Oracle11g/12cDialect`) | | SQL Server | 2022 | 2008 (`SQLServer2008Dialect`) | +| MariaDB | 11.8 | 10.3 | | H2 | 2.3 | 2.x | | SQLite | 3.46 | 3.x | +| DuckDB (OLAP) | 1.5 | 1.x | +| ClickHouse (OLAP) | 26.9 | 24.x | All JDBC drivers are bundled, no extra downloads. @@ -106,6 +120,7 @@ bin/ ├── sqljam.properties # configuration, edit and restart ├── sqljam.vmoptions # JVM options, one per line ├── sqljam.png # Dock icon of macOS +├── sqljam-tutorial.mp4 # tutorial video, export and import in under 3 minutes ├── sqljam-1.0.0-SNAPSHOT-sources.jar └── sqljam-1.0.0-SNAPSHOT-javadoc.jar ``` @@ -116,14 +131,22 @@ SqlJam starts with a splash screen while it loads the configuration, the data so

SqlJam splash screen

-1. Create a **data source** on the login page and click **Connect**. +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** (folder) or **Database** (another data source), press **Start**. +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**. +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. + +A short [tutorial video](docs/assets/sqljam-tutorial.mp4) walks through the example database: query rows, export into files, import the package into PostgreSQL, and copy the tables straight into SQL Server and Oracle. | Login | Export wizard | Progress | |---|---|---| | ![login](docs/assets/login.png) | ![export](docs/assets/export-wizard.png) | ![progress](docs/assets/progress.png) | +| Data query | Package viewer | Parquet files | +|---|---|---| +| ![query](docs/assets/table-data.png) | ![viewer](docs/assets/package-viewer.png) | ![parquet](docs/assets/import-parquet.png) | + Expected export package: ```text @@ -139,6 +162,8 @@ 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. + --- ## Examples @@ -160,6 +185,13 @@ config.setIdReused(true); // keep identity values exporter.setTargetDbType(DbType.POSTGRESQL); // generate PostgreSQL sql exporter.setTargetSchemaName("public"); exporter.exportDdlAndData(); + +// The same tables as a Parquet package: data/orders.parquet ... +ParquetExporter parquet = new ParquetExporter(new File("export-parquet")); +// parquet.getConfiguration() is configured like the configuration above +parquet.setTargetDbType(DbType.POSTGRESQL); // schema.sql and constraints.sql for PostgreSQL +parquet.setCompression("ZSTD"); +parquet.exportDdlAndData(); ``` **Output**: `export/schema.sql`, `export/data/orders.sql`, `export/data/orders_2.sql`, `export/lob/…`, `export/manifest.json`: @@ -205,6 +237,16 @@ importer.exportDdlAndData(); **Output**: `0% … 100%`. Tables, keys, indexes, comments and sequences in `demo.hr`. Identity columns continue from the imported max value. +Rows of a query and copies in the same schema use the same API: + +```java +// Only some columns and rows of EMPLOYEES, the target table is created with these columns +source.getTableQueries().put("EMPLOYEES", new TableQuery(List.of("EMPLOYEE_ID", "NAME", "SALARY"), + "SALARY > 1000", null, "SALARY DESC")); +// Copies next to the originals: EMPLOYEES_COPY, keys and indexes get their own names +target.setTableNamePattern("{table}_copy"); +``` + ### 3. Import an export package ```java @@ -216,7 +258,12 @@ 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. +**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`: + +```java +ParquetImporter importer = new ParquetImporter(connection, DbType.POSTGRESQL); +long rows = importer.importFiles(List.of(new File("sales-2024.parquet")), "sales", ParquetImporter.Mode.CREATE); +``` ### 4. Type translation (cross-database) @@ -229,6 +276,10 @@ try (Connection connection = DriverManager.getConnection( | Oracle `NUMBER(9)` / `NUMBER(19)` | `int4` / `int8` | same | `int` / `bigint` | `int` / `bigint` | | SQL Server `datetime2(7)` | `timestamp` (µs) | `TIMESTAMP(7)` | same | `datetime(6)` | | SQL Server `tinyint` (0 to 255) | `int2` | `NUMBER(5)` | same | `smallint` | +| PostgreSQL enum / domain | same (type created in the target schema) | `VARCHAR2` / base type | `varchar` / base type | `varchar` / base type | +| MySQL / Oracle `VECTOR` | `text` | same | `nvarchar(max)` | same | +| ClickHouse `Nullable(String)` / `Array(T)` | `text` | `CLOB` | `nvarchar(max)` | `longtext` | +| DuckDB `HUGEINT` / `UNION` | `numeric(38, 0)` / `text` | `NUMBER(38,0)` / `CLOB` | `decimal(38,0)` / `nvarchar(max)` | `decimal(38,0)` / `longtext` | ### Best practices @@ -238,6 +289,8 @@ try (Connection connection = DriverManager.getConnection( | Pick the **target version** when exporting for an older server | e.g. `11.2` generates sequences + triggers instead of identity columns | | 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` | +| Copy with a **target table name** | `{table}_copy` keeps the originals, a copy in the same schema without a new name is refused | --- @@ -293,6 +346,9 @@ Export options (UI / `ExportConfiguration`): | `indexIncluded` / `foreignKeyIncluded` / `commentIncluded` / `sequenceIncluded` | `true` | Objects to copy | | `failFast` | `true` | Stop on the first data error | | `identifierCase` | `AUTO` | `AUTO`, `KEEP`, `LOWER`, `UPPER` | +| `dataFormat` / `compression` | `SQL` / `ZSTD` | `SQL` or `PARQUET` packages, Parquet compression `ZSTD`, `SNAPPY`, `GZIP`, `UNCOMPRESSED` | +| `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 | --- @@ -326,12 +382,15 @@ Environment: Apple M2 Max, 32 GB, JDK 17. MySQL/PostgreSQL local, Oracle/SQL Ser | Project site | [paganini2008.github.io/sqljam](https://paganini2008.github.io/sqljam/) | | Blog (English / 中文 / Medium) | [docs/blogger](docs/blogger) | | Change logs | [change_logs](change_logs) | -| Core API | `com.github.sqljam.impexp` (`Exporter`, `ScriptExporter`, `ImportExporter`, `ScriptImporter`) | +| Core API | `com.github.sqljam.impexp` (`Exporter`, `ScriptExporter`, `ImportExporter`, `ScriptImporter`, `ParquetExporter`, `ParquetImporter`) | **FAQ** | Question | Answer | |---|---| +| 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 | | 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 | @@ -344,7 +403,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` (≥ 80% coverage gate) → PR | +| Pull requests | Fork → branch → `mvn verify` (≥ 90% coverage gate) → 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 e21036b..b05e75a 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 906bdfd..a29e796 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 new file mode 100644 index 0000000..476e159 Binary files /dev/null and b/docs/assets/import-parquet.png differ diff --git a/docs/assets/login.png b/docs/assets/login.png index 0ca255c..96f635a 100644 Binary files a/docs/assets/login.png and b/docs/assets/login.png differ diff --git a/docs/assets/main-dark.png b/docs/assets/main-dark.png index 72710d3..c2ef415 100644 Binary files a/docs/assets/main-dark.png and b/docs/assets/main-dark.png differ diff --git a/docs/assets/main-light.png b/docs/assets/main-light.png index e00ee57..9a3fc8d 100644 Binary files a/docs/assets/main-light.png and b/docs/assets/main-light.png differ diff --git a/docs/assets/main-nord.png b/docs/assets/main-nord.png index 9525589..418e226 100644 Binary files a/docs/assets/main-nord.png and b/docs/assets/main-nord.png differ diff --git a/docs/assets/package-viewer.png b/docs/assets/package-viewer.png new file mode 100644 index 0000000..f262b2b Binary files /dev/null and b/docs/assets/package-viewer.png differ diff --git a/docs/assets/sqljam-tutorial.mp4 b/docs/assets/sqljam-tutorial.mp4 new file mode 100644 index 0000000..8c5bdb0 Binary files /dev/null and b/docs/assets/sqljam-tutorial.mp4 differ diff --git a/docs/assets/table-data.png b/docs/assets/table-data.png index 17da667..292df00 100644 Binary files a/docs/assets/table-data.png and b/docs/assets/table-data.png differ diff --git a/docs/blogger/sqljam-en.md b/docs/blogger/sqljam-en.md index 50d7e65..28daa11 100644 --- a/docs/blogger/sqljam-en.md +++ b/docs/blogger/sqljam-en.md @@ -6,8 +6,8 @@ **SqlJam**: *Move tables across databases. Schema, data and all.* -A Java 17 + JavaFX desktop tool that copies tables between **MySQL, PostgreSQL, Oracle, SQL Server, H2, SQLite** and more, in any direction: -**import directly** into a target database, or save a self-describing **SQL export package** and import it later. +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 main window](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/main-dark.png) @@ -25,6 +25,11 @@ SqlJam reads **one metadata tree** and generates SQL with a **dialect chosen by | Huge scripts | Data files split at 10 MB: `orders.sql`, `orders_2.sql` … | | 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 | +| 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 | +| Custom and modern types | PostgreSQL enums and domains, vectors, unions, maps, tuples and variants | ## 3. Quick Start @@ -43,7 +48,8 @@ bin/ ├── sqljam.sh / sqljam.bat # launchers, pick the jar of the platform ├── sqljam.properties # configuration, edit and restart ├── sqljam.vmoptions # JVM options, one per line -└── sqljam.png # Dock icon of macOS +├── sqljam.png # Dock icon of macOS +└── sqljam-tutorial.mp4 # tutorial video, export and import in under 3 minutes ``` `sqljam.properties` and `sqljam.vmoptions` sit next to the runnable jar. Edit them and restart, no rebuild needed. @@ -52,10 +58,20 @@ A splash screen shows the loading of the configuration, the data sources and the

SqlJam splash screen

+The first start brings an **Example Shop (H2)** data source with a few e-commerce tables (categories, customers, products, orders, order_items) to try everything right away. **Example** on the login page or **Help → Restore Example Database** brings it back at any time. + +Right-click a data source to **Connect**, and once it is connected the same menu offers **Disconnect**, which also closes its table tabs. When an import or export needs a target data source, the **+** button next to it creates one on the spot. **Exit** sits at the right end of the toolbar. **Help → About SqlJam** shows the version, the home page, the repository and how to reach the author, and the GitHub icon at the bottom right opens the repository. + +A short [tutorial video](https://github.com/paganini2008/sqljam/blob/master/docs/assets/sqljam-tutorial.mp4) walks through the example database: query rows, export into files, import the package into PostgreSQL, and copy the tables straight into SQL Server and Oracle. + | 1. Log in to a data source | 2. Export wizard | 3. Progress | |---|---|---| | ![login](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/login.png) | ![export](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/export-wizard.png) | ![progress](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/progress.png) | +| 4. Data query | 5. Package viewer | 6. Parquet files | +|---|---|---| +| ![query](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/table-data.png) | ![viewer](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/package-viewer.png) | ![parquet](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/import-parquet.png) | + ## 4. Requirements Only **Java 17+** is needed to run SqlJam. JavaFX and the JDBC drivers are inside the runnable jar, and building needs no Maven installation thanks to the Maven Wrapper (`./mvnw`). @@ -73,7 +89,10 @@ Only **Java 17+** is needed to run SqlJam. JavaFX and the JDBC drivers are insid | PostgreSQL | 9.x to 16 | | Oracle | 11g to 23ai | | SQL Server | 2008 to 2022 | +| MariaDB | 10.3 to 11.x | | H2 / SQLite | 2.x / 3.x | +| DuckDB (OLAP) | 1.x | +| ClickHouse (OLAP) | 24.x to 26.x | All JDBC drivers are bundled in the fat jar. @@ -88,11 +107,14 @@ 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 ``` - **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. ## 6. Code Examples @@ -121,6 +143,15 @@ export/ └── manifest.json ``` +The same tables as Parquet files: + +```java +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 +``` + ### Example 2: Oracle → SQL Server directly (schema created) ```java @@ -142,11 +173,22 @@ importer.exportDdlAndData(); **Output**: tables, keys, indexes, foreign keys, comments and sequences in `demo.hr`. Identities continue from the imported max value. +Rows of a query and copies in the same schema: + +```java +source.getTableQueries().put("EMPLOYEES", new TableQuery(List.of("EMPLOYEE_ID", "NAME", "SALARY"), + "SALARY > 1000", null, "SALARY DESC")); // target table created with these columns +target.setTableNamePattern("{table}_copy"); // EMPLOYEES_COPY next to EMPLOYEES +``` + | Source column | → PostgreSQL | → Oracle | → SQL Server | |---|---|---|---| | MySQL `bigint unsigned` | `numeric(20, 0)` | `NUMBER(20,0)` | `decimal(20,0)` | | PostgreSQL `jsonb` | same | `CLOB` | `nvarchar(max)` | | SQL Server `datetime2(7)` | `timestamp` | `TIMESTAMP(7)` | same | +| PostgreSQL enum / domain | same (created in the target schema) | `VARCHAR2` / base type | `varchar` / base type | +| MySQL / Oracle `VECTOR` | `text` | same | `nvarchar(max)` | +| ClickHouse `Array(T)` / `Map(K, V)` | `text` | `CLOB` | `nvarchar(max)` | ## 7. Configuration @@ -179,7 +221,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**: 303 tests (full cross-database import matrix, export package round trips, old-version SQL executed on real servers, UI and boundary tests), 91% line coverage. +**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. ## 9. Design Trade-offs @@ -188,6 +230,9 @@ 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. +- **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. ## 10. Summary @@ -199,3 +244,5 @@ Environment: Apple M2 Max, 32 GB, JDK 17. MySQL/PostgreSQL local, Oracle/SQL Ser 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. +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 f5817c0..ad023d6 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 **MySQL, PostgreSQL, Oracle, SQL Server, H2, SQLite** and more, in any direction. Import directly into a target database, or save a self-describing **SQL export package** 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 or **Parquet** files and import it later. Source: https://github.com/paganini2008/sqljam @@ -22,6 +22,11 @@ SqlJam reads one metadata tree and generates SQL with a dialect chosen by target - **Huge scripts** → data files split at 10 MB: orders.sql, orders_2.sql … - **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 +- **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 +- **Custom and modern types** → PostgreSQL enums and domains, vectors, unions, maps, tuples and variants ## Quick Start @@ -37,11 +42,13 @@ A splash screen shows the loading of the configuration, the data sources and the ![Splash screen](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/splash.png) -Step 1. log in to a data source: +A short tutorial video walks through all of the steps below with the example database: https://github.com/paganini2008/sqljam/blob/master/docs/assets/sqljam-tutorial.mp4 + +Step 1. log in to a data source. 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: ![Login](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/login.png) -Step 2. choose tables and a target in the export wizard: +Step 2. choose tables and a target in the export wizard. A target data source that does not exist yet is created from the + button next to it: ![Export wizard](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/export-wizard.png) @@ -49,6 +56,18 @@ Step 3. watch the progress: ![Progress](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/progress.png) +Step 4. narrow the rows of a table on the Data tab and export just those rows. Right-click a connected data source to Disconnect when you are done, and Help → About SqlJam shows the version, the home page and the repository: + +![Data query](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/table-data.png) + +Step 5. look inside an export package, Parquet files included: + +![Package viewer](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/package-viewer.png) + +Step 6. load Parquet files of other tools into a table: + +![Parquet files](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/import-parquet.png) + ## Requirements Only Java 17+ is needed. JavaFX and the JDBC drivers are inside the runnable jar, and the Maven Wrapper builds without a Maven installation. @@ -66,7 +85,9 @@ Databases: - PostgreSQL 9.x to 16 - Oracle 11g to 23ai - SQL Server 2008 to 2022 +- MariaDB 10.3 to 11.x - H2 2.x, SQLite 3.x +- DuckDB 1.x and ClickHouse 24.x to 26.x (OLAP) - All JDBC drivers are bundled ## How It Works @@ -76,6 +97,7 @@ 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. ## Code Examples @@ -107,6 +129,15 @@ export/ manifest.json ``` +The same tables as Parquet files: + +``` +ParquetExporter parquet = new ParquetExporter(new File("export-parquet")); +parquet.setTargetDbType(DbType.POSTGRESQL); +parquet.setCompression("ZSTD"); +parquet.exportDdlAndData(); +``` + ### Example 2: Oracle → SQL Server directly ``` @@ -128,11 +159,22 @@ importer.exportDdlAndData(); Output: tables, keys, indexes, foreign keys, comments and sequences in demo.hr. Identities continue from the imported max value. +Rows of a query and copies in the same schema: + +``` +source.getTableQueries().put("EMPLOYEES", new TableQuery(List.of("EMPLOYEE_ID", "NAME", "SALARY"), + "SALARY > 1000", null, "SALARY DESC")); +target.setTableNamePattern("{table}_copy"); +``` + Type translation examples: - MySQL bigint unsigned → PostgreSQL numeric(20, 0) · Oracle NUMBER(20,0) · SQL Server decimal(20,0) - PostgreSQL jsonb → Oracle CLOB · SQL Server nvarchar(max) · MySQL json - SQL Server datetime2(7) → PostgreSQL timestamp · Oracle TIMESTAMP(7) · MySQL datetime(6) +- PostgreSQL enum / domain → same type created in the target schema · varchar or the base type elsewhere +- MySQL / Oracle VECTOR → PostgreSQL text · SQL Server nvarchar(max) · same type kept +- ClickHouse Array(T) / Map(K, V) → PostgreSQL text · Oracle CLOB · SQL Server nvarchar(max) ## Configuration @@ -160,7 +202,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: 303 tests (full cross-database import matrix, export package round trips, old-version SQL executed on real servers, UI and boundary tests), 91% line coverage. +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. ## Design Trade-offs @@ -169,6 +211,9 @@ Quality: 303 tests (full cross-database import matrix, export package round trip - **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. +- **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. ## Summary @@ -180,5 +225,7 @@ Quality: 303 tests (full cross-database import matrix, export package round trip 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. +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 d6f223f..4a07049 100644 --- a/docs/blogger/sqljam-zh.md +++ b/docs/blogger/sqljam-zh.md @@ -6,8 +6,8 @@ **SqlJam**:*Move tables across databases. Schema, data and all.* -一个 Java 17 + JavaFX 的桌面导入导出工具,支持 **MySQL、PostgreSQL、Oracle、SQL Server、H2、SQLite** 等数据库任意互导: -可以**直接导入目标库**,也可以导出为带描述文件的 **SQL 导出包**,之后再成对导入。 +一个 Java 17 + JavaFX 的桌面导入导出工具,支持关系型与 OLAP 数据库(**MySQL、MariaDB、PostgreSQL、Oracle、SQL Server、H2、SQLite、DuckDB、ClickHouse** 等)任意互导: +可以**直接导入目标库**,也可以在同一个 schema 里复制表,或者导出为带描述文件的 **SQL / Parquet 导出包**,之后再成对导入。 ![SqlJam 主界面](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/main-dark.png) @@ -25,6 +25,11 @@ SqlJam 用**一棵元数据树 + 按目标库和版本选择的方言**,把这 | 脚本太大 | 数据文件按 10 MB 切分:`orders.sql`、`orders_2.sql`… | | LOB 难处理 | LOB 单独存文件,按主键回填 | | 脚本没有上下文 | `manifest.json`:源、目标、选项、文件 SHA-256、行数 | +| 分析要 Parquet | 内置 DuckDB 生成 Parquet 导出包,也能把其他工具产出的 Parquet 文件加载进任意表 | +| 数仓和业务库割裂 | DuckDB、ClickHouse 作为 OLAP 数据源,和关系型数据库分组展示 | +| 只要表的一部分 | 数据页查询(列、WHERE、GROUP BY、ORDER BY),只导出查询结果的行 | +| 原地复制一份表 | 同一 schema 内按 `{table}_copy` 复制,键、索引、约束自动改名,原表受保护 | +| 自定义和新型类型 | PostgreSQL 枚举与域、向量、联合、Map、Tuple、Variant | ## 3. Quick Start @@ -43,7 +48,8 @@ bin/ ├── sqljam.sh / sqljam.bat # 启动脚本,自动选择本平台的 jar ├── sqljam.properties # 外置配置,改完重启即可 ├── sqljam.vmoptions # JVM 参数,一行一个 -└── sqljam.png # macOS 程序坞图标 +├── sqljam.png # macOS 程序坞图标 +└── sqljam-tutorial.mp4 # 教学视频,3 分钟内看完导出与导入 ``` `sqljam.properties`、`sqljam.vmoptions` 和可运行 jar 放在一起。整个目录拷到任何地方都能用,双击启动也能找到配置。 @@ -52,10 +58,20 @@ bin/

SqlJam 启动画面

+首次启动会自带一个 **Example Shop (H2)** 示例数据源,包含几张电商表(categories、customers、products、orders、order_items),装好就能直接体验。删掉了也没关系,登录页的 **Example** 按钮或菜单 **Help → Restore Example Database** 随时可以恢复。 + +右键数据源选 **Connect**,连上之后同一个菜单变成 **Disconnect**,断开时它的表页签一起关闭。导入导出选目标数据源时,旁边的 **+** 按钮可以当场新建一个。工具栏最右边是 **Exit**。**Help → About SqlJam** 里有版本、主页、仓库地址和作者联系方式,右下角的 GitHub 图标点一下直接打开仓库。 + +[教学视频](https://github.com/paganini2008/sqljam/blob/master/docs/assets/sqljam-tutorial.mp4)以示例库演示一遍:筛选行、导出到文件、导出包导入 PostgreSQL,再直接复制到 SQL Server 和 Oracle。 + | 1. 登录数据源 | 2. 导出向导 | 3. 进度 | |---|---|---| | ![login](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/login.png) | ![export](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/export-wizard.png) | ![progress](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/progress.png) | +| 4. 数据查询 | 5. 导出包浏览 | 6. Parquet 文件 | +|---|---|---| +| ![query](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/table-data.png) | ![viewer](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/package-viewer.png) | ![parquet](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/import-parquet.png) | + ## 4. Requirements 运行只需要 **Java 17+**。JavaFX 和 JDBC 驱动都打进了可运行 jar,从源码构建也不需要安装 Maven,项目自带 Maven Wrapper(`./mvnw`)。 @@ -73,7 +89,10 @@ bin/ | PostgreSQL | 9.x ~ 16 | | Oracle | 11g ~ 23ai | | SQL Server | 2008 ~ 2022 | +| MariaDB | 10.3 ~ 11.x | | H2 / SQLite | 2.x / 3.x | +| DuckDB(OLAP) | 1.x | +| ClickHouse(OLAP) | 24.x ~ 26.x | JDBC 驱动全部打进 fat jar,无需额外下载。 @@ -88,11 +107,14 @@ 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 ``` - **读一次元数据**:`XxxMetaDataOperations` 按库补全注释、自增、生成列、分区、序列。 - **按目标生成 SQL**:`DbType.createDialect(major, minor)` 选出版本子类。 - **数据流**:先统计行数(百分比进度)→ 按主键分页读取 → 值归一化 → 批量写入或生成字面量。 +- **Parquet**:数据经过内置的 DuckDB 中转,`COPY ... TO 'x.parquet'` 写出,`read_parquet` 读回。 ## 6. Code Examples @@ -121,6 +143,15 @@ export/ └── manifest.json ``` +同样的表导出为 Parquet: + +```java +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 +``` + ### Example 2:Oracle 直接导入 SQL Server(自动建 schema) ```java @@ -142,11 +173,22 @@ importer.exportDdlAndData(); **Output**:表、主键、索引、外键、注释、序列都在 `demo.hr`,自增从导入的最大值继续。 +按查询导出部分行,或者在同一 schema 里复制: + +```java +source.getTableQueries().put("EMPLOYEES", new TableQuery(List.of("EMPLOYEE_ID", "NAME", "SALARY"), + "SALARY > 1000", null, "SALARY DESC")); // 目标表按这几列自动创建 +target.setTableNamePattern("{table}_copy"); // 在 EMPLOYEES 旁边生成 EMPLOYEES_COPY +``` + | 源列 | → PostgreSQL | → Oracle | → SQL Server | |---|---|---|---| | MySQL `bigint unsigned` | `numeric(20, 0)` | `NUMBER(20,0)` | `decimal(20,0)` | | PostgreSQL `jsonb` | same | `CLOB` | `nvarchar(max)` | | SQL Server `datetime2(7)` | `timestamp` | `TIMESTAMP(7)` | same | +| PostgreSQL 枚举 / 域 | same(在目标 schema 创建) | `VARCHAR2` / 基础类型 | `varchar` / 基础类型 | +| MySQL / Oracle `VECTOR` | `text` | same | `nvarchar(max)` | +| ClickHouse `Array(T)` / `Map(K, V)` | `text` | `CLOB` | `nvarchar(max)` | ## 7. Configuration @@ -179,7 +221,7 @@ importer.exportDdlAndData(); 环境:Apple M2 Max / 32 GB / JDK 17。MySQL、PostgreSQL 本机,Oracle、SQL Server 在 Docker。默认配置。 -**质量**:303 个测试(含完整的跨库互导矩阵、导出包回放、旧版本语法在真实库上执行、界面功能与边界测试),行覆盖率 91%。 +**质量**:578 个测试(含覆盖每种数据库全部字段类型的跨库互导矩阵、导出包与 Parquet 回放、同 schema 复制、旧版本语法在真实库上执行、界面功能与边界测试),行覆盖率 91%。 ## 9. 设计取舍(Design Trade-offs) @@ -188,6 +230,9 @@ importer.exportDdlAndData(); - **逐表复制**:对生产库的压力可预期。 - **导出包是纯 SQL**:可读、可改、可移植。直接导入走批量写入,追求速度。 - **按版本生成 SQL**:老版本数据库拿到的是它能理解的语法,而不是最低公分母。 +- **Parquet 交给 DuckDB**:一个内置引擎负责读写 Parquet,不引入 Hadoop 依赖。 +- **分组结果用于浏览**:导出的始终是表的真实行,GROUP BY 的结果是这些行的视图。 +- **复制要有新表名**:同一 schema 内复制需要 `{table}_copy` 这样的表名,原表永远不会被覆盖。 ## 10. Summary @@ -199,3 +244,5 @@ importer.exportDdlAndData(); 6. 时间、无符号、bit、money、UUID、JSON 等值跨库不失真。 7. 连接池 + 批量写入,直接导入 4 万 ~ 9.5 万行/秒。 8. 暗色主流界面,7 种主题,百分比进度与取消。 +9. Parquet 导出包与外部 Parquet 文件加载,DuckDB、ClickHouse 作为 OLAP 数据源。 +10. 表数据可查询,只导出查询结果,也能在同一 schema 内复制表。 diff --git a/docs/index.html b/docs/index.html index 98a0868..cee8247 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,7 +4,7 @@ SqlJam · Move tables across databases - + @@ -185,15 +185,17 @@

SqlJam

Move tables across databases. Schema, data and all.

-

A desktop import/export tool that copies tables, indexes, constraints, sequences, partitions and rows between databases, directly or through a self-describing SQL 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 or Parquet export package you can import later.

- MySQLPostgreSQLOracle + MySQLMariaDBPostgreSQLOracle SQL ServerH2SQLite + DuckDBClickHouse
SqlJam main window with the table structure of an employee table
@@ -229,6 +231,10 @@

Everything a table needs on the other side

Verified imports

Status, target type and SHA-256 checked before running.

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.

+

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.

@@ -243,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.

+
3

Rows: counted, paged by primary key, normalized and written in batches, or written to Parquet by an embedded DuckDB.

@@ -289,12 +295,13 @@

Result

Use cases
-

Three ways to move a table

+

Ways to move a table

- + +
Export wizard @@ -304,7 +311,7 @@

Three ways to move a table

Progress dialog -

Copy straight into another data source. Target schemas are created, identities continue from the imported max value.

+

Copy straight into another data source, or create one on the spot with the + button next to it. Target schemas are created, identities continue from the imported max value.

target.setDbType(DbType.SQLSERVER);
 target.setTargetSchemaName("hr");   // created if missing
 importer.exportDdlAndData();
@@ -316,8 +323,18 @@

Three ways to move a table

importer.importDirectory(new File("export"));
- Table data viewer -

Browse databases, schemas and tables. Inspect columns, indexes, foreign keys and DDL in any dialect, then page through rows.

+ Table data viewer with a query +

Browse databases, schemas and tables. Narrow the rows with columns, WHERE, GROUP BY and ORDER BY, then export just those rows. A missing target table is created with the selected columns.

+
source.getTableQueries().put("EMPLOYEES", new TableQuery(columns,
+        "SALARY > 1000", null, "SALARY DESC"));
+target.setTableNamePattern("{table}_copy");   // copies in the same schema
+
+
+ Package viewer with a Parquet file +

Export packages of Parquet files, look inside them with sizes, times and previews, and load Parquet files of other tools into any table.

+
parquet.setCompression("ZSTD");
+parquet.exportDdlAndData();   // data/<table>.parquet
+importer.importFiles(files, "sales", ParquetImporter.Mode.CREATE);
@@ -330,7 +347,7 @@

Tens of thousands of rows per second

95.3krows/s · H2 → Oracle
100%cross-database pairs pass
-
303automated tests
+
578automated tests
91%line coverage
Throughput chart
@@ -360,7 +377,7 @@

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.

Browse →
+

API Reference

Exporter, ScriptExporter, ImportExporter, ScriptImporter, ParquetExporter, ParquetImporter.

Browse →

Architecture

Metadata tree, dialects and version subclasses.

Read →

Blog

English, 中文 and a Medium edition.

Read →
diff --git a/pom.xml b/pom.xml index 23809b1..8e06051 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,8 @@ 17.0.13 2.0.1 12.3.1 + 1.5.6.0 + 0.10.0 1.18.34 com.github.sqljam.face.Launcher @@ -65,7 +67,7 @@ commons-io commons-io - 2.17.0 + 2.20.0 com.zaxxer @@ -112,6 +114,11 @@ + + org.mariadb.jdbc + mariadb-java-client + 3.5.10 + org.postgresql postgresql @@ -142,6 +149,17 @@ sqlite-jdbc 3.46.1.3 + + + org.duckdb + duckdb_jdbc + ${duckdb.version} + + + com.clickhouse + clickhouse-jdbc + ${clickhouse.version} + @@ -553,6 +571,13 @@ *.png + + + ${project.basedir}/docs/assets + + sqljam-tutorial.mp4 + + @@ -593,4 +618,26 @@ + + + + ui + + + + org.apache.maven.plugins + maven-surefire-plugin + + + glass + false + 30000 + en + + + + + + + diff --git a/src/assembly/linux.xml b/src/assembly/linux.xml index f98971c..2db831a 100644 --- a/src/assembly/linux.xml +++ b/src/assembly/linux.xml @@ -19,6 +19,10 @@ META-INF/MANIFEST.MF + + libduckdb_java.so_linux_arm64 + libduckdb_java.so_osx_universal + libduckdb_java.so_windows_amd64 / diff --git a/src/assembly/mac-aarch64.xml b/src/assembly/mac-aarch64.xml index d9823a4..a89b6de 100644 --- a/src/assembly/mac-aarch64.xml +++ b/src/assembly/mac-aarch64.xml @@ -19,6 +19,10 @@ META-INF/MANIFEST.MF + + libduckdb_java.so_linux_amd64 + libduckdb_java.so_linux_arm64 + libduckdb_java.so_windows_amd64 / diff --git a/src/assembly/mac.xml b/src/assembly/mac.xml index fe206f0..7bf2e2f 100644 --- a/src/assembly/mac.xml +++ b/src/assembly/mac.xml @@ -19,6 +19,10 @@ META-INF/MANIFEST.MF + + libduckdb_java.so_linux_amd64 + libduckdb_java.so_linux_arm64 + libduckdb_java.so_windows_amd64 / diff --git a/src/assembly/win.xml b/src/assembly/win.xml index 9981152..c738f42 100644 --- a/src/assembly/win.xml +++ b/src/assembly/win.xml @@ -19,6 +19,10 @@ META-INF/MANIFEST.MF + + libduckdb_java.so_linux_amd64 + libduckdb_java.so_linux_arm64 + libduckdb_java.so_osx_universal / diff --git a/src/main/java/com/github/sqljam/config/Config.java b/src/main/java/com/github/sqljam/config/Config.java index 3ea4b78..b832df3 100644 --- a/src/main/java/com/github/sqljam/config/Config.java +++ b/src/main/java/com/github/sqljam/config/Config.java @@ -18,6 +18,7 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; import java.io.Reader; import java.io.Writer; import java.net.URISyntaxException; @@ -67,7 +68,7 @@ public Config(File appFile, File userFile) { this.userFile = userFile; try (InputStream in = Config.class.getResourceAsStream("/" + FILE_NAME)) { if (in != null) { - defaults.load(new java.io.InputStreamReader(in, StandardCharsets.UTF_8)); + defaults.load(new InputStreamReader(in, StandardCharsets.UTF_8)); } } catch (IOException e) { throw new IllegalStateException("Unable to load default configuration", e); diff --git a/src/main/java/com/github/sqljam/face/SqlJamApplication.java b/src/main/java/com/github/sqljam/face/SqlJamApplication.java index 17b8ce4..3a78562 100644 --- a/src/main/java/com/github/sqljam/face/SqlJamApplication.java +++ b/src/main/java/com/github/sqljam/face/SqlJamApplication.java @@ -15,10 +15,15 @@ */ package com.github.sqljam.face; +import java.io.File; +import java.io.IOException; + import com.github.sqljam.config.Config; import com.github.sqljam.face.model.AppSettings; import com.github.sqljam.face.model.ConnectionProfile; import com.github.sqljam.face.service.ConnectionStore; +import com.github.sqljam.face.service.ExampleDatabase; +import com.github.sqljam.face.service.JsonStore; import com.github.sqljam.face.service.SettingsStore; import com.github.sqljam.face.view.AppContext; import com.github.sqljam.face.view.Branding; @@ -68,7 +73,17 @@ public void init() { settingsStore = new SettingsStore(); notifyPreloader(new SplashPreloader.Status(Messages.get("splash.loadingDataSources"), 0.35)); + File connectionsFile = Config.getInstance().getFile("sqljam.connections.file", + new File(JsonStore.DEFAULT_DIRECTORY, "connections.json")); profileRegistry = new ProfileRegistry(new ConnectionStore()); + // A new user starts with an example database of a small online shop + if (!connectionsFile.exists()) { + notifyPreloader(new SplashPreloader.Status(Messages.get("splash.preparingExample"), 0.38)); + } + ExampleDatabase exampleDatabase = new ExampleDatabase(); + exampleDatabase.createAtFirstStart(connectionsFile).ifPresent(this::saveExample); + // The database file of the example data source may be deleted by the user + exampleDatabase.repairAtStart(profileRegistry.getProfiles()); // Loading JDBC drivers takes a while, it is done here instead of at the first connection DbType[] dbTypes = DbType.values(); @@ -90,6 +105,16 @@ public void init() { Branding.getIcons(); } + private void saveExample(ConnectionProfile profile) { + try { + profileRegistry.saveProfile(profile, true); + } catch (IOException e) { + if (log.isWarnEnabled()) { + log.warn("Unable to save the data source of the example database", e); + } + } + } + @Override public void start(Stage primaryStage) { this.stage = primaryStage; diff --git a/src/main/java/com/github/sqljam/face/model/ConnectionProfile.java b/src/main/java/com/github/sqljam/face/model/ConnectionProfile.java index 0ca2458..bf19175 100644 --- a/src/main/java/com/github/sqljam/face/model/ConnectionProfile.java +++ b/src/main/java/com/github/sqljam/face/model/ConnectionProfile.java @@ -58,8 +58,12 @@ public String getJdbcUrl() { * Jdbc url connecting to the given database (catalog), PostgreSQL connects to one database per connection */ public String getJdbcUrl(String catalog) { + if (dbType != null && dbType.isFileBased()) { + // The catalog of a database file is the file itself + catalog = null; + } if (StringUtils.isNotBlank(url) && (StringUtils.isBlank(catalog) || StringUtils.equals(catalog, database))) { - return url; + return dbType != null ? dbType.normalizeUrl(url) : url; } return dbType.getUrl(hostname, port, StringUtils.defaultIfBlank(catalog, database)); } 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 ae2317b..1bfc4b0 100644 --- a/src/main/java/com/github/sqljam/face/model/TransferRequest.java +++ b/src/main/java/com/github/sqljam/face/model/TransferRequest.java @@ -17,14 +17,19 @@ import java.io.File; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import com.github.sqljam.impexp.DataFileStrategy; +import com.github.sqljam.impexp.DataFormat; import com.github.sqljam.impexp.DbType; import com.github.sqljam.impexp.ExportMode; import com.github.sqljam.impexp.Exporter; import com.github.sqljam.impexp.IdentifierCase; +import com.github.sqljam.impexp.ParquetExporter; import com.github.sqljam.impexp.ScriptExportHandler; +import com.github.sqljam.impexp.TableQuery; import lombok.Getter; import lombok.Setter; @@ -81,10 +86,27 @@ public enum Target { */ private long maxFileSize = ScriptExportHandler.DEFAULT_MAX_FILE_SIZE; private boolean lobSeparated = true; + /** + * Rows of the export package: SQL insert statements or Parquet files + */ + private DataFormat dataFormat = DataFormat.SQL; + /** + * Compression of Parquet files + */ + private String compression = ParquetExporter.DEFAULT_COMPRESSION; // Database target private ConnectionProfile targetProfile; private String targetCatalog; private String targetSchema; private boolean targetSchemaCreated = true; + /** + * 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. + */ + private String tableNamePattern; + /** + * Rows and columns of tables narrowed in the data viewer, by table name + */ + private Map tableQueries = new HashMap<>(); } diff --git a/src/main/java/com/github/sqljam/face/service/DatabaseSession.java b/src/main/java/com/github/sqljam/face/service/DatabaseSession.java index 470e176..5cd0ebb 100644 --- a/src/main/java/com/github/sqljam/face/service/DatabaseSession.java +++ b/src/main/java/com/github/sqljam/face/service/DatabaseSession.java @@ -16,6 +16,7 @@ package com.github.sqljam.face.service; import java.io.Closeable; +import java.math.BigDecimal; import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.DriverManager; @@ -48,6 +49,7 @@ import com.github.sqljam.impexp.HikariDataSourceConnectionFactory; import com.github.sqljam.impexp.MetaDataOperations; import com.github.sqljam.impexp.TableMetaData; +import com.github.sqljam.impexp.TableQuery; import com.github.sqljam.jdbc.ConnectionFactory; import com.github.sqljam.jdbc.JdbcUtils; import lombok.extern.slf4j.Slf4j; @@ -308,24 +310,40 @@ public long countRows(String catalog, String schema, String table) throws SQLExc */ public DataPage getData(String catalog, String schema, String table, int pageNumber, int pageSize) throws SQLException { - long totalRows = countRows(catalog, schema, table); + return getData(catalog, schema, table, null, pageNumber, pageSize); + } + + /** + * A page of rows narrowed by a query: selected columns, condition, groups and order. Rows are ordered by the + * order of the query, then by primary key (group columns of grouped rows). + * + * @param query null for all rows and columns + * @param pageNumber 1-based page number + */ + public DataPage getData(String catalog, String schema, String table, TableQuery query, int pageNumber, + int pageSize) throws SQLException { + long totalRows; + try (Connection connection = getConnection(catalog)) { + String countSql = dialect.getCountQueryStatement(getMetaCatalog(catalog), schema, table, query); + Long count = JdbcUtils.fetchOne(connection, countSql, Long.class); + totalRows = count != null ? count : 0; + } try (Connection connection = getConnection(catalog)) { Dialect dialect = this.dialect.forVersion(connection.getMetaData().getDatabaseMajorVersion(), connection.getMetaData().getDatabaseMinorVersion()); List primaryKeys = metaDataOperations.getPrimaryKeyInfos(connection.getMetaData(), getMetaCatalog(catalog), schema, table).stream() .sorted(Comparator.comparingInt(info -> TableMetaData.getInt(info, "KEY_SEQ"))) - .map(info -> dialect.quoteIdentifier((String) info.get("COLUMN_NAME"))) - .collect(Collectors.toList()); + .map(info -> (String) info.get("COLUMN_NAME")).collect(Collectors.toList()); List> columnInfos = new ArrayList<>(metaDataOperations.getColumnInfos( connection.getMetaData(), getMetaCatalog(catalog), schema, table)); columnInfos.sort(Comparator.comparingInt(info -> TableMetaData.getInt(info, "ORDINAL_POSITION"))); String sql = columnInfos.isEmpty() ? dialect.getSelectTableStatement(getMetaCatalog(catalog), schema, - table) : dialect.getSelectTableStatement(getMetaCatalog(catalog), schema, table, + table) : dialect.getQueryStatement(getMetaCatalog(catalog), schema, table, columnInfos.stream().map(info -> (String) info.get("COLUMN_NAME")).toArray(String[]::new), - columnInfos.stream().map(info -> (String) info.get("TYPE_NAME")).toArray(String[]::new)); - String pageSql = dialect.getPageStatement(sql, primaryKeys.isEmpty() ? null - : String.join(",", primaryKeys), pageSize, Math.max(pageNumber - 1, 0) * pageSize); + columnInfos.stream().map(info -> (String) info.get("TYPE_NAME")).toArray(String[]::new), query); + String pageSql = dialect.getPageStatement(sql, dialect.getQueryOrderBy(query, primaryKeys), pageSize, + Math.max(pageNumber - 1, 0) * pageSize); List columns = new ArrayList<>(); List> rows = new ArrayList<>(); try (PreparedStatement ps = connection.prepareStatement(pageSql); ResultSet rs = ps.executeQuery()) { @@ -368,7 +386,7 @@ static String formatValue(Object value) { if (value instanceof Object[]) { return Arrays.deepToString((Object[]) value); } - String text = value instanceof java.math.BigDecimal ? ((java.math.BigDecimal) value).toPlainString() + String text = value instanceof BigDecimal ? ((BigDecimal) value).toPlainString() : value.toString(); return text.length() > MAX_CELL_LENGTH ? text.substring(0, MAX_CELL_LENGTH) + "..." : text; } diff --git a/src/main/java/com/github/sqljam/face/service/ExampleDatabase.java b/src/main/java/com/github/sqljam/face/service/ExampleDatabase.java new file mode 100644 index 0000000..4b8b1ad --- /dev/null +++ b/src/main/java/com/github/sqljam/face/service/ExampleDatabase.java @@ -0,0 +1,158 @@ +/* + * 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.service; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.List; +import java.util.Optional; + +import org.apache.commons.io.FileUtils; +import com.github.sqljam.config.Config; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.SqlScriptRunner; +import lombok.extern.slf4j.Slf4j; + +/** + * @Description: ExampleDatabase creates an H2 database of a small online shop at the first start (no data sources are + * saved yet), so that a new user can browse, query, export and import tables right away. It is + * configured by sqljam.example.enabled and sqljam.example.directory. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +@Slf4j +public class ExampleDatabase { + + /** + * Name of the data source of the example database + */ + public static final String PROFILE_NAME = "Example Shop (H2)"; + /** + * Id of the data source of the example database, a restored example replaces it + */ + public static final String PROFILE_ID = "example-shop"; + public static final String DATABASE_NAME = "shop"; + public static final String USERNAME = "sa"; + + private static final String SCRIPT = "/com/github/sqljam/face/example/shop.sql"; + + private final File directory; + private final boolean enabled; + + public ExampleDatabase() { + this(Config.getInstance().getBoolean("sqljam.example.enabled", true), Config.getInstance().getFile( + "sqljam.example.directory", new File(JsonStore.DEFAULT_DIRECTORY, "example"))); + } + + public ExampleDatabase(boolean enabled, File directory) { + this.enabled = enabled; + this.directory = directory; + } + + /** + * The example database at the first start: when the file of data sources does not exist yet. A data source + * removed by the user is not created again. + * + * @param connectionsFile file of the saved data sources + * @return the data source of the example database, empty if it is not created + */ + public Optional createAtFirstStart(File connectionsFile) { + if (!enabled || connectionsFile.exists()) { + return Optional.empty(); + } + try { + return Optional.of(create()); + } catch (IOException | SQLException e) { + // The example is a convenience, the application starts without it + if (log.isWarnEnabled()) { + log.warn("Unable to create the example database in {}", directory, e); + } + return Optional.empty(); + } + } + + /** + * The example database of a saved example data source whose database file is missing (deleted by the user), + * it is created again at the start + * + * @return the data source if its database is created again + */ + public Optional repairAtStart(List profiles) { + Optional example = profiles.stream().filter(ExampleDatabase::isExample).findFirst(); + if (!enabled || example.isEmpty() || !isDatabaseMissing(example.get())) { + return Optional.empty(); + } + try { + return Optional.of(create()); + } catch (IOException | SQLException e) { + if (log.isWarnEnabled()) { + log.warn("Unable to create the example database in {} again", directory, e); + } + return Optional.empty(); + } + } + + public static boolean isExample(ConnectionProfile profile) { + return profile != null && PROFILE_ID.equals(profile.getId()); + } + + /** + * Whether the database file of the data source does not exist, H2 would create an empty database + */ + public static boolean isDatabaseMissing(ConnectionProfile profile) { + return profile.getDatabase() == null || !new File(profile.getDatabase() + ".mv.db").isFile(); + } + + /** + * Creates the database and its tables, an existing database of the directory is replaced + */ + public ConnectionProfile create() throws IOException, SQLException { + FileUtils.forceMkdir(directory); + File file = new File(directory, DATABASE_NAME); + FileUtils.deleteQuietly(new File(file.getPath() + ".mv.db")); + FileUtils.deleteQuietly(new File(file.getPath() + ".trace.db")); + ConnectionProfile profile = new ConnectionProfile(); + profile.setId(PROFILE_ID); + profile.setName(PROFILE_NAME); + profile.setDbType(DbType.H2); + profile.setDatabase(file.getAbsolutePath()); + profile.setUsername(USERNAME); + profile.setPassword(""); + try (Connection connection = DriverManager.getConnection(profile.getJdbcUrl(), USERNAME, ""); + InputStream in = ExampleDatabase.class.getResourceAsStream(SCRIPT)) { + if (in == null) { + throw new IOException("Script of the example database not found: " + SCRIPT); + } + new SqlScriptRunner(connection).runScript(new InputStreamReader(in, StandardCharsets.UTF_8)); + } + if (log.isInfoEnabled()) { + log.info("Example database created: {}", file); + } + return profile; + } + + public File getDirectory() { + return directory; + } +} 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 7d725fc..cf76c59 100644 --- a/src/main/java/com/github/sqljam/face/service/TransferService.java +++ b/src/main/java/com/github/sqljam/face/service/TransferService.java @@ -19,18 +19,25 @@ import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; import org.apache.commons.lang3.StringUtils; import com.github.sqljam.config.Config; 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.DataFormat; import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.DuckDBWorkspace; import com.github.sqljam.impexp.ExportListener; import com.github.sqljam.impexp.Exporter; import com.github.sqljam.impexp.ImportExportHandler; import com.github.sqljam.impexp.ImportExporter; +import com.github.sqljam.impexp.ParquetExporter; +import com.github.sqljam.impexp.ParquetImporter; import com.github.sqljam.impexp.ScriptExporter; -import com.github.sqljam.impexp.ScriptImporter; import com.github.sqljam.impexp.SqlScriptRunner; /** @@ -66,12 +73,17 @@ private void configureSource(Exporter.ExportConfiguration configuration, Transfe configuration.setPageSize(request.getPageSize()); configuration.setLobPageSize(request.getLobPageSize()); configuration.setConnectionPoolEnabled(true); + configuration.setTableQueries(new HashMap<>(request.getTableQueries())); } private void exportScripts(TransferRequest request, ExportListener listener) throws Exception { if (request.getOutputDirectory() == null) { throw new IllegalArgumentException("Output directory must be required."); } + if (request.getDataFormat() == DataFormat.PARQUET) { + exportParquet(request, listener); + return; + } ScriptExporter scriptExporter = new ScriptExporter(request.getOutputDirectory(), request.getDataFileStrategy(), request.getMaxFileSize()); configureSource(scriptExporter.getConfiguration(), request); @@ -87,6 +99,104 @@ private void exportScripts(TransferRequest request, ExportListener listener) thr scriptExporter.export(request.getExportMode()); } + /** + * Export package with Parquet files: schema.sql and constraints.sql for the target database type + */ + private void exportParquet(TransferRequest request, ExportListener listener) throws Exception { + ParquetExporter parquetExporter = new ParquetExporter(request.getOutputDirectory()); + configureSource(parquetExporter.getConfiguration(), request); + parquetExporter.setTargetDbType(request.getScriptDbType()); + parquetExporter.setTargetSchemaName(request.getScriptSchema()); + parquetExporter.setIdentifierCase(request.getIdentifierCase()); + parquetExporter.setCompression(request.getCompression()); + int[] version = parseVersion(request.getScriptDbVersion()); + if (version != null) { + parquetExporter.setTargetVersion(version[0], version[1]); + } + parquetExporter.setExportListener(listener); + parquetExporter.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) + 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); + } + 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.setExportListener(listener); + importer.importFiles(files, tableName, mode); + return importer; + } + } + + /** + * Columns (name and type) and rows of Parquet files, shown before importing + */ + public ParquetPreview previewParquet(List files) throws Exception { + return previewParquet(files, 0); + } + + /** + * Columns, rows and the first rows (values as text) of Parquet files + */ + public ParquetPreview previewParquet(List files, int rowLimit) throws Exception { + try (DuckDBWorkspace workspace = new DuckDBWorkspace(null)) { + List> firstRows = rowLimit > 0 ? workspace.readParquetRows(files, rowLimit) + : Collections.emptyList(); + return new ParquetPreview(workspace.describeParquet(files), workspace.countParquetRows(files), firstRows); + } + } + + /** + * Columns and rows of Parquet files + */ + public static class ParquetPreview { + + private final List columns; + private final long rows; + private final List> firstRows; + + public ParquetPreview(List columns, long rows, List> firstRows) { + this.columns = columns; + this.rows = rows; + this.firstRows = firstRows; + } + + /** + * First rows of the files, values as text + */ + public List> getFirstRows() { + return firstRows; + } + + /** + * Name and DuckDB type of each column + */ + public List getColumns() { + return columns; + } + + public long getRows() { + return rows; + } + } + private void importDatabase(TransferRequest request, ExportListener listener) throws Exception { ConnectionProfile target = request.getTargetProfile(); if (target == null) { @@ -102,6 +212,7 @@ private void importDatabase(TransferRequest request, ExportListener listener) th importConfiguration.setTargetCatalogName(request.getTargetCatalog()); importConfiguration.setTargetSchemaName(request.getTargetSchema()); importConfiguration.setTargetSchemaCreated(request.isTargetSchemaCreated()); + importConfiguration.setTableNamePattern(request.getTableNamePattern()); importConfiguration.setConnectionPoolEnabled(true); importExporter.setIdentifierCase(request.getIdentifierCase()); importExporter.setExportListener(listener); @@ -109,9 +220,9 @@ private void importDatabase(TransferRequest request, ExportListener listener) th } /** - * Imports a directory exported as sql scripts into the target database + * Imports an export package (sql scripts or Parquet files) into the target database */ - public ScriptImporter importScripts(ConnectionProfile target, String catalog, String schema, File directory, + 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) @@ -119,10 +230,12 @@ public ScriptImporter importScripts(ConnectionProfile target, String catalog, St if (StringUtils.isNotBlank(schema) && target.getDbType().isCanSetSchema()) { connection.setSchema(schema); } - if (StringUtils.isNotBlank(catalog) && target.getDbType() == DbType.MYSQL) { + if (StringUtils.isNotBlank(catalog) && (target.getDbType() == DbType.MYSQL + || target.getDbType() == DbType.MARIADB)) { connection.setCatalog(catalog); } - ScriptImporter importer = new ScriptImporter(connection, target.getDbType()); + AbstractFileImporter importer = AbstractFileImporter.forDirectory(directory, connection, + target.getDbType()); importer.setStopOnError(stopOnError); importer.setBatchSize(Config.getInstance().getInt("sqljam.import.batch-size", SqlScriptRunner.DEFAULT_BATCH_SIZE)); diff --git a/src/main/java/com/github/sqljam/face/view/AppContext.java b/src/main/java/com/github/sqljam/face/view/AppContext.java index 690f004..dbd645a 100644 --- a/src/main/java/com/github/sqljam/face/view/AppContext.java +++ b/src/main/java/com/github/sqljam/face/view/AppContext.java @@ -16,12 +16,16 @@ package com.github.sqljam.face.view; import java.io.IOException; +import java.sql.SQLException; import com.github.sqljam.face.model.AppSettings; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.service.ExampleDatabase; import com.github.sqljam.face.service.SettingsStore; import com.github.sqljam.face.service.TransferService; import javafx.application.HostServices; import lombok.Getter; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; /** @@ -39,6 +43,8 @@ public class AppContext { private final SessionManager sessionManager = new SessionManager(); private final TransferService transferService = new TransferService(); private final HostServices hostServices; + @Setter + private ExampleDatabase exampleDatabase = new ExampleDatabase(); public AppContext(SettingsStore settingsStore, ProfileRegistry profileRegistry, HostServices hostServices) { this.settingsStore = settingsStore; @@ -46,6 +52,26 @@ public AppContext(SettingsStore settingsStore, ProfileRegistry profileRegistry, this.hostServices = hostServices; } + /** + * Creates the example database again and saves its data source, e.g. after the user removed it. Sessions of + * the example are closed before, so that its file is not locked. It runs in the background. + */ + public ConnectionProfile restoreExample() throws IOException, SQLException { + sessionManager.closeSessionNow(ExampleDatabase.PROFILE_ID); + ConnectionProfile profile = exampleDatabase.create(); + profileRegistry.saveProfile(profile, true); + return profile; + } + + /** + * Opens the url in the browser of the system, nothing happens without host services, e.g. in tests + */ + public void openDocument(String url) { + if (hostServices != null) { + hostServices.showDocument(url); + } + } + public AppSettings getSettings() { return settingsStore.getSettings(); } diff --git a/src/main/java/com/github/sqljam/face/view/Branding.java b/src/main/java/com/github/sqljam/face/view/Branding.java index 7b414c1..f3e5ea1 100644 --- a/src/main/java/com/github/sqljam/face/view/Branding.java +++ b/src/main/java/com/github/sqljam/face/view/Branding.java @@ -21,8 +21,12 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import javafx.scene.Cursor; import javafx.scene.control.Dialog; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.stage.Stage; @@ -44,6 +48,16 @@ public final class Branding { public static final String LOGO = "logo.png"; public static final String LOGO_MARK = "logo-mark.png"; public static final int[] ICON_SIZES = {16, 24, 32, 48, 64, 128, 256, 512}; + /** + * Source repository of SqlJam + */ + public static final String REPOSITORY_URL = "https://github.com/paganini2008/sqljam"; + /** + * Home page of SqlJam, the GitHub Pages of the repository + */ + public static final String HOMEPAGE_URL = "https://paganini2008.github.io/sqljam/"; + public static final String AUTHOR = "Fred Feng"; + public static final String EMAIL = "paganini20080@gmail.com"; private static final Map images = new ConcurrentHashMap<>(); private static volatile List icons; @@ -51,6 +65,23 @@ public final class Branding { private Branding() { } + /** + * GitHub mark of the source repository, the url is shown by its tooltip + * + * @param browser opens the repository in the browser when the mark is clicked, null for a mark without a click + */ + public static Label repositoryMark(Consumer browser) { + Label mark = new Label(null, Icons.of(Icons.GITHUB)); + mark.setId("repositoryMark"); + mark.getStyleClass().add("repository-mark"); + mark.setTooltip(new Tooltip(REPOSITORY_URL)); + if (browser != null) { + mark.setCursor(Cursor.HAND); + mark.setOnMouseClicked(event -> browser.accept(REPOSITORY_URL)); + } + return mark; + } + /** * Returns the image in the image directory, or null if it does not exist */ diff --git a/src/main/java/com/github/sqljam/face/view/ConnectionDialog.java b/src/main/java/com/github/sqljam/face/view/ConnectionDialog.java index 85de6ad..3af535c 100644 --- a/src/main/java/com/github/sqljam/face/view/ConnectionDialog.java +++ b/src/main/java/com/github/sqljam/face/view/ConnectionDialog.java @@ -17,13 +17,19 @@ import java.io.IOException; import java.util.Optional; +import java.util.function.Consumer; import com.github.sqljam.face.model.ConnectionProfile; import javafx.event.ActionEvent; +import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.ButtonBar; import javafx.scene.control.ButtonType; +import javafx.scene.control.ComboBox; import javafx.scene.control.Dialog; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; import javafx.stage.Window; /** @@ -74,6 +80,32 @@ public ConnectionDialog(Window owner, AppContext context, ConnectionProfile prof setResultConverter(buttonType -> buttonType == ButtonType.OK ? form.getProfile() : null); } + /** + * Target data source of an import: the combo box and a button creating a new data source, which is passed to + * the consumer to be listed and selected + */ + public static HBox targetField(ComboBox combo, AppContext context, + Consumer onCreated) { + Button newButton = new Button(null, Icons.of(Icons.ADD)); + newButton.setId("newTargetButton"); + newButton.setTooltip(new Tooltip(Messages.get("action.newTargetConnection"))); + newButton.setOnAction(event -> show(combo.getScene() != null ? combo.getScene().getWindow() : null, context, + null).ifPresent(onCreated)); + combo.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(combo, Priority.ALWAYS); + HBox field = new HBox(6, combo, newButton); + field.setAlignment(Pos.CENTER_LEFT); + return field; + } + + /** + * Selects the data source of the same id in the combo box, the selection is kept if it is not listed + */ + public static void selectProfile(ComboBox combo, ConnectionProfile profile) { + combo.getItems().stream().filter(item -> item.getId().equals(profile.getId())).findFirst() + .ifPresent(combo::setValue); + } + public static Optional show(Window owner, AppContext context, ConnectionProfile profile) { return new ConnectionDialog(owner, context, profile).showAndWait(); } diff --git a/src/main/java/com/github/sqljam/face/view/ConnectionForm.java b/src/main/java/com/github/sqljam/face/view/ConnectionForm.java index 5ec0501..e7b604c 100644 --- a/src/main/java/com/github/sqljam/face/view/ConnectionForm.java +++ b/src/main/java/com/github/sqljam/face/view/ConnectionForm.java @@ -17,6 +17,7 @@ import java.io.File; import java.util.Objects; +import java.util.function.Consumer; import org.apache.commons.lang3.StringUtils; import com.github.sqljam.face.model.ConnectionProfile; @@ -25,18 +26,22 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.geometry.Insets; +import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.CheckBox; +import javafx.scene.control.ChoiceDialog; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; import javafx.scene.control.PasswordField; import javafx.scene.control.ProgressIndicator; import javafx.scene.control.TextField; import javafx.scene.control.TextFormatter; +import javafx.scene.control.Tooltip; import javafx.scene.layout.ColumnConstraints; import javafx.scene.layout.GridPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; import javafx.scene.layout.VBox; import javafx.stage.FileChooser; @@ -85,7 +90,8 @@ public ConnectionForm() { urlField.setId("urlField"); statusLabel.setId("formStatusLabel"); - dbTypeCombo.getItems().addAll(DbType.values()); + dbTypeCombo.getItems().addAll(DbTypeCells.getDbTypesByCategory()); + DbTypeCells.setupDbTypeCombo(dbTypeCombo); dbTypeCombo.setMaxWidth(Double.MAX_VALUE); dbTypeCombo.valueProperty().addListener((obs, oldType, newType) -> onDbTypeChanged(oldType, newType)); portField.setTextFormatter(new TextFormatter(change -> change.getControlNewText().matches("\\d{0,5}") @@ -109,8 +115,8 @@ public ConnectionForm() { progressIndicator.managedProperty().bind(busy); hostBox = new HBox(8, hostField, portLabel, portField); - hostBox.setAlignment(javafx.geometry.Pos.CENTER_LEFT); - portLabel.setMinWidth(javafx.scene.layout.Region.USE_PREF_SIZE); + hostBox.setAlignment(Pos.CENTER_LEFT); + portLabel.setMinWidth(Region.USE_PREF_SIZE); HBox.setHgrow(hostField, Priority.ALWAYS); HBox databaseBox = new HBox(8, databaseField, browseButton); HBox.setHgrow(databaseField, Priority.ALWAYS); @@ -148,8 +154,8 @@ private void onDbTypeChanged(DbType oldType, DbType newType) { portField.setText(newType.getDefaultPort() > 0 ? String.valueOf(newType.getDefaultPort()) : ""); } boolean fileBased = newType.isFileBased(); - // H2 may run as a tcp server, SQLite is always a file - boolean hostVisible = newType != DbType.SQLITE; + // H2 may run as a tcp server, SQLite and DuckDB are always files + boolean hostVisible = !fileBased || newType == DbType.H2; hostLabel.setVisible(hostVisible); hostLabel.setManaged(hostVisible); hostBox.setVisible(hostVisible); @@ -160,7 +166,7 @@ private void onDbTypeChanged(DbType oldType, DbType newType) { browseButton.setVisible(browsable); browseButton.setManaged(browsable); browseButton.setGraphic(Icons.of(fileBased ? Icons.FOLDER : "fth-more-horizontal")); - browseButton.setTooltip(fileBased ? null : new javafx.scene.control.Tooltip( + browseButton.setTooltip(fileBased ? null : new Tooltip( Messages.get("connection.browseDatabases"))); if (newType == DbType.ORACLE) { databaseLabel.setText(Messages.get("connection.serviceName")); @@ -213,7 +219,7 @@ private void browseDatabases() { return; } String current = databases.contains(databaseField.getText()) ? databaseField.getText() : databases.get(0); - javafx.scene.control.ChoiceDialog dialog = new javafx.scene.control.ChoiceDialog<>(current, + ChoiceDialog dialog = new ChoiceDialog<>(current, databases); dialog.initOwner(getScene() != null ? getScene().getWindow() : null); Branding.applyIcons(dialog); @@ -307,8 +313,8 @@ public String validate() { if (StringUtils.isNotBlank(edited.getUrl())) { return null; } - boolean fileMode = dbType == DbType.SQLITE || (dbType == DbType.H2 && StringUtils.isBlank( - edited.getHostname())); + boolean fileMode = (dbType.isFileBased() && dbType != DbType.H2) || (dbType == DbType.H2 + && StringUtils.isBlank(edited.getHostname())); if (!fileMode && StringUtils.isBlank(edited.getHostname())) { return Messages.get("connection.error.host"); } @@ -352,7 +358,7 @@ public void clearStatus() { * * @param onSuccess called with product name and version */ - public void testConnection(java.util.function.Consumer onSuccess) { + public void testConnection(Consumer onSuccess) { String error = validate(); if (error != null) { showError(error); diff --git a/src/main/java/com/github/sqljam/face/view/ConnectionTree.java b/src/main/java/com/github/sqljam/face/view/ConnectionTree.java index da8955f..c5c95c9 100644 --- a/src/main/java/com/github/sqljam/face/view/ConnectionTree.java +++ b/src/main/java/com/github/sqljam/face/view/ConnectionTree.java @@ -24,9 +24,11 @@ import com.github.sqljam.face.model.TableInfo; import com.github.sqljam.face.service.DatabaseSession; import com.github.sqljam.impexp.DbType; +import javafx.application.Platform; import javafx.scene.control.ContextMenu; import javafx.scene.control.MenuItem; import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.Tooltip; import javafx.scene.control.TreeCell; import javafx.scene.control.TreeItem; import javafx.scene.control.TreeView; @@ -52,11 +54,15 @@ public class ConnectionTree extends TreeView { @Setter private Consumer onImportScripts; @Setter + private Consumer onImportParquet; + @Setter private Consumer onEditConnection; @Setter private Consumer onDeleteConnection; @Setter private Consumer onStatus; + @Setter + private Consumer onDisconnect; public ConnectionTree(AppContext context) { this.context = context; @@ -78,6 +84,46 @@ public void reloadConnections() { } } + /** + * Adds connections of saved profiles which are not in the tree yet, e.g. created as the target of an import, + * opened connections are kept + */ + public void addNewConnections() { + for (ConnectionProfile profile : context.getProfileRegistry().getProfiles()) { + boolean exists = rootItem.getChildren().stream().anyMatch(item -> item.getValue().getProfile().getId() + .equals(profile.getId())); + if (!exists) { + rootItem.getChildren().add(new LazyItem(DbNode.connection(profile))); + } + } + } + + /** + * Whether the connection of the item is opened: its children are loaded + */ + public boolean isConnected(TreeItem item) { + return item instanceof LazyItem && item.getValue().getKind() == DbNode.Kind.CONNECTION + && ((LazyItem) item).loaded; + } + + /** + * Closes the session of the connection, the connection is opened again when it is expanded + */ + public void disconnect(TreeItem item) { + if (!isConnected(item)) { + return; + } + ConnectionProfile profile = item.getValue().getProfile(); + ((LazyItem) item).unload(); + context.getSessionManager().closeSession(profile.getId()); + if (onDisconnect != null) { + onDisconnect.accept(profile); + } + if (onStatus != null) { + onStatus.accept(Messages.format("tree.disconnected", profile.getName())); + } + } + public DbNode getSelectedNode() { TreeItem item = getSelectionModel().getSelectedItem(); return item != null ? item.getValue() : null; @@ -120,7 +166,7 @@ private List> loadChildren(DbNode node) throws Exception { case CONNECTION: String product = session.getDatabaseProduct(); if (onStatus != null) { - javafx.application.Platform.runLater(() -> onStatus.accept(profile.getName() + " - " + product)); + Platform.runLater(() -> onStatus.accept(profile.getName() + " - " + product)); } // Databases of the server, Oracle and SQLite have no databases List catalogs = session.getCatalogs(); @@ -182,6 +228,15 @@ public boolean isLeaf() { return false; } + /** + * Collapsed with a placeholder child, the children are loaded again when it is expanded + */ + void unload() { + loaded = false; + setExpanded(false); + getChildren().setAll(Collections.singletonList(new TreeItem<>(DbNode.loading()))); + } + void reload() { loaded = false; getChildren().setAll(Collections.singletonList(new TreeItem<>(DbNode.loading()))); @@ -255,7 +310,7 @@ protected void updateItem(DbNode node, boolean empty) { break; } if (node.getKind() == DbNode.Kind.TABLE && node.getTable().getRemarks() != null) { - setTooltip(new javafx.scene.control.Tooltip(node.getTable().getRemarks())); + setTooltip(new Tooltip(node.getTable().getRemarks())); } else { setTooltip(null); } @@ -266,12 +321,23 @@ private ContextMenu createContextMenu(DbNode node) { ContextMenu menu = new ContextMenu(); switch (node.getKind()) { case CONNECTION: - menu.getItems().addAll( - menuItem("tree.connect", Icons.CONNECT, () -> getTreeItem().setExpanded(true)), + // Connect before the connection is opened, disconnect after it is opened + MenuItem connect = menuItem("tree.connect", Icons.CONNECT, () -> getTreeItem().setExpanded(true)); + connect.setId("connectMenuItem"); + MenuItem disconnect = menuItem("tree.disconnect", Icons.DISCONNECT, () -> disconnect( + getTreeItem())); + disconnect.setId("disconnectMenuItem"); + menu.setOnShowing(event -> { + boolean connected = isConnected(getTreeItem()); + connect.setVisible(!connected); + disconnect.setVisible(connected); + }); + menu.getItems().addAll(connect, disconnect, menuItem("action.refresh", Icons.REFRESH, ConnectionTree.this::refreshSelected), new SeparatorMenuItem(), menuItem("action.export", Icons.EXPORT, () -> fire(onExport, node)), menuItem("action.importScripts", Icons.IMPORT, () -> fire(onImportScripts, node)), + menuItem("action.importParquet", Icons.IMPORT, () -> fire(onImportParquet, node)), new SeparatorMenuItem(), menuItem("action.editConnection", Icons.EDIT, () -> fire(onEditConnection, node)), menuItem("action.deleteConnection", Icons.DELETE, () -> fire(onDeleteConnection, node))); @@ -282,7 +348,8 @@ private ContextMenu createContextMenu(DbNode node) { menuItem("action.refresh", Icons.REFRESH, ConnectionTree.this::refreshSelected), new SeparatorMenuItem(), menuItem("action.export", Icons.EXPORT, () -> fire(onExport, node)), - menuItem("action.importScripts", Icons.IMPORT, () -> fire(onImportScripts, node))); + menuItem("action.importScripts", Icons.IMPORT, () -> fire(onImportScripts, node)), + menuItem("action.importParquet", Icons.IMPORT, () -> fire(onImportParquet, node))); break; case TABLE: menu.getItems().addAll( diff --git a/src/main/java/com/github/sqljam/face/view/DataPane.java b/src/main/java/com/github/sqljam/face/view/DataPane.java index c51bf22..354c2b3 100644 --- a/src/main/java/com/github/sqljam/face/view/DataPane.java +++ b/src/main/java/com/github/sqljam/face/view/DataPane.java @@ -15,26 +15,44 @@ */ package com.github.sqljam.face.view; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import com.github.sqljam.face.model.ColumnInfo; import com.github.sqljam.face.model.DataPage; import com.github.sqljam.face.service.DatabaseSession; +import com.github.sqljam.impexp.TableQuery; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; import javafx.scene.control.ComboBox; +import javafx.scene.control.CustomMenuItem; import javafx.scene.control.Label; +import javafx.scene.control.MenuButton; +import javafx.scene.control.SeparatorMenuItem; import javafx.scene.control.TableCell; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.control.Tooltip; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; /** - * @Description: DataPane shows rows of a table page by page + * @Description: DataPane shows rows of a table page by page. Rows are narrowed by a simple query: selected + * columns, a WHERE condition, GROUP BY columns (with the count of rows) and ORDER BY. The narrowed + * rows can be exported (grouped rows are for viewing). * @Author: Fred Feng * @Date: 26/03/2023 * @Version 1.0.0 @@ -56,6 +74,19 @@ public class DataPane extends BorderPane { private final Label pageLabel = new Label(); private final Label totalLabel = new Label(); private final ComboBox pageSizeCombo = new ComboBox<>(); + private final MenuButton columnsButton = new MenuButton(); + private final MenuButton groupByButton = new MenuButton(); + private final TextField whereField = new TextField(); + private final TextField orderField = new TextField(); + private final Button applyButton = new Button(Messages.get("data.query.apply"), Icons.of(Icons.START)); + private final Button clearButton = new Button(Messages.get("data.query.clear"), Icons.of(Icons.CANCEL)); + private final Button exportButton = new Button(Messages.get("action.export"), Icons.of(Icons.EXPORT)); + private final Label queryErrorLabel = new Label(); + private final Map columnChecks = new LinkedHashMap<>(); + private final Map groupChecks = new LinkedHashMap<>(); + private TableQuery query = new TableQuery(); + private Consumer onExport; + private boolean columnsLoaded; private int pageNumber = 1; private int totalPages = 1; private List columns; @@ -92,21 +123,189 @@ public DataPane(DatabaseSession session, String catalog, String schema, String t pager.setAlignment(Pos.CENTER_LEFT); pager.setPadding(new Insets(8)); pager.getStyleClass().add("pager"); + setTop(createQueryBar()); setCenter(dataTable); setBottom(pager); updatePager(false); } + /** + * Columns, WHERE, GROUP BY and ORDER BY of the rows, applied by Apply or Enter + */ + private VBox createQueryBar() { + columnsButton.setId("columnsButton"); + groupByButton.setId("groupByButton"); + whereField.setId("whereField"); + orderField.setId("orderField"); + applyButton.setId("applyQueryButton"); + clearButton.setId("clearQueryButton"); + exportButton.setId("exportQueryButton"); + queryErrorLabel.setId("queryErrorLabel"); + whereField.setPromptText(Messages.get("data.query.where")); + orderField.setPromptText(Messages.get("data.query.orderBy")); + whereField.setOnAction(event -> applyQuery()); + orderField.setOnAction(event -> applyQuery()); + applyButton.setOnAction(event -> applyQuery()); + clearButton.setOnAction(event -> clearQuery()); + exportButton.setOnAction(event -> { + if (onExport != null) { + onExport.accept(query); + } + }); + HBox.setHgrow(whereField, Priority.ALWAYS); + orderField.setPrefWidth(200); + updateQueryButtons(); + HBox bar = new HBox(6, columnsButton, whereField, groupByButton, orderField, applyButton, clearButton, + exportButton); + bar.setAlignment(Pos.CENTER_LEFT); + queryErrorLabel.getStyleClass().add("status-error"); + queryErrorLabel.setWrapText(true); + queryErrorLabel.setVisible(false); + queryErrorLabel.setManaged(false); + VBox top = new VBox(4, bar, queryErrorLabel); + top.setPadding(new Insets(8, 8, 0, 8)); + top.getStyleClass().add("query-bar"); + return top; + } + + /** + * Rows narrowed by the query are exported by the consumer, e.g. the export dialog + */ + public void setOnExport(Consumer onExport) { + this.onExport = onExport; + } + + public TableQuery getQuery() { + return query; + } + + /** + * Columns of the table for the column and group choices + */ + void setColumns(List columnNames) { + columnChecks.clear(); + groupChecks.clear(); + columnsButton.getItems().clear(); + groupByButton.getItems().clear(); + CheckBox allCheck = new CheckBox(Messages.get("data.query.allColumns")); + allCheck.setId("allColumnsCheck"); + allCheck.setSelected(true); + allCheck.setOnAction(event -> { + // Checks of columns update the All check, its state is read first + boolean all = allCheck.isSelected(); + columnChecks.values().forEach(check -> check.setSelected(all)); + }); + columnsButton.getItems().addAll(menuItem(allCheck), new SeparatorMenuItem()); + for (String columnName : columnNames) { + CheckBox columnCheck = new CheckBox(columnName); + columnCheck.setSelected(true); + columnCheck.selectedProperty().addListener((obs, oldValue, value) -> { + allCheck.setSelected(columnChecks.values().stream().allMatch(CheckBox::isSelected)); + updateQueryButtons(); + }); + columnChecks.put(columnName, columnCheck); + columnsButton.getItems().add(menuItem(columnCheck)); + CheckBox groupCheck = new CheckBox(columnName); + groupCheck.selectedProperty().addListener((obs, oldValue, value) -> updateQueryButtons()); + groupChecks.put(columnName, groupCheck); + groupByButton.getItems().add(menuItem(groupCheck)); + } + columnsLoaded = true; + updateQueryButtons(); + } + + private static CustomMenuItem menuItem(CheckBox check) { + CustomMenuItem item = new CustomMenuItem(check); + // The menu stays open while columns are checked + item.setHideOnClick(false); + return item; + } + + /** + * Query of the inputs, all checked columns mean all columns + */ + TableQuery buildQuery() { + List columns = columnChecks.values().stream().allMatch(CheckBox::isSelected) ? new ArrayList<>() + : columnChecks.entrySet().stream().filter(entry -> entry.getValue().isSelected()) + .map(Map.Entry::getKey).collect(Collectors.toList()); + List groupBy = groupChecks.entrySet().stream().filter(entry -> entry.getValue().isSelected()) + .map(Map.Entry::getKey).collect(Collectors.toList()); + return new TableQuery(columns, whereField.getText(), groupBy, orderField.getText()); + } + + void applyQuery() { + TableQuery newQuery; + try { + newQuery = buildQuery(); + } catch (IllegalArgumentException e) { + showQueryError(e.getMessage()); + return; + } + if (!newQuery.isGrouped() && !columnChecks.isEmpty() && columnChecks.values().stream() + .noneMatch(CheckBox::isSelected)) { + showQueryError(Messages.get("data.query.noColumns")); + return; + } + showQueryError(null); + query = newQuery; + columns = null; + updateQueryButtons(); + load(1); + } + + void clearQuery() { + whereField.clear(); + orderField.clear(); + columnChecks.values().forEach(check -> check.setSelected(true)); + groupChecks.values().forEach(check -> check.setSelected(false)); + applyQuery(); + } + + private void showQueryError(String message) { + queryErrorLabel.setText(message != null ? message : ""); + queryErrorLabel.setVisible(message != null); + queryErrorLabel.setManaged(message != null); + } + + private void updateQueryButtons() { + long selectedColumns = columnChecks.values().stream().filter(CheckBox::isSelected).count(); + columnsButton.setText(selectedColumns == columnChecks.size() ? Messages.get("data.query.allColumns") + : Messages.format("data.query.columns", selectedColumns, columnChecks.size())); + List groups = groupChecks.entrySet().stream().filter(entry -> entry.getValue().isSelected()) + .map(Map.Entry::getKey).collect(Collectors.toList()); + groupByButton.setText(groups.isEmpty() ? Messages.get("data.query.groupBy") + : Messages.format("data.query.groupedBy", String.join(", ", groups))); + // Grouped rows have the group columns and the count + columnsButton.setDisable(!groups.isEmpty() || !columnsLoaded); + groupByButton.setDisable(!columnsLoaded); + exportButton.setDisable(query.isGrouped()); + exportButton.setTooltip(new Tooltip(Messages.get(query.isGrouped() ? "data.query.exportGrouped" + : "data.query.export"))); + } + public void load(int page) { int targetPage = Math.max(1, Math.min(page, Math.max(totalPages, 1))); int pageSize = pageSizeCombo.getValue(); + TableQuery currentQuery = query; updatePager(true); - TaskRunner.run(() -> session.getData(catalog, schema, table, targetPage, pageSize), this::show, e -> { - updatePager(false); - dataTable.setPlaceholder(new Label(Messages.format("tree.error", e.getMessage()))); - Dialogs.showError(getScene() != null ? getScene().getWindow() : null, - Messages.format("table.loadError", table), e); - }); + if (!columnsLoaded) { + TaskRunner.run(() -> session.getColumns(catalog, schema, table), columnInfos -> setColumns( + columnInfos.stream().map(ColumnInfo::getName).collect(Collectors.toList())), e -> { + }); + } + TaskRunner.run(() -> session.getData(catalog, schema, table, currentQuery, targetPage, pageSize), + this::show, e -> { + updatePager(false); + dataTable.getItems().clear(); + dataTable.setPlaceholder(new Label(Messages.format("tree.error", e.getMessage()))); + if (currentQuery.isEmpty()) { + Dialogs.showError(getScene() != null ? getScene().getWindow() : null, + Messages.format("table.loadError", table), e); + } else { + // An invalid condition or order is shown below the query + showQueryError(Messages.format("data.query.error", e.getMessage())); + } + }); } private void show(DataPage page) { @@ -151,14 +350,14 @@ private void updatePager(boolean busy) { refreshButton.setDisable(busy); } - private static final java.util.regex.Pattern ISO_DATE_TIME = java.util.regex.Pattern.compile( + private static final Pattern ISO_DATE_TIME = Pattern.compile( "^(\\d{4}-\\d{2}-\\d{2})T(\\d{2}:\\d{2}.*)$"); /** * Text of a cell in one line, ISO date time is shown with a space separator */ static String format(String value) { - java.util.regex.Matcher matcher = ISO_DATE_TIME.matcher(value); + Matcher matcher = ISO_DATE_TIME.matcher(value); String text = matcher.matches() ? matcher.group(1) + " " + matcher.group(2) : value; return text.replace('\n', ' ').replace('\r', ' '); } diff --git a/src/main/java/com/github/sqljam/face/view/DbTypeCells.java b/src/main/java/com/github/sqljam/face/view/DbTypeCells.java index e73b56e..8b9ebcb 100644 --- a/src/main/java/com/github/sqljam/face/view/DbTypeCells.java +++ b/src/main/java/com/github/sqljam/face/view/DbTypeCells.java @@ -15,24 +15,91 @@ */ package com.github.sqljam.face.view; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.impexp.DbCategory; import com.github.sqljam.impexp.DbType; +import javafx.scene.Node; import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; import javafx.scene.control.ListCell; import javafx.scene.control.ListView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; import javafx.util.StringConverter; /** - * @Description: DbTypeCells renders database types and connection profiles in lists and combo boxes + * @Description: DbTypeCells renders database types and connection profiles in lists and combo boxes, grouped by + * categories (relational and OLAP databases) with a header before the first item of each category * @Author: Fred Feng * @Date: 26/03/2023 * @Version 1.0.0 */ public final class DbTypeCells { + /** + * Style class of a cell starting a category + */ + static final String CATEGORY_START = "category-start"; + private DbTypeCells() { } + /** + * Database types ordered by category, the order of DbType is kept in a category + */ + public static List getDbTypesByCategory() { + return Arrays.stream(DbType.values()).sorted(Comparator.comparing(DbType::getCategory)) + .collect(Collectors.toList()); + } + + /** + * Category of a profile, relational if the type is unknown + */ + public static DbCategory categoryOf(ConnectionProfile profile) { + return profile != null && profile.getDbType() != null ? profile.getDbType().getCategory() + : DbCategory.RELATIONAL; + } + + /** + * Whether the item at the index starts a category of the list + */ + static boolean isCategoryStart(List items, int index, Function category) { + if (index < 0 || index >= items.size()) { + return false; + } + return index == 0 || category.apply(items.get(index - 1)) != category.apply(items.get(index)); + } + + /** + * Cells of themes have a fixed height, a cell starting a category is higher for its header + */ + static void setCategoryStart(ListCell cell, boolean start) { + cell.getStyleClass().remove(CATEGORY_START); + // Inline, dialogs have the theme without the stylesheet of the application + cell.setStyle(start ? "-fx-cell-size: " + (cell.getListView() != null + && cell.getListView().getStyleClass().contains("profile-list") ? "4.6em" : "4em") : null); + if (start) { + cell.getStyleClass().add(CATEGORY_START); + } + } + + /** + * Content with the category header above it + */ + static Node withHeader(DbCategory category, Node content) { + Label header = new Label(category.getDisplayName()); + header.getStyleClass().add("category-header"); + header.setStyle("-fx-font-size: 11px; -fx-font-weight: bold; -fx-text-fill: -color-fg-muted;" + + " -fx-padding: 4 0 2 0;"); + return new VBox(2, header, content); + } + public static ListCell profileCell() { return new ListCell<>() { @Override @@ -65,24 +132,60 @@ public ConnectionProfile fromString(String text) { }); } + /** + * Profiles ordered by category have a category header before the first profile of each category + */ public static void setupProfileList(ListView listView) { + listView.getStyleClass().add("profile-list"); listView.setCellFactory(view -> new ListCell<>() { @Override protected void updateItem(ConnectionProfile item, boolean empty) { super.updateItem(item, empty); + setCategoryStart(this, false); if (empty || item == null) { setText(null); setGraphic(null); + return; + } + String text = item.getName() + "\n" + (item.getDbType() != null ? item.getDbType().getDisplayName() + : ""); + if (isCategoryStart(view.getItems(), getIndex(), DbTypeCells::categoryOf)) { + setCategoryStart(this, true); + setText(null); + // The gap of the icon and text of a cell + setGraphic(withHeader(categoryOf(item), new HBox(getGraphicTextGap(), Icons.of(Icons.CONNECTION), + new Label(text)))); } else { - setText(item.getName() + "\n" + (item.getDbType() != null ? item.getDbType().getDisplayName() - : "")); + setText(text); setGraphic(Icons.of(Icons.CONNECTION)); } } }); } + /** + * Types of the combo have a category header before the first type of each category, the selected type is shown + * without header + */ public static void setupDbTypeCombo(ComboBox combo) { + combo.setCellFactory(view -> new ListCell<>() { + @Override + protected void updateItem(DbType item, boolean empty) { + super.updateItem(item, empty); + setCategoryStart(this, false); + if (empty || item == null) { + setText(null); + setGraphic(null); + } else if (isCategoryStart(view.getItems(), getIndex(), DbType::getCategory)) { + setCategoryStart(this, true); + setText(null); + setGraphic(withHeader(item.getCategory(), new Label(item.getDisplayName()))); + } else { + setText(item.getDisplayName()); + setGraphic(null); + } + } + }); combo.setConverter(new StringConverter<>() { @Override public String toString(DbType dbType) { diff --git a/src/main/java/com/github/sqljam/face/view/Dialogs.java b/src/main/java/com/github/sqljam/face/view/Dialogs.java index a329338..fdeee9a 100644 --- a/src/main/java/com/github/sqljam/face/view/Dialogs.java +++ b/src/main/java/com/github/sqljam/face/view/Dialogs.java @@ -18,17 +18,21 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.util.Optional; +import java.util.function.Consumer; import org.apache.commons.lang3.StringUtils; import com.github.sqljam.face.Banner; +import javafx.geometry.Insets; import javafx.scene.control.Alert; import javafx.scene.control.ButtonType; import javafx.scene.control.Dialog; +import javafx.scene.control.Hyperlink; import javafx.scene.control.Label; import javafx.scene.control.TextArea; import javafx.scene.image.ImageView; import javafx.scene.layout.GridPane; import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; import javafx.stage.Window; /** @@ -94,24 +98,61 @@ public static void showInfo(Window owner, String title, String message) { } /** - * About dialog with the logo and the version + * About SqlJam with the version, the home page, the source repository, the author and the license + * + * @param browser opens the links in the browser, null for links without an action */ - public static Alert createAbout(Window owner) { + public static Alert createAbout(Window owner, Consumer browser) { Alert alert = new Alert(Alert.AlertType.INFORMATION); initOwner(alert, owner); Branding.applyIcons(alert); alert.setTitle(Messages.get("about.title")); alert.setHeaderText(Messages.format("about.header", Banner.getVersion())); - alert.setContentText(Messages.get("about.message")); ImageView logo = Branding.logoView(96); logo.setId("aboutLogo"); alert.setGraphic(logo); - alert.getDialogPane().setPrefWidth(460); + alert.getDialogPane().setPrefWidth(500); + Label message = new Label(Messages.get("about.message")); + message.setWrapText(true); + GridPane details = new GridPane(); + details.setId("aboutDetails"); + details.setHgap(16); + details.setVgap(4); + details.addRow(0, aboutLabel("about.version"), new Label(Banner.getVersion())); + details.addRow(1, aboutLabel("about.homepage"), aboutLink("aboutHomepage", Branding.HOMEPAGE_URL, + Branding.HOMEPAGE_URL, browser)); + details.addRow(2, aboutLabel("about.repository"), aboutLink("aboutRepository", Branding.REPOSITORY_URL, + Branding.REPOSITORY_URL, browser)); + details.addRow(3, aboutLabel("about.author"), new Label(Branding.AUTHOR)); + details.addRow(4, aboutLabel("about.email"), aboutLink("aboutEmail", Branding.EMAIL, + "mailto:" + Branding.EMAIL, browser)); + details.addRow(5, aboutLabel("about.license"), new Label(Messages.get("about.licenseName"))); + alert.getDialogPane().setContent(new VBox(14, message, details)); return alert; } - public static void showAbout(Window owner) { - createAbout(owner).showAndWait(); + private static Label aboutLabel(String key) { + Label label = new Label(Messages.get(key)); + label.getStyleClass().add("muted"); + return label; + } + + private static Hyperlink aboutLink(String id, String text, String url, Consumer browser) { + Hyperlink link = new Hyperlink(text); + link.setId(id); + link.setPadding(Insets.EMPTY); + link.setOnAction(event -> { + // Not shown as visited, it is opened again + link.setVisited(false); + if (browser != null) { + browser.accept(url); + } + }); + return link; + } + + public static void showAbout(Window owner, Consumer browser) { + createAbout(owner, browser).showAndWait(); } public static boolean confirm(Window owner, String title, String message) { diff --git a/src/main/java/com/github/sqljam/face/view/ExampleActions.java b/src/main/java/com/github/sqljam/face/view/ExampleActions.java new file mode 100644 index 0000000..41a4aab --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/ExampleActions.java @@ -0,0 +1,50 @@ +/* + * 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.function.Consumer; + +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.service.ExampleDatabase; +import javafx.stage.Window; + +/** + * @Description: ExampleActions restores the example database from the login page and the main window, e.g. after the + * user removed its data source or its file. An existing example is replaced after a confirmation. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public final class ExampleActions { + + private ExampleActions() { + } + + /** + * Restores the example database in the background + * + * @param onRestored called with the data source of the example when it is restored + */ + public static void restore(Window owner, AppContext context, Consumer onRestored) { + boolean exists = context.getProfileRegistry().findProfile(ExampleDatabase.PROFILE_ID).isPresent(); + if (exists && !Dialogs.confirm(owner, Messages.get("example.restore.title"), + Messages.format("example.restore.message", ExampleDatabase.PROFILE_NAME))) { + return; + } + TaskRunner.run(context::restoreExample, onRestored, e -> Dialogs.showError(owner, + Messages.get("example.restore.error"), e)); + } +} 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 ae376fd..c5ac061 100644 --- a/src/main/java/com/github/sqljam/face/view/ExportDialog.java +++ b/src/main/java/com/github/sqljam/face/view/ExportDialog.java @@ -20,6 +20,7 @@ import java.util.EnumMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; @@ -28,10 +29,15 @@ 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.DataFormat; import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.Dialect; import com.github.sqljam.impexp.ExportMode; import com.github.sqljam.impexp.Exporter; import com.github.sqljam.impexp.IdentifierCase; +import com.github.sqljam.impexp.ParquetExporter; +import com.github.sqljam.impexp.TableQuery; +import javafx.beans.InvalidationListener; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.collections.FXCollections; @@ -51,6 +57,7 @@ import javafx.scene.control.RadioButton; import javafx.scene.control.ScrollPane; import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; import javafx.scene.control.TextField; import javafx.scene.control.ToggleGroup; import javafx.scene.control.cell.CheckBoxListCell; @@ -76,6 +83,7 @@ public class ExportDialog extends Dialog { static { VERSION_PRESETS.put(DbType.MYSQL, List.of("", "5.6", "5.7", "8.0")); + VERSION_PRESETS.put(DbType.MARIADB, List.of("", "10.6", "10.11", "11.4", "11.8")); VERSION_PRESETS.put(DbType.POSTGRESQL, List.of("", "9.6", "10", "12", "16")); VERSION_PRESETS.put(DbType.ORACLE, List.of("", "11.2", "12.1", "19", "23")); VERSION_PRESETS.put(DbType.SQLSERVER, List.of("", "2008", "2012", "2016", "2019", "2022")); @@ -119,10 +127,18 @@ public class ExportDialog extends Dialog { private final RadioButton perTableRadio = new RadioButton(Messages.get("export.script.perTable")); private final Spinner maxFileSizeSpinner = new Spinner<>(0, 100000, 10, 10); private final CheckBox lobSeparatedCheck = new CheckBox(Messages.get("export.script.lobSeparated")); + 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 ComboBox compressionCombo = new ComboBox<>(); private final Label layoutHint = new Label(); private final ComboBox targetCombo = new ComboBox<>(); private final ComboBox targetCatalogCombo = new ComboBox<>(); private final ComboBox targetSchemaCombo = new ComboBox<>(); + private final TextField tableNamePatternField = new TextField(); + private final Label queryLabel = new Label(); + private TableQuery tableQuery; + private String queryTableName; private final CheckBox createSchemaCheck = new CheckBox(Messages.get("export.database.createSchema")); private final Label errorLabel = new Label(); private Button startButton; @@ -142,7 +158,16 @@ private static class TableChoice { } public ExportDialog(Window owner, AppContext context, DbNode node) { + this(owner, context, node, null); + } + + /** + * Export of a table narrowed by a query of the data viewer: selected columns, condition and order + */ + public ExportDialog(Window owner, AppContext context, DbNode node, TableQuery query) { this.context = context; + this.tableQuery = query != null && !query.isEmpty() && node != null && node.getTable() != null ? query : null; + this.queryTableName = tableQuery != null ? node.getTable().getName() : null; Dialogs.initOwner(this, owner); Branding.applyIcons(this); setTitle(Messages.get("export.title")); @@ -166,10 +191,14 @@ public ExportDialog(Window owner, AppContext context, DbNode node) { perTableRadio.setId("perTableRadio"); maxFileSizeSpinner.setId("maxFileSizeSpinner"); lobSeparatedCheck.setId("lobSeparatedCheck"); + sqlFormatRadio.setId("sqlFormatRadio"); + parquetFormatRadio.setId("parquetFormatRadio"); + compressionCombo.setId("compressionCombo"); layoutHint.setId("layoutHint"); targetCombo.setId("targetCombo"); targetCatalogCombo.setId("targetCatalogCombo"); targetSchemaCombo.setId("targetSchemaCombo"); + tableNamePatternField.setId("tableNamePatternField"); createSchemaCheck.setId("createSchemaCheck"); errorLabel.setId("errorLabel"); recreateCheck.setId("recreateCheck"); @@ -207,6 +236,11 @@ public ExportDialog(Window owner, AppContext context, DbNode node) { setResultConverter(buttonType -> buttonType == ButtonType.OK ? buildRequest() : null); registerValidation(); preselect(node); + if (tableQuery != null) { + queryLabel.setText(Messages.format("export.query", queryTableName, tableQuery)); + queryLabel.setVisible(true); + queryLabel.setManaged(true); + } updateValidation(); } @@ -214,11 +248,17 @@ public ExportDialog(Window owner, AppContext context, DbNode node) { * Start is enabled only when the inputs are valid, the reason is shown below the form */ private void registerValidation() { - javafx.beans.InvalidationListener listener = observable -> updateValidation(); + InvalidationListener listener = observable -> updateValidation(); sourceCombo.valueProperty().addListener(listener); targetGroup.selectedToggleProperty().addListener(listener); directoryField.textProperty().addListener(listener); targetCombo.valueProperty().addListener(listener); + targetCatalogCombo.valueProperty().addListener(listener); + targetSchemaCombo.valueProperty().addListener(listener); + targetSchemaCombo.getEditor().textProperty().addListener(listener); + tableNamePatternField.textProperty().addListener(listener); + catalogCombo.valueProperty().addListener(listener); + schemaCombo.valueProperty().addListener(listener); scriptVersionCombo.getEditor().textProperty().addListener(listener); scriptVersionCombo.valueProperty().addListener(listener); pageSizeSpinner.getEditor().textProperty().addListener(listener); @@ -305,6 +345,13 @@ public TableChoice fromString(String text) { grid.addRow(1, new Label(Messages.get("export.source.catalog")), catalogCombo); grid.addRow(2, new Label(Messages.get("export.source.schema")), schemaCombo); VBox box = new VBox(8, grid, new Label(Messages.get("export.tables.hint")), filterBox, tableList); + // Rows of the table narrowed in the data viewer + queryLabel.setId("queryLabel"); + queryLabel.getStyleClass().add("muted"); + queryLabel.setWrapText(true); + queryLabel.setVisible(false); + queryLabel.setManaged(false); + box.getChildren().add(queryLabel); return box; } @@ -373,7 +420,7 @@ private Node createTargetPane() { browseButton.setOnAction(event -> chooseDirectory()); HBox directoryBox = new HBox(8, directoryField, browseButton); HBox.setHgrow(directoryField, Priority.ALWAYS); - scriptDbTypeCombo.getItems().addAll(DbType.values()); + scriptDbTypeCombo.getItems().addAll(DbTypeCells.getDbTypesByCategory()); DbTypeCells.setupDbTypeCombo(scriptDbTypeCombo); scriptDbTypeCombo.valueProperty().addListener((obs, oldType, type) -> { scriptVersionCombo.getItems().setAll(VERSION_PRESETS.getOrDefault(type, List.of(""))); @@ -389,6 +436,18 @@ private Node createTargetPane() { long maxFileSize = context.getSettings().getMaxDataFileSize(); maxFileSizeSpinner.getValueFactory().setValue(maxFileSize > 0 ? (int) Math.max(1, maxFileSize / MB) : 10); lobSeparatedCheck.setSelected(true); + sqlFormatRadio.setToggleGroup(formatGroup); + parquetFormatRadio.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 + 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()); layoutHint.getStyleClass().addAll("muted", "mono"); layoutHint.setWrapText(true); strategyGroup.selectedToggleProperty().addListener((obs, oldToggle, toggle) -> updateLayoutHint()); @@ -401,6 +460,9 @@ private Node createTargetPane() { scriptGrid.addRow(row++, new Label(Messages.get("export.script.dbType")), scriptDbTypeCombo); 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)); + scriptGrid.addRow(row++, new Label(Messages.get("export.format.compression")), compressionCombo); 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, @@ -426,10 +488,20 @@ private Node createTargetPane() { } }); GridPane databaseGrid = grid(); - databaseGrid.addRow(0, new Label(Messages.get("export.database.connection")), targetCombo); + databaseGrid.addRow(0, new Label(Messages.get("export.database.connection")), ConnectionDialog.targetField( + targetCombo, context, profile -> { + targetCombo.getItems().setAll(context.getProfileRegistry().getProfiles()); + ConnectionDialog.selectProfile(targetCombo, profile); + })); databaseGrid.addRow(1, new Label(Messages.get("export.database.catalog")), targetCatalogCombo); databaseGrid.addRow(2, new Label(Messages.get("export.database.schema")), targetSchemaCombo); databaseGrid.add(createSchemaCheck, 1, 3); + tableNamePatternField.setPromptText(Dialect.TABLE_PLACEHOLDER); + Label tableNameHint = new Label(Messages.get("export.database.tableName.hint")); + tableNameHint.getStyleClass().add("muted"); + tableNameHint.setWrapText(true); + databaseGrid.addRow(4, new Label(Messages.get("export.database.tableName")), tableNamePatternField); + databaseGrid.add(tableNameHint, 1, 5); VBox databasePane = new VBox(databaseGrid); databasePane.getStyleClass().add("target-pane"); @@ -441,6 +513,10 @@ private Node createTargetPane() { } private void updateLayoutHint() { + if (parquetFormatRadio.isSelected()) { + layoutHint.setText("schema.sql\ndata/
.parquet\nconstraints.sql\nmanifest.json"); + return; + } boolean perTable = perTableRadio.isSelected(); boolean split = maxFileSizeSpinner.getValue() != null && maxFileSizeSpinner.getValue() > 0; StringBuilder hint = new StringBuilder(); @@ -647,8 +723,13 @@ private void loadTargetSchemas(ConnectionProfile profile, String catalog) { }, this::showError); } + /** + * Errors of loading after the dialog is closed are not shown + */ private void showError(Throwable e) { - Dialogs.showError(getDialogPane().getScene().getWindow(), Messages.get("export.loadError"), e); + if (isShowing()) { + Dialogs.showError(getDialogPane().getScene().getWindow(), Messages.get("export.loadError"), e); + } } String validate() { @@ -679,17 +760,53 @@ String validate() { } } else if (targetCombo.getValue() == null) { return Messages.get("export.error.target"); + } else { + return validateTableNames(); } return null; } + /** + * Tables are not imported into themselves, tables copied in the same schema need another name. A name without + * {table} is the name of a single table. + */ + private String validateTableNames() { + String pattern = StringUtils.trimToEmpty(tableNamePatternField.getText()); + boolean renamed = !pattern.isEmpty() && !Dialect.TABLE_PLACEHOLDER.equals(pattern); + if (renamed && !pattern.contains(Dialect.TABLE_PLACEHOLDER) && getSelectedTableCount() != 1) { + return Messages.format("export.error.tableName", Dialect.TABLE_PLACEHOLDER); + } + ConnectionProfile source = sourceCombo.getValue(); + ConnectionProfile target = targetCombo.getValue(); + if (!renamed && source != null && target != null && Objects.equals(source.getId(), target.getId()) + && isSameName(catalogCombo.isDisabled() ? null : catalogCombo.getValue(), + targetCatalogCombo.isDisabled() ? null : targetCatalogCombo.getValue()) + && isSameName(schemaCombo.isDisabled() ? null : schemaCombo.getValue(), + targetSchemaCombo.isDisabled() ? null : targetSchemaCombo.getEditor().getText())) { + return Messages.format("export.error.sameTables", Dialect.TABLE_PLACEHOLDER); + } + return null; + } + + /** + * A target name which is not given is the source name + */ + private static boolean isSameName(String sourceName, String targetName) { + return StringUtils.isBlank(targetName) || StringUtils.isBlank(sourceName) + || sourceName.trim().equalsIgnoreCase(targetName.trim()); + } + + private int getSelectedTableCount() { + return (int) tableChoices.stream().filter(choice -> choice.selected.get()).count(); + } + private static int getMin(Spinner spinner) { - return ((javafx.scene.control.SpinnerValueFactory.IntegerSpinnerValueFactory) spinner.getValueFactory()) + return ((SpinnerValueFactory.IntegerSpinnerValueFactory) spinner.getValueFactory()) .getMin(); } private static int getMax(Spinner spinner) { - return ((javafx.scene.control.SpinnerValueFactory.IntegerSpinnerValueFactory) spinner.getValueFactory()) + return ((SpinnerValueFactory.IntegerSpinnerValueFactory) spinner.getValueFactory()) .getMax(); } @@ -751,6 +868,8 @@ TransferRequest buildRequest() { : DataFileStrategy.SINGLE_FILE); request.setMaxFileSize(maxFileSizeSpinner.getValue() * MB); request.setLobSeparated(lobSeparatedCheck.isSelected()); + request.setDataFormat(parquetFormatRadio.isSelected() ? DataFormat.PARQUET : DataFormat.SQL); + request.setCompression(compressionCombo.getValue()); context.getSettings().setLastExportDirectory(directory.getAbsolutePath()); context.getSettings().setMaxDataFileSize(maxFileSizeSpinner.getValue() * MB); } else { @@ -760,6 +879,11 @@ TransferRequest buildRequest() { request.setTargetSchema(targetSchemaCombo.isDisabled() ? null : StringUtils.trimToNull( targetSchemaCombo.getEditor().getText())); request.setTargetSchemaCreated(createSchemaCheck.isSelected()); + request.setTableNamePattern(StringUtils.trimToNull(tableNamePatternField.getText())); + } + // The query of the data viewer narrows its table + if (tableQuery != null && selected.contains(queryTableName)) { + request.getTableQueries().put(queryTableName, tableQuery); } context.getSettings().setTransferPageSize(pageSizeSpinner.getValue()); context.saveSettings(); 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 647ecb7..bdc8e67 100644 --- a/src/main/java/com/github/sqljam/face/view/Icons.java +++ b/src/main/java/com/github/sqljam/face/view/Icons.java @@ -51,6 +51,9 @@ public final class Icons { public static final String LAST = "fth-chevrons-right"; public static final String CONNECT = "fth-link"; public static final String OPEN = "fth-external-link"; + public static final String GITHUB = "fth-github"; + public static final String DISCONNECT = "fth-power"; + public static final String EXIT = "fth-log-out"; 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 e5bd329..f6ab515 100644 --- a/src/main/java/com/github/sqljam/face/view/ImportPackageDialog.java +++ b/src/main/java/com/github/sqljam/face/view/ImportPackageDialog.java @@ -17,13 +17,16 @@ import java.io.File; import java.util.List; +import java.util.Locale; import java.util.Objects; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; import com.github.sqljam.face.model.ConnectionProfile; +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 javafx.beans.property.ReadOnlyObjectWrapper; import javafx.event.ActionEvent; import javafx.geometry.Insets; @@ -61,6 +64,8 @@ public class ImportPackageDialog extends Dialog tableView = new TableView<>(); private final VBox summaryPane; private final ComboBox targetCombo = new ComboBox<>(); @@ -110,6 +115,8 @@ public ImportPackageDialog(Window owner, AppContext context, DbNode node) { sourceValue.setId("sourceValue"); targetValue.setId("targetValue"); filesValue.setId("filesValue"); + formatValue.setId("formatValue"); + viewButton.setId("viewPackageButton"); tableView.setId("packageTables"); targetCombo.setId("targetCombo"); catalogCombo.setId("catalogCombo"); @@ -124,7 +131,14 @@ public ImportPackageDialog(Window owner, AppContext context, DbNode node) { }); Button browseButton = new Button(null, Icons.of(Icons.FOLDER)); browseButton.setOnAction(event -> chooseDirectory()); - HBox directoryBox = new HBox(8, directoryField, browseButton); + // Files of the package are listed and previewed + viewButton.setOnAction(event -> { + File directory = new File(directoryField.getText().trim()); + if (directory.isDirectory()) { + new PackageViewer(getDialogPane().getScene().getWindow(), context, directory).show(); + } + }); + HBox directoryBox = new HBox(8, directoryField, browseButton, viewButton); HBox.setHgrow(directoryField, Priority.ALWAYS); GridPane summaryGrid = grid(); @@ -133,6 +147,7 @@ public ImportPackageDialog(Window owner, AppContext context, DbNode node) { summaryGrid.addRow(2, new Label(Messages.get("import.manifest.source")), sourceValue); 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); setupTableView(); Label summaryTitle = new Label(Messages.get("import.section.package")); summaryTitle.getStyleClass().add("section-title"); @@ -157,7 +172,12 @@ public ImportPackageDialog(Window owner, AppContext context, DbNode node) { } }); GridPane targetGrid = grid(); - targetGrid.addRow(0, new Label(Messages.get("export.database.connection")), targetCombo); + targetGrid.addRow(0, new Label(Messages.get("export.database.connection")), ConnectionDialog.targetField( + targetCombo, context, profile -> { + // A data source of another type than the package is not listed + filterTargets(); + ConnectionDialog.selectProfile(targetCombo, profile); + })); 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); @@ -273,10 +293,16 @@ private void loadManifest() { return; } statusValue.setText(Messages.get("tree.loading")); - TaskRunner.run(() -> new Object[]{ExportManifest.read(directory), isScriptDirectory(directory)}, result -> { - directoryValid = (Boolean) result[1] || result[0] != null; + TaskRunner.run(() -> new Object[]{ExportManifest.read(directory), isScriptDirectory(directory), + isParquetDirectory(directory)}, result -> { + // Parquet files without manifest are not sql scripts, even with schema.sql + directoryValid = result[0] != null || (Boolean) result[1] && !(Boolean) result[2]; showSummary((ExportManifest) result[0], directoryValid); validateTarget(); + // Parquet files of other tools have no manifest, they are loaded by Import Parquet Files + if (!directoryValid && (Boolean) result[2]) { + warningLabel.setText(Messages.get("import.warning.parquetFiles")); + } }, e -> { directoryValid = false; showSummary(null, false); @@ -289,6 +315,19 @@ static boolean isScriptDirectory(File directory) { || new File(directory, "data").isDirectory(); } + /** + * Directory with Parquet files (directly or in data/) but without manifest.json + */ + static boolean isParquetDirectory(File directory) { + return hasParquetFiles(directory) || hasParquetFiles(new File(directory, "data")); + } + + private static boolean hasParquetFiles(File directory) { + File[] files = directory.listFiles((parent, name) -> name.toLowerCase(Locale.ENGLISH) + .endsWith(ParquetExporter.PARQUET_EXTENSION)); + return files != null && files.length > 0; + } + private void showSummary(ExportManifest manifest, boolean valid) { this.manifest = manifest; tableView.getItems().clear(); @@ -299,6 +338,7 @@ private void showSummary(ExportManifest manifest, boolean valid) { sourceValue.setText("-"); targetValue.setText("-"); filesValue.setText("-"); + formatValue.setText(valid ? Messages.get("export.format.sql") : "-"); filterTargets(); return; } @@ -312,6 +352,8 @@ 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))); + formatValue.setText(Messages.get(manifest.getDataFormat() == DataFormat.PARQUET ? "export.format.parquet" + : "export.format.sql")); tableView.getItems().setAll(manifest.getTables()); filterTargets(); } @@ -412,7 +454,16 @@ private void loadCatalogs(ConnectionProfile profile) { : catalogs.contains(profile.getDatabase()) ? profile.getDatabase() : catalogs.get(0); catalogCombo.setValue(preferred); schemaCombo.setDisable(!profile.getDbType().isSchemaSupported()); - }, e -> Dialogs.showError(getDialogPane().getScene().getWindow(), Messages.get("export.loadError"), e)); + }, this::showLoadError); + } + + /** + * Errors of loading after the dialog is closed are not shown + */ + private void showLoadError(Throwable e) { + if (isShowing()) { + Dialogs.showError(getDialogPane().getScene().getWindow(), Messages.get("export.loadError"), e); + } } private void loadSchemas(ConnectionProfile profile, String catalog) { @@ -424,7 +475,7 @@ private void loadSchemas(ConnectionProfile profile, String catalog) { if (StringUtils.isNotBlank(preferred)) { schemaCombo.setValue(preferred); } - }, e -> Dialogs.showError(getDialogPane().getScene().getWindow(), Messages.get("export.loadError"), e)); + }, this::showLoadError); } ImportRequest buildRequest() { diff --git a/src/main/java/com/github/sqljam/face/view/ImportParquetDialog.java b/src/main/java/com/github/sqljam/face/view/ImportParquetDialog.java new file mode 100644 index 0000000..ffa5644 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/ImportParquetDialog.java @@ -0,0 +1,408 @@ +/* + * 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.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.service.TransferService; +import com.github.sqljam.impexp.ParquetExporter; +import com.github.sqljam.impexp.ParquetImporter; +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.ComboBox; +import javafx.scene.control.Dialog; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.RadioButton; +import javafx.scene.control.SelectionMode; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.control.ToggleGroup; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import javafx.stage.Window; +import lombok.Getter; + +/** + * @Description: ImportParquetDialog loads Parquet files (of SqlJam or other tools) into a table of a database. Files + * of one table are chosen together, columns and the first rows are previewed, the table is created + * (or replaced) or the rows are appended to an existing table. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public class ImportParquetDialog extends Dialog { + + public static final int PREVIEW_ROWS = 50; + + private final AppContext context; + private final ListView fileList = new ListView<>(); + private final Label previewLabel = new Label(); + private final TableView> previewTable = new TableView<>(); + private final TextField tableField = new TextField(); + private final RadioButton createRadio = new RadioButton(Messages.get("parquet.mode.create")); + private final RadioButton appendRadio = new RadioButton(Messages.get("parquet.mode.append")); + private final ComboBox targetCombo = new ComboBox<>(); + private final ComboBox catalogCombo = new ComboBox<>(); + private final ComboBox schemaCombo = new ComboBox<>(); + private final Label warningLabel = new Label(); + private final Button startButton; + private boolean filesValid; + private int previewVersion; + + /** + * Load of Parquet files into a table of the target connection + */ + @Getter + public static class ParquetRequest { + + private final List files; + private final String tableName; + private final ParquetImporter.Mode mode; + private final ConnectionProfile target; + private final String catalog; + private final String schema; + + ParquetRequest(List files, String tableName, ParquetImporter.Mode mode, ConnectionProfile target, + String catalog, String schema) { + this.files = files; + this.tableName = tableName; + this.mode = mode; + this.target = target; + this.catalog = catalog; + this.schema = schema; + } + } + + public ImportParquetDialog(Window owner, AppContext context, DbNode node) { + this.context = context; + Dialogs.initOwner(this, owner); + Branding.applyIcons(this); + setTitle(Messages.get("parquet.title")); + setHeaderText(Messages.get("parquet.header")); + setResizable(true); + + fileList.setId("parquetFiles"); + previewLabel.setId("parquetPreviewLabel"); + previewTable.setId("parquetPreview"); + tableField.setId("tableField"); + createRadio.setId("createRadio"); + appendRadio.setId("appendRadio"); + targetCombo.setId("targetCombo"); + catalogCombo.setId("catalogCombo"); + schemaCombo.setId("schemaCombo"); + warningLabel.setId("warningLabel"); + + fileList.setPrefHeight(110); + // The name of a file with its folder, the whole path is shown by the tooltip + fileList.setCellFactory(view -> new ListCell<>() { + @Override + protected void updateItem(File file, boolean empty) { + super.updateItem(file, empty); + if (empty || file == null) { + setText(null); + setGraphic(null); + setTooltip(null); + return; + } + Label folder = new Label(file.getParent()); + folder.getStyleClass().add("muted"); + setText(null); + setGraphic(new HBox(8, Icons.of(Icons.TABLE), new Label(file.getName()), folder)); + setTooltip(new Tooltip(file.getAbsolutePath())); + } + }); + fileList.setPlaceholder(new Label(Messages.get("parquet.files.empty"))); + fileList.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + Button addButton = new Button(Messages.get("parquet.files.add"), Icons.of(Icons.ADD)); + addButton.setId("addFilesButton"); + addButton.setOnAction(event -> chooseFiles()); + Button removeButton = new Button(Messages.get("parquet.files.remove"), Icons.of(Icons.DELETE)); + removeButton.setId("removeFilesButton"); + removeButton.disableProperty().bind(fileList.getSelectionModel().selectedItemProperty().isNull()); + removeButton.setOnAction(event -> removeFiles(new ArrayList<>(fileList.getSelectionModel() + .getSelectedItems()))); + VBox fileButtons = new VBox(8, addButton, removeButton); + addButton.setMaxWidth(Double.MAX_VALUE); + removeButton.setMaxWidth(Double.MAX_VALUE); + HBox fileBox = new HBox(8, fileList, fileButtons); + HBox.setHgrow(fileList, Priority.ALWAYS); + + previewLabel.getStyleClass().add("muted"); + previewTable.setPrefHeight(200); + 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); + filesPane.getStyleClass().add("form-section"); + + ToggleGroup modeGroup = new ToggleGroup(); + createRadio.setToggleGroup(modeGroup); + appendRadio.setToggleGroup(modeGroup); + createRadio.setSelected(true); + DbTypeCells.setupProfileCombo(targetCombo); + targetCombo.setMaxWidth(Double.MAX_VALUE); + catalogCombo.setMaxWidth(Double.MAX_VALUE); + schemaCombo.setMaxWidth(Double.MAX_VALUE); + schemaCombo.setEditable(true); + schemaCombo.setPromptText(Messages.get("import.schema.prompt")); + tableField.setPromptText(Messages.get("parquet.table.prompt")); + tableField.textProperty().addListener((obs, oldText, text) -> validate()); + targetCombo.valueProperty().addListener((obs, oldProfile, profile) -> { + loadCatalogs(profile); + validate(); + }); + catalogCombo.valueProperty().addListener((obs, oldCatalog, catalog) -> { + ConnectionProfile profile = targetCombo.getValue(); + if (catalog != null && profile != null && profile.getDbType().isSchemaSupported()) { + loadSchemas(profile, catalog); + } + }); + GridPane targetGrid = grid(); + targetGrid.addRow(0, new Label(Messages.get("export.database.connection")), ConnectionDialog.targetField( + targetCombo, context, profile -> { + targetCombo.getItems().setAll(context.getProfileRegistry().getProfiles()); + ConnectionDialog.selectProfile(targetCombo, profile); + })); + 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)); + Label targetTitle = new Label(Messages.get("import.section.target")); + targetTitle.getStyleClass().add("section-title"); + VBox targetPane = new VBox(8, targetTitle, targetGrid); + targetPane.getStyleClass().add("form-section"); + + warningLabel.getStyleClass().add("status-error"); + warningLabel.setWrapText(true); + VBox content = new VBox(14, filesPane, targetPane, warningLabel); + content.setPadding(new Insets(4)); + content.setPrefWidth(760); + getDialogPane().setContent(content); + getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + startButton = (Button) getDialogPane().lookupButton(ButtonType.OK); + startButton.setId("startButton"); + startButton.setText(Messages.get("import.start")); + startButton.setGraphic(Icons.of(Icons.START)); + startButton.addEventFilter(ActionEvent.ACTION, event -> { + if (!isReady()) { + event.consume(); + } + }); + setResultConverter(buttonType -> buttonType == ButtonType.OK ? buildRequest() : null); + + targetCombo.getItems().setAll(context.getProfileRegistry().getProfiles()); + ConnectionProfile selected = node != null && node.getProfile() != null ? targetCombo.getItems().stream() + .filter(profile -> profile.getId().equals(node.getProfile().getId())).findFirst().orElse(null) : null; + targetCombo.setValue(selected != null ? selected : targetCombo.getItems().isEmpty() ? null + : targetCombo.getItems().get(0)); + showPreview(null); + validate(); + } + + private static GridPane grid() { + GridPane grid = new GridPane(); + grid.setHgap(10); + grid.setVgap(8); + ColumnConstraints labelColumn = new ColumnConstraints(); + labelColumn.setMinWidth(150); + ColumnConstraints fieldColumn = new ColumnConstraints(); + fieldColumn.setHgrow(Priority.ALWAYS); + grid.getColumnConstraints().addAll(labelColumn, fieldColumn); + return 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)); + String lastDirectory = context.getSettings().getLastImportDirectory(); + if (StringUtils.isNotBlank(lastDirectory) && new File(lastDirectory).isDirectory()) { + chooser.setInitialDirectory(new File(lastDirectory)); + } + List files = chooser.showOpenMultipleDialog(getDialogPane().getScene().getWindow()); + if (files != null && !files.isEmpty()) { + context.getSettings().setLastImportDirectory(files.get(0).getParentFile().getAbsolutePath()); + context.saveSettings(); + addFiles(files); + } + } + + /** + * Adds Parquet files of a table, the table name is the base name of the first file by default + */ + public void addFiles(List files) { + for (File file : files) { + if (!fileList.getItems().contains(file)) { + fileList.getItems().add(file); + } + } + if (StringUtils.isBlank(tableField.getText()) && !fileList.getItems().isEmpty()) { + tableField.setText(getTableName(fileList.getItems().get(0))); + } + loadPreview(); + } + + void removeFiles(List files) { + fileList.getItems().removeAll(files); + loadPreview(); + } + + /** + * Base name of a Parquet file as an identifier: other characters are replaced by underscores + */ + static String getTableName(File file) { + String name = FilenameUtils.getBaseName(file.getName()); + // Files of a schema qualified table are named schema.table.parquet + if (name.contains(".")) { + name = name.substring(name.lastIndexOf('.') + 1); + } + name = name.replaceAll("[^\\p{L}\\p{N}_]", "_"); + return name.isEmpty() || Character.isDigit(name.charAt(0)) ? "t_" + name : name; + } + + private void loadPreview() { + List files = new ArrayList<>(fileList.getItems()); + int version = ++previewVersion; + filesValid = false; + if (files.isEmpty()) { + showPreview(null); + validate(); + return; + } + previewLabel.setText(Messages.get("package.loading")); + previewTable.getColumns().clear(); + previewTable.getItems().clear(); + validate(); + TaskRunner.run(() -> context.getTransferService().previewParquet(files, PREVIEW_ROWS), preview -> { + if (version == previewVersion) { + filesValid = true; + showPreview(preview); + validate(); + } + }, e -> { + if (version == previewVersion) { + showPreview(null); + warningLabel.setText(Messages.format("parquet.error.read", StringUtils.defaultIfBlank( + e.getMessage(), e.getClass().getSimpleName()))); + startButton.setDisable(true); + } + }); + } + + private void showPreview(TransferService.ParquetPreview preview) { + previewTable.getColumns().clear(); + previewTable.getItems().clear(); + if (preview == null) { + previewLabel.setText(Messages.get("parquet.files.hint")); + return; + } + List columns = preview.getColumns(); + for (int i = 0; i < columns.size(); i++) { + int index = i; + TableColumn, String> column = new TableColumn<>(columns.get(i)[0]); + Label header = new Label(columns.get(i)[0] + "\n" + columns.get(i)[1].toLowerCase(Locale.ENGLISH)); + column.setText(null); + column.setGraphic(header); + column.setCellValueFactory(features -> new ReadOnlyObjectWrapper<>(index < features.getValue().size() + ? features.getValue().get(index) : null)); + column.setPrefWidth(120); + previewTable.getColumns().add(column); + } + previewTable.getItems().setAll(preview.getFirstRows()); + previewLabel.setText(Messages.format("parquet.preview", columns.size(), preview.getRows(), + preview.getFirstRows().size())); + } + + private boolean isReady() { + return filesValid && targetCombo.getValue() != null && StringUtils.isNotBlank(tableField.getText()); + } + + private void validate() { + String warning = ""; + if (context.getProfileRegistry().getProfiles().isEmpty()) { + warning = Messages.get("export.error.noConnections"); + } else if (!fileList.getItems().isEmpty() && filesValid && StringUtils.isBlank(tableField.getText())) { + warning = Messages.get("parquet.error.table"); + } + warningLabel.setText(warning); + if (startButton != null) { + startButton.setDisable(!isReady()); + } + } + + private void loadCatalogs(ConnectionProfile profile) { + catalogCombo.getItems().clear(); + schemaCombo.getItems().clear(); + if (profile == null) { + return; + } + TaskRunner.run(() -> context.getSessionManager().getSession(profile).getCatalogs(), catalogs -> { + catalogCombo.setDisable(catalogs.isEmpty()); + catalogCombo.getItems().setAll(catalogs); + if (catalogs.isEmpty()) { + schemaCombo.setDisable(!profile.getDbType().isSchemaSupported()); + if (profile.getDbType().isSchemaSupported()) { + loadSchemas(profile, null); + } + return; + } + catalogCombo.setValue(catalogs.contains(profile.getDatabase()) ? profile.getDatabase() + : catalogs.get(0)); + schemaCombo.setDisable(!profile.getDbType().isSchemaSupported()); + }, this::showLoadError); + } + + /** + * Errors of loading after the dialog is closed are not shown + */ + private void showLoadError(Throwable e) { + if (isShowing()) { + Dialogs.showError(getDialogPane().getScene().getWindow(), Messages.get("export.loadError"), e); + } + } + + private void loadSchemas(ConnectionProfile profile, String catalog) { + schemaCombo.setDisable(false); + TaskRunner.run(() -> context.getSessionManager().getSession(profile).getSchemas(catalog), + schemas -> schemaCombo.getItems().setAll(schemas), + this::showLoadError); + } + + ParquetRequest buildRequest() { + return new ParquetRequest(new ArrayList<>(fileList.getItems()), tableField.getText().trim(), + appendRadio.isSelected() ? ParquetImporter.Mode.APPEND : ParquetImporter.Mode.CREATE, + targetCombo.getValue(), catalogCombo.isDisabled() ? null : catalogCombo.getValue(), + schemaCombo.isDisabled() ? null : StringUtils.trimToNull(schemaCombo.getEditor().getText())); + } +} diff --git a/src/main/java/com/github/sqljam/face/view/LoginView.java b/src/main/java/com/github/sqljam/face/view/LoginView.java index 9468958..346c513 100644 --- a/src/main/java/com/github/sqljam/face/view/LoginView.java +++ b/src/main/java/com/github/sqljam/face/view/LoginView.java @@ -16,13 +16,16 @@ package com.github.sqljam.face.view; import java.io.IOException; +import java.util.Comparator; import java.util.function.Consumer; import org.apache.commons.lang3.StringUtils; import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.service.ExampleDatabase; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.collections.transformation.FilteredList; +import javafx.collections.transformation.SortedList; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; @@ -31,6 +34,7 @@ import javafx.scene.control.ScrollPane; import javafx.scene.control.Separator; import javafx.scene.control.TextField; +import javafx.scene.control.Tooltip; import javafx.scene.image.ImageView; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; @@ -50,7 +54,10 @@ public class LoginView extends StackPane { private final AppContext context; private final ObservableList profiles = FXCollections.observableArrayList(); private final FilteredList filteredProfiles = new FilteredList<>(profiles, profile -> true); - private final ListView profileList = new ListView<>(filteredProfiles); + // Profiles are grouped by category: relational databases, OLAP databases + private final SortedList sortedProfiles = new SortedList<>(filteredProfiles, + Comparator.comparing(DbTypeCells::categoryOf)); + private final ListView profileList = new ListView<>(sortedProfiles); private final ConnectionForm connectionForm = new ConnectionForm(); private final Button loginButton = new Button(Messages.get("login.login"), Icons.of(Icons.CONNECT)); private final Button testButton = new Button(Messages.get("connection.test")); @@ -94,7 +101,16 @@ public LoginView(AppContext context, Consumer onLogin) { deleteButton.setId("deleteButton"); deleteButton.disableProperty().bind(profileList.getSelectionModel().selectedItemProperty().isNull()); deleteButton.setOnAction(event -> deleteSelected()); - HBox listButtons = new HBox(8, newButton, deleteButton); + // The example database comes back after the user removed it + Button exampleButton = new Button(Messages.get("example.button"), Icons.of(Icons.CATALOG)); + exampleButton.setId("exampleButton"); + exampleButton.setTooltip(new Tooltip(Messages.get("example.button.tooltip"))); + exampleButton.setOnAction(event -> ExampleActions.restore(getScene().getWindow(), context, profile -> { + reloadProfiles(); + profileList.getItems().stream().filter(ExampleDatabase::isExample).findFirst() + .ifPresent(profileList.getSelectionModel()::select); + })); + HBox listButtons = new HBox(8, newButton, deleteButton, exampleButton); Label savedLabel = new Label(Messages.get("login.savedConnections")); savedLabel.getStyleClass().add("section-title"); VBox left = new VBox(10, savedLabel, searchField, profileList, listButtons); @@ -109,7 +125,11 @@ public LoginView(AppContext context, Consumer onLogin) { Label subtitle = new Label(Messages.get("login.subtitle")); subtitle.getStyleClass().add("login-subtitle"); VBox header = new VBox(2, title, subtitle); - HBox headerBox = new HBox(16, logo, header); + // The GitHub mark of the source repository at the top right, the card keeps its size + Region headerSpacer = new Region(); + HBox.setHgrow(headerSpacer, Priority.ALWAYS); + Label repositoryMark = Branding.repositoryMark(context::openDocument); + HBox headerBox = new HBox(16, logo, header, headerSpacer, repositoryMark); headerBox.setAlignment(Pos.CENTER_LEFT); testButton.setOnAction(event -> connectionForm.testConnection(null)); 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 a9a3236..3bfa556 100644 --- a/src/main/java/com/github/sqljam/face/view/MainView.java +++ b/src/main/java/com/github/sqljam/face/view/MainView.java @@ -15,14 +15,18 @@ */ package com.github.sqljam.face.view; +import java.io.File; import java.io.IOException; import java.util.Optional; import com.github.sqljam.face.model.ConnectionProfile; import com.github.sqljam.face.model.TransferRequest; import com.github.sqljam.face.service.TransferService; -import com.github.sqljam.impexp.ScriptImporter; +import com.github.sqljam.impexp.AbstractFileImporter; +import com.github.sqljam.impexp.ParquetImporter; +import com.github.sqljam.impexp.TableQuery; import javafx.application.Platform; +import javafx.beans.binding.Bindings; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; @@ -32,6 +36,7 @@ import javafx.scene.control.MenuItem; import javafx.scene.control.ProgressIndicator; import javafx.scene.control.RadioMenuItem; +import javafx.scene.control.Separator; import javafx.scene.control.SeparatorMenuItem; import javafx.scene.control.SplitPane; import javafx.scene.control.Tab; @@ -41,8 +46,8 @@ import javafx.scene.control.Tooltip; import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; -import javafx.scene.input.KeyCombination; import javafx.scene.input.KeyCodeCombination; +import javafx.scene.input.KeyCombination; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; @@ -50,7 +55,10 @@ import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.text.TextAlignment; +import javafx.stage.DirectoryChooser; +import javafx.stage.Stage; import javafx.stage.Window; +import javafx.stage.WindowEvent; /** * @Description: MainView is the main window: connection tree on the left and opened tables on the right @@ -72,15 +80,17 @@ public MainView(AppContext context) { connectionTree.setOnOpenTable(this::openTable); connectionTree.setOnExport(this::showExportDialog); connectionTree.setOnImportScripts(this::showImportDialog); + connectionTree.setOnImportParquet(this::showImportParquetDialog); connectionTree.setOnEditConnection(node -> editConnection(node.getProfile())); connectionTree.setOnDeleteConnection(node -> deleteConnection(node.getProfile())); connectionTree.setOnStatus(statusLabel::setText); + connectionTree.setOnDisconnect(this::closeTabs); tabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.ALL_TABS); tabPane.setId("tableTabs"); statusLabel.setId("statusLabel"); StackPane center = new StackPane(createWelcome(), tabPane); - tabPane.visibleProperty().bind(javafx.beans.binding.Bindings.isNotEmpty(tabPane.getTabs())); + tabPane.visibleProperty().bind(Bindings.isNotEmpty(tabPane.getTabs())); SplitPane splitPane = new SplitPane(connectionTree, center); splitPane.setDividerPositions(0.24); SplitPane.setResizableWithParent(connectionTree, false); @@ -119,18 +129,17 @@ private MenuBar createMenuBar() { MenuItem importScripts = new MenuItem(Messages.get("action.importScripts"), Icons.of(Icons.IMPORT)); importScripts.setAccelerator(new KeyCodeCombination(KeyCode.I, KeyCombination.SHORTCUT_DOWN)); importScripts.setOnAction(event -> showImportDialog(connectionTree.getSelectedNode())); - MenuItem exit = new MenuItem(Messages.get("action.exit")); - exit.setOnAction(event -> { - Window window = getWindow(); - if (window != null) { - window.fireEvent(new javafx.stage.WindowEvent(window, javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST)); - if (window.isShowing()) { - ((javafx.stage.Stage) window).close(); - } - } - }); + MenuItem importParquet = new MenuItem(Messages.get("action.importParquet"), Icons.of(Icons.IMPORT)); + importParquet.setId("importParquetMenuItem"); + importParquet.setOnAction(event -> showImportParquetDialog(connectionTree.getSelectedNode())); + MenuItem openPackage = new MenuItem(Messages.get("package.open"), Icons.of(Icons.FOLDER)); + openPackage.setId("openPackageMenuItem"); + openPackage.setAccelerator(new KeyCodeCombination(KeyCode.O, KeyCombination.SHORTCUT_DOWN)); + openPackage.setOnAction(event -> openPackage()); + MenuItem exit = new MenuItem(Messages.get("action.exit"), Icons.of(Icons.EXIT)); + exit.setOnAction(event -> exit()); Menu fileMenu = new Menu(Messages.get("menu.file"), null, newConnection, new SeparatorMenuItem(), export, - importScripts, new SeparatorMenuItem(), exit); + importScripts, importParquet, new SeparatorMenuItem(), openPackage, new SeparatorMenuItem(), exit); Menu themeMenu = new Menu(Messages.get("menu.theme"), Icons.of(Icons.THEME)); ToggleGroup themeGroup = new ToggleGroup(); @@ -149,9 +158,16 @@ private MenuBar createMenuBar() { refresh.setOnAction(event -> connectionTree.refreshSelected()); Menu viewMenu = new Menu(Messages.get("menu.view"), null, themeMenu, new SeparatorMenuItem(), refresh); + MenuItem restoreExample = new MenuItem(Messages.get("action.restoreExample"), Icons.of(Icons.CATALOG)); + restoreExample.setId("restoreExampleMenuItem"); + restoreExample.setOnAction(event -> ExampleActions.restore(getWindow(), context, profile -> { + // Tabs of the old example database are closed + closeTabs(profile); + selectConnection(profile); + })); MenuItem about = new MenuItem(Messages.get("action.about"), Icons.of(Icons.INFO)); - about.setOnAction(event -> Dialogs.showAbout(getWindow())); - Menu helpMenu = new Menu(Messages.get("menu.help"), null, about); + about.setOnAction(event -> Dialogs.showAbout(getWindow(), context::openDocument)); + Menu helpMenu = new Menu(Messages.get("menu.help"), null, restoreExample, new SeparatorMenuItem(), about); MenuBar menuBar = new MenuBar(fileMenu, viewMenu, helpMenu); menuBar.setUseSystemMenuBar(true); return menuBar; @@ -196,10 +212,28 @@ private ToolBar createToolBar() { mark.setId("toolbarLogo"); StackPane markBox = new StackPane(mark); markBox.setPadding(new Insets(0, 4, 0, 6)); - return new ToolBar(markBox, new javafx.scene.control.Separator(), + // Exit at the right end + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + Button exitButton = toolButton(Icons.EXIT, "action.exit", this::exit); + exitButton.setText(Messages.get("action.exit")); + return new ToolBar(markBox, new Separator(), toolButton(Icons.ADD, "action.newConnection", this::newConnection), editButton, deleteButton, toolButton(Icons.REFRESH, "action.refresh", connectionTree::refreshSelected), - new javafx.scene.control.Separator(), exportButton, importButton); + new Separator(), exportButton, importButton, spacer, exitButton); + } + + /** + * Closes the window like its close button, running transfers are confirmed by the application + */ + private void exit() { + Window window = getWindow(); + if (window != null) { + window.fireEvent(new WindowEvent(window, WindowEvent.WINDOW_CLOSE_REQUEST)); + if (window.isShowing()) { + ((Stage) window).close(); + } + } } private HBox createStatusBar() { @@ -207,11 +241,12 @@ private HBox createStatusBar() { indicator.setPrefSize(14, 14); indicator.visibleProperty().bind(TaskRunner.runningProperty().greaterThan(0)); Label taskLabel = new Label(); - taskLabel.textProperty().bind(javafx.beans.binding.Bindings.when(TaskRunner.runningProperty().greaterThan(0)) + taskLabel.textProperty().bind(Bindings.when(TaskRunner.runningProperty().greaterThan(0)) .then(Messages.get("status.working")).otherwise("")); Region spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); - HBox statusBar = new HBox(8, statusLabel, spacer, taskLabel, indicator); + HBox statusBar = new HBox(8, statusLabel, spacer, taskLabel, indicator, + Branding.repositoryMark(context::openDocument)); statusBar.setAlignment(Pos.CENTER_LEFT); statusBar.getStyleClass().add("status-bar"); return statusBar; @@ -249,15 +284,22 @@ private void deleteConnection(ConnectionProfile profile) { try { context.getProfileRegistry().removeProfile(profile.getId()); context.getSessionManager().closeSession(profile.getId()); - tabPane.getTabs().removeIf(tab -> tab.getUserData() instanceof String - && ((String) tab.getUserData()).startsWith(profile.getId() + "|")); + closeTabs(profile); connectionTree.reloadConnections(); } catch (IOException e) { Dialogs.showError(getWindow(), Messages.get("connection.save.error"), e); } } - private void openTable(DbNode node) { + /** + * Closes table tabs of the data source, e.g. after it is disconnected or deleted + */ + private void closeTabs(ConnectionProfile profile) { + tabPane.getTabs().removeIf(tab -> tab.getUserData() instanceof String + && ((String) tab.getUserData()).startsWith(profile.getId() + "|")); + } + + void openTable(DbNode node) { String key = node.getProfile().getId() + "|" + TableTab.getKey(node); for (Tab tab : tabPane.getTabs()) { if (key.equals(tab.getUserData())) { @@ -266,18 +308,28 @@ private void openTable(DbNode node) { } } TableTab tab = new TableTab(context, node); + tab.setOnExport(this::showExportDialog); tab.setUserData(key); tabPane.getTabs().add(tab); tabPane.getSelectionModel().select(tab); } private void showExportDialog(DbNode node) { + showExportDialog(node, null); + } + + /** + * Export of the selected node, rows of a table may be narrowed by the query of its data pane + */ + private void showExportDialog(DbNode node, TableQuery query) { if (context.getProfileRegistry().getProfiles().isEmpty()) { Dialogs.showInfo(getWindow(), Messages.get("export.title"), Messages.get("export.error.noConnections")); return; } - ExportDialog dialog = new ExportDialog(getWindow(), context, node); + ExportDialog dialog = new ExportDialog(getWindow(), context, node, 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(); @@ -301,12 +353,10 @@ private void showExportDialog(DbNode node) { } private void showImportDialog(DbNode node) { - if (context.getProfileRegistry().getProfiles().isEmpty()) { - Dialogs.showInfo(getWindow(), Messages.get("import.title"), Messages.get("export.error.noConnections")); - return; - } + // Without data sources, the target is created in the dialog Optional request = new ImportPackageDialog(getWindow(), context, node) .showAndWait(); + connectionTree.addNewConnections(); request.ifPresent(importRequest -> { int totalTables = importRequest.getManifest() != null ? importRequest.getManifest().getTables().size() : 0; @@ -314,10 +364,42 @@ private void showImportDialog(DbNode node) { "progress.importTitle", importRequest.getTarget().getName()), totalTables); progress.run(() -> context.getTransferService().importScripts(importRequest.getTarget(), importRequest.getCatalog(), importRequest.getSchema(), importRequest.getDirectory(), - importRequest.isStopOnError(), progress.getListener()), (ScriptImporter importer) -> + importRequest.isStopOnError(), progress.getListener()), (AbstractFileImporter importer) -> Messages.format("import.summary", importer.getExecutedCount(), importer.getLobCount(), importer.getFailedCount()), () -> 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())); + }); + } + + /** + * Lists and previews files of an export package (sql scripts or Parquet files) + */ + private void openPackage() { + DirectoryChooser chooser = new DirectoryChooser(); + chooser.setTitle(Messages.get("package.chooseDirectory")); + String lastDirectory = context.getSettings().getLastImportDirectory(); + if (lastDirectory != null && new File(lastDirectory).isDirectory()) { + chooser.setInitialDirectory(new File(lastDirectory)); + } + File directory = chooser.showDialog(getWindow()); + if (directory != null) { + new PackageViewer(getWindow(), context, directory).show(); + } + } } diff --git a/src/main/java/com/github/sqljam/face/view/PackageViewer.java b/src/main/java/com/github/sqljam/face/view/PackageViewer.java new file mode 100644 index 0000000..072f137 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/PackageViewer.java @@ -0,0 +1,362 @@ +/* + * 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.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.github.sqljam.face.service.TransferService; +import com.github.sqljam.impexp.ExportManifest; +import com.github.sqljam.impexp.ParquetExporter; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.property.SimpleStringProperty; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.SplitPane; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeTableCell; +import javafx.scene.control.TreeTableColumn; +import javafx.scene.control.TreeTableView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; +import javafx.stage.Window; + +/** + * @Description: PackageViewer shows the files of an export package (sql scripts or Parquet files) with sizes and + * modified times, and previews the selected file: text of sql and json files (manifest.json formatted), columns and first rows of + * Parquet files, text or hex of LOB files. Large files are previewed by their first part. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public class PackageViewer { + + /** + * Bytes of a text preview + */ + static final int TEXT_PREVIEW_BYTES = 256 * 1024; + /** + * Bytes of a hex preview of binary files + */ + static final int HEX_PREVIEW_BYTES = 4 * 1024; + /** + * Rows of a Parquet preview + */ + static final int PARQUET_PREVIEW_ROWS = 100; + + private final AppContext context; + private final File directory; + private final Stage stage = new Stage(); + private static final DateTimeFormatter MODIFIED_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final TreeTableView fileTree = new TreeTableView<>(); + private final Label summaryLabel = new Label(); + private final Label fileLabel = new Label(); + private final TextArea textPreview = new TextArea(); + private final TableView> tablePreview = new TableView<>(); + private final StackPane previewPane = new StackPane(textPreview, tablePreview); + + public PackageViewer(Window owner, AppContext context, File directory) { + this.context = context; + this.directory = directory; + stage.initOwner(owner); + Branding.applyIcons(stage); + stage.setTitle(Messages.format("package.title", directory.getName())); + + fileTree.setId("packageFileTree"); + summaryLabel.setId("packageSummary"); + fileLabel.setId("packageFileLabel"); + textPreview.setId("packageTextPreview"); + tablePreview.setId("packageTablePreview"); + summaryLabel.getStyleClass().add("muted"); + summaryLabel.setWrapText(true); + fileLabel.getStyleClass().add("section-title"); + textPreview.setEditable(false); + textPreview.getStyleClass().add("mono"); + tablePreview.setPlaceholder(new Label(Messages.get("package.noRows"))); + showText(""); + + fileTree.setRoot(createItem(directory)); + fileTree.getRoot().setExpanded(true); + setupColumns(); + fileTree.getSelectionModel().selectedItemProperty().addListener((obs, oldItem, item) -> { + if (item != null && item.getValue().isFile()) { + preview(item.getValue()); + } + }); + summaryLabel.setText(describe(directory)); + + VBox left = new VBox(8, summaryLabel, fileTree); + VBox.setVgrow(fileTree, Priority.ALWAYS); + left.setPadding(new Insets(8)); + VBox right = new VBox(8, fileLabel, previewPane); + VBox.setVgrow(previewPane, Priority.ALWAYS); + right.setPadding(new Insets(8)); + SplitPane splitPane = new SplitPane(left, right); + splitPane.setDividerPositions(0.32); + BorderPane root = new BorderPane(splitPane); + root.setId("packageViewer"); + Scene scene = new Scene(root, 1100, 680); + scene.getStylesheets().add(PackageViewer.class.getResource("/com/github/sqljam/face/app.css") + .toExternalForm()); + stage.setScene(scene); + } + + /** + * Name (with icon), size and modified time of files + */ + private void setupColumns() { + TreeTableColumn nameColumn = new TreeTableColumn<>(Messages.get("package.column.name")); + nameColumn.setCellValueFactory(cell -> new ReadOnlyObjectWrapper<>(cell.getValue().getValue())); + nameColumn.setCellFactory(column -> new TreeTableCell<>() { + @Override + protected void updateItem(File item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + setGraphic(null); + } else { + setText(item.getName()); + setGraphic(Icons.of(item.isDirectory() ? Icons.FOLDER + : isParquet(item) ? Icons.TABLE : "fth-file-text")); + } + } + }); + nameColumn.setPrefWidth(200); + TreeTableColumn sizeColumn = new TreeTableColumn<>(Messages.get("package.column.size")); + sizeColumn.setCellValueFactory(cell -> new ReadOnlyObjectWrapper<>(formatSize(cell.getValue().getValue()))); + sizeColumn.setPrefWidth(80); + sizeColumn.setStyle("-fx-alignment: CENTER-RIGHT;"); + TreeTableColumn modifiedColumn = new TreeTableColumn<>( + Messages.get("package.column.modified")); + modifiedColumn.setCellValueFactory(cell -> new ReadOnlyObjectWrapper<>( + formatModified(cell.getValue().getValue()))); + modifiedColumn.setPrefWidth(140); + fileTree.getColumns().setAll(List.of(nameColumn, sizeColumn, modifiedColumn)); + fileTree.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY); + } + + /** + * Size of a file, or of all files of a directory + */ + static String formatSize(File file) { + long size = file.isDirectory() ? FileUtils.sizeOfDirectory(file) : file.length(); + return FileUtils.byteCountToDisplaySize(size); + } + + static String formatModified(File file) { + return LocalDateTime.ofInstant(Instant.ofEpochMilli(file.lastModified()), + ZoneId.systemDefault()).format(MODIFIED_FORMATTER); + } + + public void show() { + stage.show(); + // manifest.json is previewed first + selectFile(new File(directory, ExportManifest.FILE_NAME)); + } + + public Stage getStage() { + return stage; + } + + /** + * Selects the file in the tree and previews it + */ + public boolean selectFile(File file) { + TreeItem item = findItem(fileTree.getRoot(), file); + if (item != null) { + for (TreeItem parent = item.getParent(); parent != null; parent = parent.getParent()) { + parent.setExpanded(true); + } + fileTree.getSelectionModel().select(item); + return true; + } + return false; + } + + private static TreeItem findItem(TreeItem item, File file) { + if (item.getValue().equals(file)) { + return item; + } + for (TreeItem child : item.getChildren()) { + TreeItem found = findItem(child, file); + if (found != null) { + return found; + } + } + return null; + } + + /** + * Files of the package, directories first, temporary workspaces are skipped + */ + static TreeItem createItem(File file) { + TreeItem item = new TreeItem<>(file); + if (file.isDirectory()) { + File[] children = file.listFiles(child -> !child.getName().startsWith(".sqljam-workspace-") + && !child.isHidden()); + if (children != null) { + Arrays.stream(children).sorted(Comparator.comparing((File child) -> !child.isDirectory()) + .thenComparing(File::getName)).forEach(child -> item.getChildren().add(createItem(child))); + } + } + return item; + } + + /** + * Summary of the package by its manifest: data format, source, target, tables and rows + */ + static String describe(File directory) { + try { + ExportManifest manifest = ExportManifest.read(directory); + if (manifest == null) { + return Messages.get("package.noManifest"); + } + long rows = manifest.getTables().stream().mapToLong(ExportManifest.TableEntry::getRows).sum(); + return Messages.format("package.summary", manifest.getDataFormat(), manifest.getStatus(), + manifest.getSource().getDbType(), manifest.getTarget().getDbType(), manifest.getTables().size(), + rows); + } catch (IOException e) { + return e.getMessage(); + } + } + + private void preview(File file) { + fileLabel.setText(String.format("%s (%s, %s)", file.getName(), formatSize(file), formatModified(file))); + if (isParquet(file)) { + showText(Messages.get("package.loading")); + TaskRunner.run(() -> context.getTransferService().previewParquet(List.of(file), PARQUET_PREVIEW_ROWS), + this::showParquet, e -> showText(e.getMessage())); + return; + } + try { + showText(readPreview(file)); + } catch (IOException e) { + showText(e.getMessage()); + } + } + + private void showText(String text) { + textPreview.setText(text); + textPreview.setVisible(true); + tablePreview.setVisible(false); + } + + private void showParquet(TransferService.ParquetPreview preview) { + tablePreview.getColumns().clear(); + List columns = preview.getColumns(); + for (int i = 0; i < columns.size(); i++) { + int index = i; + TableColumn, String> column = new TableColumn<>(columns.get(i)[0] + "\n" + columns.get(i)[1]); + column.setCellValueFactory(cell -> new SimpleStringProperty(index < cell.getValue().size() + ? cell.getValue().get(index) : null)); + column.setPrefWidth(140); + tablePreview.getColumns().add(column); + } + tablePreview.getItems().setAll(preview.getFirstRows()); + fileLabel.setText(fileLabel.getText() + " " + Messages.format("package.parquetRows", preview.getRows(), + preview.getFirstRows().size())); + textPreview.setVisible(false); + tablePreview.setVisible(true); + } + + static boolean isParquet(File file) { + return file.getName().toLowerCase(Locale.ENGLISH).endsWith(ParquetExporter.PARQUET_EXTENSION); + } + + /** + * Preview of a non Parquet file: manifest json formatted, text files, hex of binary files + */ + static String readPreview(File file) throws IOException { + String name = file.getName().toLowerCase(Locale.ENGLISH); + if (name.endsWith(".json") && file.length() <= TEXT_PREVIEW_BYTES) { + ObjectMapper mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + try { + return mapper.writeValueAsString(mapper.readTree(file)); + } catch (IOException e) { + return readText(file, TEXT_PREVIEW_BYTES); + } + } + if (name.endsWith(".blob")) { + return hexPreview(file, HEX_PREVIEW_BYTES); + } + return readText(file, TEXT_PREVIEW_BYTES); + } + + /** + * Text of the first bytes of a file, a notice is appended if the file is larger + */ + static String readText(File file, int maxBytes) throws IOException { + byte[] bytes; + try (InputStream in = Files.newInputStream(file.toPath())) { + bytes = in.readNBytes(maxBytes); + } + String text = new String(bytes, StandardCharsets.UTF_8); + if (file.length() > maxBytes) { + text += "\n\n" + Messages.format("package.truncated", FileUtils.byteCountToDisplaySize(maxBytes), + FileUtils.byteCountToDisplaySize(file.length())); + } + return text; + } + + /** + * Hex dump of the first bytes of a binary file, 16 bytes per line + */ + static String hexPreview(File file, int maxBytes) throws IOException { + byte[] bytes; + try (InputStream in = Files.newInputStream(file.toPath())) { + bytes = in.readNBytes(maxBytes); + } + StringBuilder hex = new StringBuilder(); + for (int offset = 0; offset < bytes.length; offset += 16) { + hex.append(String.format("%08x ", offset)); + List parts = new ArrayList<>(); + for (int i = offset; i < Math.min(offset + 16, bytes.length); i++) { + parts.add(String.format("%02x", bytes[i])); + } + hex.append(StringUtils.join(parts, ' ')).append('\n'); + } + if (file.length() > maxBytes) { + hex.append('\n').append(Messages.format("package.truncated", FileUtils.byteCountToDisplaySize(maxBytes), + FileUtils.byteCountToDisplaySize(file.length()))); + } + return hex.toString(); + } +} 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 8178714..495b755 100644 --- a/src/main/java/com/github/sqljam/face/view/ProgressDialog.java +++ b/src/main/java/com/github/sqljam/face/view/ProgressDialog.java @@ -22,7 +22,6 @@ import org.apache.commons.lang3.StringUtils; import com.github.sqljam.impexp.ExportCancelledException; -import atlantafx.base.theme.Styles; import com.github.sqljam.impexp.ExportListener; import javafx.animation.KeyFrame; import javafx.animation.Timeline; @@ -46,6 +45,7 @@ import javafx.stage.Window; import javafx.util.Duration; import lombok.extern.slf4j.Slf4j; +import atlantafx.base.theme.Styles; /** * @Description: ProgressDialog shows the progress of an export or import, it can be cancelled @@ -70,6 +70,7 @@ public class ProgressDialog { private final ListView logList = new ListView<>(); 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 Label manifestLabel = new Label(); private final AtomicBoolean cancelled = new AtomicBoolean(); private final int totalTables; @@ -108,6 +109,7 @@ public ProgressDialog(Window owner, AppContext context, String title, int totalT logList.setId("logList"); cancelButton.setId("cancelButton"); openFolderButton.setId("openFolderButton"); + viewPackageButton.setId("viewPackageButton"); manifestLabel.setId("manifestLabel"); statusLabel.getStyleClass().add("progress-status"); @@ -151,6 +153,13 @@ protected void updateItem(LogEntry item, boolean empty) { }); openFolderButton.setVisible(false); openFolderButton.setManaged(false); + viewPackageButton.setVisible(false); + viewPackageButton.setManaged(false); + viewPackageButton.setOnAction(event -> { + if (outputDirectory != null) { + new PackageViewer(stage, context, outputDirectory).show(); + } + }); openFolderButton.setOnAction(event -> { if (outputDirectory != null) { context.getHostServices().showDocument(outputDirectory.toURI().toString()); @@ -162,10 +171,11 @@ protected void updateItem(LogEntry item, boolean empty) { manifestLabel.setVisible(false); manifestLabel.setManaged(false); openFolderButton.setMinWidth(Region.USE_PREF_SIZE); + viewPackageButton.setMinWidth(Region.USE_PREF_SIZE); cancelButton.setMinWidth(Region.USE_PREF_SIZE); Region spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); - HBox buttons = new HBox(8, spacer, openFolderButton, cancelButton); + HBox buttons = new HBox(8, spacer, viewPackageButton, openFolderButton, cancelButton); buttons.setAlignment(Pos.CENTER_RIGHT); Region headerSpacer = new Region(); @@ -387,6 +397,8 @@ private void finish(String status, boolean failed, boolean cancelled, String sum if (!failed && outputDirectory != null) { openFolderButton.setVisible(true); openFolderButton.setManaged(true); + viewPackageButton.setVisible(true); + viewPackageButton.setManaged(true); File manifest = new File(outputDirectory, "manifest.json"); if (manifest.exists()) { manifestLabel.setText(Messages.format("progress.manifest", manifest.getAbsolutePath())); diff --git a/src/main/java/com/github/sqljam/face/view/SessionManager.java b/src/main/java/com/github/sqljam/face/view/SessionManager.java index fd5ac4a..82426e5 100644 --- a/src/main/java/com/github/sqljam/face/view/SessionManager.java +++ b/src/main/java/com/github/sqljam/face/view/SessionManager.java @@ -45,6 +45,16 @@ public void closeSession(String profileId) { } } + /** + * Closes the session in the calling thread, e.g. before the database file of the profile is replaced + */ + public void closeSessionNow(String profileId) { + DatabaseSession session = sessions.remove(profileId); + if (session != null) { + session.close(); + } + } + public void closeAll() { sessions.values().forEach(DatabaseSession::close); sessions.clear(); diff --git a/src/main/java/com/github/sqljam/face/view/TableTab.java b/src/main/java/com/github/sqljam/face/view/TableTab.java index 52151ca..9f240b8 100644 --- a/src/main/java/com/github/sqljam/face/view/TableTab.java +++ b/src/main/java/com/github/sqljam/face/view/TableTab.java @@ -16,8 +16,10 @@ package com.github.sqljam.face.view; import java.util.List; +import java.util.function.BiConsumer; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.apache.commons.lang3.StringUtils; import com.github.sqljam.face.model.ColumnInfo; @@ -25,6 +27,7 @@ import com.github.sqljam.face.model.IndexInfo; import com.github.sqljam.face.service.DatabaseSession; import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.TableQuery; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.beans.value.ObservableValue; import javafx.geometry.Insets; @@ -37,6 +40,7 @@ import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import javafx.scene.control.TextArea; +import javafx.scene.control.Tooltip; import javafx.scene.input.Clipboard; import javafx.scene.input.ClipboardContent; import javafx.scene.layout.BorderPane; @@ -65,12 +69,23 @@ public class TableTab extends Tab { private boolean ddlLoaded; private boolean dataLoaded; + /** + * Rows narrowed in the data pane are exported by the consumer + */ + public void setOnExport(BiConsumer onExport) { + dataPane.setOnExport(query -> onExport.accept(node, query)); + } + + DataPane getDataPane() { + return dataPane; + } + public TableTab(AppContext context, DbNode node) { this.node = node; this.session = context.getSessionManager().getSession(node.getProfile()); setText(node.getTable().getName()); setGraphic(Icons.of(Icons.TABLE)); - setTooltip(new javafx.scene.control.Tooltip(getKey(node))); + setTooltip(new Tooltip(getKey(node))); dataPane = new DataPane(session, node.getCatalog(), node.getSchema(), node.getTable().getName(), context.getSettings().getDataPageSize()); @@ -102,7 +117,7 @@ public TableTab(AppContext context, DbNode node) { * Unique key of the table, an opened table is reused */ public static String getKey(DbNode node) { - return java.util.stream.Stream.of(node.getProfile().getName(), node.getCatalog(), node.getSchema(), + return Stream.of(node.getProfile().getName(), node.getCatalog(), node.getSchema(), node.getTable().getName()).filter(StringUtils::isNotBlank).collect(Collectors.joining(".")); } diff --git a/src/main/java/com/github/sqljam/face/view/ThemeManager.java b/src/main/java/com/github/sqljam/face/view/ThemeManager.java index a5b405f..2726b4d 100644 --- a/src/main/java/com/github/sqljam/face/view/ThemeManager.java +++ b/src/main/java/com/github/sqljam/face/view/ThemeManager.java @@ -21,6 +21,7 @@ import java.util.Map; import java.util.function.Supplier; +import javafx.application.Application; import atlantafx.base.theme.CupertinoDark; import atlantafx.base.theme.CupertinoLight; import atlantafx.base.theme.Dracula; @@ -29,7 +30,6 @@ import atlantafx.base.theme.PrimerDark; import atlantafx.base.theme.PrimerLight; import atlantafx.base.theme.Theme; -import javafx.application.Application; /** * @Description: ThemeManager switches AtlantaFX themes of the application diff --git a/src/main/java/com/github/sqljam/impexp/AbstractFileImporter.java b/src/main/java/com/github/sqljam/impexp/AbstractFileImporter.java new file mode 100644 index 0000000..69b7434 --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/AbstractFileImporter.java @@ -0,0 +1,258 @@ +/* + * 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 org.apache.commons.lang3.StringUtils; + +/** + * @Description: AbstractFileImporter imports files of an export package into a database by a connection: + * manifest.json is validated (format, status, target database type and checksums of files), files are + * imported in its order with the overall progress in bytes. {@link ScriptImporter} imports packages + * of sql scripts, {@link ParquetImporter} packages of Parquet files and Parquet files of other tools. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public abstract class AbstractFileImporter { + + protected final Connection connection; + protected final DbType dbType; + protected boolean stopOnError = true; + protected int batchSize = SqlScriptRunner.DEFAULT_BATCH_SIZE; + protected ExportListener exportListener = ExportListener.NONE; + protected File workspaceDirectory; + protected int executedCount; + protected int failedCount; + /** + * Overall progress in bytes of imported files + */ + protected long totalBytes; + protected long completedBytes; + + protected AbstractFileImporter(Connection connection, DbType dbType) { + this.connection = connection; + this.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} + */ + public static AbstractFileImporter forDirectory(File dir, Connection connection, DbType dbType) + throws IOException { + ExportManifest manifest = ExportManifest.read(dir); + if (manifest != null && manifest.getDataFormat() == DataFormat.PARQUET) { + return new ParquetImporter(connection, dbType); + } + return new ScriptImporter(connection, dbType); + } + + public void setStopOnError(boolean stopOnError) { + this.stopOnError = stopOnError; + } + + /** + * INSERT statements per batch of sql scripts, 1 or less executes statement by statement + */ + public void setBatchSize(int batchSize) { + this.batchSize = batchSize; + } + + public void setExportListener(ExportListener exportListener) { + this.exportListener = exportListener != null ? exportListener : ExportListener.NONE; + } + + /** + * Directory of temporary files (the DuckDB workspace of Parquet files), the system temporary directory by default + */ + public void setWorkspaceDirectory(File workspaceDirectory) { + this.workspaceDirectory = workspaceDirectory; + } + + /** + * Executed sql statements and imported rows of Parquet files + */ + public int getExecutedCount() { + return executedCount; + } + + public int getFailedCount() { + return failedCount; + } + + /** + * Restored LOB values, LOBs of Parquet packages are inside Parquet files + */ + public int getLobCount() { + return 0; + } + + /** + * Imports an export directory. Files are imported in the order of manifest.json if it exists. + */ + public void importDirectory(File dir) throws IOException, SQLException { + ExportManifest manifest = ExportManifest.read(dir); + if (manifest == null) { + importDirectoryWithoutManifest(dir); + return; + } + boolean successful = false; + try { + validateManifest(dir, manifest); + startProgress(getManifestBytes(dir, manifest)); + importManifest(dir, manifest); + successful = true; + } finally { + exportListener.onEnd(successful); + } + } + + /** + * Imports files listed by the manifest in order, the manifest is validated already + */ + protected abstract void importManifest(File dir, ExportManifest manifest) throws IOException, SQLException; + + /** + * Imports a directory without manifest.json, discovered by the directory layout + */ + protected void importDirectoryWithoutManifest(File dir) throws IOException, SQLException { + throw new ImpExpException("Manifest not found: " + new File(dir, ExportManifest.FILE_NAME)); + } + + /** + * Format, status, target database type and files of the manifest + */ + protected void validateManifest(File dir, ExportManifest manifest) throws IOException { + if (!ExportManifest.FORMAT.equals(manifest.getFormat())) { + throw new ImpExpException("Unknown export format: " + manifest.getFormat()); + } + if (manifest.getStatus() != ExportManifest.Status.COMPLETED) { + throw new ImpExpException("The export is not completed: " + manifest.getStatus()); + } + DbType targetDbType = manifest.getTarget().getDbType(); + if (dbType != null && targetDbType != null && dbType != targetDbType) { + throw new ImpExpException(String.format("The scripts are generated for %s, but the target database is %s", + targetDbType.getDisplayName(), dbType.getDisplayName())); + } + for (ExportManifest.FileEntry fileEntry : manifest.getFiles()) { + File file = new File(dir, fileEntry.getPath()); + if (!file.exists()) { + throw new ImpExpException("File not found: " + fileEntry.getPath()); + } + if (StringUtils.isNotBlank(fileEntry.getSha256()) && !fileEntry.getSha256().equals( + ExportManifest.sha256(file))) { + throw new ImpExpException("File is modified or corrupted (checksum mismatch): " + fileEntry.getPath()); + } + } + } + + /** + * Bytes of the files of the manifest, the total of the progress + */ + protected long getManifestBytes(File dir, ExportManifest manifest) { + long bytes = 0; + for (ExportManifest.FileEntry fileEntry : manifest.getFiles()) { + bytes += new File(dir, fileEntry.getPath()).length(); + } + return bytes; + } + + protected void startProgress(long totalBytes) { + this.totalBytes = Math.max(totalBytes, 1); + this.completedBytes = 0; + exportListener.onProgress(0, this.totalBytes); + } + + protected void completeBytes(long bytes) { + completedBytes += bytes; + exportListener.onProgress(Math.min(completedBytes, totalBytes), totalBytes); + } + + protected static long sizeOf(File file) { + if (file.isDirectory()) { + File[] files = file.listFiles(); + long size = 0; + if (files != null) { + for (File child : files) { + size += sizeOf(child); + } + } + return size; + } + return file.exists() ? file.length() : 0; + } + + /** + * Executes a sql script (schema.sql, data files, constraints.sql), progress of statements is converted to bytes + */ + protected void runScript(File file) throws IOException, SQLException { + if (!file.exists()) { + return; + } + exportListener.onMessage("Executing script: " + file.getName()); + SqlScriptRunner runner = new SqlScriptRunner(connection); + runner.setStopOnError(stopOnError); + runner.setBatchSize(batchSize); + long fileBytes = file.length(); + runner.setExportListener(new ForwardingListener(exportListener) { + + @Override + public void onProgress(long processed, long total) { + long bytes = total > 0 ? fileBytes * processed / total : 0; + exportListener.onProgress(Math.min(completedBytes + bytes, totalBytes), totalBytes); + } + }); + try { + runner.runScript(file); + } finally { + executedCount += runner.getExecutedCount(); + failedCount += runner.getFailedCount(); + completeBytes(fileBytes); + } + } + + /** + * Forwards messages and errors of a part of the import, the end of each part is not the end of the import + */ + protected static class ForwardingListener implements ExportListener { + + private final ExportListener delegate; + + protected ForwardingListener(ExportListener delegate) { + this.delegate = delegate; + } + + @Override + public void onMessage(String message) { + delegate.onMessage(message); + } + + @Override + public void onError(String message, Throwable e) { + delegate.onError(message, e); + } + + @Override + public boolean isCancelled() { + return delegate.isCancelled(); + } + } +} diff --git a/src/main/java/com/github/sqljam/impexp/CatalogMetaData.java b/src/main/java/com/github/sqljam/impexp/CatalogMetaData.java index 78ed266..3f3ecc0 100644 --- a/src/main/java/com/github/sqljam/impexp/CatalogMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/CatalogMetaData.java @@ -23,9 +23,10 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import lombok.extern.slf4j.Slf4j; + import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; +import lombok.extern.slf4j.Slf4j; /** * @Description: CatalogMetaData is a catalog (database) of the server, it loads the included schemas diff --git a/src/main/java/com/github/sqljam/impexp/ColumnMetaData.java b/src/main/java/com/github/sqljam/impexp/ColumnMetaData.java index 4f6cee7..455b7f4 100644 --- a/src/main/java/com/github/sqljam/impexp/ColumnMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/ColumnMetaData.java @@ -20,6 +20,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; @@ -62,9 +64,12 @@ public String[] getStatements() throws SQLException { Dialect dialect = getDialect(); String catalogName = getCatalogName(); String schemaName = getSchemaName(); - String tableName = getTableName(); + String tableName = dialect.getTargetTableName(getTableName()); int dataType = TableMetaData.getInt(detail, "DATA_TYPE"); - String typeName = (String) detail.get("TYPE_NAME"); + String typeName = getUserTypeName(dialect); + if (typeName == null) { + typeName = (String) detail.get("TYPE_NAME"); + } int columnSize = TableMetaData.getInt(detail, "COLUMN_SIZE"); int columnScale = TableMetaData.getInt(detail, "DECIMAL_DIGITS"); String comment = (String) detail.get("REMARKS"); @@ -86,7 +91,7 @@ public String[] getStatements() throws SQLException { columnSize, columnScale, defaultValue, nullable, comment); } - dialect.registerColumnTypeName(tableName, columnName, + dialect.registerColumnTypeName(getTableName(), columnName, dialect.getColumnTypeName(dataType, typeName, columnSize, columnScale)); return new String[]{statement}; } @@ -119,6 +124,32 @@ public String getColumnName() { return columnName; } + /** + * User defined type (enum, domain) of the column for the same database type, null otherwise + */ + @SuppressWarnings("unchecked") + private String getUserTypeName(Dialect dialect) { + Map userType = (Map) detail.get("USER_TYPE"); + if (userType == null || dialect.isCrossDatabase()) { + return null; + } + return Dialect.VERBATIM_TYPE_PREFIX + dialect.getUserTypeName(getCatalogName(), getSchemaName(), + (String) userType.get("NAME")); + } + + /** + * Statement creating the user defined type of the column for the same database type, null otherwise + */ + @SuppressWarnings("unchecked") + public String getUserTypeStatement() { + Map userType = (Map) detail.get("USER_TYPE"); + Dialect dialect = getDialect(); + if (userType == null || dialect.isCrossDatabase()) { + return null; + } + return dialect.getCreateUserTypeStatement(getCatalogName(), getSchemaName(), userType); + } + /** * Objects referenced by default values (e.g. sequences) are moved to the target schema */ @@ -130,8 +161,8 @@ private String replaceSchema(String defaultValue, Dialect dialect) { || dialect.isCrossDatabase()) { return defaultValue; } - String quotedSchema = java.util.regex.Pattern.quote(schemaName); - String replacement = java.util.regex.Matcher.quoteReplacement(dialect.quoteIdentifier(targetSchemaName)); + String quotedSchema = Pattern.quote(schemaName); + String replacement = Matcher.quoteReplacement(dialect.quoteIdentifier(targetSchemaName)); return defaultValue.replaceAll("(?i)(\\[" + quotedSchema + "\\]|\"" + quotedSchema + "\"|`" + quotedSchema + "`|\\b" + quotedSchema + "\\b)(?=\\s*\\.)", replacement); } diff --git a/src/main/java/com/github/sqljam/impexp/CombinedIndexMetaData.java b/src/main/java/com/github/sqljam/impexp/CombinedIndexMetaData.java index 8b4a44d..d685308 100644 --- a/src/main/java/com/github/sqljam/impexp/CombinedIndexMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/CombinedIndexMetaData.java @@ -105,7 +105,7 @@ public String[] getStatements() throws SQLException { } String catalogName = getCatalogName(); String schemaName = getSchemaName(); - String tableName = getTableName(); + String tableName = getDialect().getTargetTableName(getTableName()); boolean partition = tableMetaData.isPartitioned(); boolean unique = IndexMetaData.isUnique(getDetail()); String indexName = getDialect().getIndexNameStatement(catalogName, schemaName, tableName, columnNames, unique, diff --git a/src/main/java/com/github/sqljam/impexp/CommentMetaData.java b/src/main/java/com/github/sqljam/impexp/CommentMetaData.java index 926585a..d8be9af 100644 --- a/src/main/java/com/github/sqljam/impexp/CommentMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/CommentMetaData.java @@ -46,7 +46,7 @@ public void accept(MetaDataVisitor visitor) throws SQLException { public String[] getStatements() throws SQLException { String catalogName = getCatalogName(); String schemaName = getSchemaName(); - String tableName = getTableName(); + String tableName = getDialect().getTargetTableName(getTableName()); String comment = (String) detail.get("REMARKS"); String statement; if (columnName == null) { diff --git a/src/main/java/com/github/sqljam/impexp/DataFormat.java b/src/main/java/com/github/sqljam/impexp/DataFormat.java new file mode 100644 index 0000000..22bcf3f --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/DataFormat.java @@ -0,0 +1,30 @@ +/* + * 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; + +/** + * @Description: DataFormat of rows in an export package: SQL insert statements, or Parquet files (one file per + * table) for analytical tools + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public enum DataFormat { + + SQL, + + PARQUET +} diff --git a/src/main/java/com/github/sqljam/impexp/DbCategory.java b/src/main/java/com/github/sqljam/impexp/DbCategory.java new file mode 100644 index 0000000..2d3bdd9 --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/DbCategory.java @@ -0,0 +1,44 @@ +/* + * 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; + +/** + * @Description: DbCategory of databases: relational databases for transactions, OLAP databases for analytics + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public enum DbCategory { + + RELATIONAL("Relational databases"), + + OLAP("OLAP databases"); + + private final String displayName; + + DbCategory(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + + @Override + public String toString() { + return displayName; + } +} diff --git a/src/main/java/com/github/sqljam/impexp/DbType.java b/src/main/java/com/github/sqljam/impexp/DbType.java index 8234ef1..ad82908 100644 --- a/src/main/java/com/github/sqljam/impexp/DbType.java +++ b/src/main/java/com/github/sqljam/impexp/DbType.java @@ -20,13 +20,18 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Validate; -import com.github.sqljam.utils.CaseInsensitiveMap; +import com.github.sqljam.impexp.db.ClickHouseDialect; +import com.github.sqljam.impexp.db.ClickHouseMetaDataOperations; +import com.github.sqljam.impexp.db.DuckDBDialect; +import com.github.sqljam.impexp.db.DuckDBMetaDataOperations; import com.github.sqljam.impexp.db.H2Dialect; import com.github.sqljam.impexp.db.H2MetaDataOperations; -import com.github.sqljam.impexp.db.MySQLDialect; +import com.github.sqljam.impexp.db.MariaDBDialect; +import com.github.sqljam.impexp.db.MariaDBMetaDataOperations; import com.github.sqljam.impexp.db.MySQL55Dialect; import com.github.sqljam.impexp.db.MySQL56Dialect; import com.github.sqljam.impexp.db.MySQL57Dialect; +import com.github.sqljam.impexp.db.MySQLDialect; import com.github.sqljam.impexp.db.MySQLMetaDataOperations; import com.github.sqljam.impexp.db.Oracle11gDialect; import com.github.sqljam.impexp.db.Oracle12cDialect; @@ -41,6 +46,7 @@ import com.github.sqljam.impexp.db.SQLServerMetaDataOperations; import com.github.sqljam.impexp.db.SQLiteDialect; import com.github.sqljam.impexp.db.SQLiteMetaDataOperations; +import com.github.sqljam.utils.CaseInsensitiveMap; /** * @Description: DbType is a supported database type, it creates dialects (by versions) and metadata operations @@ -77,6 +83,24 @@ public String getUrl(String hostname, int port, String catalogName) { } }, + MARIADB("MariaDB", "org.mariadb.jdbc.Driver", 3306, true, false, false, MariaDBDialect::new, + MariaDBMetaDataOperations::new) { + /** + * Sequences of MariaDB 10.3+ are supported by the dialect, versions are not subclassed + */ + @Override + protected Dialect createVersionDialect(int majorVersion, int minorVersion) { + return new MariaDBDialect(); + } + + @Override + public String getUrl(String hostname, int port, String catalogName) { + String database = StringUtils.isNotBlank(catalogName) ? "/" + catalogName : ""; + // YEAR is a number, as YEAR of MySQL + return String.format("jdbc:mariadb://%s:%d%s?yearIsDateType=false", hostname, port, database); + } + }, + POSTGRESQL("PostgreSQL", "org.postgresql.Driver", 5432, false, true, true, PostgreSQLDialect::new, PostgreSQLMetaDataOperations::new) { @Override @@ -175,6 +199,61 @@ public boolean isCatalogSupported() { public boolean isFileBased() { return true; } + }, + + DUCKDB("DuckDB", "org.duckdb.DuckDBDriver", 0, false, true, true, DuckDBDialect::new, + DuckDBMetaDataOperations::new) { + @Override + public String getUrl(String hostname, int port, String catalogName) { + Validate.notBlank(catalogName, "Database file must be required while connecting to duckdb database."); + return "jdbc:duckdb:" + catalogName; + } + + @Override + public boolean isCatalogSupported() { + return false; + } + + @Override + public boolean isFileBased() { + return true; + } + + @Override + public DbCategory getCategory() { + return DbCategory.OLAP; + } + }, + + CLICKHOUSE("ClickHouse", "com.clickhouse.jdbc.ClickHouseDriver", 8123, false, true, true, ClickHouseDialect::new, + ClickHouseMetaDataOperations::new) { + @Override + public String getUrl(String hostname, int port, String catalogName) { + String database = StringUtils.isNotBlank(catalogName) ? "/" + catalogName : ""; + return normalizeUrl(String.format("jdbc:clickhouse:http://%s:%d%s", hostname, port, database)); + } + + /** + * Compression of ClickHouse responses is turned off unless it is configured, the LZ4 frames of recent + * servers are not read by the driver + */ + @Override + public String normalizeUrl(String url) { + if (StringUtils.isBlank(url) || StringUtils.containsIgnoreCase(url, "compress=")) { + return url; + } + return url + (url.contains("?") ? "&" : "?") + "compress=0"; + } + + @Override + public boolean isCatalogSupported() { + return false; + } + + @Override + public DbCategory getCategory() { + return DbCategory.OLAP; + } }; private DbType(String displayName, String driverClassName, int defaultPort, boolean canSetCatalog, @@ -230,6 +309,13 @@ public boolean isCatalogSupported() { return true; } + /** + * Relational database (default) or OLAP database + */ + public DbCategory getCategory() { + return DbCategory.RELATIONAL; + } + /** * Whether the database is addressed by a file path instead of host and port */ @@ -268,6 +354,13 @@ public MetaDataOperations createMetaDataOperations() { public abstract String getUrl(String hostname, int port, String catalogName); + /** + * Jdbc url with the options required by SqlJam, e.g. a url entered by the user + */ + public String normalizeUrl(String url) { + return url; + } + @Override public String toString() { return displayName; @@ -290,8 +383,10 @@ public static DbType forUrl(String jdbcUrl) { return null; } String url = jdbcUrl.toLowerCase(); - if (url.startsWith("jdbc:mysql:") || url.startsWith("jdbc:mariadb:")) { + if (url.startsWith("jdbc:mysql:")) { return MYSQL; + } else if (url.startsWith("jdbc:mariadb:")) { + return MARIADB; } else if (url.startsWith("jdbc:postgresql:")) { return POSTGRESQL; } else if (url.startsWith("jdbc:oracle:")) { @@ -302,6 +397,10 @@ public static DbType forUrl(String jdbcUrl) { return H2; } else if (url.startsWith("jdbc:sqlite:")) { return SQLITE; + } else if (url.startsWith("jdbc:duckdb:")) { + return DUCKDB; + } else if (url.startsWith("jdbc:clickhouse:") || url.startsWith("jdbc:ch:")) { + return CLICKHOUSE; } return null; } diff --git a/src/main/java/com/github/sqljam/impexp/DdlScripter.java b/src/main/java/com/github/sqljam/impexp/DdlScripter.java index 8f74e98..269e777 100644 --- a/src/main/java/com/github/sqljam/impexp/DdlScripter.java +++ b/src/main/java/com/github/sqljam/impexp/DdlScripter.java @@ -19,10 +19,11 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import lombok.Getter; -import lombok.Setter; + import org.apache.commons.lang3.StringUtils; import com.github.sqljam.utils.MapUtils; +import lombok.Getter; +import lombok.Setter; /** * @Description: DdlScripter holds statements of catalogs, schemas and tables, and formats them as pretty scripts or plain statements diff --git a/src/main/java/com/github/sqljam/impexp/DefaultMetaDataVisitor.java b/src/main/java/com/github/sqljam/impexp/DefaultMetaDataVisitor.java index f5df924..66ca6cb 100644 --- a/src/main/java/com/github/sqljam/impexp/DefaultMetaDataVisitor.java +++ b/src/main/java/com/github/sqljam/impexp/DefaultMetaDataVisitor.java @@ -94,15 +94,23 @@ public void visit(TableMetaData metaData) throws SQLException { metaData.getTableName()); addStatements(table.getCreateTableStatements(), statements); String tableOptions = configuration.getDialect().getTableOptions(metaData.getCatalogName(), - metaData.getSchemaName(), metaData.getTableName(), metaData.getDetail()); + metaData.getSchemaName(), configuration.getDialect().getTargetTableName(metaData.getTableName()), + metaData.getDetail(), + metaData.getPrimaryKeyColumnNames()); if (StringUtils.isNotBlank(tableOptions)) { table.getTableOptions().add(tableOptions); } if (configuration.isTableRecreated()) { String dropStatement = configuration.getDialect().getDropTableStatement(metaData.getCatalogName(), metaData.getSchemaName(), - metaData.getTableName()); - table.getBeforeStatements().add(0, dropStatement); + configuration.getDialect().getTargetTableName(metaData.getTableName())); + if (configuration.getDialect().isForeignKeyOrderRequired()) { + // Tables are visited with referenced tables first, they are dropped in the reverse order + Schema schema = getDdlScripter().getSchema(metaData.getCatalogName(), metaData.getSchemaName()); + schema.getBeforeStatements().add(0, dropStatement); + } else { + table.getBeforeStatements().add(0, dropStatement); + } } } } @@ -117,7 +125,7 @@ public void visit(PartitionTableMetaData metaData) throws SQLException { if (configuration.isTableRecreated()) { String dropStatement = configuration.getDialect().getDropTableStatement(metaData.getCatalogName(), metaData.getSchemaName(), - metaData.getTableName()); + configuration.getDialect().getTargetTableName(metaData.getTableName())); table.getBeforeStatements().add(0, dropStatement); } } @@ -130,10 +138,19 @@ public void visit(ColumnMetaData metaData) throws SQLException { Table table = getDdlScripter().getTable(metaData.getCatalogName(), metaData.getSchemaName(), metaData.getTableName()); addStatements(table.getColumnStatements(), statements); + // User defined types are created before the tables of the schema, once + String userTypeStatement = metaData.getUserTypeStatement(); + if (StringUtils.isNotBlank(userTypeStatement)) { + Schema schema = getDdlScripter().getSchema(metaData.getCatalogName(), metaData.getSchemaName()); + if (!schema.getBeforeStatements().contains(userTypeStatement)) { + schema.getBeforeStatements().add(userTypeStatement); + } + } if (metaData.unwrap(TableMetaData.class).getIncrementalColumnNames(configuration.getDialect()) .contains(metaData.getColumnName())) { String[] afterStatements = configuration.getDialect().getStatementAfterIncrementalColumnCreated( - metaData.getCatalogName(), metaData.getSchemaName(), metaData.getTableName(), + metaData.getCatalogName(), metaData.getSchemaName(), + configuration.getDialect().getTargetTableName(metaData.getTableName()), metaData.getColumnName()); if (ArrayUtils.isNotEmpty(afterStatements)) { addStatements(table.getAfterStatements(), afterStatements); diff --git a/src/main/java/com/github/sqljam/impexp/Dialect.java b/src/main/java/com/github/sqljam/impexp/Dialect.java index 17e6391..0595f5a 100644 --- a/src/main/java/com/github/sqljam/impexp/Dialect.java +++ b/src/main/java/com/github/sqljam/impexp/Dialect.java @@ -16,20 +16,25 @@ package com.github.sqljam.impexp; import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; -import java.util.List; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; @@ -38,6 +43,7 @@ import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.jdbc.JdbcUtils; import com.github.sqljam.utils.MapUtils; /** @@ -62,6 +68,16 @@ public abstract class Dialect { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); + /** + * Placeholder of the source table name in table name patterns + */ + public static final String TABLE_PLACEHOLDER = "{table}"; + + /** + * Prefix of a type name which is used as it is, e.g. user defined types of the same database type + */ + public static final String VERBATIM_TYPE_PREFIX = "verbatim:"; + private final TypeNames typeNames = new TypeNames(); private final Set reservedWords = new HashSet<>(); /** @@ -73,6 +89,14 @@ public abstract class Dialect { * Database type which the metadata comes from. null means the same database as this dialect. */ private DbType sourceDbType; + /** + * Database type which rows read by this dialect are written to, set on the dialect of the source database + */ + private DbType readTargetDbType; + /** + * Name pattern of target tables, {table} is replaced with the source table name, e.g. {table}_copy + */ + private String tableNamePattern; private IdentifierCase identifierCase = IdentifierCase.AUTO; /** * When set, table names in DDL/DML generated by this dialect are qualified with these names @@ -185,6 +209,56 @@ public void setSourceDbType(DbType sourceDbType) { this.sourceDbType = sourceDbType; } + public String getTableNamePattern() { + return tableNamePattern; + } + + /** + * Copies tables with other names, e.g. {table}_copy copies tables in the same schema. Names of keys, indexes + * and constraints are derived from the target table names then. + */ + public void setTableNamePattern(String tableNamePattern) { + this.tableNamePattern = tableNamePattern; + } + + /** + * Whether target tables have other names than source tables + */ + public boolean isTableRenamed() { + return StringUtils.isNotBlank(tableNamePattern) && !TABLE_PLACEHOLDER.equals(tableNamePattern.trim()); + } + + /** + * Name of the target table of a source table by the table name pattern + */ + public String getTargetTableName(String tableName) { + if (!isTableRenamed() || StringUtils.isBlank(tableName)) { + return tableName; + } + String pattern = tableNamePattern.trim(); + String name = pattern.contains(TABLE_PLACEHOLDER) ? pattern.replace(TABLE_PLACEHOLDER, tableName) : pattern; + // The name keeps the case of the source name, e.g. EMP_COPY of EMP in Oracle + if (tableName.equals(tableName.toUpperCase(Locale.ENGLISH)) + && !tableName.equals(tableName.toLowerCase(Locale.ENGLISH))) { + return name.toUpperCase(Locale.ENGLISH); + } else if (tableName.equals(tableName.toLowerCase(Locale.ENGLISH)) + && !tableName.equals(tableName.toUpperCase(Locale.ENGLISH))) { + return name.toLowerCase(Locale.ENGLISH); + } + return name; + } + + public DbType getReadTargetDbType() { + return readTargetDbType; + } + + /** + * Database type which rows read by this dialect (as the source dialect) are written to + */ + public void setReadTargetDbType(DbType readTargetDbType) { + this.readTargetDbType = readTargetDbType; + } + public boolean isCrossDatabase() { return sourceDbType != null && sourceDbType != getDbType(); } @@ -251,6 +325,8 @@ public void setDatabaseVersion(int majorVersion, int minorVersion) { public Dialect forVersion(int majorVersion, int minorVersion) { Dialect dialect = getDbType().createDialect(majorVersion, minorVersion); dialect.setSourceDbType(sourceDbType); + dialect.setReadTargetDbType(readTargetDbType); + dialect.setTableNamePattern(tableNamePattern); dialect.setIdentifierCase(identifierCase); dialect.setTargetCatalogName(targetCatalogName); dialect.setTargetSchemaName(targetSchemaName); @@ -446,6 +522,14 @@ public String getTableOptions(String catalog, String schema, String tableName, M return null; } + /** + * Options of a table with its primary key columns, e.g. the sorting key of ClickHouse MergeTree tables + */ + public String getTableOptions(String catalog, String schema, String tableName, Map detail, + List primaryKeyColumnNames) { + return getTableOptions(catalog, schema, tableName, detail); + } + /** * Creates a sequence, returns null if sequences are not supported */ @@ -519,7 +603,11 @@ public String getCreatePrimaryKeyStatement(String catalog, String schema, String String pkeyName) { if (StringUtils.isBlank(pkeyName) || pkeyName.startsWith("SYS_") || "PRIMARY".equalsIgnoreCase(pkeyName) || pkeyName.startsWith("PK__") || isCrossDatabase()) { - pkeyName = getLimitedIdentifier(foldIdentifier(tableName) + "_pkey"); + String foldedTableName = foldIdentifier(tableName); + // The suffix keeps the case of the table name, e.g. EMP_PKEY of Oracle + boolean upper = foldedTableName.equals(foldedTableName.toUpperCase(Locale.ENGLISH)) + && !foldedTableName.equals(foldedTableName.toLowerCase(Locale.ENGLISH)); + pkeyName = getLimitedIdentifier(foldedTableName + (upper ? "_PKEY" : "_pkey")); return String.format("CONSTRAINT %s PRIMARY KEY (%s)", quoteIdentifier(pkeyName), getIdentifiers(columnName)); } return String.format("CONSTRAINT %s PRIMARY KEY (%s)", getIdentifier(pkeyName), getIdentifiers(columnName)); @@ -572,6 +660,20 @@ public String getCreateForeignKeyStatement(String catalog, String schema, String return sql.toString(); } + /** + * Whether empty strings and binaries are stored as NULL (Oracle), empty LOBs are bound as LOB objects then + */ + public boolean isEmptyValueNull() { + return false; + } + + /** + * Whether LOB values of scripts can be restored by UPDATE statements after rows are inserted + */ + public boolean isLobSeparationSupported() { + return true; + } + /** * Whether foreign keys must be defined in create table statement */ @@ -579,6 +681,21 @@ public boolean isForeignKeyInline() { return false; } + /** + * Whether arrays kept as text (e.g. [1, 2, 3]) are bound as java arrays to array columns + */ + public boolean isArrayParameterSupported() { + return true; + } + + /** + * Whether foreign keys are checked while creating, loading and dropping tables (DuckDB): referenced tables are + * created and loaded first, referencing tables are dropped first + */ + public boolean isForeignKeyOrderRequired() { + return false; + } + protected boolean isOnUpdateRuleSupported() { return true; } @@ -659,6 +776,10 @@ public String getColumnStatement(String catalog, String schema, String tableName * Resolves column type of this dialect from jdbc metadata of the source column */ public String getColumnTypeName(int dataType, String typeName, int columnSize, int columnScale) { + if (typeName != null && typeName.startsWith(VERBATIM_TYPE_PREFIX)) { + // A type of the same database type, e.g. an enum type of PostgreSQL + return typeName.substring(VERBATIM_TYPE_PREFIX.length()); + } if (isCrossDatabase() && StringUtils.startsWithIgnoreCase(typeName, "interval")) { // Interval literals are not compatible across databases dataType = Types.VARCHAR; @@ -1048,10 +1169,112 @@ public String getSelectColumnExpression(String columnName, String typeName) { return quoteIdentifier(columnName); } + /** + * Reads a column value of a select statement generated by this dialect as a portable java object + */ + public Object getColumnValue(ResultSet rs, int columnIndex, int columnType, String columnTypeName) + throws SQLException { + return JdbcUtils.getColumnValue(rs, columnIndex, columnType, columnTypeName); + } + public String getCountTableStatement(String catalog, String schema, String tableName) { return String.format("SELECT COUNT(*) FROM %s", getSourceTableName(catalog, schema, tableName)); } + /** + * Rows of a table narrowed by a query: selected columns (all columns if none), condition and groups. Grouped + * rows have the group columns and the count of rows. + * + * @param columnNames columns of the table, the selected ones are read + * @param typeNames type names of the columns + */ + public String getQueryStatement(String catalog, String schema, String tableName, String[] columnNames, + String[] typeNames, TableQuery query) { + if (query == null || query.isEmpty()) { + return getSelectTableStatement(catalog, schema, tableName, columnNames, typeNames); + } + StringBuilder sql = new StringBuilder("SELECT "); + if (query.isGrouped()) { + sql.append(query.getGroupBy().stream().map(this::quoteIdentifier).collect(Collectors.joining(","))) + .append(",COUNT(*) AS ").append(getCountColumnName()); + } else { + List columns = new ArrayList<>(); + for (int i = 0; i < columnNames.length; i++) { + if (query.getColumns().isEmpty() || query.getColumns().contains(columnNames[i])) { + columns.add(getSelectColumnExpression(columnNames[i], typeNames != null ? typeNames[i] : null)); + } + } + sql.append(String.join(",", columns)); + } + sql.append(" FROM ").append(getSourceTableName(catalog, schema, tableName)); + if (StringUtils.isNotBlank(query.getWhere())) { + sql.append(" WHERE ").append(query.getWhere()); + } + if (query.isGrouped()) { + sql.append(" GROUP BY ").append(query.getGroupBy().stream().map(this::quoteIdentifier) + .collect(Collectors.joining(","))); + } + return sql.toString(); + } + + /** + * Order of rows narrowed by a query: the order of the query followed by the primary key columns which are not + * ordered yet (pages stay stable), the group columns of grouped rows, the primary key without query order + * + * @param keyColumnNames primary key columns, not quoted + * @return null if there is no order + */ + public String getQueryOrderBy(TableQuery query, List keyColumnNames) { + if (query != null && query.isGrouped()) { + return StringUtils.isNotBlank(query.getOrderBy()) ? query.getOrderBy() + : query.getGroupBy().stream().map(this::quoteIdentifier).collect(Collectors.joining(",")); + } + String keys = keyColumnNames.isEmpty() ? null + : keyColumnNames.stream().map(this::quoteIdentifier).collect(Collectors.joining(",")); + if (query == null || StringUtils.isBlank(query.getOrderBy())) { + return keys; + } + String lowerOrderBy = query.getOrderBy().toLowerCase(Locale.ENGLISH); + StringBuilder order = new StringBuilder(query.getOrderBy()); + for (String keyColumn : keyColumnNames) { + Pattern ordered = Pattern.compile("(^|[\\s,.`\"\\[])" + Pattern.quote(keyColumn.toLowerCase( + Locale.ENGLISH)) + "($|[\\s,`\"\\]])"); + if (!ordered.matcher(lowerOrderBy).find()) { + order.append(",").append(quoteIdentifier(keyColumn)); + } + } + return order.toString(); + } + + /** + * Name of the count column of grouped rows in the case of unquoted identifiers, so that ORDER BY row_count + * works without quotes + */ + public String getCountColumnName() { + return getStoredIdentifierCase() == IdentifierCase.UPPER + ? TableQuery.COUNT_COLUMN.toUpperCase(Locale.ENGLISH) : TableQuery.COUNT_COLUMN; + } + + /** + * Number of rows of a table narrowed by a query, groups are counted for grouped rows + */ + public String getCountQueryStatement(String catalog, String schema, String tableName, TableQuery query) { + if (query == null || (StringUtils.isBlank(query.getWhere()) && !query.isGrouped())) { + return getCountTableStatement(catalog, schema, tableName); + } + String count = getCountTableStatement(catalog, schema, tableName); + if (StringUtils.isNotBlank(query.getWhere())) { + count += " WHERE " + query.getWhere(); + } + if (!query.isGrouped()) { + return count; + } + String groups = query.getGroupBy().stream().map(this::quoteIdentifier).collect(Collectors.joining(",")); + return String.format("SELECT COUNT(*) FROM (SELECT %s FROM %s%s GROUP BY %s) t", groups, + getSourceTableName(catalog, schema, tableName), StringUtils.isNotBlank(query.getWhere()) + ? " WHERE " + query.getWhere() : "", groups); + } + public String getSelectMaxColumnStatement(String catalog, String schema, String tableName, String columnName) { return String.format("SELECT max(%s) FROM %s", quoteIdentifier(columnName), getSourceTableName(catalog, schema, tableName)); } @@ -1169,8 +1392,8 @@ public String getStringValue(String catalog, String schema, String tableName, St return getTimestampLiteral(((Timestamp) columnValue).toLocalDateTime()); } else if (columnValue instanceof java.sql.Date) { return getDateLiteral(((java.sql.Date) columnValue).toLocalDate()); - } else if (columnValue instanceof java.sql.Time) { - return getTimeLiteral(((java.sql.Time) columnValue).toLocalTime()); + } else if (columnValue instanceof Time) { + return getTimeLiteral(((Time) columnValue).toLocalTime()); } else if (columnValue instanceof java.util.Date) { return getTimestampLiteral(new Timestamp(((java.util.Date) columnValue).getTime()).toLocalDateTime()); } else if (columnValue instanceof LocalDateTime) { @@ -1183,8 +1406,8 @@ public String getStringValue(String catalog, String schema, String tableName, St return getTimestampWithTimeZoneLiteral((OffsetDateTime) columnValue); } else if (columnValue instanceof Object[]) { return getArrayLiteral(catalog, schema, tableName, columnName, (Object[]) columnValue); - } else if (columnValue instanceof java.time.OffsetTime) { - java.time.OffsetTime time = (java.time.OffsetTime) columnValue; + } else if (columnValue instanceof OffsetTime) { + OffsetTime time = (OffsetTime) columnValue; return getStringLiteral(formatTime(time.toLocalTime()) + time.getOffset()); } else if (columnValue instanceof ZonedDateTime) { return getTimestampLiteral(((ZonedDateTime) columnValue).toLocalDateTime()); @@ -1219,19 +1442,20 @@ public Object getJdbcValue(Object value, int sourceDataType, String sourceTypeNa value = ((Number) value).intValue() != 0; } if (isCrossDatabase()) { - if (value instanceof CharSequence && "timetz".equalsIgnoreCase(sourceTypeName)) { + if (value instanceof CharSequence && ("timetz".equalsIgnoreCase(sourceTypeName) + || "time with time zone".equalsIgnoreCase(sourceTypeName))) { String text = value.toString().replaceAll("([+-]\\d{2})$", "$1:00"); - value = java.time.OffsetTime.parse(text).toLocalTime(); + value = OffsetTime.parse(text).toLocalTime(); } else if (sourceDataType == Types.BIT && columnSize > 1) { // Bit strings are migrated as integers value = toLong(value); } else if (value instanceof Object[]) { value = Arrays.deepToString((Object[]) value); - } else if (value instanceof java.time.OffsetTime) { - value = ((java.time.OffsetTime) value).toLocalTime(); + } else if (value instanceof OffsetTime) { + value = ((OffsetTime) value).toLocalTime(); } else if (value instanceof OffsetDateTime) { // Instant in local time zone - value = ((OffsetDateTime) value).atZoneSameInstant(java.time.ZoneId.systemDefault()) + value = ((OffsetDateTime) value).atZoneSameInstant(ZoneId.systemDefault()) .toLocalDateTime(); } // Fractional seconds are truncated, rounding may overflow (e.g. 9999-12-31 23:59:59.9999999) @@ -1244,6 +1468,41 @@ public Object getJdbcValue(Object value, int sourceDataType, String sourceTypeNa return getJdbcValue(value); } + /** + * Type of a column of a user defined type (enum, domain) of the same database type, the type is created in + * the target schema + */ + public String getUserTypeName(String catalog, String schema, String typeName) { + String qualifier = getQualifier(getTargetCatalogName(), getTargetSchemaName(schema)); + return StringUtils.isNotBlank(qualifier) ? qualifier + "." + getIdentifier(typeName) + : getIdentifier(typeName); + } + + /** + * Statement creating a user defined type of the same database type if it does not exist, null if user defined + * types are not supported + * + * @param userType KIND (ENUM, DOMAIN), NAME and DEFINITION of the type + */ + public String getCreateUserTypeStatement(String catalog, String schema, Map userType) { + return null; + } + + /** + * Whether values are converted by the types of target columns (getTargetJdbcValue), the types are read from + * the target table once per table + */ + public boolean isTargetTypeConversionRequired() { + return false; + } + + /** + * Converts a value for a target column of the given type name, e.g. text of a vector for a VECTOR column + */ + public Object getTargetJdbcValue(Object value, String targetTypeName) { + return value; + } + /** * Sql type used to bind NULL of a column */ diff --git a/src/main/java/com/github/sqljam/impexp/DuckDBWorkspace.java b/src/main/java/com/github/sqljam/impexp/DuckDBWorkspace.java new file mode 100644 index 0000000..077782c --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/DuckDBWorkspace.java @@ -0,0 +1,264 @@ +/* + * 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.Closeable; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.jdbc.JdbcUtils; +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. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +@Slf4j +public class DuckDBWorkspace implements Closeable { + + public static final String DEFAULT_SCHEMA = "main"; + + private final File directory; + private final File file; + private Connection connection; + + /** + * @param parent directory of the workspace, the system temporary directory if null + */ + public DuckDBWorkspace(File parent) throws IOException { + File base = parent != null ? parent : FileUtils.getTempDirectory(); + FileUtils.forceMkdir(base); + this.directory = Files.createTempDirectory(base.toPath(), ".sqljam-workspace-").toFile(); + this.file = new File(directory, "workspace.duckdb"); + } + + public String getUrl() { + return DbType.DUCKDB.getUrl(null, 0, file.getAbsolutePath()); + } + + public File getFile() { + return file; + } + + public synchronized Connection getConnection() throws SQLException { + if (connection == null || connection.isClosed()) { + connection = DriverManager.getConnection(getUrl()); + } + return connection; + } + + /** + * Tables of the workspace: schema and table name + */ + public List getTables() throws SQLException { + String sql = "SELECT schema_name, table_name FROM duckdb_tables() WHERE database_name = current_database()" + + " AND NOT internal ORDER BY schema_name, table_name"; + return JdbcUtils.fetchAll(getConnection(), sql, new Object[0]).stream() + .map(row -> new String[]{(String) row.get("schema_name"), (String) row.get("table_name")}) + .collect(Collectors.toList()); + } + + public long countRows(String schema, String table) throws SQLException { + Map row = JdbcUtils.fetchOne(getConnection(), + String.format("SELECT count(*) AS n FROM %s", qualify(schema, table)), new Object[0]); + Object count = row != null ? row.get("n") : null; + return count instanceof Number ? ((Number) count).longValue() : 0; + } + + /** + * Writes rows of the table into a Parquet file. Integers wider than DECIMAL(38) have no Parquet type, they are + * written as DECIMAL(38,0). + * + * @param compression SNAPPY, ZSTD, GZIP or UNCOMPRESSED + * @param metadata key value metadata of the file + */ + public void writeParquet(String schema, String table, File parquetFile, String compression, + Map metadata) 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"; + List columns = new ArrayList<>(); + for (Map row : JdbcUtils.fetchAll(getConnection(), columnSql, new Object[]{schema, table})) { + String column = quote((String) row.get("column_name")); + String dataType = StringUtils.upperCase((String) row.get("data_type"), Locale.ENGLISH); + if ("HUGEINT".equals(dataType) || "UHUGEINT".equals(dataType)) { + columns.add(String.format("CAST(%1$s AS DECIMAL(38,0)) AS %1$s", column)); + } else { + columns.add(column); + } + } + StringBuilder options = new StringBuilder("FORMAT parquet, COMPRESSION ") + .append(StringUtils.defaultIfBlank(compression, "ZSTD").toLowerCase(Locale.ENGLISH)); + if (metadata != null && !metadata.isEmpty()) { + options.append(", KV_METADATA {").append(metadata.entrySet().stream() + .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); + execute(sql); + } + + /** + * Loads Parquet files into a table of the workspace. Identity columns get a sequence and a nextval default, so + * that they are recognized as identity columns of DuckDB. + */ + 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)); + if (identityColumns != null) { + for (String column : identityColumns) { + String sequence = qualify(schema, table + "_" + column + "_seq"); + execute(String.format("CREATE SEQUENCE IF NOT EXISTS %s START 1", sequence)); + execute(String.format("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT nextval('%s')", + qualify(schema, table), quote(column), sequence.replace("'", "''"))); + } + } + } + + /** + * Drops columns of a workspace table, e.g. columns which are generated by the target database + */ + public void dropColumns(String schema, String table, List columns) throws SQLException { + for (String column : columns) { + execute(String.format("ALTER TABLE %s DROP COLUMN IF EXISTS %s", qualify(schema, table), quote(column))); + } + } + + /** + * Columns of a workspace table: name and DuckDB type + */ + public List describeTable(String schema, String table) throws SQLException { + List columns = new ArrayList<>(); + try (Statement statement = getConnection().createStatement(); + ResultSet rs = statement.executeQuery(String.format("DESCRIBE %s", qualify(schema, table)))) { + while (rs.next()) { + columns.add(new String[]{rs.getString("column_name"), rs.getString("column_type")}); + } + } + return columns; + } + + /** + * Columns of Parquet files: name and DuckDB type + */ + public List describeParquet(List parquetFiles) throws SQLException { + String files = parquetFiles.stream().map(f -> literal(f.getAbsolutePath())) + .collect(Collectors.joining(", ", "[", "]")); + List columns = new ArrayList<>(); + try (Statement statement = getConnection().createStatement(); + ResultSet rs = statement.executeQuery(String.format("DESCRIBE SELECT * FROM read_parquet(%s)", files))) { + while (rs.next()) { + columns.add(new String[]{rs.getString("column_name"), rs.getString("column_type")}); + } + } + return columns; + } + + /** + * First rows of Parquet 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() + .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, + Math.max(limit, 0)); + try (Statement statement = getConnection().createStatement(); ResultSet rs = statement.executeQuery(sql)) { + int count = rs.getMetaData().getColumnCount(); + while (rs.next()) { + List row = new ArrayList<>(count); + for (int i = 1; i <= count; i++) { + row.add(rs.getString(i)); + } + rows.add(row); + } + } + return rows; + } + + public long countParquetRows(List parquetFiles) throws SQLException { + String files = parquetFiles.stream().map(f -> literal(f.getAbsolutePath())) + .collect(Collectors.joining(", ", "[", "]")); + Map row = JdbcUtils.fetchOne(getConnection(), + String.format("SELECT count(*) AS n FROM read_parquet(%s)", files), new Object[0]); + Object count = row != null ? row.get("n") : null; + return count instanceof Number ? ((Number) count).longValue() : 0; + } + + public void execute(String sql) throws SQLException { + if (log.isDebugEnabled()) { + log.debug("Workspace: {}", sql); + } + try (Statement statement = getConnection().createStatement()) { + statement.execute(sql); + } + } + + public static String quote(String name) { + return "\"" + name.replace("\"", "\"\"") + "\""; + } + + public static String qualify(String schema, String table) { + return StringUtils.isNotBlank(schema) ? quote(schema) + "." + quote(table) : quote(table); + } + + private static String literal(String value) { + return "'" + value.replace("'", "''") + "'"; + } + + /** + * Closes the connection of the workspace, it is opened again when needed + */ + public synchronized void closeConnection() { + JdbcUtils.closeQuietly(connection); + connection = null; + } + + @Override + public synchronized void close() { + JdbcUtils.closeQuietly(connection); + connection = null; + FileUtils.deleteQuietly(directory); + } +} diff --git a/src/main/java/com/github/sqljam/impexp/ExportManifest.java b/src/main/java/com/github/sqljam/impexp/ExportManifest.java index b800f8a..901fca4 100644 --- a/src/main/java/com/github/sqljam/impexp/ExportManifest.java +++ b/src/main/java/com/github/sqljam/impexp/ExportManifest.java @@ -67,7 +67,12 @@ public enum FileType { LOB_MANIFEST, - CONSTRAINTS + CONSTRAINTS, + + /** + * Rows of a table in a Parquet file + */ + PARQUET } private String format = FORMAT; @@ -75,6 +80,10 @@ public enum FileType { private Status status; private String createdAt = LocalDateTime.now().toString(); private ExportMode exportMode; + /** + * Format of rows, SQL for packages written before Parquet was supported + */ + private DataFormat dataFormat = DataFormat.SQL; private Database source = new Database(); private Database target = new Database(); private Map options = new LinkedHashMap<>(); @@ -125,6 +134,10 @@ public static class TableEntry { private long rows; 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<>(); } 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 28c191a..fad6d91 100644 --- a/src/main/java/com/github/sqljam/impexp/Exporter.java +++ b/src/main/java/com/github/sqljam/impexp/Exporter.java @@ -18,13 +18,13 @@ import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.SQLException; -import java.util.List; +import java.util.HashMap; import java.util.Map; -import java.util.stream.Collectors; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import com.github.sqljam.jdbc.ConnectionFactory; +import com.github.sqljam.jdbc.JdbcUtils; import com.github.sqljam.jdbc.SimpleConnectionFactory; import com.github.sqljam.jdbc.page.MapBasedPageReader; import com.github.sqljam.page.EachPage; @@ -110,6 +110,22 @@ public static class ExportConfiguration { * Dialect of source database to read data. Default is the dialect of dbType. */ private Dialect sourceDialect; + /** + * Queries narrowing the rows and columns of tables by table name, e.g. rows filtered in the data viewer + */ + private Map tableQueries = new HashMap<>(); + + /** + * Query of a table, null if all rows and columns are exported. Grouped rows are not rows of the table. + */ + public TableQuery getTableQuery(String tableName) { + TableQuery query = tableQueries != null ? tableQueries.get(tableName) : null; + if (query != null && query.isGrouped()) { + throw new ImpExpException("Grouped rows of table " + tableName + " can not be exported," + + " the rows are exported without GROUP BY"); + } + return query == null || query.isEmpty() ? null : query; + } public Dialect getDialect() { if (dialect == null && dbType != null) { @@ -216,7 +232,7 @@ public void export(ExportMode exportMode) throws Exception { } } - private final Map tableRows = new java.util.HashMap<>(); + private final Map tableRows = new HashMap<>(); private long totalRows; private long processedRows; @@ -237,9 +253,10 @@ private void countRows(ServerMetaData serverMetaData, ConnectionFactory connecti continue; } checkCancelled(); - String sql = sourceDialect.getCountTableStatement(catalogMd.getCatalogName(), - schemaMd.getSchemaName(), tableMd.getTableName()); - Long rows = com.github.sqljam.jdbc.JdbcUtils.fetchOne(connection, sql, Long.class); + String sql = sourceDialect.getCountQueryStatement(catalogMd.getCatalogName(), + schemaMd.getSchemaName(), tableMd.getTableName(), + configuration.getTableQuery(tableMd.getTableName())); + Long rows = JdbcUtils.fetchOne(connection, sql, Long.class); tableRows.put(tableMd, rows != null ? rows : 0L); totalRows += rows != null ? rows : 0L; } @@ -324,16 +341,18 @@ private void scanTable(String catalogName, String schemaName, String tableName, ConnectionFactory connectionFactory) { checkCancelled(); Dialect sourceDialect = configuration.getSourceDialect(); + if (configuration.getDialect() != null) { + sourceDialect.setReadTargetDbType(configuration.getDialect().getDbType()); + } String[] columnNames = tableMetaData.getColumnMetaDatas().stream().map(ColumnMetaData::getColumnName) .toArray(String[]::new); String[] typeNames = tableMetaData.getColumnMetaDatas().stream() .map(md -> (String) md.getDetail().get("TYPE_NAME")).toArray(String[]::new); + TableQuery query = configuration.getTableQuery(tableName); String sql = columnNames.length > 0 - ? sourceDialect.getSelectTableStatement(catalogName, schemaName, tableName, columnNames, typeNames) + ? sourceDialect.getQueryStatement(catalogName, schemaName, tableName, columnNames, typeNames, query) : sourceDialect.getSelectTableStatement(catalogName, schemaName, tableName); - List orderColumns = tableMetaData.getPrimaryKeyColumnNames(); - String orderBy = orderColumns.isEmpty() ? null - : orderColumns.stream().map(sourceDialect::quoteIdentifier).collect(Collectors.joining(",")); + String orderBy = sourceDialect.getQueryOrderBy(query, tableMetaData.getPrimaryKeyColumnNames()); // Rows were counted already long rows = tableRows.getOrDefault(tableMetaData, -1L); PageReader> pageReader = new MapBasedPageReader(connectionFactory, sql, new Object[0], diff --git a/src/main/java/com/github/sqljam/impexp/ForeignKeyMetaData.java b/src/main/java/com/github/sqljam/impexp/ForeignKeyMetaData.java index 3850907..c5c6f42 100644 --- a/src/main/java/com/github/sqljam/impexp/ForeignKeyMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/ForeignKeyMetaData.java @@ -48,8 +48,18 @@ public String[] getStatements() throws SQLException { String[] columnNames = details.stream().map(info -> (String) info.get("FKCOLUMN_NAME")).toArray(String[]::new); String[] refColumnNames = details.stream().map(info -> (String) info.get("PKCOLUMN_NAME")) .toArray(String[]::new); - String statement = getDialect().getCreateForeignKeyStatement(getCatalogName(), getSchemaName(), getTableName(), - foreignKeyName, columnNames, getReferencedTableName(), refColumnNames, + Dialect dialect = getDialect(); + String refTableName = getReferencedTableName(); + String fkName = foreignKeyName; + if (dialect.isTableRenamed()) { + // Copies reference copies of tables which are copied together, other tables are referenced as they are + if (unwrap(SchemaMetaData.class).findTableMetaData(refTableName).isPresent()) { + refTableName = dialect.getTargetTableName(refTableName); + } + fkName = dialect.getTargetTableName(foreignKeyName); + } + String statement = dialect.getCreateForeignKeyStatement(getCatalogName(), getSchemaName(), + dialect.getTargetTableName(getTableName()), fkName, columnNames, refTableName, refColumnNames, getRuleName(getDetail().get("UPDATE_RULE")), getRuleName(getDetail().get("DELETE_RULE"))); return new String[]{statement}; } diff --git a/src/main/java/com/github/sqljam/impexp/HikariDataSourceConnectionFactory.java b/src/main/java/com/github/sqljam/impexp/HikariDataSourceConnectionFactory.java index 7b83620..cd338b9 100644 --- a/src/main/java/com/github/sqljam/impexp/HikariDataSourceConnectionFactory.java +++ b/src/main/java/com/github/sqljam/impexp/HikariDataSourceConnectionFactory.java @@ -17,8 +17,9 @@ import java.sql.Connection; import java.sql.SQLException; -import com.github.sqljam.jdbc.ConnectionFactory; + import com.github.sqljam.config.Config; +import com.github.sqljam.jdbc.ConnectionFactory; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; diff --git a/src/main/java/com/github/sqljam/impexp/ImportExportHandler.java b/src/main/java/com/github/sqljam/impexp/ImportExportHandler.java index 933afca..8684143 100644 --- a/src/main/java/com/github/sqljam/impexp/ImportExportHandler.java +++ b/src/main/java/com/github/sqljam/impexp/ImportExportHandler.java @@ -18,23 +18,30 @@ import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Statement; import java.sql.Types; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; 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.concurrent.ConcurrentHashMap; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.impexp.DdlScripter.Catalog; +import com.github.sqljam.impexp.DdlScripter.Schema; import com.github.sqljam.jdbc.ConnectionFactory; import com.github.sqljam.jdbc.JdbcUtils; import com.github.sqljam.jdbc.SimpleConnectionFactory; import com.github.sqljam.page.EachPage; -import com.github.sqljam.impexp.DdlScripter.Catalog; -import com.github.sqljam.impexp.DdlScripter.Schema; - import lombok.Getter; import lombok.Setter; import lombok.ToString; @@ -78,18 +85,32 @@ public static class ImportConfiguration { * Creates target schema (database of MySQL) if it does not exist */ private boolean targetSchemaCreated = true; + /** + * Name pattern of target tables, {table} is replaced with the source table name, e.g. {table}_copy copies + * tables in the same schema. Target tables have the same names by default. + */ + private String tableNamePattern; } private ConnectionFactory connectionFactory; private Dialect dialect; private ExportListener exportListener = ExportListener.NONE; private final IdentityValueTracker identityValueTracker = new IdentityValueTracker(); + private final Map> targetColumnTypes = new ConcurrentHashMap<>(); @Override public void setExportListener(ExportListener exportListener) { this.exportListener = exportListener != null ? exportListener : ExportListener.NONE; } + /** + * Connections of the target database, e.g. an existing connection shared by {@link com.github.sqljam.jdbc.SharedConnectionFactory}. + * By default a connection pool is created from the configuration. + */ + public void setConnectionFactory(ConnectionFactory connectionFactory) { + this.connectionFactory = connectionFactory; + } + @Override public void start() { if (connectionFactory == null) { @@ -185,7 +206,7 @@ public void exportDdl(DdlScripter ddlScripter) throws Exception { } private void createTargetSchemas(DdlScripter ddlScripter) throws SQLException { - java.util.Set schemaNames = new java.util.LinkedHashSet<>(); + Set schemaNames = new LinkedHashSet<>(); if (!configuration.getDbType().isSchemaSupported()) { if (StringUtils.isNotBlank(configuration.getTargetCatalogName())) { schemaNames.add(configuration.getTargetCatalogName()); @@ -303,42 +324,73 @@ public void exportData(String catalogName, for (int i = 0; i < columns.length; i++) { nullTypes[i] = dialect.getNullSqlType(getNullType(tableMetaData.findColumnMetaData(columns[i]).orElse(null))); } - String insertSql = dialect.getInsertTableStatement(catalogName, schemaName, tableName, columns); + // Empty LOBs of databases which store empty values as NULL (Oracle) are bound as LOB objects + int[] emptyLobTypes = new int[columns.length]; + if (dialect.isEmptyValueNull()) { + for (int i = 0; i < columns.length; i++) { + emptyLobTypes[i] = getLobType(dialect.getRegisteredColumnTypeName(tableName, columns[i]), + tableMetaData.findColumnMetaData(columns[i]).orElse(null)); + } + } + String targetTableName = dialect.getTargetTableName(tableName); + Map targetTypes = Collections.emptyMap(); + String insertSql = dialect.getInsertTableStatement(catalogName, schemaName, targetTableName, columns); Connection connection = null; boolean autoCommit = true; + boolean transactional = false; try { connection = getConnection(catalogName, schemaName); autoCommit = connection.getAutoCommit(); - connection.setAutoCommit(false); - executeQuietly(connection, dialect.getStatementBeforeInsert(catalogName, schemaName, tableName, + transactional = JdbcUtils.beginTransaction(connection); + if (dialect.isTargetTypeConversionRequired()) { + targetTypes = getTargetColumnTypes(connection, targetTableName); + } + executeQuietly(connection, dialect.getStatementBeforeInsert(catalogName, schemaName, targetTableName, identityIncluded)); int rows = 0; 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 { - ps.setObject(index + 1, value); + 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)))); + } } index++; } ps.addBatch(); } for (int n : ps.executeBatch()) { - rows += n > 0 ? n : (n == java.sql.Statement.SUCCESS_NO_INFO ? 1 : 0); + rows += n > 0 ? n : (n == Statement.SUCCESS_NO_INFO ? 1 : 0); } } - executeQuietly(connection, dialect.getStatementAfterInsert(catalogName, schemaName, tableName, + executeQuietly(connection, dialect.getStatementAfterInsert(catalogName, schemaName, targetTableName, identityIncluded)); - connection.commit(); + if (transactional) { + connection.commit(); + } if (log.isInfoEnabled()) { log.info("Execute dml: {}", insertSql); log.info("Add {} rows to table: {}", rows, tableName); } } catch (Exception e) { - if (connection != null) { + if (connection != null && transactional) { try { connection.rollback(); } catch (SQLException ignored) { @@ -349,7 +401,7 @@ public void exportData(String catalogName, } throw e; } finally { - if (connection != null) { + if (connection != null && transactional) { try { connection.setAutoCommit(autoCommit); } catch (SQLException ignored) { @@ -371,8 +423,9 @@ private void resetIdentityValues(String catalogName, String schemaName, String t } List sqls = new ArrayList<>(); for (Map.Entry entry : maxValues.entrySet()) { - String sql = tableMetaData.getDialect().getResetIdentityStatement(catalogName, schemaName, tableName, - entry.getKey(), entry.getValue() + 1); + Dialect targetDialect = tableMetaData.getDialect(); + String sql = targetDialect.getResetIdentityStatement(catalogName, schemaName, + targetDialect.getTargetTableName(tableName), entry.getKey(), entry.getValue() + 1); if (StringUtils.isNotBlank(sql)) { sqls.add(sql); } @@ -398,6 +451,42 @@ private void executeQuietly(Connection connection, String[] sqls) { /** * Sql type to bind NULL, some drivers (SQL Server) can not convert untyped NULL to binary columns */ + /** + * BLOB or CLOB if the target column is a LOB, 0 otherwise. The type registered by created tables is preferred, + * the type of the source column is used when tables are not created. + */ + /** + * Type names of the columns of a target table by lower case column name, read once per table + */ + private Map getTargetColumnTypes(Connection connection, String targetTableName) + throws SQLException { + Map types = targetColumnTypes.get(targetTableName); + if (types != null) { + return types; + } + types = new HashMap<>(); + try (ResultSet rs = connection.getMetaData().getColumns(connection.getCatalog(), + connection.getSchema(), dialect.foldIdentifier(targetTableName), null)) { + while (rs.next()) { + types.put(rs.getString("COLUMN_NAME").toLowerCase(Locale.ENGLISH), + rs.getString("TYPE_NAME")); + } + } + targetColumnTypes.put(targetTableName, types); + return types; + } + + static int getLobType(String targetTypeName, ColumnMetaData columnMetaData) { + String typeName = StringUtils.defaultString(targetTypeName).toLowerCase(Locale.ENGLISH); + if (typeName.isEmpty() && columnMetaData != null) { + int dataType = TableMetaData.getInt(columnMetaData.getDetail(), "DATA_TYPE"); + return dataType == Types.BLOB ? Types.BLOB : dataType == Types.CLOB || dataType == Types.NCLOB + ? Types.CLOB : 0; + } + return typeName.equals("blob") ? Types.BLOB : typeName.equals("clob") || typeName.equals("nclob") + ? Types.CLOB : 0; + } + static int getNullType(ColumnMetaData columnMetaData) { if (columnMetaData == null) { return Types.VARCHAR; @@ -435,6 +524,48 @@ static int getNullType(ColumnMetaData columnMetaData) { } } + static boolean isArrayText(Object value) { + if (!(value instanceof CharSequence)) { + return false; + } + String text = value.toString(); + return text.length() >= 2 && text.charAt(0) == '[' && text.charAt(text.length() - 1) == ']'; + } + + /** + * Whether the parameter is bound to an array column, the type is read once from parameter metadata + */ + private static boolean isArrayParameter(PreparedStatement ps, int parameterIndex, + Map arrayParameters) { + return arrayParameters.computeIfAbsent(parameterIndex, index -> { + try { + return ps.getParameterMetaData().getParameterType(index) == Types.ARRAY; + } catch (SQLException | RuntimeException e) { + return false; + } + }); + } + + /** + * Elements of an array text, e.g. [1, 2, 3] or [a, b], NULL elements are nulls + */ + static Object[] parseArrayText(String text) { + String content = text.substring(1, text.length() - 1).trim(); + if (content.isEmpty()) { + return new Object[0]; + } + String[] items = content.split(",\\s*"); + Object[] elements = new Object[items.length]; + for (int i = 0; i < items.length; i++) { + String item = items[i].trim(); + if (item.length() >= 2 && item.startsWith("'") && item.endsWith("'")) { + item = item.substring(1, item.length() - 1).replace("''", "'"); + } + elements[i] = "NULL".equalsIgnoreCase(item) ? null : item; + } + return elements; + } + protected boolean shouldFilterColumn(String columnName, TableMetaData tableMetaData) { return tableMetaData.isAutoIncrementColumn(columnName); } diff --git a/src/main/java/com/github/sqljam/impexp/ImportExporter.java b/src/main/java/com/github/sqljam/impexp/ImportExporter.java index 5cac4f6..92d69d9 100644 --- a/src/main/java/com/github/sqljam/impexp/ImportExporter.java +++ b/src/main/java/com/github/sqljam/impexp/ImportExporter.java @@ -17,6 +17,7 @@ import org.apache.commons.lang3.StringUtils; import com.github.sqljam.impexp.ImportExportHandler.ImportConfiguration; +import com.github.sqljam.jdbc.ConnectionFactory; /** * @Description: ImportExporter copies tables from a source database into a target database @@ -32,6 +33,8 @@ public ImportExporter() { private final ImportExportHandler importingExportHandler = new ImportExportHandler(); private final Exporter exporter = new Exporter(importingExportHandler); private IdentifierCase identifierCase; + private Dialect targetDialect; + private Boolean sourceSchemaPreserved; private int targetMajorVersion = -1; private int targetMinorVersion = -1; @@ -43,10 +46,33 @@ public void setMetaDataOperations(MetaDataOperations metaDataOperations) { exporter.setMetaDataOperations(metaDataOperations); } + /** + * Imports by connections of the factory instead of creating a connection pool, e.g. an existing connection + * shared by {@link com.github.sqljam.jdbc.SharedConnectionFactory} + */ + public void setTargetConnectionFactory(ConnectionFactory connectionFactory) { + importingExportHandler.setConnectionFactory(connectionFactory); + } + public void setExportListener(ExportListener exportListener) { exporter.setExportListener(exportListener); } + /** + * Whether tables are imported into schemas with the same names as source schemas when no target schema is + * given. By default schemas are preserved if both databases support schemas. + */ + public void setSourceSchemaPreserved(Boolean sourceSchemaPreserved) { + this.sourceSchemaPreserved = sourceSchemaPreserved; + } + + /** + * Dialect of the target database with custom settings, created from the target database type by default + */ + public void setTargetDialect(Dialect targetDialect) { + this.targetDialect = targetDialect; + } + public void setIdentifierCase(IdentifierCase identifierCase) { this.identifierCase = identifierCase; } @@ -76,17 +102,25 @@ private void prepare() { if (importConfiguration.getDbType() == null) { importConfiguration.setDbType(DbType.forUrl(importConfiguration.getUrl())); } - Dialect dialect = importConfiguration.getDbType().createDialect(); + Dialect dialect = targetDialect != null ? targetDialect : importConfiguration.getDbType().createDialect(); dialect.setSourceDbType(exportConfiguration.getDbType()); if (identifierCase != null) { dialect.setIdentifierCase(identifierCase); } + dialect.setTableNamePattern(importConfiguration.getTableNamePattern()); + if (!dialect.isTableRenamed() && isSameTables(exportConfiguration, importConfiguration)) { + // Target tables would be dropped and created before rows are read from them + throw new ImpExpException("Source and target tables are the same, please choose another schema or a " + + "table name pattern such as " + Dialect.TABLE_PLACEHOLDER + "_copy"); + } if (StringUtils.isNotBlank(importConfiguration.getTargetSchemaName())) { dialect.setTargetCatalogName(importConfiguration.getTargetCatalogName()); dialect.setTargetSchemaName(importConfiguration.getTargetSchemaName()); } else if (StringUtils.isNotBlank(importConfiguration.getTargetCatalogName()) && !importConfiguration.getDbType().isSchemaSupported()) { dialect.setTargetCatalogName(importConfiguration.getTargetCatalogName()); + } else if (sourceSchemaPreserved != null) { + dialect.setSourceSchemaPreserved(sourceSchemaPreserved); } else if (importConfiguration.getDbType().isSchemaSupported() && exportConfiguration.getDbType().isSchemaSupported()) { // Schema level import: tables are imported into schemas with the same names @@ -102,6 +136,33 @@ private void prepare() { exportConfiguration.setShowCreateSchemaSql(false); } + /** + * Whether rows would be imported into the tables they are read from: the same database (url) and the same + * catalog and schema. A target catalog or schema which is not given means the source one. + */ + static boolean isSameTables(Exporter.ExportConfiguration exportConfiguration, + ImportConfiguration importConfiguration) { + if (exportConfiguration.getDbType() != importConfiguration.getDbType() + || StringUtils.isBlank(exportConfiguration.getUrl()) + || !StringUtils.equals(exportConfiguration.getUrl().trim(), StringUtils.trim(importConfiguration.getUrl()))) { + return false; + } + return isSameName(exportConfiguration.getIncludedCatalogNames(), importConfiguration.getTargetCatalogName()) + && isSameName(exportConfiguration.getIncludedSchemaNames(), importConfiguration.getTargetSchemaName()); + } + + private static boolean isSameName(String[] sourceNames, String targetName) { + if (StringUtils.isBlank(targetName) || sourceNames == null || sourceNames.length == 0) { + return true; + } + for (String sourceName : sourceNames) { + if (targetName.trim().equalsIgnoreCase(StringUtils.trim(sourceName))) { + return true; + } + } + return false; + } + public void export(ExportMode exportMode) throws Exception { prepare(); exporter.export(exportMode); diff --git a/src/main/java/com/github/sqljam/impexp/IndexMetaData.java b/src/main/java/com/github/sqljam/impexp/IndexMetaData.java index 7db79df..89276ab 100644 --- a/src/main/java/com/github/sqljam/impexp/IndexMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/IndexMetaData.java @@ -121,7 +121,7 @@ public String[] getStatements() throws SQLException { String catalogName = getCatalogName(); String schemaName = getSchemaName(); - String tableName = getTableName(); + String tableName = getDialect().getTargetTableName(getTableName()); boolean partition = tableMetaData.isPartitionTable(); int typeIndex = TableMetaData.getInt(detail, "TYPE"); String indexType = typeIndex == DatabaseMetaData.tableIndexHashed ? "HASH" : null; diff --git a/src/main/java/com/github/sqljam/impexp/ParquetExporter.java b/src/main/java/com/github/sqljam/impexp/ParquetExporter.java new file mode 100644 index 0000000..fdab76a --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/ParquetExporter.java @@ -0,0 +1,411 @@ +/* + * 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: 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. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +@Slf4j +public final class ParquetExporter { + + 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; + private String compression = DEFAULT_COMPRESSION; + private final List writtenFiles = new ArrayList<>(); + + public ParquetExporter(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; + } + + /** + * Compression of Parquet files: ZSTD (default), SNAPPY, GZIP or UNCOMPRESSED + */ + public void setCompression(String compression) { + this.compression = StringUtils.defaultIfBlank(compression, DEFAULT_COMPRESSION).toUpperCase(); + } + + 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)); + } + } + } + + /** + * 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, 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 files = new ArrayList<>(); + for (ExportManifest.FileEntry entry : manifest.getFiles()) { + if (entry.getType() == ExportManifest.FileType.SCHEMA) { + files.add(entry); + } + } + files.addAll(parquetFiles); + 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 parquet 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/ParquetImporter.java b/src/main/java/com/github/sqljam/impexp/ParquetImporter.java new file mode 100644 index 0000000..220ab71 --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/ParquetImporter.java @@ -0,0 +1,436 @@ +/* + * 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: ParquetImporter imports Parquet files into a database by a connection. Packages exported by + * {@link ParquetExporter} are imported in the order of manifest.json: schema.sql, Parquet files into + * the created tables (identity values are kept and identities are reset), constraints.sql. Parquet + * files of other tools are imported into a new table created from the Parquet schema, 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. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +@Slf4j +public class ParquetImporter extends AbstractFileImporter { + + /** + * CREATE drops and creates the table from the Parquet schema, APPEND inserts rows into the existing table + */ + public enum Mode { + CREATE, + APPEND + } + + 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; + + public ParquetImporter(Connection connection, DbType dbType) { + super(connection, dbType); + } + + /** + * 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; + } + + /** + * Rows imported from Parquet files + */ + public long getImportedRows() { + return importedRows; + } + + /** + * Imports Parquet 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, "Parquet files must be required"); + 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 = workspace.countParquetRows(files); + exportListener.onMessage(String.format("Import %d rows of %d Parquet files into %s", total, files.size(), + tableName)); + importTable(workspace, DuckDBWorkspace.DEFAULT_SCHEMA, tableName, files, Collections.emptyList(), 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, Parquet 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() != DataFormat.PARQUET) { + throw new ImpExpException("Packages of sql scripts are imported by ScriptImporter"); + } + importedRows = 0; + boolean parquetImported = false; + for (ExportManifest.FileEntry fileEntry : manifest.getFiles()) { + File file = new File(dir, fileEntry.getPath()); + if (fileEntry.getType() == ExportManifest.FileType.PARQUET) { + if (!parquetImported) { + importPackageFiles(dir, manifest); + parquetImported = true; + } + } else { + runScript(file); + } + } + } + + private void importPackageFiles(File dir, ExportManifest manifest) throws SQLException { + long parquetBytes = manifest.getFiles().stream() + .filter(entry -> entry.getType() == ExportManifest.FileType.PARQUET) + .mapToLong(entry -> new File(dir, entry.getPath()).length()).sum(); + long startBytes = completedBytes; + ExportListener bytesListener = new ForwardingListener(exportListener) { + + @Override + public void onProgress(long processed, long total) { + // Rows are converted to bytes of Parquet files + long bytes = total > 0 ? parquetBytes * 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(); + } + List files = 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())); + importTable(workspace, schema, table.getName(), files, table.getIdentityColumns(), Mode.APPEND, + 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(parquetBytes); + } + + private void importTable(DuckDBWorkspace workspace, String schema, String tableName, List files, + List identityColumns, Mode mode, String packageCatalog, String packageSchema, + ProgressListener listener) throws Exception { + workspace.loadParquet(schema, tableName, files, identityColumns); + String catalog = StringUtils.defaultIfBlank(targetCatalogName, packageCatalog); + String targetSchema = StringUtils.defaultIfBlank(targetSchemaName, packageSchema); + if (mode == Mode.APPEND) { + // 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); + 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); + if (listener.getError() != null) { + 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/PartitionMetaData.java b/src/main/java/com/github/sqljam/impexp/PartitionMetaData.java index e9abcc9..92af399 100644 --- a/src/main/java/com/github/sqljam/impexp/PartitionMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/PartitionMetaData.java @@ -22,11 +22,10 @@ import java.util.Map; import java.util.Optional; +import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.ArrayUtils; - /** * @Description: PartitionMetaData generates the partition clause of a partitioned table and its partitions (PostgreSQL) * @Author: Fred Feng @@ -130,7 +129,7 @@ public List getPartitionTableMetaDatas() { public String[] getStatements() throws SQLException { String catalogName = getCatalogName(); String schemaName = getSchemaName(); - String tableName = getTableName(); + String tableName = getDialect().getTargetTableName(getTableName()); String partitionType = (String) detail.get("PARTITION_TYPE"); String columnNames = (String) detail.get("PARTITION_COLUMN_NAMES"); String statement = getDialect().getDefinePartitionTableStatement(catalogName, schemaName, tableName, diff --git a/src/main/java/com/github/sqljam/impexp/PartitionTableMetaData.java b/src/main/java/com/github/sqljam/impexp/PartitionTableMetaData.java index a88c676..acd6676 100644 --- a/src/main/java/com/github/sqljam/impexp/PartitionTableMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/PartitionTableMetaData.java @@ -20,8 +20,8 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; -import java.util.stream.Collectors; import java.util.Map; +import java.util.stream.Collectors; /** * @Description: PartitionTableMetaData is a partition of a partitioned table of PostgreSQL @@ -114,9 +114,10 @@ public void accept(MetaDataVisitor visitor) throws SQLException { public String[] getStatements() throws SQLException { String catalogName = getCatalogName(); String schemaName = getSchemaName(); - String tableName = getTableName(); - String inheritedTableName = (String) detail.get("INHERITED_TABLE_NAME"); - String statement = getDialect().getCreatePartitionTableStatement(catalogName, schemaName, tableName, + Dialect dialect = getDialect(); + String tableName = dialect.getTargetTableName(getTableName()); + String inheritedTableName = dialect.getTargetTableName((String) detail.get("INHERITED_TABLE_NAME")); + String statement = dialect.getCreatePartitionTableStatement(catalogName, schemaName, tableName, inheritedTableName); return new String[]{statement}; } diff --git a/src/main/java/com/github/sqljam/impexp/PrimaryKeyMetaData.java b/src/main/java/com/github/sqljam/impexp/PrimaryKeyMetaData.java index 5cb87fd..c15ea04 100644 --- a/src/main/java/com/github/sqljam/impexp/PrimaryKeyMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/PrimaryKeyMetaData.java @@ -89,8 +89,9 @@ public void accept(MetaDataVisitor visitor) throws SQLException { public String[] getStatements() throws SQLException { String catalogName = getCatalogName(); String schemaName = getSchemaName(); - String tableName = getTableName(); - String pkName = (String) detail.get("PK_NAME"); + String tableName = getDialect().getTargetTableName(getTableName()); + // Names of copied keys are derived from the target table + String pkName = getDialect().isTableRenamed() ? null : (String) detail.get("PK_NAME"); String statement = getDialect().getCreatePrimaryKeyStatement(catalogName, schemaName, tableName, columnName, pkName); return new String[]{statement}; diff --git a/src/main/java/com/github/sqljam/impexp/SchemaMetaData.java b/src/main/java/com/github/sqljam/impexp/SchemaMetaData.java index 5886891..14074a6 100644 --- a/src/main/java/com/github/sqljam/impexp/SchemaMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/SchemaMetaData.java @@ -19,9 +19,16 @@ import java.sql.SQLException; 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.Map; import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; @@ -61,17 +68,24 @@ public void accept(MetaDataVisitor visitor) throws SQLException { Exporter.ExportConfiguration configuration = visitor.getConfiguration(); List> tableInfos = getMetaDataOperations().getTableInfos(getMetaData(), getCatalogName(), schemaName); + Set includedTableNames = new HashSet<>(); for (Map tableInfo : tableInfos) { String tableName = (String) tableInfo.get("TABLE_NAME"); - if (StringUtils.isNotBlank(configuration.getIncludedTableNamePattern()) && - !tableName.matches(configuration.getIncludedTableNamePattern())) { - continue; - } - if (ArrayUtils.isEmpty(configuration.getIncludedTableNames()) - || ArrayUtils.contains(configuration.getIncludedTableNames(), tableName)) { + if (isIncluded(configuration, tableName)) { + includedTableNames.add(tableName); + } + } + for (Map tableInfo : tableInfos) { + String tableName = (String) tableInfo.get("TABLE_NAME"); + // Partitions of included partitioned tables are included (PostgreSQL), rows are stored in them + if (includedTableNames.contains(tableName) || (Boolean.TRUE.equals(tableInfo.get("IS_PARTITION_TABLE")) + && includedTableNames.contains((String) tableInfo.get("INHERITED_TABLE_NAME")))) { tableMetaDatas.add(new TableMetaData(tableName, tableInfo, this)); } } + if (configuration.getDialect() != null && configuration.getDialect().isForeignKeyOrderRequired()) { + sortByForeignKeys(); + } for (TableMetaData tableMetaData : tableMetaDatas) { tableMetaData.accept(visitor); } @@ -94,6 +108,15 @@ public void accept(MetaDataVisitor visitor) throws SQLException { } } + private static boolean isIncluded(Exporter.ExportConfiguration configuration, String tableName) { + if (StringUtils.isNotBlank(configuration.getIncludedTableNamePattern()) + && !tableName.matches(configuration.getIncludedTableNamePattern())) { + return false; + } + return ArrayUtils.isEmpty(configuration.getIncludedTableNames()) + || ArrayUtils.contains(configuration.getIncludedTableNames(), tableName); + } + @Override public String getCatalogName() { return tiedMetaData.getCatalogName(); @@ -116,6 +139,51 @@ public T unwrap(Class clz) { /** * Whether the sequence is used by the exported tables, by column default values or triggers */ + /** + * Referenced tables are placed before referencing tables, the original order is kept otherwise. Tables in a + * reference cycle keep their original order. + */ + private void sortByForeignKeys() throws SQLException { + Map tables = new LinkedHashMap<>(); + for (TableMetaData tableMetaData : tableMetaDatas) { + tables.put(tableMetaData.getTableName(), tableMetaData); + } + Map> dependencies = new HashMap<>(); + for (String tableName : tables.keySet()) { + Set referenced = new LinkedHashSet<>(); + for (Map keyInfo : getMetaDataOperations().getImportedKeyInfos(getMetaData(), + getCatalogName(), schemaName, tableName)) { + String refTableName = (String) keyInfo.get("PKTABLE_NAME"); + if (refTableName != null && !refTableName.equals(tableName) && tables.containsKey(refTableName)) { + referenced.add(refTableName); + } + } + dependencies.put(tableName, referenced); + } + List sorted = new ArrayList<>(); + Set visited = new HashSet<>(); + Set visiting = new HashSet<>(); + for (String tableName : tables.keySet()) { + addInOrder(tableName, tables, dependencies, visited, visiting, sorted); + } + tableMetaDatas.clear(); + tableMetaDatas.addAll(sorted); + } + + private static void addInOrder(String tableName, Map tables, + Map> dependencies, Set visited, Set visiting, + List sorted) { + if (visited.contains(tableName) || !visiting.add(tableName)) { + return; + } + for (String refTableName : dependencies.getOrDefault(tableName, Collections.emptySet())) { + addInOrder(refTableName, tables, dependencies, visited, visiting, sorted); + } + visiting.remove(tableName); + visited.add(tableName); + sorted.add(tables.get(tableName)); + } + private boolean isSequenceReferenced(String sequenceName, Map sequenceInfo) { String[] referencedTableNames = (String[]) sequenceInfo.get("REFERENCED_TABLE_NAMES"); if (ArrayUtils.isNotEmpty(referencedTableNames)) { @@ -128,7 +196,7 @@ private boolean isSequenceReferenced(String sequenceName, Map se String lowerSequenceName = sequenceName.toLowerCase(); return tableMetaDatas.stream().flatMap(md -> md.getColumnMetaDatas().stream()) .map(md -> StringUtils.defaultString((String) md.getDetail().get("COLUMN_DEF")).toLowerCase()) - .anyMatch(def -> def.matches("(?s).*\\b" + java.util.regex.Pattern.quote(lowerSequenceName) + .anyMatch(def -> def.matches("(?s).*\\b" + Pattern.quote(lowerSequenceName) + "\\b.*")); } diff --git a/src/main/java/com/github/sqljam/impexp/ScriptExportHandler.java b/src/main/java/com/github/sqljam/impexp/ScriptExportHandler.java index 10f72d4..ef92465 100644 --- a/src/main/java/com/github/sqljam/impexp/ScriptExportHandler.java +++ b/src/main/java/com/github/sqljam/impexp/ScriptExportHandler.java @@ -19,11 +19,14 @@ import java.io.FileOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.sql.DatabaseMetaData; +import java.sql.Types; import java.util.ArrayList; import java.util.Arrays; 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; @@ -34,10 +37,10 @@ import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; -import com.github.sqljam.jdbc.ConnectionFactory; -import com.github.sqljam.page.EachPage; import com.github.sqljam.impexp.DdlScripter.Catalog; import com.github.sqljam.impexp.DdlScripter.Schema; +import com.github.sqljam.jdbc.ConnectionFactory; +import com.github.sqljam.page.EachPage; import lombok.extern.slf4j.Slf4j; /** @@ -125,6 +128,15 @@ File nextFile() { */ private List tablePrologue = new ArrayList<>(); + /** + * Foreign keys are always written into constraints.sql, even without rows (Parquet packages load rows later) + */ + private boolean constraintsSeparated; + + public void setConstraintsSeparated(boolean constraintsSeparated) { + this.constraintsSeparated = constraintsSeparated; + } + public void setLobSeparated(boolean lobSeparated) { this.lobSeparated = lobSeparated; } @@ -198,7 +210,7 @@ public void finish(boolean successful) throws Exception { manifest.getOptions().put("lobSeparated", lobSeparated); // Files in the order of importing - Set dirs = new java.util.LinkedHashSet<>(); + Set dirs = new LinkedHashSet<>(); writtenFiles.forEach(file -> dirs.add(file.getParentFile().getName().equals(DATA_DIR_NAME) ? file.getParentFile().getParentFile() : file.getParentFile())); for (File dir : dirs) { @@ -255,7 +267,7 @@ public void prepare(ServerMetaData serverMetaData) throws Exception { this.dialect = serverMetaData.getDialect(); manifest = new ExportManifest(); manifest.setExportMode(exportMode); - java.sql.DatabaseMetaData databaseMetaData = serverMetaData.getMetaData(); + DatabaseMetaData databaseMetaData = serverMetaData.getMetaData(); ExportManifest.Database source = manifest.getSource(); source.setDbType(configuration != null ? configuration.getDbType() : null); source.setProduct(databaseMetaData.getDatabaseProductName() + " " @@ -311,7 +323,7 @@ private LobManifestWriter getLobManifestWriter(String catalogName) throws IOExce return writer; } - private static final java.util.Set LOB_TYPE_NAMES = new java.util.HashSet<>(Arrays.asList("text", + private static final Set LOB_TYPE_NAMES = new HashSet<>(Arrays.asList("text", "tinytext", "mediumtext", "longtext", "ntext", "clob", "nclob", "blob", "tinyblob", "mediumblob", "longblob", "bytea", "image", "long", "long raw", "character large object", "binary large object", "national character large object")); @@ -338,9 +350,9 @@ static boolean isLobColumn(ColumnMetaData columnMetaData) { case "nvarchar": case "varbinary": // varchar without length (PostgreSQL), varchar(max) of SQL Server - return large && (dataType == java.sql.Types.VARCHAR || dataType == java.sql.Types.NVARCHAR - || dataType == java.sql.Types.VARBINARY || dataType == java.sql.Types.LONGVARCHAR - || dataType == java.sql.Types.LONGNVARCHAR || dataType == java.sql.Types.LONGVARBINARY); + return large && (dataType == Types.VARCHAR || dataType == Types.NVARCHAR + || dataType == Types.VARBINARY || dataType == Types.LONGVARCHAR + || dataType == Types.LONGNVARCHAR || dataType == Types.LONGVARBINARY); default: return false; } @@ -414,7 +426,7 @@ public void exportConstraints(DdlScripter ddlScripter) throws Exception { sqlLines.add(1, String.format("%s Create foreign keys", commentPrefix)); File catalogDir = getCatalogDir(catalogName); File outputFile; - if (!dataCatalogNames.contains(StringUtils.defaultString(catalogName))) { + if (!constraintsSeparated && !dataCatalogNames.contains(StringUtils.defaultString(catalogName))) { outputFile = new File(catalogDir, SCHEMA_FILE_NAME); } else { // Foreign keys are created after rows inserted and LOBs restored @@ -472,7 +484,8 @@ public void exportData(String catalogName, List keyColumnNames = tableMetaData.getPrimaryKeyColumnNames(); Set lobColumnNames = new HashSet<>(); // Rows are located by primary keys when restoring LOBs - if (lobSeparated && !keyColumnNames.isEmpty() && template.keySet().containsAll(keyColumnNames)) { + if (lobSeparated && dialect.isLobSeparationSupported() && !keyColumnNames.isEmpty() + && template.keySet().containsAll(keyColumnNames)) { tableMetaData.getColumnMetaDatas().stream().filter(ScriptExportHandler::isLobColumn) .map(ColumnMetaData::getColumnName).filter(template::containsKey).forEach(lobColumnNames::add); } diff --git a/src/main/java/com/github/sqljam/impexp/ScriptExporter.java b/src/main/java/com/github/sqljam/impexp/ScriptExporter.java index 7bdd994..5688d14 100644 --- a/src/main/java/com/github/sqljam/impexp/ScriptExporter.java +++ b/src/main/java/com/github/sqljam/impexp/ScriptExporter.java @@ -104,6 +104,13 @@ public void setLobSeparated(boolean lobSeparated) { scriptExportHandler.setLobSeparated(lobSeparated); } + /** + * Writes foreign keys into constraints.sql even if rows are not exported (default: into schema.sql) + */ + public void setConstraintsSeparated(boolean constraintsSeparated) { + scriptExportHandler.setConstraintsSeparated(constraintsSeparated); + } + public List getWrittenFiles() { return scriptExportHandler.getWrittenFiles(); } diff --git a/src/main/java/com/github/sqljam/impexp/ScriptImporter.java b/src/main/java/com/github/sqljam/impexp/ScriptImporter.java index 856e02b..25010e0 100644 --- a/src/main/java/com/github/sqljam/impexp/ScriptImporter.java +++ b/src/main/java/com/github/sqljam/impexp/ScriptImporter.java @@ -23,6 +23,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; +import java.sql.Types; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; @@ -38,6 +39,7 @@ import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.sqljam.jdbc.JdbcUtils; import lombok.extern.slf4j.Slf4j; /** @@ -49,71 +51,31 @@ * @Version 1.0.0 */ @Slf4j -public class ScriptImporter { +public class ScriptImporter extends AbstractFileImporter { private static final Pattern PART_FILE = Pattern.compile("^(.*?)(?:_(\\d+))?\\.sql$"); - private final Connection connection; - private final DbType dbType; - private boolean stopOnError = true; - private int batchSize = SqlScriptRunner.DEFAULT_BATCH_SIZE; - private ExportListener exportListener = ExportListener.NONE; - private int executedCount; - private int failedCount; private int lobCount; - /** - * Overall progress in bytes of sql files and LOB files - */ - private long totalBytes; - private long completedBytes; public ScriptImporter(Connection connection, DbType dbType) { - this.connection = connection; - this.dbType = dbType; - } - - public void setStopOnError(boolean stopOnError) { - this.stopOnError = stopOnError; - } - - /** - * INSERT statements per batch, 1 or less executes statement by statement - */ - public void setBatchSize(int batchSize) { - this.batchSize = batchSize; - } - - public void setExportListener(ExportListener exportListener) { - this.exportListener = exportListener != null ? exportListener : ExportListener.NONE; - } - - public int getExecutedCount() { - return executedCount; - } - - public int getFailedCount() { - return failedCount; + super(connection, dbType); } + @Override public int getLobCount() { return lobCount; } /** - * Imports an export directory. Files are executed in the order of manifest.json if it exists, otherwise they are - * discovered by the directory layout. + * Directories without manifest.json are discovered by the layout: schema.sql, data files, lob/ and + * constraints.sql of the directory or its sub directories (catalogs) */ - public void importDirectory(File dir) throws IOException, SQLException { - ExportManifest manifest = ExportManifest.read(dir); - if (manifest != null) { - boolean successful = false; - try { - importManifest(dir, manifest); - successful = true; - } finally { - exportListener.onEnd(successful); - } - return; + @Override + protected void importDirectoryWithoutManifest(File dir) throws IOException, SQLException { + File[] parquetFiles = new File(dir, "data").listFiles((parent, name) -> name.endsWith( + ParquetExporter.PARQUET_EXTENSION)); + if (parquetFiles != null && parquetFiles.length > 0) { + throw new ImpExpException("Parquet files without manifest are imported by ParquetImporter: " + dir); } List units = new ArrayList<>(); if (isScriptDirectory(dir)) { @@ -147,62 +109,24 @@ public void importDirectory(File dir) throws IOException, SQLException { } /** - * Validates the manifest and executes its files in order + * LOB files are counted by the size of their directory */ - private static long sizeOf(File file) { - if (file.isDirectory()) { - File[] files = file.listFiles(); - long size = 0; - if (files != null) { - for (File child : files) { - size += sizeOf(child); - } - } - return size; - } - return file.exists() ? file.length() : 0; - } - - private void startProgress(long totalBytes) { - this.totalBytes = Math.max(totalBytes, 1); - this.completedBytes = 0; - exportListener.onProgress(0, this.totalBytes); - } - - private void completeBytes(long bytes) { - completedBytes += bytes; - exportListener.onProgress(Math.min(completedBytes, totalBytes), totalBytes); - } - - private void importManifest(File dir, ExportManifest manifest) throws IOException, SQLException { - if (!ExportManifest.FORMAT.equals(manifest.getFormat())) { - throw new ImpExpException("Unknown export format: " + manifest.getFormat()); - } - if (manifest.getStatus() != ExportManifest.Status.COMPLETED) { - throw new ImpExpException("The export is not completed: " + manifest.getStatus()); - } - DbType targetDbType = manifest.getTarget().getDbType(); - if (dbType != null && targetDbType != null && dbType != targetDbType) { - throw new ImpExpException(String.format("The scripts are generated for %s, but the target database is %s", - targetDbType.getDisplayName(), dbType.getDisplayName())); - } - for (ExportManifest.FileEntry fileEntry : manifest.getFiles()) { - File file = new File(dir, fileEntry.getPath()); - if (!file.exists()) { - throw new ImpExpException("File not found: " + fileEntry.getPath()); - } - if (StringUtils.isNotBlank(fileEntry.getSha256()) && !fileEntry.getSha256().equals( - ExportManifest.sha256(file))) { - throw new ImpExpException("File is modified or corrupted (checksum mismatch): " + fileEntry.getPath()); - } - } + @Override + protected long getManifestBytes(File dir, ExportManifest manifest) { long bytes = 0; for (ExportManifest.FileEntry fileEntry : manifest.getFiles()) { File file = new File(dir, fileEntry.getPath()); bytes += fileEntry.getType() == ExportManifest.FileType.LOB_MANIFEST ? sizeOf(new File(file.getParentFile(), LobManifestWriter.LOB_DIR_NAME)) : file.length(); } - startProgress(bytes); + return bytes; + } + + @Override + protected void importManifest(File dir, ExportManifest manifest) throws IOException, SQLException { + if (manifest.getDataFormat() == DataFormat.PARQUET) { + throw new ImpExpException("Packages of Parquet files are imported by ParquetImporter"); + } for (ExportManifest.FileEntry fileEntry : manifest.getFiles()) { File file = new File(dir, fileEntry.getPath()); if (fileEntry.getType() == ExportManifest.FileType.LOB_MANIFEST) { @@ -262,40 +186,13 @@ private static String[] partOf(File file) { return new String[]{file.getName(), ""}; } - private void runScript(File file) throws IOException, SQLException { - if (!file.exists()) { - return; - } - exportListener.onMessage("Executing script: " + file.getName()); - SqlScriptRunner runner = new SqlScriptRunner(connection); - runner.setStopOnError(stopOnError); - runner.setBatchSize(batchSize); - long fileBytes = file.length(); - runner.setExportListener(new ForwardingListener(exportListener) { - - @Override - public void onProgress(long processed, long total) { - // Progress of statements in the file is converted to bytes - long bytes = total > 0 ? fileBytes * processed / total : 0; - exportListener.onProgress(Math.min(completedBytes + bytes, totalBytes), totalBytes); - } - }); - try { - runner.runScript(file); - } finally { - executedCount += runner.getExecutedCount(); - failedCount += runner.getFailedCount(); - completeBytes(fileBytes); - } - } - /** * Restores LOB values by primary keys: UPDATE table SET column = ? WHERE key = ? */ private void restoreLobs(File dir, File manifest) throws IOException, SQLException { ObjectMapper objectMapper = new ObjectMapper(); boolean autoCommit = connection.getAutoCommit(); - connection.setAutoCommit(false); + boolean transactional = JdbcUtils.beginTransaction(connection); try (JsonParser parser = objectMapper.getFactory().createParser(manifest)) { Dialect dialect = createDialect(objectMapper, manifest); while (parser.nextToken() != null) { @@ -306,12 +203,18 @@ private void restoreLobs(File dir, File manifest) throws IOException, SQLExcepti } } } - connection.commit(); + if (transactional) { + connection.commit(); + } } catch (SQLException | IOException | RuntimeException e) { - connection.rollback(); + if (transactional) { + connection.rollback(); + } throw e; } finally { - connection.setAutoCommit(autoCommit); + if (transactional) { + connection.setAutoCommit(autoCommit); + } } exportListener.onMessage(String.format("%d LOB values restored", lobCount)); } @@ -397,7 +300,7 @@ private static void bindKey(PreparedStatement ps, JsonNode key, List key for (String keyName : keyNames) { JsonNode value = key.get(keyName); if (value == null || value.isNull()) { - ps.setNull(index++, java.sql.Types.VARCHAR); + ps.setNull(index++, Types.VARCHAR); } else if (value.isIntegralNumber()) { ps.setLong(index++, value.asLong()); } else if (value.isNumber()) { @@ -409,32 +312,4 @@ private static void bindKey(PreparedStatement ps, JsonNode key, List key } } } - - /** - * Forwards errors of scripts, the end of each script is not the end of the import - */ - private static class ForwardingListener implements ExportListener { - - - private final ExportListener delegate; - - ForwardingListener(ExportListener delegate) { - this.delegate = delegate; - } - - @Override - public void onMessage(String message) { - delegate.onMessage(message); - } - - @Override - public void onError(String message, Throwable e) { - delegate.onError(message, e); - } - - @Override - public boolean isCancelled() { - return delegate.isCancelled(); - } - } } diff --git a/src/main/java/com/github/sqljam/impexp/SequenceMetaData.java b/src/main/java/com/github/sqljam/impexp/SequenceMetaData.java index a849267..63b2de5 100644 --- a/src/main/java/com/github/sqljam/impexp/SequenceMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/SequenceMetaData.java @@ -56,6 +56,10 @@ public void accept(MetaDataVisitor visitor) throws SQLException { @Override public String[] getStatements() throws SQLException { Dialect dialect = getDialect(); + if (dialect.isTableRenamed()) { + // Copies of tables use the sequences of the source tables + return null; + } String catalogName = getCatalogName(); String schemaName = getSchemaName(); long startValue = getLong("START_VALUE", 1L); diff --git a/src/main/java/com/github/sqljam/impexp/SqlScriptRunner.java b/src/main/java/com/github/sqljam/impexp/SqlScriptRunner.java index 60de517..f86663f 100644 --- a/src/main/java/com/github/sqljam/impexp/SqlScriptRunner.java +++ b/src/main/java/com/github/sqljam/impexp/SqlScriptRunner.java @@ -32,6 +32,7 @@ import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.jdbc.JdbcUtils; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -75,6 +76,11 @@ public class SqlScriptRunner { private int batchSize = DEFAULT_BATCH_SIZE; private Boolean oracle; private ExportListener exportListener = ExportListener.NONE; + private Statement statement; + /** + * Whether statements run in a transaction, false if the database has no transactions (ClickHouse) + */ + private boolean transactional; @Getter private int executedCount; @@ -116,8 +122,9 @@ public void runScript(String script) throws IOException, SQLException { public void runScript(Reader reader) throws IOException, SQLException { boolean autoCommit = connection.getAutoCommit(); - connection.setAutoCommit(false); - try (Statement statement = connection.createStatement()) { + transactional = JdbcUtils.beginTransaction(connection); + statement = connection.createStatement(); + try { List statements = parseStatements(reader); int total = statements.size(); List batch = new ArrayList<>(); @@ -132,41 +139,78 @@ public void runScript(Reader reader) throws IOException, SQLException { if (batchSize > 1 && isBatchable(sql)) { if (batch.isEmpty() && uncommitted > 0) { // A batch is rolled back alone when it fails - connection.commit(); + commit(); uncommitted = 0; } batch.add(sql); if (batch.size() >= batchSize) { - executeBatch(statement, batch); + executeBatch(batch); exportListener.onProgress(index, total); } continue; } if (!batch.isEmpty()) { - executeBatch(statement, batch); + executeBatch(batch); } - if (execute(statement, sql)) { + if (execute(sql)) { exportListener.onProgress(index, total); if (++uncommitted >= batchCommitSize) { - connection.commit(); + commit(); uncommitted = 0; exportListener.onMessage(String.format("%d statements executed", executedCount)); } } } if (!batch.isEmpty()) { - executeBatch(statement, batch); + executeBatch(batch); } - connection.commit(); + commit(); exportListener.onProgress(total, total); } finally { + closeStatement(); // A closed connection would hide the original exception - if (!connection.isClosed()) { + if (transactional && !connection.isClosed()) { connection.setAutoCommit(autoCommit); } } } + /** + * Commits executed statements, they are committed already if the database has no transactions + */ + private void commit() throws SQLException { + if (transactional) { + connection.commit(); + } + } + + private void rollback() throws SQLException { + if (transactional) { + connection.rollback(); + } + } + + /** + * The statement in use. DuckDB closes a statement when its sql fails, a new one is created then. + */ + private Statement statement() throws SQLException { + if (statement == null || statement.isClosed()) { + statement = connection.createStatement(); + } + return statement; + } + + private void closeStatement() { + if (statement != null) { + try { + statement.close(); + } catch (SQLException e) { + // Closed already + } + statement = null; + } + } + static boolean isBatchable(String sql) { return INSERT_STATEMENT.matcher(sql).matches(); } @@ -174,33 +218,33 @@ static boolean isBatchable(String sql) { /** * Executes and commits a batch. A failed batch is rolled back and executed statement by statement */ - private void executeBatch(Statement statement, List batch) throws SQLException { + private void executeBatch(List batch) throws SQLException { try { if (isOracle()) { for (String sql : getOracleBlockStatements(batch)) { - statement.execute(sql); + statement().execute(sql); } } else { for (String sql : batch) { - statement.addBatch(sql); + statement().addBatch(sql); } - statement.executeBatch(); + statement().executeBatch(); } - connection.commit(); + commit(); executedCount += batch.size(); } catch (SQLException e) { if (connection.isClosed()) { throw e; } - statement.clearBatch(); - connection.rollback(); + statement().clearBatch(); + rollback(); if (log.isDebugEnabled()) { log.debug("Batch failed, statements are executed one by one: {}", e.getMessage()); } for (String sql : batch) { - execute(statement, sql); + execute(sql); } - connection.commit(); + commit(); } finally { batch.clear(); } @@ -210,10 +254,10 @@ private void executeBatch(Statement statement, List batch) throws SQLExc * Executes a statement, returns false if it fails and errors are ignored. When errors are ignored, a savepoint * keeps the transaction usable after a failure (PostgreSQL aborts the whole transaction otherwise) */ - private boolean execute(Statement statement, String sql) throws SQLException { - Savepoint savepoint = stopOnError ? null : connection.setSavepoint(); + private boolean execute(String sql) throws SQLException { + Savepoint savepoint = stopOnError || !transactional ? null : connection.setSavepoint(); try { - statement.execute(sql); + statement().execute(sql); executedCount++; releaseSavepoint(savepoint); return true; @@ -226,10 +270,12 @@ private boolean execute(Statement statement, String sql) throws SQLException { } exportListener.onError(message, e); if (stopOnError) { - connection.rollback(); + rollback(); throw e; } - connection.rollback(savepoint); + if (savepoint != null) { + connection.rollback(savepoint); + } return false; } } diff --git a/src/main/java/com/github/sqljam/impexp/StringHelper.java b/src/main/java/com/github/sqljam/impexp/StringHelper.java index d8ab47c..7d332ef 100644 --- a/src/main/java/com/github/sqljam/impexp/StringHelper.java +++ b/src/main/java/com/github/sqljam/impexp/StringHelper.java @@ -15,8 +15,8 @@ */ package com.github.sqljam.impexp; -import lombok.experimental.UtilityClass; import org.apache.commons.lang3.StringUtils; +import lombok.experimental.UtilityClass; /** * @Description: StringHelper pads and repeats strings for pretty scripts diff --git a/src/main/java/com/github/sqljam/impexp/TableMetaData.java b/src/main/java/com/github/sqljam/impexp/TableMetaData.java index 4c32d8a..5cbce1e 100644 --- a/src/main/java/com/github/sqljam/impexp/TableMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/TableMetaData.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Predicate; import java.util.stream.Collectors; import org.apache.commons.collections4.CollectionUtils; @@ -69,6 +70,10 @@ public void accept(MetaDataVisitor visitor) throws SQLException { log.info("Begin to process table: {}", tableName); } Exporter.ExportConfiguration configuration = visitor.getConfiguration(); + TableQuery query = configuration != null ? configuration.getTableQuery(tableName) : null; + // Selected columns of a query, all columns if none + Predicate selected = columnName -> query == null || query.getColumns().isEmpty() + || query.getColumns().contains(columnName); final boolean partitioned = isPartitioned(); DatabaseMetaData databaseMetaData = getMetaData(); List> infoList = null; @@ -80,10 +85,15 @@ public void accept(MetaDataVisitor visitor) throws SQLException { List> pkInfos = infoList.stream() .sorted(Comparator.comparingInt(info -> getInt(info, "KEY_SEQ"))) .collect(Collectors.toList()); - pkInfos.forEach(info -> primaryKeyColumnNames.add((String) info.get("COLUMN_NAME"))); - // Composite primary key is one constraint - String columnNames = StringUtils.join(primaryKeyColumnNames, ","); - primaryKeyMetaDatas.add(new PrimaryKeyMetaData(columnNames, pkInfos.get(0), this)); + List keyColumnNames = pkInfos.stream().map(info -> (String) info.get("COLUMN_NAME")) + .collect(Collectors.toList()); + // A primary key is kept if all of its columns are selected + if (keyColumnNames.stream().allMatch(selected)) { + primaryKeyColumnNames.addAll(keyColumnNames); + // Composite primary key is one constraint + String columnNames = StringUtils.join(primaryKeyColumnNames, ","); + primaryKeyMetaDatas.add(new PrimaryKeyMetaData(columnNames, pkInfos.get(0), this)); + } } } @@ -91,7 +101,9 @@ public void accept(MetaDataVisitor visitor) throws SQLException { getCatalogName(), getSchemaName(), tableName); for (Map columnInfo : infoList) { String columnName = (String) columnInfo.get("COLUMN_NAME"); - columnMetaDatas.add(new ColumnMetaData(columnName, columnInfo, this)); + if (selected.test(columnName)) { + columnMetaDatas.add(new ColumnMetaData(columnName, columnInfo, this)); + } } for (ColumnMetaData columnMetaData : columnMetaDatas) { columnMetaData.accept(visitor); @@ -112,6 +124,10 @@ public void accept(MetaDataVisitor visitor) throws SQLException { List subList = MapUtils.getOrCreate(indexMetaDatas, indexName, ArrayList::new); subList.add(new IndexMetaData(indexName, columnName, indexInfo, this)); } + // Indexes are kept if all of their columns are selected + indexMetaDatas.values().removeIf(indexes -> !indexes.stream().map(IndexMetaData::getColumnName) + .allMatch(selected)); + removeDuplicateIndexes(); for (Map.Entry> entry : indexMetaDatas.entrySet()) { if (entry.getValue().size() == 1) { entry.getValue().get(0).accept(visitor); @@ -137,6 +153,9 @@ public void accept(MetaDataVisitor visitor) throws SQLException { } MapUtils.getOrCreate(fkGroups, fkName, ArrayList::new).add(fkInfo); } + // Foreign keys are kept if all of their columns are selected + fkGroups.values().removeIf(fkInfos -> !fkInfos.stream().map(info -> (String) info.get("FKCOLUMN_NAME")) + .allMatch(selected)); for (Map.Entry>> entry : fkGroups.entrySet()) { List> fkInfos = entry.getValue().stream() .sorted(Comparator.comparingInt(info -> getInt(info, "KEY_SEQ"))) @@ -220,7 +239,7 @@ public MetaDataOperations getMetaDataOperations() { public String[] getStatements() throws SQLException { String catalogName = getCatalogName(); String schemaName = getSchemaName(); - String tableName = getTableName(); + String tableName = getDialect().getTargetTableName(getTableName()); String statement = getDialect().getCreateTableStatement(catalogName, schemaName, tableName); return new String[]{statement}; } @@ -241,6 +260,35 @@ public List getPrimaryKeyColumnNames() { return primaryKeyColumnNames; } + /** + * Indexes of the same columns, e.g. an index of H2 for a foreign key next to an index created by the user, would + * get the same name in the target database, which also refuses a second index of the same columns. One index of + * the columns is kept, a unique index before others. + */ + private void removeDuplicateIndexes() { + Map, String> indexNames = new LinkedHashMap<>(); + for (Map.Entry> entry : new ArrayList<>(indexMetaDatas.entrySet())) { + if (entry.getValue().isEmpty()) { + continue; + } + List columnNames = entry.getValue().stream().map(IndexMetaData::getColumnName) + .collect(Collectors.toList()); + String existing = indexNames.get(columnNames); + if (existing == null) { + indexNames.put(columnNames, entry.getKey()); + } else if (isUniqueIndex(entry.getValue()) && !isUniqueIndex(indexMetaDatas.get(existing))) { + indexMetaDatas.remove(existing); + indexNames.put(columnNames, entry.getKey()); + } else { + indexMetaDatas.remove(entry.getKey()); + } + } + } + + private static boolean isUniqueIndex(List indexes) { + return IndexMetaData.isUnique(indexes.get(0).getDetail()); + } + public List getIndexMetaDatas() { List total = new ArrayList<>(); for (List subList : indexMetaDatas.values()) { diff --git a/src/main/java/com/github/sqljam/impexp/TableQuery.java b/src/main/java/com/github/sqljam/impexp/TableQuery.java new file mode 100644 index 0000000..465430c --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/TableQuery.java @@ -0,0 +1,161 @@ +/* + * 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.util.ArrayList; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +/** + * @Description: TableQuery narrows the rows of a table: selected columns, a WHERE condition, GROUP BY columns and + * ORDER BY. Conditions are sql of the source database. Exports use the columns, the condition and the + * order, grouped rows are for viewing. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public class TableQuery { + + /** + * Name of the count column of grouped rows + */ + public static final String COUNT_COLUMN = "row_count"; + + private List columns = new ArrayList<>(); + private String where; + private List groupBy = new ArrayList<>(); + private String orderBy; + + public TableQuery() { + } + + public TableQuery(List columns, String where, List groupBy, String orderBy) { + setColumns(columns); + setWhere(where); + setGroupBy(groupBy); + setOrderBy(orderBy); + } + + /** + * Selected columns, all columns if empty + */ + public List getColumns() { + return columns; + } + + public void setColumns(List columns) { + this.columns = columns != null ? new ArrayList<>(columns) : new ArrayList<>(); + } + + /** + * Condition without the WHERE keyword, e.g. amount > 100 AND status = 'PAID' + */ + public String getWhere() { + return where; + } + + public void setWhere(String where) { + this.where = trimClause(where, "where"); + } + + /** + * Grouped rows have the group columns and the count of rows + */ + public List getGroupBy() { + return groupBy; + } + + public void setGroupBy(List groupBy) { + this.groupBy = groupBy != null ? new ArrayList<>(groupBy) : new ArrayList<>(); + } + + /** + * Order without the ORDER BY keywords, e.g. created_at DESC, id + */ + public String getOrderBy() { + return orderBy; + } + + public void setOrderBy(String orderBy) { + this.orderBy = trimClause(orderBy, "order\\s+by"); + } + + public boolean isGrouped() { + return !groupBy.isEmpty(); + } + + /** + * Whether the query narrows nothing, all rows and columns in the default order + */ + public boolean isEmpty() { + return columns.isEmpty() && where == null && groupBy.isEmpty() && orderBy == null; + } + + /** + * A clause typed by the user, the keyword and trailing semicolons are removed. Semicolons inside the clause + * are rejected, a clause is not a statement. + */ + static String trimClause(String clause, String keyword) { + String text = StringUtils.trimToNull(clause); + if (text == null) { + return null; + } + text = text.replaceFirst("(?is)^" + keyword + "\\s+", "").replaceAll(";+\\s*$", "").trim(); + if (hasStatementSeparator(text)) { + throw new IllegalArgumentException("A clause must not contain ';': " + text); + } + return text.isEmpty() ? null : text; + } + + /** + * Semicolons outside quoted text and identifiers + */ + private static boolean hasStatementSeparator(String text) { + char quote = 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (quote != 0) { + if (c == quote) { + quote = 0; + } + } else if (c == '\'' || c == '"' || c == '`') { + quote = c; + } else if (c == ';') { + return true; + } + } + return false; + } + + @Override + public String toString() { + StringBuilder text = new StringBuilder(); + if (!columns.isEmpty()) { + text.append("SELECT ").append(String.join(", ", columns)); + } + if (where != null) { + text.append(text.length() > 0 ? " " : "").append("WHERE ").append(where); + } + if (!groupBy.isEmpty()) { + text.append(text.length() > 0 ? " " : "").append("GROUP BY ").append(String.join(", ", groupBy)); + } + if (orderBy != null) { + text.append(text.length() > 0 ? " " : "").append("ORDER BY ").append(orderBy); + } + return text.toString(); + } +} diff --git a/src/main/java/com/github/sqljam/impexp/db/ClickHouseDialect.java b/src/main/java/com/github/sqljam/impexp/db/ClickHouseDialect.java new file mode 100644 index 0000000..e59906e --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/db/ClickHouseDialect.java @@ -0,0 +1,534 @@ +/* + * 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.db; + +import java.sql.Date; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Time; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.ZoneId; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.Dialect; +import com.github.sqljam.impexp.IdentifierCase; +import com.github.sqljam.impexp.StringHelper; + +/** + * @Description: ClickHouseDialect generates ddl/dml of ClickHouse: tables of the MergeTree engine sorted by the + * primary key, Nullable types for nullable columns, comments in column definitions. ClickHouse has no + * transactions, identity columns, sequences, foreign keys and unique indexes, they are not created. + * Temporal values are bound as text, so that their wall clock time is kept whatever the time zones of + * the client and the server are. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public class ClickHouseDialect extends Dialect { + + /** + * Max precision of Decimal + */ + public static final int MAX_DECIMAL_PRECISION = 76; + + /** + * Engine of created tables + */ + public static final String DEFAULT_ENGINE = "MergeTree"; + + private static final Pattern HEX = Pattern.compile("^(?:[0-9A-F]{2})*$"); + + /** + * Labels of string columns selected by hex + */ + private final Set hexColumns = ConcurrentHashMap.newKeySet(); + + public ClickHouseDialect() { + super(); + registerColumnType(Types.BIT, "Bool"); + registerColumnType(Types.BOOLEAN, "Bool"); + registerColumnType(Types.TINYINT, "Int8"); + registerColumnType(Types.SMALLINT, "Int16"); + registerColumnType(Types.INTEGER, "Int32"); + registerColumnType(Types.BIGINT, "Int64"); + // FLOAT of jdbc is a double precision number + registerColumnType(Types.FLOAT, "Float64"); + registerColumnType(Types.REAL, "Float32"); + registerColumnType(Types.DOUBLE, "Float64"); + registerColumnType(Types.NUMERIC, "Decimal($p,$s)"); + registerColumnType(Types.DECIMAL, "Decimal($p,$s)"); + + registerColumnType(Types.DATE, "Date32"); + // Time types of ClickHouse are experimental, times are kept as text + registerColumnType(Types.TIME, "String"); + registerColumnType(Types.TIME_WITH_TIMEZONE, "String"); + registerColumnType(Types.TIMESTAMP, "DateTime64(6)"); + registerColumnType(Types.TIMESTAMP_WITH_TIMEZONE, "DateTime64(6)"); + + // String of ClickHouse is a sequence of bytes, lengths are not declared + registerColumnType(Types.BINARY, "String"); + registerColumnType(Types.VARBINARY, "String"); + registerColumnType(Types.LONGVARBINARY, "String"); + registerColumnType(Types.BLOB, "String"); + registerColumnType(Types.JAVA_OBJECT, "String"); + registerColumnType(Types.CHAR, "String"); + registerColumnType(Types.NCHAR, "String"); + registerColumnType(Types.VARCHAR, "String"); + registerColumnType(Types.NVARCHAR, "String"); + registerColumnType(Types.LONGVARCHAR, "String"); + registerColumnType(Types.LONGNVARCHAR, "String"); + registerColumnType(Types.CLOB, "String"); + registerColumnType(Types.NCLOB, "String"); + registerColumnType(Types.SQLXML, "String"); + registerColumnType(Types.OTHER, "String"); + registerColumnType(Types.ARRAY, "String"); + } + + @Override + public DbType getDbType() { + return DbType.CLICKHOUSE; + } + + @Override + public IdentifierCase getStoredIdentifierCase() { + return IdentifierCase.LOWER; + } + + @Override + public int getMaxIdentifierLength() { + return 255; + } + + @Override + public int getMaxNumericPrecision() { + return MAX_DECIMAL_PRECISION; + } + + /** + * Numerics without precision (or wider than Decimal) are kept as text + */ + @Override + public String getUnboundedNumericTypeName() { + return "String"; + } + + /** + * Arrays are accepted as text + */ + @Override + public boolean isArrayParameterSupported() { + return false; + } + + /** + * Type name of ClickHouse keeps parameters and nested types, e.g. Decimal(12, 2), Array(Int32), Enum8('a' = 1) + */ + @Override + protected String getNativeTypeName(int dataType, String sourceTypeName, int columnSize, int columnScale) { + return sourceTypeName; + } + + @Override + protected String getSpecialTypeName(int dataType, String sourceTypeName, int columnSize, int columnScale) { + String baseTypeName = sourceTypeName.replaceAll("\\(.*\\)", "").replace(" unsigned", "").trim(); + switch (baseTypeName) { + case "uuid": + case "uniqueidentifier": + return "UUID"; + case "bool": + case "boolean": + return "Bool"; + case "year": + return "Int16"; + case "money": + case "smallmoney": + return "Decimal(19,4)"; + case "json": + case "jsonb": + case "xml": + case "xmltype": + case "enum": + case "set": + case "interval": + return "String"; + default: + break; + } + if (baseTypeName.startsWith("_") || baseTypeName.startsWith("interval")) { + // Arrays of PostgreSQL and intervals are migrated as text + return "String"; + } + if (dataType == Types.BIT && columnSize > 1) { + return "Int64"; + } + if (dataType == Types.TIMESTAMP || dataType == Types.TIMESTAMP_WITH_TIMEZONE) { + return "DateTime64(" + Math.min(Math.max(columnScale, 0), 6) + ")"; + } + return null; + } + + /** + * Nullable columns are Nullable types, columns are not null by default. Nested types and LowCardinality types + * can not be inside Nullable. + */ + @Override + public String getColumnStatement(String catalog, String schema, String tableName, String columnName, int dataType, + String typeName, int columnSize, int columnScale, String defaultValue, + boolean nullable, String comment) { + StringBuilder columnDef = new StringBuilder(StringHelper.textLeft(getIdentifier(columnName), 30)); + String columnTypeName = getColumnTypeName(dataType, typeName, columnSize, columnScale); + columnDef.append(StringHelper.textLeft(nullable ? toNullable(columnTypeName) : columnTypeName, 30)); + String value = getDefaultValue(dataType, typeName, defaultValue); + if (StringUtils.isNotBlank(value)) { + columnDef.append(" DEFAULT ").append(value); + } + if (StringUtils.isNotBlank(comment)) { + columnDef.append(" COMMENT ").append(getStringLiteral(comment)); + } + return columnDef.toString().trim(); + } + + @Override + public String getColumnStatement(String catalog, String schema, String tableName, String columnName, int dataType, + String typeName, int columnSize, int columnScale, String defaultValue, + boolean nullable) { + return getColumnStatement(catalog, schema, tableName, columnName, dataType, typeName, columnSize, + columnScale, defaultValue, nullable, null); + } + + /** + * Nullable type of a column type, LowCardinality(String) becomes LowCardinality(Nullable(String)) + */ + static String toNullable(String columnTypeName) { + String baseType = ClickHouseMetaDataOperations.getBaseType(columnTypeName); + if (columnTypeName.startsWith("Nullable(")) { + return columnTypeName; + } + if (columnTypeName.startsWith("LowCardinality(")) { + return "LowCardinality(Nullable(" + columnTypeName.substring("LowCardinality(".length(), + columnTypeName.length() - 1) + "))"; + } + switch (baseType) { + case "array": + case "map": + case "tuple": + case "nested": + case "json": + case "object": + case "variant": + case "dynamic": + return columnTypeName; + default: + return "Nullable(" + columnTypeName + ")"; + } + } + + /** + * Identity columns are normal columns, imported values are kept + */ + @Override + public String getIncrementalColumnStatement(String catalog, String schema, String tableName, String columnName, + int dataType, String typeName, int columnSize, int columnScale, + String defaultValue, boolean nullable) { + return getColumnStatement(catalog, schema, tableName, columnName, dataType, typeName, columnSize, + columnScale, null, false, null); + } + + /** + * MATERIALIZED columns are stored, ALIAS columns are computed when read + */ + @Override + public String getGeneratedColumnStatement(String catalog, String schema, String tableName, String columnName, + int dataType, String typeName, int columnSize, int columnScale, + String expression, boolean stored) { + return String.format("%s %s %s %s", getIdentifier(columnName), + getColumnTypeName(dataType, typeName, columnSize, columnScale), stored ? "MATERIALIZED" : "ALIAS", + expression); + } + + /** + * The primary key of other databases is the sorting key, tables of ClickHouse keep their own primary key by + * the table engine + */ + @Override + public String getCreatePrimaryKeyStatement(String catalog, String schema, String tableName, String columnName, + String pkeyName) { + if (!isCrossDatabase()) { + return null; + } + return String.format("PRIMARY KEY (%s)", getIdentifiers(columnName)); + } + + /** + * Tables of the same database type keep their engine, sorting key, partition and settings. Other tables are + * MergeTree tables sorted by the primary key. + */ + @Override + public String getTableOptions(String catalog, String schema, String tableName, Map detail, + List primaryKeyColumnNames) { + String engine = detail != null ? (String) detail.get("ENGINE_FULL") : null; + if (!isCrossDatabase() && StringUtils.isNotBlank(engine)) { + return "ENGINE = " + engine; + } + String orderBy = primaryKeyColumnNames == null || primaryKeyColumnNames.isEmpty() ? "tuple()" + : "(" + primaryKeyColumnNames.stream().map(this::getIdentifier).collect(Collectors.joining(", ")) + + ")"; + // Primary key columns of some databases (SQLite) are nullable + return String.format("ENGINE = %s ORDER BY %s SETTINGS allow_nullable_key = 1", DEFAULT_ENGINE, orderBy); + } + + @Override + public String getDropTableStatement(String catalog, String schema, String tableName) { + return String.format("DROP TABLE IF EXISTS %s", getQualifiedTableName(catalog, schema, tableName)); + } + + @Override + public String getCreateUserStatement(String username, String password) { + return null; + } + + @Override + public String getCreateDatabaseStatement(String catalog, String username) { + return null; + } + + /** + * Schemas are databases of ClickHouse + */ + @Override + public String getCreateSchemaStatement(String catalog, String schema, String username) { + return getCreateSchemaIfNotExistsStatement(schema); + } + + @Override + public String getCreateSchemaIfNotExistsStatement(String schema) { + return String.format("CREATE DATABASE IF NOT EXISTS %s", quoteIdentifier(schema)); + } + + @Override + public String getDefaultSchemaName(String catalog) { + return "default"; + } + + @Override + public String getSequenceNameStatement(String catalog, String schema, String tableName, String columnName) { + return null; + } + + @Override + public String getDefaultSequenceName(String catalog, String schema, String tableName, String columnName) { + return null; + } + + @Override + public String getAlterSequenceStartValueStatement(String catalog, String schema, String tableName, + String sequenceName, long startValue) { + return null; + } + + @Override + public String getCreateCommentStatement(String catalog, String schema, String tableName, String columnName, + String comment) { + return String.format("ALTER TABLE %s COMMENT COLUMN %s %s", getQualifiedTableName(catalog, schema, tableName), + getIdentifier(columnName), getStringLiteral(comment)); + } + + @Override + public String getCreateTableCommentStatement(String catalog, String schema, String tableName, String comment) { + return String.format("ALTER TABLE %s MODIFY COMMENT %s", getQualifiedTableName(catalog, schema, tableName), + getStringLiteral(comment)); + } + + /** + * Indexes of other databases are not data skipping indexes + */ + @Override + public String getCreateIndexStatement(String catalog, String schema, String tableName, boolean partition, + String[] columnNames, String indexName, boolean unique, String indexType) { + return null; + } + + @Override + public String getCreateForeignKeyStatement(String catalog, String schema, String tableName, String fkName, + String[] columnNames, String refTableName, String[] refColumnNames, + String updateRule, String deleteRule) { + return null; + } + + /** + * Lightweight updates of ClickHouse need settings of the table, LOB values are written in INSERT statements + */ + @Override + public boolean isLobSeparationSupported() { + return false; + } + + @Override + public String getCreatePartitionTableStatement(String catalog, String schema, String tableName, + String inheritedTableName) { + return null; + } + + /** + * Nested types, enums, addresses, uuid and wide integers are read as text, which every database accepts + */ + @Override + public String getSelectColumnExpression(String columnName, String typeName) { + if (isBytesRead(typeName)) { + // Bytes of strings are kept between ClickHouse databases + hexColumns.add(columnName.toLowerCase(Locale.ENGLISH)); + return String.format("hex(%1$s) AS %1$s", quoteIdentifier(columnName)); + } + if (ClickHouseMetaDataOperations.isTextReadType(typeName)) { + return String.format("toString(%1$s) AS %1$s", quoteIdentifier(columnName)); + } + return super.getSelectColumnExpression(columnName, typeName); + } + + /** + * Strings are read as bytes when they are written to ClickHouse, the driver decodes them as UTF-8 otherwise + */ + private boolean isBytesRead(String typeName) { + String baseType = ClickHouseMetaDataOperations.getBaseType(typeName); + return getReadTargetDbType() == DbType.CLICKHOUSE && ("string".equals(baseType) + || "fixedstring".equals(baseType)); + } + + /** + * Strings selected by hex are decoded to their bytes + */ + @Override + public Object getColumnValue(ResultSet rs, int columnIndex, int columnType, String columnTypeName) + throws SQLException { + Object value = super.getColumnValue(rs, columnIndex, columnType, columnTypeName); + if (value instanceof String && !hexColumns.isEmpty() && hexColumns.contains(rs.getMetaData() + .getColumnLabel(columnIndex).toLowerCase(Locale.ENGLISH)) && HEX.matcher((String) value).matches()) { + return HexFormat.of().parseHex((String) value); + } + if (value instanceof String && StringUtils.containsIgnoreCase(columnTypeName, "FixedString")) { + // Values of FixedString are padded with zero bytes, which text of other databases does not accept + return StringUtils.stripEnd((String) value, "\u0000"); + } + return value; + } + + /** + * Rows without primary key are ordered by all columns, LIMIT/OFFSET of parallel reading is not deterministic + * otherwise + */ + @Override + public String getPageStatement(String sql, String orderBy, int limit, int offset) { + return super.getPageStatement(sql, StringUtils.isNotBlank(orderBy) ? orderBy : "tuple(*)", limit, offset); + } + + @Override + public String getCurrentTimestampExpression() { + return "now64()"; + } + + @Override + public String getCurrentDateExpression() { + return "today()"; + } + + @Override + public String getStringLiteral(String value) { + if (value == null) { + return "NULL"; + } + // Quotes are doubled as standard sql, so that scripts are parsed like scripts of other databases + return "'" + value.replace("\\", "\\\\").replace("'", "''") + "'"; + } + + @Override + public String getBinaryLiteral(byte[] bytes) { + return "unhex('" + toHexString(bytes) + "')"; + } + + @Override + public String getTimestampLiteral(LocalDateTime value) { + return "toDateTime64('" + formatTimestamp(value) + "', 6)"; + } + + @Override + public String getTimestampWithTimeZoneLiteral(OffsetDateTime value) { + return getTimestampLiteral(value.atZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime()); + } + + @Override + public String getDateLiteral(LocalDate value) { + return "toDate32('" + value + "')"; + } + + @Override + public String getTimeLiteral(LocalTime value) { + return getStringLiteral(formatTime(value)); + } + + @Override + public int getMaxFractionalSecondsPrecision() { + return 6; + } + + /** + * Temporal values are bound as text, the driver converts dates and times by the time zone of the client + * otherwise. UUIDs and times are text too. + */ + @Override + public Object getJdbcValue(Object value, int sourceDataType, String sourceTypeName, int columnSize) { + Object result = super.getJdbcValue(value, sourceDataType, sourceTypeName, columnSize); + if (result instanceof Timestamp) { + result = ((Timestamp) result).toLocalDateTime(); + } else if (result instanceof Date) { + result = ((Date) result).toLocalDate(); + } else if (result instanceof Time) { + result = ((Time) result).toLocalTime(); + } + if (result instanceof OffsetDateTime) { + result = ((OffsetDateTime) result).atZoneSameInstant(ZoneId.systemDefault()) + .toLocalDateTime(); + } + if (result instanceof LocalDateTime) { + return formatTimestamp((LocalDateTime) result); + } else if (result instanceof LocalDate) { + return result.toString(); + } else if (result instanceof LocalTime) { + return formatTime((LocalTime) result); + } else if (result instanceof OffsetTime) { + OffsetTime time = (OffsetTime) result; + return formatTime(time.toLocalTime()) + time.getOffset(); + } else if (result instanceof UUID) { + return result.toString(); + } + return result; + } +} diff --git a/src/main/java/com/github/sqljam/impexp/db/ClickHouseMetaDataOperations.java b/src/main/java/com/github/sqljam/impexp/db/ClickHouseMetaDataOperations.java new file mode 100644 index 0000000..f58e5e5 --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/db/ClickHouseMetaDataOperations.java @@ -0,0 +1,528 @@ +/* + * 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.db; + +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +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.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.impexp.MetaDataOperations; +import com.github.sqljam.impexp.TableMetaData; +import com.github.sqljam.jdbc.JdbcUtils; +import com.github.sqljam.utils.CaseInsensitiveMap; + +/** + * @Description: ClickHouseMetaDataOperations reads databases of ClickHouse as schemas, tables of the MergeTree family + * and other table engines, columns by system.columns (nullability, defaults, MATERIALIZED and ALIAS + * columns, comments) and primary keys by the primary key expression of the table. ClickHouse has no + * foreign keys, unique indexes and sequences. Types special to ClickHouse are mapped to standard jdbc + * types, their type names are kept for ClickHouse targets. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public class ClickHouseMetaDataOperations extends MetaDataOperations { + + /** + * Length of String columns of keys, whose lengths are not declared in ClickHouse + */ + static final int KEY_COLUMN_SIZE = 255; + + /** + * Max precision of DECIMAL which other databases hold + */ + static final int MAX_DECIMAL_PRECISION = 38; + + private static final Set INTERNAL_SCHEMAS = new HashSet<>(Arrays.asList("system", + "information_schema")); + private static final Set NULLABLE_TYPES = new HashSet<>(Arrays.asList("variant", "dynamic", "json", + "object")); + private static final Pattern WRAPPER = Pattern.compile("^(Nullable|LowCardinality)\\((.*)\\)$"); + private static final Pattern PARAMETERS = Pattern.compile("^\\w+\\((.*)\\)$"); + private static final Pattern IDENTIFIER = Pattern.compile("^[`\"]?([A-Za-z_][A-Za-z0-9_]*)[`\"]?$"); + + /** + * Types which are read as text: nested types, enums, addresses, uuid, json and integers wider than DECIMAL(38) + */ + private static final Set TEXT_READ_TYPES = new HashSet<>(Arrays.asList("array", "map", "tuple", + "nested", "ipv4", "ipv6", "enum8", "enum16", "enum", "uuid", "json", "object", "variant", "dynamic", + "int256", "uint256", "point", "ring", "linestring", "multilinestring", "polygon", "multipolygon", + "time", "time64", "aggregatefunction", "simpleaggregatefunction", "intervalsecond", "intervalminute", + "intervalhour", "intervalday", "intervalweek", "intervalmonth", "intervalquarter", "intervalyear", + "bfloat16")); + + @Override + protected String[] getTableTypes() { + return new String[]{"TABLE", "LOG TABLE", "MEMORY TABLE"}; + } + + /** + * Databases of ClickHouse are schemas, the server is the only catalog + */ + @Override + public List> getCatalogInfos(DatabaseMetaData databaseMetaData) { + Map catalogInfo = new CaseInsensitiveMap<>(); + catalogInfo.put("TABLE_CAT", ""); + List> catalogInfos = new ArrayList<>(); + catalogInfos.add(catalogInfo); + return catalogInfos; + } + + @Override + public List> getSchemaInfos(DatabaseMetaData databaseMetaData, String catalogName) + throws SQLException { + return super.getSchemaInfos(databaseMetaData, null).stream() + .filter(info -> !INTERNAL_SCHEMAS.contains( + StringUtils.lowerCase((String) info.get("TABLE_SCHEM"), Locale.ENGLISH))) + .collect(Collectors.toList()); + } + + /** + * Tables of the database, inner tables of materialized views are skipped. ENGINE_FULL keeps the table engine + * with its sorting key, partition and settings for ClickHouse targets. + */ + @Override + public List> getTableInfos(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName) throws SQLException { + String schema = getSchema(databaseMetaData, schemaName); + Map engines = new HashMap<>(); + String sql = "SELECT name, engine_full FROM system.tables WHERE database = ?"; + for (Map row : JdbcUtils.fetchAll(databaseMetaData.getConnection(), sql, + new Object[]{schema})) { + engines.put((String) row.get("name"), (String) row.get("engine_full")); + } + List> tableInfos = new ArrayList<>(); + for (Map info : super.getTableInfos(databaseMetaData, null, schema)) { + String tableName = (String) info.get("TABLE_NAME"); + if (tableName == null || tableName.startsWith(".inner")) { + continue; + } + Map tableInfo = new LinkedHashMap<>(info); + tableInfo.put("ENGINE_FULL", engines.get(tableName)); + tableInfos.add(tableInfo); + } + return tableInfos; + } + + /** + * Columns of the primary key expression in order, expressions of columns (e.g. toDate(time)) are skipped + */ + @Override + public List> getPrimaryKeyInfos(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName, String tableName) throws SQLException { + String schema = getSchema(databaseMetaData, schemaName); + Map row = JdbcUtils.fetchOne(databaseMetaData.getConnection(), + "SELECT primary_key FROM system.tables WHERE database = ? AND name = ?", + new Object[]{schema, tableName}); + List> keyInfos = new ArrayList<>(); + int keySeq = 1; + for (String column : parseKeyColumns(row != null ? (String) row.get("primary_key") : null)) { + Map keyInfo = new LinkedHashMap<>(); + keyInfo.put("TABLE_SCHEM", schema); + keyInfo.put("TABLE_NAME", tableName); + keyInfo.put("COLUMN_NAME", column); + keyInfo.put("KEY_SEQ", keySeq++); + keyInfo.put("PK_NAME", null); + keyInfos.add(keyInfo); + } + return keyInfos; + } + + /** + * Columns of a key expression, e.g. "event_time, op_type". A key with expressions is not a primary key of + * other databases, it is skipped. + */ + static List parseKeyColumns(String keyExpression) { + String expression = StringUtils.trimToEmpty(keyExpression); + // A key declared in parentheses is a tuple, e.g. (id) or tuple(a, b) + if (expression.startsWith("tuple(") && expression.endsWith(")")) { + expression = expression.substring("tuple".length()); + } + if (expression.startsWith("(") && expression.endsWith(")")) { + expression = expression.substring(1, expression.length() - 1).trim(); + } + if (expression.isEmpty()) { + return Collections.emptyList(); + } + List columns = new ArrayList<>(); + for (String part : splitTopLevel(expression)) { + Matcher matcher = IDENTIFIER.matcher(part.trim()); + if (!matcher.matches()) { + return Collections.emptyList(); + } + columns.add(matcher.group(1)); + } + return columns; + } + + /** + * Data skipping indexes of ClickHouse are not indexes of other databases + */ + @Override + public List> getIndexInfos(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName, String tableName) { + return Collections.emptyList(); + } + + @Override + public List> getImportedKeyInfos(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName, String tableName) { + return Collections.emptyList(); + } + + /** + * Columns by system.columns: MATERIALIZED and ALIAS columns are generated columns, EPHEMERAL columns have no + * values and are skipped. Nullable and LowCardinality wrappers are removed from type names, the nullability is + * kept by IS_NULLABLE. + */ + @Override + public List> getColumnInfos(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName, String tableName) throws SQLException { + String schema = getSchema(databaseMetaData, schemaName); + List> columnInfos = new ArrayList<>(super.getColumnInfos(databaseMetaData, null, schema, + tableName)); + String sql = "SELECT name, type, default_kind, default_expression, comment, is_in_primary_key" + + " FROM system.columns WHERE database = ? AND table = ? ORDER BY position"; + Map> systemColumns = new HashMap<>(); + for (Map row : JdbcUtils.fetchAll(databaseMetaData.getConnection(), sql, + new Object[]{schema, tableName})) { + systemColumns.put((String) row.get("name"), row); + } + Set keyColumns = new HashSet<>(); + for (Iterator> it = columnInfos.iterator(); it.hasNext(); ) { + Map columnInfo = it.next(); + String columnName = (String) columnInfo.get("COLUMN_NAME"); + Map systemColumn = systemColumns.get(columnName); + if (systemColumn == null) { + continue; + } + String defaultKind = StringUtils.defaultString((String) systemColumn.get("default_kind")) + .toUpperCase(Locale.ENGLISH); + if ("EPHEMERAL".equals(defaultKind)) { + it.remove(); + continue; + } + String expression = StringUtils.trimToNull((String) systemColumn.get("default_expression")); + columnInfo.put("IS_AUTOINCREMENT", "NO"); + if ("MATERIALIZED".equals(defaultKind) || "ALIAS".equals(defaultKind)) { + columnInfo.put("IS_GENERATEDCOLUMN", "YES"); + columnInfo.put("GENERATION_EXPRESSION", expression); + columnInfo.put("GENERATION_TYPE", "ALIAS".equals(defaultKind) ? "VIRTUAL" : "STORED"); + columnInfo.put("COLUMN_DEF", null); + } else { + columnInfo.put("IS_GENERATEDCOLUMN", "NO"); + columnInfo.put("COLUMN_DEF", expression); + } + columnInfo.put("REMARKS", StringUtils.trimToNull((String) systemColumn.get("comment"))); + normalizeType(columnInfo, (String) systemColumn.get("type")); + if (isTrue(systemColumn.get("is_in_primary_key"))) { + keyColumns.add(columnName); + } + } + boundKeyColumns(databaseMetaData, schema, tableName, columnInfos, keyColumns); + return columnInfos; + } + + private static boolean isTrue(Object value) { + if (value instanceof Boolean) { + return (Boolean) value; + } + return value instanceof Number ? ((Number) value).intValue() != 0 : "1".equals(String.valueOf(value)); + } + + /** + * String columns of keys get a bounded length, so that other databases can index them: at least 255, or the + * longest value + */ + private void boundKeyColumns(DatabaseMetaData databaseMetaData, String schema, String tableName, + List> columnInfos, Set keyColumns) throws SQLException { + List> stringKeys = columnInfos.stream() + .filter(info -> keyColumns.contains((String) info.get("COLUMN_NAME"))) + .filter(info -> TableMetaData.getInt(info, "DATA_TYPE") == Types.VARCHAR + && TableMetaData.getInt(info, "COLUMN_SIZE") == Integer.MAX_VALUE) + .collect(Collectors.toList()); + if (stringKeys.isEmpty()) { + return; + } + String lengths = stringKeys.stream().map(info -> String.format("max(lengthUTF8(%s))", + quote((String) info.get("COLUMN_NAME")))).collect(Collectors.joining(", ")); + List values = JdbcUtils.fetchAll(databaseMetaData.getConnection(), String.format( + "SELECT %s FROM %s.%s", lengths, quote(schema), quote(tableName)), new Object[0]).stream() + .findFirst().map(row -> (List) new ArrayList<>(row.values())).orElse(List.of()); + for (int i = 0; i < stringKeys.size(); i++) { + Object length = i < values.size() ? values.get(i) : null; + int size = length instanceof Number ? ((Number) length).intValue() : 0; + stringKeys.get(i).put("COLUMN_SIZE", Math.max(KEY_COLUMN_SIZE, size)); + } + } + + private static String quote(String name) { + return "`" + name.replace("\\", "\\\\").replace("`", "\\`") + "`"; + } + + /** + * Jdbc type, size and scale of a ClickHouse type, TYPE_NAME is the type without Nullable wrapper + */ + static void normalizeType(Map columnInfo, String type) { + String typeName = StringUtils.defaultString(type).trim(); + boolean nullable = false; + boolean lowCardinality = false; + Matcher wrapper = WRAPPER.matcher(typeName); + while (wrapper.matches()) { + if ("Nullable".equals(wrapper.group(1))) { + nullable = true; + } else { + lowCardinality = true; + } + typeName = wrapper.group(2).trim(); + wrapper = WRAPPER.matcher(typeName); + } + // Variant, Dynamic and JSON values may be NULL without Nullable + nullable = nullable || NULLABLE_TYPES.contains(getBaseType(typeName)); + columnInfo.put("IS_NULLABLE", nullable ? "YES" : "NO"); + columnInfo.put("NULLABLE", nullable ? DatabaseMetaData.columnNullable : DatabaseMetaData.columnNoNulls); + columnInfo.put("TYPE_NAME", lowCardinality ? "LowCardinality(" + typeName + ")" : typeName); + String baseType = getBaseType(typeName); + List parameters = getParameters(typeName); + int dataType; + int size = 0; + int scale = 0; + switch (baseType) { + case "bool": + case "boolean": + dataType = Types.BOOLEAN; + size = 1; + break; + case "int8": + dataType = Types.TINYINT; + size = 3; + break; + case "int16": + case "uint8": + dataType = Types.SMALLINT; + size = 5; + break; + case "int32": + case "uint16": + dataType = Types.INTEGER; + size = 10; + break; + case "int64": + case "uint32": + dataType = Types.BIGINT; + size = 19; + break; + case "uint64": + dataType = Types.NUMERIC; + size = 20; + break; + case "int128": + case "uint128": + dataType = Types.NUMERIC; + size = 38; + break; + case "float32": + dataType = Types.REAL; + size = 7; + break; + case "float64": + dataType = Types.DOUBLE; + size = 15; + break; + case "decimal": + case "decimal32": + case "decimal64": + case "decimal128": + case "decimal256": + dataType = Types.DECIMAL; + size = getDecimalPrecision(baseType, parameters); + scale = parameters.isEmpty() ? 0 : toInt(parameters.get(parameters.size() - 1), 0); + if (size > MAX_DECIMAL_PRECISION) { + // Decimals wider than DECIMAL(38) of other databases are text + dataType = Types.VARCHAR; + size += 2; + scale = 0; + } + break; + case "date": + case "date32": + dataType = Types.DATE; + size = 10; + break; + case "datetime": + dataType = Types.TIMESTAMP; + size = 19; + break; + case "datetime64": + dataType = Types.TIMESTAMP; + scale = parameters.isEmpty() ? 3 : toInt(parameters.get(0), 3); + size = 20 + scale; + break; + case "fixedstring": + dataType = Types.CHAR; + size = parameters.isEmpty() ? 1 : toInt(parameters.get(0), 1); + break; + case "string": + dataType = Types.VARCHAR; + size = Integer.MAX_VALUE; + break; + case "uuid": + dataType = Types.VARCHAR; + size = 36; + columnInfo.put("TYPE_NAME", "UUID"); + break; + case "ipv4": + dataType = Types.VARCHAR; + size = 15; + break; + case "ipv6": + dataType = Types.VARCHAR; + size = 45; + break; + case "enum8": + case "enum16": + dataType = Types.VARCHAR; + size = Math.max(getMaxEnumLength(parameters), 1); + break; + default: + // Nested types, json, geo types and integers wider than DECIMAL(38) are text of other databases + dataType = Types.VARCHAR; + size = Integer.MAX_VALUE; + break; + } + columnInfo.put("DATA_TYPE", dataType); + columnInfo.put("COLUMN_SIZE", size); + columnInfo.put("DECIMAL_DIGITS", scale); + } + + private static int getDecimalPrecision(String baseType, List parameters) { + switch (baseType) { + case "decimal32": + return 9; + case "decimal64": + return 18; + case "decimal128": + return 38; + case "decimal256": + return 76; + default: + return parameters.isEmpty() ? 10 : toInt(parameters.get(0), 10); + } + } + + private static int getMaxEnumLength(List parameters) { + int max = 0; + for (String parameter : parameters) { + String label = parameter.replaceAll("\\s*=\\s*-?\\d+\\s*$", "").trim(); + if (label.length() >= 2 && label.startsWith("'") && label.endsWith("'")) { + label = label.substring(1, label.length() - 1).replace("\\'", "'").replace("''", "'"); + } + max = Math.max(max, label.length()); + } + return max; + } + + private static int toInt(String text, int defaultValue) { + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /** + * Lower case name of a type without parameters, e.g. decimal of Decimal(18, 2) + */ + static String getBaseType(String typeName) { + String type = StringUtils.defaultString(typeName).trim(); + Matcher wrapper = WRAPPER.matcher(type); + while (wrapper.matches()) { + type = wrapper.group(2).trim(); + wrapper = WRAPPER.matcher(type); + } + int index = type.indexOf('('); + return (index >= 0 ? type.substring(0, index) : type).trim().toLowerCase(Locale.ENGLISH); + } + + private static List getParameters(String typeName) { + Matcher matcher = PARAMETERS.matcher(typeName); + return matcher.matches() ? splitTopLevel(matcher.group(1)) : Collections.emptyList(); + } + + /** + * Splits by commas which are not inside parentheses or quotes + */ + static List splitTopLevel(String text) { + List parts = new ArrayList<>(); + int depth = 0; + boolean quoted = false; + StringBuilder part = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\'' && (i == 0 || text.charAt(i - 1) != '\\')) { + quoted = !quoted; + } else if (!quoted && c == '(') { + depth++; + } else if (!quoted && c == ')') { + depth--; + } else if (!quoted && depth == 0 && c == ',') { + parts.add(part.toString().trim()); + part.setLength(0); + continue; + } + part.append(c); + } + if (part.length() > 0) { + parts.add(part.toString().trim()); + } + return parts; + } + + /** + * Whether values of the type are read as text, which every database accepts + */ + public static boolean isTextReadType(String typeName) { + String baseType = getBaseType(typeName); + if (baseType.startsWith("decimal")) { + Map columnInfo = new HashMap<>(); + normalizeType(columnInfo, typeName); + return TableMetaData.getInt(columnInfo, "DATA_TYPE") == Types.VARCHAR; + } + return TEXT_READ_TYPES.contains(baseType); + } + + /** + * The database of the connection if the schema is not given + */ + private static String getSchema(DatabaseMetaData databaseMetaData, String schemaName) throws SQLException { + return StringUtils.isNotBlank(schemaName) ? schemaName : databaseMetaData.getConnection().getSchema(); + } +} diff --git a/src/main/java/com/github/sqljam/impexp/db/DuckDBDialect.java b/src/main/java/com/github/sqljam/impexp/db/DuckDBDialect.java new file mode 100644 index 0000000..97cd405 --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/db/DuckDBDialect.java @@ -0,0 +1,528 @@ +/* + * 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.db; + +import java.sql.Timestamp; +import java.sql.Types; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.util.UUID; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.Dialect; +import com.github.sqljam.impexp.IdentifierCase; +import com.github.sqljam.impexp.StringHelper; + +/** + * @Description: DuckDBDialect for DuckDB, an embedded analytical database. Identity columns are implemented by + * sequences and nextval defaults. Sequences can not be altered, so they are recreated to reset the + * next value. Foreign keys and unique constraints can not be added by ALTER TABLE, foreign keys are + * defined in CREATE TABLE and unique constraints by unique indexes. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public class DuckDBDialect extends Dialect { + + /** + * Max precision of DECIMAL + */ + public static final int MAX_DECIMAL_PRECISION = 38; + + /** + * Whether values which DuckDB can not hold exactly (unbounded numerics, time with time zone) are stored as + * text, used when DuckDB is the intermediate database of Parquet files + */ + private boolean exactValues; + + /** + * Database type which the rows are exported for (exact mode): values of the same database type are kept as + * text if DuckDB has no exact type for them (offsets of times, bit strings), they are converted otherwise + */ + private DbType exactTargetDbType; + + public DuckDBDialect() { + super(); + registerColumnType(Types.BIT, "BOOLEAN"); + registerColumnType(Types.BOOLEAN, "BOOLEAN"); + registerColumnType(Types.TINYINT, "TINYINT"); + registerColumnType(Types.SMALLINT, "SMALLINT"); + registerColumnType(Types.INTEGER, "INTEGER"); + registerColumnType(Types.BIGINT, "BIGINT"); + // FLOAT of jdbc is a double precision number + registerColumnType(Types.FLOAT, "DOUBLE"); + registerColumnType(Types.REAL, "REAL"); + registerColumnType(Types.DOUBLE, "DOUBLE"); + registerColumnType(Types.NUMERIC, "DECIMAL($p,$s)"); + registerColumnType(Types.DECIMAL, "DECIMAL($p,$s)"); + + registerColumnType(Types.DATE, "DATE"); + registerColumnType(Types.TIME, "TIME"); + registerColumnType(Types.TIMESTAMP, "TIMESTAMP"); + registerColumnType(Types.TIME_WITH_TIMEZONE, "TIMETZ"); + registerColumnType(Types.TIMESTAMP_WITH_TIMEZONE, "TIMESTAMPTZ"); + + registerColumnType(Types.BINARY, "BLOB"); + registerColumnType(Types.VARBINARY, "BLOB"); + registerColumnType(Types.LONGVARBINARY, "BLOB"); + registerColumnType(Types.BLOB, "BLOB"); + registerColumnType(Types.JAVA_OBJECT, "BLOB"); + + // Lengths of character types are not enforced by DuckDB + registerColumnType(Types.CHAR, "VARCHAR"); + registerColumnType(Types.NCHAR, "VARCHAR"); + registerColumnType(Types.VARCHAR, "VARCHAR"); + registerColumnType(Types.NVARCHAR, "VARCHAR"); + registerColumnType(Types.LONGVARCHAR, "VARCHAR"); + registerColumnType(Types.LONGNVARCHAR, "VARCHAR"); + registerColumnType(Types.CLOB, "VARCHAR"); + registerColumnType(Types.NCLOB, "VARCHAR"); + registerColumnType(Types.SQLXML, "VARCHAR"); + registerColumnType(Types.OTHER, "VARCHAR"); + + registerReservedWords("analyze", "array", "asymmetric", "both", "cast", "collate", "columns", "do", + "lambda", "lateral", "leading", "offset", "pivot", "pivot_longer", "pivot_wider", "placing", + "qualify", "returning", "show", "summarize", "symmetric", "trailing", "unpivot", "using", + "variadic", "window"); + } + + @Override + public DbType getDbType() { + return DbType.DUCKDB; + } + + public boolean isExactValues() { + return exactValues; + } + + public void setExactValues(boolean exactValues) { + this.exactValues = exactValues; + } + + public DbType getExactTargetDbType() { + return exactTargetDbType; + } + + public void setExactTargetDbType(DbType exactTargetDbType) { + this.exactTargetDbType = exactTargetDbType; + } + + /** + * Timestamps with time zone are kept as text when they go back to the same database type, except Oracle which + * parses text by session formats + */ + private boolean isOffsetTextKept() { + return isTextKept() && getSourceDbType() != DbType.ORACLE; + } + + /** + * Values are kept as text when they go back to the same database type + */ + private boolean isTextKept() { + return exactValues && exactTargetDbType != null && exactTargetDbType == getSourceDbType(); + } + + /** + * Settings of DuckDB are kept by the dialect of the detected version + */ + @Override + public Dialect forVersion(int majorVersion, int minorVersion) { + Dialect dialect = super.forVersion(majorVersion, minorVersion); + if (dialect instanceof DuckDBDialect) { + ((DuckDBDialect) dialect).setExactValues(exactValues); + ((DuckDBDialect) dialect).setExactTargetDbType(exactTargetDbType); + } + return dialect; + } + + /** + * Lists are accepted as text + */ + @Override + public boolean isArrayParameterSupported() { + return false; + } + + @Override + public IdentifierCase getStoredIdentifierCase() { + return IdentifierCase.LOWER; + } + + @Override + public int getMaxIdentifierLength() { + return 255; + } + + @Override + public int getMaxNumericPrecision() { + return MAX_DECIMAL_PRECISION; + } + + /** + * Numerics without precision (or wider than DECIMAL) are doubles, or text when values must be exact + */ + @Override + public String getUnboundedNumericTypeName() { + return exactValues ? "VARCHAR" : "DOUBLE"; + } + + /** + * Type name of DuckDB keeps parameters and nested types, e.g. DECIMAL(12,2), INTEGER[], STRUCT(a INTEGER) + */ + @Override + protected String getNativeTypeName(int dataType, String sourceTypeName, int columnSize, int columnScale) { + if (exactValues && StringUtils.startsWithIgnoreCase(sourceTypeName, "UNION(")) { + // Unions are written as unnamed structs to Parquet, which can not be read back into tables + return "VARCHAR"; + } + if (isTextKept() && "TIME WITH TIME ZONE".equalsIgnoreCase(sourceTypeName)) { + // Parquet keeps times in UTC, the offset is kept by text + return "VARCHAR"; + } + return sourceTypeName; + } + + @Override + protected String getSpecialTypeName(int dataType, String sourceTypeName, int columnSize, int columnScale) { + String baseTypeName = sourceTypeName.replaceAll("\\(.*\\)", "").replace(" unsigned", "").trim(); + switch (baseTypeName) { + case "json": + case "jsonb": + return "JSON"; + case "uuid": + case "uniqueidentifier": + return "UUID"; + case "bool": + case "boolean": + return "BOOLEAN"; + case "bytea": + return "BLOB"; + case "year": + return "SMALLINT"; + case "money": + case "smallmoney": + return "DECIMAL(19,4)"; + case "datetimeoffset": + return isOffsetTextKept() ? "VARCHAR" : "TIMESTAMPTZ"; + case "timetz": + case "time with time zone": + return isTextKept() ? "VARCHAR" : "TIMETZ"; + case "xml": + case "xmltype": + case "enum": + case "set": + return "VARCHAR"; + default: + break; + } + if (baseTypeName.startsWith("_")) { + // Arrays of PostgreSQL are migrated as text + return "VARCHAR"; + } + if (isTextKept() && getSourceDbType() == DbType.POSTGRESQL && ("bit".equals(baseTypeName) + || "varbit".equals(baseTypeName))) { + // Bit strings of PostgreSQL are kept as text + return "VARCHAR"; + } + if (dataType == Types.BIT && columnSize > 1) { + return "BIGINT"; + } + if (dataType == Types.TIME_WITH_TIMEZONE && isTextKept()) { + return "VARCHAR"; + } + if (dataType == Types.TIMESTAMP_WITH_TIMEZONE && isOffsetTextKept()) { + // Offsets and fractional seconds of timestamps with time zone are kept by text + return "VARCHAR"; + } + return null; + } + + @Override + public String getCreateUserStatement(String username, String password) { + return null; + } + + @Override + public String getCreateDatabaseStatement(String catalog, String username) { + return null; + } + + @Override + public String getCreateSchemaStatement(String catalog, String schema, String username) { + return getCreateSchemaIfNotExistsStatement(schema); + } + + @Override + public String getCreateSchemaIfNotExistsStatement(String schema) { + return String.format("CREATE SCHEMA IF NOT EXISTS %s", quoteIdentifier(schema)); + } + + @Override + public String getDefaultSchemaName(String catalog) { + return "main"; + } + + /** + * Sequences of identity columns: table_column_seq in the schema of the table + */ + @Override + public String getDefaultSequenceName(String catalog, String schema, String tableName, String columnName) { + String name = getLimitedIdentifier(foldIdentifier(tableName + "_" + columnName + "_seq")); + String qualifier = getQualifier(getTargetCatalogName(), getTargetSchemaName(schema)); + return StringUtils.isNotBlank(qualifier) ? qualifier + "." + quoteIdentifier(name) : quoteIdentifier(name); + } + + @Override + public String getSequenceNameStatement(String catalog, String schema, String tableName, String columnName) { + return null; + } + + /** + * ALTER SEQUENCE is not supported, sequences are recreated + */ + @Override + public String getAlterSequenceStartValueStatement(String catalog, String schema, String tableName, + String sequenceName, long startValue) { + return null; + } + + @Override + public boolean isSequenceSupported() { + return true; + } + + /** + * CACHE is not supported, an existing sequence is kept since columns may depend on it + */ + @Override + public String getCreateSequenceStatement(String catalog, String schema, String sequenceName, long startValue, + long increment, Long minValue, Long maxValue, boolean cycle, + Long cacheSize, String dataType) { + String sql = super.getCreateSequenceStatement(catalog, schema, sequenceName, startValue, increment, minValue, + maxValue, cycle, null, dataType); + return "CREATE SEQUENCE IF NOT EXISTS " + sql.substring("CREATE SEQUENCE ".length()); + } + + /** + * The identity column gets a sequence and a nextval default after the table is created + */ + @Override + public String[] getStatementAfterIncrementalColumnCreated(String catalog, String schema, String tableName, + String columnName) { + String sequenceName = getDefaultSequenceName(catalog, schema, tableName, columnName); + return new String[]{String.format("DROP SEQUENCE IF EXISTS %s", sequenceName), + String.format("CREATE SEQUENCE %s START 1", sequenceName), + String.format("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT nextval('%s')", + getQualifiedTableName(catalog, schema, tableName), getIdentifier(columnName), + sequenceName.replace("'", "''"))}; + } + + /** + * The sequence of the identity column is recreated with the next value, statements are executed together + */ + @Override + public String getResetIdentityStatement(String catalog, String schema, String tableName, String columnName, + long startValue) { + String sequenceName = getDefaultSequenceName(catalog, schema, tableName, columnName); + String table = getQualifiedTableName(catalog, schema, tableName); + String column = getIdentifier(columnName); + return String.format("ALTER TABLE %1$s ALTER COLUMN %2$s DROP DEFAULT; DROP SEQUENCE IF EXISTS %3$s; " + + "CREATE SEQUENCE %3$s START %4$d; " + + "ALTER TABLE %1$s ALTER COLUMN %2$s SET DEFAULT nextval('%5$s')", table, column, + sequenceName, startValue, sequenceName.replace("'", "''")); + } + + @Override + public String getIncrementalColumnStatement(String catalog, String schema, String tableName, String columnName, + int dataType, String typeName, int columnSize, int columnScale, + String defaultValue, boolean nullable) { + StringBuilder columnDef = new StringBuilder(StringHelper.textLeft(getIdentifier(columnName), 30)); + String columnTypeName = getColumnTypeName(dataType, typeName, columnSize, columnScale); + if (columnTypeName.startsWith("DECIMAL")) { + // Sequences generate integers + columnTypeName = "BIGINT"; + } + columnDef.append(StringHelper.textLeft(columnTypeName, 30)); + columnDef.append(" NOT NULL"); + return columnDef.toString().trim(); + } + + @Override + public String getCreateCommentStatement(String catalog, String schema, String tableName, String columnName, + String comment) { + return String.format("COMMENT ON COLUMN %s.%s IS %s", getQualifiedTableName(catalog, schema, tableName), + getIdentifier(columnName), getStringLiteral(comment)); + } + + /** + * DuckDB can not add foreign keys by ALTER TABLE, they are defined in CREATE TABLE + */ + @Override + public boolean isForeignKeyInline() { + return true; + } + + /** + * Foreign keys are always checked: referenced tables are created and loaded first, referencing tables are + * dropped first + */ + @Override + public boolean isForeignKeyOrderRequired() { + return true; + } + + /** + * ON DELETE and ON UPDATE actions are not supported + */ + @Override + public String getCreateForeignKeyStatement(String catalog, String schema, String tableName, String fkName, + String[] columnNames, String refTableName, String[] refColumnNames, + String updateRule, String deleteRule) { + return String.format("CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)", + quoteIdentifier(getLimitedIdentifier(foldIdentifier(fkName))), getIdentifiers(columnNames), + getQualifiedTableName(catalog, schema, refTableName), getIdentifiers(refColumnNames)); + } + + @Override + public String getCreatePartitionTableStatement(String catalog, String schema, String tableName, + String inheritedTableName) { + return null; + } + + /** + * Generated columns of DuckDB are always virtual + */ + @Override + public String getGeneratedColumnStatement(String catalog, String schema, String tableName, String columnName, + int dataType, String typeName, int columnSize, int columnScale, + String expression, boolean stored) { + return String.format("%s %s GENERATED ALWAYS AS (%s) VIRTUAL", getIdentifier(columnName), + getColumnTypeName(dataType, typeName, columnSize, columnScale), expression); + } + + /** + * Nested types, enums, intervals, json and uuid are read as text, which every database accepts + */ + @Override + public String getSelectColumnExpression(String columnName, String typeName) { + if (DuckDBMetaDataOperations.isTextReadType(typeName)) { + return String.format("CAST(%1$s AS VARCHAR) AS %1$s", quoteIdentifier(columnName)); + } + return super.getSelectColumnExpression(columnName, typeName); + } + + /** + * Rows without primary key are ordered by rowid, LIMIT/OFFSET of parallel scans is not deterministic otherwise + */ + @Override + public String getPageStatement(String sql, String orderBy, int limit, int offset) { + return super.getPageStatement(sql, StringUtils.isNotBlank(orderBy) ? orderBy : "rowid", limit, offset); + } + + @Override + public String getCurrentDateExpression() { + return "CURRENT_DATE"; + } + + @Override + public String getBooleanLiteral(boolean value) { + return value ? "TRUE" : "FALSE"; + } + + @Override + public String getBinaryLiteral(byte[] bytes) { + StringBuilder literal = new StringBuilder("'"); + String hex = toHexString(bytes); + for (int i = 0; i < hex.length(); i += 2) { + literal.append("\\x").append(hex, i, i + 2); + } + return literal.append("'::BLOB").toString(); + } + + @Override + public String getTimestampWithTimeZoneLiteral(OffsetDateTime value) { + return "TIMESTAMPTZ '" + formatTimestamp(value.toLocalDateTime()) + value.getOffset() + "'"; + } + + /** + * Microseconds, the precision of Parquet timestamps written by DuckDB + */ + /** + * Parses text of a timestamp with offset, returns the text itself if it can not be parsed + */ + static Object parseOffsetDateTime(String text) { + String value = text.trim().replaceFirst("\\s+([+-]\\d{2}:?\\d{2})$", "$1").replace(' ', 'T'); + try { + return OffsetDateTime.parse(value); + } catch (RuntimeException e) { + return text; + } + } + + @Override + public int getMaxFractionalSecondsPrecision() { + return 6; + } + + /** + * Values which DuckDB can not bind directly are converted: offset times as text in exact mode, other + * temporal values as timestamps + */ + @Override + public Object getJdbcValue(Object value, int sourceDataType, String sourceTypeName, int columnSize) { + if (isTextKept()) { + if (value instanceof CharSequence && getSourceDbType() == DbType.POSTGRESQL + && ("bit".equalsIgnoreCase(sourceTypeName) || "varbit".equalsIgnoreCase(sourceTypeName) + || "timetz".equalsIgnoreCase(sourceTypeName))) { + return value.toString(); + } + if (value instanceof OffsetTime) { + // Offsets of times are kept by text, e.g. 12:00:00+08:00 + OffsetTime time = (OffsetTime) value; + return formatTime(time.toLocalTime()) + time.getOffset(); + } + } + if (isOffsetTextKept() && Dialect.normalizeDataType(sourceDataType) == Types.TIMESTAMP_WITH_TIMEZONE) { + if (value instanceof OffsetDateTime) { + OffsetDateTime dateTime = (OffsetDateTime) value; + return formatTimestamp(dateTime.toLocalDateTime()) + dateTime.getOffset(); + } + if (value instanceof CharSequence) { + return value.toString(); + } + } + if (value instanceof CharSequence && Dialect.normalizeDataType(sourceDataType) + == Types.TIMESTAMP_WITH_TIMEZONE) { + // Text of offset timestamps, e.g. 2024-02-29 12:00:00.1234567 +08:00 (DateTimeOffset of SQL Server) + value = parseOffsetDateTime(value.toString()); + } + Object result = super.getJdbcValue(value, sourceDataType, sourceTypeName, columnSize); + if (result instanceof OffsetTime) { + return result.toString(); + } + if (result instanceof UUID) { + return result.toString(); + } + if (result instanceof LocalDateTime) { + LocalDateTime dateTime = (LocalDateTime) result; + // Nanoseconds (TIMESTAMP_NS) are kept by text + return dateTime.getNano() % 1000 != 0 ? formatTimestamp(dateTime) : Timestamp.valueOf(dateTime); + } + return result; + } +} diff --git a/src/main/java/com/github/sqljam/impexp/db/DuckDBMetaDataOperations.java b/src/main/java/com/github/sqljam/impexp/db/DuckDBMetaDataOperations.java new file mode 100644 index 0000000..6d66fcc --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/db/DuckDBMetaDataOperations.java @@ -0,0 +1,467 @@ +/* + * 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.db; + +import java.sql.Array; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.impexp.MetaDataOperations; +import com.github.sqljam.impexp.TableMetaData; +import com.github.sqljam.jdbc.JdbcUtils; + +/** + * @Description: DuckDBMetaDataOperations reads tables of the database file (internal databases and schemas are + * skipped), identity columns (nextval defaults of table_column_seq sequences), generated columns and + * sequences of DuckDB. Types special to DuckDB are mapped to standard jdbc types, their type names are + * kept for DuckDB targets. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public class DuckDBMetaDataOperations extends MetaDataOperations { + + /** + * Length of VARCHAR columns of keys and indexes, whose lengths are not kept by DuckDB + */ + static final int KEY_COLUMN_SIZE = 255; + + private static final Set INTERNAL_SCHEMAS = new HashSet<>(Arrays.asList("information_schema", + "pg_catalog")); + private static final Pattern NEXTVAL = Pattern.compile("(?i)^nextval\\('(?:\"?([^.'\"]+)\"?\\.)?\"?([^'\"]+)\"?'\\)$"); + + @Override + protected String[] getTableTypes() { + return new String[]{"BASE TABLE", "TABLE"}; + } + + /** + * The database of the connection, internal databases (system, temp) are skipped + */ + @Override + public List> getCatalogInfos(DatabaseMetaData databaseMetaData) throws SQLException { + String current = databaseMetaData.getConnection().getCatalog(); + return super.getCatalogInfos(databaseMetaData).stream() + .filter(info -> StringUtils.equals(current, (String) info.get("TABLE_CAT"))) + .collect(Collectors.toList()); + } + + @Override + public List> getSchemaInfos(DatabaseMetaData databaseMetaData, String catalogName) + throws SQLException { + String catalog = getCatalog(databaseMetaData, catalogName); + return super.getSchemaInfos(databaseMetaData, catalog).stream() + .filter(info -> catalog.equals(info.get("TABLE_CATALOG"))) + .filter(info -> !INTERNAL_SCHEMAS.contains( + StringUtils.lowerCase((String) info.get("TABLE_SCHEM"), Locale.ENGLISH))) + .collect(Collectors.toList()); + } + + @Override + public List> getTableInfos(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName) throws SQLException { + return super.getTableInfos(databaseMetaData, getCatalog(databaseMetaData, catalogName), schemaName); + } + + @Override + public List> getPrimaryKeyInfos(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName, String tableName) throws SQLException { + return super.getPrimaryKeyInfos(databaseMetaData, getCatalog(databaseMetaData, catalogName), schemaName, + tableName); + } + + @Override + public List> getImportedKeyInfos(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName, String tableName) throws SQLException { + return super.getImportedKeyInfos(databaseMetaData, getCatalog(databaseMetaData, catalogName), schemaName, + tableName); + } + + @Override + public List> getColumnInfos(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName, String tableName) throws SQLException { + String catalog = getCatalog(databaseMetaData, catalogName); + List> columnInfos = super.getColumnInfos(databaseMetaData, catalog, schemaName, + tableName); + String tableSql = getTableSql(databaseMetaData, catalog, schemaName, tableName); + for (Map columnInfo : columnInfos) { + String columnName = (String) columnInfo.get("COLUMN_NAME"); + String expression = getGenerationExpression(tableSql, columnName); + if (expression != null) { + columnInfo.put("IS_GENERATEDCOLUMN", "YES"); + columnInfo.put("GENERATION_EXPRESSION", expression); + columnInfo.put("GENERATION_TYPE", "VIRTUAL"); + columnInfo.put("COLUMN_DEF", null); + } else { + columnInfo.put("IS_GENERATEDCOLUMN", "NO"); + if (isIdentityDefault((String) columnInfo.get("COLUMN_DEF"), tableName, columnName)) { + columnInfo.put("IS_AUTOINCREMENT", "YES"); + columnInfo.put("COLUMN_DEF", null); + } else { + columnInfo.put("IS_AUTOINCREMENT", "NO"); + } + } + normalizeType(columnInfo); + } + boundKeyColumns(databaseMetaData, catalog, schemaName, tableName, columnInfos); + return columnInfos; + } + + /** + * VARCHAR columns of primary keys and indexes get a bounded length, so that other databases can index them: + * at least 255, or the longest value + */ + private void boundKeyColumns(DatabaseMetaData databaseMetaData, String catalog, String schemaName, + String tableName, List> columnInfos) throws SQLException { + Set keyColumns = new HashSet<>(); + for (Map keyInfo : getPrimaryKeyInfos(databaseMetaData, catalog, schemaName, tableName)) { + keyColumns.add((String) keyInfo.get("COLUMN_NAME")); + } + for (Map indexInfo : getIndexInfos(databaseMetaData, catalog, schemaName, tableName)) { + keyColumns.add((String) indexInfo.get("COLUMN_NAME")); + } + for (Map columnInfo : columnInfos) { + String columnName = (String) columnInfo.get("COLUMN_NAME"); + if (!keyColumns.contains(columnName) || TableMetaData.getInt(columnInfo, "DATA_TYPE") != Types.VARCHAR + || TableMetaData.getInt(columnInfo, "COLUMN_SIZE") < Integer.MAX_VALUE) { + continue; + } + String sql = String.format("SELECT max(length(%s)) FROM %s.%s", quote(columnName), quote(schemaName), + quote(tableName)); + Map row = JdbcUtils.fetchOne(databaseMetaData.getConnection(), sql, new Object[0]); + Object maxLength = row != null ? row.values().iterator().next() : null; + long length = maxLength instanceof Number ? ((Number) maxLength).longValue() : 0; + columnInfo.put("COLUMN_SIZE", (int) Math.max(KEY_COLUMN_SIZE, length)); + } + } + + private static String quote(String name) { + return "\"" + name.replace("\"", "\"\"") + "\""; + } + + /** + * Indexes and unique constraints: jdbc index info of DuckDB has no column names + */ + @Override + public List> getIndexInfos(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName, String tableName) throws SQLException { + String catalog = getCatalog(databaseMetaData, catalogName); + List> indexInfos = new ArrayList<>(); + String indexSql = "SELECT index_name, is_unique, expressions FROM duckdb_indexes()" + + " WHERE database_name = ? AND schema_name = ? AND table_name = ? ORDER BY index_name"; + for (Map row : JdbcUtils.fetchAll(databaseMetaData.getConnection(), indexSql, + new Object[]{catalog, schemaName, tableName})) { + List columns = parseExpressions(String.valueOf(row.get("expressions"))); + if (columns == null) { + // Indexes on expressions are skipped + continue; + } + addIndexInfos(indexInfos, catalog, schemaName, tableName, (String) row.get("index_name"), + Boolean.TRUE.equals(row.get("is_unique")), columns); + } + String constraintSql = "SELECT constraint_name, constraint_column_names FROM duckdb_constraints()" + + " WHERE database_name = ? AND schema_name = ? AND table_name = ? AND constraint_type = 'UNIQUE'" + + " ORDER BY constraint_index"; + for (Map row : JdbcUtils.fetchAll(databaseMetaData.getConnection(), constraintSql, + new Object[]{catalog, schemaName, tableName})) { + List columns = toList(row.get("constraint_column_names")); + if (!columns.isEmpty()) { + addIndexInfos(indexInfos, catalog, schemaName, tableName, (String) row.get("constraint_name"), true, + columns); + } + } + return indexInfos; + } + + private static void addIndexInfos(List> indexInfos, String catalog, String schemaName, + String tableName, String indexName, boolean unique, List columns) { + for (int i = 0; i < columns.size(); i++) { + Map indexInfo = new LinkedHashMap<>(); + indexInfo.put("TABLE_CAT", catalog); + indexInfo.put("TABLE_SCHEM", schemaName); + indexInfo.put("TABLE_NAME", tableName); + indexInfo.put("NON_UNIQUE", !unique); + indexInfo.put("INDEX_QUALIFIER", null); + indexInfo.put("INDEX_NAME", indexName); + indexInfo.put("TYPE", (int) DatabaseMetaData.tableIndexOther); + indexInfo.put("ORDINAL_POSITION", i + 1); + indexInfo.put("COLUMN_NAME", columns.get(i)); + indexInfo.put("ASC_OR_DESC", "A"); + indexInfos.add(indexInfo); + } + } + + /** + * Column names of index expressions, e.g. ['"name"', 'dept_id'], or null if an expression is not a column + */ + static List parseExpressions(String expressions) { + String text = StringUtils.strip(StringUtils.defaultString(expressions).trim(), "[]"); + List columns = new ArrayList<>(); + for (String item : text.split(",")) { + String column = StringUtils.strip(item.trim(), "'").trim(); + if (column.startsWith("\"") && column.endsWith("\"") && column.length() > 1) { + column = column.substring(1, column.length() - 1).replace("\"\"", "\""); + } else if (!column.matches("[A-Za-z_][A-Za-z0-9_]*")) { + return null; + } + columns.add(column); + } + return columns.isEmpty() ? null : columns; + } + + private static List toList(Object value) throws SQLException { + List list = new ArrayList<>(); + if (value instanceof Array) { + Object array = ((Array) value).getArray(); + if (array instanceof Object[]) { + for (Object item : (Object[]) array) { + list.add(String.valueOf(item)); + } + } + } else if (value != null) { + for (String item : StringUtils.strip(value.toString(), "[]").split(",")) { + if (StringUtils.isNotBlank(item)) { + list.add(item.trim()); + } + } + } + return list; + } + + /** + * Types special to DuckDB get standard jdbc types, their type names are kept + */ + static void normalizeType(Map columnInfo) { + String typeName = StringUtils.upperCase((String) columnInfo.get("TYPE_NAME"), Locale.ENGLISH); + if (typeName == null) { + return; + } + switch (typeName) { + case "HUGEINT": + case "UHUGEINT": + case "VARINT": + case "BIGNUM": + // The widest DECIMAL of databases + setType(columnInfo, Types.NUMERIC, 38, 0); + break; + case "UBIGINT": + setType(columnInfo, Types.NUMERIC, 20, 0); + break; + case "UINTEGER": + setType(columnInfo, Types.BIGINT, 64, 0); + break; + case "USMALLINT": + setType(columnInfo, Types.INTEGER, 32, 0); + break; + case "UTINYINT": + setType(columnInfo, Types.SMALLINT, 16, 0); + break; + case "BIT": + // Bit strings, not booleans + setType(columnInfo, Types.VARCHAR, Integer.MAX_VALUE, 0); + break; + case "FLOAT": + setType(columnInfo, Types.REAL, 24, 0); + break; + case "TIME WITH TIME ZONE": + setType(columnInfo, Types.TIME_WITH_TIMEZONE, 21, 6); + break; + case "TIME": + setType(columnInfo, Types.TIME, 15, 6); + break; + case "TIMESTAMP_S": + setType(columnInfo, Types.TIMESTAMP, 19, 0); + break; + case "TIMESTAMP_MS": + setType(columnInfo, Types.TIMESTAMP, 23, 3); + break; + case "TIMESTAMP_NS": + setType(columnInfo, Types.TIMESTAMP, 29, 9); + break; + case "TIMESTAMP": + setType(columnInfo, Types.TIMESTAMP, 26, 6); + break; + case "TIMESTAMP WITH TIME ZONE": + setType(columnInfo, Types.TIMESTAMP_WITH_TIMEZONE, 32, 6); + break; + default: + if (isTextType(typeName)) { + // Nested types, enums, intervals and json are read as text + columnInfo.put("DATA_TYPE", isJsonOrUuid(typeName) ? Types.OTHER : Types.VARCHAR); + columnInfo.put("COLUMN_SIZE", Integer.MAX_VALUE); + } else if (isVarchar(typeName) && TableMetaData.getInt(columnInfo, "COLUMN_SIZE") <= 0) { + // Lengths of VARCHAR are not kept by DuckDB, they are unbounded like text + columnInfo.put("COLUMN_SIZE", Integer.MAX_VALUE); + } + break; + } + } + + private static boolean isVarchar(String typeName) { + return "VARCHAR".equals(typeName) || typeName.startsWith("VARCHAR(") || "TEXT".equals(typeName) + || "STRING".equals(typeName) || "CHAR".equals(typeName) || "BPCHAR".equals(typeName); + } + + private static void setType(Map columnInfo, int dataType, int columnSize, int scale) { + columnInfo.put("DATA_TYPE", dataType); + columnInfo.put("COLUMN_SIZE", columnSize); + columnInfo.put("DECIMAL_DIGITS", scale); + } + + private static boolean isJsonOrUuid(String typeName) { + return "JSON".equals(typeName) || "UUID".equals(typeName); + } + + /** + * Types which are read as text from DuckDB: nested types (lists, structs, maps, unions), enums, intervals, + * json and uuid + */ + static boolean isTextType(String typeName) { + String upper = StringUtils.upperCase(typeName, Locale.ENGLISH); + return upper != null && (upper.endsWith("]") || upper.startsWith("STRUCT") || upper.startsWith("MAP") + || upper.startsWith("UNION") || upper.startsWith("ENUM") || upper.equals("INTERVAL") + || upper.equals("JSON") || upper.equals("UUID") || upper.equals("BIT") + || upper.equals("TIME WITH TIME ZONE")); + } + + /** + * Types which are read as text, keeping their precision: nanoseconds of TIMESTAMP_NS + */ + static boolean isTextReadType(String typeName) { + return isTextType(typeName) || "TIMESTAMP_NS".equalsIgnoreCase(typeName); + } + + /** + * Identity column: nextval of the sequence named table_column_seq + */ + static boolean isIdentityDefault(String defaultValue, String tableName, String columnName) { + if (StringUtils.isBlank(defaultValue)) { + return false; + } + Matcher matcher = NEXTVAL.matcher(defaultValue.trim()); + return matcher.matches() && matcher.group(2).equalsIgnoreCase(tableName + "_" + columnName + "_seq"); + } + + /** + * Expression of a generated column in the create table statement: name type GENERATED ALWAYS AS(expr) + */ + static String getGenerationExpression(String tableSql, String columnName) { + if (StringUtils.isBlank(tableSql)) { + return null; + } + Pattern pattern = Pattern.compile("(?i)(?:\"" + Pattern.quote(columnName.replace("\"", "\"\"")) + "\"|\\b" + + Pattern.quote(columnName) + "\\b)\\s+[^,(]+?(?:\\([^)]*\\))?\\s+GENERATED\\s+ALWAYS\\s+AS\\s*\\("); + Matcher matcher = pattern.matcher(tableSql); + if (!matcher.find()) { + return null; + } + int start = matcher.end(); + int depth = 1; + for (int i = start; i < tableSql.length(); i++) { + char c = tableSql.charAt(i); + if (c == '(') { + depth++; + } else if (c == ')' && --depth == 0) { + return tableSql.substring(start, i).trim(); + } + } + return null; + } + + private String getTableSql(DatabaseMetaData databaseMetaData, String catalog, String schemaName, + String tableName) { + String sql = "SELECT sql FROM duckdb_tables() WHERE database_name = ? AND schema_name = ? AND table_name = ?"; + try { + Map row = JdbcUtils.fetchOne(databaseMetaData.getConnection(), sql, + new Object[]{catalog, schemaName, tableName}); + return row != null ? (String) row.get("sql") : null; + } catch (SQLException e) { + return null; + } + } + + /** + * Sequences of the schema except those of identity columns. START_VALUE is the next value. + */ + @Override + public List> getSequenceInfos(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName) throws SQLException { + String catalog = getCatalog(databaseMetaData, catalogName); + String sql = "SELECT sequence_name, start_value, increment_by, min_value, max_value, cycle, last_value" + + " FROM duckdb_sequences() WHERE database_name = ? AND schema_name = ? ORDER BY sequence_name"; + List> rows = JdbcUtils.fetchAll(databaseMetaData.getConnection(), sql, + new Object[]{catalog, schemaName}); + Set identitySequences = getIdentitySequenceNames(databaseMetaData, catalog, schemaName); + List> sequenceInfos = new ArrayList<>(); + for (Map row : rows) { + String sequenceName = (String) row.get("sequence_name"); + if (identitySequences.contains(sequenceName.toLowerCase(Locale.ENGLISH))) { + continue; + } + long increment = toLong(row.get("increment_by"), 1); + Object lastValue = row.get("last_value"); + long startValue = lastValue != null ? toLong(lastValue, 0) + increment : toLong(row.get("start_value"), 1); + Map sequenceInfo = new LinkedHashMap<>(); + sequenceInfo.put("SEQUENCE_NAME", sequenceName); + sequenceInfo.put("START_VALUE", startValue); + sequenceInfo.put("INCREMENT", increment); + sequenceInfo.put("MIN_VALUE", row.get("min_value")); + sequenceInfo.put("MAX_VALUE", row.get("max_value")); + sequenceInfo.put("CYCLE", Boolean.TRUE.equals(row.get("cycle"))); + sequenceInfo.put("CACHE_SIZE", null); + sequenceInfo.put("DATA_TYPE", null); + sequenceInfos.add(sequenceInfo); + } + return sequenceInfos; + } + + private Set getIdentitySequenceNames(DatabaseMetaData databaseMetaData, String catalog, + String schemaName) throws SQLException { + String sql = "SELECT table_name, column_name, column_default FROM duckdb_columns()" + + " WHERE database_name = ? AND schema_name = ? AND column_default LIKE 'nextval(%'"; + Set names = new HashSet<>(); + for (Map row : JdbcUtils.fetchAll(databaseMetaData.getConnection(), sql, + new Object[]{catalog, schemaName})) { + String tableName = (String) row.get("table_name"); + String columnName = (String) row.get("column_name"); + if (isIdentityDefault((String) row.get("column_default"), tableName, columnName)) { + names.add((tableName + "_" + columnName + "_seq").toLowerCase(Locale.ENGLISH)); + } + } + return names; + } + + private static long toLong(Object value, long defaultValue) { + return value instanceof Number ? ((Number) value).longValue() : defaultValue; + } + + private static String getCatalog(DatabaseMetaData databaseMetaData, String catalogName) throws SQLException { + return StringUtils.isNotBlank(catalogName) ? catalogName : databaseMetaData.getConnection().getCatalog(); + } +} diff --git a/src/main/java/com/github/sqljam/impexp/db/H2Dialect.java b/src/main/java/com/github/sqljam/impexp/db/H2Dialect.java index a3594d7..41f6dfc 100644 --- a/src/main/java/com/github/sqljam/impexp/db/H2Dialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/H2Dialect.java @@ -137,6 +137,9 @@ protected String getSpecialTypeName(int dataType, String sourceTypeName, int col int fraction = Math.min(Math.max(columnScale, 0), 9); return "timestamp(" + fraction + ")"; } + if (dataType == Types.TIME && columnScale > 0) { + return "time(" + Math.min(columnScale, 9) + ")"; + } if ((dataType == Types.VARCHAR || dataType == Types.NVARCHAR) && columnSize <= 0) { return "clob"; } @@ -285,4 +288,15 @@ public boolean isSingleIncrementalColumn() { public String getCreateSchemaIfNotExistsStatement(String schema) { return String.format("CREATE SCHEMA IF NOT EXISTS %s", quoteIdentifier(schema)); } + + /** + * Values of ROW types are read as text + */ + @Override + public String getSelectColumnExpression(String columnName, String typeName) { + if (H2MetaDataOperations.isRowType(typeName)) { + return String.format("CAST(%1$s AS VARCHAR) AS %1$s", quoteIdentifier(columnName)); + } + return super.getSelectColumnExpression(columnName, typeName); + } } diff --git a/src/main/java/com/github/sqljam/impexp/db/H2MetaDataOperations.java b/src/main/java/com/github/sqljam/impexp/db/H2MetaDataOperations.java index 73e9986..c582f6a 100644 --- a/src/main/java/com/github/sqljam/impexp/db/H2MetaDataOperations.java +++ b/src/main/java/com/github/sqljam/impexp/db/H2MetaDataOperations.java @@ -15,14 +15,16 @@ */ package com.github.sqljam.impexp.db; -import com.github.sqljam.jdbc.JdbcUtils; import java.sql.DatabaseMetaData; import java.sql.SQLException; +import java.sql.Types; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; import com.github.sqljam.impexp.MetaDataOperations; +import com.github.sqljam.jdbc.JdbcUtils; /** * @Description: H2MetaDataOperations reads tables, generated columns and sequences of H2 @@ -67,11 +69,25 @@ public List> getSequenceInfos(DatabaseMetaData databaseMetaD return sequenceInfos; } + /** + * ROW(...) types of H2 + */ + static boolean isRowType(String typeName) { + return StringUtils.startsWithIgnoreCase(StringUtils.trim(typeName), "ROW("); + } + @Override public List> getColumnInfos(DatabaseMetaData databaseMetaData, String catalogName, String schemaName, String tableName) throws SQLException { List> columnInfos = super.getColumnInfos(databaseMetaData, catalogName, schemaName, tableName); + for (Map columnInfo : columnInfos) { + if (isRowType((String) columnInfo.get("TYPE_NAME"))) { + // Values of ROW are text, they are not parameters of other databases + columnInfo.put("DATA_TYPE", Types.VARCHAR); + columnInfo.put("COLUMN_SIZE", Integer.MAX_VALUE); + } + } String sql = "SELECT COLUMN_NAME, GENERATION_EXPRESSION FROM INFORMATION_SCHEMA.COLUMNS" + " WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND IS_GENERATED = 'ALWAYS'"; List> generatedColumns; diff --git a/src/main/java/com/github/sqljam/impexp/db/MariaDBDialect.java b/src/main/java/com/github/sqljam/impexp/db/MariaDBDialect.java new file mode 100644 index 0000000..d68c840 --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/db/MariaDBDialect.java @@ -0,0 +1,90 @@ +/* + * 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.db; + +import java.sql.Types; + +import com.github.sqljam.impexp.DbType; + +/** + * @Description: MariaDBDialect generates ddl/dml of MariaDB, which is compatible with MySQL. Differences of MariaDB: + * sequences (CREATE SEQUENCE, nextval defaults), native UUID, INET4 and INET6 types, JSON stored as + * LONGTEXT with a json_valid check. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public class MariaDBDialect extends MySQLDialect { + + @Override + public DbType getDbType() { + return DbType.MARIADB; + } + + /** + * Native types of MariaDB are kept for MariaDB, UUID values of other databases are UUID + */ + @Override + protected String getSpecialTypeName(int dataType, String sourceTypeName, int columnSize, int columnScale) { + String baseTypeName = sourceTypeName.replaceAll("\\(.*\\)", "").trim(); + switch (baseTypeName) { + case "uuid": + case "uniqueidentifier": + return "uuid"; + case "inet4": + case "inet6": + return isCrossDatabase() ? "varchar(45)" : baseTypeName; + default: + break; + } + return super.getSpecialTypeName(dataType, sourceTypeName, columnSize, columnScale); + } + + @Override + protected String getVectorTextFunction() { + return "VEC_ToText"; + } + + @Override + public boolean isSequenceSupported() { + return true; + } + + @Override + public String[] getStatementBeforeSequenceCreated(String catalog, String schema, String sequenceName) { + return new String[]{String.format("DROP SEQUENCE IF EXISTS %s", + getQualifiedSequenceName(catalog, schema, sequenceName))}; + } + + /** + * MAXVALUE of MariaDB is a BIGINT, larger values of other databases (e.g. NUMBER of Oracle) are left out + */ + @Override + public String getCreateSequenceStatement(String catalog, String schema, String sequenceName, long startValue, + long increment, Long minValue, Long maxValue, boolean cycle, + Long cacheSize, String dataType) { + return super.getCreateSequenceStatement(catalog, schema, sequenceName, startValue, increment, minValue, + maxValue != null && maxValue >= Long.MAX_VALUE - 1 ? null : maxValue, cycle, cacheSize, dataType); + } + + /** + * NULL of columns reported as OTHER (UUID, INET4, INET6) is bound as VARCHAR + */ + @Override + public int getNullSqlType(int sqlType) { + return sqlType == Types.OTHER ? Types.VARCHAR : super.getNullSqlType(sqlType); + } +} diff --git a/src/main/java/com/github/sqljam/impexp/db/MariaDBMetaDataOperations.java b/src/main/java/com/github/sqljam/impexp/db/MariaDBMetaDataOperations.java new file mode 100644 index 0000000..b22f4fb --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/db/MariaDBMetaDataOperations.java @@ -0,0 +1,149 @@ +/* + * 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.db; + +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.jdbc.JdbcUtils; + +/** + * @Description: MariaDBMetaDataOperations reads MariaDB like MySQL, with the differences of MariaDB: column defaults + * of information_schema are sql already (quoted literals, NULL, current_timestamp(), nextval), JSON + * columns are LONGTEXT with a json_valid check, sequences are tables of type SEQUENCE. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public class MariaDBMetaDataOperations extends MySQLMetaDataOperations { + + private static final Set SPATIAL_TYPES = new HashSet<>(Arrays.asList("geometry", "point", + "linestring", "polygon", "multipoint", "multilinestring", "multipolygon", "geometrycollection")); + private static final Pattern FRACTION = Pattern.compile("^(?:datetime|timestamp|time)\\((\\d)\\)$"); + private static final Pattern JSON_VALID = Pattern.compile("(?i)^\\s*json_valid\\(\\s*`?([^`)]+)`?\\s*\\)\\s*$"); + + /** + * JSON columns of MariaDB are LONGTEXT checked by json_valid, they are JSON of other databases + */ + @Override + public List> getColumnInfos(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName, String tableName) throws SQLException { + List> columnInfos = super.getColumnInfos(databaseMetaData, catalogName, schemaName, + tableName); + if (columnInfos.isEmpty()) { + return columnInfos; + } + String sql = "SELECT CHECK_CLAUSE FROM information_schema.CHECK_CONSTRAINTS" + + " WHERE CONSTRAINT_SCHEMA = ? AND TABLE_NAME = ?"; + Set jsonColumns = new HashSet<>(); + for (Map row : JdbcUtils.fetchAll(databaseMetaData.getConnection(), sql, + new Object[]{catalogName, tableName})) { + Matcher matcher = JSON_VALID.matcher(StringUtils.defaultString((String) row.get("CHECK_CLAUSE"))); + if (matcher.matches()) { + jsonColumns.add(matcher.group(1)); + } + } + for (Map columnInfo : columnInfos) { + String typeName = StringUtils.defaultString((String) columnInfo.get("TYPE_NAME")) + .toLowerCase(Locale.ENGLISH); + if (jsonColumns.contains((String) columnInfo.get("COLUMN_NAME"))) { + columnInfo.put("TYPE_NAME", "json"); + } + // The driver reports no fractional seconds, they are in the column type, e.g. datetime(6) + Matcher fraction = FRACTION.matcher(typeName); + if (fraction.matches()) { + columnInfo.put("DECIMAL_DIGITS", Integer.parseInt(fraction.group(1))); + } + if (SPATIAL_TYPES.contains(typeName)) { + // Spatial values are binary (WKB) as reported by the driver of MySQL + columnInfo.put("DATA_TYPE", Types.BINARY); + columnInfo.put("COLUMN_SIZE", 65535); + } else if (typeName.equals("inet4") || typeName.equals("inet6")) { + columnInfo.put("DATA_TYPE", Types.VARCHAR); + columnInfo.put("COLUMN_SIZE", typeName.equals("inet4") ? 15 : 45); + } else if (typeName.equals("uuid")) { + columnInfo.put("COLUMN_SIZE", 36); + } + } + return columnInfos; + } + + /** + * COLUMN_DEFAULT of MariaDB is sql: 'text', 1.5, NULL, current_timestamp(), nextval(`db`.`seq`) + */ + @Override + protected String getColumnDefault(String defaultValue, String extra, int dataType, String columnType) { + if (defaultValue == null || "NULL".equalsIgnoreCase(defaultValue.trim())) { + return null; + } + String result = defaultValue.trim(); + String lowerExtra = StringUtils.defaultString(extra).toLowerCase(Locale.ENGLISH); + int index = lowerExtra.indexOf("on update "); + if (index >= 0) { + result += " " + extra.substring(index).trim(); + } + return result; + } + + /** + * Sequences of the database (tables of type SEQUENCE), START_VALUE is the next value + */ + @Override + public List> getSequenceInfos(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName) throws SQLException { + String catalog = StringUtils.isNotBlank(catalogName) ? catalogName + : databaseMetaData.getConnection().getCatalog(); + List> sequenceInfos = new ArrayList<>(); + String sql = "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?" + + " AND TABLE_TYPE = 'SEQUENCE' ORDER BY TABLE_NAME"; + for (Map row : JdbcUtils.fetchAll(databaseMetaData.getConnection(), sql, + new Object[]{catalog})) { + String sequenceName = (String) row.get("TABLE_NAME"); + Map state = JdbcUtils.fetchOne(databaseMetaData.getConnection(), String.format( + "SELECT next_not_cached_value, minimum_value, maximum_value, increment, cycle_option, cache_size" + + " FROM `%s`.`%s`", catalog.replace("`", "``"), sequenceName.replace("`", "``")), + new Object[0]); + if (state == null) { + continue; + } + Map sequenceInfo = new LinkedHashMap<>(); + sequenceInfo.put("SEQUENCE_NAME", sequenceName); + sequenceInfo.put("START_VALUE", state.get("next_not_cached_value")); + sequenceInfo.put("INCREMENT", state.get("increment")); + sequenceInfo.put("MIN_VALUE", state.get("minimum_value")); + sequenceInfo.put("MAX_VALUE", state.get("maximum_value")); + Object cycle = state.get("cycle_option"); + sequenceInfo.put("CYCLE", cycle instanceof Number ? ((Number) cycle).intValue() != 0 + : Boolean.TRUE.equals(cycle)); + sequenceInfo.put("CACHE_SIZE", state.get("cache_size")); + sequenceInfo.put("DATA_TYPE", "bigint"); + sequenceInfos.add(sequenceInfo); + } + return sequenceInfos; + } +} diff --git a/src/main/java/com/github/sqljam/impexp/db/MySQLDialect.java b/src/main/java/com/github/sqljam/impexp/db/MySQLDialect.java index 1e9353c..554bd1f 100644 --- a/src/main/java/com/github/sqljam/impexp/db/MySQLDialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/MySQLDialect.java @@ -15,11 +15,15 @@ */ package com.github.sqljam.impexp.db; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.sql.Types; +import java.util.Arrays; import java.util.Locale; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; -import java.util.Arrays; import org.apache.commons.lang3.StringUtils; import com.github.sqljam.impexp.DbType; @@ -223,6 +227,54 @@ protected String getSpecialTypeName(int dataType, String sourceTypeName, int col return null; } + /** + * Vectors are read as text for other databases and for viewing, their binary is kept for the same database type + */ + @Override + public String getSelectColumnExpression(String columnName, String typeName) { + if (MySQLMetaDataOperations.getVectorDimensions(typeName) > 0 && getReadTargetDbType() != getDbType()) { + return String.format("%s(%s) AS %s", getVectorTextFunction(), quoteIdentifier(columnName), + quoteIdentifier(columnName)); + } + return super.getSelectColumnExpression(columnName, typeName); + } + + /** + * VECTOR columns of the target are written by their binary, vectors of other databases are text + */ + @Override + public boolean isTargetTypeConversionRequired() { + return true; + } + + /** + * Text of a vector, e.g. [1,2.5,-3], is converted to the binary of VECTOR: little endian floats + */ + @Override + public Object getTargetJdbcValue(Object value, String targetTypeName) { + if (!(value instanceof CharSequence) || !"vector".equalsIgnoreCase(StringUtils.trim(targetTypeName))) { + return value; + } + String text = value.toString().trim(); + if (!text.startsWith("[") || !text.endsWith("]")) { + return value; + } + String body = text.substring(1, text.length() - 1).trim(); + String[] items = body.isEmpty() ? new String[0] : body.split(","); + ByteBuffer buffer = ByteBuffer.allocate(items.length * 4).order(ByteOrder.LITTLE_ENDIAN); + for (String item : items) { + buffer.putFloat(Float.parseFloat(item.trim())); + } + return buffer.array(); + } + + /** + * Function converting a vector to text + */ + protected String getVectorTextFunction() { + return "VECTOR_TO_STRING"; + } + @Override public String getCreateUserStatement(String username, String password) { return String.format("CREATE USER IF NOT EXISTS '%s'@'%%' IDENTIFIED BY '%s'", username, password); @@ -361,7 +413,7 @@ public String getColumnStatement(String catalog, String schema, String tableName defaultValue = null; } // Fractional seconds precision of CURRENT_TIMESTAMP must match the column - java.util.regex.Matcher precision = java.util.regex.Pattern.compile("^(datetime|timestamp)\\((\\d)\\)$") + Matcher precision = Pattern.compile("^(datetime|timestamp)\\((\\d)\\)$") .matcher(lowerTypeName); if (defaultValue != null && precision.matches() && defaultValue.equalsIgnoreCase(getCurrentTimestampExpression())) { diff --git a/src/main/java/com/github/sqljam/impexp/db/MySQLMetaDataOperations.java b/src/main/java/com/github/sqljam/impexp/db/MySQLMetaDataOperations.java index 2be8cab..609c506 100644 --- a/src/main/java/com/github/sqljam/impexp/db/MySQLMetaDataOperations.java +++ b/src/main/java/com/github/sqljam/impexp/db/MySQLMetaDataOperations.java @@ -118,6 +118,12 @@ public List> getColumnInfos(DatabaseMetaData databaseMetaDat if (StringUtils.isNotBlank(columnType)) { columnInfo.put("TYPE_NAME", columnType); } + int dimensions = getVectorDimensions(columnType); + if (dimensions > 0) { + // Vectors are text of other databases, e.g. [1.00000e+00,2.50000e+00] + columnInfo.put("DATA_TYPE", Types.VARCHAR); + columnInfo.put("COLUMN_SIZE", dimensions * 16 + 2); + } String extra = StringUtils.defaultString((String) columnDetail.get("EXTRA")); String expression = (String) columnDetail.get("GENERATION_EXPRESSION"); if (StringUtils.isNotBlank(expression)) { @@ -129,12 +135,33 @@ public List> getColumnInfos(DatabaseMetaData databaseMetaDat columnInfo.put("COLUMN_DEF", null); continue; } - columnInfo.put("COLUMN_DEF", getDefaultValue((String) columnDetail.get("COLUMN_DEFAULT"), extra, + // Connector/J reports columns with expression defaults (EXTRA DEFAULT_GENERATED) as generated + columnInfo.put("IS_GENERATEDCOLUMN", "NO"); + columnInfo.put("COLUMN_DEF", getColumnDefault((String) columnDetail.get("COLUMN_DEFAULT"), extra, TableMetaData.getInt(columnInfo, "DATA_TYPE"), columnType)); } return columnInfos; } + /** + * Default value of a column as sql, by COLUMN_DEFAULT and EXTRA of information_schema + */ + protected String getColumnDefault(String defaultValue, String extra, int dataType, String columnType) { + return getDefaultValue(defaultValue, extra, dataType, columnType); + } + + /** + * Dimensions of a VECTOR column, 0 for other types + */ + static int getVectorDimensions(String columnType) { + String type = StringUtils.defaultString(columnType).trim().toLowerCase(Locale.ENGLISH); + if (!type.startsWith("vector")) { + return 0; + } + String digits = type.replaceAll("\\D", ""); + return digits.isEmpty() ? 2048 : Integer.parseInt(digits); + } + /** * MySQL returns literal default values without quotes */ diff --git a/src/main/java/com/github/sqljam/impexp/db/OracleDialect.java b/src/main/java/com/github/sqljam/impexp/db/OracleDialect.java index ff268cf..aaddba7 100644 --- a/src/main/java/com/github/sqljam/impexp/db/OracleDialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/OracleDialect.java @@ -15,8 +15,10 @@ */ package com.github.sqljam.impexp.db; +import java.math.BigDecimal; import java.sql.Types; import java.time.LocalTime; +import java.time.OffsetDateTime; import java.util.Arrays; import java.util.Map; @@ -123,6 +125,7 @@ protected String getSpecialTypeName(int dataType, String sourceTypeName, int col case "blob": case "binary_float": case "binary_double": + case "vector": case "xmltype": case "json": case "long": @@ -403,10 +406,19 @@ public Object getJdbcValue(Object value) { } else if (value instanceof LocalTime) { // Oracle has no TIME type, time values are stored as text return formatTime((LocalTime) value); + } else if ((value instanceof Double || value instanceof Float) + && Double.isFinite(((Number) value).doubleValue())) { + // The driver binds doubles as NUMBER of 15 digits, the shortest decimal keeps the exact value + return new BigDecimal(value.toString()); } return value; } + @Override + public boolean isEmptyValueNull() { + return true; + } + @Override public boolean isSequenceSupported() { return true; @@ -442,7 +454,7 @@ public String getEmptyLobLiteral(boolean binary) { } @Override - public String getTimestampWithTimeZoneLiteral(java.time.OffsetDateTime value) { + public String getTimestampWithTimeZoneLiteral(OffsetDateTime value) { return "TIMESTAMP '" + formatTimestamp(value.toLocalDateTime()) + " " + value.getOffset() + "'"; } @@ -460,6 +472,10 @@ public String getSelectColumnExpression(String columnName, String typeName) { if (StringUtils.endsWithIgnoreCase(typeName, "XMLTYPE")) { return String.format("XMLSERIALIZE(CONTENT %s AS CLOB) AS %s", column, column); } + if ("VECTOR".equalsIgnoreCase(typeName)) { + // Text of vectors is accepted by every database, Oracle reads it back into vectors + return String.format("FROM_VECTOR(%s RETURNING CLOB) AS %s", column, column); + } return column; } } diff --git a/src/main/java/com/github/sqljam/impexp/db/OracleMetaDataOperations.java b/src/main/java/com/github/sqljam/impexp/db/OracleMetaDataOperations.java index b639b11..cfde2ac 100644 --- a/src/main/java/com/github/sqljam/impexp/db/OracleMetaDataOperations.java +++ b/src/main/java/com/github/sqljam/impexp/db/OracleMetaDataOperations.java @@ -18,6 +18,7 @@ import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.SQLException; +import java.sql.Types; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -215,6 +216,11 @@ public List> getColumnInfos(DatabaseMetaData databaseMetaDat if (StringUtils.isBlank((String) columnInfo.get("REMARKS"))) { columnInfo.put("REMARKS", columnDetail.get("COMMENTS")); } + if ("VECTOR".equalsIgnoreCase((String) columnInfo.get("TYPE_NAME"))) { + // Vectors are text of other databases, e.g. [1.0E+000,2.5E+000] + columnInfo.put("DATA_TYPE", Types.LONGVARCHAR); + columnInfo.put("COLUMN_SIZE", Integer.MAX_VALUE); + } } return columnInfos; } diff --git a/src/main/java/com/github/sqljam/impexp/db/PostgreSQLDialect.java b/src/main/java/com/github/sqljam/impexp/db/PostgreSQLDialect.java index 53a7840..110e608 100644 --- a/src/main/java/com/github/sqljam/impexp/db/PostgreSQLDialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/PostgreSQLDialect.java @@ -187,6 +187,17 @@ protected String getSpecialTypeName(int dataType, String sourceTypeName, int col } } + /** + * Enum and domain types are created in the target schema if they do not exist + */ + @Override + public String getCreateUserTypeStatement(String catalog, String schema, Map userType) { + String typeName = getUserTypeName(catalog, schema, (String) userType.get("NAME")); + String create = String.format("CREATE %s %s AS %s", "DOMAIN".equals(userType.get("KIND")) ? "DOMAIN" : "TYPE", + typeName, userType.get("DEFINITION")); + return String.format("DO $$ BEGIN %s; EXCEPTION WHEN duplicate_object THEN NULL; END $$", create); + } + @Override public String getCreateUserStatement(String username, String password) { return String.format("CREATE USER %s WITH PASSWORD '%s'", username, password); @@ -389,7 +400,7 @@ public String getGeneratedColumnStatement(String catalog, String schema, String */ @Override public int getNullSqlType(int sqlType) { - return java.sql.Types.OTHER; + return Types.OTHER; } @Override diff --git a/src/main/java/com/github/sqljam/impexp/db/PostgreSQLMetaDataOperations.java b/src/main/java/com/github/sqljam/impexp/db/PostgreSQLMetaDataOperations.java index 8554566..b5899ea 100644 --- a/src/main/java/com/github/sqljam/impexp/db/PostgreSQLMetaDataOperations.java +++ b/src/main/java/com/github/sqljam/impexp/db/PostgreSQLMetaDataOperations.java @@ -17,11 +17,15 @@ import java.sql.DatabaseMetaData; import java.sql.SQLException; +import java.sql.Types; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.commons.collections4.CollectionUtils; @@ -148,9 +152,128 @@ public List> getColumnInfos(DatabaseMetaData databaseMetaDat columnInfo.put("GENERATION_TYPE", "STORED"); } } + applyUserTypes(databaseMetaData, schemaName, tableName, columnInfos); return columnInfos; } + /** + * Name, precision and scale of a type, e.g. numeric(10,2), timestamp(3) without time zone + */ + private static final Pattern BASE_TYPE = Pattern.compile("^([a-z ]+?)\\s*(?:\\((\\d+)(?:\\s*,\\s*(\\d+))?\\))?(.*)$"); + + /** + * Columns of enum and domain types: the definition of the type is kept for PostgreSQL targets, enums are text + * and domains are their base types for other databases + */ + private void applyUserTypes(DatabaseMetaData databaseMetaData, String schemaName, String tableName, + List> columnInfos) throws SQLException { + String sql = "SELECT a.attname, t.typname, t.typtype, tn.nspname," + + " format_type(t.typbasetype, t.typtypmod) AS base_type," + + " (SELECT string_agg(pg_get_constraintdef(k.oid), ' ') FROM pg_constraint k" + + " WHERE k.contypid = t.oid) AS checks," + + " (SELECT string_agg(quote_literal(e.enumlabel), ', ' ORDER BY e.enumsortorder) FROM pg_enum e" + + " WHERE e.enumtypid = t.oid) AS labels," + + " (SELECT max(length(e.enumlabel)) FROM pg_enum e WHERE e.enumtypid = t.oid) AS label_length" + + " FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid" + + " JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_type t ON t.oid = a.atttypid" + + " JOIN pg_namespace tn ON tn.oid = t.typnamespace" + + " WHERE n.nspname = ? AND c.relname = ? AND a.attnum > 0 AND NOT a.attisdropped" + + " AND t.typtype IN ('e', 'd')"; + Map> userTypes = JdbcUtils.fetchAll(databaseMetaData.getConnection(), sql, + new Object[]{schemaName, tableName}).stream().collect(Collectors.toMap( + row -> (String) row.get("attname"), Function.identity(), (a, b) -> a)); + if (userTypes.isEmpty()) { + return; + } + for (Map columnInfo : columnInfos) { + Map userType = userTypes.get((String) columnInfo.get("COLUMN_NAME")); + if (userType == null) { + continue; + } + String typeName = (String) userType.get("typname"); + boolean enumType = "e".equals(String.valueOf(userType.get("typtype"))); + String definition = enumType ? String.format("ENUM (%s)", userType.get("labels")) + : String.format("%s%s", userType.get("base_type"), StringUtils.isNotBlank( + (String) userType.get("checks")) ? " " + userType.get("checks") : ""); + Map type = new LinkedHashMap<>(); + type.put("KIND", enumType ? "ENUM" : "DOMAIN"); + type.put("NAME", typeName); + type.put("DEFINITION", definition); + columnInfo.put("USER_TYPE", type); + columnInfo.put("TYPE_NAME", typeName); + if (enumType) { + Object length = userType.get("label_length"); + columnInfo.put("DATA_TYPE", Types.VARCHAR); + columnInfo.put("COLUMN_SIZE", length instanceof Number ? ((Number) length).intValue() : 255); + columnInfo.put("DECIMAL_DIGITS", 0); + } else { + applyBaseType(columnInfo, (String) userType.get("base_type")); + } + } + } + + /** + * Jdbc type, size and scale of the base type of a domain, e.g. character varying(100), numeric(10,2) + */ + static void applyBaseType(Map columnInfo, String baseType) { + String type = StringUtils.defaultString(baseType).trim().toLowerCase(Locale.ENGLISH); + Matcher matcher = BASE_TYPE.matcher(type); + String name = matcher.matches() ? matcher.group(1).trim() : type; + int size = matcher.matches() && matcher.group(2) != null ? Integer.parseInt(matcher.group(2)) : 0; + int scale = matcher.matches() && matcher.group(3) != null ? Integer.parseInt(matcher.group(3)) : 0; + int dataType; + switch (name) { + case "character varying": + dataType = Types.VARCHAR; + size = size > 0 ? size : Integer.MAX_VALUE; + break; + case "character": + dataType = Types.CHAR; + size = Math.max(size, 1); + break; + case "smallint": + dataType = Types.SMALLINT; + break; + case "integer": + dataType = Types.INTEGER; + break; + case "bigint": + dataType = Types.BIGINT; + break; + case "numeric": + dataType = Types.NUMERIC; + break; + case "real": + dataType = Types.REAL; + break; + case "double precision": + dataType = Types.DOUBLE; + break; + case "boolean": + dataType = Types.BOOLEAN; + break; + case "date": + dataType = Types.DATE; + break; + case "timestamp without time zone": + case "timestamp": + dataType = Types.TIMESTAMP; + scale = size > 0 ? size : 6; + break; + case "timestamp with time zone": + dataType = Types.TIMESTAMP_WITH_TIMEZONE; + scale = size > 0 ? size : 6; + break; + default: + dataType = Types.VARCHAR; + size = Integer.MAX_VALUE; + break; + } + columnInfo.put("DATA_TYPE", dataType); + columnInfo.put("COLUMN_SIZE", size); + columnInfo.put("DECIMAL_DIGITS", scale); + } + @Override public List> getIndexInfos(DatabaseMetaData databaseMetaData, String catalogName, String schemaName, String tableName) throws SQLException { diff --git a/src/main/java/com/github/sqljam/impexp/db/SQLiteDialect.java b/src/main/java/com/github/sqljam/impexp/db/SQLiteDialect.java index 13dbeda..3e35248 100644 --- a/src/main/java/com/github/sqljam/impexp/db/SQLiteDialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/SQLiteDialect.java @@ -19,7 +19,9 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.OffsetDateTime; +import org.apache.commons.lang3.StringUtils; import com.github.sqljam.impexp.DbType; import com.github.sqljam.impexp.Dialect; import com.github.sqljam.impexp.StringHelper; @@ -294,11 +296,24 @@ public Object getJdbcValue(Object value, int sourceDataType, String sourceTypeNa Object result = super.getJdbcValue(value, sourceDataType, sourceTypeName, columnSize); if (result instanceof LocalDateTime) { return formatTimestamp((LocalDateTime) result); - } else if (result instanceof LocalDate || result instanceof java.time.OffsetDateTime) { + } else if (result instanceof LocalDate || result instanceof OffsetDateTime) { return result.toString(); } else if (result instanceof LocalTime) { return formatTime((LocalTime) result); } return result; } + + /** + * Values of columns without type (or ANY) are read as text for other databases, they keep their storage class + * for SQLite + */ + @Override + public String getSelectColumnExpression(String columnName, String typeName) { + String type = StringUtils.trimToEmpty(typeName); + if ((type.isEmpty() || "ANY".equalsIgnoreCase(type)) && getReadTargetDbType() != DbType.SQLITE) { + return String.format("CAST(%1$s AS TEXT) AS %1$s", quoteIdentifier(columnName)); + } + return super.getSelectColumnExpression(columnName, typeName); + } } diff --git a/src/main/java/com/github/sqljam/impexp/db/SQLiteMetaDataOperations.java b/src/main/java/com/github/sqljam/impexp/db/SQLiteMetaDataOperations.java index 17c48d7..485ecef 100644 --- a/src/main/java/com/github/sqljam/impexp/db/SQLiteMetaDataOperations.java +++ b/src/main/java/com/github/sqljam/impexp/db/SQLiteMetaDataOperations.java @@ -22,10 +22,13 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import com.github.sqljam.impexp.MetaDataOperations; +import com.github.sqljam.impexp.TableMetaData; import com.github.sqljam.jdbc.JdbcUtils; import com.github.sqljam.utils.CaseInsensitiveMap; @@ -91,8 +94,8 @@ public static void resolveColumnType(Map columnInfo) { int[] sizes = parseSizes(declaredType); if (declaredType.indexOf('(') < 0) { // Driver reports declared size as COLUMN_SIZE, unknown size as 2000000000 - int columnSize = com.github.sqljam.impexp.TableMetaData.getInt(columnInfo, "COLUMN_SIZE"); - int scale = com.github.sqljam.impexp.TableMetaData.getInt(columnInfo, "DECIMAL_DIGITS"); + int columnSize = TableMetaData.getInt(columnInfo, "COLUMN_SIZE"); + int scale = TableMetaData.getInt(columnInfo, "DECIMAL_DIGITS"); sizes = columnSize > 0 && columnSize < 2000000000 ? new int[]{columnSize, scale} : new int[]{0, 0}; } int dataType; @@ -159,7 +162,11 @@ public static void resolveColumnType(Map columnInfo) { dataType = Types.BIGINT; } else if (baseType.contains("CHAR") || baseType.contains("CLOB") || baseType.contains("TEXT")) { dataType = columnSize > 0 ? Types.VARCHAR : Types.CLOB; - } else if (baseType.isEmpty() || baseType.contains("BLOB")) { + } else if (baseType.isEmpty() || "ANY".equals(baseType)) { + // Columns without type (or ANY) keep values of any type, they are text of other databases + dataType = Types.VARCHAR; + columnSize = Integer.MAX_VALUE; + } else if (baseType.contains("BLOB")) { dataType = Types.BLOB; } else if (baseType.contains("REAL") || baseType.contains("FLOA") || baseType.contains("DOUB")) { dataType = Types.DOUBLE; @@ -177,8 +184,8 @@ public static void resolveColumnType(Map columnInfo) { * Expression and type (STORED/VIRTUAL) of a generated column parsed from CREATE TABLE statement */ public static String[] parseGeneration(String createSql, String columnName) { - java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("(?is)[\\s(,][\"`\\[]?" - + java.util.regex.Pattern.quote(columnName) + "[\"`\\]]?\\s[^,]*?\\bAS\\s*\\(").matcher(createSql); + Matcher matcher = Pattern.compile("(?is)[\\s(,][\"`\\[]?" + + Pattern.quote(columnName) + "[\"`\\]]?\\s[^,]*?\\bAS\\s*\\(").matcher(createSql); if (!matcher.find()) { return null; } diff --git a/src/main/java/com/github/sqljam/jdbc/JdbcUtils.java b/src/main/java/com/github/sqljam/jdbc/JdbcUtils.java index 4601900..23956de 100644 --- a/src/main/java/com/github/sqljam/jdbc/JdbcUtils.java +++ b/src/main/java/com/github/sqljam/jdbc/JdbcUtils.java @@ -15,23 +15,40 @@ */ package com.github.sqljam.jdbc; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.InetAddress; +import java.sql.Array; +import java.sql.Blob; +import java.sql.Clob; import java.sql.Connection; import java.sql.DatabaseMetaData; +import java.sql.Date; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; import java.sql.SQLXML; import java.sql.Statement; +import java.sql.Time; +import java.sql.Timestamp; import java.sql.Types; -import java.sql.Blob; -import java.sql.Clob; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.Year; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; + import org.apache.commons.lang3.StringUtils; import com.github.sqljam.utils.CaseInsensitiveMap; import com.github.sqljam.utils.ConvertUtils; @@ -362,7 +379,7 @@ public static Object getColumnValue(ResultSet rs, int columnIndex, int columnTyp */ public static Object getColumnValue(ResultSet rs, int columnIndex, int columnType, String typeName) throws SQLException { - String lowerTypeName = typeName != null ? typeName.toLowerCase(java.util.Locale.ENGLISH) : ""; + String lowerTypeName = typeName != null ? typeName.toLowerCase(Locale.ENGLISH) : ""; switch (lowerTypeName) { case "money": // PostgreSQL money is formatted with currency symbol and group separators, e.g. $1,234.56 @@ -408,9 +425,9 @@ public static Object getColumnValue(ResultSet rs, int columnIndex, int columnTyp case -102: // oracle TIMESTAMP WITH LOCAL TIME ZONE try { // Date/time without time zone are read as local values to keep their wall clock time - value = columnType == Types.DATE ? getTemporal(rs, columnIndex, java.time.LocalDate.class) - : columnType == Types.TIME ? getTemporal(rs, columnIndex, java.time.LocalTime.class) - : getTemporal(rs, columnIndex, java.time.LocalDateTime.class); + value = columnType == Types.DATE ? getTemporal(rs, columnIndex, LocalDate.class) + : columnType == Types.TIME ? getTemporal(rs, columnIndex, LocalTime.class) + : getTemporal(rs, columnIndex, LocalDateTime.class); } catch (SQLException e) { // Dates stored as text (SQLite) in other formats value = rs.getString(columnIndex); @@ -422,7 +439,7 @@ public static Object getColumnValue(ResultSet rs, int columnIndex, int columnTyp case Types.TIMESTAMP_WITH_TIMEZONE: case -101: // oracle TIMESTAMP WITH TIME ZONE case -155: // sql server DATETIMEOFFSET - value = rs.getObject(columnIndex, java.time.OffsetDateTime.class); + value = rs.getObject(columnIndex, OffsetDateTime.class); break; case Types.SQLXML: SQLXML xml = rs.getSQLXML(columnIndex); @@ -439,16 +456,16 @@ private static Object getTemporal(ResultSet rs, int columnIndex, Class type) try { return rs.getObject(columnIndex, type); } catch (SQLException | RuntimeException e) { - if (type == java.time.LocalDate.class) { + if (type == LocalDate.class) { return rs.getDate(columnIndex); - } else if (type == java.time.LocalTime.class) { + } else if (type == LocalTime.class) { return rs.getTime(columnIndex); } return rs.getTimestamp(columnIndex); } } - static java.math.BigDecimal parseMoney(String text) { + static BigDecimal parseMoney(String text) { if (text == null) { return null; } @@ -458,7 +475,7 @@ static java.math.BigDecimal parseMoney(String text) { if (number.isEmpty()) { return null; } - java.math.BigDecimal value = new java.math.BigDecimal(number); + BigDecimal value = new BigDecimal(number); return negative ? value.negate() : value; } @@ -474,27 +491,31 @@ public static Object normalizeValue(Object value) throws SQLException { return blob.getBytes(1, (int) blob.length()); } else if (value instanceof SQLXML) { return ((SQLXML) value).getString(); - } else if (value instanceof java.sql.Array) { - Object array = ((java.sql.Array) value).getArray(); + } else if (value instanceof Array) { + Object array = ((Array) value).getArray(); return array instanceof Object[] ? array : String.valueOf(array); - } else if (value instanceof java.sql.Timestamp) { - return ((java.sql.Timestamp) value).toLocalDateTime(); - } else if (value instanceof java.sql.Date) { - return ((java.sql.Date) value).toLocalDate(); - } else if (value instanceof java.sql.Time) { - return ((java.sql.Time) value).toLocalTime(); - } else if (value instanceof java.math.BigInteger) { + } else if (value instanceof Timestamp) { + return ((Timestamp) value).toLocalDateTime(); + } else if (value instanceof Date) { + return ((Date) value).toLocalDate(); + } else if (value instanceof Time) { + return ((Time) value).toLocalTime(); + } else if (value instanceof BigInteger) { // BIGINT UNSIGNED of MySQL - return new java.math.BigDecimal((java.math.BigInteger) value); - } else if (value instanceof java.util.UUID) { + return new BigDecimal((BigInteger) value); + } else if (value instanceof UUID) { return value.toString(); - } else if (value instanceof java.time.Year) { - return ((java.time.Year) value).getValue(); + } else if (value instanceof Year) { + return ((Year) value).getValue(); + } else if (value instanceof InetAddress) { + // IPv4 and IPv6 of ClickHouse + return ((InetAddress) value).getHostAddress(); } String className = value.getClass().getName(); if (className.startsWith("org.postgresql.") || className.startsWith("oracle.") || className.startsWith( - "microsoft.sql.")) { - // PGobject (json, uuid, inet ...), oracle.sql.* and DateTimeOffset + "microsoft.sql.") || className.startsWith("org.duckdb.") || className.startsWith("com.clickhouse.") + || className.startsWith("org.h2.api.")) { + // PGobject (json, uuid, inet ...), oracle.sql.*, DateTimeOffset and DuckDB json, structs and maps return value.toString(); } return value; @@ -702,6 +723,33 @@ public void commitQuietly(Connection connection) { } } + private static final Map TRANSACTION_SUPPORTS = new ConcurrentHashMap<>(); + + /** + * Begins a transaction by turning off auto commit. Returns false if the database has no transactions + * (ClickHouse), statements are committed one by one then. + */ + public static boolean beginTransaction(Connection connection) throws SQLException { + // Connection pools evict connections which report unsupported features (SQLState 0A000). Some drivers + // query the server for supportsTransactions (SQL Server), the answer is kept per database url. + DatabaseMetaData metaData = connection.getMetaData(); + String url = StringUtils.defaultString(metaData.getURL()); + Boolean supported = TRANSACTION_SUPPORTS.get(url); + if (supported == null) { + supported = metaData.supportsTransactions(); + TRANSACTION_SUPPORTS.put(url, supported); + } + if (!supported) { + return false; + } + try { + connection.setAutoCommit(false); + return true; + } catch (SQLFeatureNotSupportedException e) { + return false; + } + } + public static void setPath(Connection connection, String catalog, String schema) throws SQLException { if (StringUtils.isNotBlank(catalog)) { diff --git a/src/main/java/com/github/sqljam/jdbc/SharedConnectionFactory.java b/src/main/java/com/github/sqljam/jdbc/SharedConnectionFactory.java new file mode 100644 index 0000000..aba29c0 --- /dev/null +++ b/src/main/java/com/github/sqljam/jdbc/SharedConnectionFactory.java @@ -0,0 +1,70 @@ +/* + * 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.jdbc; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.sql.Connection; + +/** + * @Description: SharedConnectionFactory hands out an existing connection which stays open: closing a connection + * of this factory does nothing, the owner of the connection closes it + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public class SharedConnectionFactory implements ConnectionFactory { + + private final Connection connection; + private final Connection proxy; + + public SharedConnectionFactory(Connection connection) { + this.connection = connection; + this.proxy = (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), + new Class[]{Connection.class}, (target, method, args) -> { + switch (method.getName()) { + case "close": + return null; + case "isClosed": + return connection.isClosed(); + case "unwrap": + if (args != null && args.length == 1 && args[0] == Connection.class) { + return connection; + } + break; + default: + break; + } + try { + return method.invoke(connection, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + }); + } + + @Override + public Connection getConnection() { + return proxy; + } + + /** + * The shared connection itself + */ + public Connection getSharedConnection() { + return connection; + } +} diff --git a/src/main/java/com/github/sqljam/jdbc/page/MapBasedPageReader.java b/src/main/java/com/github/sqljam/jdbc/page/MapBasedPageReader.java index cc0f271..24d0e27 100644 --- a/src/main/java/com/github/sqljam/jdbc/page/MapBasedPageReader.java +++ b/src/main/java/com/github/sqljam/jdbc/page/MapBasedPageReader.java @@ -89,14 +89,14 @@ public PageContent> list(int pageNumber, int offset, int lim connection = connectionFactory.getConnection(); String boundSql = dialect != null ? dialect.getPageStatement(sql, orderBy, limit, offset) : String.format("%s limit %d offset %d", sql, limit, offset); - return new DefaultPageContent<>(fetchAll(connection, boundSql, args), null); + return new DefaultPageContent<>(fetchAll(connection, boundSql, args, dialect), null); } finally { connectionFactory.close(connection); } } - private static List> fetchAll(Connection connection, String sql, Object[] args) - throws SQLException { + private static List> fetchAll(Connection connection, String sql, Object[] args, + Dialect dialect) throws SQLException { List> list = new ArrayList<>(); try (PreparedStatement ps = connection.prepareStatement(sql)) { JdbcUtils.setValues(ps, args); @@ -117,7 +117,9 @@ private static List> fetchAll(Connection connection, String if ("rn__".equalsIgnoreCase(columnLabel)) { continue; } - row.put(columnLabel, JdbcUtils.getColumnValue(rs, i, columnTypes[i - 1], columnTypeNames[i - 1])); + row.put(columnLabel, dialect != null ? dialect.getColumnValue(rs, i, columnTypes[i - 1], + columnTypeNames[i - 1]) : JdbcUtils.getColumnValue(rs, i, columnTypes[i - 1], + columnTypeNames[i - 1])); } list.add(row); } diff --git a/src/main/java/com/github/sqljam/utils/ConvertUtils.java b/src/main/java/com/github/sqljam/utils/ConvertUtils.java index 188beeb..add1cf1 100644 --- a/src/main/java/com/github/sqljam/utils/ConvertUtils.java +++ b/src/main/java/com/github/sqljam/utils/ConvertUtils.java @@ -22,6 +22,7 @@ /** * @Description: ConvertUtils (minimal replacement of doodler ConvertUtils for scalar values) * @Author: Fred Feng + * @Date: 26/03/2023 * @Version 1.0.0 */ @UtilityClass diff --git a/src/main/resources/com/github/sqljam/face/app.css b/src/main/resources/com/github/sqljam/face/app.css index 1722188..7665f80 100644 --- a/src/main/resources/com/github/sqljam/face/app.css +++ b/src/main/resources/com/github/sqljam/face/app.css @@ -144,3 +144,18 @@ .log-list .list-cell.log-error { -fx-text-fill: -color-danger-fg; } + +/* Category headers of database types: relational and OLAP databases */ + +.category-header { + -fx-font-size: 11px; + -fx-font-weight: bold; + -fx-text-fill: -color-fg-muted; + -fx-padding: 4 0 2 0; +} + +/* GitHub mark of the source repository */ + +.repository-mark .ikonli-font-icon { + -fx-icon-color: -color-fg-muted; +} diff --git a/src/main/resources/com/github/sqljam/face/example/shop.sql b/src/main/resources/com/github/sqljam/face/example/shop.sql new file mode 100644 index 0000000..57c2a35 --- /dev/null +++ b/src/main/resources/com/github/sqljam/face/example/shop.sql @@ -0,0 +1,112 @@ +-- Example database of SqlJam: a small online shop with categories, customers, products, orders and order items. +-- It is created at the first start, so that tables can be browsed, queried, exported and imported right away. + +CREATE TABLE categories ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL, + description VARCHAR(200), + CONSTRAINT uk_categories_name UNIQUE (name) +); +COMMENT ON TABLE categories IS 'Product categories'; + +CREATE TABLE customers ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(200) NOT NULL, + phone VARCHAR(30), + city VARCHAR(50), + vip BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_customers_email UNIQUE (email) +); +COMMENT ON TABLE customers IS 'Registered customers'; +COMMENT ON COLUMN customers.vip IS 'Customers with more than 5 orders'; + +CREATE TABLE products ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + category_id INT NOT NULL, + sku VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL, + price DECIMAL(10,2) NOT NULL, + stock INT DEFAULT 0, + description CLOB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_products_sku UNIQUE (sku), + CONSTRAINT fk_products_category FOREIGN KEY (category_id) REFERENCES categories (id) +); +COMMENT ON TABLE products IS 'Products for sale'; +CREATE INDEX idx_products_category ON products (category_id); + +CREATE TABLE orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_no VARCHAR(20) NOT NULL, + customer_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL, + total DECIMAL(12,2) DEFAULT 0 NOT NULL, + paid_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + CONSTRAINT uk_orders_order_no UNIQUE (order_no), + CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id) +); +COMMENT ON TABLE orders IS 'Orders of customers'; +COMMENT ON COLUMN orders.status IS 'NEW, PAID, SHIPPED, DELIVERED or CANCELLED'; +CREATE INDEX idx_orders_customer ON orders (customer_id); +CREATE INDEX idx_orders_status ON orders (status, created_at); + +CREATE TABLE order_items ( + order_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + quantity INT NOT NULL, + unit_price DECIMAL(10,2) NOT NULL, + PRIMARY KEY (order_id, product_id), + CONSTRAINT fk_order_items_order FOREIGN KEY (order_id) REFERENCES orders (id), + CONSTRAINT fk_order_items_product FOREIGN KEY (product_id) REFERENCES products (id) +); +COMMENT ON TABLE order_items IS 'Products of an order'; + +INSERT INTO categories (name, description) VALUES + ('Electronics', 'Phones, laptops and accessories'), + ('Books', 'Printed books and e-books'), + ('Home', 'Furniture, kitchen and decoration'), + ('Clothing', 'Clothes, shoes and bags'), + ('Sports', 'Fitness, outdoor and cycling'), + ('Toys', 'Toys and games for all ages'); + +INSERT INTO customers (name, email, phone, city, created_at) +SELECT ARRAY['Alice', 'Bob', 'Carol', 'David', 'Emma', 'Frank', 'Grace', 'Henry', 'Ivy', 'Jack'][MOD(X - 1, 10) + 1] + || ' ' || ARRAY['Smith', 'Chen', 'Garcia', 'Müller', 'Tanaka'][MOD((X - 1) / 10, 5) + 1], + LOWER(ARRAY['alice', 'bob', 'carol', 'david', 'emma', 'frank', 'grace', 'henry', 'ivy', 'jack'][MOD(X - 1, 10) + 1]) + || '.' || X || '@example.com', + '+1-555-' || LPAD(CAST(MOD(X * 7919, 10000) AS VARCHAR), 4, '0'), + ARRAY['New York', 'London', 'Berlin', 'Tokyo', 'Shanghai', 'Sydney', 'Paris', 'Toronto'][MOD(X * 3, 8) + 1], + DATEADD('DAY', -X * 7, TIMESTAMP '2026-09-30 09:00:00') +FROM SYSTEM_RANGE(1, 50); + +INSERT INTO products (category_id, sku, name, price, stock, description, created_at) +SELECT MOD(X - 1, 6) + 1, + 'SKU-' || LPAD(CAST(X AS VARCHAR), 5, '0'), + ARRAY['Smart', 'Classic', 'Compact', 'Premium', 'Eco'][MOD(X - 1, 5) + 1] || ' ' + || ARRAY['Phone', 'Novel', 'Lamp', 'Jacket', 'Bike', 'Puzzle'][MOD(X - 1, 6) + 1] || ' ' || X, + CAST(MOD(X * 37, 900) + 9.99 AS DECIMAL(10,2)), + MOD(X * 13, 200), + 'Product ' || X || ' of the example shop. ' || REPEAT('Long description for a CLOB column. ', 5), + DATEADD('DAY', -X * 11, TIMESTAMP '2026-09-30 10:00:00') +FROM SYSTEM_RANGE(1, 30); + +INSERT INTO orders (order_no, customer_id, status, created_at) +SELECT 'SO-2026-' || LPAD(CAST(X AS VARCHAR), 5, '0'), + MOD(X * 7, 50) + 1, + ARRAY['NEW', 'PAID', 'SHIPPED', 'DELIVERED', 'CANCELLED'][MOD(X, 5) + 1], + DATEADD('HOUR', -X * 13, TIMESTAMP '2026-09-30 12:00:00') +FROM SYSTEM_RANGE(1, 300); + +INSERT INTO order_items (order_id, product_id, quantity, unit_price) +SELECT o.X, MOD(o.X * 3 + k.X * 7, 30) + 1, MOD(o.X + k.X, 4) + 1, p.price +FROM SYSTEM_RANGE(1, 300) o +JOIN SYSTEM_RANGE(1, 3) k ON k.X <= MOD(o.X, 3) + 1 +JOIN products p ON p.id = MOD(o.X * 3 + k.X * 7, 30) + 1; + +UPDATE orders o SET total = (SELECT SUM(i.quantity * i.unit_price) FROM order_items i WHERE i.order_id = o.id), + paid_at = CASE WHEN o.status IN ('PAID', 'SHIPPED', 'DELIVERED') THEN DATEADD('MINUTE', 30, o.created_at) END; + +UPDATE customers c SET vip = TRUE WHERE (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) > 5; diff --git a/src/main/resources/com/github/sqljam/face/messages.properties b/src/main/resources/com/github/sqljam/face/messages.properties index 5908625..c4788ec 100644 --- a/src/main/resources/com/github/sqljam/face/messages.properties +++ b/src/main/resources/com/github/sqljam/face/messages.properties @@ -19,13 +19,22 @@ action.refresh=Refresh action.export=Export... action.import=Import... action.importScripts=Import Export Package... +action.importParquet=Import Parquet Files... action.exit=Exit +action.newTargetConnection=New data source as the target action.about=About SqlJam action.copy=Copy about.title=About SqlJam about.header=SqlJam {0} -about.message=Move tables across databases. Schema, data and all.\n\nCopies tables, indexes, constraints, sequences and partitions directly into another database, or saves them as SQL export packages.\n\nApache License 2.0 +about.message=Move tables across databases. Schema, data and all.\n\nCopies tables, indexes, constraints, sequences and partitions directly into another database, or saves them as SQL export packages. +about.version=Version +about.homepage=Home page +about.repository=Repository +about.author=Author +about.email=Email +about.license=License +about.licenseName=Apache License 2.0 welcome.hint=Double-click a table in the data source tree to view its columns, indexes, DDL and data. Use Export to save SQL scripts or copy tables into another database. @@ -76,6 +85,8 @@ tree.empty=(empty) tree.error=Error: {0} tree.loadError=Unable to load {0} tree.connect=Connect +tree.disconnect=Disconnect +tree.disconnected=Disconnected: {0} tree.openTable=Open Table table.columns=Columns @@ -164,6 +175,9 @@ export.database.connection=Target data source export.database.catalog=Target database export.database.schema=Target schema export.database.createSchema=Create schema if not exists +export.query=Rows of {0}: {1} +export.database.tableName=Target table name +export.database.tableName.hint={table} is the source table name, e.g. {table}_copy copies tables in the same schema export.error.source=Please choose a source data source export.error.tables=Please select at least one table export.error.directory=Please choose a valid output directory @@ -171,6 +185,8 @@ export.error.target=Please choose a target data source export.error.noConnections=Please create a data source first export.error.pageSize=Rows per batch must be a number between {0} and {1} export.error.maxFileSize=Max file size must be a number between 0 and {0} MB +export.error.sameTables=Source and target are the same tables, please choose another schema or a target table name such as {0}_copy +export.error.tableName=The target table name must contain {0} when several tables are imported export.error.version=Invalid target version: {0}, e.g. 11.2 or 2019 identifierCase.AUTO=Automatic @@ -204,6 +220,7 @@ import.error.directory=Please choose a directory exported by SqlJam import.error.manifest=Unable to read manifest: {0} import.warning.status=The package is {0}, it can not be imported import.warning.noDataSource=The package is exported for {0}, please create a {0} data source first +import.warning.parquetFiles=The directory contains Parquet files without manifest.json, please use Import Parquet Files import.summary=Executed {0} statements, restored {1} LOB values, {2} failed progress.exportTitle=Exporting SQL scripts @@ -231,6 +248,63 @@ splash.slogan=Move tables across databases. Schema, data and all. splash.starting=Starting... splash.loadingConfiguration=Loading configuration... splash.loadingDataSources=Loading data sources... +splash.preparingExample=Preparing the example database... splash.loadingDriver=Loading {0} driver... splash.preparingUi=Preparing user interface... splash.ready=Ready + +# Data formats of export packages +export.format=Data format +export.format.sql=SQL scripts +export.format.parquet=Parquet +export.format.compression=Compression + +# Export package viewer +package.title=Export Package - {0} +package.view=View Package +package.open=Open Export Package... +package.chooseDirectory=Choose an export package +package.summary=Format: {0} Status: {1}\nSource: {2} Target: {3}\nTables: {4} Rows: {5} +package.noManifest=No manifest.json, files of the directory are listed +package.loading=Loading... +package.noRows=No rows +package.parquetRows={0} rows, the first {1} are shown +package.truncated=... the first {0} of {1} are shown +package.column.name=Name +package.column.size=Size +package.column.modified=Modified +parquet.title=Import Parquet Files +parquet.header=Load Parquet files into a table of a database +parquet.section.files=Parquet files +parquet.files.add=Add Files... +parquet.files.remove=Remove +parquet.files.filter=Parquet files +parquet.files.empty=No files, add Parquet files of one table +parquet.files.hint=Files of one table are loaded together, columns and the first rows are previewed +parquet.preview={0} columns, {1} rows, the first {2} are shown +parquet.table=Table +parquet.table.prompt=Name of the target table +parquet.mode=Mode +parquet.mode.create=Create or replace the table +parquet.mode.append=Append rows to the existing table +parquet.error.read=Unable to read Parquet files: {0} +parquet.error.table=Please enter the name of the target table +parquet.summary=Loaded {0} rows into {1} +data.query.allColumns=All columns +data.query.columns={0} of {1} columns +data.query.where=WHERE e.g. salary > 1000 AND name LIKE 'A%' +data.query.groupBy=Group by +data.query.groupedBy=Group by {0} +data.query.orderBy=ORDER BY e.g. created_at DESC +data.query.apply=Apply +data.query.clear=Clear +data.query.export=Export the rows of the query (columns, condition and order) +data.query.exportGrouped=Grouped rows are for viewing, remove GROUP BY to export the rows +data.query.noColumns=Please select at least one column +data.query.error=Invalid query: {0} +action.restoreExample=Restore Example Database +example.button=Example +example.button.tooltip=Create the example database of a small online shop again +example.restore.title=Restore Example Database +example.restore.message=The example database "{0}" is created again, changes made in it are lost. +example.restore.error=Unable to create the example database diff --git a/src/main/resources/sqljam.properties b/src/main/resources/sqljam.properties index 4cfd40b..bb2055f 100644 --- a/src/main/resources/sqljam.properties +++ b/src/main/resources/sqljam.properties @@ -7,6 +7,9 @@ # Directory of application data (connections, logs) sqljam.home=${user.home}/.sqljam sqljam.connections.file=${sqljam.home}/connections.json +# Example H2 database of a small online shop, created at the first start (when no data source is saved yet) +sqljam.example.enabled=true +sqljam.example.directory=${sqljam.home}/example # Banner printed at startup: console, log or off. A custom banner file can be set by sqljam.banner.location sqljam.banner.mode=console diff --git a/src/test/java/com/github/sqljam/clickhouse/ClickHouseImportIT.java b/src/test/java/com/github/sqljam/clickhouse/ClickHouseImportIT.java new file mode 100644 index 0000000..44a5f70 --- /dev/null +++ b/src/test/java/com/github/sqljam/clickhouse/ClickHouseImportIT.java @@ -0,0 +1,32 @@ +/* + * 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.clickhouse; + +import com.github.sqljam.it.AbstractImportIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: ClickHouseImportIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class ClickHouseImportIT extends AbstractImportIT { + + ClickHouseImportIT() { + super(ItDatabase.CLICKHOUSE); + } +} diff --git a/src/test/java/com/github/sqljam/clickhouse/ClickHouseParquetIT.java b/src/test/java/com/github/sqljam/clickhouse/ClickHouseParquetIT.java new file mode 100644 index 0000000..5bd46d2 --- /dev/null +++ b/src/test/java/com/github/sqljam/clickhouse/ClickHouseParquetIT.java @@ -0,0 +1,32 @@ +/* + * 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.clickhouse; + +import com.github.sqljam.it.AbstractParquetIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: ClickHouseParquetIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class ClickHouseParquetIT extends AbstractParquetIT { + + ClickHouseParquetIT() { + super(ItDatabase.CLICKHOUSE); + } +} diff --git a/src/test/java/com/github/sqljam/clickhouse/ClickHouseScriptIT.java b/src/test/java/com/github/sqljam/clickhouse/ClickHouseScriptIT.java new file mode 100644 index 0000000..9e22874 --- /dev/null +++ b/src/test/java/com/github/sqljam/clickhouse/ClickHouseScriptIT.java @@ -0,0 +1,32 @@ +/* + * 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.clickhouse; + +import com.github.sqljam.it.AbstractScriptIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: ClickHouseScriptIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class ClickHouseScriptIT extends AbstractScriptIT { + + ClickHouseScriptIT() { + super(ItDatabase.CLICKHOUSE); + } +} diff --git a/src/test/java/com/github/sqljam/clickhouse/ClickHouseSessionIT.java b/src/test/java/com/github/sqljam/clickhouse/ClickHouseSessionIT.java new file mode 100644 index 0000000..400c054 --- /dev/null +++ b/src/test/java/com/github/sqljam/clickhouse/ClickHouseSessionIT.java @@ -0,0 +1,32 @@ +/* + * 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.clickhouse; + +import com.github.sqljam.it.AbstractSessionIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: ClickHouseSessionIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class ClickHouseSessionIT extends AbstractSessionIT { + + ClickHouseSessionIT() { + super(ItDatabase.CLICKHOUSE); + } +} diff --git a/src/test/java/com/github/sqljam/config/ConfigTest.java b/src/test/java/com/github/sqljam/config/ConfigTest.java index ce3476d..a2c3bec 100644 --- a/src/test/java/com/github/sqljam/config/ConfigTest.java +++ b/src/test/java/com/github/sqljam/config/ConfigTest.java @@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.File; +import java.nio.file.Files; import java.nio.file.Path; import org.junit.jupiter.api.Test; @@ -91,10 +92,10 @@ void instance() { void applicationFile() throws Exception { // The external application file overrides classpath defaults, the user file overrides both File appFile = tempDir.resolve("app.properties").toFile(); - java.nio.file.Files.writeString(appFile.toPath(), + Files.writeString(appFile.toPath(), "sqljam.ui.theme=Dracula\nsqljam.pool.maximum-size=20\n"); File userFile = tempDir.resolve("user.properties").toFile(); - java.nio.file.Files.writeString(userFile.toPath(), "sqljam.ui.theme=Nord Dark\n"); + Files.writeString(userFile.toPath(), "sqljam.ui.theme=Nord Dark\n"); Config config = new Config(appFile, userFile); assertEquals("Nord Dark", config.getString("sqljam.ui.theme", null)); assertEquals(20, config.getInt("sqljam.pool.maximum-size", 0)); @@ -103,7 +104,7 @@ void applicationFile() throws Exception { // Saving writes the user file only config.set("sqljam.ui.theme", "Primer Light"); config.save(); - assertTrue(java.nio.file.Files.readString(appFile.toPath()).contains("Dracula")); + assertTrue(Files.readString(appFile.toPath()).contains("Dracula")); } @Test @@ -112,11 +113,11 @@ void applicationFileLookup() throws Exception { assertNull(Config.getApplicationFile(null)); assertNull(Config.getApplicationFile(dir)); File flat = new File(dir, Config.FILE_NAME); - java.nio.file.Files.writeString(flat.toPath(), "a=1\n"); + Files.writeString(flat.toPath(), "a=1\n"); assertEquals(flat, Config.getApplicationFile(dir)); File conf = new File(new File(dir, Config.CONF_DIR_NAME), Config.FILE_NAME); conf.getParentFile().mkdirs(); - java.nio.file.Files.writeString(conf.toPath(), "a=2\n"); + Files.writeString(conf.toPath(), "a=2\n"); assertEquals(conf, Config.getApplicationFile(dir)); // Tests run from target/classes, not from a jar assertNull(Config.getJarDirectory()); diff --git a/src/test/java/com/github/sqljam/duckdb/DuckDBImportIT.java b/src/test/java/com/github/sqljam/duckdb/DuckDBImportIT.java new file mode 100644 index 0000000..4241857 --- /dev/null +++ b/src/test/java/com/github/sqljam/duckdb/DuckDBImportIT.java @@ -0,0 +1,32 @@ +/* + * 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.duckdb; + +import com.github.sqljam.it.AbstractImportIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: DuckDBImportIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class DuckDBImportIT extends AbstractImportIT { + + DuckDBImportIT() { + super(ItDatabase.DUCKDB); + } +} diff --git a/src/test/java/com/github/sqljam/duckdb/DuckDBParquetIT.java b/src/test/java/com/github/sqljam/duckdb/DuckDBParquetIT.java new file mode 100644 index 0000000..ce23f01 --- /dev/null +++ b/src/test/java/com/github/sqljam/duckdb/DuckDBParquetIT.java @@ -0,0 +1,32 @@ +/* + * 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.duckdb; + +import com.github.sqljam.it.AbstractParquetIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: DuckDBParquetIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class DuckDBParquetIT extends AbstractParquetIT { + + DuckDBParquetIT() { + super(ItDatabase.DUCKDB); + } +} diff --git a/src/test/java/com/github/sqljam/duckdb/DuckDBScriptIT.java b/src/test/java/com/github/sqljam/duckdb/DuckDBScriptIT.java new file mode 100644 index 0000000..fbd64b5 --- /dev/null +++ b/src/test/java/com/github/sqljam/duckdb/DuckDBScriptIT.java @@ -0,0 +1,32 @@ +/* + * 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.duckdb; + +import com.github.sqljam.it.AbstractScriptIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: DuckDBScriptIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class DuckDBScriptIT extends AbstractScriptIT { + + DuckDBScriptIT() { + super(ItDatabase.DUCKDB); + } +} diff --git a/src/test/java/com/github/sqljam/duckdb/DuckDBSessionIT.java b/src/test/java/com/github/sqljam/duckdb/DuckDBSessionIT.java new file mode 100644 index 0000000..01235af --- /dev/null +++ b/src/test/java/com/github/sqljam/duckdb/DuckDBSessionIT.java @@ -0,0 +1,32 @@ +/* + * 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.duckdb; + +import com.github.sqljam.it.AbstractSessionIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: DuckDBSessionIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class DuckDBSessionIT extends AbstractSessionIT { + + DuckDBSessionIT() { + super(ItDatabase.DUCKDB); + } +} diff --git a/src/test/java/com/github/sqljam/face/LauncherScriptTest.java b/src/test/java/com/github/sqljam/face/LauncherScriptTest.java new file mode 100644 index 0000000..74b16be --- /dev/null +++ b/src/test/java/com/github/sqljam/face/LauncherScriptTest.java @@ -0,0 +1,74 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * @Description: LauncherScriptTest verifies the launcher of Windows: line endings of cmd, the check of Java 17 and + * paths which are not expanded in parenthesized blocks, where a path like C:\Program Files (x86) + * would end the block + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class LauncherScriptTest { + + private static final File SCRIPT = new File("src/bin/sqljam.bat"); + + @Test + void usesWindowsLineEndings() throws Exception { + String text = Files.readString(SCRIPT.toPath(), StandardCharsets.UTF_8); + assertFalse(text.replace("\r\n", "").contains("\n"), "Lines of cmd end with CRLF"); + assertTrue(text.contains("sqljam-@project.version@-win.jar")); + } + + @Test + void checksJavaVersion() throws Exception { + String text = Files.readString(SCRIPT.toPath(), StandardCharsets.UTF_8); + assertTrue(text.contains("-version")); + assertTrue(text.contains("LSS 17 goto oldJava")); + assertTrue(text.contains("start \"SqlJam\" \"%JAVAW%\"")); + } + + @Test + void expandsNoPathInBlocks() throws Exception { + List lines = Files.readAllLines(SCRIPT.toPath(), StandardCharsets.UTF_8); + int depth = 0; + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.startsWith("rem") || trimmed.startsWith("for /f")) { + continue; + } + if (depth > 0) { + assertFalse(line.matches(".*%(JAVA_HOME|JAVA|JAVAW|JAR|DIR)%.*"), line); + } + if (trimmed.endsWith("(")) { + depth++; + } else if (trimmed.startsWith(")")) { + depth--; + } + } + } +} diff --git a/src/test/java/com/github/sqljam/face/service/ExampleDatabaseTest.java b/src/test/java/com/github/sqljam/face/service/ExampleDatabaseTest.java new file mode 100644 index 0000000..879e2f5 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/service/ExampleDatabaseTest.java @@ -0,0 +1,219 @@ +/* + * 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.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.model.TransferRequest; +import com.github.sqljam.impexp.DataFormat; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.ExportListener; +import com.github.sqljam.impexp.ExportManifest; + +/** + * @Description: ExampleDatabaseTest verifies the example database of the first start: tables, keys, indexes, rows, + * computed totals, later starts, the switch and failures which do not stop the application + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class ExampleDatabaseTest { + + @TempDir + File dir; + + private File connectionsFile() { + return new File(dir, "connections.json"); + } + + private static long count(Statement statement, String sql) throws Exception { + try (ResultSet rs = statement.executeQuery(sql)) { + rs.next(); + return rs.getLong(1); + } + } + + @Test + void createsShopAtFirstStart() throws Exception { + ExampleDatabase example = new ExampleDatabase(true, new File(dir, "example")); + Optional created = example.createAtFirstStart(connectionsFile()); + assertTrue(created.isPresent()); + ConnectionProfile profile = created.get(); + assertEquals(ExampleDatabase.PROFILE_NAME, profile.getName()); + assertEquals(DbType.H2, profile.getDbType()); + assertTrue(new File(dir, "example/shop.mv.db").isFile()); + try (Connection connection = DriverManager.getConnection(profile.getJdbcUrl(), "sa", ""); + Statement statement = connection.createStatement()) { + List tables = new ArrayList<>(); + try (ResultSet rs = connection.getMetaData().getTables(null, "PUBLIC", null, new String[]{"TABLE"})) { + while (rs.next()) { + tables.add(rs.getString("TABLE_NAME")); + } + } + assertEquals(List.of("CATEGORIES", "CUSTOMERS", "ORDERS", "ORDER_ITEMS", "PRODUCTS"), + tables.stream().sorted().toList()); + assertEquals(6, count(statement, "SELECT COUNT(*) FROM categories")); + assertEquals(50, count(statement, "SELECT COUNT(*) FROM customers")); + assertEquals(30, count(statement, "SELECT COUNT(*) FROM products")); + assertEquals(300, count(statement, "SELECT COUNT(*) FROM orders")); + // 1 to 3 items per order + assertEquals(600, count(statement, "SELECT COUNT(*) FROM order_items")); + // Totals are the sums of the items, paid orders have a payment time + assertEquals(0, count(statement, "SELECT COUNT(*) FROM orders o WHERE o.total <> (SELECT SUM(i.quantity" + + " * i.unit_price) FROM order_items i WHERE i.order_id = o.id)")); + assertEquals(0, count(statement, "SELECT COUNT(*) FROM orders WHERE status IN ('NEW', 'CANCELLED')" + + " AND paid_at IS NOT NULL")); + assertEquals(0, count(statement, "SELECT COUNT(*) FROM orders WHERE status IN ('PAID', 'SHIPPED'," + + " 'DELIVERED') AND paid_at IS NULL")); + assertTrue(count(statement, "SELECT COUNT(*) FROM customers WHERE vip") > 0); + assertEquals(50, count(statement, "SELECT COUNT(DISTINCT email) FROM customers")); + // Foreign keys, unique and regular indexes and comments for every feature of SqlJam + try (ResultSet rs = connection.getMetaData().getImportedKeys(null, "PUBLIC", "ORDER_ITEMS")) { + int foreignKeys = 0; + while (rs.next()) { + foreignKeys++; + } + assertEquals(2, foreignKeys); + } + assertEquals(1, count(statement, "SELECT COUNT(*) FROM INFORMATION_SCHEMA.INDEXES WHERE TABLE_NAME" + + " = 'ORDERS' AND INDEX_NAME = 'IDX_ORDERS_STATUS'")); + assertEquals(1, count(statement, "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME" + + " = 'PRODUCTS' AND REMARKS = 'Products for sale'")); + } + } + + @Test + void createsOnlyAtFirstStart() throws Exception { + Files.writeString(connectionsFile().toPath(), "[]"); + ExampleDatabase example = new ExampleDatabase(true, new File(dir, "example")); + // A data source removed by the user is not created again + assertFalse(example.createAtFirstStart(connectionsFile()).isPresent()); + assertFalse(new File(dir, "example").exists()); + } + + @Test + void canBeDisabled() { + ExampleDatabase example = new ExampleDatabase(false, new File(dir, "example")); + assertFalse(example.createAtFirstStart(connectionsFile()).isPresent()); + } + + @Test + void replacesExistingDatabase() throws Exception { + ExampleDatabase example = new ExampleDatabase(true, new File(dir, "example")); + example.create(); + ConnectionProfile profile = example.create(); + try (Connection connection = DriverManager.getConnection(profile.getJdbcUrl(), "sa", ""); + Statement statement = connection.createStatement()) { + assertEquals(300, count(statement, "SELECT COUNT(*) FROM orders")); + } + } + + @Test + void startsWithoutExampleWhenItFails() throws Exception { + // A file where the directory should be + File blocked = new File(dir, "blocked"); + Files.writeString(blocked.toPath(), "file"); + ExampleDatabase example = new ExampleDatabase(true, blocked); + assertFalse(example.createAtFirstStart(connectionsFile()).isPresent()); + } + + @Test + void exportsExampleAsParquetPackage() throws Exception { + ConnectionProfile profile = new ExampleDatabase(true, new File(dir, "example")).create(); + File packageDir = new File(dir, "package"); + TransferRequest request = new TransferRequest(); + request.setSource(profile); + request.setSourceSchema("PUBLIC"); + request.setTarget(TransferRequest.Target.SCRIPT); + request.setOutputDirectory(packageDir); + request.setScriptDbType(DbType.POSTGRESQL); + request.setDataFormat(DataFormat.PARQUET); + new TransferService().transfer(request, ExportListener.NONE); + ExportManifest manifest = ExportManifest.read(packageDir); + assertEquals(ExportManifest.Status.COMPLETED, manifest.getStatus()); + assertEquals(5, manifest.getTables().size()); + assertEquals(986, manifest.getTables().stream().mapToLong(ExportManifest.TableEntry::getRows).sum()); + } + + @Test + void createsOneIndexOfSameColumnsForOracle() throws Exception { + ConnectionProfile profile = new ExampleDatabase(true, new File(dir, "example")).create(); + File packageDir = new File(dir, "oracle"); + TransferRequest request = new TransferRequest(); + request.setSource(profile); + request.setSourceSchema("PUBLIC"); + request.setTarget(TransferRequest.Target.SCRIPT); + request.setOutputDirectory(packageDir); + request.setScriptDbType(DbType.ORACLE); + new TransferService().transfer(request, ExportListener.NONE); + // H2 indexes the foreign key of orders.customer_id next to idx_orders_customer, Oracle refuses a second one + List indexes = new ArrayList<>(); + try (Stream files = Files.walk(packageDir.toPath())) { + for (Path file : files.filter(path -> path.toString().endsWith(".sql")).toList()) { + Files.readAllLines(file).stream().filter(line -> line.contains("CREATE INDEX") + || line.contains("CREATE UNIQUE INDEX")).forEach(indexes::add); + } + } + assertEquals(1, indexes.stream().filter(line -> line.contains("(CUSTOMER_ID)")).count(), indexes.toString()); + assertEquals(1, indexes.stream().filter(line -> line.contains("(CATEGORY_ID)")).count(), indexes.toString()); + assertEquals(indexes.size(), indexes.stream().distinct().count(), indexes.toString()); + } + + @Test + void repairsDeletedDatabaseFile() throws Exception { + ExampleDatabase example = new ExampleDatabase(true, new File(dir, "example")); + ConnectionProfile profile = example.create(); + assertEquals(ExampleDatabase.PROFILE_ID, profile.getId()); + assertTrue(ExampleDatabase.isExample(profile)); + // The database file is there, nothing to repair + assertFalse(example.repairAtStart(List.of(profile)).isPresent()); + // The user deleted the file, H2 would open an empty database + assertTrue(new File(profile.getDatabase() + ".mv.db").delete()); + assertTrue(ExampleDatabase.isDatabaseMissing(profile)); + Optional repaired = example.repairAtStart(List.of(profile)); + assertTrue(repaired.isPresent()); + try (Connection connection = DriverManager.getConnection(profile.getJdbcUrl(), "sa", ""); + Statement statement = connection.createStatement()) { + assertEquals(30, count(statement, "SELECT COUNT(*) FROM products")); + } + // Other data sources and a disabled example are not repaired + ConnectionProfile other = new ConnectionProfile(); + other.setDbType(DbType.H2); + other.setDatabase(new File(dir, "missing").getAbsolutePath()); + assertFalse(example.repairAtStart(List.of(other)).isPresent()); + assertTrue(new File(profile.getDatabase() + ".mv.db").delete()); + assertFalse(new ExampleDatabase(false, new File(dir, "example")).repairAtStart(List.of(profile)) + .isPresent()); + assertFalse(example.repairAtStart(List.of()).isPresent()); + } +} diff --git a/src/test/java/com/github/sqljam/face/service/StoreTest.java b/src/test/java/com/github/sqljam/face/service/StoreTest.java index c39f6dd..5ff16d6 100644 --- a/src/test/java/com/github/sqljam/face/service/StoreTest.java +++ b/src/test/java/com/github/sqljam/face/service/StoreTest.java @@ -21,9 +21,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.File; +import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import com.github.sqljam.config.Config; @@ -114,7 +116,7 @@ void formatting() { assertEquals("(2 bytes) 0x0AFF", DatabaseSession.formatValue(new byte[]{10, -1})); assertTrue(DatabaseSession.formatValue(new byte[20]).endsWith("...")); assertEquals("[1, 2]", DatabaseSession.formatValue(new Object[]{1, 2})); - assertEquals("1.50", DatabaseSession.formatValue(new java.math.BigDecimal("1.50"))); + assertEquals("1.50", DatabaseSession.formatValue(new BigDecimal("1.50"))); assertEquals(1003, DatabaseSession.formatValue("x".repeat(2000)).length()); assertEquals("CASCADE", DatabaseSession.getRuleName(0)); assertEquals("SET NULL", DatabaseSession.getRuleName(2)); @@ -141,7 +143,7 @@ void versions() { assertEquals(2, version[1]); assertEquals(0, TransferService.parseVersion("8")[1]); assertEquals(12, TransferService.parseVersion("12c")[0]); - org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + Assertions.assertThrows(IllegalArgumentException.class, () -> TransferService.parseVersion("abc")); } } diff --git a/src/test/java/com/github/sqljam/face/view/BrandingTest.java b/src/test/java/com/github/sqljam/face/view/BrandingTest.java index 0375f01..dbc7c73 100644 --- a/src/test/java/com/github/sqljam/face/view/BrandingTest.java +++ b/src/test/java/com/github/sqljam/face/view/BrandingTest.java @@ -22,7 +22,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.File; +import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,10 +32,15 @@ import org.testfx.framework.junit5.ApplicationExtension; import org.testfx.framework.junit5.Start; import com.github.sqljam.face.Banner; +import javafx.scene.Cursor; import javafx.scene.Scene; import javafx.scene.control.Alert; +import javafx.scene.control.Hyperlink; +import javafx.scene.control.Label; +import javafx.scene.control.Labeled; import javafx.scene.image.Image; import javafx.scene.image.ImageView; +import javafx.scene.layout.GridPane; import javafx.scene.layout.StackPane; import javafx.stage.Stage; @@ -50,6 +57,7 @@ class BrandingTest { File dir; private Stage stage; + private final List opened = new ArrayList<>(); @Start void start(Stage stage) { @@ -103,7 +111,7 @@ void windowIcons() { @Test void about() { - Alert about = FxTestSupport.call(() -> Dialogs.createAbout(stage)); + Alert about = FxTestSupport.call(() -> Dialogs.createAbout(stage, opened::add)); assertEquals("SqlJam " + Banner.getVersion(), about.getHeaderText()); assertNotNull(about.getGraphic()); assertEquals("aboutLogo", about.getGraphic().getId()); @@ -114,9 +122,41 @@ void about() { assertSame(stage, about.getOwner()); // An owner without a scene is not set, JavaFX would fail with a NullPointerException - Alert withoutOwner = FxTestSupport.call(() -> Dialogs.createAbout(new Stage())); + Alert withoutOwner = FxTestSupport.call(() -> Dialogs.createAbout(new Stage(), null)); assertNull(withoutOwner.getOwner()); - Alert noOwner = FxTestSupport.call(() -> Dialogs.createAbout(null)); + Alert noOwner = FxTestSupport.call(() -> Dialogs.createAbout(null, null)); assertNull(noOwner.getOwner()); + // Version, home page, repository, author, email and license, the links are opened in the browser + String details = FxTestSupport.call(() -> ((GridPane) about.getDialogPane().lookup("#aboutDetails")) + .getChildren().stream().map(node -> ((Labeled) node).getText()).collect(Collectors.joining("|"))); + for (String text : List.of("Version", Banner.getVersion(), "Home page", Branding.HOMEPAGE_URL, + "Repository", Branding.REPOSITORY_URL, "Author", "Fred Feng", "Email", Branding.EMAIL, "License", + "Apache License 2.0")) { + assertTrue(details.contains(text), details); + } + for (String id : List.of("#aboutHomepage", "#aboutRepository", "#aboutEmail")) { + FxTestSupport.run(() -> ((Hyperlink) about.getDialogPane().lookup(id)).fire()); + } + assertEquals(List.of(Branding.HOMEPAGE_URL, Branding.REPOSITORY_URL, "mailto:" + Branding.EMAIL), opened); + // Links without a browser do nothing + FxTestSupport.run(() -> ((Hyperlink) noOwner.getDialogPane().lookup("#aboutHomepage")).fire()); + assertEquals(3, opened.size()); + } + + @Test + void repositoryMark() { + Label mark = FxTestSupport.call(() -> Branding.repositoryMark(opened::add)); + // An icon without text, the url is shown by the tooltip + assertNull(mark.getText()); + assertNotNull(mark.getGraphic()); + assertEquals("https://github.com/paganini2008/sqljam", FxTestSupport.call(() -> mark.getTooltip() + .getText())); + // A click opens the repository + assertEquals(Cursor.HAND, mark.getCursor()); + FxTestSupport.run(() -> mark.getOnMouseClicked().handle(null)); + assertEquals(List.of(Branding.REPOSITORY_URL), opened); + // Without a browser it is not clickable + Label plain = FxTestSupport.call(() -> Branding.repositoryMark(null)); + assertNull(plain.getOnMouseClicked()); } } diff --git a/src/test/java/com/github/sqljam/face/view/ConnectionFormTest.java b/src/test/java/com/github/sqljam/face/view/ConnectionFormTest.java index 90fbb57..02a11c9 100644 --- a/src/test/java/com/github/sqljam/face/view/ConnectionFormTest.java +++ b/src/test/java/com/github/sqljam/face/view/ConnectionFormTest.java @@ -93,7 +93,7 @@ private String validate() { @Test void listsOnlySupportedDatabaseTypes() { - assertEquals(6, FxTestSupport.call(() -> dbTypeCombo().getItems().size())); + assertEquals(DbType.values().length, FxTestSupport.call(() -> dbTypeCombo().getItems().size())); } @Test @@ -112,13 +112,17 @@ void requiresServiceNameOfOracle() { } @Test - void requiresFileOfH2AndSQLite() { + void requiresFileOfFileDatabases() { fill(DbType.H2, "", "", ""); assertEquals("File is required", validate()); fill(DbType.SQLITE, "", "", " "); assertEquals("File is required", validate()); fill(DbType.SQLITE, "", "", new File(dir, "a.sqlite").getAbsolutePath()); assertNull(validate()); + fill(DbType.DUCKDB, "", "", ""); + assertEquals("File is required", validate()); + fill(DbType.DUCKDB, "", "", new File(dir, "a.duckdb").getAbsolutePath()); + assertNull(validate()); } @Test @@ -129,6 +133,28 @@ void databaseIsOptionalForMySQLAndSQLServer() { assertNull(validate()); } + @Test + void connectsClickHouseServer() { + fill(DbType.MYSQL, "localhost", "3306", ""); + FxTestSupport.run(() -> dbTypeCombo().setValue(DbType.CLICKHOUSE)); + // The default http port of ClickHouse, the database is optional + assertEquals("8123", FxTestSupport.call(() -> field("portField").getText())); + assertEquals("Database", FxTestSupport.call(() -> ((Label) form.lookup("#databaseLabel")).getText())); + fill(DbType.CLICKHOUSE, "localhost", "8123", ""); + assertNull(validate()); + fill(DbType.CLICKHOUSE, "localhost", "18123", "app"); + assertNull(validate()); + ConnectionProfile profile = FxTestSupport.call(form::getProfile); + assertEquals("jdbc:clickhouse:http://localhost:18123/app?compress=0", profile.getJdbcUrl()); + // A url entered by the user gets the options of SqlJam, configured options are kept + profile.setUrl("jdbc:clickhouse:http://localhost:18123/app?ssl=false"); + assertEquals("jdbc:clickhouse:http://localhost:18123/app?ssl=false&compress=0", profile.getJdbcUrl()); + profile.setUrl("jdbc:clickhouse:http://localhost:18123/app?compress=1"); + assertEquals("jdbc:clickhouse:http://localhost:18123/app?compress=1", profile.getJdbcUrl()); + assertEquals(DbType.CLICKHOUSE, DbType.forUrl("jdbc:clickhouse:http://localhost:18123/app")); + assertEquals(DbType.CLICKHOUSE, DbType.forUrl("jdbc:ch://localhost:8123")); + } + @Test void requiresHost() { fill(DbType.MYSQL, " ", "3306", "test"); @@ -184,7 +210,11 @@ void switchingTypeTogglesFileChooser() { // Browsing databases of the server assertTrue(FxTestSupport.call(() -> form.lookup("#browseButton").isVisible())); FxTestSupport.run(() -> dbTypeCombo().setValue(DbType.ORACLE)); - assertFalse(FxTestSupport.call(() -> form.lookup("#browseButton").isVisible())); + assertFalse(FxTestSupport.call(() -> form.lookup("#browseButton").isVisible())); // DuckDB is a file like SQLite + FxTestSupport.run(() -> dbTypeCombo().setValue(DbType.DUCKDB)); + assertFalse(FxTestSupport.call(() -> field("hostField").getParent().isVisible())); + assertTrue(FxTestSupport.call(() -> form.lookup("#browseButton").isVisible())); + assertEquals("File", FxTestSupport.call(() -> ((Label) form.lookup("#databaseLabel")).getText())); } @Test diff --git a/src/test/java/com/github/sqljam/face/view/DataPaneTest.java b/src/test/java/com/github/sqljam/face/view/DataPaneTest.java index ea2fda4..5f01280 100644 --- a/src/test/java/com/github/sqljam/face/view/DataPaneTest.java +++ b/src/test/java/com/github/sqljam/face/view/DataPaneTest.java @@ -36,6 +36,7 @@ import javafx.scene.control.Button; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; +import javafx.scene.control.Labeled; import javafx.scene.control.TableView; import javafx.scene.layout.StackPane; import javafx.stage.Stage; @@ -184,7 +185,7 @@ void rendersNullLongAndBinaryValues() { assertTrue(rows.get(2).get(3).startsWith("(40 bytes) 0x00000000"), rows.get(2).get(3)); assertTrue(rows.get(2).get(3).endsWith("...")); FxTestSupport.waitUntil(() -> !table(pane).lookupAll(".null-cell").isEmpty()); - assertEquals("NULL", FxTestSupport.call(() -> ((javafx.scene.control.Labeled) table(pane) + assertEquals("NULL", FxTestSupport.call(() -> ((Labeled) table(pane) .lookupAll(".null-cell").iterator().next()).getText())); } diff --git a/src/test/java/com/github/sqljam/face/view/DataQueryTest.java b/src/test/java/com/github/sqljam/face/view/DataQueryTest.java new file mode 100644 index 0000000..2a022eb --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/DataQueryTest.java @@ -0,0 +1,334 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.testfx.framework.junit5.ApplicationExtension; +import org.testfx.framework.junit5.Start; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.service.DatabaseSession; +import com.github.sqljam.impexp.TableQuery; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.CustomMenuItem; +import javafx.scene.control.Label; +import javafx.scene.control.MenuButton; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; + +/** + * @Description: DataQueryTest verifies the query of the data pane: selected columns, WHERE, GROUP BY, ORDER BY, + * paging of narrowed rows, invalid and empty inputs, clearing and exporting the rows of the query + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class DataQueryTest { + + @TempDir + File dir; + + private DatabaseSession session; + private StackPane root; + + @Start + void start(Stage stage) throws Exception { + FxTestSupport.createContext(dir); + ConnectionProfile profile = UiDatabase.create(dir, "ui"); + session = new DatabaseSession(profile); + root = new StackPane(); + stage.setScene(new Scene(root, 1200, 600)); + stage.show(); + } + + @AfterEach + void close() { + session.close(); + } + + private DataPane show(String table, int pageSize) { + DataPane pane = FxTestSupport.call(() -> { + DataPane dataPane = new DataPane(session, UiDatabase.CATALOG, UiDatabase.SCHEMA, table, pageSize); + root.getChildren().setAll(dataPane); + dataPane.load(1); + return dataPane; + }); + waitLoaded(pane); + // Columns of the choices are loaded with the first page + FxTestSupport.waitUntil(() -> !FxTestSupport.call(() -> pane.lookup("#columnsButton").isDisabled())); + return pane; + } + + private static void waitLoaded(DataPane pane) { + FxTestSupport.waitUntil(() -> !((Button) pane.lookup("#refreshButton")).isDisabled()); + } + + private static String text(DataPane pane, String id) { + return FxTestSupport.call(() -> ((Label) pane.lookup("#" + id)).getText()); + } + + @SuppressWarnings("unchecked") + private static TableView> table(DataPane pane) { + return (TableView>) pane.lookup("#dataTable"); + } + + private static List headers(DataPane pane) { + return FxTestSupport.call(() -> table(pane).getColumns().stream().map(TableColumn::getText) + .collect(Collectors.toList())); + } + + private static List firstColumn(DataPane pane) { + return FxTestSupport.call(() -> table(pane).getItems().stream().map(row -> row.get(0)) + .collect(Collectors.toList())); + } + + private static void setText(DataPane pane, String id, String text) { + FxTestSupport.run(() -> ((TextField) pane.lookup("#" + id)).setText(text)); + } + + /** + * Checks a column of the columns or group by menu + */ + private static void check(DataPane pane, String menuId, String column, boolean selected) { + FxTestSupport.run(() -> ((MenuButton) pane.lookup("#" + menuId)).getItems().stream() + .filter(item -> item instanceof CustomMenuItem + && ((CustomMenuItem) item).getContent() instanceof CheckBox) + .map(item -> (CheckBox) ((CustomMenuItem) item).getContent()) + .filter(checkBox -> column.equals(checkBox.getText())).findFirst().orElseThrow() + .setSelected(selected)); + } + + private static void apply(DataPane pane) { + FxTestSupport.run(() -> ((Button) pane.lookup("#applyQueryButton")).fire()); + waitLoaded(pane); + } + + private static String queryError(DataPane pane) { + return FxTestSupport.call(() -> { + Label label = (Label) pane.lookup("#queryErrorLabel"); + return label.isVisible() ? label.getText() : null; + }); + } + + private static String buttonText(DataPane pane, String id) { + return FxTestSupport.call(() -> ((MenuButton) pane.lookup("#" + id)).getText()); + } + + @Test + void selectsColumns() { + DataPane pane = show("T_PAGED", 50); + assertEquals("All columns", buttonText(pane, "columnsButton")); + assertEquals(List.of("ID", "NAME", "NOTE", "DATA", "CREATED"), headers(pane)); + check(pane, "columnsButton", "NOTE", false); + check(pane, "columnsButton", "DATA", false); + assertEquals("3 of 5 columns", buttonText(pane, "columnsButton")); + apply(pane); + assertEquals(List.of("ID", "NAME", "CREATED"), headers(pane)); + assertEquals(List.of("ID", "NAME", "CREATED"), FxTestSupport.call(() -> pane.getQuery().getColumns())); + // No column is not a query + for (String column : new String[]{"ID", "NAME", "CREATED"}) { + check(pane, "columnsButton", column, false); + } + apply(pane); + assertEquals("Please select at least one column", queryError(pane)); + assertEquals(List.of("ID", "NAME", "CREATED"), headers(pane)); + // All columns again by the All check + FxTestSupport.run(() -> ((CheckBox) ((CustomMenuItem) ((MenuButton) pane.lookup("#columnsButton")) + .getItems().get(0)).getContent()).fire()); + apply(pane); + assertNull(queryError(pane)); + assertEquals(5, headers(pane).size()); + assertTrue(FxTestSupport.call(() -> pane.getQuery().getColumns().isEmpty())); + } + + @Test + void filtersAndPagesRows() { + DataPane pane = show("T_PAGED", 50); + assertEquals("Page 1 / 5", text(pane, "pageLabel")); + setText(pane, "whereField", "ID > 100"); + apply(pane); + assertEquals("150 rows", text(pane, "totalLabel")); + assertEquals("Page 1 / 3", text(pane, "pageLabel")); + assertEquals("101", firstColumn(pane).get(0)); + FxTestSupport.run(() -> ((Button) pane.lookup("#lastButton")).fire()); + waitLoaded(pane); + assertEquals("Page 3 / 3", text(pane, "pageLabel")); + assertEquals("250", firstColumn(pane).get(49)); + // The WHERE keyword and a trailing semicolon are accepted + setText(pane, "whereField", "where ID <= 3;"); + apply(pane); + assertEquals("3 rows", text(pane, "totalLabel")); + assertEquals("ID <= 3", FxTestSupport.call(() -> pane.getQuery().getWhere())); + // Conditions of NULL and semicolons inside quotes + setText(pane, "whereField", "NAME IS NULL OR NAME = 'a;b'"); + apply(pane); + assertEquals("1 rows", text(pane, "totalLabel")); + assertEquals("2", firstColumn(pane).get(0)); + } + + @Test + void showsNoRows() { + DataPane pane = show("T_PAGED", 50); + setText(pane, "whereField", "1 = 0"); + apply(pane); + assertEquals("0 rows", text(pane, "totalLabel")); + assertEquals("Page 1 / 1", text(pane, "pageLabel")); + assertTrue(FxTestSupport.call(() -> pane.lookup("#nextButton").isDisabled())); + assertEquals("No rows", FxTestSupport.call(() -> ((Label) table(pane).getPlaceholder()).getText())); + } + + @Test + void ordersRows() { + DataPane pane = show("T_PAGED", 50); + setText(pane, "orderField", "ID DESC"); + apply(pane); + assertEquals("250", firstColumn(pane).get(0)); + // Rows with the same value are ordered by the primary key + setText(pane, "orderField", "order by CREATED"); + apply(pane); + assertEquals("1", firstColumn(pane).get(0)); + assertEquals("CREATED", FxTestSupport.call(() -> pane.getQuery().getOrderBy())); + } + + @Test + void groupsRows() { + DataPane pane = show("T_PAGED", 50); + assertEquals("Group by", buttonText(pane, "groupByButton")); + check(pane, "groupByButton", "CREATED", true); + assertEquals("Group by CREATED", buttonText(pane, "groupByButton")); + // Grouped rows have the group columns and the count, they are not exported + assertTrue(FxTestSupport.call(() -> pane.lookup("#columnsButton").isDisabled())); + apply(pane); + assertEquals(List.of("CREATED", "ROW_COUNT"), headers(pane).stream().map(String::toUpperCase) + .collect(Collectors.toList())); + assertEquals("1 rows", text(pane, "totalLabel")); + assertEquals("250", FxTestSupport.call(() -> table(pane).getItems().get(0).get(1))); + assertTrue(FxTestSupport.call(() -> pane.lookup("#exportQueryButton").isDisabled())); + // Groups with a condition and an order by the count + check(pane, "groupByButton", "CREATED", false); + check(pane, "groupByButton", "NAME", true); + setText(pane, "whereField", "ID <= 10"); + setText(pane, "orderField", TableQuery.COUNT_COLUMN + " DESC, NAME"); + apply(pane); + assertNull(queryError(pane)); + assertEquals("10 rows", text(pane, "totalLabel")); + } + + @Test + void reportsInvalidQuery() { + DataPane pane = show("T_PAGED", 50); + setText(pane, "whereField", "NO_SUCH_COLUMN > 1"); + apply(pane); + String error = queryError(pane); + assertTrue(error != null && error.startsWith("Invalid query: "), error); + assertTrue(FxTestSupport.call(() -> table(pane).getItems().isEmpty())); + // A statement separator is rejected before reading + setText(pane, "whereField", "ID > 1; DROP TABLE T_PAGED"); + apply(pane); + assertEquals("A clause must not contain ';': ID > 1; DROP TABLE T_PAGED", queryError(pane)); + setText(pane, "whereField", ""); + apply(pane); + assertNull(queryError(pane)); + assertEquals("250 rows", text(pane, "totalLabel")); + } + + @Test + void clearsQuery() { + DataPane pane = show("T_PAGED", 50); + setText(pane, "whereField", "ID > 200"); + setText(pane, "orderField", "ID DESC"); + check(pane, "columnsButton", "NOTE", false); + apply(pane); + assertEquals("50 rows", text(pane, "totalLabel")); + FxTestSupport.run(() -> ((Button) pane.lookup("#clearQueryButton")).fire()); + waitLoaded(pane); + assertEquals("250 rows", text(pane, "totalLabel")); + assertEquals("", FxTestSupport.call(() -> ((TextField) pane.lookup("#whereField")).getText())); + assertEquals(5, headers(pane).size()); + assertTrue(FxTestSupport.call(() -> pane.getQuery().isEmpty())); + } + + @Test + void exportsRowsOfQuery() { + DataPane pane = show("T_PAGED", 50); + AtomicReference exported = new AtomicReference<>(); + FxTestSupport.run(() -> pane.setOnExport(exported::set)); + setText(pane, "whereField", "ID > 200"); + check(pane, "columnsButton", "NOTE", false); + apply(pane); + assertFalse(FxTestSupport.call(() -> pane.lookup("#exportQueryButton").isDisabled())); + FxTestSupport.run(() -> ((Button) pane.lookup("#exportQueryButton")).fire()); + assertEquals("ID > 200", exported.get().getWhere()); + assertEquals(List.of("ID", "NAME", "DATA", "CREATED"), exported.get().getColumns()); + assertEquals("SELECT ID, NAME, DATA, CREATED WHERE ID > 200", exported.get().toString()); + } + + @Test + void reportsInvalidOrder() { + DataPane pane = show("T_PAGED", 50); + setText(pane, "orderField", "NO_SUCH_COLUMN DESC"); + apply(pane); + String error = queryError(pane); + assertTrue(error != null && error.startsWith("Invalid query: "), error); + setText(pane, "orderField", "ID; DELETE FROM T_PAGED"); + apply(pane); + assertEquals("A clause must not contain ';': ID; DELETE FROM T_PAGED", queryError(pane)); + } + + @Test + @SuppressWarnings("unchecked") + void keepsQueryAcrossPageSizes() { + DataPane pane = show("T_PAGED", 50); + setText(pane, "whereField", "ID > 100"); + apply(pane); + assertEquals("Page 1 / 3", text(pane, "pageLabel")); + FxTestSupport.run(() -> ((ComboBox) pane.lookup("#pageSizeCombo")).setValue(100)); + waitLoaded(pane); + // The condition stays, rows are paged by the new size + assertEquals("150 rows", text(pane, "totalLabel")); + assertEquals("Page 1 / 2", text(pane, "pageLabel")); + } + + @Test + void groupsNoRows() { + DataPane pane = show("T_PAGED", 50); + check(pane, "groupByButton", "NAME", true); + setText(pane, "whereField", "ID < 0"); + apply(pane); + assertNull(queryError(pane)); + assertEquals("0 rows", text(pane, "totalLabel")); + assertEquals("Page 1 / 1", text(pane, "pageLabel")); + } +} diff --git a/src/test/java/com/github/sqljam/face/view/DbTypeCellsTest.java b/src/test/java/com/github/sqljam/face/view/DbTypeCellsTest.java new file mode 100644 index 0000000..0742158 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/DbTypeCellsTest.java @@ -0,0 +1,133 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testfx.framework.junit5.ApplicationExtension; +import org.testfx.framework.junit5.Start; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.impexp.DbCategory; +import com.github.sqljam.impexp.DbType; +import javafx.scene.Scene; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; + +/** + * @Description: DbTypeCellsTest verifies data source categories: relational databases before OLAP databases, a + * header before the first item of each category in type combos and data source lists + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class DbTypeCellsTest { + + private Stage stage; + + @Start + void start(Stage stage) { + this.stage = stage; + stage.setScene(new Scene(new StackPane(), 400, 600)); + stage.show(); + } + + private static ConnectionProfile profile(String name, DbType dbType) { + ConnectionProfile profile = new ConnectionProfile(); + profile.setName(name); + profile.setDbType(dbType); + return profile; + } + + /** + * Texts of category headers of a shown list + */ + private List headers(ListView listView) { + FxTestSupport.run(() -> { + if (listView.getScene() == null) { + stage.getScene().setRoot(new StackPane(listView)); + } + listView.refresh(); + }); + // Cells are created by the next layout pass + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> !listView.lookupAll(".category-header").isEmpty())); + return FxTestSupport.call(() -> listView.lookupAll(".category-header").stream() + .map(node -> ((Label) node).getText()).collect(Collectors.toList())); + } + + @Test + void ordersTypesByCategory() { + List types = DbTypeCells.getDbTypesByCategory(); + assertEquals(DbType.values().length, types.size()); + int firstOlap = types.indexOf(DbType.DUCKDB); + assertTrue(types.subList(0, firstOlap).stream().allMatch(type -> type.getCategory() == DbCategory.RELATIONAL)); + assertTrue(types.subList(firstOlap, types.size()).stream() + .allMatch(type -> type.getCategory() == DbCategory.OLAP)); + assertEquals(DbCategory.OLAP, DbType.DUCKDB.getCategory()); + assertEquals(DbCategory.OLAP, DbType.CLICKHOUSE.getCategory()); + assertEquals(DbCategory.RELATIONAL, DbType.MYSQL.getCategory()); + assertEquals(DbCategory.RELATIONAL, DbTypeCells.categoryOf(null)); + assertEquals(DbCategory.RELATIONAL, DbTypeCells.categoryOf(new ConnectionProfile())); + } + + @Test + void startsCategories() { + List types = List.of(DbType.MYSQL, DbType.H2, DbType.DUCKDB); + assertTrue(DbTypeCells.isCategoryStart(types, 0, DbType::getCategory)); + assertFalse(DbTypeCells.isCategoryStart(types, 1, DbType::getCategory)); + assertTrue(DbTypeCells.isCategoryStart(types, 2, DbType::getCategory)); + assertFalse(DbTypeCells.isCategoryStart(types, -1, DbType::getCategory)); + assertFalse(DbTypeCells.isCategoryStart(types, 3, DbType::getCategory)); + } + + @Test + void showsHeadersInTypeCombo() { + List headers = FxTestSupport.call(() -> { + ComboBox combo = new ComboBox<>(); + DbTypeCells.setupDbTypeCombo(combo); + ListView listView = new ListView<>(); + listView.getItems().setAll(DbTypeCells.getDbTypesByCategory()); + listView.setCellFactory(combo.getCellFactory()); + return List.of(listView); + }).stream().flatMap(listView -> headers(listView).stream()).collect(Collectors.toList()); + assertEquals(List.of("Relational databases", "OLAP databases"), headers); + } + + @Test + void showsHeadersInProfileList() { + ListView listView = FxTestSupport.call(() -> { + ListView profiles = new ListView<>(); + DbTypeCells.setupProfileList(profiles); + profiles.getItems().setAll(profile("Orders", DbType.MYSQL), profile("Local", DbType.H2), + profile("Lake", DbType.DUCKDB)); + return profiles; + }); + assertEquals(List.of("Relational databases", "OLAP databases"), headers(listView)); + // Without OLAP data sources only one header is shown + FxTestSupport.run(() -> listView.getItems().remove(2)); + assertEquals(List.of("Relational databases"), headers(listView)); + } +} diff --git a/src/test/java/com/github/sqljam/face/view/DocScreenshots.java b/src/test/java/com/github/sqljam/face/view/DocScreenshots.java new file mode 100644 index 0000000..5deaa61 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/DocScreenshots.java @@ -0,0 +1,346 @@ +/* + * 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.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.List; + +import javax.imageio.ImageIO; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.testfx.framework.junit5.ApplicationExtension; +import org.testfx.framework.junit5.Start; +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.impexp.DataFormat; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.ExportListener; +import com.github.sqljam.impexp.TableQuery; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Dialog; +import javafx.scene.control.ListView; +import javafx.scene.control.RadioButton; +import javafx.scene.control.TabPane; +import javafx.scene.control.TextField; +import javafx.scene.control.TreeItem; +import javafx.scene.image.PixelReader; +import javafx.scene.image.WritableImage; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; + +/** + * @Description: DocScreenshots renders screenshots of the user interface in the default theme for the documents. + * It is not a test of the build (the name does not end with Test), run it by + * mvn test -Dtest=DocScreenshots -Dscreenshots.dir=docs/assets + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class DocScreenshots { + + private static final String STYLESHEET = "/com/github/sqljam/face/app.css"; + + @TempDir + File dir; + + private Stage stage; + private AppContext context; + private ConnectionProfile mysql; + + @Start + void start(Stage stage) throws Exception { + this.stage = stage; + FxTestSupport.run(() -> ThemeManager.apply(ThemeManager.DEFAULT_THEME)); + context = FxTestSupport.createContext(dir); + ConnectionProfile h2 = UiDatabase.create(dir, "H2 demo"); + context.getProfileRegistry().saveProfile(h2, true); + mysql = saveProfile("MySQL test", DbType.MYSQL, "localhost", 3306, "test", "fengy"); + mysql.setPassword("12345678"); + context.getProfileRegistry().saveProfile(mysql, true); + saveProfile("MariaDB demo", DbType.MARIADB, "localhost", 3307, "demo", "fengy"); + saveProfile("PostgreSQL demo", DbType.POSTGRESQL, "localhost", 5432, "demo", "fengy"); + saveProfile("Oracle demo", DbType.ORACLE, "localhost", 1521, "demo", "fengy"); + saveProfile("ClickHouse app", DbType.CLICKHOUSE, "localhost", 18123, "app", "fengy"); + ConnectionProfile duck = new ConnectionProfile(); + duck.setName("DuckDB lake"); + duck.setDbType(DbType.DUCKDB); + duck.setDatabase(new File(dir, "lake.duckdb").getAbsolutePath()); + context.getProfileRegistry().saveProfile(duck, true); + stage.setScene(new Scene(new StackPane(), 400, 300)); + stage.show(); + } + + private ConnectionProfile saveProfile(String name, DbType dbType, String host, int port, String database, + String username) throws IOException { + ConnectionProfile profile = new ConnectionProfile(); + profile.setName(name); + profile.setDbType(dbType); + profile.setHostname(host); + profile.setPort(port); + profile.setDatabase(database); + profile.setUsername(username); + profile.setPassword("secret"); + context.getProfileRegistry().saveProfile(profile, true); + return profile; + } + + private static File outputDir() { + File output = new File(System.getProperty("screenshots.dir", "target/screenshots")); + output.mkdirs(); + return output; + } + + /** + * Shows the root in a scene of the size with the stylesheet of the application and writes its snapshot + */ + private void render(Parent root, double width, double height, String name) throws IOException { + render(root, width, height, name, null); + } + + /** + * @param prepare runs after the root is shown, e.g. selecting a row + */ + private void render(Parent root, double width, double height, String name, Runnable prepare) + throws IOException { + WritableImage image = FxTestSupport.call(() -> { + Scene scene = new Scene(root, width, height); + scene.getStylesheets().add(DocScreenshots.class.getResource(STYLESHEET).toExternalForm()); + stage.setScene(scene); + stage.setWidth(width); + stage.setHeight(height); + root.applyCss(); + root.layout(); + if (prepare != null) { + prepare.run(); + } + return null; + }); + // Asynchronous loading (tree, columns, rows) settles before the snapshot, no field shows the focus + sleep(1500); + FxTestSupport.run(root::requestFocus); + image = FxTestSupport.call(() -> stage.getScene().snapshot(null)); + write(image, new File(outputDir(), name)); + } + + static void write(WritableImage image, File file) throws IOException { + int width = (int) image.getWidth(); + int height = (int) image.getHeight(); + BufferedImage buffered = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + PixelReader reader = image.getPixelReader(); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + buffered.setRGB(x, y, reader.getArgb(x, y)); + } + } + ImageIO.write(buffered, "png", file); + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Test + void login() throws IOException { + LoginView loginView = FxTestSupport.call(() -> new LoginView(context, profile -> { + })); + render(loginView, 860, 620, "login.png", () -> { + @SuppressWarnings("unchecked") + ListView list = (ListView) loginView.lookup("#profileList"); + list.getItems().stream().filter(profile -> "MySQL test".equals(profile.getName())).findFirst() + .ifPresent(list.getSelectionModel()::select); + }); + } + + + /** + * Node of a fixture table of the MySQL test database + */ + private DbNode tableNode(String table) { + return DbNode.table(mysql, new TableInfo("test", null, table, null, false, false)); + } + + /** + * Main window with the MySQL connection expanded and a table opened + */ + private MainView openTable(String table, double width, double height) { + MainView mainView = FxTestSupport.call(() -> new MainView(context)); + FxTestSupport.run(() -> { + Scene scene = new Scene(mainView, width, height); + scene.getStylesheets().add(DocScreenshots.class.getResource(STYLESHEET).toExternalForm()); + stage.setScene(scene); + stage.setWidth(width); + stage.setHeight(height); + // Children of split panes exist with their skins + mainView.applyCss(); + mainView.layout(); + }); + ConnectionTree tree = FxTestSupport.call(() -> (ConnectionTree) mainView.lookup("#connectionTree")); + TreeItem connection = FxTestSupport.call(() -> tree.getRoot().getChildren().stream() + .filter(item -> "MySQL test".equals(item.getValue().getLabel())).findFirst().orElseThrow()); + expand(connection); + TreeItem catalog = FxTestSupport.call(() -> connection.getChildren().stream() + .filter(item -> "test".equals(item.getValue().getLabel())).findFirst().orElseThrow()); + expand(catalog); + FxTestSupport.run(() -> { + tree.getSelectionModel().select(catalog); + mainView.openTable(tableNode(table)); + }); + return mainView; + } + + private static void expand(TreeItem item) { + FxTestSupport.run(() -> item.setExpanded(true)); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> !item.getChildren().isEmpty() && item.getChildren() + .stream().noneMatch(child -> child.getValue().getKind() == DbNode.Kind.LOADING))); + } + + private void snapshot(String name) throws IOException { + sleep(2000); + WritableImage image = FxTestSupport.call(() -> stage.getScene().snapshot(null)); + write(image, new File(outputDir(), name)); + } + + /** + * Content tab of the opened table: 0 columns, 4 data + */ + private static void selectTableTab(MainView mainView, int index) { + FxTestSupport.run(() -> { + TabPane tables = (TabPane) mainView.lookup("#tableTabs"); + TabPane contents = (TabPane) tables.getSelectionModel().getSelectedItem().getContent(); + contents.getSelectionModel().select(index); + }); + } + + @Test + void mainWindowWithTable() throws IOException { + MainView mainView = openTable("sjm_emp", 1280, 753); + selectTableTab(mainView, 0); + snapshot("main-dark.png"); + for (String[] theme : new String[][]{{"Primer Light", "main-light.png"}, {"Nord Dark", "main-nord.png"}}) { + FxTestSupport.run(() -> ThemeManager.apply(theme[0])); + snapshot(theme[1]); + } + FxTestSupport.run(() -> ThemeManager.apply(ThemeManager.DEFAULT_THEME)); + } + + @Test + void tableDataWithQuery() throws IOException { + MainView mainView = openTable("sjm_emp", 1280, 753); + selectTableTab(mainView, 4); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> !mainView.lookup("#columnsButton").isDisabled())); + FxTestSupport.run(() -> { + ((TextField) mainView.lookup("#whereField")).setText("salary > 1000 AND active = 1"); + ((TextField) mainView.lookup("#orderField")).setText("salary DESC"); + ((Button) mainView.lookup("#applyQueryButton")).fire(); + }); + snapshot("table-data.png"); + } + + /** + * Dialogs are rendered by their own window + */ + private void snapshotDialog(Dialog dialog, double width, double height, String name) + throws IOException { + snapshotDialog(dialog, width, height, name, null); + } + + /** + * @param prepare runs after the dialog is shown, when the controls of scroll panes exist + */ + private void snapshotDialog(Dialog dialog, double width, double height, String name, Runnable prepare) + throws IOException { + FxTestSupport.run(() -> { + dialog.getDialogPane().setPrefSize(width, height); + dialog.show(); + dialog.getDialogPane().applyCss(); + dialog.getDialogPane().layout(); + if (prepare != null) { + prepare.run(); + } + }); + sleep(2500); + WritableImage image = FxTestSupport.call(() -> dialog.getDialogPane().getScene().snapshot(null)); + write(image, new File(outputDir(), name)); + FxTestSupport.run(dialog::close); + } + + @Test + void exportWizard() throws IOException { + FxTestSupport.run(() -> stage.setScene(new Scene(new StackPane(), 400, 300))); + ExportDialog dialog = FxTestSupport.call(() -> new ExportDialog(stage, context, tableNode("sjm_emp"), + new TableQuery(List.of("id", "name", "email", "salary", "hired"), "salary > 1000", null, + "salary DESC"))); + snapshotDialog(dialog, 600, 753, "export-wizard.png", () -> { + ((RadioButton) dialog.getDialogPane().lookup("#parquetFormatRadio")).setSelected(true); + ((TextField) dialog.getDialogPane().lookup("#directoryField")).setText(new File(dir, "export") + .getAbsolutePath()); + }); + } + + /** + * A Parquet package of the fixture tables of MySQL + */ + private File exportParquetPackage() throws Exception { + File packageDir = new File(dir, "mysql-parquet"); + TransferRequest request = new TransferRequest(); + request.setSource(mysql); + request.setSourceCatalog("test"); + request.setTables(List.of("sjm_dept", "sjm_emp", "sjm_emp_tag", "sjm_sales")); + request.setTarget(TransferRequest.Target.SCRIPT); + request.setOutputDirectory(packageDir); + request.setScriptDbType(DbType.POSTGRESQL); + request.setDataFormat(DataFormat.PARQUET); + context.getTransferService().transfer(request, ExportListener.NONE); + return packageDir; + } + + @Test + void parquetPackage() throws Exception { + File packageDir = exportParquetPackage(); + FxTestSupport.run(() -> stage.setScene(new Scene(new StackPane(), 400, 300))); + context.getSettings().setLastImportDirectory(packageDir.getAbsolutePath()); + ImportPackageDialog importDialog = FxTestSupport.call(() -> new ImportPackageDialog(stage, context, null)); + snapshotDialog(importDialog, 600, 753, "import-package.png"); + + PackageViewer viewer = FxTestSupport.call(() -> new PackageViewer(stage, context, packageDir)); + FxTestSupport.run(() -> { + viewer.show(); + viewer.getStage().setWidth(1280); + viewer.getStage().setHeight(753); + viewer.selectFile(new File(packageDir, "data/sjm_emp.parquet")); + }); + sleep(3000); + WritableImage image = FxTestSupport.call(() -> viewer.getStage().getScene().snapshot(null)); + write(image, new File(outputDir(), "package-viewer.png")); + FxTestSupport.run(() -> viewer.getStage().close()); + + ImportParquetDialog parquetDialog = FxTestSupport.call(() -> new ImportParquetDialog(stage, context, null)); + FxTestSupport.run(() -> parquetDialog.addFiles(List.of(new File(packageDir, "data/sjm_emp.parquet")))); + snapshotDialog(parquetDialog, 760, 753, "import-parquet.png"); + } +} diff --git a/src/test/java/com/github/sqljam/face/view/ExampleDatabaseViewTest.java b/src/test/java/com/github/sqljam/face/view/ExampleDatabaseViewTest.java new file mode 100644 index 0000000..e031326 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/ExampleDatabaseViewTest.java @@ -0,0 +1,239 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.testfx.framework.junit5.ApplicationExtension; +import org.testfx.framework.junit5.Start; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.model.TableInfo; +import com.github.sqljam.face.service.ExampleDatabase; +import javafx.application.Platform; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.control.MenuBar; +import javafx.scene.control.MenuItem; +import javafx.scene.control.TabPane; +import javafx.scene.control.TextField; +import javafx.scene.control.TreeItem; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; + +/** + * @Description: ExampleDatabaseViewTest verifies the first start of a new user: the example data source is selected + * on the login page, its tables are browsed and its rows are queried + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class ExampleDatabaseViewTest { + + @TempDir + File dir; + + private AppContext context; + private StackPane root; + private ConnectionProfile example; + + @Start + void start(Stage stage) throws Exception { + context = FxTestSupport.createContext(dir); + example = new ExampleDatabase(true, new File(dir, "example")) + .createAtFirstStart(new File(dir, "connections.json")).orElseThrow(); + context.getProfileRegistry().saveProfile(example, true); + root = new StackPane(); + stage.setScene(new Scene(root, 1200, 760)); + stage.show(); + } + + @AfterEach + void close() { + context.getSessionManager().closeAll(); + } + + @Test + @SuppressWarnings("unchecked") + void selectsExampleOnLoginPage() { + LoginView view = FxTestSupport.call(() -> { + LoginView loginView = new LoginView(context, profile -> { + }); + root.getChildren().setAll(loginView); + loginView.applyCss(); + loginView.layout(); + return loginView; + }); + ListView list = FxTestSupport.call(() -> (ListView) view.lookup( + "#profileList")); + assertEquals(List.of(ExampleDatabase.PROFILE_NAME), FxTestSupport.call(() -> list.getItems().stream() + .map(ConnectionProfile::getName).collect(Collectors.toList()))); + assertEquals(ExampleDatabase.PROFILE_NAME, FxTestSupport.call(() -> list.getSelectionModel() + .getSelectedItem().getName())); + // The example is connected without a password + assertEquals("sa", FxTestSupport.call(() -> ((TextField) view.lookup("#usernameField")).getText())); + } + + @Test + void browsesAndQueriesExampleTables() { + MainView mainView = FxTestSupport.call(() -> { + MainView view = new MainView(context); + root.getChildren().setAll(view); + view.applyCss(); + view.layout(); + return view; + }); + ConnectionTree tree = FxTestSupport.call(() -> (ConnectionTree) mainView.lookup("#connectionTree")); + TreeItem connection = FxTestSupport.call(() -> tree.getRoot().getChildren().get(0)); + TreeItem schema = expand(expand(connection).getChildren().get(0)); + TreeItem publicSchema = FxTestSupport.call(() -> schema.getChildren().stream() + .filter(item -> "PUBLIC".equals(item.getValue().getLabel())).findFirst().orElse(schema)); + TreeItem tables = expand(publicSchema); + List names = FxTestSupport.call(() -> tables.getChildren().stream() + .map(item -> item.getValue().getLabel()).collect(Collectors.toList())); + assertTrue(names.containsAll(List.of("CATEGORIES", "CUSTOMERS", "ORDERS", "ORDER_ITEMS", "PRODUCTS")), + names.toString()); + // Paid orders of the data viewer + FxTestSupport.run(() -> mainView.openTable(DbNode.table(example, new TableInfo(publicSchema.getParent() + .getValue().getLabel(), "PUBLIC", "ORDERS", null, false, false)))); + FxTestSupport.run(() -> { + TabPane tabs = (TabPane) mainView.lookup("#tableTabs"); + ((TabPane) tabs.getSelectionModel().getSelectedItem().getContent()).getSelectionModel().select(4); + }); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> { + Label total = (Label) mainView.lookup("#totalLabel"); + return total != null && "300 rows".equals(total.getText()); + })); + FxTestSupport.run(() -> { + ((TextField) mainView.lookup("#whereField")).setText("status = 'PAID'"); + ((Button) mainView.lookup("#applyQueryButton")).fire(); + }); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> "60 rows".equals(((Label) mainView + .lookup("#totalLabel")).getText()))); + } + + private static TreeItem expand(TreeItem item) { + FxTestSupport.run(() -> item.setExpanded(true)); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> !item.getChildren().isEmpty() && item.getChildren() + .stream().noneMatch(child -> child.getValue().getKind() == DbNode.Kind.LOADING))); + return item; + } + + private LoginView showLogin() { + return FxTestSupport.call(() -> { + LoginView loginView = new LoginView(context, profile -> { + }); + root.getChildren().setAll(loginView); + loginView.applyCss(); + loginView.layout(); + return loginView; + }); + } + + @SuppressWarnings("unchecked") + private static List names(LoginView view) { + return FxTestSupport.call(() -> ((ListView) view.lookup("#profileList")).getItems() + .stream().map(ConnectionProfile::getName).collect(Collectors.toList())); + } + + private long countOrders() throws Exception { + try (Connection connection = DriverManager.getConnection(example.getJdbcUrl(), "sa", ""); + ResultSet rs = connection.createStatement().executeQuery("SELECT COUNT(*) FROM orders")) { + rs.next(); + return rs.getLong(1); + } + } + + @Test + @SuppressWarnings("unchecked") + void restoresRemovedExampleOnLoginPage() throws Exception { + // The user removed the example data source + context.getProfileRegistry().removeProfile(ExampleDatabase.PROFILE_ID); + LoginView view = showLogin(); + assertTrue(names(view).isEmpty()); + // No confirmation, nothing is replaced + FxTestSupport.run(() -> ((Button) view.lookup("#exampleButton")).fire()); + FxTestSupport.waitUntil(() -> names(view).contains(ExampleDatabase.PROFILE_NAME)); + assertEquals(ExampleDatabase.PROFILE_NAME, FxTestSupport.call(() -> ((ListView) view + .lookup("#profileList")).getSelectionModel().getSelectedItem().getName())); + assertEquals(1, context.getProfileRegistry().getProfiles().size()); + assertEquals(300, countOrders()); + } + + @Test + void replacesExampleAfterConfirmation() throws Exception { + try (Connection connection = DriverManager.getConnection(example.getJdbcUrl(), "sa", ""); + Statement statement = connection.createStatement()) { + statement.execute("DELETE FROM order_items"); + statement.execute("DELETE FROM orders"); + } + LoginView view = showLogin(); + // Cancelled, the changed database is kept + FxTestSupport.fireLater((Button) FxTestSupport.call(() -> view.lookup("#exampleButton"))); + FxTestSupport.closeDialog(ButtonType.CANCEL); + assertEquals(0, countOrders()); + // Confirmed, the example is created again with one data source + FxTestSupport.fireLater((Button) FxTestSupport.call(() -> view.lookup("#exampleButton"))); + FxTestSupport.closeDialog(ButtonType.OK); + FxTestSupport.waitUntil(() -> { + try { + return countOrders() == 300; + } catch (Exception e) { + return false; + } + }); + assertEquals(List.of(ExampleDatabase.PROFILE_NAME), names(view)); + } + + @Test + void restoresExampleFromMenu() { + MainView mainView = FxTestSupport.call(() -> { + MainView view = new MainView(context); + root.getChildren().setAll(view); + view.applyCss(); + view.layout(); + return view; + }); + FxTestSupport.run(() -> mainView.openTable(DbNode.table(example, new TableInfo(null, "PUBLIC", "ORDERS", + null, false, false)))); + assertEquals(1, FxTestSupport.call(() -> ((TabPane) mainView.lookup("#tableTabs")).getTabs().size())); + MenuItem restore = FxTestSupport.call(() -> ((MenuBar) mainView + .lookup(".menu-bar")).getMenus().stream().flatMap(menu -> menu.getItems().stream()) + .filter(item -> "restoreExampleMenuItem".equals(item.getId())).findFirst().orElseThrow()); + assertEquals("Restore Example Database", restore.getText()); + Platform.runLater(restore::fire); + FxTestSupport.closeDialog(ButtonType.OK); + // Tabs of the replaced database are closed + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> ((TabPane) mainView.lookup("#tableTabs")).getTabs() + .isEmpty())); + } +} diff --git a/src/test/java/com/github/sqljam/face/view/ExportDialogTest.java b/src/test/java/com/github/sqljam/face/view/ExportDialogTest.java index 3cea8a9..2b5cf7d 100644 --- a/src/test/java/com/github/sqljam/face/view/ExportDialogTest.java +++ b/src/test/java/com/github/sqljam/face/view/ExportDialogTest.java @@ -22,9 +22,14 @@ import java.io.File; import java.nio.file.Files; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; import java.util.List; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; @@ -35,9 +40,13 @@ import com.github.sqljam.face.model.TableInfo; import com.github.sqljam.face.model.TransferRequest; import com.github.sqljam.impexp.DataFileStrategy; +import com.github.sqljam.impexp.DataFormat; import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.ExportListener; import com.github.sqljam.impexp.ExportMode; import com.github.sqljam.impexp.IdentifierCase; +import com.github.sqljam.impexp.ImpExpException; +import com.github.sqljam.impexp.TableQuery; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Button; @@ -263,6 +272,10 @@ void validatesMaxFileSize() { ComboBox targetCombo = (ComboBox) node("targetCombo"); targetCombo.setValue(targetCombo.getItems().get(0)); }); + @SuppressWarnings("unchecked") + ComboBox targetSchema = (ComboBox) node("targetSchemaCombo"); + FxTestSupport.waitUntil(() -> !targetSchema.getItems().isEmpty()); + FxTestSupport.run(() -> targetSchema.getEditor().setText("COPY")); assertFalse(startDisabled(), error()); } @@ -393,4 +406,158 @@ void requiresSourceDataSource() throws Exception { assertTrue(startDisabled()); assertEquals("Please choose a source data source", error()); } + + @Test + @SuppressWarnings("unchecked") + void mapsParquetFormat() { + open(schemaNode()); + setText("directoryField", dir.getAbsolutePath()); + // SQL is the default format, compression is only for Parquet + assertTrue(FxTestSupport.call(() -> ((RadioButton) node("sqlFormatRadio")).isSelected())); + assertTrue(FxTestSupport.call(() -> node("compressionCombo").isDisabled())); + assertEquals(DataFormat.SQL, build().getDataFormat()); + FxTestSupport.run(() -> { + ((RadioButton) node("parquetFormatRadio")).setSelected(true); + ((ComboBox) node("compressionCombo")).setValue("SNAPPY"); + }); + assertFalse(FxTestSupport.call(() -> node("compressionCombo").isDisabled())); + // Options of sql data files are disabled + for (String id : new String[]{"singleFileRadio", "perTableRadio", "maxFileSizeSpinner", + "lobSeparatedCheck"}) { + assertTrue(FxTestSupport.call(() -> node(id).isDisabled()), id); + } + String hint = FxTestSupport.call(() -> ((Label) node("layoutHint")).getText()); + assertEquals("schema.sql\ndata/
.parquet\nconstraints.sql\nmanifest.json", hint); + assertEquals(List.of("ZSTD", "SNAPPY", "GZIP", "UNCOMPRESSED"), + FxTestSupport.call(() -> List.copyOf(((ComboBox) node("compressionCombo")).getItems()))); + assertFalse(startDisabled(), error()); + TransferRequest request = build(); + assertEquals(DataFormat.PARQUET, request.getDataFormat()); + assertEquals("SNAPPY", request.getCompression()); + // Back to SQL + FxTestSupport.run(() -> ((RadioButton) node("sqlFormatRadio")).setSelected(true)); + assertFalse(FxTestSupport.call(() -> node("perTableRadio").isDisabled())); + assertEquals(DataFormat.SQL, build().getDataFormat()); + } + + @Test + @SuppressWarnings("unchecked") + void groupsScriptDatabaseTypesByCategory() { + open(schemaNode()); + List types = FxTestSupport.call(() -> List.copyOf(((ComboBox) node("scriptDbTypeCombo")) + .getItems())); + assertEquals(DbTypeCells.getDbTypesByCategory(), types); + // OLAP databases are listed after relational databases + assertEquals(List.of(DbType.DUCKDB, DbType.CLICKHOUSE), types.subList(types.size() - 2, types.size())); + } + + @SuppressWarnings("unchecked") + private void selectSameSchemaAsTarget() { + FxTestSupport.run(() -> ((RadioButton) node("databaseRadio")).setSelected(true)); + ComboBox targetCombo = (ComboBox) node("targetCombo"); + FxTestSupport.run(() -> targetCombo.setValue(targetCombo.getItems().get(0))); + ComboBox targetSchema = (ComboBox) node("targetSchemaCombo"); + FxTestSupport.waitUntil(() -> !targetSchema.getItems().isEmpty()); + FxTestSupport.run(() -> targetSchema.getEditor().setText(UiDatabase.SCHEMA)); + } + + @Test + void requiresTableNameToCopyInSameSchema() { + open(schemaNode()); + selectSameSchemaAsTarget(); + // The tables would be imported into themselves + assertTrue(startDisabled()); + assertEquals("Source and target are the same tables, please choose another schema or a target table name" + + " such as {table}_copy", error()); + setText("tableNamePatternField", "{table}"); + assertTrue(startDisabled()); + setText("tableNamePatternField", "{table}_copy"); + assertFalse(startDisabled(), error()); + TransferRequest request = build(); + assertEquals("{table}_copy", request.getTableNamePattern()); + assertEquals(UiDatabase.SCHEMA, request.getTargetSchema()); + // A name without {table} is the name of one table + setText("tableNamePatternField", "T_BACKUP"); + assertEquals("The target table name must contain {table} when several tables are imported", error()); + // Another schema needs no table name + setText("tableNamePatternField", ""); + FxTestSupport.run(() -> ((ComboBox) node("targetSchemaCombo")).getEditor().setText("COPY")); + assertFalse(startDisabled(), error()); + assertNull(build().getTableNamePattern()); + } + + @Test + void copiesTablesInSameSchema() throws Exception { + TransferRequest request = new TransferRequest(); + request.setSource(profile); + request.setSourceCatalog(UiDatabase.CATALOG); + request.setSourceSchema(UiDatabase.SCHEMA); + request.setTables(List.of("T_ONE", "T_PAGED")); + request.setTarget(TransferRequest.Target.DATABASE); + request.setTargetProfile(profile); + request.setTargetCatalog(UiDatabase.CATALOG); + request.setTargetSchema(UiDatabase.SCHEMA); + // The same tables are rejected, the source tables are kept + ImpExpException e = Assertions.assertThrows( + ImpExpException.class, () -> context.getTransferService().transfer(request, + ExportListener.NONE)); + assertTrue(e.getMessage().startsWith("Source and target tables are the same"), e.getMessage()); + request.setTableNamePattern("{table}_copy"); + context.getTransferService().transfer(request, ExportListener.NONE); + try (Connection connection = DriverManager.getConnection(profile.getJdbcUrl(), "sa", ""); + Statement statement = connection.createStatement()) { + for (String[] table : new String[][]{{"T_ONE", "5"}, {"T_ONE_COPY", "5"}, + {"T_PAGED", String.valueOf(UiDatabase.PAGED_ROWS)}, + {"T_PAGED_COPY", String.valueOf(UiDatabase.PAGED_ROWS)}}) { + try (ResultSet rs = statement.executeQuery("SELECT COUNT(*) FROM " + table[0])) { + rs.next(); + assertEquals(Integer.parseInt(table[1]), rs.getInt(1), table[0]); + } + } + // Keys and indexes of the copy have their own names + try (ResultSet rs = statement.executeQuery("SELECT COUNT(*) FROM INFORMATION_SCHEMA.INDEXES" + + " WHERE TABLE_NAME = 'T_PAGED_COPY'")) { + rs.next(); + assertEquals(2, rs.getInt(1)); + } + statement.execute("DROP TABLE T_ONE_COPY"); + statement.execute("DROP TABLE T_PAGED_COPY"); + } + } + + @Test + void exportsRowsOfQuery() { + TableQuery query = new TableQuery(List.of("ID", "NAME"), "ID > 200", null, "ID DESC"); + dialog = FxTestSupport.call(() -> { + ExportDialog exportDialog = new ExportDialog(stage, context, tableNode("T_PAGED"), query); + exportDialog.show(); + return exportDialog; + }); + FxTestSupport.waitUntil(() -> list().getItems().size() == 3); + setText("directoryField", dir.getAbsolutePath()); + // The query of the data viewer is shown for its table + String label = FxTestSupport.call(() -> ((Label) node("queryLabel")).getText()); + assertEquals("Rows of T_PAGED: SELECT ID, NAME WHERE ID > 200 ORDER BY ID DESC", label); + assertTrue(FxTestSupport.call(() -> node("queryLabel").isVisible())); + TransferRequest request = build(); + assertEquals(List.of("T_PAGED"), request.getTables()); + assertEquals(query, request.getTableQueries().get("T_PAGED")); + // Other tables selected too: the query narrows its own table only + FxTestSupport.run(() -> ((Button) node("selectAllButton")).fire()); + TransferRequest all = build(); + assertTrue(all.getTables().isEmpty(), "All tables of the schema"); + assertEquals(1, all.getTableQueries().size()); + assertEquals(query, all.getTableQueries().get("T_PAGED")); + // A query without columns, condition and order narrows nothing + FxTestSupport.run(dialog::close); + dialog = FxTestSupport.call(() -> { + ExportDialog plain = new ExportDialog(stage, context, tableNode("T_PAGED"), new TableQuery()); + plain.show(); + plain.getDialogPane().applyCss(); + plain.getDialogPane().layout(); + return plain; + }); + assertFalse(FxTestSupport.call(() -> node("queryLabel").isVisible())); + assertTrue(build().getTableQueries().isEmpty()); + } } diff --git a/src/test/java/com/github/sqljam/face/view/FxTestSupport.java b/src/test/java/com/github/sqljam/face/view/FxTestSupport.java index 498f69d..951822f 100644 --- a/src/test/java/com/github/sqljam/face/view/FxTestSupport.java +++ b/src/test/java/com/github/sqljam/face/view/FxTestSupport.java @@ -27,8 +27,11 @@ import org.testfx.util.WaitForAsyncUtils; import com.github.sqljam.config.Config; import com.github.sqljam.face.service.ConnectionStore; +import com.github.sqljam.face.service.ExampleDatabase; import com.github.sqljam.face.service.SettingsStore; import javafx.application.Platform; +import javafx.collections.ListChangeListener; +import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ButtonType; import javafx.scene.control.DialogPane; @@ -48,11 +51,58 @@ public final class FxTestSupport { private FxTestSupport() { } + private static final String STYLESHEET = "/com/github/sqljam/face/app.css"; + private static boolean styled; + + /** + * Windows of tests look like the application: the default theme, and the stylesheet of the application on + * windows other than dialogs (dialogs of the application have the theme only) + */ + public static synchronized void applyAppStyle() { + if (styled) { + return; + } + styled = true; + Runnable install = () -> { + ThemeManager.apply(ThemeManager.DEFAULT_THEME); + Window.getWindows().forEach(FxTestSupport::styleWindow); + Window.getWindows().addListener((ListChangeListener) change -> { + while (change.next()) { + change.getAddedSubList().forEach(FxTestSupport::styleWindow); + } + }); + }; + if (Platform.isFxApplicationThread()) { + install.run(); + } else { + Platform.runLater(install); + } + } + + private static void styleWindow(Window window) { + styleScene(window.getScene()); + window.sceneProperty().addListener((obs, oldScene, scene) -> styleScene(scene)); + } + + private static void styleScene(Scene scene) { + if (scene == null || scene.getRoot() instanceof DialogPane) { + return; + } + String stylesheet = FxTestSupport.class.getResource(STYLESHEET).toExternalForm(); + if (!scene.getStylesheets().contains(stylesheet)) { + scene.getStylesheets().add(stylesheet); + } + } + public static AppContext createContext(File dir) { + applyAppStyle(); Config config = new Config(new File(dir, Config.FILE_NAME)); Config.setInstance(config); - return new AppContext(new SettingsStore(config), new ProfileRegistry(new ConnectionStore( + AppContext context = new AppContext(new SettingsStore(config), new ProfileRegistry(new ConnectionStore( new File(dir, "connections.json"))), null); + // The example database of tests is not created in the home directory of the user + context.setExampleDatabase(new ExampleDatabase(true, new File(dir, "example"))); + return context; } public static T call(Callable callable) { diff --git a/src/test/java/com/github/sqljam/face/view/ImportPackageDialogTest.java b/src/test/java/com/github/sqljam/face/view/ImportPackageDialogTest.java index ee96de2..19c8ff9 100644 --- a/src/test/java/com/github/sqljam/face/view/ImportPackageDialogTest.java +++ b/src/test/java/com/github/sqljam/face/view/ImportPackageDialogTest.java @@ -21,6 +21,10 @@ import java.io.File; import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; import java.util.List; import java.util.stream.Collectors; @@ -32,14 +36,17 @@ import org.testfx.framework.junit5.Start; 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.DataFormat; import com.github.sqljam.impexp.DbType; import com.github.sqljam.impexp.ExportListener; import com.github.sqljam.impexp.ExportManifest; +import com.github.sqljam.impexp.ParquetImporter; import com.github.sqljam.impexp.ScriptExportHandler; -import com.github.sqljam.impexp.ScriptImporter; import javafx.event.ActionEvent; import javafx.scene.Node; import javafx.scene.Scene; +import javafx.scene.control.Button; import javafx.scene.control.ButtonType; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; @@ -47,6 +54,7 @@ import javafx.scene.control.TextField; import javafx.scene.layout.StackPane; import javafx.stage.Stage; +import javafx.stage.Window; /** * @Description: ImportPackageDialogTest verifies importing export packages: manifest summary, status, data source @@ -101,7 +109,11 @@ void close() { * Exports tables of the source database as an export package */ private File exportPackage() throws Exception { - File packageDir = new File(dir, "package"); + return exportPackage("package", DataFormat.SQL); + } + + private File exportPackage(String name, DataFormat dataFormat) throws Exception { + File packageDir = new File(dir, name); TransferRequest request = new TransferRequest(); request.setSource(source); request.setSourceCatalog(UiDatabase.CATALOG); @@ -109,6 +121,7 @@ private File exportPackage() throws Exception { request.setTarget(TransferRequest.Target.SCRIPT); request.setOutputDirectory(packageDir); request.setScriptDbType(DbType.H2); + request.setDataFormat(dataFormat); context.getTransferService().transfer(request, ExportListener.NONE); return packageDir; } @@ -239,7 +252,7 @@ void warnsWithoutDataSourceOfTargetType() throws Exception { private ProgressDialog importPackage(File packageDir) { ProgressDialog progress = FxTestSupport.call(() -> new ProgressDialog(stage, context, "Import", 3)); FxTestSupport.run(() -> progress.run(() -> context.getTransferService().importScripts(target, null, null, - packageDir, true, progress.getListener()), (ScriptImporter importer) -> String.format( + packageDir, true, progress.getListener()), (AbstractFileImporter importer) -> String.format( "%d %d %d", importer.getExecutedCount(), importer.getLobCount(), importer.getFailedCount()), null)); FxTestSupport.waitUntil(progress::isFinished); return progress; @@ -257,8 +270,8 @@ void importsPackage() throws Exception { assertEquals("Completed", status(progress)); assertEquals("Overall 100%", FxTestSupport.call(() -> ((Label) progress.getStage().getScene().getRoot() .lookup("#percentLabel")).getText())); - try (java.sql.Connection connection = java.sql.DriverManager.getConnection(target.getJdbcUrl(), "sa", ""); - java.sql.ResultSet rs = connection.createStatement().executeQuery("SELECT COUNT(*) FROM T_PAGED")) { + try (Connection connection = DriverManager.getConnection(target.getJdbcUrl(), "sa", ""); + ResultSet rs = connection.createStatement().executeQuery("SELECT COUNT(*) FROM T_PAGED")) { rs.next(); assertEquals(UiDatabase.PAGED_ROWS, rs.getInt(1)); } @@ -268,7 +281,7 @@ void importsPackage() throws Exception { void reportsModifiedFile() throws Exception { File packageDir = exportPackage(); File dataFile = new File(packageDir, ScriptExportHandler.DATA_FILE_NAME); - Files.writeString(dataFile.toPath(), "-- modified\n", java.nio.file.StandardOpenOption.APPEND); + Files.writeString(dataFile.toPath(), "-- modified\n", StandardOpenOption.APPEND); ProgressDialog progress = importPackage(packageDir); assertEquals("Failed", status(progress)); assertTrue(progress.getErrorLogCount() > 0); @@ -281,4 +294,66 @@ void reportsMissingFile() throws Exception { ProgressDialog progress = importPackage(packageDir); assertEquals("Failed", status(progress)); } + + @Test + void showsDataFormatOfPackages() throws Exception { + open(exportPackage()); + assertEquals("SQL scripts", text("formatValue")); + FxTestSupport.run(dialog::close); + open(exportPackage("parquet", DataFormat.PARQUET)); + assertEquals("COMPLETED", text("statusValue")); + assertEquals("Parquet", text("formatValue")); + assertFalse(startDisabled()); + } + + @Test + void guidesParquetFilesWithoutManifest() throws Exception { + File parquetDir = exportPackage("parquet", DataFormat.PARQUET); + assertTrue(new File(parquetDir, ExportManifest.FILE_NAME).delete()); + open(parquetDir); + assertTrue(startDisabled()); + assertEquals("The directory contains Parquet files without manifest.json, please use Import Parquet Files", + text("warningLabel")); + assertTrue(ImportPackageDialog.isParquetDirectory(parquetDir)); + assertFalse(ImportPackageDialog.isParquetDirectory(dir)); + } + + @Test + void viewsPackage() throws Exception { + File packageDir = exportPackage(); + open(packageDir); + FxTestSupport.run(() -> ((Button) node("viewPackageButton")).fire()); + FxTestSupport.waitUntil(() -> findViewer() != null); + Stage viewer = findViewer(); + assertEquals("Export Package - package", FxTestSupport.call(viewer::getTitle)); + FxTestSupport.run(viewer::close); + } + + private static Stage findViewer() { + return FxTestSupport.call(() -> Window.getWindows().stream().filter(window -> window instanceof Stage + && "Export Package - package".equals(((Stage) window).getTitle())).map(window -> (Stage) window) + .findFirst().orElse(null)); + } + + @Test + void importsParquetPackage() throws Exception { + File packageDir = exportPackage("parquet", DataFormat.PARQUET); + ProgressDialog progress = FxTestSupport.call(() -> new ProgressDialog(stage, context, "Import", 3)); + FxTestSupport.run(() -> progress.run(() -> context.getTransferService().importScripts(target, null, null, + packageDir, true, progress.getListener()), (AbstractFileImporter importer) -> importer.getClass() + .getSimpleName(), null)); + FxTestSupport.waitUntil(progress::isFinished); + assertEquals("Completed", status(progress)); + assertEquals("Overall 100%", FxTestSupport.call(() -> ((Label) progress.getStage().getScene().getRoot() + .lookup("#percentLabel")).getText())); + assertTrue(AbstractFileImporter.forDirectory(packageDir, null, DbType.H2) instanceof ParquetImporter); + try (Connection connection = DriverManager.getConnection(target.getJdbcUrl(), "sa", ""); + ResultSet rs = connection.createStatement().executeQuery( + "SELECT COUNT(*), COUNT(DATA), MAX(LENGTH(NOTE)) FROM T_PAGED")) { + rs.next(); + assertEquals(UiDatabase.PAGED_ROWS, rs.getInt(1)); + assertEquals(1, rs.getInt(2)); + assertEquals(UiDatabase.LONG_TEXT_LENGTH, rs.getInt(3)); + } + } } diff --git a/src/test/java/com/github/sqljam/face/view/ImportParquetDialogTest.java b/src/test/java/com/github/sqljam/face/view/ImportParquetDialogTest.java new file mode 100644 index 0000000..106f4bf --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/ImportParquetDialogTest.java @@ -0,0 +1,266 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.file.Files; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.testfx.framework.junit5.ApplicationExtension; +import org.testfx.framework.junit5.Start; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.ParquetImporter; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.control.RadioButton; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; + +/** + * @Description: ImportParquetDialogTest verifies loading Parquet files from the UI: preview of columns and rows, table + * name, modes, unreadable files and loading into a database + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class ImportParquetDialogTest { + + private static final int ROWS = 120; + + @TempDir + File dir; + + private AppContext context; + private Stage stage; + private ConnectionProfile target; + private ImportParquetDialog dialog; + + @Start + void start(Stage stage) throws Exception { + this.stage = stage; + context = FxTestSupport.createContext(dir); + target = new ConnectionProfile(); + target.setName("Target H2"); + target.setDbType(DbType.H2); + target.setDatabase(new File(dir, "target/db").getAbsolutePath()); + target.setUsername("sa"); + target.setPassword(""); + context.getProfileRegistry().saveProfile(target, true); + stage.setScene(new Scene(new StackPane(), 300, 200)); + stage.show(); + } + + @AfterEach + void close() { + if (dialog != null) { + FxTestSupport.run(dialog::close); + } + context.getSessionManager().closeAll(); + } + + /** + * Parquet file of another tool, written by DuckDB + */ + private File writeParquet(String name, int from, int to) throws Exception { + File file = new File(dir, name); + try (Connection connection = DriverManager.getConnection("jdbc:duckdb:"); + Statement statement = connection.createStatement()) { + statement.execute(String.format("COPY (SELECT range::INTEGER AS id, 'name ' || range AS name," + + " DATE '2024-01-01' + range::INTEGER AS created FROM range(%d, %d)) TO '%s' (FORMAT parquet)", + from, to, file.getAbsolutePath().replace("'", "''"))); + } + return file; + } + + @SuppressWarnings("unchecked") + private T node(String id) { + return (T) dialog.getDialogPane().lookup("#" + id); + } + + private void open(DbNode node) { + dialog = FxTestSupport.call(() -> { + ImportParquetDialog parquetDialog = new ImportParquetDialog(stage, context, node); + parquetDialog.show(); + return parquetDialog; + }); + } + + private void addFiles(File... files) { + FxTestSupport.run(() -> dialog.addFiles(List.of(files))); + waitForPreview(); + } + + private void waitForPreview() { + FxTestSupport.waitUntil(() -> !"Loading...".equals(text("parquetPreviewLabel"))); + } + + private String text(String id) { + return FxTestSupport.call(() -> ((Label) node(id)).getText()); + } + + private boolean startDisabled() { + return FxTestSupport.call(() -> dialog.getDialogPane().lookupButton(ButtonType.OK).isDisabled()); + } + + @SuppressWarnings("unchecked") + private TableView> preview() { + return (TableView>) node("parquetPreview"); + } + + @Test + void requiresFilesAndTable() throws Exception { + open(null); + assertTrue(startDisabled()); + assertEquals("Files of one table are loaded together, columns and the first rows are previewed", + text("parquetPreviewLabel")); + // The only data source is the target + assertEquals("Target H2", FxTestSupport.call(() -> { + @SuppressWarnings("unchecked") + ComboBox combo = (ComboBox) + node("targetCombo"); + return combo.getValue().getName(); + })); + addFiles(writeParquet("orders.parquet", 0, ROWS)); + assertFalse(startDisabled()); + assertEquals("orders", FxTestSupport.call(() -> ((TextField) node("tableField")).getText())); + FxTestSupport.run(() -> ((TextField) node("tableField")).setText(" ")); + assertTrue(startDisabled()); + assertEquals("Please enter the name of the target table", text("warningLabel")); + } + + @Test + void previewsColumnsAndFirstRows() throws Exception { + open(null); + File first = writeParquet("part-1.parquet", 0, ROWS); + File second = writeParquet("part-2.parquet", ROWS, ROWS + 30); + addFiles(first, second); + // The same file is added once + addFiles(first); + assertEquals(2, (int) FxTestSupport.call(() -> ((ListView) node("parquetFiles")).getItems().size())); + assertEquals("3 columns, " + (ROWS + 30) + " rows, the first " + ImportParquetDialog.PREVIEW_ROWS + + " are shown", text("parquetPreviewLabel")); + List headers = FxTestSupport.call(() -> preview().getColumns().stream() + .map(column -> ((Label) column.getGraphic()).getText()).collect(Collectors.toList())); + assertEquals(List.of("id\ninteger", "name\nvarchar", "created\ndate"), headers); + assertEquals(ImportParquetDialog.PREVIEW_ROWS, (int) FxTestSupport.call(() -> preview().getItems().size())); + assertEquals("name 0", FxTestSupport.call(() -> preview().getItems().get(0).get(1))); + assertEquals("part_1", FxTestSupport.call(() -> ((TextField) node("tableField")).getText())); + // Files are removed, the preview is cleared + FxTestSupport.run(() -> dialog.removeFiles(List.of(first, second))); + assertTrue(FxTestSupport.call(() -> preview().getColumns().isEmpty())); + assertTrue(startDisabled()); + } + + @Test + void reportsUnreadableFiles() throws Exception { + open(null); + File broken = new File(dir, "broken.parquet"); + Files.writeString(broken.toPath(), "not parquet"); + addFiles(broken); + FxTestSupport.waitUntil(() -> text("warningLabel").startsWith("Unable to read Parquet files")); + assertTrue(startDisabled()); + } + + @Test + void derivesTableNames() { + assertEquals("orders", ImportParquetDialog.getTableName(new File("orders.parquet"))); + assertEquals("T_PAGED", ImportParquetDialog.getTableName(new File("PUBLIC.T_PAGED.parquet"))); + assertEquals("sales_2024_q1", ImportParquetDialog.getTableName(new File("sales-2024 q1.parquet"))); + assertEquals("t_2024", ImportParquetDialog.getTableName(new File("2024.parquet"))); + assertEquals("订单", ImportParquetDialog.getTableName(new File("订单.parquet"))); + } + + @Test + void buildsRequest() throws Exception { + open(DbNode.connection(target)); + File file = writeParquet("orders.parquet", 0, 10); + addFiles(file); + FxTestSupport.run(() -> { + ((RadioButton) node("appendRadio")).setSelected(true); + ((TextField) node("tableField")).setText(" ORDERS "); + }); + ImportParquetDialog.ParquetRequest request = FxTestSupport.call(dialog::buildRequest); + assertEquals(List.of(file), request.getFiles()); + assertEquals("ORDERS", request.getTableName()); + assertEquals(ParquetImporter.Mode.APPEND, request.getMode()); + assertEquals(target.getId(), request.getTarget().getId()); + assertNull(request.getSchema()); + } + + private ProgressDialog load(List files, ParquetImporter.Mode mode) { + ProgressDialog progress = FxTestSupport.call(() -> new ProgressDialog(stage, context, "Import", 1)); + FxTestSupport.run(() -> progress.run(() -> context.getTransferService().importParquet(target, null, null, + files, "ORDERS", mode, progress.getListener()), (ParquetImporter importer) -> String.valueOf( + importer.getImportedRows()), null)); + FxTestSupport.waitUntil(progress::isFinished); + return progress; + } + + private static String status(ProgressDialog progress) { + return FxTestSupport.call(() -> ((Label) progress.getStage().getScene().getRoot().lookup("#progressStatus")) + .getText()); + } + + private int countRows() throws Exception { + try (Connection connection = DriverManager.getConnection(target.getJdbcUrl(), "sa", ""); + ResultSet rs = connection.createStatement().executeQuery("SELECT COUNT(*) FROM ORDERS")) { + rs.next(); + return rs.getInt(1); + } + } + + @Test + void loadsFilesIntoDatabase() throws Exception { + File first = writeParquet("orders-1.parquet", 0, ROWS); + ProgressDialog created = load(List.of(first), ParquetImporter.Mode.CREATE); + assertEquals("Completed", status(created)); + assertEquals(ROWS, countRows()); + FxTestSupport.run(() -> created.getStage().close()); + // Rows are appended to the existing table + ProgressDialog appended = load(List.of(writeParquet("orders-2.parquet", ROWS, ROWS + 5)), + ParquetImporter.Mode.APPEND); + assertEquals("Completed", status(appended)); + assertEquals(ROWS + 5, countRows()); + FxTestSupport.run(() -> appended.getStage().close()); + // Replaced by CREATE + ProgressDialog replaced = load(List.of(first), ParquetImporter.Mode.CREATE); + assertEquals("Completed", status(replaced)); + assertEquals(ROWS, countRows()); + FxTestSupport.run(() -> replaced.getStage().close()); + } +} diff --git a/src/test/java/com/github/sqljam/face/view/MainViewTest.java b/src/test/java/com/github/sqljam/face/view/MainViewTest.java index 3b085cf..1a531f6 100644 --- a/src/test/java/com/github/sqljam/face/view/MainViewTest.java +++ b/src/test/java/com/github/sqljam/face/view/MainViewTest.java @@ -21,7 +21,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.File; +import java.lang.reflect.Field; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; import java.util.List; +import java.util.function.Consumer; import java.util.stream.Collectors; import org.junit.jupiter.api.AfterEach; @@ -33,14 +38,24 @@ import com.github.sqljam.face.model.ConnectionProfile; import com.github.sqljam.face.model.TableInfo; import com.github.sqljam.impexp.DbType; +import javafx.application.Platform; +import javafx.scene.Cursor; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ButtonType; import javafx.scene.control.ComboBox; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.DialogPane; +import javafx.scene.control.Label; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuBar; +import javafx.scene.control.MenuItem; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; import javafx.scene.control.TableView; import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.TreeCell; import javafx.scene.control.TreeItem; import javafx.stage.Stage; @@ -126,15 +141,15 @@ void loadsTreeLazily() { assertFalse(FxTestSupport.call(connection::isLeaf)); TreeItem schema = expandToSchema(); assertEquals(List.of("T_EMPTY", "T_ONE", "T_PAGED"), labels(schema)); - assertTrue(FxTestSupport.call(() -> ((javafx.scene.control.Label) mainView.lookup("#statusLabel")) + assertTrue(FxTestSupport.call(() -> ((Label) mainView.lookup("#statusLabel")) .getText()).startsWith("Embedded H2 - H2")); } @Test void refreshesSelectedNode() throws Exception { TreeItem schema = expandToSchema(); - try (java.sql.Connection connection = java.sql.DriverManager.getConnection(profile.getJdbcUrl(), "sa", ""); - java.sql.Statement statement = connection.createStatement()) { + try (Connection connection = DriverManager.getConnection(profile.getJdbcUrl(), "sa", ""); + Statement statement = connection.createStatement()) { statement.execute("CREATE TABLE T_NEW (ID INT)"); } FxTestSupport.run(() -> { @@ -189,10 +204,10 @@ void opensTableOnceAndClosesTab() { */ private static void openTable(ConnectionTree tree, DbNode node) { try { - java.lang.reflect.Field field = ConnectionTree.class.getDeclaredField("onOpenTable"); + Field field = ConnectionTree.class.getDeclaredField("onOpenTable"); field.setAccessible(true); @SuppressWarnings("unchecked") - java.util.function.Consumer consumer = (java.util.function.Consumer) field.get(tree); + Consumer consumer = (Consumer) field.get(tree); consumer.accept(node); } catch (ReflectiveOperationException e) { throw new IllegalStateException(e); @@ -225,7 +240,7 @@ void showsTableMetadataDdlAndData() { FxTestSupport.waitUntil(() -> ddl.getText().contains("ENGINE=InnoDB")); FxTestSupport.run(() -> inner.getSelectionModel().select(4)); - FxTestSupport.waitUntil(() -> "250 rows".equals(((javafx.scene.control.Label) inner.getTabs().get(4) + FxTestSupport.waitUntil(() -> "250 rows".equals(((Label) inner.getTabs().get(4) .getContent().lookup("#totalLabel")).getText())); } @@ -256,4 +271,157 @@ void createsTableKey() { DbNode noSchema = DbNode.table(profile, new TableInfo(null, null, "t", null, false, false)); assertEquals("Embedded H2.t", TableTab.getKey(noSchema)); } + + private MenuItem fileMenuItem(String id) { + return FxTestSupport.call(() -> { + Menu fileMenu = ((MenuBar) mainView.lookup(".menu-bar")).getMenus().get(0); + return fileMenu.getItems().stream().filter(item -> id.equals(item.getId())).findFirst().orElse(null); + }); + } + + @Test + void opensParquetImportFromMenuAndTree() { + MenuItem importParquet = fileMenuItem("importParquetMenuItem"); + assertEquals("Import Parquet Files...", importParquet.getText()); + assertEquals("Open Export Package...", fileMenuItem("openPackageMenuItem").getText()); + Platform.runLater(importParquet::fire); + DialogPane pane = FxTestSupport.waitForDialogPane(); + assertEquals("Load Parquet files into a table of a database", FxTestSupport.call(pane::getHeaderText)); + assertTrue(FxTestSupport.call(() -> pane.lookupButton(ButtonType.OK).isDisabled())); + FxTestSupport.closeDialog(ButtonType.CANCEL); + // The context menu of a data source offers both imports + TreeItem connection = child(tree().getRoot(), "Embedded H2"); + FxTestSupport.run(() -> tree().getSelectionModel().select(connection)); + List items = FxTestSupport.call(() -> { + tree().applyCss(); + tree().layout(); + return tree().lookupAll(".tree-cell").stream() + .map(node -> (TreeCell) node) + .filter(cell -> cell.getTreeItem() == connection && cell.getContextMenu() != null) + .flatMap(cell -> cell.getContextMenu().getItems().stream()).map(MenuItem::getText) + .filter(text -> text != null).collect(Collectors.toList()); + }); + assertTrue(items.containsAll(List.of("Import Export Package...", "Import Parquet Files...")), + items.toString()); + } + + @Test + void showsRepositoryMark() { + Label mark = FxTestSupport.call(() -> (Label) mainView.lookup("#repositoryMark")); + assertNotNull(mark); + assertEquals(Branding.REPOSITORY_URL, FxTestSupport.call(() -> mark.getTooltip().getText())); + } + + @Test + void opensRepositoryFromMark() { + Label mark = FxTestSupport.call(() -> (Label) mainView.lookup("#repositoryMark")); + assertEquals(Cursor.HAND, FxTestSupport.call(mark::getCursor)); + assertNotNull(FxTestSupport.call(mark::getOnMouseClicked)); + } + + private ContextMenu contextMenu(TreeItem item) { + return FxTestSupport.call(() -> { + tree().getSelectionModel().select(item); + tree().applyCss(); + tree().layout(); + return tree().lookupAll(".tree-cell").stream().map(node -> (TreeCell) node) + .filter(cell -> cell.getTreeItem() == item && cell.getContextMenu() != null) + .map(TreeCell::getContextMenu).findFirst().orElseThrow(); + }); + } + + private static MenuItem menuItem(ContextMenu menu, String id) { + return menu.getItems().stream().filter(item -> id.equals(item.getId())).findFirst().orElseThrow(); + } + + /** + * Visibility of connect and disconnect when the menu is shown + */ + private static List connectState(ContextMenu menu) { + return FxTestSupport.call(() -> { + menu.getOnShowing().handle(null); + return List.of(menuItem(menu, "connectMenuItem").isVisible(), menuItem(menu, "disconnectMenuItem") + .isVisible()); + }); + } + + @Test + void disconnectsOpenedConnection() { + TreeItem connection = child(tree().getRoot(), "Embedded H2"); + assertEquals(List.of(true, false), connectState(contextMenu(connection))); + expandAndWait(connection); + assertTrue(FxTestSupport.call(() -> tree().isConnected(connection))); + ContextMenu menu = contextMenu(connection); + assertEquals(List.of(false, true), connectState(menu)); + FxTestSupport.run(() -> mainView.openTable(DbNode.table(profile, new TableInfo(null, "PUBLIC", "T_ONE", null, + false, false)))); + assertEquals(1, FxTestSupport.call(() -> tabs().getTabs().size())); + FxTestSupport.run(() -> menuItem(menu, "disconnectMenuItem").fire()); + // Collapsed, tabs of the data source are closed, connect is offered again + assertFalse(FxTestSupport.call(connection::isExpanded)); + assertFalse(FxTestSupport.call(() -> tree().isConnected(connection))); + assertTrue(FxTestSupport.call(() -> tabs().getTabs().isEmpty())); + assertEquals("Disconnected: Embedded H2", FxTestSupport.call(() -> ((Label) mainView.lookup("#statusLabel")) + .getText())); + assertEquals(List.of(true, false), connectState(contextMenu(connection))); + // Connected again by expanding it + expandAndWait(connection); + assertTrue(FxTestSupport.call(() -> tree().isConnected(connection))); + assertTrue(labels(connection).size() > 0); + // Not connected, nothing happens + TreeItem unreachable = child(tree().getRoot(), "Unreachable MySQL"); + FxTestSupport.run(() -> tree().disconnect(unreachable)); + assertFalse(FxTestSupport.call(() -> tree().isConnected(unreachable))); + } + + @Test + @SuppressWarnings("unchecked") + void createsTargetDataSourceInImportDialog() { + Platform.runLater(fileMenuItem("importParquetMenuItem")::fire); + DialogPane importPane = FxTestSupport.waitForDialogPane(); + Button newButton = FxTestSupport.call(() -> (Button) importPane.lookup("#newTargetButton")); + assertEquals("New data source as the target", FxTestSupport.call(() -> newButton.getTooltip().getText())); + FxTestSupport.fireLater(newButton); + FxTestSupport.waitUntil(() -> FxTestSupport.getDialogPanes().size() == 2); + DialogPane connectionPane = FxTestSupport.getDialogPanes().stream().filter(pane -> pane != importPane) + .findFirst().orElseThrow(); + File target = new File(dir, "target"); + FxTestSupport.run(() -> { + ((TextField) connectionPane.lookup("#nameField")).setText("Target H2"); + ((ComboBox) connectionPane.lookup("#dbTypeCombo")).setValue(DbType.H2); + ((TextField) connectionPane.lookup("#databaseField")).setText(target.getAbsolutePath()); + }); + FxTestSupport.fireLater((Button) FxTestSupport.call(() -> connectionPane.lookupButton(ButtonType.OK))); + FxTestSupport.waitUntil(() -> FxTestSupport.getDialogPanes().size() == 1); + // The created data source is the selected target + ComboBox targetCombo = FxTestSupport.call(() -> (ComboBox) importPane + .lookup("#targetCombo")); + assertEquals("Target H2", FxTestSupport.call(() -> targetCombo.getValue().getName())); + assertEquals(3, FxTestSupport.call(() -> targetCombo.getItems().size())); + FxTestSupport.closeDialog(ButtonType.CANCEL); + // The tree lists it after the dialog, opened connections are kept + FxTestSupport.waitUntil(() -> labels(tree().getRoot()).contains("Target H2")); + assertEquals(3, labels(tree().getRoot()).size()); + } + + @Test + void createsFirstDataSourceInImportDialog() throws Exception { + for (ConnectionProfile saved : context.getProfileRegistry().getProfiles()) { + context.getProfileRegistry().removeProfile(saved.getId()); + } + // Without data sources the import dialog is opened, the target is created there + Platform.runLater(fileMenuItem("importParquetMenuItem")::fire); + DialogPane importPane = FxTestSupport.waitForDialogPane(); + assertNotNull(FxTestSupport.call(() -> importPane.lookup("#newTargetButton"))); + FxTestSupport.closeDialog(ButtonType.CANCEL); + } + + @Test + void showsExitButton() { + Button exit = FxTestSupport.call(() -> (Button) mainView.lookup("#action-exit")); + assertEquals("Exit", FxTestSupport.call(exit::getText)); + MenuItem exitItem = FxTestSupport.call(() -> ((MenuBar) mainView.lookup(".menu-bar")).getMenus().get(0) + .getItems().stream().filter(item -> "Exit".equals(item.getText())).findFirst().orElseThrow()); + assertNotNull(exitItem.getGraphic()); + } } diff --git a/src/test/java/com/github/sqljam/face/view/PackageViewerTest.java b/src/test/java/com/github/sqljam/face/view/PackageViewerTest.java new file mode 100644 index 0000000..7e70203 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/PackageViewerTest.java @@ -0,0 +1,241 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.testfx.framework.junit5.ApplicationExtension; +import org.testfx.framework.junit5.Start; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.model.TransferRequest; +import com.github.sqljam.impexp.DataFormat; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.ExportListener; +import com.github.sqljam.impexp.ExportManifest; +import com.github.sqljam.impexp.ScriptExportHandler; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeTableView; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; + +/** + * @Description: PackageViewerTest verifies the package viewer: files with sizes and modified times, the summary of + * the manifest, previews of json, sql, LOB and Parquet files, large files and temporary workspaces + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class PackageViewerTest { + + @TempDir + File dir; + + private AppContext context; + private Stage stage; + private ConnectionProfile source; + private PackageViewer viewer; + + @Start + void start(Stage stage) throws Exception { + this.stage = stage; + context = FxTestSupport.createContext(dir); + source = UiDatabase.create(new File(dir, "source"), "Source H2"); + context.getProfileRegistry().saveProfile(source, true); + stage.setScene(new Scene(new StackPane(), 300, 200)); + stage.show(); + } + + @AfterEach + void close() { + if (viewer != null) { + FxTestSupport.run(() -> viewer.getStage().close()); + } + context.getSessionManager().closeAll(); + } + + private File exportPackage(String name, DataFormat dataFormat) throws Exception { + File packageDir = new File(dir, name); + TransferRequest request = new TransferRequest(); + request.setSource(source); + request.setSourceCatalog(UiDatabase.CATALOG); + request.setSourceSchema(UiDatabase.SCHEMA); + request.setTarget(TransferRequest.Target.SCRIPT); + request.setOutputDirectory(packageDir); + request.setScriptDbType(DbType.H2); + request.setDataFormat(dataFormat); + context.getTransferService().transfer(request, ExportListener.NONE); + return packageDir; + } + + private void open(File directory) { + viewer = FxTestSupport.call(() -> { + PackageViewer packageViewer = new PackageViewer(stage, context, directory); + packageViewer.show(); + return packageViewer; + }); + } + + @SuppressWarnings("unchecked") + private T node(String id) { + return (T) viewer.getStage().getScene().getRoot().lookup("#" + id); + } + + private TreeTableView tree() { + return node("packageFileTree"); + } + + private String fileLabel() { + return FxTestSupport.call(() -> ((Label) node("packageFileLabel")).getText()); + } + + private String textPreview() { + return FxTestSupport.call(() -> ((TextArea) node("packageTextPreview")).getText()); + } + + private void select(File file) { + assertTrue(FxTestSupport.call(() -> viewer.selectFile(file)), file.toString()); + } + + private static List names(TreeItem item) { + return item.getChildren().stream().map(child -> child.getValue().getName()).collect(Collectors.toList()); + } + + @Test + void showsFilesWithSizeAndModifiedTime() throws Exception { + File packageDir = exportPackage("sql", DataFormat.SQL); + open(packageDir); + List columns = FxTestSupport.call(() -> tree().getColumns().stream() + .map(column -> column.getText()).collect(Collectors.toList())); + assertEquals(List.of("Name", "Size", "Modified"), columns); + File manifestFile = new File(packageDir, ExportManifest.FILE_NAME); + // The cells of size and modified time + String size = FxTestSupport.call(() -> (String) tree().getColumns().get(1).getCellObservableValue( + tree().getSelectionModel().getSelectedItem()).getValue()); + assertEquals(FileUtils.byteCountToDisplaySize(manifestFile.length()), size); + String modified = FxTestSupport.call(() -> (String) tree().getColumns().get(2).getCellObservableValue( + tree().getSelectionModel().getSelectedItem()).getValue()); + assertTrue(modified.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"), modified); + assertEquals(PackageViewer.formatModified(manifestFile), modified); + // A directory is sized by its files + assertEquals(FileUtils.byteCountToDisplaySize(FileUtils.sizeOfDirectory(packageDir)), + PackageViewer.formatSize(packageDir)); + // manifest.json is selected and formatted when the viewer is shown + assertTrue(fileLabel().startsWith("manifest.json ("), fileLabel()); + assertTrue(fileLabel().contains(modified), fileLabel()); + assertTrue(textPreview().contains("\"dataFormat\" : \"SQL\""), textPreview()); + String summary = FxTestSupport.call(() -> ((Label) node("packageSummary")).getText()); + assertTrue(summary.startsWith("Format: SQL Status: COMPLETED"), summary); + assertTrue(summary.contains("Tables: 3"), summary); + // sql scripts are previewed as text + select(new File(packageDir, ScriptExportHandler.SCHEMA_FILE_NAME)); + assertTrue(textPreview().contains("CREATE TABLE"), textPreview()); + } + + @Test + void listsDirectoriesFirstAndSkipsWorkspaces() throws Exception { + File packageDir = new File(dir, "layout"); + FileUtils.forceMkdir(new File(packageDir, "data")); + FileUtils.forceMkdir(new File(packageDir, ".sqljam-workspace-1")); + Files.writeString(new File(packageDir, "b.sql").toPath(), "SELECT 1;"); + Files.writeString(new File(packageDir, "a.sql").toPath(), "SELECT 2;"); + Files.writeString(new File(packageDir, "data/t.sql").toPath(), "SELECT 3;"); + TreeItem root = PackageViewer.createItem(packageDir); + assertEquals(List.of("data", "a.sql", "b.sql"), names(root)); + assertEquals(List.of("t.sql"), names(root.getChildren().get(0))); + open(packageDir); + assertEquals("No manifest.json, files of the directory are listed", + FxTestSupport.call(() -> ((Label) node("packageSummary")).getText())); + select(new File(packageDir, "data/t.sql")); + assertEquals("SELECT 3;", textPreview()); + } + + @Test + void previewsParquetFiles() throws Exception { + File packageDir = exportPackage("parquet", DataFormat.PARQUET); + open(packageDir); + assertTrue(textPreview().contains("\"dataFormat\" : \"PARQUET\""), textPreview()); + File parquetFile = new File(packageDir, "data").listFiles((parent, name) -> name.contains("T_PAGED"))[0]; + select(parquetFile); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> ((TableView) node("packageTablePreview")) + .isVisible())); + @SuppressWarnings("unchecked") + TableView> table = node("packageTablePreview"); + assertEquals(PackageViewer.PARQUET_PREVIEW_ROWS, (int) FxTestSupport.call(() -> table.getItems().size())); + List headers = FxTestSupport.call(() -> table.getColumns().stream().map(column -> column.getText()) + .collect(Collectors.toList())); + assertEquals(5, headers.size()); + assertTrue(headers.get(0).startsWith("ID\n"), headers.toString()); + assertTrue(fileLabel().contains(UiDatabase.PAGED_ROWS + " rows, the first 100 are shown"), fileLabel()); + assertEquals("1", FxTestSupport.call(() -> table.getItems().get(0).get(0))); + // A text file is shown again after a Parquet file + select(new File(packageDir, ExportManifest.FILE_NAME)); + assertFalse(FxTestSupport.call(table::isVisible)); + } + + @Test + void reportsUnreadableParquetFile() throws Exception { + File packageDir = new File(dir, "broken"); + FileUtils.forceMkdir(packageDir); + File parquetFile = new File(packageDir, "broken.parquet"); + Files.writeString(parquetFile.toPath(), "not parquet"); + open(packageDir); + select(parquetFile); + FxTestSupport.waitUntil(() -> !"Loading...".equals(textPreview())); + assertFalse(textPreview().isEmpty()); + } + + @Test + void previewsLargeAndBinaryFiles() throws Exception { + File large = new File(dir, "large.sql"); + Files.writeString(large.toPath(), "x".repeat(PackageViewer.TEXT_PREVIEW_BYTES + 10)); + String text = PackageViewer.readPreview(large); + assertTrue(text.startsWith("x".repeat(100))); + assertTrue(text.endsWith("... the first 256 KB of 256 KB are shown"), text.substring(text.length() - 80)); + File blob = new File(dir, "1.blob"); + byte[] bytes = new byte[PackageViewer.HEX_PREVIEW_BYTES + 1]; + bytes[0] = (byte) 0xDE; + bytes[1] = (byte) 0xAD; + Files.write(blob.toPath(), bytes); + String hex = PackageViewer.readPreview(blob); + assertTrue(hex.startsWith("00000000 de ad 00"), hex.substring(0, 40)); + assertTrue(hex.contains("00000010 00"), hex.substring(0, 120)); + assertTrue(hex.contains("... the first 4 KB of 4 KB are shown")); + // Invalid json is shown as text + File json = new File(dir, "bad.json"); + Files.writeString(json.toPath(), "{bad", StandardCharsets.UTF_8); + assertEquals("{bad", PackageViewer.readPreview(json)); + assertNotNull(PackageViewer.describe(dir)); + } +} diff --git a/src/test/java/com/github/sqljam/face/view/ProgressDialogTest.java b/src/test/java/com/github/sqljam/face/view/ProgressDialogTest.java index 961a54d..7f1d642 100644 --- a/src/test/java/com/github/sqljam/face/view/ProgressDialogTest.java +++ b/src/test/java/com/github/sqljam/face/view/ProgressDialogTest.java @@ -38,6 +38,7 @@ import javafx.scene.control.ProgressBar; import javafx.scene.layout.StackPane; import javafx.stage.Stage; +import javafx.stage.WindowEvent; /** * @Description: ProgressDialogTest verifies progress, errors, cancellation and finish states @@ -209,8 +210,8 @@ void closingWindowCancelsJob() { } return null; }, null, null)); - FxTestSupport.run(() -> dialog.getStage().fireEvent(new javafx.stage.WindowEvent(dialog.getStage(), - javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST))); + FxTestSupport.run(() -> dialog.getStage().fireEvent(new WindowEvent(dialog.getStage(), + WindowEvent.WINDOW_CLOSE_REQUEST))); assertTrue(listener.isCancelled()); } @@ -226,6 +227,7 @@ void showsFailure() { assertEquals(2, dialog.getErrorLogCount()); assertEquals(0.0, progress(dialog, "overallBar"), 0.001); assertFalse(FxTestSupport.call(() -> root(dialog).lookup("#openFolderButton").isVisible())); + assertFalse(FxTestSupport.call(() -> root(dialog).lookup("#viewPackageButton").isVisible())); } @Test @@ -241,6 +243,8 @@ void showsOutputFolderAndManifestOnSuccess() throws Exception { assertEquals("Completed", text(dialog, "progressStatus")); assertEquals("Overall 100%", text(dialog, "percentLabel")); assertTrue(FxTestSupport.call(() -> root(dialog).lookup("#openFolderButton").isVisible())); + // Files of the export are viewed in the package viewer + assertTrue(FxTestSupport.call(() -> root(dialog).lookup("#viewPackageButton").isVisible())); assertTrue(text(dialog, "manifestLabel").endsWith("manifest.json")); assertTrue(text(dialog, "elapsedLabel").startsWith("Elapsed 00:00:0")); } diff --git a/src/test/java/com/github/sqljam/face/view/ThemeManagerTest.java b/src/test/java/com/github/sqljam/face/view/ThemeManagerTest.java index 67cc852..b67e7e7 100644 --- a/src/test/java/com/github/sqljam/face/view/ThemeManagerTest.java +++ b/src/test/java/com/github/sqljam/face/view/ThemeManagerTest.java @@ -29,8 +29,6 @@ import org.testfx.framework.junit5.Start; import com.github.sqljam.config.Config; import com.github.sqljam.face.service.SettingsStore; -import atlantafx.base.theme.NordDark; -import atlantafx.base.theme.PrimerDark; import javafx.application.Application; import javafx.scene.Scene; import javafx.scene.control.Menu; @@ -39,6 +37,8 @@ import javafx.scene.control.RadioMenuItem; import javafx.scene.layout.VBox; import javafx.stage.Stage; +import atlantafx.base.theme.NordDark; +import atlantafx.base.theme.PrimerDark; /** * @Description: ThemeManagerTest diff --git a/src/test/java/com/github/sqljam/face/view/TutorialMusic.java b/src/test/java/com/github/sqljam/face/view/TutorialMusic.java new file mode 100644 index 0000000..e9eca91 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/TutorialMusic.java @@ -0,0 +1,201 @@ +/* + * 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.io.BufferedOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Random; + +/** + * @Description: TutorialMusic synthesizes light office background music for the tutorial video: an electric piano + * with major seventh chords, a marimba motif, a bass and soft drums at 92 BPM. It is generated, so the + * video has no third party music. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +final class TutorialMusic { + + private static final int SAMPLE_RATE = 22050; + private static final double BEAT = 60.0 / 92; + private static final double BAR = 4 * BEAT; + /** + * Fmaj7 Em7 Dm7 Cmaj7, Fmaj7 G6 Am7 Gsus: root of the bass and notes of the chord + */ + private static final int[][] PROGRESSION = {{41, 57, 60, 64, 65}, {40, 55, 59, 62, 64}, {38, 53, 57, 60, 62}, + {36, 52, 55, 59, 60}, {41, 57, 60, 64, 65}, {43, 55, 59, 62, 64}, {45, 55, 60, 64, 67}, + {43, 55, 60, 62, 67}}; + /** + * Marimba motifs: beat offset and note + */ + private static final double[][][] MOTIFS = {{{0, 72}, {0.75, 76}, {1.5, 79}, {2.5, 77}, {3, 76}}, + {{0, 74}, {1, 72}, {1.5, 69}, {2.5, 72}}, {{0, 72}, {0.5, 74}, {1.5, 76}, {3, 79}}, + {{0, 77}, {1, 76}, {2, 74}, {2.5, 72}}}; + + private final float[] left; + private final float[] right; + private final Random random = new Random(7); + + private TutorialMusic(double seconds) { + int length = (int) (seconds * SAMPLE_RATE); + left = new float[length]; + right = new float[length]; + } + + /** + * Writes the music of the duration as a 16 bit stereo wave file + */ + static void write(File file, double seconds) throws IOException { + TutorialMusic music = new TutorialMusic(seconds); + music.compose(seconds); + music.save(file); + } + + private static double frequency(double midi) { + return 440.0 * Math.pow(2, (midi - 69) / 12.0); + } + + private void add(double start, double[] samples, double gain, double pan) { + int offset = (int) (start * SAMPLE_RATE); + double leftGain = gain * Math.cos((pan + 1) * Math.PI / 4); + double rightGain = gain * Math.sin((pan + 1) * Math.PI / 4); + for (int i = 0; i < samples.length && offset + i < left.length; i++) { + left[offset + i] += (float) (samples[i] * leftGain); + right[offset + i] += (float) (samples[i] * rightGain); + } + } + + private static double[] electricPiano(double midi, double seconds) { + double f = frequency(midi); + double[] out = new double[(int) (seconds * SAMPLE_RATE)]; + for (int i = 0; i < out.length; i++) { + double t = (double) i / SAMPLE_RATE; + double envelope = Math.exp(-t * 1.6) * Math.min(1, t * 200); + double tremolo = 1 + 0.08 * Math.sin(2 * Math.PI * 4.5 * t); + out[i] = (Math.sin(2 * Math.PI * f * t) + 0.25 * Math.sin(4 * Math.PI * f * t) * Math.exp(-t * 4)) + * envelope * tremolo; + } + return out; + } + + private static double[] marimba(double midi) { + double f = frequency(midi); + double[] out = new double[(int) (0.6 * SAMPLE_RATE)]; + for (int i = 0; i < out.length; i++) { + double t = (double) i / SAMPLE_RATE; + out[i] = (Math.sin(2 * Math.PI * f * t) + 0.3 * Math.sin(8 * Math.PI * f * t) * Math.exp(-t * 30)) + * Math.exp(-t * 7) * Math.min(1, t * 400); + } + return out; + } + + private static double[] bass(double midi, double seconds) { + double f = frequency(midi); + double[] out = new double[(int) (seconds * SAMPLE_RATE)]; + for (int i = 0; i < out.length; i++) { + double t = (double) i / SAMPLE_RATE; + out[i] = (Math.sin(2 * Math.PI * f * t) + 0.2 * Math.sin(4 * Math.PI * f * t)) * Math.exp(-t * 2.2) + * Math.min(1, t * 150); + } + return out; + } + + private static double[] kick() { + double[] out = new double[(int) (0.25 * SAMPLE_RATE)]; + double phase = 0; + for (int i = 0; i < out.length; i++) { + double t = (double) i / SAMPLE_RATE; + phase += 2 * Math.PI * (50 + 70 * Math.exp(-t * 30)) / SAMPLE_RATE; + out[i] = Math.sin(phase) * Math.exp(-t * 14); + } + return out; + } + + private double[] noise(double seconds, double decay, boolean highPass) { + double[] out = new double[(int) (seconds * SAMPLE_RATE)]; + double previous = 0; + for (int i = 0; i < out.length; i++) { + double x = random.nextDouble() * 2 - 1; + out[i] = (highPass ? x - previous : x) * Math.exp(-(double) i / SAMPLE_RATE * decay); + previous = x; + } + return out; + } + + private void compose(double seconds) { + double[] hat = noise(0.05, 90, true); + double[] snap = noise(0.12, 35, false); + double[] kick = kick(); + int bars = (int) (seconds / BAR) + 1; + for (int bar = 0; bar < bars; bar++) { + double start = bar * BAR; + int[] chord = PROGRESSION[bar % PROGRESSION.length]; + for (double hit : new double[]{0, 1.5}) { + for (int j = 1; j < chord.length; j++) { + add(start + hit * BEAT + j * 0.012, electricPiano(chord[j], hit == 0 ? 2.2 : 1.6), 0.06, + -0.25 + j * 0.15); + } + } + // Two bars of piano as the intro + if (bar >= 2) { + add(start, bass(chord[0], BEAT * 1.8), 0.22, 0); + add(start + 2.5 * BEAT, bass(chord[0] + 7, BEAT * 1.2), 0.16, 0); + add(start, kick, 0.30, 0); + add(start + 2 * BEAT, kick, 0.30, 0); + add(start + BEAT, snap, 0.05, 0.1); + add(start + 3 * BEAT, snap, 0.05, 0.1); + for (int eighth = 0; eighth < 8; eighth++) { + add(start + eighth * BEAT / 2 + (eighth % 2 == 1 ? 0.02 : 0), hat, eighth % 2 == 1 ? 0.05 : 0.08, + 0.35); + } + } + if (bar >= 4 && (bar / 8) % 2 == 0 || bar >= 20) { + for (double[] note : MOTIFS[bar % MOTIFS.length]) { + add(start + note[0] * BEAT, marimba(note[1]), 0.10, 0.2); + } + } + } + } + + private void save(File file) throws IOException { + float peak = 1e-6f; + for (int i = 0; i < left.length; i++) { + peak = Math.max(peak, Math.max(Math.abs(left[i]), Math.abs(right[i]))); + } + int dataSize = left.length * 4; + try (DataOutputStream out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(file)))) { + out.writeBytes("RIFF"); + out.writeInt(Integer.reverseBytes(36 + dataSize)); + out.writeBytes("WAVEfmt "); + out.writeInt(Integer.reverseBytes(16)); + out.writeShort(Short.reverseBytes((short) 1)); + out.writeShort(Short.reverseBytes((short) 2)); + out.writeInt(Integer.reverseBytes(SAMPLE_RATE)); + out.writeInt(Integer.reverseBytes(SAMPLE_RATE * 4)); + out.writeShort(Short.reverseBytes((short) 4)); + out.writeShort(Short.reverseBytes((short) 16)); + out.writeBytes("data"); + out.writeInt(Integer.reverseBytes(dataSize)); + for (int i = 0; i < left.length; i++) { + out.writeShort(Short.reverseBytes((short) (left[i] / peak * 0.8f * Short.MAX_VALUE))); + out.writeShort(Short.reverseBytes((short) (right[i] / peak * 0.8f * Short.MAX_VALUE))); + } + } + } +} diff --git a/src/test/java/com/github/sqljam/face/view/TutorialVideo.java b/src/test/java/com/github/sqljam/face/view/TutorialVideo.java new file mode 100644 index 0000000..95dfd3f --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/TutorialVideo.java @@ -0,0 +1,746 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.function.Supplier; + +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.testfx.framework.junit5.ApplicationExtension; +import org.testfx.framework.junit5.Start; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.service.ExampleDatabase; +import com.github.sqljam.impexp.DbType; +import javafx.application.Platform; +import javafx.event.ActionEvent; +import javafx.geometry.Bounds; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonBase; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.Labeled; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TabPane; +import javafx.scene.control.TextField; +import javafx.scene.control.TreeCell; +import javafx.scene.control.TreeItem; +import javafx.scene.control.skin.ComboBoxListViewSkin; +import javafx.scene.image.ImageView; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; +import javafx.stage.Window; + +/** + * @Description: TutorialVideo records the tutorial video of SqlJam with the example database: connect, query rows, + * export into files, import the package into PostgreSQL, and export directly into SQL Server (created + * in the export dialog) and Oracle. Subtitles are in English, the background music is generated. + * It is recorded once per major version, it is not a test of the build (the name does not + * end with Test), run it by + * mvn test -Dtest=TutorialVideo -Dvideo.file=docs/assets/sqljam-tutorial.mp4 + * The windows are shown on the screen while recording, the target databases are those of the + * integration tests (-Dvideo.postgresql.url, -Dvideo.sqlserver.url, -Dvideo.oracle.url, user and + * password by -Dvideo.username and -Dvideo.password). Recorded tables are dropped afterwards and an + * existing table of the same name stops the recording. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class TutorialVideo { + + private static final String STYLESHEET = "/com/github/sqljam/face/app.css"; + private static final String SCHEMA = "shop"; + private static final List TABLES = List.of("categories", "customers", "products", "orders", + "order_items"); + private static final String USERNAME = System.getProperty("video.username", "fengy"); + private static final String PASSWORD = System.getProperty("video.password", "123456"); + private static final String POSTGRESQL_URL = System.getProperty("video.postgresql.url", + "jdbc:postgresql://localhost:5432/demo"); + private static final String SQLSERVER_URL = System.getProperty("video.sqlserver.url", + "jdbc:sqlserver://localhost:1433;databaseName=demo;encrypt=false;trustServerCertificate=true"); + private static final String ORACLE_URL = System.getProperty("video.oracle.url", + "jdbc:oracle:thin:@//localhost:1521/demo"); + private static final double WIDTH = 1280; + private static final double HEIGHT = 800; + + @TempDir + File dir; + + private Stage stage; + private StackPane holder; + private AppContext context; + private MainView mainView; + private VideoRecorder recorder; + private File packageDir; + /** + * Tables are dropped only after the check found none of them, tables of the user are never touched + */ + private boolean targetsChecked; + + @Start + void start(Stage stage) throws Exception { + this.stage = stage; + FxTestSupport.run(() -> ThemeManager.apply(ThemeManager.DEFAULT_THEME)); + context = FxTestSupport.createContext(dir); + ConnectionProfile example = context.getExampleDatabase().create(); + context.getProfileRegistry().saveProfile(example, true); + saveProfile("PostgreSQL demo", DbType.POSTGRESQL, 5432); + saveProfile("Oracle demo", DbType.ORACLE, 1521); + holder = new StackPane(); + holder.setStyle("-fx-background-color: -color-bg-default;"); + Scene scene = new Scene(holder, WIDTH, HEIGHT); + scene.getStylesheets().add(TutorialVideo.class.getResource(STYLESHEET).toExternalForm()); + stage.setScene(scene); + stage.setTitle("SqlJam"); + Branding.applyIcons(stage); + stage.show(); + stage.centerOnScreen(); + } + + private void saveProfile(String name, DbType dbType, int port) throws Exception { + ConnectionProfile profile = new ConnectionProfile(); + profile.setName(name); + profile.setDbType(dbType); + profile.setHostname("localhost"); + profile.setPort(port); + profile.setDatabase("demo"); + profile.setUsername(USERNAME); + profile.setPassword(PASSWORD); + context.getProfileRegistry().saveProfile(profile, true); + } + + @AfterEach + void close() throws Exception { + context.getSessionManager().closeAll(); + FxTestSupport.closeAllDialogs(); + if (targetsChecked) { + dropTables(); + } + FileUtils.deleteQuietly(packageDir); + } + + @Test + void record() throws Exception { + checkTargets(); + targetsChecked = true; + File work = new File(System.getProperty("video.work", "target/video")); + work.mkdirs(); + packageDir = new File(work, "shop-export"); + FileUtils.deleteQuietly(packageDir); + File output = new File(System.getProperty("video.file", "target/video/sqljam-tutorial.mp4")); + recorder = FxTestSupport.call(() -> new VideoRecorder(stage, System.getProperty("ffmpeg", "ffmpeg"), 1.25, + 24, 88)); + recorder.start(new File(work, "raw.mp4")); + + intro(); + connect(); + query(); + exportFiles(); + importPackage(); + exportSqlServer(); + exportOracle(); + outro(); + + recorder.stop(); + File music = new File(work, "music.wav"); + TutorialMusic.write(music, recorder.getDurationSeconds() + 2); + recorder.finish(output, music, System.getProperty("video.font", "Helvetica Neue")); + assertTrue(output.length() > 0); + assertTrue(recorder.getDurationSeconds() < 180, "The video is longer than 3 minutes"); + } + + private void intro() throws Exception { + FxTestSupport.run(() -> holder.getChildren().setAll(card("Export & Import", "Getting started with the example database", + "Example Shop (H2) → PostgreSQL · SQL Server · Oracle"))); + recorder.caption("Getting started with the example database"); + pause(4500); + } + + private void outro() throws Exception { + FxTestSupport.run(() -> holder.getChildren().setAll(card("Schema, data and all.", + "Move tables across databases", Branding.REPOSITORY_URL))); + recorder.caption("More on GitHub: paganini2008/sqljam"); + pause(5000); + } + + private static VBox card(String title, String subtitle, String footer) { + ImageView logo = Branding.logoView(150); + Label titleLabel = new Label(title); + titleLabel.setStyle("-fx-font-size: 34px; -fx-font-weight: bold;"); + Label subtitleLabel = new Label(subtitle); + subtitleLabel.setStyle("-fx-font-size: 22px;"); + Label footerLabel = new Label(footer); + footerLabel.getStyleClass().add("muted"); + footerLabel.setStyle("-fx-font-size: 16px;"); + VBox card = new VBox(18, logo, titleLabel, subtitleLabel, footerLabel); + card.setAlignment(Pos.CENTER); + return card; + } + + private void connect() throws Exception { + LoginView loginView = FxTestSupport.call(() -> { + LoginView view = new LoginView(context, profile -> { + mainView = new MainView(context); + holder.getChildren().setAll(mainView); + mainView.selectConnection(profile); + }); + holder.getChildren().setAll(view); + return view; + }); + recorder.caption("The first start brings the Example Shop (H2) database, connect to it"); + @SuppressWarnings("unchecked") + ListView list = (ListView) node(loginView, "profileList"); + pause(1200); + moveTo(onFx(() -> list.lookup(".list-cell"))); + pause(800); + press(node(loginView, "loginButton")); + waitFor(() -> mainView != null && connection(ExampleDatabase.PROFILE_NAME) != null); + pause(1500); + } + + private ConnectionTree tree() { + return (ConnectionTree) mainView.lookup("#connectionTree"); + } + + private TreeItem connection(String name) { + return onFx(() -> tree().getRoot().getChildren().stream().filter(item -> name.equals(item + .getValue().getLabel())).findFirst().orElse(null)); + } + + private TreeItem child(TreeItem parent, String label) { + return waitFor(() -> parent.getChildren().stream().filter(item -> label.equalsIgnoreCase(item.getValue() + .getLabel())).findFirst().orElse(null)); + } + + private TreeItem firstChild(TreeItem parent) { + return waitFor(() -> parent.getChildren().isEmpty() ? null : parent.getChildren().get(0)); + } + + /** + * Expands the tree item like a click on its cell, its children are loaded in the background + */ + private TreeItem expand(TreeItem item) throws Exception { + moveTo(cell(item)); + recorder.click(); + FxTestSupport.run(() -> { + tree().getSelectionModel().select(item); + item.setExpanded(true); + }); + waitFor(() -> !item.getChildren().isEmpty() && item.getChildren().stream().noneMatch(child -> child + .getValue().getKind() == DbNode.Kind.LOADING) ? item : null); + pause(700); + return item; + } + + private void collapse(TreeItem item) throws Exception { + moveTo(cell(item)); + recorder.click(); + FxTestSupport.run(() -> item.setExpanded(false)); + pause(600); + } + + private Node cell(TreeItem item) { + return waitFor(() -> { + tree().scrollTo(tree().getRow(item)); + tree().layout(); + return tree().lookupAll(".tree-cell").stream().filter(node -> ((TreeCell) node).getTreeItem() == item) + .findFirst().orElse(null); + }); + } + + private void select(TreeItem item) throws Exception { + moveTo(cell(item)); + recorder.click(); + FxTestSupport.run(() -> tree().getSelectionModel().select(item)); + pause(500); + } + + private TreeItem examplePublic() throws Exception { + TreeItem example = connection(ExampleDatabase.PROFILE_NAME); + TreeItem catalog = firstChild(example.isExpanded() ? example : expand(example)); + TreeItem schema = catalog.isExpanded() ? child(catalog, "PUBLIC") : child(expand(catalog), "PUBLIC"); + return schema.isExpanded() ? schema : expand(schema); + } + + private void query() throws Exception { + recorder.caption("Browse the e-commerce tables of the example"); + TreeItem schema = examplePublic(); + pause(1000); + recorder.caption("Open ORDERS and narrow its rows on the Data tab"); + TreeItem orders = child(schema, "ORDERS"); + moveTo(cell(orders)); + recorder.click(); + FxTestSupport.run(() -> mainView.openTable(orders.getValue())); + pause(900); + TabPane tabs = waitFor(() -> { + TabPane tableTabs = (TabPane) mainView.lookup("#tableTabs"); + return tableTabs.getSelectionModel().getSelectedItem() == null ? null : (TabPane) tableTabs + .getSelectionModel().getSelectedItem().getContent(); + }); + Node dataTab = waitFor(() -> tabs.lookupAll(".tab-label").stream().filter(node -> "Data".equals(((Labeled) + node).getText())).findFirst().orElse(null)); + moveTo(dataTab); + recorder.click(); + FxTestSupport.run(() -> tabs.getSelectionModel().select(4)); + waitFor(() -> "300 rows".equals(((Label) mainView.lookup("#totalLabel")).getText()) ? true : null); + pause(1200); + type((TextField) node(mainView, "whereField"), "status = 'PAID'"); + press(node(mainView, "applyQueryButton")); + waitFor(() -> "60 rows".equals(((Label) mainView.lookup("#totalLabel")).getText()) ? true : null); + pause(2500); + } + + /** + * Opens the export dialog of the PUBLIC schema of the example with all of its tables + */ + private void openExport() throws Exception { + select(examplePublic()); + press(node(mainView, "action-export")); + Node selectAll = waitFor(() -> topNode("selectAllButton")); + fitDialog(topWindow()); + pause(900); + press(selectAll); + pause(800); + } + + private void exportFiles() throws Exception { + recorder.caption("Export to files: all tables of the schema, written for PostgreSQL"); + openExport(); + scrollDialog(1); + type((TextField) topNode("directoryField"), packageDir.getAbsolutePath()); + choose(topNode("scriptDbTypeCombo"), DbType.POSTGRESQL); + pause(1000); + press(topNode("startButton")); + recorder.caption("The package holds the schema, data, constraints and a manifest"); + Window progress = waitForProgress(); + pause(1500); + press(node(progress.getScene().getRoot(), "viewPackageButton")); + Node viewer = waitFor(() -> topNode("packageViewer")); + fitDialog(onFx(() -> viewer.getScene().getWindow())); + recorder.caption("The package viewer shows every file of the package"); + pause(4000); + FxTestSupport.run(() -> viewer.getScene().getWindow().hide()); + pause(500); + closeProgress(progress); + } + + private void importPackage() throws Exception { + recorder.caption("Import the package into the shop schema of PostgreSQL"); + select(connection(ExampleDatabase.PROFILE_NAME)); + press(node(mainView, "action-importScripts")); + TextField directory = waitFor(() -> (TextField) topNode("directoryField")); + fitDialog(topWindow()); + pause(800); + type(directory, packageDir.getAbsolutePath()); + FxTestSupport.run(() -> directory.fireEvent(new ActionEvent())); + ComboBox target = waitFor(() -> { + ComboBox combo = (ComboBox) topNode("targetCombo"); + return combo.getValue() != null && "PostgreSQL demo".equals(((ConnectionProfile) combo.getValue()) + .getName()) ? combo : null; + }); + moveTo(target); + pause(900); + ComboBox catalog = waitFor(() -> { + ComboBox combo = (ComboBox) topNode("catalogCombo"); + return "demo".equals(combo.getValue()) ? combo : null; + }); + moveTo(catalog); + pause(500); + ComboBox schema = waitFor(() -> { + ComboBox combo = (ComboBox) topNode("schemaCombo"); + return combo.getItems().contains(SCHEMA) ? combo : null; + }); + choose(schema, SCHEMA); + pause(800); + press(topNode("startButton")); + Window progress = waitForProgress(); + pause(1800); + closeProgress(progress); + recorder.caption("The tables are in PostgreSQL now"); + TreeItem postgresql = expand(connection("PostgreSQL demo")); + TreeItem shop = expand(child(expand(child(postgresql, "demo")), SCHEMA)); + assertEquals(TABLES.size(), FxTestSupport.call(() -> shop.getChildren().size()).intValue()); + pause(3000); + collapse(postgresql); + } + + private void exportSqlServer() throws Exception { + recorder.caption("Export straight into SQL Server, create its data source with the + button"); + openExport(); + RadioButton database = (RadioButton) topNode("databaseRadio"); + scrollDialog(1); + press(database, () -> database.setSelected(true)); + pause(700); + scrollDialog(1); + Window export = topWindow(); + press(topNode("newTargetButton")); + Node form = waitFor(() -> topWindow() != export ? topNode("nameField") : null); + fitDialog(topWindow()); + pause(700); + type((TextField) form, "SQL Server demo"); + choose(topNode("dbTypeCombo"), DbType.SQLSERVER); + type((TextField) topNode("hostField"), "localhost"); + type((TextField) topNode("portField"), "1433"); + type((TextField) topNode("databaseField"), "demo"); + type((TextField) topNode("usernameField"), USERNAME); + type((TextField) topNode("passwordField"), PASSWORD); + press(buttonOf(topWindow(), "Test")); + waitFor(() -> ((Labeled) topNode("formStatusLabel")).getText().startsWith("Connected") ? true : null); + pause(1500); + press(buttonOf(topWindow(), "Save")); + waitFor(() -> topWindow() == export ? true : null); + recorder.caption("The new data source is the target, a missing schema is created"); + pause(1000); + ComboBox schema = waitFor(() -> { + ComboBox combo = (ComboBox) topNode("targetSchemaCombo"); + return combo.getItems().isEmpty() ? null : combo; + }); + type(schema.getEditor(), SCHEMA); + moveTo(topNode("createSchemaCheck")); + assertTrue(FxTestSupport.call(() -> ((CheckBox) topNode("createSchemaCheck")).isSelected())); + pause(1000); + press(topNode("startButton")); + Window progress = waitForProgress(); + pause(1800); + closeProgress(progress); + TreeItem sqlServer = expand(waitFor(() -> connection("SQL Server demo"))); + expand(child(expand(child(sqlServer, "demo")), SCHEMA)); + pause(3000); + collapse(sqlServer); + } + + private void exportOracle() throws Exception { + recorder.caption("The same tables go straight into Oracle"); + openExport(); + RadioButton database = (RadioButton) topNode("databaseRadio"); + scrollDialog(1); + press(database, () -> database.setSelected(true)); + pause(700); + scrollDialog(1); + ConnectionProfile oracle = context.getProfileRegistry().getProfiles().stream().filter(profile -> "Oracle demo" + .equals(profile.getName())).findFirst().orElseThrow(); + choose(topNode("targetCombo"), oracle); + ComboBox schema = waitFor(() -> { + ComboBox combo = (ComboBox) topNode("targetSchemaCombo"); + return USERNAME.equalsIgnoreCase(Objects.toString(combo.getValue(), "")) ? combo : null; + }); + moveTo(schema); + pause(1200); + press(topNode("startButton")); + Window progress = waitForProgress(); + recorder.caption("Types are translated, keys, indexes, comments and identities come along"); + pause(2500); + closeProgress(progress); + pause(800); + } + + /** + * Waits until the progress window is completed, its log is shown for a moment + */ + private Window waitForProgress() throws InterruptedException { + Window window = waitFor(() -> Window.getWindows().stream().filter(Window::isShowing).filter(item -> item + .getScene() != null && item.getScene().getRoot().lookup("#progressStatus") != null).findFirst() + .orElse(null)); + fitDialog(window); + waitFor(() -> "Close".equals(((Labeled) window.getScene().getRoot().lookup("#cancelButton")).getText()) + ? true : null); + String status = FxTestSupport.call(() -> ((Labeled) window.getScene().getRoot().lookup("#progressStatus")) + .getText()); + assertEquals("Completed", status); + return window; + } + + /** + * Dialogs are placed by the window system, the video shows them inside the main window: a higher dialog is + * made smaller and every dialog is centered + */ + private void fitDialog(Window window) throws InterruptedException { + FxTestSupport.run(() -> { + Scene main = stage.getScene(); + double maxHeight = main.getHeight() - 30; + if (window.getHeight() > maxHeight) { + window.setHeight(maxHeight); + } + double decoration = window.getScene() == null ? 0 : window.getScene().getY(); + window.setX(stage.getX() + main.getX() + (main.getWidth() - window.getWidth()) / 2); + window.setY(stage.getY() + main.getY() + (main.getHeight() - window.getHeight() + decoration) / 2 + - decoration); + }); + Thread.sleep(200); + } + + private void closeProgress(Window window) throws Exception { + press(node(window.getScene().getRoot(), "cancelButton")); + waitFor(() -> window.isShowing() ? null : true); + pause(600); + } + + /** + * Scrolls the scroll pane of the top dialog to the position with an animation + */ + private void scrollDialog(double target) throws Exception { + ScrollPane scrollPane = FxTestSupport.call(() -> (ScrollPane) topWindow().getScene().getRoot() + .lookup(".scroll-pane")); + if (scrollPane == null) { + return; + } + double from = FxTestSupport.call(scrollPane::getVvalue); + for (int i = 1; i <= 30; i++) { + double value = from + (target - from) * i / 30; + FxTestSupport.run(() -> scrollPane.setVvalue(value)); + Thread.sleep(25); + } + pause(400); + } + + /** + * Runs on the FX thread, directly when it is called there, e.g. by waitFor + */ + private static T onFx(Callable callable) { + if (Platform.isFxApplicationThread()) { + try { + return callable.call(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + return FxTestSupport.call(callable); + } + + private static Node node(Node root, String id) { + return onFx(() -> root.lookup("#" + id)); + } + + /** + * The topmost showing window which is not a popup + */ + private Window topWindow() { + return onFx(() -> { + List windows = new ArrayList<>(Window.getWindows()); + for (int i = windows.size() - 1; i >= 0; i--) { + Window window = windows.get(i); + if (window.isShowing() && window instanceof Stage && window.getScene() != null) { + return window; + } + } + return stage; + }); + } + + /** + * Node of the id in the topmost window which has it + */ + private Node topNode(String id) { + return onFx(() -> { + List windows = new ArrayList<>(Window.getWindows()); + for (int i = windows.size() - 1; i >= 0; i--) { + Window window = windows.get(i); + if (window.isShowing() && window.getScene() != null) { + Node node = window.getScene().getRoot().lookup("#" + id); + if (node != null && node.isVisible()) { + return node; + } + } + } + return null; + }); + } + + private static Button buttonOf(Window window, String text) { + return onFx(() -> window.getScene().getRoot().lookupAll(".button").stream().filter(node -> + text.equals(((Button) node).getText())).map(node -> (Button) node).findFirst().orElseThrow()); + } + + private T waitFor(Supplier supplier) { + long deadline = System.currentTimeMillis() + 60_000; + while (System.currentTimeMillis() < deadline) { + T value = FxTestSupport.call(supplier::get); + if (value != null) { + return value; + } + sleep(100); + } + throw new AssertionError("Not shown in 60 seconds"); + } + + private void moveTo(Node node) throws InterruptedException { + double[] center = FxTestSupport.call(() -> { + Bounds bounds = node.localToScreen(node.getBoundsInLocal()); + Scene main = stage.getScene(); + return new double[]{bounds.getCenterX() - stage.getX() - main.getX(), bounds.getCenterY() - stage.getY() + - main.getY()}; + }); + recorder.movePointer(center[0], center[1], 550); + Thread.sleep(150); + } + + /** + * Moves to the button and fires it later, a modal dialog opened by it does not block the recording + */ + private void press(Node node) throws InterruptedException { + press(node, ((ButtonBase) node)::fire); + } + + private void press(Node node, Runnable action) throws InterruptedException { + moveTo(node); + recorder.click(); + Platform.runLater(action); + Thread.sleep(350); + } + + private void type(TextField field, String text) throws InterruptedException { + moveTo(field); + recorder.click(); + FxTestSupport.run(() -> { + field.requestFocus(); + field.clear(); + }); + for (char c : text.toCharArray()) { + FxTestSupport.run(() -> { + field.appendText(String.valueOf(c)); + field.positionCaret(field.getText().length()); + }); + Thread.sleep(text.length() > 30 ? 18 : 55); + } + Thread.sleep(300); + } + + /** + * Opens the drop down of the combo box, shows the choice and selects it + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private void choose(Node node, Object value) throws InterruptedException { + ComboBox combo = (ComboBox) node; + moveTo(combo); + recorder.click(); + FxTestSupport.run(combo::show); + Thread.sleep(700); + Node cell = FxTestSupport.call(() -> { + ListView list = (ListView) ((ComboBoxListViewSkin) combo.getSkin()).getPopupContent(); + list.scrollTo(list.getItems().indexOf(value)); + list.layout(); + return list.lookupAll(".list-cell").stream().filter(item -> Objects.equals(((ListCell) item).getItem(), + value)).findFirst().orElse(null); + }); + if (cell != null) { + moveTo(cell); + recorder.click(); + } + FxTestSupport.run(() -> { + combo.setValue(value); + combo.hide(); + }); + Thread.sleep(500); + } + + private static void pause(long millis) throws InterruptedException { + Thread.sleep(millis); + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** + * Tables of the video must not exist yet, they are dropped after the recording + */ + private static void checkTargets() throws SQLException { + try (Connection connection = DriverManager.getConnection(POSTGRESQL_URL, USERNAME, PASSWORD); + Statement statement = connection.createStatement()) { + assertEquals(0, count(statement, "SELECT COUNT(*) FROM pg_tables WHERE schemaname = '" + SCHEMA + "'"), + "Schema shop of PostgreSQL has tables"); + // The package import uses an existing schema + statement.execute("CREATE SCHEMA IF NOT EXISTS " + SCHEMA); + } + try (Connection connection = DriverManager.getConnection(SQLSERVER_URL, USERNAME, PASSWORD); + Statement statement = connection.createStatement()) { + assertEquals(0, count(statement, "SELECT COUNT(*) FROM sys.schemas WHERE name = '" + SCHEMA + "'"), + "Schema shop of SQL Server exists"); + } + try (Connection connection = DriverManager.getConnection(ORACLE_URL, USERNAME, PASSWORD); + Statement statement = connection.createStatement()) { + assertEquals(0, count(statement, "SELECT COUNT(*) FROM user_tables WHERE table_name IN (" + + "'CATEGORIES', 'CUSTOMERS', 'PRODUCTS', 'ORDERS', 'ORDER_ITEMS')"), "Oracle has the tables"); + } + } + + private static long count(Statement statement, String sql) throws SQLException { + try (ResultSet rs = statement.executeQuery(sql)) { + rs.next(); + return rs.getLong(1); + } + } + + private static void dropTables() { + execute(POSTGRESQL_URL, List.of("DROP SCHEMA IF EXISTS " + SCHEMA + " CASCADE")); + List sqlServer = new ArrayList<>(); + for (String table : TABLES) { + sqlServer.add("IF OBJECT_ID('" + SCHEMA + "." + table + "', 'U') IS NOT NULL ALTER TABLE " + SCHEMA + "." + + table + " NOCHECK CONSTRAINT ALL"); + } + // Children before parents + for (String table : List.of("order_items", "orders", "products", "customers", "categories")) { + sqlServer.add("DROP TABLE IF EXISTS " + SCHEMA + "." + table); + } + sqlServer.add("IF SCHEMA_ID('" + SCHEMA + "') IS NOT NULL DROP SCHEMA " + SCHEMA); + execute(SQLSERVER_URL, sqlServer); + List oracle = new ArrayList<>(); + for (String table : TABLES) { + oracle.add("BEGIN EXECUTE IMMEDIATE 'DROP TABLE " + table.toUpperCase() + " CASCADE CONSTRAINTS PURGE'; " + + "EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END;"); + } + execute(ORACLE_URL, oracle); + } + + private static void execute(String url, List sqls) { + try (Connection connection = DriverManager.getConnection(url, USERNAME, PASSWORD); + Statement statement = connection.createStatement()) { + for (String sql : sqls) { + statement.execute(sql); + } + } catch (SQLException e) { + System.err.println("Unable to drop the tables of the video in " + url + ": " + e.getMessage()); + } + } +} diff --git a/src/test/java/com/github/sqljam/face/view/VideoRecorder.java b/src/test/java/com/github/sqljam/face/view/VideoRecorder.java new file mode 100644 index 0000000..cc0e633 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/VideoRecorder.java @@ -0,0 +1,471 @@ +/* + * 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.awt.Polygon; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import javafx.application.Platform; +import javafx.geometry.Rectangle2D; +import javafx.scene.Scene; +import javafx.scene.SnapshotParameters; +import javafx.scene.image.PixelFormat; +import javafx.scene.image.WritableImage; +import javafx.scene.paint.Color; +import javafx.scene.transform.Transform; +import javafx.stage.PopupWindow; +import javafx.stage.Stage; +import javafx.stage.Window; + +/** + * @Description: VideoRecorder records the windows of the application into a video without a screen recorder: frames + * are rendered by JavaFX (the main window, its dialogs and popups at their positions), a pointer and + * click rings are drawn on top, and the frames are piped to ffmpeg. Subtitles are collected with their + * times and burned in together with the background music when the recording is finished. + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +final class VideoRecorder { + + private static final long CLICK_MILLIS = 450; + /** + * Pointer of the video, in logical pixels + */ + private static final Polygon POINTER = new Polygon(new int[]{0, 0, 4, 7, 10, 7, 12}, new int[]{0, 17, 13, 20, + 19, 12, 12}, 7); + + private final Stage stage; + private final String ffmpeg; + private final double scale; + private final int fps; + private final int width; + private final int height; + /** + * Height of the application in the video, the subtitles have their own band below it + */ + private final int sceneHeight; + private final List
captions = new ArrayList<>(); + private final AtomicBoolean rendering = new AtomicBoolean(); + private final boolean[] pointerFill; + private final boolean[] pointerEdge; + private final int pointerWidth; + private final int pointerHeight; + + private volatile double pointerX; + private volatile double pointerY; + private volatile long clickNanos; + private volatile byte[] frame; + private volatile boolean running; + private long startNanos; + private long frameCount; + private Process process; + private Thread writer; + private ScheduledExecutorService renderer; + private File rawVideo; + + /** + * @param stage the main window, the video has the size of its scene + * @param scale pixels of the video per logical pixel, e.g. 1.25 for sharper text + * @param captionBand height of the band of the subtitles below the application in logical pixels, the + * subtitles never cover the user interface + */ + VideoRecorder(Stage stage, String ffmpeg, double scale, int fps, int captionBand) { + this.stage = stage; + this.ffmpeg = ffmpeg; + this.scale = scale; + this.fps = fps; + // Even sizes for yuv420p + this.width = (int) Math.round(stage.getScene().getWidth() * scale) / 2 * 2; + this.sceneHeight = (int) Math.round(stage.getScene().getHeight() * scale); + this.height = (int) Math.round((stage.getScene().getHeight() + captionBand) * scale) / 2 * 2; + this.pointerX = stage.getScene().getWidth() / 2; + this.pointerY = stage.getScene().getHeight() / 2; + pointerWidth = (int) Math.ceil(14 * scale); + pointerHeight = (int) Math.ceil(22 * scale); + pointerFill = new boolean[pointerWidth * pointerHeight]; + pointerEdge = new boolean[pointerWidth * pointerHeight]; + for (int y = 0; y < pointerHeight; y++) { + for (int x = 0; x < pointerWidth; x++) { + pointerFill[y * pointerWidth + x] = POINTER.contains((x + 0.5) / scale, (y + 0.5) / scale); + } + } + // The edge is the outside next to the inside + for (int y = 0; y < pointerHeight; y++) { + for (int x = 0; x < pointerWidth; x++) { + boolean near = false; + for (int dy = -1; dy <= 1 && !near; dy++) { + for (int dx = -1; dx <= 1 && !near; dx++) { + int nx = x + dx; + int ny = y + dy; + near = nx >= 0 && ny >= 0 && nx < pointerWidth && ny < pointerHeight + && pointerFill[ny * pointerWidth + nx]; + } + } + pointerEdge[y * pointerWidth + x] = near && !pointerFill[y * pointerWidth + x]; + } + } + } + + int getWidth() { + return width; + } + + int getHeight() { + return height; + } + + /** + * Starts rendering and encoding into the raw video, a fast encoding which is encoded again by finish + */ + void start(File rawVideo) throws IOException, InterruptedException { + this.rawVideo = rawVideo; + process = new ProcessBuilder(ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-f", "rawvideo", + "-pix_fmt", "bgra", "-s", width + "x" + height, "-r", String.valueOf(fps), "-i", "-", "-c:v", + "libx264", "-preset", "ultrafast", "-crf", "12", "-pix_fmt", "yuv420p", rawVideo.getAbsolutePath()) + .redirectErrorStream(true).redirectOutput(new File(rawVideo.getPath() + ".log")).start(); + running = true; + long period = 1_000_000_000L / fps; + renderer = Executors.newSingleThreadScheduledExecutor(); + renderer.scheduleAtFixedRate(() -> { + if (running && rendering.compareAndSet(false, true)) { + Platform.runLater(() -> { + try { + frame = render(); + } finally { + rendering.set(false); + } + }); + } + }, 0, period, TimeUnit.NANOSECONDS); + while (frame == null) { + Thread.sleep(10); + } + startNanos = System.nanoTime(); + writer = new Thread(() -> write(period), "video-writer"); + writer.start(); + } + + /** + * Writes the latest frame at the frame rate of the video, a frame is repeated when rendering is slower, so that + * the video keeps the time of the recording and the subtitles + */ + private void write(long period) { + try (OutputStream out = process.getOutputStream()) { + while (running) { + long due = startNanos + frameCount * period; + long wait = due - System.nanoTime(); + if (wait > 0) { + Thread.sleep(wait / 1_000_000, (int) (wait % 1_000_000)); + } + out.write(frame); + frameCount++; + } + } catch (IOException e) { + throw new IllegalStateException("Unable to write the video", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + long elapsedMillis() { + return (System.nanoTime() - startNanos) / 1_000_000; + } + + /** + * Shows the subtitle from now until the next one + */ + void caption(String text) { + long now = elapsedMillis(); + endCaption(); + captions.add(new Caption(now, text)); + } + + void endCaption() { + if (!captions.isEmpty() && captions.get(captions.size() - 1).end < 0) { + captions.get(captions.size() - 1).end = elapsedMillis(); + } + } + + /** + * Moves the pointer to the position of the main scene with an eased motion + */ + void movePointer(double x, double y, long millis) throws InterruptedException { + double fromX = pointerX; + double fromY = pointerY; + long steps = Math.max(1, millis / 15); + for (long i = 1; i <= steps; i++) { + double t = (double) i / steps; + double eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + pointerX = fromX + (x - fromX) * eased; + pointerY = fromY + (y - fromY) * eased; + Thread.sleep(15); + } + } + + void click() { + clickNanos = System.nanoTime(); + } + + /** + * Stops recording, the raw video is complete afterwards + */ + void stop() throws InterruptedException, IOException { + endCaption(); + running = false; + renderer.shutdownNow(); + writer.join(); + if (process.waitFor() != 0) { + throw new IOException("ffmpeg failed, see " + rawVideo.getPath() + ".log"); + } + } + + double getDurationSeconds() { + return (double) frameCount / fps; + } + + /** + * Encodes the final video: subtitles burned in, the music faded in and out under the whole video + */ + void finish(File output, File music, String font) throws IOException, InterruptedException { + File directory = rawVideo.getParentFile(); + File subtitles = new File(directory, "subtitles.ass"); + Files.writeString(subtitles.toPath(), toAss(font), StandardCharsets.UTF_8); + double duration = getDurationSeconds(); + String audio = String.format(Locale.ENGLISH, "[1:a]volume=0.5,afade=t=in:d=2,afade=t=out:st=%.2f:d=3[a]", + Math.max(0, duration - 3)); + output.getAbsoluteFile().getParentFile().mkdirs(); + Process encoder = new ProcessBuilder(ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", + rawVideo.getName(), "-i", music.getAbsolutePath(), "-filter_complex", "[0:v]subtitles=" + subtitles + .getName() + "[v];" + audio, "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "slow", + "-crf", "22", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", "-t", String.format( + Locale.ENGLISH, "%.2f", duration), "-movflags", "+faststart", output.getAbsolutePath()) + .directory(directory).redirectErrorStream(true).redirectOutput(new File(directory, "finish.log")) + .start(); + if (encoder.waitFor() != 0) { + throw new IOException("ffmpeg failed, see " + new File(directory, "finish.log")); + } + } + + private String toAss(String font) { + int band = height - sceneHeight; + int size = Math.round(band * 0.34f); + StringBuilder ass = new StringBuilder(); + ass.append("[Script Info]\nScriptType: v4.00+\nPlayResX: ").append(width).append("\nPlayResY: ") + .append(height).append("\nWrapStyle: 0\n\n[V4+ Styles]\n") + .append("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, ") + .append("BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, ") + .append("BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n") + .append("Style: Default,").append(font).append(',').append(size) + .append(",&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,60,60,") + .append(Math.round((band - size * 1.2f) / 2)) + .append(",1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, ") + .append("Effect, Text\n"); + for (Caption caption : captions) { + long end = caption.end < 0 ? Math.round(getDurationSeconds() * 1000) : caption.end; + ass.append("Dialogue: 0,").append(assTime(caption.start)).append(',').append(assTime(end)) + .append(",Default,,0,0,0,,").append(caption.text).append('\n'); + } + return ass.toString(); + } + + private static String assTime(long millis) { + long centis = millis / 10; + return String.format(Locale.ENGLISH, "%d:%02d:%02d.%02d", centis / 360000, centis / 6000 % 60, + centis / 100 % 60, centis % 100); + } + + /** + * Renders the windows of the application at their positions relative to the main scene, then the pointer + */ + private byte[] render() { + byte[] canvas = new byte[width * height * 4]; + for (int i = 0; i < canvas.length; i += 4) { + // The band of the subtitles: #0b0e14 + canvas[i] = 0x14; + canvas[i + 1] = 0x0e; + canvas[i + 2] = 0x0b; + canvas[i + 3] = (byte) 255; + } + Scene main = stage.getScene(); + double originX = stage.getX() + main.getX(); + double originY = stage.getY() + main.getY(); + for (Window window : new ArrayList<>(Window.getWindows())) { + Scene scene = window.getScene(); + if (!window.isShowing() || scene == null || scene.getRoot() == null) { + continue; + } + int windowWidth = (int) Math.ceil(scene.getWidth() * scale); + int windowHeight = (int) Math.ceil(scene.getHeight() * scale); + if (windowWidth <= 0 || windowHeight <= 0) { + continue; + } + int x = (int) Math.round((window.getX() + scene.getX() - originX) * scale); + int y = (int) Math.round((window.getY() + scene.getY() - originY) * scale); + SnapshotParameters parameters = new SnapshotParameters(); + parameters.setTransform(Transform.scale(scale, scale)); + parameters.setViewport(new Rectangle2D(0, 0, windowWidth, windowHeight)); + boolean popup = window instanceof PopupWindow; + parameters.setFill(popup || !(scene.getFill() instanceof Color) ? Color.TRANSPARENT : scene.getFill()); + WritableImage image = scene.getRoot().snapshot(parameters, null); + if (window != stage && !popup) { + drawShadow(canvas, x, y, windowWidth, windowHeight); + } + blend(canvas, image, x, y); + } + drawClick(canvas); + drawPointer(canvas); + return canvas; + } + + private void blend(byte[] canvas, WritableImage image, int left, int top) { + int imageWidth = (int) image.getWidth(); + int imageHeight = (int) image.getHeight(); + byte[] pixels = new byte[imageWidth * imageHeight * 4]; + image.getPixelReader().getPixels(0, 0, imageWidth, imageHeight, PixelFormat.getByteBgraPreInstance(), pixels, + 0, imageWidth * 4); + for (int y = 0; y < imageHeight; y++) { + int canvasY = top + y; + if (canvasY < 0 || canvasY >= sceneHeight) { + continue; + } + for (int x = 0; x < imageWidth; x++) { + int canvasX = left + x; + if (canvasX < 0 || canvasX >= width) { + continue; + } + int source = (y * imageWidth + x) * 4; + int target = (canvasY * width + canvasX) * 4; + int alpha = pixels[source + 3] & 0xff; + if (alpha == 255) { + canvas[target] = pixels[source]; + canvas[target + 1] = pixels[source + 1]; + canvas[target + 2] = pixels[source + 2]; + } else if (alpha > 0) { + int rest = 255 - alpha; + for (int c = 0; c < 3; c++) { + canvas[target + c] = (byte) ((pixels[source + c] & 0xff) + (canvas[target + c] & 0xff) + * rest / 255); + } + } + } + } + } + + /** + * Dialogs have no window frame in the video, a shadow and a border separate them from the main window + */ + private void drawShadow(byte[] canvas, int left, int top, int w, int h) { + int spread = (int) Math.round(14 * scale); + for (int y = top - spread; y < top + h + spread; y++) { + for (int x = left - spread; x < left + w + spread; x++) { + if (x < 0 || y < 0 || x >= width || y >= sceneHeight) { + continue; + } + int dx = Math.max(Math.max(left - x, x - (left + w - 1)), 0); + int dy = Math.max(Math.max(top - y, y - (top + h - 1)), 0); + double distance = Math.sqrt(dx * dx + dy * dy); + if (distance == 0) { + continue; + } + double darken = distance <= 1.5 * scale ? 0.35 : 0.45 * Math.max(0, 1 - distance / spread); + darken(canvas, (y * width + x) * 4, darken); + } + } + } + + private static void darken(byte[] canvas, int index, double amount) { + for (int c = 0; c < 3; c++) { + canvas[index + c] = (byte) ((canvas[index + c] & 0xff) * (1 - amount)); + } + } + + private void drawClick(byte[] canvas) { + long age = (System.nanoTime() - clickNanos) / 1_000_000; + if (clickNanos == 0 || age > CLICK_MILLIS) { + return; + } + double t = (double) age / CLICK_MILLIS; + double radius = (6 + 18 * t) * scale; + double thickness = 3 * scale; + double opacity = 0.85 * (1 - t); + int centerX = (int) Math.round(pointerX * scale); + int centerY = (int) Math.round(pointerY * scale); + int reach = (int) Math.ceil(radius + thickness); + for (int y = centerY - reach; y <= centerY + reach; y++) { + for (int x = centerX - reach; x <= centerX + reach; x++) { + if (x < 0 || y < 0 || x >= width || y >= sceneHeight) { + continue; + } + double distance = Math.hypot(x - centerX, y - centerY); + if (Math.abs(distance - radius) <= thickness / 2) { + int index = (y * width + x) * 4; + // Accent blue #4493f8 in BGRA + mix(canvas, index, 0xf8, 0x93, 0x44, opacity); + } + } + } + } + + private static void mix(byte[] canvas, int index, int blue, int green, int red, double opacity) { + canvas[index] = (byte) ((canvas[index] & 0xff) * (1 - opacity) + blue * opacity); + canvas[index + 1] = (byte) ((canvas[index + 1] & 0xff) * (1 - opacity) + green * opacity); + canvas[index + 2] = (byte) ((canvas[index + 2] & 0xff) * (1 - opacity) + red * opacity); + } + + private void drawPointer(byte[] canvas) { + int left = (int) Math.round(pointerX * scale); + int top = (int) Math.round(pointerY * scale); + for (int y = 0; y < pointerHeight; y++) { + for (int x = 0; x < pointerWidth; x++) { + int canvasX = left + x; + int canvasY = top + y; + if (canvasX < 0 || canvasY < 0 || canvasX >= width || canvasY >= sceneHeight) { + continue; + } + int index = (canvasY * width + canvasX) * 4; + if (pointerFill[y * pointerWidth + x]) { + mix(canvas, index, 255, 255, 255, 1); + } else if (pointerEdge[y * pointerWidth + x]) { + mix(canvas, index, 0, 0, 0, 1); + } + } + } + } + + private static final class Caption { + + final long start; + final String text; + long end = -1; + + Caption(long start, String text) { + this.start = start; + this.text = text; + } + } +} diff --git a/src/test/java/com/github/sqljam/h2/H2ParquetIT.java b/src/test/java/com/github/sqljam/h2/H2ParquetIT.java new file mode 100644 index 0000000..e1892f4 --- /dev/null +++ b/src/test/java/com/github/sqljam/h2/H2ParquetIT.java @@ -0,0 +1,32 @@ +/* + * 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.h2; + +import com.github.sqljam.it.AbstractParquetIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: H2ParquetIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class H2ParquetIT extends AbstractParquetIT { + + H2ParquetIT() { + super(ItDatabase.H2); + } +} diff --git a/src/test/java/com/github/sqljam/impexp/DdlScripterTest.java b/src/test/java/com/github/sqljam/impexp/DdlScripterTest.java index cdc0204..b6f1e7b 100644 --- a/src/test/java/com/github/sqljam/impexp/DdlScripterTest.java +++ b/src/test/java/com/github/sqljam/impexp/DdlScripterTest.java @@ -115,7 +115,7 @@ void dbTypeLookup() { assertEquals(DbType.MYSQL, DbType.forName("mysql")); assertNull(DbType.forName("db2")); assertEquals(DbType.MYSQL, DbType.forUrl("jdbc:mysql://localhost:3306/test")); - assertEquals(DbType.MYSQL, DbType.forUrl("jdbc:mariadb://localhost:3306/test")); + assertEquals(DbType.MARIADB, DbType.forUrl("jdbc:mariadb://localhost:3306/test")); assertEquals(DbType.POSTGRESQL, DbType.forUrl("jdbc:postgresql://localhost/demo")); assertEquals(DbType.ORACLE, DbType.forUrl("jdbc:oracle:thin:@localhost:1521/demo")); assertEquals(DbType.SQLSERVER, DbType.forUrl("jdbc:sqlserver://localhost:1433;databaseName=demo")); diff --git a/src/test/java/com/github/sqljam/impexp/DialectTest.java b/src/test/java/com/github/sqljam/impexp/DialectTest.java index 9cf55db..6c5aee4 100644 --- a/src/test/java/com/github/sqljam/impexp/DialectTest.java +++ b/src/test/java/com/github/sqljam/impexp/DialectTest.java @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; +import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; import java.time.LocalDate; @@ -29,7 +30,10 @@ import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.OffsetTime; +import java.time.ZoneId; import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.HashMap; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -205,7 +209,7 @@ void jdbcValues() { Types.TIME_WITH_TIMEZONE, "timetz", 0)); assertEquals(LocalTime.of(12, 0), postgresql.getJdbcValue("12:00:00+08", Types.TIME, "timetz", 0)); OffsetDateTime offsetDateTime = OffsetDateTime.of(2024, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC); - assertEquals(offsetDateTime.atZoneSameInstant(java.time.ZoneId.systemDefault()).toLocalDateTime(), + assertEquals(offsetDateTime.atZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime(), postgresql.getJdbcValue(offsetDateTime, Types.TIMESTAMP_WITH_TIMEZONE, "timestamptz", 0)); // Fractional seconds are truncated to the precision of target database assertEquals(LocalDateTime.of(9999, 12, 31, 23, 59, 59, 999_999_000), postgresql.getJdbcValue( @@ -251,7 +255,7 @@ void literals() { assertEquals("TIME '10:20:30.5'", dialect.getStringValue(null, null, "t", "c", LocalTime.of(10, 20, 30, 500_000_000))); assertEquals("TIME '10:20:30'", dialect.getStringValue(null, null, "t", "c", - java.sql.Time.valueOf("10:20:30"))); + Time.valueOf("10:20:30"))); assertEquals("'2024-01-01 08:00:00+08:00'", dialect.getStringValue(null, null, "t", "c", OffsetDateTime.of(2024, 1, 1, 8, 0, 0, 0, ZoneOffset.ofHours(8)))); assertEquals("'12:00:00+08:00'", dialect.getStringValue(null, null, "t", "c", @@ -261,7 +265,7 @@ void literals() { assertEquals("'" + uuid + "'", dialect.getStringValue(null, null, "t", "c", uuid)); assertTrue(dialect.getStringValue(null, null, "t", "c", new java.util.Date(0)).startsWith("TIMESTAMP '")); assertTrue(dialect.getStringValue(null, null, "t", "c", - java.time.ZonedDateTime.now()).startsWith("TIMESTAMP '")); + ZonedDateTime.now()).startsWith("TIMESTAMP '")); assertEquals("X''", dialect.getEmptyLobLiteral(true)); assertEquals("''", dialect.getEmptyLobLiteral(false)); } @@ -288,7 +292,7 @@ void statements() { new String[]{"user_id"}, false, null)); assertEquals("uidx_orders_code", dialect.getIndexNameStatement(null, null, "orders", new String[]{"code"}, true, null)); - assertNull(dialect.getDefinePartitionTableStatement(null, null, "t", "RANGE", "id", new java.util.HashMap<>())); + assertNull(dialect.getDefinePartitionTableStatement(null, null, "t", "RANGE", "id", new HashMap<>())); assertNull(dialect.getSessionStatements()); assertNull(dialect.getStatementBeforeInsert(null, null, "t", true)); assertNull(dialect.getStatementAfterInsert(null, null, "t", true)); diff --git a/src/test/java/com/github/sqljam/impexp/ManifestTest.java b/src/test/java/com/github/sqljam/impexp/ManifestTest.java index 95f6c96..5ca3214 100644 --- a/src/test/java/com/github/sqljam/impexp/ManifestTest.java +++ b/src/test/java/com/github/sqljam/impexp/ManifestTest.java @@ -21,13 +21,17 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.File; +import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.sql.Connection; import java.sql.DriverManager; +import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -58,7 +62,7 @@ void lobManifest() throws Exception { writer.writeRow("PUBLIC", "DOCUMENT", key, new LinkedHashMap<>(), 2); Map otherKey = new LinkedHashMap<>(); otherKey.put("CODE", "a/b"); - otherKey.put("V", new java.math.BigDecimal("1.5")); + otherKey.put("V", new BigDecimal("1.5")); otherKey.put("F", 2.5f); otherKey.put("B", true); otherKey.put("N", null); @@ -134,9 +138,9 @@ void legacyScripts() throws Exception { Files.writeString(unit.toPath().resolve("data_10.sql"), "INSERT INTO t VALUES (10);\n"); Files.writeString(unit.toPath().resolve("data/t.sql"), "INSERT INTO t VALUES (3);\n"); Files.writeString(unit.toPath().resolve("constraints.sql"), "-- nothing\n"); - assertEquals(java.util.List.of("data.sql", "data_2.sql", "data_10.sql", "t.sql"), + assertEquals(List.of("data.sql", "data_2.sql", "data_10.sql", "t.sql"), ScriptImporter.getDataFiles(unit).stream().map(File::getName).collect( - java.util.stream.Collectors.toList())); + Collectors.toList())); try (Connection connection = DriverManager.getConnection("jdbc:h2:mem:legacy")) { ScriptImporter importer = new ScriptImporter(connection, null); importer.setStopOnError(false); @@ -149,8 +153,8 @@ void legacyScripts() throws Exception { @Test void identityValues() { IdentityValueTracker tracker = new IdentityValueTracker(); - TableMetaData table = new TableMetaData("t", new java.util.HashMap<>(), null); - tracker.track(table, java.util.List.of(), java.util.List.of()); + TableMetaData table = new TableMetaData("t", new HashMap<>(), null); + tracker.track(table, List.of(), List.of()); assertNull(tracker.get(table)); Map row1 = new LinkedHashMap<>(); row1.put("id", 5); @@ -158,7 +162,7 @@ void identityValues() { row2.put("id", 3L); Map row3 = new LinkedHashMap<>(); row3.put("id", null); - tracker.track(table, java.util.List.of("id"), java.util.List.of(row1, row2, row3)); + tracker.track(table, List.of("id"), List.of(row1, row2, row3)); assertEquals(5L, tracker.get(table).get("id")); assertEquals(5L, tracker.remove(table).get("id")); assertNull(tracker.get(table)); diff --git a/src/test/java/com/github/sqljam/impexp/SqlScriptRunnerTest.java b/src/test/java/com/github/sqljam/impexp/SqlScriptRunnerTest.java index ecaa420..a026b15 100644 --- a/src/test/java/com/github/sqljam/impexp/SqlScriptRunnerTest.java +++ b/src/test/java/com/github/sqljam/impexp/SqlScriptRunnerTest.java @@ -24,6 +24,7 @@ import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; @@ -185,7 +186,7 @@ void oracleBlocks() { assertFalse(SqlScriptRunner.isBatchable("UPDATE t SET a = 1")); assertFalse(SqlScriptRunner.isBatchable("BEGIN INSERT INTO t VALUES (1); END;")); - List batch = new java.util.ArrayList<>(); + List batch = new ArrayList<>(); for (int i = 1; i <= SqlScriptRunner.ORACLE_BLOCK_SIZE + 1; i++) { batch.add("INSERT INTO FENGY.T(ID,NAME) VALUES (" + i + ",'a;b')"); } diff --git a/src/test/java/com/github/sqljam/impexp/db/ClickHouseDialectTest.java b/src/test/java/com/github/sqljam/impexp/db/ClickHouseDialectTest.java new file mode 100644 index 0000000..f0c17bf --- /dev/null +++ b/src/test/java/com/github/sqljam/impexp/db/ClickHouseDialectTest.java @@ -0,0 +1,257 @@ +/* + * 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.db; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.sql.Date; +import java.sql.Time; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetTime; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.Dialect; + +/** + * @Description: ClickHouseDialectTest verifies ddl, literals and values of ClickHouse and the type mapping of + * ClickHouse columns + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class ClickHouseDialectTest { + + private static ClickHouseDialect cross(DbType sourceDbType) { + ClickHouseDialect dialect = new ClickHouseDialect(); + dialect.setSourceDbType(sourceDbType); + return dialect; + } + + private static Map column(String type) { + Map columnInfo = new HashMap<>(); + ClickHouseMetaDataOperations.normalizeType(columnInfo, type); + return columnInfo; + } + + @Test + void mapsColumnsOfOtherDatabases() { + ClickHouseDialect dialect = cross(DbType.MYSQL); + assertEquals("Int32", dialect.getColumnTypeName(Types.INTEGER, "int", 10, 0)); + assertEquals("Decimal(12,2)", dialect.getColumnTypeName(Types.DECIMAL, "decimal", 12, 2)); + assertEquals("String", dialect.getColumnTypeName(Types.NUMERIC, "numeric", 0, 0)); + assertEquals("DateTime64(3)", dialect.getColumnTypeName(Types.TIMESTAMP, "datetime", 23, 3)); + assertEquals("Date32", dialect.getColumnTypeName(Types.DATE, "date", 10, 0)); + assertEquals("String", dialect.getColumnTypeName(Types.TIME, "time", 8, 0)); + assertEquals("String", dialect.getColumnTypeName(Types.BLOB, "blob", 65535, 0)); + assertEquals("Bool", dialect.getColumnTypeName(Types.BIT, "bit", 1, 0)); + assertEquals("Int64", dialect.getColumnTypeName(Types.BIT, "bit(8)", 8, 0)); + assertEquals("UUID", cross(DbType.POSTGRESQL).getColumnTypeName(Types.OTHER, "uuid", 0, 0)); + assertEquals("String", cross(DbType.POSTGRESQL).getColumnTypeName(Types.ARRAY, "_int4", 0, 0)); + assertEquals("String", cross(DbType.POSTGRESQL).getColumnTypeName(Types.OTHER, "jsonb", 0, 0)); + assertEquals("Int16", dialect.getColumnTypeName(Types.DATE, "year", 4, 0)); + } + + @Test + void createsMergeTreeTables() { + ClickHouseDialect dialect = cross(DbType.MYSQL); + assertEquals("id Int32", dialect.getColumnStatement(null, null, "t", "ID", + Types.INTEGER, "int", 10, 0, null, false)); + assertEquals("name Nullable(String) DEFAULT 'n/a' COMMENT 'It''s'", + dialect.getColumnStatement(null, null, "t", "name", Types.VARCHAR, "varchar", 10, 0, "'n/a'", true, + "It's")); + assertEquals("id Int64", dialect.getIncrementalColumnStatement(null, null, "t", + "id", Types.BIGINT, "bigint", 19, 0, null, true)); + assertEquals("PRIMARY KEY (id,name)", dialect.getCreatePrimaryKeyStatement(null, null, "t", "ID,NAME", + "PRIMARY")); + assertEquals("ENGINE = MergeTree ORDER BY (id, name) SETTINGS allow_nullable_key = 1", + dialect.getTableOptions(null, null, "t", Map.of(), List.of("ID", "NAME"))); + assertEquals("ENGINE = MergeTree ORDER BY tuple() SETTINGS allow_nullable_key = 1", + dialect.getTableOptions(null, null, "t", null, List.of())); + assertNull(dialect.getCreateIndexStatement(null, null, "t", false, new String[]{"a"}, "idx", true, null)); + assertNull(dialect.getCreateForeignKeyStatement(null, null, "t", "fk", new String[]{"a"}, "r", + new String[]{"id"}, null, "CASCADE")); + assertNull(dialect.getResetIdentityStatement(null, "app", "t", "id", 10)); + assertFalse(dialect.isSequenceSupported()); + assertFalse(dialect.isLobSeparationSupported()); + assertFalse(dialect.isArrayParameterSupported()); + assertEquals("DROP TABLE IF EXISTS app.t", withSchema(dialect).getDropTableStatement(null, "app", "t")); + assertEquals("ALTER TABLE app.t COMMENT COLUMN c 'x'", withSchema(dialect).getCreateCommentStatement(null, + "app", "t", "c", "x")); + assertEquals("ALTER TABLE app.t MODIFY COMMENT 'x'", withSchema(dialect).getCreateTableCommentStatement(null, + "app", "t", "x")); + assertEquals("CREATE DATABASE IF NOT EXISTS app", dialect.getCreateSchemaStatement(null, "app", null)); + assertEquals("default", dialect.getDefaultSchemaName(null)); + assertNull(dialect.getCreateUserStatement("u", "p")); + assertNull(dialect.getCreateDatabaseStatement("db", null)); + } + + private static ClickHouseDialect withSchema(ClickHouseDialect dialect) { + dialect.setTargetSchemaName("app"); + return dialect; + } + + @Test + void keepsTablesOfClickHouse() { + ClickHouseDialect dialect = new ClickHouseDialect(); + assertFalse(dialect.isCrossDatabase()); + assertEquals("Map(String, Int32)", dialect.getColumnTypeName(Types.VARCHAR, "Map(String, Int32)", 0, 0)); + assertEquals("c LowCardinality(Nullable(String))", dialect.getColumnStatement( + null, null, "t", "c", Types.VARCHAR, "LowCardinality(String)", 0, 0, null, true)); + assertEquals("c Array(Int32)", dialect.getColumnStatement(null, null, "t", "c", + Types.VARCHAR, "Array(Int32)", 0, 0, null, true)); + assertNull(dialect.getCreatePrimaryKeyStatement(null, null, "t", "id", null)); + assertEquals("ENGINE = ReplacingMergeTree ORDER BY id", dialect.getTableOptions(null, null, "t", + Map.of("ENGINE_FULL", "ReplacingMergeTree ORDER BY id"), List.of("id"))); + assertEquals("g Int32 MATERIALIZED id * 2", dialect.getGeneratedColumnStatement(null, null, "t", "g", + Types.INTEGER, "Int32", 10, 0, "id * 2", true)); + assertEquals("a Int32 ALIAS id + 1", dialect.getGeneratedColumnStatement(null, null, "t", "a", + Types.INTEGER, "Int32", 10, 0, "id + 1", false)); + assertEquals("Nullable(Int32)", ClickHouseDialect.toNullable("Nullable(Int32)")); + assertEquals("Map(String, Int32)", ClickHouseDialect.toNullable("Map(String, Int32)")); + } + + @Test + void writesLiteralsAndValues() { + ClickHouseDialect dialect = cross(DbType.MYSQL); + assertEquals("'O''Brien \\\\ x'", dialect.getStringLiteral("O'Brien \\ x")); + assertEquals("NULL", dialect.getStringLiteral(null)); + assertEquals("unhex('DEAD')", dialect.getBinaryLiteral(new byte[]{(byte) 0xDE, (byte) 0xAD})); + assertEquals("toDateTime64('2024-01-02 03:04:05.123', 6)", + dialect.getTimestampLiteral(LocalDateTime.of(2024, 1, 2, 3, 4, 5, 123_000_000))); + assertEquals("toDate32('2024-02-29')", dialect.getDateLiteral(LocalDate.of(2024, 2, 29))); + assertEquals("'12:00:01'", dialect.getTimeLiteral(LocalTime.of(12, 0, 1))); + assertEquals("now64()", dialect.getCurrentTimestampExpression()); + assertEquals("today()", dialect.getCurrentDateExpression()); + // Temporal values are bound as text, their wall clock time is kept + assertEquals("2024-01-02 03:04:05.123456", dialect.getJdbcValue( + LocalDateTime.of(2024, 1, 2, 3, 4, 5, 123_456_789), Types.TIMESTAMP, "datetime", 26)); + assertEquals("2024-01-02 03:04:05", dialect.getJdbcValue(Timestamp.valueOf("2024-01-02 03:04:05"), + Types.TIMESTAMP, "datetime", 19)); + assertEquals("2024-02-29", dialect.getJdbcValue(Date.valueOf("2024-02-29"), Types.DATE, "date", 10)); + assertEquals("12:00:01", dialect.getJdbcValue(Time.valueOf("12:00:01"), Types.TIME, "time", 8)); + UUID uuid = UUID.randomUUID(); + assertEquals(uuid.toString(), dialect.getJdbcValue(uuid, Types.OTHER, "uuid", 36)); + ClickHouseDialect same = new ClickHouseDialect(); + assertEquals("12:00:00+08:00", same.getJdbcValue(OffsetTime.of(12, 0, 0, 0, ZoneOffset.ofHours(8)), + Types.TIME_WITH_TIMEZONE, "String", 0)); + assertEquals(new BigDecimal("1.5"), dialect.getJdbcValue(new BigDecimal("1.5"), Types.DECIMAL, "decimal", 2)); + } + + @Test + void readsTextOfSpecialTypes() { + ClickHouseDialect dialect = new ClickHouseDialect(); + assertEquals("toString(m) AS m", dialect.getSelectColumnExpression("m", "Map(String, Int32)")); + assertEquals("toString(v) AS v", dialect.getSelectColumnExpression("v", "Decimal(76, 10)")); + assertEquals("d", dialect.getSelectColumnExpression("d", "Decimal(38, 10)")); + assertEquals("s", dialect.getSelectColumnExpression("s", "String")); + // Strings are read as bytes for ClickHouse targets + dialect.setReadTargetDbType(DbType.CLICKHOUSE); + assertEquals("hex(s) AS s", dialect.getSelectColumnExpression("s", "String")); + assertEquals("hex(f) AS f", dialect.getSelectColumnExpression("f", "FixedString(4)")); + assertEquals("SELECT 1 ORDER BY tuple(*) LIMIT 10 OFFSET 0", dialect.getPageStatement("SELECT 1", null, 10, + 0)); + assertEquals("SELECT 1 ORDER BY id LIMIT 10 OFFSET 20", dialect.getPageStatement("SELECT 1", "id", 10, 20)); + } + + @Test + void mapsClickHouseTypes() { + Map nullableString = column("Nullable(String)"); + assertEquals(Types.VARCHAR, nullableString.get("DATA_TYPE")); + assertEquals(Integer.MAX_VALUE, nullableString.get("COLUMN_SIZE")); + assertEquals("YES", nullableString.get("IS_NULLABLE")); + assertEquals("String", nullableString.get("TYPE_NAME")); + Map low = column("LowCardinality(Nullable(String))"); + assertEquals("LowCardinality(String)", low.get("TYPE_NAME")); + assertEquals("YES", low.get("IS_NULLABLE")); + Map decimal = column("Decimal(18, 4)"); + assertEquals(Types.DECIMAL, decimal.get("DATA_TYPE")); + assertEquals(18, decimal.get("COLUMN_SIZE")); + assertEquals(4, decimal.get("DECIMAL_DIGITS")); + // Decimals wider than DECIMAL(38) are text of other databases + assertEquals(Types.VARCHAR, column("Decimal256(10)").get("DATA_TYPE")); + assertEquals(Types.VARCHAR, column("Decimal(76, 10)").get("DATA_TYPE")); + assertEquals(9, column("Decimal32(2)").get("COLUMN_SIZE")); + assertEquals(Types.TIMESTAMP, column("DateTime64(6, 'UTC')").get("DATA_TYPE")); + assertEquals(6, column("DateTime64(6, 'UTC')").get("DECIMAL_DIGITS")); + assertEquals(0, column("DateTime('UTC')").get("DECIMAL_DIGITS")); + assertEquals(Types.CHAR, column("FixedString(4)").get("DATA_TYPE")); + assertEquals(4, column("FixedString(4)").get("COLUMN_SIZE")); + assertEquals("UUID", column("UUID").get("TYPE_NAME")); + assertEquals(15, column("IPv4").get("COLUMN_SIZE")); + assertEquals(45, column("IPv6").get("COLUMN_SIZE")); + assertEquals(6, column("Enum8('red' = 1, 'gre''en' = 2)").get("COLUMN_SIZE")); + assertEquals(Types.NUMERIC, column("UInt64").get("DATA_TYPE")); + assertEquals(20, column("UInt64").get("COLUMN_SIZE")); + assertEquals(38, column("Int128").get("COLUMN_SIZE")); + assertEquals(Types.VARCHAR, column("Int256").get("DATA_TYPE")); + assertEquals(Types.VARCHAR, column("Array(Nullable(String))").get("DATA_TYPE")); + assertEquals("NO", column("Array(Nullable(String))").get("IS_NULLABLE")); + for (String[] type : new String[][]{{"Bool", "16"}, {"Int8", "-6"}, {"UInt8", "5"}, {"UInt16", "4"}, + {"UInt32", "-5"}, {"Float32", "7"}, {"Float64", "8"}, {"Date", "91"}, {"Date32", "91"}}) { + assertEquals(Integer.parseInt(type[1]), column(type[0]).get("DATA_TYPE"), type[0]); + } + assertTrue(ClickHouseMetaDataOperations.isTextReadType("Nullable(IPv4)")); + assertTrue(ClickHouseMetaDataOperations.isTextReadType("Tuple(a Int32, b String)")); + assertFalse(ClickHouseMetaDataOperations.isTextReadType("Nullable(Int32)")); + } + + @Test + void parsesKeys() { + assertEquals(List.of("id"), ClickHouseMetaDataOperations.parseKeyColumns("(id)")); + assertEquals(List.of("emp_id", "tag_name"), ClickHouseMetaDataOperations.parseKeyColumns( + "emp_id, tag_name")); + assertEquals(List.of("a", "b"), ClickHouseMetaDataOperations.parseKeyColumns("tuple(a, `b`)")); + assertEquals(List.of(), ClickHouseMetaDataOperations.parseKeyColumns("tuple()")); + assertEquals(List.of(), ClickHouseMetaDataOperations.parseKeyColumns("")); + // Keys with expressions are not primary keys of other databases + assertEquals(List.of(), ClickHouseMetaDataOperations.parseKeyColumns("toDate(ts), id")); + assertEquals(List.of("a", "f(b, 'x,y')", "c"), ClickHouseMetaDataOperations.splitTopLevel( + "a, f(b, 'x,y'), c")); + } + + @Test + void renamesCopiedTables() { + Dialect dialect = new ClickHouseDialect(); + assertFalse(dialect.isTableRenamed()); + assertEquals("emp", dialect.getTargetTableName("emp")); + dialect.setTableNamePattern(Dialect.TABLE_PLACEHOLDER); + assertFalse(dialect.isTableRenamed()); + dialect.setTableNamePattern("{table}_copy"); + assertTrue(dialect.isTableRenamed()); + assertEquals("emp_copy", dialect.getTargetTableName("emp")); + // The case of the source name is kept + assertEquals("EMP_COPY", dialect.getTargetTableName("EMP")); + assertEquals("Emp_copy", dialect.getTargetTableName("Emp")); + assertNull(dialect.getTargetTableName(null)); + dialect.setTableNamePattern("backup"); + assertEquals("backup", dialect.getTargetTableName("emp")); + assertEquals("backup", dialect.forVersion(26, 9).getTargetTableName("emp")); + } +} diff --git a/src/test/java/com/github/sqljam/it/AbstractImportIT.java b/src/test/java/com/github/sqljam/it/AbstractImportIT.java index 0eec2d1..77542af 100644 --- a/src/test/java/com/github/sqljam/it/AbstractImportIT.java +++ b/src/test/java/com/github/sqljam/it/AbstractImportIT.java @@ -16,16 +16,34 @@ package com.github.sqljam.it; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; +import java.io.File; import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; +import org.apache.commons.io.FileUtils; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.Dialect; +import com.github.sqljam.impexp.IdentifierCase; +import com.github.sqljam.impexp.ImpExpException; +import com.github.sqljam.impexp.ImportExportHandler; import com.github.sqljam.impexp.ImportExporter; +import com.github.sqljam.impexp.MetaDataOperations; +import com.github.sqljam.impexp.TableQuery; /** * @Description: AbstractImportIT imports fixture tables of a source database into every database directly @@ -50,7 +68,8 @@ static boolean isTargetSelected(ItDatabase target) { @EnumSource(ItDatabase.class) void importInto(ItDatabase target) throws Exception { // Oracle test user has only one schema - assumeTrue(!(source == ItDatabase.ORACLE && target == ItDatabase.ORACLE)); + // Test users of Oracle and MariaDB have one schema, the same database type is verified by copyIntoSameSchema + assumeTrue(!(source == target && source.isSingleSchema())); assumeTrue(isTargetSelected(target)); assumeTrue(source.isAvailable() && target.isAvailable()); source.loadFixture(); @@ -98,4 +117,187 @@ void importStructureOnly() throws Exception { new ItVerifier(source, target, connection).verifyStructureOnly(); } } + + private static final String[] FIXTURE_TABLES = {"emp_tag", "emp", "dept", "sales", "types"}; + private static final String COPY_SUFFIX = "_cp"; + + /** + * Configures an import from the fixture tables into the same schema of the same database + */ + private ImportExporter createCopy(String tableNamePattern) { + ImportExporter importExporter = new ImportExporter(); + source.configureSource(importExporter.getExportConfiguration()); + importExporter.getExportConfiguration().setIncludedTableNamePattern("(?i)" + source.getPrefix() + + "(dept|emp|emp_tag|sales|types)"); + importExporter.getExportConfiguration().setIdReused(true); + ImportExportHandler.ImportConfiguration configuration = importExporter.getImportConfiguration(); + configuration.setDbType(source.getDbType()); + configuration.setUrl(source.getSourceUrl()); + configuration.setUsername(source.getUsername()); + configuration.setPassword(source.getPassword()); + configuration.setTargetCatalogName(source.getSourceCatalog()); + configuration.setTargetSchemaName(source.getSourceSchema()); + configuration.setTableNamePattern(tableNamePattern); + return importExporter; + } + + /** + * Copies of the fixture tables are dropped, referencing tables first + */ + private void dropCopies() throws Exception { + Dialect dialect = source.getDbType().createDialect(); + dialect.setTargetSchemaName(source.getSourceSchema()); + try (Connection connection = source.getSourceConnection(); + Statement statement = connection.createStatement()) { + for (String table : FIXTURE_TABLES) { + // Names of copies follow the case of the source tables, e.g. SJH_EMP_CP of H2 + String copyName = source.getPrefix() + table + COPY_SUFFIX; + if (dialect.getStoredIdentifierCase() == IdentifierCase.UPPER) { + copyName = copyName.toUpperCase(Locale.ENGLISH); + } + String sql = dialect.getDropTableStatement(source.getSourceCatalog(), source.getSourceSchema(), + copyName); + if (source == ItDatabase.ORACLE) { + sql = String.format("BEGIN EXECUTE IMMEDIATE 'DROP TABLE %s CASCADE CONSTRAINTS'; EXCEPTION " + + "WHEN OTHERS THEN NULL; END;", (source.getPrefix() + table + COPY_SUFFIX) + .toUpperCase(Locale.ENGLISH)); + } + statement.execute(sql); + } + } + } + + /** + * Tables are copied in the same schema of the same database by a table name pattern, like copying tables. + * Keys, indexes, foreign keys, comments, identity and partitions of the copies are verified, the source tables + * are not changed. + */ + @Test + void copyIntoSameSchema() throws Exception { + assumeTrue(source.isAvailable()); + source.loadFixture(); + dropCopies(); + try { + ImportExporter importExporter = createCopy("{table}" + COPY_SUFFIX); + CollectingListener listener = new CollectingListener(); + importExporter.setExportListener(listener); + try { + importExporter.exportDdlAndData(); + } catch (Exception e) { + throw new AssertionError(e.getMessage() + "\nErrors: " + listener.getErrors(), e); + } + assertEquals(List.of(), listener.getErrors(), "Errors of copying " + source); + assertEquals(Boolean.TRUE, listener.getSuccessful()); + try (Connection connection = source.getSourceConnection()) { + new ItVerifier(source, source, connection, source.getSourceSchema()).withTableSuffix(COPY_SUFFIX) + .verifyAll(); + // The source tables are not changed + new ItVerifier(source, source, connection, source.getSourceSchema()).verifyCounts(); + } + } finally { + dropCopies(); + } + } + + /** + * Importing into the tables which are read is rejected, the tables would be dropped before reading + */ + @Test + void rejectsSameTables() throws Exception { + assumeTrue(source.isAvailable()); + source.loadFixture(); + for (String pattern : new String[]{null, "", Dialect.TABLE_PLACEHOLDER}) { + ImpExpException e = assertThrows(ImpExpException.class, () -> createCopy(pattern).exportDdlAndData()); + assertTrue(e.getMessage().startsWith("Source and target tables are the same"), e.getMessage()); + } + try (Connection connection = source.getSourceConnection()) { + new ItVerifier(source, source, connection, source.getSourceSchema()).verifyCounts(); + } + } + + /** + * Rows filtered in the data viewer are imported into a database without the table: the table is created with + * the selected columns, keys and indexes whose columns are selected, and the rows of the condition + */ + @Test + void importQueryIntoNewTable() throws Exception { + assumeTrue(source.isAvailable()); + source.loadFixture(); + File file = new File("target/it-data/query-" + source.name().toLowerCase(Locale.ENGLISH)); + FileUtils.deleteQuietly(new File(file.getPath() + ".mv.db")); + String url = DbType.H2.getUrl(null, 0, file.getAbsolutePath()); + + ImportExporter importExporter = new ImportExporter(); + source.configureSource(importExporter.getExportConfiguration()); + String emp = findSourceTable("emp"); + importExporter.getExportConfiguration().setIncludedTableNames(new String[]{emp}); + importExporter.getExportConfiguration().setIdReused(true); + List columns = selectColumns(emp, "id", "name", "email", "salary"); + importExporter.getExportConfiguration().getTableQueries().put(emp, new TableQuery(columns, + source.getDbType().createDialect().quoteIdentifier(columns.get(3)) + " > 1000", null, + source.getDbType().createDialect().quoteIdentifier(columns.get(1)) + " DESC")); + ImportExportHandler.ImportConfiguration target = importExporter.getImportConfiguration(); + target.setDbType(DbType.H2); + target.setUrl(url); + target.setUsername("sa"); + target.setPassword(""); + target.setTargetSchemaName("PUBLIC"); + CollectingListener listener = new CollectingListener(); + importExporter.setExportListener(listener); + importExporter.exportDdlAndData(); + assertEquals(List.of(), listener.getErrors()); + + try (Connection connection = DriverManager.getConnection(url, "sa", ""); + Statement statement = connection.createStatement()) { + String table = emp.toUpperCase(Locale.ENGLISH); + List created = new ArrayList<>(); + try (ResultSet rs = connection.getMetaData().getColumns(null, "PUBLIC", table, null)) { + while (rs.next()) { + created.add(rs.getString("COLUMN_NAME")); + } + } + assertEquals(List.of("ID", "NAME", "EMAIL", "SALARY"), created, "Columns of the created table"); + try (ResultSet rs = statement.executeQuery("SELECT COUNT(*), MIN(ID), MAX(ID) FROM " + table)) { + rs.next(); + // salary = id * 10.25 > 1000 (id >= 98) and no salary for multiples of 7 (98, 105 ...) + assertEquals(131, rs.getInt(1)); + assertEquals(99, rs.getInt(2)); + assertEquals(250, rs.getInt(3)); + } + try (ResultSet rs = connection.getMetaData().getPrimaryKeys(null, "PUBLIC", table)) { + assertTrue(rs.next(), "Primary key of the selected id"); + } + if (source != ItDatabase.CLICKHOUSE) { + List indexColumns = new ArrayList<>(); + try (ResultSet rs = connection.getMetaData().getIndexInfo(null, "PUBLIC", table, true, false)) { + while (rs.next()) { + indexColumns.add(rs.getString("COLUMN_NAME")); + } + } + assertTrue(indexColumns.contains("EMAIL"), "Unique index of the selected email: " + indexColumns); + } + try (ResultSet rs = connection.getMetaData().getImportedKeys(null, "PUBLIC", table)) { + assertFalse(rs.next(), "No foreign key, dept_id is not selected"); + } + } + } + + private String findSourceTable(String name) throws Exception { + try (Connection connection = source.getSourceConnection()) { + MetaDataOperations operations = source.getDbType().createMetaDataOperations(); + String catalog = source.getDbType().isCatalogSupported() ? source.getSourceCatalog() : null; + return operations.getTableInfos(connection.getMetaData(), catalog, source.getSourceSchema()).stream() + .map(info -> (String) info.get("TABLE_NAME")) + .filter(table -> table.equalsIgnoreCase(source.getPrefix() + name)).findFirst().orElseThrow(); + } + } + + /** + * Column names in the case of the source table + */ + private static List selectColumns(String table, String... names) { + boolean upper = table.equals(table.toUpperCase(Locale.ENGLISH)); + return Arrays.stream(names).map(name -> upper ? name.toUpperCase(Locale.ENGLISH) : name) + .collect(Collectors.toList()); + } } diff --git a/src/test/java/com/github/sqljam/it/AbstractParquetIT.java b/src/test/java/com/github/sqljam/it/AbstractParquetIT.java new file mode 100644 index 0000000..35e151b --- /dev/null +++ b/src/test/java/com/github/sqljam/it/AbstractParquetIT.java @@ -0,0 +1,169 @@ +/* + * 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.it; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.File; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.List; + +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import com.github.sqljam.impexp.DataFormat; +import com.github.sqljam.impexp.ExportManifest; +import com.github.sqljam.impexp.ExportMode; +import com.github.sqljam.impexp.ParquetExporter; +import com.github.sqljam.impexp.ParquetImporter; +import com.github.sqljam.impexp.ScriptExportHandler; + +/** + * @Description: AbstractParquetIT exports fixture tables of a source database as Parquet packages and imports them + * into every database, and loads Parquet files into new and existing tables + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +public abstract class AbstractParquetIT { + + protected final ItDatabase source; + + protected AbstractParquetIT(ItDatabase source) { + this.source = source; + } + + protected File getExportDirectory(String name) { + File dir = new File(ItDatabase.DATA_DIR, "parquet/" + source + "-" + name); + FileUtils.deleteQuietly(dir); + return dir; + } + + private ParquetExporter newExporter(File dir, ItDatabase target) { + ParquetExporter parquetExporter = new ParquetExporter(dir); + source.configureSource(parquetExporter.getConfiguration()); + parquetExporter.getConfiguration().setIdReused(true); + parquetExporter.setTargetDbType(target.getDbType()); + parquetExporter.setTargetCatalogName(target.getTargetCatalog()); + parquetExporter.setTargetSchemaName(target.getTargetSchema()); + // Compressions are rotated over the matrix + parquetExporter.setCompression(ParquetExporter.COMPRESSIONS[(source.ordinal() + target.ordinal()) + % ParquetExporter.COMPRESSIONS.length]); + return parquetExporter; + } + + @ParameterizedTest(name = "parquet for {0}") + @EnumSource(ItDatabase.class) + void exportAndImport(ItDatabase target) throws Exception { + // Oracle test user has only one schema + // Test users of Oracle and MariaDB have one schema, the same database type is verified by copyIntoSameSchema + assumeTrue(!(source == target && source.isSingleSchema())); + assumeTrue(AbstractImportIT.isTargetSelected(target)); + assumeTrue(source.isAvailable() && target.isAvailable()); + source.loadFixture(); + + File dir = getExportDirectory(target.name()); + ParquetExporter parquetExporter = newExporter(dir, target); + CollectingListener listener = new CollectingListener(); + parquetExporter.setExportListener(listener); + try { + parquetExporter.exportDdlAndData(); + } catch (Exception e) { + throw new AssertionError(e.getMessage() + "\nErrors: " + listener.getErrors(), e); + } + assertEquals(List.of(), listener.getErrors()); + assertEquals(Boolean.TRUE, listener.getSuccessful()); + + ExportManifest manifest = ExportManifest.read(dir); + assertEquals(DataFormat.PARQUET, manifest.getDataFormat()); + assertEquals(ExportManifest.Status.COMPLETED, manifest.getStatus()); + assertEquals(target.getDbType(), manifest.getTarget().getDbType()); + assertEquals(5, manifest.getTables().size(), "Tables in manifest: " + manifest.getTables().size()); + ExportManifest.TableEntry emp = manifest.getTables().stream() + .filter(table -> table.getName().equalsIgnoreCase(source.getPrefix() + "emp")).findFirst() + .orElseThrow(); + assertEquals(ItDatabase.EMP_COUNT, emp.getRows()); + // ClickHouse has no identity columns + assertEquals(source == ItDatabase.CLICKHOUSE ? 0 : 1, emp.getIdentityColumns().size(), "Identity column of emp"); + assertTrue(manifest.getFiles().stream().anyMatch(f -> f.getType() == ExportManifest.FileType.PARQUET)); + assertEquals(ExportManifest.FileType.SCHEMA, manifest.getFiles().get(0).getType()); + assertTrue(new File(dir, emp.getDataFiles().iterator().next()).isFile(), "Parquet file of emp"); + + try (Connection connection = target.getTargetConnection()) { + ParquetImporter importer = new ParquetImporter(connection, target.getDbType()); + listener = new CollectingListener(); + importer.setExportListener(listener); + try { + importer.importDirectory(dir); + } catch (Exception e) { + throw new AssertionError(e.getMessage() + "\nErrors: " + listener.getErrors(), e); + } + assertEquals(List.of(), listener.getErrors()); + assertTrue(listener.isCompleted(), "Import progress 100%"); + new ItVerifier(source, target, connection).withInstantEquality().verifyAll(); + } + } + + /** + * Parquet files are loaded into a new table (created from the Parquet schema) and appended to it + */ + @Test + void loadParquetFiles() throws Exception { + ItDatabase target = ItDatabase.H2; + assumeTrue(source.isAvailable() && target.isAvailable()); + source.loadFixture(); + File dir = getExportDirectory("files"); + ParquetExporter parquetExporter = newExporter(dir, target); + parquetExporter.export(ExportMode.DATA); + ExportManifest manifest = ExportManifest.read(dir); + assertEquals(ExportMode.DATA, manifest.getExportMode()); + ExportManifest.TableEntry emp = manifest.getTables().stream() + .filter(table -> table.getName().equalsIgnoreCase(source.getPrefix() + "emp")).findFirst() + .orElseThrow(); + File file = new File(dir, emp.getDataFiles().iterator().next()); + assertTrue(file.getParentFile().getName().equals(ScriptExportHandler.DATA_DIR_NAME)); + + String tableName = source.getPrefix() + "pq_emp"; + try (Connection connection = target.getTargetConnection()) { + ParquetImporter importer = new ParquetImporter(connection, target.getDbType()); + importer.setTargetSchemaName(target.getTargetSchema()); + CollectingListener listener = new CollectingListener(); + importer.setExportListener(listener); + assertEquals(ItDatabase.EMP_COUNT, importer.importFiles(List.of(file), tableName, + ParquetImporter.Mode.CREATE)); + assertEquals(List.of(), listener.getErrors()); + assertTrue(listener.isCompleted(), "Import progress 100%"); + // Rows are appended to the existing table + listener = new CollectingListener(); + importer.setExportListener(listener); + assertEquals(ItDatabase.EMP_COUNT, importer.importFiles(List.of(file), tableName, + ParquetImporter.Mode.APPEND)); + assertEquals(List.of(), listener.getErrors()); + assertTrue(listener.isCompleted(), "Append progress 100%"); + try (Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SELECT COUNT(*) FROM \"" + target.getTargetSchema() + + "\".\"" + tableName.toUpperCase() + "\"")) { + rs.next(); + assertEquals(ItDatabase.EMP_COUNT * 2, rs.getLong(1)); + } + } + } +} diff --git a/src/test/java/com/github/sqljam/it/AbstractScriptIT.java b/src/test/java/com/github/sqljam/it/AbstractScriptIT.java index 817a796..2eb1c69 100644 --- a/src/test/java/com/github/sqljam/it/AbstractScriptIT.java +++ b/src/test/java/com/github/sqljam/it/AbstractScriptIT.java @@ -59,7 +59,8 @@ protected File getExportDirectory(String name) { @ParameterizedTest(name = "script for {0}") @EnumSource(ItDatabase.class) void exportAndImport(ItDatabase target) throws Exception { - assumeTrue(!(source == ItDatabase.ORACLE && target == ItDatabase.ORACLE)); + // Test users of Oracle and MariaDB have one schema, the same database type is verified by copyIntoSameSchema + assumeTrue(!(source == target && source.isSingleSchema())); assumeTrue(AbstractImportIT.isTargetSelected(target)); assumeTrue(source.isAvailable() && target.isAvailable()); source.loadFixture(); @@ -84,7 +85,10 @@ void exportAndImport(ItDatabase target) throws Exception { assertEquals(List.of(), listener.getErrors()); assertTrue(listener.isCompleted(), "Export progress 100%"); assertTrue(new File(dir, ScriptExportHandler.SCHEMA_FILE_NAME).exists(), "schema.sql"); - assertTrue(new File(dir, LobManifestWriter.MANIFEST_FILE_NAME).exists(), "lob-manifest.json"); + // ClickHouse has no LOB types, LOB values of ClickHouse targets are in INSERT statements + boolean lobSeparated = source != ItDatabase.CLICKHOUSE + && target.getDbType().createDialect().isLobSeparationSupported(); + assertEquals(lobSeparated, new File(dir, LobManifestWriter.MANIFEST_FILE_NAME).exists(), "lob-manifest.json"); List dataFiles = ScriptImporter.getDataFiles(dir); assertTrue(dataFiles.size() > 1, "Data files are split"); assertTrue(dataFiles.stream().anyMatch(file -> file.getName().endsWith("_2.sql")), "Part file naming"); @@ -107,7 +111,7 @@ void exportAndImport(ItDatabase target) throws Exception { throw new AssertionError(e.getMessage() + "\nErrors: " + listener.getErrors(), e); } assertEquals(List.of(), listener.getErrors()); - assertTrue(importer.getLobCount() > 0, "LOB values restored"); + assertEquals(lobSeparated, importer.getLobCount() > 0, "LOB values restored"); assertTrue(listener.isCompleted(), "Import progress 100%"); new ItVerifier(source, target, connection).verifyAll(); } @@ -128,9 +132,10 @@ void exportWithDefaultMaxFileSize() throws Exception { scriptExporter.export(ExportMode.DDL_DATA); assertEquals(List.of(), listener.getErrors()); assertEquals(List.of(new File(dir, ScriptExportHandler.DATA_FILE_NAME)), ScriptImporter.getDataFiles(dir)); - // Foreign keys of SQLite are defined in create table statements - assertEquals(source != ItDatabase.SQLITE, new File(dir, ScriptExportHandler.CONSTRAINT_FILE_NAME).exists(), - "constraints.sql"); + // Foreign keys of SQLite and DuckDB are defined in create table statements + // ClickHouse has no foreign keys + assertEquals(!source.getDbType().createDialect().isForeignKeyInline() && source != ItDatabase.CLICKHOUSE, + new File(dir, ScriptExportHandler.CONSTRAINT_FILE_NAME).exists(), "constraints.sql"); } /** diff --git a/src/test/java/com/github/sqljam/it/AbstractSessionIT.java b/src/test/java/com/github/sqljam/it/AbstractSessionIT.java index 9228256..c29260b 100644 --- a/src/test/java/com/github/sqljam/it/AbstractSessionIT.java +++ b/src/test/java/com/github/sqljam/it/AbstractSessionIT.java @@ -23,6 +23,7 @@ import java.io.File; import java.sql.Connection; +import java.util.HexFormat; import java.util.List; import java.util.Locale; import java.util.stream.Collectors; @@ -38,10 +39,10 @@ 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.DataFileStrategy; import com.github.sqljam.impexp.DbType; import com.github.sqljam.impexp.ExportMode; -import com.github.sqljam.impexp.ScriptImporter; /** * @Description: AbstractSessionIT browses fixture tables by DatabaseSession and transfers them by TransferService @@ -99,12 +100,18 @@ void browse() throws Exception { assertTrue(columns.stream().anyMatch(column -> "employee name".equalsIgnoreCase( column.getRemarks())) || source == ItDatabase.SQLITE); List indexes = session.getIndexes(catalog, schema, emp); - assertTrue(indexes.stream().anyMatch(index -> index.isUnique() && index.getColumns().stream() - .anyMatch("email"::equalsIgnoreCase)), "Unique index of email: " + indexes.size()); List foreignKeys = session.getForeignKeys(catalog, schema, emp); - assertEquals(1, foreignKeys.size()); - assertEquals((source.getPrefix() + "dept").toLowerCase(Locale.ENGLISH), - foreignKeys.get(0).getReferencedTable().toLowerCase(Locale.ENGLISH)); + if (source == ItDatabase.CLICKHOUSE) { + // ClickHouse has no unique indexes and foreign keys + assertTrue(indexes.isEmpty()); + assertTrue(foreignKeys.isEmpty()); + } else { + assertTrue(indexes.stream().anyMatch(index -> index.isUnique() && index.getColumns().stream() + .anyMatch("email"::equalsIgnoreCase)), "Unique index of email: " + indexes.size()); + assertEquals(1, foreignKeys.size()); + assertEquals((source.getPrefix() + "dept").toLowerCase(Locale.ENGLISH), + foreignKeys.get(0).getReferencedTable().toLowerCase(Locale.ENGLISH)); + } assertEquals(ItDatabase.EMP_COUNT, session.countRows(catalog, schema, emp)); DataPage page = session.getData(catalog, schema, emp, 3, 100); @@ -113,7 +120,14 @@ void browse() throws Exception { assertEquals("201", page.getRows().get(0).get(0)); int photoIndex = page.getColumns().stream().map(column -> column.toLowerCase(Locale.ENGLISH)) .collect(Collectors.toList()).indexOf("photo"); - assertTrue(page.getRows().get(9).get(photoIndex).startsWith("(466 bytes) 0x")); + String photo = page.getRows().get(9).get(photoIndex); + if (source == ItDatabase.CLICKHOUSE) { + // Photos of the ClickHouse fixture are hex text + assertTrue(photo.startsWith(HexFormat.of().formatHex(ItDatabase.photo(210)).substring(0, + 20)), photo); + } else { + assertTrue(photo.startsWith("(466 bytes) 0x"), photo); + } String ddl = session.getDdl(catalog, schema, emp, null); assertTrue(ddl.toUpperCase(Locale.ENGLISH).contains("CREATE TABLE"), ddl); @@ -142,7 +156,7 @@ void transferByScripts() throws Exception { transferService.transfer(request, listener); assertEquals(List.of(), listener.getErrors()); - ScriptImporter importer = transferService.importScripts(target.getTargetProfile(), null, + AbstractFileImporter importer = transferService.importScripts(target.getTargetProfile(), null, target.getTargetSchema(), dir, true, listener); assertEquals(List.of(), listener.getErrors()); assertTrue(importer.getExecutedCount() > 0); diff --git a/src/test/java/com/github/sqljam/it/ItDatabase.java b/src/test/java/com/github/sqljam/it/ItDatabase.java index 863c1e1..59e7d3c 100644 --- a/src/test/java/com/github/sqljam/it/ItDatabase.java +++ b/src/test/java/com/github/sqljam/it/ItDatabase.java @@ -21,11 +21,17 @@ import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.sql.Connection; +import java.sql.Date; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.SQLException; import java.sql.Timestamp; +import java.sql.Types; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.Temporal; import java.util.EnumMap; +import java.util.HexFormat; import java.util.Map; import org.apache.commons.lang3.StringUtils; @@ -34,6 +40,7 @@ import com.github.sqljam.impexp.Exporter; import com.github.sqljam.impexp.ImportExportHandler; import com.github.sqljam.impexp.SqlScriptRunner; +import com.github.sqljam.jdbc.JdbcUtils; /** * @Description: ItDatabase holds the databases used by integration tests. Each database has its own fixture tables @@ -51,6 +58,13 @@ public String getUrl(String catalog) { } }, + MARIADB(DbType.MARIADB, "sjb_", "demo", null, "demo", null) { + @Override + public String getUrl(String catalog) { + return DbType.MARIADB.getUrl("localhost", 3307, "demo"); + } + }, + POSTGRESQL(DbType.POSTGRESQL, "sjp_", "demo", "sjit_src", "demo", "sjit_dst") { @Override public String getUrl(String catalog) { @@ -85,8 +99,26 @@ public String getUrl(String catalog) { return DbType.SQLITE.getUrl(null, 0, new File(DATA_DIR, catalog == null ? "src.sqlite" : "dst.sqlite") .getAbsolutePath()); } + }, + + DUCKDB(DbType.DUCKDB, "sjd_", null, "main", null, "main") { + @Override + public String getUrl(String catalog) { + return DbType.DUCKDB.getUrl(null, 0, getDuckDBFile(catalog == null).getAbsolutePath()); + } + }, + + CLICKHOUSE(DbType.CLICKHOUSE, "sjc_", null, "app", null, "sjit_dst") { + @Override + public String getUrl(String catalog) { + return DbType.CLICKHOUSE.getUrl("localhost", 18123, "app"); + } }; + static File getDuckDBFile(boolean source) { + return new File(DATA_DIR, source ? "duck-src.duckdb" : "duck-dst.duckdb"); + } + static final File DATA_DIR = new File("target/it-data"); public static final String NOTE = "O'Brien \\ \"quoted\" 中文 -- not a comment; /* nor this */"; @@ -139,6 +171,13 @@ public DbType getDbType() { return dbType; } + /** + * The test user has only one schema (database), imports of the same database type are copies in that schema + */ + public boolean isSingleSchema() { + return this == ORACLE || this == MARIADB; + } + public String getPrefix() { return prefix; } @@ -165,6 +204,8 @@ public String getUsername() { case POSTGRESQL: case ORACLE: case SQLSERVER: + case CLICKHOUSE: + case MARIADB: return "fengy"; case H2: return "sa"; @@ -180,6 +221,8 @@ public String getPassword() { case POSTGRESQL: case ORACLE: case SQLSERVER: + case CLICKHOUSE: + case MARIADB: return "123456"; case H2: return ""; @@ -232,6 +275,19 @@ private ConnectionProfile createProfile(String catalog, String fileKey) { case H2: profile.setDatabase(new File(DATA_DIR, fileKey == null ? "h2-src" : "h2-dst").getAbsolutePath()); break; + case DUCKDB: + profile.setDatabase(getDuckDBFile(fileKey == null).getAbsolutePath()); + break; + case CLICKHOUSE: + profile.setHostname("localhost"); + profile.setPort(18123); + profile.setDatabase("app"); + break; + case MARIADB: + profile.setHostname("localhost"); + profile.setPort(3307); + profile.setDatabase("demo"); + break; default: profile.setDatabase(new File(DATA_DIR, fileKey == null ? "src.sqlite" : "dst.sqlite") .getAbsolutePath()); @@ -246,7 +302,7 @@ public Connection getSourceConnection() throws SQLException { public Connection getTargetConnection() throws SQLException { Connection connection = connect(getTargetUrl()); - if (dbType == DbType.POSTGRESQL || dbType == DbType.ORACLE) { + if (dbType == DbType.POSTGRESQL || dbType == DbType.ORACLE || dbType == DbType.CLICKHOUSE) { connection.setSchema(targetSchema); } return connection; @@ -320,46 +376,79 @@ private String table(String name) { return dbType == DbType.POSTGRESQL ? sourceSchema + "." + tableName : tableName; } + /** + * ClickHouse has no identity columns, bulk inserts of MariaDB leave gaps of identity values, ids are given + */ + private boolean isIdentitySupported() { + return dbType != DbType.CLICKHOUSE && dbType != DbType.MARIADB; + } + + /** + * ClickHouse converts bound temporal values by the time zone of the client, they are bound as text + */ + private void setTemporal(PreparedStatement ps, int index, Temporal value) throws SQLException { + if (dbType == DbType.CLICKHOUSE) { + ps.setString(index, value.toString().replace('T', ' ')); + } else if (value instanceof LocalDate) { + ps.setDate(index, Date.valueOf((LocalDate) value)); + } else { + ps.setTimestamp(index, Timestamp.valueOf((LocalDateTime) value)); + } + } + private void insertRows(Connection connection) throws SQLException { - connection.setAutoCommit(false); + boolean transactional = JdbcUtils.beginTransaction(connection); String[] deptNames = {"Sales", "R&D", "Ops"}; - try (PreparedStatement ps = connection.prepareStatement( - String.format("INSERT INTO %s (name, budget, created_at) VALUES (?, ?, ?)", table("dept")))) { + String idColumn = isIdentitySupported() ? "" : "id, "; + String idParameter = isIdentitySupported() ? "" : "?, "; + int offset = isIdentitySupported() ? 0 : 1; + try (PreparedStatement ps = connection.prepareStatement(String.format( + "INSERT INTO %s (%sname, budget, created_at) VALUES (%s?, ?, ?)", table("dept"), idColumn, + idParameter))) { for (int i = 0; i < deptNames.length; i++) { - ps.setString(1, deptNames[i]); - ps.setBigDecimal(2, new BigDecimal("1000.50").multiply(BigDecimal.valueOf(i + 1))); - ps.setTimestamp(3, Timestamp.valueOf("2024-01-01 08:00:00")); + if (offset > 0) { + ps.setInt(1, i + 1); + } + ps.setString(offset + 1, deptNames[i]); + ps.setBigDecimal(offset + 2, new BigDecimal("1000.50").multiply(BigDecimal.valueOf(i + 1))); + setTemporal(ps, offset + 3, LocalDateTime.of(2024, 1, 1, 8, 0, 0)); ps.addBatch(); } ps.executeBatch(); } try (PreparedStatement ps = connection.prepareStatement(String.format( - "INSERT INTO %s (dept_id, name, email, salary, active, hired, updated_at, photo, profile, note)" - + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", table("emp")))) { + "INSERT INTO %s (%sdept_id, name, email, salary, active, hired, updated_at, photo, profile, note)" + + " VALUES (%s?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", table("emp"), idColumn, idParameter))) { for (int i = 1; i <= EMP_COUNT; i++) { - ps.setInt(1, i % 3 + 1); - ps.setString(2, "Emp " + i); - ps.setString(3, "emp" + i + "@sqljam.io"); + if (offset > 0) { + ps.setLong(1, i); + } + ps.setInt(offset + 1, i % 3 + 1); + ps.setString(offset + 2, "Emp " + i); + ps.setString(offset + 3, "emp" + i + "@sqljam.io"); if (i % 7 == 0) { - ps.setNull(4, java.sql.Types.DECIMAL); + ps.setNull(offset + 4, Types.DECIMAL); } else { - ps.setBigDecimal(4, new BigDecimal(i).multiply(new BigDecimal("10.25"))); + ps.setBigDecimal(offset + 4, new BigDecimal(i).multiply(new BigDecimal("10.25"))); } if (dbType == DbType.ORACLE) { - ps.setInt(5, i % 2); + ps.setInt(offset + 5, i % 2); } else { - ps.setBoolean(5, i % 2 == 0); + ps.setBoolean(offset + 5, i % 2 == 0); } - ps.setDate(6, java.sql.Date.valueOf(java.time.LocalDate.of(2020, 1, 1).plusDays(i))); - ps.setTimestamp(7, Timestamp.valueOf(java.time.LocalDateTime.of(2024, 1, 1, 10, 0, 0, 123_000_000) - .plusMinutes(i))); - if (i % 10 == 0) { - ps.setBytes(8, photo(i)); + setTemporal(ps, offset + 6, LocalDate.of(2020, 1, 1).plusDays(i)); + setTemporal(ps, offset + 7, LocalDateTime.of(2024, 1, 1, 10, 0, 0, 123_000_000) + .plusMinutes(i)); + if (i % 10 == 0 && dbType == DbType.CLICKHOUSE) { + // ClickHouse has no binary type, bytes are kept as hex text + ps.setString(offset + 8, HexFormat.of().formatHex(photo(i))); + } else if (i % 10 == 0) { + ps.setBytes(offset + 8, photo(i)); } else { - ps.setNull(8, java.sql.Types.VARBINARY); + ps.setNull(offset + 8, Types.VARBINARY); } - ps.setString(9, i == 1 ? PROFILE : "profile " + i); - ps.setString(10, i == 2 ? NOTE : "note " + i); + ps.setString(offset + 9, i == 1 ? PROFILE : "profile " + i); + ps.setString(offset + 10, i == 2 ? NOTE : "note " + i); ps.addBatch(); } ps.executeBatch(); @@ -385,8 +474,10 @@ private void insertRows(Connection connection) throws SQLException { } ps.executeBatch(); } - connection.commit(); - connection.setAutoCommit(true); + if (transactional) { + connection.commit(); + connection.setAutoCommit(true); + } } public static byte[] photo(int i) { diff --git a/src/test/java/com/github/sqljam/it/ItVerifier.java b/src/test/java/com/github/sqljam/it/ItVerifier.java index eb3bbdd..faf33e8 100644 --- a/src/test/java/com/github/sqljam/it/ItVerifier.java +++ b/src/test/java/com/github/sqljam/it/ItVerifier.java @@ -29,10 +29,15 @@ import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.sql.Statement; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.EnumSet; import java.util.HashMap; +import java.util.HexFormat; import java.util.List; import java.util.Locale; import java.util.Map; @@ -52,9 +57,11 @@ */ public class ItVerifier { - private static final Set SEQUENCE_DATABASES = EnumSet.of(ItDatabase.POSTGRESQL, ItDatabase.ORACLE, + private static final Set SEQUENCE_DATABASES = EnumSet.of(ItDatabase.DUCKDB, + ItDatabase.MARIADB, ItDatabase.POSTGRESQL, ItDatabase.ORACLE, ItDatabase.SQLSERVER, ItDatabase.H2); - private static final Set PARTITION_DATABASES = EnumSet.of(ItDatabase.MYSQL, ItDatabase.POSTGRESQL, + private static final Set PARTITION_DATABASES = EnumSet.of(ItDatabase.MYSQL, + ItDatabase.MARIADB, ItDatabase.POSTGRESQL, ItDatabase.ORACLE, ItDatabase.SQLSERVER); private final ItDatabase source; @@ -90,8 +97,18 @@ private DatabaseMetaData metaData() throws SQLException { return connection.getMetaData(); } + /** + * Suffix of copied tables, e.g. _cp of tables copied in the same schema + */ + private String tableSuffix = ""; + + public ItVerifier withTableSuffix(String tableSuffix) { + this.tableSuffix = tableSuffix; + return this; + } + public String table(String name) { - String tableName = tableNames.get((source.getPrefix() + name).toLowerCase(Locale.ENGLISH)); + String tableName = tableNames.get((source.getPrefix() + name + tableSuffix).toLowerCase(Locale.ENGLISH)); assertNotNull(tableName, "Table not found in " + target + ": " + source.getPrefix() + name + ", tables: " + tableNames.keySet()); return tableName; @@ -137,6 +154,17 @@ private Object queryEmp(Map columns, String column, long id) thr } } + /** + * Parquet semantics: timestamps with time zone are equal if they are the same instant (Parquet keeps them in + * UTC), fractional seconds are compared in microseconds (the precision of Parquet timestamps) + */ + private boolean instantEquality; + + public ItVerifier withInstantEquality() { + this.instantEquality = true; + return this; + } + public void verifyAll() throws Exception { verifyCounts(); verifyValues(); @@ -169,12 +197,32 @@ public void verifyCounts() throws SQLException { assertEquals(3, count("types"), "types rows"); } + /** + * ClickHouse has no foreign keys, unique indexes and identity columns + */ + private boolean isConstraintSupported() { + return source != ItDatabase.CLICKHOUSE && target != ItDatabase.CLICKHOUSE; + } + public void verifyValues() throws SQLException { Map columns = columns("emp"); assertEquals(ItDatabase.NOTE, queryEmp(columns, "note", 2)); assertEquals(ItDatabase.PROFILE, queryEmp(columns, "profile", 1)); - Object photo = queryEmp(columns, "photo", 10); - assertArrayEquals(ItDatabase.photo(10), (byte[]) photo); + if (source == ItDatabase.CLICKHOUSE && target == ItDatabase.CLICKHOUSE) { + // Photos of the ClickHouse fixture are hex text + assertEquals(HexFormat.of().formatHex(ItDatabase.photo(10)), queryEmp(columns, "photo", 10)); + } else if (target == ItDatabase.CLICKHOUSE) { + // String of ClickHouse keeps the bytes, the driver reads it as text + String sql = String.format("SELECT hex(%s) FROM %s WHERE %s = 10", column(columns, "photo"), ref("emp"), + column(columns, "id")); + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) { + assertTrue(rs.next()); + assertArrayEquals(ItDatabase.photo(10), HexFormat.of().parseHex(rs.getString(1))); + } + } else if (source != ItDatabase.CLICKHOUSE) { + Object photo = queryEmp(columns, "photo", 10); + assertArrayEquals(ItDatabase.photo(10), (byte[]) photo); + } Object salary = queryEmp(columns, "salary", 3); assertEquals(0, new BigDecimal("30.75").compareTo(new BigDecimal(salary.toString())), "salary"); assertEquals(null, queryEmp(columns, "salary", 7)); @@ -184,9 +232,12 @@ public void verifyConstraints() throws SQLException { List> pkInfos = operations.getPrimaryKeyInfos(metaData(), catalog, schema, table("emp_tag")); assertEquals(2, pkInfos.size(), "Composite primary key of emp_tag"); + if (!isConstraintSupported()) { + return; + } List> fkInfos = operations.getImportedKeyInfos(metaData(), catalog, schema, table("emp")); assertEquals(1, fkInfos.size(), "Foreign key of emp: " + fkInfos); - assertEquals((source.getPrefix() + "dept").toLowerCase(Locale.ENGLISH), + assertEquals((source.getPrefix() + "dept" + tableSuffix).toLowerCase(Locale.ENGLISH), ((String) fkInfos.get(0).get("PKTABLE_NAME")).toLowerCase(Locale.ENGLISH)); List> indexInfos = operations.getIndexInfos(metaData(), catalog, schema, table("emp")); boolean uniqueEmail = indexInfos.stream().anyMatch(info -> "email".equalsIgnoreCase( @@ -219,6 +270,9 @@ public void verifyComments() throws SQLException { * Identity continues from the max imported value */ public void verifyIdentity() throws SQLException { + if (!isConstraintSupported()) { + return; + } Map columns = columns("dept"); String insert = String.format("INSERT INTO %s (%s) VALUES ('New Dept')", ref("dept"), column(columns, "name")); @@ -278,10 +332,10 @@ public void verifyTypes() throws Exception { String typesTable = sourceOperations.getTableInfos(sourceConnection.getMetaData(), sourceCatalog, source.getSourceSchema()).stream().map(info -> (String) info.get("TABLE_NAME")) .filter(name -> name.equalsIgnoreCase(source.getPrefix() + "types")).findFirst().orElseThrow(); - expected = readRows(sourceConnection, dialect.getSourceTableName(sourceCatalog, source.getSourceSchema(), - typesTable)); + expected = readRows(sourceConnection, sourceOperations, sourceCatalog, source.getSourceSchema(), + typesTable); } - List> actual = readRows(connection, ref("types")); + List> actual = readRows(connection, operations, catalog, schema, table("types")); assertEquals(expected.size(), actual.size()); for (int i = 0; i < expected.size(); i++) { for (Map.Entry entry : expected.get(i).entrySet()) { @@ -289,16 +343,48 @@ public void verifyTypes() throws Exception { if (column.endsWith("rowversion")) { continue; } - assertValueEquals(entry.getValue(), actual.get(i).get(column), + Object expectedValue = entry.getValue(); + Object actualValue = actual.get(i).get(column); + ChronoUnit micros = ChronoUnit.MICROS; + if (instantEquality && expectedValue instanceof LocalDateTime + && actualValue instanceof LocalDateTime) { + expectedValue = ((LocalDateTime) expectedValue).truncatedTo(micros); + actualValue = ((LocalDateTime) actualValue).truncatedTo(micros); + } else if (instantEquality && expectedValue instanceof LocalTime + && actualValue instanceof LocalTime) { + expectedValue = ((LocalTime) expectedValue).truncatedTo(micros); + actualValue = ((LocalTime) actualValue).truncatedTo(micros); + } + if (instantEquality && expectedValue instanceof OffsetDateTime + && actualValue instanceof OffsetDateTime) { + assertTrue(((OffsetDateTime) expectedValue).truncatedTo(micros).isEqual( + ((OffsetDateTime) actualValue).truncatedTo(micros)), String.format( + "%s -> %s, row %d, column %s: %s is not the instant of %s", source, target, i + 1, column, + actualValue, expectedValue)); + continue; + } + assertValueEquals(expectedValue, actualValue, String.format("%s -> %s, row %d, column %s", source, target, i + 1, column)); } } } - private List> readRows(Connection connection, String table) throws SQLException { + /** + * Rows of a table read by the select expressions of the dialect, e.g. XMLTYPE of Oracle as CLOB + */ + private List> readRows(Connection connection, MetaDataOperations metaDataOperations, + String tableCatalog, String tableSchema, String tableName) + throws SQLException { + List> columnInfos = metaDataOperations.getColumnInfos(connection.getMetaData(), + tableCatalog, tableSchema, tableName); + String[] columnNames = columnInfos.stream().map(info -> (String) info.get("COLUMN_NAME")) + .toArray(String[]::new); + String[] typeNames = columnInfos.stream().map(info -> (String) info.get("TYPE_NAME")).toArray(String[]::new); + String sql = dialect.getSelectTableStatement(tableCatalog, tableSchema, tableName, columnNames, typeNames) + + " ORDER BY 1"; List> rows = new ArrayList<>(); try (Statement statement = connection.createStatement(); - ResultSet rs = statement.executeQuery("SELECT * FROM " + table + " ORDER BY 1")) { + ResultSet rs = statement.executeQuery(sql)) { ResultSetMetaData rsmd = rs.getMetaData(); while (rs.next()) { Map row = new HashMap<>(); diff --git a/src/test/java/com/github/sqljam/jdbc/JdbcUtilsTest.java b/src/test/java/com/github/sqljam/jdbc/JdbcUtilsTest.java index e462d06..032ec16 100644 --- a/src/test/java/com/github/sqljam/jdbc/JdbcUtilsTest.java +++ b/src/test/java/com/github/sqljam/jdbc/JdbcUtilsTest.java @@ -25,20 +25,25 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.sql.Connection; +import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Time; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.Year; import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.UUID; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import com.github.sqljam.impexp.TransactionIsolationLevel; -import com.github.sqljam.jdbc.page.MapBasedPageReader; import com.github.sqljam.impexp.db.H2Dialect; +import com.github.sqljam.jdbc.page.MapBasedPageReader; import com.github.sqljam.page.EachPage; import com.github.sqljam.page.PageRequest; import com.github.sqljam.page.PageResponse; @@ -155,9 +160,9 @@ void columnValues() throws Exception { assertEquals(new BigDecimal("18446744073709551615"), JdbcUtils.normalizeValue( new BigInteger("18446744073709551615"))); assertEquals("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", JdbcUtils.normalizeValue( - java.util.UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))); - assertEquals(2024, JdbcUtils.normalizeValue(java.time.Year.of(2024))); - assertEquals(LocalTime.of(1, 2, 3), JdbcUtils.normalizeValue(java.sql.Time.valueOf("01:02:03"))); + UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))); + assertEquals(2024, JdbcUtils.normalizeValue(Year.of(2024))); + assertEquals(LocalTime.of(1, 2, 3), JdbcUtils.normalizeValue(Time.valueOf("01:02:03"))); assertNull(JdbcUtils.normalizeValue(null)); assertEquals(new BigDecimal("1234.56"), JdbcUtils.parseMoney("$1,234.56")); assertEquals(new BigDecimal("-1234.56"), JdbcUtils.parseMoney("($1,234.56)")); @@ -239,8 +244,8 @@ void connectionFactory() throws Exception { Connection.TRANSACTION_READ_COMMITTED)); assertEquals(Connection.TRANSACTION_SERIALIZABLE, TransactionIsolationLevel.SERIALIZABLE.getLevel()); JdbcUtils.closeQuietly((Connection) null); - JdbcUtils.closeQuietly((java.sql.Statement) null); - JdbcUtils.closeQuietly((java.sql.ResultSet) null); + JdbcUtils.closeQuietly((Statement) null); + JdbcUtils.closeQuietly((ResultSet) null); try (Connection direct = JdbcUtils.getConnection("jdbc:h2:mem:direct", null, null)) { assertTrue(direct.isValid(1)); } diff --git a/src/test/java/com/github/sqljam/mariadb/MariaDBImportIT.java b/src/test/java/com/github/sqljam/mariadb/MariaDBImportIT.java new file mode 100644 index 0000000..3c83545 --- /dev/null +++ b/src/test/java/com/github/sqljam/mariadb/MariaDBImportIT.java @@ -0,0 +1,32 @@ +/* + * 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.mariadb; + +import com.github.sqljam.it.AbstractImportIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: MariaDBImportIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class MariaDBImportIT extends AbstractImportIT { + + MariaDBImportIT() { + super(ItDatabase.MARIADB); + } +} diff --git a/src/test/java/com/github/sqljam/mariadb/MariaDBParquetIT.java b/src/test/java/com/github/sqljam/mariadb/MariaDBParquetIT.java new file mode 100644 index 0000000..96ccf1c --- /dev/null +++ b/src/test/java/com/github/sqljam/mariadb/MariaDBParquetIT.java @@ -0,0 +1,32 @@ +/* + * 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.mariadb; + +import com.github.sqljam.it.AbstractParquetIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: MariaDBParquetIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class MariaDBParquetIT extends AbstractParquetIT { + + MariaDBParquetIT() { + super(ItDatabase.MARIADB); + } +} diff --git a/src/test/java/com/github/sqljam/mariadb/MariaDBScriptIT.java b/src/test/java/com/github/sqljam/mariadb/MariaDBScriptIT.java new file mode 100644 index 0000000..58d1a99 --- /dev/null +++ b/src/test/java/com/github/sqljam/mariadb/MariaDBScriptIT.java @@ -0,0 +1,32 @@ +/* + * 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.mariadb; + +import com.github.sqljam.it.AbstractScriptIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: MariaDBScriptIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class MariaDBScriptIT extends AbstractScriptIT { + + MariaDBScriptIT() { + super(ItDatabase.MARIADB); + } +} diff --git a/src/test/java/com/github/sqljam/mariadb/MariaDBSessionIT.java b/src/test/java/com/github/sqljam/mariadb/MariaDBSessionIT.java new file mode 100644 index 0000000..d4dbcfe --- /dev/null +++ b/src/test/java/com/github/sqljam/mariadb/MariaDBSessionIT.java @@ -0,0 +1,32 @@ +/* + * 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.mariadb; + +import com.github.sqljam.it.AbstractSessionIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: MariaDBSessionIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class MariaDBSessionIT extends AbstractSessionIT { + + MariaDBSessionIT() { + super(ItDatabase.MARIADB); + } +} diff --git a/src/test/java/com/github/sqljam/mysql/MySQLParquetIT.java b/src/test/java/com/github/sqljam/mysql/MySQLParquetIT.java new file mode 100644 index 0000000..a84a4ac --- /dev/null +++ b/src/test/java/com/github/sqljam/mysql/MySQLParquetIT.java @@ -0,0 +1,32 @@ +/* + * 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.mysql; + +import com.github.sqljam.it.AbstractParquetIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: MySQLParquetIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class MySQLParquetIT extends AbstractParquetIT { + + MySQLParquetIT() { + super(ItDatabase.MYSQL); + } +} diff --git a/src/test/java/com/github/sqljam/oracle/OracleParquetIT.java b/src/test/java/com/github/sqljam/oracle/OracleParquetIT.java new file mode 100644 index 0000000..0629227 --- /dev/null +++ b/src/test/java/com/github/sqljam/oracle/OracleParquetIT.java @@ -0,0 +1,32 @@ +/* + * 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.oracle; + +import com.github.sqljam.it.AbstractParquetIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: OracleParquetIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class OracleParquetIT extends AbstractParquetIT { + + OracleParquetIT() { + super(ItDatabase.ORACLE); + } +} diff --git a/src/test/java/com/github/sqljam/postgresql/PostgreSQLDialectTest.java b/src/test/java/com/github/sqljam/postgresql/PostgreSQLDialectTest.java index 6c3222a..cf9671d 100644 --- a/src/test/java/com/github/sqljam/postgresql/PostgreSQLDialectTest.java +++ b/src/test/java/com/github/sqljam/postgresql/PostgreSQLDialectTest.java @@ -23,6 +23,7 @@ import java.util.HashMap; import java.util.Map; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import com.github.sqljam.impexp.DbType; import com.github.sqljam.impexp.db.PostgreSQL10Dialect; @@ -98,7 +99,7 @@ void indexesAndVersions() { void sequences() { PostgreSQLDialect dialect = new PostgreSQLDialect(); assertTrue(dialect.isSequenceSupported()); - org.junit.jupiter.api.Assertions.assertNull(dialect.getStatementBeforeSequenceCreated(null, null, "s")); + Assertions.assertNull(dialect.getStatementBeforeSequenceCreated(null, null, "s")); assertEquals("CREATE SEQUENCE IF NOT EXISTS seq AS bigint START WITH 1000 INCREMENT BY 10 MINVALUE 1" + " MAXVALUE 99999 CYCLE CACHE 20", dialect.getCreateSequenceStatement(null, null, "seq", 1000, 10, 1L, 99999L, true, 20L, "bigint")); diff --git a/src/test/java/com/github/sqljam/postgresql/PostgreSQLParquetIT.java b/src/test/java/com/github/sqljam/postgresql/PostgreSQLParquetIT.java new file mode 100644 index 0000000..d2a6363 --- /dev/null +++ b/src/test/java/com/github/sqljam/postgresql/PostgreSQLParquetIT.java @@ -0,0 +1,32 @@ +/* + * 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.postgresql; + +import com.github.sqljam.it.AbstractParquetIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: PostgreSQLParquetIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class PostgreSQLParquetIT extends AbstractParquetIT { + + PostgreSQLParquetIT() { + super(ItDatabase.POSTGRESQL); + } +} diff --git a/src/test/java/com/github/sqljam/sqlite/SQLiteDialectTest.java b/src/test/java/com/github/sqljam/sqlite/SQLiteDialectTest.java index c813ed2..09deece 100644 --- a/src/test/java/com/github/sqljam/sqlite/SQLiteDialectTest.java +++ b/src/test/java/com/github/sqljam/sqlite/SQLiteDialectTest.java @@ -107,7 +107,9 @@ void declaredTypes() { assertEquals(Types.TIMESTAMP, resolve("DATETIME", 2000000000)); assertEquals(Types.TIME, resolve("TIME", 2000000000)); assertEquals(Types.BLOB, resolve("BLOB", 2000000000)); - assertEquals(Types.BLOB, resolve("", 2000000000)); + // Columns without type (or ANY) keep values of any type, they are text of other databases + assertEquals(Types.VARCHAR, resolve("", 2000000000)); + assertEquals(Types.VARCHAR, resolve("ANY", 2000000000)); assertEquals(Types.CLOB, resolve("TEXT", 2000000000)); assertEquals(Types.CHAR, resolve("CHARACTER", 20)); assertEquals(Types.VARCHAR, resolve("VARCHAR(20)", 20)); diff --git a/src/test/java/com/github/sqljam/sqlite/SQLiteParquetIT.java b/src/test/java/com/github/sqljam/sqlite/SQLiteParquetIT.java new file mode 100644 index 0000000..be3641d --- /dev/null +++ b/src/test/java/com/github/sqljam/sqlite/SQLiteParquetIT.java @@ -0,0 +1,32 @@ +/* + * 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.sqlite; + +import com.github.sqljam.it.AbstractParquetIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: SQLiteParquetIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class SQLiteParquetIT extends AbstractParquetIT { + + SQLiteParquetIT() { + super(ItDatabase.SQLITE); + } +} diff --git a/src/test/java/com/github/sqljam/sqlserver/SQLServerParquetIT.java b/src/test/java/com/github/sqljam/sqlserver/SQLServerParquetIT.java new file mode 100644 index 0000000..9b4bf21 --- /dev/null +++ b/src/test/java/com/github/sqljam/sqlserver/SQLServerParquetIT.java @@ -0,0 +1,32 @@ +/* + * 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.sqlserver; + +import com.github.sqljam.it.AbstractParquetIT; +import com.github.sqljam.it.ItDatabase; + +/** + * @Description: SQLServerParquetIT + * @Author: Fred Feng + * @Date: 26/03/2023 + * @Version 1.0.0 + */ +class SQLServerParquetIT extends AbstractParquetIT { + + SQLServerParquetIT() { + super(ItDatabase.SQLSERVER); + } +} diff --git a/src/test/java/com/github/sqljam/utils/UtilsTest.java b/src/test/java/com/github/sqljam/utils/UtilsTest.java index 9ade76a..6920719 100644 --- a/src/test/java/com/github/sqljam/utils/UtilsTest.java +++ b/src/test/java/com/github/sqljam/utils/UtilsTest.java @@ -23,7 +23,9 @@ import java.math.BigDecimal; import java.math.BigInteger; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -55,7 +57,7 @@ void convert() { assertEquals(true, ConvertUtils.convert(1, Boolean.class)); assertEquals(Boolean.TRUE, ConvertUtils.convert(Boolean.TRUE, Boolean.class)); assertThrows(IllegalArgumentException.class, () -> ConvertUtils.convert(new Object(), Long.class)); - assertThrows(IllegalArgumentException.class, () -> ConvertUtils.convert(1, java.util.Date.class)); + assertThrows(IllegalArgumentException.class, () -> ConvertUtils.convert(1, Date.class)); } @Test @@ -87,8 +89,8 @@ void maps() { assertEquals(1, linked.get("a")); MapUtils.concurrentCaseInsensitiveMap().put("B", 2); Map> groups = new HashMap<>(); - MapUtils.getOrCreate(groups, "x", java.util.ArrayList::new).add(1); - MapUtils.getOrCreate(groups, "x", java.util.ArrayList::new).add(2); + MapUtils.getOrCreate(groups, "x", ArrayList::new).add(1); + MapUtils.getOrCreate(groups, "x", ArrayList::new).add(2); assertEquals(List.of(1, 2), groups.get("x")); assertNull(MapUtils.getOrCreate(null, "x", () -> 1)); Map split = MapUtils.splitAsMap("a=1&b", "&", "="); diff --git a/src/test/resources/clickhouse/fixture.sql b/src/test/resources/clickhouse/fixture.sql new file mode 100644 index 0000000..1f81295 --- /dev/null +++ b/src/test/resources/clickhouse/fixture.sql @@ -0,0 +1,103 @@ +DROP TABLE IF EXISTS sjc_emp_tag; +DROP TABLE IF EXISTS sjc_emp; +DROP TABLE IF EXISTS sjc_dept; +DROP TABLE IF EXISTS sjc_sales; +DROP TABLE IF EXISTS sjc_types; + +-- ClickHouse has no identity columns, foreign keys and unique indexes, the primary key is the sorting key +CREATE TABLE sjc_dept ( + id Int32, + name String, + budget Nullable(Decimal(12,2)) DEFAULT 0, + created_at Nullable(DateTime) DEFAULT now(), + PRIMARY KEY (id) +) ENGINE = MergeTree COMMENT 'Department'; + +CREATE TABLE sjc_emp ( + id Int64, + dept_id Nullable(Int32), + name String COMMENT 'Employee name', + email String, + salary Nullable(Decimal(10,2)), + active Nullable(Bool) DEFAULT true, + hired Nullable(Date32), + updated_at Nullable(DateTime64(3)), + photo Nullable(String), + profile Nullable(String), + note Nullable(String) DEFAULT 'n/a', + PRIMARY KEY (id) +) ENGINE = MergeTree; + +CREATE TABLE sjc_emp_tag ( + emp_id Int64, + tag_name String +) ENGINE = MergeTree ORDER BY (emp_id, tag_name); + +CREATE TABLE sjc_sales ( + id Int32, + sale_year Int32, + amount Nullable(Decimal(10,2)) +) ENGINE = MergeTree PARTITION BY sale_year ORDER BY (id, sale_year); + +CREATE TABLE sjc_types ( + id Int32, + c_bool Nullable(Bool), + c_int8 Nullable(Int8), + c_int16 Nullable(Int16), + c_int32 Nullable(Int32), + c_int64 Nullable(Int64), + c_int128 Nullable(Int128), + c_int256 Nullable(Int256), + c_uint8 Nullable(UInt8), + c_uint16 Nullable(UInt16), + c_uint32 Nullable(UInt32), + c_uint64 Nullable(UInt64), + c_float32 Nullable(Float32), + c_float64 Nullable(Float64), + c_decimal Nullable(Decimal(18,4)), + c_decimal38 Nullable(Decimal(38,10)), + c_decimal76 Nullable(Decimal(76,10)), + c_string Nullable(String), + c_fixed Nullable(FixedString(4)), + c_low LowCardinality(Nullable(String)), + c_date Nullable(Date), + c_date32 Nullable(Date32), + c_datetime Nullable(DateTime), + c_datetime64 Nullable(DateTime64(6)), + c_datetime64_9 Nullable(DateTime64(9)), + c_uuid Nullable(UUID), + c_ipv4 Nullable(IPv4), + c_ipv6 Nullable(IPv6), + c_enum Enum8('red' = 1, 'green' = 2, 'blue' = 3) DEFAULT 'red', + c_array Array(Int32), + c_array_str Array(Nullable(String)), + c_map Map(String, Int32), + c_tuple Tuple(a Int32, b String), + c_enum16 Enum16('low' = 1, 'high' = 1000) DEFAULT 'low', + c_datetime_tz Nullable(DateTime('Asia/Shanghai')), + c_decimal32 Nullable(Decimal32(2)), + c_variant Variant(Int64, String), + c_point Point, + c_gen Int32 MATERIALIZED id * 2, + c_alias Int32 ALIAS id + 1 +) ENGINE = MergeTree ORDER BY id COMMENT 'All types'; + +INSERT INTO sjc_types (id, c_bool, c_int8, c_int16, c_int32, c_int64, c_int128, c_int256, c_uint8, c_uint16, + c_uint32, c_uint64, c_float32, c_float64, c_decimal, c_decimal38, c_decimal76, c_string, c_fixed, c_low, c_date, + c_date32, c_datetime, c_datetime64, c_datetime64_9, c_uuid, c_ipv4, c_ipv6, c_enum, c_array, c_array_str, c_map, + c_tuple) +VALUES (1, true, -128, -32768, -2147483648, -9223372036854775808, 99999999999999999999999999999999999999, + -57896044618658097711785492504343953926634992332820282019728792003956564819968, 255, 65535, 4294967295, + 18446744073709551615, 3.5, 2.718281828459045, 12345678901234.1234, 1234567890123456789012345678.0123456789, + 123456789012345678901234567890123456789012345678901234567890123456.0123456789, 'string ''quoted'' \\ 中文', + 'abcd', 'low', '2024-02-29', '1900-01-01', '2024-02-29 23:59:59', '2024-02-29 23:59:59.123456', + '2024-02-29 23:59:59.123456789', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', '192.168.1.1', '2001:db8::1', 'green', + [1, 2, 3], ['a', NULL], {'k1': 1, 'k2': 2}, (1, 'x')); +INSERT INTO sjc_types (id, c_bool, c_int8, c_int16, c_int32, c_int64, c_uint64, c_float32, c_float64, c_decimal, + c_string, c_fixed, c_date, c_date32, c_datetime, c_datetime64) +VALUES (2, false, 127, 32767, 2147483647, 9223372036854775807, 0, -3.5, -1E100, -0.0001, '', 'ab', '1970-01-01', + '2299-12-31', '1970-01-01 00:00:00', '2299-12-31 23:59:59.999999'); +INSERT INTO sjc_types (id) VALUES (3); +ALTER TABLE sjc_types UPDATE c_enum16 = 'high', c_datetime_tz = toDateTime('2024-02-29 20:00:00', 'Asia/Shanghai'), + c_decimal32 = 1234567.89, c_variant = 'text', c_point = (1.5, 2.5) WHERE id = 1 SETTINGS mutations_sync = 1; +ALTER TABLE sjc_types UPDATE c_variant = 42::Int64 WHERE id = 2 SETTINGS mutations_sync = 1; diff --git a/src/test/resources/duckdb/fixture.sql b/src/test/resources/duckdb/fixture.sql new file mode 100644 index 0000000..f5c6bb5 --- /dev/null +++ b/src/test/resources/duckdb/fixture.sql @@ -0,0 +1,116 @@ +DROP TABLE IF EXISTS sjd_emp_tag; +DROP TABLE IF EXISTS sjd_emp; +DROP TABLE IF EXISTS sjd_dept; +DROP TABLE IF EXISTS sjd_sales; +DROP TABLE IF EXISTS sjd_types; +DROP SEQUENCE IF EXISTS sjd_dept_id_seq; +DROP SEQUENCE IF EXISTS sjd_emp_id_seq; +DROP SEQUENCE IF EXISTS sjd_order_seq; + +-- Identity columns of DuckDB are sequences named table_column_seq with nextval defaults +CREATE SEQUENCE sjd_dept_id_seq START 1; +CREATE TABLE sjd_dept ( + id INTEGER DEFAULT nextval('sjd_dept_id_seq') NOT NULL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + budget DECIMAL(12,2) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +COMMENT ON TABLE sjd_dept IS 'Department'; + +CREATE SEQUENCE sjd_emp_id_seq START 1; +CREATE TABLE sjd_emp ( + id BIGINT DEFAULT nextval('sjd_emp_id_seq') NOT NULL PRIMARY KEY, + dept_id INTEGER REFERENCES sjd_dept (id), + name VARCHAR(100) NOT NULL, + email VARCHAR(200) NOT NULL, + salary DECIMAL(10,2), + active BOOLEAN DEFAULT true, + hired DATE, + updated_at TIMESTAMP, + photo BLOB, + profile TEXT, + note VARCHAR(500) DEFAULT 'n/a' +); +COMMENT ON COLUMN sjd_emp.name IS 'Employee name'; +CREATE UNIQUE INDEX uk_sjd_emp_email ON sjd_emp (email); +CREATE INDEX idx_sjd_emp_dept_name ON sjd_emp (dept_id, name); + +CREATE TABLE sjd_emp_tag ( + emp_id BIGINT NOT NULL REFERENCES sjd_emp (id), + tag_name VARCHAR(50) NOT NULL, + PRIMARY KEY (emp_id, tag_name) +); + +CREATE TABLE sjd_sales ( + id INTEGER NOT NULL, + sale_year INTEGER NOT NULL, + amount DECIMAL(10,2), + PRIMARY KEY (id, sale_year) +); + +CREATE SEQUENCE sjd_order_seq START 1000 INCREMENT BY 10; +CREATE TABLE sjd_types ( + id INTEGER PRIMARY KEY, + c_order_no BIGINT DEFAULT nextval('sjd_order_seq'), + c_boolean BOOLEAN, + c_tinyint TINYINT, + c_smallint SMALLINT, + c_integer INTEGER, + c_bigint BIGINT, + c_hugeint HUGEINT, + c_utinyint UTINYINT, + c_usmallint USMALLINT, + c_uinteger UINTEGER, + c_ubigint UBIGINT, + c_float FLOAT, + c_double DOUBLE, + c_decimal DECIMAL(18,4), + c_decimal38 DECIMAL(38,10), + c_varchar VARCHAR, + c_varchar_n VARCHAR(20), + c_text TEXT, + c_blob BLOB, + c_date DATE, + c_time TIME, + c_timetz TIMETZ, + c_timestamp TIMESTAMP, + c_timestamp_s TIMESTAMP_S, + c_timestamp_ms TIMESTAMP_MS, + c_timestamp_ns TIMESTAMP_NS, + c_timestamptz TIMESTAMPTZ, + c_interval INTERVAL, + c_uuid UUID, + c_json JSON, + c_enum ENUM('red', 'green', 'blue'), + c_bit BIT, + c_int_list INTEGER[], + c_varchar_list VARCHAR[], + c_struct STRUCT(a INTEGER, b VARCHAR), + c_map MAP(VARCHAR, INTEGER), + c_uhugeint UHUGEINT, + c_int_array3 INTEGER[3], + c_union UNION(num INTEGER, str VARCHAR), + c_gen INTEGER GENERATED ALWAYS AS (CAST(c_smallint AS INTEGER) * 2) VIRTUAL +); +INSERT INTO sjd_types (id, c_boolean, c_tinyint, c_smallint, c_integer, c_bigint, c_hugeint, c_utinyint, + c_usmallint, c_uinteger, c_ubigint, c_float, c_double, c_decimal, c_decimal38, c_varchar, c_varchar_n, c_text, + c_blob, c_date, c_time, c_timetz, c_timestamp, c_timestamp_s, c_timestamp_ms, c_timestamp_ns, c_timestamptz, + c_interval, c_uuid, c_json, c_enum, c_bit, c_int_list, c_varchar_list, c_struct, c_map) +VALUES (1, true, -128, -32768, -2147483648, -9223372036854775808, 99999999999999999999999999999999999999, 255, + 65535, 4294967295, 18446744073709551615, 3.5, 2.718281828459045, 12345678901234.1234, + 1234567890123456789012345678.0123456789, 'varchar ''quoted'' \ 中文', 'short', 'text', + '\xDE\xAD\xBE\xEF'::BLOB, DATE '2024-02-29', TIME '23:59:59.123456', TIMETZ '12:00:00+08:00', + TIMESTAMP '2024-02-29 23:59:59.123456', TIMESTAMP_S '2024-02-29 23:59:59', TIMESTAMP_MS '2024-02-29 23:59:59.123', + TIMESTAMP_NS '2024-02-29 23:59:59.123456789', TIMESTAMPTZ '2024-02-29 12:00:00+08:00', INTERVAL '1 year 2 months 3 days', + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', '{"k": "v", "n": [1, 2]}', 'green', '101101', [1, 2, 3], ['a', 'b'], + {'a': 1, 'b': 'x'}, MAP {'k1': 1, 'k2': 2}); +INSERT INTO sjd_types (id, c_boolean, c_tinyint, c_smallint, c_integer, c_bigint, c_hugeint, c_utinyint, + c_usmallint, c_uinteger, c_ubigint, c_float, c_double, c_decimal, c_decimal38, c_varchar, c_varchar_n, c_text, + c_blob, c_date, c_time, c_timestamp, c_timestamptz) +VALUES (2, false, 127, 32767, 2147483647, 9223372036854775807, -99999999999999999999999999999999999999, 0, 0, 0, 0, + -3.5, -1E100, -0.0001, 0, '', '', '', ''::BLOB, DATE '1970-01-01', TIME '00:00:00', TIMESTAMP '9999-12-31 23:59:59.999999', + TIMESTAMPTZ '1970-01-02 00:00:00+00:00'); +INSERT INTO sjd_types (id) VALUES (3); +UPDATE sjd_types SET c_uhugeint = 99999999999999999999999999999999999999, c_int_array3 = [1, 2, 3], + c_union = union_value(str := 'text') WHERE id = 1; +UPDATE sjd_types SET c_uhugeint = 0, c_int_array3 = [0, 0, 0], c_union = union_value(num := 7) WHERE id = 2; diff --git a/src/test/resources/h2/fixture.sql b/src/test/resources/h2/fixture.sql index 7558e21..69cbafd 100644 --- a/src/test/resources/h2/fixture.sql +++ b/src/test/resources/h2/fixture.sql @@ -74,6 +74,11 @@ CREATE TABLE sjh_types ( c_json JSON, c_enum ENUM('red', 'green', 'blue'), c_int_array INTEGER ARRAY, + c_varchar_array VARCHAR(20) ARRAY, + c_interval_ym INTERVAL YEAR TO MONTH, + c_timestamp9 TIMESTAMP(9), + c_time9 TIME(9), + c_row ROW(a INTEGER, b VARCHAR(10)), c_java_object JAVA_OBJECT, c_gen BIGINT GENERATED ALWAYS AS (CAST(c_int AS BIGINT) * 2) ); @@ -90,3 +95,6 @@ INSERT INTO sjh_types (id, c_tinyint, c_smallint, c_int, c_bigint, c_numeric, c_ VALUES (2, 127, 32767, 2147483647, 9223372036854775807, -0.000001, -3.5, -1E100, FALSE, '', '', DATE '1970-01-01', TIME '00:00:00', TIMESTAMP '9999-12-31 23:59:59.999999'); INSERT INTO sjh_types (id) VALUES (3); +UPDATE sjh_types SET c_varchar_array = ARRAY['a', 'b c'], c_interval_ym = INTERVAL '1-2' YEAR TO MONTH, + c_timestamp9 = TIMESTAMP '2024-02-29 12:00:00.123456789', c_time9 = TIME '23:59:59.123456789', + c_row = ROW(1, 'x') WHERE id = 1; diff --git a/src/test/resources/mariadb/fixture.sql b/src/test/resources/mariadb/fixture.sql new file mode 100644 index 0000000..cc51c42 --- /dev/null +++ b/src/test/resources/mariadb/fixture.sql @@ -0,0 +1,147 @@ +DROP TABLE IF EXISTS sjb_emp_tag; +DROP TABLE IF EXISTS sjb_emp; +DROP TABLE IF EXISTS sjb_dept; +DROP TABLE IF EXISTS sjb_sales; +DROP TABLE IF EXISTS sjb_types; +DROP SEQUENCE IF EXISTS sjb_order_seq; + +-- Sequences of MariaDB are independent objects, used by nextval defaults +CREATE SEQUENCE sjb_order_seq START WITH 1000 INCREMENT BY 10; + +CREATE TABLE sjb_dept ( + id INT NOT NULL AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + budget DECIMAL(12,2) DEFAULT 0.00, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_sjb_dept_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Department'; + +CREATE TABLE sjb_emp ( + id BIGINT NOT NULL AUTO_INCREMENT, + dept_id INT, + name VARCHAR(100) NOT NULL COMMENT 'Employee name', + email VARCHAR(200) NOT NULL, + salary DECIMAL(10,2), + active TINYINT(1) DEFAULT 1, + hired DATE, + updated_at DATETIME(3), + photo BLOB, + profile TEXT, + note VARCHAR(500) DEFAULT 'n/a', + PRIMARY KEY (id), + UNIQUE KEY uk_sjb_emp_email (email), + KEY idx_sjb_emp_dept_name (dept_id, name), + CONSTRAINT fk_sjb_emp_dept FOREIGN KEY (dept_id) REFERENCES sjb_dept (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE sjb_emp_tag ( + emp_id BIGINT NOT NULL, + tag_name VARCHAR(50) NOT NULL, + PRIMARY KEY (emp_id, tag_name), + CONSTRAINT fk_sjb_tag_emp FOREIGN KEY (emp_id) REFERENCES sjb_emp (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE sjb_sales ( + id INT NOT NULL, + sale_year INT NOT NULL, + amount DECIMAL(10,2), + PRIMARY KEY (id, sale_year) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 +PARTITION BY RANGE (sale_year) ( + PARTITION p2023 VALUES LESS THAN (2024), + PARTITION p2024 VALUES LESS THAN (2025), + PARTITION pmax VALUES LESS THAN MAXVALUE +); + +CREATE TABLE sjb_types ( + id INT NOT NULL PRIMARY KEY, + c_order_no BIGINT DEFAULT nextval(sjb_order_seq), + c_uuid UUID, + c_inet4 INET4, + c_inet6 INET6, + c_tinyint TINYINT, + c_tinyint_u TINYINT UNSIGNED, + c_smallint SMALLINT, + c_smallint_u SMALLINT UNSIGNED, + c_mediumint MEDIUMINT, + c_mediumint_u MEDIUMINT UNSIGNED, + c_int INT, + c_int_u INT UNSIGNED, + c_bigint BIGINT, + c_bigint_u BIGINT UNSIGNED, + c_decimal DECIMAL(20,6), + c_numeric NUMERIC(10,0), + c_float FLOAT, + c_double DOUBLE, + c_real REAL, + c_bit1 BIT(1), + c_bit8 BIT(8), + c_bool BOOLEAN, + c_date DATE, + c_datetime DATETIME, + c_datetime6 DATETIME(6), + c_timestamp TIMESTAMP NULL, + c_time TIME, + c_time3 TIME(3), + c_year YEAR, + c_char CHAR(10), + c_varchar VARCHAR(255), + c_tinytext TINYTEXT, + c_text TEXT, + c_mediumtext MEDIUMTEXT, + c_longtext LONGTEXT, + c_binary BINARY(4), + c_varbinary VARBINARY(64), + c_tinyblob TINYBLOB, + c_blob BLOB, + c_mediumblob MEDIUMBLOB, + c_longblob LONGBLOB, + c_enum ENUM('red','green','blue'), + c_set SET('a','b','c'), + c_json JSON, + c_geometry GEOMETRY, + c_point POINT, + c_linestring LINESTRING, + c_polygon POLYGON, + c_multipoint MULTIPOINT, + c_multiline MULTILINESTRING, + c_multipoly MULTIPOLYGON, + c_geomcoll GEOMETRYCOLLECTION, + c_vector VECTOR(3), + c_timestamp6 TIMESTAMP(6) NULL, + c_bit64 BIT(64), + c_decimal65 DECIMAL(65,30), + c_gen_stored BIGINT GENERATED ALWAYS AS (c_int * 2) STORED, + c_gen_virtual VARCHAR(20) GENERATED ALWAYS AS (CONCAT('#', id)) VIRTUAL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +INSERT INTO sjb_types (id, c_uuid, c_inet4, c_inet6, c_tinyint, c_tinyint_u, c_smallint, c_smallint_u, c_mediumint, c_mediumint_u, c_int, c_int_u, + c_bigint, c_bigint_u, c_decimal, c_numeric, c_float, c_double, c_real, c_bit1, c_bit8, c_bool, c_date, c_datetime, + c_datetime6, c_timestamp, c_time, c_time3, c_year, c_char, c_varchar, c_tinytext, c_text, c_mediumtext, c_longtext, + c_binary, c_varbinary, c_tinyblob, c_blob, c_mediumblob, c_longblob, c_enum, c_set, c_json, c_geometry, c_point) +VALUES (1, 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', '192.168.1.1', '2001:db8::1', -128, 255, -32768, 65535, -8388608, 16777215, -2147483648, 4294967295, -9223372036854775808, + 18446744073709551615, 12345678901234.123456, 1234567890, 3.5, 2.718281828459045, 1.25, b'1', b'10101010', TRUE, + '2024-02-29', '2024-02-29 23:59:59', '2024-02-29 23:59:59.123456', '2024-02-29 12:00:00', '23:59:59', + '12:34:56.789', 2024, 'char', 'varchar ''quoted'' \\ 中文', 'tiny', 'text', 'medium', 'long', X'01020304', + X'DEADBEEF', X'00', X'0102', X'0304', X'0506', 'red', 'a,c', '{"k": "v", "n": [1, 2]}', + ST_GeomFromText('POINT(1 2)'), ST_GeomFromText('POINT(3 4)')); +INSERT INTO sjb_types (id, c_uuid, c_inet4, c_inet6, c_tinyint, c_tinyint_u, c_smallint, c_smallint_u, c_mediumint, c_mediumint_u, c_int, c_int_u, + c_bigint, c_bigint_u, c_decimal, c_numeric, c_float, c_double, c_real, c_bit1, c_bit8, c_bool, c_date, c_datetime, + c_datetime6, c_timestamp, c_time, c_time3, c_year, c_char, c_varchar, c_tinytext, c_text, c_mediumtext, c_longtext, + c_binary, c_varbinary, c_tinyblob, c_blob, c_mediumblob, c_longblob, c_enum, c_set, c_json, c_geometry, c_point) +VALUES (2, NULL, '0.0.0.0', '::', 127, 0, 32767, 0, 8388607, 0, 2147483647, 0, 9223372036854775807, 0, -0.000001, 0, -3.5, -1E100, 0, + b'0', b'00000000', FALSE, '1970-01-01', '1000-01-01 00:00:00', '9999-12-31 23:59:59.999999', '1970-01-02 00:00:00', + '00:00:00', '00:00:00.001', 1901, '', '', '', '', '', '', X'00000000', X'', X'', X'', X'', X'', 'blue', '', + '[]', ST_GeomFromText('LINESTRING(0 0, 1 1)'), ST_GeomFromText('POINT(0 0)')); +INSERT INTO sjb_types (id) VALUES (3); +UPDATE sjb_types SET c_linestring = ST_GeomFromText('LINESTRING(0 0, 1 1, 2 1)'), + c_polygon = ST_GeomFromText('POLYGON((0 0, 4 0, 4 4, 0 0))'), c_multipoint = ST_GeomFromText('MULTIPOINT((1 1), (2 2))'), + c_multiline = ST_GeomFromText('MULTILINESTRING((0 0, 1 1), (2 2, 3 3))'), + c_multipoly = ST_GeomFromText('MULTIPOLYGON(((0 0, 1 0, 1 1, 0 0)), ((2 2, 3 2, 3 3, 2 2)))'), + c_geomcoll = ST_GeomFromText('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))'), + c_vector = VEC_FromText('[1, 2.5, -3]'), c_timestamp6 = '2024-02-29 12:00:00.123456', + c_bit64 = b'1000000000000000000000000000000000000000000000000000000000000001', + c_decimal65 = 12345678.123456789012345678901234567890 WHERE id = 1; +UPDATE sjb_types SET c_vector = VEC_FromText('[0, 0, 0]'), c_timestamp6 = '1970-01-02 00:00:00.000001', + c_bit64 = b'0', c_decimal65 = -0.000000000000000000000000000001 WHERE id = 2; + diff --git a/src/test/resources/mysql/fixture.sql b/src/test/resources/mysql/fixture.sql index aec897c..5a8e0d8 100644 --- a/src/test/resources/mysql/fixture.sql +++ b/src/test/resources/mysql/fixture.sql @@ -94,6 +94,16 @@ CREATE TABLE sjm_types ( c_json JSON, c_geometry GEOMETRY, c_point POINT, + c_linestring LINESTRING, + c_polygon POLYGON, + c_multipoint MULTIPOINT, + c_multiline MULTILINESTRING, + c_multipoly MULTIPOLYGON, + c_geomcoll GEOMETRYCOLLECTION, + c_vector VECTOR(3), + c_timestamp6 TIMESTAMP(6) NULL, + c_bit64 BIT(64), + c_decimal65 DECIMAL(65,30), c_gen_stored BIGINT GENERATED ALWAYS AS (c_int * 2) STORED, c_gen_virtual VARCHAR(20) GENERATED ALWAYS AS (CONCAT('#', id)) VIRTUAL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -116,3 +126,14 @@ VALUES (2, 127, 0, 32767, 0, 8388607, 0, 2147483647, 0, 9223372036854775807, 0, '00:00:00', '00:00:00.001', 1901, '', '', '', '', '', '', X'00000000', X'', X'', X'', X'', X'', 'blue', '', '[]', ST_GeomFromText('LINESTRING(0 0, 1 1)'), ST_GeomFromText('POINT(0 0)')); INSERT INTO sjm_types (id) VALUES (3); +UPDATE sjm_types SET c_linestring = ST_GeomFromText('LINESTRING(0 0, 1 1, 2 1)'), + c_polygon = ST_GeomFromText('POLYGON((0 0, 4 0, 4 4, 0 0))'), c_multipoint = ST_GeomFromText('MULTIPOINT((1 1), (2 2))'), + c_multiline = ST_GeomFromText('MULTILINESTRING((0 0, 1 1), (2 2, 3 3))'), + c_multipoly = ST_GeomFromText('MULTIPOLYGON(((0 0, 1 0, 1 1, 0 0)), ((2 2, 3 2, 3 3, 2 2)))'), + c_geomcoll = ST_GeomFromText('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))'), + c_vector = STRING_TO_VECTOR('[1, 2.5, -3]'), c_timestamp6 = '2024-02-29 12:00:00.123456', + c_bit64 = b'1000000000000000000000000000000000000000000000000000000000000001', + c_decimal65 = 12345678.123456789012345678901234567890 WHERE id = 1; +UPDATE sjm_types SET c_vector = STRING_TO_VECTOR('[0, 0, 0]'), c_timestamp6 = '1970-01-02 00:00:00.000001', + c_bit64 = b'0', c_decimal65 = -0.000000000000000000000000000001 WHERE id = 2; + diff --git a/src/test/resources/oracle/fixture.sql b/src/test/resources/oracle/fixture.sql index 8ff0fe0..c1ab9dd 100644 --- a/src/test/resources/oracle/fixture.sql +++ b/src/test/resources/oracle/fixture.sql @@ -87,6 +87,8 @@ CREATE TABLE sjo_types ( c_xml XMLTYPE, c_json JSON, c_boolean BOOLEAN, + c_vector VECTOR(3, FLOAT32), + c_timestamp9 TIMESTAMP(9), c_gen NUMBER GENERATED ALWAYS AS (c_number10 * 2) VIRTUAL ); INSERT INTO sjo_types (id, c_number, c_number10, c_number_ps, c_float, c_float10, c_binary_float, c_binary_double, @@ -104,3 +106,7 @@ INSERT INTO sjo_types (id, c_number, c_number10, c_number_ps, c_float, c_float10 VALUES (2, 0, 2147483647, -0.000001, -1.5, 0, -3.5, -1E100, ' ', ' ', ' ', EMPTY_BLOB(), HEXTORAW('00'), DATE '1970-01-01', TIMESTAMP '9999-12-31 23:59:59.999999', FALSE); INSERT INTO sjo_types (id) VALUES (3); +UPDATE sjo_types SET c_vector = TO_VECTOR('[1, 2.5, -3]', 3, FLOAT32), + c_timestamp9 = TIMESTAMP '2024-02-29 12:00:00.123456789' WHERE id = 1; +UPDATE sjo_types SET c_vector = TO_VECTOR('[0, 0, 0]', 3, FLOAT32), + c_timestamp9 = TIMESTAMP '1970-01-01 00:00:00.000000001' WHERE id = 2; diff --git a/src/test/resources/postgresql/fixture.sql b/src/test/resources/postgresql/fixture.sql index 8e60804..b89d391 100644 --- a/src/test/resources/postgresql/fixture.sql +++ b/src/test/resources/postgresql/fixture.sql @@ -45,6 +45,11 @@ CREATE TABLE sjit_src.sjp_sales_2024 PARTITION OF sjit_src.sjp_sales FOR VALUES DROP TABLE IF EXISTS sjit_src.sjp_types CASCADE; DROP SEQUENCE IF EXISTS sjit_src.sjp_order_seq; +-- User defined types are created in the target schema by imports into PostgreSQL +DROP TYPE IF EXISTS sjit_src.sjp_mood CASCADE; +DROP DOMAIN IF EXISTS sjit_src.sjp_email CASCADE; +CREATE TYPE sjit_src.sjp_mood AS ENUM ('sad', 'ok', 'happy'); +CREATE DOMAIN sjit_src.sjp_email AS VARCHAR(100) CHECK (VALUE LIKE '%@%'); CREATE SEQUENCE sjit_src.sjp_order_seq START WITH 1000 INCREMENT BY 10; CREATE TABLE sjit_src.sjp_types ( id INT PRIMARY KEY, @@ -99,6 +104,12 @@ CREATE TABLE sjit_src.sjp_types ( c_text_array TEXT[], c_oid OID, c_pg_lsn PG_LSN, + c_mood sjit_src.sjp_mood, + c_email sjit_src.sjp_email, + c_multirange INT4MULTIRANGE, + c_char1 "char", + c_name NAME, + c_matrix INT[][], c_identity BIGINT GENERATED BY DEFAULT AS IDENTITY, c_gen BIGINT GENERATED ALWAYS AS (c_integer::bigint * 2) STORED ); @@ -121,3 +132,6 @@ INSERT INTO sjit_src.sjp_types (id, c_smallint, c_integer, c_bigint, c_numeric, VALUES (2, 32767, 2147483647, 9223372036854775807, -0.000001, 0, -3.5, -1E100, -0.01, false, '', '', '', '', '\x', '1970-01-01', '00:00:00', '1970-01-01 00:00:00', '9999-12-31 23:59:59.999999'); INSERT INTO sjit_src.sjp_types (id) VALUES (3); +UPDATE sjit_src.sjp_types SET c_mood = 'happy', c_email = 'a@sqljam.io', c_multirange = '{[1,3),[5,7)}', c_char1 = 'x', + c_name = 'pg_name', c_matrix = '{{1,2},{3,4}}' WHERE id = 1; +UPDATE sjit_src.sjp_types SET c_mood = 'sad', c_email = 'b@x' WHERE id = 2; diff --git a/src/test/resources/sqlite/fixture.sql b/src/test/resources/sqlite/fixture.sql index 07619df..1f6cda8 100644 --- a/src/test/resources/sqlite/fixture.sql +++ b/src/test/resources/sqlite/fixture.sql @@ -67,6 +67,8 @@ CREATE TABLE sjl_types ( c_clob CLOB, c_blob BLOB, c_json JSON, + c_untyped, + c_any ANY, c_gen INTEGER GENERATED ALWAYS AS (c_int * 2) VIRTUAL ); INSERT INTO sjl_types (id, c_integer, c_int, c_tinyint, c_smallint, c_mediumint, c_bigint, c_int8, c_real, c_double, @@ -78,3 +80,5 @@ VALUES (1, -9223372036854775808, -2147483648, -128, -32768, -8388608, 9223372036 INSERT INTO sjl_types (id, c_integer, c_int, c_real, c_boolean, c_date, c_datetime, c_text, c_blob) VALUES (2, 0, 2147483647, -1E100, 0, '1970-01-01', '9999-12-31 23:59:59', '', X''); INSERT INTO sjl_types (id) VALUES (3); +UPDATE sjl_types SET c_untyped = 'no declared type', c_any = 42 WHERE id = 1; +UPDATE sjl_types SET c_untyped = 3.5, c_any = 'text' WHERE id = 2; diff --git a/src/test/resources/sqlserver/fixture.sql b/src/test/resources/sqlserver/fixture.sql index 24dd656..979d2c6 100644 --- a/src/test/resources/sqlserver/fixture.sql +++ b/src/test/resources/sqlserver/fixture.sql @@ -84,6 +84,10 @@ CREATE TABLE dbo.sjs_types ( c_hierarchyid HIERARCHYID, c_geography GEOGRAPHY, c_geometry GEOMETRY, + c_datetime2_0 DATETIME2(0), + c_time0 TIME(0), + c_dto0 DATETIMEOFFSET(0), + c_decimal38 DECIMAL(38,0), c_rowversion ROWVERSION, c_computed AS (CAST(c_int AS BIGINT) * 2), c_persisted AS (CONCAT('#', id)) PERSISTED @@ -104,3 +108,7 @@ INSERT INTO dbo.sjs_types (id, c_bit, c_tinyint, c_smallint, c_int, c_bigint, c_ VALUES (2, 0, 0, 32767, 2147483647, 9223372036854775807, -0.000001, 0, -0.0001, 0, -1E100, -3.5, '0001-01-01', '00:00:00', '1753-01-01 00:00:00', '9999-12-31 23:59:59.9999999', '', '', N'', 0x00000000, 0x); INSERT INTO dbo.sjs_types (id) VALUES (3); +UPDATE dbo.sjs_types SET c_datetime2_0 = '2024-02-29 23:59:59', c_time0 = '23:59:59', + c_dto0 = '2024-02-29 12:00:00 +08:00', c_decimal38 = 99999999999999999999999999999999999999 WHERE id = 1; +UPDATE dbo.sjs_types SET c_datetime2_0 = '0001-01-01 00:00:00', c_time0 = '00:00:00', + c_dto0 = '1970-01-02 00:00:00 +00:00', c_decimal38 = -99999999999999999999999999999999999999 WHERE id = 2; diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst index 099fef0..95a8bf3 100644 --- a/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -1,22 +1,26 @@ com/github/sqljam/impexp/ExportManifest$FileType.class com/github/sqljam/face/view/DbTypeCells$3.class com/github/sqljam/face/model/AppSettings.class +com/github/sqljam/face/service/TransferService$ParquetPreview.class com/github/sqljam/impexp/db/OracleMetaDataOperations.class com/github/sqljam/face/model/TableInfo.class com/github/sqljam/face/SplashPreloader$Status.class com/github/sqljam/face/model/IndexInfo.class com/github/sqljam/face/view/ConnectionTree$1.class com/github/sqljam/impexp/ImpExpException.class -com/github/sqljam/impexp/ScriptImporter$1.class +com/github/sqljam/face/view/PackageViewer$1.class com/github/sqljam/face/service/DdlGenerator.class com/github/sqljam/utils/CaseInsensitiveMap.class com/github/sqljam/face/view/ConnectionForm.class com/github/sqljam/impexp/DbType$4.class com/github/sqljam/face/view/MainView.class +com/github/sqljam/impexp/AbstractFileImporter.class com/github/sqljam/impexp/ScriptImporter.class com/github/sqljam/face/service/SettingsStore.class com/github/sqljam/impexp/ImportExporter.class com/github/sqljam/impexp/PartitionTableMetaData.class +com/github/sqljam/face/view/PackageViewer.class +com/github/sqljam/impexp/ParquetExporter.class com/github/sqljam/impexp/ScriptExportHandler$DataFile.class com/github/sqljam/impexp/db/SQLServerDialect.class com/github/sqljam/impexp/SqlTextUtils.class @@ -30,21 +34,27 @@ com/github/sqljam/face/view/LoginView.class com/github/sqljam/face/view/ProgressDialog$1.class com/github/sqljam/page/EachPage.class com/github/sqljam/page/SimplePageResponse$PageIterator.class +com/github/sqljam/impexp/DuckDBWorkspace.class com/github/sqljam/page/PageResponse.class com/github/sqljam/face/view/ConnectionDialog.class +com/github/sqljam/impexp/ParquetImporter$Mode.class com/github/sqljam/face/service/ConnectionStore.class com/github/sqljam/face/view/ExportDialog$1.class com/github/sqljam/impexp/ExportHandler.class +com/github/sqljam/impexp/db/MariaDBMetaDataOperations.class com/github/sqljam/face/view/ExportDialog$2.class com/github/sqljam/impexp/IdentityValueTracker.class com/github/sqljam/impexp/IdentifierCase.class +com/github/sqljam/impexp/DbCategory.class com/github/sqljam/face/view/DbNode$Kind.class +com/github/sqljam/impexp/DbType$9.class com/github/sqljam/face/Banner$Mode.class com/github/sqljam/impexp/DbType$3.class com/github/sqljam/face/view/ProgressDialog$2.class com/github/sqljam/impexp/DbType$5.class com/github/sqljam/impexp/DataFileStrategy.class com/github/sqljam/face/service/DdlGenerator$CapturingHandler.class +com/github/sqljam/impexp/db/DuckDBDialect.class com/github/sqljam/face/view/ThemeManager.class com/github/sqljam/face/view/ConnectionTree$LazyItem.class com/github/sqljam/face/view/TableTab.class @@ -63,7 +73,9 @@ com/github/sqljam/page/DefaultPageContent.class com/github/sqljam/impexp/db/SQLiteMetaDataOperations.class com/github/sqljam/impexp/db/MySQLMetaDataOperations.class com/github/sqljam/impexp/ExportManifest.class +com/github/sqljam/impexp/DbType$8.class com/github/sqljam/jdbc/SimpleConnectionFactory.class +com/github/sqljam/face/view/DbTypeCells$5.class com/github/sqljam/impexp/DdlScripter.class com/github/sqljam/impexp/TransactionIsolationLevel.class com/github/sqljam/face/view/ConnectionTree.class @@ -71,23 +83,31 @@ com/github/sqljam/impexp/db/SQLServer2008Dialect.class com/github/sqljam/face/view/DbTypeCells$2.class com/github/sqljam/face/view/AppContext.class com/github/sqljam/jdbc/JdbcUtils.class +com/github/sqljam/impexp/ParquetImporter.class +com/github/sqljam/impexp/ParquetImporter$ProgressListener.class com/github/sqljam/impexp/PartitionMetaData.class +com/github/sqljam/impexp/AbstractFileImporter$ForwardingListener.class com/github/sqljam/face/SplashPreloader$Ready.class com/github/sqljam/face/view/SessionManager.class +com/github/sqljam/impexp/db/ClickHouseMetaDataOperations.class com/github/sqljam/page/SimplePageRequest.class com/github/sqljam/impexp/Dialect$1.class com/github/sqljam/face/view/Branding.class com/github/sqljam/face/service/DatabaseSession.class +com/github/sqljam/face/view/ImportParquetDialog$1.class com/github/sqljam/impexp/CatalogMetaData.class com/github/sqljam/impexp/PooledConnectionFactory.class +com/github/sqljam/impexp/ParquetExporter$PhaseListener$1.class +com/github/sqljam/impexp/TableQuery.class com/github/sqljam/impexp/CommentMetaData.class -com/github/sqljam/impexp/ScriptImporter$ForwardingListener.class +com/github/sqljam/impexp/DbType$7.class com/github/sqljam/impexp/ScriptExportHandler.class com/github/sqljam/impexp/ServerMetaData.class com/github/sqljam/impexp/DbType$6.class com/github/sqljam/jdbc/JdbcUtils$DisposableObservable.class com/github/sqljam/face/view/DbTypeCells$4.class com/github/sqljam/face/service/TransferService.class +com/github/sqljam/face/service/ExampleDatabase.class com/github/sqljam/impexp/db/PostgreSQLMetaDataOperations.class com/github/sqljam/jdbc/page/MapBasedPageReader.class com/github/sqljam/face/view/TaskRunner$1.class @@ -114,16 +134,20 @@ com/github/sqljam/impexp/TiedMetaData.class com/github/sqljam/impexp/db/OracleDialect.class com/github/sqljam/face/view/TaskRunner.class com/github/sqljam/impexp/StringHelper.class +com/github/sqljam/impexp/AbstractFileImporter$1.class com/github/sqljam/face/model/TransferRequest$Target.class com/github/sqljam/impexp/LobManifestWriter.class com/github/sqljam/face/view/ProgressDialog.class com/github/sqljam/page/PageReader.class +com/github/sqljam/impexp/ParquetExporter$PhaseListener.class com/github/sqljam/impexp/db/PostgreSQL10Dialect.class com/github/sqljam/impexp/DbType$1.class +com/github/sqljam/face/view/ImportParquetDialog$ParquetRequest.class com/github/sqljam/face/SqlJamApplication.class com/github/sqljam/impexp/db/SQLiteDialect.class com/github/sqljam/face/view/Dialogs.class com/github/sqljam/face/view/DbTypeCells$1.class +com/github/sqljam/face/view/ImportParquetDialog.class com/github/sqljam/utils/KeyConvertibleMap.class com/github/sqljam/face/Banner.class com/github/sqljam/impexp/ExportListener$1.class @@ -139,6 +163,7 @@ com/github/sqljam/face/model/ConnectionProfile.class com/github/sqljam/face/service/JsonStore.class com/github/sqljam/impexp/IndexMetaData.class com/github/sqljam/face/view/DbTypeCells.class +com/github/sqljam/jdbc/SharedConnectionFactory.class com/github/sqljam/impexp/ColumnMetaData.class com/github/sqljam/impexp/ForeignKeyMetaData.class com/github/sqljam/face/model/DataPage.class @@ -146,9 +171,11 @@ com/github/sqljam/face/model/ForeignKeyInfo.class com/github/sqljam/impexp/CombinedIndexMetaData.class com/github/sqljam/utils/ConvertUtils.class com/github/sqljam/face/view/ImportPackageDialog.class +com/github/sqljam/impexp/db/ClickHouseDialect.class com/github/sqljam/impexp/MetaData.class com/github/sqljam/impexp/DbType$2.class com/github/sqljam/face/model/TransferRequest.class +com/github/sqljam/impexp/db/MariaDBDialect.class com/github/sqljam/impexp/MetaDataOperations.class com/github/sqljam/impexp/ExportManifest$Database.class com/github/sqljam/impexp/MetaDataVisitor.class @@ -156,9 +183,11 @@ com/github/sqljam/impexp/TableMetaData.class com/github/sqljam/impexp/db/H2MetaDataOperations.class com/github/sqljam/page/PageContent.class com/github/sqljam/impexp/ExportManifest$Status.class +com/github/sqljam/impexp/db/DuckDBMetaDataOperations.class com/github/sqljam/impexp/SequenceMetaData.class com/github/sqljam/impexp/db/MySQL55Dialect.class com/github/sqljam/impexp/db/H2Dialect.class +com/github/sqljam/impexp/DataFormat.class com/github/sqljam/impexp/DdlScripter$Schema.class com/github/sqljam/impexp/Exporter.class com/github/sqljam/page/SimplePageResponse.class @@ -167,11 +196,13 @@ com/github/sqljam/face/view/DbNode.class com/github/sqljam/face/SplashPreloader.class com/github/sqljam/impexp/DdlScripter$PartitionTable.class com/github/sqljam/impexp/ImportExportHandler.class +com/github/sqljam/face/view/ExampleActions.class com/github/sqljam/impexp/DdlScripter$Catalog.class com/github/sqljam/jdbc/JdbcUtils$ObjectCursor.class com/github/sqljam/utils/Observer.class com/github/sqljam/config/Config.class com/github/sqljam/impexp/ExportListener.class +com/github/sqljam/impexp/ParquetImporter$1.class com/github/sqljam/impexp/ScriptExporter.class com/github/sqljam/impexp/Dialect.class com/github/sqljam/jdbc/PreparedStatementCallback.class diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst index 20c3d13..f514284 100644 --- a/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -15,6 +15,7 @@ /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/service/ConnectionStore.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/service/DatabaseSession.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/service/DdlGenerator.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/service/ExampleDatabase.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/service/JsonStore.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/service/SettingsStore.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/service/TransferService.java @@ -27,12 +28,15 @@ /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/DbNode.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/DbTypeCells.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/Dialogs.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/ExampleActions.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/ExportDialog.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/Icons.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/ImportPackageDialog.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/ImportParquetDialog.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/LoginView.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/MainView.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/Messages.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/PackageViewer.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/ProfileRegistry.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/ProgressDialog.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/SessionManager.java @@ -40,15 +44,19 @@ /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/TableTab.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/TaskRunner.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/face/view/ThemeManager.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/AbstractFileImporter.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/CatalogMetaData.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/ColumnMetaData.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/CombinedIndexMetaData.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/CommentMetaData.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/DataFileStrategy.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/DataFormat.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/DbCategory.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/DbType.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/DdlScripter.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/DefaultMetaDataVisitor.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/Dialect.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/DuckDBWorkspace.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/ExportCancelledException.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/ExportHandler.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/ExportListener.java @@ -67,6 +75,8 @@ /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/MetaData.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/MetaDataOperations.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/MetaDataVisitor.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/ParquetExporter.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/ParquetImporter.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/PartitionExpressionMetaData.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/PartitionMetaData.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/PartitionTableMetaData.java @@ -82,12 +92,19 @@ /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/SqlTextUtils.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/StringHelper.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/TableMetaData.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/TableQuery.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/TiedMetaData.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/TransactionIsolationLevel.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/TypeMappingException.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/TypeNames.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/db/ClickHouseDialect.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/db/ClickHouseMetaDataOperations.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/db/DuckDBDialect.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/db/DuckDBMetaDataOperations.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/db/H2Dialect.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/db/H2MetaDataOperations.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/db/MariaDBDialect.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/db/MariaDBMetaDataOperations.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/db/MySQL55Dialect.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/db/MySQL56Dialect.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/impexp/db/MySQL57Dialect.java @@ -111,6 +128,7 @@ /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/jdbc/DataAccessException.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/jdbc/JdbcUtils.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/jdbc/PreparedStatementCallback.java +/Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/jdbc/SharedConnectionFactory.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/jdbc/SimpleConnectionFactory.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/jdbc/page/MapBasedPageReader.java /Users/fred/work/repo/sqljam/src/main/java/com/github/sqljam/page/Countable.java