diff --git a/README.md b/README.md index 4510ea9..f32214b 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,22 @@ It copies tables (with indexes, constraints, sequences and partitions) **directl | Analytics teams want Parquet, not SQL | **Parquet packages** (ZSTD, Snappy, GZIP) through an embedded DuckDB, and **Parquet files of other tools** loaded into any table | | Spreadsheets and text tools want CSV | **CSV packages** with your delimiter, quote, NULL text, header, encoding (UTF-8 with BOM for Excel, GBK and more) and date format, read back with the exact column types kept in `manifest.json`, and **CSV files of other tools** with detected delimiter and types | | An import went wrong, now what? | **One transaction** by default: an error rolls back the whole import, or **commit every N rows** like other import tools. **Skip rows with errors** goes on and lists every skipped row in the log, which you can filter and save. **Confirm before commit** lets you review a successful import in one transaction, then **Commit** or **Roll Back** | +| Did every row arrive, and what happened when it stopped? | A **migration report** for every import and export, successful, failed, cancelled or rolled back: source, target and options, each table with its source rows, written and skipped rows, the rows counted in the target afterwards and a ✓ or ✗ against the written rows, the objects which were created, every skipped row with its error, where the transfer stopped and the error which ended it. It opens on its own page with **View Report**, and **Transfer → Import History** lists every report. Export one as Markdown, HTML or JSON | +| A copy has to stay up to date | **Sync Tasks** copy the rows which changed since the last run into another database, found by a column of the update time or an increasing key, every N minutes or every day at a time. The first run copies all rows, the watermark moves on only after the commit, errors of the connection are run again and errors of the data are not. SqlJam runs the tasks while it is open, or registers them with the **system scheduler** (crontab, Task Scheduler of Windows) so that they run when it is closed. Collections of vector databases sync the same way, and every run leaves a migration report | | Some rows exist already | **Update or insert by key** updates the rows of the same primary key, or of the columns you choose, and inserts the others. **Skip existing by key** keeps them and inserts the new rows only. Insert is the default | | Warehouses live apart from OLTP databases | **DuckDB and ClickHouse data sources**, grouped as OLAP databases next to the relational ones | | The rows come from a join or a report, not from one table | **SQL Query** editor with syntax highlighting: run the SQL, check the first rows, then export all rows of the query as a table into SQL, Parquet, CSV or another database | | 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 | +| Target columns are named or typed differently | **Map Columns…** renames a column, gives it a type of the target or leaves it out, for tables and for the fields of vector database collections. Keys, indexes and foreign keys follow the new names. Columns which are not changed are imported as they are | | 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, Parquet and CSV files | -| Custom and modern types | PostgreSQL enums and domains, MySQL, MariaDB and Oracle vectors, DuckDB unions and fixed arrays, ClickHouse variants, maps and tuples | +| Custom and modern types | PostgreSQL enums and domains, DuckDB unions and fixed arrays, ClickHouse variants, maps and tuples | +| Embeddings move with their search | Vectors of pgvector, MySQL, MariaDB, Oracle, SQL Server, Db2, DuckDB and ClickHouse keep their dimensions, and vector indexes (HNSW, IVF) keep their distances and options | +| Embeddings live in a vector database | **Vector databases** sit in the connection tree like the other data sources: Milvus, Qdrant, Weaviate, Pinecone, Elasticsearch, OpenSearch and Chroma, read by their REST APIs. Expand one to its databases, collections, partitions, tenants or namespaces, open a collection to browse its fields and records page by page, then **Import…** writes it straight into a database with vectors: ids and vectors become columns, every top-level field becomes a column of its type and the whole payload is kept as JSON. Conditions, native filters and nearest neighbor searches choose the rows | +| The database sits behind a bastion host | **SSH tunnel** per data source, with a password or a private key (ed25519, RSA, ECDSA, with a passphrase). Host keys are trusted on first use and remembered, a changed host key is refused. A broken tunnel opens again by itself | +| Which server is this, and is it well? | **Info** opens the overview (product, version, driver, session, features SqlJam detected) and the health of a data source or a vector database (uptime, connections, size, cluster state, load), refreshed when you ask. Its **Dashboard** draws live charts of the last 5 minutes, sampled every 1 to 30 seconds: sessions, transactions, statements, rows, cache hits, locks, network, CPU, memory and size, read from the statistics of each database. Embedded databases show the CPU and the memory of SqlJam, in which they run. Metrics which a server does not tell are listed with the reason | +| Long values and rows for a chat or a ticket | Cells show one line, a **double click** or **Shift+Enter** opens the whole value with JSON indented and vectors described. Select rows and **Copy Rows As** CSV, TSV, JSON or Markdown, or press **Ctrl+C** to paste them into a spreadsheet | +| Many data sources, one is needed | Data sources are grouped as **relational, OLAP and vector databases** with their logos, and the **filter** above the tree finds them by name or type | --- @@ -62,6 +71,9 @@ flowchart LR DS --> PE[ParquetExporter · CsvExporter
DuckDB workspace] --> PP[/Parquet or CSV package
schema.sql · data/*.parquet or *.csv · manifest.json/] P --> SI[ScriptImporter] --> TD PP --> PI[ParquetImporter · CsvImporter] --> TD + VS[(Vector DB
Milvus · Qdrant · Weaviate · Pinecone
Elasticsearch · OpenSearch · Chroma)] --> VC[VdbClient
REST API] + VC --> VT[Collection tree
collection · field · key · vector index] + VT --> VV[VdbMetaDataVisitor] --> VD[Vdb dialect
target with vectors] --> VI[VdbImportHandler] --> TD ``` | Component | Role | @@ -76,6 +88,8 @@ flowchart LR | `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) | +| `VdbClient` + `vdb.client.*` | REST clients of the vector databases: collections as tables, records read page by page, conditions, native filters and nearest neighbor searches | +| `VdbExporter` + `vdb.dialect.*` | Pipeline of the vector databases, its own line next to the JDBC one: a visitor of the collection tree, `VdbDdlScripter` and `VdbImportHandler` write into the target with vectors | | `face.*` | JavaFX UI (AtlantaFX themes, Ikonli icons) | --- @@ -91,19 +105,31 @@ Only **Java 17+** is needed to run SqlJam. JavaFX and all JDBC drivers are insid | macOS Intel | `sqljam--mac.jar` | `sqljam.sh` | ✅ | | Linux x64 (GTK 3) | `sqljam--linux.jar` | `sqljam.sh` | ✅ | -| Database | Tested on | Supported from | -|---|---|---| -| MySQL | 9.6 | 5.5 (`MySQL55/56/57Dialect`) | -| PostgreSQL | 16 | 9.x (`PostgreSQL9/10Dialect`) | -| Oracle | 23ai Free | 11g (`Oracle11g/12cDialect`) | -| SQL Server | 2022 | 2008 (`SQLServer2008Dialect`) | -| Db2 for Linux, UNIX and Windows | 12.1 | 11.1 | -| MariaDB | 11.8 | 10.3 | -| H2 | 2.3 | 2.x | -| SQLite | 3.46 | 3.x | -| Microsoft Access (`.accdb`, `.mdb`) | 2016 | 2000 | -| DuckDB (OLAP) | 1.5 | 1.x | -| ClickHouse (OLAP) | 26.9 | 24.x | +| Database | Tested on | Supported from | Vectors | +|---|---|---|---| +| MySQL | 9.7 | 5.5 (`MySQL55/56/57Dialect`) | `VECTOR` from 9.0 | +| PostgreSQL | 17 | 9.x (`PostgreSQL9/10Dialect`) | pgvector 0.4 `vector` and IVFFlat, HNSW from 0.5, `halfvec` and `sparsevec` from 0.7 | +| Oracle | 26ai Free | 11g (`Oracle11g/12cDialect`) | `VECTOR` from 23ai, sparse from 23.7, HNSW with vector memory, IVF | +| SQL Server | 2025 | 2008 (`SQLServer2008Dialect`) | `VECTOR` from 2025, `float16` too | +| Db2 for Linux, UNIX and Windows | 12.1.5 | 11.1 | `VECTOR` and vector indexes from 12.1.2 | +| MariaDB | 11.8 | 10.3 | `VECTOR` and `VECTOR INDEX` from 11.7 | +| H2 | 2.3 | 2.x | as text | +| SQLite | 3.46 | 3.x | as text | +| Microsoft Access (`.accdb`, `.mdb`) | 2016 | 2000 | as text | +| DuckDB (OLAP) | 1.5 | 1.x | `FLOAT[n]` | +| ClickHouse (OLAP) | 25.8 | 24.x | `Array(Float32)`, `vector_similarity` indexes | + +| Vector database (source only) | Tested on | Supported from | Read by | +|---|---|---|---| +| Milvus | 3.0 | 2.4 | REST API v2, dense, 16-bit, int8, binary and sparse vectors | +| Qdrant | 1.19 | 1.10 | scroll, named, sparse and multivectors | +| Weaviate | 1.39 | 1.24 | objects cursor and GraphQL, named vectors and tenants | +| Pinecone and Pinecone Local | API 2025-01 | API 2025-01 | list and fetch, namespaces, dense and sparse values | +| Elasticsearch | 8.19 | 8.0 | point in time, `dense_vector` | +| OpenSearch | 2.19 | 2.4 | point in time, `knn_vector` | +| Chroma | 1.5 | 1.0 | REST API v2, tenants and databases | + +Collections of vector databases go into the databases above which have vectors, never the other way round. Vector types and indexes are detected on the target before they are used, by a probe of each feature, so an older server, a missing extension or a setting which is turned off keeps the vectors as text. All JDBC drivers are bundled, no extra downloads. @@ -128,7 +154,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-sql.mp4 / sqljam-vectors.mp4 / sqljam-sync.mp4 # tutorial videos of about 2 minutes each ├── sqljam-1.0.0-SNAPSHOT-sources.jar └── sqljam-1.0.0-SNAPSHOT-javadoc.jar ``` @@ -139,18 +165,25 @@ 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**. 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. +1. SqlJam opens its main window with the data sources in the tree, where it was closed, and connects again the data sources which were connected then. Expand a data source to connect it. **+** on the toolbar creates one: choose its category first (relational, OLAP or vector databases), the types and the fields follow it. **Connect through SSH tunnel** reaches a database behind a bastion host. A password which is not saved is asked when the data source is connected, and asked again when the database refuses it. The first start brings an **Example Shop (H2)** data source with a few e-commerce tables to try everything right away. **Help → Restore Example Database** brings it back at any time. 2. Right-click a database or schema → **Export…** 3. Pick tables, choose **SQL scripts**, **Parquet** or **CSV** (folder) or **Database** (another data source, or a new one from the **+** button next to it), press **Start**. An import into a database runs in one transaction or commits every N rows (N is typed next to it), check **Skip rows with errors** to go on after bad rows and **Confirm before commit** to review it first, and choose **Update or insert by key** or **Skip existing by key** for rows which exist already. -4. Open a table, narrow its rows on the **Data** tab with columns, WHERE, GROUP BY and ORDER BY, then **Export…** just those rows. +4. Open a table, narrow its rows on the **Data** tab with columns, WHERE, GROUP BY and ORDER BY, then **Export…** just those rows. A header sorts the rows of the page, a double click shows a whole value and **Copy Rows As** copies the selected rows. **Info** on the toolbar or a right-click shows the overview and the health of the data source. 5. **SQL Query** on the toolbar or a right-click opens an editor for the data source. **Ctrl+Enter** (**⌘+Enter** on macOS) runs the statement at the caret, **Ctrl+Shift+Enter** runs them all, then **Export…** sends all rows of the query through the same exits. -6. 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. +6. Create a data source of the **Vector databases** category for Milvus, Qdrant, Weaviate, Pinecone, Elasticsearch, OpenSearch or Chroma, with an API key, a user and password or neither, as the database asks. Expand it in the tree to its databases, collections and partitions (tenants of Weaviate, namespaces of Pinecone), open a collection to see its fields and its records page by page, narrowed by conditions, a native filter or a nearest neighbor search. **Import…** on the collection tab, a right-click or **File → Import from Vector Database…** opens the wizard which imports the checked collections into a data source with vectors. Only such data sources are offered as targets. +7. 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. + +Three tutorial videos of about 2 minutes each walk through SqlJam: -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. +| Video | Shows | +|---|---| +| [SQL databases](docs/assets/sqljam-sql.mp4) | Query rows of the example database, export them into files, import the package into PostgreSQL, query with the SQL editor and copy the tables straight into SQL Server | +| [Vector databases](docs/assets/sqljam-vectors.mp4) | Browse a Qdrant collection page by page, narrow it by conditions and nearest neighbors, import it into PostgreSQL and open a tenant of Weaviate | +| [Scheduled sync and reports](docs/assets/sqljam-sync.mp4) | Create a sync task of the shop into PostgreSQL, copy all rows, then only the changed ones, read the reports and let the system scheduler run it | -| Login | Export wizard | Progress | +| Data source | Export wizard | Progress | |---|---|---| -| ![login](docs/assets/login.png) | ![export](docs/assets/export-wizard.png) | ![progress](docs/assets/progress.png) | +| ![data source](docs/assets/data-source.png) | ![export](docs/assets/export-wizard.png) | ![progress](docs/assets/progress.png) | | Data query | Package viewer | Parquet files | |---|---|---| @@ -160,6 +193,18 @@ A short [tutorial video](docs/assets/sqljam-tutorial.mp4) walks through the exam |---|---|---| | ![sql](docs/assets/sql-editor.png) | ![import](docs/assets/import-package.png) | ![light](docs/assets/main-light.png) | +| Vector database collection | Vector database import | Database info | +|---|---|---| +| ![collection](docs/assets/vdb-collection.png) | ![vdb import](docs/assets/import-vdb.png) | ![info](docs/assets/database-info.png) | + +| Migration report | Import history | Sync tasks | +|---|---|---| +| ![report](docs/assets/report.png) | ![history](docs/assets/import-history.png) | ![sync](docs/assets/sync-tasks.png) | + +| Dashboard | Column mapping | Feature map | +|---|---|---| +| ![dashboard](docs/assets/dashboard.png) | ![mapping](docs/assets/column-mapping.png) | ![features](docs/assets/feature-map.png) | + Expected export package: ```text @@ -266,6 +311,21 @@ source.getTableQueries().put("EMPLOYEES", new TableQuery(List.of("EMPLOYEE_ID", target.setTableNamePattern("{table}_copy"); ``` +A collection of a vector database goes into a target with vectors through the vdb pipeline: + +```java +try (VdbClient client = VdbType.QDRANT.createClient(new VdbSource(VdbType.QDRANT, "http://qdrant-host:6333"))) { + VdbExportConfiguration configuration = new VdbExportConfiguration(); + configuration.setTargetSchema("public"); + configuration.addCollection("docs", "docs"); // collection → table + configuration.setQuery(VdbQuery.all().where("lang = 'en'")); + configuration.setTableRecreated(true); + VdbExportResult result = new VdbExporter(client, pgDataSource, configuration).export(); +} +``` + +**Output**: table `public.docs` with `id`, `embedding vector(1024)`, a column of each payload field and `payload jsonb`, an HNSW index of the cosine distance of the collection, and the rows of `lang = 'en'`. + ### 3. Import an export package ```java @@ -307,7 +367,9 @@ try (ManualTransaction transaction = new ManualTransaction(connection, DbType.PO | Db2 `CHAR(4) FOR BIT DATA` | `bytea` | `RAW(4)` | `binary(4)` | `binary(4)` | | Db2 `XML` | `xml` | `CLOB` | `xml` | `longtext` | | 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 | +| MySQL / Oracle / SQL Server `VECTOR(1024)` | `vector(1024)` | `VECTOR(1024, FLOAT32)` | `VECTOR(1024)` | `VECTOR(1024)` | +| PostgreSQL `halfvec(2560)` / `sparsevec(8)` | same | `VECTOR(2560, FLOAT32)` / `VECTOR(8, FLOAT32, SPARSE)` | `VECTOR(2560, float16)` / `nvarchar(max)` | `VECTOR(2560)` / `longtext` | +| pgvector HNSW index, cosine, `m = 16` | same | HNSW index, cosine, `NEIGHBORS 16` (IVF without vector memory) | vectors only | vectors only | | 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` | | Access `CURRENCY` / `YESNO` / `OLE` | `numeric(19, 4)` / `boolean` / `bytea` | `NUMBER(19,4)` / `NUMBER(1)` / `BLOB` | `decimal(19,4)` / `bit` / `varbinary(max)` | `decimal(19,4)` / `tinyint(1)` / `longblob` | @@ -325,6 +387,8 @@ try (ManualTransaction transaction = new ManualTransaction(connection, DbType.PO | Keep **one transaction** for imports | An error rolls back everything, **Confirm before commit** shows the log and the row counts before the commit | | Load again with **Update or insert by key** | Kept tables are updated by their primary keys, nothing is dropped | | Shape a report in **SQL Query** first | The first rows show the columns and values, **Export…** then reads all rows by one cursor | +| Remember the passwords of the data sources of **sync tasks** | A run on a schedule has nobody to ask, a password which is not remembered fails the run | +| Read the **migration report** after a long import | The target rows are counted again after the end, a ✗ shows a table whose rows differ from the written rows | | Copy with a **target table name** | `{table}_copy` keeps the originals, a copy in the same schema without a new name is refused | --- @@ -343,6 +407,9 @@ All settings live in `sqljam.properties`, placed next to the runnable jar in `bi |---|---|---| | `sqljam.home` | `${user.home}/.sqljam` | Application data directory | | `sqljam.connections.file` | `${sqljam.home}/connections.json` | Saved data sources | +| `sqljam.vdb.enabled` | `true` | Shows the **Vector databases** category, its data sources and **File → Import from Vector Database…**, `false` hides them and loads nothing of the vector databases | +| `sqljam.vdb.connections.file` | `${sqljam.home}/vdb-connections.json` | Saved vector databases, apart from the data sources | +| `sqljam.ssh.known-hosts.file` | `${sqljam.home}/ssh_known_hosts` | Host keys of the SSH servers of tunnels, trusted on first use | | `sqljam.ui.theme` | `Primer Dark` | Primer/Nord/Cupertino Light & Dark, Dracula | | `sqljam.ui.window.width` / `height` | `1280` / `800` | Main window size | | `sqljam.banner.mode` | `console` | Startup banner with the version: `console`, `log` or `off` | @@ -358,6 +425,12 @@ All settings live in `sqljam.properties`, placed next to the runnable jar in `bi | `sqljam.export.csv-delimiter` | `,` | Delimiter of CSV files: `,` `;` `\|` or `\t` | | `sqljam.import.confirm-commit.reminder-minutes` | `10` | An import waiting for commit reminds you after these minutes, its transaction holds locks and log space (0 = off) | | `sqljam.export.data-file-strategy` | `SINGLE_FILE` | `SINGLE_FILE` or `FILE_PER_TABLE` | +| `sqljam.reports.dir` | `~/.sqljam/reports` | Folder of the migration reports, one JSON file a report | +| `sqljam.reports.keep` | `200` | Reports kept in the history, the oldest are deleted beyond it | +| `sqljam.sync.file` | `~/.sqljam/sync-tasks.json` | Sync tasks with their schedules and watermarks | +| `sqljam.sync.enabled` | `true` | The scheduler of SqlJam runs the sync tasks while it is open | +| `sqljam.sync.tick-seconds` | `30` | How often the scheduler of SqlJam looks for tasks which are due | +| `sqljam.sync.retry-delay-seconds` | `30` | First delay before a run after an error of the connection, it doubles with each attempt | | `sqljam.export.lob-separated` | `true` | Write LOBs as files | | `sqljam.pool.maximum-size` | `10` | HikariCP max pool size | | `sqljam.pool.minimum-idle` | `1` | HikariCP min idle connections | @@ -375,6 +448,17 @@ JVM options live in `sqljam.vmoptions` next to the runnable jar, one option per -Dfile.encoding=UTF-8 ``` +Sync tasks run from the command line too, which is what the system scheduler calls (`sqljam.bat` on Windows): + +| Command | Does | +|---|---| +| `sqljam.sh sync --list` | Lists the tasks with their schedules, next and last runs | +| `sqljam.sh sync --task NAME` | Runs a task now, by its name or id | +| `sqljam.sh sync --task NAME --if-due` | Runs it only when it is due | +| `sqljam.sh sync --due` | Runs every task which is due, one line of cron for all of them | + +The exit code is `0` when the runs completed, `1` when one failed or was cancelled, `2` for a wrong command. A task never runs twice at once, in SqlJam and on the command line. + Export options (UI / `ExportConfiguration`): | Option | Default | Description | @@ -389,6 +473,7 @@ Export options (UI / `ExportConfiguration`): | `writeMode` / `keyColumnNames` | `INSERT` | `UPSERT` updates the rows of the same key (the primary key or chosen columns) and inserts the others, `SKIP_EXISTING` keeps them and inserts the others | | `CsvOptions` | comma, `"`, UTF-8 | Delimiter, quote, NULL text, header, encoding and date format of CSV files | | `tableNamePattern` | | Target table name, `{table}` is the source name, e.g. `{table}_copy` | +| `columnMappings` | | Target name, type or no column of a source column, by table and column, e.g. `ORDERS.NOTE → REMARK TEXT`. Other columns are imported as they are | | `tableQueries` | | Columns, WHERE and ORDER BY per table, e.g. rows narrowed in the data viewer | | `queries` | | Free queries exported as tables, SQL by table name, e.g. the query of the SQL editor | @@ -400,20 +485,28 @@ Export options (UI / `ExportConfiguration`): | Scenario (200,000 rows × 6 columns) | Time | Rows/s | |---|---|---| -| H2 → Oracle 23ai | 2.1 s | 95.3k | -| H2 → export package (PostgreSQL, 32 MB, 4 files) | 2.9 s | 69.4k | -| H2 → PostgreSQL 16 | 3.2 s | 62.4k | -| H2 → MySQL 9.6 | 3.3 s | 60.7k | -| H2 → SQL Server 2022 | 5.0 s | 40.1k | -| Export package → PostgreSQL | 4.5 s | 44.5k | - -Environment: Apple M2 Max, 32 GB, JDK 17. MySQL/PostgreSQL local, Oracle/SQL Server in Docker. Default settings. +| H2 → MySQL | 10.3 s | 19.5k | +| H2 → MariaDB | 9.5 s | 21.1k | +| H2 → PostgreSQL | 6.4 s | 31.3k | +| H2 → Oracle | 4.2 s | 47.9k | +| H2 → SQL Server | 12.0 s | 16.7k | +| H2 → Db2 | 9.3 s | 21.6k | +| H2 → ClickHouse | 4.2 s | 47.4k | +| H2 → DuckDB | 5.7 s | 35.1k | +| H2 → SQL package | 2.6 s | 76.1k | +| H2 → Parquet package | 5.7 s | 35.4k | +| H2 → CSV package | 5.8 s | 34.7k | +| SQL package → PostgreSQL | 24.3 s | 8.2k | +| Qdrant → PostgreSQL (20,000 points, 256-d vectors and payload) | 18.4 s | 1.1k | + +Environment: Apple M2 Max, 32 GB, JDK 17. The databases run in Docker on a server of the local network (about 100 Mbit/s), H2, DuckDB and the packages are local. Default settings. Each scenario is measured by `TransferBenchmark`. | Design choice | Benefit | |---|---| | Rows paged by primary key | Stable, deterministic reads on every database | | Tables copied one by one | Predictable load on production servers | -| Packages contain plain SQL | Readable, editable and portable. Direct import uses batched inserts for maximum speed | +| Packages contain plain SQL, Parquet or CSV | Readable, editable and portable, or columnar for analytics | +| Writes sized for each database | Batched inserts, rewritten into multi-row statements for PostgreSQL and MySQL, several rows a statement for DuckDB, with the same value conversions everywhere | --- @@ -443,8 +536,12 @@ Environment: Apple M2 Max, 32 GB, JDK 17. MySQL/PostgreSQL local, Oracle/SQL Ser | What does a roll back undo? | Everything on PostgreSQL, SQL Server, Db2, SQLite and DuckDB, tables included. MySQL, MariaDB, Oracle, H2 and Access commit DDL at once, there the created tables stay empty and keys and identity values are set after the commit | | Can I export only the structure? | Yes. Rows are optional. Pick content **Structure** (`ExportMode.DDL`) for a package or a direct import. Tables, keys, indexes, comments, sequences and partitions are created without rows | | Are partitioned tables kept? | Yes. The same database type creates the partitioning again with its partitions, bounds and rows: range, list, hash, key and subpartitions of MySQL and MariaDB (system time too), range, list, hash, default and nested partitions of PostgreSQL, range, list, hash, interval, automatic list, composite and reference partitions of Oracle, partition functions of SQL Server, range partitions of Db2 and partition keys of ClickHouse. Other databases get the tables with all rows. In the tree a partitioned table shows its partitioning next to its name and PostgreSQL partitions are listed under their table | +| How are vectors copied? | As vectors of the target with their dimensions: `vector(n)` of pgvector, `VECTOR(n)` of MySQL, MariaDB, SQL Server and Db2, `VECTOR(n, FLOAT32)` of Oracle, `FLOAT[n]` of DuckDB and `Array(Float32)` of ClickHouse. A vector index becomes the vector index of the target with the same distance. Vectors which the target can not keep exactly, e.g. doubles into pgvector, stay text and keep every digit | | 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 | +| Can I keep the password out of disk? | Uncheck *Remember password* in the data source, or *Save* when SqlJam asks for it. It stays in memory for the session, and so does the password or passphrase of the SSH tunnel | +| Can I rename columns or change their types? | Yes. **Map Columns…** next to the target data source renames a column, writes the type you type as it is, or leaves the column out. Keys, indexes, foreign keys and identity values follow the new names. In code, set `columnMappings` of the import configuration | +| Can I reach a database behind a bastion host? | Yes. Check **Connect through SSH tunnel** and enter the SSH server, its user and a password or a private key. The host and port of the data source are those which the SSH server reaches, e.g. `localhost:5432`. Exports, imports, queries and Info all go through the tunnel | +| Can I paste rows into a spreadsheet or a ticket? | Yes. Select rows of any data table and press **Ctrl+C** (**⌘+C** on macOS) for tab separated values, **Ctrl+Shift+C** with the headers, or right-click **Copy Rows As** CSV, TSV, JSON or Markdown | | How do I run the tests? | `mvn verify`, integration tests use local databases and are skipped when unreachable | --- diff --git a/docs/assets/benchmark.png b/docs/assets/benchmark.png index af7ec7f..1de41cc 100644 Binary files a/docs/assets/benchmark.png and b/docs/assets/benchmark.png differ diff --git a/docs/assets/benchmark.svg b/docs/assets/benchmark.svg index ed3a9ae..f40a8dd 100644 --- a/docs/assets/benchmark.svg +++ b/docs/assets/benchmark.svg @@ -1,32 +1,52 @@ - - + + Throughput · 200,000 rows, 6 columns - Apple M2 Max · 32 GB · databases local or in Docker · default settings · higher is better - - - - - 020k40k - 60k80k100k rows/s - - - - H2 → Oracle - 95.3k - H2 → export package - 69.4k - H2 → PostgreSQL - 62.4k - H2 → MySQL - 60.7k - package → PostgreSQL - 44.5k - H2 → SQL Server - 40.1k - - - import directly - export package (32 MB, split into 4 files of 10 MB) - import package (batched) - + Apple M2 Max · JDK 17 · databases in Docker on a LAN server · default settings · higher is better + + 0 + + 20k + + 40k + + 60k + + 80k rows/s + + H2 → MySQL + + 19.5k + H2 → MariaDB + + 21.1k + H2 → PostgreSQL + + 31.3k + H2 → Oracle + + 47.9k + H2 → SQL Server + + 16.7k + H2 → Db2 + + 21.6k + H2 → ClickHouse + + 47.4k + H2 → DuckDB + + 35.1k + H2 → SQL package + + 76.1k + H2 → Parquet package + + 35.4k + H2 → CSV package + + 34.7k + SQL package → PostgreSQL + + 8.2k diff --git a/docs/assets/column-mapping.png b/docs/assets/column-mapping.png new file mode 100644 index 0000000..ef8dc16 Binary files /dev/null and b/docs/assets/column-mapping.png differ diff --git a/docs/assets/dashboard.png b/docs/assets/dashboard.png new file mode 100644 index 0000000..9029c55 Binary files /dev/null and b/docs/assets/dashboard.png differ diff --git a/docs/assets/data-source.png b/docs/assets/data-source.png index c8720e2..f3b3439 100644 Binary files a/docs/assets/data-source.png and b/docs/assets/data-source.png differ diff --git a/docs/assets/database-info.png b/docs/assets/database-info.png new file mode 100644 index 0000000..4cb4ae1 Binary files /dev/null and b/docs/assets/database-info.png differ diff --git a/docs/assets/export-wizard.png b/docs/assets/export-wizard.png index 9398b8b..0cff2d4 100644 Binary files a/docs/assets/export-wizard.png and b/docs/assets/export-wizard.png differ diff --git a/docs/assets/feature-map.png b/docs/assets/feature-map.png new file mode 100644 index 0000000..1284b38 Binary files /dev/null and b/docs/assets/feature-map.png differ diff --git a/docs/assets/import-history.png b/docs/assets/import-history.png new file mode 100644 index 0000000..fd74d08 Binary files /dev/null and b/docs/assets/import-history.png differ diff --git a/docs/assets/import-package.png b/docs/assets/import-package.png index 66bb04c..98a73ad 100644 Binary files a/docs/assets/import-package.png and b/docs/assets/import-package.png differ diff --git a/docs/assets/import-parquet.png b/docs/assets/import-parquet.png index 90cdcd3..0a74031 100644 Binary files a/docs/assets/import-parquet.png and b/docs/assets/import-parquet.png differ diff --git a/docs/assets/import-vdb.png b/docs/assets/import-vdb.png new file mode 100644 index 0000000..c6c3df0 Binary files /dev/null and b/docs/assets/import-vdb.png differ diff --git a/docs/assets/main-dark.png b/docs/assets/main-dark.png index d271ebb..ff22c60 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 5a789b2..52991b4 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 ef6db9e..bdf9b3d 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 index e354f45..6e5e160 100644 Binary files a/docs/assets/package-viewer.png and b/docs/assets/package-viewer.png differ diff --git a/docs/assets/report.png b/docs/assets/report.png new file mode 100644 index 0000000..53bdfcc Binary files /dev/null and b/docs/assets/report.png differ diff --git a/docs/assets/splash.png b/docs/assets/splash.png index 2653bd2..f1296aa 100644 Binary files a/docs/assets/splash.png and b/docs/assets/splash.png differ diff --git a/docs/assets/sql-editor.png b/docs/assets/sql-editor.png index 7e1ce43..8794b64 100644 Binary files a/docs/assets/sql-editor.png and b/docs/assets/sql-editor.png differ diff --git a/docs/assets/sqljam-sql.mp4 b/docs/assets/sqljam-sql.mp4 new file mode 100644 index 0000000..1d47d05 Binary files /dev/null and b/docs/assets/sqljam-sql.mp4 differ diff --git a/docs/assets/sqljam-sync.mp4 b/docs/assets/sqljam-sync.mp4 new file mode 100644 index 0000000..a1dbcb0 Binary files /dev/null and b/docs/assets/sqljam-sync.mp4 differ diff --git a/docs/assets/sqljam-tutorial.mp4 b/docs/assets/sqljam-tutorial.mp4 deleted file mode 100644 index 9ac87ac..0000000 Binary files a/docs/assets/sqljam-tutorial.mp4 and /dev/null differ diff --git a/docs/assets/sqljam-vectors.mp4 b/docs/assets/sqljam-vectors.mp4 new file mode 100644 index 0000000..797207b Binary files /dev/null and b/docs/assets/sqljam-vectors.mp4 differ diff --git a/docs/assets/sync-tasks.png b/docs/assets/sync-tasks.png new file mode 100644 index 0000000..c557a35 Binary files /dev/null and b/docs/assets/sync-tasks.png differ diff --git a/docs/assets/table-data.png b/docs/assets/table-data.png index 58a9339..80dbd5f 100644 Binary files a/docs/assets/table-data.png and b/docs/assets/table-data.png differ diff --git a/docs/assets/vdb-collection.png b/docs/assets/vdb-collection.png new file mode 100644 index 0000000..353e60f Binary files /dev/null and b/docs/assets/vdb-collection.png differ diff --git a/docs/blogger/sqljam-en.md b/docs/blogger/sqljam-en.md index 251490f..836e4ee 100644 --- a/docs/blogger/sqljam-en.md +++ b/docs/blogger/sqljam-en.md @@ -30,12 +30,21 @@ SqlJam reads **one metadata tree** and generates SQL with a **dialect chosen by | Analytics needs Parquet | Parquet packages through an embedded DuckDB, Parquet files of other tools loaded into any table | | Spreadsheets need CSV | CSV packages with your delimiter, quote, NULL text, header, encoding (UTF-8 with BOM, GBK and more) and date format, exact types kept in `manifest.json`, CSV files of other tools loaded too | | An import went wrong | **One transaction** by default, rolled back on any error, or **commit every N rows**. **Skip rows with errors** goes on and logs each skipped row. **Confirm before commit** reviews a successful import first | +| Did every row arrive? | A **migration report** for every import and export, also when it fails or is cancelled: rows per table, skipped rows with their errors, target rows counted afterwards, created objects and where it stopped. **Import History** lists every report, Markdown, HTML or JSON on export | +| A copy has to stay up to date | **Sync Tasks** copy the changed rows by an update time or an increasing key, every N minutes or every day, run by SqlJam or by the system scheduler when it is closed, vector collections included, a report for every run | | Rows exist already | **Update or insert by key** or **skip existing by key**: the primary key or the columns of a unique index match the rows, insert is the default | | Warehouses apart from OLTP | DuckDB and ClickHouse data sources, grouped as OLAP databases | | Only part of a table | Data viewer query (columns, WHERE, GROUP BY, ORDER BY) and export of the rows of the query | | Rows of a join or a report | **SQL Query** editor with syntax highlighting, the first rows are shown and all rows of the query are exported as a table | +| Columns named or typed differently | **Map Columns…** renames a column, gives it a type of the target or leaves it out, for tables and collections. Keys, indexes and foreign keys follow the new names | | 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 | +| Custom and modern types | PostgreSQL enums and domains, unions, maps, tuples and variants | +| Embeddings move with their search | Vectors of pgvector, MySQL, MariaDB, Oracle, SQL Server, Db2, DuckDB and ClickHouse keep their dimensions, vector indexes keep their distances and options | +| Embeddings live in a vector database | Milvus, Qdrant, Weaviate, Pinecone, Elasticsearch, OpenSearch and Chroma sit in the tree as **vector databases**: browse their databases, collections, partitions, tenants and namespaces, page through the records, then import collections straight into a database with vectors: ids, vectors, a column of each field and the whole payload as JSON | +| Database behind a bastion host | **SSH tunnel** per data source with a password or a private key, host keys trusted on first use, a broken tunnel opens again by itself | +| Which server, how healthy? | **Info** shows the overview and the health of a data source or a vector database, and a **Dashboard** of live charts: sessions, transactions, rows, cache, locks, network, CPU and memory | +| Long values, rows for a ticket | One line per cell, a double click shows the whole value, **Copy Rows As** CSV, TSV, JSON or Markdown | +| Many data sources | Grouped as relational, OLAP and vector databases with their logos, a **filter** above the tree | ## 3. Quick Start @@ -55,7 +64,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-sql.mp4 / sqljam-vectors.mp4 / sqljam-sync.mp4 # tutorial videos of about 2 minutes each ``` `sqljam.properties` and `sqljam.vmoptions` sit next to the runnable jar. Edit them and restart, no rebuild needed. @@ -64,7 +73,7 @@ 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. +SqlJam opens its main window like DBeaver and DataGrip, where it was closed, with the data sources in the tree, and connects again those which were connected then. A password which is not saved is asked when a data source is connected. 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. **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. @@ -72,16 +81,34 @@ Right-click a data source to **Connect**, and once it is connected the same menu ![SQL Query](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/sql-editor.png) -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. +A new data source starts with its category: relational, OLAP or vector databases, and the types and fields follow it. **Connect through SSH tunnel** reaches a database behind a bastion host with a password or a private key. -| 1. Log in to a data source | 2. Export wizard | 3. Progress | +A vector database is a data source of the **Vector databases** category, with an API key, a user and password or neither. Expand it in the tree to its databases, collections and partitions (tenants of Weaviate, namespaces of Pinecone) and open a collection: its fields, and its records page by page, narrowed by conditions, a native filter or a nearest neighbor search. **Import…** opens the wizard which writes the checked collections into a data source with vectors. Only such data sources are offered as targets. + +Every data table shows one line per cell. A double click or **Shift+Enter** opens the whole value with JSON indented and vectors described, a header sorts the rows of the page, and **Copy Rows As** copies the selected rows as CSV, TSV, JSON or Markdown. **Info** on the toolbar shows the server, the driver, the session and the health of the selected data source. + +Three tutorial videos of about 2 minutes each walk through SqlJam: [SQL databases](https://github.com/paganini2008/sqljam/blob/master/docs/assets/sqljam-sql.mp4) (query, export into files, import into PostgreSQL, the SQL editor, a copy into SQL Server), [vector databases](https://github.com/paganini2008/sqljam/blob/master/docs/assets/sqljam-vectors.mp4) (a Qdrant collection, its import into PostgreSQL, tenants of Weaviate) and [scheduled sync and reports](https://github.com/paganini2008/sqljam/blob/master/docs/assets/sqljam-sync.mp4) (a sync task of the shop into PostgreSQL, its reports and the system scheduler). + +| 1. 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) | +| ![data source](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/data-source.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) | +| 7. Vector database collection | 8. Vector database import | 9. Database info | +|---|---|---| +| ![collection](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/vdb-collection.png) | ![vdb import](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/import-vdb.png) | ![info](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/database-info.png) | + +| 10. Migration report | 11. Import history | 12. Sync tasks | +|---|---|---| +| ![report](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/report.png) | ![history](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/import-history.png) | ![sync](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/sync-tasks.png) | + +| 13. Dashboard | 14. Column mapping | 15. Feature map | +|---|---|---| +| ![dashboard](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/dashboard.png) | ![mapping](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/column-mapping.png) | ![features](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/feature-map.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`). @@ -96,15 +123,25 @@ Only **Java 17+** is needed to run SqlJam. JavaFX and the JDBC drivers are insid | Database | Version | |---|---| | MySQL | 5.5 to 9.x | -| PostgreSQL | 9.x to 16 | +| PostgreSQL | 9.x to 17 | | Oracle | 11g to 23ai | -| SQL Server | 2008 to 2022 | +| SQL Server | 2008 to 2025 | | MariaDB | 10.3 to 11.x | | H2 / SQLite | 2.x / 3.x | | Microsoft Access (.accdb, .mdb) | 2000 to 2019 | | DuckDB (OLAP) | 1.x | | ClickHouse (OLAP) | 24.x to 26.x | +| Vector database (source only) | Version | +|---|---| +| Milvus | 2.4 to 3.x | +| Qdrant | 1.10 to 1.19 | +| Weaviate | 1.24 to 1.39 | +| Pinecone and Pinecone Local | API 2025-01 | +| Elasticsearch | 8.0 to 8.19 | +| OpenSearch | 2.4 to 2.19 | +| Chroma | 1.0 to 1.5 | + All JDBC drivers are bundled in the fat jar. ## 5. How It Works @@ -120,6 +157,7 @@ flowchart LR E --> SI[ScriptImporter] --> TD D --> P[Parquet or CSV package
DuckDB workspace · data/*.parquet or *.csv] P --> PI[ParquetImporter · CsvImporter] --> TD + V[(Vector DB)] --> VC[VdbClient · REST API] --> VT[Collection tree] --> VD[Vdb dialect] --> TD ``` - **Read metadata once**: `XxxMetaDataOperations` completes comments, identity, generated columns, partitions and sequences per database. @@ -129,6 +167,7 @@ flowchart LR - **Transactions**: an import runs in one transaction, committed when it succeeds or after your confirmation. MySQL, MariaDB, Oracle and H2 commit around DDL, so keys and identity values are set after the commit. - **Upserts and skips**: `ON CONFLICT`, `ON DUPLICATE KEY UPDATE` or `MERGE` of each database, kept tables, indexes and keys are skipped. - **Skipped errors**: a failed page is written row by row under savepoints, each bad row is logged with its key and the others are kept. +- **Vector databases**: a line of its own next to the JDBC one. REST clients read the collections, a visitor of the collection tree builds the tables, and `VdbImportHandler` writes the pages into the target, whose vector features are probed first. ## 6. Code Examples @@ -205,9 +244,24 @@ target.setTableNamePattern("{table}_copy"); // EMPLOYEES_COPY next to EMPL | 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)` | +| MySQL / Oracle / SQL Server `VECTOR(1024)` | `vector(1024)` | `VECTOR(1024, FLOAT32)` | `VECTOR(1024)` | +| PostgreSQL `halfvec(2560)` / `sparsevec(8)` | same | `VECTOR(2560, FLOAT32)` / `VECTOR(8, FLOAT32, SPARSE)` | `VECTOR(2560, float16)` / `nvarchar(max)` | | ClickHouse `Array(T)` / `Map(K, V)` | `text` | `CLOB` | `nvarchar(max)` | +A collection of a vector database goes straight into a target with vectors: + +```java +try (VdbClient client = VdbType.QDRANT.createClient(new VdbSource(VdbType.QDRANT, "http://qdrant-host:6333"))) { + VdbExportConfiguration configuration = new VdbExportConfiguration(); + configuration.setTargetSchema("public"); + configuration.addCollection("docs", "docs"); + configuration.setQuery(VdbQuery.all().where("lang = 'en'")); + new VdbExporter(client, pgDataSource, configuration).export(); +} +``` + +**Output**: `public.docs` with `id`, `embedding vector(1024)`, a column of each payload field and `payload jsonb`, and an HNSW index of the cosine distance of the collection. + ## 7. Configuration All settings live in `bin/sqljam.properties` next to the runnable jar. Settings changed in the UI are saved to `~/.sqljam/sqljam.properties` and win over the file. @@ -226,6 +280,9 @@ All settings live in `bin/sqljam.properties` next to the runnable jar. Settings | `sqljam.import.batch-size` | `1000` | INSERT statements per batch when importing a package | | `sqljam.ui.theme` | `Primer Dark` | 7 themes | | `sqljam.pool.maximum-size` | `10` | Connection pool size | +| `sqljam.vdb.enabled` | `true` | Shows the vector databases, `false` hides them | +| `sqljam.vdb.connections.file` | `${sqljam.home}/vdb-connections.json` | Saved vector databases | +| `sqljam.ssh.known-hosts.file` | `${sqljam.home}/ssh_known_hosts` | Host keys of the SSH servers of tunnels | ## 8. Performance @@ -233,14 +290,21 @@ All settings live in `bin/sqljam.properties` next to the runnable jar. Settings | Scenario (200,000 rows × 6 columns) | Time | Rows/s | |---|---|---| -| H2 → Oracle 23ai | 2.1 s | 95.3k | -| H2 → export package (32 MB, 4 files) | 2.9 s | 69.4k | -| H2 → PostgreSQL 16 | 3.2 s | 62.4k | -| H2 → MySQL 9.6 | 3.3 s | 60.7k | -| H2 → SQL Server 2022 | 5.0 s | 40.1k | -| Export package → PostgreSQL | 4.5 s | 44.5k | - -Environment: Apple M2 Max, 32 GB, JDK 17. MySQL/PostgreSQL local, Oracle/SQL Server in Docker. Default settings. +| H2 → MySQL | 10.3 s | 19.5k | +| H2 → MariaDB | 9.5 s | 21.1k | +| H2 → PostgreSQL | 6.4 s | 31.3k | +| H2 → Oracle | 4.2 s | 47.9k | +| H2 → SQL Server | 12.0 s | 16.7k | +| H2 → Db2 | 9.3 s | 21.6k | +| H2 → ClickHouse | 4.2 s | 47.4k | +| H2 → DuckDB | 5.7 s | 35.1k | +| H2 → SQL package | 2.6 s | 76.1k | +| H2 → Parquet package | 5.7 s | 35.4k | +| H2 → CSV package | 5.8 s | 34.7k | +| SQL package → PostgreSQL | 24.3 s | 8.2k | +| Qdrant → PostgreSQL (20,000 points, 256-d vectors and payload) | 18.4 s | 1.1k | + +Environment: Apple M2 Max, 32 GB, JDK 17. The databases run in Docker on a server of the local network (about 100 Mbit/s), H2, DuckDB and the packages are local. Default settings. Each scenario is measured by `TransferBenchmark`. **Quality**: a full cross-database import matrix with every column type of every database, SQL, Parquet and CSV package round trips, commit and roll back verified on every database, copies in the same schema, old-version SQL executed on real servers, UI and boundary tests. @@ -254,6 +318,7 @@ Environment: Apple M2 Max, 32 GB, JDK 17. MySQL/PostgreSQL local, Oracle/SQL Ser - **Parquet and CSV through DuckDB**: one embedded engine writes and reads both, no Hadoop libraries. - **One transaction by default**: an import is all or nothing, confirm before commit when you want to look first. - **Grouped rows are for viewing**: exports carry real table rows, a GROUP BY result is a view of them. +- **Vector databases are sources**: their collections go into a relational database with vectors, which stays the end point of every copy. - **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 @@ -268,4 +333,5 @@ Environment: Apple M2 Max, 32 GB, JDK 17. MySQL/PostgreSQL local, Oracle/SQL Ser 8. A mainstream dark UI with 7 themes, percentage progress and cancel. 9. Parquet and CSV packages and files of other tools, DuckDB and ClickHouse as OLAP data sources, imports in one transaction and rows updated by key. 10. A SQL Query editor exports the rows of any query through the same exits, columns of tables keep their types. -10. Query the rows of a table, export just those rows, or copy tables inside the same schema. +11. Query the rows of a table, export just those rows, or copy tables inside the same schema. +12. Collections of Milvus, Qdrant, Weaviate, Pinecone, Elasticsearch, OpenSearch and Chroma go straight into a database with vectors, previewed by conditions, native filters or nearest neighbor searches. diff --git a/docs/blogger/sqljam-medium.md b/docs/blogger/sqljam-medium.md index 3ee5986..3ffd8d3 100644 --- a/docs/blogger/sqljam-medium.md +++ b/docs/blogger/sqljam-medium.md @@ -27,12 +27,21 @@ SqlJam reads one metadata tree and generates SQL with a dialect chosen by target - **Analytics needs Parquet** → Parquet packages through an embedded DuckDB, Parquet files of other tools loaded into any table - **Spreadsheets need CSV** → CSV packages with your delimiter, quote, NULL text, header, encoding and date format, exact types kept in manifest.json, CSV files of other tools loaded too - **An import went wrong** → one transaction by default, rolled back on any error, or commit every N rows. Skip rows with errors goes on and logs each skipped row. Confirm before commit reviews a successful import first +- **Did every row arrive?** → a migration report for every import and export, also when it fails or is cancelled: rows per table, skipped rows with their errors, target rows counted afterwards, created objects and where it stopped. Import History lists every report, Markdown, HTML or JSON on export +- **A copy has to stay up to date** → Sync Tasks copy the changed rows by an update time or an increasing key, every N minutes or every day, run by SqlJam or by the system scheduler when it is closed, vector collections included, a report for every run - **Rows exist already** → update or insert by key, or skip existing by key: the primary key or the columns of a unique index match the rows - **Warehouses apart from OLTP** → DuckDB and ClickHouse data sources, grouped as OLAP databases - **Only part of a table** → a data viewer query (columns, WHERE, GROUP BY, ORDER BY) and export of its rows - **Rows of a join or a report** → the SQL Query editor with syntax highlighting shows the first rows and exports all rows of the query as a table +- **Columns named or typed differently** → Map Columns… renames a column, gives it a type of the target or leaves it out, for tables and collections, and keys, indexes and foreign keys follow the new names - **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 +- **Custom and modern types** → PostgreSQL enums and domains, unions, maps, tuples and variants +- **Embeddings move with their search** → vectors of pgvector, MySQL, MariaDB, Oracle, SQL Server, Db2, DuckDB and ClickHouse keep their dimensions, vector indexes keep their distances and options +- **Embeddings live in a vector database** → Milvus, Qdrant, Weaviate, Pinecone, Elasticsearch, OpenSearch and Chroma sit in the tree as vector databases. Browse their databases, collections, partitions, tenants and namespaces, page through the records, then import collections straight into a database with vectors: ids, vectors, a column of each field and the whole payload as JSON +- **Database behind a bastion host** → an SSH tunnel per data source with a password or a private key, host keys trusted on first use, a broken tunnel opens again by itself +- **Which server, how healthy?** → Info shows the overview and the health of a data source or a vector database, and a Dashboard of live charts: sessions, transactions, rows, cache, locks, network, CPU and memory +- **Long values, rows for a ticket** → one line per cell, a double click shows the whole value, Copy Rows As CSV, TSV, JSON or Markdown +- **Many data sources** → grouped as relational, OLAP and vector databases with their logos, a filter above the tree ## Quick Start @@ -48,11 +57,11 @@ 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) -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 +Three tutorial videos of about 2 minutes each walk through SqlJam: SQL databases https://github.com/paganini2008/sqljam/blob/master/docs/assets/sqljam-sql.mp4, vector databases https://github.com/paganini2008/sqljam/blob/master/docs/assets/sqljam-vectors.mp4, and scheduled sync with reports https://github.com/paganini2008/sqljam/blob/master/docs/assets/sqljam-sync.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: +Step 1. SqlJam opens its main window with the data sources in the tree and connects again those which were connected when it was closed. Expand a data source to connect it, + on the toolbar creates one. The first start brings an Example Shop (H2) data source with a few e-commerce tables to try everything right away. Help → Restore Example Database brings it back at any time: -![Login](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/login.png) +![Data source](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/data-source.png) 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: @@ -78,6 +87,26 @@ Step 7. load Parquet files of other tools into a table: ![Parquet files](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/import-parquet.png) +Step 8. Create a data source of the Vector databases category, with an API key, a user and password or neither. Expand it in the tree to its databases, collections and partitions, open a collection to page through its records, narrowed by conditions, a native filter or a nearest neighbor search, and Import… writes the checked collections into a data source with vectors. Only such data sources are offered as targets. + +![Vector database collection](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/vdb-collection.png) + +Step 9. A double click on a cell shows its whole value, Copy Rows As copies the selected rows as CSV, TSV, JSON or Markdown, and Info shows the server and its health. Connect through SSH tunnel in the data source form reaches a database behind a bastion host. + +![Database info](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/database-info.png) + +![Migration report](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/report.png) + +![Import history](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/import-history.png) + +![Sync tasks](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/sync-tasks.png) + +![Dashboard](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/dashboard.png) + +![Column mapping](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/column-mapping.png) + +![Feature map](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/feature-map.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. @@ -92,15 +121,25 @@ Platforms: Databases: - MySQL 5.5 to 9.x -- PostgreSQL 9.x to 16 +- PostgreSQL 9.x to 17 - Oracle 11g to 23ai -- SQL Server 2008 to 2022 +- SQL Server 2008 to 2025 - MariaDB 10.3 to 11.x - H2 2.x, SQLite 3.x - Microsoft Access 2000 to 2019 (.accdb and .mdb files) - DuckDB 1.x and ClickHouse 24.x to 26.x (OLAP) - All JDBC drivers are bundled +Vector databases (sources only): + +- Milvus 2.4 to 3.x +- Qdrant 1.10 to 1.19 +- Weaviate 1.24 to 1.39 +- Pinecone and Pinecone Local, API 2025-01 +- Elasticsearch 8.0 to 8.19 +- OpenSearch 2.4 to 2.19 +- Chroma 1.0 to 1.5 + ## How It Works ![How SqlJam works](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/architecture.png) @@ -112,6 +151,7 @@ Databases: - **Transactions**: an import runs in one transaction, committed when it succeeds or after your confirmation. MySQL, MariaDB, Oracle and H2 commit around DDL, so keys and identity values are set after the commit. - **Upserts and skips**: ON CONFLICT, ON DUPLICATE KEY UPDATE or MERGE of each database, kept tables, indexes and keys are skipped. - **Skipped errors**: a failed page is written row by row under savepoints, each bad row is logged with its key and the others are kept. +- **Vector databases**: a line of its own next to the JDBC one. REST clients read the collections, a visitor of the collection tree builds the tables, and VdbImportHandler writes the pages into the target, whose vector features are probed first. ## Code Examples @@ -191,9 +231,24 @@ Type translation examples: - 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 +- MySQL / Oracle / SQL Server VECTOR(1024) → PostgreSQL vector(1024) · Oracle VECTOR(1024, FLOAT32) · SQL Server VECTOR(1024) +- PostgreSQL halfvec(2560) → Oracle VECTOR(2560, FLOAT32) · SQL Server VECTOR(2560, float16) · MySQL VECTOR(2560) - ClickHouse Array(T) / Map(K, V) → PostgreSQL text · Oracle CLOB · SQL Server nvarchar(max) +A collection of a vector database goes straight into a target with vectors: + +```java +try (VdbClient client = VdbType.QDRANT.createClient(new VdbSource(VdbType.QDRANT, "http://qdrant-host:6333"))) { + VdbExportConfiguration configuration = new VdbExportConfiguration(); + configuration.setTargetSchema("public"); + configuration.addCollection("docs", "docs"); + configuration.setQuery(VdbQuery.all().where("lang = 'en'")); + new VdbExporter(client, pgDataSource, configuration).export(); +} +``` + +Output: public.docs with id, embedding vector(1024), a column of each payload field and payload jsonb, and an HNSW index of the cosine distance of the collection. + ## Configuration All settings live in bin/sqljam.properties next to the runnable jar. Settings changed in the UI are saved to ~/.sqljam/sqljam.properties and win over the file. @@ -209,19 +264,29 @@ All settings live in bin/sqljam.properties next to the runnable jar. Settings ch - **sqljam.import.batch-size** = 1000, INSERT statements per batch when importing a package - **sqljam.ui.theme** = Primer Dark, 7 themes - **sqljam.pool.maximum-size** = 10, connection pool size +- **sqljam.vdb.enabled** = true, shows the vector databases, false hides them +- **sqljam.vdb.connections.file** = ${sqljam.home}/vdb-connections.json, saved vector databases +- **sqljam.ssh.known-hosts.file** = ${sqljam.home}/ssh_known_hosts, host keys of the SSH servers of tunnels ## Performance ![Benchmark](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/benchmark.png) -200,000 rows × 6 columns, Apple M2 Max, 32 GB, JDK 17 (MySQL/PostgreSQL local, Oracle/SQL Server in Docker, default settings): - -- H2 → Oracle 23ai: 2.1 s, 95.3k rows/s -- H2 → export package (32 MB, 4 files): 2.9 s, 69.4k rows/s -- H2 → PostgreSQL 16: 3.2 s, 62.4k rows/s -- H2 → MySQL 9.6: 3.3 s, 60.7k rows/s -- H2 → SQL Server 2022: 5.0 s, 40.1k rows/s -- Export package → PostgreSQL: 4.5 s, 44.5k rows/s +200,000 rows × 6 columns, Apple M2 Max, 32 GB, JDK 17, databases in Docker on a server of the local network (about 100 Mbit/s), H2, DuckDB and the packages local, default settings: + +- H2 → MySQL: 10.3 s, 19.5k rows/s +- H2 → MariaDB: 9.5 s, 21.1k rows/s +- H2 → PostgreSQL: 6.4 s, 31.3k rows/s +- H2 → Oracle: 4.2 s, 47.9k rows/s +- H2 → SQL Server: 12.0 s, 16.7k rows/s +- H2 → Db2: 9.3 s, 21.6k rows/s +- H2 → ClickHouse: 4.2 s, 47.4k rows/s +- H2 → DuckDB: 5.7 s, 35.1k rows/s +- H2 → SQL package: 2.6 s, 76.1k rows/s +- H2 → Parquet package: 5.7 s, 35.4k rows/s +- H2 → CSV package: 5.8 s, 34.7k rows/s +- SQL package → PostgreSQL: 24.3 s, 8.2k rows/s +- Qdrant → PostgreSQL (20,000 points, 256-d vectors and payload): 18.4 s, 1.1k rows/s Quality: a full cross-database import matrix with every column type of every database, SQL, Parquet and CSV package round trips, commit and roll back verified on every database, copies in the same schema, old-version SQL executed on real servers, UI and boundary tests. @@ -235,6 +300,7 @@ Quality: a full cross-database import matrix with every column type of every dat - **Parquet and CSV through DuckDB**: one embedded engine writes and reads both, no Hadoop libraries. - **One transaction by default**: an import is all or nothing, confirm before commit when you want to look first. - **Grouped rows are for viewing**: exports carry real table rows. +- **Vector databases are sources**: their collections go into a relational database with vectors, which stays the end point of every copy. - **Copies need their own names**: a copy in the same schema takes a name such as {table}_copy. ## Summary @@ -249,6 +315,7 @@ Quality: a full cross-database import matrix with every column type of every dat 8. A mainstream dark UI with 7 themes, percentage progress and cancel. 9. Parquet and CSV packages and files of other tools, DuckDB and ClickHouse as OLAP data sources, imports in one transaction and rows updated or skipped by key. 10. A SQL Query editor exports the rows of any query through the same exits, columns of tables keep their types. -10. Query the rows of a table, export just those rows, or copy tables inside the same schema. +11. Query the rows of a table, export just those rows, or copy tables inside the same schema. +12. Collections of Milvus, Qdrant, Weaviate, Pinecone, Elasticsearch, OpenSearch and Chroma go straight into a database with vectors, previewed by conditions, native filters or nearest neighbor searches. GitHub: https://github.com/paganini2008/sqljam diff --git a/docs/blogger/sqljam-zh.md b/docs/blogger/sqljam-zh.md index 0ed633c..9de6500 100644 --- a/docs/blogger/sqljam-zh.md +++ b/docs/blogger/sqljam-zh.md @@ -30,12 +30,21 @@ SqlJam 用**一棵元数据树 + 按目标库和版本选择的方言**,把这 | 分析要 Parquet | 内置 DuckDB 生成 Parquet 导出包,也能把其他工具产出的 Parquet 文件加载进任意表 | | 表格工具要 CSV | CSV 导出包可选分隔符、引号、NULL 表示、表头、编码(Excel 用的 UTF-8 BOM、GBK 等)和日期格式,`manifest.json` 记录每列类型,导回时精确还原,也能加载其他工具的 CSV 文件 | | 导错了怎么办 | 默认**一个事务**:出错整体回滚,也可以**每 N 行提交一次**。勾选**跳过出错的行**则继续导入,每条跳过的记录都写进日志,可筛选、可保存。勾选**提交前确认**,导入成功后先看结果再 **Commit** 或 **Roll Back** | +| 数据到底全过去了没有 | 每次导入导出都有**迁移报告**,成功、失败、取消或回滚都有:每张表的源行数、写入行数、跳过行数,结束后重新统计的目标行数和 ✓ / ✗ 核对结果,创建了哪些对象,每条跳过的行和原因,停在哪张表的第几行,以及中断的错误。**View Report** 单独一页打开,**Transfer → Import History** 列出全部历史报告,可导出 Markdown、HTML、JSON | +| 副本要一直保持最新 | **Sync Tasks** 定时同步:按更新时间列或递增主键找出上次之后变化的行,每 N 分钟或每天定点运行。首次全量,提交成功后才移动水位,连接类错误自动重试,数据类错误不重试。可以由 SqlJam 在打开时运行,也可以登记到**系统调度器**(crontab、Windows 任务计划),SqlJam 关着也照跑。向量库集合同样可以同步,每次同步都有迁移报告 | | 目标已有部分数据 | **按主键更新或插入**:主键(或自选的唯一索引列)相同的行更新,其余插入。**按主键跳过已存在的行**:已有的行保持不变,只插入新行。默认是插入 | | 数仓和业务库割裂 | DuckDB、ClickHouse 作为 OLAP 数据源,和关系型数据库分组展示 | | 只要表的一部分 | 数据页查询(列、WHERE、GROUP BY、ORDER BY),只导出查询结果的行 | | 数据来自多表关联或报表 | **SQL Query** 查询器带语法高亮,先看前几行,再把查询的全部结果当作一张表导出 | +| 目标列的名字或类型不一样 | **Map Columns…** 逐列改目标列名、指定目标类型或不导入某列,表和向量库集合都可以。键、索引、外键跟着新名字走,没改的列原样导入 | | 原地复制一份表 | 同一 schema 内按 `{table}_copy` 复制,键、索引、约束自动改名,原表受保护 | -| 自定义和新型类型 | PostgreSQL 枚举与域、向量、联合、Map、Tuple、Variant | +| 自定义和新型类型 | PostgreSQL 枚举与域、联合、Map、Tuple、Variant | +| 向量检索跟着数据走 | pgvector、MySQL、MariaDB、Oracle、SQL Server、Db2、DuckDB、ClickHouse 的向量保留维度,向量索引保留距离和参数 | +| 向量存在向量数据库里 | Milvus、Qdrant、Weaviate、Pinecone、Elasticsearch、OpenSearch、Chroma 作为**向量数据库**出现在连接树中:浏览数据库、集合、分区、租户和命名空间,分页查看记录,再把集合直接写进支持向量的数据库:id、向量、每个字段各成一列,完整 payload 保存为 JSON | +| 数据库在跳板机后面 | 每个数据源可配 **SSH 隧道**,用密码或私钥认证,主机密钥首次信任后记住,隧道断开后自动重连 | +| 连的是哪台服务器,状态如何 | **信息页**显示数据源或向量数据库的概览和健康状况。**Dashboard** 子页画最近 5 分钟的实时曲线,每 1 到 30 秒采样一次:会话、事务、语句、行数、缓存命中、锁等待、网络、CPU、内存和库大小,取自各数据库自己的统计视图。嵌入式数据库跑在 SqlJam 进程里,显示 SqlJam 的 CPU 和内存。服务器不提供的指标会列出原因 | +| 长内容,要把几行贴进工单 | 单元格只显示一行,双击查看完整内容,**Copy Rows As** 复制为 CSV、TSV、JSON 或 Markdown | +| 数据源太多 | 按关系型、OLAP、向量数据库分组并显示各自的图标,树上方的**过滤框**按名称或类型查找 | ## 3. Quick Start @@ -55,7 +64,7 @@ bin/ ├── sqljam.properties # 外置配置,改完重启即可 ├── sqljam.vmoptions # JVM 参数,一行一个 ├── sqljam.png # macOS 程序坞图标 -└── sqljam-tutorial.mp4 # 教学视频,3 分钟内看完导出与导入 +└── sqljam-sql.mp4 / sqljam-vectors.mp4 / sqljam-sync.mp4 # 三段教学视频,每段约 2 分钟 ``` `sqljam.properties`、`sqljam.vmoptions` 和可运行 jar 放在一起。整个目录拷到任何地方都能用,双击启动也能找到配置。 @@ -64,7 +73,7 @@ bin/

SqlJam 启动画面

-首次启动会自带一个 **Example Shop (H2)** 示例数据源,包含几张电商表(categories、customers、products、orders、order_items),装好就能直接体验。删掉了也没关系,登录页的 **Example** 按钮或菜单 **Help → Restore Example Database** 随时可以恢复。 +启动后和 DBeaver、DataGrip 一样直接进入主界面,窗口在上次关闭的位置,树中列出所有数据源,上次连着的数据源会自动重新连接。没有保存密码的数据源在连接时询问密码。首次启动会自带一个 **Example Shop (H2)** 示例数据源,包含几张电商表(categories、customers、products、orders、order_items),装好就能直接体验。删掉了也没关系,菜单 **Help → Restore Example Database** 随时可以恢复。 右键数据源选 **Connect**,连上之后同一个菜单变成 **Disconnect**,断开时它的表页签一起关闭。导入导出选目标数据源时,旁边的 **+** 按钮可以当场新建一个。工具栏最右边是 **Exit**。**Help → About SqlJam** 里有版本、主页、仓库地址和作者联系方式,右下角的 GitHub 图标点一下直接打开仓库。 @@ -72,16 +81,34 @@ bin/ ![SQL Query](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/sql-editor.png) -[教学视频](https://github.com/paganini2008/sqljam/blob/master/docs/assets/sqljam-tutorial.mp4)以示例库演示一遍:筛选行、导出到文件、导出包导入 PostgreSQL,再直接复制到 SQL Server 和 Oracle。 +新建数据源先选类别:关系型、OLAP 或向量数据库,类型和字段随类别变化。勾选 **Connect through SSH tunnel** 可以经跳板机连接数据库,用密码或私钥认证。 -| 1. 登录数据源 | 2. 导出向导 | 3. 进度 | +向量数据库是**向量数据库**类别的数据源,按各库的要求填 API key、用户和密码,或者都不填。在树中展开到数据库、集合和分区(Weaviate 的租户、Pinecone 的命名空间),打开集合就能看到字段和分页的记录,可以按条件、原生过滤或最近邻搜索筛选。**Import…** 打开导入向导,把勾选的集合写进支持向量的数据源。目标列表只显示支持向量的数据源。 + +所有数据表格每格只显示一行,双击或 **Shift+Enter** 查看完整内容(JSON 自动缩进,向量显示维度),点表头对本页排序,**Copy Rows As** 把选中的行复制为 CSV、TSV、JSON 或 Markdown。工具栏的 **Info** 显示所选数据源的服务器、驱动、会话和健康状况。 + +三段教学视频,每段约 2 分钟:[SQL 数据库](https://github.com/paganini2008/sqljam/blob/master/docs/assets/sqljam-sql.mp4)(筛选行、导出到文件、导出包导入 PostgreSQL、SQL 查询器、直接复制到 SQL Server),[向量数据库](https://github.com/paganini2008/sqljam/blob/master/docs/assets/sqljam-vectors.mp4)(浏览 Qdrant 集合、导入 PostgreSQL、Weaviate 租户),[定时同步与迁移报告](https://github.com/paganini2008/sqljam/blob/master/docs/assets/sqljam-sync.mp4)(示例库同步到 PostgreSQL、首次全量和增量、查看报告、交给系统调度器)。 + +| 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) | +| ![data source](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/data-source.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) | +| 7. 向量数据库集合 | 8. 向量数据库导入 | 9. 数据库信息 | +|---|---|---| +| ![collection](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/vdb-collection.png) | ![vdb import](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/import-vdb.png) | ![info](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/database-info.png) | + +| 10. 迁移报告 | 11. 导入历史 | 12. 同步任务 | +|---|---|---| +| ![report](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/report.png) | ![history](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/import-history.png) | ![sync](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/sync-tasks.png) | + +| 13. 监控仪表盘 | 14. 列映射 | 15. 功能图谱 | +|---|---|---| +| ![dashboard](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/dashboard.png) | ![mapping](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/column-mapping.png) | ![features](https://raw.githubusercontent.com/paganini2008/sqljam/master/docs/assets/feature-map.png) | + ## 4. Requirements 运行只需要 **Java 17+**。JavaFX 和 JDBC 驱动都打进了可运行 jar,从源码构建也不需要安装 Maven,项目自带 Maven Wrapper(`./mvnw`)。 @@ -96,15 +123,25 @@ bin/ | 数据库 | 版本 | |---|---| | MySQL | 5.5 ~ 9.x | -| PostgreSQL | 9.x ~ 16 | +| PostgreSQL | 9.x ~ 17 | | Oracle | 11g ~ 23ai | -| SQL Server | 2008 ~ 2022 | +| SQL Server | 2008 ~ 2025 | | MariaDB | 10.3 ~ 11.x | | H2 / SQLite | 2.x / 3.x | | Microsoft Access(.accdb、.mdb) | 2000 ~ 2019 | | DuckDB(OLAP) | 1.x | | ClickHouse(OLAP) | 24.x ~ 26.x | +| 向量数据库(仅作源) | 版本 | +|---|---| +| Milvus | 2.4 ~ 3.x | +| Qdrant | 1.10 ~ 1.19 | +| Weaviate | 1.24 ~ 1.39 | +| Pinecone、Pinecone Local | API 2025-01 | +| Elasticsearch | 8.0 ~ 8.19 | +| OpenSearch | 2.4 ~ 2.19 | +| Chroma | 1.0 ~ 1.5 | + JDBC 驱动全部打进 fat jar,无需额外下载。 ## 5. How It Works @@ -120,6 +157,7 @@ flowchart LR E --> SI[ScriptImporter] --> TD D --> P[Parquet / CSV 导出包
DuckDB 中转 · data/*.parquet 或 *.csv] P --> PI[ParquetImporter · CsvImporter] --> TD + V[(向量数据库)] --> VC[VdbClient · REST API] --> VT[集合树] --> VD[Vdb 方言] --> TD ``` - **读一次元数据**:`XxxMetaDataOperations` 按库补全注释、自增、生成列、分区、序列。 @@ -129,6 +167,7 @@ flowchart LR - **事务**:导入在一个事务里完成,成功自动提交或经你确认后提交。MySQL、MariaDB、Oracle、H2 执行 DDL 会隐式提交,外键和自增值等语句延后到提交之后执行。 - **按主键更新或跳过**:使用各库的 `ON CONFLICT`、`ON DUPLICATE KEY UPDATE` 或 `MERGE`,已存在的表、索引、键自动跳过。 - **跳过出错的行**:出错的一页在 savepoint 保护下逐行重写,每条坏行连同主键和错误写进日志,其余行照常保留。 +- **向量数据库**:独立于 JDBC 主线的一条流水线。REST 客户端读取集合,访问者遍历集合树生成建表语句,`VdbImportHandler` 分页写入目标库,写入前先探测目标库的向量特性。 ## 6. Code Examples @@ -205,9 +244,24 @@ target.setTableNamePattern("{table}_copy"); // 在 EMPLOYEES 旁边生成 | 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)` | +| MySQL / Oracle / SQL Server `VECTOR(1024)` | `vector(1024)` | `VECTOR(1024, FLOAT32)` | `VECTOR(1024)` | +| PostgreSQL `halfvec(2560)` / `sparsevec(8)` | same | `VECTOR(2560, FLOAT32)` / `VECTOR(8, FLOAT32, SPARSE)` | `VECTOR(2560, float16)` / `nvarchar(max)` | | ClickHouse `Array(T)` / `Map(K, V)` | `text` | `CLOB` | `nvarchar(max)` | +向量数据库的集合直接导入支持向量的目标库: + +```java +try (VdbClient client = VdbType.QDRANT.createClient(new VdbSource(VdbType.QDRANT, "http://qdrant-host:6333"))) { + VdbExportConfiguration configuration = new VdbExportConfiguration(); + configuration.setTargetSchema("public"); + configuration.addCollection("docs", "docs"); + configuration.setQuery(VdbQuery.all().where("lang = 'en'")); + new VdbExporter(client, pgDataSource, configuration).export(); +} +``` + +**输出**:`public.docs`,含 `id`、`embedding vector(1024)`、每个 payload 字段一列和 `payload jsonb`,以及集合原有余弦距离的 HNSW 索引。 + ## 7. Configuration 全部配置在可运行 jar 旁边的 `bin/sqljam.properties` 里。界面里修改的设置保存在 `~/.sqljam/sqljam.properties`,优先于该文件。 @@ -226,6 +280,9 @@ target.setTableNamePattern("{table}_copy"); // 在 EMPLOYEES 旁边生成 | `sqljam.import.batch-size` | `1000` | 导入导出包时每批 INSERT 条数 | | `sqljam.ui.theme` | `Primer Dark` | 主题,可切换 7 种 | | `sqljam.pool.maximum-size` | `10` | 连接池大小 | +| `sqljam.vdb.enabled` | `true` | 显示向量数据库,`false` 隐藏 | +| `sqljam.vdb.connections.file` | `${sqljam.home}/vdb-connections.json` | 保存的向量数据库 | +| `sqljam.ssh.known-hosts.file` | `${sqljam.home}/ssh_known_hosts` | SSH 隧道服务器的主机密钥 | ## 8. Performance @@ -233,14 +290,21 @@ target.setTableNamePattern("{table}_copy"); // 在 EMPLOYEES 旁边生成 | 场景(20 万行 × 6 列) | 耗时 | 行/秒 | |---|---|---| -| H2 → Oracle 23ai | 2.1 s | 95.3k | -| H2 → 导出包(32 MB,切成 4 个文件) | 2.9 s | 69.4k | -| H2 → PostgreSQL 16 | 3.2 s | 62.4k | -| H2 → MySQL 9.6 | 3.3 s | 60.7k | -| H2 → SQL Server 2022 | 5.0 s | 40.1k | -| 导出包 → PostgreSQL | 4.5 s | 44.5k | - -环境:Apple M2 Max / 32 GB / JDK 17。MySQL、PostgreSQL 本机,Oracle、SQL Server 在 Docker。默认配置。 +| H2 → MySQL | 10.3 s | 19.5k | +| H2 → MariaDB | 9.5 s | 21.1k | +| H2 → PostgreSQL | 6.4 s | 31.3k | +| H2 → Oracle | 4.2 s | 47.9k | +| H2 → SQL Server | 12.0 s | 16.7k | +| H2 → Db2 | 9.3 s | 21.6k | +| H2 → ClickHouse | 4.2 s | 47.4k | +| H2 → DuckDB | 5.7 s | 35.1k | +| H2 → SQL 导出包 | 2.6 s | 76.1k | +| H2 → Parquet 导出包 | 5.7 s | 35.4k | +| H2 → CSV 导出包 | 5.8 s | 34.7k | +| SQL 导出包 → PostgreSQL | 24.3 s | 8.2k | +| Qdrant → PostgreSQL (2 万个点,256 维向量和 payload) | 18.4 s | 1.1k | + +环境:Apple M2 Max / 32 GB / JDK 17。数据库在局域网服务器的 Docker 中(约 100 Mbit/s),H2、DuckDB 和导出包在本机。默认配置。每个场景由 `TransferBenchmark` 测得。 **质量**:覆盖每种数据库全部字段类型的跨库互导矩阵,SQL、Parquet、CSV 导出包回放,事务提交与回滚、按主键更新或插入在每种数据库上验证,同 schema 复制,旧版本语法在真实库上执行,界面功能与边界测试。 @@ -254,6 +318,7 @@ target.setTableNamePattern("{table}_copy"); // 在 EMPLOYEES 旁边生成 - **Parquet 与 CSV 交给 DuckDB**:一个内置引擎负责读写,不引入 Hadoop 依赖。 - **默认一个事务**:导入要么全成要么全不成,想先看结果就勾选提交前确认。 - **分组结果用于浏览**:导出的始终是表的真实行,GROUP BY 的结果是这些行的视图。 +- **向量数据库只作源**:集合导入支持向量的关系型数据库,关系型数据库始终是终点。 - **复制要有新表名**:同一 schema 内复制需要 `{table}_copy` 这样的表名,原表永远不会被覆盖。 ## 10. Summary @@ -268,4 +333,5 @@ target.setTableNamePattern("{table}_copy"); // 在 EMPLOYEES 旁边生成 8. 暗色主流界面,7 种主题,百分比进度与取消。 9. Parquet、CSV 导出包与外部文件加载,DuckDB、ClickHouse 作为 OLAP 数据源,导入默认一个事务、出错整体回滚,支持按主键更新或插入。 10. SQL Query 查询器的任意查询结果都能走同样的导出出口,来自表的列保留原类型。 -10. 表数据可查询,只导出查询结果,也能在同一 schema 内复制表。 +11. 表数据可查询,只导出查询结果,也能在同一 schema 内复制表。 +12. Milvus、Qdrant、Weaviate、Pinecone、Elasticsearch、OpenSearch、Chroma 的集合直接导入支持向量的数据库,导入前可按条件、原生过滤或最近邻搜索预览。 diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 99e8b7f..5cd53ea 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -23,8 +23,10 @@ The desktop application is a thin layer over the `com.github.sqljam.impexp` API. | [9. Update or skip existing rows](#9-update-or-skip-existing-rows) | `WriteMode` | | [10. Progress, log and cancel](#10-progress-log-and-cancel) | `ExportListener` | | [11. Older servers, names and copies](#11-older-servers-names-and-copies) | target version, `IdentifierCase`, `tableNamePattern` | +| [12. Import from vector databases](#12-import-from-vector-databases) | `VdbClient`, `VdbExporter`, `VdbQuery` | | [Option reference](#option-reference) | `ExportConfiguration`, `ImportConfiguration` | | [Adding a database](#adding-a-database) | `Dialect`, `MetaDataOperations`, `DbType` | +| [Adding a vector database](#adding-a-vector-database) | `VdbClient`, `VdbType`, `VdbDialect` | --- @@ -75,6 +77,9 @@ flowchart LR | `ManualTransaction` | One transaction for a whole import, committed or rolled back by you | | `ExportListener` | Progress, messages, errors and cancel | | `Dialect` / `MetaDataOperations` | Everything special to one database, one subclass per database and version | +| `DatabaseFeature` | Features of a target detected on its connection before they are used, e.g. pgvector, HNSW indexes | +| `SyncScheduler` / `SyncEngine` | Sync tasks of the application: watermarks, upserts by key, attempts and reports, run by SqlJam or by `sqljam sync` | +| `VectorType` / `VectorIndex` | A vector column (elements, dimensions, dense or sparse) and a vector index (HNSW or IVF, distance, options) of any database | --- @@ -187,6 +192,30 @@ copier.exportDdlAndData(); Rows are read and written in pages of 5000 rows (`setPageSize`), tables with LOB columns in pages of 100 (`setLobPageSize`). Foreign keys are created after the rows, identity values and sequences continue after the copied rows. +Columns are renamed, typed or left out by `columnMappings` of the target. Keys, indexes, foreign keys and identity values follow the new names, the other columns are imported as they are: + +```java +Map orders = new HashMap<>(); +orders.put("NOTE", new ColumnMapping("REMARK", "TEXT", false)); // another name and type +orders.put("SECRET", ColumnMapping.skip()); // not imported +target.getColumnMappings().put("ORDERS", orders); +``` + +A database behind a bastion host is reached through an SSH tunnel: `SshUtils.open` forwards a local port to the host and port which the SSH server reaches, and the url uses the local end. The tunnel of an id is shared and opened again on the same port when it breaks. + +```java +SshSettings ssh = new SshSettings(); +ssh.setEnabled(true); +ssh.setHost("bastion.example.com"); +ssh.setUsername("deploy"); +ssh.setAuthentication(SshSettings.Authentication.KEY); +ssh.setPrivateKeyFile("~/.ssh/id_ed25519"); +int port = SshUtils.open("pg-prod", ssh, "10.0.0.12", 5432); +source.setUrl("jdbc:postgresql://127.0.0.1:" + port + "/demo"); +// ... copy, then +SshUtils.close("pg-prod"); +``` + --- ## 4. Import an export package @@ -368,10 +397,12 @@ copier.setExportListener(new ExportListener() { | Callback | When | |---|---| | `onStart` / `onEnd(successful)` | Before the first and after the last step | -| `onTableStart` / `onTableProgress` / `onTableEnd` | Per table, rows written so far | +| `onTableStart` / `onTableProgress` / `onTableEnd` | Per table, rows read so far, and the rows written at the end without the skipped rows | | `onProgress(processed, total)` | Rows of all tables, bytes of a package import | | `onMessage` | Steps such as `Reading metadata ...` | | `onError` | Errors and skipped rows or statements | +| `onRowSkipped(catalog, schema, table, key, e)` | A row which was skipped, with its key and error | +| `onDdlExecuted(statement, successful)` | A statement of the structure, executed or failed, the migration report counts the objects by them | | `isCancelled` | Asked before each page | --- @@ -388,6 +419,56 @@ copier.setExportListener(new ExportListener() { --- +## 12. Import from vector databases + +The vector databases are a line of their own in `com.github.sqljam.vdb`, next to the JDBC line of `impexp`. It reads by REST APIs (the HTTP client of the JDK and Jackson), copies what it needs instead of calling `impexp`, and writes only into targets with vectors. `impexp` never refers to it, and `sqljam.vdb.enabled=false` hides it in the UI. + +```mermaid +flowchart LR + V[(Milvus · Qdrant · Weaviate · Pinecone
Elasticsearch · OpenSearch · Chroma)] --> C[VdbClient
describe · count · read] + C --> S[VdbServerMetaData] --> CO[VdbCollectionMetaData
field · primary key · vector index · comment] + CO --> VI[DefaultVdbMetaDataVisitor] --> DS[VdbDdlScripter] + DS --> H[VdbImportHandler
pages · transactions · upserts · skipped rows] --> T[(Target with vectors)] + D[VdbDialect
features probed on the target] --> CO + D --> H +``` + +```java +VdbSource source = new VdbSource(VdbType.MILVUS, "http://milvus-host:19530"); +source.setUsername("root"); +source.setPassword("secret"); +try (VdbClient client = source.getType().createClient(source)) { + VdbExportConfiguration configuration = new VdbExportConfiguration(); + configuration.setTargetSchema("public"); + configuration.addCollection("docs", "docs"); + configuration.setQuery(VdbQuery.all().where("lang = 'en' and n >= 6")); + configuration.setWriteMode(VdbExportConfiguration.WriteMode.UPSERT); + configuration.setErrorSkipped(true); + VdbExportResult result = new VdbExporter(client, dataSource, configuration, new VdbExportListener() { + @Override + public void onRowSkipped(VdbExportResult.TableResult table, String key, Exception e) { + System.out.println("Skipped " + key + ": " + e.getMessage()); + } + }).export(); +} +``` + +| Part | Mapping | +|---|---| +| Collection | A table, named as the collection with other characters than letters, digits and `_` as `_` | +| Id | Primary key, with the tenant of Weaviate or the namespace of Pinecone when there are several | +| Vectors | Vector columns of the target: dense, 16-bit, int8 and sparse as the target has them, others as text | +| Fields | A column of each top-level field, typed by the schema or by a scan of the payloads, and `payload` with the whole payload as JSON | +| Vector index | The kind (HNSW, IVF), distance and options of the collection when the target has them | +| `VdbQuery` | `where("n >= 6 and lang IN ('en', 'zh')")`, `setNativeFilter` in the language of the database, `setNearestField` with `setNearestVector` or `setNearestId`, `setLimit` | +| `VdbExportConfiguration` | `tableRecreated`, `writeMode` (`INSERT`, `UPSERT`), `vectorIndexCreated`, `commentCreated`, `pageSize`, `transactional`, `errorSkipped` | +| Fields of a collection | `getMappings().get(i).getFields().put("title", new VdbFieldMapping("headline", null, false))`, `VdbFieldMapping.skip()` leaves a field out, the id is always imported | +| `VdbExporter.getScripts()` | The statements of the tables, which are not executed | +| Levels and parts | `VdbClient.getDatabases()` of Milvus and Chroma (`VdbSource.withDatabase`), `listPartitions(collection)` for partitions of Milvus, tenants of Weaviate and namespaces of Pinecone, read apart by `VdbQuery.getPartitions()` | +| Information | `VdbClient.getOverview()` and `getHealth()`, sections of names and values as the Info page shows them | + +--- + ## Option reference `Exporter.ExportConfiguration` (source): @@ -415,6 +496,7 @@ copier.setExportListener(new ExportListener() { | `targetCatalogName` / `targetSchemaName` | source names | Where the tables go | | `targetSchemaCreated` | `true` | Create a missing schema (database of MySQL) | | `tableNamePattern` | | Target table names, `{table}` is the source name | +| `columnMappings` | none | Target name, type written as it is, or no column, by source table and source column. Other columns are imported as they are | | `connectionPoolEnabled` | `true` | HikariCP pool for the target | --- @@ -428,7 +510,23 @@ copier.setExportListener(new ExportListener() { | Older versions | `XxxNNDialect extends XxxDialect`, chosen by `Dialect.forVersion` | | Versions other than the server version, e.g. the file format of Access | `Dialect.readDatabaseVersion` | | Tables which are emptied before they are dropped, drops of missing tables | `Dialect.getEmptyTableStatement`, `Dialect.isMissingDropError` | +| Features which the version does not tell, detected by probes which do not fail | `Dialect.detectDatabaseFeatures`, `Dialect.hasDatabaseFeature(feature, byVersion)` | +| Vector columns and indexes of the source | `VectorType.DETAIL_KEY` of a column, `VectorIndex.DETAIL_KEY` of an index, set by `MetaDataOperations` | +| Vector types, values and indexes of the target | `Dialect.getVectorTypeName`, `Dialect.getVectorValue`, `Dialect.getCreateVectorIndexStatement` | +| Sessions of the target, e.g. the search path of extensions | `Dialect.prepareSession` | | Registration | A constant of `DbType` with driver, URL template and category | | Fixture and tests | `src/test/resources//fixture.sql`, a value of `ItDatabase`, the cross-database matrix runs it | +## Adding a vector database + +| Step | Where | +|---|---| +| REST client: version, collections, describe, count, read pages | `vdb.client.XxxClient extends AbstractVdbClient` | +| Payloads without a schema | `PayloadSchema.observe` of a scan, then `getFields` | +| Vectors of other elements | `VdbValues` (dense, bytes, sparse, 16-bit floats) | +| Registration | A constant of `VdbType` with its default port, client, authentication (API key, user) and levels (databases, tenants, the name of its partitions) | +| Levels, information | `getDatabases`, `listPartitions`, `getOverview` and `getHealth` of the client when the database has them | +| Target databases | `vdb.dialect.XxxVdbDialect extends VdbDialect`, returned by `VdbDialect.of` | +| Fixture and tests | A value of `VdbServer` and its settings in `it-vdb-test.properties`, `VdbImportIT` imports it into every target | + Read the change logs in [change_logs](../change_logs) for the decisions behind each part. diff --git a/docs/index.html b/docs/index.html index 1393ff3..ce9ac2f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -197,7 +197,9 @@

SqlJam

Get Started GitHub Documentation - Tutorial Video + Video: SQL + Video: Vectors + Video: Sync
SqlJam main window with the table structure of an employee table
@@ -215,6 +217,8 @@

Migrations fail on the details

Keys and indexes go missing

Data arrives, but primary keys, foreign keys, sequences and comments do not.

→ Table-level objects move together.

Scripts become monsters

Gigabyte data.sql files and BLOB literals that break 4000-byte limits.

→ 10 MB parts and LOB files.

Values get corrupted

Shifted timestamps, overflowing unsigned numbers, garbled bits and money.

→ A portable value pipeline.

+

Embeddings are locked in a vector database

Collections of Milvus, Qdrant, Weaviate, Pinecone, Elasticsearch, OpenSearch or Chroma never meet the SQL of the business data.

→ Collections go straight into a database with vectors.

+

The database hides behind a bastion

Production servers answer only through an SSH jump host.

→ An SSH tunnel per data source.

@@ -226,17 +230,25 @@

Migrations fail on the details

Everything a table needs on the other side

Any-to-any

Every source/target combination, verified by tests.

-

Version-aware

Dialects picked by detected or selected server version.

+

Version-aware

Dialects picked by detected or selected server version, vector types and indexes by the features detected on the target.

Keys & constraints

Primary keys, indexes, foreign keys, comments and sequences.

Partitions

Range, list, hash, interval, composite, reference and nested partitions kept for the same database type, partitioned tables marked in the tree.

Export packages

SQL, Parquet or CSV files, LOB files and manifest.json, split by size and imported in order.

One transaction

Checked by status, type and SHA-256, all or nothing or bad rows skipped and logged, confirmed before the commit if you want.

Progress you can see

Overall percentage, per-table progress, error log and cancel.

+

Migration reports

Every import and export leaves a report, also when it fails or is cancelled: rows per table checked against the target, skipped rows, objects and where it stopped, kept in the import history.

+

Scheduled sync

Changed rows by an update time or an increasing key, every N minutes or every day, run by SqlJam or the system scheduler, vector collections too, a report for every run.

Dark by default

A modern JavaFX UI with 7 switchable themes.

Parquet & CSV

Columnar or text packages and files of other tools, exact types, through an embedded DuckDB.

OLAP sources

DuckDB and ClickHouse next to the relational databases.

Query & export

A highlighted SQL Query editor or the data viewer shapes the rows, then every exit exports all of them.

Copies in place

Copy tables inside the same schema with names like {table}_copy.

+

Vectors & embeddings

pgvector, MySQL, MariaDB, Oracle, SQL Server, Db2, DuckDB and ClickHouse vectors keep their dimensions, HNSW and IVF indexes keep their distances.

+

From vector databases

Milvus, Qdrant, Weaviate, Pinecone, Elasticsearch, OpenSearch and Chroma in the tree: databases, collections, partitions and tenants, records page by page, imported into tables of ids, vectors, fields and the payload as JSON.

+

Column mapping

Rename a column, give it a type of the target or leave it out, keys and indexes follow, other columns stay as they are.

+

SSH tunnels

Reach a database behind a bastion host with a password or a private key, host keys trusted on first use.

+

Info, health & dashboard

Server, driver, session and the health of a data source or a vector database, and live charts of sessions, transactions, rows, cache, locks, CPU and memory.

+

Values & rows

Double click a cell for its whole value, copy selected rows as CSV, TSV, JSON or Markdown, filter the data sources of the tree.

@@ -252,6 +264,7 @@

Read once, generate for any target

1

Metadata: per-database operations read tables, keys, indexes, comments, partitions and sequences.

2

Dialect: DbType.createDialect(major, minor) picks e.g. Oracle11gDialect.

3

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

+
4

Vector databases: a line of its own. REST clients read the collections, a visitor builds the tables and the pages go into a target whose vector features are probed first.

@@ -304,6 +317,11 @@

Ways to move a table

+ + + + +
Export wizard @@ -341,6 +359,39 @@

Ways to move a table

csv.exportDdlAndData(); // data/<table>.csv, <table>_2.csv ... importer.importFiles(files, "sales", ParquetImporter.Mode.CREATE);
+
+ Import from Vector Database dialog with collections, fields, a previewed query and a target with vectors +

Add a vector database as a data source, expand it in the tree to its databases, collections and partitions, page through the records of conditions, a native filter or a nearest neighbor search, and import the collections into a data source with vectors. Only such data sources are offered as targets.

+
configuration.addCollection("docs", "docs");
+configuration.setQuery(VdbQuery.all().where("lang = 'en'"));
+new VdbExporter(client, dataSource, configuration).export();   // id, embedding vector(1024), payload jsonb
+
+
+ Map Columns dialog with a renamed column, a type of the target and a column left out +

Map Columns renames a column, gives it a type of the target or leaves it out, for tables and for the fields of vector collections. Keys, indexes and foreign keys follow the new names, the other columns are imported as they are.

+
orders.put("NOTE", new ColumnMapping("REMARK", "TEXT", false));
+orders.put("SECRET", ColumnMapping.skip());   // not imported
+target.getColumnMappings().put("ORDERS", orders);
+
+
+ Migration report with its status, summary and the tables checked against the target +

Every import and export leaves a migration report, also when it fails or is cancelled: rows of each table counted again in the target, skipped rows with their errors, created objects and where it stopped. Transfer > Import History lists every report, View Report opens one, Export writes it as Markdown, HTML or JSON.

+
~/.sqljam/reports/20261025-134910-9fec1f3e.json
+Completed · 3 tables · 263 rows written · 0 skipped
+sjm_emp   250 → 250 ✓
+
+
+ Sync Tasks with a task run by SqlJam and a task run by the system scheduler +

Sync tasks copy the rows which changed since the last run, by a column of the update time or an increasing key, every N minutes or every day. SqlJam runs them while it is open, or the system scheduler runs them when it is closed. Vector collections sync too, every run leaves a report.

+
sqljam.sh sync --list
+sqljam.sh sync --task "Shop to PostgreSQL"
+sqljam.sh sync --due   # one line of cron for every task
+
+
+ Dashboard of a MySQL data source with live charts of threads, statements, network and buffer pool hits +

Info > Dashboard draws live charts of the last 5 minutes: sessions, transactions, statements, rows, cache hits, locks, network, CPU and memory, read from the statistics of each database. Metrics which a server does not tell are listed with the reason.

+
Sample every 1, 2, 5, 10 or 30 s · Pause · Resume
+
@@ -350,26 +401,33 @@

Ways to move a table

Performance

Tens of thousands of rows per second

-
95.3krows/s · H2 → Oracle
+
76.1krows/s · H2 → SQL package
100%cross-database pairs pass
SQL · Parquet · CSVexport package formats
Upsertupdate or skip rows by key
-
Throughput chart
+
Throughput chart
- - - - - - + + + + + + + + + + + + +
Scenario (200,000 rows × 6 columns)TimeRows/s
H2 → Oracle 23ai2.1 s95.3k
H2 → export package (32 MB, 4 files)2.9 s69.4k
H2 → PostgreSQL 163.2 s62.4k
H2 → MySQL 9.63.3 s60.7k
H2 → SQL Server 20225.0 s40.1k
Export package → PostgreSQL4.5 s44.5k
H2 → MySQL10.3 s19.5k
H2 → MariaDB9.5 s21.1k
H2 → PostgreSQL6.4 s31.3k
H2 → Oracle4.2 s47.9k
H2 → SQL Server12.0 s16.7k
H2 → Db29.3 s21.6k
H2 → ClickHouse4.2 s47.4k
H2 → DuckDB5.7 s35.1k
H2 → SQL package2.6 s76.1k
H2 → Parquet package5.7 s35.4k
H2 → CSV package5.8 s34.7k
SQL package → PostgreSQL24.3 s8.2k
Qdrant → PostgreSQL (20,000 points, 256-d vectors and payload)18.4 s1.1k
-

Environment: Apple M2 Max, 32 GB, JDK 17 · MySQL and PostgreSQL local, Oracle and SQL Server in Docker · default settings.

+

Environment: Apple M2 Max, 32 GB, JDK 17 · databases in Docker on a server of the local network, H2, DuckDB and the packages local · default settings.

diff --git a/pom.xml b/pom.xml index 7612e0f..f6ea738 100644 --- a/pom.xml +++ b/pom.xml @@ -78,6 +78,17 @@ commons-io 2.22.0 + + + org.apache.sshd + sshd-osgi + 2.15.0 + + + net.i2p.crypto + eddsa + 0.3.0 + com.zaxxer HikariCP @@ -146,7 +157,7 @@ com.microsoft.sqlserver mssql-jdbc - 12.10.2.jre11 + 13.4.0.jre11 com.h2database @@ -592,10 +603,12 @@ - + ${project.basedir}/docs/assets - sqljam-tutorial.mp4 + sqljam-sql.mp4 + sqljam-vectors.mp4 + sqljam-sync.mp4 diff --git a/src/bin/sqljam.bat b/src/bin/sqljam.bat index 6c59069..d802e82 100644 --- a/src/bin/sqljam.bat +++ b/src/bin/sqljam.bat @@ -3,6 +3,9 @@ rem rem SqlJam launcher for Windows. It runs the runnable jar of Windows next to this script rem with Java 17 or later, without a console window. rem +rem sqljam.bat sync --task NAME runs a sync task in this console and returns its exit code, +rem e.g. for the Task Scheduler of Windows +rem rem JVM options are read from sqljam.vmoptions next to this script (one option per line), rem JAVA_OPTS adds more and wins, e.g. set JAVA_OPTS=-Xmx4g. JAVA_HOME selects the Java runtime. rem @@ -42,10 +45,17 @@ if not exist "%DIR%sqljam.vmoptions" goto start for /f "usebackq eol=# tokens=* delims=" %%o in ("%DIR%sqljam.vmoptions") do call set "VMOPTIONS=%%VMOPTIONS%% %%o" :start +if /i "%~1"=="sync" goto sync start "SqlJam" "%JAVAW%" %VMOPTIONS% -Dsqljam.vmoptions.applied=true %JAVA_OPTS% -jar "%JAR%" %* endlocal exit /b 0 +:sync +rem Sync tasks run in the console, their output and exit code are kept +"%JAVA%" %VMOPTIONS% -Dsqljam.vmoptions.applied=true %JAVA_OPTS% -jar "%JAR%" %* +set "CODE=%ERRORLEVEL%" +endlocal & exit /b %CODE% + :noJar echo Runnable jar not found: "%JAR%" pause diff --git a/src/bin/sqljam.sh b/src/bin/sqljam.sh index 93182c7..05ac37d 100755 --- a/src/bin/sqljam.sh +++ b/src/bin/sqljam.sh @@ -4,6 +4,7 @@ # (Linux x64, macOS x64 or Apple Silicon) next to this script and runs it with Java 17 or later. # # sh sqljam.sh +# sh sqljam.sh sync --task NAME runs a sync task without the window, e.g. from cron # # JVM options are read from sqljam.vmoptions next to this script (one option per line), # JAVA_OPTS adds more and wins, e.g. JAVA_OPTS=-Xmx4g. JAVA_HOME selects the Java runtime. diff --git a/src/main/java/com/github/sqljam/face/Launcher.java b/src/main/java/com/github/sqljam/face/Launcher.java index ba878b0..ed53104 100644 --- a/src/main/java/com/github/sqljam/face/Launcher.java +++ b/src/main/java/com/github/sqljam/face/Launcher.java @@ -35,6 +35,12 @@ private Launcher() { } public static void main(String[] args) { + // sqljam sync runs sync tasks without the window, e.g. started by the scheduler of the system + if (SyncCommand.isCommand(args)) { + Locale.setDefault(Locale.ENGLISH); + System.exit(SyncCommand.run(args, System.out, System.err)); + return; + } // Started by double click: start again with the JVM options of sqljam.vmoptions if (JvmOptions.relaunchIfNeeded(args)) { return; diff --git a/src/main/java/com/github/sqljam/face/SqlJamApplication.java b/src/main/java/com/github/sqljam/face/SqlJamApplication.java index 3a78562..025a08a 100644 --- a/src/main/java/com/github/sqljam/face/SqlJamApplication.java +++ b/src/main/java/com/github/sqljam/face/SqlJamApplication.java @@ -27,15 +27,15 @@ import com.github.sqljam.face.service.SettingsStore; import com.github.sqljam.face.view.AppContext; import com.github.sqljam.face.view.Branding; -import com.github.sqljam.face.view.LoginView; import com.github.sqljam.face.view.MainView; import com.github.sqljam.face.view.Messages; import com.github.sqljam.face.view.ProfileRegistry; import com.github.sqljam.face.view.TaskRunner; import com.github.sqljam.face.view.ThemeManager; +import com.github.sqljam.face.view.WindowPlacement; import com.github.sqljam.impexp.DbType; import javafx.application.Application; -import javafx.scene.Parent; +import javafx.application.Platform; import javafx.scene.Scene; import javafx.stage.Stage; import lombok.extern.slf4j.Slf4j; @@ -124,7 +124,8 @@ public void start(Stage primaryStage) { stage.setTitle(Messages.get("app.title")); Branding.applyIcons(stage); - showConnectionLogin(); + // Straight into the main window like DBeaver and DataGrip, data sources are connected in its tree + showMainView(); if (SplashPreloader.isActive()) { notifyPreloader(new SplashPreloader.Status(Messages.get("splash.ready"), 1)); notifyPreloader(new SplashPreloader.Ready(stage::show)); @@ -133,34 +134,35 @@ public void start(Stage primaryStage) { } } - private void showPage(Parent root, double width, double height) { - Scene scene = new Scene(root, width, height); - scene.getStylesheets().add(getClass().getResource(STYLESHEET).toExternalForm()); - stage.setScene(scene); - stage.centerOnScreen(); - } - - private void showConnectionLogin() { - LoginView loginView = new LoginView(context, this::showMainView); - showPage(loginView, 860, 620); - stage.setTitle(Messages.get("app.title")); - } - - private void showMainView(ConnectionProfile profile) { + /** + * The main window where it was closed, the data sources which were connected then are connected again + */ + private void showMainView() { AppSettings settings = context.getSettings(); MainView mainView = new MainView(context); - showPage(mainView, settings.getWindowWidth(), settings.getWindowHeight()); + Scene scene = new Scene(mainView, settings.getWindowWidth(), settings.getWindowHeight()); + scene.getStylesheets().add(getClass().getResource(STYLESHEET).toExternalForm()); + stage.setScene(scene); + WindowPlacement.restore(stage, settings); stage.setOnCloseRequest(event -> { - settings.setWindowWidth(stage.getWidth()); - settings.setWindowHeight(stage.getHeight()); + WindowPlacement.save(stage, settings); context.saveSettings(); }); - mainView.selectConnection(profile); + // After the window is shown, a password which is asked has its owner + stage.setOnShown(event -> Platform.runLater(() -> { + mainView.restoreConnected(); + // Sync tasks run while SqlJam is open, a run which was missed meanwhile runs now + Config config = Config.getInstance(); + if (config.getBoolean("sqljam.sync.enabled", true)) { + context.getSyncScheduler().start(config.getInt("sqljam.sync.tick-seconds", 30)); + } + })); } @Override public void stop() { if (context != null) { + context.closeSyncScheduler(); context.getSessionManager().closeAll(); } TaskRunner.shutdown(); diff --git a/src/main/java/com/github/sqljam/face/SyncCommand.java b/src/main/java/com/github/sqljam/face/SyncCommand.java new file mode 100644 index 0000000..48f8a97 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/SyncCommand.java @@ -0,0 +1,197 @@ +/* + * 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 java.io.PrintStream; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.config.Config; +import com.github.sqljam.face.model.SyncTask; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.service.ConnectionStore; +import com.github.sqljam.face.service.ReportExporter; +import com.github.sqljam.face.service.SettingsStore; +import com.github.sqljam.face.service.SyncScheduler; +import com.github.sqljam.face.view.AppContext; +import com.github.sqljam.face.view.ProfileRegistry; + +/** + * @Description: SyncCommand runs sync tasks from the command line, without the window, so that the scheduler of the + * system runs them when SqlJam is closed: + * + *
+ *               sqljam sync --list                    the tasks with their schedules and last runs
+ *               sqljam sync --task NAME_OR_ID         runs a task now
+ *               sqljam sync --task NAME_OR_ID --if-due   runs it when it is due
+ *               sqljam sync --due                     runs every task which is due
+ *               
+ * + * Each run leaves its report in the history of SqlJam. The exit code is 0 when the runs completed, + * 1 when one failed or was cancelled, 2 for a wrong command or a task which is not there. A task which + * runs in SqlJam or another command meanwhile is not run twice. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class SyncCommand { + + public static final String NAME = "sync"; + public static final String ORIGIN = "Command line"; + static final int OK = 0; + static final int FAILED = 1; + static final int USAGE = 2; + /** + * A system which wakes at the time of a task may wake a little before its next run + */ + static final long SLACK_MILLIS = 60_000; + + private SyncCommand() { + } + + public static boolean isCommand(String[] args) { + return args.length > 0 && NAME.equals(args[0]); + } + + public static int run(String[] args, PrintStream out, PrintStream err) { + Config config = Config.getInstance(); + AppContext context = new AppContext(new SettingsStore(config), new ProfileRegistry(new ConnectionStore()), + null); + try { + return run(args, context, out, err); + } finally { + context.closeSyncScheduler(); + context.getSessionManager().closeAll(); + } + } + + static int run(String[] args, AppContext context, PrintStream out, PrintStream err) { + List options = List.of(args).subList(Math.min(1, args.length), args.length); + SyncScheduler scheduler = context.getSyncScheduler(); + scheduler.setOrigin(ORIGIN); + if (options.equals(List.of("--list"))) { + list(scheduler, out); + return OK; + } + if (options.equals(List.of("--due"))) { + int code = OK; + for (SyncTask task : scheduler.getStore().getTasks()) { + if (isDue(scheduler, task)) { + code = Math.max(code, runTask(scheduler, task, out, err)); + } + } + return code; + } + if (options.size() >= 2 && options.get(0).equals("--task") && (options.size() == 2 || options.size() == 3 + && options.get(2).equals("--if-due"))) { + Optional task = find(scheduler, options.get(1)); + if (task.isEmpty()) { + err.println("No sync task " + options.get(1) + ", sqljam sync --list shows them."); + return USAGE; + } + if (options.size() == 3 && !isDue(scheduler, task.get())) { + out.println(task.get().getName() + ": not due"); + return OK; + } + return runTask(scheduler, task.get(), out, err); + } + usage(err); + return USAGE; + } + + static void usage(PrintStream err) { + err.println("Usage: sqljam sync --list"); + err.println(" sqljam sync --task NAME_OR_ID [--if-due]"); + err.println(" sqljam sync --due"); + } + + /** + * A task by its id, or by its name without case + */ + static Optional find(SyncScheduler scheduler, String nameOrId) { + List tasks = scheduler.getStore().getTasks(); + Optional byId = tasks.stream().filter(task -> task.getId().equals(nameOrId)).findFirst(); + return byId.isPresent() ? byId : tasks.stream().filter(task -> StringUtils.equalsIgnoreCase(task.getName(), + nameOrId)).findFirst(); + } + + static boolean isDue(SyncScheduler scheduler, SyncTask task) { + long now = System.currentTimeMillis(); + long next = scheduler.nextRunAt(task); + return next >= 0 && next <= now + SLACK_MILLIS; + } + + private static int runTask(SyncScheduler scheduler, SyncTask task, PrintStream out, PrintStream err) { + CompletableFuture future = scheduler.runNow(task.getId()); + TransferReport report; + try { + report = future != null ? future.get() : null; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + err.println(task.getName() + ": interrupted"); + return FAILED; + } catch (Exception e) { + err.println(task.getName() + ": " + e.getMessage()); + return FAILED; + } + if (report == null) { + out.println(task.getName() + ": runs already elsewhere, not run again"); + return OK; + } + String line = String.format("%s: %s, %,d rows in %s, report %s", task.getName(), ReportExporter.words(report + .getStatus()), report.getWrittenRows(), ReportExporter.duration(report.getElapsedMillis()), report + .getId()); + if (report.getStatus().isKept()) { + out.println(line); + return OK; + } + err.println(line + (report.getFailure() != null ? ": " + report.getFailure() : "")); + return FAILED; + } + + private static void list(SyncScheduler scheduler, PrintStream out) { + List tasks = scheduler.getStore().getTasks(); + if (tasks.isEmpty()) { + out.println("No sync tasks, create them in SqlJam: Transfer > Sync Tasks"); + return; + } + out.println(String.join("\t", "ID", "Name", "Runner", "Schedule", "Next run", "Last run", "Last status")); + for (SyncTask task : tasks) { + long next = scheduler.nextRunAt(task); + out.println(List.of(task.getId(), task.getName(), task.getRunner().name(), schedule(task), next >= 0 + ? ReportExporter.time(next) : "", ReportExporter.time(task.getLastRunAt()), task.getLastStatus() + != null ? ReportExporter.words(task.getLastStatus()) : "").stream().collect(Collectors.joining( + "\t"))); + } + } + + static String schedule(SyncTask task) { + if (!task.isEnabled()) { + return "disabled"; + } + switch (task.getScheduleType()) { + case INTERVAL: + return "every " + task.getIntervalMinutes() + " min"; + case DAILY: + return "daily " + task.getDailyTime(); + default: + return "manual"; + } + } +} diff --git a/src/main/java/com/github/sqljam/face/model/AppSettings.java b/src/main/java/com/github/sqljam/face/model/AppSettings.java index bde2635..c2e2d68 100644 --- a/src/main/java/com/github/sqljam/face/model/AppSettings.java +++ b/src/main/java/com/github/sqljam/face/model/AppSettings.java @@ -15,6 +15,9 @@ */ package com.github.sqljam.face.model; +import java.util.ArrayList; +import java.util.List; + import com.github.sqljam.impexp.Exporter; import lombok.Getter; import lombok.Setter; @@ -56,4 +59,14 @@ public class AppSettings { private int confirmCommitReminderMinutes = 10; private double windowWidth = 1280; private double windowHeight = 800; + /** + * Position of the main window on the screens, NaN until it was closed once + */ + private double windowX = Double.NaN; + private double windowY = Double.NaN; + private boolean windowMaximized; + /** + * Ids of the data sources which were connected when the window was closed, they are connected again + */ + private List connectedSources = new ArrayList<>(); } 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 bf19175..14aaa61 100644 --- a/src/main/java/com/github/sqljam/face/model/ConnectionProfile.java +++ b/src/main/java/com/github/sqljam/face/model/ConnectionProfile.java @@ -16,9 +16,14 @@ package com.github.sqljam.face.model; import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.github.sqljam.impexp.DbType; +import com.github.sqljam.utils.SshSettings; +import com.github.sqljam.utils.SshUtils; import lombok.Getter; import lombok.Setter; import lombok.ToString; @@ -49,7 +54,20 @@ public class ConnectionProfile { * Custom jdbc url, overrides hostname, port and database */ private String url; + /** + * SSH server through which the host and port are reached, none if it is null or disabled + */ + private SshSettings ssh; + + /** + * Host and port of a custom url, e.g. //host:5432/ or @host:1521 + */ + static final Pattern URL_ADDRESS = Pattern.compile("(?<=//|@)([\\w.-]+|\\[[^\\]]+\\]):(\\d+)"); + /** + * Jdbc url of the database, not saved: it is made of the fields, and an SSH tunnel is opened for it + */ + @JsonIgnore public String getJdbcUrl() { return getJdbcUrl(database); } @@ -62,12 +80,59 @@ public String getJdbcUrl(String catalog) { // The catalog of a database file is the file itself catalog = null; } - if (StringUtils.isNotBlank(url) && (StringUtils.isBlank(catalog) || StringUtils.equals(catalog, database))) { + boolean custom = StringUtils.isNotBlank(url) && (StringUtils.isBlank(catalog) || StringUtils.equals(catalog, + database)); + if (isSshEnabled()) { + return getTunnelUrl(custom, catalog); + } + if (custom) { return dbType != null ? dbType.normalizeUrl(url) : url; } return dbType.getUrl(hostname, port, StringUtils.defaultIfBlank(catalog, database)); } + /** + * Whether the data source is reached through its SSH server, a database file never is + */ + @JsonIgnore + public boolean isSshEnabled() { + return ssh != null && ssh.isEnabled() && (dbType == null || !dbType.isFileBased()); + } + + /** + * Url of the local end of the SSH tunnel: the host and port, as the SSH server reaches them, are forwarded to + * a local port. The tunnel is opened when the url is first asked for. + */ + private String getTunnelUrl(boolean custom, String catalog) { + String[] target = tunnelTarget(custom); + int localPort = SshUtils.open(id, ssh, target[0], Integer.parseInt(target[1])); + if (custom) { + Matcher matcher = URL_ADDRESS.matcher(url); + String local = matcher.find() ? matcher.replaceFirst(SshUtils.LOCALHOST_ADDRESS + ":" + localPort) + : url; + return dbType != null ? dbType.normalizeUrl(local) : local; + } + return dbType.getUrl(SshUtils.LOCALHOST_ADDRESS, localPort, StringUtils.defaultIfBlank(catalog, database)); + } + + /** + * Host and port which the SSH server forwards to: those of the fields with the default port of the type, or + * those of a custom url, a host in brackets (IPv6) without them + */ + public String[] tunnelTarget(boolean custom) { + String targetHost = hostname; + int targetPort = port > 0 ? port : dbType.getDefaultPort(); + Matcher matcher = URL_ADDRESS.matcher(StringUtils.defaultString(url)); + if (StringUtils.isNotBlank(url) && matcher.find() && (custom || StringUtils.isBlank(hostname))) { + targetHost = matcher.group(1); + targetPort = Integer.parseInt(matcher.group(2)); + } + if (StringUtils.isBlank(targetHost)) { + throw new IllegalStateException("No host of " + name + " to reach through SSH"); + } + return new String[]{targetHost.replaceAll("^\\[|\\]$", ""), String.valueOf(targetPort)}; + } + public ConnectionProfile copy() { ConnectionProfile copy = new ConnectionProfile(); copy.setId(id); @@ -79,6 +144,7 @@ public ConnectionProfile copy() { copy.setUsername(username); copy.setPassword(password); copy.setUrl(url); + copy.setSsh(ssh != null ? ssh.copy() : null); return copy; } } diff --git a/src/main/java/com/github/sqljam/face/model/SyncTask.java b/src/main/java/com/github/sqljam/face/model/SyncTask.java new file mode 100644 index 0000000..4324613 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/model/SyncTask.java @@ -0,0 +1,200 @@ +/* + * 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.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import lombok.Setter; + +/** + * @Description: SyncTask copies the rows of tables which changed since its last run into a target database, on a + * schedule. A watermark column of each table (a column of the time of the last update by default, or + * an increasing key) tells the changed rows, they are updated or inserted by key. The first run copies + * all rows, its watermark is read before. The watermark moves on only when the rows are committed, so + * that a failed run is run again. Rows which were deleted in the source stay in the target. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Getter +@Setter +public class SyncTask { + + public enum Kind { + /** + * Tables of a data source into tables of another + */ + DATABASE, + /** + * Collections of a vector database into tables of a database with vectors + */ + VECTOR + } + + public enum WatermarkType { + /** + * A column of the time of the last change: rows of the same time are read again, they are updated + */ + UPDATED_AT, + /** + * A key which only grows: new rows are read, changed rows are not + */ + INCREASING_KEY + } + + /** + * Who runs the task on its schedule + */ + public enum Runner { + /** + * The scheduler of SqlJam, while it is open + */ + SQLJAM, + /** + * The scheduler of the system (crontab, Task Scheduler of Windows) by the command line, also when SqlJam + * is closed + */ + SYSTEM + } + + public enum ScheduleType { + /** + * Run Now only + */ + MANUAL, + /** + * Every N minutes + */ + INTERVAL, + /** + * Every day at a time + */ + DAILY + } + + /** + * A table or a collection of the task with its watermark + */ + @Getter + @Setter + public static class Table { + + private String name; + /** + * Name of the target table, the same name if it is blank + */ + private String targetName; + private String watermarkColumn; + private WatermarkType watermarkType = WatermarkType.UPDATED_AT; + /** + * Key columns of the updates, the primary key if it is empty + */ + private List keyColumns = new ArrayList<>(); + /** + * Watermark of the last committed run, null before the first run + */ + private Watermark watermark; + + public Table() { + } + + public Table(String name, String watermarkColumn, WatermarkType watermarkType) { + this.name = name; + this.watermarkColumn = watermarkColumn; + this.watermarkType = watermarkType; + } + } + + /** + * The greatest value of a watermark column which was copied, with its type to make a literal of it again + */ + @Getter + @Setter + public static class Watermark { + + /** + * Kind of the value: NUMBER, TIMESTAMP, DATE or TEXT + */ + private String type; + private String value; + + public Watermark() { + } + + public Watermark(String type, String value) { + this.type = type; + this.value = value; + } + + @Override + public String toString() { + return value; + } + } + + private String id = UUID.randomUUID().toString(); + private String name; + private Kind kind = Kind.DATABASE; + private boolean enabled = true; + /** + * Data source or vector database of the source + */ + private String sourceId; + private String sourceCatalog; + private String sourceSchema; + private String targetId; + private String targetCatalog; + private String targetSchema; + private List tables = new ArrayList<>(); + private ScheduleType scheduleType = ScheduleType.INTERVAL; + private Runner runner = Runner.SQLJAM; + private int intervalMinutes = 60; + /** + * Time of a daily run, HH:mm + */ + private String dailyTime = "02:00"; + /** + * Runs again after errors of the connection, errors of the data are not run again + */ + private int retries = 3; + private int pageSize = 5000; + private long createdAt = System.currentTimeMillis(); + private long lastRunAt; + private TransferReport.Status lastStatus; + private String lastReportId; + private long lastRowCount; + /** + * What started the last run, e.g. SqlJam or Command line + */ + private String lastRunBy; + + @JsonIgnore + public Table findTable(String name) { + return tables.stream().filter(table -> table.getName().equals(name)).findFirst().orElse(null); + } + + /** + * Whether the task has run once and has a watermark for every table, a full copy comes first otherwise + */ + @JsonIgnore + public boolean isInitialized() { + return !tables.isEmpty() && tables.stream().allMatch(table -> table.getWatermark() != null); + } +} diff --git a/src/main/java/com/github/sqljam/face/model/TransferReport.java b/src/main/java/com/github/sqljam/face/model/TransferReport.java new file mode 100644 index 0000000..5d76cbb --- /dev/null +++ b/src/main/java/com/github/sqljam/face/model/TransferReport.java @@ -0,0 +1,272 @@ +/* + * 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.model; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import lombok.Setter; + +/** + * @Description: TransferReport is the report of an import or an export, whatever its end: what was moved from where + * to where with which options, each table with its rows (read, written, skipped, failed, found in the + * target afterwards), the objects which were created, where it stopped when it failed, was cancelled or + * was rolled back, and the rows which were skipped. Reports are kept as the history of the imports. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Getter +@Setter +public class TransferReport { + + /** + * Rows which a report lists at most, the others are counted + */ + public static final int MAX_SKIPPED_ROWS = 1000; + + public enum Kind { + /** + * Tables or the rows of a query straight into another database + */ + DATABASE, + /** + * Tables into an export package of SQL, Parquet or CSV files + */ + PACKAGE, + /** + * An export package into a database + */ + PACKAGE_IMPORT, + /** + * Parquet or CSV files of other tools into a database + */ + FILE_IMPORT, + /** + * Collections of a vector database into a database with vectors + */ + VECTOR_IMPORT, + /** + * A run of a sync task: the rows of tables which changed since its last run + */ + SYNC, + /** + * A run of a sync task of collections of a vector database + */ + VECTOR_SYNC + } + + public enum Status { + RUNNING, COMPLETED, COMPLETED_WITH_ERRORS, COMMITTED, ROLLED_BACK, FAILED, CANCELLED; + + /** + * Whether the rows which were written are kept in the target + */ + public boolean isKept() { + return this == COMPLETED || this == COMPLETED_WITH_ERRORS || this == COMMITTED; + } + } + + /** + * How the rows are committed: in one transaction, every N rows, or not at all for files + */ + public enum Transaction { + ONE, EVERY_ROWS, NONE + } + + /** + * The verdict of a table after the transfer + */ + public enum TableStatus { + /** + * Not reached before the end + */ + NOT_STARTED, + /** + * Started and not ended: the transfer stopped in this table + */ + INTERRUPTED, + COMPLETED, + COMPLETED_WITH_ERRORS, + FAILED + } + + @Getter + @Setter + public static class TableReport { + + private String catalog; + private String schema; + private String name; + /** + * Rows of the source counted before the table was read, -1 when they were not counted + */ + private long sourceRows = -1; + private long written; + private long skipped; + private long failed; + /** + * Rows of the target table found after the transfer, null when they were not counted + */ + private Long targetRows; + /** + * Whether the target rows are the rows of the transfer only (tables which were created), so that they are + * compared with the written rows + */ + private boolean comparable; + private TableStatus status = TableStatus.NOT_STARTED; + private long startedAt; + private long endedAt; + private String error; + + @JsonIgnore + public String getQualifiedName() { + StringBuilder name = new StringBuilder(); + if (catalog != null) { + name.append(catalog).append('.'); + } + if (schema != null) { + name.append(schema).append('.'); + } + return name.append(this.name).toString(); + } + + @JsonIgnore + public long getElapsedMillis() { + return startedAt > 0 && endedAt >= startedAt ? endedAt - startedAt : 0; + } + + /** + * Rows written a second, 0 for a table which took no time + */ + @JsonIgnore + public double getRowsPerSecond() { + long millis = getElapsedMillis(); + return millis > 0 ? written * 1000.0 / millis : 0; + } + + /** + * Whether the rows found in the target are the rows which were written, null when they can not be compared + */ + @JsonIgnore + public Boolean getVerified() { + return targetRows != null && comparable ? targetRows == written : null; + } + } + + @Getter + @Setter + public static class SkippedRow { + + private String table; + private String key; + private String error; + + public SkippedRow() { + } + + public SkippedRow(String table, String key, String error) { + this.table = table; + this.key = key; + this.error = error; + } + } + + private String id = UUID.randomUUID().toString(); + private Kind kind; + private String title; + /** + * The source, e.g. a data source and its schema, a package directory or a vector database + */ + private String source; + /** + * The target, e.g. a data source and its schema or a package directory and its format + */ + private String target; + /** + * Options of the transfer by name, in the order in which they are shown + */ + private Map options = new LinkedHashMap<>(); + private Transaction transaction = Transaction.NONE; + private Status status = Status.RUNNING; + private long startedAt; + private long endedAt; + private List tables = new ArrayList<>(); + /** + * Objects which were created by kind, e.g. Tables 5, Indexes 7, and the statements which failed + */ + private Map objects = new LinkedHashMap<>(); + private int failedStatements; + private List skippedRows = new ArrayList<>(); + private long skippedRowCount; + private List errors = new ArrayList<>(); + /** + * The error which ended the transfer, with the trace of its causes + */ + private String failure; + private String failureTrace; + /** + * Where the transfer stopped when it did not complete: the table and the rows written in it + */ + private String stoppedAt; + /** + * The transaction was rolled back, nothing which was written is kept in the target + */ + private boolean rolledBack; + /** + * Summary of the end, e.g. statements and files of a package + */ + private String summary; + + @JsonIgnore + public long getElapsedMillis() { + return endedAt >= startedAt && startedAt > 0 ? endedAt - startedAt : 0; + } + + @JsonIgnore + public long getWrittenRows() { + return tables.stream().mapToLong(TableReport::getWritten).sum(); + } + + @JsonIgnore + public long getSourceRows() { + return tables.stream().mapToLong(table -> Math.max(0, table.getSourceRows())).sum(); + } + + /** + * The report of the table, created when it is first told + */ + public TableReport table(String catalog, String schema, String name) { + for (TableReport table : tables) { + if (name != null && name.equals(table.getName()) && (catalog == null || table.getCatalog() == null + || catalog.equals(table.getCatalog())) && (schema == null || table.getSchema() == null + || schema.equals(table.getSchema()))) { + return table; + } + } + TableReport table = new TableReport(); + table.setCatalog(catalog); + table.setSchema(schema); + table.setName(name); + tables.add(table); + return table; + } +} 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 a8ac4d4..6989ad6 100644 --- a/src/main/java/com/github/sqljam/face/model/TransferRequest.java +++ b/src/main/java/com/github/sqljam/face/model/TransferRequest.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Map; +import com.github.sqljam.impexp.ColumnMapping; import com.github.sqljam.impexp.CsvOptions; import com.github.sqljam.impexp.DataFileStrategy; import com.github.sqljam.impexp.DataFormat; @@ -131,6 +132,11 @@ public void setCsvDelimiter(String csvDelimiter) { * Chosen key columns of upserts by table, the primary key of other tables */ private Map> keyColumnNames = new HashMap<>(); + /** + * Columns which the user mapped by table and column, for imports into a database; none imports the tables as + * they are + */ + private Map> columnMappings = new HashMap<>(); /** * Name pattern of target tables, {table} is the source table name, e.g. {table}_copy copies tables in the same * schema. Target tables have the same names if it is blank. diff --git a/src/main/java/com/github/sqljam/face/service/DashboardSampler.java b/src/main/java/com/github/sqljam/face/service/DashboardSampler.java new file mode 100644 index 0000000..d064fb2 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/service/DashboardSampler.java @@ -0,0 +1,140 @@ +/* + * Copyright 2023-2026 Fred Feng + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.sqljam.face.service; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.LongSupplier; + +/** + * @Description: DashboardSampler reads the metrics of a source and turns them into the values of the series of the + * charts: gauges as they are, counters a second since the last sample, ratios of the growth of + * counters in percent. A counter which went back (a restart of the server) starts again. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class DashboardSampler { + + /** + * Values of the series of one moment by chart and series, e.g. "Sessions/Active" + */ + public static final class Sample { + + private final long time; + private final Map values; + + Sample(long time, Map values) { + this.time = time; + this.values = values; + } + + public long getTime() { + return time; + } + + public Map getValues() { + return values; + } + + public Double get(MetricSource.Chart chart, MetricSource.Series series) { + return values.get(key(chart, series)); + } + } + + private final MetricSource source; + private final LongSupplier clock; + private Map previous; + private long previousTime; + + public DashboardSampler(MetricSource source) { + this(source, System::currentTimeMillis); + } + + DashboardSampler(MetricSource source, LongSupplier clock) { + this.source = source; + this.clock = clock; + } + + public MetricSource getSource() { + return source; + } + + static String key(MetricSource.Chart chart, MetricSource.Series series) { + return chart.getTitle() + "/" + series.getName(); + } + + /** + * Reads the source once: rates and ratios have values from the second sample on + */ + public Sample sample() throws Exception { + Map raw; + try { + raw = source.read(); + } catch (Exception e) { + // The counters of a new connection start again + previous = null; + throw e; + } + long time = clock.getAsLong(); + double seconds = previous != null ? (time - previousTime) / 1000.0 : 0; + Map values = new LinkedHashMap<>(); + for (MetricSource.Chart chart : source.getCharts()) { + for (MetricSource.Series series : chart.getSeries()) { + Double value = value(series, raw, seconds); + if (value != null) { + values.put(key(chart, series), value); + } + } + } + previous = new HashMap<>(raw); + previousTime = time; + return new Sample(time, values); + } + + private Double value(MetricSource.Series series, Map raw, double seconds) { + Double now = raw.get(series.getKey()); + switch (series.getKind()) { + case GAUGE: + return now; + case RATE: + Double grown = growth(series.getKey(), raw); + return grown != null && seconds > 0 ? grown / seconds : null; + default: + Double part = growth(series.getKey(), raw); + double total = 0; + for (String key : series.getTotalKeys()) { + Double growth = growth(key, raw); + if (growth == null) { + return null; + } + total += growth; + } + // Nothing happened: no share to tell + return part != null && total > 0 ? Math.min(100, Math.max(0, part * 100 / total)) : null; + } + } + + private Double growth(String key, Map raw) { + Double now = raw.get(key); + Double before = previous != null ? previous.get(key) : null; + if (now == null || before == null || now < before) { + return null; + } + return now - before; + } +} diff --git a/src/main/java/com/github/sqljam/face/service/DatabaseInfo.java b/src/main/java/com/github/sqljam/face/service/DatabaseInfo.java new file mode 100644 index 0000000..757ccfa --- /dev/null +++ b/src/main/java/com/github/sqljam/face/service/DatabaseInfo.java @@ -0,0 +1,314 @@ +/* + * 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.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; +import java.util.concurrent.Callable; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.impexp.DatabaseFeature; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.Dialect; +import com.github.sqljam.utils.SshSettings; +import com.github.sqljam.utils.SshUtils; + +/** + * @Description: DatabaseInfo reads the overview and the health of a data source for its info tab: the server, the + * driver, the session and the features which SqlJam uses (vectors and their indexes), then whether it + * answers and how fast, its uptime, connections and the space of the database. Each item is read by + * itself, an item which is not readable (no privilege, another version) tells why. It is read only + * and serves the imports and exports, it manages nothing. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class DatabaseInfo { + + public static final String UNAVAILABLE = "Unavailable: "; + + private final ConnectionProfile profile; + private final DatabaseSession session; + + public DatabaseInfo(ConnectionProfile profile, DatabaseSession session) { + this.profile = profile; + this.session = session; + } + + static String read(Callable item) { + try { + Object value = item.call(); + return value == null || StringUtils.isBlank(value.toString()) ? "-" : value.toString(); + } catch (Exception e) { + return UNAVAILABLE + StringUtils.abbreviate(StringUtils.defaultString(e.getMessage(), e.getClass() + .getSimpleName()).trim(), 200); + } + } + + /** + * First column of the first row of a query + */ + static String query(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) { + return rs.next() ? rs.getString(1) : null; + } catch (SQLException e) { + if (!connection.getAutoCommit()) { + connection.rollback(); + } + throw e; + } + } + + static String bytes(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } + String[] units = {"KB", "MB", "GB", "TB"}; + double value = bytes; + int unit = -1; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return String.format(Locale.ENGLISH, "%.1f %s", value, units[unit]); + } + + static String duration(long seconds) { + long days = seconds / 86400; + long hours = seconds % 86400 / 3600; + long minutes = seconds % 3600 / 60; + return days > 0 ? String.format("%dd %dh %dm", days, hours, minutes) : String.format("%dh %dm", hours, + minutes); + } + + private Connection connect() throws SQLException { + return session.openSqlConnection(null, null); + } + + /** + * Server, driver, session and the features of SqlJam + */ + public Map> getOverview() throws SQLException { + Map> sections = new LinkedHashMap<>(); + DbType dbType = profile.getDbType(); + try (Connection connection = connect()) { + DatabaseMetaData metaData = connection.getMetaData(); + Map server = new LinkedHashMap<>(); + // The version of some drivers starts with the name (Oracle) + server.put("Product", read(() -> metaData.getDatabaseProductVersion().startsWith(metaData + .getDatabaseProductName()) ? metaData.getDatabaseProductVersion() : metaData + .getDatabaseProductName() + " " + metaData.getDatabaseProductVersion())); + server.put("Version", read(() -> metaData.getDatabaseMajorVersion() + "." + metaData + .getDatabaseMinorVersion())); + server.put("URL", read(metaData::getURL)); + server.put("User", read(metaData::getUserName)); + if (profile.isSshEnabled()) { + // The url is of the local end of the tunnel + SshSettings ssh = profile.getSsh(); + server.put("SSH tunnel", ssh.getUsername() + "@" + ssh.getHost() + ":" + ssh.getEffectivePort() + + ", local port " + SshUtils.getLocalPort(profile.getId())); + } + sections.put("Server", server); + + Map driver = new LinkedHashMap<>(); + driver.put("Driver", read(() -> metaData.getDriverName() + " " + metaData.getDriverVersion())); + driver.put("JDBC", read(() -> metaData.getJDBCMajorVersion() + "." + metaData.getJDBCMinorVersion())); + sections.put("Driver", driver); + + Map sessionInfo = new LinkedHashMap<>(); + sessionInfo.put("Catalog", read(connection::getCatalog)); + sessionInfo.put("Schema", read(connection::getSchema)); + sessionInfo.put("Read only", read(() -> connection.isReadOnly() ? "Yes" : "No")); + sessionInfo.put("Isolation", read(() -> isolation(connection.getTransactionIsolation()))); + sessionInfo.put("Transactions", read(() -> metaData.supportsTransactions() ? "Supported" + : "Not supported")); + sessionInfo.put("Client time zone", TimeZone.getDefault().getID()); + sections.put("Session", sessionInfo); + + Map sqljam = new LinkedHashMap<>(); + Dialect dialect = read(() -> dbType.createDialect(metaData.getDatabaseMajorVersion(), metaData + .getDatabaseMinorVersion()).getClass().getSimpleName()).startsWith(UNAVAILABLE) ? null + : dbType.createDialect(metaData.getDatabaseMajorVersion(), metaData.getDatabaseMinorVersion()); + sqljam.put("Dialect", dialect != null ? dialect.getClass().getSimpleName() : "-"); + sqljam.put("Features", read(() -> { + Set features = dialect.readDatabaseFeatures(connection); + return features.isEmpty() ? "None detected" : features.stream().map(Enum::name).collect( + Collectors.joining(", ")); + })); + sqljam.put("Category", dbType.getCategory().getDisplayName()); + sections.put("SqlJam", sqljam); + } + return sections; + } + + static String isolation(int level) { + switch (level) { + case Connection.TRANSACTION_READ_UNCOMMITTED: + return "Read uncommitted"; + case Connection.TRANSACTION_READ_COMMITTED: + return "Read committed"; + case Connection.TRANSACTION_REPEATABLE_READ: + return "Repeatable read"; + case Connection.TRANSACTION_SERIALIZABLE: + return "Serializable"; + default: + return "None"; + } + } + + /** + * Whether the server answers and how fast, its uptime, its connections and the space of the database + */ + public Map> getHealth() { + Map> sections = new LinkedHashMap<>(); + Map reach = new LinkedHashMap<>(); + sections.put("Connection", reach); + long start = System.nanoTime(); + Connection connection; + try { + connection = connect(); + } catch (SQLException e) { + reach.put("Reachable", UNAVAILABLE + e.getMessage()); + return sections; + } + try (Connection opened = connection) { + reach.put("Reachable", "Yes"); + reach.put("Connect time", (System.nanoTime() - start) / 1_000_000 + " ms"); + long ping = System.nanoTime(); + reach.put("Valid", read(() -> opened.isValid(10) ? "Yes" : "No")); + reach.put("Response time", (System.nanoTime() - ping) / 1_000_000 + " ms"); + Map server = new LinkedHashMap<>(); + sections.put("Server", server); + readServer(opened, server); + } catch (SQLException e) { + reach.put("Closed", UNAVAILABLE + e.getMessage()); + } + return sections; + } + + private void readServer(Connection connection, Map server) { + switch (profile.getDbType()) { + case POSTGRESQL: + server.put("Uptime", read(() -> duration(Long.parseLong(query(connection, + "SELECT CAST(EXTRACT(EPOCH FROM now() - pg_postmaster_start_time()) AS bigint)"))))); + server.put("Connections", read(() -> query(connection, "SELECT count(*) FROM pg_stat_activity") + + " of " + query(connection, "SELECT current_setting('max_connections')"))); + server.put("Database size", read(() -> bytes(Long.parseLong(query(connection, + "SELECT pg_database_size(current_database())"))))); + server.put("Encoding", read(() -> query(connection, "SELECT current_setting('server_encoding')"))); + server.put("Time zone", read(() -> query(connection, "SELECT current_setting('TimeZone')"))); + server.put("pgvector", read(() -> StringUtils.defaultIfBlank(query(connection, + "SELECT extversion FROM pg_extension WHERE extname = 'vector'"), "Not installed"))); + break; + case MYSQL: + case MARIADB: + server.put("Uptime", read(() -> duration(Long.parseLong(status(connection, "Uptime"))))); + server.put("Connections", read(() -> status(connection, "Threads_connected") + " of " + + query(connection, "SELECT @@max_connections"))); + server.put("Database size", read(() -> bytes(Long.parseLong(StringUtils.defaultIfBlank(query( + connection, "SELECT SUM(data_length + index_length) FROM information_schema.tables" + + " WHERE table_schema = DATABASE()"), "0"))))); + server.put("Character set", read(() -> query(connection, "SELECT @@character_set_server"))); + server.put("Time zone", read(() -> query(connection, "SELECT @@time_zone"))); + break; + case ORACLE: + server.put("Uptime", read(() -> duration(Long.parseLong(query(connection, + "SELECT ROUND((SYSDATE - startup_time) * 86400) FROM v$instance"))))); + server.put("Sessions", read(() -> query(connection, "SELECT COUNT(*) FROM v$session") + + " of " + query(connection, "SELECT value FROM v$parameter WHERE name = 'sessions'"))); + server.put("Schema size", read(() -> bytes(Long.parseLong(StringUtils.defaultIfBlank(query( + connection, "SELECT SUM(bytes) FROM user_segments"), "0"))))); + server.put("Character set", read(() -> query(connection, "SELECT value FROM" + + " nls_database_parameters WHERE parameter = 'NLS_CHARACTERSET'"))); + server.put("Vector memory", read(() -> bytes(Long.parseLong(query(connection, + "SELECT value FROM v$parameter WHERE name = 'vector_memory_size'"))))); + break; + case SQLSERVER: + server.put("Uptime", read(() -> duration(Long.parseLong(query(connection, + "SELECT DATEDIFF(SECOND, sqlserver_start_time, SYSDATETIME()) FROM sys.dm_os_sys_info"))))); + server.put("Sessions", read(() -> query(connection, + "SELECT COUNT(*) FROM sys.dm_exec_sessions WHERE is_user_process = 1") + " of " + + query(connection, "SELECT @@MAX_CONNECTIONS"))); + server.put("Database size", read(() -> bytes(Long.parseLong(query(connection, + "SELECT CAST(SUM(size) AS bigint) * 8192 FROM sys.database_files"))))); + server.put("Collation", read(() -> query(connection, + "SELECT CAST(DATABASEPROPERTYEX(DB_NAME(), 'Collation') AS nvarchar(128))"))); + break; + case DB2: + server.put("Connected since", read(() -> query(connection, + "SELECT DB_CONN_TIME FROM TABLE(MON_GET_DATABASE(-2))"))); + server.put("Connections", read(() -> query(connection, + "SELECT APPLS_CUR_CONS FROM TABLE(MON_GET_DATABASE(-2))"))); + server.put("Code page", read(() -> query(connection, + "SELECT VALUE FROM SYSIBMADM.DBCFG WHERE NAME = 'codeset'"))); + break; + case CLICKHOUSE: + server.put("Uptime", read(() -> duration(Long.parseLong(query(connection, "SELECT uptime()"))))); + server.put("Connections", read(() -> query(connection, + "SELECT value FROM system.metrics WHERE metric = 'TCPConnection'") + " TCP, " + + query(connection, "SELECT value FROM system.metrics WHERE metric = 'HTTPConnection'") + + " HTTP")); + server.put("Database size", read(() -> bytes(Long.parseLong(StringUtils.defaultIfBlank(query( + connection, "SELECT SUM(bytes_on_disk) FROM system.parts WHERE active AND database" + + " = currentDatabase()"), "0"))))); + server.put("Free disk", read(() -> bytes(Long.parseLong(query(connection, + "SELECT free_space FROM system.disks WHERE name = 'default'"))))); + break; + case DUCKDB: + server.put("Database size", read(() -> query(connection, + "SELECT database_size FROM pragma_database_size() LIMIT 1"))); + server.put("Memory", read(() -> query(connection, + "SELECT memory_usage FROM pragma_database_size() LIMIT 1") + " of " + query(connection, + "SELECT current_setting('memory_limit')"))); + server.put("File", read(() -> fileSize())); + break; + default: + // Embedded databases of a file: H2, SQLite, Access + server.put("File", read(() -> fileSize())); + break; + } + } + + private static String status(Connection connection, String name) throws SQLException { + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery( + "SHOW GLOBAL STATUS LIKE '" + name + "'")) { + return rs.next() ? rs.getString(2) : null; + } + } + + /** + * Size of the file of an embedded database + */ + String fileSize() { + String database = StringUtils.defaultString(profile.getDatabase()); + File file = new File(database); + if (!file.isFile() && new File(database + ".mv.db").isFile()) { + file = new File(database + ".mv.db"); + } + return file.isFile() ? file.getAbsolutePath() + ", " + bytes(file.length()) : "Not a local file"; + } +} 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 fa2739c..3fa59bf 100644 --- a/src/main/java/com/github/sqljam/face/service/DatabaseSession.java +++ b/src/main/java/com/github/sqljam/face/service/DatabaseSession.java @@ -40,6 +40,7 @@ import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; +import lombok.extern.slf4j.Slf4j; import com.github.sqljam.face.model.ColumnInfo; import com.github.sqljam.face.model.ConnectionProfile; import com.github.sqljam.face.model.DataPage; @@ -56,7 +57,7 @@ import com.github.sqljam.impexp.TableQuery; import com.github.sqljam.jdbc.ConnectionFactory; import com.github.sqljam.jdbc.JdbcUtils; -import lombok.extern.slf4j.Slf4j; +import com.github.sqljam.utils.SshUtils; /** * @Description: DatabaseSession browses a database: catalogs, schemas, tables, metadata and rows of tables @@ -100,9 +101,15 @@ public DbType getDbType() { */ public static String testConnection(ConnectionProfile profile) throws SQLException { DriverManager.setLoginTimeout(10); + // A tunnel opened only for the test is closed after it + boolean tunnelOpened = profile.isSshEnabled() && !SshUtils.isOpen(profile.getId()); try (Connection connection = connect(profile, profile.getJdbcUrl())) { DatabaseMetaData metaData = connection.getMetaData(); return metaData.getDatabaseProductName() + " " + metaData.getDatabaseProductVersion(); + } finally { + if (tunnelOpened) { + SshUtils.close(profile.getId()); + } } } @@ -123,6 +130,12 @@ private ConnectionFactory getConnectionFactory(String catalog) { } private Connection getConnection(String catalog) throws SQLException { + if (profile.isSshEnabled() && !SshUtils.isOpen(profile.getId())) { + // A tunnel which was closed, e.g. by the network, is opened again on its local port, the connections + // of the pools went through the closed one + closePools(); + profile.getJdbcUrl(); + } return getConnectionFactory(catalog).getConnection(); } @@ -491,6 +504,11 @@ public String getDdl(String catalog, String schema, String table, DbType targetD @Override public void close() { + closePools(); + SshUtils.close(profile.getId()); + } + + private void closePools() { for (ConnectionFactory connectionFactory : connectionFactories.values()) { try { connectionFactory.destroy(); diff --git a/src/main/java/com/github/sqljam/face/service/JdbcMetrics.java b/src/main/java/com/github/sqljam/face/service/JdbcMetrics.java new file mode 100644 index 0000000..9460a7a --- /dev/null +++ b/src/main/java/com/github/sqljam/face/service/JdbcMetrics.java @@ -0,0 +1,527 @@ +/* + * 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.lang.management.ManagementFactory; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.service.MetricSource.Chart; +import com.github.sqljam.face.service.MetricSource.Series; +import lombok.extern.slf4j.Slf4j; + +/** + * @Description: JdbcMetrics reads the metrics of the dashboard of a data source by SQL, as many as the server tells: + * sessions, transactions, statements, rows, cache, locks, network, CPU, memory and size, from the + * statistics views of each database. Embedded databases (H2, SQLite, Access, DuckDB) run in SqlJam, + * their dashboard shows the CPU and the memory of SqlJam and the size of their file. A metric which + * can not be read is told once with its reason and not read again, a connection which was lost is + * opened again. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Slf4j +public class JdbcMetrics implements MetricSource { + + static final String SAMPLE_TIME = "sample.millis"; + + @FunctionalInterface + interface Probe { + Double read(Context context) throws Exception; + } + + /** + * Queries of one sample, each runs once whatever the number of metrics it gives + */ + static final class Context { + + private final Connection connection; + private final Map results = new HashMap<>(); + + Context(Connection connection) { + this.connection = connection; + } + + Connection getConnection() { + return connection; + } + + private Object cached(String sql, boolean pairs) throws Exception { + Object result = results.computeIfAbsent((pairs ? "P:" : "R:") + sql, key -> { + try { + return pairs ? readPairs(connection, sql) : readRow(connection, sql); + } catch (Exception e) { + return e; + } + }); + if (result instanceof Exception) { + throw (Exception) result; + } + return result; + } + + /** + * Names and values of the rows of a query of two columns + */ + @SuppressWarnings("unchecked") + Map pairs(String sql) throws Exception { + return (Map) cached(sql, true); + } + + /** + * Values of the first row by the names of the columns in upper case + */ + @SuppressWarnings("unchecked") + Map row(String sql) throws Exception { + return (Map) cached(sql, false); + } + } + + private final ConnectionProfile profile; + private final DatabaseSession session; + private final List charts = new ArrayList<>(); + private final Map probes = new LinkedHashMap<>(); + private final Map unavailable = new LinkedHashMap<>(); + private Connection connection; + + public JdbcMetrics(ConnectionProfile profile, DatabaseSession session) { + this.profile = profile; + this.session = session; + define(); + chart("Sample time", Unit.MILLIS, Series.gauge("All metrics", SAMPLE_TIME)); + } + + @Override + public List getCharts() { + return charts; + } + + @Override + public synchronized Map getUnavailable() { + return new LinkedHashMap<>(unavailable); + } + + private void chart(String title, Unit unit, Series... series) { + charts.add(new Chart(title, unit, series)); + } + + private void probe(String key, Probe probe) { + probes.put(key, probe); + } + + private static Double number(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + try { + return Double.valueOf(value.toString().trim()); + } catch (NumberFormatException e) { + return null; + } + } + + static Map readPairs(Connection connection, String sql) throws SQLException { + Map values = new HashMap<>(); + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) { + while (rs.next()) { + String name = rs.getString(1); + Double value = number(rs.getObject(2)); + if (name != null && value != null) { + values.merge(name.trim(), value, Double::sum); + } + } + } + return values; + } + + static Map readRow(Connection connection, String sql) throws SQLException { + Map values = new HashMap<>(); + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) { + if (rs.next()) { + ResultSetMetaData metaData = rs.getMetaData(); + for (int i = 1; i <= metaData.getColumnCount(); i++) { + values.put(metaData.getColumnLabel(i).toUpperCase(Locale.ENGLISH), number(rs.getObject(i))); + } + } + } + return values; + } + + /** + * A value of the rows of a query of names and values, 0 for a name which is missing when zero is true (e.g. + * sessions of a state which no session has now) + */ + private static Probe pair(String sql, String name, boolean zero) { + return context -> { + Double value = context.pairs(sql).get(name); + return value == null && zero ? Double.valueOf(0) : value; + }; + } + + private static Probe column(String sql, String column) { + return context -> context.row(sql).get(column.toUpperCase(Locale.ENGLISH)); + } + + private static Probe single(String sql) { + return context -> { + Map row = context.row(sql); + return row.isEmpty() ? null : row.values().iterator().next(); + }; + } + + private void define() { + switch (profile.getDbType()) { + case POSTGRESQL: + definePostgreSql(); + break; + case MYSQL: + case MARIADB: + defineMySql(); + break; + case SQLSERVER: + defineSqlServer(); + break; + case ORACLE: + defineOracle(); + break; + case DB2: + defineDb2(); + break; + case CLICKHOUSE: + defineClickHouse(); + break; + default: + defineEmbedded(); + break; + } + } + + private void definePostgreSql() { + String states = "SELECT state, count(*) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> " + + "pg_backend_pid() GROUP BY state"; + String database = "SELECT xact_commit, xact_rollback, tup_fetched, tup_inserted, tup_updated, tup_deleted, " + + "blks_hit, blks_read FROM pg_stat_database WHERE datname = current_database()"; + chart("Sessions", Unit.COUNT, Series.gauge("Active", "pg.active"), Series.gauge("Idle", "pg.idle"), + Series.gauge("Idle in transaction", "pg.idleTx")); + probe("pg.active", pair(states, "active", true)); + probe("pg.idle", pair(states, "idle", true)); + probe("pg.idleTx", pair(states, "idle in transaction", true)); + chart("Transactions", Unit.PER_SECOND, Series.rate("Commits", "pg.commit"), Series.rate("Rollbacks", + "pg.rollback")); + probe("pg.commit", column(database, "xact_commit")); + probe("pg.rollback", column(database, "xact_rollback")); + chart("Rows", Unit.PER_SECOND, Series.rate("Fetched", "pg.fetched"), Series.rate("Inserted", + "pg.inserted"), Series.rate("Updated", "pg.updated"), Series.rate("Deleted", "pg.deleted")); + probe("pg.fetched", column(database, "tup_fetched")); + probe("pg.inserted", column(database, "tup_inserted")); + probe("pg.updated", column(database, "tup_updated")); + probe("pg.deleted", column(database, "tup_deleted")); + chart("Buffer cache hits", Unit.PERCENT, Series.ratio("Hits", "pg.hit", "pg.hit", "pg.read")); + probe("pg.hit", column(database, "blks_hit")); + probe("pg.read", column(database, "blks_read")); + chart("Locks waiting", Unit.COUNT, Series.gauge("Not granted", "pg.locks")); + probe("pg.locks", single("SELECT count(*) FROM pg_locks WHERE NOT granted")); + chart("Database size", Unit.BYTES, Series.gauge("Size", "pg.size")); + probe("pg.size", single("SELECT pg_database_size(current_database())")); + } + + private void defineMySql() { + String status = "SHOW GLOBAL STATUS"; + chart("Threads", Unit.COUNT, Series.gauge("Connected", "my.connected"), Series.gauge("Running", + "my.running")); + probe("my.connected", pair(status, "Threads_connected", false)); + probe("my.running", pair(status, "Threads_running", false)); + chart("Statements", Unit.PER_SECOND, Series.rate("All", "my.questions"), Series.rate("Select", + "my.select"), Series.rate("Insert", "my.insert"), Series.rate("Update", "my.update"), Series.rate( + "Delete", "my.delete")); + probe("my.questions", pair(status, "Questions", false)); + probe("my.select", pair(status, "Com_select", true)); + probe("my.insert", pair(status, "Com_insert", true)); + probe("my.update", pair(status, "Com_update", true)); + probe("my.delete", pair(status, "Com_delete", true)); + chart("Network", Unit.BYTES_PER_SECOND, Series.rate("Received", "my.received"), Series.rate("Sent", + "my.sent")); + probe("my.received", pair(status, "Bytes_received", false)); + probe("my.sent", pair(status, "Bytes_sent", false)); + chart("InnoDB buffer pool hits", Unit.PERCENT, Series.ratio("Hits", "my.hits", "my.requests")); + probe("my.requests", pair(status, "Innodb_buffer_pool_read_requests", false)); + probe("my.hits", context -> { + Map values = context.pairs(status); + Double requests = values.get("Innodb_buffer_pool_read_requests"); + Double reads = values.get("Innodb_buffer_pool_reads"); + return requests != null && reads != null ? requests - reads : null; + }); + chart("Row lock waits", Unit.COUNT, Series.gauge("Waiting", "my.lockWaits")); + probe("my.lockWaits", pair(status, "Innodb_row_lock_current_waits", false)); + chart("InnoDB buffer pool data", Unit.BYTES, Series.gauge("Data", "my.poolData")); + probe("my.poolData", pair(status, "Innodb_buffer_pool_bytes_data", false)); + } + + private void defineSqlServer() { + String counters = "SELECT RTRIM(counter_name), cntr_value FROM sys.dm_os_performance_counters WHERE " + + "(object_name LIKE '%SQL Statistics%' AND counter_name IN ('Batch Requests/sec', " + + "'SQL Compilations/sec')) OR (object_name LIKE '%:Databases%' AND counter_name = " + + "'Transactions/sec' AND instance_name = '_Total') OR (object_name LIKE '%Buffer Manager%' AND " + + "counter_name = 'Page life expectancy')"; + String health = "SELECT TOP 1 CONVERT(xml, record).value('(./Record/SchedulerMonitorEvent/SystemHealth/" + + "ProcessUtilization)[1]', 'int') AS process_cpu, 100 - CONVERT(xml, record).value('(./Record/" + + "SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]', 'int') AS system_cpu FROM " + + "sys.dm_os_ring_buffers WHERE ring_buffer_type = N'RING_BUFFER_SCHEDULER_MONITOR' AND record LIKE N'%%' " + + "ORDER BY timestamp DESC"; + chart("Sessions", Unit.COUNT, Series.gauge("User sessions", "ms.sessions"), Series.gauge("Running requests", + "ms.requests")); + probe("ms.sessions", single("SELECT COUNT(*) FROM sys.dm_exec_sessions WHERE is_user_process = 1")); + probe("ms.requests", single("SELECT COUNT(*) FROM sys.dm_exec_requests WHERE session_id > 50 AND " + + "session_id <> @@SPID")); + chart("Requests", Unit.PER_SECOND, Series.rate("Batches", "ms.batches"), Series.rate("Transactions", + "ms.transactions"), Series.rate("Compilations", "ms.compilations")); + probe("ms.batches", pair(counters, "Batch Requests/sec", false)); + probe("ms.transactions", pair(counters, "Transactions/sec", false)); + probe("ms.compilations", pair(counters, "SQL Compilations/sec", false)); + chart("CPU", Unit.PERCENT, Series.gauge("SQL Server", "ms.cpu"), Series.gauge("System", "ms.systemCpu")); + probe("ms.cpu", column(health, "process_cpu")); + probe("ms.systemCpu", column(health, "system_cpu")); + chart("Memory", Unit.BYTES, Series.gauge("In use", "ms.memory")); + probe("ms.memory", single("SELECT physical_memory_in_use_kb * 1024 FROM sys.dm_os_process_memory")); + chart("Page life expectancy", Unit.COUNT, Series.gauge("Seconds", "ms.ple")); + probe("ms.ple", pair(counters, "Page life expectancy", false)); + } + + private void defineOracle() { + String stats = "SELECT name, value FROM v$sysstat WHERE name IN ('user commits', 'user rollbacks', " + + "'user calls', 'execute count', 'session logical reads', 'physical reads', 'physical writes', " + + "'bytes sent via SQL*Net to client', 'bytes received via SQL*Net from client')"; + String sessions = "SELECT status, COUNT(*) FROM v$session WHERE type = 'USER' GROUP BY status"; + String os = "SELECT stat_name, value FROM v$osstat WHERE stat_name IN ('BUSY_TIME', 'IDLE_TIME')"; + chart("Sessions", Unit.COUNT, Series.gauge("Active", "ora.active"), Series.gauge("Inactive", + "ora.inactive")); + probe("ora.active", pair(sessions, "ACTIVE", true)); + probe("ora.inactive", pair(sessions, "INACTIVE", true)); + chart("Calls", Unit.PER_SECOND, Series.rate("Commits", "ora.commits"), Series.rate("Rollbacks", + "ora.rollbacks"), Series.rate("User calls", "ora.calls"), Series.rate("Executions", "ora.executes")); + probe("ora.commits", pair(stats, "user commits", false)); + probe("ora.rollbacks", pair(stats, "user rollbacks", false)); + probe("ora.calls", pair(stats, "user calls", false)); + probe("ora.executes", pair(stats, "execute count", false)); + chart("Reads and writes", Unit.PER_SECOND, Series.rate("Logical reads", "ora.logical"), Series.rate( + "Physical reads", "ora.physicalReads"), Series.rate("Physical writes", "ora.physicalWrites")); + probe("ora.logical", pair(stats, "session logical reads", false)); + probe("ora.physicalReads", pair(stats, "physical reads", false)); + probe("ora.physicalWrites", pair(stats, "physical writes", false)); + chart("Network", Unit.BYTES_PER_SECOND, Series.rate("Received", "ora.received"), Series.rate("Sent", + "ora.sent")); + probe("ora.received", pair(stats, "bytes received via SQL*Net from client", false)); + probe("ora.sent", pair(stats, "bytes sent via SQL*Net to client", false)); + chart("Host CPU", Unit.PERCENT, Series.ratio("Busy", "ora.busy", "ora.busy", "ora.idle")); + probe("ora.busy", pair(os, "BUSY_TIME", false)); + probe("ora.idle", pair(os, "IDLE_TIME", false)); + chart("Memory", Unit.BYTES, Series.gauge("SGA", "ora.sga"), Series.gauge("PGA", "ora.pga")); + probe("ora.sga", single("SELECT SUM(value) FROM v$sga")); + probe("ora.pga", single("SELECT value FROM v$pgastat WHERE name = 'total PGA allocated'")); + } + + private void defineDb2() { + String database = "SELECT APPLS_CUR_CONS, TOTAL_APP_COMMITS, TOTAL_APP_ROLLBACKS, ROWS_READ, ROWS_MODIFIED " + + "FROM TABLE(MON_GET_DATABASE(-2))"; + String resources = "SELECT CPU_USAGE_TOTAL, MEMORY_TOTAL * 1048576 AS MEMORY_TOTAL, (MEMORY_TOTAL - " + + "MEMORY_FREE) * 1048576 AS MEMORY_USED FROM TABLE(SYSPROC.ENV_GET_SYSTEM_RESOURCES())"; + chart("Connections", Unit.COUNT, Series.gauge("Connected", "db2.connections")); + probe("db2.connections", column(database, "APPLS_CUR_CONS")); + chart("Transactions", Unit.PER_SECOND, Series.rate("Commits", "db2.commits"), Series.rate("Rollbacks", + "db2.rollbacks")); + probe("db2.commits", column(database, "TOTAL_APP_COMMITS")); + probe("db2.rollbacks", column(database, "TOTAL_APP_ROLLBACKS")); + chart("Rows", Unit.PER_SECOND, Series.rate("Read", "db2.read"), Series.rate("Modified", "db2.modified")); + probe("db2.read", column(database, "ROWS_READ")); + probe("db2.modified", column(database, "ROWS_MODIFIED")); + chart("CPU", Unit.PERCENT, Series.gauge("Host", "db2.cpu")); + probe("db2.cpu", column(resources, "CPU_USAGE_TOTAL")); + chart("Memory", Unit.BYTES, Series.gauge("Used", "db2.memoryUsed"), Series.gauge("Total", + "db2.memoryTotal")); + probe("db2.memoryUsed", column(resources, "MEMORY_USED")); + probe("db2.memoryTotal", column(resources, "MEMORY_TOTAL")); + } + + private void defineClickHouse() { + String metrics = "SELECT metric, value FROM system.metrics WHERE metric IN ('TCPConnection', " + + "'HTTPConnection', 'Query', 'MemoryTracking')"; + String events = "SELECT event, value FROM system.events WHERE event IN ('Query', 'SelectQuery', " + + "'InsertQuery', 'InsertedRows', 'SelectedRows')"; + String async = "SELECT metric, value FROM system.asynchronous_metrics WHERE metric IN ('LoadAverage1', " + + "'OSMemoryAvailable', 'OSMemoryTotal')"; + chart("Connections and queries", Unit.COUNT, Series.gauge("TCP", "ch.tcp"), Series.gauge("HTTP", "ch.http"), + Series.gauge("Running queries", "ch.running")); + probe("ch.tcp", pair(metrics, "TCPConnection", true)); + probe("ch.http", pair(metrics, "HTTPConnection", true)); + probe("ch.running", pair(metrics, "Query", true)); + chart("Queries", Unit.PER_SECOND, Series.rate("All", "ch.queries"), Series.rate("Select", "ch.selects"), + Series.rate("Insert", "ch.inserts")); + probe("ch.queries", pair(events, "Query", true)); + probe("ch.selects", pair(events, "SelectQuery", true)); + probe("ch.inserts", pair(events, "InsertQuery", true)); + chart("Rows", Unit.PER_SECOND, Series.rate("Inserted", "ch.insertedRows"), Series.rate("Selected", + "ch.selectedRows")); + probe("ch.insertedRows", pair(events, "InsertedRows", true)); + probe("ch.selectedRows", pair(events, "SelectedRows", true)); + chart("Memory", Unit.BYTES, Series.gauge("Tracked", "ch.memory"), Series.gauge("Available of the host", + "ch.available")); + probe("ch.memory", pair(metrics, "MemoryTracking", false)); + probe("ch.available", pair(async, "OSMemoryAvailable", false)); + chart("Load average", Unit.COUNT, Series.gauge("1 minute", "ch.load")); + probe("ch.load", pair(async, "LoadAverage1", false)); + } + + /** + * H2, SQLite, Access and DuckDB run in SqlJam: its CPU and memory, and the file of the database + */ + private void defineEmbedded() { + chart("CPU of SqlJam", Unit.PERCENT, Series.gauge("SqlJam", "jvm.cpu"), Series.gauge("System", + "jvm.systemCpu")); + probe("jvm.cpu", context -> cpu(true)); + probe("jvm.systemCpu", context -> cpu(false)); + chart("Memory of SqlJam", Unit.BYTES, Series.gauge("Heap used", "jvm.heap"), Series.gauge("Heap maximum", + "jvm.heapMax")); + probe("jvm.heap", context -> (double) ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed()); + probe("jvm.heapMax", context -> { + long max = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getMax(); + return max > 0 ? (double) max : null; + }); + switch (profile.getDbType()) { + case H2: + chart("Sessions", Unit.COUNT, Series.gauge("Sessions", "h2.sessions")); + probe("h2.sessions", single("SELECT COUNT(*) FROM INFORMATION_SCHEMA.SESSIONS")); + break; + case DUCKDB: + chart("Memory of DuckDB", Unit.BYTES, Series.gauge("Used", "duck.memory")); + probe("duck.memory", single("SELECT SUM(memory_usage_bytes) FROM duckdb_memory()")); + break; + default: + break; + } + chart("File", Unit.BYTES, Series.gauge("Size", "file.size")); + probe("file.size", context -> { + File file = databaseFile(); + return file != null ? (double) file.length() : null; + }); + } + + File databaseFile() { + String database = StringUtils.defaultString(profile.getDatabase()); + for (String name : new String[]{database, database + ".mv.db"}) { + File file = new File(name); + if (file.isFile()) { + return file; + } + } + return null; + } + + private static Double cpu(boolean process) { + java.lang.management.OperatingSystemMXBean bean = ManagementFactory.getOperatingSystemMXBean(); + if (bean instanceof com.sun.management.OperatingSystemMXBean) { + com.sun.management.OperatingSystemMXBean os = (com.sun.management.OperatingSystemMXBean) bean; + double load = process ? os.getProcessCpuLoad() : os.getCpuLoad(); + // Negative before the first measure of the system + return Math.max(0, load) * 100; + } + return null; + } + + private Connection connection() throws SQLException { + if (connection == null) { + connection = session.openSqlConnection(null, null); + connection.setAutoCommit(true); + } + return connection; + } + + @Override + public synchronized Map read() throws Exception { + long start = System.nanoTime(); + Context context = new Context(connection()); + Map values = new LinkedHashMap<>(); + for (Map.Entry entry : probes.entrySet()) { + if (unavailable.containsKey(entry.getKey())) { + continue; + } + try { + Double value = entry.getValue().read(context); + if (value == null) { + unavailable.put(entry.getKey(), "Not reported by this server"); + } else { + values.put(entry.getKey(), value); + } + } catch (Exception e) { + if (!isValid()) { + // The connection was lost, not the metric: it is opened again by the next sample + closeConnection(); + throw e; + } + unavailable.put(entry.getKey(), StringUtils.defaultIfBlank(e.getMessage(), e.getClass() + .getSimpleName()).trim()); + } + } + values.put(SAMPLE_TIME, (System.nanoTime() - start) / 1_000_000.0); + return values; + } + + /** + * The connection of the samples, null before the first one or after it was lost + */ + public synchronized Connection getConnection() { + return connection; + } + + private boolean isValid() { + try { + return connection != null && !connection.isClosed() && connection.isValid(5); + } catch (SQLException e) { + return false; + } + } + + private void closeConnection() { + if (connection != null) { + try { + connection.close(); + } catch (SQLException e) { + log.debug("Connection of the dashboard is closed already: {}", e.getMessage()); + } + connection = null; + } + } + + @Override + public synchronized void close() { + closeConnection(); + } +} diff --git a/src/main/java/com/github/sqljam/face/service/MetricSource.java b/src/main/java/com/github/sqljam/face/service/MetricSource.java new file mode 100644 index 0000000..9612256 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/service/MetricSource.java @@ -0,0 +1,152 @@ +/* + * 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.util.List; +import java.util.Map; + +/** + * @Description: MetricSource is where the dashboard of a data source reads its metrics: the charts with their + * series, and the raw values of a moment. A value which can not be read (no permission, another + * version) is left out and told with its reason, the others are read all the same. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public interface MetricSource extends AutoCloseable { + + enum Unit { + COUNT, PER_SECOND, BYTES, BYTES_PER_SECOND, PERCENT, MILLIS + } + + enum Kind { + /** + * The value as it is read, e.g. sessions + */ + GAUGE, + /** + * A counter which only grows, shown a second, e.g. commits + */ + RATE, + /** + * The growth of a counter of the growth of others in percent, e.g. busy time of busy and idle time + */ + RATIO + } + + /** + * A line of a chart: its name and the keys of the raw values it is made of + */ + final class Series { + + private final String name; + private final Kind kind; + private final String key; + private final List totalKeys; + + private Series(String name, Kind kind, String key, List totalKeys) { + this.name = name; + this.kind = kind; + this.key = key; + this.totalKeys = totalKeys; + } + + public static Series gauge(String name, String key) { + return new Series(name, Kind.GAUGE, key, List.of()); + } + + public static Series rate(String name, String key) { + return new Series(name, Kind.RATE, key, List.of()); + } + + /** + * Growth of the key of the growth of the sum of the total keys, in percent + */ + public static Series ratio(String name, String key, String... totalKeys) { + return new Series(name, Kind.RATIO, key, List.of(totalKeys)); + } + + public String getName() { + return name; + } + + public Kind getKind() { + return kind; + } + + public String getKey() { + return key; + } + + public List getTotalKeys() { + return totalKeys; + } + + /** + * Keys of the raw values of the series + */ + public List getKeys() { + if (totalKeys.isEmpty()) { + return List.of(key); + } + List keys = new java.util.ArrayList<>(List.of(key)); + totalKeys.stream().filter(total -> !total.equals(key)).forEach(keys::add); + return keys; + } + } + + final class Chart { + + private final String title; + private final Unit unit; + private final List series; + + public Chart(String title, Unit unit, Series... series) { + this.title = title; + this.unit = unit; + this.series = List.of(series); + } + + public String getTitle() { + return title; + } + + public Unit getUnit() { + return unit; + } + + public List getSeries() { + return series; + } + } + + List getCharts(); + + /** + * Raw values of this moment by key, keys which can not be read are left out + * + * @throws Exception when the source can not be reached at all + */ + Map read() throws Exception; + + /** + * Keys which can not be read with the reason, they are not read again + */ + Map getUnavailable(); + + @Override + void close(); +} diff --git a/src/main/java/com/github/sqljam/face/service/ReportExporter.java b/src/main/java/com/github/sqljam/face/service/ReportExporter.java new file mode 100644 index 0000000..10ff101 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/service/ReportExporter.java @@ -0,0 +1,272 @@ +/* + * 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.IOException; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.model.TransferReport.TableReport; + +/** + * @Description: ReportExporter writes a report as Markdown, as one HTML file which opens anywhere, or as JSON: the + * summary, the options, the tables with their rows and verdicts, the objects, where it stopped and the + * skipped rows + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class ReportExporter { + + public enum Format { + MARKDOWN("md"), HTML("html"), JSON("json"); + + private final String extension; + + Format(String extension) { + this.extension = extension; + } + + public String getExtension() { + return extension; + } + } + + static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId + .systemDefault()); + + private ReportExporter() { + } + + public static String export(TransferReport report, Format format) throws IOException { + switch (format) { + case JSON: + return ReportStore.MAPPER.writeValueAsString(report); + case HTML: + return html(report); + default: + return markdown(report); + } + } + + public static String time(long millis) { + return millis > 0 ? TIME.format(Instant.ofEpochMilli(millis)) : ""; + } + + public static String duration(long millis) { + long seconds = millis / 1000; + return seconds >= 3600 ? String.format("%d h %d min", seconds / 3600, seconds % 3600 / 60) : seconds >= 60 + ? String.format("%d min %d s", seconds / 60, seconds % 60) : String.format(Locale.ENGLISH, "%.1f s", + millis / 1000.0); + } + + /** + * Words of a status, e.g. Completed with errors + */ + public static String words(Enum value) { + String text = value.name().replace('_', ' ').toLowerCase(Locale.ENGLISH); + return Character.toUpperCase(text.charAt(0)) + text.substring(1); + } + + /** + * Verdict of the rows of a table in the target + */ + public static String verdict(TableReport table) { + if (table.getTargetRows() == null) { + return ""; + } + Boolean verified = table.getVerified(); + String rows = String.format("%,d", table.getTargetRows()); + return verified == null ? rows : verified ? rows + " ✓" : rows + " ✗"; + } + + /** + * Rows of the summary: name and value + */ + public static List summary(TransferReport report) { + List rows = new ArrayList<>(); + rows.add(new String[]{"Status", words(report.getStatus())}); + rows.add(new String[]{"Kind", words(report.getKind())}); + rows.add(new String[]{"Source", StringUtils.defaultString(report.getSource())}); + rows.add(new String[]{"Target", StringUtils.defaultString(report.getTarget())}); + rows.add(new String[]{"Started", time(report.getStartedAt())}); + rows.add(new String[]{"Ended", time(report.getEndedAt())}); + rows.add(new String[]{"Duration", duration(report.getElapsedMillis())}); + rows.add(new String[]{"Transaction", words(report.getTransaction())}); + rows.add(new String[]{"Tables", String.valueOf(report.getTables().size())}); + rows.add(new String[]{"Rows written", String.format("%,d", report.getWrittenRows())}); + rows.add(new String[]{"Rows skipped", String.format("%,d", report.getSkippedRowCount())}); + if (report.isRolledBack()) { + rows.add(new String[]{"Kept in target", "Nothing, the transaction was rolled back"}); + } + if (StringUtils.isNotBlank(report.getStoppedAt())) { + rows.add(new String[]{"Stopped at", report.getStoppedAt()}); + } + if (StringUtils.isNotBlank(report.getFailure())) { + rows.add(new String[]{"Error", report.getFailure()}); + } + if (StringUtils.isNotBlank(report.getSummary())) { + rows.add(new String[]{"Summary", report.getSummary()}); + } + return rows; + } + + public static String[] tableRow(TableReport table) { + return new String[]{table.getQualifiedName(), words(table.getStatus()), table.getSourceRows() >= 0 ? String + .format("%,d", table.getSourceRows()) : "", String.format("%,d", table.getWritten()), String.format( + "%,d", table.getSkipped()), verdict(table), duration(table.getElapsedMillis()), table.getRowsPerSecond() + > 0 ? String.format(Locale.ENGLISH, "%,.0f", table.getRowsPerSecond()) : ""}; + } + + public static final String[] TABLE_HEADERS = {"Table", "Status", "Source rows", "Written", "Skipped", "Target rows", + "Time", "Rows/s"}; + + private static String cell(String text) { + return StringUtils.defaultString(text).replace("|", "\\|").replaceAll("\\R", " "); + } + + private static void markdownTable(StringBuilder text, String[] headers, List rows) { + text.append("| ").append(String.join(" | ", headers)).append(" |\n|"); + for (int i = 0; i < headers.length; i++) { + text.append("---|"); + } + text.append('\n'); + for (String[] row : rows) { + text.append("| "); + for (int i = 0; i < row.length; i++) { + text.append(cell(row[i])).append(i < row.length - 1 ? " | " : " |\n"); + } + } + } + + static String markdown(TransferReport report) { + StringBuilder text = new StringBuilder("# ").append(StringUtils.defaultIfBlank(report.getTitle(), + "Transfer report")).append("\n\n"); + markdownTable(text, new String[]{"Item", "Value"}, summary(report)); + if (!report.getOptions().isEmpty()) { + text.append("\n## Options\n\n"); + List options = new ArrayList<>(); + report.getOptions().forEach((name, value) -> options.add(new String[]{name, value})); + markdownTable(text, new String[]{"Option", "Value"}, options); + } + text.append("\n## Tables\n\n"); + List tables = new ArrayList<>(); + report.getTables().forEach(table -> tables.add(tableRow(table))); + markdownTable(text, TABLE_HEADERS, tables); + if (!report.getObjects().isEmpty() || report.getFailedStatements() > 0) { + text.append("\n## Objects\n\n"); + List objects = new ArrayList<>(); + report.getObjects().forEach((kind, count) -> objects.add(new String[]{kind, String.valueOf(count)})); + if (report.getFailedStatements() > 0) { + objects.add(new String[]{"Failed statements", String.valueOf(report.getFailedStatements())}); + } + markdownTable(text, new String[]{"Kind", "Count"}, objects); + } + if (!report.getSkippedRows().isEmpty()) { + text.append("\n## Skipped rows\n\n"); + List rows = new ArrayList<>(); + report.getSkippedRows().forEach(row -> rows.add(new String[]{row.getTable(), row.getKey(), + row.getError()})); + markdownTable(text, new String[]{"Table", "Row", "Error"}, rows); + if (report.getSkippedRowCount() > report.getSkippedRows().size()) { + text.append(String.format("%n%,d more rows were skipped.%n", report.getSkippedRowCount() - report + .getSkippedRows().size())); + } + } + if (StringUtils.isNotBlank(report.getFailureTrace())) { + text.append("\n## Error\n\n```\n").append(report.getFailureTrace().trim()).append("\n```\n"); + } + return text.toString(); + } + + static String escape(String text) { + return StringUtils.defaultString(text).replace("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """).replace("'", "'"); + } + + private static void htmlTable(StringBuilder html, String[] headers, List rows) { + html.append("
"); + for (String header : headers) { + html.append(""); + } + html.append(""); + for (String[] row : rows) { + html.append(""); + for (String value : row) { + html.append(""); + } + html.append(""); + } + html.append("
").append(escape(header)).append("
").append(escape(value)).append("
"); + } + + static String html(TransferReport report) { + String title = StringUtils.defaultIfBlank(report.getTitle(), "Transfer report"); + StringBuilder html = new StringBuilder("").append(escape(title)) + .append("") + .append("

").append(escape(title)).append("

"); + html.append("

") + .append(escape(words(report.getStatus()))).append("

"); + htmlTable(html, new String[]{"Item", "Value"}, summary(report)); + if (!report.getOptions().isEmpty()) { + html.append("

Options

"); + List options = new ArrayList<>(); + for (Map.Entry option : report.getOptions().entrySet()) { + options.add(new String[]{option.getKey(), option.getValue()}); + } + htmlTable(html, new String[]{"Option", "Value"}, options); + } + html.append("

Tables

"); + List tables = new ArrayList<>(); + report.getTables().forEach(table -> tables.add(tableRow(table))); + htmlTable(html, TABLE_HEADERS, tables); + if (!report.getObjects().isEmpty() || report.getFailedStatements() > 0) { + html.append("

Objects

"); + List objects = new ArrayList<>(); + report.getObjects().forEach((kind, count) -> objects.add(new String[]{kind, String.valueOf(count)})); + if (report.getFailedStatements() > 0) { + objects.add(new String[]{"Failed statements", String.valueOf(report.getFailedStatements())}); + } + htmlTable(html, new String[]{"Kind", "Count"}, objects); + } + if (!report.getSkippedRows().isEmpty()) { + html.append("

Skipped rows

"); + List rows = new ArrayList<>(); + report.getSkippedRows().forEach(row -> rows.add(new String[]{row.getTable(), row.getKey(), + row.getError()})); + htmlTable(html, new String[]{"Table", "Row", "Error"}, rows); + } + if (StringUtils.isNotBlank(report.getFailureTrace())) { + html.append("

Error

").append(escape(report.getFailureTrace().trim())).append("
"); + } + return html.append("\n").toString(); + } +} diff --git a/src/main/java/com/github/sqljam/face/service/ReportRecorder.java b/src/main/java/com/github/sqljam/face/service/ReportRecorder.java new file mode 100644 index 0000000..f27904c --- /dev/null +++ b/src/main/java/com/github/sqljam/face/service/ReportRecorder.java @@ -0,0 +1,228 @@ +/* + * 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.PrintWriter; +import java.io.StringWriter; +import java.util.Locale; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.model.TransferReport.TableReport; +import com.github.sqljam.face.model.TransferReport.TableStatus; + +/** + * @Description: ReportRecorder fills the report of a transfer from its events: the tables with their rows, the + * rows which were skipped, the statements of the structure by the kinds of objects, the errors, and at + * the end its status and where it stopped. The events come from any thread, the report is read after + * the end. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class ReportRecorder { + + /** + * Errors which a report keeps at most, the first ones tell most + */ + static final int MAX_ERRORS = 200; + static final Pattern VECTOR_INDEX = Pattern.compile("(?i)\\b(hnsw|ivfflat|ivf|diskann|vector_similarity|" + + "vector\\s+index|vector_distance|annoy|usearch)\\b"); + + private final TransferReport report; + private TableReport current; + + public ReportRecorder(TransferReport report) { + this.report = report; + } + + public TransferReport getReport() { + return report; + } + + public synchronized void start() { + report.setStartedAt(System.currentTimeMillis()); + report.setStatus(TransferReport.Status.RUNNING); + } + + /** + * A new attempt of a run whose last attempt was rolled back: its tables, objects and skipped rows are gone, its + * errors stay + */ + public synchronized void restart() { + report.getTables().clear(); + report.getObjects().clear(); + report.setFailedStatements(0); + report.getSkippedRows().clear(); + report.setSkippedRowCount(0); + report.setRolledBack(false); + current = null; + } + + public synchronized void tableStarted(String catalog, String schema, String table, long totalRows) { + TableReport tableReport = report.table(catalog, schema, table); + tableReport.setStatus(TableStatus.INTERRUPTED); + tableReport.setStartedAt(System.currentTimeMillis()); + if (totalRows >= 0) { + tableReport.setSourceRows(totalRows); + } + current = tableReport; + } + + /** + * Rows of a table read so far, the rows which were skipped are not written + */ + public synchronized void tableProgress(String catalog, String schema, String table, long processedRows) { + TableReport tableReport = report.table(catalog, schema, table); + tableReport.setWritten(Math.max(0, processedRows - tableReport.getSkipped())); + } + + /** + * The end of a table with its written rows: the importers tell them without the rows which were skipped + */ + public synchronized void tableEnded(String catalog, String schema, String table, long writtenRows) { + TableReport tableReport = report.table(catalog, schema, table); + tableReport.setWritten(writtenRows); + tableReport.setEndedAt(System.currentTimeMillis()); + tableReport.setStatus(tableReport.getSkipped() > 0 ? TableStatus.COMPLETED_WITH_ERRORS + : TableStatus.COMPLETED); + if (current == tableReport) { + current = null; + } + } + + /** + * A row which was skipped, of the table which is written when the event names no schema + */ + public synchronized void rowSkipped(String catalog, String schema, String table, String key, Throwable e) { + TableReport tableReport = findTable(catalog, schema, table); + tableReport.setSkipped(tableReport.getSkipped() + 1); + report.setSkippedRowCount(report.getSkippedRowCount() + 1); + if (report.getSkippedRows().size() < TransferReport.MAX_SKIPPED_ROWS) { + report.getSkippedRows().add(new TransferReport.SkippedRow(table, key, e != null ? StringUtils + .defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()) : null)); + } + } + + private TableReport findTable(String catalog, String schema, String table) { + if (catalog == null && schema == null) { + for (TableReport candidate : report.getTables()) { + if (candidate.getName() != null && candidate.getName().equalsIgnoreCase(table)) { + return candidate; + } + } + } + return report.table(catalog, schema, table); + } + + public synchronized void error(String message) { + if (report.getErrors().size() < MAX_ERRORS && StringUtils.isNotBlank(message)) { + report.getErrors().add(message); + } + } + + /** + * A statement of the structure counted by the kind of its object + */ + public synchronized void ddlExecuted(String statement, boolean successful) { + if (!successful) { + report.setFailedStatements(report.getFailedStatements() + 1); + return; + } + String kind = kindOf(statement); + if (kind != null) { + report.getObjects().merge(kind, 1, Integer::sum); + } + } + + /** + * Kind of the object which a statement creates, null for statements which create none (drops) + */ + static String kindOf(String statement) { + String text = StringUtils.normalizeSpace(StringUtils.defaultString(statement)).toUpperCase(Locale.ENGLISH); + if (text.startsWith("DROP") || text.startsWith("TRUNCATE") || text.isEmpty()) { + return null; + } + if (text.contains("FOREIGN KEY")) { + return "Foreign keys"; + } + if (text.contains("PRIMARY KEY") && !text.startsWith("CREATE TABLE")) { + return "Primary keys"; + } + if (text.matches("^CREATE\\s+TABLE\\s+.*\\bPARTITION\\s+OF\\b.*")) { + return "Partitions"; + } + if (text.startsWith("CREATE TABLE") || text.startsWith("CREATE COLUMN TABLE")) { + return "Tables"; + } + if (text.contains("INDEX") && VECTOR_INDEX.matcher(text).find()) { + return "Vector indexes"; + } + if (text.contains(" INDEX ") || text.startsWith("CREATE INDEX") || text.startsWith("CREATE UNIQUE")) { + return "Indexes"; + } + if (text.startsWith("CREATE SEQUENCE") || text.startsWith("ALTER SEQUENCE")) { + return "Sequences"; + } + if (text.startsWith("COMMENT ON") || text.contains("SP_ADDEXTENDEDPROPERTY") || text.contains("COMMENT =")) { + return "Comments"; + } + if (text.startsWith("CREATE SCHEMA") || text.startsWith("CREATE DATABASE")) { + return "Schemas"; + } + if (text.startsWith("CREATE TYPE") || text.startsWith("CREATE DOMAIN")) { + return "Types"; + } + return "Other statements"; + } + + /** + * The end of the transfer: its status, the error which ended it and where it stopped + */ + public synchronized void finish(TransferReport.Status status, Throwable failure, String summary) { + report.setEndedAt(System.currentTimeMillis()); + report.setStatus(status); + report.setSummary(summary); + if (failure != null) { + Throwable cause = failure; + while (cause.getCause() != null && StringUtils.isBlank(cause.getMessage())) { + cause = cause.getCause(); + } + report.setFailure(StringUtils.defaultIfBlank(cause.getMessage(), cause.getClass().getName())); + StringWriter trace = new StringWriter(); + failure.printStackTrace(new PrintWriter(trace)); + report.setFailureTrace(StringUtils.abbreviate(trace.toString(), 20000)); + } + for (TableReport table : report.getTables()) { + if (table.getStatus() == TableStatus.INTERRUPTED) { + table.setEndedAt(report.getEndedAt()); + if (failure != null && table.getError() == null) { + table.setError(report.getFailure()); + } + if (status == TransferReport.Status.FAILED) { + table.setStatus(TableStatus.FAILED); + } + } + } + if (current != null && !status.isKept()) { + report.setStoppedAt(String.format("%s after %,d rows", current.getQualifiedName(), current + .getWritten())); + } else if (current != null) { + report.setStoppedAt(null); + } + } +} diff --git a/src/main/java/com/github/sqljam/face/service/ReportStore.java b/src/main/java/com/github/sqljam/face/service/ReportStore.java new file mode 100644 index 0000000..202766b --- /dev/null +++ b/src/main/java/com/github/sqljam/face/service/ReportStore.java @@ -0,0 +1,141 @@ +/* + * 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.nio.file.Files; +import java.time.Instant; +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.Optional; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.github.sqljam.config.Config; +import com.github.sqljam.face.model.TransferReport; +import lombok.extern.slf4j.Slf4j; + +/** + * @Description: ReportStore keeps the reports of the transfers as the history of the imports: a JSON file a report + * in sqljam.reports.dir, newest first, the number of sqljam.reports.keep at most. A file which can not + * be read is left out of the history, it does not hide the others. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Slf4j +public class ReportStore { + + public static final int DEFAULT_KEEP = 200; + static final DateTimeFormatter FILE_TIME = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").withZone(ZoneId + .systemDefault()); + static final ObjectMapper MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT).disable( + DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + + private final File directory; + private final int keep; + + public ReportStore(Config config) { + this(config.getFile("sqljam.reports.dir", new File(config.getString("sqljam.home", System.getProperty( + "user.home") + "/.sqljam"), "reports")), config.getInt("sqljam.reports.keep", DEFAULT_KEEP)); + } + + public ReportStore(File directory, int keep) { + this.directory = directory; + this.keep = Math.max(1, keep); + } + + public File getDirectory() { + return directory; + } + + /** + * Saves the report, the oldest reports beyond the number to keep are deleted + */ + public synchronized File save(TransferReport report) throws IOException { + Files.createDirectories(directory.toPath()); + File file = fileOf(report); + File temporary = new File(directory, file.getName() + ".tmp"); + MAPPER.writeValue(temporary, report); + Files.move(temporary.toPath(), file.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + List files = files(); + for (int i = keep; i < files.size(); i++) { + Files.deleteIfExists(files.get(i).toPath()); + } + return file; + } + + private File fileOf(TransferReport report) { + // A report saved again (e.g. after its target rows were counted) keeps its file + for (File file : files()) { + if (file.getName().endsWith(report.getId() + ".json")) { + return file; + } + } + return new File(directory, FILE_TIME.format(Instant.ofEpochMilli(Math.max(report.getStartedAt(), 1))) + + "-" + report.getId() + ".json"); + } + + /** + * Files of the reports, newest first + */ + private List files() { + File[] files = directory.listFiles((dir, name) -> name.endsWith(".json")); + if (files == null) { + return new ArrayList<>(); + } + List list = new ArrayList<>(Arrays.asList(files)); + list.sort(Comparator.comparing(File::getName).reversed()); + return list; + } + + /** + * Reports of the history, newest first, files which can not be read are left out + */ + public synchronized List list() { + List reports = new ArrayList<>(); + for (File file : files()) { + try { + reports.add(MAPPER.readValue(file, TransferReport.class)); + } catch (IOException e) { + if (log.isWarnEnabled()) { + log.warn("Report {} can not be read: {}", file, e.getMessage()); + } + } + } + return reports; + } + + public synchronized Optional find(String id) { + return list().stream().filter(report -> report.getId().equals(id)).findFirst(); + } + + public synchronized boolean delete(String id) throws IOException { + for (File file : files()) { + if (file.getName().endsWith(id + ".json")) { + Files.delete(file.toPath()); + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/github/sqljam/face/service/SettingsStore.java b/src/main/java/com/github/sqljam/face/service/SettingsStore.java index e5d6d5c..af9cec3 100644 --- a/src/main/java/com/github/sqljam/face/service/SettingsStore.java +++ b/src/main/java/com/github/sqljam/face/service/SettingsStore.java @@ -16,6 +16,8 @@ package com.github.sqljam.face.service; import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; import org.apache.commons.lang3.StringUtils; import com.github.sqljam.config.Config; @@ -41,6 +43,11 @@ public SettingsStore(Config config) { settings.setTheme(config.getString("sqljam.ui.theme", AppSettings.DEFAULT_THEME)); settings.setWindowWidth(config.getDouble("sqljam.ui.window.width", settings.getWindowWidth())); settings.setWindowHeight(config.getDouble("sqljam.ui.window.height", settings.getWindowHeight())); + settings.setWindowX(config.getDouble("sqljam.ui.window.x", Double.NaN)); + settings.setWindowY(config.getDouble("sqljam.ui.window.y", Double.NaN)); + settings.setWindowMaximized(config.getBoolean("sqljam.ui.window.maximized", false)); + settings.setConnectedSources(new ArrayList<>(Arrays.asList(StringUtils.split(StringUtils.defaultString( + config.getString("sqljam.ui.connected-sources", "")), ',')))); settings.setDataPageSize(config.getInt("sqljam.ui.data.page-size", settings.getDataPageSize())); settings.setTransferPageSize(config.getInt("sqljam.export.page-size", settings.getTransferPageSize())); settings.setLobPageSize(config.getInt("sqljam.export.lob-page-size", settings.getLobPageSize())); @@ -69,6 +76,10 @@ public synchronized void save() throws IOException { config.set("sqljam.ui.theme", settings.getTheme()); config.set("sqljam.ui.window.width", (long) settings.getWindowWidth()); config.set("sqljam.ui.window.height", (long) settings.getWindowHeight()); + config.set("sqljam.ui.window.x", Double.isNaN(settings.getWindowX()) ? null : (long) settings.getWindowX()); + config.set("sqljam.ui.window.y", Double.isNaN(settings.getWindowY()) ? null : (long) settings.getWindowY()); + config.set("sqljam.ui.window.maximized", settings.isWindowMaximized()); + config.set("sqljam.ui.connected-sources", String.join(",", settings.getConnectedSources())); config.set("sqljam.ui.data.page-size", settings.getDataPageSize()); config.set("sqljam.export.page-size", settings.getTransferPageSize()); config.set("sqljam.export.max-file-size", settings.getMaxDataFileSize()); diff --git a/src/main/java/com/github/sqljam/face/service/SyncEngine.java b/src/main/java/com/github/sqljam/face/service/SyncEngine.java new file mode 100644 index 0000000..7d80461 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/service/SyncEngine.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.service; + +import java.util.Map; + +import com.github.sqljam.face.model.SyncTask; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.impexp.ExportListener; + +/** + * @Description: SyncEngine runs the sync tasks of a kind of source: tables of data sources, or collections of + * vector databases which the vdb line registers, so that the scheduler knows neither + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public interface SyncEngine { + + /** + * Header of the report of a run: its kind, source, target and options + */ + void describe(SyncTask task, TransferReport report); + + /** + * Copies the rows which changed since the watermarks of the task, returns the watermarks of the copied rows by + * table, which are saved after a successful run only + */ + Map run(SyncTask task, TransferReport report, ExportListener listener) + throws Exception; + + /** + * Counts the rows of the target tables after a run + */ + default void verify(SyncTask task, TransferReport report) throws Exception { + } +} diff --git a/src/main/java/com/github/sqljam/face/service/SyncErrors.java b/src/main/java/com/github/sqljam/face/service/SyncErrors.java new file mode 100644 index 0000000..f01d993 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/service/SyncErrors.java @@ -0,0 +1,88 @@ +/* + * 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.IOException; +import java.sql.SQLException; +import java.sql.SQLNonTransientConnectionException; +import java.sql.SQLRecoverableException; +import java.sql.SQLTransientException; +import java.util.concurrent.TimeoutException; + +import org.apache.commons.lang3.StringUtils; + +/** + * @Description: SyncErrors tells the errors which a sync task runs again after from the errors which it does not: + * a connection which was lost or refused, a timeout, a deadlock or an SSH tunnel which broke may pass, + * a constraint, a type, a syntax or a missing password does not, running again would fail the same. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class SyncErrors { + + private SyncErrors() { + } + + /** + * An error which can not pass by running again, e.g. the password of a data source is not remembered + */ + public static class PermanentException extends IllegalStateException { + + private static final long serialVersionUID = 1L; + + public PermanentException(String message) { + super(message); + } + } + + public static boolean isRetryable(Throwable e) { + for (Throwable cause = e; cause != null; cause = cause.getCause()) { + if (cause instanceof PermanentException) { + return false; + } + if (cause instanceof SQLException) { + SQLException sqlException = (SQLException) cause; + String state = StringUtils.defaultString(sqlException.getSQLState()); + // Data, integrity, syntax and access: the same rows fail again + if (state.startsWith("22") || state.startsWith("23") || state.startsWith("42") || state.startsWith( + "28")) { + return false; + } + // Connection, transaction rolled back by the database (deadlocks, serialization), resources + if (state.startsWith("08") || state.startsWith("40") || state.startsWith("53") || state.startsWith( + "57") || state.equals("HYT00") || state.equals("HYT01")) { + return true; + } + if (cause instanceof SQLTransientException || cause instanceof SQLRecoverableException + || cause instanceof SQLNonTransientConnectionException) { + return true; + } + // H2: the connection is broken, the database was closed + if (state.equals("90067") || state.equals("90098") || state.equals("90121")) { + return true; + } + } + if (cause instanceof IOException || cause instanceof TimeoutException) { + return true; + } + if (cause.getClass().getName().startsWith("org.apache.sshd.")) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/github/sqljam/face/service/SyncRunner.java b/src/main/java/com/github/sqljam/face/service/SyncRunner.java new file mode 100644 index 0000000..8e1d3a2 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/service/SyncRunner.java @@ -0,0 +1,282 @@ +/* + * 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.math.BigDecimal; +import java.math.BigInteger; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.model.SyncTask; +import com.github.sqljam.face.model.SyncTask.Watermark; +import com.github.sqljam.face.model.SyncTask.WatermarkType; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.model.TransferRequest; +import com.github.sqljam.impexp.Dialect; +import com.github.sqljam.impexp.ExportListener; +import com.github.sqljam.impexp.ExportMode; +import com.github.sqljam.impexp.TableQuery; +import com.github.sqljam.impexp.WriteMode; +import com.github.sqljam.jdbc.ManualTransaction; + +/** + * @Description: SyncRunner runs a sync task of tables once: it reads the watermarks of the source first, copies the + * rows between the last watermarks and them (all rows the first time) by update or insert by key into + * the kept tables of the target, in one transaction where the target has transactions, and returns + * the new watermarks, which are saved only after the commit. Tables which the target misses are + * created. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class SyncRunner implements SyncEngine { + + static final String NUMBER = "NUMBER"; + static final String TIMESTAMP = "TIMESTAMP"; + static final String TIMESTAMP_TZ = "TIMESTAMPTZ"; + static final String DATE = "DATE"; + static final String TEXT = "TEXT"; + + private final TransferService transferService; + private final Function> profiles; + + public SyncRunner(TransferService transferService, Function> profiles) { + this.transferService = transferService; + this.profiles = profiles; + } + + ConnectionProfile profile(String id, String role) { + return requireProfile(profiles, id, role); + } + + /** + * A data source which a task runs with alone: it exists and its password is known + */ + public static ConnectionProfile requireProfile(Function> profiles, String id, + String role) { + ConnectionProfile profile = profiles.apply(id).orElseThrow(() -> new SyncErrors.PermanentException( + "The " + role + " data source of the task was removed.")); + // A password which is not remembered is null, an empty one is remembered + if (profile.getPassword() == null && StringUtils.isNotBlank(profile.getUsername())) { + throw new SyncErrors.PermanentException("The password of " + profile.getName() + " is not remembered, " + + "a task which runs by itself needs it. Remember it in the data source."); + } + return profile; + } + + @Override + public void describe(SyncTask task, TransferReport report) { + report.setKind(TransferReport.Kind.SYNC); + report.setTitle("Sync " + task.getName()); + report.setSource(TransferService.describe(profiles.apply(task.getSourceId()).map(ConnectionProfile::getName) + .orElse(task.getSourceId()), task.getSourceCatalog(), task.getSourceSchema())); + report.setTarget(TransferService.describe(profiles.apply(task.getTargetId()).map(ConnectionProfile::getName) + .orElse(task.getTargetId()), task.getTargetCatalog(), task.getTargetSchema())); + report.getOptions().put("Task", task.getName()); + report.getOptions().put("Copy", task.isInitialized() ? "Rows changed since the last run" + : "All rows, the first run"); + report.getOptions().put("Write mode", "Update or insert by key"); + report.getOptions().put("Rows deleted in the source", "Kept in the target"); + } + + @Override + public Map run(SyncTask task, TransferReport report, ExportListener listener) + throws Exception { + ConnectionProfile source = profile(task.getSourceId(), "source"); + ConnectionProfile target = profile(task.getTargetId(), "target"); + Dialect dialect = source.getDbType().createDialect(); + Map watermarks = new LinkedHashMap<>(); + Map queries = new LinkedHashMap<>(); + // The watermarks are read before the rows: rows which change meanwhile are copied by the next run + try (Connection connection = TransferService.connect(source, task.getSourceCatalog(), task + .getSourceSchema())) { + for (SyncTask.Table table : task.getTables()) { + String column = dialect.quoteIdentifier(table.getWatermarkColumn()); + Watermark last = table.getWatermark(); + Watermark next = readMax(connection, dialect.getSourceTableName(task.getSourceCatalog(), task + .getSourceSchema(), table.getName()), column); + watermarks.put(table.getName(), next != null ? next : last); + List conditions = new ArrayList<>(); + if (last != null) { + // Rows of the same time are read again and updated, keys which only grow are new rows + conditions.add(column + (table.getWatermarkType() == WatermarkType.UPDATED_AT ? " >= " : " > ") + + literal(dialect, task, table, last)); + } + if (next != null) { + conditions.add(column + " <= " + literal(dialect, task, table, next)); + } + if (!conditions.isEmpty()) { + queries.put(table.getName(), new TableQuery(null, String.join(" AND ", conditions), null, + null)); + } + report.getOptions().put("Watermark of " + table.getName(), table.getWatermarkColumn() + " from " + + (last != null ? last : "the first row") + " to " + (next != null ? next : "no rows")); + } + } + TransferRequest request = request(task, source, target, queries); + ManualTransaction transaction = null; + if (target.getDbType().isTransactionSupported()) { + transaction = transferService.beginTransaction(target, task.getTargetCatalog(), task.getTargetSchema()); + report.setTransaction(TransferReport.Transaction.ONE); + } else { + report.setTransaction(TransferReport.Transaction.EVERY_ROWS); + } + try { + transferService.transfer(request, transaction, listener); + if (transaction != null) { + // Statements after the commit, e.g. identity values, which failed + transaction.commit().forEach(failure -> listener.onError(failure, null)); + } + } catch (Exception e) { + // Closed without a commit: rolled back, nothing of the run is kept + if (transaction != null && !transaction.isFinished()) { + report.setRolledBack(true); + } + throw e; + } finally { + if (transaction != null) { + transaction.close(); + } + } + return watermarks; + } + + /** + * Rows of the target tables, kept tables hold rows of earlier runs and of others: they are not compared + */ + @Override + public void verify(SyncTask task, TransferReport report) throws Exception { + transferService.verifyReport(request(task, profile(task.getSourceId(), "source"), profile(task + .getTargetId(), "target"), Map.of()), report); + } + + TransferRequest request(SyncTask task, ConnectionProfile source, ConnectionProfile target, + Map queries) { + TransferRequest request = new TransferRequest(); + request.setSource(source); + request.setSourceCatalog(task.getSourceCatalog()); + request.setSourceSchema(task.getSourceSchema()); + request.setTables(task.getTables().stream().map(SyncTask.Table::getName).collect(Collectors.toList())); + request.setTarget(TransferRequest.Target.DATABASE); + request.setTargetProfile(target); + request.setTargetCatalog(task.getTargetCatalog()); + request.setTargetSchema(task.getTargetSchema()); + request.setExportMode(ExportMode.DDL_DATA); + // Kept tables, missing ones are created with their keys and indexes + request.setTableRecreated(false); + request.setWriteMode(WriteMode.UPSERT); + request.setForeignKeyIncluded(false); + request.setSequenceIncluded(false); + request.setIdReused(true); + request.setFailFast(true); + request.setPageSize(task.getPageSize()); + for (SyncTask.Table table : task.getTables()) { + if (!table.getKeyColumns().isEmpty()) { + request.getKeyColumnNames().put(table.getName(), table.getKeyColumns()); + } + } + request.getTableQueries().putAll(queries); + return request; + } + + /** + * The greatest value of the column, null for a table without rows + */ + static Watermark readMax(Connection connection, String table, String column) throws Exception { + try (Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SELECT MAX(" + column + ") FROM " + table)) { + return rs.next() ? watermarkOf(rs.getObject(1)) : null; + } + } + + public static Watermark watermarkOf(Object value) { + if (value == null) { + return null; + } + if (value instanceof BigDecimal) { + return new Watermark(NUMBER, ((BigDecimal) value).toPlainString()); + } + if (value instanceof Integer || value instanceof Long || value instanceof Short || value instanceof Byte + || value instanceof BigInteger) { + return new Watermark(NUMBER, value.toString()); + } + if (value instanceof Number) { + return new Watermark(NUMBER, new BigDecimal(value.toString()).toPlainString()); + } + if (value instanceof Timestamp) { + return new Watermark(TIMESTAMP, ((Timestamp) value).toLocalDateTime().toString()); + } + if (value instanceof LocalDateTime) { + return new Watermark(TIMESTAMP, value.toString()); + } + if (value instanceof OffsetDateTime) { + return new Watermark(TIMESTAMP_TZ, value.toString()); + } + if (value instanceof ZonedDateTime) { + return new Watermark(TIMESTAMP_TZ, ((ZonedDateTime) value).toOffsetDateTime().toString()); + } + if (value instanceof Instant) { + return new Watermark(TIMESTAMP_TZ, ((Instant) value).atOffset(ZoneOffset.UTC).toString()); + } + if (value instanceof java.sql.Date) { + return new Watermark(DATE, ((java.sql.Date) value).toLocalDate().toString()); + } + if (value instanceof LocalDate) { + return new Watermark(DATE, value.toString()); + } + return new Watermark(TEXT, value.toString()); + } + + /** + * The value of a watermark again, as the source returned it + */ + public static Object valueOf(Watermark watermark) { + switch (watermark.getType()) { + case NUMBER: + return new BigDecimal(watermark.getValue()); + case TIMESTAMP: + return LocalDateTime.parse(watermark.getValue()); + case TIMESTAMP_TZ: + return OffsetDateTime.parse(watermark.getValue()); + case DATE: + return java.sql.Date.valueOf(LocalDate.parse(watermark.getValue())); + default: + return watermark.getValue(); + } + } + + private static String literal(Dialect dialect, SyncTask task, SyncTask.Table table, Watermark watermark) { + return dialect.getStringValue(task.getSourceCatalog(), task.getSourceSchema(), table.getName(), table + .getWatermarkColumn(), valueOf(watermark)); + } +} diff --git a/src/main/java/com/github/sqljam/face/service/SyncScheduler.java b/src/main/java/com/github/sqljam/face/service/SyncScheduler.java new file mode 100644 index 0000000..c162613 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/service/SyncScheduler.java @@ -0,0 +1,497 @@ +/* + * 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.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.util.EnumMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.LongSupplier; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.model.SyncTask; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.impexp.ExportCancelledException; +import com.github.sqljam.impexp.ExportListener; +import lombok.extern.slf4j.Slf4j; + +/** + * @Description: SyncScheduler runs the sync tasks in SqlJam while it is open, like the schedulers of DBeaver and + * DataGrip: every N minutes or every day at a time, and Run Now. A task never runs twice at once, a run + * which was missed while SqlJam was closed runs once when it starts. Errors of the connection are run + * again after a delay which doubles, errors of the data are not. Every run leaves a report in the + * history, the watermarks move on after a successful run only. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Slf4j +public class SyncScheduler implements AutoCloseable { + + /** + * Waits between the attempts of a run + */ + @FunctionalInterface + public interface Sleeper { + void sleep(long millis) throws InterruptedException; + } + + private final SyncTaskStore store; + private final ReportStore reportStore; + private final Map engines = new EnumMap<>(SyncTask.Kind.class); + private final Map running = new ConcurrentHashMap<>(); + private final Map cancelled = new ConcurrentHashMap<>(); + private final Set> listeners = ConcurrentHashMap.newKeySet(); + private final ExecutorService workers = Executors.newFixedThreadPool(2, runnable -> { + Thread thread = new Thread(runnable, "sqljam-sync"); + thread.setDaemon(true); + return thread; + }); + private ScheduledExecutorService ticker; + private LongSupplier clock = System::currentTimeMillis; + private Sleeper sleeper = Thread::sleep; + private ZoneId zone = ZoneId.systemDefault(); + private long retryDelayMillis = 30_000; + private volatile boolean closed; + /** + * What starts the runs of this scheduler, kept in the reports and the tasks + */ + private String origin = "SqlJam"; + + public SyncScheduler(SyncTaskStore store, ReportStore reportStore) { + this.store = store; + this.reportStore = reportStore; + } + + public void register(SyncTask.Kind kind, SyncEngine engine) { + engines.put(kind, engine); + } + + public SyncTaskStore getStore() { + return store; + } + + /** + * Tells a task which changed: it started, ended, or was saved + */ + public void addListener(Consumer listener) { + listeners.add(listener); + } + + public void removeListener(Consumer listener) { + listeners.remove(listener); + } + + void setClock(LongSupplier clock) { + this.clock = clock; + } + + void setZone(ZoneId zone) { + this.zone = zone; + } + + void setSleeper(Sleeper sleeper) { + this.sleeper = sleeper; + } + + public void setOrigin(String origin) { + this.origin = origin; + } + + /** + * File of the lock of a task: SqlJam and the command line never run a task at once + */ + File lockFile(String taskId) { + return new File(new File(store.getFile().getParentFile(), "sync-locks"), taskId + ".lock"); + } + + /** + * Whether another process runs the task now, e.g. the command line started by the system + */ + public boolean isRunningElsewhere(String taskId) { + if (isRunning(taskId)) { + return false; + } + File file = lockFile(taskId); + if (!file.exists()) { + return false; + } + try (FileChannel channel = FileChannel.open(file.toPath(), StandardOpenOption.WRITE)) { + FileLock lock = channel.tryLock(); + if (lock != null) { + lock.release(); + return false; + } + return true; + } catch (OverlappingFileLockException e) { + return false; + } catch (IOException e) { + return false; + } + } + + public void setRetryDelayMillis(long retryDelayMillis) { + this.retryDelayMillis = Math.max(0, retryDelayMillis); + } + + /** + * Looks for tasks to run every given seconds + */ + public synchronized void start(long tickSeconds) { + if (ticker != null || closed) { + return; + } + ticker = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "sqljam-sync-ticker"); + thread.setDaemon(true); + return thread; + }); + ticker.scheduleWithFixedDelay(() -> { + try { + tick(); + } catch (RuntimeException e) { + log.error("Sync tasks are not checked: {}", e.getMessage(), e); + } + }, 0, Math.max(1, tickSeconds), TimeUnit.SECONDS); + } + + public boolean isRunning(String taskId) { + AtomicBoolean flag = running.get(taskId); + return flag != null && flag.get(); + } + + /** + * Time of the next run, -1 for tasks which run by Run Now only + */ + public long nextRunAt(SyncTask task) { + return nextRunAt(task, clock.getAsLong()); + } + + long nextRunAt(SyncTask task, long now) { + if (!task.isEnabled() || task.getScheduleType() == SyncTask.ScheduleType.MANUAL) { + return -1; + } + // A new task waits for its first time, the creation stands for its last run + long last = Math.max(task.getLastRunAt(), task.getCreatedAt()); + if (task.getScheduleType() == SyncTask.ScheduleType.INTERVAL) { + // Overdue after SqlJam was closed: due at once + return last + Math.max(1, task.getIntervalMinutes()) * 60_000L; + } + LocalTime time = dailyTime(task); + ZonedDateTime today = LocalDate.ofInstant(Instant.ofEpochMilli(now), zone).atTime(time).atZone(zone); + ZonedDateTime previous = today.toInstant().toEpochMilli() <= now ? today : today.minusDays(1); + long previousMillis = previous.toInstant().toEpochMilli(); + // The time of today passed while SqlJam was closed: it runs now + return last < previousMillis ? previousMillis : previous.plusDays(1).toInstant().toEpochMilli(); + } + + static LocalTime dailyTime(SyncTask task) { + try { + return LocalTime.parse(StringUtils.defaultIfBlank(task.getDailyTime(), "02:00")); + } catch (DateTimeParseException e) { + return LocalTime.of(2, 0); + } + } + + /** + * Runs the tasks which are due and not running + */ + public void tick() { + long now = clock.getAsLong(); + for (SyncTask task : store.getTasks()) { + // Tasks of the system scheduler run by the command line, not twice + if (task.getRunner() == SyncTask.Runner.SYSTEM) { + continue; + } + long next = nextRunAt(task, now); + if (next >= 0 && next <= now && !isRunning(task.getId())) { + runNow(task.getId()); + } + } + } + + /** + * Runs the task in the background, the future has its report, or is null when it runs already + */ + public CompletableFuture runNow(String taskId) { + AtomicBoolean flag = running.computeIfAbsent(taskId, id -> new AtomicBoolean()); + if (closed || !flag.compareAndSet(false, true)) { + return null; + } + CompletableFuture future = new CompletableFuture<>(); + try { + workers.execute(() -> { + TransferReport report = null; + Throwable failure = null; + try { + report = runLocked(taskId); + } catch (Throwable e) { + failure = e; + } finally { + // The task is told as ended before the future completes + flag.set(false); + store.findTask(taskId).ifPresent(this::changed); + } + if (failure != null) { + future.completeExceptionally(failure); + } else { + future.complete(report); + } + }); + } catch (RuntimeException e) { + flag.set(false); + future.completeExceptionally(e); + } + return future; + } + + /** + * Stops a running task: it is cancelled before its next page, its transaction rolled back + */ + public void cancel(String taskId) { + AtomicBoolean flag = cancelled.computeIfAbsent(taskId, id -> new AtomicBoolean()); + flag.set(true); + } + + /** + * Runs the task holding its lock, null when another process runs it + */ + private TransferReport runLocked(String taskId) throws IOException { + File file = lockFile(taskId); + Files.createDirectories(file.getParentFile().toPath()); + try (FileChannel channel = FileChannel.open(file.toPath(), StandardOpenOption.CREATE, StandardOpenOption + .WRITE)) { + FileLock lock = channel.tryLock(); + if (lock == null) { + log.info("Sync task {} runs in another process, it is not run here", taskId); + return null; + } + try { + return store.findTask(taskId).map(this::run).orElse(null); + } finally { + lock.release(); + } + } + } + + private void changed(SyncTask task) { + for (Consumer listener : listeners) { + try { + listener.accept(task); + } catch (RuntimeException e) { + log.warn("A listener of sync tasks failed: {}", e.getMessage()); + } + } + } + + /** + * One run with its attempts, its report is saved whatever its end + */ + TransferReport run(SyncTask task) { + AtomicBoolean cancel = cancelled.computeIfAbsent(task.getId(), id -> new AtomicBoolean()); + cancel.set(false); + changed(task); + SyncEngine engine = engines.get(task.getKind()); + TransferReport report = new TransferReport(); + ReportRecorder recorder = new ReportRecorder(report); + recorder.start(); + TransferReport.Status status; + Throwable failure = null; + Map watermarks = null; + int attempts = 0; + while (true) { + attempts++; + int errorsBefore = report.getErrors().size(); + try { + if (engine == null) { + throw new SyncErrors.PermanentException("Tasks of " + task.getKind() + " can not run here."); + } + recorder.restart(); + engine.describe(task, report); + watermarks = engine.run(task, report, listener(recorder, cancel)); + // Errors of the attempts before, which were run again, do not count + status = report.getErrors().size() == errorsBefore && report.getSkippedRowCount() == 0 + ? TransferReport.Status.COMPLETED : TransferReport.Status.COMPLETED_WITH_ERRORS; + break; + } catch (Throwable e) { + failure = e; + if (isCancellation(e, cancel)) { + status = TransferReport.Status.CANCELLED; + break; + } + if (attempts > task.getRetries() || !SyncErrors.isRetryable(e)) { + status = TransferReport.Status.FAILED; + break; + } + long delay = retryDelayMillis << Math.min(attempts - 1, 10); + recorder.error(String.format("Attempt %d failed: %s. It runs again in %d s.", attempts, message(e), + delay / 1000)); + log.warn("Sync task {} failed, attempt {} of {}: {}", task.getName(), attempts, task.getRetries() + + 1, message(e)); + try { + sleeper.sleep(delay); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + status = TransferReport.Status.CANCELLED; + break; + } + if (cancel.get() || closed) { + status = TransferReport.Status.CANCELLED; + break; + } + } + } + report.getOptions().put("Attempts", String.valueOf(attempts)); + report.getOptions().put("Started by", origin); + recorder.finish(status, status == TransferReport.Status.FAILED ? failure : null, null); + // The target is counted unless it can not be reached + boolean reachable = status != TransferReport.Status.FAILED || !SyncErrors.isRetryable(failure); + if (engine != null && reachable && !(failure instanceof SyncErrors.PermanentException)) { + try { + engine.verify(task, report); + } catch (Exception e) { + log.warn("Rows of the target of {} are not counted: {}", task.getName(), e.getMessage()); + } + } + try { + reportStore.save(report); + } catch (Exception e) { + log.error("The report of sync task {} is not saved: {}", task.getName(), e.getMessage()); + } + Map committed = status.isKept() ? watermarks : null; + TransferReport.Status finalStatus = status; + try { + store.updateTask(task.getId(), saved -> { + saved.setLastRunAt(report.getStartedAt()); + saved.setLastStatus(finalStatus); + saved.setLastReportId(report.getId()); + saved.setLastRowCount(report.getWrittenRows()); + saved.setLastRunBy(origin); + if (committed != null) { + committed.forEach((name, watermark) -> { + SyncTask.Table table = saved.findTable(name); + if (table != null && watermark != null) { + table.setWatermark(watermark); + } + }); + } + }); + } catch (Exception e) { + log.error("Sync task {} is not saved after its run: {}", task.getName(), e.getMessage()); + } + return report; + } + + private static String message(Throwable e) { + Throwable cause = e; + while (cause.getCause() != null && StringUtils.isBlank(cause.getMessage())) { + cause = cause.getCause(); + } + return StringUtils.defaultIfBlank(cause.getMessage(), cause.getClass().getSimpleName()); + } + + private boolean isCancellation(Throwable e, AtomicBoolean cancel) { + for (Throwable cause = e; cause != null; cause = cause.getCause()) { + if (cause instanceof ExportCancelledException || cause instanceof InterruptedException) { + return true; + } + } + return cancel.get() || closed; + } + + private ExportListener listener(ReportRecorder recorder, AtomicBoolean cancel) { + return new ExportListener() { + @Override + public void onTableStart(String catalogName, String schemaName, String tableName, long totalRows) { + recorder.tableStarted(catalogName, schemaName, tableName, totalRows); + } + + @Override + public void onTableProgress(String catalogName, String schemaName, String tableName, long processedRows, + long totalRows) { + recorder.tableProgress(catalogName, schemaName, tableName, processedRows); + } + + @Override + public void onTableEnd(String catalogName, String schemaName, String tableName, long processedRows) { + recorder.tableEnded(catalogName, schemaName, tableName, processedRows); + } + + @Override + public void onError(String message, Throwable e) { + recorder.error(message); + } + + @Override + public void onRowSkipped(String catalogName, String schemaName, String tableName, String key, + Throwable e) { + recorder.rowSkipped(catalogName, schemaName, tableName, key, e); + } + + @Override + public void onDdlExecuted(String statement, boolean successful) { + recorder.ddlExecuted(statement, successful); + } + + @Override + public boolean isCancelled() { + return cancel.get() || closed || Thread.currentThread().isInterrupted(); + } + }; + } + + /** + * Stops the scheduler: running tasks are cancelled and their reports saved, waiting a few seconds for them + */ + @Override + public void close() { + closed = true; + synchronized (this) { + if (ticker != null) { + ticker.shutdownNow(); + } + } + workers.shutdown(); + try { + if (!workers.awaitTermination(10, TimeUnit.SECONDS)) { + workers.shutdownNow(); + } + } catch (InterruptedException e) { + workers.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/com/github/sqljam/face/service/SyncTaskStore.java b/src/main/java/com/github/sqljam/face/service/SyncTaskStore.java new file mode 100644 index 0000000..150d204 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/service/SyncTaskStore.java @@ -0,0 +1,144 @@ +/* + * 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.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import com.github.sqljam.config.Config; +import com.github.sqljam.face.model.SyncTask; +import lombok.extern.slf4j.Slf4j; + +/** + * @Description: SyncTaskStore keeps the sync tasks with their watermarks in sqljam.sync.file, by default + * sync-tasks.json in the home directory of SqlJam. A task is saved after each run, its watermarks with + * it. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Slf4j +public class SyncTaskStore { + + private final JsonStore jsonStore; + private final String fileName; + private final File file; + private final List tasks = new ArrayList<>(); + /** + * Time of the file which was read or written last: the command line saves the tasks too + */ + private long readModified; + private long readLength; + + public SyncTaskStore(Config config) { + this(config.getFile("sqljam.sync.file", new File(config.getString("sqljam.home", JsonStore.DEFAULT_DIRECTORY + .getAbsolutePath()), "sync-tasks.json"))); + } + + public SyncTaskStore(File file) { + this.jsonStore = new JsonStore(file.getAbsoluteFile().getParentFile()); + this.fileName = file.getName(); + this.file = file.getAbsoluteFile(); + read(); + } + + public File getFile() { + return file; + } + + private void read() { + readModified = file.lastModified(); + readLength = file.length(); + SyncTask[] saved = jsonStore.read(fileName, SyncTask[].class, null); + if (saved == null && file.exists()) { + // A file which can not be read is kept aside, the next save would replace the tasks in it + File broken = new File(file.getAbsoluteFile().getParentFile(), fileName + ".broken-" + System + .currentTimeMillis()); + if (file.renameTo(broken) && log.isWarnEnabled()) { + log.warn("Sync tasks can not be read, the file is kept as {}", broken); + } + } + tasks.clear(); + if (saved != null) { + tasks.addAll(Arrays.asList(saved)); + } + } + + /** + * Reads the file again when another process (SqlJam or the command line) wrote it meanwhile + */ + private void reloadIfChanged() { + if (file.lastModified() != readModified || file.length() != readLength) { + read(); + } + } + + private void write() throws IOException { + jsonStore.write(fileName, tasks); + readModified = file.lastModified(); + readLength = file.length(); + } + + public synchronized List getTasks() { + reloadIfChanged(); + return new ArrayList<>(tasks); + } + + public synchronized Optional findTask(String id) { + reloadIfChanged(); + return tasks.stream().filter(task -> task.getId().equals(id)).findFirst(); + } + + /** + * Adds a new task or replaces the task with the same id, in its place + */ + public synchronized void saveTask(SyncTask task) throws IOException { + reloadIfChanged(); + for (int i = 0; i < tasks.size(); i++) { + if (tasks.get(i).getId().equals(task.getId())) { + tasks.set(i, task); + write(); + return; + } + } + tasks.add(task); + write(); + } + + /** + * Changes the saved task of the id, e.g. its watermarks after a run, and keeps the other changes of the user + * which were saved meanwhile. Nothing is changed when the task was removed. + */ + public synchronized Optional updateTask(String id, Consumer change) throws IOException { + Optional task = findTask(id); + if (task.isPresent()) { + change.accept(task.get()); + write(); + } + return task; + } + + public synchronized void removeTask(String id) throws IOException { + reloadIfChanged(); + tasks.removeIf(task -> task.getId().equals(id)); + write(); + } +} diff --git a/src/main/java/com/github/sqljam/face/service/SystemScheduler.java b/src/main/java/com/github/sqljam/face/service/SystemScheduler.java new file mode 100644 index 0000000..8d3bc0f --- /dev/null +++ b/src/main/java/com/github/sqljam/face/service/SystemScheduler.java @@ -0,0 +1,304 @@ +/* + * 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.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.config.Config; +import com.github.sqljam.face.model.SyncTask; +import lombok.extern.slf4j.Slf4j; + +/** + * @Description: SystemScheduler registers sync tasks with the scheduler of the system, like DBeaver does, so that + * they run when SqlJam is closed: a line of the crontab on macOS and Linux, a task of the Task + * Scheduler on Windows. Each runs sqljam sync --task with the id of the task. Lines of the crontab + * of SqlJam are marked with the id, the other lines are kept as they are. Schedules which cron can + * not tell exactly (e.g. every 90 minutes) wake every 5 minutes and run when the task is due. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Slf4j +public class SystemScheduler { + + static final String MARK = "# sqljam-sync "; + static final String WINDOWS_FOLDER = "SqlJam\\"; + + /** + * Runs a command of the system with its input, returns its exit code and output + */ + @FunctionalInterface + public interface CommandRunner { + Result run(List command, String input) throws IOException; + } + + public static final class Result { + + private final int exitCode; + private final String output; + + public Result(int exitCode, String output) { + this.exitCode = exitCode; + this.output = output; + } + + public int getExitCode() { + return exitCode; + } + + public String getOutput() { + return output; + } + } + + private final boolean windows; + private final List launcher; + private final File logFile; + private final CommandRunner runner; + + /** + * @param launcher command which starts SqlJam, empty when SqlJam does not run from its jar or installation + */ + public SystemScheduler(String osName, List launcher, File logFile, CommandRunner runner) { + this.windows = StringUtils.defaultString(osName).toLowerCase(Locale.ENGLISH).startsWith("windows"); + this.launcher = launcher; + this.logFile = logFile; + this.runner = runner; + } + + /** + * The scheduler of this system with the launcher of this SqlJam + */ + public static SystemScheduler create(Config config) { + File home = new File(config.getString("sqljam.home", JsonStore.DEFAULT_DIRECTORY.getAbsolutePath())); + return new SystemScheduler(System.getProperty("os.name"), launcher(), new File(home, "sync.log"), + SystemScheduler::execute); + } + + /** + * Java with the jar of SqlJam, or the launcher of an installation of SqlJam + */ + static List launcher() { + String command = ProcessHandle.current().info().command().orElse(null); + if (command == null) { + return List.of(); + } + String name = new File(command).getName().toLowerCase(Locale.ENGLISH); + if (!name.equals("java") && !name.equals("java.exe") && !name.equals("javaw.exe")) { + // The launcher of the installation passes its arguments to SqlJam + return List.of(command); + } + File jarDirectory = Config.getJarDirectory(); + if (jarDirectory == null) { + return List.of(); + } + try { + File jar = new File(Config.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + return List.of(command, "-Dsqljam.vmoptions.applied=true", "-jar", jar.getAbsolutePath()); + } catch (Exception e) { + return List.of(); + } + } + + static Result execute(List command, String input) throws IOException { + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + try (OutputStream stdin = process.getOutputStream()) { + if (input != null) { + stdin.write(input.getBytes(StandardCharsets.UTF_8)); + } + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (InputStream stdout = process.getInputStream()) { + stdout.transferTo(output); + } + try { + if (!process.waitFor(60, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IOException("No answer of " + command.get(0)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } + return new Result(process.exitValue(), output.toString(StandardCharsets.UTF_8)); + } + + public boolean isAvailable() { + return !launcher.isEmpty(); + } + + /** + * Arguments of the command line of the task, the check of the time when the system wakes more often + */ + List arguments(SyncTask task) { + List arguments = new ArrayList<>(launcher); + arguments.addAll(List.of("sync", "--task", task.getId())); + if (!isExact(task)) { + arguments.add("--if-due"); + } + return arguments; + } + + /** + * The command line which runs the task, e.g. to copy it into a scheduler of your own + */ + public String getCommand(SyncTask task) { + return arguments(task).stream().map(argument -> windows ? windowsQuote(argument) : shellQuote(argument)) + .collect(Collectors.joining(" ")); + } + + /** + * Whether the system runs the task at its times exactly, it wakes every 5 minutes otherwise + */ + boolean isExact(SyncTask task) { + if (task.getScheduleType() == SyncTask.ScheduleType.DAILY) { + return true; + } + int minutes = Math.max(1, task.getIntervalMinutes()); + if (windows) { + return minutes <= 1439 || minutes == 1440; + } + return minutes < 60 && 60 % minutes == 0 || minutes % 60 == 0 && 24 % (minutes / 60) == 0; + } + + /** + * When cron runs the task + */ + String cron(SyncTask task) { + if (task.getScheduleType() == SyncTask.ScheduleType.DAILY) { + LocalTime time = SyncScheduler.dailyTime(task); + return time.getMinute() + " " + time.getHour() + " * * *"; + } + int minutes = Math.max(1, task.getIntervalMinutes()); + if (minutes < 60 && 60 % minutes == 0) { + return minutes == 1 ? "* * * * *" : "*/" + minutes + " * * * *"; + } + if (minutes % 60 == 0 && 24 % (minutes / 60) == 0) { + int hours = minutes / 60; + return hours == 24 ? "0 0 * * *" : hours == 1 ? "0 * * * *" : "0 */" + hours + " * * *"; + } + return "*/5 * * * *"; + } + + /** + * Registers the task, or removes it when it is not run by the system on a schedule + */ + public void apply(SyncTask task) throws IOException { + if (task.getRunner() == SyncTask.Runner.SYSTEM && task.isEnabled() && task.getScheduleType() + != SyncTask.ScheduleType.MANUAL) { + register(task); + } else { + unregister(task.getId()); + } + } + + public void register(SyncTask task) throws IOException { + if (!isAvailable()) { + throw new IOException("SqlJam does not run from its jar or its installation, the system can not start " + + "it. Run this command on a schedule yourself: sqljam sync --task " + task.getId()); + } + if (windows) { + List command = new ArrayList<>(List.of("schtasks", "/Create", "/F", "/TN", taskName(task + .getId()), "/TR", getCommand(task))); + if (task.getScheduleType() == SyncTask.ScheduleType.DAILY) { + LocalTime time = SyncScheduler.dailyTime(task); + command.addAll(List.of("/SC", "DAILY", "/ST", String.format("%02d:%02d", time.getHour(), time + .getMinute()))); + } else if (isExact(task) && task.getIntervalMinutes() < 1440) { + command.addAll(List.of("/SC", "MINUTE", "/MO", String.valueOf(Math.max(1, task + .getIntervalMinutes())))); + } else if (task.getIntervalMinutes() == 1440) { + command.addAll(List.of("/SC", "DAILY", "/ST", "00:00")); + } else { + command.addAll(List.of("/SC", "MINUTE", "/MO", "5")); + } + check(runner.run(command, null), command); + return; + } + List lines = crontab(); + lines.removeIf(line -> line.endsWith(MARK + task.getId())); + lines.add(cron(task) + " " + getCommand(task) + " >> " + shellQuote(logFile.getAbsolutePath()) + " 2>&1 " + + MARK + task.getId()); + writeCrontab(lines); + } + + public void unregister(String taskId) throws IOException { + if (windows) { + // A task which was never registered is not there + runner.run(List.of("schtasks", "/Delete", "/F", "/TN", taskName(taskId)), null); + return; + } + List lines = crontab(); + if (lines.removeIf(line -> line.endsWith(MARK + taskId))) { + writeCrontab(lines); + } + } + + public boolean isRegistered(String taskId) throws IOException { + if (windows) { + return runner.run(List.of("schtasks", "/Query", "/TN", taskName(taskId)), null).getExitCode() == 0; + } + return crontab().stream().anyMatch(line -> line.endsWith(MARK + taskId)); + } + + static String taskName(String taskId) { + return WINDOWS_FOLDER + "sync-" + taskId; + } + + private List crontab() throws IOException { + Result result = runner.run(List.of("crontab", "-l"), null); + // No crontab yet + if (result.getExitCode() != 0) { + return new ArrayList<>(); + } + return new ArrayList<>(Arrays.asList(result.getOutput().split("\\R"))).stream().filter(StringUtils + ::isNotEmpty).collect(Collectors.toCollection(ArrayList::new)); + } + + private void writeCrontab(List lines) throws IOException { + List command = List.of("crontab", "-"); + check(runner.run(command, lines.isEmpty() ? "" : String.join("\n", lines) + "\n"), command); + } + + private static void check(Result result, List command) throws IOException { + if (result.getExitCode() != 0) { + throw new IOException(command.get(0) + " failed: " + StringUtils.defaultIfBlank(result.getOutput() + .trim(), "exit code " + result.getExitCode())); + } + } + + static String shellQuote(String argument) { + return argument.matches("[A-Za-z0-9_./:=@%+-]+") ? argument : "'" + argument.replace("'", "'\\''") + "'"; + } + + static String windowsQuote(String argument) { + return argument.matches("[A-Za-z0-9_./:=\\\\-]+") ? argument : "\"" + argument.replace("\"", "\\\"") + + "\""; + } +} 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 fa58169..ab1f4fa 100644 --- a/src/main/java/com/github/sqljam/face/service/TransferService.java +++ b/src/main/java/com/github/sqljam/face/service/TransferService.java @@ -17,16 +17,23 @@ import java.io.File; import java.sql.Connection; +import java.sql.DatabaseMetaData; import java.sql.DriverManager; +import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Statement; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; 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.TransferReport; import com.github.sqljam.face.model.TransferRequest; import com.github.sqljam.impexp.AbstractFileImporter; import com.github.sqljam.impexp.CsvExporter; @@ -35,6 +42,7 @@ import com.github.sqljam.impexp.DataFileImporter; import com.github.sqljam.impexp.DataFormat; import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.Dialect; import com.github.sqljam.impexp.DuckDBWorkspace; import com.github.sqljam.impexp.ExportListener; import com.github.sqljam.impexp.Exporter; @@ -44,6 +52,8 @@ import com.github.sqljam.impexp.ParquetImporter; import com.github.sqljam.impexp.ScriptExporter; import com.github.sqljam.impexp.SqlScriptRunner; +import com.github.sqljam.impexp.WriteMode; +import com.github.sqljam.jdbc.JdbcUtils; import com.github.sqljam.jdbc.ManualTransaction; import com.github.sqljam.jdbc.SharedConnectionFactory; @@ -92,16 +102,139 @@ public ManualTransaction beginTransaction(ConnectionProfile target, String catal } } + /** + * Header of the report of a transfer: what goes from where to where with which options + */ + public static void describeReport(TransferRequest request, TransferReport report) { + boolean database = request.getTarget() == TransferRequest.Target.DATABASE; + report.setKind(database ? TransferReport.Kind.DATABASE : TransferReport.Kind.PACKAGE); + report.setSource(describe(request.getSource() != null ? request.getSource().getName() : null, request + .getSourceCatalog(), request.getSourceSchema())); + if (database) { + report.setTarget(describe(request.getTargetProfile().getName(), request.getTargetCatalog(), request + .getTargetSchema())); + report.setTransaction(request.isOneTransaction() ? TransferReport.Transaction.ONE + : TransferReport.Transaction.EVERY_ROWS); + } else { + report.setTarget(request.getOutputDirectory() + " (" + request.getDataFormat() + ")"); + report.setTransaction(TransferReport.Transaction.NONE); + } + Map options = report.getOptions(); + options.put("Content", ReportExporter.words(request.getExportMode())); + options.put("Tables", request.getQueries().isEmpty() ? (request.getTables().isEmpty() ? "All tables" + : String.join(", ", request.getTables())) : String.join(", ", request.getQueries().keySet())); + if (database) { + options.put("Write mode", ReportExporter.words(request.getWriteMode())); + options.put("Tables recreated", String.valueOf(request.isTableRecreated())); + options.put("Skip rows with errors", String.valueOf(!request.isFailFast())); + options.put("Confirm before commit", String.valueOf(request.isConfirmCommit())); + if (StringUtils.isNotBlank(request.getTableNamePattern())) { + options.put("Table names", request.getTableNamePattern()); + } + long mapped = request.getColumnMappings().values().stream().mapToLong(Map::size).sum(); + if (mapped > 0) { + options.put("Mapped columns", String.valueOf(mapped)); + } + } else { + options.put("Format", String.valueOf(request.getDataFormat())); + if (request.getScriptDbType() != null) { + options.put("Statements for", request.getScriptDbType().getDisplayName()); + } + } + options.put("Identity values", String.valueOf(request.isIdReused())); + options.put("Indexes, foreign keys, comments, sequences", request.isIndexIncluded() + ", " + request + .isForeignKeyIncluded() + ", " + request.isCommentIncluded() + ", " + request.isSequenceIncluded()); + options.put("Page size", String.valueOf(request.getPageSize())); + } + + static String describe(String name, String catalog, String schema) { + StringBuilder text = new StringBuilder(StringUtils.defaultString(name)); + for (String part : new String[]{catalog, schema}) { + if (StringUtils.isNotBlank(part)) { + text.append(" / ").append(part); + } + } + return text.toString(); + } + + /** + * Rows of the target tables after a transfer into a database, compared with the written rows when the tables + * were created by it + */ + public void verifyReport(TransferRequest request, TransferReport report) throws SQLException { + if (request.getTarget() != TransferRequest.Target.DATABASE) { + return; + } + boolean comparable = request.isTableRecreated() && request.getWriteMode() == WriteMode.INSERT; + try (Connection connection = connect(request.getTargetProfile(), request.getTargetCatalog(), request + .getTargetSchema())) { + countTargetRows(connection, request.getTargetCatalog(), request.getTargetSchema(), report, name -> + StringUtils.isBlank(request.getTableNamePattern()) ? name : request.getTableNamePattern() + .replace(Dialect.TABLE_PLACEHOLDER, name), comparable); + } + } + + /** + * Counts the rows of the tables of the report which were reached, by their names in the target found without + * case like an import finds them + * + * @param targetName name of the target table of a source table + */ + public static void countTargetRows(Connection connection, String catalog, String schema, TransferReport report, + Function targetName, boolean comparable) throws SQLException { + DatabaseMetaData metaData = connection.getMetaData(); + String quote = StringUtils.defaultIfBlank(StringUtils.trim(metaData.getIdentifierQuoteString()), "\""); + String catalogName = StringUtils.defaultIfBlank(catalog, connection.getCatalog()); + String schemaName = StringUtils.defaultIfBlank(schema, safeSchema(connection)); + Map tables = new HashMap<>(); + try (ResultSet rs = metaData.getTables(catalogName, schemaName, null, new String[]{"TABLE", + "PARTITIONED TABLE", "BASE TABLE"})) { + while (rs.next()) { + tables.putIfAbsent(rs.getString("TABLE_NAME").toLowerCase(Locale.ENGLISH), new String[]{rs + .getString("TABLE_CAT"), rs.getString("TABLE_SCHEM"), rs.getString("TABLE_NAME")}); + } + } + for (TransferReport.TableReport table : report.getTables()) { + if (table.getStatus() == TransferReport.TableStatus.NOT_STARTED) { + continue; + } + String[] found = tables.get(targetName.apply(table.getName()).toLowerCase(Locale.ENGLISH)); + if (found == null) { + continue; + } + StringBuilder name = new StringBuilder(); + if (StringUtils.isNotBlank(found[1])) { + name.append(quote).append(found[1].replace(quote, quote + quote)).append(quote).append('.'); + } + name.append(quote).append(found[2].replace(quote, quote + quote)).append(quote); + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery( + "SELECT COUNT(*) FROM " + name)) { + if (rs.next()) { + table.setTargetRows(rs.getLong(1)); + table.setComparable(comparable); + } + } + } + } + + private static String safeSchema(Connection connection) { + try { + return connection.getSchema(); + } catch (SQLException | AbstractMethodError e) { + return null; + } + } + /** * Connection of the target database in the catalog and schema */ - private static Connection connect(ConnectionProfile target, String catalog, String schema) throws SQLException { + static Connection connect(ConnectionProfile target, String catalog, String schema) throws SQLException { String url = target.getJdbcUrl(StringUtils.defaultIfBlank(catalog, null)); Connection connection = StringUtils.isBlank(target.getUsername()) ? DriverManager.getConnection(url) : DriverManager.getConnection(url, target.getUsername(), target.getPassword()); try { if (StringUtils.isNotBlank(schema) && target.getDbType().isCanSetSchema()) { - connection.setSchema(schema); + JdbcUtils.setSchema(connection, schema); } if (StringUtils.isNotBlank(catalog) && (target.getDbType() == DbType.MYSQL || target.getDbType() == DbType.MARIADB)) { @@ -341,6 +474,7 @@ private void importDatabase(TransferRequest request, ManualTransaction transacti importConfiguration.setTargetSchemaName(request.getTargetSchema()); importConfiguration.setTargetSchemaCreated(request.isTargetSchemaCreated()); importConfiguration.setTableNamePattern(request.getTableNamePattern()); + importConfiguration.setColumnMappings(new HashMap<>(request.getColumnMappings())); importConfiguration.setConnectionPoolEnabled(transaction == null); if (transaction != null) { // All pages and tables in the one transaction diff --git a/src/main/java/com/github/sqljam/face/vdb/ProfileDataSource.java b/src/main/java/com/github/sqljam/face/vdb/ProfileDataSource.java new file mode 100644 index 0000000..20fb14a --- /dev/null +++ b/src/main/java/com/github/sqljam/face/vdb/ProfileDataSource.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.face.vdb; + +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.logging.Logger; +import javax.sql.DataSource; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.model.ConnectionProfile; + +/** + * @Description: ProfileDataSource gives connections of a data source of the jdbc line, the vdb line writes by + * plain JDBC + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +final class ProfileDataSource implements DataSource { + + private final ConnectionProfile profile; + + ProfileDataSource(ConnectionProfile profile) { + this.profile = profile; + } + + @Override + public Connection getConnection() throws SQLException { + String url = profile.getJdbcUrl(); + return StringUtils.isBlank(profile.getUsername()) ? DriverManager.getConnection(url) + : DriverManager.getConnection(url, profile.getUsername(), profile.getPassword()); + } + + @Override + public Connection getConnection(String username, String password) throws SQLException { + return DriverManager.getConnection(profile.getJdbcUrl(), username, password); + } + + @Override + public PrintWriter getLogWriter() { + return null; + } + + @Override + public void setLogWriter(PrintWriter out) { + // Not used + } + + @Override + public void setLoginTimeout(int seconds) { + // DriverManager has its own timeout + } + + @Override + public int getLoginTimeout() { + return 0; + } + + @Override + public Logger getParentLogger() throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException(); + } + + @Override + public T unwrap(Class type) throws SQLException { + throw new SQLException("Not a wrapper of " + type); + } + + @Override + public boolean isWrapperFor(Class type) { + return false; + } +} diff --git a/src/main/java/com/github/sqljam/face/vdb/VdbCollectionTab.java b/src/main/java/com/github/sqljam/face/vdb/VdbCollectionTab.java new file mode 100644 index 0000000..6d84ea6 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/vdb/VdbCollectionTab.java @@ -0,0 +1,546 @@ +/* + * 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.vdb; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Function; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.view.DataPane; +import com.github.sqljam.face.view.Icons; +import com.github.sqljam.face.view.Messages; +import com.github.sqljam.face.view.TableColumns; +import com.github.sqljam.face.view.TaskRunner; +import com.github.sqljam.face.view.ValueCell; +import com.github.sqljam.vdb.VdbClient; +import com.github.sqljam.vdb.VdbCollection; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VectorType; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.collections.ObservableList; +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.Tab; +import javafx.scene.control.TabPane; +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: VdbCollectionTab shows a collection of a vector database like a table of a data source: its fields + * (the columns of its table in a target) and its records, narrowed by conditions or a native filter, + * or the nearest neighbors of a vector or of a record. Import... imports the records of the query. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbCollectionTab extends Tab { + + private static final String CHECK = "✓"; + /** + * Records which pages read at most, a page is read by the cursor of the database from the first record + */ + static final int MAX_RECORDS = 10000; + + private final VdbProfile profile; + private final String collectionName; + private final TableView fieldTable = new TableView<>(); + private final TableView> dataTable = new TableView<>(); + private final ComboBox filterTypeCombo = new ComboBox<>(); + private final TextField filterField = new TextField(); + private final ComboBox nearestFieldCombo = new ComboBox<>(); + private final TextField nearestField = 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 importButton = new Button(Messages.get("vdb.import"), Icons.of(Icons.IMPORT)); + private final Button refreshButton = new Button(null, Icons.of(Icons.REFRESH)); + private final Button firstButton = new Button(null, Icons.of(Icons.FIRST)); + private final Button previousButton = new Button(null, Icons.of(Icons.PREVIOUS)); + private final Button nextButton = new Button(null, Icons.of(Icons.NEXT)); + private final Button lastButton = new Button(null, Icons.of(Icons.LAST)); + private final Label pageLabel = new Label(); + /** + * Records of the page, its headers sort them on the page + */ + private final ObservableList> dataRows = TableColumns.sortedRows(dataTable); + private int pageNumber = 1; + /** + * Pages of the query, 0 while its count is unknown + */ + private int totalPages; + private boolean hasNextPage; + private final ComboBox limitCombo = new ComboBox<>(); + private final Label totalLabel = new Label(); + private final Label errorLabel = new Label(); + /** + * Partitions, tenants or namespaces of the collection which are read, all when none is checked + */ + private final MenuButton partitionsButton = new MenuButton(); + private final Map partitionChecks = new LinkedHashMap<>(); + private final List initialPartitions; + private VdbCollection collection; + private VdbQuery query = new VdbQuery(); + private boolean dataLoaded; + private Tab dataTab; + private BiConsumer onImport; + + public VdbCollectionTab(VdbProfile profile, String collectionName) { + this(profile, collectionName, List.of()); + } + + /** + * @param partitions partitions, tenants or namespaces which are read first, all if it is empty + */ + public VdbCollectionTab(VdbProfile profile, String collectionName, List partitions) { + this.profile = profile; + this.collectionName = collectionName; + this.initialPartitions = partitions; + String partitionName = profile.getType().getPartitionName(); + setText(partitions.size() == 1 ? collectionName + " / " + partitions.get(0) : collectionName); + setGraphic(Icons.of(Icons.TABLE)); + setTooltip(new Tooltip(profile.getName() + "." + collectionName)); + fieldTable.setId("vdbFieldTable"); + dataTable.setId("vdbDataTable"); + filterTypeCombo.setId("vdbFilterTypeCombo"); + filterField.setId("vdbFilterField"); + nearestFieldCombo.setId("vdbNearestFieldCombo"); + nearestField.setId("vdbNearestField"); + applyButton.setId("vdbApplyButton"); + clearButton.setId("vdbClearButton"); + importButton.setId("vdbImportButton"); + refreshButton.setId("vdbRefreshButton"); + firstButton.setId("vdbFirstButton"); + previousButton.setId("vdbPreviousButton"); + nextButton.setId("vdbNextButton"); + lastButton.setId("vdbLastButton"); + pageLabel.setId("vdbPageLabel"); + limitCombo.setId("vdbLimitCombo"); + totalLabel.setId("vdbTotalLabel"); + errorLabel.setId("vdbQueryErrorLabel"); + partitionsButton.setId("vdbPartitionsButton"); + partitionsButton.setVisible(false); + partitionsButton.setManaged(false); + partitionsButton.setText(partitionName != null ? Messages.format("vdb.partitions.all", partitionName) + : null); + + TabPane tabs = new TabPane(); + tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); + Tab fieldsTab = new Tab(Messages.get("vdb.fields"), fieldTable); + dataTab = new Tab(Messages.get("table.data"), createDataPane()); + fieldsTab.setGraphic(Icons.of(Icons.COLUMNS)); + dataTab.setGraphic(Icons.of(Icons.DATA)); + tabs.getTabs().addAll(fieldsTab, dataTab); + tabs.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, tab) -> { + if (tab == dataTab && !dataLoaded && collection != null) { + dataLoaded = true; + load(); + } + }); + setContent(tabs); + setupFieldTable(); + describe(); + } + + /** + * The records of the query are imported by the consumer, e.g. the import dialog + */ + public void setOnImport(BiConsumer onImport) { + this.onImport = onImport; + } + + VdbCollection getCollection() { + return collection; + } + + private static TableColumn column(String key, Function getter, double width) { + TableColumn column = new TableColumn<>(Messages.get(key)); + column.setCellValueFactory(features -> new ReadOnlyObjectWrapper<>(getter.apply(features.getValue()))); + column.setPrefWidth(width); + return column; + } + + private void setupFieldTable() { + fieldTable.getColumns().setAll(List.of( + column("column.name", VdbField::getName, 180), + column("column.type", VdbCollectionTab::describeType, 200), + column("column.nullable", field -> field.isNullable() && !field.isPrimaryKey() ? CHECK : "", + 80), + column("column.primaryKey", field -> field.isPrimaryKey() ? CHECK : "", 90), + column("vdb.field.index", field -> field.getVectorIndex() == null ? "" : field.getVectorIndex() + .getKind() + " " + field.getVectorIndex().getDistance(), 160), + column("column.remarks", field -> StringUtils.defaultString(field.getRemarks()), 260))); + fieldTable.setPlaceholder(new Label(Messages.get("tree.loading"))); + } + + /** + * Type of a field: a vector of its elements and dimensions, or the kind of a scalar + */ + static String describeType(VdbField field) { + if (field.isVector()) { + VectorType type = field.getVectorType(); + return (type.isSparse() ? "SPARSE " : "VECTOR ") + type.getElement() + (type.getDimensions() > 0 + ? "(" + type.getDimensions() + ")" : ""); + } + return field.getKind() == VdbField.Kind.VARCHAR ? "VARCHAR(" + field.getSize() + ")" : field.getKind() + .name(); + } + + /** + * Conditions or a native filter, the nearest neighbors and the records at most, like the query bar of a table + */ + private BorderPane createDataPane() { + filterTypeCombo.getItems().setAll(Messages.get("vdb.query.conditions"), Messages.get("vdb.query.native")); + filterTypeCombo.setValue(filterTypeCombo.getItems().get(0)); + filterTypeCombo.valueProperty().addListener((obs, oldType, type) -> updateFilterPrompt()); + updateFilterPrompt(); + filterField.setOnAction(event -> applyQuery()); + nearestField.setOnAction(event -> applyQuery()); + nearestField.setPromptText(Messages.get("vdb.query.nearest.prompt")); + nearestField.setPrefWidth(220); + nearestFieldCombo.setPromptText(Messages.get("vdb.query.nearest")); + applyButton.setOnAction(event -> applyQuery()); + clearButton.setOnAction(event -> clearQuery()); + importButton.setOnAction(event -> { + if (onImport != null) { + onImport.accept(collectionName, query); + } + }); + HBox.setHgrow(filterField, Priority.ALWAYS); + HBox bar = new HBox(6, partitionsButton, filterTypeCombo, filterField, nearestFieldCombo, nearestField, + applyButton, clearButton, importButton); + bar.setAlignment(Pos.CENTER_LEFT); + errorLabel.getStyleClass().add("status-error"); + errorLabel.setWrapText(true); + errorLabel.setVisible(false); + errorLabel.setManaged(false); + VBox top = new VBox(4, bar, errorLabel); + top.setPadding(new Insets(8, 8, 0, 8)); + top.getStyleClass().add("query-bar"); + + dataTable.getStyleClass().add("data-table"); + // Rows of one line, long values are shown by a double click + dataTable.setFixedCellSize(26); + // The columns fill the width, no empty column is left at the right + TableColumns.fill(dataTable); + TableColumns.fill(fieldTable); + dataTable.setPlaceholder(new Label(Messages.get("tree.loading"))); + limitCombo.getItems().addAll(50, 100, 200, 500); + limitCombo.setValue(50); + limitCombo.valueProperty().addListener((obs, oldLimit, limit) -> load()); + refreshButton.setOnAction(event -> load(pageNumber)); + refreshButton.setTooltip(new Tooltip(Messages.get("action.refresh"))); + firstButton.setOnAction(event -> load(1)); + previousButton.setOnAction(event -> load(pageNumber - 1)); + nextButton.setOnAction(event -> load(pageNumber + 1)); + lastButton.setOnAction(event -> load(getLastPage())); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + HBox pager = new HBox(6, firstButton, previousButton, pageLabel, nextButton, lastButton, refreshButton, + spacer, totalLabel, new Label(Messages.get("data.pageSize")), limitCombo); + updatePager(true); + pager.setAlignment(Pos.CENTER_LEFT); + pager.setPadding(new Insets(8)); + pager.getStyleClass().add("pager"); + BorderPane pane = new BorderPane(dataTable); + pane.setTop(top); + pane.setBottom(pager); + return pane; + } + + private boolean isNativeFilter() { + return filterTypeCombo.getSelectionModel().getSelectedIndex() == 1; + } + + private void updateFilterPrompt() { + filterField.setPromptText(Messages.get(isNativeFilter() ? "vdb.query.native.prompt" + : "vdb.query.conditions.prompt")); + } + + private void describe() { + TaskRunner.run(() -> { + try (VdbClient client = profile.getType().createClient(profile.toSource())) { + return client.describe(collectionName); + } + }, described -> { + collection = described; + showPartitions(described); + fieldTable.getItems().setAll(described.getFields()); + fieldTable.setPlaceholder(new Label(Messages.get("vdb.fields.empty"))); + nearestFieldCombo.getItems().setAll(described.getVectorFields().stream().map(VdbField::getName) + .toList()); + if (!nearestFieldCombo.getItems().isEmpty()) { + nearestFieldCombo.setValue(nearestFieldCombo.getItems().get(0)); + } + setTooltip(new Tooltip(Messages.format("vdb.collection.tooltip", profile.getName(), collectionName, + described.getCount()))); + // The data tab which was chosen before the fields were read reads its records now + if (dataTab.isSelected() && !dataLoaded) { + dataLoaded = true; + load(); + } + }, e -> { + fieldTable.setPlaceholder(new Label(Messages.format("tree.error", e.getMessage()))); + dataTable.setPlaceholder(new Label(Messages.format("tree.error", e.getMessage()))); + }); + } + + /** + * Partitions of the collection to choose from, the partitions of the tab are checked + */ + private void showPartitions(VdbCollection described) { + List partitions; + try (VdbClient client = profile.getType().createClient(profile.toSource())) { + partitions = client.getPartitions(described); + } + partitionChecks.clear(); + partitionsButton.getItems().clear(); + boolean visible = !partitions.isEmpty(); + partitionsButton.setVisible(visible); + partitionsButton.setManaged(visible); + for (String partition : partitions) { + CheckBox check = new CheckBox(partition); + check.setSelected(initialPartitions.contains(partition)); + check.selectedProperty().addListener((obs, oldValue, value) -> updatePartitionsText()); + partitionChecks.put(partition, check); + CustomMenuItem item = new CustomMenuItem(check); + // The menu stays open while partitions are checked + item.setHideOnClick(false); + partitionsButton.getItems().add(item); + } + query.getPartitions().addAll(getCheckedPartitions()); + updatePartitionsText(); + } + + private List getCheckedPartitions() { + List checked = new ArrayList<>(); + partitionChecks.forEach((partition, check) -> { + if (check.isSelected()) { + checked.add(partition); + } + }); + return checked; + } + + private void updatePartitionsText() { + List checked = getCheckedPartitions(); + String partitionName = profile.getType().getPartitionName(); + partitionsButton.setText(checked.isEmpty() ? Messages.format("vdb.partitions.all", partitionName) + : checked.size() == 1 ? partitionName + ": " + checked.get(0) : Messages.format( + "vdb.partitions.some", partitionName, checked.size())); + } + + /** + * Query of the inputs + * + * @throws IllegalArgumentException if a condition or the vector is invalid + */ + VdbQuery buildQuery() { + VdbQuery built = new VdbQuery(); + String filter = StringUtils.trimToNull(filterField.getText()); + if (filter != null) { + if (isNativeFilter()) { + built.setNativeFilter(filter); + } else { + built.where(filter); + } + } + String nearest = StringUtils.trimToNull(nearestField.getText()); + if (nearest != null) { + built.setNearestField(nearestFieldCombo.getValue()); + if (nearest.startsWith("[")) { + VectorType.Values values = VectorType.parseValues(nearest); + if (values == null || values.isSparse()) { + throw new IllegalArgumentException(Messages.get("vdb.query.nearest.error")); + } + built.setNearestVector(values.toFloats()); + } else { + built.setNearestId(nearest.matches("\\d{1,18}") ? (Object) Long.parseLong(nearest) : nearest); + } + built.setLimit(10); + } + built.getPartitions().addAll(getCheckedPartitions()); + return built; + } + + private void applyQuery() { + try { + query = buildQuery(); + } catch (IllegalArgumentException e) { + showError(e.getMessage()); + return; + } + showError(null); + load(); + } + + private void clearQuery() { + filterField.clear(); + nearestField.clear(); + query = new VdbQuery(); + query.getPartitions().addAll(getCheckedPartitions()); + showError(null); + load(); + } + + private void showError(String message) { + errorLabel.setText(message); + errorLabel.setVisible(message != null); + errorLabel.setManaged(message != null); + } + + /** + * Reads the first page of the query + */ + void load() { + load(1); + } + + /** + * Pages of the records which are read at most, all pages of a known count within MAX_RECORDS + */ + int getLastPage() { + int maxPages = Math.max(1, MAX_RECORDS / limitCombo.getValue()); + return totalPages > 0 ? Math.min(totalPages, maxPages) : maxPages; + } + + /** + * Reads the records of the page of the query. The nearest neighbors are one page, other records are read by + * the cursor of the database from the first one and those of the page are kept, as far as MAX_RECORDS. + */ + void load(int page) { + if (collection == null) { + return; + } + int limit = limitCombo.getValue(); + boolean nearest = query.getNearestField() != null && (query.getNearestVector() != null || query + .getNearestId() != null); + int targetPage = nearest ? 1 : Math.max(1, Math.min(page, getLastPage())); + int offset = (targetPage - 1) * limit; + VdbQuery read = new VdbQuery(); + read.getConditions().addAll(query.getConditions()); + read.setNativeFilter(query.getNativeFilter()); + read.setNearestField(query.getNearestField()); + read.setNearestVector(query.getNearestVector()); + read.setNearestId(query.getNearestId()); + read.getPartitions().addAll(query.getPartitions()); + // One more record tells whether a next page follows + int wanted = offset + limit + 1; + read.setLimit(query.getLimit() > 0 ? Math.min(query.getLimit(), wanted) : wanted); + VdbCollection described = collection; + dataTable.setPlaceholder(new Label(Messages.get("tree.loading"))); + updatePager(true); + long start = System.currentTimeMillis(); + TaskRunner.run(() -> { + List> rows = new ArrayList<>(); + int[] seen = {0}; + try (VdbClient client = profile.getType().createClient(profile.toSource())) { + client.read(described, read, Math.min(wanted, 500), (records, last) -> { + for (Map record : records) { + if (seen[0] >= offset && seen[0] < wanted) { + rows.add(record); + } + seen[0]++; + } + return seen[0] < wanted; + }); + long count = client.count(described, query); + return new Object[]{rows, count}; + } + }, result -> { + @SuppressWarnings("unchecked") + List> rows = (List>) result[0]; + long count = (Long) result[1]; + if (rows.isEmpty() && targetPage > 1) { + // The records of the page were deleted meanwhile, the last page which has records is shown + totalPages = targetPage - 1; + load(targetPage - 1); + return; + } + pageNumber = targetPage; + hasNextPage = !nearest && rows.size() > limit && offset + limit < MAX_RECORDS; + List> shown = rows.size() > limit ? rows.subList(0, limit) : rows; + totalPages = nearest ? 1 : count >= 0 ? (int) Math.max(1, (count + limit - 1) / limit) : 0; + showRows(shown, offset, count, System.currentTimeMillis() - start); + updatePager(false); + }, e -> { + dataRows.clear(); + dataTable.setPlaceholder(new Label(Messages.get("package.noRows"))); + showError(e.getMessage()); + updatePager(false); + }); + } + + private void updatePager(boolean busy) { + pageLabel.setText(totalPages > 0 ? Messages.format("data.page", pageNumber, totalPages) : Messages.format( + "vdb.page", pageNumber)); + firstButton.setDisable(busy || pageNumber <= 1); + previousButton.setDisable(busy || pageNumber <= 1); + nextButton.setDisable(busy || !hasNextPage); + lastButton.setDisable(busy || !hasNextPage || totalPages <= 0); + refreshButton.setDisable(busy); + } + + private void showRows(List> rows, int offset, long count, long millis) { + List fields = collection.getFields(); + List> items = new ArrayList<>(); + for (Map row : rows) { + List values = new ArrayList<>(); + for (VdbField field : fields) { + Object value = row.get(field.getName()); + values.add(value == null ? null : value.toString()); + } + items.add(values); + } + dataTable.getColumns().clear(); + for (int i = 0; i < fields.size(); i++) { + int index = i; + VdbField field = fields.get(i); + TableColumn, String> column = new TableColumn<>(field.getName()); + column.setCellValueFactory(features -> new ReadOnlyObjectWrapper<>(features.getValue().get(index))); + // One line of a value, a double click shows it whole + column.setCellFactory(tableColumn -> new ValueCell()); + // Widths estimated by the header and the values, like the columns of a table + column.setPrefWidth(DataPane.getColumnWidth(field.getName(), items, index)); + column.setComparator(ValueCell.COMPARATOR); + dataTable.getColumns().add(column); + } + dataRows.setAll(items); + dataTable.setPlaceholder(new Label(Messages.get("package.noRows"))); + int from = rows.isEmpty() ? 0 : offset + 1; + totalLabel.setText(count >= 0 ? Messages.format("vdb.records.shown", from, offset + rows.size(), count, + millis) : Messages.format("vdb.records.shownUnknown", from, offset + rows.size(), millis)); + // Pages after the records which are read at most are reached by a narrower query + totalLabel.setTooltip(count > MAX_RECORDS ? new Tooltip(Messages.format("vdb.records.max", MAX_RECORDS)) + : null); + } +} diff --git a/src/main/java/com/github/sqljam/face/vdb/VdbConnectionDialog.java b/src/main/java/com/github/sqljam/face/vdb/VdbConnectionDialog.java new file mode 100644 index 0000000..3a7af70 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/vdb/VdbConnectionDialog.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.vdb; + +import com.github.sqljam.face.view.Branding; +import com.github.sqljam.face.view.DbIcons; +import com.github.sqljam.face.view.Dialogs; +import com.github.sqljam.face.view.Icons; +import com.github.sqljam.face.view.Messages; +import com.github.sqljam.vdb.VdbType; +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.TextField; +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.Window; + +/** + * @Description: VdbConnectionDialog creates or edits a vector database: its name and type, and the fields of + * VdbConnectionForm. Test connects and lists the collections. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbConnectionDialog extends Dialog { + + static final double LABEL_WIDTH = 130; + + private final TextField nameField = new TextField(); + private final ComboBox typeCombo = new ComboBox<>(); + private final VdbConnectionForm form = new VdbConnectionForm(LABEL_WIDTH); + private final Label statusLabel = new Label(); + + public VdbConnectionDialog(Window owner, VdbProfile profile, boolean secretRemembered) { + Dialogs.initOwner(this, owner); + Branding.applyIcons(this); + setTitle(Messages.get(profile != null ? "vdb.connection.edit" : "vdb.connection.new")); + setHeaderText(getTitle()); + nameField.setId("vdbNameField"); + typeCombo.setId("vdbTypeCombo"); + statusLabel.setId("vdbStatusLabel"); + nameField.setPromptText(Messages.get("connection.name.prompt")); + typeCombo.getItems().setAll(VdbType.values()); + typeCombo.setMaxWidth(Double.MAX_VALUE); + typeCombo.setCellFactory(view -> typeCell()); + typeCombo.setButtonCell(typeCell()); + form.setProfile(profile, secretRemembered); + nameField.setText(profile != null ? profile.getName() : null); + typeCombo.setValue(form.getType()); + typeCombo.valueProperty().addListener((obs, oldType, type) -> form.setType(type)); + + GridPane grid = new GridPane(); + grid.setHgap(10); + grid.setVgap(10); + ColumnConstraints labelColumn = new ColumnConstraints(); + labelColumn.setMinWidth(LABEL_WIDTH); + ColumnConstraints fieldColumn = new ColumnConstraints(); + fieldColumn.setHgrow(Priority.ALWAYS); + grid.getColumnConstraints().addAll(labelColumn, fieldColumn); + grid.addRow(0, new Label(Messages.get("connection.name")), nameField); + grid.addRow(1, new Label(Messages.get("vdb.connection.type")), typeCombo); + + Button testButton = new Button(Messages.get("connection.test"), Icons.of(Icons.CONNECT)); + testButton.setId("vdbTestButton"); + testButton.setOnAction(event -> { + showStatus("status-pending", Messages.get("connection.connecting")); + form.test(nameField.getText(), message -> showStatus("status-success", message), + error -> showStatus("status-error", error)); + }); + statusLabel.setWrapText(true); + HBox testBox = new HBox(10, testButton, statusLabel); + VBox content = new VBox(10, grid, form, testBox); + content.setPadding(new Insets(4)); + content.setPrefWidth(560); + getDialogPane().setContent(content); + getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + Button okButton = (Button) getDialogPane().lookupButton(ButtonType.OK); + okButton.addEventFilter(ActionEvent.ACTION, event -> { + String error = form.validate(); + if (error != null) { + showStatus("status-error", error); + event.consume(); + } + }); + setResultConverter(button -> button == ButtonType.OK ? read() : null); + } + + static ListCell typeCell() { + return new ListCell<>() { + @Override + protected void updateItem(VdbType type, boolean empty) { + super.updateItem(type, empty); + setText(empty || type == null ? null : type.getDisplayName()); + setGraphic(empty || type == null ? null : DbIcons.of(type.getDisplayName())); + } + }; + } + + private void showStatus(String styleClass, String text) { + statusLabel.getStyleClass().setAll("label", styleClass); + statusLabel.setText(text); + } + + public boolean isSecretRemembered() { + return form.isSecretRemembered(); + } + + VdbProfile read() { + return form.read(nameField.getText()); + } +} diff --git a/src/main/java/com/github/sqljam/face/vdb/VdbConnectionForm.java b/src/main/java/com/github/sqljam/face/vdb/VdbConnectionForm.java new file mode 100644 index 0000000..1e0091e --- /dev/null +++ b/src/main/java/com/github/sqljam/face/vdb/VdbConnectionForm.java @@ -0,0 +1,282 @@ +/* + * 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.vdb; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.view.Messages; +import com.github.sqljam.face.view.TaskRunner; +import com.github.sqljam.vdb.VdbClient; +import com.github.sqljam.vdb.VdbType; +import javafx.scene.Node; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +/** + * @Description: VdbConnectionForm is the fields of a vector database below its name and type: the url of the REST + * API, the authentication which the type supports (none, an API key, or a user and a password) with + * the fields of that authentication only, the database (Milvus, Chroma) and the tenant (Chroma) for the + * types which have them, a self-signed certificate and whether the credentials are saved. The url of + * the default port follows the type until it is edited. Used by the data source form of the login page + * and of New Data Source, and by VdbConnectionDialog. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbConnectionForm extends GridPane { + + /** + * Authentication of a vector database, a type supports some of them + */ + public enum Authentication { + NONE, API_KEY, USER_PASSWORD + } + + private final TextField urlField = new TextField(); + private final ComboBox authenticationCombo = new ComboBox<>(); + private final PasswordField apiKeyField = new PasswordField(); + private final TextField usernameField = new TextField(); + private final PasswordField passwordField = new PasswordField(); + private final TextField databaseField = new TextField(); + private final TextField tenantField = new TextField(); + private final CheckBox trustCheck = new CheckBox(Messages.get("vdb.connection.trust")); + private final CheckBox rememberCheck = new CheckBox(Messages.get("vdb.connection.remember")); + private final Label apiKeyLabel = new Label(Messages.get("vdb.connection.apiKey")); + private final Label usernameLabel = new Label(Messages.get("connection.username")); + private final Label passwordLabel = new Label(Messages.get("connection.password")); + private final Label databaseLabel = new Label(Messages.get("connection.database")); + private final Label tenantLabel = new Label(Messages.get("vdb.connection.tenant")); + private VdbProfile profile = new VdbProfile(); + private VdbType type; + + /** + * @param labelWidth width of the labels, the same as the labels of the form around it + */ + public VdbConnectionForm(double labelWidth) { + setHgap(10); + setVgap(10); + urlField.setId("vdbUrlField"); + authenticationCombo.setId("vdbAuthenticationCombo"); + apiKeyField.setId("vdbApiKeyField"); + usernameField.setId("vdbUsernameField"); + passwordField.setId("vdbPasswordField"); + databaseField.setId("vdbDatabaseField"); + tenantField.setId("vdbTenantField"); + trustCheck.setId("vdbTrustCheck"); + rememberCheck.setId("vdbRememberCheck"); + urlField.setPromptText("http://localhost:6333"); + apiKeyField.setPromptText(Messages.get("vdb.connection.apiKey.prompt")); + databaseField.setPromptText(Messages.get("vdb.connection.database.prompt")); + tenantField.setPromptText("default_tenant"); + authenticationCombo.setMaxWidth(Double.MAX_VALUE); + authenticationCombo.setCellFactory(view -> authenticationCell()); + authenticationCombo.setButtonCell(authenticationCell()); + authenticationCombo.valueProperty().addListener((obs, oldValue, value) -> showAuthentication(value)); + ColumnConstraints labelColumn = new ColumnConstraints(); + labelColumn.setMinWidth(labelWidth); + ColumnConstraints fieldColumn = new ColumnConstraints(); + fieldColumn.setHgrow(Priority.ALWAYS); + getColumnConstraints().addAll(labelColumn, fieldColumn); + addRow(0, new Label(Messages.get("vdb.connection.url")), urlField); + addRow(1, new Label(Messages.get("vdb.connection.authentication")), authenticationCombo); + addRow(2, apiKeyLabel, apiKeyField); + addRow(3, usernameLabel, usernameField); + addRow(4, passwordLabel, passwordField); + addRow(5, databaseLabel, databaseField); + addRow(6, tenantLabel, tenantField); + add(trustCheck, 1, 7); + add(rememberCheck, 1, 8); + rememberCheck.setSelected(true); + setType(VdbType.QDRANT); + } + + private static ListCell authenticationCell() { + return new ListCell<>() { + @Override + protected void updateItem(Authentication item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : Messages.get("vdb.authentication." + item.name() + .toLowerCase(Locale.ENGLISH))); + } + }; + } + + private static void show(boolean visible, Node... nodes) { + for (Node node : nodes) { + node.setVisible(visible); + node.setManaged(visible); + } + } + + private void showAuthentication(Authentication authentication) { + show(authentication == Authentication.API_KEY, apiKeyLabel, apiKeyField); + show(authentication == Authentication.USER_PASSWORD, usernameLabel, usernameField, passwordLabel, + passwordField); + show(authentication != null && authentication != Authentication.NONE, rememberCheck); + } + + /** + * Authentications of a type: none, and the ones which the type supports + */ + static List getAuthentications(VdbType type) { + List authentications = new ArrayList<>(); + authentications.add(Authentication.NONE); + if (type != null && type.isApiKeySupported()) { + authentications.add(Authentication.API_KEY); + } + if (type != null && type.isUserSupported()) { + authentications.add(Authentication.USER_PASSWORD); + } + return authentications; + } + + /** + * Type of the vector database: the url of the old default port is replaced, the authentications, the database + * and the tenant follow the type + */ + public void setType(VdbType newType) { + VdbType oldType = type; + type = newType; + if (newType != null && (StringUtils.isBlank(urlField.getText()) || (oldType != null && urlField.getText() + .equals(oldType.getUrl("localhost", 0))))) { + urlField.setText(newType.getUrl("localhost", 0)); + } + Authentication selected = authenticationCombo.getValue(); + List authentications = getAuthentications(newType); + authenticationCombo.getItems().setAll(authentications); + // The authentication is kept if the type has it, otherwise the first one with credentials + authenticationCombo.setValue(selected != null && authentications.contains(selected) ? selected + : authentications.get(authentications.size() > 1 ? 1 : 0)); + show(newType != null && newType.isDatabaseSupported(), databaseLabel, databaseField); + show(newType != null && newType.isTenantSupported(), tenantLabel, tenantField); + } + + public VdbType getType() { + return type; + } + + public Authentication getAuthentication() { + return authenticationCombo.getValue(); + } + + public void setAuthentication(Authentication authentication) { + authenticationCombo.setValue(authentication); + } + + /** + * Shows a vector database, a new one if it is null + */ + public void setProfile(VdbProfile profile, boolean secretRemembered) { + this.profile = profile != null ? profile.copy() : new VdbProfile(); + urlField.setText(this.profile.getUrl()); + authenticationCombo.setValue(null); + setType(this.profile.getType() != null ? this.profile.getType() : VdbType.QDRANT); + apiKeyField.setText(this.profile.getApiKey()); + usernameField.setText(this.profile.getUsername()); + passwordField.setText(this.profile.getPassword()); + databaseField.setText(this.profile.getDatabase()); + tenantField.setText(this.profile.getTenant()); + trustCheck.setSelected(this.profile.isCertificateTrusted()); + rememberCheck.setSelected(secretRemembered); + if (profile != null) { + // A saved database shows the authentication of its credentials, without them it has none + authenticationCombo.setValue(StringUtils.isNotBlank(this.profile.getUsername()) + && type.isUserSupported() ? Authentication.USER_PASSWORD : this.profile.getApiKey() != null + || (!secretRemembered && type.isApiKeySupported()) ? Authentication.API_KEY + : Authentication.NONE); + } + } + + public boolean isSecretRemembered() { + return rememberCheck.isSelected(); + } + + /** + * Error of the fields, null if they are valid + */ + public String validate() { + if (type == null) { + return Messages.get("connection.error.dbType"); + } + String url = StringUtils.trimToEmpty(urlField.getText()); + if (!url.startsWith("http://") && !url.startsWith("https://")) { + return Messages.get("vdb.connection.error.url"); + } + if (getAuthentication() == Authentication.API_KEY && StringUtils.isBlank(apiKeyField.getText())) { + return Messages.get("vdb.connection.error.apiKey"); + } + if (getAuthentication() == Authentication.USER_PASSWORD && StringUtils.isBlank(usernameField.getText())) { + return Messages.get("vdb.connection.error.username"); + } + return null; + } + + /** + * The vector database of the fields, named by the type and the url when the name is blank. Only the + * credentials of the authentication are kept, fields of the type only. + */ + public VdbProfile read(String name) { + Authentication authentication = getAuthentication(); + profile.setType(type); + profile.setUrl(StringUtils.removeEnd(StringUtils.trimToEmpty(urlField.getText()), "/")); + profile.setName(StringUtils.defaultIfBlank(StringUtils.trimToNull(name), type.getDisplayName() + " " + + StringUtils.substringAfter(profile.getUrl(), "://"))); + profile.setApiKey(authentication == Authentication.API_KEY ? StringUtils.trimToNull(apiKeyField.getText()) + : null); + boolean user = authentication == Authentication.USER_PASSWORD; + profile.setUsername(user ? StringUtils.trimToNull(usernameField.getText()) : null); + profile.setPassword(user && !StringUtils.isEmpty(passwordField.getText()) ? passwordField.getText() : null); + profile.setDatabase(type.isDatabaseSupported() ? StringUtils.trimToNull(databaseField.getText()) : null); + profile.setTenant(type.isTenantSupported() ? StringUtils.trimToNull(tenantField.getText()) : null); + profile.setCertificateTrusted(trustCheck.isSelected()); + return profile.copy(); + } + + /** + * Connects in the background and lists the collections + */ + public void test(String name, Consumer onSuccess, Consumer onError) { + String error = validate(); + if (error != null) { + onError.accept(error); + return; + } + VdbProfile tested = read(name); + TaskRunner.run(() -> { + try (VdbClient client = tested.getType().createClient(tested.toSource())) { + String version = client.getVersion(); + int collections = client.getCollections().size(); + return Messages.format("vdb.connection.success", tested.getType().getDisplayName(), + StringUtils.defaultIfBlank(version, "?"), collections); + } + }, onSuccess, e -> onError.accept(Messages.format("connection.failure", e.getMessage()))); + } + + public void focusFirstField() { + urlField.requestFocus(); + } +} diff --git a/src/main/java/com/github/sqljam/face/vdb/VdbDataSourceExtension.java b/src/main/java/com/github/sqljam/face/vdb/VdbDataSourceExtension.java new file mode 100644 index 0000000..2a41095 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/vdb/VdbDataSourceExtension.java @@ -0,0 +1,493 @@ +/* + * 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.vdb; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +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.SyncTask; +import com.github.sqljam.face.service.SyncEngine; +import com.github.sqljam.face.view.AppContext; +import com.github.sqljam.face.view.DataSourceExtension; +import com.github.sqljam.face.view.Dialogs; +import com.github.sqljam.face.view.Messages; +import com.github.sqljam.face.view.SourceItem; +import com.github.sqljam.face.view.CredentialsDialog; +import com.github.sqljam.utils.RestException; +import com.github.sqljam.vdb.VdbClient; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VdbType; +import javafx.scene.Node; +import javafx.scene.control.Tab; +import javafx.stage.Window; +import lombok.extern.slf4j.Slf4j; + +/** + * @Description: VdbDataSourceExtension makes the vector databases a category of data sources: the data source + * form offers it next to the relational and the OLAP databases with the vector databases as its types + * and VdbConnectionForm as its fields, the login page and the connection tree list the vector databases + * in a group of their own, which opens the import of a vector database. It is switched by + * sqljam.vdb.enabled like the menu item. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Slf4j +public class VdbDataSourceExtension implements DataSourceExtension { + + /** + * A vector database of the store + */ + static final class VdbSourceItem implements Source { + + private final VdbProfile profile; + private final VdbProfileStore store; + + VdbSourceItem(VdbProfile profile, VdbProfileStore store) { + this.profile = profile; + this.store = store; + } + + @Override + public String getId() { + return profile.getId(); + } + + @Override + public String getName() { + return profile.getName(); + } + + @Override + public String getTypeName() { + return profile.getType().getDisplayName(); + } + + @Override + public void open(Window owner, AppContext context) { + new VdbImportDialog(owner, context, store, profile).showAndImport(); + } + + /** + * Levels of the source: databases (Milvus, Chroma), collections, then their partitions (partitions of + * Milvus, tenants of Weaviate, namespaces of Pinecone) + */ + @Override + public List loadItems(SourceItem parent) throws Exception { + VdbType type = profile.getType(); + List items = new ArrayList<>(); + if (parent == null && type.isDatabaseSupported()) { + List databases = listDatabases(); + for (String database : databases) { + items.add(new SourceItem(SourceItem.Kind.DATABASE, List.of(database), Messages.get( + "connection.database"), false)); + } + if (!databases.isEmpty()) { + return items; + } + // Without databases to list, the collections of the database of the source + } + if (parent == null || parent.getKind() == SourceItem.Kind.DATABASE) { + String database = parent != null ? parent.getName() : null; + boolean partitioned = type.getPartitionName() != null; + try (VdbClient client = type.createClient(profileOf(database).toSource())) { + for (String collection : client.getCollections()) { + SourceItem item = parent != null ? parent.child(SourceItem.Kind.COLLECTION, collection, + Messages.get("vdb.collection"), !partitioned) : new SourceItem( + SourceItem.Kind.COLLECTION, List.of(collection), Messages.get("vdb.collection"), + !partitioned); + items.add(item); + } + } + return items; + } + if (parent.getKind() == SourceItem.Kind.COLLECTION) { + try (VdbClient client = type.createClient(profileOf(databaseOf(parent)).toSource())) { + for (String partition : listPartitions(client, parent.getName())) { + items.add(parent.child(SourceItem.Kind.PARTITION, partition, type.getPartitionName(), true)); + } + } + } + return items; + } + + /** + * Databases of the server. A user who may not list them (Milvus) or a server without the route (older + * Chroma) sees the database of the source, or the collections of the default one when it names none. + */ + List listDatabases() { + try (VdbClient client = profile.getType().createClient(profile.toSource())) { + return client.getDatabases(); + } catch (RuntimeException e) { + if (log.isInfoEnabled()) { + log.info("Databases of {} are not listed: {}", profile.getName(), e.getMessage()); + } + String database = StringUtils.trimToNull(profile.getDatabase()); + return database != null ? List.of(database) : List.of(); + } + } + + /** + * Partitions of the collection, none when the server does not list them, the collection is read whole + */ + List listPartitions(VdbClient client, String collection) { + try { + return client.listPartitions(collection); + } catch (RuntimeException e) { + if (log.isInfoEnabled()) { + log.info("Partitions of {} are not listed: {}", collection, e.getMessage()); + } + return List.of(); + } + } + + /** + * A key or a password which the database refused (HTTP 401 or 403, Milvus 1800), e.g. one which is not + * remembered, is asked again. The key is asked of a database which has keys, the user and password of the + * others. + */ + @Override + public boolean authenticate(Window owner, Throwable error) { + if (!isAccessDenied(error)) { + return false; + } + VdbType type = profile.getType(); + boolean key = type.isApiKeySupported() && (!type.isUserSupported() || StringUtils.isEmpty(profile + .getUsername())); + Optional credentials = CredentialsDialog.ask(owner, profile.getName(), + Messages.get(key ? "credentials.apiKey" : "connection.password"), key ? null : StringUtils + .defaultString(profile.getUsername()), StringUtils.abbreviate(error.getMessage(), 300)); + credentials.ifPresent(entered -> { + if (key) { + profile.setApiKey(entered.getSecret()); + } else { + profile.setUsername(entered.getUsername()); + profile.setPassword(entered.getSecret()); + } + try { + store.saveProfile(profile, entered.isSaved()); + } catch (IOException e) { + Dialogs.showError(owner, Messages.get("connection.saveError"), e); + } + }); + return credentials.isPresent(); + } + + static boolean isAccessDenied(Throwable error) { + for (Throwable cause = error; cause != null && cause.getCause() != cause; cause = cause.getCause()) { + if (cause instanceof RestException && (((RestException) cause).getStatus() == 401 + || ((RestException) cause).getStatus() == 403)) { + return true; + } + } + return false; + } + + @Override + public Map> loadOverview() { + try (VdbClient client = profile.getType().createClient(profile.toSource())) { + return client.getOverview(); + } + } + + @Override + public Map> loadHealth() { + try (VdbClient client = profile.getType().createClient(profile.toSource())) { + return client.getHealth(); + } + } + + /** + * The source of a database of the server, the source itself for null + */ + VdbProfile profileOf(String database) { + if (database == null) { + return profile; + } + VdbProfile copy = profile.copy(); + copy.setDatabase(database); + return copy; + } + + String databaseOf(SourceItem item) { + return profile.getType().isDatabaseSupported() ? item.getPath().get(0) : null; + } + + String collectionOf(SourceItem item) { + return item.getPath().get(profile.getType().isDatabaseSupported() ? 1 : 0); + } + + @Override + public Tab openItem(SourceItem item, Window owner, AppContext context) { + String database = databaseOf(item); + VdbCollectionTab tab = new VdbCollectionTab(profileOf(database), collectionOf(item), + item.getKind() == SourceItem.Kind.PARTITION ? List.of(item.getName()) : List.of()); + tab.setOnImport((collection, query) -> new VdbImportDialog(owner, context, store, profile, database, + List.of(collection), query).showAndImport()); + return tab; + } + + /** + * Import of the collections of the items, of their partitions when the items are partitions + */ + @Override + public void open(List items, Window owner, AppContext context) { + String database = items.isEmpty() ? null : databaseOf(items.get(0)); + List collections = new ArrayList<>(); + VdbQuery query = new VdbQuery(); + for (SourceItem item : items) { + if (item.getKind() == SourceItem.Kind.DATABASE) { + continue; + } + String collection = collectionOf(item); + if (!collections.contains(collection)) { + collections.add(collection); + } + if (item.getKind() == SourceItem.Kind.PARTITION) { + query.getPartitions().add(item.getName()); + } + } + new VdbImportDialog(owner, context, store, profile, database, collections, query).showAndImport(); + } + + @Override + public boolean edit(Window owner, AppContext context) { + return VdbDataSourceExtension.edit(owner, store, profile) != null; + } + + @Override + public boolean delete(Window owner, AppContext context) { + if (!Dialogs.confirm(owner, Messages.get("vdb.connection.delete"), Messages.format( + "connection.delete.message", profile.getName()))) { + return false; + } + try { + store.removeProfile(profile.getId()); + return true; + } catch (IOException e) { + Dialogs.showError(owner, Messages.get("connection.save.error"), e); + return false; + } + } + } + + /** + * Fields of a vector database of the data source form + */ + final class VdbSourceForm implements SourceForm { + + private final VdbConnectionForm form; + private VdbProfile profile; + + VdbSourceForm(double labelWidth) { + form = new VdbConnectionForm(labelWidth); + } + + @Override + public Node getNode() { + return form; + } + + @Override + public List getTypeNames() { + List names = new ArrayList<>(); + for (VdbType type : VdbType.values()) { + names.add(type.getDisplayName()); + } + return names; + } + + @Override + public void setType(String typeName) { + for (VdbType type : VdbType.values()) { + if (type.getDisplayName().equals(typeName)) { + form.setType(type); + } + } + } + + @Override + public String getType() { + return form.getType() != null ? form.getType().getDisplayName() : null; + } + + @Override + public void setSource(Source source) { + profile = source instanceof VdbSourceItem ? ((VdbSourceItem) source).profile : null; + form.setProfile(profile, profile == null || getStore().isSecretRemembered(profile.getId())); + } + + @Override + public String validate() { + return form.validate(); + } + + @Override + public Source save(String name) throws IOException { + VdbProfile saved = form.read(name); + getStore().saveProfile(saved, form.isSecretRemembered()); + profile = saved; + return new VdbSourceItem(saved, getStore()); + } + + @Override + public void test(String name, Consumer onSuccess, Consumer onError) { + form.test(name, onSuccess, onError); + } + + @Override + public void focusFirstField() { + form.focusFirstField(); + } + } + + private final VdbProfileStore store; + + public VdbDataSourceExtension() { + this(null); + } + + /** + * @param store store of the vector databases, null for the store of the application + */ + VdbDataSourceExtension(VdbProfileStore store) { + this.store = store; + } + + private VdbProfileStore getStore() { + return store != null ? store : VdbProfileStore.getInstance(); + } + + @Override + public SyncSupport getSyncSupport() { + return new SyncSupport() { + @Override + public SyncTask.Kind getKind() { + return SyncTask.Kind.VECTOR; + } + + @Override + public SyncEngine createEngine(Function> profiles) { + return new VdbSyncEngine(getStore(), profiles); + } + + private VdbProfile profile(String sourceId, String database) { + VdbProfile profile = getStore().getProfiles().stream().filter(item -> item.getId().equals(sourceId)) + .findFirst().orElseThrow(() -> new IllegalArgumentException("No vector database " + sourceId)); + if (StringUtils.isNotBlank(database)) { + profile = profile.copy(); + profile.setDatabase(database); + } + return profile; + } + + @Override + public List getDatabases(String sourceId) { + VdbProfile profile = profile(sourceId, null); + try (VdbClient client = profile.getType().createClient(profile.toSource())) { + return client.getDatabases(); + } + } + + @Override + public List getCollections(String sourceId, String database) { + VdbProfile profile = profile(sourceId, database); + try (VdbClient client = profile.getType().createClient(profile.toSource())) { + return client.getCollections(); + } + } + + @Override + public List getWatermarkFields(String sourceId, String database, String collection) { + VdbProfile profile = profile(sourceId, database); + try (VdbClient client = profile.getType().createClient(profile.toSource())) { + return client.describe(collection).getFields().stream().filter(field -> !field.isPrimaryKey() + && WATERMARK_KINDS.contains(field.getKind())).map(VdbField::getName).collect(Collectors + .toList()); + } + } + }; + } + + /** + * Kinds of the fields which a sync task compares: numbers and times + */ + static final Set WATERMARK_KINDS = EnumSet.of(VdbField.Kind.SMALLINT, VdbField.Kind.INTEGER, + VdbField.Kind.BIGINT, VdbField.Kind.REAL, VdbField.Kind.DOUBLE, VdbField.Kind.TIMESTAMP); + + @Override + public SourceForm createForm(double labelWidth) { + return new VdbSourceForm(labelWidth); + } + + @Override + public String getGroupName() { + return Messages.get("vdb.group"); + } + + @Override + public String getOpenItemText() { + return Messages.get("vdb.openItem"); + } + + @Override + public String getOpenText() { + return Messages.get("vdb.open"); + } + + @Override + public boolean isEnabled(Config config) { + return config.getBoolean(VdbMenuExtension.ENABLED_KEY, true); + } + + @Override + public List getSources() { + List sources = new ArrayList<>(); + getStore().getProfiles().forEach(profile -> sources.add(new VdbSourceItem(profile, getStore()))); + return sources; + } + + /** + * Creates or edits a vector database, the saved one or null if it is cancelled + */ + static VdbProfile edit(Window owner, VdbProfileStore store, VdbProfile profile) { + VdbConnectionDialog dialog = new VdbConnectionDialog(owner, profile, profile == null + || store.isSecretRemembered(profile.getId())); + VdbProfile saved = dialog.showAndWait().orElse(null); + if (saved == null) { + return null; + } + try { + store.saveProfile(saved, dialog.isSecretRemembered()); + return saved; + } catch (IOException e) { + Dialogs.showError(owner, Messages.get("connection.save.error"), e); + return null; + } + } +} diff --git a/src/main/java/com/github/sqljam/face/vdb/VdbImportDialog.java b/src/main/java/com/github/sqljam/face/vdb/VdbImportDialog.java new file mode 100644 index 0000000..912d051 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/vdb/VdbImportDialog.java @@ -0,0 +1,752 @@ +/* + * 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.vdb; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.service.TransferService; +import com.github.sqljam.face.view.AppContext; +import com.github.sqljam.face.view.Branding; +import com.github.sqljam.face.view.ColumnMappingDialog; +import com.github.sqljam.face.view.ConnectionDialog; +import com.github.sqljam.face.view.DbIcons; +import com.github.sqljam.face.view.DbTypeCells; +import com.github.sqljam.face.view.Dialogs; +import com.github.sqljam.face.view.Icons; +import com.github.sqljam.face.view.Messages; +import com.github.sqljam.face.view.ProgressDialog; +import com.github.sqljam.face.view.TaskRunner; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.ExportListener; +import com.github.sqljam.vdb.VdbClient; +import com.github.sqljam.vdb.VdbCollectionMetaData; +import com.github.sqljam.vdb.VdbExportConfiguration; +import com.github.sqljam.vdb.VdbExportListener; +import com.github.sqljam.vdb.VdbExportResult; +import com.github.sqljam.vdb.VdbExporter; +import com.github.sqljam.vdb.VdbFieldMapping; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.dialect.VdbDialect; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.event.ActionEvent; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Dialog; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; +import javafx.scene.control.ToggleGroup; +import javafx.scene.control.Tooltip; +import javafx.scene.control.cell.CheckBoxTableCell; +import javafx.scene.control.cell.TextFieldTableCell; +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.Window; + +/** + * @Description: VdbImportDialog imports collections of a vector database into a data source with vectors, a wizard + * like the export of tables: the source (the vector database, its database when it has some, the + * collections and their tables, the records of a query, partitions, tenants or namespaces), the options + * and the target. Only data sources with vectors are targets. The import runs in the progress dialog + * of the imports of the data sources. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbImportDialog extends Dialog { + + /** + * Types of data sources which may have vectors + */ + static final Set TARGET_TYPES = EnumSet.of(DbType.POSTGRESQL, DbType.MYSQL, DbType.MARIADB, + DbType.ORACLE, DbType.SQLSERVER, DbType.DB2, DbType.DUCKDB, DbType.CLICKHOUSE); + /** + * Whether data sources have vectors, by their probe keys, for the session of the application + */ + static final Map VECTOR_TARGETS = new ConcurrentHashMap<>(); + + /** + * A collection of the list: whether it is imported and the name of its table + */ + static final class CollectionItem { + final String name; + final SimpleBooleanProperty selected = new SimpleBooleanProperty(); + final SimpleStringProperty tableName; + + CollectionItem(String name) { + this.name = name; + this.tableName = new SimpleStringProperty(VdbCollectionMetaData.getDefaultTableName(name)); + } + } + + private final AppContext context; + private final VdbProfileStore profileStore; + private final VdbQuery query; + private final List preselected; + private final ComboBox sourceCombo = new ComboBox<>(); + private final ComboBox databaseCombo = new ComboBox<>(); + private final Label databaseLabel = new Label(Messages.get("connection.database")); + private final TableView collectionTable = new TableView<>(); + private final CheckBox allCheck = new CheckBox(Messages.get("export.tables.all")); + /** + * Fields which the user mapped by collection, none imports the collections as they are + */ + private final Button mapColumnsButton = new Button(Messages.get("mapping.button"), Icons.of(Icons.COLUMNS)); + private final Label mappingLabel = new Label(); + private Map> fieldMappings = new HashMap<>(); + private final Label queryLabel = new Label(); + private final ComboBox targetCombo = new ComboBox<>(); + private final ComboBox schemaCombo = new ComboBox<>(); + private final RadioButton recreateRadio = new RadioButton(Messages.get("vdb.mode.recreate")); + private final RadioButton appendRadio = new RadioButton(Messages.get("vdb.mode.append")); + private final RadioButton upsertRadio = new RadioButton(Messages.get("vdb.mode.upsert")); + private final CheckBox indexCheck = new CheckBox(Messages.get("vdb.option.index")); + private final CheckBox skipErrorsCheck = new CheckBox(Messages.get("vdb.option.skipErrors")); + private final Label statusLabel = new Label(); + private final Button startButton; + private VdbDialect targetDialect; + private int targetsVersion; + private String preselectedDatabase; + + public VdbImportDialog(Window owner, AppContext context, VdbProfileStore profileStore) { + this(owner, context, profileStore, null, null, List.of(), null); + } + + public VdbImportDialog(Window owner, AppContext context, VdbProfileStore profileStore, VdbProfile selected) { + this(owner, context, profileStore, selected, null, List.of(), null); + } + + /** + * @param selected vector database which is chosen first, null for the first one + * @param database database of the vector database (Milvus, Chroma), null for its default one + * @param collections collections which are checked + * @param query records of the collections which are imported, all records if it is null + */ + public VdbImportDialog(Window owner, AppContext context, VdbProfileStore profileStore, VdbProfile selected, + String database, List collections, VdbQuery query) { + this.context = context; + this.profileStore = profileStore; + this.query = query; + this.preselected = collections; + this.preselectedDatabase = database; + Dialogs.initOwner(this, owner); + Branding.applyIcons(this); + setTitle(Messages.get("vdb.title")); + setHeaderText(Messages.get("vdb.header")); + setResizable(true); + sourceCombo.setId("vdbSourceCombo"); + databaseCombo.setId("vdbDatabaseCombo"); + collectionTable.setId("vdbCollections"); + allCheck.setId("vdbAllCheck"); + queryLabel.setId("vdbQueryLabel"); + targetCombo.setId("vdbTargetCombo"); + schemaCombo.setId("vdbSchemaCombo"); + recreateRadio.setId("vdbRecreateRadio"); + appendRadio.setId("vdbAppendRadio"); + upsertRadio.setId("vdbUpsertRadio"); + indexCheck.setId("vdbIndexCheck"); + skipErrorsCheck.setId("vdbSkipErrorsCheck"); + statusLabel.setId("vdbStatus"); + + VBox content = new VBox(16, section("export.section.source", createSourcePane()), + section("export.section.options", createOptionsPane()), + section("export.section.target", createTargetPane())); + content.setPadding(new Insets(4, 8, 4, 4)); + ScrollPane scrollPane = new ScrollPane(content); + scrollPane.setFitToWidth(true); + scrollPane.setPrefSize(720, 600); + statusLabel.setWrapText(true); + VBox root = new VBox(8, scrollPane, statusLabel); + VBox.setVgrow(scrollPane, Priority.ALWAYS); + getDialogPane().setContent(root); + ButtonType showSql = new ButtonType(Messages.get("vdb.showSql"), ButtonBar.ButtonData.LEFT); + getDialogPane().getButtonTypes().addAll(showSql, ButtonType.OK, ButtonType.CANCEL); + Button sqlButton = (Button) getDialogPane().lookupButton(showSql); + sqlButton.setId("vdbShowSqlButton"); + sqlButton.addEventFilter(ActionEvent.ACTION, event -> { + event.consume(); + showScripts(); + }); + startButton = (Button) getDialogPane().lookupButton(ButtonType.OK); + startButton.setId("vdbStartButton"); + startButton.setText(Messages.get("import.start")); + startButton.setGraphic(Icons.of(Icons.START)); + startButton.addEventFilter(ActionEvent.ACTION, event -> { + String error = validate(); + if (error != null) { + showStatus("status-error", error); + event.consume(); + } else if (recreateRadio.isSelected() && !Dialogs.confirm(getOwnerWindow(), Messages.get("vdb.title"), + Messages.format("vdb.recreate.confirm", getSelectedCollections().size()))) { + event.consume(); + } + }); + setResultConverter(button -> button == ButtonType.OK); + + reloadSources(selected); + reloadTargets(null); + updateStart(); + } + + private static Node section(String key, Node body) { + Label title = new Label(Messages.get(key)); + title.getStyleClass().add("section-title"); + VBox box = new VBox(8, title, body); + box.getStyleClass().add("form-section"); + return box; + } + + 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 Window getOwnerWindow() { + return getDialogPane().getScene() != null ? getDialogPane().getScene().getWindow() : null; + } + + // ------------------------------------------------------------------ + // Source + // ------------------------------------------------------------------ + + private Node createSourcePane() { + sourceCombo.setMaxWidth(Double.MAX_VALUE); + sourceCombo.setCellFactory(view -> profileCell()); + sourceCombo.setButtonCell(profileCell()); + sourceCombo.valueProperty().addListener((obs, oldProfile, profile) -> loadDatabases(profile)); + databaseCombo.setMaxWidth(Double.MAX_VALUE); + databaseCombo.valueProperty().addListener((obs, oldDatabase, database) -> loadCollections()); + + TableColumn selectColumn = new TableColumn<>(); + selectColumn.setCellValueFactory(data -> data.getValue().selected); + selectColumn.setCellFactory(CheckBoxTableCell.forTableColumn(selectColumn)); + selectColumn.setEditable(true); + selectColumn.setPrefWidth(40); + TableColumn nameColumn = new TableColumn<>(Messages.get("vdb.collection")); + nameColumn.setCellValueFactory(data -> new ReadOnlyObjectWrapper<>(data.getValue().name)); + nameColumn.setPrefWidth(280); + TableColumn tableColumn = new TableColumn<>(Messages.get("vdb.table")); + tableColumn.setCellValueFactory(data -> data.getValue().tableName); + tableColumn.setCellFactory(TextFieldTableCell.forTableColumn()); + tableColumn.setEditable(true); + tableColumn.setPrefWidth(280); + collectionTable.getColumns().setAll(List.of(selectColumn, nameColumn, tableColumn)); + collectionTable.setEditable(true); + collectionTable.setPrefHeight(220); + collectionTable.setPlaceholder(new Label(Messages.get("vdb.collections.empty"))); + allCheck.setOnAction(event -> collectionTable.getItems().forEach(item -> item.selected.set(allCheck + .isSelected()))); + + queryLabel.getStyleClass().add("muted"); + queryLabel.setWrapText(true); + queryLabel.setText(describeQuery()); + GridPane grid = grid(); + grid.addRow(0, new Label(Messages.get("vdb.connection")), sourceCombo); + grid.addRow(1, databaseLabel, databaseCombo); + grid.addRow(2, new Label(Messages.get("vdb.rows")), queryLabel); + mapColumnsButton.setId("vdbMapColumnsButton"); + mapColumnsButton.setTooltip(new Tooltip(Messages.get("mapping.button.tooltip"))); + mapColumnsButton.setOnAction(event -> mapColumns()); + mappingLabel.setId("vdbMappingLabel"); + mappingLabel.getStyleClass().add("muted"); + updateMappingLabel(); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + HBox collectionBar = new HBox(10, allCheck, spacer, mappingLabel, mapColumnsButton); + collectionBar.setAlignment(Pos.CENTER_LEFT); + return new VBox(8, grid, collectionBar, collectionTable); + } + + /** + * Records of the query: all, or its conditions, filter, search and partitions + */ + String describeQuery() { + if (query == null || (!query.isFiltered() && !query.isNearest() && query.getPartitions().isEmpty() + && query.getLimit() <= 0)) { + return Messages.get("vdb.rows.all"); + } + List parts = new ArrayList<>(); + if (query.isNative()) { + parts.add(query.getNativeFilter()); + } + query.getConditions().forEach(condition -> parts.add(condition.getField() + " " + condition.getOperator() + + " " + condition.getValue())); + if (query.isNearest()) { + parts.add(Messages.format("vdb.rows.nearest", query.getLimit(), query.getNearestField())); + } + if (!query.getPartitions().isEmpty()) { + parts.add(String.join(", ", query.getPartitions())); + } + return String.join(", ", parts); + } + + private static ListCell profileCell() { + return new ListCell<>() { + @Override + protected void updateItem(VdbProfile profile, boolean empty) { + super.updateItem(profile, empty); + setText(empty || profile == null ? null : profile.getName()); + setGraphic(empty || profile == null ? null : DbIcons.of(profile.getType().getDisplayName())); + } + }; + } + + private void reloadSources(VdbProfile selected) { + sourceCombo.getItems().setAll(profileStore.getProfiles()); + if (selected != null) { + sourceCombo.getItems().stream().filter(selected::equals).findFirst().ifPresent(sourceCombo::setValue); + } else if (!sourceCombo.getItems().isEmpty()) { + sourceCombo.setValue(sourceCombo.getItems().get(0)); + } + } + + /** + * Databases of the vector database (Milvus, Chroma), the collections of the others + */ + private void loadDatabases(VdbProfile profile) { + databaseCombo.getItems().clear(); + collectionTable.getItems().clear(); + boolean databases = profile != null && profile.getType().isDatabaseSupported(); + databaseLabel.setVisible(databases); + databaseLabel.setManaged(databases); + databaseCombo.setVisible(databases); + databaseCombo.setManaged(databases); + if (profile == null) { + return; + } + if (!databases) { + loadCollections(); + return; + } + TaskRunner.run(() -> { + try (VdbClient client = profile.getType().createClient(profile.toSource())) { + return client.getDatabases(); + } + }, names -> { + if (profile.equals(sourceCombo.getValue())) { + databaseCombo.getItems().setAll(names); + String database = preselectedDatabase != null ? preselectedDatabase : profile.getDatabase(); + preselectedDatabase = null; + databaseCombo.setValue(names.contains(database) ? database : names.isEmpty() ? null : names.get(0)); + } + }, e -> showStatus("status-error", Messages.format("connection.failure", e.getMessage()))); + } + + /** + * The vector database of the chosen database + */ + VdbProfile getSourceProfile() { + VdbProfile profile = sourceCombo.getValue(); + if (profile == null || !profile.getType().isDatabaseSupported() || databaseCombo.getValue() == null) { + return profile; + } + VdbProfile copy = profile.copy(); + copy.setDatabase(databaseCombo.getValue()); + return copy; + } + + private void loadCollections() { + collectionTable.getItems().clear(); + // Mappings belong to the collections which were shown + fieldMappings = new HashMap<>(); + updateMappingLabel(); + VdbProfile profile = getSourceProfile(); + if (profile == null) { + return; + } + showStatus("status-pending", Messages.get("connection.connecting")); + TaskRunner.run(() -> { + try (VdbClient client = profile.getType().createClient(profile.toSource())) { + return client.getCollections(); + } + }, names -> { + for (String name : names) { + CollectionItem item = new CollectionItem(name); + item.selected.set(preselected.contains(name)); + item.selected.addListener((obs, oldValue, value) -> updateStart()); + collectionTable.getItems().add(item); + } + showStatus("status-success", Messages.format("vdb.collections.loaded", names.size())); + updateStart(); + }, e -> showStatus("status-error", Messages.format("connection.failure", e.getMessage()))); + } + + List getSelectedCollections() { + List items = new ArrayList<>(); + collectionTable.getItems().stream().filter(item -> item.selected.get()).forEach(items::add); + return items; + } + + // ------------------------------------------------------------------ + // Options and target + // ------------------------------------------------------------------ + + private Node createOptionsPane() { + ToggleGroup modeGroup = new ToggleGroup(); + recreateRadio.setToggleGroup(modeGroup); + appendRadio.setToggleGroup(modeGroup); + upsertRadio.setToggleGroup(modeGroup); + recreateRadio.setSelected(true); + indexCheck.setSelected(true); + skipErrorsCheck.setSelected(true); + GridPane grid = grid(); + grid.addRow(0, new Label(Messages.get("parquet.mode")), new VBox(6, recreateRadio, appendRadio, + upsertRadio)); + grid.addRow(1, new Label(Messages.get("vdb.options")), new VBox(6, indexCheck, skipErrorsCheck)); + return grid; + } + + private Node createTargetPane() { + DbTypeCells.setupProfileCombo(targetCombo); + targetCombo.setMaxWidth(Double.MAX_VALUE); + targetCombo.valueProperty().addListener((obs, oldProfile, profile) -> checkTarget(profile)); + schemaCombo.setEditable(true); + schemaCombo.setMaxWidth(Double.MAX_VALUE); + schemaCombo.setPromptText(Messages.get("import.schema.prompt")); + GridPane grid = grid(); + grid.addRow(0, new Label(Messages.get("export.database.connection")), ConnectionDialog.targetField( + targetCombo, context, this::reloadTargets)); + grid.addRow(1, new Label(Messages.get("export.database.schema")), schemaCombo); + return grid; + } + + /** + * Lists the data sources which have vectors: data sources of the types which may have them are probed in the + * background, those with vectors are added in their order, the others are never listed. Probes are kept for the + * session of the application. + * + * @param selected data source which is selected when it is listed + */ + private void reloadTargets(ConnectionProfile selected) { + List candidates = new ArrayList<>(); + for (ConnectionProfile profile : context.getProfileRegistry().getProfiles()) { + if (profile.getDbType() != null && TARGET_TYPES.contains(profile.getDbType())) { + candidates.add(profile); + } + } + int version = ++targetsVersion; + targetCombo.getItems().clear(); + int[] pending = {0}; + for (ConnectionProfile profile : candidates) { + Boolean vectors = VECTOR_TARGETS.get(probeKey(profile)); + if (Boolean.TRUE.equals(vectors)) { + addTarget(candidates, profile, selected); + } else if (vectors == null) { + pending[0]++; + TaskRunner.run(() -> hasVectors(profile), result -> { + VECTOR_TARGETS.put(probeKey(profile), result); + if (version != targetsVersion) { + return; + } + if (result) { + addTarget(candidates, profile, selected); + } + showTargetsPrompt(--pending[0]); + }, e -> { + // A data source which is not reachable now is probed again next time + if (version == targetsVersion) { + showTargetsPrompt(--pending[0]); + } + }); + } + } + showTargetsPrompt(pending[0]); + } + + private void addTarget(List candidates, ConnectionProfile profile, ConnectionProfile selected) { + int index = 0; + for (ConnectionProfile listed : targetCombo.getItems()) { + if (candidates.indexOf(listed) > candidates.indexOf(profile)) { + break; + } + index++; + } + targetCombo.getItems().add(index, profile); + if (selected != null && selected.getId().equals(profile.getId())) { + targetCombo.setValue(profile); + } + } + + private void showTargetsPrompt(int pending) { + targetCombo.setPromptText(Messages.get(pending > 0 ? "vdb.targets.checking" : targetCombo.getItems() + .isEmpty() ? "vdb.targets.none" : "vdb.targets.choose")); + } + + /** + * Key of a probe: the data source as it connects, a probe is repeated after it is edited + */ + static String probeKey(ConnectionProfile profile) { + return profile.getId() + "|" + profile.getJdbcUrl() + "|" + profile.getUsername(); + } + + static boolean hasVectors(ConnectionProfile profile) throws SQLException { + try (Connection connection = new ProfileDataSource(profile).getConnection()) { + VdbDialect dialect = VdbDialect.of(connection); + return dialect != null && dialect.isVectorSupported(); + } + } + + private void checkTarget(ConnectionProfile profile) { + targetDialect = null; + schemaCombo.getItems().clear(); + updateStart(); + if (profile == null) { + return; + } + showStatus("status-pending", Messages.get("vdb.target.checking")); + TaskRunner.run(() -> { + try (Connection connection = new ProfileDataSource(profile).getConnection()) { + VdbDialect dialect = VdbExporter.getDialect(connection); + List schemas = new ArrayList<>(); + boolean catalogs = profile.getDbType() == DbType.MYSQL || profile.getDbType() == DbType.MARIADB + || profile.getDbType() == DbType.CLICKHOUSE; + try (ResultSet rs = catalogs ? connection.getMetaData().getCatalogs() + : connection.getMetaData().getSchemas()) { + while (rs.next()) { + schemas.add(rs.getString(1)); + } + } + String current = catalogs ? connection.getCatalog() : connection.getSchema(); + return new Object[]{dialect, schemas, current}; + } + }, result -> { + if (profile != targetCombo.getValue()) { + return; + } + targetDialect = (VdbDialect) result[0]; + @SuppressWarnings("unchecked") + List schemas = (List) result[1]; + schemaCombo.getItems().setAll(schemas); + schemaCombo.setValue((String) result[2]); + boolean upsert = targetDialect.isUpsertSupported(); + upsertRadio.setDisable(!upsert); + if (!upsert && upsertRadio.isSelected()) { + appendRadio.setSelected(true); + } + showStatus("status-success", Messages.format("vdb.target.ready", targetDialect.getName(), + targetDialect.getProductVersion(), String.join(", ", targetDialect.getFeatures().stream() + .map(Enum::name).toArray(String[]::new)))); + updateStart(); + }, e -> { + if (profile != targetCombo.getValue()) { + return; + } + // A data source without vectors is not a target + VECTOR_TARGETS.remove(probeKey(profile)); + targetCombo.getItems().remove(profile); + targetCombo.setValue(null); + showStatus("status-error", e.getMessage()); + }); + } + + // ------------------------------------------------------------------ + // Import + // ------------------------------------------------------------------ + + private void updateStart() { + startButton.setDisable(targetDialect == null || sourceCombo.getValue() == null + || getSelectedCollections().isEmpty()); + } + + private String validate() { + if (getSelectedCollections().isEmpty() || targetDialect == null) { + return Messages.get("vdb.start.error"); + } + return null; + } + + /** + * Target names and types of the fields of the checked collections, the id of a collection is always imported + */ + private void mapColumns() { + VdbProfile profile = getSourceProfile(); + List collections = getSelectedCollections().stream().map(item -> item.name).collect(Collectors + .toList()); + if (profile == null || collections.isEmpty()) { + showStatus("status-error", Messages.get("vdb.collections.choose")); + return; + } + new ColumnMappingDialog(getDialogPane().getScene().getWindow(), collections, collection -> { + try (VdbClient client = profile.getType().createClient(profile.toSource())) { + return client.describe(collection).getFields().stream().map(field -> new ColumnMappingDialog.Column( + field.getName(), VdbCollectionTab.describeType(field), field.isPrimaryKey())).collect( + Collectors.toList()); + } + }, fieldMappings).showAndWait().ifPresent(mappings -> { + fieldMappings = new HashMap<>(mappings); + updateMappingLabel(); + }); + } + + private void updateMappingLabel() { + long changed = fieldMappings.values().stream().mapToLong(Map::size).sum(); + mappingLabel.setText(changed == 0 ? Messages.get("mapping.unchanged") : Messages.format("mapping.changed", + changed)); + } + + VdbExportConfiguration buildConfiguration() { + VdbExportConfiguration configuration = new VdbExportConfiguration(); + configuration.setDialect(targetDialect); + configuration.setTargetSchema(StringUtils.trimToNull(schemaCombo.getEditor().getText())); + for (CollectionItem item : getSelectedCollections()) { + configuration.addCollection(item.name, StringUtils.trimToNull(item.tableName.get())); + // Fields which the user mapped, the others are imported as they are + Map fields = configuration.getMappings().get(configuration.getMappings().size() + - 1).getFields(); + fieldMappings.getOrDefault(item.name, Map.of()).forEach((field, mapping) -> fields.put(field, + new VdbFieldMapping(mapping.getName(), mapping.getType(), mapping.isSkipped()))); + } + configuration.setQuery(query); + configuration.setTableRecreated(recreateRadio.isSelected()); + configuration.setWriteMode(upsertRadio.isSelected() ? VdbExportConfiguration.WriteMode.UPSERT + : VdbExportConfiguration.WriteMode.INSERT); + configuration.setVectorIndexCreated(indexCheck.isSelected()); + configuration.setErrorSkipped(skipErrorsCheck.isSelected()); + configuration.setPageSize(Math.max(1, Math.min(context.getSettings().getTransferPageSize(), 1000))); + return configuration; + } + + private void showScripts() { + String error = validate(); + if (error != null) { + showStatus("status-error", error); + return; + } + VdbProfile profile = getSourceProfile(); + ConnectionProfile target = targetCombo.getValue(); + VdbExportConfiguration configuration = buildConfiguration(); + TaskRunner.run(() -> { + try (VdbClient client = profile.getType().createClient(profile.toSource())) { + return new VdbExporter(client, new ProfileDataSource(target), configuration).getScripts(); + } + }, scripts -> { + TextArea area = new TextArea(String.join(";\n\n", scripts) + (scripts.isEmpty() ? "" : ";")); + area.setEditable(false); + area.getStyleClass().add("mono"); + area.setPrefSize(820, 480); + Alert alert = new Alert(Alert.AlertType.INFORMATION); + Dialogs.initOwner(alert, getOwnerWindow()); + Branding.applyIcons(alert); + alert.setTitle(Messages.get("vdb.showSql")); + alert.setHeaderText(Messages.get("vdb.showSql")); + alert.getDialogPane().setContent(area); + alert.setResizable(true); + alert.showAndWait(); + }, e -> showStatus("status-error", e.getMessage())); + } + + /** + * Shows the dialog and runs the import of the chosen collections in the progress dialog + */ + public void showAndImport() { + if (Boolean.TRUE.equals(showAndWait().orElse(false))) { + startImport(getOwner(), context, getSourceProfile(), targetCombo.getValue(), buildConfiguration()); + } + } + + /** + * Runs an import in the progress dialog of the imports, each collection is a table of its progress + */ + static void startImport(Window owner, AppContext context, VdbProfile source, ConnectionProfile target, + VdbExportConfiguration configuration) { + ProgressDialog progress = new ProgressDialog(owner, context, Messages.format("progress.importTitle", + target.getName()), configuration.getMappings().size()); + progress.setSkipErrors(configuration.isErrorSkipped()); + ExportListener listener = progress.getListener(); + String schema = configuration.getTargetSchema(); + describeReport(progress.getReport(), source, target, configuration); + progress.setReportVerifier(report -> { + try (Connection connection = new ProfileDataSource(target).getConnection()) { + // Tables which are created by the import hold its rows only + TransferService.countTargetRows(connection, null, schema, report, name -> name, configuration + .isTableRecreated()); + } + }); + VdbExportListener adapter = VdbListeners.adapt(listener, schema); + progress.run(() -> { + try (VdbClient client = source.getType().createClient(source.toSource())) { + return new VdbExporter(client, new ProfileDataSource(target), configuration, adapter).export(); + } + }, (VdbExportResult result) -> Messages.format("vdb.result.done", result.getWritten(), result.getSkipped()), + () -> context.getSessionManager().closeSession(target.getId())); + } + + /** + * Header of the report of an import of collections, the collections are its tables + */ + static void describeReport(TransferReport report, VdbProfile source, ConnectionProfile target, + VdbExportConfiguration configuration) { + report.setKind(TransferReport.Kind.VECTOR_IMPORT); + report.setSource(source.getName() + " (" + source.getType() + ")" + (StringUtils.isNotBlank(source + .getDatabase()) ? " / " + source.getDatabase() : "")); + report.setTarget(target.getName() + (StringUtils.isNotBlank(configuration.getTargetSchema()) ? " / " + + configuration.getTargetSchema() : "")); + report.setTransaction(configuration.isTransactional() ? TransferReport.Transaction.EVERY_ROWS + : TransferReport.Transaction.NONE); + report.getOptions().put("Write mode", String.valueOf(configuration.getWriteMode())); + report.getOptions().put("Recreate tables", String.valueOf(configuration.isTableRecreated())); + report.getOptions().put("Vector indexes", String.valueOf(configuration.isVectorIndexCreated())); + report.getOptions().put("Page size", String.valueOf(configuration.getPageSize())); + report.getOptions().put("Skip rows with errors", String.valueOf(configuration.isErrorSkipped())); + for (VdbExportConfiguration.Mapping mapping : configuration.getMappings()) { + report.table(null, configuration.getTargetSchema(), mapping.getTableName()); + } + } + + private void showStatus(String styleClass, String text) { + statusLabel.getStyleClass().setAll("label", styleClass); + statusLabel.setText(text); + } + +} diff --git a/src/main/java/com/github/sqljam/face/vdb/VdbListeners.java b/src/main/java/com/github/sqljam/face/vdb/VdbListeners.java new file mode 100644 index 0000000..b087b2c --- /dev/null +++ b/src/main/java/com/github/sqljam/face/vdb/VdbListeners.java @@ -0,0 +1,79 @@ +/* + * 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.vdb; + +import com.github.sqljam.face.view.Messages; +import com.github.sqljam.impexp.ExportListener; +import com.github.sqljam.vdb.VdbExportListener; +import com.github.sqljam.vdb.VdbExportResult; + +/** + * @Description: VdbListeners turns the events of the vdb line into the events of the progress and the reports, each + * collection is a table. It needs no JavaFX, so that sync tasks run by the command line use it too. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +final class VdbListeners { + + private VdbListeners() { + } + + /** + * Events of the vdb line as events of the progress and the report: each collection is a table + */ + static VdbExportListener adapt(ExportListener listener, String schema) { + return new VdbExportListener() { + @Override + public void onTableStarted(VdbExportResult.TableResult table) { + listener.onTableStart(null, schema, table.getTableName(), table.getTotal()); + } + + @Override + public void onTableCreated(VdbExportResult.TableResult table, String statement) { + listener.onDdlExecuted(statement, true); + } + + @Override + public void onRowsWritten(VdbExportResult.TableResult table) { + listener.onTableProgress(null, schema, table.getTableName(), table.getRead(), table.getTotal()); + } + + @Override + public void onRowSkipped(VdbExportResult.TableResult table, String key, Exception e) { + listener.onError(Messages.format("vdb.progress.skipped", table.getTableName(), key, e.getMessage()), + e); + listener.onRowSkipped(null, schema, table.getTableName(), key, e); + } + + @Override + public void onStatementSkipped(VdbExportResult.TableResult table, String statement, Exception e) { + listener.onMessage(Messages.format("vdb.progress.statementSkipped", e.getMessage(), statement)); + listener.onDdlExecuted(statement, false); + } + + @Override + public void onTableFinished(VdbExportResult.TableResult table) { + listener.onTableEnd(null, schema, table.getTableName(), table.getWritten()); + } + + @Override + public boolean isCancelled() { + return listener.isCancelled(); + } + }; + } +} diff --git a/src/main/java/com/github/sqljam/face/vdb/VdbMenuExtension.java b/src/main/java/com/github/sqljam/face/vdb/VdbMenuExtension.java new file mode 100644 index 0000000..163624b --- /dev/null +++ b/src/main/java/com/github/sqljam/face/vdb/VdbMenuExtension.java @@ -0,0 +1,54 @@ +/* + * 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.vdb; + +import com.github.sqljam.config.Config; +import com.github.sqljam.face.view.AppContext; +import com.github.sqljam.face.view.MenuExtension; +import com.github.sqljam.face.view.Messages; +import javafx.stage.Window; + +/** + * @Description: VdbMenuExtension is the entry of the vdb line in the File menu, switched by sqljam.vdb.enabled. It + * loads no class of the vdb line until its item is chosen. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbMenuExtension implements MenuExtension { + + public static final String ENABLED_KEY = "sqljam.vdb.enabled"; + + @Override + public String getId() { + return "vdbImportMenuItem"; + } + + @Override + public String getText() { + return Messages.get("action.importVectorDatabase"); + } + + @Override + public boolean isEnabled(Config config) { + return config.getBoolean(ENABLED_KEY, true); + } + + @Override + public void show(Window owner, AppContext context) { + new VdbImportDialog(owner, context, VdbProfileStore.getInstance()).showAndImport(); + } +} diff --git a/src/main/java/com/github/sqljam/face/vdb/VdbProfile.java b/src/main/java/com/github/sqljam/face/vdb/VdbProfile.java new file mode 100644 index 0000000..ddded87 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/vdb/VdbProfile.java @@ -0,0 +1,94 @@ +/* + * 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.vdb; + +import java.util.UUID; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import com.github.sqljam.vdb.VdbSource; +import com.github.sqljam.vdb.VdbType; + +/** + * @Description: VdbProfile is a saved vector database: its type, the url of its REST API, its credentials (an API + * key, or a user and a password), the database and the tenant + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Getter +@Setter +@ToString(exclude = {"apiKey", "password"}) +public class VdbProfile { + + private String id = UUID.randomUUID().toString(); + private String name; + private VdbType type; + private String url; + private String apiKey; + private String username; + private String password; + private String database; + private String tenant; + private boolean certificateTrusted; + private int timeoutSeconds = 60; + + public VdbSource toSource() { + VdbSource source = new VdbSource(type, url); + source.setApiKey(apiKey); + source.setUsername(username); + source.setPassword(password); + source.setDatabase(database); + source.setTenant(tenant); + source.setCertificateTrusted(certificateTrusted); + source.setTimeoutSeconds(timeoutSeconds); + return source; + } + + public VdbProfile copy() { + VdbProfile copy = new VdbProfile(); + copy.setId(id); + copy.setName(name); + copy.setType(type); + copy.setUrl(url); + copy.setApiKey(apiKey); + copy.setUsername(username); + copy.setPassword(password); + copy.setDatabase(database); + copy.setTenant(tenant); + copy.setCertificateTrusted(certificateTrusted); + copy.setTimeoutSeconds(timeoutSeconds); + return copy; + } + + /** + * Whether the API key or the password is saved + */ + public boolean hasSecret() { + return apiKey != null || password != null; + } + + @Override + public boolean equals(Object other) { + return other instanceof VdbProfile && id.equals(((VdbProfile) other).id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } +} diff --git a/src/main/java/com/github/sqljam/face/vdb/VdbProfileStore.java b/src/main/java/com/github/sqljam/face/vdb/VdbProfileStore.java new file mode 100644 index 0000000..df6eebb --- /dev/null +++ b/src/main/java/com/github/sqljam/face/vdb/VdbProfileStore.java @@ -0,0 +1,119 @@ +/* + * 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.vdb; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import com.github.sqljam.config.Config; +import com.github.sqljam.face.service.JsonStore; + +/** + * @Description: VdbProfileStore keeps the vector databases in their own file (vdb-connections.json next to the + * connections), apart from the data sources of the jdbc line. Secrets which are not remembered are kept + * for this session only. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbProfileStore { + + public static final String FILE_NAME = "vdb-connections.json"; + + private final JsonStore jsonStore; + private final String fileName; + private final List profiles = new ArrayList<>(); + private final Map sessionSecrets = new ConcurrentHashMap<>(); + + private static VdbProfileStore instance; + private static Config instanceConfig; + + /** + * The store of the configuration of the application, shared by the menu, the data source form, the connection + * tree and the dialogs + */ + public static synchronized VdbProfileStore getInstance() { + Config config = Config.getInstance(); + if (instance == null || instanceConfig != config) { + instance = new VdbProfileStore(); + instanceConfig = config; + } + return instance; + } + + public VdbProfileStore() { + this(Config.getInstance().getFile("sqljam.vdb.connections.file", new File(Config.getInstance().getFile( + "sqljam.connections.file", new File(JsonStore.DEFAULT_DIRECTORY, "connections.json")) + .getAbsoluteFile().getParentFile(), FILE_NAME))); + } + + public VdbProfileStore(File file) { + this.jsonStore = new JsonStore(file.getAbsoluteFile().getParentFile()); + this.fileName = file.getName(); + profiles.addAll(Arrays.asList(jsonStore.read(fileName, VdbProfile[].class, new VdbProfile[0]))); + } + + /** + * Profiles with the secrets of this session filled in + */ + public synchronized List getProfiles() { + return profiles.stream().map(this::withSessionSecret).collect(Collectors.toList()); + } + + private VdbProfile withSessionSecret(VdbProfile profile) { + VdbProfile copy = profile.copy(); + VdbProfile secret = sessionSecrets.get(profile.getId()); + if (!copy.hasSecret() && secret != null) { + copy.setApiKey(secret.getApiKey()); + copy.setPassword(secret.getPassword()); + } + return copy; + } + + public synchronized boolean isSecretRemembered(String id) { + return profiles.stream().filter(profile -> profile.getId().equals(id)).findFirst().map(VdbProfile::hasSecret) + .orElse(true); + } + + /** + * Adds or replaces a profile, its API key and password are saved only if remembered + */ + public synchronized void saveProfile(VdbProfile profile, boolean secretRemembered) throws IOException { + VdbProfile saved = profile.copy(); + if (secretRemembered) { + sessionSecrets.remove(profile.getId()); + } else { + saved.setApiKey(null); + saved.setPassword(null); + sessionSecrets.put(profile.getId(), profile.copy()); + } + profiles.removeIf(existing -> existing.getId().equals(profile.getId())); + profiles.add(saved); + jsonStore.write(fileName, profiles); + } + + public synchronized void removeProfile(String id) throws IOException { + sessionSecrets.remove(id); + profiles.removeIf(existing -> existing.getId().equals(id)); + jsonStore.write(fileName, profiles); + } +} diff --git a/src/main/java/com/github/sqljam/face/vdb/VdbSyncEngine.java b/src/main/java/com/github/sqljam/face/vdb/VdbSyncEngine.java new file mode 100644 index 0000000..dc70d35 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/vdb/VdbSyncEngine.java @@ -0,0 +1,207 @@ +/* + * 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.vdb; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.model.SyncTask; +import com.github.sqljam.face.model.SyncTask.Watermark; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.service.SyncEngine; +import com.github.sqljam.face.service.SyncErrors; +import com.github.sqljam.face.service.SyncRunner; +import com.github.sqljam.face.service.TransferService; +import com.github.sqljam.impexp.ExportCancelledException; +import com.github.sqljam.impexp.ExportListener; +import com.github.sqljam.vdb.VdbClient; +import com.github.sqljam.vdb.VdbExportConfiguration; +import com.github.sqljam.vdb.VdbExportResult; +import com.github.sqljam.vdb.VdbExporter; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbSyncEngine runs sync tasks of collections of a vector database: the records whose watermark + * field (a number or a time of the payload) is at least the last watermark are updated or inserted by + * id into the kept tables of the target, all records the first time. Vector databases can not tell + * the greatest value of a field, so the watermark is read from the target table after the rows were + * written; records of that value are read again by the next run and updated. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbSyncEngine implements SyncEngine { + + private final VdbProfileStore store; + private final Function> profiles; + + public VdbSyncEngine(VdbProfileStore store, Function> profiles) { + this.store = store; + this.profiles = profiles; + } + + private Optional findSource(String id) { + return store.getProfiles().stream().filter(profile -> profile.getId().equals(id)).findFirst(); + } + + /** + * The vector database of the task in its database of the task + */ + VdbProfile source(SyncTask task) { + VdbProfile profile = findSource(task.getSourceId()).orElseThrow(() -> new SyncErrors.PermanentException( + "The vector database of the task was removed.")); + if (StringUtils.isNotBlank(task.getSourceCatalog())) { + profile = profile.copy(); + profile.setDatabase(task.getSourceCatalog()); + } + return profile; + } + + @Override + public void describe(SyncTask task, TransferReport report) { + report.setKind(TransferReport.Kind.VECTOR_SYNC); + report.setTitle("Sync " + task.getName()); + report.setSource(findSource(task.getSourceId()).map(profile -> profile.getName() + " (" + profile.getType() + + ")").orElse(task.getSourceId()) + (StringUtils.isNotBlank(task.getSourceCatalog()) ? " / " + task + .getSourceCatalog() : "")); + report.setTarget(profiles.apply(task.getTargetId()).map(ConnectionProfile::getName).orElse(task + .getTargetId()) + (StringUtils.isNotBlank(task.getTargetSchema()) ? " / " + task.getTargetSchema() + : "")); + report.setTransaction(TransferReport.Transaction.EVERY_ROWS); + report.getOptions().put("Task", task.getName()); + report.getOptions().put("Copy", task.isInitialized() ? "Records changed since the last run" + : "All records, the first run"); + report.getOptions().put("Write mode", "Update or insert by id"); + report.getOptions().put("Records deleted in the source", "Kept in the target"); + } + + @Override + public Map run(SyncTask task, TransferReport report, ExportListener listener) + throws Exception { + VdbProfile source = source(task); + ConnectionProfile target = SyncRunner.requireProfile(profiles, task.getTargetId(), "target"); + ProfileDataSource dataSource = new ProfileDataSource(target); + VdbDialect dialect; + try (Connection connection = dataSource.getConnection()) { + dialect = VdbExporter.getDialect(connection); + } + if (!dialect.isUpsertSupported()) { + throw new SyncErrors.PermanentException(target.getName() + " can not update rows by key, a sync task " + + "needs it."); + } + Map watermarks = new LinkedHashMap<>(); + for (SyncTask.Table table : task.getTables()) { + Watermark last = table.getWatermark(); + VdbExportConfiguration configuration = new VdbExportConfiguration(); + configuration.setDialect(dialect); + configuration.setTargetSchema(StringUtils.trimToNull(task.getTargetSchema())); + configuration.addCollection(table.getName(), StringUtils.trimToNull(table.getTargetName())); + VdbQuery query = VdbQuery.all(); + if (last != null) { + // Records of the same value are read again and updated, keys which only grow are new records + query.where(table.getWatermarkColumn(), table.getWatermarkType() == SyncTask.WatermarkType + .UPDATED_AT ? VdbQuery.Operator.GE : VdbQuery.Operator.GT, queryValue(last)); + } + configuration.setQuery(query); + configuration.setTableRecreated(false); + configuration.setWriteMode(VdbExportConfiguration.WriteMode.UPSERT); + configuration.setVectorIndexCreated(true); + configuration.setErrorSkipped(false); + configuration.setPageSize(Math.max(1, Math.min(task.getPageSize(), 1000))); + VdbExportResult result; + try (VdbClient client = source.getType().createClient(source.toSource())) { + result = new VdbExporter(client, dataSource, configuration, VdbListeners.adapt(listener, task + .getTargetSchema())).export(); + } + if (result.isCancelled() || listener.isCancelled()) { + throw new ExportCancelledException(); + } + String tableName = result.getTables().isEmpty() ? table.getName() : result.getTables().get(0) + .getTableName(); + Watermark next; + try (Connection connection = dataSource.getConnection()) { + next = readMax(connection, task.getTargetSchema(), tableName, table.getWatermarkColumn()); + } + watermarks.put(table.getName(), next != null ? next : last); + report.getOptions().put("Watermark of " + table.getName(), table.getWatermarkColumn() + " from " + + (last != null ? last : "the first record") + " to " + (next != null ? next : "no records")); + } + return watermarks; + } + + @Override + public void verify(SyncTask task, TransferReport report) throws Exception { + ConnectionProfile target = SyncRunner.requireProfile(profiles, task.getTargetId(), "target"); + try (Connection connection = new ProfileDataSource(target).getConnection()) { + TransferService.countTargetRows(connection, null, task.getTargetSchema(), report, name -> name, false); + } + } + + /** + * A number or a time as the vector database compares it + */ + static Object queryValue(Watermark watermark) { + Object value = SyncRunner.valueOf(watermark); + if (value instanceof BigDecimal) { + BigDecimal number = ((BigDecimal) value).stripTrailingZeros(); + return number.scale() <= 0 ? (Object) number.longValueExact() : (Object) number.doubleValue(); + } + return watermark.getValue(); + } + + /** + * The greatest value of the column of the field in the target table, whose names are found without case + */ + static Watermark readMax(Connection connection, String schema, String table, String field) throws Exception { + DatabaseMetaData metaData = connection.getMetaData(); + String quote = StringUtils.defaultIfBlank(StringUtils.trim(metaData.getIdentifierQuoteString()), "\""); + String[] found = null; + try (ResultSet rs = metaData.getColumns(null, StringUtils.trimToNull(schema), null, null)) { + while (rs.next()) { + if (rs.getString("TABLE_NAME").equalsIgnoreCase(table) && rs.getString("COLUMN_NAME") + .equalsIgnoreCase(field)) { + found = new String[]{rs.getString("TABLE_SCHEM"), rs.getString("TABLE_NAME"), rs.getString( + "COLUMN_NAME")}; + break; + } + } + } + if (found == null) { + throw new SyncErrors.PermanentException(String.format(Locale.ENGLISH, "The field %s is not a column " + + "of the table %s of the target.", field, table)); + } + String name = (StringUtils.isNotBlank(found[0]) ? quote(found[0], quote) + "." : "") + quote(found[1], quote); + try (Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SELECT MAX(" + quote(found[2], quote) + ") FROM " + name)) { + return rs.next() ? SyncRunner.watermarkOf(rs.getObject(1)) : null; + } + } + + private static String quote(String name, String quote) { + return quote + name.replace(quote, quote + quote) + quote; + } +} 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 dbd645a..dcaf17c 100644 --- a/src/main/java/com/github/sqljam/face/view/AppContext.java +++ b/src/main/java/com/github/sqljam/face/view/AppContext.java @@ -17,11 +17,20 @@ import java.io.IOException; import java.sql.SQLException; +import java.util.function.Consumer; +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.model.SyncTask; +import com.github.sqljam.face.model.TransferReport; import com.github.sqljam.face.service.ExampleDatabase; +import com.github.sqljam.face.service.ReportStore; import com.github.sqljam.face.service.SettingsStore; +import com.github.sqljam.face.service.SyncRunner; +import com.github.sqljam.face.service.SyncScheduler; +import com.github.sqljam.face.service.SyncTaskStore; +import com.github.sqljam.face.service.SystemScheduler; import com.github.sqljam.face.service.TransferService; import javafx.application.HostServices; import lombok.Getter; @@ -45,6 +54,14 @@ public class AppContext { private final HostServices hostServices; @Setter private ExampleDatabase exampleDatabase = new ExampleDatabase(); + private ReportStore reportStore; + private SyncScheduler syncScheduler; + private SystemScheduler systemScheduler; + /** + * Shows a report on its own page, set by the main window + */ + @Setter + private Consumer reportViewer; public AppContext(SettingsStore settingsStore, ProfileRegistry profileRegistry, HostServices hostServices) { this.settingsStore = settingsStore; @@ -72,6 +89,68 @@ public void openDocument(String url) { } } + /** + * Reports of the transfers, the history of the imports + */ + public synchronized ReportStore getReportStore() { + if (reportStore == null) { + reportStore = new ReportStore(settingsStore.getConfig()); + } + return reportStore; + } + + public void setReportStore(ReportStore reportStore) { + this.reportStore = reportStore; + } + + /** + * Scheduler of the sync tasks: tasks of tables run here, tasks of the other kinds of sources run by their + * extensions. It is started by the application, tests run its tasks by Run Now. + */ + public synchronized SyncScheduler getSyncScheduler() { + if (syncScheduler == null) { + Config config = settingsStore.getConfig(); + syncScheduler = new SyncScheduler(new SyncTaskStore(config), getReportStore()); + syncScheduler.setRetryDelayMillis(config.getInt("sqljam.sync.retry-delay-seconds", 30) * 1000L); + syncScheduler.register(SyncTask.Kind.DATABASE, new SyncRunner(transferService, + profileRegistry::findProfile)); + for (DataSourceExtension extension : DataSourceExtension.load(config)) { + DataSourceExtension.SyncSupport support = extension.getSyncSupport(); + if (support != null) { + syncScheduler.register(support.getKind(), support.createEngine(profileRegistry::findProfile)); + } + } + } + return syncScheduler; + } + + /** + * The scheduler of the system which runs sync tasks by the command line + */ + public synchronized SystemScheduler getSystemScheduler() { + if (systemScheduler == null) { + systemScheduler = SystemScheduler.create(settingsStore.getConfig()); + } + return systemScheduler; + } + + public synchronized void setSystemScheduler(SystemScheduler systemScheduler) { + this.systemScheduler = systemScheduler; + } + + public synchronized void setSyncScheduler(SyncScheduler syncScheduler) { + this.syncScheduler = syncScheduler; + } + + /** + * Stops the scheduler if it was created: running tasks are cancelled and their reports saved + */ + public synchronized void closeSyncScheduler() { + if (syncScheduler != null) { + syncScheduler.close(); + } + } + public AppSettings getSettings() { return settingsStore.getSettings(); } diff --git a/src/main/java/com/github/sqljam/face/view/AuthFailures.java b/src/main/java/com/github/sqljam/face/view/AuthFailures.java new file mode 100644 index 0000000..6128ea1 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/AuthFailures.java @@ -0,0 +1,87 @@ +/* + * 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.sql.SQLException; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; + +/** + * @Description: AuthFailures tells the errors which a new secret may fix: a database which refused the user or the + * password, and an SSH server which refused the password or could not read the key without its + * passphrase. Other errors (host unknown, database down) are shown as they are. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class AuthFailures { + + /** + * Error codes of a refused login: MySQL and MariaDB 1045, Oracle 1017, SQL Server 18456, Db2 -4214 and + * -30082, ClickHouse 516 + */ + static final Set ERROR_CODES = Set.of(1045, 1017, 18456, -4214, -30082, 516); + static final String SSH_TUNNEL = "SSH tunnel through"; + + private AuthFailures() { + } + + /** + * Whether the database refused the user or the password, SQLState class 28 or a known error code + */ + public static boolean isDatabaseFailure(Throwable error) { + for (Throwable cause = error; cause != null; cause = cause.getCause()) { + if (StringUtils.startsWith(cause.getMessage(), SSH_TUNNEL)) { + // The SSH server refused, not the database + return false; + } + if (cause instanceof SQLException) { + SQLException e = (SQLException) cause; + if (StringUtils.startsWith(e.getSQLState(), "28") || ERROR_CODES.contains(e.getErrorCode())) { + return true; + } + } + String message = StringUtils.defaultString(cause.getMessage()); + if (message.contains("password authentication failed") || message.contains("AUTHENTICATION_FAILED") + || message.contains("Authentication failed")) { + return true; + } + if (cause.getCause() == cause) { + break; + } + } + return false; + } + + /** + * Whether the SSH server of the tunnel refused the password or the key could not be read without its + * passphrase + */ + public static boolean isSshFailure(Throwable error) { + for (Throwable cause = error; cause != null; cause = cause.getCause()) { + String message = StringUtils.defaultString(cause.getMessage()); + if (message.startsWith(SSH_TUNNEL) && (message.contains("No more authentication methods") + || message.contains("unable to read the private key"))) { + return true; + } + if (cause.getCause() == cause) { + break; + } + } + return false; + } +} diff --git a/src/main/java/com/github/sqljam/face/view/ColumnMappingDialog.java b/src/main/java/com/github/sqljam/face/view/ColumnMappingDialog.java new file mode 100644 index 0000000..0f676d8 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/ColumnMappingDialog.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.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; + +import org.apache.commons.lang3.StringUtils; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ReadOnlyStringWrapper; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.event.ActionEvent; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Dialog; +import javafx.scene.control.Label; +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.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Window; + +/** + * @Description: ColumnMappingDialog maps the columns of the tables of an import into a database: a column is renamed, + * gets a type of the target written as it is, or is left out. Columns which are not changed are + * imported as they are, so an import without mappings is the import of before, like the column mapping + * of DBeaver. Two columns of a table can not have the same name, a required column (the id of a + * collection) is always imported. It knows the columns only, the lines of SQL and of vector databases + * turn its results into their own mappings. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class ColumnMappingDialog extends Dialog>> { + + /** + * A source column, required when it can not be left out + */ + public static final class Column { + + private final String name; + private final String type; + private final boolean required; + + public Column(String name, String type, boolean required) { + this.name = name; + this.type = type; + this.required = required; + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } + + public boolean isRequired() { + return required; + } + } + + /** + * The target of a column which the user changed: its name and type, null when unchanged, or left out + */ + public static final class Mapping { + + private final String name; + private final String type; + private final boolean skipped; + + public Mapping(String name, String type, boolean skipped) { + this.name = StringUtils.trimToNull(name); + this.type = StringUtils.trimToNull(type); + this.skipped = skipped; + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } + + public boolean isSkipped() { + return skipped; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof Mapping)) { + return false; + } + Mapping mapping = (Mapping) other; + return skipped == mapping.skipped && Objects.equals(name, mapping.name) && Objects.equals(type, + mapping.type); + } + + @Override + public int hashCode() { + return Objects.hash(name, type, skipped); + } + + @Override + public String toString() { + return skipped ? "skipped" : StringUtils.defaultString(name) + " " + StringUtils.defaultString(type); + } + } + + /** + * Reads the columns of a table, in the background + */ + @FunctionalInterface + public interface ColumnLoader { + List load(String table) throws Exception; + } + + /** + * A row of the table of columns + */ + public static final class Row { + + final Column column; + final BooleanProperty imported = new SimpleBooleanProperty(true); + final StringProperty targetName = new SimpleStringProperty(""); + final StringProperty targetType = new SimpleStringProperty(""); + + Row(Column column, Mapping mapping) { + this.column = column; + if (mapping != null) { + imported.set(!mapping.isSkipped() || column.isRequired()); + targetName.set(StringUtils.defaultString(mapping.getName())); + targetType.set(StringUtils.defaultString(mapping.getType())); + } + } + + /** + * The mapping of the row, null when it changes nothing + */ + Mapping toMapping() { + if (!imported.get()) { + return new Mapping(null, null, true); + } + String name = StringUtils.trimToNull(targetName.get()); + if (name != null && name.equals(column.getName())) { + name = null; + } + String type = StringUtils.trimToNull(targetType.get()); + return name == null && type == null ? null : new Mapping(name, type, false); + } + + String getTargetName() { + return StringUtils.defaultIfBlank(StringUtils.trim(targetName.get()), column.getName()); + } + + public Column getColumn() { + return column; + } + + public BooleanProperty importedProperty() { + return imported; + } + + public StringProperty targetNameProperty() { + return targetName; + } + + public StringProperty targetTypeProperty() { + return targetType; + } + } + + private final ComboBox tableCombo = new ComboBox<>(); + private final TableView columnTable = new TableView<>(); + private final Label errorLabel = new Label(); + private final Label statusLabel = new Label(); + private final Map> rowsByTable = new LinkedHashMap<>(); + private final Map> chosen; + private final ColumnLoader loader; + private final Button okButton; + + /** + * @param tables tables (or collections) of the import + * @param chosen mappings chosen before, by table and column + */ + public ColumnMappingDialog(Window owner, List tables, ColumnLoader loader, + Map> chosen) { + Dialogs.initOwner(this, owner); + Branding.applyIcons(this); + this.loader = loader; + this.chosen = new LinkedHashMap<>(); + chosen.forEach((table, mappings) -> this.chosen.put(table, new LinkedHashMap<>(mappings))); + setTitle(Messages.get("mapping.title")); + setHeaderText(Messages.get("mapping.header")); + setResizable(true); + tableCombo.setId("mappingTableCombo"); + columnTable.setId("mappingColumnTable"); + errorLabel.setId("mappingError"); + statusLabel.setId("mappingStatus"); + errorLabel.getStyleClass().add("status-error"); + errorLabel.setWrapText(true); + statusLabel.getStyleClass().add("muted"); + + tableCombo.getItems().setAll(tables); + tableCombo.setMaxWidth(Double.MAX_VALUE); + tableCombo.valueProperty().addListener((obs, oldTable, table) -> showTable(table)); + Label tableLabel = new Label(Messages.get("mapping.table")); + tableLabel.setMinWidth(70); + HBox tableBox = new HBox(10, tableLabel, tableCombo); + tableBox.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(tableCombo, Priority.ALWAYS); + + columnTable.setEditable(true); + columnTable.getColumns().add(importColumn()); + columnTable.getColumns().add(textColumn(Messages.get("mapping.source"), row -> row.column.getName())); + columnTable.getColumns().add(textColumn(Messages.get("mapping.sourceType"), row -> row.column.getType())); + columnTable.getColumns().add(editColumn(Messages.get("mapping.target"), "mappingTarget", true)); + columnTable.getColumns().add(editColumn(Messages.get("mapping.targetType"), "mappingType", false)); + columnTable.setPlaceholder(new Label(Messages.get("mapping.loading"))); + columnTable.setPrefSize(760, 380); + TableColumns.fill(columnTable); + + VBox content = new VBox(10, tableBox, columnTable, errorLabel, statusLabel); + content.setPadding(new Insets(4)); + getDialogPane().setContent(content); + ButtonType reset = new ButtonType(Messages.get("mapping.reset"), ButtonBar.ButtonData.LEFT); + getDialogPane().getButtonTypes().addAll(reset, ButtonType.OK, ButtonType.CANCEL); + Button resetButton = (Button) getDialogPane().lookupButton(reset); + resetButton.setId("mappingResetButton"); + resetButton.addEventFilter(ActionEvent.ACTION, event -> { + event.consume(); + resetTable(); + }); + okButton = (Button) getDialogPane().lookupButton(ButtonType.OK); + okButton.addEventFilter(ActionEvent.ACTION, event -> { + if (validate() != null) { + event.consume(); + } + }); + setResultConverter(type -> type == ButtonType.OK ? getMappings() : null); + // One table needs no choice of tables + tableBox.setVisible(tables.size() > 1); + tableBox.setManaged(tables.size() > 1); + if (!tables.isEmpty()) { + tableCombo.setValue(tables.get(0)); + } + } + + private TableColumn importColumn() { + TableColumn column = new TableColumn<>(Messages.get("mapping.import")); + column.setPrefWidth(70); + column.setSortable(false); + column.setCellFactory(tableColumn -> new TableCell<>() { + private final CheckBox check = new CheckBox(); + + @Override + protected void updateItem(Boolean item, boolean empty) { + super.updateItem(item, empty); + Row row = empty || getTableRow() == null ? null : getTableRow().getItem(); + if (row == null) { + setGraphic(null); + return; + } + check.setSelected(row.imported.get()); + check.setOnAction(event -> { + row.imported.set(check.isSelected()); + validate(); + }); + // The id of a collection is always imported + check.setDisable(row.column.isRequired()); + check.setTooltip(row.column.isRequired() ? new Tooltip(Messages.format("mapping.required", + row.column.getName())) : null); + setGraphic(check); + } + }); + column.setCellValueFactory(features -> features.getValue().imported.asObject()); + return column; + } + + private static TableColumn textColumn(String title, Function value) { + TableColumn column = new TableColumn<>(title); + column.setPrefWidth(170); + column.setCellValueFactory(features -> new ReadOnlyStringWrapper(value.apply(features.getValue()))); + return column; + } + + /** + * A column of text fields which are always editable, a value is kept as it is typed + */ + private TableColumn editColumn(String title, String id, boolean name) { + TableColumn column = new TableColumn<>(title); + column.setPrefWidth(180); + column.setSortable(false); + column.setCellValueFactory(features -> name ? features.getValue().targetName : features.getValue() + .targetType); + column.setCellFactory(tableColumn -> new TableCell<>() { + private final TextField field = new TextField(); + private StringProperty bound; + + { + field.setId(id); + field.textProperty().addListener((obs, oldText, text) -> validate()); + } + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (bound != null) { + field.textProperty().unbindBidirectional(bound); + bound = null; + } + Row row = empty || getTableRow() == null ? null : getTableRow().getItem(); + if (row == null) { + setGraphic(null); + return; + } + bound = name ? row.targetName : row.targetType; + field.textProperty().bindBidirectional(bound); + field.setPromptText(name ? row.column.getName() : Messages.get("mapping.automatic")); + field.disableProperty().bind(row.imported.not()); + setGraphic(field); + } + }); + return column; + } + + private void showTable(String table) { + if (table == null) { + return; + } + List rows = rowsByTable.get(table); + if (rows != null) { + columnTable.getItems().setAll(rows); + validate(); + return; + } + columnTable.getItems().clear(); + columnTable.setPlaceholder(new Label(Messages.get("mapping.loading"))); + TaskRunner.run(() -> loader.load(table), columns -> { + Map mappings = chosen.getOrDefault(table, Map.of()); + List loaded = new ArrayList<>(); + columns.forEach(column -> loaded.add(new Row(column, mappings.get(column.getName())))); + rowsByTable.put(table, loaded); + if (table.equals(tableCombo.getValue())) { + columnTable.getItems().setAll(loaded); + columnTable.setPlaceholder(new Label(Messages.get("mapping.empty"))); + } + validate(); + }, e -> columnTable.setPlaceholder(new Label(Messages.format("tree.error", e.getMessage())))); + } + + /** + * The columns of the table as they are + */ + void resetTable() { + List rows = rowsByTable.get(tableCombo.getValue()); + if (rows != null) { + for (Row row : rows) { + row.imported.set(true); + row.targetName.set(""); + row.targetType.set(""); + } + columnTable.refresh(); + validate(); + } + } + + /** + * The first error of the mappings, null if they can be imported: two columns with the same name, a table + * without columns + */ + String validate() { + String error = null; + for (Map.Entry> entry : rowsByTable.entrySet()) { + Map names = new HashMap<>(); + boolean anyImported = false; + for (Row row : entry.getValue()) { + if (!row.imported.get()) { + continue; + } + anyImported = true; + String target = row.getTargetName(); + String other = names.putIfAbsent(target.toLowerCase(Locale.ENGLISH), row.column.getName()); + if (other != null && error == null) { + error = Messages.format("mapping.error.duplicate", other, row.column.getName(), target, + entry.getKey()); + } + } + if (!anyImported && !entry.getValue().isEmpty() && error == null) { + error = Messages.format("mapping.error.noColumns", entry.getKey()); + } + } + errorLabel.setText(error); + errorLabel.setVisible(error != null); + errorLabel.setManaged(error != null); + okButton.setDisable(error != null); + long changed = getMappings().values().stream().mapToLong(Map::size).sum(); + statusLabel.setText(changed == 0 ? Messages.get("mapping.unchanged") : Messages.format("mapping.changed", + changed)); + return error; + } + + /** + * Mappings of the columns which are changed, by table; tables which were not opened keep their mappings + */ + Map> getMappings() { + Map> mappings = new LinkedHashMap<>(); + chosen.forEach((table, columns) -> { + if (!rowsByTable.containsKey(table) && !columns.isEmpty()) { + mappings.put(table, new LinkedHashMap<>(columns)); + } + }); + rowsByTable.forEach((table, rows) -> { + Map columns = new LinkedHashMap<>(); + rows.forEach(row -> { + Mapping mapping = row.toMapping(); + if (mapping != null) { + columns.put(row.column.getName(), mapping); + } + }); + if (!columns.isEmpty()) { + mappings.put(table, columns); + } + }); + return mappings; + } + + String getTable() { + return tableCombo.getValue(); + } + + void setTable(String table) { + tableCombo.setValue(table); + } + + List getRows() { + return columnTable.getItems(); + } +} 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 3af535c..00739fd 100644 --- a/src/main/java/com/github/sqljam/face/view/ConnectionDialog.java +++ b/src/main/java/com/github/sqljam/face/view/ConnectionDialog.java @@ -16,10 +16,12 @@ package com.github.sqljam.face.view; import java.io.IOException; +import java.util.List; import java.util.Optional; import java.util.function.Consumer; import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.impexp.DbCategory; import javafx.event.ActionEvent; import javafx.geometry.Pos; import javafx.scene.control.Button; @@ -40,16 +42,35 @@ */ public class ConnectionDialog extends Dialog { + private DataSourceExtension.Source savedSource; + public ConnectionDialog(Window owner, AppContext context, ConnectionProfile profile) { + this(owner, context, profile, null, List.of()); + } + + public ConnectionDialog(Window owner, AppContext context, ConnectionProfile profile, DbCategory category) { + this(owner, context, profile, category, List.of()); + } + + /** + * @param category category of a new data source: a DbCategory or one of the extensions, null for the + * relational databases + * @param extensions kinds of data sources other than JDBC which the category may choose, e.g. vector databases + */ + public ConnectionDialog(Window owner, AppContext context, ConnectionProfile profile, Object category, + List extensions) { Dialogs.initOwner(this, owner); Branding.applyIcons(this); boolean editing = profile != null; setTitle(Messages.get(editing ? "connection.edit.title" : "connection.new.title")); setHeaderText(getTitle()); setResizable(true); - ConnectionForm form = new ConnectionForm(); + ConnectionForm form = new ConnectionForm(editing ? List.of() : extensions); if (editing) { form.setProfile(profile, context.getProfileRegistry().isPasswordRemembered(profile.getId())); + form.setCategoryEditable(false); + } else if (category != null) { + form.setCategory(category); } form.setPrefWidth(520); getDialogPane().setContent(form); @@ -71,13 +92,18 @@ public ConnectionDialog(Window owner, AppContext context, ConnectionProfile prof return; } try { - context.getProfileRegistry().saveProfile(form.getProfile(), form.isPasswordRemembered()); + if (form.getSelectedExtension() != null) { + savedSource = form.saveSource(); + } else { + context.getProfileRegistry().saveProfile(form.getProfile(), form.isPasswordRemembered()); + } } catch (IOException e) { event.consume(); Dialogs.showError(getOwner(), Messages.get("connection.save.error"), e); } }); - setResultConverter(buttonType -> buttonType == ButtonType.OK ? form.getProfile() : null); + setResultConverter(buttonType -> buttonType == ButtonType.OK && form.getSelectedExtension() == null + ? form.getProfile() : null); } /** @@ -109,4 +135,18 @@ public static void selectProfile(ComboBox combo, ConnectionPr public static Optional show(Window owner, AppContext context, ConnectionProfile profile) { return new ConnectionDialog(owner, context, profile).showAndWait(); } + + /** + * The source of an extension which was saved, e.g. a vector database, null for a relational data source + */ + public DataSourceExtension.Source getSavedSource() { + return savedSource; + } + + /** + * A new data source of the types of the category + */ + public static Optional create(Window owner, AppContext context, DbCategory category) { + return new ConnectionDialog(owner, context, null, category).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 ec5f68a..becf58a 100644 --- a/src/main/java/com/github/sqljam/face/view/ConnectionForm.java +++ b/src/main/java/com/github/sqljam/face/view/ConnectionForm.java @@ -16,15 +16,22 @@ package com.github.sqljam.face.view; import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.function.Consumer; +import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import com.github.sqljam.face.model.ConnectionProfile; import com.github.sqljam.face.service.DatabaseSession; import com.github.sqljam.face.service.JdbcUrls; +import com.github.sqljam.impexp.DbCategory; import com.github.sqljam.impexp.DbType; import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -33,6 +40,7 @@ import javafx.scene.control.ChoiceDialog; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; +import javafx.scene.control.ListCell; import javafx.scene.control.PasswordField; import javafx.scene.control.ProgressIndicator; import javafx.scene.control.TextField; @@ -43,6 +51,7 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.stage.FileChooser; @@ -66,6 +75,10 @@ public class ConnectionForm extends VBox { private final PasswordField passwordField = new PasswordField(); private final CheckBox rememberPasswordCheck = new CheckBox(Messages.get("connection.rememberPassword")); private final TextField urlField = new TextField(); + /** + * SSH tunnel of a server database, a database file has none + */ + private final SshForm sshForm = new SshForm(LABEL_WIDTH); private final Label statusLabel = new Label(); private final ProgressIndicator progressIndicator = new ProgressIndicator(); private final BooleanProperty busy = new SimpleBooleanProperty(false); @@ -73,13 +86,101 @@ public class ConnectionForm extends VBox { private final Label portLabel = new Label(Messages.get("connection.port")); private final HBox hostBox; + /** + * Category of the data source: a DbCategory (relational, OLAP) or a DataSourceExtension (vector databases), the + * types follow it, like the drivers of DBeaver + */ + private final ComboBox categoryCombo = new ComboBox<>(); + /** + * Types of the category of an extension, e.g. Qdrant + */ + private final ComboBox sourceTypeCombo = new ComboBox<>(); + private final List extensions; + private final Map sourceForms = new HashMap<>(); + private final VBox sourceBox = new VBox(); + private GridPane jdbcGrid; + private DataSourceExtension.SourceForm sourceForm; + private boolean switching; + /** + * A data source is being shown, its category is not a choice of the user + */ + private boolean loading; + private ConnectionProfile profile = new ConnectionProfile(); /** * The url and the fields are being updated by each other */ private boolean syncing; + /** + * Chooses the category of a new data source: a DbCategory, whose first type is chosen, or a + * DataSourceExtension + */ + public void setCategory(Object category) { + categoryCombo.setValue(category); + if (category instanceof DbCategory && !dbTypeCombo.getItems().isEmpty()) { + dbTypeCombo.setValue(dbTypeCombo.getItems().get(0)); + } + } + + public Object getCategory() { + return categoryCombo.getValue(); + } + + public ReadOnlyObjectProperty categoryProperty() { + return categoryCombo.valueProperty(); + } + + /** + * Whether the category may be changed, the category of a saved data source is kept + */ + public void setCategoryEditable(boolean editable) { + categoryCombo.setDisable(!editable); + } + + /** + * The extension of the chosen category, null for the relational and the OLAP databases + */ + public DataSourceExtension getSelectedExtension() { + return categoryCombo.getValue() instanceof DataSourceExtension ? (DataSourceExtension) categoryCombo + .getValue() : null; + } + + /** + * Shows a source of an extension, a new one of the extension if it is null + */ + public void setSource(DataSourceExtension extension, DataSourceExtension.Source source) { + loading = true; + try { + categoryCombo.setValue(extension); + } finally { + loading = false; + } + sourceForm.setSource(source); + nameField.setText(source != null ? source.getName() : ""); + sourceTypeCombo.setValue(sourceForm.getType()); + clearStatus(); + } + + /** + * Saves the source of the fields of an extension + */ + public DataSourceExtension.Source saveSource() throws IOException { + return sourceForm.save(nameField.getText()); + } + + static final double LABEL_WIDTH = 110; + public ConnectionForm() { + this(List.of()); + } + + /** + * @param extensions kinds of data sources other than the relational and the OLAP databases, offered as + * categories + */ + public ConnectionForm(List extensions) { + this.extensions = extensions; setSpacing(10); getStyleClass().add("connection-form"); nameField.setId("nameField"); @@ -95,10 +196,32 @@ public ConnectionForm() { urlField.setId("urlField"); statusLabel.setId("formStatusLabel"); - dbTypeCombo.getItems().addAll(DbTypeCells.getDbTypesByCategory()); + categoryCombo.setId("categoryCombo"); + sourceTypeCombo.setId("sourceTypeCombo"); + categoryCombo.getItems().addAll(DbCategory.values()); + categoryCombo.getItems().addAll(extensions); + categoryCombo.setMaxWidth(Double.MAX_VALUE); + categoryCombo.setCellFactory(view -> categoryCell()); + categoryCombo.setButtonCell(categoryCell()); + categoryCombo.valueProperty().addListener((obs, oldCategory, category) -> onCategoryChanged(category)); + sourceTypeCombo.setMaxWidth(Double.MAX_VALUE); + sourceTypeCombo.setCellFactory(view -> sourceTypeCell()); + sourceTypeCombo.setButtonCell(sourceTypeCell()); + sourceTypeCombo.valueProperty().addListener((obs, oldType, type) -> { + if (sourceForm != null && type != null) { + sourceForm.setType(type); + } + }); DbTypeCells.setupDbTypeCombo(dbTypeCombo); dbTypeCombo.setMaxWidth(Double.MAX_VALUE); - dbTypeCombo.valueProperty().addListener((obs, oldType, newType) -> onDbTypeChanged(oldType, newType)); + dbTypeCombo.valueProperty().addListener((obs, oldType, newType) -> { + // A type of another category chooses its category + if (newType != null && !switching && categoryCombo.getValue() != newType.getCategory()) { + categoryCombo.setValue(newType.getCategory()); + dbTypeCombo.setValue(newType); + } + onDbTypeChanged(oldType, newType); + }); portField.setTextFormatter(new TextFormatter(change -> change.getControlNewText().matches("\\d{0,5}") ? change : null)); portField.setPrefColumnCount(6); @@ -126,23 +249,22 @@ public ConnectionForm() { HBox databaseBox = new HBox(8, databaseField, browseButton); HBox.setHgrow(databaseField, Priority.ALWAYS); - GridPane grid = new GridPane(); - grid.setHgap(10); - grid.setVgap(10); - ColumnConstraints labelColumn = new ColumnConstraints(); - labelColumn.setMinWidth(110); - ColumnConstraints fieldColumn = new ColumnConstraints(); - fieldColumn.setHgrow(Priority.ALWAYS); - grid.getColumnConstraints().addAll(labelColumn, fieldColumn); + // Category and type first, the fields of the type follow them + GridPane headGrid = grid(); + StackPane typeBox = new StackPane(dbTypeCombo, sourceTypeCombo); + headGrid.addRow(0, new Label(Messages.get("connection.category")), categoryCombo); + headGrid.addRow(1, new Label(Messages.get("connection.dbType")), typeBox); + headGrid.addRow(2, new Label(Messages.get("connection.name")), nameField); + GridPane grid = grid(); + jdbcGrid = grid; int row = 0; - grid.addRow(row++, new Label(Messages.get("connection.name")), nameField); - grid.addRow(row++, new Label(Messages.get("connection.dbType")), dbTypeCombo); grid.addRow(row++, hostLabel, hostBox); grid.addRow(row++, databaseLabel, databaseBox); grid.addRow(row++, new Label(Messages.get("connection.username")), usernameField); grid.addRow(row++, new Label(Messages.get("connection.password")), passwordField); grid.add(rememberPasswordCheck, 1, row++); - grid.addRow(row, new Label(Messages.get("connection.url")), urlField); + grid.addRow(row++, new Label(Messages.get("connection.url")), urlField); + grid.add(sshForm, 0, row, 2, 1); // The url follows the fields, a pasted or edited url fills the fields, like DBeaver hostField.textProperty().addListener((obs, oldText, text) -> updateUrl()); @@ -153,10 +275,91 @@ public ConnectionForm() { HBox statusBox = new HBox(8, progressIndicator, statusLabel); statusBox.setPadding(new Insets(4, 0, 0, 0)); - getChildren().addAll(grid, statusBox); + getChildren().addAll(headGrid, grid, sourceBox, statusBox); setProfile(new ConnectionProfile(), true); } + private static GridPane grid() { + GridPane grid = new GridPane(); + grid.setHgap(10); + grid.setVgap(10); + ColumnConstraints labelColumn = new ColumnConstraints(); + labelColumn.setMinWidth(LABEL_WIDTH); + ColumnConstraints fieldColumn = new ColumnConstraints(); + fieldColumn.setHgrow(Priority.ALWAYS); + grid.getColumnConstraints().addAll(labelColumn, fieldColumn); + return grid; + } + + /** + * The types follow the category: types of a DbCategory with the JDBC fields, or types of an extension with + * its fields + */ + private void onCategoryChanged(Object category) { + boolean jdbc = !(category instanceof DataSourceExtension); + if (!loading) { + // Another category chosen by the user is a new data source, the shown one is not overwritten + profile = new ConnectionProfile(); + nameField.clear(); + } + jdbcGrid.setVisible(jdbc); + jdbcGrid.setManaged(jdbc); + dbTypeCombo.setVisible(jdbc); + sourceTypeCombo.setVisible(!jdbc); + sourceBox.getChildren().clear(); + if (jdbc) { + sourceForm = null; + DbType selected = dbTypeCombo.getValue(); + switching = true; + try { + dbTypeCombo.getItems().setAll(DbTypeCells.getDbTypesByCategory().stream().filter(type -> category + == null || type.getCategory() == category).collect(Collectors.toList())); + // The type is kept in its category, otherwise the first type of the category is chosen + dbTypeCombo.setValue(selected != null && dbTypeCombo.getItems().contains(selected) ? selected + : dbTypeCombo.getItems().isEmpty() ? null : dbTypeCombo.getItems().get(0)); + } finally { + switching = false; + } + } else { + DataSourceExtension extension = (DataSourceExtension) category; + sourceForm = sourceForms.computeIfAbsent(extension, key -> key.createForm(LABEL_WIDTH)); + if (!loading) { + sourceForm.setSource(null); + } + sourceBox.getChildren().add(sourceForm.getNode()); + sourceTypeCombo.getItems().setAll(sourceForm.getTypeNames()); + sourceTypeCombo.setValue(sourceForm.getType()); + } + clearStatus(); + } + + private static String categoryName(Object category) { + return category instanceof DataSourceExtension ? ((DataSourceExtension) category).getGroupName() + : category != null ? ((DbCategory) category).getDisplayName() : ""; + } + + private static ListCell categoryCell() { + return new ListCell<>() { + @Override + protected void updateItem(Object item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : categoryName(item)); + setGraphic(empty || item == null ? null : DbIcons.of(categoryName(item))); + } + }; + } + + private static ListCell sourceTypeCell() { + return new ListCell<>() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : item); + setGraphic(empty || item == null ? null : DbIcons.of(item)); + } + }; + } + private void onDbTypeChanged(DbType oldType, DbType newType) { if (newType == null) { return; @@ -172,6 +375,8 @@ private void onDbTypeChanged(DbType oldType, DbType newType) { hostLabel.setManaged(hostVisible); hostBox.setVisible(hostVisible); hostBox.setManaged(hostVisible); + sshForm.setVisible(!fileBased); + sshForm.setManaged(!fileBased); hostField.setPromptText(newType == DbType.H2 ? Messages.get("connection.host.h2.prompt") : "localhost"); // Files are chosen for H2/SQLite, databases of the server for MySQL/PostgreSQL/SQL Server boolean browsable = fileBased || newType.isCatalogSupported(); @@ -301,6 +506,12 @@ private void browseDatabases() { public void setProfile(ConnectionProfile profile, boolean passwordRemembered) { this.profile = profile.copy(); DbType dbType = profile.getDbType() != null ? profile.getDbType() : DbType.MYSQL; + loading = true; + try { + categoryCombo.setValue(DbTypeCells.categoryOf(profile)); + } finally { + loading = false; + } dbTypeCombo.setValue(null); portField.setText(profile.getPort() > 0 ? String.valueOf(profile.getPort()) : ""); dbTypeCombo.setValue(dbType); @@ -310,6 +521,7 @@ public void setProfile(ConnectionProfile profile, boolean passwordRemembered) { usernameField.setText(StringUtils.defaultString(profile.getUsername())); passwordField.setText(StringUtils.defaultString(profile.getPassword())); rememberPasswordCheck.setSelected(passwordRemembered); + sshForm.setSettings(profile.getSsh()); syncing = true; try { urlField.setText(StringUtils.isNotBlank(profile.getUrl()) ? profile.getUrl() : buildUrl()); @@ -334,6 +546,7 @@ public ConnectionProfile getProfile() { // The url of the fields is not kept, a url with its own options is String url = StringUtils.trimToNull(urlField.getText()); result.setUrl(url == null || url.equals(buildUrl()) ? null : url); + result.setSsh(dbType != null && dbType.isFileBased() ? null : sshForm.getSettings()); String name = StringUtils.trimToNull(nameField.getText()); if (name == null) { name = buildDefaultName(result); @@ -368,11 +581,18 @@ public void setRememberPasswordVisible(boolean visible) { * Validates required fields, returns the error message or null */ public String validate() { + if (sourceForm != null) { + return sourceForm.validate(); + } ConnectionProfile edited = getProfile(); if (edited.getDbType() == null) { return Messages.get("connection.error.dbType"); } DbType dbType = edited.getDbType(); + String sshError = dbType.isFileBased() ? null : sshForm.validate(); + if (sshError != null) { + return sshError; + } // PostgreSQL and Db2 connect to a database, Oracle to a service, H2/SQLite to a file; the database of MySQL // and SQL Server is the optional default database boolean databaseRequired = dbType == DbType.POSTGRESQL || dbType == DbType.ORACLE || dbType == DbType.DB2 @@ -434,6 +654,21 @@ public void testConnection(Consumer onSuccess) { showError(error); return; } + if (sourceForm != null) { + busy.set(true); + showInfo(Messages.get("connection.connecting")); + sourceForm.test(nameField.getText(), message -> { + busy.set(false); + showSuccess(message); + if (onSuccess != null) { + onSuccess.accept(message); + } + }, failure -> { + busy.set(false); + showError(failure); + }); + return; + } ConnectionProfile edited = getProfile(); busy.set(true); showInfo(Messages.get("connection.connecting")); 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 2b417c4..f295169 100644 --- a/src/main/java/com/github/sqljam/face/view/ConnectionTree.java +++ b/src/main/java/com/github/sqljam/face/view/ConnectionTree.java @@ -15,21 +15,32 @@ */ package com.github.sqljam.face.view; +import java.util.stream.Stream; +import java.util.Optional; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.function.Consumer; +import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.kordamp.ikonli.javafx.FontIcon; +import lombok.Setter; +import com.github.sqljam.utils.SshSettings; import com.github.sqljam.face.model.ConnectionProfile; import com.github.sqljam.face.model.TableInfo; import com.github.sqljam.face.service.DatabaseSession; +import com.github.sqljam.impexp.DbCategory; import com.github.sqljam.impexp.DbType; +import javafx.stage.Window; import javafx.application.Platform; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; import javafx.geometry.Pos; import javafx.scene.control.ContextMenu; import javafx.scene.control.Label; @@ -41,7 +52,6 @@ import javafx.scene.control.TreeView; import javafx.scene.input.MouseButton; import javafx.scene.layout.HBox; -import lombok.Setter; /** * @Description: ConnectionTree shows connections, catalogs, schemas and tables, children are loaded lazily in @@ -54,9 +64,27 @@ public class ConnectionTree extends TreeView { private final AppContext context; private final TreeItem rootItem = new TreeItem<>(DbNode.root()); + private final List extensions; + /** + * Items of all data sources and of the sources of the extensions, the tree shows those of the filter + */ + private final List> connectionItems = new ArrayList<>(); + private final Map>> sourceItems = new LinkedHashMap<>(); + private final Map> groupItems = new HashMap<>(); + private final StringProperty filter = new SimpleStringProperty(""); @Setter private Consumer onOpenTable; + /** + * Opens an item of a source of an extension, e.g. the tab of a collection + */ + @Setter + private Consumer onOpenSourceItem; + /** + * Opens the info of a data source or of a source of an extension + */ + @Setter + private Consumer onInfo; /** * Opens a SQL editor on the data source, catalog, schema or table of the node */ @@ -78,23 +106,158 @@ public class ConnectionTree extends TreeView { private Consumer onDisconnect; public ConnectionTree(AppContext context) { + this(context, DataSourceExtension.load(context.getSettingsStore().getConfig())); + } + + public ConnectionTree(AppContext context, List extensions) { this.context = context; + this.extensions = extensions; setRoot(rootItem); setShowRoot(false); getStyleClass().add("connection-tree"); setId("connectionTree"); setCellFactory(view -> new DbNodeCell()); + filter.addListener((obs, oldText, text) -> applyFilter()); reloadConnections(); } /** - * Reloads saved connections, expanded connections are collapsed + * Reloads saved connections in the groups of their categories (relational, OLAP), then the groups of the + * extensions (vector databases). Expanded connections are collapsed. */ public void reloadConnections() { - rootItem.getChildren().clear(); + connectionItems.clear(); for (ConnectionProfile profile : context.getProfileRegistry().getProfiles()) { - rootItem.getChildren().add(new LazyItem(DbNode.connection(profile))); + connectionItems.add(new LazyItem(DbNode.connection(profile))); + } + reloadSources(); + } + + /** + * Shows the data sources whose names or types contain the text, all for a blank text. The items are kept, a + * connection which is opened stays opened. Groups without data sources are not shown. + */ + public void setFilter(String text) { + filter.set(text); + } + + public String getFilter() { + return StringUtils.defaultString(filter.get()); + } + + public StringProperty filterProperty() { + return filter; + } + + private boolean matches(DbNode node) { + String text = StringUtils.trimToEmpty(filter.get()).toLowerCase(Locale.ENGLISH); + if (text.isEmpty()) { + return true; } + String type = node.getProfile() != null ? (node.getProfile().getDbType() != null ? node.getProfile() + .getDbType().getDisplayName() : "") : node.getSource().getTypeName(); + return node.getLabel().toLowerCase(Locale.ENGLISH).contains(text) || type.toLowerCase(Locale.ENGLISH) + .contains(text); + } + + /** + * Groups of the matching data sources and sources, in the order of the categories and of the extensions + */ + private void applyFilter() { + TreeItem selected = getSelectionModel().getSelectedItem(); + List> groups = new ArrayList<>(); + for (DbCategory category : DbCategory.values()) { + List> members = connectionItems.stream().filter(item -> DbTypeCells.categoryOf(item + .getValue().getProfile()) == category && matches(item.getValue())).collect(Collectors.toList()); + if (!members.isEmpty()) { + TreeItem group = groupItems.computeIfAbsent(category, key -> { + TreeItem created = new TreeItem<>(DbNode.group(category)); + created.setExpanded(true); + return created; + }); + group.getChildren().setAll(members); + groups.add(group); + } + } + for (Map.Entry>> entry : sourceItems.entrySet()) { + List> members = entry.getValue().stream().filter(item -> matches(item.getValue())) + .collect(Collectors.toList()); + if (!members.isEmpty()) { + TreeItem group = groupItems.computeIfAbsent(entry.getKey(), key -> { + TreeItem created = new TreeItem<>(DbNode.group(entry.getKey())); + created.setExpanded(true); + return created; + }); + group.getChildren().setAll(members); + groups.add(group); + } + } + rootItem.getChildren().setAll(groups); + if (selected != null && getRow(selected) >= 0) { + getSelectionModel().select(selected); + } + } + + /** + * Group of the data sources of a category, created in the order of the categories when it is missing: a + * category without data sources has no group + */ + TreeItem getGroup(DbCategory category) { + return groupItems.get(category); + } + + /** + * Items of the connections of all categories + */ + public List> getConnectionItems() { + List> items = new ArrayList<>(); + for (TreeItem group : rootItem.getChildren()) { + if (group.getValue().getCategory() != null) { + items.addAll(group.getChildren()); + } + } + return items; + } + + /** + * Reloads the groups of the sources of extensions (vector databases) after the groups of the categories + */ + public void reloadSources() { + sourceItems.clear(); + for (DataSourceExtension extension : extensions) { + List> items = new ArrayList<>(); + for (DataSourceExtension.Source source : extension.getSources()) { + // Its items (collections) are read when it is expanded, like the tables of a connection + items.add(new LazyItem(DbNode.source(extension, source))); + } + sourceItems.put(extension, items); + } + applyFilter(); + } + + /** + * Selects the source of the id in its group + */ + public void selectSource(String sourceId) { + if (sourceItems.values().stream().flatMap(List::stream).noneMatch(item -> item.getValue().getSource() + .getId().equals(sourceId) && matches(item.getValue()))) { + // A source hidden by the filter is shown + setFilter(""); + } + for (TreeItem group : rootItem.getChildren()) { + for (TreeItem item : group.getChildren()) { + if (item.getValue().getKind() == DbNode.Kind.SOURCE && item.getValue().getSource().getId() + .equals(sourceId)) { + getSelectionModel().select(item); + scrollTo(getRow(item)); + return; + } + } + } + } + + public List getExtensions() { + return extensions; } /** @@ -103,12 +266,13 @@ public void reloadConnections() { */ public void addNewConnections() { for (ConnectionProfile profile : context.getProfileRegistry().getProfiles()) { - boolean exists = rootItem.getChildren().stream().anyMatch(item -> item.getValue().getProfile().getId() - .equals(profile.getId())); + boolean exists = connectionItems.stream().anyMatch(item -> item.getValue().getProfile().getId().equals( + profile.getId())); if (!exists) { - rootItem.getChildren().add(new LazyItem(DbNode.connection(profile))); + connectionItems.add(new LazyItem(DbNode.connection(profile))); } } + applyFilter(); } /** @@ -129,6 +293,7 @@ public void disconnect(TreeItem item) { ConnectionProfile profile = item.getValue().getProfile(); ((LazyItem) item).unload(); context.getSessionManager().closeSession(profile.getId()); + rememberConnected(item.getValue(), false); if (onDisconnect != null) { onDisconnect.accept(profile); } @@ -137,16 +302,32 @@ public void disconnect(TreeItem item) { } } + /** + * The selected node of the relational data sources, null for a group or a source of an extension + */ public DbNode getSelectedNode() { TreeItem item = getSelectionModel().getSelectedItem(); - return item != null ? item.getValue() : null; + return item != null && item.getValue().getKind() != DbNode.Kind.GROUP && item.getValue().getKind() + != DbNode.Kind.SOURCE && item.getValue().getKind() != DbNode.Kind.SOURCE_ITEM ? item.getValue() : null; + } + + /** + * The selected source of an extension, e.g. a vector database + */ + public DbNode getSelectedSource() { + TreeItem item = getSelectionModel().getSelectedItem(); + return item != null && item.getValue().getKind() == DbNode.Kind.SOURCE ? item.getValue() : null; } /** * Selects and expands the connection of the profile */ public void selectConnection(String profileId) { - for (TreeItem item : rootItem.getChildren()) { + if (connectionItems.stream().noneMatch(item -> item.getValue().getProfile().getId().equals(profileId) + && matches(item.getValue()))) { + setFilter(""); + } + for (TreeItem item : getConnectionItems()) { if (item.getValue().getProfile().getId().equals(profileId)) { getSelectionModel().select(item); item.setExpanded(true); @@ -171,6 +352,18 @@ public void refreshSelected() { } private List> loadChildren(DbNode node) throws Exception { + if (node.getKind() == DbNode.Kind.SOURCE || node.getKind() == DbNode.Kind.SOURCE_ITEM) { + // Databases, collections and partitions as the source has them + List> items = new ArrayList<>(); + for (SourceItem item : node.getSource().loadItems(node.getItem())) { + DbNode child = DbNode.sourceItem(node.getExtension(), node.getSource(), item); + items.add(item.isLeaf() ? new TreeItem<>(child) : new LazyItem(child)); + } + if (items.isEmpty()) { + items.add(new TreeItem<>(DbNode.message(Messages.get("tree.empty")))); + } + return items; + } ConnectionProfile profile = node.getProfile(); DatabaseSession session = context.getSessionManager().getSession(profile); DbType dbType = profile.getDbType(); @@ -289,6 +482,140 @@ static String tableTooltip(TableInfo table) { /** * Tree item loading its children when expanded for the first time */ + /** + * Creates a data source of the category, which the form may change, and selects it: a DbCategory, an extension + * (vector databases) or null for the relational databases + */ + public void createConnection(Object category) { + ConnectionDialog dialog = new ConnectionDialog(getScene().getWindow(), context, null, category, extensions); + dialog.showAndWait().ifPresent(profile -> { + addNewConnections(); + selectConnection(profile.getId()); + }); + if (dialog.getSavedSource() != null) { + reloadSources(); + selectSource(dialog.getSavedSource().getId()); + } + } + + public void editSource(DbNode node) { + if (node.getSource().edit(getScene().getWindow(), context)) { + reloadSources(); + selectSource(node.getSource().getId()); + } + } + + public void deleteSource(DbNode node) { + if (node.getSource().delete(getScene().getWindow(), context)) { + reloadSources(); + } + } + + private Window getWindow() { + return getScene() != null ? getScene().getWindow() : null; + } + + /** + * The password of a data source which is not remembered is asked before it is connected, like DBeaver does. + * Database files without users (SQLite, DuckDB, Access) need none. + * + * @return false when it is cancelled + */ + boolean askMissingPassword(ConnectionProfile profile) { + if (!needsPassword(profile)) { + return true; + } + Optional credentials = CredentialsDialog.ask(getWindow(), profile.getName(), + Messages.get("connection.password"), StringUtils.defaultString(profile.getUsername()), null); + credentials.ifPresent(entered -> { + profile.setUsername(entered.getUsername()); + profile.setPassword(entered.getSecret()); + saveCredentials(profile, entered.isSaved()); + }); + return credentials.isPresent(); + } + + boolean needsPassword(ConnectionProfile profile) { + DbType dbType = profile.getDbType(); + return profile.getPassword() == null && dbType != null && (!dbType.isFileBased() || dbType == DbType.H2) + && !context.getProfileRegistry().isPasswordRemembered(profile.getId()); + } + + /** + * Asks again for the secret which was refused: the password of the SSH tunnel or the key, the password of the + * database, or the key of a source of an extension + * + * @return true when a new secret is entered and the node is connected again + */ + boolean askAfterFailure(DbNode node, Throwable error) { + if (node.getKind() == DbNode.Kind.SOURCE) { + return node.getSource().authenticate(getWindow(), error); + } + if (node.getKind() != DbNode.Kind.CONNECTION) { + return false; + } + ConnectionProfile profile = node.getProfile(); + String message = StringUtils.abbreviate(StringUtils.defaultString(error.getMessage()), 300); + Optional credentials; + if (profile.isSshEnabled() && AuthFailures.isSshFailure(error)) { + SshSettings ssh = profile.getSsh(); + boolean key = ssh.getAuthentication() == SshSettings.Authentication.KEY; + credentials = CredentialsDialog.ask(getWindow(), profile.getName(), Messages.get(key + ? "credentials.ssh.passphrase" : "credentials.ssh.password"), null, message); + credentials.ifPresent(entered -> ssh.setSecret(entered.getSecret())); + } else if (AuthFailures.isDatabaseFailure(error)) { + credentials = CredentialsDialog.ask(getWindow(), profile.getName(), Messages.get("connection.password"), + StringUtils.defaultString(profile.getUsername()), message); + credentials.ifPresent(entered -> { + profile.setUsername(entered.getUsername()); + profile.setPassword(entered.getSecret()); + }); + } else { + return false; + } + credentials.ifPresent(entered -> { + // The session of the refused secret is closed, the next one connects with the new secret + context.getSessionManager().closeSessionNow(profile.getId()); + saveCredentials(profile, entered.isSaved()); + }); + return credentials.isPresent(); + } + + private void saveCredentials(ConnectionProfile profile, boolean saved) { + try { + context.getProfileRegistry().saveProfile(profile, saved); + } catch (IOException e) { + Dialogs.showError(getWindow(), Messages.get("connection.saveError"), e); + } + } + + /** + * Data sources which are connected when the window is closed are connected again when it is opened + */ + void rememberConnected(DbNode node, boolean connected) { + String id = node.getProfile() != null ? node.getProfile().getId() : node.getSource().getId(); + List ids = context.getSettings().getConnectedSources(); + boolean changed = connected ? !ids.contains(id) && ids.add(id) : ids.remove(id); + if (changed) { + context.saveSettings(); + } + } + + /** + * Expands the data sources of the ids, which connects them, e.g. those connected when SqlJam was closed + */ + public void restoreConnected(List ids) { + for (String id : new ArrayList<>(ids)) { + Stream.concat(connectionItems.stream(), sourceItems.values().stream().flatMap(List::stream)) + .filter(item -> id.equals(item.getValue().getProfile() != null ? item.getValue().getProfile() + .getId() : item.getValue().getSource().getId())).findFirst() + .ifPresentOrElse(item -> item.setExpanded(true), () -> { + // A data source which was deleted meanwhile + context.getSettings().getConnectedSources().remove(id); + }); + } + } + private class LazyItem extends TreeItem { private boolean loaded; @@ -329,17 +656,43 @@ void reload() { } private void load() { + DbNode node = getValue(); + if (node.getKind() == DbNode.Kind.CONNECTION && needsPassword(node.getProfile())) { + // Asked after the expansion is handled, a dialog may not wait during a layout + loading = true; + Platform.runLater(() -> { + loading = false; + if (askMissingPassword(node.getProfile())) { + connect(node); + } else { + // Cancelled, nothing is connected + setExpanded(false); + } + }); + return; + } + connect(node); + } + + private void connect(DbNode node) { loading = true; - TaskRunner.run(() -> loadChildren(getValue()), children -> { + TaskRunner.run(() -> loadChildren(node), children -> { loading = false; loaded = true; getChildren().setAll(children); + if (node.getKind() == DbNode.Kind.CONNECTION || node.getKind() == DbNode.Kind.SOURCE) { + rememberConnected(node, true); + } }, e -> { loading = false; + // A secret which the database or the SSH server refused is asked again, then connected again + if (askAfterFailure(node, e)) { + connect(node); + return; + } getChildren().setAll(Collections.singletonList(new TreeItem<>(DbNode.message(Messages.format( "tree.error", e.getMessage()))))); - Dialogs.showError(getScene() != null ? getScene().getWindow() : null, - Messages.format("tree.loadError", getValue().getLabel()), e); + Dialogs.showError(getWindow(), Messages.format("tree.loadError", node.getLabel()), e); }); } } @@ -352,6 +705,10 @@ private class DbNodeCell extends TreeCell { if (node != null && node.getKind() == DbNode.Kind.TABLE && event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2 && onOpenTable != null) { onOpenTable.accept(node); + } else if (node != null && node.getKind() == DbNode.Kind.SOURCE_ITEM && node.getItem().getKind() + != SourceItem.Kind.DATABASE && event.getButton() == MouseButton.PRIMARY + && event.getClickCount() == 2 && onOpenSourceItem != null) { + onOpenSourceItem.accept(node); } }); } @@ -359,7 +716,7 @@ private class DbNodeCell extends TreeCell { @Override protected void updateItem(DbNode node, boolean empty) { super.updateItem(node, empty); - getStyleClass().removeAll("tree-message"); + getStyleClass().removeAll("tree-message", "tree-group"); if (empty || node == null) { setText(null); setGraphic(null); @@ -370,7 +727,7 @@ protected void updateItem(DbNode node, boolean empty) { setText(node.getLabel()); switch (node.getKind()) { case CONNECTION: - setGraphic(Icons.of(Icons.CONNECTION)); + setGraphic(DbTypeCells.iconOf(node.getProfile())); break; case CATALOG: setGraphic(Icons.of(Icons.CATALOG)); @@ -391,6 +748,18 @@ protected void updateItem(DbNode node, boolean empty) { setGraphic(tableIcon(table)); } break; + case GROUP: + setGraphic(DbIcons.of(node.getLabel())); + getStyleClass().add("tree-group"); + break; + case SOURCE: + setGraphic(DbIcons.of(node.getSource().getTypeName())); + break; + case SOURCE_ITEM: + SourceItem.Kind itemKind = node.getItem().getKind(); + setGraphic(Icons.of(itemKind == SourceItem.Kind.DATABASE ? Icons.CATALOG + : itemKind == SourceItem.Kind.PARTITION ? Icons.PARTITION : Icons.TABLE)); + break; case LOADING: setGraphic(Icons.of(Icons.LOADING)); getStyleClass().add("tree-message"); @@ -400,7 +769,9 @@ protected void updateItem(DbNode node, boolean empty) { getStyleClass().add("tree-message"); break; } - String tooltip = node.getKind() == DbNode.Kind.TABLE ? tableTooltip(node.getTable()) : null; + String tooltip = node.getKind() == DbNode.Kind.TABLE ? tableTooltip(node.getTable()) + : node.getKind() == DbNode.Kind.SOURCE ? node.getSource().getTypeName() + : node.getKind() == DbNode.Kind.SOURCE_ITEM ? node.getItem().getKindName() : null; if (tooltip != null) { setTooltip(new Tooltip(tooltip)); } else { @@ -426,6 +797,7 @@ private ContextMenu createContextMenu(DbNode node) { }); menu.getItems().addAll(connect, disconnect, menuItem("action.refresh", Icons.REFRESH, ConnectionTree.this::refreshSelected), + menuItem("action.info", Icons.INFO, () -> fire(onInfo, node)), new SeparatorMenuItem(), menuItem("action.sqlQuery", Icons.SQL, () -> fire(onSqlQuery, node)), menuItem("action.export", Icons.EXPORT, () -> fire(onExport, node)), @@ -451,6 +823,43 @@ private ContextMenu createContextMenu(DbNode node) { menuItem("action.sqlQuery", Icons.SQL, () -> fire(onSqlQuery, node)), menuItem("action.export", Icons.EXPORT, () -> fire(onExport, node))); break; + case GROUP: + if (node.getExtension() != null) { + menu.getItems().addAll( + menuItem("connection.kind.new", Icons.ADD, () -> createConnection(node.getExtension())), + menuItem("action.refresh", Icons.REFRESH, ConnectionTree.this::reloadSources)); + } else { + menu.getItems().addAll( + menuItem("connection.kind.new", Icons.ADD, () -> createConnection(node.getCategory())), + menuItem("action.refresh", Icons.REFRESH, ConnectionTree.this::reloadConnections)); + } + break; + case SOURCE: + MenuItem open = new MenuItem(node.getExtension().getOpenText(), Icons.of(Icons.IMPORT)); + open.setId("openSourceMenuItem"); + open.setOnAction(event -> node.getSource().open(getScene().getWindow(), context)); + menu.getItems().addAll(open, menuItem("action.refresh", Icons.REFRESH, + ConnectionTree.this::refreshSelected), menuItem("action.info", Icons.INFO, + () -> fire(onInfo, node)), new SeparatorMenuItem(), + menuItem("action.editConnection", Icons.EDIT, () -> editSource(node)), + menuItem("action.deleteConnection", Icons.DELETE, () -> deleteSource(node))); + break; + case SOURCE_ITEM: + MenuItem importItem = new MenuItem(node.getExtension().getOpenText(), Icons.of(Icons.IMPORT)); + importItem.setId("importSourceItemMenuItem"); + importItem.setOnAction(event -> node.getSource().open(List.of(node.getItem()), + getScene().getWindow(), context)); + if (node.getItem().getKind() == SourceItem.Kind.DATABASE) { + menu.getItems().addAll(importItem, menuItem("action.refresh", Icons.REFRESH, + ConnectionTree.this::refreshSelected)); + } else { + MenuItem openItem = new MenuItem(node.getExtension().getOpenItemText(), + Icons.of(Icons.TABLE)); + openItem.setId("openSourceItemMenuItem"); + openItem.setOnAction(event -> fire(onOpenSourceItem, node)); + menu.getItems().addAll(openItem, importItem); + } + break; default: return null; } diff --git a/src/main/java/com/github/sqljam/face/view/CredentialsDialog.java b/src/main/java/com/github/sqljam/face/view/CredentialsDialog.java new file mode 100644 index 0000000..4cb44de --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/CredentialsDialog.java @@ -0,0 +1,129 @@ +/* + * 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.Optional; + +import org.apache.commons.lang3.StringUtils; +import javafx.application.Platform; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Dialog; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.stage.Window; + +/** + * @Description: CredentialsDialog asks for the secret of a data source when it is connected without one, or when + * the database refused it: the password of a database, the password or passphrase of its SSH tunnel, + * or the API key of a vector database. Save keeps it, otherwise it is kept for the session, like the + * authentication dialog of DBeaver. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class CredentialsDialog extends Dialog { + + /** + * The user and the secret which were entered, and whether they are saved + */ + public static final class Credentials { + + private final String username; + private final String secret; + private final boolean saved; + + public Credentials(String username, String secret, boolean saved) { + this.username = username; + this.secret = secret; + this.saved = saved; + } + + public String getUsername() { + return username; + } + + public String getSecret() { + return secret; + } + + public boolean isSaved() { + return saved; + } + } + + private final TextField usernameField = new TextField(); + private final PasswordField secretField = new PasswordField(); + private final CheckBox saveCheck = new CheckBox(Messages.get("credentials.save")); + + /** + * @param name name of the data source + * @param secretLabel label of the secret, e.g. Password, API key or Passphrase + * @param username user which is shown, null for a secret without a user (API key, passphrase) + * @param message why it is asked, e.g. the error of the database, null when the secret is missing + */ + public CredentialsDialog(Window owner, String name, String secretLabel, String username, String message) { + Dialogs.initOwner(this, owner); + Branding.applyIcons(this); + setTitle(Messages.get("credentials.title")); + setHeaderText(Messages.format("credentials.header", name)); + usernameField.setId("credentialsUsernameField"); + secretField.setId("credentialsSecretField"); + saveCheck.setId("credentialsSaveCheck"); + usernameField.setText(StringUtils.defaultString(username)); + + GridPane grid = new GridPane(); + grid.setHgap(10); + grid.setVgap(10); + ColumnConstraints labelColumn = new ColumnConstraints(); + labelColumn.setMinWidth(110); + ColumnConstraints fieldColumn = new ColumnConstraints(); + fieldColumn.setHgrow(Priority.ALWAYS); + grid.getColumnConstraints().addAll(labelColumn, fieldColumn); + int row = 0; + if (message != null) { + Label messageLabel = new Label(message); + messageLabel.setId("credentialsMessageLabel"); + messageLabel.getStyleClass().add("status-error"); + messageLabel.setWrapText(true); + messageLabel.setMaxWidth(420); + grid.add(messageLabel, 0, row++, 2, 1); + } + if (username != null) { + grid.addRow(row++, new Label(Messages.get("connection.username")), usernameField); + } + grid.addRow(row++, new Label(secretLabel), secretField); + grid.add(saveCheck, 1, row); + grid.setPrefWidth(440); + getDialogPane().setContent(grid); + getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + setResultConverter(button -> button == ButtonType.OK ? new Credentials(username != null ? StringUtils + .trimToNull(usernameField.getText()) : null, secretField.getText(), saveCheck.isSelected()) : null); + Platform.runLater(secretField::requestFocus); + } + + /** + * Asks for the secret, empty when it is cancelled + */ + public static Optional ask(Window owner, String name, String secretLabel, String username, + String message) { + return new CredentialsDialog(owner, name, secretLabel, username, message).showAndWait(); + } +} diff --git a/src/main/java/com/github/sqljam/face/view/DashboardPane.java b/src/main/java/com/github/sqljam/face/view/DashboardPane.java new file mode 100644 index 0000000..89e34ec --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/DashboardPane.java @@ -0,0 +1,368 @@ +/* + * 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.time.Instant; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.service.DashboardSampler; +import com.github.sqljam.face.service.MetricSource; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.chart.LineChart; +import javafx.scene.chart.NumberAxis; +import javafx.scene.chart.XYChart; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.TilePane; +import javafx.scene.layout.VBox; +import javafx.util.StringConverter; +import lombok.extern.slf4j.Slf4j; + +/** + * @Description: DashboardPane shows the metrics of a data source in live line charts like the dashboards of DBeaver: + * a sample every few seconds while it is shown, the last 5 minutes of each chart. Charts of metrics + * which the server does not tell are left out and listed with their reasons. A lost connection is told + * and opened again by the next sample. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Slf4j +public class DashboardPane extends BorderPane { + + static final int[] INTERVALS = {1, 2, 5, 10, 30}; + static final int WINDOW_SECONDS = 300; + private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm:ss"); + + private final Supplier sourceFactory; + private final ComboBox intervalCombo = new ComboBox<>(); + private final Button pauseButton = new Button(Messages.get("dashboard.pause")); + private final Label statusLabel = new Label(); + private final Label unavailableLabel = new Label(); + private final TilePane charts = new TilePane(); + private final Map> chartViews = new LinkedHashMap<>(); + private final Map> lines = new LinkedHashMap<>(); + private DashboardSampler sampler; + private ScheduledExecutorService executor; + private ScheduledFuture future; + private boolean paused; + private long startTime; + + public DashboardPane(Supplier sourceFactory) { + this.sourceFactory = sourceFactory; + setId("dashboardPane"); + intervalCombo.setId("dashboardIntervalCombo"); + pauseButton.setId("dashboardPauseButton"); + statusLabel.setId("dashboardStatusLabel"); + unavailableLabel.setId("dashboardUnavailableLabel"); + statusLabel.getStyleClass().add("muted"); + unavailableLabel.getStyleClass().add("muted"); + unavailableLabel.setWrapText(true); + for (int interval : INTERVALS) { + intervalCombo.getItems().add(interval); + } + intervalCombo.setConverter(new StringConverter<>() { + @Override + public String toString(Integer seconds) { + return seconds == null ? "" : Messages.format("dashboard.every", seconds); + } + + @Override + public Integer fromString(String string) { + return null; + } + }); + intervalCombo.setValue(2); + intervalCombo.valueProperty().addListener((observable, old, interval) -> { + if (future != null) { + schedule(); + } + }); + pauseButton.setOnAction(event -> setPaused(!paused)); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + HBox bar = new HBox(8, new Label(Messages.get("dashboard.sample")), intervalCombo, pauseButton, spacer, + statusLabel); + bar.setAlignment(Pos.CENTER_LEFT); + bar.setPadding(new Insets(8)); + charts.setId("dashboardCharts"); + charts.setHgap(12); + charts.setVgap(12); + charts.setPadding(new Insets(8)); + charts.setPrefTileWidth(460); + charts.setPrefTileHeight(260); + ScrollPane scroll = new ScrollPane(new VBox(8, charts, unavailableLabel)); + scroll.setFitToWidth(true); + setTop(bar); + setCenter(scroll); + } + + /** + * Samples while the dashboard is shown + */ + public void start() { + if (future != null || paused) { + return; + } + if (executor == null) { + executor = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "sqljam-dashboard"); + thread.setDaemon(true); + return thread; + }); + } + schedule(); + } + + private void schedule() { + if (future != null) { + future.cancel(false); + } + future = executor.scheduleWithFixedDelay(this::sampleOnce, 0, intervalCombo.getValue(), TimeUnit.SECONDS); + } + + /** + * Stops sampling, e.g. when another page is shown, the charts stay + */ + public void stop() { + if (future != null) { + future.cancel(false); + future = null; + } + } + + /** + * Stops sampling and closes the connection of the metrics + */ + public void close() { + stop(); + if (executor != null) { + ScheduledExecutorService closing = executor; + executor = null; + closing.execute(() -> { + if (sampler != null) { + sampler.getSource().close(); + } + }); + closing.shutdown(); + } + } + + void setPaused(boolean paused) { + this.paused = paused; + pauseButton.setText(Messages.get(paused ? "dashboard.resume" : "dashboard.pause")); + if (paused) { + stop(); + } else { + start(); + } + } + + /** + * One sample in the background, the charts are updated on the FX thread + */ + void sampleOnce() { + try { + if (sampler == null) { + sampler = new DashboardSampler(sourceFactory.get()); + MetricSource source = sampler.getSource(); + Platform.runLater(() -> createCharts(source.getCharts())); + } + DashboardSampler.Sample sample = sampler.sample(); + Map unavailable = sampler.getSource().getUnavailable(); + Platform.runLater(() -> show(sample, unavailable)); + } catch (Exception e) { + String message = StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()); + log.debug("A sample of the dashboard failed: {}", message); + Platform.runLater(() -> { + statusLabel.getStyleClass().setAll("label", "status-error"); + statusLabel.setText(Messages.format("dashboard.disconnected", message)); + }); + } + } + + private void createCharts(List definitions) { + charts.getChildren().clear(); + chartViews.clear(); + lines.clear(); + startTime = System.currentTimeMillis(); + for (MetricSource.Chart definition : definitions) { + NumberAxis time = new NumberAxis(); + time.setAutoRanging(false); + time.setForceZeroInRange(false); + time.setTickLabelFormatter(new StringConverter<>() { + @Override + public String toString(Number seconds) { + return LocalTime.ofInstant(Instant.ofEpochMilli(startTime + seconds.longValue() * 1000), ZoneId + .systemDefault()).format(TIME); + } + + @Override + public Number fromString(String string) { + return 0; + } + }); + NumberAxis values = new NumberAxis(); + values.setTickLabelFormatter(new StringConverter<>() { + @Override + public String toString(Number value) { + return format(definition.getUnit(), value.doubleValue()); + } + + @Override + public Number fromString(String string) { + return 0; + } + }); + if (definition.getUnit() == MetricSource.Unit.PERCENT) { + values.setAutoRanging(false); + values.setLowerBound(0); + values.setUpperBound(100); + values.setTickUnit(25); + } + LineChart chart = new LineChart<>(time, values); + chart.setTitle(definition.getTitle() + unitSuffix(definition.getUnit())); + chart.setCreateSymbols(false); + chart.setAnimated(false); + chart.setLegendVisible(definition.getSeries().size() > 1); + chart.getStyleClass().add("dashboard-chart"); + for (MetricSource.Series series : definition.getSeries()) { + XYChart.Series line = new XYChart.Series<>(); + line.setName(series.getName()); + chart.getData().add(line); + lines.put(definition.getTitle() + "/" + series.getName(), line); + } + chartViews.put(definition, chart); + charts.getChildren().add(chart); + } + } + + private void show(DashboardSampler.Sample sample, Map unavailable) { + double seconds = (sample.getTime() - startTime) / 1000.0; + for (Map.Entry> entry : chartViews.entrySet()) { + MetricSource.Chart definition = entry.getKey(); + for (MetricSource.Series series : definition.getSeries()) { + Double value = sample.get(definition, series); + if (value == null) { + continue; + } + XYChart.Series line = lines.get(definition.getTitle() + "/" + series.getName()); + line.getData().add(new XYChart.Data<>(seconds, value)); + // The last 5 minutes + while (!line.getData().isEmpty() && line.getData().get(0).getXValue().doubleValue() < seconds + - WINDOW_SECONDS) { + line.getData().remove(0); + } + } + NumberAxis time = (NumberAxis) entry.getValue().getXAxis(); + time.setLowerBound(Math.max(0, seconds - WINDOW_SECONDS)); + time.setUpperBound(Math.max(seconds, 10)); + time.setTickUnit(Math.max(10, Math.round((time.getUpperBound() - time.getLowerBound()) / 5))); + // A chart whose metrics the server does not tell at all is left out + boolean shown = definition.getSeries().stream().anyMatch(series -> series.getKeys().stream().noneMatch( + unavailable::containsKey)); + entry.getValue().setVisible(shown); + entry.getValue().setManaged(shown); + } + statusLabel.getStyleClass().setAll("label", "muted"); + statusLabel.setText(Messages.format("dashboard.sampled", LocalTime.ofInstant(Instant.ofEpochMilli(sample + .getTime()), ZoneId.systemDefault()).format(TIME))); + List missing = new ArrayList<>(); + for (MetricSource.Chart definition : chartViews.keySet()) { + for (MetricSource.Series series : definition.getSeries()) { + series.getKeys().stream().filter(unavailable::containsKey).findFirst().ifPresent(key -> missing.add( + definition.getTitle() + " / " + series.getName() + ": " + StringUtils.abbreviate(unavailable + .get(key), 160))); + } + } + unavailableLabel.setText(missing.isEmpty() ? "" : Messages.get("dashboard.unavailable") + "\n" + missing + .stream().collect(Collectors.joining("\n"))); + } + + static String unitSuffix(MetricSource.Unit unit) { + switch (unit) { + case PER_SECOND: + return " (/s)"; + case BYTES_PER_SECOND: + return " (bytes/s)"; + case PERCENT: + return " (%)"; + case MILLIS: + return " (ms)"; + default: + return ""; + } + } + + /** + * A value of the axis in its unit, e.g. 1.5 MB or 12.3 + */ + static String format(MetricSource.Unit unit, double value) { + if (unit == MetricSource.Unit.BYTES || unit == MetricSource.Unit.BYTES_PER_SECOND) { + String[] units = {"B", "KB", "MB", "GB", "TB"}; + int index = 0; + double size = value; + while (Math.abs(size) >= 1024 && index < units.length - 1) { + size /= 1024; + index++; + } + return index == 0 ? String.format(Locale.ENGLISH, "%.0f B", size) : String.format(Locale.ENGLISH, + "%.1f %s", size, units[index]); + } + if (Math.abs(value) >= 1_000_000) { + return String.format(Locale.ENGLISH, "%.1fM", value / 1_000_000); + } + if (Math.abs(value) >= 10_000) { + return String.format(Locale.ENGLISH, "%.0fk", value / 1000); + } + return value == Math.rint(value) ? String.format(Locale.ENGLISH, "%.0f", value) : String.format(Locale + .ENGLISH, "%.1f", value); + } + + List> getCharts() { + return new ArrayList<>(chartViews.values()); + } + + Label getUnavailableLabel() { + return unavailableLabel; + } + + Label getStatusLabel() { + return statusLabel; + } +} 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 4f56fcc..12416f8 100644 --- a/src/main/java/com/github/sqljam/face/view/DataPane.java +++ b/src/main/java/com/github/sqljam/face/view/DataPane.java @@ -29,6 +29,7 @@ import com.github.sqljam.face.service.DatabaseSession; import com.github.sqljam.impexp.TableQuery; import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.collections.ObservableList; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; @@ -38,7 +39,6 @@ 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; @@ -66,6 +66,10 @@ public class DataPane extends BorderPane { private final String schema; private final String table; private final TableView> dataTable = new TableView<>(); + /** + * Rows of the page, its headers sort them on the page, ORDER BY sorts all rows + */ + private final ObservableList> dataRows = TableColumns.sortedRows(dataTable); private final Button firstButton = new Button(null, Icons.of(Icons.FIRST)); private final Button previousButton = new Button(null, Icons.of(Icons.PREVIOUS)); private final Button nextButton = new Button(null, Icons.of(Icons.NEXT)); @@ -99,6 +103,8 @@ public DataPane(DatabaseSession session, String catalog, String schema, String t dataTable.setPlaceholder(new Label(Messages.get("tree.loading"))); dataTable.getStyleClass().add("data-table"); dataTable.setId("dataTable"); + // The columns fill the width, no empty column is left at the right + TableColumns.fill(dataTable); firstButton.setId("firstButton"); previousButton.setId("previousButton"); nextButton.setId("nextButton"); @@ -296,7 +302,7 @@ public void load(int page) { TaskRunner.run(() -> session.getData(catalog, schema, table, currentQuery, targetPage, pageSize), this::show, e -> { updatePager(false); - dataTable.getItems().clear(); + dataRows.clear(); dataTable.setPlaceholder(new Label(Messages.format("tree.error", e.getMessage()))); if (currentQuery.isEmpty()) { Dialogs.showError(getScene() != null ? getScene().getWindow() : null, @@ -320,10 +326,11 @@ private void show(DataPage page) { column.setCellValueFactory(features -> new ReadOnlyObjectWrapper<>(features.getValue().get(index))); column.setCellFactory(tableColumn -> new NullableCell()); column.setPrefWidth(getColumnWidth(columns.get(i), page.getRows(), index)); + column.setComparator(ValueCell.COMPARATOR); dataTable.getColumns().add(column); } } - dataTable.getItems().setAll(page.getRows()); + dataRows.setAll(page.getRows()); dataTable.setPlaceholder(new Label(Messages.get("data.empty"))); totalLabel.setText(Messages.format("data.total", page.getTotalRows())); updatePager(false); @@ -332,7 +339,7 @@ private void show(DataPage page) { /** * Column width estimated by the header and values of the first rows */ - static double getColumnWidth(String header, List> rows, int index) { + public static double getColumnWidth(String header, List> rows, int index) { int length = header.length(); for (int i = 0; i < Math.min(rows.size(), 50); i++) { String value = rows.get(i).get(index); @@ -363,22 +370,13 @@ static String format(String value) { } /** - * Cell showing NULL values in a dimmed style + * Cell showing NULL values in a dimmed style, a value in one line which a double click shows whole */ - static class NullableCell extends TableCell, String> { + static class NullableCell extends ValueCell { @Override - protected void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - getStyleClass().remove("null-cell"); - if (empty) { - setText(null); - } else if (item == null) { - setText(NULL_TEXT); - getStyleClass().add("null-cell"); - } else { - setText(format(item)); - } + protected String display(String item) { + return format(item); } } } diff --git a/src/main/java/com/github/sqljam/face/view/DataSourceExtension.java b/src/main/java/com/github/sqljam/face/view/DataSourceExtension.java new file mode 100644 index 0000000..0276d13 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/DataSourceExtension.java @@ -0,0 +1,220 @@ +/* + * 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.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.ServiceLoader; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.github.sqljam.config.Config; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.model.SyncTask; +import com.github.sqljam.face.service.SyncEngine; +import javafx.scene.Node; +import javafx.scene.control.Tab; +import javafx.stage.Window; + +/** + * @Description: DataSourceExtension is a kind of data source other than the relational and the OLAP databases, + * found by ServiceLoader, e.g. the vector databases of the vdb line. The data source form offers it as a + * category whose types follow it in the type list and whose fields replace the JDBC fields, the login + * page and the connection tree list its sources in a group of their own. Its own settings switch it + * on or off, and the classes of its line are loaded only when one of its sources is used. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public interface DataSourceExtension { + + /** + * A data source of the kind, shown in the connection tree + */ + interface Source { + + String getId(); + + String getName(); + + /** + * Type of the source, e.g. Qdrant + */ + String getTypeName(); + + /** + * The main action of the source, e.g. the import of its collections + */ + void open(Window owner, AppContext context); + + /** + * Children of an item of the source in the connection tree, the top items for null: databases, + * collections and their partitions as the source has them, read in the background + */ + List loadItems(SourceItem parent) throws Exception; + + /** + * Tab of a collection or of a partition in the tabs of the main window: its fields and its records + */ + Tab openItem(SourceItem item, Window owner, AppContext context); + + /** + * The main action of some items of the source, e.g. the import of some collections or partitions + */ + void open(List items, Window owner, AppContext context); + + /** + * Overview of the source by sections, read in the background + */ + Map> loadOverview() throws Exception; + + /** + * Health of the source by sections, read in the background + */ + Map> loadHealth() throws Exception; + + /** + * Edits the source, true if it is saved + */ + boolean edit(Window owner, AppContext context); + + /** + * Deletes the source after a confirmation, true if it is deleted + */ + boolean delete(Window owner, AppContext context); + + /** + * Asks for the secret which the source refused (e.g. an API key which is not remembered), true when a new + * one is entered and the source is read again + */ + default boolean authenticate(Window owner, Throwable error) { + return false; + } + } + + /** + * Fields of a source of the kind below its name and type, shown by the data source form + */ + interface SourceForm { + + Node getNode(); + + /** + * Type names of the kind, e.g. Qdrant, in the order of the type list + */ + List getTypeNames(); + + void setType(String typeName); + + String getType(); + + /** + * Shows a source, a new one if it is null + */ + void setSource(Source source); + + /** + * Error of the fields, null if they are valid + */ + String validate(); + + /** + * Saves the source of the fields, a new source or the shown one + */ + Source save(String name) throws IOException; + + /** + * Connects in the background + */ + void test(String name, Consumer onSuccess, Consumer onError); + + void focusFirstField(); + } + + /** + * Fields of the sources of the kind + * + * @param labelWidth width of the labels of the form around them + */ + SourceForm createForm(double labelWidth); + + /** + * Name of the group of the sources in the connection tree, e.g. Vector databases + */ + String getGroupName(); + + /** + * Text of the main action of a source in its menu, e.g. Import into Database... + */ + String getOpenText(); + + /** + * Text of opening an item of a source, e.g. Open Collection + */ + String getOpenItemText(); + + boolean isEnabled(Config config); + + List getSources(); + + /** + * Sync tasks of the sources of the kind: the engine which runs them, and the parts which the task dialog lists + */ + interface SyncSupport { + + /** + * Kind of the tasks of the sources + */ + SyncTask.Kind getKind(); + + SyncEngine createEngine(Function> profiles); + + /** + * Databases of a source, empty for sources without databases + */ + List getDatabases(String sourceId) throws Exception; + + List getCollections(String sourceId, String database) throws Exception; + + /** + * Fields of a collection which can be watermarks: numbers and times + */ + List getWatermarkFields(String sourceId, String database, String collection) throws Exception; + } + + /** + * Sync tasks of the sources, null for kinds without + */ + default SyncSupport getSyncSupport() { + return null; + } + + /** + * Extensions which their settings switch on + */ + static List load(Config config) { + List extensions = new ArrayList<>(); + for (DataSourceExtension extension : ServiceLoader.load(DataSourceExtension.class)) { + if (extension.isEnabled(config)) { + extensions.add(extension); + } + } + return extensions; + } +} diff --git a/src/main/java/com/github/sqljam/face/view/DbIcons.java b/src/main/java/com/github/sqljam/face/view/DbIcons.java new file mode 100644 index 0000000..8c1f3d1 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/DbIcons.java @@ -0,0 +1,252 @@ +/* + * 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.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; + +import org.kordamp.ikonli.javafx.FontIcon; +import javafx.geometry.Pos; +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Rectangle; +import javafx.scene.shape.SVGPath; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.scene.text.Text; + +/** + * @Description: DbIcons are the colored icons of the databases: the logo of a database on a rounded tile of its + * brand color (SVG paths of Simple Icons in db-icons.properties), or the image of its logo (the avatar + * of the GitHub organization of the project), a badge of its short name for an unknown one. Categories + * have a glyph on a tile of their color. Types are named by their display names, e.g. PostgreSQL or Qdrant, + * so that the icons of other lines (vector databases) need no class of them. Categories have badges. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class DbIcons { + + public static final double SIZE = 18; + + /** + * Color of the badge, color of the text and the short name of a database + */ + private static final class Badge { + final Color background; + final Color foreground; + final String text; + + Badge(String background, String foreground, String text) { + this.background = Color.web(background); + this.foreground = Color.web(foreground); + this.text = text; + } + } + + private static final Map BADGES = new HashMap<>(); + /** + * Logos by the names of the databases: tile color, logo color and SVG path of 24x24 + */ + private static final Map LOGOS = new HashMap<>(); + + static { + Properties logos = new Properties(); + try (InputStream in = DbIcons.class.getResourceAsStream("/com/github/sqljam/face/images/db-icons.properties")) { + if (in != null) { + logos.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + } + } catch (IOException e) { + // Badges are drawn instead + } + logos.stringPropertyNames().forEach(name -> LOGOS.put(name.toLowerCase(Locale.ENGLISH), logos.getProperty( + name).split("\\|", 3))); + } + + static { + // Relational databases + put("MySQL", "#00758F", "#FFFFFF", "My"); + put("MariaDB", "#C0765A", "#FFFFFF", "Ma"); + put("PostgreSQL", "#336791", "#FFFFFF", "Pg"); + put("Oracle", "#C74634", "#FFFFFF", "Or"); + put("SQL Server", "#CC2927", "#FFFFFF", "SS"); + put("Db2", "#054ADA", "#FFFFFF", "D2"); + put("H2", "#0D47A1", "#FFFFFF", "H2"); + put("SQLite", "#0F80CC", "#FFFFFF", "SL"); + put("Access", "#A4373A", "#FFFFFF", "Ac"); + // OLAP databases + put("DuckDB", "#FFC600", "#1A1A1A", "Du"); + put("ClickHouse", "#FAFF69", "#1A1A1A", "CH"); + // Vector databases + put("Milvus", "#00A1EA", "#FFFFFF", "Mv"); + put("Qdrant", "#DC244C", "#FFFFFF", "Qd"); + put("Weaviate", "#61BD73", "#FFFFFF", "Wv"); + put("Pinecone", "#2B2B2B", "#FFFFFF", "Pc"); + put("Elasticsearch", "#00BFB3", "#FFFFFF", "ES"); + put("OpenSearch", "#005EB8", "#FFFFFF", "OS"); + put("Chroma", "#FF6446", "#FFFFFF", "Ch"); + // Categories + put("Relational databases", "#3B82F6", "#FFFFFF", "R"); + put("OLAP databases", "#F59E0B", "#1A1A1A", "O"); + put("Vector databases", "#8B5CF6", "#FFFFFF", "V"); + } + + private DbIcons() { + } + + /** + * Whether the database has a logo, not a badge + */ + public static boolean hasLogo(String name) { + return name != null && LOGOS.containsKey(name.toLowerCase(Locale.ENGLISH)); + } + + static final String IMAGE = "png:"; + private static final Map IMAGES = new HashMap<>(); + /** + * Icons of the categories by their names + */ + private static final Map CATEGORIES = Map.of( + "relational databases", "fth-database", + "olap databases", "fth-bar-chart-2", + "vector databases", "fth-share-2"); + + /** + * An image of a logo (png:file|full or png:file|tile): full images fill the tile, others are inset on a white + * tile, the tile is rounded + */ + private static Node image(String[] logo) { + boolean full = logo.length > 1 && "full".equals(logo[1]); + // Images are read once, the cells of lists and trees share them + Image image = IMAGES.computeIfAbsent(logo[0], key -> new Image(DbIcons.class.getResourceAsStream( + "/com/github/sqljam/face/images/db/" + key.substring(IMAGE.length())), 48, 48, true, true)); + ImageView view = new ImageView(image); + double size = full ? SIZE : SIZE - 3; + view.setFitWidth(size); + view.setFitHeight(size); + view.setSmooth(true); + Rectangle clip = new Rectangle(size, size); + clip.setArcWidth(full ? 6 : 4); + clip.setArcHeight(full ? 6 : 4); + view.setClip(clip); + Rectangle tile = new Rectangle(SIZE, SIZE); + tile.setArcWidth(6); + tile.setArcHeight(6); + tile.setFill(Color.WHITE); + StackPane icon = full ? new StackPane(view) : new StackPane(tile, view); + icon.setAlignment(Pos.CENTER); + icon.setMinSize(SIZE, SIZE); + icon.setMaxSize(SIZE, SIZE); + icon.getStyleClass().addAll("db-icon", "db-logo"); + return icon; + } + + /** + * Icon of a category: its glyph on a tile of its color + */ + private static Node category(String name, String literal) { + Badge badge = BADGES.get(name.toLowerCase(Locale.ENGLISH)); + Rectangle tile = new Rectangle(SIZE, SIZE); + tile.setArcWidth(6); + tile.setArcHeight(6); + tile.setFill(badge != null ? badge.background : Color.GRAY); + FontIcon glyph = new FontIcon(literal); + glyph.setIconSize(12); + glyph.setIconColor(badge != null ? badge.foreground : Color.WHITE); + StackPane icon = new StackPane(tile, glyph); + icon.setAlignment(Pos.CENTER); + icon.setMinSize(SIZE, SIZE); + icon.setMaxSize(SIZE, SIZE); + icon.getStyleClass().addAll("db-icon", "db-category"); + return icon; + } + + /** + * A logo of 24x24 scaled into a tile of the brand color + */ + private static Node logo(String[] logo) { + Rectangle tile = new Rectangle(SIZE, SIZE); + tile.setArcWidth(6); + tile.setArcHeight(6); + tile.setFill(Color.web(logo[0])); + SVGPath path = new SVGPath(); + path.setContent(logo[2]); + path.setFill(Color.web(logo[1])); + double scale = (SIZE - 6) / 24; + path.setScaleX(scale); + path.setScaleY(scale); + StackPane icon = new StackPane(tile, new Group(path)); + icon.setAlignment(Pos.CENTER); + icon.setMinSize(SIZE, SIZE); + icon.setMaxSize(SIZE, SIZE); + icon.getStyleClass().addAll("db-icon", "db-logo"); + return icon; + } + + private static void put(String name, String background, String foreground, String text) { + BADGES.put(name.toLowerCase(Locale.ENGLISH), new Badge(background, foreground, text)); + } + + /** + * Whether a database or a category of the name has an icon + */ + public static boolean has(String name) { + return name != null && (BADGES.containsKey(name.toLowerCase(Locale.ENGLISH)) || hasLogo(name)); + } + + /** + * Icon of a database or a category by its display name, a gray badge of its first letters if it is unknown + */ + public static Node of(String name) { + String[] logo = name != null ? LOGOS.get(name.toLowerCase(Locale.ENGLISH)) : null; + if (logo != null) { + return logo[0].startsWith(IMAGE) ? image(logo) : logo(logo); + } + String category = name != null ? CATEGORIES.get(name.toLowerCase(Locale.ENGLISH)) : null; + if (category != null) { + return category(name, category); + } + Badge badge = name != null ? BADGES.get(name.toLowerCase(Locale.ENGLISH)) : null; + if (badge == null) { + String text = name == null || name.isBlank() ? "?" : name.trim().substring(0, Math.min(2, + name.trim().length())); + badge = new Badge("#6B7280", "#FFFFFF", text); + } + Rectangle shape = new Rectangle(SIZE, SIZE); + shape.setArcWidth(6); + shape.setArcHeight(6); + shape.setFill(badge.background); + Text text = new Text(badge.text); + text.setFill(badge.foreground); + text.setFont(Font.font("System", FontWeight.BOLD, badge.text.length() > 1 ? 8.5 : 10)); + StackPane icon = new StackPane(shape, text); + icon.setAlignment(Pos.CENTER); + icon.setMinSize(SIZE, SIZE); + icon.setMaxSize(SIZE, SIZE); + icon.getStyleClass().add("db-icon"); + return icon; + } +} diff --git a/src/main/java/com/github/sqljam/face/view/DbNode.java b/src/main/java/com/github/sqljam/face/view/DbNode.java index d4672a1..3154e29 100644 --- a/src/main/java/com/github/sqljam/face/view/DbNode.java +++ b/src/main/java/com/github/sqljam/face/view/DbNode.java @@ -17,10 +17,13 @@ import com.github.sqljam.face.model.ConnectionProfile; import com.github.sqljam.face.model.TableInfo; +import com.github.sqljam.impexp.DbCategory; import lombok.Getter; /** - * @Description: DbNode is a node of the connection tree: connection, catalog, schema or table + * @Description: DbNode is a node of the connection tree: a group of the data sources of a category (relational, + * OLAP) or of an extension (vector databases), a connection, catalog, schema or table, or a source of + * an extension * @Author: Fred Feng * @Date: 26/03/2023 * @Version 1.0.0 @@ -42,7 +45,22 @@ public enum Kind { LOADING, - MESSAGE + MESSAGE, + + /** + * Group of the sources of an extension, e.g. Vector databases + */ + GROUP, + + /** + * A source of an extension, e.g. a vector database + */ + SOURCE, + + /** + * An item of a source of an extension, e.g. a collection of a vector database + */ + SOURCE_ITEM } private final Kind kind; @@ -51,15 +69,55 @@ public enum Kind { private final String schema; private final TableInfo table; private final String label; + private final DataSourceExtension extension; + private final DataSourceExtension.Source source; + /** + * Category of a group of relational or OLAP data sources + */ + private final DbCategory category; + /** + * Item of a source of an extension: a database, a collection or a partition + */ + private final SourceItem item; private DbNode(Kind kind, ConnectionProfile profile, String catalog, String schema, TableInfo table, String label) { + this(kind, profile, catalog, schema, table, label, null, null, null, null); + } + + private DbNode(Kind kind, ConnectionProfile profile, String catalog, String schema, TableInfo table, + String label, DataSourceExtension extension, DataSourceExtension.Source source, + DbCategory category, SourceItem item) { this.kind = kind; this.profile = profile; this.catalog = catalog; this.schema = schema; this.table = table; this.label = label; + this.extension = extension; + this.source = source; + this.category = category; + this.item = item; + } + + /** + * Group of the data sources of a category, e.g. Relational databases + */ + public static DbNode group(DbCategory category) { + return new DbNode(Kind.GROUP, null, null, null, null, category.getDisplayName(), null, null, category, null); + } + + public static DbNode group(DataSourceExtension extension) { + return new DbNode(Kind.GROUP, null, null, null, null, extension.getGroupName(), extension, null, null, null); + } + + public static DbNode sourceItem(DataSourceExtension extension, DataSourceExtension.Source source, + SourceItem item) { + return new DbNode(Kind.SOURCE_ITEM, null, null, null, null, item.getName(), extension, source, null, item); + } + + public static DbNode source(DataSourceExtension extension, DataSourceExtension.Source source) { + return new DbNode(Kind.SOURCE, null, null, null, null, source.getName(), extension, source, null, null); } public static DbNode root() { 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 8b9ebcb..e927749 100644 --- a/src/main/java/com/github/sqljam/face/view/DbTypeCells.java +++ b/src/main/java/com/github/sqljam/face/view/DbTypeCells.java @@ -18,12 +18,14 @@ import java.util.Arrays; import java.util.Comparator; import java.util.List; +import java.util.Objects; 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.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; @@ -67,13 +69,14 @@ public static DbCategory categoryOf(ConnectionProfile profile) { } /** - * Whether the item at the index starts a category of the list + * Whether the item at the index starts a category (or a group) of the list */ - static boolean isCategoryStart(List items, int index, Function category) { + 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)); + return index == 0 || !Objects.equals(category.apply(items.get(index - 1)), category.apply(items.get( + index))); } /** @@ -82,8 +85,7 @@ static boolean isCategoryStart(List items, int index, Function 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); + cell.setStyle(start ? "-fx-cell-size: 4em" : null); if (start) { cell.getStyleClass().add(CATEGORY_START); } @@ -93,13 +95,28 @@ static void setCategoryStart(ListCell cell, boolean start) { * Content with the category header above it */ static Node withHeader(DbCategory category, Node content) { - Label header = new Label(category.getDisplayName()); + return withHeader(category.getDisplayName(), content); + } + + /** + * Content with the header of a group above it, e.g. Vector databases + */ + static Node withHeader(String group, Node content) { + Label header = new Label(group, DbIcons.of(group)); 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); } + /** + * Colored icon of the database of a profile, the icon of a connection if its type is unknown + */ + public static Node iconOf(ConnectionProfile profile) { + return profile != null && profile.getDbType() != null ? DbIcons.of(profile.getDbType().getDisplayName()) + : Icons.of(Icons.CONNECTION); + } + public static ListCell profileCell() { return new ListCell<>() { @Override @@ -110,7 +127,7 @@ protected void updateItem(ConnectionProfile item, boolean empty) { setGraphic(null); } else { setText(item.getName()); - setGraphic(Icons.of(Icons.CONNECTION)); + setGraphic(iconOf(item)); } } }; @@ -132,37 +149,6 @@ 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(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 @@ -176,16 +162,29 @@ protected void updateItem(DbType item, boolean empty) { if (empty || item == null) { setText(null); setGraphic(null); - } else if (isCategoryStart(view.getItems(), getIndex(), DbType::getCategory)) { + } else if (isCategoryStart(view.getItems(), getIndex(), DbType::getCategory) && view.getItems() + .stream().map(DbType::getCategory).distinct().count() > 1) { setCategoryStart(this, true); setText(null); - setGraphic(withHeader(item.getCategory(), new Label(item.getDisplayName()))); + HBox type = new HBox(getGraphicTextGap(), DbIcons.of(item.getDisplayName()), + new Label(item.getDisplayName())); + type.setAlignment(Pos.CENTER_LEFT); + setGraphic(withHeader(item.getCategory(), type)); } else { setText(item.getDisplayName()); - setGraphic(null); + setGraphic(DbIcons.of(item.getDisplayName())); } } }); + // The selected type shows its icon too + combo.setButtonCell(new ListCell<>() { + @Override + protected void updateItem(DbType item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : item.getDisplayName()); + setGraphic(empty || item == null ? null : DbIcons.of(item.getDisplayName())); + } + }); 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 fdeee9a..8ae515f 100644 --- a/src/main/java/com/github/sqljam/face/view/Dialogs.java +++ b/src/main/java/com/github/sqljam/face/view/Dialogs.java @@ -28,6 +28,9 @@ import javafx.scene.control.Dialog; import javafx.scene.control.Hyperlink; import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; import javafx.scene.control.TextArea; import javafx.scene.image.ImageView; import javafx.scene.layout.GridPane; @@ -50,7 +53,7 @@ private Dialogs() { * Sets the owner of a dialog. JavaFX fails with a NullPointerException for an owner window without a scene, * the dialog is then shown without an owner. */ - static void initOwner(Dialog dialog, Window owner) { + public static void initOwner(Dialog dialog, Window owner) { if (owner != null && owner.getScene() != null) { dialog.initOwner(owner); } @@ -88,7 +91,15 @@ public static void showError(Window owner, String title, Throwable e) { } public static void showInfo(Window owner, String title, String message) { - Alert alert = new Alert(Alert.AlertType.INFORMATION); + show(Alert.AlertType.INFORMATION, owner, title, message); + } + + public static void showWarning(Window owner, String title, String message) { + show(Alert.AlertType.WARNING, owner, title, message); + } + + private static void show(Alert.AlertType type, Window owner, String title, String message) { + Alert alert = new Alert(type); initOwner(alert, owner); Branding.applyIcons(alert); alert.setTitle(title); @@ -127,7 +138,29 @@ public static Alert createAbout(Window owner, Consumer browser) { 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)); + // About and the map of the features + Tab aboutTab = new Tab(Messages.get("about.tab"), new VBox(14, message, details)); + aboutTab.setId("aboutTab"); + ScrollPane mapPane = new ScrollPane(new FeatureMap()); + mapPane.setFitToWidth(true); + mapPane.setPrefViewportHeight(560); + Tab featuresTab = new Tab(Messages.get("about.features"), mapPane); + featuresTab.setId("featuresTab"); + TabPane tabs = new TabPane(aboutTab, featuresTab); + tabs.setId("aboutTabs"); + tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); + // The map needs room, the dialog grows while it is shown + tabs.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, tab) -> { + double width = tab == featuresTab ? 1000 : 500; + alert.getDialogPane().setPrefWidth(width); + // A dialog which is shown takes another width by the dialog, it stays in the middle + if (alert.isShowing()) { + double centerX = alert.getX() + alert.getWidth() / 2; + alert.setWidth(width + alert.getWidth() - alert.getDialogPane().getWidth()); + alert.setX(centerX - alert.getWidth() / 2); + } + }); + alert.getDialogPane().setContent(tabs); return alert; } 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 4ba1c31..9516a35 100644 --- a/src/main/java/com/github/sqljam/face/view/ExportDialog.java +++ b/src/main/java/com/github/sqljam/face/view/ExportDialog.java @@ -19,23 +19,26 @@ import java.util.ArrayList; import java.util.EnumMap; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.model.ColumnInfo; import com.github.sqljam.face.model.ConnectionProfile; import com.github.sqljam.face.model.TableInfo; import com.github.sqljam.face.model.TransferRequest; import com.github.sqljam.face.service.DatabaseSession; -import com.github.sqljam.impexp.DataFileStrategy; +import com.github.sqljam.impexp.ColumnMapping; import com.github.sqljam.impexp.CsvExporter; import com.github.sqljam.impexp.CsvOptions; -import com.github.sqljam.impexp.DuckDBWorkspace; +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.DuckDBWorkspace; import com.github.sqljam.impexp.ExportMode; import com.github.sqljam.impexp.Exporter; import com.github.sqljam.impexp.IdentifierCase; @@ -152,6 +155,12 @@ public class ExportDialog extends Dialog { private final RadioButton upsertRadio = new RadioButton(Messages.get("export.writeMode.upsert")); private final RadioButton skipExistingRadio = new RadioButton(Messages.get("export.writeMode.skip")); private final Button keyColumnsButton = new Button(Messages.get("export.writeMode.keys")); + /** + * Columns which the user mapped for an import into a database, none imports the tables as they are + */ + private final Button mapColumnsButton = new Button(Messages.get("mapping.button"), Icons.of(Icons.COLUMNS)); + private final Label mappingLabel = new Label(); + private Map> columnMappings = new HashMap<>(); /** * Chosen key columns of upserts by table, the primary key of the other tables */ @@ -177,6 +186,7 @@ public class ExportDialog extends Dialog { private final TextField queryTableField = new TextField(DEFAULT_QUERY_TABLE); private VBox tablesBox; private Label tableNamePatternLabel; + private Label mappingColumnsLabel; private final CheckBox createSchemaCheck = new CheckBox(Messages.get("export.database.createSchema")); private final Label errorLabel = new Label(); private Button startButton; @@ -641,6 +651,16 @@ private Node createTargetPane() { } }); databaseGrid.addRow(6, new Label(Messages.get("import.transaction")), transactionOption.getNode()); + mapColumnsButton.setId("mapColumnsButton"); + mapColumnsButton.setTooltip(new Tooltip(Messages.get("mapping.button.tooltip"))); + mapColumnsButton.setOnAction(event -> mapColumns()); + mappingLabel.setId("mappingLabel"); + mappingLabel.getStyleClass().add("muted"); + updateMappingLabel(); + HBox mappingBox = new HBox(10, mapColumnsButton, mappingLabel); + mappingBox.setAlignment(Pos.CENTER_LEFT); + mappingColumnsLabel = new Label(Messages.get("mapping.columns")); + databaseGrid.addRow(7, mappingColumnsLabel, mappingBox); VBox databasePane = new VBox(databaseGrid); databasePane.getStyleClass().add("target-pane"); @@ -717,7 +737,8 @@ private void applyQueryMode() { control.setFocusTraversable(false); } for (Node option : new Node[]{idReusedCheck, indexCheck, foreignKeyCheck, commentCheck, sequenceCheck, - keyColumnsButton, tableNamePatternField, tableNamePatternLabel}) { + keyColumnsButton, tableNamePatternField, tableNamePatternLabel, mapColumnsButton, mappingLabel, + mappingColumnsLabel}) { option.setVisible(false); option.setManaged(false); } @@ -759,6 +780,57 @@ private void chooseKeyColumns() { keyColumnNames).showAndWait().ifPresent(keys -> keyColumnNames = new HashMap<>(keys)); } + /** + * Target names and types of the columns of the selected tables, all tables when none is selected + */ + private void mapColumns() { + ConnectionProfile source = sourceCombo.getValue(); + if (source == null) { + return; + } + List tables = tableChoices.stream().filter(choice -> choice.selected.get()) + .map(choice -> choice.table.getName()).collect(Collectors.toList()); + if (tables.isEmpty()) { + tables = tableChoices.stream().map(choice -> choice.table.getName()).collect(Collectors.toList()); + } + DatabaseSession session = getSession(source); + String catalog = catalogCombo.isDisabled() ? null : catalogCombo.getValue(); + String schema = schemaCombo.isDisabled() ? null : schemaCombo.getValue(); + new ColumnMappingDialog(getDialogPane().getScene().getWindow(), tables, table -> session.getColumns(catalog, + schema, table).stream().map(column -> new ColumnMappingDialog.Column(column.getName(), + describe(column), false)).collect(Collectors.toList()), columnMappings).showAndWait() + .ifPresent(mappings -> { + columnMappings = new HashMap<>(mappings); + updateMappingLabel(); + }); + } + + private static String describe(ColumnInfo column) { + return column.getTypeName() + (column.getSize() > 0 && column.getSize() < Integer.MAX_VALUE ? "(" + + column.getSize() + (column.getScale() > 0 ? "," + column.getScale() : "") + ")" : ""); + } + + private void updateMappingLabel() { + long changed = columnMappings.values().stream().mapToLong(Map::size).sum(); + mappingLabel.setText(changed == 0 ? Messages.get("mapping.unchanged") : Messages.format("mapping.changed", + changed)); + } + + /** + * Mappings of the dialog as mappings of the import + */ + static Map> toColumnMappings( + Map> mappings) { + Map> result = new HashMap<>(); + mappings.forEach((table, columns) -> { + Map converted = new LinkedHashMap<>(); + columns.forEach((column, mapping) -> converted.put(column, new ColumnMapping(mapping.getName(), + mapping.getType(), mapping.isSkipped()))); + result.put(table, converted); + }); + return result; + } + /** * Rows are not matched by key for targets which can not do it, e.g. ClickHouse */ @@ -869,6 +941,9 @@ private static String getDefaultSchema(ConnectionProfile profile, List s } private void loadTables(String catalog, String schema, String table) { + // Mappings belong to the tables of the schema which was shown + columnMappings = new HashMap<>(); + updateMappingLabel(); ConnectionProfile profile = sourceCombo.getValue(); tableChoices.clear(); if (profile == null) { @@ -1114,6 +1189,8 @@ TransferRequest buildRequest() { // The table of a query has its own name request.setTableNamePattern(freeQuery != null ? null : StringUtils.trimToNull( tableNamePatternField.getText())); + // Columns which the user mapped, the table of a query has its own columns + request.setColumnMappings(freeQuery != null ? new HashMap<>() : toColumnMappings(columnMappings)); request.setOneTransaction(transactionOption.isOneTransaction()); request.setConfirmCommit(transactionOption.isConfirmCommit()); request.setFailFast(!transactionOption.isSkipErrors()); diff --git a/src/main/java/com/github/sqljam/face/view/FeatureMap.java b/src/main/java/com/github/sqljam/face/view/FeatureMap.java new file mode 100644 index 0000000..9e39244 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/FeatureMap.java @@ -0,0 +1,224 @@ +/* + * Copyright 2023-2026 Fred Feng + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.sqljam.face.view; + +import java.util.ArrayList; +import java.util.List; + +import com.github.sqljam.impexp.DbCategory; +import com.github.sqljam.impexp.DbType; +import javafx.geometry.Bounds; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import javafx.scene.image.ImageView; +import javafx.scene.layout.FlowPane; +import javafx.scene.layout.Pane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.CubicCurve; + +/** + * @Description: FeatureMap shows what SqlJam does as a map of its features: SqlJam in the middle and a branch for + * each group of features on both sides, joined by curves. The texts are messages + * (feature.<group>.title and feature.<group>.items), the data sources show their logos. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class FeatureMap extends Pane { + + /** + * Groups of features, the first half on the left and the second half on the right + */ + static final String[] GROUPS = {"sources", "structure", "rows", "transfer", "reliability", "vectors"}; + static final String[] ICONS = {Icons.CATALOG, Icons.TABLE, Icons.DATA, Icons.EXPORT, Icons.CONNECT, + "fth-share-2"}; + static final double CARD_WIDTH = 300; + static final double GAP = 18; + static final double CENTER_WIDTH = 170; + + private final VBox center; + private final List cards = new ArrayList<>(); + private final List curves = new ArrayList<>(); + + public FeatureMap() { + setId("featureMap"); + getStyleClass().add("feature-map"); + ImageView logo = Branding.logoView(84); + Label name = new Label("SqlJam"); + name.getStyleClass().add("feature-map-name"); + name.setStyle("-fx-font-size: 22px; -fx-font-weight: bold;"); + Label tagline = new Label(Messages.get("feature.tagline")); + tagline.getStyleClass().add("muted"); + tagline.setWrapText(true); + tagline.setMaxWidth(CENTER_WIDTH); + center = new VBox(6, logo, name, tagline); + center.setAlignment(Pos.CENTER); + center.getStyleClass().add("feature-map-center"); + center.setPrefWidth(CENTER_WIDTH); + for (int i = 0; i < GROUPS.length; i++) { + CubicCurve curve = new CubicCurve(); + curve.getStyleClass().add("feature-map-curve"); + // Styles of the theme, the dialog has no stylesheet of the application + curve.setStyle("-fx-fill: transparent; -fx-stroke: -color-accent-emphasis; -fx-stroke-width: 1.6;"); + curves.add(curve); + cards.add(card(GROUPS[i], ICONS[i])); + } + getChildren().addAll(curves); + getChildren().add(center); + getChildren().addAll(cards); + } + + /** + * A group of features: its icon and title, its features one a line, the logos of the data sources + */ + private static VBox card(String group, String icon) { + Label title = new Label(Messages.get("feature." + group + ".title"), Icons.of(icon)); + title.getStyleClass().add("feature-map-title"); + title.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;"); + VBox card = new VBox(6, title); + card.setId("feature-" + group); + card.getStyleClass().add("feature-map-card"); + card.setStyle("-fx-background-color: -color-bg-subtle; -fx-background-radius: 10; -fx-border-color:" + + " -color-border-default; -fx-border-radius: 10;"); + card.setPadding(new Insets(10, 12, 10, 12)); + card.setPrefWidth(CARD_WIDTH); + card.setMaxWidth(CARD_WIDTH); + for (String item : Messages.get("feature." + group + ".items").split("\\|")) { + Label line = new Label("• " + item.trim()); + line.setWrapText(true); + line.setMaxWidth(CARD_WIDTH - 24); + card.getChildren().add(line); + } + if ("sources".equals(group)) { + card.getChildren().add(logos()); + } + return card; + } + + /** + * Logos of the databases by category, vector databases after them + */ + private static Node logos() { + FlowPane logos = new FlowPane(4, 4); + logos.setId("featureLogos"); + logos.setPrefWrapLength(CARD_WIDTH - 24); + for (DbCategory category : DbCategory.values()) { + for (DbType type : DbType.values()) { + if (type.getCategory() == category && DbIcons.has(type.getDisplayName())) { + Node icon = DbIcons.of(type.getDisplayName()); + Label label = new Label(null, icon); + label.setTooltip(new Tooltip(type.getDisplayName())); + logos.getChildren().add(label); + } + } + } + for (String vector : new String[]{"Milvus", "Qdrant", "Weaviate", "Pinecone", "Elasticsearch", "OpenSearch", + "Chroma"}) { + if (DbIcons.has(vector)) { + Label label = new Label(null, DbIcons.of(vector)); + label.setTooltip(new Tooltip(vector)); + logos.getChildren().add(label); + } + } + return logos; + } + + List getCards() { + return cards; + } + + @Override + protected double computePrefWidth(double height) { + return 2 * CARD_WIDTH + CENTER_WIDTH + 4 * GAP + 2 * 40; + } + + /** + * The map is never narrower than its columns, a narrow window scrolls it instead of putting cards on each other + */ + @Override + protected double computeMinWidth(double height) { + return computePrefWidth(height); + } + + @Override + protected double computeMinHeight(double width) { + return computePrefHeight(width); + } + + @Override + protected double computePrefHeight(double width) { + return Math.max(columnHeight(0), columnHeight(GROUPS.length / 2)) + 2 * GAP; + } + + private double columnHeight(int from) { + double height = 0; + for (int i = from; i < from + GROUPS.length / 2; i++) { + height += cards.get(i).prefHeight(CARD_WIDTH) + GAP; + } + return height - GAP; + } + + /** + * SqlJam in the middle, the groups in two columns, each joined to the middle by a curve + */ + @Override + protected void layoutChildren() { + double width = getWidth(); + double height = getHeight(); + double centerHeight = center.prefHeight(CENTER_WIDTH); + double centerX = (width - CENTER_WIDTH) / 2; + double centerY = (height - centerHeight) / 2; + center.resizeRelocate(centerX, centerY, CENTER_WIDTH, centerHeight); + int half = GROUPS.length / 2; + double leftX = Math.max(GAP, centerX - 40 - CARD_WIDTH); + double rightX = Math.min(width - CARD_WIDTH - GAP, centerX + CENTER_WIDTH + 40); + for (int column = 0; column < 2; column++) { + int from = column * half; + double y = Math.max(GAP, (height - columnHeight(from)) / 2); + for (int i = from; i < from + half; i++) { + VBox card = cards.get(i); + double cardHeight = card.prefHeight(CARD_WIDTH); + double x = column == 0 ? leftX : rightX; + card.resizeRelocate(x, y, CARD_WIDTH, cardHeight); + CubicCurve curve = curves.get(i); + double startX = column == 0 ? centerX : centerX + CENTER_WIDTH; + double startY = centerY + centerHeight / 2; + double endX = column == 0 ? x + CARD_WIDTH : x; + double endY = y + Math.min(cardHeight / 2, 24); + double middle = (startX + endX) / 2; + curve.setStartX(startX); + curve.setStartY(startY); + curve.setControlX1(middle); + curve.setControlY1(startY); + curve.setControlX2(middle); + curve.setControlY2(endY); + curve.setEndX(endX); + curve.setEndY(endY); + y += cardHeight + GAP; + } + } + } + + /** + * Bounds of a card in the map, e.g. for tests + */ + Bounds getCardBounds(int index) { + return cards.get(index).getBoundsInParent(); + } +} diff --git a/src/main/java/com/github/sqljam/face/view/HistoryTab.java b/src/main/java/com/github/sqljam/face/view/HistoryTab.java new file mode 100644 index 0000000..7d2140d --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/HistoryTab.java @@ -0,0 +1,151 @@ +/* + * Copyright 2023-2026 Fred Feng + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.sqljam.face.view; + +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.service.ReportExporter; +import com.github.sqljam.face.service.ReportStore; +import javafx.beans.property.ReadOnlyStringWrapper; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.Tab; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableRow; +import javafx.scene.control.TableView; +import javafx.scene.control.ToolBar; +import javafx.scene.input.MouseButton; +import javafx.scene.layout.BorderPane; + +/** + * @Description: HistoryTab lists the reports of the imports and exports, newest first: when, what, from where to + * where, how it ended, rows and time. A report is opened on its own page, deleted after a confirmation, + * and the folder of the reports is opened. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class HistoryTab extends Tab { + + private final AppContext context; + private final TableView table = new TableView<>(); + private final Consumer opener; + + public HistoryTab(AppContext context, Consumer opener) { + this.context = context; + this.opener = opener; + setText(Messages.get("history.tab")); + setGraphic(Icons.of(Icons.HISTORY)); + setId("historyTab"); + table.setId("historyTable"); + table.getColumns().add(column(Messages.get("history.started"), report -> ReportExporter.time(report + .getStartedAt()), 160)); + table.getColumns().add(column(Messages.get("history.kind"), report -> report.getKind() != null + ? ReportExporter.words(report.getKind()) : "", 120)); + table.getColumns().add(column(Messages.get("history.source"), TransferReport::getSource, 220)); + table.getColumns().add(column(Messages.get("history.target"), TransferReport::getTarget, 220)); + TableColumn status = column(Messages.get("history.status"), report -> + ReportExporter.words(report.getStatus()), 160); + status.setCellFactory(tableColumn -> new TableCell<>() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + getStyleClass().removeAll("status-success", "status-error"); + setText(empty ? null : item); + TransferReport report = empty || getTableRow() == null ? null : getTableRow().getItem(); + if (report != null) { + getStyleClass().add(report.getStatus().isKept() ? "status-success" : "status-error"); + } + } + }); + table.getColumns().add(status); + table.getColumns().add(column(Messages.get("history.rows"), report -> String.format("%,d", report + .getWrittenRows()), 110)); + table.getColumns().add(column(Messages.get("history.duration"), report -> ReportExporter.duration(report + .getElapsedMillis()), 110)); + table.setRowFactory(view -> { + TableRow row = new TableRow<>(); + row.setOnMouseClicked(event -> { + if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2 && !row.isEmpty()) { + opener.accept(row.getItem()); + } + }); + return row; + }); + table.setPlaceholder(new Label(Messages.get("history.empty"))); + TableColumns.fill(table); + + Button open = new Button(Messages.get("history.open"), Icons.of(Icons.OPEN)); + open.setId("historyOpenButton"); + open.disableProperty().bind(table.getSelectionModel().selectedItemProperty().isNull()); + open.setOnAction(event -> opener.accept(table.getSelectionModel().getSelectedItem())); + Button delete = new Button(Messages.get("history.delete"), Icons.of(Icons.DELETE)); + delete.setId("historyDeleteButton"); + delete.disableProperty().bind(table.getSelectionModel().selectedItemProperty().isNull()); + delete.setOnAction(event -> delete(table.getSelectionModel().getSelectedItem())); + Button refresh = new Button(Messages.get("history.refresh"), Icons.of(Icons.REFRESH)); + refresh.setId("historyRefreshButton"); + refresh.setOnAction(event -> refresh()); + Button folder = new Button(Messages.get("history.folder"), Icons.of(Icons.FOLDER)); + folder.setId("historyFolderButton"); + folder.setOnAction(event -> context.openDocument(context.getReportStore().getDirectory().toURI() + .toString())); + BorderPane root = new BorderPane(table); + root.setTop(new ToolBar(open, delete, refresh, folder)); + setContent(root); + refresh(); + } + + private static TableColumn column(String title, Function value, + double width) { + TableColumn column = new TableColumn<>(title); + column.setCellValueFactory(features -> new ReadOnlyStringWrapper(value.apply(features.getValue()))); + column.setPrefWidth(width); + return column; + } + + /** + * Reads the reports again, in the background + */ + public void refresh() { + ReportStore store = context.getReportStore(); + TaskRunner.run(store::list, reports -> table.getItems().setAll(reports), e -> table.setPlaceholder( + new Label(Messages.format("tree.error", e.getMessage())))); + } + + void delete(TransferReport report) { + if (report == null || !Dialogs.confirm(table.getScene() != null ? table.getScene().getWindow() : null, + Messages.get("history.delete"), Messages.format("history.delete.confirm", ReportExporter.time(report + .getStartedAt())))) { + return; + } + TaskRunner.run(() -> context.getReportStore().delete(report.getId()), deleted -> refresh(), + e -> Dialogs.showError(table.getScene() != null ? table.getScene().getWindow() : null, + Messages.get("history.delete"), e)); + } + + List getReports() { + return table.getItems(); + } + + TableView getTable() { + return table; + } +} 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 9a4dd27..2365e73 100644 --- a/src/main/java/com/github/sqljam/face/view/Icons.java +++ b/src/main/java/com/github/sqljam/face/view/Icons.java @@ -40,6 +40,8 @@ public final class Icons { public static final String IMPORT = "fth-upload"; public static final String COPY = "fth-copy"; public static final String CANCEL = "fth-x"; + public static final String SEARCH = "fth-search"; + public static final String HISTORY = "fth-clock"; public static final String START = "fth-play"; public static final String FOLDER = "fth-folder"; public static final String SETTINGS = "fth-settings"; @@ -57,6 +59,12 @@ public final class Icons { public static final String EXIT = "fth-log-out"; public static final String SAVE = "fth-save"; public static final String SQL = "fth-terminal"; + public static final String COLUMNS = "fth-columns"; + public static final String INDEXES = "fth-list"; + public static final String FOREIGN_KEYS = "fth-link-2"; + public static final String DDL = "fth-code"; + public static final String DATA = "fth-database"; + public static final String HEALTH = "fth-activity"; private Icons() { } diff --git a/src/main/java/com/github/sqljam/face/view/InfoTab.java b/src/main/java/com/github/sqljam/face/view/InfoTab.java new file mode 100644 index 0000000..d4271d1 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/InfoTab.java @@ -0,0 +1,227 @@ +/* + * 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.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.function.Supplier; + +import com.github.sqljam.face.service.MetricSource; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableRow; +import javafx.scene.control.TableView; +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; + +/** + * @Description: InfoTab shows the overview and the health of a data source or of a vector database by sections, + * read in the background when it opens and again by Refresh, never by itself. Values of one line are + * shown whole by a double click. It serves imports and exports, it manages nothing. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class InfoTab extends Tab { + + static final String SECTION = "section"; + private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm:ss"); + + private final Callable>> overview; + private final Callable>> health; + private final TableView> overviewTable = table("infoOverviewTable"); + private final TableView> healthTable = table("infoHealthTable"); + private final Label refreshedLabel = new Label(); + private final Button refreshButton = new Button(Messages.get("action.refresh"), Icons.of(Icons.REFRESH)); + private DashboardPane dashboardPane; + private TabPane tabs; + + /** + * @param overview sections of the overview, read in the background + * @param health sections of the health, read in the background + */ + public InfoTab(String name, Node graphic, Callable>> overview, + Callable>> health) { + this(name, graphic, overview, health, null); + } + + /** + * @param dashboard the metrics of the live charts of the dashboard, null for sources without + */ + public InfoTab(String name, Node graphic, Callable>> overview, + Callable>> health, Supplier dashboard) { + this.overview = overview; + this.health = health; + setText(Messages.format("info.title", name)); + setGraphic(graphic); + setTooltip(new Tooltip(Messages.format("info.tooltip", name))); + refreshButton.setId("infoRefreshButton"); + refreshedLabel.setId("infoRefreshedLabel"); + refreshedLabel.getStyleClass().add("muted"); + refreshButton.setOnAction(event -> refresh()); + + TabPane tabs = new TabPane(); + tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); + Tab overviewTab = new Tab(Messages.get("info.overview"), overviewTable); + overviewTab.setGraphic(Icons.of(Icons.INFO)); + Tab healthTab = new Tab(Messages.get("info.health"), healthTable); + healthTab.setGraphic(Icons.of(Icons.HEALTH)); + tabs.getTabs().addAll(overviewTab, healthTab); + if (dashboard != null) { + dashboardPane = new DashboardPane(dashboard); + Tab dashboardTab = new Tab(Messages.get("info.dashboard"), dashboardPane); + dashboardTab.setId("infoDashboardTab"); + dashboardTab.setGraphic(Icons.of(Icons.HEALTH)); + tabs.getTabs().add(dashboardTab); + // Samples are read while the dashboard is shown only: its page and this tab are selected + Runnable follow = () -> { + if (dashboardTab.isSelected() && isSelected()) { + dashboardPane.start(); + } else { + dashboardPane.stop(); + } + }; + dashboardTab.selectedProperty().addListener((observable, was, selected) -> follow.run()); + selectedProperty().addListener((observable, was, selected) -> follow.run()); + // Closed by the user or removed, e.g. when its data source is disconnected + tabPaneProperty().addListener((observable, old, pane) -> { + if (pane == null) { + dashboardPane.close(); + } + }); + setOnClosed(event -> dashboardPane.close()); + } + this.tabs = tabs; + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + HBox bar = new HBox(8, spacer, refreshedLabel, refreshButton); + bar.setAlignment(Pos.CENTER_RIGHT); + bar.setPadding(new Insets(8)); + bar.getStyleClass().add("pager"); + BorderPane content = new BorderPane(tabs); + content.setTop(bar); + setContent(content); + refresh(); + } + + private static TableView> table(String id) { + TableView> table = new TableView<>(); + table.setId(id); + TableColumns.fill(table); + table.setFixedCellSize(26); + table.getStyleClass().add("data-table"); + TableColumn, String> item = new TableColumn<>(Messages.get("info.item")); + item.setCellValueFactory(features -> new ReadOnlyObjectWrapper<>(features.getValue().get(0))); + item.setPrefWidth(220); + item.setSortable(false); + TableColumn, String> value = new TableColumn<>(Messages.get("info.value")); + value.setCellValueFactory(features -> new ReadOnlyObjectWrapper<>(features.getValue().get(1))); + value.setCellFactory(column -> new ValueCell()); + value.setPrefWidth(760); + value.setSortable(false); + table.getColumns().add(item); + table.getColumns().add(value); + // Sections are rows of a title, the items follow them + table.setRowFactory(view -> new TableRow<>() { + @Override + protected void updateItem(List row, boolean empty) { + super.updateItem(row, empty); + getStyleClass().remove("info-section"); + setStyle(null); + if (!empty && row != null && SECTION.equals(row.get(2))) { + getStyleClass().add("info-section"); + setStyle("-fx-font-weight: bold;"); + } + } + }); + table.setPlaceholder(new Label(Messages.get("tree.loading"))); + return table; + } + + /** + * Rows of the sections: a title row of each section, then its items + */ + static List> rows(Map> sections) { + List> rows = new ArrayList<>(); + sections.forEach((section, items) -> { + rows.add(Arrays.asList(section, "", SECTION)); + items.forEach((name, value) -> rows.add(Arrays.asList(" " + name, value, ""))); + }); + return rows; + } + + /** + * Reads the overview and the health again + */ + public void refresh() { + refreshButton.setDisable(true); + refreshedLabel.setText(Messages.get("info.refreshing")); + load(overviewTable, overview, false); + load(healthTable, health, true); + } + + private void load(TableView> table, Callable>> reader, + boolean last) { + table.setPlaceholder(new Label(Messages.get("tree.loading"))); + TaskRunner.run(reader, sections -> { + table.getItems().setAll(rows(sections)); + if (last) { + done(); + } + }, e -> { + table.getItems().clear(); + table.setPlaceholder(new Label(Messages.format("tree.error", e.getMessage()))); + if (last) { + done(); + } + }); + } + + private void done() { + refreshButton.setDisable(false); + refreshedLabel.setText(Messages.format("info.refreshed", LocalTime.now().format(TIME))); + } + + /** + * The dashboard of the tab, null for sources without + */ + DashboardPane getDashboard() { + return dashboardPane; + } + + /** + * Shows the dashboard, it starts its samples + */ + public void showDashboard() { + tabs.getTabs().stream().filter(tab -> "infoDashboardTab".equals(tab.getId())).findFirst().ifPresent(tab -> + tabs.getSelectionModel().select(tab)); + } +} diff --git a/src/main/java/com/github/sqljam/face/view/LoginView.java b/src/main/java/com/github/sqljam/face/view/LoginView.java deleted file mode 100644 index 5b80d40..0000000 --- a/src/main/java/com/github/sqljam/face/view/LoginView.java +++ /dev/null @@ -1,209 +0,0 @@ -/* - * 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.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; -import javafx.scene.control.Label; -import javafx.scene.control.ListView; -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; -import javafx.scene.layout.Priority; -import javafx.scene.layout.Region; -import javafx.scene.layout.StackPane; -import javafx.scene.layout.VBox; - -/** - * @Description: LoginView is the startup page to choose a saved connection or connect a new database - * @Author: Fred Feng - * @Date: 26/03/2023 - * @Version 1.0.0 - */ -public class LoginView extends StackPane { - - private final AppContext context; - private final ObservableList profiles = FXCollections.observableArrayList(); - private final FilteredList filteredProfiles = new FilteredList<>(profiles, profile -> true); - // 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")); - private final Button skipButton = new Button(Messages.get("login.skip")); - - /** - * @param onLogin called with the logged in profile, or null when skipped - */ - public LoginView(AppContext context, Consumer onLogin) { - this.context = context; - getStyleClass().add("login-view"); - - TextField searchField = new TextField(); - searchField.setId("searchField"); - profileList.setId("profileList"); - loginButton.setId("loginButton"); - testButton.setId("testButton"); - skipButton.setId("skipButton"); - searchField.setPromptText(Messages.get("login.search")); - searchField.textProperty().addListener((obs, oldText, text) -> filteredProfiles.setPredicate(profile -> - StringUtils.isBlank(text) || StringUtils.containsIgnoreCase(profile.getName(), text) - || (profile.getDbType() != null && StringUtils.containsIgnoreCase( - profile.getDbType().getDisplayName(), text)))); - DbTypeCells.setupProfileList(profileList); - profileList.setPlaceholder(new Label(Messages.get("login.noConnections"))); - profileList.getSelectionModel().selectedItemProperty().addListener((obs, oldProfile, profile) -> { - if (profile != null) { - connectionForm.setProfile(profile, context.getProfileRegistry().isPasswordRemembered( - profile.getId())); - } - }); - VBox.setVgrow(profileList, Priority.ALWAYS); - // Icons of the list like DBeaver and DataGrip, the names are their tooltips; texts are never cut off - Button newButton = new Button(null, Icons.of(Icons.ADD)); - newButton.setId("newButton"); - newButton.setTooltip(new Tooltip(Messages.get("login.new"))); - newButton.setAccessibleText(Messages.get("login.new")); - newButton.setOnAction(event -> { - profileList.getSelectionModel().clearSelection(); - connectionForm.setProfile(new ConnectionProfile(), true); - connectionForm.focusFirstField(); - }); - Button deleteButton = new Button(null, Icons.of(Icons.DELETE)); - deleteButton.setId("deleteButton"); - deleteButton.setTooltip(new Tooltip(Messages.get("login.delete"))); - deleteButton.setAccessibleText(Messages.get("login.delete")); - deleteButton.disableProperty().bind(profileList.getSelectionModel().selectedItemProperty().isNull()); - deleteButton.setOnAction(event -> deleteSelected()); - // 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); - listButtons.getChildren().forEach(button -> ((Button) button).setMinWidth(Region.USE_PREF_SIZE)); - Label savedLabel = new Label(Messages.get("login.savedConnections")); - savedLabel.getStyleClass().add("section-title"); - VBox left = new VBox(10, savedLabel, searchField, profileList, listButtons); - left.setPrefWidth(260); - left.getStyleClass().add("login-sidebar"); - - ImageView logo = Branding.logoView(60); - logo.setId("loginLogo"); - logo.getStyleClass().add("login-logo"); - Label title = new Label("SqlJam"); - title.getStyleClass().add("login-title"); - Label subtitle = new Label(Messages.get("login.subtitle")); - subtitle.getStyleClass().add("login-subtitle"); - VBox header = new VBox(2, title, subtitle); - // 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)); - loginButton.setDefaultButton(true); - loginButton.getStyleClass().add("accent"); - loginButton.setOnAction(event -> login(onLogin)); - skipButton.setOnAction(event -> onLogin.accept(null)); - testButton.disableProperty().bind(connectionForm.busyProperty()); - loginButton.disableProperty().bind(connectionForm.busyProperty()); - Region spacer = new Region(); - HBox.setHgrow(spacer, Priority.ALWAYS); - HBox buttons = new HBox(8, testButton, spacer, skipButton, loginButton); - buttons.setAlignment(Pos.CENTER_RIGHT); - - VBox right = new VBox(16, headerBox, new Separator(), connectionForm, buttons); - right.setPrefWidth(480); - right.getStyleClass().add("login-form"); - - HBox card = new HBox(0, left, right); - card.getStyleClass().add("login-card"); - card.setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); - HBox.setHgrow(right, Priority.ALWAYS); - - ScrollPane scrollPane = new ScrollPane(new BorderPane(card)); - scrollPane.setFitToWidth(true); - scrollPane.setFitToHeight(true); - BorderPane wrapper = (BorderPane) scrollPane.getContent(); - BorderPane.setAlignment(card, Pos.CENTER); - BorderPane.setMargin(card, new Insets(24)); - getChildren().add(scrollPane); - - reloadProfiles(); - if (!profiles.isEmpty()) { - profileList.getSelectionModel().selectFirst(); - } - wrapper.getStyleClass().add("login-background"); - } - - private void reloadProfiles() { - profiles.setAll(context.getProfileRegistry().getProfiles()); - profiles.sort((a, b) -> StringUtils.compareIgnoreCase(a.getName(), b.getName())); - } - - private void deleteSelected() { - ConnectionProfile profile = profileList.getSelectionModel().getSelectedItem(); - if (profile == null || !Dialogs.confirm(getScene().getWindow(), Messages.get("connection.delete.title"), - Messages.format("connection.delete.message", profile.getName()))) { - return; - } - try { - context.getProfileRegistry().removeProfile(profile.getId()); - reloadProfiles(); - connectionForm.setProfile(new ConnectionProfile(), true); - } catch (IOException e) { - Dialogs.showError(getScene().getWindow(), Messages.get("connection.save.error"), e); - } - } - - private void login(Consumer onLogin) { - connectionForm.testConnection(product -> { - ConnectionProfile profile = connectionForm.getProfile(); - try { - context.getProfileRegistry().saveProfile(profile, connectionForm.isPasswordRemembered()); - } catch (IOException e) { - Dialogs.showError(getScene().getWindow(), Messages.get("connection.save.error"), e); - return; - } - onLogin.accept(profile); - }); - } -} 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 103712a..4942749 100644 --- a/src/main/java/com/github/sqljam/face/view/MainView.java +++ b/src/main/java/com/github/sqljam/face/view/MainView.java @@ -17,16 +17,26 @@ import java.io.File; import java.io.IOException; +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.ServiceLoader; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; +import atlantafx.base.controls.CustomTextField; +import atlantafx.base.theme.Styles; +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.TableInfo; +import com.github.sqljam.face.model.TransferReport; import com.github.sqljam.face.model.TransferRequest; +import com.github.sqljam.face.service.DatabaseInfo; import com.github.sqljam.face.service.DatabaseSession; +import com.github.sqljam.face.service.JdbcMetrics; import com.github.sqljam.face.service.TransferService; import com.github.sqljam.impexp.AbstractFileImporter; import com.github.sqljam.impexp.DataFileImporter; @@ -37,6 +47,7 @@ import com.github.sqljam.jdbc.ManualTransaction; import javafx.application.Platform; import javafx.beans.binding.Bindings; +import javafx.event.Event; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; @@ -44,6 +55,7 @@ import javafx.scene.control.Label; import javafx.scene.control.Menu; import javafx.scene.control.MenuBar; +import javafx.scene.control.MenuButton; import javafx.scene.control.MenuItem; import javafx.scene.control.ProgressIndicator; import javafx.scene.control.RadioMenuItem; @@ -52,13 +64,17 @@ import javafx.scene.control.SplitPane; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; import javafx.scene.control.ToggleGroup; import javafx.scene.control.ToolBar; import javafx.scene.control.Tooltip; +import javafx.scene.control.TreeItem; import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; +import javafx.scene.input.MouseEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; @@ -79,17 +95,30 @@ */ public class MainView extends BorderPane { + /** + * Share of the connection tree of the width of the window + */ + static final double TREE_WIDTH = 0.24; + static final double MIN_SPLIT_WIDTH = 200; + + private final AppContext context; private final ConnectionTree connectionTree; private final TabPane tabPane = new TabPane(); + private final List themeRadios = new ArrayList<>(); + private MenuBar menuBar; private int sqlTabCount; private final Label statusLabel = new Label(Messages.get("status.ready")); public MainView(AppContext context) { this.context = context; getStyleClass().add("main-view"); + // Reports of the transfers are shown on their own pages + context.setReportViewer(this::openReport); connectionTree = new ConnectionTree(context); connectionTree.setOnOpenTable(this::openTable); + connectionTree.setOnOpenSourceItem(this::openSourceItem); + connectionTree.setOnInfo(this::openInfo); connectionTree.setOnSqlQuery(this::openSqlTab); connectionTree.setOnExport(this::showExportDialog); connectionTree.setOnImportScripts(this::showImportDialog); @@ -107,15 +136,62 @@ public MainView(AppContext context) { welcome.visibleProperty().bind(Bindings.isEmpty(tabPane.getTabs())); StackPane center = new StackPane(welcome, tabPane); tabPane.visibleProperty().bind(Bindings.isNotEmpty(tabPane.getTabs())); - SplitPane splitPane = new SplitPane(connectionTree, center); - splitPane.setDividerPositions(0.24); - SplitPane.setResizableWithParent(connectionTree, false); + SplitPane splitPane = new SplitPane(createTreePane(), center); + splitPane.setDividerPositions(TREE_WIDTH); + SplitPane.setResizableWithParent(splitPane.getItems().get(0), false); + // The tree keeps the width which the user dragged it to when the window is resized. Until then it has its + // share of every width: the first layouts (before the view has its place in a window, or of a narrow + // preferred width) would keep it narrow otherwise. + boolean[] dragged = {false}; + splitPane.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { + if (event.getTarget() instanceof Node && ((Node) event.getTarget()).getStyleClass().contains( + "split-pane-divider")) { + dragged[0] = true; + } + }); + splitPane.widthProperty().addListener((obs, oldWidth, width) -> { + if (!dragged[0] && width.doubleValue() > MIN_SPLIT_WIDTH) { + // After the layout of the new width, which keeps the width of the tree + Platform.runLater(() -> { + if (!dragged[0]) { + splitPane.setDividerPositions(TREE_WIDTH); + } + }); + } + }); setTop(new VBox(createMenuBar(), createToolBar())); setCenter(splitPane); setBottom(createStatusBar()); } + /** + * The connection tree under its filter, Escape clears the filter + */ + private Node createTreePane() { + CustomTextField filterField = new CustomTextField(); + filterField.setId("treeFilterField"); + filterField.setPromptText(Messages.get("tree.filter.prompt")); + filterField.setLeft(Icons.of(Icons.SEARCH)); + Button clearButton = new Button(null, Icons.of(Icons.CANCEL)); + clearButton.setId("treeFilterClearButton"); + clearButton.getStyleClass().addAll(Styles.BUTTON_ICON, Styles.FLAT); + clearButton.setFocusTraversable(false); + clearButton.visibleProperty().bind(filterField.textProperty().isNotEmpty()); + clearButton.setOnAction(event -> filterField.clear()); + filterField.setRight(clearButton); + filterField.textProperty().bindBidirectional(connectionTree.filterProperty()); + filterField.setOnKeyPressed(event -> { + if (event.getCode() == KeyCode.ESCAPE) { + filterField.clear(); + } + }); + VBox pane = new VBox(4, filterField, connectionTree); + pane.setPadding(new Insets(4, 0, 0, 0)); + VBox.setVgrow(connectionTree, Priority.ALWAYS); + return pane; + } + private Window getWindow() { return getScene() != null ? getScene().getWindow() : null; } @@ -127,72 +203,354 @@ private VBox createWelcome() { hint.setTextAlignment(TextAlignment.CENTER); ImageView logo = Branding.logoView(170); logo.setId("welcomeLogo"); + // The first step without data sources, like the empty explorer of DataGrip + Button newDataSource = new Button(Messages.get("welcome.newDataSource"), Icons.of(Icons.ADD)); + newDataSource.setId("welcomeNewDataSourceButton"); + newDataSource.getStyleClass().add(Styles.ACCENT); + newDataSource.setOnAction(event -> newConnection()); // The logo shows the name, no title is needed - VBox welcome = new VBox(16, logo, hint); + VBox welcome = new VBox(16, logo, hint, newDataSource); welcome.getStyleClass().add("welcome"); welcome.setAlignment(Pos.CENTER); welcome.setMaxWidth(420); return welcome; } + private static MenuItem item(String key, String icon, KeyCombination accelerator, Runnable action) { + MenuItem item = new MenuItem(Messages.get(key), icon != null ? Icons.of(icon) : null); + item.setId(key.replace('.', '-') + "-menu"); + if (accelerator != null) { + item.setAccelerator(accelerator); + } + item.setOnAction(event -> action.run()); + return item; + } + + private static KeyCombination shortcut(KeyCode code, KeyCombination.Modifier... modifiers) { + KeyCombination.Modifier[] all = new KeyCombination.Modifier[modifiers.length + 1]; + all[0] = KeyCombination.SHORTCUT_DOWN; + System.arraycopy(modifiers, 0, all, 1, modifiers.length); + return new KeyCodeCombination(code, all); + } + + /** + * The menus of the main window: what the toolbar and the menus of the tree do, in one place. Items which need a + * data source are switched off while none is selected. + */ private MenuBar createMenuBar() { - MenuItem newConnection = new MenuItem(Messages.get("action.newConnection"), Icons.of(Icons.ADD)); - newConnection.setAccelerator(new KeyCodeCombination(KeyCode.N, KeyCombination.SHORTCUT_DOWN)); - newConnection.setOnAction(event -> newConnection()); - MenuItem export = new MenuItem(Messages.get("action.export"), Icons.of(Icons.EXPORT)); - export.setAccelerator(new KeyCodeCombination(KeyCode.E, KeyCombination.SHORTCUT_DOWN)); - export.setOnAction(event -> showExportDialog(connectionTree.getSelectedNode())); - 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 importParquet = new MenuItem(Messages.get("action.importParquet"), Icons.of(Icons.IMPORT)); - importParquet.setId("importParquetMenuItem"); - importParquet.setOnAction(event -> showImportParquetDialog(connectionTree.getSelectedNode())); - MenuItem sqlQuery = new MenuItem(Messages.get("action.sqlQuery"), Icons.of(Icons.SQL)); - sqlQuery.setId("sqlQueryMenuItem"); - sqlQuery.setAccelerator(new KeyCodeCombination(KeyCode.Q, KeyCombination.SHORTCUT_DOWN, - KeyCombination.SHIFT_DOWN)); - sqlQuery.setOnAction(event -> openSqlTab(connectionTree.getSelectedNode())); - MenuItem openPackage = new MenuItem(Messages.get("package.open"), Icons.of(Icons.FOLDER)); + menuBar = new MenuBar(createFileMenu(), createDatabaseMenu(), createTransferMenu(), createViewMenu(), + createHelpMenu()); + menuBar.setUseSystemMenuBar(true); + return menuBar; + } + + private Menu createFileMenu() { + MenuItem newConnection = item("action.newConnection", Icons.ADD, shortcut(KeyCode.N), this::newConnection); + MenuItem edit = item("action.editConnection", Icons.EDIT, null, this::editSelected); + MenuItem delete = item("action.deleteConnection", Icons.DELETE, null, this::deleteSelected); + MenuItem openPackage = item("package.open", Icons.FOLDER, shortcut(KeyCode.O), this::openPackage); 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(), sqlQuery, - export, importScripts, importParquet, new SeparatorMenuItem(), openPackage, new SeparatorMenuItem(), - exit); + MenuItem exit = item("action.exit", Icons.EXIT, null, this::exit); + Menu menu = new Menu(Messages.get("menu.file"), null, newConnection, edit, delete, new SeparatorMenuItem(), + openPackage, new SeparatorMenuItem(), exit); + menu.setOnShowing(event -> { + boolean source = isSourceSelected(); + edit.setDisable(!source); + delete.setDisable(!source); + }); + return menu; + } + + private Menu createDatabaseMenu() { + MenuItem connect = item("tree.connect", Icons.CONNECT, null, () -> { + TreeItem item = selectedConnectionItem(); + if (item != null) { + item.setExpanded(true); + } + }); + MenuItem disconnect = item("tree.disconnect", Icons.DISCONNECT, null, () -> { + TreeItem item = selectedConnectionItem(); + if (item != null) { + connectionTree.disconnect(item); + } + }); + MenuItem disconnectAll = item("action.disconnectAll", Icons.DISCONNECT, null, () -> List.copyOf( + connectionTree.getConnectionItems()).forEach(connectionTree::disconnect)); + MenuItem refresh = item("action.refresh", Icons.REFRESH, new KeyCodeCombination(KeyCode.F5), + connectionTree::refreshSelected); + MenuItem sqlQuery = item("action.sqlQuery", Icons.SQL, shortcut(KeyCode.Q, KeyCombination.SHIFT_DOWN), + () -> openSqlTab(connectionTree.getSelectedNode())); + sqlQuery.setId("sqlQueryMenuItem"); + MenuItem info = item("action.info", Icons.INFO, null, this::infoSelected); + MenuItem dashboard = item("action.dashboard", Icons.HEALTH, shortcut(KeyCode.D, KeyCombination.SHIFT_DOWN), + this::dashboardSelected); + Menu menu = new Menu(Messages.get("menu.database"), null, connect, disconnect, disconnectAll, + new SeparatorMenuItem(), refresh, new SeparatorMenuItem(), sqlQuery, info, dashboard); + menu.setOnShowing(event -> { + TreeItem item = selectedConnectionItem(); + boolean connected = item != null && connectionTree.isConnected(item); + connect.setDisable(item == null || connected); + disconnect.setDisable(!connected); + disconnectAll.setDisable(connectionTree.getConnectionItems().stream().noneMatch( + connectionTree::isConnected)); + sqlQuery.setDisable(connectionTree.getSelectedNode() == null); + info.setDisable(!isInfoAvailable()); + dashboard.setDisable(item == null); + }); + return menu; + } + private Menu createTransferMenu() { + MenuItem export = item("action.export", Icons.EXPORT, shortcut(KeyCode.E), + () -> showExportDialog(connectionTree.getSelectedNode())); + MenuItem importScripts = item("action.importScripts", Icons.IMPORT, shortcut(KeyCode.I), + () -> showImportDialog(connectionTree.getSelectedNode())); + MenuItem importParquet = item("action.importParquet", Icons.IMPORT, null, + () -> showImportParquetDialog(connectionTree.getSelectedNode())); + importParquet.setId("importParquetMenuItem"); + Menu menu = new Menu(Messages.get("menu.transfer"), null, export, importScripts, importParquet); + // Items of optional lines (vector databases) follow the imports when their settings switch them on + for (MenuExtension extension : ServiceLoader.load(MenuExtension.class)) { + if (extension.isEnabled(context.getSettingsStore().getConfig())) { + MenuItem item = new MenuItem(extension.getText(), Icons.of(Icons.IMPORT)); + item.setId(extension.getId()); + item.setOnAction(event -> extension.show(getWindow(), context)); + menu.getItems().add(item); + } + } + MenuItem sync = item("sync.title", Icons.REFRESH, null, this::openSync); + sync.setId("syncMenuItem"); + MenuItem newSync = item("sync.new", Icons.ADD, null, () -> { + openSync(); + Tab tab = tabPane.getSelectionModel().getSelectedItem(); + if (tab instanceof SyncTab) { + ((SyncTab) tab).edit(null); + } + }); + newSync.setId("newSyncMenuItem"); + MenuItem history = item("history.title", Icons.HISTORY, shortcut(KeyCode.H, KeyCombination.SHIFT_DOWN), + this::openHistory); + history.setId("historyMenuItem"); + MenuItem reports = item("action.reportsFolder", Icons.FOLDER, null, () -> context.openDocument(context + .getReportStore().getDirectory().toURI().toString())); + menu.getItems().addAll(new SeparatorMenuItem(), newSync, sync, new SeparatorMenuItem(), history, reports); + return menu; + } + + private Menu createViewMenu() { Menu themeMenu = new Menu(Messages.get("menu.theme"), Icons.of(Icons.THEME)); - ToggleGroup themeGroup = new ToggleGroup(); + themeMenu.setId("themeMenu"); + themeMenu.getItems().addAll(themeItems()); + MenuItem nextTheme = item("action.nextTheme", Icons.THEME, shortcut(KeyCode.T, KeyCombination.SHIFT_DOWN), + this::nextTheme); + MenuItem filter = item("action.filter", Icons.SEARCH, shortcut(KeyCode.F), () -> { + Node field = lookup("#treeFilterField"); + if (field != null) { + field.requestFocus(); + } + }); + MenuItem collapse = item("action.collapseAll", null, null, () -> connectionTree.getRoot().getChildren() + .forEach(group -> group.getChildren().forEach(item -> item.setExpanded(false)))); + MenuItem closeTab = item("action.closeTab", Icons.CANCEL, shortcut(KeyCode.W), () -> { + Tab tab = tabPane.getSelectionModel().getSelectedItem(); + if (tab != null) { + closeTabs(List.of(tab)); + } + }); + MenuItem closeOthers = item("action.closeOtherTabs", null, null, () -> { + Tab selected = tabPane.getSelectionModel().getSelectedItem(); + closeTabs(tabPane.getTabs().stream().filter(tab -> tab != selected).collect(Collectors.toList())); + }); + MenuItem closeAll = item("action.closeAllTabs", null, shortcut(KeyCode.W, KeyCombination.SHIFT_DOWN), + () -> closeTabs(List.copyOf(tabPane.getTabs()))); + MenuItem fullScreen = item("action.fullScreen", null, new KeyCodeCombination(KeyCode.F11), () -> { + if (getWindow() instanceof Stage) { + Stage stage = (Stage) getWindow(); + stage.setFullScreen(!stage.isFullScreen()); + } + }); + Menu menu = new Menu(Messages.get("menu.view"), null, themeMenu, nextTheme, new SeparatorMenuItem(), filter, + collapse, new SeparatorMenuItem(), closeTab, closeOthers, closeAll, new SeparatorMenuItem(), + fullScreen); + menu.setOnShowing(event -> { + int tabs = tabPane.getTabs().size(); + closeTab.setDisable(tabs == 0); + closeOthers.setDisable(tabs < 2); + closeAll.setDisable(tabs == 0); + }); + return menu; + } + + private Menu createHelpMenu() { + String repository = Branding.REPOSITORY_URL; + Menu videos = new Menu(Messages.get("help.videos"), Icons.of(Icons.START)); + for (String video : new String[]{"sql", "vectors", "sync"}) { + videos.getItems().add(item("help.video." + video, null, null, () -> context.openDocument(repository + + "/blob/master/docs/assets/sqljam-" + video + ".mp4"))); + } + MenuItem docs = item("help.docs", Icons.OPEN, null, () -> context.openDocument(repository + "#readme")); + MenuItem guide = item("help.developerGuide", Icons.OPEN, null, () -> context.openDocument(repository + + "/blob/master/docs/developer-guide.md")); + MenuItem issue = item("help.issue", Icons.GITHUB, null, () -> context.openDocument(repository + "/issues")); + MenuItem settings = item("help.settingsFile", Icons.SETTINGS, null, () -> { + File file = context.getSettingsStore().getConfig().getUserFile(); + context.openDocument((file.exists() ? file : file.getParentFile()).toURI().toString()); + }); + MenuItem dataFolder = item("help.dataFolder", Icons.FOLDER, null, () -> context.openDocument(context + .getSettingsStore().getConfig().getUserFile().getAbsoluteFile().getParentFile().toURI().toString())); + MenuItem restoreExample = item("action.restoreExample", Icons.CATALOG, null, () -> ExampleActions.restore( + getWindow(), context, profile -> { + // Tabs of the old example database are closed + closeTabs(profile); + selectConnection(profile); + })); + restoreExample.setId("restoreExampleMenuItem"); + MenuItem shortcuts = item("help.shortcuts", null, null, this::showShortcuts); + MenuItem about = item("action.about", Icons.INFO, null, () -> Dialogs.showAbout(getWindow(), + context::openDocument)); + return new Menu(Messages.get("menu.help"), null, videos, docs, guide, issue, new SeparatorMenuItem(), + settings, dataFolder, new SeparatorMenuItem(), restoreExample, new SeparatorMenuItem(), shortcuts, + about); + } + + /** + * Items of the themes, the theme in use is checked, the same items in the menu and in the toolbar + */ + private List themeItems() { + ToggleGroup group = new ToggleGroup(); + List items = new ArrayList<>(); for (String themeName : ThemeManager.getThemeNames()) { RadioMenuItem item = new RadioMenuItem(themeName); - item.setToggleGroup(themeGroup); + item.setToggleGroup(group); item.setSelected(themeName.equals(context.getSettings().getTheme())); - item.setOnAction(event -> { - context.getSettings().setTheme(ThemeManager.apply(themeName)); - context.saveSettings(); - }); - themeMenu.getItems().add(item); + item.setOnAction(event -> applyTheme(themeName)); + items.add(item); + themeRadios.add(item); } - MenuItem refresh = new MenuItem(Messages.get("action.refresh"), Icons.of(Icons.REFRESH)); - refresh.setAccelerator(new KeyCodeCombination(KeyCode.F5)); - refresh.setOnAction(event -> connectionTree.refreshSelected()); - Menu viewMenu = new Menu(Messages.get("menu.view"), null, themeMenu, new SeparatorMenuItem(), refresh); + return items; + } - 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(), 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; + /** + * Applies the theme, saves it and checks it in every list of the themes + */ + void applyTheme(String themeName) { + String applied = ThemeManager.apply(themeName); + context.getSettings().setTheme(applied); + context.saveSettings(); + themeRadios.forEach(item -> item.setSelected(item.getText().equals(applied))); + } + + void nextTheme() { + List names = ThemeManager.getThemeNames(); + int index = names.indexOf(context.getSettings().getTheme()); + applyTheme(names.get((index + 1) % names.size())); + } + + /** + * The keys of the menus, read from the menus themselves + */ + List getShortcuts() { + List rows = new ArrayList<>(); + for (Menu menu : menuBar.getMenus()) { + collectShortcuts(menu, rows); + } + return rows; + } + + private static void collectShortcuts(Menu menu, List rows) { + for (MenuItem item : menu.getItems()) { + if (item instanceof Menu) { + collectShortcuts((Menu) item, rows); + } else if (item.getAccelerator() != null) { + rows.add(new String[]{menu.getText() + " > " + item.getText(), item.getAccelerator() + .getDisplayText()}); + } + } + } + + private void showShortcuts() { + TableView table = new TableView<>(); + table.setId("shortcutsTable"); + TableColumn action = new TableColumn<>(Messages.get("shortcuts.action")); + action.setCellValueFactory(features -> new javafx.beans.property.ReadOnlyStringWrapper(features.getValue()[0])); + action.setPrefWidth(300); + TableColumn keys = new TableColumn<>(Messages.get("shortcuts.keys")); + keys.setCellValueFactory(features -> new javafx.beans.property.ReadOnlyStringWrapper(features.getValue()[1])); + keys.setPrefWidth(160); + table.getColumns().add(action); + table.getColumns().add(keys); + table.getItems().setAll(getShortcuts()); + table.setPrefSize(480, 420); + javafx.scene.control.Alert alert = new javafx.scene.control.Alert(javafx.scene.control.Alert.AlertType + .INFORMATION); + Dialogs.initOwner(alert, getWindow()); + Branding.applyIcons(alert); + alert.setTitle(Messages.get("help.shortcuts")); + alert.setHeaderText(Messages.get("help.shortcuts")); + alert.getDialogPane().setContent(table); + alert.show(); + } + + /** + * The connection of the selected node, e.g. of a table, null for groups and sources of extensions + */ + private TreeItem selectedConnectionItem() { + TreeItem item = connectionTree.getSelectionModel().getSelectedItem(); + while (item != null && item.getValue() != null && item.getValue().getKind() != DbNode.Kind.CONNECTION) { + item = item.getParent(); + } + return item != null && item.getValue() != null ? item : null; + } + + private boolean isSourceSelected() { + TreeItem item = connectionTree.getSelectionModel().getSelectedItem(); + return item != null && (item.getValue().getKind() == DbNode.Kind.CONNECTION || item.getValue().getKind() + == DbNode.Kind.SOURCE); + } + + private boolean isInfoAvailable() { + TreeItem item = connectionTree.getSelectionModel().getSelectedItem(); + return item != null && (item.getValue().getProfile() != null || item.getValue().getKind() + == DbNode.Kind.SOURCE); + } + + private void editSelected() { + DbNode node = connectionTree.getSelectedNode(); + if (node != null && node.getProfile() != null) { + editConnection(node.getProfile()); + } else if (connectionTree.getSelectedSource() != null) { + connectionTree.editSource(connectionTree.getSelectedSource()); + } + } + + private void deleteSelected() { + DbNode node = connectionTree.getSelectedNode(); + if (node != null && node.getProfile() != null) { + deleteConnection(node.getProfile()); + } else if (connectionTree.getSelectedSource() != null) { + connectionTree.deleteSource(connectionTree.getSelectedSource()); + } + } + + private void infoSelected() { + DbNode node = connectionTree.getSelectedNode(); + openInfo(connectionTree.getSelectedSource() != null ? connectionTree.getSelectedSource() + : node != null && node.getProfile() != null ? DbNode.connection(node.getProfile()) : null); + } + + /** + * The info of the selected data source on its dashboard + */ + private void dashboardSelected() { + TreeItem item = selectedConnectionItem(); + if (item == null) { + return; + } + openInfo(item.getValue()); + Tab tab = tabPane.getSelectionModel().getSelectedItem(); + if (tab instanceof InfoTab) { + ((InfoTab) tab).showDashboard(); + } } private Button toolButton(String icon, String key, Runnable action) { @@ -205,23 +563,20 @@ private Button toolButton(String icon, String key, Runnable action) { } private ToolBar createToolBar() { - Button editButton = toolButton(Icons.EDIT, "action.editConnection", () -> { - DbNode node = connectionTree.getSelectedNode(); - if (node != null && node.getProfile() != null) { - editConnection(node.getProfile()); - } - }); - Button deleteButton = toolButton(Icons.DELETE, "action.deleteConnection", () -> { - DbNode node = connectionTree.getSelectedNode(); - if (node != null && node.getProfile() != null) { - deleteConnection(node.getProfile()); - } - }); + Button editButton = toolButton(Icons.EDIT, "action.editConnection", this::editSelected); + Button deleteButton = toolButton(Icons.DELETE, "action.deleteConnection", this::deleteSelected); + Button infoButton = toolButton(Icons.INFO, "action.info", this::infoSelected); connectionTree.getSelectionModel().selectedItemProperty().addListener((obs, oldItem, item) -> { - boolean connection = item != null && item.getValue().getKind() == DbNode.Kind.CONNECTION; + boolean connection = item != null && (item.getValue().getKind() == DbNode.Kind.CONNECTION + || item.getValue().getKind() == DbNode.Kind.SOURCE); editButton.setDisable(!connection); deleteButton.setDisable(!connection); + // The info of the data source of any of its nodes, e.g. of a table + infoButton.setDisable(item == null || (item.getValue().getProfile() == null && item.getValue() + .getKind() != DbNode.Kind.SOURCE)); }); + infoButton.setText(Messages.get("action.info")); + infoButton.setDisable(true); editButton.setDisable(true); deleteButton.setDisable(true); Button exportButton = toolButton(Icons.EXPORT, "action.export", @@ -242,10 +597,24 @@ private ToolBar createToolBar() { HBox.setHgrow(spacer, Priority.ALWAYS); Button exitButton = toolButton(Icons.EXIT, "action.exit", this::exit); exitButton.setText(Messages.get("action.exit")); + Button syncButton = toolButton(Icons.REFRESH, "sync.title", this::openSync); + syncButton.setId("toolbarSyncButton"); + syncButton.setText(Messages.get("action.sync")); + Button historyButton = toolButton(Icons.HISTORY, "history.title", this::openHistory); + historyButton.setId("toolbarHistoryButton"); + historyButton.setText(Messages.get("action.history")); + // The themes at hand, the same as View > Theme + MenuButton themeButton = new MenuButton(null, Icons.of(Icons.THEME)); + themeButton.setId("toolbarThemeButton"); + themeButton.setTooltip(new Tooltip(Messages.get("menu.theme"))); + themeButton.getStyleClass().add("flat"); + themeButton.getItems().addAll(themeItems()); return new ToolBar(markBox, new Separator(), toolButton(Icons.ADD, "action.newConnection", this::newConnection), editButton, - deleteButton, toolButton(Icons.REFRESH, "action.refresh", connectionTree::refreshSelected), - new Separator(), sqlButton, exportButton, importButton, spacer, exitButton); + deleteButton, infoButton, toolButton(Icons.REFRESH, "action.refresh", + connectionTree::refreshSelected), + new Separator(), sqlButton, exportButton, importButton, new Separator(), syncButton, historyButton, + spacer, themeButton, exitButton); } /** @@ -278,7 +647,14 @@ private HBox createStatusBar() { } /** - * Selects and expands the connection, e.g. after login + * Connects the data sources which were connected when SqlJam was closed + */ + public void restoreConnected() { + connectionTree.restoreConnected(context.getSettings().getConnectedSources()); + } + + /** + * Selects and expands the connection, e.g. a restored example database */ public void selectConnection(ConnectionProfile profile) { if (profile != null) { @@ -287,9 +663,12 @@ public void selectConnection(ConnectionProfile profile) { } } + /** + * A new data source of the category which the user chooses in the form: relational, OLAP, or a kind of an + * extension (vector databases) when one is switched on + */ private void newConnection() { - Optional profile = ConnectionDialog.show(getWindow(), context, null); - profile.ifPresent(this::selectConnection); + connectionTree.createConnection(null); } private void editConnection(ConnectionProfile profile) { @@ -320,11 +699,17 @@ private void deleteConnection(ConnectionProfile profile) { * Closes table tabs of the data source, e.g. after it is disconnected or deleted */ private void closeTabs(ConnectionProfile profile) { - List closed = tabPane.getTabs().stream().filter(tab -> tab.getUserData() instanceof String - && ((String) tab.getUserData()).startsWith(profile.getId() + "|")).collect(Collectors.toList()); - tabPane.getTabs().removeAll(closed); - // Removed tabs are not closed by the user, the connections of SQL editors are closed here - closed.stream().filter(tab -> tab instanceof SqlTab).forEach(tab -> ((SqlTab) tab).close()); + closeTabs(tabPane.getTabs().stream().filter(tab -> tab.getUserData() instanceof String && ((String) tab + .getUserData()).startsWith(profile.getId() + "|")).collect(Collectors.toList())); + } + + /** + * Removes the tabs and tells them they are closed, e.g. SQL editors close their connections and dashboards + * their samples, like tabs closed by the user + */ + void closeTabs(List tabs) { + tabPane.getTabs().removeAll(tabs); + tabs.forEach(tab -> Event.fireEvent(tab, new Event(Tab.CLOSED_EVENT))); } /** @@ -358,6 +743,105 @@ private void showQueryExportDialog(DbNode node, String sql) { request.ifPresent(transferRequest -> startTransfer(transferRequest, 1)); } + /** + * Opens the report on its own page, an opened report is selected + */ + public void openReport(TransferReport report) { + for (Tab tab : tabPane.getTabs()) { + if (tab instanceof ReportTab && ((ReportTab) tab).getReport().getId().equals(report.getId())) { + tabPane.getSelectionModel().select(tab); + return; + } + } + ReportTab tab = new ReportTab(report); + tabPane.getTabs().add(tab); + tabPane.getSelectionModel().select(tab); + // A history which is open shows the new report + tabPane.getTabs().stream().filter(HistoryTab.class::isInstance).forEach(history -> ((HistoryTab) history) + .refresh()); + } + + /** + * Opens the sync tasks, the open tab is selected + */ + public void openSync() { + for (Tab tab : tabPane.getTabs()) { + if (tab instanceof SyncTab) { + ((SyncTab) tab).refresh(); + tabPane.getSelectionModel().select(tab); + return; + } + } + SyncTab tab = new SyncTab(context, this::openReport); + tabPane.getTabs().add(tab); + tabPane.getSelectionModel().select(tab); + } + + /** + * Opens the history of the imports, it is read again when it is open already + */ + public void openHistory() { + for (Tab tab : tabPane.getTabs()) { + if (tab instanceof HistoryTab) { + ((HistoryTab) tab).refresh(); + tabPane.getSelectionModel().select(tab); + return; + } + } + HistoryTab tab = new HistoryTab(context, this::openReport); + tabPane.getTabs().add(tab); + tabPane.getSelectionModel().select(tab); + } + + /** + * Opens the tab of an item of a source of an extension, e.g. a collection of a vector database, like a table + */ + void openSourceItem(DbNode node) { + String key = node.getSource().getId() + "|item|" + node.getItem().getKey(); + for (Tab tab : tabPane.getTabs()) { + if (key.equals(tab.getUserData())) { + tabPane.getSelectionModel().select(tab); + return; + } + } + Tab tab = node.getSource().openItem(node.getItem(), getWindow(), context); + tab.setUserData(key); + tabPane.getTabs().add(tab); + tabPane.getSelectionModel().select(tab); + } + + /** + * Opens the info tab of a data source or of a source of an extension, an opened one is selected + */ + void openInfo(DbNode node) { + if (node == null || (node.getProfile() == null && node.getSource() == null)) { + return; + } + String key = (node.getSource() != null ? node.getSource().getId() : node.getProfile().getId()) + "|info"; + for (Tab tab : tabPane.getTabs()) { + if (key.equals(tab.getUserData())) { + tabPane.getSelectionModel().select(tab); + ((InfoTab) tab).refresh(); + return; + } + } + InfoTab tab; + if (node.getSource() != null) { + DataSourceExtension.Source source = node.getSource(); + tab = new InfoTab(source.getName(), DbIcons.of(source.getTypeName()), source::loadOverview, + source::loadHealth); + } else { + ConnectionProfile profile = node.getProfile(); + DatabaseSession session = context.getSessionManager().getSession(profile); + DatabaseInfo info = new DatabaseInfo(profile, session); + tab = new InfoTab(profile.getName(), DbTypeCells.iconOf(profile), info::getOverview, info::getHealth, + () -> new JdbcMetrics(profile, session)); + } + tab.setUserData(key); + tabPane.getTabs().add(tab); + tabPane.getSelectionModel().select(tab); + } + void openTable(DbNode node) { String key = node.getProfile().getId() + "|" + TableTab.getKey(node); for (Tab tab : tabPane.getTabs()) { @@ -392,7 +876,7 @@ private void showExportDialog(DbNode node, TableQuery query) { request.ifPresent(transferRequest -> startTransfer(transferRequest, dialog.getListedTableCount())); } - private void startTransfer(TransferRequest transferRequest, int listedTableCount) { + ProgressDialog startTransfer(TransferRequest transferRequest, int listedTableCount) { int totalTables = transferRequest.getTables().isEmpty() ? listedTableCount : transferRequest.getTables().size(); String title = transferRequest.getTarget() == TransferRequest.Target.SCRIPT @@ -403,6 +887,8 @@ private void startTransfer(TransferRequest transferRequest, int listedTableCount progress.setOutputDirectory(transferRequest.getOutputDirectory()); } TransferService transferService = context.getTransferService(); + TransferService.describeReport(transferRequest, progress.getReport()); + progress.setReportVerifier(report -> transferService.verifyReport(transferRequest, report)); progress.setSkipErrors(!transferRequest.isFailFast()); // One transaction is committed when the import is complete, or by the user when it is confirmed AtomicReference transaction = new AtomicReference<>(); @@ -419,6 +905,7 @@ private void startTransfer(TransferRequest transferRequest, int listedTableCount context.getSessionManager().closeSession(transferRequest.getTargetProfile().getId()); } }); + return progress; } private void showImportDialog(DbNode node) { @@ -429,12 +916,24 @@ private void showImportDialog(DbNode node) { request.ifPresent(this::startImport); } - private void startImport(ImportPackageDialog.ImportRequest importRequest) { + ProgressDialog startImport(ImportPackageDialog.ImportRequest importRequest) { int totalTables = importRequest.getManifest() != null ? importRequest.getManifest().getTables().size() : 0; ProgressDialog progress = new ProgressDialog(getWindow(), context, Messages.format( "progress.importTitle", importRequest.getTarget().getName()), totalTables); progress.setSkipErrors(!importRequest.isStopOnError()); + describeImport(progress.getReport(), TransferReport.Kind.PACKAGE_IMPORT, importRequest.getDirectory() + .getAbsolutePath(), importRequest.getTarget(), importRequest.getCatalog(), importRequest.getSchema(), + importRequest.isOneTransaction(), !importRequest.isStopOnError()); + if (importRequest.getManifest() != null) { + // The tables of the package with their rows, which the target is checked against + importRequest.getManifest().getTables().forEach(table -> progress.getReport().table(null, null, table + .getName()).setSourceRows(table.getRows())); + } + progress.getReport().getOptions().put("Rows per commit", String.valueOf(importRequest.getCommitRows())); + // Tables which the package created hold its rows only, rows of tables which were there are not compared + progress.setReportVerifier(report -> countRows(report, importRequest.getTarget(), importRequest.getCatalog(), + importRequest.getSchema(), report.getObjects().containsKey("Tables"))); AtomicReference transaction = new AtomicReference<>(); progress.run(() -> { if (importRequest.isOneTransaction()) { @@ -447,6 +946,40 @@ private void startImport(ImportPackageDialog.ImportRequest importRequest) { }, (AbstractFileImporter importer) -> Messages.format("import.summary", importer.getExecutedCount(), importer.getLobCount(), importer.getFailedCount()), transaction::get, importRequest.isConfirmCommit(), () -> context.getSessionManager().closeSession(importRequest.getTarget().getId())); + return progress; + } + + /** + * Header of the report of an import of files into a database + */ + private static void describeImport(TransferReport report, TransferReport.Kind kind, String source, + ConnectionProfile target, String catalog, String schema, boolean oneTransaction, + boolean skipErrors) { + report.setKind(kind); + report.setSource(source); + StringBuilder text = new StringBuilder(target.getName()); + for (String part : new String[]{catalog, schema}) { + if (StringUtils.isNotBlank(part)) { + text.append(" / ").append(part); + } + } + report.setTarget(text.toString()); + report.setTransaction(oneTransaction ? TransferReport.Transaction.ONE : TransferReport.Transaction.EVERY_ROWS); + report.getOptions().put("Skip rows with errors", String.valueOf(skipErrors)); + } + + /** + * Rows of the tables of the report in the target + * + * @param comparable whether the tables were created by the import, so that their rows are its rows + */ + private static void countRows(TransferReport report, ConnectionProfile target, String catalog, String schema, + boolean comparable) 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())) { + TransferService.countTargetRows(connection, catalog, schema, report, name -> name, comparable); + } } private void showImportParquetDialog(DbNode node) { @@ -456,10 +989,19 @@ private void showImportParquetDialog(DbNode node) { request.ifPresent(this::startFileImport); } - private void startFileImport(ImportParquetDialog.ParquetRequest parquetRequest) { + ProgressDialog startFileImport(ImportParquetDialog.ParquetRequest parquetRequest) { ProgressDialog progress = new ProgressDialog(getWindow(), context, Messages.format( "progress.importTitle", parquetRequest.getTarget().getName()), 1); progress.setSkipErrors(parquetRequest.isSkipErrors()); + describeImport(progress.getReport(), TransferReport.Kind.FILE_IMPORT, parquetRequest.getFiles().stream() + .map(File::getName).collect(Collectors.joining(", ")), parquetRequest.getTarget(), parquetRequest + .getCatalog(), parquetRequest.getSchema(), parquetRequest.isOneTransaction(), parquetRequest + .isSkipErrors()); + progress.getReport().getOptions().put("Mode", String.valueOf(parquetRequest.getMode())); + progress.getReport().getOptions().put("Rows per commit", String.valueOf(parquetRequest.getCommitRows())); + progress.getReport().table(null, null, parquetRequest.getTableName()); + progress.setReportVerifier(report -> countRows(report, parquetRequest.getTarget(), parquetRequest + .getCatalog(), parquetRequest.getSchema(), false)); AtomicReference transaction = new AtomicReference<>(); progress.run(() -> { if (parquetRequest.isOneTransaction()) { @@ -474,6 +1016,7 @@ private void startFileImport(ImportParquetDialog.ParquetRequest parquetRequest) }, (DataFileImporter importer) -> Messages.format("parquet.summary", importer.getImportedRows(), parquetRequest.getTableName()), transaction::get, parquetRequest.isConfirmCommit(), () -> context.getSessionManager().closeSession(parquetRequest.getTarget().getId())); + return progress; } /** diff --git a/src/main/java/com/github/sqljam/face/view/MenuExtension.java b/src/main/java/com/github/sqljam/face/view/MenuExtension.java new file mode 100644 index 0000000..66433bb --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/MenuExtension.java @@ -0,0 +1,47 @@ +/* + * 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 com.github.sqljam.config.Config; +import javafx.stage.Window; + +/** + * @Description: MenuExtension adds an item to the File menu, found by ServiceLoader. An extension is a thin entry of + * an optional line (vector databases of the vdb line): its own settings switch it on or off, and the + * classes of the line are loaded only when its item is chosen, so that the main line runs without it. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public interface MenuExtension { + + /** + * Id of the menu item, e.g. vdbImportMenuItem + */ + String getId(); + + /** + * Text of the menu item + */ + String getText(); + + /** + * Whether the item is shown, e.g. sqljam.vdb.enabled + */ + boolean isEnabled(Config config); + + void show(Window owner, AppContext context); +} diff --git a/src/main/java/com/github/sqljam/face/view/ProfileRegistry.java b/src/main/java/com/github/sqljam/face/view/ProfileRegistry.java index d59cbbf..05cff52 100644 --- a/src/main/java/com/github/sqljam/face/view/ProfileRegistry.java +++ b/src/main/java/com/github/sqljam/face/view/ProfileRegistry.java @@ -36,6 +36,10 @@ public class ProfileRegistry { private final ConnectionStore connectionStore; private final Map sessionPasswords = new ConcurrentHashMap<>(); + /** + * Passwords or passphrases of the SSH tunnels which are not remembered, of this session only + */ + private final Map sessionSshSecrets = new ConcurrentHashMap<>(); public ProfileRegistry(ConnectionStore connectionStore) { this.connectionStore = connectionStore; @@ -58,6 +62,10 @@ private ConnectionProfile withSessionPassword(ConnectionProfile profile) { if (copy.getPassword() == null && password != null) { copy.setPassword(password); } + String sshSecret = sessionSshSecrets.get(profile.getId()); + if (copy.getSsh() != null && copy.getSsh().getSecret() == null && sshSecret != null) { + copy.getSsh().setSecret(sshSecret); + } return copy; } @@ -73,19 +81,30 @@ public boolean isPasswordRemembered(String id) { */ public void saveProfile(ConnectionProfile profile, boolean passwordRemembered) throws IOException { ConnectionProfile saved = profile.copy(); + if (passwordRemembered && saved.getPassword() == null) { + // An empty password which is remembered, apart from one which is not (null) + saved.setPassword(""); + } if (passwordRemembered) { sessionPasswords.remove(profile.getId()); + sessionSshSecrets.remove(profile.getId()); } else { saved.setPassword(null); if (profile.getPassword() != null) { sessionPasswords.put(profile.getId(), profile.getPassword()); } + sessionSshSecrets.remove(profile.getId()); + if (saved.getSsh() != null && saved.getSsh().getSecret() != null) { + sessionSshSecrets.put(profile.getId(), saved.getSsh().getSecret()); + saved.getSsh().setSecret(null); + } } connectionStore.saveProfile(saved); } public void removeProfile(String id) throws IOException { sessionPasswords.remove(id); + sessionSshSecrets.remove(id); connectionStore.removeProfile(id); } } 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 a7ca42d..9053754 100644 --- a/src/main/java/com/github/sqljam/face/view/ProgressDialog.java +++ b/src/main/java/com/github/sqljam/face/view/ProgressDialog.java @@ -31,6 +31,8 @@ import java.util.function.Supplier; import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.service.ReportRecorder; import com.github.sqljam.impexp.ExportCancelledException; import com.github.sqljam.impexp.ExportListener; import com.github.sqljam.jdbc.ManualTransaction; @@ -104,6 +106,25 @@ public class ProgressDialog { private final Button commitButton = new Button(Messages.get("progress.commit"), Icons.of(Icons.START)); private final Button rollbackButton = new Button(Messages.get("progress.rollback"), Icons.of(Icons.CANCEL)); private final Label manifestLabel = new Label(); + /** + * Opens the report of the transfer on its own page, shown when the report is saved + */ + private final Button viewReportButton = new Button(Messages.get("report.view"), Icons.of(Icons.INFO)); + /** + * Report of the transfer, its header is filled by the caller, its rows by the events + */ + private final TransferReport report = new TransferReport(); + private final ReportRecorder recorder = new ReportRecorder(report); + private ReportVerifier reportVerifier; + private Throwable failure; + /** + * Commit or roll back of the user, null without a decision + */ + private Boolean decision; + /** + * Summary of the result of a job whose transaction is committed or rolled back afterwards, for its report + */ + private String resultSummary; private final AtomicBoolean cancelled = new AtomicBoolean(); private final int totalTables; private int completedTables; @@ -150,9 +171,18 @@ public String toString() { } } + /** + * Counts the rows of the target tables after the transfer, in the background + */ + @FunctionalInterface + public interface ReportVerifier { + void verify(TransferReport report) throws Exception; + } + public ProgressDialog(Window owner, AppContext context, String title, int totalTables) { this.context = context; this.totalTables = totalTables; + report.setTitle(title); stage.initOwner(owner); Branding.applyIcons(stage); stage.initModality(Modality.NONE); @@ -251,8 +281,17 @@ protected void updateItem(LogEntry item, boolean empty) { rollbackButton.setOnAction(event -> decide(false)); Region spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); - HBox buttons = new HBox(8, errorsOnlyCheck, saveLogButton, spacer, viewPackageButton, openFolderButton, - rollbackButton, commitButton, cancelButton); + viewReportButton.setId("viewReportButton"); + viewReportButton.setVisible(false); + viewReportButton.setManaged(false); + viewReportButton.setMinWidth(Region.USE_PREF_SIZE); + viewReportButton.setOnAction(event -> { + if (context.getReportViewer() != null) { + context.getReportViewer().accept(report); + } + }); + HBox buttons = new HBox(8, errorsOnlyCheck, saveLogButton, spacer, viewReportButton, viewPackageButton, + openFolderButton, rollbackButton, commitButton, cancelButton); buttons.setAlignment(Pos.CENTER_RIGHT); Region headerSpacer = new Region(); @@ -312,6 +351,7 @@ public ExportListener getListener() { return new ExportListener() { @Override public void onTableStart(String catalogName, String schemaName, String tableName, long totalRows) { + recorder.tableStarted(catalogName, schemaName, tableName, totalRows); Platform.runLater(() -> { tableLabel.setText(Messages.format("progress.table", qualify(catalogName, schemaName, tableName), totalRows)); @@ -322,6 +362,7 @@ public void onTableStart(String catalogName, String schemaName, String tableName @Override public void onTableProgress(String catalogName, String schemaName, String tableName, long processedRows, long totalRows) { + recorder.tableProgress(catalogName, schemaName, tableName, processedRows); Platform.runLater(() -> { if (totalRows > 0) { tableBar.setProgress(Math.min(1.0, (double) processedRows / totalRows)); @@ -331,6 +372,7 @@ public void onTableProgress(String catalogName, String schemaName, String tableN @Override public void onTableEnd(String catalogName, String schemaName, String tableName, long processedRows) { + recorder.tableEnded(catalogName, schemaName, tableName, processedRows); Platform.runLater(() -> { completedTables++; tableBar.setProgress(1); @@ -352,12 +394,24 @@ public void onMessage(String message) { @Override public void onError(String message, Throwable e) { + recorder.error(message); Platform.runLater(() -> { errorCount++; addLog(message, true); }); } + @Override + public void onRowSkipped(String catalogName, String schemaName, String tableName, String key, + Throwable e) { + recorder.rowSkipped(catalogName, schemaName, tableName, key, e); + } + + @Override + public void onDdlExecuted(String statement, boolean successful) { + recorder.ddlExecuted(statement, successful); + } + @Override public boolean isCancelled() { return cancelled.get(); @@ -478,6 +532,7 @@ public void run(Callable job, Function summary, Supplier { try { @@ -496,6 +551,7 @@ public void run(Callable job, Function summary, Supplier void run(Callable job, Function summary, Supplier addLog(Messages.get("progress.rolledBack"), false)); } Platform.runLater(() -> { + if (pending != null) { + report.setRolledBack(true); + } if (!cancel) { errorCount++; addLog(StringUtils.defaultIfBlank(rootMessage(e), e.getClass().getName()), true); @@ -526,6 +585,7 @@ private void awaitDecision(ManualTransaction transaction, String summary, Runnab if (StringUtils.isNotBlank(summary)) { addLog(summary, false); } + resultSummary = summary; pendingTransaction = transaction; pendingCompleted = onCompleted; if (transaction.isRolledBack() || (errorCount > 0 && !skipErrors)) { @@ -615,6 +675,7 @@ private void completeTransaction(ManualTransaction transaction, String summary, if (StringUtils.isNotBlank(summary)) { addLog(summary, false); } + resultSummary = summary; pendingTransaction = transaction; pendingCompleted = onCompleted; automatic = true; @@ -659,6 +720,7 @@ private void decide(boolean commit, boolean close) { if (transaction == null || transaction.isFinished()) { return; } + decision = commit; commitButton.setDisable(true); rollbackButton.setDisable(true); statusLabel.setText(Messages.get(commit ? "progress.committing" : "progress.rollingBack")); @@ -693,6 +755,8 @@ private void decide(boolean commit, boolean close) { completeDecision(status, !commit, close); }, e -> { errorCount++; + // The commit or the roll back failed, the report tells why + failure = e; addLog(StringUtils.defaultIfBlank(rootMessage(e), e.getClass().getName()), true); completeDecision(Messages.get("progress.failed"), true, close); }); @@ -782,11 +846,73 @@ private void finish(String status, boolean failed, boolean cancelled, String sum manifestLabel.setManaged(true); } } + finishReport(failed, cancelled, summary); if (onCompleted != null) { onCompleted.run(); } } + /** + * Status of the report by the end: cancelled, rolled back by the user, failed, committed or completed + */ + TransferReport.Status reportStatus(boolean failed, boolean cancelled) { + if (cancelled) { + return Boolean.FALSE.equals(decision) ? TransferReport.Status.ROLLED_BACK : TransferReport.Status.CANCELLED; + } + if (failed) { + return Boolean.FALSE.equals(decision) ? TransferReport.Status.ROLLED_BACK : TransferReport.Status.FAILED; + } + if (Boolean.TRUE.equals(decision) && !automatic) { + return TransferReport.Status.COMMITTED; + } + return errorCount > 0 || report.getSkippedRowCount() > 0 ? TransferReport.Status.COMPLETED_WITH_ERRORS + : TransferReport.Status.COMPLETED; + } + + /** + * The report is completed: its status, the rows of the target tables, and it is saved in the history. + * A report which can not be counted or saved is shown all the same. + */ + private void finishReport(boolean failed, boolean cancelled, String summary) { + TransferReport.Status status = reportStatus(failed, cancelled); + if (status == TransferReport.Status.ROLLED_BACK) { + report.setRolledBack(true); + } + recorder.finish(status, failed && !cancelled ? failure : null, summary != null ? summary : resultSummary); + ReportVerifier verifier = reportVerifier; + TaskRunner.run(() -> { + if (verifier != null) { + try { + verifier.verify(report); + } catch (Exception e) { + log.warn("Rows of the target are not counted: {}", e.getMessage()); + } + } + context.getReportStore().save(report); + return report; + }, saved -> showReportButton(), e -> { + addLog(Messages.format("report.notSaved", StringUtils.defaultIfBlank(e.getMessage(), e.getClass() + .getSimpleName())), true); + showReportButton(); + }); + } + + private void showReportButton() { + viewReportButton.setVisible(true); + viewReportButton.setManaged(true); + } + + /** + * Report of the transfer, its header is filled by the caller before the transfer runs + */ + public TransferReport getReport() { + return report; + } + + public void setReportVerifier(ReportVerifier reportVerifier) { + this.reportVerifier = reportVerifier; + } + private void updateElapsed() { Duration elapsed = Duration.millis(System.currentTimeMillis() - startTime); long seconds = (long) elapsed.toSeconds(); diff --git a/src/main/java/com/github/sqljam/face/view/ReportTab.java b/src/main/java/com/github/sqljam/face/view/ReportTab.java new file mode 100644 index 0000000..58d9e65 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/ReportTab.java @@ -0,0 +1,238 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.service.ReportExporter; +import javafx.beans.property.ReadOnlyStringWrapper; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.MenuButton; +import javafx.scene.control.MenuItem; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Tab; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; +import javafx.scene.control.TitledPane; +import javafx.scene.control.ToolBar; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.BorderPane; +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; + +/** + * @Description: ReportTab shows the report of a transfer on its own page: its status, a summary, the tables with + * their rows and verdicts, the objects which were created, the rows which were skipped and the error + * which ended it. Export writes it as Markdown, HTML or JSON. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class ReportTab extends Tab { + + private final TransferReport report; + + public ReportTab(TransferReport report) { + this.report = report; + setText(Messages.format("report.tab", ReportExporter.time(report.getStartedAt()))); + setGraphic(Icons.of(Icons.INFO)); + setTooltip(new Tooltip(StringUtils.defaultString(report.getTitle()))); + setId("reportTab"); + + Label status = new Label(ReportExporter.words(report.getStatus())); + status.setId("reportStatus"); + status.getStyleClass().addAll("report-status", report.getStatus().isKept() ? "status-success" + : "status-error"); + status.setStyle("-fx-font-size: 18px; -fx-font-weight: bold;"); + Label title = new Label(StringUtils.defaultString(report.getTitle())); + title.setStyle("-fx-font-size: 15px;"); + + VBox content = new VBox(14, new VBox(4, status, title), summary()); + if (!report.getOptions().isEmpty()) { + content.getChildren().add(section(Messages.get("report.options"), options(), false)); + } + content.getChildren().add(section(Messages.format("report.tables", report.getTables().size()), tables(), + true)); + if (!report.getObjects().isEmpty() || report.getFailedStatements() > 0) { + content.getChildren().add(section(Messages.get("report.objects"), objects(), true)); + } + if (report.getSkippedRowCount() > 0) { + content.getChildren().add(section(Messages.format("report.skipped", report.getSkippedRowCount()), + skippedRows(), true)); + } + if (StringUtils.isNotBlank(report.getFailureTrace())) { + TextArea trace = new TextArea(report.getFailureTrace()); + trace.setId("reportTrace"); + trace.setEditable(false); + trace.setPrefRowCount(14); + trace.getStyleClass().add("mono"); + content.getChildren().add(section(Messages.get("report.error"), trace, false)); + } + content.setPadding(new Insets(16)); + ScrollPane scroll = new ScrollPane(content); + scroll.setFitToWidth(true); + BorderPane root = new BorderPane(scroll); + root.setTop(toolBar()); + root.getStyleClass().add("report-tab"); + setContent(root); + } + + public TransferReport getReport() { + return report; + } + + private ToolBar toolBar() { + MenuButton export = new MenuButton(Messages.get("report.export"), Icons.of(Icons.EXPORT)); + export.setId("reportExportButton"); + for (ReportExporter.Format format : ReportExporter.Format.values()) { + MenuItem item = new MenuItem(format == ReportExporter.Format.MARKDOWN ? "Markdown" : format.name()); + item.setId("reportExport" + format.name()); + item.setOnAction(event -> export(format)); + export.getItems().add(item); + } + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + Label id = new Label(report.getId()); + id.getStyleClass().add("muted"); + return new ToolBar(export, spacer, id); + } + + private void export(ReportExporter.Format format) { + FileChooser chooser = new FileChooser(); + chooser.setTitle(Messages.get("report.export")); + chooser.setInitialFileName("sqljam-report-" + report.getId().substring(0, 8) + "." + format + .getExtension()); + File file = chooser.showSaveDialog(getTabPane() != null && getTabPane().getScene() != null ? getTabPane() + .getScene().getWindow() : null); + if (file != null) { + exportTo(file, format); + } + } + + /** + * Writes the report as the format to the file, an error is shown + */ + void exportTo(File file, ReportExporter.Format format) { + try { + Files.writeString(file.toPath(), ReportExporter.export(report, format), StandardCharsets.UTF_8); + } catch (Exception e) { + Dialogs.showError(getTabPane() != null && getTabPane().getScene() != null ? getTabPane().getScene() + .getWindow() : null, Messages.get("report.export"), e); + } + } + + private static TitledPane section(String title, Node content, boolean expanded) { + TitledPane pane = new TitledPane(title, content); + pane.setExpanded(expanded); + pane.setAnimated(false); + return pane; + } + + private Node summary() { + GridPane grid = new GridPane(); + grid.setId("reportSummary"); + grid.setHgap(18); + grid.setVgap(6); + int row = 0; + for (String[] item : ReportExporter.summary(report)) { + Label name = new Label(item[0]); + name.getStyleClass().add("muted"); + Label value = new Label(item[1]); + value.setWrapText(true); + grid.addRow(row++, name, value); + } + return grid; + } + + private Node options() { + List rows = new ArrayList<>(); + report.getOptions().forEach((name, value) -> rows.add(new String[]{name, value})); + return table("reportOptions", new String[]{Messages.get("report.option"), Messages.get("report.value")}, + rows); + } + + private Node tables() { + List rows = new ArrayList<>(); + report.getTables().forEach(table -> rows.add(ReportExporter.tableRow(table))); + return table("reportTables", ReportExporter.TABLE_HEADERS, rows); + } + + private Node objects() { + List rows = new ArrayList<>(); + report.getObjects().forEach((kind, count) -> rows.add(new String[]{kind, String.valueOf(count)})); + if (report.getFailedStatements() > 0) { + rows.add(new String[]{Messages.get("report.failedStatements"), String.valueOf(report + .getFailedStatements())}); + } + return table("reportObjects", new String[]{Messages.get("report.kind"), Messages.get("report.count")}, rows); + } + + private Node skippedRows() { + List rows = new ArrayList<>(); + report.getSkippedRows().forEach(row -> rows.add(new String[]{row.getTable(), row.getKey(), + row.getError()})); + VBox box = new VBox(6, table("reportSkippedRows", new String[]{Messages.get("report.table"), Messages.get( + "report.row"), Messages.get("report.error")}, rows)); + long more = report.getSkippedRowCount() - report.getSkippedRows().size(); + if (more > 0) { + Label label = new Label(Messages.format("report.moreSkipped", more)); + label.getStyleClass().add("muted"); + box.getChildren().add(label); + } + return box; + } + + /** + * A table of text rows, as high as its rows up to a limit; long values are shown whole by a double click + */ + private static TableView> table(String id, String[] headers, List rows) { + TableView> table = new TableView<>(); + table.setId(id); + for (int i = 0; i < headers.length; i++) { + int index = i; + TableColumn, String> column = new TableColumn<>(headers[i]); + column.setCellValueFactory(features -> new ReadOnlyStringWrapper(index < features.getValue().size() + ? features.getValue().get(index) : null)); + column.setCellFactory(tableColumn -> new ValueCell()); + column.setComparator(ValueCell.COMPARATOR); + column.setPrefWidth(i == 0 ? 220 : 120); + table.getColumns().add(column); + } + rows.forEach(row -> table.getItems().add(Arrays.asList(row))); + table.setFixedCellSize(28); + // Rows, the header and the horizontal scroll bar of wide tables + table.setPrefHeight(Math.min(28 * rows.size() + 52, 440)); + TableColumns.fill(table); + table.setPlaceholder(new Label(Messages.get("report.none"))); + VBox.setVgrow(table, Priority.NEVER); + table.setMinHeight(Region.USE_PREF_SIZE); + return table; + } +} diff --git a/src/main/java/com/github/sqljam/face/view/SourceItem.java b/src/main/java/com/github/sqljam/face/view/SourceItem.java new file mode 100644 index 0000000..66beec1 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/SourceItem.java @@ -0,0 +1,104 @@ +/* + * Copyright 2023-2026 Fred Feng + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.sqljam.face.view; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * @Description: SourceItem is a node of a source of an extension in the connection tree, like a catalog or a table of + * a data source: a database (Milvus, Chroma), a collection, or a part of a collection which is read + * apart (a partition of Milvus, a tenant of Weaviate, a namespace of Pinecone). Its path is the names + * from the source down to it. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class SourceItem { + + public enum Kind { + DATABASE, COLLECTION, PARTITION + } + + private final Kind kind; + private final List path; + /** + * Name of the kind of a partition, e.g. Tenant + */ + private final String kindName; + private final boolean leaf; + + public SourceItem(Kind kind, List path, String kindName, boolean leaf) { + this.kind = kind; + this.path = Collections.unmodifiableList(new ArrayList<>(path)); + this.kindName = kindName; + this.leaf = leaf; + } + + /** + * A child of this item + */ + public SourceItem child(Kind childKind, String name, String childKindName, boolean childLeaf) { + List childPath = new ArrayList<>(path); + childPath.add(name); + return new SourceItem(childKind, childPath, childKindName, childLeaf); + } + + public Kind getKind() { + return kind; + } + + public List getPath() { + return path; + } + + public String getName() { + return path.get(path.size() - 1); + } + + public String getKindName() { + return kindName; + } + + public boolean isLeaf() { + return leaf; + } + + /** + * Unique key of the item in its source + */ + public String getKey() { + return kind + ":" + String.join("/", path); + } + + @Override + public boolean equals(Object other) { + return other instanceof SourceItem && kind == ((SourceItem) other).kind && path.equals(((SourceItem) other) + .path); + } + + @Override + public int hashCode() { + return Objects.hash(kind, path); + } + + @Override + public String toString() { + return getName(); + } +} diff --git a/src/main/java/com/github/sqljam/face/view/SqlTab.java b/src/main/java/com/github/sqljam/face/view/SqlTab.java index 386d14a..6f5091b 100644 --- a/src/main/java/com/github/sqljam/face/view/SqlTab.java +++ b/src/main/java/com/github/sqljam/face/view/SqlTab.java @@ -26,6 +26,7 @@ import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; +import lombok.extern.slf4j.Slf4j; import com.github.sqljam.face.model.ConnectionProfile; import com.github.sqljam.face.model.SqlResult; import com.github.sqljam.face.service.DatabaseSession; @@ -34,9 +35,11 @@ import com.github.sqljam.impexp.Dialect; import com.github.sqljam.jdbc.JdbcUtils; import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.collections.ObservableList; import javafx.geometry.Orientation; import javafx.geometry.Pos; import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; import javafx.scene.control.Label; import javafx.scene.control.ListCell; import javafx.scene.control.ListView; @@ -55,7 +58,6 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; -import lombok.extern.slf4j.Slf4j; /** * @Description: SqlTab runs SQL on a data source and exports the rows of a query, SqlJam is an import and export @@ -82,6 +84,7 @@ public class SqlTab extends Tab { private final DatabaseSession session; private final SqlEditor editor = new SqlEditor(); private final TableView> resultTable = new TableView<>(); + private final ObservableList> resultRows = TableColumns.sortedRows(resultTable); private final ListView messageList = new ListView<>(); private final TabPane resultTabs = new TabPane(); private final Tab resultTab = new Tab(Messages.get("sql.result")); @@ -91,6 +94,13 @@ public class SqlTab extends Tab { private final Button stopButton = new Button(Messages.get("sql.stop"), Icons.of(Icons.CANCEL)); private final Button exportButton = new Button(Messages.get("sql.export"), Icons.of(Icons.EXPORT)); private final Label statusLabel = new Label(); + /** + * Database and schema in which the statements run, for this editor only: the data source keeps its own + */ + private final ComboBox catalogCombo = new ComboBox<>(); + private final ComboBox schemaCombo = new ComboBox<>(); + private final Label targetLabel = new Label(); + private boolean switching; private final AtomicReference running = new AtomicReference<>(); private final AtomicBoolean cancelled = new AtomicBoolean(); private Connection connection; @@ -129,6 +139,7 @@ public SqlTab(AppContext context, DbNode node, int number) { exportButton.setId("sqlExportButton"); statusLabel.setId("sqlStatusLabel"); resultTable.setId("sqlResultTable"); + TableColumns.fill(resultTable); messageList.setId("sqlMessageList"); runButton.setTooltip(new Tooltip(Messages.get("sql.run.tooltip"))); runAllButton.setTooltip(new Tooltip(Messages.get("sql.runAll.tooltip"))); @@ -157,12 +168,14 @@ public SqlTab(AppContext context, DbNode node, int number) { this.schema = StringUtils.defaultIfBlank(tableSchema, schema); } - Label targetLabel = new Label(describeTarget()); + targetLabel.setText(profile.getName()); targetLabel.setId("sqlTargetLabel"); targetLabel.getStyleClass().add("muted"); Region spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); - ToolBar toolBar = new ToolBar(runButton, runAllButton, stopButton, exportButton, spacer, targetLabel); + ToolBar toolBar = new ToolBar(runButton, runAllButton, stopButton, exportButton, spacer, targetLabel, + catalogCombo, schemaCombo); + setUpSwitches(); resultTable.setPlaceholder(new Label(Messages.get("sql.noResult"))); resultTab.setContent(resultTable); @@ -198,6 +211,101 @@ protected void updateItem(LogEntry item, boolean empty) { setOnClosed(event -> close()); } + /** + * Database and schema of the editor like the editors of DBeaver and DataGrip, each shown when the type of the + * database has it. They are read in the background, a choice applies to the next statement. + */ + private void setUpSwitches() { + DbType dbType = profile.getDbType(); + catalogCombo.setId("sqlCatalogCombo"); + schemaCombo.setId("sqlSchemaCombo"); + catalogCombo.setPromptText(Messages.get("sql.catalog")); + schemaCombo.setPromptText(Messages.get("sql.schema")); + catalogCombo.setTooltip(new Tooltip(Messages.get("sql.catalog.tooltip"))); + schemaCombo.setTooltip(new Tooltip(Messages.get("sql.schema.tooltip"))); + boolean catalogs = dbType.isCatalogSupported(); + boolean schemas = dbType.isSchemaSupported(); + catalogCombo.setVisible(catalogs); + catalogCombo.setManaged(catalogs); + schemaCombo.setVisible(schemas); + schemaCombo.setManaged(schemas); + if (catalogs) { + TaskRunner.run(session::getCatalogs, names -> { + switching = true; + catalogCombo.getItems().setAll(names); + catalogCombo.setValue(StringUtils.defaultIfBlank(catalog, profile.getDatabase())); + switching = false; + loadSchemas(); + }, e -> catalogCombo.setDisable(true)); + } else { + loadSchemas(); + } + catalogCombo.valueProperty().addListener((obs, oldValue, value) -> { + if (!switching && value != null && !value.equals(catalog)) { + switchTo(value, null); + loadSchemas(); + } + }); + schemaCombo.valueProperty().addListener((obs, oldValue, value) -> { + if (!switching && value != null && !value.equals(schema)) { + switchTo(catalog, value); + } + }); + } + + private void loadSchemas() { + if (!profile.getDbType().isSchemaSupported()) { + return; + } + String of = catalog != null ? catalog : catalogCombo.getValue(); + TaskRunner.run(() -> session.getSchemas(of), names -> { + switching = true; + schemaCombo.getItems().setAll(names); + schemaCombo.setValue(schema != null && names.contains(schema) ? schema : null); + switching = false; + }, e -> schemaCombo.setDisable(true)); + } + + /** + * Statements after the switch run in the database and schema. Another database of PostgreSQL is another + * connection, other databases switch the connection which is open. + */ + void switchTo(String newCatalog, String newSchema) { + boolean otherDatabase = !StringUtils.equals(newCatalog, catalog); + catalog = newCatalog; + schema = newSchema; + setTooltip(new Tooltip(describeTarget())); + Connection open = connection; + if (open == null) { + return; + } + if (otherDatabase && profile.getDbType() == DbType.POSTGRESQL) { + connection = null; + TaskRunner.execute(() -> JdbcUtils.closeQuietly(open)); + return; + } + String toCatalog = profile.getDbType() == DbType.POSTGRESQL ? null : newCatalog; + TaskRunner.run(() -> { + synchronized (this) { + JdbcUtils.setPath(open, toCatalog, newSchema); + } + return null; + }, result -> addMessage(Messages.format("sql.switched", describeTarget()), false), e -> { + addMessage(e.getMessage(), true); + // The next statement opens a connection in the chosen database and schema + connection = null; + JdbcUtils.closeQuietly(open); + }); + } + + String getCatalog() { + return catalog; + } + + String getSchema() { + return schema; + } + /** * The export wizard receives the data source, catalog and schema of the editor and the SQL of the last query */ @@ -350,10 +458,10 @@ private void showRows(SqlResult result) { column.setCellValueFactory(features -> new ReadOnlyObjectWrapper<>(features.getValue().get(index))); column.setCellFactory(tableColumn -> new DataPane.NullableCell()); column.setPrefWidth(DataPane.getColumnWidth(columns.get(i), result.getRows(), index)); - column.setSortable(false); + column.setComparator(ValueCell.COMPARATOR); resultTable.getColumns().add(column); } - resultTable.getItems().setAll(result.getRows()); + resultRows.setAll(result.getRows()); resultTable.setPlaceholder(new Label(Messages.get("data.empty"))); } @@ -368,6 +476,9 @@ private static String abbreviate(String sql) { private void setBusy(boolean busy) { this.busy = busy; + // The database and schema are not switched while statements run + catalogCombo.setDisable(busy); + schemaCombo.setDisable(busy); runButton.setDisable(busy); runAllButton.setDisable(busy); stopButton.setDisable(!busy); diff --git a/src/main/java/com/github/sqljam/face/view/SshForm.java b/src/main/java/com/github/sqljam/face/view/SshForm.java new file mode 100644 index 0000000..cc70ac2 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/SshForm.java @@ -0,0 +1,227 @@ +/* + * 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 org.apache.commons.lang3.StringUtils; +import com.github.sqljam.utils.SshSettings; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; +import javafx.scene.control.TextFormatter; +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; + +/** + * @Description: SshForm edits the SSH tunnel of a data source: Connect through SSH tunnel shows the SSH server, its + * user and a password or a private key with its passphrase. The host and port of the data source are + * those which the SSH server reaches, e.g. localhost. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class SshForm extends VBox { + + private final CheckBox enabledCheck = new CheckBox(Messages.get("ssh.enabled")); + private final TextField hostField = new TextField(); + private final TextField portField = new TextField(); + private final TextField usernameField = new TextField(); + private final ComboBox authenticationCombo = new ComboBox<>(); + private final PasswordField passwordField = new PasswordField(); + private final TextField keyFileField = new TextField(); + private final Button keyBrowseButton = new Button(null, Icons.of(Icons.FOLDER)); + private final PasswordField passphraseField = new PasswordField(); + private final GridPane grid = new GridPane(); + private final Label passwordLabel = new Label(Messages.get("connection.password")); + private final Label keyFileLabel = new Label(Messages.get("ssh.keyFile")); + private final Label passphraseLabel = new Label(Messages.get("ssh.passphrase")); + private final HBox keyBox; + + public SshForm(double labelWidth) { + setSpacing(10); + setId("sshForm"); + grid.setId("sshGrid"); + enabledCheck.setId("sshEnabledCheck"); + hostField.setId("sshHostField"); + portField.setId("sshPortField"); + usernameField.setId("sshUsernameField"); + authenticationCombo.setId("sshAuthenticationCombo"); + passwordField.setId("sshPasswordField"); + keyFileField.setId("sshKeyFileField"); + keyBrowseButton.setId("sshKeyBrowseButton"); + passphraseField.setId("sshPassphraseField"); + hostField.setPromptText(Messages.get("ssh.host.prompt")); + portField.setTextFormatter(new TextFormatter(change -> change.getControlNewText().matches( + "\\d{0,5}") ? change : null)); + portField.setPrefColumnCount(6); + keyFileField.setPromptText("~/.ssh/id_ed25519"); + passphraseField.setPromptText(Messages.get("ssh.passphrase.prompt")); + passwordField.setPromptText(Messages.get("ssh.password.prompt")); + authenticationCombo.getItems().setAll(SshSettings.Authentication.values()); + authenticationCombo.setMaxWidth(Double.MAX_VALUE); + authenticationCombo.setCellFactory(view -> authenticationCell()); + authenticationCombo.setButtonCell(authenticationCell()); + authenticationCombo.valueProperty().addListener((obs, oldValue, value) -> updateFields()); + keyBrowseButton.setOnAction(event -> browseKey()); + + grid.setHgap(10); + grid.setVgap(10); + ColumnConstraints labelColumn = new ColumnConstraints(); + labelColumn.setMinWidth(labelWidth); + ColumnConstraints fieldColumn = new ColumnConstraints(); + fieldColumn.setHgrow(Priority.ALWAYS); + grid.getColumnConstraints().addAll(labelColumn, fieldColumn); + Label portLabel = new Label(Messages.get("connection.port")); + portLabel.setMinWidth(Region.USE_PREF_SIZE); + HBox hostBox = new HBox(8, hostField, portLabel, portField); + hostBox.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(hostField, Priority.ALWAYS); + keyBox = new HBox(8, keyFileField, keyBrowseButton); + HBox.setHgrow(keyFileField, Priority.ALWAYS); + int row = 0; + grid.addRow(row++, new Label(Messages.get("ssh.host")), hostBox); + grid.addRow(row++, new Label(Messages.get("connection.username")), usernameField); + grid.addRow(row++, new Label(Messages.get("ssh.authentication")), authenticationCombo); + grid.addRow(row++, passwordLabel, passwordField); + grid.addRow(row++, keyFileLabel, keyBox); + grid.addRow(row, passphraseLabel, passphraseField); + grid.visibleProperty().bind(enabledCheck.selectedProperty()); + grid.managedProperty().bind(enabledCheck.selectedProperty()); + HBox checkBox = new HBox(enabledCheck); + checkBox.setPadding(new Insets(0, 0, 0, labelWidth + 10)); + getChildren().addAll(checkBox, grid); + setSettings(null); + } + + private static ListCell authenticationCell() { + return new ListCell<>() { + @Override + protected void updateItem(SshSettings.Authentication item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : Messages.get("ssh.authentication." + item.name() + .toLowerCase())); + } + }; + } + + /** + * The fields of the password or of the key only + */ + private void updateFields() { + boolean key = authenticationCombo.getValue() == SshSettings.Authentication.KEY; + for (Node node : new Node[]{keyFileLabel, keyBox, passphraseLabel, + passphraseField}) { + node.setVisible(key); + node.setManaged(key); + } + passwordLabel.setVisible(!key); + passwordLabel.setManaged(!key); + passwordField.setVisible(!key); + passwordField.setManaged(!key); + } + + private void browseKey() { + FileChooser chooser = new FileChooser(); + chooser.setTitle(Messages.get("ssh.keyFile")); + File ssh = new File(System.getProperty("user.home"), ".ssh"); + if (ssh.isDirectory()) { + chooser.setInitialDirectory(ssh); + } + File file = chooser.showOpenDialog(getScene() != null ? getScene().getWindow() : null); + if (file != null) { + keyFileField.setText(file.getAbsolutePath()); + } + } + + public void setSettings(SshSettings settings) { + SshSettings shown = settings != null ? settings : new SshSettings(); + enabledCheck.setSelected(shown.isEnabled()); + hostField.setText(StringUtils.defaultString(shown.getHost())); + portField.setText(String.valueOf(shown.getEffectivePort())); + usernameField.setText(StringUtils.defaultString(shown.getUsername())); + authenticationCombo.setValue(shown.getAuthentication() != null ? shown.getAuthentication() + : SshSettings.Authentication.PASSWORD); + passwordField.setText(StringUtils.defaultString(shown.getPassword())); + keyFileField.setText(StringUtils.defaultString(shown.getPrivateKeyFile())); + passphraseField.setText(StringUtils.defaultString(shown.getPassphrase())); + updateFields(); + } + + /** + * Settings of the fields, null if the tunnel is not used and nothing is filled. Only the secret of the chosen + * authentication is kept. + */ + public SshSettings getSettings() { + SshSettings settings = new SshSettings(); + settings.setEnabled(enabledCheck.isSelected()); + settings.setHost(StringUtils.trimToNull(hostField.getText())); + settings.setPort(StringUtils.isNumeric(portField.getText()) ? Integer.parseInt(portField.getText()) + : SshSettings.DEFAULT_PORT); + settings.setUsername(StringUtils.trimToNull(usernameField.getText())); + settings.setAuthentication(authenticationCombo.getValue()); + if (settings.getAuthentication() == SshSettings.Authentication.KEY) { + settings.setPrivateKeyFile(StringUtils.trimToNull(keyFileField.getText())); + settings.setPassphrase(StringUtils.defaultIfEmpty(passphraseField.getText(), null)); + } else { + settings.setPassword(StringUtils.defaultIfEmpty(passwordField.getText(), null)); + } + if (!settings.isEnabled() && settings.getHost() == null) { + return null; + } + return settings; + } + + /** + * The error of the fields of an enabled tunnel, null if there is none + */ + public String validate() { + if (!enabledCheck.isSelected()) { + return null; + } + SshSettings settings = getSettings(); + if (settings.getHost() == null) { + return Messages.get("ssh.error.host"); + } + if (settings.getPort() <= 0 || settings.getPort() > 65535) { + return Messages.get("ssh.error.port"); + } + if (settings.getUsername() == null) { + return Messages.get("ssh.error.username"); + } + if (settings.getAuthentication() == SshSettings.Authentication.KEY && settings.getPrivateKeyFile() + == null) { + return Messages.get("ssh.error.keyFile"); + } + return null; + } + + public boolean isTunnelEnabled() { + return enabledCheck.isSelected(); + } +} diff --git a/src/main/java/com/github/sqljam/face/view/SyncTab.java b/src/main/java/com/github/sqljam/face/view/SyncTab.java new file mode 100644 index 0000000..d1efc09 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/SyncTab.java @@ -0,0 +1,360 @@ +/* + * 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.IOException; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.model.SyncTask; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.service.ReportExporter; +import com.github.sqljam.face.service.SyncScheduler; +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.application.Platform; +import javafx.beans.property.ReadOnlyStringWrapper; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.Tab; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableRow; +import javafx.scene.control.TableView; +import javafx.scene.control.ToolBar; +import javafx.scene.input.MouseButton; +import javafx.scene.layout.BorderPane; +import javafx.stage.Window; +import javafx.util.Duration; + +/** + * @Description: SyncTab lists the sync tasks with their schedules, next and last runs, the status and the rows of + * their last runs: tasks are created, edited, deleted, run now, cancelled, switched on and off, and the + * report of the last run is opened. The list follows the runs of the scheduler. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class SyncTab extends Tab { + + private final AppContext context; + private final SyncScheduler scheduler; + private final Consumer opener; + private final TableView table = new TableView<>(); + private final Button edit = new Button(Messages.get("sync.editButton"), Icons.of(Icons.EDIT)); + private final Button delete = new Button(Messages.get("sync.delete"), Icons.of(Icons.DELETE)); + private final Button run = new Button(Messages.get("sync.run"), Icons.of(Icons.START)); + private final Button cancel = new Button(Messages.get("sync.cancel"), Icons.of(Icons.CANCEL)); + private final Button toggle = new Button(Messages.get("sync.disable")); + private final Button report = new Button(Messages.get("sync.report"), Icons.of(Icons.HISTORY)); + private final Consumer listener = task -> Platform.runLater(this::refresh); + + public SyncTab(AppContext context, Consumer opener) { + this.context = context; + this.scheduler = context.getSyncScheduler(); + this.opener = opener; + setText(Messages.get("sync.tab")); + setGraphic(Icons.of(Icons.REFRESH)); + setId("syncTab"); + table.setId("syncTable"); + // What matters first: the name, how the last run ended, then when it runs; the route last + table.getColumns().add(column(Messages.get("sync.column.name"), SyncTask::getName, 160)); + TableColumn status = column(Messages.get("sync.column.status"), this::status, 160); + status.setCellFactory(tableColumn -> new TableCell<>() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + getStyleClass().removeAll("status-success", "status-error", "status-pending"); + setText(empty ? null : item); + SyncTask task = empty || getTableRow() == null ? null : getTableRow().getItem(); + if (task != null && (scheduler.isRunning(task.getId()) || scheduler.isRunningElsewhere(task + .getId()))) { + getStyleClass().add("status-pending"); + } else if (task != null && task.getLastStatus() != null) { + getStyleClass().add(task.getLastStatus().isKept() ? "status-success" : "status-error"); + } + } + }); + table.getColumns().add(status); + table.getColumns().add(column(Messages.get("sync.column.rows"), task -> task.getLastRunAt() > 0 ? String + .format("%,d", task.getLastRowCount()) : "", 90)); + table.getColumns().add(column(Messages.get("sync.column.schedule"), SyncTab::schedule, 140)); + table.getColumns().add(column(Messages.get("sync.column.runner"), task -> Messages.get("sync.runner.short." + + task.getRunner().name().toLowerCase(java.util.Locale.ENGLISH)), 130)); + table.getColumns().add(column(Messages.get("sync.column.next"), this::nextRun, 150)); + table.getColumns().add(column(Messages.get("sync.column.last"), task -> task.getLastRunAt() > 0 + ? ReportExporter.time(task.getLastRunAt()) : Messages.get("sync.never"), 150)); + table.getColumns().add(column(Messages.get("sync.column.kind"), task -> Messages.get("sync.kind." + task + .getKind().name().toLowerCase(java.util.Locale.ENGLISH)), 160)); + table.getColumns().add(column(Messages.get("sync.column.route"), this::route, 260)); + table.setRowFactory(view -> { + TableRow row = new TableRow<>(); + row.setOnMouseClicked(event -> { + if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2 && !row.isEmpty()) { + edit(row.getItem()); + } + }); + return row; + }); + table.setPlaceholder(new Label(Messages.get("sync.empty"))); + TableColumns.fill(table); + + Button add = new Button(Messages.get("sync.add"), Icons.of(Icons.ADD)); + add.setId("syncAddButton"); + add.setOnAction(event -> edit(null)); + edit.setId("syncEditButton"); + edit.setOnAction(event -> edit(selected())); + delete.setId("syncDeleteButton"); + delete.setOnAction(event -> delete(selected())); + run.setId("syncRunButton"); + run.setOnAction(event -> runNow(selected())); + cancel.setId("syncCancelButton"); + cancel.setOnAction(event -> { + if (selected() != null) { + scheduler.cancel(selected().getId()); + } + }); + toggle.setId("syncToggleButton"); + toggle.setOnAction(event -> toggle(selected())); + report.setId("syncReportButton"); + report.setOnAction(event -> openReport(selected())); + Button refresh = new Button(Messages.get("sync.refresh"), Icons.of(Icons.REFRESH)); + refresh.setId("syncRefreshButton"); + refresh.setOnAction(event -> refresh()); + table.getSelectionModel().selectedItemProperty().addListener((observable, old, task) -> updateButtons()); + BorderPane root = new BorderPane(table); + root.setTop(new ToolBar(add, edit, delete, run, cancel, toggle, report, refresh)); + setContent(root); + scheduler.addListener(listener); + // Runs of the command line are read from the file of the tasks + Timeline poll = new Timeline(new KeyFrame(Duration.seconds(5), event -> refresh())); + poll.setCycleCount(Timeline.INDEFINITE); + poll.play(); + setOnClosed(event -> { + scheduler.removeListener(listener); + poll.stop(); + }); + refresh(); + } + + private static TableColumn column(String title, Function value, + double width) { + TableColumn column = new TableColumn<>(title); + column.setCellValueFactory(features -> new ReadOnlyStringWrapper(value.apply(features.getValue()))); + column.setPrefWidth(width); + return column; + } + + private String name(String profileId) { + if (profileId == null) { + return ""; + } + Optional profile = context.getProfileRegistry().findProfile(profileId); + if (profile.isPresent()) { + return profile.get().getName(); + } + for (DataSourceExtension extension : DataSourceExtension.load(context.getSettingsStore().getConfig())) { + for (DataSourceExtension.Source source : extension.getSources()) { + if (source.getId().equals(profileId)) { + return source.getName(); + } + } + } + return profileId; + } + + String route(SyncTask task) { + return name(task.getSourceId()) + part(task.getSourceCatalog()) + part(task.getSourceSchema()) + " → " + + name(task.getTargetId()) + part(task.getTargetCatalog()) + part(task.getTargetSchema()); + } + + private static String part(String name) { + return StringUtils.isNotBlank(name) ? " / " + name : ""; + } + + static String schedule(SyncTask task) { + switch (task.getScheduleType()) { + case INTERVAL: + return Messages.format("sync.every", task.getIntervalMinutes()); + case DAILY: + return Messages.format("sync.daily", task.getDailyTime()); + default: + return Messages.get("sync.manual"); + } + } + + private String nextRun(SyncTask task) { + if (!task.isEnabled()) { + return Messages.get("sync.disabled"); + } + long next = scheduler.nextRunAt(task); + return next < 0 ? "" : ReportExporter.time(Math.max(next, System.currentTimeMillis())); + } + + private String status(SyncTask task) { + if (scheduler.isRunning(task.getId())) { + return Messages.get("sync.running"); + } + if (scheduler.isRunningElsewhere(task.getId())) { + return Messages.get("sync.runningElsewhere"); + } + return task.getLastStatus() != null ? ReportExporter.words(task.getLastStatus()) : ""; + } + + private SyncTask selected() { + return table.getSelectionModel().getSelectedItem(); + } + + private Window window() { + return table.getScene() != null ? table.getScene().getWindow() : null; + } + + private void updateButtons() { + SyncTask task = selected(); + boolean running = task != null && scheduler.isRunning(task.getId()); + edit.setDisable(task == null); + delete.setDisable(task == null || running); + run.setDisable(task == null || running); + cancel.setDisable(!running); + toggle.setDisable(task == null); + toggle.setText(Messages.get(task != null && !task.isEnabled() ? "sync.enable" : "sync.disable")); + report.setDisable(task == null || task.getLastReportId() == null); + } + + /** + * Reads the tasks again, keeping the selection + */ + public void refresh() { + SyncTask task = selected(); + table.getItems().setAll(scheduler.getStore().getTasks()); + if (task != null) { + table.getItems().stream().filter(item -> item.getId().equals(task.getId())).findFirst().ifPresent( + item -> table.getSelectionModel().select(item)); + } + table.refresh(); + updateButtons(); + } + + void edit(SyncTask task) { + new SyncTaskDialog(window(), context, task).showAndWait().ifPresent(this::save); + } + + void save(SyncTask task) { + boolean wasSystem = scheduler.getStore().findTask(task.getId()).map(saved -> saved.getRunner() + == SyncTask.Runner.SYSTEM).orElse(false); + try { + scheduler.getStore().saveTask(task); + } catch (IOException e) { + Dialogs.showError(window(), Messages.get("sync.title"), e); + return; + } + refresh(); + if (wasSystem || task.getRunner() == SyncTask.Runner.SYSTEM) { + applySystem(task); + } + warnPasswords(task); + } + + /** + * Registers a task of the system scheduler with the system, or removes it from there + */ + private void applySystem(SyncTask task) { + try { + context.getSystemScheduler().apply(task); + } catch (IOException e) { + Dialogs.showWarning(window(), Messages.get("sync.title"), Messages.format("sync.system.error", e + .getMessage())); + } + } + + /** + * A scheduled run alone fails without the passwords, it is told when the task is saved + */ + private void warnPasswords(SyncTask task) { + if (task.getScheduleType() == SyncTask.ScheduleType.MANUAL) { + return; + } + for (String id : new String[]{task.getSourceId(), task.getTargetId()}) { + Optional profile = context.getProfileRegistry().findProfile(id); + if (profile.isPresent() && !context.getProfileRegistry().isPasswordRemembered(id) && StringUtils + .isNotBlank(profile.get().getUsername())) { + Dialogs.showWarning(window(), Messages.get("sync.title"), Messages.format("sync.password", profile + .get().getName())); + } + } + } + + void delete(SyncTask task) { + if (task == null || !Dialogs.confirm(window(), Messages.get("sync.delete"), Messages.format( + "sync.delete.confirm", task.getName()))) { + return; + } + try { + scheduler.getStore().removeTask(task.getId()); + if (task.getRunner() == SyncTask.Runner.SYSTEM) { + context.getSystemScheduler().unregister(task.getId()); + } + } catch (IOException e) { + Dialogs.showError(window(), Messages.get("sync.delete"), e); + } + refresh(); + } + + CompletableFuture runNow(SyncTask task) { + if (task == null) { + return null; + } + CompletableFuture future = scheduler.runNow(task.getId()); + refresh(); + return future; + } + + void toggle(SyncTask task) { + if (task == null) { + return; + } + Optional changed; + try { + changed = scheduler.getStore().updateTask(task.getId(), saved -> saved.setEnabled(!saved.isEnabled())); + } catch (IOException e) { + Dialogs.showError(window(), Messages.get("sync.title"), e); + return; + } + refresh(); + // A task of the system which is switched off is removed from there, it is registered again when it is on + changed.filter(saved -> saved.getRunner() == SyncTask.Runner.SYSTEM).ifPresent(this::applySystem); + } + + void openReport(SyncTask task) { + if (task == null || task.getLastReportId() == null) { + return; + } + context.getReportStore().find(task.getLastReportId()).ifPresent(opener); + } + + List getTasks() { + return table.getItems(); + } + + TableView getTable() { + return table; + } +} diff --git a/src/main/java/com/github/sqljam/face/view/SyncTaskDialog.java b/src/main/java/com/github/sqljam/face/view/SyncTaskDialog.java new file mode 100644 index 0000000..fa0cba0 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/SyncTaskDialog.java @@ -0,0 +1,684 @@ +/* + * Copyright 2023-2026 Fred Feng + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.sqljam.face.view; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.sqljam.face.model.ColumnInfo; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.model.SyncTask; +import com.github.sqljam.face.model.TableInfo; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.event.ActionEvent; +import javafx.geometry.Insets; +import javafx.scene.Node; +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.Spinner; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.control.cell.CheckBoxTableCell; +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.Window; +import javafx.util.StringConverter; + +/** + * @Description: SyncTaskDialog creates or edits a sync task: the source (a data source and its schema, or a vector + * database and its database), its tables with their watermark columns, the target data source and + * schema, the schedule and the attempts after errors of the connection. The watermark column is + * guessed: a time column named like an update time, otherwise a number primary key. A table whose + * watermark column changes copies all its rows again. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class SyncTaskDialog extends Dialog { + + static final Pattern WATERMARK_TYPES = Pattern.compile("(?i).*(date|time|int|serial|number|numeric|decimal|long" + + "|float|double|real).*"); + static final Pattern TIME_TYPES = Pattern.compile("(?i).*(date|time).*"); + static final Pattern UPDATED_NAMES = Pattern.compile("(?i).*(updated|update_time|updatetime|modified|modify_time|" + + "changed|last_update|gmt_modified|mtime|ts).*"); + private static final ObjectMapper MAPPER = new ObjectMapper().disable(DeserializationFeature + .FAIL_ON_UNKNOWN_PROPERTIES); + + /** + * A table or a collection of the source, checked to be synced + */ + static final class TableRow { + + final String name; + final BooleanProperty selected = new SimpleBooleanProperty(); + final StringProperty watermarkColumn = new SimpleStringProperty(); + final ObjectProperty watermarkType = new SimpleObjectProperty<>(SyncTask + .WatermarkType.UPDATED_AT); + final StringProperty keyColumns = new SimpleStringProperty(""); + /** + * Columns which can be watermarks, null before they are read + */ + List candidates; + SyncTask.Table saved; + + TableRow(String name) { + this.name = name; + } + } + + private final AppContext context; + private final SyncTask original; + private final DataSourceExtension.SyncSupport vectorSupport; + private final TextField nameField = new TextField(); + private final ComboBox kindCombo = new ComboBox<>(); + private final ComboBox sourceCombo = new ComboBox<>(); + private final ComboBox vectorSourceCombo = new ComboBox<>(); + private final ComboBox sourceCatalogCombo = new ComboBox<>(); + private final ComboBox sourceSchemaCombo = new ComboBox<>(); + private final TableView tableView = new TableView<>(); + private final ComboBox watermarkCombo = new ComboBox<>(); + private final ComboBox watermarkTypeCombo = new ComboBox<>(); + private final TextField keyField = new TextField(); + private final ComboBox targetCombo = new ComboBox<>(); + private final ComboBox targetCatalogCombo = new ComboBox<>(); + private final ComboBox targetSchemaCombo = new ComboBox<>(); + private final ComboBox scheduleCombo = new ComboBox<>(); + private final ComboBox runnerCombo = new ComboBox<>(); + private final Spinner intervalSpinner = new Spinner<>(1, 7 * 24 * 60, 60); + private final TextField dailyField = new TextField("02:00"); + private final Spinner retriesSpinner = new Spinner<>(0, 10, 3); + private final Label statusLabel = new Label(); + private boolean loading; + + /** + * @param task the task to edit, null for a new task + */ + public SyncTaskDialog(Window owner, AppContext context, SyncTask task) { + this.context = context; + this.original = task; + this.vectorSupport = DataSourceExtension.load(context.getSettingsStore().getConfig()).stream().map( + DataSourceExtension::getSyncSupport).filter(support -> support != null && support.getKind() + == SyncTask.Kind.VECTOR).findFirst().orElse(null); + Dialogs.initOwner(this, owner); + Branding.applyIcons(this); + setTitle(Messages.get(task == null ? "sync.new" : "sync.edit")); + setHeaderText(Messages.get("sync.header")); + setResizable(true); + nameField.setId("syncNameField"); + kindCombo.setId("syncKindCombo"); + sourceCombo.setId("syncSourceCombo"); + vectorSourceCombo.setId("syncVectorSourceCombo"); + sourceCatalogCombo.setId("syncSourceCatalogCombo"); + sourceSchemaCombo.setId("syncSourceSchemaCombo"); + tableView.setId("syncTables"); + watermarkCombo.setId("syncWatermarkCombo"); + watermarkTypeCombo.setId("syncWatermarkTypeCombo"); + keyField.setId("syncKeyField"); + targetCombo.setId("syncTargetCombo"); + targetCatalogCombo.setId("syncTargetCatalogCombo"); + targetSchemaCombo.setId("syncTargetSchemaCombo"); + scheduleCombo.setId("syncScheduleCombo"); + runnerCombo.setId("syncRunnerCombo"); + runnerCombo.getItems().setAll(SyncTask.Runner.values()); + runnerCombo.setConverter(converter(runner -> Messages.get("sync.runner." + runner.name().toLowerCase(Locale + .ENGLISH)))); + runnerCombo.setValue(SyncTask.Runner.SQLJAM); + intervalSpinner.setId("syncIntervalSpinner"); + dailyField.setId("syncDailyField"); + retriesSpinner.setId("syncRetriesSpinner"); + statusLabel.setId("syncStatusLabel"); + statusLabel.setWrapText(true); + + kindCombo.getItems().add(SyncTask.Kind.DATABASE); + if (vectorSupport != null) { + kindCombo.getItems().add(SyncTask.Kind.VECTOR); + } + kindCombo.setConverter(converter(kind -> Messages.get("sync.kind." + kind.name().toLowerCase(Locale + .ENGLISH)))); + kindCombo.setValue(SyncTask.Kind.DATABASE); + List profiles = context.getProfileRegistry().getProfiles(); + sourceCombo.getItems().setAll(profiles); + DbTypeCells.setupProfileCombo(sourceCombo); + targetCombo.getItems().setAll(profiles); + DbTypeCells.setupProfileCombo(targetCombo); + if (vectorSupport != null) { + for (DataSourceExtension extension : DataSourceExtension.load(context.getSettingsStore().getConfig())) { + if (extension.getSyncSupport() != null && extension.getSyncSupport().getKind() + == SyncTask.Kind.VECTOR) { + vectorSourceCombo.getItems().addAll(extension.getSources()); + } + } + } + vectorSourceCombo.setConverter(converter(source -> source.getName() + " (" + source.getTypeName() + ")")); + vectorSourceCombo.setButtonCell(new ListCell<>() { + @Override + protected void updateItem(DataSourceExtension.Source item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : item.getName() + " (" + item.getTypeName() + ")"); + } + }); + for (ComboBox combo : List.of(sourceCatalogCombo, sourceSchemaCombo, targetCatalogCombo, + targetSchemaCombo)) { + combo.setEditable(true); + combo.setMaxWidth(Double.MAX_VALUE); + } + watermarkCombo.setMaxWidth(Double.MAX_VALUE); + watermarkCombo.setEditable(true); + watermarkTypeCombo.getItems().setAll(SyncTask.WatermarkType.values()); + watermarkTypeCombo.setConverter(converter(type -> Messages.get("sync.watermark." + type.name().toLowerCase( + Locale.ENGLISH)))); + scheduleCombo.getItems().setAll(SyncTask.ScheduleType.values()); + scheduleCombo.setConverter(converter(type -> Messages.get("sync.schedule." + type.name().toLowerCase(Locale + .ENGLISH)))); + scheduleCombo.setValue(SyncTask.ScheduleType.INTERVAL); + intervalSpinner.setEditable(true); + retriesSpinner.setEditable(true); + dailyField.setPrefColumnCount(5); + + TableColumn checkColumn = new TableColumn<>(); + checkColumn.setCellValueFactory(features -> features.getValue().selected); + checkColumn.setCellFactory(CheckBoxTableCell.forTableColumn(checkColumn)); + checkColumn.setPrefWidth(36); + TableColumn nameColumn = new TableColumn<>(Messages.get("sync.table")); + nameColumn.setCellValueFactory(features -> new SimpleStringProperty(features.getValue().name)); + nameColumn.setPrefWidth(240); + TableColumn watermarkColumn = new TableColumn<>(Messages.get("sync.watermark")); + watermarkColumn.setCellValueFactory(features -> features.getValue().watermarkColumn); + watermarkColumn.setPrefWidth(200); + tableView.getColumns().add(checkColumn); + tableView.getColumns().add(nameColumn); + tableView.getColumns().add(watermarkColumn); + tableView.setEditable(true); + tableView.setPrefHeight(220); + // The tables stay in view when the dialog is short, the window scrolls instead + tableView.setMinHeight(170); + tableView.setPlaceholder(new Label(Messages.get("sync.noTables"))); + + GridPane grid = new GridPane(); + grid.setHgap(10); + grid.setVgap(8); + ColumnConstraints labels = new ColumnConstraints(); + labels.setMinWidth(130); + ColumnConstraints fields = new ColumnConstraints(); + fields.setHgrow(Priority.ALWAYS); + grid.getColumnConstraints().addAll(labels, fields); + int row = 0; + grid.addRow(row++, new Label(Messages.get("sync.name")), nameField); + if (vectorSupport != null) { + grid.addRow(row++, new Label(Messages.get("sync.kind")), kindCombo); + } + HBox sources = new HBox(8, sourceCombo, vectorSourceCombo); + HBox.setHgrow(sourceCombo, Priority.ALWAYS); + HBox.setHgrow(vectorSourceCombo, Priority.ALWAYS); + sourceCombo.setMaxWidth(Double.MAX_VALUE); + vectorSourceCombo.setMaxWidth(Double.MAX_VALUE); + grid.addRow(row++, new Label(Messages.get("sync.source")), sources); + grid.addRow(row++, new Label(Messages.get("sync.sourceCatalog")), sourceCatalogCombo); + Label schemaLabel = new Label(Messages.get("sync.sourceSchema")); + grid.addRow(row++, schemaLabel, sourceSchemaCombo); + grid.add(tableView, 0, row++, 2, 1); + Label watermarkLabel = new Label(Messages.get("sync.watermark")); + Label keysLabel = new Label(Messages.get("sync.keys")); + watermarkLabel.setMinWidth(Region.USE_PREF_SIZE); + keysLabel.setMinWidth(Region.USE_PREF_SIZE); + watermarkTypeCombo.setMinWidth(Region.USE_PREF_SIZE); + HBox detail = new HBox(8, watermarkLabel, watermarkCombo, watermarkTypeCombo, keysLabel, keyField); + detail.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + HBox.setHgrow(watermarkCombo, Priority.ALWAYS); + HBox.setHgrow(keyField, Priority.ALWAYS); + detail.setId("syncTableDetail"); + grid.add(detail, 0, row++, 2, 1); + grid.addRow(row++, new Label(Messages.get("sync.target")), targetCombo); + targetCombo.setMaxWidth(Double.MAX_VALUE); + grid.addRow(row++, new Label(Messages.get("sync.targetCatalog")), targetCatalogCombo); + grid.addRow(row++, new Label(Messages.get("sync.targetSchema")), targetSchemaCombo); + HBox schedule = new HBox(8, scheduleCombo, intervalSpinner, new Label(Messages.get("sync.minutes")), + dailyField); + grid.addRow(row++, new Label(Messages.get("sync.schedule")), schedule); + grid.addRow(row++, new Label(Messages.get("sync.runner")), runnerCombo); + grid.addRow(row++, new Label(Messages.get("sync.retries")), retriesSpinner); + Label note = new Label(Messages.get("sync.note")); + note.getStyleClass().add("muted"); + note.setWrapText(true); + note.setMinHeight(Region.USE_PREF_SIZE); + VBox content = new VBox(10, grid, note, statusLabel); + content.setPadding(new Insets(4)); + content.setPrefWidth(760); + // A short screen scrolls the fields, the buttons of the dialog stay in view + javafx.scene.control.ScrollPane scroll = new javafx.scene.control.ScrollPane(content); + scroll.setFitToWidth(true); + scroll.setStyle("-fx-background-color: transparent;"); + getDialogPane().setContent(scroll); + getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + + kindCombo.valueProperty().addListener((observable, old, kind) -> kindChanged()); + sourceCombo.valueProperty().addListener((observable, old, profile) -> loadCatalogs()); + vectorSourceCombo.valueProperty().addListener((observable, old, source) -> loadCatalogs()); + sourceCatalogCombo.valueProperty().addListener((observable, old, catalog) -> { + if (!loading) { + loadSchemas(); + } + }); + sourceSchemaCombo.valueProperty().addListener((observable, old, schema) -> { + if (!loading) { + loadTables(); + } + }); + tableView.getSelectionModel().selectedItemProperty().addListener((observable, old, item) -> showRow(old, + item)); + targetCombo.valueProperty().addListener((observable, old, profile) -> loadTargetSchemas()); + scheduleCombo.valueProperty().addListener((observable, old, type) -> scheduleChanged()); + getDialogPane().lookupButton(ButtonType.OK).addEventFilter(ActionEvent.ACTION, event -> { + saveRow(tableView.getSelectionModel().getSelectedItem()); + String error = validate(); + if (error != null) { + showStatus("status-error", error); + event.consume(); + } + }); + setResultConverter(button -> button == ButtonType.OK ? buildTask() : null); + kindChanged(); + scheduleChanged(); + if (task != null) { + fill(task); + } + } + + private static StringConverter converter(java.util.function.Function text) { + return new StringConverter<>() { + @Override + public String toString(T value) { + return value == null ? "" : text.apply(value); + } + + @Override + public T fromString(String string) { + return null; + } + }; + } + + private boolean isVector() { + return kindCombo.getValue() == SyncTask.Kind.VECTOR; + } + + private void kindChanged() { + boolean vector = isVector(); + sourceCombo.setVisible(!vector); + sourceCombo.setManaged(!vector); + vectorSourceCombo.setVisible(vector); + vectorSourceCombo.setManaged(vector); + sourceSchemaCombo.setDisable(vector); + // Fields of vector databases are compared as they are, keys are their ids + watermarkTypeCombo.setDisable(false); + keyField.setDisable(vector); + tableView.getItems().clear(); + loadCatalogs(); + } + + private void scheduleChanged() { + SyncTask.ScheduleType type = scheduleCombo.getValue(); + intervalSpinner.setDisable(type != SyncTask.ScheduleType.INTERVAL); + runnerCombo.setDisable(type == SyncTask.ScheduleType.MANUAL); + dailyField.setDisable(type != SyncTask.ScheduleType.DAILY); + } + + private void showStatus(String styleClass, String text) { + statusLabel.getStyleClass().setAll("label", styleClass); + statusLabel.setText(text); + } + + private void fill(SyncTask task) { + nameField.setText(task.getName()); + kindCombo.setValue(task.getKind()); + loading = true; + if (task.getKind() == SyncTask.Kind.VECTOR) { + vectorSourceCombo.getItems().stream().filter(source -> source.getId().equals(task.getSourceId())) + .findFirst().ifPresent(vectorSourceCombo::setValue); + } else { + sourceCombo.getItems().stream().filter(profile -> profile.getId().equals(task.getSourceId())).findFirst() + .ifPresent(sourceCombo::setValue); + } + sourceCatalogCombo.setValue(task.getSourceCatalog()); + sourceSchemaCombo.setValue(task.getSourceSchema()); + loading = false; + loadTables(); + targetCombo.getItems().stream().filter(profile -> profile.getId().equals(task.getTargetId())).findFirst() + .ifPresent(targetCombo::setValue); + targetCatalogCombo.setValue(task.getTargetCatalog()); + targetSchemaCombo.setValue(task.getTargetSchema()); + scheduleCombo.setValue(task.getScheduleType()); + runnerCombo.setValue(task.getRunner()); + intervalSpinner.getValueFactory().setValue(task.getIntervalMinutes()); + dailyField.setText(task.getDailyTime()); + retriesSpinner.getValueFactory().setValue(task.getRetries()); + } + + private String sourceId() { + return isVector() ? (vectorSourceCombo.getValue() != null ? vectorSourceCombo.getValue().getId() : null) + : (sourceCombo.getValue() != null ? sourceCombo.getValue().getId() : null); + } + + private void loadCatalogs() { + if (loading) { + return; + } + sourceCatalogCombo.getItems().clear(); + sourceSchemaCombo.getItems().clear(); + tableView.getItems().clear(); + String sourceId = sourceId(); + if (sourceId == null) { + return; + } + ConnectionProfile profile = sourceCombo.getValue(); + boolean vector = isVector(); + TaskRunner.run(() -> vector ? vectorSupport.getDatabases(sourceId) : context.getSessionManager() + .getSession(profile).getCatalogs(), catalogs -> { + if (!sourceId.equals(sourceId())) { + return; + } + sourceCatalogCombo.getItems().setAll(catalogs); + String current = vector ? null : profile.getDatabase(); + sourceCatalogCombo.setValue(catalogs.contains(current) ? current : catalogs.isEmpty() ? null + : vector ? null : catalogs.get(0)); + if (catalogs.isEmpty() || vector) { + loadSchemas(); + } + }, e -> showStatus("status-error", Messages.format("tree.error", e.getMessage()))); + } + + private void loadSchemas() { + String sourceId = sourceId(); + if (sourceId == null) { + return; + } + if (isVector()) { + loadTables(); + return; + } + ConnectionProfile profile = sourceCombo.getValue(); + String catalog = sourceCatalogCombo.getValue(); + TaskRunner.run(() -> context.getSessionManager().getSession(profile).getSchemas(catalog), schemas -> { + if (!sourceId.equals(sourceId())) { + return; + } + sourceSchemaCombo.getItems().setAll(schemas); + sourceSchemaCombo.setValue(schemas.isEmpty() ? null : schemas.contains("public") ? "public" + : schemas.contains("PUBLIC") ? "PUBLIC" : schemas.contains("dbo") ? "dbo" : schemas.get(0)); + if (schemas.isEmpty()) { + loadTables(); + } + }, e -> showStatus("status-error", Messages.format("tree.error", e.getMessage()))); + } + + private void loadTables() { + String sourceId = sourceId(); + tableView.getItems().clear(); + if (sourceId == null) { + return; + } + boolean vector = isVector(); + ConnectionProfile profile = sourceCombo.getValue(); + String catalog = StringUtils.trimToNull(sourceCatalogCombo.getValue()); + String schema = StringUtils.trimToNull(sourceSchemaCombo.getValue()); + TaskRunner.run(() -> vector ? vectorSupport.getCollections(sourceId, catalog) : context.getSessionManager() + .getSession(profile).getTables(catalog, schema).stream().map(TableInfo::getName).collect(Collectors + .toList()), names -> { + if (!sourceId.equals(sourceId())) { + return; + } + List rows = new ArrayList<>(); + for (String name : names) { + TableRow row = new TableRow(name); + SyncTask.Table saved = original != null && original.getKind() == (vector ? SyncTask.Kind.VECTOR + : SyncTask.Kind.DATABASE) && sourceId.equals(original.getSourceId()) ? original.findTable( + name) : null; + if (saved != null) { + row.saved = saved; + row.selected.set(true); + row.watermarkColumn.set(saved.getWatermarkColumn()); + row.watermarkType.set(saved.getWatermarkType()); + row.keyColumns.set(String.join(", ", saved.getKeyColumns())); + } + // A checked table shows its watermark, its columns are read + row.selected.addListener((observable, was, checked) -> { + if (checked) { + tableView.getSelectionModel().select(row); + } + }); + rows.add(row); + } + tableView.getItems().setAll(rows); + if (!rows.isEmpty()) { + tableView.getSelectionModel().select(0); + } + }, e -> showStatus("status-error", Messages.format("tree.error", e.getMessage()))); + } + + private void loadTargetSchemas() { + ConnectionProfile profile = targetCombo.getValue(); + if (profile == null) { + return; + } + TaskRunner.run(() -> { + List catalogs = context.getSessionManager().getSession(profile).getCatalogs(); + String catalog = StringUtils.defaultIfBlank(targetCatalogCombo.getValue(), catalogs.contains(profile + .getDatabase()) ? profile.getDatabase() : null); + return new Object[]{catalogs, catalog, profile.getDbType().isSchemaSupported() ? context + .getSessionManager().getSession(profile).getSchemas(catalog) : List.of()}; + }, result -> { + if (profile != targetCombo.getValue()) { + return; + } + @SuppressWarnings("unchecked") + List catalogs = (List) result[0]; + @SuppressWarnings("unchecked") + List schemas = (List) result[2]; + String catalog = targetCatalogCombo.getValue(); + String schema = targetSchemaCombo.getValue(); + targetCatalogCombo.getItems().setAll(catalogs); + targetCatalogCombo.setValue(StringUtils.defaultIfBlank(catalog, (String) result[1])); + targetSchemaCombo.getItems().setAll(schemas); + targetSchemaCombo.setValue(StringUtils.isNotBlank(schema) ? schema : schemas.contains("public") + ? "public" : schemas.contains("PUBLIC") ? "PUBLIC" : schemas.contains("dbo") ? "dbo" : null); + }, e -> showStatus("status-error", Messages.format("tree.error", e.getMessage()))); + } + + /** + * Shows the watermark of the row, its candidates are read the first time + */ + private void showRow(TableRow old, TableRow row) { + saveRow(old); + watermarkCombo.getItems().clear(); + watermarkCombo.setValue(null); + keyField.clear(); + if (row == null) { + return; + } + watermarkTypeCombo.setValue(row.watermarkType.get()); + keyField.setText(row.keyColumns.get()); + if (row.candidates != null) { + watermarkCombo.getItems().setAll(row.candidates); + watermarkCombo.setValue(row.watermarkColumn.get()); + return; + } + boolean vector = isVector(); + String sourceId = sourceId(); + ConnectionProfile profile = sourceCombo.getValue(); + String catalog = StringUtils.trimToNull(sourceCatalogCombo.getValue()); + String schema = StringUtils.trimToNull(sourceSchemaCombo.getValue()); + TaskRunner.run(() -> vector ? vectorSupport.getWatermarkFields(sourceId, catalog, row.name) : columns(context + .getSessionManager().getSession(profile).getColumns(catalog, schema, row.name), row), candidates -> { + row.candidates = candidates; + if (row.watermarkColumn.get() == null && !candidates.isEmpty()) { + row.watermarkColumn.set(candidates.get(0)); + } + if (tableView.getSelectionModel().getSelectedItem() == row) { + watermarkCombo.getItems().setAll(candidates); + watermarkCombo.setValue(row.watermarkColumn.get()); + watermarkTypeCombo.setValue(row.watermarkType.get()); + } + }, e -> showStatus("status-error", Messages.format("tree.error", e.getMessage()))); + } + + /** + * Columns which can be watermarks, the guessed one first: a time named like an update time, a number key + */ + static List columns(List columns, TableRow row) { + List candidates = columns.stream().filter(column -> WATERMARK_TYPES.matcher(StringUtils + .defaultString(column.getTypeName())).matches()).map(ColumnInfo::getName).collect(Collectors + .toCollection(ArrayList::new)); + ColumnInfo updated = columns.stream().filter(column -> TIME_TYPES.matcher(StringUtils.defaultString(column + .getTypeName())).matches() && UPDATED_NAMES.matcher(column.getName()).matches()).findFirst() + .orElse(null); + ColumnInfo key = columns.stream().filter(column -> column.isPrimaryKey() && !TIME_TYPES.matcher(StringUtils + .defaultString(column.getTypeName())).matches() && WATERMARK_TYPES.matcher(StringUtils.defaultString( + column.getTypeName())).matches()).findFirst().orElse(null); + ColumnInfo guess = updated != null ? updated : key; + if (guess != null && row.watermarkColumn.get() == null) { + candidates.remove(guess.getName()); + candidates.add(0, guess.getName()); + row.watermarkType.set(guess == updated ? SyncTask.WatermarkType.UPDATED_AT + : SyncTask.WatermarkType.INCREASING_KEY); + } + return candidates; + } + + private void saveRow(TableRow row) { + if (row == null) { + return; + } + String column = StringUtils.trimToNull(watermarkCombo.getEditor() != null && watermarkCombo.isEditable() + ? watermarkCombo.getEditor().getText() : watermarkCombo.getValue()); + if (column == null) { + column = watermarkCombo.getValue(); + } + if (column != null) { + row.watermarkColumn.set(column); + } + if (watermarkTypeCombo.getValue() != null) { + row.watermarkType.set(watermarkTypeCombo.getValue()); + } + row.keyColumns.set(StringUtils.defaultString(keyField.getText()).trim()); + } + + List getSelectedRows() { + return tableView.getItems().stream().filter(row -> row.selected.get()).collect(Collectors.toList()); + } + + String validate() { + if (StringUtils.isBlank(nameField.getText())) { + return Messages.get("sync.error.name"); + } + if (sourceId() == null) { + return Messages.get("sync.error.source"); + } + if (targetCombo.getValue() == null) { + return Messages.get("sync.error.target"); + } + List rows = getSelectedRows(); + if (rows.isEmpty()) { + return Messages.get("sync.error.tables"); + } + for (TableRow row : rows) { + if (StringUtils.isBlank(row.watermarkColumn.get())) { + return Messages.format("sync.error.watermark", row.name); + } + } + if (!isVector() && targetCombo.getValue().getId().equals(sourceId()) && StringUtils.equals(StringUtils + .trimToNull(sourceCatalogCombo.getValue()), StringUtils.trimToNull(targetCatalogCombo.getValue())) + && StringUtils.equalsIgnoreCase(StringUtils.trimToNull(sourceSchemaCombo.getValue()), StringUtils + .trimToNull(targetSchemaCombo.getValue()))) { + return Messages.get("sync.error.same"); + } + if (scheduleCombo.getValue() == SyncTask.ScheduleType.DAILY && !dailyField.getText().trim().matches( + "([01]?\\d|2[0-3]):[0-5]\\d")) { + return Messages.get("sync.error.time"); + } + return null; + } + + /** + * The task of the dialog: an edited task keeps its id, its runs and the watermarks of the tables whose + * watermark column stays + */ + SyncTask buildTask() { + SyncTask task; + try { + task = original != null ? MAPPER.readValue(MAPPER.writeValueAsString(original), SyncTask.class) + : new SyncTask(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + task.setName(nameField.getText().trim()); + task.setKind(kindCombo.getValue()); + task.setSourceId(sourceId()); + task.setSourceCatalog(StringUtils.trimToNull(sourceCatalogCombo.getValue())); + task.setSourceSchema(isVector() ? null : StringUtils.trimToNull(sourceSchemaCombo.getValue())); + task.setTargetId(targetCombo.getValue().getId()); + task.setTargetCatalog(StringUtils.trimToNull(targetCatalogCombo.getValue())); + task.setTargetSchema(StringUtils.trimToNull(targetSchemaCombo.getValue())); + List tables = new ArrayList<>(); + for (TableRow row : getSelectedRows()) { + SyncTask.Table table = new SyncTask.Table(row.name, row.watermarkColumn.get().trim(), row.watermarkType + .get()); + table.setKeyColumns(Arrays.stream(StringUtils.defaultString(row.keyColumns.get()).split(",")).map( + String::trim).filter(StringUtils::isNotEmpty).collect(Collectors.toList())); + SyncTask.Table saved = original != null ? original.findTable(row.name) : null; + boolean sameSource = original != null && StringUtils.equals(original.getSourceId(), task.getSourceId()); + if (saved != null && sameSource && saved.getWatermarkColumn().equals(table.getWatermarkColumn()) + && saved.getWatermarkType() == table.getWatermarkType()) { + table.setWatermark(saved.getWatermark()); + } + tables.add(table); + } + task.setTables(tables); + task.setScheduleType(scheduleCombo.getValue()); + task.setRunner(runnerCombo.getValue()); + task.setIntervalMinutes(intervalSpinner.getValue()); + task.setDailyTime(dailyField.getText().trim()); + task.setRetries(retriesSpinner.getValue()); + task.setPageSize(context.getSettings().getTransferPageSize()); + return task; + } + + /** + * The fields of the dialog, in its scroll pane + */ + Node getFields() { + return ((javafx.scene.control.ScrollPane) getDialogPane().getContent()).getContent(); + } +} diff --git a/src/main/java/com/github/sqljam/face/view/TableColumns.java b/src/main/java/com/github/sqljam/face/view/TableColumns.java new file mode 100644 index 0000000..2b77c30 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/TableColumns.java @@ -0,0 +1,110 @@ +/* + * 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 javafx.application.Platform; +import javafx.beans.InvalidationListener; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.collections.transformation.SortedList; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; + +/** + * @Description: TableColumns fills the width of a table with its columns: columns narrower than the table together + * are widened in proportion to their widths, so that no empty column is left at the right. Columns wider + * than the table together keep their widths and the table scrolls. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class TableColumns { + + static final String INSTALLED = "tableColumnsFill"; + static final String NATURAL_WIDTH = "naturalWidth"; + /** + * Width of the vertical scroll bar and of the borders, which the columns leave free + */ + static final double RESERVED = 18; + + private TableColumns() { + } + + /** + * Fills the width of the table, again when it or its columns change + */ + /** + * Rows of the table which its headers sort, the third click of a header shows them in their order again + * + * @return the rows to change, the table shows them sorted + */ + public static ObservableList sortedRows(TableView table) { + ObservableList rows = FXCollections.observableArrayList(); + SortedList sorted = new SortedList<>(rows); + sorted.comparatorProperty().bind(table.comparatorProperty()); + table.setItems(sorted); + return rows; + } + + public static void fill(TableView table) { + if (table.getProperties().containsKey(INSTALLED)) { + return; + } + table.getProperties().put(INSTALLED, Boolean.TRUE); + boolean[] scheduled = {false}; + InvalidationListener listener = observable -> { + if (!scheduled[0]) { + scheduled[0] = true; + Platform.runLater(() -> { + scheduled[0] = false; + apply(table); + }); + } + }; + table.widthProperty().addListener(listener); + table.getColumns().addListener(listener); + } + + /** + * Widths of the columns: their natural widths, widened when they are narrower than the table together + */ + static void apply(TableView table) { + double available = table.getWidth() - RESERVED; + if (available <= 0 || table.getColumns().isEmpty()) { + return; + } + double natural = 0; + for (TableColumn column : table.getColumns()) { + natural += naturalWidth(column); + } + double factor = natural > 0 && natural < available ? available / natural : 1; + for (TableColumn column : table.getColumns()) { + column.setPrefWidth(Math.floor(naturalWidth(column) * factor)); + } + } + + /** + * Natural width of a column: its width before the filling, e.g. estimated by its values + */ + static double naturalWidth(TableColumn column) { + Object width = column.getProperties().get(NATURAL_WIDTH); + if (width == null) { + column.getProperties().put(NATURAL_WIDTH, column.getPrefWidth()); + return column.getPrefWidth(); + } + return (Double) width; + } +} diff --git a/src/main/java/com/github/sqljam/face/view/TableCopy.java b/src/main/java/com/github/sqljam/face/view/TableCopy.java new file mode 100644 index 0000000..5f4e0b6 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/TableCopy.java @@ -0,0 +1,194 @@ +/* + * Copyright 2023-2026 Fred Feng + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.sqljam.face.view; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.SelectionMode; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyCodeCombination; +import javafx.scene.input.KeyCombination; +import javafx.scene.input.KeyEvent; + +/** + * @Description: TableCopy copies the selected rows of a data table to the clipboard as CSV, TSV, JSON or Markdown, in + * the order and with the headers of the visible columns. Shortcut+C copies the values tab separated, + * which spreadsheets paste into cells, Shortcut+Shift+C with the headers, like DBeaver and DataGrip. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class TableCopy { + + static final String INSTALLED = "tableCopyInstalled"; + static final KeyCombination COPY = new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN); + static final KeyCombination COPY_WITH_HEADERS = new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN, + KeyCombination.SHIFT_DOWN); + private static final ObjectMapper MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + + public enum Format { + CSV("CSV"), TSV("TSV"), JSON("JSON"), MARKDOWN("Markdown"); + + private final String displayName; + + Format(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + } + + private TableCopy() { + } + + /** + * Rows of the table are selected several at a time and copied by the keys, installed once per table + */ + static void install(TableView> table) { + if (table == null || table.getProperties().containsKey(INSTALLED)) { + return; + } + table.getProperties().put(INSTALLED, Boolean.TRUE); + table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + table.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + if (COPY_WITH_HEADERS.match(event)) { + ValueCell.copy(format(table, Format.TSV, true)); + event.consume(); + } else if (COPY.match(event)) { + ValueCell.copy(format(table, Format.TSV, false)); + event.consume(); + } + }); + } + + /** + * Copy Rows As of the context menu of a cell of the table + */ + static Menu createMenu(TableView> table) { + Menu menu = new Menu(Messages.get("value.copyRows"), Icons.of(Icons.COPY)); + menu.setId("copyRowsMenu"); + for (Format format : Format.values()) { + MenuItem item = new MenuItem(format.getDisplayName()); + item.setId("copyRows" + format.getDisplayName()); + item.setOnAction(event -> ValueCell.copy(format(table, format, true))); + menu.getItems().add(item); + } + return menu; + } + + /** + * Selected rows of the table in the format, in the order of the table + */ + static String format(TableView> table, Format format, boolean headers) { + List indexes = new ArrayList<>(table.getSelectionModel().getSelectedIndices()); + indexes.removeIf(index -> index == null || index < 0); + indexes.sort(null); + List, ?>> columns = table.getVisibleLeafColumns(); + List names = columns.stream().map(TableColumn::getText).collect(Collectors.toList()); + List> rows = new ArrayList<>(); + for (int index : indexes) { + List row = new ArrayList<>(); + for (TableColumn, ?> column : columns) { + Object value = column.getCellData(index); + row.add(value != null ? value.toString() : null); + } + rows.add(row); + } + return format(names, rows, format, headers); + } + + public static String format(List names, List> rows, Format format, boolean headers) { + switch (format) { + case CSV: + return delimited(names, rows, headers, ","); + case TSV: + return delimited(names, rows, headers, "\t"); + case JSON: + return json(names, rows); + default: + return markdown(names, rows); + } + } + + private static String delimited(List names, List> rows, boolean headers, String delimiter) { + List lines = new ArrayList<>(); + if (headers) { + lines.add(names.stream().map(name -> quote(name, delimiter)).collect(Collectors.joining(delimiter))); + } + for (List row : rows) { + lines.add(row.stream().map(value -> quote(value, delimiter)).collect(Collectors.joining(delimiter))); + } + return String.join("\n", lines); + } + + /** + * A value of CSV or TSV, quoted as RFC 4180 tells when it has the delimiter, quotes or line breaks, NULL is empty + */ + static String quote(String value, String delimiter) { + if (value == null) { + return ""; + } + if (value.contains(delimiter) || value.contains("\"") || value.contains("\n") || value.contains("\r")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; + } + + private static String json(List names, List> rows) { + List> objects = new ArrayList<>(); + for (List row : rows) { + Map object = new LinkedHashMap<>(); + for (int i = 0; i < names.size(); i++) { + object.put(names.get(i), row.get(i)); + } + objects.add(object); + } + try { + return MAPPER.writeValueAsString(objects); + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); + } + } + + private static String markdown(List names, List> rows) { + List lines = new ArrayList<>(); + lines.add(markdownRow(names)); + lines.add(names.stream().map(name -> "---").collect(Collectors.joining(" | ", "| ", " |"))); + for (List row : rows) { + lines.add(markdownRow(row.stream().map(value -> value != null ? value : ValueCell.NULL_TEXT) + .collect(Collectors.toList()))); + } + return String.join("\n", lines); + } + + private static String markdownRow(List values) { + return values.stream().map(value -> value.replace("\\", "\\\\").replace("|", "\\|").replaceAll("\\R", + "
")).collect(Collectors.joining(" | ", "| ", " |")); + } +} 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 9f240b8..fde58f2 100644 --- a/src/main/java/com/github/sqljam/face/view/TableTab.java +++ b/src/main/java/com/github/sqljam/face/view/TableTab.java @@ -96,6 +96,11 @@ public TableTab(AppContext context, DbNode node) { Tab foreignKeysTab = new Tab(Messages.get("table.foreignKeys"), foreignKeyTable); Tab ddlTab = new Tab(Messages.get("table.ddl"), createDdlPane()); Tab dataTab = new Tab(Messages.get("table.data"), dataPane); + columnsTab.setGraphic(Icons.of(Icons.COLUMNS)); + indexesTab.setGraphic(Icons.of(Icons.INDEXES)); + foreignKeysTab.setGraphic(Icons.of(Icons.FOREIGN_KEYS)); + ddlTab.setGraphic(Icons.of(Icons.DDL)); + dataTab.setGraphic(Icons.of(Icons.DATA)); tabs.getTabs().addAll(columnsTab, indexesTab, foreignKeysTab, ddlTab, dataTab); tabs.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, tab) -> { if (tab == ddlTab && !ddlLoaded) { 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 2726b4d..2720518 100644 --- a/src/main/java/com/github/sqljam/face/view/ThemeManager.java +++ b/src/main/java/com/github/sqljam/face/view/ThemeManager.java @@ -15,7 +15,10 @@ */ package com.github.sqljam.face.view; +import java.net.URL; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -40,6 +43,28 @@ public final class ThemeManager { public static final String DEFAULT_THEME = "Primer Dark"; + /** + * Text of dark themes is never gray: white, and near white for the secondary text and the prompts. Text of + * light themes is near black with clear contrast. Selected rows of tables, lists and trees are clearly blue + * in both, the themes select with a gray close to the background (user 2026-10-25). + */ + static final Map DARK_COLORS = colors("#ffffff", "#f0f6fc", "#e6edf3", + "rgba(56, 139, 253, 0.45)", "#ffffff"); + static final Map LIGHT_COLORS = colors("#1f2328", "#32383f", "#424a53", + "rgba(9, 105, 218, 0.22)", "#1f2328"); + + private static Map colors(String text, String muted, String subtle, String selection, + String selectedText) { + Map colors = new LinkedHashMap<>(); + colors.put("-color-fg-default", text); + colors.put("-color-fg-muted", muted); + colors.put("-color-fg-subtle", subtle); + colors.put("-color-cell-bg-selected", selection); + colors.put("-color-cell-bg-selected-focused", selection); + colors.put("-color-cell-fg-selected", selectedText); + colors.put("-color-cell-fg-selected-focused", selectedText); + return colors; + } private static final Map> THEMES = new LinkedHashMap<>(); @@ -68,7 +93,24 @@ public static List getThemeNames() { public static String apply(String themeName) { String name = THEMES.containsKey(themeName) ? themeName : DEFAULT_THEME; Theme theme = THEMES.get(name).get(); - Application.setUserAgentStylesheet(theme.getUserAgentStylesheet()); + Application.setUserAgentStylesheet(getStylesheet(theme)); return name; } + + /** + * Stylesheet of the theme with the colors of the text and of selected rows of SqlJam, dialogs included: the + * colors are set on the stylesheet of the theme, after it. + */ + static String getStylesheet(Theme theme) { + URL url = Theme.class.getResource(theme.getUserAgentStylesheet()); + String base = url != null ? url.toExternalForm() : theme.getUserAgentStylesheet(); + // The themes set the colors of selected cells again on the controls with cells, they are set there too + StringBuilder css = new StringBuilder("@import url(\"").append(base).append("\");\n.root, .list-view," + + " .table-view, .tree-view, .tree-table-view {\n"); + (theme.isDarkMode() ? DARK_COLORS : LIGHT_COLORS).forEach((name, value) -> css.append(" ").append(name) + .append(": ").append(value).append(";\n")); + css.append("}\n"); + return "data:text/css;base64," + Base64.getEncoder().encodeToString(css.toString().getBytes( + StandardCharsets.UTF_8)); + } } diff --git a/src/main/java/com/github/sqljam/face/view/ValueCell.java b/src/main/java/com/github/sqljam/face/view/ValueCell.java new file mode 100644 index 0000000..d1d9590 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/ValueCell.java @@ -0,0 +1,176 @@ +/* + * 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.math.BigDecimal; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Pattern; + +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.TableCell; +import javafx.scene.control.TablePosition; +import javafx.scene.control.TableView; +import javafx.scene.control.Tooltip; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseButton; + +/** + * @Description: ValueCell shows a value of a table in one line, however long it is: line breaks are spaces and the + * end of a long value is an ellipsis. A double click shows the whole value in ValueDialog. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class ValueCell extends TableCell, String> { + + static final String NULL_TEXT = "NULL"; + /** + * Characters of a value in a cell at most, the cell shows one line of them + */ + static final int MAX_CELL_LENGTH = 1000; + + /** + * Key of the property of a table whose Shift+Enter shows the value of its focused cell + */ + static final String KEYS_INSTALLED = "valueCellKeys"; + + private static final Pattern NUMBER = Pattern.compile("[-+]?(\\d+\\.?\\d*|\\.\\d+)([eE][-+]?\\d+)?"); + + /** + * Order of the values of a column sorted by its header: NULL first, then numbers by their values, then the + * others as texts + */ + public static final Comparator COMPARATOR = (left, right) -> { + if (left == null || right == null) { + return left == null ? (right == null ? 0 : -1) : 1; + } + BigDecimal leftNumber = toNumber(left); + BigDecimal rightNumber = toNumber(right); + if (leftNumber != null && rightNumber != null) { + return leftNumber.compareTo(rightNumber); + } + if (leftNumber != null || rightNumber != null) { + // Numbers before texts, so that the order stays the same whatever values a column mixes + return leftNumber != null ? -1 : 1; + } + return left.compareTo(right); + }; + + static BigDecimal toNumber(String value) { + if (value.isEmpty() || value.length() > 64 || !NUMBER.matcher(value).matches()) { + return null; + } + return new BigDecimal(value); + } + + public ValueCell() { + setWrapText(false); + getStyleClass().add("value-cell"); + setOnMouseClicked(event -> { + if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2 && !isEmpty()) { + showValue(); + } + }); + // View Value and Copy Value of the cell, like the value viewers of DBeaver and DataGrip + MenuItem view = new MenuItem(Messages.get("value.view"), Icons.of(Icons.OPEN)); + view.setId("viewValueMenuItem"); + view.setOnAction(event -> showValue()); + MenuItem copy = new MenuItem(Messages.get("value.copy"), Icons.of(Icons.COPY)); + copy.setId("copyValueMenuItem"); + copy.setOnAction(event -> copy(getItem())); + ContextMenu menu = new ContextMenu(view, copy); + emptyProperty().addListener((obs, wasEmpty, empty) -> setContextMenu(empty ? null : menu)); + tableViewProperty().addListener((obs, oldTable, table) -> { + installKeys(table); + // Copy Rows As of the selected rows of the table + menu.getItems().setAll(view, copy); + if (table != null) { + menu.getItems().addAll(new SeparatorMenuItem(), TableCopy.createMenu(table)); + } + }); + } + + private void showValue() { + ValueDialog.show(getScene() != null ? getScene().getWindow() : null, getTableColumn().getText(), getItem()); + } + + static void copy(String value) { + ClipboardContent content = new ClipboardContent(); + content.putString(value == null ? "" : value); + Clipboard.getSystemClipboard().setContent(content); + } + + /** + * Shift+Enter shows the value of the focused cell of the table, installed once per table + */ + @SuppressWarnings("unchecked") + static void installKeys(TableView> table) { + if (table == null || table.getProperties().containsKey(KEYS_INSTALLED)) { + return; + } + table.getProperties().put(KEYS_INSTALLED, Boolean.TRUE); + TableCopy.install(table); + table.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + TablePosition, ?> focused = table.getFocusModel().getFocusedCell(); + if (event.getCode() == KeyCode.ENTER && event.isShiftDown() && focused != null && focused.getRow() >= 0 + && focused.getTableColumn() != null) { + Object value = focused.getTableColumn().getCellData(focused.getRow()); + ValueDialog.show(table.getScene() != null ? table.getScene().getWindow() : null, + focused.getTableColumn().getText(), value != null ? value.toString() : null); + event.consume(); + } + }); + } + + /** + * One line of a value + */ + static String oneLine(String value) { + String text = value.length() > MAX_CELL_LENGTH ? value.substring(0, MAX_CELL_LENGTH) + "…" : value; + return text.replaceAll("\\R", " "); + } + + /** + * Text of a value in the cell, e.g. a time without its T + */ + protected String display(String item) { + return item; + } + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + getStyleClass().remove("null-cell"); + setTooltip(null); + if (empty) { + setText(null); + } else if (item == null) { + setText(NULL_TEXT); + getStyleClass().add("null-cell"); + } else { + setText(oneLine(display(item))); + if (item.length() > 80) { + setTooltip(new Tooltip(Messages.get("value.doubleClick"))); + } + } + } +} diff --git a/src/main/java/com/github/sqljam/face/view/ValueDialog.java b/src/main/java/com/github/sqljam/face/view/ValueDialog.java new file mode 100644 index 0000000..77a45a5 --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/ValueDialog.java @@ -0,0 +1,130 @@ +/* + * Copyright 2023-2026 Fred Feng + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.sqljam.face.view; + +import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.github.sqljam.utils.RestClient; +import javafx.event.ActionEvent; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; +import javafx.scene.control.TextArea; +import javafx.stage.Window; + +/** + * @Description: ValueDialog shows the whole value of a cell of a table, whose cells show one line only: JSON is + * indented, a vector tells its dimensions, long text wraps, and Copy puts the value on the clipboard + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class ValueDialog { + + /** + * JSON of more characters is shown as it is, indenting it would take long + */ + static final int MAX_INDENTED_LENGTH = 1_000_000; + + private ValueDialog() { + } + + /** + * Text of the value in the dialog: JSON objects and arrays of objects are indented, other values are as they are + */ + static String format(String value) { + String trimmed = value.trim(); + if (trimmed.length() > MAX_INDENTED_LENGTH || !(trimmed.startsWith("{") || trimmed.startsWith("["))) { + return value; + } + try { + JsonNode node = RestClient.getMapper().readTree(trimmed); + if (isVector(node)) { + return value; + } + return RestClient.getMapper().writerWithDefaultPrettyPrinter().writeValueAsString(node); + } catch (Exception e) { + return value; + } + } + + /** + * Whether the value is an array of numbers, e.g. a vector + */ + static boolean isVector(JsonNode node) { + if (!node.isArray() || node.size() == 0) { + return false; + } + for (JsonNode item : node) { + if (!item.isNumber()) { + return false; + } + } + return true; + } + + /** + * Summary of the value: its characters, or the dimensions of a vector + */ + static String describe(String value) { + String trimmed = value.trim(); + if (trimmed.startsWith("[") && trimmed.endsWith("]") && trimmed.length() <= MAX_INDENTED_LENGTH) { + try { + JsonNode node = RestClient.getMapper().readTree(trimmed); + if (isVector(node)) { + return Messages.format("value.vector", String.valueOf(node.size())); + } + } catch (Exception e) { + // Not JSON + } + } + if (trimmed.startsWith("{") && trimmed.contains("}/")) { + // A sparse vector of pgvector, {1:0.5,3:2}/8 + return Messages.format("value.sparse", StringUtils.substringAfterLast(trimmed, "}/")); + } + return Messages.format("value.length", String.valueOf(value.length())); + } + + public static void show(Window owner, String column, String value) { + Alert alert = new Alert(Alert.AlertType.NONE); + if (owner != null && owner.getScene() != null) { + alert.initOwner(owner); + } + Branding.applyIcons(alert); + alert.setTitle(column); + alert.setHeaderText(column + " · " + (value == null ? "NULL" : describe(value))); + TextArea area = new TextArea(value == null ? "NULL" : format(value)); + area.setId("valueArea"); + area.setEditable(false); + area.setWrapText(true); + area.getStyleClass().add("mono"); + area.setPrefSize(720, 420); + alert.getDialogPane().setContent(area); + alert.setResizable(true); + ButtonType copy = new ButtonType(Messages.get("action.copy"), ButtonBar.ButtonData.LEFT); + alert.getButtonTypes().addAll(copy, ButtonType.CLOSE); + Button copyButton = (Button) alert.getDialogPane().lookupButton(copy); + copyButton.setId("copyValueButton"); + copyButton.setGraphic(Icons.of(Icons.COPY)); + copyButton.addEventFilter(ActionEvent.ACTION, event -> { + // The dialog stays open + event.consume(); + ValueCell.copy(value); + }); + alert.show(); + } +} diff --git a/src/main/java/com/github/sqljam/face/view/WindowPlacement.java b/src/main/java/com/github/sqljam/face/view/WindowPlacement.java new file mode 100644 index 0000000..5f183fa --- /dev/null +++ b/src/main/java/com/github/sqljam/face/view/WindowPlacement.java @@ -0,0 +1,99 @@ +/* + * Copyright 2023-2026 Fred Feng + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.sqljam.face.view; + +import java.util.List; +import java.util.stream.Collectors; + +import com.github.sqljam.face.model.AppSettings; +import javafx.geometry.Rectangle2D; +import javafx.stage.Screen; +import javafx.stage.Stage; + +/** + * @Description: WindowPlacement keeps the size, the position and the maximized state of the main window between + * starts. A saved position is used only when the title bar of the window is on a screen, e.g. not + * after the external display it was on is unplugged, otherwise the window is centered on the main + * screen. A size larger than the screen is reduced to it. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class WindowPlacement { + + /** + * Part of the title bar which must be on a screen, so that the window can be dragged + */ + static final double TITLE_HEIGHT = 30; + static final double TITLE_WIDTH = 120; + + private WindowPlacement() { + } + + /** + * Places the stage as the settings saved it + */ + public static void restore(Stage stage, AppSettings settings) { + List screens = Screen.getScreens().stream().map(Screen::getVisualBounds).collect(Collectors + .toList()); + Rectangle2D bounds = fit(settings.getWindowX(), settings.getWindowY(), settings.getWindowWidth(), settings + .getWindowHeight(), screens, Screen.getPrimary().getVisualBounds()); + stage.setWidth(bounds.getWidth()); + stage.setHeight(bounds.getHeight()); + stage.setX(bounds.getMinX()); + stage.setY(bounds.getMinY()); + stage.setMaximized(settings.isWindowMaximized()); + } + + /** + * Saves the placement of the stage into the settings, the size and position before it was maximized are kept + */ + public static void save(Stage stage, AppSettings settings) { + settings.setWindowMaximized(stage.isMaximized()); + if (!stage.isMaximized() && !stage.isIconified()) { + settings.setWindowX(stage.getX()); + settings.setWindowY(stage.getY()); + settings.setWindowWidth(stage.getWidth()); + settings.setWindowHeight(stage.getHeight()); + } + } + + /** + * Bounds of the window: the saved ones when its title bar is on one of the screens, centered on the primary + * screen otherwise, never larger than the screen it is on + */ + static Rectangle2D fit(double x, double y, double width, double height, List screens, + Rectangle2D primary) { + if (!Double.isNaN(x) && !Double.isNaN(y)) { + Rectangle2D title = new Rectangle2D(x + Math.max(0, (width - TITLE_WIDTH) / 2), y, Math.min(width, + TITLE_WIDTH), TITLE_HEIGHT); + for (Rectangle2D screen : screens) { + if (screen.contains(title)) { + double fittedWidth = Math.min(width, screen.getWidth()); + double fittedHeight = Math.min(height, screen.getHeight()); + // The window is moved into the screen when it is reduced + double fittedX = Math.max(screen.getMinX(), Math.min(x, screen.getMaxX() - fittedWidth)); + double fittedY = Math.max(screen.getMinY(), Math.min(y, screen.getMaxY() - fittedHeight)); + return new Rectangle2D(fittedX, fittedY, fittedWidth, fittedHeight); + } + } + } + double fittedWidth = Math.min(width, primary.getWidth()); + double fittedHeight = Math.min(height, primary.getHeight()); + return new Rectangle2D(primary.getMinX() + (primary.getWidth() - fittedWidth) / 2, primary.getMinY() + + (primary.getHeight() - fittedHeight) / 2, fittedWidth, fittedHeight); + } +} diff --git a/src/main/java/com/github/sqljam/impexp/AbstractFileImporter.java b/src/main/java/com/github/sqljam/impexp/AbstractFileImporter.java index ef80d86..1bd2337 100644 --- a/src/main/java/com/github/sqljam/impexp/AbstractFileImporter.java +++ b/src/main/java/com/github/sqljam/impexp/AbstractFileImporter.java @@ -117,6 +117,9 @@ public int getLobCount() { * Imports an export directory. Files are imported in the order of manifest.json if it exists. */ public void importDirectory(File dir) throws IOException, SQLException { + if (dbType != null) { + dbType.createDialect().prepareSession(connection); + } ExportManifest manifest = ExportManifest.read(dir); if (manifest == null) { importDirectoryWithoutManifest(dir); @@ -266,6 +269,16 @@ public void onError(String message, Throwable e) { delegate.onError(message, e); } + @Override + public void onRowSkipped(String catalogName, String schemaName, String tableName, String key, Throwable e) { + delegate.onRowSkipped(catalogName, schemaName, tableName, key, e); + } + + @Override + public void onDdlExecuted(String statement, boolean successful) { + delegate.onDdlExecuted(statement, successful); + } + @Override public boolean isCancelled() { return delegate.isCancelled(); diff --git a/src/main/java/com/github/sqljam/impexp/ColumnMapping.java b/src/main/java/com/github/sqljam/impexp/ColumnMapping.java new file mode 100644 index 0000000..1c3859a --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/ColumnMapping.java @@ -0,0 +1,92 @@ +/* + * 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.Objects; + +import org.apache.commons.lang3.StringUtils; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * @Description: ColumnMapping is the target of a column of a source table which the user chose: another name, a + * type of the target database written as it is, or no column at all. A column without a mapping keeps + * its name and its translated type, so an import without mappings is the import of before. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Getter +@Setter +@ToString +public class ColumnMapping { + + /** + * Name of the target column, null for the name of the source column + */ + private String targetName; + /** + * Type of the target column in the language of the target database, null for the translated type + */ + private String targetType; + /** + * The column is not read and not written + */ + private boolean skipped; + + public ColumnMapping() { + } + + public ColumnMapping(String targetName, String targetType, boolean skipped) { + this.targetName = StringUtils.trimToNull(targetName); + this.targetType = StringUtils.trimToNull(targetType); + this.skipped = skipped; + } + + public static ColumnMapping skip() { + return new ColumnMapping(null, null, true); + } + + /** + * Whether the mapping changes nothing: same name, translated type, not skipped + */ + public boolean isIdentity() { + return !skipped && StringUtils.isBlank(targetName) && StringUtils.isBlank(targetType); + } + + /** + * Name of the target column of the source column + */ + public String getTargetName(String sourceName) { + return StringUtils.defaultIfBlank(targetName, sourceName); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof ColumnMapping)) { + return false; + } + ColumnMapping mapping = (ColumnMapping) other; + return skipped == mapping.skipped && Objects.equals(targetName, mapping.targetName) && Objects.equals( + targetType, mapping.targetType); + } + + @Override + public int hashCode() { + return Objects.hash(targetName, targetType, skipped); + } +} diff --git a/src/main/java/com/github/sqljam/impexp/ColumnMetaData.java b/src/main/java/com/github/sqljam/impexp/ColumnMetaData.java index 0bde01a..64feeb0 100644 --- a/src/main/java/com/github/sqljam/impexp/ColumnMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/ColumnMetaData.java @@ -66,7 +66,15 @@ public String[] getStatements() throws SQLException { String schemaName = getSchemaName(); String tableName = dialect.getTargetTableName(getTableName()); int dataType = TableMetaData.getInt(detail, "DATA_TYPE"); - String typeName = getUserTypeName(dialect); + // A type which the user mapped is written as it is + String typeName = dialect.getMappedColumnType(getTableName(), columnName); + if (typeName == null) { + typeName = getUserTypeName(dialect); + } + if (typeName == null) { + typeName = getVectorTypeName(dialect); + } + String targetColumnName = dialect.getTargetColumnName(getTableName(), columnName); if (typeName == null) { typeName = (String) detail.get("TYPE_NAME"); } @@ -81,15 +89,18 @@ public String[] getStatements() throws SQLException { String expression = (String) detail.get("GENERATION_EXPRESSION"); if (isGenerated() && StringUtils.isNotBlank(expression) && !dialect.isCrossDatabase() && dialect.isGeneratedColumnSupported()) { - statement = dialect.getGeneratedColumnStatement(catalogName, schemaName, tableName, columnName, dataType, + statement = dialect.getGeneratedColumnStatement(catalogName, schemaName, tableName, targetColumnName, + dataType, typeName, columnSize, columnScale, expression, !"VIRTUAL".equalsIgnoreCase((String) detail.get("GENERATION_TYPE"))); } else if (unwrap(TableMetaData.class).getIncrementalColumnNames(dialect).contains(columnName)) { - statement = dialect.getIncrementalColumnStatement(catalogName, schemaName, tableName, columnName, dataType, + statement = dialect.getIncrementalColumnStatement(catalogName, schemaName, tableName, targetColumnName, + dataType, typeName, columnSize, columnScale, defaultValue, nullable); } else { - statement = dialect.getColumnStatement(catalogName, schemaName, tableName, columnName, dataType, typeName, + statement = dialect.getColumnStatement(catalogName, schemaName, tableName, targetColumnName, dataType, + typeName, columnSize, columnScale, defaultValue, nullable, comment); } @@ -130,6 +141,24 @@ public String getColumnName() { * User defined type (enum, domain) of the column for the same database type, null otherwise */ @SuppressWarnings("unchecked") + /** + * Vector type of the target database for a vector column, null if the target keeps the vectors as text + */ + private String getVectorTypeName(Dialect dialect) { + String typeName = getTargetVectorTypeName(dialect, detail); + return typeName != null ? Dialect.VERBATIM_TYPE_PREFIX + typeName : null; + } + + /** + * Vector type of the target for the column of the details, null if the column is not a vector or the target + * keeps it as text + */ + public static String getTargetVectorTypeName(Dialect dialect, Map detail) { + VectorType vectorType = VectorType.of(detail); + return vectorType != null ? dialect.getVectorTypeName(vectorType, !"NO".equalsIgnoreCase( + (String) detail.get("IS_NULLABLE"))) : null; + } + private String getUserTypeName(Dialect dialect) { Map userType = (Map) detail.get("USER_TYPE"); if (userType == null || dialect.isCrossDatabase()) { diff --git a/src/main/java/com/github/sqljam/impexp/CombinedIndexMetaData.java b/src/main/java/com/github/sqljam/impexp/CombinedIndexMetaData.java index d685308..3120390 100644 --- a/src/main/java/com/github/sqljam/impexp/CombinedIndexMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/CombinedIndexMetaData.java @@ -21,12 +21,15 @@ import java.util.List; import java.util.Map; +import lombok.extern.slf4j.Slf4j; + /** * @Description: CombinedIndexMetaData is an index of multiple columns * @Author: Fred Feng * @Date: 26/03/2023 * @Version 1.0.0 */ +@Slf4j public class CombinedIndexMetaData implements TiedMetaData { private final String indexName; @@ -108,9 +111,22 @@ public String[] getStatements() throws SQLException { String tableName = getDialect().getTargetTableName(getTableName()); boolean partition = tableMetaData.isPartitioned(); boolean unique = IndexMetaData.isUnique(getDetail()); - String indexName = getDialect().getIndexNameStatement(catalogName, schemaName, tableName, columnNames, unique, - null); - String statement = getDialect().getCreateIndexStatement(catalogName, schemaName, tableName, partition, columnNames, + IndexMetaData.VectorIndexing vectorIndexing = IndexMetaData.getVectorIndexing(tableMetaData, columnNames); + if (vectorIndexing != IndexMetaData.VectorIndexing.NONE && vectorIndexing != IndexMetaData.VectorIndexing.SAME) { + log.info("Index of vector columns is skipped, the target has no index for them: {}{}", tableName, + Arrays.toString(columnNames)); + return null; + } + String method = IndexMetaData.getIndexMethod(getDialect(), getDetail()); + // Columns of the target, as the user mapped them + String[] targetColumnNames = getDialect().getTargetColumnNames(getTableName(), columnNames); + String indexName = getDialect().getIndexNameStatement(catalogName, schemaName, tableName, targetColumnNames, + unique, method); + String statement = method != null ? getDialect().getCreateIndexStatement(catalogName, schemaName, tableName, + partition, targetColumnNames, indexName, unique, method, indexMetaDatas.stream().map(md -> (String) md + .getDetail().get(IndexMetaData.OPERATOR_CLASS)).toArray(String[]::new), + (String) getDetail().get(IndexMetaData.INDEX_OPTIONS)) + : getDialect().getCreateIndexStatement(catalogName, schemaName, tableName, partition, targetColumnNames, indexName, unique, null); return new String[]{statement}; } diff --git a/src/main/java/com/github/sqljam/impexp/CommentMetaData.java b/src/main/java/com/github/sqljam/impexp/CommentMetaData.java index d8be9af..f202227 100644 --- a/src/main/java/com/github/sqljam/impexp/CommentMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/CommentMetaData.java @@ -52,8 +52,8 @@ public String[] getStatements() throws SQLException { if (columnName == null) { statement = getDialect().getCreateTableCommentStatement(catalogName, schemaName, tableName, comment); } else { - statement = getDialect().getCreateCommentStatement(catalogName, schemaName, tableName, columnName, - comment); + statement = getDialect().getCreateCommentStatement(catalogName, schemaName, tableName, getDialect() + .getTargetColumnName(getTableName(), columnName), comment); } return new String[]{statement}; } diff --git a/src/main/java/com/github/sqljam/impexp/DataFileExporter.java b/src/main/java/com/github/sqljam/impexp/DataFileExporter.java index 76c5873..092b576 100644 --- a/src/main/java/com/github/sqljam/impexp/DataFileExporter.java +++ b/src/main/java/com/github/sqljam/impexp/DataFileExporter.java @@ -421,6 +421,16 @@ public void onError(String message, Throwable e) { delegate.onError(message, e); } + @Override + public void onRowSkipped(String catalog, String schema, String table, String key, Throwable e) { + delegate.onRowSkipped(catalog, schema, table, key, e); + } + + @Override + public void onDdlExecuted(String statement, boolean successful) { + delegate.onDdlExecuted(statement, successful); + } + @Override public void onEnd(boolean successful) { if (!successful) { diff --git a/src/main/java/com/github/sqljam/impexp/DataFileImporter.java b/src/main/java/com/github/sqljam/impexp/DataFileImporter.java index f2d054b..7b047da 100644 --- a/src/main/java/com/github/sqljam/impexp/DataFileImporter.java +++ b/src/main/java/com/github/sqljam/impexp/DataFileImporter.java @@ -507,6 +507,16 @@ public void onError(String message, Throwable e) { delegate.onError(message, e); } + @Override + public void onRowSkipped(String catalogName, String schemaName, String tableName, String key, Throwable e) { + delegate.onRowSkipped(catalogName, schemaName, tableName, key, e); + } + + @Override + public void onDdlExecuted(String statement, boolean successful) { + delegate.onDdlExecuted(statement, successful); + } + @Override public boolean isCancelled() { return delegate.isCancelled(); diff --git a/src/main/java/com/github/sqljam/impexp/DatabaseFeature.java b/src/main/java/com/github/sqljam/impexp/DatabaseFeature.java new file mode 100644 index 0000000..937734f --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/DatabaseFeature.java @@ -0,0 +1,52 @@ +/* + * 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: DatabaseFeature is a capability of a target database which is detected on its connection before it + * is used: types and indexes of extensions (pgvector), of editions and of settings (vector memory of + * Oracle) are not told by the version + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public enum DatabaseFeature { + + /** + * Dense vectors of 32-bit floats, e.g. vector of pgvector, VECTOR of MySQL + */ + VECTOR, + + /** + * Dense vectors of 16-bit floats, e.g. halfvec of pgvector, VECTOR(n, float16) of SQL Server + */ + HALF_VECTOR, + + /** + * Sparse vectors, e.g. sparsevec of pgvector, VECTOR(n, FLOAT32, SPARSE) of Oracle + */ + SPARSE_VECTOR, + + /** + * Vector indexes of graphs (HNSW) + */ + HNSW_INDEX, + + /** + * Vector indexes of partitions (IVF) + */ + IVF_INDEX +} diff --git a/src/main/java/com/github/sqljam/impexp/DbType.java b/src/main/java/com/github/sqljam/impexp/DbType.java index 58e4c25..798d977 100644 --- a/src/main/java/com/github/sqljam/impexp/DbType.java +++ b/src/main/java/com/github/sqljam/impexp/DbType.java @@ -54,7 +54,19 @@ import com.github.sqljam.utils.CaseInsensitiveMap; /** - * @Description: DbType is a supported database type, it creates dialects (by versions) and metadata operations + * @Description: DbType is a supported database type, it creates dialects (by versions) and metadata operations. + * Supported versions, and the versions of vectors (detected on the connection of a target): + *
    + *
  • MySQL 5.5 and later, VECTOR of 9.0
  • + *
  • MariaDB 10.3 and later, VECTOR and VECTOR INDEX of 11.7
  • + *
  • PostgreSQL 9.x and later, pgvector 0.4 and later (HNSW of 0.5.0, halfvec, sparsevec of 0.7.0)
  • + *
  • Oracle 11g and later, VECTOR of 23ai (sparse of 23.7, HNSW with vector memory)
  • + *
  • SQL Server 2008 and later, VECTOR of 2025
  • + *
  • Db2 for Linux, UNIX and Windows 11.1 and later, VECTOR and vector indexes of 12.1.2
  • + *
  • H2 2.x, SQLite 3, Access 2000 files and later (.mdb, .accdb), no vectors
  • + *
  • DuckDB 1.x, vectors are FLOAT[n]
  • + *
  • ClickHouse 24.x and later, vectors are Array(Float32), vector similarity indexes of 25.x
  • + *
* @Author: Fred Feng * @Date: 25/03/2023 * @Version 1.0.0 @@ -123,7 +135,8 @@ public String getUrl(String hostname, int port, String catalogName) { Validate.notBlank(catalogName, "Catalog name must be required while using jdbc api to operate postgresql database."); return String.format( - "jdbc:postgresql://%s:%d/%s?characterEncoding=utf8&allowMultiQueries=true&useSSL=false&stringtype=unspecified", + "jdbc:postgresql://%s:%d/%s?characterEncoding=utf8&allowMultiQueries=true&useSSL=false&stringtype=unspecified" + + "&reWriteBatchedInserts=true", hostname, port, catalogName); } }, diff --git a/src/main/java/com/github/sqljam/impexp/Dialect.java b/src/main/java/com/github/sqljam/impexp/Dialect.java index 2ea185b..8f6b75e 100644 --- a/src/main/java/com/github/sqljam/impexp/Dialect.java +++ b/src/main/java/com/github/sqljam/impexp/Dialect.java @@ -36,6 +36,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -49,6 +50,8 @@ import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.github.sqljam.jdbc.JdbcUtils; import com.github.sqljam.utils.MapUtils; @@ -60,6 +63,7 @@ */ public abstract class Dialect { + private static final Logger LOGGER = LoggerFactory.getLogger(Dialect.class); private static final Pattern SIMPLE_IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_$#]*"); private static final Pattern NUMBER_LITERAL = Pattern.compile("[-+]?\\d+(\\.\\d+)?([eE][-+]?\\d+)?"); private static final Pattern STRING_LITERAL = Pattern.compile("[nN]?'((?:[^']|'')*)'"); @@ -103,6 +107,7 @@ public abstract class Dialect { * Name pattern of target tables, {table} is replaced with the source table name, e.g. {table}_copy */ private String tableNamePattern; + private Map> columnMappings = Collections.emptyMap(); private IdentifierCase identifierCase = IdentifierCase.AUTO; /** * Names of existing target tables and columns, they are used as they are @@ -122,6 +127,11 @@ public abstract class Dialect { */ private int databaseMajorVersion = -1; private int databaseMinorVersion = -1; + /** + * Features detected on the connection of the target database, null if they are not known, e.g. for a script: + * then they are taken by the version, like the latest version for an unknown version + */ + private Set databaseFeatures; protected Dialect() { @@ -219,6 +229,67 @@ public void setSourceDbType(DbType sourceDbType) { this.sourceDbType = sourceDbType; } + /** + * Columns which the user mapped, by source table and source column, none for an import as it is + */ + public void setColumnMappings(Map> columnMappings) { + this.columnMappings = columnMappings != null ? columnMappings : Collections.emptyMap(); + } + + public Map> getColumnMappings() { + return columnMappings; + } + + /** + * Mapping of a column of a source table, null if it has none + */ + public ColumnMapping getColumnMapping(String sourceTable, String column) { + if (columnMappings.isEmpty() || sourceTable == null || column == null) { + return null; + } + Map mappings = columnMappings.get(sourceTable); + return mappings != null ? mappings.get(column) : null; + } + + /** + * Name of the target column of a column of a source table, its own name without a mapping + */ + public String getTargetColumnName(String sourceTable, String column) { + ColumnMapping mapping = getColumnMapping(sourceTable, column); + return mapping != null ? mapping.getTargetName(column) : column; + } + + public String[] getTargetColumnNames(String sourceTable, String[] columns) { + if (columnMappings.isEmpty() || columns == null) { + return columns; + } + return Arrays.stream(columns).map(column -> getTargetColumnName(sourceTable, column)).toArray(String[]::new); + } + + /** + * Target columns of a list of columns separated by commas, e.g. of a composite primary key + */ + public String getTargetColumnNames(String sourceTable, String columns) { + if (columnMappings.isEmpty() || columns == null) { + return columns; + } + return String.join(",", getTargetColumnNames(sourceTable, columns.split(","))); + } + + public boolean isColumnSkipped(String sourceTable, String column) { + ColumnMapping mapping = getColumnMapping(sourceTable, column); + return mapping != null && mapping.isSkipped(); + } + + /** + * Type of the target column which the user wrote, written as it is, null for the translated type + */ + public String getMappedColumnType(String sourceTable, String column) { + ColumnMapping mapping = getColumnMapping(sourceTable, column); + return mapping != null && mapping.getTargetType() != null ? VERBATIM_TYPE_PREFIX + mapping.getTargetType() + : null; + } + public String getTableNamePattern() { return tableNamePattern; } @@ -337,14 +408,76 @@ public Dialect forVersion(int majorVersion, int minorVersion) { dialect.setSourceDbType(sourceDbType); dialect.setReadTargetDbType(readTargetDbType); dialect.setTableNamePattern(tableNamePattern); + dialect.setColumnMappings(columnMappings); dialect.setIdentifierCase(identifierCase); dialect.setTargetCatalogName(targetCatalogName); dialect.setTargetSchemaName(targetSchemaName); dialect.setSourceSchemaPreserved(sourceSchemaPreserved); dialect.addExactIdentifiers(exactIdentifiers); + dialect.setDatabaseFeatures(databaseFeatures); return dialect; } + /** + * Detects the features of the connected database before they are used, e.g. installed extensions. A feature + * which can not be detected (no privilege, an older server) is not available. + */ + public final Set readDatabaseFeatures(Connection connection) { + Set features = EnumSet.noneOf(DatabaseFeature.class); + try { + detectDatabaseFeatures(connection, features); + } catch (SQLException | RuntimeException e) { + // The features found before the error are kept + LOGGER.warn("Features of the database are not readable: {}", e.getMessage()); + } + this.databaseFeatures = features; + return features; + } + + /** + * Adds the features of the connected database, by queries which do not fail for a missing feature where the + * database has them (catalogs of PostgreSQL: a failed query aborts its transaction) + */ + protected void detectDatabaseFeatures(Connection connection, Set features) throws SQLException { + } + + public Set getDatabaseFeatures() { + return databaseFeatures != null ? Collections.unmodifiableSet(databaseFeatures) : null; + } + + public void setDatabaseFeatures(Set databaseFeatures) { + this.databaseFeatures = databaseFeatures != null ? EnumSet.copyOf(databaseFeatures.isEmpty() + ? EnumSet.noneOf(DatabaseFeature.class) : databaseFeatures) : null; + } + + /** + * Whether the database has the feature: by its detected features, by its version if they are not known + * + * @param byVersion whether the version of the database has the feature + */ + protected boolean hasDatabaseFeature(DatabaseFeature feature, boolean byVersion) { + return databaseFeatures != null ? databaseFeatures.contains(feature) : byVersion; + } + + /** + * Whether a probing query succeeds, e.g. a function of a feature. A failed query is rolled back if the + * connection has a transaction. + */ + protected static boolean probe(Connection connection, String sql) { + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) { + return rs.next(); + } catch (SQLException e) { + try { + if (!connection.getAutoCommit()) { + connection.rollback(); + } + } catch (SQLException ignored) { + // The connection is closed + } + return false; + } + } + /** * Major and minor version of the connected database by its metadata, versions of other kinds (e.g. the file * format of Access) are read by the dialect of the database @@ -669,6 +802,17 @@ public String getIndexNameStatement(String catalog, String schema, String tableN return getLimitedIdentifier(foldIdentifier(name)); } + /** + * Index of an access method of the same database type, e.g. GIN of PostgreSQL, with the operator classes of its + * columns (null for the default ones) and its storage options, e.g. fillfactor=70 + */ + public String getCreateIndexStatement(String catalog, String schema, String tableName, boolean partition, + String[] columnNames, String indexName, boolean unique, String indexType, + String[] operatorClasses, String options) { + return getCreateIndexStatement(catalog, schema, tableName, partition, columnNames, indexName, unique, + indexType); + } + public String getCreateIndexStatement(String catalog, String schema, String tableName, boolean partition, String[] columnNames, String indexName, boolean unique, String indexType) { @@ -1005,6 +1149,48 @@ public String getRegisteredColumnTypeName(String tableName, String columnName) { return typeName != null ? typeName.toLowerCase(Locale.ENGLISH) : ""; } + // ------------------------------------------------------------------ + // Vectors + // ------------------------------------------------------------------ + + /** + * Type of a vector column of this database, null if the vectors are kept as text: the database has no vectors, + * not of these elements or not of so many dimensions + */ + public String getVectorTypeName(VectorType vectorType) { + return null; + } + + /** + * Type of a vector column which takes NULL or not, a database whose vectors take no NULL (arrays of + * ClickHouse) keeps nullable vectors as text + */ + public String getVectorTypeName(VectorType vectorType, boolean nullable) { + return getVectorTypeName(vectorType); + } + + /** + * Value of a vector for a vector column of this database (see {@link #getVectorTypeName}), by the text of the + * source vector, e.g. a sparse vector as the sparse text of this database + */ + public Object getVectorValue(VectorType.Values values, VectorType vectorType) { + return values.toDenseText(); + } + + /** + * Index for the nearest neighbors of a vector column, null if this database has none for it: then no index is + * created, an ordinary index can not hold vectors + * + * @param columnTypeName type of the column of this database, see {@link #getVectorTypeName} + * @param vectorType vector type of the source column, e.g. for its dimensions + * @param nullable whether the column takes NULL + */ + public String getCreateVectorIndexStatement(String catalog, String schema, String tableName, String indexName, + String columnName, String columnTypeName, VectorType vectorType, + boolean nullable, VectorIndex vectorIndex) { + return null; + } + // ------------------------------------------------------------------ // Default values // ------------------------------------------------------------------ @@ -1158,6 +1344,13 @@ public String[] getSessionStatements() { return null; } + /** + * Prepares a session of the target before tables are created and rows are written, e.g. types of extensions are + * made visible. A session which can not be prepared is used as it is. + */ + public void prepareSession(Connection connection) { + } + public String getScriptCommentPrefix() { return "-- "; } @@ -1172,6 +1365,24 @@ public String getInsertTableStatement(String catalog, String schema, String tabl values); } + /** + * Statement which inserts the rows, each row with a parameter of each column + */ + public String getInsertTableStatement(String catalog, String schema, String tableName, String[] columnNames, + int rows) { + String values = "(" + Arrays.stream(columnNames).map(name -> "?").collect(Collectors.joining(",")) + ")"; + return String.format("INSERT INTO %s(%s) VALUES %s", getQualifiedTableName(catalog, schema, tableName), + getIdentifiers(columnNames), String.join(",", Collections.nCopies(rows, values))); + } + + /** + * Rows which a statement inserts at most, 1 for a batch of statements of one row. A database which runs the + * statements of a batch one by one (DuckDB) inserts several rows a statement. + */ + public int getRowsPerInsert() { + return 1; + } + public String getInsertTableStatement(String catalog, String schema, String tableName, String[] columnNames, Object[] columnValues) { String values = StringUtils.join(columnValues, ","); diff --git a/src/main/java/com/github/sqljam/impexp/ExportListener.java b/src/main/java/com/github/sqljam/impexp/ExportListener.java index 0dc2d8a..174da3d 100644 --- a/src/main/java/com/github/sqljam/impexp/ExportListener.java +++ b/src/main/java/com/github/sqljam/impexp/ExportListener.java @@ -51,6 +51,21 @@ default void onMessage(String message) { default void onError(String message, Throwable e) { } + /** + * A row with an error which was skipped, after its error was told by {@link #onError}, e.g. for a report + * + * @param key values which name the row, e.g. its primary key + */ + default void onRowSkipped(String catalogName, String schemaName, String tableName, String key, Throwable e) { + } + + /** + * A statement of the structure which was run on the target: a table, a key, an index, a constraint, a sequence + * or a comment, e.g. for a report of the objects which were created + */ + default void onDdlExecuted(String statement, boolean successful) { + } + default void onEnd(boolean successful) { } diff --git a/src/main/java/com/github/sqljam/impexp/Exporter.java b/src/main/java/com/github/sqljam/impexp/Exporter.java index def1e56..49f40ad 100644 --- a/src/main/java/com/github/sqljam/impexp/Exporter.java +++ b/src/main/java/com/github/sqljam/impexp/Exporter.java @@ -32,8 +32,8 @@ 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; +import com.github.sqljam.page.MapBasedPageReader; import com.github.sqljam.page.PageReader; import com.github.sqljam.page.PageRequest; import com.github.sqljam.page.PageResponse; diff --git a/src/main/java/com/github/sqljam/impexp/ForeignKeyMetaData.java b/src/main/java/com/github/sqljam/impexp/ForeignKeyMetaData.java index befc434..cdab186 100644 --- a/src/main/java/com/github/sqljam/impexp/ForeignKeyMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/ForeignKeyMetaData.java @@ -17,15 +17,19 @@ import java.sql.DatabaseMetaData; import java.sql.SQLException; +import java.util.Arrays; import java.util.List; import java.util.Map; +import lombok.extern.slf4j.Slf4j; + /** * @Description: ForeignKeyMetaData generates a foreign key constraint, which is created after rows imported * @Author: Fred Feng * @Date: 26/03/2023 * @Version 1.0.0 */ +@Slf4j public class ForeignKeyMetaData implements TiedMetaData { private final String foreignKeyName; @@ -50,6 +54,16 @@ public String[] getStatements() throws SQLException { .toArray(String[]::new); Dialect dialect = getDialect(); String refTableName = getReferencedTableName(); + String sourceRefTableName = refTableName; + // A key on a column which the user skipped has no column, other columns are named as the user mapped them + if (Arrays.stream(columnNames).anyMatch(column -> dialect.isColumnSkipped(getTableName(), column)) + || Arrays.stream(refColumnNames).anyMatch(column -> dialect.isColumnSkipped(sourceRefTableName, + column))) { + log.info("Foreign key is skipped, a column of it is not imported: {}", foreignKeyName); + return null; + } + columnNames = dialect.getTargetColumnNames(getTableName(), columnNames); + refColumnNames = dialect.getTargetColumnNames(sourceRefTableName, refColumnNames); String fkName = foreignKeyName; if (dialect.isTableRenamed()) { // Copies reference copies of tables which are copied together, other tables are referenced as they are diff --git a/src/main/java/com/github/sqljam/impexp/ImportExportHandler.java b/src/main/java/com/github/sqljam/impexp/ImportExportHandler.java index 6253a29..b2e3f91 100644 --- a/src/main/java/com/github/sqljam/impexp/ImportExportHandler.java +++ b/src/main/java/com/github/sqljam/impexp/ImportExportHandler.java @@ -37,6 +37,7 @@ import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.ArrayUtils; @@ -104,6 +105,11 @@ public static class ImportConfiguration { * Key columns of upserts by source table name, the primary key of the table by default */ private Map> keyColumnNames = new HashMap<>(); + /** + * Target columns which the user mapped by source table and source column: another name, a type of the + * target written as it is, or no column. Columns without a mapping are imported as they are. + */ + private Map> columnMappings = new HashMap<>(); /** * Whether target tables are dropped and created, existing tables, indexes and constraints are skipped when * they are kept @@ -156,9 +162,13 @@ public Dialect configureDialect(Dialect dialect) { int[] version = dialect.readDatabaseVersion(databaseMetaData); dialect = dialect.forVersion(version[0], version[1]); } + Set features = dialect.readDatabaseFeatures(connection); this.dialect = dialect; exportListener.onMessage(String.format("Target database: %s %s", databaseMetaData.getDatabaseProductName(), databaseMetaData.getDatabaseProductVersion())); + if (!features.isEmpty()) { + exportListener.onMessage("Target database features: " + features); + } } catch (SQLException e) { throw new ImpExpException("Unable to connect to target database: " + e.getMessage(), e); } finally { @@ -203,6 +213,9 @@ private Connection getConnection(String catalogName, String schemaName) throws S connection = connectionFactory.getConnection(); } executeQuietly(connection, dialect != null ? dialect.getSessionStatements() : null); + if (dialect != null) { + dialect.prepareSession(connection); + } return connection; } @@ -298,6 +311,7 @@ private void executeStatements(String catalogName, String schemaName, List targetTypes = Collections.emptyMap(); boolean upsert = configuration.getWriteMode().isKeyed(); @@ -462,7 +480,8 @@ public void exportData(String catalogName, Set fixed = new HashSet<>(); Arrays.stream(keys).forEach(name -> fixed.add(name.toLowerCase(Locale.ENGLISH))); // Identity values of existing rows are kept - identityColumnNames.forEach(name -> fixed.add(name.toLowerCase(Locale.ENGLISH))); + identityColumnNames.forEach(name -> fixed.add(dialect.getTargetColumnName(tableName, name) + .toLowerCase(Locale.ENGLISH))); if (!dialect.isIndexedColumnUpdatable()) { fixed.addAll(dialect.getIndexedColumns(connection, getTargetCatalog(connection), getTargetSchema(connection, schemaName), dialect.foldIdentifier(targetTableName))); @@ -487,35 +506,32 @@ public void exportData(String catalogName, Map arrayParameters = new HashMap<>(); final Map types = targetTypes; final Connection target = connection; - RowBinder binder = (statement, row) -> bindRow(target, statement, row, columns, nullTypes, - emptyLobTypes, types, arrayColumns, arrayParameters); + RowBinder binder = (statement, row, offset) -> bindRow(target, statement, row, offset, columns, + nullTypes, emptyLobTypes, types, arrayColumns, arrayParameters); Savepoint savepoint = configuration.isSkipErrors() && transactional ? setSavepointQuietly(connection) : null; + int rowsPerStatement = upsert ? 1 : dialect.getRowsPerInsert(); try { - for (Map row : rowList) { - binder.bind(ps, row); - ps.addBatch(); - } - int[] counts = ps.executeBatch(); - releaseQuietly(connection, savepoint); - if (upsert) { - // Updated rows count twice in MySQL, every row is written once - rows = rowList.size(); + if (rowsPerStatement > 1 && rowList.size() > 1) { + // Several rows a statement for databases which run a batch row by row (DuckDB) + rows = insertRows(connection, catalogName, schemaName, targetTableName, columns, rowList, + binder, rowsPerStatement); + releaseQuietly(connection, savepoint); } else { - for (int n : counts) { - rows += n > 0 ? n : (n == Statement.SUCCESS_NO_INFO ? 1 : 0); - } + rows = executeBatch(ps, rowList, binder, upsert); + releaseQuietly(connection, savepoint); } } catch (SQLException e) { if (!configuration.isSkipErrors()) { throw e; } - // Rows are named by their keys in the log, or by their first columns - List named = keys != null && keys.length > 0 ? Arrays.asList(keys) - : tableMetaData.getPrimaryKeyColumnNames(); + // Rows are named by their keys in the log, or by their first columns, by the source names + // which the rows keep + List named = keys != null && keys.length > 0 ? toSourceColumns(keys, columns, + sourceColumns) : tableMetaData.getPrimaryKeyColumnNames(); rows = writeRowByRow(connection, ps, insertSql, savepoint, transactional, rowList, binder, tableName, - named.isEmpty() ? Arrays.asList(columns).subList(0, Math.min(3, columns.length)) - : named, e); + named.isEmpty() ? Arrays.asList(sourceColumns).subList(0, Math.min(3, + sourceColumns.length)) : named, e); } } executeQuietly(connection, dialect.getStatementAfterInsert(catalogName, schemaName, targetTableName, @@ -553,34 +569,128 @@ public void exportData(String catalogName, } } + /** + * Source columns of target columns, by their positions in the columns which are written + */ + static List toSourceColumns(String[] targetNames, String[] columns, String[] sourceColumns) { + List names = new ArrayList<>(); + for (String name : targetNames) { + int index = 0; + while (index < columns.length && !columns[index].equalsIgnoreCase(name)) { + index++; + } + names.add(index < columns.length ? sourceColumns[index] : name); + } + return names; + } + + /** + * Writes the rows by the batch of the statement + * + * @return rows written + */ + private static int executeBatch(PreparedStatement ps, List> rowList, RowBinder binder, + boolean upsert) throws SQLException { + for (Map row : rowList) { + binder.bind(ps, row); + ps.addBatch(); + } + int[] counts = ps.executeBatch(); + if (upsert) { + // Updated rows count twice in MySQL, every row is written once + return rowList.size(); + } + int rows = 0; + for (int n : counts) { + rows += n > 0 ? n : (n == Statement.SUCCESS_NO_INFO ? 1 : 0); + } + return rows; + } + + /** + * Writes the rows by statements of several rows each, e.g. 256 rows of DuckDB, which runs the rows of a batch + * one by one. The values are bound like those of a statement of one row. + * + * @return rows written + */ + private int insertRows(Connection connection, String catalogName, String schemaName, String tableName, + String[] columns, List> rowList, RowBinder binder, + int rowsPerStatement) throws SQLException { + int rows = 0; + PreparedStatement full = null; + try { + for (int from = 0; from < rowList.size(); from += rowsPerStatement) { + List> part = rowList.subList(from, Math.min(from + rowsPerStatement, + rowList.size())); + PreparedStatement statement; + if (part.size() == rowsPerStatement) { + if (full == null) { + full = connection.prepareStatement(dialect.getInsertTableStatement(catalogName, schemaName, + tableName, columns, rowsPerStatement)); + } + statement = full; + } else { + statement = connection.prepareStatement(dialect.getInsertTableStatement(catalogName, schemaName, + tableName, columns, part.size())); + } + try { + for (int i = 0; i < part.size(); i++) { + binder.bind(statement, part.get(i), i * columns.length); + } + int count = statement.executeUpdate(); + rows += count >= 0 ? count : part.size(); + } finally { + if (statement != full) { + statement.close(); + } + } + } + } finally { + if (full != null) { + full.close(); + } + } + return rows; + } + /** * Binds the values of a row to the parameters of the statement */ @FunctionalInterface private interface RowBinder { - void bind(PreparedStatement ps, Map row) throws SQLException; + + void bind(PreparedStatement ps, Map row, int offset) throws SQLException; + + default void bind(PreparedStatement ps, Map row) throws SQLException { + bind(ps, row, 0); + } } - private void bindRow(Connection connection, PreparedStatement ps, Map row, String[] columns, - int[] nullTypes, int[] emptyLobTypes, Map targetTypes, + /** + * Binds the values of the row to the parameters after the offset, 0 for a statement of one row, a multiple of + * the columns for the next row of a statement of several rows + */ + private void bindRow(Connection connection, PreparedStatement ps, Map row, int offset, + String[] columns, int[] nullTypes, int[] emptyLobTypes, Map targetTypes, Set arrayColumns, Map arrayParameters) throws SQLException { int index = 0; for (Object value : row.values()) { + int parameter = offset + index + 1; if (value == null) { - ps.setNull(index + 1, nullTypes[index]); + ps.setNull(parameter, nullTypes[index]); } else { if (isArrayText(value) && dialect.isArrayParameterSupported() && (arrayColumns.contains(columns[index].toLowerCase(Locale.ENGLISH)) - || isArrayParameter(ps, index + 1, arrayParameters))) { + || isArrayParameter(ps, parameter, 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()); + ps.setBlob(parameter, connection.createBlob()); } else if (emptyLobTypes[index] == Types.CLOB && "".equals(value)) { - ps.setClob(index + 1, connection.createClob()); + ps.setClob(parameter, connection.createClob()); } else { - ps.setObject(index + 1, targetTypes.isEmpty() ? value : dialect.getTargetJdbcValue(value, + ps.setObject(parameter, targetTypes.isEmpty() ? value : dialect.getTargetJdbcValue(value, targetTypes.get(columns[index].toLowerCase(Locale.ENGLISH)))); } } @@ -667,12 +777,13 @@ private boolean writeRow(Connection connection, PreparedStatement statement, Map connection.rollback(); } skippedRows.computeIfAbsent(tableName, name -> new AtomicLong()).incrementAndGet(); - String message = String.format("Skipped row of table '%s' (%s): %s", tableName, - describeRow(row, namedColumns), e.getMessage()); + String key = describeRow(row, namedColumns); + String message = String.format("Skipped row of table '%s' (%s): %s", tableName, key, e.getMessage()); if (log.isWarnEnabled()) { log.warn(message); } exportListener.onError(message, e); + exportListener.onRowSkipped(null, null, tableName, key, e); return false; } } @@ -721,9 +832,11 @@ private void resetIdentityValues(String catalogName, String schemaName, String t // The existing table by its own name String targetTableName = targetDialect.getTargetTableName(tableName); Map existingNames = targetNames.getOrDefault(targetTableName, Collections.emptyMap()); + // The identity column by the name which the user mapped + String columnName = targetDialect.getTargetColumnName(tableName, entry.getKey()); String sql = targetDialect.getResetIdentityStatement(catalogName, schemaName, existingNames.getOrDefault( - "", targetTableName), existingNames.getOrDefault(entry.getKey().toLowerCase(Locale.ENGLISH), - entry.getKey()), entry.getValue() + 1); + "", targetTableName), existingNames.getOrDefault(columnName.toLowerCase(Locale.ENGLISH), + columnName), entry.getValue() + 1); if (StringUtils.isNotBlank(sql)) { sqls.add(sql); } @@ -775,6 +888,10 @@ private String[] getKeyColumnNames(Connection connection, String schemaName, Str if (CollectionUtils.isEmpty(keys)) { keys = tableMetaData.getPrimaryKeyColumnNames(); } + if (CollectionUtils.isNotEmpty(keys)) { + // Keys of the source are named as the user mapped them + keys = keys.stream().map(key -> dialect.getTargetColumnName(tableName, key)).collect(Collectors.toList()); + } if (CollectionUtils.isEmpty(keys)) { keys = dialect.getPrimaryKeyColumns(connection, getTargetCatalog(connection), getTargetSchema(connection, schemaName), dialect.foldIdentifier(targetTableName)); diff --git a/src/main/java/com/github/sqljam/impexp/ImportExporter.java b/src/main/java/com/github/sqljam/impexp/ImportExporter.java index 3c541e1..ffc651c 100644 --- a/src/main/java/com/github/sqljam/impexp/ImportExporter.java +++ b/src/main/java/com/github/sqljam/impexp/ImportExporter.java @@ -112,6 +112,16 @@ public void onError(String message, Throwable e) { delegate.onError(message, e); } + @Override + public void onRowSkipped(String catalogName, String schemaName, String tableName, String key, Throwable e) { + delegate.onRowSkipped(catalogName, schemaName, tableName, key, e); + } + + @Override + public void onDdlExecuted(String statement, boolean successful) { + delegate.onDdlExecuted(statement, successful); + } + @Override public void onEnd(boolean successful) { delegate.onEnd(successful); @@ -173,6 +183,7 @@ private void prepare() { dialect.setIdentifierCase(identifierCase); } dialect.setTableNamePattern(importConfiguration.getTableNamePattern()); + dialect.setColumnMappings(importConfiguration.getColumnMappings()); // Rows of free queries are new tables, they are not the tables which are read if (!dialect.isTableRenamed() && !exportConfiguration.isQueryExport() && isSameTables(exportConfiguration, importConfiguration)) { diff --git a/src/main/java/com/github/sqljam/impexp/IndexMetaData.java b/src/main/java/com/github/sqljam/impexp/IndexMetaData.java index 89276ab..31ea3e0 100644 --- a/src/main/java/com/github/sqljam/impexp/IndexMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/IndexMetaData.java @@ -19,14 +19,30 @@ import java.sql.SQLException; import java.util.Map; +import lombok.extern.slf4j.Slf4j; + /** * @Description: IndexMetaData is an index of one column * @Author: Fred Feng * @Date: 25/03/2023 * @Version 1.0.0 */ +@Slf4j public class IndexMetaData implements TiedMetaData { + /** + * Access method of the index, e.g. btree, gin, hnsw of PostgreSQL + */ + public static final String INDEX_METHOD = "INDEX_METHOD"; + /** + * Operator class of the column, null for the default one, e.g. vector_cosine_ops + */ + public static final String OPERATOR_CLASS = "OPERATOR_CLASS"; + /** + * Storage options of the index, e.g. m=16, ef_construction=64 + */ + public static final String INDEX_OPTIONS = "INDEX_OPTIONS"; + private final String indexName; private final String columnName; private final Map detail; @@ -125,13 +141,102 @@ public String[] getStatements() throws SQLException { boolean partition = tableMetaData.isPartitionTable(); int typeIndex = TableMetaData.getInt(detail, "TYPE"); String indexType = typeIndex == DatabaseMetaData.tableIndexHashed ? "HASH" : null; + String method = getIndexMethod(getDialect(), detail); + if (method != null) { + indexType = method; + } boolean unique = isUnique(detail); - String indexName = getDialect().getIndexNameStatement(catalogName, schemaName, tableName, new String[]{columnName}, - unique, - indexType); - String statement = getDialect().getCreateIndexStatement(catalogName, schemaName, tableName, partition, - new String[]{columnName}, indexName, - unique, indexType); + // Column of the target, as the user mapped it + String targetColumnName = getDialect().getTargetColumnName(getTableName(), columnName); + String indexName = getDialect().getIndexNameStatement(catalogName, schemaName, tableName, + new String[]{targetColumnName}, unique, indexType); + VectorIndexing vectorIndexing = getVectorIndexing(tableMetaData, new String[]{columnName}); + if (vectorIndexing == VectorIndexing.SKIPPED) { + log.info("Index of vector column is skipped, the target has no index for it: {}.{}", tableName, + columnName); + return null; + } + VectorIndex vectorIndex = VectorIndex.of(detail); + if (vectorIndexing == VectorIndexing.VECTOR_INDEX + || (vectorIndexing == VectorIndexing.SAME && method == null && vectorIndex != null)) { + // An ordinary index of another database type can not hold vectors, a vector index of the same type + // without access method (MariaDB, Oracle) is created by its kind and distance + if (vectorIndex == null) { + log.info("Index of vector column is skipped, it is not a vector index: {}.{}", tableName, columnName); + return null; + } + ColumnMetaData column = tableMetaData.findColumnMetaData(columnName).orElse(null); + String columnTypeName = ColumnMetaData.getTargetVectorTypeName(getDialect(), column.getDetail()); + boolean nullable = !"NO".equalsIgnoreCase((String) column.getDetail().get("IS_NULLABLE")); + String statement = getDialect().getCreateVectorIndexStatement(catalogName, schemaName, tableName, + indexName, targetColumnName, columnTypeName, VectorType.of(column.getDetail()), nullable, + vectorIndex); + if (statement == null) { + log.info("Vector index is skipped, the target has no index of {}: {}.{}", vectorIndex, tableName, + columnName); + return null; + } + return new String[]{statement}; + } + String statement = method != null ? getDialect().getCreateIndexStatement(catalogName, schemaName, tableName, + partition, new String[]{targetColumnName}, indexName, unique, indexType, + new String[]{(String) detail.get(OPERATOR_CLASS)}, (String) detail.get(INDEX_OPTIONS)) + : getDialect().getCreateIndexStatement(catalogName, schemaName, tableName, partition, + new String[]{targetColumnName}, indexName, unique, indexType); return new String[]{statement}; } + + /** + * How an index on columns of vectors is created + */ + enum VectorIndexing { + /** + * No column is a vector, the index is an ordinary one + */ + NONE, + /** + * The index of the same database type is created as it is, the columns keep their vector types + */ + SAME, + /** + * A vector index of the target is created, or none + */ + VECTOR_INDEX, + /** + * The index is not created: the target keeps the vectors as text, or has no index of vectors + */ + SKIPPED + } + + static VectorIndexing getVectorIndexing(TableMetaData tableMetaData, String[] columnNames) { + Dialect dialect = tableMetaData.getDialect(); + int vectors = 0; + for (String columnName : columnNames) { + ColumnMetaData column = tableMetaData.findColumnMetaData(columnName).orElse(null); + VectorType vectorType = column != null ? VectorType.of(column.getDetail()) : null; + if (vectorType == null) { + continue; + } + if (ColumnMetaData.getTargetVectorTypeName(dialect, column.getDetail()) == null) { + return VectorIndexing.SKIPPED; + } + vectors++; + } + if (vectors == 0) { + return VectorIndexing.NONE; + } + if (!dialect.isCrossDatabase()) { + return VectorIndexing.SAME; + } + return columnNames.length == 1 ? VectorIndexing.VECTOR_INDEX : VectorIndexing.SKIPPED; + } + + /** + * Access method of an index of the same database type which is not the default one, e.g. gin or hnsw of + * PostgreSQL, null for other indexes + */ + static String getIndexMethod(Dialect dialect, Map detail) { + String method = (String) detail.get(INDEX_METHOD); + return !dialect.isCrossDatabase() && method != null && !"btree".equalsIgnoreCase(method) ? method : null; + } } \ No newline at end of file diff --git a/src/main/java/com/github/sqljam/impexp/PrimaryKeyMetaData.java b/src/main/java/com/github/sqljam/impexp/PrimaryKeyMetaData.java index c15ea04..c1330ee 100644 --- a/src/main/java/com/github/sqljam/impexp/PrimaryKeyMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/PrimaryKeyMetaData.java @@ -92,8 +92,8 @@ public String[] getStatements() throws SQLException { 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); + String statement = getDialect().getCreatePrimaryKeyStatement(catalogName, schemaName, tableName, getDialect() + .getTargetColumnNames(getTableName(), columnName), pkName); return new String[]{statement}; } diff --git a/src/main/java/com/github/sqljam/impexp/ScriptExportHandler.java b/src/main/java/com/github/sqljam/impexp/ScriptExportHandler.java index 5ac8074..d94bdb1 100644 --- a/src/main/java/com/github/sqljam/impexp/ScriptExportHandler.java +++ b/src/main/java/com/github/sqljam/impexp/ScriptExportHandler.java @@ -673,6 +673,15 @@ static Object getJdbcValue(Dialect dialect, ColumnMetaData columnMetaData, Objec return dialect.getJdbcValue(value); } Map detail = columnMetaData.getDetail(); + VectorType vectorType = VectorType.of(detail); + if (vectorType != null && value instanceof CharSequence + && ColumnMetaData.getTargetVectorTypeName(dialect, detail) != null) { + // Vectors pass as text, in the format of the vectors of the target + VectorType.Values values = VectorType.parseValues(value.toString()); + if (values != null) { + return dialect.getVectorValue(values, vectorType); + } + } return dialect.getJdbcValue(value, TableMetaData.getInt(detail, "DATA_TYPE"), (String) detail.get("TYPE_NAME"), TableMetaData.getInt(detail, "COLUMN_SIZE")); } diff --git a/src/main/java/com/github/sqljam/impexp/SqlScriptRunner.java b/src/main/java/com/github/sqljam/impexp/SqlScriptRunner.java index a5b3d73..2680b22 100644 --- a/src/main/java/com/github/sqljam/impexp/SqlScriptRunner.java +++ b/src/main/java/com/github/sqljam/impexp/SqlScriptRunner.java @@ -72,6 +72,22 @@ public class SqlScriptRunner { private static final Pattern INSERT_STATEMENT = Pattern.compile("(?is)^(INSERT|MERGE)\\s+INTO\\s.*"); private static final Pattern MERGE_STATEMENT = Pattern.compile("(?is)^MERGE\\s+INTO\\s.*"); + /** + * Statements of rows, their failures are skipped rows + */ + private static final Pattern ROW_STATEMENT = Pattern.compile("(?is)^\\s*(INSERT|MERGE|UPSERT|REPLACE|UPDATE|" + + "DELETE)\\b.*"); + /** + * Statements of the session, which are neither rows nor the structure + */ + private static final Pattern SESSION_STATEMENT = Pattern.compile("(?is)^\\s*(SET|USE|COMMIT|ROLLBACK|SELECT|" + + "WITH|CALL|DECLARE|START\\s+TRANSACTION|BEGIN\\s+TRAN\\w*)\\b.*"); + /** + * Table of a statement of rows, the last part of its qualified name without quotes + */ + private static final Pattern ROW_TABLE = Pattern.compile("(?is)^\\s*(?:INSERT|MERGE|UPSERT|REPLACE)\\s+" + + "(?:IGNORE\\s+)?INTO\\s+(?:[`\"\\[]?[^\\s(`\"\\]]+[`\"\\]]?\\.)*[`\"\\[]?([^\\s(`\"\\].]+)" + + "[`\"\\]]?.*"); private final Connection connection; private boolean stopOnError = true; @@ -238,6 +254,18 @@ private void closeStatement() { } } + static boolean isRowStatement(String sql) { + return ROW_STATEMENT.matcher(sql).matches(); + } + + /** + * Table of an INSERT or MERGE statement without its schema and quotes, null for other statements + */ + static String tableOf(String sql) { + java.util.regex.Matcher matcher = ROW_TABLE.matcher(sql); + return matcher.matches() ? matcher.group(1) : null; + } + static boolean isBatchable(String sql) { return INSERT_STATEMENT.matcher(sql).matches(); } @@ -295,6 +323,9 @@ private boolean execute(String sql) throws SQLException { statement().execute(sql); executedCount++; releaseSavepoint(savepoint); + if (!isRowStatement(sql) && !SESSION_STATEMENT.matcher(sql).matches()) { + exportListener.onDdlExecuted(sql, true); + } return true; } catch (SQLException e) { if (missingDropError != null && missingDropError.test(sql, e)) { @@ -320,13 +351,18 @@ private boolean execute(String sql) throws SQLException { log.error(message); } exportListener.onError(message, e); + if (isRowStatement(sql)) { + exportListener.onRowSkipped(null, null, tableOf(sql), StringUtils.abbreviate(StringUtils + .normalizeSpace(sql), 200), e); + } else { + exportListener.onDdlExecuted(sql, false); + } if (stopOnError) { rollback(); throw e; } - if (savepoint != null) { - connection.rollback(savepoint); - } + // A failed DDL statement of H2 ends the transaction with its savepoints, nothing is rolled back then + rollbackQuietly(savepoint); return false; } } diff --git a/src/main/java/com/github/sqljam/impexp/TableMetaData.java b/src/main/java/com/github/sqljam/impexp/TableMetaData.java index 5cbce1e..fb452b6 100644 --- a/src/main/java/com/github/sqljam/impexp/TableMetaData.java +++ b/src/main/java/com/github/sqljam/impexp/TableMetaData.java @@ -19,10 +19,13 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -71,9 +74,9 @@ public void accept(MetaDataVisitor visitor) throws SQLException { } 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); + // Selected columns of a query, all columns if none, without the columns which the user skipped + Predicate selected = columnName -> (query == null || query.getColumns().isEmpty() + || query.getColumns().contains(columnName)) && !getDialect().isColumnSkipped(tableName, columnName); final boolean partitioned = isPartitioned(); DatabaseMetaData databaseMetaData = getMetaData(); List> infoList = null; @@ -105,6 +108,7 @@ public void accept(MetaDataVisitor visitor) throws SQLException { columnMetaDatas.add(new ColumnMetaData(columnName, columnInfo, this)); } } + checkColumnMappings(infoList); for (ColumnMetaData columnMetaData : columnMetaDatas) { columnMetaData.accept(visitor); } @@ -244,6 +248,34 @@ public String[] getStatements() throws SQLException { return new String[]{statement}; } + /** + * Mappings of the user which can not be imported are refused before anything is created: a column which the + * table does not have, and two columns with the same target name + */ + private void checkColumnMappings(List> columnInfos) { + Map mappings = getDialect().getColumnMappings().get(tableName); + if (mappings == null || mappings.isEmpty()) { + return; + } + Set sourceNames = columnInfos.stream().map(info -> (String) info.get("COLUMN_NAME")).collect( + Collectors.toSet()); + for (String column : mappings.keySet()) { + if (!sourceNames.contains(column)) { + throw new ImpExpException(String.format("Column %s of the mapping is not a column of table %s", + column, tableName)); + } + } + Map targetNames = new HashMap<>(); + for (ColumnMetaData column : columnMetaDatas) { + String target = getDialect().getTargetColumnName(tableName, column.getColumnName()); + String other = targetNames.putIfAbsent(target.toLowerCase(Locale.ENGLISH), column.getColumnName()); + if (other != null) { + throw new ImpExpException(String.format("Columns %s and %s of table %s are both mapped to %s", + other, column.getColumnName(), tableName, target)); + } + } + } + public List getColumnMetaDatas() { return columnMetaDatas; } diff --git a/src/main/java/com/github/sqljam/impexp/VectorIndex.java b/src/main/java/com/github/sqljam/impexp/VectorIndex.java new file mode 100644 index 0000000..445475e --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/VectorIndex.java @@ -0,0 +1,124 @@ +/* + * 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.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import org.apache.commons.lang3.StringUtils; + +/** + * @Description: VectorIndex is an index for the nearest neighbors of a vector column: its kind (HNSW graph or IVF + * partitions), the distance which it is built for and its options (m, ef_construction of HNSW, lists of + * IVF). It is kept in the details of an index as text, e.g. HNSW,COSINE,m=16;ef_construction=64. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class VectorIndex { + + /** + * Key of the vector index in the details of an index + */ + public static final String DETAIL_KEY = "VECTOR_INDEX"; + + public enum Kind { + HNSW, IVF + } + + public enum Distance { + EUCLIDEAN, COSINE, DOT, MANHATTAN, HAMMING, JACCARD + } + + private final Kind kind; + private final Distance distance; + private final Map options; + + public VectorIndex(Kind kind, Distance distance, Map options) { + this.kind = Objects.requireNonNull(kind); + this.distance = Objects.requireNonNull(distance); + this.options = options != null ? new LinkedHashMap<>(options) : new LinkedHashMap<>(); + } + + public Kind getKind() { + return kind; + } + + public Distance getDistance() { + return distance; + } + + /** + * Options of the index by their names in lower case, e.g. m, ef_construction, lists + */ + public Map getOptions() { + return options; + } + + public String getOption(String name) { + return options.get(name.toLowerCase(Locale.ENGLISH)); + } + + /** + * Vector index of the details of an index, null if the index is not a vector index + */ + public static VectorIndex of(Map detail) { + Object value = detail != null ? detail.get(DETAIL_KEY) : null; + return value != null ? parse(value.toString()) : null; + } + + public static VectorIndex parse(String text) { + String[] parts = StringUtils.defaultString(text).split(",", 3); + if (parts.length < 2) { + return null; + } + try { + Map options = new LinkedHashMap<>(); + if (parts.length == 3) { + for (String option : parts[2].split(";")) { + String[] pair = option.split("=", 2); + if (pair.length == 2 && StringUtils.isNotBlank(pair[0])) { + options.put(pair[0].trim().toLowerCase(Locale.ENGLISH), pair[1].trim()); + } + } + } + return new VectorIndex(Kind.valueOf(parts[0].trim().toUpperCase(Locale.ENGLISH)), + Distance.valueOf(parts[1].trim().toUpperCase(Locale.ENGLISH)), options); + } catch (IllegalArgumentException e) { + return null; + } + } + + @Override + public String toString() { + StringBuilder text = new StringBuilder(kind + "," + distance + ","); + options.forEach((name, value) -> text.append(text.charAt(text.length() - 1) == ',' ? "" : ";").append(name) + .append('=').append(value)); + return text.toString(); + } + + @Override + public boolean equals(Object other) { + return other instanceof VectorIndex && toString().equals(other.toString()); + } + + @Override + public int hashCode() { + return toString().hashCode(); + } +} diff --git a/src/main/java/com/github/sqljam/impexp/VectorType.java b/src/main/java/com/github/sqljam/impexp/VectorType.java new file mode 100644 index 0000000..17e027c --- /dev/null +++ b/src/main/java/com/github/sqljam/impexp/VectorType.java @@ -0,0 +1,305 @@ +/* + * 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.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import org.apache.commons.lang3.StringUtils; + +/** + * @Description: VectorType is the type of a vector column of any database: the type of its elements, the number of + * its dimensions (0 if it is not fixed) and whether it is sparse. It is kept in the column metadata as + * text (FLOAT32,1024,DENSE), so that it is written into the manifest of packages like other details. + * Vectors are passed between databases as text: dense [1,2.5,-3], sparse of PostgreSQL {1:1.5,3:2}/5 + * (indexes from 1) and sparse of Oracle [5,[0,2],[1.5,2]] (indexes from 0). + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class VectorType { + + /** + * Key of the vector type in the details of a column + */ + public static final String DETAIL_KEY = "VECTOR_TYPE"; + + /** + * Type of the elements of a vector + */ + public enum Element { + FLOAT16, FLOAT32, FLOAT64, INT8, BINARY + } + + private final Element element; + private final int dimensions; + private final boolean sparse; + + public VectorType(Element element, int dimensions, boolean sparse) { + this.element = Objects.requireNonNull(element); + this.dimensions = Math.max(dimensions, 0); + this.sparse = sparse; + } + + public static VectorType dense(Element element, int dimensions) { + return new VectorType(element, dimensions, false); + } + + public Element getElement() { + return element; + } + + /** + * Number of dimensions, 0 if the column takes vectors of any dimensions + */ + public int getDimensions() { + return dimensions; + } + + public boolean isSparse() { + return sparse; + } + + /** + * Whether the elements are exact as 32-bit floats: half precision floats and bytes are, doubles and bits are not + */ + public boolean isFloat32Compatible() { + return element == Element.FLOAT16 || element == Element.FLOAT32 || element == Element.INT8; + } + + /** + * Vector type of the details of a column, null if the column is not a vector + */ + public static VectorType of(Map detail) { + Object value = detail != null ? detail.get(DETAIL_KEY) : null; + return value != null ? parse(value.toString()) : null; + } + + /** + * Vector type of its text, e.g. FLOAT32,1024,DENSE, null if the text is blank or invalid + */ + public static VectorType parse(String text) { + String[] parts = StringUtils.defaultString(text).split(","); + if (parts.length != 3) { + return null; + } + try { + return new VectorType(Element.valueOf(parts[0].trim().toUpperCase(Locale.ENGLISH)), + Integer.parseInt(parts[1].trim()), "SPARSE".equalsIgnoreCase(parts[2].trim())); + } catch (IllegalArgumentException e) { + return null; + } + } + + @Override + public String toString() { + return element + "," + dimensions + "," + (sparse ? "SPARSE" : "DENSE"); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof VectorType)) { + return false; + } + VectorType type = (VectorType) other; + return element == type.element && dimensions == type.dimensions && sparse == type.sparse; + } + + @Override + public int hashCode() { + return Objects.hash(element, dimensions, sparse); + } + + // ------------------------------------------------------------------ + // Values + // ------------------------------------------------------------------ + + /** + * Elements of a vector by its text, numbers are kept as they are written. Null if the text is not a vector. + */ + public static Values parseValues(String text) { + String value = StringUtils.trimToEmpty(text); + if (value.startsWith("{")) { + return parsePostgreSqlSparse(value); + } + if (!value.startsWith("[") || !value.endsWith("]")) { + return null; + } + String body = value.substring(1, value.length() - 1).trim(); + if (body.contains("[")) { + return parseOracleSparse(body); + } + String[] items = body.isEmpty() ? new String[0] : body.split(","); + String[] numbers = new String[items.length]; + for (int i = 0; i < items.length; i++) { + numbers[i] = items[i].trim(); + } + return new Values(numbers.length, null, numbers); + } + + /** + * {1:1.5,3:2}/5 of pgvector, indexes start from 1 + */ + private static Values parsePostgreSqlSparse(String text) { + int slash = text.lastIndexOf("}/"); + if (slash < 0) { + return null; + } + try { + int dimensions = Integer.parseInt(text.substring(slash + 2).trim()); + String body = text.substring(1, slash).trim(); + List indexes = new ArrayList<>(); + List numbers = new ArrayList<>(); + for (String item : body.isEmpty() ? new String[0] : body.split(",")) { + String[] pair = item.split(":"); + indexes.add(Integer.parseInt(pair[0].trim()) - 1); + numbers.add(pair[1].trim()); + } + return new Values(dimensions, indexes.stream().mapToInt(Integer::intValue).toArray(), + numbers.toArray(new String[0])); + } catch (RuntimeException e) { + return null; + } + } + + /** + * [5,[0,2],[1.5,2]] of Oracle, indexes start from 0 + */ + private static Values parseOracleSparse(String body) { + int first = body.indexOf('['); + int second = body.indexOf('[', first + 1); + if (first < 0 || second < 0) { + return null; + } + try { + int dimensions = Integer.parseInt(StringUtils.removeEnd(body.substring(0, first).trim(), ",").trim()); + String indexText = StringUtils.substringBetween(body.substring(first), "[", "]").trim(); + String numberText = StringUtils.substringBetween(body.substring(second), "[", "]").trim(); + int[] indexes = indexText.isEmpty() ? new int[0] : Arrays.stream(indexText.split(",")) + .mapToInt(index -> Integer.parseInt(index.trim())).toArray(); + String[] numbers = numberText.isEmpty() ? new String[0] : Arrays.stream(numberText.split(",")) + .map(String::trim).toArray(String[]::new); + return indexes.length == numbers.length ? new Values(dimensions, indexes, numbers) : null; + } catch (RuntimeException e) { + return null; + } + } + + /** + * Elements of a dense or sparse vector + */ + public static final class Values { + + private final int dimensions; + private final int[] indexes; + private final String[] numbers; + + Values(int dimensions, int[] indexes, String[] numbers) { + this.dimensions = dimensions; + this.indexes = indexes; + this.numbers = numbers; + } + + public int getDimensions() { + return dimensions; + } + + public boolean isSparse() { + return indexes != null; + } + + /** + * All elements, zeros of a sparse vector included + */ + public String[] getDenseNumbers() { + if (indexes == null) { + return numbers.clone(); + } + String[] dense = new String[dimensions]; + Arrays.fill(dense, "0"); + for (int i = 0; i < indexes.length; i++) { + dense[indexes[i]] = numbers[i]; + } + return dense; + } + + /** + * [1,2.5,-3] + */ + public String toDenseText() { + return "[" + String.join(",", getDenseNumbers()) + "]"; + } + + /** + * {1:1.5,3:2}/5 of pgvector, zeros of a dense vector are left out + */ + public String toPostgreSqlSparseText() { + StringBuilder text = new StringBuilder("{"); + forEachNonZero((index, number) -> text.append(text.length() > 1 ? "," : "").append(index + 1).append(':') + .append(number)); + return text.append("}/").append(dimensions).toString(); + } + + /** + * [5,[0,2],[1.5,2]] of Oracle, zeros of a dense vector are left out + */ + public String toOracleSparseText() { + List indexTexts = new ArrayList<>(); + List numberTexts = new ArrayList<>(); + forEachNonZero((index, number) -> { + indexTexts.add(String.valueOf(index)); + numberTexts.add(number); + }); + return "[" + dimensions + ",[" + String.join(",", indexTexts) + "],[" + String.join(",", numberTexts) + + "]]"; + } + + /** + * Numbers as floats, zeros of a sparse vector included + */ + public float[] toFloats() { + String[] dense = getDenseNumbers(); + float[] floats = new float[dense.length]; + for (int i = 0; i < dense.length; i++) { + floats[i] = Float.parseFloat(dense[i]); + } + return floats; + } + + private void forEachNonZero(NumberConsumer consumer) { + if (indexes != null) { + for (int i = 0; i < indexes.length; i++) { + consumer.accept(indexes[i], numbers[i]); + } + return; + } + for (int i = 0; i < numbers.length; i++) { + if (Double.parseDouble(numbers[i]) != 0) { + consumer.accept(i, numbers[i]); + } + } + } + + @FunctionalInterface + private interface NumberConsumer { + void accept(int index, String number); + } + } +} diff --git a/src/main/java/com/github/sqljam/impexp/db/AccessDialect.java b/src/main/java/com/github/sqljam/impexp/db/AccessDialect.java index d1d6407..42f7c8f 100644 --- a/src/main/java/com/github/sqljam/impexp/db/AccessDialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/AccessDialect.java @@ -40,12 +40,13 @@ /** * @Description: AccessDialect generates ddl/dml of Microsoft Access files (.accdb, .mdb) through UCanAccess. Access - * has no schemas, sequences, partitions or comments. Type names are the ones of Access (INTEGER is 16-bit, - * LONG 32-bit, BYTE unsigned). Text columns hold 255 characters (MEMO beyond), + * has no schemas, sequences, partitions or comments. Type names are the ones of Access (INTEGER is + * 16-bit, LONG 32-bit, BYTE unsigned). Text columns hold 255 characters (MEMO beyond), * decimals 28 digits, date and time columns are DATETIME. 64-bit integers (BIGINT) are kept by files of * Access 2016 and later, older files get DECIMAL(28,1): UCanAccess stores BIGINT of them as 32-bit and * limits DECIMAL of scale 0 to 18 digits. Names are quoted by backticks, explicit autonumbers are * inserted with DISABLE AUTOINCREMENT. + * Files of Access 2000 and later are supported, .accdb and .mdb. Access has no vectors. * @Author: Fred Feng * @Date: 24/10/2026 * @Version 1.0.0 diff --git a/src/main/java/com/github/sqljam/impexp/db/ClickHouseDialect.java b/src/main/java/com/github/sqljam/impexp/db/ClickHouseDialect.java index eeba641..b03eb6c 100644 --- a/src/main/java/com/github/sqljam/impexp/db/ClickHouseDialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/ClickHouseDialect.java @@ -15,6 +15,7 @@ */ package com.github.sqljam.impexp.db; +import java.sql.Connection; import java.sql.Date; import java.sql.ResultSet; import java.sql.SQLException; @@ -38,10 +39,13 @@ import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.impexp.DatabaseFeature; import com.github.sqljam.impexp.DbType; import com.github.sqljam.impexp.Dialect; import com.github.sqljam.impexp.IdentifierCase; import com.github.sqljam.impexp.StringHelper; +import com.github.sqljam.impexp.VectorIndex; +import com.github.sqljam.impexp.VectorType; /** * @Description: ClickHouseDialect generates ddl/dml of ClickHouse: tables of the MergeTree engine sorted by the @@ -49,6 +53,8 @@ * 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. + * ClickHouse 24.x and later are supported. Vectors are Array(Float32), vector similarity indexes + * (HNSW) of ClickHouse 25.x are created if the settings of the server allow them. * @Author: Fred Feng * @Date: 26/03/2023 * @Version 1.0.0 @@ -149,6 +155,75 @@ public boolean isArrayParameterSupported() { return false; } + // ------------------------------------------------------------------ + // Vectors: arrays of floats + // ------------------------------------------------------------------ + + /** + * Granularity of a vector similarity index: one index of the whole part of a table + */ + static final long VECTOR_INDEX_GRANULARITY = 100_000_000L; + + /** + * Arrays of floats, and vector similarity indexes if the settings of the server allow them (they are beta of + * ClickHouse 25.x) + */ + @Override + protected void detectDatabaseFeatures(Connection connection, Set features) throws SQLException { + if (probe(connection, "SELECT CAST([1.0] AS Array(Float32))")) { + features.add(DatabaseFeature.VECTOR); + } + if (probe(connection, "SELECT 1 FROM system.settings WHERE name IN" + + " ('allow_experimental_vector_similarity_index', 'enable_vector_similarity_index') AND value = '1'")) { + features.add(DatabaseFeature.HNSW_INDEX); + } + } + + /** + * Array(Float32) of 32-bit floats (also of 16-bit floats and bytes), Array(Float64) of doubles + */ + @Override + public String getVectorTypeName(VectorType vectorType) { + if (!hasDatabaseFeature(DatabaseFeature.VECTOR, true) || vectorType.isSparse() + || vectorType.getElement() == VectorType.Element.BINARY) { + return null; + } + return vectorType.getElement() == VectorType.Element.FLOAT64 ? "Array(Float64)" : "Array(Float32)"; + } + + /** + * Arrays take no NULL: nullable vectors are kept as text, an empty array is not NULL + */ + @Override + public String getVectorTypeName(VectorType vectorType, boolean nullable) { + return nullable ? null : getVectorTypeName(vectorType); + } + + /** + * Vector similarity index (HNSW) of the cosine or euclidean distance, of the dimensions of the vectors + */ + @Override + public String getCreateVectorIndexStatement(String catalog, String schema, String tableName, String indexName, + String columnName, String columnTypeName, VectorType vectorType, + boolean nullable, VectorIndex vectorIndex) { + VectorIndex.Distance distance = vectorIndex.getDistance(); + // The settings are not known for a script, an index which they forbid would fail the table + if (!hasDatabaseFeature(DatabaseFeature.HNSW_INDEX, false) || vectorType == null + || vectorType.getDimensions() < 1 || (distance != VectorIndex.Distance.COSINE + && distance != VectorIndex.Distance.EUCLIDEAN)) { + return null; + } + String neighbors = vectorIndex.getOption("m"); + String candidates = vectorIndex.getOption("ef_construction"); + String options = StringUtils.isNumeric(neighbors) && StringUtils.isNumeric(candidates) + ? String.format(", 'bf16', %s, %s", neighbors, candidates) : ""; + return String.format("ALTER TABLE %s ADD INDEX %s %s TYPE vector_similarity('hnsw', '%s', %d%s)" + + " GRANULARITY %d", getQualifiedTableName(catalog, schema, tableName), + quoteIdentifier(indexName), getIdentifier(columnName), distance == VectorIndex.Distance.COSINE + ? "cosineDistance" : "L2Distance", vectorType.getDimensions(), options, + VECTOR_INDEX_GRANULARITY); + } + /** * Type name of ClickHouse keeps parameters and nested types, e.g. Decimal(12, 2), Array(Int32), Enum8('a' = 1) */ diff --git a/src/main/java/com/github/sqljam/impexp/db/ClickHouseMetaDataOperations.java b/src/main/java/com/github/sqljam/impexp/db/ClickHouseMetaDataOperations.java index 7dc45f3..a57af7a 100644 --- a/src/main/java/com/github/sqljam/impexp/db/ClickHouseMetaDataOperations.java +++ b/src/main/java/com/github/sqljam/impexp/db/ClickHouseMetaDataOperations.java @@ -36,6 +36,8 @@ import org.apache.commons.lang3.StringUtils; import com.github.sqljam.impexp.MetaDataOperations; import com.github.sqljam.impexp.TableMetaData; +import com.github.sqljam.impexp.VectorIndex; +import com.github.sqljam.impexp.VectorType; import com.github.sqljam.jdbc.JdbcUtils; import com.github.sqljam.utils.CaseInsensitiveMap; @@ -192,10 +194,98 @@ static List parseKeyColumns(String keyExpression) { /** * Data skipping indexes of ClickHouse are not indexes of other databases */ + /** + * Vector similarity indexes (HNSW) of a column, data skipping indexes of other types are not indexes of other + * databases + */ @Override public List> getIndexInfos(DatabaseMetaData databaseMetaData, String catalogName, - String schemaName, String tableName) { - return Collections.emptyList(); + String schemaName, String tableName) throws SQLException { + List> indexInfos = new ArrayList<>(); + getVectorIndexes(databaseMetaData, getSchema(databaseMetaData, schemaName), tableName).forEach((column, index) -> { + Map indexInfo = new CaseInsensitiveMap<>(); + indexInfo.put("TABLE_NAME", tableName); + indexInfo.put("INDEX_NAME", index[0]); + indexInfo.put("COLUMN_NAME", column); + indexInfo.put("NON_UNIQUE", true); + indexInfo.put("TYPE", (int) DatabaseMetaData.tableIndexOther); + indexInfo.put("ORDINAL_POSITION", 1); + indexInfo.put(VectorIndex.DETAIL_KEY, index[1]); + indexInfos.add(indexInfo); + }); + return indexInfos; + } + + /** + * vector_similarity('hnsw', 'cosineDistance', 1024[, quantization, M, ef_construction]) + */ + private static final Pattern VECTOR_SIMILARITY = Pattern.compile( + "(?i)^vector_similarity\\(\\s*'hnsw'\\s*,\\s*'(\\w+)'\\s*,\\s*(\\d+)(?:\\s*,\\s*'\\w+'" + + "(?:\\s*,\\s*(\\d+)(?:\\s*,\\s*(\\d+))?)?)?\\s*\\)$"); + + /** + * Vector indexes of the columns of a table: index name, vector index and dimensions. None if the indexes are + * not readable. + */ + static Map getVectorIndexes(DatabaseMetaData databaseMetaData, String schema, String tableName) { + Map indexes = new LinkedHashMap<>(); + try { + for (Map row : JdbcUtils.fetchAll(databaseMetaData.getConnection(), "SELECT name, expr," + + " type_full FROM system.data_skipping_indices WHERE database = ? AND table = ?" + + " AND type = 'vector_similarity'", new Object[]{schema, tableName})) { + String[] index = parseVectorIndex((String) row.get("type_full")); + String column = StringUtils.strip(StringUtils.trimToEmpty((String) row.get("expr")), "`"); + if (index != null && column.matches("[A-Za-z_][A-Za-z0-9_]*")) { + indexes.put(column, new String[]{(String) row.get("name"), index[0], index[1]}); + } + } + } catch (SQLException e) { + // Without system.data_skipping_indices the arrays have no indexes + } + return indexes; + } + + /** + * Vector index and dimensions of the definition of a vector similarity index, null for other definitions + */ + static String[] parseVectorIndex(String typeFull) { + Matcher matcher = VECTOR_SIMILARITY.matcher(StringUtils.trimToEmpty(typeFull)); + if (!matcher.matches()) { + return null; + } + String function = matcher.group(1).toLowerCase(Locale.ENGLISH); + VectorIndex.Distance distance = "cosinedistance".equals(function) ? VectorIndex.Distance.COSINE + : "l2distance".equals(function) ? VectorIndex.Distance.EUCLIDEAN : null; + if (distance == null) { + return null; + } + Map options = new LinkedHashMap<>(); + if (matcher.group(3) != null) { + options.put("m", matcher.group(3)); + } + if (matcher.group(4) != null) { + options.put("ef_construction", matcher.group(4)); + } + return new String[]{new VectorIndex(VectorIndex.Kind.HNSW, distance, options).toString(), matcher.group(2)}; + } + + /** + * Arrays of floats are the vectors of ClickHouse (cosineDistance, L2Distance), their dimensions are the ones of + * their vector similarity index, if they have one + */ + static VectorType getVectorType(String typeName, String dimensions) { + String type = StringUtils.defaultString(typeName).replace(" ", "").toLowerCase(Locale.ENGLISH); + int size = dimensions != null ? Integer.parseInt(dimensions) : 0; + switch (type) { + case "array(float32)": + return VectorType.dense(VectorType.Element.FLOAT32, size); + case "array(bfloat16)": + return VectorType.dense(VectorType.Element.FLOAT16, size); + case "array(float64)": + return VectorType.dense(VectorType.Element.FLOAT64, size); + default: + return null; + } } @Override @@ -223,6 +313,7 @@ public List> getColumnInfos(DatabaseMetaData databaseMetaDat systemColumns.put((String) row.get("name"), row); } Set keyColumns = new HashSet<>(); + Map vectorIndexes = getVectorIndexes(databaseMetaData, schema, tableName); for (Iterator> it = columnInfos.iterator(); it.hasNext(); ) { Map columnInfo = it.next(); String columnName = (String) columnInfo.get("COLUMN_NAME"); @@ -249,6 +340,12 @@ public List> getColumnInfos(DatabaseMetaData databaseMetaDat } columnInfo.put("REMARKS", StringUtils.trimToNull((String) systemColumn.get("comment"))); normalizeType(columnInfo, (String) systemColumn.get("type")); + String[] vectorIndex = vectorIndexes.get(columnName); + VectorType vectorType = getVectorType((String) systemColumn.get("type"), vectorIndex != null + ? vectorIndex[2] : null); + if (vectorType != null) { + columnInfo.put(VectorType.DETAIL_KEY, vectorType.toString()); + } if (isTrue(systemColumn.get("is_in_primary_key"))) { keyColumns.add(columnName); } diff --git a/src/main/java/com/github/sqljam/impexp/db/Db2Dialect.java b/src/main/java/com/github/sqljam/impexp/db/Db2Dialect.java index b73757f..2183a63 100644 --- a/src/main/java/com/github/sqljam/impexp/db/Db2Dialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/Db2Dialect.java @@ -16,6 +16,7 @@ package com.github.sqljam.impexp.db; import java.math.BigDecimal; +import java.sql.Connection; import java.sql.SQLException; import java.sql.Types; import java.time.LocalTime; @@ -25,19 +26,25 @@ import java.util.Arrays; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.impexp.DatabaseFeature; import com.github.sqljam.impexp.DbType; import com.github.sqljam.impexp.Dialect; import com.github.sqljam.impexp.IdentifierCase; import com.github.sqljam.impexp.StringHelper; +import com.github.sqljam.impexp.VectorIndex; +import com.github.sqljam.impexp.VectorType; /** * @Description: Db2Dialect for IBM Db2 for Linux, UNIX and Windows 11.1 and later versions. Strings of other * databases are sized by their characters, LOBs are created with their largest size, a table * has one identity column, upserts are MERGE statements. Db2 has no time zones in TIMESTAMP, values * with time zones are kept as text. + * VECTOR (FLOAT32, INT8) and vector indexes of Db2 12.1.2 and later, detected on the connection: the + * version does not tell the mod pack. * @Author: Fred Feng * @Date: 17/10/2026 * @Version 1.0.0 @@ -367,6 +374,66 @@ public String getCreateCommentStatement(String catalog, String schema, String ta /** * Indexes are objects of the schema of their table, LOB and XML columns can not be indexed by normal indexes */ + // ------------------------------------------------------------------ + // Vectors of Db2 12.1.2 and later + // ------------------------------------------------------------------ + + static final int MAX_VECTOR_DIMENSIONS = 8168; + + /** + * VECTOR and vector indexes (VANN) of Db2 12.1.2 and later, the version does not tell the mod pack + */ + @Override + protected void detectDatabaseFeatures(Connection connection, Set features) throws SQLException { + if (probe(connection, "SELECT VECTOR('[1]', 1, FLOAT32) FROM sysibm.sysdummy1")) { + features.add(DatabaseFeature.VECTOR); + features.add(DatabaseFeature.HNSW_INDEX); + } + } + + /** + * VECTOR(n, FLOAT32) (also of 16-bit floats) and VECTOR(n, INT8), dense and of fixed dimensions. A script of + * Db2 12.1 has them: the mod pack is not known. + */ + @Override + public String getVectorTypeName(VectorType vectorType) { + int dimensions = vectorType.getDimensions(); + if (!hasDatabaseFeature(DatabaseFeature.VECTOR, isVersionAtLeast(12, 1)) || vectorType.isSparse() + || !vectorType.isFloat32Compatible() || dimensions < 1 || dimensions > MAX_VECTOR_DIMENSIONS) { + return null; + } + return String.format("VECTOR(%d, %s)", dimensions, vectorType.getElement() == VectorType.Element.INT8 ? "INT8" + : "FLOAT32"); + } + + /** + * Bytes of the key of a vector index: 8000 of a table space of 32K pages, the table space which Db2 takes for + * wide tables. Tables of 4K pages have no vector indexes of 1024 dimensions. + */ + static final int MAX_VECTOR_INDEX_BYTES = 8000; + + /** + * Vector index of the euclidean or cosine distance (other distances have none), of vectors of 8000 bytes at + * most: 2000 dimensions of 32-bit floats + */ + @Override + public String getCreateVectorIndexStatement(String catalog, String schema, String tableName, String indexName, + String columnName, String columnTypeName, VectorType vectorType, + boolean nullable, VectorIndex vectorIndex) { + VectorIndex.Distance distance = vectorIndex.getDistance(); + int bytes = vectorType == null ? 0 : vectorType.getDimensions() * (vectorType.getElement() + == VectorType.Element.INT8 ? 1 : Float.BYTES); + if (!hasDatabaseFeature(DatabaseFeature.HNSW_INDEX, isVersionAtLeast(12, 1)) || bytes > MAX_VECTOR_INDEX_BYTES + || (distance != VectorIndex.Distance.EUCLIDEAN && distance != VectorIndex.Distance.COSINE)) { + return null; + } + String qualifier = getQualifier(null, getTargetSchemaName(schema)); + String index = StringUtils.isNotBlank(qualifier) ? qualifier + "." + quoteIdentifier(indexName) + : quoteIdentifier(indexName); + return String.format("CREATE VECTOR INDEX %s ON %s (%s) WITH DISTANCE %s", index, + getQualifiedTableName(catalog, schema, tableName), getIdentifier(columnName), distance); + } + @Override public String getCreateIndexStatement(String catalog, String schema, String tableName, boolean partition, String[] columnNames, String indexName, boolean unique, String indexType) { diff --git a/src/main/java/com/github/sqljam/impexp/db/Db2MetaDataOperations.java b/src/main/java/com/github/sqljam/impexp/db/Db2MetaDataOperations.java index 644454e..376ca98 100644 --- a/src/main/java/com/github/sqljam/impexp/db/Db2MetaDataOperations.java +++ b/src/main/java/com/github/sqljam/impexp/db/Db2MetaDataOperations.java @@ -30,6 +30,8 @@ import org.apache.commons.lang3.StringUtils; import com.github.sqljam.impexp.MetaDataOperations; import com.github.sqljam.impexp.TableMetaData; +import com.github.sqljam.impexp.VectorIndex; +import com.github.sqljam.impexp.VectorType; import com.github.sqljam.jdbc.JdbcUtils; import com.github.sqljam.utils.CaseInsensitiveMap; import lombok.extern.slf4j.Slf4j; @@ -157,9 +159,18 @@ public List> getColumnInfos(DatabaseMetaData databaseMetaDat new Object[]{schemaName, tableName})) { catalogColumns.put(StringUtils.trim((String) column.get("COLNAME")), column); } + Map vectorTypes = getVectorTypes(databaseMetaData, schemaName, tableName, catalogColumns); for (Map columnInfo : columnInfos) { columnInfo.put("TABLE_SCHEM", StringUtils.trim((String) columnInfo.get("TABLE_SCHEM"))); Map column = catalogColumns.get((String) columnInfo.get("COLUMN_NAME")); + VectorType vectorType = vectorTypes.get((String) columnInfo.get("COLUMN_NAME")); + if (vectorType != null) { + // The driver reports vectors as VARCHAR, they are text of other databases + columnInfo.put("TYPE_NAME", "VECTOR"); + columnInfo.put("DATA_TYPE", Types.LONGVARCHAR); + columnInfo.put("COLUMN_SIZE", Integer.MAX_VALUE); + columnInfo.put(VectorType.DETAIL_KEY, vectorType.toString()); + } boolean identity = column != null && "Y".equals(StringUtils.trim((String) column.get("IDENTITY"))); String expression = column != null && "A".equals(StringUtils.trim((String) column.get("GENERATED"))) && !identity ? getGenerationExpression((String) column.get("TEXT")) : null; @@ -258,10 +269,60 @@ public List> getPrimaryKeyInfos(DatabaseMetaData databaseMet return super.getPrimaryKeyInfos(databaseMetaData, null, schemaName, tableName); } + /** + * Vectors of Db2 12.1.2 and later: dimensions (LENGTH) and coordinate types (FLOAT32, INT8) of SYSCAT.COLUMNS. + * COORDINATETYPE is read only for tables with vectors, older versions do not have it. + */ + private Map getVectorTypes(DatabaseMetaData databaseMetaData, String schemaName, + String tableName, Map> catalogColumns) { + Map vectorTypes = new HashMap<>(); + if (catalogColumns.values().stream().noneMatch(column -> "VECTOR".equals(StringUtils.trim( + (String) column.get("TYPENAME"))))) { + return vectorTypes; + } + try { + for (Map column : JdbcUtils.fetchAll(databaseMetaData.getConnection(), + "SELECT COLNAME, LENGTH, COORDINATETYPE FROM SYSCAT.COLUMNS WHERE TABSCHEMA = ? AND TABNAME = ?" + + " AND TYPENAME = 'VECTOR'", new Object[]{schemaName, tableName})) { + Object length = column.get("LENGTH"); + vectorTypes.put(StringUtils.trim((String) column.get("COLNAME")), VectorType.dense("INT8".equals( + StringUtils.trim((String) column.get("COORDINATETYPE"))) ? VectorType.Element.INT8 + : VectorType.Element.FLOAT32, length instanceof Number ? ((Number) length).intValue() : 0)); + } + } catch (SQLException e) { + log.warn("Vector columns of {}.{} are not readable: {}", schemaName, tableName, e.getMessage()); + } + return vectorTypes; + } + + /** + * Vector indexes (INDEXTYPE VANN) of Db2 12.1.2 and later: the distance is not in the catalog views, the cosine + * distance is taken + */ @Override public List> getIndexInfos(DatabaseMetaData databaseMetaData, String catalogName, String schemaName, String tableName) throws SQLException { - return super.getIndexInfos(databaseMetaData, null, schemaName, tableName); + List> indexInfos = super.getIndexInfos(databaseMetaData, null, schemaName, tableName); + if (indexInfos.isEmpty()) { + return indexInfos; + } + List vectorIndexes = new ArrayList<>(); + try { + JdbcUtils.fetchAll(databaseMetaData.getConnection(), "SELECT INDNAME FROM SYSCAT.INDEXES" + + " WHERE TABSCHEMA = ? AND TABNAME = ? AND INDEXTYPE = 'VANN'", new Object[]{schemaName, + tableName}).forEach(row -> vectorIndexes.add(StringUtils.trim((String) row.get("INDNAME")))); + } catch (SQLException e) { + log.warn("Indexes of {}.{} are not readable: {}", schemaName, tableName, e.getMessage()); + } + for (Map indexInfo : indexInfos) { + if (vectorIndexes.contains(StringUtils.trim((String) indexInfo.get("INDEX_NAME")))) { + log.info("Distance of vector index {} is not in the catalog of Db2, cosine is taken", + indexInfo.get("INDEX_NAME")); + indexInfo.put(VectorIndex.DETAIL_KEY, new VectorIndex(VectorIndex.Kind.HNSW, + VectorIndex.Distance.COSINE, null).toString()); + } + } + return indexInfos; } @Override diff --git a/src/main/java/com/github/sqljam/impexp/db/DuckDBDialect.java b/src/main/java/com/github/sqljam/impexp/db/DuckDBDialect.java index 505a223..21f9647 100644 --- a/src/main/java/com/github/sqljam/impexp/db/DuckDBDialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/DuckDBDialect.java @@ -32,22 +32,32 @@ import java.util.UUID; import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.impexp.DatabaseFeature; import com.github.sqljam.impexp.DbType; import com.github.sqljam.impexp.Dialect; import com.github.sqljam.impexp.IdentifierCase; import com.github.sqljam.impexp.StringHelper; +import com.github.sqljam.impexp.VectorType; /** * @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. + * DuckDB 1.x is supported. Vectors are arrays of a fixed size (FLOAT[n], DuckDB 0.10 and later), HNSW + * indexes of the vss extension are experimental for database files, they are not created. * @Author: Fred Feng * @Date: 26/03/2023 * @Version 1.0.0 */ public class DuckDBDialect extends Dialect { + /** + * Rows of a statement which inserts rows + */ + static final int ROWS_PER_INSERT = 256; + + /** * Max precision of DECIMAL */ @@ -162,6 +172,29 @@ public Dialect forVersion(int majorVersion, int minorVersion) { /** * Lists are accepted as text */ + /** + * Vectors are arrays of a fixed size: FLOAT[n] of 32-bit floats (also of 16-bit floats and bytes), DOUBLE[n] + */ + @Override + public String getVectorTypeName(VectorType vectorType) { + if (!hasDatabaseFeature(DatabaseFeature.VECTOR, true) || vectorType.isSparse() + || vectorType.getDimensions() < 1 || vectorType.getElement() == VectorType.Element.BINARY) { + return null; + } + return (vectorType.getElement() == VectorType.Element.FLOAT64 ? "DOUBLE[" : "FLOAT[") + + vectorType.getDimensions() + "]"; + } + + /** + * Arrays of a fixed size (DuckDB 0.10 and later) + */ + @Override + protected void detectDatabaseFeatures(Connection connection, Set features) throws SQLException { + if (probe(connection, "SELECT [1.0]::FLOAT[1]")) { + features.add(DatabaseFeature.VECTOR); + } + } + @Override public boolean isArrayParameterSupported() { return false; @@ -596,4 +629,12 @@ public List getPrimaryKeyColumns(Connection connection, String catalog, } return columns; } + + /** + * DuckDB runs the statements of a batch one by one, statements of several rows insert many times faster + */ + @Override + public int getRowsPerInsert() { + return ROWS_PER_INSERT; + } } diff --git a/src/main/java/com/github/sqljam/impexp/db/DuckDBMetaDataOperations.java b/src/main/java/com/github/sqljam/impexp/db/DuckDBMetaDataOperations.java index 8a1f2e8..32b7f64 100644 --- a/src/main/java/com/github/sqljam/impexp/db/DuckDBMetaDataOperations.java +++ b/src/main/java/com/github/sqljam/impexp/db/DuckDBMetaDataOperations.java @@ -34,6 +34,7 @@ import org.apache.commons.lang3.StringUtils; import com.github.sqljam.impexp.MetaDataOperations; import com.github.sqljam.impexp.TableMetaData; +import com.github.sqljam.impexp.VectorType; import com.github.sqljam.jdbc.JdbcUtils; /** @@ -311,6 +312,10 @@ static void normalizeType(Map columnInfo) { setType(columnInfo, Types.TIMESTAMP_WITH_TIMEZONE, 32, 6); break; default: + VectorType vectorType = getVectorType(typeName); + if (vectorType != null) { + columnInfo.put(VectorType.DETAIL_KEY, vectorType.toString()); + } if (isTextType(typeName)) { // Nested types, enums, intervals and json are read as text columnInfo.put("DATA_TYPE", isJsonOrUuid(typeName) ? Types.OTHER : Types.VARCHAR); @@ -323,6 +328,22 @@ static void normalizeType(Map columnInfo) { } } + /** + * FLOAT[n] and DOUBLE[n], arrays of a fixed size of floats, are the vectors of DuckDB (array_cosine_distance), + * lists of floats (FLOAT[]) are not + */ + private static final Pattern VECTOR_ARRAY = Pattern.compile("^(FLOAT|REAL|FLOAT4|DOUBLE|FLOAT8)\\[(\\d+)\\]$"); + + static VectorType getVectorType(String typeName) { + Matcher matcher = VECTOR_ARRAY.matcher(StringUtils.defaultString(typeName).trim().toUpperCase(Locale.ENGLISH)); + if (!matcher.matches()) { + return null; + } + String element = matcher.group(1); + return VectorType.dense("DOUBLE".equals(element) || "FLOAT8".equals(element) ? VectorType.Element.FLOAT64 + : VectorType.Element.FLOAT32, Integer.parseInt(matcher.group(2))); + } + 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); 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 96280f6..48df11c 100644 --- a/src/main/java/com/github/sqljam/impexp/db/H2Dialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/H2Dialect.java @@ -28,7 +28,8 @@ import com.github.sqljam.impexp.StringHelper; /** - * @Description: H2Dialect for H2 2.x + * @Description: H2Dialect for H2 2.x. + * H2 2.x is supported, it has no vectors. * @Author: Fred Feng * @Date: 26/03/2023 * @Version 1.0.0 diff --git a/src/main/java/com/github/sqljam/impexp/db/MariaDBDialect.java b/src/main/java/com/github/sqljam/impexp/db/MariaDBDialect.java index 72c6772..eb37ab3 100644 --- a/src/main/java/com/github/sqljam/impexp/db/MariaDBDialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/MariaDBDialect.java @@ -19,14 +19,22 @@ import java.sql.SQLException; import java.sql.Statement; import java.sql.Types; +import java.util.Locale; import java.util.Map; +import java.util.Set; +import org.apache.commons.lang3.math.NumberUtils; +import com.github.sqljam.impexp.DatabaseFeature; import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.VectorIndex; +import com.github.sqljam.impexp.VectorType; /** * @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, system versioned tables. + * MariaDB 10.3 and later are supported. VECTOR and VECTOR INDEX (HNSW) of MariaDB 11.7 and later, + * detected on the connection. * @Author: Fred Feng * @Date: 26/03/2023 * @Version 1.0.0 @@ -74,6 +82,55 @@ protected String getVectorTextFunction() { return "VEC_ToText"; } + @Override + protected String getTextVectorFunction() { + return "VEC_FromText"; + } + + /** + * VECTOR of MariaDB 11.7 and later + */ + @Override + protected boolean isVectorTypeSupported() { + return isVersionAtLeast(11, 7); + } + + /** + * VECTOR and VECTOR INDEX (HNSW) come together in MariaDB + */ + @Override + protected void detectDatabaseFeatures(Connection connection, Set features) throws SQLException { + super.detectDatabaseFeatures(connection, features); + if (features.contains(DatabaseFeature.VECTOR)) { + features.add(DatabaseFeature.HNSW_INDEX); + } + } + + /** + * VECTOR INDEX (HNSW) of a NOT NULL column for the euclidean or cosine distance, M is the number of neighbors + */ + @Override + public String getCreateVectorIndexStatement(String catalog, String schema, String tableName, String indexName, + String columnName, String columnTypeName, VectorType vectorType, + boolean nullable, VectorIndex vectorIndex) { + VectorIndex.Distance distance = vectorIndex.getDistance(); + if (!hasDatabaseFeature(DatabaseFeature.HNSW_INDEX, isVectorTypeSupported()) || nullable + || (distance != VectorIndex.Distance.EUCLIDEAN && distance != VectorIndex.Distance.COSINE)) { + return null; + } + int neighbors = NumberUtils.toInt(vectorIndex.getOption("m"), 0); + return String.format("CREATE VECTOR INDEX %s ON %s (%s)%s DISTANCE=%s", quoteIdentifier(indexName), + getQualifiedTableName(catalog, schema, tableName), getIdentifier(columnName), + neighbors >= MIN_VECTOR_NEIGHBORS && neighbors <= MAX_VECTOR_NEIGHBORS ? " M=" + neighbors : "", + distance.name().toLowerCase(Locale.ENGLISH)); + } + + /** + * Neighbors (M) of a VECTOR INDEX + */ + static final int MIN_VECTOR_NEIGHBORS = 3; + static final int MAX_VECTOR_NEIGHBORS = 200; + @Override public boolean isSequenceSupported() { return true; diff --git a/src/main/java/com/github/sqljam/impexp/db/MariaDBMetaDataOperations.java b/src/main/java/com/github/sqljam/impexp/db/MariaDBMetaDataOperations.java index f87f4c6..974a45a 100644 --- a/src/main/java/com/github/sqljam/impexp/db/MariaDBMetaDataOperations.java +++ b/src/main/java/com/github/sqljam/impexp/db/MariaDBMetaDataOperations.java @@ -30,7 +30,9 @@ import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.impexp.VectorIndex; import com.github.sqljam.jdbc.JdbcUtils; +import lombok.extern.slf4j.Slf4j; /** * @Description: MariaDBMetaDataOperations reads MariaDB like MySQL, with the differences of MariaDB: column defaults @@ -41,6 +43,7 @@ * @Date: 26/03/2023 * @Version 1.0.0 */ +@Slf4j public class MariaDBMetaDataOperations extends MySQLMetaDataOperations { private static final Set SPATIAL_TYPES = new HashSet<>(Arrays.asList("geometry", "point", @@ -48,6 +51,80 @@ public class MariaDBMetaDataOperations extends MySQLMetaDataOperations { 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*$"); + /** + * VECTOR KEY `name` (`column`) `M`='8' `DISTANCE`='cosine' of SHOW CREATE TABLE + */ + private static final Pattern VECTOR_KEY = Pattern.compile( + "VECTOR KEY `((?:[^`]|``)+)` \\(`(?:[^`]|``)+`\\)((?: `[A-Z_]+`='[^']*')*)", Pattern.CASE_INSENSITIVE); + private static final Pattern VECTOR_KEY_OPTION = Pattern.compile("`([A-Z_]+)`='([^']*)'", + Pattern.CASE_INSENSITIVE); + + /** + * VECTOR INDEX (HNSW) is a vector index of other databases, its distance (euclidean by default) and number of + * neighbors (M) are read from SHOW CREATE TABLE + */ + @Override + public List> getIndexInfos(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName, String tableName) throws SQLException { + List> indexInfos = super.getIndexInfos(databaseMetaData, catalogName, schemaName, + tableName); + String sql = "SELECT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?" + + " AND INDEX_TYPE = 'VECTOR'"; + Set vectorIndexes = new HashSet<>(); + for (Map row : JdbcUtils.fetchAll(databaseMetaData.getConnection(), sql, + new Object[]{catalogName, tableName})) { + vectorIndexes.add((String) row.get("INDEX_NAME")); + } + if (vectorIndexes.isEmpty()) { + return indexInfos; + } + Map definitions = new LinkedHashMap<>(); + try { + List> createTable = JdbcUtils.fetchAll(databaseMetaData.getConnection(), + "SHOW CREATE TABLE `" + catalogName.replace("`", "``") + "`.`" + tableName.replace("`", "``") + + "`", new Object[0]); + definitions = getVectorIndexes(createTable.isEmpty() ? null + : (String) createTable.get(0).get("Create Table")); + } catch (SQLException e) { + // The defaults of MariaDB are taken: euclidean distance + log.warn("Definition of {}.{} is not readable, vector indexes take the default distance: {}", + catalogName, tableName, e.getMessage()); + } + for (Map indexInfo : indexInfos) { + String indexName = (String) indexInfo.get("INDEX_NAME"); + if (vectorIndexes.contains(indexName)) { + indexInfo.put(VectorIndex.DETAIL_KEY, definitions.getOrDefault(indexName, new VectorIndex( + VectorIndex.Kind.HNSW, VectorIndex.Distance.EUCLIDEAN, null).toString())); + } + } + return indexInfos; + } + + /** + * Vector indexes by their names, of the definition of a table + */ + static Map getVectorIndexes(String createTable) { + Map indexes = new LinkedHashMap<>(); + Matcher matcher = VECTOR_KEY.matcher(StringUtils.defaultString(createTable)); + while (matcher.find()) { + VectorIndex.Distance distance = VectorIndex.Distance.EUCLIDEAN; + Map options = new LinkedHashMap<>(); + Matcher option = VECTOR_KEY_OPTION.matcher(matcher.group(2)); + while (option.find()) { + String name = option.group(1).toLowerCase(Locale.ENGLISH); + if ("distance".equals(name)) { + distance = "cosine".equalsIgnoreCase(option.group(2)) ? VectorIndex.Distance.COSINE + : VectorIndex.Distance.EUCLIDEAN; + } else { + options.put(name, option.group(2)); + } + } + indexes.put(matcher.group(1).replace("``", "`"), new VectorIndex(VectorIndex.Kind.HNSW, distance, + options).toString()); + } + return indexes; + } + /** * System versioned tables keep WITH SYSTEM VERSIONING for MariaDB targets, it is required by SYSTEM_TIME * partitions 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 ada64bf..d4708a8 100644 --- a/src/main/java/com/github/sqljam/impexp/db/MySQLDialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/MySQLDialect.java @@ -24,17 +24,23 @@ import java.util.Arrays; 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.DatabaseFeature; import com.github.sqljam.impexp.DbType; import com.github.sqljam.impexp.Dialect; import com.github.sqljam.impexp.StringHelper; +import com.github.sqljam.impexp.VectorType; /** - * @Description: MySQLDialect for MySQL 8.0 and later versions + * @Description: MySQLDialect for MySQL 8.0 and later versions. + * MySQL 5.5 and later are supported, older versions by MySQL55Dialect, MySQL56Dialect and MySQL57Dialect. + * VECTOR of MySQL 9.0 and later, detected on the connection. MySQL has no vector indexes (searches are + * of HeatWave). * @Author: Fred Feng * @Date: 26/03/2023 * @Version 1.0.0 @@ -278,6 +284,61 @@ protected String getVectorTextFunction() { return "VECTOR_TO_STRING"; } + /** + * Function converting text to a vector + */ + protected String getTextVectorFunction() { + return "STRING_TO_VECTOR"; + } + + /** + * Dimensions of a VECTOR column + */ + static final int MAX_VECTOR_DIMENSIONS = 16383; + + /** + * VECTOR of MySQL 9.0 and later, by the version if the features of the database are not known + */ + protected boolean isVectorTypeSupported() { + return isVersionAtLeast(9, 0); + } + + /** + * VECTOR of the database: its function converting text to a vector is found + */ + @Override + protected void detectDatabaseFeatures(Connection connection, Set features) throws SQLException { + if (probe(connection, "SELECT " + getVectorTextFunction() + "(" + getTextVectorFunction() + "('[1]'))")) { + features.add(DatabaseFeature.VECTOR); + } + } + + /** + * VECTOR(n) holds dense vectors of 32-bit floats of a fixed number of dimensions + */ + @Override + public String getVectorTypeName(VectorType vectorType) { + int dimensions = vectorType.getDimensions(); + if (!hasDatabaseFeature(DatabaseFeature.VECTOR, isVectorTypeSupported()) || vectorType.isSparse() + || !vectorType.isFloat32Compatible() || dimensions < 1 || dimensions > MAX_VECTOR_DIMENSIONS) { + return null; + } + return "VECTOR(" + dimensions + ")"; + } + + /** + * Text of a vector for a VECTOR column of a script is converted by the function of the database + */ + @Override + public String getStringValue(String catalog, String schema, String tableName, String columnName, + Object columnValue) { + if (columnValue instanceof CharSequence + && getRegisteredColumnTypeName(tableName, columnName).startsWith("vector")) { + return String.format("%s(%s)", getTextVectorFunction(), getStringLiteral(columnValue.toString())); + } + return super.getStringValue(catalog, schema, tableName, columnName, columnValue); + } + @Override public String getCreateUserStatement(String username, String password) { return String.format("CREATE USER IF NOT EXISTS '%s'@'%%' IDENTIFIED BY '%s'", username, password); 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 609c506..2f15f8f 100644 --- a/src/main/java/com/github/sqljam/impexp/db/MySQLMetaDataOperations.java +++ b/src/main/java/com/github/sqljam/impexp/db/MySQLMetaDataOperations.java @@ -28,6 +28,7 @@ import org.apache.commons.lang3.StringUtils; import com.github.sqljam.impexp.MetaDataOperations; import com.github.sqljam.impexp.TableMetaData; +import com.github.sqljam.impexp.VectorType; import com.github.sqljam.jdbc.JdbcUtils; /** @@ -123,6 +124,8 @@ public List> getColumnInfos(DatabaseMetaData databaseMetaDat // 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); + columnInfo.put(VectorType.DETAIL_KEY, VectorType.dense(VectorType.Element.FLOAT32, dimensions) + .toString()); } String extra = StringUtils.defaultString((String) columnDetail.get("EXTRA")); String expression = (String) columnDetail.get("GENERATION_EXPRESSION"); 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 194eb23..b85571a 100644 --- a/src/main/java/com/github/sqljam/impexp/db/OracleDialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/OracleDialect.java @@ -16,23 +16,35 @@ package com.github.sqljam.impexp.db; import java.math.BigDecimal; +import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Statement; import java.sql.Types; import java.time.LocalTime; import java.time.OffsetDateTime; +import java.util.ArrayList; import java.util.Arrays; +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.math.NumberUtils; +import com.github.sqljam.impexp.DatabaseFeature; import com.github.sqljam.impexp.DbType; import com.github.sqljam.impexp.Dialect; import com.github.sqljam.impexp.IdentifierCase; import com.github.sqljam.impexp.StringHelper; +import com.github.sqljam.impexp.VectorIndex; +import com.github.sqljam.impexp.VectorType; /** - * @Description: OracleDialect for Oracle 12.2 and later versions + * @Description: OracleDialect for Oracle 12.2 and later versions. + * Oracle 11g and later are supported, older versions by Oracle11gDialect and Oracle12cDialect. + * VECTOR of Oracle 23ai, sparse vectors of 23.7, detected on the connection. HNSW indexes need vector + * memory (vector_memory_size), IVF indexes do not. * @Author: Fred Feng * @Date: 26/03/2023 * @Version 1.0.0 @@ -521,6 +533,102 @@ public boolean isAlreadyExistsError(SQLException e) { || super.isAlreadyExistsError(e); } + // ------------------------------------------------------------------ + // Vectors of Oracle 23ai + // ------------------------------------------------------------------ + + static final int MAX_VECTOR_DIMENSIONS = 65535; + + /** + * Vectors of Oracle 23ai (sparse ones of 23.7 and later) and their IVF indexes, HNSW indexes if the database + * has vector memory (vector_memory_size): they are kept in it. A setting which is not granted (v$parameter) is + * taken as no vector memory. + */ + @Override + protected void detectDatabaseFeatures(Connection connection, Set features) throws SQLException { + if (!probe(connection, "SELECT TO_VECTOR('[1]') FROM dual")) { + return; + } + features.add(DatabaseFeature.VECTOR); + features.add(DatabaseFeature.IVF_INDEX); + if (probe(connection, "SELECT TO_VECTOR('[1,[0],[1]]', 1, FLOAT32, SPARSE) FROM dual")) { + features.add(DatabaseFeature.SPARSE_VECTOR); + } + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery( + "SELECT value FROM v$parameter WHERE name = 'vector_memory_size'")) { + if (rs.next() && NumberUtils.toLong(rs.getString(1), 0) > 0) { + features.add(DatabaseFeature.HNSW_INDEX); + } + } catch (SQLException e) { + // Without vector memory HNSW indexes are created as IVF indexes + } + } + + /** + * VECTOR(n, format[, SPARSE]) of Oracle 23ai: FLOAT32 (also for 16-bit floats), FLOAT64, INT8 and BINARY + * (dimensions of whole bytes) + */ + @Override + public String getVectorTypeName(VectorType vectorType) { + int dimensions = vectorType.getDimensions(); + if (!hasDatabaseFeature(DatabaseFeature.VECTOR, isVersionAtLeast(23, 0)) || dimensions > MAX_VECTOR_DIMENSIONS + || (vectorType.isSparse() && !hasDatabaseFeature(DatabaseFeature.SPARSE_VECTOR, true))) { + return null; + } + VectorType.Element element = vectorType.getElement() == VectorType.Element.FLOAT16 + ? VectorType.Element.FLOAT32 : vectorType.getElement(); + if (element == VectorType.Element.BINARY && (vectorType.isSparse() || dimensions % 8 != 0)) { + return null; + } + return String.format("VECTOR(%s, %s%s)", dimensions > 0 ? String.valueOf(dimensions) : "*", element, + vectorType.isSparse() ? ", SPARSE" : ""); + } + + @Override + public Object getVectorValue(VectorType.Values values, VectorType vectorType) { + return vectorType.isSparse() ? values.toOracleSparseText() : values.toDenseText(); + } + + /** + * HNSW (in memory neighbor graph) if the database has vector memory, IVF (neighbor partitions) otherwise: + * an approximate search uses either. Sparse vectors have no index. + */ + @Override + public String getCreateVectorIndexStatement(String catalog, String schema, String tableName, String indexName, + String columnName, String columnTypeName, VectorType vectorType, + boolean nullable, VectorIndex vectorIndex) { + if (StringUtils.containsIgnoreCase(columnTypeName, "SPARSE")) { + return null; + } + // HNSW needs vector memory, it is not known for a script + boolean hnsw = vectorIndex.getKind() == VectorIndex.Kind.HNSW && hasDatabaseFeature( + DatabaseFeature.HNSW_INDEX, false); + if (!hnsw && !hasDatabaseFeature(DatabaseFeature.IVF_INDEX, true)) { + return null; + } + List parameters = new ArrayList<>(); + if (hnsw) { + addParameter(parameters, "NEIGHBORS", vectorIndex.getOption("m")); + addParameter(parameters, "EFCONSTRUCTION", vectorIndex.getOption("ef_construction")); + } else { + addParameter(parameters, "NEIGHBOR PARTITIONS", vectorIndex.getOption("lists")); + } + if (!parameters.isEmpty()) { + parameters.add(0, hnsw ? "TYPE HNSW" : "TYPE IVF"); + } + return String.format("CREATE VECTOR INDEX %s ON %s (%s) ORGANIZATION %s DISTANCE %s%s", + quoteIdentifier(indexName), getQualifiedTableName(catalog, schema, tableName), + getIdentifier(columnName), hnsw ? "INMEMORY NEIGHBOR GRAPH" : "NEIGHBOR PARTITIONS", + vectorIndex.getDistance(), parameters.isEmpty() ? "" : " PARAMETERS (" + String.join(", ", + parameters) + ")"); + } + + private static void addParameter(List parameters, String name, String value) { + if (NumberUtils.isDigits(value)) { + parameters.add(name + " " + value); + } + } + /** * Vectors of free queries are read as text, like FROM_VECTOR of the columns of a table export: the driver has * no default java type for them 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 5bdca78..d04de27 100644 --- a/src/main/java/com/github/sqljam/impexp/db/OracleMetaDataOperations.java +++ b/src/main/java/com/github/sqljam/impexp/db/OracleMetaDataOperations.java @@ -25,6 +25,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -35,6 +36,8 @@ import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import com.github.sqljam.impexp.MetaDataOperations; +import com.github.sqljam.impexp.VectorIndex; +import com.github.sqljam.impexp.VectorType; import com.github.sqljam.jdbc.JdbcUtils; import com.github.sqljam.utils.CaseInsensitiveMap; import lombok.extern.slf4j.Slf4j; @@ -262,6 +265,24 @@ private String getSubpartitions(Connection connection, String schemaName, String return " (" + StringUtils.join(definitions, ", ") + ")"; } + /** + * VECTOR(3,FLOAT32,DENSE) of VECTOR_INFO. A column of any format (VECTOR(*,*)) has no vector type, it keeps the + * vectors of any format for Oracle and as text for others. + */ + static VectorType getVectorType(String vectorInfo) { + String[] parts = StringUtils.defaultString(StringUtils.substringBetween(vectorInfo, "(", ")")).split(","); + if (parts.length < 2 || "*".equals(parts[1].trim())) { + return null; + } + try { + int dimensions = "*".equals(parts[0].trim()) ? 0 : Integer.parseInt(parts[0].trim()); + return new VectorType(VectorType.Element.valueOf(parts[1].trim().toUpperCase(Locale.ENGLISH)), dimensions, + parts.length > 2 && "SPARSE".equalsIgnoreCase(parts[2].trim())); + } catch (IllegalArgumentException e) { + return null; + } + } + private static String quote(String name) { return name.matches("[A-Z][A-Z0-9_$#]*") ? name : "\"" + name + "\""; } @@ -273,7 +294,10 @@ public List> getColumnInfos(DatabaseMetaData databaseMetaDat if (CollectionUtils.isEmpty(columnInfos)) { return columnInfos; } - String sql = "SELECT c.COLUMN_NAME, c.IDENTITY_COLUMN, c.VIRTUAL_COLUMN, m.COMMENTS FROM ALL_TAB_COLS c" + // Vectors of Oracle 23ai and later + String vectorInfo = databaseMetaData.getDatabaseMajorVersion() >= 23 ? ", c.VECTOR_INFO" : ""; + String sql = "SELECT c.COLUMN_NAME, c.IDENTITY_COLUMN, c.VIRTUAL_COLUMN, m.COMMENTS" + vectorInfo + + " FROM ALL_TAB_COLS c" + " LEFT JOIN ALL_COL_COMMENTS m ON m.OWNER = c.OWNER AND m.TABLE_NAME = c.TABLE_NAME" + " AND m.COLUMN_NAME = c.COLUMN_NAME WHERE c.OWNER = ? AND c.TABLE_NAME = ?"; List> columnDetails = JdbcUtils.fetchAll(databaseMetaData.getConnection(), sql, @@ -310,6 +334,10 @@ public List> getColumnInfos(DatabaseMetaData databaseMetaDat // 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); + VectorType vectorType = getVectorType((String) columnDetail.get("VECTOR_INFO")); + if (vectorType != null) { + columnInfo.put(VectorType.DETAIL_KEY, vectorType.toString()); + } } } return columnInfos; @@ -334,8 +362,86 @@ public List> getIndexInfos(DatabaseMetaData databaseMetaData excludedIndexNames.add(indexName); } } - return indexInfos.stream().filter(info -> !excludedIndexNames.contains((String) info.get("INDEX_NAME"))) - .collect(Collectors.toList()); + List> infos = indexInfos.stream().filter(info -> !excludedIndexNames.contains( + (String) info.get("INDEX_NAME"))).collect(Collectors.toList()); + if (databaseMetaData.getDatabaseMajorVersion() >= 23 && !infos.isEmpty()) { + applyVectorIndexes(databaseMetaData, schemaName, tableName, infos); + } + return infos; + } + + /** + * Vector indexes of Oracle 23ai: the kind by INDEX_SUBTYPE, the distance by V$VECTOR_INDEX and the neighbors of + * HNSW by GV$VECTOR_GRAPH_INDEX. Without access to the views (SELECT_CATALOG_ROLE) the default distance of + * Oracle (cosine) is taken. + */ + private void applyVectorIndexes(DatabaseMetaData databaseMetaData, String schemaName, String tableName, + List> indexInfos) throws SQLException { + Connection connection = databaseMetaData.getConnection(); + Map kinds = new HashMap<>(); + fetchQuietly(connection, "SELECT INDEX_NAME, INDEX_SUBTYPE FROM ALL_INDEXES WHERE OWNER = ? AND TABLE_NAME = '" + + tableName.replace("'", "''") + "' AND INDEX_TYPE = 'VECTOR'", schemaName).forEach((name, row) -> + kinds.put(name, StringUtils.defaultString((String) row.get("INDEX_SUBTYPE")))); + if (kinds.isEmpty()) { + return; + } + Map> distances = fetchQuietly(connection, "SELECT INDEX_NAME, DISTANCE_TYPE" + + " FROM V$VECTOR_INDEX WHERE OWNER = ?", schemaName); + Map> graphs = fetchQuietly(connection, "SELECT INDEX_NAME, NUM_NEIGHBORS," + + " EF_CONSTRUCTION FROM GV$VECTOR_GRAPH_INDEX WHERE OWNER = ?", schemaName); + for (Map indexInfo : indexInfos) { + String indexName = (String) indexInfo.get("INDEX_NAME"); + String kind = kinds.get(indexName); + if (kind == null) { + continue; + } + Map distance = distances.get(indexName); + VectorIndex.Distance distanceType = VectorIndex.Distance.COSINE; + if (distance != null && distance.get("DISTANCE_TYPE") != null) { + try { + distanceType = VectorIndex.Distance.valueOf(distance.get("DISTANCE_TYPE").toString().trim() + .toUpperCase(Locale.ENGLISH)); + } catch (IllegalArgumentException e) { + // Distances of other names, e.g. EUCLIDEAN_SQUARED, are taken as the default + log.info("Distance {} of vector index {} is taken as cosine", distance.get("DISTANCE_TYPE"), + indexName); + } + } else { + log.info("Distance of vector index {} is not readable (SELECT_CATALOG_ROLE), cosine is taken", + indexName); + } + Map options = new LinkedHashMap<>(); + Map graph = graphs.get(indexName); + if (graph != null) { + putOption(options, "m", graph.get("NUM_NEIGHBORS")); + putOption(options, "ef_construction", graph.get("EF_CONSTRUCTION")); + } + indexInfo.put(VectorIndex.DETAIL_KEY, new VectorIndex(kind.toUpperCase(Locale.ENGLISH).contains("IVF") + ? VectorIndex.Kind.IVF : VectorIndex.Kind.HNSW, distanceType, options).toString()); + } + } + + private static void putOption(Map options, String name, Object value) { + if (value != null) { + options.put(name, value.toString()); + } + } + + /** + * Rows of a view by their INDEX_NAME, none if the view is not granted + */ + private static Map> fetchQuietly(Connection connection, String sql, String owner) { + Map> rows = new HashMap<>(); + try { + for (Map row : JdbcUtils.fetchAll(connection, sql, new Object[]{owner})) { + rows.putIfAbsent((String) row.get("INDEX_NAME"), row); + } + } catch (SQLException e) { + if (log.isDebugEnabled()) { + log.debug("Vector indexes are not readable: {}", e.getMessage()); + } + } + return rows; } @Override 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 9f72746..0c986ad 100644 --- a/src/main/java/com/github/sqljam/impexp/db/PostgreSQLDialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/PostgreSQLDialect.java @@ -17,22 +17,34 @@ import java.sql.Connection; +import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.sql.Types; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; import org.postgresql.util.PGobject; +import com.github.sqljam.impexp.DatabaseFeature; 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.StringHelper; import com.github.sqljam.impexp.TypeNames; +import com.github.sqljam.impexp.VectorIndex; +import com.github.sqljam.impexp.VectorType; /** - * @Description: PostgreSQLDialect for PostgreSQL 12 and later versions + * @Description: PostgreSQLDialect for PostgreSQL 12 and later versions. + * PostgreSQL 9.x and later are supported, older versions by PostgreSQL9Dialect and PostgreSQL10Dialect. + * Vectors of pgvector, detected on the connection: vector and IVFFlat of pgvector 0.4, HNSW of 0.5.0, + * halfvec, sparsevec and the Manhattan distance of HNSW of 0.7.0. * @Author: Fred Feng * @Date: 24/03/2023 * @Version 1.0.0 @@ -267,6 +279,194 @@ public String getCreateIndexStatement(String catalog, String schema, String tabl return sql.replace("IF NOT EXISTS ", getIfNotExists()); } + /** + * Index of another access method (gin, gist, hnsw) with the operator classes of its columns and its options + */ + @Override + public String getCreateIndexStatement(String catalog, String schema, String tableName, boolean partition, + String[] columnNames, String indexName, boolean unique, String indexType, + String[] operatorClasses, String options) { + if (StringUtils.isBlank(indexType)) { + return getCreateIndexStatement(catalog, schema, tableName, partition, columnNames, indexName, unique, + indexType); + } + StringBuilder columns = new StringBuilder(); + for (int i = 0; i < columnNames.length; i++) { + String operatorClass = operatorClasses != null && i < operatorClasses.length ? operatorClasses[i] : null; + columns.append(i > 0 ? ", " : "").append(getIdentifier(columnNames[i])) + .append(StringUtils.isNotBlank(operatorClass) ? " " + operatorClass : ""); + } + return String.format("CREATE %sINDEX %s%s ON %s USING %s (%s)%s", unique ? "UNIQUE " : "", getIfNotExists(), + quoteIdentifier(indexName), getQualifiedTableName(catalog, schema, tableName), indexType, columns, + StringUtils.isNotBlank(options) ? " WITH (" + options + ")" : ""); + } + + // ------------------------------------------------------------------ + // Vectors of pgvector + // ------------------------------------------------------------------ + + /** + * Dimensions of vector and halfvec columns + */ + static final int MAX_VECTOR_DIMENSIONS = 16000; + /** + * Dimensions of an index of vector and of halfvec columns + */ + static final int MAX_VECTOR_INDEX_DIMENSIONS = 2000; + static final int MAX_HALFVEC_INDEX_DIMENSIONS = 4000; + + /** + * Types (vector, halfvec, sparsevec) and index methods (hnsw, ivfflat) of pgvector which the database has, + * the types are found by the search path. The queries do not fail for a missing type: a failed query aborts + * the transaction of PostgreSQL. + */ + @Override + protected void detectDatabaseFeatures(Connection connection, Set features) throws SQLException { + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery( + "SELECT to_regtype('vector') IS NOT NULL, to_regtype('halfvec') IS NOT NULL," + + " to_regtype('sparsevec') IS NOT NULL," + + " EXISTS (SELECT 1 FROM pg_am WHERE amname = 'hnsw')," + + " EXISTS (SELECT 1 FROM pg_am WHERE amname = 'ivfflat')")) { + if (rs.next()) { + DatabaseFeature[] columns = {DatabaseFeature.VECTOR, DatabaseFeature.HALF_VECTOR, + DatabaseFeature.SPARSE_VECTOR, DatabaseFeature.HNSW_INDEX, DatabaseFeature.IVF_INDEX}; + for (int i = 0; i < columns.length; i++) { + if (rs.getBoolean(i + 1)) { + features.add(columns[i]); + } + } + } + } + } + + /** + * The schema of pgvector is added to the search path if it is not in it, e.g. of a url with currentSchema: the + * types (vector) and operator classes (vector_cosine_ops) of pgvector are found by the search path + */ + @Override + public void prepareSession(Connection connection) { + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery( + "SELECT n.nspname, current_setting('search_path') FROM pg_extension e" + + " JOIN pg_namespace n ON n.oid = e.extnamespace WHERE e.extname = 'vector'" + + " AND NOT n.nspname = ANY (current_schemas(false))")) { + if (rs.next()) { + String path = StringUtils.trimToEmpty(rs.getString(2)); + String schema = "\"" + rs.getString(1).replace("\"", "\"\"") + "\""; + try (Statement set = connection.createStatement()) { + set.execute("SET search_path TO " + (path.isEmpty() ? schema : path + ", " + schema)); + } + } + } catch (SQLException e) { + // The search path is kept, vectors are found if pgvector is in it + } + } + + /** + * vector (32-bit floats, also of bytes), halfvec (16-bit floats) and sparsevec of pgvector. Doubles and bits + * are kept as text. + */ + @Override + public String getVectorTypeName(VectorType vectorType) { + if (vectorType.getDimensions() > MAX_VECTOR_DIMENSIONS || !vectorType.isFloat32Compatible()) { + return null; + } + String dimensions = vectorType.getDimensions() > 0 ? "(" + vectorType.getDimensions() + ")" : ""; + if (vectorType.isSparse()) { + // sparsevec of pgvector 0.7.0 and later + return hasDatabaseFeature(DatabaseFeature.SPARSE_VECTOR, true) ? "sparsevec" + dimensions : null; + } + if (vectorType.getElement() == VectorType.Element.FLOAT16 && hasDatabaseFeature(DatabaseFeature.HALF_VECTOR, + true)) { + return "halfvec" + dimensions; + } + // 16-bit floats are kept by vectors of 32-bit floats of older versions + return hasDatabaseFeature(DatabaseFeature.VECTOR, true) ? "vector" + dimensions : null; + } + + @Override + public Object getVectorValue(VectorType.Values values, VectorType vectorType) { + return vectorType.isSparse() ? values.toPostgreSqlSparseText() : values.toDenseText(); + } + + /** + * HNSW or IVFFlat index with the operator class of the distance, e.g. vector_cosine_ops. Columns of more + * dimensions than an index takes, and columns without dimensions, have no index. + */ + @Override + public String getCreateVectorIndexStatement(String catalog, String schema, String tableName, String indexName, + String columnName, String columnTypeName, VectorType vectorType, + boolean nullable, VectorIndex vectorIndex) { + String type = StringUtils.substringBefore(StringUtils.defaultString(columnTypeName), "(").trim() + .toLowerCase(Locale.ENGLISH); + int dimensions = NumberUtils.toInt(StringUtils.substringBetween(columnTypeName, "(", ")"), 0); + String operator = getVectorOperator(vectorIndex.getDistance()); + if (operator == null || dimensions <= 0 || ("vector".equals(type) && dimensions > MAX_VECTOR_INDEX_DIMENSIONS) + || ("halfvec".equals(type) && dimensions > MAX_HALFVEC_INDEX_DIMENSIONS)) { + return null; + } + // HNSW of pgvector 0.5.0 and later, IVFFlat has no sparse vectors: the other kind is taken if the database + // has not the kind of the index or not for the vectors + // IVFFlat has no Manhattan distance (l1) + boolean hnswAvailable = hasDatabaseFeature(DatabaseFeature.HNSW_INDEX, true); + boolean ivfAvailable = hasDatabaseFeature(DatabaseFeature.IVF_INDEX, true) && !"sparsevec".equals(type) + && vectorIndex.getDistance() != VectorIndex.Distance.MANHATTAN; + if (!hnswAvailable && !ivfAvailable) { + return null; + } + boolean ivf = ivfAvailable && (vectorIndex.getKind() == VectorIndex.Kind.IVF || !hnswAvailable); + List options = new ArrayList<>(); + for (String option : ivf ? new String[]{"lists"} : new String[]{"m", "ef_construction"}) { + String value = vectorIndex.getOption(option); + if (NumberUtils.isDigits(value)) { + options.add(option + " = " + value); + } + } + return String.format("CREATE INDEX %s%s ON %s USING %s (%s %s_%s_ops)%s", getIfNotExists(), + quoteIdentifier(indexName), getQualifiedTableName(catalog, schema, tableName), ivf ? "ivfflat" : "hnsw", + getIdentifier(columnName), type, operator, options.isEmpty() ? "" : " WITH (" + String.join(", ", + options) + ")"); + } + + /** + * Operator of a distance in the names of the operator classes, e.g. cosine of vector_cosine_ops + */ + static String getVectorOperator(VectorIndex.Distance distance) { + switch (distance) { + case EUCLIDEAN: + return "l2"; + case COSINE: + return "cosine"; + case DOT: + return "ip"; + case MANHATTAN: + return "l1"; + default: + // Hamming and Jaccard distances are of bit vectors + return null; + } + } + + /** + * Distance of an operator class of pgvector, e.g. vector_cosine_ops, null for other operator classes + */ + static VectorIndex.Distance getVectorDistance(String operatorClass) { + String name = StringUtils.defaultString(operatorClass).toLowerCase(Locale.ENGLISH); + if (name.endsWith("_l2_ops")) { + return VectorIndex.Distance.EUCLIDEAN; + } else if (name.endsWith("_cosine_ops")) { + return VectorIndex.Distance.COSINE; + } else if (name.endsWith("_ip_ops")) { + return VectorIndex.Distance.DOT; + } else if (name.endsWith("_l1_ops")) { + return VectorIndex.Distance.MANHATTAN; + } else if (name.endsWith("_hamming_ops")) { + return VectorIndex.Distance.HAMMING; + } else if (name.endsWith("_jaccard_ops")) { + return VectorIndex.Distance.JACCARD; + } + return null; + } + @Override public String getCreateCommentStatement(String catalog, String schema, String tableName, String columnName, String comment) { 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 b5899ea..20caa40 100644 --- a/src/main/java/com/github/sqljam/impexp/db/PostgreSQLMetaDataOperations.java +++ b/src/main/java/com/github/sqljam/impexp/db/PostgreSQLMetaDataOperations.java @@ -19,6 +19,7 @@ import java.sql.SQLException; import java.sql.Types; import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -30,10 +31,15 @@ import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; import com.github.sqljam.impexp.ImpExpException; +import com.github.sqljam.impexp.IndexMetaData; import com.github.sqljam.impexp.MetaDataOperations; +import com.github.sqljam.impexp.VectorIndex; +import com.github.sqljam.impexp.VectorType; import com.github.sqljam.jdbc.JdbcUtils; import com.github.sqljam.utils.MapUtils; +import lombok.extern.slf4j.Slf4j; /** * @Description: PostgreSQLMetaDataOperations reads partitions, comments and sequences of PostgreSQL @@ -41,6 +47,7 @@ * @Date: 29/03/2023 * @Version 1.0.0 */ +@Slf4j public class PostgreSQLMetaDataOperations extends MetaDataOperations { private static final String PARTITION_SQL = "SELECT c.relname, c.relkind, c.relispartition," @@ -128,8 +135,10 @@ public List> getColumnInfos(DatabaseMetaData databaseMetaDat if (CollectionUtils.isEmpty(columnInfos)) { throw new ImpExpException("No columns found in table: " + tableName); } - String sql = "SELECT a.attname, b.description FROM pg_attribute a" + String sql = "SELECT a.attname, b.description, t.typname, format_type(a.atttypid, a.atttypmod) AS column_type" + + " FROM pg_attribute a" + " JOIN pg_class c ON a.attrelid = c.oid JOIN pg_namespace n ON n.oid = c.relnamespace" + + " JOIN pg_type t ON t.oid = a.atttypid" + " LEFT OUTER JOIN pg_description b ON a.attrelid = b.objoid AND a.attnum = b.objsubid" + " WHERE n.nspname = ? AND c.relname = ? AND a.attnum > 0 AND NOT a.attisdropped"; List> columnDetails = JdbcUtils.fetchAll(databaseMetaData.getConnection(), sql, @@ -146,6 +155,12 @@ public List> getColumnInfos(DatabaseMetaData databaseMetaDat if (partitionTable) { columnInfo.put("TABLE_NAME", tableName); } + Map columnDetail = mutableColumnInfos.get(columnInfo.get("COLUMN_NAME")); + VectorType vectorType = columnDetail != null ? getVectorType((String) columnDetail.get("typname"), + (String) columnDetail.get("column_type")) : null; + if (vectorType != null) { + columnInfo.put(VectorType.DETAIL_KEY, vectorType.toString()); + } if ("YES".equalsIgnoreCase((String) columnInfo.get("IS_GENERATEDCOLUMN"))) { // Generation expression is reported as default value columnInfo.put("GENERATION_EXPRESSION", columnInfo.get("COLUMN_DEF")); @@ -274,9 +289,105 @@ static void applyBaseType(Map columnInfo, String baseType) { columnInfo.put("DECIMAL_DIGITS", scale); } + /** + * Vectors of a result set have no dimensions there, e.g. of an expression + */ + @Override + public void normalizeQueryColumnInfo(Map columnInfo) { + VectorType vectorType = getVectorType((String) columnInfo.get("TYPE_NAME"), null); + if (vectorType != null) { + columnInfo.put(VectorType.DETAIL_KEY, vectorType.toString()); + } + } + + /** + * Vector type of a column of pgvector: vector(1024), halfvec(1024), sparsevec(30000), null for other types + */ + static VectorType getVectorType(String typeName, String columnType) { + String name = StringUtils.defaultString(typeName).toLowerCase(Locale.ENGLISH); + int dimensions = NumberUtils.toInt(StringUtils.substringBetween(columnType, "(", ")"), 0); + switch (name) { + case "vector": + return VectorType.dense(VectorType.Element.FLOAT32, dimensions); + case "halfvec": + return VectorType.dense(VectorType.Element.FLOAT16, dimensions); + case "sparsevec": + return new VectorType(VectorType.Element.FLOAT32, dimensions, true); + default: + return null; + } + } + + /** + * Indexes with their access methods, operator classes and options: other methods than btree (gin, gist, hnsw, + * ivfflat) are created alike by PostgreSQL targets, hnsw and ivfflat of pgvector are vector indexes of others + */ @Override public List> getIndexInfos(DatabaseMetaData databaseMetaData, String catalogName, String schemaName, String tableName) throws SQLException { + List> indexInfos = getIndexInfosOfTable(databaseMetaData, catalogName, schemaName, + tableName); + if (CollectionUtils.isEmpty(indexInfos)) { + return indexInfos; + } + String sql = "SELECT ic.relname AS index_name, am.amname, array_to_string(ic.reloptions, ', ') AS options," + + " a.attname, oc.opcname, oc.opcdefault" + + " FROM pg_index x JOIN pg_class ic ON ic.oid = x.indexrelid JOIN pg_class t ON t.oid = x.indrelid" + + " JOIN pg_namespace n ON n.oid = t.relnamespace JOIN pg_am am ON am.oid = ic.relam" + + " CROSS JOIN LATERAL unnest(x.indkey::int2[], x.indclass::oid[]) AS k(attnum, opclass)" + + " JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum" + + " LEFT JOIN pg_opclass oc ON oc.oid = k.opclass" + + " WHERE n.nspname = ? AND t.relname = ?"; + Map> methods = new HashMap<>(); + String table = (String) indexInfos.get(0).get("TABLE_NAME"); + try { + for (Map row : JdbcUtils.fetchAll(databaseMetaData.getConnection(), sql, + new Object[]{schemaName, StringUtils.defaultIfBlank(table, tableName)})) { + methods.put(row.get("index_name") + "." + row.get("attname"), row); + } + } catch (SQLException e) { + // Indexes are created as ordinary ones, vector columns have none then + log.warn("Access methods of the indexes of {}.{} are not readable: {}", schemaName, tableName, + e.getMessage()); + return indexInfos; + } + for (Map indexInfo : indexInfos) { + Map method = methods.get(indexInfo.get("INDEX_NAME") + "." + indexInfo.get("COLUMN_NAME")); + if (method != null) { + applyIndexMethod(indexInfo, method); + } + } + return indexInfos; + } + + /** + * Access method, operator class (null for the default one) and options of an index column, and the vector + * index of hnsw and ivfflat + */ + static void applyIndexMethod(Map indexInfo, Map method) { + String amName = (String) method.get("amname"); + String operatorClass = (String) method.get("opcname"); + String options = StringUtils.trimToNull((String) method.get("options")); + indexInfo.put(IndexMetaData.INDEX_METHOD, amName); + indexInfo.put(IndexMetaData.OPERATOR_CLASS, Boolean.TRUE.equals(method.get("opcdefault")) ? null + : operatorClass); + indexInfo.put(IndexMetaData.INDEX_OPTIONS, options); + VectorIndex.Distance distance = PostgreSQLDialect.getVectorDistance(operatorClass); + if (distance != null && ("hnsw".equals(amName) || "ivfflat".equals(amName))) { + Map vectorOptions = new LinkedHashMap<>(); + for (String option : StringUtils.defaultString(options).split(",")) { + String[] pair = option.split("=", 2); + if (pair.length == 2) { + vectorOptions.put(pair[0].trim().toLowerCase(Locale.ENGLISH), pair[1].trim()); + } + } + indexInfo.put(VectorIndex.DETAIL_KEY, new VectorIndex("hnsw".equals(amName) ? VectorIndex.Kind.HNSW + : VectorIndex.Kind.IVF, distance, vectorOptions).toString()); + } + } + + private List> getIndexInfosOfTable(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName, String tableName) throws SQLException { List> indexInfos = super.getIndexInfos(databaseMetaData, catalogName, schemaName, tableName); if (CollectionUtils.isNotEmpty(indexInfos)) { return indexInfos; diff --git a/src/main/java/com/github/sqljam/impexp/db/SQLServerDialect.java b/src/main/java/com/github/sqljam/impexp/db/SQLServerDialect.java index 934ccd8..1cc992c 100644 --- a/src/main/java/com/github/sqljam/impexp/db/SQLServerDialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/SQLServerDialect.java @@ -15,6 +15,10 @@ */ package com.github.sqljam.impexp.db; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.sql.Connection; +import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Types; import java.time.LocalDate; @@ -22,15 +26,21 @@ import java.time.LocalTime; import java.util.Arrays; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.impexp.DatabaseFeature; import com.github.sqljam.impexp.DbType; import com.github.sqljam.impexp.Dialect; import com.github.sqljam.impexp.StringHelper; +import com.github.sqljam.impexp.VectorType; /** - * @Description: SQLServerDialect for SQL Server 2012 and later versions + * @Description: SQLServerDialect for SQL Server 2012 and later versions. + * SQL Server 2008 and later are supported, SQL Server 2008 and 2008 R2 by SQLServer2008Dialect. + * VECTOR of SQL Server 2025 (17.x), of 16-bit floats too, detected on the connection. Vector indexes + * are a preview feature of SQL Server 2025 which makes a table read only, they are not created. * @Author: Fred Feng * @Date: 26/03/2023 * @Version 1.0.0 @@ -546,4 +556,93 @@ public boolean isAlreadyExistsError(SQLException e) { int code = e.getErrorCode(); return code == 2714 || code == 1913 || code == 1779 || code == 1781 || super.isAlreadyExistsError(e); } + + // ------------------------------------------------------------------ + // Vectors of SQL Server 2025 + // ------------------------------------------------------------------ + + static final int MAX_VECTOR_DIMENSIONS = 1998; + static final int MAX_HALF_VECTOR_DIMENSIONS = 3996; + /** + * Header of the binary of a vector: 0xA9, version, dimensions (2 bytes), base type, 3 reserved bytes + */ + static final int VECTOR_HEADER_SIZE = 8; + + /** + * VECTOR(n) of 32-bit floats (also of bytes) and VECTOR(n, float16) of SQL Server 2025, dense and of fixed + * dimensions + */ + @Override + public String getVectorTypeName(VectorType vectorType) { + int dimensions = vectorType.getDimensions(); + if (!hasDatabaseFeature(DatabaseFeature.VECTOR, isVersionAtLeast(17, 0)) || vectorType.isSparse() + || !vectorType.isFloat32Compatible() || dimensions < 1) { + return null; + } + if (vectorType.getElement() == VectorType.Element.FLOAT16 && hasDatabaseFeature(DatabaseFeature.HALF_VECTOR, + isVersionAtLeast(17, 0)) && dimensions <= MAX_HALF_VECTOR_DIMENSIONS) { + return "VECTOR(" + dimensions + ", float16)"; + } + // 16-bit floats are kept by 32-bit floats + return dimensions <= MAX_VECTOR_DIMENSIONS ? "VECTOR(" + dimensions + ")" : null; + } + + /** + * VECTOR of SQL Server 2025, of 16-bit floats too + */ + @Override + protected void detectDatabaseFeatures(Connection connection, Set features) throws SQLException { + if (probe(connection, "SELECT CAST(CAST('[1]' AS VECTOR(1)) AS NVARCHAR(MAX))")) { + features.add(DatabaseFeature.VECTOR); + if (probe(connection, "SELECT CAST(CAST('[1]' AS VECTOR(1, float16)) AS NVARCHAR(MAX))")) { + features.add(DatabaseFeature.HALF_VECTOR); + } + } + } + + /** + * Vectors are read as text, the driver reads them as binary + */ + @Override + public String getSelectColumnExpression(String columnName, String typeName) { + if ("vector".equalsIgnoreCase(StringUtils.trim(typeName))) { + String column = quoteIdentifier(columnName); + return String.format("CAST(%s AS NVARCHAR(MAX)) AS %s", column, column); + } + return super.getSelectColumnExpression(columnName, typeName); + } + + /** + * Vectors of free queries are binary: the header and little endian floats, they are read as text like the + * vectors of a table export + */ + @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 byte[] && "vector".equalsIgnoreCase(columnTypeName)) { + return toVectorText((byte[]) value); + } + return value; + } + + /** + * Text of the binary of a vector of 32-bit floats, e.g. [1.0,2.5,-3.0], the bytes as they are if they are not + * a vector + */ + static Object toVectorText(byte[] bytes) { + if (bytes.length < VECTOR_HEADER_SIZE || (bytes[0] & 0xFF) != 0xA9 || bytes[4] != 0) { + return bytes; + } + ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + int dimensions = buffer.getShort(2) & 0xFFFF; + if (bytes.length != VECTOR_HEADER_SIZE + dimensions * Float.BYTES) { + return bytes; + } + StringBuilder text = new StringBuilder("["); + for (int i = 0; i < dimensions; i++) { + text.append(i > 0 ? "," : "").append(buffer.getFloat(VECTOR_HEADER_SIZE + i * Float.BYTES)); + } + return text.append(']').toString(); + } } diff --git a/src/main/java/com/github/sqljam/impexp/db/SQLServerMetaDataOperations.java b/src/main/java/com/github/sqljam/impexp/db/SQLServerMetaDataOperations.java index 8ae9b45..e925c61 100644 --- a/src/main/java/com/github/sqljam/impexp/db/SQLServerMetaDataOperations.java +++ b/src/main/java/com/github/sqljam/impexp/db/SQLServerMetaDataOperations.java @@ -18,6 +18,7 @@ import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.SQLException; +import java.sql.Types; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -27,7 +28,10 @@ import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import com.github.sqljam.impexp.MetaDataOperations; +import com.github.sqljam.impexp.TableMetaData; +import com.github.sqljam.impexp.VectorType; import com.github.sqljam.jdbc.JdbcUtils; +import lombok.extern.slf4j.Slf4j; /** * @Description: SQLServerMetaDataOperations reads comments, computed/rowversion columns, partitions and sequences of SQL Server @@ -35,6 +39,7 @@ * @Date: 26/03/2023 * @Version 1.0.0 */ +@Slf4j public class SQLServerMetaDataOperations extends MetaDataOperations { private static final String[] SYSTEM_SCHEMAS = {"sys", "INFORMATION_SCHEMA", "guest", "db_owner", @@ -192,11 +197,20 @@ public List> getColumnInfos(DatabaseMetaData databaseMetaDat Map> computedColumns = new HashMap<>(); JdbcUtils.fetchAll(databaseMetaData.getConnection(), sql, new Object[]{schemaName, tableName}) .forEach(info -> computedColumns.put((String) info.get("column_name"), info)); + Map vectorTypes = getVectorTypes(databaseMetaData, catalogName, schemaName, tableName, + columnInfos); for (Map columnInfo : columnInfos) { String columnName = (String) columnInfo.get("COLUMN_NAME"); if (StringUtils.isBlank((String) columnInfo.get("REMARKS")) && comments.containsKey(columnName)) { columnInfo.put("REMARKS", comments.get(columnName)); } + VectorType vectorType = vectorTypes.get(columnName); + if (vectorType != null) { + // Vectors are text of other databases, e.g. [1.0000000e+000,2.5000000e+000] + columnInfo.put("DATA_TYPE", Types.LONGNVARCHAR); + columnInfo.put("COLUMN_SIZE", Integer.MAX_VALUE); + columnInfo.put(VectorType.DETAIL_KEY, vectorType.toString()); + } Map computedColumn = computedColumns.get(columnName); String typeName = StringUtils.defaultString((String) columnInfo.get("TYPE_NAME")).toLowerCase(Locale.ENGLISH); if (computedColumn != null) { @@ -213,6 +227,39 @@ public List> getColumnInfos(DatabaseMetaData databaseMetaDat return columnInfos; } + /** + * Dimensions and base types (float32, float16) of the VECTOR columns of SQL Server 2025 + */ + private Map getVectorTypes(DatabaseMetaData databaseMetaData, String catalogName, + String schemaName, String tableName, + List> columnInfos) throws SQLException { + Map vectorTypes = new HashMap<>(); + if (columnInfos.stream().noneMatch(info -> "vector".equalsIgnoreCase((String) info.get("TYPE_NAME")))) { + return vectorTypes; + } + String sql = String.format("SELECT c.name AS column_name, c.vector_dimensions, c.vector_base_type_desc" + + " FROM %1$ssys.columns c JOIN %1$ssys.tables t ON t.object_id = c.object_id" + + " JOIN %1$ssys.schemas s ON s.schema_id = t.schema_id" + + " WHERE s.name = ? AND t.name = ? AND c.vector_dimensions IS NOT NULL", catalogPrefix(catalogName)); + try { + for (Map row : JdbcUtils.fetchAll(databaseMetaData.getConnection(), sql, + new Object[]{schemaName, tableName})) { + Object dimensions = row.get("vector_dimensions"); + vectorTypes.put((String) row.get("column_name"), VectorType.dense("float16".equalsIgnoreCase( + (String) row.get("vector_base_type_desc")) ? VectorType.Element.FLOAT16 + : VectorType.Element.FLOAT32, dimensions instanceof Number ? ((Number) dimensions).intValue() + : 0)); + } + } catch (SQLException e) { + // Dimensions are not known, the vectors are text of other databases + log.warn("Vector columns of {}.{} are not readable: {}", schemaName, tableName, e.getMessage()); + columnInfos.stream().filter(info -> "vector".equalsIgnoreCase((String) info.get("TYPE_NAME"))) + .forEach(info -> vectorTypes.put((String) info.get("COLUMN_NAME"), VectorType.dense( + VectorType.Element.FLOAT32, 0))); + } + return vectorTypes; + } + /** * Sequences are supported since SQL Server 2012 */ @@ -245,5 +292,12 @@ public void normalizeQueryColumnInfo(Map columnInfo) { if (typeName.equals("timestamp") || typeName.equals("rowversion")) { columnInfo.put("IS_ROWVERSION", true); } + if (typeName.equals("vector")) { + // Precision of a vector of a result set is its dimensions, see SQLServerDialect.getColumnValue + columnInfo.put(VectorType.DETAIL_KEY, VectorType.dense(VectorType.Element.FLOAT32, TableMetaData.getInt( + columnInfo, "COLUMN_SIZE")).toString()); + columnInfo.put("DATA_TYPE", Types.LONGNVARCHAR); + columnInfo.put("COLUMN_SIZE", Integer.MAX_VALUE); + } } } 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 3e35248..699f945 100644 --- a/src/main/java/com/github/sqljam/impexp/db/SQLiteDialect.java +++ b/src/main/java/com/github/sqljam/impexp/db/SQLiteDialect.java @@ -27,7 +27,8 @@ import com.github.sqljam.impexp.StringHelper; /** - * @Description: SQLiteDialect for SQLite 3 + * @Description: SQLiteDialect for SQLite 3. + * SQLite 3 is supported, it has no vectors. * @Author: Fred Feng * @Date: 26/03/2023 * @Version 1.0.0 diff --git a/src/main/java/com/github/sqljam/jdbc/JdbcUtils.java b/src/main/java/com/github/sqljam/jdbc/JdbcUtils.java index 23956de..f3059df 100644 --- a/src/main/java/com/github/sqljam/jdbc/JdbcUtils.java +++ b/src/main/java/com/github/sqljam/jdbc/JdbcUtils.java @@ -655,7 +655,9 @@ public Cursor> getIndexInfos(DatabaseMetaData databaseMetaDa ResultSet rs = null; DisposableObservable ob = new DisposableObservable(); try { - rs = databaseMetaData.getIndexInfo(catalog, schema, tableName, false, false); + // Approximate statistics: the driver of Oracle analyzes the table for exact ones, which a user who may + // only read the table can not, and which takes long for a large table + rs = databaseMetaData.getIndexInfo(catalog, schema, tableName, false, true); return new MapCursor(rs, ob, false); } finally { closeLazily(ob, rs, null, null); @@ -759,11 +761,31 @@ public static void setPath(Connection connection, String catalog, String schema) } if (StringUtils.isNotBlank(schema)) { if (!StringUtils.equals(connection.getSchema(), schema)) { - connection.setSchema(schema); + setSchema(connection, schema); } } } + /** + * Sets the schema of the connection. The schema of PostgreSQL is put in front of its search path, the other + * schemas are kept: types and operators of extensions (pgvector) are found in theirs, e.g. public. + */ + public static void setSchema(Connection connection, String schema) throws SQLException { + if (!"PostgreSQL".equalsIgnoreCase(connection.getMetaData().getDatabaseProductName())) { + connection.setSchema(schema); + return; + } + String path; + try (Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SELECT current_setting('search_path')")) { + path = rs.next() ? StringUtils.trimToEmpty(rs.getString(1)) : ""; + } + String quoted = "\"" + schema.replace("\"", "\"\"") + "\""; + try (Statement statement = connection.createStatement()) { + statement.execute("SET search_path TO " + quoted + (path.isEmpty() ? "" : ", " + path)); + } + } + /** * @Description: MapCursor * @Author: Fred Feng diff --git a/src/main/java/com/github/sqljam/jdbc/page/MapBasedPageReader.java b/src/main/java/com/github/sqljam/page/MapBasedPageReader.java similarity index 97% rename from src/main/java/com/github/sqljam/jdbc/page/MapBasedPageReader.java rename to src/main/java/com/github/sqljam/page/MapBasedPageReader.java index 24d0e27..273f70f 100644 --- a/src/main/java/com/github/sqljam/jdbc/page/MapBasedPageReader.java +++ b/src/main/java/com/github/sqljam/page/MapBasedPageReader.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.github.sqljam.jdbc.page; +package com.github.sqljam.page; import java.sql.Connection; import java.sql.PreparedStatement; @@ -30,9 +30,6 @@ import com.github.sqljam.impexp.PooledConnectionFactory; import com.github.sqljam.jdbc.ConnectionFactory; import com.github.sqljam.jdbc.JdbcUtils; -import com.github.sqljam.page.DefaultPageContent; -import com.github.sqljam.page.PageContent; -import com.github.sqljam.page.PageReader; import com.github.sqljam.utils.CaseInsensitiveMap; /** diff --git a/src/main/java/com/github/sqljam/utils/FloatUtils.java b/src/main/java/com/github/sqljam/utils/FloatUtils.java new file mode 100644 index 0000000..59bb762 --- /dev/null +++ b/src/main/java/com/github/sqljam/utils/FloatUtils.java @@ -0,0 +1,97 @@ +/* + * 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.utils; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import lombok.experimental.UtilityClass; + +/** + * @Description: FloatUtils converts floats of 16 bits to floats of 32 bits: IEEE 754 half precision (float16) and + * bfloat16, the upper 16 bits of a float, both of the binaries of vectors in little endian order + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@UtilityClass +public class FloatUtils { + + /** + * Float of the bits of a half precision float, subnormal numbers, infinities and NaN included + */ + public float halfToFloat(int bits) { + int sign = (bits & 0x8000) << 16; + int exponent = (bits >>> 10) & 0x1F; + int mantissa = bits & 0x3FF; + if (exponent == 0x1F) { + // Infinity or NaN + return Float.intBitsToFloat(sign | 0x7F800000 | (mantissa << 13)); + } + if (exponent == 0) { + if (mantissa == 0) { + return Float.intBitsToFloat(sign); + } + // Subnormal: mantissa / 2^24 + float value = mantissa / 16777216f; + return sign != 0 ? -value : value; + } + return Float.intBitsToFloat(sign | ((exponent + 112) << 23) | (mantissa << 13)); + } + + /** + * Float of the bits of a bfloat16 + */ + public float bfloat16ToFloat(int bits) { + return Float.intBitsToFloat((bits & 0xFFFF) << 16); + } + + /** + * Floats of little endian half precision floats + */ + public float[] halvesToFloats(byte[] bytes) { + return toFloats(bytes, false); + } + + /** + * Floats of little endian bfloat16 floats + */ + public float[] bfloat16sToFloats(byte[] bytes) { + return toFloats(bytes, true); + } + + private float[] toFloats(byte[] bytes, boolean bfloat16) { + ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + float[] floats = new float[bytes.length / 2]; + for (int i = 0; i < floats.length; i++) { + int bits = buffer.getShort(i * 2) & 0xFFFF; + floats[i] = bfloat16 ? bfloat16ToFloat(bits) : halfToFloat(bits); + } + return floats; + } + + /** + * Floats of little endian 32-bit floats + */ + public float[] littleEndianToFloats(byte[] bytes) { + ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + float[] floats = new float[bytes.length / Float.BYTES]; + for (int i = 0; i < floats.length; i++) { + floats[i] = buffer.getFloat(i * Float.BYTES); + } + return floats; + } +} diff --git a/src/main/java/com/github/sqljam/utils/RestClient.java b/src/main/java/com/github/sqljam/utils/RestClient.java new file mode 100644 index 0000000..09f0169 --- /dev/null +++ b/src/main/java/com/github/sqljam/utils/RestClient.java @@ -0,0 +1,224 @@ +/* + * 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.utils; + +import java.io.IOException; +import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509ExtendedTrustManager; + +import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.NullNode; +import lombok.extern.slf4j.Slf4j; + +/** + * @Description: RestClient sends JSON requests to the REST API of a vector database by the HTTP client of the JDK, + * with the headers of its credentials. An error status of the server is a RestException with the + * status and the message of the server, a server which can not be reached is a RestException of + * status 0. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Slf4j +public class RestClient { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + /** + * Characters of a response kept in the message of an error + */ + private static final int MAX_ERROR_LENGTH = 500; + + private final String baseUrl; + private final Map headers = new LinkedHashMap<>(); + private String accessDeniedHint; + private final HttpClient httpClient; + private final Duration timeout; + + public RestClient(String baseUrl, boolean certificateTrusted, int timeoutSeconds) { + this.baseUrl = StringUtils.removeEnd(StringUtils.trimToEmpty(baseUrl), "/"); + this.timeout = Duration.ofSeconds(Math.max(timeoutSeconds, 1)); + HttpClient.Builder builder = HttpClient.newBuilder().connectTimeout(this.timeout) + .version(HttpClient.Version.HTTP_1_1); + if (certificateTrusted) { + builder.sslContext(trustingContext()); + } + this.httpClient = builder.build(); + } + + public static ObjectMapper getMapper() { + return MAPPER; + } + + public String getBaseUrl() { + return baseUrl; + } + + /** + * Hint of an answer 401 or 403 in the message of its RestException, e.g. whether credentials are given + */ + public RestClient accessDeniedHint(String hint) { + this.accessDeniedHint = hint; + return this; + } + + public String getAccessDeniedHint() { + return accessDeniedHint; + } + + public RestClient header(String name, String value) { + if (StringUtils.isNotBlank(value)) { + headers.put(name, value); + } + return this; + } + + public JsonNode get(String path) { + return send("GET", path, null); + } + + public JsonNode post(String path, Object body) { + return send("POST", path, body); + } + + public JsonNode put(String path, Object body) { + return send("PUT", path, body); + } + + public JsonNode delete(String path) { + return send("DELETE", path, null); + } + + /** + * Sends a request, a path is relative to the base url, an absolute url is sent as it is (hosts of Pinecone + * indexes). The body is a JSON node, a map or a list, or text which is sent as it is (NDJSON). + * + * @return the JSON of the response, NullNode for an empty response + */ + public JsonNode send(String method, String path, Object body) { + String url = path.startsWith("http://") || path.startsWith("https://") ? path : baseUrl + path; + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url)).timeout(timeout) + .header("Accept", "application/json"); + headers.forEach(builder::header); + if (body != null) { + String text; + try { + text = body instanceof String ? (String) body : MAPPER.writeValueAsString(body); + } catch (IOException e) { + throw new RestException("Unable to write the request: " + e.getMessage(), 0, e); + } + builder.header("Content-Type", "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(text, StandardCharsets.UTF_8)); + } else { + builder.method(method, HttpRequest.BodyPublishers.noBody()); + } + HttpResponse response; + try { + response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new RestException(String.format("Unable to reach %s: %s", baseUrl, StringUtils.defaultIfBlank( + e.getMessage(), e.getClass().getSimpleName())), 0, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RestException("Interrupted: " + method + " " + path, 0, e); + } + String text = response.body(); + if (response.statusCode() >= 400) { + boolean denied = response.statusCode() == 401 || response.statusCode() == 403; + throw new RestException(String.format("%s %s: HTTP %d %s%s", method, path, response.statusCode(), + StringUtils.abbreviate(StringUtils.defaultString(text).trim(), MAX_ERROR_LENGTH), + denied && accessDeniedHint != null ? " (" + accessDeniedHint + ")" : ""), + response.statusCode(), null); + } + if (StringUtils.isBlank(text)) { + return NullNode.getInstance(); + } + try { + return MAPPER.readTree(text); + } catch (IOException e) { + // A response which is not JSON, e.g. the text of a health check + if (log.isDebugEnabled()) { + log.debug("Response of {} {} is not JSON: {}", method, path, StringUtils.abbreviate(text, 200)); + } + return MAPPER.getNodeFactory().textNode(text); + } + } + + /** + * SSL context which trusts every certificate, for servers of self-signed certificates the user trusts + */ + private static SSLContext trustingContext() { + // An extended trust manager: the JDK verifies host names for other trust managers + TrustManager[] trustManagers = {new X509ExtendedTrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + // Trusted by the user + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + // Trusted by the user + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) { + // Trusted by the user + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) { + // Trusted by the user + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) { + // Trusted by the user + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) { + // Trusted by the user + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }}; + try { + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, trustManagers, new java.security.SecureRandom()); + return context; + } catch (GeneralSecurityException e) { + throw new RestException("Unable to trust the certificate: " + e.getMessage(), 0, e); + } + } +} diff --git a/src/main/java/com/github/sqljam/utils/RestException.java b/src/main/java/com/github/sqljam/utils/RestException.java new file mode 100644 index 0000000..b811ccf --- /dev/null +++ b/src/main/java/com/github/sqljam/utils/RestException.java @@ -0,0 +1,46 @@ +/* + * 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.utils; + +/** + * @Description: RestException is an error of a REST API with its HTTP status, 0 if the server was not reached, e.g. + * 401 of a wrong key, 403 of a key which may not read + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class RestException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final int status; + + public RestException(String message, int status, Throwable cause) { + super(message, cause); + this.status = status; + } + + public int getStatus() { + return status; + } + + /** + * Whether the credentials were refused or may not read: 401 and 403 + */ + public boolean isAccessDenied() { + return status == 401 || status == 403; + } +} diff --git a/src/main/java/com/github/sqljam/utils/SshSettings.java b/src/main/java/com/github/sqljam/utils/SshSettings.java new file mode 100644 index 0000000..0058127 --- /dev/null +++ b/src/main/java/com/github/sqljam/utils/SshSettings.java @@ -0,0 +1,95 @@ +/* + * 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.utils; + +import java.util.Objects; + +import org.apache.commons.lang3.StringUtils; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * @Description: SshSettings is the SSH server through which a data source is reached: its host, port and user, and + * a password or a private key (with its passphrase). Without both the server decides, e.g. Tailscale SSH + * authorizes the machines of its network. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Getter +@Setter +@ToString(exclude = {"password", "passphrase"}) +public class SshSettings { + + public static final int DEFAULT_PORT = 22; + + public enum Authentication { + PASSWORD, KEY + } + + private boolean enabled; + private String host; + private int port = DEFAULT_PORT; + private String username; + private Authentication authentication = Authentication.PASSWORD; + private String password; + /** + * Private key file, e.g. ~/.ssh/id_ed25519 + */ + private String privateKeyFile; + private String passphrase; + + public int getEffectivePort() { + return port > 0 ? port : DEFAULT_PORT; + } + + /** + * The secret of the authentication, the password or the passphrase of the key + */ + public String getSecret() { + return authentication == Authentication.KEY ? passphrase : password; + } + + public void setSecret(String secret) { + if (authentication == Authentication.KEY) { + passphrase = secret; + } else { + password = secret; + } + } + + /** + * Key of a tunnel of these settings, a tunnel is opened again when they change + */ + public String getKey() { + return String.join("|", host, String.valueOf(getEffectivePort()), username, String.valueOf(authentication), + StringUtils.defaultString(privateKeyFile), String.valueOf(Objects.hashCode(getSecret()))); + } + + public SshSettings copy() { + SshSettings copy = new SshSettings(); + copy.setEnabled(enabled); + copy.setHost(host); + copy.setPort(port); + copy.setUsername(username); + copy.setAuthentication(authentication); + copy.setPassword(password); + copy.setPrivateKeyFile(privateKeyFile); + copy.setPassphrase(passphrase); + return copy; + } +} diff --git a/src/main/java/com/github/sqljam/utils/SshUtils.java b/src/main/java/com/github/sqljam/utils/SshUtils.java new file mode 100644 index 0000000..6f2f966 --- /dev/null +++ b/src/main/java/com/github/sqljam/utils/SshUtils.java @@ -0,0 +1,329 @@ +/* + * 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.utils; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.SocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.PublicKey; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.commons.lang3.StringUtils; +import org.apache.sshd.client.SshClient; +import org.apache.sshd.client.channel.ChannelDirectTcpip; +import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.client.session.forward.ExplicitPortForwardingTracker; +import org.apache.sshd.common.config.keys.FilePasswordProvider; +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.keyprovider.FileKeyPairProvider; +import org.apache.sshd.common.session.helpers.AbstractConnectionService; +import org.apache.sshd.common.util.net.SshdSocketAddress; +import org.apache.sshd.core.CoreModuleProperties; +import lombok.extern.slf4j.Slf4j; +import com.github.sqljam.config.Config; + +/** + * @Description: SshUtils forwards a local port through an SSH server to the host and port of a data source, like + * the SSH tunnels of DBeaver and DataGrip. A tunnel is opened for a data source when it is first + * connected, shared by its connections and opened again when its settings change or its session ends. + * Host keys are trusted on first use and kept in a known hosts file, a changed host key is refused. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Slf4j +public final class SshUtils { + + static final Duration TIMEOUT = Duration.ofSeconds(15); + static final Duration HEARTBEAT = Duration.ofSeconds(30); + /** + * Time which a closing tunnel gives the data on its way, e.g. the quit of a closed connection + */ + static final Duration CLOSE_TIMEOUT = Duration.ofSeconds(2); + public static final String LOCALHOST_ADDRESS = "127.0.0.1"; + + private static final Map TUNNELS = new ConcurrentHashMap<>(); + private static SshClient client; + /** + * Known hosts file of sqljam.ssh.known-hosts.file, read when the first tunnel is opened + */ + private static Path knownHostsFile; + + static { + Runtime.getRuntime().addShutdownHook(new Thread(SshUtils::closeAll, "sqljam-ssh-tunnels")); + } + + private SshUtils() { + } + + /** + * An open tunnel: its session and the local port forwarded to the target + */ + static final class Tunnel { + + final String key; + final ClientSession session; + final ExplicitPortForwardingTracker tracker; + + Tunnel(String key, ClientSession session, ExplicitPortForwardingTracker tracker) { + this.key = key; + this.session = session; + this.tracker = tracker; + } + + boolean isOpen() { + return session.isOpen() && tracker.isOpen(); + } + + int getLocalPort() { + return tracker.getBoundAddress().getPort(); + } + + /** + * No new connections are accepted, the connections which are closing (e.g. the quit of a closed JDBC + * connection) end before the session closes, an SSH server would refuse their last data otherwise + */ + void close() { + try { + tracker.close(); + } catch (IOException e) { + log.debug("Unable to close port forwarding: {}", e.getMessage()); + } + AbstractConnectionService connections = session.getService(AbstractConnectionService.class); + long deadline = System.currentTimeMillis() + CLOSE_TIMEOUT.toMillis(); + while (connections != null && !connections.getChannels().isEmpty() && session.isOpen() + && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + session.close(true); + } + } + + /** + * Known hosts file of the host keys, e.g. of a test or of a configured directory + */ + public static synchronized void setKnownHostsFile(Path file) { + knownHostsFile = file; + if (client != null) { + client.setServerKeyVerifier(createVerifier()); + } + } + + public static synchronized Path getKnownHostsFile() { + if (knownHostsFile == null) { + Config config = Config.getInstance(); + File home = new File(config.getString("sqljam.home", System.getProperty("user.home") + "/.sqljam")); + knownHostsFile = config.getFile("sqljam.ssh.known-hosts.file", new File(home, "ssh_known_hosts")) + .toPath(); + } + return knownHostsFile; + } + + private static KnownHostsServerKeyVerifier createVerifier() { + Path parent = getKnownHostsFile().toAbsolutePath().getParent(); + try { + // A new home or a configured directory which does not exist yet keeps the host keys too + Files.createDirectories(parent); + } catch (IOException e) { + log.warn("Unable to create the directory of the known hosts {}: {}", parent, e.getMessage()); + } + // A new host is accepted and remembered, a changed key of a known host is refused + KnownHostsServerKeyVerifier verifier = new KnownHostsServerKeyVerifier(SshUtils::trustOnFirstUse, + getKnownHostsFile()); + verifier.setModifiedServerKeyAcceptor((session, address, entry, expected, actual) -> false); + return verifier; + } + + /** + * A host which is not known yet is trusted and remembered, its fingerprint is logged to be compared + */ + static boolean trustOnFirstUse(ClientSession session, SocketAddress address, PublicKey key) { + if (log.isInfoEnabled()) { + log.info("Trusting the host key {} of {} on first use", KeyUtils.getFingerPrint(key), address); + } + return true; + } + + private static synchronized SshClient getClient() { + if (client == null) { + SshClient created = SshClient.setUpDefaultClient(); + created.setServerKeyVerifier(createVerifier()); + CoreModuleProperties.HEARTBEAT_INTERVAL.set(created, HEARTBEAT); + created.start(); + client = created; + } + return client; + } + + /** + * Local port of the tunnel of the data source to the target host and port, the tunnel is opened if it is not + * open with these settings + * + * @param id id of the data source + * @throws UncheckedIOException if the SSH server is not reached, refuses the user or the forwarding + */ + public static int open(String id, SshSettings settings, String targetHost, int targetPort) { + String key = settings.getKey() + "|" + targetHost + ":" + targetPort; + synchronized (TUNNELS) { + Tunnel tunnel = TUNNELS.get(id); + if (tunnel != null && tunnel.key.equals(key) && tunnel.isOpen()) { + return tunnel.getLocalPort(); + } + // A tunnel opened again keeps its local port, so the pools of its connections reach it + int localPort = 0; + if (tunnel != null) { + localPort = tunnel.getLocalPort(); + tunnel.close(); + TUNNELS.remove(id); + } + try { + tunnel = connect(key, settings, targetHost, targetPort, localPort); + } catch (IOException e) { + throw new UncheckedIOException(describe(settings, e), e); + } + TUNNELS.put(id, tunnel); + if (log.isInfoEnabled()) { + log.info("SSH tunnel {} -> {}:{} via {}@{}:{}", tunnel.getLocalPort(), targetHost, targetPort, + settings.getUsername(), settings.getHost(), settings.getEffectivePort()); + } + return tunnel.getLocalPort(); + } + } + + static String describe(SshSettings settings, IOException e) { + return "SSH tunnel through " + settings.getUsername() + "@" + settings.getHost() + ":" + settings + .getEffectivePort() + " failed: " + StringUtils.defaultIfBlank(e.getMessage(), e.getClass() + .getSimpleName()); + } + + private static Tunnel connect(String key, SshSettings settings, String targetHost, int targetPort, + int localPort) throws IOException { + if (StringUtils.isBlank(settings.getHost())) { + throw new IOException("no SSH host"); + } + ClientSession session = getClient().connect(settings.getUsername(), settings.getHost(), settings + .getEffectivePort()).verify(TIMEOUT).getSession(); + try { + if (settings.getAuthentication() == SshSettings.Authentication.KEY) { + for (KeyPair keyPair : loadKeys(session, settings)) { + session.addPublicKeyIdentity(keyPair); + } + } else if (StringUtils.isNotEmpty(settings.getPassword())) { + session.addPasswordIdentity(settings.getPassword()); + } + session.auth().verify(TIMEOUT); + SshdSocketAddress target = new SshdSocketAddress(targetHost, targetPort); + probe(session, target); + ExplicitPortForwardingTracker tracker; + try { + tracker = session.createLocalPortForwardingTracker(new SshdSocketAddress(LOCALHOST_ADDRESS, + localPort), target); + } catch (IOException e) { + if (localPort == 0) { + throw e; + } + // The port was taken meanwhile + tracker = session.createLocalPortForwardingTracker(new SshdSocketAddress(LOCALHOST_ADDRESS, 0), + target); + } + return new Tunnel(key, session, tracker); + } catch (IOException | RuntimeException e) { + session.close(true); + throw e; + } + } + + /** + * Opens one channel to the target through the server, so that a server which allows no forwarding or a target + * which it does not reach (a database which is down) is told now, not by connections which are reset later + */ + static void probe(ClientSession session, SshdSocketAddress target) throws IOException { + try (ChannelDirectTcpip channel = session.createDirectTcpipChannel(new SshdSocketAddress(LOCALHOST_ADDRESS, + 0), target)) { + channel.open().verify(TIMEOUT); + } catch (IOException e) { + throw new IOException("the SSH server does not reach " + target.getHostName() + ":" + target.getPort() + + ", it may allow no forwarding or the database is down (" + StringUtils.defaultIfBlank(e + .getMessage(), e.getClass().getSimpleName()) + ")", e); + } + } + + private static Iterable loadKeys(ClientSession session, SshSettings settings) throws IOException { + String file = StringUtils.trimToEmpty(settings.getPrivateKeyFile()); + if (file.startsWith("~")) { + file = System.getProperty("user.home") + file.substring(1); + } + Path path = Path.of(file); + if (file.isEmpty() || !Files.isRegularFile(path)) { + throw new IOException("private key file not found: " + settings.getPrivateKeyFile()); + } + FileKeyPairProvider provider = new FileKeyPairProvider(path); + provider.setPasswordFinder(FilePasswordProvider.of(settings.getPassphrase())); + try { + return provider.loadKeys(session); + } catch (RuntimeException e) { + throw new IOException("unable to read the private key " + settings.getPrivateKeyFile() + ": " + e + .getMessage(), e); + } + } + + /** + * Local port of the open tunnel of the data source, -1 if it has none + */ + public static int getLocalPort(String id) { + Tunnel tunnel = TUNNELS.get(id); + return tunnel != null && tunnel.isOpen() ? tunnel.getLocalPort() : -1; + } + + /** + * Whether the data source has an open tunnel + */ + public static boolean isOpen(String id) { + Tunnel tunnel = TUNNELS.get(id); + return tunnel != null && tunnel.isOpen(); + } + + /** + * Closes the tunnel of the data source, e.g. when its session ends + */ + public static void close(String id) { + synchronized (TUNNELS) { + Tunnel tunnel = TUNNELS.remove(id); + if (tunnel != null) { + tunnel.close(); + } + } + } + + public static void closeAll() { + synchronized (TUNNELS) { + TUNNELS.values().forEach(Tunnel::close); + TUNNELS.clear(); + } + } +} diff --git a/src/main/java/com/github/sqljam/vdb/DefaultVdbMetaDataVisitor.java b/src/main/java/com/github/sqljam/vdb/DefaultVdbMetaDataVisitor.java new file mode 100644 index 0000000..86297e9 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/DefaultVdbMetaDataVisitor.java @@ -0,0 +1,100 @@ +/* + * 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.vdb; + +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.vdb.VdbDdlScripter.Table; + +/** + * @Description: DefaultVdbMetaDataVisitor collects statements of the metadata of the vdb line into VdbDdlScripter, + * like DefaultMetaDataVisitor of the jdbc line + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class DefaultVdbMetaDataVisitor implements VdbMetaDataVisitor { + + private final VdbExportConfiguration configuration; + private final VdbDdlScripter ddlScripter; + + public DefaultVdbMetaDataVisitor(VdbExportConfiguration configuration, VdbDdlScripter ddlScripter) { + this.configuration = configuration; + this.ddlScripter = ddlScripter; + } + + @Override + public VdbExportConfiguration getConfiguration() { + return configuration; + } + + @Override + public VdbDdlScripter getDdlScripter() { + return ddlScripter; + } + + private static void addStatements(List target, String[] statements) { + for (String statement : statements) { + if (StringUtils.isNotBlank(statement)) { + target.add(statement); + } + } + } + + private Table getTable(VdbCollectionMetaData metaData) { + return ddlScripter.getTable(metaData.getSchemaName(), metaData.getTableName()); + } + + @Override + public void visit(VdbServerMetaData metaData) { + // Schemas are of the target connection, they are not created + } + + @Override + public void visit(VdbCollectionMetaData metaData) { + Table table = getTable(metaData); + addStatements(table.getCreateTableStatements(), metaData.getStatements()); + String tableOptions = metaData.getDialect().getTableOptions(metaData.getPrimaryKeyColumnNames()); + if (StringUtils.isNotBlank(tableOptions)) { + table.getTableOptions().add(tableOptions); + } + if (configuration.isTableRecreated()) { + table.getBeforeStatements().add(0, metaData.getDialect().getDropTableStatement(metaData.getSchemaName(), + metaData.getTableName())); + } + } + + @Override + public void visit(VdbFieldMetaData metaData) { + addStatements(getTable(metaData.getCollectionMetaData()).getColumnStatements(), metaData.getStatements()); + } + + @Override + public void visit(VdbPrimaryKeyMetaData metaData) { + addStatements(getTable(metaData.getCollectionMetaData()).getPrimaryKeyStatements(), metaData.getStatements()); + } + + @Override + public void visit(VdbIndexMetaData metaData) { + addStatements(getTable(metaData.getCollectionMetaData()).getIndexStatements(), metaData.getStatements()); + } + + @Override + public void visit(VdbCommentMetaData metaData) { + addStatements(getTable(metaData.getCollectionMetaData()).getCommentStatements(), metaData.getStatements()); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/PayloadSchema.java b/src/main/java/com/github/sqljam/vdb/PayloadSchema.java new file mode 100644 index 0000000..12c223d --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/PayloadSchema.java @@ -0,0 +1,151 @@ +/* + * 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.vdb; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * @Description: PayloadSchema finds the columns of payloads without a schema (Qdrant, Pinecone): every top-level field + * of a scalar type is a column of that type, a field of more types is text (numbers and text) or JSON, + * objects and arrays are JSON. The whole payload is a JSON column too, so nothing is lost when a value + * does not fit its column. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class PayloadSchema { + + /** + * Length of text up to which a field is VARCHAR, longer text is TEXT + */ + static final int MAX_VARCHAR_LENGTH = 4000; + /** + * Length of VARCHAR at least, values of the next rows of a filtered read may be a little longer + */ + static final int MIN_VARCHAR_LENGTH = 255; + + private final Map fields = new LinkedHashMap<>(); + private long observed; + + private static final class FieldType { + VdbField.Kind kind; + int maxLength; + boolean nullable; + } + + /** + * Observes the fields of a payload, a JSON object + */ + public void observe(JsonNode payload) { + if (payload == null || !payload.isObject()) { + return; + } + boolean first = observed == 0; + observed++; + Set present = new HashSet<>(); + Iterator> it = payload.fields(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + present.add(entry.getKey()); + FieldType type = fields.computeIfAbsent(entry.getKey(), key -> { + FieldType created = new FieldType(); + // A field which earlier payloads do not have takes NULL + created.nullable = !first; + return created; + }); + JsonNode value = entry.getValue(); + if (value == null || value.isNull()) { + type.nullable = true; + continue; + } + VdbField.Kind kind = kindOf(value); + type.kind = type.kind == null ? kind : merge(type.kind, kind); + if (value.isTextual()) { + type.maxLength = Math.max(type.maxLength, value.textValue().length()); + } + } + // Fields which this payload does not have take NULL + fields.forEach((name, type) -> { + if (!present.contains(name)) { + type.nullable = true; + } + }); + } + + static VdbField.Kind kindOf(JsonNode value) { + if (value.isBoolean()) { + return VdbField.Kind.BOOLEAN; + } else if (value.isIntegralNumber()) { + return value.canConvertToInt() ? VdbField.Kind.INTEGER : VdbField.Kind.BIGINT; + } else if (value.isNumber()) { + return VdbField.Kind.DOUBLE; + } else if (value.isTextual()) { + return VdbField.Kind.VARCHAR; + } + return VdbField.Kind.JSON; + } + + /** + * Type of a field of two types: integers widen, numbers become doubles, scalars of other types become text + */ + static VdbField.Kind merge(VdbField.Kind a, VdbField.Kind b) { + if (a == b) { + return a; + } + if (a == VdbField.Kind.JSON || b == VdbField.Kind.JSON) { + return VdbField.Kind.JSON; + } + boolean integral = (a == VdbField.Kind.INTEGER || a == VdbField.Kind.BIGINT) + && (b == VdbField.Kind.INTEGER || b == VdbField.Kind.BIGINT); + if (integral) { + return VdbField.Kind.BIGINT; + } + boolean numeric = a != VdbField.Kind.BOOLEAN && a != VdbField.Kind.VARCHAR && b != VdbField.Kind.BOOLEAN + && b != VdbField.Kind.VARCHAR; + return numeric ? VdbField.Kind.DOUBLE : VdbField.Kind.TEXT; + } + + /** + * Columns of the observed fields, named as the fields unless a column of the collection has the name + */ + public List getFields(VdbCollection collection) { + List columns = new ArrayList<>(); + fields.forEach((name, type) -> { + VdbField.Kind kind = type.kind != null ? type.kind : VdbField.Kind.TEXT; + if (kind == VdbField.Kind.VARCHAR && type.maxLength > MAX_VARCHAR_LENGTH) { + kind = VdbField.Kind.TEXT; + } + VdbField field = new VdbField(collection.getUnusedName(name), kind); + field.setSourceName(name); + field.setSize(kind == VdbField.Kind.VARCHAR ? Math.max(MIN_VARCHAR_LENGTH, type.maxLength) : 0); + field.setNullable(type.nullable || type.kind == null); + columns.add(field); + }); + return columns; + } + + public boolean isEmpty() { + return fields.isEmpty(); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbClient.java b/src/main/java/com/github/sqljam/vdb/VdbClient.java new file mode 100644 index 0000000..55a1aea --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbClient.java @@ -0,0 +1,137 @@ +/* + * 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.vdb; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * @Description: VdbClient reads a vector database by its REST API: its collections, the structure of a collection + * as a table, and the records of a collection page by page. Values of records are keyed by the names + * of the fields: vectors are text ([1,2.5,-3] dense, {1:1.5,3:2}/5 sparse), JSON fields and the + * payload are JSON text, scalars are Java values. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public interface VdbClient extends AutoCloseable { + + VdbType getType(); + + /** + * Version of the server, e.g. 1.19.2 + */ + String getVersion(); + + /** + * Databases of the server (Milvus, the tenant of Chroma), empty for servers without databases. A client of a + * database is created by a source of that database, see {@link VdbSource#withDatabase}. + */ + default List getDatabases() { + return List.of(); + } + + /** + * Parts of a collection which are read apart (partitions of Milvus, tenants of Weaviate, namespaces of + * Pinecone), empty for collections without them. A query reads some of them by {@link VdbQuery#getPartitions}. + */ + default List getPartitions(VdbCollection collection) { + return List.of(); + } + + /** + * Partitions of a collection by its name without describing it, e.g. for the connection tree + */ + default List listPartitions(String collection) { + return List.of(); + } + + /** + * Names of the collections which the credentials may read + */ + List getCollections(); + + /** + * Structure of a collection. Payloads without a schema (Qdrant, Pinecone) are read once without vectors to find + * their fields and types. + */ + VdbCollection describe(String collection); + + /** + * Records of the query, -1 if they can not be counted without reading them + */ + long count(VdbCollection collection, VdbQuery query); + + /** + * Reads the records of the query page by page, a consumer which returns false stops reading + */ + void read(VdbCollection collection, VdbQuery query, int pageSize, PageConsumer consumer); + + /** + * Information of the server by sections, e.g. Server: version, url; a value which is not readable tells why + */ + default Map> getOverview() { + Map> sections = new LinkedHashMap<>(); + Map server = new LinkedHashMap<>(); + server.put("Type", getType().getDisplayName()); + server.put("Version", VdbInfo.read(() -> { + String version = getVersion(); + return version == null || version.isBlank() ? "Not told by the REST API" : version; + })); + sections.put("Server", server); + Map data = new LinkedHashMap<>(); + data.put("Databases", VdbInfo.read(() -> getType().isDatabaseSupported() ? String.join(", ", getDatabases()) + : "None, collections only")); + data.put("Collections", VdbInfo.read(() -> { + List collections = getCollections(); + return collections.size() + (collections.isEmpty() ? "" : ": " + String.join(", ", collections)); + })); + sections.put("Data", data); + return sections; + } + + /** + * Health of the server by sections: whether it answers and how fast, the health endpoints of the database + */ + default Map> getHealth() { + Map> sections = new LinkedHashMap<>(); + Map connection = new LinkedHashMap<>(); + long start = System.nanoTime(); + String reachable = VdbInfo.read(() -> { + getCollections(); + return "Yes"; + }); + connection.put("Reachable", reachable); + connection.put("Response time", (System.nanoTime() - start) / 1_000_000 + " ms"); + sections.put("Connection", connection); + return sections; + } + + @FunctionalInterface + interface PageConsumer { + /** + * @param records records of a page, by the names of the fields + * @param last whether it is the last page + * @return whether the next page is read + */ + boolean accept(List> records, boolean last); + } + + @Override + default void close() { + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbCollection.java b/src/main/java/com/github/sqljam/vdb/VdbCollection.java new file mode 100644 index 0000000..26cd29b --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbCollection.java @@ -0,0 +1,83 @@ +/* + * 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.vdb; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import lombok.Getter; +import lombok.Setter; + +/** + * @Description: VdbCollection is a collection of a vector database (a collection of Milvus and Qdrant, a class of + * Weaviate, an index of Pinecone, Elasticsearch and OpenSearch) as a table: its fields in the order of + * the columns, the id first, then the vectors, the scalars of the payload and the whole payload. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Getter +@Setter +public class VdbCollection { + + private final String name; + private final List fields = new ArrayList<>(); + /** + * Records of the collection, -1 if it is not known + */ + private long count = -1; + private String remarks; + /** + * Details of the database for reading the collection, e.g. the host of a Pinecone index + */ + private final Map properties = new LinkedHashMap<>(); + + public VdbCollection(String name) { + this.name = name; + } + + public VdbCollection addField(VdbField field) { + fields.add(field); + return this; + } + + public Optional findField(String name) { + return fields.stream().filter(field -> field.getName().equalsIgnoreCase(name)).findFirst(); + } + + public List getPrimaryKeyFields() { + return fields.stream().filter(VdbField::isPrimaryKey).collect(Collectors.toList()); + } + + public List getVectorFields() { + return fields.stream().filter(VdbField::isVector).collect(Collectors.toList()); + } + + /** + * A column name which is not used by a field yet: the name, else the name with a suffix (_2, _3 ...) + */ + public String getUnusedName(String name) { + String candidate = name; + for (int i = 2; findField(candidate).isPresent(); i++) { + candidate = name + "_" + i; + } + return candidate; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbCollectionMetaData.java b/src/main/java/com/github/sqljam/vdb/VdbCollectionMetaData.java new file mode 100644 index 0000000..5f2ff52 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbCollectionMetaData.java @@ -0,0 +1,213 @@ +/* + * 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.vdb; + +import java.sql.SQLException; +import java.util.ArrayList; +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; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbCollectionMetaData is a collection as a table of the target: its columns of the fields (named + * uniquely in the target), its primary key, the vector indexes of its vector columns and its comments, + * like TableMetaData of the jdbc line + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbCollectionMetaData implements VdbMetaData { + + private final VdbCollection collection; + private final String tableName; + private final String schemaName; + private final VdbExportConfiguration configuration; + private final VdbServerMetaData serverMetaData; + private final List columns; + + public VdbCollectionMetaData(VdbCollection collection, String tableName, String schemaName, + VdbExportConfiguration configuration, VdbServerMetaData serverMetaData) { + this.collection = collection; + this.tableName = StringUtils.isNotBlank(tableName) ? tableName.trim() : getDefaultTableName(collection + .getName()); + this.schemaName = StringUtils.trimToNull(schemaName); + this.configuration = configuration; + this.serverMetaData = serverMetaData; + this.columns = Collections.unmodifiableList(createColumns()); + } + + /** + * Name of the table of a collection: characters other than letters, digits and underscores are underscores, + * e.g. sjv-doc of Pinecone is sjv_doc + */ + public static String getDefaultTableName(String collection) { + String name = collection.replaceAll("[^\\p{L}\\p{N}_]", "_"); + return Character.isDigit(name.charAt(0)) ? "t_" + name : name; + } + + private List createColumns() { + VdbDialect dialect = getDialect(); + Map mappings = getFieldMappings(); + checkFieldMappings(mappings); + Set used = new HashSet<>(); + // Names which the user chose are kept, other names give way to them + mappings.values().stream().filter(mapping -> !mapping.isSkipped() && mapping.getColumnName() != null) + .forEach(mapping -> used.add(key(dialect, mapping.getColumnName()))); + List list = new ArrayList<>(); + for (VdbField field : collection.getFields()) { + VdbFieldMapping mapping = mappings.get(field.getName()); + if (mapping != null && mapping.isSkipped()) { + continue; + } + String unique; + if (mapping != null && mapping.getColumnName() != null) { + unique = mapping.getColumnName(); + } else { + String name = field.getName(); + // Names of the target are compared without their cases, e.g. Title and title of PostgreSQL + unique = name; + for (int i = 2; !used.add(key(dialect, unique)); i++) { + unique = name + "_" + i; + } + } + String typeName = mapping != null && mapping.getTypeName() != null ? mapping.getTypeName() + : dialect.getTypeName(field); + list.add(new VdbDialect.Column(unique, field, typeName)); + } + return list; + } + + /** + * Fields of the collection which the user mapped, none for an import as it is + */ + private Map getFieldMappings() { + if (configuration == null) { + return Collections.emptyMap(); + } + return configuration.getMappings().stream().filter(mapping -> mapping.getCollection().equals(collection + .getName())).findFirst().map(VdbExportConfiguration.Mapping::getFields).orElse(Collections + .emptyMap()); + } + + /** + * Mappings which can not be imported are refused before a table is created: a field which the collection does + * not have, a skipped id, and two fields with the same column + */ + private void checkFieldMappings(Map mappings) { + if (mappings.isEmpty()) { + return; + } + VdbDialect dialect = getDialect(); + Set names = new HashSet<>(); + collection.getFields().forEach(field -> names.add(field.getName())); + Map columns = new HashMap<>(); + for (Map.Entry entry : mappings.entrySet()) { + if (!names.contains(entry.getKey())) { + throw new VdbException(String.format("Field %s of the mapping is not a field of collection %s", + entry.getKey(), collection.getName())); + } + VdbFieldMapping mapping = entry.getValue(); + if (mapping.isSkipped() && collection.findField(entry.getKey()).map(VdbField::isPrimaryKey) + .orElse(false)) { + throw new VdbException(String.format("Field %s is the id of collection %s, it can not be skipped", + entry.getKey(), collection.getName())); + } + if (!mapping.isSkipped() && mapping.getColumnName() != null) { + String other = columns.putIfAbsent(key(dialect, mapping.getColumnName()), entry.getKey()); + if (other != null) { + throw new VdbException(String.format("Fields %s and %s of collection %s are both mapped to %s", + other, entry.getKey(), collection.getName(), mapping.getColumnName())); + } + } + } + } + + private static String key(VdbDialect dialect, String name) { + return dialect.getLimitedIdentifier(dialect.foldIdentifier(name)).toLowerCase(Locale.ENGLISH); + } + + public VdbCollection getCollection() { + return collection; + } + + public String getTableName() { + return tableName; + } + + public String getSchemaName() { + return schemaName; + } + + public VdbServerMetaData getServerMetaData() { + return serverMetaData; + } + + public List getColumns() { + return columns; + } + + public List getPrimaryKeyColumnNames() { + return columns.stream().filter(column -> column.getField().isPrimaryKey()).map(VdbDialect.Column::getName) + .collect(Collectors.toList()); + } + + @Override + public VdbDialect getDialect() { + return serverMetaData.getDialect(); + } + + @Override + public void accept(VdbMetaDataVisitor visitor) throws SQLException { + for (VdbDialect.Column column : columns) { + new VdbFieldMetaData(column, this).accept(visitor); + } + if (!getPrimaryKeyColumnNames().isEmpty()) { + new VdbPrimaryKeyMetaData(this).accept(visitor); + } + if (configuration.isVectorIndexCreated() && getDialect().isVectorIndexAllowed(columns.stream() + .filter(column -> column.getField().isPrimaryKey()).collect(Collectors.toList()))) { + // Vectors of the first indexes are indexed when the target has fewer indexes of a table + int indexes = 0; + for (VdbDialect.Column column : columns) { + if (column.getField().getVectorIndex() != null && getDialect().isVectorTypeName(column.getField(), + column.getTypeName())) { + VdbIndexMetaData indexMetaData = new VdbIndexMetaData(column, this); + if (indexMetaData.getStatements().length > 0 && indexes++ >= getDialect().getMaxVectorIndexes()) { + break; + } + indexMetaData.accept(visitor); + } + } + } + if (configuration.isCommentCreated()) { + new VdbCommentMetaData(this).accept(visitor); + } + visitor.visit(this); + } + + @Override + public String[] getStatements() { + return new String[]{"CREATE TABLE " + getDialect().getQualifiedTableName(schemaName, tableName)}; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbCommentMetaData.java b/src/main/java/com/github/sqljam/vdb/VdbCommentMetaData.java new file mode 100644 index 0000000..2fd17c8 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbCommentMetaData.java @@ -0,0 +1,78 @@ +/* + * 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.vdb; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbCommentMetaData is the comments of the table of a collection and of its columns, of the + * descriptions of the collection and of its fields, like CommentMetaData of the jdbc line + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbCommentMetaData implements VdbMetaData { + + private final VdbCollectionMetaData collectionMetaData; + + public VdbCommentMetaData(VdbCollectionMetaData collectionMetaData) { + this.collectionMetaData = collectionMetaData; + } + + public VdbCollectionMetaData getCollectionMetaData() { + return collectionMetaData; + } + + @Override + public VdbDialect getDialect() { + return collectionMetaData.getDialect(); + } + + @Override + public void accept(VdbMetaDataVisitor visitor) throws SQLException { + visitor.visit(this); + } + + @Override + public String[] getStatements() { + VdbDialect dialect = getDialect(); + String schema = collectionMetaData.getSchemaName(); + String table = collectionMetaData.getTableName(); + List statements = new ArrayList<>(); + String remarks = collectionMetaData.getCollection().getRemarks(); + if (StringUtils.isNotBlank(remarks)) { + addStatement(statements, dialect.getTableCommentStatement(schema, table, remarks)); + } + for (VdbDialect.Column column : collectionMetaData.getColumns()) { + if (StringUtils.isNotBlank(column.getField().getRemarks())) { + addStatement(statements, dialect.getColumnCommentStatement(schema, table, column.getName(), + column.getField().getRemarks())); + } + } + return statements.toArray(new String[0]); + } + + private static void addStatement(List statements, String statement) { + if (statement != null) { + statements.add(statement); + } + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbDdlScripter.java b/src/main/java/com/github/sqljam/vdb/VdbDdlScripter.java new file mode 100644 index 0000000..9c96d8b --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbDdlScripter.java @@ -0,0 +1,123 @@ +/* + * 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.vdb; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * @Description: VdbDdlScripter collects the statements of the tables of collections and puts them in their order: + * drops, CREATE TABLE of the columns and the primary key, comments, then the vector indexes, which + * are created after the rows are written. Like DdlScripter of the jdbc line. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbDdlScripter { + + private final Map tables = new LinkedHashMap<>(); + + public Table getTable(String schemaName, String tableName) { + return tables.computeIfAbsent(schemaName + "." + tableName, key -> new Table()); + } + + public List getTables() { + return new ArrayList<>(tables.values()); + } + + public static class Table { + + private final List beforeStatements = new ArrayList<>(); + private final List createTableStatements = new ArrayList<>(); + private final List columnStatements = new ArrayList<>(); + private final List primaryKeyStatements = new ArrayList<>(); + private final List tableOptions = new ArrayList<>(); + private final List commentStatements = new ArrayList<>(); + private final List indexStatements = new ArrayList<>(); + + public List getBeforeStatements() { + return beforeStatements; + } + + public List getCreateTableStatements() { + return createTableStatements; + } + + public List getColumnStatements() { + return columnStatements; + } + + public List getPrimaryKeyStatements() { + return primaryKeyStatements; + } + + public List getTableOptions() { + return tableOptions; + } + + public List getCommentStatements() { + return commentStatements; + } + + public List getIndexStatements() { + return indexStatements; + } + + /** + * CREATE TABLE of the columns and the primary key, with its options + */ + public String getCreateTableScript() { + if (createTableStatements.isEmpty()) { + return null; + } + List definitions = new ArrayList<>(columnStatements); + definitions.addAll(primaryKeyStatements); + return createTableStatements.get(0) + " (\n " + String.join(",\n ", definitions) + "\n)" + + String.join("", tableOptions); + } + + /** + * Statements which create the table: drops, CREATE TABLE and comments + */ + public List getCreateScripts() { + List scripts = new ArrayList<>(beforeStatements); + String createTable = getCreateTableScript(); + if (createTable != null) { + scripts.add(createTable); + } + scripts.addAll(commentStatements); + return scripts; + } + + /** + * Statements of the vector indexes, after the rows + */ + public List getIndexScripts() { + return new ArrayList<>(indexStatements); + } + + /** + * All statements of the table in their order, e.g. to show them + */ + public List getScripts() { + List scripts = getCreateScripts(); + scripts.addAll(indexStatements); + return scripts; + } + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbException.java b/src/main/java/com/github/sqljam/vdb/VdbException.java new file mode 100644 index 0000000..7bd2797 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbException.java @@ -0,0 +1,37 @@ +/* + * 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.vdb; + +/** + * @Description: VdbException is an error of reading a vector database which is not an error of its REST API (those + * are RestException), e.g. a collection of a structure which can not be imported, a query which the + * database does not take + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public VdbException(String message) { + super(message); + } + + public VdbException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbExportConfiguration.java b/src/main/java/com/github/sqljam/vdb/VdbExportConfiguration.java new file mode 100644 index 0000000..8314979 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbExportConfiguration.java @@ -0,0 +1,97 @@ +/* + * 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.vdb; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import lombok.Getter; +import lombok.Setter; +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbExportConfiguration tells how collections of a vector database are imported into a target + * database: the target dialect and schema, the collections and their tables, the query, whether the + * tables are recreated, inserts or upserts, pages, transactions and skipped errors + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Getter +@Setter +public class VdbExportConfiguration { + + public enum WriteMode { + INSERT, UPSERT + } + + /** + * Collection and the name of its table in the target, the table is named as the collection when it is blank + */ + @Getter + public static final class Mapping { + + private final String collection; + private final String tableName; + /** + * Target columns of fields which the user mapped by field name, the other fields are imported as they are + */ + private final Map fields = new LinkedHashMap<>(); + + public Mapping(String collection, String tableName) { + this.collection = collection; + this.tableName = tableName; + } + } + + private VdbDialect dialect; + /** + * Schema of the tables (the database of MySQL, MariaDB and ClickHouse), null for the schema of the connection + */ + private String targetSchema; + private final List mappings = new ArrayList<>(); + /** + * Query of the records, all records when it is null. A search for the nearest neighbors names a vector field of + * each collection. + */ + private VdbQuery query; + /** + * Whether existing tables are dropped and created again, rows are added to existing tables otherwise + */ + private boolean tableRecreated; + private WriteMode writeMode = WriteMode.INSERT; + private boolean vectorIndexCreated = true; + private boolean commentCreated = true; + /** + * Records read and written at once + */ + private int pageSize = 500; + /** + * Whether each page is committed in its transaction, each row is committed otherwise + */ + private boolean transactional = true; + /** + * Whether rows which fail are skipped and reported, the import stops at the first error otherwise + */ + private boolean errorSkipped; + + public VdbExportConfiguration addCollection(String collection, String tableName) { + mappings.add(new Mapping(collection, tableName)); + return this; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbExportListener.java b/src/main/java/com/github/sqljam/vdb/VdbExportListener.java new file mode 100644 index 0000000..873b363 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbExportListener.java @@ -0,0 +1,63 @@ +/* + * 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.vdb; + +/** + * @Description: VdbExportListener follows an import of collections into a target: tables, pages of rows, skipped + * rows and vector indexes which the target could not create + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public interface VdbExportListener { + + /** + * A collection is read into its table, the records of its query are counted (-1 if they are not known) + */ + default void onTableStarted(VdbExportResult.TableResult table) { + } + + default void onTableCreated(VdbExportResult.TableResult table, String statement) { + } + + default void onRowsWritten(VdbExportResult.TableResult table) { + } + + default void onRowSkipped(VdbExportResult.TableResult table, String key, Exception e) { + } + + /** + * A statement which failed and was left out, e.g. a vector index which the settings of the target forbid + */ + default void onStatementSkipped(VdbExportResult.TableResult table, String statement, Exception e) { + } + + /** + * The rows of a collection are written and its vector indexes created + */ + default void onTableFinished(VdbExportResult.TableResult table) { + } + + default void onCompleted(VdbExportResult result) { + } + + /** + * Whether the import is stopped after the page which is written, asked after each page + */ + default boolean isCancelled() { + return false; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbExportResult.java b/src/main/java/com/github/sqljam/vdb/VdbExportResult.java new file mode 100644 index 0000000..6bbaef5 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbExportResult.java @@ -0,0 +1,69 @@ +/* + * 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.vdb; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Getter; +import lombok.Setter; + +/** + * @Description: VdbExportResult is what an import of collections did: rows read, written and skipped of each + * table, the vector indexes created and the warnings + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Getter +public class VdbExportResult { + + @Getter + @Setter + public static final class TableResult { + + private final String collection; + private final String tableName; + /** + * Records of the collection which are read, -1 if they are not known before + */ + private long total = -1; + private long read; + private long written; + private long skipped; + private int indexes; + private boolean created; + private final List warnings = new ArrayList<>(); + private long elapsed; + + public TableResult(String collection, String tableName) { + this.collection = collection; + this.tableName = tableName; + } + } + + private final List tables = new ArrayList<>(); + @Setter + private boolean cancelled; + + public long getWritten() { + return tables.stream().mapToLong(TableResult::getWritten).sum(); + } + + public long getSkipped() { + return tables.stream().mapToLong(TableResult::getSkipped).sum(); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbExporter.java b/src/main/java/com/github/sqljam/vdb/VdbExporter.java new file mode 100644 index 0000000..f9d8f73 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbExporter.java @@ -0,0 +1,228 @@ +/* + * 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.vdb; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +import javax.sql.DataSource; + +import lombok.extern.slf4j.Slf4j; +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbExporter imports collections of a vector database into a target database which has vectors, the + * pipeline of the vdb line: the features of the target are detected, the collections are described + * into the metadata tree, a visitor collects the statements of their tables into VdbDdlScripter, the + * tables are created, the records of the query are read page by page and written by + * VdbImportHandler, then the vector indexes are created. A target without vectors is refused, an index + * which the target can not create is reported and left out. Like Exporter of the jdbc line. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Slf4j +public class VdbExporter { + + private final VdbClient client; + private final DataSource dataSource; + private final VdbExportConfiguration configuration; + private final VdbExportListener listener; + private volatile boolean cancelled; + + public VdbExporter(VdbClient client, DataSource dataSource, VdbExportConfiguration configuration) { + this(client, dataSource, configuration, new VdbExportListener() { + }); + } + + public VdbExporter(VdbClient client, DataSource dataSource, VdbExportConfiguration configuration, + VdbExportListener listener) { + this.client = client; + this.dataSource = dataSource; + this.configuration = configuration; + this.listener = listener; + } + + /** + * Stops the import after the page which is written + */ + public void cancel() { + cancelled = true; + } + + private boolean isCancelled() { + return cancelled || listener.isCancelled(); + } + + /** + * Dialect of the target, which must have vectors + */ + public static VdbDialect getDialect(Connection connection) throws SQLException { + VdbDialect dialect = VdbDialect.of(connection); + if (dialect == null) { + throw new VdbException("Vector databases are imported into PostgreSQL, MySQL, MariaDB, Oracle," + + " SQL Server, Db2, DuckDB and ClickHouse: " + connection.getMetaData() + .getDatabaseProductName()); + } + if (!dialect.isVectorSupported()) { + throw new VdbException(String.format("%s %s has no vectors (e.g. pgvector of PostgreSQL, MySQL 9.0," + + " MariaDB 11.7, Oracle 23ai, SQL Server 2025, Db2 12.1.2), vector databases are imported" + + " into targets with vectors only", dialect.getName(), dialect.getProductVersion())); + } + return dialect; + } + + /** + * Statements of the tables of the collections, which are not executed + */ + public List getScripts() throws SQLException { + try (Connection connection = dataSource.getConnection()) { + VdbDdlScripter scripter = visit(new VdbServerMetaData(client, prepareDialect(connection), + configuration)); + List scripts = new ArrayList<>(); + scripter.getTables().forEach(table -> scripts.addAll(table.getScripts())); + return scripts; + } + } + + private VdbDialect prepareDialect(Connection connection) throws SQLException { + VdbDialect dialect = configuration.getDialect(); + if (dialect == null) { + dialect = getDialect(connection); + configuration.setDialect(dialect); + } else { + dialect.prepareSession(connection); + } + return dialect; + } + + private VdbDdlScripter visit(VdbServerMetaData serverMetaData) throws SQLException { + VdbDdlScripter scripter = new VdbDdlScripter(); + serverMetaData.accept(new DefaultVdbMetaDataVisitor(configuration, scripter)); + return scripter; + } + + public VdbExportResult export() throws SQLException { + VdbExportResult result = new VdbExportResult(); + try (Connection connection = dataSource.getConnection()) { + VdbDialect dialect = prepareDialect(connection); + VdbServerMetaData serverMetaData = new VdbServerMetaData(client, dialect, configuration); + VdbDdlScripter scripter = visit(serverMetaData); + for (VdbCollectionMetaData metaData : serverMetaData.getCollectionMetaData()) { + if (isCancelled()) { + break; + } + VdbExportResult.TableResult tableResult = new VdbExportResult.TableResult(metaData.getCollection() + .getName(), metaData.getTableName()); + result.getTables().add(tableResult); + exportCollection(connection, metaData, scripter.getTable(metaData.getSchemaName(), + metaData.getTableName()), tableResult); + } + } + result.setCancelled(isCancelled()); + listener.onCompleted(result); + return result; + } + + private void exportCollection(Connection connection, VdbCollectionMetaData metaData, VdbDdlScripter.Table table, + VdbExportResult.TableResult result) throws SQLException { + long start = System.currentTimeMillis(); + VdbDialect dialect = metaData.getDialect(); + boolean existing = !configuration.isTableRecreated() && dialect.isTableExisting(connection, + metaData.getSchemaName(), metaData.getTableName()); + if (!existing) { + for (String script : table.getCreateScripts()) { + if (table.getCommentStatements().contains(script)) { + executeQuietly(connection, script, result); + } else { + execute(connection, script); + if (script.equals(table.getCreateTableScript())) { + listener.onTableCreated(result, script); + } + } + } + result.setCreated(true); + } + VdbQuery query = configuration.getQuery() != null ? configuration.getQuery() : VdbQuery.all(); + result.setTotal(client.count(metaData.getCollection(), query)); + listener.onTableStarted(result); + SQLException[] error = {null}; + try (VdbImportHandler handler = new VdbImportHandler(connection, metaData, configuration, listener, result)) { + client.read(metaData.getCollection(), query, configuration.getPageSize(), (records, last) -> { + try { + handler.write(records); + } catch (SQLException e) { + error[0] = e; + return false; + } + return !isCancelled(); + }); + } + if (error[0] != null) { + throw error[0]; + } + if (!existing) { + // Indexes are built after the rows, an index which the target can not create is left out + for (String script : table.getIndexScripts()) { + if (executeQuietly(connection, script, result)) { + result.setIndexes(result.getIndexes() + 1); + } + } + } + result.setElapsed(System.currentTimeMillis() - start); + listener.onTableFinished(result); + if (log.isInfoEnabled()) { + log.info("Import {} of {} rows of collection {} into table {}, {} skipped", result.getWritten(), + result.getRead(), result.getCollection(), result.getTableName(), result.getSkipped()); + } + } + + private static void execute(Connection connection, String sql) throws SQLException { + if (log.isInfoEnabled()) { + log.info("Execute ddl: {}", sql); + } + try (Statement statement = connection.createStatement()) { + statement.execute(sql); + } + if (!connection.getAutoCommit()) { + connection.commit(); + } + } + + private boolean executeQuietly(Connection connection, String sql, VdbExportResult.TableResult result) { + try { + execute(connection, sql); + return true; + } catch (SQLException e) { + try { + if (!connection.getAutoCommit()) { + connection.rollback(); + } + } catch (SQLException ignored) { + // The connection is closed + } + if (log.isWarnEnabled()) { + log.warn("Skip statement {}: {}", sql, e.getMessage()); + } + result.getWarnings().add(e.getMessage() + ": " + sql); + listener.onStatementSkipped(result, sql, e); + return false; + } + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbField.java b/src/main/java/com/github/sqljam/vdb/VdbField.java new file mode 100644 index 0000000..2f632a8 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbField.java @@ -0,0 +1,120 @@ +/* + * 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.vdb; + +import java.util.LinkedHashMap; +import java.util.Map; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * @Description: VdbField is a column of a collection of a vector database: its id, a vector, a scalar field of the + * payload (a column of the target table), the JSON of a nested field, or the payload as a whole. The + * type of a scalar is one of {@link Kind}, the target maps it to its own types. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Getter +@Setter +@ToString +public class VdbField { + + /** + * Name of the column of the whole payload, a JSON object of the fields of a record except its id and vectors + */ + public static final String PAYLOAD = "payload"; + + /** + * Type of the values of a field, as the target keeps them + */ + public enum Kind { + BOOLEAN, + /** + * 16-bit integers + */ + SMALLINT, + INTEGER, + BIGINT, + REAL, + DOUBLE, + /** + * Text of a known maximum length (size), e.g. VarChar(512) of Milvus + */ + VARCHAR, + /** + * Text of any length + */ + TEXT, + /** + * Time with a time zone, e.g. date of Weaviate (RFC 3339) + */ + TIMESTAMP, + BINARY, + /** + * JSON text of objects and arrays + */ + JSON, + VECTOR + } + + private final String name; + private Kind kind; + /** + * Maximum length of VARCHAR + */ + private int size; + private boolean nullable = true; + private boolean primaryKey; + private VectorType vectorType; + private VectorIndex vectorIndex; + /** + * Name of the field in the records of the database, e.g. _id of Elasticsearch, if it differs from the column + */ + private String sourceName; + private String remarks; + /** + * Details of the database for reading the values, e.g. the type of a field of Milvus + */ + private final Map properties = new LinkedHashMap<>(); + + public VdbField(String name, Kind kind) { + this.name = name; + this.kind = kind; + } + + public static VdbField vector(String name, VectorType vectorType) { + VdbField field = new VdbField(name, Kind.VECTOR); + field.setVectorType(vectorType); + return field; + } + + public static VdbField varchar(String name, int size) { + VdbField field = new VdbField(name, Kind.VARCHAR); + field.setSize(size); + return field; + } + + public String getSourceName() { + return sourceName != null ? sourceName : name; + } + + public boolean isVector() { + return kind == Kind.VECTOR; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbFieldMapping.java b/src/main/java/com/github/sqljam/vdb/VdbFieldMapping.java new file mode 100644 index 0000000..aff9eb6 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbFieldMapping.java @@ -0,0 +1,64 @@ +/* + * 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.vdb; + +import java.util.Objects; + +import org.apache.commons.lang3.StringUtils; +import lombok.Getter; +import lombok.ToString; + +/** + * @Description: VdbFieldMapping is the target column of a field of a collection which the user chose: another name, + * a type of the target written as it is, or no column. A field without a mapping keeps its name and + * its type, the id of a collection can not be skipped. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Getter +@ToString +public class VdbFieldMapping { + + private final String columnName; + private final String typeName; + private final boolean skipped; + + public VdbFieldMapping(String columnName, String typeName, boolean skipped) { + this.columnName = StringUtils.trimToNull(columnName); + this.typeName = StringUtils.trimToNull(typeName); + this.skipped = skipped; + } + + public static VdbFieldMapping skip() { + return new VdbFieldMapping(null, null, true); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof VdbFieldMapping)) { + return false; + } + VdbFieldMapping mapping = (VdbFieldMapping) other; + return skipped == mapping.skipped && Objects.equals(columnName, mapping.columnName) && Objects.equals( + typeName, mapping.typeName); + } + + @Override + public int hashCode() { + return Objects.hash(columnName, typeName, skipped); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbFieldMetaData.java b/src/main/java/com/github/sqljam/vdb/VdbFieldMetaData.java new file mode 100644 index 0000000..a2cde0f --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbFieldMetaData.java @@ -0,0 +1,61 @@ +/* + * 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.vdb; + +import java.sql.SQLException; + +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbFieldMetaData is a field of a collection as a column of the target, like ColumnMetaData of the + * jdbc line + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbFieldMetaData implements VdbMetaData { + + private final VdbDialect.Column column; + private final VdbCollectionMetaData collectionMetaData; + + public VdbFieldMetaData(VdbDialect.Column column, VdbCollectionMetaData collectionMetaData) { + this.column = column; + this.collectionMetaData = collectionMetaData; + } + + public VdbDialect.Column getColumn() { + return column; + } + + public VdbCollectionMetaData getCollectionMetaData() { + return collectionMetaData; + } + + @Override + public VdbDialect getDialect() { + return collectionMetaData.getDialect(); + } + + @Override + public void accept(VdbMetaDataVisitor visitor) throws SQLException { + visitor.visit(this); + } + + @Override + public String[] getStatements() { + return new String[]{getDialect().getColumnDefinition(column)}; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbImportHandler.java b/src/main/java/com/github/sqljam/vdb/VdbImportHandler.java new file mode 100644 index 0000000..2271e98 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbImportHandler.java @@ -0,0 +1,207 @@ +/* + * 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.vdb; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import lombok.extern.slf4j.Slf4j; +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbImportHandler writes the rows of a collection into its table of the target page by page: a page + * is a batch of INSERT or upsert statements committed in its transaction. A page which fails is rolled + * back and, when errors are skipped, written again row by row, each row which fails is reported with + * its key. Without transactions every row is written and committed by itself. Like + * ImportExportHandler of the jdbc line. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Slf4j +public class VdbImportHandler implements AutoCloseable { + + private final Connection connection; + private final VdbCollectionMetaData metaData; + private final VdbExportConfiguration configuration; + private final VdbExportListener listener; + private final VdbExportResult.TableResult result; + private final VdbDialect dialect; + private final List columns; + private final String sql; + private final boolean transactional; + private final boolean autoCommit; + private PreparedStatement statement; + + public VdbImportHandler(Connection connection, VdbCollectionMetaData metaData, VdbExportConfiguration configuration, + VdbExportListener listener, VdbExportResult.TableResult result) throws SQLException { + this.connection = connection; + this.metaData = metaData; + this.configuration = configuration; + this.listener = listener; + this.result = result; + this.dialect = metaData.getDialect(); + this.columns = metaData.getColumns(); + boolean upsert = configuration.getWriteMode() == VdbExportConfiguration.WriteMode.UPSERT; + if (upsert && !dialect.isUpsertSupported()) { + throw new VdbException(dialect.getName() + " has no upserts, please insert the rows into a new table"); + } + this.sql = upsert ? dialect.getUpsertStatement(metaData.getSchemaName(), metaData.getTableName(), columns, + metaData.getPrimaryKeyColumnNames()) : dialect.getInsertStatement(metaData.getSchemaName(), + metaData.getTableName(), columns); + this.transactional = configuration.isTransactional() && dialect.isTransactionSupported(); + this.autoCommit = connection.getAutoCommit(); + if (transactional) { + connection.setAutoCommit(false); + } + this.statement = connection.prepareStatement(sql); + if (log.isInfoEnabled()) { + log.info("Execute dml: {}", sql); + } + } + + public String getSql() { + return sql; + } + + /** + * Writes the rows of a page, returns the rows written + */ + public int write(List> rows) throws SQLException { + if (rows.isEmpty()) { + return 0; + } + result.setRead(result.getRead() + rows.size()); + // A target without transactions writes a batch as one block, other targets commit each row by itself + // when pages are not transactions + int written = transactional || !dialect.isTransactionSupported() ? writeBatch(rows) : writeRowByRow(rows); + result.setWritten(result.getWritten() + written); + listener.onRowsWritten(result); + return written; + } + + private int writeBatch(List> rows) throws SQLException { + try { + for (Map row : rows) { + bind(row); + statement.addBatch(); + } + statement.executeBatch(); + if (transactional) { + connection.commit(); + } + return rows.size(); + } catch (SQLException | RuntimeException e) { + clearBatch(); + if (transactional) { + connection.rollback(); + } + if (!configuration.isErrorSkipped()) { + throw e; + } + if (log.isWarnEnabled()) { + log.warn("Unable to write a page of {} rows into table {}, the rows are written one by one: {}", + rows.size(), metaData.getTableName(), e.getMessage()); + } + return writeRowByRow(rows); + } + } + + private int writeRowByRow(List> rows) throws SQLException { + int written = 0; + for (Map row : rows) { + try { + // Some drivers close a statement when it fails (DuckDB) + if (statement.isClosed()) { + statement = connection.prepareStatement(sql); + } + bind(row); + statement.executeUpdate(); + if (transactional) { + connection.commit(); + } + written++; + } catch (SQLException | RuntimeException e) { + if (transactional) { + connection.rollback(); + } + if (!configuration.isErrorSkipped()) { + throw e; + } + String key = getKey(row); + result.setSkipped(result.getSkipped() + 1); + if (log.isWarnEnabled()) { + log.warn("Skip row {} of table {}: {}", key, metaData.getTableName(), e.getMessage()); + } + listener.onRowSkipped(result, key, e); + } + } + return written; + } + + private void bind(Map row) throws SQLException { + for (int i = 0; i < columns.size(); i++) { + VdbDialect.Column column = columns.get(i); + Object value = dialect.getJdbcValue(row.get(column.getField().getName()), column); + if (value == null) { + statement.setNull(i + 1, dialect.getNullSqlType(column)); + } else { + statement.setObject(i + 1, value); + } + } + } + + private void clearBatch() { + try { + if (!statement.isClosed()) { + statement.clearBatch(); + } + } catch (SQLException ignored) { + // The batch ended with the error + } + } + + /** + * Key of a row in the log, e.g. namespace=ns1, id=doc-1 + */ + String getKey(Map row) { + List keys = columns.stream().filter(column -> column.getField().isPrimaryKey()) + .collect(Collectors.toList()); + List parts = new ArrayList<>(); + for (VdbDialect.Column column : keys.isEmpty() ? columns.subList(0, Math.min(1, columns.size())) : keys) { + parts.add(column.getName() + "=" + StringUtils.abbreviate(String.valueOf(row.get(column.getField() + .getName())), 80)); + } + return String.join(", ", parts); + } + + @Override + public void close() throws SQLException { + try { + statement.close(); + } finally { + if (transactional) { + connection.setAutoCommit(autoCommit); + } + } + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbIndexMetaData.java b/src/main/java/com/github/sqljam/vdb/VdbIndexMetaData.java new file mode 100644 index 0000000..06f17fc --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbIndexMetaData.java @@ -0,0 +1,69 @@ +/* + * 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.vdb; + +import java.sql.SQLException; + +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbIndexMetaData is the vector index of a vector column, of the kind, the distance and the options + * of the index of the collection when the target has them, like IndexMetaData of the jdbc line + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbIndexMetaData implements VdbMetaData { + + private final VdbDialect.Column column; + private final VdbCollectionMetaData collectionMetaData; + + public VdbIndexMetaData(VdbDialect.Column column, VdbCollectionMetaData collectionMetaData) { + this.column = column; + this.collectionMetaData = collectionMetaData; + } + + public VdbDialect.Column getColumn() { + return column; + } + + public VdbCollectionMetaData getCollectionMetaData() { + return collectionMetaData; + } + + public String getIndexName() { + return "idx_" + collectionMetaData.getTableName() + "_" + column.getName(); + } + + @Override + public VdbDialect getDialect() { + return collectionMetaData.getDialect(); + } + + @Override + public void accept(VdbMetaDataVisitor visitor) throws SQLException { + visitor.visit(this); + } + + @Override + public String[] getStatements() { + VdbField field = column.getField(); + String statement = getDialect().getCreateVectorIndexStatement(collectionMetaData.getSchemaName(), + collectionMetaData.getTableName(), getIndexName(), column.getName(), column.getTypeName(), + field.getVectorType(), column.isNullable(), field.getVectorIndex()); + return statement != null ? new String[]{statement} : new String[0]; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbInfo.java b/src/main/java/com/github/sqljam/vdb/VdbInfo.java new file mode 100644 index 0000000..b69b3d1 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbInfo.java @@ -0,0 +1,68 @@ +/* + * 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.vdb; + +import java.util.Locale; +import java.util.concurrent.Callable; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.utils.RestException; + +/** + * @Description: VdbInfo reads the items of the overview and of the health of a vector database one by one: an item + * which is not readable (no privilege, an endpoint of another version) tells why instead of failing + * the others + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class VdbInfo { + + public static final String UNAVAILABLE = "Unavailable: "; + + private VdbInfo() { + } + + public static String read(Callable item) { + try { + Object value = item.call(); + return value == null || StringUtils.isBlank(value.toString()) ? "-" : value.toString(); + } catch (RestException e) { + return UNAVAILABLE + (e.isAccessDenied() ? "no privilege, HTTP " + e.getStatus() : e.getStatus() > 0 + ? "HTTP " + e.getStatus() : "not reachable"); + } catch (Exception e) { + return UNAVAILABLE + StringUtils.abbreviate(StringUtils.defaultString(e.getMessage(), e.getClass() + .getSimpleName()), 200); + } + } + + /** + * Bytes as KB, MB or GB + */ + public static String bytes(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } + String[] units = {"KB", "MB", "GB", "TB"}; + double value = bytes; + int unit = -1; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return String.format(Locale.ENGLISH, "%.1f %s", value, units[unit]); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbMetaData.java b/src/main/java/com/github/sqljam/vdb/VdbMetaData.java new file mode 100644 index 0000000..a671018 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbMetaData.java @@ -0,0 +1,37 @@ +/* + * 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.vdb; + +import java.sql.SQLException; + +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbMetaData is a node of the metadata tree of the vdb line (server, collection, field, primary key, + * vector index, comment), which generates its statements of the target by the vdb dialect and accepts a + * visitor, like MetaData of the jdbc line + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public interface VdbMetaData { + + VdbDialect getDialect(); + + void accept(VdbMetaDataVisitor visitor) throws SQLException; + + String[] getStatements() throws SQLException; +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbMetaDataVisitor.java b/src/main/java/com/github/sqljam/vdb/VdbMetaDataVisitor.java new file mode 100644 index 0000000..d576e60 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbMetaDataVisitor.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.vdb; + +import java.sql.SQLException; + +/** + * @Description: VdbMetaDataVisitor visits the metadata tree of the vdb line and collects statements, like + * MetaDataVisitor of the jdbc line + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public interface VdbMetaDataVisitor { + + void visit(VdbServerMetaData metaData) throws SQLException; + + void visit(VdbCollectionMetaData metaData) throws SQLException; + + void visit(VdbFieldMetaData metaData) throws SQLException; + + void visit(VdbPrimaryKeyMetaData metaData) throws SQLException; + + void visit(VdbIndexMetaData metaData) throws SQLException; + + void visit(VdbCommentMetaData metaData) throws SQLException; + + VdbExportConfiguration getConfiguration(); + + VdbDdlScripter getDdlScripter(); +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbPrimaryKeyMetaData.java b/src/main/java/com/github/sqljam/vdb/VdbPrimaryKeyMetaData.java new file mode 100644 index 0000000..0c6071d --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbPrimaryKeyMetaData.java @@ -0,0 +1,58 @@ +/* + * 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.vdb; + +import java.sql.SQLException; + +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbPrimaryKeyMetaData is the primary key of the table of a collection, its id and the columns of + * its partitions (tenants of Weaviate, namespaces of Pinecone), like PrimaryKeyMetaData of the jdbc + * line. Targets without constraints (ClickHouse) order the table by the key instead. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbPrimaryKeyMetaData implements VdbMetaData { + + private final VdbCollectionMetaData collectionMetaData; + + public VdbPrimaryKeyMetaData(VdbCollectionMetaData collectionMetaData) { + this.collectionMetaData = collectionMetaData; + } + + public VdbCollectionMetaData getCollectionMetaData() { + return collectionMetaData; + } + + @Override + public VdbDialect getDialect() { + return collectionMetaData.getDialect(); + } + + @Override + public void accept(VdbMetaDataVisitor visitor) throws SQLException { + visitor.visit(this); + } + + @Override + public String[] getStatements() { + String definition = getDialect().getPrimaryKeyDefinition(collectionMetaData.getTableName(), + collectionMetaData.getPrimaryKeyColumnNames()); + return definition != null ? new String[]{definition} : new String[0]; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbQuery.java b/src/main/java/com/github/sqljam/vdb/VdbQuery.java new file mode 100644 index 0000000..1df4245 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbQuery.java @@ -0,0 +1,195 @@ +/* + * 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.vdb; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; +import lombok.Getter; +import lombok.Setter; + +/** + * @Description: VdbQuery narrows the records of a collection: conditions of payload fields which every database + * translates into its own filter, or a native filter of the database (a filter of Qdrant, an + * expression of Milvus, a where filter of Weaviate, a filter of Pinecone, a query of Elasticsearch and + * OpenSearch), a limit, and the nearest neighbors of a vector or of a record. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Getter +@Setter +public class VdbQuery { + + public enum Operator { + EQ, NE, GT, GE, LT, LE, IN + } + + /** + * Condition of a field: field operator value, the value of IN is a list + */ + @Getter + public static final class Condition { + + private final String field; + private final Operator operator; + private final Object value; + + public Condition(String field, Operator operator, Object value) { + this.field = field; + this.operator = operator; + this.value = value; + } + } + + /** + * Conditions which all records match + */ + private final List conditions = new ArrayList<>(); + /** + * Filter in the language of the database, it takes the place of the conditions + */ + private String nativeFilter; + /** + * Records at most, 0 for all + */ + private long limit; + /** + * Vector field of a search for the nearest neighbors + */ + private String nearestField; + /** + * Vector of the search, or the id of the record whose vector is searched + */ + private float[] nearestVector; + private Object nearestId; + /** + * Partitions, tenants or namespaces which are read, all when it is empty + */ + private List partitions = new ArrayList<>(); + + public VdbQuery where(String field, Operator operator, Object value) { + conditions.add(new Condition(field, operator, value)); + return this; + } + + public boolean isNative() { + return StringUtils.isNotBlank(nativeFilter); + } + + public boolean isFiltered() { + return isNative() || !conditions.isEmpty(); + } + + /** + * Whether the records are the nearest neighbors of a vector, which are read in one request of the limit + */ + public boolean isNearest() { + return StringUtils.isNotBlank(nearestField) && (nearestVector != null || nearestId != null); + } + + /** + * Partitions of the collection which are read: the partitions of the query, all when it has none + */ + public List selectPartitions(List all) { + if (partitions == null || partitions.isEmpty()) { + return all; + } + List selected = new ArrayList<>(all); + selected.retainAll(partitions); + return selected; + } + + public static VdbQuery all() { + return new VdbQuery(); + } + + private static final Pattern CONDITION = Pattern.compile( + "^\\s*([\\p{L}\\p{N}_.$-]+)\\s*(>=|<=|!=|<>|=|>|<|(?i:in))\\s*(.+?)\\s*$"); + + /** + * Conditions of text, one a line or joined by AND: field operator value, e.g. n >= 6, title = 'a b', + * lang IN ('en', 'zh'). Values in quotes are text, true and false are booleans, numbers are numbers. + * + * @throws IllegalArgumentException if a condition is not of this form + */ + public VdbQuery where(String text) { + for (String line : StringUtils.defaultString(text).split( + "(?i)\\s+and\\s+(?=(?:[^']*'[^']*')*[^']*$)|\\R")) { + if (StringUtils.isBlank(line)) { + continue; + } + Matcher matcher = CONDITION.matcher(line); + if (!matcher.matches()) { + throw new IllegalArgumentException("Invalid condition: " + line.trim()); + } + String operator = matcher.group(2).toUpperCase(Locale.ENGLISH); + String value = matcher.group(3); + if ("IN".equals(operator)) { + String items = StringUtils.removeEnd(StringUtils.removeStart(value.trim(), "("), ")"); + List values = new ArrayList<>(); + for (String item : items.split(",(?=(?:[^']*'[^']*')*[^']*$)")) { + values.add(parseValue(item)); + } + where(matcher.group(1), Operator.IN, values); + } else { + where(matcher.group(1), toOperator(operator), parseValue(value)); + } + } + return this; + } + + private static Operator toOperator(String operator) { + switch (operator) { + case "=": + return Operator.EQ; + case "!=": + case "<>": + return Operator.NE; + case ">": + return Operator.GT; + case ">=": + return Operator.GE; + case "<": + return Operator.LT; + default: + return Operator.LE; + } + } + + static Object parseValue(String text) { + String value = text.trim(); + if (value.length() >= 2 && (value.startsWith("'") && value.endsWith("'") || value.startsWith("\"") + && value.endsWith("\""))) { + return value.substring(1, value.length() - 1).replace("''", "'"); + } + if ("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value)) { + return Boolean.parseBoolean(value); + } + if (value.matches("-?\\d{1,18}")) { + return Long.parseLong(value); + } + if (NumberUtils.isCreatable(value)) { + return Double.parseDouble(value); + } + return value; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbServerMetaData.java b/src/main/java/com/github/sqljam/vdb/VdbServerMetaData.java new file mode 100644 index 0000000..e063f41 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbServerMetaData.java @@ -0,0 +1,81 @@ +/* + * 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.vdb; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbServerMetaData is the root of the metadata tree of the vdb line: the vector database and the + * collections which are imported, each described once by the client + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbServerMetaData implements VdbMetaData { + + private final VdbClient client; + private final VdbDialect dialect; + private final VdbExportConfiguration configuration; + private List collectionMetaData; + + public VdbServerMetaData(VdbClient client, VdbDialect dialect, VdbExportConfiguration configuration) { + this.client = client; + this.dialect = dialect; + this.configuration = configuration; + } + + public VdbClient getClient() { + return client; + } + + @Override + public VdbDialect getDialect() { + return dialect; + } + + /** + * Collections of the configuration, in its order + */ + public List getCollectionMetaData() { + if (collectionMetaData == null) { + List list = new ArrayList<>(); + for (VdbExportConfiguration.Mapping mapping : configuration.getMappings()) { + list.add(new VdbCollectionMetaData(client.describe(mapping.getCollection()), mapping.getTableName(), + configuration.getTargetSchema(), configuration, this)); + } + collectionMetaData = Collections.unmodifiableList(list); + } + return collectionMetaData; + } + + @Override + public void accept(VdbMetaDataVisitor visitor) throws SQLException { + for (VdbCollectionMetaData metaData : getCollectionMetaData()) { + metaData.accept(visitor); + } + visitor.visit(this); + } + + @Override + public String[] getStatements() { + return new String[0]; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbSource.java b/src/main/java/com/github/sqljam/vdb/VdbSource.java new file mode 100644 index 0000000..f48a413 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbSource.java @@ -0,0 +1,85 @@ +/* + * 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.vdb; + +import org.apache.commons.lang3.StringUtils; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * @Description: VdbSource is the connection of a vector database: its REST url and its credentials, an API key or a + * token (Qdrant api-key, Weaviate and Milvus bearer tokens, Pinecone Api-Key) or a user and a password + * (Elasticsearch, OpenSearch). Certificates of servers may be trusted without verification, e.g. the + * self-signed certificate of a development OpenSearch. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@Getter +@Setter +@ToString(exclude = {"apiKey", "password"}) +public class VdbSource { + + private VdbType type; + /** + * Base url of the REST API, e.g. http://localhost:6333 + */ + private String url; + private String apiKey; + private String username; + private String password; + /** + * Database of Milvus and Chroma, null for the default one + */ + private String database; + /** + * Tenant of Chroma, null for the default one + */ + private String tenant; + private boolean certificateTrusted; + private int timeoutSeconds = 60; + + /** + * Whether an API key or a user is given, blank texts are none + */ + public boolean hasCredentials() { + return StringUtils.isNotBlank(apiKey) || StringUtils.isNotBlank(username); + } + + /** + * The source of another database of the same server + */ + public VdbSource withDatabase(String otherDatabase) { + VdbSource copy = new VdbSource(type, url); + copy.setApiKey(apiKey); + copy.setUsername(username); + copy.setPassword(password); + copy.setDatabase(otherDatabase); + copy.setTenant(tenant); + copy.setCertificateTrusted(certificateTrusted); + copy.setTimeoutSeconds(timeoutSeconds); + return copy; + } + + public VdbSource() { + } + + public VdbSource(VdbType type, String url) { + this.type = type; + this.url = url; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbType.java b/src/main/java/com/github/sqljam/vdb/VdbType.java new file mode 100644 index 0000000..814ee9a --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbType.java @@ -0,0 +1,144 @@ +/* + * 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.vdb; + +import java.util.function.Function; + +import com.github.sqljam.vdb.client.ChromaClient; +import com.github.sqljam.vdb.client.ElasticsearchClient; +import com.github.sqljam.vdb.client.MilvusClient; +import com.github.sqljam.vdb.client.OpenSearchClient; +import com.github.sqljam.vdb.client.PineconeClient; +import com.github.sqljam.vdb.client.QdrantClient; +import com.github.sqljam.vdb.client.WeaviateClient; + +/** + * @Description: VdbType is a vector database which SqlJam reads by its REST API. Vector databases are sources only, + * their collections are imported straight into a relational database with vectors, never the other + * way round, and they are not written into export packages. Supported versions: + *
    + *
  • Milvus 2.4 and later (REST API v2)
  • + *
  • Qdrant 1.10 and later (scroll, named and sparse vectors)
  • + *
  • Weaviate 1.24 and later (named vectors, cursor of objects)
  • + *
  • Pinecone, and Pinecone Local (API version 2025-01)
  • + *
  • Elasticsearch 8.0 and later (dense_vector, point in time)
  • + *
  • OpenSearch 2.4 and later (k-NN plugin, point in time)
  • + *
  • Chroma 1.0 and later (REST API v2, tenants and databases)
  • + *
+ * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public enum VdbType { + + MILVUS("Milvus", 19530, false, true, true, MilvusClient::new), + QDRANT("Qdrant", 6333, false, true, false, QdrantClient::new), + WEAVIATE("Weaviate", 8080, false, true, false, WeaviateClient::new), + PINECONE("Pinecone", 5080, false, true, false, PineconeClient::new), + ELASTICSEARCH("Elasticsearch", 9200, false, true, true, ElasticsearchClient::new), + OPENSEARCH("OpenSearch", 9200, true, false, true, OpenSearchClient::new), + CHROMA("Chroma", 8000, false, true, false, ChromaClient::new); + + private final String displayName; + private final int defaultPort; + private final boolean secureByDefault; + private final boolean apiKeySupported; + private final boolean userSupported; + private final Function clientFactory; + + VdbType(String displayName, int defaultPort, boolean secureByDefault, boolean apiKeySupported, + boolean userSupported, Function clientFactory) { + this.displayName = displayName; + this.defaultPort = defaultPort; + this.secureByDefault = secureByDefault; + this.apiKeySupported = apiKeySupported; + this.userSupported = userSupported; + this.clientFactory = clientFactory; + } + + /** + * Whether the database is authenticated by an API key (a token of Milvus, a bearer token of Weaviate and + * Chroma, an API key of Qdrant, Pinecone and Elasticsearch) + */ + public boolean isApiKeySupported() { + return apiKeySupported; + } + + /** + * Whether the database is authenticated by a user and a password (Milvus, Elasticsearch, OpenSearch) + */ + public boolean isUserSupported() { + return userSupported; + } + + /** + * Whether the database has databases of its own (Milvus, Chroma) + */ + public boolean isDatabaseSupported() { + return this == MILVUS || this == CHROMA; + } + + /** + * Name of the parts of a collection which are read apart: partitions of Milvus, tenants of Weaviate, + * namespaces of Pinecone, null for the databases without them + */ + public String getPartitionName() { + switch (this) { + case MILVUS: + return "Partition"; + case WEAVIATE: + return "Tenant"; + case PINECONE: + return "Namespace"; + default: + return null; + } + } + + /** + * Whether the database has tenants (Chroma) + */ + public boolean isTenantSupported() { + return this == CHROMA; + } + + public String getDisplayName() { + return displayName; + } + + public int getDefaultPort() { + return defaultPort; + } + + /** + * Whether the server listens on HTTPS by default, e.g. OpenSearch with its security plugin + */ + public boolean isSecureByDefault() { + return secureByDefault; + } + + /** + * Base url of a server, e.g. http://localhost:6333 + */ + public String getUrl(String hostname, int port) { + return String.format("%s://%s:%d", secureByDefault ? "https" : "http", hostname, + port > 0 ? port : defaultPort); + } + + public VdbClient createClient(VdbSource source) { + return clientFactory.apply(source); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VdbValues.java b/src/main/java/com/github/sqljam/vdb/VdbValues.java new file mode 100644 index 0000000..9de4996 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VdbValues.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.vdb; + +import java.io.IOException; +import java.util.Base64; +import java.util.Map; +import java.util.TreeMap; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.sqljam.utils.FloatUtils; +import com.github.sqljam.utils.RestClient; +import lombok.experimental.UtilityClass; + +/** + * @Description: VdbValues converts the values of vector databases into the values of records: vectors as text + * (dense [1.0,2.5,-3.0], sparse {1:1.5,3:2.0}/5 with indexes from 1), binaries of 16-bit floats + * (Float16Vector and BFloat16Vector of Milvus, base64) as floats, JSON as text. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@UtilityClass +public class VdbValues { + + /** + * Text of a dense vector, e.g. [1.0,2.5,-3.0] + */ + public String toDenseText(float[] values) { + StringBuilder text = new StringBuilder(values.length * 12 + 2).append('['); + for (int i = 0; i < values.length; i++) { + text.append(i > 0 ? "," : "").append(values[i]); + } + return text.append(']').toString(); + } + + /** + * Text of a dense vector of a JSON array of numbers, null if it is not one + */ + public String toDenseText(JsonNode array) { + if (array == null || !array.isArray() || array.size() == 0 || !array.get(0).isNumber()) { + return null; + } + float[] values = new float[array.size()]; + for (int i = 0; i < values.length; i++) { + values[i] = array.get(i).floatValue(); + } + return toDenseText(values); + } + + /** + * Text of a vector of bytes of unsigned integers, e.g. a binary vector of 16 bits [255,0] + */ + public String toByteText(byte[] bytes) { + StringBuilder text = new StringBuilder(bytes.length * 4 + 2).append('['); + for (int i = 0; i < bytes.length; i++) { + text.append(i > 0 ? "," : "").append(bytes[i] & 0xFF); + } + return text.append(']').toString(); + } + + /** + * Text of a sparse vector by its elements of indexes from 0 + * + * @param dimensions dimensions of the vector, larger than every index + */ + public String toSparseText(Map elements, long dimensions) { + StringBuilder text = new StringBuilder("{"); + new TreeMap<>(elements).forEach((index, value) -> text.append(text.length() > 1 ? "," : "") + .append(index + 1).append(':').append(value)); + return text.append("}/").append(dimensions).toString(); + } + + /** + * Elements of a sparse vector of Qdrant and Pinecone: {"indices": [..], "values": [..]} + */ + public Map getSparseElements(JsonNode indices, JsonNode values) { + Map elements = new TreeMap<>(); + if (indices != null && values != null) { + for (int i = 0; i < Math.min(indices.size(), values.size()); i++) { + elements.put(indices.get(i).asLong(), values.get(i).floatValue()); + } + } + return elements; + } + + /** + * Elements of a sparse vector of Milvus: {"index": value} + */ + public Map getSparseElements(JsonNode object) { + Map elements = new TreeMap<>(); + if (object != null && object.isObject()) { + object.fields().forEachRemaining(entry -> elements.put(Long.parseLong(entry.getKey()), + entry.getValue().floatValue())); + } + return elements; + } + + /** + * Floats of a base64 binary of 16-bit floats, half precision or bfloat16 + */ + public float[] decodeHalves(String base64, boolean bfloat16) { + byte[] bytes = Base64.getDecoder().decode(base64); + return bfloat16 ? FloatUtils.bfloat16sToFloats(bytes) : FloatUtils.halvesToFloats(bytes); + } + + /** + * JSON text of a value, null of null + */ + public String toJson(Object value) { + if (value == null || (value instanceof JsonNode && ((JsonNode) value).isNull())) { + return null; + } + try { + return RestClient.getMapper().writeValueAsString(value); + } catch (IOException e) { + throw new VdbException("Unable to write JSON: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VectorIndex.java b/src/main/java/com/github/sqljam/vdb/VectorIndex.java new file mode 100644 index 0000000..284787e --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VectorIndex.java @@ -0,0 +1,125 @@ +/* + * 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.vdb; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import org.apache.commons.lang3.StringUtils; + +/** + * @Description: VectorIndex of the vector line (copied from the SQL line, the two lines do not share code) is an + * index for the nearest neighbors of a vector column: its kind (HNSW graph or IVF + * partitions), the distance which it is built for and its options (m, ef_construction of HNSW, lists of + * IVF). It is kept in the details of an index as text, e.g. HNSW,COSINE,m=16;ef_construction=64. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class VectorIndex { + + /** + * Key of the vector index in the details of an index + */ + public static final String DETAIL_KEY = "VECTOR_INDEX"; + + public enum Kind { + HNSW, IVF + } + + public enum Distance { + EUCLIDEAN, COSINE, DOT, MANHATTAN, HAMMING, JACCARD + } + + private final Kind kind; + private final Distance distance; + private final Map options; + + public VectorIndex(Kind kind, Distance distance, Map options) { + this.kind = Objects.requireNonNull(kind); + this.distance = Objects.requireNonNull(distance); + this.options = options != null ? new LinkedHashMap<>(options) : new LinkedHashMap<>(); + } + + public Kind getKind() { + return kind; + } + + public Distance getDistance() { + return distance; + } + + /** + * Options of the index by their names in lower case, e.g. m, ef_construction, lists + */ + public Map getOptions() { + return options; + } + + public String getOption(String name) { + return options.get(name.toLowerCase(Locale.ENGLISH)); + } + + /** + * Vector index of the details of an index, null if the index is not a vector index + */ + public static VectorIndex of(Map detail) { + Object value = detail != null ? detail.get(DETAIL_KEY) : null; + return value != null ? parse(value.toString()) : null; + } + + public static VectorIndex parse(String text) { + String[] parts = StringUtils.defaultString(text).split(",", 3); + if (parts.length < 2) { + return null; + } + try { + Map options = new LinkedHashMap<>(); + if (parts.length == 3) { + for (String option : parts[2].split(";")) { + String[] pair = option.split("=", 2); + if (pair.length == 2 && StringUtils.isNotBlank(pair[0])) { + options.put(pair[0].trim().toLowerCase(Locale.ENGLISH), pair[1].trim()); + } + } + } + return new VectorIndex(Kind.valueOf(parts[0].trim().toUpperCase(Locale.ENGLISH)), + Distance.valueOf(parts[1].trim().toUpperCase(Locale.ENGLISH)), options); + } catch (IllegalArgumentException e) { + return null; + } + } + + @Override + public String toString() { + StringBuilder text = new StringBuilder(kind + "," + distance + ","); + options.forEach((name, value) -> text.append(text.charAt(text.length() - 1) == ',' ? "" : ";").append(name) + .append('=').append(value)); + return text.toString(); + } + + @Override + public boolean equals(Object other) { + return other instanceof VectorIndex && toString().equals(other.toString()); + } + + @Override + public int hashCode() { + return toString().hashCode(); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/VectorType.java b/src/main/java/com/github/sqljam/vdb/VectorType.java new file mode 100644 index 0000000..e9fc605 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/VectorType.java @@ -0,0 +1,306 @@ +/* + * 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.vdb; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import org.apache.commons.lang3.StringUtils; + +/** + * @Description: VectorType of the vector line (copied from the SQL line, the two lines do not share code) is the + * type of a vector column of any database: the type of its elements, the number of + * its dimensions (0 if it is not fixed) and whether it is sparse. It is kept in the column metadata as + * text (FLOAT32,1024,DENSE), so that it is written into the manifest of packages like other details. + * Vectors are passed between databases as text: dense [1,2.5,-3], sparse of PostgreSQL {1:1.5,3:2}/5 + * (indexes from 1) and sparse of Oracle [5,[0,2],[1.5,2]] (indexes from 0). + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class VectorType { + + /** + * Key of the vector type in the details of a column + */ + public static final String DETAIL_KEY = "VECTOR_TYPE"; + + /** + * Type of the elements of a vector + */ + public enum Element { + FLOAT16, FLOAT32, FLOAT64, INT8, BINARY + } + + private final Element element; + private final int dimensions; + private final boolean sparse; + + public VectorType(Element element, int dimensions, boolean sparse) { + this.element = Objects.requireNonNull(element); + this.dimensions = Math.max(dimensions, 0); + this.sparse = sparse; + } + + public static VectorType dense(Element element, int dimensions) { + return new VectorType(element, dimensions, false); + } + + public Element getElement() { + return element; + } + + /** + * Number of dimensions, 0 if the column takes vectors of any dimensions + */ + public int getDimensions() { + return dimensions; + } + + public boolean isSparse() { + return sparse; + } + + /** + * Whether the elements are exact as 32-bit floats: half precision floats and bytes are, doubles and bits are not + */ + public boolean isFloat32Compatible() { + return element == Element.FLOAT16 || element == Element.FLOAT32 || element == Element.INT8; + } + + /** + * Vector type of the details of a column, null if the column is not a vector + */ + public static VectorType of(Map detail) { + Object value = detail != null ? detail.get(DETAIL_KEY) : null; + return value != null ? parse(value.toString()) : null; + } + + /** + * Vector type of its text, e.g. FLOAT32,1024,DENSE, null if the text is blank or invalid + */ + public static VectorType parse(String text) { + String[] parts = StringUtils.defaultString(text).split(","); + if (parts.length != 3) { + return null; + } + try { + return new VectorType(Element.valueOf(parts[0].trim().toUpperCase(Locale.ENGLISH)), + Integer.parseInt(parts[1].trim()), "SPARSE".equalsIgnoreCase(parts[2].trim())); + } catch (IllegalArgumentException e) { + return null; + } + } + + @Override + public String toString() { + return element + "," + dimensions + "," + (sparse ? "SPARSE" : "DENSE"); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof VectorType)) { + return false; + } + VectorType type = (VectorType) other; + return element == type.element && dimensions == type.dimensions && sparse == type.sparse; + } + + @Override + public int hashCode() { + return Objects.hash(element, dimensions, sparse); + } + + // ------------------------------------------------------------------ + // Values + // ------------------------------------------------------------------ + + /** + * Elements of a vector by its text, numbers are kept as they are written. Null if the text is not a vector. + */ + public static Values parseValues(String text) { + String value = StringUtils.trimToEmpty(text); + if (value.startsWith("{")) { + return parsePostgreSqlSparse(value); + } + if (!value.startsWith("[") || !value.endsWith("]")) { + return null; + } + String body = value.substring(1, value.length() - 1).trim(); + if (body.contains("[")) { + return parseOracleSparse(body); + } + String[] items = body.isEmpty() ? new String[0] : body.split(","); + String[] numbers = new String[items.length]; + for (int i = 0; i < items.length; i++) { + numbers[i] = items[i].trim(); + } + return new Values(numbers.length, null, numbers); + } + + /** + * {1:1.5,3:2}/5 of pgvector, indexes start from 1 + */ + private static Values parsePostgreSqlSparse(String text) { + int slash = text.lastIndexOf("}/"); + if (slash < 0) { + return null; + } + try { + int dimensions = Integer.parseInt(text.substring(slash + 2).trim()); + String body = text.substring(1, slash).trim(); + List indexes = new ArrayList<>(); + List numbers = new ArrayList<>(); + for (String item : body.isEmpty() ? new String[0] : body.split(",")) { + String[] pair = item.split(":"); + indexes.add(Integer.parseInt(pair[0].trim()) - 1); + numbers.add(pair[1].trim()); + } + return new Values(dimensions, indexes.stream().mapToInt(Integer::intValue).toArray(), + numbers.toArray(new String[0])); + } catch (RuntimeException e) { + return null; + } + } + + /** + * [5,[0,2],[1.5,2]] of Oracle, indexes start from 0 + */ + private static Values parseOracleSparse(String body) { + int first = body.indexOf('['); + int second = body.indexOf('[', first + 1); + if (first < 0 || second < 0) { + return null; + } + try { + int dimensions = Integer.parseInt(StringUtils.removeEnd(body.substring(0, first).trim(), ",").trim()); + String indexText = StringUtils.substringBetween(body.substring(first), "[", "]").trim(); + String numberText = StringUtils.substringBetween(body.substring(second), "[", "]").trim(); + int[] indexes = indexText.isEmpty() ? new int[0] : Arrays.stream(indexText.split(",")) + .mapToInt(index -> Integer.parseInt(index.trim())).toArray(); + String[] numbers = numberText.isEmpty() ? new String[0] : Arrays.stream(numberText.split(",")) + .map(String::trim).toArray(String[]::new); + return indexes.length == numbers.length ? new Values(dimensions, indexes, numbers) : null; + } catch (RuntimeException e) { + return null; + } + } + + /** + * Elements of a dense or sparse vector + */ + public static final class Values { + + private final int dimensions; + private final int[] indexes; + private final String[] numbers; + + Values(int dimensions, int[] indexes, String[] numbers) { + this.dimensions = dimensions; + this.indexes = indexes; + this.numbers = numbers; + } + + public int getDimensions() { + return dimensions; + } + + public boolean isSparse() { + return indexes != null; + } + + /** + * All elements, zeros of a sparse vector included + */ + public String[] getDenseNumbers() { + if (indexes == null) { + return numbers.clone(); + } + String[] dense = new String[dimensions]; + Arrays.fill(dense, "0"); + for (int i = 0; i < indexes.length; i++) { + dense[indexes[i]] = numbers[i]; + } + return dense; + } + + /** + * [1,2.5,-3] + */ + public String toDenseText() { + return "[" + String.join(",", getDenseNumbers()) + "]"; + } + + /** + * {1:1.5,3:2}/5 of pgvector, zeros of a dense vector are left out + */ + public String toPostgreSqlSparseText() { + StringBuilder text = new StringBuilder("{"); + forEachNonZero((index, number) -> text.append(text.length() > 1 ? "," : "").append(index + 1).append(':') + .append(number)); + return text.append("}/").append(dimensions).toString(); + } + + /** + * [5,[0,2],[1.5,2]] of Oracle, zeros of a dense vector are left out + */ + public String toOracleSparseText() { + List indexTexts = new ArrayList<>(); + List numberTexts = new ArrayList<>(); + forEachNonZero((index, number) -> { + indexTexts.add(String.valueOf(index)); + numberTexts.add(number); + }); + return "[" + dimensions + ",[" + String.join(",", indexTexts) + "],[" + String.join(",", numberTexts) + + "]]"; + } + + /** + * Numbers as floats, zeros of a sparse vector included + */ + public float[] toFloats() { + String[] dense = getDenseNumbers(); + float[] floats = new float[dense.length]; + for (int i = 0; i < dense.length; i++) { + floats[i] = Float.parseFloat(dense[i]); + } + return floats; + } + + private void forEachNonZero(NumberConsumer consumer) { + if (indexes != null) { + for (int i = 0; i < indexes.length; i++) { + consumer.accept(indexes[i], numbers[i]); + } + return; + } + for (int i = 0; i < numbers.length; i++) { + if (Double.parseDouble(numbers[i]) != 0) { + consumer.accept(i, numbers[i]); + } + } + } + + @FunctionalInterface + private interface NumberConsumer { + void accept(int index, String number); + } + } +} diff --git a/src/main/java/com/github/sqljam/vdb/client/AbstractVdbClient.java b/src/main/java/com/github/sqljam/vdb/client/AbstractVdbClient.java new file mode 100644 index 0000000..e4fa704 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/client/AbstractVdbClient.java @@ -0,0 +1,228 @@ +/* + * 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.vdb.client; + +import java.io.IOException; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.sqljam.utils.RestClient; +import com.github.sqljam.vdb.VdbClient; +import com.github.sqljam.vdb.VdbCollection; +import com.github.sqljam.vdb.VdbException; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VdbInfo; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VdbSource; +import com.github.sqljam.vdb.VdbType; +import com.github.sqljam.vdb.VdbValues; + +/** + * @Description: AbstractVdbClient is the REST client of a vector database with the records of its collections as + * rows: the id and the vectors are columns, the top-level fields of the payload are columns of their + * types (a value which does not fit is NULL there), nested fields are JSON, and the whole payload is a + * JSON column which keeps every value. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public abstract class AbstractVdbClient implements VdbClient { + + protected final VdbSource source; + protected final RestClient rest; + + protected AbstractVdbClient(VdbSource source) { + this.source = source; + this.rest = new RestClient(source.getUrl(), source.isCertificateTrusted(), source.getTimeoutSeconds()); + rest.accessDeniedHint(getAccessDeniedHint(source)); + } + + /** + * Hint of a refused request: credentials are missing, or they are refused or may not read + */ + static String getAccessDeniedHint(VdbSource source) { + return source.hasCredentials() ? "the API key, or the user and the password, are refused or may not read" + : "no API key or user is given, the server requires credentials"; + } + + /** + * Overview of the defaults with the url and the authentication of the source + */ + @Override + public Map> getOverview() { + Map> sections = VdbClient.super.getOverview(); + Map server = sections.get("Server"); + server.put("URL", source.getUrl()); + server.put("Authentication", StringUtils.isNotBlank(source.getApiKey()) ? "API key" + : StringUtils.isNotBlank(source.getUsername()) ? "User " + source.getUsername() : "None"); + if (StringUtils.isNotBlank(source.getDatabase())) { + server.put("Database", source.getDatabase()); + } + return sections; + } + + @Override + public Map> getHealth() { + return VdbClient.super.getHealth(); + } + + /** + * Section of the overview or of the health, created when it is missing + */ + protected static Map section(Map> sections, String name) { + return sections.computeIfAbsent(name, key -> new LinkedHashMap<>()); + } + + @Override + public VdbType getType() { + return source.getType(); + } + + protected static String encode(String name) { + return java.net.URLEncoder.encode(name, java.nio.charset.StandardCharsets.UTF_8).replace("+", "%20"); + } + + /** + * Adds the column of the whole payload + */ + protected static void addPayloadField(VdbCollection collection) { + VdbField payload = new VdbField(collection.getUnusedName(VdbField.PAYLOAD), VdbField.Kind.JSON); + payload.setSourceName(VdbField.PAYLOAD); + payload.setRemarks("Payload of the record as JSON"); + collection.addField(payload); + } + + /** + * Row of a record: the values of the id and of the vectors, the scalar columns taken from the payload, the + * payload as JSON + * + * @param values values of the id, of the vectors and of other columns, by the names of the columns + * @param payload fields of the record which are not its id and its vectors, a JSON object, or null + */ + protected static Map toRow(VdbCollection collection, Map values, + JsonNode payload) { + Map row = new LinkedHashMap<>(); + for (VdbField field : collection.getFields()) { + if (values.containsKey(field.getName())) { + row.put(field.getName(), values.get(field.getName())); + } else if (field.getKind() == VdbField.Kind.JSON && VdbField.PAYLOAD.equals(field.getSourceName())) { + row.put(field.getName(), payload != null && payload.isObject() && payload.size() > 0 + ? VdbValues.toJson(payload) : null); + } else if (!field.isVector() && !field.isPrimaryKey()) { + row.put(field.getName(), payload != null ? toValue(payload.get(field.getSourceName()), field) : null); + } else { + row.put(field.getName(), null); + } + } + return row; + } + + /** + * Value of a JSON value for a column, NULL if it does not fit, e.g. an array of a number column + */ + protected static Object toValue(JsonNode value, VdbField field) { + if (value == null || value.isNull() || value.isMissingNode()) { + return null; + } + switch (field.getKind()) { + case BOOLEAN: + return value.isBoolean() ? value.booleanValue() : null; + case SMALLINT: + case INTEGER: + return value.isIntegralNumber() && value.canConvertToInt() ? value.intValue() : null; + case BIGINT: + return value.isIntegralNumber() && value.canConvertToLong() ? value.longValue() : null; + case REAL: + return value.isNumber() ? value.floatValue() : null; + case DOUBLE: + return value.isNumber() ? value.doubleValue() : null; + case VARCHAR: + case TEXT: + // Numbers and booleans of a field of more types are text + return value.isValueNode() ? value.asText() : VdbValues.toJson(value); + case TIMESTAMP: + try { + return value.isTextual() ? OffsetDateTime.parse(value.textValue()) : null; + } catch (DateTimeParseException e) { + return null; + } + case BINARY: + try { + return value.isTextual() ? Base64.getDecoder().decode(value.textValue()) : null; + } catch (IllegalArgumentException e) { + return null; + } + case JSON: + return VdbValues.toJson(value); + default: + return value.isValueNode() ? value.asText() : VdbValues.toJson(value); + } + } + + /** + * Text of a JSON node of a response, null if it is missing + */ + protected static String text(JsonNode node, String field) { + JsonNode value = node != null ? node.get(field) : null; + return value != null && !value.isNull() ? value.asText() : null; + } + + protected static String required(String value, String name) { + if (StringUtils.isBlank(value)) { + throw new IllegalArgumentException(name + " must be given"); + } + return value; + } + + /** + * Filter of operators ($eq, $gte, $in ...) of Pinecone and Chroma: the native filter as JSON, or the conditions + * joined by $and, null without conditions + */ + protected static JsonNode toOperatorFilter(VdbCollection collection, VdbQuery query, String database) { + if (query.isNative()) { + try { + return RestClient.getMapper().readTree(query.getNativeFilter()); + } catch (IOException e) { + throw new VdbException("Filter of " + database + " is not JSON: " + e.getMessage(), e); + } + } + if (query.getConditions().isEmpty()) { + return null; + } + ArrayNode and = RestClient.getMapper().createArrayNode(); + for (VdbQuery.Condition condition : query.getConditions()) { + String name = collection.findField(condition.getField()).map(VdbField::getSourceName) + .orElse(condition.getField()); + String operator = "$" + condition.getOperator().name().toLowerCase(Locale.ENGLISH) + .replace("ge", "gte").replace("le", "lte"); + and.addObject().putObject(name).set(operator, RestClient.getMapper().valueToTree(condition.getValue())); + } + if (and.size() == 1) { + return and.get(0); + } + ObjectNode filter = RestClient.getMapper().createObjectNode(); + filter.set("$and", and); + return filter; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/client/ChromaClient.java b/src/main/java/com/github/sqljam/vdb/client/ChromaClient.java new file mode 100644 index 0000000..3ed3e7b --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/client/ChromaClient.java @@ -0,0 +1,311 @@ +/* + * 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.vdb.client; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.sqljam.utils.RestClient; +import com.github.sqljam.vdb.PayloadSchema; +import com.github.sqljam.vdb.VdbCollection; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VdbInfo; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VdbSource; +import com.github.sqljam.vdb.VdbValues; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: ChromaClient reads Chroma 1.0 and later by the REST API v2 with a token, in a tenant and a database + * (default_tenant and default_database by default). A collection is a table of the id, the embedding + * of its dimension and HNSW space (cosine, l2, ip), the document and the fields of the metadata. + * Records are read by offsets, filters are where filters of Chroma, searches are queries. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class ChromaClient extends AbstractVdbClient { + + static final String DEFAULT_TENANT = "default_tenant"; + static final String DEFAULT_DATABASE = "default_database"; + static final String EMBEDDING = "embedding"; + static final String DOCUMENT = "document"; + static final String ID = "collectionId"; + private static final int SCAN_PAGE_SIZE = 1000; + /** + * Collections of a page of the list + */ + private static final int LIST_PAGE_SIZE = 100; + + private final String basePath; + + public ChromaClient(VdbSource source) { + super(source); + if (StringUtils.isNotBlank(source.getApiKey())) { + rest.header("Authorization", "Bearer " + source.getApiKey()); + } + basePath = "/api/v2/tenants/" + encode(StringUtils.defaultIfBlank(source.getTenant(), DEFAULT_TENANT)) + + "/databases/" + encode(StringUtils.defaultIfBlank(source.getDatabase(), DEFAULT_DATABASE)); + } + + /** + * Version of the API, Chroma tells no version of its server + */ + @Override + public String getVersion() { + return rest.get("/api/v2/version").asText(); + } + + /** + * Heartbeat and the limits of the server, the tenant and databases of the credentials + */ + @Override + public Map> getHealth() { + Map> sections = super.getHealth(); + Map health = section(sections, "Chroma"); + health.put("Heartbeat", VdbInfo.read(() -> rest.get("/api/v2/heartbeat").elements().hasNext() + ? "Yes" : "No")); + health.put("Max batch size", VdbInfo.read(() -> text(rest.get("/api/v2/pre-flight-checks"), + "max_batch_size"))); + health.put("Identity", VdbInfo.read(() -> { + JsonNode identity = rest.get("/api/v2/auth/identity"); + return "tenant " + text(identity, "tenant") + ", databases " + identity.path("databases"); + })); + return sections; + } + + /** + * Databases of the tenant + */ + @Override + public List getDatabases() { + List names = new ArrayList<>(); + rest.get("/api/v2/tenants/" + encode(StringUtils.defaultIfBlank(source.getTenant(), DEFAULT_TENANT)) + + "/databases").forEach(database -> names.add(text(database, "name"))); + return names; + } + + @Override + public List getCollections() { + List names = new ArrayList<>(); + for (int offset = 0; ; offset += LIST_PAGE_SIZE) { + JsonNode page = rest.get(basePath + "/collections?limit=" + LIST_PAGE_SIZE + "&offset=" + offset); + page.forEach(node -> names.add(text(node, "name"))); + if (page.size() < LIST_PAGE_SIZE) { + return names; + } + } + } + + @Override + public VdbCollection describe(String name) { + JsonNode node = rest.get(basePath + "/collections/" + encode(name)); + VdbCollection collection = new VdbCollection(name); + String id = required(text(node, "id"), "Id of the collection " + name); + collection.getProperties().put(ID, id); + collection.setRemarks(StringUtils.trimToNull(text(node.path("metadata"), "description"))); + VdbField idField = VdbField.varchar("id", 512); + idField.setPrimaryKey(true); + idField.setNullable(false); + collection.addField(idField); + + Scan scan = scan(id); + int dimensions = node.path("dimension").asInt(0) > 0 ? node.path("dimension").asInt() : scan.dimensions; + VdbField embedding = VdbField.vector(EMBEDDING, VectorType.dense(VectorType.Element.FLOAT32, dimensions)); + embedding.setVectorIndex(toVectorIndex(node.path("configuration_json"))); + embedding.setNullable(scan.missingEmbeddings); + collection.addField(embedding); + VdbField document = new VdbField(DOCUMENT, VdbField.Kind.TEXT); + document.setNullable(true); + collection.addField(document); + collection.getFields().addAll(scan.metadata.getFields(collection)); + addPayloadField(collection); + collection.setCount(scan.records); + return collection; + } + + /** + * HNSW index of the configuration, or of SPANN of Chroma Cloud + */ + static VectorIndex toVectorIndex(JsonNode configuration) { + JsonNode hnsw = configuration.path("hnsw"); + JsonNode spann = configuration.path("spann"); + String space = StringUtils.defaultIfBlank(text(hnsw, "space"), text(spann, "space")); + VectorIndex.Distance distance = toDistance(StringUtils.defaultIfBlank(space, "l2")); + if (distance == null) { + return null; + } + Map options = new LinkedHashMap<>(); + if (hnsw.path("max_neighbors").asInt(0) > 0) { + options.put("m", hnsw.path("max_neighbors").asText()); + } + if (hnsw.path("ef_construction").asInt(0) > 0) { + options.put("ef_construction", hnsw.path("ef_construction").asText()); + } + return new VectorIndex(VectorIndex.Kind.HNSW, distance, options); + } + + static VectorIndex.Distance toDistance(String space) { + switch (StringUtils.defaultString(space).toLowerCase(Locale.ENGLISH)) { + case "cosine": + return VectorIndex.Distance.COSINE; + case "l2": + return VectorIndex.Distance.EUCLIDEAN; + case "ip": + return VectorIndex.Distance.DOT; + default: + return null; + } + } + + /** + * What the records tell of the collection: the types of the metadata fields, the dimensions of the embeddings, + * the number of records + */ + static final class Scan { + final PayloadSchema metadata = new PayloadSchema(); + int dimensions; + boolean missingEmbeddings; + long records; + } + + private Scan scan(String id) { + Scan scan = new Scan(); + for (int offset = 0; ; offset += SCAN_PAGE_SIZE) { + ObjectNode body = RestClient.getMapper().createObjectNode().put("limit", SCAN_PAGE_SIZE) + .put("offset", offset); + // Embeddings of the first page only, for the dimensions + ArrayNode include = body.putArray("include").add("metadatas"); + if (offset == 0) { + include.add("embeddings"); + } + JsonNode page = rest.post(basePath + "/collections/" + id + "/get", body); + JsonNode metadatas = page.path("metadatas"); + for (int i = 0; i < page.path("ids").size(); i++) { + scan.records++; + scan.metadata.observe(metadatas.path(i)); + JsonNode embedding = page.path("embeddings").path(i); + if (offset == 0) { + if (embedding.isArray() && embedding.size() > 0) { + scan.dimensions = Math.max(scan.dimensions, embedding.size()); + } else { + scan.missingEmbeddings = true; + } + } + } + if (page.path("ids").size() < SCAN_PAGE_SIZE) { + return scan; + } + } + } + + private String getPath(VdbCollection collection, String operation) { + return basePath + "/collections/" + collection.getProperties().get(ID) + "/" + operation; + } + + @Override + public long count(VdbCollection collection, VdbQuery query) { + if (query.isNearest()) { + return query.getLimit(); + } + long count; + if (query.isFiltered()) { + ObjectNode body = RestClient.getMapper().createObjectNode(); + body.set("where", toOperatorFilter(collection, query, "Chroma")); + body.putArray("include"); + count = rest.post(getPath(collection, "get"), body).path("ids").size(); + } else { + count = rest.get(getPath(collection, "count")).asLong(); + } + return query.getLimit() > 0 ? Math.min(count, query.getLimit()) : count; + } + + @Override + public void read(VdbCollection collection, VdbQuery query, int pageSize, PageConsumer consumer) { + JsonNode where = toOperatorFilter(collection, query, "Chroma"); + if (query.isNearest()) { + consumer.accept(search(collection, query, where, pageSize), true); + return; + } + long remaining = query.getLimit() > 0 ? query.getLimit() : Long.MAX_VALUE; + for (long offset = 0; remaining > 0; ) { + int size = (int) Math.min(pageSize, remaining); + ObjectNode body = RestClient.getMapper().createObjectNode().put("limit", size).put("offset", offset); + body.putArray("include").add("embeddings").add("metadatas").add("documents"); + if (where != null) { + body.set("where", where); + } + JsonNode page = rest.post(getPath(collection, "get"), body); + List> rows = new ArrayList<>(); + for (int i = 0; i < page.path("ids").size(); i++) { + rows.add(toRow(collection, page.path("ids").path(i), page.path("embeddings").path(i), + page.path("documents").path(i), page.path("metadatas").path(i))); + } + offset += rows.size(); + remaining -= rows.size(); + boolean last = rows.size() < size || remaining <= 0; + if (!consumer.accept(rows, last) || last) { + return; + } + } + } + + private List> search(VdbCollection collection, VdbQuery query, JsonNode where, + int pageSize) { + ObjectNode body = RestClient.getMapper().createObjectNode().put("n_results", query.getLimit() > 0 + ? (int) query.getLimit() : pageSize); + ArrayNode vector = body.putArray("query_embeddings").addArray(); + if (query.getNearestVector() != null) { + for (float value : query.getNearestVector()) { + vector.add(value); + } + } else { + ObjectNode get = RestClient.getMapper().createObjectNode(); + get.putArray("ids").add(String.valueOf(query.getNearestId())); + get.putArray("include").add("embeddings"); + vector.addAll((ArrayNode) rest.post(getPath(collection, "get"), get).path("embeddings").path(0)); + } + body.putArray("include").add("embeddings").add("metadatas").add("documents"); + if (where != null) { + body.set("where", where); + } + JsonNode result = rest.post(getPath(collection, "query"), body); + List> rows = new ArrayList<>(); + JsonNode ids = result.path("ids").path(0); + for (int i = 0; i < ids.size(); i++) { + rows.add(toRow(collection, ids.path(i), result.path("embeddings").path(0).path(i), + result.path("documents").path(0).path(i), result.path("metadatas").path(0).path(i))); + } + return rows; + } + + private Map toRow(VdbCollection collection, JsonNode id, JsonNode embedding, JsonNode document, + JsonNode metadata) { + Map values = new LinkedHashMap<>(); + values.put("id", id.asText()); + values.put(EMBEDDING, embedding.isArray() && embedding.size() > 0 ? VdbValues.toDenseText(embedding) : null); + values.put(DOCUMENT, document.isTextual() ? document.textValue() : null); + return toRow(collection, values, metadata.isObject() ? metadata : null); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/client/ElasticsearchClient.java b/src/main/java/com/github/sqljam/vdb/client/ElasticsearchClient.java new file mode 100644 index 0000000..8610e43 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/client/ElasticsearchClient.java @@ -0,0 +1,470 @@ +/* + * 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.vdb.client; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.sqljam.utils.RestClient; +import com.github.sqljam.vdb.VdbCollection; +import com.github.sqljam.vdb.VdbException; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VdbInfo; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VdbSource; +import com.github.sqljam.vdb.VdbValues; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: ElasticsearchClient reads Elasticsearch 8.0 and later by its REST API with a user and a password or + * an API key. An index is a collection: _id is the id, dense_vector fields are vectors of their + * similarity and HNSW options, the top-level fields of the mapping are columns (any field may hold + * an array, which is NULL in its column and kept by the payload). Documents are read in a point in + * time by search_after. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class ElasticsearchClient extends AbstractVdbClient { + + static final String ID = "_id"; + /** + * Length of _id at most + */ + static final int MAX_ID_LENGTH = 512; + static final String KEEP_ALIVE = "5m"; + + public ElasticsearchClient(VdbSource source) { + super(source); + if (StringUtils.isNotBlank(source.getApiKey())) { + rest.header("Authorization", "ApiKey " + source.getApiKey()); + } else if (StringUtils.isNotBlank(source.getUsername())) { + rest.header("Authorization", "Basic " + Base64.getEncoder().encodeToString((source.getUsername() + ":" + + StringUtils.defaultString(source.getPassword())).getBytes(StandardCharsets.UTF_8))); + } + } + + @Override + public String getVersion() { + return text(rest.get("/").path("version"), "number"); + } + + /** + * Indexes which are not hidden or of the system (names of a dot) + */ + @Override + public Map> getOverview() { + Map> sections = super.getOverview(); + Map server = section(sections, "Server"); + server.put("Cluster", VdbInfo.read(() -> text(rest.get("/"), "cluster_name"))); + server.put("Lucene", VdbInfo.read(() -> text(rest.get("/").path("version"), "lucene_version"))); + Map data = section(sections, "Data"); + data.put("Documents", VdbInfo.read(() -> rest.get("/_cluster/stats").path("indices").path("docs") + .path("count").asText())); + data.put("Store size", VdbInfo.read(() -> VdbInfo.bytes(rest.get("/_cluster/stats").path("indices") + .path("store").path("size_in_bytes").asLong()))); + return sections; + } + + /** + * Health of the cluster: green, yellow or red, its nodes and shards + */ + @Override + public Map> getHealth() { + Map> sections = super.getHealth(); + Map cluster = section(sections, "Cluster"); + String error = VdbInfo.read(() -> { + JsonNode health = rest.get("/_cluster/health"); + cluster.put("Status", text(health, "status")); + cluster.put("Nodes", health.path("number_of_nodes").asText() + ", " + health.path( + "number_of_data_nodes").asText() + " data nodes"); + cluster.put("Active shards", health.path("active_shards").asText() + " of which " + + health.path("active_primary_shards").asText() + " primary"); + cluster.put("Unassigned shards", health.path("unassigned_shards").asText()); + cluster.put("Pending tasks", health.path("number_of_pending_tasks").asText()); + return ""; + }); + if (error.startsWith(VdbInfo.UNAVAILABLE)) { + cluster.put("Status", error); + } + return sections; + } + + @Override + public List getCollections() { + List names = new ArrayList<>(); + for (JsonNode index : rest.get("/_cat/indices?format=json&h=index")) { + String name = text(index, "index"); + if (name != null && !isSystemIndex(name)) { + names.add(name); + } + } + names.sort(String::compareTo); + return names; + } + + /** + * Whether an index is of the server, hidden indexes start with a dot + */ + protected boolean isSystemIndex(String name) { + return name.startsWith("."); + } + + @Override + public VdbCollection describe(String name) { + JsonNode mappings = rest.get("/" + encode(name) + "/_mapping"); + JsonNode properties = mappings.path(name).path("mappings").path("properties"); + if (properties.isMissingNode() && mappings.size() == 1) { + // An alias answers with the name of its index + properties = mappings.elements().next().path("mappings").path("properties"); + } + VdbCollection collection = new VdbCollection(name); + VdbField id = VdbField.varchar("id", MAX_ID_LENGTH); + id.setSourceName(ID); + id.setPrimaryKey(true); + id.setNullable(false); + collection.addField(id); + properties.fields().forEachRemaining(entry -> { + VdbField field = toField(collection.getUnusedName(entry.getKey()), entry.getValue()); + field.setSourceName(entry.getKey()); + collection.addField(field); + }); + addPayloadField(collection); + collection.setCount(count(collection, VdbQuery.all())); + return collection; + } + + /** + * Column of a field of the mapping + */ + VdbField toField(String name, JsonNode mapping) { + String type = StringUtils.defaultString(text(mapping, "type"), mapping.has("properties") ? "object" : ""); + if (isVectorType(type)) { + return toVectorField(name, mapping); + } + switch (type) { + case "boolean": + return new VdbField(name, VdbField.Kind.BOOLEAN); + case "byte": + case "short": + return new VdbField(name, VdbField.Kind.SMALLINT); + case "integer": + return new VdbField(name, VdbField.Kind.INTEGER); + case "long": + case "unsigned_long": + return new VdbField(name, VdbField.Kind.BIGINT); + case "float": + case "half_float": + return new VdbField(name, VdbField.Kind.REAL); + case "double": + case "scaled_float": + return new VdbField(name, VdbField.Kind.DOUBLE); + case "keyword": + case "constant_keyword": + case "wildcard": + case "text": + case "match_only_text": + case "ip": + case "version": + case "date": + case "date_nanos": + // Dates of a mapping take formats of their own, they are text + return new VdbField(name, VdbField.Kind.TEXT); + case "binary": + return new VdbField(name, VdbField.Kind.BINARY); + default: + // Objects, nested documents, geo shapes, ranges, sparse vectors of rank features + return new VdbField(name, VdbField.Kind.JSON); + } + } + + protected boolean isVectorType(String type) { + return "dense_vector".equals(type); + } + + /** + * dense_vector of its element type (float, byte, bit), similarity and HNSW options + */ + protected VdbField toVectorField(String name, JsonNode mapping) { + String elementType = StringUtils.defaultIfBlank(text(mapping, "element_type"), "float"); + int dimensions = mapping.path("dims").asInt(0); + VectorType.Element element = "byte".equals(elementType) ? VectorType.Element.INT8 + : "bit".equals(elementType) ? VectorType.Element.BINARY : VectorType.Element.FLOAT32; + VdbField field = VdbField.vector(name, VectorType.dense(element, dimensions)); + VectorIndex.Distance distance = toDistance(text(mapping, "similarity")); + JsonNode indexOptions = mapping.path("index_options"); + if (mapping.path("index").asBoolean(true) && distance != null) { + Map options = new LinkedHashMap<>(); + if (indexOptions.has("m")) { + options.put("m", indexOptions.path("m").asText()); + } + if (indexOptions.has("ef_construction")) { + options.put("ef_construction", indexOptions.path("ef_construction").asText()); + } + String indexType = StringUtils.defaultString(text(indexOptions, "type"), "hnsw"); + // Flat indexes are scans, HNSW of quantized vectors (int8_hnsw, bbq_hnsw) is HNSW + if (indexType.contains("hnsw")) { + field.setVectorIndex(new VectorIndex(VectorIndex.Kind.HNSW, distance, options)); + } + } + return field; + } + + static VectorIndex.Distance toDistance(String similarity) { + switch (StringUtils.defaultString(similarity).toLowerCase(Locale.ENGLISH)) { + case "cosine": + return VectorIndex.Distance.COSINE; + case "l2_norm": + return VectorIndex.Distance.EUCLIDEAN; + case "dot_product": + case "max_inner_product": + return VectorIndex.Distance.DOT; + default: + return null; + } + } + + @Override + public long count(VdbCollection collection, VdbQuery query) { + if (query.isNearest()) { + return query.getLimit(); + } + ObjectNode body = RestClient.getMapper().createObjectNode(); + JsonNode filter = toQuery(collection, query); + if (filter != null) { + body.set("query", filter); + } + long count = rest.post("/" + encode(collection.getName()) + "/_count", body).path("count").asLong(); + return query.getLimit() > 0 ? Math.min(count, query.getLimit()) : count; + } + + @Override + public void read(VdbCollection collection, VdbQuery query, int pageSize, PageConsumer consumer) { + if (query.isNearest()) { + consumer.accept(toRows(collection, rest.post("/" + encode(collection.getName()) + "/_search", + toNearestSearch(collection, query, pageSize)).path("hits").path("hits")), true); + return; + } + String pit = openPointInTime(collection.getName()); + try { + long remaining = query.getLimit() > 0 ? query.getLimit() : Long.MAX_VALUE; + JsonNode searchAfter = null; + JsonNode filter = toQuery(collection, query); + while (remaining > 0) { + int size = (int) Math.min(pageSize, remaining); + ObjectNode body = RestClient.getMapper().createObjectNode().put("size", size); + body.putObject("pit").put("id", pit).put("keep_alive", KEEP_ALIVE); + body.putArray("sort").addObject().put(getPointInTimeSort(), "asc"); + if (filter != null) { + body.set("query", filter); + } + if (searchAfter != null) { + body.set("search_after", searchAfter); + } + JsonNode response = rest.post("/_search", body); + JsonNode hits = response.path("hits").path("hits"); + String nextPit = text(response, getPointInTimeIdKey()); + if (nextPit != null) { + pit = nextPit; + } + List> rows = toRows(collection, hits); + remaining -= rows.size(); + boolean last = rows.size() < size || remaining <= 0; + if (hits.size() > 0) { + searchAfter = hits.get(hits.size() - 1).path("sort"); + } + if (!consumer.accept(rows, last) || last) { + return; + } + } + } finally { + closePointInTime(pit); + } + } + + protected String openPointInTime(String index) { + return text(rest.post("/" + encode(index) + "/_pit?keep_alive=" + KEEP_ALIVE, null), "id"); + } + + protected void closePointInTime(String pit) { + try { + rest.send("DELETE", "/_pit", RestClient.getMapper().createObjectNode().put("id", pit)); + } catch (RuntimeException e) { + // A point in time which is not closed expires + } + } + + protected String getPointInTimeSort() { + return "_shard_doc"; + } + + protected String getPointInTimeIdKey() { + return "pit_id"; + } + + /** + * Search of the nearest neighbors of a vector, or of the vector of a document + */ + protected ObjectNode toNearestSearch(VdbCollection collection, VdbQuery query, int pageSize) { + int k = query.getLimit() > 0 ? (int) query.getLimit() : pageSize; + ObjectNode body = RestClient.getMapper().createObjectNode().put("size", k); + ObjectNode knn = body.putObject("knn").put("field", sourceNameOf(collection, query.getNearestField())) + .put("k", k).put("num_candidates", Math.max(k * 10, 100)); + knn.set("query_vector", toVectorNode(nearestVector(collection, query))); + JsonNode filter = toQuery(collection, query); + if (filter != null) { + knn.set("filter", filter); + } + return body; + } + + protected static ArrayNode toVectorNode(float[] vector) { + ArrayNode node = RestClient.getMapper().createArrayNode(); + for (float value : vector) { + node.add(value); + } + return node; + } + + /** + * Vector of the query, or of the document of its id + */ + protected float[] nearestVector(VdbCollection collection, VdbQuery query) { + if (query.getNearestVector() != null) { + return query.getNearestVector(); + } + String field = sourceNameOf(collection, query.getNearestField()); + JsonNode vector = rest.get("/" + encode(collection.getName()) + "/_doc/" + encode(String.valueOf( + query.getNearestId()))).path("_source").path(field); + if (!vector.isArray()) { + throw new VdbException("Document " + query.getNearestId() + " has no vector " + field); + } + float[] values = new float[vector.size()]; + for (int i = 0; i < values.length; i++) { + values[i] = vector.get(i).floatValue(); + } + return values; + } + + protected static String sourceNameOf(VdbCollection collection, String column) { + return collection.findField(column).map(VdbField::getSourceName).orElse(column); + } + + List> toRows(VdbCollection collection, JsonNode hits) { + List> rows = new ArrayList<>(); + for (JsonNode hit : hits) { + JsonNode document = hit.path("_source"); + Map values = new LinkedHashMap<>(); + ObjectNode payload = RestClient.getMapper().createObjectNode(); + document.fields().forEachRemaining(entry -> payload.set(entry.getKey(), entry.getValue())); + for (VdbField field : collection.getFields()) { + if (field.isPrimaryKey()) { + values.put(field.getName(), text(hit, ID)); + } else if (field.isVector()) { + JsonNode vector = document.get(field.getSourceName()); + values.put(field.getName(), vector == null || vector.isNull() ? null + : field.getVectorType().getElement() == VectorType.Element.BINARY && vector.isTextual() + ? VdbValues.toByteText(hexToBytes(vector.textValue())) : VdbValues.toDenseText(vector)); + payload.remove(field.getSourceName()); + } + } + rows.add(toRow(collection, values, payload)); + } + return rows; + } + + /** + * Bytes of a bit vector of Elasticsearch, which is read as hexadecimal text + */ + static byte[] hexToBytes(String hex) { + byte[] bytes = new byte[hex.length() / 2]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return bytes; + } + + /** + * Query of the search: the native query (JSON of the query DSL), or a bool filter of the conditions + */ + protected JsonNode toQuery(VdbCollection collection, VdbQuery query) { + if (query.isNative()) { + try { + return RestClient.getMapper().readTree(query.getNativeFilter()); + } catch (IOException e) { + throw new VdbException("The query is not JSON: " + e.getMessage(), e); + } + } + if (query.getConditions().isEmpty()) { + return null; + } + ObjectNode bool = RestClient.getMapper().createObjectNode(); + ObjectNode clauses = bool.putObject("bool"); + ArrayNode filter = clauses.putArray("filter"); + ArrayNode mustNot = clauses.putArray("must_not"); + for (VdbQuery.Condition condition : query.getConditions()) { + String field = sourceNameOf(collection, condition.getField()); + JsonNode value = RestClient.getMapper().valueToTree(condition.getValue()); + ObjectNode clause = RestClient.getMapper().createObjectNode(); + switch (condition.getOperator()) { + case EQ: + clause.putObject("term").set(field, value); + filter.add(clause); + break; + case NE: + clause.putObject("term").set(field, value); + mustNot.add(clause); + break; + case IN: + clause.putObject("terms").set(field, value); + filter.add(clause); + break; + default: + clause.putObject("range").putObject(field).set(rangeOperator(condition.getOperator()), value); + filter.add(clause); + break; + } + } + if (mustNot.size() == 0) { + clauses.remove("must_not"); + } + return bool; + } + + static String rangeOperator(VdbQuery.Operator operator) { + switch (operator) { + case GT: + return "gt"; + case GE: + return "gte"; + case LT: + return "lt"; + default: + return "lte"; + } + } +} diff --git a/src/main/java/com/github/sqljam/vdb/client/MilvusClient.java b/src/main/java/com/github/sqljam/vdb/client/MilvusClient.java new file mode 100644 index 0000000..dba0185 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/client/MilvusClient.java @@ -0,0 +1,606 @@ +/* + * 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.vdb.client; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.sqljam.utils.RestClient; +import com.github.sqljam.utils.RestException; +import com.github.sqljam.vdb.VdbCollection; +import com.github.sqljam.vdb.VdbException; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VdbInfo; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VdbSource; +import com.github.sqljam.vdb.VdbValues; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: MilvusClient reads Milvus 2.4 and later by its REST API v2 with a bearer token (user:password or an + * API key). Collections have a schema: scalar fields, JSON, arrays, dense vectors (FloatVector, + * Float16Vector, BFloat16Vector, Int8Vector, BinaryVector) and sparse vectors. Fields of the dynamic + * field ($meta) are kept by the payload column. Rows are read by their primary key (id > last + * id), offset and limit of a query are limited to 16384, with the strong consistency level, so that + * the rows of the last writes are read. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class MilvusClient extends AbstractVdbClient { + + static final String PRIMARY_KEY = "primaryKey"; + static final String PRIMARY_KEY_TEXT = "primaryKeyText"; + static final String DYNAMIC = "dynamic"; + static final String MILVUS_TYPE = "milvusType"; + static final String PARTITIONS = "partitions"; + /** + * Code of a request without valid credentials + */ + static final int AUTHENTICATION_ERROR = 1800; + /** + * Rows of a query at most, offset and limit together + */ + static final int MAX_QUERY_WINDOW = 16384; + + public MilvusClient(VdbSource source) { + super(source); + String token = StringUtils.isNotBlank(source.getApiKey()) ? source.getApiKey() + : StringUtils.isNotBlank(source.getUsername()) ? source.getUsername() + ":" + + StringUtils.defaultString(source.getPassword()) : null; + rest.header("Authorization", token != null ? "Bearer " + token : null); + } + + private JsonNode call(String path, ObjectNode body) { + if (StringUtils.isNotBlank(source.getDatabase())) { + body.put("dbName", source.getDatabase()); + } + JsonNode response = rest.post("/v2/vectordb" + path, body); + int code = response.path("code").asInt(0); + String message = StringUtils.defaultString(text(response, "message")); + if (code == AUTHENTICATION_ERROR || StringUtils.containsIgnoreCase(message, "privilege") + || StringUtils.containsIgnoreCase(message, "permission denied")) { + // Errors of credentials are answered with HTTP 200 too, they are 401 and 403 like other databases + throw new RestException(String.format("Milvus %s: %s (code %d, %s)", path, message, code, + rest.getAccessDeniedHint()), code == AUTHENTICATION_ERROR ? 401 : 403, null); + } + if (code != 0) { + // Errors of Milvus are answered with HTTP 200 and a code + throw new VdbException(String.format("Milvus %s: %s (code %d)", path, text(response, "message"), code)); + } + return response.path("data"); + } + + private static ObjectNode body(String collection) { + ObjectNode body = RestClient.getMapper().createObjectNode(); + if (collection != null) { + body.put("collectionName", collection); + } + return body; + } + + /** + * Version of the server, reported by its health endpoint of the REST API + */ + @Override + public String getVersion() { + try { + JsonNode response = rest.get("/v2/vectordb/server/version"); + String version = text(response.path("data"), "version"); + if (version != null) { + return version; + } + } catch (RuntimeException e) { + // Servers without the endpoint + } + call("/collections/list", body(null)); + return ""; + } + + @Override + public List getCollections() { + List names = new ArrayList<>(); + call("/collections/list", body(null)).forEach(node -> names.add(node.asText())); + return names; + } + + @Override + public VdbCollection describe(String name) { + JsonNode data = call("/collections/describe", body(name)); + VdbCollection collection = new VdbCollection(name); + collection.setRemarks(StringUtils.trimToNull(text(data, "description"))); + Map indexes = getIndexes(name); + List partitions = new ArrayList<>(); + try { + call("/partitions/list", body(name)).forEach(partition -> partitions.add(partition.asText())); + } catch (RestException e) { + // Partitions are extra, a server or a proxy without the route reads the collection whole + } + collection.getProperties().put(PARTITIONS, partitions); + for (JsonNode field : data.path("fields")) { + VdbField column = toField(field); + if (column == null) { + continue; + } + JsonNode index = indexes.get(column.getName()); + if (column.isVector() && index != null) { + column.setVectorIndex(toVectorIndex(index)); + } + if (column.isPrimaryKey()) { + collection.getProperties().put(PRIMARY_KEY, column.getName()); + collection.getProperties().put(PRIMARY_KEY_TEXT, column.getKind() == VdbField.Kind.VARCHAR); + } + collection.addField(column); + } + collection.getProperties().put(DYNAMIC, data.path("enableDynamicField").asBoolean(false)); + if (collection.getPrimaryKeyFields().isEmpty()) { + throw new VdbException("Collection " + name + " of Milvus has no primary key"); + } + addPayloadField(collection); + collection.setCount(count(collection, VdbQuery.all())); + return collection; + } + + /** + * Column of a field of a collection, null for a field which keeps no values of its own (a function output) + */ + static VdbField toField(JsonNode field) { + String name = text(field, "name"); + String type = StringUtils.defaultString(text(field, "type")); + Map params = new LinkedHashMap<>(); + field.path("params").forEach(param -> params.put(text(param, "key"), text(param, "value"))); + int dimensions = Integer.parseInt(params.getOrDefault("dim", "0")); + VdbField column; + switch (type) { + case "Bool": + column = new VdbField(name, VdbField.Kind.BOOLEAN); + break; + case "Int8": + case "Int16": + column = new VdbField(name, VdbField.Kind.SMALLINT); + break; + case "Int32": + column = new VdbField(name, VdbField.Kind.INTEGER); + break; + case "Int64": + column = new VdbField(name, VdbField.Kind.BIGINT); + break; + case "Float": + column = new VdbField(name, VdbField.Kind.REAL); + break; + case "Double": + column = new VdbField(name, VdbField.Kind.DOUBLE); + break; + case "VarChar": + column = VdbField.varchar(name, Integer.parseInt(params.getOrDefault("max_length", "65535"))); + if (column.getSize() > 4000) { + column.setKind(VdbField.Kind.TEXT); + } + break; + case "String": + case "Text": + column = new VdbField(name, VdbField.Kind.TEXT); + break; + case "Timestamptz": + column = new VdbField(name, VdbField.Kind.TIMESTAMP); + break; + case "JSON": + case "Array": + case "Geometry": + case "Struct": + column = new VdbField(name, VdbField.Kind.JSON); + break; + case "FloatVector": + column = VdbField.vector(name, VectorType.dense(VectorType.Element.FLOAT32, dimensions)); + break; + case "Float16Vector": + case "BFloat16Vector": + column = VdbField.vector(name, VectorType.dense(VectorType.Element.FLOAT16, dimensions)); + break; + case "Int8Vector": + column = VdbField.vector(name, VectorType.dense(VectorType.Element.INT8, dimensions)); + break; + case "BinaryVector": + column = VdbField.vector(name, VectorType.dense(VectorType.Element.BINARY, dimensions)); + break; + case "SparseFloatVector": + column = VdbField.vector(name, new VectorType(VectorType.Element.FLOAT32, 0, true)); + break; + default: + column = new VdbField(name, VdbField.Kind.TEXT); + break; + } + column.setPrimaryKey(field.path("primaryKey").asBoolean(false)); + column.setNullable(field.path("nullable").asBoolean(false) && !column.isPrimaryKey()); + column.setRemarks(StringUtils.trimToNull(text(field, "description"))); + // The type of Milvus is kept to read the values, the name of the column is the name of the field + column.getProperties().put(MILVUS_TYPE, type); + column.setSourceName(name); + return column; + } + + private Map getIndexes(String collection) { + Map indexes = new LinkedHashMap<>(); + for (JsonNode indexName : call("/indexes/list", body(collection))) { + ObjectNode body = body(collection); + body.put("indexName", indexName.asText()); + for (JsonNode index : call("/indexes/describe", body)) { + indexes.put(text(index, "fieldName"), index); + } + } + return indexes; + } + + /** + * Vector index of a Milvus index: HNSW (HNSW_SQ, HNSW_PQ ...) or IVF (IVF_FLAT, IVF_SQ8, IVF_PQ ...) of the + * metric, DISKANN is a graph like HNSW. Indexes of sparse vectors and of other metrics are not vector indexes. + */ + static VectorIndex toVectorIndex(JsonNode index) { + String type = StringUtils.defaultString(text(index, "indexType")).toUpperCase(Locale.ENGLISH); + VectorIndex.Distance distance = toDistance(text(index, "metricType")); + if (distance == null) { + return null; + } + Map options = new LinkedHashMap<>(); + for (JsonNode param : index.path("indexParams")) { + if ("params".equals(text(param, "key"))) { + try { + JsonNode values = RestClient.getMapper().readTree(text(param, "value")); + putOption(options, "m", values.get("M")); + putOption(options, "ef_construction", values.get("efConstruction")); + putOption(options, "lists", values.get("nlist")); + } catch (IOException e) { + // Options which are not JSON are left out + } + } + } + if (type.startsWith("HNSW") || type.equals("DISKANN")) { + return new VectorIndex(VectorIndex.Kind.HNSW, distance, options); + } else if (type.startsWith("IVF") || type.startsWith("SCANN")) { + return new VectorIndex(VectorIndex.Kind.IVF, distance, options); + } + return null; + } + + private static void putOption(Map options, String name, JsonNode value) { + if (value != null && value.isNumber()) { + options.put(name, value.asText()); + } + } + + static VectorIndex.Distance toDistance(String metric) { + switch (StringUtils.defaultString(metric).toUpperCase(Locale.ENGLISH)) { + case "COSINE": + return VectorIndex.Distance.COSINE; + case "L2": + return VectorIndex.Distance.EUCLIDEAN; + case "IP": + return VectorIndex.Distance.DOT; + case "HAMMING": + return VectorIndex.Distance.HAMMING; + case "JACCARD": + return VectorIndex.Distance.JACCARD; + default: + return null; + } + } + + /** + * Load states of the collections, a collection which is not loaded is not read + */ + @Override + public Map> getHealth() { + Map> sections = super.getHealth(); + Map loads = section(sections, "Load states"); + for (String collection : getCollections()) { + loads.put(collection, VdbInfo.read(() -> text(call("/collections/get_load_state", body(collection)), + "loadState"))); + } + return sections; + } + + /** + * Databases of the server + */ + @Override + public List getDatabases() { + List names = new ArrayList<>(); + call("/databases/list", body(null)).forEach(name -> names.add(name.asText())); + return names; + } + + @Override + public List listPartitions(String collection) { + List partitions = new ArrayList<>(); + call("/partitions/list", body(collection)).forEach(partition -> partitions.add(partition.asText())); + return partitions; + } + + /** + * Partitions of the collection, _default included + */ + @SuppressWarnings("unchecked") + @Override + public List getPartitions(VdbCollection collection) { + return (List) collection.getProperties().getOrDefault(PARTITIONS, List.of()); + } + + /** + * Partitions of the query, all partitions without them + */ + private void addPartitions(ObjectNode body, VdbCollection collection, VdbQuery query) { + if (query.getPartitions() != null && !query.getPartitions().isEmpty()) { + ArrayNode names = body.putArray("partitionNames"); + query.selectPartitions(getPartitions(collection)).forEach(names::add); + } + } + + @Override + public long count(VdbCollection collection, VdbQuery query) { + if (query.isNearest()) { + return query.getLimit(); + } + ObjectNode body = body(collection.getName()); + body.put("filter", toFilter(collection, query)); + body.putArray("outputFields").add("count(*)"); + addPartitions(body, collection, query); + body.put("consistencyLevel", "Strong"); + JsonNode data = call("/entities/query", body); + long count = data.size() > 0 ? data.get(0).path("count(*)").asLong() : 0; + return query.getLimit() > 0 ? Math.min(count, query.getLimit()) : count; + } + + @Override + public void read(VdbCollection collection, VdbQuery query, int pageSize, PageConsumer consumer) { + if (query.isNearest()) { + consumer.accept(search(collection, query, pageSize), true); + return; + } + String primaryKey = (String) collection.getProperties().get(PRIMARY_KEY); + boolean textKey = Boolean.TRUE.equals(collection.getProperties().get(PRIMARY_KEY_TEXT)); + long remaining = query.getLimit() > 0 ? query.getLimit() : Long.MAX_VALUE; + String filter = toFilter(collection, query); + Object last = null; + int size = Math.min(pageSize, MAX_QUERY_WINDOW); + while (remaining > 0) { + ObjectNode body = body(collection.getName()); + String after = last == null ? null : textKey ? String.format("%s > \"%s\"", primaryKey, + last.toString().replace("\\", "\\\\").replace("\"", "\\\"")) : primaryKey + " > " + last; + body.put("filter", combine(filter, after)); + body.putArray("outputFields").add("*"); + addPartitions(body, collection, query); + body.put("limit", (int) Math.min(size, remaining)); + body.put("consistencyLevel", "Strong"); + List> rows = toRows(collection, call("/entities/query", body)); + // Rows of a query by the primary key are in the order of the key + rows.sort((a, b) -> compareKeys(a.get(primaryKey), b.get(primaryKey))); + remaining -= rows.size(); + boolean lastPage = rows.size() < size || remaining <= 0; + if (!rows.isEmpty()) { + last = rows.get(rows.size() - 1).get(primaryKey); + } + if (!consumer.accept(rows, lastPage) || lastPage) { + return; + } + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static int compareKeys(Object a, Object b) { + return a instanceof Comparable && b != null ? ((Comparable) a).compareTo(b) : 0; + } + + private List> search(VdbCollection collection, VdbQuery query, int pageSize) { + ObjectNode body = body(collection.getName()); + ArrayNode data = body.putArray("data"); + if (query.getNearestVector() != null) { + ArrayNode vector = data.addArray(); + for (float value : query.getNearestVector()) { + vector.add(value); + } + } else { + // The vector of a record is searched + String primaryKey = (String) collection.getProperties().get(PRIMARY_KEY); + ObjectNode get = body(collection.getName()); + get.set("id", RestClient.getMapper().valueToTree(List.of(query.getNearestId()))); + get.putArray("outputFields").add(query.getNearestField()); + JsonNode records = call("/entities/get", get); + if (records.size() == 0) { + throw new VdbException("No record of " + primaryKey + " " + query.getNearestId()); + } + data.add(records.get(0).get(query.getNearestField())); + } + body.put("annsField", query.getNearestField()); + body.put("limit", query.getLimit() > 0 ? (int) query.getLimit() : pageSize); + body.putArray("outputFields").add("*"); + String filter = toFilter(collection, query); + if (StringUtils.isNotBlank(filter)) { + body.put("filter", filter); + } + body.put("consistencyLevel", "Strong"); + addPartitions(body, collection, query); + return toRows(collection, call("/entities/search", body)); + } + + List> toRows(VdbCollection collection, JsonNode records) { + boolean dynamic = Boolean.TRUE.equals(collection.getProperties().get(DYNAMIC)); + List> rows = new ArrayList<>(); + for (JsonNode record : records) { + Map values = new LinkedHashMap<>(); + ObjectNode payload = RestClient.getMapper().createObjectNode(); + for (VdbField field : collection.getFields()) { + if (VdbField.PAYLOAD.equals(field.getSourceName()) && field.getKind() == VdbField.Kind.JSON + && !record.has(field.getName())) { + continue; + } + JsonNode value = normalize(record.get(field.getSourceName()), field); + if (field.isVector()) { + values.put(field.getName(), toVectorText(value, field)); + } else if (field.isPrimaryKey()) { + values.put(field.getName(), value == null ? null : value.isIntegralNumber() ? (Object) + value.longValue() : value.asText()); + } else { + values.put(field.getName(), toValue(value, field)); + if (value != null) { + payload.set(field.getSourceName(), value); + } + } + } + if (dynamic) { + // Keys of the dynamic field are the keys of the record which are not fields + List names = collection.getFields().stream().map(VdbField::getSourceName) + .collect(Collectors.toList()); + record.fields().forEachRemaining(entry -> { + if (!names.contains(entry.getKey()) && !"distance".equals(entry.getKey())) { + payload.set(entry.getKey(), entry.getValue()); + } + }); + } + rows.add(toRow(collection, values, payload)); + } + return rows; + } + + /** + * Value of a scalar field as JSON: REST returns JSON fields as text and arrays as {"Data": {"StringData": + * {"data": [..]}}} of its protocol buffers + */ + static JsonNode normalize(JsonNode value, VdbField field) { + if (value == null || field.isVector()) { + return value; + } + String type = (String) field.getProperties().get(MILVUS_TYPE); + if ("JSON".equals(type) && value.isTextual()) { + try { + return RestClient.getMapper().readTree(value.textValue()); + } catch (IOException e) { + return value; + } + } + if ("Array".equals(type) && value.path("Data").isObject() && value.path("Data").size() == 1) { + JsonNode data = value.path("Data").elements().next().path("data"); + return data.isArray() ? data : RestClient.getMapper().createArrayNode(); + } + return value; + } + + /** + * Text of a vector of a record: dense vectors are arrays of numbers, 16-bit floats and int8 are base64 binaries, + * sparse vectors are objects of indexes + */ + static String toVectorText(JsonNode value, VdbField field) { + if (value == null || value.isNull()) { + return null; + } + String type = String.valueOf(field.getProperties().get(MILVUS_TYPE)); + switch (type) { + case "Float16Vector": + return value.isTextual() ? VdbValues.toDenseText(VdbValues.decodeHalves(value.textValue(), false)) + : VdbValues.toDenseText(value); + case "BFloat16Vector": + return value.isTextual() ? VdbValues.toDenseText(VdbValues.decodeHalves(value.textValue(), true)) + : VdbValues.toDenseText(value); + case "Int8Vector": + if (value.isTextual()) { + byte[] bytes = Base64.getDecoder().decode(value.textValue()); + float[] floats = new float[bytes.length]; + for (int i = 0; i < bytes.length; i++) { + floats[i] = bytes[i]; + } + return VdbValues.toDenseText(floats); + } + return VdbValues.toDenseText(value); + case "BinaryVector": + return value.isTextual() ? VdbValues.toByteText(Base64.getDecoder().decode(value.textValue())) + : VdbValues.toJson(value); + case "SparseFloatVector": + Map elements = VdbValues.getSparseElements(value); + long dimensions = elements.keySet().stream().mapToLong(index -> index + 1).max().orElse(1); + return VdbValues.toSparseText(elements, dimensions); + default: + return VdbValues.toDenseText(value); + } + } + + /** + * Expression of Milvus: the native expression, or the conditions joined by and + */ + static String toFilter(VdbCollection collection, VdbQuery query) { + if (query.isNative()) { + return query.getNativeFilter().trim(); + } + List conditions = new ArrayList<>(); + for (VdbQuery.Condition condition : query.getConditions()) { + String field = collection.findField(condition.getField()).map(VdbField::getSourceName) + .orElse(condition.getField()); + String value = literal(condition.getValue()); + switch (condition.getOperator()) { + case EQ: + conditions.add(field + " == " + value); + break; + case NE: + conditions.add(field + " != " + value); + break; + case GT: + conditions.add(field + " > " + value); + break; + case GE: + conditions.add(field + " >= " + value); + break; + case LT: + conditions.add(field + " < " + value); + break; + case LE: + conditions.add(field + " <= " + value); + break; + default: + conditions.add(field + " in " + value); + break; + } + } + return String.join(" and ", conditions); + } + + private static String combine(String filter, String after) { + if (StringUtils.isBlank(filter)) { + return StringUtils.defaultString(after); + } + return after == null ? filter : "(" + filter + ") and " + after; + } + + static String literal(Object value) { + if (value instanceof Iterable) { + List items = new ArrayList<>(); + ((Iterable) value).forEach(item -> items.add(literal(item))); + return "[" + String.join(", ", items) + "]"; + } + if (value instanceof Number || value instanceof Boolean) { + return value.toString(); + } + return "\"" + String.valueOf(value).replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/client/OpenSearchClient.java b/src/main/java/com/github/sqljam/vdb/client/OpenSearchClient.java new file mode 100644 index 0000000..0fb3abf --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/client/OpenSearchClient.java @@ -0,0 +1,146 @@ +/* + * 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.vdb.client; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.sqljam.utils.RestClient; +import com.github.sqljam.vdb.VdbCollection; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VdbSource; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: OpenSearchClient reads OpenSearch 2.4 and later (k-NN plugin, point in time) by its REST API, like + * Elasticsearch: knn_vector fields are vectors of their data type (float, byte, binary), space type + * (cosinesimil, l2, innerproduct, l1, hamming) and method (HNSW or IVF of the engines faiss, lucene + * and nmslib). Documents are read in a point in time sorted by _id. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class OpenSearchClient extends ElasticsearchClient { + + public OpenSearchClient(VdbSource source) { + super(source); + } + + /** + * Indexes of the security audit log and of the query insights are of the server too + */ + @Override + protected boolean isSystemIndex(String name) { + return super.isSystemIndex(name) || name.startsWith("security-auditlog-") || name.startsWith("top_queries-"); + } + + @Override + protected boolean isVectorType(String type) { + return "knn_vector".equals(type); + } + + /** + * knn_vector of its data type, space type and method, the space type of 2.17 and later may be of the field + */ + @Override + protected VdbField toVectorField(String name, JsonNode mapping) { + String dataType = StringUtils.defaultIfBlank(text(mapping, "data_type"), "float"); + VectorType.Element element = "byte".equals(dataType) ? VectorType.Element.INT8 + : "binary".equals(dataType) ? VectorType.Element.BINARY : VectorType.Element.FLOAT32; + VdbField field = VdbField.vector(name, VectorType.dense(element, mapping.path("dimension").asInt(0))); + JsonNode method = mapping.path("method"); + String spaceType = StringUtils.defaultIfBlank(text(method, "space_type"), text(mapping, "space_type")); + VectorIndex.Distance distance = toSpaceDistance(StringUtils.defaultIfBlank(spaceType, "l2")); + if (distance == null) { + return field; + } + Map options = new LinkedHashMap<>(); + JsonNode parameters = method.path("parameters"); + if (parameters.has("m")) { + options.put("m", parameters.path("m").asText()); + } + if (parameters.has("ef_construction")) { + options.put("ef_construction", parameters.path("ef_construction").asText()); + } + if (parameters.has("nlist")) { + options.put("lists", parameters.path("nlist").asText()); + } + boolean ivf = "ivf".equalsIgnoreCase(text(method, "name")); + field.setVectorIndex(new VectorIndex(ivf ? VectorIndex.Kind.IVF : VectorIndex.Kind.HNSW, distance, options)); + return field; + } + + static VectorIndex.Distance toSpaceDistance(String spaceType) { + switch (StringUtils.defaultString(spaceType).toLowerCase(Locale.ENGLISH)) { + case "cosinesimil": + return VectorIndex.Distance.COSINE; + case "l2": + return VectorIndex.Distance.EUCLIDEAN; + case "innerproduct": + return VectorIndex.Distance.DOT; + case "l1": + return VectorIndex.Distance.MANHATTAN; + case "hamming": + case "hammingbit": + return VectorIndex.Distance.HAMMING; + default: + // linf has no index of other databases + return null; + } + } + + @Override + protected String openPointInTime(String index) { + return text(rest.post("/" + encode(index) + "/_search/point_in_time?keep_alive=" + KEEP_ALIVE, null), + "pit_id"); + } + + @Override + protected void closePointInTime(String pit) { + try { + ObjectNode body = RestClient.getMapper().createObjectNode(); + body.putArray("pit_id").add(pit); + rest.send("DELETE", "/_search/point_in_time", body); + } catch (RuntimeException e) { + // A point in time which is not closed expires + } + } + + @Override + protected String getPointInTimeSort() { + return "_id"; + } + + @Override + protected ObjectNode toNearestSearch(VdbCollection collection, VdbQuery query, int pageSize) { + int k = query.getLimit() > 0 ? (int) query.getLimit() : pageSize; + ObjectNode body = RestClient.getMapper().createObjectNode().put("size", k); + ObjectNode knn = body.putObject("query").putObject("knn").putObject(sourceNameOf(collection, + query.getNearestField())).put("k", k); + knn.set("vector", toVectorNode(nearestVector(collection, query))); + JsonNode filter = toQuery(collection, query); + if (filter != null) { + knn.set("filter", filter); + } + return body; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/client/PineconeClient.java b/src/main/java/com/github/sqljam/vdb/client/PineconeClient.java new file mode 100644 index 0000000..49adc3f --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/client/PineconeClient.java @@ -0,0 +1,492 @@ +/* + * 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.vdb.client; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.sqljam.utils.RestClient; +import com.github.sqljam.vdb.PayloadSchema; +import com.github.sqljam.vdb.VdbCollection; +import com.github.sqljam.vdb.VdbException; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VdbInfo; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VdbSource; +import com.github.sqljam.vdb.VdbValues; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: PineconeClient reads Pinecone and Pinecone Local by the REST API of the version 2025-01 with an + * API key. An index is a collection: the id, the dense values of its dimension and metric, the sparse + * values, and the fields of the metadata are columns. An index of more namespaces has a namespace + * column in its key. Vectors are listed by their ids and fetched in pages; metadata filters of the + * conditions are applied to the fetched vectors, native filters and searches are queries. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class PineconeClient extends AbstractVdbClient { + + static final String API_VERSION = "2025-01"; + static final String NAMESPACE = "namespace"; + static final String DEFAULT_NAMESPACE = "__default__"; + static final String VALUES = "values"; + static final String SPARSE_VALUES = "sparse_values"; + static final String HOST = "host"; + static final String NAMESPACES = "namespaces"; + static final String METRIC = "metric"; + /** + * Ids of a page of a list and of a fetch at most + */ + static final int MAX_PAGE_SIZE = 100; + /** + * Records of a query at most (topK) + */ + static final int MAX_TOP_K = 10000; + + public PineconeClient(VdbSource source) { + super(source); + rest.header("Api-Key", source.getApiKey()); + rest.header("X-Pinecone-API-Version", API_VERSION); + } + + /** + * Pinecone tells no version of its server, it is the version of the API + */ + @Override + public String getVersion() { + rest.get("/indexes"); + return API_VERSION; + } + + /** + * Each index: its dimensions, metric, state and vectors, how full it is + */ + @Override + public Map> getHealth() { + Map> sections = super.getHealth(); + Map indexes = section(sections, "Indexes"); + for (String name : getCollections()) { + indexes.put(name, VdbInfo.read(() -> { + JsonNode index = rest.get("/indexes/" + encode(name)); + JsonNode stats = rest.post(toHost(text(index, HOST)) + "/describe_index_stats", + RestClient.getMapper().createObjectNode()); + return String.format("%s, %s dimensions, %s, %d vectors in %d namespaces, %.0f%% full", + StringUtils.defaultIfBlank(text(index.path("status"), "state"), "?"), text(index, + "dimension"), text(index, METRIC), stats.path("totalVectorCount").asLong(), + stats.path(NAMESPACES).size(), stats.path("indexFullness").asDouble() * 100); + })); + } + return sections; + } + + @Override + public List getCollections() { + List names = new ArrayList<>(); + rest.get("/indexes").path("indexes").forEach(node -> names.add(text(node, "name"))); + return names; + } + + @Override + public VdbCollection describe(String name) { + JsonNode index = rest.get("/indexes/" + encode(name)); + VdbCollection collection = new VdbCollection(name); + String host = toHost(required(text(index, HOST), "Host of the index " + name)); + collection.getProperties().put(HOST, host); + String metric = StringUtils.defaultIfBlank(text(index, METRIC), "cosine"); + collection.getProperties().put(METRIC, metric); + + List namespaces = new ArrayList<>(); + rest.post(host + "/describe_index_stats", RestClient.getMapper().createObjectNode()).path(NAMESPACES) + .fieldNames().forEachRemaining(namespaces::add); + if (namespaces.isEmpty()) { + namespaces.add(""); + } + collection.getProperties().put(NAMESPACES, namespaces); + boolean named = namespaces.size() > 1 || !isDefaultNamespace(namespaces.get(0)); + if (named) { + VdbField namespace = VdbField.varchar(NAMESPACE, 64); + namespace.setPrimaryKey(true); + namespace.setNullable(false); + collection.addField(namespace); + } + VdbField id = VdbField.varchar("id", 512); + id.setPrimaryKey(true); + id.setNullable(false); + collection.addField(id); + + boolean sparseIndex = "sparse".equalsIgnoreCase(text(index, "vector_type")); + if (!sparseIndex) { + VdbField values = VdbField.vector(VALUES, VectorType.dense(VectorType.Element.FLOAT32, + index.path("dimension").asInt(0))); + VectorIndex.Distance distance = toDistance(metric); + if (distance != null) { + values.setVectorIndex(new VectorIndex(VectorIndex.Kind.HNSW, distance, null)); + } + collection.addField(values); + } + Scan scan = scan(collection); + if (sparseIndex || scan.sparseDimensions > 0) { + VdbField sparse = VdbField.vector(SPARSE_VALUES, new VectorType(VectorType.Element.FLOAT32, + (int) Math.min(scan.sparseDimensions, Integer.MAX_VALUE), true)); + sparse.setSourceName("sparseValues"); + collection.addField(sparse); + } + collection.getFields().addAll(scan.payloads.getFields(collection)); + addPayloadField(collection); + collection.setCount(scan.vectors); + return collection; + } + + /** + * Host of an index: Pinecone Local tells localhost, which is the host of the source + */ + String toHost(String host) { + URI url = URI.create(source.getUrl()); + String hostName = StringUtils.substringBefore(host, ":"); + if ("localhost".equalsIgnoreCase(hostName) || "127.0.0.1".equals(hostName) || "0.0.0.0".equals(hostName)) { + String port = host.contains(":") ? ":" + StringUtils.substringAfterLast(host, ":") : ""; + return url.getScheme() + "://" + url.getHost() + port; + } + return host.contains("://") ? host : "https://" + host; + } + + static boolean isDefaultNamespace(String namespace) { + return namespace.isEmpty() || DEFAULT_NAMESPACE.equals(namespace); + } + + static VectorIndex.Distance toDistance(String metric) { + switch (StringUtils.defaultString(metric).toLowerCase(Locale.ENGLISH)) { + case "cosine": + return VectorIndex.Distance.COSINE; + case "euclidean": + return VectorIndex.Distance.EUCLIDEAN; + case "dotproduct": + return VectorIndex.Distance.DOT; + default: + return null; + } + } + + /** + * What the vectors tell of the index: the types of the metadata fields, the dimensions of the sparse values, the + * number of vectors + */ + static final class Scan { + final PayloadSchema payloads = new PayloadSchema(); + long sparseDimensions; + long vectors; + } + + private Scan scan(VdbCollection collection) { + Scan scan = new Scan(); + for (String namespace : getNamespaces(collection)) { + listAndFetch(collection, namespace, (ids, vectors) -> { + for (JsonNode vector : vectors) { + scan.vectors++; + scan.payloads.observe(vector.path("metadata")); + for (JsonNode index : vector.path("sparseValues").path("indices")) { + scan.sparseDimensions = Math.max(scan.sparseDimensions, index.asLong() + 1); + } + } + return true; + }); + } + return scan; + } + + @Override + public List listPartitions(String collection) { + String host = toHost(required(text(rest.get("/indexes/" + encode(collection)), HOST), "Host of the index " + + collection)); + List names = new ArrayList<>(); + rest.post(host + "/describe_index_stats", RestClient.getMapper().createObjectNode()).path(NAMESPACES) + .fieldNames().forEachRemaining(namespace -> names.add(isDefaultNamespace(namespace) + ? DEFAULT_NAMESPACE : namespace)); + return names; + } + + /** + * Namespaces of the index, the default one is __default__ + */ + @Override + public List getPartitions(VdbCollection collection) { + List names = new ArrayList<>(); + getNamespaces(collection).forEach(namespace -> names.add(isDefaultNamespace(namespace) ? DEFAULT_NAMESPACE + : namespace)); + return names; + } + + /** + * Namespaces which are read, the namespaces of the query or all + */ + static List getNamespaces(VdbCollection collection, VdbQuery query) { + List selected = new ArrayList<>(); + for (String namespace : getNamespaces(collection)) { + String name = isDefaultNamespace(namespace) ? DEFAULT_NAMESPACE : namespace; + if (!query.selectPartitions(List.of(name)).isEmpty()) { + selected.add(namespace); + } + } + return selected; + } + + @SuppressWarnings("unchecked") + static List getNamespaces(VdbCollection collection) { + return (List) collection.getProperties().get(NAMESPACES); + } + + @FunctionalInterface + private interface FetchConsumer { + /** + * @param vectors fetched vectors in the order of the list + * @return whether the next page is fetched + */ + boolean accept(List ids, List vectors); + } + + /** + * Lists the ids of a namespace page by page and fetches their vectors + */ + private void listAndFetch(VdbCollection collection, String namespace, FetchConsumer consumer) { + String host = (String) collection.getProperties().get(HOST); + String token = null; + do { + JsonNode page = rest.get(host + "/vectors/list?namespace=" + encode(namespace) + "&limit=" + + MAX_PAGE_SIZE + (token == null ? "" : "&paginationToken=" + encode(token))); + List ids = new ArrayList<>(); + page.path("vectors").forEach(vector -> ids.add(text(vector, "id"))); + token = StringUtils.trimToNull(text(page.path("pagination"), "next")); + if (ids.isEmpty()) { + return; + } + if (!consumer.accept(ids, fetch(host, namespace, ids))) { + return; + } + } while (token != null); + } + + private List fetch(String host, String namespace, Collection ids) { + StringBuilder path = new StringBuilder(host).append("/vectors/fetch?namespace=").append(encode(namespace)); + ids.forEach(id -> path.append("&ids=").append(encode(id))); + JsonNode fetched = rest.get(path.toString()).path("vectors"); + List vectors = new ArrayList<>(); + for (String id : ids) { + // A vector deleted between the list and the fetch is not there + if (fetched.has(id)) { + vectors.add(fetched.get(id)); + } + } + return vectors; + } + + @Override + public long count(VdbCollection collection, VdbQuery query) { + if (query.isNearest()) { + return query.getLimit() > 0 ? Math.min(query.getLimit(), MAX_TOP_K) : MAX_TOP_K; + } + if (query.isFiltered()) { + return -1; + } + String host = (String) collection.getProperties().get(HOST); + long count = 0; + JsonNode namespaces = rest.post(host + "/describe_index_stats", RestClient.getMapper().createObjectNode()) + .path(NAMESPACES); + for (String namespace : getNamespaces(collection, query)) { + count += namespaces.path(namespace).path("vectorCount").asLong(0); + } + return query.getLimit() > 0 ? Math.min(count, query.getLimit()) : count; + } + + @Override + public void read(VdbCollection collection, VdbQuery query, int pageSize, PageConsumer consumer) { + if (query.isNearest()) { + List> rows = search(collection, query); + consumer.accept(rows, true); + return; + } + if (query.isNative()) { + throw new VdbException("Pinecone filters the metadata of a search, please give a vector or an id to " + + "search with the filter"); + } + List namespaces = getNamespaces(collection, query); + long[] remaining = {query.getLimit() > 0 ? query.getLimit() : Long.MAX_VALUE}; + boolean[] stopped = {false}; + List> buffer = new ArrayList<>(); + int size = Math.max(1, pageSize); + for (String namespace : namespaces) { + listAndFetch(collection, namespace, (ids, vectors) -> { + for (JsonNode vector : vectors) { + if (remaining[0] <= 0) { + break; + } + if (matches(collection, vector.path("metadata"), query.getConditions())) { + buffer.add(toRow(collection, namespace, vector)); + remaining[0]--; + } + } + if (buffer.size() >= size && remaining[0] > 0) { + List> rows = new ArrayList<>(buffer); + buffer.clear(); + if (!consumer.accept(rows, false)) { + stopped[0] = true; + } + } + return !stopped[0] && remaining[0] > 0; + }); + if (stopped[0] || remaining[0] <= 0) { + break; + } + } + if (!stopped[0]) { + consumer.accept(buffer, true); + } + } + + /** + * Nearest vectors of each namespace, merged by their scores + */ + private List> search(VdbCollection collection, VdbQuery query) { + String host = (String) collection.getProperties().get(HOST); + int topK = (int) (query.getLimit() > 0 ? Math.min(query.getLimit(), MAX_TOP_K) : MAX_TOP_K); + JsonNode filter = toOperatorFilter(collection, query, "Pinecone"); + List matches = new ArrayList<>(); + for (String namespace : getNamespaces(collection, query)) { + ObjectNode body = RestClient.getMapper().createObjectNode().put(NAMESPACE, namespace).put("topK", topK) + .put("includeValues", true).put("includeMetadata", true); + if (query.getNearestVector() != null) { + ArrayNode vector = body.putArray("vector"); + for (float value : query.getNearestVector()) { + vector.add(value); + } + } else { + body.put("id", String.valueOf(query.getNearestId())); + } + if (filter != null) { + body.set("filter", filter); + } + JsonNode namespaceNode = RestClient.getMapper().getNodeFactory().textNode(namespace); + rest.post(host + "/query", body).path("matches").forEach(match -> matches.add(new JsonNode[]{match, + namespaceNode})); + } + // Higher scores are nearer, except the distances of euclidean + Comparator byScore = Comparator.comparingDouble(match -> match[0].path("score").asDouble()); + boolean ascending = "euclidean".equalsIgnoreCase((String) collection.getProperties().get(METRIC)); + matches.sort(ascending ? byScore : byScore.reversed()); + List> rows = new ArrayList<>(); + for (int i = 0; i < matches.size() && i < topK; i++) { + rows.add(toRow(collection, matches.get(i)[1].asText(), matches.get(i)[0])); + } + return rows; + } + + private Map toRow(VdbCollection collection, String namespace, JsonNode vector) { + Map values = new LinkedHashMap<>(); + values.put(NAMESPACE, isDefaultNamespace(namespace) ? DEFAULT_NAMESPACE : namespace); + values.put("id", text(vector, "id")); + JsonNode dense = vector.path(VALUES); + values.put(VALUES, dense.isArray() && dense.size() > 0 ? VdbValues.toDenseText(dense) : null); + collection.findField(SPARSE_VALUES).ifPresent(field -> { + JsonNode sparse = vector.path("sparseValues"); + values.put(SPARSE_VALUES, sparse.isObject() ? VdbValues.toSparseText(VdbValues.getSparseElements( + sparse.path("indices"), sparse.path(VALUES)), field.getVectorType().getDimensions()) : null); + }); + return toRow(collection, values, vector.path("metadata")); + } + + /** + * Whether the metadata matches all the conditions, a missing field matches none but NE + */ + static boolean matches(VdbCollection collection, JsonNode metadata, List conditions) { + for (VdbQuery.Condition condition : conditions) { + String name = collection.findField(condition.getField()).map(VdbField::getSourceName) + .orElse(condition.getField()); + JsonNode value = metadata.get(name); + if (!matches(value, condition.getOperator(), condition.getValue())) { + return false; + } + } + return true; + } + + static boolean matches(JsonNode value, VdbQuery.Operator operator, Object expected) { + if (value == null || value.isNull()) { + return operator == VdbQuery.Operator.NE; + } + if (operator == VdbQuery.Operator.IN) { + Iterable items = expected instanceof Iterable ? (Iterable) expected : List.of(expected); + for (Object item : items) { + if (compare(value, item) == 0) { + return true; + } + } + return false; + } + // A list of strings of the metadata matches EQ if it contains the value, as Pinecone does + if (value.isArray() && (operator == VdbQuery.Operator.EQ || operator == VdbQuery.Operator.NE)) { + boolean contains = false; + for (JsonNode item : value) { + contains |= compare(item, expected) == 0; + } + return operator == VdbQuery.Operator.EQ ? contains : !contains; + } + Integer result = compare(value, expected); + if (result == null) { + return operator == VdbQuery.Operator.NE; + } + switch (operator) { + case EQ: + return result == 0; + case NE: + return result != 0; + case GT: + return result > 0; + case GE: + return result >= 0; + case LT: + return result < 0; + default: + return result <= 0; + } + } + + /** + * Comparison of a value of the metadata with a value of a condition, null if they are of different types + */ + static Integer compare(JsonNode value, Object expected) { + if (value.isNumber() && expected instanceof Number) { + return Double.compare(value.doubleValue(), ((Number) expected).doubleValue()); + } else if (value.isBoolean() && expected instanceof Boolean) { + return Boolean.compare(value.booleanValue(), (Boolean) expected); + } else if (value.isTextual() && expected != null && !(expected instanceof Number) + && !(expected instanceof Boolean)) { + return value.textValue().compareTo(String.valueOf(expected)); + } + return null; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/client/QdrantClient.java b/src/main/java/com/github/sqljam/vdb/client/QdrantClient.java new file mode 100644 index 0000000..7306edc --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/client/QdrantClient.java @@ -0,0 +1,388 @@ +/* + * 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.vdb.client; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.sqljam.utils.RestClient; +import com.github.sqljam.vdb.PayloadSchema; +import com.github.sqljam.vdb.VdbCollection; +import com.github.sqljam.vdb.VdbException; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VdbInfo; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VdbSource; +import com.github.sqljam.vdb.VdbValues; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: QdrantClient reads Qdrant 1.10 and later by its REST API with the api-key header (a read-only key + * is enough). Points are read by scroll in the order of their ids. A collection has an unnamed vector + * (column vector) or named vectors, dense vectors of float32, float16 or uint8 and sparse vectors. + * Payloads have no schema: the points are read once without their dense vectors to find the types of + * the payload fields, the type of the ids (integers or UUIDs) and the dimensions of the sparse + * vectors. Multivectors (late interaction) are JSON. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class QdrantClient extends AbstractVdbClient { + + /** + * Name of the column of an unnamed vector + */ + static final String UNNAMED_VECTOR = "vector"; + static final String UNNAMED = "unnamed"; + static final String SPARSE_NAMES = "sparseNames"; + private static final int SCAN_PAGE_SIZE = 1000; + + public QdrantClient(VdbSource source) { + super(source); + rest.header("api-key", source.getApiKey()); + } + + @Override + public String getVersion() { + return text(rest.get("/"), "version"); + } + + @Override + public Map> getOverview() { + Map> sections = super.getOverview(); + section(sections, "Server").put("Commit", VdbInfo.read(() -> text(rest.get("/"), "commit"))); + return sections; + } + + /** + * Readiness and liveness probes, the state of the cluster of distributed deployments + */ + @Override + public Map> getHealth() { + Map> sections = super.getHealth(); + Map health = section(sections, "Qdrant"); + health.put("Ready (/readyz)", VdbInfo.read(() -> rest.get("/readyz").asText())); + health.put("Live (/livez)", VdbInfo.read(() -> rest.get("/livez").asText())); + health.put("Cluster", VdbInfo.read(() -> { + JsonNode cluster = rest.get("/cluster").path("result"); + return "enabled".equals(text(cluster, "status")) ? "Enabled, " + cluster.path("peers").size() + " peers" + : "Disabled, a single node"; + })); + return sections; + } + + @Override + public List getCollections() { + List names = new ArrayList<>(); + rest.get("/collections").path("result").path("collections").forEach(node -> names.add(text(node, "name"))); + return names; + } + + @Override + public VdbCollection describe(String name) { + JsonNode config = rest.get("/collections/" + encode(name)).path("result").path("config"); + JsonNode params = config.path("params"); + JsonNode defaultHnsw = config.path("hnsw_config"); + VdbCollection collection = new VdbCollection(name); + Scan scan = scan(name, params.path("sparse_vectors")); + + VdbField id = scan.integerIds ? new VdbField("id", VdbField.Kind.BIGINT) : VdbField.varchar("id", 36); + id.setPrimaryKey(true); + id.setNullable(false); + collection.addField(id); + + JsonNode vectors = params.path("vectors"); + boolean unnamed = vectors.has("size"); + collection.getProperties().put(UNNAMED, unnamed); + if (unnamed) { + collection.addField(toVectorField(UNNAMED_VECTOR, vectors, defaultHnsw)); + } else { + vectors.fields().forEachRemaining(entry -> { + // A vector named by the empty text (LangChain and others) is the default vector, its column is vector + VdbField field = toVectorField(entry.getKey().isEmpty() ? collection.getUnusedName(UNNAMED_VECTOR) + : entry.getKey(), entry.getValue(), defaultHnsw); + field.setSourceName(entry.getKey()); + collection.addField(field); + }); + } + List sparseNames = new ArrayList<>(); + params.path("sparse_vectors").fieldNames().forEachRemaining(sparseName -> { + sparseNames.add(sparseName); + long dimensions = scan.sparseDimensions.getOrDefault(sparseName, 0L); + VdbField field = VdbField.vector(sparseName, new VectorType(VectorType.Element.FLOAT32, + (int) Math.min(dimensions, Integer.MAX_VALUE), true)); + collection.addField(field); + }); + collection.getProperties().put(SPARSE_NAMES, sparseNames); + collection.getFields().addAll(scan.payloads.getFields(collection)); + addPayloadField(collection); + collection.setCount(scan.points); + return collection; + } + + /** + * Column of a dense vector, JSON of a multivector. Its index is HNSW of its distance, with the options of the + * vector or of the collection. + */ + static VdbField toVectorField(String name, JsonNode vector, JsonNode defaultHnsw) { + if (vector.has("multivector_config")) { + VdbField field = new VdbField(name, VdbField.Kind.JSON); + field.setRemarks("Multivector of Qdrant"); + return field; + } + String datatype = StringUtils.defaultIfBlank(text(vector, "datatype"), "float32"); + // Vectors of uint8 (0 to 255) are kept by 32-bit floats, int8 of other databases is signed + VectorType.Element element = "float16".equalsIgnoreCase(datatype) ? VectorType.Element.FLOAT16 + : VectorType.Element.FLOAT32; + VdbField field = VdbField.vector(name, VectorType.dense(element, vector.path("size").asInt())); + VectorIndex.Distance distance = toDistance(text(vector, "distance")); + if (distance != null) { + JsonNode hnsw = vector.path("hnsw_config"); + Map options = new LinkedHashMap<>(); + String m = hnsw.has("m") ? hnsw.path("m").asText() : text(defaultHnsw, "m"); + String ef = hnsw.has("ef_construct") ? hnsw.path("ef_construct").asText() : text(defaultHnsw, + "ef_construct"); + if (m != null && !"0".equals(m)) { + options.put("m", m); + } + if (ef != null) { + options.put("ef_construction", ef); + } + field.setVectorIndex(new VectorIndex(VectorIndex.Kind.HNSW, distance, options)); + } + return field; + } + + static VectorIndex.Distance toDistance(String distance) { + switch (StringUtils.defaultString(distance).toLowerCase(Locale.ENGLISH)) { + case "cosine": + return VectorIndex.Distance.COSINE; + case "euclid": + return VectorIndex.Distance.EUCLIDEAN; + case "dot": + return VectorIndex.Distance.DOT; + case "manhattan": + return VectorIndex.Distance.MANHATTAN; + default: + return null; + } + } + + /** + * What the points tell of the collection: the types of the ids and the payload fields, the dimensions of the + * sparse vectors, the number of points + */ + static final class Scan { + boolean integerIds = true; + final PayloadSchema payloads = new PayloadSchema(); + final Map sparseDimensions = new LinkedHashMap<>(); + long points; + } + + private Scan scan(String name, JsonNode sparseVectors) { + Scan scan = new Scan(); + List sparseNames = new ArrayList<>(); + sparseVectors.fieldNames().forEachRemaining(sparseNames::add); + JsonNode offset = null; + do { + ObjectNode body = RestClient.getMapper().createObjectNode().put("limit", SCAN_PAGE_SIZE) + .put("with_payload", true); + if (sparseNames.isEmpty()) { + body.put("with_vector", false); + } else { + // Sparse vectors only, dense vectors are not needed to find the dimensions + ArrayNode with = body.putArray("with_vector"); + sparseNames.forEach(with::add); + } + if (offset != null) { + body.set("offset", offset); + } + JsonNode result = rest.post("/collections/" + encode(name) + "/points/scroll", body).path("result"); + for (JsonNode point : result.path("points")) { + scan.points++; + if (!point.path("id").isIntegralNumber()) { + scan.integerIds = false; + } + scan.payloads.observe(point.path("payload")); + for (String sparseName : sparseNames) { + for (JsonNode index : point.path("vector").path(sparseName).path("indices")) { + scan.sparseDimensions.merge(sparseName, index.asLong() + 1, Math::max); + } + } + } + offset = result.path("next_page_offset"); + } while (offset != null && !offset.isNull() && !offset.isMissingNode()); + return scan; + } + + @Override + public long count(VdbCollection collection, VdbQuery query) { + ObjectNode body = RestClient.getMapper().createObjectNode().put("exact", true); + JsonNode filter = toFilter(collection, query); + if (filter != null) { + body.set("filter", filter); + } + long count = rest.post("/collections/" + encode(collection.getName()) + "/points/count", body) + .path("result").path("count").asLong(); + return query.isNearest() || query.getLimit() > 0 ? Math.min(count, query.getLimit() > 0 ? query.getLimit() + : count) : count; + } + + @Override + public void read(VdbCollection collection, VdbQuery query, int pageSize, PageConsumer consumer) { + String path = "/collections/" + encode(collection.getName()) + "/points/"; + JsonNode filter = toFilter(collection, query); + if (query.isNearest()) { + ObjectNode body = RestClient.getMapper().createObjectNode().put("with_payload", true) + .put("with_vector", true).put("limit", query.getLimit() > 0 ? query.getLimit() : pageSize); + if (query.getNearestVector() != null) { + ArrayNode vector = body.putArray("query"); + for (float value : query.getNearestVector()) { + vector.add(value); + } + } else { + body.set("query", RestClient.getMapper().valueToTree(query.getNearestId())); + } + if (!Boolean.TRUE.equals(collection.getProperties().get(UNNAMED))) { + String using = collection.findField(query.getNearestField()).map(VdbField::getSourceName) + .orElse(query.getNearestField()); + if (!using.isEmpty()) { + body.put("using", using); + } + } + if (filter != null) { + body.set("filter", filter); + } + consumer.accept(toRows(collection, rest.post(path + "query", body).path("result").path("points")), + true); + return; + } + long remaining = query.getLimit() > 0 ? query.getLimit() : Long.MAX_VALUE; + JsonNode offset = null; + while (remaining > 0) { + ObjectNode body = RestClient.getMapper().createObjectNode().put("with_payload", true) + .put("with_vector", true).put("limit", (int) Math.min(pageSize, remaining)); + if (offset != null) { + body.set("offset", offset); + } + if (filter != null) { + body.set("filter", filter); + } + JsonNode result = rest.post(path + "scroll", body).path("result"); + List> rows = toRows(collection, result.path("points")); + remaining -= rows.size(); + offset = result.path("next_page_offset"); + boolean last = rows.isEmpty() || offset.isNull() || offset.isMissingNode() || remaining <= 0; + if (!consumer.accept(rows, last) || last) { + return; + } + } + } + + @SuppressWarnings("unchecked") + List> toRows(VdbCollection collection, JsonNode points) { + boolean unnamed = Boolean.TRUE.equals(collection.getProperties().get(UNNAMED)); + List sparseNames = (List) collection.getProperties().getOrDefault(SPARSE_NAMES, + new ArrayList()); + List> rows = new ArrayList<>(); + for (JsonNode point : points) { + Map values = new LinkedHashMap<>(); + JsonNode id = point.path("id"); + values.put("id", id.isIntegralNumber() ? (Object) id.longValue() : id.asText()); + JsonNode vector = point.path("vector"); + for (VdbField field : collection.getFields()) { + if (!field.isVector() && field.getKind() != VdbField.Kind.JSON) { + continue; + } + if (unnamed && UNNAMED_VECTOR.equals(field.getName())) { + values.put(field.getName(), VdbValues.toDenseText(vector)); + } else if (sparseNames.contains(field.getName())) { + JsonNode sparse = vector.path(field.getName()); + values.put(field.getName(), sparse.isMissingNode() ? null : VdbValues.toSparseText( + VdbValues.getSparseElements(sparse.path("indices"), sparse.path("values")), + Math.max(field.getVectorType().getDimensions(), 1))); + } else if (field.isVector()) { + // The vector of the empty name is answered as the vector itself + JsonNode value = field.getSourceName().isEmpty() && vector.isArray() ? vector + : vector.get(field.getSourceName()); + values.put(field.getName(), VdbValues.toDenseText(value)); + } else if (vector.has(field.getSourceName())) { + // A multivector + values.put(field.getName(), VdbValues.toJson(vector.get(field.getSourceName()))); + } + } + rows.add(toRow(collection, values, point.path("payload"))); + } + return rows; + } + + /** + * Filter of Qdrant: the native filter, or must conditions of the payload fields + */ + static JsonNode toFilter(VdbCollection collection, VdbQuery query) { + if (query.isNative()) { + try { + return RestClient.getMapper().readTree(query.getNativeFilter()); + } catch (IOException e) { + throw new VdbException("The filter of Qdrant is not JSON: " + e.getMessage(), e); + } + } + if (query.getConditions().isEmpty()) { + return null; + } + ObjectNode filter = RestClient.getMapper().createObjectNode(); + ArrayNode must = filter.putArray("must"); + ArrayNode mustNot = filter.putArray("must_not"); + for (VdbQuery.Condition condition : query.getConditions()) { + String key = collection.findField(condition.getField()).map(VdbField::getSourceName) + .orElse(condition.getField()); + ObjectNode clause = RestClient.getMapper().createObjectNode().put("key", key); + JsonNode value = RestClient.getMapper().valueToTree(condition.getValue()); + switch (condition.getOperator()) { + case EQ: + clause.putObject("match").set("value", value); + must.add(clause); + break; + case NE: + clause.putObject("match").set("value", value); + mustNot.add(clause); + break; + case IN: + clause.putObject("match").set("any", value); + must.add(clause); + break; + default: + clause.putObject("range").set(condition.getOperator().name().toLowerCase(Locale.ENGLISH) + .replace("ge", "gte").replace("le", "lte"), value); + must.add(clause); + break; + } + } + if (mustNot.size() == 0) { + filter.remove("must_not"); + } + return filter; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/client/WeaviateClient.java b/src/main/java/com/github/sqljam/vdb/client/WeaviateClient.java new file mode 100644 index 0000000..f075c14 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/client/WeaviateClient.java @@ -0,0 +1,566 @@ +/* + * 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.vdb.client; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.sqljam.utils.RestClient; +import com.github.sqljam.vdb.VdbCollection; +import com.github.sqljam.vdb.VdbException; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VdbInfo; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VdbSource; +import com.github.sqljam.vdb.VdbValues; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: WeaviateClient reads Weaviate 1.24 and later by its REST API and GraphQL with a bearer API key. A + * class is a collection: the UUID is the id, named vectors (or the vector of a class without named + * vectors, column vector) are vectors of their index (HNSW, flat or dynamic) and distance, properties + * are columns of their data types. The schema has no dimensions of vectors, they are read of an + * object. Objects are read by the cursor of the REST API, filters, searches and tenants by GraphQL. + * Each tenant of a multi tenant class is read, the tenant is a column of the key. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class WeaviateClient extends AbstractVdbClient { + + static final String UNNAMED_VECTOR = "vector"; + static final String TENANT = "tenant"; + static final String NAMED = "named"; + static final String TENANTS = "tenants"; + static final String PROPERTIES = "properties"; + /** + * Objects of a GraphQL query at most (QUERY_MAXIMUM_RESULTS of the server, 10000 by default) + */ + static final int MAX_QUERY_RESULTS = 10000; + + public WeaviateClient(VdbSource source) { + super(source); + rest.header("Authorization", StringUtils.isNotBlank(source.getApiKey()) ? "Bearer " + source.getApiKey() + : null); + } + + @Override + public String getVersion() { + return text(rest.get("/v1/meta"), "version"); + } + + @Override + public Map> getOverview() { + Map> sections = super.getOverview(); + section(sections, "Server").put("Modules", VdbInfo.read(() -> { + List modules = new ArrayList<>(); + rest.get("/v1/meta").path("modules").fieldNames().forEachRemaining(modules::add); + return modules.isEmpty() ? "None" : String.join(", ", modules); + })); + return sections; + } + + /** + * Readiness and liveness probes, the status, objects and shards of each node + */ + @Override + public Map> getHealth() { + Map> sections = super.getHealth(); + Map health = section(sections, "Weaviate"); + health.put("Ready", VdbInfo.read(() -> { + rest.get("/v1/.well-known/ready"); + return "Yes"; + })); + health.put("Live", VdbInfo.read(() -> { + rest.get("/v1/.well-known/live"); + return "Yes"; + })); + Map nodes = section(sections, "Nodes"); + String error = VdbInfo.read(() -> { + for (JsonNode node : rest.get("/v1/nodes?output=verbose").path("nodes")) { + nodes.put(text(node, "name"), String.format("%s, version %s, %d objects, %d shards", text(node, + "status"), text(node, "version"), node.path("stats").path("objectCount").asLong(), + node.path("stats").path("shardCount").asLong())); + } + return ""; + }); + if (error.startsWith(VdbInfo.UNAVAILABLE)) { + nodes.put("Nodes", error); + } + return sections; + } + + @Override + public List getCollections() { + List names = new ArrayList<>(); + rest.get("/v1/schema").path("classes").forEach(node -> names.add(text(node, "class"))); + return names; + } + + @Override + public VdbCollection describe(String name) { + JsonNode schema = rest.get("/v1/schema/" + encode(name)); + VdbCollection collection = new VdbCollection(name); + collection.setRemarks(StringUtils.trimToNull(text(schema, "description"))); + List tenants = new ArrayList<>(); + if (schema.path("multiTenancyConfig").path("enabled").asBoolean(false)) { + rest.get("/v1/schema/" + encode(name) + "/tenants").forEach(tenant -> tenants.add(text(tenant, + "name"))); + VdbField tenant = VdbField.varchar(TENANT, 64); + tenant.setPrimaryKey(true); + tenant.setNullable(false); + collection.addField(tenant); + } + collection.getProperties().put(TENANTS, tenants); + VdbField id = VdbField.varchar("id", 36); + id.setPrimaryKey(true); + id.setNullable(false); + collection.addField(id); + + Map dimensions = getDimensions(name, tenants); + JsonNode vectorConfig = schema.path("vectorConfig"); + boolean named = vectorConfig.isObject() && vectorConfig.size() > 0; + collection.getProperties().put(NAMED, named); + if (named) { + vectorConfig.fields().forEachRemaining(entry -> collection.addField(toVectorField(entry.getKey(), + text(entry.getValue(), "vectorIndexType"), entry.getValue().path("vectorIndexConfig"), + dimensions.getOrDefault(entry.getKey(), 0)))); + } else if (dimensions.containsKey(UNNAMED_VECTOR) || schema.has("vectorIndexConfig")) { + collection.addField(toVectorField(UNNAMED_VECTOR, text(schema, "vectorIndexType"), + schema.path("vectorIndexConfig"), dimensions.getOrDefault(UNNAMED_VECTOR, 0))); + } + List properties = new ArrayList<>(); + for (JsonNode property : schema.path("properties")) { + properties.add(property); + VdbField field = toField(collection.getUnusedName(text(property, "name")), property); + field.setSourceName(text(property, "name")); + collection.addField(field); + } + collection.getProperties().put(PROPERTIES, properties); + addPayloadField(collection); + collection.setCount(count(collection, VdbQuery.all())); + return collection; + } + + /** + * Dimensions of the vectors, read of an object + */ + private Map getDimensions(String name, List tenants) { + Map dimensions = new LinkedHashMap<>(); + for (String tenant : tenants.isEmpty() ? List.of("") : tenants) { + JsonNode objects = rest.get("/v1/objects?class=" + encode(name) + "&limit=1&include=vector" + + (tenant.isEmpty() ? "" : "&tenant=" + encode(tenant))).path("objects"); + if (objects.size() > 0) { + JsonNode object = objects.get(0); + object.path("vectors").fields().forEachRemaining(entry -> dimensions.put(entry.getKey(), + entry.getValue().size())); + if (object.path("vector").isArray() && object.path("vector").size() > 0) { + dimensions.put(UNNAMED_VECTOR, object.path("vector").size()); + } + break; + } + } + return dimensions; + } + + /** + * Column of a vector, JSON of a multivector. HNSW and dynamic indexes are HNSW, flat indexes are scans. + */ + static VdbField toVectorField(String name, String indexType, JsonNode config, int dimensions) { + if (config.path("multivector").path("enabled").asBoolean(false)) { + VdbField field = new VdbField(name, VdbField.Kind.JSON); + field.setRemarks("Multivector of Weaviate"); + return field; + } + VdbField field = VdbField.vector(name, VectorType.dense(VectorType.Element.FLOAT32, dimensions)); + String type = StringUtils.defaultIfBlank(indexType, "hnsw").toLowerCase(Locale.ENGLISH); + JsonNode hnsw = "dynamic".equals(type) ? config.path("hnsw") : config; + VectorIndex.Distance distance = toDistance(StringUtils.defaultIfBlank(text(config, "distance"), + text(hnsw, "distance"))); + if (distance != null && !"flat".equals(type)) { + Map options = new LinkedHashMap<>(); + if (hnsw.path("maxConnections").asInt(0) > 0) { + options.put("m", hnsw.path("maxConnections").asText()); + } + if (hnsw.path("efConstruction").asInt(0) > 0) { + options.put("ef_construction", hnsw.path("efConstruction").asText()); + } + field.setVectorIndex(new VectorIndex(VectorIndex.Kind.HNSW, distance, options)); + } + return field; + } + + static VectorIndex.Distance toDistance(String distance) { + switch (StringUtils.defaultIfBlank(distance, "cosine").toLowerCase(Locale.ENGLISH)) { + case "cosine": + return VectorIndex.Distance.COSINE; + case "l2-squared": + return VectorIndex.Distance.EUCLIDEAN; + case "dot": + return VectorIndex.Distance.DOT; + case "manhattan": + return VectorIndex.Distance.MANHATTAN; + case "hamming": + return VectorIndex.Distance.HAMMING; + default: + return null; + } + } + + /** + * Column of a property of its data type, arrays and objects are JSON + */ + static VdbField toField(String name, JsonNode property) { + String dataType = StringUtils.defaultString(property.path("dataType").path(0).asText()); + VdbField field; + switch (dataType) { + case "text": + case "string": + field = new VdbField(name, VdbField.Kind.TEXT); + break; + case "int": + field = new VdbField(name, VdbField.Kind.BIGINT); + break; + case "number": + field = new VdbField(name, VdbField.Kind.DOUBLE); + break; + case "boolean": + field = new VdbField(name, VdbField.Kind.BOOLEAN); + break; + case "date": + field = new VdbField(name, VdbField.Kind.TIMESTAMP); + break; + case "uuid": + field = VdbField.varchar(name, 36); + break; + case "blob": + field = new VdbField(name, VdbField.Kind.BINARY); + break; + default: + // Arrays, objects, geo coordinates, phone numbers and references + field = new VdbField(name, VdbField.Kind.JSON); + break; + } + field.setRemarks(StringUtils.trimToNull(text(property, "description"))); + return field; + } + + @SuppressWarnings("unchecked") + @Override + public long count(VdbCollection collection, VdbQuery query) { + if (query.isNearest()) { + return query.getLimit(); + } + long count = 0; + for (String tenant : getParts(collection, query)) { + String arguments = arguments(tenant, toWhere(collection, query), null); + JsonNode result = graphQl(String.format("{ Aggregate { %s%s { meta { count } } } }", collection.getName(), + arguments.isEmpty() ? "" : "(" + arguments + ")")); + JsonNode aggregate = result.path("Aggregate").path(collection.getName()); + count += aggregate.size() > 0 ? aggregate.get(0).path("meta").path("count").asLong() : 0; + } + return query.getLimit() > 0 ? Math.min(count, query.getLimit()) : count; + } + + @Override + public List listPartitions(String collection) { + List tenants = new ArrayList<>(); + if (rest.get("/v1/schema/" + encode(collection)).path("multiTenancyConfig").path("enabled").asBoolean(false)) { + rest.get("/v1/schema/" + encode(collection) + "/tenants").forEach(tenant -> tenants.add(text(tenant, + "name"))); + } + return tenants; + } + + /** + * Tenants of a multi-tenant class + */ + @SuppressWarnings("unchecked") + @Override + public List getPartitions(VdbCollection collection) { + return (List) collection.getProperties().get(TENANTS); + } + + /** + * Tenants which are read, the tenants of the query or all, the class itself when it has no tenants + */ + private List getParts(VdbCollection collection, VdbQuery query) { + List tenants = getPartitions(collection); + return tenants.isEmpty() ? List.of("") : query.selectPartitions(tenants); + } + + private JsonNode graphQl(String query) { + JsonNode response = rest.post("/v1/graphql", RestClient.getMapper().createObjectNode().put("query", query)); + if (response.path("errors").size() > 0) { + throw new VdbException("Weaviate: " + response.path("errors").get(0).path("message").asText()); + } + return response.path("data"); + } + + @SuppressWarnings("unchecked") + @Override + public void read(VdbCollection collection, VdbQuery query, int pageSize, PageConsumer consumer) { + List parts = getParts(collection, query); + if (parts.isEmpty()) { + consumer.accept(List.of(), true); + return; + } + long[] remaining = {query.getLimit() > 0 ? query.getLimit() : Long.MAX_VALUE}; + for (int i = 0; i < parts.size() && remaining[0] > 0; i++) { + String tenant = parts.get(i); + boolean lastTenant = i == parts.size() - 1; + boolean[] stopped = {false}; + PageConsumer partConsumer = (records, last) -> { + remaining[0] -= records.size(); + boolean end = last && lastTenant || remaining[0] <= 0; + if (!consumer.accept(records, end)) { + stopped[0] = true; + return false; + } + return true; + }; + if (!query.isFiltered() && !query.isNearest()) { + readByCursor(collection, tenant, remaining[0], pageSize, partConsumer); + } else { + readByGraphQl(collection, query, tenant, remaining[0], pageSize, partConsumer); + } + if (stopped[0]) { + return; + } + } + } + + private void readByCursor(VdbCollection collection, String tenant, long limit, int pageSize, + PageConsumer consumer) { + String after = null; + long remaining = limit; + while (remaining > 0) { + int size = (int) Math.min(pageSize, remaining); + String path = "/v1/objects?class=" + encode(collection.getName()) + "&limit=" + size + "&include=vector" + + (tenant.isEmpty() ? "" : "&tenant=" + encode(tenant)) + (after == null ? "" : "&after=" + + encode(after)); + JsonNode objects = rest.get(path).path("objects"); + List> rows = new ArrayList<>(); + for (JsonNode object : objects) { + rows.add(toRow(collection, tenant, text(object, "id"), object.path("vectors"), object.path("vector"), + object.path("properties"))); + after = text(object, "id"); + } + remaining -= rows.size(); + boolean last = rows.size() < size || remaining <= 0; + if (!consumer.accept(rows, last) || last) { + return; + } + } + } + + /** + * Objects of a filter or a search by GraphQL: pages by offset, QUERY_MAXIMUM_RESULTS of the server limits them + */ + @SuppressWarnings("unchecked") + private void readByGraphQl(VdbCollection collection, VdbQuery query, String tenant, long limit, int pageSize, + PageConsumer consumer) { + boolean named = Boolean.TRUE.equals(collection.getProperties().get(NAMED)); + List properties = (List) collection.getProperties().get(PROPERTIES); + StringBuilder selection = new StringBuilder(); + properties.forEach(property -> selection.append(selectionOf(property)).append(' ')); + List vectors = new ArrayList<>(); + collection.getVectorFields().forEach(field -> vectors.add(field.getSourceName())); + collection.getFields().stream().filter(field -> field.getKind() == VdbField.Kind.JSON + && "Multivector of Weaviate".equals(field.getRemarks())).forEach(field -> vectors.add(field.getName())); + String additional = named ? "_additional { id vectors { " + String.join(" ", vectors) + " } }" + : "_additional { id vector }"; + String where = toWhere(collection, query); + String search = query.isNearest() ? toSearch(query, named) : null; + long remaining = Math.min(limit, query.isNearest() && query.getLimit() <= 0 ? pageSize : limit); + int offset = 0; + while (remaining > 0) { + int size = (int) Math.min(Math.min(pageSize, remaining), MAX_QUERY_RESULTS - offset); + if (size <= 0) { + throw new VdbException("Weaviate reads " + MAX_QUERY_RESULTS + " objects of a filter at most" + + " (QUERY_MAXIMUM_RESULTS), please narrow the filter or read the whole class"); + } + // Pages of a filter are sorted by the ids, the order of a filter alone is not stable + String arguments = arguments(tenant, where, search) + (search == null ? ", sort: [{ path: [\"_id\"], " + + "order: asc }], offset: " + offset : "") + ", limit: " + size; + JsonNode objects = graphQl(String.format("{ Get { %s(%s) { %s%s } } }", collection.getName(), + StringUtils.removeStart(arguments, ", "), selection, additional)).path("Get") + .path(collection.getName()); + List> rows = new ArrayList<>(); + for (JsonNode object : objects) { + JsonNode extra = object.path("_additional"); + ObjectNode propertiesNode = ((ObjectNode) object.deepCopy()); + propertiesNode.remove("_additional"); + rows.add(toRow(collection, tenant, text(extra, "id"), extra.path("vectors"), extra.path("vector"), + propertiesNode)); + } + remaining -= rows.size(); + offset += rows.size(); + boolean last = rows.size() < size || remaining <= 0 || search != null; + if (!consumer.accept(rows, last) || last) { + return; + } + } + } + + /** + * Selection of a property in GraphQL, objects select their nested properties + */ + static String selectionOf(JsonNode property) { + String name = text(property, "name"); + JsonNode nested = property.path("nestedProperties"); + String dataType = property.path("dataType").path(0).asText(); + if (nested.size() > 0) { + StringBuilder selection = new StringBuilder(name).append(" { "); + nested.forEach(child -> selection.append(selectionOf(child)).append(' ')); + return selection.append('}').toString(); + } + if ("geoCoordinates".equals(dataType)) { + return name + " { latitude longitude }"; + } else if ("phoneNumber".equals(dataType)) { + return name + " { input internationalFormatted countryCode national nationalFormatted valid }"; + } else if (Character.isUpperCase(dataType.isEmpty() ? 'a' : dataType.charAt(0))) { + // A reference to another class + return name + " { ... on " + dataType + " { _additional { id } } }"; + } + return name; + } + + private static String arguments(String tenant, String where, String search) { + StringBuilder arguments = new StringBuilder(); + if (!tenant.isEmpty()) { + arguments.append(", tenant: ").append(quote(tenant)); + } + if (where != null) { + arguments.append(", where: ").append(where); + } + if (search != null) { + arguments.append(", ").append(search); + } + return StringUtils.removeStart(arguments.toString(), ", "); + } + + private static String toSearch(VdbQuery query, boolean named) { + String target = named ? ", targetVectors: [" + quote(query.getNearestField()) + "]" : ""; + if (query.getNearestVector() != null) { + return "nearVector: { vector: " + VdbValues.toDenseText(query.getNearestVector()) + target + " }"; + } + return "nearObject: { id: " + quote(String.valueOf(query.getNearestId())) + target + " }"; + } + + private Map toRow(VdbCollection collection, String tenant, String id, JsonNode vectors, + JsonNode vector, JsonNode properties) { + Map values = new LinkedHashMap<>(); + values.put("id", id); + if (!tenant.isEmpty()) { + values.put(TENANT, tenant); + } + for (VdbField field : collection.getFields()) { + if (field.isVector()) { + JsonNode value = UNNAMED_VECTOR.equals(field.getName()) && vector.isArray() ? vector + : vectors.get(field.getSourceName()); + values.put(field.getName(), VdbValues.toDenseText(value)); + } else if ("Multivector of Weaviate".equals(field.getRemarks())) { + values.put(field.getName(), VdbValues.toJson(vectors.get(field.getName()))); + } + } + return toRow(collection, values, properties); + } + + /** + * Where filter of GraphQL: the native filter, or the conditions joined by And + */ + static String toWhere(VdbCollection collection, VdbQuery query) { + if (query.isNative()) { + return query.getNativeFilter().trim(); + } + if (query.getConditions().isEmpty()) { + return null; + } + List operands = new ArrayList<>(); + for (VdbQuery.Condition condition : query.getConditions()) { + String path = collection.findField(condition.getField()).map(VdbField::getSourceName) + .orElse(condition.getField()); + Object value = condition.getValue(); + String operator; + switch (condition.getOperator()) { + case EQ: + operator = "Equal"; + break; + case NE: + operator = "NotEqual"; + break; + case GT: + operator = "GreaterThan"; + break; + case GE: + operator = "GreaterThanEqual"; + break; + case LT: + operator = "LessThan"; + break; + case LE: + operator = "LessThanEqual"; + break; + default: + operator = "ContainsAny"; + break; + } + operands.add(String.format("{ path: [%s], operator: %s, %s }", quote(path), operator, + valueOf(value))); + } + return operands.size() == 1 ? operands.get(0) : "{ operator: And, operands: [" + String.join(", ", operands) + + "] }"; + } + + private static String valueOf(Object value) { + if (value instanceof Iterable) { + List items = new ArrayList<>(); + Iterator it = ((Iterable) value).iterator(); + Object first = null; + while (it.hasNext()) { + Object item = it.next(); + first = first == null ? item : first; + items.add(item instanceof Number || item instanceof Boolean ? item.toString() : quote(String.valueOf( + item))); + } + String key = first instanceof Integer || first instanceof Long ? "valueIntArray" + : first instanceof Number ? "valueNumberArray" : first instanceof Boolean ? "valueBooleanArray" + : "valueTextArray"; + return key + ": [" + String.join(", ", items) + "]"; + } + if (value instanceof Integer || value instanceof Long) { + return "valueInt: " + value; + } else if (value instanceof Number) { + return "valueNumber: " + value; + } else if (value instanceof Boolean) { + return "valueBoolean: " + value; + } + return "valueText: " + quote(String.valueOf(value)); + } + + static String quote(String text) { + return "\"" + text.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/dialect/ClickHouseVdbDialect.java b/src/main/java/com/github/sqljam/vdb/dialect/ClickHouseVdbDialect.java new file mode 100644 index 0000000..730da9c --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/dialect/ClickHouseVdbDialect.java @@ -0,0 +1,207 @@ +/* + * 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.vdb.dialect; + +import java.sql.Connection; +import java.sql.SQLException; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: ClickHouseVdbDialect writes into ClickHouse 24.8 and later: vectors are arrays (Array(Float32), + * Array(Float64)) which take no NULL, a missing vector is an empty array. Vector similarity indexes + * (HNSW) of NOT NULL columns are created when the settings allow them. Tables are MergeTree ordered + * by their keys, ClickHouse has no upserts. Rules are copied from ClickHouseDialect of impexp. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class ClickHouseVdbDialect extends VdbDialect { + + static final long VECTOR_INDEX_GRANULARITY = 100_000_000L; + private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern( + "yyyy-MM-dd HH:mm:ss.SSSSSS"); + + @Override + public String getName() { + return "ClickHouse"; + } + + @Override + protected void detectFeatures(Connection connection, Set features) throws SQLException { + if (probe(connection, "SELECT CAST([1.0] AS Array(Float32))")) { + features.add(VdbFeature.VECTOR); + } + if (probe(connection, "SELECT 1 FROM system.settings WHERE name IN" + + " ('allow_experimental_vector_similarity_index', 'enable_vector_similarity_index') AND value = '1'")) { + features.add(VdbFeature.HNSW_INDEX); + } + } + + @Override + protected String getOpenQuote() { + return "`"; + } + + @Override + protected String getCloseQuote() { + return "`"; + } + + @Override + protected boolean isCatalogQualifier() { + return true; + } + + /** + * Scalars which take NULL are Nullable, NOT NULL is not written + */ + @Override + protected String getScalarTypeName(VdbField field) { + String type; + switch (field.getKind()) { + case BOOLEAN: + type = "Bool"; + break; + case SMALLINT: + type = "Int16"; + break; + case INTEGER: + type = "Int32"; + break; + case BIGINT: + type = "Int64"; + break; + case REAL: + type = "Float32"; + break; + case DOUBLE: + type = "Float64"; + break; + case TIMESTAMP: + type = "DateTime64(6, 'UTC')"; + break; + default: + type = "String"; + break; + } + return field.isNullable() && !field.isPrimaryKey() ? "Nullable(" + type + ")" : type; + } + + @Override + public String getVectorTypeName(VectorType vectorType, boolean nullable) { + if (!hasFeature(VdbFeature.VECTOR) || vectorType.isSparse() + || vectorType.getElement() == VectorType.Element.BINARY) { + return null; + } + return vectorType.getElement() == VectorType.Element.FLOAT64 ? "Array(Float64)" : "Array(Float32)"; + } + + @Override + public String getColumnDefinition(Column column) { + String remarks = column.getField().getRemarks(); + return getIdentifier(column.getName()) + " " + column.getTypeName() + (StringUtils.isNotBlank(remarks) + ? " COMMENT " + quoteLiteral(remarks) : ""); + } + + @Override + public String getPrimaryKeyDefinition(String tableName, List keyColumnNames) { + return null; + } + + @Override + public String getTableOptions(List keyColumnNames) { + return " ENGINE = MergeTree ORDER BY " + (keyColumnNames.isEmpty() ? "tuple()" : "(" + getIdentifiers( + keyColumnNames) + ")"); + } + + @Override + protected String quoteLiteralText(String text) { + return text.replace("\\", "\\\\"); + } + + @Override + public String getTableCommentStatement(String schema, String tableName, String comment) { + return String.format("ALTER TABLE %s MODIFY COMMENT %s", getQualifiedTableName(schema, tableName), + quoteLiteral(comment)); + } + + /** + * Comments of columns are in their definitions + */ + @Override + public String getColumnCommentStatement(String schema, String tableName, String columnName, String comment) { + return null; + } + + @Override + public boolean isTransactionSupported() { + return false; + } + + @Override + public boolean isUpsertSupported() { + return false; + } + + @Override + public String getUpsertStatement(String schema, String tableName, List columns, + List keyColumnNames) { + throw new UnsupportedOperationException("ClickHouse has no upserts"); + } + + /** + * A missing vector is an empty array, times are text of UTC + */ + @Override + public Object getJdbcValue(Object value, Column column) { + if (value == null) { + return column.getTypeName().startsWith("Array(") ? "[]" : null; + } + if (value instanceof OffsetDateTime) { + return ((OffsetDateTime) value).withOffsetSameInstant(ZoneOffset.UTC).format(TIMESTAMP_FORMAT); + } + return super.getJdbcValue(value, column); + } + + @Override + public String getCreateVectorIndexStatement(String schema, String tableName, String indexName, String columnName, + String columnTypeName, VectorType vectorType, boolean nullable, + VectorIndex vectorIndex) { + VectorIndex.Distance distance = vectorIndex.getDistance(); + // Empty arrays of missing vectors are of other dimensions, they would fail the index + if (!hasFeature(VdbFeature.HNSW_INDEX) || nullable || vectorType == null || vectorType.getDimensions() < 1 + || (distance != VectorIndex.Distance.COSINE && distance != VectorIndex.Distance.EUCLIDEAN)) { + return null; + } + String neighbors = vectorIndex.getOption("m"); + String candidates = vectorIndex.getOption("ef_construction"); + String options = StringUtils.isNumeric(neighbors) && StringUtils.isNumeric(candidates) + ? String.format(", 'bf16', %s, %s", neighbors, candidates) : ""; + return String.format("ALTER TABLE %s ADD INDEX %s %s TYPE vector_similarity('hnsw', '%s', %d%s)" + + " GRANULARITY %d", getQualifiedTableName(schema, tableName), getIdentifier(indexName), + getIdentifier(columnName), distance == VectorIndex.Distance.COSINE ? "cosineDistance" : "L2Distance", + vectorType.getDimensions(), options, VECTOR_INDEX_GRANULARITY); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/dialect/Db2VdbDialect.java b/src/main/java/com/github/sqljam/vdb/dialect/Db2VdbDialect.java new file mode 100644 index 0000000..f27062b --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/dialect/Db2VdbDialect.java @@ -0,0 +1,148 @@ +/* + * 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.vdb.dialect; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Types; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: Db2VdbDialect writes into Db2 12.1.2 and later: VECTOR columns of FLOAT32 and INT8 elements, vector + * indexes (12.1.5) of keys up to 8000 bytes with the Euclidean or the cosine distance, upserts by + * MERGE. Rules are copied from Db2Dialect of impexp. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class Db2VdbDialect extends VdbDialect { + + static final int MAX_VECTOR_DIMENSIONS = 8168; + static final int MAX_VECTOR_INDEX_BYTES = 8000; + private static final int BYTES_PER_CHAR = 3; + private static final int MAX_VARCHAR_BYTES = 32672; + + @Override + public String getName() { + return "Db2"; + } + + @Override + protected void detectFeatures(Connection connection, Set features) throws SQLException { + if (probe(connection, "SELECT VECTOR('[1]', 1, FLOAT32) FROM sysibm.sysdummy1")) { + features.add(VdbFeature.VECTOR); + features.add(VdbFeature.HNSW_INDEX); + } + } + + @Override + public String foldIdentifier(String name) { + return name.toUpperCase(Locale.ENGLISH); + } + + @Override + protected String getScalarTypeName(VdbField field) { + switch (field.getKind()) { + case BOOLEAN: + return "BOOLEAN"; + case SMALLINT: + return "SMALLINT"; + case INTEGER: + return "INTEGER"; + case BIGINT: + return "BIGINT"; + case REAL: + return "REAL"; + case DOUBLE: + return "DOUBLE"; + case VARCHAR: + long bytes = (long) field.getSize() * BYTES_PER_CHAR; + return field.getSize() > 0 && bytes <= MAX_VARCHAR_BYTES ? "VARCHAR(" + bytes + ")" : "CLOB(2G)"; + case TIMESTAMP: + return "TIMESTAMP(6)"; + case BINARY: + return "BLOB(2G)"; + default: + return "CLOB(2G)"; + } + } + + @Override + protected boolean isTimeZoneSupported() { + return false; + } + + @Override + public String getVectorTypeName(VectorType vectorType, boolean nullable) { + int dimensions = vectorType.getDimensions(); + if (!hasFeature(VdbFeature.VECTOR) || vectorType.isSparse() || !vectorType.isFloat32Compatible() + || dimensions < 1 || dimensions > MAX_VECTOR_DIMENSIONS) { + return null; + } + return String.format("VECTOR(%d, %s)", dimensions, vectorType.getElement() == VectorType.Element.INT8 ? "INT8" + : "FLOAT32"); + } + + /** + * Vectors are converted from their text, parameters of MERGE are cast to the types of their columns + */ + @Override + public String getParameterMarker(Column column) { + String type = column.getTypeName(); + if (type.startsWith("VECTOR(")) { + return "VECTOR(CAST(? AS CLOB(2G)), " + StringUtils.substringBetween(type, "(", ")") + ")"; + } + return "CAST(? AS " + type + ")"; + } + + @Override + public int getNullSqlType(Column column) { + return column.getTypeName().startsWith("VECTOR(") ? Types.CLOB : super.getNullSqlType(column); + } + + @Override + public String getUpsertStatement(String schema, String tableName, List columns, + List keyColumnNames) { + String source = "(VALUES (" + columns.stream().map(this::getParameterMarker).collect(Collectors.joining( + ", ")) + ")) AS src (" + getIdentifiers(names(columns)) + ")"; + return getMergeStatement(schema, tableName, columns, keyColumnNames, source, ""); + } + + @Override + public String getCreateVectorIndexStatement(String schema, String tableName, String indexName, String columnName, + String columnTypeName, VectorType vectorType, boolean nullable, + VectorIndex vectorIndex) { + VectorIndex.Distance distance = vectorIndex.getDistance(); + int bytes = vectorType == null ? 0 : vectorType.getDimensions() * (vectorType.getElement() + == VectorType.Element.INT8 ? 1 : Float.BYTES); + if (!hasFeature(VdbFeature.HNSW_INDEX) || bytes > MAX_VECTOR_INDEX_BYTES + || (distance != VectorIndex.Distance.EUCLIDEAN && distance != VectorIndex.Distance.COSINE)) { + return null; + } + String index = StringUtils.isNotBlank(schema) ? getIdentifier(schema) + "." + getIdentifier(indexName) + : getIdentifier(indexName); + return String.format("CREATE VECTOR INDEX %s ON %s (%s) WITH DISTANCE %s", index, + getQualifiedTableName(schema, tableName), getIdentifier(columnName), distance); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/dialect/DuckDBVdbDialect.java b/src/main/java/com/github/sqljam/vdb/dialect/DuckDBVdbDialect.java new file mode 100644 index 0000000..44b8547 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/dialect/DuckDBVdbDialect.java @@ -0,0 +1,104 @@ +/* + * 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.vdb.dialect; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Types; +import java.time.OffsetDateTime; +import java.util.Set; + +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: DuckDBVdbDialect writes into DuckDB 1.0 and later: vectors are arrays of a fixed size (FLOAT[n], + * DOUBLE[n]), upserts by ON CONFLICT. Indexes of the vss extension are experimental, none are created. + * Rules are copied from DuckDBDialect of impexp. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class DuckDBVdbDialect extends VdbDialect { + + @Override + public String getName() { + return "DuckDB"; + } + + @Override + protected void detectFeatures(Connection connection, Set features) throws SQLException { + if (probe(connection, "SELECT [1.0]::FLOAT[1]")) { + features.add(VdbFeature.VECTOR); + } + } + + @Override + protected String getScalarTypeName(VdbField field) { + switch (field.getKind()) { + case BOOLEAN: + return "BOOLEAN"; + case SMALLINT: + return "SMALLINT"; + case INTEGER: + return "INTEGER"; + case BIGINT: + return "BIGINT"; + case REAL: + return "REAL"; + case DOUBLE: + return "DOUBLE"; + case TIMESTAMP: + return "TIMESTAMPTZ"; + case BINARY: + return "BLOB"; + default: + // JSON of the json extension is not loaded offline, it is text + return "VARCHAR"; + } + } + + @Override + public String getVectorTypeName(VectorType vectorType, boolean nullable) { + if (!hasFeature(VdbFeature.VECTOR) || vectorType.isSparse() || vectorType.getDimensions() < 1 + || vectorType.getElement() == VectorType.Element.BINARY) { + return null; + } + return (vectorType.getElement() == VectorType.Element.FLOAT64 ? "DOUBLE[" : "FLOAT[") + + vectorType.getDimensions() + "]"; + } + + /** + * Arrays and times are cast from their text + */ + @Override + public String getParameterMarker(Column column) { + String type = column.getTypeName(); + return type.endsWith("]") || "TIMESTAMPTZ".equals(type) ? "CAST(? AS " + type + ")" : "?"; + } + + @Override + public Object getJdbcValue(Object value, Column column) { + Object jdbcValue = super.getJdbcValue(value, column); + return jdbcValue instanceof OffsetDateTime ? ((OffsetDateTime) jdbcValue).format(OFFSET_TIMESTAMP_FORMAT) + : jdbcValue; + } + + @Override + public int getNullSqlType(Column column) { + return column.getField().getKind() == VdbField.Kind.TIMESTAMP ? Types.VARCHAR : super.getNullSqlType(column); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/dialect/MariaDBVdbDialect.java b/src/main/java/com/github/sqljam/vdb/dialect/MariaDBVdbDialect.java new file mode 100644 index 0000000..b1853db --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/dialect/MariaDBVdbDialect.java @@ -0,0 +1,127 @@ +/* + * 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.vdb.dialect; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.apache.commons.lang3.math.NumberUtils; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: MariaDBVdbDialect writes into MariaDB 11.7 and later: VECTOR columns like MySQL, vector indexes of + * NOT NULL columns by HNSW (M of 3 to 200) with the Euclidean or the cosine distance, upserts by + * ON DUPLICATE KEY UPDATE of VALUES(). Rules are copied from MariaDBDialect of impexp. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class MariaDBVdbDialect extends MySQLVdbDialect { + + static final int MIN_VECTOR_NEIGHBORS = 3; + static final int MAX_VECTOR_NEIGHBORS = 200; + + @Override + public String getName() { + return "MariaDB"; + } + + @Override + protected void detectFeatures(Connection connection, Set features) throws SQLException { + super.detectFeatures(connection, features); + if (features.contains(VdbFeature.VECTOR)) { + features.add(VdbFeature.HNSW_INDEX); + } + } + + @Override + protected String getVectorTextFunction() { + return "VEC_ToText"; + } + + @Override + protected String getTextVectorFunction() { + return "VEC_FromText"; + } + + @Override + protected boolean isRowAliasSupported() { + return false; + } + + /** + * Bytes of a primary key of a table with a vector index at most + */ + static final int MAX_VECTOR_INDEX_KEY_BYTES = 256; + + /** + * The key of a vector index is the primary key, text of utf8mb4 takes 4 bytes a character + */ + @Override + public boolean isVectorIndexAllowed(List keyColumns) { + int bytes = 0; + for (Column column : keyColumns) { + switch (column.getField().getKind()) { + case BOOLEAN: + bytes += 1; + break; + case SMALLINT: + bytes += Short.BYTES; + break; + case INTEGER: + case REAL: + bytes += Integer.BYTES; + break; + case BIGINT: + case DOUBLE: + case TIMESTAMP: + bytes += Long.BYTES; + break; + case VARCHAR: + bytes += column.getField().getSize() * 4; + break; + default: + return false; + } + } + return bytes <= MAX_VECTOR_INDEX_KEY_BYTES; + } + + @Override + public int getMaxVectorIndexes() { + return 1; + } + + @Override + public String getCreateVectorIndexStatement(String schema, String tableName, String indexName, String columnName, + String columnTypeName, VectorType vectorType, boolean nullable, + VectorIndex vectorIndex) { + VectorIndex.Distance distance = vectorIndex.getDistance(); + if (!hasFeature(VdbFeature.HNSW_INDEX) || nullable || (distance != VectorIndex.Distance.EUCLIDEAN + && distance != VectorIndex.Distance.COSINE)) { + return null; + } + int neighbors = NumberUtils.toInt(vectorIndex.getOption("m"), 0); + return String.format("CREATE VECTOR INDEX %s ON %s (%s)%s DISTANCE=%s", getIdentifier(indexName), + getQualifiedTableName(schema, tableName), getIdentifier(columnName), + neighbors >= MIN_VECTOR_NEIGHBORS && neighbors <= MAX_VECTOR_NEIGHBORS ? " M=" + neighbors : "", + distance.name().toLowerCase(Locale.ENGLISH)); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/dialect/MySQLVdbDialect.java b/src/main/java/com/github/sqljam/vdb/dialect/MySQLVdbDialect.java new file mode 100644 index 0000000..ea61d23 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/dialect/MySQLVdbDialect.java @@ -0,0 +1,193 @@ +/* + * 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.vdb.dialect; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: MySQLVdbDialect writes into MySQL 9.0 and later: VECTOR columns of 32-bit floats, written as + * binaries of little endian floats, upserts by ON DUPLICATE KEY UPDATE of a row alias. MySQL has no + * vector indexes. Rules are copied from MySQLDialect of impexp. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class MySQLVdbDialect extends VdbDialect { + + static final int MAX_VECTOR_DIMENSIONS = 16383; + /** + * Characters of VARCHAR at most, longer text is LONGTEXT + */ + static final int MAX_VARCHAR_LENGTH = 16383; + + @Override + public String getName() { + return "MySQL"; + } + + @Override + protected void detectFeatures(Connection connection, Set features) throws SQLException { + if (probe(connection, "SELECT " + getVectorTextFunction() + "(" + getTextVectorFunction() + "('[1]'))")) { + features.add(VdbFeature.VECTOR); + } + } + + protected String getVectorTextFunction() { + return "VECTOR_TO_STRING"; + } + + protected String getTextVectorFunction() { + return "STRING_TO_VECTOR"; + } + + @Override + protected String getOpenQuote() { + return "`"; + } + + @Override + protected String getCloseQuote() { + return "`"; + } + + @Override + public int getMaxIdentifierLength() { + return 64; + } + + @Override + protected boolean isCatalogQualifier() { + return true; + } + + @Override + protected String getScalarTypeName(VdbField field) { + switch (field.getKind()) { + case BOOLEAN: + return "BOOLEAN"; + case SMALLINT: + return "SMALLINT"; + case INTEGER: + return "INT"; + case BIGINT: + return "BIGINT"; + case REAL: + return "FLOAT"; + case DOUBLE: + return "DOUBLE"; + case VARCHAR: + return field.getSize() > 0 && field.getSize() <= MAX_VARCHAR_LENGTH ? "VARCHAR(" + field.getSize() + + ")" : "LONGTEXT"; + case TIMESTAMP: + return "DATETIME(6)"; + case BINARY: + return "LONGBLOB"; + case JSON: + return "JSON"; + default: + return "LONGTEXT"; + } + } + + @Override + protected boolean isTimeZoneSupported() { + return false; + } + + @Override + public String getVectorTypeName(VectorType vectorType, boolean nullable) { + int dimensions = vectorType.getDimensions(); + if (!hasFeature(VdbFeature.VECTOR) || vectorType.isSparse() || !vectorType.isFloat32Compatible() + || dimensions < 1 || dimensions > MAX_VECTOR_DIMENSIONS) { + return null; + } + return "VECTOR(" + dimensions + ")"; + } + + /** + * Vectors are binaries of little endian 32-bit floats + */ + @Override + protected Object getVectorValue(VectorType.Values values, VectorType vectorType) { + float[] floats = values.toFloats(); + ByteBuffer buffer = ByteBuffer.allocate(floats.length * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (float value : floats) { + buffer.putFloat(value); + } + return buffer.array(); + } + + @Override + public String getColumnDefinition(Column column) { + String remarks = column.getField().getRemarks(); + return super.getColumnDefinition(column) + (StringUtils.isNotBlank(remarks) ? " COMMENT " + + quoteLiteral(StringUtils.left(remarks, 1024)) : ""); + } + + @Override + public String getPrimaryKeyDefinition(String tableName, List keyColumnNames) { + return "PRIMARY KEY (" + getIdentifiers(keyColumnNames) + ")"; + } + + @Override + public String getTableCommentStatement(String schema, String tableName, String comment) { + return String.format("ALTER TABLE %s COMMENT = %s", getQualifiedTableName(schema, tableName), + quoteLiteral(StringUtils.left(comment, 2048))); + } + + /** + * Comments of columns are in their definitions + */ + @Override + public String getColumnCommentStatement(String schema, String tableName, String columnName, String comment) { + return null; + } + + @Override + protected String quoteLiteralText(String text) { + return text.replace("\\", "\\\\"); + } + + /** + * Whether the values of a row are named by an alias (MySQL 8.0.19 and later), VALUES() is deprecated there + */ + protected boolean isRowAliasSupported() { + return true; + } + + @Override + public String getUpsertStatement(String schema, String tableName, List columns, + List keyColumnNames) { + boolean alias = isRowAliasSupported(); + String insert = getInsertStatement(schema, tableName, columns) + (alias ? " AS new_row" : ""); + List updates = updated(columns, keyColumnNames); + // A row of keys only sets a key to its own value, the existing row is kept + List updated = updates.isEmpty() ? keyColumnNames.subList(0, 1) : updates; + return String.format("%s ON DUPLICATE KEY UPDATE %s", insert, updated.stream().map(name -> getIdentifier(name) + + " = " + (alias ? "new_row." + getIdentifier(name) : "VALUES(" + getIdentifier(name) + ")")) + .collect(Collectors.joining(", "))); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/dialect/OracleVdbDialect.java b/src/main/java/com/github/sqljam/vdb/dialect/OracleVdbDialect.java new file mode 100644 index 0000000..91d33a0 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/dialect/OracleVdbDialect.java @@ -0,0 +1,186 @@ +/* + * 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.vdb.dialect; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: OracleVdbDialect writes into Oracle 23ai and later: VECTOR columns of FLOAT32, FLOAT64, INT8 and + * BINARY elements, sparse vectors (23.7), IVF indexes, HNSW indexes when the vector memory is set, + * upserts by MERGE. Rules are copied from OracleDialect of impexp. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class OracleVdbDialect extends VdbDialect { + + static final int MAX_VECTOR_DIMENSIONS = 65535; + static final int MAX_VARCHAR_LENGTH = 4000; + + @Override + public String getName() { + return "Oracle"; + } + + @Override + protected void detectFeatures(Connection connection, Set features) throws SQLException { + if (!probe(connection, "SELECT TO_VECTOR('[1]') FROM dual")) { + return; + } + features.add(VdbFeature.VECTOR); + features.add(VdbFeature.IVF_INDEX); + if (probe(connection, "SELECT TO_VECTOR('[1,[0],[1]]', 1, FLOAT32, SPARSE) FROM dual")) { + features.add(VdbFeature.SPARSE_VECTOR); + } + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery( + "SELECT value FROM v$parameter WHERE name = 'vector_memory_size'")) { + if (rs.next() && NumberUtils.toLong(rs.getString(1), 0) > 0) { + features.add(VdbFeature.HNSW_INDEX); + } + } catch (SQLException e) { + // Without vector memory, or without the privilege to read it, HNSW indexes are created as IVF indexes + } + } + + @Override + public String foldIdentifier(String name) { + return name.toUpperCase(Locale.ENGLISH); + } + + @Override + protected String getScalarTypeName(VdbField field) { + switch (field.getKind()) { + case BOOLEAN: + return "BOOLEAN"; + case SMALLINT: + return "NUMBER(5)"; + case INTEGER: + return "NUMBER(10)"; + case BIGINT: + return "NUMBER(19)"; + case REAL: + return "BINARY_FLOAT"; + case DOUBLE: + return "BINARY_DOUBLE"; + case VARCHAR: + return field.getSize() > 0 && field.getSize() <= MAX_VARCHAR_LENGTH ? "VARCHAR2(" + field.getSize() + + " CHAR)" : "CLOB"; + case TIMESTAMP: + return "TIMESTAMP(6) WITH TIME ZONE"; + case BINARY: + return "BLOB"; + case JSON: + return "JSON"; + default: + return "CLOB"; + } + } + + @Override + public String getVectorTypeName(VectorType vectorType, boolean nullable) { + int dimensions = vectorType.getDimensions(); + if (!hasFeature(VdbFeature.VECTOR) || dimensions > MAX_VECTOR_DIMENSIONS + || (vectorType.isSparse() && !hasFeature(VdbFeature.SPARSE_VECTOR))) { + return null; + } + VectorType.Element element = vectorType.getElement() == VectorType.Element.FLOAT16 + ? VectorType.Element.FLOAT32 : vectorType.getElement(); + if (element == VectorType.Element.BINARY && (vectorType.isSparse() || dimensions % 8 != 0)) { + return null; + } + return String.format("VECTOR(%s, %s%s)", dimensions > 0 ? String.valueOf(dimensions) : "*", element, + vectorType.isSparse() ? ", SPARSE" : ""); + } + + @Override + protected Object getVectorValue(VectorType.Values values, VectorType vectorType) { + return vectorType.isSparse() ? values.toOracleSparseText() : values.toDenseText(); + } + + /** + * Vectors are converted from their text by the format of their column, the text may be longer than VARCHAR2 + */ + @Override + public String getParameterMarker(Column column) { + String type = column.getTypeName(); + if (type.startsWith("VECTOR(")) { + return "TO_VECTOR(?, " + StringUtils.substringBetween(type, "(", ")") + ")"; + } + return "?"; + } + + @Override + public int getNullSqlType(Column column) { + return column.getTypeName().startsWith("VECTOR(") || "JSON".equals(column.getTypeName()) + ? Types.CLOB : super.getNullSqlType(column); + } + + @Override + public String getUpsertStatement(String schema, String tableName, List columns, + List keyColumnNames) { + String source = "(SELECT " + columns.stream().map(column -> getParameterMarker(column) + " " + + getIdentifier(column.getName())).collect(Collectors.joining(", ")) + " FROM dual) src"; + return getMergeStatement(schema, tableName, columns, keyColumnNames, source, ""); + } + + @Override + public String getCreateVectorIndexStatement(String schema, String tableName, String indexName, String columnName, + String columnTypeName, VectorType vectorType, boolean nullable, + VectorIndex vectorIndex) { + if (StringUtils.containsIgnoreCase(columnTypeName, "SPARSE")) { + return null; + } + boolean hnsw = vectorIndex.getKind() == VectorIndex.Kind.HNSW && hasFeature(VdbFeature.HNSW_INDEX); + if (!hnsw && !hasFeature(VdbFeature.IVF_INDEX)) { + return null; + } + List parameters = new ArrayList<>(); + if (hnsw) { + addParameter(parameters, "NEIGHBORS", vectorIndex.getOption("m")); + addParameter(parameters, "EFCONSTRUCTION", vectorIndex.getOption("ef_construction")); + } else { + addParameter(parameters, "NEIGHBOR PARTITIONS", vectorIndex.getOption("lists")); + } + if (!parameters.isEmpty()) { + parameters.add(0, hnsw ? "TYPE HNSW" : "TYPE IVF"); + } + return String.format("CREATE VECTOR INDEX %s ON %s (%s) ORGANIZATION %s DISTANCE %s%s", + getIdentifier(indexName), getQualifiedTableName(schema, tableName), getIdentifier(columnName), + hnsw ? "INMEMORY NEIGHBOR GRAPH" : "NEIGHBOR PARTITIONS", vectorIndex.getDistance(), + parameters.isEmpty() ? "" : " PARAMETERS (" + String.join(", ", parameters) + ")"); + } + + private static void addParameter(List parameters, String name, String value) { + if (NumberUtils.isDigits(value)) { + parameters.add(name + " " + value); + } + } +} diff --git a/src/main/java/com/github/sqljam/vdb/dialect/PostgreSQLVdbDialect.java b/src/main/java/com/github/sqljam/vdb/dialect/PostgreSQLVdbDialect.java new file mode 100644 index 0000000..5097521 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/dialect/PostgreSQLVdbDialect.java @@ -0,0 +1,233 @@ +/* + * 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.vdb.dialect; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Types; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: PostgreSQLVdbDialect writes into PostgreSQL with pgvector 0.5.0 and later: vector, halfvec (0.7.0), + * sparsevec (0.7.0), HNSW (0.5.0) and IVFFlat indexes, upserts by ON CONFLICT. Rules are copied from + * PostgreSQLDialect of impexp. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class PostgreSQLVdbDialect extends VdbDialect { + + static final int MAX_VECTOR_DIMENSIONS = 16000; + /** + * Dimensions of vector and halfvec columns which HNSW and IVFFlat index at most + */ + static final int MAX_VECTOR_INDEX_DIMENSIONS = 2000; + static final int MAX_HALFVEC_INDEX_DIMENSIONS = 4000; + + @Override + public String getName() { + return "PostgreSQL"; + } + + @Override + protected void detectFeatures(Connection connection, Set features) throws SQLException { + prepareSession(connection); + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery( + "SELECT to_regtype('vector') IS NOT NULL, to_regtype('halfvec') IS NOT NULL," + + " to_regtype('sparsevec') IS NOT NULL," + + " EXISTS (SELECT 1 FROM pg_am WHERE amname = 'hnsw')," + + " EXISTS (SELECT 1 FROM pg_am WHERE amname = 'ivfflat')")) { + if (rs.next()) { + VdbFeature[] columns = {VdbFeature.VECTOR, VdbFeature.HALF_VECTOR, VdbFeature.SPARSE_VECTOR, + VdbFeature.HNSW_INDEX, VdbFeature.IVF_INDEX}; + for (int i = 0; i < columns.length; i++) { + if (rs.getBoolean(i + 1)) { + features.add(columns[i]); + } + } + } + } + } + + /** + * Adds the schema of pgvector to the search path when it is not there, its types are found then + */ + @Override + public void prepareSession(Connection connection) { + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery( + "SELECT n.nspname, current_setting('search_path') FROM pg_extension e" + + " JOIN pg_namespace n ON n.oid = e.extnamespace WHERE e.extname = 'vector'" + + " AND NOT n.nspname = ANY (current_schemas(false))")) { + if (rs.next()) { + String path = StringUtils.trimToEmpty(rs.getString(2)); + String schema = "\"" + rs.getString(1).replace("\"", "\"\"") + "\""; + try (Statement set = connection.createStatement()) { + set.execute("SET search_path TO " + (path.isEmpty() ? schema : path + ", " + schema)); + } + } + } catch (SQLException e) { + // The search path is kept, vectors are found if pgvector is in it + } + } + + @Override + public String foldIdentifier(String name) { + return name.toLowerCase(Locale.ENGLISH); + } + + @Override + public int getMaxIdentifierLength() { + return 63; + } + + @Override + protected String getScalarTypeName(VdbField field) { + switch (field.getKind()) { + case BOOLEAN: + return "boolean"; + case SMALLINT: + return "smallint"; + case INTEGER: + return "integer"; + case BIGINT: + return "bigint"; + case REAL: + return "real"; + case DOUBLE: + return "double precision"; + case VARCHAR: + return field.getSize() > 0 && field.getSize() <= 10485760 ? "varchar(" + field.getSize() + ")" : "text"; + case TIMESTAMP: + return "timestamptz"; + case BINARY: + return "bytea"; + case JSON: + return "jsonb"; + default: + return "text"; + } + } + + @Override + public String getVectorTypeName(VectorType vectorType, boolean nullable) { + if (vectorType.getDimensions() > MAX_VECTOR_DIMENSIONS || !vectorType.isFloat32Compatible()) { + return null; + } + String dimensions = vectorType.getDimensions() > 0 ? "(" + vectorType.getDimensions() + ")" : ""; + if (vectorType.isSparse()) { + // sparsevec of pgvector 0.7.0 and later + return hasFeature(VdbFeature.SPARSE_VECTOR) ? "sparsevec" + dimensions : null; + } + if (vectorType.getElement() == VectorType.Element.FLOAT16 && hasFeature(VdbFeature.HALF_VECTOR)) { + return "halfvec" + dimensions; + } + // 16-bit floats are kept by vectors of 32-bit floats of older versions + return hasFeature(VdbFeature.VECTOR) ? "vector" + dimensions : null; + } + + @Override + protected Object getVectorValue(VectorType.Values values, VectorType vectorType) { + return vectorType.isSparse() ? values.toPostgreSqlSparseText() : values.toDenseText(); + } + + /** + * Vectors and JSON are cast from text, the connection may send text as varchar + */ + @Override + public String getParameterMarker(Column column) { + String type = column.getTypeName(); + if (type.startsWith("vector") || type.startsWith("halfvec") || type.startsWith("sparsevec") + || "jsonb".equals(type) || "timestamptz".equals(type)) { + return "CAST(? AS " + type + ")"; + } + return "?"; + } + + @Override + public Object getJdbcValue(Object value, Column column) { + Object jdbcValue = super.getJdbcValue(value, column); + // Times are cast from their text + return jdbcValue instanceof OffsetDateTime ? ((OffsetDateTime) jdbcValue).format(OFFSET_TIMESTAMP_FORMAT) + : jdbcValue; + } + + @Override + public int getNullSqlType(Column column) { + return "timestamptz".equals(column.getTypeName()) ? Types.VARCHAR : super.getNullSqlType(column); + } + + @Override + public String getCreateVectorIndexStatement(String schema, String tableName, String indexName, String columnName, + String columnTypeName, VectorType vectorType, boolean nullable, + VectorIndex vectorIndex) { + String type = StringUtils.substringBefore(StringUtils.defaultString(columnTypeName), "(").trim() + .toLowerCase(Locale.ENGLISH); + int dimensions = NumberUtils.toInt(StringUtils.substringBetween(columnTypeName, "(", ")"), 0); + String operator = getVectorOperator(vectorIndex.getDistance()); + if (operator == null || dimensions <= 0 || ("vector".equals(type) && dimensions > MAX_VECTOR_INDEX_DIMENSIONS) + || ("halfvec".equals(type) && dimensions > MAX_HALFVEC_INDEX_DIMENSIONS)) { + return null; + } + // HNSW of pgvector 0.5.0 and later, IVFFlat has no sparse vectors: the other kind is taken if the database + // has not the kind of the index or not for the vectors. IVFFlat has no Manhattan distance (l1). + boolean hnswAvailable = hasFeature(VdbFeature.HNSW_INDEX); + boolean ivfAvailable = hasFeature(VdbFeature.IVF_INDEX) && !"sparsevec".equals(type) + && vectorIndex.getDistance() != VectorIndex.Distance.MANHATTAN; + if (!hnswAvailable && !ivfAvailable) { + return null; + } + boolean ivf = ivfAvailable && (vectorIndex.getKind() == VectorIndex.Kind.IVF || !hnswAvailable); + List options = new ArrayList<>(); + for (String option : ivf ? new String[]{"lists"} : new String[]{"m", "ef_construction"}) { + String value = vectorIndex.getOption(option); + if (NumberUtils.isDigits(value)) { + options.add(option + " = " + value); + } + } + return String.format("CREATE INDEX IF NOT EXISTS %s ON %s USING %s (%s %s_%s_ops)%s", + getIdentifier(indexName), getQualifiedTableName(schema, tableName), ivf ? "ivfflat" : "hnsw", + getIdentifier(columnName), type, operator, options.isEmpty() ? "" : " WITH (" + String.join(", ", + options) + ")"); + } + + static String getVectorOperator(VectorIndex.Distance distance) { + switch (distance) { + case EUCLIDEAN: + return "l2"; + case COSINE: + return "cosine"; + case DOT: + return "ip"; + case MANHATTAN: + return "l1"; + default: + // Hamming and Jaccard distances are of bit vectors + return null; + } + } +} diff --git a/src/main/java/com/github/sqljam/vdb/dialect/SQLServerVdbDialect.java b/src/main/java/com/github/sqljam/vdb/dialect/SQLServerVdbDialect.java new file mode 100644 index 0000000..01a3c3c --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/dialect/SQLServerVdbDialect.java @@ -0,0 +1,137 @@ +/* + * 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.vdb.dialect; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: SQLServerVdbDialect writes into SQL Server 2025 and later: VECTOR columns of 32-bit floats (1998 + * dimensions at most) and of 16-bit floats (3996), upserts by MERGE. Vector indexes of SQL Server are + * a preview, none are created. Rules are copied from SQLServerDialect of impexp. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class SQLServerVdbDialect extends VdbDialect { + + static final int MAX_VECTOR_DIMENSIONS = 1998; + static final int MAX_HALF_VECTOR_DIMENSIONS = 3996; + static final int MAX_NVARCHAR_LENGTH = 4000; + + @Override + public String getName() { + return "SQL Server"; + } + + @Override + protected void detectFeatures(Connection connection, Set features) throws SQLException { + if (probe(connection, "SELECT CAST(CAST('[1]' AS VECTOR(1)) AS NVARCHAR(MAX))")) { + features.add(VdbFeature.VECTOR); + if (probe(connection, "SELECT CAST(CAST('[1]' AS VECTOR(1, float16)) AS NVARCHAR(MAX))")) { + features.add(VdbFeature.HALF_VECTOR); + } + } + } + + @Override + protected String getOpenQuote() { + return "["; + } + + @Override + protected String getCloseQuote() { + return "]"; + } + + @Override + protected String getScalarTypeName(VdbField field) { + switch (field.getKind()) { + case BOOLEAN: + return "BIT"; + case SMALLINT: + return "SMALLINT"; + case INTEGER: + return "INT"; + case BIGINT: + return "BIGINT"; + case REAL: + return "REAL"; + case DOUBLE: + return "FLOAT"; + case VARCHAR: + return field.getSize() > 0 && field.getSize() <= MAX_NVARCHAR_LENGTH ? "NVARCHAR(" + field.getSize() + + ")" : "NVARCHAR(MAX)"; + case TIMESTAMP: + return "DATETIMEOFFSET(6)"; + case BINARY: + return "VARBINARY(MAX)"; + default: + return "NVARCHAR(MAX)"; + } + } + + @Override + public String getVectorTypeName(VectorType vectorType, boolean nullable) { + int dimensions = vectorType.getDimensions(); + if (!hasFeature(VdbFeature.VECTOR) || vectorType.isSparse() || !vectorType.isFloat32Compatible() + || dimensions < 1) { + return null; + } + if (vectorType.getElement() == VectorType.Element.FLOAT16 && hasFeature(VdbFeature.HALF_VECTOR) + && dimensions <= MAX_HALF_VECTOR_DIMENSIONS) { + return "VECTOR(" + dimensions + ", float16)"; + } + // 16-bit floats are kept by 32-bit floats + return dimensions <= MAX_VECTOR_DIMENSIONS ? "VECTOR(" + dimensions + ")" : null; + } + + /** + * Keys of NVARCHAR(512) are longer than 900 bytes of a clustered index, a nonclustered key has 1700 bytes + */ + @Override + public String getPrimaryKeyDefinition(String tableName, List keyColumnNames) { + return String.format("CONSTRAINT %s PRIMARY KEY NONCLUSTERED (%s)", getIdentifier("pk_" + tableName), + getIdentifiers(keyColumnNames)); + } + + /** + * Comments are extended properties, they are left out + */ + @Override + public String getTableCommentStatement(String schema, String tableName, String comment) { + return null; + } + + @Override + public String getColumnCommentStatement(String schema, String tableName, String columnName, String comment) { + return null; + } + + @Override + public String getUpsertStatement(String schema, String tableName, List columns, + List keyColumnNames) { + String source = "(VALUES (" + columns.stream().map(this::getParameterMarker).collect(Collectors.joining( + ", ")) + ")) AS src (" + getIdentifiers(names(columns)) + ")"; + return getMergeStatement(schema, tableName, columns, keyColumnNames, source, ";"); + } +} diff --git a/src/main/java/com/github/sqljam/vdb/dialect/VdbDialect.java b/src/main/java/com/github/sqljam/vdb/dialect/VdbDialect.java new file mode 100644 index 0000000..5dd585f --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/dialect/VdbDialect.java @@ -0,0 +1,539 @@ +/* + * 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.vdb.dialect; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import com.github.sqljam.vdb.VdbException; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: VdbDialect writes the tables of vector database collections into a target database which has + * vectors: types of the columns, vector types and indexes, CREATE TABLE, INSERT and upsert. Its rules + * are copies of the dialects of impexp for the vdb line, which do not depend on each other. The + * features of the target (vectors, half vectors, sparse vectors, HNSW and IVF indexes) are detected on + * its connection: a column whose vectors the target has not is text, an index the target can not + * create is left out. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public abstract class VdbDialect { + + /** + * Column of a table: its name in the target, its field and its type + */ + public static final class Column { + + private final String name; + private final VdbField field; + private final String typeName; + + public Column(String name, VdbField field, String typeName) { + this.name = name; + this.field = field; + this.typeName = typeName; + } + + public String getName() { + return name; + } + + public VdbField getField() { + return field; + } + + public String getTypeName() { + return typeName; + } + + public boolean isNullable() { + return field.isNullable() && !field.isPrimaryKey(); + } + } + + /** + * Text of a time with its offset, which targets cast to their times + */ + protected static final DateTimeFormatter OFFSET_TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern( + "yyyy-MM-dd HH:mm:ss.SSSSSSxxx"); + + private final Set features = EnumSet.noneOf(VdbFeature.class); + private int majorVersion; + private int minorVersion; + private String productVersion; + + /** + * Name of the target database, e.g. PostgreSQL + */ + public abstract String getName(); + + /** + * Dialect of the database of a connection, null if the vdb line does not write into it + */ + public static VdbDialect of(Connection connection) throws SQLException { + DatabaseMetaData metaData = connection.getMetaData(); + String product = StringUtils.defaultString(metaData.getDatabaseProductName()).toLowerCase(Locale.ENGLISH); + String version = StringUtils.defaultString(metaData.getDatabaseProductVersion()).toLowerCase(Locale.ENGLISH); + VdbDialect dialect; + if (product.contains("postgresql")) { + dialect = new PostgreSQLVdbDialect(); + } else if (product.contains("mariadb") || version.contains("mariadb")) { + dialect = new MariaDBVdbDialect(); + } else if (product.contains("mysql")) { + dialect = new MySQLVdbDialect(); + } else if (product.contains("oracle")) { + dialect = new OracleVdbDialect(); + } else if (product.contains("microsoft sql server")) { + dialect = new SQLServerVdbDialect(); + } else if (product.startsWith("db2")) { + dialect = new Db2VdbDialect(); + } else if (product.contains("duckdb")) { + dialect = new DuckDBVdbDialect(); + } else if (product.contains("clickhouse")) { + dialect = new ClickHouseVdbDialect(); + } else { + return null; + } + dialect.initialize(connection); + return dialect; + } + + /** + * Reads the version, detects the features and prepares the session of a connection + */ + public void initialize(Connection connection) throws SQLException { + DatabaseMetaData metaData = connection.getMetaData(); + majorVersion = metaData.getDatabaseMajorVersion(); + minorVersion = metaData.getDatabaseMinorVersion(); + productVersion = metaData.getDatabaseProductVersion(); + features.clear(); + detectFeatures(connection, features); + prepareSession(connection); + } + + /** + * Detects the features of the target, each by a probe which fails without it + */ + protected abstract void detectFeatures(Connection connection, Set features) throws SQLException; + + /** + * Prepares a connection before tables are created and rows are written, e.g. the search path of pgvector + */ + public void prepareSession(Connection connection) { + } + + /** + * Features of a target which is not connected, e.g. to show the statements of a version + */ + public void useFeatures(Set targetFeatures) { + features.clear(); + features.addAll(targetFeatures); + } + + public Set getFeatures() { + return Collections.unmodifiableSet(features); + } + + public boolean hasFeature(VdbFeature feature) { + return features.contains(feature); + } + + /** + * Whether the target keeps vectors, the vdb line writes into targets with vectors only + */ + public boolean isVectorSupported() { + return hasFeature(VdbFeature.VECTOR); + } + + public String getProductVersion() { + return productVersion; + } + + protected boolean isVersionAtLeast(int major, int minor) { + return majorVersion > major || (majorVersion == major && minorVersion >= minor); + } + + protected static boolean probe(Connection connection, String sql) { + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) { + return rs.next(); + } catch (SQLException e) { + try { + if (!connection.getAutoCommit()) { + connection.rollback(); + } + } catch (SQLException ignored) { + // The connection is closed + } + return false; + } + } + + // ------------------------------------------------------------------ + // Identifiers + // ------------------------------------------------------------------ + + protected String getOpenQuote() { + return "\""; + } + + protected String getCloseQuote() { + return "\""; + } + + /** + * Name of a table or a column in the target: unquoted names of PostgreSQL are lower case, of Oracle and Db2 + * upper case. Names of other cases are kept. + */ + public String foldIdentifier(String name) { + return name; + } + + public int getMaxIdentifierLength() { + return 128; + } + + public String quoteIdentifier(String name) { + return getOpenQuote() + name.replace(getCloseQuote(), getCloseQuote() + getCloseQuote()) + getCloseQuote(); + } + + /** + * Name of a table, a column or an index of the target, folded, cut to the maximum length and quoted + */ + public String getIdentifier(String name) { + return quoteIdentifier(getLimitedIdentifier(foldIdentifier(name))); + } + + public String getLimitedIdentifier(String name) { + return name.length() > getMaxIdentifierLength() ? name.substring(0, getMaxIdentifierLength()) : name; + } + + public String getIdentifiers(List names) { + return names.stream().map(this::getIdentifier).collect(Collectors.joining(", ")); + } + + public String getQualifiedTableName(String schema, String tableName) { + return StringUtils.isNotBlank(schema) ? getIdentifier(schema) + "." + getIdentifier(tableName) + : getIdentifier(tableName); + } + + // ------------------------------------------------------------------ + // Types + // ------------------------------------------------------------------ + + /** + * Type of a scalar column of a kind + */ + protected abstract String getScalarTypeName(VdbField field); + + /** + * Type of a vector column of this database, null if the vectors are kept as text: the database has no vectors, + * not of these elements or not of so many dimensions + */ + public String getVectorTypeName(VectorType vectorType, boolean nullable) { + return null; + } + + /** + * Type of a column of the target: vectors which the target has not are text + */ + public String getTypeName(VdbField field) { + if (field.isVector()) { + String typeName = getVectorTypeName(field.getVectorType(), field.isNullable() && !field.isPrimaryKey()); + if (typeName != null) { + return typeName; + } + return getScalarTypeName(new VdbField(field.getName(), VdbField.Kind.TEXT)); + } + return getScalarTypeName(field); + } + + /** + * Whether a column of the type keeps vectors of the target, see {@link #getVectorTypeName} + */ + public boolean isVectorTypeName(VdbField field, String typeName) { + return field.isVector() && typeName.equals(getVectorTypeName(field.getVectorType(), field.isNullable() + && !field.isPrimaryKey())); + } + + // ------------------------------------------------------------------ + // Statements + // ------------------------------------------------------------------ + + public String getColumnDefinition(Column column) { + return getIdentifier(column.getName()) + " " + column.getTypeName() + (column.isNullable() ? "" : " NOT NULL"); + } + + public String getPrimaryKeyDefinition(String tableName, List keyColumnNames) { + return String.format("CONSTRAINT %s PRIMARY KEY (%s)", getIdentifier("pk_" + tableName), + getIdentifiers(keyColumnNames)); + } + + /** + * Options of CREATE TABLE after the columns, e.g. the engine of ClickHouse + */ + public String getTableOptions(List keyColumnNames) { + return ""; + } + + public String getDropTableStatement(String schema, String tableName) { + return "DROP TABLE IF EXISTS " + getQualifiedTableName(schema, tableName); + } + + /** + * Comment of a table, null if the database has no comments + */ + public String getTableCommentStatement(String schema, String tableName, String comment) { + return String.format("COMMENT ON TABLE %s IS %s", getQualifiedTableName(schema, tableName), + quoteLiteral(comment)); + } + + /** + * Comment of a column, null if the database has no comments + */ + public String getColumnCommentStatement(String schema, String tableName, String columnName, String comment) { + return String.format("COMMENT ON COLUMN %s.%s IS %s", getQualifiedTableName(schema, tableName), + getIdentifier(columnName), quoteLiteral(comment)); + } + + protected String quoteLiteral(String text) { + return "'" + quoteLiteralText(text).replace("'", "''") + "'"; + } + + /** + * Text of a literal before its quotes are doubled, e.g. backslashes of MySQL + */ + protected String quoteLiteralText(String text) { + return text; + } + + /** + * Index for the nearest neighbors of a vector column, null if this database has none for it: then no index is + * created, an ordinary index can not hold vectors + * + * @param columnTypeName type of the column of this database, see {@link #getVectorTypeName} + * @param nullable whether the column takes NULL + */ + public String getCreateVectorIndexStatement(String schema, String tableName, String indexName, String columnName, + String columnTypeName, VectorType vectorType, boolean nullable, + VectorIndex vectorIndex) { + return null; + } + + /** + * Parameter of a value of a column, e.g. a cast of the text of a vector + */ + public String getParameterMarker(Column column) { + return "?"; + } + + public String getInsertStatement(String schema, String tableName, List columns) { + return String.format("INSERT INTO %s (%s) VALUES (%s)", getQualifiedTableName(schema, tableName), + getIdentifiers(names(columns)), columns.stream().map(this::getParameterMarker) + .collect(Collectors.joining(", "))); + } + + public boolean isUpsertSupported() { + return true; + } + + /** + * Whether vector indexes may be created on a table of the key columns, e.g. keys of MariaDB up to 256 bytes + */ + public boolean isVectorIndexAllowed(List keyColumns) { + return true; + } + + /** + * Vector indexes of a table at most, MariaDB has one + */ + public int getMaxVectorIndexes() { + return Integer.MAX_VALUE; + } + + /** + * Whether rows of a page are written in a transaction, ClickHouse writes each batch as a block of its own + */ + public boolean isTransactionSupported() { + return true; + } + + /** + * Statement which inserts a row, or updates the row of its key + */ + public String getUpsertStatement(String schema, String tableName, List columns, + List keyColumnNames) { + String insert = getInsertStatement(schema, tableName, columns); + List updates = updated(columns, keyColumnNames); + if (updates.isEmpty()) { + return String.format("%s ON CONFLICT (%s) DO NOTHING", insert, getIdentifiers(keyColumnNames)); + } + return String.format("%s ON CONFLICT (%s) DO UPDATE SET %s", insert, getIdentifiers(keyColumnNames), + updates.stream().map(name -> getIdentifier(name) + " = EXCLUDED." + getIdentifier(name)) + .collect(Collectors.joining(", "))); + } + + /** + * MERGE of a row of parameters (Oracle, SQL Server, Db2) + * + * @param source rows of the parameters as src, e.g. (VALUES (?, ?)) AS src (a, b) + */ + protected String getMergeStatement(String schema, String tableName, List columns, + List keyColumnNames, String source, String end) { + StringBuilder sql = new StringBuilder("MERGE INTO ").append(getQualifiedTableName(schema, tableName)) + .append(" tgt USING ").append(source).append(" ON (").append(keyColumnNames.stream() + .map(name -> "tgt." + getIdentifier(name) + " = src." + getIdentifier(name)) + .collect(Collectors.joining(" AND "))).append(")"); + List updates = updated(columns, keyColumnNames); + if (!updates.isEmpty()) { + sql.append(" WHEN MATCHED THEN UPDATE SET ").append(updates.stream().map(name -> "tgt." + + getIdentifier(name) + " = src." + getIdentifier(name)).collect(Collectors.joining(", "))); + } + return sql.append(" WHEN NOT MATCHED THEN INSERT (").append(getIdentifiers(names(columns))) + .append(") VALUES (").append(columns.stream().map(column -> "src." + getIdentifier(column.getName())) + .collect(Collectors.joining(", "))).append(")").append(end).toString(); + } + + protected static List names(List columns) { + return columns.stream().map(Column::getName).collect(Collectors.toList()); + } + + protected static List updated(List columns, List keyColumnNames) { + return names(columns).stream().filter(name -> !keyColumnNames.contains(name)).collect(Collectors.toList()); + } + + // ------------------------------------------------------------------ + // Values + // ------------------------------------------------------------------ + + /** + * Value of a row for the parameter of its column: vectors are text of the target, times are of UTC for targets + * without time zones + */ + public Object getJdbcValue(Object value, Column column) { + if (value == null) { + return null; + } + VdbField field = column.getField(); + if (field.isVector()) { + if (!isVectorTypeName(field, column.getTypeName())) { + return value.toString(); + } + VectorType.Values values = VectorType.parseValues(value.toString()); + if (values == null) { + throw new VdbException("Invalid vector of column " + column.getName() + ": " + StringUtils.abbreviate( + value.toString(), 80)); + } + return getVectorValue(values, field.getVectorType()); + } + VdbField.Kind kind = field.getKind(); + if ((kind == VdbField.Kind.VARCHAR || kind == VdbField.Kind.TEXT || kind == VdbField.Kind.JSON) + && !(value instanceof String)) { + // Ids of numbers and UUIDs (Qdrant) are text, a batch binds every row as its first + return value.toString(); + } + if (value instanceof OffsetDateTime && !isTimeZoneSupported()) { + return Timestamp.valueOf(((OffsetDateTime) value).withOffsetSameInstant(ZoneOffset.UTC) + .toLocalDateTime()); + } + return value; + } + + /** + * Value of a vector for a vector column of this database, e.g. a sparse vector as the sparse text of this + * database + */ + protected Object getVectorValue(VectorType.Values values, VectorType vectorType) { + return values.toDenseText(); + } + + /** + * Whether TIMESTAMP columns keep time zones, times of other targets are of UTC + */ + protected boolean isTimeZoneSupported() { + return true; + } + + /** + * Sql type used to bind NULL of a column + */ + public int getNullSqlType(Column column) { + VdbField field = column.getField(); + switch (field.getKind()) { + case BOOLEAN: + return Types.BOOLEAN; + case SMALLINT: + return Types.SMALLINT; + case INTEGER: + return Types.INTEGER; + case BIGINT: + return Types.BIGINT; + case REAL: + return Types.REAL; + case DOUBLE: + return Types.DOUBLE; + case TIMESTAMP: + return isTimeZoneSupported() ? Types.TIMESTAMP_WITH_TIMEZONE : Types.TIMESTAMP; + case BINARY: + return Types.VARBINARY; + default: + return Types.VARCHAR; + } + } + + /** + * Whether a table exists in the target + */ + public boolean isTableExisting(Connection connection, String schema, String tableName) throws SQLException { + String name = getLimitedIdentifier(foldIdentifier(tableName)); + String schemaName = StringUtils.isNotBlank(schema) ? foldIdentifier(schema) : null; + DatabaseMetaData metaData = connection.getMetaData(); + try (ResultSet rs = isCatalogQualifier() ? metaData.getTables(schemaName, null, name, null) + : metaData.getTables(null, schemaName, name, null)) { + while (rs.next()) { + if (name.equals(rs.getString("TABLE_NAME"))) { + return true; + } + } + } + return false; + } + + /** + * Whether the qualifier of a table is a catalog of JDBC (databases of MySQL and ClickHouse) + */ + protected boolean isCatalogQualifier() { + return false; + } +} diff --git a/src/main/java/com/github/sqljam/vdb/dialect/VdbFeature.java b/src/main/java/com/github/sqljam/vdb/dialect/VdbFeature.java new file mode 100644 index 0000000..b9a5bb1 --- /dev/null +++ b/src/main/java/com/github/sqljam/vdb/dialect/VdbFeature.java @@ -0,0 +1,52 @@ +/* + * 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.vdb.dialect; + +/** + * @Description: VdbFeature is a capability of a target database which is detected on its connection before it + * is used: types and indexes of extensions (pgvector), of editions and of settings (vector memory of + * Oracle) are not told by the version. It is a copy of DatabaseFeature of impexp for the vdb line. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public enum VdbFeature { + + /** + * Dense vectors of 32-bit floats, e.g. vector of pgvector, VECTOR of MySQL + */ + VECTOR, + + /** + * Dense vectors of 16-bit floats, e.g. halfvec of pgvector, VECTOR(n, float16) of SQL Server + */ + HALF_VECTOR, + + /** + * Sparse vectors, e.g. sparsevec of pgvector, VECTOR(n, FLOAT32, SPARSE) of Oracle + */ + SPARSE_VECTOR, + + /** + * Vector indexes of graphs (HNSW) + */ + HNSW_INDEX, + + /** + * Vector indexes of partitions (IVF) + */ + IVF_INDEX +} diff --git a/src/main/resources/META-INF/services/com.github.sqljam.face.view.DataSourceExtension b/src/main/resources/META-INF/services/com.github.sqljam.face.view.DataSourceExtension new file mode 100644 index 0000000..1aef8f9 --- /dev/null +++ b/src/main/resources/META-INF/services/com.github.sqljam.face.view.DataSourceExtension @@ -0,0 +1 @@ +com.github.sqljam.face.vdb.VdbDataSourceExtension diff --git a/src/main/resources/META-INF/services/com.github.sqljam.face.view.MenuExtension b/src/main/resources/META-INF/services/com.github.sqljam.face.view.MenuExtension new file mode 100644 index 0000000..d00045c --- /dev/null +++ b/src/main/resources/META-INF/services/com.github.sqljam.face.view.MenuExtension @@ -0,0 +1 @@ +com.github.sqljam.face.vdb.VdbMenuExtension diff --git a/src/main/resources/com/github/sqljam/face/app.css b/src/main/resources/com/github/sqljam/face/app.css index 891d42e..f9962ce 100644 --- a/src/main/resources/com/github/sqljam/face/app.css +++ b/src/main/resources/com/github/sqljam/face/app.css @@ -47,45 +47,6 @@ -fx-font-size: 12px; } -/* Login */ - -.login-view, .login-background { - -fx-background-color: -color-bg-inset; -} - -.login-card { - -fx-background-color: -color-bg-default; - -fx-background-radius: 12; - -fx-border-color: -color-border-default; - -fx-border-radius: 12; - -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.35), 24, 0.2, 0, 6); -} - -.login-sidebar { - -fx-padding: 20; - -fx-background-color: -color-bg-subtle; - -fx-background-radius: 12 0 0 12; - -fx-border-color: transparent -color-border-muted transparent transparent; -} - -.login-form { - -fx-padding: 24 28 24 28; -} - -.login-logo { - -fx-icon-size: 40px; - -fx-icon-color: -color-accent-fg; -} - -.login-title { - -fx-font-size: 24px; - -fx-font-weight: bold; -} - -.login-subtitle { - -fx-text-fill: -color-fg-muted; -} - /* Main window */ .connection-tree { diff --git a/src/main/resources/com/github/sqljam/face/example/shop.sql b/src/main/resources/com/github/sqljam/face/example/shop.sql index 57c2a35..347ebaf 100644 --- a/src/main/resources/com/github/sqljam/face/example/shop.sql +++ b/src/main/resources/com/github/sqljam/face/example/shop.sql @@ -45,11 +45,13 @@ CREATE TABLE orders ( total DECIMAL(12,2) DEFAULT 0 NOT NULL, paid_at TIMESTAMP, created_at TIMESTAMP NOT NULL, + updated_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'; +COMMENT ON COLUMN orders.updated_at IS 'Time of the last change, e.g. the watermark of a sync task'; CREATE INDEX idx_orders_customer ON orders (customer_id); CREATE INDEX idx_orders_status ON orders (status, created_at); @@ -93,10 +95,11 @@ SELECT MOD(X - 1, 6) + 1, 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) +INSERT INTO orders (order_no, customer_id, status, created_at, updated_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'), DATEADD('HOUR', -X * 13, TIMESTAMP '2026-09-30 12:00:00') FROM SYSTEM_RANGE(1, 300); @@ -108,5 +111,6 @@ 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 orders SET updated_at = COALESCE(paid_at, created_at); 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/images/db-icons.properties b/src/main/resources/com/github/sqljam/face/images/db-icons.properties new file mode 100644 index 0000000..83272a2 --- /dev/null +++ b/src/main/resources/com/github/sqljam/face/images/db-icons.properties @@ -0,0 +1,22 @@ +# Logos of the databases: the brand color of the tile, the color of the logo and its SVG path of 24x24. +# Paths of Simple Icons (https://simpleicons.org, CC0 1.0): 13.21.0, SQL Server, Access and IBM (Db2) of 5.24.0. +# Images (png:) are the avatars of the GitHub organizations of the projects: qdrant, weaviate, pinecone-io, +# chroma-core and h2database, in images/db. The logos are trademarks of their owners, they only name the databases. +H2=png:h2.png|full +Qdrant=png:qdrant.png|tile +Weaviate=png:weaviate.png|tile +Pinecone=png:pinecone.png|tile +Chroma=png:chroma.png|tile +PostgreSQL=#4169E1|#FFFFFF|M23.5594 14.7228a.5269.5269 0 0 0-.0563-.1191c-.139-.2632-.4768-.3418-1.0074-.2321-1.6533.3411-2.2935.1312-2.5256-.0191 1.342-2.0482 2.445-4.522 3.0411-6.8297.2714-1.0507.7982-3.5237.1222-4.7316a1.5641 1.5641 0 0 0-.1509-.235C21.6931.9086 19.8007.0248 17.5099.0005c-1.4947-.0158-2.7705.3461-3.1161.4794a9.449 9.449 0 0 0-.5159-.0816 8.044 8.044 0 0 0-1.3114-.1278c-1.1822-.0184-2.2038.2642-3.0498.8406-.8573-.3211-4.7888-1.645-7.2219.0788C.9359 2.1526.3086 3.8733.4302 6.3043c.0409.818.5069 3.334 1.2423 5.7436.4598 1.5065.9387 2.7019 1.4334 3.582.553.9942 1.1259 1.5933 1.7143 1.7895.4474.1491 1.1327.1441 1.8581-.7279.8012-.9635 1.5903-1.8258 1.9446-2.2069.4351.2355.9064.3625 1.39.3772a.0569.0569 0 0 0 .0004.0041 11.0312 11.0312 0 0 0-.2472.3054c-.3389.4302-.4094.5197-1.5002.7443-.3102.064-1.1344.2339-1.1464.8115-.0025.1224.0329.2309.0919.3268.2269.4231.9216.6097 1.015.6331 1.3345.3335 2.5044.092 3.3714-.6787-.017 2.231.0775 4.4174.3454 5.0874.2212.5529.7618 1.9045 2.4692 1.9043.2505 0 .5263-.0291.8296-.0941 1.7819-.3821 2.5557-1.1696 2.855-2.9059.1503-.8707.4016-2.8753.5388-4.1012.0169-.0703.0357-.1207.057-.1362.0007-.0005.0697-.0471.4272.0307a.3673.3673 0 0 0 .0443.0068l.2539.0223.0149.001c.8468.0384 1.9114-.1426 2.5312-.4308.6438-.2988 1.8057-1.0323 1.5951-1.6698zM2.371 11.8765c-.7435-2.4358-1.1779-4.8851-1.2123-5.5719-.1086-2.1714.4171-3.6829 1.5623-4.4927 1.8367-1.2986 4.8398-.5408 6.108-.13-.0032.0032-.0066.0061-.0098.0094-2.0238 2.044-1.9758 5.536-1.9708 5.7495-.0002.0823.0066.1989.0162.3593.0348.5873.0996 1.6804-.0735 2.9184-.1609 1.1504.1937 2.2764.9728 3.0892.0806.0841.1648.1631.2518.2374-.3468.3714-1.1004 1.1926-1.9025 2.1576-.5677.6825-.9597.5517-1.0886.5087-.3919-.1307-.813-.5871-1.2381-1.3223-.4796-.839-.9635-2.0317-1.4155-3.5126zm6.0072 5.0871c-.1711-.0428-.3271-.1132-.4322-.1772.0889-.0394.2374-.0902.4833-.1409 1.2833-.2641 1.4815-.4506 1.9143-1.0002.0992-.126.2116-.2687.3673-.4426a.3549.3549 0 0 0 .0737-.1298c.1708-.1513.2724-.1099.4369-.0417.156.0646.3078.26.3695.4752.0291.1016.0619.2945-.0452.4444-.9043 1.2658-2.2216 1.2494-3.1676 1.0128zm2.094-3.988-.0525.141c-.133.3566-.2567.6881-.3334 1.003-.6674-.0021-1.3168-.2872-1.8105-.8024-.6279-.6551-.9131-1.5664-.7825-2.5004.1828-1.3079.1153-2.4468.079-3.0586-.005-.0857-.0095-.1607-.0122-.2199.2957-.2621 1.6659-.9962 2.6429-.7724.4459.1022.7176.4057.8305.928.5846 2.7038.0774 3.8307-.3302 4.7363-.084.1866-.1633.3629-.2311.5454zm7.3637 4.5725c-.0169.1768-.0358.376-.0618.5959l-.146.4383a.3547.3547 0 0 0-.0182.1077c-.0059.4747-.054.6489-.115.8693-.0634.2292-.1353.4891-.1794 1.0575-.11 1.4143-.8782 2.2267-2.4172 2.5565-1.5155.3251-1.7843-.4968-2.0212-1.2217a6.5824 6.5824 0 0 0-.0769-.2266c-.2154-.5858-.1911-1.4119-.1574-2.5551.0165-.5612-.0249-1.9013-.3302-2.6462.0044-.2932.0106-.5909.019-.8918a.3529.3529 0 0 0-.0153-.1126 1.4927 1.4927 0 0 0-.0439-.208c-.1226-.4283-.4213-.7866-.7797-.9351-.1424-.059-.4038-.1672-.7178-.0869.067-.276.1831-.5875.309-.9249l.0529-.142c.0595-.16.134-.3257.213-.5012.4265-.9476 1.0106-2.2453.3766-5.1772-.2374-1.0981-1.0304-1.6343-2.2324-1.5098-.7207.0746-1.3799.3654-1.7088.5321a5.6716 5.6716 0 0 0-.1958.1041c.0918-1.1064.4386-3.1741 1.7357-4.4823a4.0306 4.0306 0 0 1 .3033-.276.3532.3532 0 0 0 .1447-.0644c.7524-.5706 1.6945-.8506 2.802-.8325.4091.0067.8017.0339 1.1742.081 1.939.3544 3.2439 1.4468 4.0359 2.3827.8143.9623 1.2552 1.9315 1.4312 2.4543-1.3232-.1346-2.2234.1268-2.6797.779-.9926 1.4189.543 4.1729 1.2811 5.4964.1353.2426.2522.4522.2889.5413.2403.5825.5515.9713.7787 1.2552.0696.087.1372.1714.1885.245-.4008.1155-1.1208.3825-1.0552 1.717-.0123.1563-.0423.4469-.0834.8148-.0461.2077-.0702.4603-.0994.7662zm.8905-1.6211c-.0405-.8316.2691-.9185.5967-1.0105a2.8566 2.8566 0 0 0 .135-.0406 1.202 1.202 0 0 0 .1342.103c.5703.3765 1.5823.4213 3.0068.1344-.2016.1769-.5189.3994-.9533.6011-.4098.1903-1.0957.333-1.7473.3636-.7197.0336-1.0859-.0807-1.1721-.151zm.5695-9.2712c-.0059.3508-.0542.6692-.1054 1.0017-.055.3576-.112.7274-.1264 1.1762-.0142.4368.0404.8909.0932 1.3301.1066.887.216 1.8003-.2075 2.7014a3.5272 3.5272 0 0 1-.1876-.3856c-.0527-.1276-.1669-.3326-.3251-.6162-.6156-1.1041-2.0574-3.6896-1.3193-4.7446.3795-.5427 1.3408-.5661 2.1781-.463zm.2284 7.0137a12.3762 12.3762 0 0 0-.0853-.1074l-.0355-.0444c.7262-1.1995.5842-2.3862.4578-3.4385-.0519-.4318-.1009-.8396-.0885-1.2226.0129-.4061.0666-.7543.1185-1.0911.0639-.415.1288-.8443.1109-1.3505.0134-.0531.0188-.1158.0118-.1902-.0457-.4855-.5999-1.938-1.7294-3.253-.6076-.7073-1.4896-1.4972-2.6889-2.0395.5251-.1066 1.2328-.2035 2.0244-.1859 2.0515.0456 3.6746.8135 4.8242 2.2824a.908.908 0 0 1 .0667.1002c.7231 1.3556-.2762 6.2751-2.9867 10.5405zm-8.8166-6.1162c-.025.1794-.3089.4225-.6211.4225a.5821.5821 0 0 1-.0809-.0056c-.1873-.026-.3765-.144-.5059-.3156-.0458-.0605-.1203-.178-.1055-.2844.0055-.0401.0261-.0985.0925-.1488.1182-.0894.3518-.1226.6096-.0867.3163.0441.6426.1938.6113.4186zm7.9305-.4114c.0111.0792-.049.201-.1531.3102-.0683.0717-.212.1961-.4079.2232a.5456.5456 0 0 1-.075.0052c-.2935 0-.5414-.2344-.5607-.3717-.024-.1765.2641-.3106.5611-.352.297-.0414.6111.0088.6356.1851z +MySQL=#4479A1|#FFFFFF|M16.405 5.501c-.115 0-.193.014-.274.033v.013h.014c.054.104.146.18.214.273.054.107.1.214.154.32l.014-.015c.094-.066.14-.172.14-.333-.04-.047-.046-.094-.08-.14-.04-.067-.126-.1-.18-.153zM5.77 18.695h-.927a50.854 50.854 0 00-.27-4.41h-.008l-1.41 4.41H2.45l-1.4-4.41h-.01a72.892 72.892 0 00-.195 4.41H0c.055-1.966.192-3.81.41-5.53h1.15l1.335 4.064h.008l1.347-4.064h1.095c.242 2.015.384 3.86.428 5.53zm4.017-4.08c-.378 2.045-.876 3.533-1.492 4.46-.482.716-1.01 1.073-1.583 1.073-.153 0-.34-.046-.566-.138v-.494c.11.017.24.026.386.026.268 0 .483-.075.647-.222.197-.18.295-.382.295-.605 0-.155-.077-.47-.23-.944L6.23 14.615h.91l.727 2.36c.164.536.233.91.205 1.123.4-1.064.678-2.227.835-3.483zm12.325 4.08h-2.63v-5.53h.885v4.85h1.745zm-3.32.135l-1.016-.5c.09-.076.177-.158.255-.25.433-.506.648-1.258.648-2.253 0-1.83-.718-2.746-2.155-2.746-.704 0-1.254.232-1.65.697-.43.508-.646 1.256-.646 2.245 0 .972.19 1.686.574 2.14.35.41.877.615 1.583.615.264 0 .506-.033.725-.098l1.325.772.36-.622zM15.5 17.588c-.225-.36-.337-.94-.337-1.736 0-1.393.424-2.09 1.27-2.09.443 0 .77.167.977.5.224.362.336.936.336 1.723 0 1.404-.424 2.108-1.27 2.108-.445 0-.77-.167-.978-.5zm-1.658-.425c0 .47-.172.856-.516 1.156-.344.3-.803.45-1.384.45-.543 0-1.064-.172-1.573-.515l.237-.476c.438.22.833.328 1.19.328.332 0 .593-.073.783-.22a.754.754 0 00.3-.615c0-.33-.23-.61-.648-.845-.388-.213-1.163-.657-1.163-.657-.422-.307-.632-.636-.632-1.177 0-.45.157-.81.47-1.085.315-.278.72-.415 1.22-.415.512 0 .98.136 1.4.41l-.213.476a2.726 2.726 0 00-1.064-.23c-.283 0-.502.068-.654.206a.685.685 0 00-.248.524c0 .328.234.61.666.85.393.215 1.187.67 1.187.67.433.305.648.63.648 1.168zm9.382-5.852c-.535-.014-.95.04-1.297.188-.1.04-.26.04-.274.167.055.053.063.14.11.214.08.134.218.313.346.407.14.11.28.216.427.31.26.16.555.255.81.416.145.094.293.213.44.313.073.05.12.14.214.172v-.02c-.046-.06-.06-.147-.105-.214-.067-.067-.134-.127-.2-.193a3.223 3.223 0 00-.695-.675c-.214-.146-.682-.35-.77-.595l-.013-.014c.146-.013.32-.066.46-.106.227-.06.435-.047.67-.106.106-.027.213-.06.32-.094v-.06c-.12-.12-.21-.283-.334-.395a8.867 8.867 0 00-1.104-.823c-.21-.134-.476-.22-.697-.334-.08-.04-.214-.06-.26-.127-.12-.146-.19-.34-.275-.514a17.69 17.69 0 01-.547-1.163c-.12-.262-.193-.523-.34-.763-.69-1.137-1.437-1.826-2.586-2.5-.247-.14-.543-.2-.856-.274-.167-.008-.334-.02-.5-.027-.11-.047-.216-.174-.31-.235-.38-.24-1.364-.76-1.644-.072-.18.434.267.862.422 1.082.115.153.26.328.34.5.047.116.06.235.107.356.106.294.207.622.347.897.073.14.153.287.247.413.054.073.146.107.167.227-.094.136-.1.334-.154.5-.24.757-.146 1.693.194 2.25.107.166.362.534.703.393.3-.12.234-.5.32-.835.02-.08.007-.133.048-.187v.015c.094.188.188.367.274.555.206.328.566.668.867.895.16.12.287.328.487.402v-.02h-.015c-.043-.058-.1-.086-.154-.133a3.445 3.445 0 01-.35-.4 8.76 8.76 0 01-.747-1.218c-.11-.21-.202-.436-.29-.643-.04-.08-.04-.2-.107-.24-.1.146-.247.273-.32.453-.127.288-.14.642-.188 1.01-.027.007-.014 0-.027.014-.214-.052-.287-.274-.367-.46-.2-.475-.233-1.238-.06-1.785.047-.14.247-.582.167-.716-.042-.127-.174-.2-.247-.303a2.478 2.478 0 01-.24-.427c-.16-.374-.24-.788-.414-1.162-.08-.173-.22-.354-.334-.513-.127-.18-.267-.307-.368-.52-.033-.073-.08-.194-.027-.274.014-.054.042-.075.094-.09.088-.072.335.022.422.062.247.1.455.194.662.334.094.066.195.193.315.226h.14c.214.047.455.014.655.073.355.114.675.28.962.46a5.953 5.953 0 012.085 2.286c.08.154.115.295.188.455.14.33.313.663.455.982.14.315.275.636.476.897.1.14.502.213.682.286.133.06.34.115.46.188.23.14.454.3.67.454.11.076.443.243.463.378z +MariaDB=#003545|#FFFFFF|M23.157 4.412c-.676.284-.79.31-1.673.372-.65.045-.757.057-1.212.209-.75.246-1.395.75-2.02 1.59-.296.398-1.249 1.913-1.249 1.988 0 .057-.65.998-.915 1.32-.574.713-1.08 1.079-2.14 1.59-.77.36-1.224.524-4.102 1.477-1.073.353-2.133.738-2.367.864-.852.449-1.515 1.036-2.203 1.938-1.003 1.32-.972 1.313-3.042.947a12.264 12.264 0 00-.675-.063c-.644-.05-1.023.044-1.332.334L0 17.193l.177.088c.094.05.353.234.561.398.215.17.461.347.55.391.088.044.17.088.183.101.012.013-.089.17-.228.353-.435.581-.593.871-.574 1.048.019.164.032.17.43.17.517-.006.826-.056 1.261-.208.65-.233 2.058-.94 2.784-1.4.776-.5 1.717-.998 1.956-1.042.082-.02.354-.07.594-.114.58-.107 1.464-.095 2.587.05.108.013.373.045.6.064.227.025.43.057.454.076.026.012.474.037.998.056.934.026 1.104.007 1.3-.189.126-.133.385-.631.498-.985.209-.643.417-.921.366-.492-.113.966-.322 1.692-.713 2.411-.259.499-.663 1.092-.934 1.395-.322.347-.315.36.088.315.619-.063 1.471-.397 2.096-.82.827-.562 1.647-1.691 2.19-3.03.107-.27.22-.22.183.083-.013.094-.038.315-.057.498l-.031.328.353-.202c.833-.48 1.414-1.262 2.127-2.884.227-.518.877-2.922 1.073-3.976a9.64 9.64 0 01.271-1.042c.127-.429.196-.555.48-.858.183-.19.625-.555.978-.808.72-.505.953-.75 1.187-1.205.208-.417.284-1.13.132-1.357-.132-.202-.284-.196-.763.006Z +Oracle=#F80000|#FFFFFF|M16.412 4.412h-8.82a7.588 7.588 0 0 0-.008 15.176h8.828a7.588 7.588 0 0 0 0-15.176zm-.193 12.502H7.786a4.915 4.915 0 0 1 0-9.828h8.433a4.914 4.914 0 1 1 0 9.828z +SQL\ Server=#CC2927|#FFFFFF|M4.724 2.505s-.08.127-.004.315c.046.116.186.256.34.404 0 0 1.615 1.576 1.813 1.804.895 1.033 1.284 2.05 1.32 3.453.022.9-.151 1.692-.573 2.613-.756 1.649-2.35 3.468-4.81 5.49l.36-.12c.233-.173.548-.359 1.292-.766 1.713-.936 3.636-1.798 5.999-2.686 3.399-1.277 8.99-2.776 12.172-3.263l.331-.051-.05-.08c-.292-.452-.49-.731-.73-1.027-.697-.863-1.542-1.567-2.577-2.146-1.422-.797-3.267-1.416-5.6-1.88a67.93 67.93 0 00-2.191-.375 209.29 209.29 0 01-3.924-.64c-.425-.075-1.06-.181-1.481-.272a9.404 9.404 0 01-.961-.258c-.268-.105-.645-.207-.726-.515zm.936.909c.003-.002.063.017.137.042.136.046.316.1.526.159.146.04.307.084.479.127.218.056.399.104.401.107.024.027.391 1.198.516 1.647.048.172.084.315.081.318a.789.789 0 01-.09-.14c-.424-.746-1.097-1.505-1.874-2.116a3.104 3.104 0 01-.176-.144zm1.79.494c.018-.001.099.012.195.034.619.136 1.725.35 2.435.47.119.02.216.04.216.047a.348.348 0 01-.098.062c-.119.06-.602.349-.763.457-.403.27-.766.559-1.03.821a5.4 5.4 0 01-.197.192c-.003 0-.022-.062-.041-.137a12.09 12.09 0 00-.65-1.779 1.801 1.801 0 01-.071-.165c0-.001 0-.002.004-.002zm3.147.598c.02.007.06.13.129.404a6.05 6.05 0 01.153 1.977l-.012.038-.187-.06c-.388-.124-1.02-.31-1.562-.46a6.625 6.625 0 01-.56-.17c0-.022.449-.471.642-.642.369-.326 1.362-1.098 1.397-1.087zm.25.036c.011-.01 1.504.248 2.182.378.506.097 1.237.25 1.281.269.022.008-.054.05-.297.16-.96.432-1.672.82-2.38 1.293-.186.124-.341.226-.344.226-.004 0-.006-.104-.006-.23 0-.69-.139-1.387-.391-1.976a.688.688 0 01-.045-.12zm3.86.764c.011.011-.038.306-.08.48-.132.54-.482 1.344-.914 2.099a2.26 2.26 0 01-.152.246 1.499 1.499 0 01-.219-.115c-.422-.247-.9-.48-1.425-.697a4.588 4.588 0 01-.278-.12c-.024-.022 1.143-.795 1.762-1.166.495-.297 1.292-.741 1.306-.727zm.276.043c.033 0 .695.18 1.037.283.853.255 1.837.614 2.475.904l.265.12-.187.043c-1.561.36-2.9.773-4.188 1.296-.107.044-.2.08-.207.08a.911.911 0 01.075-.185c.388-.823.638-1.687.703-2.42.006-.067.018-.121.027-.121zm-6.58 1.512c.01-.01.514.108.789.185.413.116 1.292.41 1.292.433 0 .004-.097.089-.215.188-.475.397-.934.813-1.483 1.343a5.27 5.27 0 01-.308.285c-.007 0-.01-.023-.006-.05.083-.611.065-1.395-.05-2.193a1.29 1.29 0 01-.02-.19zm10.61.01c.007.008-.234.385-.384.6-.22.314-.537.726-1.261 1.637l-.954 1.202a9.418 9.418 0 01-.269.333c-.003 0-.05-.066-.103-.146a7.584 7.584 0 00-1.47-1.625 9.59 9.59 0 00-.27-.218.427.427 0 01-.074-.063c0-.01.617-.274 1.088-.466a37.02 37.02 0 012.778-.99c.442-.135.912-.27.919-.264zm.278.073a.93.93 0 01.207.1 12.274 12.274 0 012.428 1.824c.194.19.667.683.66.687l-.363.029c-1.53.115-3.486.44-5.37.893-.128.03-.238.056-.246.056-.007 0 .133-.14.311-.312 1.107-1.063 1.611-1.734 2.205-2.934.088-.178.163-.333.166-.342h.002zm-8.088.83c.051.01.523.23.879.408.325.163.818.426.843.449.003.003-.17.093-.386.201-.683.342-1.268.664-1.878 1.037-.175.107-.32.194-.325.194-.015 0-.01-.013.088-.191a7.702 7.702 0 00.738-2.002c.014-.062.03-.1.041-.097zm-.475.084c.01.01-.112.46-.19.7a9.092 9.092 0 01-.835 1.808l-.09.147-.203-.197a2.671 2.671 0 00-.676-.5 1.009 1.009 0 01-.176-.102c0-.03.62-.593 1.098-.998.343-.29 1.064-.867 1.072-.858zm2.888 1.188l.177.115c.407.264.888.619 1.255.924.206.172.605.53.687.616l.044.047-.294.082a53.8 53.8 0 00-4.45 1.424c-.167.061-.31.112-.32.112-.021 0-.042.019.333-.326.96-.883 1.807-1.856 2.44-2.802zm-.759.19c.009.009-.492.71-.789 1.106-.356.473-.99 1.265-1.426 1.78a8.769 8.769 0 01-.346.397c-.01.003-.015-.05-.016-.133 0-.44-.112-.91-.308-1.308-.083-.168-.097-.208-.08-.224.068-.062 1.127-.666 1.794-1.023.459-.246 1.163-.604 1.171-.595zm-4.59 1.125a3.988 3.988 0 01.812.518c.008.005-.087.083-.21.172-.345.249-.87.644-1.173.886-.32.255-.331.263-.295.207.24-.367.36-.574.486-.84.113-.236.224-.516.304-.76a.675.675 0 01.077-.183zm1.223.96c.017-.003.04.028.139.175.207.31.366.722.407 1.058l.008.073-.497.192c-.89.346-1.711.687-2.266.94-.155.072-.428.202-.607.292-.179.09-.325.16-.325.156 0-.004.112-.089.25-.188 1.087-.79 2.025-1.654 2.732-2.519.075-.092.144-.172.153-.178a.016.016 0 01.006-.002zm-.564.14c.015.014-.401.484-.681.77-.7.715-1.396 1.275-2.256 1.821-.108.069-.206.13-.22.138-.023.014.008-.022.386-.434.238-.259.42-.474.628-.743.136-.177.162-.202.362-.346.537-.388 1.767-1.221 1.781-1.207zM9.925 0c-.08-.01-1.371.455-2.2.791-1.123.457-1.996.894-2.534 1.272-.2.14-.452.393-.488.49a.356.356 0 00-.021.123l.488.46 1.158.37L9.087 4l3.153.542.032-.27-.028-.005-.415-.066-.085-.148a27.702 27.702 0 01-1.177-2.325 12.264 12.264 0 01-.53-1.465C9.969.02 9.962.005 9.925 0zm-.061.186h.005c.003.003.017.105.032.225.062.508.176 1 .354 1.53.134.4.136.377-.024.332-.37-.103-2.032-.388-3.234-.555a8.796 8.796 0 01-.357-.053c-.015-.015.867-.477 1.258-.66.501-.232 1.867-.8 1.966-.819zM6.362 1.814l.141.048c.772.262 2.706.632 3.775.72.12.01.222.021.225.024.003.003-.1.058-.228.122-.515.258-1.083.573-1.476.819-.115.072-.22.13-.235.129a4.868 4.868 0 01-.17-.027l-.144-.023-.365-.355c-.641-.62-1.141-1.1-1.335-1.28zm-.143.114l.511.638c.282.35.564.699.626.774.063.075.111.138.108.14-.014.011-.74-.13-1.125-.219a8.532 8.532 0 01-.803-.212l-.2-.064.001-.049c.003-.245.312-.607.836-.976zm4.352.869c.015.001.032.032.077.131.124.272.51 1.008.603 1.15.03.047.08.05-.433-.033-1.23-.198-1.629-.265-1.629-.273a.36.36 0 01.083-.054 7.13 7.13 0 001.107-.767l.175-.147c.006-.005.012-.008.017-.007zm4.309 8.408l-4.808 1.568-4.18 1.846-1.17.31c-.298.282-.613.568-.948.86-.37.321-.716.612-.98.822a7.46 7.46 0 00-.953.945c-.332.414-.592.854-.704 1.193-.2.61-.103 1.228.285 1.798.495.728 1.48 1.468 2.625 1.972.585.256 1.57.588 2.31.774 1.233.312 3.614.65 4.926.7.266.01.62.01.637-.002.028-.019.233-.405.47-.89.806-1.646 1.389-3.19 1.703-4.508.19-.799.338-1.863.434-3.125.027-.354.037-1.533.016-1.934a13.564 13.564 0 00-.183-1.706.435.435 0 01-.012-.15c.014-.01.059-.025.65-.197zm-1.1.645c.045 0 .16 1.114.191 1.82.006.151.005.247-.004.247-.028 0-.615-.345-1.032-.606a28.716 28.716 0 01-1.162-.772c-.035-.028-.031-.029.266-.131.505-.174 1.704-.558 1.742-.558zm-2.448.803c.03 0 .115.047.315.172.75.47 1.766 1.035 2.2 1.225.136.06.151.036-.16.247-.662.45-1.486.892-2.497 1.342a7.59 7.59 0 01-.331.142.989.989 0 01.043-.2c.245-.905.383-1.82.387-2.554.002-.362.002-.364.037-.373h.006zm-.504.193c.021.022.006.834-.02 1.056a9.206 9.206 0 01-.418 1.837c-.014.017-.511-.468-.676-.66a4.918 4.918 0 01-.669-.973c-.082-.162-.214-.484-.202-.493.056-.04 1.971-.78 1.985-.767zm-2.375.936c.004 0 .008.001.01.004a.881.881 0 01.056.131c.116.315.376.782.602 1.08a6.247 6.247 0 001.017 1.06c.023.02.03.016-.562.24a48.53 48.53 0 01-2.294.8c-.327.106-.604.195-.615.2-.033.011-.023-.009.073-.158.427-.666 1.073-1.97 1.435-2.892.062-.16.122-.32.133-.356.015-.052.031-.07.08-.092a.149.149 0 01.065-.017zm-.728.3c.01.009-.174.398-.356.751-.351.686-.739 1.361-1.253 2.185l-.182.288c-.018.027-.026.018-.082-.094a3.307 3.307 0 01-.28-.842 3.39 3.39 0 01.02-1.083c.047-.227.045-.222.152-.276.462-.237 1.966-.942 1.981-.929zm6.268.255v.154a20.106 20.106 0 01-.255 2.992 9.362 9.362 0 01-1.898-.782c-.354-.194-.865-.507-.85-.522.003-.004.154-.083.334-.177.714-.37 1.395-.77 1.988-1.166.222-.148.555-.389.629-.454zM4.981 15.41c.015 0 .011.028-.012.161a4.137 4.137 0 00-.041.39c-.03.532.057.924.32 1.46.074.15.132.274.129.276-.027.023-2.43.726-3.186.933l-.435.12c-.027.008-.029.002-.02-.06.083-.533.49-1.232 1.058-1.82.378-.39.68-.622 1.195-.915a30.782 30.782 0 01.992-.545zm5.669 1.015c.002-.002.091.045.197.107.777.449 1.86.87 2.783 1.081l.084.02-.115.063c-.482.268-2.071.929-3.694 1.537a68.82 68.82 0 00-.513.194.314.314 0 01-.082.027c0-.004.067-.132.149-.286.456-.852.91-1.887 1.144-2.605.023-.073.044-.135.047-.138zm-.578.19a1.39 1.39 0 01-.063.169 23.534 23.534 0 01-1.261 2.54 9.009 9.009 0 01-.252.433c-.005 0-.114-.066-.244-.145-.77-.472-1.452-1.052-1.9-1.617l-.064-.08.332-.091a23.616 23.616 0 003.19-1.103c.142-.06.26-.109.262-.106zm3.59 1.253c.001 0 .002.001.002.003 0 .08-.183.828-.336 1.37-.128.453-.236.808-.435 1.437a8.533 8.533 0 01-.168.504 15.004 15.004 0 01-3-.841 7.964 7.964 0 01-.639-.283c-.006-.007.213-.11.486-.23 1.655-.721 3.369-1.543 3.955-1.896a.432.432 0 01.135-.064zm-8.287.283c.009.009-.454.671-1.1 1.576l-.587.823c-.097.139-.245.358-.329.488l-.153.236-.162-.137c-.191-.16-.525-.501-.677-.69-.312-.389-.523-.798-.607-1.174-.038-.174-.04-.262-.003-.273a176.26 176.26 0 011.934-.455l1.3-.305c.209-.05.382-.09.384-.089zm.465.178l.117.131a6.763 6.763 0 001.706 1.394c.115.066.202.124.195.128a281.967 281.967 0 01-4.33 1.53.858.858 0 01-.072-.048l-.067-.048.105-.152c.34-.493.768-1.035 1.705-2.162zm2.9 2.073c.003-.003.165.054.362.128.473.177.844.292 1.347.418.617.155 1.51.31 2.038.354.08.006.122.016.11.024-.025.016-.56.194-.953.318a258.526 258.526 0 01-4.636 1.363c-.035.007-.157-.025-.157-.04 0-.009.087-.119.193-.246a22.027 22.027 0 001.476-1.984 56.9 56.9 0 01.22-.335zm-.642.018c.005.005-.253.418-.706 1.132-.192.301-.409.645-.483.762-.075.118-.184.298-.242.4l-.107.185-.054-.014c-.13-.035-1.049-.36-1.291-.456-.301-.12-.615-.264-.846-.389-.289-.156-.655-.388-.627-.397l1.105-.302c1.592-.434 2.473-.683 3.05-.864.109-.033.199-.059.2-.057zm4.523 1.061h.006c.015.038-.575 1.67-.79 2.188-.049.116-.066.145-.092.143a55.54 55.54 0 01-1.433-.2c-.906-.138-2.423-.403-2.806-.49l-.089-.02.543-.122c1.164-.262 1.723-.403 2.29-.577a16.544 16.544 0 002.138-.824c.113-.052.21-.093.233-.098Z +Db2=#052FAD|#FFFFFF|M23.544 15.993c.038 0 .06-.017.06-.053v-.036c0-.035-.022-.052-.06-.052h-.09v.14zm-.09.262h-.121v-.498h.225c.112 0 .169.066.169.157 0 .079-.036.129-.09.15l.111.19h-.133l-.092-.17h-.07zm.434-.222v-.062c0-.2-.157-.357-.363-.357a.355.355 0 00-.363.357v.062c0 .2.156.358.363.358a.355.355 0 00.363-.358zm-.838-.03c0-.28.212-.492.475-.492.264 0 .475.213.475.491 0 .279-.211.491-.475.491a.477.477 0 01-.475-.49zM16.21 8.13l-.216-.624h-3.56v.624zm.413 1.19l-.216-.623h-3.973v.624zm2.65 7.147h3.107v-.624h-3.108zm0-1.192h3.107v-.623h-3.108zm0-1.19h1.864v-.624h-1.865zm0-1.191h1.864v-.624h-1.865zm0-1.191h1.864v-.624h-3.555l-.175.504-.175-.504h-3.555v.624h1.865v-.574l.2.574h3.33l.2-.574zm1.864-1.815h-3.142l-.217.624h3.359zm-7.46 3.006h1.865v-.624h-1.865zm0 1.19h1.865v-.623h-1.865zm-1.243 1.191h3.108v-.623h-3.108zm0 1.192h3.108v-.624h-3.108zm6.386-8.961l-.216.624h3.776v-.624zm-.629 1.815h4.19v-.624h-3.974zm-4.514 1.19h3.359l-.216-.623h-3.143zm2.482 2.383h2.496l.218-.624h-2.932zm.417 1.19h1.662l.218-.623h-2.098zm.416 1.191h.83l.218-.623h-1.266zm.414 1.192l.217-.624h-.432zm-12.433-.006l4.578.006c.622 0 1.18-.237 1.602-.624h-6.18zm4.86-3v.624h2.092c0-.216-.03-.425-.083-.624zm-3.616.624h1.865v-.624H6.217zm3.617-3.573h2.008c.053-.199.083-.408.083-.624H9.834zm-3.617 0h1.865v-.624H6.217zM9.55 7.507H4.973v.624h6.18a2.36 2.36 0 00-1.602-.624zm2.056 1.191H4.973v.624h6.884a2.382 2.382 0 00-.25-.624zm-5.39 2.382v.624h4.87c.207-.176.382-.387.519-.624zm4.87 1.191h-4.87v.624h5.389a2.39 2.39 0 00-.519-.624zm-6.114 3.006h6.634c.11-.193.196-.402.25-.624H4.973zM0 8.13h4.352v-.624H0zm0 1.191h4.352v-.624H0zm1.243 1.191h1.865v-.624H1.243zm0 1.191h1.865v-.624H1.243zm0 1.19h1.865v-.623H1.243zm0 1.192h1.865v-.624H1.243zM0 15.276h4.352v-.623H0zm0 1.192h4.352v-.624H0Z +SQLite=#003B57|#FFFFFF|M21.678.521c-1.032-.92-2.28-.55-3.513.544a8.71 8.71 0 0 0-.547.535c-2.109 2.237-4.066 6.38-4.674 9.544.237.48.422 1.093.544 1.561a13.044 13.044 0 0 1 .164.703s-.019-.071-.096-.296l-.05-.146a1.689 1.689 0 0 0-.033-.08c-.138-.32-.518-.995-.686-1.289-.143.423-.27.818-.376 1.176.484.884.778 2.4.778 2.4s-.025-.099-.147-.442c-.107-.303-.644-1.244-.772-1.464-.217.804-.304 1.346-.226 1.478.152.256.296.698.422 1.186.286 1.1.485 2.44.485 2.44l.017.224a22.41 22.41 0 0 0 .056 2.748c.095 1.146.273 2.13.5 2.657l.155-.084c-.334-1.038-.47-2.399-.41-3.967.09-2.398.642-5.29 1.661-8.304 1.723-4.55 4.113-8.201 6.3-9.945-1.993 1.8-4.692 7.63-5.5 9.788-.904 2.416-1.545 4.684-1.931 6.857.666-2.037 2.821-2.912 2.821-2.912s1.057-1.304 2.292-3.166c-.74.169-1.955.458-2.362.629-.6.251-.762.337-.762.337s1.945-1.184 3.613-1.72C21.695 7.9 24.195 2.767 21.678.521m-18.573.543A1.842 1.842 0 0 0 1.27 2.9v16.608a1.84 1.84 0 0 0 1.835 1.834h9.418a22.953 22.953 0 0 1-.052-2.707c-.006-.062-.011-.141-.016-.2a27.01 27.01 0 0 0-.473-2.378c-.121-.47-.275-.898-.369-1.057-.116-.197-.098-.31-.097-.432 0-.12.015-.245.037-.386a9.98 9.98 0 0 1 .234-1.045l.217-.028c-.017-.035-.014-.065-.031-.097l-.041-.381a32.8 32.8 0 0 1 .382-1.194l.2-.019c-.008-.016-.01-.038-.018-.053l-.043-.316c.63-3.28 2.587-7.443 4.8-9.791.066-.069.133-.128.198-.194Z +Access=#A4373A|#FFFFFF|M15 .75q1.605 0 3.21.176.282.035.815.111.534.076 1.178.211.645.135 1.318.328.674.194 1.225.457.55.264.902.598.352.334.352.744v17.25q0 .41-.352.744-.351.334-.902.598-.55.263-1.225.457-.673.193-1.318.328-.644.135-1.178.217-.533.082-.814.105-1.606.176-3.211.176-1.617 0-3.21-.176-.282-.023-.815-.105-.534-.082-1.178-.217-.645-.135-1.318-.328-.674-.194-1.225-.457-.55-.264-.902-.598Q6 21.035 6 20.625V18H.996q-.41 0-.703-.293T0 17.004V6.996q0-.41.293-.703T.996 6H6V3.375q0-.41.352-.744.351-.334.902-.598.55-.263 1.225-.457.673-.193 1.318-.328.644-.135 1.178-.21.533-.077.814-.112Q13.383.75 15 .75zm0 1.5q-.762 0-1.7.053-.937.052-1.904.181-.966.13-1.88.346-.914.217-1.618.545.727.34 1.635.557.908.216 1.863.34.956.123 1.887.175.932.053 1.717.053t1.717-.053q.931-.052 1.887-.176.955-.123 1.863-.34.908-.216 1.635-.556-.704-.328-1.618-.545-.914-.217-1.88-.346-.967-.129-1.905-.181Q15.762 2.25 15 2.25zM4.723 14.145h2.543l.597 1.793h2.004l-2.87-7.876H5.038l-2.906 7.876h1.992zM22.5 20.379v-3.55q-.773.339-1.729.562-.955.222-1.962.357-1.008.135-1.993.193Q15.832 18 15 18q-.855 0-1.705-.053-.85-.052-1.693-.146-.27.199-.598.199H7.5v2.379q.34.27.914.48.574.211 1.272.364.697.152 1.459.252.761.1 1.482.164.72.064 1.342.088.62.023 1.031.023.41 0 1.031-.023.621-.024 1.342-.088.72-.065 1.482-.164.762-.1 1.46-.252.697-.153 1.27-.364.575-.21.915-.48zm0-5.227v-4.324q-.773.34-1.729.563-.955.222-1.962.357-1.008.135-1.993.193Q15.832 12 15 12q-.75 0-1.5-.041t-1.5-.111v4.5q.75.082 1.5.117t1.5.035q.434 0 1.055-.023.62-.024 1.33-.088.709-.065 1.459-.164.75-.1 1.441-.252.692-.153 1.272-.352.58-.2.943-.469zm0-6V4.828q-.773.34-1.729.563-.955.222-1.962.357-1.008.135-1.993.193Q15.832 6 15 6q-.832 0-1.816-.059-.985-.058-1.993-.193-1.007-.135-1.962-.357-.956-.223-1.729-.563V6h3.504q.41 0 .703.293t.293.703v3.352q.75.082 1.5.117t1.5.035q.434 0 1.055-.023.62-.024 1.33-.088.709-.065 1.459-.164.75-.1 1.441-.252.692-.153 1.266-.352.574-.2.949-.469zm-17.32 3.47l.808-2.45.785 2.45Z +DuckDB=#FFF000|#1A1A1A|M12 0C5.363 0 0 5.363 0 12s5.363 12 12 12 12-5.363 12-12S18.637 0 12 0zM9.502 7.03a4.974 4.974 0 0 1 4.97 4.97 4.974 4.974 0 0 1-4.97 4.97A4.974 4.974 0 0 1 4.532 12a4.974 4.974 0 0 1 4.97-4.97zm6.563 3.183h2.351c.98 0 1.787.782 1.787 1.762s-.807 1.789-1.787 1.789h-2.351v-3.551z +ClickHouse=#FFCC01|#1A1A1A|M21.333 10H24v4h-2.667ZM16 1.335h2.667v21.33H16Zm-5.333 0h2.666v21.33h-2.666ZM0 22.665V1.335h2.667v21.33zm5.333-21.33H8v21.33H5.333Z +Milvus=#00A1EA|#FFFFFF|M15.536 11.997a4.005 4.005 0 0 1-4.015 4.018c-2.36-.005-4.05-1.954-4.01-4.08a4.013 4.013 0 0 1 8.025.061zm8.463-.04a.823.823 0 0 1-.25.616q-1.122 1.128-2.248 2.25c-.082.083-.165.1-.25.054-.091-.048-.124-.121-.098-.242a12.764 12.764 0 0 0 .25-1.626 12.153 12.153 0 0 0 .028-1.49 11.56 11.56 0 0 0-.27-2.114.255.255 0 0 1 .017-.213.194.194 0 0 1 .273-.052.628.628 0 0 1 .086.075l2.174 2.174c.171.17.29.366.289.567zM11.226 20c-1.89-.002-3.488-.565-4.926-1.611a8.317 8.317 0 0 1-.952-.816q-1.125-1.112-2.253-2.221l-1.931-1.899c-.291-.285-.583-.57-.873-.857-.393-.39-.387-.83.012-1.217.296-.288.59-.579.885-.868q1.433-1.41 2.867-2.818c.532-.522 1.057-1.051 1.604-1.555a7.996 7.996 0 0 1 3.786-1.95A7.668 7.668 0 0 1 11.135 4c1.89.015 3.612.546 5.113 1.723a7.987 7.987 0 0 1 2.282 2.77 7.567 7.567 0 0 1 .76 2.556c.255 2.404-.414 4.518-2.046 6.312a7.772 7.772 0 0 1-3.927 2.366 8.995 8.995 0 0 1-2.091.274zm.364-2.137a5.819 5.819 0 0 0 .605-.036 5.35 5.35 0 0 0 .38-.05c1.525-.28 2.76-1.051 3.704-2.263.92-1.178 1.28-2.542 1.168-4.028a5.626 5.626 0 0 0-.631-2.168c-.627-1.213-1.596-2.067-2.817-2.646-1.096-.519-2.248-.649-3.443-.464A5.814 5.814 0 0 0 7.33 7.8c-.858.83-1.707 1.669-2.56 2.504-.426.419-.85.841-1.28 1.257-.274.266-.286.588-.013.857q.804.793 1.61 1.583c.722.709 1.44 1.42 2.165 2.126 1.184 1.153 2.604 1.737 4.339 1.738z +Elasticsearch=#005571|#FFFFFF|M13.394 0C8.683 0 4.609 2.716 2.644 6.667h15.641a4.77 4.77 0 0 0 3.073-1.11c.446-.375.864-.785 1.247-1.243l.001-.002A11.974 11.974 0 0 0 13.394 0zM1.804 8.889a12.009 12.009 0 0 0 0 6.222h14.7a3.111 3.111 0 1 0 0-6.222zm.84 8.444C4.61 21.283 8.684 24 13.395 24c3.701 0 7.011-1.677 9.212-4.312l-.001-.002a9.958 9.958 0 0 0-1.247-1.243 4.77 4.77 0 0 0-3.073-1.11z +OpenSearch=#005EB8|#FFFFFF|M23.1515 8.8125a.8484.8484 0 0 0-.8484.8485c0 6.982-5.6601 12.6421-12.6421 12.6421a.8485.8485 0 0 0 0 1.6969C17.5802 24 24 17.5802 24 9.661a.8485.8485 0 0 0-.8485-.8485Zm-5.121 5.4375c.816-1.3311 1.6051-3.1058 1.4498-5.5905-.3216-5.1468-4.9832-9.0512-9.3851-8.6281C8.372.1971 6.6025 1.6017 6.7598 4.1177c.0683 1.0934.6034 1.7386 1.473 2.2348.8279.4722 1.8914.7713 3.097 1.1104 1.4563.4096 3.1455.8697 4.4438 1.8265 1.5561 1.1467 2.6198 2.4759 2.2569 4.9606Zm-16.561-9C.6535 6.581-.1355 8.3558.0197 10.8405c.3216 5.1468 4.9832 9.0512 9.385 8.6281 1.7233-.1657 3.4927-1.5703 3.3355-4.0863-.0683-1.0934-.6034-1.7386-1.4731-2.2348-.8278-.4722-1.8913-.7713-3.0969-1.1104-1.4563-.4096-3.1455-.8697-4.4438-1.8265-1.5561-1.1467-2.6198-2.476-2.257-4.9606Z diff --git a/src/main/resources/com/github/sqljam/face/images/db/chroma.png b/src/main/resources/com/github/sqljam/face/images/db/chroma.png new file mode 100644 index 0000000..0f32b9c Binary files /dev/null and b/src/main/resources/com/github/sqljam/face/images/db/chroma.png differ diff --git a/src/main/resources/com/github/sqljam/face/images/db/h2.png b/src/main/resources/com/github/sqljam/face/images/db/h2.png new file mode 100644 index 0000000..6fa9097 Binary files /dev/null and b/src/main/resources/com/github/sqljam/face/images/db/h2.png differ diff --git a/src/main/resources/com/github/sqljam/face/images/db/pinecone.png b/src/main/resources/com/github/sqljam/face/images/db/pinecone.png new file mode 100644 index 0000000..ffeba39 Binary files /dev/null and b/src/main/resources/com/github/sqljam/face/images/db/pinecone.png differ diff --git a/src/main/resources/com/github/sqljam/face/images/db/qdrant.png b/src/main/resources/com/github/sqljam/face/images/db/qdrant.png new file mode 100644 index 0000000..da699ca Binary files /dev/null and b/src/main/resources/com/github/sqljam/face/images/db/qdrant.png differ diff --git a/src/main/resources/com/github/sqljam/face/images/db/weaviate.png b/src/main/resources/com/github/sqljam/face/images/db/weaviate.png new file mode 100644 index 0000000..3599d2c Binary files /dev/null and b/src/main/resources/com/github/sqljam/face/images/db/weaviate.png differ diff --git a/src/main/resources/com/github/sqljam/face/messages.properties b/src/main/resources/com/github/sqljam/face/messages.properties index b80c31f..470f05d 100644 --- a/src/main/resources/com/github/sqljam/face/messages.properties +++ b/src/main/resources/com/github/sqljam/face/messages.properties @@ -20,6 +20,7 @@ action.export=Export... action.import=Import... action.importScripts=Import Export Package... action.importParquet=Import Parquet or CSV Files... +action.importVectorDatabase=Import from Vector Database... action.exit=Exit action.newTargetConnection=New data source as the target action.about=About SqlJam @@ -27,7 +28,22 @@ 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. +about.message=Move tables across databases. Schema, data and all.\n\nCopies tables, indexes, constraints, sequences and partitions directly into another database, saves them as SQL, Parquet or CSV export packages, and imports collections of vector databases into databases with vectors. +about.tab=About +about.features=Features +feature.reliability.title=Reliability +feature.reliability.items=One transaction, rolled back on any error|Commit every N rows|Confirm before commit|Skip rows with errors, each one logged|Progress of every table, cancel at any time|A report of every transfer, kept in the history|Sync tasks on a schedule, in SqlJam or by the system +feature.vectors.title=Vector databases +feature.vectors.items=Databases, collections, partitions and tenants|Records page by page, conditions and native filters|Nearest neighbors of a record or a vector|Ids and vectors as columns, the payload as JSON|Vector indexes with their distances +feature.tagline=Import and export for relational, OLAP and vector databases +feature.sources.title=Data sources +feature.sources.items=Relational and OLAP databases, vector databases as sources|Categories with logos, a filter of the tree|SSH tunnels with passwords or keys|Info, health and a live dashboard of a data source +feature.structure.title=Structure +feature.structure.items=Tables, primary keys, indexes and foreign keys|Sequences, identity values and comments|Partitions of every kind of the same database|Custom types, vectors and vector indexes|Dialects of older versions +feature.rows.title=Rows +feature.rows.items=Data viewer with columns, WHERE and ORDER BY|SQL Query with highlighting, its rows exported|Insert, update or insert, or skip existing by key|Column mapping: names, types, columns left out|Values in one line, whole values, rows copied as CSV, JSON or Markdown +feature.transfer.title=Import and export +feature.transfer.items=Straight into another database|Export packages of SQL, Parquet or CSV|Packages imported in order, checked by SHA-256|Parquet and CSV files of other tools|Copies in the same schema|Package viewer about.version=Version about.homepage=Home page about.repository=Repository @@ -39,19 +55,13 @@ 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. -login.subtitle=Connect to a data source to import or export -login.savedConnections=Data Sources -login.search=Search data sources -login.noConnections=No data sources -login.new=New -login.delete=Delete -login.login=Connect -login.skip=Skip connection.new.title=New Data Source connection.edit.title=Edit Data Source +connection.kind.new=New... connection.name=Name connection.name.prompt=Data source name (optional) +connection.category=Category connection.dbType=Database type connection.host=Host connection.host.h2.prompt=Empty for embedded file @@ -62,6 +72,28 @@ connection.file=File connection.username=Username connection.password=Password connection.rememberPassword=Remember password +credentials.title=Authentication +connection.saveError=Unable to save the data source +credentials.header=Sign in to {0} +credentials.save=Save +credentials.ssh.password=SSH password +credentials.ssh.passphrase=Passphrase of the key +credentials.apiKey=API key +welcome.newDataSource=New Data Source +ssh.enabled=Connect through SSH tunnel +ssh.host=SSH host +ssh.host.prompt=SSH server, the host above is reached from it +ssh.authentication=Authentication +ssh.authentication.password=Password +ssh.authentication.key=Private key +ssh.password.prompt=Empty when the server authorizes the machine, e.g. Tailscale SSH +ssh.keyFile=Private key +ssh.passphrase=Passphrase +ssh.passphrase.prompt=Passphrase of the key, if any +ssh.error.host=Please enter the SSH host +ssh.error.port=Please enter an SSH port from 1 to 65535 +ssh.error.username=Please enter the SSH username +ssh.error.keyFile=Please choose the private key file connection.url=JDBC URL connection.url.prompt=Optional, overrides host, port and database connection.test=Test @@ -81,6 +113,7 @@ connection.delete.title=Delete Data Source connection.delete.message=Delete data source "{0}"? tree.loading=Loading... +tree.filter.prompt=Filter data sources tree.empty=(empty) tree.error=Error: {0} tree.loadError=Unable to load {0} @@ -235,6 +268,39 @@ import.warning.noDataSource=The package is exported for {0}, please create a {0} import.warning.parquetFiles=The directory contains Parquet or CSV files without manifest.json, please use Import Parquet or CSV Files import.summary=Executed {0} statements, restored {1} LOB values, {2} failed +report.view=View Report +report.tab=Report {0} +report.options=Options +report.tables=Tables ({0}) +report.objects=Objects +report.skipped=Skipped rows ({0}) +report.moreSkipped={0} more rows were skipped, the report lists the first ones +report.error=Error +report.export=Export +report.option=Option +report.value=Value +report.kind=Kind +report.count=Count +report.table=Table +report.row=Row +report.failedStatements=Failed statements +report.none=None +history.title=Import History +history.tab=Import History +history.empty=No imports yet, each import and export leaves its report here +history.open=Open +history.delete=Delete +history.delete.confirm=Delete the report of {0}? +history.refresh=Refresh +history.folder=Open Folder +history.started=Started +history.kind=Kind +history.source=Source +history.target=Target +history.status=Status +history.rows=Rows +history.duration=Duration +report.notSaved=The report is not saved: {0} progress.exportTitle=Exporting SQL scripts progress.importTitle=Importing into {0} progress.running=Running... @@ -354,11 +420,30 @@ data.query.exportGrouped=Grouped rows are for viewing, remove GROUP BY to export 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 +mapping.title=Map Columns +mapping.header=Rename a column, give it a type of the target or leave it out. Columns which are not changed are imported as they are. +mapping.button=Map Columns... +mapping.button.tooltip=Target names and types of the columns, columns which are left out +mapping.table=Table +vdb.collections.choose=Check the collections to map their fields +mapping.columns=Columns +mapping.import=Import +mapping.source=Source column +mapping.sourceType=Source type +mapping.target=Target column +mapping.targetType=Target type +mapping.automatic=Translated type +mapping.reset=Reset +mapping.loading=Loading columns... +mapping.empty=No columns +mapping.required={0} is the key of the rows, it is always imported +mapping.error.duplicate=Columns {0} and {1} of {3} are both mapped to {2} +mapping.error.noColumns=No column of {0} is imported +mapping.unchanged=All columns are imported as they are +mapping.changed={0} columns are mapped keys.title=Key Columns keys.header=Columns which identify a row of each table keys.hint=Rows are matched by these columns: existing rows are updated or skipped, other rows are inserted. Leave a field empty for the primary key, separate columns by commas. @@ -390,6 +475,11 @@ export.query.table=Table name export.query.sql=Query export.error.queryTable=The table name of the query must start with a letter or underscore and have letters, digits and underscores only action.sqlQuery=SQL Query +sql.catalog=Database +sql.schema=Schema +sql.catalog.tooltip=Database of the statements of this editor, the data source keeps its own +sql.schema.tooltip=Schema of the statements of this editor, the data source keeps its own +sql.switched=Statements run in {0} sql.tabTitle=SQL {0}: {1} sql.result=Result sql.messages=Messages @@ -412,3 +502,190 @@ sql.updated={0} rows changed in {1} ms: {2} sql.status.rows={0} rows sql.status.first=First {0} rows, Export reads all of them sql.status.done={0} statements done + +vdb.title=Import from Vector Database +vdb.group=Vector databases +vdb.open=Import into Database... +vdb.openItem=Open Collection +vdb.connection=Vector database +vdb.rows=Records +vdb.rows.all=All records of the collections +vdb.rows.nearest=nearest {0} by {1} +vdb.partitions.all={0}: all +vdb.partitions.some={0}: {1} chosen +vdb.progress.skipped=Skipped a row of {0}, {1}: {2} +vdb.progress.statementSkipped=Skipped {1}: {0} +vdb.import=Import... +vdb.fields=Fields +vdb.records.shown=Records {0} to {1} of {2} in {3} ms +vdb.records.shownUnknown=Records {0} to {1} in {2} ms +vdb.records.max=Pages show the first {0} records, conditions or partitions narrow the records +vdb.page=Page {0} +vdb.collection.tooltip={0}.{1}, {2} records +vdb.header=Import collections of a vector database into a database with vectors +vdb.connection.new=New Vector Database +vdb.connection.edit=Edit Vector Database +vdb.connection.delete=Delete Vector Database +vdb.connection.type=Type +vdb.connection.url=REST API URL +vdb.connection.apiKey=API key +vdb.connection.apiKey.prompt=API key or token +vdb.connection.authentication=Authentication +vdb.authentication.none=None +vdb.authentication.api_key=API key +vdb.authentication.user_password=User and password +vdb.connection.error.apiKey=API key is required, or choose None +vdb.connection.error.username=Username is required, or choose None +vdb.connection.database.prompt=Milvus and Chroma, empty for the default database +vdb.connection.tenant=Tenant +vdb.connection.trust=Trust self-signed certificates +vdb.connection.remember=Remember the credentials +vdb.connection.error.url=The URL starts with http:// or https:// +vdb.connection.success=Connected: {0} {1}, {2} collections +vdb.collection=Collection +vdb.collections.empty=No collections, choose or create a vector database +vdb.collections.loaded={0} collections, check those to import, select one to see its fields +vdb.table=Target table +vdb.field.index=Vector index +vdb.fields.empty=Select a collection to see its fields +vdb.query.conditions=Conditions +vdb.query.conditions.prompt=One condition a line, e.g. n >= 6, lang IN ('en', 'zh'), title = 'a b' +vdb.query.native=Native filter +vdb.query.native.prompt=Filter of the database, e.g. a Milvus expression, a Qdrant filter, an Elasticsearch query, a Weaviate where, a Pinecone or Chroma filter. It replaces the conditions +vdb.query.nearest=Nearest to +vdb.query.nearest.prompt=Id of a record or a vector [0.1, 0.2, ...], empty for all records +vdb.query.nearest.error=The vector is a list of numbers, e.g. [0.1, 0.2] +vdb.mode.recreate=Create or replace the tables +vdb.mode.append=Append rows to existing tables +vdb.mode.upsert=Update or insert rows by key +vdb.options=Options +vdb.option.index=Create vector indexes +vdb.option.skipErrors=Skip rows with errors +vdb.targets.checking=Checking which data sources have vectors... +vdb.targets.none=No data source with vectors, create one with + +vdb.targets.choose=A data source with vectors +vdb.target.checking=Checking the vectors of the target... +vdb.target.ready={0} {1} has vectors: {2} +vdb.showSql=Show SQL +vdb.start.error=Choose collections and a target with vectors +vdb.recreate.confirm=Existing tables of the {0,choice,1#checked collection|1<{0} checked collections} are dropped and created again. Continue? +vdb.result.done=Imported {0} rows, {1} skipped +value.vector=Vector of {0} dimensions +value.sparse=Sparse vector of {0} dimensions +value.length={0} characters +value.doubleClick=Double click or Shift+Enter to see the whole value +value.view=View Value +value.copy=Copy Value +value.copyRows=Copy Rows As +info.title={0} Info +info.tooltip=Overview and health of {0} +info.overview=Overview +info.health=Health +info.item=Item +info.value=Value +info.refreshing=Reading... +info.refreshed=Last refreshed {0} +action.info=Info +sync.title=Sync Tasks +sync.tab=Sync Tasks +sync.new=New Sync Task +sync.edit=Edit Sync Task +sync.header=Copy the rows which changed into another database, on a schedule +sync.name=Name +sync.kind=Source kind +sync.kind.database=Tables of a data source +sync.kind.vector=Collections of a vector database +sync.source=Source +sync.sourceCatalog=Source database +sync.sourceSchema=Source schema +sync.table=Table +sync.watermark=Watermark +sync.watermark.updated_at=Update time +sync.watermark.increasing_key=Increasing key +sync.keys=Keys +sync.noTables=Choose a source to list its tables +sync.target=Target +sync.targetCatalog=Target database +sync.targetSchema=Target schema +sync.schedule=Schedule +sync.schedule.manual=Run Now only +sync.schedule.interval=Every +sync.schedule.daily=Every day at +sync.minutes=minutes +sync.retries=Attempts after errors +sync.note=The first run copies all rows. Later runs copy the rows whose watermark is at least the last one, by update or insert by key (the primary key if Keys is empty). Rows deleted in the source stay in the target. Tasks run while SqlJam is open, a run missed meanwhile runs when it starts. +sync.error.name=Name the task. +sync.error.source=Choose the source. +sync.error.target=Choose the target data source. +sync.error.tables=Check at least one table. +sync.error.watermark=Choose the watermark column of {0}. +sync.error.same=The target is the source itself, choose another data source or schema. +sync.error.time=Type the time of the day as HH:mm, e.g. 02:00. +sync.column.name=Name +sync.column.kind=Kind +sync.column.route=Source → Target +sync.column.schedule=Schedule +sync.column.next=Next run +sync.column.last=Last run +sync.column.status=Last status +sync.column.rows=Rows +sync.add=New +sync.editButton=Edit +sync.delete=Delete +sync.delete.confirm=Delete the sync task {0}? Its reports stay in the history. +sync.run=Run Now +sync.cancel=Cancel Run +sync.enable=Enable +sync.disable=Disable +sync.report=Last Report +sync.refresh=Refresh +sync.running=Running +sync.never=Never +sync.disabled=Disabled +sync.every=Every {0} min +sync.daily=Every day at {0} +sync.manual=Run Now only +sync.empty=No sync tasks yet, New creates one +sync.password=The password of {0} is not remembered, scheduled runs need it. Remember it in the data source. +sync.runner=Run by +sync.runner.sqljam=SqlJam, while it is open +sync.runner.system=The system scheduler, also when SqlJam is closed +sync.column.runner=Run by +sync.runner.short.sqljam=SqlJam +sync.runner.short.system=System scheduler +sync.runningElsewhere=Running (command line) +sync.system.error=The system scheduler did not take the task: {0} +info.dashboard=Dashboard +dashboard.sample=Sample +dashboard.every=every {0} s +dashboard.pause=Pause +dashboard.resume=Resume +dashboard.sampled=Sampled at {0} +dashboard.disconnected=Not connected, tried again with the next sample: {0} +dashboard.unavailable=Not told by this server: +menu.database=Database +menu.transfer=Transfer +action.disconnectAll=Disconnect All +action.dashboard=Dashboard +action.reportsFolder=Open Reports Folder +action.nextTheme=Next Theme +action.filter=Filter Data Sources +action.collapseAll=Collapse All +action.closeTab=Close Tab +action.closeOtherTabs=Close Other Tabs +action.closeAllTabs=Close All Tabs +action.fullScreen=Full Screen +action.sync=Sync +action.history=History +help.videos=Tutorial Videos +help.video.sql=SQL Databases +help.video.vectors=Vector Databases +help.video.sync=Scheduled Sync and Reports +help.docs=Documentation +help.developerGuide=Developer Guide +help.issue=Report an Issue +help.settingsFile=Open Settings File +help.dataFolder=Open SqlJam Folder +help.shortcuts=Keyboard Shortcuts +shortcuts.action=Action +shortcuts.keys=Keys diff --git a/src/main/resources/sqljam.properties b/src/main/resources/sqljam.properties index be5c808..bc8684e 100644 --- a/src/main/resources/sqljam.properties +++ b/src/main/resources/sqljam.properties @@ -51,6 +51,14 @@ sqljam.import.confirm-commit.reminder-minutes=10 sqljam.export.last-directory= sqljam.import.last-directory= +# Vector databases (Milvus, Qdrant, Weaviate, Pinecone, Elasticsearch, OpenSearch, Chroma) as sources, imported +# straight into a target database with vectors. false hides the vector databases and File > Import from Vector Database, nothing of them is loaded +sqljam.vdb.enabled=true +# Vector databases are saved apart from the data sources +sqljam.vdb.connections.file=${sqljam.home}/vdb-connections.json +# Host keys of the SSH servers of tunnels, trusted on first use, a changed key is refused +sqljam.ssh.known-hosts.file=${sqljam.home}/ssh_known_hosts + # Connection pool (HikariCP). Tables are copied one by one, a few connections are enough sqljam.pool.maximum-size=10 sqljam.pool.minimum-idle=1 diff --git a/src/test/java/com/github/sqljam/face/LauncherScriptTest.java b/src/test/java/com/github/sqljam/face/LauncherScriptTest.java index 74b16be..b73fad9 100644 --- a/src/test/java/com/github/sqljam/face/LauncherScriptTest.java +++ b/src/test/java/com/github/sqljam/face/LauncherScriptTest.java @@ -52,6 +52,19 @@ void checksJavaVersion() throws Exception { assertTrue(text.contains("start \"SqlJam\" \"%JAVAW%\"")); } + /** + * sqljam.bat sync runs in the console with java.exe, so that the Task Scheduler gets its output and exit code + */ + @Test + void runsSyncTasksInTheConsole() throws Exception { + String text = Files.readString(SCRIPT.toPath(), StandardCharsets.UTF_8); + assertTrue(text.contains("if /i \"%~1\"==\"sync\" goto sync")); + assertTrue(text.contains("\"%JAVA%\" %VMOPTIONS% -Dsqljam.vmoptions.applied=true %JAVA_OPTS% -jar \"%JAR%\" %*")); + assertTrue(text.contains("endlocal & exit /b %CODE%")); + assertTrue(Files.readString(new File("src/bin/sqljam.sh").toPath(), StandardCharsets.UTF_8).contains( + "sqljam.sh sync --task NAME")); + } + @Test void expandsNoPathInBlocks() throws Exception { List lines = Files.readAllLines(SCRIPT.toPath(), StandardCharsets.UTF_8); diff --git a/src/test/java/com/github/sqljam/face/SyncCommandTest.java b/src/test/java/com/github/sqljam/face/SyncCommandTest.java new file mode 100644 index 0000000..b6c38f9 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/SyncCommandTest.java @@ -0,0 +1,207 @@ +/* + * 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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.github.sqljam.config.Config; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.model.SyncTask; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.service.ConnectionStore; +import com.github.sqljam.face.service.SettingsStore; +import com.github.sqljam.face.view.AppContext; +import com.github.sqljam.face.view.ProfileRegistry; +import com.github.sqljam.impexp.DbType; + +/** + * @Description: SyncCommandTest runs sync tasks by the command line: the list, a task by its name or id, a task + * when it is due, every task which is due, the exit codes, and a task which runs elsewhere is not run + * twice. The runs leave their reports in the history and tell the command line as their starter. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class SyncCommandTest { + + @TempDir + File dir; + + private AppContext context; + private SyncTask task; + private final ByteArrayOutputStream out = new ByteArrayOutputStream(); + private final ByteArrayOutputStream err = new ByteArrayOutputStream(); + + private ConnectionProfile h2(String name) throws Exception { + ConnectionProfile profile = new ConnectionProfile(); + profile.setName(name); + profile.setDbType(DbType.H2); + profile.setDatabase(new File(dir, name).getAbsolutePath()); + profile.setUsername("sa"); + profile.setPassword(""); + context.getProfileRegistry().saveProfile(profile, true); + return profile; + } + + @BeforeEach + void setUp() throws Exception { + Config config = new Config(new File(dir, Config.FILE_NAME)); + config.set("sqljam.reports.dir", new File(dir, "reports").getAbsolutePath()); + config.set("sqljam.sync.file", new File(dir, "sync-tasks.json").getAbsolutePath()); + config.set("sqljam.vdb.connections.file", new File(dir, "vdb-connections.json").getAbsolutePath()); + Config.setInstance(config); + context = new AppContext(new SettingsStore(config), new ProfileRegistry(new ConnectionStore(new File(dir, + "connections.json"))), null); + ConnectionProfile source = h2("source"); + ConnectionProfile target = h2("target"); + try (Connection connection = DriverManager.getConnection(source.getJdbcUrl(), "sa", ""); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE ITEMS (ID INT PRIMARY KEY, NAME VARCHAR(20))"); + statement.execute("INSERT INTO ITEMS VALUES (1, 'a'), (2, 'b')"); + } + task = new SyncTask(); + task.setName("Items"); + task.setSourceId(source.getId()); + task.setSourceSchema("PUBLIC"); + task.setTargetId(target.getId()); + task.setTargetSchema("PUBLIC"); + task.getTables().add(new SyncTask.Table("ITEMS", "ID", SyncTask.WatermarkType.INCREASING_KEY)); + task.setScheduleType(SyncTask.ScheduleType.INTERVAL); + task.setIntervalMinutes(30); + task.setRunner(SyncTask.Runner.SYSTEM); + context.getSyncScheduler().getStore().saveTask(task); + } + + @AfterEach + void tearDown() { + context.closeSyncScheduler(); + } + + private int run(String... args) { + out.reset(); + err.reset(); + return SyncCommand.run(args, context, new PrintStream(out, true, StandardCharsets.UTF_8), new PrintStream( + err, true, StandardCharsets.UTF_8)); + } + + private String out() { + return out.toString(StandardCharsets.UTF_8); + } + + private String err() { + return err.toString(StandardCharsets.UTF_8); + } + + @Test + void runsTasks() throws Exception { + assertTrue(SyncCommand.isCommand(new String[]{"sync", "--list"})); + assertFalse(SyncCommand.isCommand(new String[]{})); + assertEquals(0, run("sync", "--list")); + assertTrue(out().contains("Items\tSYSTEM\tevery 30 min"), out()); + + // Created just now: not due yet + assertEquals(0, run("sync", "--task", "items", "--if-due")); + assertEquals("Items: not due", out().trim()); + assertEquals(0, run("sync", "--due")); + assertEquals("", out()); + + assertEquals(0, run("sync", "--task", "Items")); + assertTrue(out().startsWith("Items: Completed, 2 rows in "), out()); + SyncTask saved = context.getSyncScheduler().getStore().getTasks().get(0); + assertEquals("Command line", saved.getLastRunBy()); + assertEquals("2", saved.getTables().get(0).getWatermark().getValue()); + TransferReport report = context.getReportStore().list().get(0); + assertEquals("Command line", report.getOptions().get("Started by")); + + // Overdue: run by --due + context.getSyncScheduler().getStore().updateTask(task.getId(), changed -> { + changed.setCreatedAt(System.currentTimeMillis() - 24 * 60 * 60_000L); + changed.setLastRunAt(System.currentTimeMillis() - 31 * 60_000L); + }); + assertEquals(0, run("sync", "--due")); + assertTrue(out().startsWith("Items: Completed, 0 rows"), out()); + assertEquals(2, context.getReportStore().list().size()); + } + + @Test + void tellsWrongCommandsAndFailures() throws Exception { + assertEquals(2, run("sync")); + assertTrue(err().startsWith("Usage: sqljam sync --list")); + assertEquals(2, run("sync", "--task")); + assertEquals(2, run("sync", "--task", "x", "--now")); + assertEquals(2, run("sync", "--task", "missing")); + assertTrue(err().contains("No sync task missing")); + // The target is gone: failed, exit code 1 + context.getProfileRegistry().removeProfile(task.getTargetId()); + assertEquals(1, run("sync", "--task", task.getId())); + assertTrue(err().contains("Items: Failed"), err()); + assertTrue(err().contains("target data source of the task was removed"), err()); + } + + @Test + void runsNotATaskWhichRunsElsewhere() throws Exception { + File lock = new File(new File(dir, "sync-locks"), task.getId() + ".lock"); + Files.createDirectories(lock.getParentFile().toPath()); + // Another process holds the lock: a child JVM, the lock of this JVM would overlap + Process holder = new ProcessBuilder(ProcessHandle.current().info().command().orElse("java"), "-cp", + System.getProperty("java.class.path"), LockHolder.class.getName(), lock.getAbsolutePath()) + .redirectErrorStream(true).start(); + try { + assertEquals("locked", new String(holder.getInputStream().readNBytes(6), StandardCharsets.UTF_8)); + assertTrue(context.getSyncScheduler().isRunningElsewhere(task.getId())); + assertEquals(0, run("sync", "--task", "Items")); + assertEquals("Items: runs already elsewhere, not run again", out().trim()); + assertTrue(context.getReportStore().list().isEmpty()); + } finally { + holder.destroy(); + holder.waitFor(); + } + assertFalse(context.getSyncScheduler().isRunningElsewhere(task.getId())); + } + + /** + * Holds the lock of a task until it is killed + */ + public static final class LockHolder { + + public static void main(String[] args) throws Exception { + try (FileChannel channel = FileChannel.open(new File(args[0]).toPath(), StandardOpenOption.CREATE, + StandardOpenOption.WRITE); FileLock lock = channel.lock()) { + System.out.print("locked"); + System.out.flush(); + Thread.sleep(60_000); + } + } + } +} diff --git a/src/test/java/com/github/sqljam/face/service/DashboardTest.java b/src/test/java/com/github/sqljam/face/service/DashboardTest.java new file mode 100644 index 0000000..32332af --- /dev/null +++ b/src/test/java/com/github/sqljam/face/service/DashboardTest.java @@ -0,0 +1,187 @@ +/* + * 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +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.service.MetricSource.Chart; +import com.github.sqljam.face.service.MetricSource.Series; +import com.github.sqljam.impexp.DbType; + +/** + * @Description: DashboardTest verifies the values of the dashboard: gauges as they are, counters a second, ratios of + * counters in percent, counters which went back, and the metrics of an embedded H2 database read in + * SqlJam, whose lost connection is opened again + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class DashboardTest { + + @TempDir + File dir; + + /** + * Raw values given one sample after the other + */ + private static final class QueuedSource implements MetricSource { + + final Deque samples = new ArrayDeque<>(); + boolean closed; + + @Override + public List getCharts() { + return List.of(new Chart("Sessions", Unit.COUNT, Series.gauge("Active", "active")), new Chart( + "Commits", Unit.PER_SECOND, Series.rate("Commits", "commits")), new Chart("CPU", Unit.PERCENT, + Series.ratio("Busy", "busy", "busy", "idle"))); + } + + @Override + @SuppressWarnings("unchecked") + public Map read() throws Exception { + Object next = samples.removeFirst(); + if (next instanceof Exception) { + throw (Exception) next; + } + return (Map) next; + } + + @Override + public Map getUnavailable() { + return Map.of(); + } + + @Override + public void close() { + closed = true; + } + } + + @Test + void turnsCountersIntoRates() throws Exception { + QueuedSource source = new QueuedSource(); + AtomicLong clock = new AtomicLong(10_000); + DashboardSampler sampler = new DashboardSampler(source, clock::get); + Chart sessions = source.getCharts().get(0); + Chart commits = source.getCharts().get(1); + Chart cpu = source.getCharts().get(2); + source.samples.add(Map.of("active", 3.0, "commits", 100.0, "busy", 10.0, "idle", 90.0)); + DashboardSampler.Sample first = sampler.sample(); + assertEquals(3.0, first.get(sessions, sessions.getSeries().get(0))); + // Rates need two samples + assertNull(first.get(commits, commits.getSeries().get(0))); + assertNull(first.get(cpu, cpu.getSeries().get(0))); + + clock.addAndGet(2000); + source.samples.add(Map.of("active", 4.0, "commits", 160.0, "busy", 40.0, "idle", 100.0)); + DashboardSampler.Sample second = sampler.sample(); + assertEquals(12_000, second.getTime()); + assertEquals(30.0, second.get(commits, commits.getSeries().get(0)), 0.001); + // 30 busy of 40 + assertEquals(75.0, second.get(cpu, cpu.getSeries().get(0)), 0.001); + + // A restart of the server: counters start again, no negative rate + clock.addAndGet(2000); + source.samples.add(Map.of("active", 1.0, "commits", 5.0, "busy", 40.0, "idle", 100.0)); + DashboardSampler.Sample restarted = sampler.sample(); + assertNull(restarted.get(commits, commits.getSeries().get(0))); + // Nothing happened: no share + assertNull(restarted.get(cpu, cpu.getSeries().get(0))); + + // A lost connection: the counters of the next connection start again + source.samples.add(new SQLException("connection reset")); + assertThrows(SQLException.class, sampler::sample); + clock.addAndGet(2000); + source.samples.add(Map.of("active", 1.0, "commits", 50.0)); + DashboardSampler.Sample again = sampler.sample(); + assertNull(again.get(commits, commits.getSeries().get(0))); + assertEquals(1.0, again.get(sessions, sessions.getSeries().get(0))); + assertEquals(List.of("busy", "idle"), cpu.getSeries().get(0).getKeys()); + assertEquals(List.of("active"), sessions.getSeries().get(0).getKeys()); + } + + @Test + void readsMetricsOfEmbeddedDatabases() throws Exception { + ConnectionProfile profile = new ConnectionProfile(); + profile.setName("h2"); + profile.setDbType(DbType.H2); + profile.setDatabase(new File(dir, "db").getAbsolutePath()); + profile.setUsername("sa"); + profile.setPassword(""); + try (Connection connection = DriverManager.getConnection(profile.getJdbcUrl(), "sa", ""); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE T (ID INT)"); + } + DatabaseSession session = new DatabaseSession(profile); + try (JdbcMetrics metrics = new JdbcMetrics(profile, session)) { + assertEquals(List.of("CPU of SqlJam", "Memory of SqlJam", "Sessions", "File", "Sample time"), metrics + .getCharts().stream().map(Chart::getTitle).toList()); + Map values = metrics.read(); + assertTrue(metrics.getUnavailable().isEmpty(), metrics.getUnavailable().toString()); + assertTrue(values.get("h2.sessions") >= 1); + assertTrue(values.get("file.size") > 0); + assertTrue(values.get("jvm.heap") > 0); + assertTrue(values.get("jvm.cpu") >= 0); + assertNotNull(values.get(JdbcMetrics.SAMPLE_TIME)); + assertEquals(new File(dir, "db.mv.db"), metrics.databaseFile()); + + // The connection is lost: the sample fails, the next one opens a new connection + metrics.getConnection().close(); + assertThrows(SQLException.class, metrics::read); + assertNull(metrics.getConnection()); + assertTrue(metrics.read().get("h2.sessions") >= 1); + assertTrue(metrics.getUnavailable().isEmpty()); + } finally { + session.close(); + } + } + + @Test + void readsEachQueryOnceASample() throws Exception { + try (Connection connection = DriverManager.getConnection("jdbc:h2:mem:pairs")) { + JdbcMetrics.Context context = new JdbcMetrics.Context(connection); + String sql = "SELECT 'a', '1' UNION ALL SELECT 'a', '2' UNION ALL SELECT 'b', 'x' UNION ALL SELECT NULL, " + + "'3'"; + Map pairs = context.pairs(sql); + // Rows of the same name are added, values which are no numbers are left out + assertEquals(Map.of("a", 3.0), pairs); + assertTrue(pairs == context.pairs(sql)); + assertEquals(Map.of("X", 1.5, "Y", 2.0), context.row("SELECT 1.5 AS x, '2' AS y")); + SQLException failure = assertThrows(SQLException.class, () -> context.row("SELECT * FROM missing")); + // The failure is kept for the other metrics of the query + assertTrue(failure == assertThrows(SQLException.class, () -> context.row("SELECT * FROM missing"))); + assertEquals(Map.of(), context.row("SELECT 1 FROM DUAL WHERE 1 = 0")); + } + } +} diff --git a/src/test/java/com/github/sqljam/face/service/ReportExporterTest.java b/src/test/java/com/github/sqljam/face/service/ReportExporterTest.java new file mode 100644 index 0000000..9428f10 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/service/ReportExporterTest.java @@ -0,0 +1,150 @@ +/* + * 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.IOException; +import java.sql.SQLException; +import java.util.Arrays; + +import org.junit.jupiter.api.Test; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.model.TransferReport.TableReport; + +/** + * @Description: ReportExporterTest verifies the report as Markdown, HTML and JSON, the verdicts of the tables and + * the words of durations and statuses + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class ReportExporterTest { + + /** + * A failed import with a skipped row, objects and values which need escaping + */ + private static TransferReport report() { + ReportRecorder recorder = new ReportRecorder(new TransferReport()); + TransferReport report = recorder.getReport(); + report.setTitle("Import into "); + report.setKind(TransferReport.Kind.PACKAGE_IMPORT); + report.setSource("/tmp/package"); + report.setTarget("pg / app"); + report.setTransaction(TransferReport.Transaction.ONE); + report.getOptions().put("Skip rows with errors", "true"); + recorder.start(); + recorder.ddlExecuted("CREATE TABLE a (id INT)", true); + recorder.ddlExecuted("CREATE INDEX i ON a (id)", false); + recorder.tableStarted(null, "app", "a", 2); + recorder.rowSkipped(null, null, "a", "id=1", new SQLException("bad | value\nhere")); + recorder.tableEnded(null, "app", "a", 1); + recorder.tableStarted(null, "app", "b", 10); + recorder.tableProgress(null, "app", "b", 4); + report.setRolledBack(true); + recorder.finish(TransferReport.Status.FAILED, new SQLException("disk full"), null); + return report; + } + + @Test + void writesMarkdown() throws IOException { + String text = ReportExporter.export(report(), ReportExporter.Format.MARKDOWN); + assertTrue(text.startsWith("# Import into \n")); + assertTrue(text.contains("| Status | Failed |")); + assertTrue(text.contains("| Kind | Package import |")); + assertTrue(text.contains("| Stopped at | app.b after 4 rows |")); + assertTrue(text.contains("| Error | disk full |")); + assertTrue(text.contains("| Kept in target | Nothing, the transaction was rolled back |")); + assertTrue(text.contains("## Options")); + assertTrue(text.contains("| app.a | Completed with errors | 2 | 1 | 1 |")); + assertTrue(text.contains("| Tables | 1 |")); + assertTrue(text.contains("| Failed statements | 1 |")); + // A cell is one line and its bars are escaped + assertTrue(text.contains("| a | id=1 | bad \\| value here |")); + assertTrue(text.contains("## Error\n\n```\njava.sql.SQLException: disk full")); + } + + @Test + void writesHtmlWhichOpensAnywhere() throws IOException { + String html = ReportExporter.export(report(), ReportExporter.Format.HTML); + assertTrue(html.startsWith("")); + assertTrue(html.contains("Import into <prod>")); + assertFalse(html.contains("")); + assertTrue(html.contains("

Failed

")); + assertTrue(html.contains("prefers-color-scheme: dark")); + assertTrue(html.contains("

Skipped rows

")); + assertTrue(html.contains("

Error

java.sql.SQLException: disk full"));
+        assertEquals("&<>"'", ReportExporter.escape("&<>\"'"));
+    }
+
+    @Test
+    void writesJsonWhichIsReadBack() throws IOException {
+        TransferReport report = report();
+        String json = ReportExporter.export(report, ReportExporter.Format.JSON);
+        TransferReport read = ReportStore.MAPPER.readValue(json, TransferReport.class);
+        assertEquals(report.getId(), read.getId());
+        assertEquals(TransferReport.Status.FAILED, read.getStatus());
+        assertTrue(read.isRolledBack());
+        assertEquals(2, read.getTables().size());
+        assertEquals(1, read.getSkippedRows().size());
+        assertFalse(json.contains("qualifiedName"));
+    }
+
+    @Test
+    void writesAReportWithoutTablesOrTitle() throws IOException {
+        TransferReport report = new TransferReport();
+        report.setKind(TransferReport.Kind.PACKAGE);
+        report.setStatus(TransferReport.Status.COMPLETED);
+        String text = ReportExporter.export(report, ReportExporter.Format.MARKDOWN);
+        assertTrue(text.startsWith("# Transfer report\n"));
+        assertFalse(text.contains("## Options"));
+        assertFalse(text.contains("## Objects"));
+        assertFalse(text.contains("Stopped at"));
+        assertFalse(text.contains("Kept in target"));
+        assertTrue(ReportExporter.export(report, ReportExporter.Format.HTML).contains("

")); + } + + @Test + void tellsVerdictsOfTables() { + TableReport table = new TableReport(); + table.setName("t"); + table.setWritten(5); + assertEquals("", ReportExporter.verdict(table)); + table.setTargetRows(1234L); + // Rows of tables which were there before are shown, not compared + assertEquals("1,234", ReportExporter.verdict(table)); + table.setComparable(true); + assertEquals("1,234 ✗", ReportExporter.verdict(table)); + table.setTargetRows(5L); + assertEquals("5 ✓", ReportExporter.verdict(table)); + table.setStartedAt(1000); + table.setEndedAt(3000); + assertEquals(Arrays.asList("t", "Not started", "", "5", "0", "5 ✓", "2.0 s", "3"), + Arrays.asList(ReportExporter.tableRow(table))); + } + + @Test + void tellsWordsAndTimes() { + assertEquals("Completed with errors", ReportExporter.words(TransferReport.Status.COMPLETED_WITH_ERRORS)); + assertEquals("0.3 s", ReportExporter.duration(250)); + assertEquals("2 min 5 s", ReportExporter.duration(125_000)); + assertEquals("2 h 3 min", ReportExporter.duration(7_380_000)); + assertEquals("", ReportExporter.time(0)); + assertEquals(19, ReportExporter.time(1_700_000_000_000L).length()); + } +} diff --git a/src/test/java/com/github/sqljam/face/service/ReportRecorderTest.java b/src/test/java/com/github/sqljam/face/service/ReportRecorderTest.java new file mode 100644 index 0000000..ccdf08d --- /dev/null +++ b/src/test/java/com/github/sqljam/face/service/ReportRecorderTest.java @@ -0,0 +1,191 @@ +/* + * 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.SQLException; + +import org.junit.jupiter.api.Test; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.model.TransferReport.TableReport; +import com.github.sqljam.face.model.TransferReport.TableStatus; + +/** + * @Description: ReportRecorderTest verifies that the events of a transfer fill its report: rows of the tables, + * skipped rows, objects by kind, errors, and where a failed or cancelled transfer stopped + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class ReportRecorderTest { + + @Test + void recordsCompletedTables() { + ReportRecorder recorder = new ReportRecorder(new TransferReport()); + recorder.start(); + assertEquals(TransferReport.Status.RUNNING, recorder.getReport().getStatus()); + recorder.tableStarted(null, "app", "users", 3); + recorder.tableProgress(null, "app", "users", 2); + assertEquals(2, recorder.getReport().table(null, "app", "users").getWritten()); + recorder.tableEnded(null, "app", "users", 3); + recorder.finish(TransferReport.Status.COMPLETED, null, "done"); + + TransferReport report = recorder.getReport(); + TableReport users = report.getTables().get(0); + assertEquals(TableStatus.COMPLETED, users.getStatus()); + assertEquals(3, users.getSourceRows()); + assertEquals(3, users.getWritten()); + assertEquals(3, report.getWrittenRows()); + assertEquals("done", report.getSummary()); + assertNull(report.getStoppedAt()); + assertNull(report.getFailure()); + assertTrue(report.getEndedAt() >= report.getStartedAt()); + } + + @Test + void recordsSkippedRowsOfTheTableBeingWritten() { + ReportRecorder recorder = new ReportRecorder(new TransferReport()); + recorder.start(); + recorder.tableStarted("db", "app", "Orders", -1); + // The handler names the table only, in another case + recorder.rowSkipped(null, null, "orders", "id=7", new SQLException("value too long")); + recorder.rowSkipped(null, null, "orders", "id=9", new IllegalStateException()); + // Written rows at the end, without the skipped ones + recorder.tableEnded("db", "app", "Orders", 8); + recorder.finish(TransferReport.Status.COMPLETED_WITH_ERRORS, null, null); + + TransferReport report = recorder.getReport(); + assertEquals(1, report.getTables().size()); + TableReport orders = report.getTables().get(0); + assertEquals(-1, orders.getSourceRows()); + assertEquals(2, orders.getSkipped()); + assertEquals(8, orders.getWritten()); + assertEquals(TableStatus.COMPLETED_WITH_ERRORS, orders.getStatus()); + assertEquals(2, report.getSkippedRowCount()); + assertEquals("value too long", report.getSkippedRows().get(0).getError()); + assertEquals("IllegalStateException", report.getSkippedRows().get(1).getError()); + } + + @Test + void countsSkippedRowsBeyondTheListedOnes() { + ReportRecorder recorder = new ReportRecorder(new TransferReport()); + for (int i = 0; i < TransferReport.MAX_SKIPPED_ROWS + 5; i++) { + recorder.rowSkipped(null, null, "t", String.valueOf(i), null); + } + for (int i = 0; i < ReportRecorder.MAX_ERRORS + 5; i++) { + recorder.error("error " + i); + } + recorder.error(" "); + assertEquals(TransferReport.MAX_SKIPPED_ROWS, recorder.getReport().getSkippedRows().size()); + assertEquals(TransferReport.MAX_SKIPPED_ROWS + 5, recorder.getReport().getSkippedRowCount()); + assertNull(recorder.getReport().getSkippedRows().get(0).getError()); + assertEquals(ReportRecorder.MAX_ERRORS, recorder.getReport().getErrors().size()); + } + + @Test + void recordsWhereAFailedTransferStopped() { + ReportRecorder recorder = new ReportRecorder(new TransferReport()); + recorder.start(); + recorder.tableStarted(null, null, "a", 2); + recorder.tableEnded(null, null, "a", 2); + recorder.tableStarted(null, null, "b", 100); + recorder.tableProgress(null, null, "b", 40); + recorder.getReport().table(null, null, "c"); + // The message is in the cause + recorder.finish(TransferReport.Status.FAILED, new RuntimeException(new SQLException("connection lost")), + null); + + TransferReport report = recorder.getReport(); + assertEquals("b after 40 rows", report.getStoppedAt()); + assertTrue(report.getFailure().contains("connection lost")); + assertTrue(report.getFailureTrace().contains("SQLException")); + assertEquals(TableStatus.COMPLETED, report.table(null, null, "a").getStatus()); + TableReport b = report.table(null, null, "b"); + assertEquals(TableStatus.FAILED, b.getStatus()); + assertEquals(report.getFailure(), b.getError()); + assertEquals(report.getEndedAt(), b.getEndedAt()); + assertEquals(TableStatus.NOT_STARTED, report.table(null, null, "c").getStatus()); + } + + @Test + void recordsWhereACancelledTransferStopped() { + ReportRecorder recorder = new ReportRecorder(new TransferReport()); + recorder.start(); + recorder.tableStarted(null, null, "b", 100); + recorder.tableProgress(null, null, "b", 10); + recorder.finish(TransferReport.Status.CANCELLED, null, null); + + TransferReport report = recorder.getReport(); + assertEquals("b after 10 rows", report.getStoppedAt()); + assertNull(report.getFailure()); + // A cancelled table is interrupted, it did not fail + assertEquals(TableStatus.INTERRUPTED, report.getTables().get(0).getStatus()); + assertNull(report.getTables().get(0).getError()); + } + + @Test + void stopsNowhereWhenTheRowsAreKept() { + ReportRecorder recorder = new ReportRecorder(new TransferReport()); + recorder.tableStarted(null, null, "script", -1); + recorder.finish(TransferReport.Status.COMMITTED, null, null); + assertNull(recorder.getReport().getStoppedAt()); + } + + @Test + void countsStatementsByTheirObjects() { + ReportRecorder recorder = new ReportRecorder(new TransferReport()); + recorder.ddlExecuted("CREATE TABLE a (id INT)", true); + recorder.ddlExecuted("create table b (id int)", true); + recorder.ddlExecuted("CREATE INDEX i ON a (id)", true); + recorder.ddlExecuted("DROP TABLE a", true); + recorder.ddlExecuted("CREATE TABLE c (id INT)", false); + assertEquals(2, recorder.getReport().getObjects().get("Tables")); + assertEquals(1, recorder.getReport().getObjects().get("Indexes")); + assertFalse(recorder.getReport().getObjects().containsKey("Other statements")); + assertEquals(1, recorder.getReport().getFailedStatements()); + } + + @Test + void tellsKindsOfObjects() { + assertNull(ReportRecorder.kindOf(null)); + assertNull(ReportRecorder.kindOf(" ")); + assertNull(ReportRecorder.kindOf("TRUNCATE TABLE a")); + assertEquals("Foreign keys", ReportRecorder.kindOf("ALTER TABLE a ADD CONSTRAINT f FOREIGN KEY (b) " + + "REFERENCES b (id)")); + assertEquals("Primary keys", ReportRecorder.kindOf("ALTER TABLE a ADD PRIMARY KEY (id)")); + assertEquals("Tables", ReportRecorder.kindOf("CREATE TABLE a (id INT PRIMARY KEY)")); + assertEquals("Tables", ReportRecorder.kindOf("CREATE COLUMN TABLE a (id INT)")); + assertEquals("Partitions", ReportRecorder.kindOf("CREATE TABLE a_2026 PARTITION OF a FOR VALUES IN (1)")); + assertEquals("Vector indexes", ReportRecorder.kindOf("CREATE INDEX v ON a USING hnsw (embedding " + + "vector_cosine_ops)")); + assertEquals("Indexes", ReportRecorder.kindOf("CREATE UNIQUE INDEX u ON a (code)")); + assertEquals("Indexes", ReportRecorder.kindOf("ALTER TABLE a ADD INDEX i (code)")); + assertEquals("Sequences", ReportRecorder.kindOf("CREATE SEQUENCE s START WITH 1")); + assertEquals("Sequences", ReportRecorder.kindOf("ALTER SEQUENCE s RESTART WITH 5")); + assertEquals("Comments", ReportRecorder.kindOf("COMMENT ON TABLE a IS 'x'")); + assertEquals("Comments", ReportRecorder.kindOf("EXEC sp_addextendedproperty 'MS_Description', 'x'")); + assertEquals("Comments", ReportRecorder.kindOf("ALTER TABLE a COMMENT = 'x'")); + assertEquals("Schemas", ReportRecorder.kindOf("CREATE SCHEMA app")); + assertEquals("Schemas", ReportRecorder.kindOf("CREATE DATABASE app")); + assertEquals("Types", ReportRecorder.kindOf("CREATE TYPE mood AS ENUM ('a')")); + assertEquals("Types", ReportRecorder.kindOf("CREATE DOMAIN d AS INT")); + assertEquals("Other statements", ReportRecorder.kindOf("SET search_path TO app")); + } +} diff --git a/src/test/java/com/github/sqljam/face/service/ReportStoreTest.java b/src/test/java/com/github/sqljam/face/service/ReportStoreTest.java new file mode 100644 index 0000000..34a4d27 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/service/ReportStoreTest.java @@ -0,0 +1,143 @@ +/* + * 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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.github.sqljam.config.Config; +import com.github.sqljam.face.model.TransferReport; + +/** + * @Description: ReportStoreTest verifies the history of the reports: newest first, the number to keep, a report + * saved again keeps its file, files which can not be read and directories which can not be written + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class ReportStoreTest { + + @TempDir + File dir; + + private static TransferReport report(long startedAt) { + TransferReport report = new TransferReport(); + report.setStartedAt(startedAt); + report.setEndedAt(startedAt + 1500); + report.setKind(TransferReport.Kind.DATABASE); + report.setStatus(TransferReport.Status.COMPLETED); + report.table(null, "app", "users").setWritten(3); + return report; + } + + private static List ids(List reports) { + return reports.stream().map(TransferReport::getId).collect(Collectors.toList()); + } + + @Test + void listsNewestFirst() throws IOException { + ReportStore store = new ReportStore(new File(dir, "reports"), 10); + assertTrue(store.list().isEmpty()); + TransferReport first = report(1_700_000_000_000L); + TransferReport second = report(1_700_000_100_000L); + store.save(second); + store.save(first); + + List reports = store.list(); + assertEquals(List.of(second.getId(), first.getId()), ids(reports)); + assertEquals(3, reports.get(0).getWrittenRows()); + assertEquals("users", reports.get(0).getTables().get(0).getName()); + assertEquals(first.getId(), store.find(first.getId()).orElseThrow().getId()); + assertFalse(store.find("missing").isPresent()); + } + + @Test + void keepsItsFileWhenSavedAgain() throws IOException { + ReportStore store = new ReportStore(new File(dir, "reports"), 10); + TransferReport report = report(1_700_000_000_000L); + File file = store.save(report); + report.getTables().get(0).setTargetRows(3L); + assertEquals(file, store.save(report)); + assertEquals(1, store.getDirectory().listFiles().length); + assertEquals(3L, store.list().get(0).getTables().get(0).getTargetRows()); + } + + @Test + void keepsTheNewestReports() throws IOException { + ReportStore store = new ReportStore(new File(dir, "reports"), 3); + TransferReport newest = null; + for (int i = 0; i < 5; i++) { + newest = report(1_700_000_000_000L + i * 1000L); + store.save(newest); + } + List reports = store.list(); + assertEquals(3, reports.size()); + assertEquals(newest.getId(), reports.get(0).getId()); + assertEquals(1_700_000_002_000L, reports.get(2).getStartedAt()); + } + + @Test + void leavesOutFilesWhichCanNotBeRead() throws IOException { + ReportStore store = new ReportStore(new File(dir, "reports"), 10); + TransferReport report = report(1_700_000_000_000L); + store.save(report); + Files.writeString(new File(store.getDirectory(), "29991231-000000-broken.json").toPath(), "{not json", + StandardCharsets.UTF_8); + // Unknown fields of a newer version are read all the same + Files.writeString(new File(store.getDirectory(), "29991230-000000-newer.json").toPath(), + "{\"id\":\"newer\",\"status\":\"COMPLETED\",\"futureField\":1}", StandardCharsets.UTF_8); + assertEquals(List.of("newer", report.getId()), ids(store.list())); + } + + @Test + void deletesReports() throws IOException { + ReportStore store = new ReportStore(new File(dir, "reports"), 10); + TransferReport report = report(1_700_000_000_000L); + store.save(report); + assertTrue(store.delete(report.getId())); + assertFalse(store.delete(report.getId())); + assertTrue(store.list().isEmpty()); + } + + @Test + void failsWhenTheDirectoryCanNotBeWritten() throws IOException { + File file = new File(dir, "a-file"); + Files.writeString(file.toPath(), "x"); + ReportStore store = new ReportStore(file, 10); + assertThrows(IOException.class, () -> store.save(report(1L))); + assertTrue(store.list().isEmpty()); + } + + @Test + void readsItsDirectoryFromTheConfiguration() { + Config config = new Config(new File(dir, Config.FILE_NAME)); + config.set("sqljam.home", dir.getAbsolutePath()); + assertEquals(new File(dir, "reports"), new ReportStore(config).getDirectory()); + config.set("sqljam.reports.dir", new File(dir, "elsewhere").getAbsolutePath()); + assertEquals(new File(dir, "elsewhere"), new ReportStore(config).getDirectory()); + } +} diff --git a/src/test/java/com/github/sqljam/face/service/SyncSchedulerTest.java b/src/test/java/com/github/sqljam/face/service/SyncSchedulerTest.java new file mode 100644 index 0000000..60d6ee9 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/service/SyncSchedulerTest.java @@ -0,0 +1,510 @@ +/* + * 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLTransientConnectionException; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.SyncTask; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.ExportCancelledException; +import com.github.sqljam.impexp.ExportListener; + +/** + * @Description: SyncSchedulerTest runs sync tasks between two H2 databases: the first run copies all rows, later + * runs the rows after the watermark, deleted rows stay, increasing keys copy new rows, errors of the + * connection are run again and errors of the data are not, the watermark moves on after a commit + * only, and every run leaves a report. A stub engine shows cancels, closes, edits during a run and + * the times of the schedules. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class SyncSchedulerTest { + + private static final LocalDateTime BASE = LocalDateTime.of(2026, 10, 1, 8, 0); + + @TempDir + File dir; + + private final Map profiles = new HashMap<>(); + private final List sleeps = new ArrayList<>(); + private ConnectionProfile source; + private ConnectionProfile target; + private SyncTaskStore store; + private ReportStore reports; + private SyncScheduler scheduler; + + private ConnectionProfile h2(String name) { + ConnectionProfile profile = new ConnectionProfile(); + profile.setName(name); + profile.setDbType(DbType.H2); + profile.setDatabase(new File(dir, name).getAbsolutePath()); + profile.setUsername("sa"); + profile.setPassword(""); + profiles.put(profile.getId(), profile); + return profile; + } + + private static void execute(ConnectionProfile profile, String... sqls) throws SQLException { + try (Connection connection = DriverManager.getConnection(profile.getJdbcUrl(), "sa", ""); + Statement statement = connection.createStatement()) { + for (String sql : sqls) { + statement.execute(sql); + } + } + } + + private static List rows(ConnectionProfile profile, String sql) throws SQLException { + List rows = new ArrayList<>(); + try (Connection connection = DriverManager.getConnection(profile.getJdbcUrl(), "sa", ""); + Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) { + while (rs.next()) { + rows.add(rs.getString(1)); + } + } + return rows; + } + + private static String at(int minutes) { + return "TIMESTAMP '" + BASE.plusMinutes(minutes).toString().replace('T', ' ') + ":00'"; + } + + @BeforeEach + void setUp() throws Exception { + source = h2("source"); + target = h2("target"); + execute(source, "CREATE TABLE ORDERS (ID INT PRIMARY KEY, NAME VARCHAR(20), UPDATED_AT TIMESTAMP)"); + for (int i = 1; i <= 5; i++) { + execute(source, "INSERT INTO ORDERS VALUES (" + i + ", 'order " + i + "', " + at(i) + ")"); + } + store = new SyncTaskStore(new File(dir, "sync-tasks.json")); + reports = new ReportStore(new File(dir, "reports"), 50); + scheduler = new SyncScheduler(store, reports); + scheduler.register(SyncTask.Kind.DATABASE, new SyncRunner(new TransferService(), id -> Optional + .ofNullable(profiles.get(id)).map(ConnectionProfile::copy))); + scheduler.setSleeper(sleeps::add); + scheduler.setRetryDelayMillis(1000); + } + + @AfterEach + void tearDown() { + scheduler.close(); + } + + private SyncTask task(SyncTask.WatermarkType type, String column) throws Exception { + SyncTask task = new SyncTask(); + task.setName("orders"); + task.setSourceId(source.getId()); + task.setSourceSchema("PUBLIC"); + task.setTargetId(target.getId()); + task.setTargetSchema("PUBLIC"); + task.getTables().add(new SyncTask.Table("ORDERS", column, type)); + task.setScheduleType(SyncTask.ScheduleType.MANUAL); + task.setRetries(2); + store.saveTask(task); + return task; + } + + private TransferReport run(SyncTask task) throws Exception { + CompletableFuture future = scheduler.runNow(task.getId()); + assertNotNull(future); + return future.get(60, TimeUnit.SECONDS); + } + + private SyncTask saved(SyncTask task) { + return store.findTask(task.getId()).orElseThrow(); + } + + @Test + void copiesAllRowsFirstThenTheChangedOnes() throws Exception { + SyncTask task = task(SyncTask.WatermarkType.UPDATED_AT, "UPDATED_AT"); + TransferReport first = run(task); + assertEquals(TransferReport.Status.COMPLETED, first.getStatus(), first.getFailure()); + assertEquals(TransferReport.Kind.SYNC, first.getKind()); + assertEquals("All rows, the first run", first.getOptions().get("Copy")); + assertEquals(TransferReport.Transaction.ONE, first.getTransaction()); + assertEquals("1", first.getOptions().get("Attempts")); + assertEquals(5, rows(target, "SELECT COUNT(*) FROM ORDERS").get(0).length() > 0 ? Integer.parseInt(rows( + target, "SELECT COUNT(*) FROM ORDERS").get(0)) : -1); + // The target table was created with its rows, counted afterwards + assertEquals(5L, first.getTables().get(0).getTargetRows()); + SyncTask afterFirst = saved(task); + assertEquals(SyncRunner.TIMESTAMP, afterFirst.getTables().get(0).getWatermark().getType()); + assertEquals(BASE.plusMinutes(5), LocalDateTime.parse(afterFirst.getTables().get(0).getWatermark() + .getValue())); + assertEquals(TransferReport.Status.COMPLETED, afterFirst.getLastStatus()); + assertEquals(first.getId(), afterFirst.getLastReportId()); + assertTrue(afterFirst.isInitialized()); + + // Two rows change, one is new, one is deleted + execute(source, "UPDATE ORDERS SET NAME = 'changed 2', UPDATED_AT = " + at(10) + " WHERE ID = 2", + "UPDATE ORDERS SET NAME = 'changed 3', UPDATED_AT = " + at(11) + " WHERE ID = 3", + "INSERT INTO ORDERS VALUES (6, 'order 6', " + at(12) + ")", "DELETE FROM ORDERS WHERE ID = 1"); + TransferReport second = run(task); + assertEquals(TransferReport.Status.COMPLETED, second.getStatus(), second.getFailure()); + assertEquals("Rows changed since the last run", second.getOptions().get("Copy")); + // The rows of the last watermark are read again: row 5, and the changed and new rows + assertEquals(4, second.getWrittenRows()); + assertEquals(List.of("order 1", "changed 2", "changed 3", "order 4", "order 5", "order 6"), rows(target, + "SELECT NAME FROM ORDERS ORDER BY ID")); + assertEquals(BASE.plusMinutes(12), LocalDateTime.parse(saved(task).getTables().get(0).getWatermark() + .getValue())); + + // Nothing changed: only the rows of the watermark again + TransferReport third = run(task); + assertEquals(1, third.getWrittenRows()); + assertEquals(3, reports.list().size()); + } + + @Test + void copiesNewRowsOfIncreasingKeys() throws Exception { + SyncTask task = task(SyncTask.WatermarkType.INCREASING_KEY, "ID"); + run(task); + assertEquals("5", saved(task).getTables().get(0).getWatermark().getValue()); + execute(source, "UPDATE ORDERS SET NAME = 'not copied' WHERE ID = 2", "INSERT INTO ORDERS VALUES (7, " + + "'order 7', " + at(20) + ")"); + TransferReport report = run(task); + assertEquals(1, report.getWrittenRows()); + assertEquals(List.of("order 2", "order 7"), rows(target, "SELECT NAME FROM ORDERS WHERE ID IN (2, 7) " + + "ORDER BY ID")); + assertEquals("7", saved(task).getTables().get(0).getWatermark().getValue()); + } + + @Test + void copiesNothingOfEmptyTables() throws Exception { + execute(source, "DELETE FROM ORDERS"); + SyncTask task = task(SyncTask.WatermarkType.UPDATED_AT, "UPDATED_AT"); + TransferReport report = run(task); + assertEquals(TransferReport.Status.COMPLETED, report.getStatus(), report.getFailure()); + assertNull(saved(task).getTables().get(0).getWatermark()); + assertFalse(saved(task).isInitialized()); + assertEquals("UPDATED_AT from the first row to no rows", report.getOptions().get("Watermark of ORDERS")); + } + + @Test + void runsAgainAfterErrorsOfTheConnection() throws Exception { + SyncTask task = task(SyncTask.WatermarkType.UPDATED_AT, "UPDATED_AT"); + // A server which is not there + target.setDatabase(null); + target.setUrl("jdbc:h2:tcp://127.0.0.1:1/nowhere"); + TransferReport report = run(task); + assertEquals(TransferReport.Status.FAILED, report.getStatus()); + assertEquals("3", report.getOptions().get("Attempts")); + // The delay doubles + assertEquals(List.of(1000L, 2000L), sleeps); + assertTrue(report.getErrors().get(0).startsWith("Attempt 1 failed: "), report.getErrors().get(0)); + assertNull(saved(task).getTables().get(0).getWatermark()); + assertEquals(TransferReport.Status.FAILED, saved(task).getLastStatus()); + } + + @Test + void runsNotAgainAfterErrorsOfTheData() throws Exception { + execute(target, "CREATE TABLE ORDERS (ID INT PRIMARY KEY, NAME VARCHAR(20), UPDATED_AT TIMESTAMP, " + + "CONSTRAINT NO_FOUR CHECK (ID <> 4))"); + SyncTask task = task(SyncTask.WatermarkType.UPDATED_AT, "UPDATED_AT"); + TransferReport report = run(task); + assertEquals(TransferReport.Status.FAILED, report.getStatus()); + assertEquals("1", report.getOptions().get("Attempts")); + assertTrue(sleeps.isEmpty()); + assertTrue(report.isRolledBack()); + assertTrue(report.getFailure().contains("NO_FOUR"), report.getFailure()); + // Rolled back: nothing is kept, the watermark stays, the rows are counted + assertEquals(List.of("0"), rows(target, "SELECT COUNT(*) FROM ORDERS")); + assertEquals(0L, report.getTables().get(0).getTargetRows()); + assertNull(saved(task).getTables().get(0).getWatermark()); + + // Fixed: the next run copies all rows + execute(target, "ALTER TABLE ORDERS DROP CONSTRAINT NO_FOUR"); + assertEquals(TransferReport.Status.COMPLETED, run(task).getStatus()); + assertEquals(List.of("5"), rows(target, "SELECT COUNT(*) FROM ORDERS")); + } + + @Test + void failsWithoutPasswordsOrDataSources() throws Exception { + SyncTask task = task(SyncTask.WatermarkType.UPDATED_AT, "UPDATED_AT"); + target.setPassword(null); + TransferReport report = run(task); + assertEquals(TransferReport.Status.FAILED, report.getStatus()); + assertEquals("1", report.getOptions().get("Attempts")); + assertTrue(report.getFailure().contains("password of target is not remembered"), report.getFailure()); + profiles.remove(target.getId()); + assertTrue(run(task).getFailure().contains("target data source of the task was removed")); + task.setKind(SyncTask.Kind.VECTOR); + store.saveTask(task); + assertTrue(run(task).getFailure().contains("can not run here")); + } + + /** + * An engine whose runs are told by the test + */ + private static final class StubEngine implements SyncEngine { + + final AtomicInteger runs = new AtomicInteger(); + final CountDownLatch started = new CountDownLatch(1); + volatile CountDownLatch release = new CountDownLatch(0); + volatile Exception firstFailure; + volatile boolean waitForCancel; + + @Override + public void describe(SyncTask task, TransferReport report) { + report.setKind(TransferReport.Kind.SYNC); + report.setTitle("Sync " + task.getName()); + } + + @Override + public Map run(SyncTask task, TransferReport report, ExportListener listener) + throws Exception { + int run = runs.incrementAndGet(); + started.countDown(); + if (run == 1 && firstFailure != null) { + listener.onError("Row of the failed attempt", null); + throw firstFailure; + } + if (waitForCancel) { + while (!listener.isCancelled()) { + Thread.sleep(10); + } + throw new ExportCancelledException(); + } + release.await(30, TimeUnit.SECONDS); + listener.onTableStart(null, null, "T", 2); + listener.onTableEnd(null, null, "T", 2); + return Map.of("T", new SyncTask.Watermark(SyncRunner.NUMBER, String.valueOf(run))); + } + } + + private SyncTask stubTask(StubEngine engine) throws Exception { + scheduler.register(SyncTask.Kind.DATABASE, engine); + SyncTask task = new SyncTask(); + task.setName("stub"); + task.getTables().add(new SyncTask.Table("T", "ID", SyncTask.WatermarkType.INCREASING_KEY)); + task.setScheduleType(SyncTask.ScheduleType.MANUAL); + store.saveTask(task); + return task; + } + + @Test + void completesAfterAnAttemptWhichFailed() throws Exception { + StubEngine engine = new StubEngine(); + engine.firstFailure = new SQLTransientConnectionException("connection reset", "08006"); + SyncTask task = stubTask(engine); + TransferReport report = run(task); + assertEquals(TransferReport.Status.COMPLETED, report.getStatus()); + assertEquals("2", report.getOptions().get("Attempts")); + assertEquals(2, report.getTables().get(0).getWritten()); + assertEquals(1, report.getTables().size()); + assertTrue(report.getErrors().stream().anyMatch(error -> error.startsWith("Attempt 1 failed: connection " + + "reset"))); + assertEquals("2", saved(task).getTables().get(0).getWatermark().getValue()); + } + + @Test + void runsATaskOnceAtATime() throws Exception { + StubEngine engine = new StubEngine(); + engine.release = new CountDownLatch(1); + SyncTask task = stubTask(engine); + CompletableFuture first = scheduler.runNow(task.getId()); + assertTrue(engine.started.await(30, TimeUnit.SECONDS)); + assertTrue(scheduler.isRunning(task.getId())); + assertNull(scheduler.runNow(task.getId())); + // The user saves the task meanwhile: the run keeps the change and adds its watermark + SyncTask edited = new com.fasterxml.jackson.databind.ObjectMapper().readValue( + new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(task), SyncTask.class); + edited.setName("renamed"); + store.saveTask(edited); + engine.release.countDown(); + first.get(30, TimeUnit.SECONDS); + assertFalse(scheduler.isRunning(task.getId())); + assertEquals("renamed", saved(task).getName()); + assertEquals("1", saved(task).getTables().get(0).getWatermark().getValue()); + assertEquals(1, engine.runs.get()); + } + + @Test + void cancelsARun() throws Exception { + StubEngine engine = new StubEngine(); + engine.waitForCancel = true; + SyncTask task = stubTask(engine); + List changes = new ArrayList<>(); + scheduler.addListener(changed -> changes.add(changed.getName())); + CompletableFuture future = scheduler.runNow(task.getId()); + assertTrue(engine.started.await(30, TimeUnit.SECONDS)); + scheduler.cancel(task.getId()); + TransferReport report = future.get(30, TimeUnit.SECONDS); + assertEquals(TransferReport.Status.CANCELLED, report.getStatus()); + assertNull(saved(task).getTables().get(0).getWatermark()); + assertEquals(TransferReport.Status.CANCELLED, saved(task).getLastStatus()); + // Started and ended + assertEquals(List.of("stub", "stub"), changes); + } + + @Test + void cancelsRunsWhenItCloses() throws Exception { + StubEngine engine = new StubEngine(); + engine.waitForCancel = true; + SyncTask task = stubTask(engine); + CompletableFuture future = scheduler.runNow(task.getId()); + assertTrue(engine.started.await(30, TimeUnit.SECONDS)); + scheduler.close(); + assertEquals(TransferReport.Status.CANCELLED, future.get(30, TimeUnit.SECONDS).getStatus()); + // The report is saved, a closed scheduler runs nothing + assertEquals(1, reports.list().size()); + assertNull(scheduler.runNow(task.getId())); + } + + @Test + void ticksTasksWhichAreDue() throws Exception { + StubEngine engine = new StubEngine(); + SyncTask task = stubTask(engine); + task.setScheduleType(SyncTask.ScheduleType.INTERVAL); + task.setIntervalMinutes(10); + task.setCreatedAt(1_000_000L); + store.saveTask(task); + scheduler.setClock(() -> 1_000_000L + 9 * 60_000L); + scheduler.tick(); + assertEquals(0, engine.runs.get()); + scheduler.setClock(() -> 1_000_000L + 10 * 60_000L); + scheduler.tick(); + assertTrue(engine.started.await(30, TimeUnit.SECONDS)); + } + + @Test + void tellsTheNextRuns() { + ZoneId zone = ZoneId.of("Asia/Shanghai"); + scheduler.setZone(zone); + long ten = ZonedDateTime.of(2026, 10, 25, 10, 0, 0, 0, zone).toInstant().toEpochMilli(); + long minute = 60_000L; + SyncTask task = new SyncTask(); + task.setCreatedAt(ten - 30 * minute); + task.setScheduleType(SyncTask.ScheduleType.INTERVAL); + task.setIntervalMinutes(60); + assertEquals(ten + 30 * minute, scheduler.nextRunAt(task, ten)); + task.setCreatedAt(ten - 10 * 60 * minute); + task.setLastRunAt(ten - 5 * 60 * minute); + // Overdue: due at once + assertTrue(scheduler.nextRunAt(task, ten) <= ten); + task.setIntervalMinutes(0); + assertEquals(task.getLastRunAt() + minute, scheduler.nextRunAt(task, ten)); + + task.setScheduleType(SyncTask.ScheduleType.DAILY); + task.setDailyTime("02:00"); + task.setCreatedAt(ten - 30 * minute); + task.setLastRunAt(0); + // Created today after 02:00: tomorrow at 02:00 + assertEquals(ZonedDateTime.of(2026, 10, 26, 2, 0, 0, 0, zone).toInstant().toEpochMilli(), + scheduler.nextRunAt(task, ten)); + // Last run the day before yesterday: the run of today at 02:00 was missed, it is due + task.setCreatedAt(ten - 30 * 24 * 60 * minute); + task.setLastRunAt(ten - 2 * 24 * 60 * minute); + assertEquals(ZonedDateTime.of(2026, 10, 25, 2, 0, 0, 0, zone).toInstant().toEpochMilli(), + scheduler.nextRunAt(task, ten)); + task.setDailyTime("23:30"); + task.setLastRunAt(ZonedDateTime.of(2026, 10, 24, 23, 30, 5, 0, zone).toInstant().toEpochMilli()); + assertEquals(ZonedDateTime.of(2026, 10, 25, 23, 30, 0, 0, zone).toInstant().toEpochMilli(), + scheduler.nextRunAt(task, ten)); + task.setDailyTime("not a time"); + assertEquals(java.time.LocalTime.of(2, 0), SyncScheduler.dailyTime(task)); + + task.setScheduleType(SyncTask.ScheduleType.MANUAL); + assertEquals(-1, scheduler.nextRunAt(task, ten)); + task.setScheduleType(SyncTask.ScheduleType.INTERVAL); + task.setEnabled(false); + assertEquals(-1, scheduler.nextRunAt(task, ten)); + } + + @Test + void tellsErrorsWhichPass() { + assertTrue(SyncErrors.isRetryable(new SQLException("lost", "08S01"))); + assertTrue(SyncErrors.isRetryable(new SQLException("deadlock", "40001"))); + assertTrue(SyncErrors.isRetryable(new SQLException("broken", "90067"))); + assertTrue(SyncErrors.isRetryable(new SQLTransientConnectionException("reset"))); + assertTrue(SyncErrors.isRetryable(new RuntimeException(new java.net.SocketTimeoutException("timed out")))); + assertFalse(SyncErrors.isRetryable(new SQLException("duplicate key", "23505"))); + assertFalse(SyncErrors.isRetryable(new SQLException("too long", "22001"))); + assertFalse(SyncErrors.isRetryable(new SQLException("no table", "42S02"))); + assertFalse(SyncErrors.isRetryable(new SQLException("login failed", "28000"))); + assertFalse(SyncErrors.isRetryable(new SyncErrors.PermanentException("no password"))); + assertFalse(SyncErrors.isRetryable(new IllegalArgumentException("bad"))); + } + + @Test + void keepsWatermarksOfEveryType() { + for (Object value : new Object[]{42, 42L, new BigDecimal("12.50"), 1.5d, new java.math.BigInteger("7"), + java.sql.Timestamp.valueOf("2026-10-25 10:00:00.123456"), LocalDateTime.of(2026, 1, 2, 3, 4), + OffsetDateTime.parse("2026-10-25T10:00+08:00"), ZonedDateTime.parse("2026-10-25T10:00+08:00[Asia/" + + "Shanghai]"), java.time.Instant.parse("2026-10-25T02:00:00Z"), java.sql.Date.valueOf( + "2026-10-25"), java.time.LocalDate.of(2026, 10, 25), "b-17", java.util.UUID.fromString( + "123e4567-e89b-12d3-a456-426614174000")}) { + SyncTask.Watermark watermark = SyncRunner.watermarkOf(value); + Object again = SyncRunner.valueOf(watermark); + assertEquals(watermark.getValue(), SyncRunner.watermarkOf(again).getValue(), String.valueOf(value)); + } + assertNull(SyncRunner.watermarkOf(null)); + assertEquals("2026-10-25T10:00:00.123456", SyncRunner.watermarkOf(java.sql.Timestamp.valueOf( + "2026-10-25 10:00:00.123456")).getValue()); + } + + @Test + void keepsTasksOfAFileWhichCanNotBeRead() throws Exception { + File file = new File(dir, "broken/sync-tasks.json"); + Files.createDirectories(file.getParentFile().toPath()); + Files.writeString(file.toPath(), "[{not json"); + SyncTaskStore broken = new SyncTaskStore(file); + assertTrue(broken.getTasks().isEmpty()); + assertEquals(1, file.getParentFile().listFiles((parent, name) -> name.startsWith("sync-tasks.json.broken-")) + .length); + SyncTask task = new SyncTask(); + task.setName("new"); + broken.saveTask(task); + assertEquals("new", new SyncTaskStore(file).getTasks().get(0).getName()); + assertFalse(broken.updateTask("missing", changed -> changed.setName("x")).isPresent()); + broken.removeTask(task.getId()); + assertTrue(new SyncTaskStore(file).getTasks().isEmpty()); + } +} diff --git a/src/test/java/com/github/sqljam/face/service/SystemSchedulerTest.java b/src/test/java/com/github/sqljam/face/service/SystemSchedulerTest.java new file mode 100644 index 0000000..00ffbee --- /dev/null +++ b/src/test/java/com/github/sqljam/face/service/SystemSchedulerTest.java @@ -0,0 +1,177 @@ +/* + * 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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import com.github.sqljam.face.model.SyncTask; + +/** + * @Description: SystemSchedulerTest registers sync tasks with a crontab and a Task Scheduler which are recorded: + * the lines of SqlJam are marked, the other lines are kept, a task is replaced and removed, schedules + * which cron can not tell wake every 5 minutes and run when due, and arguments are quoted + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class SystemSchedulerTest { + + /** + * A crontab in memory, and the commands which were run + */ + private final List> commands = new ArrayList<>(); + private String crontab; + + private SystemScheduler scheduler(String os, List launcher) { + return new SystemScheduler(os, launcher, new File("/home/u/.sqljam/sync.log"), (command, input) -> { + commands.add(command); + if (command.equals(List.of("crontab", "-l"))) { + return crontab == null ? new SystemScheduler.Result(1, "no crontab for u") + : new SystemScheduler.Result(0, crontab); + } + if (command.equals(List.of("crontab", "-"))) { + crontab = input; + } + return new SystemScheduler.Result(0, ""); + }); + } + + private static SyncTask task(String id, SyncTask.ScheduleType type, int minutes) { + SyncTask task = new SyncTask(); + task.setId(id); + task.setName(id); + task.setScheduleType(type); + task.setIntervalMinutes(minutes); + task.setRunner(SyncTask.Runner.SYSTEM); + return task; + } + + @Test + void registersWithTheCrontab() throws IOException { + crontab = "0 3 * * * /usr/bin/backup\n"; + SystemScheduler scheduler = scheduler("Mac OS X", List.of("/usr/bin/java", "-jar", "/opt/Sql Jam/sqljam.jar")); + assertTrue(scheduler.isAvailable()); + SyncTask task = task("a1", SyncTask.ScheduleType.INTERVAL, 15); + scheduler.apply(task); + assertEquals("0 3 * * * /usr/bin/backup\n*/15 * * * * /usr/bin/java -jar '/opt/Sql Jam/sqljam.jar' sync " + + "--task a1 >> /home/u/.sqljam/sync.log 2>&1 # sqljam-sync a1\n", crontab); + assertTrue(scheduler.isRegistered("a1")); + // Registered again: replaced, not added + task.setScheduleType(SyncTask.ScheduleType.DAILY); + task.setDailyTime("02:30"); + scheduler.apply(task); + assertEquals(2, crontab.split("\n").length); + assertTrue(crontab.contains("30 2 * * * /usr/bin/java")); + // Switched off: removed, the other line stays + task.setEnabled(false); + scheduler.apply(task); + assertEquals("0 3 * * * /usr/bin/backup\n", crontab); + assertFalse(scheduler.isRegistered("a1")); + // Nothing to remove: the crontab is not written + int written = commands.size(); + scheduler.unregister("a1"); + assertEquals(written + 1, commands.size()); + } + + @Test + void createsTheFirstCrontab() throws IOException { + SystemScheduler scheduler = scheduler("Linux", List.of("/opt/sqljam/bin/SqlJam")); + scheduler.register(task("b2", SyncTask.ScheduleType.INTERVAL, 90)); + // Every 90 minutes is no cron time: it wakes every 5 minutes and runs when due + assertEquals("*/5 * * * * /opt/sqljam/bin/SqlJam sync --task b2 --if-due >> /home/u/.sqljam/sync.log 2>&1 " + + "# sqljam-sync b2\n", crontab); + scheduler.unregister("b2"); + assertEquals("", crontab); + } + + @Test + void tellsCronTimes() { + SystemScheduler scheduler = scheduler("Linux", List.of("sqljam")); + assertEquals("* * * * *", scheduler.cron(task("t", SyncTask.ScheduleType.INTERVAL, 1))); + assertEquals("*/20 * * * *", scheduler.cron(task("t", SyncTask.ScheduleType.INTERVAL, 20))); + assertEquals("0 * * * *", scheduler.cron(task("t", SyncTask.ScheduleType.INTERVAL, 60))); + assertEquals("0 */6 * * *", scheduler.cron(task("t", SyncTask.ScheduleType.INTERVAL, 360))); + assertEquals("0 0 * * *", scheduler.cron(task("t", SyncTask.ScheduleType.INTERVAL, 1440))); + assertEquals("*/5 * * * *", scheduler.cron(task("t", SyncTask.ScheduleType.INTERVAL, 7))); + assertFalse(scheduler.isExact(task("t", SyncTask.ScheduleType.INTERVAL, 7))); + assertFalse(scheduler.isExact(task("t", SyncTask.ScheduleType.INTERVAL, 300))); + SyncTask daily = task("t", SyncTask.ScheduleType.DAILY, 0); + daily.setDailyTime("23:05"); + assertEquals("5 23 * * *", scheduler.cron(daily)); + } + + @Test + void registersWithTheTaskSchedulerOfWindows() throws IOException { + SystemScheduler scheduler = scheduler("Windows 11", List.of("C:\\Program Files\\SqlJam\\SqlJam.exe")); + scheduler.apply(task("c3", SyncTask.ScheduleType.INTERVAL, 90)); + assertEquals(List.of("schtasks", "/Create", "/F", "/TN", "SqlJam\\sync-c3", "/TR", + "\"C:\\Program Files\\SqlJam\\SqlJam.exe\" sync --task c3", "/SC", "MINUTE", "/MO", "90"), commands + .get(0)); + SyncTask daily = task("c3", SyncTask.ScheduleType.DAILY, 0); + daily.setDailyTime("02:00"); + scheduler.apply(daily); + assertEquals(List.of("/SC", "DAILY", "/ST", "02:00"), commands.get(1).subList(7, 11)); + scheduler.apply(task("c3", SyncTask.ScheduleType.INTERVAL, 1440)); + assertEquals(List.of("/SC", "DAILY", "/ST", "00:00"), commands.get(2).subList(7, 11)); + scheduler.apply(task("c3", SyncTask.ScheduleType.INTERVAL, 3000)); + assertTrue(commands.get(3).get(6).endsWith("--if-due")); + assertEquals(List.of("/SC", "MINUTE", "/MO", "5"), commands.get(3).subList(7, 11)); + SyncTask manual = task("c3", SyncTask.ScheduleType.MANUAL, 0); + scheduler.apply(manual); + assertEquals(List.of("schtasks", "/Delete", "/F", "/TN", "SqlJam\\sync-c3"), commands.get(4)); + assertTrue(scheduler.isRegistered("c3")); + } + + @Test + void failsWithoutALauncherOrWhenTheSystemRefuses() { + SystemScheduler unavailable = scheduler("Linux", List.of()); + assertFalse(unavailable.isAvailable()); + IOException e = assertThrows(IOException.class, () -> unavailable.register(task("d4", SyncTask.ScheduleType + .INTERVAL, 5))); + assertTrue(e.getMessage().contains("sqljam sync --task d4")); + SystemScheduler refused = new SystemScheduler("Linux", List.of("sqljam"), new File("sync.log"), + (command, input) -> new SystemScheduler.Result(command.contains("-l") ? 0 : 1, "permission denied")); + assertTrue(assertThrows(IOException.class, () -> refused.register(task("d4", SyncTask.ScheduleType.INTERVAL, + 5))).getMessage().contains("permission denied")); + } + + @Test + void quotesArguments() { + assertEquals("/usr/bin/java", SystemScheduler.shellQuote("/usr/bin/java")); + assertEquals("'a b'", SystemScheduler.shellQuote("a b")); + assertEquals("'it'\\''s'", SystemScheduler.shellQuote("it's")); + assertEquals("C:\\SqlJam\\sqljam.exe", SystemScheduler.windowsQuote("C:\\SqlJam\\sqljam.exe")); + assertEquals("\"a \\\"b\\\"\"", SystemScheduler.windowsQuote("a \"b\"")); + assertEquals("SqlJam\\sync-x", SystemScheduler.taskName("x")); + } + + @Test + void runsCommandsOfTheSystem() throws IOException { + SystemScheduler.Result result = SystemScheduler.execute(List.of("sh", "-c", "cat; echo done"), "input "); + assertEquals(0, result.getExitCode()); + assertEquals("input done", result.getOutput().trim()); + assertTrue(SystemScheduler.launcher() != null); + } +} diff --git a/src/test/java/com/github/sqljam/face/service/TransferServiceTest.java b/src/test/java/com/github/sqljam/face/service/TransferServiceTest.java index 524a239..14bf38a 100644 --- a/src/test/java/com/github/sqljam/face/service/TransferServiceTest.java +++ b/src/test/java/com/github/sqljam/face/service/TransferServiceTest.java @@ -17,6 +17,7 @@ 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -31,6 +32,7 @@ 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.face.model.TransferReport; import com.github.sqljam.face.view.UiDatabase; import com.github.sqljam.impexp.CsvImporter; import com.github.sqljam.impexp.DataFileImporter; @@ -134,6 +136,77 @@ void importsDirectlyInTransaction() throws Exception { assertEquals(5, count(target, "T_ONE")); } + /** + * The report of a direct import: its header, the tables with their rows, the objects, and the rows of the + * target which are compared with the written rows of tables which the import created + */ + @Test + void reportsDirectImport() throws Exception { + ConnectionProfile source = UiDatabase.create(dir, "source"); + ConnectionProfile target = target("reported"); + TransferRequest request = new TransferRequest(); + request.setSource(source); + request.setSourceCatalog(UiDatabase.CATALOG); + request.setSourceSchema(UiDatabase.SCHEMA); + request.setTarget(TransferRequest.Target.DATABASE); + request.setTargetProfile(target); + request.setTableRecreated(true); + request.setTables(List.of("T_PAGED", "T_ONE")); + TransferReport report = new TransferReport(); + TransferService.describeReport(request, report); + assertEquals(TransferReport.Kind.DATABASE, report.getKind()); + assertEquals("source / " + UiDatabase.CATALOG + " / " + UiDatabase.SCHEMA, report.getSource()); + assertEquals("reported", report.getTarget()); + assertEquals("T_PAGED, T_ONE", report.getOptions().get("Tables")); + assertEquals("true", report.getOptions().get("Tables recreated")); + + ReportRecorder recorder = new ReportRecorder(report); + recorder.start(); + service.transfer(request, new ExportListener() { + @Override + public void onTableStart(String catalogName, String schemaName, String tableName, long totalRows) { + recorder.tableStarted(catalogName, schemaName, tableName, totalRows); + } + + @Override + public void onTableEnd(String catalogName, String schemaName, String tableName, long processedRows) { + recorder.tableEnded(catalogName, schemaName, tableName, processedRows); + } + + @Override + public void onDdlExecuted(String statement, boolean successful) { + recorder.ddlExecuted(statement, successful); + } + }); + recorder.finish(TransferReport.Status.COMPLETED, null, null); + // A table which was not reached is not counted + report.table(null, null, "NEVER"); + service.verifyReport(request, report); + + assertEquals(2, report.getObjects().get("Tables")); + TransferReport.TableReport paged = report.getTables().stream().filter(table -> "T_PAGED".equalsIgnoreCase( + table.getName())).findFirst().orElseThrow(); + assertEquals(UiDatabase.PAGED_ROWS, paged.getWritten()); + assertEquals(UiDatabase.PAGED_ROWS, paged.getTargetRows()); + assertTrue(paged.getVerified()); + assertNull(report.table(null, null, "NEVER").getTargetRows()); + + // Rows of tables which were there before are counted, not compared + request.setTableRecreated(false); + service.verifyReport(request, report); + assertNull(paged.getVerified()); + // Nothing is counted in a package + TransferReport packageReport = new TransferReport(); + packageReport.table(null, null, "T_PAGED").setStatus(TransferReport.TableStatus.COMPLETED); + request.setTarget(TransferRequest.Target.SCRIPT); + request.setOutputDirectory(new File(dir, "package")); + service.verifyReport(request, packageReport); + assertNull(packageReport.getTables().get(0).getTargetRows()); + TransferService.describeReport(request, packageReport); + assertEquals(TransferReport.Kind.PACKAGE, packageReport.getKind()); + assertEquals(TransferReport.Transaction.NONE, packageReport.getTransaction()); + } + @Test void importsPackageAndFilesInTransaction() throws Exception { File packageDir = exportCsv(UiDatabase.create(dir, "source")); @@ -173,4 +246,42 @@ void importsPackageAndFilesInTransaction() throws Exception { DataFileImporter.Mode.CREATE, ExportListener.NONE).getImportedRows()); assertEquals(UiDatabase.PAGED_ROWS, count(target, "PAGED_AUTO")); } + + /** + * Statements of the structure and skipped rows of package and file imports reach the listener of the user + * interface through the listeners of the importers, so that their reports hold them + */ + @Test + void reportsStatementsOfImports() throws Exception { + File packageDir = exportCsv(UiDatabase.create(dir, "source")); + TransferReport report = new TransferReport(); + ReportRecorder recorder = new ReportRecorder(report); + ExportListener listener = new ExportListener() { + @Override + public void onRowSkipped(String catalogName, String schemaName, String tableName, String key, + Throwable e) { + recorder.rowSkipped(catalogName, schemaName, tableName, key, e); + } + + @Override + public void onDdlExecuted(String statement, boolean successful) { + recorder.ddlExecuted(statement, successful); + } + }; + service.importScripts(target("statements"), null, null, packageDir, false, 0, null, listener); + assertTrue(report.getObjects().getOrDefault("Tables", 0) >= 2, report.getObjects().toString()); + + // A file whose rows do not fit the table: the rows are skipped one by one + ConnectionProfile target = target("skipped"); + try (Connection connection = DriverManager.getConnection(target.getJdbcUrl(), "sa", ""); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE NARROW (ID INT PRIMARY KEY, NAME VARCHAR(1))"); + } + File csv = new File(dir, "narrow.csv"); + java.nio.file.Files.writeString(csv.toPath(), "ID,NAME\n1,a\n2,too long\n3,c\n"); + service.importFiles(target, null, null, List.of(csv), "NARROW", DataFileImporter.Mode.APPEND, List.of(), + null, true, 0, null, listener); + assertEquals(1, report.getSkippedRowCount(), report.getSkippedRows().toString()); + assertEquals(2, count(target, "NARROW")); + } } diff --git a/src/test/java/com/github/sqljam/face/vdb/DataSourceKindsTest.java b/src/test/java/com/github/sqljam/face/vdb/DataSourceKindsTest.java new file mode 100644 index 0000000..5c2b560 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/vdb/DataSourceKindsTest.java @@ -0,0 +1,413 @@ +/* + * 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.vdb; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.util.List; +import java.util.Optional; +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 org.testfx.util.WaitForAsyncUtils; +import com.github.sqljam.utils.RestException; +import com.github.sqljam.config.Config; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.view.AppContext; +import com.github.sqljam.face.view.ConnectionDialog; +import com.github.sqljam.face.view.ConnectionTree; +import com.github.sqljam.face.view.DataSourceExtension; +import com.github.sqljam.face.view.DbIcons; +import com.github.sqljam.face.view.DbNode; +import com.github.sqljam.face.view.FxTestSupport; +import com.github.sqljam.impexp.DbCategory; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.vdb.VdbType; +import javafx.application.Platform; +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.ContextMenu; +import javafx.scene.control.DialogPane; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.control.MenuItem; +import javafx.scene.control.TextField; +import javafx.scene.control.TreeCell; +import javafx.scene.control.TreeItem; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; +import javafx.stage.Window; + +/** + * @Description: DataSourceKindsTest verifies the three kinds of data sources of the user interface: the category + * of the data source form (relational, OLAP or vector databases) chooses its types and fields, the connection tree groups the data sources by + * their kinds with colored icons, and the vector databases of the tree are opened, edited and deleted + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class DataSourceKindsTest { + + @TempDir + File dir; + + private AppContext context; + private Stage stage; + private VdbProfileStore store; + private VdbDataSourceExtension extension; + private ConnectionTree tree; + + @Start + void start(Stage stage) throws Exception { + this.stage = stage; + context = FxTestSupport.createContext(dir); + ConnectionProfile h2 = new ConnectionProfile(); + h2.setName("Shop H2"); + h2.setDbType(DbType.H2); + h2.setDatabase(new File(dir, "h2/db").getAbsolutePath()); + h2.setUsername("sa"); + h2.setPassword(""); + context.getProfileRegistry().saveProfile(h2, true); + ConnectionProfile duck = new ConnectionProfile(); + duck.setName("Lake DuckDB"); + duck.setDbType(DbType.DUCKDB); + duck.setDatabase(new File(dir, "lake.duckdb").getAbsolutePath()); + context.getProfileRegistry().saveProfile(duck, true); + store = new VdbProfileStore(new File(dir, VdbProfileStore.FILE_NAME)); + VdbProfile qdrant = new VdbProfile(); + qdrant.setName("Docs Qdrant"); + qdrant.setType(VdbType.QDRANT); + qdrant.setUrl("http://127.0.0.1:1"); + qdrant.setApiKey("key"); + store.saveProfile(qdrant, true); + extension = new VdbDataSourceExtension(store); + tree = new ConnectionTree(context, List.of(extension)); + stage.setScene(new Scene(new StackPane(tree), 500, 600)); + stage.show(); + } + + @AfterEach + void close() { + FxTestSupport.closeAllDialogs(); + context.getSessionManager().closeAll(); + } + + private TreeItem group(String label) { + return FxTestSupport.call(() -> tree.getRoot().getChildren().stream().filter(item -> item.getValue() + .getLabel().equals(label)).findFirst().orElseThrow()); + } + + private static List labels(TreeItem item) { + return FxTestSupport.call(() -> item.getChildren().stream().map(child -> child.getValue().getLabel()) + .collect(Collectors.toList())); + } + + 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 Node graphic(TreeItem item) { + return FxTestSupport.call(() -> { + tree.applyCss(); + tree.layout(); + return tree.lookupAll(".tree-cell").stream().map(node -> (TreeCell) node) + .filter(cell -> cell.getTreeItem() == item).map(TreeCell::getGraphic).findFirst().orElse(null); + }); + } + + private static void fire(ContextMenu menu, String text) { + MenuItem item = FxTestSupport.call(() -> menu.getItems().stream().filter(candidate -> text.equals( + candidate.getText())).findFirst().orElseThrow()); + Platform.runLater(item::fire); + WaitForAsyncUtils.waitForFxEvents(); + } + + private DialogPane waitForDialog(String id) { + FxTestSupport.waitUntil(() -> dialogs().stream().anyMatch(pane -> pane.lookup("#" + id) != null)); + return dialogs().stream().filter(pane -> pane.lookup("#" + id) != null).findFirst().orElseThrow(); + } + + private static List dialogs() { + return FxTestSupport.getDialogPanes(); + } + + private static void press(DialogPane pane, ButtonType type) { + Platform.runLater(() -> Optional.ofNullable((Button) pane.lookupButton(type)).ifPresent(Button::fire)); + WaitForAsyncUtils.waitForFxEvents(); + } + + @Test + void groupsDataSourcesByKind() { + assertEquals(List.of("Relational databases", "OLAP databases", "Vector databases"), FxTestSupport.call( + () -> tree.getRoot().getChildren().stream().map(item -> item.getValue().getLabel()) + .collect(Collectors.toList()))); + assertEquals(List.of("Shop H2"), labels(group("Relational databases"))); + assertEquals(List.of("Lake DuckDB"), labels(group("OLAP databases"))); + assertEquals(List.of("Docs Qdrant"), labels(group("Vector databases"))); + assertEquals(2, FxTestSupport.call(() -> tree.getConnectionItems().size())); + assertEquals(List.of(extension), tree.getExtensions()); + + // Colored icons of the groups and of the databases + assertTrue(graphic(group("Vector databases")).getStyleClass().contains("db-icon")); + TreeItem qdrant = FxTestSupport.call(() -> group("Vector databases").getChildren().get(0)); + assertTrue(graphic(qdrant).getStyleClass().contains("db-icon")); + assertTrue(graphic(FxTestSupport.call(() -> group("OLAP databases").getChildren().get(0))).getStyleClass() + .contains("db-icon")); + + // A group or a vector database is not a node of the relational data sources + FxTestSupport.run(() -> tree.getSelectionModel().select(qdrant)); + assertNull(tree.getSelectedNode()); + assertEquals("Docs Qdrant", tree.getSelectedSource().getLabel()); + assertEquals("Qdrant", tree.getSelectedSource().getSource().getTypeName()); + FxTestSupport.run(() -> tree.getSelectionModel().select(group("Relational databases"))); + assertNull(tree.getSelectedNode()); + assertNull(tree.getSelectedSource()); + FxTestSupport.run(tree::refreshSelected); + assertEquals(List.of("Shop H2"), labels(group("Relational databases"))); + + assertTrue(DbIcons.has("PostgreSQL")); + assertTrue(DbIcons.has("chroma")); + assertFalse(DbIcons.has("Other")); + assertFalse(DbIcons.has(null)); + assertNotNull(DbIcons.of("Other")); + assertNotNull(DbIcons.of(null)); + } + + private List groupLabels() { + return FxTestSupport.call(() -> tree.getRoot().getChildren().stream().map(item -> item.getValue() + .getLabel()).collect(Collectors.toList())); + } + + /** + * A key which the database refused, e.g. one which is not remembered, is asked and saved + */ + @Test + void asksRefusedKey() { + DataSourceExtension.Source source = FxTestSupport.call(() -> extension.getSources().get(0)); + boolean[] asked = new boolean[1]; + Platform.runLater(() -> asked[0] = source.authenticate(stage, new RestException("Qdrant: 401", 401, null))); + FxTestSupport.waitUntil(() -> dialogs().stream().anyMatch(pane -> pane.lookup("#credentialsSecretField") + != null)); + DialogPane pane = dialogs().stream().filter(candidate -> candidate.lookup("#credentialsSecretField") + != null).findFirst().orElseThrow(); + // An API key has no user + assertNull(pane.lookup("#credentialsUsernameField")); + FxTestSupport.run(() -> { + ((TextField) pane.lookup("#credentialsSecretField")).setText("new-key"); + ((Button) pane.lookupButton(ButtonType.OK)).fire(); + }); + FxTestSupport.waitUntil(() -> asked[0]); + assertEquals("new-key", store.getProfiles().get(0).getApiKey()); + // Other errors ask nothing + assertFalse(FxTestSupport.call(() -> source.authenticate(stage, new IllegalStateException("down")))); + } + + @Test + void filtersDataSources() { + TreeItem h2 = FxTestSupport.call(() -> group("Relational databases").getChildren().get(0)); + FxTestSupport.run(() -> h2.setExpanded(true)); + // By name or type, ignoring case, groups without matches are hidden + FxTestSupport.run(() -> tree.setFilter(" QDRANT ")); + assertEquals(List.of("Vector databases"), groupLabels()); + FxTestSupport.run(() -> tree.setFilter("duckdb")); + assertEquals(List.of("OLAP databases"), groupLabels()); + assertEquals(List.of("Lake DuckDB"), labels(group("OLAP databases"))); + FxTestSupport.run(() -> tree.setFilter("shop")); + assertEquals(List.of("Relational databases"), groupLabels()); + FxTestSupport.run(() -> tree.setFilter("nothing")); + assertEquals(List.of(), groupLabels()); + // The items are kept, an expanded connection stays expanded + FxTestSupport.run(() -> tree.setFilter(null)); + assertEquals(List.of("Relational databases", "OLAP databases", "Vector databases"), groupLabels()); + assertSame(h2, FxTestSupport.call(() -> group("Relational databases").getChildren().get(0))); + assertTrue(h2.isExpanded()); + assertEquals("", tree.getFilter()); + + // Selecting a hidden data source clears the filter + FxTestSupport.run(() -> tree.setFilter("qdrant")); + String h2Id = h2.getValue().getProfile().getId(); + FxTestSupport.run(() -> tree.selectConnection(h2Id)); + assertEquals("", tree.getFilter()); + assertSame(h2, FxTestSupport.call(() -> tree.getSelectionModel().getSelectedItem())); + FxTestSupport.run(() -> tree.setFilter("duck")); + String qdrantId = FxTestSupport.call(() -> extension.getSources().get(0).getId()); + FxTestSupport.run(() -> tree.selectSource(qdrantId)); + assertEquals("", tree.getFilter()); + assertEquals("Docs Qdrant", tree.getSelectedSource().getLabel()); + // A visible one keeps it + FxTestSupport.run(() -> tree.setFilter("docs")); + FxTestSupport.run(() -> tree.selectSource(qdrantId)); + assertEquals("docs", tree.getFilter()); + } + + @Test + @SuppressWarnings("unchecked") + void linksCategoryAndTypes() { + // A new data source of the relational category, the types follow the category + Platform.runLater(() -> new ConnectionDialog(stage, context, null, null, List.of(extension)).show()); + DialogPane pane = waitForDialog("categoryCombo"); + ComboBox categories = (ComboBox) pane.lookup("#categoryCombo"); + ComboBox types = (ComboBox) pane.lookup("#dbTypeCombo"); + ComboBox sourceTypes = (ComboBox) pane.lookup("#sourceTypeCombo"); + assertEquals(List.of(DbCategory.RELATIONAL, DbCategory.OLAP, extension), FxTestSupport.call( + () -> List.copyOf(categories.getItems()))); + assertEquals(DbCategory.RELATIONAL, FxTestSupport.call(categories::getValue)); + assertFalse(FxTestSupport.call(() -> types.getItems().contains(DbType.DUCKDB))); + FxTestSupport.run(() -> categories.setValue(DbCategory.OLAP)); + assertEquals(List.of(DbType.DUCKDB, DbType.CLICKHOUSE), FxTestSupport.call(() -> List.copyOf(types + .getItems()))); + // The first type of the category is chosen, the type is never empty + assertEquals(DbType.DUCKDB, FxTestSupport.call(types::getValue)); + + // The vector databases are the types of their category, with their own fields + FxTestSupport.run(() -> categories.setValue(extension)); + assertTrue(FxTestSupport.call(sourceTypes::isVisible)); + assertFalse(FxTestSupport.call(types::isVisible)); + assertEquals(VdbType.values().length, FxTestSupport.call(() -> sourceTypes.getItems().size())); + assertNotNull(pane.lookup("#vdbUrlField")); + FxTestSupport.run(() -> { + sourceTypes.setValue("Chroma"); + ((TextField) pane.lookup("#nameField")).setText("Docs Chroma"); + // A Chroma without authentication + ((ComboBox) pane.lookup("#vdbAuthenticationCombo")).setValue( + VdbConnectionForm.Authentication.NONE); + }); + assertEquals(VdbType.CHROMA.getUrl("localhost", 0), FxTestSupport.call(() -> ((TextField) pane.lookup( + "#vdbUrlField")).getText())); + press(pane, ButtonType.OK); + FxTestSupport.waitUntil(() -> store.getProfiles().stream().anyMatch(profile -> "Docs Chroma".equals( + profile.getName()))); + + // A type of another category chooses its category + Platform.runLater(() -> new ConnectionDialog(stage, context, null, DbCategory.OLAP, List.of(extension)) + .show()); + DialogPane olap = waitForDialog("categoryCombo"); + ComboBox olapCategories = (ComboBox) olap.lookup("#categoryCombo"); + ComboBox olapTypes = (ComboBox) olap.lookup("#dbTypeCombo"); + assertEquals(DbType.DUCKDB, FxTestSupport.call(olapTypes::getValue)); + FxTestSupport.run(() -> olapTypes.setValue(DbType.POSTGRESQL)); + assertEquals(DbCategory.RELATIONAL, FxTestSupport.call(olapCategories::getValue)); + press(olap, ButtonType.CANCEL); + + assertTrue(extension.getOpenText().startsWith("Import")); + assertTrue(extension.isEnabled(new Config(new File(dir, "kinds.properties")))); + assertEquals(1, DataSourceExtension.load(context.getSettingsStore().getConfig()).size()); + } + + @Test + void managesVectorDatabasesOfTree() throws Exception { + // A new vector database of the group, the form shows the category of the group + fire(contextMenu(group("Vector databases")), "New..."); + DialogPane pane = waitForDialog("vdbUrlField"); + FxTestSupport.run(() -> { + ((TextField) pane.lookup("#nameField")).setText("Docs Chroma"); + @SuppressWarnings("unchecked") + ComboBox type = (ComboBox) pane.lookup("#sourceTypeCombo"); + type.setValue("Chroma"); + ((TextField) pane.lookup("#vdbApiKeyField")).setText("token"); + }); + press(pane, ButtonType.OK); + FxTestSupport.waitUntil(() -> labels(group("Vector databases")).contains("Docs Chroma")); + assertEquals(2, store.getProfiles().size()); + assertEquals("Docs Chroma", tree.getSelectedSource().getLabel()); + + // Edited and cancelled, then opened in the import dialog + TreeItem chroma = FxTestSupport.call(() -> group("Vector databases").getChildren().stream().filter( + item -> "Docs Chroma".equals(item.getValue().getLabel())).findFirst().orElseThrow()); + fire(contextMenu(chroma), "Edit Data Source"); + press(waitForDialog("vdbUrlField"), ButtonType.CANCEL); + fire(contextMenu(chroma), "Import into Database..."); + DialogPane importPane = waitForDialog("vdbSourceCombo"); + @SuppressWarnings("unchecked") + ComboBox sources = (ComboBox) importPane.lookup("#vdbSourceCombo"); + assertEquals("Docs Chroma", FxTestSupport.call(() -> sources.getValue().getName())); + FxTestSupport.run(() -> importPane.getScene().getWindow().hide()); + + // Deleted after the confirmation + fire(contextMenu(chroma), "Delete Data Source"); + FxTestSupport.waitUntil(() -> !dialogs().isEmpty()); + press(dialogs().get(0), ButtonType.OK); + FxTestSupport.waitUntil(() -> !labels(group("Vector databases")).contains("Docs Chroma")); + assertEquals(1, store.getProfiles().size()); + + // A relational data source of its group + fire(contextMenu(group("Relational databases")), "New..."); + DialogPane form = waitForDialog("dbTypeCombo"); + @SuppressWarnings("unchecked") + ComboBox types = (ComboBox) form.lookup("#dbTypeCombo"); + assertFalse(FxTestSupport.call(() -> types.getItems().contains(DbType.DUCKDB))); + press(form, ButtonType.CANCEL); + assertTrue(Window.getWindows().stream().noneMatch(window -> window.isShowing() && window != stage)); + } + + @Test + @SuppressWarnings("unchecked") + void neverOffersVectorDatabasesAsTargets() { + // The import of a vector database: data sources only, its + creates relational and OLAP ones only + Platform.runLater(() -> new VdbImportDialog(stage, context, store).show()); + DialogPane wizard = waitForDialog("vdbTargetCombo"); + ComboBox targets = (ComboBox) wizard.lookup("#vdbTargetCombo"); + FxTestSupport.waitUntil(() -> !targets.getItems().isEmpty()); + assertTrue(FxTestSupport.call(() -> targets.getItems().stream().allMatch(item -> item + instanceof ConnectionProfile && ((ConnectionProfile) item).getDbType() == DbType.DUCKDB))); + Platform.runLater(() -> ((Button) wizard.lookup("#newTargetButton")).fire()); + DialogPane create = waitForDialog("categoryCombo"); + ComboBox categories = (ComboBox) create.lookup("#categoryCombo"); + assertEquals(List.of(DbCategory.RELATIONAL, DbCategory.OLAP), FxTestSupport.call(() -> List.copyOf( + categories.getItems()))); + press(create, ButtonType.CANCEL); + press(wizard, ButtonType.CANCEL); + FxTestSupport.waitUntil(() -> dialogs().isEmpty()); + + // The export of tables: data sources only + Platform.runLater(() -> new com.github.sqljam.face.view.ExportDialog(stage, context, null).show()); + DialogPane export = waitForDialog("targetCombo"); + ComboBox exportTargets = (ComboBox) export.lookup("#targetCombo"); + assertTrue(FxTestSupport.call(() -> exportTargets.getItems().stream().allMatch(item -> item + instanceof ConnectionProfile))); + assertFalse(FxTestSupport.call(() -> exportTargets.getItems().stream().anyMatch(item -> "Docs Qdrant" + .equals(((ConnectionProfile) item).getName())))); + press(export, ButtonType.CANCEL); + + // A vector database is imported into a database, nothing is written into it + TreeItem qdrant = FxTestSupport.call(() -> group("Vector databases").getChildren().get(0)); + assertEquals(List.of("Import into Database...", "Refresh", "Info", "Edit Data Source", "Delete Data Source"), + FxTestSupport.call(() -> contextMenu(qdrant).getItems().stream().map(MenuItem::getText).filter( + text -> text != null).collect(Collectors.toList()))); + } +} diff --git a/src/test/java/com/github/sqljam/face/vdb/VdbImportDialogTest.java b/src/test/java/com/github/sqljam/face/vdb/VdbImportDialogTest.java new file mode 100644 index 0000000..e9757b3 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/vdb/VdbImportDialogTest.java @@ -0,0 +1,652 @@ +/* + * 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.vdb; + +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.assertNull; +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.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +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 org.testfx.util.WaitForAsyncUtils; +import com.github.sqljam.config.Config; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.view.AppContext; +import com.github.sqljam.face.view.ColumnMappingDialog; +import com.github.sqljam.face.view.FxTestSupport; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.vdb.VdbExportConfiguration; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VdbFieldMapping; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VdbType; +import com.github.sqljam.vdb.it.VdbServer; +import javafx.application.Platform; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.DialogPane; +import javafx.scene.control.Label; +import javafx.scene.control.TabPane; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; +import javafx.stage.Window; + +/** + * @Description: VdbImportDialogTest imports the Qdrant fixture into DuckDB from the dialog of the vdb line: vector + * databases are created, edited and tested, collections and their fields are listed, queries are + * previewed, only data sources of databases with vectors are targets, the SQL is shown and the import + * runs. The menu item is switched by sqljam.vdb.enabled. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class VdbImportDialogTest { + + static final VdbServer SERVER = VdbServer.QDRANT; + + @TempDir + File dir; + + private AppContext context; + private Stage stage; + private VdbProfileStore store; + private VdbImportDialog dialog; + private File duckdb; + + @BeforeAll + static void createFixture() throws Exception { + if (SERVER.isAvailable()) { + SERVER.createFixture(); + } + } + + @AfterAll + static void dropFixture() { + if (SERVER.isAvailable()) { + SERVER.dropFixture(); + } + } + + @Start + void start(Stage stage) throws Exception { + this.stage = stage; + context = FxTestSupport.createContext(dir); + duckdb = new File(dir, "target.duckdb"); + ConnectionProfile target = new ConnectionProfile(); + target.setName("Target DuckDB"); + target.setDbType(DbType.DUCKDB); + target.setDatabase(duckdb.getAbsolutePath()); + context.getProfileRegistry().saveProfile(target, true); + ConnectionProfile h2 = new ConnectionProfile(); + h2.setName("No vectors H2"); + h2.setDbType(DbType.H2); + h2.setDatabase(new File(dir, "h2/db").getAbsolutePath()); + h2.setUsername("sa"); + h2.setPassword(""); + context.getProfileRegistry().saveProfile(h2, true); + store = new VdbProfileStore(new File(dir, VdbProfileStore.FILE_NAME)); + stage.setScene(new Scene(new StackPane(), 300, 200)); + stage.show(); + } + + @AfterEach + void close() { + if (dialog != null) { + FxTestSupport.run(dialog::close); + } + FxTestSupport.closeAllDialogs(); + } + + static VdbProfile profile() { + VdbProfile profile = new VdbProfile(); + profile.setName("Qdrant of the tests"); + profile.setType(VdbType.QDRANT); + profile.setUrl(SERVER.getSource().getUrl()); + profile.setApiKey(SERVER.getSource().getApiKey()); + return profile; + } + + @SuppressWarnings("unchecked") + private T node(String id) { + return (T) dialog.getDialogPane().lookup("#" + id); + } + + private String status() { + return FxTestSupport.call(() -> ((Label) node("vdbStatus")).getText()); + } + + private boolean statusIs(String styleClass) { + return ((Label) node("vdbStatus")).getStyleClass().contains(styleClass); + } + + /** + * Dialog pane of an alert above the import dialog + */ + private DialogPane waitForAlert() { + FxTestSupport.waitUntil(() -> alerts().size() > 0); + return alerts().get(0); + } + + private List alerts() { + return FxTestSupport.getDialogPanes().stream().filter(pane -> pane != dialog.getDialogPane()) + .collect(Collectors.toList()); + } + + private void fireAlert(DialogPane pane, ButtonType type) { + Platform.runLater(() -> Optional.ofNullable((Button) pane.lookupButton(type)).ifPresent(Button::fire)); + WaitForAsyncUtils.waitForFxEvents(); + } + + private void open() { + dialog = FxTestSupport.call(() -> { + VdbImportDialog importDialog = new VdbImportDialog(stage, context, store); + importDialog.show(); + return importDialog; + }); + } + + @SuppressWarnings("unchecked") + private T node(Node root, String id) { + return (T) root.lookup("#" + id); + } + + private static long count(File file, String table) throws Exception { + try (Connection connection = DriverManager.getConnection("jdbc:duckdb:" + file.getAbsolutePath()); + Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SELECT COUNT(*) FROM main." + table)) { + rs.next(); + return rs.getLong(1); + } + } + + private String text(Node content, String id) { + return FxTestSupport.call(() -> ((Label) node(content, id)).getText()); + } + + private boolean disabled(Node content, String id) { + return FxTestSupport.call(() -> node(content, id).isDisabled()); + } + + private static List column(TableView> data, String name) { + return FxTestSupport.call(() -> { + TableColumn, ?> column = data.getColumns().stream().filter(candidate -> candidate + .getText().equals(name)).findFirst().orElseThrow(); + return data.getItems().stream().map(row -> (String) column.getCellData(row)).collect(Collectors + .toList()); + }); + } + + /** + * Records page by page, by the cursor of the database, and sorted on the page by their headers + */ + @Test + @SuppressWarnings("unchecked") + void pagesAndSortsRecords() { + assumeTrue(SERVER.isAvailable()); + VdbCollectionTab tab = FxTestSupport.call(() -> { + VdbCollectionTab created = new VdbCollectionTab(profile(), SERVER.getCollection()); + stage.setScene(new Scene(new TabPane(created), 1200, 700)); + return created; + }); + Node content = tab.getContent(); + FxTestSupport.run(() -> ((TabPane) content).getSelectionModel().select(1)); + TableView> data = node(content, "vdbDataTable"); + FxTestSupport.waitUntil(() -> data.getItems().size() == VdbServer.RECORDS); + assertEquals("Page 1 / 1", text(content, "vdbPageLabel")); + assertTrue(disabled(content, "vdbNextButton") && disabled(content, "vdbPreviousButton")); + + // Three records a page + FxTestSupport.run(() -> ((ComboBox) node(content, "vdbLimitCombo")).setValue(3)); + FxTestSupport.waitUntil(() -> data.getItems().size() == 3 && "Page 1 / 3".equals(text(content, + "vdbPageLabel"))); + assertTrue(text(content, "vdbTotalLabel").startsWith("Records 1 to 3 of 8")); + List first = column(data, "n"); + FxTestSupport.fireLater((Button) node(content, "vdbNextButton")); + FxTestSupport.waitUntil(() -> "Page 2 / 3".equals(text(content, "vdbPageLabel")) && !disabled(content, + "vdbRefreshButton")); + assertTrue(text(content, "vdbTotalLabel").startsWith("Records 4 to 6 of 8")); + List second = column(data, "n"); + assertTrue(second.stream().noneMatch(first::contains), first + " " + second); + FxTestSupport.fireLater((Button) node(content, "vdbLastButton")); + FxTestSupport.waitUntil(() -> "Page 3 / 3".equals(text(content, "vdbPageLabel")) && !disabled(content, + "vdbRefreshButton")); + assertEquals(2, data.getItems().size()); + assertTrue(disabled(content, "vdbNextButton") && disabled(content, "vdbLastButton")); + assertTrue(text(content, "vdbTotalLabel").startsWith("Records 7 to 8 of 8")); + FxTestSupport.fireLater((Button) node(content, "vdbPreviousButton")); + FxTestSupport.waitUntil(() -> "Page 2 / 3".equals(text(content, "vdbPageLabel")) && !disabled(content, + "vdbRefreshButton")); + FxTestSupport.fireLater((Button) node(content, "vdbRefreshButton")); + FxTestSupport.waitUntil(() -> "Page 2 / 3".equals(text(content, "vdbPageLabel")) && !disabled(content, + "vdbRefreshButton")); + assertEquals(second, column(data, "n")); + FxTestSupport.fireLater((Button) node(content, "vdbFirstButton")); + FxTestSupport.waitUntil(() -> "Page 1 / 3".equals(text(content, "vdbPageLabel")) && !disabled(content, + "vdbRefreshButton")); + assertEquals(first, column(data, "n")); + assertEquals(3, tab.getLastPage()); + + // A header sorts the page by numbers, the third click shows the order of the database again + FxTestSupport.run(() -> { + TableColumn, ?> n = data.getColumns().stream().filter(column -> column.getText() + .equals("n")).findFirst().orElseThrow(); + n.setSortType(TableColumn.SortType.DESCENDING); + data.getSortOrder().setAll(n); + }); + List descending = new java.util.ArrayList<>(first); + descending.sort(Comparator.comparing(Integer::valueOf, Comparator.reverseOrder())); + assertEquals(descending, column(data, "n")); + FxTestSupport.run(() -> data.getSortOrder().clear()); + assertEquals(first, column(data, "n")); + + // The nearest neighbors are one page + FxTestSupport.run(() -> ((TextField) node(content, "vdbNearestField")).setText("2")); + FxTestSupport.fireLater((Button) node(content, "vdbApplyButton")); + FxTestSupport.waitUntil(() -> "Page 1 / 1".equals(text(content, "vdbPageLabel")) && !disabled(content, + "vdbRefreshButton")); + assertTrue(disabled(content, "vdbNextButton")); + } + + /** + * The fields and the records of a collection in its tab, the records of a query are imported by the wizard + */ + @Test + @SuppressWarnings("unchecked") + void browsesCollectionAndImportsQuery() throws Exception { + assumeTrue(SERVER.isAvailable()); + VdbProfile profile = profile(); + store.saveProfile(profile, true); + VdbCollectionTab tab = FxTestSupport.call(() -> { + VdbCollectionTab created = new VdbCollectionTab(profile, SERVER.getCollection()); + TabPane tabs = new TabPane(created); + stage.setScene(new Scene(tabs, 1200, 700)); + return created; + }); + Node content = tab.getContent(); + // The data tab chosen before the fields are read reads its records when they are + FxTestSupport.run(() -> ((TabPane) content).getSelectionModel().select(1)); + TableView> early = node(content, "vdbDataTable"); + FxTestSupport.waitUntil(() -> early.getItems().size() == VdbServer.RECORDS); + FxTestSupport.run(() -> ((TabPane) content).getSelectionModel().select(0)); + TableView fields = node(content, "vdbFieldTable"); + FxTestSupport.waitUntil(() -> !fields.getItems().isEmpty()); + assertEquals("VECTOR FLOAT32(1024)", VdbCollectionTab.describeType(tab.getCollection().findField( + "embedding").get())); + // Qdrant has no partitions + assertFalse(node(content, "vdbPartitionsButton").isVisible()); + + // The records of the data tab, narrowed by conditions, a native filter or a search + FxTestSupport.run(() -> ((TabPane) content).getSelectionModel().select(1)); + TableView> data = node(content, "vdbDataTable"); + FxTestSupport.waitUntil(() -> data.getItems().size() == VdbServer.RECORDS); + assertTrue(FxTestSupport.call(() -> ((Label) node(content, "vdbTotalLabel")).getText()).startsWith( + "Records 1 to 8 of 8")); + TextField filter = node(content, "vdbFilterField"); + Button apply = node(content, "vdbApplyButton"); + FxTestSupport.run(() -> filter.setText("n >= 6")); + FxTestSupport.fireLater(apply); + FxTestSupport.waitUntil(() -> data.getItems().size() == 3); + ComboBox filterType = node(content, "vdbFilterTypeCombo"); + FxTestSupport.run(() -> { + filterType.getSelectionModel().select(1); + filter.setText("{\"must\": [{\"key\": \"n\", \"match\": {\"value\": 2}}]}"); + }); + FxTestSupport.fireLater(apply); + FxTestSupport.waitUntil(() -> data.getItems().size() == 1); + FxTestSupport.run(() -> { + filter.clear(); + ((TextField) node(content, "vdbNearestField")).setText("[1, 2"); + }); + FxTestSupport.fireLater(apply); + FxTestSupport.waitUntil(() -> node(content, "vdbQueryErrorLabel").isVisible()); + FxTestSupport.run(() -> ((TextField) node(content, "vdbNearestField")).setText("2")); + FxTestSupport.fireLater(apply); + FxTestSupport.waitUntil(() -> !node(content, "vdbQueryErrorLabel").isVisible() && data.getItems().size() + > 0 && data.getItems().size() < VdbServer.RECORDS); + FxTestSupport.fireLater((Button) node(content, "vdbClearButton")); + FxTestSupport.waitUntil(() -> data.getItems().size() == VdbServer.RECORDS); + FxTestSupport.run(() -> { + filterType.getSelectionModel().select(0); + filter.setText("n >= 6"); + }); + FxTestSupport.fireLater(apply); + FxTestSupport.waitUntil(() -> data.getItems().size() == 3); + + // Import... opens the wizard of the collection and of the query + String[] importedCollection = new String[1]; + VdbQuery[] importedQuery = new VdbQuery[1]; + tab.setOnImport((collection, query) -> { + importedCollection[0] = collection; + importedQuery[0] = query; + }); + FxTestSupport.fireLater((Button) node(content, "vdbImportButton")); + assertEquals(SERVER.getCollection(), importedCollection[0]); + assertEquals(1, importedQuery[0].getConditions().size()); + + dialog = FxTestSupport.call(() -> new VdbImportDialog(stage, context, store, profile, null, + List.of(importedCollection[0]), importedQuery[0])); + Platform.runLater(dialog::showAndImport); + WaitForAsyncUtils.waitForFxEvents(); + TableView collections = node("vdbCollections"); + FxTestSupport.waitUntil(() -> collections.getItems().stream().anyMatch(item -> item.selected.get())); + assertTrue(FxTestSupport.call(() -> ((Label) node("vdbQueryLabel")).getText()).contains("n GE 6")); + assertFalse(node("vdbDatabaseCombo").isVisible()); + // Only data sources with vectors are targets, they are probed in the background + ComboBox targets = node("vdbTargetCombo"); + FxTestSupport.waitUntil(() -> !targets.getItems().isEmpty()); + assertEquals(List.of("Target DuckDB"), FxTestSupport.call(() -> targets.getItems().stream() + .map(ConnectionProfile::getName).collect(Collectors.toList()))); + Button start = (Button) dialog.getDialogPane().lookupButton(ButtonType.OK); + FxTestSupport.run(() -> targets.setValue(targets.getItems().get(0))); + FxTestSupport.waitUntil(() -> statusIs("status-success") && status().contains("DuckDB")); + assertFalse(start.isDisable()); + FxTestSupport.run(() -> { + collections.getItems().stream().filter(item -> item.selected.get()).forEach(item -> item.tableName + .set("sjvt_ui")); + }); + + // The SQL of the tables + Button showSql = (Button) dialog.getDialogPane().lookupButton(dialog.getDialogPane().getButtonTypes() + .get(0)); + FxTestSupport.fireLater(showSql); + DialogPane sql = waitForAlert(); + String scripts = FxTestSupport.call(() -> ((TextArea) sql.getContent()).getText()); + assertTrue(scripts.contains("CREATE TABLE \"main\".\"sjvt_ui\""), scripts); + fireAlert(sql, ButtonType.OK); + FxTestSupport.waitUntil(() -> alerts().isEmpty()); + + // The import, after the confirmation of the drops, runs in the progress dialog + FxTestSupport.run(() -> ((CheckBox) node("vdbAllCheck")).fire()); + FxTestSupport.run(() -> ((CheckBox) node("vdbAllCheck")).fire()); + FxTestSupport.run(() -> collections.getItems().forEach(item -> item.selected.set(item.name.equals( + SERVER.getCollection())))); + FxTestSupport.fireLater(start); + fireAlert(waitForAlert(), ButtonType.OK); + FxTestSupport.waitUntil(() -> { + try { + return count(duckdb, "sjvt_ui") == 3; + } catch (Exception e) { + return false; + } + }); + FxTestSupport.waitUntil(() -> Window.getWindows().stream().anyMatch(window -> window.isShowing() + && window.getScene() != null && window.getScene().getRoot().lookup("#progressStatus") != null)); + dialog = null; + + // The report of the import is in the history: the collection is its table, its rows are counted + FxTestSupport.waitUntil(() -> !context.getReportStore().list().isEmpty()); + TransferReport report = context.getReportStore().list().get(0); + assertEquals(TransferReport.Kind.VECTOR_IMPORT, report.getKind()); + assertEquals(TransferReport.Status.COMPLETED, report.getStatus(), report.getFailure()); + assertTrue(report.getSource().startsWith(profile.getName() + " (QDRANT)"), report.getSource()); + assertTrue(report.getTarget().startsWith("Target DuckDB"), report.getTarget()); + assertEquals("true", report.getOptions().get("Recreate tables")); + TransferReport.TableReport table = report.getTables().stream().filter(item -> "sjvt_ui".equals(item + .getName())).findFirst().orElseThrow(); + assertEquals(3, table.getWritten()); + assertEquals(3L, table.getTargetRows()); + assertTrue(table.getVerified()); + } + + /** + * Fields of the checked collections are mapped, the id is always imported, the mappings go with the import + */ + @Test + @SuppressWarnings("unchecked") + void mapsFieldsOfCollections() throws Exception { + assumeTrue(SERVER.isAvailable()); + VdbProfile profile = profile(); + store.saveProfile(profile, true); + dialog = FxTestSupport.call(() -> { + VdbImportDialog created = new VdbImportDialog(stage, context, store, profile, null, List.of(), null); + created.show(); + return created; + }); + TableView collections = node("vdbCollections"); + FxTestSupport.waitUntil(() -> !collections.getItems().isEmpty()); + // Nothing is checked + FxTestSupport.run(() -> ((Button) node("vdbMapColumnsButton")).fire()); + assertTrue(status().contains("Check the collections"), status()); + FxTestSupport.run(() -> collections.getItems().stream().filter(item -> item.name.equals(SERVER + .getCollection())).forEach(item -> item.selected.set(true))); + FxTestSupport.fireLater((Button) node("vdbMapColumnsButton")); + FxTestSupport.waitUntil(() -> FxTestSupport.getDialogPanes().stream().anyMatch(pane -> pane.lookup( + "#mappingColumnTable") != null)); + DialogPane mapping = FxTestSupport.getDialogPanes().stream().filter(pane -> pane.lookup( + "#mappingColumnTable") != null).findFirst().orElseThrow(); + TableView fields = (TableView) mapping.lookup( + "#mappingColumnTable"); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> !fields.getItems().isEmpty())); + assertTrue(FxTestSupport.call(() -> fields.getItems().stream().anyMatch(row -> row.getColumn().isRequired()))); + FxTestSupport.run(() -> { + fields.getItems().stream().filter(row -> row.getColumn().getName().equals("title")).forEach(row -> row + .targetNameProperty().set("headline")); + fields.getItems().stream().filter(row -> row.getColumn().getName().equals("tags")).forEach(row -> row + .importedProperty().set(false)); + ((Button) mapping.lookupButton(ButtonType.OK)).fire(); + }); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> ((Label) node("vdbMappingLabel")).getText().equals( + "2 columns are mapped"))); + VdbExportConfiguration configuration = FxTestSupport.call(dialog::buildConfiguration); + assertEquals(new VdbFieldMapping("headline", null, false), configuration.getMappings().get(0).getFields() + .get("title")); + assertEquals(VdbFieldMapping.skip(), configuration.getMappings().get(0).getFields().get("tags")); + } + + @Test + @SuppressWarnings("unchecked") + void editsVectorDatabases() throws Exception { + open(); + ComboBox sources = node("vdbSourceCombo"); + assertTrue(FxTestSupport.call(() -> sources.getItems().isEmpty())); + VdbConnectionDialog connectionDialog = FxTestSupport.call(() -> { + VdbConnectionDialog created = new VdbConnectionDialog(stage, null, true); + created.show(); + return created; + }); + DialogPane pane = connectionDialog.getDialogPane(); + ComboBox type = (ComboBox) pane.lookup("#vdbTypeCombo"); + TextField url = (TextField) pane.lookup("#vdbUrlField"); + assertEquals(VdbType.QDRANT.getUrl("localhost", 0), FxTestSupport.call(url::getText)); + // The url of the default port follows the type + FxTestSupport.run(() -> type.setValue(VdbType.CHROMA)); + assertEquals(VdbType.CHROMA.getUrl("localhost", 0), FxTestSupport.call(url::getText)); + assertTrue(pane.lookup("#vdbTenantField").isVisible()); + assertTrue(pane.lookup("#vdbDatabaseField").isVisible()); + FxTestSupport.run(() -> type.setValue(VdbType.QDRANT)); + assertFalse(pane.lookup("#vdbTenantField").isVisible()); + + // An url which is not of HTTP is refused + FxTestSupport.run(() -> url.setText("ftp://x")); + FxTestSupport.fireLater((Button) pane.lookup("#vdbTestButton")); + Label status = (Label) pane.lookup("#vdbStatusLabel"); + FxTestSupport.waitUntil(() -> status.getStyleClass().contains("status-error")); + Button ok = (Button) pane.lookupButton(ButtonType.OK); + FxTestSupport.fireLater(ok); + assertTrue(FxTestSupport.call(connectionDialog::isShowing)); + + // A wrong key fails, the right key connects + if (SERVER.isAvailable()) { + FxTestSupport.run(() -> { + url.setText(SERVER.getSource().getUrl() + "/"); + ((TextField) pane.lookup("#vdbApiKeyField")).setText("wrong"); + }); + FxTestSupport.fireLater((Button) pane.lookup("#vdbTestButton")); + FxTestSupport.waitUntil(() -> status.getStyleClass().contains("status-error") && status.getText() + .contains("40")); + FxTestSupport.run(() -> ((TextField) pane.lookup("#vdbApiKeyField")).setText(SERVER.getSource() + .getApiKey())); + FxTestSupport.fireLater((Button) pane.lookup("#vdbTestButton")); + FxTestSupport.waitUntil(() -> status.getStyleClass().contains("status-success")); + } + FxTestSupport.run(() -> { + ((CheckBox) pane.lookup("#vdbRememberCheck")).setSelected(false); + ((CheckBox) pane.lookup("#vdbTrustCheck")).setSelected(true); + // Qdrant has no users, a username of another authentication is not kept + ((TextField) pane.lookup("#vdbUsernameField")).setText(" user "); + }); + VdbProfile read = FxTestSupport.call(connectionDialog::read); + assertFalse(connectionDialog.isSecretRemembered()); + assertTrue(read.getName().startsWith("Qdrant "), read.getName()); + assertFalse(read.getUrl().endsWith("/")); + assertNull(read.getUsername()); + assertTrue(read.isCertificateTrusted()); + assertNull(read.getTenant()); + FxTestSupport.run(connectionDialog::close); + + // Secrets which are not remembered are kept for the session only + store.saveProfile(read, false); + VdbProfileStore reloaded = new VdbProfileStore(new File(dir, VdbProfileStore.FILE_NAME)); + assertNull(reloaded.getProfiles().get(0).getApiKey()); + assertEquals(read.getApiKey(), store.getProfiles().get(0).getApiKey()); + assertFalse(store.isSecretRemembered(read.getId())); + assertTrue(store.isSecretRemembered("missing")); + store.saveProfile(read, true); + assertEquals(read.getApiKey(), new VdbProfileStore(new File(dir, VdbProfileStore.FILE_NAME)).getProfiles() + .get(0).getApiKey()); + // Secrets are never in the text of a profile, whether the server of the tests is reachable or not + VdbProfile secret = read.copy(); + secret.setApiKey("sj-api-key-secret"); + secret.setPassword("sj-password-secret"); + assertFalse(secret.toString().contains("sj-api-key-secret") || secret.toString().contains( + "sj-password-secret"), secret.toString()); + assertEquals(read, read.copy()); + assertEquals(read.hashCode(), read.copy().hashCode()); + store.removeProfile(read.getId()); + assertTrue(store.getProfiles().isEmpty()); + } + + @Test + void switchesMenuItem() { + VdbMenuExtension extension = new VdbMenuExtension(); + Config config = new Config(new File(dir, "switch.properties")); + assertTrue(extension.isEnabled(config)); + config.set(VdbMenuExtension.ENABLED_KEY, "false"); + assertFalse(extension.isEnabled(config)); + assertEquals("vdbImportMenuItem", extension.getId()); + assertTrue(extension.getText().contains("Vector")); + Platform.runLater(() -> extension.show(stage, context)); + FxTestSupport.waitUntil(() -> Window.getWindows().stream().anyMatch(window -> window.getScene() != null + && window.getScene().getRoot() instanceof DialogPane && ((DialogPane) window.getScene().getRoot()) + .lookup("#vdbSourceCombo") != null)); + FxTestSupport.closeAllDialogs(); + } + + /** + * The authentications follow the type: an API key or a user and a password, only the fields of the chosen one + * are shown and kept + */ + @Test + void choosesAuthenticationOfType() { + VdbConnectionForm form = FxTestSupport.call(() -> new VdbConnectionForm(110)); + TextField apiKey = (TextField) form.lookup("#vdbApiKeyField"); + TextField username = (TextField) form.lookup("#vdbUsernameField"); + TextField database = (TextField) form.lookup("#vdbDatabaseField"); + TextField tenant = (TextField) form.lookup("#vdbTenantField"); + // Qdrant: an API key, no users, databases or tenants + assertEquals(List.of(VdbConnectionForm.Authentication.NONE, VdbConnectionForm.Authentication.API_KEY), + VdbConnectionForm.getAuthentications(VdbType.QDRANT)); + assertEquals(VdbConnectionForm.Authentication.API_KEY, form.getAuthentication()); + assertTrue(apiKey.isVisible()); + assertFalse(username.isVisible()); + assertFalse(database.isVisible()); + assertFalse(tenant.isVisible()); + FxTestSupport.run(() -> form.setProfile(null, true)); + assertEquals("API key is required, or choose None", FxTestSupport.call(form::validate)); + // OpenSearch: users only, the authentication changes with the type + FxTestSupport.run(() -> form.setType(VdbType.OPENSEARCH)); + assertEquals(VdbConnectionForm.Authentication.USER_PASSWORD, form.getAuthentication()); + assertFalse(apiKey.isVisible()); + assertTrue(username.isVisible()); + assertEquals("Username is required, or choose None", FxTestSupport.call(form::validate)); + // Elasticsearch has both, the chosen one is kept + FxTestSupport.run(() -> form.setType(VdbType.ELASTICSEARCH)); + assertEquals(VdbConnectionForm.Authentication.USER_PASSWORD, form.getAuthentication()); + FxTestSupport.run(() -> { + username.setText("elastic"); + apiKey.setText("hidden key"); + }); + VdbProfile user = FxTestSupport.call(() -> form.read(null)); + assertEquals("elastic", user.getUsername()); + assertNull(user.getApiKey()); + FxTestSupport.run(() -> form.setAuthentication(VdbConnectionForm.Authentication.API_KEY)); + VdbProfile key = FxTestSupport.call(() -> form.read(null)); + assertEquals("hidden key", key.getApiKey()); + assertNull(key.getUsername()); + // None keeps no credentials, the remember box is hidden + FxTestSupport.run(() -> form.setAuthentication(VdbConnectionForm.Authentication.NONE)); + assertNull(FxTestSupport.call(form::validate)); + VdbProfile none = FxTestSupport.call(() -> form.read(null)); + assertFalse(none.hasSecret()); + assertNull(none.getUsername()); + assertFalse(form.lookup("#vdbRememberCheck").isVisible()); + // Chroma has databases and tenants, Milvus databases + FxTestSupport.run(() -> form.setType(VdbType.CHROMA)); + assertTrue(database.isVisible()); + assertTrue(tenant.isVisible()); + FxTestSupport.run(() -> form.setType(VdbType.MILVUS)); + assertTrue(database.isVisible()); + assertFalse(tenant.isVisible()); + + // A saved database shows the authentication of its credentials + VdbProfile milvus = new VdbProfile(); + milvus.setType(VdbType.MILVUS); + milvus.setUrl("http://h:19530"); + milvus.setUsername("root"); + FxTestSupport.run(() -> form.setProfile(milvus, true)); + assertEquals(VdbConnectionForm.Authentication.USER_PASSWORD, form.getAuthentication()); + milvus.setUsername(null); + milvus.setApiKey("token"); + FxTestSupport.run(() -> form.setProfile(milvus, true)); + assertEquals(VdbConnectionForm.Authentication.API_KEY, form.getAuthentication()); + milvus.setApiKey(null); + FxTestSupport.run(() -> form.setProfile(milvus, true)); + assertEquals(VdbConnectionForm.Authentication.NONE, form.getAuthentication()); + // Its key which is kept for the session only shows the API key + FxTestSupport.run(() -> form.setProfile(milvus, false)); + assertEquals(VdbConnectionForm.Authentication.API_KEY, form.getAuthentication()); + } +} diff --git a/src/test/java/com/github/sqljam/face/vdb/VdbSourceItemsTest.java b/src/test/java/com/github/sqljam/face/vdb/VdbSourceItemsTest.java new file mode 100644 index 0000000..3efa4dd --- /dev/null +++ b/src/test/java/com/github/sqljam/face/vdb/VdbSourceItemsTest.java @@ -0,0 +1,135 @@ +/* + * 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.vdb; + +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.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.github.sqljam.face.view.DataSourceExtension; +import com.github.sqljam.face.view.SourceItem; +import com.github.sqljam.utils.RestException; +import com.github.sqljam.vdb.VdbType; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +/** + * @Description: VdbSourceItemsTest browses vector databases of servers which differ from those of the tests: a + * Milvus user who may not list the databases sees the database of the source, or the collections of + * the default database, and a server without the route of partitions shows its collections without + * them + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class VdbSourceItemsTest { + + @TempDir + File dir; + + private HttpServer server; + + /** + * Milvus which refuses databases/list (code 1800) and has no route of partitions + */ + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/", exchange -> { + String path = exchange.getRequestURI().getPath(); + if (path.endsWith("/databases/list")) { + reply(exchange, 200, "{\"code\": 1800, \"message\": \"permission denied\"}"); + } else if (path.endsWith("/collections/list")) { + reply(exchange, 200, "{\"code\": 0, \"data\": [\"docs\", \"faq\"]}"); + } else { + reply(exchange, 404, "{\"error\": \"no route\"}"); + } + }); + server.start(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + private static void reply(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } + + private DataSourceExtension.Source source(String database) throws IOException { + VdbProfileStore store = new VdbProfileStore(new File(dir, VdbProfileStore.FILE_NAME)); + VdbProfile profile = new VdbProfile(); + profile.setName("Milvus of another team"); + profile.setType(VdbType.MILVUS); + profile.setUrl("http://127.0.0.1:" + server.getAddress().getPort()); + profile.setUsername("reader"); + profile.setPassword("secret"); + profile.setDatabase(database); + store.saveProfile(profile, true); + return new VdbDataSourceExtension(store).getSources().get(0); + } + + private static List names(List items) { + return items.stream().map(item -> item.getKind() + " " + item.getName()).collect(Collectors.toList()); + } + + @Test + void showsCollectionsWhenDatabasesAreNotListed() throws Exception { + DataSourceExtension.Source source = source(null); + List items = source.loadItems(null); + assertEquals(List.of("COLLECTION docs", "COLLECTION faq"), names(items)); + // Partitions are not listed by this server, the collection is read whole + assertEquals(List.of(), source.loadItems(items.get(0))); + } + + @Test + void showsDatabaseOfSourceWhenDatabasesAreNotListed() throws Exception { + DataSourceExtension.Source source = source("team_db"); + List databases = source.loadItems(null); + assertEquals(List.of("DATABASE team_db"), names(databases)); + List collections = source.loadItems(databases.get(0)); + assertEquals(List.of("COLLECTION docs", "COLLECTION faq"), names(collections)); + assertEquals(List.of(), source.loadItems(collections.get(1))); + } + + @Test + void tellsRefusedKeys() { + assertTrue(VdbDataSourceExtension.VdbSourceItem.isAccessDenied(new RestException("denied", 401, null))); + assertTrue(VdbDataSourceExtension.VdbSourceItem.isAccessDenied(new IllegalStateException(new RestException( + "Milvus: denied (code 1800)", 403, null)))); + assertFalse(VdbDataSourceExtension.VdbSourceItem.isAccessDenied(new RestException("missing", 404, null))); + assertFalse(VdbDataSourceExtension.VdbSourceItem.isAccessDenied(new IOException("refused"))); + } +} diff --git a/src/test/java/com/github/sqljam/face/vdb/VdbSyncEngineTest.java b/src/test/java/com/github/sqljam/face/vdb/VdbSyncEngineTest.java new file mode 100644 index 0000000..a58d59b --- /dev/null +++ b/src/test/java/com/github/sqljam/face/vdb/VdbSyncEngineTest.java @@ -0,0 +1,188 @@ +/* + * 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.vdb; + +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 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.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +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.SyncTask; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.face.service.ReportStore; +import com.github.sqljam.face.service.SyncScheduler; +import com.github.sqljam.face.service.SyncTaskStore; +import com.github.sqljam.face.view.DataSourceExtension; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.vdb.it.VdbServer; + +/** + * @Description: VdbSyncEngineTest syncs the Qdrant fixture into DuckDB: the first run copies every record, its + * watermark is read from the target, the next run reads the records of the watermark and later only. + * The sync support of the extension lists the collections and the fields which can be watermarks. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class VdbSyncEngineTest { + + static final VdbServer SERVER = VdbServer.QDRANT; + + @TempDir + File dir; + + private SyncScheduler scheduler; + + @BeforeAll + static void createFixture() throws Exception { + if (SERVER.isAvailable()) { + SERVER.createFixture(); + } + } + + @AfterAll + static void dropFixture() { + if (SERVER.isAvailable()) { + SERVER.dropFixture(); + } + } + + @AfterEach + void close() { + if (scheduler != null) { + scheduler.close(); + } + } + + private static long count(File duckdb, String sql) throws Exception { + try (Connection connection = DriverManager.getConnection("jdbc:duckdb:" + duckdb.getAbsolutePath()); + Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) { + rs.next(); + return rs.getLong(1); + } + } + + @Test + void syncsCollections() throws Exception { + assumeTrue(SERVER.isAvailable()); + VdbProfileStore store = new VdbProfileStore(new File(dir, VdbProfileStore.FILE_NAME)); + VdbProfile profile = VdbImportDialogTest.profile(); + store.saveProfile(profile, true); + File duckdb = new File(dir, "target.duckdb"); + ConnectionProfile target = new ConnectionProfile(); + target.setName("Target DuckDB"); + target.setDbType(DbType.DUCKDB); + target.setDatabase(duckdb.getAbsolutePath()); + Map profiles = Map.of(target.getId(), target); + + DataSourceExtension.SyncSupport support = new VdbDataSourceExtension(store).getSyncSupport(); + assertEquals(SyncTask.Kind.VECTOR, support.getKind()); + assertTrue(support.getDatabases(profile.getId()).isEmpty()); + assertTrue(support.getCollections(profile.getId(), null).contains(SERVER.getCollection())); + List fields = support.getWatermarkFields(profile.getId(), null, SERVER.getCollection()); + assertTrue(fields.contains("n") && fields.contains("score"), fields.toString()); + assertFalse(fields.contains("title")); + + scheduler = new SyncScheduler(new SyncTaskStore(new File(dir, "sync-tasks.json")), new ReportStore(new File( + dir, "reports"), 20)); + scheduler.register(SyncTask.Kind.VECTOR, support.createEngine(id -> Optional.ofNullable(profiles.get(id)))); + SyncTask task = new SyncTask(); + task.setName("vectors"); + task.setKind(SyncTask.Kind.VECTOR); + task.setSourceId(profile.getId()); + task.setTargetId(target.getId()); + SyncTask.Table table = new SyncTask.Table(SERVER.getCollection(), "n", SyncTask.WatermarkType.UPDATED_AT); + table.setTargetName("sjvt_sync"); + task.getTables().add(table); + task.setScheduleType(SyncTask.ScheduleType.MANUAL); + scheduler.getStore().saveTask(task); + + TransferReport first = scheduler.runNow(task.getId()).get(120, TimeUnit.SECONDS); + assertEquals(TransferReport.Status.COMPLETED, first.getStatus(), first.getFailure()); + assertEquals(TransferReport.Kind.VECTOR_SYNC, first.getKind()); + assertEquals("All records, the first run", first.getOptions().get("Copy")); + assertEquals(1, first.getObjects().get("Tables")); + assertEquals(VdbServer.RECORDS, first.getWrittenRows()); + assertEquals(Long.valueOf(VdbServer.RECORDS), first.getTables().get(0).getTargetRows()); + assertEquals(VdbServer.RECORDS, count(duckdb, "SELECT COUNT(*) FROM sjvt_sync")); + SyncTask.Watermark watermark = scheduler.getStore().findTask(task.getId()).orElseThrow().getTables().get(0) + .getWatermark(); + assertEquals(String.valueOf(VdbServer.RECORDS), watermark.getValue()); + + // The records of the watermark are read again and updated, the table is kept + TransferReport second = scheduler.runNow(task.getId()).get(120, TimeUnit.SECONDS); + assertEquals(TransferReport.Status.COMPLETED, second.getStatus(), second.getFailure()); + assertEquals("Records changed since the last run", second.getOptions().get("Copy")); + assertEquals(1, second.getWrittenRows()); + assertTrue(second.getObjects().isEmpty()); + assertEquals(VdbServer.RECORDS, count(duckdb, "SELECT COUNT(*) FROM sjvt_sync")); + assertEquals("n from " + VdbServer.RECORDS + " to " + VdbServer.RECORDS, second.getOptions().get( + "Watermark of " + SERVER.getCollection())); + } + + @Test + void failsWithoutTheSourceOrAField() throws Exception { + VdbProfileStore store = new VdbProfileStore(new File(dir, VdbProfileStore.FILE_NAME)); + VdbSyncEngine engine = new VdbSyncEngine(store, id -> Optional.empty()); + SyncTask task = new SyncTask(); + task.setName("gone"); + task.setKind(SyncTask.Kind.VECTOR); + task.setSourceId("missing"); + TransferReport report = new TransferReport(); + engine.describe(task, report); + assertEquals(TransferReport.Kind.VECTOR_SYNC, report.getKind()); + assertEquals("missing", report.getSource()); + try { + engine.run(task, report, com.github.sqljam.impexp.ExportListener.NONE); + throw new AssertionError("A removed source runs"); + } catch (com.github.sqljam.face.service.SyncErrors.PermanentException e) { + assertTrue(e.getMessage().contains("vector database of the task was removed")); + } + assertEquals(8L, VdbSyncEngine.queryValue(new SyncTask.Watermark("NUMBER", "8.0"))); + assertEquals(2.5, VdbSyncEngine.queryValue(new SyncTask.Watermark("NUMBER", "2.5"))); + assertEquals("2026-10-25T10:00+08:00", VdbSyncEngine.queryValue(new SyncTask.Watermark("TIMESTAMPTZ", + "2026-10-25T10:00+08:00"))); + File h2 = new File(dir, "h2"); + try (Connection connection = DriverManager.getConnection("jdbc:h2:" + h2.getAbsolutePath(), "sa", ""); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE \"docs\" (\"id\" INT, \"n\" INT)"); + statement.execute("INSERT INTO \"docs\" VALUES (1, 4), (2, 9)"); + assertEquals("9", VdbSyncEngine.readMax(connection, "PUBLIC", "DOCS", "N").getValue()); + try { + VdbSyncEngine.readMax(connection, "PUBLIC", "docs", "missing"); + throw new AssertionError("A missing column is read"); + } catch (com.github.sqljam.face.service.SyncErrors.PermanentException e) { + assertTrue(e.getMessage().contains("missing is not a column")); + } + } + } +} 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 dbc7c73..e5c6e6c 100644 --- a/src/test/java/com/github/sqljam/face/view/BrandingTest.java +++ b/src/test/java/com/github/sqljam/face/view/BrandingTest.java @@ -15,6 +15,7 @@ */ package com.github.sqljam.face.view; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -143,6 +144,55 @@ void about() { assertEquals(3, opened.size()); } + /** + * The map of the features: SqlJam in the middle, six groups on both sides joined by curves, the logos of the + * data sources, the dialog wider while it is shown + */ + @Test + void featureMap() throws Exception { + Alert about = FxTestSupport.call(() -> Dialogs.createAbout(stage, null)); + FxTestSupport.run(about::show); + javafx.scene.control.TabPane tabs = FxTestSupport.call(() -> (javafx.scene.control.TabPane) about + .getDialogPane().lookup("#aboutTabs")); + assertEquals(List.of("About", "Features"), FxTestSupport.call(() -> tabs.getTabs().stream().map( + javafx.scene.control.Tab::getText).collect(Collectors.toList()))); + FxTestSupport.run(() -> tabs.getSelectionModel().select(1)); + assertEquals(1000, FxTestSupport.call(() -> about.getDialogPane().getPrefWidth())); + FeatureMap map = FxTestSupport.call(() -> (FeatureMap) about.getDialogPane().lookup("#featureMap")); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> about.getDialogPane().getScene().getWindow() + .getWidth() >= 990)); + // The layout of the dialog, not a size of the test + FxTestSupport.run(() -> { + about.getDialogPane().applyCss(); + about.getDialogPane().layout(); + }); + assertTrue(FxTestSupport.call(map::getWidth) >= FxTestSupport.call(() -> map.prefWidth(-1)) - 1); + List cards = map.getCards(); + assertEquals(6, cards.size()); + List all = FxTestSupport.call(() -> java.util.stream.IntStream.range(0, cards.size()) + .mapToObj(map::getCardBounds).collect(Collectors.toList())); + for (int i = 0; i < cards.size(); i++) { + javafx.geometry.Bounds bounds = all.get(i); + for (int j = i + 1; j < cards.size(); j++) { + javafx.geometry.Bounds other = all.get(j); + assertFalse(bounds.intersects(other), FeatureMap.GROUPS[i] + " and " + FeatureMap.GROUPS[j]); + } + } + // Left and right of the middle + assertTrue(all.get(0).getMaxX() < all.get(3).getMinX()); + // Every group has items, the data sources show their logos + assertTrue(FxTestSupport.call(() -> cards.stream().allMatch(card -> card.getChildren().size() > 2))); + assertTrue(FxTestSupport.call(() -> ((javafx.scene.layout.FlowPane) map.lookup("#featureLogos")) + .getChildren().size() > 5)); + File image = new File("target/screenshots/feature-map.png"); + image.getParentFile().mkdirs(); + DocImages.write(FxTestSupport.call(() -> about.getDialogPane().getScene().snapshot(null)), image); + // Back to About, the dialog is narrow again + FxTestSupport.run(() -> tabs.getSelectionModel().select(0)); + assertEquals(500, FxTestSupport.call(() -> about.getDialogPane().getPrefWidth())); + FxTestSupport.run(about::close); + } + @Test void repositoryMark() { Label mark = FxTestSupport.call(() -> Branding.repositoryMark(opened::add)); diff --git a/src/test/java/com/github/sqljam/face/view/ColumnMappingDialogTest.java b/src/test/java/com/github/sqljam/face/view/ColumnMappingDialogTest.java new file mode 100644 index 0000000..ea48a28 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/ColumnMappingDialogTest.java @@ -0,0 +1,211 @@ +/* + * 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.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterEach; +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.view.ColumnMappingDialog.Mapping; +import com.github.sqljam.impexp.ColumnMapping; +import javafx.application.Platform; +import javafx.event.ActionEvent; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.TableView; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; + +/** + * @Description: ColumnMappingDialogTest maps the columns of two tables in the dialog: names, types and columns left + * out, a required column which is always imported, two columns of the same name and a table without + * columns which are refused, reset, and the mappings of the dialog as mappings of an import + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class ColumnMappingDialogTest { + + private Stage stage; + + @Start + void start(Stage stage) { + this.stage = stage; + stage.setScene(new Scene(new StackPane(), 400, 300)); + stage.show(); + } + + @AfterEach + void close() { + FxTestSupport.closeAllDialogs(); + } + + private static List columns(String table) { + if ("ORDERS".equals(table)) { + return List.of(new ColumnMappingDialog.Column("ID", "BIGINT", true), new ColumnMappingDialog.Column( + "NOTE", "VARCHAR(100)", false), new ColumnMappingDialog.Column("SECRET", "VARCHAR(20)", false)); + } + if ("BROKEN".equals(table)) { + throw new IllegalStateException("no access"); + } + return List.of(new ColumnMappingDialog.Column("NAME", "VARCHAR(20)", false)); + } + + private ColumnMappingDialog open(Map> chosen, + AtomicReference>>> result) { + ColumnMappingDialog dialog = FxTestSupport.call(() -> new ColumnMappingDialog(stage, List.of("ORDERS", + "CUSTOMERS", "BROKEN"), ColumnMappingDialogTest::columns, chosen)); + Platform.runLater(() -> result.set(dialog.showAndWait())); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> dialog.getRows().size() == 3)); + return dialog; + } + + private static ColumnMappingDialog.Row row(ColumnMappingDialog dialog, String column) { + return FxTestSupport.call(() -> dialog.getRows().stream().filter(row -> row.column.getName().equals(column)) + .findFirst().orElseThrow()); + } + + private static String error(ColumnMappingDialog dialog) { + return FxTestSupport.call(() -> ((Label) dialog.getDialogPane().lookup("#mappingError")).getText()); + } + + @Test + void mapsColumnsOfTables() { + AtomicReference>>> result = new AtomicReference<>(); + // A mapping chosen before is shown again + ColumnMappingDialog dialog = open(Map.of("ORDERS", Map.of("NOTE", new Mapping("REMARK", null, false))), + result); + assertEquals("REMARK", FxTestSupport.call(() -> row(dialog, "NOTE").targetName.get())); + assertEquals("1 columns are mapped", FxTestSupport.call(() -> ((Label) dialog.getDialogPane().lookup( + "#mappingStatus")).getText())); + FxTestSupport.run(() -> { + row(dialog, "NOTE").targetType.set("TEXT"); + row(dialog, "SECRET").imported.set(false); + dialog.validate(); + }); + // The required id is always imported, its check box is disabled + FxTestSupport.run(() -> { + TableView table = (TableView) dialog.getDialogPane().lookup("#mappingColumnTable"); + table.applyCss(); + table.layout(); + }); + assertTrue(FxTestSupport.call(() -> dialog.getDialogPane().lookupAll(".check-box").stream().anyMatch( + node -> node.isDisabled()))); + + // Another table keeps the mappings of the first one + ComboBox tables = FxTestSupport.call(() -> (ComboBox) dialog.getDialogPane().lookup( + "#mappingTableCombo")); + assertTrue(FxTestSupport.call(tables::isVisible)); + FxTestSupport.run(() -> dialog.setTable("CUSTOMERS")); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> dialog.getRows().size() == 1)); + FxTestSupport.run(() -> row(dialog, "NAME").targetName.set("FULL_NAME")); + // A table which can not be read shows its error + FxTestSupport.run(() -> dialog.setTable("BROKEN")); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> ((TableView) dialog.getDialogPane().lookup( + "#mappingColumnTable")).getPlaceholder() instanceof Label && ((Label) ((TableView) dialog + .getDialogPane().lookup("#mappingColumnTable")).getPlaceholder()).getText().contains("no access"))); + FxTestSupport.run(() -> dialog.setTable("ORDERS")); + assertEquals("TEXT", FxTestSupport.call(() -> row(dialog, "NOTE").targetType.get())); + + FxTestSupport.run(() -> ((Button) dialog.getDialogPane().lookupButton(ButtonType.OK)).fire()); + FxTestSupport.waitUntil(() -> result.get() != null); + Map> mappings = result.get().orElseThrow(); + assertEquals(new Mapping("REMARK", "TEXT", false), mappings.get("ORDERS").get("NOTE")); + assertEquals(new Mapping(null, null, true), mappings.get("ORDERS").get("SECRET")); + assertFalse(mappings.get("ORDERS").containsKey("ID")); + assertEquals(new Mapping("FULL_NAME", null, false), mappings.get("CUSTOMERS").get("NAME")); + + // As mappings of an import + Map> converted = ExportDialog.toColumnMappings(mappings); + assertEquals(new ColumnMapping("REMARK", "TEXT", false), converted.get("ORDERS").get("NOTE")); + assertEquals(ColumnMapping.skip(), converted.get("ORDERS").get("SECRET")); + assertEquals("skipped", new Mapping(null, null, true).toString()); + assertEquals("A TEXT", new Mapping("A", "TEXT", false).toString()); + assertEquals(new Mapping("A", null, false).hashCode(), new Mapping(" A ", "", false).hashCode()); + assertFalse(new Mapping("A", null, false).equals("A")); + } + + @Test + void refusesMappingsWhichCanNotBeImported() { + AtomicReference>>> result = new AtomicReference<>(); + ColumnMappingDialog dialog = open(Map.of(), result); + Button ok = FxTestSupport.call(() -> (Button) dialog.getDialogPane().lookupButton(ButtonType.OK)); + // Two columns of the same name, in another case too + FxTestSupport.run(() -> { + row(dialog, "SECRET").targetName.set("note"); + dialog.validate(); + }); + assertTrue(error(dialog).contains("are both mapped to note"), error(dialog)); + assertTrue(FxTestSupport.call(ok::isDisabled)); + // A column of the same name as its source is no mapping + FxTestSupport.run(() -> { + row(dialog, "SECRET").targetName.set("SECRET"); + dialog.validate(); + }); + assertFalse(FxTestSupport.call(ok::isDisabled)); + assertTrue(FxTestSupport.call(() -> dialog.getMappings().isEmpty())); + + // No column of a table is imported + FxTestSupport.run(() -> dialog.setTable("CUSTOMERS")); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> dialog.getRows().size() == 1)); + FxTestSupport.run(() -> { + row(dialog, "NAME").imported.set(false); + dialog.validate(); + }); + assertTrue(error(dialog).contains("No column of CUSTOMERS is imported"), error(dialog)); + + // Reset shows the columns as they are + FxTestSupport.run(() -> dialog.getDialogPane().lookupButton(dialog.getDialogPane().getButtonTypes().get(0)) + .fireEvent(new ActionEvent())); + assertTrue(FxTestSupport.call(() -> row(dialog, "NAME").imported.get())); + assertEquals("All columns are imported as they are", FxTestSupport.call(() -> ((Label) dialog + .getDialogPane().lookup("#mappingStatus")).getText())); + + // Cancel keeps nothing + FxTestSupport.run(() -> ((Button) dialog.getDialogPane().lookupButton(ButtonType.CANCEL)).fire()); + FxTestSupport.waitUntil(() -> result.get() != null); + assertTrue(result.get().isEmpty()); + } + + @Test + void hidesChoiceOfOneTable() { + ColumnMappingDialog dialog = FxTestSupport.call(() -> new ColumnMappingDialog(stage, List.of("CUSTOMERS"), + ColumnMappingDialogTest::columns, Map.of("ORDERS", Map.of("NOTE", new Mapping("REMARK", null, + false))))); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> dialog.getRows().size() == 1)); + assertFalse(FxTestSupport.call(() -> dialog.getDialogPane().lookup("#mappingTableCombo").getParent() + .isVisible())); + assertEquals("CUSTOMERS", FxTestSupport.call(dialog::getTable)); + // Mappings of tables which were not opened are kept + assertEquals(new Mapping("REMARK", null, false), FxTestSupport.call(dialog::getMappings).get("ORDERS") + .get("NOTE")); + } +} 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 941cf19..6d43f26 100644 --- a/src/test/java/com/github/sqljam/face/view/ConnectionFormTest.java +++ b/src/test/java/com/github/sqljam/face/view/ConnectionFormTest.java @@ -23,6 +23,9 @@ import java.io.File; import java.nio.file.Files; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -32,7 +35,9 @@ 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 com.github.sqljam.utils.SshSettings; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ButtonType; @@ -91,9 +96,21 @@ private String validate() { return FxTestSupport.call(form::validate); } + /** + * The types follow the category, the categories offer every type + */ @Test void listsOnlySupportedDatabaseTypes() { - assertEquals(DbType.values().length, FxTestSupport.call(() -> dbTypeCombo().getItems().size())); + Set types = EnumSet.noneOf(DbType.class); + for (DbCategory category : DbCategory.values()) { + FxTestSupport.run(() -> form.setCategory(category)); + List items = FxTestSupport.call(() -> List.copyOf(dbTypeCombo().getItems())); + assertTrue(items.stream().allMatch(type -> type.getCategory() == category), items.toString()); + types.addAll(items); + } + assertEquals(EnumSet.allOf(DbType.class), types); + assertEquals(DbCategory.OLAP, FxTestSupport.call(form::getCategory)); + assertNull(form.getSelectedExtension()); } @Test @@ -382,4 +399,93 @@ void invalidFormIsNotSaved() { .getText())); FxTestSupport.run(dialog::close); } + @Test + @SuppressWarnings("unchecked") + void editsSshTunnel() throws Exception { + fill(DbType.MYSQL, "localhost", "3306", "shop"); + CheckBox enabled = (CheckBox) form.lookup("#sshEnabledCheck"); + assertFalse(FxTestSupport.call(() -> form.lookup("#sshGrid").isVisible())); + assertNull(FxTestSupport.call(form::getProfile).getSsh()); + FxTestSupport.run(() -> enabled.setSelected(true)); + assertTrue(FxTestSupport.call(() -> form.lookup("#sshGrid").isVisible())); + // An enabled tunnel needs its host, user and key + assertEquals("Please enter the SSH host", FxTestSupport.call(form::validate)); + FxTestSupport.run(() -> field("sshHostField").setText("bastion")); + FxTestSupport.run(() -> field("sshPortField").setText("0")); + assertEquals("Please enter an SSH port from 1 to 65535", FxTestSupport.call(form::validate)); + FxTestSupport.run(() -> field("sshPortField").setText("2222")); + assertEquals("Please enter the SSH username", FxTestSupport.call(form::validate)); + FxTestSupport.run(() -> { + field("sshUsernameField").setText("fred"); + field("sshPasswordField").setText("ssh-pw"); + }); + assertNull(FxTestSupport.call(form::validate)); + SshSettings settings = FxTestSupport.call(form::getProfile).getSsh(); + assertTrue(settings.isEnabled()); + assertEquals("bastion", settings.getHost()); + assertEquals(2222, settings.getPort()); + assertEquals("ssh-pw", settings.getPassword()); + + // The key and its passphrase instead of the password + ComboBox authentication = (ComboBox) form.lookup( + "#sshAuthenticationCombo"); + FxTestSupport.run(() -> authentication.setValue(SshSettings.Authentication.KEY)); + assertFalse(FxTestSupport.call(() -> form.lookup("#sshPasswordField").isVisible())); + assertTrue(FxTestSupport.call(() -> form.lookup("#sshKeyFileField").isVisible())); + assertEquals("Please choose the private key file", FxTestSupport.call(form::validate)); + FxTestSupport.run(() -> { + field("sshKeyFileField").setText("~/.ssh/id_ed25519"); + field("sshPassphraseField").setText("phrase"); + }); + settings = FxTestSupport.call(form::getProfile).getSsh(); + assertNull(settings.getPassword()); + assertEquals("phrase", settings.getPassphrase()); + assertEquals("~/.ssh/id_ed25519", settings.getPrivateKeyFile()); + assertEquals("Private key", FxTestSupport.call(() -> authentication.getButtonCell().getText())); + + // Shown again with the profile, hidden for a database file + ConnectionProfile profile = FxTestSupport.call(form::getProfile); + FxTestSupport.run(() -> form.setProfile(new ConnectionProfile(), true)); + assertFalse(FxTestSupport.call(enabled::isSelected)); + FxTestSupport.run(() -> form.setProfile(profile, true)); + assertTrue(FxTestSupport.call(enabled::isSelected)); + assertEquals("phrase", FxTestSupport.call(() -> field("sshPassphraseField").getText())); + FxTestSupport.run(() -> dbTypeCombo().setValue(DbType.SQLITE)); + assertFalse(FxTestSupport.call(() -> form.lookup("#sshForm").isVisible())); + assertNull(FxTestSupport.call(form::getProfile).getSsh()); + } + + /** + * The secret of the tunnel follows Remember password, like the password of the database + */ + @Test + void keepsSshSecretsOfSession() throws Exception { + ConnectionProfile profile = new ConnectionProfile(); + profile.setName("tunneled"); + profile.setDbType(DbType.MYSQL); + profile.setHostname("localhost"); + profile.setPort(3306); + profile.setPassword("db-pw"); + SshSettings ssh = new SshSettings(); + ssh.setEnabled(true); + ssh.setHost("bastion"); + ssh.setUsername("fred"); + ssh.setPassword("ssh-pw"); + profile.setSsh(ssh); + context.getProfileRegistry().saveProfile(profile, false); + String json = Files.readString(new File(dir, "connections.json").toPath()); + assertTrue(json.contains("bastion"), json); + assertFalse(json.contains("ssh-pw") || json.contains("db-pw"), json); + assertFalse(json.contains("sshEnabled") || json.contains("jdbcUrl"), json); + ConnectionProfile loaded = context.getProfileRegistry().findProfile(profile.getId()).orElseThrow(); + assertEquals("ssh-pw", loaded.getSsh().getPassword()); + assertTrue(loaded.isSshEnabled()); + // The settings are copied, not shared + loaded.getSsh().setHost("other"); + assertEquals("bastion", context.getProfileRegistry().findProfile(profile.getId()).orElseThrow().getSsh() + .getHost()); + context.getProfileRegistry().saveProfile(loaded, true); + assertTrue(Files.readString(new File(dir, "connections.json").toPath()).contains("ssh-pw")); + context.getProfileRegistry().removeProfile(profile.getId()); + } } diff --git a/src/test/java/com/github/sqljam/face/view/DashboardPaneTest.java b/src/test/java/com/github/sqljam/face/view/DashboardPaneTest.java new file mode 100644 index 0000000..3f7d854 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/DashboardPaneTest.java @@ -0,0 +1,221 @@ +/* + * Copyright 2023-2026 Fred Feng + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.sqljam.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.io.File; +import java.sql.SQLException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +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.face.service.JdbcMetrics; +import com.github.sqljam.face.service.MetricSource; +import com.github.sqljam.face.service.MetricSource.Chart; +import com.github.sqljam.face.service.MetricSource.Series; +import com.github.sqljam.face.service.MetricSource.Unit; +import javafx.scene.Scene; +import javafx.scene.chart.LineChart; +import javafx.scene.chart.NumberAxis; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.TabPane; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; + +/** + * @Description: DashboardPaneTest shows the live charts of a dashboard: a chart a group of metrics with a line a + * metric, the values in their units, charts whose metrics the server does not tell are left out and + * listed, a lost connection is told, samples pause, resume and stop with the dashboard, whose + * metrics are closed with it. The info tab of a data source samples while its dashboard is shown. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class DashboardPaneTest { + + @TempDir + File dir; + + private Stage stage; + + @Start + void start(Stage stage) { + this.stage = stage; + FxTestSupport.createContext(dir); + stage.setScene(new Scene(new StackPane(), 1200, 800)); + stage.show(); + } + + /** + * Sessions grow by one a sample, commits by 10, the cache is not told + */ + private static final class CountingSource implements MetricSource { + + final AtomicInteger reads = new AtomicInteger(); + final AtomicBoolean closed = new AtomicBoolean(); + volatile boolean failing; + + @Override + public List getCharts() { + return List.of(new Chart("Sessions", Unit.COUNT, Series.gauge("Active", "active"), Series.gauge("Idle", + "idle")), new Chart("Commits", Unit.PER_SECOND, Series.rate("Commits", "commits")), new Chart( + "Cache", Unit.PERCENT, Series.ratio("Hits", "hits", "hits", "reads")), new Chart("Size", + Unit.BYTES, Series.gauge("Size", "size"))); + } + + @Override + public Map read() throws Exception { + if (failing) { + throw new SQLException("Connection reset"); + } + int read = reads.incrementAndGet(); + return Map.of("active", (double) read, "idle", 2.0, "commits", read * 10.0, "size", 3.5 * 1024 * 1024); + } + + @Override + public Map getUnavailable() { + return Map.of("hits", "permission denied for pg_statio"); + } + + @Override + public void close() { + closed.set(true); + } + } + + @Test + void showsLiveCharts() { + CountingSource source = new CountingSource(); + DashboardPane pane = FxTestSupport.call(() -> { + DashboardPane created = new DashboardPane(() -> source); + ((StackPane) stage.getScene().getRoot()).getChildren().setAll(created); + return created; + }); + FxTestSupport.run(() -> ((ComboBox) pane.lookup("#dashboardIntervalCombo")).setValue(1)); + FxTestSupport.run(pane::start); + FxTestSupport.waitUntil(() -> source.reads.get() >= 3); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> pane.getCharts().size() == 4 && pane.getCharts() + .get(0).getData().get(0).getData().size() >= 3)); + List> charts = FxTestSupport.call(pane::getCharts); + assertEquals("Sessions", charts.get(0).getTitle()); + assertEquals(2, charts.get(0).getData().size()); + assertTrue(charts.get(0).isLegendVisible()); + assertEquals("Commits (/s)", charts.get(1).getTitle()); + // Rates from the second sample on: 10 commits a second + double rate = FxTestSupport.call(() -> charts.get(1).getData().get(0).getData().get(0).getYValue() + .doubleValue()); + assertTrue(rate > 5 && rate < 20, String.valueOf(rate)); + // The cache is not told: its chart is left out and listed with the reason + assertFalse(FxTestSupport.call(() -> charts.get(2).isVisible())); + assertTrue(FxTestSupport.call(() -> pane.getUnavailableLabel().getText()).contains( + "Cache / Hits: permission denied for pg_statio")); + assertEquals("3.5 MB", FxTestSupport.call(() -> ((NumberAxis) charts.get(3).getYAxis()) + .getTickLabelFormatter().toString(3.5 * 1024 * 1024))); + assertTrue(FxTestSupport.call(() -> pane.getStatusLabel().getText()).startsWith("Sampled at ")); + + // Paused: no samples, resumed: samples again + Button pause = (Button) pane.lookup("#dashboardPauseButton"); + FxTestSupport.run(pause::fire); + assertEquals("Resume", FxTestSupport.call(pause::getText)); + int paused = source.reads.get(); + org.testfx.util.WaitForAsyncUtils.sleep(1500, java.util.concurrent.TimeUnit.MILLISECONDS); + assertTrue(source.reads.get() <= paused + 1); + FxTestSupport.run(pause::fire); + FxTestSupport.waitUntil(() -> source.reads.get() > paused + 1); + + // Lost: told, the charts stay + source.failing = true; + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> pane.getStatusLabel().getText()).startsWith( + "Not connected, tried again with the next sample: Connection reset")); + source.failing = false; + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> pane.getStatusLabel().getText()).startsWith( + "Sampled at ")); + FxTestSupport.run(pane::close); + FxTestSupport.waitUntil(source.closed::get); + } + + @Test + void formatsValues() { + assertEquals("512 B", DashboardPane.format(Unit.BYTES, 512)); + assertEquals("2.0 KB", DashboardPane.format(Unit.BYTES_PER_SECOND, 2048)); + assertEquals("1.5 GB", DashboardPane.format(Unit.BYTES, 1.5 * 1024 * 1024 * 1024)); + assertEquals("42", DashboardPane.format(Unit.COUNT, 42)); + assertEquals("2.5", DashboardPane.format(Unit.PER_SECOND, 2.5)); + assertEquals("12k", DashboardPane.format(Unit.PER_SECOND, 12_345)); + assertEquals("3.2M", DashboardPane.format(Unit.COUNT, 3_210_000)); + assertEquals(" (%)", DashboardPane.unitSuffix(Unit.PERCENT)); + assertEquals(" (ms)", DashboardPane.unitSuffix(Unit.MILLIS)); + assertEquals(" (bytes/s)", DashboardPane.unitSuffix(Unit.BYTES_PER_SECOND)); + assertEquals("", DashboardPane.unitSuffix(Unit.BYTES)); + } + + /** + * The info tab of a data source: its dashboard samples while it is shown + */ + @Test + void samplesTheDashboardOfADataSource() throws Exception { + ConnectionProfile profile = UiDatabase.create(dir, "Embedded H2"); + DatabaseSession session = new DatabaseSession(profile); + AtomicInteger created = new AtomicInteger(); + java.util.concurrent.atomic.AtomicReference metrics = new java.util.concurrent.atomic + .AtomicReference<>(); + InfoTab tab = FxTestSupport.call(() -> { + InfoTab info = new InfoTab(profile.getName(), null, () -> Map.of(), () -> Map.of(), () -> { + created.incrementAndGet(); + metrics.set(new JdbcMetrics(profile, session)); + return metrics.get(); + }); + TabPane tabs = new TabPane(info); + ((StackPane) stage.getScene().getRoot()).getChildren().setAll(tabs); + return info; + }); + DashboardPane pane = tab.getDashboard(); + // Not shown: not sampled + org.testfx.util.WaitForAsyncUtils.sleep(500, java.util.concurrent.TimeUnit.MILLISECONDS); + assertEquals(0, created.get()); + FxTestSupport.run(tab::showDashboard); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> pane.getCharts().size() == 5 && !pane.getCharts() + .get(2).getData().get(0).getData().isEmpty())); + assertEquals("Sessions", FxTestSupport.call(() -> pane.getCharts().get(2).getTitle())); + assertEquals("", FxTestSupport.call(() -> pane.getUnavailableLabel().getText())); + assertEquals(1, created.get()); + // Another tab is shown: no samples + FxTestSupport.run(() -> { + tab.getTabPane().getTabs().add(new javafx.scene.control.Tab("other")); + tab.getTabPane().getSelectionModel().select(1); + }); + int points = FxTestSupport.call(() -> pane.getCharts().get(2).getData().get(0).getData().size()); + org.testfx.util.WaitForAsyncUtils.sleep(4500, java.util.concurrent.TimeUnit.MILLISECONDS); + assertTrue(FxTestSupport.call(() -> pane.getCharts().get(2).getData().get(0).getData().size()) <= points + 1); + // Removed with its data source: the dashboard is closed, its connection too + FxTestSupport.run(() -> tab.getTabPane().getTabs().remove(tab)); + FxTestSupport.waitUntil(() -> metrics.get().getConnection() == null); + session.close(); + } +} diff --git a/src/test/java/com/github/sqljam/face/view/DbTypeCellsTest.java b/src/test/java/com/github/sqljam/face/view/DbTypeCellsTest.java index 0742158..2925591 100644 --- a/src/test/java/com/github/sqljam/face/view/DbTypeCellsTest.java +++ b/src/test/java/com/github/sqljam/face/view/DbTypeCellsTest.java @@ -115,19 +115,4 @@ void showsHeadersInTypeCombo() { }).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/DocImages.java b/src/test/java/com/github/sqljam/face/view/DocImages.java new file mode 100644 index 0000000..65846b9 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/DocImages.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.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; + +import javax.imageio.ImageIO; +import javafx.scene.image.PixelReader; +import javafx.scene.image.WritableImage; + +/** + * @Description: DocImages writes snapshots of JavaFX as PNG files of the documents, e.g. the screenshots and the + * benchmark chart + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public final class DocImages { + + private DocImages() { + } + + public 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); + } +} diff --git a/src/test/java/com/github/sqljam/face/view/DocScreenshots.java b/src/test/java/com/github/sqljam/face/view/DocScreenshots.java index 0fe18af..f4695a2 100644 --- a/src/test/java/com/github/sqljam/face/view/DocScreenshots.java +++ b/src/test/java/com/github/sqljam/face/view/DocScreenshots.java @@ -15,35 +15,49 @@ */ package com.github.sqljam.face.view; -import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; +import java.util.Arrays; import java.util.List; -import javax.imageio.ImageIO; +import org.junit.jupiter.api.Assumptions; 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.service.DatabaseSession; import com.github.sqljam.face.model.ConnectionProfile; import com.github.sqljam.face.model.TableInfo; import com.github.sqljam.face.model.TransferRequest; +import com.github.sqljam.face.vdb.VdbImportDialog; +import com.github.sqljam.face.vdb.VdbProfile; +import com.github.sqljam.face.vdb.VdbProfileStore; 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 com.github.sqljam.it.ItDatabase; +import com.github.sqljam.utils.SshSettings; +import com.github.sqljam.vdb.VdbType; +import com.github.sqljam.vdb.it.VdbServer; +import javafx.application.Platform; +import javafx.scene.Node; import javafx.scene.Parent; import javafx.scene.Scene; import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; import javafx.scene.control.Dialog; +import javafx.scene.control.DialogPane; +import javafx.scene.control.Label; import javafx.scene.control.ListView; import javafx.scene.control.RadioButton; import javafx.scene.control.TabPane; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; 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; @@ -60,6 +74,10 @@ class DocScreenshots { private static final String STYLESHEET = "/com/github/sqljam/face/app.css"; + /** + * Database of the fixture tables of MySQL + */ + private static final String CATALOG = ItDatabase.MYSQL.getSourceCatalog(); @TempDir File dir; @@ -75,8 +93,10 @@ void start(Stage stage) throws Exception { 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"); + // The MySQL of the integration tests with its fixture tables (sjm_...), 192.168.0.63 by default + ItDatabase.MYSQL.loadFixture(); + mysql = ItDatabase.MYSQL.getSourceProfile(); + mysql.setName("MySQL test"); context.getProfileRegistry().saveProfile(mysql, true); saveProfile("MariaDB demo", DbType.MARIADB, "localhost", 3307, "demo", "fengy"); saveProfile("PostgreSQL demo", DbType.POSTGRESQL, "localhost", 5432, "demo", "fengy"); @@ -144,16 +164,7 @@ private void render(Parent root, double width, double height, String name, Runna } 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); + DocImages.write(image, file); } private static void sleep(long millis) { @@ -173,16 +184,22 @@ void splash() throws IOException { render(splash, SplashView.WIDTH, SplashView.HEIGHT, "splash.png"); } + /** + * The data source dialog: its category and type, the fields of the type and its SSH tunnel + */ @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); - }); + void dataSource() throws IOException { + ConnectionProfile profile = context.getProfileRegistry().getProfiles().stream().filter(candidate -> + "PostgreSQL demo".equals(candidate.getName())).findFirst().orElseThrow(); + SshSettings ssh = new SshSettings(); + ssh.setEnabled(true); + ssh.setHost("bastion.example.com"); + ssh.setUsername("deploy"); + ssh.setAuthentication(SshSettings.Authentication.KEY); + ssh.setPrivateKeyFile("~/.ssh/id_ed25519"); + profile.setSsh(ssh); + ConnectionDialog dialog = FxTestSupport.call(() -> new ConnectionDialog(stage, context, profile)); + snapshotDialog(dialog, 640, 760, "data-source.png"); } @@ -190,7 +207,7 @@ void login() throws IOException { * 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)); + return DbNode.table(mysql, new TableInfo(CATALOG, null, table, null, false, false)); } /** @@ -209,11 +226,11 @@ private MainView openTable(String table, double width, double height) { mainView.layout(); }); ConnectionTree tree = FxTestSupport.call(() -> (ConnectionTree) mainView.lookup("#connectionTree")); - TreeItem connection = FxTestSupport.call(() -> tree.getRoot().getChildren().stream() + TreeItem connection = FxTestSupport.call(() -> tree.getConnectionItems().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()); + .filter(item -> CATALOG.equals(item.getValue().getLabel())).findFirst().orElseThrow()); expand(catalog); FxTestSupport.run(() -> { // The fixture tables with the partitioned ones (sjm_pt_..., sjm_sales) are in view, the table selected @@ -285,7 +302,7 @@ void sqlEditor() throws IOException { FxTestSupport.run(() -> { TabPane tables = (TabPane) mainView.lookup("#tableTabs"); tables.getTabs().clear(); - mainView.openSqlTab(DbNode.catalog(mysql, "test")); + mainView.openSqlTab(DbNode.catalog(mysql, CATALOG)); }); SqlTab tab = FxTestSupport.call(() -> (SqlTab) ((TabPane) mainView.lookup("#tableTabs")).getTabs().get(0)); FxTestSupport.run(() -> tab.getEditor().setText("-- Employees with their departments\n" @@ -349,7 +366,7 @@ private File exportParquetPackage() throws Exception { File packageDir = new File(dir, "mysql-parquet"); TransferRequest request = new TransferRequest(); request.setSource(mysql); - request.setSourceCatalog("test"); + request.setSourceCatalog(CATALOG); request.setTables(List.of("sjm_dept", "sjm_emp", "sjm_emp_tag", "sjm_sales")); request.setTarget(TransferRequest.Target.SCRIPT); request.setOutputDirectory(packageDir); @@ -383,4 +400,251 @@ void parquetPackage() throws Exception { FxTestSupport.run(() -> parquetDialog.addFiles(List.of(new File(packageDir, "data/sjm_emp.parquet")))); snapshotDialog(parquetDialog, 760, 753, "import-parquet.png"); } + + /** + * A Qdrant collection of the tree in its tab, its Info page and the wizard which imports it + */ + @Test + void vectorDatabase() throws Exception { + VdbServer server = VdbServer.QDRANT; + Assumptions.assumeTrue(server.isAvailable()); + server.createFixture(); + try { + VdbProfile profile = new VdbProfile(); + profile.setName("Qdrant docs"); + profile.setType(VdbType.QDRANT); + profile.setUrl(server.getSource().getUrl()); + profile.setApiKey(server.getSource().getApiKey()); + VdbProfileStore.getInstance().saveProfile(profile, true); + MainView mainView = FxTestSupport.call(() -> new MainView(context)); + FxTestSupport.run(() -> { + Scene scene = new Scene(mainView, 1280, 753); + scene.getStylesheets().add(DocScreenshots.class.getResource(STYLESHEET).toExternalForm()); + stage.setScene(scene); + stage.setWidth(1280); + stage.setHeight(753); + mainView.applyCss(); + mainView.layout(); + }); + ConnectionTree tree = FxTestSupport.call(() -> (ConnectionTree) mainView.lookup("#connectionTree")); + TreeItem qdrant = FxTestSupport.call(() -> tree.getRoot().getChildren().stream().flatMap(group + -> group.getChildren().stream()).filter(item -> "Qdrant docs".equals(item.getValue().getLabel())) + .findFirst().orElseThrow()); + expand(qdrant); + TreeItem collection = FxTestSupport.call(() -> qdrant.getChildren().stream().filter(item -> server + .getCollection().equals(item.getValue().getLabel())).findFirst().orElseThrow()); + FxTestSupport.run(() -> { + tree.getSelectionModel().select(collection); + mainView.openSourceItem(collection.getValue()); + }); + TabPane contents = FxTestSupport.call(() -> (TabPane) ((TabPane) mainView.lookup("#tableTabs")) + .getSelectionModel().getSelectedItem().getContent()); + FxTestSupport.run(() -> contents.getSelectionModel().select(1)); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> { + TableView data = (TableView) contents.lookup("#vdbDataTable"); + return data != null && data.getItems().size() == VdbServer.RECORDS; + })); + snapshot("vdb-collection.png"); + + FxTestSupport.run(() -> { + tree.getSelectionModel().select(qdrant); + mainView.openInfo(qdrant.getValue()); + }); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> { + Label refreshed = (Label) mainView.lookup("#infoRefreshedLabel"); + return refreshed != null && !refreshed.getText().isEmpty(); + })); + // The address of the server of the tests is not shown in the documents + FxTestSupport.run(() -> { + @SuppressWarnings("unchecked") + TableView> overview = (TableView>) mainView.lookup("#infoOverviewTable"); + overview.getItems().replaceAll(row -> "URL".equals(row.get(0).trim()) ? Arrays.asList(row.get(0), + "http://qdrant-host:6333", row.get(2)) : row); + }); + snapshot("database-info.png"); + + VdbImportDialog dialog = FxTestSupport.call(() -> new VdbImportDialog(stage, context, + VdbProfileStore.getInstance(), profile, null, List.of(server.getCollection()), null)); + // The targets are probed in the background, a target with vectors is chosen when they are known + FxTestSupport.run(() -> { + dialog.getDialogPane().setPrefSize(900, 860); + dialog.show(); + }); + @SuppressWarnings("unchecked") + ComboBox targets = FxTestSupport.call(() -> (ComboBox) dialog + .getDialogPane().lookup("#vdbTargetCombo")); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> targets.getItems().stream().anyMatch(target + -> target.getDbType() == DbType.DUCKDB))); + FxTestSupport.run(() -> targets.getItems().stream().filter(target -> target.getDbType() == DbType.DUCKDB) + .findFirst().ifPresent(targets::setValue)); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> dialog.getDialogPane().lookup("#vdbStatus") + .getStyleClass().contains("status-success"))); + sleep(1500); + write(FxTestSupport.call(() -> dialog.getDialogPane().getScene().snapshot(null)), new File(outputDir(), + "import-vdb.png")); + FxTestSupport.run(dialog::close); + } finally { + server.dropFixture(); + } + } + + private MainView mainWindow(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); + mainView.applyCss(); + mainView.layout(); + }); + return mainView; + } + + private ConnectionProfile profile(String name) { + return context.getProfileRegistry().getProfiles().stream().filter(profile -> name.equals(profile.getName())) + .findFirst().orElseThrow(); + } + + /** + * The migration report of a copy of the MySQL fixture into H2, opened by View Report + */ + @Test + void migrationReport() throws Exception { + MainView mainView = mainWindow(1280, 753); + com.github.sqljam.face.model.TransferRequest request = new com.github.sqljam.face.model.TransferRequest(); + request.setSource(mysql); + request.setSourceCatalog(CATALOG); + request.setTables(List.of("sjm_dept", "sjm_emp", "sjm_emp_tag")); + request.setTarget(com.github.sqljam.face.model.TransferRequest.Target.DATABASE); + request.setTargetProfile(profile("H2 demo")); + request.setTargetSchema("PUBLIC"); + request.setFailFast(false); + ProgressDialog progress = FxTestSupport.call(() -> mainView.startTransfer(request, 3)); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> progress.getStage().getScene().getRoot().lookup( + "#viewReportButton").isVisible())); + FxTestSupport.run(() -> { + ((Button) progress.getStage().getScene().getRoot().lookup("#viewReportButton")).fire(); + progress.getStage().close(); + }); + snapshot("report.png"); + // The history of the imports lists the report with the earlier ones + FxTestSupport.run(mainView::openHistory); + snapshot("import-history.png"); + } + + /** + * Columns of the employees mapped for the target: a new name, a type of the target and a column left out + */ + @Test + void columnMapping() throws Exception { + DatabaseSession session = context.getSessionManager().getSession(mysql); + ColumnMappingDialog.ColumnLoader loader = table -> session.getColumns(CATALOG, null, table).stream().map( + column -> new ColumnMappingDialog.Column(column.getName(), column.getTypeName() + (column.getSize() + > 0 && column.getTypeName().toUpperCase(java.util.Locale.ENGLISH).matches( + "VARCHAR|CHAR|DECIMAL") ? "(" + column.getSize() + (column.getScale() > 0 ? "," + column + .getScale() : "") + ")" : ""), column.isPrimaryKey())).collect(java.util.stream.Collectors + .toList()); + java.util.Map> chosen = java.util.Map.of( + "sjm_emp", java.util.Map.of("name", new ColumnMappingDialog.Mapping("full_name", null, false), + "salary", new ColumnMappingDialog.Mapping(null, "DECIMAL(14,2)", false), + "hired", new ColumnMappingDialog.Mapping(null, null, true))); + ColumnMappingDialog dialog = FxTestSupport.call(() -> new ColumnMappingDialog(stage, List.of("sjm_emp", + "sjm_dept"), loader, chosen)); + // Wide enough for the target types + snapshotDialog(dialog, 980, 600, "column-mapping.png", () -> { + javafx.stage.Window window = dialog.getDialogPane().getScene().getWindow(); + window.setWidth(980); + window.setHeight(620); + }); + } + + /** + * The feature map of the About dialog + */ + @Test + void featureMap() throws Exception { + javafx.scene.control.Alert about = FxTestSupport.call(() -> Dialogs.createAbout(stage, null)); + FxTestSupport.run(() -> { + about.show(); + ((TabPane) about.getDialogPane().lookup("#aboutTabs")).getSelectionModel().select(1); + }); + sleep(2500); + // The whole map, also the part which the dialog scrolls + write(FxTestSupport.call(() -> { + Node map = about.getDialogPane().lookup("#featureMap"); + javafx.scene.SnapshotParameters parameters = new javafx.scene.SnapshotParameters(); + parameters.setFill(javafx.scene.paint.Color.web("#0d1117")); + return map.snapshot(parameters, null); + }), new File(outputDir(), "feature-map.png")); + FxTestSupport.run(about::close); + } + + /** + * Sync tasks of the MySQL fixture into H2, one run now, one run by the system scheduler every night + */ + @Test + void syncTasks() throws Exception { + MainView mainView = mainWindow(1280, 753); + ConnectionProfile h2 = profile("H2 demo"); + com.github.sqljam.face.model.SyncTask hourly = new com.github.sqljam.face.model.SyncTask(); + hourly.setName("Employees to H2"); + hourly.setSourceId(mysql.getId()); + hourly.setSourceCatalog(CATALOG); + hourly.setTargetId(h2.getId()); + hourly.setTargetSchema("PUBLIC"); + hourly.getTables().add(new com.github.sqljam.face.model.SyncTask.Table("sjm_dept", "id", + com.github.sqljam.face.model.SyncTask.WatermarkType.INCREASING_KEY)); + hourly.getTables().add(new com.github.sqljam.face.model.SyncTask.Table("sjm_emp", "id", + com.github.sqljam.face.model.SyncTask.WatermarkType.INCREASING_KEY)); + hourly.setIntervalMinutes(15); + context.getSyncScheduler().getStore().saveTask(hourly); + com.github.sqljam.face.model.SyncTask nightly = new com.github.sqljam.face.model.SyncTask(); + nightly.setName("Sales every night"); + nightly.setSourceId(mysql.getId()); + nightly.setSourceCatalog(CATALOG); + nightly.setTargetId(h2.getId()); + nightly.setTargetSchema("PUBLIC"); + nightly.getTables().add(new com.github.sqljam.face.model.SyncTask.Table("sjm_sales", "id", + com.github.sqljam.face.model.SyncTask.WatermarkType.INCREASING_KEY)); + nightly.setScheduleType(com.github.sqljam.face.model.SyncTask.ScheduleType.DAILY); + nightly.setDailyTime("02:00"); + nightly.setRunner(com.github.sqljam.face.model.SyncTask.Runner.SYSTEM); + context.getSyncScheduler().getStore().saveTask(nightly); + context.getSyncScheduler().runNow(hourly.getId()).get(120, java.util.concurrent.TimeUnit.SECONDS); + FxTestSupport.run(mainView::openSync); + snapshot("sync-tasks.png"); + } + + /** + * The live dashboard of the MySQL of the tests, sampled every second for a while + */ + @Test + void dashboard() throws Exception { + MainView mainView = mainWindow(1280, 753); + ConnectionTree tree = FxTestSupport.call(() -> (ConnectionTree) mainView.lookup("#connectionTree")); + TreeItem connection = FxTestSupport.call(() -> tree.getConnectionItems().stream() + .filter(item -> "MySQL test".equals(item.getValue().getLabel())).findFirst().orElseThrow()); + FxTestSupport.run(() -> { + tree.getSelectionModel().select(connection); + mainView.openInfo(connection.getValue()); + }); + InfoTab info = FxTestSupport.call(() -> (InfoTab) ((TabPane) mainView.lookup("#tableTabs")) + .getSelectionModel().getSelectedItem()); + FxTestSupport.run(() -> { + ((ComboBox) info.getDashboard().lookup("#dashboardIntervalCombo")).setValue(1); + info.showDashboard(); + }); + // Curves of half a minute, with some work of the server meanwhile + for (int i = 0; i < 30; i++) { + try (java.sql.Connection jdbc = ItDatabase.MYSQL.getSourceConnection(); + java.sql.Statement statement = jdbc.createStatement()) { + statement.executeQuery("SELECT COUNT(*) FROM " + CATALOG + ".sjm_emp WHERE salary > " + i).close(); + } + sleep(1000); + } + snapshot("dashboard.png"); + FxTestSupport.run(() -> info.getTabPane().getTabs().remove(info)); + } } diff --git a/src/test/java/com/github/sqljam/face/view/ExampleDatabaseViewTest.java b/src/test/java/com/github/sqljam/face/view/ExampleDatabaseViewTest.java index e031326..0072d7c 100644 --- a/src/test/java/com/github/sqljam/face/view/ExampleDatabaseViewTest.java +++ b/src/test/java/com/github/sqljam/face/view/ExampleDatabaseViewTest.java @@ -40,7 +40,6 @@ 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; @@ -82,25 +81,15 @@ void close() { context.getSessionManager().closeAll(); } + /** + * The first start shows the example in the tree of the main window, connected without a password + */ @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())); + void showsExampleInTree() { + MainView mainView = showMain(); + assertEquals(List.of(ExampleDatabase.PROFILE_NAME), names(mainView)); + assertEquals("sa", FxTestSupport.call(() -> tree(mainView).getConnectionItems().get(0).getValue() + .getProfile().getUsername())); } @Test @@ -113,7 +102,7 @@ void browsesAndQueriesExampleTables() { return view; }); ConnectionTree tree = FxTestSupport.call(() -> (ConnectionTree) mainView.lookup("#connectionTree")); - TreeItem connection = FxTestSupport.call(() -> tree.getRoot().getChildren().get(0)); + TreeItem connection = FxTestSupport.call(() -> tree.getConnectionItems().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)); @@ -148,21 +137,32 @@ private static TreeItem expand(TreeItem item) { return item; } - private LoginView showLogin() { + private MainView showMain() { return FxTestSupport.call(() -> { - LoginView loginView = new LoginView(context, profile -> { - }); - root.getChildren().setAll(loginView); - loginView.applyCss(); - loginView.layout(); - return loginView; + MainView mainView = new MainView(context); + root.getChildren().setAll(mainView); + mainView.applyCss(); + mainView.layout(); + return mainView; }); } - @SuppressWarnings("unchecked") - private static List names(LoginView view) { - return FxTestSupport.call(() -> ((ListView) view.lookup("#profileList")).getItems() - .stream().map(ConnectionProfile::getName).collect(Collectors.toList())); + private static ConnectionTree tree(MainView mainView) { + return (ConnectionTree) mainView.lookup("#connectionTree"); + } + + private static List names(MainView mainView) { + return FxTestSupport.call(() -> tree(mainView).getConnectionItems().stream().map(item -> item.getValue() + .getLabel()).collect(Collectors.toList())); + } + + /** + * Help → Restore Example Database of the main window + */ + private static MenuItem restoreMenuItem(MainView mainView) { + return FxTestSupport.call(() -> ((MenuBar) mainView.lookup(".menu-bar")).getMenus().stream().flatMap(menu + -> menu.getItems().stream()).filter(item -> "restoreExampleMenuItem".equals(item.getId())) + .findFirst().orElseThrow()); } private long countOrders() throws Exception { @@ -174,17 +174,17 @@ private long countOrders() throws Exception { } @Test - @SuppressWarnings("unchecked") - void restoresRemovedExampleOnLoginPage() throws Exception { + void restoresRemovedExample() throws Exception { // The user removed the example data source context.getProfileRegistry().removeProfile(ExampleDatabase.PROFILE_ID); - LoginView view = showLogin(); + MainView view = showMain(); assertTrue(names(view).isEmpty()); - // No confirmation, nothing is replaced - FxTestSupport.run(() -> ((Button) view.lookup("#exampleButton")).fire()); + // No confirmation, nothing is replaced, the restored example is selected in the tree + FxTestSupport.run(() -> restoreMenuItem(view).fire()); FxTestSupport.waitUntil(() -> names(view).contains(ExampleDatabase.PROFILE_NAME)); - assertEquals(ExampleDatabase.PROFILE_NAME, FxTestSupport.call(() -> ((ListView) view - .lookup("#profileList")).getSelectionModel().getSelectedItem().getName())); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> tree(view).getSelectionModel().getSelectedItem() + != null && ExampleDatabase.PROFILE_NAME.equals(tree(view).getSelectionModel().getSelectedItem() + .getValue().getLabel()))); assertEquals(1, context.getProfileRegistry().getProfiles().size()); assertEquals(300, countOrders()); } @@ -196,13 +196,13 @@ void replacesExampleAfterConfirmation() throws Exception { statement.execute("DELETE FROM order_items"); statement.execute("DELETE FROM orders"); } - LoginView view = showLogin(); + MainView view = showMain(); // Cancelled, the changed database is kept - FxTestSupport.fireLater((Button) FxTestSupport.call(() -> view.lookup("#exampleButton"))); + Platform.runLater(() -> restoreMenuItem(view).fire()); 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"))); + Platform.runLater(() -> restoreMenuItem(view).fire()); FxTestSupport.closeDialog(ButtonType.OK); FxTestSupport.waitUntil(() -> { try { 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 b523b09..8ab3301 100644 --- a/src/test/java/com/github/sqljam/face/view/ExportDialogTest.java +++ b/src/test/java/com/github/sqljam/face/view/ExportDialogTest.java @@ -157,6 +157,20 @@ private String error() { return FxTestSupport.call(() -> ((Label) node("errorLabel")).getText()); } + /** + * Whether the node and all of its parents are visible + */ + private boolean shown(String id) { + return FxTestSupport.call(() -> { + for (javafx.scene.Node node = node(id); node != null; node = node.getParent()) { + if (!node.isVisible()) { + return false; + } + } + return true; + }); + } + private TransferRequest build() { return FxTestSupport.call(dialog::buildRequest); } @@ -364,6 +378,38 @@ void showsLayoutHintOfSingleFile() { assertTrue(hint.contains("manifest.json"), hint); } + /** + * Columns are mapped for imports into a database only, the mappings go with the request + */ + @Test + void mapsColumnsOfDatabaseTargets() { + open(schemaNode()); + // Packages are written as they are + assertFalse(shown("mapColumnsButton")); + FxTestSupport.run(() -> ((RadioButton) node("databaseRadio")).setSelected(true)); + assertEquals("All columns are imported as they are", FxTestSupport.call(() -> ((Label) node("mappingLabel")) + .getText())); + assertTrue(build().getColumnMappings().isEmpty()); + assertTrue(shown("mapColumnsButton")); + FxTestSupport.fireLater((Button) node("mapColumnsButton")); + FxTestSupport.waitUntil(() -> FxTestSupport.getDialogPanes().stream().anyMatch(pane -> pane.lookup( + "#mappingColumnTable") != null)); + javafx.scene.control.DialogPane mapping = FxTestSupport.getDialogPanes().stream().filter(pane -> pane + .lookup("#mappingColumnTable") != null).findFirst().orElseThrow(); + @SuppressWarnings("unchecked") + javafx.scene.control.TableView columns = (javafx.scene.control.TableView< + ColumnMappingDialog.Row>) mapping.lookup("#mappingColumnTable"); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> !columns.getItems().isEmpty())); + FxTestSupport.run(() -> { + columns.getItems().get(0).targetName.set("MAPPED_FIRST"); + ((Button) mapping.lookupButton(ButtonType.OK)).fire(); + }); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> ((Label) node("mappingLabel")).getText() + .equals("1 columns are mapped"))); + assertTrue(build().getColumnMappings().values().stream().anyMatch(columnsOfTable -> columnsOfTable.values() + .stream().anyMatch(columnMapping -> "MAPPED_FIRST".equals(columnMapping.getTargetName())))); + } + @Test void allowsSameDataSourceAsTarget() { open(schemaNode()); 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 951822f..e11be71 100644 --- a/src/test/java/com/github/sqljam/face/view/FxTestSupport.java +++ b/src/test/java/com/github/sqljam/face/view/FxTestSupport.java @@ -29,6 +29,7 @@ import com.github.sqljam.face.service.ConnectionStore; import com.github.sqljam.face.service.ExampleDatabase; import com.github.sqljam.face.service.SettingsStore; +import com.github.sqljam.face.service.SystemScheduler; import javafx.application.Platform; import javafx.collections.ListChangeListener; import javafx.scene.Scene; @@ -97,11 +98,19 @@ private static void styleScene(Scene scene) { public static AppContext createContext(File dir) { applyAppStyle(); Config config = new Config(new File(dir, Config.FILE_NAME)); + // Vector databases of tests are not read from the home directory of the user either + config.set("sqljam.vdb.connections.file", new File(dir, "vdb-connections.json").getAbsolutePath()); + // Nor the reports of the transfers + config.set("sqljam.reports.dir", new File(dir, "reports").getAbsolutePath()); + config.set("sqljam.sync.file", new File(dir, "sync-tasks.json").getAbsolutePath()); Config.setInstance(config); 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"))); + // Nor is the crontab or the Task Scheduler of this machine changed + context.setSystemScheduler(new SystemScheduler("Linux", List.of("sqljam"), new File(dir, "sync.log"), + (command, input) -> new SystemScheduler.Result(command.contains("-l") ? 1 : 0, ""))); return context; } @@ -174,4 +183,24 @@ public static void closeAllDialogs() { } WaitForAsyncUtils.waitForFxEvents(); } + + /** + * The item of the id in any menu of the menu bar of the root, also in sub menus + */ + public static javafx.scene.control.MenuItem menuItem(javafx.scene.Node root, String id) { + return call(() -> { + javafx.scene.control.MenuBar bar = (javafx.scene.control.MenuBar) root.lookup(".menu-bar"); + java.util.Deque items = new java.util.ArrayDeque<>(bar.getMenus()); + while (!items.isEmpty()) { + javafx.scene.control.MenuItem item = items.removeFirst(); + if (id.equals(item.getId())) { + return item; + } + if (item instanceof javafx.scene.control.Menu) { + items.addAll(((javafx.scene.control.Menu) item).getItems()); + } + } + throw new IllegalArgumentException("No menu item " + id); + }); + } } diff --git a/src/test/java/com/github/sqljam/face/view/LoginViewTest.java b/src/test/java/com/github/sqljam/face/view/LoginViewTest.java deleted file mode 100644 index 33d4af4..0000000 --- a/src/test/java/com/github/sqljam/face/view/LoginViewTest.java +++ /dev/null @@ -1,237 +0,0 @@ -/* - * 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.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; -import org.testfx.api.FxRobot; -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 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.TextField; -import javafx.scene.input.KeyCode; -import javafx.scene.layout.StackPane; -import javafx.stage.Stage; - -/** - * @Description: LoginViewTest verifies the data source login page - * @Author: Fred Feng - * @Date: 26/03/2023 - * @Version 1.0.0 - */ -@ExtendWith(ApplicationExtension.class) -class LoginViewTest { - - @TempDir - File dir; - - private AppContext context; - private StackPane root; - private final AtomicReference loggedIn = new AtomicReference<>(); - private final AtomicBoolean called = new AtomicBoolean(); - private ConnectionProfile embedded; - - @Start - void start(Stage stage) { - context = FxTestSupport.createContext(dir); - root = new StackPane(); - stage.setScene(new Scene(root, 900, 650)); - stage.show(); - } - - private LoginView show() { - return FxTestSupport.call(() -> { - LoginView view = new LoginView(context, profile -> { - called.set(true); - loggedIn.set(profile); - }); - root.getChildren().setAll(view); - // Content of the scroll pane is looked up after the skin is created - view.applyCss(); - view.layout(); - return view; - }); - } - - private void saveProfiles() throws Exception { - embedded = UiDatabase.create(dir, "Embedded H2"); - context.getProfileRegistry().saveProfile(embedded, true); - ConnectionProfile unreachable = new ConnectionProfile(); - unreachable.setName("Unreachable MySQL"); - unreachable.setDbType(DbType.MYSQL); - unreachable.setHostname("127.0.0.1"); - unreachable.setPort(1); - unreachable.setDatabase("test"); - context.getProfileRegistry().saveProfile(unreachable, true); - ConnectionProfile sqlite = new ConnectionProfile(); - sqlite.setName("Local file"); - sqlite.setDbType(DbType.SQLITE); - sqlite.setDatabase(new File(dir, "local.sqlite").getAbsolutePath()); - context.getProfileRegistry().saveProfile(sqlite, true); - } - - @SuppressWarnings("unchecked") - private static ListView list(LoginView view) { - return (ListView) view.lookup("#profileList"); - } - - private static void select(LoginView view, String name) { - FxTestSupport.run(() -> list(view).getItems().stream().filter(profile -> profile.getName().equals(name)) - .findFirst().ifPresent(list(view).getSelectionModel()::select)); - } - - @Test - void showsEmptyList() { - LoginView view = show(); - assertEquals(0, FxTestSupport.call(() -> list(view).getItems().size())); - assertEquals("No data sources", FxTestSupport.call(() -> ((Label) list(view).getPlaceholder()).getText())); - assertTrue(FxTestSupport.call(() -> view.lookup("#deleteButton").isDisabled())); - } - - @Test - void listsAndSelectsFirstDataSource() throws Exception { - saveProfiles(); - LoginView view = show(); - assertEquals(3, FxTestSupport.call(() -> list(view).getItems().size())); - // Sorted by name, the first one fills the form - assertEquals("Embedded H2", FxTestSupport.call(() -> list(view).getSelectionModel().getSelectedItem() - .getName())); - assertEquals("Embedded H2", FxTestSupport.call(() -> ((TextField) view.lookup("#nameField")).getText())); - select(view, "Unreachable MySQL"); - assertEquals("1", FxTestSupport.call(() -> ((TextField) view.lookup("#portField")).getText())); - } - - @Test - void filtersByNameAndType(FxRobot robot) throws Exception { - saveProfiles(); - LoginView view = show(); - robot.clickOn(view.lookup("#searchField")).write("mysql"); - assertEquals(1, FxTestSupport.call(() -> list(view).getItems().size())); - FxTestSupport.run(() -> ((TextField) view.lookup("#searchField")).setText("SQLite")); - // Matched by database type - assertEquals("Local file", FxTestSupport.call(() -> list(view).getItems().get(0).getName())); - FxTestSupport.run(() -> ((TextField) view.lookup("#searchField")).setText("nothing")); - assertEquals(0, FxTestSupport.call(() -> list(view).getItems().size())); - FxTestSupport.run(() -> ((TextField) view.lookup("#searchField")).setText("")); - assertEquals(3, FxTestSupport.call(() -> list(view).getItems().size())); - } - - @Test - void newClearsForm() throws Exception { - saveProfiles(); - LoginView view = show(); - FxTestSupport.run(() -> ((Button) view.lookup("#newButton")).fire()); - assertEquals("", FxTestSupport.call(() -> ((TextField) view.lookup("#nameField")).getText())); - assertNull(FxTestSupport.call(() -> list(view).getSelectionModel().getSelectedItem())); - } - - @Test - void deletesDataSource() throws Exception { - saveProfiles(); - LoginView view = show(); - select(view, "Local file"); - FxTestSupport.fireLater((Button) view.lookup("#deleteButton")); - FxTestSupport.closeDialog(ButtonType.OK); - FxTestSupport.waitUntil(() -> list(view).getItems().size() == 2); - assertFalse(Files.readString(new File(dir, "connections.json").toPath()).contains("Local file")); - } - - @Test - void cancelsDeletingDataSource() throws Exception { - saveProfiles(); - LoginView view = show(); - select(view, "Local file"); - FxTestSupport.fireLater((Button) view.lookup("#deleteButton")); - FxTestSupport.closeDialog(ButtonType.CANCEL); - assertEquals(3, FxTestSupport.call(() -> list(view).getItems().size())); - } - - @Test - void skipsLogin() { - LoginView view = show(); - FxTestSupport.run(() -> ((Button) view.lookup("#skipButton")).fire()); - assertTrue(called.get()); - assertNull(loggedIn.get()); - } - - @Test - void enterKeyLogsIn(FxRobot robot) throws Exception { - saveProfiles(); - LoginView view = show(); - select(view, "Embedded H2"); - robot.clickOn(view.lookup("#usernameField")).type(KeyCode.ENTER); - FxTestSupport.waitUntil(called::get); - assertEquals(embedded.getId(), loggedIn.get().getId()); - } - - @Test - void failedLoginStaysOnPage() throws Exception { - saveProfiles(); - LoginView view = show(); - select(view, "Unreachable MySQL"); - FxTestSupport.run(() -> ((Button) view.lookup("#loginButton")).fire()); - FxTestSupport.waitUntil(() -> !view.lookup("#loginButton").isDisabled()); - assertFalse(called.get()); - assertTrue(FxTestSupport.call(() -> ((Label) view.lookup("#formStatusLabel")).getText()) - .startsWith("Connection failed:")); - assertTrue(FxTestSupport.call(() -> root.getChildren().contains(view))); - } - - @Test - void invalidFormIsNotLoggedIn() { - LoginView view = show(); - FxTestSupport.run(() -> { - ((TextField) view.lookup("#hostField")).setText(""); - ((Button) view.lookup("#loginButton")).fire(); - }); - assertFalse(called.get()); - assertEquals("Host is required", FxTestSupport.call(() -> ((Label) view.lookup("#formStatusLabel")) - .getText())); - } - - @Test - void loginSavesNewDataSource() throws Exception { - LoginView view = show(); - ConnectionProfile profile = UiDatabase.create(dir, "New H2"); - FxTestSupport.run(() -> { - ConnectionForm form = (ConnectionForm) view.lookup(".connection-form"); - form.setProfile(profile, false); - ((Button) view.lookup("#loginButton")).fire(); - }); - FxTestSupport.waitUntil(called::get); - assertEquals("New H2", loggedIn.get().getName()); - assertEquals(1, context.getProfileRegistry().getProfiles().size()); - // Password is not remembered - assertFalse(context.getProfileRegistry().isPasswordRemembered(profile.getId())); - } -} diff --git a/src/test/java/com/github/sqljam/face/view/MainViewIT.java b/src/test/java/com/github/sqljam/face/view/MainViewIT.java index 00fad44..039aa2f 100644 --- a/src/test/java/com/github/sqljam/face/view/MainViewIT.java +++ b/src/test/java/com/github/sqljam/face/view/MainViewIT.java @@ -101,25 +101,19 @@ void browsesAndExportsTables(FxRobot robot) throws Exception { profile.setName("MySQL fixture"); context.getProfileRegistry().saveProfile(profile, true); - // Login - LoginView loginView = FxTestSupport.call(() -> { - LoginView view = new LoginView(context, loggedIn -> { - MainView main = new MainView(context); - root.getChildren().setAll(main); - main.selectConnection(loggedIn); - mainView.set(main); - }); - root.getChildren().setAll(view); - view.applyCss(); - view.layout(); - return view; + // The main window, the data source is connected in its tree + FxTestSupport.run(() -> { + MainView main = new MainView(context); + root.getChildren().setAll(main); + main.applyCss(); + main.layout(); + main.selectConnection(profile); + mainView.set(main); }); - FxTestSupport.run(() -> ((Button) loginView.lookup("#loginButton")).fire()); - FxTestSupport.waitUntil(() -> mainView.get() != null); // Browse: data source -> database -> table ConnectionTree tree = (ConnectionTree) FxTestSupport.call(() -> mainView.get().lookup("#connectionTree")); - TreeItem connection = FxTestSupport.call(() -> tree.getRoot().getChildren().get(0)); + TreeItem connection = FxTestSupport.call(() -> tree.getConnectionItems().get(0)); waitLoaded(connection); TreeItem catalog = child(connection, database.getSourceCatalog()); FxTestSupport.run(() -> catalog.setExpanded(true)); 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 466b02c..da13841 100644 --- a/src/test/java/com/github/sqljam/face/view/MainViewTest.java +++ b/src/test/java/com/github/sqljam/face/view/MainViewTest.java @@ -35,10 +35,12 @@ import org.junit.jupiter.api.io.TempDir; import org.testfx.framework.junit5.ApplicationExtension; import org.testfx.framework.junit5.Start; +import org.testfx.util.WaitForAsyncUtils; 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.event.Event; import javafx.scene.Cursor; import javafx.scene.Scene; import javafx.scene.control.Button; @@ -59,6 +61,8 @@ import javafx.scene.control.TextField; import javafx.scene.control.TreeCell; import javafx.scene.control.TreeItem; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; import javafx.stage.Stage; import javafx.stage.Window; @@ -112,6 +116,19 @@ private TreeItem child(TreeItem parent, String label) { .startsWith(label)).findFirst().orElse(null)); } + /** + * A data source of the tree, in the group of its category + */ + private TreeItem connection(String label) { + return FxTestSupport.call(() -> tree().getConnectionItems().stream().filter(item -> item.getValue() + .getLabel().startsWith(label)).findFirst().orElse(null)); + } + + private List connectionLabels() { + return FxTestSupport.call(() -> tree().getConnectionItems().stream().map(item -> item.getValue() + .getLabel()).collect(Collectors.toList())); + } + private static List labels(TreeItem item) { return FxTestSupport.call(() -> item.getChildren().stream().map(child -> child.getValue().getLabel()) .collect(Collectors.toList())); @@ -124,7 +141,7 @@ private static void expandAndWait(TreeItem item) { } private TreeItem expandToSchema() { - TreeItem connection = child(tree().getRoot(), "Embedded H2"); + TreeItem connection = connection("Embedded H2"); expandAndWait(connection); TreeItem catalog = child(connection, UiDatabase.CATALOG); assertNotNull(catalog, labels(connection).toString()); @@ -134,10 +151,37 @@ private TreeItem expandToSchema() { return schema; } + @Test + void filtersTreeByField() { + TextField field = (TextField) mainView.lookup("#treeFilterField"); + Button clear = (Button) mainView.lookup("#treeFilterClearButton"); + assertFalse(FxTestSupport.call(clear::isVisible)); + FxTestSupport.run(() -> field.setText("embedded")); + assertEquals(List.of("Embedded H2"), connectionLabels().stream().map(label -> label.substring(0, 11)) + .collect(Collectors.toList())); + assertEquals("embedded", tree().getFilter()); + assertTrue(FxTestSupport.call(clear::isVisible)); + FxTestSupport.fireLater(clear); + WaitForAsyncUtils.waitForFxEvents(); + assertEquals("", FxTestSupport.call(field::getText)); + assertTrue(connectionLabels().size() > 1); + // Escape clears it too + FxTestSupport.run(() -> field.setText("nothing")); + assertEquals(List.of(), connectionLabels()); + Platform.runLater(() -> Event.fireEvent(field, new KeyEvent(KeyEvent.KEY_PRESSED, "", "", KeyCode.ESCAPE, + false, false, false, false))); + WaitForAsyncUtils.waitForFxEvents(); + assertEquals("", tree().getFilter()); + // The tree changes the field + FxTestSupport.run(() -> tree().setFilter("h2")); + assertEquals("h2", FxTestSupport.call(field::getText)); + FxTestSupport.run(() -> tree().setFilter("")); + } + @Test void loadsTreeLazily() { - TreeItem connection = child(tree().getRoot(), "Embedded H2"); - assertEquals(2, FxTestSupport.call(() -> tree().getRoot().getChildren().size())); + TreeItem connection = connection("Embedded H2"); + assertEquals(2, FxTestSupport.call(() -> tree().getConnectionItems().size())); // Placeholder child until expanded assertEquals(DbNode.Kind.LOADING, FxTestSupport.call(() -> connection.getChildren().get(0).getValue() .getKind())); @@ -173,7 +217,7 @@ void refreshesSelectedNode() throws Exception { @Test void showsEmptyAndErrorNodes() { - TreeItem unreachable = child(tree().getRoot(), "Unreachable MySQL"); + TreeItem unreachable = connection("Unreachable MySQL"); FxTestSupport.run(() -> unreachable.setExpanded(true)); FxTestSupport.waitForDialogPane(); FxTestSupport.closeAllDialogs(); @@ -249,13 +293,13 @@ void showsTableMetadataDdlAndData() { @Test void deletesDataSource() { - TreeItem unreachable = child(tree().getRoot(), "Unreachable MySQL"); + TreeItem unreachable = connection("Unreachable MySQL"); FxTestSupport.run(() -> tree().getSelectionModel().select(unreachable)); Button deleteButton = FxTestSupport.call(() -> (Button) mainView.lookup("#action-deleteConnection")); assertFalse(FxTestSupport.call(deleteButton::isDisabled)); FxTestSupport.fireLater(deleteButton); FxTestSupport.closeDialog(ButtonType.OK); - FxTestSupport.waitUntil(() -> tree().getRoot().getChildren().size() == 1); + FxTestSupport.waitUntil(() -> tree().getConnectionItems().size() == 1); assertEquals(1, context.getProfileRegistry().getProfiles().size()); } @@ -276,10 +320,7 @@ void createsTableKey() { } 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); - }); + return FxTestSupport.menuItem(mainView, id); } @Test @@ -293,7 +334,7 @@ void opensParquetImportFromMenuAndTree() { 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"); + TreeItem connection = connection("Embedded H2"); FxTestSupport.run(() -> tree().getSelectionModel().select(connection)); List items = FxTestSupport.call(() -> { tree().applyCss(); @@ -350,7 +391,7 @@ private static List connectState(ContextMenu menu) { @Test void disconnectsOpenedConnection() { - TreeItem connection = child(tree().getRoot(), "Embedded H2"); + TreeItem connection = connection("Embedded H2"); assertEquals(List.of(true, false), connectState(contextMenu(connection))); expandAndWait(connection); assertTrue(FxTestSupport.call(() -> tree().isConnected(connection))); @@ -372,7 +413,7 @@ void disconnectsOpenedConnection() { assertTrue(FxTestSupport.call(() -> tree().isConnected(connection))); assertTrue(labels(connection).size() > 0); // Not connected, nothing happens - TreeItem unreachable = child(tree().getRoot(), "Unreachable MySQL"); + TreeItem unreachable = connection("Unreachable MySQL"); FxTestSupport.run(() -> tree().disconnect(unreachable)); assertFalse(FxTestSupport.call(() -> tree().isConnected(unreachable))); } @@ -403,8 +444,8 @@ void createsTargetDataSourceInImportDialog() { 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()); + FxTestSupport.waitUntil(() -> connectionLabels().contains("Target H2")); + assertEquals(3, connectionLabels().size()); } @Test @@ -543,7 +584,7 @@ void exportsQueryOfSqlEditor() throws Exception { assertEquals(3, rs.getInt(1)); } // Closed with its data source - TreeItem connection = child(tree().getRoot(), "Embedded H2"); + TreeItem connection = connection("Embedded H2"); FxTestSupport.run(() -> tree().disconnect(connection)); assertTrue(FxTestSupport.call(() -> tabs().getTabs().isEmpty())); // Nothing selected: the first data source @@ -551,4 +592,86 @@ void exportsQueryOfSqlEditor() throws Exception { assertTrue(FxTestSupport.call(() -> tabs().getTabs().get(0).getText()).startsWith("SQL 2: ")); FxTestSupport.run(() -> ((SqlTab) tabs().getTabs().get(0)).close()); } + + private static boolean disabledOnShowing(MenuItem item) { + return FxTestSupport.call(() -> { + item.getParentMenu().getOnShowing().handle(new javafx.event.Event(Menu.ON_SHOWING)); + return item.isDisable(); + }); + } + + /** + * The menus of the main window: the actions of the toolbar and of the tree, switched off without a data source, + * the themes in the menu and in the toolbar, tabs closed together and the keys read from the menus + */ + @Test + void offersActionsInMenus() { + assertEquals(List.of("File", "Database", "Transfer", "View", "Help"), FxTestSupport.call(() -> ((MenuBar) + mainView.lookup(".menu-bar")).getMenus().stream().map(Menu::getText).collect(Collectors.toList()))); + MenuItem edit = FxTestSupport.menuItem(mainView, "action-editConnection-menu"); + MenuItem connect = FxTestSupport.menuItem(mainView, "tree-connect-menu"); + MenuItem disconnect = FxTestSupport.menuItem(mainView, "tree-disconnect-menu"); + MenuItem disconnectAll = FxTestSupport.menuItem(mainView, "action-disconnectAll-menu"); + MenuItem dashboard = FxTestSupport.menuItem(mainView, "action-dashboard-menu"); + FxTestSupport.run(() -> tree().getSelectionModel().clearSelection()); + assertTrue(disabledOnShowing(edit)); + assertTrue(disabledOnShowing(connect)); + assertTrue(disabledOnShowing(dashboard)); + + // A data source: connected by the menu, its table selected keeps it, disconnected again + TreeItem h2 = connection("Embedded H2"); + FxTestSupport.run(() -> tree().getSelectionModel().select(h2)); + assertFalse(disabledOnShowing(edit)); + assertFalse(disabledOnShowing(connect)); + assertTrue(disabledOnShowing(disconnect)); + FxTestSupport.run(connect::fire); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> tree().isConnected(h2))); + assertTrue(disabledOnShowing(connect)); + assertFalse(disabledOnShowing(disconnectAll)); + FxTestSupport.run(dashboard::fire); + InfoTab info = FxTestSupport.call(() -> (InfoTab) tabs().getSelectionModel().getSelectedItem()); + assertTrue(FxTestSupport.call(() -> info.getDashboard() != null)); + FxTestSupport.run(disconnectAll::fire); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> !tree().isConnected(h2))); + + // Disconnected: its info was closed with it + assertTrue(FxTestSupport.call(() -> tabs().getTabs().isEmpty())); + // Tabs: the others, then all of them, closed like by the user + FxTestSupport.run(() -> { + mainView.openHistory(); + mainView.openSync(); + }); + assertEquals(2, FxTestSupport.call(() -> tabs().getTabs().size()).intValue()); + FxTestSupport.run(FxTestSupport.menuItem(mainView, "action-closeOtherTabs-menu")::fire); + assertEquals(List.of("Sync Tasks"), FxTestSupport.call(() -> tabs().getTabs().stream().map(Tab::getText) + .collect(Collectors.toList()))); + FxTestSupport.run(FxTestSupport.menuItem(mainView, "action-closeAllTabs-menu")::fire); + assertTrue(FxTestSupport.call(() -> tabs().getTabs().isEmpty())); + assertTrue(disabledOnShowing(FxTestSupport.menuItem(mainView, "action-closeTab-menu"))); + + // The theme of the toolbar is checked in the menu too, Next Theme goes on through the list + javafx.scene.control.MenuButton themes = FxTestSupport.call(() -> (javafx.scene.control.MenuButton) mainView + .lookup("#toolbarThemeButton")); + String second = ThemeManager.getThemeNames().get(1); + FxTestSupport.run(() -> themes.getItems().stream().filter(item -> second.equals(item.getText())).findFirst() + .orElseThrow().fire()); + assertEquals(second, context.getSettings().getTheme()); + assertTrue(FxTestSupport.call(() -> ((Menu) FxTestSupport.menuItem(mainView, "themeMenu")).getItems().stream() + .filter(item -> second.equals(item.getText())).map(item -> ((javafx.scene.control.RadioMenuItem) item) + .isSelected()).findFirst().orElseThrow())); + FxTestSupport.run(FxTestSupport.menuItem(mainView, "action-nextTheme-menu")::fire); + assertEquals(ThemeManager.getThemeNames().get(2), context.getSettings().getTheme()); + FxTestSupport.run(() -> mainView.applyTheme(ThemeManager.DEFAULT_THEME)); + + // The keys of the menus + List shortcuts = FxTestSupport.call(mainView::getShortcuts); + assertTrue(shortcuts.stream().anyMatch(row -> row[0].equals("Transfer > Export...")), shortcuts.stream().map( + row -> row[0]).collect(Collectors.joining(", "))); + assertTrue(shortcuts.stream().anyMatch(row -> row[0].equals("Database > Refresh"))); + FxTestSupport.run(FxTestSupport.menuItem(mainView, "help-shortcuts-menu")::fire); + javafx.scene.control.DialogPane pane = FxTestSupport.waitForDialogPane(); + assertEquals(shortcuts.size(), FxTestSupport.call(() -> ((javafx.scene.control.TableView) pane.lookup( + "#shortcutsTable")).getItems().size()).intValue()); + FxTestSupport.closeDialog(javafx.scene.control.ButtonType.OK); + } } 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 a8be569..7e34572 100644 --- a/src/test/java/com/github/sqljam/face/view/ProgressDialogTest.java +++ b/src/test/java/com/github/sqljam/face/view/ProgressDialogTest.java @@ -17,6 +17,7 @@ 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; @@ -35,6 +36,7 @@ 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.TransferReport; import com.github.sqljam.impexp.ExportCancelledException; import com.github.sqljam.impexp.ExportListener; import com.github.sqljam.jdbc.ManualTransaction; @@ -117,6 +119,14 @@ private static void release(Object[] handle) { } } + /** + * The report of the dialog as it was saved in the history, once its button is shown + */ + private TransferReport savedReport(ProgressDialog dialog) { + FxTestSupport.waitUntil(() -> root(dialog).lookup("#viewReportButton").isVisible()); + return context.getReportStore().find(dialog.getReport().getId()).orElseThrow(); + } + @Test void showsUnknownTotalAsIndeterminate() { ProgressDialog dialog = create(0); @@ -208,6 +218,10 @@ void cancelsRunningJob() { assertEquals("Cancelled", text(dialog, "progressStatus")); assertEquals("Close", FxTestSupport.call(cancelButton::getText)); assertEquals(0, dialog.getErrorLogCount()); + TransferReport report = savedReport(dialog); + assertEquals(TransferReport.Status.CANCELLED, report.getStatus()); + assertNull(report.getFailure()); + assertFalse(report.isRolledBack()); // Close button closes the window after finished FxTestSupport.run(cancelButton::fire); assertFalse(FxTestSupport.call(() -> dialog.getStage().isShowing())); @@ -241,6 +255,10 @@ void showsFailure() { assertEquals(0.0, progress(dialog, "overallBar"), 0.001); assertFalse(FxTestSupport.call(() -> root(dialog).lookup("#openFolderButton").isVisible())); assertFalse(FxTestSupport.call(() -> root(dialog).lookup("#viewPackageButton").isVisible())); + TransferReport report = savedReport(dialog); + assertEquals(TransferReport.Status.FAILED, report.getStatus()); + assertEquals("database is down", report.getFailure()); + assertTrue(report.getFailureTrace().contains("IllegalStateException")); } @Test @@ -371,6 +389,10 @@ void rollsBackFromQuestion() throws Exception { assertTrue(completed.get()); // A roll back by the user is not an error assertEquals(0, dialog.getErrorLogCount()); + TransferReport report = savedReport(dialog); + assertEquals(TransferReport.Status.ROLLED_BACK, report.getStatus()); + assertTrue(report.isRolledBack()); + assertNull(report.getFailure()); } @Test @@ -389,6 +411,7 @@ void decidesLaterWithButtons() throws Exception { FxTestSupport.waitUntil(dialog::isFinished); assertEquals("Committed", text(dialog, "progressStatus")); assertEquals(1, count(url)); + assertEquals(TransferReport.Status.COMMITTED, savedReport(dialog).getStatus()); } @Test @@ -411,6 +434,7 @@ void closingPendingWindowRollsBack() throws Exception { FxTestSupport.waitUntil(() -> !dialog.getStage().isShowing()); assertEquals("Rolled back", text(dialog, "progressStatus")); assertEquals(0, count(url)); + assertEquals(TransferReport.Status.ROLLED_BACK, savedReport(dialog).getStatus()); } @Test @@ -426,6 +450,9 @@ void rollsBackImportWithErrors() throws Exception { assertEquals(0, count(url)); assertTrue(transaction.isFinished()); assertTrue(completed.get()); + TransferReport report = savedReport(dialog); + assertEquals(TransferReport.Status.ROLLED_BACK, report.getStatus()); + assertTrue(report.isRolledBack()); } @Test @@ -444,6 +471,11 @@ void rollsBackFailedImport() throws Exception { FxTestSupport.waitUntil(() -> logs(dialog).contains("Rolled back")); assertEquals(0, count(url)); assertTrue(transaction.isFinished()); + // Failed, and nothing of it is kept + TransferReport report = savedReport(dialog); + assertEquals(TransferReport.Status.FAILED, report.getStatus()); + assertTrue(report.isRolledBack()); + assertEquals("connection lost", report.getFailure()); } @SuppressWarnings("unchecked") @@ -522,6 +554,10 @@ void commitsOneTransactionAutomatically() throws Exception { assertEquals(1, count(url)); assertTrue(transaction.isFinished()); assertTrue(completed.get()); + TransferReport report = savedReport(dialog); + assertEquals(TransferReport.Status.COMPLETED, report.getStatus()); + assertEquals("Imported 1 rows", report.getSummary()); + assertFalse(report.isRolledBack()); } @Test @@ -560,6 +596,7 @@ void commitsSkippedErrorsAutomatically() throws Exception { FxTestSupport.waitUntil(dialog::isFinished); assertEquals("Completed with 1 errors", text(dialog, "progressStatus")); assertEquals(1, count(url)); + assertEquals(TransferReport.Status.COMPLETED_WITH_ERRORS, savedReport(dialog).getStatus()); assertTrue(logs(dialog).contains("Skipped row of table 'T' (ID=2): value too long")); } @@ -589,6 +626,93 @@ void confirmsImportWithSkippedErrors() throws Exception { } } + /** + * A cancelled import in one transaction: cancelled, and nothing of it is kept + */ + @Test + void reportsCancelledTransaction() throws Exception { + String url = newDatabase(); + ProgressDialog dialog = create(1); + ManualTransaction transaction = new ManualTransaction(DriverManager.getConnection(url), false); + ExportListener listener = dialog.getListener(); + FxTestSupport.run(() -> dialog.run(() -> { + try (Statement statement = transaction.getConnection().createStatement()) { + statement.executeUpdate("INSERT INTO t VALUES (1)"); + } + listener.onTableStart(null, "PUBLIC", "T", 10); + listener.onTableProgress(null, "PUBLIC", "T", 1, 10); + while (!listener.isCancelled()) { + Thread.sleep(20); + } + throw new ExportCancelledException(); + }, null, () -> transaction, false, null)); + FxTestSupport.waitUntil(() -> dialog.getReport().table(null, "PUBLIC", "T").getWritten() == 1); + FxTestSupport.run(((Button) root(dialog).lookup("#cancelButton"))::fire); + TransferReport report = savedReport(dialog); + assertEquals(TransferReport.Status.CANCELLED, report.getStatus()); + assertTrue(report.isRolledBack()); + assertEquals("PUBLIC.T after 1 rows", report.getStoppedAt()); + assertEquals(TransferReport.TableStatus.INTERRUPTED, report.getTables().get(0).getStatus()); + assertEquals(0, count(url)); + } + + /** + * The report holds the tables, the skipped rows and the objects, the rows of the target are counted after the + * end, and the button shows the report + */ + @Test + void reportsTablesAndTargetRows() { + ProgressDialog dialog = create(1); + dialog.getReport().setTitle("Import into test"); + dialog.setReportVerifier(report -> report.getTables().forEach(table -> { + table.setTargetRows(table.getWritten()); + table.setComparable(true); + })); + AtomicReference shown = new AtomicReference<>(); + context.setReportViewer(shown::set); + FxTestSupport.run(() -> dialog.setSkipErrors(true)); + ExportListener listener = dialog.getListener(); + FxTestSupport.run(() -> dialog.run(() -> { + listener.onDdlExecuted("CREATE TABLE users (id INT)", true); + listener.onDdlExecuted("CREATE INDEX i ON users (id)", false); + listener.onTableStart(null, "app", "users", 3); + listener.onRowSkipped(null, null, "users", "id=2", new IllegalArgumentException("bad date")); + listener.onTableEnd(null, "app", "users", 2); + return 2; + }, rows -> rows + " rows", null)); + TransferReport report = savedReport(dialog); + assertEquals("Import into test", report.getTitle()); + assertEquals(TransferReport.Status.COMPLETED_WITH_ERRORS, report.getStatus()); + TransferReport.TableReport users = report.getTables().get(0); + assertEquals(2, users.getWritten()); + assertEquals(1, users.getSkipped()); + assertEquals(2L, users.getTargetRows()); + assertTrue(users.getVerified()); + assertEquals(1, report.getObjects().get("Tables")); + assertEquals(1, report.getFailedStatements()); + assertEquals("bad date", report.getSkippedRows().get(0).getError()); + FxTestSupport.run(((Button) root(dialog).lookup("#viewReportButton"))::fire); + assertEquals(dialog.getReport(), shown.get()); + } + + /** + * Rows which can not be counted leave the report without them, a report which can not be saved is still shown + */ + @Test + void reportsWhenRowsCanNotBeCountedOrSaved() throws Exception { + File file = new File(dir, "not-a-directory"); + Files.writeString(file.toPath(), "x"); + context.setReportStore(new com.github.sqljam.face.service.ReportStore(file, 10)); + ProgressDialog dialog = create(1); + dialog.setReportVerifier(report -> { + throw new java.sql.SQLException("target is down"); + }); + FxTestSupport.run(() -> dialog.run(() -> "done", null, null)); + FxTestSupport.waitUntil(() -> root(dialog).lookup("#viewReportButton").isVisible()); + assertTrue(logs(dialog).stream().anyMatch(log -> log.startsWith("The report is not saved: "))); + assertEquals(TransferReport.Status.COMPLETED, dialog.getReport().getStatus()); + } + /** * The window shows the last entries, errors only on demand, the saved log keeps every entry */ diff --git a/src/test/java/com/github/sqljam/face/view/ReportBoundaryTest.java b/src/test/java/com/github/sqljam/face/view/ReportBoundaryTest.java new file mode 100644 index 0000000..4037dfa --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/ReportBoundaryTest.java @@ -0,0 +1,442 @@ +/* + * 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.file.Files; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.h2.api.Trigger; +import org.h2.tools.Server; +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.TransferReport; +import com.github.sqljam.face.model.TransferRequest; +import com.github.sqljam.impexp.DataFileImporter; +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 javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.stage.Stage; + +/** + * @Description: ReportBoundaryTest runs transfers of the main window which end badly and verifies their reports: + * the target database stops while rows are written, the user cancels while rows are written with and + * without one transaction, a row fails with rows committed every page, and a package can not be + * written. Every report is saved, tells how it ended, where it stopped and what the target keeps. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class ReportBoundaryTest { + + private static final int ROWS = 3000; + private static final int PAGE = 500; + + /** + * Blocks the insert of the row of BLOCK_AT of the target until it is released, so that the database is stopped + * or the transfer is cancelled in the middle of its rows + */ + public static class BlockingTrigger implements Trigger { + + static final AtomicInteger ROWS_SEEN = new AtomicInteger(); + static volatile int blockAt = -1; + static volatile CountDownLatch reached = new CountDownLatch(1); + static volatile CountDownLatch release = new CountDownLatch(1); + + static void reset(int row) { + ROWS_SEEN.set(0); + blockAt = row; + reached = new CountDownLatch(1); + release = new CountDownLatch(1); + } + + @Override + public void fire(Connection connection, Object[] oldRow, Object[] newRow) throws SQLException { + if (ROWS_SEEN.incrementAndGet() == blockAt) { + reached.countDown(); + try { + release.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + } + + /** + * Forwards connections to the database until it is cut: the client sees its connections reset, like a network + * which goes down or a database host which stops + */ + static final class CuttableProxy implements AutoCloseable { + + private final ServerSocket serverSocket = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + private final List sockets = new CopyOnWriteArrayList<>(); + + CuttableProxy(int targetPort) throws IOException { + Thread acceptor = new Thread(() -> { + while (!serverSocket.isClosed()) { + try { + Socket client = serverSocket.accept(); + Socket database = new Socket(InetAddress.getLoopbackAddress(), targetPort); + sockets.add(client); + sockets.add(database); + pump(client, database); + pump(database, client); + } catch (IOException e) { + // Cut + } + } + }, "proxy-acceptor"); + acceptor.setDaemon(true); + acceptor.start(); + } + + private static void pump(Socket from, Socket to) { + Thread thread = new Thread(() -> { + try { + from.getInputStream().transferTo(to.getOutputStream()); + } catch (IOException e) { + // Cut + } finally { + closeQuietly(from); + closeQuietly(to); + } + }, "proxy-pump"); + thread.setDaemon(true); + thread.start(); + } + + private static void closeQuietly(Socket socket) { + try { + socket.close(); + } catch (IOException e) { + // Closed + } + } + + int getPort() { + return serverSocket.getLocalPort(); + } + + /** + * Resets every connection and refuses new ones + */ + void cut() throws IOException { + if (serverSocket.isClosed()) { + return; + } + serverSocket.close(); + for (Socket socket : sockets) { + try { + // Reset, not closed gracefully + socket.setSoLinger(true, 0); + } catch (IOException e) { + // Closed already + } + closeQuietly(socket); + } + } + + @Override + public void close() throws IOException { + cut(); + } + } + + @TempDir + File dir; + + private AppContext context; + private MainView mainView; + private Server server; + private CuttableProxy proxy; + private String directUrl; + private ConnectionProfile source; + private ConnectionProfile target; + + @Start + void start(Stage stage) throws Exception { + context = FxTestSupport.createContext(dir); + mainView = new MainView(context); + stage.setScene(new Scene(mainView, 1000, 700)); + stage.show(); + source = new ConnectionProfile(); + source.setName("source"); + source.setDbType(DbType.H2); + source.setDatabase(new File(dir, "source").getAbsolutePath()); + source.setUsername("sa"); + source.setPassword(""); + try (Connection connection = DriverManager.getConnection(source.getJdbcUrl(), "sa", ""); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE BIG (ID INT PRIMARY KEY, NAME VARCHAR(20))"); + try (PreparedStatement insert = connection.prepareStatement("INSERT INTO BIG VALUES (?, ?)")) { + for (int i = 1; i <= ROWS; i++) { + insert.setInt(1, i); + insert.setString(2, "name " + i); + insert.addBatch(); + } + insert.executeBatch(); + } + } + server = Server.createTcpServer("-tcpPort", "0", "-ifNotExists", "-baseDir", new File(dir, "server") + .getAbsolutePath()).start(); + proxy = new CuttableProxy(server.getPort()); + directUrl = "jdbc:h2:tcp://localhost:" + server.getPort() + "/target"; + target = new ConnectionProfile(); + target.setName("target"); + target.setDbType(DbType.H2); + // The transfer reaches the database through the proxy, the test reaches it directly + target.setUrl("jdbc:h2:tcp://localhost:" + proxy.getPort() + "/target"); + target.setUsername("sa"); + target.setPassword(""); + } + + @AfterEach + void stop() throws IOException { + BlockingTrigger.release.countDown(); + context.getSessionManager().closeAll(); + proxy.close(); + server.stop(); + } + + /** + * The target table, kept by the transfer, with the blocking trigger and a check which a row may fail + */ + private void createTarget(String check) throws SQLException { + try (Connection connection = DriverManager.getConnection(directUrl, "sa", ""); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE BIG (ID INT PRIMARY KEY, NAME VARCHAR(20)" + (check != null + ? ", CONSTRAINT BIG_CHECK CHECK (" + check + ")" : "") + ")"); + statement.execute("CREATE TRIGGER BIG_BLOCK BEFORE INSERT ON BIG FOR EACH ROW CALL \"" + + BlockingTrigger.class.getName() + "\""); + } + } + + private long targetRows() throws SQLException { + try (Connection connection = DriverManager.getConnection(directUrl, "sa", ""); + Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SELECT COUNT(*) FROM BIG")) { + rs.next(); + return rs.getLong(1); + } + } + + private TransferRequest request(boolean oneTransaction, boolean failFast) { + TransferRequest request = new TransferRequest(); + request.setSource(source); + request.setSourceSchema("PUBLIC"); + request.setTarget(TransferRequest.Target.DATABASE); + request.setTargetProfile(target); + request.setTargetSchema("PUBLIC"); + request.setTables(List.of("BIG")); + request.setTableRecreated(false); + request.setOneTransaction(oneTransaction); + request.setFailFast(failFast); + request.setPageSize(PAGE); + return request; + } + + private static TransferReport saved(AppContext context, ProgressDialog dialog) { + FxTestSupport.waitUntil(() -> dialog.getStage().getScene().getRoot().lookup("#viewReportButton") + .isVisible()); + return context.getReportStore().find(dialog.getReport().getId()).orElseThrow(); + } + + private static TransferReport.TableReport big(TransferReport report) { + return report.getTables().stream().filter(table -> "BIG".equalsIgnoreCase(table.getName())).findFirst() + .orElseThrow(); + } + + @Test + void reportsTargetWhichStops() throws Exception { + createTarget(null); + BlockingTrigger.reset(1200); + ProgressDialog dialog = FxTestSupport.call(() -> mainView.startTransfer(request(false, true), 1)); + assertTrue(BlockingTrigger.reached.await(30, TimeUnit.SECONDS)); + // The database goes away in the middle of a page + proxy.cut(); + BlockingTrigger.release.countDown(); + TransferReport report = saved(context, dialog); + assertEquals(TransferReport.Status.FAILED, report.getStatus()); + assertNotNull(report.getFailure()); + assertNotNull(report.getFailureTrace()); + assertTrue(report.getStoppedAt().contains("PUBLIC.BIG after "), report.getStoppedAt()); + assertEquals(TransferReport.TableStatus.FAILED, big(report).getStatus()); + assertEquals(ROWS, big(report).getSourceRows()); + // Rows of the target are not counted while it is down, the report is saved all the same + assertNull(big(report).getTargetRows()); + assertEquals(TransferReport.Kind.DATABASE, report.getKind()); + assertEquals(TransferReport.Transaction.EVERY_ROWS, report.getTransaction()); + } + + @Test + void reportsCancelKeepingCommittedPages() throws Exception { + createTarget(null); + BlockingTrigger.reset(1200); + ProgressDialog dialog = FxTestSupport.call(() -> mainView.startTransfer(request(false, true), 1)); + assertTrue(BlockingTrigger.reached.await(30, TimeUnit.SECONDS)); + FxTestSupport.run(((Button) dialog.getStage().getScene().getRoot().lookup("#cancelButton"))::fire); + BlockingTrigger.release.countDown(); + TransferReport report = saved(context, dialog); + assertEquals(TransferReport.Status.CANCELLED, report.getStatus()); + assertFalse(report.isRolledBack()); + assertNull(report.getFailure()); + assertTrue(report.getStoppedAt().contains("PUBLIC.BIG after "), report.getStoppedAt()); + // The pages which were committed before the cancel are kept and counted + long kept = targetRows(); + assertTrue(kept >= PAGE && kept < ROWS, "Kept " + kept); + assertEquals(kept, big(report).getTargetRows()); + // Rows of a table which was there are shown, not compared + assertNull(big(report).getVerified()); + } + + @Test + void reportsCancelRollingBackOneTransaction() throws Exception { + createTarget(null); + BlockingTrigger.reset(1200); + ProgressDialog dialog = FxTestSupport.call(() -> mainView.startTransfer(request(true, true), 1)); + assertTrue(BlockingTrigger.reached.await(30, TimeUnit.SECONDS)); + FxTestSupport.run(((Button) dialog.getStage().getScene().getRoot().lookup("#cancelButton"))::fire); + BlockingTrigger.release.countDown(); + TransferReport report = saved(context, dialog); + assertEquals(TransferReport.Status.CANCELLED, report.getStatus()); + assertTrue(report.isRolledBack()); + assertEquals(TransferReport.Transaction.ONE, report.getTransaction()); + assertEquals(0, targetRows()); + assertEquals(0L, big(report).getTargetRows()); + } + + @Test + void reportsRowWhichFailsWithPagesCommitted() throws Exception { + createTarget("ID <> 1700"); + BlockingTrigger.reset(-1); + ProgressDialog dialog = FxTestSupport.call(() -> mainView.startTransfer(request(false, true), 1)); + TransferReport report = saved(context, dialog); + assertEquals(TransferReport.Status.FAILED, report.getStatus()); + assertTrue(report.getFailure().contains("BIG_CHECK"), report.getFailure()); + long kept = targetRows(); + assertTrue(kept >= PAGE && kept < 1700, "Kept " + kept); + assertEquals(kept, big(report).getTargetRows()); + assertFalse(report.isRolledBack()); + } + + @Test + void reportsRowWhichIsSkipped() throws Exception { + createTarget("ID <> 1700"); + BlockingTrigger.reset(-1); + ProgressDialog dialog = FxTestSupport.call(() -> mainView.startTransfer(request(false, false), 1)); + TransferReport report = saved(context, dialog); + assertEquals(TransferReport.Status.COMPLETED_WITH_ERRORS, report.getStatus()); + assertEquals(1, report.getSkippedRowCount()); + assertEquals(1, big(report).getSkipped()); + assertEquals(ROWS - 1, big(report).getWritten()); + assertEquals(ROWS - 1L, big(report).getTargetRows()); + assertTrue(report.getSkippedRows().get(0).getKey().contains("1700"), report.getSkippedRows().get(0) + .getKey()); + assertTrue(report.getSkippedRows().get(0).getError().contains("BIG_CHECK")); + } + + @Test + void reportsPackageWhichCanNotBeWritten() throws Exception { + File file = new File(dir, "not-a-directory"); + Files.writeString(file.toPath(), "x"); + TransferRequest request = new TransferRequest(); + request.setSource(source); + request.setSourceSchema("PUBLIC"); + request.setTarget(TransferRequest.Target.SCRIPT); + request.setOutputDirectory(new File(file, "package")); + request.setScriptDbType(DbType.H2); + request.setDataFormat(DataFormat.CSV); + ProgressDialog dialog = FxTestSupport.call(() -> mainView.startTransfer(request, 1)); + TransferReport report = saved(context, dialog); + assertEquals(TransferReport.Status.FAILED, report.getStatus()); + assertEquals(TransferReport.Kind.PACKAGE, report.getKind()); + assertEquals(TransferReport.Transaction.NONE, report.getTransaction()); + assertNotNull(report.getFailure()); + } + + /** + * A package and its files into a database: the tables of the manifest with their rows, the objects which were + * created, and the rows of the target compared with them + */ + @Test + void reportsImportsOfPackagesAndFiles() throws Exception { + File packageDir = new File(dir, "package"); + TransferRequest export = new TransferRequest(); + export.setSource(source); + export.setSourceSchema("PUBLIC"); + export.setTarget(TransferRequest.Target.SCRIPT); + export.setOutputDirectory(packageDir); + export.setScriptDbType(DbType.H2); + export.setDataFormat(DataFormat.CSV); + context.getTransferService().transfer(export, ExportListener.NONE); + ExportManifest manifest = ExportManifest.read(packageDir); + + ProgressDialog dialog = FxTestSupport.call(() -> mainView.startImport(new ImportPackageDialog.ImportRequest( + packageDir, target, null, null, false, manifest, false, false, 0))); + TransferReport report = saved(context, dialog); + assertEquals(TransferReport.Status.COMPLETED, report.getStatus(), report.getFailure()); + assertEquals(TransferReport.Kind.PACKAGE_IMPORT, report.getKind()); + assertEquals(packageDir.getAbsolutePath(), report.getSource()); + assertEquals(ROWS, big(report).getSourceRows()); + assertEquals(1, report.getObjects().get("Tables")); + assertEquals((long) ROWS, big(report).getTargetRows()); + assertTrue(big(report).getVerified()); + + List files = manifest.getTables().get(0).getDataFiles().stream().map(path -> new File(packageDir, + path)).toList(); + ProgressDialog files2 = FxTestSupport.call(() -> mainView.startFileImport(new ImportParquetDialog + .ParquetRequest(files, "BIG_COPY", DataFileImporter.Mode.CREATE, List.of(), null, target, null, null, + true, false, false, 0))); + TransferReport fileReport = saved(context, files2); + assertEquals(TransferReport.Status.COMPLETED, fileReport.getStatus(), fileReport.getFailure()); + assertEquals(TransferReport.Kind.FILE_IMPORT, fileReport.getKind()); + assertEquals(TransferReport.Transaction.ONE, fileReport.getTransaction()); + TransferReport.TableReport copy = fileReport.getTables().get(0); + assertEquals("BIG_COPY", copy.getName()); + assertEquals((long) ROWS, copy.getTargetRows()); + assertEquals(2, context.getReportStore().list().size()); + } +} diff --git a/src/test/java/com/github/sqljam/face/view/ReportTabTest.java b/src/test/java/com/github/sqljam/face/view/ReportTabTest.java new file mode 100644 index 0000000..8c71293 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/ReportTabTest.java @@ -0,0 +1,253 @@ +/* + * 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.sql.SQLException; +import java.util.List; +import java.util.stream.Collectors; + +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.TransferReport; +import com.github.sqljam.face.service.ReportExporter; +import com.github.sqljam.face.service.ReportRecorder; +import javafx.application.Platform; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Label; +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.TitledPane; +import javafx.stage.Stage; + +/** + * @Description: ReportTabTest verifies the page of a report, the history of the imports, and how the main window + * opens them: one page a report and one history + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class ReportTabTest { + + @TempDir + File dir; + + private AppContext context; + private MainView mainView; + + @Start + void start(Stage stage) { + context = FxTestSupport.createContext(dir); + mainView = new MainView(context); + stage.setScene(new Scene(mainView, 1200, 800)); + stage.show(); + } + + private static TransferReport report(long startedAt, TransferReport.Status status) { + ReportRecorder recorder = new ReportRecorder(new TransferReport()); + TransferReport report = recorder.getReport(); + report.setTitle("Import into pg"); + report.setKind(TransferReport.Kind.DATABASE); + report.setSource("h2 / PUBLIC"); + report.setTarget("pg / app"); + report.getOptions().put("Batch size", "500"); + recorder.ddlExecuted("CREATE TABLE users (id INT)", true); + recorder.tableStarted(null, "app", "users", 3); + recorder.rowSkipped(null, null, "users", "id=2", new SQLException("bad date")); + recorder.tableEnded(null, "app", "users", 2); + recorder.tableStarted(null, "app", "orders", 10); + recorder.finish(status, status == TransferReport.Status.FAILED ? new SQLException("connection lost") + : null, null); + report.setStartedAt(startedAt); + report.setEndedAt(startedAt + 2000); + return report; + } + + /** + * Shows the tab in the main window, the content of its scroll pane is found once it is laid out + */ + private ReportTab show(TransferReport report) { + return FxTestSupport.call(() -> { + ReportTab tab = new ReportTab(report); + tabs().getTabs().add(tab); + tabs().getSelectionModel().select(tab); + mainView.applyCss(); + mainView.layout(); + return tab; + }); + } + + private TabPane tabs() { + return (TabPane) mainView.lookup("#tableTabs"); + } + + @SuppressWarnings("unchecked") + private static List> rows(Tab tab, String id) { + return FxTestSupport.call(() -> ((TableView>) tab.getContent().lookup("#" + id)).getItems() + .stream().collect(Collectors.toList())); + } + + @Test + void showsTheReport() { + TransferReport report = report(1_700_000_000_000L, TransferReport.Status.FAILED); + ReportTab tab = show(report); + Node content = tab.getContent(); + Label status = (Label) content.lookup("#reportStatus"); + assertEquals("Failed", FxTestSupport.call(status::getText)); + assertTrue(status.getStyleClass().contains("status-error")); + assertEquals("Import into pg", tab.getTooltip().getText()); + assertEquals(report.getId(), tab.getReport().getId()); + assertNotNull(content.lookup("#reportSummary")); + assertEquals(List.of("Batch size", "500"), rows(tab, "reportOptions").get(0)); + List> tables = rows(tab, "reportTables"); + assertEquals(2, tables.size()); + assertEquals(List.of("app.users", "Completed with errors", "3", "2", "1"), tables.get(0).subList(0, 5)); + assertEquals("Failed", tables.get(1).get(1)); + assertEquals(List.of("Tables", "1"), rows(tab, "reportObjects").get(0)); + assertEquals(List.of("users", "id=2", "bad date"), rows(tab, "reportSkippedRows").get(0)); + assertTrue(((TextArea) content.lookup("#reportTrace")).getText().contains("connection lost")); + // Options are folded, the tables are open + List sections = FxTestSupport.call(() -> content.lookupAll(".titled-pane").stream() + .map(TitledPane.class::cast).collect(Collectors.toList())); + assertFalse(sections.get(0).isExpanded()); + assertTrue(sections.get(1).isExpanded()); + } + + @Test + void showsAReportWithoutErrors() { + TransferReport report = new TransferReport(); + report.setStatus(TransferReport.Status.COMPLETED); + report.setKind(TransferReport.Kind.PACKAGE); + ReportTab tab = show(report); + Node content = tab.getContent(); + assertTrue(content.lookup("#reportStatus").getStyleClass().contains("status-success")); + assertNull(content.lookup("#reportOptions")); + assertNull(content.lookup("#reportObjects")); + assertNull(content.lookup("#reportSkippedRows")); + assertNull(content.lookup("#reportTrace")); + assertNotNull(content.lookup("#reportTables")); + } + + @Test + void exportsTheReport() throws Exception { + TransferReport report = report(1_700_000_000_000L, TransferReport.Status.COMPLETED_WITH_ERRORS); + ReportTab tab = FxTestSupport.call(() -> new ReportTab(report)); + for (ReportExporter.Format format : ReportExporter.Format.values()) { + File file = new File(dir, "report." + format.getExtension()); + FxTestSupport.run(() -> tab.exportTo(file, format)); + assertEquals(ReportExporter.export(report, format), Files.readString(file.toPath(), + StandardCharsets.UTF_8)); + } + } + + @Test + void opensOnePageAReport() { + TransferReport report = report(1_700_000_000_000L, TransferReport.Status.COMPLETED); + FxTestSupport.run(() -> context.getReportViewer().accept(report)); + FxTestSupport.run(() -> mainView.openReport(report)); + List reports = FxTestSupport.call(() -> tabs().getTabs().stream().filter(ReportTab.class::isInstance) + .collect(Collectors.toList())); + assertEquals(1, reports.size()); + assertEquals(reports.get(0), FxTestSupport.call(() -> tabs().getSelectionModel().getSelectedItem())); + FxTestSupport.run(() -> mainView.openReport(report(1_700_000_100_000L, TransferReport.Status.COMPLETED))); + assertEquals(2, FxTestSupport.call(() -> tabs().getTabs().stream().filter(ReportTab.class::isInstance) + .count())); + } + + @Test + void listsTheHistory() throws Exception { + TransferReport older = report(1_700_000_000_000L, TransferReport.Status.COMPLETED); + TransferReport newer = report(1_700_000_100_000L, TransferReport.Status.FAILED); + context.getReportStore().save(older); + context.getReportStore().save(newer); + // From the File menu, opened once + MenuItem item = FxTestSupport.menuItem(mainView, "historyMenuItem"); + FxTestSupport.run(item::fire); + FxTestSupport.run(item::fire); + List histories = FxTestSupport.call(() -> tabs().getTabs().stream().filter( + HistoryTab.class::isInstance).map(HistoryTab.class::cast).collect(Collectors.toList())); + assertEquals(1, histories.size()); + HistoryTab history = histories.get(0); + FxTestSupport.waitUntil(() -> history.getReports().size() == 2); + assertEquals(newer.getId(), history.getReports().get(0).getId()); + TableView table = history.getTable(); + assertEquals("Failed", FxTestSupport.call(() -> table.getColumns().get(4).getCellData(0))); + assertEquals("2", FxTestSupport.call(() -> table.getColumns().get(5).getCellData(0))); + assertEquals("2.0 s", FxTestSupport.call(() -> table.getColumns().get(6).getCellData(0))); + assertEquals("Database", FxTestSupport.call(() -> table.getColumns().get(1).getCellData(0))); + + // Open shows the selected report on its own page + Button open = (Button) history.getContent().lookup("#historyOpenButton"); + assertTrue(open.isDisabled()); + FxTestSupport.run(() -> table.getSelectionModel().select(1)); + FxTestSupport.run(open::fire); + ReportTab opened = (ReportTab) FxTestSupport.call(() -> tabs().getSelectionModel().getSelectedItem()); + assertEquals(older.getId(), opened.getReport().getId()); + + // A report of a new import shows in the open history + context.getReportStore().save(report(1_700_000_200_000L, TransferReport.Status.CANCELLED)); + FxTestSupport.run(() -> mainView.openReport(context.getReportStore().list().get(0))); + FxTestSupport.waitUntil(() -> history.getReports().size() == 3); + } + + @Test + void deletesFromTheHistory() throws Exception { + TransferReport report = report(1_700_000_000_000L, TransferReport.Status.COMPLETED); + context.getReportStore().save(report); + HistoryTab history = FxTestSupport.call(() -> new HistoryTab(context, opened -> { })); + FxTestSupport.run(() -> tabs().getTabs().add(history)); + FxTestSupport.waitUntil(() -> history.getReports().size() == 1); + // Kept when the question is cancelled + Platform.runLater(() -> history.delete(report)); + FxTestSupport.closeDialog(ButtonType.CANCEL); + assertEquals(1, context.getReportStore().list().size()); + Platform.runLater(() -> history.delete(report)); + FxTestSupport.closeDialog(ButtonType.OK); + FxTestSupport.waitUntil(() -> history.getReports().isEmpty()); + assertTrue(context.getReportStore().list().isEmpty()); + FxTestSupport.run(() -> history.delete(null)); + } + + @Test + void showsWhenTheHistoryCanNotBeRead() throws Exception { + File file = new File(dir, "not-a-directory"); + Files.writeString(file.toPath(), "x"); + context.setReportStore(new com.github.sqljam.face.service.ReportStore(file, 10)); + HistoryTab history = FxTestSupport.call(() -> new HistoryTab(context, opened -> { })); + FxTestSupport.waitUntil(() -> history.getReports().isEmpty()); + assertEquals("No imports yet, each import and export leaves its report here", FxTestSupport.call(() -> + ((Label) history.getTable().getPlaceholder()).getText())); + } +} diff --git a/src/test/java/com/github/sqljam/face/view/SqlTabTest.java b/src/test/java/com/github/sqljam/face/view/SqlTabTest.java index 00fe9b6..0ed8cec 100644 --- a/src/test/java/com/github/sqljam/face/view/SqlTabTest.java +++ b/src/test/java/com/github/sqljam/face/view/SqlTabTest.java @@ -284,4 +284,64 @@ void highlightsSyntax() { assertEquals(List.of("sql-keyword"), FxTestSupport.call(() -> List.copyOf(tab.getEditor().getCodeArea() .getStyleOfChar(1)))); } + + /** + * The schema of the editor is switched for the next statements, the data source keeps its own + */ + @Test + @SuppressWarnings("unchecked") + void switchesSchema() throws Exception { + try (java.sql.Connection connection = java.sql.DriverManager.getConnection(profile.getJdbcUrl(), "sa", ""); + java.sql.Statement statement = connection.createStatement()) { + statement.execute("CREATE SCHEMA IF NOT EXISTS SJ_OTHER"); + statement.execute("CREATE TABLE IF NOT EXISTS SJ_OTHER.T_OTHER (ID INT PRIMARY KEY)"); + statement.execute("MERGE INTO SJ_OTHER.T_OTHER KEY (ID) VALUES (7)"); + } + open(schemaNode()); + javafx.scene.control.ComboBox schemas = (javafx.scene.control.ComboBox) node( + "sqlSchemaCombo"); + javafx.scene.control.ComboBox catalogs = (javafx.scene.control.ComboBox) node( + "sqlCatalogCombo"); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> schemas.getItems().contains("SJ_OTHER") + && UiDatabase.SCHEMA.equals(schemas.getValue()))); + assertEquals(FxTestSupport.call(catalogs::isVisible), true); + // The table of the other schema is not found in the schema of the tab + type("SELECT ID FROM T_OTHER", 0); + runAndWait(false); + assertTrue(messages().stream().anyMatch(message -> message.contains("T_OTHER")), messages().toString()); + + FxTestSupport.run(() -> schemas.setValue("SJ_OTHER")); + FxTestSupport.waitUntil(() -> messages().stream().anyMatch(message -> message.startsWith( + "Statements run in"))); + assertEquals("SJ_OTHER", tab.getSchema()); + assertTrue(FxTestSupport.call(() -> tab.getTooltip().getText()).endsWith("SJ_OTHER")); + runAndWait(false); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> results().getItems().size() == 1)); + assertEquals("7", FxTestSupport.call(() -> results().getItems().get(0).get(0))); + // The data source keeps its own database and schema + assertEquals(profile.getDatabase(), context.getProfileRegistry().findProfile(profile.getId()).orElseThrow() + .getDatabase()); + // Not switched while statements run + FxTestSupport.run(() -> tab.run(false)); + FxTestSupport.waitUntil(() -> !tab.isBusy()); + assertEquals(false, FxTestSupport.call(schemas::isDisabled)); + } + + /** + * A switch before the first statement opens the connection in the chosen schema + */ + @Test + void switchesBeforeFirstStatement() throws Exception { + try (java.sql.Connection connection = java.sql.DriverManager.getConnection(profile.getJdbcUrl(), "sa", ""); + java.sql.Statement statement = connection.createStatement()) { + statement.execute("CREATE SCHEMA IF NOT EXISTS SJ_FIRST"); + statement.execute("CREATE TABLE IF NOT EXISTS SJ_FIRST.T_FIRST (ID INT PRIMARY KEY)"); + } + open(schemaNode()); + FxTestSupport.run(() -> tab.switchTo(tab.getCatalog(), "SJ_FIRST")); + type("SELECT COUNT(*) FROM T_FIRST", 0); + runAndWait(false); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> results().getItems().size() == 1)); + assertEquals("0", FxTestSupport.call(() -> results().getItems().get(0).get(0))); + } } diff --git a/src/test/java/com/github/sqljam/face/view/StartupTest.java b/src/test/java/com/github/sqljam/face/view/StartupTest.java new file mode 100644 index 0000000..1c1d562 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/StartupTest.java @@ -0,0 +1,386 @@ +/* + * 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.sql.SQLException; +import java.util.List; + +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 org.testfx.util.WaitForAsyncUtils; +import com.github.sqljam.face.model.AppSettings; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.utils.SshSettings; +import javafx.application.Platform; +import javafx.geometry.Rectangle2D; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; +import javafx.scene.control.DialogPane; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.SplitPane; +import javafx.scene.control.TextField; +import javafx.scene.control.TreeItem; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; + +/** + * @Description: StartupTest verifies the start in the main window like DBeaver and DataGrip: the password of a data + * source which is not remembered is asked when it is connected, a refused password is asked again, + * the data sources which were connected are connected again, a window is placed on a screen which + * exists, and the welcome page creates a data source + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class StartupTest { + + @TempDir + File dir; + + private Stage stage; + private AppContext context; + private ConnectionProfile profile; + private MainView mainView; + + @Start + void start(Stage stage) throws Exception { + this.stage = stage; + context = FxTestSupport.createContext(dir); + profile = UiDatabase.create(dir, "Embedded H2"); + stage.setScene(new Scene(new StackPane(), 400, 300)); + stage.show(); + } + + @AfterEach + void close() { + FxTestSupport.closeAllDialogs(); + context.getSessionManager().closeAll(); + } + + /** + * A new start: the main window of the saved data sources, as SqlJam shows it + */ + private MainView restart() { + context.getSessionManager().closeAll(); + context = FxTestSupport.createContext(dir); + mainView = FxTestSupport.call(() -> { + MainView view = new MainView(context); + stage.setScene(new Scene(view, 1200, 800)); + view.applyCss(); + view.layout(); + return view; + }); + return mainView; + } + + private ConnectionTree tree() { + return (ConnectionTree) mainView.lookup("#connectionTree"); + } + + private TreeItem connection() { + return FxTestSupport.call(() -> tree().getConnectionItems().stream().filter(item -> "Embedded H2".equals( + item.getValue().getLabel())).findFirst().orElseThrow()); + } + + private static boolean connected(TreeItem item) { + return FxTestSupport.call(() -> !item.getChildren().isEmpty() && item.getChildren().stream().noneMatch( + child -> child.getValue().getKind() == DbNode.Kind.LOADING || child.getValue().getKind() + == DbNode.Kind.MESSAGE)); + } + + private static DialogPane credentials() { + FxTestSupport.waitUntil(() -> FxTestSupport.getDialogPanes().stream().anyMatch(pane -> pane.lookup( + "#credentialsSecretField") != null)); + return FxTestSupport.getDialogPanes().stream().filter(pane -> pane.lookup("#credentialsSecretField") + != null).findFirst().orElseThrow(); + } + + private static void enter(DialogPane pane, String password, boolean save) { + FxTestSupport.run(() -> { + ((PasswordField) pane.lookup("#credentialsSecretField")).setText(password); + ((CheckBox) pane.lookup("#credentialsSaveCheck")).setSelected(save); + ((Button) pane.lookupButton(ButtonType.OK)).fire(); + }); + } + + @Test + void asksPasswordWhichIsNotRemembered() throws Exception { + context.getProfileRegistry().saveProfile(profile, false); + restart(); + TreeItem item = connection(); + // Cancelled, nothing is connected + FxTestSupport.run(() -> item.setExpanded(true)); + DialogPane pane = credentials(); + assertEquals("sa", FxTestSupport.call(() -> ((TextField) pane.lookup("#credentialsUsernameField")) + .getText())); + assertTrue(pane.getHeaderText().contains("Embedded H2")); + assertNull(pane.lookup("#credentialsMessageLabel")); + FxTestSupport.run(() -> ((Button) pane.lookupButton(ButtonType.CANCEL)).fire()); + assertFalse(FxTestSupport.call(item::isExpanded)); + assertFalse(context.getSettings().getConnectedSources().contains(profile.getId())); + + // Entered and saved, it is connected and remembered + FxTestSupport.run(() -> item.setExpanded(true)); + enter(credentials(), "", true); + FxTestSupport.waitUntil(() -> connected(item)); + assertTrue(context.getProfileRegistry().isPasswordRemembered(profile.getId())); + assertTrue(context.getSettings().getConnectedSources().contains(profile.getId())); + } + + @Test + void asksRefusedPasswordAgain() throws Exception { + profile.setPassword("wrong"); + context.getProfileRegistry().saveProfile(profile, true); + restart(); + TreeItem item = connection(); + FxTestSupport.run(() -> item.setExpanded(true)); + // The error of the database is shown, the password is kept for the session only + DialogPane pane = credentials(); + assertNotNull(pane.lookup("#credentialsMessageLabel")); + assertFalse(FxTestSupport.call(() -> ((Label) pane.lookup("#credentialsMessageLabel")).getText()).isEmpty()); + enter(pane, "", false); + FxTestSupport.waitUntil(() -> connected(item)); + assertFalse(context.getProfileRegistry().isPasswordRemembered(profile.getId())); + assertEquals("", context.getProfileRegistry().findProfile(profile.getId()).orElseThrow().getPassword()); + // Cancelled after a refusal, the error is shown in the tree + profile.setPassword("wrong again"); + context.getProfileRegistry().saveProfile(profile, true); + restart(); + TreeItem again = connection(); + FxTestSupport.run(() -> again.setExpanded(true)); + DialogPane refused = credentials(); + FxTestSupport.run(() -> ((Button) refused.lookupButton(ButtonType.CANCEL)).fire()); + FxTestSupport.closeAllDialogs(); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> again.getChildren().stream().anyMatch(child -> child + .getValue().getKind() == DbNode.Kind.MESSAGE))); + } + + @Test + void connectsAgainWhatWasConnected() throws Exception { + context.getProfileRegistry().saveProfile(profile, true); + restart(); + TreeItem item = connection(); + FxTestSupport.run(() -> item.setExpanded(true)); + FxTestSupport.waitUntil(() -> connected(item)); + assertEquals(List.of(profile.getId()), context.getSettings().getConnectedSources()); + context.getSettings().getConnectedSources().add("deleted meanwhile"); + context.saveSettings(); + + // The next start connects it again, a data source which was deleted is forgotten + restart(); + assertTrue(context.getSettings().getConnectedSources().contains(profile.getId())); + FxTestSupport.run(mainView::restoreConnected); + TreeItem restored = connection(); + FxTestSupport.waitUntil(() -> connected(restored)); + assertFalse(context.getSettings().getConnectedSources().contains("deleted meanwhile")); + + // Disconnected, the next start leaves it + FxTestSupport.run(() -> tree().disconnect(restored)); + assertFalse(context.getSettings().getConnectedSources().contains(profile.getId())); + restart(); + assertTrue(context.getSettings().getConnectedSources().isEmpty()); + } + + @Test + void createsDataSourceFromWelcome() throws Exception { + restart(); + Button button = FxTestSupport.call(() -> (Button) mainView.lookup("#welcomeNewDataSourceButton")); + assertTrue(FxTestSupport.call(button::isVisible)); + FxTestSupport.fireLater(button); + FxTestSupport.waitUntil(() -> FxTestSupport.getDialogPanes().stream().anyMatch(pane -> pane.lookup( + "#categoryCombo") != null)); + } + + /** + * A first layout before the view has a width keeps the tree narrow otherwise, its share is set again + */ + @Test + void keepsWidthOfTree() { + // A window of its own, the window of the tests has the size which other tests left + Stage window = FxTestSupport.call(() -> { + Stage created = new Stage(); + created.setScene(new Scene(new StackPane(), 1280, 800)); + created.show(); + return created; + }); + try { + StackPane root = FxTestSupport.call(() -> (StackPane) window.getScene().getRoot()); + MainView view = FxTestSupport.call(() -> { + MainView created = new MainView(context); + root.getChildren().setAll(created); + // Laid out before the root gives it its width + created.applyCss(); + created.layout(); + return created; + }); + SplitPane split = FxTestSupport.call(() -> (SplitPane) view.getCenter()); + // The tree has its share of the width, not the narrow width of the first layout + long deadline = System.currentTimeMillis() + 30_000; + String state = ""; + while (System.currentTimeMillis() < deadline) { + state = FxTestSupport.call(() -> split.getWidth() + " " + split.getDividerPositions()[0] + " " + split + .getItems().get(0).getLayoutBounds().getWidth() + " window " + window.getWidth() + " scene " + + window.getScene().getWidth()); + if (FxTestSupport.call(() -> split.getWidth() > MainView.MIN_SPLIT_WIDTH && Math.abs(split + .getDividerPositions()[0] - MainView.TREE_WIDTH) < 0.01 && Math.abs(split.getItems().get(0) + .getLayoutBounds().getWidth() / split.getWidth() - MainView.TREE_WIDTH) < 0.02)) { + state = null; + break; + } + WaitForAsyncUtils.sleep(100, java.util.concurrent.TimeUnit.MILLISECONDS); + } + assertNull(state, "Width, divider, tree, window and scene: " + state); + // Dragged by the user: the tree keeps its width when the window is resized (narrower, the headless + // screen of the tests is 1280 wide) + FxTestSupport.run(() -> { + Node divider = split.lookup(".split-pane-divider"); + divider.fireEvent(new MouseEvent(MouseEvent.MOUSE_PRESSED, 0, 0, 0, 0, MouseButton.PRIMARY, 1, false, + false, false, false, true, false, false, false, false, false, null)); + split.setDividerPositions(0.3); + }); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> Math.abs(split.getDividerPositions()[0] - 0.3) + < 0.01)); + double tree = FxTestSupport.call(() -> split.getItems().get(0).getLayoutBounds().getWidth()); + FxTestSupport.run(() -> window.setWidth(1100)); + FxTestSupport.waitUntil(() -> FxTestSupport.call(() -> split.getWidth() < 1150)); + WaitForAsyncUtils.waitForFxEvents(); + assertEquals(tree, FxTestSupport.call(() -> split.getItems().get(0).getLayoutBounds().getWidth()), 2); + } finally { + FxTestSupport.run(window::close); + } + } + + @Test + void placesWindowOnScreens() { + Rectangle2D laptop = new Rectangle2D(0, 25, 1440, 875); + Rectangle2D external = new Rectangle2D(1440, 0, 2560, 1440); + // On the screen it was closed on + assertEquals(new Rectangle2D(1600, 100, 1280, 800), WindowPlacement.fit(1600, 100, 1280, 800, List.of( + laptop, external), laptop)); + // The external display was unplugged: centered on the laptop + assertEquals(new Rectangle2D(80, 62.5, 1280, 800), WindowPlacement.fit(1600, 100, 1280, 800, List.of( + laptop), laptop)); + // Never placed before + assertEquals(new Rectangle2D(80, 62.5, 1280, 800), WindowPlacement.fit(Double.NaN, Double.NaN, 1280, 800, + List.of(laptop), laptop)); + // Larger than the screen: reduced and moved into it + assertEquals(new Rectangle2D(0, 25, 1440, 875), WindowPlacement.fit(100, 30, 2400, 1300, List.of(laptop), + laptop)); + // The title bar above the screen can not be dragged + assertEquals(new Rectangle2D(80, 62.5, 1280, 800), WindowPlacement.fit(100, -200, 1280, 800, List.of( + laptop), laptop)); + // Partly out at the right, the title bar is still on the screen: moved in + assertEquals(new Rectangle2D(160, 100, 1280, 800), WindowPlacement.fit(700, 100, 1280, 800, List.of( + laptop), laptop)); + } + + @Test + void savesAndRestoresPlacement() { + AppSettings settings = new AppSettings(); + FxTestSupport.run(() -> { + stage.setX(120); + stage.setY(90); + stage.setWidth(900); + stage.setHeight(600); + WindowPlacement.save(stage, settings); + }); + assertEquals(120, settings.getWindowX()); + assertEquals(600, settings.getWindowHeight()); + assertFalse(settings.isWindowMaximized()); + FxTestSupport.run(() -> { + stage.setX(400); + WindowPlacement.restore(stage, settings); + }); + assertEquals(900, FxTestSupport.call(stage::getWidth)); + } + + @Test + void tellsRefusedLogins() { + assertTrue(AuthFailures.isDatabaseFailure(new SQLException("Access denied", "28000", 1045))); + assertTrue(AuthFailures.isDatabaseFailure(new SQLException("ORA-01017", "72000", 1017))); + assertTrue(AuthFailures.isDatabaseFailure(new SQLException("Login failed", "S0001", 18456))); + assertTrue(AuthFailures.isDatabaseFailure(new SQLException("x", "28P01", 0))); + assertTrue(AuthFailures.isDatabaseFailure(new RuntimeException(new SQLException( + "FATAL: password authentication failed for user")))); + assertTrue(AuthFailures.isDatabaseFailure(new IllegalStateException("Code: 516. AUTHENTICATION_FAILED"))); + assertFalse(AuthFailures.isDatabaseFailure(new SQLException("Connection refused", "08001", 0))); + assertFalse(AuthFailures.isDatabaseFailure(new IOException((String) null))); + + IOException ssh = new IOException("SSH tunnel through deploy@bastion:22 failed: No more authentication " + + "methods available"); + assertTrue(AuthFailures.isSshFailure(new RuntimeException(ssh))); + assertFalse(AuthFailures.isDatabaseFailure(new RuntimeException("SSH tunnel through deploy@bastion:22 " + + "failed: Authentication failed"))); + assertTrue(AuthFailures.isSshFailure(new IOException("SSH tunnel through a@b:22 failed: unable to read " + + "the private key ~/.ssh/id"))); + assertFalse(AuthFailures.isSshFailure(new IOException("SSH tunnel through a@b:22 failed: the SSH server " + + "does not reach db:5432"))); + assertFalse(AuthFailures.isSshFailure(new IOException("No more authentication methods available"))); + } + + /** + * A tunnel which the SSH server refused: the password of the tunnel is asked, not the one of the database + */ + @Test + void asksPasswordOfRefusedTunnel() throws Exception { + SshSettings ssh = new SshSettings(); + ssh.setEnabled(true); + ssh.setHost("bastion"); + ssh.setUsername("deploy"); + profile.setDbType(DbType.MYSQL); + profile.setHostname("localhost"); + profile.setPort(3306); + profile.setSsh(ssh); + context.getProfileRegistry().saveProfile(profile, true); + restart(); + DbNode node = connection().getValue(); + RuntimeException refused = new RuntimeException(new IOException("SSH tunnel through deploy@bastion:22 " + + "failed: No more authentication methods available")); + boolean[] asked = new boolean[1]; + Platform.runLater(() -> asked[0] = tree().askAfterFailure(node, refused)); + DialogPane pane = credentials(); + assertNull(pane.lookup("#credentialsUsernameField")); + enter(pane, "ssh-secret", false); + FxTestSupport.waitUntil(() -> asked[0]); + assertEquals("ssh-secret", node.getProfile().getSsh().getPassword()); + // Kept for the session, not in the file + assertEquals("ssh-secret", context.getProfileRegistry().findProfile(profile.getId()).orElseThrow().getSsh() + .getPassword()); + // Another error asks nothing + assertFalse(FxTestSupport.call(() -> tree().askAfterFailure(node, new SQLException("refused", "08001", + 0)))); + } +} diff --git a/src/test/java/com/github/sqljam/face/view/SyncDemo.java b/src/test/java/com/github/sqljam/face/view/SyncDemo.java new file mode 100644 index 0000000..987d346 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/SyncDemo.java @@ -0,0 +1,339 @@ +/* + * 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.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.concurrent.Callable; + +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.SyncTask; +import com.github.sqljam.it.ItDatabase; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonBase; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ComboBox; +import javafx.scene.control.DialogPane; +import javafx.scene.control.Label; +import javafx.scene.control.MenuBar; +import javafx.scene.control.MenuItem; +import javafx.scene.control.TabPane; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.layout.BorderPane; +import javafx.stage.Stage; +import javafx.stage.Window; + +/** + * @Description: SyncDemo shows the scheduled sync on the screen, step by step with captions: a task is created in + * its dialog (MySQL orders into PostgreSQL by the time of the last update), it runs and shows its + * report, rows of the source change and the next run copies only them, and the history lists both + * runs. It is not a test of the build (the name does not end with Test), run it by + * mvn test -Pui -Dtest=SyncDemo + * on the databases of the integration tests, its tables are dropped when the tests are cleaned up. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class SyncDemo { + + private static final String STYLESHEET = "/com/github/sqljam/face/app.css"; + private static final String TABLE = "sjm_sync_orders"; + private static final String SCHEMA = "sjit_sync"; + private static final long STEP = Long.getLong("demo.step", 1600); + + @TempDir + File dir; + + private Stage stage; + private AppContext context; + private MainView mainView; + private Label caption; + private ConnectionProfile mysql; + private ConnectionProfile postgresql; + + @Start + void start(Stage stage) throws Exception { + this.stage = stage; + FxTestSupport.run(() -> ThemeManager.apply(ThemeManager.DEFAULT_THEME)); + context = FxTestSupport.createContext(dir); + mysql = ItDatabase.MYSQL.getSourceProfile(); + mysql.setName("MySQL on 63"); + context.getProfileRegistry().saveProfile(mysql, true); + postgresql = ItDatabase.POSTGRESQL.getSourceProfile(); + postgresql.setName("PostgreSQL on 63"); + context.getProfileRegistry().saveProfile(postgresql, true); + resetData(); + mainView = new MainView(context); + caption = new Label(); + caption.setWrapText(true); + caption.setMaxWidth(Double.MAX_VALUE); + caption.setPadding(new Insets(12, 18, 12, 18)); + caption.setStyle("-fx-font-size: 17px; -fx-text-fill: white; -fx-background-color: #1f6feb;"); + BorderPane root = new BorderPane(mainView); + root.setBottom(caption); + Scene scene = new Scene(root, 1360, 860); + scene.getStylesheets().add(SyncDemo.class.getResource(STYLESHEET).toExternalForm()); + stage.setScene(scene); + stage.setTitle("SqlJam: scheduled sync demo"); + Branding.applyIcons(stage); + stage.show(); + stage.centerOnScreen(); + } + + /** + * Five orders in MySQL, no copy in PostgreSQL yet + */ + private void resetData() throws Exception { + try (Connection connection = ItDatabase.MYSQL.getSourceConnection(); + Statement statement = connection.createStatement()) { + statement.execute("DROP TABLE IF EXISTS " + TABLE); + statement.execute("CREATE TABLE " + TABLE + " (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, customer " + + "VARCHAR(50), amount DECIMAL(10,2), updated_at TIMESTAMP(3) NOT NULL DEFAULT " + + "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))"); + statement.execute("INSERT INTO " + TABLE + " (customer, amount) VALUES ('Alice', 120.00), ('Bob', " + + "80.50), ('Carol', 300.00), ('Dave', 45.25), ('Eve', 99.99)"); + } + try (Connection connection = DriverManager.getConnection(postgresql.getJdbcUrl(), postgresql.getUsername(), + postgresql.getPassword()); Statement statement = connection.createStatement()) { + statement.execute("DROP SCHEMA IF EXISTS " + SCHEMA + " CASCADE"); + } + } + + private static T onFx(Callable callable) { + return FxTestSupport.call(callable); + } + + private void say(String text) { + FxTestSupport.run(() -> caption.setText(text)); + pause(STEP + text.length() * 25L); + } + + private static void pause(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** + * Glows the node a moment, so that the step is seen before it happens + */ + private static void highlight(Node node) { + String style = onFx(node::getStyle); + FxTestSupport.run(() -> node.setStyle(style + "; -fx-effect: dropshadow(gaussian, #ffb000, 18, 0.75, 0, 0);")); + pause(900); + FxTestSupport.run(() -> node.setStyle(style)); + } + + private static void press(Node node) { + highlight(node); + Platform.runLater(((ButtonBase) node)::fire); + pause(700); + } + + private static void type(TextField field, String text) { + highlight(field); + FxTestSupport.run(field::clear); + for (char c : text.toCharArray()) { + FxTestSupport.run(() -> field.appendText(String.valueOf(c))); + pause(60); + } + pause(400); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static void choose(Node node, Object value) { + ComboBox combo = (ComboBox) node; + highlight(combo); + FxTestSupport.run(combo::show); + pause(900); + FxTestSupport.run(() -> { + combo.setValue(value); + combo.hide(); + }); + pause(500); + } + + /** + * Node of the id in the topmost window which has it, e.g. a dialog over the main window + */ + private static Node top(String id) { + long deadline = System.currentTimeMillis() + 60_000; + while (System.currentTimeMillis() < deadline) { + Node node = 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 found = window.getScene().getRoot().lookup("#" + id); + if (found != null && found.isVisible()) { + return found; + } + } + } + return null; + }); + if (node != null) { + return node; + } + pause(100); + } + throw new AssertionError("#" + id + " is not shown"); + } + + private void menu(String id) { + MenuItem item = FxTestSupport.menuItem(mainView, id); + FxTestSupport.run(() -> item.getParentMenu().show()); + pause(1200); + FxTestSupport.run(() -> { + item.getParentMenu().hide(); + item.fire(); + }); + pause(900); + } + + private SyncTask task() { + return context.getSyncScheduler().getStore().getTasks().get(0); + } + + private String targetRows() throws Exception { + StringBuilder rows = new StringBuilder(); + try (Connection connection = DriverManager.getConnection(postgresql.getJdbcUrl(), postgresql.getUsername(), + postgresql.getPassword()); Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SELECT customer, amount FROM " + SCHEMA + "." + TABLE + + " ORDER BY id")) { + while (rs.next()) { + rows.append(rows.length() > 0 ? ", " : "").append(rs.getString(1)).append(' ').append(rs + .getBigDecimal(2)); + } + } + return rows.toString(); + } + + private void runNow(SyncTab tab) { + TableView table = tab.getTable(); + FxTestSupport.run(() -> table.getSelectionModel().select(0)); + long runs = task().getLastRunAt(); + press(tab.getContent().lookup("#syncRunButton")); + FxTestSupport.waitUntil(() -> task().getLastRunAt() != runs && !context.getSyncScheduler().isRunning(task() + .getId())); + FxTestSupport.run(tab::refresh); + pause(1500); + } + + @Test + @SuppressWarnings("unchecked") + void showsScheduledSync() throws Exception { + say("Scheduled sync: keep a copy of MySQL orders in PostgreSQL, copying only the rows which changed."); + menu("syncMenuItem"); + SyncTab tab = onFx(() -> (SyncTab) ((TabPane) mainView.lookup("#tableTabs")).getSelectionModel() + .getSelectedItem()); + say("Transfer > Sync Tasks lists the tasks. New creates one."); + press(tab.getContent().lookup("#syncAddButton")); + + say("Name the task and choose the source: the MySQL data source and its database."); + type((TextField) top("syncNameField"), "Orders to PostgreSQL"); + ComboBox sources = (ComboBox) top("syncSourceCombo"); + choose(sources, onFx(() -> sources.getItems().stream().filter(profile -> profile.getName().equals( + "MySQL on 63")).findFirst().orElseThrow())); + ComboBox catalogs = (ComboBox) top("syncSourceCatalogCombo"); + FxTestSupport.waitUntil(() -> catalogs.getItems().contains(ItDatabase.MYSQL.getSourceCatalog())); + choose(catalogs, ItDatabase.MYSQL.getSourceCatalog()); + TableView tables = (TableView) top("syncTables"); + FxTestSupport.waitUntil(() -> tables.getItems().stream().anyMatch(row -> row.name.equals(TABLE))); + SyncTaskDialog.TableRow orders = onFx(() -> tables.getItems().stream().filter(row -> row.name.equals(TABLE)) + .findFirst().orElseThrow()); + say("Check the table of the orders. SqlJam picks its watermark: updated_at, the time of the last change."); + FxTestSupport.run(() -> { + tables.scrollTo(orders); + orders.selected.set(true); + }); + FxTestSupport.waitUntil(() -> "updated_at".equalsIgnoreCase(orders.watermarkColumn.get())); + highlight(top("syncWatermarkCombo")); + highlight(top("syncWatermarkTypeCombo")); + + say("The target: PostgreSQL, into a schema of its own. Missing tables are created with their keys."); + ComboBox targets = (ComboBox) top("syncTargetCombo"); + choose(targets, onFx(() -> targets.getItems().stream().filter(profile -> profile.getName().equals( + "PostgreSQL on 63")).findFirst().orElseThrow())); + ComboBox schemas = (ComboBox) top("syncTargetSchemaCombo"); + FxTestSupport.waitUntil(() -> !schemas.getItems().isEmpty()); + highlight(schemas); + FxTestSupport.run(() -> schemas.setValue(SCHEMA)); + pause(600); + + say("Every 15 minutes, run by SqlJam while it is open. The system scheduler would run it when SqlJam is " + + "closed too."); + ComboBox schedules = (ComboBox) top("syncScheduleCombo"); + choose(schedules, SyncTask.ScheduleType.INTERVAL); + highlight(top("syncIntervalSpinner")); + FxTestSupport.run(() -> ((javafx.scene.control.Spinner) top("syncIntervalSpinner")) + .getValueFactory().setValue(15)); + highlight(top("syncRunnerCombo")); + DialogPane pane = onFx(() -> (DialogPane) top("syncNameField").getScene().getRoot()); + press(onFx(() -> pane.lookupButton(ButtonType.OK))); + FxTestSupport.waitUntil(() -> !tab.getTasks().isEmpty()); + + say("Run Now: the first run copies all rows. The watermark is read before the rows."); + runNow(tab); + say("Completed, 5 rows. PostgreSQL has: " + targetRows() + "."); + say("Last Report opens the report of the run: rows of each table, checked in the target, and the " + + "watermark."); + press(tab.getContent().lookup("#syncReportButton")); + pause(3500); + FxTestSupport.run(() -> ((TabPane) mainView.lookup("#tableTabs")).getSelectionModel().select(tab)); + + try (Connection connection = ItDatabase.MYSQL.getSourceConnection(); + Statement statement = connection.createStatement()) { + statement.execute("UPDATE " + TABLE + " SET amount = 150.00 WHERE customer = 'Alice'"); + statement.execute("UPDATE " + TABLE + " SET customer = 'Bob Jr.' WHERE customer = 'Bob'"); + statement.execute("INSERT INTO " + TABLE + " (customer, amount) VALUES ('Frank', 60.00), ('Grace', " + + "210.00)"); + statement.execute("DELETE FROM " + TABLE + " WHERE customer = 'Dave'"); + } + say("Meanwhile in MySQL: Alice is now 150.00, Bob is renamed Bob Jr., Frank and Grace are new, Dave is " + + "deleted."); + say("Run Now again: only the rows after the watermark are read, and updated or inserted by key."); + runNow(tab); + say("PostgreSQL now has: " + targetRows() + ". Dave stays: deleted rows are kept in the target."); + press(tab.getContent().lookup("#syncReportButton")); + say("The report tells the rows which changed since the last run and the new watermark."); + pause(3000); + + say("Transfer > Import History keeps the report of every run, next to the imports and exports."); + menu("historyMenuItem"); + pause(4000); + say("That is the scheduled sync. The tables of this demo are dropped with the other test tables."); + pause(2000); + } +} diff --git a/src/test/java/com/github/sqljam/face/view/SyncTabTest.java b/src/test/java/com/github/sqljam/face/view/SyncTabTest.java new file mode 100644 index 0000000..db20fc0 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/SyncTabTest.java @@ -0,0 +1,366 @@ +/* + * 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.assertNull; +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.concurrent.TimeUnit; +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.ColumnInfo; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.model.SyncTask; +import com.github.sqljam.face.model.TransferReport; +import com.github.sqljam.impexp.DbType; +import javafx.application.Platform; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.MenuBar; +import javafx.scene.control.MenuItem; +import javafx.scene.control.TabPane; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.stage.Stage; + +/** + * @Description: SyncTabTest creates a sync task in its dialog between two H2 databases, the watermark column is + * guessed, wrong input is refused, and the list of the tasks runs it now, shows its status and rows, + * opens its report, switches it off and deletes it. An edited task keeps its watermarks. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class SyncTabTest { + + @TempDir + File dir; + + private AppContext context; + private MainView mainView; + private Stage stage; + private ConnectionProfile source; + private ConnectionProfile target; + + private ConnectionProfile h2(String name) throws Exception { + ConnectionProfile profile = new ConnectionProfile(); + profile.setName(name); + profile.setDbType(DbType.H2); + profile.setDatabase(new File(dir, name).getAbsolutePath()); + profile.setUsername("sa"); + profile.setPassword(""); + context.getProfileRegistry().saveProfile(profile, true); + return profile; + } + + @Start + void start(Stage stage) throws Exception { + this.stage = stage; + context = FxTestSupport.createContext(dir); + source = h2("source"); + target = h2("target"); + try (Connection connection = DriverManager.getConnection(source.getJdbcUrl(), "sa", ""); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE ORDERS (ID INT PRIMARY KEY, NAME VARCHAR(20), UPDATED_AT TIMESTAMP)"); + statement.execute("CREATE TABLE NOTES (CODE VARCHAR(10) PRIMARY KEY, TEXT VARCHAR(20))"); + for (int i = 1; i <= 4; i++) { + statement.execute("INSERT INTO ORDERS VALUES (" + i + ", 'order " + i + "', TIMESTAMP " + + "'2026-10-01 08:0" + i + ":00')"); + } + } + mainView = new MainView(context); + stage.setScene(new Scene(mainView, 1200, 800)); + stage.show(); + } + + @AfterEach + void close() { + FxTestSupport.closeAllDialogs(); + context.closeSyncScheduler(); + context.getSessionManager().closeAll(); + } + + private TabPane tabs() { + return (TabPane) mainView.lookup("#tableTabs"); + } + + private SyncTab openSync() { + MenuItem item = FxTestSupport.menuItem(mainView, "syncMenuItem"); + FxTestSupport.run(item::fire); + FxTestSupport.run(item::fire); + List tabs = FxTestSupport.call(() -> tabs().getTabs().stream().filter(SyncTab.class::isInstance) + .map(SyncTab.class::cast).collect(Collectors.toList())); + assertEquals(1, tabs.size()); + return tabs.get(0); + } + + @SuppressWarnings("unchecked") + private static T node(SyncTaskDialog dialog, String id) { + return (T) dialog.getFields().lookup("#" + id); + } + + /** + * The dialog of a new task: ORDERS with its update time, into the target + */ + private SyncTaskDialog dialog(SyncTask task) { + SyncTaskDialog dialog = FxTestSupport.call(() -> new SyncTaskDialog(stage, context, task)); + if (task == null) { + FxTestSupport.run(() -> { + ((TextField) node(dialog, "syncNameField")).setText("orders"); + ComboBox sources = node(dialog, "syncSourceCombo"); + sources.setValue(sources.getItems().stream().filter(profile -> profile.getName().equals("source")) + .findFirst().orElseThrow()); + }); + } + TableView tables = node(dialog, "syncTables"); + FxTestSupport.waitUntil(() -> tables.getItems().size() == 2); + return dialog; + } + + private static SyncTaskDialog.TableRow row(SyncTaskDialog dialog, String name) { + TableView tables = node(dialog, "syncTables"); + return tables.getItems().stream().filter(row -> row.name.equals(name)).findFirst().orElseThrow(); + } + + @Test + void createsAndRunsTasks() throws Exception { + SyncTab tab = openSync(); + assertTrue(tab.getTasks().isEmpty()); + SyncTaskDialog dialog = dialog(null); + assertEquals(List.of(SyncTask.Kind.DATABASE, SyncTask.Kind.VECTOR), FxTestSupport.call(() -> + ((ComboBox) node(dialog, "syncKindCombo")).getItems())); + assertEquals("Choose the target data source.", FxTestSupport.call(dialog::validate)); + FxTestSupport.run(() -> { + ComboBox targets = node(dialog, "syncTargetCombo"); + targets.setValue(targets.getItems().stream().filter(profile -> profile.getName().equals("target")) + .findFirst().orElseThrow()); + }); + assertEquals("Check at least one table.", FxTestSupport.call(dialog::validate)); + // ORDERS is checked and shown, its update time is guessed + SyncTaskDialog.TableRow orders = row(dialog, "ORDERS"); + FxTestSupport.run(() -> orders.selected.set(true)); + FxTestSupport.waitUntil(() -> "UPDATED_AT".equals(orders.watermarkColumn.get())); + assertEquals(SyncTask.WatermarkType.UPDATED_AT, orders.watermarkType.get()); + // The target schema is the source schema of the same database file is not, PUBLIC of another one is fine + FxTestSupport.waitUntil(() -> "PUBLIC".equals(((ComboBox) node(dialog, "syncTargetSchemaCombo")) + .getValue())); + FxTestSupport.run(() -> ((ComboBox) node(dialog, "syncScheduleCombo")).setValue( + SyncTask.ScheduleType.DAILY)); + FxTestSupport.run(() -> ((TextField) node(dialog, "syncDailyField")).setText("25:00")); + assertEquals("Type the time of the day as HH:mm, e.g. 02:00.", FxTestSupport.call(dialog::validate)); + FxTestSupport.run(() -> ((ComboBox) node(dialog, "syncScheduleCombo")).setValue( + SyncTask.ScheduleType.MANUAL)); + assertNull(FxTestSupport.call(dialog::validate)); + SyncTask task = FxTestSupport.call(dialog::buildTask); + assertEquals("orders", task.getName()); + assertEquals(source.getId(), task.getSourceId()); + assertEquals("PUBLIC", task.getSourceSchema()); + assertEquals(1, task.getTables().size()); + assertEquals("UPDATED_AT", task.getTables().get(0).getWatermarkColumn()); + FxTestSupport.run(() -> tab.save(task)); + assertEquals(1, tab.getTasks().size()); + assertEquals("source / PUBLIC → target / PUBLIC", FxTestSupport.call(() -> tab.route(tab.getTasks().get(0))) + .replace(" / " + source.getDatabase(), "").replace(" / " + target.getDatabase(), "").replace( + " / SOURCE", "").replace(" / TARGET", "")); + + // Run Now: the rows are copied, the list shows the status and the rows, the report opens + TableView table = tab.getTable(); + FxTestSupport.run(() -> table.getSelectionModel().select(0)); + TransferReport report = FxTestSupport.call(() -> tab.runNow(tab.getTasks().get(0))).get(60, + TimeUnit.SECONDS); + assertEquals(TransferReport.Status.COMPLETED, report.getStatus(), report.getFailure()); + FxTestSupport.waitUntil(() -> "Completed".equals(table.getColumns().get(1).getCellData(0))); + assertEquals("4", FxTestSupport.call(() -> table.getColumns().get(2).getCellData(0))); + assertEquals("Run Now only", FxTestSupport.call(() -> table.getColumns().get(3).getCellData(0))); + try (Connection connection = DriverManager.getConnection(target.getJdbcUrl(), "sa", ""); + Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SELECT COUNT(*) FROM ORDERS")) { + rs.next(); + assertEquals(4, rs.getInt(1)); + } + Button reportButton = (Button) tab.getContent().lookup("#syncReportButton"); + FxTestSupport.waitUntil(() -> !reportButton.isDisabled()); + FxTestSupport.run(reportButton::fire); + ReportTab opened = (ReportTab) FxTestSupport.call(() -> tabs().getSelectionModel().getSelectedItem()); + assertEquals(report.getId(), opened.getReport().getId()); + assertEquals(TransferReport.Kind.SYNC, opened.getReport().getKind()); + + // Edited: the watermark of ORDERS stays, NOTES has none + SyncTask saved = context.getSyncScheduler().getStore().getTasks().get(0); + assertNotNull(saved.getTables().get(0).getWatermark()); + SyncTaskDialog edit = dialog(saved); + assertEquals("orders", FxTestSupport.call(() -> ((TextField) node(edit, "syncNameField")).getText())); + assertTrue(row(edit, "ORDERS").selected.get()); + FxTestSupport.run(() -> row(edit, "NOTES").selected.set(true)); + SyncTaskDialog.TableRow notes = row(edit, "NOTES"); + FxTestSupport.run(() -> ((TableView) node(edit, "syncTables")).getSelectionModel() + .select(notes)); + FxTestSupport.waitUntil(() -> notes.candidates != null); + // A table without a time or a number has nothing to guess + assertEquals("Choose the watermark column of NOTES.", FxTestSupport.call(edit::validate)); + FxTestSupport.run(() -> notes.selected.set(false)); + SyncTask edited = FxTestSupport.call(edit::buildTask); + assertEquals(saved.getId(), edited.getId()); + assertEquals(saved.getTables().get(0).getWatermark().getValue(), edited.getTables().get(0).getWatermark() + .getValue()); + + // Switched off and on, deleted after the question + Button toggle = (Button) tab.getContent().lookup("#syncToggleButton"); + FxTestSupport.run(toggle::fire); + assertFalse(context.getSyncScheduler().getStore().getTasks().get(0).isEnabled()); + assertEquals("Disabled", FxTestSupport.call(() -> table.getColumns().get(5).getCellData(0))); + assertEquals("Enable", FxTestSupport.call(toggle::getText)); + FxTestSupport.run(toggle::fire); + assertTrue(context.getSyncScheduler().getStore().getTasks().get(0).isEnabled()); + SyncTask toDelete = tab.getTasks().get(0); + Platform.runLater(() -> tab.delete(toDelete)); + FxTestSupport.closeDialog(ButtonType.CANCEL); + assertEquals(1, tab.getTasks().size()); + Platform.runLater(() -> tab.delete(toDelete)); + FxTestSupport.closeDialog(ButtonType.OK); + FxTestSupport.waitUntil(() -> tab.getTasks().isEmpty()); + assertEquals("No sync tasks yet, New creates one", ((Label) table.getPlaceholder()).getText()); + } + + @Test + void refusesTheSourceAsTarget() { + SyncTaskDialog dialog = dialog(null); + FxTestSupport.run(() -> { + ComboBox targets = node(dialog, "syncTargetCombo"); + targets.setValue(targets.getItems().stream().filter(profile -> profile.getName().equals("source")) + .findFirst().orElseThrow()); + row(dialog, "ORDERS").selected.set(true); + }); + FxTestSupport.waitUntil(() -> "UPDATED_AT".equals(row(dialog, "ORDERS").watermarkColumn.get())); + FxTestSupport.waitUntil(() -> "PUBLIC".equals(((ComboBox) node(dialog, "syncTargetSchemaCombo")) + .getValue())); + FxTestSupport.run(() -> ((ComboBox) node(dialog, "syncTargetCatalogCombo")).setValue(((ComboBox< + String>) node(dialog, "syncSourceCatalogCombo")).getValue())); + assertEquals("The target is the source itself, choose another data source or schema.", FxTestSupport.call( + dialog::validate)); + FxTestSupport.run(() -> ((TextField) node(dialog, "syncNameField")).setText(" ")); + assertEquals("Name the task.", FxTestSupport.call(dialog::validate)); + } + + @Test + void warnsOfPasswordsWhichAreNotRemembered() throws Exception { + ConnectionProfile secret = h2("secret"); + secret.setPassword("x"); + context.getProfileRegistry().saveProfile(secret, false); + SyncTab tab = openSync(); + SyncTask task = new SyncTask(); + task.setName("scheduled"); + task.setSourceId(source.getId()); + task.setTargetId(secret.getId()); + task.getTables().add(new SyncTask.Table("ORDERS", "ID", SyncTask.WatermarkType.INCREASING_KEY)); + Platform.runLater(() -> tab.save(task)); + FxTestSupport.waitUntil(() -> FxTestSupport.getDialogPanes().stream().anyMatch(pane -> pane + .getContentText() != null && pane.getContentText().contains("password of secret is not remembered"))); + FxTestSupport.closeDialog(ButtonType.OK); + assertEquals("Every 60 min", FxTestSupport.call(() -> tab.getTable().getColumns().get(3).getCellData(0))); + } + + /** + * A task of the system scheduler is registered when it is saved, removed when it is switched off or deleted + */ + @Test + void registersTasksOfTheSystemScheduler() throws Exception { + String[] crontab = {null}; + context.setSystemScheduler(new com.github.sqljam.face.service.SystemScheduler("Linux", List.of("sqljam"), + new File(dir, "sync.log"), (command, input) -> { + if (command.contains("-l")) { + return new com.github.sqljam.face.service.SystemScheduler.Result(crontab[0] == null ? 1 : 0, + crontab[0] == null ? "" : crontab[0]); + } + crontab[0] = input; + return new com.github.sqljam.face.service.SystemScheduler.Result(0, ""); + })); + SyncTab tab = openSync(); + SyncTaskDialog dialog = dialog(null); + FxTestSupport.run(() -> { + ComboBox targets = node(dialog, "syncTargetCombo"); + targets.setValue(targets.getItems().stream().filter(profile -> profile.getName().equals("target")) + .findFirst().orElseThrow()); + row(dialog, "ORDERS").selected.set(true); + ((ComboBox) node(dialog, "syncRunnerCombo")).setValue(SyncTask.Runner.SYSTEM); + }); + FxTestSupport.waitUntil(() -> "UPDATED_AT".equals(row(dialog, "ORDERS").watermarkColumn.get())); + SyncTask task = FxTestSupport.call(dialog::buildTask); + assertEquals(SyncTask.Runner.SYSTEM, task.getRunner()); + FxTestSupport.run(() -> tab.save(task)); + assertTrue(crontab[0].contains("0 * * * * sqljam sync --task " + task.getId()), crontab[0]); + assertEquals("System scheduler", FxTestSupport.call(() -> tab.getTable().getColumns().get(4).getCellData(0))); + // The scheduler of SqlJam leaves it to the system + context.getSyncScheduler().tick(); + assertFalse(context.getSyncScheduler().isRunning(task.getId())); + FxTestSupport.run(() -> tab.getTable().getSelectionModel().select(0)); + FxTestSupport.run(((Button) tab.getContent().lookup("#syncToggleButton"))::fire); + assertEquals("", crontab[0]); + FxTestSupport.run(((Button) tab.getContent().lookup("#syncToggleButton"))::fire); + assertTrue(crontab[0].contains(task.getId())); + Platform.runLater(() -> tab.delete(tab.getTasks().get(0))); + FxTestSupport.closeDialog(ButtonType.OK); + FxTestSupport.waitUntil(() -> "".equals(crontab[0])); + } + + @Test + void guessesWatermarkColumns() { + ColumnInfo id = column("ID", "INTEGER", true); + ColumnInfo name = column("NAME", "VARCHAR", false); + ColumnInfo created = column("CREATED_AT", "TIMESTAMP", false); + ColumnInfo modified = column("GMT_MODIFIED", "DATETIME", false); + SyncTaskDialog.TableRow row = new SyncTaskDialog.TableRow("T"); + assertEquals(List.of("GMT_MODIFIED", "ID", "CREATED_AT"), SyncTaskDialog.columns(List.of(id, name, created, + modified), row)); + assertEquals(SyncTask.WatermarkType.UPDATED_AT, row.watermarkType.get()); + SyncTaskDialog.TableRow keyed = new SyncTaskDialog.TableRow("K"); + assertEquals(List.of("ID", "CREATED_AT"), SyncTaskDialog.columns(List.of(name, created, id), keyed)); + assertEquals(SyncTask.WatermarkType.INCREASING_KEY, keyed.watermarkType.get()); + assertEquals(List.of(), SyncTaskDialog.columns(List.of(name), new SyncTaskDialog.TableRow("N"))); + assertEquals("Every day at 02:00", SyncTab.schedule(new SyncTask() { + { + setScheduleType(ScheduleType.DAILY); + } + })); + } + + private static ColumnInfo column(String name, String type, boolean key) { + ColumnInfo column = new ColumnInfo(); + column.setName(name); + column.setTypeName(type); + column.setPrimaryKey(key); + return column; + } +} diff --git a/src/test/java/com/github/sqljam/face/view/TableCopyTest.java b/src/test/java/com/github/sqljam/face/view/TableCopyTest.java new file mode 100644 index 0000000..f911052 --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/TableCopyTest.java @@ -0,0 +1,171 @@ +/* + * 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 java.util.Arrays; +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.testfx.framework.junit5.ApplicationExtension; +import org.testfx.framework.junit5.Start; +import org.testfx.util.WaitForAsyncUtils; +import com.github.sqljam.face.view.TableCopy.Format; +import javafx.application.Platform; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.event.Event; +import javafx.scene.Scene; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.SelectionMode; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.input.Clipboard; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.stage.Stage; + +/** + * @Description: TableCopyTest verifies the copy of the selected rows of a data table as CSV, TSV, JSON and Markdown: + * the order of the rows and of the visible columns, quotes, NULL, and the keys and the menu of the cells + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class TableCopyTest { + + private TableView> table; + + @Start + void start(Stage stage) { + FxTestSupport.applyAppStyle(); + table = new TableView<>(); + table.setFixedCellSize(26); + for (String name : List.of("id", "name", "note")) { + int index = table.getColumns().size(); + TableColumn, String> column = new TableColumn<>(name); + column.setCellValueFactory(features -> new ReadOnlyObjectWrapper<>(features.getValue().get(index))); + column.setCellFactory(tableColumn -> new DataPane.NullableCell()); + table.getColumns().add(column); + } + table.getItems().setAll(Arrays.asList(Arrays.asList("1", "Ann", "a, \"b\""), Arrays.asList("2", "Bob", + null), Arrays.asList("3", "C|D", "line\nnext"))); + stage.setScene(new Scene(table, 400, 300)); + stage.show(); + } + + @AfterEach + void close() { + FxTestSupport.closeAllDialogs(); + } + + private void select(int... rows) { + FxTestSupport.run(() -> { + table.getSelectionModel().clearSelection(); + for (int row : rows) { + table.getSelectionModel().select(row); + } + }); + } + + private String copy(Format format) { + return FxTestSupport.call(() -> TableCopy.format(table, format, true)); + } + + @Test + void copiesSelectedRowsInFormats() { + assertEquals(SelectionMode.MULTIPLE, FxTestSupport.call(() -> { + table.applyCss(); + table.layout(); + return table.getSelectionModel().getSelectionMode(); + })); + // Rows are copied in the order of the table, whatever the order of the selection + select(2, 0); + assertEquals("id,name,note\n1,Ann,\"a, \"\"b\"\"\"\n3,C|D,\"line\nnext\"", copy(Format.CSV)); + assertEquals("id\tname\tnote\n1\tAnn\t\"a, \"\"b\"\"\"\n3\tC|D\t\"line\nnext\"", copy(Format.TSV)); + assertEquals("| id | name | note |\n| --- | --- | --- |\n| 1 | Ann | a, \"b\" |\n| 3 | C\\|D | line
next |", + copy(Format.MARKDOWN)); + // NULL is empty in CSV, null in JSON and NULL in Markdown + select(1); + assertEquals("id,name,note\n2,Bob,", copy(Format.CSV)); + assertEquals("[ {\n \"id\" : \"2\",\n \"name\" : \"Bob\",\n \"note\" : null\n} ]", copy(Format.JSON) + .replace("\r\n", "\n")); + assertEquals("| id | name | note |\n| --- | --- | --- |\n| 2 | Bob | NULL |", copy(Format.MARKDOWN)); + // Columns in their visible order, hidden ones are left out + FxTestSupport.run(() -> { + TableColumn, ?> id = table.getColumns().remove(0); + table.getColumns().add(id); + table.getColumns().get(1).setVisible(false); + }); + assertEquals("name,id\nBob,2", copy(Format.CSV)); + FxTestSupport.run(() -> table.getSelectionModel().clearSelection()); + assertEquals("name,id", copy(Format.CSV)); + assertEquals("[ ]", copy(Format.JSON)); + assertEquals(List.of("CSV", "TSV", "JSON", "Markdown"), Arrays.stream(Format.values()) + .map(Format::getDisplayName).collect(Collectors.toList())); + } + + @Test + void copiesByKeysAndMenu() { + select(0, 1); + FxTestSupport.run(table::requestFocus); + Platform.runLater(() -> Event.fireEvent(table, shortcutC(false))); + WaitForAsyncUtils.waitForFxEvents(); + assertEquals("1\tAnn\t\"a, \"\"b\"\"\"\n2\tBob\t", clipboard()); + // Shift adds the headers + Platform.runLater(() -> Event.fireEvent(table, shortcutC(true))); + WaitForAsyncUtils.waitForFxEvents(); + assertEquals("id\tname\tnote\n1\tAnn\t\"a, \"\"b\"\"\"\n2\tBob\t", clipboard()); + + // Copy Rows As of the context menu of a cell + @SuppressWarnings("unchecked") + ContextMenu menu = FxTestSupport.call(() -> { + table.applyCss(); + table.layout(); + return table.lookupAll(".value-cell").stream().map(node -> (TableCell, String>) node) + .filter(cell -> !cell.isEmpty()).findFirst().orElseThrow().getContextMenu(); + }); + Menu copyRows = (Menu) menu.getItems().get(3); + assertEquals("Copy Rows As", copyRows.getText()); + MenuItem markdown = copyRows.getItems().get(3); + FxTestSupport.run(markdown::fire); + assertEquals(copy(Format.MARKDOWN), clipboard()); + FxTestSupport.run(() -> copyRows.getItems().get(0).fire()); + assertEquals(copy(Format.CSV), clipboard()); + // Installed once + FxTestSupport.run(() -> TableCopy.install(table)); + FxTestSupport.run(() -> TableCopy.install(null)); + } + + /** + * Shortcut+C, Command on macOS and Ctrl elsewhere + */ + private static KeyEvent shortcutC(boolean shift) { + boolean mac = System.getProperty("os.name").toLowerCase().contains("mac"); + return new KeyEvent(KeyEvent.KEY_PRESSED, "", "", KeyCode.C, shift, !mac, false, mac); + } + + private static String clipboard() { + return FxTestSupport.call(() -> Clipboard.getSystemClipboard().getString()); + } +} 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 b67e7e7..a23d4f4 100644 --- a/src/test/java/com/github/sqljam/face/view/ThemeManagerTest.java +++ b/src/test/java/com/github/sqljam/face/view/ThemeManagerTest.java @@ -18,6 +18,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Base64; +import java.nio.charset.StandardCharsets; import java.io.File; import java.nio.file.Files; import java.util.List; @@ -29,6 +31,12 @@ import org.testfx.framework.junit5.Start; import com.github.sqljam.config.Config; import com.github.sqljam.face.service.SettingsStore; +import javafx.scene.paint.Color; +import javafx.scene.control.TableView; +import javafx.scene.control.TableRow; +import javafx.scene.control.TableColumn; +import javafx.scene.control.Label; +import javafx.beans.property.ReadOnlyStringWrapper; import javafx.application.Application; import javafx.scene.Scene; import javafx.scene.control.Menu; @@ -73,13 +81,67 @@ void listsAllThemes() { @Test void appliesTheme() { assertEquals("Nord Dark", FxTestSupport.call(() -> ThemeManager.apply("Nord Dark"))); - assertEquals(new NordDark().getUserAgentStylesheet(), Application.getUserAgentStylesheet()); + assertTrue(Application.getUserAgentStylesheet().startsWith("data:text/css;base64,")); + assertTrue(stylesheet().contains(new NordDark().getUserAgentStylesheet()), stylesheet()); + } + + private static String stylesheet() { + String url = Application.getUserAgentStylesheet(); + return new String(Base64.getDecoder().decode(url.substring(url.indexOf(',') + 1)), + StandardCharsets.UTF_8); + } + + /** + * Text and selected rows as the themes render them: text of dark themes is never gray, text of light themes + * has clear contrast, selected rows are blue + */ + @Test + void rendersTextAndSelection() { + for (String name : ThemeManager.getThemeNames()) { + FxTestSupport.run(() -> ThemeManager.apply(name)); + boolean dark = !name.contains("Light"); + Color[] colors = FxTestSupport.call(() -> { + Label text = new Label("text"); + Label muted = new Label("muted"); + muted.getStyleClass().add("muted"); + TableView table = new TableView<>(); + TableColumn column = new TableColumn<>("c"); + column.setCellValueFactory(features -> new ReadOnlyStringWrapper(features.getValue())); + table.getColumns().add(column); + table.getItems().setAll("a", "b"); + VBox box = new VBox(text, muted, table); + Scene scene = new Scene(box, 300, 300); + scene.getStylesheets().add(ThemeManagerTest.class.getResource("/com/github/sqljam/face/app.css") + .toExternalForm()); + table.getSelectionModel().select(0); + box.applyCss(); + box.layout(); + TableRow row = (TableRow) table.lookupAll(".table-row-cell").stream().filter(node -> + ((TableRow) node).getIndex() == 0).findFirst().orElseThrow(); + Color background = (Color) row.getBackground().getFills().get(row.getBackground().getFills().size() + - 1).getFill(); + return new Color[]{(Color) text.getTextFill(), (Color) muted.getTextFill(), background}; + }); + for (int i = 0; i < 2; i++) { + Color color = colors[i]; + if (dark) { + // White or near white, never gray + assertTrue(color.getBrightness() > 0.93 && color.getSaturation() < 0.1, name + " " + color); + } else { + assertTrue(color.getBrightness() < 0.26, name + " " + color); + } + } + // Selected rows are blue + Color selection = colors[2]; + assertTrue(selection.getBlue() > selection.getRed() + 0.1, name + " selection " + selection); + } + FxTestSupport.run(() -> ThemeManager.apply(ThemeManager.DEFAULT_THEME)); } @Test void unknownThemeFallsBackToDefault() { assertEquals("Primer Dark", FxTestSupport.call(() -> ThemeManager.apply("No Such Theme"))); - assertEquals(new PrimerDark().getUserAgentStylesheet(), Application.getUserAgentStylesheet()); + assertTrue(stylesheet().contains(new PrimerDark().getUserAgentStylesheet()), stylesheet()); assertEquals("Primer Dark", FxTestSupport.call(() -> ThemeManager.apply(null))); assertEquals("Primer Dark", FxTestSupport.call(() -> ThemeManager.apply(""))); } diff --git a/src/test/java/com/github/sqljam/face/view/TutorialVideo.java b/src/test/java/com/github/sqljam/face/view/TutorialVideo.java index e01d651..bd7c40c 100644 --- a/src/test/java/com/github/sqljam/face/view/TutorialVideo.java +++ b/src/test/java/com/github/sqljam/face/view/TutorialVideo.java @@ -38,55 +38,68 @@ 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.SyncTask; import com.github.sqljam.face.Banner; import com.github.sqljam.face.model.ConnectionProfile; import com.github.sqljam.face.service.ExampleDatabase; import com.github.sqljam.face.service.JdbcUrls; +import com.github.sqljam.face.vdb.VdbProfile; +import com.github.sqljam.face.vdb.VdbProfileStore; import com.github.sqljam.impexp.DbType; +import com.github.sqljam.vdb.VdbCollectionMetaData; +import com.github.sqljam.vdb.VdbType; +import com.github.sqljam.vdb.it.VdbServer; import javafx.application.Platform; import javafx.event.ActionEvent; +import javafx.event.Event; import javafx.geometry.Bounds; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.Scene; +import javafx.scene.control.MenuBar; import javafx.scene.control.Button; import javafx.scene.control.ButtonBase; import javafx.scene.control.CheckBox; import javafx.scene.control.ComboBox; +import javafx.scene.control.ContextMenu; import javafx.scene.control.DialogPane; import javafx.scene.control.Label; import javafx.scene.control.Labeled; import javafx.scene.control.ListCell; import javafx.scene.control.ListView; +import javafx.scene.control.Menu; import javafx.scene.control.RadioButton; import javafx.scene.control.ScrollPane; +import javafx.scene.control.Tab; import javafx.scene.control.TabPane; +import javafx.scene.control.TableCell; import javafx.scene.control.TableView; 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.input.MouseButton; +import javafx.scene.input.MouseEvent; 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 CSV files, import the package into PostgreSQL (with a partitioned table in the tree), - * query with the SQL editor and export the result into PostgreSQL, export directly into SQL Server - * (its data source created from a pasted JDBC URL) and Db2 (skipping existing rows and rows with - * errors). Imports run in one transaction, the package import is confirmed before its commit. - * It starts with the name, the version (-Dvideo.version) and what SqlJam is for. Subtitles are in - * English, the background music is generated. It runs about 3 minutes. - * 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 + * @Description: TutorialVideo records the three tutorial videos of SqlJam, about 2 minutes each, for users, with + * generated music and English subtitles, starting with the name, the version (-Dvideo.version) + * and the subject: SQL databases (query the example, export into CSV files, import the package + * into PostgreSQL with a partitioned table in the tree, the SQL editor, a direct copy into SQL + * Server), vector databases (the filter of the tree, a Qdrant collection page by page, conditions, + * nearest neighbors and whole values, its import into PostgreSQL, the tenants of Weaviate, the info) + * and the scheduled sync (a task of the example into PostgreSQL, the first and the next run, their + * reports, the system scheduler and the history). They are recorded once per major version, they + * are not tests of the build (the name does not end with Test), run them by + * mvn test -Pui -Dtest=TutorialVideo -Dvideo.dir=docs/assets * 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.db2.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. + * integration tests (-Dvideo.postgresql.url, -Dvideo.sqlserver.url, user and password by + * -Dvideo.username and -Dvideo.password), the vector databases those of it-vdb-test.properties * @Author: Fred Feng * @Date: 26/03/2023 * @Version 1.0.0 @@ -104,24 +117,31 @@ class TutorialVideo { "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 DB2_URL = System.getProperty("video.db2.url", "jdbc:db2://localhost:50000/demo"); - /** - * Schema of Db2, names of Db2 are upper case - */ - private static final String DB2_SCHEMA = "SHOP"; /** * Partitioned table of PostgreSQL shown in the tree with its partitions */ private static final String PARTITIONED = "order_archive"; - /** - * Table of the rows of the SQL editor, exported into PostgreSQL - */ - private static final String QUERY_TABLE = "category_revenue"; /** * Version shown by the title, the version of the build without -SNAPSHOT by default */ private static final String VERSION = System.getProperty("video.version", Banner.getVersion().replace("-SNAPSHOT", "")); + /** + * Vector database of the video, its collection is imported into PostgreSQL + */ + private static final VdbServer VECTOR_SERVER = VdbServer.QDRANT; + private static final String VECTOR_SOURCE = "Qdrant docs"; + /** + * Vector database with tenants, its collection of two tenants is created for the video + */ + private static final VdbServer TENANT_SERVER = VdbServer.WEAVIATE; + private static final String TENANT_SOURCE = "Weaviate tenants"; + private static final String TENANT_COLLECTION = "SjvtTenant"; + /** + * Table of the imported collection in the schema shop of PostgreSQL + */ + private static final String VECTOR_TABLE = VdbCollectionMetaData.getDefaultTableName(VECTOR_SERVER + .getCollection()); private static final double WIDTH = 1280; private static final double HEIGHT = 800; @@ -138,10 +158,7 @@ class TutorialVideo { * Tables are dropped only after the check found none of them, tables of the user are never touched */ private boolean targetsChecked; - /** - * Keeps Db2 active while recording, a database without connections is deactivated and started slowly - */ - private Connection db2Connection; + private boolean vectorsCreated; @Start void start(Stage stage) throws Exception { @@ -151,7 +168,19 @@ void start(Stage stage) throws Exception { ConnectionProfile example = context.getExampleDatabase().create(); context.getProfileRegistry().saveProfile(example, true); saveProfile("PostgreSQL demo", POSTGRESQL_URL); - saveProfile("Db2 demo", DB2_URL); + // The vector database of the integration tests, its collection is created for the video + VdbProfile qdrant = new VdbProfile(); + qdrant.setName(VECTOR_SOURCE); + qdrant.setType(VdbType.QDRANT); + qdrant.setUrl(VECTOR_SERVER.getSource().getUrl()); + qdrant.setApiKey(VECTOR_SERVER.getSource().getApiKey()); + VdbProfileStore.getInstance().saveProfile(qdrant, true); + VdbProfile weaviate = new VdbProfile(); + weaviate.setName(TENANT_SOURCE); + weaviate.setType(VdbType.WEAVIATE); + weaviate.setUrl(TENANT_SERVER.getSource().getUrl()); + weaviate.setApiKey(TENANT_SERVER.getSource().getApiKey()); + VdbProfileStore.getInstance().saveProfile(weaviate, true); holder = new StackPane(); holder.setStyle("-fx-background-color: -color-bg-default;"); Scene scene = new Scene(holder, WIDTH, HEIGHT); @@ -176,53 +205,267 @@ private void saveProfile(String name, String url) throws Exception { @AfterEach void close() throws Exception { + context.closeSyncScheduler(); context.getSessionManager().closeAll(); FxTestSupport.closeAllDialogs(); - if (db2Connection != null) { - db2Connection.close(); - } if (targetsChecked) { dropTables(); } + if (vectorsCreated) { + VECTOR_SERVER.dropFixture(); + TENANT_SERVER.dropPartitionedFixture(); + } FileUtils.deleteQuietly(packageDir); } - @Test - void record() throws Exception { - checkTargets(); - targetsChecked = true; - db2Connection = DriverManager.getConnection(DB2_URL, USERNAME, PASSWORD); + /** + * Steps of a video between its title and its end + */ + @FunctionalInterface + private interface Steps { + void run() throws Exception; + } + + /** + * Records one video of about 2 minutes: its title, its steps and the end, with music and subtitles + */ + private void record(String name, String subject, Steps steps) throws Exception { 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")); + File output = new File(System.getProperty("video.dir", "target/video"), "sqljam-" + name + ".mp4"); + output.getParentFile().mkdirs(); recorder = FxTestSupport.call(() -> new VideoRecorder(stage, System.getProperty("ffmpeg", "ffmpeg"), 1.25, 24, 100)); - recorder.start(new File(work, "raw.mp4")); - - title(); - connect(); - query(); - exportFiles(); - importPackage(); - sqlQuery(); - exportSqlServer(); - exportDb2(); + recorder.start(new File(work, "raw-" + name + ".mp4")); + title(subject); + steps.run(); outro(); - recorder.stop(); - File music = new File(work, "music.wav"); + File music = new File(work, "music-" + name + ".wav"); TutorialMusic.write(music, recorder.getDurationSeconds() + 2); recorder.finish(output, music, System.getProperty("video.font", "Helvetica Neue")); assertTrue(output.length() > 0); - assertTrue(recorder.getDurationSeconds() < 210, "The video is longer than about 3 minutes"); + assertTrue(recorder.getDurationSeconds() < 150, "The video is longer than 2.5 minutes: " + recorder + .getDurationSeconds()); + } + + /** + * SQL databases: query, export files, import the package, the SQL editor and a direct copy + */ + @Test + void recordSql() throws Exception { + checkTargets(); + targetsChecked = true; + record("sql", "Relational and OLAP databases", () -> { + connect(); + query(); + exportFiles(); + importPackage(); + sqlQuery(); + exportSqlServer(); + }); + } + + /** + * Vector databases: the tree, a collection, its import into PostgreSQL, tenants and the info + */ + @Test + void recordVectors() throws Exception { + checkVectorTarget(); + targetsChecked = true; + VECTOR_SERVER.createFixture(); + TENANT_SERVER.createPartitionedFixture(); + vectorsCreated = true; + record("vectors", "Vector databases", () -> { + connect(); + vectorDatabase(); + }); + } + + /** + * Scheduled sync of the example into PostgreSQL, its reports and the history + */ + @Test + void recordSync() throws Exception { + checkSyncTarget(); + targetsChecked = true; + record("sync", "Scheduled sync and migration reports", () -> { + connect(); + sync(); + }); + } + + /** + * Opens the File menu a moment and runs its item of the id + */ + private void fileMenu(String id) throws Exception { + MenuBar bar = (MenuBar) onFx(() -> mainView.lookup(".menu-bar")); + javafx.scene.control.MenuItem item = FxTestSupport.menuItem(mainView, id); + javafx.scene.control.Menu menu = item.getParentMenu(); + int index = onFx(() -> bar.getMenus().indexOf(menu)); + // The button of the menu in the menu bar + Node button = waitFor(() -> { + bar.applyCss(); + bar.layout(); + List buttons = new ArrayList<>(bar.lookupAll(".menu")); + return buttons.size() > index ? buttons.get(index) : null; + }); + moveTo(button); + recorder.click(); + FxTestSupport.run(menu::show); + pause(1300); + FxTestSupport.run(() -> { + menu.hide(); + item.fire(); + }); + pause(900); + } + + private SyncTask syncTask() { + return context.getSyncScheduler().getStore().getTasks().get(0); + } + + /** + * Runs the task of the list now and waits for its end + */ + private void runSync(SyncTab tab) throws Exception { + FxTestSupport.run(() -> tab.getTable().getSelectionModel().select(0)); + long last = syncTask().getLastRunAt(); + press(node(tab.getContent(), "syncRunButton")); + waitFor(() -> syncTask().getLastRunAt() != last && !context.getSyncScheduler().isRunning(syncTask().getId()) + ? true : null); + FxTestSupport.run(tab::refresh); + pause(1600); + } + + /** + * Changes of the shop meanwhile: orders are shipped, new orders and a new customer arrive + */ + private void changeShop() throws SQLException { + ConnectionProfile example = onFx(() -> connection(ExampleDatabase.PROFILE_NAME).getValue().getProfile()); + try (Connection connection = DriverManager.getConnection(example.getJdbcUrl(), example.getUsername(), + example.getPassword()); Statement statement = connection.createStatement()) { + statement.execute("UPDATE orders SET status = 'SHIPPED', updated_at = CURRENT_TIMESTAMP WHERE id IN " + + "(1, 2, 3)"); + statement.execute("INSERT INTO customers (name, email, city) VALUES ('Nina Video', " + + "'nina.video@example.com', 'Lisbon')"); + statement.execute("INSERT INTO orders (order_no, customer_id, status, total, created_at, updated_at) " + + "VALUES ('SO-2026-90001', 1, 'NEW', 59.90, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), " + + "('SO-2026-90002', 2, 'NEW', 120.00, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); + } + } + + /** + * A sync task of the example into PostgreSQL: the first run copies all rows, the next one the changed rows, + * every run leaves a report, the system scheduler runs it when SqlJam is closed, the history lists the runs + */ + @SuppressWarnings("unchecked") + private void sync() throws Exception { + recorder.caption("Keep a copy up to date: Transfer > Sync Tasks copies the rows which changed, on a schedule"); + fileMenu("syncMenuItem"); + SyncTab tab = onFx(() -> (SyncTab) ((TabPane) mainView.lookup("#tableTabs")).getSelectionModel() + .getSelectedItem()); + pause(1200); + press(node(tab.getContent(), "syncAddButton")); + TextField name = waitFor(() -> (TextField) topNode("syncNameField")); + fitDialog(topWindow()); + recorder.caption("The source: the tables of the shop"); + type(name, "Shop to PostgreSQL"); + ComboBox sources = (ComboBox) topNode("syncSourceCombo"); + choose(sources, onFx(() -> sources.getItems().stream().filter(profile -> ExampleDatabase.PROFILE_NAME + .equals(profile.getName())).findFirst().orElseThrow())); + TableView tables = waitFor(() -> { + TableView view = (TableView) topNode("syncTables"); + return view != null && view.getItems().stream().anyMatch(row -> row.name.equalsIgnoreCase("orders")) + ? view : null; + }); + pause(800); + recorder.caption("Check a table: SqlJam picks its watermark, the time of the last update when it has one"); + for (String table : new String[]{"ORDERS", "CUSTOMERS"}) { + SyncTaskDialog.TableRow row = onFx(() -> tables.getItems().stream().filter(item -> item.name + .equalsIgnoreCase(table)).findFirst().orElseThrow()); + Node cell = onFx(() -> { + tables.scrollTo(row); + tables.layout(); + return tables.lookupAll(".table-row-cell").stream().filter(item -> ((javafx.scene.control.TableRow) + item).getItem() == row).findFirst().map(item -> item.lookup(".check-box")).orElse(tables); + }); + press(cell, () -> row.selected.set(true)); + waitFor(() -> row.watermarkColumn.get() != null ? true : null); + moveTo(topNode("syncWatermarkCombo")); + pause(1500); + } + recorder.caption("Orders by their update time, customers by their key which only grows"); + moveTo(topNode("syncWatermarkTypeCombo")); + pause(1600); + recorder.caption("The target: PostgreSQL. Missing tables are created with their keys, rows are updated or " + + "inserted by key"); + ComboBox targets = (ComboBox) topNode("syncTargetCombo"); + choose(targets, onFx(() -> targets.getItems().stream().filter(profile -> "PostgreSQL demo".equals(profile + .getName())).findFirst().orElseThrow())); + ComboBox schemas = waitFor(() -> { + ComboBox combo = (ComboBox) topNode("syncTargetSchemaCombo"); + return combo != null && !combo.getItems().isEmpty() ? combo : null; + }); + moveTo(schemas); + recorder.click(); + FxTestSupport.run(() -> schemas.setValue(SCHEMA)); + pause(900); + recorder.caption("Every 15 minutes, while SqlJam is open"); + choose(topNode("syncScheduleCombo"), SyncTask.ScheduleType.INTERVAL); + moveTo(topNode("syncIntervalSpinner")); + FxTestSupport.run(() -> ((javafx.scene.control.Spinner) topNode("syncIntervalSpinner")) + .getValueFactory().setValue(15)); + pause(1200); + press(buttonOf(topWindow(), "OK")); + waitFor(() -> tab.getTasks().isEmpty() ? null : true); + pause(800); + + recorder.caption("Run Now: the first run copies all rows, its watermark is read before them"); + runSync(tab); + recorder.caption("Each run leaves a report: tables, rows, the rows found in the target and the watermarks"); + press(node(tab.getContent(), "syncReportButton")); + pause(4200); + + FxTestSupport.run(() -> ((TabPane) mainView.lookup("#tableTabs")).getSelectionModel().select(tab)); + changeShop(); + recorder.caption("Meanwhile the shop changes: three orders are shipped, two orders and a customer are new"); + pause(3500); + recorder.caption("The next run reads only the rows after the watermarks, deleted rows stay in the target"); + runSync(tab); + press(node(tab.getContent(), "syncReportButton")); + pause(1500); + recorder.caption("Its report: the changed rows of each table, and how far the watermarks moved"); + pause(4000); + + FxTestSupport.run(() -> ((TabPane) mainView.lookup("#tableTabs")).getSelectionModel().select(tab)); + recorder.caption("Errors of the connection are run again, errors of the data are not, the watermark moves " + + "after the commit only"); + pause(3500); + recorder.caption("Run by the system scheduler (crontab, Task Scheduler), the task runs when SqlJam is closed " + + "too"); + FxTestSupport.run(() -> tab.getTable().getSelectionModel().select(0)); + press(node(tab.getContent(), "syncEditButton")); + waitFor(() -> topNode("syncRunnerCombo")); + fitDialog(topWindow()); + pause(800); + choose(topNode("syncRunnerCombo"), SyncTask.Runner.SYSTEM); + pause(800); + press(buttonOf(topWindow(), "OK")); + waitFor(() -> syncTask().getRunner() == SyncTask.Runner.SYSTEM ? true : null); + pause(2500); + + recorder.caption("Transfer > Import History keeps the report of every run, next to the imports and exports"); + fileMenu("historyMenuItem"); + pause(3800); } /** * The name, the version and what SqlJam is for, before the tutorial starts */ - private void title() throws Exception { + private void title(String subject) throws Exception { FxTestSupport.run(() -> { ImageView logo = Branding.logoView(190); Label name = new Label("SqlJam"); @@ -230,23 +473,25 @@ private void title() throws Exception { Label version = new Label("v" + VERSION); version.getStyleClass().add("muted"); version.setStyle("-fx-font-size: 18px;"); - Label tagline = new Label("Import and export tool for relational and OLAP databases"); + Label tagline = new Label("Import and export tool for relational, OLAP and vector databases"); tagline.getStyleClass().add("muted"); tagline.setStyle("-fx-font-size: 18px;"); - VBox card = new VBox(12, logo, name, version, tagline); + Label topic = new Label(subject); + topic.setStyle("-fx-font-size: 26px; -fx-font-weight: bold;"); + VBox card = new VBox(12, logo, name, version, tagline, topic); card.setAlignment(Pos.CENTER); holder.getChildren().setAll(card); }); // The pointer starts at the centre, it would cover the logo recorder.movePointer(WIDTH * 0.82, HEIGHT * 0.78, 1); - pause(5500); + 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(4000); + pause(3500); } private static VBox card(String title, String subtitle, String footer) { @@ -263,25 +508,22 @@ private static VBox card(String title, String subtitle, String footer) { return card; } + /** + * SqlJam opens its main window with the data sources in the tree, the example is connected by expanding it + */ 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); - }); + mainView = FxTestSupport.call(() -> { + MainView view = new MainView(context); + // The video shows the window only: its menus are in it, not in the menu bar of macOS + ((MenuBar) view.lookup(".menu-bar")).setUseSystemMenuBar(false); holder.getChildren().setAll(view); + view.applyCss(); + view.layout(); return view; }); - recorder.caption("Getting started: 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); + recorder.caption("SqlJam opens with its data sources, the first start brings the Example Shop (H2) database"); + waitFor(() -> connection(ExampleDatabase.PROFILE_NAME)); + pause(1800); } private ConnectionTree tree() { @@ -289,8 +531,12 @@ private ConnectionTree tree() { } private TreeItem connection(String name) { - return onFx(() -> tree().getRoot().getChildren().stream().filter(item -> name.equals(item - .getValue().getLabel())).findFirst().orElse(null)); + return onFx(() -> { + // The tree is found once the window was laid out + ConnectionTree tree = tree(); + return tree == null ? null : tree.getConnectionItems().stream().filter(item -> name.equals(item + .getValue().getLabel())).findFirst().orElse(null); + }); } private TreeItem child(TreeItem parent, String label) { @@ -369,11 +615,11 @@ private void query() throws Exception { recorder.click(); FxTestSupport.run(() -> tabs.getSelectionModel().select(4)); waitFor(() -> "300 rows".equals(((Label) mainView.lookup("#totalLabel")).getText()) ? true : null); - pause(1200); + pause(800); type((TextField) node(mainView, "whereField"), "status = 'PAID'"); press(node(mainView, "applyQueryButton")); waitFor(() -> "60 rows".equals(((Label) mainView.lookup("#totalLabel")).getText()) ? true : null); - pause(1800); + pause(1100); } /** @@ -401,12 +647,12 @@ private void exportFiles() throws Exception { press(topNode("startButton")); recorder.caption("The package holds the schema, CSV data files, constraints and a manifest"); Window progress = waitForProgress(); - pause(1500); + pause(900); 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(2400); + pause(1400); FxTestSupport.run(() -> viewer.getScene().getWindow().hide()); pause(500); closeProgress(progress); @@ -446,20 +692,20 @@ private void importPackage() throws Exception { press(confirm, () -> confirm.setSelected(true)); pause(1200); press(topNode("startButton")); - Window progress = commitImport("Review the import, then commit or roll back", 2600); + Window progress = commitImport("Review the import, then commit or roll back", 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)); // The partitioned table of the schema, its partitions are below it assertEquals(TABLES.size() + 1, FxTestSupport.call(() -> shop.getChildren().size()).intValue()); - pause(1200); + pause(700); recorder.caption("Partitioned tables show their partitioning, their partitions are listed below them"); TreeItem partitioned = child(shop, PARTITIONED); moveTo(cell(partitioned)); recorder.click(); FxTestSupport.run(() -> partitioned.setExpanded(true)); - pause(2600); + pause(1500); collapse(postgresql); } @@ -486,31 +732,10 @@ private void sqlQuery() throws Exception { press(node(mainView, "sqlRunButton")); TableView result = (TableView) node(mainView, "sqlResultTable"); waitFor(() -> result.getItems().isEmpty() ? null : true); - pause(1500); - recorder.caption("Export the rows of the query as a new table of PostgreSQL"); - press(node(mainView, "sqlExportButton")); - TextField table = waitFor(() -> (TextField) topNode("queryTableField")); - fitDialog(topWindow()); - pause(500); - type(table, QUERY_TABLE); - RadioButton database = (RadioButton) topNode("databaseRadio"); - scrollDialog(1); - press(database, () -> database.setSelected(true)); - pause(500); - scrollDialog(1); - ConnectionProfile postgresql = context.getProfileRegistry().getProfiles().stream().filter(profile -> - "PostgreSQL demo".equals(profile.getName())).findFirst().orElseThrow(); - choose(topNode("targetCombo"), postgresql); - ComboBox schema = waitFor(() -> { - ComboBox combo = (ComboBox) topNode("targetSchemaCombo"); - return combo.getItems().contains(SCHEMA) ? combo : null; - }); - choose(schema, SCHEMA); - pause(500); - press(topNode("startButton")); - Window progress = waitForProgress(); - pause(1000); - closeProgress(progress); + pause(1200); + recorder.caption("Export… sends all rows of the query to files or another database, like a table"); + moveTo(node(mainView, "sqlExportButton")); + pause(1600); } private void exportSqlServer() throws Exception { @@ -529,12 +754,12 @@ private void exportSqlServer() throws Exception { type((TextField) form, "SQL Server demo"); recorder.caption("Paste a JDBC URL: the type, host, port and database follow it"); type((TextField) topNode("urlField"), SQLSERVER_URL); - pause(1500); + pause(1000); type((TextField) topNode("usernameField"), USERNAME); type((TextField) topNode("passwordField"), PASSWORD); press(buttonOf(topWindow(), "Test")); waitFor(() -> ((Labeled) topNode("formStatusLabel")).getText().startsWith("Connected") ? true : null); - pause(1200); + pause(800); press(buttonOf(topWindow(), "Save")); waitFor(() -> topWindow() == export ? true : null); recorder.caption("The new data source is the target, a missing schema is created"); @@ -546,7 +771,13 @@ private void exportSqlServer() throws Exception { type(schema.getEditor(), SCHEMA); moveTo(topNode("createSchemaCheck")); assertTrue(FxTestSupport.call(() -> ((CheckBox) topNode("createSchemaCheck")).isSelected())); - pause(1000); + pause(700); + recorder.caption("Rows are inserted, updated or skipped by key, rows with errors can be skipped and logged"); + RadioButton skipExisting = (RadioButton) topNode("skipExistingRadio"); + press(skipExisting, () -> skipExisting.setSelected(true)); + CheckBox skipErrors = (CheckBox) topNode("skipErrorsCheck"); + press(skipErrors, () -> skipErrors.setSelected(true)); + pause(900); press(topNode("startButton")); // Committed when the import succeeds Window progress = waitForProgress(); @@ -555,39 +786,217 @@ private void exportSqlServer() throws Exception { } /** - * Into Db2 with the write modes: existing rows are skipped by key, rows with errors are skipped and logged + * The vector databases: a Qdrant collection is browsed, searched and imported into PostgreSQL, the tenants of + * Weaviate are listed, and the Info pages show the servers */ - private void exportDb2() throws Exception { - recorder.caption("Straight into Db2: types are translated, keys, indexes and comments come along"); - openExport(); - RadioButton database = (RadioButton) topNode("databaseRadio"); - scrollDialog(1); - press(database, () -> database.setSelected(true)); - pause(500); + private void vectorDatabase() throws Exception { + FxTestSupport.run(() -> ((TabPane) mainView.lookup("#tableTabs")).getTabs().clear()); + recorder.caption("Vector databases are the third kind of data sources, next to relational and OLAP ones"); + TreeItem vectorGroup = waitFor(() -> tree().getRoot().getChildren().stream().filter(group -> group + .getValue().getExtension() != null).findFirst().orElse(null)); + moveTo(cell(vectorGroup)); + pause(2200); + recorder.caption("The filter above the tree finds data sources by name or type"); + TextField filter = (TextField) node(mainView, "treeFilterField"); + type(filter, "qdrant"); + pause(1200); + TreeItem qdrant = source(VECTOR_SOURCE); + expand(qdrant); + TreeItem collection = child(qdrant, VECTOR_SERVER.getCollection()); + recorder.caption("Open a collection like a table: its fields with their types, vectors with their dimensions"); + TabPane contents = openSourceItem(collection); + pause(2600); + + recorder.caption("Its records, page by page, read by the API of the database"); + selectTab(contents, "Data", 1); + TableView> data = dataTable(contents, VdbServer.RECORDS); + pause(2200); + recorder.caption("Conditions narrow the records, like a WHERE"); + type((TextField) node(contents, "vdbFilterField"), "n >= 6"); + press(node(contents, "vdbApplyButton")); + waitFor(() -> data.getItems().size() == 3 ? true : null); + pause(1800); + recorder.caption("Or the nearest neighbors of a record or a vector"); + FxTestSupport.run(() -> ((TextField) node(contents, "vdbFilterField")).clear()); + type((TextField) node(contents, "vdbNearestField"), "2"); + press(node(contents, "vdbApplyButton")); + waitFor(() -> !data.getItems().isEmpty() && data.getItems().size() < VdbServer.RECORDS ? true : null); + pause(2000); + press(node(contents, "vdbClearButton")); + waitFor(() -> data.getItems().size() == VdbServer.RECORDS ? true : null); + pause(800); + + recorder.caption("Cells show one line, a double click shows the whole value"); + Node vectorCell = waitFor(() -> data.lookupAll(".value-cell").stream().filter(node -> { + TableCell cell = (TableCell) node; + return !cell.isEmpty() && cell.getTableColumn() != null && "embedding".equals(cell.getTableColumn() + .getText()); + }).findFirst().orElse(null)); + doubleClick(vectorCell); + Node valueArea = waitFor(() -> topNode("valueArea")); + Window valueWindow = onFx(() -> valueArea.getScene().getWindow()); + fitDialog(valueWindow); + pause(2600); + FxTestSupport.run(valueWindow::hide); + pause(400); + recorder.caption("Select rows and copy them as CSV, TSV, JSON or Markdown"); + Node titleCell = waitFor(() -> data.lookupAll(".value-cell").stream().filter(node -> { + TableCell cell = (TableCell) node; + return !cell.isEmpty() && cell.getIndex() == 2 && cell.getTableColumn() != null && "title".equals(cell + .getTableColumn().getText()); + }).findFirst().orElse(null)); + moveTo(titleCell); + recorder.click(); + FxTestSupport.run(() -> data.getSelectionModel().selectRange(0, 3)); + pause(600); + ContextMenu menu = onFx(() -> ((TableCell) titleCell).getContextMenu()); + FxTestSupport.run(() -> { + Bounds bounds = titleCell.localToScreen(titleCell.getBoundsInLocal()); + menu.show(titleCell, bounds.getMinX() + 20, bounds.getMaxY()); + }); + pause(900); + Menu copyRows = onFx(() -> (Menu) menu.getItems().stream().filter(item -> item instanceof Menu) + .findFirst().orElseThrow()); + FxTestSupport.run(copyRows::show); + pause(1600); + FxTestSupport.run(() -> copyRows.getItems().get(3).fire()); + FxTestSupport.run(menu::hide); + pause(600); + + recorder.caption("Import the collection into PostgreSQL: only databases with vectors are targets"); + Window main = topWindow(); + press(node(contents, "vdbImportButton")); + ComboBox target = waitFor(() -> topWindow() != main ? (ComboBox) topNode("vdbTargetCombo") : null); + fitDialog(topWindow()); + pause(1400); + recorder.caption("Ids and vectors become columns, each field a column, the whole payload is kept as JSON"); + pause(2400); scrollDialog(1); - ConnectionProfile db2 = context.getProfileRegistry().getProfiles().stream().filter(profile -> "Db2 demo" - .equals(profile.getName())).findFirst().orElseThrow(); - choose(topNode("targetCombo"), db2); + ConnectionProfile postgresql = waitFor(() -> target.getItems().stream().map(item -> (ConnectionProfile) + item).filter(profile -> "PostgreSQL demo".equals(profile.getName())).findFirst().orElse(null)); + choose(target, postgresql); ComboBox schema = waitFor(() -> { - ComboBox combo = (ComboBox) topNode("targetSchemaCombo"); - return combo.getItems().isEmpty() ? null : combo; + ComboBox combo = (ComboBox) topNode("vdbSchemaCombo"); + return combo != null && combo.getItems().contains(SCHEMA) ? combo : null; }); - type(schema.getEditor(), DB2_SCHEMA); - recorder.caption("Rows are inserted, updated or skipped by key"); - RadioButton skipExisting = (RadioButton) topNode("skipExistingRadio"); - press(skipExisting, () -> skipExisting.setSelected(true)); - recorder.caption("Rows with errors can be skipped, each of them is logged"); - CheckBox skipErrors = (CheckBox) topNode("skipErrorsCheck"); - press(skipErrors, () -> skipErrors.setSelected(true)); - pause(800); - press(topNode("startButton")); + choose(schema, SCHEMA); + Node start = waitFor(() -> { + Node button = topNode("vdbStartButton"); + return button != null && !button.isDisabled() ? button : null; + }); + recorder.caption("Show SQL: the table, a vector(1024) column of pgvector and its HNSW index"); + Window wizard = topWindow(); + press(topNode("vdbShowSqlButton")); + Window sql = waitFor(() -> topWindow() != wizard ? topWindow() : null); + fitDialog(sql); + pause(2700); + press(buttonOf(sql, "OK")); + waitFor(() -> topWindow() == wizard ? true : null); + pause(500); + press(start); + // Existing tables of the collections are dropped and created again after a confirmation + Window confirmation = waitFor(() -> topWindow() != wizard ? topWindow() : null); + pause(900); + press(buttonOf(confirmation, "OK")); Window progress = waitForProgress(); pause(1500); closeProgress(progress); - TreeItem db2Item = expand(waitFor(() -> connection("Db2 demo"))); - expand(child(db2Item, DB2_SCHEMA)); - pause(1800); - collapse(db2Item); + + recorder.caption("The collection is a table of PostgreSQL now, its vectors are searchable by SQL"); + FxTestSupport.run(filter::clear); + pause(500); + TreeItem postgresqlItem = expand(waitFor(() -> connection("PostgreSQL demo"))); + TreeItem shop = expand(child(expand(child(postgresqlItem, "demo")), SCHEMA)); + // The schema was read before the import, Refresh reads its tables again + select(shop); + press(node(mainView, "action-refresh")); + TreeItem table = child(shop, VECTOR_TABLE); + moveTo(cell(table)); + recorder.click(); + FxTestSupport.run(() -> mainView.openTable(table.getValue())); + TabPane tableContents = waitFor(() -> { + TabPane tableTabs = (TabPane) mainView.lookup("#tableTabs"); + Tab tab = tableTabs.getSelectionModel().getSelectedItem(); + return tab instanceof TableTab ? (TabPane) tab.getContent() : null; + }); + pause(2400); + selectTab(tableContents, "Data", 4); + waitFor(() -> ((Label) mainView.lookup("#totalLabel")).getText().startsWith(VdbServer.RECORDS + " ") + ? true : null); + pause(2400); + collapse(postgresqlItem); + + recorder.caption("Each vector database differs: Weaviate keeps tenants, Milvus partitions, Pinecone namespaces"); + TreeItem weaviate = expand(source(TENANT_SOURCE)); + TreeItem tenants = expand(child(weaviate, TENANT_COLLECTION)); + pause(1500); + recorder.caption("Open one tenant, its records are read and imported apart"); + TabPane tenant = openSourceItem(child(tenants, "t2")); + selectTab(tenant, "Data", 1); + dataTable(tenant, 2); + pause(2100); + + recorder.caption("Info shows the server and its health, refreshed when you ask"); + select(qdrant); + press(node(mainView, "action-info")); + waitForInfo(); + pause(2300); + select(postgresqlItem); + press(node(mainView, "action-info")); + waitForInfo(); + pause(2300); + } + + private TreeItem source(String name) { + return waitFor(() -> tree().getRoot().getChildren().stream().flatMap(group -> group.getChildren().stream()) + .filter(item -> name.equals(item.getValue().getLabel())).findFirst().orElse(null)); + } + + /** + * Opens the tab of a collection or a partition, like a double click on its cell + */ + private TabPane openSourceItem(TreeItem item) throws Exception { + moveTo(cell(item)); + recorder.click(); + FxTestSupport.run(() -> mainView.openSourceItem(item.getValue())); + return waitFor(() -> { + TabPane tableTabs = (TabPane) mainView.lookup("#tableTabs"); + Tab tab = tableTabs.getSelectionModel().getSelectedItem(); + return tab != null && !(tab instanceof TableTab) && tab.getContent() instanceof TabPane ? (TabPane) tab + .getContent() : null; + }); + } + + private void selectTab(TabPane tabs, String text, int index) throws Exception { + Node label = waitFor(() -> tabs.lookupAll(".tab-label").stream().filter(node -> text.equals(((Labeled) node) + .getText())).findFirst().orElse(null)); + moveTo(label); + recorder.click(); + FxTestSupport.run(() -> tabs.getSelectionModel().select(index)); + } + + @SuppressWarnings("unchecked") + private TableView> dataTable(TabPane contents, int rows) { + return waitFor(() -> { + TableView> table = (TableView>) contents.lookup("#vdbDataTable"); + return table != null && table.getItems().size() == rows ? table : null; + }); + } + + private void doubleClick(Node node) throws Exception { + moveTo(node); + recorder.click(); + Platform.runLater(() -> Event.fireEvent(node, new MouseEvent(MouseEvent.MOUSE_CLICKED, 0, 0, 0, 0, + MouseButton.PRIMARY, 2, false, false, false, false, true, false, false, true, false, false, null))); + } + + private void waitForInfo() { + waitFor(() -> { + TabPane tableTabs = (TabPane) mainView.lookup("#tableTabs"); + Tab tab = tableTabs.getSelectionModel().getSelectedItem(); + Label refreshed = tab instanceof InfoTab ? (Label) tab.getContent().lookup("#infoRefreshedLabel") : null; + return refreshed != null && !refreshed.getText().isEmpty() ? tab : null; + }); } /** @@ -849,10 +1258,28 @@ private static void checkTargets() throws SQLException { assertEquals(0, count(statement, "SELECT COUNT(*) FROM sys.schemas WHERE name = '" + SCHEMA + "'"), "Schema shop of SQL Server exists"); } - try (Connection connection = DriverManager.getConnection(DB2_URL, USERNAME, PASSWORD); + } + + /** + * The vector video imports into the schema of the SQL video, it is created for it when it is missing + */ + private static void checkVectorTarget() throws SQLException { + try (Connection connection = DriverManager.getConnection(POSTGRESQL_URL, USERNAME, PASSWORD); Statement statement = connection.createStatement()) { - assertEquals(0, count(statement, "SELECT COUNT(*) FROM SYSCAT.TABLES WHERE TABSCHEMA = '" + DB2_SCHEMA - + "'"), "Schema SHOP of Db2 has tables"); + assertEquals(0, count(statement, "SELECT COUNT(*) FROM pg_tables WHERE schemaname = '" + SCHEMA + "'"), + "Schema shop of PostgreSQL has tables"); + statement.execute("CREATE SCHEMA IF NOT EXISTS " + SCHEMA); + } + } + + /** + * The sync video creates the schema and its tables by its first run + */ + private static void checkSyncTarget() 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"); } } @@ -876,13 +1303,6 @@ private static void dropTables() { } sqlServer.add("IF SCHEMA_ID('" + SCHEMA + "') IS NOT NULL DROP SCHEMA " + SCHEMA); execute(SQLSERVER_URL, sqlServer); - List db2 = new ArrayList<>(); - for (String table : List.of("order_items", "orders", "products", "customers", "categories")) { - db2.add("DROP TABLE IF EXISTS " + DB2_SCHEMA + "." + table.toUpperCase()); - } - db2.add("BEGIN DECLARE CONTINUE HANDLER FOR SQLSTATE '42704' BEGIN END; " - + "EXECUTE IMMEDIATE 'DROP SCHEMA " + DB2_SCHEMA + " RESTRICT'; END"); - execute(DB2_URL, db2); } private static void execute(String url, List sqls) { diff --git a/src/test/java/com/github/sqljam/face/view/ValueCellTest.java b/src/test/java/com/github/sqljam/face/view/ValueCellTest.java new file mode 100644 index 0000000..288bc9e --- /dev/null +++ b/src/test/java/com/github/sqljam/face/view/ValueCellTest.java @@ -0,0 +1,187 @@ +/* + * 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +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.testfx.framework.junit5.ApplicationExtension; +import org.testfx.framework.junit5.Start; +import org.testfx.util.WaitForAsyncUtils; +import javafx.application.Platform; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.event.Event; +import javafx.scene.Scene; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.DialogPane; +import javafx.scene.control.MenuItem; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; +import javafx.scene.input.Clipboard; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.stage.Stage; + +/** + * @Description: ValueCellTest verifies the cells of the data tables: one line of a long value, NULL, and the whole + * value by a double click, by Shift+Enter and by the context menu, with indented JSON, the dimensions + * of vectors and Copy + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class ValueCellTest { + + static final String LONG = "line one\nline two " + "x".repeat(2000); + private TableView> table; + + @Start + void start(Stage stage) { + FxTestSupport.applyAppStyle(); + table = new TableView<>(); + table.setFixedCellSize(26); + TableColumn, String> value = new TableColumn<>("payload"); + value.setCellValueFactory(features -> new ReadOnlyObjectWrapper<>(features.getValue().get(0))); + value.setCellFactory(column -> new DataPane.NullableCell()); + value.setPrefWidth(300); + table.getColumns().add(value); + table.getItems().setAll(Arrays.asList(Arrays.asList(LONG), Arrays.asList((String) null), + Arrays.asList("{\"a\": {\"b\": 1}}"), Arrays.asList("[0.5, 1.5, 2]"), Arrays.asList( + "2024-01-01T08:30:00"))); + stage.setScene(new Scene(table, 400, 300)); + stage.show(); + } + + @AfterEach + void close() { + FxTestSupport.closeAllDialogs(); + } + + @SuppressWarnings("unchecked") + private TableCell, String> cell(int row) { + return FxTestSupport.call(() -> { + table.applyCss(); + table.layout(); + return table.lookupAll(".value-cell").stream().map(node -> (TableCell, String>) node) + .filter(cell -> cell.getIndex() == row && !cell.isEmpty()).findFirst().orElseThrow(); + }); + } + + private static DialogPane valueDialog() { + FxTestSupport.waitUntil(() -> FxTestSupport.getDialogPanes().stream().anyMatch(pane -> pane.lookup( + "#valueArea") != null)); + return FxTestSupport.getDialogPanes().stream().filter(pane -> pane.lookup("#valueArea") != null) + .findFirst().orElseThrow(); + } + + @Test + void showsOneLine() { + TableCell, String> cell = cell(0); + String text = FxTestSupport.call(cell::getText); + assertFalse(text.contains("\n")); + assertTrue(text.startsWith("line one line two")); + assertEquals(ValueCell.MAX_CELL_LENGTH + 1, text.length()); + assertFalse(cell.isWrapText()); + assertNotNull(FxTestSupport.call(cell::getTooltip)); + assertEquals("NULL", FxTestSupport.call(() -> cell(1).getText())); + assertTrue(FxTestSupport.call(() -> cell(1).getStyleClass().contains("null-cell"))); + // Times of the data source are shown without their T + assertEquals("2024-01-01 08:30:00", FxTestSupport.call(() -> cell(4).getText())); + assertNull(FxTestSupport.call(() -> cell(4).getTooltip())); + } + + @Test + void showsWholeValueByDoubleClick() { + TableCell, String> cell = cell(0); + Platform.runLater(() -> Event.fireEvent(cell, new MouseEvent(MouseEvent.MOUSE_CLICKED, 0, 0, 0, 0, + MouseButton.PRIMARY, 2, false, false, false, false, true, false, false, true, false, false, null))); + DialogPane pane = valueDialog(); + assertEquals(LONG, FxTestSupport.call(() -> ((TextArea) pane.lookup("#valueArea")).getText())); + assertTrue(pane.getHeaderText().contains("payload")); + assertTrue(pane.getHeaderText().contains(LONG.length() + " characters")); + // Copy puts the value on the clipboard and keeps the dialog + FxTestSupport.fireLater((javafx.scene.control.Button) pane.lookup("#copyValueButton")); + assertEquals(LONG, FxTestSupport.call(() -> Clipboard.getSystemClipboard().getString())); + assertTrue(FxTestSupport.call(() -> pane.getScene().getWindow().isShowing())); + } + + @Test + void showsValueByShiftEnterAndMenu() { + // JSON is indented + FxTestSupport.run(() -> { + table.requestFocus(); + table.getSelectionModel().select(2, table.getColumns().get(0)); + table.getFocusModel().focus(2, table.getColumns().get(0)); + }); + Platform.runLater(() -> Event.fireEvent(table, new KeyEvent(KeyEvent.KEY_PRESSED, "", "", KeyCode.ENTER, true, + false, false, false))); + DialogPane json = valueDialog(); + String text = FxTestSupport.call(() -> ((TextArea) json.lookup("#valueArea")).getText()); + assertTrue(text.contains("\n") && text.contains("\"b\" : 1"), text); + FxTestSupport.closeAllDialogs(); + + // A vector tells its dimensions, View Value of the context menu shows it + ContextMenu menu = FxTestSupport.call(() -> cell(3).getContextMenu()); + List items = menu.getItems().stream().map(MenuItem::getText).collect(Collectors.toList()); + assertEquals(Arrays.asList("View Value", "Copy Value", null, "Copy Rows As"), items); + FxTestSupport.run(() -> menu.getItems().get(0).fire()); + DialogPane vector = valueDialog(); + assertTrue(vector.getHeaderText().contains("Vector of 3 dimensions"), vector.getHeaderText()); + assertEquals("[0.5, 1.5, 2]", FxTestSupport.call(() -> ((TextArea) vector.lookup("#valueArea")).getText())); + FxTestSupport.run(() -> menu.getItems().get(1).fire()); + assertEquals("[0.5, 1.5, 2]", FxTestSupport.call(() -> Clipboard.getSystemClipboard().getString())); + WaitForAsyncUtils.waitForFxEvents(); + } + + @Test + void describesValues() { + assertEquals("Sparse vector of 8 dimensions", ValueDialog.describe("{1:0.5,3:2}/8")); + assertEquals("3 characters", ValueDialog.describe("abc")); + assertEquals("[1, \"a\"]".length() + " characters", ValueDialog.describe("[1, \"a\"]")); + assertEquals("not json {", ValueDialog.format("not json {")); + assertEquals("plain", ValueDialog.format("plain")); + assertTrue(ValueDialog.format("[{\"a\": 1}]").contains("\n")); + List lines = new ArrayList<>(List.of("a\r\nb")); + assertEquals("a b", ValueCell.oneLine(lines.get(0))); + // Headers sort NULL first, numbers by their values and other values as texts + List values = new ArrayList<>(Arrays.asList("10", null, "9", "-1.5", "1e3", "b", "a", "")); + values.sort(ValueCell.COMPARATOR); + assertEquals(Arrays.asList(null, "-1.5", "9", "10", "1e3", "", "a", "b"), values); + assertEquals(0, ValueCell.COMPARATOR.compare(null, null)); + assertNull(ValueCell.toNumber("1".repeat(65))); + assertNull(ValueCell.toNumber("12abc")); + // NULL in the dialog + Platform.runLater(() -> ValueDialog.show(null, "c", null)); + DialogPane pane = valueDialog(); + assertEquals("NULL", FxTestSupport.call(() -> ((TextArea) pane.lookup("#valueArea")).getText())); + } +} diff --git a/src/test/java/com/github/sqljam/impexp/ColumnMappingTest.java b/src/test/java/com/github/sqljam/impexp/ColumnMappingTest.java new file mode 100644 index 0000000..f3e6fed --- /dev/null +++ b/src/test/java/com/github/sqljam/impexp/ColumnMappingTest.java @@ -0,0 +1,283 @@ +/* + * 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 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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +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.db.H2Dialect; +import com.github.sqljam.it.CollectingListener; + +/** + * @Description: ColumnMappingTest imports H2 tables with the columns which the user mapped: renamed columns in the + * table, its primary key, indexes, foreign keys (also of referenced columns), comments, identity values + * and keys of upserts, types written as they are, skipped columns with their keys and foreign keys, and + * the mappings which are refused. An import without mappings is the import of before. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class ColumnMappingTest { + + private static String newUrl() { + return "jdbc:h2:mem:cm" + UUID.randomUUID().toString().replace("-", "") + ";DB_CLOSE_DELAY=-1"; + } + + private static void execute(String url, String... sqls) throws Exception { + try (Connection connection = DriverManager.getConnection(url, "sa", ""); + Statement statement = connection.createStatement()) { + for (String sql : sqls) { + statement.execute(sql); + } + } + } + + private static List values(String url, String sql) throws Exception { + List values = new ArrayList<>(); + try (Connection connection = DriverManager.getConnection(url, "sa", ""); + Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) { + while (rs.next()) { + List row = new ArrayList<>(); + for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) { + row.add(rs.getString(i)); + } + values.add(String.join("|", row)); + } + } + return values; + } + + private static String source() throws Exception { + String url = newUrl(); + execute(url, "CREATE TABLE CUSTOMERS (ID INT PRIMARY KEY, NAME VARCHAR(20))", + "CREATE TABLE ORDERS (ORDER_ID BIGINT AUTO_INCREMENT PRIMARY KEY, CUSTOMER_ID INT REFERENCES CUSTOMERS(ID)," + + " AMOUNT DECIMAL(10,2), NOTE VARCHAR(100), SECRET VARCHAR(20))", + "CREATE INDEX IDX_AMOUNT ON ORDERS(AMOUNT)", + "COMMENT ON COLUMN ORDERS.NOTE IS 'Note of the order'", + "INSERT INTO CUSTOMERS VALUES (1, 'Ann'), (2, 'Bob')", + "INSERT INTO ORDERS (CUSTOMER_ID, AMOUNT, NOTE, SECRET) VALUES (1, 10.5, 'first', 's1')," + + " (2, 20.25, 'second', 's2'), (1, 30, 'third', 's3')"); + return url; + } + + private static ImportExporter importer(String source, String target) { + ImportExporter importExporter = new ImportExporter(); + Exporter.ExportConfiguration configuration = importExporter.getExportConfiguration(); + configuration.setDbType(DbType.H2); + configuration.setUrl(source); + configuration.setUsername("sa"); + configuration.setPassword(""); + configuration.setIncludedSchemaNames(new String[]{"PUBLIC"}); + configuration.setIdReused(true); + configuration.setConnectionPoolEnabled(false); + configuration.setPageSize(2); + importExporter.getImportConfiguration().setDbType(DbType.H2); + importExporter.getImportConfiguration().setUrl(target); + importExporter.getImportConfiguration().setUsername("sa"); + importExporter.getImportConfiguration().setPassword(""); + importExporter.getImportConfiguration().setConnectionPoolEnabled(false); + return importExporter; + } + + private static Map mappings(ImportExporter importExporter, String table) { + return importExporter.getImportConfiguration().getColumnMappings().computeIfAbsent(table, + key -> new HashMap<>()); + } + + private static List columns(String url, String table) throws Exception { + return values(url, "SELECT COLUMN_NAME, DATA_TYPE, NUMERIC_SCALE FROM INFORMATION_SCHEMA.COLUMNS" + + " WHERE TABLE_SCHEMA = 'PUBLIC' AND TABLE_NAME = '" + table + "' ORDER BY ORDINAL_POSITION"); + } + + @Test + void importsMappedColumns() throws Exception { + String source = source(); + String target = newUrl(); + ImportExporter importExporter = importer(source, target); + mappings(importExporter, "ORDERS").put("ORDER_ID", new ColumnMapping("ID", null, false)); + mappings(importExporter, "ORDERS").put("CUSTOMER_ID", new ColumnMapping("CUSTOMER", null, false)); + mappings(importExporter, "ORDERS").put("AMOUNT", new ColumnMapping(null, "DECIMAL(18,4)", false)); + mappings(importExporter, "ORDERS").put("SECRET", ColumnMapping.skip()); + // A referenced column which is renamed is the column of the foreign key + mappings(importExporter, "CUSTOMERS").put("ID", new ColumnMapping(" CUSTOMER_KEY ", " ", false)); + importExporter.exportDdlAndData(); + + assertEquals(List.of("ID|BIGINT|0", "CUSTOMER|INTEGER|0", "AMOUNT|NUMERIC|4", "NOTE|CHARACTER VARYING|null"), + columns(target, "ORDERS")); + assertEquals(List.of("CUSTOMER_KEY|INTEGER|0", "NAME|CHARACTER VARYING|null"), columns(target, "CUSTOMERS")); + assertEquals(List.of("1|1|10.5000|first", "2|2|20.2500|second", "3|1|30.0000|third"), + values(target, "SELECT ID, CUSTOMER, AMOUNT, NOTE FROM ORDERS ORDER BY ID")); + // Primary keys, the index, the foreign key and the comment are on the mapped columns + assertEquals(List.of("ORDERS|ID", "CUSTOMERS|CUSTOMER_KEY"), values(target, "SELECT TC.TABLE_NAME," + + " KCU.COLUMN_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS TC JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE" + + " KCU ON KCU.CONSTRAINT_NAME = TC.CONSTRAINT_NAME WHERE TC.CONSTRAINT_TYPE = 'PRIMARY KEY'" + + " ORDER BY TC.TABLE_NAME DESC")); + assertEquals(List.of("CUSTOMER|CUSTOMER_KEY"), values(target, "SELECT KCU.COLUMN_NAME, CCU.COLUMN_NAME FROM" + + " INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS RC JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE KCU" + + " ON KCU.CONSTRAINT_NAME = RC.CONSTRAINT_NAME JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE CCU" + + " ON CCU.CONSTRAINT_NAME = RC.UNIQUE_CONSTRAINT_NAME")); + assertEquals(List.of("AMOUNT"), values(target, "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.INDEX_COLUMNS" + + " WHERE TABLE_NAME = 'ORDERS' AND INDEX_NAME NOT LIKE 'PRIMARY%' AND INDEX_NAME NOT LIKE 'CONSTRAINT%'" + + " AND COLUMN_NAME = 'AMOUNT'")); + assertEquals(List.of("Note of the order"), values(target, "SELECT REMARKS FROM INFORMATION_SCHEMA.COLUMNS" + + " WHERE TABLE_NAME = 'ORDERS' AND COLUMN_NAME = 'NOTE'")); + // Identity values go on after the copied rows in the renamed column + execute(target, "INSERT INTO ORDERS (CUSTOMER, AMOUNT, NOTE) VALUES (2, 1, 'new')"); + assertEquals(List.of("4"), values(target, "SELECT ID FROM ORDERS WHERE NOTE = 'new'")); + } + + @Test + void upsertsByMappedKey() throws Exception { + String source = source(); + String target = newUrl(); + execute(target, "CREATE TABLE CUSTOMERS (CUSTOMER_KEY INT PRIMARY KEY, FULL_NAME VARCHAR(20))", + "INSERT INTO CUSTOMERS VALUES (1, 'old'), (9, 'kept')"); + ImportExporter importExporter = importer(source, target); + importExporter.getExportConfiguration().setIncludedTableNames(new String[]{"CUSTOMERS"}); + importExporter.getExportConfiguration().setTableRecreated(false); + importExporter.getExportConfiguration().setWriteMode(WriteMode.UPSERT); + mappings(importExporter, "CUSTOMERS").put("ID", new ColumnMapping("customer_key", null, false)); + mappings(importExporter, "CUSTOMERS").put("NAME", new ColumnMapping("full_name", null, false)); + importExporter.exportDdlAndData(); + // The key of the source is the mapped key of the existing table, names differ in case only + assertEquals(List.of("1|Ann", "2|Bob", "9|kept"), values(target, + "SELECT CUSTOMER_KEY, FULL_NAME FROM CUSTOMERS ORDER BY 1")); + + // A chosen key column is named by its source column too + ImportExporter chosen = importer(source, target); + chosen.getExportConfiguration().setIncludedTableNames(new String[]{"CUSTOMERS"}); + chosen.getExportConfiguration().setTableRecreated(false); + chosen.getExportConfiguration().setWriteMode(WriteMode.SKIP_EXISTING); + chosen.getExportConfiguration().getKeyColumnNames().put("CUSTOMERS", List.of("ID")); + mappings(chosen, "CUSTOMERS").put("ID", new ColumnMapping("CUSTOMER_KEY", null, false)); + mappings(chosen, "CUSTOMERS").put("NAME", new ColumnMapping("FULL_NAME", null, false)); + execute(target, "UPDATE CUSTOMERS SET FULL_NAME = 'changed' WHERE CUSTOMER_KEY = 2"); + chosen.exportDdlAndData(); + assertEquals(List.of("changed"), values(target, "SELECT FULL_NAME FROM CUSTOMERS WHERE CUSTOMER_KEY = 2")); + } + + @Test + void skipsColumnsWithTheirKeys() throws Exception { + String source = source(); + String target = newUrl(); + ImportExporter importExporter = importer(source, target); + // The referenced key is not imported: no primary key of customers, no foreign key of orders + mappings(importExporter, "CUSTOMERS").put("ID", ColumnMapping.skip()); + mappings(importExporter, "ORDERS").put("AMOUNT", ColumnMapping.skip()); + CollectingListener listener = new CollectingListener(); + importExporter.setExportListener(listener); + importExporter.exportDdlAndData(); + assertEquals(List.of(), listener.getErrors()); + assertEquals(List.of("NAME|CHARACTER VARYING|null"), columns(target, "CUSTOMERS")); + assertEquals(List.of("Ann", "Bob"), values(target, "SELECT NAME FROM CUSTOMERS ORDER BY NAME")); + assertFalse(columns(target, "ORDERS").stream().anyMatch(column -> column.startsWith("AMOUNT|"))); + assertEquals(List.of("0"), values(target, "SELECT COUNT(*) FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS")); + assertEquals(List.of("0"), values(target, "SELECT COUNT(*) FROM INFORMATION_SCHEMA.INDEX_COLUMNS" + + " WHERE TABLE_NAME = 'ORDERS' AND COLUMN_NAME = 'AMOUNT'")); + assertEquals(List.of("3"), values(target, "SELECT COUNT(*) FROM ORDERS")); + } + + @Test + void refusesMappingsWhichCanNotBeImported() throws Exception { + String source = source(); + // Two columns with the same target name, in another case too + ImportExporter twice = importer(source, newUrl()); + mappings(twice, "ORDERS").put("NOTE", new ColumnMapping("secret", null, false)); + ImpExpException e = assertThrows(ImpExpException.class, twice::exportDdlAndData); + assertTrue(e.getMessage().contains("are both mapped to"), e.getMessage()); + // A column which the table does not have + ImportExporter missing = importer(source, newUrl()); + mappings(missing, "ORDERS").put("TOTAL", new ColumnMapping("SUM", null, false)); + e = assertThrows(ImpExpException.class, missing::exportDdlAndData); + assertTrue(e.getMessage().contains("Column TOTAL of the mapping is not a column of table ORDERS"), + e.getMessage()); + // A key which is skipped can not be the key of an upsert + ImportExporter key = importer(source, newUrl()); + key.getExportConfiguration().setWriteMode(WriteMode.UPSERT); + key.getExportConfiguration().setIncludedTableNames(new String[]{"CUSTOMERS"}); + key.getExportConfiguration().getKeyColumnNames().put("CUSTOMERS", List.of("ID")); + mappings(key, "CUSTOMERS").put("ID", ColumnMapping.skip()); + e = assertThrows(ImpExpException.class, key::exportDdlAndData); + assertTrue(e.getMessage().contains("Key column"), e.getMessage()); + // A type which the target does not have fails with the error of the database + ImportExporter type = importer(source, newUrl()); + mappings(type, "ORDERS").put("NOTE", new ColumnMapping(null, "NO_SUCH_TYPE", false)); + assertThrows(Exception.class, type::exportDdlAndData); + } + + /** + * Mappings which change nothing, and no mappings, import the tables as before + */ + @Test + void importsAsBeforeWithoutMappings() throws Exception { + String source = source(); + String plain = newUrl(); + importer(source, plain).exportDdlAndData(); + String identity = newUrl(); + ImportExporter importExporter = importer(source, identity); + mappings(importExporter, "ORDERS").put("NOTE", new ColumnMapping()); + mappings(importExporter, "ORDERS").put("AMOUNT", new ColumnMapping("AMOUNT", null, false)); + importExporter.exportDdlAndData(); + for (String table : List.of("ORDERS", "CUSTOMERS")) { + assertEquals(columns(plain, table), columns(identity, table)); + assertEquals(values(plain, "SELECT * FROM " + table + " ORDER BY 1"), values(identity, "SELECT * FROM " + + table + " ORDER BY 1")); + } + + // The dialect returns what it is given without mappings + H2Dialect dialect = new H2Dialect(); + String[] names = {"A", "B"}; + assertSame(names, dialect.getTargetColumnNames("T", names)); + assertEquals("A,B", dialect.getTargetColumnNames("T", "A,B")); + assertEquals("A", dialect.getTargetColumnName("T", "A")); + assertFalse(dialect.isColumnSkipped("T", "A")); + assertNull(dialect.getMappedColumnType("T", "A")); + assertNull(dialect.getColumnMapping(null, "A")); + dialect.setColumnMappings(null); + assertTrue(dialect.getColumnMappings().isEmpty()); + dialect.setColumnMappings(Map.of("T", Map.of("A", new ColumnMapping("X", "TEXT", false)))); + assertEquals("X,B", dialect.getTargetColumnNames("T", "A,B")); + assertEquals(Dialect.VERBATIM_TYPE_PREFIX + "TEXT", dialect.getMappedColumnType("T", "A")); + assertNull(dialect.getColumnMapping("T", null)); + assertNull(dialect.getColumnMapping("U", "A")); + // Copied with the dialect of another version + assertEquals("X", dialect.forVersion(2, 0).getTargetColumnName("T", "A")); + + ColumnMapping mapping = new ColumnMapping(); + assertTrue(mapping.isIdentity()); + assertEquals("A", mapping.getTargetName("A")); + assertFalse(ColumnMapping.skip().isIdentity()); + assertEquals(new ColumnMapping(" X ", "", false), new ColumnMapping("X", null, false)); + assertEquals(new ColumnMapping("X", null, false).hashCode(), new ColumnMapping("X", null, false).hashCode()); + assertFalse(new ColumnMapping("X", null, false).equals("X")); + assertTrue(ColumnMapping.skip().toString().contains("skipped=true")); + } +} diff --git a/src/test/java/com/github/sqljam/impexp/DialectTest.java b/src/test/java/com/github/sqljam/impexp/DialectTest.java index c8335b5..8213de4 100644 --- a/src/test/java/com/github/sqljam/impexp/DialectTest.java +++ b/src/test/java/com/github/sqljam/impexp/DialectTest.java @@ -37,6 +37,7 @@ import java.util.UUID; import org.junit.jupiter.api.Test; +import com.github.sqljam.impexp.db.DuckDBDialect; import com.github.sqljam.impexp.db.H2Dialect; import com.github.sqljam.impexp.db.MySQLDialect; import com.github.sqljam.impexp.db.OracleDialect; @@ -281,6 +282,11 @@ void statements() { "users", new String[]{"id"}, "SET NULL", "CASCADE")); assertEquals("INSERT INTO orders(id,name) VALUES (?,?)", dialect.getInsertTableStatement(null, null, "orders", new String[]{"id", "name"})); + // Statements of several rows, one row a statement unless the database runs batches row by row + assertEquals("INSERT INTO orders(id,name) VALUES (?,?),(?,?),(?,?)", + dialect.getInsertTableStatement(null, null, "orders", new String[]{"id", "name"}, 3)); + assertEquals(1, dialect.getRowsPerInsert()); + assertEquals(256, new DuckDBDialect().getRowsPerInsert()); assertEquals("SELECT id,name FROM src.orders", dialect.getSelectTableStatement(null, "src", "orders", new String[]{"id", "name"})); assertEquals("SELECT COUNT(*) FROM orders", dialect.getCountTableStatement(null, null, "orders")); diff --git a/src/test/java/com/github/sqljam/impexp/SqlScriptRunnerTest.java b/src/test/java/com/github/sqljam/impexp/SqlScriptRunnerTest.java index 5c3821a..b204c1f 100644 --- a/src/test/java/com/github/sqljam/impexp/SqlScriptRunnerTest.java +++ b/src/test/java/com/github/sqljam/impexp/SqlScriptRunnerTest.java @@ -135,6 +135,46 @@ void runScript() throws Exception { } } + /** + * Statements of the structure are told with their outcome, failed rows as skipped rows of their tables, and + * statements of the session are neither + */ + @Test + void tellsStatementsAndSkippedRows() throws Exception { + try (Connection connection = DriverManager.getConnection("jdbc:h2:mem:told;DB_CLOSE_DELAY=-1")) { + List statements = new ArrayList<>(); + List skipped = new ArrayList<>(); + SqlScriptRunner runner = new SqlScriptRunner(connection); + runner.setStopOnError(false); + runner.setExportListener(new ExportListener() { + @Override + public void onDdlExecuted(String statement, boolean successful) { + statements.add(successful + " " + statement); + } + + @Override + public void onRowSkipped(String catalogName, String schemaName, String tableName, String key, + Throwable e) { + skipped.add(tableName + " " + key); + } + }); + runner.runScript("SET SCHEMA PUBLIC;\nCREATE TABLE t (id INT PRIMARY KEY);\n" + + "CREATE INDEX i ON missing (id);\nINSERT INTO t VALUES (1);\nINSERT INTO PUBLIC.\"t\" VALUES (1);\n" + + "INSERT INTO t VALUES (1);"); + assertEquals(List.of("true CREATE TABLE t (id INT PRIMARY KEY)", "false CREATE INDEX i ON missing (id)"), + statements); + // A table which is missing, a key which exists + assertEquals(List.of("t INSERT INTO PUBLIC.\"t\" VALUES (1)", "t INSERT INTO t VALUES (1)"), skipped); + } + assertEquals("users", SqlScriptRunner.tableOf("INSERT INTO \"app\".\"users\" (id) VALUES (1)")); + assertEquals("users", SqlScriptRunner.tableOf("insert ignore into `db`.`users` values (1)")); + assertEquals("users", SqlScriptRunner.tableOf("MERGE INTO [dbo].[users] USING x ON (1 = 1)")); + assertEquals("users", SqlScriptRunner.tableOf("INSERT INTO users(id) VALUES (1)")); + assertEquals(null, SqlScriptRunner.tableOf("UPDATE users SET a = 1")); + assertTrue(SqlScriptRunner.isRowStatement(" update users SET a = 1")); + assertFalse(SqlScriptRunner.isRowStatement("CREATE TABLE inserts (id INT)")); + } + @Test void runBatches() throws Exception { try (Connection connection = DriverManager.getConnection("jdbc:h2:mem:batch;DB_CLOSE_DELAY=-1")) { diff --git a/src/test/java/com/github/sqljam/impexp/VectorTypeTest.java b/src/test/java/com/github/sqljam/impexp/VectorTypeTest.java new file mode 100644 index 0000000..43b9f37 --- /dev/null +++ b/src/test/java/com/github/sqljam/impexp/VectorTypeTest.java @@ -0,0 +1,148 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +/** + * @Description: VectorTypeTest + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class VectorTypeTest { + + @Test + void types() { + VectorType type = VectorType.dense(VectorType.Element.FLOAT32, 1024); + assertEquals("FLOAT32,1024,DENSE", type.toString()); + assertEquals(type, VectorType.parse("float32, 1024, dense")); + assertEquals(type.hashCode(), VectorType.parse(type.toString()).hashCode()); + assertNotEquals(type, VectorType.dense(VectorType.Element.FLOAT32, 3)); + assertNotEquals(type, new VectorType(VectorType.Element.FLOAT32, 1024, true)); + assertNotEquals(type, "FLOAT32,1024,DENSE"); + assertEquals("FLOAT16,0,SPARSE", new VectorType(VectorType.Element.FLOAT16, -1, true).toString()); + assertTrue(VectorType.dense(VectorType.Element.INT8, 3).isFloat32Compatible()); + assertTrue(VectorType.dense(VectorType.Element.FLOAT16, 3).isFloat32Compatible()); + assertFalse(VectorType.dense(VectorType.Element.FLOAT64, 3).isFloat32Compatible()); + assertFalse(VectorType.dense(VectorType.Element.BINARY, 8).isFloat32Compatible()); + assertThrows(NullPointerException.class, () -> new VectorType(null, 3, false)); + + // Invalid texts are no vector types + assertNull(VectorType.parse(null)); + assertNull(VectorType.parse("FLOAT32,3")); + assertNull(VectorType.parse("FLOAT128,3,DENSE")); + assertNull(VectorType.parse("FLOAT32,many,DENSE")); + + Map detail = new HashMap<>(); + assertNull(VectorType.of(detail)); + assertNull(VectorType.of(null)); + detail.put(VectorType.DETAIL_KEY, "INT8,4,DENSE"); + assertEquals(VectorType.dense(VectorType.Element.INT8, 4), VectorType.of(detail)); + } + + @Test + void denseValues() { + VectorType.Values values = VectorType.parseValues(" [1, 2.5E+000 ,-3] "); + assertEquals(3, values.getDimensions()); + assertFalse(values.isSparse()); + assertEquals("[1,2.5E+000,-3]", values.toDenseText()); + assertArrayEquals(new float[]{1, 2.5f, -3}, values.toFloats()); + // Zeros are left out of sparse texts, indexes of pgvector start from 1 and of Oracle from 0 + assertEquals("{1:1,2:2.5E+000,3:-3}/3", values.toPostgreSqlSparseText()); + assertEquals("[3,[0,1,2],[1,2.5E+000,-3]]", values.toOracleSparseText()); + assertEquals("{2:5}/3", VectorType.parseValues("[0,5,0.0]").toPostgreSqlSparseText()); + assertEquals("[]", VectorType.parseValues("[]").toDenseText()); + assertEquals(0, VectorType.parseValues("[ ]").getDimensions()); + } + + @Test + void sparseValues() { + VectorType.Values postgres = VectorType.parseValues("{1:1.5,3:2}/5"); + assertTrue(postgres.isSparse()); + assertEquals(5, postgres.getDimensions()); + assertEquals("[1.5,0,2,0,0]", postgres.toDenseText()); + assertEquals("[5,[0,2],[1.5,2]]", postgres.toOracleSparseText()); + assertEquals("{1:1.5,3:2}/5", postgres.toPostgreSqlSparseText()); + + VectorType.Values oracle = VectorType.parseValues("[5,[0,2],[1.5E+000,2.0E+000]]"); + assertTrue(oracle.isSparse()); + assertEquals("{1:1.5E+000,3:2.0E+000}/5", oracle.toPostgreSqlSparseText()); + assertArrayEquals(new float[]{1.5f, 0, 2, 0, 0}, oracle.toFloats()); + + // A sparse vector without elements + assertEquals("[0,0,0]", VectorType.parseValues("{}/3").toDenseText()); + assertEquals("[3,[],[]]", VectorType.parseValues("{}/3").toOracleSparseText()); + assertEquals("{}/3", VectorType.parseValues("[3,[],[]]").toPostgreSqlSparseText()); + } + + @Test + void invalidValues() { + assertNull(VectorType.parseValues(null)); + assertNull(VectorType.parseValues("1,2,3")); + assertNull(VectorType.parseValues("[1,2")); + assertNull(VectorType.parseValues("{1:1.5")); + assertNull(VectorType.parseValues("{a:1}/3")); + assertNull(VectorType.parseValues("{1:1}/x")); + assertNull(VectorType.parseValues("[5,[0,2]]")); + assertNull(VectorType.parseValues("[x,[0],[1]]")); + // Indexes and numbers of different counts + assertNull(VectorType.parseValues("[5,[0,2],[1]]")); + } + + @Test + void vectorIndexes() { + Map options = new LinkedHashMap<>(); + options.put("m", "16"); + options.put("ef_construction", "64"); + VectorIndex index = new VectorIndex(VectorIndex.Kind.HNSW, VectorIndex.Distance.COSINE, options); + assertEquals("HNSW,COSINE,m=16;ef_construction=64", index.toString()); + assertEquals(index, VectorIndex.parse(index.toString())); + assertEquals(index.hashCode(), VectorIndex.parse(index.toString()).hashCode()); + assertEquals("16", index.getOption("M")); + assertNull(index.getOption("lists")); + assertEquals("IVF,EUCLIDEAN,", new VectorIndex(VectorIndex.Kind.IVF, VectorIndex.Distance.EUCLIDEAN, null) + .toString()); + VectorIndex parsed = VectorIndex.parse("ivf, dot, Lists = 100; ; bad"); + assertEquals(VectorIndex.Kind.IVF, parsed.getKind()); + assertEquals(VectorIndex.Distance.DOT, parsed.getDistance()); + assertEquals(Map.of("lists", "100"), parsed.getOptions()); + assertEquals(VectorIndex.parse("HNSW,COSINE"), VectorIndex.parse("HNSW,COSINE,")); + assertNotEquals(index, "HNSW,COSINE,m=16;ef_construction=64"); + + assertNull(VectorIndex.parse(null)); + assertNull(VectorIndex.parse("HNSW")); + assertNull(VectorIndex.parse("GRAPH,COSINE")); + assertNull(VectorIndex.parse("HNSW,NEAR")); + Map detail = new HashMap<>(); + assertNull(VectorIndex.of(detail)); + assertNull(VectorIndex.of(null)); + detail.put(VectorIndex.DETAIL_KEY, "HNSW,EUCLIDEAN,"); + assertEquals(VectorIndex.Distance.EUCLIDEAN, VectorIndex.of(detail).getDistance()); + } +} diff --git a/src/test/java/com/github/sqljam/impexp/db/VectorDialectTest.java b/src/test/java/com/github/sqljam/impexp/db/VectorDialectTest.java new file mode 100644 index 0000000..7a9b0b4 --- /dev/null +++ b/src/test/java/com/github/sqljam/impexp/db/VectorDialectTest.java @@ -0,0 +1,641 @@ +/* + * 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.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Proxy; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Types; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; +import com.github.sqljam.impexp.DatabaseFeature; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.Dialect; +import com.github.sqljam.impexp.IndexMetaData; +import com.github.sqljam.impexp.VectorIndex; +import com.github.sqljam.impexp.VectorType; + +/** + * @Description: VectorDialectTest checks vector types, values and indexes of the dialects: by detected features, by + * versions if the features are not known (scripts), and without features (an older server, an + * extension which is not installed, a setting or a view which is not granted) + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class VectorDialectTest { + + private static final VectorType FLOAT32 = VectorType.dense(VectorType.Element.FLOAT32, 3); + private static final VectorType FLOAT16 = VectorType.dense(VectorType.Element.FLOAT16, 3); + private static final VectorType FLOAT64 = VectorType.dense(VectorType.Element.FLOAT64, 3); + private static final VectorType INT8 = VectorType.dense(VectorType.Element.INT8, 3); + private static final VectorType SPARSE = new VectorType(VectorType.Element.FLOAT32, 5, true); + private static final VectorType ANY_DIMENSIONS = VectorType.dense(VectorType.Element.FLOAT32, 0); + private static final Set ALL = EnumSet.allOf(DatabaseFeature.class); + private static final Set NONE = EnumSet.noneOf(DatabaseFeature.class); + + private static VectorIndex index(String text) { + return VectorIndex.parse(text); + } + + private static T withFeatures(T dialect, Set features) { + dialect.setDatabaseFeatures(features); + return dialect; + } + + @Test + void postgreSqlTypes() { + PostgreSQLDialect unknown = new PostgreSQLDialect(); + // A script: pgvector is taken as installed + assertEquals("vector(3)", unknown.getVectorTypeName(FLOAT32)); + assertEquals("vector(3)", unknown.getVectorTypeName(INT8)); + assertEquals("halfvec(3)", unknown.getVectorTypeName(FLOAT16)); + assertEquals("sparsevec(5)", unknown.getVectorTypeName(SPARSE)); + assertEquals("vector", unknown.getVectorTypeName(ANY_DIMENSIONS)); + assertEquals("vector(3)", unknown.getVectorTypeName(FLOAT32, true)); + // Doubles and bits would change, they are text + assertNull(unknown.getVectorTypeName(FLOAT64)); + assertNull(unknown.getVectorTypeName(VectorType.dense(VectorType.Element.BINARY, 8))); + assertEquals("vector(16000)", unknown.getVectorTypeName(VectorType.dense(VectorType.Element.FLOAT32, 16000))); + assertNull(unknown.getVectorTypeName(VectorType.dense(VectorType.Element.FLOAT32, 16001))); + + // pgvector is not installed + PostgreSQLDialect none = withFeatures(new PostgreSQLDialect(), NONE); + assertNull(none.getVectorTypeName(FLOAT32)); + assertNull(none.getVectorTypeName(FLOAT16)); + assertNull(none.getVectorTypeName(SPARSE)); + // pgvector before 0.7.0: no halfvec and sparsevec + PostgreSQLDialect old = withFeatures(new PostgreSQLDialect(), EnumSet.of(DatabaseFeature.VECTOR, + DatabaseFeature.IVF_INDEX)); + assertEquals("vector(3)", old.getVectorTypeName(FLOAT16)); + assertNull(old.getVectorTypeName(SPARSE)); + // The features are kept by the dialect of a version + assertEquals(EnumSet.of(DatabaseFeature.VECTOR, DatabaseFeature.IVF_INDEX), old.forVersion(16, 0) + .getDatabaseFeatures()); + assertNull(unknown.forVersion(16, 0).getDatabaseFeatures()); + + assertEquals("[1,2.5,-3]", unknown.getVectorValue(VectorType.parseValues("[1,2.5,-3]"), FLOAT32)); + assertEquals("{1:1.5,3:2}/5", unknown.getVectorValue(VectorType.parseValues("[5,[0,2],[1.5,2]]"), SPARSE)); + } + + @Test + void postgreSqlIndexes() { + PostgreSQLDialect all = withFeatures(new PostgreSQLDialect(), ALL); + assertEquals("CREATE INDEX IF NOT EXISTS idx_t_v ON t USING hnsw (v vector_cosine_ops) WITH (m = 16," + + " ef_construction = 64)", all.getCreateVectorIndexStatement(null, "s", "t", "idx_t_v", "v", + "vector(3)", FLOAT32, false, index("HNSW,COSINE,m=16;ef_construction=64"))); + assertEquals("CREATE INDEX IF NOT EXISTS i ON t USING ivfflat (v halfvec_l2_ops) WITH (lists = 100)", + all.getCreateVectorIndexStatement(null, null, "t", "i", "v", "halfvec(3)", FLOAT16, true, + index("IVF,EUCLIDEAN,lists=100;m=8"))); + // Options which are not numbers are left out + assertEquals("CREATE INDEX IF NOT EXISTS i ON t USING hnsw (v vector_ip_ops)", + all.getCreateVectorIndexStatement(null, null, "t", "i", "v", "vector(3)", FLOAT32, true, + index("HNSW,DOT,m=many"))); + // IVFFlat has no Manhattan distance and no sparse vectors, HNSW is taken + assertTrue(all.getCreateVectorIndexStatement(null, null, "t", "i", "v", "vector(3)", FLOAT32, true, + index("IVF,MANHATTAN,lists=10")).contains("USING hnsw (v vector_l1_ops)")); + assertTrue(all.getCreateVectorIndexStatement(null, null, "t", "i", "v", "sparsevec(5)", SPARSE, true, + index("IVF,COSINE,lists=10")).contains("USING hnsw (v sparsevec_cosine_ops)")); + // Dimensions which an index does not take, and vectors without dimensions + assertNull(all.getCreateVectorIndexStatement(null, null, "t", "i", "v", "vector(2001)", null, true, + index("HNSW,COSINE,"))); + assertTrue(all.getCreateVectorIndexStatement(null, null, "t", "i", "v", "halfvec(4000)", null, true, + index("HNSW,COSINE,")).contains("halfvec_cosine_ops")); + assertNull(all.getCreateVectorIndexStatement(null, null, "t", "i", "v", "halfvec(4001)", null, true, + index("HNSW,COSINE,"))); + assertNull(all.getCreateVectorIndexStatement(null, null, "t", "i", "v", "vector", null, true, + index("HNSW,COSINE,"))); + // Distances of bit vectors + assertNull(all.getCreateVectorIndexStatement(null, null, "t", "i", "v", "vector(3)", FLOAT32, true, + index("HNSW,HAMMING,"))); + + // pgvector before 0.5.0 has no HNSW: IVFFlat is taken + PostgreSQLDialect ivfOnly = withFeatures(new PostgreSQLDialect(), EnumSet.of(DatabaseFeature.VECTOR, + DatabaseFeature.IVF_INDEX)); + assertTrue(ivfOnly.getCreateVectorIndexStatement(null, null, "t", "i", "v", "vector(3)", FLOAT32, true, + index("HNSW,COSINE,m=16")).contains("USING ivfflat (v vector_cosine_ops)")); + assertNull(ivfOnly.getCreateVectorIndexStatement(null, null, "t", "i", "v", "vector(3)", FLOAT32, true, + index("HNSW,MANHATTAN,"))); + assertNull(withFeatures(new PostgreSQLDialect(), EnumSet.of(DatabaseFeature.VECTOR)) + .getCreateVectorIndexStatement(null, null, "t", "i", "v", "vector(3)", FLOAT32, true, + index("HNSW,COSINE,"))); + + // Indexes of other access methods of the same database type + assertEquals("CREATE INDEX IF NOT EXISTS i ON t USING gin (doc jsonb_path_ops, tags) WITH (fastupdate=off)", + all.getCreateIndexStatement(null, null, "t", false, new String[]{"doc", "tags"}, "i", false, "gin", + new String[]{"jsonb_path_ops"}, "fastupdate=off")); + assertEquals(all.getCreateIndexStatement(null, null, "t", false, new String[]{"a"}, "i", true, null), + all.getCreateIndexStatement(null, null, "t", false, new String[]{"a"}, "i", true, null, null, null)); + + assertEquals("l2", PostgreSQLDialect.getVectorOperator(VectorIndex.Distance.EUCLIDEAN)); + assertNull(PostgreSQLDialect.getVectorOperator(VectorIndex.Distance.JACCARD)); + assertEquals(VectorIndex.Distance.HAMMING, PostgreSQLDialect.getVectorDistance("bit_hamming_ops")); + assertEquals(VectorIndex.Distance.JACCARD, PostgreSQLDialect.getVectorDistance("bit_jaccard_ops")); + assertEquals(VectorIndex.Distance.MANHATTAN, PostgreSQLDialect.getVectorDistance("VECTOR_L1_OPS")); + assertNull(PostgreSQLDialect.getVectorDistance("jsonb_path_ops")); + assertNull(PostgreSQLDialect.getVectorDistance(null)); + } + + @Test + void postgreSqlFeatures() { + // vector and hnsw, an older pgvector without halfvec, sparsevec and ivfflat + Connection connection = connection(sql -> new Object[]{true, false, false, true, false}); + assertEquals(EnumSet.of(DatabaseFeature.VECTOR, DatabaseFeature.HNSW_INDEX), + new PostgreSQLDialect().readDatabaseFeatures(connection)); + // A catalog which is not readable: no features, the vectors are text + PostgreSQLDialect failed = new PostgreSQLDialect(); + assertEquals(NONE, failed.readDatabaseFeatures(connection(sql -> { + throw new SQLException("permission denied"); + }))); + assertNull(failed.getVectorTypeName(FLOAT32)); + assertEquals(NONE, new PostgreSQLDialect().readDatabaseFeatures(connection(sql -> null))); + } + + @Test + void mySqlAndMariaDb() { + // VECTOR of MySQL 9.0 and MariaDB 11.7, by the version for a script + assertEquals("VECTOR(3)", new MySQLDialect().getVectorTypeName(FLOAT32)); + assertEquals("VECTOR(3)", new MySQLDialect().getVectorTypeName(FLOAT16)); + assertNull(DbType.MYSQL.createDialect(8, 0).getVectorTypeName(FLOAT32)); + assertNull(DbType.MARIADB.createDialect(11, 4).getVectorTypeName(FLOAT32)); + assertEquals("VECTOR(3)", DbType.MARIADB.createDialect(11, 8).getVectorTypeName(FLOAT32)); + MySQLDialect mysql = (MySQLDialect) DbType.MYSQL.createDialect(9, 7); + assertEquals("VECTOR(16383)", mysql.getVectorTypeName(VectorType.dense(VectorType.Element.FLOAT32, 16383))); + assertNull(mysql.getVectorTypeName(VectorType.dense(VectorType.Element.FLOAT32, 16384))); + assertNull(mysql.getVectorTypeName(ANY_DIMENSIONS)); + assertNull(mysql.getVectorTypeName(SPARSE)); + assertNull(mysql.getVectorTypeName(FLOAT64)); + // The function is not found: a server of an older version than it says, or an edition without vectors + assertNull(withFeatures((MySQLDialect) DbType.MYSQL.createDialect(9, 7), NONE).getVectorTypeName(FLOAT32)); + assertNull(mysql.getCreateVectorIndexStatement(null, "d", "t", "i", "v", "VECTOR(3)", FLOAT32, false, + index("HNSW,COSINE,"))); + + // Text of a script for a VECTOR column + mysql.registerColumnTypeName("t", "v", "VECTOR(3)"); + assertEquals("STRING_TO_VECTOR('[1,2]')", mysql.getStringValue(null, "d", "t", "v", "[1,2]")); + assertEquals("'x'", mysql.getStringValue(null, "d", "t", "w", "x")); + MariaDBDialect mariadb = new MariaDBDialect(); + mariadb.registerColumnTypeName("t", "v", "VECTOR(3)"); + assertEquals("VEC_FromText('[1,2]')", mariadb.getStringValue(null, "d", "t", "v", "[1,2]")); + + assertEquals("CREATE VECTOR INDEX i ON t (v) M=8 DISTANCE=cosine", mariadb.getCreateVectorIndexStatement( + null, "d", "t", "i", "v", "VECTOR(3)", FLOAT32, false, index("HNSW,COSINE,m=8"))); + // M of 3 to 200, other values are left out + assertEquals("CREATE VECTOR INDEX i ON t (v) DISTANCE=euclidean", mariadb.getCreateVectorIndexStatement( + null, null, "t", "i", "v", "VECTOR(3)", FLOAT32, false, index("IVF,EUCLIDEAN,m=500"))); + assertTrue(mariadb.getCreateVectorIndexStatement(null, null, "t", "i", "v", "VECTOR(3)", FLOAT32, false, + index("HNSW,COSINE,m=3")).contains(" M=3 ")); + // A vector index needs NOT NULL, it has no dot product + assertNull(mariadb.getCreateVectorIndexStatement(null, null, "t", "i", "v", "VECTOR(3)", FLOAT32, true, + index("HNSW,COSINE,"))); + assertNull(mariadb.getCreateVectorIndexStatement(null, null, "t", "i", "v", "VECTOR(3)", FLOAT32, false, + index("HNSW,DOT,"))); + assertNull(withFeatures(new MariaDBDialect(), EnumSet.of(DatabaseFeature.VECTOR)) + .getCreateVectorIndexStatement(null, null, "t", "i", "v", "VECTOR(3)", FLOAT32, false, + index("HNSW,COSINE,"))); + + assertEquals(EnumSet.of(DatabaseFeature.VECTOR), new MySQLDialect().readDatabaseFeatures(connection( + sql -> new Object[]{"[1.00000e+00]"}))); + assertEquals(EnumSet.of(DatabaseFeature.VECTOR, DatabaseFeature.HNSW_INDEX), new MariaDBDialect() + .readDatabaseFeatures(connection(sql -> new Object[]{"[1]"}))); + assertEquals(NONE, new MariaDBDialect().readDatabaseFeatures(connection(sql -> { + throw new SQLException("FUNCTION VEC_FromText does not exist"); + }))); + } + + @Test + void oracle() { + assertNull(DbType.ORACLE.createDialect(19, 0).getVectorTypeName(FLOAT32)); + OracleDialect oracle = (OracleDialect) DbType.ORACLE.createDialect(23, 26); + assertEquals("VECTOR(3, FLOAT32)", oracle.getVectorTypeName(FLOAT32)); + assertEquals("VECTOR(3, FLOAT32)", oracle.getVectorTypeName(FLOAT16)); + assertEquals("VECTOR(3, FLOAT64)", oracle.getVectorTypeName(FLOAT64)); + assertEquals("VECTOR(3, INT8)", oracle.getVectorTypeName(INT8)); + assertEquals("VECTOR(*, FLOAT32)", oracle.getVectorTypeName(ANY_DIMENSIONS)); + assertEquals("VECTOR(5, FLOAT32, SPARSE)", oracle.getVectorTypeName(SPARSE)); + assertEquals("VECTOR(16, BINARY)", oracle.getVectorTypeName(VectorType.dense(VectorType.Element.BINARY, 16))); + // Bits of whole bytes, and no sparse bits + assertNull(oracle.getVectorTypeName(VectorType.dense(VectorType.Element.BINARY, 12))); + assertNull(oracle.getVectorTypeName(new VectorType(VectorType.Element.BINARY, 16, true))); + assertNull(oracle.getVectorTypeName(VectorType.dense(VectorType.Element.FLOAT32, 65536))); + assertEquals("[5,[0,2],[1.5,2]]", oracle.getVectorValue(VectorType.parseValues("{1:1.5,3:2}/5"), SPARSE)); + assertEquals("[1,2]", oracle.getVectorValue(VectorType.parseValues("[1,2]"), FLOAT32)); + + // HNSW is kept in vector memory, IVF otherwise and for scripts + OracleDialect memory = withFeatures((OracleDialect) DbType.ORACLE.createDialect(23, 26), ALL); + assertEquals("CREATE VECTOR INDEX I ON T (V) ORGANIZATION INMEMORY NEIGHBOR GRAPH DISTANCE COSINE" + + " PARAMETERS (TYPE HNSW, NEIGHBORS 16, EFCONSTRUCTION 64)", memory.getCreateVectorIndexStatement( + null, null, "T", "I", "V", "VECTOR(3, FLOAT32)", FLOAT32, true, + index("HNSW,COSINE,m=16;ef_construction=64"))); + assertEquals("CREATE VECTOR INDEX I ON T (V) ORGANIZATION NEIGHBOR PARTITIONS DISTANCE COSINE", + oracle.getCreateVectorIndexStatement(null, null, "T", "I", "V", "VECTOR(3, FLOAT32)", FLOAT32, true, + index("HNSW,COSINE,m=16"))); + OracleDialect noMemory = withFeatures((OracleDialect) DbType.ORACLE.createDialect(23, 26), EnumSet.of( + DatabaseFeature.VECTOR, DatabaseFeature.IVF_INDEX)); + assertEquals("CREATE VECTOR INDEX I ON T (V) ORGANIZATION NEIGHBOR PARTITIONS DISTANCE DOT PARAMETERS" + + " (TYPE IVF, NEIGHBOR PARTITIONS 8)", noMemory.getCreateVectorIndexStatement(null, null, "T", "I", + "V", "VECTOR(3, FLOAT32)", FLOAT32, true, index("HNSW,DOT,lists=8"))); + assertNull(oracle.getCreateVectorIndexStatement(null, null, "T", "I", "V", "VECTOR(5, FLOAT32, SPARSE)", SPARSE, + true, index("HNSW,COSINE,"))); + // Vectors were not found: no type and no index + OracleDialect none = withFeatures((OracleDialect) DbType.ORACLE.createDialect(23, 26), NONE); + assertNull(none.getVectorTypeName(FLOAT32)); + assertNull(none.getCreateVectorIndexStatement(null, null, "T", "I", "V", "VECTOR(3, FLOAT32)", FLOAT32, true, + index("IVF,COSINE,"))); + // Sparse vectors of 23.7 and later + assertNull(noMemory.getVectorTypeName(SPARSE)); + + // Vector memory of 256 MB + assertEquals(ALL.size() - 1, new OracleDialect().readDatabaseFeatures(connection(sql -> sql.contains( + "v$parameter") ? new Object[]{"268435456"} : new Object[]{"[1]"})).size()); + // v$parameter is not granted: no HNSW + assertEquals(EnumSet.of(DatabaseFeature.VECTOR, DatabaseFeature.IVF_INDEX, DatabaseFeature.SPARSE_VECTOR), + new OracleDialect().readDatabaseFeatures(connection(sql -> { + if (sql.contains("v$parameter")) { + throw new SQLException("ORA-00942: table or view does not exist"); + } + return new Object[]{"[1]"}; + }))); + // No vector memory, an older release without sparse vectors + assertEquals(EnumSet.of(DatabaseFeature.VECTOR, DatabaseFeature.IVF_INDEX), new OracleDialect() + .readDatabaseFeatures(connection(sql -> { + if (sql.contains("SPARSE")) { + throw new SQLException("ORA-00907: missing right parenthesis"); + } + return new Object[]{sql.contains("v$parameter") ? "0" : "[1]"}; + }))); + // Oracle 19c: no vectors at all + assertEquals(NONE, new OracleDialect().readDatabaseFeatures(connection(sql -> { + throw new SQLException("ORA-00904: TO_VECTOR: invalid identifier"); + }))); + } + + @Test + void sqlServer() throws SQLException { + assertNull(DbType.SQLSERVER.createDialect(16, 0).getVectorTypeName(FLOAT32)); + SQLServerDialect sqlServer = (SQLServerDialect) DbType.SQLSERVER.createDialect(17, 0); + assertEquals("VECTOR(3)", sqlServer.getVectorTypeName(FLOAT32)); + assertEquals("VECTOR(3)", sqlServer.getVectorTypeName(INT8)); + assertEquals("VECTOR(3, float16)", sqlServer.getVectorTypeName(FLOAT16)); + assertEquals("VECTOR(1998)", sqlServer.getVectorTypeName(VectorType.dense(VectorType.Element.FLOAT32, 1998))); + assertNull(sqlServer.getVectorTypeName(VectorType.dense(VectorType.Element.FLOAT32, 1999))); + assertEquals("VECTOR(3996, float16)", sqlServer.getVectorTypeName(VectorType.dense(VectorType.Element.FLOAT16, + 3996))); + // 16-bit floats of more dimensions than float16 takes, and without float16: 32-bit floats if they fit + assertNull(sqlServer.getVectorTypeName(VectorType.dense(VectorType.Element.FLOAT16, 3997))); + assertEquals("VECTOR(3)", withFeatures((SQLServerDialect) DbType.SQLSERVER.createDialect(17, 0), + EnumSet.of(DatabaseFeature.VECTOR)).getVectorTypeName(FLOAT16)); + assertNull(sqlServer.getVectorTypeName(ANY_DIMENSIONS)); + assertNull(sqlServer.getVectorTypeName(SPARSE)); + assertNull(sqlServer.getVectorTypeName(FLOAT64)); + // Vector indexes are a preview feature, a table with one is read only + assertNull(sqlServer.getCreateVectorIndexStatement(null, "s", "t", "i", "v", "VECTOR(3)", FLOAT32, false, + index("HNSW,COSINE,"))); + + assertEquals("CAST(v AS NVARCHAR(MAX)) AS v", sqlServer.getSelectColumnExpression("v", "vector")); + assertEquals("v", sqlServer.getSelectColumnExpression("v", "nvarchar")); + // The binary of a vector: header 0xA9 0x01, dimensions, base type 0 and little endian floats + byte[] binary = vectorBinary(1, 2.5f, -3); + assertEquals("[1.0,2.5,-3.0]", SQLServerDialect.toVectorText(binary)); + byte[] shortBinary = {1, 2}; + assertSame(shortBinary, SQLServerDialect.toVectorText(shortBinary)); + byte[] otherHeader = vectorBinary(1); + otherHeader[0] = 0; + assertSame(otherHeader, SQLServerDialect.toVectorText(otherHeader)); + byte[] otherType = vectorBinary(1); + otherType[4] = 1; + assertSame(otherType, SQLServerDialect.toVectorText(otherType)); + byte[] truncated = new byte[binary.length - 1]; + System.arraycopy(binary, 0, truncated, 0, truncated.length); + assertSame(truncated, SQLServerDialect.toVectorText(truncated)); + assertEquals("[1.0,2.5,-3.0]", sqlServer.getColumnValue(resultSet(binary), 1, -160, "vector")); + assertArrayEquals(shortBinary, (byte[]) sqlServer.getColumnValue(resultSet(shortBinary), 1, Types.VARBINARY, + "varbinary")); + + assertEquals(EnumSet.of(DatabaseFeature.VECTOR, DatabaseFeature.HALF_VECTOR), new SQLServerDialect() + .readDatabaseFeatures(connection(sql -> new Object[]{"[1.0000000e+000]"}))); + assertEquals(EnumSet.of(DatabaseFeature.VECTOR), new SQLServerDialect().readDatabaseFeatures(connection(sql -> { + if (sql.contains("float16")) { + throw new SQLException("The type 'float16' is not supported"); + } + return new Object[]{"[1.0000000e+000]"}; + }))); + // SQL Server 2022: no VECTOR + assertEquals(NONE, new SQLServerDialect().readDatabaseFeatures(connection(sql -> { + throw new SQLException("Column, parameter, or variable #1: Cannot find data type VECTOR."); + }))); + } + + @Test + void fileAndOlapDatabases() { + DuckDBDialect duckdb = new DuckDBDialect(); + assertEquals("FLOAT[3]", duckdb.getVectorTypeName(FLOAT32)); + assertEquals("FLOAT[3]", duckdb.getVectorTypeName(FLOAT16)); + assertEquals("DOUBLE[3]", duckdb.getVectorTypeName(FLOAT64)); + assertNull(duckdb.getVectorTypeName(ANY_DIMENSIONS)); + assertNull(duckdb.getVectorTypeName(SPARSE)); + assertNull(duckdb.getVectorTypeName(VectorType.dense(VectorType.Element.BINARY, 8))); + assertNull(duckdb.getCreateVectorIndexStatement(null, "main", "t", "i", "v", "FLOAT[3]", FLOAT32, false, + index("HNSW,COSINE,"))); + assertEquals(EnumSet.of(DatabaseFeature.VECTOR), new DuckDBDialect().readDatabaseFeatures(connection( + sql -> new Object[]{"[1.0]"}))); + + ClickHouseDialect clickHouse = new ClickHouseDialect(); + assertEquals("Array(Float32)", clickHouse.getVectorTypeName(FLOAT32, false)); + assertEquals("Array(Float32)", clickHouse.getVectorTypeName(ANY_DIMENSIONS, false)); + assertEquals("Array(Float64)", clickHouse.getVectorTypeName(FLOAT64, false)); + // Arrays take no NULL: nullable vectors are text + assertNull(clickHouse.getVectorTypeName(FLOAT32, true)); + assertNull(clickHouse.getVectorTypeName(SPARSE, false)); + // Vector similarity indexes are allowed by settings, they are not known for a script + assertNull(clickHouse.getCreateVectorIndexStatement(null, "d", "t", "i", "v", "Array(Float32)", FLOAT32, + false, index("HNSW,COSINE,"))); + ClickHouseDialect allowed = withFeatures(new ClickHouseDialect(), ALL); + assertEquals("ALTER TABLE t ADD INDEX i v TYPE vector_similarity('hnsw', 'cosineDistance', 3, 'bf16', 16," + + " 64) GRANULARITY 100000000", allowed.getCreateVectorIndexStatement(null, "d", "t", "i", "v", + "Array(Float32)", FLOAT32, false, index("HNSW,COSINE,m=16;ef_construction=64"))); + assertEquals("ALTER TABLE t ADD INDEX i v TYPE vector_similarity('hnsw', 'L2Distance', 3) GRANULARITY" + + " 100000000", allowed.getCreateVectorIndexStatement(null, null, "t", "i", "v", "Array(Float32)", + FLOAT32, false, index("IVF,EUCLIDEAN,m=16"))); + assertNull(allowed.getCreateVectorIndexStatement(null, null, "t", "i", "v", "Array(Float32)", FLOAT32, false, + index("HNSW,DOT,"))); + assertNull(allowed.getCreateVectorIndexStatement(null, null, "t", "i", "v", "Array(Float32)", ANY_DIMENSIONS, + false, index("HNSW,COSINE,"))); + assertNull(allowed.getCreateVectorIndexStatement(null, null, "t", "i", "v", "Array(Float32)", null, false, + index("HNSW,COSINE,"))); + assertEquals(EnumSet.of(DatabaseFeature.VECTOR), new ClickHouseDialect().readDatabaseFeatures(connection( + sql -> sql.contains("system.settings") ? null : new Object[]{"[1]"}))); + + // Databases without vectors keep them as text and have no vector indexes + for (DbType dbType : new DbType[]{DbType.H2, DbType.SQLITE, DbType.ACCESS}) { + Dialect dialect = dbType.createDialect(); + assertNull(dialect.getVectorTypeName(FLOAT32), dbType.name()); + assertNull(dialect.getVectorTypeName(FLOAT32, false), dbType.name()); + assertNull(dialect.getCreateVectorIndexStatement(null, null, "t", "i", "v", "text", FLOAT32, false, + index("HNSW,COSINE,")), dbType.name()); + assertEquals(NONE, dialect.readDatabaseFeatures(connection(sql -> new Object[]{1})), dbType.name()); + } + } + + @Test + void db2() { + // Db2 12.1.2 and later, the version tells no mod pack: by the features of the connection + Db2Dialect db2 = new Db2Dialect(); + assertEquals("VECTOR(3, FLOAT32)", db2.getVectorTypeName(FLOAT32)); + assertEquals("VECTOR(3, FLOAT32)", db2.getVectorTypeName(FLOAT16)); + assertEquals("VECTOR(3, INT8)", db2.getVectorTypeName(INT8)); + assertEquals("VECTOR(8168, FLOAT32)", db2.getVectorTypeName(VectorType.dense(VectorType.Element.FLOAT32, 8168))); + assertNull(db2.getVectorTypeName(VectorType.dense(VectorType.Element.FLOAT32, 8169))); + assertNull(db2.getVectorTypeName(ANY_DIMENSIONS)); + assertNull(db2.getVectorTypeName(FLOAT64)); + assertNull(db2.getVectorTypeName(SPARSE)); + assertNull(DbType.DB2.createDialect(11, 5).getVectorTypeName(FLOAT32)); + assertNull(withFeatures(new Db2Dialect(), NONE).getVectorTypeName(FLOAT32)); + + assertEquals("CREATE VECTOR INDEX I ON T (V) WITH DISTANCE COSINE", db2.getCreateVectorIndexStatement(null, + null, "T", "I", "V", "VECTOR(3, FLOAT32)", FLOAT32, true, index("HNSW,COSINE,m=16"))); + assertEquals("CREATE VECTOR INDEX I ON T (V) WITH DISTANCE EUCLIDEAN", db2.getCreateVectorIndexStatement(null, + null, "T", "I", "V", "VECTOR(3, FLOAT32)", FLOAT32, false, index("IVF,EUCLIDEAN,"))); + // Keys of 8000 bytes: 2000 dimensions of 32-bit floats, 8000 of bytes + assertTrue(db2.getCreateVectorIndexStatement(null, null, "T", "I", "V", "VECTOR(2000, FLOAT32)", + VectorType.dense(VectorType.Element.FLOAT32, 2000), true, index("HNSW,COSINE,")) != null); + assertNull(db2.getCreateVectorIndexStatement(null, null, "T", "I", "V", "VECTOR(2001, FLOAT32)", + VectorType.dense(VectorType.Element.FLOAT32, 2001), true, index("HNSW,COSINE,"))); + assertTrue(db2.getCreateVectorIndexStatement(null, null, "T", "I", "V", "VECTOR(8000, INT8)", + VectorType.dense(VectorType.Element.INT8, 8000), true, index("HNSW,COSINE,")) != null); + // Dot product and Manhattan distance have no vector index of Db2 + assertNull(db2.getCreateVectorIndexStatement(null, null, "t", "i", "v", "VECTOR(3, FLOAT32)", FLOAT32, true, + index("HNSW,DOT,"))); + assertNull(withFeatures(new Db2Dialect(), NONE).getCreateVectorIndexStatement(null, null, "t", "i", "v", + "VECTOR(3, FLOAT32)", FLOAT32, true, index("HNSW,COSINE,"))); + + assertEquals(EnumSet.of(DatabaseFeature.VECTOR, DatabaseFeature.HNSW_INDEX), new Db2Dialect() + .readDatabaseFeatures(connection(sql -> new Object[]{"[1]"}))); + // Db2 12.1.1 and older: no VECTOR function + assertEquals(NONE, new Db2Dialect().readDatabaseFeatures(connection(sql -> { + throw new SQLException("SQLCODE=-440, SQLSTATE=42884, SQLERRMC=VECTOR;FUNCTION"); + }))); + } + + @Test + void metadataOfSources() { + assertEquals(VectorType.dense(VectorType.Element.FLOAT32, 1024), PostgreSQLMetaDataOperations.getVectorType( + "vector", "vector(1024)")); + assertEquals(VectorType.dense(VectorType.Element.FLOAT16, 0), PostgreSQLMetaDataOperations.getVectorType( + "halfvec", null)); + assertEquals(new VectorType(VectorType.Element.FLOAT32, 30000, true), PostgreSQLMetaDataOperations + .getVectorType("sparsevec", "sparsevec(30000)")); + assertNull(PostgreSQLMetaDataOperations.getVectorType("int4", "integer")); + + Map hnsw = new HashMap<>(); + PostgreSQLMetaDataOperations.applyIndexMethod(hnsw, method("hnsw", "vector_cosine_ops", false, + "m=16, ef_construction=64")); + assertEquals("hnsw", hnsw.get(IndexMetaData.INDEX_METHOD)); + assertEquals("vector_cosine_ops", hnsw.get(IndexMetaData.OPERATOR_CLASS)); + assertEquals("HNSW,COSINE,m=16;ef_construction=64", hnsw.get(VectorIndex.DETAIL_KEY)); + Map ivf = new HashMap<>(); + PostgreSQLMetaDataOperations.applyIndexMethod(ivf, method("ivfflat", "halfvec_ip_ops", false, null)); + assertEquals("IVF,DOT,", ivf.get(VectorIndex.DETAIL_KEY)); + // Default operator classes are not written, ordinary indexes are not vector indexes + Map btree = new HashMap<>(); + PostgreSQLMetaDataOperations.applyIndexMethod(btree, method("btree", "int4_ops", true, null)); + assertNull(btree.get(IndexMetaData.OPERATOR_CLASS)); + assertNull(btree.get(VectorIndex.DETAIL_KEY)); + Map gin = new HashMap<>(); + PostgreSQLMetaDataOperations.applyIndexMethod(gin, method("gin", "gin_trgm_ops", false, "fastupdate=off")); + assertEquals("fastupdate=off", gin.get(IndexMetaData.INDEX_OPTIONS)); + assertNull(gin.get(VectorIndex.DETAIL_KEY)); + + Map mariadb = MariaDBMetaDataOperations.getVectorIndexes("CREATE TABLE `t` (\n" + + " `v` vector(3) NOT NULL,\n `w` vector(3) NOT NULL,\n `x` vector(3) NOT NULL,\n" + + " VECTOR KEY `ix_v` (`v`) `M`='8' `DISTANCE`='cosine',\n VECTOR KEY `ix``w` (`w`),\n" + + " VECTOR KEY `ix_x` (`x`) `DISTANCE`='euclidean'\n) ENGINE=InnoDB"); + assertEquals("HNSW,COSINE,m=8", mariadb.get("ix_v")); + assertEquals("HNSW,EUCLIDEAN,", mariadb.get("ix`w")); + assertEquals("HNSW,EUCLIDEAN,", mariadb.get("ix_x")); + assertTrue(MariaDBMetaDataOperations.getVectorIndexes(null).isEmpty()); + + assertEquals(VectorType.dense(VectorType.Element.FLOAT32, 3), OracleMetaDataOperations.getVectorType( + "VECTOR(3,FLOAT32,DENSE)")); + assertEquals(new VectorType(VectorType.Element.FLOAT64, 8, true), OracleMetaDataOperations.getVectorType( + "VECTOR(8,FLOAT64,SPARSE)")); + assertEquals(VectorType.dense(VectorType.Element.INT8, 0), OracleMetaDataOperations.getVectorType( + "VECTOR(*,INT8,DENSE)")); + // A column of any format keeps the vectors of Oracle, it has no vector type + assertNull(OracleMetaDataOperations.getVectorType("VECTOR(*,*,DENSE)")); + assertNull(OracleMetaDataOperations.getVectorType(null)); + assertNull(OracleMetaDataOperations.getVectorType("VECTOR(3,FLOAT8,DENSE)")); + + assertArrayEquals(new String[]{"HNSW,COSINE,", "1024"}, ClickHouseMetaDataOperations.parseVectorIndex( + "vector_similarity('hnsw', 'cosineDistance', 1024)")); + assertArrayEquals(new String[]{"HNSW,EUCLIDEAN,m=16;ef_construction=64", "3"}, ClickHouseMetaDataOperations + .parseVectorIndex("vector_similarity('hnsw', 'L2Distance', 3, 'bf16', 16, 64)")); + assertArrayEquals(new String[]{"HNSW,COSINE,m=32", "8"}, ClickHouseMetaDataOperations.parseVectorIndex( + "vector_similarity('hnsw', 'cosineDistance', 8, 'f32', 32)")); + assertNull(ClickHouseMetaDataOperations.parseVectorIndex("vector_similarity('hnsw', 'dotProduct', 3)")); + assertNull(ClickHouseMetaDataOperations.parseVectorIndex("minmax")); + assertEquals(VectorType.dense(VectorType.Element.FLOAT32, 3), ClickHouseMetaDataOperations.getVectorType( + "Array(Float32)", "3")); + assertEquals(VectorType.dense(VectorType.Element.FLOAT16, 0), ClickHouseMetaDataOperations.getVectorType( + "Array(BFloat16)", null)); + assertEquals(VectorType.dense(VectorType.Element.FLOAT64, 0), ClickHouseMetaDataOperations.getVectorType( + "Array( Float64 )", null)); + assertNull(ClickHouseMetaDataOperations.getVectorType("Array(Int32)", null)); + + assertEquals(VectorType.dense(VectorType.Element.FLOAT32, 3), DuckDBMetaDataOperations.getVectorType( + "FLOAT[3]")); + assertEquals(VectorType.dense(VectorType.Element.FLOAT64, 1024), DuckDBMetaDataOperations.getVectorType( + "double[1024]")); + // Lists of floats and arrays of integers are not vectors + assertNull(DuckDBMetaDataOperations.getVectorType("FLOAT[]")); + assertNull(DuckDBMetaDataOperations.getVectorType("INTEGER[3]")); + assertNull(DuckDBMetaDataOperations.getVectorType(null)); + } + + @Test + void probes() throws SQLException { + List rolledBack = new ArrayList<>(); + Connection transactional = connection(sql -> { + throw new SQLException("no such function"); + }, false, rolledBack); + // A failed probe rolls back the transaction which it aborted + assertEquals(NONE, new MySQLDialect().readDatabaseFeatures(transactional)); + assertEquals(List.of("rollback"), rolledBack); + // A rollback which fails, e.g. of a closed connection + Connection closed = (Connection) Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[]{Connection.class}, (proxy, method, args) -> { + throw new SQLException("closed"); + }); + assertEquals(NONE, new MySQLDialect().readDatabaseFeatures(closed)); + assertEquals(NONE, new DuckDBDialect().readDatabaseFeatures(connection(sql -> { + throw new IllegalStateException("broken driver"); + }))); + } + + private static Map method(String amName, String operatorClass, boolean defaultClass, + String options) { + Map method = new LinkedHashMap<>(); + method.put("amname", amName); + method.put("opcname", operatorClass); + method.put("opcdefault", defaultClass); + method.put("options", options); + return method; + } + + private static byte[] vectorBinary(float... values) { + ByteBuffer buffer = ByteBuffer.allocate(8 + values.length * 4).order(ByteOrder.LITTLE_ENDIAN); + buffer.put((byte) 0xA9).put((byte) 1).putShort((short) values.length).put((byte) 0).put(new byte[3]); + for (float value : values) { + buffer.putFloat(value); + } + return buffer.array(); + } + + @FunctionalInterface + interface Query { + /** + * Columns of the only row of a query, null for no row + */ + Object[] execute(String sql) throws SQLException; + } + + private static Connection connection(Query query) { + return connection(query, true, new ArrayList<>()); + } + + /** + * Connection whose statements answer queries by the function + */ + private static Connection connection(Query query, boolean autoCommit, List calls) { + return (Connection) Proxy.newProxyInstance(VectorDialectTest.class.getClassLoader(), + new Class[]{Connection.class}, (proxy, method, args) -> { + switch (method.getName()) { + case "createStatement": + return statement(query); + case "getAutoCommit": + return autoCommit; + case "rollback": + calls.add("rollback"); + return null; + default: + return null; + } + }); + } + + private static Statement statement(Query query) { + return (Statement) Proxy.newProxyInstance(VectorDialectTest.class.getClassLoader(), + new Class[]{Statement.class}, (proxy, method, args) -> { + if ("executeQuery".equals(method.getName())) { + Object[] row = query.execute((String) args[0]); + return resultSet(row == null ? null : new Object[][]{row}, i -> null); + } + return null; + }); + } + + private static ResultSet resultSet(byte[] value) { + return resultSet(new Object[][]{{value}}, i -> value); + } + + private static ResultSet resultSet(Object[][] rows, Function objects) { + int[] position = {-1}; + ResultSetMetaData metaData = (ResultSetMetaData) Proxy.newProxyInstance( + VectorDialectTest.class.getClassLoader(), new Class[]{ResultSetMetaData.class}, + (proxy, method, args) -> "getColumnCount".equals(method.getName()) ? 1 : null); + return (ResultSet) Proxy.newProxyInstance(VectorDialectTest.class.getClassLoader(), + new Class[]{ResultSet.class}, (proxy, method, args) -> { + switch (method.getName()) { + case "next": + return rows != null && ++position[0] < rows.length; + case "getBoolean": + return Boolean.TRUE.equals(rows[position[0]][(Integer) args[0] - 1]); + case "getString": + Object value = rows[Math.max(position[0], 0)][(Integer) args[0] - 1]; + return value != null ? value.toString() : null; + case "getBytes": + case "getObject": + return objects.apply((Integer) args[0]) != null ? objects.apply((Integer) args[0]) + : rows[Math.max(position[0], 0)][(Integer) args[0] - 1]; + case "getMetaData": + return metaData; + case "wasNull": + return false; + default: + return null; + } + }); + } +} diff --git a/src/test/java/com/github/sqljam/it/ItDatabase.java b/src/test/java/com/github/sqljam/it/ItDatabase.java index b26b474..5cf246b 100644 --- a/src/test/java/com/github/sqljam/it/ItDatabase.java +++ b/src/test/java/com/github/sqljam/it/ItDatabase.java @@ -216,7 +216,7 @@ public DbType getDbType() { * 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; + return this == ORACLE; } public String getPrefix() { diff --git a/src/test/java/com/github/sqljam/it/JdbcMetricsIT.java b/src/test/java/com/github/sqljam/it/JdbcMetricsIT.java new file mode 100644 index 0000000..99d50a1 --- /dev/null +++ b/src/test/java/com/github/sqljam/it/JdbcMetricsIT.java @@ -0,0 +1,77 @@ +/* + * 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.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.util.Map; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.face.service.DashboardSampler; +import com.github.sqljam.face.service.DatabaseSession; +import com.github.sqljam.face.service.JdbcMetrics; +import com.github.sqljam.face.service.MetricSource; + +/** + * @Description: JdbcMetricsIT samples the dashboard of each database twice: its metrics are read, counters become + * rates after the second sample, and most metrics are told by the server. The values and the metrics + * which a server does not tell are printed. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class JdbcMetricsIT { + + @ParameterizedTest + @EnumSource(ItDatabase.class) + void samplesDashboards(ItDatabase database) throws Exception { + assumeTrue(database.isAvailable(), database + " is not available"); + ConnectionProfile profile = database.getSourceProfile(); + DatabaseSession session = new DatabaseSession(profile); + try (JdbcMetrics metrics = new JdbcMetrics(profile, session)) { + DashboardSampler sampler = new DashboardSampler(metrics); + sampler.sample(); + Thread.sleep(1500); + DashboardSampler.Sample sample = sampler.sample(); + int series = 0; + int shown = 0; + StringBuilder text = new StringBuilder(database.name()).append('\n'); + for (MetricSource.Chart chart : metrics.getCharts()) { + for (MetricSource.Series line : chart.getSeries()) { + series++; + Double value = sample.get(chart, line); + boolean missing = line.getKeys().stream().anyMatch(metrics.getUnavailable()::containsKey); + if (!missing) { + shown++; + } + text.append(String.format(" %-28s %-22s %s%n", chart.getTitle(), line.getName(), missing + ? "UNAVAILABLE " + line.getKeys().stream().map(metrics.getUnavailable()::get).filter( + reason -> reason != null).findFirst().orElse("") : value)); + } + } + System.out.println(text); + Map values = sample.getValues(); + assertTrue(values.containsKey("Sample time/All metrics")); + // As many as the server tells: most of them for the user of the tests + assertTrue(shown * 2 >= series, database + " tells " + shown + " of " + series + "\n" + text); + } finally { + session.close(); + } + } +} diff --git a/src/test/java/com/github/sqljam/it/PartitionIT.java b/src/test/java/com/github/sqljam/it/PartitionIT.java index ccbb5eb..9f743f6 100644 --- a/src/test/java/com/github/sqljam/it/PartitionIT.java +++ b/src/test/java/com/github/sqljam/it/PartitionIT.java @@ -42,6 +42,7 @@ import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import com.github.sqljam.face.model.TableInfo; @@ -89,6 +90,51 @@ private static synchronized void loadPartitions(ItDatabase db) throws Exception LOADED.add(db); } + /** + * Partitioned tables (not the fixture table sales) and their copies are dropped after the tests, other tests + * expect the fixture tables only + */ + @AfterAll + static synchronized void dropPartitionedTables() throws SQLException { + for (ItDatabase db : LOADED) { + List tables; + try (Connection connection = db.getSourceConnection()) { + tables = getPartitionedTables(db, connection).stream().filter(table -> !table.toLowerCase( + Locale.ENGLISH).endsWith("sales")).collect(Collectors.toList()); + dropWithDialect(db, connection, getSourceSchema(db), tables); + } + if (!db.isSingleSchema()) { + try (Connection connection = db.getTargetConnection()) { + dropWithDialect(db, connection, getCopySchema(db), tables); + } + } else { + dropCopies(db, tables); + } + } + LOADED.clear(); + } + + private static void dropWithDialect(ItDatabase db, Connection connection, String schema, List tables) + throws SQLException { + Dialect dialect = db.getDbType().createDialect(); + boolean catalog = db.getDbType() == DbType.MYSQL || db.getDbType() == DbType.MARIADB; + // Children of references first (Oracle) + List ordered = new ArrayList<>(tables); + ordered.sort((a, b) -> Boolean.compare(!a.toLowerCase(Locale.ENGLISH).contains("child"), + !b.toLowerCase(Locale.ENGLISH).contains("child"))); + for (String table : ordered) { + try (Statement statement = connection.createStatement()) { + statement.execute(dialect.getDropTableStatement(catalog ? schema : null, catalog ? null : schema, + table)); + } catch (SQLException e) { + // A table which is gone already + } + } + if (!connection.getAutoCommit()) { + connection.commit(); + } + } + /** * Partitioned tables and their partitions, copies of earlier runs are not read */ diff --git a/src/test/java/com/github/sqljam/it/SshTunnelIT.java b/src/test/java/com/github/sqljam/it/SshTunnelIT.java new file mode 100644 index 0000000..3258149 --- /dev/null +++ b/src/test/java/com/github/sqljam/it/SshTunnelIT.java @@ -0,0 +1,212 @@ +/* + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +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.service.DatabaseInfo; +import com.github.sqljam.face.service.DatabaseSession; +import com.github.sqljam.utils.EmbeddedSshServer; +import com.github.sqljam.utils.SshSettings; +import com.github.sqljam.utils.SshUtils; + +/** + * @Description: SshTunnelIT connects to the server databases of the tests through SSH tunnels of an embedded SSH + * server: the test of a connection, the browsing of a session (a tunnel for all databases of + * PostgreSQL), SQL, the Info page and a custom url. A real SSH server is tested too when + * -Dsqljam.it.ssh.host (with .port, .user, .password or .key, .passphrase and .target) tells it. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class SshTunnelIT { + + @TempDir + static Path dir; + + static EmbeddedSshServer server; + + @BeforeAll + static void startServer() throws Exception { + SshUtils.setKnownHostsFile(dir.resolve("known_hosts")); + server = new EmbeddedSshServer(dir.resolve("host.key"), 0); + } + + @AfterAll + static void stopServer() throws Exception { + SshUtils.closeAll(); + server.close(); + } + + static ConnectionProfile tunneled(ItDatabase database, SshSettings settings) { + ConnectionProfile profile = database.getSourceProfile(); + profile.setSsh(settings); + return profile; + } + + /** + * Catalogs of the database, schemas of a database without catalogs (Oracle) + */ + static List browse(DatabaseSession session) throws Exception { + return session.getDbType().isCatalogSupported() ? session.getCatalogs() : session.getSchemas(null); + } + + @ParameterizedTest + @EnumSource(value = ItDatabase.class, names = {"MYSQL", "POSTGRESQL", "SQLSERVER", "ORACLE"}) + void connectsThroughTunnel(ItDatabase database) throws Exception { + assumeTrue(database.isAvailable()); + ConnectionProfile profile = tunneled(database, server.passwordSettings()); + String url = profile.getJdbcUrl(); + assertTrue(url.contains(SshUtils.LOCALHOST_ADDRESS + ":" + SshUtils.getLocalPort(profile.getId())), + url); + assertFalse(url.contains(database.getSourceProfile().getHostname()), url); + // A tunnel opened for the test is closed after it + SshUtils.close(profile.getId()); + assertFalse(StringUtils.isBlank(DatabaseSession.testConnection(profile))); + assertFalse(SshUtils.isOpen(profile.getId())); + + try (DatabaseSession session = new DatabaseSession(profile)) { + assertFalse(browse(session).isEmpty()); + try (Connection connection = session.openSqlConnection(null, null); Statement statement = connection + .createStatement(); ResultSet rs = statement.executeQuery(database == ItDatabase.ORACLE + ? "SELECT 1 FROM DUAL" : "SELECT 1")) { + assertTrue(rs.next()); + assertEquals(1, rs.getInt(1)); + } + Map> overview = new DatabaseInfo(profile, session).getOverview(); + String tunnel = overview.get("Server").get("SSH tunnel"); + assertTrue(tunnel.startsWith(EmbeddedSshServer.USER + "@127.0.0.1:" + server.getPort()), tunnel); + // A broken tunnel is opened again on its port, the pool of the session reaches it + int port = SshUtils.getLocalPort(profile.getId()); + server.dropSessions(); + long deadline = System.currentTimeMillis() + 5000; + while (SshUtils.isOpen(profile.getId()) && System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + assertFalse(browse(session).isEmpty()); + assertEquals(port, SshUtils.getLocalPort(profile.getId())); + } + // The session closes its tunnel + assertFalse(SshUtils.isOpen(profile.getId())); + } + + /** + * PostgreSQL connects to a database per connection, all of them through one tunnel + */ + @Test + void browsesDatabasesOfPostgreSqlThroughOneTunnel() throws Exception { + ItDatabase database = ItDatabase.POSTGRESQL; + assumeTrue(database.isAvailable()); + ConnectionProfile profile = tunneled(database, server.passwordSettings()); + int sessions = server.getSessions(); + try (DatabaseSession session = new DatabaseSession(profile)) { + for (String catalog : session.getCatalogs()) { + session.getSchemas(catalog); + } + assertEquals(sessions + 1, server.getSessions()); + } + } + + /** + * The host and port of a custom url are reached through the tunnel + */ + @Test + void rewritesCustomUrl() throws Exception { + ItDatabase database = ItDatabase.MYSQL; + assumeTrue(database.isAvailable()); + ConnectionProfile profile = database.getSourceProfile(); + profile.setUrl(profile.getJdbcUrl()); + profile.setHostname(null); + profile.setSsh(server.passwordSettings()); + String url = profile.getJdbcUrl(); + assertTrue(url.startsWith("jdbc:mysql://127.0.0.1:" + SshUtils.getLocalPort(profile.getId())), url); + assertFalse(StringUtils.isBlank(DatabaseSession.testConnection(profile))); + SshUtils.close(profile.getId()); + } + + /** + * A real SSH server, e.g. OpenSSH of Windows or of a Tailscale network: -Dsqljam.it.ssh.host (with .port, + * .user, .password or .key and .passphrase). The databases of the tests are reached as -Dsqljam.it.ssh.targetHost + * names them from the SSH server, e.g. localhost, their own host by default. -Dsqljam.it.ssh.targets limits + * the databases, e.g. MYSQL,ORACLE. + */ + @ParameterizedTest + @EnumSource(value = ItDatabase.class, names = {"MYSQL", "POSTGRESQL", "SQLSERVER", "ORACLE"}) + void connectsThroughRealServer(ItDatabase database) throws Exception { + String host = System.getProperty("sqljam.it.ssh.host"); + assumeTrue(StringUtils.isNotBlank(host), "-Dsqljam.it.ssh.host is not given"); + String targets = System.getProperty("sqljam.it.ssh.targets", ""); + assumeTrue(targets.isBlank() || List.of(targets.split(",")).contains(database.name())); + assumeTrue(database.isAvailable()); + SshSettings settings = new SshSettings(); + settings.setEnabled(true); + settings.setHost(host); + settings.setPort(Integer.parseInt(System.getProperty("sqljam.it.ssh.port", "22"))); + settings.setUsername(System.getProperty("sqljam.it.ssh.user", System.getProperty("user.name"))); + String key = System.getProperty("sqljam.it.ssh.key"); + if (StringUtils.isNotBlank(key)) { + settings.setAuthentication(SshSettings.Authentication.KEY); + settings.setPrivateKeyFile(key); + settings.setPassphrase(System.getProperty("sqljam.it.ssh.passphrase")); + } else { + settings.setPassword(System.getProperty("sqljam.it.ssh.password")); + } + // Host keys of a real server are kept apart from those of the embedded one + Path knownHosts = dir.resolve("real_known_hosts"); + SshUtils.setKnownHostsFile(knownHosts); + try { + ConnectionProfile profile = tunneled(database, settings); + String targetHost = System.getProperty("sqljam.it.ssh.targetHost"); + if (StringUtils.isNotBlank(targetHost)) { + profile.setHostname(targetHost); + } + assertFalse(StringUtils.isBlank(DatabaseSession.testConnection(profile))); + assertTrue(Files.readString(knownHosts).contains(host), "The host key is remembered"); + try (DatabaseSession session = new DatabaseSession(profile)) { + assertFalse(browse(session).isEmpty()); + try (Connection connection = session.openSqlConnection(null, null); Statement statement = connection + .createStatement(); ResultSet rs = statement.executeQuery(database == ItDatabase.ORACLE + ? "SELECT 1 FROM DUAL" : "SELECT 1")) { + assertTrue(rs.next()); + } + String tunnel = new DatabaseInfo(profile, session).getOverview().get("Server").get("SSH tunnel"); + assertTrue(tunnel.startsWith(settings.getUsername() + "@" + host), tunnel); + } + assertFalse(SshUtils.isOpen(profile.getId())); + } finally { + SshUtils.setKnownHostsFile(dir.resolve("known_hosts")); + } + } +} diff --git a/src/test/java/com/github/sqljam/it/TransferBenchmark.java b/src/test/java/com/github/sqljam/it/TransferBenchmark.java new file mode 100644 index 0000000..7673d65 --- /dev/null +++ b/src/test/java/com/github/sqljam/it/TransferBenchmark.java @@ -0,0 +1,485 @@ +/* + * 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.assertFalse; + +import java.io.File; +import java.nio.charset.StandardCharsets; +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.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Random; +import java.util.stream.Collectors; + +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testfx.framework.junit5.ApplicationExtension; +import com.github.sqljam.face.view.DocImages; +import com.github.sqljam.face.view.FxTestSupport; +import com.github.sqljam.impexp.AbstractFileImporter; +import com.github.sqljam.impexp.CsvExporter; +import com.github.sqljam.impexp.DataFileStrategy; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.Exporter; +import com.github.sqljam.impexp.ImportExporter; +import com.github.sqljam.impexp.ParquetExporter; +import com.github.sqljam.impexp.ScriptExporter; +import com.github.sqljam.utils.RestClient; +import com.github.sqljam.vdb.VdbClient; +import com.github.sqljam.vdb.VdbExportConfiguration; +import com.github.sqljam.vdb.VdbExporter; +import com.github.sqljam.vdb.it.VdbImportIT; +import com.github.sqljam.vdb.it.VdbServer; +import javafx.scene.Group; +import javafx.scene.image.WritableImage; +import javafx.scene.paint.Color; +import javafx.scene.shape.Line; +import javafx.scene.shape.Rectangle; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; + +/** + * @Description: TransferBenchmark measures the throughput of SqlJam for the Performance section of the documents: + * a table of 200,000 rows and 6 columns of H2 copied straight into each database of the tests, + * exported as SQL, Parquet and CSV packages, a SQL package imported into PostgreSQL, and a Qdrant + * collection of vectors imported into PostgreSQL. It writes target/bench/transfer.md and the chart + * benchmark.svg and benchmark.png. It is not a test of the build (the name does not end with Test or + * IT), run it by + * mvn test -Dtest=TransferBenchmark -Dbench.output=docs/assets + * with -Dbench.rows, -Dbench.targets (e.g. POSTGRESQL,MYSQL), -Dbench.vectors (points of the + * collection, 0 leaves it out) and -Dbench.environment (the line under the title of the chart). + * The tables and the collection are dropped after each measurement, the source database at the end. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +@ExtendWith(ApplicationExtension.class) +class TransferBenchmark { + + private static final String TABLE = "BENCH_ROWS"; + private static final String COLLECTION = "sjvt_bench"; + private static final int DIMENSIONS = 256; + private static final File DIR = new File("target/bench"); + + static final String DIRECT = "#3fb950"; + static final String PACKAGE = "#d29922"; + static final String IMPORT = "#58a6ff"; + static final String VECTOR = "#a371f7"; + + /** + * One measured scenario: its label on the chart, the rows and the seconds + */ + static final class Result { + + final String label; + final String color; + final long rows; + final double seconds; + + Result(String label, String color, long rows, double seconds) { + this.label = label; + this.color = color; + this.rows = rows; + this.seconds = seconds; + } + + double rowsPerSecond() { + return rows / seconds; + } + } + + /** + * A new source of 6 columns: id, order number, customer, status, total and time + */ + private static String createSource(long rows) throws Exception { + File file = new File(DIR, "transfer-source"); + FileUtils.deleteQuietly(new File(file.getPath() + ".mv.db")); + String url = "jdbc:h2:file:" + file.getAbsolutePath(); + try (Connection connection = DriverManager.getConnection(url, "sa", ""); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE " + TABLE + " (ID BIGINT PRIMARY KEY, ORDER_NO VARCHAR(20) NOT NULL," + + " CUSTOMER_ID INT NOT NULL, STATUS VARCHAR(12) NOT NULL, TOTAL DECIMAL(12,2) NOT NULL," + + " CREATED_AT TIMESTAMP NOT NULL)"); + statement.execute("INSERT INTO " + TABLE + " SELECT X, 'SO-' || LPAD(X, 12, '0'), MOD(X, 5000)," + + " CASEWHEN(MOD(X, 3) = 0, 'PAID', 'NEW'), MOD(X, 100000) / 100.0," + + " TIMESTAMP '2024-01-01 00:00:00' + X * INTERVAL '1' SECOND FROM SYSTEM_RANGE(1, " + rows + ")"); + } + return url; + } + + private static void configureSource(Exporter.ExportConfiguration source, String url) { + source.setDbType(DbType.H2); + source.setUrl(url); + source.setUsername("sa"); + source.setPassword(""); + source.setIncludedSchemaNames(new String[]{"PUBLIC"}); + source.setIncludedTableNames(new String[]{TABLE}); + source.setIdReused(true); + } + + private static double seconds(long start) { + return (System.nanoTime() - start) / 1e9; + } + + /** + * Rows of the imported table, its name is folded by the database or kept in lower case + */ + private static long count(ItDatabase target) throws SQLException { + String owner = target.getDbType() == DbType.MYSQL || target.getDbType() == DbType.MARIADB + ? target.getTargetCatalog() : target.getTargetSchema(); + for (String table : new String[]{TABLE, TABLE.toLowerCase(Locale.ENGLISH)}) { + String name = (owner != null ? owner + "." : "") + table; + try (Connection connection = target.getTargetConnection(); + Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SELECT COUNT(*) FROM " + name)) { + rs.next(); + return rs.getLong(1); + } catch (SQLException e) { + // The other case of the name + } + } + throw new SQLException("Table " + TABLE + " is not in " + target); + } + + private static void drop(ItDatabase target, String table) { + String owner = target.getDbType() == DbType.MYSQL || target.getDbType() == DbType.MARIADB + ? target.getTargetCatalog() : target.getTargetSchema(); + for (String name : new String[]{table, table.toLowerCase(Locale.ENGLISH), table.toUpperCase(Locale.ENGLISH)}) { + try (Connection connection = target.getTargetConnection(); + Statement statement = connection.createStatement()) { + statement.execute("DROP TABLE " + (owner != null ? owner + "." : "") + name); + return; + } catch (Exception ignored) { + // Not created, or another case + } + } + } + + private static Result copy(String sourceUrl, ItDatabase target, long rows) throws Exception { + drop(target, TABLE); + ImportExporter copier = new ImportExporter(); + configureSource(copier.getExportConfiguration(), sourceUrl); + target.configureTarget(copier.getImportConfiguration()); + long start = System.nanoTime(); + copier.exportDdlAndData(); + double seconds = seconds(start); + assertEquals(rows, count(target), "Rows of " + target); + drop(target, TABLE); + return new Result("H2 → " + target.getDbType().getDisplayName(), DIRECT, rows, seconds); + } + + private static Result sqlPackage(String sourceUrl, File dir, long rows) throws Exception { + FileUtils.deleteQuietly(dir); + ScriptExporter exporter = new ScriptExporter(dir, DataFileStrategy.FILE_PER_TABLE, 10L * 1024 * 1024); + configureSource(exporter.getConfiguration(), sourceUrl); + exporter.setTargetDbType(DbType.POSTGRESQL); + long start = System.nanoTime(); + exporter.exportDdlAndData(); + return new Result("H2 → SQL package", PACKAGE, rows, seconds(start)); + } + + private static Result parquetPackage(String sourceUrl, File dir, long rows) throws Exception { + FileUtils.deleteQuietly(dir); + ParquetExporter exporter = new ParquetExporter(dir); + configureSource(exporter.getConfiguration(), sourceUrl); + long start = System.nanoTime(); + exporter.exportDdlAndData(); + return new Result("H2 → Parquet package", PACKAGE, rows, seconds(start)); + } + + private static Result csvPackage(String sourceUrl, File dir, long rows) throws Exception { + FileUtils.deleteQuietly(dir); + CsvExporter exporter = new CsvExporter(dir); + configureSource(exporter.getConfiguration(), sourceUrl); + long start = System.nanoTime(); + exporter.exportDdlAndData(); + return new Result("H2 → CSV package", PACKAGE, rows, seconds(start)); + } + + private static Result importPackage(File dir, ItDatabase target, long rows) throws Exception { + drop(target, TABLE); + double seconds; + try (Connection connection = target.getTargetConnection()) { + AbstractFileImporter importer = AbstractFileImporter.forDirectory(dir, connection, target.getDbType()); + importer.setStopOnError(true); + long start = System.nanoTime(); + importer.importDirectory(dir); + seconds = seconds(start); + } + assertEquals(rows, count(target), "Rows of the package in " + target); + drop(target, TABLE); + return new Result("SQL package → " + target.getDbType().getDisplayName(), IMPORT, rows, seconds); + } + + /** + * A collection of random vectors and a payload of two fields, imported into the target + */ + private static Result vectors(ItDatabase target, int points) throws Exception { + VdbServer server = VdbServer.QDRANT; + RestClient rest = server.rest(); + try { + rest.delete("/collections/" + COLLECTION); + } catch (RuntimeException e) { + // There is none + } + Map vectors = new LinkedHashMap<>(); + vectors.put("size", DIMENSIONS); + vectors.put("distance", "Cosine"); + rest.put("/collections/" + COLLECTION, Map.of("vectors", vectors)); + Random random = new Random(7); + for (int from = 1; from <= points; from += 500) { + List> batch = new ArrayList<>(); + for (int id = from; id < Math.min(from + 500, points + 1); id++) { + float[] vector = new float[DIMENSIONS]; + for (int i = 0; i < DIMENSIONS; i++) { + vector[i] = random.nextFloat() - 0.5f; + } + batch.add(Map.of("id", id, "vector", vector, "payload", Map.of("title", "Document " + id, + "n", id))); + } + rest.put("/collections/" + COLLECTION + "/points?wait=true", Map.of("points", batch)); + } + drop(target, COLLECTION); + try { + VdbExportConfiguration configuration = new VdbExportConfiguration(); + configuration.setTargetSchema(VdbImportIT.getSchema(target)); + configuration.addCollection(COLLECTION, COLLECTION); + configuration.setTableRecreated(true); + configuration.setVectorIndexCreated(Boolean.parseBoolean(System.getProperty("bench.vectorIndex", + "true"))); + long start = System.nanoTime(); + try (VdbClient client = server.createClient()) { + assertEquals(points, new VdbExporter(client, VdbImportIT.dataSource(target), configuration).export() + .getWritten()); + } + return new Result(String.format(Locale.ENGLISH, "Qdrant → %s (%,d points, %d-d vectors and payload)", + target.getDbType().getDisplayName(), points, DIMENSIONS), VECTOR, points, seconds(start)); + } finally { + drop(target, COLLECTION); + rest.delete("/collections/" + COLLECTION); + } + } + + @Test + void run() throws Exception { + long rows = Long.getLong("bench.rows", 200_000); + int points = Integer.getInteger("bench.vectors", 20_000); + List names = Arrays.asList(System.getProperty("bench.targets", + "MYSQL,MARIADB,POSTGRESQL,ORACLE,SQLSERVER,DB2,CLICKHOUSE,DUCKDB").split(",")); + String environment = System.getProperty("bench.environment", "Apple M2 Max · JDK 17 · databases in Docker" + + " on a LAN server · default settings · higher is better"); + DIR.mkdirs(); + String sourceUrl = createSource(rows); + List results = new ArrayList<>(); + try { + for (String name : names) { + ItDatabase target = ItDatabase.valueOf(name.trim()); + if (target.isAvailable()) { + results.add(copy(sourceUrl, target, rows)); + } + } + File sql = new File(DIR, "transfer-sql"); + results.add(sqlPackage(sourceUrl, sql, rows)); + results.add(parquetPackage(sourceUrl, new File(DIR, "transfer-parquet"), rows)); + results.add(csvPackage(sourceUrl, new File(DIR, "transfer-csv"), rows)); + if (ItDatabase.POSTGRESQL.isAvailable()) { + results.add(importPackage(sql, ItDatabase.POSTGRESQL, rows)); + if (points > 0 && VdbServer.QDRANT.isAvailable()) { + results.add(vectors(ItDatabase.POSTGRESQL, points)); + } + } + } finally { + FileUtils.deleteQuietly(new File(DIR, "transfer-source.mv.db")); + } + assertFalse(results.isEmpty()); + String title = String.format(Locale.ENGLISH, "Throughput · %,d rows, 6 columns", rows); + Files.writeString(new File(DIR, "transfer.md").toPath(), table(results, environment)); + // Rows of vectors are not rows of 6 columns, the chart compares the rows of the table only + List shapes = chart(title, environment, results.stream().filter(result -> !VECTOR.equals( + result.color)).collect(Collectors.toList())); + File output = new File(System.getProperty("bench.output", DIR.getPath())); + output.mkdirs(); + Files.writeString(new File(output, "benchmark.svg").toPath(), svg(shapes), StandardCharsets.UTF_8); + WritableImage image = FxTestSupport.call(() -> new Group(shapes.stream().map(Shape::toNode) + .collect(Collectors.toList())).snapshot(null, null)); + DocImages.write(image, new File(output, "benchmark.png")); + } + + /** + * The Performance table of the README + */ + static String table(List results, String environment) { + StringBuilder text = new StringBuilder("| Scenario | Rows | Time | Rows/s |\n|---|---|---|---|\n"); + for (Result result : results) { + text.append(String.format(Locale.ENGLISH, "| %s | %,d | %.1f s | %.1fk |%n", result.label, result.rows, + result.seconds, result.rowsPerSecond() / 1000)); + } + return text.append("\nEnvironment: ").append(environment).append('\n').toString(); + } + + /** + * An element of the chart, drawn as SVG and as a JavaFX node from the same values + */ + static final class Shape { + + final String kind; + final double x; + final double y; + final double width; + final double height; + final String text; + final String color; + final double size; + final String anchor; + final boolean bold; + + Shape(String kind, double x, double y, double width, double height, String text, String color, double size, + String anchor, boolean bold) { + this.kind = kind; + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.text = text; + this.color = color; + this.size = size; + this.anchor = anchor; + this.bold = bold; + } + + static Shape rect(double x, double y, double width, double height, String color) { + return new Shape("rect", x, y, width, height, null, color, 0, null, false); + } + + static Shape line(double x1, double y1, double x2, double y2, String color) { + return new Shape("line", x1, y1, x2, y2, null, color, 0, null, false); + } + + static Shape text(double x, double y, String text, String color, double size, String anchor, boolean bold) { + return new Shape("text", x, y, 0, 0, text, color, size, anchor, bold); + } + + String toSvg() { + switch (kind) { + case "rect": + return String.format(Locale.ENGLISH, "", x, y, width, height, height > 100 ? 16 : 4, color); + case "line": + return String.format(Locale.ENGLISH, "", x, y, width, height, color); + default: + return String.format(Locale.ENGLISH, "%s", x, y, color, size, bold ? " font-weight=\"700\"" : "", + "start".equals(anchor) ? "" : " text-anchor=\"" + anchor + "\"", text.replace("&", + "&").replace("<", "<")); + } + } + + javafx.scene.Node toNode() { + switch (kind) { + case "rect": + Rectangle rectangle = new Rectangle(x, y, width, height); + double arc = height > 100 ? 32 : 8; + rectangle.setArcWidth(arc); + rectangle.setArcHeight(arc); + rectangle.setFill(Color.web(color)); + return rectangle; + case "line": + Line line = new Line(x, y, width, height); + line.setStroke(Color.web(color)); + return line; + default: + Text node = new Text(text); + node.setFont(Font.font("Helvetica", bold ? FontWeight.BOLD : FontWeight.NORMAL, size)); + node.setFill(Color.web(color)); + double textWidth = node.getLayoutBounds().getWidth(); + node.setX("end".equals(anchor) ? x - textWidth : "middle".equals(anchor) ? x - textWidth / 2 : x); + node.setY(y); + node.setTextAlignment(TextAlignment.LEFT); + return node; + } + } + } + + /** + * Bars of rows per second, like the chart of the earlier documents + */ + static List chart(String title, String environment, List results) { + double left = 340; + double right = 1140; + double top = 110; + double bar = 26; + double gap = 14; + double height = top + results.size() * (bar + gap) + 70; + double max = results.stream().mapToDouble(Result::rowsPerSecond).max().orElse(1); + double step = niceStep(max / 5); + double scale = Math.ceil(max / step) * step; + double bottom = top + results.size() * (bar + gap); + List shapes = new ArrayList<>(); + shapes.add(Shape.rect(0, 0, 1200, height, "#0d1117")); + shapes.add(Shape.text(40, 52, title, "#e6edf3", 24, "start", true)); + shapes.add(Shape.text(40, 78, environment, "#8b949e", 14, "start", false)); + for (double value = 0; value <= scale + 1e-6; value += step) { + double x = left + (right - left) * value / scale; + shapes.add(Shape.line(x, top, x, bottom, value == 0 ? "#30363d" : "#21262d")); + shapes.add(Shape.text(x, bottom + 22, value == 0 ? "0" : (Math.round(value / 1000) + "k") + + (value + step > scale + 1e-6 ? " rows/s" : ""), "#8b949e", 12, "middle", false)); + } + shapes.add(Shape.line(left, bottom, right, bottom, "#30363d")); + double y = top + gap / 2; + for (Result result : results) { + double width = Math.max(2, (right - left) * result.rowsPerSecond() / scale); + shapes.add(Shape.text(left - 16, y + bar * 0.68, result.label, "#c9d1d9", 14, "end", false)); + shapes.add(Shape.rect(left, y, width, bar, result.color)); + shapes.add(Shape.text(left + width + 10, y + bar * 0.68, String.format(Locale.ENGLISH, "%.1fk", + result.rowsPerSecond() / 1000), "#c9d1d9", 14, "start", false)); + y += bar + gap; + } + return shapes; + } + + static double niceStep(double raw) { + double magnitude = Math.pow(10, Math.floor(Math.log10(raw))); + for (double factor : new double[]{1, 2, 2.5, 5, 10}) { + if (factor * magnitude >= raw) { + return factor * magnitude; + } + } + return 10 * magnitude; + } + + static String svg(List shapes) { + Shape background = shapes.get(0); + return String.format(Locale.ENGLISH, "" + + "%n", background.width, background.height, background.width, background.height) + + shapes.stream().map(shape -> " " + shape.toSvg()).collect(Collectors.joining("\n")) + "\n\n"; + } +} diff --git a/src/test/java/com/github/sqljam/it/VectorIT.java b/src/test/java/com/github/sqljam/it/VectorIT.java new file mode 100644 index 0000000..9324f8c --- /dev/null +++ b/src/test/java/com/github/sqljam/it/VectorIT.java @@ -0,0 +1,826 @@ +/* + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import com.github.sqljam.impexp.AbstractFileImporter; +import com.github.sqljam.impexp.CsvExporter; +import com.github.sqljam.impexp.DataFileExporter; +import com.github.sqljam.impexp.DataFileStrategy; +import com.github.sqljam.impexp.DatabaseFeature; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.impexp.Dialect; +import com.github.sqljam.impexp.Exporter; +import com.github.sqljam.impexp.ImportExporter; +import com.github.sqljam.impexp.MetaDataOperations; +import com.github.sqljam.impexp.ParquetExporter; +import com.github.sqljam.impexp.ScriptExporter; +import com.github.sqljam.impexp.VectorIndex; +import com.github.sqljam.impexp.VectorType; + +/** + * @Description: VectorIT copies vectors of real embeddings (bge-m3 of 1024 dimensions, qwen3-embedding of 2560 + * dimensions, generated by Ollama into vector/embeddings.tsv) between the databases which have vectors + * and those which keep them as text. The vector types, the vector indexes, every element of the vectors + * and the nearest neighbors are verified. Boundaries: a target without pgvector, users without the + * views of vector indexes and of settings, rows which a database refuses. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class VectorIT { + + static final String TABLE = "sjv_doc"; + private static final String PATTERN = "(?i)sjv_.*"; + /** + * Relative difference of elements: some databases print vectors with 6 digits, e.g. 1.00000e+00 of MySQL + */ + private static final double TOLERANCE = 1e-5; + private static final double HALF_TOLERANCE = 1e-3; + /** + * Rows of the embeddings and a row of boundary values + */ + private static final int ROWS = 9; + private static final int BOUNDARY_ID = 9; + static final List EMBEDDINGS = new ArrayList<>(); + + /** + * Targets which keep the vectors of the columns as vectors: embedding vector(1024) NOT NULL, + * embedding_large halfvec(2560), small vector(3), tags sparsevec(8) + */ + private static final Map> VECTOR_COLUMNS = new EnumMap<>(ItDatabase.class); + /** + * Targets with a vector index on embedding (cosine), and which search the nearest neighbors + */ + private static final Set INDEXED = EnumSet.of(ItDatabase.POSTGRESQL, ItDatabase.MARIADB, + ItDatabase.ORACLE, ItDatabase.DB2, ItDatabase.CLICKHOUSE); + private static final Set SEARCHABLE = EnumSet.of(ItDatabase.POSTGRESQL, ItDatabase.MARIADB, + ItDatabase.ORACLE, ItDatabase.SQLSERVER, ItDatabase.DB2, ItDatabase.DUCKDB, ItDatabase.CLICKHOUSE); + + static { + VECTOR_COLUMNS.put(ItDatabase.POSTGRESQL, Set.of("embedding", "embedding_large", "small", "tags")); + VECTOR_COLUMNS.put(ItDatabase.MYSQL, Set.of("embedding", "embedding_large", "small")); + VECTOR_COLUMNS.put(ItDatabase.MARIADB, Set.of("embedding", "embedding_large", "small")); + VECTOR_COLUMNS.put(ItDatabase.ORACLE, Set.of("embedding", "embedding_large", "small", "tags")); + // 2560 dimensions of 16-bit floats: VECTOR(2560, float16) of SQL Server, 32-bit floats of Db2 + VECTOR_COLUMNS.put(ItDatabase.SQLSERVER, Set.of("embedding", "embedding_large", "small")); + VECTOR_COLUMNS.put(ItDatabase.DB2, Set.of("embedding", "embedding_large", "small")); + VECTOR_COLUMNS.put(ItDatabase.DUCKDB, Set.of("embedding", "embedding_large", "small")); + // Arrays of ClickHouse take no NULL: the nullable vectors are text + VECTOR_COLUMNS.put(ItDatabase.CLICKHOUSE, Set.of("embedding")); + } + + @BeforeAll + static void loadEmbeddings() throws IOException { + if (!EMBEDDINGS.isEmpty()) { + return; + } + for (String line : Files.readAllLines(Paths.get("src/test/resources/vector/embeddings.tsv"), + StandardCharsets.UTF_8)) { + if (!line.startsWith("#") && !line.isBlank()) { + EMBEDDINGS.add(line.split("\t")); + } + } + assertEquals(ROWS - 1, EMBEDDINGS.size()); + } + + /** + * Rows of the source table: id, title, embedding, embedding_large, small, tags + */ + static List rows() { + List rows = new ArrayList<>(); + for (String[] embedding : EMBEDDINGS) { + int id = Integer.parseInt(embedding[0]); + rows.add(new Object[]{id, embedding[1], embedding[2], embedding[3], + String.format("[%d,%s,-%d.5]", id, id % 2 == 0 ? "0" : "0.25", id), + String.format("{%d:1,%d:0.5}/8", id, id % 8 + 1)}); + } + // Boundary values: an embedding of negated elements, tiny and huge elements, no large embedding, a sparse + // vector without elements + String[] last = EMBEDDINGS.get(EMBEDDINGS.size() - 1); + String negated = "[" + java.util.Arrays.stream(last[2].substring(1, last[2].length() - 1).split(",")) + .map(item -> item.startsWith("-") ? item.substring(1) : "-" + item).collect(Collectors.joining(",")) + + "]"; + rows.add(new Object[]{BOUNDARY_ID, "Grenzwerte 境界値 '\"\\", negated, null, "[1e-5,-0.5,1e18]", "{}/8"}); + return rows; + } + + static void createPgvectorSource() throws Exception { + try (Connection connection = ItDatabase.POSTGRESQL.getSourceConnection(); + Statement statement = connection.createStatement()) { + String table = ItDatabase.POSTGRESQL.getSourceSchema() + "." + TABLE; + statement.execute("DROP TABLE IF EXISTS " + table); + statement.execute("CREATE TABLE " + table + " (id int PRIMARY KEY, title text NOT NULL," + + " embedding vector(1024) NOT NULL, embedding_large halfvec(2560), small vector(3)," + + " tags sparsevec(8))"); + statement.execute("COMMENT ON COLUMN " + table + ".embedding IS 'bge-m3'"); + try (PreparedStatement ps = connection.prepareStatement("INSERT INTO " + table + + " VALUES (?, ?, ?, ?, ?, ?)")) { + for (Object[] row : rows()) { + for (int i = 0; i < row.length; i++) { + ps.setObject(i + 1, row[i]); + } + ps.addBatch(); + } + ps.executeBatch(); + } + statement.execute("CREATE INDEX sjv_doc_embedding ON " + table + + " USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64)"); + statement.execute("CREATE INDEX sjv_doc_large ON " + table + + " USING hnsw (embedding_large halfvec_l2_ops)"); + statement.execute("CREATE INDEX sjv_doc_small ON " + table + + " USING ivfflat (small vector_l2_ops) WITH (lists = 1)"); + } + } + + // ------------------------------------------------------------------ + // pgvector into every database + // ------------------------------------------------------------------ + + @ParameterizedTest(name = "pgvector into {0}") + @EnumSource(ItDatabase.class) + void pgvectorIntoEveryDatabase(ItDatabase target) throws Exception { + assumeTrue(AbstractImportIT.isTargetSelected(target)); + assumeTrue(ItDatabase.POSTGRESQL.isAvailable() && target.isAvailable()); + createPgvectorSource(); + CollectingListener listener = copy(ItDatabase.POSTGRESQL, target, false); + assertEquals(List.of(), listener.getErrors(), "pgvector into " + target); + + try (Connection connection = target.getTargetConnection()) { + Map> columns = columns(target, connection, TABLE); + Set expected = VECTOR_COLUMNS.getOrDefault(target, Set.of()); + for (String column : new String[]{"embedding", "embedding_large", "small", "tags"}) { + VectorType vectorType = VectorType.of(columns.get(column)); + assertEquals(expected.contains(column), vectorType != null, target + " " + column + " is a vector: " + + columns.get(column)); + } + assertEquals(VectorType.dense(VectorType.Element.FLOAT32, 1024).getDimensions(), + expected.contains("embedding") ? VectorType.of(columns.get("embedding")).getDimensions() + : 1024, target + " dimensions"); + assertRows(target, connection, TABLE, columns); + Map indexes = vectorIndexes(target, connection, TABLE); + if (INDEXED.contains(target)) { + assertNotNull(indexes.get("embedding"), target + " vector index of embedding: " + indexes); + assertEquals(VectorIndex.Distance.COSINE, indexes.get("embedding").getDistance(), target.name()); + } else { + assertTrue(indexes.isEmpty(), target + " has no vector indexes: " + indexes); + } + if (SEARCHABLE.contains(target)) { + assertEquals(nearest(ItDatabase.POSTGRESQL, null), nearest(target, connection), + "nearest neighbors of " + target); + } + } + if (target == ItDatabase.POSTGRESQL) { + // The indexes of pgvector are copied as they are, vector_l2_ops is the default operator class of ivfflat + assertEquals(List.of("hnsw (embedding vector_cosine_ops) WITH (m='16', ef_construction='64')", + "hnsw (embedding_large halfvec_l2_ops)", "ivfflat (small) WITH (lists='1')"), + indexDefinitions(ItDatabase.POSTGRESQL.getTargetSchema())); + } + assertFeatures(target, listener); + } + + /** + * The import tells the detected features of the target + */ + private static void assertFeatures(ItDatabase target, CollectingListener listener) { + String features = listener.getMessages().stream().filter(message -> message.startsWith( + "Target database features")).findFirst().orElse(""); + if (VECTOR_COLUMNS.containsKey(target)) { + assertTrue(features.contains(DatabaseFeature.VECTOR.name()), target + ": " + features); + } else { + assertEquals("", features, target.name()); + } + } + + // ------------------------------------------------------------------ + // Every database with vectors into pgvector + // ------------------------------------------------------------------ + + /** + * Source table of a database with vectors: id, embedding of 1024 dimensions NOT NULL and a vector index of it + * where the database has one + */ + static void createVectorSource(ItDatabase source) throws Exception { + try (Connection connection = source.getSourceConnection(); Statement statement = connection.createStatement()) { + String schema = source.getSourceSchema(); + String table = (schema != null && source.getDbType() != DbType.MYSQL && source.getDbType() != DbType.MARIADB + ? schema + "." : "") + TABLE; + String drop = "DROP TABLE " + table; + String create; + String index = null; + String vector = "?"; + switch (source.getDbType()) { + case MYSQL: + create = "CREATE TABLE " + table + " (id INT PRIMARY KEY, embedding VECTOR(1024) NOT NULL)"; + vector = "STRING_TO_VECTOR(?)"; + break; + case MARIADB: + create = "CREATE TABLE " + table + " (id INT PRIMARY KEY, embedding VECTOR(1024) NOT NULL," + + " VECTOR INDEX sjv_doc_embedding (embedding) M=16 DISTANCE=cosine)"; + vector = "VEC_FromText(?)"; + break; + case ORACLE: + drop = "BEGIN EXECUTE IMMEDIATE 'DROP TABLE " + table + " PURGE'; EXCEPTION WHEN OTHERS THEN" + + " NULL; END;"; + create = "CREATE TABLE " + table + " (ID NUMBER(10) PRIMARY KEY, EMBEDDING VECTOR(1024, FLOAT32)" + + " NOT NULL)"; + index = "CREATE VECTOR INDEX SJV_DOC_EMBEDDING ON " + table + " (EMBEDDING) ORGANIZATION" + + " INMEMORY NEIGHBOR GRAPH DISTANCE COSINE PARAMETERS (TYPE HNSW, NEIGHBORS 16," + + " EFCONSTRUCTION 64)"; + break; + case SQLSERVER: + drop = "DROP TABLE IF EXISTS " + table; + create = "CREATE TABLE " + table + " (id INT PRIMARY KEY, embedding VECTOR(1024) NOT NULL)"; + break; + case DB2: + create = "CREATE TABLE " + table + " (ID INT NOT NULL PRIMARY KEY, EMBEDDING VECTOR(1024, FLOAT32)" + + " NOT NULL)"; + index = "CREATE VECTOR INDEX " + schema + ".SJV_DOC_EMBEDDING ON " + table + + " (EMBEDDING) WITH DISTANCE COSINE"; + vector = "VECTOR(CAST(? AS CLOB), 1024, FLOAT32)"; + break; + case DUCKDB: + drop = "DROP TABLE IF EXISTS " + table; + create = "CREATE TABLE " + table + " (id INTEGER PRIMARY KEY, embedding FLOAT[1024] NOT NULL)"; + vector = "CAST(? AS FLOAT[1024])"; + break; + case CLICKHOUSE: + drop = "DROP TABLE IF EXISTS " + table; + create = "CREATE TABLE " + table + " (id UInt32, embedding Array(Float32), INDEX" + + " sjv_doc_embedding embedding TYPE vector_similarity('hnsw', 'cosineDistance', 1024)" + + " GRANULARITY 100000000) ENGINE = MergeTree ORDER BY id"; + break; + default: + throw new IllegalArgumentException(source.name()); + } + try { + statement.execute(drop); + } catch (SQLException e) { + // The table does not exist + } + statement.execute(create); + if (index != null) { + statement.execute(index); + } + try (PreparedStatement ps = connection.prepareStatement("INSERT INTO " + table + " VALUES (?, " + + vector + ")")) { + for (String[] embedding : EMBEDDINGS) { + ps.setInt(1, Integer.parseInt(embedding[0])); + ps.setString(2, embedding[2]); + ps.addBatch(); + } + ps.executeBatch(); + } + if (!connection.getAutoCommit()) { + connection.commit(); + } + } + } + + @ParameterizedTest(name = "{0} into pgvector") + @EnumSource(value = ItDatabase.class, names = {"MYSQL", "MARIADB", "ORACLE", "SQLSERVER", "DB2", "DUCKDB", + "CLICKHOUSE"}) + void vectorsIntoPgvector(ItDatabase source) throws Exception { + assumeTrue(source.isAvailable() && ItDatabase.POSTGRESQL.isAvailable()); + assumeTrue(AbstractImportIT.isTargetSelected(ItDatabase.POSTGRESQL)); + createVectorSource(source); + CollectingListener listener = copy(source, ItDatabase.POSTGRESQL, false); + assertEquals(List.of(), listener.getErrors(), source + " into pgvector"); + try (Connection connection = ItDatabase.POSTGRESQL.getTargetConnection()) { + Map> columns = columns(ItDatabase.POSTGRESQL, connection, TABLE); + assertEquals(VectorType.dense(VectorType.Element.FLOAT32, 1024), VectorType.of(columns.get("embedding")), + source.name()); + Map indexes = vectorIndexes(ItDatabase.POSTGRESQL, connection, TABLE); + if (source == ItDatabase.MYSQL || source == ItDatabase.SQLSERVER || source == ItDatabase.DUCKDB) { + // These have no vector indexes + assertTrue(indexes.isEmpty(), source + ": " + indexes); + } else { + assertEquals(VectorIndex.Distance.COSINE, indexes.get("embedding").getDistance(), source.name()); + } + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery( + "SELECT id, embedding::text FROM " + ItDatabase.POSTGRESQL.getTargetSchema() + "." + TABLE + + " ORDER BY id")) { + int count = 0; + while (rs.next()) { + String[] embedding = EMBEDDINGS.get(count++); + assertEquals(Integer.parseInt(embedding[0]), rs.getInt(1)); + assertVectorEquals(embedding[2], rs.getString(2), source + " row " + rs.getInt(1)); + } + assertEquals(EMBEDDINGS.size(), count, source.name()); + } + assertEquals(nearestOfEmbeddings(), nearest(ItDatabase.POSTGRESQL, connection), source.name()); + } + } + + // ------------------------------------------------------------------ + // Boundaries + // ------------------------------------------------------------------ + + /** + * A database of PostgreSQL without pgvector: the vectors are text, vector indexes are skipped + */ + @Test + void withoutPgvector() throws Exception { + assumeTrue(ItDatabase.POSTGRESQL.isAvailable()); + createPgvectorSource(); + String url = DbType.POSTGRESQL.getUrl(ItDatabase.Settings.get(ItDatabase.POSTGRESQL, "host"), Integer.parseInt( + ItDatabase.Settings.get(ItDatabase.POSTGRESQL, "port")), "sjit_novector"); + try (Connection connection = DriverManager.getConnection(url, ItDatabase.POSTGRESQL.getUsername(), + ItDatabase.POSTGRESQL.getPassword()); Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SELECT to_regtype('vector') IS NULL")) { + assumeTrue(rs.next() && rs.getBoolean(1), "sjit_novector has pgvector"); + statement.execute("CREATE SCHEMA IF NOT EXISTS sjit_dst"); + } + ImportExporter importExporter = new ImportExporter(); + configureSource(ItDatabase.POSTGRESQL, importExporter.getExportConfiguration()); + ItDatabase.POSTGRESQL.configureTarget(importExporter.getImportConfiguration()); + importExporter.getImportConfiguration().setUrl(url); + importExporter.getImportConfiguration().setTargetCatalogName("sjit_novector"); + CollectingListener listener = new CollectingListener(); + importExporter.setExportListener(listener); + importExporter.exportDdlAndData(); + assertEquals(List.of(), listener.getErrors()); + assertTrue(listener.getMessages().stream().noneMatch(message -> message.startsWith("Target database features")), + listener.getMessages().toString()); + try (Connection connection = DriverManager.getConnection(url, ItDatabase.POSTGRESQL.getUsername(), + ItDatabase.POSTGRESQL.getPassword())) { + Map> columns = columns(ItDatabase.POSTGRESQL, connection, TABLE); + for (String column : new String[]{"embedding", "embedding_large", "small", "tags"}) { + assertEquals("text", columns.get(column).get("TYPE_NAME"), column); + } + assertEquals(List.of(), indexDefinitions(connection, "sjit_dst")); + assertRows(ItDatabase.POSTGRESQL, connection, TABLE, columns); + } + } + + /** + * SQL, Parquet and CSV packages of vectors for PostgreSQL: the vector types and indexes are restored by + * schema.sql + */ + @Test + void packagesOfVectors() throws Exception { + assumeTrue(ItDatabase.POSTGRESQL.isAvailable()); + createPgvectorSource(); + for (String format : new String[]{"sql", "parquet", "csv"}) { + File dir = new File(ItDatabase.DATA_DIR, "vector/" + format); + FileUtils.deleteQuietly(dir); + CollectingListener listener = new CollectingListener(); + if ("sql".equals(format)) { + ScriptExporter exporter = new ScriptExporter(dir, DataFileStrategy.SINGLE_FILE, 64 * 1024); + configureSource(ItDatabase.POSTGRESQL, exporter.getConfiguration()); + exporter.setTargetDbType(DbType.POSTGRESQL); + exporter.setTargetSchemaName(ItDatabase.POSTGRESQL.getTargetSchema()); + exporter.setExportListener(listener); + exporter.exportDdlAndData(); + } else { + DataFileExporter exporter = "csv".equals(format) ? new CsvExporter(dir) : new ParquetExporter(dir); + configureSource(ItDatabase.POSTGRESQL, exporter.getConfiguration()); + exporter.setTargetDbType(DbType.POSTGRESQL); + exporter.setTargetSchemaName(ItDatabase.POSTGRESQL.getTargetSchema()); + exporter.setExportListener(listener); + exporter.exportDdlAndData(); + } + assertEquals(List.of(), listener.getErrors(), format); + try (Connection connection = ItDatabase.POSTGRESQL.getTargetConnection(); + Statement statement = connection.createStatement()) { + statement.execute("DROP TABLE IF EXISTS " + ItDatabase.POSTGRESQL.getTargetSchema() + "." + TABLE); + AbstractFileImporter importer = AbstractFileImporter.forDirectory(dir, connection, DbType.POSTGRESQL); + CollectingListener importListener = new CollectingListener(); + importer.setExportListener(importListener); + importer.importDirectory(dir); + assertEquals(List.of(), importListener.getErrors(), format); + Map> columns = columns(ItDatabase.POSTGRESQL, connection, TABLE); + assertEquals(VectorType.dense(VectorType.Element.FLOAT16, 2560), VectorType.of(columns.get( + "embedding_large")), format); + assertEquals(new VectorType(VectorType.Element.FLOAT32, 8, true), VectorType.of(columns.get("tags")), + format); + assertRows(ItDatabase.POSTGRESQL, connection, TABLE, columns); + } + assertEquals(3, indexDefinitions(ItDatabase.POSTGRESQL.getTargetSchema()).size(), format); + } + } + + /** + * A user of Oracle without SELECT_CATALOG_ROLE reads no distances of vector indexes (V$VECTOR_INDEX) and no + * vector memory (v$parameter): the default distance is taken, IVF indexes are created + */ + @Test + void oracleUserWithoutCatalogViews() throws Exception { + assumeTrue(ItDatabase.ORACLE.isAvailable() && ItDatabase.POSTGRESQL.isAvailable()); + createVectorSource(ItDatabase.ORACLE); + String url = ItDatabase.ORACLE.getSourceUrl(); + try (Connection admin = ItDatabase.ORACLE.getSourceConnection(); Statement statement = admin.createStatement()) { + statement.execute("GRANT SELECT ON " + ItDatabase.ORACLE.getSourceSchema() + "." + TABLE.toUpperCase( + Locale.ENGLISH) + " TO SJIT_READER"); + } + try (Connection reader = DriverManager.getConnection(url, "SJIT_READER", "Sjit_reader123")) { + OracleFeatures features = new OracleFeatures(reader); + assertTrue(features.features.contains(DatabaseFeature.VECTOR)); + assertFalse(features.features.contains(DatabaseFeature.HNSW_INDEX), "v$parameter is not granted"); + // The table of ADMIN read by the user: the index is found, its distance is the default one + MetaDataOperations operations = DbType.ORACLE.createMetaDataOperations(); + VectorIndex index = operations.getIndexInfos(reader.getMetaData(), null, ItDatabase.ORACLE + .getSourceSchema(), TABLE.toUpperCase(Locale.ENGLISH)).stream().map(VectorIndex::of) + .filter(java.util.Objects::nonNull).findFirst().orElse(null); + assertNotNull(index); + assertEquals(VectorIndex.Distance.COSINE, index.getDistance()); + + // A target of the user: IVF index of the HNSW index of pgvector + createPgvectorSource(); + ImportExporter importExporter = new ImportExporter(); + configureSource(ItDatabase.POSTGRESQL, importExporter.getExportConfiguration()); + importExporter.getImportConfiguration().setDbType(DbType.ORACLE); + importExporter.getImportConfiguration().setUrl(url); + importExporter.getImportConfiguration().setUsername("SJIT_READER"); + importExporter.getImportConfiguration().setPassword("Sjit_reader123"); + importExporter.getImportConfiguration().setTargetSchemaName("SJIT_READER"); + CollectingListener listener = new CollectingListener(); + importExporter.setExportListener(listener); + importExporter.exportDdlAndData(); + assertEquals(List.of(), listener.getErrors()); + try (Statement statement = reader.createStatement(); ResultSet rs = statement.executeQuery( + "SELECT INDEX_SUBTYPE FROM USER_INDEXES WHERE TABLE_NAME = 'SJV_DOC' AND INDEX_TYPE = 'VECTOR'" + + " ORDER BY INDEX_NAME")) { + List kinds = new ArrayList<>(); + while (rs.next()) { + kinds.add(rs.getString(1)); + } + assertFalse(kinds.isEmpty()); + assertTrue(kinds.stream().allMatch(kind -> kind.contains("IVF")), kinds.toString()); + } + try (Statement statement = reader.createStatement()) { + statement.execute("DROP TABLE SJIT_READER.SJV_DOC PURGE"); + } + } + } + + private static final class OracleFeatures { + final Set features; + + OracleFeatures(Connection connection) { + features = DbType.ORACLE.createDialect(23, 0).readDatabaseFeatures(connection); + } + } + + /** + * Users with few privileges detect the features of their databases alike: the probes are queries of functions + * and catalogs which every user may read + */ + @Test + void featuresOfUsersWithFewPrivileges() throws Exception { + Map readers = new LinkedHashMap<>(); + readers.put("MySQL", new Object[]{ItDatabase.MYSQL, "sjit_reader", "sjit_reader123"}); + readers.put("PostgreSQL", new Object[]{ItDatabase.POSTGRESQL, "sjit_reader", "sjit_reader123"}); + readers.put("SQL Server", new Object[]{ItDatabase.SQLSERVER, "sjit_reader", "Ro#Vector2026x"}); + for (Map.Entry entry : readers.entrySet()) { + ItDatabase database = (ItDatabase) entry.getValue()[0]; + if (!database.isAvailable()) { + continue; + } + try (Connection connection = DriverManager.getConnection(database.getSourceUrl(), + (String) entry.getValue()[1], (String) entry.getValue()[2])) { + Set features = database.getDbType().createDialect().readDatabaseFeatures(connection); + assertTrue(features.contains(DatabaseFeature.VECTOR), entry.getKey() + ": " + features); + } + } + } + + /** + * Rows which the target refuses are skipped and logged with "skip errors", the other rows are written: MariaDB + * refuses the largest float, PostgreSQL an empty array of ClickHouse + */ + @Test + void rowsRefusedByTheTarget() throws Exception { + assumeTrue(ItDatabase.MARIADB.isAvailable() && ItDatabase.POSTGRESQL.isAvailable()); + try (Connection connection = ItDatabase.POSTGRESQL.getSourceConnection(); + Statement statement = connection.createStatement()) { + String table = ItDatabase.POSTGRESQL.getSourceSchema() + "." + TABLE; + statement.execute("DROP TABLE IF EXISTS " + table); + statement.execute("CREATE TABLE " + table + " (id int PRIMARY KEY, small vector(3) NOT NULL)"); + statement.execute("INSERT INTO " + table + " VALUES (1, '[1,2,3]'), (2, '[1,-0.5,3.4028235e38]')," + + " (3, '[0,0,0]')"); + } + CollectingListener listener = copy(ItDatabase.POSTGRESQL, ItDatabase.MARIADB, true); + assertEquals(1, listener.getErrors().size(), listener.getErrors().toString()); + assertTrue(listener.getErrors().get(0).contains("vector value"), listener.getErrors().get(0)); + try (Connection connection = ItDatabase.MARIADB.getTargetConnection(); + Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SELECT id FROM " + TABLE + " ORDER BY id")) { + List ids = new ArrayList<>(); + while (rs.next()) { + ids.add(rs.getInt(1)); + } + assertEquals(List.of(1, 3), ids); + } + + assumeTrue(ItDatabase.CLICKHOUSE.isAvailable()); + try (Connection connection = ItDatabase.CLICKHOUSE.getSourceConnection(); + Statement statement = connection.createStatement()) { + statement.execute("DROP TABLE IF EXISTS " + TABLE); + statement.execute("CREATE TABLE " + TABLE + " (id UInt32, small Array(Float32)) ENGINE = MergeTree" + + " ORDER BY id"); + statement.execute("INSERT INTO " + TABLE + " VALUES (1, [1,2,3]), (2, []), (3, [0,0,1])"); + } + listener = copy(ItDatabase.CLICKHOUSE, ItDatabase.POSTGRESQL, true); + assertEquals(1, listener.getErrors().size(), listener.getErrors().toString()); + try (Connection connection = ItDatabase.POSTGRESQL.getTargetConnection(); + Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SELECT id FROM " + ItDatabase.POSTGRESQL.getTargetSchema() + "." + + TABLE + " ORDER BY id")) { + List ids = new ArrayList<>(); + while (rs.next()) { + ids.add(rs.getInt(1)); + } + assertEquals(List.of(1, 3), ids); + } + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + static void configureSource(ItDatabase source, Exporter.ExportConfiguration configuration) { + source.configureSource(configuration); + configuration.setIncludedTableNamePattern(PATTERN); + } + + static CollectingListener copy(ItDatabase source, ItDatabase target, boolean skipErrors) throws Exception { + ImportExporter importExporter = new ImportExporter(); + configureSource(source, importExporter.getExportConfiguration()); + target.configureTarget(importExporter.getImportConfiguration()); + importExporter.getImportConfiguration().setSkipErrors(skipErrors); + CollectingListener listener = new CollectingListener(); + importExporter.setExportListener(listener); + try { + importExporter.exportDdlAndData(); + } catch (Exception e) { + if (!skipErrors) { + throw new AssertionError(e.getMessage() + "\nErrors: " + listener.getErrors(), e); + } + } + return listener; + } + + /** + * Columns of the target table by their names in lower case, read by the metadata of SqlJam: the vector types + * of the target are its detected types + */ + static Map> columns(ItDatabase target, Connection connection, String table) + throws SQLException { + Map> columns = new LinkedHashMap<>(); + MetaDataOperations operations = target.getDbType().createMetaDataOperations(); + for (Map column : operations.getColumnInfos(connection.getMetaData(), + catalog(target, connection), schema(target), tableName(target, table))) { + columns.put(((String) column.get("COLUMN_NAME")).toLowerCase(Locale.ENGLISH), column); + } + assertFalse(columns.isEmpty(), "Columns of " + table + " in " + target); + return columns; + } + + /** + * Vector indexes of the target table by their columns in lower case + */ + static Map vectorIndexes(ItDatabase target, Connection connection, String table) + throws SQLException { + Map indexes = new LinkedHashMap<>(); + MetaDataOperations operations = target.getDbType().createMetaDataOperations(); + for (Map index : operations.getIndexInfos(connection.getMetaData(), catalog(target, + connection), schema(target), tableName(target, table))) { + VectorIndex vectorIndex = VectorIndex.of(index); + if (vectorIndex != null) { + indexes.put(((String) index.get("COLUMN_NAME")).toLowerCase(Locale.ENGLISH), vectorIndex); + } + } + return indexes; + } + + /** + * Catalog of the target table: the database of the connection for PostgreSQL, which connects to one database + */ + private static String catalog(ItDatabase target, Connection connection) throws SQLException { + return target.getTargetCatalog() != null && target.getDbType() != DbType.POSTGRESQL + ? target.getTargetCatalog() : connection.getCatalog(); + } + + private static String schema(ItDatabase target) { + return target.getTargetSchema(); + } + + private static String tableName(ItDatabase target, String table) { + DbType dbType = target.getDbType(); + return dbType == DbType.ORACLE || dbType == DbType.DB2 || dbType == DbType.H2 ? table.toUpperCase( + Locale.ENGLISH) : table; + } + + /** + * Every element of every vector of the target equals the source, vectors kept as text included + */ + static void assertRows(ItDatabase target, Connection connection, String table, + Map> columns) throws SQLException { + Dialect dialect = target.getDbType().createDialect(); + // Vectors are read as text, like by an export into another database + dialect.setReadTargetDbType(DbType.POSTGRESQL); + String[] names = {"id", "title", "embedding", "embedding_large", "small", "tags"}; + String select = java.util.Arrays.stream(names).map(name -> dialect.getSelectColumnExpression( + (String) columns.get(name).get("COLUMN_NAME"), (String) columns.get(name).get("TYPE_NAME"))) + .collect(Collectors.joining(", ")); + String qualified = dialect.getSourceTableName(catalog(target, connection), schema(target), + tableName(target, table)); + List expected = rows(); + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery( + "SELECT " + select + " FROM " + qualified + " ORDER BY 1")) { + int count = 0; + while (rs.next()) { + Object[] row = expected.get(count++); + String label = target + " row " + row[0]; + assertEquals(((Number) row[0]).longValue(), ((Number) rs.getObject(1)).longValue(), label); + assertEquals(row[1], rs.getString(2), label); + for (int i = 2; i < names.length; i++) { + Object value = dialect.getColumnValue(rs, i + 1, rs.getMetaData().getColumnType(i + 1), + rs.getMetaData().getColumnTypeName(i + 1)); + String text = value != null ? value.toString() : null; + if (row[i] == null) { + // An empty array of ClickHouse is not NULL, text of a nullable vector is + assertNull(text, label + " " + names[i]); + } else if ("embedding_large".equals(names[i])) { + // 16-bit floats of halfvec: 11 bits of precision, subnormal below 6.1e-5 + assertVectorEquals((String) row[i], text, label + " " + names[i], HALF_TOLERANCE, 1e-7); + } else { + assertVectorEquals((String) row[i], text, label + " " + names[i]); + } + } + } + assertEquals(ROWS, count, target + " rows"); + } + } + + static void assertVectorEquals(String expected, String actual, String label) { + assertVectorEquals(expected, actual, label, TOLERANCE, 1e-12); + } + + /** + * Elements are equal by the relative difference, or by the absolute difference of tiny elements + */ + static void assertVectorEquals(String expected, String actual, String label, double relative, double absolute) { + assertNotNull(actual, label); + VectorType.Values expectedValues = VectorType.parseValues(expected); + VectorType.Values actualValues = VectorType.parseValues(actual); + assertNotNull(actualValues, label + ": " + actual); + float[] expectedFloats = expectedValues.toFloats(); + float[] actualFloats = actualValues.toFloats(); + assertEquals(expectedFloats.length, actualFloats.length, label + " dimensions"); + for (int i = 0; i < expectedFloats.length; i++) { + double difference = Math.abs(expectedFloats[i] - actualFloats[i]); + assertTrue(difference <= relative * Math.abs(expectedFloats[i]) || difference <= absolute, + label + " element " + i + ": " + expectedFloats[i] + " != " + actualFloats[i]); + } + } + + /** + * Ids of the 3 nearest neighbors of the embedding of row 1 by the cosine distance, searched by the database + */ + static List nearest(ItDatabase database, Connection connection) throws SQLException { + boolean source = connection == null; + DbType dbType = database.getDbType(); + String schema = source ? database.getSourceSchema() : database.getTargetSchema(); + String table = dbType == DbType.MYSQL || dbType == DbType.MARIADB || schema == null ? tableName(database, TABLE) + : schema + "." + tableName(database, TABLE); + String query = "(SELECT embedding FROM " + table + " WHERE id = 1)"; + String sql; + switch (dbType) { + case POSTGRESQL: + sql = "SELECT id FROM " + table + " ORDER BY embedding <=> " + query + " LIMIT 3"; + break; + case MARIADB: + sql = "SELECT id FROM " + table + " ORDER BY VEC_DISTANCE_COSINE(embedding, " + query + ") LIMIT 3"; + break; + case ORACLE: + sql = "SELECT id FROM " + table + " ORDER BY VECTOR_DISTANCE(embedding, " + query + ", COSINE)" + + " FETCH FIRST 3 ROWS ONLY"; + break; + case SQLSERVER: + sql = "SELECT TOP 3 id FROM " + table + " ORDER BY VECTOR_DISTANCE('cosine', embedding, " + query + + ")"; + break; + case DB2: + sql = "SELECT id FROM " + table + " ORDER BY VECTOR_DISTANCE(embedding, " + query + ", COSINE)" + + " FETCH FIRST 3 ROWS ONLY"; + break; + case DUCKDB: + sql = "SELECT id FROM " + table + " ORDER BY array_cosine_distance(embedding, " + query + ") LIMIT 3"; + break; + case CLICKHOUSE: + sql = "SELECT id FROM " + table + " ORDER BY cosineDistance(embedding, " + query + ") LIMIT 3"; + break; + default: + throw new IllegalArgumentException(database.name()); + } + Connection opened = source ? database.getSourceConnection() : null; + // Operators of pgvector are found by the search path, e.g. of a connection with currentSchema + dbType.createDialect().prepareSession(source ? opened : connection); + try (Statement statement = (source ? opened : connection).createStatement(); + ResultSet rs = statement.executeQuery(sql)) { + List ids = new ArrayList<>(); + while (rs.next()) { + ids.add(((Number) rs.getObject(1)).intValue()); + } + return ids; + } finally { + if (opened != null) { + opened.close(); + } + } + } + + /** + * The 3 nearest neighbors of the embedding of row 1 by the cosine distance, computed of the embeddings + */ + static List nearestOfEmbeddings() { + float[] first = VectorType.parseValues(EMBEDDINGS.get(0)[2]).toFloats(); + return EMBEDDINGS.stream().sorted(java.util.Comparator.comparingDouble(embedding -> cosineDistance(first, + VectorType.parseValues(embedding[2]).toFloats()))).limit(3).map(embedding -> Integer.parseInt( + embedding[0])).collect(Collectors.toList()); + } + + private static double cosineDistance(float[] a, float[] b) { + double dot = 0; + double normA = 0; + double normB = 0; + for (int i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + return 1 - dot / (Math.sqrt(normA) * Math.sqrt(normB)); + } + + private static List indexDefinitions(String schema) throws SQLException { + try (Connection connection = ItDatabase.POSTGRESQL.getTargetConnection()) { + return indexDefinitions(connection, schema); + } + } + + /** + * Access methods, columns and options of the indexes of the table in PostgreSQL, the primary key excluded + */ + private static List indexDefinitions(Connection connection, String schema) throws SQLException { + List definitions = new ArrayList<>(); + try (PreparedStatement ps = connection.prepareStatement("SELECT indexdef FROM pg_indexes WHERE schemaname = ?" + + " AND tablename = ? AND indexname NOT LIKE '%pkey' ORDER BY indexdef")) { + ps.setString(1, schema); + ps.setString(2, TABLE); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + // Operator classes outside of the search path are qualified, e.g. public.vector_cosine_ops + definitions.add(rs.getString(1).replaceAll("^.* USING ", "").replace("public.", "")); + } + } + } + return definitions; + } +} diff --git a/src/test/java/com/github/sqljam/jdbc/JdbcUtilsTest.java b/src/test/java/com/github/sqljam/jdbc/JdbcUtilsTest.java index 032ec16..60fa219 100644 --- a/src/test/java/com/github/sqljam/jdbc/JdbcUtilsTest.java +++ b/src/test/java/com/github/sqljam/jdbc/JdbcUtilsTest.java @@ -43,8 +43,8 @@ import org.junit.jupiter.api.Test; import com.github.sqljam.impexp.TransactionIsolationLevel; 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.MapBasedPageReader; import com.github.sqljam.page.PageRequest; import com.github.sqljam.page.PageResponse; diff --git a/src/test/java/com/github/sqljam/utils/EmbeddedSshServer.java b/src/test/java/com/github/sqljam/utils/EmbeddedSshServer.java new file mode 100644 index 0000000..e9c8df3 --- /dev/null +++ b/src/test/java/com/github/sqljam/utils/EmbeddedSshServer.java @@ -0,0 +1,146 @@ +/* + * 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.utils; + +import java.io.Closeable; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyEncryptionContext; +import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter; +import org.apache.sshd.common.session.Session; +import org.apache.sshd.common.session.SessionListener; +import org.apache.sshd.server.SshServer; +import org.apache.sshd.server.forward.AcceptAllForwardingFilter; +import org.apache.sshd.server.forward.RejectAllForwardingFilter; +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider; + +/** + * @Description: EmbeddedSshServer is an SSH server of the tests: a user with a password or with a key, and local + * port forwarding to any host, so that tunnels are tested without an SSH server on the network + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class EmbeddedSshServer implements Closeable { + + public static final String USER = "sjtunnel"; + public static final String PASSWORD = "sj-secret"; + + private final SshServer server; + private KeyPair userKey; + private final AtomicInteger sessions = new AtomicInteger(); + + /** + * @param hostKeyFile host key of the server, generated if it does not exist + * @param port port of the server, 0 for a free one + */ + public EmbeddedSshServer(Path hostKeyFile, int port) throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(256); + userKey = generator.generateKeyPair(); + server = SshServer.setUpDefaultServer(); + server.setHost("127.0.0.1"); + server.setPort(port); + SimpleGeneratorHostKeyProvider hostKeys = new SimpleGeneratorHostKeyProvider(hostKeyFile); + hostKeys.setAlgorithm(KeyUtils.EC_ALGORITHM); + server.setKeyPairProvider(hostKeys); + server.setPasswordAuthenticator((username, password, session) -> USER.equals(username) && PASSWORD.equals( + password)); + server.setPublickeyAuthenticator((username, key, session) -> USER.equals(username) && KeyUtils + .compareKeys(key, userKey.getPublic())); + server.setForwardingFilter(AcceptAllForwardingFilter.INSTANCE); + server.addSessionListener(new SessionListener() { + @Override + public void sessionCreated(Session session) { + sessions.incrementAndGet(); + } + }); + server.start(); + } + + /** + * Key of the user, e.g. ed25519 or RSA instead of the generated ECDSA key + */ + public void setUserKey(KeyPair userKey) { + this.userKey = userKey; + } + + /** + * Refuses port forwarding, like AllowTcpForwarding no of OpenSSH + */ + public void refuseForwarding() { + server.setForwardingFilter(RejectAllForwardingFilter.INSTANCE); + } + + public int getPort() { + return server.getPort(); + } + + /** + * Sessions which were opened to the server + */ + public int getSessions() { + return sessions.get(); + } + + /** + * Closes the sessions of the clients, as a broken network would + */ + public void dropSessions() { + server.getActiveSessions().forEach(session -> session.close(true)); + } + + /** + * Writes the key of the user in the OpenSSH format, encrypted by the passphrase if it is given + */ + public Path writeUserKey(Path file, String passphrase) throws Exception { + OpenSSHKeyPairResourceWriter writer = new OpenSSHKeyPairResourceWriter(); + OpenSSHKeyEncryptionContext context = null; + if (passphrase != null) { + context = new OpenSSHKeyEncryptionContext(); + context.setPassword(passphrase); + context.setCipherName("AES"); + context.setCipherMode("CTR"); + context.setCipherType("256"); + } + try (OutputStream out = Files.newOutputStream(file)) { + writer.writePrivateKey(userKey, "sqljam test", context, out); + } + return file; + } + + public SshSettings passwordSettings() { + SshSettings settings = new SshSettings(); + settings.setEnabled(true); + settings.setHost("127.0.0.1"); + settings.setPort(getPort()); + settings.setUsername(USER); + settings.setPassword(PASSWORD); + return settings; + } + + @Override + public void close() throws IOException { + server.stop(true); + } +} diff --git a/src/test/java/com/github/sqljam/utils/SshUtilsTest.java b/src/test/java/com/github/sqljam/utils/SshUtilsTest.java new file mode 100644 index 0000000..31942d9 --- /dev/null +++ b/src/test/java/com/github/sqljam/utils/SshUtilsTest.java @@ -0,0 +1,393 @@ +/* + * 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.utils; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.io.UncheckedIOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.keyprovider.KeyPairProvider; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.github.sqljam.face.model.ConnectionProfile; +import com.github.sqljam.impexp.DbType; + +/** + * @Description: SshUtilsTest opens tunnels through an embedded SSH server to a local echo server: by a password, + * by a key with or without a passphrase, shared and opened again on the same local port, refused for + * a wrong password, a missing key or a changed host key + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class SshUtilsTest { + + static ServerSocket echo; + static Path knownHosts; + + @TempDir + Path dir; + + private EmbeddedSshServer server; + + /** + * Echoes each line of its clients, the target of the tunnels + */ + @BeforeAll + static void startEcho(@TempDir Path hostsDir) throws IOException { + knownHosts = hostsDir.resolve("known_hosts"); + SshUtils.setKnownHostsFile(knownHosts); + echo = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + Thread thread = new Thread(() -> { + while (!echo.isClosed()) { + try { + Socket socket = echo.accept(); + new Thread(() -> { + try (socket; BufferedReader in = new BufferedReader(new InputStreamReader(socket + .getInputStream(), StandardCharsets.UTF_8)); PrintWriter out = new PrintWriter(socket + .getOutputStream(), true, StandardCharsets.UTF_8)) { + String line; + while ((line = in.readLine()) != null) { + out.println("echo " + line); + } + } catch (IOException e) { + // The client went away + } + }).start(); + } catch (IOException e) { + return; + } + } + }); + thread.setDaemon(true); + thread.start(); + } + + @AfterAll + static void stopEcho() throws IOException { + echo.close(); + SshUtils.closeAll(); + } + + @AfterEach + void stopServer() throws IOException { + SshUtils.closeAll(); + if (server != null) { + server.close(); + } + } + + private EmbeddedSshServer start() throws Exception { + server = new EmbeddedSshServer(dir.resolve("host.key"), 0); + return server; + } + + static String send(int port, String text) throws IOException { + try (Socket socket = new Socket(SshUtils.LOCALHOST_ADDRESS, port); PrintWriter out = new PrintWriter(socket + .getOutputStream(), true, StandardCharsets.UTF_8); BufferedReader in = new BufferedReader( + new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) { + socket.setSoTimeout(10000); + out.println(text); + return in.readLine(); + } + } + + @Test + void forwardsByPassword() throws Exception { + start(); + SshSettings settings = server.passwordSettings(); + int port = SshUtils.open("a", settings, "127.0.0.1", echo.getLocalPort()); + assertEquals("echo hello", send(port, "hello")); + assertTrue(SshUtils.isOpen("a")); + assertEquals(port, SshUtils.getLocalPort("a")); + // Shared by the connections of the data source + assertEquals(port, SshUtils.open("a", settings, "127.0.0.1", echo.getLocalPort())); + assertEquals(1, server.getSessions()); + // The host key is remembered + assertTrue(Files.readString(knownHosts).contains("127.0.0.1")); + + // A broken session is opened again on its local port + server.dropSessions(); + long deadline = System.currentTimeMillis() + 5000; + while (SshUtils.isOpen("a") && System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + assertFalse(SshUtils.isOpen("a")); + assertEquals(-1, SshUtils.getLocalPort("a")); + assertEquals(port, SshUtils.open("a", settings, "127.0.0.1", echo.getLocalPort())); + assertEquals("echo again", send(port, "again")); + + // Other settings open another tunnel, closed ones are gone + SshSettings other = settings.copy(); + other.setPort(settings.getPort()); + int second = SshUtils.open("b", other, "127.0.0.1", echo.getLocalPort()); + assertNotEquals(port, second); + SshUtils.close("a"); + SshUtils.close("missing"); + assertFalse(SshUtils.isOpen("a")); + assertTrue(SshUtils.isOpen("b")); + assertThrows(IOException.class, () -> send(port, "closed")); + } + + @Test + void forwardsByKey() throws Exception { + start(); + SshSettings settings = server.passwordSettings(); + settings.setPassword(null); + settings.setAuthentication(SshSettings.Authentication.KEY); + settings.setPrivateKeyFile(server.writeUserKey(dir.resolve("id_plain"), null).toString()); + int port = SshUtils.open("key", settings, "127.0.0.1", echo.getLocalPort()); + assertEquals("echo key", send(port, "key")); + + settings.setPrivateKeyFile(server.writeUserKey(dir.resolve("id_secret"), "phrase").toString()); + settings.setPassphrase("phrase"); + int encrypted = SshUtils.open("encrypted", settings, "127.0.0.1", echo.getLocalPort()); + assertEquals("echo phrase", send(encrypted, "phrase")); + + // A wrong passphrase, a missing key + settings.setPassphrase("wrong"); + assertThrows(UncheckedIOException.class, () -> SshUtils.open("wrong", settings, "127.0.0.1", echo + .getLocalPort())); + settings.setPrivateKeyFile(dir.resolve("missing").toString()); + UncheckedIOException missing = assertThrows(UncheckedIOException.class, () -> SshUtils.open("missing", + settings, "127.0.0.1", echo.getLocalPort())); + assertTrue(missing.getMessage().contains("private key file not found"), missing.getMessage()); + settings.setPrivateKeyFile("~/sqljam-missing-key"); + assertThrows(UncheckedIOException.class, () -> SshUtils.open("home", settings, "127.0.0.1", echo + .getLocalPort())); + settings.setPrivateKeyFile(Files.writeString(dir.resolve("not_a_key"), "not a key").toString()); + assertThrows(UncheckedIOException.class, () -> SshUtils.open("invalid", settings, "127.0.0.1", echo + .getLocalPort())); + } + + /** + * Keys which other machines have most: ed25519 and RSA, an ed25519 key with a passphrase too + */ + @Test + void forwardsByKeysOfOtherTypes() throws Exception { + start(); + SshSettings settings = server.passwordSettings(); + settings.setPassword(null); + settings.setAuthentication(SshSettings.Authentication.KEY); + server.setUserKey(KeyUtils.generateKeyPair(KeyPairProvider.SSH_ED25519, 256)); + settings.setPrivateKeyFile(server.writeUserKey(dir.resolve("id_ed25519"), null).toString()); + assertEquals("echo ed25519", send(SshUtils.open("ed25519", settings, "127.0.0.1", echo.getLocalPort()), + "ed25519")); + settings.setPrivateKeyFile(server.writeUserKey(dir.resolve("id_ed25519_secret"), "phrase").toString()); + settings.setPassphrase("phrase"); + assertEquals("echo secret", send(SshUtils.open("ed25519 secret", settings, "127.0.0.1", echo + .getLocalPort()), "secret")); + server.setUserKey(KeyUtils.generateKeyPair(KeyPairProvider.SSH_RSA, 2048)); + settings.setPassphrase(null); + settings.setPrivateKeyFile(server.writeUserKey(dir.resolve("id_rsa"), null).toString()); + assertEquals("echo rsa", send(SshUtils.open("rsa", settings, "127.0.0.1", echo.getLocalPort()), "rsa")); + // A key which the server does not know + server.setUserKey(KeyUtils.generateKeyPair(KeyPairProvider.SSH_ED25519, 256)); + assertThrows(UncheckedIOException.class, () -> SshUtils.open("unknown", settings, "127.0.0.1", echo + .getLocalPort())); + } + + /** + * Servers and networks of other machines: no forwarding allowed, an unknown host, a target which is down and a + * known hosts file in a directory which does not exist yet + */ + @Test + void reportsEnvironmentsOfOtherMachines(@TempDir Path other) throws Exception { + start(); + // A directory which does not exist yet keeps the host keys + Path nested = other.resolve("missing/dir/known_hosts"); + SshUtils.setKnownHostsFile(nested); + try { + int port = SshUtils.open("nested", server.passwordSettings(), "127.0.0.1", echo.getLocalPort()); + assertEquals("echo nested", send(port, "nested")); + assertTrue(Files.readString(nested).contains("127.0.0.1"), "The host key is remembered"); + } finally { + SshUtils.setKnownHostsFile(knownHosts); + } + + // A target which the server does not reach, e.g. a database which is down, is told when the tunnel opens + int closedPort; + try (ServerSocket socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + closedPort = socket.getLocalPort(); + } + UncheckedIOException down = assertThrows(UncheckedIOException.class, () -> SshUtils.open("down", server + .passwordSettings(), "127.0.0.1", closedPort)); + assertTrue(down.getMessage().contains("does not reach 127.0.0.1:" + closedPort), down.getMessage()); + assertFalse(SshUtils.isOpen("down")); + + // An unknown host of the SSH server + SshSettings unknown = server.passwordSettings(); + unknown.setHost("sqljam-no-such-host.invalid"); + UncheckedIOException unresolved = assertThrows(UncheckedIOException.class, () -> SshUtils.open( + "unknown", unknown, "127.0.0.1", echo.getLocalPort())); + assertTrue(unresolved.getMessage().contains("sqljam-no-such-host.invalid"), unresolved.getMessage()); + + // A server which allows no forwarding + server.refuseForwarding(); + SshUtils.closeAll(); + UncheckedIOException refused = assertThrows(UncheckedIOException.class, () -> SshUtils.open("refused", + server.passwordSettings(), "127.0.0.1", echo.getLocalPort())); + assertTrue(refused.getMessage().startsWith("SSH tunnel through") && refused.getMessage().contains( + "may allow no forwarding"), refused.getMessage()); + } + + @Test + void refusesWrongUsersAndHosts() throws Exception { + start(); + SshSettings settings = server.passwordSettings(); + settings.setPassword("wrong"); + UncheckedIOException wrong = assertThrows(UncheckedIOException.class, () -> SshUtils.open("w", settings, + "127.0.0.1", echo.getLocalPort())); + assertTrue(wrong.getMessage().startsWith("SSH tunnel through " + EmbeddedSshServer.USER + "@127.0.0.1:"), + wrong.getMessage()); + settings.setPassword(null); + assertThrows(UncheckedIOException.class, () -> SshUtils.open("w", settings, "127.0.0.1", echo + .getLocalPort())); + settings.setHost(" "); + assertTrue(assertThrows(UncheckedIOException.class, () -> SshUtils.open("w", settings, "127.0.0.1", echo + .getLocalPort())).getMessage().contains("no SSH host")); + // Nobody listens + SshSettings closed = server.passwordSettings(); + int port = server.getPort(); + server.close(); + server = null; + closed.setPort(port); + assertThrows(UncheckedIOException.class, () -> SshUtils.open("c", closed, "127.0.0.1", echo + .getLocalPort())); + assertFalse(SshUtils.isOpen("w")); + } + + @Test + void refusesChangedHostKey(@TempDir Path other) throws Exception { + start(); + int port = server.getPort(); + SshSettings settings = server.passwordSettings(); + assertEquals("echo first", send(SshUtils.open("h", settings, "127.0.0.1", echo.getLocalPort()), "first")); + SshUtils.closeAll(); + server.close(); + // Another server with another host key on the same address + server = new EmbeddedSshServer(other.resolve("other.key"), port); + UncheckedIOException changed = assertThrows(UncheckedIOException.class, () -> SshUtils.open("h", + settings, "127.0.0.1", echo.getLocalPort())); + assertTrue(changed.getMessage().startsWith("SSH tunnel through"), changed.getMessage()); + } + + /** + * The url of a profile through its tunnel: of its fields, of a custom url and never of a database file + */ + @Test + void tunnelsUrlsOfProfiles() throws Exception { + start(); + ConnectionProfile profile = new ConnectionProfile(); + profile.setDbType(DbType.MYSQL); + profile.setHostname("127.0.0.1"); + profile.setDatabase("shop"); + profile.setSsh(server.passwordSettings()); + // The default port of the type, worked out without a database listening on it + assertArrayEquals(new String[]{"127.0.0.1", "3306"}, profile.tunnelTarget(false)); + profile.setPort(echo.getLocalPort()); + String url = profile.getJdbcUrl(); + int port = SshUtils.getLocalPort(profile.getId()); + assertTrue(url.startsWith("jdbc:mysql://127.0.0.1:" + port + "/shop?"), url); + assertEquals("echo url", send(port, "url")); + + // A custom url, e.g. Oracle, with its host and port + ConnectionProfile oracle = new ConnectionProfile(); + oracle.setDbType(DbType.ORACLE); + oracle.setUrl("jdbc:oracle:thin:@//127.0.0.1:" + echo.getLocalPort() + "/DEMO"); + oracle.setSsh(server.passwordSettings()); + String oracleUrl = oracle.getJdbcUrl(); + assertEquals("jdbc:oracle:thin:@//127.0.0.1:" + SshUtils.getLocalPort(oracle.getId()) + "/DEMO", + oracleUrl); + ConnectionProfile ipv6 = new ConnectionProfile(); + ipv6.setDbType(DbType.POSTGRESQL); + ipv6.setUrl("jdbc:postgresql://[::1]:" + echo.getLocalPort() + "/app"); + ipv6.setSsh(server.passwordSettings()); + assertArrayEquals(new String[]{"::1", String.valueOf(echo.getLocalPort())}, ipv6.tunnelTarget(true)); + // The host in brackets is the target, the echo server listens on IPv4 only, so it is not reached + UncheckedIOException ipv6Error = assertThrows(UncheckedIOException.class, ipv6::getJdbcUrl); + assertTrue(ipv6Error.getMessage().contains("does not reach ::1:" + echo.getLocalPort()), ipv6Error + .getMessage()); + + // Without a host, with the tunnel disabled, of a database file + ConnectionProfile hostless = new ConnectionProfile(); + hostless.setName("hostless"); + hostless.setDbType(DbType.MYSQL); + hostless.setSsh(server.passwordSettings()); + assertTrue(assertThrows(IllegalStateException.class, hostless::getJdbcUrl).getMessage().contains( + "hostless")); + hostless.getSsh().setEnabled(false); + hostless.setHostname("db"); + assertFalse(hostless.isSshEnabled()); + assertTrue(hostless.getJdbcUrl().startsWith("jdbc:mysql://db:")); + ConnectionProfile file = new ConnectionProfile(); + file.setDbType(DbType.SQLITE); + file.setDatabase(dir.resolve("a.db").toString()); + file.setSsh(server.passwordSettings()); + assertFalse(file.isSshEnabled()); + assertFalse(file.getJdbcUrl().contains("127.0.0.1")); + assertFalse(SshUtils.isOpen(file.getId())); + } + + @Test + void describesSettings() { + SshSettings settings = new SshSettings(); + assertEquals(22, settings.getEffectivePort()); + settings.setPort(0); + assertEquals(22, settings.getEffectivePort()); + settings.setPassword("pw"); + assertEquals("pw", settings.getSecret()); + settings.setAuthentication(SshSettings.Authentication.KEY); + settings.setSecret("phrase"); + assertEquals("phrase", settings.getPassphrase()); + assertEquals("phrase", settings.getSecret()); + settings.setAuthentication(SshSettings.Authentication.PASSWORD); + settings.setSecret("new"); + assertEquals("new", settings.getPassword()); + String key = settings.getKey(); + settings.setPassword("other"); + assertNotEquals(key, settings.getKey()); + assertFalse(settings.toString().contains("other")); + assertFalse(settings.copy().toString().contains("phrase")); + assertEquals(settings.getKey(), settings.copy().getKey()); + assertEquals(knownHosts, SshUtils.getKnownHostsFile()); + // The file of sqljam.ssh.known-hosts.file by default + SshUtils.setKnownHostsFile(null); + assertTrue(SshUtils.getKnownHostsFile().endsWith("ssh_known_hosts"), SshUtils.getKnownHostsFile() + .toString()); + SshUtils.setKnownHostsFile(knownHosts); + assertTrue(SshUtils.describe(settings, new IOException()).endsWith("IOException")); + } +} diff --git a/src/test/java/com/github/sqljam/vdb/VdbCoreTest.java b/src/test/java/com/github/sqljam/vdb/VdbCoreTest.java new file mode 100644 index 0000000..a926d9b --- /dev/null +++ b/src/test/java/com/github/sqljam/vdb/VdbCoreTest.java @@ -0,0 +1,188 @@ +/* + * 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.vdb; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.JsonNode; +import com.github.sqljam.utils.RestClient; + +/** + * @Description: VdbCoreTest tests the parts of the vdb line without servers: conditions of text, the columns of + * payloads without a schema, the text of vectors, collections and sources + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class VdbCoreTest { + + static JsonNode json(String text) throws Exception { + return RestClient.getMapper().readTree(text); + } + + @Test + void parsesConditions() { + VdbQuery query = VdbQuery.all().where("n >= 6 AND title = 'rock and roll'\nlang IN ('en', 'zh')\n\n" + + "score < 1.5 and active != true and x <> \"y\" and k > -3 and v <= 2 and w = abc"); + List conditions = query.getConditions(); + assertEquals(9, conditions.size()); + assertEquals(VdbQuery.Operator.GE, conditions.get(0).getOperator()); + assertEquals(6L, conditions.get(0).getValue()); + assertEquals("rock and roll", conditions.get(1).getValue()); + assertEquals(VdbQuery.Operator.IN, conditions.get(2).getOperator()); + assertEquals(List.of("en", "zh"), conditions.get(2).getValue()); + assertEquals(1.5, conditions.get(3).getValue()); + assertEquals(VdbQuery.Operator.LT, conditions.get(3).getOperator()); + assertEquals(Boolean.TRUE, conditions.get(4).getValue()); + assertEquals(VdbQuery.Operator.NE, conditions.get(5).getOperator()); + assertEquals("y", conditions.get(5).getValue()); + assertEquals(VdbQuery.Operator.GT, conditions.get(6).getOperator()); + assertEquals(-3L, conditions.get(6).getValue()); + assertEquals(VdbQuery.Operator.LE, conditions.get(7).getOperator()); + assertEquals(VdbQuery.Operator.EQ, conditions.get(8).getOperator()); + assertEquals("abc", conditions.get(8).getValue()); + assertTrue(query.isFiltered()); + assertFalse(query.isNative()); + assertFalse(query.isNearest()); + assertThrows(IllegalArgumentException.class, () -> VdbQuery.all().where("n ~ 3")); + assertFalse(VdbQuery.all().where(null).isFiltered()); + assertEquals("it''s".replace("''", "'"), VdbQuery.parseValue("'it''s'")); + + VdbQuery nearest = new VdbQuery(); + nearest.setNearestField("embedding"); + assertFalse(nearest.isNearest()); + nearest.setNearestId(1); + assertTrue(nearest.isNearest()); + nearest.setNativeFilter(" "); + assertFalse(nearest.isNative()); + } + + @Test + void findsColumnsOfPayloads() throws Exception { + PayloadSchema schema = new PayloadSchema(); + assertTrue(schema.isEmpty()); + schema.observe(json("{\"a\": 1, \"b\": \"x\", \"c\": true, \"d\": 1.5, \"e\": {\"k\": 1}, \"f\": null," + + " \"g\": 1, \"h\": 1, \"i\": \"" + "y".repeat(5000) + "\"}")); + schema.observe(json("{\"a\": 10000000000, \"b\": \"" + "z".repeat(300) + "\", \"c\": 1, \"d\": 2," + + " \"e\": 3, \"g\": 2.5, \"h\": \"text\", \"new\": 1}")); + schema.observe(json("[1]")); + schema.observe(null); + VdbCollection collection = new VdbCollection("c"); + collection.addField(new VdbField("a", VdbField.Kind.TEXT)); + Map fields = new java.util.LinkedHashMap<>(); + schema.getFields(collection).forEach(field -> fields.put(field.getSourceName(), field)); + assertEquals("a_2", fields.get("a").getName()); + assertEquals(VdbField.Kind.BIGINT, fields.get("a").getKind()); + assertEquals(VdbField.Kind.VARCHAR, fields.get("b").getKind()); + assertEquals(300, fields.get("b").getSize()); + assertFalse(fields.get("b").isNullable()); + assertEquals(VdbField.Kind.TEXT, fields.get("c").getKind()); + assertEquals(VdbField.Kind.DOUBLE, fields.get("d").getKind()); + assertEquals(VdbField.Kind.JSON, fields.get("e").getKind()); + assertEquals(VdbField.Kind.TEXT, fields.get("f").getKind()); + assertTrue(fields.get("f").isNullable()); + assertEquals(VdbField.Kind.DOUBLE, fields.get("g").getKind()); + assertEquals(VdbField.Kind.TEXT, fields.get("h").getKind()); + assertEquals(VdbField.Kind.TEXT, fields.get("i").getKind()); + assertTrue(fields.get("i").isNullable()); + assertTrue(fields.get("new").isNullable()); + assertFalse(schema.isEmpty()); + } + + @Test + void writesVectors() throws Exception { + assertEquals("[1.0,-2.5]", VdbValues.toDenseText(new float[]{1, -2.5f})); + assertEquals("[1.0,2.0]", VdbValues.toDenseText(json("[1, 2]"))); + assertNull(VdbValues.toDenseText(json("[]"))); + assertNull(VdbValues.toDenseText(json("[\"a\"]"))); + assertNull(VdbValues.toDenseText((JsonNode) null)); + assertEquals("[0,255]", VdbValues.toByteText(new byte[]{0, (byte) 255})); + Map elements = VdbValues.getSparseElements(json("[3, 1]"), json("[0.5, 2]")); + assertEquals("{2:2.0,4:0.5}/10", VdbValues.toSparseText(elements, 10)); + assertEquals(elements, VdbValues.getSparseElements(json("{\"3\": 0.5, \"1\": 2}"))); + assertTrue(VdbValues.getSparseElements(null, null).isEmpty()); + assertTrue(VdbValues.getSparseElements(json("[]")).isEmpty()); + // 1.0 and -2.0 of 16-bit floats and of bfloat16 + assertArrayEquals(new float[]{1, -2}, VdbValues.decodeHalves("ADwAwA==", false)); + assertArrayEquals(new float[]{1, -2}, VdbValues.decodeHalves("gD8AwA==", true)); + assertNull(VdbValues.toJson(null)); + assertNull(VdbValues.toJson(json("null"))); + assertEquals("{\"a\":[1]}", VdbValues.toJson(Map.of("a", List.of(1)))); + assertThrows(VdbException.class, () -> VdbValues.toJson(new Object())); + } + + @Test + void describesCollections() { + VdbCollection collection = new VdbCollection("c"); + VdbField id = VdbField.varchar("id", 36); + id.setPrimaryKey(true); + collection.addField(id).addField(VdbField.vector("v", VectorType.dense(VectorType.Element.FLOAT32, 3))); + assertEquals(List.of(id), collection.getPrimaryKeyFields()); + assertEquals(1, collection.getVectorFields().size()); + assertEquals("V_2", collection.getUnusedName("V")); + assertEquals("w", collection.getUnusedName("w")); + assertTrue(collection.findField("id").isPresent()); + assertEquals("id", id.getSourceName()); + id.setSourceName("_id"); + assertEquals("_id", id.getSourceName()); + assertTrue(id.toString().contains("id")); + + VdbSource source = new VdbSource(VdbType.QDRANT, "http://h:6333"); + source.setApiKey("secret"); + source.setPassword("secret"); + assertFalse(source.toString().contains("secret")); + assertEquals("https://h:9200", VdbType.OPENSEARCH.getUrl("h", 0)); + assertEquals("http://h:1", VdbType.CHROMA.getUrl("h", 1)); + for (VdbType type : VdbType.values()) { + assertTrue(type.getDefaultPort() > 0); + assertTrue(type.getDisplayName().length() > 3); + assertEquals(type == VdbType.OPENSEARCH, type.isSecureByDefault()); + } + VdbException e = new VdbException("m", new IllegalStateException()); + assertEquals("m", e.getMessage()); + } + + @Test + void parsesVectorTypes() { + VectorType type = VectorType.parse("FLOAT16,8,SPARSE"); + assertEquals(VectorType.Element.FLOAT16, type.getElement()); + assertTrue(type.isSparse()); + assertEquals(type, VectorType.parse(type.toString())); + assertNull(VectorType.parse("bad")); + assertNull(VectorType.parse(null)); + VectorType.Values values = VectorType.parseValues("{1:0.5,3:2}/4"); + assertTrue(values.isSparse()); + assertEquals(4, values.getDimensions()); + assertEquals("[4,[0,2],[0.5,2]]", values.toOracleSparseText()); + assertArrayEquals(new float[]{0.5f, 0, 2, 0}, values.toFloats()); + VectorType.Values dense = VectorType.parseValues("[1, 2.5]"); + assertEquals("[1,2.5]", dense.toDenseText()); + assertNull(VectorType.parseValues("x")); + VectorIndex index = VectorIndex.parse(new VectorIndex(VectorIndex.Kind.HNSW, VectorIndex.Distance.DOT, + Map.of("m", "8")).toString()); + assertEquals(VectorIndex.Distance.DOT, index.getDistance()); + assertEquals("8", index.getOption("m")); + } +} diff --git a/src/test/java/com/github/sqljam/vdb/client/FakeServer.java b/src/test/java/com/github/sqljam/vdb/client/FakeServer.java new file mode 100644 index 0000000..271e73a --- /dev/null +++ b/src/test/java/com/github/sqljam/vdb/client/FakeServer.java @@ -0,0 +1,119 @@ +/* + * 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.vdb.client; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import com.sun.net.httpserver.HttpServer; + +/** + * @Description: FakeServer answers requests of a vector database client with JSON of its routes: the route of the + * longest prefix of "METHOD /path?query" answers, by a fixed text or by a function of the request body. + * Requests are recorded with their headers. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +final class FakeServer implements AutoCloseable { + + /** + * A request: method, path with the query, body and the authorization header + */ + static final class Request { + final String target; + final String body; + final String authorization; + + Request(String target, String body, String authorization) { + this.target = target; + this.body = body; + this.authorization = authorization; + } + } + + private final HttpServer server; + private final Map> routes = new LinkedHashMap<>(); + private final Map statuses = new LinkedHashMap<>(); + final List requests = new ArrayList<>(); + + FakeServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + String target = exchange.getRequestMethod() + " " + exchange.getRequestURI().toString(); + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + synchronized (requests) { + requests.add(new Request(target, body, exchange.getRequestHeaders().getFirst("Authorization"))); + } + String route = null; + for (String prefix : routes.keySet()) { + if (target.startsWith(prefix) && (route == null || prefix.length() > route.length())) { + route = prefix; + } + } + byte[] response = (route != null ? routes.get(route).apply(body) : "{\"error\": \"no route\"}") + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(route != null ? statuses.getOrDefault(route, 200) : 404, + response.length == 0 ? -1 : response.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(response); + } + }); + server.start(); + } + + FakeServer route(String prefix, String response) { + return route(prefix, body -> response); + } + + FakeServer route(String prefix, int status, String response) { + statuses.put(prefix, status); + return route(prefix, body -> response); + } + + FakeServer route(String prefix, Function response) { + routes.put(prefix, response); + return this; + } + + String getUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + /** + * Requests whose method and path start with the prefix + */ + List requests(String prefix) { + List matched = new ArrayList<>(); + synchronized (requests) { + requests.stream().filter(request -> request.target.startsWith(prefix)).forEach(matched::add); + } + return matched; + } + + @Override + public void close() { + server.stop(0); + } +} diff --git a/src/test/java/com/github/sqljam/vdb/client/VdbClientsTest.java b/src/test/java/com/github/sqljam/vdb/client/VdbClientsTest.java new file mode 100644 index 0000000..689ee49 --- /dev/null +++ b/src/test/java/com/github/sqljam/vdb/client/VdbClientsTest.java @@ -0,0 +1,669 @@ +/* + * 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.vdb.client; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.JsonNode; +import com.github.sqljam.utils.RestClient; +import com.github.sqljam.utils.RestException; +import com.github.sqljam.vdb.VdbClient; +import com.github.sqljam.vdb.VdbCollection; +import com.github.sqljam.vdb.VdbException; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VdbSource; +import com.github.sqljam.vdb.VdbType; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: VdbClientsTest tests the clients of the vector databases with a fake server and their mappings + * without one: types of fields, vectors of other elements (16-bit floats, int8, bits, multivectors), + * indexes and distances, filters of conditions, tenants and namespaces, errors of the servers + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class VdbClientsTest { + + static JsonNode json(String text) throws Exception { + return RestClient.getMapper().readTree(text); + } + + static VdbSource source(VdbType type, FakeServer server) { + VdbSource source = new VdbSource(type, server.getUrl()); + source.setApiKey("key"); + source.setTimeoutSeconds(5); + return source; + } + + static List> readAll(VdbClient client, VdbCollection collection, VdbQuery query, + int pageSize) { + List> rows = new ArrayList<>(); + client.read(collection, query, pageSize, (records, last) -> { + rows.addAll(records); + return true; + }); + return rows; + } + + static VdbQuery allOperators() { + return VdbQuery.all().where("a = 1 and b != 'x' and c > 1.5 and d >= 2 and e < 3 and f <= 4 and g IN (1, 2)"); + } + + // ------------------------------------------------------------------ + // Milvus + // ------------------------------------------------------------------ + + @Test + void mapsMilvusFields() throws Exception { + String[] types = {"Bool", "Int8", "Int16", "Int32", "Int64", "Float", "Double", "String", "Text", + "Timestamptz", "JSON", "Geometry", "Struct", "Int8Vector", "BinaryVector", "BFloat16Vector", + "Function"}; + VdbField.Kind[] kinds = {VdbField.Kind.BOOLEAN, VdbField.Kind.SMALLINT, VdbField.Kind.SMALLINT, + VdbField.Kind.INTEGER, VdbField.Kind.BIGINT, VdbField.Kind.REAL, VdbField.Kind.DOUBLE, + VdbField.Kind.TEXT, VdbField.Kind.TEXT, VdbField.Kind.TIMESTAMP, VdbField.Kind.JSON, + VdbField.Kind.JSON, VdbField.Kind.JSON, VdbField.Kind.VECTOR, VdbField.Kind.VECTOR, + VdbField.Kind.VECTOR, VdbField.Kind.TEXT}; + for (int i = 0; i < types.length; i++) { + VdbField field = MilvusClient.toField(json("{\"name\": \"f\", \"type\": \"" + types[i] + + "\", \"params\": [{\"key\": \"dim\", \"value\": \"16\"}], \"description\": \"d\"," + + " \"nullable\": true}")); + assertEquals(kinds[i], field.getKind(), types[i]); + assertTrue(field.isNullable()); + assertEquals("d", field.getRemarks()); + } + VdbField text = MilvusClient.toField(json("{\"name\": \"t\", \"type\": \"VarChar\", \"params\": [{\"key\":" + + " \"max_length\", \"value\": \"65535\"}], \"primaryKey\": true}")); + assertEquals(VdbField.Kind.TEXT, text.getKind()); + assertTrue(text.isPrimaryKey()); + assertFalse(text.isNullable()); + + VectorIndex hnsw = MilvusClient.toVectorIndex(json("{\"indexType\": \"DISKANN\", \"metricType\": \"IP\"," + + " \"indexParams\": [{\"key\": \"params\", \"value\": \"{\\\"M\\\": 8, \\\"efConstruction\\\": 32}\"}," + + " {\"key\": \"params\", \"value\": \"not json\"}]}")); + assertEquals(VectorIndex.Kind.HNSW, hnsw.getKind()); + assertEquals(VectorIndex.Distance.DOT, hnsw.getDistance()); + assertEquals("8", hnsw.getOption("m")); + VectorIndex ivf = MilvusClient.toVectorIndex(json("{\"indexType\": \"SCANN\", \"metricType\": \"HAMMING\"}")); + assertEquals(VectorIndex.Kind.IVF, ivf.getKind()); + assertEquals(VectorIndex.Distance.HAMMING, ivf.getDistance()); + assertEquals(VectorIndex.Distance.JACCARD, MilvusClient.toDistance("jaccard")); + assertNull(MilvusClient.toVectorIndex(json("{\"indexType\": \"SPARSE_INVERTED_INDEX\", \"metricType\":" + + " \"IP\"}"))); + assertNull(MilvusClient.toVectorIndex(json("{\"indexType\": \"HNSW\", \"metricType\": \"BM25\"}"))); + } + + static VdbField milvusVector(String type) throws Exception { + return MilvusClient.toField(json("{\"name\": \"v\", \"type\": \"" + type + "\", \"params\": [{\"key\":" + + " \"dim\", \"value\": \"2\"}]}")); + } + + @Test + void readsMilvusValues() throws Exception { + // 1.0 and -2.0 as 16-bit floats, bfloat16, int8 and the bits of a byte + assertEquals("[1.0,-2.0]", MilvusClient.toVectorText(json("\"ADwAwA==\""), milvusVector("Float16Vector"))); + assertEquals("[1.0,-2.0]", MilvusClient.toVectorText(json("\"gD8AwA==\""), milvusVector("BFloat16Vector"))); + assertEquals("[1.0,-2.0]", MilvusClient.toVectorText(json("[1, -2]"), milvusVector("Float16Vector"))); + assertEquals("[1.0,-2.0]", MilvusClient.toVectorText(json("[1, -2]"), milvusVector("BFloat16Vector"))); + assertEquals("[1.0,-2.0]", MilvusClient.toVectorText(json("\"Af4=\""), milvusVector("Int8Vector"))); + assertEquals("[1.0,-2.0]", MilvusClient.toVectorText(json("[1, -2]"), milvusVector("Int8Vector"))); + assertEquals("[170]", MilvusClient.toVectorText(json("\"qg==\""), milvusVector("BinaryVector"))); + assertEquals("[1]", MilvusClient.toVectorText(json("[1]"), milvusVector("BinaryVector"))); + assertEquals("{1:0.5,5:1.0}/5", MilvusClient.toVectorText(json("{\"0\": 0.5, \"4\": 1}"), + milvusVector("SparseFloatVector"))); + assertNull(MilvusClient.toVectorText(json("null"), milvusVector("FloatVector"))); + assertNull(MilvusClient.toVectorText(null, milvusVector("FloatVector"))); + + VdbField json = MilvusClient.toField(json("{\"name\": \"j\", \"type\": \"JSON\"}")); + assertEquals(json("{\"a\": 1}"), MilvusClient.normalize(json("\"{\\\"a\\\": 1}\""), json)); + assertEquals(json("\"{bad\""), MilvusClient.normalize(json("\"{bad\""), json)); + VdbField array = MilvusClient.toField(json("{\"name\": \"a\", \"type\": \"Array\"}")); + assertEquals(json("[1, 2]"), MilvusClient.normalize(json("{\"Data\": {\"LongData\": {\"data\": [1, 2]}}}"), + array)); + assertEquals(json("[]"), MilvusClient.normalize(json("{\"Data\": {\"LongData\": {}}}"), array)); + assertNull(MilvusClient.normalize(null, array)); + + VdbCollection collection = new VdbCollection("c"); + collection.addField(new VdbField("a", VdbField.Kind.BIGINT)); + assertEquals("a == 1 and b != \"x\" and c > 1.5 and d >= 2 and e < 3 and f <= 4 and g in [1, 2]", + MilvusClient.toFilter(collection, allOperators())); + assertEquals("\"a\\\\\\\"b\"", MilvusClient.literal("a\\\"b")); + VdbQuery query = new VdbQuery(); + query.setNativeFilter(" id > 3 "); + assertEquals("id > 3", MilvusClient.toFilter(collection, query)); + } + + @Test + void readsMilvusOverRest() throws Exception { + try (FakeServer server = new FakeServer()) { + server.route("GET /v2/vectordb/server/version", 404, "{}") + .route("POST /v2/vectordb/collections/list", "{\"code\": 0, \"data\": [\"docs\", \"bad\"]}") + .route("POST /v2/vectordb/collections/describe", body -> body.contains("\"bad\"") + ? "{\"code\": 0, \"data\": {\"fields\": [{\"name\": \"x\", \"type\": \"Int64\"}]}}" + : "{\"code\": 0, \"data\": {\"description\": \"Docs\", \"enableDynamicField\": true," + + " \"fields\": [{\"name\": \"pk\", \"type\": \"VarChar\", \"primaryKey\": true," + + " \"params\": [{\"key\": \"max_length\", \"value\": \"64\"}]}," + + " {\"name\": \"v\", \"type\": \"FloatVector\", \"params\": [{\"key\": \"dim\"," + + " \"value\": \"2\"}]}]}}") + .route("POST /v2/vectordb/indexes/list", "{\"code\": 0, \"data\": [\"v_idx\"]}") + .route("POST /v2/vectordb/indexes/describe", "{\"code\": 0, \"data\": [{\"fieldName\": \"v\"," + + " \"indexType\": \"HNSW\", \"metricType\": \"L2\"}]}") + .route("POST /v2/vectordb/entities/query", body -> body.contains("count(*)") + ? "{\"code\": 0, \"data\": [{\"count(*)\": 3}]}" + : body.contains("pk > \\\"b\\\"") ? "{\"code\": 0, \"data\": [{\"pk\": \"c\", \"v\": [5, 6]}]}" + : "{\"code\": 0, \"data\": [{\"pk\": \"b\", \"v\": [3, 4], \"dyn\": 1, \"distance\": 0}," + + " {\"pk\": \"a\", \"v\": [1, 2]}]}") + .route("POST /v2/vectordb/entities/get", body -> body.contains("missing") + ? "{\"code\": 0, \"data\": []}" : "{\"code\": 0, \"data\": [{\"pk\": \"a\", \"v\": [1, 2]}]}") + .route("POST /v2/vectordb/entities/search", "{\"code\": 0, \"data\": [{\"pk\": \"a\"," + + " \"v\": [1, 2], \"distance\": 0}]}") + .route("POST /v2/vectordb/collections/fail", "{\"code\": 1800, \"message\": \"denied\"}"); + VdbSource source = source(VdbType.MILVUS, server); + source.setApiKey(null); + source.setUsername("u"); + source.setPassword("p"); + source.setDatabase("db1"); + try (VdbClient client = VdbType.MILVUS.createClient(source)) { + assertEquals("", client.getVersion()); + assertEquals(List.of("docs", "bad"), client.getCollections()); + assertEquals("Bearer u:p", server.requests("POST /v2/vectordb/collections/list").get(0) + .authorization); + assertTrue(server.requests("POST /v2/vectordb/collections/list").get(0).body.contains("db1")); + VdbException e = assertThrows(VdbException.class, () -> client.describe("bad")); + assertTrue(e.getMessage().contains("no primary key")); + VdbCollection collection = client.describe("docs"); + // A server without the route of partitions reads the collection whole + assertEquals(List.of(), client.getPartitions(collection)); + assertEquals(3, collection.getCount()); + assertEquals("Docs", collection.getRemarks()); + assertEquals(VectorIndex.Distance.EUCLIDEAN, collection.findField("v").get().getVectorIndex() + .getDistance()); + // Pages of text keys follow the last key in quotes, rows are sorted by the key + List> rows = readAll(client, collection, VdbQuery.all(), 2); + assertEquals(List.of("a", "b", "c"), List.of(rows.get(0).get("pk"), rows.get(1).get("pk"), + rows.get(2).get("pk"))); + assertEquals("{\"dyn\":1}", rows.get(1).get("payload")); + VdbQuery nearest = new VdbQuery(); + nearest.setNearestField("v"); + nearest.setNearestId("a"); + nearest.setLimit(1); + assertEquals(1, client.count(collection, nearest)); + assertEquals(1, readAll(client, collection, nearest, 10).size()); + nearest.setNearestId("missing"); + assertThrows(VdbException.class, () -> readAll(client, collection, nearest, 10)); + nearest.setNearestId(null); + nearest.setNearestVector(new float[]{1, 2}); + nearest.where("pk = 'a'"); + assertEquals(1, readAll(client, collection, nearest, 10).size()); + } + source.setUsername(null); + source.setApiKey("token"); + try (MilvusClient client = new MilvusClient(source)) { + VdbException e = assertThrows(VdbException.class, () -> client.describe("fail".replace("fail", + "bad"))); + assertTrue(e.getMessage().contains("Milvus")); + } + } + } + + // ------------------------------------------------------------------ + // Weaviate + // ------------------------------------------------------------------ + + @Test + void mapsWeaviateFields() throws Exception { + String[] types = {"text", "string", "int", "number", "boolean", "date", "uuid", "blob", "text[]", + "object", "geoCoordinates", "phoneNumber", "Other"}; + VdbField.Kind[] kinds = {VdbField.Kind.TEXT, VdbField.Kind.TEXT, VdbField.Kind.BIGINT, VdbField.Kind.DOUBLE, + VdbField.Kind.BOOLEAN, VdbField.Kind.TIMESTAMP, VdbField.Kind.VARCHAR, VdbField.Kind.BINARY, + VdbField.Kind.JSON, VdbField.Kind.JSON, VdbField.Kind.JSON, VdbField.Kind.JSON, VdbField.Kind.JSON}; + for (int i = 0; i < types.length; i++) { + assertEquals(kinds[i], WeaviateClient.toField("f", json("{\"dataType\": [\"" + types[i] + "\"]}")) + .getKind(), types[i]); + } + assertEquals("o { a b { c } }", WeaviateClient.selectionOf(json("{\"name\": \"o\", \"dataType\":" + + " [\"object\"], \"nestedProperties\": [{\"name\": \"a\", \"dataType\": [\"text\"]}, {\"name\": \"b\"," + + " \"dataType\": [\"object\"], \"nestedProperties\": [{\"name\": \"c\", \"dataType\": [\"int\"]}]}]}"))); + assertEquals("g { latitude longitude }", WeaviateClient.selectionOf(json("{\"name\": \"g\", \"dataType\":" + + " [\"geoCoordinates\"]}"))); + assertTrue(WeaviateClient.selectionOf(json("{\"name\": \"p\", \"dataType\": [\"phoneNumber\"]}")) + .startsWith("p { input")); + assertEquals("r { ... on Other { _additional { id } } }", WeaviateClient.selectionOf(json("{\"name\": \"r\"," + + " \"dataType\": [\"Other\"]}"))); + assertEquals("e", WeaviateClient.selectionOf(json("{\"name\": \"e\", \"dataType\": []}"))); + + VdbField multivector = WeaviateClient.toVectorField("m", "hnsw", json("{\"multivector\": {\"enabled\":" + + " true}}"), 4); + assertEquals(VdbField.Kind.JSON, multivector.getKind()); + assertNull(WeaviateClient.toVectorField("f", "flat", json("{\"distance\": \"cosine\"}"), 4).getVectorIndex()); + VectorIndex dynamic = WeaviateClient.toVectorField("d", "dynamic", json("{\"distance\": \"dot\", \"hnsw\":" + + " {\"maxConnections\": 8, \"efConstruction\": 32}}"), 4).getVectorIndex(); + assertEquals(VectorIndex.Distance.DOT, dynamic.getDistance()); + assertEquals("8", dynamic.getOption("m")); + assertEquals(VectorIndex.Distance.MANHATTAN, WeaviateClient.toDistance("manhattan")); + assertEquals(VectorIndex.Distance.HAMMING, WeaviateClient.toDistance("hamming")); + assertEquals(VectorIndex.Distance.EUCLIDEAN, WeaviateClient.toDistance("l2-squared")); + assertEquals(VectorIndex.Distance.COSINE, WeaviateClient.toDistance(null)); + assertNull(WeaviateClient.toDistance("other")); + + VdbCollection collection = new VdbCollection("C"); + assertEquals("{ operator: And, operands: [{ path: [\"a\"], operator: Equal, valueInt: 1 }, { path: [\"b\"]," + + " operator: NotEqual, valueText: \"x\" }, { path: [\"c\"], operator: GreaterThan, valueNumber: 1.5 }," + + " { path: [\"d\"], operator: GreaterThanEqual, valueInt: 2 }, { path: [\"e\"], operator: LessThan," + + " valueInt: 3 }, { path: [\"f\"], operator: LessThanEqual, valueInt: 4 }, { path: [\"g\"], operator:" + + " ContainsAny, valueIntArray: [1, 2] }] }", WeaviateClient.toWhere(collection, allOperators())); + assertEquals("{ path: [\"t\"], operator: ContainsAny, valueTextArray: [\"a\", \"b\\\"\"] }", + WeaviateClient.toWhere(collection, VdbQuery.all().where("t IN ('a', 'b\"')"))); + assertEquals("{ path: [\"t\"], operator: Equal, valueBoolean: true }", WeaviateClient.toWhere(collection, + VdbQuery.all().where("t = true"))); + assertTrue(WeaviateClient.toWhere(collection, VdbQuery.all().where("t IN (1.5)")).contains( + "valueNumberArray: [1.5]")); + assertTrue(WeaviateClient.toWhere(collection, VdbQuery.all().where("t IN (true)")).contains( + "valueBooleanArray: [true]")); + assertNull(WeaviateClient.toWhere(collection, VdbQuery.all())); + } + + @Test + void readsWeaviateTenants() throws Exception { + try (FakeServer server = new FakeServer()) { + server.route("GET /v1/meta", "{\"version\": \"1.24.0\"}") + .route("GET /v1/schema", "{\"classes\": [{\"class\": \"Doc\"}]}") + .route("GET /v1/schema/Doc", "{\"class\": \"Doc\", \"description\": \"D\",\"multiTenancyConfig\":" + + " {\"enabled\": true}, \"vectorIndexType\": \"hnsw\", \"vectorIndexConfig\": {\"distance\":" + + " \"cosine\"}, \"properties\": [{\"name\": \"n\", \"dataType\": [\"int\"]}," + + " {\"name\": \"id\", \"dataType\": [\"text\"]}]}") + .route("GET /v1/schema/Doc/tenants", "[{\"name\": \"t1\"}, {\"name\": \"t2\"}]") + .route("GET /v1/objects?class=Doc&limit=1&include=vector&tenant=t1", "{\"objects\": []}") + .route("GET /v1/objects?class=Doc&limit=1&include=vector&tenant=t2", "{\"objects\": [{\"id\":" + + " \"u1\", \"vector\": [1, 2]}]}") + .route("GET /v1/objects?class=Doc&limit=2&include=vector&tenant=t1", "{\"objects\": [{\"id\":" + + " \"u1\", \"vector\": [1, 2], \"properties\": {\"n\": 1, \"id\": \"x\"}}, {\"id\": \"u2\"," + + " \"vector\": [3, 4], \"properties\": {\"n\": 2}}]}") + .route("GET /v1/objects?class=Doc&limit=2&include=vector&tenant=t1&after=u2", "{\"objects\": []}") + .route("GET /v1/objects?class=Doc&limit=2&include=vector&tenant=t2", "{\"objects\": [{\"id\":" + + " \"u3\", \"vector\": [5, 6], \"properties\": {\"n\": 3}}]}") + .route("POST /v1/graphql", body -> body.contains("Aggregate") ? "{\"data\": {\"Aggregate\":" + + " {\"Doc\": [{\"meta\": {\"count\": 2}}]}}}" : body.contains("bad") + ? "{\"errors\": [{\"message\": \"bad where\"}]}" : body.contains("offset: 1") + ? "{\"data\": {\"Get\": {\"Doc\": []}}}" : "{\"data\": {\"Get\": {\"Doc\": [{\"n\": 1, \"_additional\": {\"id\": \"u1\"," + + " \"vector\": [1, 2]}}]}}}"); + try (VdbClient client = VdbType.WEAVIATE.createClient(source(VdbType.WEAVIATE, server))) { + assertEquals("1.24.0", client.getVersion()); + assertEquals(List.of("Doc"), client.getCollections()); + VdbCollection collection = client.describe("Doc"); + assertEquals("Bearer key", server.requests("GET /v1/meta").get(0).authorization); + assertEquals(4, collection.getCount()); + assertEquals(List.of("tenant", "id"), List.of(collection.getPrimaryKeyFields().get(0).getName(), + collection.getPrimaryKeyFields().get(1).getName())); + assertEquals(2, collection.findField("vector").get().getVectorType().getDimensions()); + // The property id is a column of its own + assertEquals("id", collection.findField("id_2").get().getSourceName()); + List> rows = readAll(client, collection, VdbQuery.all(), 2); + assertEquals(3, rows.size()); + assertEquals("t2", rows.get(2).get("tenant")); + assertEquals("x", rows.get(0).get("id_2")); + VdbQuery limited = VdbQuery.all(); + limited.setLimit(1); + assertEquals(1, readAll(client, collection, limited, 2).size()); + VdbQuery filtered = VdbQuery.all().where("n = 1"); + assertEquals(2, readAll(client, collection, filtered, 1).size()); + VdbQuery nearest = new VdbQuery(); + nearest.setNearestField("vector"); + nearest.setNearestId("u1"); + nearest.setLimit(5); + assertEquals(5, client.count(collection, nearest)); + assertEquals(2, readAll(client, collection, nearest, 10).size()); + assertTrue(server.requests("POST /v1/graphql").stream().anyMatch(request -> request.body.contains( + "nearObject"))); + VdbQuery bad = new VdbQuery(); + bad.setNativeFilter("bad"); + VdbException e = assertThrows(VdbException.class, () -> readAll(client, collection, bad, 10)); + assertTrue(e.getMessage().contains("bad where")); + } + } + } + + // ------------------------------------------------------------------ + // Pinecone + // ------------------------------------------------------------------ + + @Test + void matchesPineconeMetadata() throws Exception { + VdbCollection collection = new VdbCollection("i"); + JsonNode metadata = json("{\"a\": 1, \"b\": \"y\", \"c\": 2, \"d\": 2, \"e\": 2, \"f\": 4, \"g\": 2," + + " \"t\": [\"x\", \"z\"], \"flag\": true}"); + assertTrue(PineconeClient.matches(collection, metadata, allOperators().getConditions())); + assertTrue(PineconeClient.matches(collection, metadata, VdbQuery.all().where("t = 'x' and t != 'q' and" + + " flag = true and missing != 1").getConditions())); + assertFalse(PineconeClient.matches(collection, metadata, VdbQuery.all().where("missing = 1") + .getConditions())); + assertFalse(PineconeClient.matches(collection, metadata, VdbQuery.all().where("a = 'text'") + .getConditions())); + assertTrue(PineconeClient.matches(collection, metadata, VdbQuery.all().where("a != 'text'") + .getConditions())); + assertFalse(PineconeClient.matches(collection, metadata, VdbQuery.all().where("g IN (5, 6)") + .getConditions())); + assertTrue(PineconeClient.matches(json("1"), VdbQuery.Operator.IN, 1L)); + assertEquals(VectorIndex.Distance.EUCLIDEAN, PineconeClient.toDistance("euclidean")); + assertEquals(VectorIndex.Distance.DOT, PineconeClient.toDistance("dotproduct")); + assertNull(PineconeClient.toDistance("other")); + assertTrue(PineconeClient.isDefaultNamespace("__default__")); + assertEquals(json("{\"$and\": [{\"a\": {\"$eq\": 1}}, {\"g\": {\"$in\": [1, 2]}}]}").toString(), + AbstractVdbClient.toOperatorFilter(collection, VdbQuery.all().where("a = 1 and g IN (1, 2)"), "P") + .toString()); + VdbQuery invalid = new VdbQuery(); + invalid.setNativeFilter("{bad"); + assertThrows(VdbException.class, () -> AbstractVdbClient.toOperatorFilter(collection, invalid, "P")); + + PineconeClient client = new PineconeClient(new VdbSource(VdbType.PINECONE, "https://api.pinecone.io")); + assertEquals("https://idx.svc.pinecone.io", client.toHost("idx.svc.pinecone.io")); + assertEquals("http://h.example", client.toHost("http://h.example")); + client = new PineconeClient(new VdbSource(VdbType.PINECONE, "http://10.0.0.1:5080")); + assertEquals("http://10.0.0.1:5081", client.toHost("localhost:5081")); + assertEquals("http://10.0.0.1", client.toHost("127.0.0.1")); + } + + @Test + void readsPineconeSparseIndex() throws Exception { + try (FakeServer server = new FakeServer()) { + String host = server.getUrl().replace("127.0.0.1", "localhost").substring("http://".length()); + server.route("GET /indexes/sparse", "{\"name\": \"sparse\", \"metric\": \"euclidean\", \"vector_type\":" + + " \"sparse\", \"host\": \"" + host + "\"}") + .route("GET /indexes", "{\"indexes\": [{\"name\": \"sparse\"}]}") + .route("POST /describe_index_stats", "{\"namespaces\": {}}") + .route("GET /vectors/list", "{\"vectors\": [{\"id\": \"a\"}, {\"id\": \"b\"}]}") + .route("GET /vectors/fetch", "{\"vectors\": {\"a\": {\"id\": \"a\", \"sparseValues\": {\"indices\":" + + " [3], \"values\": [0.5]}, \"metadata\": {\"n\": 1}}}}") + .route("POST /query", "{\"matches\": [{\"id\": \"b\", \"score\": 2}, {\"id\": \"a\", \"score\":" + + " 1, \"sparseValues\": {\"indices\": [1], \"values\": [1]}}]}"); + try (VdbClient client = VdbType.PINECONE.createClient(source(VdbType.PINECONE, server))) { + assertEquals("2025-01", client.getVersion()); + assertEquals(List.of("sparse"), client.getCollections()); + VdbCollection collection = client.describe("sparse"); + assertFalse(collection.findField("values").isPresent()); + assertFalse(collection.findField("namespace").isPresent()); + VdbField sparse = collection.findField("sparse_values").get(); + assertEquals(4, sparse.getVectorType().getDimensions()); + // A vector deleted between the list and the fetch is not read + assertEquals(1, collection.getCount()); + List> rows = readAll(client, collection, VdbQuery.all(), 10); + assertEquals("{4:0.5}/4", rows.get(0).get("sparse_values")); + assertEquals(-1, client.count(collection, VdbQuery.all().where("n = 1"))); + VdbQuery nearest = new VdbQuery(); + nearest.setNearestField("sparse_values"); + nearest.setNearestId("a"); + nearest.where("n = 1"); + assertEquals(10000, client.count(collection, nearest)); + rows = readAll(client, collection, nearest, 10); + // Smaller distances of euclidean are nearer + assertEquals(List.of("a", "b"), List.of(rows.get(0).get("id"), rows.get(1).get("id"))); + assertTrue(server.requests("POST /query").get(0).body.contains("\"filter\"")); + } + } + } + + // ------------------------------------------------------------------ + // Elasticsearch and OpenSearch + // ------------------------------------------------------------------ + + @Test + void mapsElasticsearchFields() throws Exception { + ElasticsearchClient client = new ElasticsearchClient(new VdbSource(VdbType.ELASTICSEARCH, "http://h")); + String[] types = {"boolean", "byte", "short", "integer", "long", "unsigned_long", "float", "half_float", + "double", "scaled_float", "keyword", "wildcard", "ip", "date", "binary", "geo_shape"}; + VdbField.Kind[] kinds = {VdbField.Kind.BOOLEAN, VdbField.Kind.SMALLINT, VdbField.Kind.SMALLINT, + VdbField.Kind.INTEGER, VdbField.Kind.BIGINT, VdbField.Kind.BIGINT, VdbField.Kind.REAL, + VdbField.Kind.REAL, VdbField.Kind.DOUBLE, VdbField.Kind.DOUBLE, VdbField.Kind.TEXT, VdbField.Kind.TEXT, + VdbField.Kind.TEXT, VdbField.Kind.TEXT, VdbField.Kind.BINARY, VdbField.Kind.JSON}; + for (int i = 0; i < types.length; i++) { + assertEquals(kinds[i], client.toField("f", json("{\"type\": \"" + types[i] + "\"}")).getKind(), + types[i]); + } + assertEquals(VdbField.Kind.JSON, client.toField("o", json("{\"properties\": {}}")).getKind()); + VdbField bits = client.toField("b", json("{\"type\": \"dense_vector\", \"dims\": 16, \"element_type\":" + + " \"bit\", \"similarity\": \"max_inner_product\", \"index_options\": {\"type\": \"flat\"}}")); + assertEquals(VectorType.Element.BINARY, bits.getVectorType().getElement()); + assertNull(bits.getVectorIndex()); + VdbField bytes = client.toField("b", json("{\"type\": \"dense_vector\", \"dims\": 4, \"element_type\":" + + " \"byte\", \"similarity\": \"dot_product\", \"index_options\": {\"type\": \"int8_hnsw\"}}")); + assertEquals(VectorType.Element.INT8, bytes.getVectorType().getElement()); + assertEquals(VectorIndex.Distance.DOT, bytes.getVectorIndex().getDistance()); + assertNull(client.toField("n", json("{\"type\": \"dense_vector\", \"dims\": 4, \"index\": false," + + " \"similarity\": \"cosine\"}")).getVectorIndex()); + assertNull(ElasticsearchClient.toDistance("other")); + assertArrayEquals(new byte[]{(byte) 0xAB, 1}, ElasticsearchClient.hexToBytes("ab01")); + VdbCollection collection = new VdbCollection("i"); + assertEquals(json("{\"bool\": {\"filter\": [{\"term\": {\"a\": 1}}, {\"range\": {\"c\": {\"gt\": 1.5}}}," + + " {\"range\": {\"d\": {\"gte\": 2}}}, {\"range\": {\"e\": {\"lt\": 3}}}, {\"range\": {\"f\":" + + " {\"lte\": 4}}}, {\"terms\": {\"g\": [1, 2]}}], \"must_not\": [{\"term\": {\"b\": \"x\"}}]}}").toString(), + client.toQuery(collection, allOperators()).toString()); + VdbQuery invalid = new VdbQuery(); + invalid.setNativeFilter("{bad"); + assertThrows(VdbException.class, () -> client.toQuery(collection, invalid)); + + OpenSearchClient openSearch = new OpenSearchClient(new VdbSource(VdbType.OPENSEARCH, "https://h")); + VdbField ivf = openSearch.toField("v", json("{\"type\": \"knn_vector\", \"dimension\": 8, \"data_type\":" + + " \"binary\", \"method\": {\"name\": \"ivf\", \"space_type\": \"hamming\", \"parameters\":" + + " {\"nlist\": 4}}}")); + assertEquals(VectorType.Element.BINARY, ivf.getVectorType().getElement()); + assertEquals(VectorIndex.Kind.IVF, ivf.getVectorIndex().getKind()); + assertEquals("4", ivf.getVectorIndex().getOption("lists")); + VdbField linf = openSearch.toField("v", json("{\"type\": \"knn_vector\", \"dimension\": 8, \"data_type\":" + + " \"byte\", \"space_type\": \"linf\"}")); + assertEquals(VectorType.Element.INT8, linf.getVectorType().getElement()); + assertNull(linf.getVectorIndex()); + assertEquals(VectorIndex.Distance.MANHATTAN, OpenSearchClient.toSpaceDistance("l1")); + assertEquals(VectorIndex.Distance.DOT, OpenSearchClient.toSpaceDistance("innerproduct")); + assertTrue(openSearch.isSystemIndex("top_queries-2026")); + assertFalse(openSearch.isSystemIndex("docs")); + } + + @Test + void readsElasticsearchAlias() throws Exception { + try (FakeServer server = new FakeServer()) { + server.route("GET /_cat/indices", "[{\"index\": \"docs\"}, {\"index\": \".hidden\"}]") + .route("GET /alias/_mapping", "{\"docs\": {\"mappings\": {\"properties\": {\"b\": {\"type\":" + + " \"dense_vector\", \"dims\": 16, \"element_type\": \"bit\"}, \"n\": {\"type\": \"long\"}}}}}") + .route("POST /alias/_count", "{\"count\": 1}") + .route("POST /alias/_pit", "{\"id\": \"p1\"}") + .route("POST /_search", "{\"pit_id\": \"p2\", \"hits\": {\"hits\": [{\"_id\": \"1\", \"_source\":" + + " {\"b\": \"ab01\", \"n\": 5}, \"sort\": [1]}]}}") + .route("DELETE /_pit", 500, "{}") + .route("GET /alias/_doc/1", "{\"_source\": {\"n\": 1}}") + .route("POST /alias/_search", "{\"hits\": {\"hits\": []}}"); + try (VdbClient client = VdbType.ELASTICSEARCH.createClient(source(VdbType.ELASTICSEARCH, server))) { + assertEquals(List.of("docs"), client.getCollections()); + assertEquals("ApiKey key", server.requests("GET /_cat").get(0).authorization); + VdbCollection collection = client.describe("alias"); + List> rows = readAll(client, collection, VdbQuery.all(), 10); + assertEquals("[171,1]", rows.get(0).get("b")); + assertEquals(5L, rows.get(0).get("n")); + VdbQuery nearest = new VdbQuery(); + nearest.setNearestField("b"); + nearest.setNearestId("1"); + VdbException e = assertThrows(VdbException.class, () -> readAll(client, collection, nearest, 10)); + assertTrue(e.getMessage().contains("has no vector")); + } + } + } + + // ------------------------------------------------------------------ + // Qdrant, Chroma and the payloads + // ------------------------------------------------------------------ + + @Test + void mapsQdrantAndChroma() throws Exception { + VdbField multivector = QdrantClient.toVectorField("m", json("{\"size\": 4, \"multivector_config\": {}}"), + json("{}")); + assertEquals(VdbField.Kind.JSON, multivector.getKind()); + VdbField uint8 = QdrantClient.toVectorField("u", json("{\"size\": 4, \"datatype\": \"uint8\", \"distance\":" + + " \"Manhattan\", \"hnsw_config\": {\"m\": 0}}"), json("{\"m\": 16, \"ef_construct\": 100}")); + assertEquals(VectorType.Element.FLOAT32, uint8.getVectorType().getElement()); + assertEquals(VectorIndex.Distance.MANHATTAN, uint8.getVectorIndex().getDistance()); + assertNull(uint8.getVectorIndex().getOption("m")); + assertNull(QdrantClient.toVectorField("x", json("{\"size\": 4, \"distance\": \"Other\"}"), json("{}")) + .getVectorIndex()); + VdbCollection collection = new VdbCollection("c"); + VdbField renamed = new VdbField("a_2", VdbField.Kind.BIGINT); + renamed.setSourceName("a"); + collection.addField(renamed); + assertEquals(json("{\"must\": [{\"key\": \"a\", \"match\": {\"value\": 1}}, {\"key\": \"c\", \"range\":" + + " {\"gt\": 1.5}}, {\"key\": \"d\", \"range\": {\"gte\": 2}}, {\"key\": \"e\", \"range\": {\"lt\":" + + " 3}}, {\"key\": \"f\", \"range\": {\"lte\": 4}}, {\"key\": \"g\", \"match\": {\"any\": [1, 2]}}]," + + " \"must_not\": [{\"key\": \"b\", \"match\": {\"value\": \"x\"}}]}").toString(), + QdrantClient.toFilter(collection, VdbQuery.all().where("a_2 = 1 and b != 'x' and c > 1.5 and d >= 2" + + " and e < 3 and f <= 4 and g IN (1, 2)")).toString()); + VdbQuery invalid = new VdbQuery(); + invalid.setNativeFilter("{bad"); + assertThrows(VdbException.class, () -> QdrantClient.toFilter(collection, invalid)); + + assertEquals(VectorIndex.Distance.DOT, ChromaClient.toVectorIndex(json("{\"spann\": {\"space\": \"ip\"}}")) + .getDistance()); + assertEquals(VectorIndex.Distance.EUCLIDEAN, ChromaClient.toVectorIndex(json("{}")).getDistance()); + assertNull(ChromaClient.toVectorIndex(json("{\"hnsw\": {\"space\": \"other\"}}"))); + } + + @Test + void readsChromaWithoutEmbeddings() throws Exception { + try (FakeServer server = new FakeServer()) { + String base = "/api/v2/tenants/t/databases/d"; + server.route("GET /api/v2/version", "\"1.0.0\"") + .route("GET " + base + "/collections?", "[{\"name\": \"c\"}]") + .route("GET " + base + "/collections/c", "{\"id\": \"id1\", \"metadata\": null," + + " \"configuration_json\": {\"hnsw\": {\"space\": \"l2\"}}}") + .route("POST " + base + "/collections/id1/get", body -> body.contains("\"where\"") + ? "{\"ids\": [\"1\", \"2\"]}" : "{\"ids\": [\"1\"], \"metadatas\": [null], \"embeddings\":" + + " [null], \"documents\": [null]}") + .route("GET " + base + "/collections/id1/count", "1"); + VdbSource source = source(VdbType.CHROMA, server); + source.setTenant("t"); + source.setDatabase("d"); + try (VdbClient client = VdbType.CHROMA.createClient(source)) { + assertEquals("1.0.0", client.getVersion()); + assertEquals(List.of("c"), client.getCollections()); + VdbCollection collection = client.describe("c"); + assertTrue(collection.findField("embedding").get().isNullable()); + assertNull(collection.getRemarks()); + List> rows = readAll(client, collection, VdbQuery.all(), 10); + assertNull(rows.get(0).get("embedding")); + assertNull(rows.get(0).get("document")); + assertEquals(2, client.count(collection, VdbQuery.all().where("n = 1"))); + VdbQuery limited = VdbQuery.all(); + limited.setLimit(1); + assertEquals(1, client.count(collection, limited)); + } + } + } + + @Test + void convertsPayloadValues() throws Exception { + assertNull(AbstractVdbClient.toValue(json("true"), new VdbField("f", VdbField.Kind.INTEGER))); + assertEquals(2, AbstractVdbClient.toValue(json("2"), new VdbField("f", VdbField.Kind.SMALLINT))); + assertNull(AbstractVdbClient.toValue(json("1"), new VdbField("f", VdbField.Kind.BOOLEAN))); + assertEquals(1.5f, AbstractVdbClient.toValue(json("1.5"), new VdbField("f", VdbField.Kind.REAL))); + assertNull(AbstractVdbClient.toValue(json("\"x\""), new VdbField("f", VdbField.Kind.DOUBLE))); + assertEquals("[1]", AbstractVdbClient.toValue(json("[1]"), new VdbField("f", VdbField.Kind.TEXT))); + assertEquals(OffsetDateTime.parse("2024-01-01T00:00Z"), AbstractVdbClient.toValue(json( + "\"2024-01-01T00:00:00Z\""), new VdbField("f", VdbField.Kind.TIMESTAMP))); + assertNull(AbstractVdbClient.toValue(json("\"2024\""), new VdbField("f", VdbField.Kind.TIMESTAMP))); + assertNull(AbstractVdbClient.toValue(json("1"), new VdbField("f", VdbField.Kind.TIMESTAMP))); + assertArrayEquals(new byte[]{1}, (byte[]) AbstractVdbClient.toValue(json("\"AQ==\""), new VdbField("f", + VdbField.Kind.BINARY))); + assertNull(AbstractVdbClient.toValue(json("\"!!\""), new VdbField("f", VdbField.Kind.BINARY))); + assertNull(AbstractVdbClient.toValue(json("1"), new VdbField("f", VdbField.Kind.BINARY))); + assertEquals("{\"a\":1}", AbstractVdbClient.toValue(json("{\"a\": 1}"), new VdbField("f", + VdbField.Kind.VECTOR))); + assertEquals("x", AbstractVdbClient.toValue(json("\"x\""), new VdbField("f", VdbField.Kind.VECTOR))); + assertNull(AbstractVdbClient.toValue(json("null"), new VdbField("f", VdbField.Kind.TEXT))); + assertThrows(IllegalArgumentException.class, () -> AbstractVdbClient.required(" ", "Host")); + assertEquals("a%20b", AbstractVdbClient.encode("a b")); + } + + @Test + void reportsHttpErrors() throws Exception { + try (FakeServer server = new FakeServer()) { + server.route("GET /collections", 403, "{\"status\": {\"error\": \"forbidden\"}}"); + try (VdbClient client = VdbType.QDRANT.createClient(source(VdbType.QDRANT, server))) { + RestException e = assertThrows(RestException.class, client::getCollections); + assertTrue(e.isAccessDenied()); + assertEquals(VdbType.QDRANT, client.getType()); + } + } + } + + /** + * Blank API keys and users are no credentials: no authorization header is sent, a refusal tells so + */ + @Test + void sendsNoBlankCredentials() throws Exception { + try (FakeServer server = new FakeServer()) { + server.route("GET ", 401, "{\"error\": \"Unauthorized\"}") + .route("POST ", 401, "{\"error\": \"Unauthorized\"}"); + for (VdbType type : VdbType.values()) { + if (type == VdbType.MILVUS) { + continue; + } + VdbSource source = source(type, server); + source.setApiKey(" "); + source.setUsername(" "); + assertFalse(source.hasCredentials()); + try (VdbClient client = type.createClient(source)) { + RestException e = assertThrows(RestException.class, client::getCollections); + assertTrue(e.isAccessDenied()); + assertTrue(e.getMessage().contains("no API key or user is given"), e.getMessage()); + } + } + assertTrue(server.requests.stream().allMatch(request -> request.authorization == null)); + int requests = server.requests.size(); + VdbSource source = source(VdbType.QDRANT, server); + try (VdbClient client = VdbType.QDRANT.createClient(source)) { + RestException e = assertThrows(RestException.class, client::getCollections); + assertTrue(e.getMessage().contains("are refused"), e.getMessage()); + } + assertEquals(requests + 1, server.requests.size()); + } + // Milvus refuses with HTTP 200 and its codes + try (FakeServer server = new FakeServer()) { + server.route("POST /v2/vectordb/collections/list", "{\"code\": 1800, \"message\": \"user hasn't" + + " authenticated\"}") + .route("POST /v2/vectordb/collections/describe", "{\"code\": 65535, \"message\":" + + " \"PrivilegeLoad: permission denied\"}"); + VdbSource source = source(VdbType.MILVUS, server); + source.setApiKey(null); + try (VdbClient client = VdbType.MILVUS.createClient(source)) { + RestException e = assertThrows(RestException.class, client::getCollections); + assertEquals(401, e.getStatus()); + assertTrue(e.getMessage().contains("no API key or user is given"), e.getMessage()); + RestException denied = assertThrows(RestException.class, () -> client.describe("docs")); + assertEquals(403, denied.getStatus()); + } + assertTrue(server.requests.stream().allMatch(request -> request.authorization == null)); + } + } +} diff --git a/src/test/java/com/github/sqljam/vdb/dialect/VdbDialectTest.java b/src/test/java/com/github/sqljam/vdb/dialect/VdbDialectTest.java new file mode 100644 index 0000000..a42e218 --- /dev/null +++ b/src/test/java/com/github/sqljam/vdb/dialect/VdbDialectTest.java @@ -0,0 +1,466 @@ +/* + * 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.vdb.dialect; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.OffsetDateTime; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import com.github.sqljam.vdb.VdbFieldMapping; +import com.github.sqljam.vdb.DefaultVdbMetaDataVisitor; +import com.github.sqljam.vdb.VdbClient; +import com.github.sqljam.vdb.VdbCollection; +import com.github.sqljam.vdb.VdbCollectionMetaData; +import com.github.sqljam.vdb.VdbDdlScripter; +import com.github.sqljam.vdb.VdbException; +import com.github.sqljam.vdb.VdbExportConfiguration; +import com.github.sqljam.vdb.VdbField; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VdbServerMetaData; +import com.github.sqljam.vdb.VdbType; +import com.github.sqljam.vdb.VectorIndex; +import com.github.sqljam.vdb.VectorType; + +/** + * @Description: VdbDialectTest tests the statements and values of the vdb dialects without a target: column types, + * vector types and indexes of the features of a target, upserts, and targets without vectors + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class VdbDialectTest { + + static VdbCollection collection() { + VdbCollection collection = new VdbCollection("sjv-doc"); + collection.setRemarks("Docs of 'SqlJam'"); + VdbField id = new VdbField("id", VdbField.Kind.BIGINT); + id.setPrimaryKey(true); + id.setNullable(false); + collection.addField(id); + VdbField title = VdbField.varchar("title", 255); + title.setRemarks("Title"); + collection.addField(title); + collection.addField(new VdbField("Title", VdbField.Kind.TEXT)); + for (VdbField.Kind kind : new VdbField.Kind[]{VdbField.Kind.SMALLINT, VdbField.Kind.INTEGER, + VdbField.Kind.REAL, VdbField.Kind.DOUBLE, VdbField.Kind.BOOLEAN, VdbField.Kind.TIMESTAMP, + VdbField.Kind.BINARY, VdbField.Kind.JSON, VdbField.Kind.TEXT}) { + collection.addField(new VdbField("c_" + kind.name().toLowerCase(), kind)); + } + collection.addField(VdbField.varchar("long_text", 70000)); + VdbField embedding = VdbField.vector("embedding", VectorType.dense(VectorType.Element.FLOAT32, 1024)); + embedding.setNullable(false); + embedding.setVectorIndex(new VectorIndex(VectorIndex.Kind.HNSW, VectorIndex.Distance.COSINE, Map.of("m", + "16", "ef_construction", "64"))); + collection.addField(embedding); + VdbField half = VdbField.vector("half", VectorType.dense(VectorType.Element.FLOAT16, 2560)); + half.setVectorIndex(new VectorIndex(VectorIndex.Kind.IVF, VectorIndex.Distance.EUCLIDEAN, Map.of("lists", + "4"))); + collection.addField(half); + VdbField sparse = VdbField.vector("sparse", new VectorType(VectorType.Element.FLOAT32, 100, true)); + sparse.setVectorIndex(new VectorIndex(VectorIndex.Kind.HNSW, VectorIndex.Distance.DOT, null)); + collection.addField(sparse); + return collection; + } + + static VdbClient client(VdbCollection collection) { + return new VdbClient() { + @Override + public VdbType getType() { + return VdbType.QDRANT; + } + + @Override + public String getVersion() { + return "1"; + } + + @Override + public List getCollections() { + return List.of(collection.getName()); + } + + @Override + public VdbCollection describe(String name) { + return collection; + } + + @Override + public long count(VdbCollection collection, VdbQuery query) { + return 0; + } + + @Override + public void read(VdbCollection collection, VdbQuery query, int pageSize, PageConsumer consumer) { + consumer.accept(List.of(), true); + } + }; + } + + static VdbDialect full(VdbDialect dialect) { + dialect.useFeatures(EnumSet.allOf(VdbFeature.class)); + return dialect; + } + + static Stream dialects() { + return Stream.of(new PostgreSQLVdbDialect(), new MySQLVdbDialect(), new MariaDBVdbDialect(), + new OracleVdbDialect(), new SQLServerVdbDialect(), new Db2VdbDialect(), new DuckDBVdbDialect(), + new ClickHouseVdbDialect()).map(VdbDialectTest::full); + } + + static VdbCollectionMetaData metaData(VdbDialect dialect, VdbExportConfiguration configuration) { + VdbCollection collection = collection(); + configuration.setDialect(dialect); + configuration.addCollection(collection.getName(), null); + return new VdbServerMetaData(client(collection), dialect, configuration).getCollectionMetaData().get(0); + } + + static VdbDdlScripter.Table scripts(VdbDialect dialect, boolean recreated) throws Exception { + VdbExportConfiguration configuration = new VdbExportConfiguration(); + configuration.setTargetSchema("sjit_dst"); + configuration.setTableRecreated(recreated); + VdbCollectionMetaData metaData = metaData(dialect, configuration); + VdbDdlScripter scripter = new VdbDdlScripter(); + metaData.getServerMetaData().accept(new DefaultVdbMetaDataVisitor(configuration, scripter)); + return scripter.getTable(metaData.getSchemaName(), metaData.getTableName()); + } + + /** + * Scripts of the collection with the fields which the user mapped + */ + static VdbDdlScripter.Table mappedScripts(VdbDialect dialect, Map fields) + throws Exception { + VdbCollection collection = collection(); + VdbExportConfiguration configuration = new VdbExportConfiguration(); + configuration.setTargetSchema("sjit_dst"); + configuration.setTableRecreated(true); + configuration.setDialect(dialect); + configuration.addCollection(collection.getName(), null); + configuration.getMappings().get(0).getFields().putAll(fields); + VdbCollectionMetaData metaData = new VdbServerMetaData(client(collection), dialect, configuration) + .getCollectionMetaData().get(0); + VdbDdlScripter scripter = new VdbDdlScripter(); + metaData.getServerMetaData().accept(new DefaultVdbMetaDataVisitor(configuration, scripter)); + return scripter.getTable(metaData.getSchemaName(), metaData.getTableName()); + } + + @ParameterizedTest + @MethodSource("dialects") + void mapsFields(VdbDialect dialect) throws Exception { + Map fields = new LinkedHashMap<>(); + fields.put("title", new VdbFieldMapping(" headline ", null, false)); + fields.put("c_text", new VdbFieldMapping(null, "SJ_TEXT_TYPE", false)); + fields.put("half", VdbFieldMapping.skip()); + VdbDdlScripter.Table table = mappedScripts(dialect, fields); + String create = table.getCreateTableScript(); + assertTrue(create.contains(dialect.getIdentifier("headline")), create); + // Title keeps its name, title is another column now + assertTrue(create.contains(dialect.getIdentifier("Title")) && !create.contains("Title_2"), create); + assertTrue(create.contains("SJ_TEXT_TYPE"), create); + // The skipped vector has no column and no index + assertFalse(create.contains(dialect.getIdentifier("half")), create); + assertTrue(table.getIndexScripts().stream().noneMatch(script -> script.contains(dialect.getIdentifier( + "half"))), table.getIndexScripts().toString()); + + // Mappings which change nothing, and no mappings, give the same scripts + Map same = new LinkedHashMap<>(); + same.put("title", new VdbFieldMapping(null, " ", false)); + assertEquals(scripts(dialect, true).getScripts(), mappedScripts(dialect, same).getScripts()); + assertEquals(scripts(dialect, true).getScripts(), mappedScripts(dialect, Map.of()).getScripts()); + } + + @Test + void refusesFieldMappingsWhichCanNotBeImported() { + VdbDialect dialect = full(new PostgreSQLVdbDialect()); + VdbException missing = assertThrows(VdbException.class, () -> mappedScripts(dialect, Map.of("nothing", + VdbFieldMapping.skip()))); + assertTrue(missing.getMessage().contains("Field nothing of the mapping is not a field of collection sjv-doc"), + missing.getMessage()); + VdbException id = assertThrows(VdbException.class, () -> mappedScripts(dialect, Map.of("id", + VdbFieldMapping.skip()))); + assertTrue(id.getMessage().contains("it can not be skipped"), id.getMessage()); + Map twice = new LinkedHashMap<>(); + twice.put("title", new VdbFieldMapping("name", null, false)); + twice.put("Title", new VdbFieldMapping("NAME", null, false)); + VdbException duplicate = assertThrows(VdbException.class, () -> mappedScripts(dialect, twice)); + assertTrue(duplicate.getMessage().contains("are both mapped to"), duplicate.getMessage()); + // An unmapped field gives way to a name which the user chose + Map taken = new LinkedHashMap<>(); + taken.put("c_text", new VdbFieldMapping("long_text", null, false)); + taken.put("long_text", VdbFieldMapping.skip()); + assertDoesNotThrow(() -> mappedScripts(dialect, taken)); + + VdbFieldMapping mapping = new VdbFieldMapping(" a ", "", false); + assertEquals(new VdbFieldMapping("a", null, false), mapping); + assertEquals(mapping.hashCode(), new VdbFieldMapping("a", null, false).hashCode()); + assertFalse(mapping.equals("a")); + assertTrue(VdbFieldMapping.skip().toString().contains("skipped=true")); + } + + @ParameterizedTest + @MethodSource("dialects") + void createsTables(VdbDialect dialect) throws Exception { + VdbDdlScripter.Table table = scripts(dialect, true); + List scripts = table.getScripts(); + String create = table.getCreateTableScript(); + assertTrue(scripts.get(0).startsWith("DROP TABLE IF EXISTS"), scripts.get(0)); + assertTrue(create.startsWith("CREATE TABLE"), create); + // The table of sjv-doc is sjv_doc, Title and title are different columns + assertTrue(create.contains(dialect.getIdentifier("sjv_doc")), create); + assertTrue(create.contains(dialect.getIdentifier("Title_2")), create); + String vectorType = dialect.getTypeName(collection().findField("embedding").get()); + assertTrue(create.contains(vectorType), create); + // MySQL, SQL Server and DuckDB have no vector indexes + boolean indexed = !(dialect instanceof MySQLVdbDialect) || dialect instanceof MariaDBVdbDialect; + indexed &= !(dialect instanceof SQLServerVdbDialect) && !(dialect instanceof DuckDBVdbDialect); + assertEquals(indexed, !table.getIndexScripts().isEmpty(), dialect.getName()); + assertEquals(scripts.size(), table.getCreateScripts().size() + table.getIndexScripts().size()); + assertTrue(scripts(dialect, false).getBeforeStatements().isEmpty()); + } + + @Test + void writesPostgreSQL() throws Exception { + VdbDialect dialect = full(new PostgreSQLVdbDialect()); + VdbDdlScripter.Table table = scripts(dialect, true); + String create = table.getCreateTableScript(); + assertTrue(create.contains("\"embedding\" vector(1024) NOT NULL"), create); + assertTrue(create.contains("\"half\" halfvec(2560)"), create); + assertTrue(create.contains("\"sparse\" sparsevec(100)"), create); + assertTrue(create.contains("\"c_timestamp\" timestamptz"), create); + assertTrue(create.contains("\"long_text\" varchar(70000)"), create); + assertTrue(create.contains("CONSTRAINT \"pk_sjv_doc\" PRIMARY KEY (\"id\")"), create); + assertTrue(table.getCommentStatements().contains("COMMENT ON TABLE \"sjit_dst\".\"sjv_doc\" IS 'Docs of" + + " ''SqlJam'''"), String.valueOf(table.getCommentStatements())); + assertEquals(List.of("CREATE INDEX IF NOT EXISTS \"idx_sjv_doc_embedding\" ON \"sjit_dst\".\"sjv_doc\"" + + " USING hnsw (\"embedding\" vector_cosine_ops) WITH (m = 16, ef_construction = 64)", + "CREATE INDEX IF NOT EXISTS \"idx_sjv_doc_half\" ON \"sjit_dst\".\"sjv_doc\" USING ivfflat (\"half\"" + + " halfvec_l2_ops) WITH (lists = 4)", + "CREATE INDEX IF NOT EXISTS \"idx_sjv_doc_sparse\" ON \"sjit_dst\".\"sjv_doc\" USING hnsw (\"sparse\"" + + " sparsevec_ip_ops)"), table.getIndexScripts()); + + VdbCollectionMetaData metaData = metaData(dialect, new VdbExportConfiguration()); + List columns = metaData.getColumns(); + String upsert = dialect.getUpsertStatement("s", "t", columns, List.of("id")); + assertTrue(upsert.contains("ON CONFLICT (\"id\") DO UPDATE SET \"title\" = EXCLUDED.\"title\""), upsert); + assertTrue(upsert.contains("CAST(? AS vector(1024))"), upsert); + assertTrue(dialect.getUpsertStatement("s", "t", columns.subList(0, 1), List.of("id")) + .endsWith("ON CONFLICT (\"id\") DO NOTHING")); + VdbDialect.Column sparse = column(columns, "sparse"); + assertEquals("{3:0.5}/100", dialect.getJdbcValue("{3:0.5}/100", sparse)); + VdbDialect.Column time = column(columns, "c_timestamp"); + assertEquals("2024-01-01 08:30:00.000000+00:00", dialect.getJdbcValue(OffsetDateTime.parse( + "2024-01-01T08:30Z"), time)); + assertEquals(Types.VARCHAR, dialect.getNullSqlType(time)); + assertEquals("12", dialect.getJdbcValue(12L, column(columns, "title"))); + assertThrows(VdbException.class, () -> dialect.getJdbcValue("not a vector", column(columns, + "embedding"))); + } + + static VdbDialect.Column column(List columns, String name) { + return columns.stream().filter(column -> column.getName().equals(name)).findFirst().orElseThrow(); + } + + @Test + void writesMySQLAndMariaDB() throws Exception { + VdbDialect mysql = full(new MySQLVdbDialect()); + String create = scripts(mysql, true).getCreateTableScript(); + assertTrue(create.contains("`embedding` VECTOR(1024) NOT NULL"), create); + assertTrue(create.contains("`title` VARCHAR(255) COMMENT 'Title'"), create); + assertTrue(create.contains("`long_text` LONGTEXT"), create); + // Sparse vectors are text + assertTrue(create.contains("`sparse` LONGTEXT"), create); + assertTrue(scripts(mysql, true).getIndexScripts().isEmpty()); + List columns = metaData(mysql, new VdbExportConfiguration()).getColumns(); + byte[] bytes = (byte[]) mysql.getJdbcValue("[1.5,-2]", column(columns, "embedding")); + ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + assertEquals(1.5f, buffer.getFloat()); + assertEquals(-2f, buffer.getFloat()); + assertEquals(Timestamp.valueOf("2024-01-01 08:30:00"), mysql.getJdbcValue(OffsetDateTime.parse( + "2024-01-01T10:30+02:00"), column(columns, "c_timestamp"))); + assertTrue(mysql.getUpsertStatement("s", "t", columns, List.of("id")).contains(" AS new_row ON DUPLICATE" + + " KEY UPDATE `title` = new_row.`title`")); + assertTrue(mysql.getUpsertStatement("s", "t", columns.subList(0, 1), List.of("id")) + .endsWith("`id` = new_row.`id`")); + assertEquals("ALTER TABLE `s`.`t` COMMENT = 'a\\\\b'", mysql.getTableCommentStatement("s", "t", "a\\b")); + + VdbDialect mariadb = full(new MariaDBVdbDialect()); + // One vector index of a table + assertEquals(List.of("CREATE VECTOR INDEX `idx_sjv_doc_embedding` ON `sjit_dst`.`sjv_doc` (`embedding`)" + + " M=16 DISTANCE=cosine"), scripts(mariadb, true).getIndexScripts()); + assertTrue(mariadb.getUpsertStatement("s", "t", columns, List.of("id")).contains("`title` = VALUES(" + + "`title`)")); + List keys = List.of(new VdbDialect.Column("id", VdbField.varchar("id", 512), + "VARCHAR(512)")); + assertFalse(mariadb.isVectorIndexAllowed(keys)); + assertTrue(mariadb.isVectorIndexAllowed(List.of(column(columns, "id")))); + } + + @Test + void writesOracle() throws Exception { + VdbDialect dialect = full(new OracleVdbDialect()); + VdbDdlScripter.Table table = scripts(dialect, true); + String create = table.getCreateTableScript(); + assertTrue(create.contains("\"EMBEDDING\" VECTOR(1024, FLOAT32) NOT NULL"), create); + assertTrue(create.contains("\"HALF\" VECTOR(2560, FLOAT32)"), create); + assertTrue(create.contains("\"SPARSE\" VECTOR(100, FLOAT32, SPARSE)"), create); + assertTrue(create.contains("\"C_JSON\" JSON"), create); + assertTrue(create.contains("\"LONG_TEXT\" CLOB"), create); + assertEquals(List.of("CREATE VECTOR INDEX \"IDX_SJV_DOC_EMBEDDING\" ON \"SJIT_DST\".\"SJV_DOC\"" + + " (\"EMBEDDING\") ORGANIZATION INMEMORY NEIGHBOR GRAPH DISTANCE COSINE PARAMETERS (TYPE HNSW," + + " NEIGHBORS 16, EFCONSTRUCTION 64)", + "CREATE VECTOR INDEX \"IDX_SJV_DOC_HALF\" ON \"SJIT_DST\".\"SJV_DOC\" (\"HALF\") ORGANIZATION" + + " NEIGHBOR PARTITIONS DISTANCE EUCLIDEAN PARAMETERS (TYPE IVF, NEIGHBOR PARTITIONS 4)"), + table.getIndexScripts()); + List columns = metaData(dialect, new VdbExportConfiguration()).getColumns(); + assertEquals("[100,[2],[0.5]]", dialect.getJdbcValue("{3:0.5}/100", column(columns, "sparse"))); + String upsert = dialect.getUpsertStatement("s", "t", columns, List.of("id")); + assertTrue(upsert.startsWith("MERGE INTO \"S\".\"T\" tgt USING (SELECT ? \"ID\""), upsert); + assertTrue(upsert.contains("TO_VECTOR(?, 1024, FLOAT32) \"EMBEDDING\""), upsert); + assertEquals(Types.CLOB, dialect.getNullSqlType(column(columns, "embedding"))); + + // Without vector memory HNSW indexes are IVF indexes + dialect.useFeatures(EnumSet.of(VdbFeature.VECTOR, VdbFeature.IVF_INDEX)); + assertTrue(scripts(dialect, true).getIndexScripts().get(0).contains("NEIGHBOR PARTITIONS DISTANCE COSINE")); + } + + @Test + void writesSQLServerAndDb2() throws Exception { + VdbDialect sqlserver = full(new SQLServerVdbDialect()); + String create = scripts(sqlserver, true).getCreateTableScript(); + assertTrue(create.contains("[embedding] VECTOR(1024) NOT NULL"), create); + assertTrue(create.contains("[half] VECTOR(2560, float16)"), create); + assertTrue(create.contains("PRIMARY KEY NONCLUSTERED ([id])"), create); + assertTrue(create.contains("[c_timestamp] DATETIMEOFFSET(6)"), create); + assertTrue(scripts(sqlserver, true).getCommentStatements().isEmpty()); + List columns = metaData(sqlserver, new VdbExportConfiguration()).getColumns(); + String merge = sqlserver.getUpsertStatement("s", "t", columns, List.of("id")); + assertTrue(merge.startsWith("MERGE INTO [s].[t] tgt USING (VALUES (?, ?"), merge); + assertTrue(merge.endsWith(");"), merge); + + VdbDialect db2 = full(new Db2VdbDialect()); + VdbDdlScripter.Table table = scripts(db2, true); + create = table.getCreateTableScript(); + assertTrue(create.contains("\"EMBEDDING\" VECTOR(1024, FLOAT32) NOT NULL"), create); + assertTrue(create.contains("\"TITLE\" VARCHAR(765)"), create); + assertTrue(create.contains("\"HALF\" VECTOR(2560, FLOAT32)"), create); + assertTrue(create.contains("\"SPARSE\" CLOB(2G)"), create); + // 1024 floats are 4096 bytes of a key of at most 8000 bytes + assertEquals(List.of("CREATE VECTOR INDEX \"SJIT_DST\".\"IDX_SJV_DOC_EMBEDDING\" ON \"SJIT_DST\".\"SJV_DOC\"" + + " (\"EMBEDDING\") WITH DISTANCE COSINE"), table.getIndexScripts()); + columns = metaData(db2, new VdbExportConfiguration()).getColumns(); + String upsert = db2.getUpsertStatement("s", "t", columns, List.of("id")); + assertTrue(upsert.contains("VECTOR(CAST(? AS CLOB(2G)), 1024, FLOAT32)"), upsert); + assertTrue(upsert.contains("CAST(? AS BIGINT)"), upsert); + assertEquals(Types.CLOB, db2.getNullSqlType(column(columns, "embedding"))); + assertEquals(Types.TIMESTAMP, db2.getNullSqlType(column(columns, "c_timestamp"))); + } + + @Test + void writesDuckDBAndClickHouse() throws Exception { + VdbDialect duckdb = full(new DuckDBVdbDialect()); + String create = scripts(duckdb, true).getCreateTableScript(); + assertTrue(create.contains("\"embedding\" FLOAT[1024] NOT NULL"), create); + assertTrue(create.contains("\"sparse\" VARCHAR"), create); + List columns = metaData(duckdb, new VdbExportConfiguration()).getColumns(); + assertTrue(duckdb.getInsertStatement("main", "t", columns).contains("CAST(? AS FLOAT[1024])")); + assertEquals("2024-01-01 08:30:00.000000+00:00", duckdb.getJdbcValue(OffsetDateTime.parse( + "2024-01-01T08:30Z"), column(columns, "c_timestamp"))); + assertEquals(Types.VARCHAR, duckdb.getNullSqlType(column(columns, "c_timestamp"))); + + VdbDialect clickhouse = full(new ClickHouseVdbDialect()); + VdbDdlScripter.Table table = scripts(clickhouse, true); + create = table.getCreateTableScript(); + assertTrue(create.contains("`embedding` Array(Float32)"), create); + assertTrue(create.contains("`title` Nullable(String) COMMENT 'Title'"), create); + assertTrue(create.endsWith(") ENGINE = MergeTree ORDER BY (`id`)"), create); + assertFalse(create.contains("PRIMARY KEY"), create); + // Missing vectors are empty arrays, which are not indexed + assertEquals(1, table.getIndexScripts().size()); + assertTrue(table.getIndexScripts().get(0).contains("vector_similarity('hnsw', 'cosineDistance', 1024," + + " 'bf16', 16, 64)")); + columns = metaData(clickhouse, new VdbExportConfiguration()).getColumns(); + assertEquals("[]", clickhouse.getJdbcValue(null, column(columns, "half"))); + assertEquals("2024-01-01 08:30:00.000000", clickhouse.getJdbcValue(OffsetDateTime.parse( + "2024-01-01T10:30+02:00"), column(columns, "c_timestamp"))); + assertFalse(clickhouse.isUpsertSupported()); + assertFalse(clickhouse.isTransactionSupported()); + List all = columns; + assertThrows(UnsupportedOperationException.class, () -> clickhouse.getUpsertStatement("s", "t", all, + List.of("id"))); + assertNull(clickhouse.getColumnCommentStatement("s", "t", "c", "x")); + assertEquals("ALTER TABLE `s`.`t` MODIFY COMMENT 'x'", clickhouse.getTableCommentStatement("s", "t", "x")); + assertTrue(clickhouse.getTableOptions(List.of()).endsWith("ORDER BY tuple()")); + } + + /** + * A target without vectors keeps vectors as text and has no vector indexes + */ + @ParameterizedTest + @MethodSource("dialects") + void degradesWithoutVectors(VdbDialect dialect) throws Exception { + dialect.useFeatures(EnumSet.noneOf(VdbFeature.class)); + assertFalse(dialect.isVectorSupported()); + VdbField embedding = collection().findField("embedding").get(); + String type = dialect.getTypeName(embedding); + assertFalse(dialect.isVectorTypeName(embedding, type), type); + VdbDdlScripter.Table table = scripts(dialect, false); + assertTrue(table.getIndexScripts().isEmpty(), String.valueOf(table.getIndexScripts())); + List columns = metaData(dialect, new VdbExportConfiguration()).getColumns(); + assertEquals("[1.0,2.0]", dialect.getJdbcValue("[1.0,2.0]", column(columns, "embedding"))); + } + + @ParameterizedTest + @MethodSource("dialects") + void quotesIdentifiers(VdbDialect dialect) { + String quoted = dialect.quoteIdentifier("a\"b`c]d"); + assertTrue(quoted.length() > "a\"b`c]d".length()); + String limited = dialect.getLimitedIdentifier("x".repeat(300)); + assertEquals(dialect.getMaxIdentifierLength(), limited.length()); + assertEquals(dialect.getIdentifier("t"), dialect.getQualifiedTableName(null, "t")); + assertTrue(dialect.getName().length() > 1); + assertNull(dialect.getProductVersion()); + assertTrue(dialect.getFeatures().contains(VdbFeature.VECTOR)); + List names = Stream.of("a", "b").collect(Collectors.toList()); + assertEquals(dialect.getIdentifier("a") + ", " + dialect.getIdentifier("b"), dialect.getIdentifiers(names)); + } + + @Test + void namesTablesOfCollections() { + assertEquals("sjv_doc", VdbCollectionMetaData.getDefaultTableName("sjv-doc")); + assertEquals("t_1doc", VdbCollectionMetaData.getDefaultTableName("1doc")); + assertEquals("文档_a", VdbCollectionMetaData.getDefaultTableName("文档.a")); + assertArrayEquals(new String[0], new VdbDialect.Column("c", new VdbField("c", VdbField.Kind.TEXT), + "text").getField().getProperties().keySet().toArray(new String[0])); + } +} diff --git a/src/test/java/com/github/sqljam/vdb/it/AbstractDataSource.java b/src/test/java/com/github/sqljam/vdb/it/AbstractDataSource.java new file mode 100644 index 0000000..3d89fc0 --- /dev/null +++ b/src/test/java/com/github/sqljam/vdb/it/AbstractDataSource.java @@ -0,0 +1,73 @@ +/* + * 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.vdb.it; + +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.logging.Logger; + +import javax.sql.DataSource; + +/** + * @Description: AbstractDataSource is a data source of the tests whose subclasses tell its connections only + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +abstract class AbstractDataSource implements DataSource { + + @Override + public Connection getConnection(String username, String password) throws SQLException { + return getConnection(); + } + + @Override + public PrintWriter getLogWriter() { + return null; + } + + @Override + public void setLogWriter(PrintWriter out) { + // Not used + } + + @Override + public void setLoginTimeout(int seconds) { + // Not used + } + + @Override + public int getLoginTimeout() { + return 0; + } + + @Override + public Logger getParentLogger() throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException(); + } + + @Override + public T unwrap(Class type) throws SQLException { + throw new SQLException("Not a wrapper"); + } + + @Override + public boolean isWrapperFor(Class type) { + return false; + } +} diff --git a/src/test/java/com/github/sqljam/vdb/it/VdbBoundaryIT.java b/src/test/java/com/github/sqljam/vdb/it/VdbBoundaryIT.java new file mode 100644 index 0000000..1ef8bc0 --- /dev/null +++ b/src/test/java/com/github/sqljam/vdb/it/VdbBoundaryIT.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.vdb.it; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +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 org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import com.github.sqljam.it.ItDatabase; +import com.github.sqljam.utils.RestException; +import com.github.sqljam.vdb.VdbClient; +import com.github.sqljam.vdb.VdbCollection; +import com.github.sqljam.vdb.VdbException; +import com.github.sqljam.vdb.VdbExportConfiguration; +import com.github.sqljam.vdb.VdbExportListener; +import com.github.sqljam.vdb.VdbExportResult; +import com.github.sqljam.vdb.VdbExporter; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VdbSource; +import com.github.sqljam.vdb.VdbType; +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbBoundaryIT tests the boundaries of the vdb line: wrong and read-only keys of vector databases, + * unreachable servers and missing collections, native filters of each database, a target without + * vectors (PostgreSQL without pgvector), a target user who may not create tables, rows which the target + * refuses (skipped or failing the import) and a cancelled import + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class VdbBoundaryIT { + + @BeforeAll + static void createFixtures() throws Exception { + for (VdbServer server : VdbServer.values()) { + if (server.isAvailable()) { + server.createFixture(); + } + } + } + + @AfterAll + static void dropFixtures() throws Exception { + for (VdbServer server : VdbServer.values()) { + if (server.isAvailable()) { + server.dropFixture(); + } + } + if (ItDatabase.POSTGRESQL.isAvailable()) { + VdbImportIT.dropTables(ItDatabase.POSTGRESQL); + } + } + + @ParameterizedTest + @EnumSource(value = VdbServer.class, names = {"QDRANT", "WEAVIATE", "ELASTICSEARCH", "OPENSEARCH", "CHROMA", + "MILVUS"}) + void refusesWrongCredentials(VdbServer server) { + assumeTrue(server.isAvailable()); + VdbSource source = server.getSource(server.getSetting("apiKey") != null ? "wrong-key" : null); + if (source.getUsername() != null) { + source.setPassword("wrong-password"); + } + try (VdbClient client = server.getType().createClient(source)) { + // Milvus answers HTTP 200 with an error code, which is 401 too + RestException e = assertThrows(RestException.class, client::getCollections); + assertTrue(e.isAccessDenied(), e.toString()); + assertTrue(e.getMessage().contains("are refused"), e.getMessage()); + } + } + + /** + * Without an API key and a user (or with blank ones) no credentials are sent: a server which requires them + * refuses with a hint, a server without authentication (Pinecone Local) is read + */ + @ParameterizedTest + @EnumSource(VdbServer.class) + void reportsMissingCredentials(VdbServer server) { + assumeTrue(server.isAvailable()); + for (String blank : new String[]{null, "", " "}) { + VdbSource source = server.getSource(blank); + source.setUsername(blank); + source.setPassword(blank); + assertFalse(source.hasCredentials()); + try (VdbClient client = server.getType().createClient(source)) { + if (server == VdbServer.PINECONE) { + assertTrue(client.getCollections().contains(server.getCollection())); + continue; + } + RestException e = assertThrows(RestException.class, client::getCollections); + assertTrue(e.isAccessDenied(), e.toString()); + assertTrue(e.getMessage().contains("no API key or user is given"), e.getMessage()); + } + } + } + + @Test + void readsWithReadOnlyKey() { + VdbServer server = VdbServer.QDRANT; + assumeTrue(server.isAvailable() && server.getSetting("readonlyKey") != null); + try (VdbClient client = server.getType().createClient(server.getSource(server.getSetting("readonlyKey")))) { + VdbCollection collection = client.describe(server.getCollection()); + assertEquals(VdbServer.RECORDS, collection.getCount()); + List> rows = new ArrayList<>(); + client.read(collection, VdbQuery.all(), 100, (records, last) -> rows.addAll(records)); + assertEquals(VdbServer.RECORDS, rows.size()); + } + } + + @ParameterizedTest + @EnumSource(VdbType.class) + void reportsUnreachableServer(VdbType type) { + VdbSource source = new VdbSource(type, "http://127.0.0.1:1"); + source.setApiKey("key"); + source.setTimeoutSeconds(5); + try (VdbClient client = type.createClient(source)) { + RestException e = assertThrows(RestException.class, client::getCollections); + assertEquals(0, e.getStatus()); + } + } + + @ParameterizedTest + @EnumSource(VdbServer.class) + void reportsMissingCollection(VdbServer server) { + assumeTrue(server.isAvailable()); + try (VdbClient client = server.createClient()) { + assertThrows(RuntimeException.class, () -> client.describe("sjvt_missing")); + } + } + + /** + * Native filters of n >= 6 in the language of each database + */ + @ParameterizedTest + @EnumSource(VdbServer.class) + void filtersNatively(VdbServer server) { + assumeTrue(server.isAvailable()); + String filter; + switch (server) { + case MILVUS: + filter = "n >= 6"; + break; + case QDRANT: + filter = "{\"must\": [{\"key\": \"n\", \"range\": {\"gte\": 6}}]}"; + break; + case WEAVIATE: + filter = "{ path: [\"n\"], operator: GreaterThanEqual, valueInt: 6 }"; + break; + case ELASTICSEARCH: + case OPENSEARCH: + filter = "{\"range\": {\"n\": {\"gte\": 6}}}"; + break; + default: + filter = "{\"n\": {\"$gte\": 6}}"; + break; + } + try (VdbClient client = server.createClient()) { + VdbCollection collection = client.describe(server.getCollection()); + VdbQuery query = new VdbQuery(); + query.setNativeFilter(filter); + List numbers = new ArrayList<>(); + if (server == VdbServer.PINECONE) { + // Pinecone filters the metadata of searches only + assertThrows(VdbException.class, () -> client.read(collection, query, 10, (records, last) -> true)); + query.setNearestField("values"); + query.setNearestVector(VdbServer.getDocuments().get(0).embedding); + query.setLimit(10); + } + client.read(collection, query, 2, (records, last) -> { + records.forEach(row -> numbers.add(((Number) row.get("n")).intValue())); + return true; + }); + numbers.sort(null); + assertEquals(List.of(6, 7, 8), numbers, server.toString()); + } + } + + @Test + void stopsReadingWhenConsumerStops() { + VdbServer server = VdbServer.QDRANT; + assumeTrue(server.isAvailable()); + try (VdbClient client = server.createClient()) { + VdbCollection collection = client.describe(server.getCollection()); + int[] pages = {0}; + client.read(collection, VdbQuery.all(), 2, (records, last) -> ++pages[0] < 2); + assertEquals(2, pages[0]); + } + } + + @Test + void refusesTargetWithoutVectors() throws Exception { + assumeTrue(ItDatabase.POSTGRESQL.isAvailable() && VdbServer.QDRANT.isAvailable()); + String url = ItDatabase.POSTGRESQL.getUrl("sjit_novector"); + try (Connection connection = DriverManager.getConnection(url, ItDatabase.POSTGRESQL.getUsername(), + ItDatabase.POSTGRESQL.getPassword())) { + VdbDialect dialect = VdbDialect.of(connection); + assertNotNull(dialect); + assertFalse(dialect.isVectorSupported()); + VdbException e = assertThrows(VdbException.class, () -> VdbExporter.getDialect(connection)); + assertTrue(e.getMessage().contains("has no vectors"), e.getMessage()); + } catch (SQLException e) { + assumeTrue(false, "No database sjit_novector: " + e.getMessage()); + } + } + + @Test + void reportsTargetUserWithoutPrivileges() throws Exception { + VdbServer server = VdbServer.QDRANT; + assumeTrue(ItDatabase.POSTGRESQL.isAvailable() && server.isAvailable()); + VdbExportConfiguration configuration = VdbImportIT.configuration(server, ItDatabase.POSTGRESQL); + String url = ItDatabase.POSTGRESQL.getTargetUrl(); + try (VdbClient client = server.createClient()) { + // The reader may read the schema of the targets but not create tables there + VdbExporter reader = new VdbExporter(client, new SimpleDataSource(url, "sjit_reader", "sjit_reader123"), + configuration); + SQLException e = assertThrows(SQLException.class, reader::export); + String message = e.getMessage().toLowerCase(Locale.ENGLISH); + assertTrue(message.contains("denied") || message.contains("permission") || message.contains("owner"), + e.getMessage()); + } + } + + @Test + void skipsRowsWhichTargetRefuses() throws Exception { + VdbServer server = VdbServer.QDRANT; + ItDatabase target = ItDatabase.POSTGRESQL; + assumeTrue(target.isAvailable() && server.isAvailable()); + VdbExportConfiguration configuration = VdbImportIT.configuration(server, target); + configuration.getMappings().set(0, new VdbExportConfiguration.Mapping(server.getCollection(), + server.getTableName() + "_e")); + VdbImportIT.export(server, target, configuration); + String table = "\"" + VdbImportIT.getSchema(target) + "\".\"" + server.getTableName() + "_e\""; + try (Connection connection = target.getTargetConnection(); Statement statement = connection + .createStatement()) { + statement.execute("DELETE FROM " + table); + statement.execute("ALTER TABLE " + table + " ADD CONSTRAINT sjvt_n CHECK (n < 8)"); + } + configuration.setTableRecreated(false); + List skipped = new ArrayList<>(); + VdbExportResult result; + try (VdbClient client = server.createClient()) { + result = new VdbExporter(client, VdbImportIT.dataSource(target), configuration, new VdbExportListener() { + @Override + public void onRowSkipped(VdbExportResult.TableResult table, String key, Exception e) { + skipped.add(key); + } + }).export(); + } + VdbExportResult.TableResult tableResult = result.getTables().get(0); + assertEquals(VdbServer.RECORDS - 1, tableResult.getWritten()); + assertEquals(1, tableResult.getSkipped()); + assertEquals(List.of("id=8"), skipped); + + // Without skipping the import fails, rows of the failed page are rolled back + try (Connection connection = target.getTargetConnection(); Statement statement = connection + .createStatement()) { + statement.execute("DELETE FROM " + table); + } + configuration.setErrorSkipped(false); + assertThrows(SQLException.class, () -> VdbImportIT.export(server, target, configuration)); + try (Connection connection = target.getTargetConnection()) { + // Pages of 3 rows before the page of the refused row are committed, that page is rolled back + long written = countTable(connection, table); + assertTrue(written < VdbServer.RECORDS && written % 3 == 0, String.valueOf(written)); + } + + // Rows written one by one, each committed by itself + try (Connection connection = target.getTargetConnection(); Statement statement = connection + .createStatement()) { + statement.execute("DELETE FROM " + table); + } + configuration.setErrorSkipped(true); + configuration.setTransactional(false); + assertEquals(VdbServer.RECORDS - 1, VdbImportIT.export(server, target, configuration).getWritten()); + } + + private static long countTable(Connection connection, String table) throws SQLException { + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery( + "SELECT COUNT(*) FROM " + table)) { + rs.next(); + return rs.getLong(1); + } + } + + @Test + void cancelsImport() throws Exception { + VdbServer server = VdbServer.ELASTICSEARCH; + ItDatabase target = ItDatabase.DUCKDB; + assumeTrue(target.isAvailable() && server.isAvailable()); + VdbExportConfiguration configuration = VdbImportIT.configuration(server, target); + configuration.setPageSize(2); + try (VdbClient client = server.createClient()) { + VdbExporter[] exporter = new VdbExporter[1]; + exporter[0] = new VdbExporter(client, VdbImportIT.dataSource(target), configuration, + new VdbExportListener() { + @Override + public void onRowsWritten(VdbExportResult.TableResult table) { + exporter[0].cancel(); + } + }); + VdbExportResult result = exporter[0].export(); + assertTrue(result.isCancelled()); + assertEquals(2, result.getWritten()); + List scripts = exporter[0].getScripts(); + assertTrue(scripts.get(0).startsWith("DROP TABLE"), scripts.get(0)); + } finally { + VdbImportIT.dropTables(target); + } + } + + /** + * Connections of a user of the url + */ + static final class SimpleDataSource extends AbstractDataSource { + + private final String url; + private final String username; + private final String password; + + SimpleDataSource(String url, String username, String password) { + this.url = url; + this.username = username; + this.password = password; + } + + @Override + public Connection getConnection() throws SQLException { + return DriverManager.getConnection(url, username, password); + } + } +} diff --git a/src/test/java/com/github/sqljam/vdb/it/VdbImportIT.java b/src/test/java/com/github/sqljam/vdb/it/VdbImportIT.java new file mode 100644 index 0000000..5b451a8 --- /dev/null +++ b/src/test/java/com/github/sqljam/vdb/it/VdbImportIT.java @@ -0,0 +1,287 @@ +/* + * 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.vdb.it; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import com.github.sqljam.impexp.DbType; +import com.github.sqljam.it.ItDatabase; +import com.github.sqljam.vdb.VdbClient; +import com.github.sqljam.vdb.VdbCollection; +import com.github.sqljam.vdb.VdbException; +import com.github.sqljam.vdb.VdbExportConfiguration; +import com.github.sqljam.vdb.VdbExportResult; +import com.github.sqljam.vdb.VdbExporter; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbImportIT imports the fixture collection of each vector database into each target database with + * vectors: the rows, the nearest neighbors of the imported vectors (the same as of the vector database), + * upserts of existing rows, and the rows of conditions and of a search + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public class VdbImportIT { + + static final ItDatabase[] TARGETS = {ItDatabase.POSTGRESQL, ItDatabase.MYSQL, ItDatabase.MARIADB, + ItDatabase.ORACLE, ItDatabase.SQLSERVER, ItDatabase.DB2, ItDatabase.DUCKDB, ItDatabase.CLICKHOUSE}; + /** + * Documents nearest to the first one by the cosine distance of bge-m3, as each vector database finds them + */ + static final List NEAREST = List.of(1, 5, 2); + + @BeforeAll + static void createFixtures() throws Exception { + for (VdbServer server : VdbServer.values()) { + if (server.isAvailable()) { + server.createFixture(); + } + } + } + + @AfterAll + static void dropFixtures() throws Exception { + for (VdbServer server : VdbServer.values()) { + if (server.isAvailable()) { + server.dropFixture(); + } + } + for (ItDatabase target : TARGETS) { + if (target.isAvailable()) { + dropTables(target); + } + } + } + + static Stream pairs() { + List pairs = new ArrayList<>(); + for (VdbServer server : VdbServer.values()) { + for (ItDatabase target : TARGETS) { + pairs.add(Arguments.of(server, target)); + } + } + return pairs.stream(); + } + + /** + * Schema of the target tables, the database of MySQL and MariaDB + */ + public static String getSchema(ItDatabase target) { + return target.getTargetSchema() != null ? target.getTargetSchema() : target.getTargetCatalog(); + } + + public static DataSource dataSource(ItDatabase target) { + return new AbstractDataSource() { + @Override + public Connection getConnection() throws SQLException { + return target.getTargetConnection(); + } + }; + } + + static VdbExportConfiguration configuration(VdbServer server, ItDatabase target) { + VdbExportConfiguration configuration = new VdbExportConfiguration(); + configuration.setTargetSchema(getSchema(target)); + configuration.addCollection(server.getCollection(), server.getTableName()); + configuration.setTableRecreated(true); + configuration.setPageSize(3); + configuration.setErrorSkipped(true); + return configuration; + } + + static VdbExportResult.TableResult export(VdbServer server, ItDatabase target, + VdbExportConfiguration configuration) throws SQLException { + try (VdbClient client = server.createClient()) { + return new VdbExporter(client, dataSource(target), configuration).export().getTables().get(0); + } + } + + static void assume(VdbServer server, ItDatabase target) { + assumeTrue(server.isAvailable(), server + " is not available"); + assumeTrue(target.isAvailable(), target + " is not available"); + } + + /** + * Records of the fixture, Pinecone has 2 more in its default namespace + */ + static int getRecords(VdbServer server) { + return server == VdbServer.PINECONE ? VdbServer.RECORDS + 2 : VdbServer.RECORDS; + } + + @ParameterizedTest(name = "{0} -> {1}") + @MethodSource("pairs") + void importsCollection(VdbServer server, ItDatabase target) throws Exception { + assume(server, target); + VdbExportResult.TableResult result = export(server, target, configuration(server, target)); + int records = getRecords(server); + assertEquals(records, result.getRead()); + assertEquals(records, result.getWritten()); + assertEquals(0, result.getSkipped(), String.valueOf(result.getWarnings())); + assertTrue(result.isCreated()); + try (Connection connection = target.getTargetConnection()) { + VdbDialect dialect = VdbDialect.of(connection); + assertEquals(records, count(connection, dialect, target, server, null)); + List nearest = nearest(connection, dialect, target, server); + if (nearest != null) { + assertEquals(NEAREST, nearest, server + " into " + target); + } + } + // Rows of the same keys are updated, ClickHouse has no upserts + VdbExportConfiguration upsert = configuration(server, target); + upsert.setTableRecreated(false); + upsert.setWriteMode(VdbExportConfiguration.WriteMode.UPSERT); + if (target == ItDatabase.CLICKHOUSE) { + assertThrows(VdbException.class, () -> export(server, target, upsert)); + } else { + VdbExportResult.TableResult updated = export(server, target, upsert); + assertEquals(records, updated.getWritten()); + assertEquals(0, updated.getSkipped(), String.valueOf(updated.getWarnings())); + try (Connection connection = target.getTargetConnection()) { + assertEquals(records, count(connection, VdbDialect.of(connection), target, server, null)); + } + } + } + + @ParameterizedTest(name = "{0} -> {1}") + @MethodSource("pairs") + void importsQuery(VdbServer server, ItDatabase target) throws Exception { + assume(server, target); + try (VdbClient client = server.createClient()) { + VdbCollection collection = client.describe(server.getCollection()); + VdbQuery query = VdbQuery.all().where("n >= 6"); + long expected = 3; + // Counts of filters which the database can not count are -1 + long count = client.count(collection, query); + assertTrue(count == expected || count == -1, String.valueOf(count)); + VdbExportConfiguration configuration = configuration(server, target); + configuration.setQuery(query); + configuration.getMappings().set(0, new VdbExportConfiguration.Mapping(server.getCollection(), + server.getTableName() + "_q")); + VdbExportResult.TableResult result = export(server, target, configuration); + assertEquals(expected, result.getWritten()); + + VdbQuery search = new VdbQuery(); + search.setNearestField(collection.getVectorFields().get(0).getName()); + search.setNearestVector(VdbServer.getDocuments().get(0).embedding); + search.setLimit(3); + configuration.setQuery(search); + result = export(server, target, configuration); + assertEquals(3, result.getWritten()); + } + } + + static long count(Connection connection, VdbDialect dialect, ItDatabase target, VdbServer server, String where) + throws SQLException { + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery( + "SELECT COUNT(*) FROM " + dialect.getQualifiedTableName(getSchema(target), server.getTableName()) + + (where != null ? " WHERE " + where : ""))) { + rs.next(); + return rs.getLong(1); + } + } + + /** + * Numbers of the documents nearest to the first one by the SQL of the target, null if the target has no + * distance of vectors (MySQL Community) + */ + static List nearest(Connection connection, VdbDialect dialect, ItDatabase target, VdbServer server) + throws SQLException { + String table = dialect.getQualifiedTableName(getSchema(target), server.getTableName()); + String vector = dialect.getIdentifier(server == VdbServer.PINECONE ? "values" : "embedding"); + String n = dialect.getIdentifier("n"); + String filter = server == VdbServer.PINECONE ? " AND " + dialect.getIdentifier("namespace") + " = 'ns1'" + : ""; + String first = String.format("(SELECT %s FROM %s WHERE %s = 1%s)", vector, table, n, filter); + String where = filter.isEmpty() ? "" : " WHERE " + filter.substring(5); + String sql; + DbType type = target.getDbType(); + switch (type) { + case POSTGRESQL: + sql = String.format("SELECT %s FROM %s%s ORDER BY %s <=> %s LIMIT 3", n, table, where, vector, first); + break; + case MARIADB: + sql = String.format("SELECT %s FROM %s%s ORDER BY VEC_DISTANCE_COSINE(%s, %s) LIMIT 3", n, table, + where, vector, first); + break; + case ORACLE: + case DB2: + sql = String.format("SELECT %s FROM %s%s ORDER BY VECTOR_DISTANCE(%s, %s, COSINE) FETCH FIRST 3 ROWS" + + " ONLY", n, table, where, vector, first); + break; + case SQLSERVER: + sql = String.format("SELECT TOP 3 %s FROM %s%s ORDER BY VECTOR_DISTANCE('cosine', %s, %s)", n, table, + where, vector, first); + break; + case DUCKDB: + sql = String.format("SELECT %s FROM %s%s ORDER BY array_cosine_distance(%s, %s) LIMIT 3", n, table, + where, vector, first); + break; + case CLICKHOUSE: + sql = String.format("SELECT %s FROM %s%s ORDER BY cosineDistance(%s, %s) LIMIT 3", n, table, where, + vector, first); + break; + default: + return null; + } + List numbers = new ArrayList<>(); + try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) { + while (rs.next()) { + numbers.add(rs.getInt(1)); + } + } + return numbers; + } + + /** + * Drops the tables of the tests (sjvt_*) of a target + */ + static void dropTables(ItDatabase target) throws SQLException { + try (Connection connection = target.getTargetConnection()) { + VdbDialect dialect = VdbDialect.of(connection); + for (VdbServer server : VdbServer.values()) { + for (String table : new String[]{server.getTableName(), server.getTableName() + "_q", + server.getTableName() + "_e"}) { + try (Statement statement = connection.createStatement()) { + statement.execute(dialect.getDropTableStatement(getSchema(target), table)); + } + } + } + if (!connection.getAutoCommit()) { + connection.commit(); + } + } + } +} diff --git a/src/test/java/com/github/sqljam/vdb/it/VdbMappingIT.java b/src/test/java/com/github/sqljam/vdb/it/VdbMappingIT.java new file mode 100644 index 0000000..fee3b84 --- /dev/null +++ b/src/test/java/com/github/sqljam/vdb/it/VdbMappingIT.java @@ -0,0 +1,116 @@ +/* + * 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.vdb.it; + +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 static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import com.github.sqljam.it.ItDatabase; +import com.github.sqljam.vdb.VdbClient; +import com.github.sqljam.vdb.VdbExportConfiguration; +import com.github.sqljam.vdb.VdbExportResult; +import com.github.sqljam.vdb.VdbExporter; +import com.github.sqljam.vdb.VdbFieldMapping; +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbMappingIT imports the Qdrant fixture with the fields which the user mapped: a renamed payload + * field, a field with a type written as it is, and a skipped field. The rows are bound to the mapped + * columns, the vectors are searchable as before. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class VdbMappingIT { + + static final VdbServer SERVER = VdbServer.QDRANT; + static final ItDatabase TARGET = ItDatabase.DUCKDB; + static final String TABLE = "sjvt_mapped"; + + @BeforeAll + static void createFixture() throws Exception { + if (SERVER.isAvailable()) { + SERVER.createFixture(); + } + } + + @AfterAll + static void dropFixture() throws Exception { + if (SERVER.isAvailable()) { + SERVER.dropFixture(); + } + if (TARGET.isAvailable()) { + try (Connection connection = TARGET.getTargetConnection(); + Statement statement = connection.createStatement()) { + statement.execute("DROP TABLE IF EXISTS main." + TABLE); + } + } + } + + private static List values(String sql) throws Exception { + List values = new ArrayList<>(); + try (Connection connection = TARGET.getTargetConnection(); Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery(sql)) { + while (rs.next()) { + values.add(rs.getString(1)); + } + } + return values; + } + + @Test + void importsMappedFields() throws Exception { + assumeTrue(SERVER.isAvailable() && TARGET.isAvailable()); + VdbExportConfiguration configuration = new VdbExportConfiguration(); + configuration.setTargetSchema(VdbImportIT.getSchema(TARGET)); + configuration.addCollection(SERVER.getCollection(), TABLE); + configuration.setTableRecreated(true); + configuration.getMappings().get(0).getFields().put("title", new VdbFieldMapping("headline", null, false)); + configuration.getMappings().get(0).getFields().put("n", new VdbFieldMapping(null, "BIGINT", false)); + configuration.getMappings().get(0).getFields().put("tags", VdbFieldMapping.skip()); + VdbExportResult result; + try (VdbClient client = SERVER.createClient()) { + result = new VdbExporter(client, VdbImportIT.dataSource(TARGET), configuration).export(); + } + assertEquals(VdbServer.RECORDS, result.getWritten()); + String table; + try (Connection connection = TARGET.getTargetConnection()) { + table = VdbDialect.of(connection).getQualifiedTableName("main", TABLE); + } + List columns = values("SELECT column_name FROM information_schema.columns WHERE table_name = '" + TABLE + + "'"); + assertTrue(columns.contains("headline"), columns.toString()); + assertFalse(columns.contains("title") || columns.contains("tags"), columns.toString()); + assertEquals(List.of("BIGINT"), values("SELECT data_type FROM information_schema.columns WHERE table_name = '" + + TABLE + "' AND column_name = 'n'")); + // Each row is bound to its mapped column + assertEquals(VdbServer.getDocuments().get(1).title, values("SELECT headline FROM " + table + " WHERE n = 2") + .get(0)); + assertEquals(List.of(String.valueOf(VdbServer.RECORDS)), values("SELECT COUNT(*) FROM " + table + + " WHERE headline IS NOT NULL")); + } +} diff --git a/src/test/java/com/github/sqljam/vdb/it/VdbPartitionsIT.java b/src/test/java/com/github/sqljam/vdb/it/VdbPartitionsIT.java new file mode 100644 index 0000000..cc47a52 --- /dev/null +++ b/src/test/java/com/github/sqljam/vdb/it/VdbPartitionsIT.java @@ -0,0 +1,215 @@ +/* + * 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.vdb.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.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import com.github.sqljam.it.ItDatabase; +import com.github.sqljam.vdb.VdbClient; +import com.github.sqljam.vdb.VdbCollection; +import com.github.sqljam.vdb.VdbExportConfiguration; +import com.github.sqljam.vdb.VdbExportResult; +import com.github.sqljam.vdb.VdbExporter; +import com.github.sqljam.vdb.VdbQuery; +import com.github.sqljam.vdb.VdbSource; +import com.github.sqljam.vdb.VdbType; +import com.github.sqljam.vdb.dialect.VdbDialect; + +/** + * @Description: VdbPartitionsIT reads the levels and the parts of the vector databases which have them: databases of + * Milvus and Chroma, partitions of Milvus, tenants of Weaviate and namespaces of Pinecone. Parts are + * listed, counted, read and imported apart, all parts when a query names none. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +class VdbPartitionsIT { + + static final VdbServer[] SERVERS = {VdbServer.MILVUS, VdbServer.WEAVIATE, VdbServer.CHROMA, VdbServer.PINECONE}; + + @BeforeAll + static void createFixtures() throws Exception { + for (VdbServer server : SERVERS) { + if (server.isAvailable()) { + server.createFixture(); + server.createPartitionedFixture(); + } + } + } + + @AfterAll + static void dropFixtures() throws Exception { + for (VdbServer server : SERVERS) { + if (server.isAvailable()) { + server.dropPartitionedFixture(); + server.dropFixture(); + } + } + if (ItDatabase.DUCKDB.isAvailable()) { + try (Connection connection = ItDatabase.DUCKDB.getTargetConnection(); + Statement statement = connection.createStatement()) { + statement.execute("DROP TABLE IF EXISTS main.sjvt_parts"); + } + } + } + + static List numbers(VdbClient client, VdbCollection collection, VdbQuery query) { + List numbers = new ArrayList<>(); + client.read(collection, query, 2, (records, last) -> { + records.forEach(row -> numbers.add(((Number) row.get("n")).intValue())); + return true; + }); + numbers.sort(null); + return numbers; + } + + static VdbQuery partitions(String... partitions) { + VdbQuery query = new VdbQuery(); + query.getPartitions().addAll(List.of(partitions)); + return query; + } + + @Test + void readsTenantsOfWeaviate() { + VdbServer server = VdbServer.WEAVIATE; + assumeTrue(server.isAvailable()); + try (VdbClient client = server.createClient()) { + assertEquals(List.of("t1", "t2"), sorted(client.listPartitions("SjvtTenant"))); + assertEquals(List.of(), client.listPartitions(server.getCollection())); + VdbCollection collection = client.describe("SjvtTenant"); + assertEquals(List.of("t1", "t2"), sorted(client.getPartitions(collection))); + assertEquals(5, collection.getCount()); + assertEquals(List.of(1, 2, 3), numbers(client, collection, partitions("t1"))); + assertEquals(List.of(4, 5), numbers(client, collection, partitions("t2"))); + assertEquals(List.of(1, 2, 3, 4, 5), numbers(client, collection, VdbQuery.all())); + assertEquals(List.of(), numbers(client, collection, partitions("missing"))); + assertEquals(2, client.count(collection, partitions("t2"))); + // Filters and searches of a tenant + VdbQuery filtered = partitions("t1").where("n >= 2"); + assertEquals(List.of(2, 3), numbers(client, collection, filtered)); + assertEquals(2, client.count(collection, filtered)); + VdbQuery nearest = partitions("t2"); + nearest.setNearestField("embedding"); + nearest.setNearestVector(VdbServer.getDocuments().get(0).embedding); + nearest.setLimit(5); + assertEquals(List.of(4, 5), numbers(client, collection, nearest)); + } + } + + @Test + void readsPartitionsAndDatabasesOfMilvus() { + VdbServer server = VdbServer.MILVUS; + assumeTrue(server.isAvailable()); + try (VdbClient client = server.createClient()) { + assertTrue(client.getDatabases().contains("default"), client.getDatabases().toString()); + assertEquals(List.of("_default", "p1", "p2"), sorted(client.listPartitions("sjvt_part"))); + VdbCollection collection = client.describe("sjvt_part"); + assertEquals(8, collection.getCount()); + assertEquals(List.of(1, 2, 3, 4), numbers(client, collection, partitions("p1"))); + assertEquals(4, client.count(collection, partitions("p2"))); + assertEquals(List.of(5, 6, 7, 8), numbers(client, collection, partitions("p2"))); + assertEquals(List.of(7, 8), numbers(client, collection, partitions("p2").where("n >= 7"))); + VdbQuery nearest = partitions("p1"); + nearest.setNearestField("embedding"); + nearest.setNearestVector(VdbServer.getDocuments().get(7).embedding); + nearest.setLimit(8); + assertEquals(List.of(1, 2, 3, 4), numbers(client, collection, nearest)); + } + // A client of the database default reads the same collections + VdbSource source = server.getSource().withDatabase("default"); + try (VdbClient client = VdbType.MILVUS.createClient(source)) { + assertTrue(client.getCollections().contains("sjvt_part")); + } + } + + @Test + void readsDatabasesOfChroma() { + VdbServer server = VdbServer.CHROMA; + assumeTrue(server.isAvailable()); + try (VdbClient client = server.createClient()) { + List databases = client.getDatabases(); + assertTrue(databases.contains("default_database") && databases.contains("sjvt_db2"), + databases.toString()); + assertTrue(client.getCollections().contains(server.getCollection())); + assertTrue(!client.getCollections().contains("sjvt_doc2")); + } + try (VdbClient client = VdbType.CHROMA.createClient(server.getSource().withDatabase("sjvt_db2"))) { + assertEquals(List.of("sjvt_doc2"), client.getCollections()); + VdbCollection collection = client.describe("sjvt_doc2"); + assertEquals(List.of(1, 2), numbers(client, collection, VdbQuery.all())); + assertEquals(List.of(), client.getPartitions(collection)); + } + } + + @Test + void readsNamespacesOfPinecone() { + VdbServer server = VdbServer.PINECONE; + assumeTrue(server.isAvailable()); + try (VdbClient client = server.createClient()) { + assertEquals(List.of("__default__", "ns1"), sorted(client.listPartitions(server.getCollection()))); + VdbCollection collection = client.describe(server.getCollection()); + assertEquals(List.of(1, 2), numbers(client, collection, partitions("__default__"))); + assertEquals(2, client.count(collection, partitions("__default__"))); + assertEquals(8, numbers(client, collection, partitions("ns1")).size()); + assertEquals(10, client.count(collection, VdbQuery.all())); + } + } + + /** + * Rows of a tenant are imported with their tenant + */ + @Test + void importsTenant() throws Exception { + VdbServer server = VdbServer.WEAVIATE; + ItDatabase target = ItDatabase.DUCKDB; + assumeTrue(server.isAvailable() && target.isAvailable()); + VdbExportConfiguration configuration = new VdbExportConfiguration(); + configuration.setTargetSchema(VdbImportIT.getSchema(target)); + configuration.addCollection("SjvtTenant", "sjvt_parts"); + configuration.setTableRecreated(true); + configuration.setQuery(partitions("t2")); + VdbExportResult result; + try (VdbClient client = server.createClient()) { + result = new VdbExporter(client, VdbImportIT.dataSource(target), configuration).export(); + } + assertEquals(2, result.getWritten()); + assertEquals(2, result.getTables().get(0).getTotal()); + try (Connection connection = target.getTargetConnection(); Statement statement = connection + .createStatement(); ResultSet rs = statement.executeQuery("SELECT tenant, COUNT(*) FROM " + + VdbDialect.of(connection).getQualifiedTableName("main", "sjvt_parts") + " GROUP BY tenant")) { + assertTrue(rs.next()); + assertEquals("t2", rs.getString(1)); + assertEquals(2, rs.getInt(2)); + } + } + + private static List sorted(List names) { + List copy = new ArrayList<>(names); + copy.sort(null); + return copy; + } +} diff --git a/src/test/java/com/github/sqljam/vdb/it/VdbServer.java b/src/test/java/com/github/sqljam/vdb/it/VdbServer.java new file mode 100644 index 0000000..7743719 --- /dev/null +++ b/src/test/java/com/github/sqljam/vdb/it/VdbServer.java @@ -0,0 +1,536 @@ +/* + * 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.vdb.it; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; + +import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.sqljam.utils.RestClient; +import com.github.sqljam.utils.RestException; +import com.github.sqljam.vdb.VdbClient; +import com.github.sqljam.vdb.VdbSource; +import com.github.sqljam.vdb.VdbType; + +/** + * @Description: VdbServer is a vector database of the integration tests of the vdb line (it-vdb-test.properties) + * with its fixture: a collection of the 8 documents of vector/embeddings.tsv, their bge-m3 embeddings + * (1024 dimensions, an HNSW index of the cosine distance), their qwen3-embedding embeddings (2560 + * dimensions), a title, a number, a score, tags and a flag. Milvus and Qdrant have sparse vectors too. + * @Author: Fred Feng + * @Date: 24/10/2026 + * @Version 1.0.0 + */ +public enum VdbServer { + + MILVUS(VdbType.MILVUS, "sjvt_doc"), + QDRANT(VdbType.QDRANT, "sjvt_doc"), + WEAVIATE(VdbType.WEAVIATE, "SjvtDoc"), + PINECONE(VdbType.PINECONE, "sjvt-doc"), + ELASTICSEARCH(VdbType.ELASTICSEARCH, "sjvt_doc"), + OPENSEARCH(VdbType.OPENSEARCH, "sjvt_doc"), + CHROMA(VdbType.CHROMA, "sjvt_doc"); + + /** + * Records of the fixture, Pinecone has 2 more in its default namespace + */ + public static final int RECORDS = 8; + static final ObjectMapper MAPPER = RestClient.getMapper(); + + private final VdbType type; + private final String collection; + + VdbServer(VdbType type, String collection) { + this.type = type; + this.collection = collection; + } + + /** + * A holder class, because static fields of an enum are not set yet when its constants are created + */ + static final class Settings { + + private static final Properties PROPERTIES = new Properties(); + private static final Map AVAILABLE = new EnumMap<>(VdbServer.class); + + static { + try (InputStream in = VdbServer.class.getResourceAsStream("/it-vdb-test.properties")) { + PROPERTIES.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + static String get(VdbServer server, String key) { + String name = server.name().toLowerCase(Locale.ENGLISH) + "." + key; + return StringUtils.trimToNull(System.getProperty(name, PROPERTIES.getProperty(name))); + } + } + + /** + * A document of the fixture + */ + public static final class Document { + public final int id; + public final String title; + public final float[] embedding; + public final float[] embeddingLarge; + + Document(int id, String title, float[] embedding, float[] embeddingLarge) { + this.id = id; + this.title = title; + this.embedding = embedding; + this.embeddingLarge = embeddingLarge; + } + } + + private static List documents; + + public static synchronized List getDocuments() { + if (documents == null) { + List list = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(VdbServer.class.getResourceAsStream( + "/vector/embeddings.tsv"), StandardCharsets.UTF_8))) { + for (String line = reader.readLine(); line != null; line = reader.readLine()) { + if (line.startsWith("#") || line.isBlank()) { + continue; + } + String[] parts = line.split("\t"); + list.add(new Document(Integer.parseInt(parts[0]), parts[1], MAPPER.readValue(parts[2], + float[].class), MAPPER.readValue(parts[3], float[].class))); + } + } catch (IOException e) { + throw new IllegalStateException(e); + } + documents = list; + } + return documents; + } + + public VdbType getType() { + return type; + } + + public String getCollection() { + return collection; + } + + /** + * Name of the target table of the fixture, e.g. sjvt_qdrant + */ + public String getTableName() { + return "sjvt_" + name().toLowerCase(Locale.ENGLISH); + } + + public boolean isConfigured() { + return Settings.get(this, "url") != null; + } + + public VdbSource getSource() { + return getSource(Settings.get(this, "apiKey")); + } + + public VdbSource getSource(String apiKey) { + VdbSource source = new VdbSource(type, Settings.get(this, "url")); + source.setApiKey(apiKey); + source.setUsername(Settings.get(this, "username")); + source.setPassword(Settings.get(this, "password")); + source.setCertificateTrusted(true); + return source; + } + + public String getSetting(String key) { + return Settings.get(this, key); + } + + public VdbClient createClient() { + return type.createClient(getSource()); + } + + public synchronized boolean isAvailable() { + return Settings.AVAILABLE.computeIfAbsent(this, server -> { + if (!isConfigured()) { + return false; + } + try (VdbClient client = createClient()) { + client.getCollections(); + return true; + } catch (RuntimeException e) { + System.err.println("Vector database is not available: " + this + ", " + e.getMessage()); + return false; + } + }); + } + + public RestClient rest() { + RestClient rest = new RestClient(Settings.get(this, "url"), true, 120); + String apiKey = Settings.get(this, "apiKey"); + switch (type) { + case MILVUS: + rest.header("Authorization", "Bearer " + Settings.get(this, "username") + ":" + + Settings.get(this, "password")); + break; + case QDRANT: + rest.header("api-key", apiKey); + break; + case PINECONE: + rest.header("Api-Key", apiKey); + rest.header("X-Pinecone-API-Version", "2025-01"); + break; + case ELASTICSEARCH: + case OPENSEARCH: + rest.header("Authorization", "Basic " + java.util.Base64.getEncoder().encodeToString( + (Settings.get(this, "username") + ":" + Settings.get(this, "password")) + .getBytes(StandardCharsets.UTF_8))); + break; + default: + rest.header("Authorization", "Bearer " + apiKey); + break; + } + return rest; + } + + private static Map map(Object... entries) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < entries.length; i += 2) { + map.put((String) entries[i], entries[i + 1]); + } + return map; + } + + private static Map payload(Document document) { + return map("title", document.title, "n", document.id, "score", document.id / 2.0, "tags", List.of("t" + + document.id, "x"), "active", document.id % 2 == 0); + } + + /** + * Creates the fixture collection again + */ + public void createFixture() throws Exception { + dropFixture(); + RestClient rest = rest(); + List docs = getDocuments(); + switch (type) { + case MILVUS: + milvus(rest, "/v2/vectordb/collections/create", map("collectionName", collection, "schema", map( + "autoId", false, "enableDynamicField", true, "fields", List.of( + map("fieldName", "id", "dataType", "Int64", "isPrimary", true), + map("fieldName", "title", "dataType", "VarChar", "elementTypeParams", + map("max_length", 512)), + map("fieldName", "embedding", "dataType", "FloatVector", "elementTypeParams", + map("dim", 1024)), + map("fieldName", "embedding_large", "dataType", "Float16Vector", + "elementTypeParams", map("dim", 2560)), + map("fieldName", "sparse", "dataType", "SparseFloatVector"), + map("fieldName", "n", "dataType", "Int32"), + map("fieldName", "score", "dataType", "Double", "nullable", true), + map("fieldName", "tags", "dataType", "Array", "elementDataType", "VarChar", + "elementTypeParams", map("max_capacity", 8, "max_length", 32)), + map("fieldName", "active", "dataType", "Bool"))), + "indexParams", List.of( + map("fieldName", "embedding", "indexName", "embedding_hnsw", "metricType", "COSINE", + "indexType", "HNSW", "params", map("M", 16, "efConstruction", 64)), + map("fieldName", "embedding_large", "indexName", "large_ivf", "metricType", "L2", + "indexType", "IVF_FLAT", "params", map("nlist", 4)), + map("fieldName", "sparse", "indexName", "sparse_idx", "metricType", "IP", + "indexType", "SPARSE_INVERTED_INDEX")))); + List rows = new ArrayList<>(); + for (Document document : docs) { + Map row = map("id", document.id, "embedding", document.embedding, + "embedding_large", document.embeddingLarge, "sparse", map(String.valueOf(document.id), + 1.0, String.valueOf(document.id + 10), 0.5), "extra_dynamic", "dyn" + document.id); + row.putAll(payload(document)); + row.put("score", document.id % 3 == 0 ? null : document.id / 2.0); + rows.add(row); + } + milvus(rest, "/v2/vectordb/entities/insert", map("collectionName", collection, "data", rows)); + break; + case QDRANT: + rest.put("/collections/" + collection, map("vectors", map( + "embedding", map("size", 1024, "distance", "Cosine", "hnsw_config", map("m", 16, + "ef_construct", 64)), + "embedding_large", map("size", 2560, "distance", "Euclid", "datatype", "float16")), + "sparse_vectors", map("keywords", map()))); + List points = new ArrayList<>(); + for (Document document : docs) { + Map vectors = map("embedding", document.embedding, "keywords", map("indices", + List.of(document.id, document.id + 10), "values", List.of(1.0, 0.5))); + // A point without a vector of the collection + if (document.id != RECORDS) { + vectors.put("embedding_large", document.embeddingLarge); + } + points.add(map("id", document.id == 3 ? new UUID(0, 3).toString() : document.id, "vector", + vectors, "payload", payload(document))); + } + rest.put("/collections/" + collection + "/points?wait=true", map("points", points)); + break; + case WEAVIATE: + rest.post("/v1/schema", map("class", collection, "description", "Documents of SqlJam", + "vectorConfig", map( + "embedding", map("vectorizer", map("none", map()), "vectorIndexType", "hnsw", + "vectorIndexConfig", map("distance", "cosine", "maxConnections", 16, + "efConstruction", 64)), + "embedding_large", map("vectorizer", map("none", map()), "vectorIndexType", + "flat", "vectorIndexConfig", map("distance", "l2-squared"))), + "properties", List.of(map("name", "title", "dataType", List.of("text")), + map("name", "n", "dataType", List.of("int")), + map("name", "score", "dataType", List.of("number")), + map("name", "tags", "dataType", List.of("text[]")), + map("name", "active", "dataType", List.of("boolean")), + map("name", "published", "dataType", List.of("date"))))); + List objects = new ArrayList<>(); + for (Document document : docs) { + Map properties = payload(document); + properties.put("published", String.format("2024-%02d-01T08:30:00Z", document.id)); + objects.add(map("class", collection, "id", new UUID(0, document.id).toString(), "properties", + properties, "vectors", map("embedding", document.embedding, "embedding_large", + document.embeddingLarge))); + } + rest.post("/v1/batch/objects", map("objects", objects)); + break; + case PINECONE: + rest.post("/indexes", map("name", collection, "dimension", 1024, "metric", "cosine", "spec", + map("serverless", map("cloud", "aws", "region", "us-east-1")))); + String host = pineconeHost(rest); + List vectors = new ArrayList<>(); + for (Document document : docs) { + vectors.add(map("id", "doc-" + document.id, "values", document.embedding, "metadata", + payload(document))); + } + rest.post(host + "/vectors/upsert", map("vectors", vectors, "namespace", "ns1")); + rest.post(host + "/vectors/upsert", map("vectors", vectors.subList(0, 2))); + break; + case ELASTICSEARCH: + case OPENSEARCH: + boolean opensearch = type == VdbType.OPENSEARCH; + Map embedding = opensearch ? map("type", "knn_vector", "dimension", 1024, "method", + map("name", "hnsw", "engine", "faiss", "space_type", "cosinesimil", "parameters", map("m", 16, + "ef_construction", 64))) : map("type", "dense_vector", "dims", 1024, "index", true, + "similarity", "cosine", "index_options", map("type", "hnsw", "m", 16, "ef_construction", 64)); + Map large = opensearch ? map("type", "knn_vector", "dimension", 2560, "method", + map("name", "hnsw", "engine", "lucene", "space_type", "l2")) : map("type", "dense_vector", + "dims", 2560, "index", true, "similarity", "l2_norm"); + Map index = map("mappings", map("properties", map("title", map("type", "text"), + "n", map("type", "integer"), "score", map("type", "double"), "tags", map("type", "keyword"), + "active", map("type", "boolean"), "embedding", embedding, "embedding_large", large))); + if (opensearch) { + index.put("settings", map("index.knn", true)); + } + rest.put("/" + collection, index); + StringBuilder bulk = new StringBuilder(); + for (Document document : docs) { + Map source = payload(document); + source.put("embedding", document.embedding); + if (document.id != RECORDS) { + source.put("embedding_large", document.embeddingLarge); + } + bulk.append(MAPPER.writeValueAsString(map("index", map("_id", String.valueOf(document.id))))) + .append('\n').append(MAPPER.writeValueAsString(source)).append('\n'); + } + rest.post("/" + collection + "/_bulk?refresh=true", bulk.toString()); + break; + default: + String base = "/api/v2/tenants/default_tenant/databases/default_database/collections"; + JsonNode created = rest.post(base, map("name", collection, "metadata", map("description", + "Documents of SqlJam"), "configuration", map("hnsw", map("space", "cosine", + "ef_construction", 100, "max_neighbors", 16)))); + List ids = new ArrayList<>(); + List embeddings = new ArrayList<>(); + List metadatas = new ArrayList<>(); + List texts = new ArrayList<>(); + for (Document document : docs) { + ids.add(String.valueOf(document.id)); + embeddings.add(document.embedding); + Map metadata = payload(document); + metadata.remove("tags"); + metadatas.add(metadata); + texts.add(document.title); + } + rest.post(base + "/" + created.path("id").asText() + "/add", map("ids", ids, "embeddings", embeddings, + "metadatas", metadatas, "documents", texts)); + break; + } + } + + private void milvus(RestClient rest, String path, Object body) { + JsonNode response = rest.post(path, body); + if (response.path("code").asInt(0) != 0) { + throw new IllegalStateException(path + ": " + response); + } + } + + /** + * Host of the Pinecone index, localhost of Pinecone Local is the host of the url + */ + String pineconeHost(RestClient rest) { + String host = rest.get("/indexes/" + collection).path("host").asText(); + String url = Settings.get(this, "url"); + String server = StringUtils.substringBefore(StringUtils.substringAfter(url, "://"), ":"); + return StringUtils.substringBefore(url, "://") + "://" + host.replace("localhost", server); + } + + /** + * Name of the collection of the parts of a collection which are read apart: a Weaviate class of the tenants t1 + * (records 1 to 3) and t2 (records 4 and 5), a Milvus collection of the partitions p1 (records 1 to 4) and p2 + * (records 5 to 8), and a Chroma collection in the database sjvt_db2 (records 1 and 2). Pinecone has the + * namespaces ns1 and __default__ of its fixture, the others have none. + */ + public String getPartitionedCollection() { + switch (type) { + case WEAVIATE: + return "SjvtTenant"; + case MILVUS: + return "sjvt_part"; + case CHROMA: + return "sjvt_doc2"; + case PINECONE: + return collection; + default: + return null; + } + } + + /** + * Creates the collection of parts again + */ + public void createPartitionedFixture() throws Exception { + dropPartitionedFixture(); + RestClient rest = rest(); + List docs = getDocuments(); + switch (type) { + case WEAVIATE: + rest.post("/v1/schema", map("class", "SjvtTenant", "multiTenancyConfig", map("enabled", true), + "vectorConfig", map("embedding", map("vectorizer", map("none", map()), "vectorIndexType", + "hnsw", "vectorIndexConfig", map("distance", "cosine"))), + "properties", List.of(map("name", "n", "dataType", List.of("int"))))); + rest.post("/v1/schema/SjvtTenant/tenants", List.of(map("name", "t1"), map("name", "t2"))); + List objects = new ArrayList<>(); + for (Document document : docs.subList(0, 5)) { + objects.add(map("class", "SjvtTenant", "tenant", document.id <= 3 ? "t1" : "t2", "id", + new UUID(0, document.id).toString(), "properties", map("n", document.id), "vectors", + map("embedding", document.embedding))); + } + rest.post("/v1/batch/objects", map("objects", objects)); + break; + case MILVUS: + milvus(rest, "/v2/vectordb/collections/create", map("collectionName", "sjvt_part", "schema", map( + "autoId", false, "fields", List.of( + map("fieldName", "id", "dataType", "Int64", "isPrimary", true), + map("fieldName", "n", "dataType", "Int32"), + map("fieldName", "embedding", "dataType", "FloatVector", "elementTypeParams", + map("dim", 1024)))), + "indexParams", List.of(map("fieldName", "embedding", "indexName", "embedding_hnsw", + "metricType", "COSINE", "indexType", "HNSW")))); + for (String partition : List.of("p1", "p2")) { + milvus(rest, "/v2/vectordb/partitions/create", map("collectionName", "sjvt_part", + "partitionName", partition)); + List rows = new ArrayList<>(); + for (Document document : docs) { + if ((document.id <= 4) == partition.equals("p1")) { + rows.add(map("id", document.id, "n", document.id, "embedding", document.embedding)); + } + } + milvus(rest, "/v2/vectordb/entities/insert", map("collectionName", "sjvt_part", + "partitionName", partition, "data", rows)); + } + break; + case CHROMA: + String databases = "/api/v2/tenants/default_tenant/databases"; + rest.post(databases, map("name", "sjvt_db2")); + JsonNode created = rest.post(databases + "/sjvt_db2/collections", map("name", "sjvt_doc2")); + rest.post(databases + "/sjvt_db2/collections/" + created.path("id").asText() + "/add", map("ids", + List.of("1", "2"), "embeddings", List.of(docs.get(0).embedding, docs.get(1).embedding), + "metadatas", List.of(map("n", 1), map("n", 2)))); + break; + default: + break; + } + } + + /** + * Drops the collection of parts, a missing one is fine + */ + public void dropPartitionedFixture() { + RestClient rest = rest(); + try { + switch (type) { + case WEAVIATE: + rest.delete("/v1/schema/SjvtTenant"); + break; + case MILVUS: + rest.post("/v2/vectordb/collections/drop", map("collectionName", "sjvt_part")); + break; + case CHROMA: + rest.delete("/api/v2/tenants/default_tenant/databases/sjvt_db2"); + break; + default: + break; + } + } catch (RestException e) { + if (e.getStatus() != 404 && e.getStatus() != 400 && e.getStatus() != 422) { + throw e; + } + } + } + + /** + * Drops the fixture collection, a missing collection is fine + */ + public void dropFixture() { + RestClient rest = rest(); + try { + switch (type) { + case MILVUS: + rest.post("/v2/vectordb/collections/drop", map("collectionName", collection)); + break; + case QDRANT: + rest.delete("/collections/" + collection); + break; + case WEAVIATE: + rest.delete("/v1/schema/" + collection); + break; + case PINECONE: + rest.delete("/indexes/" + collection); + break; + case ELASTICSEARCH: + case OPENSEARCH: + rest.delete("/" + collection); + break; + default: + rest.delete("/api/v2/tenants/default_tenant/databases/default_database/collections/" + + collection); + break; + } + } catch (RestException e) { + if (e.getStatus() != 404 && e.getStatus() != 400) { + throw e; + } + } + } +} diff --git a/src/test/resources/it-databases-test.properties b/src/test/resources/it-databases-test.properties index be28f8b..9de7338 100644 --- a/src/test/resources/it-databases-test.properties +++ b/src/test/resources/it-databases-test.properties @@ -10,20 +10,21 @@ # targetCatalog, targetSchema where imports go # MySQL runs on the local machine in both environments -mysql.host=localhost -mysql.port=3306 -mysql.username=fengy -mysql.password=12345678 -mysql.sourceCatalog=test -mysql.targetCatalog=test_test +mysql.host=192.168.0.63 +mysql.port=13306 +mysql.username=admin +mysql.password=admin123 +mysql.database=sjit_src +mysql.sourceCatalog=sjit_src +mysql.targetCatalog=sjit_dst mariadb.host=192.168.0.63 mariadb.port=13307 mariadb.username=admin mariadb.password=admin123 -mariadb.database=demo -mariadb.sourceCatalog=demo -mariadb.targetCatalog=demo +mariadb.database=sjit_src +mariadb.sourceCatalog=sjit_src +mariadb.targetCatalog=sjit_dst postgresql.host=192.168.0.63 postgresql.port=15432 diff --git a/src/test/resources/it-vdb-test.properties b/src/test/resources/it-vdb-test.properties new file mode 100644 index 0000000..e2fce91 --- /dev/null +++ b/src/test/resources/it-vdb-test.properties @@ -0,0 +1,28 @@ +# Vector databases of the integration tests of the vdb line: the servers of 192.168.0.63 (the default). +# A system property of the same name overrides a key, e.g. mvn verify -Dqdrant.url=http://localhost:6333 +# A vector database without settings or unreachable is skipped. Fixture collections are named sjvt_doc +# (SjvtDoc of Weaviate, sjvt-doc of Pinecone), they are created before the tests and dropped after them. +milvus.url=http://192.168.0.63:29530 +milvus.username=admin +milvus.password=admin123 + +qdrant.url=http://192.168.0.63:16333 +qdrant.apiKey=admin123 +qdrant.readonlyKey=readonly123 + +weaviate.url=http://192.168.0.63:18080 +weaviate.apiKey=admin123 + +pinecone.url=http://192.168.0.63:15080 +pinecone.apiKey=admin123 + +elasticsearch.url=http://192.168.0.63:19200 +elasticsearch.username=admin +elasticsearch.password=admin123 + +opensearch.url=https://192.168.0.63:19201 +opensearch.username=admin +opensearch.password=admin123 + +chroma.url=http://192.168.0.63:18000 +chroma.apiKey=admin123 diff --git a/src/test/resources/vector/embeddings.tsv b/src/test/resources/vector/embeddings.tsv new file mode 100644 index 0000000..72f00b0 --- /dev/null +++ b/src/test/resources/vector/embeddings.tsv @@ -0,0 +1,9 @@ +# Embeddings of bge-m3 (1024 dimensions) and qwen3-embedding:4b (2560 dimensions), generated by Ollama on 24/10/2026 +1 SqlJam copies tables between databases [-0.015943345,0.021567784,-0.044217415,0.02853222,-0.02990997,0.024123695,-0.010360499,-0.023499977,-0.022431958,-0.013888504,0.0052159075,0.04221941,-0.017939372,0.01939214,0.040896777,0.0030800246,0.0047457493,-0.012629553,-0.03459049,-0.014263226,0.011468685,-0.01746362,0.06316758,0.016131764,0.010245642,-0.021819087,0.0009354211,-0.02950241,0.025350768,0.0022396902,0.052988764,-0.027924292,0.039915677,-0.0058184657,-0.01547732,-0.005876734,0.007784501,-0.0017276666,-0.027337207,0.0020023645,-0.030643273,-0.014486177,0.040369917,-0.07526694,0.022732496,0.0040355255,-0.0005296887,-0.0069831833,-0.03624,0.02310893,-0.036178447,-0.0019769412,0.04013207,0.0009294423,0.08080367,0.033498306,-0.04205313,-0.017429743,-0.038515355,0.0038447361,0.022416813,-0.007192083,-0.029679378,-0.017888589,0.012671058,0.10127183,0.031017086,0.03480538,-0.04226395,-0.033446696,-0.03634513,0.01122384,0.026067419,-0.024486315,-0.016258145,0.0075536077,0.027402977,0.0031397822,0.01790548,0.027144717,0.050828643,0.024328586,0.009647015,0.002940839,-0.048132256,0.09120526,-0.0350198,0.06298571,-0.01763904,-0.002554087,0.028235685,-0.021757089,0.05704932,-0.015349647,-0.048484944,-0.00857485,-0.02067936,0.02690654,0.02432909,0.025836088,-0.009645256,-0.007491417,-0.011370202,-0.023770494,0.050315715,-0.021206422,0.023713863,-0.033808775,0.014621087,-0.0153396055,0.027604008,0.016581055,0.024354238,-0.0033186974,-0.047290213,0.014197279,-0.028615154,-0.017821308,0.0352788,0.0014527828,-0.029467251,-0.043666217,0.07683727,-0.033586103,-0.0015212484,-0.048188806,0.021427792,0.051218197,-0.02079619,-0.0093959,0.011917588,0.05619996,0.04102416,-0.0004758089,-0.027272541,-0.03560704,0.025160437,-0.013877653,-0.008427567,-0.03856447,0.0118232025,-0.010802201,0.012924575,-0.07296659,0.0018359261,-0.0044957628,0.039744318,-0.007717249,-0.004335284,-0.020227402,-0.022592524,0.0032289259,0.01980717,-0.029793838,-0.020351628,-0.054589115,-0.0003164904,0.020911584,0.006813196,0.017154139,0.008402872,0.02043813,-0.020061197,0.015106034,0.04584452,0.031248474,0.051628,-0.00024927687,0.0052987016,-0.035417948,0.002248774,-0.0038058965,0.04524008,0.0044796807,0.030066349,0.022670763,0.07481579,0.01829305,-0.006567751,-0.038803168,-0.030885989,-0.025002236,-0.017617462,-0.024419863,-0.035585813,0.004420168,-0.011013264,-0.011326157,-0.005442045,0.000101798636,-0.029919052,-0.055855747,0.02363997,0.0009300048,0.025791982,-0.012498353,0.0058424943,0.013464431,0.022756359,-0.023919478,-0.023861066,0.046758197,-0.016040616,0.0023838466,-0.043095298,-0.0009866067,-0.013413693,-0.03731644,-0.01887143,0.018126275,0.044298623,0.021124369,-0.019530237,0.025189595,-0.012413788,-0.038398463,-0.0069675758,0.051534716,-0.041230485,-0.018512646,0.0013603704,-0.007306441,0.0014267399,-0.00600929,-0.00012324432,-0.008777911,0.023859916,0.073040076,0.0049663777,-0.03194157,-0.0959278,0.0044361698,0.0015621966,0.014588236,0.010541056,-0.005921043,-0.05822876,0.02871109,0.02492683,0.026115108,-0.06517888,0.0013530178,-0.034868956,-0.00016624661,-0.002105639,-0.006453844,-0.0013280291,0.0019198122,-0.021968836,-0.01785351,0.009666122,0.009577695,-0.04867049,-0.007585209,0.036981095,0.058396112,0.012506705,0.018414456,0.050847363,0.0150309075,0.021566713,-0.00017731516,0.028234731,0.016249051,0.007957033,-0.028004343,-0.044208594,-0.01029311,0.008072305,0.023040121,-0.061830815,0.01909363,-0.0010502259,0.014356516,0.029955251,0.034830265,-0.03236059,0.006332693,0.04087062,0.030639146,0.0023762118,0.018109722,0.024488816,0.016400091,0.023473294,0.023740277,0.027214862,0.0072966507,-0.015715158,-0.035050847,0.04244142,-0.03542371,0.048665285,-0.03378846,-0.025840426,0.018968932,-0.03302088,-0.13739058,0.03236483,0.007066233,-0.02473556,0.011178105,-0.010706658,-0.04104443,-0.0019555744,-0.035700973,0.08518671,0.01996111,-0.0636576,-0.060824446,-0.05732875,0.05211015,-0.0067849797,-0.008883004,-0.048860434,0.0033598386,-0.042682294,-0.039548583,-0.043295402,0.004552434,0.006001984,0.018710297,-0.060708527,0.028551834,0.04877579,-0.0072430894,-0.0382133,0.025620403,-0.00027106816,-0.0015282681,0.017577471,-0.0122668855,0.013163277,0.024899866,-0.05239482,-0.03557059,-0.028978271,0.01434515,0.029016636,-0.04713862,0.03066285,0.021354474,-0.033855323,-0.01295385,-0.0030976473,-0.04519316,0.002433771,-0.02464724,-0.021993335,0.049081326,-0.038375136,-0.014069569,0.0021562958,-0.05979903,0.016532568,-0.01547907,0.0057678507,-0.006722064,0.04439625,-0.0077007576,0.0071800128,-0.003090223,0.015101795,0.035939604,-0.024320215,0.03233635,-0.01050041,0.07020132,0.015470838,-0.042682555,0.024957577,-0.033980384,-0.010159116,-0.025820827,-0.019794548,-0.008873991,-0.09029754,-0.0060343994,-0.019868104,0.046316545,0.03390108,-0.024794636,0.053652763,-0.0072888527,0.0137832295,0.040675674,0.19588992,0.0025501493,-0.06919822,-0.013726734,0.0077811936,-0.0002060199,0.022911904,0.013785648,-0.015882375,-0.04661644,-0.0021308106,-0.0033013697,-0.0128189465,-0.026938887,-0.010482182,0.049889892,-0.050803926,-0.0027278676,0.05325613,-0.010271752,0.012578276,-0.009105728,-0.011315474,0.037333086,-0.017627537,-0.023647165,-0.04544805,-0.016473336,-0.030745802,-0.018116225,-0.05472571,0.0021332125,0.055690493,0.0059355926,-0.026043575,0.013745376,-0.026898853,-0.014072223,-0.0114497915,0.043277055,-0.022969494,-0.020605257,-0.014013922,0.06465494,-0.0081401095,-0.023937186,-0.0055893385,0.041113384,-0.03668684,-0.009598873,-0.054741506,-0.04555433,-0.016758842,0.01903529,0.017004745,-0.034958635,0.0029756702,0.013921176,-0.013711873,-0.0071123117,0.043638855,0.008036228,-0.004906607,0.060791176,0.021747738,0.010145835,0.03454299,0.024565611,0.0024649934,0.029535105,0.060636025,-0.019303136,0.025314983,-0.025815584,0.0035816177,0.04288796,-0.04718076,0.060903843,0.028561518,0.013864906,-0.0077166357,0.019898819,-0.01716381,0.023647953,0.045516163,-0.00452531,-0.041595053,0.034898847,0.0060315747,-0.013181683,0.03270542,-0.025767084,-0.017850384,0.00080840447,0.017220756,0.033731397,-0.024242405,-0.028306212,-0.035133258,0.05099785,-0.0064832033,-0.07482658,-0.020368975,-0.02798454,0.011055369,-0.009457856,-0.018467842,-0.032168478,0.017703708,-0.024168372,0.01940154,0.05412097,-0.045340136,-0.025614163,0.0029256374,0.031921532,-0.007119648,0.06245898,0.011683684,0.042186424,-0.02958304,-0.010135185,-0.012093439,0.0067535704,0.030535666,-0.015594909,-0.043249987,-0.0062107723,-0.042838667,0.050944827,0.004319492,-0.038332373,-0.006458696,0.015090284,0.0067598824,-0.014803577,0.0026904934,0.017820317,-0.017028835,0.014911845,-0.055574633,-0.007956456,0.00250434,0.03261776,-0.007991072,-0.022363983,0.019288193,-0.0197117,0.00054672145,0.013081909,-0.0036022598,0.07712244,-0.06151599,-0.0851891,-0.015158388,0.032339584,-0.03614537,0.042247403,0.020579282,-0.017844489,-0.015647212,0.013527915,0.006566349,0.0038985678,0.026305443,0.014278587,0.0050196103,0.05677292,-0.0063415836,-0.012870991,-0.04011842,-0.024563918,-0.02961481,0.048054583,0.07360181,-0.009963911,-0.08784738,-0.014753648,-0.033906266,-0.010577288,0.069757804,-0.017286403,-0.041713815,-0.00849311,-0.011901634,0.10474119,-0.006300173,-0.0013116293,-0.0011303255,-0.009736783,-0.036495067,0.10574723,-0.026524624,-0.019373318,0.014082592,0.018813062,-0.015655652,0.0006734452,-0.00949876,-0.030224986,0.021710195,-0.040465176,0.02200775,-0.062347244,0.007374293,0.037407707,-0.009570076,-0.011697984,-0.009563421,-0.036811385,-0.015238296,0.0061442214,-0.05796881,0.014896454,0.02924569,-0.03890224,0.061545074,0.008342391,0.011736742,-0.030082025,0.0029611832,-0.007887484,0.03741861,-0.02898458,-0.034348104,-0.033129334,0.030236466,-0.028177798,0.008402391,0.025008297,0.0043156,-0.0013535562,-0.015872527,-0.018288448,0.040187623,0.011586931,0.029585226,0.021644266,0.017165983,-0.047691338,0.0064653326,-0.047885623,0.030153375,0.033874538,-0.005577824,-0.040724434,-0.004016546,0.055783384,0.003758628,0.035886712,-0.013060973,0.05263653,0.026334714,-0.012856307,0.0020599768,0.015965296,0.0016793592,-0.002211936,-0.04659661,-0.032077726,-0.019364845,0.004620159,-0.04745799,-0.00958064,-0.02148563,-0.06631139,0.020221852,-0.023568584,-0.02052905,-0.04157565,-0.029306086,0.011993408,-0.06899964,-0.024462117,0.03841145,-0.007033452,-0.010125853,-0.021935632,0.030248277,0.030980503,-0.03227671,0.029083788,0.017727518,0.02863627,-0.028631106,-0.0037343148,0.025150456,0.019582393,-0.02541005,-0.031093419,-0.026376069,-0.002287971,-0.040334158,0.025637638,-0.026979653,0.0005426143,-0.009142646,0.005655791,-0.009909681,0.024112063,0.0134769995,0.018457456,-0.0020779173,0.023975665,-0.044592425,-0.003667223,0.03074393,-0.041809574,0.030040802,0.008813727,0.049080458,0.006696391,-0.0062726517,0.05875638,0.039484844,-0.024343623,0.015504651,-0.01947612,0.005522472,-0.0014889851,-0.010238266,-0.020822337,-0.04052967,-0.027863787,0.005899595,0.019322269,-0.06680862,0.031108074,-0.0149504235,-0.00705316,-0.017536387,0.018077606,0.06626992,0.034611575,0.0027607472,0.0015124353,0.019799005,-0.022074929,0.029496344,0.013697474,0.0123200985,-0.017458975,0.044745624,-0.012803448,0.039781887,0.023174739,0.027307017,0.0021756683,-0.0073640607,-0.037434768,-0.0061790943,0.02824797,-0.015162894,0.008623288,-0.0017013131,-0.031264067,-0.054017223,-0.05519154,0.048697308,-0.004230681,-0.014681274,-0.0022274863,-0.029774556,0.0066541643,0.019976087,-0.027730523,0.00548606,0.0075498177,-0.030351039,-0.02599977,-0.0044360184,0.0020630406,0.025779305,0.0015154703,-0.0041845366,0.032631807,-0.020989122,-0.008195632,-0.0037080133,-0.04022435,-0.007486028,-0.031240938,-0.018441401,-0.008710869,0.00031933215,0.014480077,-0.10905526,-0.01730101,-0.055786815,0.03871065,-0.015107433,-0.003614517,-0.0076224837,0.018868795,-0.10953779,0.014027388,-0.012796213,0.024979798,-0.030288814,0.033652138,0.04917433,-0.018841293,0.013292938,-0.028713847,-0.054067142,-0.029097926,-0.03996966,-0.028100798,0.00069876295,0.00578083,0.0062670684,-0.017593406,-0.014695502,0.0199625,-0.005663126,0.024527127,0.013779751,0.03386288,-0.05127364,0.0073445165,-0.013678647,-0.011254207,-0.032344475,-0.024633216,-0.01439319,-0.032859758,0.01845138,0.057462145,0.01601654,0.02767065,0.010900687,0.008812704,0.0015351298,-0.027826477,-0.014625522,0.03811243,0.0037722725,0.04155586,0.076586016,-0.054396145,-0.050350673,-0.0076494557,0.013699047,-0.01254212,0.015730657,0.017662268,-0.054530125,-0.034026545,0.03399267,-0.019196052,-0.026813004,0.008463033,0.048176337,0.034986813,0.0051710303,0.012984966,-0.01944767,-0.038004905,-0.04365656,0.0066977837,-0.026735421,0.013591952,0.02569745,0.04529721,0.0077958587,0.011434735,0.027619416,-0.035357762,0.0049418593,0.021862308,0.0076793423,0.008420551,-0.027290123,-0.0075956155,-0.025710158,-0.007894903,-0.0153488275,-0.024082445,0.03122209,0.004152871,-0.0152076995,0.01241529,-0.0076520913,0.043108653,0.0040981914,-0.018606639,-0.04912185,0.011223376,-0.028249148,0.05950344,-0.028017042,-0.007883532,-0.0031516098,0.020924967,-0.04140362,-0.027344672,-0.024915453,0.04212682,-0.035109792,-0.0012510887,-0.001248119,-0.047225118,0.030384133,0.024003401,0.0586368,0.00044104643,0.050590046,-0.045655776,-0.018798288,-0.031157805,0.023231532,-0.01690296,-0.023141144,-0.0032802112,-0.009439171,-0.00052971207,0.0066903797,-0.013648005,0.049257524,0.032530524,0.01040134,0.008946101,-0.00014842824,0.009356825,0.023358265,-0.009350035,-0.039602134,-0.00035440427,0.020120934,0.0010620191,-0.02495814,0.05165726,-0.051170956,-0.016148176,0.022311702,-0.017874654,-0.0007864836,-0.022610482,0.0012386381,-0.036134847,0.009532403,0.0025809025,-0.06084733,-0.039139345,-0.019591602,0.030685129,0.005413749,0.005841356,0.0051638717,0.057283662,0.020108491,0.044095196,0.008223173,-0.005015844,-0.004745602,0.015515687,0.028493239,-0.0109052695,0.017288797,0.025242457,-0.009992803,-0.031573247,-0.046053488,0.014226485,0.009788864,0.0073235626,0.009040056,0.011743211,0.023063494,-0.037546493,-0.023554763,-0.03925862,0.040368833,0.015405615,0.0051511675,-0.01908115,0.034656055,-0.008320745,0.030015327,0.015095574,0.032622304,0.02888622,-0.022863608,-0.013342277,0.02735289,0.016116213,0.01145282,-0.014380084,-0.015014719,-0.011927377,-0.03356432,0.027550157,-0.0333025,0.0122057125,-0.031276356,-0.00024719053,-0.06566531,0.014852725,-0.029601103,-0.021345621,-0.0022858179,0.012535095,-0.011053568,0.06125521,0.016785279,0.0040413495,-0.05746758,0.03241255,-0.0042440235,-0.009212826,-0.004131734,-0.006124381,0.010457575,-0.011427499,-0.010975564,-0.013819106,-0.017808234,0.046082817,0.002724472,0.0025495703,-0.035091303,0.006788956,-0.052798383,-0.0042512766,-0.0057629193,-0.046074454,0.010781932,0.06902439,0.016325286,-0.03422101,0.067623585,0.07086542,0.029758124,-0.032850157,-0.0061894925,0.041017916,0.024097163,0.02169327] [-0.00026551017,-0.038816974,-0.029778175,-0.009886834,-0.001111944,0.020909436,0.035658654,0.0027601293,0.017326614,-0.020272642,0.026484119,0.006049569,0.0015636942,0.0040778187,-0.006597321,-2.8681561e-05,-0.011910434,-0.025222057,0.011544675,-0.0057715275,-0.01812088,0.010053065,0.027628407,0.016691528,-0.04903555,0.028372034,-0.022674965,-0.041696057,-0.02562289,0.008141126,-0.024620172,-0.0522794,0.031818166,0.012862842,0.00032902483,-0.014970105,0.013818018,-0.0006556346,-0.024951503,-0.0069631687,-0.0051484285,-0.02763143,0.009267823,-0.006707598,-0.005800701,0.03172169,0.0111639565,-0.022393044,-0.023525214,-0.024323462,-0.00074781594,0.01654251,-0.018168153,-0.026823465,0.046938613,-0.0031412959,0.05229185,-0.0013582519,-0.044361696,-0.025239069,0.006191765,-0.0057343575,-0.013215777,-0.014622125,-0.009737049,-0.007649746,-0.0031943296,-0.059477698,0.008558227,0.023905762,-0.02523518,0.019672731,0.008345036,-0.0036752052,-0.004051588,-0.027575718,-0.021319605,0.01623637,0.010387732,-0.00094717677,0.01600952,0.018577008,-0.008610862,0.047903143,0.014566261,0.010022161,0.027970549,-0.0047778874,-0.03495186,0.0054290327,0.0026068094,-0.0018976278,-0.0031962148,0.069218054,0.005227112,0.019475807,-0.0032323524,-0.011348247,-0.0024906911,0.015224859,-0.0048078527,-0.0030198698,0.006966837,-0.013003722,-0.0014795422,-0.01524063,-0.024778988,0.012492991,0.011736362,0.020545566,0.0044222767,-0.005100774,0.06719852,0.051939297,-0.020178838,-0.014309191,-0.008849829,0.045464873,0.0025249193,0.0077293036,-0.0034362576,0.013110233,0.015800312,-0.009070901,0.00020899648,0.010365691,0.005999025,0.0105294045,0.024488948,0.02518231,0.021558223,-0.015444478,-0.039985634,-0.022536416,-0.02225164,-0.0032180022,0.006226779,-0.0067079747,-0.015687907,0.0020884222,0.0015916035,-0.021114815,0.012258381,-0.022421336,2.4403082e-05,0.014285641,-0.0013782068,0.02039794,-0.00430098,0.01015734,0.008941608,0.019420926,0.0033646324,0.010611048,0.0045996103,0.02421994,-0.025657402,0.049126167,-0.003712102,0.004298205,-0.011130345,0.0070024906,0.02541194,0.017197056,0.019895248,0.014189452,-0.05349009,0.02571091,-0.016589949,-0.019329138,0.0067688986,-0.041175947,-0.011179714,0.0018797556,-0.013135316,-0.007595019,-0.031831976,0.030008946,-0.010524221,0.025554202,0.0005486288,0.0061646826,0.00094940484,0.016279787,0.0072960374,0.0028394454,-0.0020223998,0.01460838,0.007634139,0.009562914,0.018160282,-0.013083105,-0.01830196,0.011073058,0.013324324,-0.015681706,0.007385072,0.025655273,-0.00918077,0.032925244,-0.014064933,0.013535321,0.02035272,-0.009486217,0.015608775,-0.0037682075,0.004272446,-0.011786977,0.00031129172,-0.02896686,0.010978518,-0.015091408,-0.01364652,-0.01231017,-0.008765748,0.013828011,-0.01633976,-0.016719949,0.01106946,0.026715478,0.004675307,0.008081051,-0.004036562,0.0099703735,-0.029941253,-0.007695044,-0.0067343386,-0.0011713185,-0.004022485,-0.021316338,-0.0016470475,0.010308384,0.017707089,-0.0063733286,-0.004352746,-0.0037476222,0.025589794,0.006203112,0.0013888455,-0.057474125,0.008090039,-0.01345721,0.005460712,0.005254073,0.016935334,-0.01988797,0.022067241,-0.030411027,0.015650788,-0.02496368,-0.0050487616,0.0047480506,0.0063316184,-0.017599538,-0.029037692,-0.01006621,0.008248183,0.006223682,0.0010527145,0.028527286,-0.020272167,0.011959684,-0.016175896,-0.0032047837,-0.02617738,9.186099e-05,-0.02254915,0.0019877604,-0.00060652354,0.009969679,0.057941854,-0.0036679322,0.00240779,0.002055405,-0.026374025,-0.026376285,0.025130214,0.025359867,0.02135204,0.039985158,-0.023247413,0.007861698,0.0051301774,0.010281534,0.024644034,0.011873098,0.024489863,0.0003945927,-0.014100979,0.013014021,0.033633884,-0.039587665,0.014582705,0.006205015,-0.017772509,-0.0048528546,0.025467137,-0.010567757,-0.016883308,-0.024354044,-0.0080269845,-0.003104942,0.0074930405,-0.002559803,-0.0031020483,-0.00028289776,-0.016359553,0.0015766365,0.014619583,0.048356697,0.0055701095,0.0031270871,-0.007830745,0.009774456,0.02257159,-0.020470023,0.005294837,-0.019137977,-0.010073286,0.0014949976,0.006630108,-0.052269574,0.006450483,-0.0072448794,0.053802524,0.0034027067,0.03750795,0.0010889394,-0.020886123,0.05183742,0.01588781,0.02162238,-0.043770026,-0.0225333,-0.0032902071,-0.004386898,0.011517871,0.0035367357,0.04487439,0.013409607,-0.0023531658,0.0027711214,-0.019058106,0.021334395,-0.030841438,0.013431264,0.03631872,0.010683589,-0.024322415,0.027110001,0.024258466,-0.015150531,0.018985938,0.00062076445,-0.0049206936,0.02163481,0.0015013572,0.0016828912,-0.004831071,-0.0084865475,-0.0140775265,0.0001449058,0.007479715,0.034487993,-0.02836531,-0.013708945,-0.0033556654,0.0156706,-0.0052604335,-0.019067558,-0.011743097,-0.005513802,0.037484076,-0.020012379,0.0010412317,-0.007610458,0.011511595,-0.036450505,-0.011468807,-0.006926219,0.025829202,0.026087021,-0.025324523,-0.008085823,-0.0025892467,-0.0026825978,-0.021303404,0.026596915,-0.0016287504,0.006278519,0.00509755,-0.0032335876,0.005034727,0.0009737361,-0.014316223,0.053031042,0.0128401965,0.027285684,0.0021522378,-0.018846264,0.00718449,-0.01842923,-0.01427759,-0.037112672,-0.0034800372,-0.009115957,-0.02159723,0.012544904,-0.00338348,-0.0014109812,0.028062781,-0.0042096493,-0.0019595295,-0.016715175,-0.029268328,0.010528602,0.0045661563,-0.020013753,4.932962e-05,0.0029703684,-0.017533107,-0.015208959,-0.0077679832,-0.019954633,0.02958624,0.011665926,-0.011081102,0.0033290118,0.044864215,-0.030379213,-0.021414265,0.010048868,-0.016313676,0.015057489,-0.008112183,-0.0048855273,-0.01048309,-0.0002865239,-0.026408244,-0.016618457,-0.023003764,-0.017228598,0.004718002,0.034264676,-0.007091449,0.021681868,0.00019767614,0.0040787654,0.022558197,-0.0058357273,-0.029741487,-0.005855989,0.00638103,0.014106799,0.015454678,0.027807608,0.0003423667,0.009089726,-0.056697536,0.019758603,-0.069496,-0.017877936,0.020390518,-0.006355223,-0.025619024,0.016170664,-0.0064990525,-0.0052785776,0.0022872696,0.011157472,-0.025557365,0.030214475,0.021636833,-0.013394249,-0.036351003,0.017105501,0.013159792,-0.0031761865,-0.023762181,-0.025938902,0.013810334,0.016144589,0.01596763,0.0011660137,0.002921077,0.05217225,0.017653266,0.024959339,-0.021779468,0.013158375,0.018089416,0.008548635,-0.004248884,-0.01759975,0.00025796122,-0.013230522,0.04007679,-0.038218897,-0.013166592,0.025127938,-0.011962682,-0.015825283,-0.013713967,0.016088784,0.00075469894,0.015370145,-0.010692504,-0.015654983,0.009654555,-0.007955335,0.014015585,0.025662642,0.00070373574,-0.031245023,0.024395626,0.036141116,-0.011654952,-0.04147984,-0.010955161,0.014183886,-0.035877444,-0.01955177,0.043498047,-0.02619396,-0.0151583105,0.025889847,0.024481285,-0.054260083,-0.029051667,-0.041911677,-0.013520881,0.0025682936,-0.015036915,0.006630448,0.010918172,-0.024684906,0.012022962,0.013265844,0.01268578,0.018662384,0.005028787,-0.004923292,0.007236447,-0.010493671,0.029531341,0.00057606783,0.017049113,-0.0051543033,-0.017679704,-0.0069096154,0.016374914,0.0074930964,0.011519635,-0.039373998,0.0074746916,0.005268465,0.0064079794,0.02630054,-0.0041832626,-0.014402537,-0.012326753,-0.013266473,0.006996908,0.024967538,-0.007943565,-0.007112047,-0.0048713866,0.025247192,0.02252053,0.018960433,-0.028577365,0.0026264924,-0.021007538,0.033823177,0.03820299,-0.041082848,-0.019096896,-0.025534768,0.026494194,0.013076803,0.015310537,0.043684177,-0.01597071,-0.044086765,-0.008818118,-0.00073363126,-0.037522912,0.0017445608,0.022972759,0.0048437873,0.017268108,-0.010545185,0.0061345026,0.053404756,0.0130213285,0.026476927,0.033821907,-0.02203589,-0.0068981955,-0.013200097,0.03350484,-0.0020173192,-0.021860745,0.0025808362,-0.025998764,0.0070978967,-0.009814557,-0.0011601363,0.00491276,-0.005978,-0.027218983,-0.018959899,-0.032022644,-0.0010710781,0.017899731,-0.024944132,-0.0021032465,0.0037709626,-0.03084281,-0.015951866,-0.0424235,-0.034351096,0.013353113,-0.0072252853,0.019069694,-0.05671097,0.01119029,-0.0075954203,-0.026346724,-0.026471479,0.004142859,0.017977612,0.05007259,0.003351717,0.0018619868,-0.015801698,-0.032496456,0.039080046,-0.0027420088,-0.022182357,0.028378487,-0.0063135875,0.029586935,0.0030712015,0.031154297,-0.027800325,-0.018287368,0.0111754555,0.010310334,-0.009265509,-0.011910467,-0.007883423,0.006537456,0.021022767,-0.005893543,-0.023717718,0.008789084,-0.061667178,0.040371053,-0.0117143355,-0.010495355,-0.01391725,-0.0277938,0.033522397,-0.008380102,-0.013148939,-0.001667617,-0.03644065,0.052604057,0.010962105,-0.009841429,-0.0008509759,0.019616699,-0.019973315,0.028937925,-0.043594543,0.008474142,0.004785461,0.019005936,-0.020093242,0.019685151,-0.0057750735,0.003960009,0.009343641,0.024692973,0.014264842,-0.01882286,0.005576454,0.017713461,0.019377837,0.00911219,-0.019516755,0.046645854,-0.009972212,0.013146661,-0.0075426386,0.006387314,0.028522214,-0.028296769,-0.008847604,-0.0010107536,-0.022576457,0.009694071,-0.0020716097,0.033304725,0.02331398,0.01346463,0.0076117874,-0.00995881,0.016535727,-0.006844622,-0.02055099,-0.0332049,-0.03621257,-0.027623892,0.0024112258,-0.006068936,8.158791e-05,-0.005564695,-0.02064582,-0.025381755,0.034938183,0.014326185,0.004601905,-0.022501497,-0.010275159,0.00817231,-0.008490285,-0.0075186053,0.07024452,0.0065089706,-0.022366442,-0.018092107,0.053359415,-0.034236506,0.013746292,0.001173323,0.0044289143,0.0024701746,-0.0034620222,0.025738742,-0.00875293,0.019693919,0.0067998385,-0.024588887,-0.011235429,-0.01728668,0.031377092,0.0077240104,0.0002906139,0.0035458591,-0.005515864,0.02091524,0.033857036,0.0012375829,0.03295845,0.016995646,0.00069883326,0.008872415,0.034909084,-0.056228235,-0.022968436,-0.0053682993,-0.03130434,0.0021935934,-0.0042076474,-0.0036565901,-0.022431282,-0.030629024,0.022033961,-0.005605239,0.021888021,0.031193936,-0.020502165,0.02344889,-0.023758985,-0.035678618,0.01711068,0.022949498,0.008528252,0.008965938,0.026438233,0.023230558,0.0071199536,0.012327108,-0.01926756,0.006345126,-0.026480978,-0.017568525,-0.03397152,0.024281912,0.0036816841,-0.0109539395,-0.013924351,0.02619095,0.005636935,-0.021689653,0.026369777,0.001387728,0.0011123641,-0.025099862,-0.020044915,0.0049053053,-0.0015075911,-0.015479571,-0.006837999,0.0072411695,0.01125089,0.02448753,0.013142266,0.010023302,-0.032982185,-0.013963821,0.00058535684,0.020068873,-0.021220317,0.0063474653,-0.0020395443,0.025097612,0.011681922,0.015719807,0.016117966,-0.030096745,0.0034328247,-0.0056626815,0.009345771,0.008051809,-0.040698264,-0.024107449,0.043460444,-0.0046429737,0.02581594,-0.0057371696,0.0031110286,-0.010779876,-0.018934267,0.016636068,-0.016447265,-0.0019715396,-0.045308746,-0.006014399,0.01955635,0.028325941,-0.007622532,-0.002095125,0.0045428392,0.02580417,-0.028463203,0.009352595,-0.0040943297,-0.011945801,0.037200257,-0.022669312,0.015907679,0.02153028,0.0017986678,0.019065361,0.01533788,0.011064887,0.006499932,0.02756613,-0.02559959,-0.025271539,-0.031854972,-0.0017265399,0.0023418362,0.027971456,-0.0006544593,0.0443801,-0.019796912,0.036242638,0.008028638,0.001362604,-0.011814691,0.005392776,0.024457162,-0.0015904827,-0.004544225,-0.01198402,0.0072282567,0.03112162,-0.012656717,0.050428566,-0.004989168,-0.015247382,0.016976772,0.00014574794,0.008409939,-0.0053850226,0.0006751395,-0.014736612,0.0031964278,-0.0012462076,0.03216183,-0.01070688,0.018051136,0.013165477,0.012226007,0.016604163,-0.021874376,-0.0058464343,0.06359638,0.016856877,-0.01863293,0.045595836,-0.014818551,0.016547866,0.031293113,-0.007893335,-0.004247167,-0.0068152663,-0.03582778,0.013591754,-0.021592543,-0.04590025,-0.008410191,-0.0014787152,0.027711691,-0.027358368,-0.008788722,0.053018853,0.0020424707,-0.025041608,0.0029111218,0.046776842,0.008233398,-0.04753579,-0.03221789,-0.0053531574,-0.0020709967,-0.013847014,0.018449632,-0.0108213015,-0.010403731,0.010526606,0.007814316,0.017503973,0.017707625,0.0069850804,-0.017534636,-0.018104006,-0.013593329,0.012847449,0.05475909,-0.0052207406,0.0017695613,0.013249108,0.03660805,0.026437951,-0.0029602991,0.014518795,0.0007999917,-0.01059309,-0.0149145,-0.012863031,-0.013265877,0.03664275,0.003673075,0.007829146,-0.0008242617,-0.022015397,0.01795693,-0.03172338,0.013428471,0.0043452154,-0.027520183,0.0016599628,-0.018759873,0.010816556,0.018712234,0.005721386,0.027067542,-0.022169143,-0.002282259,0.0075420323,0.025125219,-0.031136949,0.02356696,0.023538986,0.036863178,0.007797158,0.003919501,0.035064198,0.004194676,0.005767135,-0.028668592,0.01290807,-0.022130294,0.0003706048,-0.0154102845,-0.021376839,5.0935487e-05,-0.020220509,-0.0055390014,0.0014372331,0.021412771,-0.041322548,-0.0022147764,0.053993963,-0.026507394,-0.031358995,-0.0059677693,0.019614767,0.0066301897,0.02207031,-0.008181585,0.02051683,-0.031566743,0.019851472,-0.012711384,-0.013878747,-0.02566833,-0.01994098,-0.019050851,-0.023584876,-0.0053391904,-0.024553293,-0.0025335455,-0.014237696,0.021903621,-0.00914506,0.018837051,-0.0082819965,-0.026471524,0.014662839,-0.018096147,0.016594565,-0.0075874655,0.010498656,0.02418252,0.01782402,-0.011384446,-0.01585838,0.010995405,0.016015753,-0.007624208,0.009199403,0.0027154398,0.023208741,0.00452572,-0.02382572,0.0051031336,0.0046952157,-0.008001603,-0.017853461,0.00014729946,-0.019215921,0.013545685,-0.012920804,0.022083154,-0.008522423,0.010144387,-0.014296019,0.02626958,-0.004882438,0.0005758925,0.00045723165,-0.029322036,0.013303914,0.028260434,0.01153538,-0.027219301,0.036477506,-0.017660625,0.008761486,-0.013838412,-0.02486393,0.026617046,-0.019063268,-0.011522879,-0.005964504,0.020405902,-0.006595415,-0.011234536,0.027494052,-0.029233944,0.008328125,-0.02096544,-0.018600779,-0.016690494,-0.061774235,0.0074789077,-0.014151657,0.013415706,-0.00056703534,-0.015166774,0.005760332,0.003672623,-0.002968857,0.008699096,0.0027877148,-0.010571498,-0.025497084,-0.031530555,0.030784862,-0.04328231,-0.027564472,-0.014083949,0.008943579,0.0042145182,-0.0170196,0.020386923,-0.01629816,-0.0047365627,-0.0043474655,-0.002579902,-0.0013457787,0.03205405,0.0061584697,0.042927347,-0.0135934865,0.023651766,-0.009777099,0.014671318,-0.014542709,0.01302067,-0.020334825,0.007101908,-0.0074114664,-0.011152908,-0.012002954,0.027056752,-0.024450105,0.027984433,-0.0104427785,-0.041777983,0.023879927,0.029216154,0.004626827,0.028140958,-0.009467029,0.029761216,0.009884232,-0.00081953174,0.026687268,0.011982042,0.0037098974,0.018697761,0.01609811,0.011696787,-0.017947707,-0.0025784033,-0.015596278,-0.018765435,0.0041946266,0.04636041,0.02658223,0.020463722,0.029811645,0.014612674,0.0029824495,0.014990381,-0.0068552517,-0.005133571,0.014978437,-0.023429204,0.0054603093,0.01863498,-0.0155388415,0.014252753,0.015978415,0.0032499619,-0.00092078006,-0.007434613,-0.049781367,0.012807093,-0.007963682,-0.0050247605,-0.017779905,-0.014106457,-0.0129298065,-0.04045522,-0.0009672911,0.013426675,-0.020756776,-0.00677963,0.013102792,-0.037195727,0.011105429,-0.012300464,0.0031251882,-0.03574248,0.014016836,-0.008977991,0.01102304,0.020007933,0.029803501,0.01362499,-0.029563561,-0.0071679065,0.023959935,-0.022459399,-0.001993907,0.0057433285,0.030286212,0.006301074,0.011045507,-0.011268122,-0.039605957,-0.018188192,-0.005088077,-0.039314516,-0.014466155,0.002483332,-0.005395689,0.015239198,0.0033817822,0.018085444,0.012751083,0.008508945,0.014923013,-0.034192763,-0.014888219,0.039097305,0.007714443,-0.015606541,0.0023928937,0.0014522974,0.043214537,-0.014935343,-0.035164822,0.005760556,-0.009641259,-0.017528841,-0.023611585,-0.012597985,-0.027490478,0.02459664,-0.013882102,-0.011584375,0.017058631,0.030655608,0.010994146,-0.024062028,0.004398847,0.0064001535,-0.0026403256,-0.014798411,0.038195945,0.005579352,-0.004654005,0.03535763,0.004819233,0.00629274,0.042372305,0.026105322,-0.008511063,0.001854896,-0.01555698,-0.029874153,-0.0272079,0.013890432,-0.035300445,0.00884193,0.03150217,0.011066807,0.0016865272,0.00011374508,-0.007662331,0.028426982,-0.020463543,-0.009921558,0.010723891,-0.043136757,0.028091043,-0.005924674,0.008027922,-0.00846469,-0.01019898,0.006673865,0.024451435,-0.024246551,0.009993961,0.022631234,-0.043660045,-0.014469683,0.0028977357,-0.023378043,-0.004246003,-0.03450599,-0.0009386497,0.0020494654,-0.012460193,-0.0010889278,0.018592777,-0.011521873,-0.020266768,-0.005580113,0.0035058907,-0.03615645,0.009782204,-0.0039718435,-0.030105913,-0.0061995913,0.025034945,-0.021740435,0.009723894,0.01643215,-0.015690247,-0.011129715,0.01659496,-0.003477719,-0.01254459,-0.01686127,0.012775773,-0.014116863,0.02801101,-0.012500412,0.002614501,0.011260285,0.017644044,0.024234528,0.0016479627,0.0031931375,0.019059291,-0.0068553477,-0.023710176,-0.0007622869,-0.011214428,0.010191382,-0.027442522,-0.014180869,0.001424978,0.02979938,0.01576934,-0.009000188,-0.017083798,0.009096615,0.0038692537,-0.00979168,-0.027512375,0.0053560794,-0.0005004989,0.025592752,-0.017676754,0.007346082,0.005501974,0.042736158,0.027955187,0.02105256,-0.016923994,-0.0023395966,-0.017816203,0.005040039,0.0055386377,0.00068835274,-0.022499181,0.016775487,0.019415714,-0.017786227,0.031847183,-0.012718141,0.02354567,-0.029374989,-0.0016997244,-1.4081717e-05,-0.006416391,0.019589368,-0.026873982,0.035362583,0.01575342,-0.022758467,0.0062202006,0.0015873025,0.010852572,0.02034333,0.02746052,-0.006857871,0.0071951947,0.018427677,0.019399146,0.01623574,-0.00588043,-0.0008453549,0.010762216,0.034986753,0.0034711685,-0.002873055,0.0065885596,0.009072031,0.007827679,-0.05342144,0.051126055,0.00028139693,0.0007506151,-0.000613433,-0.039589968,0.012997658,-0.004776306,0.023227923,0.009953005,-0.006610819,-0.023037031,0.02003452,-0.016620368,-0.022062663,0.023601923,-0.014151556,-0.03341734,0.026493726,0.005160784,-0.014467991,-0.041694324,0.014890134,-0.012922913,0.010550313,0.01551008,-0.018743828,-0.05831319,-0.049843166,-0.0037817173,0.057383098,-0.027841663,-0.02303178,0.011128052,-0.031914677,-0.0013771311,-0.007290602,-0.008892551,0.021253722,0.0039690165,-0.0023186917,-0.007675506,0.004364611,0.0059926934,-0.001284341,0.010492996,-0.0017106083,-0.0024486145,-0.01347052,0.031622827,0.011648777,0.0085902745,-0.014800909,-0.0019124435,0.0038206892,0.04565029,0.0033668864,0.046963952,0.011427256,0.006318174,0.009890495,0.0013555826,-0.011058944,0.014683522,0.0044124555,0.0019027542,-0.001758271,-0.016501276,-0.014507867,-0.018412884,0.004257669,0.026772864,0.012990754,0.016589887,0.0090076905,-0.007874903,-0.015244179,0.004382541,0.008802486,0.013053967,0.021412957,-0.046131637,0.014899084,0.029920027,-0.008765624,-0.03794902,-0.022671813,-0.0074651674,-0.009369487,-0.0019291003,-0.022330798,0.009447522,-0.009671831,-0.00549534,0.031277698,0.002913858,-0.02044278,0.031812437,-0.0154401325,-0.002678052,-0.0046740873,-0.011925329,0.0050027864,-0.011803563,-0.019856896,-0.020602163,-0.024062207,-0.015128978,-0.004821954,0.0056244223,-0.0030059195,0.0058308747,-0.00022966356,0.03794046,0.007133306,-0.014635068,0.00983985,-0.009833297,-0.0028716605,-0.011716427,-0.014391811,-0.021942837,-0.0026893842,0.0017555521,-0.0199113,-0.00721394,0.0047451174,0.009839723,-0.034423772,-0.015510752,-0.011335076,0.015293911,0.022681719,-0.011118871,0.03297951,0.022291906,-0.011636038,-0.0010225984,0.01798156,0.003570634,-0.033893105,0.0003251977,-0.016682616,0.0137351295,-0.016050002,0.0033693083,0.00925398,-0.008769168,0.029117068,0.006041404,-0.03180653,0.0154150305,-0.0103917625,0.00024923237,0.021400306,-0.010530533,-0.009158463,0.0075933705,-0.0215979,-0.012313523,0.007584005,0.02494566,0.008201137,-0.010626024,0.0020025899,0.021434592,0.014746112,-0.01635388,0.00868264,-0.023479061,-0.019009767,0.043467417,0.022031853,-0.018020157,0.04121576,0.0065506063,-0.012435505,-0.022365175,0.008123824,-0.0023134002,0.028462151,-0.036440376,0.03801792,0.025282769,-0.012832875,0.013667294,0.01857809,-0.0056769503,0.013236119,-0.02380715,0.022384344,-0.027007129,0.009024172,-0.004371655,0.007987924,-0.00057481707,0.011645457,0.0037508453,0.02399883,-0.022206651,-0.012818582,0.018813768,-0.018325515,-0.008034436,-0.002488723,0.032882962,0.024372606,0.004756728,-0.007154125,0.008840336,0.033924695,0.004923809,0.028101187,0.0011567777,-0.033118032,0.0014709946,0.018968675,0.04073541,0.020883441,0.015055441,-0.015444881,0.021542162,0.008628047,-0.0030415752,-0.0155702,-0.012744384,-0.020694735,0.026783759,0.00584521,0.004449301,0.009891127,-0.014021559,-0.0009108157,-0.02880018,0.0058691218,-0.016619751,0.001721128,0.003237445,-0.013216843,0.055943068,-0.050911162,-0.032033935,-0.052194387,0.0016075411,0.038831152,-0.007957827,-0.009557039,-0.017818442,0.0152757,0.0083184345,-0.00042278005,0.011326217,0.0021161959,-0.0002193272,0.027957853,-0.013305981,0.035657983,-0.013165111,0.0005986952,-0.035234846,-1.5578284e-05,-0.01433782,-0.001024776,-0.017086742,0.01999992,0.041877832,0.029938353,0.043687142,-0.018299218,0.02725692,-0.0031307493,0.0007838496,-0.012475986,0.005770288,-0.01627102,0.0021207428,-0.009914394,-0.015833264,0.008851034,0.0038030338,-0.008600516,0.045620885,0.009155764,-0.06347259,-0.0007097418,0.0061188536,-0.013429907,-0.01649602,0.018072741,-0.0027508144,-0.009634713,-0.0050747045,0.020696336,-0.014386188,-0.006845232,-0.025400616,0.016473645,-0.016792906,0.0032563754,-0.03377091,-0.014293825,0.0043730843,0.01502264,-0.0157646,-0.02652054,-0.035252456,0.019272333,-0.013998395,0.012600779,0.011701158,0.017471485,-0.0030760656,0.03570938,-0.009996069,0.00840038,0.01656581,-0.020307913,-0.017517542,0.035491735,-0.003722908,-0.0016397722,0.0015381491,-0.006834483,-0.0029895941,-0.0125149535,-0.02271754,-0.009251452,-0.012210296,-0.014091226,-0.024111133,0.0038503332,-0.018304583,-0.012125335,-0.0041678725,0.01801956,0.021407725,0.006218059,0.014846134,-0.0052521126,0.028621357,0.013184012,-0.018341687,-0.034394752,0.04143013,-0.0055503394,0.03900089,0.02493735,0.028398624,0.018097756,0.006014215,0.005025393,0.047795918,-0.015857121,-0.021092316,-0.013949589,-0.017042475,0.013290158,-0.009484738,-0.0007285911,-0.037768297,0.028833404,-0.00693453,-0.004454527,0.002113846,-0.01978263,-0.009340045,0.020037757,-0.0016911578,-0.007990502,0.03131998,0.028261269,0.009607331,0.008618444,-0.017201612,0.021367174,-0.0008969255,-0.002285063,-0.016884612,0.023264632,0.008216245,-0.003484704,-0.017260311,0.01367494,-0.03421122,-0.029046657,-0.011587536,-0.022169188,-0.0044997153,-0.014156054,-0.010947211,0.0039821765,0.026389271,0.048734244,0.01920368,0.0011037338,0.0019529223,0.009163412,-0.014523344,-0.031103384,-0.00032392328,-0.0113050435,-0.005166227,0.005936383,0.020147542,-0.017402975,-0.0076714703,0.028827466,0.009551638,-0.016245013,-0.014694198,-0.006207183,0.012936668,-0.0020516638,-0.021029439,0.03549449,-0.006718399,-0.0026693973,-0.014718577,0.0357486,-0.02946455,-0.025655584,0.0128174545,-0.0074430215,0.019867448,0.018247012,-0.028046774,-0.007269168,0.010675149,0.011364808,0.020949977,0.01875876,0.009505807,-0.016681846,0.009903328,-0.0027832887,0.0048030894,0.011661059,-0.004830766,0.0054331417,-0.008724451,-0.0017855716,-0.0036233228,0.01942795,-0.0024266564,-0.011528502,0.02051719,0.0044017024,-0.0053536794,0.053944882,-0.02597292,-0.006014761,-0.015590735,-0.023139428,-0.01716032,-0.01570671,0.025614707,-0.030106917,-0.0054010334,-0.020448321,0.012276269,0.02327875,-0.021619707,0.03526351,-0.0016244856,0.0081615625,-0.000287514,0.011620998,-0.013426027,0.019508261,0.006149253,-0.012995779,-0.035322167,-0.005677411,-0.011948057,-0.014109454,-0.00220135,-0.013126617,-0.0036142198,-0.010868123,-0.018523613,-0.028844258,0.0027370988,-0.030207355,-0.0060667363,0.01163723,-0.0023381251,0.00441028,-0.00208677,0.016873987,-0.0001688928,-0.02843814,0.008197144,-0.011814598,0.01298731,0.0039019438,0.012399036,0.03216339,-0.0031979505,0.02117594,0.006114901,0.025511105,-0.0065100715,0.037649367,0.037787076,0.0109890215,0.036967702,0.0016161454,-0.0006173257,-0.036379322,0.0027434388,0.038825516,0.0018272868,0.02235066,0.009454159,0.01613086,-0.0038860445,0.015592997,0.00744219,0.019819174,0.015390149,-0.043265484,-0.020173704,0.013799418,-0.015591946,-0.007469776,-0.0175034,0.0148008205,-0.009612996,0.026015963,0.0016475004,0.006503369,0.02037339,0.0126002,-0.010143431,0.018233638,0.017310489,0.01072706,-0.0047418033,-0.032062616,0.022544844,0.015772792,0.019069528,0.0072142472,0.012100283,0.04893012,0.0024139655,0.010997438,-0.0020689913,0.013981695,0.042769447,-0.033231325,-0.0007279002,0.025163373,0.008678846,0.02033521,0.0027723978,0.0014130801,0.04247314,0.017904809,-0.056596596,0.006397148,0.010278494,-0.0030354238,0.019399935,-0.0043407264,0.006148229,0.028471027,-0.00557494,0.005750654,-0.012354362,-0.017921224,-0.026228717,-0.019085111,0.012830837,-0.009682493,-0.010641139,0.003683564,-0.035630424,0.0025173794,0.044135094,0.02070657,-0.046042837,-0.026504397,-0.013766713,-0.031001389,0.015254796,-0.027038114,-0.00038772175,0.0021039452,-0.015936552,-0.013779682,-0.011821748,-0.0113081075,0.01279407,-0.005873946,-0.0062978584,0.0017771185,-0.0048269867,-0.03925452,0.0037558733,-0.021634392,-0.022383828,-0.010243108,0.012290725,-0.014033367,0.017554857,-0.036810044,-0.01466983,0.0056420723,0.009765231,-0.0091088265,-0.008372081,0.0061074733,0.02911344,0.023125267,0.014863724,0.035985425,-0.013070266,0.010523335,0.00074845774,0.012503435,0.031091915,-0.010248912,-0.0009472991,-0.0096523035,-0.01266894,-0.0052457964,-0.017487876,-0.0041926107,-0.017480662,-0.009602008,0.00699516,-0.012216739,0.008160897,-0.0004213635,0.040939957,-0.021861438,-0.011315669,-0.023164239,0.00411752,0.02526571,0.015145957,-0.04686861,0.0028305373,-0.022154318,0.014866544,-0.0073631746,-0.023809781,0.010736099,0.026005015,0.0058648074,-0.008597518,0.021622146,0.04403029,-0.0054679634,0.0025453612,0.0038488286,0.0049695224,-0.017648201,0.030381516,-0.015099464,0.021520557,-0.0055181836,-0.011747352,-0.03994604,0.000593667,-0.034504205,-0.0011584703,0.022508444,-0.015481325,-0.00054546073,-0.042351343,-0.020210914,-0.0038506908,0.018966088,0.00925185,-0.002982021,-0.027647628,0.0041572433,-0.024225308,0.016741268,-0.010745181,-0.007426764,-0.018179214,0.0035396067,6.388777e-06,-0.00091863726,-0.010541224,-0.02011654,-0.0035270278,-0.023940943,-0.019323427,0.011447785,-0.021241885,0.0034533192,0.01355962,-0.014349974,0.0070695565,-0.008948234,-0.014314536,-0.017158277,0.0026556426,-0.005939786,-0.0072304276,-0.032969512,0.018885093,0.010520544,0.0061346362,-0.012024843,0.022161307,0.016942972,-0.0059665535,0.015608609,0.02494916,-0.0061157527,-0.0044227974,0.02131916,0.017460484,-0.0027599852,0.0063476567,-0.010317735,0.00547651,-0.009095578,-0.010312481,-0.0016084263,0.015449674,-0.017248014,-0.012501141,0.014399287,-0.03297527,-0.005299847,0.015022758,0.0047202716,0.0034386904,0.01607545,0.00704564,-0.011575525,0.009622939,-0.0011848892,0.015805228,-0.022676522,-0.029573694,-0.016197061,0.015862692,0.012302713,0.008737697,-0.022868712,-0.012550288,-0.006344312,-0.012436157,-0.0006612247,0.044037562,-0.0050104866,0.019686773,-0.0418014,-0.0014872915,-0.0028775942,-0.0060001053,0.037413955,-0.005885762,-0.025369447,-0.0037863627,0.0050168806,0.0008610768,0.018774865,0.043670483,0.008621518,0.021344662,-0.0030905781,0.007977042,0.013050434,-0.032046612,-0.011462441,-0.00812558,-0.032702766,0.0025026368,0.01680944,-0.011839455,0.01079142,0.0043450124,0.0028238732,-0.011022156,0.0055596307,-0.024392508,-0.0013910652,-0.0041792844,-0.02577377,-0.0013020778,-0.0055927257,0.006145046,-0.0023670136,0.029727774,0.01206791,0.02768111,-0.013695488,-0.03067791,-0.011391505,0.017071076,-0.004502289,-0.04427672,0.03509213,-0.012548625,-0.0049913214,-0.030530227,0.011053135,-0.014034699,-0.0021046838,0.011775069,0.008817098,-0.0038018473,-0.026583917,0.019634442,-0.0067788186,0.02410054,0.019210648,0.030786986,0.0013557613,-0.010338283,0.002304107,-0.020833347,-0.01494186,-0.026330551,-0.005715194,-0.00425564,0.005423116,0.02532661,-0.03404577,0.018666755,0.0036151763,-0.0066735535,-0.026993155,-0.034068678,0.021732545,-0.009310602,0.013460622,0.02387572,0.012801804,0.005838803,0.0066141435,0.007572208,0.0063665337,0.0056333253,0.0059080934,-0.029891212,0.006709112,-0.003661195,0.03799782,0.024678791,0.00020438881,-0.02955312,0.024065202,-0.0024115983,0.017772838,0.021768486,0.013097433,-0.017872918,0.0018921841,-0.0041804733,0.037745055,-0.0104224365,-0.014398577,-0.025599202,-0.0018520937,-0.029202713,-0.014694872,-0.0139928125,0.0074151596,0.0063213347,0.009515135,-0.0049241423,-0.012861774,0.009465117,-0.012744458,0.013586398,0.0010359962,-0.011212684,-0.0032927897,-0.0022136683,-0.02450233,-0.026496962,-0.013925314,0.010414011,0.011595501,-0.012770814,0.012248112,-0.018063186,-0.062211774,-0.011451581,0.004096785,-0.019103914,-0.013388503,-0.01290071,-0.0020969235,-0.010860758,-0.016271977,0.034434985,0.007284907,0.02012744,-5.623308e-05,0.009132729,-0.00097090914,0.010335527,0.0065064165,-0.018514648,-0.046195567,-0.0132083595,0.012248625,-0.011503309,0.017297734,-0.0045528724,0.038766116,0.002566869,0.021601927,-0.009109438,-0.017332083,0.010474475,-0.0064050076,-0.0148255285,0.021192487,0.007332327,-0.0030047596,0.012328474,-0.012853175,0.0009250851,0.003326485,-0.020991735,-0.027756594,-0.024792247,0.005541278,0.026251597,0.020684348,-0.029556772,-0.004663065,-0.03434706,-0.029174557,0.016653368,0.017895874,-0.0017419097,0.000120427074,0.018977733,0.008531665,0.00012507477,0.018587323,-0.017995331,-0.015658626,-0.0045028906,0.0031611247,-0.014592697,-0.005933299,-0.030738726,-0.005287147,-0.025713569,0.0023267618,-0.019747453,0.024234846,0.0025784667,-0.012571405,0.030859735,0.00062323833,-0.00020309711,0.009986822,0.01022744,0.019404618,-0.004724331,-0.0025858616,0.011939786,-0.0015151937,-0.030037984,0.01717953,0.03919919,-0.023346372,0.0010893925,0.025281027,-0.015454004,-0.009879839,0.0027269414,-0.0049340273,0.009505227,-0.0065771793,-0.0031186703,0.0043134526,0.03349664,0.029807247,0.007803251,-0.028255386,-0.017919725,0.040132497,-0.030778328,0.017707033,-0.0069059804,0.027601281,-0.00039379808,0.046648182,-0.006769381,-0.027657153,0.010942666,-0.0052738036,0.014382098,0.039219294,0.009695361,0.02218637,-0.0083095245,0.018538956,-0.015796091,-0.020387005,0.03878525,-0.008455286,-0.0051344773,0.015282412,0.01428858,0.009778786,-0.0060884845,0.01828441,0.0365458,-0.02510663,0.00094583124,-0.027518814,-0.03782499,-0.01950169,0.017408695,-0.0062331143,-0.005277843,0.016678628,-0.0059624445,9.0801994e-05,-0.02272243,-0.0069876662,0.006706291,0.023062272,0.0012849532,0.00999882,-0.0013350288,0.005874769,0.0032982545,-0.018124314,0.0045515248,0.026976112,-0.023633044,-0.01874399,0.012299293,0.008414244,-0.011776983,0.017077828,-0.038464107,-0.047828887,0.014512755,-0.014219132,-0.011354393,-0.0018655938,0.017313708,0.0065570557,0.01788209,-0.030165914,0.016554957,-0.0032448135,0.018460143,-0.0021945578,-0.0009628781,0.007417024,0.0051865834,-0.012732378,0.0011741674,0.020149225,-0.016348585,0.021343589,0.002626293,0.024257906,-0.009546772,0.015840776,-0.024033593,-0.012061836,0.0058383564,0.000841087,0.0046484973,-0.02091044,-0.0142166605,-0.02149694,0.019211752,0.00079641794,0.00084040704,0.017103767,-0.013074069,0.02947818,-0.0036574865,0.020647217,-0.013980122,-0.004798608,-0.0084509775,-0.014805444,0.041231435,0.0151307145,-0.0011657011,0.017274601,-0.016919604,-0.018189562,0.018384807,0.002929798,0.01003894,-0.00022808503,-0.00063462823,0.00664277,-0.0015288815,0.014192518,-0.010718986,0.0010136402,-0.011868383,-0.030766727,0.0211807,0.0022749943,0.038143374,0.028322533,0.02553402,0.026582427,-0.0045730215,-0.0007943222,0.007002887,-0.0015645259,-0.0008477551,0.003303981,-0.0009776787,0.0014543516,0.015263173,0.003727308,-0.017953252,-0.028840195,-0.0016210059,0.0017781227,-0.00825045,-0.0071744393,0.011864513,0.019335663,-0.015075754,0.018067803,-0.008210857,-0.03454431,0.022921974,-0.013318129,0.017264243,-0.0047710096,-0.010767917,0.00677949,0.012377868,-0.032198563,-0.010858322,0.0049511166,-0.0077919136,0.03670969,0.0372335,0.004849542,-0.0030915404,0.013326541,0.004727865,0.011106703,-0.018891083,0.0138387745,-0.024924522,0.011388823,0.02040103,0.0009457004,-0.009514696,-0.011879969,0.019253667,0.021088308,-0.015115504,-0.011460899,-0.0010577552,-0.016781356,0.03951938,-0.0029560167,-0.008461896,-0.023339242,-0.01876239,0.04055624,-0.013448743,-0.012495286,-0.013938786,-0.025780179,-0.016174061,-0.013121533] +2 Export a schema as a SQL package [-0.03523064,0.011878959,-0.026240852,0.03253414,-0.001963035,-0.004145357,-0.005877611,-0.0005382819,0.019249335,-0.0055676373,0.015013755,0.072819725,0.0006059794,0.023325961,0.011558393,0.019368099,0.0004946947,0.007962973,0.026586058,0.045195326,-0.0016024777,-0.033703215,0.019497786,-0.0059100194,-0.013800246,-0.017664984,0.03710439,0.0052502044,-0.014460149,0.008231491,0.009455503,-0.009537099,-0.017957434,-0.02369486,-0.05524053,-0.02480173,0.014935052,-0.021400081,-0.051249675,0.024901533,-0.042548332,-0.011211825,0.042647604,-0.033546418,0.026389537,-0.010035377,-0.004160427,-0.03271647,-0.001249834,0.021001043,-0.055277817,0.01211651,0.009475132,-0.007812687,0.043673202,-0.008037031,-0.06380398,0.026341192,-0.06319057,-0.01974492,0.008005483,0.009720949,0.008959809,0.016294738,0.046906255,0.041547984,0.015809303,0.008859438,-0.0034462586,-0.040095977,-0.047791358,-0.014343524,-0.033913136,-0.0342744,-0.059328575,0.006248745,0.039734256,-0.0044308044,-0.0008789784,0.037630867,0.024038874,0.0012104294,-0.013633355,0.020118045,-0.03344059,-0.0036811,-0.021886693,0.079236865,-0.024860673,0.029169254,-0.0035133713,0.0018497858,0.012285419,-0.020017741,-0.040735953,0.0008732663,-0.016372183,0.03174882,-0.00063311704,0.004790983,0.006241321,-0.043661315,-0.020515205,0.0012835033,0.093292005,-0.02844539,0.015597924,-0.025574533,-0.03344167,0.00092313235,0.015664479,0.026324417,0.020051349,0.007216226,-0.022541452,-0.023645975,0.00986945,-0.052296076,0.054105494,0.018224735,0.034609362,0.004409663,0.022546832,-0.035473455,-0.013942807,-0.013006561,0.014458817,-0.001894069,-0.011993167,0.010526576,-0.012731405,0.009748155,-0.026680645,-0.0077996165,0.004086088,-0.0044244616,0.01354727,0.010357349,-0.0038209707,-0.05390031,0.01648883,-0.0643652,0.019314133,-0.056351125,0.010370557,-0.0038149217,0.07140078,-0.01342612,-0.004983248,-1.115129e-05,-0.0009783376,0.043584704,0.018845653,-0.00038361305,0.040731143,-0.05775639,-0.026239038,0.043823414,0.01200281,-0.0057438225,0.012403702,0.02161266,0.023975898,-0.025117146,0.063916884,-0.02683114,0.019374806,0.026683865,0.015116399,-0.02882967,0.057685792,-0.011489657,-0.004696622,-0.023358565,0.023377778,0.018551344,0.034676827,0.027960656,0.03959118,-0.038899913,-0.0041256403,0.02680278,-0.03989367,-0.024643483,-0.044443157,-0.011457066,0.023757877,-0.016675595,-0.003889649,0.011084242,0.022242362,-0.0017391739,0.02943803,0.018190421,0.032943893,0.028416025,0.017413296,-0.014858963,-0.0013412428,-0.003298099,-0.00643672,0.044248886,-0.032662172,-0.006406342,-0.034260463,-0.02788176,-0.041275,-0.023594335,-0.03863621,0.020030545,-0.0041386513,-0.021262143,-0.016819987,0.0027772211,-0.033503413,-0.0020756556,-0.012353193,0.021110825,-0.039980225,-0.066859394,0.030485777,0.0030381894,0.03240497,0.020877836,0.0027609202,0.034236956,0.050927397,0.011462815,0.008660593,-0.012008087,-0.016175611,-0.026377797,-0.01648044,0.011007588,0.057069115,-0.003079729,-0.040678706,0.010336831,0.04207934,-0.06348451,-0.055547733,0.0022155747,-0.0024543817,-0.023783777,-0.06165749,-0.027948856,0.037315004,0.00042580633,-0.051470846,-0.006409281,0.040165678,0.025699444,-0.042508245,0.011301268,0.07233298,0.015771033,0.03523824,-0.013525491,0.035622194,0.02923277,0.011610892,0.013565753,0.009503212,0.04864237,0.009441745,-0.018141694,-0.07260746,0.05657237,0.034608293,0.008524047,-0.025824204,0.03136589,-0.016068589,-0.007851677,0.004361235,0.009094182,-0.03220273,-0.02139937,0.08221651,0.028634435,0.019846128,0.0006669344,0.016168462,-0.04687486,0.030614344,-0.01961014,-0.038528994,-0.030316401,-0.044910755,-0.02666787,-0.044443958,-0.013462045,0.077736944,0.0022328317,0.017384162,0.025608387,-0.043924052,-0.147587,-0.0018070182,-0.03079115,0.009388969,0.014568414,-0.035466544,0.01738404,-0.03711965,-0.042181794,0.051685557,-0.005343652,-0.05389361,-0.015962884,-0.059539467,0.011936461,-0.0149758365,-0.01518952,-0.021405185,0.040761746,-0.046854865,-0.008931865,-0.09886635,0.014004568,0.025918609,0.028630711,-0.022172023,0.024488885,-0.008424045,0.00043764483,-0.0052276757,0.03224366,-0.023355564,-0.0047166967,0.032674726,-0.03549183,0.0019545932,0.032473676,-0.034663975,-0.0050297207,-0.027425114,0.023387304,-0.04890533,-0.015046504,0.047172256,-0.030518424,0.009677836,0.003052002,-0.00891745,-0.021483285,0.021886239,0.021325758,-0.066595405,0.0070217866,-0.022860462,-0.033967692,-0.0020651293,-0.05326966,0.05920855,-0.035518277,0.0014171704,0.03813291,-0.0354777,0.0068411124,-0.007170867,-0.023888137,-0.037118472,0.0144069195,0.023867263,0.0063559553,0.012636059,0.04847365,0.003949751,-0.024837384,-0.030068802,-0.01927608,0.010908831,-0.021281663,0.023015695,-0.013150187,-0.08491079,-0.061696168,-0.015886052,0.018113248,0.016658911,-0.037390713,0.06291845,0.027930953,0.0075211753,0.007369856,0.18694347,0.002437093,-0.019998638,-0.0028986686,0.040565114,0.019442856,-0.02605754,0.013186792,-0.0023207741,-0.033799008,-0.017074874,0.0005168778,0.026926184,-0.014778206,-0.0052962345,-0.015756944,-0.03791465,0.015560681,0.04582459,-0.0023909232,0.02638752,-0.040019136,0.024371622,0.027759893,0.024153834,-0.05922733,-0.034887332,0.042517744,-0.020015799,0.008206281,0.02595379,0.043231014,0.05442699,-0.014072888,-0.05754603,0.014496394,0.028899936,-0.014447449,-0.053199124,-0.009944027,-0.028044388,-0.03028225,-0.030581916,0.0026490076,0.0065261875,-0.07112752,-0.0040275813,-0.011204688,0.021977684,-0.030962473,-0.00057558896,-0.069676645,-0.03482976,-0.004286738,0.054520547,0.0027225236,0.003310395,0.001105359,-0.022503827,-0.015237845,0.011127641,0.01718004,-0.058143023,-0.0013354482,0.051212955,0.023206018,-0.009000672,-0.0074736765,0.05291711,0.0057439003,0.028376337,0.014306441,0.0019627323,-0.03342638,0.020117788,-0.011481846,-0.041064553,0.058343187,0.022282675,0.051761463,0.025775012,0.021903422,-0.04242848,0.007716789,0.041154254,-0.011810739,0.0058346516,0.038303316,-0.035959013,0.019025134,0.025196811,-0.0010799379,0.009220052,0.060705602,-0.0005297007,0.0037833173,0.018003913,-0.00964949,-0.023993185,-0.0039322637,0.0018084125,-0.005542929,-0.0438827,-0.04197895,-0.01325826,-0.021104109,0.0025069718,-0.024889493,0.023960682,-0.009101824,-0.031068513,0.036004003,-0.0010253796,-0.040315285,0.027849711,0.026663972,0.01329161,0.025313871,0.046248615,0.019961758,-0.02648383,-0.04388356,-0.04718545,0.013871968,0.014381675,-0.042914648,-0.038990274,-0.036132846,-0.012606858,0.04105651,0.019483995,-0.016762536,0.06556425,0.022378389,-0.013590043,-0.005865366,-0.02805568,0.048483014,-0.006216472,0.010283904,-0.02891031,0.03265905,0.028245755,0.016236454,0.015981391,0.01682833,0.018431729,-0.005542979,0.027706351,-0.00393304,-0.0025861394,0.0416692,0.028136564,-0.03347152,-0.01064646,-0.0111487,0.0085414965,0.032738257,0.06630041,-0.02291397,-0.010952977,0.0046403953,0.036977474,0.024502788,0.020280518,0.03278653,-0.026556205,0.023824932,8.0707236e-05,-0.019592505,0.015106283,-0.0073196143,-0.04739123,0.0013448605,0.07059822,0.01320317,-0.06989844,-0.02397978,-0.0035410349,-0.018499957,0.043187637,-0.026062498,-0.051332008,0.01295686,-0.0048034173,0.013637156,-0.016414473,-0.009872183,0.006089419,0.009940134,-0.03952161,0.0904721,-0.036246315,-0.012605967,0.006590721,-0.02673529,-0.0063413004,0.08053542,-0.04405505,-0.056284614,0.024516998,-0.03940258,0.019743748,-0.07178264,-0.008565185,0.01103991,0.009457379,-0.020924525,-0.012884737,0.007796417,-0.022190647,-0.0044040848,-0.030916218,-0.007191917,0.05076578,-0.025152542,0.060901657,0.023493143,-0.017870277,-0.014280339,0.039867498,-0.0015132638,0.012607161,-0.05203707,-0.013348957,-0.023882568,0.030933576,-0.010586321,-0.0033628922,0.022307625,0.008677177,-0.003982715,-0.0008860578,-0.013932681,0.013172795,-0.0029713793,0.023577375,0.026267964,-0.0023901162,-0.0131595405,-0.027914928,-0.020801552,0.025065307,0.06684309,0.0019138765,-0.033964965,-0.02056635,0.006387669,-0.031420145,0.05545812,-0.0038068795,0.057179157,0.006807747,0.0016911448,-0.042604297,0.012823673,0.0035059766,0.008658036,-0.02771699,-0.00051449036,-0.01428007,0.0029663192,-0.040202066,0.006745266,0.038742572,-0.043588143,0.03678337,-0.05287966,-0.008222639,0.006781336,-0.027402718,-0.015391651,-0.049383607,-0.00900736,0.025981562,-0.014205399,-0.050157677,-0.010106031,0.04531193,0.028517349,-0.01192838,-0.031822834,0.012279615,-0.003435765,0.0005564801,0.020711092,0.015984321,0.045927238,-0.0105606625,-0.03573722,-0.039684992,0.004093143,-0.030030768,0.021985475,-0.021858575,-0.027273875,-0.0043087727,-0.024258187,0.0010220109,-0.0073059388,-0.0328201,0.038088217,-0.016699368,-0.008981095,0.008190494,-0.007281547,-0.006860551,-0.026995981,0.05056208,-0.0053627733,0.02136085,0.00810289,0.016471619,0.01870297,0.02258968,0.005588664,0.040142924,0.022991082,-0.014205701,0.027591811,-0.02428774,0.0030166286,-0.0010726999,-0.004251021,0.045711678,-0.0008858221,0.015196255,0.0043092878,-0.045381546,-0.01991431,-0.018486317,0.015044424,0.034292784,0.0008694207,0.0034367454,-0.015497294,0.06162889,-0.021166988,0.04439029,-0.0069160745,0.044323217,-0.021607937,0.04365611,-0.02198166,0.06990918,-0.029377302,-0.00359447,-0.010673575,0.027696932,-0.021726204,-0.029812073,0.002861003,-0.001534565,-0.03765199,-0.013320178,-0.032440636,-0.023342831,-0.008855439,0.06618849,-0.015127521,-0.0076883864,-0.0062723286,0.020030247,-0.009963718,-0.0037506407,-0.0014696821,-0.007158462,-0.017233934,-0.022203969,-0.0068031815,0.0017663186,0.004994564,0.008092829,-0.004360731,-0.013153476,0.03005008,-0.028567027,0.010052589,0.0047099786,-0.04469708,0.00028789966,-0.015939455,-0.053419653,-0.0276103,0.02500915,0.032993663,-0.034650147,-0.004539958,-0.027232496,0.004602638,-0.0045965184,-0.018724225,-0.028575202,0.036905546,-0.12415304,0.013692847,-0.029837077,0.030272039,-0.013800549,-0.0129223075,0.0064049237,-0.036582533,0.027672393,-0.02274982,-0.04960883,-0.026978562,-0.039952677,-0.025928376,0.022589168,0.02130079,0.018148497,0.011746332,-0.028696053,-0.020442111,0.014364908,-0.011161506,0.030944211,0.03239087,-0.006084619,-0.022196671,0.019865023,0.010045566,-0.018328423,-0.025043376,-0.0016161328,-0.059245046,0.042262223,0.09762607,0.023629025,0.0009947683,0.0034397016,-0.02144789,-0.02775967,-0.030421868,-0.021263614,0.022016402,-0.029779594,0.0034418658,0.048701324,0.0034149708,0.010633066,-0.024950357,0.001001164,-0.013044036,-0.0130953565,0.005546636,-0.05545386,-0.00250348,0.050913658,-0.050301995,-0.015176829,0.055699464,-0.0020160992,0.041176386,0.03281068,0.03753084,-0.02960807,-0.044220254,0.04062624,-0.033636082,-0.019240111,0.015797833,-0.0059017316,0.06907038,0.038439635,0.01446531,-0.0061392467,-0.018773003,0.036381327,0.022944512,0.022395952,-0.020878542,-0.039812263,0.014932497,0.052489515,-0.02027439,-0.056141164,0.0019875553,0.011011557,0.029412743,-0.008541115,0.06008095,-0.06148182,0.041724343,0.020509368,-0.038223505,-0.03715875,0.0010987116,-0.06584093,0.03510788,-0.018655863,-0.0046133967,0.02507017,-0.0023943183,0.0035060614,-0.02157141,-0.016927645,0.022380093,0.010153699,0.0810682,0.032305073,-0.028826674,0.012597055,0.01786292,0.0031076956,0.011502786,0.010670594,-0.020594524,-0.042671103,-0.020237127,0.024334308,-0.0037987465,-0.0063537722,-0.013226924,-0.040724058,0.03692182,-0.047189582,-0.050872393,0.03885506,0.019552676,0.048367977,0.013908286,-0.06910207,0.0282565,0.010737157,0.0062445686,-0.004537491,0.018042617,0.059358243,0.0114346165,-0.032685194,0.05859506,-0.07208336,-0.04207404,-0.0027541108,-0.012422035,0.020449718,-0.005399983,-0.03486435,0.008804935,-0.020906676,-0.02001106,-0.036021598,-0.03912312,0.07567506,0.02642568,0.0018399844,0.023291692,-0.026814654,-0.0059069423,-0.0026210293,0.015874792,0.012096013,0.04349505,0.0108227655,-0.047496766,0.04533138,0.0070988415,0.0019463766,-0.0046485607,-0.023721255,-0.01634692,0.0038431603,-0.008411509,0.0073862737,-0.041278407,-0.03262068,-0.008350711,0.032421317,-0.047295433,0.0138994,0.02439451,0.059157114,-0.0042478847,0.01323569,-0.01497614,0.063211374,0.010558768,-0.013053844,0.07655856,0.017307922,-0.0011713456,-0.059944957,-0.022768537,-0.0023982623,0.009718378,0.036346372,-0.042109158,0.016913343,0.0070263157,0.0114767635,0.029580815,-0.05138581,0.004297972,-0.042999458,-0.0035492391,-0.053399477,-0.024704684,-0.031774558,0.0054122275,0.0026903707,-0.0050221146,-0.046368994,0.04363435,-0.019816771,0.016895387,-0.050359033,0.019799188,-0.018519195,-1.4892302e-05,-0.048914433,0.033089954,0.021738809,-0.016679266,-0.052778076,-0.036311742,0.01353008,-0.013133616,-0.033604983,-0.017479526,-0.018951867,0.030592343,-0.01799567,-0.0524116,-0.013603508,-0.036145806,-0.0022327402,0.075362325,0.022391137,-0.018174483,0.061062086,0.05324474,0.017046845,0.00033932365,-0.0087127825,0.028797997,0.027734024,-0.004271503] [-0.00019252417,-0.033771712,-0.05627955,0.038293395,-0.00062639354,0.006595628,0.045828722,-0.0009663238,0.01238321,0.027024094,-0.018451992,0.024561722,-0.0021584164,0.019635478,0.040972274,-0.013976746,-0.0026264333,-0.033063002,0.020702625,-0.006544476,-0.016846968,0.008655776,0.06545751,0.06775526,-0.051006436,0.052764133,-0.018375263,-0.034172762,0.0034575153,0.009479534,0.020113256,-0.02095754,0.00032960193,-0.018033698,6.1694576e-05,0.0008665726,0.017776996,-0.007001907,0.015734755,0.0066505126,0.021087777,-0.034721915,0.018155172,0.02010673,0.021841234,-0.0033975474,0.011447304,-0.046741758,-0.0025419795,-0.02814557,-0.010004121,0.013090699,0.012801124,-0.015271847,0.035186335,-0.033883683,0.010247853,-0.00015631721,-0.0074157934,-0.040261142,-0.0076220767,-0.024571642,-0.0033066939,-0.020512726,-0.0035894432,-0.0065469313,-0.0064785294,-0.03718849,0.0108067775,0.003016937,0.0042837714,0.022382692,-0.003002771,-0.017855488,-0.017484076,-0.04083167,-0.020247154,0.016041642,-0.036079925,-0.0047894544,0.00467384,-0.017031444,0.014986495,0.02529977,-0.009637842,-0.021811012,0.013172874,-0.011390123,-0.012385833,0.006664752,0.008967306,-0.011494084,0.023229856,0.057193156,-0.0143542625,-0.0016701156,0.0023387582,-0.008297047,0.0013526058,-0.00045184858,0.019942848,-0.0052123787,0.00016456634,0.013690967,-0.00020824932,-0.0035324707,-0.012488675,0.009343276,0.0063486197,0.011017584,0.015316514,-0.0041657994,0.01076701,0.051672686,-0.018880218,-0.0034769035,-0.014281629,0.02084551,-0.0081829475,0.038303785,0.0007424671,0.0039438726,0.026565745,0.0025775377,0.005599008,0.029758371,-0.0049031666,-0.024738295,-0.003881723,0.020733647,0.0028460007,-0.0018054065,0.0004731318,-0.010450301,0.012524181,0.0047974302,0.009969004,-0.014283658,0.00027790226,0.016395424,-0.018456543,-0.0020619691,-0.005819379,-0.021596354,0.009566024,-0.004148301,0.009588729,0.0117024835,0.00494819,0.011373085,0.055386733,-0.0061210417,-0.001480582,0.00848217,-0.0046827276,0.010621524,-0.009982643,0.030540088,-0.007635367,-0.01242421,-0.007545901,-0.021310639,0.029703235,0.0014267266,-0.0022033125,0.00028099937,-0.017175315,0.018047275,-0.0008124097,0.032504924,0.003156725,0.00033532523,-0.02151972,-0.0013266096,-0.016982652,-0.0011337886,-0.017001474,0.047759455,0.005067791,0.012955275,0.0050433823,0.027239215,0.0062508197,-0.006434355,-0.0061683687,0.01371301,-0.00014455272,0.009575896,-0.0008157396,0.0072202957,0.015469426,-0.058494203,-0.024390237,0.009181285,-0.01808931,-0.014515473,-0.00050903775,-0.008280141,-0.010959768,0.006875018,0.014403036,0.022036372,0.022632014,-0.00088396017,-0.0023595884,-0.0017852681,0.004392236,-0.0057824133,0.0051149563,-0.01204692,-0.0014418771,-0.027921172,-0.010651918,0.01983228,0.0008326658,0.004232973,-0.0041961237,-0.016969582,-0.007484867,0.018214302,0.01232795,-0.0017647541,0.016740087,0.009296498,-0.008621758,-0.024465092,-0.011231527,0.00052650983,-0.0024810296,-0.015028054,0.008900016,0.0023142325,0.0029018235,-0.016646175,0.005682545,0.014023593,-0.020264063,0.018891208,0.0044940617,-0.042017598,0.02862274,-0.014263396,0.007790642,0.008551346,-0.00401547,-0.007097106,-0.00879536,-0.020438883,0.025700908,-0.018303316,-0.00022046761,-0.008857767,0.010044001,-0.02187845,0.01418248,-0.0152515,0.003165969,0.012968897,0.03403713,-0.013169446,0.0008274198,-0.01353996,-0.021131065,0.0034353267,-0.0035939505,-0.0081124855,0.0025626363,-0.017111635,0.0058680503,-0.0076893535,0.065117985,-0.01567176,-0.026103359,-0.0055577955,-0.003198593,-0.018663306,0.0141066,0.007976898,0.02637274,0.014371856,-0.023863932,0.019242132,-0.012653441,-0.0020589076,0.0006116671,-0.0010301059,0.013025515,-0.005161825,0.0104970075,0.011702158,0.0070884842,-0.029269328,0.012564426,0.004678169,-0.044298586,-0.01703678,0.0017714082,-0.0077372272,-0.0058278134,-0.023543349,-0.013153649,0.009485462,-0.0056416774,0.0037390604,-0.006858028,-0.020303873,-0.007324672,-0.00051965204,-0.00952308,0.05013357,0.010836074,0.007781069,0.0048699006,0.0009973508,0.006742018,-0.01717254,-0.010372658,-0.009519705,-0.02979504,-0.015863493,-0.005293348,-0.04912722,-0.03466637,0.0035229214,0.014430278,-0.009142247,0.017709002,0.011457295,0.00014039416,-0.013665388,-0.027551178,0.014142347,-0.035376012,-0.019448897,-0.026082989,-0.0016797214,-0.0076353587,0.03602533,0.01604399,0.00018925867,-0.0075881104,-0.018079689,-0.017181395,0.029420685,-0.019561257,-0.005968356,0.009585125,-0.0056082914,0.017931944,0.020616427,-0.014434182,-0.018468171,-0.018653212,0.020158187,0.012881204,0.031109916,0.0017802166,0.008096922,0.0021968004,0.003335075,-0.014177861,-0.0068341186,-0.0063340534,0.00778475,-0.033578783,0.00063783425,-0.005164099,0.024171168,-0.009223191,-0.018979797,0.017352287,0.0028206364,0.024516081,-0.009426374,-0.012008101,-0.015755463,0.009010279,-0.0085565485,0.005836625,0.013609559,0.007424912,0.013002375,-0.012339376,0.009739632,-0.0070970734,0.0070420248,-0.044984646,0.01749466,0.0063126837,0.018628387,-0.0024826042,-0.03555942,0.010405287,0.012814976,-0.0028506615,0.0024897954,0.014011422,0.0026719496,-0.013105243,-0.017282834,0.006688114,0.012794985,-0.022106223,-0.035087675,-0.005269346,0.004778064,-0.028009651,0.013565646,0.0024955932,-0.0004327023,0.0055317227,-0.0008848176,-0.008830862,-0.033014692,-0.042735677,0.017482491,0.020770352,-0.010868822,-0.0013205179,0.0016908,-0.016910411,-0.030187782,-0.014469233,-0.01097793,0.016570125,-0.026714865,0.0011759902,0.015378447,0.012291336,-0.008820714,0.008659701,0.00071471295,-0.023083547,0.021545047,-0.018531112,-0.01126293,-0.006297099,0.042401552,0.017139139,-0.01781661,0.009757402,0.025294328,-0.01511709,0.008708012,-0.010626418,0.027605515,-0.008633303,0.0006283629,0.025893906,-0.0102608055,-0.03979812,-0.05540433,0.006588143,-0.004278783,-0.026208423,0.017940024,-0.00087762816,-0.010022081,-0.06013983,-0.00084146985,-0.060525313,-0.051693574,0.017411971,-0.026206648,-0.016669199,-0.00083048944,-0.0030420877,0.008104969,0.0034837096,-0.010500651,-0.010576801,-0.008946436,-0.001591939,-0.011998114,-0.010759274,0.019833399,-0.008348229,-0.005128805,-0.0077084587,-0.0054613394,0.012614729,0.016838457,0.01092045,-0.016635401,-0.0050677364,0.05244861,0.012172647,0.01871413,-0.009717314,0.005264881,-0.01534188,0.0034636857,-0.010624439,-0.027667895,0.02943751,-0.0032799458,0.043477956,-0.030133605,-0.03400375,-0.0014063291,0.014901157,0.0040629585,0.005879232,-0.01542115,0.0017559171,0.019121746,-0.026187867,-0.039979164,-0.007817752,0.020047626,0.040794656,0.014820129,-0.026036913,-0.02046738,-0.014990585,-0.014646654,-0.023237957,-0.013932593,0.022122554,0.009844923,-0.016924808,0.03972572,0.037525166,0.0012950519,-0.021968674,0.011339335,-0.010867483,-0.026040295,-0.011389867,-0.026270919,0.008979359,-0.0050662854,0.0060698306,-0.0067802663,-0.02109684,-0.005169535,0.010354023,0.017447544,0.008257972,0.016564677,0.0030867003,0.02381523,0.0025959972,-0.0057440614,0.021691957,-0.0061120475,0.0071023586,-0.0032437462,0.01546311,-0.009575631,0.037371114,-0.0018333984,-0.013948835,0.011070744,-0.0031324641,-0.006542841,-0.010115169,0.0048950114,0.03708298,-0.0051441416,-0.0041410355,0.018462325,0.016429037,-0.012556505,0.029851643,0.024488742,-0.0031614702,0.0051332624,0.028678795,0.015805814,-0.0120259,0.015148694,0.013541458,-0.0015850657,0.013861973,-0.0016554868,-0.002474601,-0.030345224,0.0060995,-0.0043338826,0.014857378,0.029244566,0.00730056,-0.0012673937,-0.0167561,-0.007880172,-0.025132142,0.001477976,0.018058091,-0.0052494532,0.0110190185,-0.02814669,0.013112085,0.029839102,0.012656859,0.007478881,0.01887028,-0.020325575,0.008228937,-0.02236927,0.022019353,0.0047798194,-0.022144902,-0.002013153,-0.033483353,-0.0056785694,-0.0037166406,-0.021760117,0.008394862,0.011361886,-0.0012005388,-0.017982483,-0.033813477,0.019978665,0.053946137,0.025171345,-0.0056558507,0.012926352,0.008714649,-0.010577153,-0.025854759,-0.02132291,0.033408344,-0.0016115415,0.04470113,-0.034292225,0.0017418567,0.0061095436,0.0071995817,-0.008314166,0.017520253,0.05492593,0.017552197,0.0050094253,0.021903282,0.005920449,-0.0026566647,0.027234536,0.019496862,0.0104700755,-0.0056136153,0.01758598,0.0063672406,0.0039915894,0.021114344,-0.010805477,-0.016861957,0.028330993,0.009753048,0.004651295,-0.0009716239,-0.008314697,-0.0146046765,0.020035185,-0.013117426,-0.016111702,-0.0045222524,0.0005316935,0.0049842745,-0.02060795,-0.020820979,-0.01670269,-0.011942186,0.016318321,0.022926405,0.011319057,0.0070607807,-0.039795335,0.027059704,-0.011352914,-0.0044782795,-0.036542114,0.028035149,0.00907929,0.034948517,-0.039104953,-0.01685875,0.0039834967,-0.029103585,0.016478779,0.032136906,0.020658722,-0.0079221325,0.015217214,0.0072752303,0.00713114,-0.0128084095,0.0013588056,0.0146800345,0.03465226,-0.026371907,-0.032648854,0.039215047,0.015294337,0.021830287,0.011025348,0.005569936,0.009892777,0.008484267,0.030825425,-0.026673956,0.0067180083,0.010323148,0.01698157,0.016741976,-0.018776571,-0.010075202,0.022479936,-0.009581413,0.0074330266,-0.008172817,-0.014976455,-0.022647394,-0.021950668,-0.03702254,0.009021229,-0.024416259,-0.004530435,0.009511884,0.0029016025,-0.004153413,0.018889334,-0.0012593032,-0.0016886727,0.0071483925,-0.015087139,-0.00080798456,0.016284607,-0.018007956,0.056522857,0.0068851975,-0.038764697,-0.0007095732,0.011614692,-0.012044778,0.03078123,0.0074119405,0.008811502,0.015624453,0.016010452,0.014847024,0.028636811,0.014628848,0.004692473,0.02154115,-0.035491243,-0.010708618,-0.002249813,0.0045733145,0.0054955576,0.0056152865,-0.021179311,0.0011795657,-0.016132431,6.881954e-05,0.0167103,0.017943537,-0.017914785,0.0070032594,-0.014026566,-0.04405952,0.0033189266,0.007459377,-0.0428499,-0.033391975,-0.008785815,0.020260043,0.0016398958,-0.027105048,0.012858808,-0.0138429925,-0.016155934,0.029222067,-0.008874405,0.0102734715,-0.0320879,-0.03774989,0.013478621,-0.004532042,-0.019448124,0.0119115645,-0.008788139,0.027291086,0.00793536,0.02188461,0.023421993,-0.02704938,-0.0458541,0.016972661,0.0008998358,0.0056555006,-0.03625657,-0.027565148,-0.008482522,-0.010395742,-0.0072981576,0.012985228,-0.0025077886,-0.02255513,-0.0031032343,-0.008541921,-0.006008743,0.03227099,0.010792549,0.006625345,0.0042486913,-0.0013268715,0.039832838,0.013064152,0.026956651,0.016064659,-0.02537931,-0.018917922,-0.034943823,0.0016367905,-0.018352654,0.030507745,-0.0026980154,0.021225126,-0.0105155995,0.009584375,-0.0007844456,-0.009170733,-0.0036737795,-0.0034814507,0.006818317,0.020651579,-0.012678139,-0.011714023,0.012531402,-0.03035083,0.041641008,0.016389873,0.043228086,-0.0015127172,-0.004280203,-0.010120798,-0.014087195,0.019988535,-0.018678889,0.012218358,0.0013110967,0.024909448,-0.017993744,-0.02334629,0.013264405,0.010280324,-0.03804022,-0.023560384,-0.006844284,-5.615252e-05,0.014562966,-0.010993122,0.044441454,-0.038346026,0.0033935318,0.0011327853,0.01979888,0.0014477761,0.004310085,0.024518255,-0.02120905,-0.017145986,-0.02457983,0.0039603002,-0.0049116807,-0.028978432,-0.014915853,0.0068722614,0.0010825611,0.023435274,0.02943662,0.04514806,0.005982295,-0.027954761,-0.0052096513,0.034557715,0.011106055,-0.011755243,-0.008494228,0.008347655,0.021295596,0.023602856,0.032115288,-0.012653757,0.017301362,0.0060009337,-0.00035226616,0.02895111,0.0021496073,-0.0023674753,-0.0039053147,0.005093628,0.020861953,-0.011227431,0.03230931,-0.017462969,-0.015029164,0.027480228,-0.040453676,0.009838841,0.005712362,0.0020768878,-0.02714231,0.034789328,-0.01453877,0.020321315,0.040355794,-0.041137256,0.043349564,0.0030256645,0.008531405,0.013115597,-0.031858966,-0.020601943,-0.00023715933,-0.0030456076,-0.025573632,-0.01838141,0.0029471654,0.03604272,0.0074674077,0.0039398344,-0.019265648,0.03422755,-0.020251835,-0.009488936,-0.02393395,-0.014843788,0.009023105,0.01714205,-0.005955258,-0.0043305447,0.017280752,-0.010335255,0.030908953,0.020888064,-0.002862139,0.0066387556,0.031593602,-0.00068568287,-0.01645763,0.0041032103,0.023095408,-0.013757631,-0.006824452,0.018429039,0.0324191,0.0026076692,0.015938345,0.0071795504,-0.018120833,-0.010608497,-0.02556865,-0.029882511,-0.0037403544,0.034462236,0.00955988,0.018418046,-0.014196838,-0.036327552,0.0075727813,-0.0037270153,0.056679316,0.014720027,-0.02814383,-0.004266867,-0.011530716,-0.014440789,0.032489788,-0.0015570191,0.014554365,-0.0040170923,0.0037300773,-0.008462585,0.03422077,-0.025960155,-0.01102745,-0.024340896,-0.023580913,-0.0066923494,-0.0052554556,0.023056217,-0.012034,0.0076307603,-0.042758048,0.023527108,0.0112589765,0.028917024,0.0180541,-0.011114093,0.026203506,0.0035046423,0.031856947,0.036594234,0.031540006,-0.020990204,-0.012347909,0.025275832,-0.028994588,-0.020384049,0.023264663,0.019689582,-0.006434529,0.012777447,-0.02902041,0.014748159,-0.012088968,0.010337802,-0.0072232275,-0.03032637,-0.047148153,-0.029534183,-0.030036062,-0.028678171,0.0010849278,-0.0042603468,-0.0010545104,-0.027276864,0.0019583034,0.012373353,-0.0014461852,-0.0024038446,-0.042402223,-0.0066836653,-0.042749804,0.0032151432,0.0025244146,0.008213736,0.00863159,0.032163013,-0.033896975,-0.030023254,0.022223132,0.0052486463,-0.01766487,0.01040339,0.021909263,-0.012970603,0.0077409167,-0.015580862,-0.014562627,-0.009594662,-0.0125125935,-0.037662543,0.0010296508,-0.026409894,-0.004608225,0.007945412,0.008557954,0.01439755,0.013511773,-0.011683429,0.026568584,-0.012437417,0.010883697,-0.016534148,-0.02429613,0.0070703705,0.023211835,0.018843086,-0.020997433,-0.0067835627,0.0025982312,-0.0024741543,-0.037569053,-0.012336611,0.024122251,-0.010623721,-0.0021033736,-0.03217016,-0.011221704,0.011255074,0.0044791205,0.028628664,-0.032810226,-0.0065754424,-0.004185527,-0.01948019,-0.0009917455,-0.033617623,-0.0116180545,-0.018101826,-0.008122415,-0.013203052,-0.022684706,0.04260158,0.009429882,-0.018378539,-0.0035686728,0.027247855,0.00979327,-0.0017219968,-0.027991898,0.01798864,-0.04642005,0.010362706,-0.012111567,-0.012903011,0.033549096,-0.0068158894,0.013277271,-0.027543703,0.00207384,0.0059262146,-0.009477424,-0.025416682,-0.03557082,0.0009605675,0.028150612,-0.014689416,0.022264794,-0.010996427,0.01006793,0.037420418,-0.003001003,-0.03038836,-0.0024878744,-0.009606544,-0.0076408964,0.010227826,0.015175094,-0.0077494727,-0.007895571,-0.045676034,-0.030058963,-0.0016347336,0.00042808673,-0.025590083,0.04585597,-0.015212449,-0.0033132893,0.00079214346,0.015591741,0.019935315,-0.022308106,-0.013462175,-0.0009491358,0.008607384,0.007989707,-0.027073083,0.02102765,0.024617175,-0.0152383335,0.018047575,0.03168939,0.033849817,-0.003954543,0.02568652,0.00087464723,-0.011780848,0.011195862,0.0030331998,0.003948108,-0.021969687,-0.0075128595,0.043775488,0.031470824,-0.029331598,0.028545218,-0.0017987157,-0.006673713,-0.003600531,0.015010743,-0.029361337,0.016260022,-0.0034445687,0.020471688,-0.009758407,0.013397424,-0.04831667,-0.04708006,-0.00066950324,0.028930044,-0.015903296,0.01718894,0.0027081373,-0.019335432,-0.00612732,-0.009862845,-0.006788948,-0.021932552,0.0066732364,-0.020147184,0.002459763,-0.007395861,0.0042710183,0.019399816,0.0045990953,-0.0032201135,0.008808653,-0.021974036,-0.022428665,0.009444445,0.014687522,0.012620298,0.0431325,-0.02794735,0.0013217544,0.0055215517,0.004045073,-0.058965437,0.0010666852,-0.004098625,0.009351695,-0.01886388,0.012421748,-0.0054289573,0.008818262,0.018813662,-0.013956191,-0.024323752,0.002797987,0.030962495,-0.01503711,0.0036894016,0.029020367,0.023513727,0.01421334,-0.029801577,-0.0049290177,-0.006663377,-0.012907229,-0.0056958417,-0.0016896029,-0.021210732,-0.008535679,0.02877059,0.011699727,0.0033280991,0.02830189,0.0107163945,-0.023130307,-0.009818809,-0.009741974,0.006421592,-0.014155573,-0.005859511,-0.017917704,0.013137916,0.017202303,0.028875092,0.008364231,0.0008102544,0.041279316,-0.0029981448,-0.01984745,0.0033934154,0.015867516,0.004821395,-0.024693435,0.030527167,-0.016702043,-0.0009631416,0.017049495,0.021633001,-0.002871714,0.006683754,0.006529033,-0.00017979903,-0.026452523,-0.015525544,-0.005642305,-0.00900644,0.0008995252,0.021834034,0.006462624,-0.0060220687,-0.0043851086,0.004991707,0.019697899,0.011586094,0.0050801523,0.048567228,-0.029488917,-0.001957704,-0.03436862,-0.0103394035,0.0021146333,-0.012483012,0.006488654,0.0012822006,0.0072447797,-0.020144904,0.0067724986,-0.009042598,-0.018344017,0.03651017,-0.03742658,-0.007882753,0.023239905,-0.021628521,-0.03863364,-0.0141335055,0.017038677,-0.03429325,0.0026703256,0.009566321,-0.015642397,0.03417418,0.020862147,-0.034549862,0.019927857,-0.030998956,0.019852588,0.0020339799,-0.008078372,-0.028416835,0.009684199,-0.026758607,0.015174815,-0.0016200849,0.037894495,0.008471771,0.029528093,-0.025924377,-0.016583707,-0.019555587,-0.014404223,0.008026038,-0.009203363,-0.0007286753,0.032863274,0.019177996,0.030829309,0.016042203,-0.014281273,-0.01601909,0.0008475609,-0.010480826,-0.012134504,-0.004009237,0.002794664,0.035580434,0.0064091585,-0.019711833,-0.006122261,0.012693208,0.02645703,-0.004806822,-0.017330363,0.008751837,-0.036495738,0.022953283,0.026945094,-0.018625243,-0.023062032,0.020207874,0.027971683,0.0012530187,0.0066191964,-0.0028882176,-0.013791991,-0.0073297736,-0.0039101345,-0.008323168,-0.03281862,0.014541083,-0.01631492,0.032593198,-0.0038269048,-0.03931945,0.009494454,-0.0071508484,-0.022354899,0.020652084,0.010989855,0.01189063,0.0071332115,0.015793048,0.003042386,0.0026978075,-0.012863839,0.02139497,0.03144135,0.032927558,-0.014602287,-0.01614877,0.004781959,-0.02722169,0.019703161,-0.048345275,0.021153955,-0.012116884,0.031386074,0.018511327,-0.0072206934,-0.0038577763,0.0141208,-0.0041212775,-0.000741053,-0.01958569,-0.007841757,0.007300397,-0.003272187,-0.0064391317,0.037232634,-0.02070106,-0.008333556,-0.004554801,0.0045995964,0.0065124542,-0.02101884,-0.016905107,-0.0115905665,0.02375526,-0.0059541375,-0.040486153,-0.032234177,-0.035153545,-0.0042267516,0.043224227,-0.037080303,-0.01354607,0.024145834,-0.023604233,-0.008771717,-0.01390489,0.026766846,0.00889274,0.04234096,-0.023005847,0.020354366,-0.005064077,-0.008217369,0.007017401,0.0047547384,0.03270705,-0.023168636,-0.023604464,0.011707397,-0.0007857147,-0.0134965,0.0008690079,-0.01800392,0.010703998,0.009845625,-0.013793806,0.016806046,-0.013142304,0.009187285,0.0023055198,-0.008876687,0.011104206,-0.01861525,0.0052564195,0.002866296,-0.015932662,-0.02980103,0.0025577715,-0.019008249,0.0013258063,0.021348821,0.006043424,0.017466815,-0.00081073743,-0.023840398,-0.0220921,0.027745698,0.012588237,0.012409255,0.0013003061,-0.020201564,0.0144724455,-0.0045151655,0.0074424897,-0.007891559,0.02167215,0.0110227335,-0.008021922,-0.023251254,0.024435703,0.034091756,0.006167291,-0.030104926,0.009275236,0.0076611363,-0.005561455,0.011600638,-0.02089399,-0.028219117,-0.00053548004,0.012403644,0.014983149,-0.013884835,-0.008117628,0.0039914805,-0.02092183,-0.016936205,0.02721709,0.0099645145,0.01103366,0.019937439,-0.012731407,0.03321303,-0.0018965298,0.013216067,0.023371795,0.02078811,-0.014208768,-0.013584796,-0.03075338,-0.0104036005,-0.032642253,0.010717131,-0.0074159144,-0.005855159,0.00988718,0.0044918573,-0.0130995745,-0.023694051,-0.00976419,-0.008892499,0.018520381,-0.020302076,-0.0072085606,0.013504812,-0.005228,-0.033267166,-0.0004850496,-0.018458532,-0.0033164122,0.0015997294,-0.00051679864,0.011640634,-0.0109456675,-0.017934604,0.02791433,-0.028762598,0.035454255,0.018094033,-0.013049684,0.0026625204,-0.0073640235,0.018356003,-0.0031418386,0.030943923,-0.018523507,-0.02714447,-0.04952177,-0.015064176,0.025908133,0.019940013,0.015337555,0.018600142,-0.025197966,0.0028635284,-0.013626951,0.016986502,0.039685123,-0.020289497,0.0036008486,0.034907605,0.013990054,-0.002600184,0.0025707195,0.00882634,-0.05176249,-0.00999908,-0.0080138985,0.017147666,0.03724395,-0.03611782,0.020970087,-0.013849368,-0.0070107593,-0.02933381,0.01656419,0.016199807,-0.018783823,0.013954454,-0.00017826438,-0.0042162896,-0.022353336,-0.009325549,0.003566805,0.004987158,0.02086336,0.021068798,0.03231856,-0.0068845875,-0.034706604,-0.0017786484,-0.006506998,-0.011387323,-0.0014114222,0.0073309722,-0.0005872401,-0.0010416051,-0.009433475,0.012995961,-0.0019387616,0.0037927611,0.06116552,0.010396803,0.022075972,0.031173682,0.025155855,0.04310352,0.031372923,-0.0035696472,-0.025831562,0.04483449,0.035357855,-0.0013877494,-0.01422573,-0.011235307,0.0004102838,0.022873925,0.013825854,-0.0152263325,-0.015450858,-0.03281999,-0.0035412884,0.009055088,0.027676586,-0.02305673,0.020495271,0.018343639,-0.0025671446,0.04724789,-0.031987917,-0.024249941,-0.024574991,0.015686529,-6.190664e-05,0.00995686,0.010774134,-0.009829545,0.03201879,0.033994418,0.0024313172,-0.0027862212,0.030337622,0.0059719845,0.029363465,-0.0060915244,0.03128999,0.009528194,0.024907177,-0.029602768,0.0034927016,-0.01716352,-0.0018149224,0.014870933,0.033810247,0.019169174,-0.00042077768,0.05407715,-0.02202894,0.023905544,0.0359821,-0.006625832,-0.006460288,0.0018290246,0.018515376,0.0029052827,-0.028782088,-0.007659302,-0.0024399646,0.027219184,-0.016755393,0.026021373,0.011450921,-0.021178974,0.012779237,0.024036279,0.004955172,-0.012720218,0.015221442,-0.006672251,-0.029306514,-0.011296924,-0.0067861467,-0.04260546,-0.021078324,-0.025382327,0.010513966,-0.009214276,-0.03482578,-0.0055667204,-0.004356873,0.016089385,0.019975534,0.0032288593,-0.041061096,0.003736417,0.0004158672,-0.020190613,0.012216458,0.015510439,0.0017978287,0.018557554,0.017738543,-0.003114589,0.017670425,0.0036653245,-0.018688861,-0.007855263,0.00074979104,0.007993101,-0.008767294,-0.008441989,-0.015726244,-0.008885304,-0.013601812,-0.06070058,-0.024474101,-0.015004189,0.017547255,0.0043287557,-0.002120117,0.015372023,-0.009278462,-0.0054616304,0.033320058,0.013249043,0.029273774,-0.0087545235,-0.0056549823,0.039151788,0.009608453,0.006031541,-0.029085336,0.033850994,0.00967032,0.009535115,0.025220394,-0.0010983504,-0.015812004,0.031821743,0.010781969,-0.007748587,-0.035156123,0.0090035135,0.0041750744,-0.021444514,0.0298365,-0.02150177,0.0025473288,-0.02417607,0.013682953,-0.008023337,-0.0038160344,0.053494,-0.009995971,-0.0011182909,-0.013957258,-0.014299472,-0.01573153,0.017394021,-0.010179862,-0.023082102,0.016906625,0.00834162,-0.018048873,0.003635487,0.021262335,-0.028427655,-0.015183039,0.010691414,0.019060928,-0.01390489,-0.04232216,-0.017913492,-0.011743584,0.016395358,0.006206688,-0.02582973,0.020529877,0.00057469425,-0.027888754,0.013280787,0.011420694,0.013218629,-0.032083556,0.011400157,0.0028004122,0.018788094,-0.02968197,0.034821294,-0.042461954,0.0002680436,0.008949533,0.008480698,-0.016274791,0.00535699,0.02217737,0.02010428,0.012707859,0.0091487,0.015144508,0.039339613,-0.014729607,0.0010613154,-0.0056269467,-0.010378973,-0.007813558,-0.02390261,-0.0004669709,-0.02665291,-0.04339343,-0.0059471857,0.022973172,0.002927526,0.018856572,0.00024220259,-0.011205707,-0.018307604,-0.01138987,0.034013543,0.0040533333,0.008717726,0.023031492,-0.0166252,0.00025535974,0.017279966,-0.0016253613,0.012079321,-0.014658815,0.0077523403,0.008222401,0.0032407427,-0.025806258,0.00027937474,-0.028764876,-0.00682039,0.00861509,-0.02296107,0.0033611534,-0.027009444,-0.013135416,0.0015663616,-0.04262566,-0.030835208,-0.0426853,0.023282949,-0.0073217107,-0.014783083,0.007829892,-0.003576975,0.00813381,-0.0041060224,0.009186398,-0.02402295,0.0038786838,0.013960765,-0.0066947513,-0.030198371,0.027511358,0.040205594,-0.014534776,-0.0018555552,0.03929264,0.008454728,0.00034978456,0.03219772,-0.00044034445,0.0007793928,0.0013168476,-0.019190198,-0.008397446,0.007420415,-0.008034872,0.01050861,-0.008951014,-0.014522844,0.008098349,0.021213671,-0.009071633,-0.033751372,-0.023644352,0.018183652,-0.011752889,-0.0010423667,0.00978338,0.017380925,0.014030245,-0.018827414,0.00953333,0.013430888,-0.010892375,0.02960905,0.020838678,0.01627366,-0.013951359,0.020135336,-0.0030702867,0.020789962,-0.0062194252,-0.001012953,0.019320065,0.015712714,0.012225714,-0.017461179,0.033049233,-0.013821144,0.023732789,-0.032579448,0.007832988,0.03513204,-0.051598065,-0.0030731207,0.010189243,-0.022056542,-0.018258844,-0.004711044,0.0022578225,0.0012632872,0.018924864,-0.012586061,0.00937662,-0.014295592,0.015062696,-0.029504465,0.0011125304,0.021309115,0.032135565,-0.0057521034,-0.023326822,0.00821409,0.033626277,-0.0030236738,-0.009177966,-0.0051144506,0.03688925,0.014032628,0.012539537,-0.006929828,0.014747875,0.0019444299,-0.032010034,0.0056254077,-0.01461902,0.014635855,0.034792356,-0.0028070237,0.021407116,0.006545585,-0.0023803723,-0.057900324,0.008231429,0.004821897,0.0009823805,0.021563198,0.0031137299,-0.020965545,0.0176693,0.015616165,0.01398396,-0.00079751835,-0.016397927,-0.0041657393,-0.0046547665,0.028159913,-0.019272842,0.021260284,0.019634143,-0.021536477,-0.008084204,0.016966473,0.013074576,-0.053711377,-0.0067389007,-0.013509784,-0.010067668,0.033718105,-0.012555638,0.019496938,-0.015788333,-0.03770399,-0.002536408,-0.017109776,-0.006398198,0.01811399,-0.00392432,-0.018857395,0.014573331,-0.03960548,-0.0246005,0.016456917,-0.0029414103,-0.061918568,-0.026942993,0.0048153177,-0.008980475,0.011519085,-0.0515283,-0.025617577,0.041161023,0.0018567686,-0.023785543,-0.015067836,-0.020251514,0.03604793,0.013443703,0.013941062,0.0037669765,-0.014703264,0.012726392,0.0038744065,0.01576319,0.03973205,0.008077877,-0.00012057972,-0.0029586898,0.010069694,-0.016582808,-0.010870265,-0.022974888,0.0023011935,-0.015661348,0.007827367,0.010599285,-0.0048758714,0.0052430816,0.03578449,-0.024483941,-0.0136835305,-0.017731406,-0.0043849587,0.01494534,0.018468462,-0.005405657,-0.00043836224,-0.0048118513,0.014041734,-0.0020191954,-0.015142039,0.005459077,0.012438567,0.037135016,-0.005135934,-0.0243827,0.018321756,0.016914988,-0.03265541,-0.015338474,0.026595559,-0.020581458,0.018053731,-0.014547007,-0.0075235455,0.0004025799,0.01214307,-0.002566888,-0.0053435685,-0.014191511,-0.0033696827,-0.020749154,-0.017011864,0.0051381825,-0.0065456387,-0.023967145,-0.042113297,-0.015450566,0.009207063,0.016046517,-0.0057547395,0.006067247,-0.030490326,0.005927342,-0.013358434,-0.021601275,-0.0012148281,0.020530952,0.010537073,-0.0058263373,-0.018939529,-0.028451152,-0.033570588,0.001314956,-0.00022018026,-0.009988708,0.0037733018,0.00051285065,-0.008763106,0.00011408695,0.011589202,-0.012546494,-0.00062234723,-0.010049788,0.0014480328,0.014674621,-0.034767944,-0.022095589,0.0072127827,0.03290654,-0.017293205,-0.021598844,-0.0075467355,0.015059245,0.014218681,0.021452771,0.024610782,0.003359862,0.026644727,0.0050610625,-0.010845487,0.011637256,0.0052255476,-0.018379891,0.00024993985,0.015484112,0.0138565935,-0.030007586,-0.011658131,0.010270143,0.03344922,-0.012380963,-0.020516653,-0.0023086127,0.04418294,0.023485517,-0.013697631,0.02670673,0.007168797,0.0073668296,0.012603226,0.0114433775,0.0009168335,0.007275553,-0.020822663,0.00014523283,-0.010700744,0.0018813622,-0.00060774636,-0.010960819,0.015106708,0.037636045,-0.013249078,-0.012452458,0.0026334594,-0.0243987,0.015847666,-0.0013088923,0.021422822,0.015511621,0.019495359,0.00027045145,0.009810772,-0.044673026,-0.007451569,0.011343192,0.00041583198,0.02781877,0.030544192,0.04236916,0.020780459,-0.0027115068,0.01759504,0.009581012,-0.012545897,-0.0019284331,-0.03807522,-0.022459839,-0.029725073,0.043901306,-0.016981266,0.00686527,0.022822024,-0.0109077,-0.013310034,-0.009175817,-0.039535806,0.010942657,-0.040570907,-0.030753251,0.008405812,-0.0005427228,0.04255305,-0.0035361098,0.004313896,0.0048576468,0.037346397,-0.022396369,-0.03261093,0.024019858,0.00600756,-0.0049044373,-0.01263359,0.0035001987,-0.0023630396,-0.018292109,-0.039761182,0.029375153,-0.00551641,0.026422892,-0.024811773,0.028671222,0.018670244,-0.043186557,0.004844911,-0.007590756,0.011252772,0.03402073,0.024933688,-0.012817118,-0.04415915,-0.023089314,-0.006949113,-0.027912755,-0.013094624,0.010705628,-0.004724025,-0.0054006563,-0.0029721567,-0.04043363,0.0026005947,0.012508767,0.018607968,-0.014510876,-0.038147137,0.0016012153,-0.016313642,0.03712056,0.012630878,0.024781523,0.026578365,0.00624784,-0.01694119,0.016131636,-0.015762264,-0.020101864,-0.0263798,0.008685016,0.012737092,0.008669971,0.009769072,-0.020854693,-0.01971882,0.016417285,0.0129678715,0.0026396594,0.04035505,-0.011234848,-0.014341941,-0.012032603,0.013839226,0.056228664,-0.02625674,0.0013873009,-0.021670567,0.024633009,-0.006828204,-0.010536804,-0.00026471418,-0.016666582,-0.011745829,0.06752081,-0.020424007,0.011949283,0.0151477475,-0.016245358,-0.015729941,-0.015789155,0.028159792,-0.0073081516,-0.018159593,0.0018899272,0.0037399435,-0.026143573,-0.021987014,0.016550153,0.018871538,-0.017881177,0.017707521,-0.041669127,-0.031528257,0.024780337,-0.011772974,0.02636386,0.0074511124,0.011776759,-0.029733947,0.00730529,-0.0076513775,0.028400682,-0.025117714,-0.005321291,-0.02768507,0.008167702,-0.016161289,0.018971778,-6.3559026e-05,-0.035042915,-0.02924016,0.00014734015,-0.010794476,-0.00027934086,-0.037135586,0.013822794,-0.011765659,0.04011495,-0.024002274,-0.020225996,0.013838632,0.0021070365,0.013525088,0.01625664,0.0036242616,0.013678359,0.041392446,0.007741664,-0.006359324,0.004732614,-0.02212315,-0.029001096,-0.038295325,0.0025701476,0.032901194,-0.0033222814,-0.011085802,-0.0318422,-0.020573681,-0.026854437,-0.0042253216,0.029164951,0.023473093,-0.045299955,0.01838697,0.012225543,0.003231727,-0.009370401,-0.020492794,-0.020657867,0.018701129,0.029169397,0.0059940657,-0.00922737,-0.025404619,-0.0074049425,0.017024338,0.021353755,-0.029773738,0.024191054,-0.0232961,-0.028567996,0.019361196,0.021955628,0.002216748,0.017804122,0.0042807544,0.005313566,0.015544867,-0.01829035,-0.011752045,-0.030388005,-0.0348796,-0.002736485,0.00474711,-0.018218374,-0.009845701,0.0171951,-0.037180338,-0.026357725,0.025397195,0.0142067475,0.008444599,-0.0143807065,-0.00033777673,0.011085125,0.023705985,0.022957869,-0.016998751,0.027562883,-0.021468885,0.00753315,-0.020333838,-0.015388516,-0.0017910184,0.018549386,-0.004171442,0.015789516,-0.019639367,-0.030104036,0.026460301,0.015853055,-0.021853015,0.019635694,0.0071878927,-0.0030697566,-0.003478673,-0.019886993,-0.01998945,-0.03657484,0.006236228,-0.0033126352,-0.010689758,-0.021463584,-0.015389689,-0.033579536,0.013769829,-0.006207014,0.033800945,0.007441753,-0.00962099,-0.04429496,-0.011236525,-0.02528535,-0.026162455,-0.006939097,0.0012669916,0.03420196,-0.021515913,-0.018712856,0.011619402,0.007894077,0.037973225,0.009220152,-0.002548702,-0.0032108559,-0.011592386,0.0015188314,-0.020391276,-0.0037514633,-0.0048731132,0.026266223,-0.021264188,-0.030370004,0.001005227,0.018796371,-0.022753926,0.01806559,-0.036015,-0.037569117,-0.0032015494,0.0055819494,-0.02073395,0.009102717,-0.015869506,0.008361041,-0.016198725,-0.03566984,0.03453398,-0.016289689,0.016503297,-0.0037697763,0.015932133,-0.0073114987,0.005309773,-0.0023472162,-0.021451473,0.002448041,-0.013516726,0.010068783,-0.016137749,0.03579915,-0.02720281,-0.00065741007,0.010618626,-0.016830456,0.02649999,0.003175096,-0.0077338037,-0.0217344,-0.0005415661,0.003476338,0.019910397,0.030368723,-0.0067516775,0.01437904,-0.0062805205,0.010385805,-0.013043476,0.0061719846,0.021181932,0.021939676,0.015804574,0.011656019,0.0037459834,0.016267175,-0.014788033,0.0061075,0.0023365524,-0.007043209,0.04073011,-0.00082151126,-0.011377816,0.020039817,0.017942473,0.04838046,-0.016518535,0.016692497,-0.013558284,-0.017512662,-0.0137429675,-0.0049359836,0.011272426,0.008028994,0.009427725,0.013302058,0.035071872,0.041818205,0.02611986,0.0057670036,0.004885794,-0.0013117793,0.013582509,0.0050350237,-0.005667956,0.011754053,-0.012626069,0.02836019,-0.038151782,-0.016860293,0.017799666,-0.0065562692,-0.0154845,0.0211833,0.023923315,-0.003538441,0.015466208,0.009715646,-0.015689878,-0.0016879509,-0.007863574,-0.023133844,0.007899519,-0.004594176,0.00842771,0.0062131025,0.017548015,-0.005666428,-0.03906602,-0.0022803738,-0.001116818,0.03564478,0.021090357,0.011531938,-0.0052245203,0.008674643,0.007892089,0.0036759619,-0.0043045045,-0.009144441,-0.02167621,0.0042522815,0.011383036,-0.003129614,-0.009220433,0.00017165163,-0.0032099292,0.00039149096,-0.013936275,-0.057058517,0.0077927453,0.009526418,0.054318093,-0.0075708167,-0.027009677,-0.017691044,0.0029054661,0.028879,-0.0009701434,-0.019228928,-0.023470748,-0.035856705,-0.02319412,0.0025348775] +3 Vectors keep their dimensions across databases [-0.029333446,-0.027376162,-0.046158645,0.02763368,-0.022198169,-0.007034548,-0.0009618661,-0.03589987,0.0054650106,-0.03271758,0.001162794,0.022735396,0.006459185,0.03534111,0.024735821,0.0060454947,0.0060379687,-0.0055011944,0.010026699,-0.027854508,-0.018611174,-0.019197028,0.024327585,-0.02608653,0.0076513817,0.02082665,-0.051633544,-0.017097687,-0.0045321602,0.06145396,-0.0040171635,0.03803617,0.024428459,0.005325195,-0.0006458118,-0.009483129,0.038368378,-0.020847745,-0.0189865,0.05645034,-0.02216918,-0.014512725,-0.03037545,-0.02968811,0.025906064,-0.06714698,-0.0039153355,-0.02989166,-0.064616054,-0.016038427,-0.033149086,-0.011449735,0.037486814,-0.00074291695,0.081114374,0.053358756,-0.04680122,-0.01033885,-0.017597353,-0.0044983197,0.004964636,0.016411526,-0.025432989,0.010153162,0.0057384986,0.04623752,-0.0015436827,-0.012956755,-0.03321551,-0.043956656,-0.015801182,-0.017403172,-0.011954866,0.0097670015,-0.06503507,0.006352285,-0.019419199,-0.0015338355,-0.045458175,-0.0050419206,0.012299438,-0.06663015,0.043610267,-0.015062618,-0.010733142,0.03376307,-0.014934972,0.04205643,0.0018036453,0.030106133,0.036886167,-0.024071435,0.017904209,-0.015015124,-0.02537513,0.012832237,0.009045137,0.01677629,0.03074679,0.01605879,0.057556696,0.002869906,-0.020574227,0.055879507,0.052370485,-0.034803845,0.03709861,-0.0019683996,0.0010688667,-0.0054801563,0.008627805,0.008013592,0.015862256,0.004277648,-0.038571384,0.013573357,-0.02751778,0.0019606845,0.057930302,0.029494656,0.10213997,-0.017812356,0.05462907,-0.02790098,-0.032669213,-0.04595141,0.016787475,0.06118308,-0.001107707,0.01872381,-0.010967678,0.0150304185,-0.001141413,-0.032368597,-0.021272464,-0.037295453,0.015453898,0.045015223,0.0070143417,-0.049037695,0.016116444,0.0075397333,-0.048971675,-0.043254063,0.007999297,0.012070473,0.04793979,0.039340038,-0.015903115,2.3726972e-05,0.014757836,0.011860692,0.0100239515,-0.0046506543,0.031779554,-0.01337011,-0.015772035,0.038199484,-0.020516176,0.0031147017,0.008481235,-0.028663302,-0.015088137,0.012582921,0.049872734,0.028383197,0.018319527,0.05131884,0.022429613,-0.029274857,0.018008584,-0.016897831,0.04914627,-0.01833601,0.037739247,0.02080202,0.09582989,0.018323274,0.0022740306,-0.049761284,0.033855148,-0.012541166,-0.014991567,-0.016045686,-0.027919536,-0.012612591,1.0697605e-05,-0.021606004,0.005067748,-0.0094985105,0.031478252,-0.00812528,0.020367317,-0.04838695,0.009660434,0.0050167055,-0.054499123,0.027714409,0.019405091,-0.02700134,-0.029984491,0.019761076,0.022033304,-0.02236894,-0.07369828,0.0015130575,-0.062195033,-0.023185687,0.045756463,0.012704593,-0.015717339,-0.00082821125,0.013417033,0.026217217,-0.022133615,-0.04683392,-0.011715892,-0.005527822,-0.06612307,-0.07599161,-0.00887198,-0.03291811,0.03781812,-0.014312874,0.012629221,0.017839052,0.0074740457,0.03272027,-0.0015100408,-0.048114248,-0.032650623,0.040536758,0.004361719,-0.0036247373,-0.008484719,-0.030584473,0.012850358,0.00065506616,0.010857139,-0.01750601,-0.018242832,-0.015950887,0.04756776,-0.013585697,0.0026687142,0.022420915,-0.027756153,-0.007698197,0.0056079044,-0.011846419,0.042385,0.025956796,-0.022793923,-0.022417616,0.016232649,0.0012295566,0.045980684,-0.008753074,0.011174464,0.030919407,0.02451843,-0.053462148,0.029251423,0.013177731,-0.0034769643,-0.0134544745,-0.015346658,0.007103889,0.0044397837,0.021587592,-0.024043158,-0.02932423,0.01908122,0.019921903,0.01640709,0.04215539,-0.016815146,-0.032026794,0.03412182,0.037454303,-0.020039903,0.006201385,0.02416491,-0.040276177,0.02490559,0.0034252817,0.0042636944,0.019132914,-0.02089475,-0.0056838933,-0.032442436,-0.009259598,0.06238864,0.026814407,-0.009784949,0.008889171,-0.013706507,-0.14632314,0.015244647,-0.027748702,-0.008533358,0.012029823,-0.04365612,-0.025284149,0.019784713,-0.044076715,0.03130935,-0.03247965,-0.084567636,-0.03300403,-0.0020318578,0.04322629,0.020044958,0.005449384,-0.021776535,0.017797519,-0.011965508,-0.00866529,-0.053060643,0.07990682,-0.038241405,0.011096165,0.009381748,-0.03170154,0.040918272,-0.03116569,-0.054631677,0.018938355,0.01738502,-0.00684746,0.036608696,-0.025523167,0.011564517,0.020111298,0.004605117,-0.04005991,-0.012793379,0.006682595,0.01542957,0.0036480618,0.031014737,0.020500207,-0.034425065,-0.018776994,0.015812995,-0.04516439,0.028654156,0.008136294,0.020024175,0.025083322,-0.044177357,-0.057484724,-0.0033119393,-0.03665119,-0.022698907,-0.011092054,0.043891653,-0.022882784,0.04422569,0.03238766,0.011123435,-0.037525762,-0.031239117,0.06610348,-0.007832436,0.02429703,-0.0034696013,0.043876436,0.011467102,-0.040866803,0.004007243,-0.053077895,0.023436742,-0.036585007,-0.0035024723,-0.034953117,-0.103166,-0.013732003,-0.034204606,0.02635754,0.038101744,-0.037489798,0.014889373,0.01878825,-0.0018425015,0.009313169,0.190407,-0.02402869,-0.058334064,0.058377203,0.03793641,0.013604979,0.10196709,0.032855302,-0.027149517,-0.012904168,-0.01934908,0.03792364,0.0075704986,0.014568061,0.0005988793,0.009385194,-0.042304747,0.0004755744,0.051037017,0.011967429,0.005477603,-0.0035236059,-0.00029812814,0.026071047,-0.03839839,-0.045428943,-0.008678882,-4.3209557e-06,0.021252342,0.033347357,-0.029532945,-0.01580361,0.019544117,0.00759244,-0.031433817,0.012868066,0.0067056175,-0.027094672,-0.006131591,0.024251364,-0.05713055,-0.017473638,-0.034333616,0.02798459,0.02009953,-0.032097362,0.021780131,-0.008192174,-0.014866446,-0.032006778,-0.023819577,-0.0064002005,-0.0326601,0.017204557,0.030548833,-0.01904191,-0.016218167,0.049270503,-0.051085655,0.044154722,0.06581113,-0.009754529,-0.016766697,0.007955952,0.008707909,0.021783844,0.027594127,-0.0036714494,0.017971108,0.05750172,0.062335387,-0.00885033,-0.017243234,-0.04124337,0.037340946,0.009955808,-0.03691573,0.027036857,-0.012654619,0.016035007,-0.05917357,0.00019068556,-0.043852147,0.02485079,0.013940174,-0.005675954,-0.07181133,0.03881821,-0.0005123167,-0.04093811,-0.0025317518,0.014419913,-0.032987278,-0.03996644,-0.020380406,-0.0015570496,0.002611454,0.0019744595,-0.022151692,-0.01317456,0.0081594335,-0.036674343,0.0016038102,-0.015327253,-0.025513466,-0.050297055,0.0049773827,0.008481122,0.029558418,0.02937018,-0.033947464,0.044330623,-0.044510823,0.012111756,0.0338498,0.0140434075,-0.01909341,0.006771259,0.025869329,0.008106698,-0.025939476,-0.00039744994,-0.015473609,-0.030241806,-0.023562241,-0.00035817642,-0.02306126,-0.026738621,-0.017222013,0.02519606,0.027141228,-0.029599965,-0.031398725,0.018504873,-0.028710447,0.006488162,-0.024374697,0.015002938,0.009071608,0.01356438,-0.013251675,-0.024737356,-0.014176474,0.039886724,-0.008202082,-0.045126416,0.039480776,-0.009152213,-0.008650557,0.009203944,0.002761768,0.025322855,-0.038974848,-0.036624804,-0.03630671,0.0008021613,-0.044510644,0.037596323,0.035104018,-0.013973098,-0.025975995,-0.0059320615,0.006446839,0.011963571,0.00057909713,0.03628423,-0.02492371,0.03564903,-0.036071233,-0.026992705,-0.033848632,-0.013566403,-0.03994821,-0.0038874776,0.05075628,0.025111308,-0.06357874,-0.025137642,-0.0553137,0.03347527,0.01277552,0.00090748974,-0.0018254681,-0.005111715,-0.033265043,0.05602245,0.012723619,-0.005254591,0.008316509,-0.015717287,0.006568546,0.0809292,-0.043748528,-0.014972693,-0.018101964,0.01324909,-0.033238657,0.032533772,-0.04637504,-0.061423812,0.027920846,-0.019625569,0.028191518,0.036334194,0.060437236,0.029438427,-0.02906909,0.010536573,0.008364404,-0.014193433,-0.041570157,0.0029708934,-0.05535154,0.019963067,-0.0038184244,-0.030020926,0.020233575,-0.028878266,0.012593645,-0.023379087,0.01980039,0.03258346,0.020406555,-0.029694023,-0.061307427,-0.06730788,0.027220272,0.025574205,0.012835337,0.024300167,0.010423538,-0.012489839,-0.05655118,-0.029168857,0.06432804,-0.022533445,-0.017668981,0.04131465,-0.01073021,-0.013670631,0.03964141,-0.0010034805,0.022566186,0.028041054,-0.008760624,0.008789727,0.012590716,-0.034946207,0.02955112,0.022575531,0.006259226,0.058588564,0.041204613,0.008901373,-0.031471033,-0.0027080935,-0.01468084,-0.01423707,-0.012967215,-0.022428067,-0.047186736,0.042731266,-0.031659298,0.027722415,-0.028962485,0.008674669,0.034335323,-0.0470279,0.006317215,-0.041403618,0.011861019,0.007096954,-0.03327483,-0.047531337,0.062276054,-0.010225871,-0.016377319,-0.03140778,-0.025085358,0.044281956,-0.016543714,0.052803863,-0.00515951,0.03838512,-0.06139526,0.00436239,0.010075422,0.0073351553,0.023904715,-0.0019229884,0.023520412,0.0052258377,-0.027075944,0.020639142,-0.038579814,0.0029797957,-0.0421245,0.029313933,-0.03193641,0.031105623,0.0031888841,0.005782779,-0.0401598,-0.0074204863,0.016376354,-0.014815908,-0.021554776,-0.011922191,0.025391463,-0.0013625894,0.009663616,0.022639727,0.028747723,0.0032545996,0.02532937,0.013959018,0.043283567,-0.034498364,-0.027206065,0.011703796,0.0080136135,-0.02964618,-0.0016242961,-0.016096199,0.039183702,-0.03460305,-0.0739217,0.009309757,-0.030748548,0.04878833,-0.014270232,-0.0075658904,-0.029258108,0.0075084027,-0.0008684732,-0.012298997,0.022158893,-0.07139871,0.011619489,0.021110244,-0.01965058,0.004990677,0.06718976,-0.03978912,0.012969088,-0.014227144,0.03301615,-0.021471152,-0.011107474,-0.03011924,0.00012127554,0.013490023,-0.011996094,-0.04386099,-0.015490485,-0.02993783,-0.04832035,-0.036736254,0.049405575,-0.00061551074,-0.018171735,0.0074466486,-0.0015040226,0.0015025823,0.02551032,0.02774402,0.03862531,-0.047357712,-0.022107622,-0.037010103,0.016250037,0.048177816,-0.055443317,-0.0012269584,-0.019506609,0.052924093,-0.0234052,-0.0065591694,0.044548422,0.02225642,-0.013407957,-0.0019277034,-0.031179538,-0.027349694,0.017209882,-0.022801617,-0.04730187,-0.0276063,-0.0032578418,0.042650327,0.003479852,0.005380346,-0.018150467,0.019454503,-0.14537212,0.019099714,0.0024400984,0.012242149,0.0054706307,-0.0070757116,0.034336843,-0.03449151,0.011053607,-0.029061964,-0.062753804,-0.0500574,0.0037210386,-0.023119885,0.0327958,-0.004714602,0.012457423,-0.042867,-0.016377456,0.0441276,-0.0047827945,0.027901482,0.033360835,0.03262804,0.025353706,-0.012467398,0.016416075,0.03763804,-0.014808965,-0.011545112,0.0066933893,-0.027299568,0.029615462,0.047691446,0.040221207,0.03424911,-0.0036707527,-0.03456494,0.011783675,0.009788123,0.039137017,0.027472068,-0.0048976485,0.007818604,0.008550602,0.028518388,-0.037446167,-0.010843695,-0.031031825,-0.024344666,-0.014841213,0.027491372,-0.016384438,0.027868787,0.007868691,-0.01829068,0.021769919,5.2345364e-05,0.021070812,-0.0041435123,-0.025918085,0.0073224828,-0.025616763,-0.096072756,-0.009175011,-0.009601244,-0.038892265,0.015072309,0.017327223,0.046705585,0.016614547,0.024849353,0.017941037,-0.0226665,0.003485281,0.016260577,0.027341792,-0.0236617,-0.026342828,-0.015076021,0.0041223993,-0.017349416,-0.028048055,0.005506166,0.04247808,-0.016782027,-0.029035822,0.020831276,-0.020455614,-0.0022484448,-0.01551552,0.03065415,0.008176483,0.014686351,-0.04614464,0.0126704285,-0.031139331,0.010606967,0.0030250144,-0.0032268972,0.02043104,-0.027099889,-0.016436169,0.00999377,-0.062763005,-0.00987158,0.005070548,-0.03397806,0.03777859,0.011858812,0.057910733,0.005904295,-0.009357977,0.03414859,0.014277914,-0.009871022,0.011589553,0.02367989,-0.02706311,0.06399821,0.04854244,0.05633195,0.030301696,-0.025450826,0.019909043,0.012636094,-0.000121987134,0.025620999,-0.028350731,-0.011048643,0.026576469,-0.014545012,0.0058483835,0.014163939,0.045212258,0.009947278,0.0011780313,0.063016035,-0.016913995,-0.05530654,-0.026503945,-0.04757578,0.004601391,-0.007501558,-0.005331365,-0.047124527,-0.005136922,0.036021963,-0.035767213,-0.038838603,-0.004435493,-0.0001410229,0.011079153,0.019423775,0.005660363,0.032924607,0.044972327,0.015780559,0.0053299726,-0.039461333,0.049436357,-0.0795017,-0.009098986,-0.014216203,-0.0032926914,-0.008701993,-0.0040076096,0.0183204,-0.01060041,-0.01940521,-0.0090314755,0.025565457,-0.0044369306,0.0045152833,0.00552562,-0.064073965,0.015351157,0.038752437,0.02941523,-0.013413728,0.033079337,-0.011539635,0.0029256886,0.02954135,0.01756881,0.008910781,-0.017304791,0.06237796,0.0010869032,0.03287063,0.009218723,-0.009317177,-0.007641761,0.013552602,0.029354645,-0.03081291,0.02228707,-0.023114098,-0.017241074,-0.0075768917,-0.06326294,-0.000805985,-0.07623757,-0.010446781,-0.019567888,-0.0083060805,-0.025377648,0.052035607,-0.036614645,0.07628332,-0.01563751,-0.006539634,-0.03410999,0.034857832,0.03353916,-0.04406576,0.013601706,0.0019067426,-0.02001341,-0.030696845,-0.039459325,-0.001534766,-0.0271902,-0.0040423707,-0.020626588,-0.010020654,0.030590935,-0.00027787188,0.009298083,-0.024670023,-0.061146352,-0.013310537,-0.031920973,0.09924513,0.053452224,-0.004629481,0.06581443,0.026408995,0.02647064,0.0017455555,0.062577195,0.05599244,0.0060845544,-0.0005617254] [-0.00025052013,-0.02659389,0.043278765,0.01631952,-0.0012997393,0.044981338,0.037847273,-0.011061412,0.033050947,-0.007986752,-0.007916347,0.016720578,0.00049679895,-0.02438747,-0.0017184486,0.017916868,0.018800221,0.0031674227,-0.012659808,-0.0059059476,-0.021158248,0.02760362,0.003255141,0.06577494,-0.08624208,-0.017137146,-0.031392563,-0.041595016,0.0052345837,0.035352357,0.035915066,-0.06239274,0.031988855,-0.00020958194,0.005765166,-0.028895112,0.010453552,-0.008876151,-0.0033100243,-0.020132352,0.0121719465,-0.056935523,0.01037479,0.017100837,-0.03183995,-0.0035096935,0.012720673,-0.05878309,-0.023009598,-0.014587606,0.0016686678,0.013691214,0.00092715846,-0.022683287,0.055650964,0.0075785667,-0.00072286034,1.6646011e-06,-0.029039832,-0.037230026,-0.006590156,-0.00961856,0.008197167,-0.022361035,-0.007871572,-0.0214671,0.009924409,-0.047295805,0.019664437,-0.00024194864,-0.0041370727,0.005010681,-0.005153311,0.012017014,-8.098536e-06,-0.03575003,-0.009536455,0.022484295,-0.022816207,0.012620778,0.0060615614,0.010985445,0.0146529,0.0017117324,0.026710175,-0.001835971,-0.006690857,-0.0039221556,-0.03766879,0.004881603,0.0030608112,-0.029143153,-0.0090504065,0.051001277,-0.00428803,0.014735893,-0.008501526,-0.019210743,-0.019778077,0.008802142,0.0017326397,-0.0075957826,0.0053501893,-0.012682604,0.0065152613,-0.028870495,-0.00805443,0.0039952477,-0.0020026697,0.00909528,0.012228058,-0.004095413,0.011221932,0.033004608,-0.010806295,-0.0058294684,-0.024478642,0.04920207,0.013219136,0.02718595,-0.002755377,0.012257397,-0.00496512,-0.005072001,-0.0017688712,-0.01829631,0.0022002656,-0.01703948,-0.0024461602,0.033318568,0.022054201,-0.0080434745,-0.010219906,-0.0076980353,-0.0047685313,0.011597442,-0.006722754,-0.013669902,-0.00078658934,0.008092696,-0.006902156,0.0045708395,0.002081081,-0.039312307,-0.007532297,-0.02392871,0.0011403251,-0.019687073,0.009082616,0.001934221,0.027778974,0.014638108,0.020655096,0.016087297,0.006023064,0.035518136,-0.026570505,0.024748811,0.023821078,0.004187146,-0.009375809,0.010170457,0.021066274,0.020314123,0.01181788,0.0072038025,-0.027906587,0.01974605,-0.023624012,-0.014148055,-0.0006750195,-0.026300656,-0.011561819,-0.0069743856,-0.025195861,0.00506907,-0.012532518,0.0067948434,-0.016905207,0.019406673,-0.005651372,0.018168014,0.04013827,0.0026660468,0.009023822,0.00016434869,-0.0033354145,0.00775829,0.019363927,0.013619026,0.0020738766,0.015967933,0.0006405682,0.0015569788,-0.0090612145,-0.012342314,-0.006771209,0.021801393,-0.010679974,0.023899704,-0.008571199,0.0010547296,-0.005465444,-0.019847699,0.009061683,-0.006030914,0.004249301,-0.02180525,0.0022178353,-0.01855828,0.008428711,-0.021614894,-0.0068519716,-0.02772509,-0.0051995106,0.0039810366,-0.02753565,-0.016017878,0.021304313,0.034287836,0.010684518,0.009074796,0.0015135042,0.012771209,-0.037485044,0.0045354837,-0.001888675,-0.008823544,-0.013956123,-0.01888398,0.004970015,0.0118078925,0.0011190211,0.0122958645,-0.0060467017,0.005847311,-0.023347218,0.002753115,0.0073408755,-0.04646656,-0.0074705584,-0.01731927,-0.014067248,0.001954136,0.0005215263,-0.02074761,0.010763253,-0.028046506,0.0022718555,-0.021232508,-0.016312208,0.008824966,0.0095088035,-0.011375114,-0.037866704,0.008924388,-0.017476257,-0.016229806,-0.0023579386,0.01674573,0.0010187338,-0.004538187,0.00056027767,-0.0026451289,-0.003491187,-0.01609964,-0.022332158,0.0019413591,0.0030598307,0.0040419945,0.06313053,-0.015735602,0.0028816764,0.013224701,0.0021369683,-0.028044213,0.00498952,0.032114506,0.0301656,0.04605285,-0.012819282,0.0037590736,-0.004120662,0.019428186,0.010213559,-0.008340956,0.03324001,0.0030481797,-0.0047955746,0.01794791,0.01682319,-0.025019526,0.0070265383,-0.008787937,-0.024879696,0.0023463643,0.025132984,-0.008183257,-0.022611342,-0.026368365,-0.017279668,-0.006217939,-0.0034653298,0.033132114,0.008988424,-0.00038010828,0.008471282,-0.0033047681,-0.048912384,0.046648543,0.005481235,-0.0015679997,-0.008240978,-0.0046314923,0.003897684,-0.05181549,-0.0016939659,4.1026447e-05,-0.013481992,-0.0219796,0.007173459,-0.05247868,0.015204962,-0.008106772,0.011982311,0.0017618706,0.01684476,0.016432714,-0.0356964,0.008720172,-0.0137484325,0.036801428,-0.04964505,-0.013203319,0.0039254315,-0.0041926857,-0.0007624677,0.02751308,0.030261755,0.014845721,-0.013351375,0.012350455,-0.022058949,0.042387858,-0.047073815,0.008287825,0.02138989,-0.0018663738,-0.027837213,0.0030899101,0.007303991,-0.019111875,-0.0066421665,-0.025534773,0.011214439,0.0071470533,0.0028305652,-0.0002214935,0.029601054,-0.03307229,-0.011822348,-0.007245923,0.015131649,0.021173347,-0.039916825,-0.016244316,-0.01623051,0.01823201,0.00066674914,-0.023354953,0.010080577,0.0021397802,0.029459463,-0.017029352,-0.038641628,0.012460373,0.041101776,-0.011973908,0.00037652,-0.002681921,0.01713603,0.03366548,-0.02735317,-0.00489258,-0.024157766,0.010145217,-0.025158323,0.04501913,-0.037198894,-0.0006154819,-0.0074320026,-0.0089712925,-0.0075145243,0.01654957,-0.04144435,0.037341703,0.011348451,0.030111765,0.017034706,-0.009172676,-0.02682884,-0.002371024,-0.030262752,-0.026943514,0.0055950857,-0.015531614,-0.004057863,0.022077562,0.017120216,-0.0020184333,0.022973664,-0.008444909,0.016936721,-0.0123903435,-0.04327282,0.011437352,0.031667527,-0.028466593,-0.01619203,0.011804626,-0.0038430763,-0.020159988,-0.015989073,-0.013756199,0.012780489,0.010427616,-0.028344924,0.02435281,-0.022492196,-0.037964802,0.0027561432,0.024111155,0.042493917,0.040229764,0.0022092862,0.0008993614,0.018160384,-0.011267078,-0.030724162,-0.015912028,-0.0036805666,-0.0063488605,0.0020075182,0.024763888,-0.025292639,0.017921967,-0.010889688,0.004204664,0.067370884,-0.04500552,-0.015865248,-0.029459078,0.01597838,-0.01179095,-0.01704822,0.033785574,-0.01061931,-0.0026078133,-0.023778187,0.04625731,-0.101580575,-0.008207046,0.02292389,-0.0046766596,-0.014575942,0.011266785,0.0036667082,-0.011210511,0.02982198,0.0005516308,-0.015876768,-0.01686607,0.017360829,-0.026871022,-0.028498616,0.009387196,0.0022233485,-0.027791614,-0.011274809,-0.026829848,0.0120459255,0.0008378673,0.025045045,-0.0052006748,-0.0037288803,0.038333606,-0.0011535516,0.040626667,0.016870042,0.023186957,0.019381426,-0.04487603,-0.019234562,-0.025712606,0.0014525676,0.0065742563,0.029933289,-0.022194553,0.0033467687,-0.0025666656,0.0020817844,-0.011591973,0.013355686,0.004373263,-0.029223336,0.012850981,-0.0004945605,-0.016495772,0.0025252705,0.016657362,0.011568259,0.024804886,-0.012188697,-0.06975137,0.030156381,0.021708038,-0.011551365,-0.027024364,0.007961468,0.009966973,-0.02204123,0.01492717,0.021639729,-0.009862225,-0.027848,0.02276006,0.023553222,-0.015508991,-0.009559291,-0.017168822,0.0023482647,-0.015893294,-0.010318321,-0.0050018625,0.0055638123,0.007435987,0.022016319,-0.0004627836,0.017848603,0.0052516293,-0.017226044,-0.0009404364,0.014484941,-0.020717274,0.007892277,0.010975447,0.009232319,0.0015775897,-0.0249774,0.007903071,0.0056004524,-0.0035118426,0.012870296,-0.019776467,0.0053508193,0.0041063246,-0.02097688,0.0083287,0.0076991804,0.0065044845,-0.012337731,-0.00019635826,0.0031808356,0.0129622845,0.0033897932,-0.024719303,-0.017587014,0.033989906,-0.007407812,-0.013951233,-0.018575909,8.7020104e-05,-0.015715692,6.8630696e-05,0.041224383,-0.024979446,-0.015413884,-0.0036079518,0.018145684,0.017271826,0.004521095,0.042437818,-0.0078013614,-0.020671373,0.0002982648,-0.023654647,-0.025846474,-0.006417604,0.0259429,-0.0013151976,-0.010657389,0.009609403,0.005529442,0.010867642,0.010678569,-0.015047073,0.01829548,-0.021940801,-0.0034090192,-0.026930358,0.04350662,-0.008100321,-0.030880623,0.014900536,-0.011723485,0.0013974288,-0.0073202495,0.017668465,-0.030897275,0.010332444,-0.029910484,0.018265186,-0.01501418,0.022093982,0.012529334,-0.028873345,-0.028401371,-0.0054062223,-0.007804325,0.009787689,-0.046699613,-0.006613136,0.020297145,0.0043936186,0.0061354423,-0.06251339,-0.01460588,-0.008487612,-0.012682408,-0.007799772,-0.012328437,-0.0027577742,0.06677433,-0.01802252,0.01826209,-0.02254271,-0.03810283,0.012229633,0.0018960069,0.0032714894,0.04040674,0.03412917,0.033085823,-0.026113216,0.038395617,-0.019115662,-0.03047946,-0.013563408,-0.0034940664,0.0029218511,-0.0031293782,0.019933539,-0.00094665913,0.027644496,0.011661383,-0.038350306,0.011852664,-0.015267348,0.011393181,0.008621548,-0.00029579276,-0.01962149,-0.019806104,0.036690664,0.005202642,-0.02062499,-0.049819823,-0.023035541,0.032428432,-0.017224448,-0.03747083,0.0126710255,-0.0016077718,-0.016164586,0.021205338,-0.042957854,0.007351064,0.008628488,0.022212707,-0.009501101,0.017420422,0.016847335,0.02439859,0.027916392,0.024636324,0.0006702552,-0.004393844,0.01925692,0.023552865,0.03127406,0.011988547,-0.023145432,0.02287075,0.03388157,0.009874606,-0.009677116,-0.012445286,0.014404151,-0.020712318,-0.008578581,-0.014986728,-0.028851083,0.0060973205,-0.020848708,0.019719584,0.0062796483,0.02887548,0.0007496004,-0.008030414,0.02975108,-0.0023141766,-0.0429097,-0.0668342,-0.029482342,-0.020147322,-0.012945328,-0.024309259,-0.0018917201,-0.003875799,-0.01999398,-0.023316922,0.00844223,-0.02641652,-0.0072535714,-0.018217567,-0.0016391827,-0.010923782,0.010911075,0.000926264,0.052047335,0.017646622,-0.052169524,0.00037914136,0.033149343,-0.026996223,0.0038298944,0.0036664493,0.0068909335,0.010700999,0.009941059,0.026479138,-0.005280638,0.0066727754,0.00018368747,-0.00097321504,0.004945732,-0.042491578,-0.008815017,0.001198792,-0.010392348,0.03739974,0.0048110145,0.03369949,-0.014957459,0.002120381,0.028805599,0.020610504,-0.013331126,0.0125929965,-0.0067618527,-0.014579539,0.0050737727,-0.0009257617,-0.025753649,-0.029837152,-0.015857363,0.0054819924,-0.021297527,-0.04586238,0.033461597,-0.014908462,0.028371092,-0.001889471,-0.022649467,0.03142616,-0.015763681,-0.030954767,-0.013362707,0.03659288,-0.015048867,-0.010296344,0.0049303607,0.0020554701,0.012930782,-0.0014465033,-0.0066943504,-0.044030122,-0.017502846,0.0027417808,-0.012322495,-0.009922467,0.038299967,0.00027730234,-0.028722802,0.008143211,-0.019826736,-0.0067422274,0.011932095,-0.020590024,-0.011225159,-0.016631136,-0.026440585,-0.004080235,0.0349236,0.004507642,-0.0011455714,-0.0005149604,-0.01032825,0.0021258967,0.015774373,-0.001940459,-0.021656219,-0.025350949,0.009896033,0.0013964283,-0.018503668,0.004407041,0.0052208216,0.010308052,0.023545701,-0.0047777626,0.009110651,-0.0027608688,-0.013506508,0.01355207,-0.009125837,0.005917568,-0.034504283,-0.019322991,0.031442996,0.0011576383,0.014732318,0.004673519,0.004853417,0.0049715126,0.00851218,0.04073045,-0.025920164,0.008289737,-0.014898571,-0.011629576,0.0015175114,0.040884066,0.0122190155,0.010704265,-0.00965121,-0.008877191,-0.007807496,0.00517014,-0.0031818962,0.004926052,0.060452428,-0.010679564,0.02142114,0.008611826,0.005088272,0.010111573,0.002158652,-0.004091842,0.000103507984,0.02652732,-0.023944974,-0.012154615,-0.018610384,0.0063461787,-0.005766595,0.008354139,-0.024215778,0.016198002,-0.032300696,-0.006847855,0.0046069976,0.025591196,-0.0030225895,0.0049460614,0.0035120319,0.0057203732,-0.022740733,4.454381e-06,0.028303511,0.05971844,-0.0010215548,0.036521167,0.039831135,-0.0077826097,-0.008352824,0.017921118,0.008987985,0.016020974,0.017154401,-0.010583124,0.0031719815,-0.012470055,0.023987485,-0.0007624837,-0.018746639,-8.8930625e-05,0.012214017,0.009936227,-0.024058651,-0.019166742,0.040694434,-0.006676628,-0.0016874967,0.031959653,-0.004139784,-0.0101454295,0.02216606,0.010620733,0.006380206,-0.014935408,-0.006679042,0.014373447,0.024974653,-0.04398057,-0.011244633,-0.031964414,0.00033503806,-0.0019587972,0.024284419,0.02799427,0.011499761,-0.030380316,-0.018727332,0.0171665,0.016433638,-0.0116939945,-0.030534225,-0.03753256,7.285639e-05,-0.0051514967,0.028500969,-0.0035878893,-0.017159982,-0.0100153005,0.016137883,-0.006313619,0.0011805259,0.0115377875,-0.012924402,-0.015872838,0.011279601,-0.006532677,0.046351936,-0.014419365,-0.00013010803,0.01650412,-0.00031154876,0.025572011,-0.0039566783,-0.008092617,-3.5271307e-05,-0.024024757,-0.011027632,0.0016407017,-0.010990815,0.017833944,-0.00875906,-0.0038502074,0.004944762,-0.04872018,0.0014308156,-0.03663035,0.038238086,0.032941453,0.024701424,0.009752109,-0.019499727,-0.013294372,0.006975304,0.0041490076,-0.022373784,-0.0008842294,-0.022411413,-0.003533601,0.0044454634,-0.029067814,0.0055568796,0.02194747,0.037182167,0.014510633,-0.0275713,0.023138529,-0.02205921,-0.016667375,-0.011051667,0.0019630613,-0.005275237,0.034885935,-0.009479372,-0.0009475097,0.030594382,-0.0031003912,-0.008513805,0.015288041,0.024441805,-0.018504554,-0.0009817879,0.028370693,-0.033987872,-0.00068440975,0.016045123,0.026401902,-0.020302407,0.0072754486,-0.033739123,0.026847238,-0.0029146476,0.037455034,-0.04254565,-0.017420027,-0.008793352,-0.022898767,-0.004509006,-0.03760085,-0.0032166622,0.014247725,0.0052652163,-0.03533786,0.033296388,0.0031717103,0.0047135805,-0.00964724,-0.0077386373,0.025601722,-0.04630126,0.02897509,0.021226415,-0.028743774,0.017722927,0.01672084,-0.007983748,-0.02730081,0.026394472,0.03158302,-0.023053927,0.0033526102,-0.00051124115,0.0004409094,-0.0053264056,-0.021851802,0.006228855,0.002092473,-0.043799695,-0.0034076993,0.0020050684,-0.0007955937,0.007813642,-0.029536981,0.018138044,-0.008471857,0.018043801,-0.0077456045,0.0029214397,-0.010711015,0.007140339,0.0041763163,0.015157451,0.02455823,0.055441245,0.0066275764,-0.019938486,0.012014676,-0.016418623,0.013111468,-0.027518092,-0.0049681314,0.012576292,-0.014719249,0.013028973,-0.025294427,0.035041958,-0.0064270836,-0.012456092,0.01962924,-0.037338853,0.00091595505,-0.027221683,-0.02229828,-0.010366512,-0.044500448,-0.0035355163,-0.011297518,-0.008198829,-0.026875181,0.008031862,0.021674454,0.01810822,-0.026748247,0.007991136,0.026909942,0.021942182,-0.007123301,-0.03997478,0.035604436,-0.02704741,0.014257598,0.012700552,0.0091360165,-0.005272623,-0.0018809384,-0.0044171084,-0.0119942585,-0.003753265,-0.014331127,-0.018344449,0.0026617334,0.0070165023,0.015274947,0.042562276,-0.014623077,0.011747521,-0.0144974915,0.010949635,-0.009649896,-0.010834481,-0.021473499,-0.014128127,-0.032275423,-0.01462363,-0.011520155,0.028828884,-0.011927836,0.029945638,-0.004924306,-0.0019897288,0.028847596,0.01931842,0.00901545,0.026221987,-0.021831604,0.029368987,0.0017781704,-0.009914446,0.026138658,0.013719969,0.0003421649,0.0103774015,0.047632497,9.962986e-05,-0.02430611,0.0042003496,-0.013883026,-0.021882597,-0.0012028001,0.042001557,-0.0131041845,0.024598077,0.01108184,-0.01537595,-0.027641002,0.012373574,-0.016636724,-0.002203822,0.008789919,-0.012669213,0.028002493,-0.003682967,-0.01289588,0.009294655,-0.002316907,0.004311273,0.010045188,0.026666766,-0.045215286,-0.00079059845,0.018953599,0.0015858054,-0.04031902,-0.005158687,-0.012515465,-0.052719742,0.003684229,0.028472008,0.016816935,0.01726969,0.019434288,-0.021905564,0.020592373,-0.0030411116,0.02267898,-0.000100538455,0.025780722,-0.0054430547,-0.0025745027,0.03869956,0.0017981866,0.014695417,-0.020365933,-0.01204967,0.02656893,-0.028908648,-0.0038862578,0.0019260951,0.005161698,-0.021458393,0.0057199546,-0.024682717,-0.030159088,-0.022160662,-0.008899486,-0.033605024,-0.0010651395,-0.0017487914,-0.012324085,0.024703482,-0.012221115,0.016760541,-0.0107657155,0.019133827,0.014949571,-0.006056416,0.022341846,0.028776042,-0.022190133,-0.0028212974,0.021039562,-0.0010180393,0.010770699,-0.013888505,-0.024650786,0.0014086618,0.007994351,-0.021665946,-0.011394161,-0.027183639,-0.01948046,0.028066088,-0.010670057,-0.0011186616,0.0076356065,0.026194237,-0.0017179918,-0.004995171,-0.013245066,0.0052854433,-0.033227406,-0.014905297,0.029890617,0.024745394,-0.017749699,0.023003012,0.009625838,0.02310602,0.045695353,0.023929875,0.00027270493,0.0085343635,-0.012854266,-0.026277086,-0.030498857,0.022872489,-0.0077629597,-0.002665728,0.0013181271,0.01826044,0.033015653,-0.0102277845,0.010813,0.008617527,-0.056897637,-0.030211736,-0.028396787,-0.017157499,0.024062742,-0.0129156755,0.0026071202,0.0097626215,-0.013186602,-0.0030267823,0.02987236,-0.003922209,0.015523166,0.015635261,-0.025106926,0.00899132,-0.00465193,-0.026903827,0.007614313,-0.0056773582,-0.017024621,0.007725007,-0.0010043879,-0.007635699,0.008558905,-0.02267586,-0.013761136,0.017609729,-0.011059906,-0.007017284,-0.0007167748,0.0062654833,-0.041766606,0.0029297355,0.0039563337,-0.0013658913,0.02681795,-0.0009810356,-0.015115391,0.006736912,-0.027963094,-0.0016827608,0.0053757094,-0.005253159,0.015620983,-0.019422151,0.03215075,-0.008080653,-0.012444522,0.0031858585,0.020491118,-0.030628737,0.025676467,0.0043872665,0.014781953,-0.009103158,-0.029740978,0.0036807663,-0.014192109,-0.014700223,-0.023449097,-0.017266512,-0.00011559826,0.027206274,0.012227474,-0.003658178,-0.024992567,-0.005336695,-0.010550062,-0.004880376,-0.015230262,0.01765094,-0.017354727,0.017015459,-0.0059492243,-0.0027043747,0.030638598,0.033361353,0.05322388,-0.00583308,-0.03462751,-0.019464092,-0.035228282,-0.008265857,-0.0055295355,0.0066601555,-0.037493616,-0.0066660014,0.027289275,-0.012039528,0.013519373,-0.0038153422,-0.0025012405,0.008617371,0.020691164,0.021037342,-0.0002691382,0.007873342,0.00050314335,0.0028053639,0.023146102,-0.0332894,0.027371807,-0.014489231,-0.005670014,0.018947992,0.020552073,-0.0027832084,0.032611012,0.022626568,0.004797168,0.01623911,0.0049512424,-0.011383493,0.035481166,0.03087581,-0.005832265,0.00996767,-0.016187465,-0.016296275,0.02319016,-0.031679038,0.047705602,-0.0053215334,0.024756633,0.01774606,-0.04312218,0.024133526,-0.0074405326,0.025116956,-0.013868026,-0.029059352,-0.006038942,0.011735661,0.012469571,-0.029731566,0.020698726,-0.02109254,-0.038075633,0.006261901,-0.007350805,0.0014931719,-0.038270734,-0.011083437,0.031032456,0.005255655,0.017343372,-0.027072426,-0.023061788,-0.04768779,0.00704154,0.063498996,-0.030600753,-0.03930345,0.0042134584,-0.005139322,0.032134816,0.01669703,0.015046026,0.015037121,0.016235312,-0.008105624,0.00706443,0.006445082,0.009843636,-0.016378127,0.005010447,-0.018408805,0.0026011053,-0.028147181,0.020854127,-0.020429123,-0.015352104,-0.010449186,-0.007900953,0.005737501,0.010914365,0.0064277956,0.03596411,0.0023959058,-0.027540598,-0.027990246,0.0041647125,-0.005638953,-0.008147179,-0.008531184,-0.004890435,-0.00034956582,-0.026299564,0.0018845077,-0.0053869896,-0.0051804525,0.031404812,-0.00090450014,0.001013293,0.016803911,-0.016309189,-0.027754493,0.0008225366,-0.010207916,0.020864226,0.03678967,-0.014019287,0.028560469,0.047439136,-0.027814055,-0.02921198,-0.03349302,-0.011631378,-0.015705481,0.0077705253,-0.0019674904,-0.011475843,-0.019009255,0.008256674,-0.004066784,0.019473553,-0.018076504,0.016862417,-0.0440334,-0.015254432,-0.009157568,0.008258475,0.015156207,-0.028589547,-0.01946568,-0.018689347,-0.0118042175,0.013618728,0.009678658,0.010103551,0.008977002,0.00531186,-0.0015552671,0.02676056,-0.0046096947,0.0027693901,0.01530136,-0.014668653,-0.02510831,-0.0042071757,-0.011300539,-0.023336802,0.005465616,0.019191606,-0.033086583,-0.004987155,-0.011196557,0.031581335,-0.018987812,-0.02035647,-0.010913193,0.0030830146,0.011552777,-0.011944777,0.019205846,0.022670336,0.0025666803,-0.01487841,0.0088993125,-0.0052162507,-0.030982843,-0.011890545,0.0019006524,0.015260062,-0.015960323,0.005952431,0.016706087,-0.0002766593,0.014508195,0.015357133,-0.026211053,0.00618911,-0.0062185563,0.003188339,-0.017542608,-0.007971045,0.010468689,0.01750927,0.002373918,0.0069462173,-0.00647945,0.01297995,0.0028979096,0.007026933,0.00091609673,0.012040738,0.014949599,0.01990793,0.0136612905,-0.01886269,-0.026521934,0.01899654,0.015184355,-0.019827243,0.037447497,0.018194105,0.007336457,-0.0126800025,0.015356025,-0.0047434294,0.037390053,-0.032028344,0.019770531,-0.0070054266,-0.027894108,0.00828549,0.030636765,-0.026588729,0.002760421,-0.025775215,-0.00066861435,0.0143950945,0.005885321,-0.010059673,0.0008156562,0.0027482205,-0.0026105535,0.0063604447,0.021347538,0.023764692,0.0034878287,-0.0014443303,-0.006829869,-0.009217175,-0.0076150075,0.020734403,0.035594314,-0.0018410115,-0.009849789,0.017786212,-0.0016035725,0.011391818,0.015384939,0.008249798,-0.01756326,0.0065084966,0.016572459,0.055011187,0.024817003,0.006712886,-0.007202511,0.0084027,0.014084972,-0.007964653,0.004593699,0.02578394,-0.019960979,-0.012847132,-0.024730772,-0.0009578414,-0.010547918,-0.010498325,0.00053549313,-0.003004262,-0.015194098,-0.0051724813,0.027423631,0.021530004,0.0064007677,0.011926523,-0.0002815294,-0.0038376208,-0.046787143,0.004611302,0.047932617,-0.023726474,-0.0030600259,-0.014419828,0.0017280987,0.02318321,0.0050627985,0.0072307284,-0.0036112054,-0.011690511,-0.018131208,0.0060847052,0.04062478,0.0026261646,-0.009317502,0.01215782,0.009406255,-0.0008423306,-0.031790596,-0.013853575,0.009318424,-0.0015282357,0.012787694,0.023017013,-0.01548106,0.024715967,0.017319668,-0.0072105075,-0.0026087898,0.0074374527,0.0003313425,-0.009233005,-0.007502624,0.011586603,-0.00954491,-0.0030007337,-0.012881419,0.03606299,0.005522258,-0.03646941,-0.0051053716,-0.0038265404,-0.010436441,0.0011857736,0.009858235,-0.00021481203,-0.028522706,0.014952202,0.025293713,-0.00537349,-0.029780028,-0.020900423,-0.00012175207,-0.027096376,-0.0059647746,0.001246538,-0.025464311,0.02758988,0.022457775,-0.013302664,-0.029962007,0.02962459,-0.004589872,-0.022075813,0.013186429,0.01917017,-0.0010556951,-0.0012862321,0.01193904,-0.014183253,0.015515087,-0.0019285069,-0.009333478,-0.012864667,0.027723132,-0.012831815,-0.010943608,0.019450707,0.005003232,0.00937285,-0.020660533,-0.03228961,-0.0138637535,-0.024622254,0.021175945,-0.006367891,-0.0006823961,-0.021091005,-0.01709548,0.006284794,0.024041178,-0.020023677,-0.0028864776,0.030667596,0.009428205,0.02655089,0.007942845,0.0028825167,-0.0075931083,0.029475931,-0.0049445727,0.045444895,0.0005254786,0.019973917,0.034264077,0.017510055,-0.010723776,0.018524077,-0.014028194,0.008392625,-0.011574896,-0.013856274,-0.009011963,-0.006947789,-0.0041358573,-0.022650097,0.015271093,-0.01090552,-0.0036220145,-0.00093832536,0.01081436,-0.016672513,0.013072575,0.018072227,-0.0025159456,0.02062187,0.03322337,0.008409011,0.001448197,-0.021184513,0.010205155,-0.003732742,-0.015898263,0.002168141,-0.0026463966,0.013962829,0.009862169,-0.016130758,-0.008853572,-0.016590528,-0.012121761,-0.013824163,0.0043517216,-0.0091477595,0.0035996006,-0.007893113,0.011655585,0.02377605,0.05322598,0.012562218,0.0076662,0.0053935875,0.009279168,-0.021108719,-0.026290327,-0.009000088,-0.020796884,-0.0079854,-0.012850528,0.009914358,0.016520027,-0.008809124,0.0029653076,0.024343522,-0.0014747883,-0.03820002,0.009873553,0.0032015205,-0.006602748,-0.009188304,0.011192363,-0.009124496,0.00029745843,-0.020531515,0.041546278,-0.012854987,-0.038839374,-0.0013663039,0.017884338,0.028677694,0.005823299,-0.0042455676,0.001437432,-0.010418331,-0.0027916129,0.038167853,0.003387925,-0.0066446,-0.02242857,-0.0031564375,0.0146807805,0.018394478,0.01685887,0.020940656,-0.019929249,0.008206674,-0.0027586266,-0.02179031,0.0052453605,-0.023382597,-0.020414162,0.00973269,0.00025179776,-0.009552413,0.038755514,-0.041031796,-0.0045811255,-0.03196199,-0.037354957,0.010454379,-0.006619132,-0.0073388037,-0.030815613,0.024523878,-0.01453215,0.032663614,-0.010002015,-0.00774812,0.028027166,0.008500586,-0.0080530215,-0.014622088,-0.018726176,-0.024761455,0.022500854,-0.0024084933,-0.027599808,-0.015489129,0.016550614,0.00037670624,-0.014762777,-0.014110477,0.0003458347,-0.0037067144,-0.012702663,-0.009980554,-0.022749877,0.017651876,-0.015149514,0.009141529,0.003948707,2.0541895e-06,-0.021635806,-0.021441927,0.015633814,-0.0019297256,-0.015517708,-0.0044303364,-0.011930042,0.022532942,0.028675258,0.03297538,0.017640797,0.009282159,0.03256215,0.009150367,0.037928365,0.015646841,0.032537267,0.02744492,0.006447607,0.014379128,0.012795125,-0.010448893,-0.015675437,-0.008380216,0.032060675,0.025668032,0.02338247,0.018788474,0.018665122,0.01679524,0.009165733,-0.0031853877,0.032998696,-0.0049872035,-0.039651006,-0.024952,0.021247724,-0.014004825,0.008533379,-0.02836697,-0.0036156713,-0.00472591,0.01793967,-0.013046784,-0.009621988,0.0011539088,-0.0010759804,-0.024735004,0.03522156,0.0033494,0.008094293,0.014673755,-0.039950535,-0.0010787242,0.0060117757,0.019422192,0.0015765739,0.0046905545,0.018147811,-0.0046550585,-0.030007761,0.0025384626,0.01466232,0.020246226,-0.034866173,0.019721895,-0.0023291449,0.0064881034,0.016481176,0.010912395,0.014654564,0.02132766,0.0056755585,-0.040936276,-0.0010163013,-6.264852e-05,0.0014922019,0.014243218,-0.02183647,0.0055095395,0.017668195,0.015687771,-0.007963186,-0.00868638,-0.036332954,-0.01538718,0.00561944,-0.010858188,0.01875134,-0.0074027358,-0.019802295,-0.039965916,0.01587435,0.008550112,0.0037458548,-0.040051796,-0.01686961,-0.03732595,0.00078121666,0.03382209,-0.05155497,-0.020414064,-0.0022103442,0.010486501,-0.020091124,-0.010111561,-0.011163638,-0.0061976286,0.019342683,-0.042614985,0.007236638,-0.0046570688,-0.044959847,0.009090739,-0.0051293788,-0.027101621,0.014832605,0.02222734,-0.007195544,0.008932283,-0.037159506,-0.013993123,0.014237365,0.026222281,-0.0009033493,-0.011547544,0.00923523,0.025168898,0.011629878,0.0095985,0.05799991,-0.006576316,-0.008723211,-0.019201623,0.040659692,0.04409227,0.0048136716,-0.0018598508,-0.039566115,-0.017904859,-0.013078799,-0.015882095,-0.024545703,-0.009377812,-0.04912582,0.018097315,-0.011364981,-0.033795077,-0.0069613955,0.010042556,-0.0034232165,-0.013178038,-0.008807269,0.003335153,0.015726501,0.0032489449,-0.0035181171,0.01672357,-0.007918273,0.01623183,-0.01177024,0.0013232672,0.005310915,0.0023653307,-0.003182656,0.00350536,0.02885695,0.02192759,-0.016765952,-0.0337631,-0.016800735,0.030449705,-0.013497887,0.010845447,-0.0036520483,0.008798417,0.028426006,-0.007880807,-0.0040925737,-0.009271316,-0.015142706,0.01969908,0.030156864,-0.008686779,-0.0001532532,-0.034760185,0.005981872,-0.0143189775,-0.004548658,0.02180789,-0.0050848965,-0.013239495,-0.0034812381,-0.04232714,0.021173486,-0.0009523798,-0.04529752,-0.0021569137,0.018389892,-0.010585152,-0.0015882079,0.01738711,0.0015833072,-0.030270299,-0.026164195,-0.02879038,0.0029723821,0.0058367457,0.002535478,-0.010752319,0.0030582258,-0.02670317,-0.006371912,-0.008543798,-0.03233119,0.0032224103,0.015546279,-0.03328094,-0.007908577,0.023452498,0.012883574,-0.0099679595,-0.03886863,0.007961934,0.019720973,0.016648713,0.054051112,0.034840047,0.026758881,0.021486333,0.0026013346,0.0027662788,-0.01094773,0.017127246,0.013917185,0.004562301,-0.008370802,-0.0021613196,0.025051465,0.0045776158,-0.020044558,-0.013481846,0.012289275,-0.031217996,-0.022930352,-0.0044755563,0.022357387,-0.0033567187,0.013078114,-0.009230779,-0.023502223,0.0185646,-0.013098241,-0.00056600285,-0.016597006,-0.014900282,0.035003923,0.0116339885,-0.015411049,-0.007714759,-0.020015543,-0.031733237,-0.019559117,-0.005866085,-0.015066358,0.01261368,-0.005939309,0.02800442,-0.03661984,0.0062992126,0.028123146,-0.0052151466,0.031774122,0.010128622,-0.034629256,0.00876695,0.019199884,0.013205183,0.025935795,0.049593356,0.034481715,0.024179,-0.005713854,-0.004057418,0.024808548,-0.023317123,0.0014672751,-0.003408155,-0.05680869,0.0043800767,0.012413059,-0.0249487,-0.008311417,0.008289836,-0.0031195811,-0.0033811587,-0.0024643608,-0.021671157,-0.020158287,-0.04534392,-0.034247536,0.0305433,-0.018851474,0.0075208554,-0.002535171,0.0066872495,0.045474283,0.007140637,-0.043394007,-0.006274827,-0.0036855615,0.011324699,0.021626564,-0.020108443,0.0030953302,-0.03493395,0.009142471,-0.009161751,-0.0076910118,0.011016546,0.0021509312,0.0024812452,0.014346859,0.0028065157,-0.033719126,0.021702863,-0.004199202,7.1034876e-05,0.02171804,0.028828723,-0.002021994,-0.02264617,-0.0065634036,0.0019777615,-0.020310938,-0.041838598,-0.0028603848,-0.00012296786,0.00082302344,0.019990617,-0.018529031,0.025752185,0.009529049,-0.003736641,-0.0025473416,-0.03363385,0.02506408,-0.014999764,0.009081481,0.008537061,-0.03615147,-0.0074372236,-0.009115664,0.027350983,0.009342293,0.005313563,-0.0065906476,0.010048656,0.008752685,0.01207214,0.019000603,0.0144813005,-0.01319646,0.0041106744,0.014193217,0.018156542,-0.022085777,0.04157164,0.021506514,-0.00023562027,0.017492224,0.008143074,0.032319266,-0.010855894,-0.020421082,-0.027179977,-0.019022064,-0.0051824595,-0.0033551594,-0.009797296,0.01940583,-0.011007318,-0.004416092,-0.016541129,-0.028364955,-0.0029596188,-0.039469667,0.0037563639,0.0016640282,-0.015268943,-0.0018847388,0.017290384,0.005951398,-0.0059085805,-0.00871033,0.0045531057,0.020840935,-0.008376233,-0.015451329,0.0002040409,-0.0418218,-0.016075585,-0.014713285,-0.017165164,0.0019869423,-0.025267161,0.0086460775,-0.0046478524,-0.0009905207,0.004422623,-0.00931115,0.00025554726,0.0014088447,0.012753024,0.017968897,-0.0066301236,-0.004721002,-0.016564474,-0.023631893,-0.02672209,-0.007446221,-0.0053887907,0.014430874,0.010454564,0.017387068,0.02197471,0.02166446,-0.010112183,-0.02410337,0.008979,0.0038539295,-0.0012196492,0.021559825,-0.0050109834,0.015478005,0.03622767,-0.0045397463,0.011984523,-0.009020582,-0.014316866,-0.018509556,-0.024563177,0.00468124,-0.008358964,0.0022150842,-0.008473976,-0.0064854748,-0.04707208,0.01819002,0.008706907,0.032241113,0.012658427,-0.037336417,-0.017932221,0.0041125,-0.0044955756,0.023515068,-0.0031418935,0.0005648735,0.015090152,-0.008156654,-0.0021563522,0.0072254464,-0.01914875,-0.030756235,0.006501768,0.004321803,-0.016270695,0.012137775,0.0063322764,-0.027541785,0.044382162,-0.006150942,0.011941605,0.009002198,-0.019874621,-0.0048262416,-0.0017454937,-0.01725278,0.0073928037,0.001063539,-0.043702364,0.0050908867,0.04252948,-0.01327181,0.0075205597,0.004870683,-0.010224139,-0.033114508,0.017707167,-0.01047442,0.026067369,-0.004030257,-0.032908276,-0.009353805,0.02883079,0.016034763,-0.025558174,-0.014194815,-0.003241583,0.028601073,-0.008351362,0.017540835,-0.011846448,0.029163757,-0.0020117778,0.032047063,0.0044978103,-0.0057965834,0.0008153521,-0.023219015,0.020615593,0.007909618,0.0006166469,0.012778591,-0.0010087704,0.003460315,0.00097503763,-0.034919184,0.0067742616,-0.014858507,-0.020727972,0.0056921276,0.04346926,-0.0059821378,-0.008303332,0.033310246,0.02717636,-0.009312994,0.008086798,-0.022839017,0.002142283,-0.009820747,0.029747164,0.0060981037,-0.009755291,0.013397868,-0.012551863,-0.019782603,-0.033590805,-0.0003296833,0.024130346,0.0002960846,-0.008087798,0.021150142,-0.00731315,0.009128097,-0.0068354225,-0.005448926,0.03110639,0.012735185,-0.024259893,-0.02678418,-0.0027226673,0.0015559572,-0.023307653,0.009697893,-0.015568888,-0.04123252,-0.032632556,0.013525585,-0.012382655,-0.023820434,-0.00814709,-0.0011780823,0.010595854,-0.033790205,0.0208206,-0.0141470805,0.024859957,0.01042606,-0.03204272,0.010458119,0.0074030147,0.021170173,0.0032722875,0.018821314,-0.011822178,0.02471531,-0.0047458047,0.0054152957,-0.004715891,0.043639727,-0.012176953,0.014457254,0.00405016,0.0134438,-0.014742549,-0.0056745275,0.010808241,-0.00080902217,0.01783703,-0.015626421,-0.016981516,0.0071189376,0.006080916,0.020986704,-0.018779365,0.032413915,-0.012803933,-0.015452086,-0.0051656943,0.018270878,0.009607151,0.017075358,0.00044543005,0.023262594,-0.014152293,-0.018111454,0.00943382,0.007458413,-0.0021499915,0.012633578,0.01147046,0.009772952,0.0010750386,0.015352966,-0.010034524,0.007402725,-0.0008418867,-0.025682768,-0.00558639,-0.0109265335,0.03401959,0.008349923,0.041003454,0.009333024,0.0053999494,0.007220462,-0.012104157,-0.01805239,-0.01357166,0.009597086,0.01995036,0.0006490532,-0.012442019,-0.013520799,-0.013223742,-0.0030612831,0.015223023,-0.031324033,-0.0074616414,-0.007457363,0.013358275,0.01445208,-0.002687245,0.0015767952,-0.028913867,0.025415769,0.018843634,-0.007348319,0.01222275,-0.02983932,-0.0027834745,-0.0020170817,0.0048818355,-0.03135419,-0.0032210755,0.0046277484,0.012389098,0.019712945,0.02680663,-0.009609343,-0.008422377,0.015667984,-0.026674634,0.004258455,-0.012748945,0.011799693,-0.010770967,-0.00050564745,-0.0032302635,-0.0034854473,-0.003288003,0.0034517988,0.0037844006,0.007742924,0.002500029,-0.03225418,-0.009622249,0.018298615,0.0072798533,0.016681427,0.0064246203,-0.0018929297,-0.020704113,0.018070122,0.015301253,-0.0065826215,-0.011357264,-0.010940313,0.013892557,0.0060900124] +4 向量检索用于语义搜索 [-0.027028307,-0.018454418,-0.020752339,0.014658693,-0.012577907,0.031206137,0.03128784,-0.02762865,-0.016586652,0.0004722909,-0.05154268,-0.033152554,0.005617677,-0.0037056417,0.006256383,-0.03583639,-0.0057257675,-0.028783357,0.022280317,-0.038117167,0.012682711,0.037622757,0.051565528,0.05026264,0.02113089,0.018320508,0.0054609682,0.010702975,0.010596307,0.013566467,0.026630875,0.021864397,0.03659466,-0.030101158,-0.05870346,-0.024428587,-0.010679111,-0.022148043,-0.018757105,0.03530027,0.008236382,-0.012015388,0.020585211,-0.017727304,0.030625258,-0.02530681,0.020680176,-0.03272597,-0.06310806,0.019496137,0.0029903823,-0.008265816,0.07791637,0.010919141,0.038637117,0.0112266345,-0.05817905,-0.012845009,-0.08831811,-0.06768337,0.009251135,-0.018619135,-0.028432013,0.03648414,0.034186244,0.06557044,-0.04166449,0.033390637,0.013031797,-0.026525145,0.017918918,0.0041121054,-0.008970216,0.005446718,-0.025512742,-0.0022323695,-0.01697996,-0.029741624,0.009104791,-4.4942994e-05,0.00265351,0.014857048,0.0034733673,0.0148677295,-0.026491437,0.018521896,0.004430444,0.0005250101,0.009057151,0.037439983,-0.026575089,0.01587454,-0.009376227,-0.031052394,-0.032155413,0.010295814,-0.01838685,-0.023581987,0.02093864,0.019792743,0.02320153,-0.0011655277,0.032974508,0.010397206,0.023697684,-0.030511344,-0.014984385,0.021475269,-0.0029799754,0.0018887277,-0.007701886,0.015253059,0.030296365,0.016385898,-0.007961702,-0.029468363,-0.009097098,0.005173954,0.04908055,0.011009307,0.027618567,0.01873562,0.045140427,-0.027164696,-0.014797248,0.012669827,0.008838803,0.035845384,-0.015425219,-0.0011529295,0.0030177066,0.0113962935,0.0034463084,-0.005234705,-0.036284897,-0.057482187,0.014648586,0.025830464,-0.0029423095,-0.068249956,0.018892806,0.0732921,0.016254693,-0.06831323,-0.006495641,-0.01557419,0.04946617,0.011134508,0.023745526,-0.07852445,0.0011760446,-0.001811588,0.057608888,0.04264815,0.019570433,-0.003692346,-0.07294579,-0.016653862,-0.008585068,0.0040898495,0.0006597195,0.010848479,-0.018962093,0.019659517,0.020586213,-0.00797197,0.011569513,0.033280045,-0.0032123064,-0.074088104,0.030359145,-0.018801646,-0.018740123,0.004345945,0.037621174,0.012214812,0.104389496,0.0072688977,0.038556553,-0.047284428,0.008090808,0.029038055,-0.004370008,0.0055907215,-0.054026674,-0.043546688,0.0018599546,-0.055455133,-0.0086151175,0.008665101,0.0068986192,-0.05033979,0.025354685,0.0045933602,0.034084313,-0.028919794,-0.013527014,-0.013169502,0.0005694613,-0.057426803,0.0019768283,0.002833342,0.00988338,-0.046493016,-0.027775493,-0.0029786567,-0.011310298,-0.04045663,0.05258926,-0.011925596,0.037908565,-0.00087273726,-0.017430568,-0.0037282235,-0.031504832,0.00056181586,-0.007969968,0.017152572,-0.011651625,-0.050248437,-0.021928012,0.03941112,0.012617211,-0.00830732,0.06758783,-0.0073520276,0.039357252,0.023355596,0.01683412,-0.00020889036,-0.0024081578,0.0048249634,-0.0036552742,-0.0375592,0.019652758,-0.023951013,0.019731754,0.007888082,-0.011941006,0.026560862,-0.064070195,-0.068686776,0.07536235,-0.005409503,-0.048255894,0.008121076,0.02519715,0.043467555,0.010990065,-0.0005524118,0.03426651,-0.0043872767,-0.059083953,-0.012042777,0.046328075,-0.028827097,0.05242085,-0.022383355,-0.0019588107,-0.009640234,0.0068587535,0.020508034,0.015228534,0.023379138,0.01760272,-0.0448751,-0.006241157,-0.015765162,-0.018965818,0.01679093,-0.027412051,0.017661067,0.011701643,-0.01279126,0.00078299013,-0.023006255,-0.042070445,-0.02450163,0.015453433,0.006021128,-0.017953139,0.012949167,0.0009985176,-0.026912617,0.012827562,-0.005461256,0.04668043,-0.018358327,0.040278763,-0.047174845,0.0061355806,-0.03729744,0.06839239,-0.003487128,-0.05294799,0.004893578,-0.020826764,-0.14075607,-0.008788457,0.0017013233,0.009511729,0.029964611,0.005930201,-0.05048128,0.0018205215,-0.057734717,0.013329159,-0.022767344,-0.053395778,-0.018664712,-0.030528393,-0.01057633,0.021282116,0.008929843,-0.027991982,0.0067726267,-0.0053144917,-0.030099828,-0.040999234,0.027734717,0.047994617,0.0023852559,-0.028430406,0.015446953,0.028165264,-0.02588144,-0.050806914,0.021397406,0.053532816,-0.014012767,0.019608164,-0.008488163,0.027465213,0.008894915,0.025909664,-0.0031120265,0.024743881,0.012022964,0.018926783,-0.0088660745,0.050889514,0.0010654264,-0.030657565,0.006334183,0.026740896,-0.024708668,-0.026691593,0.013716934,-0.020056292,0.040643606,-0.01609809,-0.064223416,0.014338976,-0.009439757,0.0074210535,-0.0299989,0.012670323,-0.035582058,-0.047305036,0.020561727,-0.012790401,-0.0565082,-0.034547944,0.030139025,-0.009004321,0.0355639,-0.0005578928,0.064476736,-0.032049336,-0.04091055,-0.040153477,-0.03635758,0.042825304,0.025782034,-0.05537734,0.028624674,-0.11312563,0.012959637,-0.031547967,0.03593362,-0.049294252,-0.005658173,0.0025248555,0.015458002,0.034001704,0.010034578,0.20071973,-0.010155263,0.003453359,-0.014933899,0.034917694,0.010642711,0.01541926,0.010941553,-0.0044264845,-0.02298832,0.039701063,0.047636796,0.014636478,0.016471863,0.022600591,0.040950675,-0.024942111,0.00783015,0.062052462,0.013381561,-0.031157672,-0.019950762,-0.020231469,0.055761367,0.023719123,-0.011739495,-0.0009401134,0.034187175,-0.04728611,0.07000632,0.0020718467,0.030750535,0.011580414,-0.025778508,-0.051217083,-0.038582467,0.026892545,-0.01523184,-0.027210511,0.022889655,-0.04791021,-0.023297504,-0.022999082,0.023230292,-0.0029499815,-0.016704315,-0.0060295425,-0.066669695,0.008841965,-0.019183412,-0.02176469,-0.034576885,8.936629e-05,0.030973429,0.029081335,-0.014049001,0.0019389107,-0.000801926,-0.0014365815,0.041867424,0.05751022,0.003955865,-0.039078884,0.020071208,0.016813636,0.017411243,0.03988093,0.005766326,0.06451386,0.03439112,-0.023184177,0.0147909755,-0.0070052966,-0.0053235893,0.011783478,0.010011617,-0.023271471,-0.020991746,-0.016054178,0.0344054,0.012723585,0.007937268,0.020287246,0.00012125414,-0.011071029,0.033122912,-0.01657088,0.00404085,-0.0029800457,-0.019361008,-0.04982303,-0.043625627,-0.027410526,0.0075603537,-0.024584014,-0.0032171933,0.06593552,-0.0036068778,-0.019793658,-0.0257234,0.055007868,-0.017925723,-0.020391513,-0.019927073,-0.00045252853,-0.008459779,0.002418343,0.005501621,0.0066389972,0.014148736,-0.042491894,0.05155435,-0.024857432,-0.038719375,-0.007200525,0.026418291,-0.029860293,8.8686356e-05,-0.0015406448,0.045693193,-0.03829264,0.00012171524,-0.020241393,0.023100747,-0.01393565,0.012543318,0.00939588,0.02923087,-0.041004565,0.057567023,0.057725728,-0.03681416,-0.045881394,-0.01750238,0.017897254,-0.036039207,0.037094295,0.03925467,-0.01692263,0.0018171525,0.023266444,0.0150627885,0.004592093,-0.002283799,0.018486004,-0.006769525,0.028151063,-0.044038847,-0.031323932,0.011064207,0.016236117,0.018536365,0.018805573,-0.0635246,0.0071291053,-0.021447571,-0.0502245,-0.004014146,0.012246607,-0.02419828,-0.013383885,0.014722871,0.0005977002,0.017958293,0.00991444,0.03539078,-0.016396595,0.005810177,0.0063735596,-0.033323858,0.027242007,-0.022236597,-0.053074792,0.032888655,0.035754476,-0.012451514,-0.050493598,-0.0169929,-0.026489455,-0.011987468,0.017548267,0.0037686029,0.016170057,-0.029493898,-0.010878428,0.033443477,0.009453536,-0.011446381,0.040235884,-0.03939419,0.015331388,0.10210376,-0.0061175954,-0.007832271,0.00041205302,-0.034122482,0.083775796,0.022444554,-0.010275098,-0.02411015,-0.011665968,0.037556585,0.0335922,-0.0027976441,0.002910901,-0.015217097,0.01900079,0.0054701404,-0.01374142,0.0039703217,-0.015927648,-0.0072420733,-0.06277,-0.015240784,0.0048713456,-0.040965345,0.060414504,-0.0020566727,-0.024907077,-0.004068991,-0.007248404,0.014454299,0.009404875,-0.029250901,-0.061031062,0.01437013,0.0069994275,-0.00830011,-0.0056234132,0.024954254,0.0023953356,-0.0044458364,-0.078765385,-0.046880852,0.01238598,0.010888452,0.0073574577,0.045893066,-0.0010247789,-0.03710627,-0.002456625,-0.032063194,0.012827895,0.07498527,-0.03054718,-0.005504839,-0.017070532,0.007815754,0.08040226,0.001659331,-0.03268817,0.010630757,0.026545828,-0.015822861,0.026753478,0.004988584,0.018846167,0.027926637,-0.012447355,-0.028233588,-0.011064253,0.0055138213,-0.026670178,-0.022833016,-0.0433008,-0.05278069,0.026522622,-0.023533702,-0.005724127,-0.066748545,-0.009582779,-0.0064594,-0.024412557,-0.06758469,0.03782239,-0.013092633,-0.07508461,-0.037447367,0.027834207,0.01715145,-0.01612742,0.03110332,-0.016917372,0.01989196,-0.02689158,0.027807757,0.007516618,0.008682904,-0.01044869,-0.038629584,-0.00041229435,-0.009937017,-0.03580617,0.005056233,-0.015755631,0.011572031,-0.0320622,0.038210757,0.0028599736,-0.023527822,-0.01205414,0.061461274,-0.04122327,0.029874872,-0.006602531,0.016131401,0.0058313487,0.0075746835,0.046198033,-0.0037547392,-0.01619417,0.012624718,0.012858155,-0.003298366,0.015779687,0.03750853,0.052277897,-0.038679544,0.046352517,0.03573213,-0.035470627,-0.0051352936,-0.0113894995,-0.038450394,-0.014415598,-0.022748776,-0.0012773583,-0.020256896,-0.03098444,-0.020733176,-0.019844716,-0.031736407,0.007842755,-0.021053191,0.010822791,0.008031342,0.0083837025,-0.03530217,0.019532466,0.028706893,0.055810235,-0.0099948365,0.054785896,-0.06711614,0.030519413,0.0007395431,-0.022889776,-0.034003213,0.037371796,-0.013475217,0.011042911,0.017670818,-0.015622809,0.0038918972,0.02698487,0.022728309,-0.0271386,0.010730327,0.025124136,0.019883566,-0.021724604,0.029915817,-0.02104327,0.018435052,-0.00362233,-0.022638215,0.028020497,-0.025723381,-0.012782323,-0.030724052,-0.042445727,0.04567308,0.004208166,-0.05301613,0.010960295,0.023202213,-0.03637717,-0.027636731,-0.0029191603,0.023844251,0.039975487,-0.036683857,-0.0020500962,0.012205054,-0.0074870563,0.011206855,0.008609057,-0.007566396,-0.0057504363,0.041588333,-0.017575216,0.008741176,-0.0234616,0.07495003,-0.15138985,0.018858092,-0.020032568,0.0030144162,-0.036755633,0.019578686,-0.031943664,-0.039441776,0.028646542,-0.025495036,-0.01766476,0.039841942,0.01345574,-0.049637575,0.03326788,0.028726919,0.005788635,-0.028325072,0.025280202,0.046998322,0.0053835823,-0.01019354,0.028389992,0.021377988,-0.0059337583,-0.002798413,-0.03972116,-0.0102809975,-0.03231922,-0.035577692,0.012157603,-0.07171533,0.042399865,0.080860525,0.072581224,0.0057977587,0.025425466,-0.009175978,0.038107663,0.004801347,0.01660607,0.013408649,-0.019288335,0.04029825,0.0042065727,0.035137042,0.06003402,-0.008692207,0.0052638007,0.006708894,0.011851557,0.023497198,-0.06411597,0.0028245943,-0.012315698,0.02326979,-0.05408277,0.017085133,0.0161593,0.01839178,-0.036180712,0.008024038,-0.021015007,-0.028126195,-0.006290129,0.033209104,-0.03169164,0.054739617,0.03980994,0.025669735,-0.00025317605,-0.015722701,-0.030031314,-0.023049034,0.013823266,0.030890012,0.011139629,-0.03433192,0.00047745885,-0.017165732,0.045191206,-0.012646251,0.014665415,0.006940717,0.0008287753,-0.025304882,-0.0021576416,0.03163531,-0.07550926,-0.026078979,0.016422186,-0.029327126,0.013826449,-0.011201773,-0.049987756,-0.029113773,-0.03979838,-0.008590075,-0.02050171,-0.02039293,0.011034814,-0.013818395,0.0015683812,0.04003942,-0.033354614,0.049072485,0.075714074,-0.0026191622,0.05172621,-0.0042640874,-0.014800921,-0.032711737,-0.0036816227,0.024092607,-0.012812979,-0.0030004731,-0.025611704,-0.020915903,0.012473539,0.02305682,0.022394668,-0.0033083963,-0.0066342736,-0.03188014,0.005565977,0.017800737,0.005438518,0.012419975,-0.0018948576,-0.030542875,0.023998575,0.0027472614,0.02116987,0.053615753,0.0026265536,-0.0096333595,-0.01144284,0.046475865,-0.01195006,-0.024279136,-0.020647306,0.011338807,-0.001097892,-0.0059875525,0.00023543877,0.03210973,0.0020468081,-0.019695252,-0.047413368,-0.060564116,0.002353305,0.011989898,-0.003233252,0.02886623,-0.02141227,-0.027778529,-0.04066916,-0.017810374,-0.018867115,0.046729513,0.029051762,-0.007439066,-0.009534164,-0.0012353467,0.0070293224,-0.0042377235,0.014078904,0.0019753862,-0.032234967,-0.021879159,0.050527126,-0.004914207,-0.03522421,0.004184878,0.023953566,0.0045608548,0.02052343,-0.012175278,0.056359917,-0.08944671,0.004532651,-0.024120249,-0.02120115,0.017929671,-0.017754275,0.014156042,-0.05833406,0.012673803,0.046647828,-0.008026725,0.033218168,-0.021870175,0.035905622,-0.020662982,0.010830058,0.037406053,0.024279686,-0.045571543,-0.033529233,0.03289335,-0.07415477,0.056372162,-0.058900185,-0.052198265,-0.04020808,-0.025233654,-0.0030147103,-0.045442566,-0.028932236,-0.006057155,-0.017452419,-0.0032542213,0.028003,0.002541323,-0.03283125,-0.009957826,0.036254544,-0.012552086,0.008839926,0.001041657,0.0038530994,0.0081348475,0.034677137,-0.011876113,-0.058768798,-0.02224873,-0.009267207,0.028035535,-0.00019208739,-0.015870208,-0.013636308,-0.0108294245,-0.023536447,0.04798635,0.01417597,-0.022596221,0.10651076,0.012112892,-0.02775572,0.033242587,0.0475799,0.0064098006,0.060481563,-0.019666798] [-0.00032334373,0.026418112,0.02309961,0.04352296,-0.0017813423,0.0513523,0.06888018,-0.02573161,0.014928402,-0.009221125,0.05311334,0.03138243,0.0006615238,-0.02967993,-0.05829057,0.0033586153,0.010996887,0.0012119539,-0.026156565,-0.0076912222,-0.00269416,0.020435091,-0.0012587175,0.048743818,-0.033248242,-0.0068898136,-0.017551722,-0.064929835,0.02189721,0.0016433055,0.018665323,-0.060464285,-0.012111978,0.035065513,0.0063968194,-0.015823456,-0.025066653,-0.0074929553,0.014295699,0.015932556,-0.006812029,-0.04715528,0.027947286,0.0076289927,-0.014788793,0.006239103,0.010770901,-0.03150065,-0.020392124,-0.0016583017,0.0053876946,0.017911976,-0.015474081,-0.016759427,0.026964512,-0.0012039063,-0.009545656,-0.0062130396,-0.019871222,-0.035483193,-0.005503777,-0.00065519265,-0.036046628,0.004935446,-0.008790509,-0.009247758,-0.008896432,-0.014949856,0.013209029,0.0022173242,-0.014566885,0.011673673,-0.0052771964,7.5919765e-05,-0.0004983281,-0.030143587,-0.00095564505,0.013407742,0.009658991,0.007993539,-0.012445104,0.0069348137,0.014860247,0.0025169754,0.014374679,0.002347297,0.01355203,0.0064845565,-0.021970592,-0.011055802,0.016799029,-0.021217193,0.008072398,0.008225785,0.00082733255,0.01265563,-0.020425726,-0.021698957,-0.018810859,0.00554892,2.2328644e-05,-0.016524967,0.02647141,-0.0132172145,0.011581242,-0.023991011,-0.004496101,0.0027438807,0.022142189,0.0013398393,0.007804249,0.0022260393,-0.015137405,0.025185332,-0.0009062907,-0.0007196228,-0.014483482,0.042896967,0.006959331,-0.00014978967,-0.01015876,0.018727988,0.0077835335,0.002199553,0.0063319854,-0.04709547,-0.019258065,-0.0070817824,0.033285845,-0.0046602883,0.031089094,-0.019819295,0.006917558,-0.025010394,0.0059654154,0.013705634,-0.013663052,0.0070538805,-0.0022515717,0.017250173,-0.010099455,-0.006943599,0.0056528896,-0.06306872,-0.0020631421,-0.033022143,-0.005755546,-0.017801315,0.006018463,-0.008919163,0.029554538,0.012872789,0.018982604,0.0066506485,0.002025642,0.022520842,-0.027623134,-0.010292554,-0.00963903,0.016934859,0.00275871,0.008775636,0.0016494612,0.042625688,-0.0055182935,0.023808913,-0.022028297,0.007242246,-0.010236123,-0.02225495,0.020176511,-0.017804552,-0.0016243095,-0.008305141,-0.019030992,-0.009688826,-0.020584816,0.0041036224,-0.013363237,0.028281722,-0.010224736,0.007894272,0.027591698,0.009041003,0.026949888,-0.00049959956,0.0032648358,-0.009814729,0.020431742,0.010231712,-0.017216396,0.014341689,0.0029310489,-0.006983754,-0.00036043258,0.0020926867,-0.007335737,0.010730455,0.02074322,0.046753284,-0.0069428813,0.007418813,-0.008540465,-0.0023408572,0.008016155,-0.008413646,0.018610924,-0.019753762,0.014848084,-0.006880526,0.0038597626,-0.04850952,-0.022404833,-0.010349593,0.0109028015,-0.0023568014,-0.020862645,-0.013194627,0.031062385,0.014292991,0.011335416,0.0026608347,-0.0148943905,-0.003975663,-0.032258872,-0.01361939,-0.0014260056,-0.014665739,-0.010385653,-0.000516069,0.009614582,0.00094333617,-0.022415081,-0.0023916713,0.010383154,0.0048137163,-0.014786671,-0.015853139,-0.00014554166,-0.015288978,-0.018353647,-0.04020391,0.014242566,0.01978364,-0.00027870445,-0.023886167,-0.011828206,-0.021738615,-0.015055413,-0.019235874,-0.033080813,-0.0037597895,-0.0016337838,-0.02228126,-0.053218756,0.007838434,-0.01453608,-0.030895708,-0.009777779,0.025790751,0.009523721,-0.0054637417,-0.00053421664,-0.0057705184,-0.0044474048,0.019109132,-0.0069876965,-0.014414437,0.01068496,-0.011050838,0.03088721,-0.012073045,0.015452374,0.0047067944,-0.022844514,-0.031506147,0.0003814417,-0.0045937574,0.0070975255,0.021304145,0.001677821,-0.0023155706,-0.004597633,0.018127244,-0.009075998,0.0033392287,0.040657654,-0.019900812,0.010412847,-0.0012785248,0.013008618,-0.018706476,-0.007841146,-0.0071576927,-0.008180297,-0.0035841665,0.017239518,0.0034458532,0.0013720453,-0.025934173,-0.01219503,-0.005735144,-0.008605903,-0.004267097,0.03560516,0.03184466,0.012337296,-0.0037552952,-0.035928957,-0.0001132505,-0.01140419,0.004704191,-0.004987557,-0.010700937,0.0064005144,-0.05964459,0.018777857,0.011145358,-0.017234774,0.004931979,0.0012374129,-0.057913024,-0.02642846,-0.008859655,-0.04602775,0.011132639,0.010285184,0.024990037,-0.007248818,-0.005367307,-0.006914523,0.016664362,-0.02540826,-0.010937809,0.004217772,-0.014992759,0.017414682,0.047888976,0.014784536,0.02102414,-0.02166695,0.0044427845,0.0124740265,0.013841016,0.012643708,-0.000100986304,-0.006654479,0.0152482735,-0.034305558,-0.01696173,0.0074364175,-0.013290104,-0.0011691228,-0.01186829,-0.025892232,0.012045529,0.009411875,-0.0041704024,0.024790429,-0.020591026,0.00056423934,-0.017221352,-0.008614101,0.02441325,-0.04187153,0.005938516,-0.01351655,0.013405978,0.0047772923,-0.021190697,0.014071568,-0.029487588,0.032774508,-0.013594179,-0.04225612,0.009046992,-0.02461572,0.013967671,-0.0036983716,-0.008257378,0.011506554,0.032581493,-0.023836408,0.008358258,-0.018750252,-0.00591178,-0.008518503,0.015895316,-0.007872346,0.024045244,0.0007726724,0.031843267,0.0006894129,-0.019149909,-0.029702328,0.033109747,0.0054756324,0.003677335,0.0074228137,0.0020999385,-0.017458,0.034153897,-0.012869966,-0.012910824,0.0016000317,-0.018756038,-0.01974252,0.03973896,0.0022340014,0.0031170736,0.020012332,0.01623432,0.02007714,-0.0044813883,-0.02527414,0.013042374,0.030356735,-0.019708367,-0.002550965,-0.0020390435,-0.022775102,0.01698466,0.0006096494,-0.009363232,0.026565965,0.0139833605,-0.02905463,0.01843614,0.0061856383,-0.035771143,0.00083079515,0.018278457,0.0019045932,0.03446568,0.0033577397,0.019440793,0.04109526,-0.024241488,-0.027481848,-0.027277488,0.0126016,-0.009156846,0.0023166037,0.023971902,-0.05141916,-0.0059909853,-0.009270764,0.0087964265,0.041779578,-0.026622066,0.011254461,0.013400809,0.015218911,-0.025008798,0.0007880931,0.038750667,0.023868717,0.018790962,0.00072205794,0.021697354,-0.07666595,0.020064797,0.016641071,0.0014203471,-0.030797854,-0.02326236,0.010976729,-0.029656164,0.018043298,0.0037952529,-0.027667763,-0.016549146,0.0014302114,0.011796792,-0.014884519,0.019935831,0.017990869,0.011743572,8.906212e-05,-0.009320363,0.0030110697,-0.020737432,0.0069736945,0.014532184,-0.014013226,0.017238582,-0.0040609157,0.032736223,0.0023432306,0.030525668,-0.00085161824,-0.050749842,-0.031059949,-0.013243878,-0.028517159,0.026016941,0.017265834,-0.005304343,0.042721026,0.0049372674,-0.03566009,-0.012965805,0.01012972,-0.018016351,-0.021255972,0.033008024,0.011236684,0.01317209,-0.015630972,0.02868662,0.03541719,0.007727573,-0.02208577,-0.042627934,0.016704053,0.009811866,-0.015395739,0.0030280803,-0.0068813227,0.021679953,-0.020921854,0.029146545,0.03915947,0.0013397019,-0.04965795,0.004264031,0.0058940644,0.025463175,0.034038242,-0.008454236,-0.005971064,0.010970358,0.020656494,-0.0026523536,0.0071946024,0.021181455,0.0021750606,0.006129944,0.0096668,0.0045852773,-0.041002985,-0.008456415,0.010595685,-0.0063181305,-0.0025486147,-0.010365887,-0.040740795,0.0070858398,-0.027286945,-0.0022176404,0.026987873,0.009689519,0.0029321923,-0.00021297237,0.0017252465,0.020484816,-0.004800148,0.009710845,0.013042433,0.012906496,-0.02076586,0.008018855,-0.0064031747,-0.010326474,-0.0048953686,-0.01827074,0.020453554,0.020751055,-0.03327227,-0.012418307,-0.0021029927,-0.012351553,0.01061186,-0.006950396,0.025242504,-0.009614489,-0.02246331,-0.014060581,-0.014455029,0.02684108,0.036543414,0.014513755,-0.007834365,0.013138907,0.014346375,-0.023423273,-0.022846514,-0.021825051,0.01735038,-0.016984347,-0.012767594,0.01744281,-0.017719697,-0.026781429,0.009753652,-0.019872438,-0.0018985397,-0.03273091,-0.0020741725,-0.020357454,0.045369215,0.012263944,-0.017167263,0.014794956,-0.0015394065,0.022056036,0.025949692,0.021758555,-0.031676903,-0.005860121,-0.003120979,-0.01370788,0.0181907,-0.022610672,0.0120466715,-0.011338582,0.0018303177,-0.006108854,-0.029870994,0.00015922345,-0.026615765,0.017364742,-0.029448055,-0.010509286,0.016429387,-0.031089863,-0.012223129,-0.008841276,-0.005127253,-0.0061717895,-0.01983439,-0.018608468,0.02201366,-0.025244182,-0.0016832087,0.008154609,-0.048447598,0.026830696,0.018966444,-0.012762052,0.025807101,0.01693155,0.020489713,-0.045693822,0.013848499,0.021673312,-0.04304075,-0.037983812,0.02057353,0.0053753103,-0.013610788,0.025597876,-0.03700531,0.0059914985,0.025356228,-0.0069318446,0.0053036762,-0.016861334,-0.005764746,0.0066213305,0.008788556,-0.045045625,-0.023711476,0.05271904,0.010203624,-0.007442556,-0.034568656,-0.020243136,0.01119501,-0.031011699,-0.016359428,0.003612632,0.0026223885,-0.0043067243,-0.023741072,-0.024039488,0.031859137,-0.01252827,0.026434593,-0.01962917,-0.0065115774,0.0023938185,0.0155818,-0.001944341,0.0005032583,-0.012091399,0.0035941375,0.029633325,0.0320745,-0.0038694246,0.008768328,-0.004435959,-0.0038601663,0.027112609,0.03518521,0.012281228,-0.0009093471,-0.037566375,-0.015023954,-0.00585433,-0.0025309573,-0.03198037,-0.013732127,-0.012911516,0.010023305,-0.013135693,0.038994092,-0.0013884199,0.019596078,0.03119729,0.01617523,-0.02785155,-0.058127522,-0.02474723,-0.018932557,-0.012392059,-0.011763971,-0.0058444818,0.007617072,-0.0042839795,-0.004755272,0.011219733,-0.008330694,-0.006192539,0.0029326244,0.020179948,0.007779114,-0.007183954,-0.015201683,0.07497708,0.027333945,-0.02079141,-0.015162029,0.00029446709,0.007325831,0.008571376,-0.012429898,0.016823206,-0.0009954022,-0.0028253957,-0.0035553405,-0.019354967,-0.0059554554,0.0014048881,-0.00086433376,0.0056799897,-0.020140301,-0.02463942,0.023237849,-0.015305286,0.010899781,-0.0067985253,0.028409405,0.0237929,-0.021041535,-0.005642848,0.017128099,-0.014025518,0.003893298,-0.01467755,-0.030284738,0.0006198416,-0.009424107,-0.020952173,-0.022143202,0.018862067,0.00085296656,-0.023224697,0.009967488,0.031984217,-0.004270837,0.024989812,0.017539522,-0.03349796,0.012011016,-0.0132897515,-0.015672926,-0.00068146316,0.022134993,0.014800547,0.0048997006,-0.005899841,0.032887593,-0.01213199,0.011308497,-0.014880206,-0.019075364,-0.031765733,0.0340708,0.024746785,0.015541964,0.029956613,0.0014974001,0.004594526,0.0014337247,-0.022545919,0.011135823,-0.004290762,-0.050475944,-0.022273295,0.015479359,-0.013913251,0.012597321,0.03580559,-0.001436134,-0.006660957,0.0015332235,-0.03344802,-0.03447382,-0.0037434325,0.0032529964,-0.03750069,-0.03789354,8.141116e-06,-0.021201832,-0.006607266,-0.011376289,-0.014761735,0.032672733,0.036153674,-0.014604571,0.009068089,-0.005327003,-0.026798835,0.030839248,0.010166883,-0.0056682057,-0.007958416,0.01538641,0.04423342,0.028632082,0.013320489,0.019745016,-0.003826323,0.0034920797,0.012484553,0.005972643,-0.047469478,0.03400929,-0.016059028,-0.016397774,-0.01214294,0.057072002,-0.012080324,0.006601791,0.0055089444,0.021838346,0.01385568,0.02017149,-0.00024163187,0.003770831,0.05967931,-0.0072547295,0.023754975,0.004890654,0.017979257,0.032563087,-0.010267767,0.029570725,0.013044862,0.029182974,-0.025290698,-0.0283004,-0.008521793,-0.011683342,0.009046554,-0.0017651154,-0.035441145,0.0062726336,-0.020078508,0.011872121,0.03794776,0.015758568,-0.007563584,0.003626616,0.007904202,-0.018775186,-0.007851871,0.012619094,0.013300632,0.024896827,0.0069288067,0.035811454,0.010323123,0.007494225,-0.002543483,-0.009793812,0.0129647385,-0.020079013,0.020761687,-0.02569978,0.02378588,0.0057545183,0.031593155,-0.013469847,-0.018392025,0.012857692,-0.018889248,-0.0012033974,-0.031351764,-0.0024063694,0.027471554,-0.0025187018,0.022050563,0.0006314989,0.014634001,0.005568986,-0.013231015,-0.002013933,0.0121953115,-0.0004183712,-0.019255722,-0.020124262,0.018321782,-0.034533508,-0.03168439,0.0011364527,-0.0027402702,-0.003126623,0.0029109516,0.02195271,0.01326952,-0.018678026,0.010153282,0.040657587,-0.009787088,-0.015550108,-0.018770695,-0.05213824,0.023184353,-0.0031746652,0.03155689,-0.009828245,-0.028087806,-0.0026248358,0.0070663854,0.0061258534,0.005690783,0.0027157457,0.020865468,-0.020271013,0.0028124042,-0.0032977758,0.0564203,0.00045410727,0.018564638,0.010185821,-0.002237333,0.012562162,-0.012613138,0.008142713,0.0080717085,0.006022887,-0.028166486,0.022759685,-0.011761083,-0.0019549788,-0.00725243,-0.009064038,0.024511639,-0.03663709,0.05398717,-0.013495994,0.005852909,-0.00688495,0.01015503,0.007387004,-0.017072598,-0.011382659,0.022883473,-0.00392041,-0.040380582,-0.0018438024,-0.021459946,-0.0058579636,-0.00018030955,-0.008245022,0.030223835,0.050589252,0.0021370286,-0.008590564,-0.00030602617,-0.00863024,-0.0184926,-0.003246863,0.011870761,0.03914331,-0.008754317,0.037618376,-0.019819953,-0.007032719,0.009842521,-0.0065794443,0.019787652,-0.016592255,-0.0013128181,-0.03246049,0.021779407,0.0036775896,-0.011147244,0.013513745,0.013473223,0.0023951626,-0.04576045,0.000726851,-0.02744838,0.00011943094,0.00030647454,0.04456683,-0.032629304,-0.024102965,0.005857489,-0.0074439896,0.0034896082,-0.036269773,-0.029285606,-0.020753752,0.019535823,-0.033907484,0.014702069,-0.00025283062,0.015395157,-0.003497827,0.0026707486,0.04173722,-0.015505164,0.02454403,0.058591895,-0.031714514,-0.0057327743,0.0045042024,-0.018337913,-0.029046226,0.019214319,0.013892585,0.00798338,0.018447965,0.013641617,0.0033003031,-0.018994888,-0.006925521,0.020312112,-0.0071470216,0.0004912208,-0.0008822415,-0.012981288,0.015090372,0.0043816622,-0.0030464253,0.01932254,0.010912314,0.025682261,-0.0009904399,0.041043516,-0.002347718,-0.038652502,0.017327495,0.024012404,0.032269098,0.028215848,0.023239478,-0.022875236,0.0021650875,-0.013425299,0.0047263945,-0.03477706,0.028673043,-0.020955712,-0.01424707,0.023510987,-0.049794536,0.0019650927,-0.036533486,-0.007711301,0.0071477317,-0.021186102,-0.017732607,-0.03323017,-0.03509023,-0.02639255,-0.039549477,-0.018489921,0.0003206496,-0.0047968,-0.009297368,0.022349678,0.013151835,0.023646012,-0.009141866,0.004086383,0.011328796,0.0031344837,-0.013524262,-0.027496852,0.050655853,-0.030966414,0.0046735425,0.004661307,-0.009029566,-0.0056358892,-0.035402533,-0.009432398,-0.008696086,-0.027655242,-0.012592371,-0.0027899472,-0.011615701,-0.0036698203,0.012790811,0.013916023,-0.009520331,-0.014267933,-0.02664726,0.026967743,0.010972904,-0.0145445615,-0.016488867,-0.010420707,-0.04570478,-0.011081467,-0.030747425,0.015862508,-0.0408554,0.0030010855,-0.0068975994,0.013902397,0.030091872,0.023557702,0.007861832,0.03836544,-0.006041666,0.03735494,-0.027117774,-0.018265348,0.019969024,0.038813397,-0.004810262,-0.0009519262,0.02545179,-0.012868787,-0.0047118836,0.013798037,0.009541712,0.018983452,-0.015403486,0.009885095,-0.037879854,0.006427453,0.0009733179,0.016275998,-0.02972203,0.004338005,-0.012498665,0.010831338,-0.011615554,0.018209025,0.0183906,0.024913155,0.012728334,-0.0011480032,-0.0012723686,0.0010609081,-0.0026692508,-0.0034575863,-0.05858852,0.0013267412,0.01196207,0.009504838,-0.01139895,0.04607006,-0.008833275,-0.025425063,0.00560438,-0.0009849261,-0.0015931113,0.04490569,0.031889748,-0.027177341,-0.0030747957,0.010557516,0.019879824,0.0033205585,0.010241218,-0.0044235205,-0.023760717,0.03757358,-0.013945052,0.015592565,-0.021499248,-0.012994188,0.03295422,-0.010593787,-0.008663418,-0.035740104,0.0008215954,-0.009777383,-0.0024851842,-0.04047453,0.010254298,0.0011559791,-0.020900654,-0.032736618,0.0035338113,-0.006966103,-0.021881992,0.021784712,-0.026297532,0.016355367,-0.011631606,0.054648682,0.007672596,-0.00874949,-0.014682288,0.0038505264,0.0038955768,0.0028476836,0.02165369,-0.010754234,0.010170961,-0.0264928,-0.008915672,0.0022721356,0.001518843,-0.014721732,-0.054879848,-0.016240701,-0.005509261,0.042191833,-0.002237143,-0.013196215,-0.0276922,0.002902316,-0.023636317,0.036940806,-0.010688681,-0.008015867,-0.015517522,-0.00879773,0.008682368,0.021254987,-0.012402162,0.016919458,0.024807077,0.020620398,0.052286595,-0.003344519,0.0013104662,0.0017134253,0.0057256916,-0.020058688,-0.0068309144,0.039690107,0.0040187654,-0.001538112,-0.02297414,-0.002233665,0.045546792,0.0054887845,0.001417274,0.026073176,-0.024526192,-0.009411955,-0.049576115,-0.002219747,0.022257695,-0.012204511,0.0043950933,0.019534111,-0.005082526,-0.0067573157,0.0021166487,0.002804339,-0.0022974345,0.030195922,-0.005359056,-0.004366534,-0.011713324,-0.0014629108,0.008727789,0.0035150342,-0.014820544,0.023246013,0.000408487,-0.0018451462,0.0033377672,0.001793411,-0.015889479,0.014303514,-0.0053642523,0.0040304423,0.021910658,0.02224935,-0.02264446,-0.006961262,0.010497223,-0.009145204,0.011279256,0.0011761608,0.00941315,0.0053918175,-0.02058213,-0.006684431,0.0069482964,-0.0047568833,-0.019086583,-0.03352581,0.008163691,0.0007777378,-0.02720622,-0.010054697,-0.021955479,-0.04437463,-0.009507621,0.004352714,0.0026927479,-0.0038984132,0.012976626,-0.021358501,-0.0005047047,-0.009647555,-0.016595367,0.0007500688,-0.010828497,0.014635031,0.005966586,-0.01770634,-0.011212238,-0.01750282,0.00012336869,-0.00739502,-0.017679358,0.013424863,-0.0072525954,0.012033022,-0.009457403,-0.0046848576,0.048925843,0.014194635,0.046047077,-0.016223459,-0.01503329,-0.049952827,-0.018172555,0.010879516,-0.015437606,0.017249165,-0.04079431,-0.0008007455,0.023965355,-0.022194404,0.0035443385,-0.011872815,0.009574162,0.025493914,0.023665478,0.02662234,0.00079510896,0.0142911635,0.020480465,-0.014151526,0.022654554,-0.054932047,0.03072702,-0.010985903,0.0011639083,0.014811552,0.03040733,-0.004576088,-0.015511362,0.0054711746,-0.01631474,0.050072532,-0.0032385008,-0.016359085,0.03915725,0.014408674,-0.022778967,0.039325766,-0.00577773,-0.001998545,0.015489981,-0.021015858,0.026706602,-0.0012499538,0.009724338,0.054182306,-0.03417499,0.020024596,-0.003878142,0.012367475,-0.00088304444,-0.018412413,-0.027883396,0.019734584,-0.0062864274,-0.041607026,0.024461146,-0.006847418,-0.017045468,0.009809133,0.009362933,0.009484971,-0.03719751,-0.012771424,0.028055228,-0.027914431,0.02073996,-0.01763927,0.0030636874,-0.009643223,0.007896162,0.013270901,-0.0097362865,-0.021155493,0.0224654,-0.028019702,-0.0120212445,-0.023171892,0.010951083,-0.011857793,0.0119244065,-0.00951911,-0.004315208,0.007028045,0.020932306,0.0031935638,0.00437505,-0.014515207,0.010839882,-0.032547105,-0.0029320605,-0.0043818248,0.0072972802,-0.0008140714,-0.0071291826,0.041335944,-0.015127816,0.025084967,0.008329809,0.0067245625,-0.033700988,-0.021444958,0.009012599,-0.0023274184,-0.0119708795,0.0076949033,-0.0038688588,-0.014493857,-0.03491991,-0.017440265,-0.011081101,-0.019230198,0.017512398,-0.028813906,-0.021726176,0.013126526,0.003973573,0.0011861464,0.01999212,0.0036872893,0.024162402,0.031658884,0.011305765,0.029999388,0.007853621,-0.0459594,-0.0035761977,-0.02369416,-0.01934886,-0.01540093,-0.0075064935,0.0032913394,0.0004910544,0.016007436,-0.010524878,-0.01013283,0.045446403,-0.03591068,-0.012650481,-0.04266207,-0.02111371,0.0062845303,0.013718533,0.005150644,-0.025381504,-0.004631853,-0.024314431,-0.00385294,0.00903792,0.0015192934,0.022917358,-0.025889466,0.008608761,-0.019815201,0.0017738575,-0.009604496,-0.0073989187,-0.002866686,0.013570155,-0.026222112,0.020419054,0.0041460926,-0.016108453,-0.0050559994,0.005938035,0.006579968,0.005598553,0.010167845,0.00914253,-0.008250842,-0.009226258,2.4188254e-05,0.011256351,0.008488549,-0.024278414,-0.017322939,0.022738954,0.008146981,0.008742565,0.015915787,0.00889431,-0.020174095,0.010645077,0.0021013438,-0.010741434,0.000120480916,0.024072574,0.009624674,0.019731537,-0.014444415,0.021291882,-0.032182656,0.019043248,0.010040943,0.011497285,0.022378938,0.007423733,0.016728112,0.03165771,0.025292546,-0.0035687177,0.029835034,0.005958824,-0.008762628,0.0017047387,0.0005943129,0.0010226694,0.02793465,0.003749743,0.005252504,0.017674847,-0.03573914,0.018431958,0.018899374,-0.026250463,0.04110027,0.033405833,0.032180645,-0.0013869628,-0.0015622026,-0.0028881542,0.047902938,-0.015114185,0.0075443587,-0.006896506,0.010361725,0.000531907,0.0015306202,0.0062612663,-0.0027198477,-0.019279378,0.014731618,0.0136464955,-0.030457882,0.00043992302,0.0023918918,0.034188475,0.0019187914,0.008365452,0.007912472,0.012169908,-0.006990824,0.019950349,-0.012026728,-0.03454039,-0.0065554176,0.036875114,-0.006625441,0.008922375,-0.029977018,0.01546298,-0.00064983394,-0.010507829,-0.0028362416,-0.014554488,-0.004096294,0.008847385,0.0013136623,0.019758947,0.02331465,-0.015876077,-0.02322261,-0.019972634,0.016402407,-0.039388917,0.011920968,0.019450191,-0.025049794,0.00086690206,-0.01624136,-0.0050243516,-0.029621312,-0.014728981,0.02669195,0.0007808735,-0.018782264,-0.0045703193,0.016939754,-0.006772887,0.028533988,0.020937977,0.0063344114,-0.037788898,-0.033540476,0.0051725795,0.042496547,-0.011558643,-0.008514133,0.00060621114,-0.036021125,0.0330808,-0.005297214,0.018441781,-0.0100969365,-0.045811243,0.008706549,-0.00819574,0.0513163,0.018390497,-0.022468057,0.0016266776,0.019093862,0.007634186,-0.041291047,-0.003152136,-0.028768675,0.00845644,-0.014328194,0.019487318,0.0021696654,0.033027306,0.024534957,0.015157336,0.017191151,0.0144483065,0.014219465,-0.0071111484,-0.0141586065,-0.00948536,0.023833916,-0.025914824,-0.04091271,0.042866606,0.00556968,-0.03092534,0.0058640125,-0.014911905,-0.018670298,0.0097271865,0.014318753,-0.012547714,-0.022176279,0.040544104,0.022755522,0.0033890645,-0.022958813,-0.030063711,0.036288973,-0.01019661,-0.0075010885,-0.014096682,-0.030651996,0.011448047,0.033305038,-0.008590324,0.02594071,0.0025931897,0.015991475,-0.016987396,0.011565078,0.029372137,0.00047827046,-0.014054497,0.027426075,0.030518126,0.020706391,0.0005376821,0.004567319,0.0068677613,0.034335658,-0.024266126,-0.003986068,0.0070395777,0.024666036,0.0065969187,0.0042179176,-0.026997656,-0.035035543,0.0012792305,0.014821067,0.008982801,-0.0006036028,-0.0028292171,-0.022703225,-0.011532701,0.00026211402,-0.025736343,0.003276934,0.023379512,0.017712103,0.0062430967,0.014684691,-0.04357725,-0.010570047,-0.0031324776,0.012966051,0.011817832,0.014358698,0.01890103,0.025451725,-0.02211731,0.01946177,0.008901765,-0.00383797,0.018662058,0.0015903346,-0.013960275,-0.01213389,-0.0033971078,0.01580853,-0.026790693,0.014927829,-0.005393982,-6.843084e-05,-0.022414576,-0.0016713195,-0.011372256,0.014385374,0.010524695,-0.0039859274,0.00086462096,0.023360806,0.010259073,0.016264085,-0.018694676,0.015388831,-0.011926501,0.010480768,-0.014731324,0.004279983,0.008392303,-0.010991202,-0.024016159,-0.0019082995,-0.0126227075,0.0031604813,-0.0015937974,0.020027174,-0.010486026,0.006464034,-0.021649083,0.0033839708,0.0022214716,0.023612736,0.0035797649,0.026306883,0.019745018,0.020441651,-0.008571613,-0.0116837155,-0.0039727665,0.00048307248,-0.029610893,0.013079258,0.034400217,0.006450981,-0.020724546,-0.01957403,0.020820318,-0.020950017,0.008431401,-0.0006641908,-0.022142703,-0.014495506,-0.018390892,-0.013201243,-0.027745716,0.012834442,-0.046286613,0.04004122,-0.032348335,-0.023797905,-0.0007449679,0.017420925,0.042602703,0.035030458,-0.0154398745,-0.0065045995,0.015665889,0.0019339423,0.037659198,-0.018648697,0.010892814,0.028479116,0.003417597,0.017694367,0.0068749064,0.015226386,0.017803827,-0.0064241383,-0.022722207,0.0016964567,-0.02102853,0.014369401,-0.0041979505,0.0018891315,7.186794e-05,-0.0072517996,-0.020683395,0.010998897,-0.019626204,0.013201845,-0.040730536,-0.020250376,0.004642581,-0.0103146145,-0.0256002,-0.006796858,-0.0060787695,-0.016856117,0.0008285867,0.0011672549,-0.008602288,0.02218076,-0.009737234,-0.027093932,-0.010304815,-0.0022056948,0.005096873,0.008619227,0.021021128,-0.03977233,-0.010617587,0.022999234,0.0021939143,-0.009746202,-0.0085295895,-0.00783401,0.019179877,0.0074376226,-0.005267164,-0.011313128,-0.010596392,0.005816859,0.029238693,-0.0029951162,0.0040249163,-0.008468137,-0.016920744,0.021224573,-0.0005870203,0.005013932,0.0019860335,-0.010256987,0.0030646296,0.022778308,0.018109825,0.015038961,0.019352745,0.00635626,-0.0011161129,0.03130801,-0.0068236976,0.04700945,0.037568465,0.0013018409,0.020647096,0.01683907,0.022832375,-0.017230723,0.0013668743,-0.013129951,0.018753154,-0.0025441716,0.013683496,-0.010912582,0.008395038,0.008214586,0.0057662157,0.031047858,-0.004920332,-0.043174356,-0.027216868,0.004030229,-0.0076014292,0.024498736,-0.033673923,0.0115755135,-0.015993696,-0.025393464,-0.0058888844,0.006917787,0.009111857,-0.008960983,0.00044986786,0.03274781,0.005210041,0.014228768,0.028397873,-0.030094454,-0.01341858,0.019041618,0.04523974,-0.013791117,0.010946685,0.01806382,-0.0070980764,-0.02417716,-0.013590065,0.009209467,-0.005400239,-0.020751812,0.0023758665,-0.013372129,-0.015604693,0.011394232,0.0013800954,0.012461932,0.018101754,0.008760921,-0.0042646173,0.03666262,0.02540932,-0.029955797,0.017656205,-0.0034253474,-0.022156158,-0.0070198094,0.0078009255,-0.0012199045,0.0054630614,-0.034380533,-0.017389955,0.018994454,-0.0001008845,0.009219418,-0.027867155,-0.032741137,-0.014119876,-0.022455176,0.012512374,0.006595917,-0.04296119,-0.050757874,-0.0155314,-0.010956548,0.0015030652,-0.033210322,0.008967898,-0.007718667,0.017528784,-0.0025787835,-0.000539465,-0.014998078,0.009344781,0.01711046,-0.012901215,-0.016934201,0.0061878976,-0.0459394,0.015361992,-0.026086373,-0.0006060967,0.008270673,0.006212423,0.00076072523,0.013825345,-0.027828353,-0.0007766406,0.020308748,-0.0023140963,-0.003346583,-0.037424084,0.026924629,0.034103014,0.0049853926,-0.00015483904,0.043562,0.0152481655,0.0033557198,-0.004592253,0.008483575,0.01656002,0.022528961,0.012520476,-0.03924854,-0.008502571,-0.0042788945,-0.007468368,-0.017271953,4.562732e-06,-0.02474664,0.018196259,-0.004413022,-0.047357038,-0.018143151,0.017206281,0.0072556953,-0.0007242941,-0.034504257,0.012723391,0.014834794,-0.008702445,0.018504286,0.007423583,-0.009840906,0.051857736,0.0053053205,-0.0052776965,-0.0027669605,-0.0075197644,0.016891327,-0.025961999,0.00054836634,0.02901817,-0.011006681,-0.036988392,0.0014885121,0.0069968505,-0.029212331,-0.00399068,0.0035960441,0.0026317388,0.043250673,0.015953083,-0.0058463537,0.0017055508,0.005566087,0.030121649,0.013806975,-0.017159576,0.0054699373,-0.025688428,0.005548817,-0.016082607,-0.0125870155,0.041085966,0.024945209,-0.007537514,0.011519115,-0.025817333,0.019080002,-0.0074494258,-0.011365393,0.010490052,-0.013964136,-0.000579173,-0.018283758,0.018970083,-0.0071591455,-0.018960968,0.00085553434,-0.008552036,-0.011719861,-0.035782818,0.011300259,-0.01510328,0.0071917004,-0.0023047943,-0.003142593,-0.0059099267,-0.009928202,-0.008520383,0.006380142,-0.0064698905,-0.02516543,0.022097208,-0.012107039,-0.043062437,-0.016317865,-0.010308886,0.03028826,0.0117058735,0.030617677,0.0139256725,0.03971485,0.009972457,0.0070283245,0.0073656025,-0.012849828,0.0036807812,0.014491011,-0.0077222288,-0.012906204,-0.0063389908,-0.035368964,0.010084476,-0.010818232,-0.02657078,-0.0083569,-0.049154185,0.00037826566,0.004737177,0.0047277478,0.012721219,0.004808643,-0.010931057,-0.04331809,-0.004824575,0.017659543,-0.002672089,-0.010424303,0.017222242,0.032162633,0.0030882068,-0.012751543,0.0016709757,-0.014809739,-0.027995232,-0.026584864,-0.020131484,0.0010347009,0.007694443,0.0076558082,-0.0055968855,-0.017477706,0.057366814,-0.00975219,0.0011357288,0.011086218,0.01006077,-0.0089353,0.021529201,0.012355373,0.00020194125,0.018088924,0.04114285,0.034362286,0.021546852,-0.014749182,0.007928069,0.03400008,-0.022225995,0.0052764243,-0.007902665,-0.04941228,-0.0068803164,0.021929748,-0.0053089946,0.008380805,0.003356756,-0.014046791,-0.0039409907,-0.002780395,-0.0036840234,-0.011898775,-0.036087736,-0.019231692,0.007509921,-0.0180364,-0.0016387546,-0.019406248,0.019242698,0.00085987645,-0.0044071446,-0.02470683,0.02032294,-0.0038502836,-0.0017744032,0.027537124,-0.003265404,0.0042664837,-0.025909312,0.022520145,-0.017723441,-0.021851595,0.002540087,-0.0010439309,0.025057048,0.025080727,-0.011983867,0.0071478128,0.03446249,0.0028889673,0.0097562885,0.00947043,0.039846744,0.0028316882,-0.036398068,0.023053436,-0.0030235925,0.01954435,-0.021419767,0.005013532,-0.015813548,-0.02884758,0.01430352,-0.01303839,0.008579618,0.014772547,-0.01556187,0.015984071,-0.016471665,0.018805332,0.0023086136,0.046190053,0.013210048,-0.02208867,-0.0052109957,0.006827586,0.019418227,-0.009798211,-0.0027080502,-0.01389817,0.014570578,0.019381432,0.009165624,0.028748587,0.02528033,0.013794462,0.010065476,-0.0436544,0.02551462,-0.017196901,0.020649374,0.004741603,-0.005053888,0.020211097,-0.009123531,0.004335844,-0.008812196,-0.037863832,-0.0032632567,-0.026328817,-0.013376649,0.0031002846,-0.014719296,-0.0059357504,-0.006283941,-0.017062357,-0.042332888,-0.021512838,0.00065372785,-0.022653015,0.020225799,0.005877077,-0.04022235,-0.005191143,0.049942642,0.010488792,-0.014868575,-0.0077316845,-1.677351e-05,0.034386147,0.01766874,-0.00817171,0.035206795,-0.010704152,-0.014350226,-0.024386715,0.0036328535,0.03555802,-0.008525119,0.0024804096,-0.010881028,0.010310121,-0.018167412,-0.005623549,0.004575369,-0.011359335,0.036961704,0.007196039,-0.009619269,0.0113541605,-0.003183555,-0.011750895,-0.04058726,-0.014320041,0.009790556,0.01826735,0.012713633,0.027965577,-0.009985758,0.003018856,0.0023668997,-0.015772574,0.021498406,0.0018059777,-0.03024711,0.006095481,0.0014347129,-0.019486602,0.005490962,0.01076585,0.0075584333,0.012305902,-0.009798465,0.004821588,-0.038465884,0.0079584615,-0.0008864816,0.012524273,-0.00044528572,-0.0014953107,-0.02492302,0.023750542,-0.014766248,0.02852566,0.022853436,-0.033305287,-0.03521367,-0.030084385,0.010373139,0.0043483074,0.01479091,-0.009676475,-0.0050437544,-0.0034883562,-0.013916001,0.0035314038,-0.021816378,-0.00030350246,-0.008507112,0.00979186,-0.017133575,0.01843133,0.0184013,-0.02372231,0.019523472,0.014881922,0.026997022,0.014218316,-0.029706197,-0.008039087,-0.013129226,-0.013931264,-0.016567929,-0.008410809,-0.008973282,-0.011812913,-0.025085011,-0.014238639,-0.007637362,-0.022464916,-0.012470468,-0.032410514,0.048946243,0.00042299266,0.019056497,0.0005916444,-0.0029245662,0.016191347,0.030199852,0.019771768,-0.0069808066,0.0071349842,-0.0017361039,0.008630123,-0.037587,0.011187836,-0.011053505,0.0045172623,-0.006353217,0.030821983,-0.0034645717,0.014738199,0.025901038,-0.0018501787,0.03524272,0.01678172,-0.0069495756,0.037873965,0.016386013,-0.015924763,-0.017457424,-0.056479536,0.0044809086,0.0039980556,-0.017377848,0.022105927,0.024490498,-0.0047039697,-0.014839196,0.021224963,0.015030887,0.0041610966,0.020198088,-0.010816117,0.019667862,-0.010321008,-0.0048440993,0.008093135,-0.0013733176,0.0071516884,-0.021482829,-0.029845014,0.00025119042,0.0064369556,0.002711981,-0.017357435,0.013866113,0.022894694,-0.009207468,-0.011711698,0.012109621,0.0061134095,0.059697255,0.0064271446,0.007048464,-0.008848916,-0.011908008,0.0053710165,0.015768943,0.018384604,-0.0089393165,-0.025338758,-0.0011313042,-0.001896805,-0.011227889,-0.04190293,0.0050248234,-0.016507111,0.016252149,-0.033039417,0.018581357,-0.0025520218,-0.008946651,0.0057834806,0.002972895,0.019697748,0.0044831284,0.019789038,0.007871866,0.018033179,0.013668981,-0.0058520087,-0.012363151,0.005849008,-0.0053329426,0.016969483,0.00096803944,0.013070262,-0.005940744,-8.826838e-05,0.014659362,-0.007099296,0.009734227,-0.012268851,0.014939795,0.0125218965,-0.0168552,-0.011992082,-0.015801465,0.013753378,0.015830474,0.024044603,-0.00746664,-0.014818941,-0.046341557,0.02058303,0.0002885538,0.0025158855,-0.025297662,0.0014245972,-0.031152226,-0.009950442,0.009190438,-0.011182723,-0.00818279,0.023520647,0.001003651,0.021823443,0.0036674747,-0.011674379,-0.035151698,-0.015762871,0.0134479785,0.01223582,0.00780602,0.008947943,0.043826085,-0.005166579,0.010874173,-0.0036619736,-0.020387724,-0.020412428,-0.011333269,-0.0017023219,0.008246247,0.041480355,0.022005128,-0.010905069,0.005074921,-0.008142062,0.007727991,-0.03077782,0.003224937,-0.021051185,-0.005983066,-0.0010768797,0.03834468,0.016859176,-0.012615176,0.0045216274,-0.02301035,0.023454737,0.009412375,-0.0007146381,0.007742554,-0.0043850215,-0.00532946,0.0062356046,-0.0057423357,-0.03576206,0.0066009816,0.02222122,-0.009313488,0.015779763,0.0016681772,-0.00058785034,0.03080069,0.014736451,-0.0076692873,0.0068319,-0.043822456,-0.030787699,-0.0028950004,0.008128185,0.010651213,0.017304067,0.0058796466,-0.01371728,-0.030553346,-0.009568781,6.742399e-05,-0.021062413,-0.012616496,0.0014932466,-0.010136996,0.008461636,-0.0006102037,0.00988645,-0.030284207,0.0063029747,0.023383154,0.023437904,-0.036234844,-0.01850133,0.013933661,-0.008333041] +5 数据库迁移需要保持索引 [-0.064426616,-0.001207138,-0.0713309,0.049292944,-0.009048609,-0.015453497,-0.0023770062,-0.037300456,0.0051348847,-0.028086284,0.0057374598,0.008059732,-0.011375116,0.02702987,0.0032636323,-0.00105386,0.0050121625,-0.02433932,0.013531977,0.012355277,-0.003130261,-0.04026512,0.06717116,0.01608247,-0.013189183,0.0018786723,-0.01281024,-0.0120001845,0.025227081,-0.008524924,0.015429054,0.057220962,0.003405117,0.011054058,-0.012134321,-0.03284421,-0.0088922065,-0.023652783,-0.005643064,0.040094938,-0.015518143,-0.0013294867,-0.013880124,-0.018515814,0.01564722,-0.02415111,0.018111063,-0.024424966,-0.008013256,0.040967762,-0.03581496,-0.035579734,0.023067923,-0.044838186,0.058313195,0.03225447,-0.026758257,-0.013556155,-0.011299004,-0.026543887,-0.020896439,-0.022602344,-0.014973883,0.003672289,-0.0002478248,0.024854572,0.0039662803,-0.0018422818,-0.020800103,-0.052634023,-0.029446958,0.01592074,-0.026575593,-0.013669618,-0.058027297,0.025429936,0.0068130633,-0.02622623,-0.05923753,-0.013713238,0.028048774,-0.0069395658,0.04446725,0.023465853,-0.002072727,0.012252308,-0.021010807,0.02836368,0.02035843,0.0272169,0.01244501,-0.04881478,0.0150948325,-0.021822026,-0.06093074,-0.024733819,0.010453659,0.004341396,0.042774808,0.022578783,0.048830602,0.007351894,0.006907199,-0.005152823,0.025972001,0.0017945934,0.042968165,0.01922017,-0.0005691123,-0.031199854,-0.008808326,0.0015242964,0.02672093,0.006413862,-0.023461433,-0.017087186,-0.038385563,-0.020984104,0.04847405,0.009852036,0.062907286,0.013367648,0.07399699,-0.029766515,0.04372282,0.0008623802,0.0086928075,0.03375998,-0.012688413,0.029110478,0.009152635,0.01068571,-0.005170518,-0.05311701,-0.06071397,-0.0065291435,0.063891515,0.03255982,0.02916442,-0.06760291,-0.0156231085,0.02461083,0.015371051,-0.05361907,0.021446213,0.013074934,0.05408617,0.054006208,-0.0060011214,-0.018576663,-0.0051681204,-2.2915168e-05,0.028704861,0.012333694,0.041595504,-0.032132745,-0.042407658,0.010730643,0.02267153,0.015169963,-0.0261152,0.067466214,-0.059616394,0.02098231,0.055612385,-0.013753478,0.011016809,0.0056326366,0.03807351,-0.038029626,-0.018534651,-0.019019919,0.030848473,0.012487254,0.02858175,0.030918332,0.08302672,0.027457964,-0.002043242,-0.05181608,-0.031816624,0.0243964,-0.004457115,-0.025473485,-0.03341642,0.012294413,-0.00019219659,-0.0378566,-0.01719585,-0.007888617,0.014735261,-0.012036959,-0.02482523,0.001525415,0.004122833,0.041999087,-0.012966451,0.03063495,0.0047166157,0.009182805,-0.042648576,0.026648503,0.043035083,0.0015875491,-0.0372804,0.00046554478,-0.01998124,-0.061923344,0.042318333,0.00014379332,0.005498323,-0.01743722,-0.012680427,0.03728894,-0.019161347,-0.046201523,-0.015120669,0.004863122,-0.032895718,-0.07081763,-0.0020776347,-0.035508454,0.011201885,-0.06476493,7.079143e-05,0.0217696,0.0318883,0.016750604,0.03484872,-0.0049801925,-0.021349603,0.0062082894,0.007504409,-0.0013951714,0.03963115,-0.017492231,-0.016108623,0.0061330735,0.0032574863,-0.01166122,-0.048381057,-0.04276962,0.009875375,-0.013956389,0.014824587,-0.012838535,-0.03014706,-0.014141071,-0.005784843,0.0096345935,0.016859805,0.0038252785,0.00078591594,-0.033676244,0.04119641,0.019357681,0.014645064,0.008149157,0.012824883,-0.002162193,0.036467813,0.013633159,0.040389825,0.040333588,-0.006523264,-0.00594748,-0.060163792,-0.015408161,0.019226212,0.013336342,-0.015648624,0.025981212,0.038935144,0.025070485,0.049603004,-0.0061085103,-0.0040282095,-0.035734434,0.025703177,0.02251567,-0.026224561,0.023720404,0.002311322,-0.011221136,-0.0053054607,-0.046189975,0.032067005,0.0032404573,-0.015922982,-0.04906685,-0.019491568,0.021462882,0.064740956,0.053752672,-0.0061933445,0.010253724,-0.016935742,-0.14706251,0.038690854,-0.004970487,0.014818644,-0.0049287234,-0.01495864,-0.043172326,-0.016041338,-0.08026426,0.006776685,0.013578216,-0.060426485,-0.025415663,-0.003593898,0.017653761,-0.009113023,0.0039400663,-0.012268166,0.021811742,-0.045244504,-0.0005774012,-0.039873667,0.03723094,0.03088625,0.011338845,-0.01035331,0.021508258,0.014268456,-0.05698953,-0.018692229,0.026021445,0.03782687,-0.00034429564,0.009050123,-0.004241634,0.009997246,0.019998694,-0.031086165,0.018109038,-0.007129154,0.03640035,0.0062186476,-0.013733614,0.030109277,0.01894292,-0.02646245,-0.0361627,-0.015864246,-0.030663641,0.037785523,0.023609208,-0.0069929366,0.011252445,0.0020658285,-0.040591888,-0.0028809768,-0.0023431713,0.048797946,-0.04251109,0.031161882,0.03963156,0.009362748,0.03871715,-0.015454548,-0.025215244,0.0065374454,0.025083156,-0.011081751,0.039306678,-0.02937181,0.018590022,0.0041908505,-0.005215575,0.01081526,-0.078601316,-0.0039346,0.0142075075,-0.033988684,-0.013933648,-0.13937667,-0.008428729,-0.03433534,0.015047238,0.015848862,-0.027218977,0.006333344,0.011637219,0.04038656,-0.006575619,0.22943908,0.016821362,-0.034904394,0.05028508,0.07685586,0.011096684,0.024248788,-0.006477823,-0.0043010493,-0.026914176,-0.005560181,0.04843288,-0.0009882448,-0.0017240847,-0.02206474,0.034948416,-0.049953967,0.013242352,0.05911099,0.0026827066,0.018359955,0.024929654,0.009501271,0.06616266,-0.0054554823,-0.05721844,-0.01690973,0.0146252895,-0.068252034,0.001158502,-0.013982473,-0.018747477,0.029889919,-0.0076430948,-0.08981503,0.017646361,0.028668806,-0.014064487,-0.007880641,-0.008108172,-0.017394366,-0.023158804,-0.020144787,-0.01870825,-0.018911676,-0.030721493,-0.017788744,-0.02430952,-0.015629062,-0.022573683,-0.022395516,-0.04774374,-0.05084935,-0.012050188,0.058636963,0.00751847,-0.039070133,0.02228351,-0.02940404,0.042964183,-0.0147400815,-0.00016679712,-0.014741295,0.027271,-0.0059485924,0.014980603,0.0086801145,0.057327047,0.056508794,0.046604313,0.029985877,-0.01670053,-0.00037324184,-0.025207411,0.014906425,0.014341405,-0.021781037,0.0955532,0.011167657,0.0038792228,-0.013750652,-0.002516282,-0.035906497,0.0032975732,0.01310703,-0.0006764472,-0.021433251,0.06848093,-0.010199567,0.00037150324,-0.006169471,-0.028596722,-0.0312961,0.032505073,-0.029109363,0.021861233,0.009172135,-0.0064311326,-0.02831344,0.015749337,-0.006537876,-0.015500083,-0.025540883,-0.0826838,0.012244671,-0.027228514,0.009292745,0.004977117,0.014060177,0.025514938,-0.02253163,0.041195255,-0.06600774,-0.017049043,0.017375663,-0.028132206,0.03544679,0.043460634,0.013801113,0.030377535,-0.035088267,-0.016995812,0.0035354483,-0.033263214,-0.02662336,-0.012365621,0.0007772778,-0.04992548,-0.022758018,0.051314805,0.0051531894,-0.015030115,-0.039168365,-0.007915368,-0.01355168,-0.0059510884,0.013762192,0.04649824,-0.014273013,0.029487368,-0.012538009,0.00437861,-0.005070766,0.012299895,-0.016042799,-0.06578017,0.02039954,0.023373744,0.02013309,0.006228181,0.016873302,0.048374645,-0.0069646183,-0.020212509,-0.0126279285,0.002035063,-0.030332606,0.052526377,0.065632254,-0.013115928,-0.0062125525,-0.00042949244,0.05303482,0.05181912,0.039937656,0.027746838,-0.031085232,0.043048814,0.008770819,0.0042531434,0.017991459,-0.006652797,-0.030927656,0.004903227,0.07795736,-0.013313729,-0.070612706,-0.010249342,-0.003547553,0.040450256,0.034082506,0.014447191,-0.033480056,-0.008979672,0.0021579766,0.054265898,-0.0026085225,-0.013360705,0.036704555,-0.008891171,0.015642278,0.10096839,-0.035680417,-0.01809261,0.013649192,0.035988513,0.008179155,0.042292632,-0.043441292,-0.07384337,0.008612148,-0.04474326,0.031336427,-0.018741535,0.03932266,0.018502206,0.010817545,0.014049003,0.0050711147,-0.0029539836,-0.04842969,-0.00770084,-0.077612385,-0.0088444315,0.06406704,-0.032275826,0.070797265,-0.007214315,-6.412233e-05,-0.0041863704,0.036269434,0.020870414,0.012786678,-0.04977176,-0.030133894,-0.034151834,0.0045748902,-0.015819771,0.018203579,0.024371397,-0.00042414007,0.0044649094,-0.00332539,-0.027518727,0.040474057,0.0035328506,0.012732345,0.026342273,0.032517165,-0.014288096,0.010337403,-0.02660936,0.032813303,0.02312578,0.00018446159,-0.0026555085,-0.02356192,-0.030993918,-0.027981162,0.0014449084,-0.03302337,0.024092333,-0.015658598,0.0027880003,-0.023530332,0.021419749,0.01857213,-0.020562496,-0.009466435,-0.029821241,-0.039168976,0.023304358,-0.048504937,0.014700223,-0.028338244,-0.037586078,0.03358855,-0.0279392,0.0073409495,-0.026953047,0.038384415,0.010223158,-0.050920106,-0.06842851,-0.016821554,-0.029728489,-0.0012844342,-0.020017492,-0.040554885,0.006593376,-0.04520678,0.028169194,0.011880207,0.014806201,-0.039007723,0.040918108,0.052630626,-0.0030764109,-0.015556017,-0.032231737,0.012151345,-0.00041408476,-0.018879997,-0.0030000254,-0.031694546,0.005147434,0.032328937,0.02479109,-0.010435313,0.009488145,-0.015586773,0.017638724,-0.045104194,-0.009290293,-0.003468461,0.013471612,-0.020721925,-0.02962081,0.008688582,0.021612186,0.02795207,0.013970303,0.010201323,0.03036364,0.01765097,-0.0011721164,0.044234686,0.011558379,-0.039114773,-0.014168916,-0.0072391033,0.02334064,-0.030887755,0.0098788915,-0.022764271,-0.012606862,-0.046065886,0.031976804,-0.034143463,0.028968139,-0.029833347,-0.017394949,0.06428782,0.008525074,0.028356988,0.0035051634,0.023179194,-0.067556,0.021765582,-0.009542262,-0.0019717515,-0.043431237,0.036007922,-0.04162152,0.018346986,-0.06543169,0.027071966,-0.047737595,-0.025960384,0.022555072,-0.007830858,0.015118655,-0.0012776118,-0.023757072,-0.005730299,-0.042929787,-0.018804874,-0.035038747,0.03792305,0.014510945,-0.015980365,0.032265086,-0.009907654,-0.029634478,0.019227017,-0.02060878,-0.008411694,-0.021343324,-0.025914812,-0.0024942064,0.009752468,0.0069113965,0.01534082,-0.006885086,-0.008386026,0.002790661,0.0036004742,0.024509665,0.0385351,-0.020962616,-0.02865497,-0.03386882,-0.027513219,-0.01285634,0.025415016,0.04022907,-0.06469488,-0.04424166,-0.005234042,0.048294503,0.0059926133,-0.02747564,-0.014602611,0.042190462,-0.16294987,0.024854725,-0.018719994,-0.008842056,0.013161904,-0.013166136,-0.01935866,-0.0072145304,0.002033257,-0.019677073,-0.058025368,-0.032914195,-0.008195546,-0.006743466,0.0033013797,0.0020380777,0.028207535,-0.028719958,-0.022116132,0.028995076,-0.020568967,-0.016001355,0.030647535,0.016021708,-0.0011998776,-0.010576395,-0.00454394,0.03161194,-0.027785832,-0.05481918,0.024250764,-0.03515779,0.042582784,0.03126992,0.01729981,0.012897777,0.014821021,-0.030843174,-0.008524084,-0.019230127,0.0034712262,0.029585293,-0.014623742,0.024655486,0.034185257,0.0070468755,-0.02627089,-0.019049084,-0.02088672,0.0069206893,-0.011368967,0.03063138,0.01737597,0.00018906596,-0.005601176,-0.00484746,-0.004612093,0.005258078,0.02437791,0.004188249,0.025717156,0.027872913,-0.061393324,-0.07527063,-0.039480697,-0.020435393,-0.033941917,0.010320568,-0.012530514,0.01511323,0.0041491524,-0.017869396,-0.0045711347,-0.006521255,0.0064824168,0.007305704,0.017935304,-0.042280138,-0.0238236,-0.0037348624,-0.016087504,-0.019480709,-0.041221168,-0.02157038,0.048273593,-0.012985903,-0.029066289,-0.009372082,0.06376747,-0.0038622657,-0.031445008,-0.018746518,-0.01865484,0.0118917115,-0.030794172,0.044080842,-0.043477923,-0.017264914,0.008232656,-0.02344888,-0.013270956,-0.03775264,-0.017742172,0.035251096,-0.059730556,0.00732816,0.022063974,-0.014350424,0.012441672,0.003946303,0.077573925,-0.008338275,-0.0031228582,-0.011237952,0.018647542,0.0103028035,0.00039133022,0.0034580678,0.00595909,0.006554111,-0.013657225,0.035869993,-0.030902026,-0.031647816,-0.0091730505,0.031095713,0.044570483,-0.016932335,-0.012695248,-0.020577485,0.023512885,-0.015363156,-0.02952602,0.049802653,0.034865215,-0.014677028,0.02210346,0.06537863,-0.029297294,-0.007183274,-0.011772337,-0.033363644,0.003546601,-0.02025585,0.0072700563,-0.025558818,0.032804824,0.021032883,-0.025889477,-0.053816315,0.035975825,0.012725741,-0.010741485,0.032508433,0.010172312,0.032983635,-0.009096561,0.02114118,-0.0145888105,-0.0041565234,0.042856235,-0.076612085,0.008788198,-0.028592493,0.016019085,0.03961575,0.010100498,0.0151845915,-0.05676776,0.006936942,-0.020004803,0.028794011,-0.009508972,0.0018358631,0.019737259,-0.117287315,0.0064888014,0.009464817,0.052566465,-0.008676634,0.009764104,-0.005920326,0.0409161,0.0021767889,0.016799068,0.03262261,0.015355443,0.045053035,-0.038959976,0.04319054,0.009646504,-0.029409235,-0.015719734,0.024837526,-0.0065491293,0.00310401,0.0074385135,0.042477682,-0.01374058,0.01548232,-0.007805225,0.007533184,-0.05402695,0.019021401,0.01070089,0.008073747,0.024453985,0.036566734,-0.03152586,0.05843392,-0.034770153,-0.0012305145,-0.027704578,0.0072089573,0.003522131,-0.047780357,-0.012765434,-0.00093152054,-0.01649813,-0.03535762,-0.014364627,-0.007504374,0.0081095835,0.04312018,-0.048891217,-0.028143385,-0.002624379,-0.004890693,-0.034227777,-0.023397155,0.034231607,-0.022251043,-0.044124294,0.05732759,0.02177144,-0.038950935,0.019694282,0.036996413,0.023879804,0.027116632,-0.0135136815,0.050028417,0.01976392,0.038296275] [-0.00043095608,-0.023050845,-0.009936141,-0.006288679,-0.0019149109,0.07082469,0.009944633,0.014747406,0.020319393,-0.026571034,0.0021662698,0.010558003,0.0021708217,0.032868568,-0.010196294,-0.010784109,-0.006983718,0.02049253,0.001411599,-0.0027514733,-0.014560044,0.024784593,0.008837399,0.058439996,-0.05242426,0.020558754,-0.024424803,-0.034789164,0.0017780227,-0.00065751915,-0.03201451,-0.041433103,-0.017097145,6.138022e-06,0.0050549884,-0.026971819,0.0006029351,-0.005029851,-0.043037195,-0.00805603,0.0073519815,-0.05465613,-0.00049897586,0.0072477516,0.015137797,-0.0028424799,0.0023666124,-0.040766228,-0.0029322621,-0.025113693,0.0063170735,0.014667402,-0.021814173,-0.012303358,0.037818495,-0.016314374,0.03294678,0.0036490576,-0.011365915,-0.010753073,0.008243739,-0.015118009,-0.00878307,-0.011436538,-0.0071654893,-0.00620772,-0.014249595,-0.03576455,0.00329691,0.010597699,-0.061595798,-0.00077259366,-0.016825762,0.021631148,-0.0069909804,-0.032066308,-0.00051109825,0.008582744,-0.00095525663,0.01650401,-0.01775534,0.0035499332,-0.011564649,0.048342694,0.011760372,-0.0052391966,0.012029828,-0.010626891,-0.03871893,0.007319357,0.01114596,-0.053665802,0.006786056,0.06420225,-0.0002692553,0.019294543,-0.0012554559,-0.025321947,-0.031841587,0.008216951,0.0075052097,0.009288805,-0.0066719744,0.004913788,0.032367393,-0.021883795,-0.032073643,0.019535568,0.018230725,0.012613321,-0.013264504,0.019128319,0.06913149,0.025892463,-0.014160461,0.017093262,0.0040238257,0.03786389,0.023197781,0.024587946,-0.004567745,0.027899386,0.0024535176,-0.003042346,0.011186646,-0.0075488207,0.0023849455,0.027326724,0.043172117,0.026304223,0.016053883,-0.024855703,-0.02016832,-0.009986575,-0.022154022,0.023890274,0.0042911023,0.0032598707,-0.0050689736,0.00019889575,-0.0040427987,-0.00093210017,0.00328499,-0.015229164,-0.0038426018,0.006574079,-0.008507885,0.018693596,0.0050937594,-0.0075477147,-0.00048816763,0.006370077,0.011395776,0.017204586,0.008056572,0.012403685,-0.029699847,0.021902392,-0.0070868796,0.002422774,0.0049107373,0.0067501906,0.0030142304,0.030763732,-0.0033117638,0.0040287515,-0.050555985,0.013427234,-0.014107972,-0.024630044,0.012041772,-0.030287907,-0.0065179854,-0.002262319,-0.021712475,-0.015013017,-0.015424955,0.009789491,-0.0033480425,0.040815324,-0.0035650562,-0.00079893164,0.0113106705,-0.0007755712,0.009378116,0.011685413,0.00281264,0.0038831283,-0.00026609979,0.0121824555,0.0069087157,-0.02787578,-0.0059899963,0.006707776,-0.005894177,-0.015741069,-0.0028580045,0.016118197,-0.034195945,0.01735972,0.019765317,0.00926472,0.04389642,-0.006249726,-0.0032211149,-0.016444327,0.013718479,-0.011662552,0.013493483,-0.014163566,-0.0036939376,-0.046494797,-0.007567956,-0.02133919,-0.00044497525,0.02738094,-0.008698718,-0.019005181,0.020457009,0.016773913,-0.010904911,-0.008954987,-0.0069196313,0.005572536,-0.048027933,-0.007537406,-0.022415146,0.006512466,-0.01608598,-0.01555015,-0.00508324,0.017815897,0.0025731204,-0.010210734,-0.008897946,0.008929629,0.0080419425,0.0062272567,0.013214737,-0.024051633,0.018107556,-0.007899003,0.02001041,0.014942411,-0.008070531,-0.03070556,0.01186432,-0.022385277,-0.016477233,-0.011731802,-0.013239436,-0.0039640185,-0.010208347,-0.028153168,-0.022409344,-0.008704977,-0.0070692166,-0.0014837782,-0.014285748,-0.003135587,0.0036982973,-0.0025079993,0.0037620019,-0.0011478444,-0.007394702,-0.005461537,-0.053446062,-0.01333591,0.007381176,-0.008059328,0.05158707,-0.013467811,0.0031393068,0.009400123,-0.012209139,-0.035406284,-0.00505768,-0.009421201,0.015434122,0.03166153,-0.013846046,-0.008951534,-0.012111091,-0.0023698953,0.022915388,0.0051135775,-0.01716662,-0.011719331,-0.0007238064,0.026400067,0.0148779685,-0.0143104885,0.0012580231,-0.018725554,-0.032988794,-0.020488763,0.026844677,-0.01356372,-0.022750976,-0.021750847,-0.011707861,-0.022013538,0.013508886,-0.011152846,0.0023092695,0.0028101553,-0.0003150501,0.0018245615,-0.023926716,0.056358874,-0.01994918,-0.017195616,0.011151826,-0.0038387273,-0.00036254356,-0.03916433,0.003699633,0.007385164,-0.012539238,-0.008501961,0.0012106874,-0.021869991,-0.015840186,-0.0053653293,-0.0077494,0.0027057321,0.0197138,0.0033138208,0.010127666,0.02841417,0.010493713,0.027066834,-0.04602722,-0.0059867958,-0.010275202,-0.013237908,0.00053938763,0.025173727,0.032997288,0.01034743,-0.005177418,0.00837006,0.0018898944,-0.009805132,-0.023608124,-0.009814284,0.0038240638,0.010017439,-0.022344729,0.032082636,0.02112472,-0.0014281793,0.014773443,-0.015189451,0.0067194332,0.02340918,-0.0036697108,0.0023499697,0.010721624,0.0019136779,0.0010658542,-0.010612409,-0.00559062,0.003516111,-0.03129688,-0.022970317,-0.02340517,0.009772521,-0.019825334,-0.034742065,0.0006392047,-0.008361943,0.037779156,-0.03303283,-0.039842933,0.006937951,0.010684221,-0.014206904,-0.005637976,-0.00929959,0.02621954,0.04910407,-0.03109615,0.01884262,-0.020709915,-0.011406802,-0.013131011,0.013403383,0.032098107,0.021999333,-0.011475489,-0.017459283,0.0064790733,-0.011385556,-0.009712267,0.030598214,0.008312058,0.0103639765,-0.009683915,-0.007859672,0.012717635,0.007178539,-0.015166458,-0.016283838,0.009577194,-0.025969496,-0.0084231235,0.012717874,0.025145229,-0.028662955,0.025329998,-0.01607756,-0.014221608,-0.052685473,-0.039024413,0.006539906,0.0048353015,-0.041298557,-0.019899292,-0.016002787,-0.009162914,0.009301209,-0.01575881,0.00994818,0.013793373,0.0029341255,-0.01991429,-0.0057961536,0.018297905,-0.027291393,-0.020307623,0.034721777,0.0047849272,0.0119807795,0.009576824,0.012944846,0.010859232,-0.01773908,-0.0058777686,-0.027017105,-2.807816e-05,-0.01485901,0.0057727224,0.03285194,-0.012651225,0.0145621,3.987298e-05,-0.025614766,0.024296409,-0.028433047,-0.031883974,-0.00017073697,0.027994994,-0.016405001,-0.02591799,0.022366319,-0.010596733,-0.006306788,-0.023512615,0.041799754,-0.083979145,0.006134221,0.020914393,-0.009545505,-0.04201682,0.025884658,0.009086694,-0.013807963,0.01487815,0.0108503215,-0.010889182,-0.0097774565,0.012539848,-0.0059411144,-0.031167116,0.008094574,0.0253939,-0.016813911,0.006691479,-0.0148568675,0.02647326,-0.015193383,0.020262722,-0.010057458,-0.0029051376,0.03782054,0.015062383,0.030080391,0.0014412226,0.045120087,-0.00395968,0.009785385,-0.006202277,-0.03774302,0.013890062,0.0120051205,0.019623127,-0.030902598,0.001332463,0.00086577557,-0.0033183442,0.034956805,0.015150577,0.025692806,-0.021068705,0.011326431,-0.010871831,-0.0106467055,-0.014857152,0.009656031,0.037980374,-0.0041893264,0.0017813058,-0.026813699,0.019252632,-0.00079768756,-0.027475448,-0.014580783,-0.0025167027,0.044231057,-0.06172189,-0.037819292,0.04296922,0.0006261732,0.009931281,0.0025536877,0.022671588,-0.030280374,0.028012332,-0.018213663,-0.00026624312,0.016892664,0.022087323,-0.008379573,0.025577398,-0.019518528,-0.013896143,0.018324794,0.0011255644,0.008910384,-0.000682981,0.0058170026,-0.0039381715,0.0068722563,0.025987905,-0.0112152975,-0.004623657,0.015978705,-0.041284192,0.018021204,0.027306557,0.013046602,0.010912274,-0.02537333,0.01351592,0.0008921393,0.010081338,0.033578746,0.010094751,0.0021976272,-0.0041409717,-0.012720068,-0.023609921,-0.0009757695,0.0098788915,-0.00796319,0.031056037,0.029859973,-0.009116916,0.0022431465,-0.019440591,0.0034011104,0.013861472,0.021269726,0.01802754,-0.05526168,-0.038486205,0.0009842333,0.011132397,0.03507842,0.01656598,0.029449575,-0.01439217,0.032219857,-0.027342504,-0.024406616,-0.0018381467,-0.012278307,0.0034991042,0.009936103,-0.024259802,0.0019247809,-0.0043912632,0.028432637,0.0046125334,0.009477401,0.016449306,-0.009760982,-0.00432004,-0.008952268,0.104854494,-0.0115464935,-0.024294479,0.01130138,0.007102068,0.023339277,-0.0025567883,0.0039088326,0.007446429,0.02973824,-0.039950076,-0.011154644,0.0014161537,0.0061450005,0.02002073,-0.026511705,0.00043627247,-0.007857061,-0.04276367,0.009658404,-0.06721835,-0.02248152,0.0364606,-0.057934005,0.0072720246,-0.020370277,0.020291334,0.017268367,-0.020903422,0.010429469,-0.027661024,-0.00043055302,0.019981926,-0.01718953,0.0084997825,-0.0044144928,-0.022259144,0.004976665,0.017286656,-0.018572431,0.015370907,0.0082418835,0.0494502,-0.013612576,0.021912983,-0.03901118,-0.032344017,-0.025138134,0.014017473,-0.0123756565,0.009170344,0.025337856,-0.01872097,0.0013282537,0.02518719,-0.01657991,0.0040874826,-0.03952638,0.018478572,-0.009547676,-0.01976974,0.005610054,-0.024552379,0.012232375,-0.014228203,-0.008441281,-0.011673204,-0.029276716,0.043878138,-0.024507787,-0.0052281884,-0.016801193,-0.015640263,0.00020619668,0.024830937,-0.012880449,0.019386426,0.009196055,0.020290254,-0.026854988,0.010399622,-0.0217313,0.0071251118,0.020634223,0.016110916,-0.02769998,-0.011488109,0.017284144,0.0014738659,0.015495196,0.008912106,-0.009719347,0.024828754,0.005614504,0.00320499,0.00080193294,0.001335048,0.0002812703,-0.002536339,0.0024805695,-0.006395599,-0.021646686,0.006053842,0.018273052,-0.0006207664,0.006238769,0.033072483,0.015499239,0.003721352,0.055916175,0.016976364,-0.029950606,-0.037985608,-0.041129116,-0.04023747,0.014305651,-0.017223055,0.0048710513,0.041766196,-0.012948029,-0.019034231,0.009610852,-0.009631973,0.019240344,-0.007728856,0.003517886,-0.023022126,-0.01726188,-0.015388321,0.030189686,0.040498935,-0.010225546,-0.028048994,0.03483071,-0.008840598,0.0030556864,0.011971956,0.013040852,-0.0077010733,-0.0018223585,-0.0074270153,0.011856722,0.006747572,-0.0071184156,0.005830082,0.0039680162,-0.030706037,-0.003929455,0.008718116,0.01530217,0.012590067,0.0140257375,0.007614777,-0.0065540443,0.0038159995,0.022049285,0.012485578,0.0032787777,0.016184263,0.017744487,-0.014229701,0.003767697,-0.008327981,-0.047440477,-0.0063517243,-0.027683118,-0.0012384625,-0.025715033,0.0013841821,0.02175113,-0.006765851,0.01728648,0.022858325,-0.017061982,0.028752599,-0.014212421,-0.032907933,0.017446887,0.020761529,0.0061962465,0.019963475,0.026134526,0.012335574,-0.0047095087,-0.0047449665,0.005164249,0.0024327429,-0.04636896,0.0073938123,0.0060803303,-0.008195192,0.029654013,-0.009032166,0.004930741,-0.013101079,0.009399341,0.017280672,0.02876283,-0.008282074,0.0039400687,-0.013825817,-0.01760159,0.014588373,0.0363298,-0.0044345683,-0.013334595,0.005743405,0.033200406,0.012938058,0.0320734,0.009010772,-0.024931049,-0.029651165,0.012785001,-0.009656753,-0.00088510965,-0.0006807722,-0.03897661,0.008815723,0.016620219,0.031462792,0.0060661114,-0.020880712,0.0043323385,0.026250247,0.0010903277,0.02909649,-0.0146260215,-0.023382025,0.041816596,0.014686194,0.02401537,0.015219616,0.040750694,0.031994514,-0.00495054,0.018814642,-0.04851906,0.012305224,0.0023606843,0.00079366256,0.0030104874,0.03883854,-0.02358883,-0.004212408,0.021043863,-0.016649319,-0.004129592,-0.014088102,-0.02452953,0.0031088511,0.05185991,0.004195871,0.0122782225,0.016215751,0.026120504,0.0075855562,-0.01937926,-0.0018004561,0.027821433,0.01047103,-0.02820679,-0.031036299,-0.03330727,0.017189803,0.010884135,0.004911319,-0.0041941386,0.016199132,-0.026914993,-0.009222796,-0.0037032412,0.0037339472,0.010970798,-0.006481345,0.02332323,0.015636347,0.025741551,0.013207397,0.01542803,0.038397893,-0.0037140811,0.036690876,0.024258282,0.004805185,0.0031139029,-0.0017211476,-0.010661064,0.024994649,0.017740285,-0.026794964,-8.3409366e-05,0.016791778,0.0021983674,0.0067880633,0.0004889312,0.0041856486,0.0053999573,0.00700431,-0.015602554,-0.0069947857,0.053630874,0.0058672884,0.0061371583,0.030394468,-0.017392665,0.009291332,0.010371311,-0.012013656,-0.010122771,-0.01977626,-9.177477e-05,-0.0017189998,-0.0040591857,-0.031019188,-0.019971935,-0.014767318,0.033868633,-0.025356563,0.016328283,0.047226783,0.017391836,-0.014392975,-0.012757168,0.022619167,0.0015205302,0.00081519835,0.009751128,-0.028327301,0.01887957,-0.005456663,0.011765205,-0.022133803,-0.042057466,-0.0099195875,0.04151013,0.042206492,0.0065713865,0.014380521,-0.00019198154,-0.03653801,-0.0036722727,-0.010148561,0.027404202,0.010442047,-0.011661545,-0.0021823053,0.0007532573,0.012634987,0.016041402,-0.016696008,0.0025893783,-0.01454357,0.025318436,0.0059111635,-0.017822033,0.0137589825,-0.002522595,-0.006668767,0.0009799406,-0.033057444,0.0071967714,-0.014385318,0.023268726,0.016683755,-0.024208542,0.02118584,-0.044088226,-0.01623076,-0.008352982,-0.011761544,-0.0106334295,-0.0039773267,-0.0115096,-0.018746408,-0.0118583515,-0.04221851,0.04154033,0.0017129651,0.02315862,0.008441166,0.0022873578,0.019363876,-0.014371578,-0.011206846,0.018016545,0.02430763,-0.0023458286,0.025582125,-0.011183347,-0.01626026,0.023019034,-0.005613005,-0.008986612,-0.0165931,0.02167985,-0.029048348,0.00028310058,0.0028815127,-0.023359518,-0.006802272,0.027151525,0.05067504,0.0068924665,0.05495644,-0.03240983,0.00089683704,-0.0011661738,0.056531645,-0.0391114,-0.0001955238,0.00078108587,-0.014458255,-0.018110748,-0.029858982,-0.014716243,-0.013542873,-0.009317253,-0.009010941,0.006649372,0.025451327,0.0066335327,-0.02825071,-0.020866001,0.0099650845,-0.007952837,0.022302687,0.029040191,0.0249653,-0.00065594184,0.0061452263,-0.023898171,0.003276795,0.0313005,0.04244164,-0.020706158,0.0051562996,0.02034065,0.01574294,-0.025158957,-0.029372191,0.024862869,-0.018926404,-0.021748379,-0.015541922,-0.01787585,-0.008083203,0.006260648,-0.007960437,-0.007781509,0.0015165397,0.00735773,-0.011971269,0.0019438124,0.019217124,-0.032309525,0.00016861761,-0.0025076787,0.021319317,0.03730082,0.029674912,-0.030998843,-0.02296274,-0.0012373761,0.010438693,-0.017912827,-0.023905437,0.0031141173,-0.00974684,-0.016805258,-0.023782076,0.029415708,0.024183065,-0.0074187936,0.016524075,-0.030882508,-0.0019727454,0.0043158513,-0.025398673,-0.03332907,-0.04295168,-0.015442409,-0.0179398,0.0002821254,-0.033997465,-0.0028745253,0.025649127,0.009450658,-0.019025065,0.008947525,0.02188653,-0.0033443607,-0.030446421,-0.010289013,0.045504656,-0.030395418,0.008565359,-0.01940636,0.006091135,-0.00015624403,-0.022468137,-0.007977316,-0.0151294805,0.004109543,-0.03359691,-0.035737816,0.0007161047,0.021228775,0.020670652,0.028653063,0.0035750945,-0.0031039424,-0.02249047,0.026325358,-0.008806933,-0.012017492,0.009391809,-0.023669533,-0.020578997,0.0028734258,0.013235219,0.019008096,-0.023202239,0.017060589,-0.011676478,-0.007748596,0.020326348,0.00017237858,0.0044606826,0.031967916,-0.023859324,0.037994307,0.019952951,-0.017832581,0.03750553,-0.005388362,0.031641036,-0.0039065797,-0.0026772125,0.015209652,-0.024189672,0.0025651592,0.0067802067,-0.0118326275,0.015507107,0.030247394,-0.008724775,0.0050853663,0.024644414,0.03340282,-0.0020002986,-0.013707731,-0.012128286,0.012271494,0.026980584,-0.003687934,0.022635993,0.014104464,0.00527969,-0.0067844186,0.013468676,-0.010472238,-0.011180465,0.007610059,-0.031687032,-0.011125582,0.00095188915,-0.013519132,-0.010383245,0.008318439,0.013596589,-0.04481951,0.0020389308,0.011717728,-0.01344637,-0.007398374,0.014398262,-0.07092897,-0.0056195054,-0.026941914,0.025791824,-0.011238285,0.025926925,-0.013092973,-0.025913835,-0.011890831,-0.009173692,-0.0021892863,-0.022023931,-0.027579246,0.030618582,-0.0008199936,0.011231917,-0.0026002384,-0.007339409,-0.005145087,-0.016528944,-0.009092564,-0.018532922,0.00991745,-0.022221861,-0.021707399,-0.013075288,-0.025043616,-0.012272554,0.0048150127,-0.0004501185,0.02051958,0.017126933,0.027411804,0.010060323,-0.01402268,-0.013182198,0.03479467,0.021209577,-0.020799307,-0.026715599,0.008811151,0.02973876,0.00083661225,-0.011318996,0.006114655,-0.0052258535,-0.018564466,-0.011468333,-0.019775284,-0.02158645,-0.00300246,-0.002073598,0.0035315081,0.0027577577,0.016820977,-0.0014929606,-0.0044204453,0.0015678536,0.008093703,-0.0068607545,-0.0005037582,0.028550534,0.03203727,0.007710439,0.02181327,0.023853632,0.009604717,0.030874351,0.005381428,-0.019226784,0.008476645,-0.021719148,-0.026943967,-0.023504663,0.021319764,-0.0031643733,-0.012826323,0.016119694,0.0048031835,0.008913485,0.0052336277,0.009879032,0.027969895,-0.01046173,-0.02015072,-0.014010982,-0.014811101,0.015637815,-0.0033815976,0.019313348,-0.00807197,0.008673856,-0.02385843,0.03112308,0.027072385,0.0135089215,0.021926878,-0.03134112,0.0176507,-0.0068908534,-0.006873979,0.008725894,-0.009928267,-0.0072165895,0.022845173,0.012510686,-0.014232994,0.017523143,-0.0059615634,0.019829225,0.014490989,-0.00059052027,-0.024049267,0.021340167,0.0066658948,-0.03523965,0.0054600458,0.0102637205,-0.048141155,0.013622496,-0.011324401,-0.00077618135,0.022888966,0.0033639087,-0.0057684793,0.009007459,-0.028040467,-0.002545979,-0.032575417,0.027917653,0.00047247388,-0.020996168,-0.0019415758,0.012169876,-0.035370894,0.021092443,0.007819807,0.045537684,-0.022907956,0.00010496693,-0.012370061,-0.015676387,-0.0021477628,-0.024077468,-0.026593303,-0.0013778667,0.0031347612,0.010635357,-0.009038711,-0.020228438,-0.012125334,0.005826376,-0.010307884,0.0030048282,-0.0056014303,-0.03109355,0.01583239,0.00055850716,-0.01209639,0.008535468,0.027381452,0.017091425,-0.026845738,-0.016080173,-0.022916649,-0.015304483,0.020139862,0.00722446,0.0019284443,-0.019336192,-0.0027849255,0.04208733,0.0072136116,0.013499189,0.029163366,0.009245939,-0.00947333,0.02287392,0.016091777,-0.016154546,0.0066570337,0.02364886,-0.011316471,0.015870145,-0.0281705,0.007855112,-0.009314626,-0.010319446,0.013451353,0.026966082,0.0065980824,0.008460415,0.021515075,0.03157993,0.026357016,0.028996864,-0.0046282923,0.04071315,0.03650103,0.0069340095,-0.0064418917,-0.00554893,-0.012552914,0.02541215,-0.031145955,0.017729318,0.013567784,0.024105366,0.0019534049,-0.043057412,0.02275921,0.0060542366,0.013237581,-0.017858176,-0.0098730065,-0.034224823,0.026656974,0.020646807,-0.0145246005,0.019330725,-0.04206101,-0.030206354,0.016434828,0.010947024,-0.022056093,-0.053875856,-0.013655792,0.022961276,0.0004524751,0.028920328,-0.022144858,-0.0286035,-0.037777513,0.015959907,0.02766722,-0.0042803325,-0.030765053,0.010008721,-0.01757999,0.01619442,-0.010503156,0.02849967,0.0018984358,0.022921266,-0.011791185,-0.014584845,0.008943329,-0.010529421,-0.00976056,0.015737083,0.004584487,-0.012681822,-0.034896944,0.015468621,-0.0060232533,-0.020864854,0.013226393,-0.0062746275,0.0085126655,0.008927021,0.00681766,0.032079484,0.03321183,-0.014985676,-0.024088757,-0.030768333,-0.025408521,-0.012659597,-0.0067099477,-0.005123571,-0.006821575,-0.019239187,-0.0049910326,-0.0018485155,-0.013338082,0.035271123,0.0028371355,0.0050195837,0.03102535,-0.012134119,-0.026036086,0.0049810805,-0.0015755394,0.033052504,0.027223166,-0.025977716,0.008549099,0.026221028,-0.006735943,-0.038094074,-0.04936073,-0.0032744773,0.018244525,0.0075895153,0.011156768,0.028421108,0.010890785,0.015865564,0.016066572,0.015934447,-0.040171992,0.0069079255,-0.0057190536,-0.008849772,0.036735505,0.02279328,0.0034935211,-0.004355486,-0.019224223,-0.025379132,-0.025861828,0.018383445,-0.0033097148,0.024964938,-0.017869275,0.0076367864,0.013257189,0.0075920173,0.0026941583,0.039040722,-0.0040263757,0.015774596,-0.031164555,-0.013798685,0.021466458,-0.033242118,-0.0050642635,-0.007863365,0.0018186271,-0.023611035,-0.006246853,0.036696285,-0.018958492,0.0029735328,-0.030074093,0.02298718,0.035960432,-0.008289586,0.0069338935,0.001424932,-0.00821021,-0.011579914,-0.010854053,0.008944462,0.0001910085,0.0077480692,-0.015621675,0.01608856,0.0022141594,-0.009802882,0.02071394,-0.026706118,0.029780382,0.010858674,-0.009919194,0.0027019416,0.015660621,0.020988245,-0.002995758,-0.010343467,-0.0010442397,0.0047657206,0.013056988,-0.024990575,0.0072509926,0.015411575,0.014118849,-0.018680422,-0.012694142,0.026855582,0.001398265,-0.022453995,0.019721733,-0.019211344,-0.031155327,0.02112477,0.019602273,-0.0012624253,0.019807626,-0.010174662,0.0133034205,-0.022779763,0.016274294,0.001500384,0.024856843,-0.03152307,0.023955578,0.027479379,-0.028957125,0.004455812,0.011247171,0.009091151,-0.0032122347,-0.011478321,0.00372809,-0.0032723802,0.03724664,0.0002628704,-0.00913884,-0.005713371,-0.0004080234,-0.010852498,0.055521768,0.02175574,-0.020789107,0.004987743,-0.0016991452,-0.022074014,0.01878492,0.0066646766,-0.00013653564,0.009346089,-0.01947521,0.010434148,0.024599886,-0.027001727,0.034057938,-0.018406518,0.019345202,0.016941795,0.0012821703,0.057604942,-0.021640023,-0.0038731701,-0.019566234,-0.020630375,0.017955959,-0.013581768,0.01083334,0.017940778,-0.017683214,0.0047620595,0.008862551,-0.0102872625,0.011440477,-0.006904806,-0.0028512324,-0.013673864,0.002351474,-0.013967712,0.02781586,0.005933337,0.0022421172,0.04513212,-0.017269487,-0.017151302,-0.0077453093,-0.006822545,0.029797602,0.01621372,-0.0281791,-0.006400686,-0.023344193,0.003608058,-0.0039598243,0.005373861,-0.0022568367,-0.015860016,0.0006712904,-0.012080476,0.040483482,0.013404868,0.000752996,-0.010461637,-0.003993259,0.025430461,-0.047598723,0.0026256852,0.014530435,0.02148825,0.010289429,0.04272188,-0.0055919127,0.0018892185,0.015640646,0.017498812,0.02007096,-0.004835448,-0.00079784705,-0.008393821,-0.0034401047,0.019781869,-0.029964834,0.014662316,-0.03784598,0.03036091,0.019472085,-0.032979835,0.0120468605,-0.008790114,-0.0018413082,-0.018658994,0.0202373,0.009242418,-0.0153661175,0.021641789,0.02381081,0.0045135226,0.0019793422,-0.013457285,0.026283491,-0.029123561,-0.030166592,-0.021088414,-0.007297846,-0.006693202,0.025600526,-0.0069995,-0.022042768,0.003993532,-0.004447225,-0.030750765,-0.007844931,0.026672311,0.0020280883,-0.0027324562,0.006627837,0.010308065,-0.00065514597,0.020073006,0.006477385,0.010627705,0.0007196094,-0.00021983233,0.018391253,0.0120205,0.0045974655,0.002997768,-0.032826696,-0.01984873,-0.032063764,0.0035628628,0.034177527,-0.0075516594,-0.011734688,-0.011868673,-0.037750702,-0.0026926815,0.007797567,0.006430828,0.010820032,0.03594583,0.01352685,0.0018445986,0.017299162,-0.008182913,-0.0138462335,0.013314077,0.0046229684,0.030414835,0.0061655934,0.033543933,0.017231215,0.00757129,0.012076496,0.03786684,-0.0119156465,0.00563638,0.0020012022,-0.01916272,0.00688376,0.0085983705,-0.01150157,-0.015486775,0.020555763,-0.008643054,-0.011836537,0.015161124,0.01386455,-0.004837344,-0.0005451282,0.012705768,0.009093858,0.054461308,0.02006161,-0.0107594235,-0.0031680902,0.0031367538,0.005114069,-0.005816729,-0.015374543,-0.030567583,0.03306909,0.02819552,0.0022009579,0.004694904,-0.015636578,-0.024518576,-0.011183628,-0.015866617,0.02094445,-0.019637333,0.0036098398,0.0049190037,-0.002662349,0.011911096,0.026268383,0.031161115,0.008058848,-0.028105792,0.03519352,0.006362646,0.0041629644,0.011423074,-0.026421126,-0.0064972406,-0.00010272947,0.025072934,0.018723158,0.005928967,0.0088226525,0.042288516,0.0062359856,-0.039758533,-0.03141117,-0.013808642,-0.0013188613,0.008571066,0.009334217,-0.016153222,0.027519546,-0.018255202,0.009996669,-0.018044025,-0.025083022,0.0021661965,-0.0040544015,0.012122923,0.006517848,-0.012774314,-0.00940411,0.0016664928,0.013352128,0.049286816,0.0057746205,0.007558985,0.014012761,-0.0117225675,0.0077239145,0.01802188,0.017447965,0.004485766,-0.019661022,0.0058464454,-0.006151598,-0.012741716,0.024697462,-0.0021486827,-0.008352175,0.023931097,-0.002126425,-0.015000846,0.010886758,-0.03408586,0.0022203592,-0.031576753,-0.024417227,0.0014647637,-0.025337685,0.016393527,-0.017033076,-0.014694793,-0.0033359765,0.039955452,0.015320454,-0.0050244764,-0.010357333,-0.041174363,-0.012934496,0.01510456,-0.004888249,-0.01817634,0.009885247,0.011960805,-0.030216506,-0.0071787504,0.011196476,0.002806899,0.000991404,0.0021819675,0.058496498,-0.021603035,-0.0062117404,-0.023152862,-0.04571475,-0.0027878573,0.013322185,0.009520431,0.02084445,-0.017076982,-0.0022653237,-0.0016018032,0.0043775877,-0.010306977,-0.019139213,-0.00022197663,0.01400385,0.0012377782,0.041043382,0.03438293,0.035526913,-0.003395705,0.02134513,-0.012851927,0.022352457,0.019336542,0.020243345,0.021755189,-0.014178741,0.033985954,0.026002295,0.014073307,-0.03258973,0.024406256,0.020567534,0.014372951,0.030118803,0.035391524,-0.009985794,-0.010991928,0.0022848574,0.011768911,0.015048259,0.015155943,-0.037288744,-0.0132661965,0.017533882,-0.00299751,-0.011522772,-0.025183521,0.025463715,-0.021262566,0.008899359,0.01818659,-0.014857642,0.041156705,0.021936314,0.011621294,0.017248062,0.013872046,0.006935498,-0.006228438,-0.029668018,0.0018901272,0.009944532,0.0009046069,-0.021287087,0.011851857,0.038661692,0.011748794,-0.011347662,0.010739984,-0.003801201,-0.008865342,-0.03313865,-0.002858907,0.00675751,0.032085378,0.008779308,-0.00116519,0.0064988853,0.029274834,0.0026912412,-0.013735599,0.022569364,0.0030304391,-0.0020629752,0.010176289,0.0008428793,-0.009024227,0.010949054,-0.023154987,-0.021413445,-0.014713545,-0.013244941,-0.008651025,0.03041689,0.03174078,0.0014702219,0.000773734,-0.0018703954,0.007677499,-0.0029378517,0.017155679,0.005418792,-0.02266192,-0.0069642346,-0.014057731,-0.0067957724,0.00316408,-0.033161774,-0.010988102,0.0076088696,0.0058845123,-0.0019093283,-0.015598976,-0.0007063813,0.0034443883,0.022236805,-0.025128536,-0.0032862846,-0.03640177,-0.032728925,-0.009765491,-0.008006884,0.01957013,0.025412671,0.029523047,-0.010529085,0.0030505832,-0.0029564202,-0.014241705,0.02703784,-0.0060190414,-0.019527972,-0.029156683,-0.025625734,0.037782986,-0.013430107,0.007364727,0.03705782,0.036600634,0.009297442,-0.0030503266,0.019211838,0.029235262,0.023203632,-0.025374867,-0.03731622,0.0041187713,-0.011124442,0.0040798723,-0.0068396353,0.002065587,0.010890746,0.034368806,0.0015737432,-0.022333242,-0.005745126,0.011173981,-0.02075105,0.0103086615,-0.021338316,0.008718866,0.0031943829,0.025563715,0.00890966,0.0046768654,-0.03816555,0.0055748043,0.008106915,-0.028700106,0.006680951,0.016006373,0.024820052,-0.010882015,0.028761346,0.03579486,-0.008141806,-0.018327119,-0.006729119,-0.002857837,-0.011094484,0.02343792,-0.0077172946,0.009672828,0.03729965,0.0041847024,-0.01786077,-0.020099621,-0.018894363,0.010317872,0.007102289,-0.018460946,0.023732144,-0.022473818,-0.0130971335,-0.025894389,0.0038451108,0.025597783,0.009873767,-0.033815816,-0.009565204,-0.045173515,0.03048733,-0.01840384,-0.009324968,-0.027700212,0.0036996615,-0.0017279513,-0.0080827465,-0.009504589,-0.027930027,-0.029518485,-0.01082028,-0.023162963,0.0026827015,-0.0112412935,-0.0007822506,0.00023303821,0.0029603997,-0.0037146574,-0.027192373,-0.0029417751,-0.017760623,-0.001717856,-0.021553094,-0.008662834,-0.014233402,0.042350437,0.02449416,-0.055086464,-0.01887936,0.010393863,0.021027602,0.016159927,0.01863635,0.0052639223,0.02666508,0.0024360716,0.0344467,-0.013868177,-0.0103989765,0.02213106,-0.016720874,0.010079472,-0.0111632785,-0.004600549,-0.02752812,0.012688776,-0.00301779,-0.016924385,0.006368629,-0.029938487,-0.019218419,0.0011120718,0.036712166,0.009583829,-0.0080347825,-0.0019662809,-0.012545096,0.00035413622,-0.0014279352,0.0036811093,0.0003754014,-0.021281311,-0.007648054,-0.022670541,0.011318254,-0.010481671,-0.007551135,-0.014020097,0.0028037915,-0.006056178,-0.014321424,0.013950436,-0.0067180838,0.027676282,-0.041135114,0.0243756,-0.009963151,-0.00062619493,-0.0075148507,-0.009853488,-0.011309567,0.016811788,0.022938527,-0.001178288,0.010020652,0.013178449,0.022595394,0.012257234,0.016509915,-0.020731349,0.015198068,-0.029186798,0.010405655,-0.009330668,-0.065823525,-0.00957556,0.02114228,-0.029557211,-0.010530927,0.016634673,0.0036417083,-0.007730592,-0.027122814,-0.042147323,-0.023951897,0.0029974743,-0.0222781,0.02637795,0.005619902,0.02321526,-0.0005204515,0.020900926,0.007331927,0.006242816,-0.0153588345,-0.026895633,0.0014668863,-0.00955567,0.011198644,-0.008448599,-0.0047281496,-0.009924892,-0.013090595,-0.024056938,-0.002946734,-0.0034720341,-0.013210024,0.016417438,0.017511431,0.0061723557,-0.04616792,0.034405384,0.0004491292,0.021105517,0.028196014,0.018877983,-0.015880449,-0.03227615,0.01803893,-0.0034151608,-0.00054407754,-0.018481176,0.029017622,0.0020353408,0.006727324,-0.0006689687,0.01244909,0.00091651076,0.009993779,-0.010678958,0.0099873515,-0.012197804,0.016204763,-0.031428352,0.026976837,0.008099963,-0.00079209503,0.0125429,0.009319973,0.027845088,0.012345733,0.00027321753,0.003000537,-0.013628908,0.019178972,0.0044157775,0.008320306,-0.009386588,-0.0051446795,0.008309151,-0.011519242,0.00955356,0.014409387,0.02275787,0.008674916,0.0041052154,-0.007073927,0.006262177,0.031443655,0.009188464,-0.017089384,-0.033338103,-0.015749464,-0.011753733,-0.0060136723,-0.036323495,0.015679266,0.0073606963,0.015989032,-0.02259625,-0.013001371,0.0036556446,0.0023280636,0.014880067,-0.0048898295,0.0019074026,0.013310097,0.015827216,0.0057039787,-0.019645507,-0.01220753,-0.010739545,0.0063294875,-0.011350609,-0.002522825,-0.00721388,-0.05119599,-0.0053118314,-0.004543251,-0.013930817,0.033175237,-0.0022776902,0.014714916,0.0013855307,0.01820934,0.013647204,0.003335252,-0.00543937,-0.007926919,0.0056685666,0.0036235307,-0.0050256695,0.007995197,-0.03823924,-0.02552961,0.0023470435,-0.004195957,-0.010409863,0.016766656,-0.02255285,0.027381785,-0.009142093,0.03529851,-0.0048756986,-0.0024670162,0.007900042,-0.013211047,-0.0036919322,0.0047269636,-0.0072506405,-0.0053037386,0.02637167,-0.0013950284,0.022068644,-0.017976627,-0.025243323,-0.025754409,-0.028515453,0.02618391,0.017993717,-0.0015651829,-0.013229839,-0.012609051,-0.046315942,-0.019056594,-0.0043490254,0.041466396,0.004063035,-0.020291548,-0.009710889,0.010651514,0.010818646,0.020005478,0.005729606,-0.02612541,0.006162281,0.0141355,0.0034030632,-0.00029457855,-0.0391127,-0.042991433,-0.013088808,0.026009817,-0.02886776,0.016573684,0.003107542,-0.0009015691,0.013646015,0.00021005602,-0.0018879088,0.010038845,0.0022940545,-0.014522279,0.01926708,0.0018596945,-0.025625201,0.009681592,-0.008112179,0.017153336,-0.0033937565,-0.012972674,0.013753638,-0.015696166,-0.0035665212,0.002677662,0.023734888,0.006841326,0.0075131566,-0.010948266,-0.007176072,-0.002617745,0.0067140423,0.020711133,0.0015086124,-0.0025146469,-0.017044814,-0.009886609,-0.027822856,0.0360385,-0.038346577,0.0010144785,-0.0007764156,0.04860052,-0.012340763,-0.004629891,0.020894613,-0.030521553,0.024202984,0.01940828,-0.0149368085,0.01871109,0.0100051565,0.0122125475,-0.016990604,-0.011512005,0.052328892,-0.006771611,-0.0110439975,0.009109464,0.031324383,0.0061777206,0.0029452564,0.02921454,0.057671353,0.008321698,-0.0063767578,-0.011151802,-0.0062085073,-0.019802077,0.023518324,-0.0025078857,-0.0068217493,0.0097643165,-0.02399772,0.004721033,-0.009075694,-0.033326734,0.023371654,0.0129928915,0.038193133,0.011546375,-0.021089405,-0.023049707,-0.0113829775,-0.014824239,0.032733552,-0.0077422583,-0.023011023,-0.05966102,0.022488652,0.008721311,0.026027555,0.032821454,-0.023265913,-0.010307549,-0.003136032,0.010251785,-0.009138222,-0.02192369,-0.01836441,0.025678148,0.01114469,-0.037548233,0.027489286,-0.036775038,-0.0028992544,0.038915478,-0.02517495,-0.0044011855,0.0382619,0.01866176,0.009955766,0.019022198,-0.014344553,0.011414187,0.0053998004,0.012446027,0.015447317,0.04572277,-0.012424055,-0.013950513,0.014202624,0.0045079747,0.009512551,-0.0025970635,-0.025867565,0.0044275564,0.026968617,-0.0057281107,-0.005345296,0.008637367,-0.00067604345,0.0016230297,-0.0058214623,0.010266117,0.0031073005,-0.015480032,0.0006231659,0.0028858965,0.01988369,-0.0033157032,0.01025869,0.010400107,-0.006149002,-0.027100863,0.009195072,0.012265567,-0.0025255687,-7.644044e-05,-0.005816754,0.045306787,-0.013469404,-0.0071499073,-0.009605731,0.0073357816,-0.013383758,-0.01841468,0.017236501,0.003708679,0.0060484726,0.010362862,0.029791156,0.03251122,0.020105595,0.00031744738,-0.005961911,0.011921483,-0.008226171,0.030801997,0.00922447,0.0005951811,-0.0069189966,-0.010524166,-0.018770022,-0.04176556,0.008350659,-0.015484537,0.022626733,0.01210481,0.023746483,0.0016831044,0.0037341563,0.019599445,0.0016429767,0.011498996,0.020514052,-0.015665613,0.02039117,-0.018906623,0.0026434644,0.007764963,-0.0058377,-0.013142836,0.012319424,-0.0027092209,-0.022784393,0.006549409,0.0398641,-0.0014069651,0.0323998,0.018795261,-0.020135788,0.019639041,-0.0091299685,-0.013844194,-0.0003700012,0.015658265,0.019516988,-0.0049623162,-0.01776138,0.00621061,-0.005811626,0.024042087,0.00089714094,-0.013217089,0.0011820694,-0.033087976,0.011920845,0.007502017,0.014534625,0.003107303,0.006051404,0.026637798,0.0043103397,-0.008703985,-0.011897756,-0.027688714,0.0006156966,-0.0046398514] +6 The quick brown fox jumps over the lazy dog [-0.029032236,0.027541267,-0.049360942,0.0026571557,-0.016211323,0.008966428,0.023268595,-0.038530067,-0.024515703,-0.05216744,-0.020084511,-0.010053353,-0.0120895505,-0.004225678,0.02517628,-0.016566766,0.021050926,-0.03178753,-0.022233265,-0.0111827515,-0.025391089,-0.008553723,0.037377592,0.034131184,-0.02063081,0.028366731,0.047541816,-0.008195971,-0.00048127724,0.061091848,-0.007545091,-0.023015982,-0.043029148,-0.06256858,-0.018269828,-0.06744673,-0.006603377,0.0030787706,-0.037319306,-0.032983057,0.012743918,0.012649069,0.013381752,-0.0010312264,0.01806989,-0.011420886,0.022888036,-0.013341586,-0.014315622,-0.03698223,-0.013638079,0.027158687,0.008590075,0.026723148,0.026619148,-0.009275909,0.02529872,-0.011084807,-0.013885452,-0.029864071,-0.014064913,0.046105843,0.0024218177,-0.008019403,-0.047738157,0.110396005,-0.005279425,-0.03182223,-0.0070092287,0.0035405864,0.004673825,-0.015463238,-0.028634654,-0.025528686,-0.016760226,0.02886832,0.032572184,-0.02842717,0.009914399,0.04954739,0.07454552,0.009173633,-0.057309378,0.00034880813,0.008527929,0.049139176,0.0056147515,-0.03298989,0.015185219,0.035666287,-0.0015743512,0.01367141,0.009062021,-0.020319268,-0.0070784283,-0.02348468,0.003491604,0.024424402,-0.028939357,-0.022141214,0.0041457512,0.030775279,-0.0063227313,-0.051581204,-1.8472706e-07,-0.010937068,0.016264204,0.007170256,-0.02334083,-0.010495747,0.05859036,0.024999954,-0.014638252,0.016519208,-0.0095166275,-0.051654898,-0.014216941,-0.020318374,0.014194449,-0.0011400392,0.03041403,0.039039426,0.080581404,-0.030900763,0.027184706,-0.05403562,0.01091366,-0.008990012,0.00019269668,0.0816926,0.011066008,0.0037669975,0.025137706,-0.04432386,-0.04332134,-0.009512481,0.0045623337,0.029903479,0.034954015,0.00037793102,0.05934266,0.02825433,0.0060193683,-0.046925373,0.027027726,-0.08615702,0.058653336,-0.041149467,0.010134666,-0.015393636,-0.068177804,-0.018608646,-0.013894178,-0.021091167,-0.017168805,-0.018828785,0.0027248522,0.04457523,-0.00064965594,0.035847817,0.020864038,-0.01954997,-0.006292807,-0.02420156,0.0045407736,-0.0032544343,0.0021352312,0.01704365,0.0035498342,-0.027347045,-0.031832032,-0.08659941,0.018539209,-0.0141063165,0.033021167,-0.0023002028,0.023596285,0.009151327,-0.0018593688,-0.022782313,-0.013646492,-0.0035246362,-0.005827542,-0.021454046,0.005617944,-0.023166966,0.020891007,0.008279503,-0.003523943,0.009084478,-0.018899431,0.017956033,-0.012373295,-0.030652866,-0.013992818,-0.03971101,0.010339661,-0.075278305,0.019667802,-0.0031458943,0.0011443228,0.024797358,0.026554594,-0.0385151,-0.015933214,-0.020515047,-0.033362288,-0.05018405,0.02270959,0.024541026,0.024050323,0.042023372,0.04343512,-0.066038,-0.00697414,0.0022782255,0.01038387,-0.0017622124,0.011812393,-0.023322195,0.031925455,0.016324658,0.016113428,0.026504522,0.07107637,-0.0050686398,0.010541682,0.04306067,-0.030528773,-0.032413222,-0.008004368,-0.007486079,-0.025118964,0.019677402,0.019462753,0.00872305,-0.01256769,0.044494756,0.041111797,0.04990029,-0.022030592,0.011073367,0.012289722,-0.003416942,0.02946172,-0.016325159,0.015433186,-0.028255522,0.023287045,0.042727724,-0.021317644,-0.051636986,-0.02033665,-0.01554735,-0.010799427,0.036458984,-0.004210024,0.0099789975,0.037401367,0.0013109646,0.013590533,0.04634951,0.016906822,0.041651722,-0.060277052,-0.0075358353,0.010659047,0.028094325,0.0027020923,0.00786185,-0.019595763,-0.009291591,-0.035853207,0.012377613,0.045850758,0.05218025,0.009682198,0.002579546,0.051883414,-0.025523806,-0.011864099,0.029231831,0.0056085205,0.015151311,-5.2779866e-05,-0.0028387052,-0.012266712,0.038371045,-0.015375685,-0.049642548,-0.0068658167,-0.046315324,0.02915837,-0.027734954,0.05393971,-0.043115936,-0.011586032,-0.11912329,0.03271244,0.0053361664,-0.02654155,0.009041747,-0.021327818,0.018109042,-0.012309006,0.044054624,-0.045485917,-0.037562553,-0.038395617,-0.00085795537,-0.015647313,0.070276976,0.05040885,0.014516033,-0.052314375,-0.002416441,-0.022550073,-0.060473926,0.055714026,0.043880057,-0.050106324,-0.019636557,-0.03573261,-0.034023546,-0.050661605,-0.026773129,0.010235709,-0.021179019,-0.014773596,-0.0101006925,-0.000896457,0.010528311,0.064702764,-0.033500217,-0.08366466,-0.010372936,0.0151527515,0.044639118,0.088840835,-0.015347727,0.028613089,-0.0118400315,-0.0786659,-0.029807458,0.004859265,-0.0018762939,-0.0075682583,-0.014361755,0.02191934,0.044453025,-0.015190103,-0.0073861345,0.03873795,-0.016177544,-0.02629211,-0.033452608,0.05223485,0.017069537,0.029593721,0.018236399,0.017212493,-0.021333111,0.03767868,-0.030281723,-0.06660921,0.052576944,-0.012371403,-0.004140076,-0.035153072,0.008647434,0.016138555,0.046432365,0.019738676,0.06350088,-0.0052767987,-0.042961318,-0.07451252,0.03484245,-0.00048273578,0.0065238248,0.0014623344,-0.02136029,-0.016452096,0.019530699,0.01099558,0.031322785,0.19658546,0.024536634,-0.022284785,0.046578456,-0.012957386,-0.009765504,0.04526831,0.00061344786,-0.016241977,-0.0102810925,-0.022228172,-0.00040116554,0.02221187,-0.004733062,-0.04832785,0.04206737,-0.024739558,0.013646776,0.04028664,-0.06942355,0.028228926,0.010920678,-0.02976753,0.038866527,-0.04578389,-0.071879074,0.039328195,0.058829557,-0.032810643,-0.035801034,-0.018803468,-0.03621923,-0.02247097,0.029181648,0.034382742,0.014105579,0.0088357525,0.00639955,-0.01671419,-0.014079323,-0.010824278,-0.013390188,-0.043241616,-0.022888424,0.024219658,-0.023814885,-0.024244366,0.0027036183,-0.03583181,-0.05340871,-0.020888608,0.028326409,-0.01908272,0.011103025,0.010749846,0.016117435,-0.03835777,-0.048518892,-0.01790978,0.019024558,-0.04647557,0.056169197,-0.052743666,-0.024081336,-0.035478275,0.026676917,-0.029208042,0.0034931798,-0.020711854,-0.0035989266,0.0065865316,0.02423219,-0.012301781,-0.015528001,0.0051970803,-0.0060134707,0.007618042,0.031077906,0.07022116,0.031118842,-0.0048810043,-0.041220725,-0.06001965,-0.026325645,0.025280187,0.041063435,0.0016195797,0.06695501,-0.029371329,0.016537245,0.011808403,0.024047764,-0.061922923,-0.006432753,-0.04724287,0.0014184486,0.011193248,-0.025458114,-0.016273905,0.023544768,0.011296197,0.0006673169,0.026123274,-0.013401752,0.07747566,-0.014411226,-0.03036236,0.059674077,-0.008075383,-0.018516926,0.06476084,-0.02419832,-0.035707343,-0.06562547,0.010402222,0.015166347,0.0104067335,-0.0006514749,0.024349516,0.02223856,0.025299126,0.016701797,-0.019606367,-0.02128431,0.005371002,-0.07514987,-0.0003629181,0.027569095,-0.007273748,-0.008917242,-0.0070796884,-0.0017334168,-0.013740548,0.033698387,0.030531632,-0.014760793,0.041112084,0.0011269189,-0.0021155751,0.04941831,0.008075308,-0.0089725135,-0.011412492,0.03977043,0.049596902,0.014840332,-0.032614026,-0.009406574,-0.038295403,-0.01314724,0.009199688,-0.0057843667,-0.02573366,-0.019425305,2.981677e-05,0.011803169,-0.035339635,-0.00579383,-0.039862167,-0.028695151,0.00979492,0.0062058615,-0.012980507,0.012621026,-0.012511154,0.030896222,-0.0005479328,-5.1143634e-05,0.01826911,-0.0093016885,-0.021909676,-0.011932237,0.0034069442,-0.0047332775,-0.014452855,0.019523457,0.006770324,-0.019737573,0.04022002,0.014073834,0.007725252,0.07370726,-0.025840456,0.0077573336,0.0010253345,0.087171845,-0.013565483,-0.019708045,0.021780746,-0.00024205631,-0.052851908,0.06583808,0.032716088,-0.009629032,0.023607953,0.036821887,0.000301589,0.04434612,0.026733002,-0.051516,0.041421443,-0.001630902,-0.048127655,0.03905054,-0.01914189,0.009681427,-0.013721871,0.009606066,0.007132624,-0.052502308,-0.039823104,-0.0042031943,-0.022829855,-0.007968902,0.025500603,0.0274561,-0.00767887,-0.038882058,0.005996106,-0.036868587,0.006190867,0.014900514,0.0337955,0.005621392,-0.029571233,-0.01532957,0.018419921,-0.016231341,-0.014013308,0.007615559,0.0071666054,-0.036003515,0.021036066,0.0042608525,0.003095334,0.02074705,0.0013329037,0.014653115,0.042488877,0.007977819,-0.0387064,-0.018004702,0.02372878,0.07297897,-0.0066522136,-0.00017362671,0.022323648,0.0066302856,0.029584065,0.008738882,-0.017427685,0.056002088,0.021325577,-0.008814906,0.006497661,-0.0038686516,0.029628461,-0.040057737,0.004091832,0.0040631886,-0.027008757,0.008637314,-0.019353177,0.03401452,-0.07762298,-0.07626619,0.022870656,-0.01994437,-0.020035552,-0.029492773,-0.02334895,-0.016283024,-0.0160117,-0.011388794,0.0029625217,0.04168151,0.004351741,0.002102229,0.0050086463,0.0022641749,-0.03792554,0.06541656,0.011909673,0.004188335,-0.03916983,-0.00013763207,-0.0035865174,0.036573276,-0.0011557344,-0.026049215,-0.02135405,-0.0366553,-0.024216166,0.025407683,-0.010887304,-0.0056620063,-0.0026714513,0.026523715,0.004046031,0.027538048,0.020267453,0.006162037,-0.012055703,0.021607554,0.008869754,0.001590233,0.0141579425,-0.025123864,0.06779549,-0.008096917,0.055918626,0.010769218,0.02083379,-0.012091559,0.018923115,-0.011721172,0.024080811,-0.040444072,0.0032546415,-0.00747296,0.047698125,0.0015670046,0.010313855,0.030567124,-0.04908677,0.027930839,-0.018480418,0.043604247,-0.008054652,0.008163224,-0.012693677,-0.008546101,0.041715804,0.04710968,0.03080414,-0.03280536,0.0069360863,-0.016405351,0.054527614,-0.055383857,-2.5600988e-05,0.05142004,0.06278709,0.003661354,0.029217128,0.009122803,-0.010532947,-0.008938612,0.0711063,-0.0072848527,0.0257736,0.028441375,-0.036895152,-0.03436255,-0.032650888,-0.040534902,0.0017578156,-0.035824757,0.024611074,-0.05147681,-0.0025018014,-0.01398988,-0.03609318,0.01503415,-0.010743528,-0.0057467693,-0.015777024,-0.0134013025,0.034471598,0.029991718,-0.029799996,-0.045813713,0.0027390704,0.010904141,0.00024124964,0.016535897,-0.06610352,-0.082819216,-0.024650633,0.010938825,-0.039629187,-0.04729703,-0.013869611,-0.032956168,0.03559851,-0.018381853,-0.0296762,-0.0033895245,0.0044701104,0.011819111,-0.035965726,-0.006810468,-0.01109219,0.0062390394,-0.1217179,0.0076250583,0.033556562,0.022579659,-0.029878413,0.026759055,-0.036560334,-0.02918532,0.0041901506,0.0041152644,-0.0005159235,0.028137084,-0.028514326,-0.045404624,-0.012421633,0.030047102,0.008228033,0.0063862633,0.017270016,0.023194736,-0.0058123996,0.030250363,-0.025852382,-0.045394797,-0.0006903015,0.01020241,-0.029663308,-0.027762724,-0.037675608,-0.070036344,-0.012101096,-0.042162534,0.04272352,0.020835834,-0.017432163,0.052006915,-0.007907625,-0.0050630663,0.023518058,-0.05701346,0.03216327,0.0290774,0.016461916,-0.008338994,-0.021504441,-0.015692448,-0.016096927,-0.0004243537,-0.023692274,-0.006875069,-0.0038105831,-0.040281087,-0.017641528,0.022684304,0.00046133393,0.025547162,-0.0001266806,-0.0012157641,-0.007444698,0.07728923,-0.015379809,-0.028062373,-0.06975879,-0.028663429,-0.02248913,-0.01722224,-0.04707055,0.063811205,0.000833201,0.022033617,-0.017557817,-0.024220292,0.017244259,-0.033904348,0.03576548,0.04458491,0.007500571,-0.02028446,-0.0578517,-0.05370045,-0.03653663,0.01455794,-0.0038376143,-0.034244876,0.023536226,0.046485696,-0.025075331,-0.0031697117,-0.040650297,-0.0062479293,-0.041117374,-0.017834626,-0.037792776,-0.010901289,-0.056138996,-0.029041046,0.01494152,0.028286193,-0.038172062,0.008174347,0.006239656,0.0071062725,-0.007155162,0.0017259975,-0.04566293,-0.043015372,0.010082377,0.017336644,-0.0070169964,0.046315253,0.013324042,0.020289846,0.028382534,0.013524852,-0.01708038,-0.014150225,0.016319737,-0.027446708,-0.024036705,-0.030854287,-0.007868621,0.002767755,0.021544311,-0.028215492,0.013024701,0.040946767,0.01702796,0.007184068,0.011672652,-0.00842753,0.022322752,-0.050013755,0.021590117,0.027056376,0.02593966,-0.041275352,-0.017635738,0.021398723,-0.04646987,-0.01649955,0.0046722335,-0.028439358,0.039706297,-0.008640619,0.029892823,-0.04043326,0.04153833,-0.014515616,-0.042010304,-0.024027416,0.044268563,0.001856589,0.04939307,0.09253509,-0.0037803212,0.019846376,0.007814832,0.004975097,0.014140383,-0.04565493,-0.0085318,-0.0041644047,-0.0037995847,0.003245309,-0.020834424,-0.02891318,0.008167698,-0.007607568,-0.05438528,-0.01873897,0.03957401,0.048702113,-0.007642824,-0.0010100476,-0.015144389,0.0020465802,0.017060393,-0.011072318,0.042989995,0.013606487,0.01278016,0.006976025,0.0008189299,-0.005262739,-0.032642525,0.025262553,-0.002827571,0.027005125,-0.0037480332,0.024744181,0.0043783635,-0.040136084,0.04464139,-0.01931568,0.030145638,0.0019730525,0.010576557,0.04725749,0.013311044,0.018074095,-0.0191941,-0.022671176,0.018354837,-0.0014677666,0.012874243,-0.021667263,-0.014271504,0.0035561104,-0.029314537,0.03482702,-0.009601449,-0.031305797,0.0035266744,0.042158905,0.004062101,0.019094126,0.0016859861,-0.026865184,-0.028459786,0.0020367852,-0.022874609,-0.004964856,-0.037500456,-0.067877084,0.004090317,-0.015400968,-0.02666113,-0.043095365,-0.051067125,-0.013393183,-0.017267652,0.046029635,0.010297703,0.069241315,0.08784794,0.020988274,0.061185457,0.041941985,0.023753492,-0.03309264,0.03357712,-0.030270172,-0.011497612,0.018058946] [-0.00024267368,-0.021487301,0.015893064,0.016589241,-0.0012004591,0.026794905,0.069360554,-0.022258257,0.024625847,0.028194314,0.027430387,-0.010018289,0.0042832014,-0.045728993,0.012988385,-0.044747014,0.054939177,-0.0108724665,0.007016344,-0.0063600577,-0.020102464,-0.018656872,0.002913016,-0.028696457,-0.020325214,0.040209513,-0.02309031,-0.03193941,0.040535264,-0.012941098,-0.0346764,-0.013267057,0.03049164,-0.002727138,0.00060568494,-0.03061178,-0.0011055344,-0.007997505,-0.027909208,-0.050118893,3.899234e-05,-0.042683765,0.026048103,0.015896777,-0.013189266,-0.0058835912,-0.002400474,-0.015676627,-0.0322551,-0.036144305,0.004570902,0.012121983,0.0069960086,-0.020438857,-0.005981235,0.02296801,0.006952576,-0.004247029,-0.0047158315,-0.008665586,0.0035224254,0.008505767,-0.028840886,-0.0016711585,-0.0025143789,-0.019334638,0.012209534,-0.026985457,0.016836226,-0.025630256,-0.0140343215,0.029859,-0.0019047316,0.011636102,-0.023930544,-0.016754862,-0.014079984,0.01916767,-0.0022649837,0.03846287,0.022936774,0.016311351,0.0041684713,0.02078179,0.03345968,0.029949576,0.0077745505,-0.0036578213,0.031497866,0.006099116,0.010269461,0.02898384,-0.018314289,0.020681081,0.00026616233,0.0114278,-0.024009733,-0.020501977,-0.010300889,0.010762857,0.015717264,-0.026469246,-0.024065804,-0.010810337,-0.0017680385,-0.030372143,-0.03781727,-0.008127811,-0.005819896,0.004224963,-0.00053318834,0.005893492,0.013727306,0.005089114,-0.008025947,-0.01494737,-0.010207145,0.04309166,0.010333305,-0.00795705,-0.008496765,0.019971782,-0.014927983,0.0010441104,0.01737064,-0.011388803,-0.023519803,-0.0025593506,-0.018231824,-0.008805737,-0.0026090187,-0.013747436,-0.005854039,0.008293816,-0.009759852,0.016784681,-0.014070859,-0.007418482,-0.014121461,0.0059316205,-0.012842653,-0.0009011491,0.005875817,0.0038749184,-0.021162558,-0.04327404,-0.017497042,-4.0693616e-05,0.012612304,0.006553346,0.031040628,-0.0027753534,0.0055727437,0.043239795,0.021779805,0.024656788,-0.005395243,0.0045758737,-0.0015798623,0.015342429,0.0045967125,0.014557218,0.010606539,0.051071297,0.0248606,-0.004861986,-0.04210206,-0.0042584743,-0.0016449502,-0.022185137,0.005132951,-0.023732798,0.008707319,-0.015492189,-0.022553839,-0.0056613553,-0.039272416,0.011652289,0.003566712,0.01718642,-0.001626922,-0.0017337018,-0.023728726,0.007838558,0.029227186,0.0057010986,-0.0092274975,0.007398929,0.021940421,0.009243053,-0.00036666275,0.021799129,0.0071659572,-0.019765485,-0.0055201906,-0.018056523,0.004777906,0.034119457,0.027709255,0.018896157,0.006427009,-0.009372351,0.009667367,-0.002712454,0.013816319,0.0060277074,-0.0036394035,-0.012843054,0.005442229,-0.0032221724,0.01340514,-0.01886698,-0.035329487,-0.0030466423,-0.016057856,-0.0017798954,-0.032207843,-0.010295281,0.024943534,0.035538673,-0.00519189,0.018202616,-0.006051458,-0.0047974726,-0.041709665,0.008676952,0.0034524563,0.0007977529,0.0057490403,-0.009034729,-0.0009700782,0.011790938,-0.006849878,-0.013010502,-0.015521541,0.025569446,0.0055900794,-0.011569961,-0.00046157796,0.0012954412,-0.03292501,-0.017469374,0.0004819257,0.012228712,0.017796693,-0.018160254,-0.00034844692,-0.019511111,3.2090862e-05,-0.027306942,-0.013932291,0.013310623,-0.0016296405,-0.025479943,0.043643028,-0.009240783,-0.0076925685,-0.03199841,-0.011280381,0.00057636545,0.0015920885,0.018489743,-0.004972585,0.009271358,0.0038322816,-0.029091142,0.0033457517,0.0052615944,0.0013952982,-0.008348527,0.0574619,-0.003828375,-0.0027651426,-6.387033e-05,0.023922531,-0.017745268,0.014320491,-0.0038711138,-0.011704324,0.021682417,-0.020866113,0.022251502,-0.0036267822,-0.0033173913,0.0066860775,-0.008043606,0.023141554,0.021994656,-0.030789755,0.0022088576,-0.0007598994,-0.04870303,0.010584595,-0.027358484,-0.0029548337,0.012971045,0.01409591,-0.026165226,0.0051151817,-0.0364553,-0.0013899142,-0.0046411254,0.004759202,0.007880048,0.019000003,0.03958215,4.4382567e-05,0.002325146,-0.008335243,-0.013906758,-0.0031144228,-0.00816585,-0.0036985797,0.003541235,0.055202335,-0.049531903,-0.012589474,0.013582292,-0.035912156,-0.03064723,0.0012784944,-0.05335294,-0.0053751706,-0.0034608757,-0.05098018,0.01213912,0.018172177,0.009535755,0.009011686,0.026806088,-0.032896455,0.039046634,-0.050126504,-0.0021217891,0.00759653,0.0032871715,-0.004636473,0.051764958,0.03040577,0.0047946516,-0.012043151,0.014714196,0.019824859,0.004458446,-0.013347778,0.0047053075,0.018150069,-0.018608391,0.0012878706,-0.062291823,-0.0075988746,-0.01835789,0.0022287606,-0.0063712173,0.0203182,0.013274508,0.01645004,0.019338593,0.017800009,-0.031294517,-0.0091345655,0.016530542,0.0020289556,0.050010737,-0.01856595,-0.006551016,-0.0077726496,0.027494727,-0.0077746515,-0.017239671,-0.0005142606,-0.022906618,0.021366997,-0.01470745,-0.0040997514,0.0021410245,0.011863405,-0.03277853,-0.011108079,-0.0041146097,0.010332278,0.0025001804,-0.0066138725,0.018047638,-0.007857263,0.0155427735,-0.029137386,-0.019924847,-0.02491911,0.0053268606,0.0169834,0.0015917837,-0.0054884125,-0.025929052,-0.029954584,0.020406432,0.008972708,0.0012921264,0.038721155,-0.01181391,-0.0048665474,-0.008977169,-0.013826192,-0.009933627,0.0084199365,-0.010260418,-0.020392591,0.046079338,-0.0023203786,-0.02186018,0.000431082,0.03308857,0.0009624641,-0.022306211,-0.007354926,0.015768746,0.020663545,-0.05539082,-0.012203236,-0.004783765,-0.027604926,-0.010428542,-0.0015871079,0.0010033498,0.020863678,0.020947453,-0.005558368,0.023703104,-0.012788062,0.008978838,-0.012872263,0.020549892,0.025299128,0.016638944,-0.0059925085,-0.006942984,0.0066104527,-0.0098551065,2.5698379e-05,-0.0044352436,-0.0025132403,0.0029748278,-0.0020528117,0.036266867,0.012519123,0.008749627,0.008375131,0.008062301,0.02263168,-0.035986196,-0.03120648,-0.026136465,0.00093351357,-0.009055535,-0.05803958,0.013408964,0.004284206,-0.013152534,-0.029616233,-0.0061402773,-0.063316055,0.026023319,-0.0055164555,-0.027754204,-0.023860803,-0.0456182,0.0022137924,-0.0058808136,0.008433605,0.037672732,-0.025550026,-0.012295198,-0.03010133,0.0075681373,-0.003683165,0.013532438,0.017615108,0.008904832,-0.022722088,0.026327258,0.050453134,0.0037541555,0.0034731652,0.0352877,0.0018415613,0.031235568,0.024870792,0.020357184,-0.031890947,-0.003091019,0.024663413,0.022705147,0.028746417,-0.038985763,-0.04171101,0.02816726,0.02069387,0.0033102178,-0.008508766,-0.0017985533,0.003932371,0.02233867,-0.003817583,-0.0060381177,-0.02451837,0.017558206,0.0504704,-0.053006157,0.0124742165,-0.010503532,0.024148377,-0.00470445,0.0028559568,-0.03535859,0.038445294,0.0024499123,-0.0068438454,-0.03722551,0.009132428,-0.031774215,-0.02858779,0.0020618506,0.023995707,-0.017833175,-0.049867526,0.039068665,0.03047132,-0.044551842,-0.011932001,-0.028719632,-0.016445665,-0.0032579552,-0.00324205,0.00026991492,-0.043726873,-0.015706249,0.017811151,0.05507638,0.020126035,-0.02540957,-0.011716383,0.004410255,0.0018554624,-0.009659572,0.015862433,-0.011937564,-0.021190131,-0.016428525,-0.008803384,0.018369319,-0.005731961,-0.00665447,0.01867499,-0.01868691,0.009519513,0.029252019,0.014837277,-0.0030046145,-0.013680333,0.009872983,-0.0008936905,-0.013230925,-0.007582905,0.020981245,0.019379443,-0.031128997,-0.025996504,0.016584445,-0.011114791,-0.004296012,-0.0087273335,0.023447178,-0.007258827,0.026747843,-0.013322931,0.0031738365,-0.0078097205,-0.019352421,-0.033470757,-0.008912123,0.010154,-0.0008026122,-0.022623306,-0.042048324,0.0076609803,-0.0038351724,0.0070149954,-0.009358906,0.005830765,0.0036114277,-0.031669136,0.021684194,0.0059839454,-0.021513605,0.03320448,-0.02024704,-0.01694883,-0.014606571,0.0098580485,0.0033596756,0.031652134,-0.006980615,-0.012827098,0.009454013,-0.008549105,-0.01091782,0.02607684,0.006640972,-0.03358734,0.007988577,-0.02761488,-0.0070922417,-0.022015382,0.027037917,0.017610224,-0.041508123,-0.011218916,-0.00037557265,-0.028436556,-0.010325239,-0.028418008,-0.028529799,-0.020240063,0.024983432,0.0006400344,-0.03535984,-0.04649459,0.00975418,-0.0059546307,5.155751e-05,-0.020558922,-0.03794495,0.02939053,0.0118246125,-0.035044476,0.0063866144,-0.05406864,0.015245387,0.019555904,-0.017540252,0.01762712,0.015582118,0.022053976,0.018877579,0.0059720054,-0.022827439,-0.0506016,-0.029416105,-0.0020566732,0.013137142,-0.007501166,-0.008226888,-0.023639351,0.040670313,0.019309135,-0.019101622,0.051145133,-0.015882576,0.030655537,0.008469303,0.0076893,0.0041581294,0.004627955,0.014366257,-0.021020724,0.008165668,-0.039331935,0.020560963,0.03681732,-0.012590241,0.0118674515,-0.014674032,-0.01750814,0.0205416,0.011193214,-0.025189597,-0.009202702,0.01982845,0.007840681,-0.003103538,0.0032081024,-0.012070817,0.014176772,-0.026781645,0.007362489,0.01953439,-0.029274872,0.008238321,0.008339173,0.024237517,-0.005363041,-0.012208067,-0.03182138,0.025223905,-0.009799573,-0.008497717,0.011564594,-0.0039504776,-0.04871098,-0.034350242,-0.00413963,0.012529396,0.050332893,-0.004026361,-0.009709422,0.02136874,0.004377584,0.014967099,-0.0071387268,0.026168872,0.004178993,-0.027399665,-0.0269995,-0.022488195,0.005549367,-0.004258719,-0.018060345,-0.023483235,0.009204998,0.01328567,0.005767717,0.011637052,-0.021624038,0.0056217047,-0.0075412006,-0.013136864,-0.0105228815,0.022365786,-0.060373645,0.053898502,0.01018337,0.014835223,-0.017861342,0.020652082,-0.0107762655,0.008757168,-0.004032616,0.009179854,-0.0070626447,-0.009149378,-0.0030832114,-0.014034732,-0.005538136,0.0029583578,-0.03754335,0.020194815,-0.035253007,-0.041622747,0.026342265,0.0026248968,0.023771705,0.04172215,-0.002349904,-0.013602273,-0.011990952,0.049277235,0.0057909195,-0.010711709,0.00076012267,0.013577527,-0.021750217,0.0063105244,-0.008881719,0.0018905769,-0.01956773,0.0121974945,0.028953647,-0.014445335,-0.0003542535,-0.020738041,0.017571554,0.013968478,0.0074832374,-0.0153996665,0.017037828,-0.0055163135,-0.01462511,-0.033884168,0.046555497,0.015528302,0.01995886,-0.024791349,0.0144665465,0.027982108,-0.03888978,-0.029541133,-0.044751696,-0.024675073,-0.024588272,-0.022521473,0.041048028,-0.0021770354,0.00011659505,-0.018976798,0.03614383,-0.0057680397,0.023830876,-0.017944323,-0.025471045,-0.02024532,-0.015061346,0.0033763566,0.015865803,0.050355107,0.008669804,0.013181941,-0.01695463,0.00813532,-0.00545517,0.020044211,0.034707665,-0.026894463,-0.022935478,-0.0012593097,-0.003752728,-0.022921037,0.03984814,-0.022305474,0.017055986,0.017449824,0.008763252,0.020226907,-0.0182033,-0.0050772657,0.01886673,0.0033108124,0.009128782,-0.0027548184,0.00054444844,-0.021356033,0.022801174,-0.008786279,-0.012120217,0.027789216,-0.020534333,-0.019466529,0.025493195,-0.060903706,-0.027263597,-0.017240819,0.012223756,0.012422643,0.014563722,-0.014025156,-0.012794488,0.008606344,0.0031766286,0.02231264,-0.011598761,0.0038892953,-0.0025072044,0.03463882,-0.0019672706,-0.01387582,-0.0058652284,0.025427723,0.023665218,0.0025365276,0.036781162,-0.014184062,0.029389035,-0.057659514,-0.032372005,-0.04814723,0.006698256,-0.034963377,0.021484807,0.02209865,0.0077516558,-0.028468274,0.017862627,0.025885215,0.012180474,-0.013650216,0.009709212,-0.010340697,-0.003828865,-0.029323181,-0.027232362,-0.013087637,0.033330504,-0.015486637,0.037474163,0.033875637,-0.03246953,0.0418718,-0.024683163,0.008692375,0.012752512,-0.01090396,-0.0071006,0.010776585,-0.0122396415,0.011094257,0.010134566,0.00016044009,0.030944377,-0.023120543,0.01377695,-0.0077899345,-0.012666253,0.020202981,-0.013635351,0.03848536,0.010211207,0.004399083,-0.029741164,-0.034354985,0.0076200254,0.00094140426,-0.0020198394,-0.021882668,0.03161261,-0.00034412456,-0.028154157,-0.0021348658,-0.022492612,0.03858481,-0.036061928,-0.024218444,0.01202477,-0.00613896,-0.006726259,-0.01566572,0.033521924,0.0022185103,-0.0063885534,0.004655637,-0.023283273,-0.022416117,-0.02951215,0.004122557,-0.007892109,0.0029055285,0.014841403,-0.0039192033,-0.018823164,0.0138696255,0.042212684,-0.005197231,-0.018764377,-0.011071061,0.0063042627,0.04770125,-0.010497088,0.0074719572,0.0015049551,-0.013200041,0.00881476,-0.0015855887,-0.02238941,-0.0061257654,-0.005627718,0.022125516,-0.014640757,-0.021467386,-0.0021687862,0.0036478194,0.016969258,-0.02266182,-0.015741166,-0.015408973,-0.018163987,-0.0002392327,0.020102968,-0.0021366002,-0.00061328924,-0.007480159,-0.0014200871,-0.0069198515,-0.0028080815,-0.015220832,0.011437678,-0.0006378274,-0.013323566,0.030123254,0.015797285,0.026834285,0.04419431,0.032587834,-0.023222959,-0.00034025768,-0.014079093,0.0046857526,-0.01808492,-0.001980707,0.023010809,-0.027903251,0.008513121,0.012628666,0.0025809119,-0.002088257,0.0039265957,0.0066735195,0.0017301523,0.020064112,-0.048530348,0.027857803,0.019289251,0.011647673,-0.022457657,0.014443341,0.028700693,-0.016065985,0.0042738575,-0.012871124,0.028445467,0.014130604,0.029348051,0.003039014,0.02774779,-0.0063918284,0.013446176,0.0021201405,-0.008487891,0.006538752,-0.016020557,-0.009475539,-0.023978256,0.008221974,-0.0020902192,0.0005359979,0.012313957,0.021827092,-0.006588006,-0.0137084685,0.04028243,0.016329568,-0.0011363347,0.022904927,0.018863076,-0.017972456,-0.0007510832,0.006588514,0.0134662455,-0.009809063,-0.013401178,0.0045240866,-0.00956537,0.018241845,-0.021422833,-0.0010267502,-0.00289635,-0.007133643,-0.017814033,-0.017060865,0.02806171,0.0028713434,-0.033844233,-0.012043531,0.011313921,0.033768922,0.01368209,0.0076162806,-0.021440772,-0.0048628575,0.024763882,0.004204164,-0.027047487,0.009003794,0.010398401,-0.02403879,0.005670232,0.017869594,-0.017251786,0.0092842635,-0.011618361,-0.009581907,-0.0002755433,0.03473096,0.008177542,0.0039456664,-0.011171803,0.024252288,0.0011783363,-0.000657186,0.0072459113,-0.021705719,-0.009130691,-0.02995561,-0.034651082,-0.00083639636,-0.018122572,-0.029429566,0.017826185,-0.008481195,0.037784442,0.010587789,-0.023434227,0.011519345,-0.00053836184,0.0048702266,0.007840031,0.013104218,0.068232566,-0.013039503,0.009431776,0.0018511711,0.0094541805,-0.027901284,-0.005815051,0.010332467,-0.019050652,-0.0032200837,-0.0119234715,-0.006778652,0.0083207,0.026913168,0.008415471,0.0052614976,-0.015173454,0.009853354,0.015165068,0.013258264,-0.0032711425,0.002701223,-0.010471716,0.010798812,0.0046816804,-0.022687413,0.005546252,0.034326825,-0.0123100355,-0.0147433765,-0.026800457,-0.011013104,0.025582023,-0.01974204,-0.03706836,0.0037997386,-0.003810066,0.019201078,0.016731296,-0.00352471,0.030544575,0.03088358,-0.009243757,0.010503127,0.023127608,-0.0014192227,-0.0061571994,0.0082404055,-0.028970554,-0.012358754,-0.0021685301,0.02884365,-0.020755595,0.024667803,0.006349765,0.0022376748,-0.039794683,0.024167828,-0.0021507067,0.015279973,0.0033314596,-0.0046550757,0.022810243,0.020663973,-0.016382657,0.02303464,-0.012973084,-0.019472962,0.014221559,0.00950309,-0.015514049,0.01122536,-0.030963432,0.00011404412,0.0068940385,-0.00096813845,-0.024275126,-0.035409767,0.014439035,0.009082747,0.008498731,-0.012826359,0.026419722,0.008531578,0.009318971,-0.04228536,0.030490799,0.0017735371,-0.004037618,-0.011189628,-0.0060426556,0.032163206,0.0007302897,-0.04122508,-0.030001484,-0.015345345,0.06054198,-0.03030229,-0.0040953034,-0.009504324,-0.01670203,-0.030098585,0.009263579,-0.025106106,-0.0004137296,-0.016122015,0.00019980248,-0.03297206,-0.0028595247,0.0023732313,0.009845841,0.008715686,-0.010242942,-0.017469082,0.013545218,0.034496944,0.008190543,-0.0148575725,0.013398665,0.02271916,-0.01752868,-0.012771228,0.01328426,-0.0009918696,0.037504487,-0.025332555,0.011113998,0.0044818665,-2.9574492e-06,-0.03669982,-0.015167083,0.03480064,-0.0102014225,0.032717776,-0.0073434133,-0.003335911,-0.0046425415,0.004592727,0.012225075,0.013379861,-0.038447578,0.006904284,-0.023978116,-0.030770583,0.018374598,0.014605595,0.0032175062,0.006037945,-0.008200893,0.012055672,0.04272968,-0.00030492744,-0.02911222,0.03974012,0.01857777,-0.029744005,-0.027245121,0.005779295,0.018097227,-0.009937123,-0.014029431,-0.0019266349,-0.0014603005,-0.00028854483,0.019753281,0.0025115346,-0.015836935,0.011109778,-0.022324389,-0.032960653,-0.0027895886,0.012904056,0.023982983,0.002762532,-0.012812732,0.00075593486,0.02148294,0.030221991,0.008919172,0.030724924,-0.018288253,-0.03763611,0.0074766115,-0.04699262,0.007938568,-0.022371411,-0.005535436,0.026477875,0.0114578055,0.0054343618,-0.0052833827,-0.04866044,-0.029494287,0.018699776,0.012109972,-0.04086452,0.0053898864,-0.008965855,-0.048133567,-0.017895764,-0.006564663,-0.010811456,0.019821893,-0.0007176921,-0.0031697773,-0.006675193,0.0035921638,-0.0115300715,0.041907076,0.008848459,-0.008955545,-0.019165155,0.051270384,0.00022545987,-0.025827203,0.0093700485,0.029901756,0.0058298185,-0.031546712,0.009730219,0.04469632,-0.011579079,-0.029749129,-0.022032978,0.011433184,-0.010975039,-0.0088797035,0.0018670985,0.0066258432,0.008333256,-0.008340421,-0.017687714,0.0011408816,-0.02008959,0.014764967,-0.019361291,-0.019696813,0.024122614,0.020340761,0.025989324,-0.023901306,-0.020552645,-0.001558439,-0.010481883,0.035458926,-0.020225244,-0.030541671,0.007854103,-0.02897717,0.0336305,0.004212072,0.012264887,-0.02089613,0.017094068,0.002195437,-0.017138882,0.0010571126,0.008478123,0.011865421,0.027019253,-0.012035237,-0.015555442,0.0063274847,0.016492628,0.005790049,0.011749328,0.023752451,-0.0077222795,0.003247299,0.021467892,-0.009366232,0.014692967,0.021637013,0.004509816,-0.009338595,0.021612266,0.010956248,-0.004573334,-0.007936667,-0.009129032,0.009851445,0.00067400205,-0.022516755,0.01917193,-0.026600115,-0.022287771,-0.0043713073,-0.014684931,-0.013397959,0.0064607123,0.0266541,0.026337521,-0.018316083,0.028714163,0.018565431,0.023139779,-0.02197338,0.014525441,-0.027710414,0.009990922,-0.0091096675,-0.01941378,0.0034388434,-0.0061051454,-0.04348583,0.019220836,0.032797057,-0.022577249,-0.025093175,0.017774818,0.021219378,-0.0034455096,0.022341872,0.003868037,-0.034721836,-0.024277948,-0.009448205,0.04495804,-0.035141453,-0.043184575,-0.016597435,-0.0038226985,0.015531035,-0.01771637,-0.0020671287,-0.016434308,0.04449302,-0.028591832,-0.01767189,0.0008499603,0.0058097835,-0.016589671,-0.0029210332,-0.024338098,-0.0267555,-0.0012083132,-0.0006108589,-0.021624627,-0.031553093,-0.0159007,0.0049000317,0.02213871,0.02347469,0.016060572,0.031150557,0.00020196244,-0.020156844,-0.013119703,0.018991113,-8.761725e-05,-0.0008127755,-0.009175092,-0.0081901075,-0.014622797,0.020788712,-0.014424454,-0.011255027,0.009161296,-0.0022244367,-0.010303642,0.03425754,0.0033978906,-0.0026645656,0.012340846,0.0155304205,0.0072258553,-0.01017363,0.017582193,-0.024561802,0.003568018,0.04780643,-0.009296173,-0.040330153,0.002119711,-0.0051860176,-0.02261644,-0.006686554,0.037874993,-0.011010803,0.0061914674,-0.0017432595,0.038998913,0.016638376,-0.015474328,-0.025256421,-0.026368128,0.004214032,0.0114254095,0.007038938,0.011486602,-0.032660365,0.010345188,-0.027909378,-0.0038914473,-0.013175248,0.00928196,0.01897263,-0.022424938,0.015928015,0.020486189,0.06514308,-0.026352158,-0.012034719,-0.032654002,-0.00041267378,-0.04433009,0.024969956,0.004643545,0.004014367,0.0135170035,-0.019455055,-0.009442955,0.019156786,0.0012915635,0.0069936523,-0.0021441225,-0.012794571,-0.016662057,-0.010113999,0.030556727,-0.012205535,0.026084255,0.0032252776,0.023597691,-0.017628375,0.0009467979,0.018059384,-0.017319141,-0.019207872,0.018216852,0.032369543,-0.002744933,0.015635172,0.023466786,0.0074598175,0.0064193346,0.018450653,-0.006869334,0.010848055,-0.015897293,-0.020748628,0.030951336,-0.023236934,0.009249363,0.029690672,0.0015248964,-0.006987848,0.015448163,-0.018607844,0.02298657,0.031541992,0.013429106,0.017807685,0.03413122,0.0053413105,-0.009131292,-0.028460186,-0.0032896781,0.019774185,-0.015378136,-0.03662061,0.043999843,0.027384127,-0.009092522,0.0017271158,0.024073364,-0.007705582,0.042892665,-0.04905383,0.0152355395,0.012584393,0.0039011317,0.009827283,0.0269478,-0.025795698,0.006005405,-0.04375448,0.008097532,0.02184615,0.028802704,-0.004780637,0.017967755,0.007866634,0.00334233,-0.007359514,0.0085154865,-0.0028779767,-0.03237078,-0.005766504,-0.011431701,-0.005249802,-0.0046030977,-0.008740974,0.0018001345,-0.0074090436,-0.018218674,0.017151063,0.0018227265,0.0109140035,0.010678392,-0.024671413,0.0015786868,0.031809412,0.009761643,0.022970391,0.027018944,0.013006988,-0.009657005,0.009791891,0.039186936,-0.051649854,-0.030842442,0.0165433,-0.014967948,-0.015829979,-0.0010334082,0.017027073,0.026413089,0.027352653,-0.00042613057,0.0078143785,-0.0023911677,0.004668304,0.019040605,-0.004649195,-0.007479005,0.04479568,-0.0068705585,-0.0076929163,-0.046819568,0.02204752,0.02023078,-0.0096579995,0.012632654,-0.031069852,-0.0040072585,0.020403327,0.0017991018,0.012289179,-0.02262916,-0.023344913,0.00087204657,-0.0076088165,0.040636454,-0.010899757,-0.029515667,0.006993184,-0.008405494,0.005231158,-0.030580066,-0.005320328,0.0084261075,0.000570629,-0.0073126876,0.018461358,0.0045870044,0.056616105,-0.018559959,-0.0021233908,0.020841556,0.003158163,0.00047697104,-0.0051070335,-0.004667418,-0.026218232,0.028021649,-0.02458899,-0.02188264,0.04180034,0.0025743877,-0.00042599172,0.0227727,0.0016147002,0.0065161358,-0.0060948883,0.013410188,2.4508081e-05,-0.020204905,0.024145933,0.013786027,2.9939827e-05,-0.016974056,-0.03337483,0.019253701,-0.016517727,-0.009102114,0.000581568,-0.038451366,-0.020522358,0.03640393,-0.01459188,-0.026314821,0.00049431954,-0.020901116,-0.0055393223,0.0027131238,0.007524265,-0.0056906985,-0.007016529,0.02498593,0.009810982,0.022495868,-0.01169718,-0.009632542,-0.015705416,0.014779644,-0.038841203,-0.026906718,0.010743908,-0.031514496,0.021204732,-0.0038019326,-0.005269144,-0.01212857,-0.04214718,0.0094853975,-0.0072099194,0.013837081,-0.005421846,-0.007122633,0.01137939,-0.0015634913,-0.008724957,0.010428231,0.0017322048,0.012989153,-0.02160338,0.018967327,-0.028058127,0.0019049334,0.012366068,0.018875686,0.034406498,0.00629495,0.03224435,0.04209112,0.0067365225,-0.0020072542,0.007872349,-0.031978577,0.011779179,-0.036108587,-0.005031046,0.007706306,-0.0031230184,0.012165479,-0.004768999,0.019898333,-0.04306672,-0.029038353,-0.004297955,-0.019331735,-0.015733918,0.011109672,0.022008093,0.015009312,-0.0053124432,0.040377602,0.018062428,-0.017820902,-0.003221119,0.012429792,0.028969364,-0.0060952557,0.006564573,0.014894757,0.0035905282,-0.038788125,-0.018109424,-0.029402327,-0.00042196282,-0.023060963,-0.015726091,0.02333191,-0.0072183805,-0.0061553195,0.010887796,0.017624913,0.0037257227,0.012345701,-0.0013128076,-0.011579132,0.0019298913,0.012034976,-0.020804698,-0.024943663,-0.006833081,-0.0038632574,-0.032784287,0.0074368934,0.014887302,0.027889425,0.01898399,0.00401196,-0.015531644,-0.012657027,-0.021083854,-0.0014707834,-0.0040956624,-0.025025602,0.0071989754,0.020844713,-0.023311803,0.02632922,0.011658275,0.04140709,-0.00017747699,-0.03702862,-0.002316529,0.0032648947,0.04468538,-0.006906409,-0.010533507,0.004476478,-0.0009233782,-0.012212785,0.027561836,0.010975932,0.004067399,-0.024224158,-0.0072436286,-0.009152237,-0.0072757546,0.029018681,0.00035976063,-0.014273797,-0.0009943171,-0.03546474,-0.04034728,-0.02622074,-0.016143084,-0.027040565,-0.01689713,-0.0009272707,-0.022669379,0.020709692,-0.022120588,-0.022054603,-0.050766177,-0.0072950395,0.026195295,0.00965087,0.015682673,-0.043189336,0.022436436,-0.018309893,0.02873666,-0.0030048292,-0.009265798,0.010589317,-0.024892788,0.014510168,-0.025807118,-0.0029707714,-0.019754382,0.006884571,0.0048716455,-0.0076335142,-0.022030704,0.007242216,-0.0053959624,0.0162949,-0.0050248913,-0.0011292725,-0.0009150183,-0.011223167,0.00038990082,-0.027804961,-0.0062553133,-0.00079858996,-0.0007642462,0.0033556682,0.012083532,-0.00583905,-0.03316594,-0.012582775,-0.0035070595,-0.002439141,-0.0064058723,0.0035421255,0.015081846,0.01269456,0.024530608,0.022138398,0.008823023,-0.016913287,-0.010407133,0.040098775,0.02676654,0.033914644,0.018695204,0.001502096,0.012621049,0.0050260616,0.013960515,-0.0024793958,0.0069002006,0.028330553,0.026620723,0.023966216,0.0083863055,0.02544791,0.012117349,-0.013527134,-0.011045021,0.0007966869,0.0059196525,-0.0070238593,-0.016960585,0.011407754,0.00460437,-0.018829431,0.004749522,-0.0038777872,-0.041684847,-0.03103168,0.0041921535,0.0052315444,-0.0042784247,-0.0105194105,-0.008252249,0.022514598,-0.0024049468,-0.0037386825,0.02208721,-0.04758916,0.025343647,0.009176391,0.016153255,-0.0005566114,0.011743809,0.033349343,0.01638561,-0.040597335,-0.0001291277,0.019497778,0.021523582,-0.03634662,0.0143991355,-0.014547505,0.0099594435,0.022779021,0.037034426,-0.011482749,0.009335556,0.004776742,0.017699376,0.022154083,-0.029795032,0.008712831,0.0021256912,-0.013198266,0.0039735325,0.015014414,0.03688036,0.010594521,-0.025447706,-0.023050625,-0.01743911,0.00851915,-0.01756308,0.016153457,-0.014842503,0.009599317,-0.005335633,-0.0023551509,0.0037226032,0.007997945,-0.023680178,-0.047673356,-0.033188466,0.0018292655,-0.0003372096,-0.013293735,-0.026071716,-0.0064360593,0.0024331142,0.011508825,-0.010083831,-0.003143134,0.008729002,0.008525453,-0.03316263,0.0033331092,-0.026400818,-0.050445016,0.0052639237,-0.026296597,-0.018231185,0.026987953,0.027462427,-0.0056554936,0.049628988,-0.024260998,-0.0014768739,0.031067448,-0.0034304317,-0.020775987,0.021034116,0.016741594,-0.008038318,-0.046149157,0.00013821866,0.028454134,-0.015680237,0.005404622,0.012766963,0.027042644,0.03699348,-0.007809924,0.022685463,-0.039355624,-0.02429901,0.018348144,-0.0073356186,-0.015115159,-0.0069002234,-0.054463323,0.0064641316,-0.009734596,-0.032225613,-0.019799933,-0.0038321808,-0.011813447,-0.0042914595,-0.0032771837,-0.008371809,-0.006199547,-0.0070745484,-0.0006234032,-0.0028979268,-0.013809393,0.012757593,-0.00089861004,0.011896636,-0.030635912,-0.008584339,-0.011597748,-0.029175011,0.021185454,0.016548697,-0.013826589,-0.026361423,0.004962428,-0.004385318,-0.025502956,-0.0070474152,-0.0121515915,0.0025287734,0.034385163,0.008645544,-0.01956097,0.007096263,-0.010897492,-0.0095161125,0.008577017,0.00080138736,0.031093601,0.00046236077,0.010015531,-0.0012149347,-0.03559282,0.03176611,0.009428101,-0.01371977,-0.0015811707,-0.008069905,0.019912325,0.023844419,-0.04556066,0.003472693,0.008772142,-0.03290412,0.0029154825,0.02799689,-0.016763257,-0.041685812,-0.019124683,-0.029946085,-0.007115806,0.0037271045,-0.007918687,0.0065283123,0.008565672,-0.025848439,-0.019669147,-0.011144125,0.026125975,0.007838553,0.012171545,-0.016780613,0.0046117776,-0.0062717926,0.009822836,0.0020222622,-0.00446524,-0.011175886,-0.010754584,0.0027272971,-0.007816528,0.031938724,0.0007929651,-0.022740314,0.008017436,0.0019703775,-0.032478668,0.013852747,0.009671654,-0.0058968584,-0.008122079,0.012823456,0.0100639565,0.027965177,0.0069890427,-0.007990639,-0.010553487,-0.019360926,-0.03174913,0.0042923237,0.019552018,-0.006091087,0.0074963365,-0.010954984,-0.027962755,0.0060788114,-0.008880124,0.0021867522,-0.014204981,-0.014930899,0.023393832,-0.009233235,0.0021837938,0.011182014,0.02262341,-0.0179453,-0.02672681,-0.0027992306,-0.028249964,-0.01274691,-0.022317277,0.012778262,-0.006500912,0.0027793779,-0.016243672,-0.0022268174,0.0224488,-0.0052257106,-0.004601208,0.0012553238,0.020235656,0.026644194,0.008809621,0.03482308,-0.02485302,0.025697729,0.02766356,0.022915525,0.008734563,-0.03525049,-0.01237805,0.0046779905,-0.00715927,0.03429018,0.01882004,-0.017702777,0.0038459871,-0.01870176,0.0001571841,-0.0071366527,-0.026718928,-0.0016473134,-0.012182814,-0.011434495,-0.030688467,-0.0076967916,-0.028151877,-0.013972883,-0.010521123,0.011104291,-0.0013730563,0.007274913,-0.017637374,-0.019436663,0.0046913116,-0.022826746,-0.012201886,-0.015653916,0.017864393,-0.020431262,0.037330728,-0.0014790251,-0.018790558,-0.011404642,0.006161083,0.041565273,0.033859786,0.0061378293,-0.020955328,0.02174415,0.007813012,-0.0037736238,0.008184105,0.030075833,0.0064136037,-0.004659893,0.006385666,-0.026483541,0.000301772,-0.034145128,0.009498547,-0.019888913,-0.025814202,-0.013008882,-0.037218805,0.0069329366,-0.014789702,6.221071e-05,-0.016153762,-0.039265428,0.029364532,-0.0399849,0.0025985711,0.014106096,-0.0073142597,-0.0038969354,0.003295791,0.03632401,-0.0014085062,0.01037962,-0.022066377,-0.0029793996,0.029387658,0.016187767,-0.002851046,-0.011517511,0.019347157,-0.0027748244,0.0038541218,0.030014543,-0.015243439,0.002330392,0.021443972,-0.030674815,-0.0049938885,0.028225506,0.011256226,-0.0022395684,-0.0074224724,-0.02512694,-0.006091018,-0.012968855,0.00549689,-0.012012146,-0.00439793,-0.015063986,0.011888775,-0.022017507,0.0039008819,0.0017843302,0.003264593,0.0077943006,0.015907483,-0.023742272,0.0014119382,0.0074287835,-0.001036859,-0.0040441095,0.00086214184,0.021300623,0.007221447,-0.029896377,0.0044047902,-0.00023779541,-0.0339963,-0.0038180705,-0.04247016,-0.008192977,0.010450159,-0.0029777002,-0.017791543,0.018503921,0.033303294,-0.0060617914,-0.0051990803,0.015905308,0.008124337,0.031166932,0.021655468,0.014150701,-0.010197931,0.0038984306,-0.009460078,-0.023748482,0.0122810025,0.031660102,0.016956165,0.0024541104,0.04256339,0.0071448716,0.009472911,-0.026514364,-0.012154771,-0.0025821908,0.0019772018,-0.023286803,0.009647991,0.008468954,0.0105430195,0.010289543,0.018524466,0.005793788,0.012413176,-0.008339459,-0.00939645,0.004010828,0.0106833875,-0.00844111,0.004028038,0.0029394913,-0.0032140482,-0.025845567,-0.00032363113,0.00218673,0.043698173,0.029181,-0.00878905,-0.032847058,0.030235527,0.006577545,0.010898463,-0.014088009,0.009487763,0.020590158,0.006100509,-0.001692574,0.008656821,-0.00926158,-0.01229674,-0.01004327,0.0040507456,-0.020722773,0.002574318,0.014271454,-0.01424777,0.008455001,0.011984798,-0.010780983,-0.004040666,-0.009750502,-0.010507367,0.0193458,-0.0062873997,0.0015370351,0.015893638,-0.03013931,0.012049747,0.0039438033,-0.0032220222,0.026688242,-0.014899762,0.003066757,0.00021333693,0.0037476171,-0.03905065,0.01278285,0.013598366,-0.013899592,-0.023534419,0.019796694,0.0020167504,-0.0021973725,0.0018853506,-0.008718533,0.025278045,-0.009882118,-0.018341338,-0.01854062,0.016630733,-0.019279245,0.0022685123,-0.02945781,-0.008221306,0.011255,0.0031481846,0.02998244,0.021352405,0.006028142,0.027929766,-0.012488304,0.01156762,0.0050712973,-0.017294718,-0.0012906917,-0.016342713,0.00058989157,-0.0006366425,-0.0051192287,-0.0060791774,-0.009041613,0.026577607,0.011483906,0.02863288,0.0219844,0.020507451,-0.0021226008,0.010418702,0.002145922,0.007920271,0.020735355,-0.019818086,-0.007686162,0.010561854,-0.012443867,0.009989577,0.01467792,-0.018394815,0.010853789,0.016918318,-0.019021899,0.0019535802,-0.015012047,-0.00021202222,0.016856616,0.00061130465,0.011879789,-0.013193331,-0.0055983695,-0.015984423,-0.018626453,0.05119695,0.011892907,-0.052834824,0.019444052,-0.0015966045,-0.0025541177,-0.007523563,6.690198e-05,-0.00632064,0.030231675,0.002080079,0.0007964901,-0.008674522,0.018926851,-0.022483109,-0.0012370156,0.018643033,-0.0040134923,-0.0041468088,0.016692612,0.0047018966,-0.020420542,0.021785792,-0.0009862904,-0.004770278,0.009042245,0.020937603,0.010605702,0.014061268,-0.011267066,0.0055088615,-0.006032583,0.016248288,0.0027387403,0.008379737,0.007550378,-0.00044807335,-0.019282563,-0.012168031,-0.017692864,0.034626294,0.027611721,0.006208511,-0.00746815,0.020847004,-0.008084168,-0.028331712,-0.011775997,0.030306013,-0.008762579,0.005082877,-0.010588805,-0.034134354,0.019604687,-0.0034432337,-0.00010114479,0.009743227,0.004045527,0.016685935,0.01784942,0.009972822,-0.005730217,-0.015267829,0.0044166055,-0.033267684,0.014155919,0.0043972074,0.0019638285,0.0023008462,0.02792869,0.009335816,0.012971803,0.013413559,0.006799286,-0.02235107,-0.02220177,0.01767513,0.010613913,0.013051877,0.019421563,-0.0003353937,-0.019927694,-0.014888054,-0.013400868,-0.0061439634,0.012132696,0.009862187,-0.0073171463,0.009993303,-0.010985007,-0.0067201233,-0.026538517,0.015750136,-0.00013355006,-0.016992161,0.012103095,-0.0047502345,0.0044317255,0.019926919,-0.0024960516,0.022340592,-0.03151791,0.023126142,0.01097051,-0.017855832,0.0014118769,0.008323097,0.025926815,0.0029480641,0.0041475366,-0.013211286,-0.01763575,0.0014499307,0.0039267372,-0.023294497,-0.0063269436,-0.014777471,0.0015790195,0.014638143,-0.0036158059,-0.0070197023,0.01144381,-0.0003977513,-0.005763912,-0.02516165,-0.010209644,-0.0020647615,-0.00026053484,-0.033568002,0.0025472518,-0.018129094,0.013226278,-0.008911643,-0.002223027,-0.01205678,0.008293359,-0.021834893] +7 Parquet files are columnar [-0.023682063,-0.032833345,-0.06902501,0.024668073,-5.6846984e-05,-0.027005594,-0.011906388,-0.00868068,0.016971627,0.015536214,0.014307582,0.025496792,-0.030030364,0.0557373,0.016060695,0.03743481,0.024215262,0.006062146,0.038586326,-0.004827157,-0.018988023,-0.035866573,-0.00946166,0.0038454772,-0.007985372,0.04209261,-0.016676562,-0.025152417,-0.028467793,0.0059374124,0.017330116,0.028832724,0.061656725,-0.006291398,-0.00080972293,-0.032750934,0.015527241,-0.027630873,-0.027298953,0.00527458,0.01956522,-0.001469471,0.0055297734,-0.0773329,-0.013313535,-0.0021877338,-0.01965962,-0.026466098,-0.011141945,0.012168391,0.0071664397,0.0069124764,0.04142341,-0.033512063,-0.026160514,0.029886292,-0.020526825,-0.019003768,-0.034620956,0.0254125,0.0009920985,0.003718527,-0.025663653,-0.00618431,-0.010279452,0.07276463,0.0031461732,0.03069169,0.0017647375,-0.012892743,0.008499622,-0.021745369,0.011919635,-0.029969662,-0.08687667,-0.02714352,0.02179988,-0.047233578,-0.04838182,-0.016019199,0.038621403,-0.01267664,0.007870654,0.023762451,-0.013914475,0.027331661,0.000560119,0.046828747,-0.0042801313,0.0027596971,0.00061443204,0.045857783,0.051499568,-0.0072739483,-0.04855564,0.008917735,-0.049384654,0.0042007286,0.023901466,0.0342614,0.025548361,0.028670695,0.02554758,-0.000953824,0.027414171,-0.03397028,0.02230361,0.018535053,0.007702856,-0.0047025513,0.028292282,0.0137791345,0.022568574,-0.022033265,0.0036700347,-0.0021001322,0.014824926,-0.028489348,-0.008889479,0.030294817,-0.031967014,-0.026566403,0.07463765,-0.037023973,-0.04292661,-0.02654268,0.02964507,0.042210847,0.036287017,0.015267381,-0.00462466,0.025214357,-0.01932325,-0.0030732397,0.0009211581,-0.039331213,-0.017532675,0.044611163,0.010375,-0.03347621,0.032275993,0.0049839322,-0.016070304,-0.016795846,-0.0023471662,-0.026718939,-0.011605472,0.019855972,0.03231699,-0.0032354826,-0.018169357,0.014863632,0.007793162,0.05207446,0.064357616,-0.05108204,-0.014145387,0.02684028,0.0250514,0.03128539,0.028004352,0.030098965,0.009306542,-0.035414208,-0.021305727,-0.0016249481,0.065698855,-0.0040404666,0.015009681,-0.007609286,-0.012744594,0.011725766,-0.00023135514,-0.0026209077,0.035688233,-0.0043431157,-0.014560665,0.0017928284,0.012210505,-0.03649759,0.038662244,0.014448862,0.0049803597,0.009077265,-0.042879447,-0.029904746,-0.036889814,0.0012130373,0.025462802,0.005872985,-0.030756941,-0.0004599617,0.020005055,0.014124864,0.00899751,0.022933897,-0.078656234,-0.012834629,-0.020192822,-0.004135302,-0.0066263773,0.021732507,-0.023764785,0.012004045,-0.017691448,-0.0061396626,-0.07454238,-0.04582841,0.036749512,-0.034769516,-0.0005272502,0.0416026,-0.0031678246,0.024787048,-0.012073827,-0.011845352,0.035777535,-0.009231664,0.014398096,-0.061313625,-0.038602754,0.0014585495,-0.010957197,-0.0026848055,0.04733127,-0.002454718,-0.011382102,0.01966283,0.007137198,-0.049585808,-0.03030696,0.017087575,0.011670621,-0.01849997,0.09071894,-0.0053674607,0.012406884,0.04866129,0.027267411,-0.015740842,-0.029233187,-0.016783807,0.003981133,-0.017008416,0.023865921,-0.039786406,-0.0029470911,0.007965769,0.029406752,0.014737654,0.0012950613,0.009984163,0.015502748,0.021558171,0.011801459,0.017163824,0.0026969742,0.007848569,0.014791643,-0.0080638,-0.008244736,0.0060582934,-0.0023763257,0.025160896,-0.0064100632,0.016623965,-0.019332746,0.0050195623,-0.01838592,-0.056396082,-0.0025425549,0.0023340194,0.01412592,0.013517352,0.00309592,0.035770234,-0.010109058,-0.0013432603,-0.025506778,0.012270868,0.0048505124,-0.007330199,-0.0074802707,-0.016843863,-0.021383146,0.027314685,0.007056597,-0.05502256,-0.027414434,0.0055257343,0.05215493,0.0390842,0.049887538,0.04398868,-0.0070315627,0.013581056,-0.029059649,-0.13190313,-0.007114852,-0.017509207,0.0023433943,0.031149736,0.0041142697,-0.015284662,0.006328854,-0.025792418,-0.006053831,-0.0044684815,-0.06642822,-0.042901505,-0.03117791,0.034019653,0.01681015,0.01567192,0.010437482,0.005098199,-0.011562103,-0.042185765,-0.027658263,0.010629246,0.017148469,0.0006590653,0.02005569,0.017941564,-0.010210686,-0.030533718,-0.032241356,0.01878194,-0.007966179,-0.008942613,0.0320518,-0.037173998,0.036914833,0.041904807,0.026586052,0.0083040465,-0.010665988,0.027990602,0.05173235,0.025648544,0.031218506,-0.020338159,-0.059917543,0.024455853,0.00072036346,-0.034427695,0.014688915,-0.01656644,0.04914453,-0.005481048,-0.036187,-0.037066508,-0.05346375,-0.007847893,0.018155312,0.010198821,0.02307635,-0.003657166,-0.029951163,0.017410742,0.014358619,0.0076794275,-0.011192293,0.02654397,-0.032312658,0.016699515,-0.005726064,-0.015400773,0.02046287,-0.0018482052,-0.018874567,-0.0051316265,0.021257918,0.026389064,0.024616074,-0.03832372,-0.113317,-0.036567062,0.016976384,-0.03462731,-0.02595934,-0.037783705,-0.021520108,0.03757063,0.040188935,0.030143956,0.2279303,-0.018853758,0.0039021587,0.040537868,0.09510006,-0.027883328,0.08435943,0.044332337,-0.054122638,-0.03255944,-0.030142838,0.026195018,0.033496507,0.019100863,0.0143314535,0.04196023,-0.008615214,-0.02397133,0.08282248,0.0070879254,0.0020147192,0.026901776,0.060292915,0.014989542,0.021581514,-0.047779184,-0.0013063691,0.04831642,-0.0039483174,-0.03556401,-0.047434587,0.0027187828,0.0079496885,0.012043267,-0.049138587,-0.038447544,0.047617353,-0.0043477877,-0.06352378,0.04364959,-0.022984922,-0.0195265,-0.067608,-0.0013877839,-0.0092861485,-0.04264079,-0.0094948495,0.008073185,0.033739243,-0.03032079,-0.018159553,-0.033451766,-0.04872257,0.011914207,0.012238902,-0.027129322,-0.026139341,-0.02517782,0.010871397,0.06685563,0.04530045,-0.004323509,-0.036327135,0.0019876594,-0.018814556,0.0036175272,-0.012549762,-0.024421737,0.0052927732,0.045507807,0.049143292,-0.04086935,0.010491315,-0.015446589,0.058292296,0.008753295,-0.035547864,0.0458262,-0.04397264,0.06357639,-0.005361289,0.008650358,-0.00390362,0.028210841,0.021413717,-0.00020875665,-0.058363434,0.008645615,-0.033664767,-0.025219234,-0.00031167493,-0.025501704,-0.000981706,-0.012040178,-0.013568784,0.013202248,-0.016094971,-0.013555161,-0.050441526,-0.037776273,0.02811247,0.0022847722,-0.00924574,-0.12106013,0.019667236,0.00673789,0.028532984,0.008596639,-0.025836227,0.003408281,-0.016491646,0.026626065,-0.0046473565,0.020375362,0.040819064,0.022746118,0.0036179794,-0.015091212,0.033887602,0.06678785,-0.06905154,-0.011709049,0.02421409,-0.021073284,0.042658802,-0.0259564,0.041147076,-0.012667479,-0.07550508,0.052898757,0.019556688,-0.015048094,-0.056728285,-0.018221814,0.006914431,-0.013639853,0.045968667,0.029246189,-0.062100224,0.017605565,-0.045642212,0.015668549,-7.674889e-05,-0.012181486,-0.019170353,-0.00502381,-0.012107258,-0.019123562,-0.035721127,0.02256079,0.017736828,0.013132696,-0.041411445,-0.04319524,0.023485107,0.006276855,-0.027008895,0.04105709,0.012202731,-0.003805354,0.0018943858,0.032570623,0.04490152,0.0037049002,-0.017666344,0.053548403,-0.052020643,0.0015652848,0.005856539,-0.032407783,-0.011370952,-0.014774455,-0.01316012,0.09026252,0.0793829,0.006290707,-0.0081090685,-0.052456398,-0.038368173,-0.025061328,0.07354199,0.05017542,-0.02541594,-0.009178533,0.01805838,0.040807877,-0.035695452,-0.026372958,-0.036952063,-0.045834705,-0.023766845,0.07413627,0.02987637,-0.04238826,-0.03550062,0.022359,0.0027142875,-0.0061770794,-0.00612196,-0.035536416,-0.005238753,-0.03610674,0.017153166,0.02245568,-0.014013361,0.008749535,0.007631384,0.024087647,0.009272416,-0.023753988,0.011186803,-0.002665063,0.025404233,0.011478065,0.008815735,0.013953683,0.043569144,0.019810408,0.03241294,-0.022385696,0.038591556,-0.013618819,0.027447017,-0.045915645,-0.028645104,-0.024929,-0.034351423,-0.017597657,0.0050640316,-0.024977904,0.014588099,-0.0237862,-0.056655437,0.011181819,0.007623063,-0.004871036,0.02913209,0.030134667,0.014165294,0.03884541,0.022091843,-0.014974394,0.052908856,0.07259117,0.024654752,0.056528237,0.024019338,-0.040724684,-0.025192942,0.08399433,0.0055880337,0.012681264,0.00827939,-0.030884687,0.0265368,0.011969817,-0.05412115,0.014511466,-0.07390572,-0.036219988,0.011191022,0.05818688,-0.0043367175,-0.012587183,-0.03167597,0.0012118869,0.030908708,-0.04257112,-0.0126098255,-0.06580541,-0.037839446,-0.0013019363,-0.042823996,-0.04011075,0.05715569,-0.007466242,-0.027439522,0.030050853,0.036740854,-0.0073207538,-0.009470894,0.0128318565,-0.01118021,-0.011868015,-0.021125833,0.0055886973,-0.0073861275,-0.0024080062,-0.014293175,0.026110508,0.010554324,-0.0017423546,-0.026980221,-0.0068668933,-0.044912443,0.021778246,-0.026696483,0.0039058789,-0.03154097,0.009161911,-0.0247428,0.003866152,-0.03864716,-0.03151714,-0.012648065,0.0090537565,0.038393598,0.009380847,-0.03171691,-0.020537939,0.030603252,-0.010296463,0.027299503,-0.04883778,0.021866502,-0.009696584,0.012707092,0.00650771,-0.008711661,0.012864064,-0.023980526,0.0062279897,-0.018958583,-0.0020278734,-0.017297173,0.014408769,-0.026632527,-0.03075069,-0.043155648,-0.025711933,-0.03201647,-0.044044886,0.017464988,0.044430245,0.07022969,0.033866007,0.012309565,-0.03571073,0.034195404,-0.014842167,-0.000713859,-0.02022521,0.0035737585,-0.016987422,0.050561916,-0.045789484,0.0060196863,-0.028041655,-0.007939881,-0.014750375,-0.0063648326,-0.036483474,-0.023240447,-0.047029592,0.00014322175,-0.003615268,0.05987179,-0.019370086,0.001475926,-0.0014909299,-0.030592434,0.02168837,-0.050663624,0.013096564,0.041340843,-0.00996734,0.011762199,-0.06698224,-0.0058962773,-0.030669456,0.0031369901,-0.0018546057,-0.0393429,0.03419587,0.017374825,0.014171771,-0.009498996,-0.028023425,0.019126015,-0.016827501,-0.008927361,-0.0055986396,-0.008746208,-0.01878373,0.07353666,-0.03403903,-0.03929451,-0.01223537,-0.003279992,0.042940993,0.029657174,-0.009360198,-0.029353255,-0.022437634,-0.13486932,-0.012394393,0.015279345,-0.02464415,-0.02327625,-0.010412412,0.010803587,-0.05813773,0.023267375,-0.037948836,-0.0016754126,0.0015159238,-0.03584643,-0.027460098,-0.04101444,0.009351305,0.05975233,-0.020180132,-0.0034001856,0.08846648,-0.014614055,0.027759515,-0.027739715,0.004523402,-0.027724234,0.029306194,-0.004636497,0.0142489495,-0.0729046,0.021516891,0.018042838,-0.053163085,0.024714964,-0.0038035235,0.010212715,-0.007428017,-0.018650427,-0.052607212,0.016272249,0.0064989612,-0.0017307756,0.0197016,-0.026193168,0.050560247,-0.018734058,-0.01616509,0.0012285621,-0.00081842707,-0.044418316,0.00407345,0.021889823,-0.0077233375,-0.014105171,-0.00574882,0.031020869,0.011432396,-0.046487805,0.039846655,0.021399494,0.015267342,-0.032863885,-0.0072455225,0.0066952864,-0.080467485,-0.01934462,-0.010760726,0.01443086,-0.013857748,0.01868496,0.062716395,-0.024182023,-0.012236874,0.02668184,-0.0309128,-0.019682951,0.022462016,0.006934234,-0.006376601,0.042934146,0.0066588493,-0.009605296,-0.009132193,-0.061055478,-0.004988638,0.04778157,-0.026937088,-0.011328893,-0.0042365193,-0.046148162,0.00863427,-0.03099509,-0.018575938,-0.01043668,0.008476128,-0.002371401,-0.0101667,-0.03390354,-0.0031227407,-0.02673273,-0.057721242,-0.020053677,-0.01705741,-0.010232316,0.013613898,-0.04244278,0.048077352,0.077949025,-0.030870287,0.025955813,-0.0099028805,0.0052695805,-0.0046954323,-0.0016438552,0.038586285,-0.04603323,-0.003130868,0.032199398,0.010297489,0.0019368882,0.034510385,0.032938134,-0.0020173148,0.015418896,-0.045787476,0.043983407,0.0051296037,0.014680091,-0.013486454,0.00021723616,0.016824024,0.032956466,-0.009185487,-0.0022343297,0.02551308,0.05542036,0.04512882,-0.017734203,0.0656224,-0.03147921,-0.026927855,-0.020473959,0.0076135895,-0.010577004,0.0154911,-0.054876756,0.000704769,0.002713066,0.017313845,-0.003299415,-0.010494889,-0.010388335,-0.011131649,0.00011799999,-0.03969246,-0.011806918,-0.012381005,0.052774385,0.010111124,-0.010080211,0.016717678,0.029953279,-0.06679601,-0.0039019731,0.005227935,0.018512947,0.02811758,-0.018183712,-0.034699563,-0.057655957,-0.018408656,-0.05480903,0.002718565,-0.028438224,0.005944639,-0.017748864,-0.020946316,0.013744966,0.021056028,0.057963658,-0.014236668,0.04138635,0.019752992,-0.0076711355,0.02673002,-0.0068712216,0.052163545,-0.009114759,0.038986135,-0.020586338,0.004592078,-0.0033145095,-0.01530686,0.018775057,0.0062373905,0.051148362,0.023222934,0.0026492765,0.016606204,-0.022056108,-0.0015576788,0.0012644454,-0.00949926,-0.059683703,-0.0008094497,-0.04350071,-0.004289652,0.0032832387,-0.003062697,-0.0073310006,0.00918171,0.012968163,0.02923329,-0.04222787,0.03343999,-0.010142357,0.025301494,-0.019630603,0.02957513,-0.0044086147,0.012422029,-0.04873233,0.027360134,-0.02889857,-0.021410609,-0.040436417,0.010440795,-0.019710703,0.002523362,0.049546268,0.030501459,-0.008156021,-0.009847684,0.0046576415,0.047701333,0.022497224,-0.01020545,-0.020459682,0.026257947,-0.02917449,0.0109072905,0.03921631,-0.0007065528,-0.03275592,0.042810474] [-0.0004994617,-0.057172082,-0.0012671864,0.026145807,-0.0022746283,0.01386885,0.044545766,0.006237679,0.015314917,0.007909899,-0.016880086,0.044878054,0.0016722775,-0.041267663,0.020395163,-0.0049547763,0.0036795754,-0.0018439543,0.0020703305,-0.007000754,-0.013879582,0.04160804,0.0070426846,0.013729516,-0.0893799,-0.025377154,-0.02304588,-0.043769088,-0.00071786315,-0.00263831,0.036977265,-0.056683358,0.026815534,-0.015989752,0.0058235,-0.015912848,-0.018319447,-0.010376105,0.030871434,-0.013977889,0.021521706,-0.035388388,0.0047875014,0.028974535,0.011653587,-0.008279291,0.019193543,-0.0520818,-0.007102405,-0.0196273,0.009249456,0.01406019,-0.014485639,-0.01672993,0.025354493,-0.010259783,0.035118442,0.004952714,-0.0202419,-0.008934827,-0.008543542,-0.027236067,-0.0006533334,-0.021167176,-0.0073312162,-0.021594977,-0.0015327167,-0.04008528,0.01228298,-0.0089098755,0.009646716,0.011703244,-0.006339538,-0.013089547,0.022489738,-0.03568407,-0.0047601024,0.025523154,-0.021229548,0.018328046,0.0018658922,-0.007155553,0.009291849,0.0010592315,0.023182623,0.012895909,0.011162503,0.0027720602,-0.021359416,-0.003970259,0.010940503,0.019408925,0.01294623,0.0053375885,-0.00524371,-0.005666487,-0.015216508,-0.023181403,-0.011642603,0.01029914,0.005414332,-0.00974134,-0.027954191,-0.020964647,0.02920853,-0.021873372,-0.010272712,0.0028485197,0.005843694,0.014221196,0.009461366,-0.001603647,0.020555587,0.023382207,-0.011415319,0.003940242,0.00835504,0.041975282,-0.0036756822,0.0015161289,-0.014005276,0.031580035,0.018414155,-0.012381198,0.0068193246,0.00029132387,-0.0014276515,0.0075337123,0.02523615,0.022973789,0.01422007,-0.020584667,-0.013570646,-0.012314917,0.03766775,0.027195496,-0.012378031,-0.0121569745,-0.014677353,0.0015219095,-0.0012050117,0.0051067793,-0.0039346316,-0.00095527666,-0.017299794,-0.04798516,-0.004369591,-0.029882131,0.0029780557,0.0026233566,0.03521474,-0.005084462,0.0089831725,0.023587117,-0.0034951807,0.005981186,-0.009528958,0.027170995,0.004246827,0.0071180845,3.6709534e-05,0.0098224925,0.011778398,0.023173664,0.00037216285,0.023836702,-0.010142883,0.005558528,-0.017164476,0.00083482696,0.00016734262,-0.026965331,-0.028315684,-0.0015084405,-0.018160999,-0.00036923133,-0.010375185,-0.007295331,-0.019183353,0.026458995,-0.0018972227,0.013684328,0.023052469,0.0005649627,-0.0050253817,-0.005199811,0.002201517,0.026109356,0.010613903,0.024501456,-0.0042679794,0.00051715394,0.003621733,0.019030245,-0.011488028,-0.015058559,-0.01045429,0.019065974,-0.053343598,0.015877208,-0.0018353411,0.007940236,-0.0024228122,-0.023504272,-0.00081613485,-0.010164647,0.00690911,-0.01221248,0.010861583,-0.023676962,0.020514108,-0.035844512,-0.016232865,-0.021714736,-0.014676266,-0.001056718,-0.024800522,-0.0072517553,0.018283853,0.026036117,0.016454922,0.007892819,0.0071565495,0.012015065,-0.021328226,-0.0073359604,-0.016051494,-0.00950281,0.005448319,-0.01837699,0.03154997,0.025335385,-0.0018652063,-0.0055297674,-0.02112662,0.0031733238,-0.021139141,0.0019176941,0.010419002,-0.043886043,-0.0023688362,-0.024645101,-0.0030183664,0.024990015,0.012111339,-0.020730812,0.00699135,-0.0016947085,0.0038062353,-0.017760357,-0.0005583271,0.001649345,0.0017922656,-0.005872639,-0.035200026,-0.010323525,-0.017790362,-0.0024242501,-0.0052762185,-0.046607945,-0.01844648,-0.011059155,-0.019063957,-0.0053429464,-0.012648724,-0.014886411,-0.00036718298,-0.006821594,0.033938333,0.011430964,0.060685914,-0.009499518,0.0076026367,0.009573912,0.04243212,-0.041181866,0.007986947,0.015036383,0.0105451895,0.029554104,-0.01457724,0.0206188,-0.01915737,0.009687898,-0.0111933155,0.0023705962,0.02777837,0.006174201,-0.0035897775,0.015633298,0.020270385,-0.00740379,0.01171278,-0.015141119,-0.03089765,0.0101241255,0.0074104755,-0.041643944,-0.018022075,-0.008179191,0.0008162101,-0.0069477977,0.006336383,-0.028798966,0.0018671236,0.04192059,0.03165183,-0.012616615,-0.008135519,0.030722814,0.0051187444,-0.00034606035,-0.0009150805,-0.006671612,0.04673549,-0.034721345,0.0028625156,0.0045371796,-0.022515666,-0.032845136,0.008654807,-0.041792672,-0.048459698,-0.017382102,0.0014164463,-0.003625168,0.008359384,0.018875534,-0.036139704,0.023005124,0.019622995,0.023284893,-0.07041972,0.0019224896,0.0051462,-0.0066205855,0.004104436,0.033577263,0.02304349,-0.00031522007,0.006646922,0.0133665195,-0.013994064,0.020541772,-0.014605099,0.0016087317,0.020562874,-0.012931589,-0.019354962,-0.004006584,-0.019486722,-0.025344439,0.0149228815,-0.0009083104,0.01085421,0.013511875,0.005065524,-0.0053922264,-0.02365412,-0.037470303,-0.019179706,0.0057746964,0.011841207,0.0170513,-0.04541427,-0.024017647,-0.0048755202,0.03191686,0.016829818,-0.035329647,0.014024976,-0.015046326,0.011518453,-0.021675339,-0.021705626,0.019488508,0.016915165,-0.037255503,-0.015151959,-0.025090959,-0.0054353615,0.02548223,-0.016867286,0.011277002,-0.0026051283,0.013742669,-0.02623311,0.03271045,-0.017750407,-0.00069161394,-0.008479134,-0.006102348,-0.0015179781,0.030178929,0.019102888,0.054603126,0.015989678,0.012137755,-0.0065897624,-0.00954278,-0.015477264,0.01447033,-0.03570728,-0.026492514,0.008971154,-0.01846382,-0.0007213999,0.029709127,0.005054743,-0.015917052,0.026190978,0.0067559276,-0.0055681863,-0.004728136,-0.03255961,-0.006468962,0.028098699,-0.013038738,-0.034407977,-0.0021724396,-0.019846998,-0.010689676,-0.013939087,0.0038304087,0.021308891,0.021118697,-0.009128762,0.015286527,-0.02474545,0.010729594,0.010158356,-0.0008431317,0.031024976,0.035107873,0.024056258,0.016057864,-0.009539499,-0.01612338,-0.014607066,-0.025130726,-0.030762218,-0.029601173,0.0030631663,0.008397515,-0.016492007,0.0070570474,-0.009739377,0.02330149,0.022714904,-0.029611945,-0.03044071,-0.012707612,0.021013914,-0.0039314306,-0.013227965,0.007765817,-0.017174875,0.03714996,-0.01615992,-0.021071557,-0.09592211,0.007878635,0.012874709,-0.004651811,-0.014880894,0.022441931,-0.017475046,-0.02504842,0.05565305,0.00695174,-0.016531412,-0.01789257,0.00354466,-0.014456699,-0.024625722,0.0055087144,0.021099834,-0.02566551,-0.0125419665,-0.020192422,0.034463644,0.01628993,-0.007917597,-0.015645828,0.002216998,0.04890754,0.010293106,0.016555045,0.0157742,0.016495366,0.012930455,0.004523022,-0.016579939,-0.016720919,0.004522111,0.03208654,0.01779911,-0.019156653,-0.013217648,-0.009870626,0.00729707,-0.007889651,-0.013130411,-0.020166188,-0.016312309,0.017387476,0.016397675,-0.030641546,0.015404868,0.0065302677,0.00919805,0.017939664,-0.023927806,-0.058523726,0.024773397,-0.019759072,-0.022395665,-0.028217187,0.0040324884,0.0137311695,-0.016957762,0.0055188043,0.013294315,-0.018156685,-0.04086814,-0.0073470254,0.026495703,-0.0426225,-0.022450013,0.031631697,0.021528706,0.015102848,0.012259626,0.0044321227,-0.010628507,-0.013273815,-0.00272964,0.0023322138,0.043511152,0.007283039,0.0006412035,-0.0038035517,0.008364712,-0.0051972084,-0.009885354,0.029225988,-0.026451182,-0.0037568652,-0.047907874,-0.009713438,0.023233626,-0.0048465314,0.018187905,0.00097264006,0.016816001,0.014565076,-0.005598042,0.020579498,0.035644162,0.0045934683,0.009759093,0.0135371275,-0.015206665,0.03650139,0.0011609029,-0.021677839,-0.020928789,0.030235173,-0.0066817193,-0.02288825,-0.04405941,0.03639599,-0.015637964,0.019732494,0.032456305,0.016721036,-0.027860045,0.015941186,0.0100262165,-0.019760778,-0.0013776282,0.016916968,-0.018123563,-0.021220144,-0.0060089626,-0.032442685,-0.0071514803,0.009942432,0.022559708,0.002120402,0.00042535187,-0.025159193,0.013232798,0.014607909,0.029008918,0.00392631,-0.006436598,-0.017169924,0.0022944068,-0.0067848354,0.039278906,-0.036250625,-0.0030405605,-0.019013632,-0.018833293,0.025285276,0.01728601,0.02160406,-0.014947477,-0.0074389665,0.01721353,0.0053694244,-0.02427637,0.005745711,0.017898085,0.020586755,-0.020149576,-0.019216036,-0.016099812,0.006699284,-0.017880082,-0.032981876,0.015370045,0.016955312,-0.01840099,-0.047420938,-0.015381293,0.0012168413,-0.031428237,-0.004428528,-0.020848135,-0.005645841,0.033003457,-0.025481416,0.029025666,-0.003120445,-0.0105119,-0.001298888,0.005421789,0.0040482855,0.016117191,-0.003290576,0.03746937,-0.0121225,0.0060510673,-0.025604712,-0.011602039,0.030959377,-0.018091025,0.023940459,-0.0075013167,0.0044258125,-0.017741255,0.036495224,0.025939906,-0.008096373,0.011755294,-0.021293089,0.002089248,0.0066261324,-0.0043316227,-0.026265161,-0.00028655486,0.024603317,-0.026902374,0.0029375632,-0.032840487,-0.008760442,0.027872007,-0.0031323705,-0.011538101,-0.0003210651,-0.0035262972,-0.0049381177,0.026031865,-0.039506733,0.021798687,0.011198752,0.02818646,-0.023117593,0.007826536,-0.0083465725,0.027412409,0.008292324,0.012849773,-0.0070279487,-0.008849376,0.012165622,0.041041385,0.048715595,0.014404118,-0.033822164,0.0059103137,0.024844132,0.014830448,0.0047763838,0.0020890662,-0.006806465,-0.049601335,-0.026724953,-0.034929767,-0.009097679,0.03024979,-0.027373435,0.029636245,-0.0008841803,0.023618577,0.003911852,-0.0075572804,0.019393936,-0.007942123,-0.031248206,-0.04976805,-0.0068972283,-0.0035557686,-0.021021295,-0.02542568,-0.020808326,0.005372501,-0.014784504,-0.011567419,0.027277024,-0.031348273,0.009934035,0.020040577,-0.0019358775,0.005263057,0.0025735921,-0.051378574,0.034018356,0.0053010015,-0.01619746,-0.0032901468,0.028966403,-0.006096457,0.009016008,0.018391253,0.014890954,0.026958408,0.006652661,0.0014260232,-0.0043096016,0.00889209,0.0034225618,0.0038661503,0.048354417,-0.04100124,-0.036691435,0.0027518878,0.01145279,0.0040661935,0.00025459222,0.005727606,-0.01986734,-0.00976529,0.029305918,0.008988254,0.010134952,0.025848757,-0.018291032,-0.015918862,-0.005616588,-0.00437196,-0.015901929,-0.013113545,-0.008097294,-0.0064495765,-0.027445745,-0.0030310685,0.056943983,-0.008904972,0.016695753,-0.0071963444,-0.017625822,0.040962394,-0.008884984,-0.031001244,-0.008375853,0.043487858,0.019741017,-0.030437978,-0.0068441043,0.0025293315,-0.010384378,0.00060676085,-0.0016458316,-0.03491041,-0.015719831,0.0025905725,-0.022029256,-0.0036959874,0.024920987,-0.00473223,-0.029612362,0.021203164,0.011991066,-0.026322652,0.005801459,-0.049416807,-0.0148111265,-0.0072885337,-0.010750848,0.033631973,0.0073000635,0.006792932,-0.00060767605,0.008152018,0.03138514,-0.00852911,0.0139597785,0.00057874457,-0.022886174,-0.034413833,-0.028058069,0.00075545756,-0.022214705,0.018846162,-0.016249122,0.005506641,0.0262669,-0.016860496,0.0005814669,-0.02141882,-0.014577671,0.021186713,-0.03048293,0.028800942,-0.026641782,-0.009124024,0.02122679,-0.0041745207,0.04254282,0.0007668629,0.007411488,-0.00745971,-0.01743286,0.013545657,-0.012195275,-0.041171886,0.0072266315,-0.020072458,0.0010131933,0.046889484,-0.01002627,-0.0036190073,0.016995683,-0.013479536,0.009269388,-0.01448766,0.017192576,0.0025410375,0.046327583,-0.01659782,0.028774554,-0.005286087,-0.0038181066,0.020908821,-0.004003519,0.007875068,-0.01720839,0.025935914,-0.0034208624,-0.00959741,-0.042241592,0.021147616,-0.005862533,-0.028949454,-0.028653279,0.01610047,-0.033412915,0.019827824,0.052084174,0.018951261,0.0097118085,0.007387253,0.040768318,0.01393325,-0.0022433524,-0.006918378,0.0026192171,0.0062230937,0.0035558832,0.055550057,0.019271305,-0.003914393,-0.024210405,-0.0048143826,0.02135759,0.016688386,0.011947048,-0.02352519,0.010515743,-0.045782298,0.015077876,-0.026957676,-0.014541162,-0.029021373,0.009124283,0.00779531,-0.029087994,0.011706518,0.023967097,-0.020286689,0.0075330874,0.011715915,0.0020685166,-0.018637909,-0.00071065954,0.004057344,0.030228326,-0.030572508,-0.0087320525,-0.005960078,-0.005834774,-0.016734578,-0.024799725,-0.009652333,0.005668602,-0.00055626547,-0.032335714,0.041878264,0.014294174,-0.018886557,-0.024563748,0.024006909,0.0074624387,-0.014753924,0.0059471615,-0.04920247,0.0054184305,-0.007207809,-0.012644311,-0.01654344,-0.015210198,-0.012565539,0.0045119626,-0.014396785,-0.006504714,0.019818924,0.005672036,-0.019363293,0.009102057,-0.004024076,0.04147518,-0.007859924,-0.0003450769,0.022693904,0.025047962,-0.028366094,0.03683917,-0.002759976,0.0126324,-0.02046225,-0.028884867,0.019291565,-0.003692077,0.021122672,-0.011479857,0.0029598554,-0.0058407774,-0.026128912,0.008796274,-0.037459202,0.02231014,0.023749009,-0.0011422224,0.0064487234,-0.035232656,-0.022840288,0.0028687252,0.016087923,0.002858789,0.00886107,-0.015878962,0.016739117,0.007901181,-0.0035354286,0.044649865,0.0073780096,0.030837901,-0.027872754,-0.020433892,0.02546657,-0.02156992,0.009717759,-0.028006362,-0.011929881,0.019795517,0.009583155,-0.009601054,-0.010948195,0.0021699667,0.02280214,0.004910469,-0.005396095,0.007526734,-0.035406277,-0.0038823718,0.030645143,-0.0038353456,-0.025710331,-0.007739869,0.044938516,-0.017771466,0.016974555,-0.02986128,0.03502501,0.015665995,0.046604544,-0.03522382,-0.007424792,-0.007518949,-0.007492107,0.010708926,-0.007963732,-0.014226606,0.00037790768,0.0019366019,0.0006756589,0.008966353,-0.02882888,-0.0287747,0.0017021553,0.004086269,0.021962328,-0.048459493,0.009051803,0.015388854,-0.030541275,0.0022643497,0.003163119,-0.031169046,0.004023109,-0.0038350162,0.01477108,-0.03954839,0.0011666534,0.028493626,0.03206952,-0.01723189,0.0068528815,0.011353968,-0.010994685,-0.032748334,-0.0019646853,0.015542377,-0.0009960567,-0.008612572,-0.026903503,0.026397396,0.005554145,0.013582108,-0.008462517,0.015459371,-0.010892335,0.0125916535,-0.0048233997,0.024711905,0.0066833044,0.03363214,0.02510704,-0.0034320403,0.002477611,-0.018745214,-0.009061459,-0.0361497,0.02566835,-0.0062543843,-0.013727908,0.0031574853,-0.017573768,0.027504409,0.019312581,-0.016725812,-0.0018958624,-0.021546742,-0.005792882,-0.004658719,-0.02121877,-0.013166842,-0.02797925,0.014374485,-0.0028182338,0.004575958,-0.030601323,0.012248375,0.045404255,0.02478685,0.0040146774,2.0545245e-05,0.031855714,-0.006983795,-0.008128101,-0.028950999,0.011484593,-0.027511416,0.010558215,0.0040599275,-0.011729439,-0.0038094127,-0.0032795158,0.022965744,-0.0038882855,-0.0021718568,-0.013222835,-0.019916644,0.0037738604,0.008920781,-0.0070406673,-0.0077195526,-0.013396984,0.015501289,-0.040853508,0.02195432,0.011239222,-0.009373868,-0.00705704,0.0045754067,-0.03775283,-0.0045808204,-0.0055308724,0.009909151,-0.020526493,0.016667612,0.006618499,-0.015847845,0.021903051,-0.014133079,-0.01793398,0.020936575,-0.0046119383,0.0334317,0.0046251644,-0.01056123,0.04561049,0.031730592,-0.01174728,0.035424568,0.016104454,0.011629679,-0.014786493,0.0014735225,-0.004329032,-0.017669966,0.010242595,0.021991951,-0.019762266,0.017998599,-0.025457645,0.021484738,-0.026939346,0.01206644,-0.00430349,0.00035061978,0.0038650518,0.012550247,0.02939603,0.020389058,-0.007705195,0.020091722,-0.03450267,0.0052889017,0.03597206,0.019955888,-0.047565673,0.017005565,0.020194316,0.026953677,-0.02201048,-0.012931642,-0.02228899,-0.041006807,0.006691936,0.02836759,0.030461546,0.023841705,0.027141105,-0.02457862,-0.011463175,-0.0028666703,0.020067958,-0.011777026,0.0072374567,-0.02435421,0.0014586663,0.013773815,0.008189587,0.015807275,-0.015930329,-0.002370034,0.03969918,-0.028953148,0.027293997,-0.043673363,0.0038828203,-0.0050098086,0.009880416,-0.032539364,-0.013608531,-0.031548254,-0.026622184,-0.0077927983,-0.015476818,-0.026433993,-0.018205155,0.007927411,0.024361923,0.016899902,0.0086611,0.053402554,-0.0050081536,0.0003326029,0.014975173,0.026370917,-0.008031767,0.031543586,0.033899337,0.0022273548,0.010021556,0.010455714,-0.025729137,-0.0015378422,0.029217906,-0.009678569,-0.0062538167,-0.0025673993,-0.008257444,-0.0045508654,0.019353341,0.012178035,0.027883587,-0.01705841,0.012909094,0.0150748165,-0.031388197,-0.00709116,-0.0020213996,-0.010689539,0.030459244,0.043262493,-0.020286135,-0.012500568,0.03496671,0.00688553,0.056884903,0.011791846,-0.0048004175,0.017566446,0.004073125,-0.017645443,-0.02723815,0.030618506,-0.005135425,0.0021369304,-0.0022439233,-0.014013859,0.021061838,-0.0067316936,-0.014616105,0.01990942,-0.02143703,-0.016161636,-0.010507305,-0.009072372,0.022989292,0.0054606283,-0.021752438,0.00818657,-0.013689867,0.0035716756,0.0007736105,0.009903693,0.028845446,0.023375513,-0.014936729,0.004449904,-0.013250516,-0.010704198,-0.0032539864,-0.0053656553,-0.010586368,0.0070115477,0.020608665,0.016229102,-0.012011913,-0.010342393,-0.038099147,-0.01521937,-0.010939112,0.0010366892,0.005044141,-0.007684795,-0.015448311,0.016581232,0.014491402,-0.007852717,0.018037325,-0.026634857,-0.0031753115,0.00023115902,-0.0034451259,0.0045305174,0.011430592,-0.01485354,0.01762556,-0.031104805,0.03787098,0.003574624,-0.008194198,-0.0008142459,0.025569309,-0.03964489,0.017693838,0.039718326,0.008256197,-0.01571447,-0.041738633,0.0007227985,-0.03404722,-0.010889518,-0.021237541,-0.016405173,-0.019602844,0.02880722,0.021647029,-0.003431604,0.00012818232,-0.0074144923,0.012126742,0.0032472725,-0.020439489,0.001229194,0.0075661577,0.0038595279,0.003954406,-0.011462935,0.027127426,0.023207726,0.02746564,-0.0006863245,-0.009759315,-0.015484628,-0.020461138,0.0036293431,0.015135986,0.0013105962,-0.04425814,0.0023787199,0.021795684,-0.00095006474,0.0020905857,0.0043357215,-0.0319161,0.019900536,0.008069777,0.026371485,-0.0029660747,0.01779881,0.01488244,-0.0030338173,0.04548659,-0.02938,0.015357185,0.010914718,0.009076043,0.007719866,0.03720897,0.023482045,-0.009086279,0.019537643,0.01060581,0.045001186,-0.02522307,0.019964663,0.03297817,0.030673444,-0.0091540385,0.011534785,-0.03389897,0.014620288,0.011988237,-0.026318919,0.030401848,-0.013850253,0.016189786,0.014139731,-0.041778162,0.023213312,0.0066437847,0.011547481,-0.024368946,0.008553883,-0.017283494,0.024126638,-0.0037475885,-0.025059953,0.03822021,-0.01945277,-0.018057844,0.018772746,-0.01488135,-0.010917278,-0.02207563,-0.008559882,-0.019533636,0.015555316,0.0154511,-0.021708114,-0.014415942,-0.050672147,0.0087718805,0.033579268,-0.020682449,-0.039534867,6.190989e-05,-0.028125435,0.031587202,-0.011263056,-0.006805186,-0.018281473,0.023555726,-0.036095932,0.030037766,0.029175213,-0.010624907,0.0049726013,0.010800122,-0.038415145,-0.03269792,-0.025579326,0.0015812998,-0.02989866,-0.035324633,-0.017028077,0.011006367,-0.01432071,0.013333226,0.01532466,0.028107531,-0.006367769,-0.015041447,-0.01593129,0.010550924,-0.0077745216,-0.020519562,-0.0043482776,0.0032642512,-0.02157653,0.010191398,0.0019623167,-0.01762394,-0.012898977,0.042189494,0.009727649,0.0062942985,0.0064796507,-0.011986863,-0.017585723,-0.00086930295,0.011086606,0.0116406325,0.0073113265,-0.027165499,0.0233975,0.027945753,-0.039692692,-0.0029025846,-0.018039891,-0.02538782,-0.036702964,-0.008837619,0.0065463916,-0.0050113597,-0.012169666,0.0029700147,-0.012017429,0.03202891,-0.033649903,0.006703431,0.014591841,-0.017639743,-0.025313282,0.02111233,0.011542749,-0.03161628,-0.03733407,-0.014760205,-0.009193616,-0.0022941718,0.019923693,0.019466646,-0.0013895755,0.0199999,0.005858306,0.03411524,0.016665414,-0.034203924,0.016297676,-0.027767183,-0.023831794,0.0058950433,-0.00603024,-0.0073156757,0.023771457,0.00083602354,-0.044493515,0.03940022,0.003226853,0.023564441,-0.013386504,-3.080343e-05,0.010776003,0.010994488,0.029649705,-0.027177716,0.012470538,0.034989167,-0.020647563,-0.010727084,0.02286518,-0.022578241,-0.028719459,-0.015398541,-0.009198039,0.001298648,-0.03250725,-0.004678382,0.014357576,0.0046393457,-0.014560925,-0.012701974,-0.028515648,-0.010751945,-0.011932046,0.0021079872,0.013486203,-0.0059627383,0.0132768415,-0.016770344,-0.0024378973,-0.019947592,-0.0068411906,0.0084138345,0.0074434304,0.0027992758,-0.012023649,-0.0023338161,-0.0012399101,0.00820735,0.032460216,-0.012804717,-0.020616427,0.022584489,0.019964783,-0.043720517,0.025713634,-0.010275859,-0.011537361,-0.0031465995,0.01845359,0.0018976475,0.036811076,-0.049881615,0.016345913,-0.0014013443,-0.010579375,-0.007162749,0.02060397,-0.0031621389,0.009570548,-0.019107526,-0.00068255264,0.021764388,0.025004646,0.013819001,0.021756684,0.025121594,-0.0114012,0.021055896,0.01550288,0.011244944,0.0034804193,0.008199048,-0.028467966,-0.011794517,-0.02661525,-0.0038012525,0.000939244,-0.011872038,-0.0031527844,0.009580675,-0.002892955,0.010094217,-0.00013458949,-0.021978509,0.008096382,0.0057580946,0.032861665,0.022224708,0.008259535,-0.025628963,-0.021358848,0.006955101,0.03748654,-0.0143712545,0.00653715,-0.0009270324,0.007401546,-0.028261228,-0.0039387047,0.026215233,0.024563218,-0.0051057613,0.0056571383,0.004342375,-0.006306796,-0.015588256,0.03189377,0.034964107,-0.01488309,0.008965492,-0.0024916294,0.008359985,-0.05158785,0.0053226333,0.00951016,-0.008349061,-0.004357131,0.004789566,0.0134979235,0.040470142,-0.007511162,0.0143522965,0.016166741,-0.021600936,-0.02710797,-0.009510073,0.019780984,0.0004491149,0.0154094435,0.017298812,0.009029982,-6.0204347e-05,-0.03846691,0.008283221,0.010211136,-0.010097421,0.019632163,0.019645669,-0.017688034,0.014679756,0.018035626,-0.0064042276,-0.032613564,-0.007963817,0.0007753147,-0.009347272,0.013308321,0.007718045,-0.006476264,0.0050709667,-0.019727372,0.010288304,0.012303587,0.004277866,0.014501317,-0.004010791,-0.012839498,-0.001282158,0.0023650469,-0.009539907,-0.029475434,0.004690465,0.02218413,-0.028351258,-0.024806352,-0.0064686746,0.02271127,-0.01759937,-0.02281926,-0.025128141,-0.006782455,0.0061521153,0.048821677,-0.020478966,-0.026156755,0.019413617,0.025036113,-0.022789093,0.028319232,0.001025767,0.007559981,0.0063045714,0.019516569,0.01576677,0.018750746,0.006553766,-0.0081520835,-0.019288182,0.002844412,0.0018953814,0.00841638,0.0023137983,-0.01389218,0.018418778,-0.023673728,-0.018794397,-0.028203517,0.007912365,-0.006645597,0.0069148154,0.014394132,-0.009330805,0.0135597875,0.0036154957,-0.002689162,0.009714688,0.011970521,0.013306799,0.021501321,0.01584567,0.021147486,-0.006791813,-0.010745219,0.024997767,-0.022540623,0.027267981,-0.008146323,0.004844967,0.028877169,0.008810587,0.019062778,0.030752013,-0.013912704,-0.036736738,-0.017907763,-0.010432433,-0.011350127,-0.028501233,0.004650617,-0.00021958112,-0.009358257,-0.028337527,0.009223558,-0.0021236257,0.025746815,0.009024178,0.029275717,0.017797653,-0.008873394,0.017634403,0.0009005282,0.0017253583,0.0010389511,-0.025768688,0.0232572,0.009387136,0.009762502,0.002610882,0.004393988,0.001491042,0.003740872,-0.03878895,-0.035472665,-0.004746207,-0.023946187,-0.008303195,0.011354993,-0.013218053,-0.0026623155,-0.007848716,0.012113992,0.017603617,0.063815825,0.009777975,-0.016755844,0.016703324,0.013830192,-0.006496669,-0.018176915,-0.012536513,-0.0051078266,-0.012017596,0.002262733,0.011713136,-0.0026642464,0.00063356984,0.031189457,0.030134657,0.01805626,-0.027387721,0.01617562,-0.011150734,-0.010939203,-0.0058022602,0.0019753717,-0.007195578,0.023422807,-0.00932008,0.012079599,-0.026543058,-0.038747113,0.0018032066,-0.0013275355,0.0069982964,-0.020548064,-0.004582007,0.00093819865,-0.010983205,0.015751105,0.036886055,7.5771536e-06,-0.0068000946,0.026785588,-0.010597154,0.019634817,0.0015942499,0.018617587,-0.0001129658,0.0043925922,0.0031724728,-0.026108654,-0.0158289,-0.028240256,-0.014975585,-0.029204758,0.018170854,0.007065718,0.0061095585,-0.00056473794,-0.021951819,-0.006606573,-0.037349224,-0.02932499,-0.016658103,-0.025556598,-0.008741015,-0.019259114,0.0035912667,0.008828039,0.03523988,0.0070479317,-0.0010790299,0.012695069,-0.020865947,-0.0082616275,-0.0029886025,-0.031200176,-0.020806482,0.02855268,0.0053449953,-0.008917395,0.0045127883,0.006610759,-0.020537052,0.0013012037,-0.033440348,0.012285717,-0.00026934725,-0.0053059696,-0.04222237,-0.014533586,0.020221695,-0.034250215,0.021896178,-0.0033306214,-0.022375116,0.009118665,0.028149147,0.0017884469,-0.009333534,0.004739093,-0.0051518315,0.0016948866,0.009691882,0.028030187,0.01182394,0.00320112,0.02778933,0.029146431,0.0030938461,0.016832406,0.025771346,-0.011227051,0.016149921,0.016028043,0.01808216,0.010864637,-0.004561129,-0.013573785,-0.0064154686,0.03561581,0.01943632,0.019976815,0.005324883,-0.0059495834,0.012952475,0.0025062917,-0.018159661,0.0044993884,0.012703721,-0.013926188,-0.0147098275,0.012331503,-0.015188495,-0.008518274,-0.01775155,-0.003818526,-0.014978471,-0.012358842,0.0005742916,-0.012366285,0.009400776,0.005682893,0.014921375,0.030329289,-0.009183646,0.0013043215,0.0051558027,-0.027237521,-0.009397541,0.0006553256,0.014715718,-0.0035636341,-0.01784771,0.012363224,0.006751663,-0.017883008,-0.027151302,-0.0023111494,0.015709411,-0.028878663,0.0012290814,-0.009117487,-0.00334621,0.02181083,-0.0033324645,-0.014257496,0.034445334,0.04075265,-0.010797252,0.027311066,0.0021708284,-0.016772723,-0.015908156,-0.017598493,0.0030121764,0.007447731,-0.009919521,0.017024375,0.007456587,-0.00041771677,-0.041219324,-0.008157913,0.0039726226,-0.0056579346,-0.01964956,-0.013040087,-0.023488056,-0.026197102,0.023656333,0.018057046,-0.01596994,-0.030057952,0.0012356268,0.024523715,0.0010670522,-0.02617977,0.009533458,0.0015781194,0.013264279,-0.007878129,-0.022957688,-0.015043068,-0.0051641935,-0.00085946004,-0.011669703,0.008046815,-0.015025131,-0.048949122,0.00214287,-0.0022075207,-0.0049539898,0.008003909,0.04153805,0.0021043867,-0.003091527,-0.007388232,0.0025870893,-0.00073887035,0.013237742,0.010812072,-0.010313283,0.008479145,0.0088558365,-0.024042647,0.024483833,0.05001743,0.00090197433,-0.028932557,-0.005798916,0.013021156,0.03524035,-0.02271533,-0.012550923,-0.023619054,-0.0058756983,-0.0077225715,-0.04718579,-0.020680415,0.0018721741,-0.041705552,0.0020008015,-0.0111205345,-0.01526408,-0.017265333,0.014500808,-0.0086259125,0.011768965,-0.032123044,0.010715157,0.00523531,0.003102435,0.008623433,-0.017739652,-0.04701476,0.026327848,-0.014402364,-0.034907952,0.017365767,0.004837314,-0.0018239375,-0.016122513,-0.009363036,-0.016672716,0.014934673,-0.02831896,-0.02106185,0.037568204,-0.016026748,0.03327374,-0.013721855,-0.0034725645,-0.005692785,0.0075322026,-0.023535086,-0.0033193242,-0.026416682,0.037085608,0.00657903,-0.01768847,0.0111660985,-0.0019712923,0.016584741,-0.012775777,-0.017145382,0.025575764,-0.008606519,-0.005335103,-0.008422354,-0.015793858,0.03425735,-0.021059986,-0.06436884,0.020509249,-0.0008706302,-0.005693617,-0.008262705,-0.006908304,-0.0030725019,-0.036164515,-0.021802858,0.0027307814,0.022935716,0.004213192,-0.0155390985,0.0040203677,0.00025010074,0.00443526,-0.01659094,-0.009346217,-0.012799264,0.016486939,0.024552388,-0.038828623,0.0036718717,-0.029348616,0.008324261,0.0038319018,-0.014867674,0.003162748,-0.0050929673,0.003469857,0.03522441,0.008730207,0.019094264,0.039552636,0.0005554274,0.0077822274,-0.0012469008,0.02438895,-0.0072552273,-0.023868866,0.0012024837,-0.009260106,0.00638856,0.022205172,-0.024129706,0.0045144246,0.010981646,-0.039522186,-0.015526241,0.0073261824,0.017282574,0.017506588,-0.0072652483,0.018259943,-0.019445805,0.01289838,-0.0027257341,-0.004611249,-0.02515257,-0.024834462,-0.005773249,-0.017759277,0.0049493536,-0.017668704,-0.0025196914,-0.011615752,-0.007224294,0.012326984,-0.008790728,-0.00059227174,-0.0074914712,0.012922811,-0.041776303,0.0031385913,0.029605001,0.0007152976,0.018094188,0.039184574,-0.023325983,0.03676247,0.01746168,-0.01740204,0.020870158,0.04073714,0.01781042,0.019257084,0.0072035263,0.021640519,-0.011817891,-0.021381738,0.006715254,0.007487627,-0.0253736,0.005280713,0.0207536,-0.04016211,-0.017978115,-0.011370832,-0.0068301326,0.00046961097,-0.03122957,-0.0011823323,0.007616536,-0.037572853,-0.039878152,0.017993135,-0.040038593,0.0075980206,-0.024759896,-0.0010638566,0.04186935,0.0031134773,-0.038419887,0.01077777,-0.01708446,0.0055661774,0.008574727,-0.016930789,-0.0011757391,-0.03247536,0.011055001,0.01083506,0.014377923,-0.008225383,-0.0077258204,0.015570728,0.017031807,0.0062038,-0.020184074,0.031303715,-0.029184362,0.008716414,0.010567111,0.015216457,0.021380046,-0.013380027,-0.014460345,-0.02045984,-0.008727181,-0.025169274,-0.011332486,0.0012912394,-0.01414628,0.014276793,-0.037235122,0.012981621,0.002876665,0.018871529,-0.005426923,-0.03310017,-0.0024368255,-0.009905022,0.011101065,0.0065211253,-0.018057028,0.009865744,-0.032353457,-0.0067339647,0.016222991,0.005055102,-0.027206842,0.02226723,0.011339275,0.015503494,0.036204744,0.0126777915,-0.0038408248,0.01226222,0.0021864795,0.03388467,-0.015031006,0.05053971,0.019905355,-0.019393742,-0.027979389,-0.01797887,0.011889083,0.006928742,-0.014095665,-0.040549695,-0.01897143,-0.0067885304,-0.023595074,-0.0029657143,0.0015901672,-0.010791937,0.0047307783,-0.038319845,-0.04823125,-0.012637971,-0.025432432,-0.0007040972,-0.005729749,0.0035604094,0.019417368,0.024467586,-0.014055223,0.017507743,-0.014877269,0.009819037,0.029681267,0.0024131145,-6.261236e-05,0.021489987,-0.02803578,-0.020636266,-0.031912,0.0016736228,0.016847542,-0.015125641,0.01894611,0.02927835,0.013642395,-0.0025826106,-0.017185567,0.0027711326,0.007290008,0.0044333725,0.018884454,0.015849127,-0.0015516758,-0.010520341,-0.014409578,-0.02610737,0.0030893984,-0.004348578,0.014385056,-0.0017066314,0.023880472,-0.009317848,0.015682107,0.023986867,-0.033747338,0.016992804,0.004667998,0.0049671377,0.011048198,-0.0074215108,-0.00913143,0.041374926,-0.00499633,-0.0016060323,0.024665873,-0.014611133,-0.010411219,-0.0072755665,0.02258859,-0.013871599,0.012083496,0.009343247,-0.004601798,-0.03202194,0.013658524,-0.0015605593,0.026328424,0.024338428,-0.018500531,0.0051465034,0.01945951,-0.0059053604,-0.0012074658,0.0012801962,0.0051485077,0.024683237,0.01248426,0.024275323,-0.00029105702,-0.011435256,-0.022771416,0.006705998,0.026778428,-0.013673038,-0.0062018596,-0.012031734,-0.03692934,0.018165026,-0.002805474,-0.0075492742,-0.016399201,-0.027988994,0.0008431073,-0.03338457,-0.030943414,-0.018212121,0.0002490459,-0.0074218838,-0.014862493,0.021955151,-0.016333917,-0.0027525623,0.004147307,0.017229745,-0.045627452,0.012652443,-0.0047662742,0.0062997662,0.008466899,-0.032652497,0.008100708,0.02678016,0.009760637,-0.002193134,-0.035855543,-0.038294144,0.008587334,-0.024428314,-0.02150775,-0.012051991,0.0044005984,-0.012605242,0.035955217,0.0021306174,-0.008890368,0.004796883,0.001989879,-0.008540161,0.016068682,0.0065339515,-0.012916668,-0.0029632119,0.016832266,-0.014222666,-0.029681353,-0.006386454,-0.025137939,-0.0056086197,-0.0032112699,0.017914463,0.0038246682,0.0037228474,0.046732325,0.018998465,-0.0056154197,0.012028392,-0.04421291,-0.008750399,-0.038377497,0.016159033,0.0037919092,-0.036648087,0.0011131831,-0.023883684,-0.033570588,0.0047753276,0.0006152053,0.033115093,0.018598268,0.0007740504,2.4467925e-05,-0.03276651,0.017245604,0.0042495155,-0.009506448,-0.0077904672,0.0101994565,-0.0012227074,-0.021552261,0.0046219747,-0.0103698615,-0.008182934,0.017619569,-0.024562174,-0.03895608,-0.019870806,-0.006545862,0.0034258375,-0.0067589115,-0.004816604,-0.013592945,0.033182606,-0.03718167,0.004141898,-0.0054053674,-0.0047503468,0.01881834,-0.005402544,0.0040575783,0.015753647,0.00954082,0.0042170603,0.027363453,0.016439607,0.031035714,0.002577981,0.0058073667,0.010788288,0.00785544,-0.036359813,-0.007422024,-0.011066796,-0.0071881413,-0.009943174,0.006913965,0.021290302,0.009144004,0.00761163,-0.0036687802,-0.0028006902,0.021441981,0.014347954,0.0064376984,5.2678137e-05,0.03776423,0.021535095,-0.021450195,-0.0050266683,-0.0034452535,0.0027009055,0.013601269,-0.005335218,0.022810385,-0.019462716,-0.01689745,0.030604688,0.005695386,-0.017451767,0.018538631,0.012816232,0.014511694,-0.0045059905,0.02364823,-0.009905975,-0.0096566975,0.027904931,-0.011758712,-0.02847486,-0.020403141,0.017564291,0.007852677,0.023893563,-0.0005028182,0.022130277,-0.0037204616,-0.004442002,-0.0038019915,-0.018084973,0.015298971,-0.001276855,0.019773206,0.0026148101,-0.0066809044,-0.020457895,-0.008210892,0.022456642,-0.0038447608,-0.0023503602,0.013039335,0.040252145,0.039758768,-0.018920826,0.029616948,-0.015757661,0.019699989,-0.004442465,-0.006424958,0.0019348401,-0.018905533,-0.018349508,-0.01938789,0.004780734,-0.010094235,-0.019914005,-0.022428755,-0.011219026,0.009796019,-0.00969967,0.00043424216,-0.015092059,-0.0018075628,-0.017619565,0.017917702,-0.0249041,-0.0064979033,-0.013693424,-0.013041031,-0.0052249446,0.018723017,0.010002474,-0.015086798,0.016375808,-0.00088271016,-0.010482702,-0.017991163,-0.025333066,-0.021001045,0.011868265,0.014787793,0.010495138,0.001351328,0.019909132,0.024343226,0.002383318,-0.003284937,-0.021887554,-0.032555163,-0.0015490545,-0.022060275] +8 嵌入向量由模型生成 [-0.032592606,-0.02127858,-0.023006821,0.013831074,0.0060901553,0.016513404,0.01980371,-0.012798685,0.019966522,0.011307839,-0.033023667,0.0057181707,0.05214625,0.016958386,0.0010888971,0.00063200976,-0.028263075,0.020151934,0.06482984,-0.038450796,-0.0052374946,0.011622368,0.019647662,-0.0050679496,0.017908458,0.04315336,-0.026513554,0.0004836478,-0.011129043,-0.015372468,0.028945368,0.0019062086,-0.0026367912,-0.045506388,-0.021421261,-0.01984568,0.0057959165,-0.012661431,-0.027959429,0.033919122,-0.04379864,-0.018787893,0.0003467359,-0.038533762,0.04715522,-0.04231504,0.014121045,-0.037820347,-0.035250172,-0.028337756,-0.008844089,0.02056177,0.0242012,0.03911713,0.07069728,0.028491315,-0.048157673,0.0017193459,-0.048055056,-0.029518032,-0.0014339087,0.026312655,-0.017932342,0.04454843,0.025968175,0.04466124,-0.016008329,0.001360072,0.0068401927,-0.04982843,0.026283504,0.0368692,-0.0053650495,0.008391652,-0.05338967,0.014252501,0.032194406,-0.008718469,-0.025040293,0.03434837,0.011393644,-0.059543718,-0.040228833,0.007164157,0.025599048,0.00392695,-0.020337267,0.019252023,0.009087121,0.003119713,0.0064398726,0.0040666014,-0.011334818,-0.04681432,-0.04270572,-0.0039559174,0.015774563,-0.00018070661,0.014773953,0.008612673,0.03436116,0.014531861,0.0404167,0.015593647,0.006633841,-0.056739897,-0.009241663,0.011170151,-0.058230747,-0.012091395,0.005853159,0.020036995,0.022502424,0.028228479,-0.007563642,-0.023729892,0.0055040494,-0.018093586,0.025798582,-0.002216167,0.07468177,-0.004550706,0.045239728,-0.002775683,-0.008786672,-0.029855577,0.008827198,0.022836965,-0.04839645,0.0014578867,0.016627828,0.032641437,0.018959025,-0.0015860201,-0.06199068,-0.01081691,-0.0024375091,0.04081852,0.00038429425,-0.018267257,0.034847632,-0.006262356,-0.03049181,-0.030831221,0.011679005,-0.00041572822,0.036777373,0.004947274,0.0123394085,-0.0378152,-0.01932921,-0.012530296,0.031841673,0.0308998,-0.009463598,-0.010600572,-0.02511129,-0.0062969625,-0.012216378,0.026610145,-0.009496271,0.02126465,-0.018226413,0.017012002,0.037879363,0.0058624065,0.024779947,0.04890256,0.009664293,-0.025669888,0.03107358,0.026989471,0.00046185133,-0.00515255,0.016921697,0.034682114,0.08787518,-0.0016188356,0.001794781,-0.03456719,-0.015742734,-0.005690139,0.009460084,0.018710079,-0.006645372,0.0011177772,0.0066948193,-0.03968041,0.005014367,0.049643647,0.0045869676,-0.007986915,0.010583331,-0.015564347,0.045970436,0.03711854,0.010690846,0.011604715,-0.0015259802,-0.035904136,-0.028174924,0.025914416,-0.05382875,-0.021139724,-0.025887253,0.016456932,-0.028842058,-0.056617606,0.01997646,0.018666403,-0.021291718,2.1757562e-05,-0.0028035804,-0.022674005,-0.0013949679,-0.0066103064,0.003923835,-0.00027988412,0.016169684,-0.080952756,-0.028344788,0.06300726,-0.007466161,0.04698898,0.033076506,-0.011813879,0.048788853,0.02548151,0.010444848,-0.04264888,0.013144378,-0.018507184,-0.033754442,-0.015341686,-0.0005328281,-0.015641667,0.011070615,0.007844642,0.014534064,-0.02999458,-0.078525044,0.013435385,0.060018457,-0.011225269,-0.058675155,0.01999512,-0.009979954,0.023353774,0.0065501556,0.021694377,0.022580637,0.019141482,-0.06908313,0.006389691,0.028344566,0.0046803686,0.043096725,-0.0005460897,-0.010257081,-0.016651979,0.013555261,-0.0030471934,0.0018755429,0.004204041,0.0037930578,-0.029904304,-0.017991567,-0.03253158,-0.005296643,-0.05201234,0.00035340642,-0.004993564,0.0012006646,0.0036302682,0.013704135,0.009515863,-0.048912466,-0.03239486,0.04552195,0.019793294,0.016829034,-0.0029314368,0.003722389,-0.03837534,0.002594457,-0.013186858,-0.031626925,-0.015920307,0.002910865,-0.046423167,-0.02093119,-0.030364633,0.08824978,-0.0041676196,-0.0262117,0.0032314388,-0.012784049,-0.16149835,-0.019487424,-0.021443136,0.0049915346,0.016656192,-0.033371024,-0.023548396,0.011976639,-0.035333868,0.0371095,-0.030968701,-0.068056956,0.002949624,-0.03995223,-0.0146358,-0.012038512,0.01627488,-0.02459389,0.013715859,-0.025723632,0.010011949,-0.053642724,0.058620486,0.02544867,-0.014564624,-0.0046799015,0.017400282,0.04370895,0.005364384,-0.04752482,0.017764483,0.040104106,0.005074806,0.049054865,-0.042255398,-0.013017462,-0.002027853,0.015472132,-0.0028132915,-0.009141898,-0.0058063744,-0.028245227,0.020347018,0.06223926,0.01122796,0.029815104,0.017496606,0.007964869,-0.015652113,0.01825571,0.028677035,0.0020777183,-0.0015596442,-0.050465092,-0.035558805,0.028904228,0.00010975803,0.015095624,-0.021173658,-0.004617067,-0.044937387,-0.03824549,-0.00404479,0.01475206,0.0006501596,-0.04577731,0.007050054,0.026009189,0.017613603,-0.021748373,0.03523661,-0.009239539,0.00577751,-0.03669383,0.020752221,0.045273814,-0.03159759,-0.03590078,-0.0061687203,-0.09985901,-0.028574323,-0.006463266,0.014353925,-0.026954634,-0.036802705,0.06382718,0.01903059,-0.0022210889,-0.045179553,0.21658897,-0.04040946,0.013011315,0.017326102,-0.0008450859,0.024605904,0.04134912,-0.0049977824,-0.038375933,-0.019494994,-0.0002959682,-0.004769976,0.035281345,0.011086794,-0.005186774,0.020887796,-0.020001339,0.010640165,0.07306239,-0.03055118,0.001956996,-0.04163002,0.01648895,0.018787777,-0.04289544,-0.013878013,0.015718535,0.012483991,-0.009656795,0.05083282,0.024340933,0.042716313,0.0065478175,0.012190374,-0.010343786,-0.02277163,0.029661467,-0.000966871,0.018272383,0.020443505,-0.015263215,-0.0037640764,-0.032094058,0.020384103,0.03541264,-0.04523199,0.034340303,-0.048390776,-0.032518342,-0.026319083,-0.04094875,-0.041294903,-0.03180957,0.0019411831,0.032058984,0.014379043,-0.008393572,0.010892238,-0.013766625,0.0010544506,0.06084749,-0.0010499639,-0.0029801773,0.021580895,0.013363757,0.025733462,0.009278383,-0.015284671,0.008267264,0.03527989,0.006160679,0.0019930892,-0.009855405,-0.030939212,0.04326298,0.0030702192,0.0567554,0.03620661,-0.027572438,0.03675617,-0.032643773,0.00021042279,-0.0040304153,0.008966009,-0.016511312,-0.011669762,-0.049299978,0.026011955,-0.0111000035,-0.012433597,-0.031288095,0.032507844,0.016455144,0.0036082661,0.025620967,-0.03817479,0.021452965,-0.056859914,0.0037090755,0.014132042,-0.0062997225,-0.04532972,-0.030192101,0.014589466,-0.021846902,0.0027100486,0.0026853324,-0.009414959,-0.01485,0.0051756618,-0.036245536,0.036738716,-0.021368174,-0.020725636,0.038096063,0.02543421,-0.038943,0.038201526,0.029044122,-0.02471275,0.0014214225,-0.050084665,-0.029518906,-0.02217961,-0.028099608,0.015704745,-0.048225284,-0.0344016,-0.037964124,0.026633628,0.038161926,-0.0070681926,0.005171648,-0.03156341,0.023973405,0.013633585,0.034289904,0.023565648,0.007977292,0.03625189,0.012171255,-0.014928936,-0.0031104856,0.014674975,0.05277483,0.027448801,0.06767221,-0.019056063,-0.047304105,0.009217678,0.05163955,0.009542222,0.032912076,-0.04357526,-0.033133727,0.0052599288,-0.007959903,0.013244826,0.0063057947,-0.022416722,-0.010915807,0.014821901,0.045319237,0.0046866913,-0.011635093,0.045356836,-0.04432679,0.028795281,0.0123631675,-0.104003176,-0.00475593,-0.026763704,-0.054948755,0.01360158,0.055189822,-0.0013860882,-0.10326373,-0.04206533,-0.017043633,-0.0053958623,0.042795513,0.016036091,-0.035810243,-0.02112178,0.007300363,-0.0050593815,-0.043774705,-0.018052312,0.02265347,-0.011695023,-0.033886515,0.11321345,-0.029707476,-0.010921408,0.014325465,-0.013541339,-0.0049534068,0.016081603,-0.024062773,-0.043058705,0.01676811,-0.034422427,0.008055672,-0.033277523,0.031085009,-0.016101433,0.027991986,-0.021105165,-0.021786278,0.011800108,-0.012030307,-0.0025299985,-0.029363737,-0.0015135299,0.047226984,-0.014560783,0.069822095,0.0027677491,-0.024707515,-0.037991416,-0.0026832933,0.008754286,-0.024315853,0.010826309,-0.065589845,-0.021990266,-0.0105620455,-0.04713483,0.02533356,0.053226415,0.017922591,-0.0042712595,-0.0636536,-0.025950283,0.028021852,0.022671748,-0.011968791,0.026746968,-0.03322123,-0.06303369,0.04876896,-0.013696993,0.014281424,0.09139489,0.00205869,0.02315386,-0.004258024,-0.03387937,0.03167221,0.0078137275,0.038919676,-0.007255151,0.04941833,-0.01500015,-3.9870327e-05,-0.011054332,-0.039877046,0.019571595,0.017752908,-0.03314394,-0.05234701,0.0024092714,-0.03325199,-0.022272354,-0.020963572,-0.04242532,-0.0066662813,-0.05996053,0.0060875583,-0.018866802,-0.024848348,-0.034177724,-0.003573322,-0.107585795,0.059068877,-0.029772224,-0.029966755,-0.0655761,-0.027681775,0.020146,-0.0475767,0.032475404,-0.038761694,-0.003375215,-0.048427902,0.017375112,0.021583254,-0.02005038,0.026834523,-0.07204335,0.034412164,0.028602257,-0.031660475,-0.0137716485,-0.020624332,0.008654451,-0.049706705,0.04861657,-0.006755816,0.023735724,-0.010088879,0.04969505,-0.047774386,0.001159612,-0.023682399,-0.013540711,-0.022573788,-0.027257476,0.012986905,0.01857045,0.021661237,0.0069452464,-0.01914122,0.052714642,0.0035971096,0.05522658,0.065379314,-0.024372723,0.021651577,0.01272073,-0.007912517,-0.0021560632,-0.00934567,-0.037737146,-0.05394336,-0.011417715,-0.0012275316,-0.0035633116,0.0038057081,-0.0312491,-0.047225136,-0.027162611,-0.03303955,-0.012333813,-0.017389432,-0.021426031,0.014745614,-0.008462595,0.040079318,0.025362907,0.013811313,-0.041488543,0.06051282,-0.049207292,0.00023708402,-0.016716035,-0.048172098,-0.0066052084,0.040301677,-0.011304912,0.018995732,0.0066342307,-0.007952075,-0.04761732,0.0022885231,-0.013470104,-0.06263427,-0.003075941,0.025249526,-0.015790062,-0.00094778737,0.027929695,0.014562683,0.01309514,-0.012823336,0.032314405,0.04726679,-0.036765818,0.0066370345,-0.010321774,-0.0009239484,-0.006717619,-0.008744845,0.025105515,-0.0100889895,0.008924347,-0.061770108,0.0012774703,0.0043685995,0.0076100337,0.0053573707,-0.018958466,-0.013360919,-0.033773746,0.012888387,0.018344313,-0.0119305905,-0.03169445,-0.003765578,0.049098607,-0.037216432,0.020109365,-0.028972099,0.054420885,-0.15096837,-0.0034570184,-0.0077594076,-0.012483028,-0.013629292,-0.00461444,-0.002389013,-0.041268773,0.034121178,-0.027075857,-0.0081955865,-0.00567027,0.013698982,-0.023789322,0.037246205,0.01723199,0.0065506953,-0.018054284,0.0023914208,0.02596332,-0.0012453002,-0.013638288,0.106291346,0.012549983,0.00176101,-0.01316672,-0.051393807,0.02587139,-0.027199844,-0.01025515,-0.008413187,0.00051203923,0.03555962,0.07463568,0.0058764447,-0.0003977884,0.013021085,-0.027741656,-0.021989714,0.013653806,0.02020595,0.037363015,0.014103826,0.023752047,0.0143275885,0.03555593,0.017943326,-0.0045812204,-0.034159943,-0.014296733,0.000154279,0.03190241,-0.07593294,0.025621649,0.035584845,-0.009283666,-0.010303696,-0.00979582,0.008472064,0.0004535454,-0.05010019,-0.0123872515,-0.030963333,-0.0525116,-0.019350482,-0.0053166747,-0.024794765,0.0051571643,0.007453546,0.0028164033,0.015441536,0.038526922,0.010238096,-0.035365365,0.03650379,0.023611937,0.0125289885,0.014258478,0.024419725,-0.03088927,-0.013221106,0.008932039,-0.009238931,0.030701624,0.00012592672,0.013015825,-0.012269199,0.06478154,-0.06264387,0.00994873,-0.00011738726,-0.00783776,-0.011573083,-0.016396344,-0.10778908,0.015996123,-0.0059296275,0.045804933,-0.012984431,-0.003863784,0.030037375,-0.01752516,-0.018054787,0.05264462,-0.020056413,-0.0066893906,0.03677623,-0.0033017164,0.0388435,0.036527984,-0.012859802,-0.030836929,-0.008897424,0.011108428,-0.0132287275,0.00078651094,0.015111172,-0.021948311,0.0010691532,0.033398572,0.039409883,0.06720087,-0.006051541,-0.043368377,0.03263887,0.026866084,0.016850209,0.03094084,0.03528038,-0.018844595,0.043751672,-0.03645693,0.030789481,0.027896818,0.025780842,0.039750826,-0.015882531,0.069602065,-0.008437851,-0.04397312,-0.025525974,0.023321358,0.008823574,-0.00684475,0.029051937,0.0065265414,-0.0077578123,-0.033323087,-0.009570148,-0.028316418,-0.00021040915,0.014603422,0.028183576,0.014677489,-0.022614319,0.022022698,0.038692188,-0.0052433326,-0.0181248,0.007385661,0.058850415,-0.0075698704,-0.0039411807,0.0029967953,3.626367e-05,-0.045331202,0.0044554947,0.010390652,-0.022290243,-0.022487126,0.024102557,0.0039884504,-0.010682246,0.038189273,-0.0024874408,-0.0033143915,0.03206896,0.0073646596,0.044868715,-0.028344102,0.005548938,0.013355757,0.032792844,-0.0021041976,-0.00073837256,0.03574778,0.0010051873,0.01954632,0.015893895,0.009836255,0.015068417,-0.009412508,-0.002680072,-0.03379696,0.006739423,0.009444242,0.036499962,-0.004692299,-0.01971165,0.06270216,-0.03308143,-0.015555398,-0.07590499,-0.0395042,0.002484643,0.0010926683,-0.053396195,0.01338205,-0.07802589,0.022868898,-0.04778015,-0.018951382,-0.032967772,0.016647073,0.00846127,-0.033447023,0.028464142,0.021637794,0.0019078081,-0.019518815,-0.016123192,0.0044279536,-0.0011478689,-0.008891402,-0.012805781,-0.030626722,0.004867729,-0.010463329,0.011302559,-0.0040919418,0.011031375,0.008213134,0.004116539,0.07476006,0.0064744633,0.04706397,0.080611,0.06281758,-0.032381095,-0.0070809964,0.052897733,0.0052391994,0.0071731084,-0.011804285] [-0.00043888084,0.010064229,0.035385057,0.036282137,-0.0022313686,0.07062938,0.037917238,-0.03833226,0.007274024,-0.0054509556,0.03996909,0.017260578,0.0025109155,-0.031814534,-0.0012206052,0.010896925,0.01914049,-0.019424856,0.01710096,-0.0049927616,-0.006580841,0.020512711,-0.012818956,0.059844717,-0.033704586,-0.010504871,-0.02282882,-0.0665023,0.04987101,-0.0039008756,0.030048262,-0.04153095,-0.018018844,0.009486952,0.004002568,-0.020621514,-0.0045297616,-0.01360478,0.028799037,-0.009273012,0.019376509,-0.050047018,0.0017086015,0.023808675,0.03459977,0.023125682,0.006548706,-0.046499576,-0.012742877,-0.017144978,0.0029274127,0.012272591,-0.022176107,-0.027949097,0.026818883,0.0146967005,0.011305503,-0.0042742044,-0.012149373,0.0030008256,0.0005756235,0.0058168895,-0.049190037,-0.013603651,-0.010787847,-0.022239858,-0.023137325,-0.009177937,-0.0043025985,0.005731504,0.020756407,0.0042346795,-0.011594626,0.010814797,-0.019714052,-0.016959686,-0.0058916425,0.002708538,0.026682215,0.014949526,-0.025574885,0.008208934,0.00957321,0.014737239,0.00712008,0.0074484856,0.021513345,0.009289742,-0.02950054,-0.0076450934,0.008882457,-0.029812386,-0.015109651,0.0036022614,-0.014174998,0.012185774,-0.010573096,-0.026876446,-0.027356004,0.0012950243,-0.01410535,0.0034130078,0.011586841,-0.0034695985,0.028691266,-0.026296163,-0.020673474,-0.017217828,0.010037859,-0.002458763,-0.004573561,0.025430476,-0.0013224926,-0.0067826696,0.013178933,-0.01003322,-0.024446158,0.018875778,0.025237367,0.03587091,-0.004597915,0.0122633,-0.014304914,-0.003874638,0.0022091325,-0.024415279,-0.006036851,0.0034837725,0.023255337,0.016158594,0.005049379,-0.019116791,0.024387874,-0.012570113,-0.015389363,0.030471666,-0.00987388,0.01912159,-0.017304521,0.011995667,-0.0067461277,0.0063359784,0.005630732,-0.048680767,-0.019125914,-0.020203503,-0.018815385,-0.015497215,0.011131368,0.004440492,0.027790884,-0.0015542055,-0.0010699097,0.016497955,0.0034501462,0.0326756,-0.014008359,0.0027034963,-0.0051141847,-0.0037936005,0.005852649,0.009003565,0.008118118,0.052225176,-0.011042192,0.0021794036,-0.002171598,-0.0020722689,-0.033875037,-0.020849308,0.0011564008,-0.02675991,0.0026193755,-0.0059099873,-0.010522797,0.0029672838,-0.019662196,-0.01629037,-0.015573865,0.0062623257,-0.023605697,0.017208809,0.016093561,0.0026238458,0.020878417,0.008033213,-0.00048472884,-0.016464084,0.0015261538,0.0021433958,-0.015648942,-0.0114148045,-0.0021597464,-0.010089923,-0.0069884816,-0.011861548,-0.017699406,0.004435803,-0.0132174725,0.036920626,0.0094250925,-0.0034103605,-0.008361419,-0.0048731635,0.012618401,-0.0040295655,0.021245372,-0.015354832,0.008388246,-0.0083493395,-0.0005097189,-0.032582965,-0.008069843,-0.0008949101,0.005249053,0.0056641656,-0.027966656,-0.016120205,0.033611458,0.0051838285,0.0004120616,0.018288102,-0.0067820987,0.00033181658,-0.030278435,0.012236315,-0.0066942796,-0.0032955674,-0.0024639226,0.0018679883,0.035894226,0.015587761,-0.008241079,-0.0054033203,-0.0014196427,0.00079752563,-0.051259723,-0.012358313,0.02287089,-0.013261394,-0.014247255,-0.01887554,0.009484183,0.0027322972,0.0010355837,-0.019289656,-0.008480137,-0.013864151,-0.015975052,-0.010944484,-0.0010861069,0.0056745117,0.003238605,-0.014810496,-0.01899861,-0.0028350581,-0.022469379,-0.04729093,-0.0021338651,0.00993885,0.008747415,-0.005107847,0.0052409964,0.00023065583,-0.014576373,0.018294701,0.008332688,-0.021745168,0.01951898,-0.025162498,0.0058740685,-0.0061177993,0.018569289,-0.00027112212,-0.014300057,-0.033314884,-0.0066417195,0.00872282,0.015586144,0.02018321,-0.0068046474,0.005967032,0.0013763148,0.018755766,-0.017983312,0.005775713,0.055678893,-0.023795864,-0.00068122847,0.007430649,0.03025305,-0.022043997,-0.015682627,-0.007792891,-0.042884637,-0.024885597,0.011614318,-0.014744382,-0.00330908,-0.022369644,-0.013173269,-0.010812337,-0.0063803648,0.010348879,0.011050307,0.037761897,0.014115374,0.0022759524,-0.0068816356,0.03343044,-0.0027535139,-0.004861956,0.015161548,0.015875202,0.018355234,-0.058851313,0.010377704,0.00024136188,-0.0085912505,-0.022515362,0.0002794299,-0.036663707,-0.0091182655,-0.01903997,-0.054214817,0.020116989,0.0117519675,0.033496153,-0.015838953,-0.0058276914,-0.018876435,0.013336095,-0.02550714,-0.028510723,-0.0031534787,-0.022442544,0.021544399,0.0505816,0.014018737,-0.007298808,-0.04129749,0.0045806267,0.0021771444,-0.004586858,-0.0027851528,0.009020496,0.0052745515,0.007151069,-0.01834008,0.003587043,-0.0066937828,-0.017890222,0.0073972023,-0.024390155,-0.0030193203,0.014810657,0.0169277,-0.0046318704,0.008003685,-0.013482382,-0.0034894508,-0.011733816,0.005226514,0.009038564,-0.0212041,0.005045238,-0.01983451,0.010766445,0.0024036702,-0.030085186,0.02003246,-0.012073732,0.027046513,-0.009470646,0.001315292,0.0071030413,0.016820444,0.0021770496,-0.009972738,-0.0075717485,-0.0017132123,0.012833638,-0.021337619,0.024111645,-0.013008093,-0.0011169648,0.004233438,-0.005017244,-0.034522388,-0.009190016,0.007465302,0.030521344,0.00091726426,-0.0058096396,-0.007503483,0.053818103,0.006977875,0.01805213,0.010563425,-0.019665105,0.008499247,-0.022306163,0.002686468,-0.007217842,0.0060056937,-0.029392792,-0.008522676,0.054250196,0.009579997,-0.007226736,-0.0050710468,0.013151493,0.022535631,0.0006413423,0.00530215,0.017142704,0.03588958,-0.0362509,0.03430337,-0.010000865,0.02234807,-0.010933072,-0.03369348,0.0007626883,0.025966026,-0.004361792,-0.009834629,0.012091933,0.0057589896,-0.011170208,0.0030503448,0.0027152211,-0.0005660551,0.040305987,0.014146223,0.024119861,0.01423251,-0.028879523,-0.014446236,-0.023096474,-0.009780214,-0.0069393613,0.0072079436,0.026956262,-0.071050666,0.007780501,0.026051978,0.013502454,0.04158207,-0.003535495,-0.017102148,0.010328019,0.014729209,-0.00661686,-0.017437072,0.012798368,0.009385876,-0.006048506,-0.013253006,0.022723103,-0.07100923,0.009331326,0.021415416,0.0021601415,-0.009591125,-0.032246187,-0.008121151,-0.017292153,0.004498167,-0.006869357,-0.036958776,-0.004526747,0.003085893,-0.0037967842,-0.010169802,0.03689211,0.0121407155,-0.009685134,0.010428638,-0.027533233,0.03369279,-0.015625976,0.008912804,-0.0103669735,-0.021878589,0.017820623,0.023753243,0.03518648,-0.016502548,0.011112387,0.010985647,-0.01749866,-0.03087894,-0.023755739,-0.04495823,0.00037348154,0.017820911,0.0037406238,0.007764285,0.01745966,-0.005446078,-0.0334064,0.04168881,-0.006312329,-0.033288028,-0.009067852,0.0037151047,-0.0059845666,0.027987264,0.03782037,0.018905316,0.0021067292,0.013285197,-0.038188033,0.027316436,-0.009372403,0.0034240193,-0.012872769,0.03401295,-0.002511255,-0.027671374,-0.027135544,0.02863089,-0.0035279144,-0.04313665,0.027624948,0.0103135435,0.0132321315,-0.01909584,0.01531641,-0.013777451,0.015590164,0.014694399,-0.0057039172,0.015874365,-0.013494605,-0.015681596,-0.019375902,0.015440479,0.004373864,-0.014835745,-0.0036430417,0.020976556,0.0025877922,0.0054126545,0.0013725336,-0.046739727,0.007875432,-0.037524384,0.002347458,-0.008779943,-0.00787611,-0.019662108,-0.021043755,-0.011793613,0.033509634,0.005452942,0.010857428,0.021615807,0.00035849915,-0.0039448366,-0.0041216556,-0.0018243594,-0.020137962,-0.0052994443,-0.011900426,-0.007182716,0.049567718,-0.03325983,-0.005410858,0.017642315,0.0053783883,0.011835735,-0.021480422,-0.001862243,0.022605674,-0.031135647,0.010452089,-0.0035275365,0.034245983,0.034281947,0.0063441945,-0.0006289015,0.009401273,0.0034910475,-0.009323956,-0.002913833,0.0009018485,-0.015927695,-0.011592845,-0.017355943,0.017836725,-0.0060241465,0.002925659,0.014701696,-0.043640953,-0.0076912977,0.0021328947,-0.030565115,0.0024997657,0.023705319,-0.02630356,-0.013340535,-0.008312453,-0.002114964,0.032972235,0.0017754401,0.069847204,-0.041936412,0.0070937886,-0.0038194563,0.0049541392,0.03079307,-0.009883303,0.00825505,-0.007953132,-0.026477296,-0.013462484,0.005899825,-0.00035409332,-0.015173097,-0.018664027,-0.029865835,-0.043456353,0.01570908,-0.038964868,-0.014662561,-0.0051303427,-0.01048689,-0.017291088,-0.034878287,0.014336268,0.024576446,-0.01942282,-0.010447903,0.014119986,-0.037019353,0.00956747,0.02121652,-0.01896785,-0.0021266595,0.0034484179,0.031239005,-0.008439151,0.02762129,0.0009391148,-0.034198564,0.006121723,-0.004190767,0.010305833,0.008298887,0.029656373,-0.044411726,-0.019250743,0.01150074,-0.019162938,0.010097759,0.0035606858,0.021725338,0.001183217,0.016988663,-0.020879975,-0.0044026608,0.028368607,0.0050237607,0.014152438,-0.03799071,-0.025555192,0.016318832,-0.024402287,-0.019592768,0.011291268,0.024291504,0.00047956672,-0.013445606,0.004369491,0.021654297,-0.0021336277,0.015270859,-0.01715015,-0.012287409,-0.017110404,0.012856446,0.025718397,-0.004797091,0.020308824,0.0033317355,0.033273317,0.030139482,0.018226929,0.04733395,0.008059441,0.0051875566,0.053040367,0.038519733,0.019907387,-0.011627125,-0.015734209,-0.040599953,-0.026478408,0.009843225,-0.020313943,-0.0071347086,0.016321763,-0.00030702932,0.013571928,0.036568154,-0.0035627475,0.022333963,0.00028365,5.0592986e-05,-0.024466962,-0.04594634,-0.02809114,-0.035458613,0.011499292,-0.033176932,-0.025861798,0.0011169409,-0.0014545433,-0.011865099,-0.005451035,-0.016017674,-0.013665665,0.0064251698,0.036521934,0.0007617291,0.0075247665,-0.0059393733,0.05641924,0.009035866,0.001592016,-0.011885669,0.018934768,-0.010945957,0.009536714,-0.009275422,-0.012251928,0.009567838,-0.008246719,0.007232158,0.0024381725,-0.019306738,-0.0019204977,-0.009171293,-0.00078521617,-0.043910213,-0.018308159,0.002433521,-0.033224583,-0.009859091,-0.014710245,0.015941856,-0.032148138,-0.0035851565,-0.0035578364,0.004853416,-0.020194389,0.0042933985,0.0136722475,0.0038935312,-0.027405266,-0.0103999395,-0.017656323,-0.017222384,0.017053163,0.020627964,-0.017342778,-0.0025585282,0.054085612,0.01047985,0.019528981,0.0050092577,0.008113461,0.0062938314,0.008501784,-0.008049956,-0.033119377,0.02679209,-0.011248008,0.0153491,0.017221067,0.039746977,-0.0014536709,-0.004943411,-0.003558186,-0.002877431,-0.0040837834,0.014223607,0.027776346,0.008206469,0.026541226,-0.02068667,0.009922001,0.00073336775,-0.0227245,-0.035611257,0.025445703,-0.022059182,-0.001834305,0.009146505,-0.039098416,-0.009189657,0.04516138,0.0070761694,-0.019723397,0.017722247,0.0041767797,0.0045974893,0.0046199244,0.01639841,-0.039888963,-0.046200465,0.006525853,-0.0003858267,-0.0020400228,0.018201001,-0.03428886,0.022777589,0.010228126,0.007935344,-0.0026712178,-0.0018779162,-0.00084930944,0.008427211,0.01627501,0.020408958,-0.036412984,0.024203783,0.027754253,0.025678918,0.032419834,-0.0027376516,-0.0117534865,0.003982566,-0.0056119184,0.022222152,-0.028162817,0.016183887,-0.003007229,-0.0125066675,-0.0051880833,0.039359532,-0.024581902,-0.01419547,0.016865043,-0.001688868,-0.00939366,-0.014462349,-0.0038169066,0.020285167,0.046439935,0.010008124,0.034739427,-0.00027503734,0.026509948,0.015200338,0.03114114,-0.0020992141,0.006023996,0.048042312,-0.025514675,-0.014876316,-0.009505797,0.010599346,-0.013261807,0.0036494373,-0.008330778,0.010019076,-0.01251476,0.013001004,0.031731315,0.016981713,-0.0014158224,-0.0030113913,-0.009533908,-0.0057585817,-0.017287098,-0.00061275205,-0.0038537076,0.012948393,0.00083037786,0.063870326,0.028157076,0.0053685205,0.0034909442,0.009701781,0.022483405,0.012061115,0.004925256,-0.0047274306,0.016083773,0.014679932,-0.0018697263,-0.0076849964,-0.013427599,-0.008744454,-0.028697912,-0.0107891485,-0.04030242,0.01807508,0.044646367,-0.02091828,0.010875763,0.017025849,0.007384502,-0.005564449,0.005231019,0.014378369,0.016350163,-0.015807075,-0.010769232,0.006230671,-0.0020719732,-0.011109546,-0.010191367,0.0028003221,0.021042151,-0.028452639,-0.008464664,0.039462153,0.013294837,-0.01710925,0.0069198916,0.010958657,-0.015672654,-0.022182984,-0.0005415939,-0.06890494,0.015809124,-0.017161839,0.010053487,0.004423564,0.0019765017,-0.025349401,0.022604233,0.023146302,-0.020611443,-0.00810692,0.036887243,-0.035350736,0.022487031,0.010329623,0.062225737,0.025354452,0.012239612,0.040459365,-0.009985783,0.0030297325,-0.0047238087,0.009990177,-0.009708421,-0.0015417585,-0.008293324,0.009875573,-0.009967637,0.00591149,-0.009853044,-0.01479511,0.000362642,-0.008962117,0.029292202,0.0008605932,0.007872877,0.0047856965,0.03337952,0.0007374608,-0.045288928,-0.0007155551,0.020274151,-0.030780444,-0.012012746,0.008259954,-0.0024250078,-0.003746291,0.0050361417,-0.01889589,0.026861567,0.030675221,0.027984053,-0.044280477,0.02121798,0.0055090766,0.0035794717,-0.009700118,-0.0014733096,0.022251822,-0.011979807,0.039985973,-0.030558432,0.016566059,0.019227946,-0.013097443,0.024243288,-0.016437873,0.018849133,-0.009187988,0.02033165,-0.0023481238,-0.021600252,0.0048436862,-0.009396118,0.023348266,-0.023237895,-0.0012814201,-0.033980537,0.0054939617,0.0022479703,0.03757249,-0.025697183,0.0054226806,0.011623,-0.007856521,-0.002186799,-0.03700545,-0.054738093,-0.010351911,0.014305784,-0.0417207,-0.009080352,0.013146336,-0.0060101673,-0.0336388,0.013233407,0.010162011,-0.011652899,0.035791706,0.048783034,-0.023886971,0.014770075,0.0038678907,-0.02191981,-0.02945267,0.030666469,0.024122545,0.013423656,0.017034741,0.007282924,0.010718068,-0.019700717,0.0038537246,0.015609096,-0.0040877094,-0.01000541,-0.016784716,0.00072181225,0.0027299677,0.019893607,0.0078842,0.012744005,0.029021665,0.01337682,0.0055828034,0.034628324,-0.008149641,-0.008793387,0.029471396,0.04581664,0.026357828,0.016187798,0.006552952,-0.02516311,-0.016793465,-0.04380474,-0.010430723,-0.02777239,0.00023172844,-0.024223542,-0.01742603,0.020592514,-0.049959715,0.0023778758,-0.0130122835,0.016218439,0.036741607,-0.008372587,-0.01030176,0.0028719218,-0.016978698,-0.033767633,-0.047141783,-0.011682385,-0.004484695,-0.010752503,0.0076125637,-0.011849341,0.028025704,0.014208219,-0.019332709,0.014230733,-0.005332097,0.0034138786,-0.01914601,-0.015281703,0.03623092,-0.022542436,0.012130569,-0.006157562,-0.0077087884,-0.005255889,-0.0055815466,-0.016264167,0.0073631923,-0.025822617,-0.0248307,-0.008869322,0.007833033,0.0013243237,0.01941962,0.011773531,-0.016838202,0.012359716,-0.04139008,0.03367666,0.005928383,-0.008066318,-0.014217811,-0.008139627,-0.037487537,-0.021356795,-0.018175118,0.015567226,0.00021972902,0.0036437185,0.0009874521,-0.02071284,0.0141214635,0.0066559534,0.007960029,0.021577824,-0.025066858,0.035423066,-0.0009912326,-0.0002214476,0.015923738,0.0353596,-0.0059726685,-0.009932372,0.023326319,-0.021692542,-0.03078264,0.017852532,0.016893897,-0.00618346,-0.011727501,-0.025267765,-0.021582523,0.013167011,0.0062517645,0.010879914,-0.012292178,-0.027759017,-0.00085589965,0.011144635,-0.022892997,0.009746614,0.01864125,0.0043919324,0.025092049,-0.010011533,0.012593483,-0.027356211,-0.0005156177,0.007842007,-0.023621019,-0.0048905183,0.0036410054,-0.009010278,-0.001920871,0.011037081,-0.012626502,-0.027381511,-0.0074964333,0.017343108,0.0077384305,0.037464183,0.02822742,-0.035219427,0.01887636,-0.0007209561,0.012962107,0.007567334,0.028673522,-0.01715949,-0.027144622,0.042659108,-0.00397564,0.016808053,-0.029752672,-0.007804824,0.028992534,-0.0100074075,0.015125762,-0.027364006,0.024652312,-0.019261472,-0.028858662,-0.029297195,0.033696182,0.0031153192,-0.017225103,-0.035750885,-0.030969951,0.009700901,-0.0022994885,0.03259697,-0.0018147301,0.014485253,0.011055376,0.017958095,-0.022675289,-0.0093333265,0.0024679024,0.004003889,-0.036312383,-0.0017114126,0.03931309,-0.012555464,0.01749933,-0.0063242135,-0.019974323,0.02181679,-0.015041652,-0.025472404,-0.05275504,-0.028697358,-0.0061315713,0.024494827,-0.00791938,0.0039152782,-0.0025596542,0.035114583,-0.012034858,0.030472016,-0.0152364625,0.012684347,0.0065173,0.0022622969,-0.004517346,0.015088984,-0.022197936,0.00038274136,0.013126678,0.028067036,0.045301426,0.0012400919,-0.0067908894,0.018791186,-0.011427681,-0.010030311,-0.025978543,0.030685095,-0.0026185357,-0.0051067653,0.011414106,0.016079633,0.014824242,0.008440582,-0.021323465,0.020317074,-0.029075444,-0.041359723,-0.034261275,-0.017127816,0.022012945,-0.020681359,-0.003311714,0.0015572945,0.003842157,0.004259879,0.015773779,-0.0068174815,0.010557491,0.03191498,-0.009189065,0.028390175,-0.014662107,0.0032890707,0.01335846,0.00935532,-0.008929905,0.0048679574,0.0028554406,0.02292422,-0.001667006,0.0062811957,-0.018514188,0.014956988,-0.03543527,-0.013433276,0.002835244,0.02269391,-0.031001857,0.013927836,-0.0011145921,-0.0029895192,0.007796328,0.0027484987,0.02564728,0.0032167214,-0.026129484,0.03095655,-5.6591667e-05,-0.00088623125,-0.016099444,-0.027240666,0.023599235,0.011098884,0.009303351,-0.011476857,0.005525489,-0.031060439,-0.0059094233,0.009653784,0.034201816,-0.01191497,0.0019486864,-0.017907154,0.0032989574,-0.0048286193,-0.014045843,0.009933816,0.008397211,0.015764913,-0.014775264,-0.015297882,-0.002489433,-0.0085742,-0.012249565,-0.013119609,0.011223297,0.028989136,-0.011126433,0.00452454,-0.00067740615,-0.011709687,0.030645873,0.009530959,0.03687369,-0.010940085,-0.02636934,-0.027038036,-0.021679657,0.009800008,0.0077321404,0.012179554,-0.016399546,0.028568147,0.052173212,-0.031927153,0.010739969,0.012391743,-0.0109110065,0.0058829486,0.03329713,0.0005365943,0.023036478,-0.010948905,0.029241543,-0.010648183,0.02717126,-0.030051516,0.027587542,-0.02151227,-0.0038374322,0.0022857646,0.03189867,0.008132007,-0.013273913,0.01406956,-0.013513286,0.023077616,-0.008001037,-0.008508754,0.021477824,0.017395632,-0.012753316,0.0136575755,-0.01637526,-0.0031769546,0.016622601,-0.03306974,0.0016727095,0.014922964,0.023159439,0.038503423,-0.050742123,0.027333792,-0.00010087816,0.016618326,-0.007367918,0.004716329,-0.027339585,0.015547648,-0.024740078,-0.037650056,0.028878262,-0.004583126,-0.012838544,-0.0024064411,0.019872446,0.016737975,-0.036997907,0.010984164,0.010160668,-0.00061936164,0.013030526,-0.015540923,-0.012943752,-0.029340753,-0.026891839,0.022595711,0.010735616,-0.024154197,0.0045660874,-0.006115724,-0.017305719,-0.03289647,0.0036768531,-0.017993415,0.0011367516,-0.0045018187,-0.02207373,0.016817715,0.020715466,-0.028040908,0.014046403,-0.02466725,-0.003916455,-0.022988975,0.008232634,0.0070415847,-0.0011923829,-0.013266358,-0.0016376319,0.0023378928,-0.00963001,0.021612233,0.00500931,0.0038255157,-0.022416797,0.0038807497,0.04021338,-0.0010865221,-0.02194559,0.0044041337,0.0013739206,-0.025819717,-0.01987162,0.023138085,-0.006799188,-0.02248134,0.028230187,-0.00596432,-0.013104973,0.025734816,0.031531837,0.014797788,0.00032858364,-0.0073057506,0.017760413,0.060520563,0.0038043326,0.033070177,0.05240934,-0.040075786,-0.009270257,-0.0044157435,-0.0007471322,-0.0020788277,-0.013829018,0.0062717614,0.020955857,0.026528746,-0.022062073,-0.018609278,0.03332793,-0.0076485137,-0.018052604,-0.024141565,-0.016890924,-0.013309516,0.016489545,0.007339736,-0.04466016,-0.015043559,-0.009864952,-0.012229249,-0.016646093,0.014823634,0.005847327,-0.0019218654,0.016923156,-0.010130683,0.0017749108,-0.020841211,-0.025274007,-0.004398175,0.006093325,-0.04477667,0.0077001723,0.057356305,-0.011378838,-0.008687168,0.019290388,0.014673344,0.0042427788,0.013280325,0.03128235,-0.012295242,-0.0042618453,-0.0023668269,-0.0032620023,0.017070873,-0.016277585,-0.0033919353,-0.00040750287,0.007367579,0.0044148564,-0.000548269,-0.0014099041,-0.026213553,0.0025488404,-0.0024884492,-0.010903007,-0.011286968,0.00096061453,0.030593429,0.023129307,0.0040521976,0.02265038,-0.017056223,0.018567767,-0.003114527,-0.012610581,0.007547201,0.014665901,0.04142493,-0.0015056151,0.017901473,-0.00016457772,-0.0064837914,0.02789162,-0.015430436,-0.008759588,-0.006903385,0.016341018,0.037058074,-0.014068731,0.014861523,-0.01621673,-0.021826645,0.01276032,0.007939147,-0.03517303,0.027444197,-0.003304607,0.017482907,-0.018969819,0.004873403,-0.006296646,0.02895607,-0.038265593,0.011257127,-0.0025617254,-0.005790459,-0.0075471,-0.0024353287,0.006395111,-0.019855553,-0.018935317,0.017707406,0.0053678304,0.009140495,-0.011026064,0.018754382,0.017931055,0.001405399,0.012443902,0.036442075,-0.0045140684,-0.00019759784,-0.006167285,-0.010543998,-0.014574537,-0.017924482,0.021623941,-0.026372405,-0.0041525,-0.03441245,-0.013281018,0.004259193,0.0019437772,0.012647667,-0.0046845023,-0.0071182153,-0.0052770474,0.00999896,0.03282185,0.014189526,-0.020907396,-0.021254312,-0.029231034,0.0040996457,-0.010741372,0.0029624682,0.01938201,-0.020460712,0.009676277,-0.013426563,-0.009789964,-0.016570427,0.007199828,-0.0062952936,0.0014195779,-0.0019139156,-0.021317463,0.024592709,0.012995138,0.03368559,0.027218575,0.011232379,-0.03715403,0.0016346389,0.02578156,0.011682589,0.014477554,-0.008024367,-0.0028998805,-0.027891038,0.03783361,-0.015141537,0.01740741,-0.0023314448,-0.05147873,0.003711815,-0.010531898,0.04023469,0.0036569403,-0.023372421,0.015630057,0.002683749,0.030431032,-0.04630873,-0.022930125,0.0019694183,0.04160948,0.0097969575,0.024017537,0.01680591,0.025922956,0.006059105,-0.0007973433,0.030275665,0.0071799266,0.018103434,-0.0056028203,0.008251305,-0.007349937,0.0128295235,-0.03810285,-0.009720937,0.0254046,-0.0011926538,-0.019158788,-0.004844166,0.013064749,-0.0118827345,0.0032364265,0.016412452,-0.004076288,-0.02381056,0.038298,0.0021545826,0.003553007,-0.00987301,-0.013119777,0.04701003,-0.018858401,-0.0059408634,-0.019195618,-0.04294771,0.010229049,0.02262425,0.013121867,-0.015589612,0.002389053,-0.011773254,-0.0030383598,0.018860668,0.03190386,-0.0031422952,-0.021629132,0.03533225,0.02187003,0.005687974,0.004942323,-0.012584212,-0.003192901,0.038356625,-0.035450984,0.012777468,0.020028446,0.007818852,0.017783852,-0.0100992145,-0.03970698,-0.01948464,-0.004170745,0.00784466,0.022867365,0.011466004,-0.017940486,-0.025582412,-0.033047393,0.005119754,-0.0124119185,-0.0018860181,0.020209655,-0.0036906102,0.019015856,0.016354613,-0.029290035,-0.0010464696,0.03396744,0.019011132,-0.0051294323,0.0057920385,0.0072728875,0.014997635,-0.009651578,0.013348157,0.006824754,-0.027685177,0.020925472,-0.033351336,-0.012240557,-0.016181644,0.012364911,0.0039659324,-0.008511325,0.0119133,-0.013487898,-0.00042494517,-0.013438785,-0.0061509493,0.00207739,-0.004391414,0.03893858,-0.013765656,0.0115400255,0.024004217,-0.00024043603,0.017528478,-0.031666845,0.031576842,0.011629496,-0.0074819406,-0.001232977,-0.0027119955,0.013825764,-0.02386262,-0.03838706,-0.0018250716,-0.033593945,0.00020234869,-0.015698066,0.031603903,-0.014920008,0.0054551773,0.00013214964,0.007949086,0.0045514116,0.0200444,0.01276233,0.0392696,0.031031338,0.018935412,0.015569856,-0.012941269,-0.006314466,-0.025851462,-0.036099985,0.00692487,0.02553151,-0.010337753,-0.023529684,-0.019566303,0.039924905,-0.01629188,-0.0043313457,-0.014980671,0.014860917,-0.022598056,-0.033236984,0.0143355625,-0.036117446,-0.0002572702,-0.030551763,0.022565575,-0.027319284,-0.025564307,-0.0055438187,0.012166327,0.044677347,0.0158264,-0.008088046,0.008262914,-0.0011386459,-0.012959964,0.031446427,-0.010671285,-0.0050823204,0.012690253,-0.0038716265,0.017978707,-0.003409566,0.03980772,0.018526006,-0.026622003,-0.011940543,0.032771863,-0.033955727,0.01964601,0.0042805397,-0.009651317,-0.014760126,0.011800687,-0.0005475485,0.006959756,-0.036079563,0.007673123,-0.03815248,-0.035414528,-0.020550825,-0.02365881,-0.017714957,-0.011052479,0.013282857,-0.019181496,-0.0067689656,0.011713641,-0.00093182205,0.0185339,-0.0023920697,-0.022393383,0.01824346,-0.022256676,-0.018808288,0.0057522426,0.0007224669,-0.02229344,-0.017325977,0.009456942,-0.001741603,0.013648054,-0.004986299,0.014742844,-0.008004744,0.025029093,-0.0074123554,-0.05269893,-0.0040332517,0.007600425,0.026861837,-0.009158719,0.00066312146,-0.006867898,-0.018097153,0.029672505,0.008669145,-0.0038177825,-0.0021149553,-0.016022656,0.01025228,0.029377338,0.003645079,-0.0029148026,0.010098195,0.010563134,0.0024963273,0.027400305,0.0058735516,0.027722044,0.05532975,0.022049915,-0.009995486,0.025847252,0.030537538,-0.007390051,-0.011240648,-0.013333238,0.019084366,0.02230234,0.03238266,-0.020831915,0.008700438,-0.0077135907,0.0042190007,0.03933357,-0.028524643,-0.02361194,-0.027867377,0.021467602,0.00048040546,0.0106291305,-0.0041540037,-0.004632336,-0.015925828,-0.012166313,0.004816171,-0.012955342,0.017753204,0.020849047,-0.026579173,0.04378424,0.023663664,-0.01532666,0.0123600755,-0.02299343,-0.032375544,0.036315154,0.03890968,-0.013212403,-0.0101365475,0.0059070145,-0.025649646,-0.031067895,0.014604353,-0.020470722,-0.00756122,-0.025606457,0.0069950605,-0.019251026,-0.010910583,0.0105339,0.0017663179,0.00431287,0.026859967,-0.010214661,0.009764019,0.00553867,0.0023257362,-0.007947305,-0.00029109974,-0.018330097,0.0127253495,-0.008333999,0.019472284,0.033101372,0.023033483,-0.023474444,0.003549307,0.007834722,0.034199532,0.009770782,-0.020296827,-0.03784896,0.008775063,0.004181295,0.0041797697,0.003407148,-0.052145045,-0.0412283,-0.029959232,0.007939273,-0.011552012,-0.049889084,0.038868673,-0.025003746,0.021611368,-0.017194588,-0.003399691,-0.018609503,-0.0022707162,0.0020355997,-0.02514002,-0.0020400765,-0.00497688,-0.045323007,0.0067283963,-0.026328485,-0.014569159,0.026841728,0.008548219,-0.010988592,0.008077372,-0.02492029,-0.018902358,0.03369758,0.020584865,-0.036892634,-0.045601673,0.01277968,0.009920486,-0.016296435,0.019136036,0.0409212,0.0044907974,-0.0071904375,-0.00992843,0.0051266374,0.031089557,0.03653391,-0.0067753093,-0.017784864,-0.006682795,-0.014960802,-0.013326768,-0.017884381,-0.020183953,-0.015950873,0.026623318,-0.015088812,-0.01803876,-0.0029404527,-0.013948695,-0.014882147,-0.021033742,-0.025985874,0.038116995,-0.015222262,-0.010345744,0.015866548,-0.002901211,-0.02316057,0.034660548,-0.015312072,-0.025651032,-0.00775001,-0.011427996,0.01788557,-0.016387908,0.02586366,0.01945635,-0.0067824377,-0.034669157,0.011616714,-0.0060581486,-0.0021756643,0.011181876,0.0112316115,0.010708116,0.030294886,0.021976193,-0.0017710233,0.01431786,-0.007521296,0.015403019,0.012448935,0.015385487,0.021723801,-0.023086531,-0.0054850606,-0.010631848,0.012172002,0.020367319,0.013493075,-0.012230428,0.021105725,-0.042638976,0.041709036,-0.008607715,-0.041707072,-0.012805332,-0.011402244,0.0147720305,-0.010284758,8.504053e-05,0.012608127,-0.0292576,-0.02703923,-0.010266934,0.0018076984,-0.029718433,0.00078265235,-0.00661614,0.009528668,-0.022617036,-0.011240203,-0.008607371,-0.0011659893,-0.0025144804,-0.0025277082,-0.029882694,-0.0141318925,0.02403232,-0.0036800092,-0.039114315,-0.0326807,-0.0016511619,0.026736451,0.020634279,0.009594554,0.017298814,0.026138995,-0.031643394,-0.0067301188,0.014558594,-0.02006735,0.0037525038,-0.013484756,-0.012003008,0.005268215,0.007238224,-0.02475004,0.01730516,-0.017188696,-0.0033034,0.02002786,-0.03708292,0.024229323,0.019583108,-0.002075307,0.019490952,0.014699636,-0.014513321,0.002021493,-0.0002897847,0.0062127234,0.025350552,-0.008024825,0.020154282,0.020966182,-0.0046751886,-0.016612858,-0.01537969,-0.012765143,-0.011911934,-0.035091303,-0.017256211,-0.0002753146,0.015868641,-0.009185282,0.009079679,-0.022006521,0.03423098,0.019968823,-0.033981435,0.029894073,0.00699433,-0.019108802,0.024606334,0.013155228,0.005679999,0.011105047,0.029614728,0.0053199325,0.03243119,-0.026824092,0.0070501124,0.032534204,-0.024578108,0.018566413,0.021878846,-0.058400385,0.017617857,0.028656092,-0.0040120315,0.019189892,0.010564047,-0.012239812,0.0026420031,-0.0038664849,-0.004198595,-0.030964084,-0.014171585,-0.027738005,0.01992692,-0.023356965,0.0144606335,-0.025174474,0.0034109754,-0.0029020368,-0.016072184,-0.022035006,-0.0011700038,-0.0067975293,0.017011754,0.013302185,0.008977035,0.0070430296,-0.025686668,0.008141109,-0.008651052,0.0030577835,-0.0042570424,0.0066613806,0.008788745,0.029496305,0.031665552,-0.006109858,0.04450453,-0.0085394,0.008373168,0.02092451,0.021716572,-0.021968704,-0.023149857,0.0025939024,-0.01015511,0.038461253,-0.025451113,0.004754193,-0.02525665,-0.0033952924,0.0011290229,-0.03690592,0.029311609,0.0016711405,-0.011818583,0.022979276,-0.027906211,0.012890766,0.00972964,0.015413523,-0.016017722,-0.0031696279,-0.018254036,0.01108831,0.023960056,-0.0038092416,0.012979714,-0.0034785287,0.009420632,0.0077361665,0.014135554,0.016151857,-0.0013367798,0.013955394,0.027600516,-0.02416085,0.004080072,-0.028618004,0.02420082,0.025290014,-0.02105777,0.012275087,-0.0129569685,0.0013389928,0.006269741,-0.015477424,-0.011144235,0.00036034873,-0.0009166368,0.0017238914,-0.026608484,-0.0006559434,-0.010509436,0.020399816,-0.020858599,0.008563966,-0.016281,-0.020752968,0.026383312,-0.0035515835,-0.008563081,0.020216608,0.0130014345,0.007859609,0.0066859764,-0.01837838,-0.0040127425,-0.0009839704,-0.00023039924,0.0062073837,0.017460931,-0.052294068,0.01836758,-0.020413809,0.002744356,0.005541627,-0.0187056,0.024304142,-0.013691449,0.03688495,-0.004814724,0.016062383,-0.0028424633,-0.0069452617,0.02591596,0.012302196,0.018625895,0.0073783593,-0.022229174,-0.020182353,-0.041115753,-0.0015006879,0.005669352,0.0044288044,-0.0070070825,0.020079577,0.0002558309,0.0143877845,0.002272275,-0.0033701274,0.027098892,-0.0072721685,-0.03397488,0.029287258,0.019622022,0.0020805928,-0.009442994,0.000988,0.006243586,0.011991248,-0.013725304,0.0072516142,-0.012597423,0.009873813,-0.0076120733,-0.008981402,0.008813132,-0.0110565685,-0.039658368,0.016968131,-0.028800543,0.008303782,0.02221395,-0.031175058,-0.032978926,-0.0022029541,-0.024015822,0.0057426244,-0.013470741,-0.030829819,0.014833237,0.008993489,-0.0110818595,0.0057405727,-0.002517907,-0.0034596794,-0.017604707,0.0126801375,0.006863479,0.033452395,-0.005696013,-0.009065959,0.03255709,0.012610335,0.012069848,0.021402068,-0.016720016,-0.014934949,-0.0016374881,-0.0103502255,-0.019108247,-0.0017200578,-0.041368783,0.0033222344,-0.024315804,-0.008197347,-0.026188364,0.0047499826,0.009918687,-0.036384847,0.018974528,0.0022930228,0.019619111,-0.0031480957,-0.008483742,0.021463383,0.040040642,0.0153468745,-0.02157442,0.00067760336,0.012496099,0.019640844,-0.005380632,0.020344824,-0.0118187,0.012869449,-0.024488494,0.008581752,0.0068369694,0.0156645,0.016751919,0.005795027,0.026092093,0.011916162,-0.00910573,0.01709456,-0.0020211667,-0.0055410215,0.001721539,-0.039381314,0.020325225,-0.013575959,-0.01754455,0.022461986,0.027777359,0.026830629,-0.005692919,0.021652987,0.037796523,-0.026320552,-0.020324128,-0.011827028,0.024902524,0.012228895,0.00976273,-0.0051174406,0.008341172,-0.007418582,-0.018634629,0.010404702,0.0057147807,0.0044654342,-0.001967469,-0.012368226,-0.005418809,0.016359305,-0.013088599,-0.0037538793,-0.010306994,0.013472934,0.0346561,0.0073039634,-0.0070817033,-0.0042016283,-0.0026717552,0.019217702,0.021407133,0.0430673,-0.025823733,-0.016312016,-0.008436043,0.009131315,-0.018498626,-0.031246316,0.00095278525,-0.024925595,-0.009631444,-0.011187596,0.021851718,-0.0027255006,0.017276213,-0.0050248276,0.009341864,0.008558858,0.034903836,0.01688284,-0.0048197205,0.04519903,0.014300936,0.006362668,-0.018866846,0.017451322,0.015025282,0.021226745,0.0047659297,0.02296233,-0.02126139,0.022942226,0.004135988,0.0053184247,-0.012994796,0.0048972573,0.029275768,-0.02510846,0.008840451,-0.0012010303,-0.025765881,1.3161042e-05,0.0013193558,0.037932243,-0.003626704,0.00043455532,-0.018017376,0.018157378,0.012740366,0.0024680598,-0.010218323,0.009302064,-0.034453657,0.0026879397,0.012808719,-0.02186209,-0.0017856051,-0.00208927,-0.0029334547,0.015395416,0.0072100805,-0.03599899,-0.02479429,-0.0043502036,0.0070934393,-0.004374234,-0.011067623,-0.018796083,0.005016073,-0.0058341613,0.018321143,-0.0042493045,0.0017474103,-0.013151629,-0.023017455,-0.016500253,0.012496377,0.03465835,-0.0060469015,0.011646962,-0.021851504,0.0005544392,0.014419081,-0.02167141,0.021931952,-0.0019824596,-0.0129978,-0.010684337,0.016493289,0.012641919,-0.0016368428,0.015862504,-0.018356115,0.014205686,0.016233185,0.0057088234,0.0013446178,0.006276955,-0.03400066,-0.028293282,-0.007462615,-0.018586611,0.024643851,0.0070455167,-0.026687678,-0.03706883,-0.017926931,-0.025260784,0.021071644,0.016715316,-0.019908018,0.01954986,-0.015638832,0.0010511903,0.0058506723,0.019890217,0.0048489138,-0.014180828,0.0011671596,0.00021365544,0.0057431,-0.01716976,0.0041581737,-0.014269677,-0.03549173,-0.017639315,-0.0016383507,0.0054538324,0.007980294,0.0074886424,-0.015067017,0.0056998357,0.0042224564,0.023209346,-0.016668558,-0.013121912,0.02128014,0.0012170626]