From eef0e69f1a18a3c0702e5af16e9b4966681212da Mon Sep 17 00:00:00 2001 From: Richardson Douglas Date: Fri, 30 Oct 2026 12:46:39 -0300 Subject: [PATCH] feat: Apache Cassandra Adds Apache Cassandra (CQL) as a connection type, on the `scylla` native driver with TLS through rustls. Works with Cassandra 3.x, 4.x and 5.x. - Driver: contact points, port, optional user and password (keychain), default keyspace, local datacenter and TLS modes; server version from system.local; row limits through paging; server warnings as messages; Cancel stops waiting (CQL has no server-side cancel); no transactions. - Exact values: varint and decimal keep every digit, timestamp, date, time, duration, uuid, inet, blobs as hex, and collections, tuples, UDTs and Cassandra 5 vectors as CQL literals. - Schema browser from system_schema: keyspaces, tables, materialized views, types, functions, aggregates and indexes, with partition, clustering and static column markers. Show DDL builds CREATE statements from the catalog. - Grid editing for single-table SELECTs that include the whole primary key, saved as one logged batch. - Interface: engine picker, connection dialog, CQL editor mode with BEGIN BATCH ... APPLY BATCH as one statement, script splitting, identifier quoting, tree actions and DDL templates, no commit controls. - Export to SQL, CSV and JSON and import from CSV and JSON, MCP tools with a CQL statement classifier, DBeaver and DataGrip connection import. - JSON import keeps numbers exact (no rounding beyond 17 digits). - CI runs Cassandra 4.1 and 5.0 integration tests. --- .env.example | 6 + .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .github/workflows/ci.yml | 41 + CHANGELOG.md | 14 + CONTRIBUTING.md | 2 +- README.md | 6 +- docs/AI.md | 15 +- docs/ARCHITECTURE.md | 14 + packaging/arch/PKGBUILD | 2 +- packaging/arch/PKGBUILD.ci | 2 +- packaging/arch/lowquery.desktop | 4 +- src-tauri/Cargo.lock | 204 +- src-tauri/Cargo.toml | 4 +- src-tauri/src/drivers/cassandra.rs | 2619 ++++++++++++++++++++ src-tauri/src/drivers/cassandra_tests.rs | 705 ++++++ src-tauri/src/drivers/mod.rs | 10 + src-tauri/src/drivers/mysql.rs | 1 + src-tauri/src/drivers/oracle.rs | 1 + src-tauri/src/drivers/postgres.rs | 3 +- src-tauri/src/drivers/sqlite.rs | 1 + src-tauri/src/drivers/sqlserver.rs | 1 + src-tauri/src/import/datagrip.rs | 40 +- src-tauri/src/import/dbeaver.rs | 61 +- src-tauri/src/import/jdbc.rs | 80 + src-tauri/src/lib.rs | 1 + src-tauri/src/mcp/catalog.rs | 170 +- src-tauri/src/mcp/e2e_tests.rs | 319 ++- src-tauri/src/mcp/guard.rs | 206 +- src-tauri/src/mcp/mod.rs | 6 +- src-tauri/src/mcp/server.rs | 10 +- src-tauri/src/mcp/tools.rs | 47 +- src-tauri/src/model.rs | 9 + src-tauri/src/transfer/catalog.rs | 8 +- src-tauri/src/transfer/export.rs | 23 +- src-tauri/src/transfer/import.rs | 149 +- src-tauri/src/transfer/round_trip.rs | 129 +- src-tauri/src/transfer/sql.rs | 371 ++- src/App.svelte | 10 +- src/lib/api.ts | 8 +- src/lib/components/ConnectionDialog.svelte | 32 +- src/lib/components/QueryView.svelte | 56 +- src/lib/components/Sidebar.svelte | 38 +- src/lib/components/SqlActionDialog.svelte | 2 +- src/lib/components/SqlEditor.svelte | 3 +- src/lib/components/TableDesigner.svelte | 18 +- src/lib/components/TreeMenu.svelte | 28 +- src/lib/dev-mock.ts | 157 +- src/lib/engines/index.ts | 18 +- src/lib/engines/logos.ts | 7 + src/lib/sql/ddl.fixtures.json | 177 ++ src/lib/sql/ddl.fixtures.test.ts | 97 +- src/lib/sql/ddl.test.ts | 110 + src/lib/sql/ddl.ts | 142 +- src/lib/sql/ident.test.ts | 31 +- src/lib/sql/ident.ts | 13 + src/lib/sql/split.test.ts | 88 +- src/lib/sql/split.ts | 32 +- src/lib/state/actions.svelte.ts | 38 +- src/lib/state/transfer.svelte.ts | 3 +- src/lib/view.fixtures.test.ts | 2 +- src/lib/view.test.ts | 1 + src/lib/view.ts | 4 +- 62 files changed, 6192 insertions(+), 208 deletions(-) create mode 100644 src-tauri/src/drivers/cassandra.rs create mode 100644 src-tauri/src/drivers/cassandra_tests.rs diff --git a/.env.example b/.env.example index 8b1a1bb..f9cf99a 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,9 @@ LQ_MSSQL_DB=master LQ_REDIS_HOST= LQ_REDIS_PORT=6379 LQ_REDIS_PASSWORD= + +LQ_CASSANDRA_HOST= +LQ_CASSANDRA_PORT=9042 +LQ_CASSANDRA_USER= +LQ_CASSANDRA_PASSWORD= +LQ_CASSANDRA_DC= diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 079a86a..3fa8f0a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -33,6 +33,7 @@ body: - SQL Server / Azure SQL - SQLite - Redis / Valkey + - Apache Cassandra - Not related to a specific database validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff427af..f7352a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -313,6 +313,47 @@ jobs: - name: MCP tests against a real server run: cargo test --locked --lib --manifest-path src-tauri/Cargo.toml mcp_redis -- --ignored --test-threads=1 + cassandra: + name: Cassandra ${{ matrix.version }} integration + runs-on: ubuntu-22.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + version: ["4.1", "5.0"] + services: + cassandra: + image: cassandra:${{ matrix.version }} + env: + MAX_HEAP_SIZE: 1G + HEAP_NEWSIZE: 200M + ports: ["9042:9042"] + # The server takes about a minute to accept CQL connections. + options: >- + --health-cmd "cqlsh -e 'SELECT release_version FROM system.local'" + --health-interval 15s --health-timeout 10s --health-retries 20 --health-start-period 40s + env: + LQ_CASSANDRA_HOST: 127.0.0.1 + LQ_CASSANDRA_PORT: "9042" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: src-tauri + key: cassandra + - name: Install Linux desktop dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev libdbus-1-dev + - run: mkdir -p dist + - name: Integration tests against a real server + run: cargo test --locked --lib --manifest-path src-tauri/Cargo.toml drivers::cassandra -- --ignored --test-threads=1 + - name: MCP tests against a real server + run: cargo test --locked --lib --manifest-path src-tauri/Cargo.toml mcp_cassandra -- --ignored --test-threads=1 + - name: Export and import round trip + run: cargo test --locked --lib --manifest-path src-tauri/Cargo.toml cassandra_export_import -- --ignored --test-threads=1 + # Builds the native Arch package the release publishes. Manual runs only: # it compiles the whole app, which pull requests do not need. arch-package: diff --git a/CHANGELOG.md b/CHANGELOG.md index ee9520a..c072d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to LowQuery are documented here. The format follows [Keep a ## [Unreleased] +### Added +- **Apache Cassandra** (CQL) as a connection type, on the `scylla` native driver with TLS through rustls; it works with Cassandra 3.x, 4.x and 5.x. + - Contact points (several, comma separated), port, optional user and password (kept in the OS credential store), default keyspace, local datacenter and TLS mode. + - Schema browser from `system_schema`: keyspaces (system ones last), tables, materialized views, types, functions, aggregates and indexes, with partition key, clustering and static column markers. Show DDL builds `CREATE` statements from the catalog. + - Exact values: `varint` and `decimal` keep every digit, `timestamp`, `date`, `time` and `duration`, `uuid`, `inet`, blobs as hex, and collections, tuples, UDTs and Cassandra 5 vectors as CQL literals that can be pasted back into an `INSERT`. + - Row limits use paging, so a `SELECT` on a big table reads one page, not the table; server warnings show as messages. + - CQL editor mode, with `BEGIN BATCH ... APPLY BATCH` run as one statement and `$$`, `--`, `//` and `/* */` handled by the script splitter. Cassandra has no transactions: the auto-commit, commit and rollback controls are hidden, and Cancel stops waiting for the answer (CQL has no server-side cancel). + - Grid editing for a single-table `SELECT` that includes the whole primary key; changes are saved as one logged batch. + - Export to SQL, CSV and JSON and import from CSV and JSON (imports run one `INSERT` at a time, with no transaction to roll back), and the AI agents (MCP) tools with a CQL-aware statement classifier. + - DBeaver and DataGrip connection import maps Cassandra connections. + +### Fixed +- Importing a JSON file no longer rounds numbers with more than 17 digits. + ## [0.5.3] - 2026-10-24 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c04c071..fafcc3c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,7 +54,7 @@ set -a; source ../.env.test; set +a cargo test --lib -- --ignored --test-threads=1 ``` -SQLite tests need no server and always run. CI runs the server tests against PostgreSQL 13 and 17, Oracle Database Free, MySQL 8.4, MariaDB 11.4, SQL Server 2022, Redis 7.4 and Valkey 8 in containers, so a pull request shows whether a change works on every engine. For a bug fix, add a regression test that fails without the fix. +SQLite tests need no server and always run. CI runs the server tests against PostgreSQL 13 and 17, Oracle Database Free, MySQL 8.4, MariaDB 11.4, SQL Server 2022, Redis 7.4, Valkey 8 and Apache Cassandra 4.1 and 5.0 in containers, so a pull request shows whether a change works on every engine. For a bug fix, add a regression test that fails without the fix. ## Open a pull request diff --git a/README.md b/README.md index db34948..46785f7 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ --- -LowQuery is an **open-source desktop SQL client** for Windows, Linux and macOS. It connects to **PostgreSQL, Oracle, MySQL, MariaDB, SQL Server, SQLite and Redis**, and treats each one as itself: every engine goes through its own native driver and protocol, with server-side row limits, exact values and the script rules of that engine. Oracle needs **no Oracle Client or Instant Client**. The app is built with Tauri and Rust, so it starts fast and stays small. +LowQuery is an **open-source desktop SQL client** for Windows, Linux and macOS. It connects to **PostgreSQL, Oracle, MySQL, MariaDB, SQL Server, SQLite, Redis and Apache Cassandra**, and treats each one as itself: every engine goes through its own native driver and protocol, with server-side row limits, exact values and the script rules of that engine. Oracle needs **no Oracle Client or Instant Client**. The app is built with Tauri and Rust, so it starts fast and stays small. | Database | How LowQuery connects | | --- | --- | @@ -34,6 +34,7 @@ LowQuery is an **open-source desktop SQL client** for Windows, Linux and macOS. | SQL Server and Azure SQL | TDS protocol with [`tiberius`](https://crates.io/crates/tiberius), SQL logins or Windows accounts | | SQLite | Embedded engine ([`rusqlite`](https://crates.io/crates/rusqlite)), opens local database files | | Redis and Valkey | RESP3 with [`redis-rs`](https://crates.io/crates/redis): redis-cli commands, results as tables, keys browsed by type | +| Apache Cassandra | CQL native protocol with [`scylla`](https://crates.io/crates/scylla) (Cassandra 3.x to 5.x): keyspaces, tables, views, types and functions from `system_schema`, exact CQL values | ![LowQuery on Windows with the schema browser, SQL editor and result grid](assets/screenshots/lowquery-desktop.png) @@ -120,8 +121,9 @@ gh attestation verify LowQuery--linux-x64.AppImage --repo LowBloat/LowQ | SQL Server, Azure SQL | `SET ROWCOUNT` for plain SELECT batches | Exact decimal, datetime2 and datetimeoffset precision, uniqueidentifier, binary as hex | Not exposed by the driver yet | TDS attention | `GO` batches, `BEGIN...END` blocks, `[brackets]` | | SQLite | Stops stepping at the limit | Storage class and declared type; blobs as hex | — | `sqlite3_interrupt` | Trigger bodies, `[brackets]`, backticks | | Redis, Valkey | Long replies cut at the limit; the tree uses `SCAN` | RESP3 types: maps as field/value, `WITHSCORES` as member/score, `INFO` as rows | — | `CLIENT UNBLOCK` for blocking commands | One redis-cli command per line, `#` comments | +| Apache Cassandra | Paging: the page size comes from the limit and reading stops there | Exact `varint` and `decimal`, `timestamp`, `date`, `time`, `duration`, `uuid`, blobs as hex; collections, tuples, UDTs and vectors as CQL literals | Server warnings (tombstones, aggregations without a partition key) | Stops waiting; CQL cannot cancel on the server | `;`, `BEGIN BATCH ... APPLY BATCH` as one statement, `$$` bodies, `--`, `//` and `/* */` comments | -**Network configuration.** PostgreSQL, MySQL, MariaDB and SQL Server use host, port and database, with `disable`, `prefer`, `require` or `verify-full` TLS. SQL Server also takes a named instance (found through SQL Server Browser) and can sign in with the Windows account running LowQuery. Oracle reads the `tnsnames.ora` found through `TNS_ADMIN`, `ORACLE_HOME`, registered Oracle homes or an Instant Client on `PATH`, or a folder you pick (`IFILE`, `NAMES.DEFAULT_DOMAIN` and Latin-1 files are supported), and accepts TCPS descriptors. SQLite opens or creates a local file. Redis takes host, port, an optional ACL user and the database index. +**Network configuration.** PostgreSQL, MySQL, MariaDB and SQL Server use host, port and database, with `disable`, `prefer`, `require` or `verify-full` TLS. SQL Server also takes a named instance (found through SQL Server Browser) and can sign in with the Windows account running LowQuery. Oracle reads the `tnsnames.ora` found through `TNS_ADMIN`, `ORACLE_HOME`, registered Oracle homes or an Instant Client on `PATH`, or a folder you pick (`IFILE`, `NAMES.DEFAULT_DOMAIN` and Latin-1 files are supported), and accepts TCPS descriptors. SQLite opens or creates a local file. Redis takes host, port, an optional ACL user and the database index. Cassandra takes one or more contact points (comma separated), the port (9042), an optional user and password, a default keyspace, the local datacenter and the TLS mode; it has no transactions, so every statement commits on its own. Connection profiles are stored in `connections.json` in the user configuration folder (`%APPDATA%\LowBloat\LowQuery\config` on Windows, `~/Library/Application Support/dev.LowBloat.LowQuery` on macOS, `~/.config/lowquery` on Linux). Open query tabs and their SQL are restored on the next start. diff --git a/docs/AI.md b/docs/AI.md index c3a1d10..ab892cb 100644 --- a/docs/AI.md +++ b/docs/AI.md @@ -24,12 +24,12 @@ The server is the LowQuery executable started as `lowquery mcp`. It speaks MCP o | Tool | What it does | | --- | --- | | `list_connections` | Connections open to agents: id, name, engine, where it points (never passwords), access and dialect notes | -| `list_schemas` | Schemas of a connection | +| `list_schemas` | Schemas of a connection (keyspaces on Cassandra) | | `list_tables` | Tables and views of a schema (the current schema by default) | -| `describe_table` | Columns with types, nullability and primary key, plus foreign keys in both directions | -| `search_objects` | Tables, views and columns whose name contains a text, across schemas | +| `describe_table` | Columns with types, nullability and primary key (Cassandra: partition, clustering and static roles), plus foreign keys in both directions | +| `search_objects` | Tables, views and columns whose name contains a text, across schemas (Cassandra: also materialized views and types, from `system_schema`) | | `query` | One read statement; 100 rows by default, up to 1,000 | -| `explain` | The engine's execution plan, without running the statement | +| `explain` | The engine's execution plan, without running the statement (not available on Redis or Cassandra, which have none) | | `execute` | One statement of any kind, only on **Read and write** connections | Results come as a compact table for the model plus structured JSON for clients that use it. Long values are cut at 1,000 characters and a reply at about 60,000, with a note saying what was left out. @@ -38,8 +38,8 @@ Results come as a compact table for the model plus structured JSON for clients t Read-only access does not rely on the agent's good behaviour. Every call goes through several independent checks: -1. **One statement per call.** A lexer that knows each dialect's strings, quoted names and comments refuses text with more than one statement (MySQL executable comments `/*! */` included). -2. **A read classifier.** On read-only connections the statement must start with `SELECT`, `WITH`, `SHOW`, `DESCRIBE`, `EXPLAIN` (no `ANALYZE`), `VALUES`, `TABLE` or a reporting `PRAGMA`. Write keywords anywhere in the statement and functions with side effects (`nextval`, `set_config`, `load_extension`...) are refused, and so are `EXEC` and `SET` on SQL Server. +1. **One statement per call.** A lexer that knows each dialect's strings, quoted names and comments refuses text with more than one statement (MySQL executable comments `/*! */` included). On Cassandra a `BEGIN [UNLOGGED|COUNTER] BATCH ... APPLY BATCH` block counts as one statement, and `$$...$$` strings and `//` comments are understood. +2. **A read classifier.** On read-only connections the statement must start with `SELECT`, `WITH`, `SHOW`, `DESCRIBE`, `EXPLAIN` (no `ANALYZE`), `VALUES`, `TABLE` or a reporting `PRAGMA`; on Cassandra only `SELECT` and `LIST` (`DESCRIBE` is a cqlsh command, not CQL: use the schema tools). Write keywords anywhere in the statement and functions with side effects (`nextval`, `set_config`, `load_extension`...) are refused, and so are `EXEC` and `SET` on SQL Server. 3. **A read-only session in the database itself**, separate from the app's sessions: | Database | Session | Each call | @@ -50,8 +50,9 @@ Read-only access does not rely on the agent's good behaviour. Every call goes th | SQL Server | implicit transactions | always rolled back | | SQLite | file opened read-only (`mode=ro`) and `PRAGMA query_only` | rolled back | | Redis | | allowlist of read commands; `KEYS` refused in favour of `SCAN` | + | Cassandra | | CQL has no read-only transaction: only `SELECT` and `LIST` pass the classifier, and `USE` is refused so no call changes the session keyspace (names are written `keyspace.table`) | -On **Read and write** connections, `execute` runs with autocommit. Transaction control (`BEGIN`, `COMMIT`, `SET autocommit`...) is refused, and a statement that leaves a transaction open is rolled back. +On **Read and write** connections, `execute` runs with autocommit. Transaction control (`BEGIN`, `COMMIT`, `SET autocommit`...) is refused (on Cassandra: `BEGIN TRANSACTION`, `COMMIT`, `ROLLBACK`; `BEGIN BATCH` is allowed), and a statement that leaves a transaction open is rolled back. Every call has a time limit (30 seconds, or `LOWQUERY_MCP_TIMEOUT` in seconds), after which the statement is cancelled on the server. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 610fbc9..2077610 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -37,6 +37,7 @@ LowQuery is a Tauri 2 desktop app. The interface is Svelte 5 with the [CodeAtlas | `drivers/sqlserver.rs` | SQL Server and Azure SQL session on `tiberius` (TDS) | | `drivers/redis_db.rs` | Redis and Valkey session on `redis-rs` (RESP3) | | `drivers/sqlite.rs` | SQLite session on `rusqlite` (bundled engine) | +| `drivers/cassandra.rs` | Apache Cassandra session on `scylla` (CQL native protocol); `cassandra_tests.rs` holds its tests against a real server | | `mcp/` | `lowquery mcp`: MCP server over stdio for AI agents (`server.rs` protocol and sessions, `tools.rs`, `guard.rs` statement checks, `catalog.rs` search/foreign keys/plans per engine, `audit.rs` activity log); see [AI.md](AI.md) | | `transfer/` | Export to SQL, CSV and JSON and import from CSV and JSON (`export.rs`, `import.rs`, `sql.rs` literals, `catalog.rs` generated and identity columns, foreign key order) | | `ai_setup.rs` | The MCP command and the configuration of each AI client | @@ -88,6 +89,17 @@ LowQuery is a Tauri 2 desktop app. The interface is Svelte 5 with the [CodeAtlas - The tree lists databases (`db0`...) and up to 1000 keys per database with `SCAN` and `TYPE`; ▶ runs the read command that fits the key type. - There is no autocommit: `MULTI` opens a queue that the toolbar's EXEC and DISCARD close. Cancel only works for blocking commands, through `CLIENT UNBLOCK` from a second connection. +### Apache Cassandra + +- The `scylla` driver speaks the CQL native protocol to Apache Cassandra 3.x, 4.x and 5.x. Statements are sent as text, unprepared, at consistency `LOCAL_ONE`; contact points are comma separated, and a local datacenter limits requests to its nodes. TLS uses rustls (`prefer` and `require` encrypt without validating the certificate; `verify-full` checks the chain against the system and bundled roots, but not the host name, because the driver connects to the addresses the cluster reports). +- Rows are read with paging: the page size is derived from the row limit and reading stops at `max_rows + 1`. Values keep their exact CQL text; collections, tuples, UDTs and vectors are valid CQL literals, so a cell can be pasted into an `INSERT`. `cql_literal` turns cell text back into a literal per column type (used by grid edits, export and import). +- There are no transactions: the session is always in autocommit mode (the interface hides the controls), `BEGIN BATCH ... APPLY BATCH` is one statement, and saving grid edits sends one logged batch. CQL writes are upserts, so an UPDATE or DELETE never reports a missing row. +- CQL has no server-side cancel. Cancel stops waiting (the request future is dropped); the coordinator finishes or times out on its own. +- Server warnings arrive as messages, like PostgreSQL notices. +- The schema browser reads `system_schema` (and `system_virtual_schema` for the virtual keyspaces of 4.0+): keyspaces are the schemas; tables, materialized views, types, functions and aggregates (named `name(argument types)`, since CQL allows overloads) and indexes are the objects. `DESCRIBE` is a cqlsh command, not part of the protocol, so `object_ddl` builds the `CREATE` statements from the catalog. +- Result editing: a `SELECT` on one table whose columns include the whole primary key (partition and clustering columns); expressions, aliases, counters, materialized views and system tables are read-only. +- Imports run one `INSERT` per row without a transaction (multi-partition batches are an anti-pattern in Cassandra), so a failed import leaves the rows already written. + ## Interface side (`src`) | Path | Responsibility | @@ -121,6 +133,7 @@ LowQuery is a Tauri 2 desktop app. The interface is Svelte 5 with the [CodeAtlas | MySQL, MariaDB | `SHOW CREATE TABLE/VIEW/FUNCTION/PROCEDURE/SEQUENCE`, triggers with `SHOW CREATE TRIGGER`, routines between `DELIMITER` lines | | SQL Server | `OBJECT_DEFINITION` for views, routines and triggers; tables, sequences, synonyms and alias types scripted from `sys.*` views; batches separated by `GO` | | SQLite | The statements kept in `sqlite_schema` for the table, its indexes and triggers | +| Apache Cassandra | `system_schema`: `CREATE TABLE` with its primary key, clustering order and options followed by the table's `CREATE INDEX` statements; materialized views, types, functions, aggregates, indexes and keyspaces | The statements the menus and the table designer run come from `lib/sql/ddl.ts`. They run one at a time on the tree's session (never a tab's, so an open transaction in a tab is not touched), and every one is shown before it runs. `ddl.fixtures.json` holds them for a fixed scenario per dialect; `drivers/ddl_fixtures.rs` runs that scenario on every database in CI, including a round trip of the DDL each driver reads back (drop the object, run its DDL, compare the columns). @@ -147,6 +160,7 @@ A result is editable only when the driver can prove which table row each grid ro | MySQL, MariaDB | `org_table`/`org_name`/`schema` of each column plus the alias; key from `information_schema` | Several tables or aliases, set operators, views, temporary tables | | SQL Server | `sys.dm_exec_describe_first_result_set` in browse mode, with the keys from `sys.indexes` in the same batch | Several tables or the same table twice, computed and rowversion columns, temp tables | | SQLite | Column metadata (`sqlite3_column_table_name`) checked against `EXPLAIN QUERY PLAN`; `rowid` works as the key of a table without one | Compound selects, more than one table scan, results without the key | +| Apache Cassandra | The table of each result column (the result metadata names it); keys from `system_schema.columns` | Several tables, aliases, counters, views, system tables, results without the whole primary key | `apply_changes` receives the target and a list of updates (old key plus new values), deletes and inserts. Each driver builds one statement per change with quoted, schema-qualified names and binds every value, converting the text the grid shows back into the column type the engine's own way (typed binds or explicit conversions that do not depend on session date or number formats). Every UPDATE and DELETE must touch exactly one row. With autocommit on the whole save is one transaction; inside an open transaction it runs behind a savepoint, so a failed save is undone without losing earlier work. After saving, the interface runs the query again to show what the database now holds. diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index dff9e19..44f6bf4 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -5,7 +5,7 @@ pkgname=lowquery-bin pkgver=__VERSION__ pkgrel=1 -pkgdesc="Light SQL client for PostgreSQL, Oracle, MySQL, MariaDB, SQL Server, SQLite and Redis" +pkgdesc="Light SQL client for PostgreSQL, Oracle, MySQL, MariaDB, SQL Server, SQLite, Redis and Apache Cassandra" arch=('x86_64') url="https://github.com/LowBloat/LowQuery" license=('AGPL-3.0-only') diff --git a/packaging/arch/PKGBUILD.ci b/packaging/arch/PKGBUILD.ci index e0bbc2c..68c1d6d 100644 --- a/packaging/arch/PKGBUILD.ci +++ b/packaging/arch/PKGBUILD.ci @@ -4,7 +4,7 @@ pkgname=lowquery-bin pkgver=__VERSION__ pkgrel=1 -pkgdesc="Light SQL client for PostgreSQL, Oracle, MySQL, MariaDB, SQL Server, SQLite and Redis" +pkgdesc="Light SQL client for PostgreSQL, Oracle, MySQL, MariaDB, SQL Server, SQLite, Redis and Apache Cassandra" arch=('x86_64') url="https://github.com/LowBloat/LowQuery" license=('AGPL-3.0-only') diff --git a/packaging/arch/lowquery.desktop b/packaging/arch/lowquery.desktop index 3da76e1..3fec228 100644 --- a/packaging/arch/lowquery.desktop +++ b/packaging/arch/lowquery.desktop @@ -1,10 +1,10 @@ [Desktop Entry] Name=LowQuery -Comment=Light SQL client for PostgreSQL, Oracle, MySQL, MariaDB, SQL Server, SQLite and Redis +Comment=Light SQL client for PostgreSQL, Oracle, MySQL, MariaDB, SQL Server, SQLite, Redis and Apache Cassandra Exec=lowquery Icon=lowquery Terminal=false Type=Application Categories=Development;Database; -Keywords=sql;database;postgresql;oracle;mysql;mariadb;sqlserver;sqlite;redis; +Keywords=sql;database;postgresql;oracle;mysql;mariadb;sqlserver;sqlite;redis;cassandra; StartupWMClass=LowQuery diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a062959..21f9cbc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -67,6 +67,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -868,7 +874,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -963,6 +969,20 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "dbus" version = "0.9.12" @@ -1239,6 +1259,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + [[package]] name = "embed-resource" version = "3.0.12" @@ -1324,7 +1350,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1477,6 +1503,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -1558,6 +1599,7 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1886,6 +1928,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.1" @@ -1901,6 +1949,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -2275,6 +2325,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2623,6 +2682,7 @@ dependencies = [ "rusqlite", "rustls", "rustls-native-certs", + "scylla", "serde", "serde_json", "tauri", @@ -2651,6 +2711,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "lz4_flex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226" +dependencies = [ + "twox-hash", +] + [[package]] name = "manyhow" version = "0.11.4" @@ -3687,11 +3756,21 @@ checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" dependencies = [ "getrandom 0.1.16", "libc", - "rand_chacha", + "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.3" @@ -3713,6 +3792,16 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3722,6 +3811,15 @@ dependencies = [ "getrandom 0.1.16", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -3737,6 +3835,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_pcg" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b48ac3f7ffaab7fac4d2376632268aa5f89abdb55f7ebf8f4d11fffccb2320f7" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3965,7 +4072,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4023,7 +4130,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4159,6 +4266,83 @@ dependencies = [ "sha2 0.11.1", ] +[[package]] +name = "scylla" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29eebcb7e34257f8ce01aaa1469644825bd4b4995d401c362be65d994e3feaa3" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "chrono", + "dashmap", + "futures", + "hashbrown 0.17.1", + "itertools", + "rand 0.9.5", + "rand_pcg", + "rustls", + "scylla-cql", + "scylla-cql-core", + "serde", + "serde_json", + "smallvec", + "socket2", + "thiserror 2.0.21", + "tokio", + "tokio-rustls", + "tracing", + "uuid", +] + +[[package]] +name = "scylla-cql" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a398b0e78fb3872c4d5afc74e461d6b80dd69d030e13ddc7442e5f32c482e0da" +dependencies = [ + "byteorder", + "bytes", + "chrono", + "itertools", + "lz4_flex", + "scylla-cql-core", + "snap", + "stable_deref_trait", + "thiserror 2.0.21", + "tokio", + "uuid", + "yoke", +] + +[[package]] +name = "scylla-cql-core" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ca9de2eb08d04a9c85002ac179c51353c91152cd2cd58fc3d7cbc1eee8ab16" +dependencies = [ + "byteorder", + "bytes", + "chrono", + "itertools", + "scylla-macros", + "thiserror 2.0.21", + "uuid", +] + +[[package]] +name = "scylla-macros" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c82c9c67cb4912cefc8cb2a68ebdcbe0273b66510c2bb06581ae6b68d98498aa" +dependencies = [ + "darling 0.24.1", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -4483,6 +4667,12 @@ version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +[[package]] +name = "snap" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" + [[package]] name = "socket2" version = "0.6.5" @@ -5146,7 +5336,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6062,7 +6252,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8afc60e..785a89f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,7 +23,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["preserve_order"] } +serde_json = { version = "1", features = ["preserve_order", "raw_value"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "io-std"] } thiserror = "2" uuid = { version = "1", features = ["v4", "serde"] } @@ -47,6 +47,8 @@ tiberius = { version = "0.13", default-features = false, features = ["tds80", "r tokio-util = { version = "0.7", features = ["compat"] } # Redis: RESP protocol over tokio, TLS through rustls. redis = { version = "1.7", default-features = false, features = ["aio", "tokio-comp", "tokio-rustls-comp", "tls-rustls-insecure", "tls-rustls-webpki-roots"] } +# Apache Cassandra: native CQL protocol (v4/v5), TLS through rustls. +scylla = { version = "1.9", default-features = false, features = ["rustls-023"] } # SQLite: embedded engine compiled into the app. rusqlite = { version = "0.40", features = ["bundled", "column_decltype", "column_metadata"] } keyring = { version = "=3.6.3", features = ["apple-native", "windows-native", "sync-secret-service"] } diff --git a/src-tauri/src/drivers/cassandra.rs b/src-tauri/src/drivers/cassandra.rs new file mode 100644 index 0000000..53d1979 --- /dev/null +++ b/src-tauri/src/drivers/cassandra.rs @@ -0,0 +1,2619 @@ +//! Apache Cassandra session over the native CQL protocol, through the +//! `scylla` driver (it speaks to Apache Cassandra 3.x, 4.x and 5.x as well). +//! +//! - Every statement is sent unprepared as text, like cqlsh does. Rows are +//! read with paging: the page size comes from the row limit and reading +//! stops at `max_rows + 1`, so a `SELECT` on a big table never streams +//! everything to show a few rows. +//! - Values keep their exact CQL text (`varint` and `decimal` keep every +//! digit, floats are shortest round-trip, blobs are `0x...`). Collections, +//! tuples, UDTs and vectors are written as valid CQL literals, so a cell can +//! be pasted into an `INSERT` or `UPDATE` as it is. +//! - There are no transactions: the session is always in autocommit mode and +//! `BEGIN BATCH ... APPLY BATCH` is just one statement. Saving edits from +//! the grid sends one logged batch. +//! - CQL has no server-side cancel. `cancel` drops the request future, so the +//! interface gets its answer at once, but the coordinator keeps working +//! until its own request timeout. +//! - Server warnings (tombstone thresholds, large batches, aggregations +//! without a partition key) arrive as messages, like PostgreSQL notices. +//! - Requests run at consistency `LOCAL_ONE`, the cqlsh default. TLS uses +//! rustls; with the local datacenter set, only its nodes get requests. +//! - The schema comes from `system_schema` (and `system_virtual_schema` for +//! the virtual keyspaces of 4.0+), which every 3.0+ server has. +//! `DESCRIBE` is a cqlsh command, not part of the protocol, so the CREATE +//! statements are built from the catalog. + +use std::collections::HashMap; +use std::fmt::Write as _; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use futures_util::StreamExt; +use parking_lot::Mutex; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::client::WebPkiServerVerifier; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{CertificateError, DigitallySignedStruct, SignatureScheme}; +use scylla::client::execution_profile::ExecutionProfile; +use scylla::client::session::Session as Cql; +use scylla::client::session_builder::SessionBuilder; +use scylla::errors::{ + DbError, ExecutionError, IntoRowsResultError, NewSessionError, NextPageError, NextRowError, + PagerExecutionError, RequestAttemptError, RequestError, +}; +use scylla::frame::response::result::{CollectionType, ColumnType, NativeType}; +use scylla::policies::load_balancing::DefaultPolicy; +use scylla::response::PagingState; +use scylla::statement::{Consistency, Statement}; +use scylla::value::{CqlValue, Row}; +use tokio::sync::Notify; + +use super::{first_keywords, RowSink, StreamEvent, TxState, STREAM_BATCH}; +use crate::error::{AppError, AppResult, ErrorKind}; +use crate::model::{ + ApplyRequest, ApplyResult, ColumnInfo, ConnectionProfile, DbColumn, DbObject, EditColumn, + EditTarget, ExecRequest, ExecResult, RowChange, SslMode, ValueKind, +}; + +const DEFAULT_PORT: u16 = 9042; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// TLS attempt of `prefer`: a server without TLS gives up early and the +/// session falls back to plain TCP. +const PREFER_TLS_TIMEOUT: Duration = Duration::from_secs(8); +/// Largest page asked for in one request. +const PAGE_CAP: usize = 5000; +/// Page size of the catalog queries. +const CATALOG_PAGE: i32 = 1000; +const BINARY_PREVIEW_BYTES: usize = 32 * 1024; + +/// Keyspaces that belong to the server, listed after the user's. +const SYSTEM_KEYSPACES: &[&str] = &[ + "system", + "system_schema", + "system_auth", + "system_distributed", + "system_traces", + "system_views", + "system_virtual_schema", +]; +const VIRTUAL_KEYSPACES: &[&str] = &["system_views", "system_virtual_schema"]; + +pub struct CassSession { + cql: Cql, + busy: AtomicBool, + cancel: Arc, + /// Key information per table, for result editing. Cleared by any + /// statement that is not a SELECT. + tables: Mutex>>, + pub server_version: String, + pub current_user: Option, + pub current_schema: Option, +} + +pub async fn connect( + profile: &ConnectionProfile, + password: Option<&str>, +) -> AppResult { + // rustls has more than one crypto provider compiled in. + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let port = if profile.port == 0 { + DEFAULT_PORT + } else { + profile.port + }; + let contact_points: Vec = profile + .host + .split(',') + .map(str::trim) + .filter(|h| !h.is_empty()) + .map(|h| contact_point(h, port)) + .collect(); + if contact_points.is_empty() { + return Err(AppError::invalid("Enter at least one contact point.")); + } + + let build = |tls: Option>| { + let mut builder = SessionBuilder::new() + .known_nodes(&contact_points) + .connection_timeout(CONNECT_TIMEOUT) + .fetch_schema_metadata(false) + .default_execution_profile_handle(execution_profile(&profile.datacenter)); + let keyspace = profile.database.trim(); + if !keyspace.is_empty() { + builder = builder.use_keyspace(keyspace, true); + } + let user = profile.user.trim(); + if !user.is_empty() { + builder = builder.user(user, password.unwrap_or_default()); + } + if let Some(config) = tls { + builder = builder.tls_context(Some(config)); + } + async move { builder.build().await } + }; + + let cql = match profile.ssl_mode { + SslMode::Disable => build(None).await, + SslMode::Prefer => { + let attempt = tokio::time::timeout( + PREFER_TLS_TIMEOUT, + build(Some(tls_config(SslMode::Prefer)?)), + ) + .await; + match attempt { + Ok(Ok(cql)) => Ok(cql), + Ok(Err(e)) if is_auth_text(&chain(&e)) => Err(e), + _ => build(None).await, + } + } + SslMode::Require => build(Some(tls_config(SslMode::Require)?)).await, + SslMode::VerifyFull => build(Some(tls_config(SslMode::VerifyFull)?)).await, + } + .map_err(connect_error)?; + + let version = fetch(&cql, "SELECT release_version FROM system.local") + .await + .ok() + .and_then(|f| f.rows.into_iter().next()) + .and_then(|mut r| r.pop().flatten()) + .and_then(|v| text_of(&Some(v))); + let user = profile.user.trim(); + Ok(CassSession { + current_schema: cql.get_keyspace().map(|k| (*k).clone()), + cql, + busy: AtomicBool::new(false), + cancel: Arc::new(Notify::new()), + tables: Mutex::new(HashMap::new()), + server_version: match version { + Some(v) => format!("Apache Cassandra {v}"), + None => "Apache Cassandra".into(), + }, + current_user: (!user.is_empty()).then(|| user.to_string()), + }) +} + +/// `host` or `host:port` as the driver wants it; a bare IPv6 address is +/// bracketed. +fn contact_point(host: &str, default_port: u16) -> String { + if host.starts_with('[') { + return if host.contains("]:") { + host.to_string() + } else { + format!("{host}:{default_port}") + }; + } + match host.matches(':').count() { + 0 => format!("{host}:{default_port}"), + 1 => host.to_string(), + _ => format!("[{host}]:{default_port}"), + } +} + +/// Requests go to the local datacenter only, when one is named. +fn execution_profile( + datacenter: &str, +) -> scylla::client::execution_profile::ExecutionProfileHandle { + let mut policy = DefaultPolicy::builder(); + let dc = datacenter.trim(); + if !dc.is_empty() { + policy = policy + .prefer_datacenter(dc.to_string()) + .permit_dc_failover(false); + } + ExecutionProfile::builder() + .load_balancing_policy(policy.build()) + .consistency(Consistency::LocalOne) + // The server's own timeouts apply; Cancel stops waiting. + .request_timeout(None) + .build() + .into_handle() +} + +// --------------------------------------------------------------------------- +// TLS + +fn tls_config(mode: SslMode) -> AppResult> { + if mode != SslMode::VerifyFull { + // `prefer` and `require` encrypt without validating the certificate, + // as everywhere else in the app. + return super::postgres::tls_config(mode).map(Arc::new); + } + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + for cert in rustls_native_certs::load_native_certs().certs { + let _ = roots.add(cert); + } + let inner = WebPkiServerVerifier::builder_with_provider(Arc::new(roots), provider.clone()) + .build() + .map_err(|e| AppError::internal(e.to_string()))?; + let config = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| AppError::internal(e.to_string()))? + .dangerous() + .with_custom_certificate_verifier(Arc::new(ChainVerifier(inner))) + .with_no_client_auth(); + Ok(Arc::new(config)) +} + +/// Validates the chain, expiry and signatures against the trusted roots but +/// not the host name: the driver connects to the addresses the cluster +/// reports (`system.peers`), which certificates rarely list. +#[derive(Debug)] +struct ChainVerifier(Arc); + +impl ServerCertVerifier for ChainVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + now: UnixTime, + ) -> Result { + match self + .0 + .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + { + Err(rustls::Error::InvalidCertificate( + CertificateError::NotValidForName | CertificateError::NotValidForNameContext { .. }, + )) => Ok(ServerCertVerified::assertion()), + other => other, + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.0.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.0.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.0.supported_verify_schemes() + } +} + +// --------------------------------------------------------------------------- +// Queries + +/// Rows of a catalog query, values as the driver decoded them. +struct Fetched { + names: Vec, + rows: Vec>>, +} + +impl Fetched { + fn index(&self, name: &str) -> Option { + self.names.iter().position(|n| n == name) + } + + fn get<'a>(&self, row: &'a [Option], name: &str) -> Option<&'a CqlValue> { + self.index(name) + .and_then(|i| row.get(i)) + .and_then(Option::as_ref) + } + + fn text(&self, row: &[Option], name: &str) -> Option { + self.index(name).and_then(|i| row.get(i)).and_then(text_of) + } +} + +fn text_of(v: &Option) -> Option { + match v { + Some(CqlValue::Text(s) | CqlValue::Ascii(s)) => Some(s.clone()), + _ => None, + } +} + +fn text_list(v: Option<&CqlValue>) -> Vec { + match v { + Some(CqlValue::List(items) | CqlValue::Set(items)) => items + .iter() + .filter_map(|i| text_of(&Some(i.clone()))) + .collect(), + _ => Vec::new(), + } +} + +/// Runs a catalog statement and reads every page. +async fn fetch(cql: &Cql, sql: &str) -> AppResult { + let mut names; + let mut rows = Vec::new(); + let mut state = PagingState::start(); + loop { + let statement = Statement::new(sql).with_page_size(CATALOG_PAGE); + let (result, flow) = cql + .query_single_page(statement, &[], state) + .await + .map_err(|e| map_execution(e, Some(sql)))?; + let page = result.into_rows_result().map_err(|e| match e { + IntoRowsResultError::ResultNotRows(_) => { + AppError::internal("The catalog statement returned no rows.") + } + other => AppError::internal(other.to_string()), + })?; + names = page + .column_specs() + .iter() + .map(|c| c.name().to_string()) + .collect(); + for row in page + .rows::() + .map_err(|e| AppError::internal(e.to_string()))? + { + rows.push(row.map_err(|e| AppError::internal(e.to_string()))?.columns); + } + match flow.into_paging_control_flow() { + std::ops::ControlFlow::Break(()) => break, + std::ops::ControlFlow::Continue(next) => state = next, + } + } + Ok(Fetched { names, rows }) +} + +/// Names a catalog query matches on, as a CQL string literal. +fn lit(s: &str) -> String { + format!("'{}'", s.replace('\'', "''")) +} + +fn is_virtual(keyspace: &str) -> bool { + VIRTUAL_KEYSPACES.contains(&keyspace) +} + +fn is_system(keyspace: &str) -> bool { + SYSTEM_KEYSPACES.contains(&keyspace) || keyspace.starts_with("system_") +} + +/// One result column: what the editor needs to know about its origin. +struct ResultColumn { + name: String, + keyspace: String, + table: String, +} + +struct TableMeta { + columns: Vec, + view: bool, +} + +impl CassSession { + /// Cassandra 3.x, whose table options differ from 4.0 and later. + fn legacy(&self) -> bool { + self.server_version.starts_with("Apache Cassandra 3.") + } + + pub fn tx_state(&self) -> TxState { + TxState { + autocommit: true, + in_transaction: false, + } + } + + pub async fn execute(&self, req: &ExecRequest) -> AppResult { + self.busy.store(true, Ordering::SeqCst); + let cancelled = self.cancel.notified(); + tokio::pin!(cancelled); + cancelled.as_mut().enable(); + let outcome = tokio::select! { + r = self.run(req) => r, + () = &mut cancelled => Err(AppError::new(ErrorKind::Cancelled, "Cancelled.")), + }; + self.busy.store(false, Ordering::SeqCst); + outcome + } + + /// CQL cannot ask the server to stop a request: the wait ends here and the + /// coordinator finishes (or times out) on its own. + pub async fn cancel(&self) -> AppResult<()> { + if self.busy.load(Ordering::SeqCst) { + self.cancel.notify_waiters(); + } + Ok(()) + } + + pub async fn set_autocommit(&self, on: bool) -> AppResult { + if on { + Ok(self.tx_state()) + } else { + Err(AppError::invalid( + "Cassandra has no transactions: every statement commits on its own.", + )) + } + } + + pub async fn end_transaction(&self, _commit: bool) -> AppResult { + Err(AppError::invalid( + "Cassandra has no transactions: every statement commits on its own.", + )) + } + + async fn run(&self, req: &ExecRequest) -> AppResult { + let started = Instant::now(); + let sql = req.sql.as_str(); + let limit = req.max_rows; + let mut result = ExecResult::default(); + let mut columns: Option> = None; + let mut origins: Vec = Vec::new(); + let mut rows: Vec>> = Vec::new(); + let mut state = PagingState::start(); + loop { + let size = if limit == 0 { + PAGE_CAP + } else { + (limit + 1 - rows.len().min(limit + 1)).clamp(1, PAGE_CAP) + }; + let statement = Statement::new(sql).with_page_size(size as i32); + let (page, flow) = self + .cql + .query_single_page(statement, &[], state) + .await + .map_err(|e| map_execution(e, Some(sql)))?; + match page.into_rows_result() { + Ok(page) => { + for w in page.warnings() { + result.notices.push(format!("WARNING: {w}")); + } + if columns.is_none() { + columns = Some( + page.column_specs() + .iter() + .map(|c| ColumnInfo { + name: c.name().to_string(), + type_name: type_name(c.typ()), + kind: column_kind(c.typ()), + }) + .collect(), + ); + origins = page + .column_specs() + .iter() + .map(|c| ResultColumn { + name: c.name().to_string(), + keyspace: c.table_spec().ks_name().to_string(), + table: c.table_spec().table_name().to_string(), + }) + .collect(); + } + let iter = page + .rows::() + .map_err(|e| AppError::internal(e.to_string()))?; + for row in iter { + let row = row.map_err(|e| AppError::internal(e.to_string()))?; + rows.push(row.columns.iter().map(cell).collect()); + } + } + Err(IntoRowsResultError::ResultNotRows(page)) => { + for w in page.warnings() { + result.notices.push(format!("WARNING: {w}")); + } + break; + } + Err(e) => return Err(AppError::internal(e.to_string())), + } + if limit > 0 && rows.len() > limit { + break; + } + match flow.into_paging_control_flow() { + std::ops::ControlFlow::Break(()) => break, + std::ops::ControlFlow::Continue(next) => state = next, + } + } + if limit > 0 && rows.len() > limit { + rows.truncate(limit); + result.truncated = true; + } + + let is_select = first_keywords(sql, 1).first().map(String::as_str) == Some("SELECT"); + if !is_select { + self.tables.lock().clear(); + } + if let Some(cols) = columns { + let (editable, reason) = self.editable(sql, is_select, &origins).await; + result.editable = editable; + result.read_only_reason = reason; + result.columns = Some(cols); + result.rows = rows; + } + result.elapsed_ms = started.elapsed().as_millis() as u64; + Ok(result) + } + + pub async fn list_schemas(&self) -> AppResult> { + let mut names: Vec = fetch( + &self.cql, + "SELECT keyspace_name FROM system_schema.keyspaces", + ) + .await? + .rows + .into_iter() + .filter_map(|mut r| r.pop().and_then(|v| text_of(&v))) + .collect(); + // Virtual keyspaces exist from Cassandra 4.0. + if let Ok(f) = fetch( + &self.cql, + "SELECT keyspace_name FROM system_virtual_schema.keyspaces", + ) + .await + { + names.extend( + f.rows + .into_iter() + .filter_map(|mut r| r.pop().and_then(|v| text_of(&v))), + ); + } + names.sort(); + names.dedup(); + names.sort_by_key(|n| is_system(n)); + Ok(names) + } + + pub async fn list_objects(&self, schema: &str) -> AppResult> { + let ks = lit(schema); + let mut out = Vec::new(); + let mut add = |kind: &str, names: Vec| { + let mut names = names; + names.sort(); + out.extend(names.into_iter().map(|name| DbObject { + name, + kind: kind.into(), + })); + }; + let names_of = |f: Fetched, column: &str| -> Vec { + f.rows.iter().filter_map(|r| f.text(r, column)).collect() + }; + if is_virtual(schema) { + let f = fetch( + &self.cql, + &format!( + "SELECT table_name FROM system_virtual_schema.tables WHERE keyspace_name = {ks}" + ), + ) + .await?; + add("table", names_of(f, "table_name")); + return Ok(out); + } + let f = fetch( + &self.cql, + &format!("SELECT table_name FROM system_schema.tables WHERE keyspace_name = {ks}"), + ) + .await?; + add("table", names_of(f, "table_name")); + let f = fetch( + &self.cql, + &format!("SELECT view_name FROM system_schema.views WHERE keyspace_name = {ks}"), + ) + .await?; + add("matview", names_of(f, "view_name")); + let f = fetch( + &self.cql, + &format!("SELECT type_name FROM system_schema.types WHERE keyspace_name = {ks}"), + ) + .await?; + add("type", names_of(f, "type_name")); + for (kind, table, column) in [ + ("function", "functions", "function_name"), + ("aggregate", "aggregates", "aggregate_name"), + ] { + let f = fetch( + &self.cql, + &format!( + "SELECT {column}, argument_types FROM system_schema.{table} WHERE keyspace_name = {ks}" + ), + ) + .await?; + let names = f + .rows + .iter() + .filter_map(|r| { + let name = f.text(r, column)?; + Some(format!( + "{name}({})", + text_list(f.get(r, "argument_types")).join(", ") + )) + }) + .collect(); + add(kind, names); + } + let f = fetch( + &self.cql, + &format!("SELECT index_name FROM system_schema.indexes WHERE keyspace_name = {ks}"), + ) + .await?; + add("index", names_of(f, "index_name")); + Ok(out) + } + + pub async fn list_columns(&self, schema: &str, table: &str) -> AppResult> { + let (ks, t) = (lit(schema), lit(table)); + let f = if is_virtual(schema) { + fetch( + &self.cql, + &format!( + "SELECT column_name, type, kind, position FROM system_virtual_schema.columns \ + WHERE keyspace_name = {ks} AND table_name = {t}" + ), + ) + .await? + } else { + fetch( + &self.cql, + &format!( + "SELECT column_name, type, kind, position FROM system_schema.columns \ + WHERE keyspace_name = {ks} AND table_name = {t}" + ), + ) + .await? + }; + let mut cols: Vec<(u8, i32, DbColumn)> = f + .rows + .iter() + .filter_map(|r| { + let name = f.text(r, "column_name")?; + let kind = f.text(r, "kind").unwrap_or_default().to_ascii_lowercase(); + let position = match f.get(r, "position") { + Some(CqlValue::Int(p)) => *p, + _ => 0, + }; + let (order, role) = match kind.as_str() { + "partition_key" => (0, "partition"), + "clustering" => (1, "clustering"), + "static" => (2, "static"), + _ => (3, "regular"), + }; + let key = order < 2; + Some(( + order, + if key { position } else { 0 }, + DbColumn { + name, + type_name: f.text(r, "type").unwrap_or_default(), + nullable: !key, + primary_key: key, + default_value: None, + key_role: Some(role.into()), + }, + )) + }) + .collect(); + cols.sort_by(|a, b| (a.0, a.1, &a.2.name).cmp(&(b.0, b.1, &b.2.name))); + Ok(cols.into_iter().map(|c| c.2).collect()) + } + + /// Runs a query and sends its rows to `sink` in batches, one page at a time. + pub async fn stream(&self, sql: &str, sink: RowSink) -> AppResult { + let statement = Statement::new(sql).with_page_size(STREAM_BATCH as i32); + let pager = self + .cql + .query_iter(statement, &[]) + .await + .map_err(map_pager)?; + let info: Vec = pager + .column_specs() + .iter() + .map(|c| ColumnInfo { + name: c.name().to_string(), + type_name: type_name(c.typ()), + kind: column_kind(c.typ()), + }) + .collect(); + if info.is_empty() { + return Err(AppError::invalid("The statement returns no rows.")); + } + sink.send(StreamEvent::Columns(info)) + .await + .map_err(|_| super::stream_stopped())?; + let mut rows = pager + .rows_stream::() + .map_err(|e| AppError::internal(e.to_string()))?; + let mut total = 0u64; + let mut batch = Vec::with_capacity(STREAM_BATCH); + while let Some(row) = rows.next().await { + let row = row.map_err(map_next_row)?; + batch.push(super::full_values(|| { + row.columns.iter().map(cell).collect::>() + })); + total += 1; + if batch.len() == STREAM_BATCH { + let full = std::mem::replace(&mut batch, Vec::with_capacity(STREAM_BATCH)); + sink.send(StreamEvent::Rows(full)) + .await + .map_err(|_| super::stream_stopped())?; + } + } + if !batch.is_empty() { + sink.send(StreamEvent::Rows(batch)) + .await + .map_err(|_| super::stream_stopped())?; + } + Ok(total) + } + + // ----------------------------------------------------------------------- + // Result editing + + /// The table behind a result, when each row can be addressed by its full + /// primary key. + async fn editable( + &self, + sql: &str, + is_select: bool, + origins: &[ResultColumn], + ) -> (Option, Option) { + let reason = |text: &str| (None, Some(text.to_string())); + if !is_select { + return reason("Only the rows of a SELECT can be edited."); + } + let Some(first) = origins.first() else { + return reason("The result has no columns."); + }; + if origins + .iter() + .any(|c| (&c.keyspace, &c.table) != (&first.keyspace, &first.table)) + { + return reason("The result does not come from a single table."); + } + if is_system(&first.keyspace) { + return reason("System tables are read-only."); + } + if has_alias(sql) { + return reason("Columns with an alias (AS) cannot be matched to the table."); + } + let meta = match self.table_meta(&first.keyspace, &first.table).await { + Ok(m) => m, + Err(_) => return reason("The table's key could not be read."), + }; + if meta.view { + return reason("Materialized views cannot be edited."); + } + if meta.columns.iter().any(|c| c.type_name == "counter") { + return reason("Counter tables cannot be edited in the grid."); + } + let mut key = Vec::new(); + let mut missing = Vec::new(); + for c in meta.columns.iter().filter(|c| c.primary_key) { + match origins.iter().position(|o| o.name == c.name) { + Some(i) => key.push(i), + None => missing.push(quote_name(&c.name)), + } + } + if key.is_empty() && missing.is_empty() { + return reason("The table has no primary key."); + } + if !missing.is_empty() { + return reason(&format!( + "The result must include the whole primary key (missing {}).", + missing.join(", ") + )); + } + let columns = origins + .iter() + .map(|o| { + meta.columns + .iter() + .find(|c| c.name == o.name) + .map(|c| EditColumn { + name: c.name.clone(), + type_name: c.type_name.clone(), + }) + }) + .collect(); + ( + Some(EditTarget { + schema: Some(first.keyspace.clone()), + table: first.table.clone(), + columns, + key, + }), + None, + ) + } + + async fn table_meta(&self, keyspace: &str, table: &str) -> AppResult> { + let id = (keyspace.to_string(), table.to_string()); + if let Some(m) = self.tables.lock().get(&id) { + return Ok(m.clone()); + } + let columns = self.list_columns(keyspace, table).await?; + let views = fetch( + &self.cql, + &format!( + "SELECT view_name FROM system_schema.views WHERE keyspace_name = {} AND view_name = {}", + lit(keyspace), + lit(table) + ), + ) + .await?; + let meta = Arc::new(TableMeta { + columns, + view: !views.rows.is_empty(), + }); + self.tables.lock().insert(id, meta.clone()); + Ok(meta) + } + + /// Saves the grid's changes as one logged batch, which Cassandra applies + /// completely or not at all. CQL writes are upserts, so an UPDATE or + /// DELETE whose row was removed meanwhile does not fail: the counts are + /// the statements sent. + pub async fn apply_changes(&self, req: &ApplyRequest) -> AppResult { + let (statements, mut result) = plan_changes(&req.target, &req.changes)?; + let started = Instant::now(); + let sql = match statements.len() { + 0 => return Ok(result), + 1 => statements[0].clone(), + _ => format!("BEGIN BATCH\n{};\nAPPLY BATCH", statements.join(";\n")), + }; + self.execute(&ExecRequest { sql, max_rows: 0 }).await?; + result.elapsed_ms = started.elapsed().as_millis() as u64; + Ok(result) + } + + // ----------------------------------------------------------------------- + // DDL + + pub async fn object_ddl(&self, schema: &str, name: &str, kind: &str) -> AppResult { + match kind { + "keyspace" => self.keyspace_ddl(name).await, + "table" => self.table_ddl(schema, name).await, + "matview" => self.view_ddl(schema, name).await, + "type" => self.type_ddl(schema, name).await, + "function" => self.function_ddl(schema, name).await, + "aggregate" => self.aggregate_ddl(schema, name).await, + "index" => self.index_ddl(schema, name).await, + other => Err(AppError::invalid(format!( + "LowQuery cannot script a {other} on Cassandra." + ))), + } + } + + async fn keyspace_ddl(&self, name: &str) -> AppResult { + let f = fetch( + &self.cql, + &format!( + "SELECT * FROM system_schema.keyspaces WHERE keyspace_name = {}", + lit(name) + ), + ) + .await?; + let Some(row) = f.rows.first() else { + return Ok(String::new()); + }; + let replication = f + .get(row, "replication") + .map_or_else(|| "{}".to_string(), literal); + let durable = f + .get(row, "durable_writes") + .map_or_else(|| "true".to_string(), literal); + Ok(format!( + "CREATE KEYSPACE {} WITH replication = {replication}\n AND durable_writes = {durable};\n", + quote_name(name) + )) + } + + async fn type_ddl(&self, keyspace: &str, name: &str) -> AppResult { + let f = fetch( + &self.cql, + &format!( + "SELECT * FROM system_schema.types WHERE keyspace_name = {} AND type_name = {}", + lit(keyspace), + lit(name) + ), + ) + .await?; + let Some(row) = f.rows.first() else { + return Ok(String::new()); + }; + let names = text_list(f.get(row, "field_names")); + let types = text_list(f.get(row, "field_types")); + let fields: Vec = names + .iter() + .zip(&types) + .map(|(n, t)| format!(" {} {t}", quote_name(n))) + .collect(); + Ok(format!( + "CREATE TYPE {}.{} (\n{}\n);\n", + quote_name(keyspace), + quote_name(name), + fields.join(",\n") + )) + } + + async fn table_ddl(&self, keyspace: &str, name: &str) -> AppResult { + let f = fetch( + &self.cql, + &format!( + "SELECT * FROM system_schema.tables WHERE keyspace_name = {} AND table_name = {}", + lit(keyspace), + lit(name) + ), + ) + .await?; + let Some(row) = f.rows.first() else { + return Ok(String::new()); + }; + let mut out = String::new(); + let columns = self.catalog_columns(keyspace, name).await?; + write_create( + &mut out, + &format!("CREATE TABLE {}.{}", quote_name(keyspace), quote_name(name)), + &columns, + &table_options(&f, row, self.legacy()), + ); + out.push('\n'); + for statement in self.index_statements(keyspace, Some(name), None).await? { + out.push_str(&statement); + out.push('\n'); + } + Ok(out) + } + + async fn view_ddl(&self, keyspace: &str, name: &str) -> AppResult { + let f = fetch( + &self.cql, + &format!( + "SELECT * FROM system_schema.views WHERE keyspace_name = {} AND view_name = {}", + lit(keyspace), + lit(name) + ), + ) + .await?; + let Some(row) = f.rows.first() else { + return Ok(String::new()); + }; + let columns = self.catalog_columns(keyspace, name).await?; + let base = f.text(row, "base_table_name").unwrap_or_default(); + let all = matches!( + f.get(row, "include_all_columns"), + Some(CqlValue::Boolean(true)) + ); + let select = if all { + "*".to_string() + } else { + columns + .iter() + .map(|c| quote_name(&c.name)) + .collect::>() + .join(", ") + }; + let header = format!( + "CREATE MATERIALIZED VIEW {}.{} AS\n SELECT {select}\n FROM {}.{}\n WHERE {}", + quote_name(keyspace), + quote_name(name), + quote_name(keyspace), + quote_name(&base), + f.text(row, "where_clause").unwrap_or_default() + ); + let mut out = String::new(); + write_primary_key( + &mut out, + &header, + &columns, + &table_options(&f, row, self.legacy()), + ); + Ok(out) + } + + async fn catalog_columns(&self, keyspace: &str, table: &str) -> AppResult> { + let f = fetch( + &self.cql, + &format!( + "SELECT column_name, type, kind, position, clustering_order \ + FROM system_schema.columns WHERE keyspace_name = {} AND table_name = {}", + lit(keyspace), + lit(table) + ), + ) + .await?; + let mut cols: Vec = f + .rows + .iter() + .filter_map(|r| { + Some(CatalogColumn { + name: f.text(r, "column_name")?, + type_name: f.text(r, "type")?, + kind: f.text(r, "kind").unwrap_or_default().to_ascii_lowercase(), + position: match f.get(r, "position") { + Some(CqlValue::Int(p)) => *p, + _ => 0, + }, + descending: f + .text(r, "clustering_order") + .is_some_and(|o| o.eq_ignore_ascii_case("desc")), + }) + }) + .collect(); + cols.sort_by(|a, b| (a.rank(), a.position, &a.name).cmp(&(b.rank(), b.position, &b.name))); + Ok(cols) + } + + /// CREATE INDEX statements of a table (or the one index named `only`). + async fn index_statements( + &self, + keyspace: &str, + table: Option<&str>, + only: Option<&str>, + ) -> AppResult> { + let f = fetch( + &self.cql, + &format!( + "SELECT * FROM system_schema.indexes WHERE keyspace_name = {}", + lit(keyspace) + ), + ) + .await?; + let mut out = Vec::new(); + for row in &f.rows { + let (Some(index), Some(on)) = (f.text(row, "index_name"), f.text(row, "table_name")) + else { + continue; + }; + if table.is_some_and(|t| t != on) || only.is_some_and(|o| o != index) { + continue; + } + let kind = f.text(row, "kind").unwrap_or_default(); + let mut options: Vec<(String, String)> = match f.get(row, "options") { + Some(CqlValue::Map(m)) => m + .iter() + .filter_map(|(k, v)| { + Some((text_of(&Some(k.clone()))?, text_of(&Some(v.clone()))?)) + }) + .collect(), + _ => Vec::new(), + }; + let target = options + .iter() + .position(|(k, _)| k == "target") + .map(|i| options.remove(i).1) + .unwrap_or_default(); + let class = options + .iter() + .position(|(k, _)| k == "class_name") + .map(|i| options.remove(i).1); + let mut statement = format!( + "CREATE {}INDEX {} ON {}.{} ({target})", + if kind == "CUSTOM" { "CUSTOM " } else { "" }, + quote_name(&index), + quote_name(keyspace), + quote_name(&on) + ); + if let Some(class) = class { + let _ = write!(statement, " USING {}", quote_str(&class)); + if !options.is_empty() { + options.sort(); + let pairs: Vec = options + .iter() + .map(|(k, v)| format!("{}: {}", quote_str(k), quote_str(v))) + .collect(); + let _ = write!(statement, " WITH OPTIONS = {{{}}}", pairs.join(", ")); + } + } + statement.push(';'); + out.push(statement); + } + Ok(out) + } + + async fn index_ddl(&self, keyspace: &str, name: &str) -> AppResult { + Ok(self + .index_statements(keyspace, None, Some(name)) + .await? + .join("\n")) + } + + /// Functions and aggregates are named `name(argument types)` in the tree. + async fn routine_row( + &self, + table: &str, + column: &str, + keyspace: &str, + name: &str, + ) -> AppResult> { + let (bare, args) = match name.split_once('(') { + Some((b, rest)) => (b, rest.trim_end_matches(')')), + None => (name, ""), + }; + let f = fetch( + &self.cql, + &format!( + "SELECT * FROM system_schema.{table} WHERE keyspace_name = {} AND {column} = {}", + lit(keyspace), + lit(bare) + ), + ) + .await?; + let found = f + .rows + .iter() + .position(|r| text_list(f.get(r, "argument_types")).join(", ") == args.trim()); + Ok(found.map(|i| (f, i))) + } + + async fn function_ddl(&self, keyspace: &str, name: &str) -> AppResult { + let Some((f, i)) = self + .routine_row("functions", "function_name", keyspace, name) + .await? + else { + return Ok(String::new()); + }; + let row = &f.rows[i]; + let args: Vec = text_list(f.get(row, "argument_names")) + .iter() + .zip(text_list(f.get(row, "argument_types"))) + .map(|(n, t)| format!("{} {t}", quote_name(n))) + .collect(); + let on_null = if matches!( + f.get(row, "called_on_null_input"), + Some(CqlValue::Boolean(true)) + ) { + "CALLED ON NULL INPUT" + } else { + "RETURNS NULL ON NULL INPUT" + }; + let body = f.text(row, "body").unwrap_or_default(); + Ok(format!( + "CREATE OR REPLACE FUNCTION {}.{} ({})\n {on_null}\n RETURNS {}\n LANGUAGE {}\n AS {};\n", + quote_name(keyspace), + quote_name(f.text(row, "function_name").as_deref().unwrap_or(name)), + args.join(", "), + f.text(row, "return_type").unwrap_or_default(), + f.text(row, "language").unwrap_or_default(), + dollar_body(&body) + )) + } + + async fn aggregate_ddl(&self, keyspace: &str, name: &str) -> AppResult { + let Some((f, i)) = self + .routine_row("aggregates", "aggregate_name", keyspace, name) + .await? + else { + return Ok(String::new()); + }; + let row = &f.rows[i]; + let mut out = format!( + "CREATE OR REPLACE AGGREGATE {}.{} ({})\n SFUNC {}\n STYPE {}", + quote_name(keyspace), + quote_name(f.text(row, "aggregate_name").as_deref().unwrap_or(name)), + text_list(f.get(row, "argument_types")).join(", "), + quote_name(&f.text(row, "state_func").unwrap_or_default()), + f.text(row, "state_type").unwrap_or_default() + ); + if let Some(fin) = f.text(row, "final_func") { + let _ = write!(out, "\n FINALFUNC {}", quote_name(&fin)); + } + if let Some(init) = f.text(row, "initcond") { + let _ = write!(out, "\n INITCOND {init}"); + } + out.push_str(";\n"); + Ok(out) + } +} + +struct CatalogColumn { + name: String, + type_name: String, + /// `partition_key`, `clustering`, `static` or `regular`. + kind: String, + position: i32, + descending: bool, +} + +impl CatalogColumn { + fn rank(&self) -> u8 { + match self.kind.as_str() { + "partition_key" => 0, + "clustering" => 1, + "static" => 2, + _ => 3, + } + } +} + +/// Options a `CREATE TABLE`/`CREATE MATERIALIZED VIEW` can carry, in the +/// order cqlsh writes them, taken from the row the catalog holds. +fn table_options(f: &Fetched, row: &[Option], legacy: bool) -> Vec { + const OPTIONS: &[&str] = &[ + "additional_write_policy", + "allow_auto_snapshot", + "bloom_filter_fp_chance", + "caching", + "comment", + "compaction", + "compression", + "crc_check_chance", + "default_time_to_live", + "gc_grace_seconds", + "incremental_backups", + "max_index_interval", + "memtable", + "memtable_flush_period_in_ms", + "min_index_interval", + "read_repair", + "speculative_retry", + ]; + // Cassandra 3.x still has the read repair chances that 4.0 removed (it + // refuses them as options). + let legacy_options: &[&str] = if legacy { + &["dclocal_read_repair_chance", "read_repair_chance"] + } else { + &[] + }; + OPTIONS + .iter() + .chain(legacy_options) + .filter_map(|name| { + let value = f.get(row, name)?; + Some(format!("{name} = {}", literal(value))) + }) + .collect() +} + +fn write_create(out: &mut String, header: &str, columns: &[CatalogColumn], options: &[String]) { + let _ = writeln!(out, "{header} ("); + for c in columns { + let _ = writeln!( + out, + " {} {}{},", + quote_name(&c.name), + c.type_name, + if c.kind == "static" { " static" } else { "" } + ); + } + let _ = write!(out, " PRIMARY KEY ({})\n)", primary_key(columns)); + write_with(out, columns, options); +} + +/// Materialized views list their key after the select, not in a column list. +fn write_primary_key( + out: &mut String, + header: &str, + columns: &[CatalogColumn], + options: &[String], +) { + let _ = write!(out, "{header}\n PRIMARY KEY ({})", primary_key(columns)); + write_with(out, columns, options); +} + +fn write_with(out: &mut String, columns: &[CatalogColumn], options: &[String]) { + let clustering: Vec = columns + .iter() + .filter(|c| c.kind == "clustering") + .map(|c| { + format!( + "{} {}", + quote_name(&c.name), + if c.descending { "DESC" } else { "ASC" } + ) + }) + .collect(); + let mut parts = Vec::new(); + if !clustering.is_empty() { + parts.push(format!("CLUSTERING ORDER BY ({})", clustering.join(", "))); + } + parts.extend(options.iter().cloned()); + for (i, part) in parts.iter().enumerate() { + let _ = write!( + out, + "{}{part}", + if i == 0 { "\n WITH " } else { "\n AND " } + ); + } + out.push_str(";\n"); +} + +/// `(a, b), c, d` for a table keyed by (a, b) with clustering columns c, d. +fn primary_key(columns: &[CatalogColumn]) -> String { + let partition: Vec = columns + .iter() + .filter(|c| c.kind == "partition_key") + .map(|c| quote_name(&c.name)) + .collect(); + let clustering: Vec = columns + .iter() + .filter(|c| c.kind == "clustering") + .map(|c| quote_name(&c.name)) + .collect(); + let head = if partition.len() == 1 { + partition[0].clone() + } else { + format!("({})", partition.join(", ")) + }; + std::iter::once(head) + .chain(clustering) + .collect::>() + .join(", ") +} + +/// A function body as `$$...$$`, or a quoted string when it holds `$$`. +fn dollar_body(body: &str) -> String { + if body.contains("$$") { + quote_str(body) + } else { + format!("$${body}$$") + } +} + +// --------------------------------------------------------------------------- +// Edits + +fn plan_changes( + target: &EditTarget, + changes: &[RowChange], +) -> AppResult<(Vec, ApplyResult)> { + let table = match &target.schema { + Some(schema) => format!("{}.{}", quote_name(schema), quote_name(&target.table)), + None => quote_name(&target.table), + }; + let key_columns: Vec<&EditColumn> = target + .key + .iter() + .map(|&i| column_at(target, i)) + .collect::>()?; + let condition = |key: &[Option]| -> AppResult { + if key.len() != key_columns.len() { + return Err(AppError::invalid("The row's key is incomplete.")); + } + key_columns + .iter() + .zip(key) + .map(|(c, v)| match v { + Some(v) => Ok(format!( + "{} = {}", + quote_name(&c.name), + cql_literal(&c.type_name, Some(v)) + )), + None => Err(AppError::invalid(format!( + "The key column {} is empty.", + c.name + ))), + }) + .collect::>>() + .map(|parts| parts.join(" AND ")) + }; + let mut statements = Vec::new(); + let mut result = ApplyResult::default(); + for change in changes { + match change { + RowChange::Update { values, .. } if values.is_empty() => {} + RowChange::Update { key, values } => { + let mut sets = Vec::with_capacity(values.len()); + for v in values { + let c = column_at(target, v.column)?; + if key_columns.iter().any(|k| k.name == c.name) { + return Err(AppError::invalid(format!( + "{} is part of the primary key, which CQL cannot update. \ + Delete the row and insert it again.", + c.name + ))); + } + sets.push(format!( + "{} = {}", + quote_name(&c.name), + cql_literal(&c.type_name, v.value.as_deref()) + )); + } + statements.push(format!( + "UPDATE {table} SET {} WHERE {}", + sets.join(", "), + condition(key)? + )); + result.updated += 1; + } + RowChange::Delete { key } => { + statements.push(format!("DELETE FROM {table} WHERE {}", condition(key)?)); + result.deleted += 1; + } + RowChange::Insert { values } => { + if values.is_empty() { + return Err(AppError::invalid("Enter a value for the new row.")); + } + let mut names = Vec::with_capacity(values.len()); + let mut literals = Vec::with_capacity(values.len()); + for v in values { + let c = column_at(target, v.column)?; + names.push(quote_name(&c.name)); + literals.push(cql_literal(&c.type_name, v.value.as_deref())); + } + statements.push(format!( + "INSERT INTO {table} ({}) VALUES ({})", + names.join(", "), + literals.join(", ") + )); + result.inserted += 1; + } + } + } + Ok((statements, result)) +} + +fn column_at(target: &EditTarget, index: usize) -> AppResult<&EditColumn> { + target + .columns + .get(index) + .and_then(Option::as_ref) + .ok_or_else(|| { + AppError::invalid(format!( + "Column {} is not a column of the table and cannot be saved.", + index + 1 + )) + }) +} + +/// True when the text has the word `AS` outside strings, quoted names and +/// comments. +fn has_alias(sql: &str) -> bool { + let b = sql.as_bytes(); + let mut i = 0; + let mut word = String::new(); + let mut found = false; + let mut flush = |word: &mut String| { + if word.eq_ignore_ascii_case("as") { + found = true; + } + word.clear(); + }; + while i < b.len() { + let c = b[i]; + if c == b'\'' || c == b'"' { + flush(&mut word); + i += 1; + while i < b.len() { + if b[i] == c { + if b.get(i + 1) == Some(&c) { + i += 1; + } else { + break; + } + } + i += 1; + } + } else if c == b'$' && b.get(i + 1) == Some(&b'$') { + flush(&mut word); + i = sql[i + 2..].find("$$").map_or(b.len(), |p| i + 2 + p + 1); + } else if (c == b'-' && b.get(i + 1) == Some(&b'-')) + || (c == b'/' && b.get(i + 1) == Some(&b'/')) + { + flush(&mut word); + while i < b.len() && b[i] != b'\n' { + i += 1; + } + } else if c == b'/' && b.get(i + 1) == Some(&b'*') { + flush(&mut word); + i = sql[i + 2..].find("*/").map_or(b.len(), |p| i + 2 + p + 1); + } else if c.is_ascii_alphanumeric() || c == b'_' { + word.push(c as char); + } else { + flush(&mut word); + } + i += 1; + } + flush(&mut word); + found +} + +// --------------------------------------------------------------------------- +// Names and types + +/// CQL's reserved words (Cassandra 3.0 to 5.0): they need quotes as names. +const RESERVED: &[&str] = &[ + "ADD", + "ALLOW", + "ALTER", + "AND", + "APPLY", + "ASC", + "AUTHORIZE", + "BATCH", + "BEGIN", + "BY", + "COLUMNFAMILY", + "CREATE", + "DEFAULT", + "DELETE", + "DESC", + "DESCRIBE", + "DROP", + "ENTRIES", + "EXECUTE", + "FROM", + "FULL", + "GRANT", + "IF", + "IN", + "INDEX", + "INFINITY", + "INSERT", + "INTO", + "IS", + "KEYSPACE", + "LIMIT", + "MATERIALIZED", + "MODIFY", + "NAN", + "NORECURSIVE", + "NOT", + "NULL", + "OF", + "ON", + "OR", + "ORDER", + "PRIMARY", + "RENAME", + "REPLACE", + "REVOKE", + "SCHEMA", + "SELECT", + "SET", + "TABLE", + "TO", + "TOKEN", + "TRUNCATE", + "UNLOGGED", + "UPDATE", + "USE", + "USING", + "VIEW", + "WHERE", + "WITH", +]; + +/// An identifier as CQL reads it back: bare when it is lowercase and not a +/// reserved word, otherwise double-quoted. +pub fn quote_name(name: &str) -> String { + let bare = name.bytes().next().is_some_and(|c| c.is_ascii_lowercase()) + && name + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_') + && !RESERVED.contains(&name.to_ascii_uppercase().as_str()); + if bare { + name.to_string() + } else { + format!("\"{}\"", name.replace('"', "\"\"")) + } +} + +fn quote_str(s: &str) -> String { + format!("'{}'", s.replace('\'', "''")) +} + +fn native_name(t: &NativeType) -> &'static str { + match t { + NativeType::Ascii => "ascii", + NativeType::Boolean => "boolean", + NativeType::Blob => "blob", + NativeType::Counter => "counter", + NativeType::Date => "date", + NativeType::Decimal => "decimal", + NativeType::Double => "double", + NativeType::Duration => "duration", + NativeType::Float => "float", + NativeType::Int => "int", + NativeType::BigInt => "bigint", + NativeType::Text => "text", + NativeType::Timestamp => "timestamp", + NativeType::Inet => "inet", + NativeType::SmallInt => "smallint", + NativeType::TinyInt => "tinyint", + NativeType::Time => "time", + NativeType::Timeuuid => "timeuuid", + NativeType::Uuid => "uuid", + NativeType::Varint => "varint", + _ => "custom", + } +} + +/// The type as `system_schema` writes it: `list`, +/// `frozen>`, `vector`, a UDT by its name. +fn type_name(t: &ColumnType<'_>) -> String { + let frozen = |frozen: bool, inner: String| { + if frozen { + format!("frozen<{inner}>") + } else { + inner + } + }; + match t { + ColumnType::Native(n) => native_name(n).into(), + ColumnType::Collection { frozen: f, typ } => frozen( + *f, + match typ { + CollectionType::List(e) => format!("list<{}>", type_name(e)), + CollectionType::Set(e) => format!("set<{}>", type_name(e)), + CollectionType::Map(k, v) => format!("map<{}, {}>", type_name(k), type_name(v)), + _ => "custom".into(), + }, + ), + ColumnType::Vector { typ, dimensions } => { + format!("vector<{}, {dimensions}>", type_name(typ)) + } + ColumnType::UserDefinedType { + frozen: f, + definition, + } => frozen(*f, quote_name(&definition.name)), + ColumnType::Tuple(items) => format!( + "tuple<{}>", + items.iter().map(type_name).collect::>().join(", ") + ), + _ => "custom".into(), + } +} + +fn column_kind(t: &ColumnType<'_>) -> ValueKind { + match t { + ColumnType::Native(n) => match n { + NativeType::TinyInt + | NativeType::SmallInt + | NativeType::Int + | NativeType::BigInt + | NativeType::Varint + | NativeType::Decimal + | NativeType::Float + | NativeType::Double + | NativeType::Counter => ValueKind::Number, + NativeType::Boolean => ValueKind::Bool, + NativeType::Timestamp | NativeType::Date | NativeType::Time | NativeType::Duration => { + ValueKind::Temporal + } + NativeType::Blob => ValueKind::Binary, + NativeType::Text + | NativeType::Ascii + | NativeType::Uuid + | NativeType::Timeuuid + | NativeType::Inet => ValueKind::Text, + _ => ValueKind::Other, + }, + _ => ValueKind::Other, + } +} + +// --------------------------------------------------------------------------- +// Values + +/// A value as the grid shows it: text as it is, everything else as its CQL +/// text (collections and UDTs as valid CQL literals). +fn cell(v: &Option) -> Option { + let v = v.as_ref()?; + Some(match v { + CqlValue::Text(s) | CqlValue::Ascii(s) => s.clone(), + CqlValue::Timestamp(_) | CqlValue::Date(_) | CqlValue::Time(_) | CqlValue::Inet(_) => { + plain(v) + } + CqlValue::Blob(bytes) => blob(bytes, super::binary_limit(BINARY_PREVIEW_BYTES)), + CqlValue::Empty => String::new(), + other => literal(other), + }) +} + +/// The text of a temporal value or an address, unquoted. +fn plain(v: &CqlValue) -> String { + match v { + CqlValue::Timestamp(t) => timestamp_text(t.0), + CqlValue::Date(d) => date_text(i64::from(d.0) - (1 << 31)), + CqlValue::Time(t) => time_text(t.0), + CqlValue::Inet(ip) => ip.to_string(), + _ => String::new(), + } +} + +/// `0x00ff`, cut to `limit` bytes with the full size noted. +fn blob(bytes: &[u8], limit: usize) -> String { + let shown = &bytes[..bytes.len().min(limit)]; + let mut s = String::with_capacity(shown.len() * 2 + 2); + s.push_str("0x"); + for b in shown { + let _ = write!(s, "{b:02x}"); + } + if bytes.len() > shown.len() { + let _ = write!(s, "… ({} bytes)", bytes.len()); + } + s +} + +/// A value as a CQL literal, as it is written inside a collection. +fn literal(v: &CqlValue) -> String { + match v { + CqlValue::Text(s) | CqlValue::Ascii(s) => quote_str(s), + CqlValue::Boolean(b) => b.to_string(), + CqlValue::TinyInt(n) => n.to_string(), + CqlValue::SmallInt(n) => n.to_string(), + CqlValue::Int(n) => n.to_string(), + CqlValue::BigInt(n) => n.to_string(), + CqlValue::Counter(c) => c.0.to_string(), + CqlValue::Float(f) => float_text(f64::from(*f), &f.to_string(), &format!("{f:e}")), + CqlValue::Double(d) => float_text(*d, &d.to_string(), &format!("{d:e}")), + CqlValue::Varint(n) => integer_text(n.as_signed_bytes_be_slice()), + CqlValue::Decimal(d) => { + let (bytes, scale) = d.as_signed_be_bytes_slice_and_exponent(); + decimal_text(&integer_text(bytes), scale) + } + CqlValue::Uuid(u) => u.to_string(), + CqlValue::Timeuuid(u) => u.to_string(), + CqlValue::Blob(bytes) => blob(bytes, usize::MAX), + CqlValue::Timestamp(_) | CqlValue::Date(_) | CqlValue::Time(_) | CqlValue::Inet(_) => { + quote_str(&plain(v)) + } + CqlValue::Duration(d) => duration_text(d.months, d.days, d.nanoseconds), + CqlValue::List(items) | CqlValue::Vector(items) => { + format!( + "[{}]", + items.iter().map(literal).collect::>().join(", ") + ) + } + CqlValue::Set(items) => { + format!( + "{{{}}}", + items.iter().map(literal).collect::>().join(", ") + ) + } + CqlValue::Map(pairs) => format!( + "{{{}}}", + pairs + .iter() + .map(|(k, v)| format!("{}: {}", literal(k), literal(v))) + .collect::>() + .join(", ") + ), + CqlValue::Tuple(items) => format!( + "({})", + items + .iter() + .map(|i| i.as_ref().map_or_else(|| "null".into(), literal)) + .collect::>() + .join(", ") + ), + CqlValue::UserDefinedType { fields, .. } => format!( + "{{{}}}", + fields + .iter() + .map(|(n, v)| format!( + "{}: {}", + quote_name(n), + v.as_ref().map_or_else(|| "null".into(), literal) + )) + .collect::>() + .join(", ") + ), + _ => "null".into(), + } +} + +/// Shortest round-trip text; very large or small magnitudes use an exponent. +fn float_text(value: f64, plain: &str, exponent: &str) -> String { + if value.is_nan() { + "NaN".into() + } else if value.is_infinite() { + if value > 0.0 { "Infinity" } else { "-Infinity" }.into() + } else if value != 0.0 && (value.abs() >= 1e16 || value.abs() < 1e-5) { + exponent.to_string() + } else { + plain.to_string() + } +} + +/// Decimal digits of a two's complement big-endian integer. +fn integer_text(bytes: &[u8]) -> String { + let negative = bytes.first().is_some_and(|b| b & 0x80 != 0); + let mut magnitude = bytes.to_vec(); + if negative { + // Negate: invert and add one. + let mut carry = true; + for b in magnitude.iter_mut().rev() { + *b = !*b; + if carry { + let (n, c) = b.overflowing_add(1); + *b = n; + carry = c; + } + } + } + // Base 2^32 limbs, most significant first. + let pad = (4 - magnitude.len() % 4) % 4; + let mut limbs: Vec = std::iter::repeat_n(0u8, pad) + .chain(magnitude) + .collect::>() + .chunks(4) + .map(|c| u32::from_be_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + let mut parts: Vec = Vec::new(); + while limbs.iter().any(|&l| l != 0) { + let mut rem = 0u64; + for limb in &mut limbs { + let cur = (rem << 32) | u64::from(*limb); + *limb = (cur / 1_000_000_000) as u32; + rem = cur % 1_000_000_000; + } + parts.push(rem as u32); + let zeros = limbs.iter().take_while(|&&l| l == 0).count(); + limbs.drain(..zeros); + } + let Some(top) = parts.pop() else { + return "0".into(); + }; + let mut out = String::new(); + if negative { + out.push('-'); + } + let _ = write!(out, "{top}"); + for part in parts.iter().rev() { + let _ = write!(out, "{part:09}"); + } + out +} + +/// `unscaled × 10^-scale` the way Java's `BigDecimal.toString` writes it: +/// plain when the exponent allows, scientific otherwise. +fn decimal_text(unscaled: &str, scale: i32) -> String { + let (sign, digits) = match unscaled.strip_prefix('-') { + Some(d) => ("-", d), + None => ("", unscaled), + }; + let adjusted = -i64::from(scale) + digits.len() as i64 - 1; + if scale == 0 { + return unscaled.to_string(); + } + if scale > 0 && adjusted >= -6 { + let scale = scale as usize; + return if digits.len() > scale { + let (int, frac) = digits.split_at(digits.len() - scale); + format!("{sign}{int}.{frac}") + } else { + format!("{sign}0.{}{digits}", "0".repeat(scale - digits.len())) + }; + } + let (head, rest) = digits.split_at(1); + let mantissa = if rest.is_empty() { + head.to_string() + } else { + format!("{head}.{rest}") + }; + format!( + "{sign}{mantissa}E{}{adjusted}", + if adjusted >= 0 { "+" } else { "" } + ) +} + +/// Year, month and day of a day count since 1970-01-01. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let year = yoe + era * 400 + i64::from(month <= 2); + (year, month, day) +} + +fn date_text(days: i64) -> String { + let (y, m, d) = civil_from_days(days); + if y < 0 { + format!("-{:04}-{m:02}-{d:02}", -y) + } else { + format!("{y:04}-{m:02}-{d:02}") + } +} + +/// `2024-05-06 07:08:09.123+0000`. +fn timestamp_text(millis: i64) -> String { + let secs = millis.div_euclid(1000); + let ms = millis.rem_euclid(1000); + let day = secs.div_euclid(86_400); + let tod = secs.rem_euclid(86_400); + format!( + "{} {:02}:{:02}:{:02}.{ms:03}+0000", + date_text(day), + tod / 3600, + tod % 3600 / 60, + tod % 60 + ) +} + +/// `07:08:09.123456789`. +fn time_text(nanos: i64) -> String { + let secs = nanos.div_euclid(1_000_000_000); + let frac = nanos.rem_euclid(1_000_000_000); + format!( + "{:02}:{:02}:{:02}.{frac:09}", + secs / 3600, + secs % 3600 / 60, + secs % 60 + ) +} + +/// `1y2mo3d4h5m6s7ms8us9ns`, the form CQL reads back. +fn duration_text(months: i32, days: i32, nanos: i64) -> String { + let negative = months < 0 || days < 0 || nanos < 0; + let months = i64::from(months).unsigned_abs(); + let days = i64::from(days).unsigned_abs(); + let nanos = nanos.unsigned_abs(); + let mut out = String::new(); + let mut put = |n: u64, unit: &str| { + if n > 0 { + let _ = write!(out, "{n}{unit}"); + } + }; + put(months / 12, "y"); + put(months % 12, "mo"); + put(days, "d"); + put(nanos / 3_600_000_000_000, "h"); + put(nanos / 60_000_000_000 % 60, "m"); + put(nanos / 1_000_000_000 % 60, "s"); + put(nanos / 1_000_000 % 1000, "ms"); + put(nanos / 1000 % 1000, "us"); + put(nanos % 1000, "ns"); + if out.is_empty() { + out.push_str("0s"); + } + if negative { + out.insert(0, '-'); + } + out +} + +/// A cell's text as a CQL literal for a column of `type_name` (the type as +/// `system_schema` writes it). Numbers, booleans, uuids, blobs and durations +/// are bare, text and temporal values quoted; collections, tuples, vectors and +/// UDTs are already CQL literals in cell text and stay as they are. `None` is +/// `null`. +pub fn cql_literal(type_name: &str, value: Option<&str>) -> String { + let Some(v) = value else { + return "null".into(); + }; + let mut t = type_name.trim().to_ascii_lowercase(); + while let Some(inner) = t + .strip_prefix("frozen<") + .and_then(|rest| rest.strip_suffix('>')) + { + t = inner.trim().to_string(); + } + let trimmed = v.trim(); + let bare = match t.as_str() { + "tinyint" | "smallint" | "int" | "bigint" | "varint" | "decimal" | "float" | "double" + | "counter" => is_number(trimmed).then(|| trimmed.to_string()), + "boolean" => match trimmed.to_ascii_lowercase().as_str() { + b @ ("true" | "false") => Some(b.to_string()), + _ => None, + }, + "uuid" | "timeuuid" => is_uuid(trimmed).then(|| trimmed.to_string()), + "blob" => is_blob(trimmed).then(|| trimmed.to_string()), + "duration" => (!trimmed.is_empty() && !trimmed.contains(char::is_whitespace)) + .then(|| trimmed.to_string()), + "text" | "varchar" | "ascii" | "inet" | "timestamp" | "date" | "time" => { + return quote_str(v); + } + // Collections, tuples, vectors and UDTs. + _ => return v.to_string(), + }; + bare.unwrap_or_else(|| quote_str(v)) +} + +fn is_number(v: &str) -> bool { + if matches!(v, "NaN" | "Infinity" | "-Infinity") { + return true; + } + let body = v.strip_prefix(['-', '+']).unwrap_or(v); + let (mantissa, exponent) = match body.find(['e', 'E']) { + Some(i) => (&body[..i], Some(&body[i + 1..])), + None => (body, None), + }; + let digits = mantissa.replacen('.', "", 1); + !digits.is_empty() + && digits.bytes().all(|b| b.is_ascii_digit()) + && mantissa.matches('.').count() <= 1 + && exponent.is_none_or(|e| { + let e = e.strip_prefix(['-', '+']).unwrap_or(e); + !e.is_empty() && e.bytes().all(|b| b.is_ascii_digit()) + }) +} + +fn is_uuid(v: &str) -> bool { + v.len() == 36 + && v.bytes().enumerate().all(|(i, b)| { + if matches!(i, 8 | 13 | 18 | 23) { + b == b'-' + } else { + b.is_ascii_hexdigit() + } + }) +} + +fn is_blob(v: &str) -> bool { + v.strip_prefix("0x") + .is_some_and(|h| h.len() % 2 == 0 && h.bytes().all(|b| b.is_ascii_hexdigit())) +} + +// --------------------------------------------------------------------------- +// Errors + +/// Every message in the chain of an error, joined. +fn chain(e: &dyn std::error::Error) -> String { + let mut text = e.to_string(); + let mut source = e.source(); + while let Some(s) = source { + let part = s.to_string(); + if !text.contains(&part) { + text.push_str(": "); + text.push_str(&part); + } + source = s.source(); + } + text +} + +fn is_auth_text(text: &str) -> bool { + let t = text.to_ascii_lowercase(); + t.contains("authenticat") || t.contains("credentials") || t.contains("username and/or password") +} + +fn connect_error(e: NewSessionError) -> AppError { + let text = chain(&e); + let mut err = AppError::connection(match &e { + NewSessionError::EmptyKnownNodesList => "Enter at least one contact point.".to_string(), + _ => text.clone(), + }); + if is_auth_text(&text) { + err.code = Some("AuthenticationError".into()); + } + err +} + +/// The server's error behind a failed request, when there is one. +fn attempt_error(e: &RequestAttemptError) -> Option<(&DbError, &str)> { + match e { + RequestAttemptError::DbError(db, message) => Some((db, message)), + _ => None, + } +} + +/// `line 1:7 no viable alternative at input ...` as a 0-based character +/// offset into `sql`. +fn error_position(sql: &str, message: &str) -> Option { + let rest = &message[message.find("line ")? + 5..]; + let (line, rest) = rest.split_once(':')?; + let line: usize = line.trim().parse().ok()?; + let col: usize = rest + .chars() + .take_while(char::is_ascii_digit) + .collect::() + .parse() + .ok()?; + let before: usize = sql + .split('\n') + .take(line.checked_sub(1)?) + .map(|l| l.chars().count() + 1) + .sum(); + Some(before + col) +} + +fn db_error(db: &DbError, message: &str, sql: Option<&str>) -> AppError { + let debug = format!("{db:?}"); + let code: String = debug + .chars() + .take_while(|c| c.is_ascii_alphanumeric()) + .collect(); + let kind = match db { + DbError::AuthenticationError => ErrorKind::Connection, + _ => ErrorKind::Database, + }; + let mut err = AppError::new(kind, message); + err.code = Some(code); + if matches!(db, DbError::SyntaxError) { + err.position = sql.and_then(|s| error_position(s, message)); + } + err +} + +fn timeout_error(d: Duration) -> AppError { + AppError::new( + ErrorKind::Database, + format!("The request took longer than {} ms.", d.as_millis()), + ) +} + +fn map_request(e: &RequestError, sql: Option<&str>) -> AppError { + match e { + RequestError::LastAttemptError(a) => match attempt_error(a) { + Some((db, message)) => db_error(db, message, sql), + None => AppError::connection(chain(a)), + }, + RequestError::RequestTimeout(d) => timeout_error(*d), + RequestError::EmptyPlan => no_node(), + other => AppError::connection(chain(other)), + } +} + +fn no_node() -> AppError { + AppError::connection( + "No node accepts requests. If a local datacenter is set, check its name: only its nodes are used.", + ) +} + +fn map_execution(e: ExecutionError, sql: Option<&str>) -> AppError { + match &e { + ExecutionError::LastAttemptError(a) => match attempt_error(a) { + Some((db, message)) => db_error(db, message, sql), + None => AppError::connection(chain(a)), + }, + ExecutionError::RequestTimeout(d) => timeout_error(*d), + ExecutionError::EmptyPlan => no_node(), + ExecutionError::BadQuery(_) => AppError::invalid(e.to_string()), + ExecutionError::ConnectionPoolError(_) => AppError::connection(chain(&e)), + _ => AppError::new(ErrorKind::Database, chain(&e)), + } +} + +fn map_next_page(e: &NextPageError) -> AppError { + match e { + NextPageError::RequestFailure(r) => map_request(r, None), + other => AppError::new(ErrorKind::Database, chain(other)), + } +} + +fn map_next_row(e: NextRowError) -> AppError { + match &e { + NextRowError::NextPageError(p) => map_next_page(p), + other => AppError::internal(other.to_string()), + } +} + +fn map_pager(e: PagerExecutionError) -> AppError { + match &e { + PagerExecutionError::NextPageError(p) => map_next_page(p), + other => AppError::new(ErrorKind::Database, chain(other)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use scylla::value::{ + Counter, CqlDate, CqlDecimal, CqlDuration, CqlTime, CqlTimestamp, CqlTimeuuid, CqlVarint, + }; + + fn text(s: &str) -> CqlValue { + CqlValue::Text(s.into()) + } + + #[test] + fn integers_keep_every_digit() { + assert_eq!(integer_text(&[]), "0"); + assert_eq!(integer_text(&[0]), "0"); + assert_eq!(integer_text(&[0x7f]), "127"); + assert_eq!(integer_text(&[0xff]), "-1"); + assert_eq!(integer_text(&[0x80]), "-128"); + assert_eq!(integer_text(&[0x00, 0xff]), "255"); + // 2^64 and -(2^64) - 1 + assert_eq!( + integer_text(&[0x01, 0, 0, 0, 0, 0, 0, 0, 0]), + "18446744073709551616" + ); + assert_eq!( + integer_text(&[0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), + "-18446744073709551617" + ); + // 123456789012345678901234567890 + let n = CqlVarint::from_signed_bytes_be(vec![ + 0x01, 0x8e, 0xe9, 0x0f, 0xf6, 0xc3, 0x73, 0xe0, 0xee, 0x4e, 0x3f, 0x0a, 0xd2, + ]); + assert_eq!( + literal(&CqlValue::Varint(n)), + "123456789012345678901234567890" + ); + } + + #[test] + fn decimals_are_exact() { + assert_eq!(decimal_text("12345", 2), "123.45"); + assert_eq!(decimal_text("-5", 3), "-0.005"); + assert_eq!(decimal_text("0", 2), "0.00"); + assert_eq!(decimal_text("7", 0), "7"); + assert_eq!(decimal_text("1", -3), "1E+3"); + assert_eq!(decimal_text("-12", -3), "-1.2E+4"); + assert_eq!(decimal_text("1", 10), "1E-10"); + let d = CqlDecimal::from_signed_be_bytes_and_exponent(vec![0x30, 0x39], 2); + assert_eq!(literal(&CqlValue::Decimal(d)), "123.45"); + } + + #[test] + fn floats_round_trip() { + assert_eq!(literal(&CqlValue::Double(0.1)), "0.1"); + assert_eq!(literal(&CqlValue::Float(0.1)), "0.1"); + assert_eq!(literal(&CqlValue::Double(1.0)), "1"); + assert_eq!(literal(&CqlValue::Double(f64::NAN)), "NaN"); + assert_eq!(literal(&CqlValue::Double(f64::INFINITY)), "Infinity"); + assert_eq!(literal(&CqlValue::Double(f64::NEG_INFINITY)), "-Infinity"); + assert_eq!(literal(&CqlValue::Double(1.5e300)), "1.5e300"); + assert_eq!(literal(&CqlValue::Double(1e-7)), "1e-7"); + assert_eq!(literal(&CqlValue::Double(0.0)), "0"); + } + + #[test] + fn temporal_values() { + assert_eq!( + cell(&Some(CqlValue::Timestamp(CqlTimestamp(0)))).unwrap(), + "1970-01-01 00:00:00.000+0000" + ); + assert_eq!( + timestamp_text(1_714_979_289_123), + "2024-05-06 07:08:09.123+0000" + ); + assert_eq!(timestamp_text(-1), "1969-12-31 23:59:59.999+0000"); + assert_eq!(date_text(0), "1970-01-01"); + assert_eq!(date_text(19_849), "2024-05-06"); + assert_eq!( + cell(&Some(CqlValue::Date(CqlDate(1 << 31)))).unwrap(), + "1970-01-01" + ); + assert_eq!( + cell(&Some(CqlValue::Time(CqlTime(25_689_123_456_789)))).unwrap(), + "07:08:09.123456789" + ); + assert_eq!( + literal(&CqlValue::Timestamp(CqlTimestamp(0))), + "'1970-01-01 00:00:00.000+0000'" + ); + } + + #[test] + fn durations_read_back() { + assert_eq!(duration_text(0, 0, 0), "0s"); + assert_eq!( + duration_text(14, 3, 14_706_007_008_009), + "1y2mo3d4h5m6s7ms8us9ns" + ); + assert_eq!(duration_text(-1, -2, -3_000_000_000), "-1mo2d3s"); + assert_eq!( + literal(&CqlValue::Duration(CqlDuration { + months: 1, + days: 0, + nanoseconds: 0 + })), + "1mo" + ); + } + + #[test] + fn scalars() { + assert_eq!(cell(&None), None); + assert_eq!(cell(&Some(text("it's"))).unwrap(), "it's"); + assert_eq!(literal(&text("it's")), "'it''s'"); + assert_eq!(cell(&Some(CqlValue::Boolean(true))).unwrap(), "true"); + assert_eq!(cell(&Some(CqlValue::Counter(Counter(-4)))).unwrap(), "-4"); + assert_eq!(cell(&Some(CqlValue::Blob(vec![0, 255]))).unwrap(), "0x00ff"); + assert_eq!(cell(&Some(CqlValue::Blob(vec![]))).unwrap(), "0x"); + assert_eq!( + cell(&Some(CqlValue::Inet("127.0.0.1".parse().unwrap()))).unwrap(), + "127.0.0.1" + ); + assert_eq!(literal(&CqlValue::Inet("::1".parse().unwrap())), "'::1'"); + let id = uuid::Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").unwrap(); + assert_eq!( + cell(&Some(CqlValue::Uuid(id))).unwrap(), + "123e4567-e89b-12d3-a456-426614174000" + ); + assert_eq!( + cell(&Some(CqlValue::Timeuuid(CqlTimeuuid::from(id)))).unwrap(), + "123e4567-e89b-12d3-a456-426614174000" + ); + } + + #[test] + fn composites_are_cql_literals() { + let list = CqlValue::List(vec![CqlValue::Int(1), CqlValue::Int(2)]); + assert_eq!(cell(&Some(list)).unwrap(), "[1, 2]"); + let set = CqlValue::Set(vec![text("a"), text("b'")]); + assert_eq!(cell(&Some(set)).unwrap(), "{'a', 'b'''}"); + let map = CqlValue::Map(vec![(text("a"), CqlValue::Int(1))]); + assert_eq!(cell(&Some(map)).unwrap(), "{'a': 1}"); + let tuple = CqlValue::Tuple(vec![Some(CqlValue::Int(1)), Some(text("x")), None]); + assert_eq!(cell(&Some(tuple)).unwrap(), "(1, 'x', null)"); + let udt = CqlValue::UserDefinedType { + keyspace: "ks".into(), + name: "address".into(), + fields: vec![ + ("street".into(), Some(text("Main"))), + ("Zip".into(), Some(CqlValue::Int(12345))), + ("floor".into(), None), + ], + }; + assert_eq!( + cell(&Some(udt)).unwrap(), + "{street: 'Main', \"Zip\": 12345, floor: null}" + ); + let vector = CqlValue::Vector(vec![CqlValue::Float(0.5), CqlValue::Float(1.5)]); + assert_eq!(cell(&Some(vector)).unwrap(), "[0.5, 1.5]"); + let nested = CqlValue::Map(vec![( + text("k"), + CqlValue::List(vec![CqlValue::Blob(vec![1])]), + )]); + assert_eq!(literal(&nested), "{'k': [0x01]}"); + } + + #[test] + fn blob_preview_is_cut() { + let bytes = vec![0xab; 10]; + assert_eq!(blob(&bytes, 4), "0xabababab… (10 bytes)"); + assert_eq!(blob(&bytes, usize::MAX).len(), 22); + } + + #[test] + fn literals_follow_the_column_type() { + assert_eq!(cql_literal("int", Some("42")), "42"); + assert_eq!( + cql_literal("varint", Some("-12345678901234567890")), + "-12345678901234567890" + ); + assert_eq!(cql_literal("double", Some("NaN")), "NaN"); + assert_eq!(cql_literal("decimal", Some("1.50")), "1.50"); + assert_eq!(cql_literal("boolean", Some("TRUE")), "true"); + assert_eq!(cql_literal("text", Some("it's")), "'it''s'"); + assert_eq!(cql_literal("ascii", Some("x")), "'x'"); + assert_eq!( + cql_literal("timestamp", Some("2024-05-06 07:08:09.123+0000")), + "'2024-05-06 07:08:09.123+0000'" + ); + assert_eq!(cql_literal("date", Some("2024-05-06")), "'2024-05-06'"); + assert_eq!(cql_literal("time", Some("07:08:09")), "'07:08:09'"); + assert_eq!(cql_literal("inet", Some("::1")), "'::1'"); + assert_eq!( + cql_literal("uuid", Some("123e4567-e89b-12d3-a456-426614174000")), + "123e4567-e89b-12d3-a456-426614174000" + ); + assert_eq!(cql_literal("blob", Some("0x00ff")), "0x00ff"); + assert_eq!(cql_literal("duration", Some("1mo2d")), "1mo2d"); + assert_eq!(cql_literal("int", None), "null"); + // Not a value of the type: quoted, so the server explains the error. + assert_eq!(cql_literal("int", Some("abc")), "'abc'"); + assert_eq!(cql_literal("blob", Some("xyz")), "'xyz'"); + // Collections, tuples, UDTs and vectors are literals already. + assert_eq!(cql_literal("list", Some("[1, 2]")), "[1, 2]"); + assert_eq!( + cql_literal("frozen>", Some("{'a': 1}")), + "{'a': 1}" + ); + assert_eq!(cql_literal("frozen
", Some("{zip: 1}")), "{zip: 1}"); + assert_eq!( + cql_literal("tuple", Some("(1, 'x')")), + "(1, 'x')" + ); + assert_eq!( + cql_literal("vector", Some("[0.5, 1.5]")), + "[0.5, 1.5]" + ); + assert_eq!(cql_literal("FROZEN>", Some("['a']")), "['a']"); + } + + #[test] + fn names_are_quoted_when_needed() { + assert_eq!(quote_name("users"), "users"); + assert_eq!(quote_name("user_id2"), "user_id2"); + assert_eq!(quote_name("Users"), "\"Users\""); + assert_eq!(quote_name("2fast"), "\"2fast\""); + assert_eq!(quote_name("select"), "\"select\""); + assert_eq!(quote_name("a\"b"), "\"a\"\"b\""); + assert_eq!(quote_name("with space"), "\"with space\""); + assert_eq!(quote_name(""), "\"\""); + } + + #[test] + fn type_names() { + assert_eq!(type_name(&ColumnType::Native(NativeType::Int)), "int"); + let list = ColumnType::Collection { + frozen: false, + typ: CollectionType::List(Box::new(ColumnType::Native(NativeType::Text))), + }; + assert_eq!(type_name(&list), "list"); + let map = ColumnType::Collection { + frozen: true, + typ: CollectionType::Map( + Box::new(ColumnType::Native(NativeType::Text)), + Box::new(ColumnType::Native(NativeType::Int)), + ), + }; + assert_eq!(type_name(&map), "frozen>"); + assert_eq!(column_kind(&map), ValueKind::Other); + assert_eq!( + type_name(&ColumnType::Vector { + typ: Box::new(ColumnType::Native(NativeType::Float)), + dimensions: 3 + }), + "vector" + ); + assert_eq!( + type_name(&ColumnType::Tuple(vec![ + ColumnType::Native(NativeType::Int), + ColumnType::Native(NativeType::Text) + ])), + "tuple" + ); + assert_eq!( + column_kind(&ColumnType::Native(NativeType::Varint)), + ValueKind::Number + ); + assert_eq!( + column_kind(&ColumnType::Native(NativeType::Blob)), + ValueKind::Binary + ); + assert_eq!( + column_kind(&ColumnType::Native(NativeType::Uuid)), + ValueKind::Text + ); + assert_eq!( + column_kind(&ColumnType::Native(NativeType::Timestamp)), + ValueKind::Temporal + ); + assert_eq!( + column_kind(&ColumnType::Native(NativeType::Boolean)), + ValueKind::Bool + ); + } + + #[test] + fn contact_points() { + assert_eq!(contact_point("db1", 9042), "db1:9042"); + assert_eq!(contact_point("db1:9043", 9042), "db1:9043"); + assert_eq!(contact_point("::1", 9042), "[::1]:9042"); + assert_eq!(contact_point("[::1]", 9042), "[::1]:9042"); + assert_eq!(contact_point("[::1]:9043", 9042), "[::1]:9043"); + } + + #[test] + fn syntax_error_positions() { + let sql = "SELECT *\nFROM WHERE"; + let message = "line 2:6 no viable alternative at input 'WHERE'"; + // "SELECT *" is 8 chars plus the newline, then column 6. + assert_eq!(error_position(sql, message), Some(15)); + assert_eq!(error_position("x", "line 1:0 mismatched input"), Some(0)); + assert_eq!(error_position("x", "Unknown table"), None); + } + + #[test] + fn aliases_are_found_outside_strings() { + assert!(has_alias("SELECT id AS key FROM t")); + assert!(has_alias("select id as key from t")); + assert!(!has_alias("SELECT id FROM t WHERE name = 'a as b'")); + assert!(!has_alias("SELECT \"as\", assigned FROM t -- as x\n")); + assert!(!has_alias("SELECT id FROM base")); + } + + #[test] + fn primary_keys_and_options() { + let col = |name: &str, kind: &str, position: i32, descending: bool| CatalogColumn { + name: name.into(), + type_name: "int".into(), + kind: kind.into(), + position, + descending, + }; + let columns = vec![ + col("a", "partition_key", 0, false), + col("b", "partition_key", 1, false), + col("c", "clustering", 0, true), + col("d", "regular", -1, false), + ]; + assert_eq!(primary_key(&columns), "(a, b), c"); + assert_eq!(primary_key(&columns[1..]), "b, c"); + let mut out = String::new(); + write_create( + &mut out, + "CREATE TABLE ks.t", + &columns, + &[ + "comment = ''".to_string(), + "gc_grace_seconds = 864000".to_string(), + ], + ); + assert_eq!( + out, + "CREATE TABLE ks.t (\n a int,\n b int,\n c int,\n d int,\n \ + PRIMARY KEY ((a, b), c)\n)\n WITH CLUSTERING ORDER BY (c DESC)\n \ + AND comment = ''\n AND gc_grace_seconds = 864000;\n" + ); + assert_eq!(dollar_body("return a;"), "$$return a;$$"); + assert_eq!(dollar_body("a $$ b"), "'a $$ b'"); + } + + fn target() -> EditTarget { + let col = |name: &str, ty: &str| { + Some(EditColumn { + name: name.into(), + type_name: ty.into(), + }) + }; + EditTarget { + schema: Some("My Ks".into()), + table: "items".into(), + columns: vec![ + col("id", "int"), + col("day", "date"), + col("tags", "set"), + None, + ], + key: vec![0, 1], + } + } + + #[test] + fn edits_become_cql() { + use crate::model::CellValue; + let changes = vec![ + RowChange::Update { + key: vec![Some("1".into()), Some("2024-05-06".into())], + values: vec![CellValue { + column: 2, + value: Some("{'a', 'b'}".into()), + }], + }, + RowChange::Delete { + key: vec![Some("2".into()), Some("2024-05-07".into())], + }, + RowChange::Insert { + values: vec![ + CellValue { + column: 0, + value: Some("3".into()), + }, + CellValue { + column: 1, + value: Some("2024-05-08".into()), + }, + CellValue { + column: 2, + value: None, + }, + ], + }, + ]; + let (statements, result) = plan_changes(&target(), &changes).unwrap(); + assert_eq!( + statements, + [ + "UPDATE \"My Ks\".items SET tags = {'a', 'b'} WHERE id = 1 AND day = '2024-05-06'", + "DELETE FROM \"My Ks\".items WHERE id = 2 AND day = '2024-05-07'", + "INSERT INTO \"My Ks\".items (id, day, tags) VALUES (3, '2024-05-08', null)", + ] + ); + assert_eq!((result.updated, result.deleted, result.inserted), (1, 1, 1)); + } + + #[test] + fn edits_refuse_key_changes_and_expressions() { + use crate::model::CellValue; + let key = vec![Some("1".into()), Some("2024-05-06".into())]; + let change = |column| RowChange::Update { + key: key.clone(), + values: vec![CellValue { + column, + value: Some("5".into()), + }], + }; + let err = plan_changes(&target(), &[change(0)]).unwrap_err(); + assert!(err.message.contains("primary key"), "{err}"); + let err = plan_changes(&target(), &[change(3)]).unwrap_err(); + assert!(err.message.contains("not a column"), "{err}"); + let short = RowChange::Delete { + key: vec![Some("1".into())], + }; + assert!(plan_changes(&target(), &[short]).is_err()); + let null_key = RowChange::Delete { + key: vec![Some("1".into()), None], + }; + assert!(plan_changes(&target(), &[null_key]).is_err()); + } +} diff --git a/src-tauri/src/drivers/cassandra_tests.rs b/src-tauri/src/drivers/cassandra_tests.rs new file mode 100644 index 0000000..5459869 --- /dev/null +++ b/src-tauri/src/drivers/cassandra_tests.rs @@ -0,0 +1,705 @@ +//! Tests of the Cassandra driver against a real server. They only run with +//! these environment variables (all but the host are optional): +//! +//! `LQ_CASSANDRA_HOST`, `LQ_CASSANDRA_PORT` (9042), `LQ_CASSANDRA_USER`, +//! `LQ_CASSANDRA_PASSWORD`, `LQ_CASSANDRA_DC` +//! +//! `cargo test --lib cassandra -- --ignored --test-threads=1` + +use std::time::Duration; + +use super::{RowSink, Session, StreamEvent}; +use crate::error::ErrorKind; +use crate::model::{ + ApplyRequest, CellValue, ConnectionProfile, Engine, ExecRequest, ExecResult, RowChange, + SslMode, ValueKind, +}; + +fn env(name: &str) -> Option { + std::env::var(name).ok().filter(|v| !v.is_empty()) +} + +async fn open() -> Option { + let host = env("LQ_CASSANDRA_HOST")?; + let profile = ConnectionProfile { + name: "test".into(), + engine: Engine::Cassandra, + host, + port: env("LQ_CASSANDRA_PORT") + .and_then(|p| p.parse().ok()) + .unwrap_or(9042), + user: env("LQ_CASSANDRA_USER").unwrap_or_default(), + datacenter: env("LQ_CASSANDRA_DC").unwrap_or_default(), + ssl_mode: SslMode::Disable, + ..Default::default() + }; + let password = env("LQ_CASSANDRA_PASSWORD"); + Some( + Session::open(&profile, password.as_deref()) + .await + .expect("connection"), + ) +} + +async fn run(s: &Session, sql: &str, max_rows: usize) -> ExecResult { + s.execute(&ExecRequest { + sql: sql.into(), + max_rows, + }) + .await + .unwrap_or_else(|e| panic!("{sql}: {e}")) +} + +async fn keyspace(s: &Session, name: &str) { + run(s, &format!("DROP KEYSPACE IF EXISTS {name}"), 0).await; + run( + s, + &format!( + "CREATE KEYSPACE {name} WITH replication = \ + {{'class': 'SimpleStrategy', 'replication_factor': 1}}" + ), + 0, + ) + .await; +} + +fn row(r: &ExecResult) -> Vec { + r.rows[0] + .iter() + .map(|v| v.clone().unwrap_or_else(|| "".into())) + .collect() +} + +fn value(column: usize, v: Option<&str>) -> CellValue { + CellValue { + column, + value: v.map(str::to_string), + } +} + +#[tokio::test] +#[ignore] +async fn cassandra_types_limits_and_errors() { + let Some(s) = open().await else { + return; + }; + assert!( + s.server_version().starts_with("Apache Cassandra "), + "{}", + s.server_version() + ); + assert!(s.tx_state().autocommit && !s.tx_state().in_transaction); + assert!(s.set_autocommit(false).await.is_err()); + assert!(s.end_transaction(true).await.is_err()); + + keyspace(&s, "lq_types").await; + run( + &s, + "CREATE TYPE lq_types.addr (street text, zip int, tags set)", + 0, + ) + .await; + run( + &s, + "CREATE TABLE lq_types.t ( + id int PRIMARY KEY, a ascii, b bigint, bl blob, bo boolean, d date, de decimal, + du double, dur duration, f float, i inet, s smallint, t text, ti time, + ts timestamp, tu timeuuid, ty tinyint, u uuid, v varint, + l list, st set, m map>>, + tp tuple, ad frozen, n int)", + 0, + ) + .await; + run( + &s, + "INSERT INTO lq_types.t (id, a, b, bl, bo, d, de, du, dur, f, i, s, t, ti, ts, tu, ty, u, v, + l, st, m, tp, ad) VALUES ( + 1, 'ascii', 9223372036854775807, 0x00ff10, true, '2024-05-06', + 12345678901234567890.123456789, 0.1, 1y2mo3d4h5m6s7ms8us9ns, 1.5, '::1', -32768, + 'it''s é', '07:08:09.123456789', '2024-05-06 07:08:09.123+0000', + 50554d6e-29bb-11e5-b345-feff819cdc9f, -128, 123e4567-e89b-12d3-a456-426614174000, + -123456789012345678901234567890, + [1, 2, 3], {'b', 'a''x'}, {'k': [1, 2]}, (7, 'x', false), + {street: 'Main', zip: 12345, tags: {'p', 'q'}})", + 0, + ) + .await; + let r = run(&s, "SELECT * FROM lq_types.t", 100).await; + let columns = r.columns.clone().unwrap(); + let by = |name: &str| columns.iter().position(|c| c.name == name).unwrap(); + let cells = row(&r); + let cell = |name: &str| cells[by(name)].as_str(); + assert_eq!(cell("a"), "ascii"); + assert_eq!(cell("b"), "9223372036854775807"); + assert_eq!(cell("bl"), "0x00ff10"); + assert_eq!(cell("bo"), "true"); + assert_eq!(cell("d"), "2024-05-06"); + assert_eq!(cell("de"), "12345678901234567890.123456789"); + assert_eq!(cell("du"), "0.1"); + assert_eq!(cell("dur"), "1y2mo3d4h5m6s7ms8us9ns"); + assert_eq!(cell("f"), "1.5"); + assert_eq!(cell("i"), "::1"); + assert_eq!(cell("s"), "-32768"); + assert_eq!(cell("t"), "it's é"); + assert_eq!(cell("ti"), "07:08:09.123456789"); + assert_eq!(cell("ts"), "2024-05-06 07:08:09.123+0000"); + assert_eq!(cell("tu"), "50554d6e-29bb-11e5-b345-feff819cdc9f"); + assert_eq!(cell("ty"), "-128"); + assert_eq!(cell("u"), "123e4567-e89b-12d3-a456-426614174000"); + assert_eq!(cell("v"), "-123456789012345678901234567890"); + assert_eq!(cell("l"), "[1, 2, 3]"); + assert_eq!(cell("st"), "{'a''x', 'b'}"); + assert_eq!(cell("m"), "{'k': [1, 2]}"); + assert_eq!(cell("tp"), "(7, 'x', false)"); + assert_eq!(cell("ad"), "{street: 'Main', zip: 12345, tags: {'p', 'q'}}"); + assert_eq!(cell("n"), ""); + let type_of = |name: &str| columns[by(name)].type_name.clone(); + assert_eq!(type_of("l"), "list"); + // Result metadata does not say which collections and UDTs are frozen. + assert_eq!(type_of("m"), "map>"); + assert_eq!(type_of("tp"), "tuple"); + assert_eq!(type_of("ad"), "addr"); + assert_eq!(type_of("v"), "varint"); + assert_eq!(columns[by("de")].kind, ValueKind::Number); + assert_eq!(columns[by("bl")].kind, ValueKind::Binary); + assert_eq!(columns[by("ts")].kind, ValueKind::Temporal); + assert_eq!(columns[by("st")].kind, ValueKind::Other); + + // Every composite cell is a literal the server reads back. + run( + &s, + &format!( + "INSERT INTO lq_types.t (id, l, st, m, tp, ad) VALUES (2, {}, {}, {}, {}, {})", + cell("l"), + cell("st"), + cell("m"), + cell("tp"), + cell("ad") + ), + 0, + ) + .await; + let again = run( + &s, + "SELECT l, st, m, tp, ad FROM lq_types.t WHERE id = 2", + 10, + ) + .await; + assert_eq!( + row(&again), + [cell("l"), cell("st"), cell("m"), cell("tp"), cell("ad")] + ); + + // Counters are not editable. + run( + &s, + "CREATE TABLE lq_types.c (id int PRIMARY KEY, n counter)", + 0, + ) + .await; + run(&s, "UPDATE lq_types.c SET n = n + 5 WHERE id = 1", 0).await; + let c = run(&s, "SELECT id, n FROM lq_types.c", 10).await; + assert_eq!(c.columns.as_ref().unwrap()[1].type_name, "counter"); + assert_eq!(row(&c), ["1", "5"]); + assert!(c.read_only_reason.as_deref().unwrap().contains("Counter")); + + // The row limit is applied by paging. + run( + &s, + "CREATE TABLE lq_types.many (id int PRIMARY KEY, v int)", + 0, + ) + .await; + for i in 0..120 { + run( + &s, + &format!("INSERT INTO lq_types.many (id, v) VALUES ({i}, {i})"), + 0, + ) + .await; + } + let r = run(&s, "SELECT id FROM lq_types.many", 10).await; + assert_eq!(r.rows.len(), 10); + assert!(r.truncated); + let r = run(&s, "SELECT id FROM lq_types.many", 120).await; + assert_eq!(r.rows.len(), 120); + assert!(!r.truncated); + let r = run(&s, "SELECT id FROM lq_types.many", 0).await; + assert_eq!(r.rows.len(), 120); + assert!(!r.truncated); + + // Statements without rows. + let r = run(&s, "INSERT INTO lq_types.many (id, v) VALUES (500, 1)", 10).await; + assert!(r.columns.is_none() && r.rows.is_empty()); + // A conditional write answers with [applied]. + let r = run( + &s, + "INSERT INTO lq_types.many (id, v) VALUES (500, 2) IF NOT EXISTS", + 10, + ) + .await; + assert_eq!(r.columns.as_ref().unwrap()[0].name, "[applied]"); + assert_eq!(r.rows[0][0].as_deref(), Some("false")); + // A batch is one statement. + run( + &s, + "BEGIN BATCH + INSERT INTO lq_types.many (id, v) VALUES (501, 1); + INSERT INTO lq_types.many (id, v) VALUES (502, 1); + APPLY BATCH", + 0, + ) + .await; + let r = run( + &s, + "SELECT count(*) FROM lq_types.many WHERE id IN (501, 502)", + 10, + ) + .await; + assert_eq!(row(&r), ["2"]); + + // Errors carry the server's code; a syntax error has a position. + let err = s + .execute(&ExecRequest { + sql: "SELECT * FROM lq_types.nope".into(), + max_rows: 10, + }) + .await + .unwrap_err(); + assert_eq!(err.kind, ErrorKind::Database); + assert_eq!(err.code.as_deref(), Some("Invalid"), "{err}"); + let err = s + .execute(&ExecRequest { + sql: "SELEC * FROM lq_types.t".into(), + max_rows: 10, + }) + .await + .unwrap_err(); + assert_eq!(err.code.as_deref(), Some("SyntaxError"), "{err}"); + assert!(err.position.is_some(), "{err}"); + + // USE sticks to the session. + run(&s, "USE lq_types", 0).await; + let r = run(&s, "SELECT count(*) FROM c", 10).await; + assert_eq!(row(&r), ["1"]); + + // Warnings of the server come back as notices (Cassandra 4.0 and later + // warn about aggregations without a partition key). + let r = run(&s, "SELECT count(*) FROM lq_types.many", 10).await; + assert_eq!(row(&r), ["123"]); + if !s.server_version().contains("Cassandra 3.") { + assert!( + r.notices + .iter() + .any(|n| n.starts_with("WARNING: ") && n.contains("partition key")), + "{:?}", + r.notices + ); + } + + // A filtering scan works. + let r = run( + &s, + "SELECT * FROM lq_types.many WHERE v = 5 ALLOW FILTERING", + 10, + ) + .await; + assert_eq!(r.rows.len(), 1); + + if s.server_version().contains("Cassandra 5") { + run( + &s, + "CREATE TABLE lq_types.vec (id int PRIMARY KEY, e vector)", + 0, + ) + .await; + run( + &s, + "INSERT INTO lq_types.vec (id, e) VALUES (1, [0.5, 1.5, -2])", + 0, + ) + .await; + let r = run(&s, "SELECT e FROM lq_types.vec", 10).await; + assert_eq!(r.columns.as_ref().unwrap()[0].type_name, "vector"); + assert_eq!(row(&r), ["[0.5, 1.5, -2]"]); + } + run(&s, "DROP KEYSPACE lq_types", 0).await; +} + +#[tokio::test] +#[ignore] +async fn cassandra_schema_ddl_and_stream() { + let Some(s) = open().await else { + return; + }; + keyspace(&s, "lq_schema").await; + run(&s, "CREATE TYPE lq_schema.addr (street text, zip int)", 0).await; + run( + &s, + "CREATE TABLE lq_schema.\"Items\" ( + tenant text, day date, seq int, \"Name\" text, tags set, home frozen, + total decimal static, + PRIMARY KEY ((tenant, day), seq) + ) WITH CLUSTERING ORDER BY (seq DESC) AND comment = 'it''s here' + AND gc_grace_seconds = 3600", + 0, + ) + .await; + run( + &s, + "CREATE INDEX lq_items_name ON lq_schema.\"Items\" (\"Name\")", + 0, + ) + .await; + // User-defined functions are off unless cassandra.yaml enables them. + let functions = s + .execute(&ExecRequest { + sql: "CREATE FUNCTION lq_schema.plus (a int, b int) CALLED ON NULL INPUT \ + RETURNS int LANGUAGE java AS 'return a + b;'" + .into(), + max_rows: 0, + }) + .await + .is_ok(); + + let schemas = s.list_schemas().await.unwrap(); + let user = schemas.iter().position(|n| n == "lq_schema").unwrap(); + let system = schemas.iter().position(|n| n == "system").unwrap(); + assert!(user < system, "user keyspaces come first: {schemas:?}"); + + let objects = s.list_objects("lq_schema").await.unwrap(); + let has = |kind: &str, name: &str| objects.iter().any(|o| o.kind == kind && o.name == name); + assert!(has("table", "Items"), "{objects:?}"); + assert!(has("type", "addr")); + assert!(has("index", "lq_items_name")); + assert!( + !functions || has("function", "plus(int, int)"), + "{objects:?}" + ); + + let columns = s.list_columns("lq_schema", "Items").await.unwrap(); + let shape: Vec<(String, String, bool)> = columns + .iter() + .map(|c| (c.name.clone(), c.key_role.clone().unwrap(), c.primary_key)) + .collect(); + assert_eq!( + shape, + [ + ("tenant".into(), "partition".into(), true), + ("day".into(), "partition".into(), true), + ("seq".into(), "clustering".into(), true), + ("total".into(), "static".into(), false), + ("Name".into(), "regular".into(), false), + ("home".into(), "regular".into(), false), + ("tags".into(), "regular".into(), false), + ] + ); + assert_eq!(columns[5].type_name, "frozen"); + assert_eq!(columns[6].type_name, "set"); + + // The table's DDL recreates it, indexes included. + let ddl = s.object_ddl("lq_schema", "Items", "table").await.unwrap(); + assert!(ddl.contains("CREATE TABLE lq_schema.\"Items\" ("), "{ddl}"); + assert!(ddl.contains("total decimal static"), "{ddl}"); + assert!(ddl.contains("PRIMARY KEY ((tenant, day), seq)"), "{ddl}"); + assert!(ddl.contains("CLUSTERING ORDER BY (seq DESC)"), "{ddl}"); + assert!(ddl.contains("comment = 'it''s here'"), "{ddl}"); + assert!(ddl.contains("CREATE INDEX lq_items_name ON"), "{ddl}"); + run(&s, "DROP TABLE lq_schema.\"Items\"", 0).await; + for statement in super::ddl_fixtures::statements(&ddl, "cassandra") { + run(&s, &statement, 0).await; + } + let again = s.list_columns("lq_schema", "Items").await.unwrap(); + assert_eq!( + serde_json::to_value(&again).unwrap(), + serde_json::to_value(&columns).unwrap() + ); + + let ddl = s.object_ddl("lq_schema", "addr", "type").await.unwrap(); + assert!(ddl.contains("CREATE TYPE lq_schema.addr ("), "{ddl}"); + let ddl = s + .object_ddl("lq_schema", "lq_items_name", "index") + .await + .unwrap(); + assert!( + ddl.starts_with("CREATE INDEX lq_items_name ON lq_schema.\"Items\""), + "{ddl}" + ); + if functions { + let ddl = s + .object_ddl("lq_schema", "plus(int, int)", "function") + .await + .unwrap(); + assert!( + ddl.contains("CREATE OR REPLACE FUNCTION lq_schema.plus (a int, b int)"), + "{ddl}" + ); + assert!(ddl.contains("$$return a + b;$$"), "{ddl}"); + run(&s, "DROP FUNCTION lq_schema.plus(int, int)", 0).await; + run(&s, &ddl, 0).await; + } + let ddl = s + .object_ddl("lq_schema", "lq_schema", "keyspace") + .await + .unwrap(); + assert!( + ddl.starts_with("CREATE KEYSPACE lq_schema WITH replication = {"), + "{ddl}" + ); + assert!(s.object_ddl("lq_schema", "x", "sequence").await.is_err()); + + // Materialized views exist only where the server enables them. + run( + &s, + "CREATE TABLE lq_schema.base (id int, day int, v text, PRIMARY KEY (id, day))", + 0, + ) + .await; + let view = s + .execute(&ExecRequest { + sql: "CREATE MATERIALIZED VIEW lq_schema.by_day AS SELECT * FROM lq_schema.base \ + WHERE day IS NOT NULL AND id IS NOT NULL PRIMARY KEY (day, id)" + .into(), + max_rows: 0, + }) + .await; + if view.is_ok() { + let objects = s.list_objects("lq_schema").await.unwrap(); + assert!(objects + .iter() + .any(|o| o.kind == "matview" && o.name == "by_day")); + let ddl = s + .object_ddl("lq_schema", "by_day", "matview") + .await + .unwrap(); + assert!( + ddl.contains("CREATE MATERIALIZED VIEW lq_schema.by_day AS"), + "{ddl}" + ); + assert!(ddl.contains("PRIMARY KEY (day, id)"), "{ddl}"); + run( + &s, + "INSERT INTO lq_schema.base (id, day, v) VALUES (1, 2, 'x')", + 0, + ) + .await; + let r = run(&s, "SELECT * FROM lq_schema.by_day", 10).await; + assert!(r.editable.is_none() && r.read_only_reason.is_some()); + } + + // Streaming reads every row, a page at a time. + for i in 0..1200 { + run( + &s, + &format!("INSERT INTO lq_schema.base (id, day, v) VALUES ({i}, 1, 'v{i}')"), + 0, + ) + .await; + } + let (tx, mut rx) = tokio::sync::mpsc::channel(4); + let sink: RowSink = tx; + let reader = tokio::spawn(async move { + let mut columns = 0; + let mut rows = 0; + while let Some(event) = rx.recv().await { + match event { + StreamEvent::Columns(c) => columns = c.len(), + StreamEvent::Rows(r) => rows += r.len(), + } + } + (columns, rows) + }); + let total = s + .stream("SELECT id, day, v FROM lq_schema.base", sink) + .await + .unwrap(); + assert_eq!(total, 1200); + assert_eq!(reader.await.unwrap(), (3, 1200)); + // A reader that goes away stops the stream. + let (tx, rx) = tokio::sync::mpsc::channel(1); + drop(rx); + let err = s + .stream("SELECT id FROM lq_schema.base", tx) + .await + .unwrap_err(); + assert_eq!(err.kind, ErrorKind::Cancelled); + + run(&s, "DROP KEYSPACE lq_schema", 0).await; +} + +#[tokio::test] +#[ignore] +async fn cassandra_result_editing() { + let Some(s) = open().await else { + return; + }; + keyspace(&s, "lq_edit").await; + run( + &s, + "CREATE TABLE lq_edit.items (tenant text, day date, seq int, name text, tags set, + PRIMARY KEY (tenant, day, seq))", + 0, + ) + .await; + run( + &s, + "INSERT INTO lq_edit.items (tenant, day, seq, name, tags) + VALUES ('a', '2024-05-06', 1, 'one', {'x'})", + 0, + ) + .await; + run( + &s, + "INSERT INTO lq_edit.items (tenant, day, seq, name, tags) + VALUES ('a', '2024-05-06', 2, 'two', null)", + 0, + ) + .await; + + let r = run( + &s, + "SELECT tenant, day, seq, name, tags FROM lq_edit.items", + 100, + ) + .await; + assert!(r.read_only_reason.is_none(), "{:?}", r.read_only_reason); + let target = r.editable.clone().expect("editable"); + assert_eq!(target.schema.as_deref(), Some("lq_edit")); + assert_eq!(target.table, "items"); + assert_eq!(target.key, [0, 1, 2]); + assert_eq!(target.columns[4].as_ref().unwrap().type_name, "set"); + + // Without the whole key, with an alias, or from system tables: read-only. + let r = run(&s, "SELECT tenant, name FROM lq_edit.items", 100).await; + assert!(r.editable.is_none()); + assert!(r.read_only_reason.unwrap().contains("primary key")); + let r = run( + &s, + "SELECT tenant, day, seq, name AS n FROM lq_edit.items", + 100, + ) + .await; + assert!(r.editable.is_none()); + let r = run(&s, "SELECT keyspace_name FROM system_schema.keyspaces", 100).await; + assert!(r.editable.is_none() && r.read_only_reason.is_some()); + + let key = |seq: &str| { + vec![ + Some("a".to_string()), + Some("2024-05-06".to_string()), + Some(seq.to_string()), + ] + }; + let changes = vec![ + RowChange::Update { + key: key("1"), + values: vec![value(3, Some("it's one")), value(4, Some("{'x', 'y'}"))], + }, + RowChange::Delete { key: key("2") }, + RowChange::Insert { + values: vec![ + value(0, Some("b")), + value(1, Some("2024-05-07")), + value(2, Some("3")), + value(3, Some("three")), + ], + }, + ]; + let saved = s + .apply_changes(&ApplyRequest { + target: target.clone(), + changes, + }) + .await + .unwrap(); + assert_eq!((saved.updated, saved.deleted, saved.inserted), (1, 1, 1)); + assert!(!saved.in_transaction); + let r = run(&s, "SELECT seq, name, tags FROM lq_edit.items", 100).await; + let mut rows: Vec> = r + .rows + .iter() + .map(|r| { + r.iter() + .map(|v| v.clone().unwrap_or_else(|| "".into())) + .collect() + }) + .collect(); + rows.sort(); + assert_eq!( + rows, + [["1", "it's one", "{'x', 'y'}"], ["3", "three", ""]] + ); + + // A batch the server refuses changes nothing. + let bad = s + .apply_changes(&ApplyRequest { + target, + changes: vec![ + RowChange::Delete { key: key("1") }, + RowChange::Delete { + key: vec![ + Some("a".into()), + Some("not a date".into()), + Some("1".into()), + ], + }, + ], + }) + .await; + assert!(bad.is_err()); + let r = run(&s, "SELECT count(*) FROM lq_edit.items", 10).await; + assert_eq!(row(&r), ["2"]); + run(&s, "DROP KEYSPACE lq_edit", 0).await; +} + +#[tokio::test] +#[ignore] +async fn cassandra_ddl_fixtures() { + let Some(s) = open().await else { + return; + }; + keyspace(&s, "lq_cql").await; + super::ddl_fixtures::check(&s, "cassandra", "lq_cql").await; + run(&s, "DROP KEYSPACE lq_cql", 0).await; +} + +#[tokio::test] +#[ignore] +async fn cassandra_cancel_stops_waiting() { + let Some(s) = open().await else { + return; + }; + let s = std::sync::Arc::new(s); + // Nothing runs: cancel is harmless. + s.cancel().await.unwrap(); + keyspace(&s, "lq_cancel").await; + run( + &s, + "CREATE TABLE lq_cancel.t (id int PRIMARY KEY, v int)", + 0, + ) + .await; + let running = { + let s = s.clone(); + tokio::spawn(async move { + // Statements in a row, so one is in flight when the cancel comes. + for i in 0..100_000 { + let sql = format!("INSERT INTO lq_cancel.t (id, v) VALUES ({i}, 1)"); + if let Err(e) = s.execute(&ExecRequest { sql, max_rows: 0 }).await { + return Some(e); + } + } + None + }) + }; + // Cancel only acts on a request in flight: keep asking until one stops. + while !running.is_finished() { + tokio::time::sleep(Duration::from_millis(50)).await; + s.cancel().await.unwrap(); + } + let err = running.await.unwrap().expect("cancelled"); + assert_eq!(err.kind, ErrorKind::Cancelled); + // The session still works. + run(&s, "SELECT count(*) FROM lq_cancel.t", 10).await; + run(&s, "DROP KEYSPACE lq_cancel", 0).await; +} diff --git a/src-tauri/src/drivers/mod.rs b/src-tauri/src/drivers/mod.rs index 1bf75e3..116d61c 100644 --- a/src-tauri/src/drivers/mod.rs +++ b/src-tauri/src/drivers/mod.rs @@ -1,3 +1,6 @@ +pub mod cassandra; +#[cfg(test)] +mod cassandra_tests; #[cfg(test)] pub(crate) mod ddl_fixtures; #[cfg(test)] @@ -92,6 +95,7 @@ pub enum Session { SqlServer(sqlserver::MsSession), Sqlite(sqlite::LiteSession), Redis(redis_db::RedisSession), + Cassandra(cassandra::CassSession), } macro_rules! dispatch { @@ -103,6 +107,7 @@ macro_rules! dispatch { Session::SqlServer($s) => $body, Session::Sqlite($s) => $body, Session::Redis($s) => $body, + Session::Cassandra($s) => $body, } }; } @@ -118,6 +123,7 @@ impl Session { Engine::Sqlserver => Session::SqlServer(sqlserver::connect(profile, password).await?), Engine::Sqlite => Session::Sqlite(sqlite::connect(profile).await?), Engine::Redis => Session::Redis(redis_db::connect(profile, password).await?), + Engine::Cassandra => Session::Cassandra(cassandra::connect(profile, password).await?), }) } @@ -129,6 +135,7 @@ impl Session { Session::SqlServer(_) => Engine::Sqlserver, Session::Sqlite(_) => Engine::Sqlite, Session::Redis(_) => Engine::Redis, + Session::Cassandra(_) => Engine::Cassandra, } } @@ -195,6 +202,7 @@ impl Session { Session::Redis(_) => Err(AppError::invalid( "Redis replies cannot be edited in the grid.", )), + Session::Cassandra(s) => s.apply_changes(req).await, } } @@ -217,6 +225,7 @@ impl Session { Session::SqlServer(s) => s.stream(sql, sink).await, Session::Sqlite(s) => s.stream(sql, sink).await, Session::Redis(_) => Err(AppError::invalid("Redis keys cannot be exported yet.")), + Session::Cassandra(s) => s.stream(sql, sink).await, } } @@ -232,6 +241,7 @@ impl Session { Session::Redis(_) => { return Err(AppError::invalid("Redis keys have no DDL.")); } + Session::Cassandra(s) => s.object_ddl(schema, name, kind).await?, }; if ddl.trim().is_empty() { return Err(AppError::not_found(format!( diff --git a/src-tauri/src/drivers/mysql.rs b/src-tauri/src/drivers/mysql.rs index 70fc502..6a24705 100644 --- a/src-tauri/src/drivers/mysql.rs +++ b/src-tauri/src/drivers/mysql.rs @@ -572,6 +572,7 @@ impl MySession { default_value: (r[4] == "0") .then(|| default_expr(&r[5], &r[6], mariadb)) .flatten(), + key_role: None, }) .collect()) } diff --git a/src-tauri/src/drivers/oracle.rs b/src-tauri/src/drivers/oracle.rs index 973e7b3..4e41e9e 100644 --- a/src-tauri/src/drivers/oracle.rs +++ b/src-tauri/src/drivers/oracle.rs @@ -408,6 +408,7 @@ impl OraSession { nullable: r[2] == "Y", primary_key: r[3] == "Y", default_value: Some(r[4].clone()).filter(|d| !d.is_empty()), + key_role: None, }) .collect()) } diff --git a/src-tauri/src/drivers/postgres.rs b/src-tauri/src/drivers/postgres.rs index 601ff79..bcc3b35 100644 --- a/src-tauri/src/drivers/postgres.rs +++ b/src-tauri/src/drivers/postgres.rs @@ -566,6 +566,7 @@ impl PgSession { nullable: r.get(2), primary_key: r.get(3), default_value: r.get(4), + key_role: None, }) .collect()) } @@ -1462,7 +1463,7 @@ pub fn map_error(e: tokio_postgres::Error) -> AppError { AppError::connection(message) } -fn tls_config(mode: SslMode) -> AppResult { +pub(super) fn tls_config(mode: SslMode) -> AppResult { let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); let builder = rustls::ClientConfig::builder_with_provider(provider.clone()) .with_safe_default_protocol_versions() diff --git a/src-tauri/src/drivers/sqlite.rs b/src-tauri/src/drivers/sqlite.rs index c5fdff1..2899062 100644 --- a/src-tauri/src/drivers/sqlite.rs +++ b/src-tauri/src/drivers/sqlite.rs @@ -241,6 +241,7 @@ impl LiteSession { nullable: r[2] == "0", primary_key: r[3] != "0", default_value: Some(r[4].clone()).filter(|d| !d.is_empty()), + key_role: None, }) .collect()) } diff --git a/src-tauri/src/drivers/sqlserver.rs b/src-tauri/src/drivers/sqlserver.rs index 9e21cbf..1018ca5 100644 --- a/src-tauri/src/drivers/sqlserver.rs +++ b/src-tauri/src/drivers/sqlserver.rs @@ -786,6 +786,7 @@ impl MsSession { nullable: text(2) == "true", primary_key: text(3) == "true", default_value: r.get(4).cloned().flatten(), + key_role: None, } }) .collect()) diff --git a/src-tauri/src/import/datagrip.rs b/src-tauri/src/import/datagrip.rs index f4199cc..fd7b064 100644 --- a/src-tauri/src/import/datagrip.rs +++ b/src-tauri/src/import/datagrip.rs @@ -646,7 +646,7 @@ pub(super) fn apply_jdbc(url: &str, p: &mut ConnectionProfile) -> Result<(), Url "jtds" if strip_prefix_ci(&rest, "jtds:sybase:").is_some() => { return Err(UrlError::Unsupported("Sybase".into())); } - "postgresql" | "mariadb" | "sqlserver" | "jtds" | "sqlite" => {} + "postgresql" | "mariadb" | "sqlserver" | "jtds" | "sqlite" | "cassandra" => {} other => return Err(UrlError::Unsupported(product_name(other))), } jdbc::apply_url(&format!("jdbc:{rest}"), p).map_err(UrlError::Invalid) @@ -732,7 +732,6 @@ fn product_name(scheme: &str) -> String { "redshift" => "Amazon Redshift", "bigquery" => "BigQuery", "mongodb" | "mongo" => "MongoDB", - "cassandra" => "Cassandra", "hive2" => "Apache Hive", "sap" => "SAP HANA", "exa" => "Exasol", @@ -1256,6 +1255,43 @@ mod tests { assert!(parsed.notes[0].contains("keychain")); } + #[test] + fn cassandra_data_source() { + let dir = TempDir::new("cassandra"); + let path = dir.write( + "app/.idea/dataSources.xml", + r#" + + + + cassandra + com.ing.data.cassandra.jdbc.CassandraDriver + jdbc:cassandra://n1.local:9043,n2.local/events?localdatacenter=dc1 + + + jdbc:cassandra://n1.local + + +"#, + ); + let parsed = parse(&path).unwrap(); + let c = find(&parsed, "cass-1"); + assert_eq!(c.profile.engine, Engine::Cassandra); + assert_eq!( + (c.profile.host.as_str(), c.profile.port), + ("n1.local,n2.local", 9043) + ); + assert_eq!(c.profile.database, "events"); + assert_eq!(c.profile.datacenter, "dc1"); + assert_eq!(c.folder.as_deref(), Some("dev")); + let bare = find(&parsed, "cass-2"); + assert_eq!( + (bare.profile.host.as_str(), bare.profile.port), + ("n1.local", 9042) + ); + assert!(parsed.skipped.is_empty()); + } + #[test] fn global_file_without_local_settings() { let dir = TempDir::new("global"); diff --git a/src-tauri/src/import/dbeaver.rs b/src-tauri/src/import/dbeaver.rs index 52381aa..73a1907 100644 --- a/src-tauri/src/import/dbeaver.rs +++ b/src-tauri/src/import/dbeaver.rs @@ -374,7 +374,7 @@ fn connection(id: &str, conn: &Obj, secret: &Obj, types: &Obj, parsed: &mut Pars .push("Uses Oracle OS authentication, which LowQuery does not support.".into()); } } - Engine::Sqlite | Engine::Redis => {} + Engine::Sqlite | Engine::Redis | Engine::Cassandra => {} } auth_model_warnings(&mut p, &text(cfg.get("auth-model")), &mut warnings); @@ -420,6 +420,11 @@ fn engine_for(provider: &str, driver: &str) -> Result, String> { if provider == "redis" || d.contains("redis") { return Ok(Some(Engine::Redis)); } + // DBeaver ships Cassandra through commercial and third-party drivers; + // what they have in common is the id and the `jdbc:cassandra:` URL. + if provider.contains("cassandra") || d.contains("cassandra") { + return Ok(Some(Engine::Cassandra)); + } match provider { "postgresql" | "greenplum" => { for (id, what) in [ @@ -471,7 +476,6 @@ fn product(id: &str) -> String { ("clickhouse", "ClickHouse"), ("snowflake", "Snowflake"), ("mongo", "MongoDB"), - ("cassandra", "Cassandra"), ("hana", "SAP HANA"), ("vertica", "Vertica"), ("exasol", "Exasol"), @@ -514,6 +518,7 @@ fn default_port(engine: Engine) -> u16 { Engine::Sqlserver => 1433, Engine::Sqlite => 0, Engine::Redis => 6379, + Engine::Cassandra => 9042, } } @@ -630,6 +635,17 @@ fn manual_target(engine: Engine, cfg: &Obj, provider_props: &Obj, p: &mut Connec p.port = port_or(&port, p.port); p.database = database; } + // The database field holds the keyspace; contact points may be a + // comma-separated list. + Engine::Cassandra => { + p.host = host + .split([',', ';']) + .map(str::trim) + .collect::>() + .join(","); + p.port = port_or(&port, p.port); + p.database = database; + } } } @@ -1252,6 +1268,21 @@ mod tests { "name": "Cache", "configuration": { "host": "cache", "database": "2", "configurationType": "MANUAL" } }, + "cassandra-2b3c": { + "provider": "cassandra", + "driver": "cassandra_jdbc", + "name": "Events", + "configuration": { "host": "n1, n2", "database": "events", "configurationType": "MANUAL" } + }, + "cassandra-url-4d5e": { + "provider": "generic", + "driver": "18f0c2a1-cassandra-custom", + "name": "Cassandra by URL", + "configuration": { + "url": "jdbc:cassandra://c1:9043,c2/shop?localdc=dc1", + "configurationType": "URL" + } + }, "db2_luw-5a6b": { "provider": "db2", "driver": "db2_luw", @@ -1469,6 +1500,30 @@ mod tests { (6379, "2") ); + let cass = find(&p, "cassandra-2b3c"); + assert_eq!(cass.profile.engine, Engine::Cassandra); + assert_eq!( + ( + cass.profile.host.as_str(), + cass.profile.port, + cass.profile.database.as_str() + ), + ("n1,n2", 9042, "events") + ); + let cass_url = find(&p, "cassandra-url-4d5e"); + assert_eq!(cass_url.profile.engine, Engine::Cassandra); + assert_eq!( + (cass_url.profile.host.as_str(), cass_url.profile.port), + ("c1,c2", 9043) + ); + assert_eq!( + ( + cass_url.profile.database.as_str(), + cass_url.profile.datacenter.as_str() + ), + ("shop", "dc1") + ); + let custom_pg = find(&p, "custom-pg-1a1a"); assert_eq!(custom_pg.profile.engine, Engine::Postgres); assert_eq!( @@ -1483,7 +1538,7 @@ mod tests { ); assert_eq!(reason(&p, "Local H2"), "H2 is not supported by LowQuery."); assert_eq!(reason(&p, "No host"), "No host is set."); - assert_eq!(p.candidates.len(), 14); + assert_eq!(p.candidates.len(), 16); assert_eq!(p.passwords.len(), 4); // Tunnel credentials are not connection passwords. assert!(!p.passwords.values().any(|v| v == "fake-pass-5")); diff --git a/src-tauri/src/import/jdbc.rs b/src-tauri/src/import/jdbc.rs index 2a9b2c5..ed495ce 100644 --- a/src-tauri/src/import/jdbc.rs +++ b/src-tauri/src/import/jdbc.rs @@ -36,10 +36,64 @@ pub fn apply_url(url: &str, profile: &mut ConnectionProfile) -> Result<(), Strin profile.port = 0; return Ok(()); } + if let Some(r) = strip_prefix_ci(rest, "cassandra:") { + return cassandra(r, profile); + } let scheme = rest.split(':').next().unwrap_or_default(); Err(format!("{scheme} URLs are not supported")) } +/// `//host1[:port][,host2...][/keyspace][?params]` (Cassandra JDBC wrappers +/// also join the hosts with `--`). The contact points stay in `host`, +/// comma-separated; the port is the first one given. Cassandra's native +/// protocol is plain text unless the URL asks for TLS. +fn cassandra(rest: &str, profile: &mut ConnectionProfile) -> Result<(), String> { + profile.engine = Engine::Cassandra; + let r = rest.strip_prefix("//").ok_or("missing //host")?; + let (main, query) = r.split_once('?').unwrap_or((r, "")); + let (hosts, keyspace) = main.split_once('/').unwrap_or((main, "")); + let mut port = None; + let mut points = Vec::new(); + for h in hosts + .split([',', ';']) + .flat_map(|h| h.split("--")) + .map(str::trim) + .filter(|h| !h.is_empty()) + { + let (host, p) = split_host_port(h, 0); + if port.is_none() && p != 0 { + port = Some(p); + } + points.push(host); + } + profile.host = if points.is_empty() { + "localhost".into() + } else { + points.join(",") + }; + profile.port = port.unwrap_or(9042); + profile.database = percent_decode(keyspace); + profile.ssl_mode = SslMode::Disable; + for pair in query.split('&').filter(|p| !p.is_empty()) { + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + cassandra_params(k, &percent_decode(v), profile); + } + Ok(()) +} + +fn cassandra_params(k: &str, v: &str, p: &mut ConnectionProfile) { + match k.to_ascii_lowercase().as_str() { + "localdc" | "localdatacenter" | "datacenter" if !v.is_empty() => { + p.datacenter = v.to_string(); + } + "ssl" | "usessl" | "enablessl" | "tls" if v.eq_ignore_ascii_case("true") => { + p.ssl_mode = SslMode::Require; + } + "user" | "username" if p.user.is_empty() => p.user = v.to_string(), + _ => {} + } +} + fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { let head = s.get(..prefix.len())?; head.eq_ignore_ascii_case(prefix) @@ -397,6 +451,32 @@ mod tests { assert_eq!(p.user, "scott"); } + #[test] + fn cassandra_forms() { + let p = parse("jdbc:cassandra://10.0.0.1:9043,10.0.0.2/shop?localdc=dc1&user=ana"); + assert_eq!(p.engine, Engine::Cassandra); + assert_eq!((p.host.as_str(), p.port), ("10.0.0.1,10.0.0.2", 9043)); + assert_eq!(p.database, "shop"); + assert_eq!((p.datacenter.as_str(), p.user.as_str()), ("dc1", "ana")); + assert_eq!(p.ssl_mode, SslMode::Disable); + let p = parse("jdbc:cassandra://a--b--c/?ssl=true&localdatacenter=eu"); + assert_eq!((p.host.as_str(), p.port), ("a,b,c", 9042)); + assert_eq!(p.database, ""); + assert_eq!( + (p.datacenter.as_str(), p.ssl_mode), + ("eu", SslMode::Require) + ); + let p = parse("jdbc:cassandra://[::1]:9142/ks%201"); + assert_eq!( + (p.host.as_str(), p.port, p.database.as_str()), + ("::1", 9142, "ks 1") + ); + let p = parse("jdbc:cassandra://"); + assert_eq!((p.host.as_str(), p.port), ("localhost", 9042)); + let mut p = ConnectionProfile::default(); + assert!(apply_url("jdbc:cassandra:host/ks", &mut p).is_err()); + } + #[test] fn sqlite_and_unsupported() { let p = parse("jdbc:sqlite:C:\\data\\app.db"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index deb4068..d502abc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -51,6 +51,7 @@ fn is_auth_failure(e: &AppError) -> bool { | "1698 (28000)" | "NOAUTH" | "WRONGPASS" + | "AuthenticationError" ) ) || e .code diff --git a/src-tauri/src/mcp/catalog.rs b/src-tauri/src/mcp/catalog.rs index 2e60e70..5b2a541 100644 --- a/src-tauri/src/mcp/catalog.rs +++ b/src-tauri/src/mcp/catalog.rs @@ -101,6 +101,7 @@ pub async fn search(session: &Session, pattern: &str, limit: usize) -> AppResult let current = session.current_schema(); let mut found = match session.engine() { Engine::Redis => return redis_search(session, &pattern, limit).await, + Engine::Cassandra => cassandra_search(session, &pattern).await?, Engine::Sqlite => sqlite_search(session, &pattern, limit).await?, Engine::Oracle => { let sql = |modern| oracle_search_sql(&pattern, current.as_deref(), modern); @@ -142,7 +143,8 @@ pub async fn foreign_keys( table: &str, ) -> AppResult<(Vec, Vec<(Option, String, ForeignKey)>)> { let engine = session.engine(); - if engine == Engine::Redis { + // Neither Redis nor CQL has foreign keys. + if matches!(engine, Engine::Redis | Engine::Cassandra) { return Ok((Vec::new(), Vec::new())); } if table.is_empty() { @@ -170,6 +172,11 @@ pub async fn explain(session: &Session, sql: &str) -> AppResult { "Redis has no execution plans: each command runs as it is.", )); } + if engine == Engine::Cassandra { + return Err(AppError::invalid( + "CQL has no EXPLAIN: Cassandra does not report execution plans. Filter on the partition key (and clustering columns in order) so the query reads one partition.", + )); + } check_text(sql)?; let sql = if engine == Engine::Oracle { oracle::prepare_sql(sql) @@ -203,7 +210,7 @@ pub async fn explain(session: &Session, sql: &str) -> AppResult { .collect(); Ok(sqlite_plan_tree(&rows)) } - Engine::Redis => unreachable!(), + Engine::Redis | Engine::Cassandra => unreachable!(), } } @@ -226,7 +233,9 @@ fn dialect(engine: Engine) -> Dialect { Engine::Oracle => Dialect::Oracle, Engine::Mysql | Engine::Mariadb => Dialect::Mysql, Engine::Sqlserver => Dialect::SqlServer, - Engine::Sqlite | Engine::Redis => Dialect::Sqlite, + // CQL strings are quoted like SQLite's: `'..'` with `''` inside, no + // backslash escapes. Redis and Cassandra never reach the SQL builders. + Engine::Sqlite | Engine::Redis | Engine::Cassandra => Dialect::Sqlite, } } @@ -549,6 +558,121 @@ async fn sqlite_search( Ok(tables) } +// --------------------------------------------------------------------------- +// Cassandra + +/// Keyspaces of the server itself, left out of searches. +const CASSANDRA_SYSTEM: &[&str] = &[ + "system", + "system_schema", + "system_auth", + "system_distributed", + "system_traces", + "system_views", + "system_virtual_schema", +]; +/// Rows read from one `system_schema` table for one keyspace. +const CASSANDRA_ROWS: usize = 100_000; + +/// What `system_schema` holds about one keyspace. +#[derive(Debug, Default)] +struct CassandraKeyspace { + tables: Vec, + views: Vec, + types: Vec, + /// (table or view, column, CQL type) + columns: Vec<(String, String, String)>, +} + +/// The matches of `pattern` in one keyspace: tables, materialized views and +/// types by name, columns by name. `system_schema` tables only filter on +/// their partition key, so the contains test happens here. +fn cassandra_matches(keyspace: &str, k: &CassandraKeyspace, pattern: &str) -> Vec { + let p = pattern.to_lowercase(); + let hit = |name: &str| name.to_lowercase().contains(&p); + let table = |name: &String, kind: &str| ObjectMatch { + schema: Some(keyspace.to_string()), + table: name.clone(), + kind: kind.into(), + column: None, + column_type: None, + }; + let mut out: Vec = Vec::new(); + out.extend( + k.tables + .iter() + .filter(|n| hit(n)) + .map(|n| table(n, "table")), + ); + out.extend( + k.views + .iter() + .filter(|n| hit(n)) + .map(|n| table(n, "materialized view")), + ); + out.extend(k.types.iter().filter(|n| hit(n)).map(|n| table(n, "type"))); + for (relation, column, ty) in &k.columns { + if hit(column) { + let view = k.views.contains(relation); + out.push(ObjectMatch { + schema: Some(keyspace.to_string()), + table: relation.clone(), + kind: if view { "materialized view" } else { "table" }.into(), + column: Some(column.clone()), + column_type: Some(ty.clone()), + }); + } + } + out +} + +async fn cassandra_search(session: &Session, pattern: &str) -> AppResult> { + let mut found = Vec::new(); + for keyspace in session.list_schemas().await? { + if CASSANDRA_SYSTEM.contains(&keyspace.as_str()) { + continue; + } + let ks = literal(Dialect::Sqlite, &keyspace)?; + let names = |sql: String| async move { + let r = query(session, &sql, CASSANDRA_ROWS).await?; + Ok::<_, AppError>(r.rows.iter().map(|row| cell(row, 0)).collect::>()) + }; + let tables = names(format!( + "SELECT table_name FROM system_schema.tables WHERE keyspace_name = {ks}" + )) + .await?; + let views = names(format!( + "SELECT view_name FROM system_schema.views WHERE keyspace_name = {ks}" + )) + .await?; + let types = names(format!( + "SELECT type_name FROM system_schema.types WHERE keyspace_name = {ks}" + )) + .await?; + let columns = query( + session, + &format!( + "SELECT table_name, column_name, type FROM system_schema.columns \ + WHERE keyspace_name = {ks}" + ), + CASSANDRA_ROWS, + ) + .await? + .rows + .iter() + .map(|row| (cell(row, 0), cell(row, 1), cell(row, 2))) + .collect(); + let k = CassandraKeyspace { + tables, + views, + types, + columns, + }; + found.extend(cassandra_matches(&keyspace, &k, pattern)); + } + Ok(found) +} + fn tables_of(r: &ExecResult) -> Vec { r.rows .iter() @@ -976,7 +1100,7 @@ fn check_explainable(engine: Engine, sql: &str) -> AppResult<()> { Engine::Sqlite => &[ "SELECT", "WITH", "INSERT", "UPDATE", "DELETE", "REPLACE", "VALUES", ], - Engine::Redis => &[], + Engine::Redis | Engine::Cassandra => &[], }; let rest = after_comments(sql, mysql); if rest.is_empty() { @@ -1278,6 +1402,44 @@ mod tests { use super::*; use crate::model::{ConnectionProfile, ValueKind}; + #[test] + fn cassandra_search_filters_and_ranks() { + let k = CassandraKeyspace { + tables: vec!["users".into(), "events".into(), "UserLog".into()], + views: vec!["users_by_email".into()], + types: vec!["user_address".into(), "money".into()], + columns: vec![ + ("users".into(), "id".into(), "uuid".into()), + ("users".into(), "email".into(), "text".into()), + ("users_by_email".into(), "email".into(), "text".into()), + ("events".into(), "user_id".into(), "uuid".into()), + ], + }; + let mut found = cassandra_matches("shop", &k, "USER"); + rank(&mut found, "user", Some("other")); + let names: Vec<_> = found + .iter() + .map(|m| (m.table.as_str(), m.kind.as_str(), m.column.as_deref())) + .collect(); + assert_eq!( + names, + [ + ("users", "table", None), + ("UserLog", "table", None), + ("users_by_email", "materialized view", None), + ("user_address", "type", None), + ("events", "table", Some("user_id")), + ] + ); + assert_eq!(found[4].column_type.as_deref(), Some("uuid")); + let found = cassandra_matches("shop", &k, "email"); + assert_eq!(found.len(), 3, "{found:?}"); + assert!(found + .iter() + .any(|m| m.table == "users_by_email" && m.kind == "materialized view")); + assert!(cassandra_matches("shop", &k, "zzz").is_empty()); + } + #[test] fn literals_per_dialect() { assert_eq!(literal(Dialect::Postgres, "o'k").unwrap(), "'o''k'"); diff --git a/src-tauri/src/mcp/e2e_tests.rs b/src-tauri/src/mcp/e2e_tests.rs index 82137ed..0e073dc 100644 --- a/src-tauri/src/mcp/e2e_tests.rs +++ b/src-tauri/src/mcp/e2e_tests.rs @@ -8,12 +8,15 @@ //! `LQ_MYSQL_PASSWORD`, `LQ_MYSQL_DB`, `LQ_MYSQL_ENGINE` //! - SQL Server: `LQ_MSSQL_HOST`, `LQ_MSSQL_PORT`, `LQ_MSSQL_USER`, `LQ_MSSQL_PASSWORD`, `LQ_MSSQL_DB` //! - Redis: `LQ_REDIS_HOST`, `LQ_REDIS_PORT`, `LQ_REDIS_PASSWORD` +//! - Cassandra: `LQ_CASSANDRA_HOST`, `LQ_CASSANDRA_PORT` (9042), `LQ_CASSANDRA_USER`, +//! `LQ_CASSANDRA_PASSWORD`, `LQ_CASSANDRA_DC` //! //! `cargo test --lib mcp_postgres_server -- --ignored --test-threads=1` //! //! Each test writes a `connections.json` with a read, an off and a write //! profile for the same server, creates `lq_mcp_e2e_*` objects through the -//! write connection and drops them at the end, also when a check fails. +//! write connection and drops them at the end, also when a check fails +//! (Cassandra: the keyspace `lq_mcp_e2e`). use std::future::Future; use std::path::PathBuf; @@ -133,6 +136,21 @@ fn redis_target() -> Option { )) } +fn cassandra_target() -> Option { + Some(( + ConnectionProfile { + engine: Engine::Cassandra, + host: env("LQ_CASSANDRA_HOST")?, + port: port("LQ_CASSANDRA_PORT", 9042), + user: env("LQ_CASSANDRA_USER").unwrap_or_default(), + datacenter: env("LQ_CASSANDRA_DC").unwrap_or_default(), + ssl_mode: SslMode::Disable, + ..Default::default() + }, + env("LQ_CASSANDRA_PASSWORD"), + )) +} + // --------------------------------------------------------------------------- // Harness @@ -1380,3 +1398,302 @@ async fn mcp_redis_server() { }) .await; } + +// --------------------------------------------------------------------------- +// Cassandra + +const KEYSPACE: &str = "lq_mcp_e2e"; + +#[tokio::test(flavor = "multi_thread")] +#[ignore] +async fn mcp_cassandra_server() { + let Some(target) = cassandra_target() else { + return; + }; + let t2 = target.clone(); + let cleanup = format!("DROP KEYSPACE IF EXISTS {KEYSPACE}"); + guarded(&target, &[cleanup.as_str()], async move { + let mut t = E2e::start(&t2, "cassandra").await; + let (ro, rw) = (t.ro.clone(), t.rw.clone()); + t.check_connections().await; + let list = t.ok("list_connections", json!({})).await; + let conns = list["structuredContent"]["connections"].as_array().unwrap(); + assert_eq!(conns[0]["engine"], "Cassandra"); + assert!(conns[0]["dialect"] + .as_str() + .unwrap() + .contains("ALLOW FILTERING")); + + // The write connection creates a keyspace, a type and a table. Names + // are keyspace-qualified: no call may change the session keyspace. + t.write(&format!( + "CREATE KEYSPACE {KEYSPACE} WITH replication = \ + {{'class': 'SimpleStrategy', 'replication_factor': 1}}" + )) + .await; + t.write(&format!( + "CREATE TYPE {KEYSPACE}.addr (street text, zip int)" + )) + .await; + t.write(&format!( + "CREATE TABLE {KEYSPACE}.items (id int, seq int, name text, tags set, \ + home frozen, PRIMARY KEY (id, seq))" + )) + .await; + let insert = format!( + "INSERT INTO {KEYSPACE}.items (id, seq, name, tags) VALUES (1, 1, 'uno', {{'a', 'b'}})" + ); + t.write(&insert).await; + // A batch is one statement, semicolons and all. + t.write(&format!( + "BEGIN BATCH \ + INSERT INTO {KEYSPACE}.items (id, seq, name) VALUES (1, 2, 'it''s; two'); \ + INSERT INTO {KEYSPACE}.items (id, seq, name) VALUES (2, 1, 'tres'); \ + APPLY BATCH;" + )) + .await; + t.write(&format!( + "UPDATE {KEYSPACE}.items SET home = {{street: 'Main', zip: 12345}} \ + WHERE id = 1 AND seq = 1" + )) + .await; + + // Reads on the read connection. + let rows = t + .read(&format!( + "SELECT seq, name, tags FROM {KEYSPACE}.items WHERE id = 1" + )) + .await; + assert_eq!( + rows, + [ + row(&[Some("1"), Some("uno"), Some("{'a', 'b'}")]), + row(&[Some("2"), Some("it's; two"), None]), + ] + ); + let rows = t + .read(&format!( + "SELECT home FROM {KEYSPACE}.items WHERE id = 1 AND seq = 1;" + )) + .await; + assert_eq!(rows, [row(&[Some("{street: 'Main', zip: 12345}")])]); + let rows = t + .read(&format!( + "SELECT name FROM {KEYSPACE}.items WHERE name = 'tres' ALLOW FILTERING" + )) + .await; + assert_eq!(rows, [row(&[Some("tres")])]); + let r = t + .ok( + "query", + json!({ "connection": ro, "max_rows": 1, "sql": format!( + "SELECT seq FROM {KEYSPACE}.items WHERE id = 1 -- ; INSERT\n" + ) }), + ) + .await; + assert_eq!(rows_of(&r).len(), 1); + assert_eq!(r["structuredContent"]["truncated"], true); + + // Read-only: SELECT and LIST only, whatever the statement hides. + let refused = [ + format!("INSERT INTO {KEYSPACE}.items (id, seq) VALUES (9, 9)"), + format!("UPDATE {KEYSPACE}.items SET name = 'x' WHERE id = 1 AND seq = 1"), + format!("DELETE FROM {KEYSPACE}.items WHERE id = 1"), + format!("TRUNCATE {KEYSPACE}.items"), + format!("DROP TABLE {KEYSPACE}.items"), + format!("ALTER TABLE {KEYSPACE}.items ADD extra int"), + format!("CREATE TABLE {KEYSPACE}.other (k int PRIMARY KEY)"), + format!( + "BEGIN BATCH INSERT INTO {KEYSPACE}.items (id, seq) VALUES (9, 9); APPLY BATCH" + ), + format!("USE {KEYSPACE}"), + "GRANT SELECT ON ALL KEYSPACES TO nobody".to_string(), + format!("SELECT * FROM {KEYSPACE}.items; DELETE FROM {KEYSPACE}.items WHERE id = 1"), + format!("SELECT * FROM {KEYSPACE}.items WHERE id = 1 /* ; */ ; TRUNCATE x"), + ]; + for sql in refused { + let e = t + .refused("query", json!({ "connection": ro, "sql": sql })) + .await; + assert!( + e.contains(READ_ONLY) || e.contains("one statement"), + "{sql}: {e}" + ); + } + let e = t + .refused( + "query", + json!({ "connection": ro, "sql": "DESCRIBE KEYSPACES" }), + ) + .await; + assert!(e.contains("cqlsh"), "{e}"); + let e = t + .refused("execute", json!({ "connection": ro, "sql": insert })) + .await; + assert!(e.contains(READ_ONLY), "{e}"); + // None of it changed anything. + let n = t + .value(&format!("SELECT count(*) FROM {KEYSPACE}.items")) + .await; + assert_eq!(n.as_deref(), Some("3")); + + // The write connection refuses transaction control and USE, and + // sends reads to `query`. + for sql in [ + "BEGIN TRANSACTION".to_string(), + "COMMIT TRANSACTION".to_string(), + "COMMIT".to_string(), + format!("USE {KEYSPACE}"), + format!("{insert}; {insert}"), + ] { + t.refused("execute", json!({ "connection": rw, "sql": sql })) + .await; + } + let e = t + .refused( + "query", + json!({ "connection": rw, "sql": format!( + "DELETE FROM {KEYSPACE}.items WHERE id = 2" + ) }), + ) + .await; + assert!(e.contains("use execute"), "{e}"); + + // Schema tools. + let schemas = t.ok("list_schemas", json!({ "connection": ro })).await; + let names = schemas["structuredContent"]["schemas"].as_array().unwrap(); + assert!(names.iter().any(|n| n == KEYSPACE), "{names:?}"); + let tables = t + .ok( + "list_tables", + json!({ "connection": ro, "schema": KEYSPACE }), + ) + .await; + let objects = tables["structuredContent"]["tables"].as_array().unwrap(); + for (name, kind) in [("items", "table"), ("addr", "type")] { + assert!( + objects + .iter() + .any(|o| o["name"] == name && o["kind"] == kind), + "{name}: {objects:?}" + ); + } + let d = t + .ok( + "describe_table", + json!({ "connection": ro, "table": "items", "schema": KEYSPACE }), + ) + .await; + let cols = d["structuredContent"]["columns"].as_array().unwrap(); + let role = |name: &str| { + cols.iter().find(|c| c["name"] == name).map(|c| { + ( + c["keyRole"].clone(), + c["primaryKey"].clone(), + c["type"].clone(), + ) + }) + }; + assert_eq!( + role("id"), + Some((json!("partition"), json!(true), json!("int"))) + ); + assert_eq!( + role("seq"), + Some((json!("clustering"), json!(true), json!("int"))) + ); + assert_eq!( + role("name"), + Some((json!("regular"), json!(false), json!("text"))) + ); + assert_eq!( + role("tags").map(|r| r.2), + Some(json!("set")), + "{cols:?}" + ); + assert!(d["structuredContent"]["foreignKeys"] + .as_array() + .is_some_and(Vec::is_empty)); + // keyspace.table works as the table argument. + t.ok( + "describe_table", + json!({ "connection": ro, "table": format!("{KEYSPACE}.items") }), + ) + .await; + let e = t + .refused( + "describe_table", + json!({ "connection": ro, "table": "nope", "schema": KEYSPACE }), + ) + .await; + assert!(e.contains("not found"), "{e}"); + + let search = |pattern: &'static str| json!({ "connection": ro, "pattern": pattern }); + let found = t.ok("search_objects", search("ITEM")).await; + let matches = found["structuredContent"]["matches"].as_array().unwrap(); + assert!( + matches.iter().any(|m| m["table"] == "items" + && m["schema"] == KEYSPACE + && m["kind"] == "table" + && m["column"].is_null()), + "{matches:?}" + ); + let found = t.ok("search_objects", search("tags")).await; + let matches = found["structuredContent"]["matches"].as_array().unwrap(); + assert!( + matches.iter().any(|m| m["table"] == "items" + && m["column"] == "tags" + && m["columnType"] == "set"), + "{matches:?}" + ); + let found = t.ok("search_objects", search("addr")).await; + let matches = found["structuredContent"]["matches"].as_array().unwrap(); + assert!( + matches + .iter() + .any(|m| m["table"] == "addr" && m["kind"] == "type"), + "{matches:?}" + ); + let e = t + .refused( + "explain", + json!({ "connection": ro, "sql": format!("SELECT * FROM {KEYSPACE}.items") }), + ) + .await; + assert!(e.contains("no EXPLAIN"), "{e}"); + + // Writes through the write connection are visible to reads. + t.write(&format!( + "DELETE FROM {KEYSPACE}.items WHERE id = 2 AND seq = 1" + )) + .await; + let n = t + .value(&format!("SELECT count(*) FROM {KEYSPACE}.items")) + .await; + assert_eq!(n.as_deref(), Some("2")); + + // Activity log. + let log = t.log(); + let on = |e: &AuditEntry, id: &str| e.connection_id.as_deref() == Some(id); + assert!(log.iter().any(|e| e.tool == "list_connections" && e.ok)); + assert!(log + .iter() + .any(|e| e.tool == "search_objects" && e.ok && on(e, &ro))); + assert!(log.iter().any(|e| e.tool == "execute" + && e.ok + && on(e, &rw) + && e.detail.as_deref() == Some(insert.as_str()))); + assert!(log.iter().any(|e| e.tool == "query" + && !e.ok + && on(e, &ro) + && e.error.as_deref().is_some_and(|m| m.contains(READ_ONLY)))); + assert!(log + .iter() + .any(|e| e.tool == "execute" && !e.ok && on(e, &ro))); + assert!(log + .iter() + .any(|e| !e.ok && e.tool == "list_schemas" && e.connection_id.is_none())); + let _ = std::fs::remove_dir_all(&t.dir); + }) + .await; +} diff --git a/src-tauri/src/mcp/guard.rs b/src-tauri/src/mcp/guard.rs index 40452f1..5292110 100644 --- a/src-tauri/src/mcp/guard.rs +++ b/src-tauri/src/mcp/guard.rs @@ -18,6 +18,7 @@ enum Dialect { Mysql, SqlServer, Sqlite, + Cassandra, } impl Dialect { @@ -29,6 +30,7 @@ impl Dialect { Engine::Sqlserver => Self::SqlServer, // Redis never reaches the SQL lexer. Engine::Sqlite | Engine::Redis => Self::Sqlite, + Engine::Cassandra => Self::Cassandra, } } } @@ -95,7 +97,7 @@ fn is_word_char(c: u8, d: Dialect) -> bool { c.is_ascii_alphanumeric() || c == b'_' || c >= 0x80 - || (c == b'$' && d != Dialect::Sqlite) + || (c == b'$' && !matches!(d, Dialect::Sqlite | Dialect::Cassandra)) || (c == b'#' && matches!(d, Dialect::Oracle | Dialect::SqlServer)) } @@ -165,6 +167,15 @@ fn skip_dollar_quote(b: &[u8], i: usize) -> Result, String> { .ok_or_else(|| UNTERMINATED.to_string()) } +/// CQL `$$...$$`: `i` is at the first `$`, the next one is known to be `$`. +fn skip_double_dollar(b: &[u8], i: usize) -> Result { + b[i + 2..] + .windows(2) + .position(|w| w == b"$$") + .map(|p| i + 2 + p + 2) + .ok_or_else(|| UNTERMINATED.to_string()) +} + fn skip_block_comment(b: &[u8], i: usize, nested: bool) -> Result { let mut depth = 1; let mut j = i + 2; @@ -239,7 +250,9 @@ fn lex(sql: &str, d: Dialect, backslash: bool) -> Result, String> { push(&mut out, Tok::Punct(c), i + 1); i += 1; } - } else if c == b'#' && d == Dialect::Mysql { + } else if (c == b'#' && d == Dialect::Mysql) + || (c == b'/' && next == Some(b'/') && d == Dialect::Cassandra) + { i = line_end(b, i); } else if c == b'/' && next == Some(b'*') { if d == Dialect::Mysql @@ -278,6 +291,14 @@ fn lex(sql: &str, d: Dialect, backslash: bool) -> Result, String> { } i = j + 1; push(&mut out, Tok::Quoted, i); + } else if c == b'$' && d == Dialect::Cassandra { + if next == Some(b'$') { + i = skip_double_dollar(b, i)?; + push(&mut out, Tok::Quoted, i); + } else { + i += 1; + push(&mut out, Tok::Punct(c), i); + } } else if c == b'$' && d == Dialect::Postgres { match skip_dollar_quote(b, i)? { Some(end) => { @@ -355,17 +376,50 @@ fn is_routine(toks: &[Token]) -> bool { }) } +/// A CQL `BEGIN [UNLOGGED|COUNTER] BATCH` whose `APPLY BATCH` has not come +/// yet: the `;` between its statements do not end it. +fn is_cql_batch_open(group: &[Token]) -> bool { + let mut w = group.iter().filter_map(|t| match &t.tok { + Tok::Word(w) => Some(w.as_str()), + _ => None, + }); + let opens = w.next() == Some("BEGIN") + && match w.next() { + Some("UNLOGGED" | "COUNTER") => w.next() == Some("BATCH"), + Some("BATCH") => true, + _ => false, + }; + if !opens { + return false; + } + let mut prev = ""; + for word in group.iter().filter_map(|t| match &t.tok { + Tok::Word(w) => Some(w.as_str()), + _ => None, + }) { + if prev == "APPLY" && word == "BATCH" { + return false; + } + prev = word; + } + true +} + /// Splits `toks` and keeps the only statement, with the byte offset where /// it ends. fn split_one(toks: Vec, sql: &str, d: Dialect) -> Result<(Vec, usize), String> { - let block = if d == Dialect::Oracle { - is_plsql(sql) - } else { - is_routine(&toks) + // CQL function bodies are `$$...$$` strings, so only batches span `;`. + let block = match d { + Dialect::Oracle => is_plsql(sql), + Dialect::Cassandra => false, + _ => is_routine(&toks), }; let mut groups: Vec> = vec![Vec::new()]; for t in toks { - let separator = t.tok == Tok::Slash || (t.tok == Tok::Semi && !block); + let in_batch = d == Dialect::Cassandra + && t.tok == Tok::Semi + && groups.last().is_some_and(|g| is_cql_batch_open(g)); + let separator = t.tok == Tok::Slash || (t.tok == Tok::Semi && !block && !in_batch); if separator { groups.push(Vec::new()); } else if let Some(g) = groups.last_mut() { @@ -566,9 +620,32 @@ fn check_pragma(toks: &[Tok]) -> Result<(), String> { } } +/// CQL reads: SELECT and LIST only. LIST (ROLES, USERS, PERMISSIONS) only +/// reports, so its permission names (CREATE, ALTER, DROP...) are not scanned. +fn check_cql_view(first: &str, toks: &[Tok]) -> Result<(), String> { + match first { + "LIST" => Ok(()), + "SELECT" => match words(toks).find(|w| WRITE_WORDS.contains(w)) { + Some(w) => Err(format!("{w} is not allowed in a read statement.")), + None => Ok(()), + }, + "DESCRIBE" | "DESC" => Err( + "DESCRIBE is a cqlsh command, not CQL; use the schema, table and search tools instead." + .into(), + ), + "WITH" | "EXPLAIN" => Err(format!("{first} does not exist in CQL; send a SELECT.")), + _ => Err(format!( + "{first} is not a read statement; CQL reads are SELECT and LIST." + )), + } +} + fn check_view(toks: &[Tok], engine: Engine) -> Result<(), String> { let first = first_word(toks).ok_or("The statement must start with a keyword such as SELECT.")?; + if engine == Engine::Cassandra { + return check_cql_view(first, toks); + } let allowed = matches!( first, "SELECT" | "WITH" | "SHOW" | "DESCRIBE" | "DESC" | "EXPLAIN" | "VALUES" | "TABLE" @@ -610,7 +687,8 @@ fn check_view(toks: &[Tok], engine: Engine) -> Result<(), String> { } /// Accepts SELECT, WITH, SHOW, DESCRIBE, EXPLAIN (without ANALYZE), VALUES, -/// TABLE and reading PRAGMAs; the error says why the text is not a read. +/// TABLE and reading PRAGMAs (CQL: SELECT and LIST); the error says why the +/// text is not a read. pub fn check_read(stmt: &Checked, engine: Engine) -> Result<(), String> { stmt.views.iter().try_for_each(|v| check_view(v, engine)) } @@ -625,6 +703,19 @@ pub fn check_write(stmt: &Checked, engine: Engine) -> Result<(), String> { let first = w.next().unwrap_or(""); let second = w.next().unwrap_or(""); let control = match first { + // `BEGIN [UNLOGGED|COUNTER] BATCH` is one statement, not a + // transaction; Accord (Cassandra 5.1+) uses BEGIN TRANSACTION. + "BEGIN" if engine == Engine::Cassandra => { + matches!(second, "TRANSACTION" | "TRAN") + } + // CQL has no savepoints; USE would change the session's keyspace + // for later calls. + "USE" if engine == Engine::Cassandra => { + return Err( + "USE changes the session keyspace; qualify names as keyspace.table instead." + .into(), + ); + } "BEGIN" => match engine { Engine::Oracle => false, Engine::Sqlserver => matches!(second, "TRAN" | "TRANSACTION" | "DISTRIBUTED"), @@ -1000,6 +1091,105 @@ mod tests { assert!(write("set implicit_transactions on", Engine::Sqlserver).is_err()); } + #[test] + fn cql_statements_and_batches() { + let cql = |sql: &str| one(sql, Engine::Cassandra); + assert_eq!(cql("select * from ks.t;").unwrap(), "select * from ks.t"); + assert!(cql("select 1 from t; select 2 from t").is_err()); + assert!(cql("select 1 from t; delete from t where k = 1;").is_err()); + assert!(cql(" -- nothing\n // nothing\n ; ").is_err()); + let batch = "BEGIN BATCH\n INSERT INTO ks.t (k) VALUES (1);\n \ + INSERT INTO ks.t (k) VALUES (2);\nAPPLY BATCH;"; + assert!(cql(batch).unwrap().ends_with("APPLY BATCH")); + for head in ["begin unlogged batch", "BEGIN COUNTER BATCH"] { + let sql = format!("{head} update t set c = c + 1 where k = 1; apply batch"); + assert!(cql(&sql).is_ok(), "{sql}"); + } + // Whatever follows APPLY BATCH is a second statement. + assert!(cql(&format!("{batch} select 1 from t")).is_err()); + assert!(cql(&format!("{batch} delete from t where k = 1;")).is_err()); + // Other BEGIN statements still split at `;`. + assert!(cql("begin; select 1 from t").is_err()); + } + + #[test] + fn cql_lexer_hides_semicolons_and_keywords() { + let cases = [ + "select 'a;b', \"c;d\" from t -- ;\n where k = 1", + "select 'it''s; here' from t // ; delete from t\n", + "select \"we\"\"ird;\" from t /* ; /* */", + "create function f(a int) returns null on null input returns int \ + language java as $$ return a; $$", + "select $$a;b$$ from t", + ]; + for sql in cases { + assert!(one(sql, Engine::Cassandra).is_ok(), "{sql}"); + } + // Block comments do not nest, `\` does not escape in strings. + assert!(one("select 1 /* /* */ ; delete from t */", Engine::Cassandra).is_err()); + assert!(one(r"select 'a\'; delete from t; -- '", Engine::Cassandra).is_err()); + assert!(one("select 'abc", Engine::Cassandra).is_err()); + assert!(one("select $$ abc", Engine::Cassandra).is_err()); + assert!(one("select 1 /* open", Engine::Cassandra).is_err()); + // A comment hides a write word from the classifier too. + assert!(read("select a from t -- delete\n", Engine::Cassandra).is_ok()); + } + + #[test] + fn cql_classifier() { + let cql = |sql: &str| read(sql, Engine::Cassandra); + assert!(cql("SELECT * FROM ks.t WHERE k = 1 LIMIT 10").is_ok()); + assert!(cql("select * from ks.t where c > 1 allow filtering").is_ok()); + assert!(cql("select \"insert\", 'delete from t' from t").is_ok()); + assert!(cql("select writetime(v), ttl(v) from t").is_ok()); + assert!(cql("LIST ROLES").is_ok()); + assert!(cql("list all permissions of alice").is_ok()); + assert!(cql("LIST USERS").is_ok()); + let describe = cql("describe keyspaces").unwrap_err(); + assert!(describe.contains("cqlsh"), "{describe}"); + assert!(cql("desc table ks.t").is_err()); + for sql in [ + "insert into t (k) values (1)", + "update t set v = 1 where k = 1", + "delete from t where k = 1", + "begin batch insert into t (k) values (1); apply batch", + "create table t (k int primary key)", + "alter table t add c int", + "drop table t", + "truncate t", + "grant select on t to bob", + "revoke select on t from bob", + "use ks", + "with x as (select 1) select * from x", + "explain select * from t", + "select * from t where k = 1 delete from t", + "select * into t2 from t", + ] { + assert!(cql(sql).is_err(), "{sql}"); + } + } + + #[test] + fn cql_write_check() { + let write = |sql: &str| { + check_write( + &one_statement(sql, Engine::Cassandra).unwrap(), + Engine::Cassandra, + ) + }; + assert!(write("insert into ks.t (k) values (1)").is_ok()); + assert!(write("create table ks.t (k int primary key)").is_ok()); + assert!(write("begin batch insert into t (k) values (1); apply batch").is_ok()); + assert!(write("BEGIN UNLOGGED BATCH delete from t where k = 1; APPLY BATCH").is_ok()); + assert!(write("BEGIN COUNTER BATCH update t set c = c + 1; APPLY BATCH").is_ok()); + assert!(write("begin transaction").is_err()); + assert!(write("commit transaction").is_err()); + assert!(write("start transaction").is_err()); + assert!(write("commit").is_err()); + assert!(write("rollback").is_err()); + assert!(write("use ks").is_err()); + } + #[test] fn explain_takes_the_statement() { let c = one_statement("explain select 1", Engine::Postgres).unwrap(); diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 65ca523..83a0940 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -5,9 +5,11 @@ //! //! - `server.rs`: JSON-RPC over stdio, agent sessions, limits. //! - `tools.rs`: tool definitions and handlers. -//! - `guard.rs`: one statement per call, read-only classifier, Redis lists. +//! - `guard.rs`: one statement per call (a CQL batch counts as one), read-only +//! classifier, Redis lists. //! - `format.rs`: result tables, output caps, error text. -//! - `catalog.rs`: per-engine search, foreign keys and execution plans. +//! - `catalog.rs`: per-engine search (Cassandra: `system_schema`), foreign keys and +//! execution plans. //! - `audit.rs`: the activity log shown in the app. //! - `e2e_tests.rs`: the server against real servers, one test per engine. diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs index 6e2ac43..75c4c1f 100644 --- a/src-tauri/src/mcp/server.rs +++ b/src-tauri/src/mcp/server.rs @@ -561,7 +561,11 @@ impl Server { op(session.clone()).await }; let (mut outcome, mut healthy) = self.bounded(Some(&session), cancel, work).await; - if healthy && read_only && session.engine() != Engine::Redis { + // Redis and Cassandra have no transactions to roll back. + if healthy + && read_only + && !matches!(session.engine(), Engine::Redis | Engine::Cassandra) + { healthy = matches!( tokio::time::timeout(GRACE, session.end_transaction(false)).await, Ok(Ok(_)) @@ -621,6 +625,8 @@ async fn begin_read(session: &Session, kind: Kind) -> AppResult<()> { Engine::Postgres => exec(session, "SET TRANSACTION READ ONLY").await, Engine::Mysql | Engine::Mariadb => exec(session, "START TRANSACTION READ ONLY").await, Engine::Oracle if kind != Kind::Explain => exec(session, "SET TRANSACTION READ ONLY").await, + // SQLite is read-only by session setting; Redis and Cassandra have no + // read-only transaction (the guard is the protection). _ => Ok(()), } } @@ -641,7 +647,7 @@ async fn make_read_only(session: &Session) -> AppResult<()> { } Engine::Sqlite => exec(session, "PRAGMA query_only = ON").await?, Engine::Oracle | Engine::Sqlserver => {} - Engine::Redis => return Ok(()), + Engine::Redis | Engine::Cassandra => return Ok(()), } session.set_autocommit(false).await.map(|_| ()) } diff --git a/src-tauri/src/mcp/tools.rs b/src-tauri/src/mcp/tools.rs index 9c90a51..7b48981 100644 --- a/src-tauri/src/mcp/tools.rs +++ b/src-tauri/src/mcp/tools.rs @@ -10,7 +10,7 @@ use super::guard; use super::server::{CallInfo, Cancel, Kind, Reply, Server}; use crate::error::AppError; use crate::model::{ - AiAccess, ConnectionProfile, Engine, ExecRequest, ExecResult, OracleConnectType, + AiAccess, ConnectionProfile, DbColumn, Engine, ExecRequest, ExecResult, OracleConnectType, }; const READ_ONLY: &str = "This connection is read-only for AI agents. Ask the user to allow changes in LowQuery (connection → AI access)."; @@ -117,7 +117,7 @@ pub fn list(version: &str) -> Value { tool( "list_schemas", "List schemas", - "Lists the schemas (databases on MySQL, attached databases on SQLite, db0... on Redis) of a connection, and the current one.", + "Lists the schemas (databases on MySQL, attached databases on SQLite, keyspaces on Cassandra, db0... on Redis) of a connection, and the current one.", object(json!({ "connection": connection_prop() }), &["connection"]), json!({ "type": "object", @@ -156,7 +156,7 @@ pub fn list(version: &str) -> Value { tool( "describe_table", "Describe table", - "Columns of a table or view (name, type, nullable, primary key) and its foreign keys, both outgoing and those referencing it. Read this before writing SQL against a table.", + "Columns of a table or view (name, type, nullable, primary key; on Cassandra the key role: partition, clustering, static or regular) and its foreign keys, both outgoing and those referencing it. Read this before writing SQL against a table.", object( json!({ "connection": connection_prop(), @@ -174,7 +174,8 @@ pub fn list(version: &str) -> Value { "type": "object", "properties": { "name": { "type": "string" }, "type": { "type": "string" }, - "nullable": { "type": "boolean" }, "primaryKey": { "type": "boolean" } + "nullable": { "type": "boolean" }, "primaryKey": { "type": "boolean" }, + "keyRole": { "type": ["string", "null"] } }, "required": ["name", "type", "nullable", "primaryKey"] } }, @@ -188,7 +189,7 @@ pub fn list(version: &str) -> Value { tool( "search_objects", "Search objects", - "Finds tables, views and columns whose name contains the pattern (case-insensitive) across the schemas of a connection; exact and prefix matches first. On Redis, finds keys.", + "Finds tables, views and columns whose name contains the pattern (case-insensitive) across the schemas of a connection (on Cassandra: tables, materialized views, columns and user-defined types of every keyspace); exact and prefix matches first. On Redis, finds keys.", object( json!({ "connection": connection_prop(), @@ -214,7 +215,7 @@ pub fn list(version: &str) -> Value { tool( "query", "Run a query", - "Runs ONE read statement (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN without ANALYZE, VALUES, TABLE, reading PRAGMAs; on Redis one read command) in the connection's dialect and returns the rows as a table. Rows are capped at max_rows; select only the columns you need and filter in SQL.", + "Runs ONE read statement (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN without ANALYZE, VALUES, TABLE, reading PRAGMAs; on Cassandra SELECT and LIST; on Redis one read command) in the connection's dialect (CQL on Cassandra) and returns the rows as a table. Rows are capped at max_rows; select only the columns you need and filter in SQL.", object( json!({ "connection": connection_prop(), @@ -241,7 +242,7 @@ pub fn list(version: &str) -> Value { tool( "execute", "Execute a change", - "Runs ONE statement of any kind (INSERT, UPDATE, DELETE, DDL, a procedure call; on Redis one command) on a connection with write access, and commits it. Use only when the user asked for the change. Returns rows affected, notices and any rows. Transaction control (BEGIN, COMMIT...) is refused: each call commits on its own.", + "Runs ONE statement of any kind (INSERT, UPDATE, DELETE, DDL, a procedure call, a CQL BEGIN BATCH ... APPLY BATCH; on Redis one command) on a connection with write access, and commits it. Use only when the user asked for the change. Returns rows affected, notices and any rows. Transaction control (BEGIN, COMMIT...) is refused: each call commits on its own.", object( json!({ "connection": connection_prop(), @@ -307,6 +308,7 @@ pub fn engine_label(engine: Engine) -> &'static str { Engine::Sqlserver => "SQL Server", Engine::Sqlite => "SQLite", Engine::Redis => "Redis", + Engine::Cassandra => "Cassandra", } } @@ -318,6 +320,7 @@ fn dialect_notes(engine: Engine) -> &'static str { Engine::Mariadb => "MariaDB: LIMIT n; backtick-quote names; databases are the schemas.", Engine::Sqlserver => "SQL Server (T-SQL): SELECT TOP (n) or ORDER BY ... OFFSET 0 ROWS FETCH NEXT n ROWS ONLY; [bracket] names; schema.table names (dbo.x).", Engine::Sqlite => "SQLite: LIMIT n; flexible column types; schemas are attached databases (main).", + Engine::Cassandra => "Cassandra (CQL): no joins or subqueries; filter on the partition key (other columns need an index or ALLOW FILTERING); LIMIT n; keyspaces are the schemas (write keyspace.table); names are lowercase unless double-quoted; text literals use single quotes.", Engine::Redis => "Redis: not SQL; pass one redis-cli command, such as GET key, HGETALL key or SCAN 0 MATCH user:* COUNT 100 (KEYS is refused).", } } @@ -371,6 +374,7 @@ fn location(p: &ConnectionProfile) -> String { with_port(&p.host, p.port), if db.is_empty() { "0" } else { db } )), + Engine::Cassandra if db.is_empty() => at(with_port(&p.host, p.port)), _ => at(format!("{}/{db}", with_port(&p.host, p.port))), } } @@ -383,6 +387,18 @@ fn access_label(p: &ConnectionProfile) -> &'static str { } } +/// The `key` cell of describe_table: PK, and on Cassandra the role of the +/// column in the key. +fn key_label(c: &DbColumn) -> &'static str { + match c.key_role.as_deref() { + Some("partition") => "PK (partition)", + Some("clustering") => "PK (clustering)", + Some("static") => "static", + _ if c.primary_key => "PK", + _ => "", + } +} + /// Finds a connection by id or by name (case-insensitive). fn resolve(server: &Server, key: &str, info: &mut CallInfo) -> Result { let all = server.agent_profiles()?; @@ -621,14 +637,18 @@ async fn describe_table( c.name, c.type_name, if c.nullable { "yes" } else { "no" }, - if c.primary_key { "PK" } else { "" } + key_label(c) )); - cols.push(json!({ + let mut col = json!({ "name": c.name, "type": c.type_name, "nullable": c.nullable, "primaryKey": c.primary_key, - })); + }); + if let Some(role) = &c.key_role { + col["keyRole"] = json!(role); + } + cols.push(col); } let mut structured = json!({ "schema": schema, "table": name, "columns": cols }); match fks { @@ -963,6 +983,13 @@ mod tests { ms.instance = "SQLEXPRESS".into(); ms.windows_auth = true; assert_eq!(location(&ms), "Windows account@db.local\\SQLEXPRESS/app"); + let mut cass = profile(Engine::Cassandra); + cass.port = 9042; + assert_eq!(location(&cass), "ann@db.local:9042/app"); + cass.database.clear(); + assert_eq!(location(&cass), "ann@db.local:9042"); + assert_eq!(engine_label(Engine::Cassandra), "Cassandra"); + assert!(dialect_notes(Engine::Cassandra).contains("ALLOW FILTERING")); } #[test] diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs index a22fe6b..6542dc2 100644 --- a/src-tauri/src/model.rs +++ b/src-tauri/src/model.rs @@ -10,6 +10,7 @@ pub enum Engine { Sqlserver, Sqlite, Redis, + Cassandra, } /// libpq `sslmode` values that make sense for a desktop client. @@ -95,6 +96,7 @@ pub struct ConnectionProfile { pub port: u16, /// PostgreSQL, MySQL, SQL Server: database. Oracle: service name or SID. /// SQLite: path of the database file. Redis: logical database index. + /// Cassandra: default keyspace (optional). pub database: String, pub user: String, pub save_password: bool, @@ -115,6 +117,9 @@ pub struct ConnectionProfile { pub windows_auth: bool, /// SQLite: create the file when it does not exist. pub create_file: bool, + /// Cassandra: local datacenter. Requests go to its nodes only; empty + /// means every node the cluster reports. + pub datacenter: String, /// Access for AI agents through `lowquery mcp`. pub ai_access: AiAccess, } @@ -140,6 +145,7 @@ impl Default for ConnectionProfile { instance: String::new(), windows_auth: false, create_file: false, + datacenter: String::new(), ai_access: AiAccess::Off, } } @@ -289,6 +295,9 @@ pub struct DbColumn { pub primary_key: bool, /// Default as an SQL expression, as the table definition writes it. pub default_value: Option, + /// Cassandra: `partition`, `clustering`, `static` or `regular`. `None` + /// on every other engine. + pub key_role: Option, } #[derive(Debug, Clone, Serialize)] diff --git a/src-tauri/src/transfer/catalog.rs b/src-tauri/src/transfer/catalog.rs index 1d897e9..57a815c 100644 --- a/src-tauri/src/transfer/catalog.rs +++ b/src-tauri/src/transfer/catalog.rs @@ -1,7 +1,7 @@ //! Catalog facts exports and imports need beyond what the tree shows: //! generated columns (never inserted), identity columns (inserted with their //! values, then the generator moved past them) and foreign keys (tables in -//! dependency order). +//! dependency order). Cassandra has none of the three: the answers are empty. use std::collections::{HashMap, HashSet}; @@ -59,7 +59,8 @@ pub async fn generated_columns( text(e, table), text(e, schema) ), - Engine::Redis => return Ok(HashSet::new()), + // CQL has no generated columns (counters are written by UPDATE). + Engine::Redis | Engine::Cassandra => return Ok(HashSet::new()), }; Ok(names(first_column(s, sql).await?)) } @@ -207,7 +208,8 @@ pub async fn dependency_order(s: &Session, schema: &str, tables: Vec) -> super::sql::ident(e, schema), text(e, schema) ), - Engine::Redis => return tables, + // No foreign keys in Redis or CQL. + Engine::Redis | Engine::Cassandra => return tables, }; let Ok(rows) = first_column(s, sql).await else { return tables; diff --git a/src-tauri/src/transfer/export.rs b/src-tauri/src/transfer/export.rs index c2b6fa1..11f953c 100644 --- a/src-tauri/src/transfer/export.rs +++ b/src-tauri/src/transfer/export.rs @@ -61,8 +61,9 @@ pub struct ExportSummary { pub warnings: Vec, } -/// Kinds a schema export scripts, in the order they are created. -const STRUCTURE_ORDER: [&str; 10] = [ +/// Kinds a schema export scripts, in the order they are created. Cassandra +/// indexes are not listed: the DDL of their table already ends with them. +const STRUCTURE_ORDER: [&str; 11] = [ "sequence", "type", "table", @@ -70,6 +71,7 @@ const STRUCTURE_ORDER: [&str; 10] = [ "view", "matview", "function", + "aggregate", "procedure", "package", "synonym", @@ -84,6 +86,7 @@ pub async fn export( progress: &(dyn Fn(Progress) + Sync), ) -> AppResult { let whole = req.objects.is_none(); + let e = s.engine(); let mut objects = match &req.objects { Some(list) => list.clone(), None => s.list_objects(&req.schema).await?, @@ -95,6 +98,9 @@ pub async fn export( .filter(|o| { if whole { o.kind == "table" + } else if e == Engine::Cassandra && req.format == ExportFormat::Sql { + // Materialized views cannot be INSERTed into. + o.kind == "table" } else { DATA_KINDS.contains(&o.kind.as_str()) } @@ -309,16 +315,19 @@ async fn write_table_rows( ) -> AppResult { let e = s.engine(); let generated = catalog::generated_columns(s, schema, table).await?; - let columns: Vec = s + let table_columns: Vec<_> = s .list_columns(schema, table) .await? .into_iter() - .map(|c| c.name) - .filter(|n| !generated.contains(n)) + .filter(|c| !generated.contains(&c.name)) .collect(); - if columns.is_empty() { + if table_columns.is_empty() { return Ok(0); } + // CQL literals depend on the column's type; counters need the key. + let types: Vec = table_columns.iter().map(|c| c.type_name.clone()).collect(); + let keys: Vec = table_columns.iter().map(|c| c.primary_key).collect(); + let columns: Vec = table_columns.into_iter().map(|c| c.name).collect(); let target = sql::qualified(e, schema, table); let quoted: Vec = columns.iter().map(|c| sql::ident(e, c)).collect(); let ids = catalog::identity_columns(s, schema, table).await?; @@ -343,6 +352,8 @@ async fn write_table_rows( table: &target, columns: "ed, kinds: &kinds, + types: &types, + keys: &keys, }; sql::write_inserts(w, &t, batch)?; Ok(()) diff --git a/src-tauri/src/transfer/import.rs b/src-tauri/src/transfer/import.rs index 3a57adb..34fb929 100644 --- a/src-tauri/src/transfer/import.rs +++ b/src-tauri/src/transfer/import.rs @@ -2,7 +2,9 @@ //! delimiter and header are detected; columns are matched by name; values //! written the way spreadsheets in many locales write them (`1.234,56`, //! `31/12/2024`, `sim`) are converted for the column's type. Everything runs -//! in one transaction: an error leaves the table as it was. +//! in one transaction: an error leaves the table as it was. Cassandra has no +//! transactions: its rows are inserted one statement at a time, and an error +//! leaves the rows written so far. use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; @@ -14,7 +16,7 @@ use super::sql::{self, InsertTarget}; use super::Progress; use crate::drivers::Session; use crate::error::{AppError, AppResult}; -use crate::model::{ExecRequest, ValueKind}; +use crate::model::{Engine, ExecRequest, ValueKind}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -204,43 +206,100 @@ fn parse_csv(text: &str, delimiter: char) -> Vec> { /// Column names and rows, every value as text. type Table = (Vec, Vec>>); +/// One JSON record with its values kept as the text they were written in: +/// parsing numbers into `f64` would cut `NUMERIC`, `BIGINT` and varint values +/// to 17 digits. +struct Record(Vec<(String, Option)>); + +impl<'de> serde::Deserialize<'de> for Record { + fn deserialize>(d: D) -> Result { + struct Fields; + impl<'de> serde::de::Visitor<'de> for Fields { + type Value = Record; + + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("an object with one key per column") + } + + fn visit_map>( + self, + mut map: A, + ) -> Result { + let mut fields: Vec<(String, Option)> = Vec::new(); + while let Some((key, raw)) = + map.next_entry::>()? + { + let value = raw_text(raw.get()).map_err(serde::de::Error::custom)?; + // As in a JSON object, the last of repeated keys wins. + match fields.iter_mut().find(|(k, _)| *k == key) { + Some(slot) => slot.1 = value, + None => fields.push((key, value)), + } + } + Ok(Record(fields)) + } + } + d.deserialize_map(Fields) + } +} + +/// A raw JSON value as the text of the cell: strings unescaped, numbers as +/// written, `null` as no value, arrays, objects and booleans compact. +fn raw_text(raw: &str) -> Result, serde_json::Error> { + let raw = raw.trim(); + match raw.as_bytes().first() { + Some(b'"') => serde_json::from_str::(raw).map(Some), + Some(b'-' | b'0'..=b'9') => Ok(Some(raw.to_string())), + _ => Ok(match serde_json::from_str::(raw)? { + serde_json::Value::Null => None, + other => Some(other.to_string()), + }), + } +} + fn parse_json(text: &str) -> AppResult { - let values: Vec = if text.trim_start().starts_with('[') { - serde_json::from_str(text) - .map_err(|e| AppError::invalid(format!("The JSON file is not valid: {e}")))? + const NOT_AN_OBJECT: &str = "Each JSON item must be an object with one key per column."; + let not_object = |e: &serde_json::Error| e.to_string().contains("one key per column"); + let records: Vec = if text.trim_start().starts_with('[') { + serde_json::from_str(text).map_err(|e| { + if not_object(&e) { + AppError::invalid(NOT_AN_OBJECT) + } else { + AppError::invalid(format!("The JSON file is not valid: {e}")) + } + })? } else { text.lines() .filter(|l| !l.trim().is_empty()) .enumerate() .map(|(i, l)| { serde_json::from_str(l).map_err(|e| { - AppError::invalid(format!("Line {} is not valid JSON: {e}", i + 1)) + if not_object(&e) { + AppError::invalid(NOT_AN_OBJECT) + } else { + AppError::invalid(format!("Line {} is not valid JSON: {e}", i + 1)) + } }) }) .collect::>()? }; let mut columns: Vec = Vec::new(); - for v in &values { - let Some(obj) = v.as_object() else { - return Err(AppError::invalid( - "Each JSON item must be an object with one key per column.", - )); - }; - for k in obj.keys() { + for record in &records { + for (k, _) in &record.0 { if !columns.contains(k) { columns.push(k.clone()); } } } - let rows = values + let rows = records .iter() - .map(|v| { + .map(|r| { columns .iter() - .map(|c| match v.get(c) { - None | Some(serde_json::Value::Null) => None, - Some(serde_json::Value::String(s)) => Some(s.clone()), - Some(other) => Some(other.to_string()), + .map(|c| { + r.0.iter() + .find(|(k, _)| k == c) + .and_then(|(_, v)| v.clone()) }) .collect() }) @@ -554,8 +613,10 @@ pub async fn import( let quoted: Vec = names.iter().map(|n| sql::ident(e, n)).collect(); let kinds: Vec = used .iter() - .map(|(_, c)| sql::kind_of_type(&c.type_name)) + .map(|(_, c)| sql::column_kind(e, &c.type_name)) .collect(); + let types: Vec = used.iter().map(|(_, c)| c.type_name.clone()).collect(); + let keys: Vec = used.iter().map(|(_, c)| c.primary_key).collect(); let ids = catalog::identity_columns(s, &req.schema, &req.table).await?; let (before, after) = catalog::identity_statements(e, &target, &ids, &names); @@ -564,7 +625,12 @@ pub async fn import( .await .map(|_| ()) }; - let was_autocommit = s.tx_state().autocommit; + // CQL has no transactions (set_autocommit(false) is an error there), and + // the INSERTs are not wrapped in BEGIN BATCH either: a batch spanning + // partitions is an anti-pattern. Each row is its own statement; the + // chunks only group the progress reports. + let transactional = e != Engine::Cassandra; + let was_autocommit = transactional && s.tx_state().autocommit; if was_autocommit { s.set_autocommit(false).await?; } @@ -575,13 +641,21 @@ pub async fn import( run(stmt.clone()).await?; } if req.replace { - run(format!("DELETE FROM {target}")).await?; + // CQL cannot DELETE without a key. + let clear = if e == Engine::Cassandra { + "TRUNCATE" + } else { + "DELETE FROM" + }; + run(format!("{clear} {target}")).await?; } let t = InsertTarget { engine: e, table: &target, columns: "ed, kinds: &kinds, + types: &types, + keys: &keys, }; let mut done = 0usize; for chunk in data.rows.chunks(sql::ROWS_PER_INSERT) { @@ -617,7 +691,11 @@ pub async fn import( Ok(done as u64) } .await; - let finish = s.end_transaction(outcome.is_ok()).await; + let finish = if transactional { + s.end_transaction(outcome.is_ok()).await + } else { + Ok(s.tx_state()) + }; if was_autocommit { let _ = s.set_autocommit(true).await; } @@ -699,6 +777,31 @@ mod tests { assert_eq!(rows.len(), 2); } + #[test] + fn json_numbers_keep_every_digit() { + let (_, rows) = parse_json( + "[{\"a\": 123456789012345678901234567890, \"b\": 1.50, \"c\": -1e5, \ + \"d\": \"x\\ny\", \"e\": {\"k\": [1, true]}, \"f\": false}]", + ) + .unwrap(); + assert_eq!( + rows[0], + [ + Some("123456789012345678901234567890".into()), + Some("1.50".into()), + Some("-1e5".into()), + Some("x\ny".into()), + Some("{\"k\":[1,true]}".into()), + Some("false".into()), + ] + ); + assert!(parse_json("[1]").is_err()); + assert!(parse_json("{\"a\": 1}\n[2]").is_err()); + // The last of repeated keys wins. + let (cols, rows) = parse_json("[{\"a\": 1, \"a\": 2}]").unwrap(); + assert_eq!((cols.len(), rows[0][0].as_deref()), (1, Some("2"))); + } + #[test] fn header_detection() { let r = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); diff --git a/src-tauri/src/transfer/round_trip.rs b/src-tauri/src/transfer/round_trip.rs index 284732e..89f732a 100644 --- a/src-tauri/src/transfer/round_trip.rs +++ b/src-tauri/src/transfer/round_trip.rs @@ -2,13 +2,19 @@ //! accents, binary, JSON, dates, identity and generated columns) and loads //! it back: from the SQL script after dropping the table, and from CSV and //! JSON after deleting its rows. The rows must come back the same. +//! +//! Cassandra (`cassandra_export_import_round_trip`, needs `LQ_CASSANDRA_HOST` +//! and creates the keyspace `lq_round_trip`) has no ORDER BY on this table, no +//! identity or generated columns, and keeps its collections as CQL literals. use std::sync::atomic::AtomicBool; +use futures_util::FutureExt; + use super::export::{export, ExportContent, ExportFormat, ExportRequest}; use super::import::{import, ImportRequest, ReadOptions}; use crate::drivers::Session; -use crate::model::{DbObject, Engine, ExecRequest}; +use crate::model::{ConnectionProfile, DbObject, Engine, ExecRequest, SslMode}; struct Case { create: &'static str, @@ -75,6 +81,25 @@ fn case(engine: Engine) -> Case { ], compare: "id, name, amount, born, seen, flag, data, doc, total", }, + Engine::Cassandra => Case { + create: "CREATE TABLE lq_schema.lq_xfer ( + id int PRIMARY KEY, name text, big varint, amount decimal, born date, + seen timestamp, flag boolean, data blob, uid uuid, nums list, + tags set, scores map)", + fill: &[ + "INSERT INTO lq_schema.lq_xfer (id, name, big, amount, born, seen, flag, data, + uid, nums, tags, scores) VALUES (1, 'it''s \"quoted\" +line \\ back; -- x', 123456789012345678901234567890, 1234567890.0987654321, '2024-02-29', + '2024-02-29 13:45:10.250+0000', true, 0xdeadbeef, + 123e4567-e89b-12d3-a456-426614174000, [3, 1, 2], {'a', 'it''s, {x}'}, + {'k': 1, 'it''s': -2})", + "INSERT INTO lq_schema.lq_xfer (id, name, big, amount, flag, data) + VALUES (2, 'Ação ☃', -98765432109876543210, -0.5, false, 0x)", + "INSERT INTO lq_schema.lq_xfer (id, born, uid) VALUES + (3, '1999-12-31', 00000000-0000-1000-8000-000000000000)", + ], + compare: "id, name, big, amount, born, seen, flag, data, uid, nums, tags, scores", + }, Engine::Sqlite | Engine::Redis => Case { create: "CREATE TABLE lq_schema.lq_xfer ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, amount NUMERIC, born TEXT, @@ -133,8 +158,22 @@ pub async fn check(s: &Session, schema: &str) { for f in c.fill { run(s, &sql(f)).await; } - let select = format!("SELECT {} FROM {target} ORDER BY 1", up(c.compare)); - let original = run(s, &select).await; + // CQL cannot ORDER BY a regular column: rows are sorted here instead (the + // first column is the integer key). + let order = if e == Engine::Cassandra { + "" + } else { + " ORDER BY 1" + }; + let select = format!("SELECT {} FROM {target}{order}", up(c.compare)); + let fetch = |sel: String| async move { + let mut rows = run(s, &sel).await; + if e == Engine::Cassandra { + rows.sort_by_key(|r| r[0].as_deref().and_then(|k| k.parse::().ok())); + } + rows + }; + let original = fetch(select.clone()).await; assert_eq!(original.len(), 3); let cancel = AtomicBool::new(false); @@ -168,7 +207,11 @@ pub async fn check(s: &Session, schema: &str) { .await .unwrap_or_else(|err| panic!("{err}\n{stmt}\n\nscript:\n{script}")); } - assert_eq!(run(s, &select).await, original, "SQL round trip:\n{script}"); + assert_eq!( + fetch(select.clone()).await, + original, + "SQL round trip:\n{script}" + ); let _ = std::fs::remove_file(&path); // CSV and JSON: delete the rows, import the file. @@ -203,17 +246,20 @@ pub async fn check(s: &Session, schema: &str) { ) }); assert_eq!(summary.rows, 3, "{name}"); - assert_eq!(run(s, &select).await, original, "{name} round trip"); + assert_eq!(fetch(select.clone()).await, original, "{name} round trip"); let _ = std::fs::remove_file(&path); } - // Identity generators moved past the imported values. - let name_col = super::sql::ident(e, &up("name")); - run( - s, - &format!("INSERT INTO {target} ({name_col}) VALUES ('after')"), - ) - .await; + // Identity generators moved past the imported values (CQL has none: its + // keys are always given). + if e != Engine::Cassandra { + let name_col = super::sql::ident(e, &up("name")); + run( + s, + &format!("INSERT INTO {target} ({name_col}) VALUES ('after')"), + ) + .await; + } run(s, &format!("DROP TABLE {target}")).await; } @@ -223,6 +269,63 @@ fn dialect(e: Engine) -> &'static str { Engine::Oracle => "oracle", Engine::Mysql | Engine::Mariadb => "mysql", Engine::Sqlserver => "sqlserver", - _ => "sqlite", + Engine::Cassandra => "cassandra", + Engine::Sqlite | Engine::Redis => "sqlite", + } +} + +fn env(name: &str) -> Option { + std::env::var(name).ok().filter(|v| !v.is_empty()) +} + +#[tokio::test] +#[ignore] +async fn cassandra_export_import_round_trip() { + let Some(host) = env("LQ_CASSANDRA_HOST") else { + return; + }; + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let profile = ConnectionProfile { + engine: Engine::Cassandra, + host, + port: env("LQ_CASSANDRA_PORT") + .and_then(|p| p.parse().ok()) + .unwrap_or(9042), + user: env("LQ_CASSANDRA_USER").unwrap_or_default(), + datacenter: env("LQ_CASSANDRA_DC").unwrap_or_default(), + ssl_mode: SslMode::Disable, + ..Default::default() + }; + let password = env("LQ_CASSANDRA_PASSWORD"); + let s = Session::open(&profile, password.as_deref()) + .await + .expect("connection"); + let keyspace = "lq_round_trip"; + let _ = s + .execute(&ExecRequest { + sql: format!("DROP KEYSPACE IF EXISTS {keyspace}"), + max_rows: 0, + }) + .await; + run( + &s, + &format!( + "CREATE KEYSPACE {keyspace} WITH replication = \ + {{'class': 'SimpleStrategy', 'replication_factor': 1}}" + ), + ) + .await; + // Also when a check fails: the keyspace must not outlive the test. + let outcome = std::panic::AssertUnwindSafe(check(&s, keyspace)) + .catch_unwind() + .await; + let _ = s + .execute(&ExecRequest { + sql: format!("DROP KEYSPACE IF EXISTS {keyspace}"), + max_rows: 0, + }) + .await; + if let Err(panic) = outcome { + std::panic::resume_unwind(panic); } } diff --git a/src-tauri/src/transfer/sql.rs b/src-tauri/src/transfer/sql.rs index 866b6ee..2d4d438 100644 --- a/src-tauri/src/transfer/sql.rs +++ b/src-tauri/src/transfer/sql.rs @@ -2,12 +2,18 @@ //! statements in each database's syntax. Values arrive as the drivers format //! them (see the `cell` functions), and every literal reads back as the same //! value in the same database. +//! +//! CQL (Cassandra) literals depend on the column's type, not only its kind +//! (a uuid is bare, text is quoted, both are [`ValueKind::Text`]), so they +//! come from [`literal_typed`], which hands the type to the driver's +//! `cql_literal`. use std::io::{self, Write}; use crate::model::{Engine, ValueKind}; -/// Always-quoted identifier: exports keep names exactly as the catalog has them. +/// Always-quoted identifier: exports keep names exactly as the catalog has them +/// (CQL too: `"` doubled, so case-sensitive names survive). pub fn ident(engine: Engine, name: &str) -> String { match engine { Engine::Mysql | Engine::Mariadb => format!("`{}`", name.replace('`', "``")), @@ -53,6 +59,43 @@ pub fn kind_of_type(type_name: &str) -> ValueKind { } } +/// The kind of value a column holds, from its CQL type as `system_schema` +/// writes it (`int`, `frozen>`, `vector`...): +/// collections, tuples, UDTs and vectors are [`ValueKind::Other`]. +pub fn kind_of_cql_type(type_name: &str) -> ValueKind { + match cql_native_type(type_name).as_str() { + "tinyint" | "smallint" | "int" | "bigint" | "varint" | "decimal" | "float" | "double" + | "counter" => ValueKind::Number, + "boolean" => ValueKind::Bool, + "timestamp" | "date" | "time" | "duration" => ValueKind::Temporal, + "blob" => ValueKind::Binary, + "text" | "varchar" | "ascii" | "uuid" | "timeuuid" | "inet" => ValueKind::Text, + _ => ValueKind::Other, + } +} + +/// The kind of a column of `engine` from its declared type. +pub fn column_kind(engine: Engine, type_name: &str) -> ValueKind { + if engine == Engine::Cassandra { + kind_of_cql_type(type_name) + } else { + kind_of_type(type_name) + } +} + +/// A CQL type name lowercased and without `frozen<...>` wrappers; what is +/// left is a native type name (`int`) or a collection, tuple, vector or UDT. +fn cql_native_type(type_name: &str) -> String { + let mut t = type_name.trim().to_ascii_lowercase(); + while let Some(inner) = t + .strip_prefix("frozen<") + .and_then(|rest| rest.strip_suffix('>')) + { + t = inner.trim().to_string(); + } + t +} + fn is_number(v: &str) -> bool { let v = v.strip_prefix(['-', '+']).unwrap_or(v); let (mantissa, exponent) = match v.find(['e', 'E']) { @@ -123,8 +166,20 @@ fn string_literal(engine: Engine, v: &str) -> String { } } -/// A value as a literal of `engine`, for a column of `kind`. +/// A value as a literal of `engine`, for a column of `kind`. CQL columns +/// should go through [`literal_typed`]; here a CQL value is written as the +/// plain CQL type of its kind (`Other` cannot be told apart: it is quoted). pub fn literal(engine: Engine, kind: ValueKind, value: Option<&str>) -> String { + if engine == Engine::Cassandra { + let ty = match kind { + ValueKind::Number => "decimal", + ValueKind::Bool => "boolean", + ValueKind::Binary => "blob", + ValueKind::Temporal => "timestamp", + ValueKind::Text | ValueKind::Json | ValueKind::Other => "text", + }; + return literal_typed(engine, kind, ty, value); + } let Some(v) = value else { return "NULL".into(); }; @@ -159,6 +214,25 @@ pub fn literal(engine: Engine, kind: ValueKind, value: Option<&str>) -> String { } } +/// A value as a literal of `engine`, for a column of `kind` and declared +/// type `type_name` (`DbColumn.type_name`, the same text as +/// `ColumnInfo.type_name`). Only CQL reads the type: numbers, booleans, +/// uuids, blobs and durations are bare, text and temporal values quoted, and +/// collections, tuples, UDTs and vectors (whose cell text is already a CQL +/// literal) are kept as they are; the driver's `cql_literal` decides. +pub fn literal_typed( + engine: Engine, + kind: ValueKind, + type_name: &str, + value: Option<&str>, +) -> String { + if engine == Engine::Cassandra { + crate::drivers::cassandra::cql_literal(type_name, value) + } else { + literal(engine, kind, value) + } +} + /// Where rows go: the table and the columns written, with their kinds. pub struct InsertTarget<'a> { pub engine: Engine, @@ -167,6 +241,12 @@ pub struct InsertTarget<'a> { /// Quoted column names. pub columns: &'a [String], pub kinds: &'a [ValueKind], + /// Declared types of the columns, as `DbColumn.type_name`. Only CQL reads + /// them; other engines may pass an empty slice. + pub types: &'a [String], + /// Whether each column is part of the primary key. Only CQL reads it (to + /// address counter updates); may be empty. + pub keys: &'a [bool], } /// Rows per INSERT where the engine takes several (SQL Server allows 1000). @@ -199,15 +279,21 @@ pub fn insert_statements(t: &InsertTarget<'_>, rows: &[Vec>]) -> "" } ); + let ty = |i: usize| t.types.get(i).map_or("", String::as_str); + if t.engine == Engine::Cassandra && (0..t.columns.len()).any(|i| is_counter(ty(i))) { + return counter_updates(t, rows); + } let tuple = |row: &Vec>| { let values: Vec = row .iter() .zip(t.kinds) - .map(|(v, k)| literal(t.engine, *k, v.as_deref())) + .enumerate() + .map(|(i, (v, k))| literal_typed(t.engine, *k, ty(i), v.as_deref())) .collect(); format!("({})", values.join(", ")) }; - let per = if t.engine == Engine::Oracle { + // CQL has no multi-row INSERT. + let per = if matches!(t.engine, Engine::Oracle | Engine::Cassandra) { 1 } else { ROWS_PER_INSERT @@ -224,6 +310,64 @@ pub fn insert_statements(t: &InsertTarget<'_>, rows: &[Vec>]) -> .collect() } +fn is_counter(type_name: &str) -> bool { + cql_native_type(type_name) == "counter" +} + +/// Rows of a CQL table with counter columns. Counters cannot be INSERTed, so +/// each row becomes `UPDATE t SET c = c + n WHERE key = ...`: loaded into an +/// empty table (a fresh export script, or an import that deleted the rows) +/// the counters hold the exported values, and on a table that has rows the +/// values are added to them. A row without a counter value has nothing to +/// add and is left out. With no key columns known no UPDATE can address the +/// row, so INSERTs are written and the server refuses them with its message. +fn counter_updates(t: &InsertTarget<'_>, rows: &[Vec>]) -> Vec { + let ty = |i: usize| t.types.get(i).map_or("", String::as_str); + let key = |i: usize| t.keys.get(i).copied().unwrap_or(false); + let n = t.columns.len(); + let lit = |row: &[Option], i: usize| { + let kind = t.kinds.get(i).copied().unwrap_or(ValueKind::Other); + literal_typed(t.engine, kind, ty(i), row.get(i).and_then(|v| v.as_deref())) + }; + if !(0..n).any(key) { + let head = format!("INSERT INTO {} ({})", t.table, t.columns.join(", ")); + return rows + .iter() + .map(|row| { + let values: Vec = (0..n).map(|i| lit(row, i)).collect(); + format!("{head} VALUES ({})", values.join(", ")) + }) + .collect(); + } + rows.iter() + .filter_map(|row| { + let sets: Vec = (0..n) + .filter(|&i| is_counter(ty(i)) && row.get(i).is_some_and(Option::is_some)) + .map(|i| { + let (c, v) = (&t.columns[i], lit(row, i)); + match v.strip_prefix('-') { + Some(abs) => format!("{c} = {c} - {abs}"), + None => format!("{c} = {c} + {v}"), + } + }) + .collect(); + if sets.is_empty() { + return None; + } + let wheres: Vec = (0..n) + .filter(|&i| key(i)) + .map(|i| format!("{} = {}", t.columns[i], lit(row, i))) + .collect(); + Some(format!( + "UPDATE {} SET {} WHERE {}", + t.table, + sets.join(", "), + wheres.join(" AND ") + )) + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -347,6 +491,8 @@ mod tests { table: "\"s\".\"t\"", columns: &cols, kinds: &kinds, + types: &[], + keys: &[], }; assert_eq!( insert_statements(&t, &rows), @@ -358,4 +504,221 @@ mod tests { }; assert_eq!(insert_statements(&t, &rows).len(), 2); } + + fn cql(kind: ValueKind, ty: &str, v: &str) -> String { + literal_typed(Engine::Cassandra, kind, ty, Some(v)) + } + + #[test] + fn cql_identifiers_and_text() { + assert_eq!(ident(Engine::Cassandra, "My\"Tbl"), "\"My\"\"Tbl\""); + assert_eq!(qualified(Engine::Cassandra, "ks", "t"), "\"ks\".\"t\""); + assert_eq!(text(Engine::Cassandra, "it's"), "'it''s'"); + } + + #[test] + fn cql_numbers_and_booleans_are_bare() { + use ValueKind::{Bool, Number}; + assert_eq!(cql(Number, "int", "42"), "42"); + assert_eq!( + cql(Number, "varint", "-123456789012345678901234567890"), + "-123456789012345678901234567890" + ); + assert_eq!( + cql(Number, "decimal", "1234567890.0987654321"), + "1234567890.0987654321" + ); + assert_eq!(cql(Number, "double", "1.5e-10"), "1.5e-10"); + assert_eq!(cql(Number, "double", "NaN"), "NaN"); + assert_eq!(cql(Number, "float", "-Infinity"), "-Infinity"); + assert_eq!(cql(Number, "counter", "7"), "7"); + assert_eq!(cql(Bool, "boolean", "true"), "true"); + assert_eq!(cql(Bool, "boolean", "false"), "false"); + assert_eq!( + literal_typed(Engine::Cassandra, ValueKind::Text, "text", None), + "null" + ); + } + + #[test] + fn cql_uuids_blobs_and_durations_are_bare() { + use ValueKind::{Binary, Temporal, Text}; + let id = "123e4567-e89b-12d3-a456-426614174000"; + assert_eq!(cql(Text, "uuid", id), id); + assert_eq!(cql(Text, "timeuuid", id), id); + // Same kind, but a text column: quoted. + assert_eq!(cql(Text, "text", id), format!("'{id}'")); + assert_eq!(cql(Binary, "blob", "0xDEADBEEF"), "0xDEADBEEF"); + assert_eq!(cql(Binary, "blob", "0x"), "0x"); + assert_eq!( + cql(Temporal, "duration", "1y2mo3d4h5m6s7ms8us9ns"), + "1y2mo3d4h5m6s7ms8us9ns" + ); + } + + #[test] + fn cql_text_and_temporal_are_quoted() { + use ValueKind::{Temporal, Text}; + assert_eq!( + cql(Text, "text", "it's \\ a \"x\"\nline"), + "'it''s \\ a \"x\"\nline'" + ); + assert_eq!(cql(Text, "ascii", "abc"), "'abc'"); + assert_eq!(cql(Text, "varchar", "Ação ☃"), "'Ação ☃'"); + assert_eq!(cql(Text, "inet", "10.0.0.1"), "'10.0.0.1'"); + assert_eq!( + cql(Temporal, "timestamp", "2024-05-06 07:08:09.123+0000"), + "'2024-05-06 07:08:09.123+0000'" + ); + assert_eq!(cql(Temporal, "date", "2024-05-06"), "'2024-05-06'"); + assert_eq!( + cql(Temporal, "time", "07:08:09.123456789"), + "'07:08:09.123456789'" + ); + // Without a type the kind decides. + assert_eq!(literal(Engine::Cassandra, Text, Some("a'b")), "'a''b'"); + assert_eq!( + literal(Engine::Cassandra, ValueKind::Number, Some("5")), + "5" + ); + assert_eq!( + literal(Engine::Cassandra, ValueKind::Bool, Some("true")), + "true" + ); + assert_eq!(literal(Engine::Cassandra, Temporal, None), "null"); + } + + #[test] + fn cql_collections_tuples_udts_and_vectors_are_verbatim() { + for (ty, v) in [ + ("list", "[1, 2]"), + ("set", "{'a', 'it''s ; here'}"), + ("map", "{'a': 1, 'b': 2}"), + ( + "frozen>>>", + "{'a': [1], 'b': []}", + ), + ("tuple", "(1, 'x')"), + ("frozen
", "{street: 'Main', zip: 12345}"), + ("vector", "[0.1, 0.2, 0.3]"), + ] { + assert_eq!(cql(ValueKind::Other, ty, v), v, "{ty}"); + } + } + + #[test] + fn cql_types_map_to_kinds() { + assert_eq!(kind_of_cql_type("int"), ValueKind::Number); + assert_eq!(kind_of_cql_type("varint"), ValueKind::Number); + assert_eq!(kind_of_cql_type("counter"), ValueKind::Number); + assert_eq!(kind_of_cql_type("boolean"), ValueKind::Bool); + assert_eq!(kind_of_cql_type("timestamp"), ValueKind::Temporal); + assert_eq!(kind_of_cql_type("duration"), ValueKind::Temporal); + assert_eq!(kind_of_cql_type("blob"), ValueKind::Binary); + assert_eq!(kind_of_cql_type("uuid"), ValueKind::Text); + assert_eq!(kind_of_cql_type("inet"), ValueKind::Text); + // `kind_of_type` would call these numbers and dates. + assert_eq!(kind_of_cql_type("list"), ValueKind::Other); + assert_eq!( + kind_of_cql_type("frozen>"), + ValueKind::Other + ); + assert_eq!(kind_of_cql_type("tuple"), ValueKind::Other); + assert_eq!(kind_of_cql_type("vector"), ValueKind::Other); + assert_eq!(kind_of_cql_type("frozen
"), ValueKind::Other); + assert_eq!( + column_kind(Engine::Cassandra, "list"), + ValueKind::Other + ); + assert_eq!(column_kind(Engine::Postgres, "integer"), ValueKind::Number); + } + + #[test] + fn cql_inserts_are_one_row_each() { + let cols = vec![ + "\"id\"".to_string(), + "\"name\"".to_string(), + "\"tags\"".to_string(), + ]; + let kinds = [ValueKind::Text, ValueKind::Text, ValueKind::Other]; + let types = [ + "uuid".to_string(), + "text".to_string(), + "set".to_string(), + ]; + let rows = vec![ + vec![ + Some("123e4567-e89b-12d3-a456-426614174000".into()), + Some("it's".into()), + Some("{'a', 'b'}".into()), + ], + vec![ + Some("123e4567-e89b-12d3-a456-426614174001".into()), + None, + None, + ], + ]; + let t = InsertTarget { + engine: Engine::Cassandra, + table: "\"ks\".\"t\"", + columns: &cols, + kinds: &kinds, + types: &types, + keys: &[true, false, false], + }; + assert_eq!( + insert_statements(&t, &rows), + [ + "INSERT INTO \"ks\".\"t\" (\"id\", \"name\", \"tags\") VALUES (123e4567-e89b-12d3-a456-426614174000, 'it''s', {'a', 'b'})", + "INSERT INTO \"ks\".\"t\" (\"id\", \"name\", \"tags\") VALUES (123e4567-e89b-12d3-a456-426614174001, null, null)", + ] + ); + } + + #[test] + fn cql_counter_tables_are_written_as_updates() { + let cols = vec![ + "\"id\"".to_string(), + "\"seq\"".to_string(), + "\"hits\"".to_string(), + "\"misses\"".to_string(), + ]; + let kinds = [ + ValueKind::Text, + ValueKind::Number, + ValueKind::Number, + ValueKind::Number, + ]; + let types = [ + "text".to_string(), + "int".to_string(), + "counter".to_string(), + "counter".to_string(), + ]; + let rows = vec![ + vec![ + Some("a'b".into()), + Some("1".into()), + Some("5".into()), + Some("-2".into()), + ], + vec![Some("c".into()), Some("2".into()), Some("9".into()), None], + vec![Some("d".into()), Some("3".into()), None, None], + ]; + let t = InsertTarget { + engine: Engine::Cassandra, + table: "\"ks\".\"c\"", + columns: &cols, + kinds: &kinds, + types: &types, + keys: &[true, true, false, false], + }; + assert_eq!( + insert_statements(&t, &rows), + [ + "UPDATE \"ks\".\"c\" SET \"hits\" = \"hits\" + 5, \"misses\" = \"misses\" - 2 WHERE \"id\" = 'a''b' AND \"seq\" = 1", + "UPDATE \"ks\".\"c\" SET \"hits\" = \"hits\" + 9 WHERE \"id\" = 'c' AND \"seq\" = 2", + ] + ); + } } diff --git a/src/App.svelte b/src/App.svelte index 329e415..e253057 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -11,6 +11,7 @@ import { ai } from "./lib/state/ai.svelte"; import { transfer } from "./lib/state/transfer.svelte"; import { emptyProfile } from "./lib/api"; + import { engineInfo } from "./lib/engines"; import Sidebar from "./lib/components/Sidebar.svelte"; import TabBar from "./lib/components/TabBar.svelte"; import QueryView from "./lib/components/QueryView.svelte"; @@ -88,6 +89,7 @@ const tab = workspace.activeTab; const profile = workspace.profile(tab?.profileId ?? null); const connected = !!tab?.session; + const transactions = !profile || !engineInfo(profile.engine).noTransactions; return [ appMenu("file", "File", [ { label: "New query", shortcut: "Ctrl+T", action: () => workspace.newTab(tab?.profileId ?? null) }, @@ -104,10 +106,10 @@ { label: "Run statement", shortcut: "Ctrl+Enter", disabled: !tab || !profile || tab.running, action: () => tab && workspace.run(tab, "statement") }, { label: "Run script", shortcut: "F5", disabled: !tab || !profile || tab.running, action: () => tab && workspace.run(tab, "script") }, { label: "Stop", disabled: !tab?.running, action: () => tab && workspace.cancel(tab) }, - SEPARATOR, - { label: "Commit", disabled: !tab?.inTransaction, action: () => tab && workspace.endTransaction(tab, true) }, - { label: "Rollback", disabled: !tab?.inTransaction, action: () => tab && workspace.endTransaction(tab, false) }, - { label: "Auto-commit", checked: tab?.autocommit ?? true, disabled: !connected, action: () => tab && workspace.setAutocommit(tab, !tab.autocommit) }, + transactions && SEPARATOR, + transactions && { label: "Commit", disabled: !tab?.inTransaction, action: () => tab && workspace.endTransaction(tab, true) }, + transactions && { label: "Rollback", disabled: !tab?.inTransaction, action: () => tab && workspace.endTransaction(tab, false) }, + transactions && { label: "Auto-commit", checked: tab?.autocommit ?? true, disabled: !connected, action: () => tab && workspace.setAutocommit(tab, !tab.autocommit) }, ]), appMenu("view", "View", [ { label: "Dark theme", checked: colorScheme.isDark, action: () => colorScheme.toggle() }, diff --git a/src/lib/api.ts b/src/lib/api.ts index ad29b96..6d6059f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -38,6 +38,8 @@ export interface ConnectionProfile { windowsAuth: boolean; /** SQLite: create the file when it does not exist. */ createFile: boolean; + /** Cassandra: local datacenter; empty means every node the cluster reports. */ + datacenter: string; /** What AI agents may do through `lowquery mcp`. */ aiAccess: AiAccess; } @@ -206,6 +208,8 @@ export interface DbColumn { primaryKey: boolean; /** Default as an SQL expression. */ defaultValue?: string | null; + /** Cassandra: "partition", "clustering", "static" or "regular"; null elsewhere. */ + keyRole?: "partition" | "clustering" | "static" | "regular" | null; } export type ExportFormat = "sql" | "csv" | "csv-excel" | "json"; @@ -303,6 +307,7 @@ const DEFAULTS: Partial> = { mariadb: { database: "", user: "root" }, sqlserver: { database: "master", user: "sa" }, redis: { database: "0", user: "" }, + cassandra: { database: "", user: "" }, }; export function emptyProfile(engine: Engine = "postgres"): ConnectionProfile { @@ -316,7 +321,7 @@ export function emptyProfile(engine: Engine = "postgres"): ConnectionProfile { database: DEFAULTS[engine]?.database ?? "", user: DEFAULTS[engine]?.user ?? "", savePassword: true, - sslMode: "prefer", + sslMode: engine === "cassandra" ? "disable" : "prefer", oracleConnectType: "service", connectString: "", tnsAlias: "", @@ -325,6 +330,7 @@ export function emptyProfile(engine: Engine = "postgres"): ConnectionProfile { instance: "", windowsAuth: false, createFile: false, + datacenter: "", aiAccess: "off", }; } diff --git a/src/lib/components/ConnectionDialog.svelte b/src/lib/components/ConnectionDialog.svelte index b416bc6..61c17c9 100644 --- a/src/lib/components/ConnectionDialog.svelte +++ b/src/lib/components/ConnectionDialog.svelte @@ -394,7 +394,18 @@ /> {/if} - + {#if form.engine === "cassandra"} + + {:else} + + {/if} {#if form.engine === "sqlserver"} + {#if form.engine === "cassandra"} + + {/if} {/if} {#if form.engine !== "oracle"} - (row.nullable = !e.currentTarget.checked)} /> + (row.nullable = !e.currentTarget.checked)} /> - + move(i, -1)}> move(i, 1)}> @@ -191,7 +197,9 @@ Add column - {#if !creating} + {#if cql} +

Columns ticked as primary key form the key in the order shown: the first is the partition key, the rest are clustering columns. A table cannot be renamed, and its key and column types cannot change.

+ {:else if !creating}

Defaults are SQL expressions: quote text values ('active'). Moving columns does not reorder an existing table.

{/if} diff --git a/src/lib/components/TreeMenu.svelte b/src/lib/components/TreeMenu.svelte index 082161f..adcc2a1 100644 --- a/src/lib/components/TreeMenu.svelte +++ b/src/lib/components/TreeMenu.svelte @@ -58,7 +58,8 @@ let { oninsert, onnavigate }: Props = $props(); - const TEMPLATE_ICON = { view: Eye, function: SquareFunction, procedure: Cog, sequence: Hash } as const; + const TEMPLATE_ICON = { view: Eye, function: SquareFunction, procedure: Cog, sequence: Hash, type: Shapes, aggregate: Sigma } as const; + const SYSTEM_KEYSPACES = new Set(["system", "system_schema", "system_auth", "system_distributed", "system_traces", "system_views", "system_virtual_schema"]); function go(fn: () => unknown) { fn(); @@ -159,7 +160,7 @@ {:else if t.type === "schema"} {@const s = t.schema} {@const word = schemaWord(e)} - {@const builtIn = e === "sqlite" && (s.name === "main" || s.name === "temp")} + {@const builtIn = (e === "sqlite" && (s.name === "main" || s.name === "temp")) || (e === "cassandra" && SYSTEM_KEYSPACES.has(s.name))} {s.name} go(() => act.newQueryIn(p, s))}> {#snippet icon()}{/snippet} @@ -186,7 +187,7 @@ {@const Icon = TEMPLATE_ICON[kind]} go(() => act.template(p, s, kind))}> {#snippet icon()}{/snippet} - {kind.charAt(0).toUpperCase() + kind.slice(1)} + {e === "cassandra" && kind === "view" ? "Materialized view" : kind.charAt(0).toUpperCase() + kind.slice(1)} {/each} @@ -227,6 +228,7 @@ {@const o = t.obj} {@const relation = RELATION_KINDS.has(o.kind)} {@const table = o.kind === "table"} + {@const cql = e === "cassandra"} {o.name} {#if REDIS_KINDS.has(o.kind)} go(() => act.preview(p, s, o))}> @@ -280,7 +282,7 @@ Show DDL {/if} - {#if relation || hasDdl(o.kind, e)} + {#if relation || (hasDdl(o.kind, e) && !cql)} transfer.openExport(p, s.name, { name: o.name, kind: o.kind })}> {#snippet icon()}{/snippet} Export… @@ -321,14 +323,16 @@ {#snippet icon()}{/snippet} Edit table… - act.renameTable(p, s, o)}> - {#snippet icon()}{/snippet} - Rename… - - act.duplicateTable(p, s, o)}> - {#snippet icon()}{/snippet} - Duplicate… - + {#if !cql} + act.renameTable(p, s, o)}> + {#snippet icon()}{/snippet} + Rename… + + act.duplicateTable(p, s, o)}> + {#snippet icon()}{/snippet} + Duplicate… + + {/if} act.truncate(p, s, o)}> {#snippet icon()}{/snippet} Empty table… diff --git a/src/lib/dev-mock.ts b/src/lib/dev-mock.ts index d3e5054..e38c1b2 100644 --- a/src/lib/dev-mock.ts +++ b/src/lib/dev-mock.ts @@ -30,6 +30,15 @@ const profiles: ConnectionProfile[] = [ windowsAuth: true, }, { ...emptyProfile("sqlite"), id: "lite1", name: "Local cache", database: String.raw`C:\data\cache.db` }, + { + ...emptyProfile("cassandra"), + id: "cass1", + name: "Events cluster", + host: "cass-1.internal,cass-2.internal", + database: "shop", + user: "app", + datacenter: "dc1", + }, ]; // tnsnames.ora entries as the backend reports them (see src-tauri/src/drivers/tns.rs). @@ -149,6 +158,130 @@ const TABLE_COLUMNS: Record = { ], }; +// ---- Cassandra --------------------------------------------------------------- + +const CQL_KEYSPACES = ["shop", "system_auth", "system_schema", "system", "system_distributed", "system_traces", "system_views", "system_virtual_schema"]; + +const CQL_OBJECTS = [ + { name: "events", kind: "table" }, + { name: "users_by_email", kind: "table" }, + { name: "events_by_kind", kind: "matview" }, + { name: "address", kind: "type" }, + { name: "lq_double(int)", kind: "function" }, + { name: "lq_total", kind: "aggregate" }, + { name: "events_kind_idx", kind: "index" }, +]; + +const cqlCol = (name: string, typeName: string, keyRole: NonNullable): DbColumn => ({ + name, + typeName, + nullable: keyRole === "regular" || keyRole === "static", + primaryKey: keyRole === "partition" || keyRole === "clustering", + defaultValue: null, + keyRole, +}); + +const CQL_EVENT_COLUMNS: DbColumn[] = [ + cqlCol("tenant", "text", "partition"), + cqlCol("day", "date", "partition"), + cqlCol("ts", "timestamp", "clustering"), + cqlCol("id", "uuid", "clustering"), + cqlCol("tenant_name", "text", "static"), + cqlCol("kind", "text", "regular"), + cqlCol("tags", "set", "regular"), + cqlCol("attrs", "map", "regular"), + cqlCol("payload", "blob", "regular"), +]; + +const CQL_COLUMNS: Record = { + events: CQL_EVENT_COLUMNS, + events_by_kind: CQL_EVENT_COLUMNS.map((c) => ({ ...c, keyRole: c.primaryKey ? c.keyRole : "regular" as const })), + users_by_email: [ + cqlCol("email", "text", "partition"), + cqlCol("name", "text", "regular"), + cqlCol("address", "frozen
", "regular"), + cqlCol("embedding", "vector", "regular"), + ], +}; + +const CQL_EVENTS_DDL = `CREATE TABLE shop.events ( + tenant text, + day date, + ts timestamp, + id uuid, + tenant_name text STATIC, + kind text, + tags set, + attrs map, + payload blob, + PRIMARY KEY ((tenant, day), ts, id) +) WITH CLUSTERING ORDER BY (ts DESC, id ASC) + AND comment = '' + AND default_time_to_live = 0; + +CREATE INDEX events_kind_idx ON shop.events (kind); +`; + +const CQL_KINDS = ["login", "logout", "purchase", "refund", "view"]; +const CQL_EVENTS: (string | null)[][] = Array.from({ length: 240 }, (_, i) => { + const ts = new Date(Date.UTC(2024, 4, 6, 7, 8, 9, 123) - i * 61_000); + const hex = (n: number, len: number) => Math.floor(rand(n) * 16 ** len).toString(16).padStart(len, "0"); + return [ + i % 3 === 0 ? "acme" : "globex", + ts.toISOString().slice(0, 10), + `${ts.toISOString().slice(0, 19).replace("T", " ")}.${String(ts.getUTCMilliseconds()).padStart(3, "0")}+0000`, + `${hex(i, 8)}-${hex(i + 1, 4)}-4${hex(i + 2, 3)}-a${hex(i + 3, 3)}-${hex(i + 4, 12)}`, + i % 3 === 0 ? "Acme Corp" : "Globex", + CQL_KINDS[i % CQL_KINDS.length], + i % 4 === 0 ? "{'mobile', 'web'}" : i % 4 === 1 ? "{'web'}" : null, + `{'items': ${(i % 5) + 1}, 'retries': ${i % 2}}`, + i % 7 === 0 ? `0x${hex(i + 9, 8)}` : null, + ]; +}); + +const CQL_TARGET: EditTarget = { + schema: "shop", + table: "events", + columns: CQL_EVENT_COLUMNS.map((c) => ({ name: c.name, typeName: c.typeName })), + key: [0, 1, 2, 3], +}; + +const CQL_EVENT_RESULT_COLUMNS = [ + { name: "tenant", typeName: "text", kind: "text" }, + { name: "day", typeName: "date", kind: "temporal" }, + { name: "ts", typeName: "timestamp", kind: "temporal" }, + { name: "id", typeName: "uuid", kind: "text" }, + { name: "tenant_name", typeName: "text", kind: "text" }, + { name: "kind", typeName: "text", kind: "text" }, + { name: "tags", typeName: "set", kind: "other" }, + { name: "attrs", typeName: "map", kind: "other" }, + { name: "payload", typeName: "blob", kind: "binary" }, +] satisfies ExecResult["columns"]; + +function executeCql(sql: string, maxRows: number): ExecResult { + const s = sql.trim().toLowerCase(); + const base = { truncated: false, rowsAffected: null, notices: [], inTransaction: false, elapsedMs: 8, editable: null, readOnlyReason: null }; + if (s.startsWith("select")) { + const limit = /\blimit\s+(\d+)/.exec(s); + const rows = limit ? CQL_EVENTS.slice(0, Number(limit[1])) : CQL_EVENTS; + const events = /\bfrom\s+(shop\.)?events\b/.test(s); + if (!events) { + return { ...base, columns: [{ name: "count", typeName: "bigint", kind: "number" }], rows: [[String(CQL_EVENTS.length)]] }; + } + return { + ...base, + truncated: maxRows > 0 && rows.length > maxRows, + editable: CQL_TARGET, + columns: CQL_EVENT_RESULT_COLUMNS, + rows: maxRows > 0 ? rows.slice(0, maxRows) : rows, + }; + } + if (s.startsWith("insert") || s.startsWith("update") || s.startsWith("delete") || s.startsWith("begin")) { + return { ...base, columns: null, rows: [] }; + } + return { ...base, columns: null, rows: [] }; +} + function execute(sql: string, maxRows: number): ExecResult { const s = sql.trim().toLowerCase(); const base = { @@ -404,6 +537,8 @@ function IMPORT_PREVIEW(delimiter: string, header: boolean) { export function installDevMock() { let n = 0; + const sessionEngines = new Map(); + const isCql = (args: Record) => sessionEngines.get(args.sessionId) === "cassandra"; mockIPC(async (cmd, payload) => { const args = (payload ?? {}) as Record; await new Promise((r) => setTimeout(r, 120)); @@ -428,20 +563,25 @@ export function installDevMock() { return { serverVersion: "PostgreSQL 17.2" }; case "open_session": { const p = profiles.find((x) => x.id === args.profileId)!; + const sessionId = `s${++n}`; + sessionEngines.set(sessionId, p.engine); return { - sessionId: `s${++n}`, + sessionId, profileId: p.id, engine: p.engine, - serverVersion: p.engine === "postgres" ? "PostgreSQL 17.2" : "Oracle 23.6.0.24.10", + serverVersion: + p.engine === "postgres" ? "PostgreSQL 17.2" : p.engine === "cassandra" ? "Cassandra 5.0.2" : "Oracle 23.6.0.24.10", currentUser: p.user, - currentSchema: p.engine === "postgres" ? "public" : p.user, + currentSchema: p.engine === "postgres" ? "public" : p.engine === "cassandra" ? p.database || null : p.user, autocommit: true, }; } case "execute": - return execute(args.request.sql, args.request.maxRows); + return sessionEngines.get(args.sessionId) === "cassandra" + ? executeCql(args.request.sql, args.request.maxRows) + : execute(args.request.sql, args.request.maxRows); case "apply_changes": - return applyChanges(args.request); + return sessionEngines.get(args.sessionId) === "cassandra" ? { updated: 0, deleted: 0, inserted: 0, elapsedMs: 5, inTransaction: false } : applyChanges(args.request); case "ai_setup": return AI_SETUP; case "ai_connect_client": { @@ -469,8 +609,9 @@ export function installDevMock() { return { created: args.keys.length, passwords }; } case "list_schemas": - return ["public", "sales", "audit", "information_schema", "pg_catalog"]; + return isCql(args) ? CQL_KEYSPACES : ["public", "sales", "audit", "information_schema", "pg_catalog"]; case "list_objects": + if (isCql(args)) return args.schema === "shop" ? CQL_OBJECTS : []; return [ { name: "customers", kind: "table" }, { name: "orders", kind: "table" }, @@ -480,7 +621,7 @@ export function installDevMock() { { name: "recalc_balance(integer)", kind: "function" }, ]; case "list_columns": - return TABLE_COLUMNS[args.table] ?? CUSTOMER_COLUMNS; + return isCql(args) ? (CQL_COLUMNS[args.table] ?? []) : (TABLE_COLUMNS[args.table] ?? CUSTOMER_COLUMNS); case "plugin:dialog|save": return `C:/Users/demo/Documents/${args.options?.defaultPath ?? "export.sql"}`; case "plugin:dialog|open": { @@ -504,7 +645,7 @@ export function installDevMock() { case "read_sql_file": return "-- dump\nCREATE TABLE t (id int);\nINSERT INTO t VALUES (1);\nINSERT INTO t VALUES (2);\n"; case "object_ddl": - return args.name === "customers" ? CUSTOMERS_DDL : `-- ${args.kind} ${args.schema}.${args.name} + return isCql(args) && args.name === "events" ? CQL_EVENTS_DDL : args.name === "customers" ? CUSTOMERS_DDL : `-- ${args.kind} ${args.schema}.${args.name} CREATE ${args.kind.toUpperCase()} ${args.schema}.${args.name} ...; `; case "set_autocommit": diff --git a/src/lib/engines/index.ts b/src/lib/engines/index.ts index 02b0375..43b897d 100644 --- a/src/lib/engines/index.ts +++ b/src/lib/engines/index.ts @@ -1,6 +1,6 @@ -import { MARIADB_PATH, MYSQL_PATH, POSTGRESQL_PATH, REDIS_PATH, SQLITE_PATH } from "./logos"; +import { CASSANDRA_PATH, MARIADB_PATH, MYSQL_PATH, POSTGRESQL_PATH, REDIS_PATH, SQLITE_PATH } from "./logos"; -export type Engine = "postgres" | "oracle" | "mysql" | "mariadb" | "sqlserver" | "sqlite" | "redis"; +export type Engine = "postgres" | "oracle" | "mysql" | "mariadb" | "sqlserver" | "sqlite" | "redis" | "cassandra"; /** Lexical rules used to split scripts. */ export type { Dialect } from "../sql/split"; @@ -23,6 +23,8 @@ export interface EngineInfo { short: string; /** Key-value store: commands instead of SQL, keys instead of tables. */ keyValue?: boolean; + /** No transactions: the editor hides autocommit, Commit and Rollback. */ + noTransactions?: boolean; } export const ENGINES: EngineInfo[] = [ @@ -104,6 +106,18 @@ export const ENGINES: EngineInfo[] = [ short: "RDS", keyValue: true, }, + { + id: "cassandra", + label: "Apache Cassandra", + driver: "Native CQL protocol (scylla), Cassandra 3.x to 5.x", + color: "#1287B1", + logo: { path: CASSANDRA_PATH }, + defaultPort: 9042, + file: false, + dialect: "cassandra", + short: "CASS", + noTransactions: true, + }, ]; const BY_ID = new Map(ENGINES.map((e) => [e.id, e])); diff --git a/src/lib/engines/logos.ts b/src/lib/engines/logos.ts index df027e6..a4eb1ec 100644 --- a/src/lib/engines/logos.ts +++ b/src/lib/engines/logos.ts @@ -18,3 +18,10 @@ export const SQLITE_PATH = export const REDIS_PATH = "M22.71 13.145c-1.66 2.092-3.452 4.483-7.038 4.483-3.203 0-4.397-2.825-4.48-5.12.701 1.484 2.073 2.685 4.214 2.63 4.117-.133 6.94-3.852 6.94-7.239 0-4.05-3.022-6.972-8.268-6.972-3.752 0-8.4 1.428-11.455 3.685C2.59 6.937 3.885 9.958 4.35 9.626c2.648-1.904 4.748-3.13 6.784-3.744C8.12 9.244.886 17.05 0 18.425c.1 1.261 1.66 4.648 2.424 4.648.232 0 .431-.133.664-.365a100.49 100.49 0 0 0 5.54-6.765c.222 3.104 1.748 6.898 6.014 6.898 3.819 0 7.604-2.756 9.33-8.965.2-.764-.73-1.361-1.261-.73zm-4.349-5.013c0 1.959-1.926 2.922-3.685 2.922-.941 0-1.664-.247-2.235-.568 1.051-1.592 2.092-3.225 3.21-4.973 1.972.334 2.71 1.43 2.71 2.619z"; + +/** + * Cassandra's eye: a ring around a pupil with the lids drawn as two arcs. + * Drawn here as a simple monochrome mark; it is not Apache's official artwork. + */ +export const CASSANDRA_PATH = + "M12 3C6.2 3 1.8 7.2.5 12c1.3 4.8 5.7 9 11.5 9s10.2-4.2 11.5-9C22.2 7.2 17.8 3 12 3zm0 2.2c4.4 0 7.9 2.8 9.1 6.8-1.2 4-4.7 6.8-9.1 6.8S4.1 16 2.9 12C4.1 8 7.6 5.2 12 5.2zM12 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9zm0 2.2a2.3 2.3 0 1 1 0 4.6 2.3 2.3 0 0 1 0-4.6z"; diff --git a/src/lib/sql/ddl.fixtures.json b/src/lib/sql/ddl.fixtures.json index 0b8b455..3e0a565 100644 --- a/src/lib/sql/ddl.fixtures.json +++ b/src/lib/sql/ddl.fixtures.json @@ -1129,6 +1129,183 @@ "sql": "DROP TABLE lq_schema.lq_ddl_parent" } ] + }, + "cassandra": { + "schema": "lq_cql", + "cleanup": [ + "DROP INDEX lq_cql.ix_lq_ddl_items_name", + "DROP TABLE lq_cql.lq_ddl_items", + "DROP TYPE lq_cql.lq_ddl_addr", + "DROP KEYSPACE lq_ddl_schema" + ], + "steps": [ + { + "label": "create type", + "sql": "CREATE TYPE lq_cql.lq_ddl_addr (street text, zip int)" + }, + { + "label": "type DDL", + "ddl": { + "name": "lq_ddl_addr", + "kind": "type", + "contains": "lq_ddl_addr" + } + }, + { + "label": "create table", + "sql": "CREATE TABLE lq_cql.lq_ddl_items (\n tenant text,\n day int,\n seq int,\n name text,\n price decimal,\n note text,\n obsolete text,\n tags set,\n addr frozen,\n PRIMARY KEY (tenant, day, seq)\n)" + }, + { + "label": "create index", + "sql": "CREATE INDEX ix_lq_ddl_items_name ON lq_cql.lq_ddl_items (name)" + }, + { + "label": "insert rows (1)", + "sql": "INSERT INTO lq_cql.lq_ddl_items (tenant, day, seq, name, price, note, obsolete, tags, addr) VALUES ('a', 1, 1, 'a', 1.5, 'n1', 'z', {'x', 'y'}, {street: 'Main', zip: 12345})" + }, + { + "label": "insert rows (2)", + "sql": "INSERT INTO lq_cql.lq_ddl_items (tenant, day, seq, name, price, note, obsolete, tags, addr) VALUES ('a', 1, 2, 'b', 2.5, NULL, 'z', NULL, NULL)" + }, + { + "label": "collections and UDTs print as CQL literals", + "sql": "SELECT tags, addr FROM lq_cql.lq_ddl_items WHERE tenant = 'a' AND day = 1 AND seq = 1", + "rows": [ + "{'x', 'y'}" + ] + }, + { + "label": "secondary index lookup", + "sql": "SELECT seq FROM lq_cql.lq_ddl_items WHERE name = 'b'", + "rows": [ + "2" + ] + }, + { + "label": "alter table (1)", + "sql": "ALTER TABLE lq_cql.lq_ddl_items DROP obsolete" + }, + { + "label": "alter table (2)", + "sql": "ALTER TABLE lq_cql.lq_ddl_items RENAME seq TO pos" + }, + { + "label": "alter table (3)", + "sql": "ALTER TABLE lq_cql.lq_ddl_items ADD qty int" + }, + { + "label": "insert with the new columns", + "sql": "INSERT INTO lq_cql.lq_ddl_items (tenant, day, pos, qty) VALUES ('a', 2, 3, 7)" + }, + { + "label": "added column", + "sql": "SELECT qty FROM lq_cql.lq_ddl_items WHERE tenant = 'a' AND day = 2", + "rows": [ + "7" + ] + }, + { + "label": "renamed clustering column kept its values", + "sql": "SELECT pos FROM lq_cql.lq_ddl_items WHERE tenant = 'a' AND day = 1", + "rows": [ + "1", + "2" + ] + }, + { + "label": "rows", + "sql": "SELECT COUNT(*) FROM lq_cql.lq_ddl_items", + "rows": [ + "3" + ] + }, + { + "label": "index DDL", + "ddl": { + "name": "ix_lq_ddl_items_name", + "kind": "index", + "contains": "ix_lq_ddl_items_name" + } + }, + { + "label": "table DDL round trip", + "roundTrip": { + "name": "lq_ddl_items", + "kind": "table", + "drop": "DROP TABLE lq_cql.lq_ddl_items" + } + }, + { + "label": "recreated table is empty", + "sql": "SELECT COUNT(*) FROM lq_cql.lq_ddl_items", + "rows": [ + "0" + ] + }, + { + "label": "recreated index works", + "sql": "SELECT pos FROM lq_cql.lq_ddl_items WHERE name = 'b'", + "rows": [] + }, + { + "label": "recreated index DDL", + "ddl": { + "name": "ix_lq_ddl_items_name", + "kind": "index", + "contains": "ix_lq_ddl_items_name" + } + }, + { + "label": "fill again", + "sql": "INSERT INTO lq_cql.lq_ddl_items (tenant, day, pos, name) VALUES ('b', 1, 1, 'c')" + }, + { + "label": "filled", + "sql": "SELECT COUNT(*) FROM lq_cql.lq_ddl_items", + "rows": [ + "1" + ] + }, + { + "label": "truncate", + "sql": "TRUNCATE TABLE lq_cql.lq_ddl_items" + }, + { + "label": "truncated", + "sql": "SELECT COUNT(*) FROM lq_cql.lq_ddl_items", + "rows": [ + "0" + ] + }, + { + "label": "drop index", + "sql": "DROP INDEX lq_cql.ix_lq_ddl_items_name" + }, + { + "label": "drop table", + "sql": "DROP TABLE lq_cql.lq_ddl_items" + }, + { + "label": "drop type", + "sql": "DROP TYPE lq_cql.lq_ddl_addr" + }, + { + "label": "create keyspace", + "sql": "CREATE KEYSPACE lq_ddl_schema WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}" + }, + { + "label": "keyspace DDL", + "ddl": { + "name": "lq_ddl_schema", + "kind": "keyspace", + "contains": "lq_ddl_schema" + } + }, + { + "label": "drop keyspace", + "sql": "DROP KEYSPACE lq_ddl_schema" + } + ] } } } diff --git a/src/lib/sql/ddl.fixtures.test.ts b/src/lib/sql/ddl.fixtures.test.ts index 3868a6a..c20198a 100644 --- a/src/lib/sql/ddl.fixtures.test.ts +++ b/src/lib/sql/ddl.fixtures.test.ts @@ -6,8 +6,9 @@ * After changing `ddl.ts`, regenerate the file with * `LQ_UPDATE_FIXTURES=1 bun test src/lib/sql/ddl.fixtures.test.ts`. * - * `lq_schema` (`LQ_SCHEMA` in Oracle) stands for the test schema; the Rust - * side replaces it. + * `lq_schema` (`LQ_SCHEMA` in Oracle, `lq_cql` in Cassandra) stands for the + * test schema; the Rust side replaces it. The Cassandra keyspace must exist + * before the steps run; the fixtures create and drop only `lq_ddl_schema`. */ import { expect, it } from "bun:test"; import { readFileSync, writeFileSync } from "node:fs"; @@ -193,11 +194,103 @@ function build(d: Dialect) { return { schema, cleanup, steps }; } +/** + * Cassandra has no views fixtures, rename, duplicate, defaults or foreign + * keys, and user-defined functions are off by default, so it gets its own + * scenario: types, a table with collections and a UDT, clustering rename, + * ADD and DROP column, an index, TRUNCATE and keyspaces. + */ +function buildCassandra() { + const e: Engine = "cassandra"; + const schema = "lq_cql"; + const tbl = (s: string) => qualified(schema, s, e); + const q = (s: string) => quoteIdent(s, e); + const items = "lq_ddl_items"; + const index = "ix_lq_ddl_items_name"; + const steps: Step[] = []; + const run = (label: string, sqls: string[]) => sqls.forEach((sql, i) => steps.push({ label: sqls.length > 1 ? `${label} (${i + 1})` : label, sql })); + const check = (label: string, sql: string, rows: (string | number)[]) => steps.push({ label, sql, rows: rows.map(String) }); + + const cleanup = [ + ...dropObjectSql(schema, index, "index", e), + ...dropObjectSql(schema, items, "table", e), + ...dropObjectSql(schema, "lq_ddl_addr", "type", e), + ...dropSchemaSql("lq_ddl_schema", e), + ]; + + run("create type", [`CREATE TYPE ${tbl("lq_ddl_addr")} (street text, zip int)`]); + steps.push({ label: "type DDL", ddl: { name: "lq_ddl_addr", kind: "type", contains: "lq_ddl_addr" } }); + + const before: ColumnSpec[] = [ + col("tenant", "text", { primaryKey: true, keyRole: "partition" }), + col("day", "int", { primaryKey: true, keyRole: "clustering" }), + col("seq", "int", { primaryKey: true, keyRole: "clustering" }), + col("name", "text", { keyRole: "regular" }), + col("price", "decimal", { keyRole: "regular" }), + col("note", "text", { keyRole: "regular" }), + col("obsolete", "text", { keyRole: "regular" }), + col("tags", "set", { keyRole: "regular" }), + col("addr", "frozen", { keyRole: "regular" }), + ]; + const created = createTableSql(schema, items, before, e); + run("create table", created.statements); + run("create index", [createIndexSql(schema, items, "name", e)]); + const insert = (seq: number, name: string, note: string, tags: string, addr: string) => + `INSERT INTO ${tbl(items)} (${q("tenant")}, ${q("day")}, ${q("seq")}, ${q("name")}, ${q("price")}, ${q("note")}, ${q("obsolete")}, ${q("tags")}, ${q("addr")}) VALUES ('a', 1, ${seq}, ${name}, ${seq + 0.5}, ${note}, 'z', ${tags}, ${addr})`; + run("insert rows", [insert(1, "'a'", "'n1'", "{'x', 'y'}", "{street: 'Main', zip: 12345}"), insert(2, "'b'", "NULL", "NULL", "NULL")]); + check("collections and UDTs print as CQL literals", `SELECT ${q("tags")}, ${q("addr")} FROM ${tbl(items)} WHERE ${q("tenant")} = 'a' AND ${q("day")} = 1 AND ${q("seq")} = 1`, ["{'x', 'y'}"]); + check("secondary index lookup", `SELECT ${q("seq")} FROM ${tbl(items)} WHERE ${q("name")} = 'b'`, [2]); + + // Designer edits: drop a column, rename a clustering column (a regular + // column keeps its name), add a column. + const after: ColumnDraft[] = [ + { ...before[0], origin: before[0].name }, + { ...before[1], origin: before[1].name }, + { ...before[2], origin: before[2].name, name: "pos" }, + { ...before[3], origin: before[3].name }, + { ...before[4], origin: before[4].name }, + { ...before[5], origin: before[5].name, name: "remark" }, + { ...before[7], origin: before[7].name }, + { ...before[8], origin: before[8].name }, + { ...col("qty", "int"), origin: null }, + ]; + const altered = alterTableSql(schema, items, before, after, e); + expect(altered.warnings).toEqual(["Cassandra renames only clustering columns; note keeps its name."]); + run("alter table", altered.statements); + run("insert with the new columns", [`INSERT INTO ${tbl(items)} (${q("tenant")}, ${q("day")}, ${q("pos")}, ${q("qty")}) VALUES ('a', 2, 3, 7)`]); + check("added column", `SELECT ${q("qty")} FROM ${tbl(items)} WHERE ${q("tenant")} = 'a' AND ${q("day")} = 2`, [7]); + check("renamed clustering column kept its values", `SELECT ${q("pos")} FROM ${tbl(items)} WHERE ${q("tenant")} = 'a' AND ${q("day")} = 1`, [1, 2]); + check("rows", countSql(schema, items, e), [3]); + + steps.push({ label: "index DDL", ddl: { name: index, kind: "index", contains: index } }); + steps.push({ + label: "table DDL round trip", + roundTrip: { name: items, kind: "table", drop: dropObjectSql(schema, items, "table", e)[0] }, + }); + check("recreated table is empty", countSql(schema, items, e), [0]); + check("recreated index works", `SELECT ${q("pos")} FROM ${tbl(items)} WHERE ${q("name")} = 'b'`, []); + steps.push({ label: "recreated index DDL", ddl: { name: index, kind: "index", contains: index } }); + + run("fill again", [`INSERT INTO ${tbl(items)} (${q("tenant")}, ${q("day")}, ${q("pos")}, ${q("name")}) VALUES ('b', 1, 1, 'c')`]); + check("filled", countSql(schema, items, e), [1]); + run("truncate", truncateSql(schema, items, e)); + check("truncated", countSql(schema, items, e), [0]); + run("drop index", dropObjectSql(schema, index, "index", e)); + run("drop table", dropObjectSql(schema, items, "table", e)); + run("drop type", dropObjectSql(schema, "lq_ddl_addr", "type", e)); + + run("create keyspace", createSchemaSql("lq_ddl_schema", e)); + steps.push({ label: "keyspace DDL", ddl: { name: "lq_ddl_schema", kind: "keyspace", contains: "lq_ddl_schema" } }); + run("drop keyspace", dropSchemaSql("lq_ddl_schema", e)); + return { schema, cleanup, steps }; +} + const PATH = new URL("./ddl.fixtures.json", import.meta.url); it("ddl.fixtures.json matches the SQL ddl.ts writes", () => { const dialects: Record> = {}; for (const d of Object.keys(TYPES) as Dialect[]) dialects[d] = build(d); + dialects.cassandra = buildCassandra(); const text = JSON.stringify({ dialects }, null, "\t") + "\n"; if (process.env.LQ_UPDATE_FIXTURES) writeFileSync(PATH, text); expect(readFileSync(PATH, "utf8").replaceAll("\r\n", "\n")).toBe(text); diff --git a/src/lib/sql/ddl.test.ts b/src/lib/sql/ddl.test.ts index 2610686..4af287c 100644 --- a/src/lib/sql/ddl.test.ts +++ b/src/lib/sql/ddl.test.ts @@ -1,9 +1,20 @@ import { describe, expect, it } from "bun:test"; import { alterTableSql, + canDrop, + createIndexSql, + createKeyspaceSql, + createSchemaSql, createTableSql, dropObjectSql, + dropSchemaSql, + duplicateTableSql, foldName, + hasDdl, + objectTemplate, + schemaWord, + templateKinds, + truncateSql, refreshMatviewSql, renameTableSql, toScript, @@ -141,3 +152,102 @@ describe("toScript", () => { expect(parts[1].text).toBe("ALTER TABLE dbo.t DROP COLUMN v"); }); }); + +describe("Cassandra", () => { + const key = (name: string, type: string, role: string) => col(name, type, { primaryKey: role === "partition" || role === "clustering", keyRole: role }); + + it("folds names to lowercase only when they are plain", () => { + expect(foldName("Events", "cassandra")).toBe("events"); + expect(foldName("My Events", "cassandra")).toBe("My Events"); + expect(foldName("_x", "cassandra")).toBe("_x"); + }); + + it("creates keyspaces with either replication strategy", () => { + expect(createSchemaSql("shop", "cassandra")).toEqual(["CREATE KEYSPACE shop WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}"]); + expect(createKeyspaceSql("Shop", "network", "dc1", 3)).toEqual([ + "CREATE KEYSPACE \"Shop\" WITH replication = {'class': 'NetworkTopologyStrategy', 'dc1': 3}", + ]); + expect(createKeyspaceSql("s", "network", "")[0]).toContain("'datacenter1': 3"); + expect(dropSchemaSql("shop", "cassandra")).toEqual(["DROP KEYSPACE shop"]); + expect(schemaWord("cassandra")).toBe("keyspace"); + }); + + it("creates a table whose first key column is the partition key", () => { + const plan = createTableSql("shop", "events", [key("tenant", "text", "partition"), key("ts", "timestamp", "clustering"), col("kind", "text")], "cassandra"); + expect(plan.statements).toEqual(["CREATE TABLE shop.events (\n tenant text,\n ts timestamp,\n kind text,\n PRIMARY KEY (tenant, ts)\n)"]); + expect(plan.warnings[0]).toContain("partition key"); + }); + + it("needs a primary key", () => { + const plan = createTableSql("shop", "t", [col("a", "int")], "cassandra"); + expect(plan.statements).toEqual([]); + expect(plan.warnings[0]).toContain("needs a primary key"); + }); + + it("adds and drops columns without the COLUMN keyword", () => { + const before = [key("k", "text", "partition"), col("a", "int", { keyRole: "regular" }), col("b", "int", { keyRole: "regular" })]; + const after = [keep(before[0]), keep(before[1]), { ...col("c", "list"), origin: null }]; + expect(alterTableSql("ks", "t", before, after, "cassandra").statements).toEqual(["ALTER TABLE ks.t DROP b", "ALTER TABLE ks.t ADD c list"]); + }); + + it("renames clustering columns only", () => { + const before = [key("k", "text", "partition"), key("c", "int", "clustering"), col("v", "int", { keyRole: "regular" })]; + const renamed = alterTableSql("ks", "t", before, [keep(before[0]), keep(before[1], { name: "d" }), keep(before[2])], "cassandra"); + expect(renamed.statements).toEqual(["ALTER TABLE ks.t RENAME c TO d"]); + const refused = alterTableSql("ks", "t", before, [keep(before[0], { name: "j" }), keep(before[1]), keep(before[2], { name: "w" })], "cassandra"); + expect(refused.statements).toEqual([]); + expect(refused.warnings).toEqual([ + "Cassandra renames only clustering columns; k keeps its name.", + "Cassandra renames only clustering columns; v keeps its name.", + ]); + }); + + it("refuses what CQL cannot alter", () => { + const before = [key("k", "text", "partition"), col("v", "int", { keyRole: "regular" })]; + const plan = alterTableSql("ks", "t", before, [keep(before[0]), keep(before[1], { type: "bigint" })], "cassandra", "u"); + expect(plan.statements).toEqual([]); + expect(plan.warnings).toContain("Cassandra cannot change the type of v; add a column and copy the data."); + expect(plan.warnings).toContain("Cassandra cannot rename a table."); + const dropKey = alterTableSql("ks", "t", before, [keep(before[1])], "cassandra"); + expect(dropKey.statements).toEqual([]); + expect(dropKey.warnings[0]).toContain("part of the primary key"); + }); + + it("drops every kind with its own statement", () => { + expect(dropObjectSql("ks", "t", "table", "cassandra")).toEqual(["DROP TABLE ks.t"]); + expect(dropObjectSql("ks", "mv", "matview", "cassandra")).toEqual(["DROP MATERIALIZED VIEW ks.mv"]); + expect(dropObjectSql("ks", "addr", "type", "cassandra")).toEqual(["DROP TYPE ks.addr"]); + expect(dropObjectSql("ks", "Calc(int, text)", "function", "cassandra")).toEqual(['DROP FUNCTION ks."Calc"(int, text)']); + expect(dropObjectSql("ks", "total(int)", "aggregate", "cassandra")).toEqual(["DROP AGGREGATE ks.total(int)"]); + expect(dropObjectSql("ks", "ix", "index", "cassandra")).toEqual(["DROP INDEX ks.ix"]); + expect(dropObjectSql("ks", "t", "table", "cassandra", true)).toEqual(["DROP TABLE ks.t"]); + }); + + it("truncates, indexes and switches keyspace", () => { + expect(truncateSql("ks", "t", "cassandra")).toEqual(["TRUNCATE TABLE ks.t"]); + expect(createIndexSql("ks", "t", "name", "cassandra")).toBe("CREATE INDEX ix_t_name ON ks.t (name)"); + expect(useSchemaSql("Shop", "cassandra")).toBe('USE "Shop"'); + }); + + it("offers only actions CQL has", () => { + expect(renameTableSql("ks", "a", "b", "cassandra")).toEqual([]); + expect(duplicateTableSql("ks", "a", "b", true, "cassandra").statements).toEqual([]); + expect(templateKinds("cassandra")).toEqual(["type", "function", "aggregate", "view"]); + for (const kind of ["table", "matview", "type", "function", "aggregate", "index"]) { + expect(canDrop(kind, "cassandra")).toBe(true); + expect(hasDdl(kind, "cassandra")).toBe(true); + } + for (const kind of ["view", "sequence", "procedure", "package", "synonym", "foreign"]) { + expect(canDrop(kind, "cassandra")).toBe(false); + } + }); + + it("writes templates the CQL splitter keeps whole", () => { + for (const kind of templateKinds("cassandra")) { + const script = toScript([objectTemplate(kind, "ks", "cassandra")], "cassandra"); + expect(splitStatements(script, "cassandra")).toHaveLength(1); + } + expect(objectTemplate("function", "ks", "cassandra")).toContain("$$"); + expect(objectTemplate("view", "ks", "cassandra")).toStartWith("CREATE MATERIALIZED VIEW ks.new_view"); + }); +}); diff --git a/src/lib/sql/ddl.ts b/src/lib/sql/ddl.ts index 4ad5e01..4cc1ee1 100644 --- a/src/lib/sql/ddl.ts +++ b/src/lib/sql/ddl.ts @@ -20,6 +20,8 @@ export interface ColumnSpec { primaryKey: boolean; /** Values generated by the database (identity, AUTO_INCREMENT...); new columns only. */ identity: boolean; + /** Cassandra: "partition", "clustering", "static" or "regular" for a column read from the database. */ + keyRole?: string | null; } /** A designer row: `origin` is the column it edits, null for a new one. */ @@ -42,6 +44,8 @@ const isMysql = (e: Engine) => e === "mysql" || e === "mariadb"; */ export function foldName(name: string, engine: Engine): string { const n = name.trim(); + // CQL names are bare only with letters, digits and underscores, starting with a letter. + if (engine === "cassandra") return /^[A-Za-z][A-Za-z0-9_]*$/.test(n) ? n.toLowerCase() : n; if (!/^[A-Za-z_][A-Za-z0-9_$#]*$/.test(n)) return n; if (engine === "postgres") return n.toLowerCase(); if (engine === "oracle") return n.toUpperCase(); @@ -111,6 +115,8 @@ export function columnDef(c: ColumnSpec, engine: Engine, inlinePk = false): stri if (def) parts.push(`DEFAULT ${def}`); break; case "redis": + case "cassandra": + // CQL columns carry no NOT NULL, DEFAULT or identity clause. break; } return parts.join(" "); @@ -160,6 +166,17 @@ export function createTableSql(schema: string, name: string, columns: ColumnSpec if (identities.length > 1 && engine !== "postgres" && engine !== "oracle") { warnings.push("Only one column per table can generate values."); } + if (engine === "cassandra") { + if (pk.length === 0) { + warnings.push("A Cassandra table needs a primary key: tick at least one column."); + return { statements: [], warnings }; + } + if (pk.length > 1) warnings.push("The first primary key column is the partition key; the others are clustering columns, in the order shown."); + if (columns.some((c) => !c.primaryKey && (!c.nullable || c.defaultValue.trim()))) { + warnings.push("Cassandra has no NOT NULL or DEFAULT: those settings are ignored."); + } + if (identities.length > 0) warnings.push("Cassandra cannot generate values: the Auto setting is ignored."); + } const lines = columns.map((c) => ` ${columnDef(c, engine, c === sqliteRowid)}`); if (pk.length > 0 && !sqliteRowid) { lines.push(` PRIMARY KEY (${pk.map((c) => quoteIdent(c.name, engine)).join(", ")})`); @@ -205,8 +222,13 @@ export function alterTableSql( const kept = new Set(after.filter((c) => c.origin !== null).map((c) => c.origin)); for (const old of before) { if (kept.has(old.name)) continue; + if (engine === "cassandra" && old.primaryKey) { + warnings.push(`Cassandra cannot drop ${old.name}: it is part of the primary key.`); + continue; + } if (engine === "sqlserver" && old.defaultValue) out.push(mssqlDropDefault(t, old.name)); - out.push(`ALTER TABLE ${t} DROP COLUMN ${q(old.name)}`); + // CQL has no COLUMN keyword in ADD and DROP. + out.push(`ALTER TABLE ${t} DROP ${engine === "cassandra" ? "" : "COLUMN "}${q(old.name)}`); } for (const c of after) { @@ -216,12 +238,20 @@ export function alterTableSql( let name = old.name; if (c.name.trim() !== old.name) { const renamed = c.name.trim(); - out.push( - engine === "sqlserver" - ? `EXEC sp_rename ${nstr(`${t}.${q(old.name)}`)}, ${nstr(renamed)}, N'COLUMN'` - : `ALTER TABLE ${t} RENAME COLUMN ${q(old.name)} TO ${q(renamed)}`, - ); - name = renamed; + if (engine === "cassandra") { + // CQL renames only clustering columns, and does not know the key role of a new one. + const renamable = old.keyRole ? old.keyRole === "clustering" : old.primaryKey; + if (renamable) out.push(`ALTER TABLE ${t} RENAME ${q(old.name)} TO ${q(renamed)}`); + else warnings.push(`Cassandra renames only clustering columns; ${old.name} keeps its name.`); + name = renamable ? renamed : old.name; + } else { + out.push( + engine === "sqlserver" + ? `EXEC sp_rename ${nstr(`${t}.${q(old.name)}`)}, ${nstr(renamed)}, N'COLUMN'` + : `ALTER TABLE ${t} RENAME COLUMN ${q(old.name)} TO ${q(renamed)}`, + ); + name = renamed; + } } const col = q(name); const typeChanged = c.type.trim() !== old.type.trim(); @@ -270,6 +300,10 @@ export function alterTableSql( if (nullChanged) unsupported(`change whether ${name} accepts NULL`); if (defaultChanged) unsupported(`change the default of ${name}`); break; + case "cassandra": + if (typeChanged) warnings.push(`Cassandra cannot change the type of ${name}; add a column and copy the data.`); + if (nullChanged || defaultChanged) warnings.push(`Cassandra has no NOT NULL or DEFAULT for ${name}.`); + break; case "redis": break; } @@ -281,6 +315,9 @@ export function alterTableSql( warnings.push(`SQLite adds a NOT NULL column only with a default; give ${c.name} one.`); } if (engine === "sqlite" && (c.primaryKey || c.identity)) unsupported(`add a primary key column (${c.name})`); + if (engine === "cassandra" && c.primaryKey) { + warnings.push(`Cassandra cannot add ${c.name} to the primary key; recreate the table.`); + } const spec = { ...c, primaryKey: false }; const def = columnDef({ ...spec, nullable: c.nullable && !c.primaryKey }, engine); out.push( @@ -288,7 +325,9 @@ export function alterTableSql( ? `ALTER TABLE ${t} ADD (${def})` : engine === "sqlserver" ? `ALTER TABLE ${t} ADD ${def}` - : `ALTER TABLE ${t} ADD COLUMN ${def}`, + : engine === "cassandra" + ? `ALTER TABLE ${t} ADD ${def}` + : `ALTER TABLE ${t} ADD COLUMN ${def}`, ); } @@ -300,6 +339,9 @@ export function alterTableSql( const cols = pkAfter.map((c) => q(c.name.trim())).join(", "); if (engine === "sqlite") { unsupported("change the primary key"); + } else if (engine === "cassandra") { + // Added key columns were already reported above. + if (pkBefore.length > 0) warnings.push("Cassandra cannot change the primary key; recreate the table."); } else if (pkBefore.length === 0) { out.push(`ALTER TABLE ${t} ADD PRIMARY KEY (${cols})`); } else if (isMysql(engine) || engine === "oracle") { @@ -314,7 +356,10 @@ export function alterTableSql( } const renamed = newName.trim(); - if (renamed && renamed !== table) out.push(...renameTableSql(schema, table, renamed, engine)); + if (renamed && renamed !== table) { + if (engine === "cassandra") warnings.push("Cassandra cannot rename a table."); + else out.push(...renameTableSql(schema, table, renamed, engine)); + } if (out.length === 0 && warnings.length === 0) warnings.push("Nothing changed."); return { statements: out, warnings }; } @@ -322,6 +367,7 @@ export function alterTableSql( // ---- table and object actions ---------------------------------------------- export function renameTableSql(schema: string, table: string, newName: string, engine: Engine): string[] { + if (engine === "cassandra") return []; const t = target(schema, table, engine); switch (engine) { case "mysql": @@ -369,6 +415,7 @@ export function duplicateTableSql(schema: string, table: string, newName: string warnings: ["Only columns are copied: keys, constraints and defaults are not."], }; case "redis": + case "cassandra": return { statements: [], warnings: [] }; } } @@ -382,6 +429,7 @@ export function truncateSql(schema: string, table: string, engine: Engine): stri export function canDrop(kind: string, engine: Engine): boolean { if (engine === "redis") return false; if (engine === "sqlite") return kind === "table" || kind === "view"; + if (engine === "cassandra") return ["table", "matview", "type", "function", "aggregate", "index"].includes(kind); return ["table", "view", "matview", "foreign", "sequence", "function", "procedure", "package", "type", "synonym"].includes(kind); } @@ -394,7 +442,8 @@ export function dropCascades(kind: string, engine: Engine): boolean { export function dropObjectSql(schema: string, name: string, kind: string, engine: Engine, cascade = false): string[] { const { base, args } = splitArgs(name); - const t = engine === "postgres" ? `${target(schema, base, engine)}${args}` : target(schema, name, engine); + // PostgreSQL and CQL functions are dropped with their argument types: `f(int, text)`. + const t = engine === "postgres" || engine === "cassandra" ? `${target(schema, base, engine)}${args}` : target(schema, name, engine); const word: Record = { table: "TABLE", view: "VIEW", @@ -406,6 +455,8 @@ export function dropObjectSql(schema: string, name: string, kind: string, engine package: "PACKAGE", type: "TYPE", synonym: "SYNONYM", + aggregate: "AGGREGATE", + index: "INDEX", }; if (engine === "oracle" && kind === "synonym" && schema === "PUBLIC") { return [`DROP PUBLIC SYNONYM ${quoteIdent(name, engine)}`]; @@ -428,10 +479,30 @@ export function refreshMatviewSql(schema: string, name: string, engine: Engine): /** What the second level of the tree is called for this engine. */ export function schemaWord(engine: Engine): string { + if (engine === "cassandra") return "keyspace"; return isMysql(engine) || engine === "redis" || engine === "sqlite" ? "database" : "schema"; } +/** + * CREATE KEYSPACE with SimpleStrategy (one replica, for a single node or a + * test cluster) or NetworkTopologyStrategy (replicas per datacenter). + */ +export function createKeyspaceSql( + name: string, + strategy: "simple" | "network" = "simple", + datacenter = "", + replicas = strategy === "simple" ? 1 : 3, +): string[] { + const n = quoteIdent(name, "cassandra"); + const replication = + strategy === "simple" + ? `{'class': 'SimpleStrategy', 'replication_factor': ${replicas}}` + : `{'class': 'NetworkTopologyStrategy', ${literal(datacenter.trim() || "datacenter1")}: ${replicas}}`; + return [`CREATE KEYSPACE ${n} WITH replication = ${replication}`]; +} + export function createSchemaSql(name: string, engine: Engine): string[] { + if (engine === "cassandra") return createKeyspaceSql(name); const n = quoteIdent(name, engine); return [isMysql(engine) ? `CREATE DATABASE ${n}` : `CREATE SCHEMA ${n}`]; } @@ -460,6 +531,8 @@ export function dropSchemaSql(name: string, engine: Engine, cascade = false): st return [`DETACH DATABASE ${n}`]; case "redis": return ["FLUSHDB"]; + case "cassandra": + return [`DROP KEYSPACE ${n}`]; } } @@ -476,6 +549,8 @@ export function useSchemaSql(name: string, engine: Engine): string | null { return `ALTER SESSION SET CURRENT_SCHEMA = ${n}`; case "redis": return `SELECT ${name.replace(/^db/, "")}`; + case "cassandra": + return `USE ${n}`; default: return null; } @@ -495,6 +570,9 @@ export function sessionsSql(engine: Engine): string | null { return "SELECT s.session_id, s.login_name, s.host_name, s.program_name, s.status, s.login_time,\n DB_NAME(s.database_id) AS database_name, r.command, r.wait_type, r.blocking_session_id\nFROM sys.dm_exec_sessions s\nLEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id\nWHERE s.is_user_process = 1\nORDER BY s.login_time"; case "redis": return "CLIENT LIST"; + case "cassandra": + // Cassandra 4.0 and later. + return "SELECT * FROM system_views.clients"; default: return null; } @@ -531,7 +609,7 @@ export function deleteSql(schema: string, table: string, columns: string[], key: } export function createIndexSql(schema: string, table: string, column: string, engine: Engine): string { - const name = foldName(`ix_${table}_${column}`.replace(/[^A-Za-z0-9_]/g, "_").slice(0, 60), engine); + const name = foldName(`ix_${table}_${column}`.replace(/[^A-Za-z0-9_]/g, "_").slice(0, engine === "cassandra" ? 48 : 60), engine); // SQLite qualifies the index, never the table; Oracle may qualify both; // the others keep the index in the table's schema. const on = engine === "sqlite" ? quoteIdent(table, engine) : target(schema, table, engine); @@ -539,10 +617,33 @@ export function createIndexSql(schema: string, table: string, column: string, en return `CREATE INDEX ${ix} ON ${on} (${quoteIdent(column, engine)})`; } +/** What the schema menu can open as a skeleton: "view" is a materialized view in Cassandra. */ +export type TemplateKind = "view" | "function" | "procedure" | "sequence" | "type" | "aggregate"; + +/** CQL skeletons; UDFs and UDAs need `user_defined_functions_enabled` on the server. */ +function cqlTemplate(kind: TemplateKind, name: (n: string) => string): string { + switch (kind) { + case "type": + return `CREATE TYPE ${name("new_type")} (\n street text,\n zip int\n)`; + case "function": + return `CREATE OR REPLACE FUNCTION ${name("new_function")}(value int)\n CALLED ON NULL INPUT\n RETURNS int\n LANGUAGE java\n AS $$ return value == null ? null : value * 2; $$`; + case "aggregate": + return `CREATE OR REPLACE AGGREGATE ${name("new_aggregate")}(int)\n SFUNC state_function\n STYPE int\n INITCOND 0`; + case "view": + return `CREATE MATERIALIZED VIEW ${name("new_view")} AS\n SELECT *\n FROM ${name("table_name")}\n WHERE key_column IS NOT NULL AND id IS NOT NULL\n PRIMARY KEY (key_column, id)`; + default: + return ""; + } +} + /** Skeletons for new objects, opened in the editor. */ -export function objectTemplate(kind: "view" | "function" | "procedure" | "sequence", schema: string, engine: Engine): string { +export function objectTemplate(kind: TemplateKind, schema: string, engine: Engine): string { const name = (n: string) => target(schema, foldName(n, engine), engine); + if (engine === "cassandra") return cqlTemplate(kind, name); switch (kind) { + case "type": + case "aggregate": + return ""; case "view": return `CREATE VIEW ${name("new_view")} AS\nSELECT *\nFROM ${name("table_name")}`; case "sequence": @@ -593,11 +694,13 @@ export function hasDdl(kind: string, engine: Engine): boolean { return kind === "table" || kind === "view"; case "redis": return false; + case "cassandra": + return ["table", "matview", "type", "function", "aggregate", "index"].includes(kind); } } /** Object templates the schema menu offers. */ -export function templateKinds(engine: Engine): ("view" | "function" | "procedure" | "sequence")[] { +export function templateKinds(engine: Engine): TemplateKind[] { switch (engine) { case "postgres": case "oracle": @@ -608,6 +711,8 @@ export function templateKinds(engine: Engine): ("view" | "function" | "procedure return ["view", "function", "procedure"]; case "sqlite": return ["view"]; + case "cassandra": + return ["type", "function", "aggregate", "view"]; case "redis": return []; } @@ -617,11 +722,11 @@ type FixedKind = Exclude; /** Column types for values found in an imported file, per engine. */ const IMPORT_TYPES: Record> = { - integer: { postgres: "bigint", oracle: "NUMBER(19)", mysql: "BIGINT", mariadb: "BIGINT", sqlserver: "bigint", sqlite: "INTEGER", redis: "" }, - decimal: { postgres: "numeric", oracle: "NUMBER", mysql: "DECIMAL(28,8)", mariadb: "DECIMAL(28,8)", sqlserver: "decimal(28,8)", sqlite: "NUMERIC", redis: "" }, - boolean: { postgres: "boolean", oracle: "NUMBER(1)", mysql: "TINYINT(1)", mariadb: "TINYINT(1)", sqlserver: "bit", sqlite: "INTEGER", redis: "" }, - date: { postgres: "date", oracle: "DATE", mysql: "DATE", mariadb: "DATE", sqlserver: "date", sqlite: "TEXT", redis: "" }, - timestamp: { postgres: "timestamp", oracle: "TIMESTAMP", mysql: "DATETIME(6)", mariadb: "DATETIME(6)", sqlserver: "datetime2", sqlite: "TEXT", redis: "" }, + integer: { postgres: "bigint", oracle: "NUMBER(19)", mysql: "BIGINT", mariadb: "BIGINT", sqlserver: "bigint", sqlite: "INTEGER", redis: "", cassandra: "bigint" }, + decimal: { postgres: "numeric", oracle: "NUMBER", mysql: "DECIMAL(28,8)", mariadb: "DECIMAL(28,8)", sqlserver: "decimal(28,8)", sqlite: "NUMERIC", redis: "", cassandra: "decimal" }, + boolean: { postgres: "boolean", oracle: "NUMBER(1)", mysql: "TINYINT(1)", mariadb: "TINYINT(1)", sqlserver: "bit", sqlite: "INTEGER", redis: "", cassandra: "boolean" }, + date: { postgres: "date", oracle: "DATE", mysql: "DATE", mariadb: "DATE", sqlserver: "date", sqlite: "TEXT", redis: "", cassandra: "date" }, + timestamp: { postgres: "timestamp", oracle: "TIMESTAMP", mysql: "DATETIME(6)", mariadb: "DATETIME(6)", sqlserver: "datetime2", sqlite: "TEXT", redis: "", cassandra: "timestamp" }, }; /** Column type for a kind of value found in an imported file; text gets room to grow. */ @@ -630,6 +735,7 @@ export function typeFor(kind: InferredType, maxLength: number, engine: Engine): const len = Math.max(50, Math.ceil((maxLength * 1.5) / 10) * 10); switch (engine) { case "postgres": + case "cassandra": return "text"; case "sqlite": return "TEXT"; diff --git a/src/lib/sql/ident.test.ts b/src/lib/sql/ident.test.ts index d84cee0..7c73908 100644 --- a/src/lib/sql/ident.test.ts +++ b/src/lib/sql/ident.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { previewQuery, quoteIdent, redisArg, redisPreview } from "./ident"; +import { previewQuery, qualified, quoteIdent, redisArg, redisPreview } from "./ident"; describe("quoteIdent", () => { it("PostgreSQL quotes uppercase names and reserved words", () => { @@ -27,6 +27,33 @@ describe("quoteIdent for MySQL, SQL Server and SQLite", () => { }); }); +describe("quoteIdent for Cassandra", () => { + it("keeps plain lowercase names bare and quotes the rest", () => { + expect(quoteIdent("users", "cassandra")).toBe("users"); + expect(quoteIdent("user_events_2", "cassandra")).toBe("user_events_2"); + expect(quoteIdent("Users", "cassandra")).toBe('"Users"'); + expect(quoteIdent("_x", "cassandra")).toBe('"_x"'); + expect(quoteIdent("1st", "cassandra")).toBe('"1st"'); + expect(quoteIdent("my table", "cassandra")).toBe('"my table"'); + expect(quoteIdent('a"b', "cassandra")).toBe('"a""b"'); + expect(quoteIdent("", "cassandra")).toBe('""'); + }); + + it("quotes reserved keywords in any case", () => { + expect(quoteIdent("select", "cassandra")).toBe('"select"'); + expect(quoteIdent("table", "cassandra")).toBe('"table"'); + expect(quoteIdent("token", "cassandra")).toBe('"token"'); + expect(quoteIdent("keyspace", "cassandra")).toBe('"keyspace"'); + expect(quoteIdent("key", "cassandra")).toBe("key"); + expect(quoteIdent("user", "cassandra")).toBe("user"); + }); + + it("qualifies keyspace and table", () => { + expect(qualified("shop", "orders", "cassandra")).toBe("shop.orders"); + expect(qualified("Shop", "order", "cassandra")).toBe('"Shop"."order"'); + }); +}); + describe("previewQuery", () => { it("uses each database's row limit syntax", () => { expect(previewQuery("public", "t", "postgres")).toBe("SELECT * FROM public.t LIMIT 200"); @@ -34,6 +61,8 @@ describe("previewQuery", () => { expect(previewQuery("dbo", "Orders", "sqlserver")).toBe("SELECT TOP 200 * FROM dbo.Orders"); expect(previewQuery("shop", "orders", "mysql")).toBe("SELECT * FROM shop.orders LIMIT 200"); expect(previewQuery("main", "t", "sqlite")).toBe("SELECT * FROM main.t LIMIT 200"); + expect(previewQuery("shop", "orders", "cassandra")).toBe("SELECT * FROM shop.orders LIMIT 200"); + expect(previewQuery("Shop", "Orders", "cassandra")).toBe('SELECT * FROM "Shop"."Orders" LIMIT 200'); }); }); diff --git a/src/lib/sql/ident.ts b/src/lib/sql/ident.ts index 4b9bd1c..6f94423 100644 --- a/src/lib/sql/ident.ts +++ b/src/lib/sql/ident.ts @@ -8,6 +8,16 @@ const RESERVED = new Set([ "VIEW", "WHEN", "WHERE", "WITH", ]); +/** Words CQL does not accept as a bare name (reserved keywords of Cassandra 3 to 5). */ +const CQL_RESERVED = new Set([ + "ADD", "ALLOW", "ALTER", "AND", "APPLY", "ASC", "AUTHORIZE", "BATCH", "BEGIN", "BY", "COLUMNFAMILY", + "CREATE", "DEFAULT", "DELETE", "DESC", "DESCRIBE", "DROP", "ENTRIES", "EXECUTE", "FROM", "FULL", + "GRANT", "IF", "IN", "INDEX", "INFINITY", "INSERT", "INTO", "IS", "KEYSPACE", "LIMIT", + "MATERIALIZED", "MODIFY", "NAN", "NORECURSIVE", "NOT", "NULL", "OF", "ON", "OR", "ORDER", "PRIMARY", + "RENAME", "REPLACE", "REVOKE", "SCHEMA", "SELECT", "SET", "TABLE", "TO", "TOKEN", "TRUNCATE", + "UNLOGGED", "UPDATE", "USE", "USING", "VIEW", "WHERE", "WITH", +]); + /** * Quotes the identifier only when needed, in each engine's style. * PostgreSQL folds unquoted names to lowercase and Oracle to uppercase; @@ -28,6 +38,9 @@ export function quoteIdent(name: string, engine: Engine): string { return /^[A-Za-z_][A-Za-z0-9_@#$]*$/.test(name) && !reserved ? name : `[${name.replace(/]/g, "]]")}]`; case "sqlite": return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !reserved ? name : `"${name.replace(/"/g, '""')}"`; + case "cassandra": + // CQL folds unquoted names to lowercase. + return /^[a-z][a-z0-9_]*$/.test(name) && !CQL_RESERVED.has(name.toUpperCase()) ? name : `"${name.replace(/"/g, '""')}"`; case "redis": return redisArg(name); } diff --git a/src/lib/sql/split.test.ts b/src/lib/sql/split.test.ts index 2b10d30..ba3df6e 100644 --- a/src/lib/sql/split.test.ts +++ b/src/lib/sql/split.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { isPlsqlStart, splitStatements, statementAt, type Dialect } from "./split"; +import { isCqlBatchStart, isPlsqlStart, splitStatements, statementAt, type Dialect } from "./split"; const texts = (sql: string, dialect: Dialect, blank = false) => splitStatements(sql, dialect, { blankLineDelimiter: blank }).map((s) => s.text); @@ -175,3 +175,89 @@ describe("Redis", () => { expect(sql.slice(list[1].start, list[1].end)).toBe("HGETALL user:1"); }); }); + +describe("Cassandra", () => { + const cql = (sql: string, blank = false) => texts(sql, "cassandra", blank); + + it("splits on semicolons and skips strings, quoted names and comments", () => { + const sql = [ + "-- first; comment", + "SELECT 'a;b', \"c;d\" FROM ks.t; // second; comment", + "/* third; */ INSERT INTO ks.t (k, v) VALUES (1, 'it''s; fine');", + "SELECT * FROM \"we\"\"ird;name\"", + ].join("\n"); + expect(cql(sql)).toEqual([ + "SELECT 'a;b', \"c;d\" FROM ks.t", + "INSERT INTO ks.t (k, v) VALUES (1, 'it''s; fine')", + 'SELECT * FROM "we""ird;name"', + ]); + }); + + it("does not treat a backslash as an escape", () => { + expect(cql("SELECT 'a\\'; SELECT 'b';")).toEqual(["SELECT 'a\\'", "SELECT 'b'"]); + expect(cql('SELECT "a\\"; SELECT 2;')).toEqual(['SELECT "a\\"', "SELECT 2"]); + }); + + it("does not nest block comments", () => { + expect(cql("/* a /* b */ SELECT 1; SELECT 2;")).toEqual(["SELECT 1", "SELECT 2"]); + }); + + it("keeps a batch whole up to APPLY BATCH", () => { + const batch = "BEGIN BATCH\n INSERT INTO ks.t (k) VALUES (1);\n INSERT INTO ks.t (k) VALUES (2);\nAPPLY BATCH"; + expect(cql(`${batch};\nSELECT 1;`)).toEqual([batch, "SELECT 1"]); + expect(cql(`${batch};\nSELECT 1;`, true)).toEqual([batch, "SELECT 1"]); + }); + + it("recognizes UNLOGGED and COUNTER batches, USING TIMESTAMP and any case", () => { + const unlogged = "begin unlogged batch using timestamp 5 insert into t (k) values (1); apply batch"; + const counter = "BEGIN COUNTER BATCH UPDATE t SET c = c + 1 WHERE k = 1; APPLY BATCH"; + expect(cql(`${unlogged}; ${counter}; SELECT 1`)).toEqual([unlogged, counter, "SELECT 1"]); + }); + + it("keeps blank lines inside a batch together", () => { + const batch = "BEGIN BATCH\nINSERT INTO t (k) VALUES (1);\n\nINSERT INTO t (k) VALUES (2);\nAPPLY BATCH"; + expect(cql(`${batch};`, true)).toEqual([batch]); + }); + + it("ignores APPLY BATCH inside strings and comments, and an unclosed batch runs to the end", () => { + const batch = "BEGIN BATCH INSERT INTO t (k, v) VALUES (1, 'APPLY BATCH;'); -- apply batch;\nAPPLY BATCH"; + expect(cql(`${batch}; SELECT 1;`)).toEqual([batch, "SELECT 1"]); + expect(cql("BEGIN BATCH INSERT INTO t (k) VALUES (1); INSERT INTO t (k) VALUES (2);")).toEqual([ + "BEGIN BATCH INSERT INTO t (k) VALUES (1); INSERT INTO t (k) VALUES (2);", + ]); + }); + + it("does not treat other BEGIN statements as batches", () => { + expect(cql("BEGIN; SELECT 1;")).toEqual(["BEGIN", "SELECT 1"]); + }); + + it("keeps $$ strings whole", () => { + const fn = + "CREATE FUNCTION ks.f(a int) CALLED ON NULL INPUT RETURNS int LANGUAGE java AS $$ int x = a; return x; $$"; + expect(cql(`${fn};\nSELECT 1;`)).toEqual([fn, "SELECT 1"]); + expect(cql("SELECT $$a'b;c$$ FROM t; SELECT 2;")).toEqual(["SELECT $$a'b;c$$ FROM t", "SELECT 2"]); + }); + + it("does not treat tagged dollar quotes as strings", () => { + expect(cql("SELECT $a$ ; $a$;")).toEqual(["SELECT $a$", "$a$"]); + }); + + it("splits statements separated by blank lines like other dialects", () => { + expect(cql("SELECT 1\n\nSELECT 2", true)).toEqual(["SELECT 1", "SELECT 2"]); + }); + + it("reports statement ranges", () => { + const sql = "USE ks;\n// c\nSELECT * FROM t;"; + const list = splitStatements(sql, "cassandra"); + expect(list.map((s) => sql.slice(s.start, s.end))).toEqual(["USE ks", "SELECT * FROM t"]); + }); +}); + +describe("isCqlBatchStart", () => { + it("matches batch openers only", () => { + expect(isCqlBatchStart("BEGIN BATCH")).toBe(true); + expect(isCqlBatchStart("-- c\nbegin counter batch")).toBe(true); + expect(isCqlBatchStart("BEGIN TRANSACTION")).toBe(false); + expect(isCqlBatchStart("SELECT 1")).toBe(false); + }); +}); diff --git a/src/lib/sql/split.ts b/src/lib/sql/split.ts index c137898..0cb933f 100644 --- a/src/lib/sql/split.ts +++ b/src/lib/sql/split.ts @@ -2,10 +2,11 @@ * Splits a SQL script into statements following each database's lexical * rules: strings, quoted identifiers, comments, PostgreSQL dollar quoting, * Oracle q-quoting and PL/SQL blocks ended by `/`, MySQL `DELIMITER`, - * SQL Server `GO` batches and BEGIN...END blocks, and SQLite trigger bodies. + * SQL Server `GO` batches and BEGIN...END blocks, SQLite trigger bodies, and + * Cassandra `BEGIN BATCH ... APPLY BATCH`, `$$` strings and `//` comments. */ -export type Dialect = "postgres" | "oracle" | "mysql" | "sqlserver" | "sqlite" | "redis"; +export type Dialect = "postgres" | "oracle" | "mysql" | "sqlserver" | "sqlite" | "redis" | "cassandra"; export interface SqlStatement { /** Statement text, without the terminator (`;`, `/`, `GO` or a custom delimiter). */ @@ -73,6 +74,13 @@ export function isTsqlModuleStart(sql: string): boolean { return ["PROCEDURE", "PROC", "FUNCTION", "TRIGGER", "VIEW"].includes(kw[i] ?? ""); } +/** CQL `BEGIN [UNLOGGED | COUNTER] BATCH`, whose `;` belong to the batch up to APPLY BATCH. */ +export function isCqlBatchStart(sql: string): boolean { + const kw = firstKeywords(sql, 3); + if (kw[0] !== "BEGIN") return false; + return kw[1] === "BATCH" || ((kw[1] === "UNLOGGED" || kw[1] === "COUNTER") && kw[2] === "BATCH"); +} + /** SQLite `CREATE [TEMP] TRIGGER`, whose body holds `;` up to its END. */ export function isSqliteTriggerStart(sql: string): boolean { const kw = firstKeywords(sql, 3); @@ -126,8 +134,9 @@ export function splitStatements( const out: SqlStatement[] = []; const n = sql.length; let stmtStart = -1; // first significant character of the current statement - // "plsql": ends only at "/" (Oracle); "batch": ends only at GO (T-SQL modules). - let mode: "normal" | "plsql" | "batch" = "normal"; + // "plsql": ends only at "/" (Oracle); "batch": ends only at GO (T-SQL modules); + // "cql-batch": ends at the first ";" after APPLY BATCH (Cassandra). + let mode = "normal" as "normal" | "plsql" | "batch" | "cql-batch"; // BEGIN/CASE ... END nesting (T-SQL blocks, SQLite trigger bodies). let blocks = 0; let trackBlocks = false; @@ -155,6 +164,7 @@ export function splitStatements( const head = sql.slice(at, at + 400); if (dialect === "oracle" && isPlsqlStart(head)) mode = "plsql"; else if (dialect === "sqlserver" && isTsqlModuleStart(head)) mode = "batch"; + else if (dialect === "cassandra" && isCqlBatchStart(head)) mode = "cql-batch"; trackBlocks = dialect === "sqlserver" || (dialect === "sqlite" && isSqliteTriggerStart(head)); }; @@ -218,9 +228,9 @@ export function splitStatements( continue; } - // Line comments. MySQL needs whitespace after "--" and also accepts "#". + // Line comments. MySQL needs whitespace after "--" and also accepts "#"; CQL accepts "//". const dashComment = c === "-" && next === "-" && (dialect !== "mysql" || next === undefined || /\s/.test(sql[i + 2] ?? " ")); - if (dashComment || (dialect === "mysql" && c === "#")) { + if (dashComment || (dialect === "mysql" && c === "#") || (dialect === "cassandra" && c === "/" && next === "/")) { while (i < n && sql[i] !== "\n") i++; continue; } @@ -302,11 +312,21 @@ export function splitStatements( } } + // CQL string between two bare "$$" (function bodies). + if (dialect === "cassandra" && c === "$" && next === "$") { + const end = sql.indexOf("$$", i + 2); + i = end < 0 ? n : end + 2; + continue; + } + // Words: BEGIN/CASE/END nesting where `;` can appear inside a block. if (WORD_START.test(c) && !isIdentChar(sql[i - 1])) { let j = i; // A custom delimiter can follow a word directly ("END$$"). while (j < n && isIdentChar(sql[j]) && !(delimiter !== ";" && sql.startsWith(delimiter, j))) j++; + if (mode === "cql-batch" && sql.slice(i, j).toUpperCase() === "APPLY") { + if (firstKeywords(sql.slice(j, j + 40), 1)[0] === "BATCH") mode = "normal"; + } if (trackBlocks) { const word = sql.slice(i, j).toUpperCase(); if (word === "BEGIN") { diff --git a/src/lib/state/actions.svelte.ts b/src/lib/state/actions.svelte.ts index c612650..026be65 100644 --- a/src/lib/state/actions.svelte.ts +++ b/src/lib/state/actions.svelte.ts @@ -63,6 +63,8 @@ const KIND_NAME: Record = { package: "package", type: "type", synonym: "synonym", + aggregate: "aggregate", + index: "index", }; /** "table", "materialized view"... and "key" for Redis keys. */ @@ -209,6 +211,25 @@ class Actions { return; } const word = database ? "database" : ddl.schemaWord(e); + if (e === "cassandra") { + const dc = p.datacenter.trim() || "datacenter1"; + this.ask({ + title: "Create keyspace", + description: "SimpleStrategy keeps one replica and suits a single node. NetworkTopologyStrategy keeps three replicas in the datacenter below.", + profile: p, + confirmLabel: "Create", + input: { label: "Name", value: "", placeholder: "new_keyspace" }, + options: [{ id: "network", label: `NetworkTopologyStrategy in ${dc} (otherwise SimpleStrategy)`, checked: !!p.datacenter.trim() }], + build: ({ input, options }) => { + const name = ddl.foldName(input, e); + if (!name) return plan([], ["Type a name for the keyspace."]); + return plan(ddl.createKeyspaceSql(name, options.network ? "network" : "simple", dc)); + }, + success: ({ input }) => `Keyspace ${ddl.foldName(input, e)} created`, + after: () => explorer.refresh(p), + }); + return; + } this.ask({ title: `Create ${word}`, profile: p, @@ -238,7 +259,7 @@ class Actions { this.designer = { profile: p, schema: s.name, table: null, columns: [] }; } - template(p: ConnectionProfile, s: SchemaNode, kind: "view" | "function" | "procedure" | "sequence") { + template(p: ConnectionProfile, s: SchemaNode, kind: ddl.TemplateKind) { workspace.openScript(p.id, `New ${kind}`, ddl.objectTemplate(kind, s.name, p.engine)); } @@ -348,7 +369,9 @@ ${command}`); async createIndex(p: ConnectionProfile, s: SchemaNode, o: ObjectNode) { const cols = await explorer.columns(p, s, o).catch(() => []); - const sql = ddl.createIndexSql(s.name, o.name, cols[0]?.name ?? "column_name", p.engine); + // Cassandra indexes a regular column, not the key it already reads by. + const first = (p.engine === "cassandra" ? cols.find((c) => c.keyRole === "regular") : null) ?? cols[0]; + const sql = ddl.createIndexSql(s.name, o.name, first?.name ?? "column_name", p.engine); workspace.openScript(p.id, `Index on ${o.name}`, ddl.toScript([sql], p.engine)); } @@ -401,7 +424,9 @@ ${command}`); description: p.engine === "sqlite" ? "Deletes every row of the table." - : "TRUNCATE removes every row at once. Most databases cannot roll it back.", + : p.engine === "cassandra" + ? "TRUNCATE removes every row of the table on every replica. It cannot be undone, and it fails when a node is down." + : "TRUNCATE removes every row at once. Most databases cannot roll it back.", profile: p, confirmLabel: "Empty table", destructive: true, @@ -415,7 +440,12 @@ ${command}`); const cascade = ddl.dropCascades(o.kind, p.engine); this.ask({ title: `Drop ${kind} ${o.name}?`, - description: o.kind === "table" ? "The table and its rows are lost. It cannot be undone." : "It cannot be undone.", + description: + o.kind === "table" + ? "The table and its rows are lost. It cannot be undone." + : o.kind === "matview" && p.engine === "cassandra" + ? "The materialized view and its rows are dropped. The base table is not changed." + : "It cannot be undone.", profile: p, confirmLabel: "Drop", destructive: true, diff --git a/src/lib/state/transfer.svelte.ts b/src/lib/state/transfer.svelte.ts index 9cb0272..25097e7 100644 --- a/src/lib/state/transfer.svelte.ts +++ b/src/lib/state/transfer.svelte.ts @@ -256,7 +256,8 @@ class Transfer { const plan = createTableSql( t.schema, table, - this.newColumns(t).map((c) => ({ ...c, nullable: true, defaultValue: "", primaryKey: false, identity: false })), + // A Cassandra table needs a key: the first file column becomes the partition key. + this.newColumns(t).map((c, i) => ({ ...c, nullable: true, defaultValue: "", primaryKey: e === "cassandra" && i === 0, identity: false })), e, ); if (plan.statements.length === 0) { diff --git a/src/lib/view.fixtures.test.ts b/src/lib/view.fixtures.test.ts index 5c17e0d..7d38d3f 100644 --- a/src/lib/view.fixtures.test.ts +++ b/src/lib/view.fixtures.test.ts @@ -11,7 +11,7 @@ import type { ColumnInfo } from "./api"; import type { Dialect } from "./sql/split"; import { viewSql, type ColumnFilter, type ResultView } from "./view"; -type SqlDialect = Exclude; +type SqlDialect = Exclude; // Four rows: 1 Alpha 2026-10-22, 2 beta 2025-01-05, 3 NULL NULL, 4 it's 2026-10-01. const BASE: Record = { diff --git a/src/lib/view.test.ts b/src/lib/view.test.ts index 4777dc3..8de8234 100644 --- a/src/lib/view.test.ts +++ b/src/lib/view.test.ts @@ -120,6 +120,7 @@ describe("canViewOnServer", () => { expect(canViewOnServer("exec sp_who", "sqlserver")).toBe(false); expect(canViewOnServer("pragma table_info(t)", "sqlite")).toBe(false); expect(canViewOnServer("select", "redis")).toBe(false); + expect(canViewOnServer("SELECT * FROM ks.t", "cassandra")).toBe(false); }); }); diff --git a/src/lib/view.ts b/src/lib/view.ts index b62d311..0c6db23 100644 --- a/src/lib/view.ts +++ b/src/lib/view.ts @@ -115,9 +115,9 @@ export function viewRows(columns: ColumnInfo[], rows: (string | null)[][], view: return isEmptyView(view) ? rows : viewOrder(columns, rows, view).map((i) => rows[i]); } -/** A query the view can wrap: Redis commands and non-SELECT statements cannot. */ +/** A query the view can wrap: Redis and CQL (no subqueries), and non-SELECT statements cannot. */ export function canViewOnServer(sql: string, dialect: Dialect): boolean { - if (dialect === "redis") return false; + if (dialect === "redis" || dialect === "cassandra") return false; const kw = firstKeywords(sql, 1)[0]; return kw === "SELECT" || kw === "WITH"; }