diff --git a/.claude/commands/new-db.md b/.claude/commands/new-db.md new file mode 100644 index 00000000..890c7179 --- /dev/null +++ b/.claude/commands/new-db.md @@ -0,0 +1,438 @@ +# /new-db — Scaffold a new database driver for DbPaw + +Scaffold all boilerplate needed to add a new database type to DbPaw. + +**Usage:** `/new-db [network|file] [mysql-family]` + +**Examples:** +- `/new-db Redis 6379 redis-rs network` +- `/new-db CockroachDB 26257 sqlx network` +- `/new-db LanceDB 0 lancedb file` + +--- + +## Parse $ARGUMENTS + +Extract the following variables from `$ARGUMENTS`: + +- `DB_NAME` — display name, e.g. `Redis` (keep original casing) +- `DRIVER_ID` — lowercase of DB_NAME, e.g. `redis` +- `DEFAULT_PORT` — integer, e.g. `6379`; use `0` if file-based +- `RUST_CRATE` — crate name on crates.io, e.g. `redis` +- `IS_FILE_BASED` — `true` if `DEFAULT_PORT == 0` OR `file` flag is present; else `false` +- `IS_MYSQL_FAMILY` — `true` if `mysql-family` flag is present; else `false` +- `ENV_PREFIX` — `DRIVER_ID` uppercased, e.g. `REDIS` + +If `$ARGUMENTS` is empty or unclear, ask the user before proceeding. + +--- + +## Execution Steps + +Work through each step in order. After completing each numbered step, confirm with a brief status message before continuing. + +--- + +### Step 1 — Create the Rust driver file + +**CREATE** `src-tauri/src/db/drivers/{DRIVER_ID}.rs` + +Use the most appropriate reference driver as a template: +- **sqlx-based (most SQL databases):** copy structure from `src-tauri/src/db/drivers/mysql.rs` +- **HTTP-based:** copy structure from `src-tauri/src/db/drivers/clickhouse.rs` +- **File-based / embedded:** copy structure from `src-tauri/src/db/drivers/duckdb.rs` + +The file must contain: + +1. **Struct definition:** + ```rust + pub struct {DbName}Driver { + // connection pool or client + pub ssh_tunnel: Option, + } + ``` + +2. **`connect()` function** that: + - Handles SSH tunnel if `IS_FILE_BASED == false`: + ```rust + let mut ssh_tunnel = None; + if let Some(true) = form.ssh_enabled { + let tunnel = crate::ssh::start_ssh_tunnel(form) + .map_err(|e| format!("[CONN_FAILED] SSH tunnel failed: {}", e))?; + // Override host/port to tunnel local endpoint + ssh_tunnel = Some(tunnel); + } + ``` + - Builds connection string / config + - Creates a connection pool or client + - Returns `Ok(Self { ..., ssh_tunnel })` + - Maps all connection errors via `super::conn_failed_error(&e)` + +3. **`#[async_trait] impl DatabaseDriver for {DbName}Driver`** implementing all 13 methods: + - `test_connection` — run a simple health-check query (e.g. `SELECT 1`) + - `list_databases` — query the database catalog + - `list_tables` — query information_schema or equivalent; return `Vec` + - `get_table_structure` — return column definitions as `TableStructure` + - `get_table_metadata` — return row count, indexes, size as `TableMetadata` + - `get_table_ddl` — return `CREATE TABLE ...` DDL string + - `get_table_data` — paginated SELECT with optional sort/filter; return `TableDataResponse` + - `get_table_data_chunk` — same signature as `get_table_data`; implement identically unless streaming is needed + - `execute_query` — run arbitrary SQL; return `QueryResult` + - `execute_query_with_id` — only override if the DB supports cancellable queries; otherwise leave as default (already inherited from trait) + - `get_schema_overview` — return counts of tables/views per schema as `SchemaOverview` + - `close` — close pool / drop resources + + Error prefix conventions (must be followed exactly — frontend parses these): + - Connection errors: `[CONN_FAILED] ...` + - Query errors: `[QUERY_ERROR] ...` + - Validation errors: `[VALIDATION_ERROR] ...` + - Unsupported operations: `[NOT_SUPPORTED] ...` + + Use `super::strip_trailing_statement_terminator(&sql)` before executing user queries. + +--- + +### Step 2 — Register the driver in `mod.rs` + +**UPDATE** `src-tauri/src/db/drivers/mod.rs` + +1. Add at the top with the other `use self::` imports (keep alphabetical order): + ```rust + use self::{DRIVER_ID}::{DbName}Driver; + ``` + +2. Add with the other `pub mod` declarations (keep alphabetical order): + ```rust + pub mod {DRIVER_ID}; + ``` + +3. Add a match arm inside the `connect()` function (before the `_ => Err(...)` catch-all): + ```rust + "{DRIVER_ID}" => { + let driver = {DbName}Driver::connect(form).await?; + Ok(Box::new(driver) as Box) + } + ``` + +--- + +### Step 3 — Add SSH default port + +**UPDATE** `src-tauri/src/ssh.rs` + +In the `default_port` match block at approximately line 48: + +```rust +let default_port: i64 = match config.driver.to_ascii_lowercase().as_str() { + "mysql" => 3306, + "mssql" => 1433, + "clickhouse" => 9000, + "sqlite" => 0, + // ADD THIS LINE: + "{DRIVER_ID}" => {DEFAULT_PORT}, + _ => 5432, +}; +``` + +If `IS_FILE_BASED == true`, use `0` as the port value. + +--- + +### Step 4 — Update connection input normalization + +**UPDATE** `src-tauri/src/connection_input/mod.rs` + +- If `IS_MYSQL_FAMILY == true`: add `| "{DRIVER_ID}"` to the mysql-family match on line 57: + ```rust + if matches!(driver.as_str(), "mysql" | "mariadb" | "tidb" | "{DRIVER_ID}") { + ``` + +- If `IS_FILE_BASED == true`: add `| "{DRIVER_ID}"` to the file-based match on line 65: + ```rust + if matches!(driver.as_str(), "sqlite" | "duckdb" | "{DRIVER_ID}") { + ``` + +- If neither: no change needed to this file. + +--- + +### Step 5 — Add Cargo dependency + +**UPDATE** `src-tauri/Cargo.toml` + +Add the new crate under `[dependencies]`. Look up the latest stable version before adding. + +```toml +{RUST_CRATE} = "LATEST_VERSION" +``` + +Add any required feature flags based on the driver's needs (async runtime, TLS, connection pooling, etc.). + +--- + +### Step 6 — Register in frontend driver registry + +**UPDATE** `src/lib/driver-registry.tsx` + +This is the **single frontend entry point** — all other frontend files (`api.ts`, `rules.ts`, +`ConnectionList.tsx`, `helpers.tsx`) derive their data from this registry automatically. + +**6a.** Add `"{DRIVER_ID}"` to the `DRIVER_IDS` tuple (lines 16–25): + +```typescript +const DRIVER_IDS = [ + "postgres", + // ... existing entries ... + "{DRIVER_ID}", // ADD THIS +] as const; +``` + +**6b.** Add a `DriverConfig` entry to `DRIVER_REGISTRY` (lines 55–152), before the closing `];`: + +```typescript +{ + id: "{DRIVER_ID}", + label: "{DB_NAME}", + defaultPort: {DEFAULT_PORT}, // use null if IS_FILE_BASED == true + isFileBased: {IS_FILE_BASED}, + isMysqlFamily: {IS_MYSQL_FAMILY}, + supportsSSLCA: false, // true only if driver verifies SSL CA certs + supportsSchemaBrowsing: false, // true if driver exposes named schemas (like postgres/mssql) + supportsCreateDatabase: true, // false for file-based and read-only drivers + importCapability: "supported", // "supported" | "read_only_not_supported" | "unsupported" + icon: () => renderSimpleIcon(si{DbName}), // or if no simple-icons entry +}, +``` + +For the icon: check if `simple-icons` exports `si{DbName}`. If yes, add the import at the top of the file: +```typescript +import { ..., si{DbName} } from "simple-icons"; +``` +If no matching icon exists, use `` (already imported from lucide-react). + +--- + +### Step 7 — Create integration test files + +**CREATE** `src-tauri/tests/common/{DRIVER_ID}_context.rs` + +Follow the exact pattern of `src-tauri/tests/common/mysql_context.rs`: + +```rust +mod shared; + +use dbpaw_lib::models::ConnectionForm; +use std::env; +use std::time::Duration; +use testcontainers::clients::Cli; +use testcontainers::core::WaitFor; +use testcontainers::{Container, GenericImage, RunnableImage}; + +pub use shared::{connect_with_retry, should_reuse_local_db}; + +pub fn {DRIVER_ID}_form_from_test_context<'a>( + docker: Option<&'a Cli>, +) -> (Option>, ConnectionForm) { + if should_reuse_local_db() { + return (None, {DRIVER_ID}_form_from_local_env()); + } + shared::ensure_docker_available(); + + let docker = docker.expect("docker client is required when IT_REUSE_LOCAL_DB is not enabled"); + let image = GenericImage::new("{docker_image}", "{docker_tag}") + .with_env_var("{ENV_PREFIX}_PASSWORD", "123456") + .with_env_var("{ENV_PREFIX}_DATABASE", "test_db") + .with_wait_for(WaitFor::seconds(5)) + .with_exposed_port({DEFAULT_PORT}); + let runnable = + RunnableImage::from(image).with_container_name(shared::unique_container_name("{DRIVER_ID}")); + let container = docker.run(runnable); + let port = container.get_host_port_ipv4({DEFAULT_PORT}); + + shared::wait_for_port("127.0.0.1", port, Duration::from_secs(45)); + + let mut form = ConnectionForm { + driver: "{DRIVER_ID}".to_string(), + host: Some("127.0.0.1".to_string()), + port: Some(i64::from(port)), + username: Some("root".to_string()), + password: Some("123456".to_string()), + database: Some("test_db".to_string()), + ..Default::default() + }; + apply_{DRIVER_ID}_env_overrides(&mut form); + (Some(container), form) +} + +fn {DRIVER_ID}_form_from_local_env() -> ConnectionForm { + let mut form = ConnectionForm { + driver: "{DRIVER_ID}".to_string(), + host: Some(shared::env_or("{ENV_PREFIX}_HOST", "localhost")), + port: Some(shared::env_i64("{ENV_PREFIX}_PORT", {DEFAULT_PORT})), + username: Some(shared::env_or("{ENV_PREFIX}_USER", "root")), + password: Some(shared::env_or("{ENV_PREFIX}_PASSWORD", "123456")), + database: Some(shared::env_or("{ENV_PREFIX}_DB", "test_db")), + ..Default::default() + }; + apply_{DRIVER_ID}_env_overrides(&mut form); + form +} + +fn apply_{DRIVER_ID}_env_overrides(form: &mut ConnectionForm) { + if let Ok(host) = env::var("{ENV_PREFIX}_HOST") { form.host = Some(host); } + if let Ok(port) = env::var("{ENV_PREFIX}_PORT") { + form.port = Some(port.parse::().expect("{ENV_PREFIX}_PORT should be a valid number")); + } + if let Ok(user) = env::var("{ENV_PREFIX}_USER") { form.username = Some(user); } + if let Ok(password) = env::var("{ENV_PREFIX}_PASSWORD") { form.password = Some(password); } + if let Ok(database) = env::var("{ENV_PREFIX}_DB") { form.database = Some(database); } +} +``` + +Replace `{docker_image}` and `{docker_tag}` with the official Docker Hub image name and tag for this database. + +--- + +**CREATE** `src-tauri/tests/{DRIVER_ID}_integration.rs` + +Follow the pattern of `src-tauri/tests/mysql_integration.rs`: + +```rust +#[path = "common/{DRIVER_ID}_context.rs"] +mod {DRIVER_ID}_context; + +use dbpaw_lib::db::drivers::{DRIVER_ID}::{DbName}Driver; +use dbpaw_lib::db::drivers::DatabaseDriver; +use testcontainers::clients::Cli; + +#[tokio::test] +#[ignore] +async fn test_{DRIVER_ID}_integration_flow() { + let docker = (!{DRIVER_ID}_context::should_reuse_local_db()).then(Cli::default); + let (_container, form) = {DRIVER_ID}_context::{DRIVER_ID}_form_from_test_context(docker.as_ref()); + let database = form.database.clone(); + + let driver: {DbName}Driver = + {DRIVER_ID}_context::connect_with_retry(|| {DbName}Driver::connect(&form)).await; + + // 1. test_connection + let result = driver.test_connection().await; + assert!(result.is_ok(), "Connection failed: {:?}", result.err()); + + // 2. list_databases + let dbs = driver.list_databases().await; + assert!(dbs.is_ok(), "list_databases failed: {:?}", dbs.err()); + assert!(!dbs.unwrap().is_empty()); + + // 3. Per-database operations + if let Some(db_name) = database { + let table_name = "test_{DRIVER_ID}_integration"; + + // list_tables + let tables = driver.list_tables(Some(db_name.clone())).await; + assert!(tables.is_ok(), "list_tables failed: {:?}", tables.err()); + + // execute_query: create table (adapt DDL to target database SQL dialect) + let _ = driver.execute_query(format!( + "CREATE TABLE IF NOT EXISTS {} (id INT PRIMARY KEY, name VARCHAR(50))", + table_name + )).await.expect("create table failed"); + + // execute_query: insert + let _ = driver.execute_query(format!( + "DELETE FROM {} WHERE id = 1", table_name + )).await; + driver.execute_query(format!( + "INSERT INTO {} (id, name) VALUES (1, 'DbPaw')", table_name + )).await.expect("insert failed"); + + // execute_query: select + let result = driver.execute_query(format!( + "SELECT * FROM {} WHERE id = 1", table_name + )).await.expect("select failed"); + assert_eq!(result.row_count, 1); + if let Some(row) = result.data.first() { + assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("DbPaw")); + } + + // get_table_structure + let structure = driver.get_table_structure(db_name.clone(), table_name.to_string()).await; + assert!(structure.is_ok(), "get_table_structure failed: {:?}", structure.err()); + + // get_table_data + let data = driver.get_table_data( + db_name.clone(), table_name.to_string(), + 1, 20, None, None, None, None, + ).await; + assert!(data.is_ok(), "get_table_data failed: {:?}", data.err()); + + // get_table_ddl + let ddl = driver.get_table_ddl(db_name.clone(), table_name.to_string()).await; + assert!(ddl.is_ok(), "get_table_ddl failed: {:?}", ddl.err()); + + // get_schema_overview + let overview = driver.get_schema_overview(Some(db_name.clone())).await; + assert!(overview.is_ok(), "get_schema_overview failed: {:?}", overview.err()); + + // cleanup + let _ = driver.execute_query(format!("DROP TABLE {}", table_name)).await; + println!("{DB_NAME} integration test passed"); + } +} +``` + +--- + +**CREATE** `src-tauri/tests/{DRIVER_ID}_command_integration.rs` + +Follow the pattern of `src-tauri/tests/mysql_command_integration.rs`. Key points: +- Import `{DRIVER_ID}_context` with `#[path = "common/{DRIVER_ID}_context.rs"]` +- Use `connection::test_connection_ephemeral`, `metadata::*`, `query::execute_by_conn_direct` +- Test: ephemeral connect, list databases, list tables, execute query, get table structure + +--- + +### Step 8 — Update i18n (file-based drivers only) + +**Only if `IS_FILE_BASED == true`**, update all three locale files: +- `src/lib/i18n/locales/en.ts` +- `src/lib/i18n/locales/zh.ts` +- `src/lib/i18n/locales/ja.ts` + +Look for the `sqliteFilePath` / `duckdbFilePath` section and add analogous entries: +- `en.ts`: `{DRIVER_ID}FilePath: "{DB_NAME} File"`, `{DRIVER_ID}Path: "/path/to/db.{DRIVER_ID}"` +- `zh.ts`: appropriate Chinese translation +- `ja.ts`: appropriate Japanese translation + +--- + +### Step 9 — Update test integration script + +**UPDATE** `scripts/test-integration.sh` + +Add a named case for the new driver and include it in the `all)` block. Follow the exact pattern of existing cases in the file. + +--- + +## Final Verification + +Run these three checks and fix any errors before declaring done: + +```bash +bun run typecheck +bun run lint +cargo check --manifest-path src-tauri/Cargo.toml +``` + +Report the result of each check to the user. + +--- + +## Common Pitfalls + +- **Don't forget `ssh.rs`** — missing a case means SSH tunnel uses wrong default port silently +- **Error prefixes are parsed by the frontend** — must use `[CONN_FAILED]`, `[QUERY_ERROR]`, `[VALIDATION_ERROR]`, `[NOT_SUPPORTED]` exactly +- **`execute_query_with_id`** — do NOT override this unless the database actually supports query cancellation; the trait provides a sensible default +- **`get_table_data` vs `get_table_data_chunk`** — both must be implemented; they share the same signature; implement identically unless the driver has streaming support +- **Port type** — `ConnectionForm` uses `i64` for port; cast to `u16` with `form.port.unwrap_or({DEFAULT_PORT}) as u16` after validation +- **`strip_trailing_statement_terminator`** — call `super::strip_trailing_statement_terminator(&sql)` before executing user-provided SQL diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb298fc2..167de31a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,30 +31,18 @@ jobs: env: OUTPUT: CHANGELOG.md - - name: Generate Changelog (Chinese) - uses: orhun/git-cliff-action@v4 - id: git_cliff_zh - with: - config: cliff.zh.toml - args: --verbose --latest --strip header - env: - OUTPUT: CHANGELOG_CN.md - - name: Create or Update Draft Release uses: actions/github-script@v8 env: TAG_NAME: ${{ inputs.tag || github.ref_name }} - RELEASE_BODY_EN: ${{ steps.git_cliff_en.outputs.content || 'See the assets to download this version and install.' }} - RELEASE_BODY_ZH: ${{ steps.git_cliff_zh.outputs.content || '请下载附件安装此版本。' }} + RELEASE_BODY: ${{ steps.git_cliff_en.outputs.content || 'See the assets to download this version and install.' }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const tag = process.env.TAG_NAME; const owner = context.repo.owner; const repo = context.repo.repo; - const bodyEn = process.env.RELEASE_BODY_EN; - const bodyZh = process.env.RELEASE_BODY_ZH; - const body = bodyEn + "\n\n---\n\n**中文版**\n\n" + bodyZh; + const body = process.env.RELEASE_BODY; const releaseName = `DbPaw ${tag}`; const manualTag = context.payload?.inputs?.tag; diff --git a/ADD_NEW_DB.md b/ADD_NEW_DB.md new file mode 100644 index 00000000..c25ef4bd --- /dev/null +++ b/ADD_NEW_DB.md @@ -0,0 +1,342 @@ +# ADD_NEW_DB — DbPaw 新增数据库驱动操作手册 + +本文档记录新增一个数据库驱动类型时需要修改的全部文件,包含精确路径、行号和改法。 + +--- + +## 术语约定 + +- `{driver}` — 小写 driver ID,与前端 `DRIVER_IDS` 保持一致(例:`oracle`) +- `{DriverName}` — PascalCase(例:`Oracle`) +- `network` 型 — 通过 host:port 连接(postgres、mysql、mssql、clickhouse 等) +- `file` 型 — 通过本地文件路径连接(sqlite、duckdb) + +--- + +## Step 1:创建 Rust Driver 文件 + +**文件:** `src-tauri/src/db/drivers/{driver}.rs`(新建) + +参考模板选择: +- **PostgreSQL-like**(独立 schema、SSl CA、sqlx)→ 复制 `postgres.rs` +- **MySQL-like**(共享驱动、MySQL 协议)→ 复制 `mysql.rs` +- **HTTP API 型**(ClickHouse-like)→ 复制 `clickhouse.rs` +- **嵌入式/文件型**(无网络连接)→ 复制 `duckdb.rs` + +必须实现 `DatabaseDriver` trait 的全部方法(定义见 `src-tauri/src/db/drivers/mod.rs:64-121`): + +``` +test_connection, get_databases, get_table_names, get_table_structure, +get_table_info, get_table_data, execute_query, cancel_query, +get_schema_names, get_table_ddl, get_schema_overview, close +``` + +--- + +## Step 2:注册到 `drivers/mod.rs` + +**文件:** `src-tauri/src/db/drivers/mod.rs` + +### 2a. 顶部 use 语句(第 1-6 行附近) + +在现有的 `use self::...` 行中加入: + +```rust +use self::{driver}::{DriverName}Driver; +``` + +### 2b. mod 声明(第 13-18 行附近) + +在现有的 `pub mod ...` 行中加入: + +```rust +pub mod {driver}; +``` + +### 2c. `connect()` match 分支(第 133-163 行) + +在 `_ =>` 分支前加入: + +```rust +"{driver}" => { + let driver = {DriverName}Driver::connect(form).await?; + Ok(Box::new(driver) as Box) +} +``` + +**注意(MySQL family):** 如果是 MySQL 协议兼容的变体(如 PolarDB),可以复用 `MysqlDriver`,直接在第 139 行的现有 arm 里加 `| "{driver}"`: + +```rust +"mysql" | "tidb" | "mariadb" | "{driver}" => { +``` + +--- + +## Step 3:SSH 默认端口(仅 network 型) + +**文件:** `src-tauri/src/ssh.rs`,第 48-54 行 + +在 `_ => 5432` 之前加入一行: + +```rust +"{driver}" => {PORT}, +``` + +**示例(当前内容):** + +```rust +let default_port: i64 = match config.driver.to_ascii_lowercase().as_str() { + "mysql" => 3306, + "mssql" => 1433, + "clickhouse" => 9000, + "sqlite" => 0, + // ← 在这里加新 driver + _ => 5432, // postgres and unknown drivers +}; +``` + +**注意:** +- file 型 driver 不走 SSH 隧道的端口逻辑,但若要防止 fallback 到 5432,可加 `"sqlite" => 0,` 同款的占位。 +- 端口 0 不会通过第 56-58 行的校验(`1..=65535`),file 型 driver 传 `port=None` 即可,无需额外处理。 +- 忘记加这一行不会 crash,但 SSH 连接会用 5432 作为默认端口,导致隧道目标端口错误。 + +--- + +## Step 4:连接表单校验 + +**文件:** `src-tauri/src/connection_input/mod.rs` + +### 4a. network 型 — 无需修改 + +第 69-71 行的 `else if form.host.is_none()` 已覆盖所有 network 型 driver。 + +### 4b. file 型 — 第 65 行 + +在现有的 `matches!` 中加入新 driver: + +```rust +if matches!(driver.as_str(), "sqlite" | "duckdb" | "{driver}") { +``` + +### 4c. MySQL family(支持 host:port 嵌入语法)— 第 57 行 + +如果新 driver 允许 `host:port` 写法(如 `localhost:3307`),在现有 `matches!` 中加入: + +```rust +if matches!(driver.as_str(), "mysql" | "mariadb" | "tidb" | "{driver}") { +``` + +--- + +## Step 5:import/export 事务语法(如支持 import) + +**文件:** `src-tauri/src/commands/transfer.rs`,第 615-628 行(`import_transaction_sql` 函数) + +根据 driver 支持的事务语法加入 match arm: + +```rust +// BEGIN / COMMIT / ROLLBACK(与 postgres 相同) +"postgres" | "sqlite" | "duckdb" | "{driver}" => Ok(("BEGIN", "COMMIT", "ROLLBACK")), + +// 或 START TRANSACTION(MySQL 系) +"mysql" | "mariadb" | "tidb" | "{driver}" => Ok(("START TRANSACTION", "COMMIT", "ROLLBACK")), + +// 不支持 import +"{driver}" => Err("[UNSUPPORTED] Driver {driver} is read-only in this import flow".to_string()), +``` + +--- + +## Step 6:create_database 支持(如支持) + +**文件:** `src-tauri/src/commands/connection.rs` + +两处需要改(`create_database_by_id` 约第 262 行,`create_database_by_id_direct` 约第 343 行): + +**6a. 从"不支持"排除列表移除**(file 型专用黑名单,network 型无需改): + +```rust +// 第 262、343 行: +if matches!(driver.as_str(), "sqlite" | "duckdb") { // 不要在此加 network 型 driver +``` + +**6b. 在 match 中加入建库 SQL**(第 269-319、350-400 行): + +```rust +"{driver}" => { + let sql = format!("CREATE DATABASE {}", quote_ident(&db_name)); + super::execute_with_retry(&state, id, None, |driver| { + let sql_clone = sql.clone(); + async move { driver.execute_query(sql_clone).await.map(|_| ()) } + }) + .await +} +``` + +--- + +## Step 7:前端 driver-registry.tsx(必改) + +**文件:** `src/lib/driver-registry.tsx` + +### 7a. DRIVER_IDS(第 16-25 行) + +在 `as const` 数组中加入新 driver ID: + +```typescript +const DRIVER_IDS = [ + "postgres", + "mysql", + // ... + "{driver}", // ← 加在这里 +] as const; +``` + +### 7b. DRIVER_REGISTRY(第 55-152 行) + +在数组末尾(`];` 之前)加入一条记录: + +```typescript +{ + id: "{driver}", + label: "DisplayName", + defaultPort: 1234, // file 型填 null + isFileBased: false, // file 型填 true + isMysqlFamily: false, // MySQL 协议兼容时填 true + supportsSSLCA: false, // 支持 SSL CA 证书验证时填 true(需后端也支持) + supportsSchemaBrowsing: false, // 支持 schema 列表时填 true + supportsCreateDatabase: true, // 支持 CREATE DATABASE 时填 true + importCapability: "supported", // "supported" | "read_only_not_supported" | "unsupported" + icon: () => renderSimpleIcon(si{DriverName}), // 或 +}, +``` + +**图标规则:** +- 优先从 `simple-icons` 导入:`import { si{DriverName} } from "simple-icons";` +- 无 simple-icons 时用 ``(通用服务器图标)或 `` + +**这一个文件改完,以下前端逻辑自动生效(无需再改):** +- `src/services/api.ts` — `Driver` 类型 +- `src/lib/connection-form/rules.ts` — MySQL family / file-based 数组 +- `src/components/business/Sidebar/connection-list/helpers.tsx` — 图标映射 +- `src/components/business/Sidebar/ConnectionList.tsx` — SelectItem、默认 port、SSL/file 条件渲染 + +--- + +## Step 8:i18n(仅 file 型 driver) + +**文件:** `src/lib/i18n/locales/en.ts`、`zh.ts`、`ja.ts` + +file 型 driver 需要在三个 locale 文件里加"文件路径"标签和占位符。 + +在 `en.ts` 中搜索 `duckdbFilePath`(约第 221 行)附近加入: + +```typescript +{driver}FilePath: "{DriverName} File Path", +{driver}Path: "/path/to/db.{driver}", +``` + +zh.ts 和 ja.ts 同理加入对应翻译。 + +--- + +## Step 9:Cargo.toml 依赖 + +**文件:** `src-tauri/Cargo.toml` + +按驱动依赖类型选择: + +| 类型 | 做法 | +|------|------| +| 使用 sqlx(postgres/mysql 系)| 在 sqlx `features` 列表加 driver 名(第 34 行) | +| 独立 crate(如 DuckDB)| 加一行 `{driver} = { version = "x.y", features = [...] }` | +| HTTP 协议(如 ClickHouse)| 加 HTTP client 依赖(参考 clickhouse.rs 的 import) | +| 微软协议(MSSQL)| 使用 `tiberius`(已有,无需重复加) | + +--- + +## Step 10:集成测试骨架 + +**新建 3 个文件**(参考同类 driver 复制修改): + +``` +src-tauri/tests/common/{driver}_context.rs ← testcontainers 容器配置 +src-tauri/tests/{driver}_integration.rs ← DatabaseDriver trait 方法直接测试 +src-tauri/tests/{driver}_command_integration.rs ← Tauri command 层测试 +``` + +在 `src-tauri/tests/common/mod.rs` 中加入模块声明: + +```rust +pub mod {driver}_context; +``` + +更新 `scripts/test-integration.sh` 加入新 driver(搜索其他 driver 名的赋值行)。 + +**可选:** 如果 driver 支持多语句事务,创建: + +``` +src-tauri/tests/{driver}_stateful_command_integration.rs +``` + +参考 `postgres_stateful_command_integration.rs`。 + +--- + +## 验证 Checklist + +每次新增 driver 后执行: + +```bash +# 必须全部通过 +bun run typecheck +bun run lint +cargo check --manifest-path src-tauri/Cargo.toml + +# 有条件时执行 +bun run test:unit +IT_DB={driver} bun run test:integration # 需要 Docker +``` + +快速一键验证: + +```bash +bun run test:smoke # typecheck + lint + unit tests +``` + +--- + +## 常见陷阱 + +| 陷阱 | 后果 | 解法 | +|------|------|------| +| 忘记改 `ssh.rs` 默认端口 | SSH 隧道目标端口错误(fallback 到 5432) | Step 3 | +| file 型 driver 未加入 `connection_input` 的 matches! | 校验报"host cannot be empty"而不是"file path" | Step 4b | +| 前端 `DRIVER_IDS` 加了但 `DRIVER_REGISTRY` 没加 | TypeScript 编译报错,图标/port 逻辑异常 | Step 7 | +| 图标使用了不存在的 simple-icons 导出名 | 前端运行时崩溃 | 验证 `si{DriverName}` 是否存在于 `simple-icons` 包 | +| 忘记改 `import_transaction_sql` | import 功能对新 driver 返回"不支持"或使用错误事务语法 | Step 5 | +| MySQL family 新 driver 未加入 `connection_input` 的 mysql arm | `host:port` 嵌入写法不被解析 | Step 4c | +| i18n 只改了 en.ts | 中文/日文界面显示 key 字符串而非翻译文本 | Step 8 三个文件都要改 | + +--- + +## 文件改动汇总 + +| 文件 | 类型 | 条件 | +|------|------|------| +| `src-tauri/src/db/drivers/{driver}.rs` | 新建 | 必须 | +| `src-tauri/src/db/drivers/mod.rs` | 改 | 必须(3处) | +| `src-tauri/src/ssh.rs` | 改 | network 型 | +| `src-tauri/src/connection_input/mod.rs` | 改 | file 型或 MySQL family | +| `src-tauri/src/commands/transfer.rs` | 改 | 支持 import 时 | +| `src-tauri/src/commands/connection.rs` | 改 | 支持 create database 时 | +| `src-tauri/Cargo.toml` | 改 | 必须 | +| `src/lib/driver-registry.tsx` | 改 | 必须(前端唯一入口) | +| `src/lib/i18n/locales/en.ts` | 改 | file 型 | +| `src/lib/i18n/locales/zh.ts` | 改 | file 型 | +| `src/lib/i18n/locales/ja.ts` | 改 | file 型 | +| `src-tauri/tests/common/{driver}_context.rs` | 新建 | 集成测试 | +| `src-tauri/tests/{driver}_integration.rs` | 新建 | 集成测试 | +| `src-tauri/tests/{driver}_command_integration.rs` | 新建 | 集成测试 | +| `src-tauri/tests/common/mod.rs` | 改 | 集成测试 | +| `scripts/test-integration.sh` | 改 | 集成测试 | diff --git a/CLAUDE.md b/CLAUDE.md index a946e349..ab2ac0cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,8 @@ Database Drivers → Driver integration tests (*_integration.rs) ### Database Driver Development +For a complete step-by-step checklist (exact file paths, line numbers, and gotchas), see [ADD_NEW_DB.md](ADD_NEW_DB.md). Use the `/new-db` skill to scaffold automatically. + When adding/modifying database drivers: 1. Implement `DatabaseDriver` trait in `src-tauri/src/db/drivers/.rs` 2. Add to driver enum in `src-tauri/src/db/drivers/mod.rs` diff --git a/docs/table-selection-optimization.md b/docs/table-selection-optimization.md new file mode 100644 index 00000000..b6af5914 --- /dev/null +++ b/docs/table-selection-optimization.md @@ -0,0 +1,365 @@ +# 表格拖动选中效果优化方案 + +## 问题分析 + +经过代码分析,当前表格的拖动选中效果存在以下问题: + +### 1. 选中状态切换逻辑问题 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 543-561 行 + +在 `handleCellClick` 中,每次点击单元格都会**清空所有行选中状态**: +```tsx +const nextSelectedRows = new Set(); +selectedRowsRef.current = nextSelectedRows; +setSelectedRows(nextSelectedRows); +``` + +这导致用户无法通过拖动行号列来多选行后,再点击某个单元格保留行选中状态。 + +### 2. 不支持单元格区域拖动选择 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 543-561 行 + +当前的 `handleCellClick` 只支持单单元格点击选中,不支持类似 Excel 的拖动选择矩形区域。 + +### 3. 拖动选择体验不佳 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 489-519 行 + +- 行号列的拖动选择 (`handleIndexMouseDown` / `handleIndexMouseEnter`) 只支持**行选择**,不支持**单元格区域拖动选择** +- 没有视觉反馈指示当前正在拖动选择中 + +### 4. 选中样式过渡生硬 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 2009-2029 行 + +单元格的选中样式只有简单的背景色变化,缺少平滑过渡: +```tsx +selected && !editing + ? "bg-accent text-accent-foreground" + : "", +``` + +### 5. 混合选中状态不清晰 +- 单元格选中 (`selectedCell`) 和行选中 (`selectedRows`) 互斥,容易让用户困惑 +- 没有明确区分「单选单元格」和「多选行」的操作模式 + +### 6. 缺少键盘多选支持 +无法通过 `Shift+Click` 或 `Ctrl/Cmd+Click` 进行多选。 + +--- + +## 优化方案:单元格区域拖动选择 + +### 方案概述 +实现类似 Excel 的单元格区域拖动选择功能,允许用户通过鼠标拖动选择一个矩形区域的单元格。 + +### 需要修改的地方 + +#### 1. 新增状态管理 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 218-227 行之后 + +新增以下状态: +```tsx +// Cell range selection state (Excel-like drag selection) +const [selectedRange, setSelectedRange] = useState<{ + startRow: number; + endRow: number; + startColIndex: number; + endColIndex: number; +} | null>(null); +const [isRangeSelecting, setIsRangeSelecting] = useState(false); +const [rangeSelectionAnchor, setRangeSelectionAnchor] = useState<{ + row: number; + colIndex: number; +} | null>(null); +``` + +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 260-261 行之后 + +新增 ref: +```tsx +const selectedRangeRef = useRef<{ + startRow: number; + endRow: number; + startColIndex: number; + endColIndex: number; +} | null>(null); +``` + +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 267-270 行之后 + +添加 useEffect 同步: +```tsx +useEffect(() => { + selectedRangeRef.current = selectedRange; +}, [selectedRange]); +``` + +--- + +#### 2. 修改单元格交互逻辑 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 542-562 行 + +将 `handleCellClick` 替换为三个新函数: + +```tsx +// --- Cell interaction handlers --- +const handleCellMouseDown = useCallback( + (e: React.MouseEvent, rowIndex: number, colIndex: number, col: string) => { + if (e.button !== 0) return; // Only handle left click + + // If editing a different cell, commit first + if ( + editingCell && + (editingCell.row !== rowIndex || editingCell.col !== col) + ) { + commitEdit(); + } + + // Clear row selection when starting cell selection + const nextSelectedRows = new Set(); + selectedRowsRef.current = nextSelectedRows; + setSelectedRows(nextSelectedRows); + setRowSelectionAnchor(null); + setIsRowSelecting(false); + + // Start range selection + setIsRangeSelecting(true); + setRangeSelectionAnchor({ row: rowIndex, colIndex }); + + // Initialize range as single cell + const range = { + startRow: rowIndex, + endRow: rowIndex, + startColIndex: colIndex, + endColIndex: colIndex, + }; + setSelectedRange(range); + selectedRangeRef.current = range; + + // Also set selected cell for compatibility + const nextSelectedCell = { row: rowIndex, col }; + selectedCellRef.current = nextSelectedCell; + setSelectedCell(nextSelectedCell); + }, + [editingCell, commitEdit], +); + +const handleCellMouseEnter = useCallback( + (rowIndex: number, colIndex: number) => { + if (!isRangeSelecting || !rangeSelectionAnchor) return; + + // Calculate the normalized range (start <= end) + const startRow = Math.min(rangeSelectionAnchor.row, rowIndex); + const endRow = Math.max(rangeSelectionAnchor.row, rowIndex); + const startColIndex = Math.min(rangeSelectionAnchor.colIndex, colIndex); + const endColIndex = Math.max(rangeSelectionAnchor.colIndex, colIndex); + + const range = { startRow, endRow, startColIndex, endColIndex }; + setSelectedRange(range); + selectedRangeRef.current = range; + }, + [isRangeSelecting, rangeSelectionAnchor], +); + +const handleCellClick = useCallback( + (rowIndex: number, col: string) => { + // This is now called on mouseup, just ensure state is clean + // The actual selection logic is in handleCellMouseDown + }, + [], +); +``` + +--- + +#### 3. 添加鼠标释放事件处理 +**位置**: `src/components/business/DataGrid/TableView.tsx` 在 useEffect 中添加全局 mouseup 监听 + +在组件中添加以下 useEffect: +```tsx +// Handle mouse up to end range selection +useEffect(() => { + const handleMouseUp = () => { + setIsRangeSelecting(false); + }; + + if (isRangeSelecting) { + window.addEventListener('mouseup', handleMouseUp); + return () => window.removeEventListener('mouseup', handleMouseUp); + } +}, [isRangeSelecting]); +``` + +--- + +#### 4. 修改单元格渲染逻辑 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 2004-2083 行 + +修改 `` 的渲染: + +```tsx += selectedRange.startRow && + rowIndex <= selectedRange.endRow && + colIndex >= selectedRange.startColIndex && + colIndex <= selectedRange.endColIndex + ? "bg-primary/10 ring-1 ring-inset ring-primary/30" + : "", + // Single cell selected (when no range or range is single cell) + selected && !editing && !selectedRange + ? "bg-accent text-accent-foreground" + : "", + isRowSelected && !selected && !editing + ? "bg-accent/60" + : "", + matched && !editing + ? "bg-amber-100/60 dark:bg-amber-900/20" + : "", + activeSearchMatch && !editing + ? "border-b-2 border-b-amber-500/70" + : "", + modified && !editing + ? "border-l-2 border-l-orange-400" + : "", + isEditableForUpdates ? "cursor-pointer" : "", + ] + .filter(Boolean) + .join(" ")} + style={{ + width: getColWidth(column), + minWidth: 50, + }} + onMouseDown={(e) => handleCellMouseDown(e, rowIndex, colIndex, column)} + onMouseEnter={() => handleCellMouseEnter(rowIndex, colIndex)} + onClick={() => handleCellClick(rowIndex, column)} + onContextMenu={() => { + if (selectedRows.size > 1 && selectedRows.has(rowIndex)) { + return; + } + handleCellClick(rowIndex, column); + }} + onDoubleClick={() => + handleCellDoubleClick(rowIndex, column, row[column]) + } +> +``` + +--- + +#### 5. 优化选中区域的视觉效果 +**位置**: `src/components/business/DataGrid/TableView.tsx` 样式部分 + +建议添加以下 CSS 样式增强(可以在 tailwind.config.js 或全局 CSS 中): + +```css +/* 选中区域的单元格效果 */ +.cell-in-range { + @apply bg-primary/10 ring-1 ring-inset ring-primary/30; + transition: all 0.1s ease-out; +} + +/* 拖动选择时的视觉反馈 */ +.cell-selecting { + @apply bg-primary/15 ring-2 ring-inset ring-primary/50; +} + +/* 选中区域的活动单元格 */ +.cell-active-in-range { + @apply bg-accent text-accent-foreground font-medium; +} +``` + +--- + +#### 6. 更新复制逻辑以支持选中区域 +**位置**: `src/components/business/DataGrid/TableView.tsx` 复制相关的函数 + +需要添加一个辅助函数来获取选中范围内的数据: + +```tsx +const getSelectedRangeCopyText = useCallback(() => { + if (!selectedRange) return null; + + const { startRow, endRow, startColIndex, endColIndex } = selectedRange; + const rangeData: string[][] = []; + + for (let r = startRow; r <= endRow; r++) { + const rowData: string[] = []; + for (let c = startColIndex; c <= endColIndex; c++) { + const col = columns[c]; + const value = currentData[r]?.[col]; + const displayValue = getCellDisplayValue(r, col, value); + rowData.push( + displayValue === null || displayValue === undefined + ? "" + : String(displayValue) + ); + } + rangeData.push(rowData); + } + + return rangeData.map((row) => row.join("\t")).join("\n"); +}, [selectedRange, columns, currentData, getCellDisplayValue]); +``` + +--- + +#### 7. 更新右键菜单 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 2088-2191 行 + +在右键菜单中添加对选中范围的复制支持: + +```tsx + { + if (selectedRange) { + const text = getSelectedRangeCopyText(); + if (text) { + handleCopy(text); + } + } else if (selectedCell && selectedCell.row === rowIndex) { + const text = getSelectedCellCopyText(); + if (text !== null) { + handleCopy(text); + } + } + }} +> + + {selectedRange ? "Copy Range" : "Copy Cell"} + +``` + +--- + +## 修改文件清单 + +| 文件路径 | 修改内容 | +|---------|---------| +| `src/components/business/DataGrid/TableView.tsx` | 新增状态、修改交互逻辑、更新渲染逻辑 | + +--- + +## 预期效果 + +1. **拖动选择**: 用户可以在表格上按住鼠标左键拖动,选择一个矩形区域的单元格 +2. **视觉反馈**: 选中的区域会有半透明背景和边框高亮 +3. **平滑过渡**: 选中状态的切换有平滑的过渡动画 +4. **兼容性**: 保持原有的行选择功能(通过行号列)和单单元格选择功能 + +--- + +## 可选增强功能 + +如果需要进一步优化,可以考虑: + +1. **键盘辅助选择**: 支持 `Shift+Click` 范围选择、`Ctrl/Cmd+Click` 多选 +2. **拖动方向指示**: 在拖动过程中显示选择方向的箭头 +3. **选中区域统计**: 在状态栏显示选中区域的行数、列数 +4. **跨页选择**: 支持跨分页的单元格选择(需要更复杂的实现) diff --git a/package-lock.json b/package-lock.json index 390da769..6501ed68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dbpaw", - "version": "0.2.9", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dbpaw", - "version": "0.2.9", + "version": "0.3.1", "dependencies": { "@codemirror/lang-sql": "^6.10.0", "@codemirror/theme-one-dark": "^6.1.3", diff --git a/package.json b/package.json index 9fd14fe5..6331011d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "url": "git+https://github.com/codeErrorSleep/dbpaw.git" }, "private": true, - "version": "0.3.1", + "version": "0.3.2", "type": "module", "scripts": { "dev": "vite", diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh index bc644e28..b6079307 100755 --- a/scripts/test-integration.sh +++ b/scripts/test-integration.sh @@ -64,6 +64,10 @@ case "${it_db}" in run_integration_test "sqlite_integration" run_integration_test "sqlite_command_integration" ;; + oracle) + run_integration_test "oracle_integration" + run_integration_test "oracle_command_integration" + ;; all) run_integration_test "mysql_integration" run_integration_test "mysql_command_integration" @@ -81,9 +85,11 @@ case "${it_db}" in run_integration_test "duckdb_command_integration" run_integration_test "sqlite_integration" run_integration_test "sqlite_command_integration" + run_integration_test "oracle_integration" + run_integration_test "oracle_command_integration" ;; *) - echo "[error] Invalid IT_DB='${it_db}'. Expected one of: mysql|mariadb|postgres|clickhouse|mssql|duckdb|sqlite|all" + echo "[error] Invalid IT_DB='${it_db}'. Expected one of: mysql|mariadb|postgres|clickhouse|mssql|duckdb|sqlite|oracle|all" exit 1 ;; esac diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4799250a..7f44fe04 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1144,6 +1144,7 @@ dependencies = [ "chrono", "duckdb", "futures-util", + "oracle", "rand 0.8.5", "reqwest 0.12.28", "rust_decimal", @@ -3364,6 +3365,15 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "odpic-sys" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3448,6 +3458,32 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "oracle" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0" +dependencies = [ + "cc", + "odpic-sys", + "once_cell", + "oracle_procmacro", + "paste", + "rustversion", +] + +[[package]] +name = "oracle_procmacro" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27" +dependencies = [ + "darling 0.13.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3526,6 +3562,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6575ed17..76ff1e4c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,6 +44,7 @@ futures-util = "0.3" aes-gcm = "0.10" base64 = "0.22" duckdb = { version = "1.2.2", features = ["bundled"] } +oracle = "0.6" [dev-dependencies] testcontainers = "0.15.0" diff --git a/src-tauri/src/commands/transfer.rs b/src-tauri/src/commands/transfer.rs index ff1cf336..fbbe0f5f 100644 --- a/src-tauri/src/commands/transfer.rs +++ b/src-tauri/src/commands/transfer.rs @@ -620,6 +620,7 @@ fn import_transaction_sql<'a>( "COMMIT TRANSACTION", "ROLLBACK TRANSACTION", )), + "oracle" => Ok(("SELECT 1 FROM DUAL", "COMMIT", "ROLLBACK")), "clickhouse" => { Err("[UNSUPPORTED] Driver clickhouse is read-only in this import flow".to_string()) } @@ -1671,6 +1672,10 @@ mod tests { "ROLLBACK TRANSACTION" ) ); + assert_eq!( + import_transaction_sql("oracle", "oracle").unwrap(), + ("SELECT 1 FROM DUAL", "COMMIT", "ROLLBACK") + ); assert!(import_transaction_sql("clickhouse", "clickhouse").is_err()); } diff --git a/src-tauri/src/db/drivers/mod.rs b/src-tauri/src/db/drivers/mod.rs index f83d4076..803d6841 100644 --- a/src-tauri/src/db/drivers/mod.rs +++ b/src-tauri/src/db/drivers/mod.rs @@ -2,6 +2,7 @@ use self::clickhouse::ClickHouseDriver; use self::duckdb::DuckdbDriver; use self::mssql::MssqlDriver; use self::mysql::MysqlDriver; +use self::oracle::OracleDriver; use self::postgres::PostgresDriver; use self::sqlite::SqliteDriver; use crate::models::{ @@ -14,6 +15,7 @@ pub mod clickhouse; pub mod duckdb; pub mod mssql; pub mod mysql; +pub mod oracle; pub mod postgres; pub mod sqlite; @@ -24,7 +26,12 @@ pub(crate) fn conn_failed_error(e: &dyn std::fmt::Display) -> String { let raw = e.to_string(); let lower = raw.to_ascii_lowercase(); - let hint = if lower.contains("handshake") + let hint = if lower.contains("dpi-1047") || lower.contains("cannot locate a 64-bit oracle client") { + "hint: Oracle Instant Client is not installed — download it from \ + https://www.oracle.com/database/technologies/instant-client/downloads.html \ + and add the directory containing libclntsh to your library path \ + (macOS: DYLD_LIBRARY_PATH; Linux: LD_LIBRARY_PATH)" + } else if lower.contains("handshake") || lower.contains("fatal alert") || lower.contains("tls") || lower.contains("ssl") @@ -156,6 +163,10 @@ pub async fn connect(form: &ConnectionForm) -> Result, S let driver = MssqlDriver::connect(form).await?; Ok(Box::new(driver) as Box) } + "oracle" => { + let driver = OracleDriver::connect(form).await?; + Ok(Box::new(driver) as Box) + } _ => Err(format!( "[UNSUPPORTED] Driver {} not supported", form.driver @@ -167,6 +178,17 @@ pub async fn connect(form: &ConnectionForm) -> Result, S mod tests { use super::{conn_failed_error, strip_trailing_statement_terminator}; + #[test] + fn conn_failed_error_oracle_client_hint() { + let msg = conn_failed_error( + &"DPI-1047: Cannot locate a 64-bit Oracle Client library: \"dlopen(libclntsh.dylib, 0x0001): tried: '/usr/local/lib/libclntsh.dylib' (no such file)\"", + ); + assert!(msg.starts_with("[CONN_FAILED]")); + assert!(msg.contains("Oracle Instant Client is not installed")); + assert!(msg.contains("DYLD_LIBRARY_PATH")); + assert!(!msg.contains("TLS/SSL handshake failed")); + } + #[test] fn conn_failed_error_tls_hint() { let msg = conn_failed_error( diff --git a/src-tauri/src/db/drivers/mysql.rs b/src-tauri/src/db/drivers/mysql.rs index f1533bb0..4fa891a4 100644 --- a/src-tauri/src/db/drivers/mysql.rs +++ b/src-tauri/src/db/drivers/mysql.rs @@ -167,9 +167,12 @@ fn cleanup_ca_file_opt(path: Option<&PathBuf>) { fn is_prepared_protocol_unsupported_error(err: &str) -> bool { let lower = err.to_ascii_lowercase(); - lower.contains("1295") || lower.contains("prepared statement protocol") + lower.contains("1295") + || lower.contains("prepared statement protocol") + || lower.contains("preparedoes not support") // PolarDB-X } + impl Drop for MysqlDriver { fn drop(&mut self) { cleanup_ca_file_opt(self.ca_cert_path.as_ref()); @@ -1364,6 +1367,9 @@ mod tests { assert!(is_prepared_protocol_unsupported_error( "prepared statement protocol is unsupported" )); + assert!(is_prepared_protocol_unsupported_error( + "error returned from database: 0 (HYo00):[1b6d607a89402000][10.233.70.102:3306][polardbx]Preparedoes not support sql: SELECT 1" + )); assert!(!is_prepared_protocol_unsupported_error( "syntax error near ...", )); diff --git a/src-tauri/src/db/drivers/oracle.rs b/src-tauri/src/db/drivers/oracle.rs new file mode 100644 index 00000000..a11d461e --- /dev/null +++ b/src-tauri/src/db/drivers/oracle.rs @@ -0,0 +1,817 @@ +use super::{conn_failed_error, strip_trailing_statement_terminator, DatabaseDriver}; +use crate::models::{ + ColumnInfo, ColumnSchema, ConnectionForm, ForeignKeyInfo, IndexInfo, QueryColumn, QueryResult, + SchemaOverview, TableDataResponse, TableInfo, TableMetadata, TableSchema, TableStructure, +}; +use async_trait::async_trait; +use std::collections::HashMap; + +pub struct OracleDriver { + config: OracleConfig, + _ssh_tunnel: Option, +} + +#[derive(Clone)] +struct OracleConfig { + host: String, + port: u16, + /// Oracle Easy Connect service name (e.g. "ORCL", "FREE", "XE") + service_name: String, + username: String, + password: String, +} + +fn build_connect_string(cfg: &OracleConfig) -> String { + format!("//{}:{}/{}", cfg.host, cfg.port, cfg.service_name) +} + +/// Oracle uses double-quote identifiers. Upper-case is the Oracle default. +fn quote_ident(ident: &str) -> String { + format!("\"{}\"", ident.replace('"', "\"\"")) +} + +fn escape_literal(value: &str) -> String { + value.replace('\'', "''") +} + +fn first_sql_keyword(sql: &str) -> Option { + let bytes = sql.as_bytes(); + let len = bytes.len(); + let mut i = 0; + loop { + while i < len && (bytes[i].is_ascii_whitespace() || bytes[i] == b';') { + i += 1; + } + if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' { + i += 2; + while i < len && bytes[i] != b'\n' { + i += 1; + } + continue; + } + if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' { + i += 2; + while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + if i + 1 >= len { + return None; + } + i += 2; + continue; + } + break; + } + if i >= len { + return None; + } + let start = i; + while i < len && bytes[i].is_ascii_alphabetic() { + i += 1; + } + if start == i { + return None; + } + Some(sql[start..i].to_ascii_lowercase()) +} + +/// Convert a single Oracle column value (by index) into a `serde_json::Value`. +/// +/// Strategy: try integer → float → bytes → string → null. This cascade avoids +/// the need to inspect `OracleType` while still producing sensible JSON types +/// for the common Oracle types (NUMBER, VARCHAR2, DATE, TIMESTAMP, CLOB, BLOB). +/// +/// Precision note: very large NUMBER values (> i64::MAX) fall through to f64, +/// which may lose precision. This is acceptable for a v1 display client. +fn oracle_value_to_json(row: &oracle::Row, idx: usize) -> serde_json::Value { + // Try integer (covers NUMBER(p,0), INTEGER, SMALLINT, etc.) + match row.get::<_, Option>(idx) { + Ok(None) => return serde_json::Value::Null, + Ok(Some(v)) => return serde_json::Value::Number(v.into()), + Err(_) => {} + } + // Try float (covers NUMBER(p,s) with fractional part, FLOAT, BINARY_FLOAT, etc.) + match row.get::<_, Option>(idx) { + Ok(None) => return serde_json::Value::Null, + Ok(Some(v)) => { + if let Some(n) = serde_json::Number::from_f64(v) { + return serde_json::Value::Number(n); + } + return serde_json::Value::String(v.to_string()); + } + Err(_) => {} + } + // Try bytes (covers BLOB, RAW — returned as hex string) + match row.get::<_, Option>>(idx) { + Ok(None) => return serde_json::Value::Null, + Ok(Some(v)) => { + return serde_json::Value::String(v.iter().map(|b| format!("{b:02x}")).collect()); + } + Err(_) => {} + } + // Try string (covers VARCHAR2, NVARCHAR2, CHAR, DATE, TIMESTAMP, CLOB, etc.) + match row.get::<_, Option>(idx) { + Ok(None) => serde_json::Value::Null, + Ok(Some(v)) => serde_json::Value::String(v), + Err(_) => serde_json::Value::Null, + } +} + +impl OracleDriver { + pub async fn connect(form: &ConnectionForm) -> Result { + let mut effective_form = form.clone(); + let mut ssh_tunnel = None; + + if let Some(true) = form.ssh_enabled { + let tunnel = crate::ssh::start_ssh_tunnel(form)?; + effective_form.host = Some("127.0.0.1".to_string()); + effective_form.port = Some(tunnel.local_port as i64); + ssh_tunnel = Some(tunnel); + } + + let host = effective_form + .host + .clone() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .ok_or("[VALIDATION_ERROR] host cannot be empty")?; + let port = effective_form.port.unwrap_or(1521); + if !(1..=65535).contains(&port) { + return Err("[VALIDATION_ERROR] port out of range".to_string()); + } + let service_name = effective_form + .database + .clone() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "ORCL".to_string()); + let username = effective_form + .username + .clone() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .ok_or("[VALIDATION_ERROR] username cannot be empty")?; + let password = effective_form.password.clone().unwrap_or_default(); + + let config = OracleConfig { + host, + port: port as u16, + service_name, + username, + password, + }; + let driver = Self { + config, + _ssh_tunnel: ssh_tunnel, + }; + driver.test_connection().await?; + Ok(driver) + } + + /// Run a blocking Oracle OCI call on tokio's blocking thread pool. + /// + /// A fresh `oracle::Connection` is created for each call (reconnect-per-call + /// pattern). This avoids the complexity of sharing a `!Sync` OCI handle + /// across async tasks at the cost of a new TCP handshake per driver method. + /// For a desktop database-client with low concurrency this is acceptable. + /// + /// DML statements are followed by an explicit `conn.commit()` so that the + /// work is persisted even though the connection is dropped at the end of + /// the closure. + async fn run_blocking(&self, f: F) -> Result + where + F: FnOnce(oracle::Connection) -> Result + Send + 'static, + T: Send + 'static, + { + let cfg = self.config.clone(); + tokio::task::spawn_blocking(move || { + let connect_string = build_connect_string(&cfg); + let conn = + oracle::Connection::connect(&cfg.username, &cfg.password, &connect_string) + .map_err(|e| conn_failed_error(&e))?; + f(conn) + }) + .await + .map_err(|e| format!("[ORACLE_ERROR] {e}"))? + } +} + +#[async_trait] +impl DatabaseDriver for OracleDriver { + async fn close(&self) { + // No persistent connection to close. + } + + async fn test_connection(&self) -> Result<(), String> { + self.run_blocking(|conn| { + conn.query("SELECT 1 FROM DUAL", &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[CONN_FAILED] {e}"))? + .next() + .ok_or("[CONN_FAILED] Empty response from DUAL")? + .map_err(|e| format!("[CONN_FAILED] {e}"))?; + Ok(()) + }) + .await + } + + /// In Oracle, "databases" map to schemas (users visible via ALL_USERS). + async fn list_databases(&self) -> Result, String> { + self.run_blocking(|conn| { + let rows = conn + .query( + "SELECT USERNAME FROM ALL_USERS ORDER BY USERNAME", + &[] as &[&dyn oracle::sql_type::ToSql], + ) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut result = Vec::new(); + for row_result in rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let name: Option = row.get(0).ok().flatten(); + if let Some(n) = name { + if !n.is_empty() { + result.push(n); + } + } + } + Ok(result) + }) + .await + } + + async fn list_tables(&self, schema: Option) -> Result, String> { + let schema_upper = schema + .map(|s| s.trim().to_uppercase()) + .filter(|s| !s.is_empty()); + self.run_blocking(move |conn| { + let sql = if let Some(ref s) = schema_upper { + format!( + "SELECT OWNER, TABLE_NAME, 'table' AS TABLE_TYPE \ + FROM ALL_TABLES WHERE OWNER = '{}' \ + UNION ALL \ + SELECT OWNER, VIEW_NAME, 'view' \ + FROM ALL_VIEWS WHERE OWNER = '{}' \ + ORDER BY 1, 2", + escape_literal(s), + escape_literal(s), + ) + } else { + "SELECT OWNER, TABLE_NAME, 'table' AS TABLE_TYPE \ + FROM ALL_TABLES \ + UNION ALL \ + SELECT OWNER, VIEW_NAME, 'view' \ + FROM ALL_VIEWS \ + ORDER BY 1, 2" + .to_string() + }; + let rows = conn + .query(&sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut result = Vec::new(); + for row_result in rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let schema_name: Option = row.get(0).ok().flatten(); + let table_name: Option = row.get(1).ok().flatten(); + let table_type: Option = row.get(2).ok().flatten(); + if let (Some(s), Some(t), Some(ty)) = (schema_name, table_name, table_type) { + result.push(TableInfo { + schema: s, + name: t, + r#type: ty, + }); + } + } + Ok(result) + }) + .await + } + + async fn get_table_structure( + &self, + schema: String, + table: String, + ) -> Result { + self.run_blocking(move |conn| { + // Primary keys + let pk_sql = format!( + "SELECT ac.COLUMN_NAME \ + FROM ALL_CONSTRAINTS con \ + JOIN ALL_CONS_COLUMNS ac \ + ON con.CONSTRAINT_NAME = ac.CONSTRAINT_NAME \ + AND con.OWNER = ac.OWNER \ + WHERE con.CONSTRAINT_TYPE = 'P' \ + AND con.OWNER = '{}' \ + AND con.TABLE_NAME = '{}' \ + ORDER BY ac.POSITION", + escape_literal(&schema.to_uppercase()), + escape_literal(&table.to_uppercase()), + ); + let pk_rows = conn + .query(&pk_sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut pk_set = std::collections::HashSet::::new(); + for row_result in pk_rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let col: Option = row.get(0).ok().flatten(); + if let Some(c) = col { + pk_set.insert(c); + } + } + + // Columns + let col_sql = format!( + "SELECT \ + COLUMN_NAME, \ + DATA_TYPE || \ + CASE \ + WHEN DATA_TYPE IN ('VARCHAR2','NVARCHAR2','CHAR','NCHAR') \ + THEN '(' || CHAR_LENGTH || ')' \ + WHEN DATA_TYPE = 'NUMBER' AND DATA_PRECISION IS NOT NULL \ + THEN '(' || DATA_PRECISION || \ + CASE WHEN DATA_SCALE > 0 THEN ',' || DATA_SCALE ELSE '' END \ + || ')' \ + ELSE '' \ + END AS FULL_TYPE, \ + NULLABLE, \ + DATA_DEFAULT \ + FROM ALL_TAB_COLUMNS \ + WHERE OWNER = '{}' AND TABLE_NAME = '{}' \ + ORDER BY COLUMN_ID", + escape_literal(&schema.to_uppercase()), + escape_literal(&table.to_uppercase()), + ); + let col_rows = conn + .query(&col_sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut columns = Vec::new(); + for row_result in col_rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let name: Option = row.get(0).ok().flatten(); + let col_type: Option = row.get(1).ok().flatten(); + let nullable: Option = row.get(2).ok().flatten(); + let default_val: Option = row.get(3).ok().flatten(); + if let (Some(name), Some(col_type)) = (name, col_type) { + let is_nullable = nullable.as_deref() != Some("N"); + let default_value = default_val + .map(|d| d.trim().to_string()) + .filter(|d| !d.is_empty()); + let primary_key = pk_set.contains(&name); + columns.push(ColumnInfo { + name, + r#type: col_type, + nullable: is_nullable, + default_value, + primary_key, + comment: None, + }); + } + } + Ok(TableStructure { columns }) + }) + .await + } + + async fn get_table_metadata( + &self, + schema: String, + table: String, + ) -> Result { + let columns = self + .get_table_structure(schema.clone(), table.clone()) + .await? + .columns; + + let (indexes, foreign_keys) = self + .run_blocking(move |conn| { + // Indexes + let idx_sql = format!( + "SELECT i.INDEX_NAME, \ + CASE WHEN i.UNIQUENESS = 'UNIQUE' THEN 1 ELSE 0 END AS IS_UNIQUE, \ + i.INDEX_TYPE, \ + ic.COLUMN_NAME, \ + ic.COLUMN_POSITION \ + FROM ALL_INDEXES i \ + JOIN ALL_IND_COLUMNS ic \ + ON ic.INDEX_NAME = i.INDEX_NAME \ + AND ic.TABLE_OWNER = i.TABLE_OWNER \ + WHERE i.TABLE_OWNER = '{}' \ + AND i.TABLE_NAME = '{}' \ + ORDER BY i.INDEX_NAME, ic.COLUMN_POSITION", + escape_literal(&schema.to_uppercase()), + escape_literal(&table.to_uppercase()), + ); + let idx_rows = conn + .query(&idx_sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut idx_map: HashMap, Vec<(i64, String)>)> = + HashMap::new(); + for row_result in idx_rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let idx_name: Option = row.get(0).ok().flatten(); + let is_unique: Option = row.get(1).ok().flatten(); + let idx_type: Option = row.get(2).ok().flatten(); + let col_name: Option = row.get(3).ok().flatten(); + let position: Option = row.get(4).ok().flatten(); + if let (Some(name), Some(col_name)) = (idx_name, col_name) { + let unique = is_unique.unwrap_or(0) == 1; + let pos = position.unwrap_or(0); + let entry = idx_map + .entry(name) + .or_insert((unique, idx_type.clone(), Vec::new())); + entry.0 = unique; + if entry.1.is_none() { + entry.1 = idx_type; + } + entry.2.push((pos, col_name)); + } + } + let mut indexes: Vec = idx_map + .into_iter() + .map(|(name, (unique, index_type, mut cols))| { + cols.sort_by_key(|(pos, _)| *pos); + IndexInfo { + name, + unique, + index_type, + columns: cols.into_iter().map(|(_, c)| c).collect(), + } + }) + .collect(); + indexes.sort_by(|a, b| a.name.cmp(&b.name)); + + // Foreign keys + let fk_sql = format!( + "SELECT c.CONSTRAINT_NAME, \ + cc.COLUMN_NAME, \ + rc.OWNER AS REF_OWNER, \ + rc.TABLE_NAME AS REF_TABLE, \ + rcc.COLUMN_NAME AS REF_COLUMN, \ + c.DELETE_RULE \ + FROM ALL_CONSTRAINTS c \ + JOIN ALL_CONS_COLUMNS cc \ + ON cc.CONSTRAINT_NAME = c.CONSTRAINT_NAME \ + AND cc.OWNER = c.OWNER \ + JOIN ALL_CONSTRAINTS rc \ + ON rc.CONSTRAINT_NAME = c.R_CONSTRAINT_NAME \ + AND rc.OWNER = c.R_OWNER \ + JOIN ALL_CONS_COLUMNS rcc \ + ON rcc.CONSTRAINT_NAME = rc.CONSTRAINT_NAME \ + AND rcc.OWNER = rc.OWNER \ + AND rcc.POSITION = cc.POSITION \ + WHERE c.CONSTRAINT_TYPE = 'R' \ + AND c.OWNER = '{}' \ + AND c.TABLE_NAME = '{}' \ + ORDER BY c.CONSTRAINT_NAME, cc.POSITION", + escape_literal(&schema.to_uppercase()), + escape_literal(&table.to_uppercase()), + ); + let fk_rows = conn + .query(&fk_sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut foreign_keys = Vec::new(); + for row_result in fk_rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let fk_name: Option = row.get(0).ok().flatten(); + let col_name: Option = row.get(1).ok().flatten(); + let ref_schema: Option = row.get(2).ok().flatten(); + let ref_table: Option = row.get(3).ok().flatten(); + let ref_col: Option = row.get(4).ok().flatten(); + let delete_rule: Option = row.get(5).ok().flatten(); + if let (Some(fk_name), Some(col_name), Some(ref_table), Some(ref_col)) = + (fk_name, col_name, ref_table, ref_col) + { + foreign_keys.push(ForeignKeyInfo { + name: fk_name, + column: col_name, + referenced_schema: ref_schema, + referenced_table: ref_table, + referenced_column: ref_col, + on_update: None, // Oracle does not support ON UPDATE in FK constraints + on_delete: delete_rule, + }); + } + } + Ok((indexes, foreign_keys)) + }) + .await?; + + Ok(TableMetadata { + columns, + indexes, + foreign_keys, + clickhouse_extra: None, + }) + } + + /// Returns the table DDL using DBMS_METADATA.GET_DDL. + /// Requires EXECUTE privilege on DBMS_METADATA (granted to public in most installs). + async fn get_table_ddl(&self, schema: String, table: String) -> Result { + self.run_blocking(move |conn| { + let sql = format!( + "SELECT DBMS_METADATA.GET_DDL('TABLE', '{}', '{}') AS DDL FROM DUAL", + escape_literal(&table.to_uppercase()), + escape_literal(&schema.to_uppercase()), + ); + let rows = conn + .query(&sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + for row_result in rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let ddl: Option = row.get(0).ok().flatten(); + if let Some(d) = ddl { + return Ok(d.trim().to_string()); + } + } + Err("[QUERY_ERROR] DBMS_METADATA.GET_DDL returned no result".to_string()) + }) + .await + } + + /// Paginated table data. Requires Oracle 12c+ for OFFSET/FETCH syntax. + async fn get_table_data( + &self, + schema: String, + table: String, + page: i64, + limit: i64, + sort_column: Option, + sort_direction: Option, + filter: Option, + order_by: Option, + ) -> Result { + let start = std::time::Instant::now(); + let safe_page = if page < 1 { 1 } else { page }; + let safe_limit = if limit < 1 { 100 } else { limit }; + let offset = (safe_page - 1) * safe_limit; + + let filter = filter.map(|f| super::normalize_quotes(&f)); + let order_by = order_by.map(|f| super::normalize_quotes(&f)); + + let table_ref = format!( + "{}.{}", + quote_ident(&schema.to_uppercase()), + quote_ident(&table.to_uppercase()) + ); + + let where_clause = match &filter { + Some(f) if !f.trim().is_empty() => format!(" WHERE {}", f.trim()), + _ => String::new(), + }; + + let order_clause = if let Some(ref raw) = order_by { + if raw.trim().is_empty() { + String::new() + } else { + format!(" ORDER BY {}", raw.trim()) + } + } else if let Some(ref col) = sort_column { + let dir = if matches!(sort_direction.as_deref(), Some("desc")) { + "DESC" + } else { + "ASC" + }; + format!(" ORDER BY {} {}", quote_ident(col), dir) + } else { + String::new() + }; + + self.run_blocking(move |conn| { + // Total count + let count_sql = format!("SELECT COUNT(*) FROM {}{}", table_ref, where_clause); + let count_rows = conn + .query(&count_sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut total: i64 = 0; + for row_result in count_rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + total = row.get::<_, Option>(0).ok().flatten().unwrap_or(0); + } + + // Paginated data (Oracle 12c+ OFFSET/FETCH) + let data_sql = format!( + "SELECT * FROM {}{}{} OFFSET {} ROWS FETCH NEXT {} ROWS ONLY", + table_ref, where_clause, order_clause, offset, safe_limit + ); + let rows = conn + .query(&data_sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + + // Collect column metadata before consuming rows + let col_names: Vec = rows + .column_info() + .iter() + .map(|c| c.name().to_string()) + .collect(); + + let mut data = Vec::new(); + for row_result in rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut map = serde_json::Map::new(); + for (i, name) in col_names.iter().enumerate() { + map.insert(name.clone(), oracle_value_to_json(&row, i)); + } + data.push(serde_json::Value::Object(map)); + } + + Ok(TableDataResponse { + data, + total, + page: safe_page, + limit: safe_limit, + execution_time_ms: start.elapsed().as_millis() as i64, + }) + }) + .await + } + + async fn get_table_data_chunk( + &self, + schema: String, + table: String, + page: i64, + limit: i64, + sort_column: Option, + sort_direction: Option, + filter: Option, + order_by: Option, + ) -> Result { + self.get_table_data( + schema, + table, + page, + limit, + sort_column, + sort_direction, + filter, + order_by, + ) + .await + } + + async fn execute_query(&self, sql: String) -> Result { + let start = std::time::Instant::now(); + let sql_clean = strip_trailing_statement_terminator(&sql).to_string(); + let first_kw = first_sql_keyword(&sql_clean); + let is_read = matches!( + first_kw.as_deref(), + Some("select") | Some("with") | Some("show") + ); + + self.run_blocking(move |conn| { + if is_read { + let rows = conn + .query(&sql_clean, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + + // Collect column metadata before consuming rows + let col_info: Vec<(String, String)> = rows + .column_info() + .iter() + .map(|c| (c.name().to_string(), format!("{}", c.oracle_type()))) + .collect(); + let columns: Vec = col_info + .iter() + .map(|(name, ty)| QueryColumn { + name: name.clone(), + r#type: ty.clone(), + }) + .collect(); + + let mut data = Vec::new(); + for row_result in rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut map = serde_json::Map::new(); + for (i, (name, _)) in col_info.iter().enumerate() { + map.insert(name.clone(), oracle_value_to_json(&row, i)); + } + data.push(serde_json::Value::Object(map)); + } + + Ok(QueryResult { + row_count: data.len() as i64, + data, + columns, + time_taken_ms: start.elapsed().as_millis() as i64, + success: true, + error: None, + }) + } else { + // DML or DDL — use Statement API to get affected-row count + let mut stmt = conn + .statement(&sql_clean) + .build() + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + stmt.execute(&[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let row_count = stmt.row_count().unwrap_or(0) as i64; + // Commit so the change is visible after the connection closes. + conn.commit().map_err(|e| format!("[QUERY_ERROR] commit failed: {e}"))?; + Ok(QueryResult { + row_count, + data: vec![], + columns: vec![], + time_taken_ms: start.elapsed().as_millis() as i64, + success: true, + error: None, + }) + } + }) + .await + } + + async fn get_schema_overview(&self, schema: Option) -> Result { + let schema_upper = schema + .map(|s| s.trim().to_uppercase()) + .filter(|s| !s.is_empty()); + self.run_blocking(move |conn| { + let sql = if let Some(ref s) = schema_upper { + format!( + "SELECT OWNER, TABLE_NAME, COLUMN_NAME, DATA_TYPE \ + FROM ALL_TAB_COLUMNS \ + WHERE OWNER = '{}' \ + ORDER BY OWNER, TABLE_NAME, COLUMN_ID", + escape_literal(s), + ) + } else { + "SELECT OWNER, TABLE_NAME, COLUMN_NAME, DATA_TYPE \ + FROM ALL_TAB_COLUMNS \ + ORDER BY OWNER, TABLE_NAME, COLUMN_ID" + .to_string() + }; + + let rows = conn + .query(&sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut table_map: HashMap<(String, String), Vec> = HashMap::new(); + for row_result in rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let schema_name: Option = row.get(0).ok().flatten(); + let table_name: Option = row.get(1).ok().flatten(); + let col_name: Option = row.get(2).ok().flatten(); + let col_type: Option = row.get(3).ok().flatten(); + if let (Some(sn), Some(tn), Some(cn), Some(ct)) = + (schema_name, table_name, col_name, col_type) + { + table_map + .entry((sn, tn)) + .or_default() + .push(ColumnSchema { name: cn, r#type: ct }); + } + } + let mut tables: Vec = table_map + .into_iter() + .map(|((s, n), cols)| TableSchema { + schema: s, + name: n, + columns: cols, + }) + .collect(); + tables.sort_by(|a, b| a.schema.cmp(&b.schema).then(a.name.cmp(&b.name))); + Ok(SchemaOverview { tables }) + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::{escape_literal, first_sql_keyword, quote_ident}; + + #[test] + fn quote_ident_wraps_in_double_quotes() { + assert_eq!(quote_ident("MY_TABLE"), "\"MY_TABLE\""); + } + + #[test] + fn quote_ident_escapes_embedded_double_quote() { + assert_eq!(quote_ident("a\"b"), "\"a\"\"b\""); + } + + #[test] + fn escape_literal_escapes_single_quote() { + assert_eq!(escape_literal("O'Brien"), "O''Brien"); + } + + #[test] + fn first_sql_keyword_extracts_select() { + assert_eq!( + first_sql_keyword(" SELECT id FROM t"), + Some("select".to_string()) + ); + } + + #[test] + fn first_sql_keyword_skips_comments() { + assert_eq!( + first_sql_keyword("-- comment\nINSERT INTO t VALUES(1)"), + Some("insert".to_string()) + ); + } + + #[test] + fn first_sql_keyword_identifies_with() { + assert_eq!( + first_sql_keyword("WITH cte AS (SELECT 1) SELECT * FROM cte"), + Some("with".to_string()) + ); + } +} diff --git a/src-tauri/src/db/drivers/postgres.rs b/src-tauri/src/db/drivers/postgres.rs index e1d1bad9..ad351af4 100644 --- a/src-tauri/src/db/drivers/postgres.rs +++ b/src-tauri/src/db/drivers/postgres.rs @@ -1066,6 +1066,138 @@ impl DatabaseDriver for PostgresDriver { .ok() .map(|v| v.0) .unwrap_or(serde_json::Value::Null), + // PostgreSQL array types (element type prefixed with _) + "_BOOL" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(b) => serde_json::Value::Bool(b), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_INT2" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(n) => serde_json::Value::Number(n.into()), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_INT4" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(n) => serde_json::Value::Number(n.into()), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_INT8" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(n) => serde_json::Value::Number(n.into()), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_FLOAT4" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(f) => serde_json::Number::from_f64(f as f64) + .map(serde_json::Value::Number) + .unwrap_or_else(|| { + serde_json::Value::String(f.to_string()) + }), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_FLOAT8" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(f) => serde_json::Number::from_f64(f) + .map(serde_json::Value::Number) + .unwrap_or_else(|| { + serde_json::Value::String(f.to_string()) + }), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_NUMERIC" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(d) => serde_json::Value::String(d.to_string()), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_TEXT" | "_VARCHAR" | "_BPCHAR" | "_NAME" | "_UUID" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(s) => serde_json::Value::String(s), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_JSON" | "_JSONB" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| o.unwrap_or(serde_json::Value::Null)) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), _ => { if let Ok(v) = row.try_get::(name) { serde_json::Value::String(v) diff --git a/src-tauri/src/ssh.rs b/src-tauri/src/ssh.rs index 3c91e17a..75f7bcb8 100644 --- a/src-tauri/src/ssh.rs +++ b/src-tauri/src/ssh.rs @@ -48,6 +48,7 @@ pub fn start_ssh_tunnel(config: &ConnectionForm) -> Result { let default_port: i64 = match config.driver.to_ascii_lowercase().as_str() { "mysql" => 3306, "mssql" => 1433, + "oracle" => 1521, "clickhouse" => 9000, "sqlite" => 0, _ => 5432, // postgres and unknown drivers diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9782f466..869d8ad0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "DbPaw", - "version": "0.3.1", + "version": "0.3.2", "identifier": "com.father.dbpaw", "build": { "beforeDevCommand": "bun run dev", diff --git a/src-tauri/tests/common/oracle_context.rs b/src-tauri/tests/common/oracle_context.rs new file mode 100644 index 00000000..0b0ccd9d --- /dev/null +++ b/src-tauri/tests/common/oracle_context.rs @@ -0,0 +1,39 @@ +mod shared; + +use dbpaw_lib::models::ConnectionForm; + +pub use shared::{connect_with_retry, should_reuse_local_db}; + +/// Oracle has no freely-distributable Docker image (the Oracle Database Free image +/// at container-registry.oracle.com requires an Oracle account and terms acceptance). +/// Integration tests therefore only support IT_REUSE_LOCAL_DB=1 mode. +/// +/// Required environment variables (all have defaults): +/// ORACLE_HOST – defaults to "localhost" +/// ORACLE_PORT – defaults to 1521 +/// ORACLE_USER – defaults to "system" +/// ORACLE_PASSWORD – no default (required) +/// ORACLE_SERVICE – defaults to "FREE" (service name / SID) +/// ORACLE_SCHEMA – defaults to "SYSTEM" (schema to use for test tables) +pub fn oracle_form_from_test_context() -> ConnectionForm { + if !should_reuse_local_db() { + panic!( + "Oracle integration tests require a local Oracle instance. \ + Set IT_REUSE_LOCAL_DB=1 and provide ORACLE_HOST/PORT/USER/PASSWORD/SERVICE env vars." + ); + } + oracle_form_from_local_env() +} + +fn oracle_form_from_local_env() -> ConnectionForm { + ConnectionForm { + driver: "oracle".to_string(), + host: Some(shared::env_or("ORACLE_HOST", "localhost")), + port: Some(shared::env_i64("ORACLE_PORT", 1521)), + username: Some(shared::env_or("ORACLE_USER", "system")), + password: Some(shared::env_or("ORACLE_PASSWORD", "")), + database: Some(shared::env_or("ORACLE_SERVICE", "FREE")), + schema: Some(shared::env_or("ORACLE_SCHEMA", "SYSTEM")), + ..Default::default() + } +} diff --git a/src-tauri/tests/oracle_command_integration.rs b/src-tauri/tests/oracle_command_integration.rs new file mode 100644 index 00000000..8ac73961 --- /dev/null +++ b/src-tauri/tests/oracle_command_integration.rs @@ -0,0 +1,276 @@ +#[path = "common/oracle_context.rs"] +mod oracle_context; + +use dbpaw_lib::commands::{connection, metadata, query}; +use dbpaw_lib::db::drivers::oracle::OracleDriver; +use dbpaw_lib::db::drivers::DatabaseDriver; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn unique_table_name(prefix: &str) -> String { + let ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time after epoch") + .as_millis(); + format!("{prefix}_{ms}") +} + +async fn prepare_test_table(schema: &str, table: &str, form: &dbpaw_lib::models::ConnectionForm) { + let driver = OracleDriver::connect(form) + .await + .expect("connect for setup should succeed"); + let _ = driver + .execute_query(format!( + "BEGIN \ + EXECUTE IMMEDIATE 'DROP TABLE \"{schema}\".\"{table}\"'; \ + EXCEPTION WHEN OTHERS THEN NULL; \ + END;" + )) + .await; + driver + .execute_query(format!( + "CREATE TABLE \"{schema}\".\"{table}\" \ + (id NUMBER(10) PRIMARY KEY, name VARCHAR2(64))" + )) + .await + .expect("CREATE TABLE should succeed"); + driver + .execute_query(format!( + "INSERT INTO \"{schema}\".\"{table}\" (id, name) VALUES (1, 'DbPaw')" + )) + .await + .expect("INSERT should succeed"); + driver.close().await; +} + +async fn cleanup_table(schema: &str, table: &str, form: &dbpaw_lib::models::ConnectionForm) { + let driver = OracleDriver::connect(form) + .await + .expect("connect for cleanup should succeed"); + let _ = driver + .execute_query(format!("DROP TABLE \"{schema}\".\"{table}\"")) + .await; + driver.close().await; +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_test_connection_success() { + let form = oracle_context::oracle_form_from_test_context(); + let result = connection::test_connection_ephemeral(form) + .await + .expect("test_connection_ephemeral should succeed"); + assert!(result.success); + assert!(result.latency_ms.is_some()); +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_test_connection_invalid_password_returns_error() { + let mut form = oracle_context::oracle_form_from_test_context(); + form.password = Some("dbpaw_wrong_password_xyz".to_string()); + let result = connection::test_connection_ephemeral(form).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(!err.trim().is_empty()); +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_list_databases_returns_schemas() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + + let databases = connection::list_databases(form) + .await + .expect("list_databases should succeed"); + assert!(!databases.is_empty()); + assert!( + databases.iter().any(|d| d == &schema), + "schemas should include {schema}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_list_tables_by_conn_contains_created_table() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + let table = unique_table_name("DBPAW_CMD_TABLES").to_uppercase(); + prepare_test_table(&schema, &table, &form).await; + + let tables = metadata::list_tables_by_conn(form.clone()) + .await + .expect("list_tables_by_conn should succeed"); + assert!( + tables.iter().any(|t| t.name == table), + "tables should contain {table}" + ); + + cleanup_table(&schema, &table, &form).await; +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_execute_select_returns_rows() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + let table = unique_table_name("DBPAW_CMD_SEL").to_uppercase(); + prepare_test_table(&schema, &table, &form).await; + + let sql = format!("SELECT id, name FROM \"{schema}\".\"{table}\" ORDER BY id"); + let result = query::execute_by_conn_direct(form.clone(), sql) + .await + .expect("execute SELECT should succeed"); + + assert!(result.success); + assert_eq!(result.row_count, 1); + assert!(!result.data.is_empty()); + let row = result.data.first().unwrap(); + let name = row.get("NAME").and_then(|v| v.as_str()); + assert_eq!(name, Some("DbPaw")); + + cleanup_table(&schema, &table, &form).await; +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_execute_invalid_sql_returns_error() { + let form = oracle_context::oracle_form_from_test_context(); + let result = + query::execute_by_conn_direct(form, "SELECT * FROM __dbpaw_no_such_table".to_string()) + .await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(!err.trim().is_empty()); +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_execute_insert_affects_rows() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + let table = unique_table_name("DBPAW_CMD_INS").to_uppercase(); + + let driver = OracleDriver::connect(&form) + .await + .expect("connect for setup"); + let _ = driver + .execute_query(format!( + "BEGIN \ + EXECUTE IMMEDIATE 'DROP TABLE \"{schema}\".\"{table}\"'; \ + EXCEPTION WHEN OTHERS THEN NULL; \ + END;" + )) + .await; + driver + .execute_query(format!( + "CREATE TABLE \"{schema}\".\"{table}\" \ + (id NUMBER(10) PRIMARY KEY, name VARCHAR2(64))" + )) + .await + .expect("CREATE TABLE"); + driver.close().await; + + let sql = format!( + "INSERT INTO \"{schema}\".\"{table}\" (id, name) VALUES (1, 'alpha')" + ); + let result = query::execute_by_conn_direct(form.clone(), sql) + .await + .expect("INSERT should succeed"); + assert!(result.success); + assert_eq!(result.row_count, 1); + + cleanup_table(&schema, &table, &form).await; +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_get_table_data_pagination_works() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + let table = unique_table_name("DBPAW_CMD_PAGE").to_uppercase(); + + let driver = OracleDriver::connect(&form) + .await + .expect("connect for setup"); + let _ = driver + .execute_query(format!( + "BEGIN \ + EXECUTE IMMEDIATE 'DROP TABLE \"{schema}\".\"{table}\"'; \ + EXCEPTION WHEN OTHERS THEN NULL; \ + END;" + )) + .await; + driver + .execute_query(format!( + "CREATE TABLE \"{schema}\".\"{table}\" (id NUMBER(10) PRIMARY KEY)" + )) + .await + .expect("CREATE TABLE"); + for i in 1..=3 { + driver + .execute_query(format!( + "INSERT INTO \"{schema}\".\"{table}\" (id) VALUES ({i})" + )) + .await + .expect("INSERT"); + } + driver.close().await; + + let page1 = + query::get_table_data_by_conn(form.clone(), schema.clone(), table.clone(), 1, 2) + .await + .expect("page 1 should succeed"); + let page2 = + query::get_table_data_by_conn(form.clone(), schema.clone(), table.clone(), 2, 2) + .await + .expect("page 2 should succeed"); + + assert_eq!(page1.total, 3); + assert_eq!(page1.limit, 2); + assert_eq!(page1.data.len(), 2); + assert_eq!(page2.data.len(), 1); + + cleanup_table(&schema, &table, &form).await; +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_get_table_data_invalid_pagination_returns_error() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + let table = unique_table_name("DBPAW_CMD_INVP").to_uppercase(); + prepare_test_table(&schema, &table, &form).await; + + let result = + query::get_table_data_by_conn(form.clone(), schema.clone(), table.clone(), 0, 10).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("[VALIDATION_ERROR]")); + + cleanup_table(&schema, &table, &form).await; +} diff --git a/src-tauri/tests/oracle_integration.rs b/src-tauri/tests/oracle_integration.rs new file mode 100644 index 00000000..02c216e4 --- /dev/null +++ b/src-tauri/tests/oracle_integration.rs @@ -0,0 +1,254 @@ +#[path = "common/oracle_context.rs"] +mod oracle_context; + +use dbpaw_lib::db::drivers::oracle::OracleDriver; +use dbpaw_lib::db::drivers::DatabaseDriver; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn unique_table_name() -> String { + let ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time after epoch") + .as_millis(); + format!("DBPAW_ORA_IT_{}", ms) +} + +#[tokio::test] +#[ignore] +async fn test_oracle_integration_flow() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + + let driver = oracle_context::connect_with_retry(|| OracleDriver::connect(&form)).await; + + // test_connection + driver + .test_connection() + .await + .expect("test_connection should succeed"); + + // list_databases returns schema names + let schemas = driver + .list_databases() + .await + .expect("list_databases should succeed"); + assert!(!schemas.is_empty(), "list_databases returned empty list"); + assert!( + schemas.iter().any(|s| s == &schema), + "list_databases should include {schema}" + ); + + let table = unique_table_name(); + + // Clean up any leftovers from previous runs + let _ = driver + .execute_query(format!( + "BEGIN \ + EXECUTE IMMEDIATE 'DROP TABLE \"{schema}\".\"{table}\"'; \ + EXCEPTION WHEN OTHERS THEN NULL; \ + END;" + )) + .await; + + // Create test table + driver + .execute_query(format!( + "CREATE TABLE \"{schema}\".\"{table}\" ( \ + id NUMBER(10) PRIMARY KEY, \ + name VARCHAR2(50), \ + amount NUMBER(10,2), \ + ts DATE \ + )" + )) + .await + .expect("CREATE TABLE should succeed"); + + // Insert a row + driver + .execute_query(format!( + "INSERT INTO \"{schema}\".\"{table}\" (id, name, amount, ts) \ + VALUES (1, 'hello', 12.34, SYSDATE)" + )) + .await + .expect("INSERT should succeed"); + + // list_tables + let tables = driver + .list_tables(Some(schema.clone())) + .await + .expect("list_tables should succeed"); + assert!( + tables.iter().any(|t| t.name == table), + "list_tables should contain {table}" + ); + + // get_table_structure + let structure = driver + .get_table_structure(schema.clone(), table.clone()) + .await + .expect("get_table_structure should succeed"); + assert!( + !structure.columns.is_empty(), + "structure should have columns" + ); + assert!( + structure.columns.iter().any(|c| c.name == "ID" && c.primary_key), + "ID column should be marked as primary key" + ); + assert!( + structure.columns.iter().any(|c| c.name == "NAME"), + "NAME column should be present" + ); + + // get_table_metadata + let metadata = driver + .get_table_metadata(schema.clone(), table.clone()) + .await + .expect("get_table_metadata should succeed"); + assert!( + metadata.columns.iter().any(|c| c.primary_key), + "metadata should have a primary key column" + ); + + // get_table_ddl + let ddl = driver + .get_table_ddl(schema.clone(), table.clone()) + .await + .expect("get_table_ddl should succeed"); + assert!( + ddl.to_uppercase().contains("CREATE TABLE"), + "DDL should contain CREATE TABLE" + ); + + // get_table_data + let result = driver + .get_table_data( + schema.clone(), + table.clone(), + 1, + 10, + None, + None, + None, + None, + ) + .await + .expect("get_table_data should succeed"); + assert_eq!(result.total, 1, "total should be 1"); + assert_eq!(result.data.len(), 1, "data should have 1 row"); + let row = result.data.first().unwrap(); + assert!( + row.get("ID").is_some() || row.get("id").is_some(), + "row should have ID column" + ); + + // execute_query SELECT + let qr = driver + .execute_query(format!( + "SELECT id, name FROM \"{schema}\".\"{table}\"" + )) + .await + .expect("execute_query SELECT should succeed"); + assert!(qr.success); + assert_eq!(qr.row_count, 1); + assert!( + qr.columns.iter().any(|c| c.name == "ID"), + "columns should include ID" + ); + + // execute_query DML affected rows + let upd = driver + .execute_query(format!( + "UPDATE \"{schema}\".\"{table}\" SET amount = 99.99 WHERE id = 1" + )) + .await + .expect("execute_query UPDATE should succeed"); + assert!(upd.success); + assert_eq!(upd.row_count, 1, "UPDATE should affect 1 row"); + + // get_schema_overview + let overview = driver + .get_schema_overview(Some(schema.clone())) + .await + .expect("get_schema_overview should succeed"); + assert!( + overview.tables.iter().any(|t| t.name == table), + "schema_overview should include {table}" + ); + + // Cleanup + let _ = driver + .execute_query(format!("DROP TABLE \"{schema}\".\"{table}\"")) + .await; +} + +#[tokio::test] +#[ignore] +async fn test_oracle_integration_pagination() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + let driver = oracle_context::connect_with_retry(|| OracleDriver::connect(&form)).await; + let table = unique_table_name(); + + let _ = driver + .execute_query(format!( + "BEGIN \ + EXECUTE IMMEDIATE 'DROP TABLE \"{schema}\".\"{table}\"'; \ + EXCEPTION WHEN OTHERS THEN NULL; \ + END;" + )) + .await; + + driver + .execute_query(format!( + "CREATE TABLE \"{schema}\".\"{table}\" (id NUMBER(10) PRIMARY KEY)" + )) + .await + .expect("CREATE TABLE should succeed"); + + // Insert 5 rows + for i in 1..=5 { + driver + .execute_query(format!( + "INSERT INTO \"{schema}\".\"{table}\" (id) VALUES ({i})" + )) + .await + .expect("INSERT should succeed"); + } + + let page1 = driver + .get_table_data(schema.clone(), table.clone(), 1, 3, None, None, None, None) + .await + .expect("page 1 should succeed"); + assert_eq!(page1.total, 5); + assert_eq!(page1.data.len(), 3); + + let page2 = driver + .get_table_data(schema.clone(), table.clone(), 2, 3, None, None, None, None) + .await + .expect("page 2 should succeed"); + assert_eq!(page2.data.len(), 2); + + let _ = driver + .execute_query(format!("DROP TABLE \"{schema}\".\"{table}\"")) + .await; +} + +#[tokio::test] +#[ignore] +async fn test_oracle_integration_connection_failure() { + let mut form = oracle_context::oracle_form_from_test_context(); + form.password = Some("dbpaw_wrong_password_xyz".to_string()); + let result = OracleDriver::connect(&form).await; + assert!(result.is_err(), "wrong password should fail"); + let err = result.err().expect("should have an error"); + assert!(err.contains("[CONN_FAILED]"), "error should be tagged CONN_FAILED"); +} diff --git a/src-tauri/tests/postgres_integration.rs b/src-tauri/tests/postgres_integration.rs index 9c5992be..025581bf 100644 --- a/src-tauri/tests/postgres_integration.rs +++ b/src-tauri/tests/postgres_integration.rs @@ -776,6 +776,153 @@ async fn test_postgres_view_can_be_listed_and_queried() { .await; } +#[tokio::test] +#[ignore] +async fn test_postgres_array_types_decoded_as_json_arrays() { + let docker = (!postgres_context::should_reuse_local_db()).then(Cli::default); + let (_container, form) = postgres_context::postgres_form_from_test_context(docker.as_ref()); + let driver = postgres_context::connect_with_retry(|| PostgresDriver::connect(&form)).await; + + let table_name = "dbpaw_pg_array_type_probe"; + let qualified = format!("public.{}", table_name); + + let _ = driver + .execute_query(format!("DROP TABLE IF EXISTS {}", qualified)) + .await; + + driver + .execute_query(format!( + "CREATE TABLE {} (\ + id INT PRIMARY KEY,\ + ints2 SMALLINT[],\ + ints4 INT[],\ + ints8 BIGINT[],\ + floats4 FLOAT4[],\ + floats8 FLOAT8[],\ + texts TEXT[],\ + bools BOOLEAN[],\ + jsonbs JSONB[]\ + )", + qualified + )) + .await + .expect("create array probe table failed"); + + // row 1: fully populated arrays + driver + .execute_query(format!( + "INSERT INTO {} VALUES \ + (1, ARRAY[1::smallint,2::smallint], ARRAY[10,20,30], ARRAY[100::bigint,200::bigint], \ + ARRAY[1.5::float4,2.5::float4], ARRAY[3.14::float8,6.28::float8], \ + ARRAY['hello','world'], ARRAY[true,false,true], \ + ARRAY['{{\"a\":1}}'::jsonb,'{{\"b\":2}}'::jsonb])", + qualified + )) + .await + .expect("insert full-array row failed"); + + // row 2: arrays containing NULL elements + driver + .execute_query(format!( + "INSERT INTO {} VALUES \ + (2, ARRAY[NULL::smallint,5::smallint], ARRAY[NULL::int,42], NULL, \ + NULL, NULL, \ + ARRAY['x',NULL::text,'z'], ARRAY[NULL::boolean], \ + NULL)", + qualified + )) + .await + .expect("insert null-element row failed"); + + // row 3: empty arrays + driver + .execute_query(format!( + "INSERT INTO {} VALUES \ + (3, ARRAY[]::smallint[], ARRAY[]::int[], ARRAY[]::bigint[], \ + ARRAY[]::float4[], ARRAY[]::float8[], \ + ARRAY[]::text[], ARRAY[]::boolean[], \ + ARRAY[]::jsonb[])", + qualified + )) + .await + .expect("insert empty-array row failed"); + + let result = driver + .execute_query(format!("SELECT * FROM {} ORDER BY id", qualified)) + .await + .expect("select array probe rows failed"); + + assert_eq!(result.row_count, 3, "expected 3 rows"); + + // ---- row 1: full arrays ---- + let r1 = &result.data[0]; + + let ints2 = r1["ints2"].as_array().expect("ints2 should be array"); + assert_eq!(ints2.len(), 2); + assert_eq!(ints2[0].as_i64().unwrap_or(-1), 1); + assert_eq!(ints2[1].as_i64().unwrap_or(-1), 2); + + let ints4 = r1["ints4"].as_array().expect("ints4 should be array"); + assert_eq!(ints4.len(), 3); + assert_eq!(ints4[2].as_i64().unwrap_or(-1), 30); + + let ints8 = r1["ints8"].as_array().expect("ints8 should be array"); + assert_eq!(ints8.len(), 2); + assert_eq!(ints8[1].as_i64().unwrap_or(-1), 200); + + let floats8 = r1["floats8"].as_array().expect("floats8 should be array"); + assert_eq!(floats8.len(), 2); + assert!(floats8[0].as_f64().map(|v| (v - 3.14).abs() < 0.01).unwrap_or(false), + "floats8[0] should be ~3.14, got {:?}", floats8[0]); + + let texts = r1["texts"].as_array().expect("texts should be array"); + assert_eq!(texts.len(), 2); + assert_eq!(texts[0].as_str().unwrap_or(""), "hello"); + assert_eq!(texts[1].as_str().unwrap_or(""), "world"); + + let bools = r1["bools"].as_array().expect("bools should be array"); + assert_eq!(bools.len(), 3); + assert_eq!(bools[0], serde_json::Value::Bool(true)); + assert_eq!(bools[1], serde_json::Value::Bool(false)); + + let jsonbs = r1["jsonbs"].as_array().expect("jsonbs should be array"); + assert_eq!(jsonbs.len(), 2); + assert_eq!(jsonbs[0]["a"], serde_json::Value::Number(1.into())); + assert_eq!(jsonbs[1]["b"], serde_json::Value::Number(2.into())); + + // ---- row 2: null elements inside arrays ---- + let r2 = &result.data[1]; + + let ints2_null = r2["ints2"].as_array().expect("ints2 row2 should be array"); + assert_eq!(ints2_null[0], serde_json::Value::Null, "first element should be NULL"); + assert_eq!(ints2_null[1].as_i64().unwrap_or(-1), 5); + + let ints4_null = r2["ints4"].as_array().expect("ints4 row2 should be array"); + assert_eq!(ints4_null[0], serde_json::Value::Null, "first int4 element should be NULL"); + + let texts_null = r2["texts"].as_array().expect("texts row2 should be array"); + assert_eq!(texts_null[0].as_str().unwrap_or(""), "x"); + assert_eq!(texts_null[1], serde_json::Value::Null, "middle text element should be NULL"); + assert_eq!(texts_null[2].as_str().unwrap_or(""), "z"); + + let bools_null = r2["bools"].as_array().expect("bools row2 should be array"); + assert_eq!(bools_null[0], serde_json::Value::Null, "bool element should be NULL"); + + // column-level NULL (entire array is NULL) + assert_eq!(r2["ints8"], serde_json::Value::Null, "whole ints8 column should be NULL"); + + // ---- row 3: empty arrays ---- + let r3 = &result.data[2]; + assert_eq!(r3["ints4"].as_array().expect("ints4 row3").len(), 0); + assert_eq!(r3["texts"].as_array().expect("texts row3").len(), 0); + assert_eq!(r3["bools"].as_array().expect("bools row3").len(), 0); + assert_eq!(r3["jsonbs"].as_array().expect("jsonbs row3").len(), 0); + + let _ = driver + .execute_query(format!("DROP TABLE IF EXISTS {}", qualified)) + .await; +} + #[tokio::test] #[ignore] async fn test_postgres_connection_failure_with_wrong_password() { diff --git a/src/App.tsx b/src/App.tsx index d6a35ba3..f3b30334 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -100,6 +100,7 @@ interface TabItem { savedQueryId?: number; savedQueryDescription?: string; availableDatabases?: string[]; + isLoading?: boolean; } type TableRefreshOverrides = { @@ -116,6 +117,10 @@ type ActiveTableTarget = { schema?: string; }; +type SidebarRevealRequest = ActiveTableTarget & { + id: number; +}; + type SidebarLayoutMode = "tabs" | "tree"; const DEFAULT_SQL = ""; @@ -154,6 +159,25 @@ function LazyPanelFallback({ ); } +function getTableTargetFromTab(tab?: TabItem): ActiveTableTarget | undefined { + if ( + tab && + (tab.type === "table" || tab.type === "ddl") && + tab.connectionId && + tab.database && + tab.tableName + ) { + return { + connectionId: tab.connectionId, + database: tab.database, + table: tab.tableName, + schema: tab.schema, + }; + } + + return undefined; +} + export default function App() { const { t } = useTranslation(); const resolveTableScope = ( @@ -197,6 +221,8 @@ export default function App() { const [tabs, setTabs] = useState([]); const [activeTab, setActiveTab] = useState(""); const [aiVisible, setAiVisible] = useState(false); + const [sidebarRevealRequest, setSidebarRevealRequest] = + useState(); const [openSettings, setOpenSettings] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const isDefaultQueryTitle = (title?: string) => @@ -212,6 +238,30 @@ export default function App() { const closeSaveCompletedRef = useRef(false); const unsavedConfirmActionRef = useRef<"save" | "discard" | null>(null); const schemaOverviewRequestKeysRef = useRef>(new Map()); + const sidebarRevealRequestIdRef = useRef(0); + + const revealSidebarForTab = useCallback( + (tabId: string, sourceTabs = tabs) => { + const target = getTableTargetFromTab( + sourceTabs.find((tab) => tab.id === tabId), + ); + if (!target) return; + + setSidebarRevealRequest({ + ...target, + id: ++sidebarRevealRequestIdRef.current, + }); + }, + [tabs], + ); + + const handleMainTabChange = useCallback( + (tabId: string) => { + setActiveTab(tabId); + revealSidebarForTab(tabId); + }, + [revealSidebarForTab], + ); useEffect(() => { void getSetting("sidebarLayout", "tabs").then( @@ -685,6 +735,23 @@ export default function App() { setActiveTab(tabId); return; } + + // Immediately create a placeholder tab and switch to it for instant feedback + setTabs((prev) => [ + ...prev, + { + id: tabId, + type: "table", + title: table, + connection, + database, + connectionId, + driver, + isLoading: true, + }, + ]); + setActiveTab(tabId); + try { const { schema, dbParam } = resolveTableScope( driver, @@ -719,28 +786,30 @@ export default function App() { columns = resp.data.length > 0 ? Object.keys(resp.data[0]) : []; } - const newTab: TabItem = { - id: tabId, - type: "table", - title: table, - connection, - database, - schema, - tableName: table, - data: resp.data, - columns, - total: resp.total, - page: resp.page, - pageSize: resp.limit, - executionTimeMs: resp.executionTimeMs, - connectionId, - driver, - }; - setTabs([...tabs, newTab]); - setActiveTab(tabId); + setTabs((prev) => + prev.map((t) => + t.id === tabId + ? { + ...t, + isLoading: false, + schema, + tableName: table, + data: resp.data, + columns, + total: resp.total, + page: resp.page, + pageSize: resp.limit, + executionTimeMs: resp.executionTimeMs, + } + : t, + ), + ); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); console.error("get_table_data failed", errorMessage); + setTabs((prev) => + prev.map((t) => (t.id === tabId ? { ...t, isLoading: false } : t)), + ); toast.error(t("app.error.loadTableData"), { description: errorMessage, }); @@ -1081,16 +1150,21 @@ export default function App() { unsavedConfirmActionRef.current = null; }, []); - const closeTabNow = useCallback((tabId: string) => { - setTabs((prev) => { - const newTabs = prev.filter((t) => t.id !== tabId); - setActiveTab((currentActiveTab) => { - if (currentActiveTab !== tabId) return currentActiveTab; - return newTabs[newTabs.length - 1]?.id || ""; + const closeTabNow = useCallback( + (tabId: string) => { + setTabs((prev) => { + const newTabs = prev.filter((t) => t.id !== tabId); + setActiveTab((currentActiveTab) => { + if (currentActiveTab !== tabId) return currentActiveTab; + const nextActiveTab = newTabs[newTabs.length - 1]?.id || ""; + if (nextActiveTab) revealSidebarForTab(nextActiveTab, newTabs); + return nextActiveTab; + }); + return newTabs; }); - return newTabs; - }); - }, []); + }, + [revealSidebarForTab], + ); const saveEditorTab = useCallback( async (tab: TabItem, name: string, description: string) => { @@ -1186,8 +1260,9 @@ export default function App() { (tabId: string) => { requestCloseTabs(tabs.filter((t) => t.id !== tabId).map((t) => t.id)); setActiveTab(tabId); + revealSidebarForTab(tabId); }, - [requestCloseTabs, tabs], + [requestCloseTabs, revealSidebarForTab, tabs], ); const handleUnsavedCloseCancel = useCallback(() => { @@ -1281,7 +1356,9 @@ export default function App() { const currentIndex = tabs.findIndex((t) => t.id === activeTab); const startIndex = currentIndex >= 0 ? currentIndex : 0; const nextIndex = (startIndex + direction + tabs.length) % tabs.length; - setActiveTab(tabs[nextIndex].id); + const nextTabId = tabs[nextIndex].id; + setActiveTab(nextTabId); + revealSidebarForTab(nextTabId, tabs); }; // Global Keyboard Shortcuts @@ -1344,23 +1421,7 @@ export default function App() { const activeTabItem = tabs.find((t) => t.id === activeTab); const activeTableTarget = useMemo(() => { - if (!activeTabItem) return undefined; - - if ( - (activeTabItem.type === "table" || activeTabItem.type === "ddl") && - activeTabItem.connectionId && - activeTabItem.database && - activeTabItem.tableName - ) { - return { - connectionId: activeTabItem.connectionId, - database: activeTabItem.database, - table: activeTabItem.tableName, - schema: activeTabItem.schema, - }; - } - - return undefined; + return getTableTargetFromTab(activeTabItem); }, [activeTabItem]); const tableTabTitleCounts = useMemo(() => { const counts = new Map(); @@ -1418,6 +1479,7 @@ export default function App() { onSelectSavedQuery={handleOpenSavedQuery} lastUpdated={queriesLastUpdated} activeTableTarget={activeTableTarget} + sidebarRevealRequest={sidebarRevealRequest} layoutMode={sidebarLayout} /> @@ -1433,7 +1495,7 @@ export default function App() { >
@@ -1572,6 +1634,7 @@ export default function App() { ) : Promise.resolve(false) } + isExecuting={!!tab.activeQueryId} queryResults={tab.queryResults} value={tab.sqlContent} onChange={(sql) => handleSqlChange(tab.id, sql)} @@ -1610,6 +1673,7 @@ export default function App() { ) : tab.type === "table" ? ( void; +} + +type TabId = "json" | "tree" | "table"; + +// --- Tree View --- + +function TreeNode({ + label, + value, + depth = 0, +}: { + label: string; + value: unknown; + depth?: number; +}) { + const [expanded, setExpanded] = useState(depth < 2); + const isComplex = value !== null && value !== undefined && typeof value === "object"; + const isArr = Array.isArray(value); + + if (!isComplex) { + const isNull = value === null; + const isStr = typeof value === "string"; + const isBool = typeof value === "boolean"; + const isNum = typeof value === "number"; + return ( +
+ {label} + : + + {isNull ? "null" : isStr ? `"${value}"` : String(value)} + +
+ ); + } + + const entries = isArr + ? (value as unknown[]).map((v, i) => [String(i), v] as [string, unknown]) + : Object.entries(value as Record); + + return ( +
+
setExpanded((v) => !v)} + > + + {expanded ? : } + + {label} + : + + {isArr ? `[ ${entries.length} ]` : `{ ${entries.length} }`} + +
+ {expanded && + entries.map(([k, v]) => ( + + ))} +
+ ); +} + +// --- Table View --- + +function cellText(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function TableView({ value }: { value: unknown }) { + if (Array.isArray(value)) { + const arr = value as unknown[]; + const allObjects = + arr.length > 0 && + arr.every((item) => item !== null && typeof item === "object" && !Array.isArray(item)); + + const keys = allObjects + ? Array.from(new Set(arr.flatMap((item) => Object.keys(item as object)))) + : null; + + return ( + + + + {keys ? ( + keys.map((k) => ( + + )) + ) : ( + <> + + + + )} + + + + {arr.map((row, i) => ( + + {keys ? ( + keys.map((k) => { + const v = (row as Record)[k]; + return ( + + ); + }) + ) : ( + <> + + + + )} + + ))} + +
+ {k} + #value
+ {cellText(v)} + {i} + {cellText(row)} +
+ ); + } + + if (value !== null && typeof value === "object") { + return ( + + + + + + + + + {Object.entries(value as Record).map(([k, v]) => ( + + + + + ))} + +
keyvalue
{k} + {cellText(v)} +
+ ); + } + + return null; +} + +// --- Main Component --- + +const TABS: { id: TabId; label: string }[] = [ + { id: "json", label: "JSON" }, + { id: "tree", label: "Tree" }, + { id: "table", label: "Table" }, +]; + +export function ComplexValueViewer({ + value, + columnName, + open, + onOpenChange, +}: ComplexValueViewerProps) { + const [activeTab, setActiveTab] = useState("json"); + const [copied, setCopied] = useState(false); + const formatted = JSON.stringify(value, null, 2); + const typeLabel = Array.isArray(value) ? "array" : "object"; + + const handleCopy = () => { + navigator.clipboard.writeText(formatted).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + + return ( + + + {columnName} + + {/* Header */} +
+ {columnName} + + {typeLabel} + + +
+ + {/* Custom Tab Bar */} +
+ {TABS.map((tab) => { + const active = activeTab === tab.id; + return ( + + ); + })} +
+ + {/* Tab Content */} +
+ {activeTab === "json" && ( + +
+                {formatted}
+              
+
+ )} + {activeTab === "tree" && ( + +
+ +
+
+ )} + {activeTab === "table" && ( + + + + )} +
+
+
+ ); +} diff --git a/src/components/business/DataGrid/TableView.tsx b/src/components/business/DataGrid/TableView.tsx index 77396916..cddbf226 100644 --- a/src/components/business/DataGrid/TableView.tsx +++ b/src/components/business/DataGrid/TableView.tsx @@ -24,6 +24,7 @@ import { X, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { Input } from "@/components/ui/input"; import { Select, @@ -76,14 +77,20 @@ import { canMutateClickHouseTable, collectSearchMatches, escapeSQL, + cellValueToString, + formatCellValue, formatInsertSQLValue, formatSQLValue, getQualifiedTableName, isClickHouseMergeTreeEngine, + isComplexValue, isInsertColumnRequired, quoteIdent, sortRows, } from "./tableView/utils"; +import { ComplexValueViewer } from "./ComplexValueViewer"; +import { ColumnAutocompleteInput } from "./tableView/ColumnAutocompleteInput"; +import type { ColumnAutocompleteOption } from "./tableView/columnAutocomplete"; import { toast } from "sonner"; interface PendingChange { @@ -139,6 +146,7 @@ interface TableViewProps { table: string; driver: string; }; + isLoading?: boolean; } export function TableView({ @@ -161,6 +169,7 @@ export function TableView({ onDataRefresh, onCreateQuery, tableContext, + isLoading, }: TableViewProps) { const { t } = useTranslation(); const PAGE_SIZE_OPTIONS = ["10", "50", "100", "200", "500", "1000"] as const; @@ -240,6 +249,16 @@ export function TableView({ const [columnComments, setColumnComments] = useState>( {}, ); + const columnAutocompleteOptions = useMemo(() => { + if (tableColumns.length > 0) { + return tableColumns.map((column) => ({ + name: column.name, + type: column.type, + })); + } + + return columns.map((column) => ({ name: column })); + }, [columns, tableColumns]); const [isSaving, setIsSaving] = useState(false); const [isExporting, setIsExporting] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); @@ -253,6 +272,10 @@ export function TableView({ const [pendingFocusDraftId, setPendingFocusDraftId] = useState( null, ); + const [complexViewer, setComplexViewer] = useState<{ + value: unknown; + columnName: string; + } | null>(null); const editInputRef = useRef(null); const searchInputRef = useRef(null); const saveButtonRef = useRef(null); @@ -545,11 +568,7 @@ export function TableView({ // Check if there's a pending change for this cell const key = `${rowIndex}_${col}`; const pending = pendingChanges.get(key); - const value = pending - ? pending.newValue - : currentValue !== null && currentValue !== undefined - ? String(currentValue) - : ""; + const value = pending ? pending.newValue : cellValueToString(currentValue); setEditingCell({ row: rowIndex, col }); setEditValue(value); setSelectedCell({ row: rowIndex, col }); @@ -569,10 +588,7 @@ export function TableView({ } const sourceRowIndex = data.indexOf(originalRow); const originalValue = originalRow[col]; - const originalStr = - originalValue !== null && originalValue !== undefined - ? String(originalValue) - : ""; + const originalStr = cellValueToString(originalValue); const key = `${row}_${col}`; if (editValue !== originalStr) { @@ -1065,7 +1081,8 @@ export function TableView({ return columns .map((col) => { const value = getCellDisplayValue(rowIndex, col, row[col]); - return value === null || value === undefined ? "" : String(value); + if (value === null || value === undefined) return ""; + return cellValueToString(value); }) .join("\t"); }) @@ -1085,7 +1102,8 @@ export function TableView({ selectedCell.col, row[selectedCell.col], ); - return value === null || value === undefined ? "" : String(value); + if (value === null || value === undefined) return ""; + return cellValueToString(value); }, [currentData, getCellDisplayValue]); const buildRowsCSV = useCallback( @@ -1099,7 +1117,7 @@ export function TableView({ .map((col) => { const value = getCellDisplayValue(rowIndex, col, row[col]); if (value === null || value === undefined) return ""; - const str = String(value); + const str = cellValueToString(value); if ( str.includes(",") || str.includes('"') || @@ -1481,6 +1499,18 @@ export function TableView({ handleCopy, ]); + if (isLoading) { + return ( +
+ + + + + +
+ ); + } + return (
{!hideHeader && ( @@ -1830,32 +1860,24 @@ export function TableView({
- setWhereInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - onFilterChange(whereInput, orderByInput); - } - }} + onValueChange={setWhereInput} + onSubmit={() => onFilterChange(whereInput, orderByInput)} + options={columnAutocompleteOptions} />
- setOrderByInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - onFilterChange(whereInput, orderByInput); - } - }} + onValueChange={setOrderByInput} + onSubmit={() => onFilterChange(whereInput, orderByInput)} + options={columnAutocompleteOptions} />
{tableContext && mutabilityHint && ( @@ -2007,7 +2029,7 @@ export function TableView({ data-row-index={rowIndex} data-col-index={colIndex} className={[ - "px-0 py-0 text-sm text-foreground font-mono border-r border-border relative transition-all duration-150 ease-out", + "px-0 py-0 text-sm text-foreground font-mono border-r border-border relative group transition-all duration-150 ease-out", selected && !editing ? "bg-accent text-accent-foreground" : "", @@ -2065,19 +2087,30 @@ export function TableView({ {displayValue !== null && displayValue !== undefined ? ( - {String(displayValue)} + {formatCellValue(displayValue)} ) : ( NULL )} + {isComplexValue(displayValue) && ( + + )}
)} @@ -2271,6 +2304,15 @@ export function TableView({
)} + {complexViewer && ( + { if (!open) setComplexViewer(null); }} + /> + )} +
Query executed in{" "} diff --git a/src/components/business/DataGrid/tableView/ColumnAutocompleteInput.tsx b/src/components/business/DataGrid/tableView/ColumnAutocompleteInput.tsx new file mode 100644 index 00000000..e06f46ec --- /dev/null +++ b/src/components/business/DataGrid/tableView/ColumnAutocompleteInput.tsx @@ -0,0 +1,167 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { KeyboardEvent as ReactKeyboardEvent } from "react"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/components/ui/utils"; +import { + getAutocompleteToken, + getColumnAutocompleteOptions, + replaceAutocompleteToken, + type ColumnAutocompleteOption, +} from "./columnAutocomplete"; + +interface ColumnAutocompleteInputProps { + value: string; + onValueChange: (value: string) => void; + onSubmit: () => void; + options: ColumnAutocompleteOption[]; + placeholder: string; + className?: string; +} + +export function ColumnAutocompleteInput({ + value, + onValueChange, + onSubmit, + options, + placeholder, + className, +}: ColumnAutocompleteInputProps) { + const inputRef = useRef(null); + const [cursorIndex, setCursorIndex] = useState(value.length); + const [activeIndex, setActiveIndex] = useState(0); + const [isOpen, setIsOpen] = useState(false); + + const token = useMemo( + () => getAutocompleteToken(value, cursorIndex), + [value, cursorIndex], + ); + + const filteredOptions = useMemo( + () => getColumnAutocompleteOptions(options, token), + [options, token], + ); + + const hasSuggestions = filteredOptions.length > 0; + + useEffect(() => { + setActiveIndex(0); + setIsOpen(hasSuggestions); + }, [hasSuggestions, token?.text]); + + const syncCursor = useCallback(() => { + const nextCursor = inputRef.current?.selectionStart ?? value.length; + setCursorIndex(nextCursor); + }, [value.length]); + + const acceptSuggestion = useCallback( + (option: ColumnAutocompleteOption) => { + if (!token) return; + + const nextValue = replaceAutocompleteToken(value, token, option.name); + const nextCursor = token.from + option.name.length; + onValueChange(nextValue); + setCursorIndex(nextCursor); + setIsOpen(false); + + requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.setSelectionRange(nextCursor, nextCursor); + }); + }, + [onValueChange, token, value], + ); + + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (isOpen && hasSuggestions) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((idx) => (idx + 1) % filteredOptions.length); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex( + (idx) => (idx - 1 + filteredOptions.length) % filteredOptions.length, + ); + return; + } + + if (event.key === "Tab" || event.key === "Enter") { + event.preventDefault(); + acceptSuggestion(filteredOptions[activeIndex]); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + setIsOpen(false); + return; + } + } + + if (event.key === "Enter") { + onSubmit(); + } + }; + + return ( + + + { + onValueChange(event.target.value); + setCursorIndex( + event.target.selectionStart ?? event.target.value.length, + ); + }} + onClick={syncCursor} + onKeyUp={syncCursor} + onKeyDown={handleKeyDown} + /> + + event.preventDefault()} + className="w-[260px] p-1 shadow-lg" + > +
+ {filteredOptions.map((option, index) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/business/DataGrid/tableView/columnAutocomplete.ts b/src/components/business/DataGrid/tableView/columnAutocomplete.ts new file mode 100644 index 00000000..2992b75a --- /dev/null +++ b/src/components/business/DataGrid/tableView/columnAutocomplete.ts @@ -0,0 +1,47 @@ +export interface ColumnAutocompleteOption { + name: string; + type?: string; +} + +export interface AutocompleteToken { + from: number; + to: number; + text: string; +} + +export const MAX_COLUMN_AUTOCOMPLETE_OPTIONS = 8; + +export function getAutocompleteToken( + value: string, + cursorIndex: number, +): AutocompleteToken | null { + const beforeCursor = value.slice(0, cursorIndex); + const match = beforeCursor.match(/[A-Za-z_][A-Za-z0-9_$]*$/); + if (!match || match.index === undefined) return null; + + return { + from: match.index, + to: cursorIndex, + text: match[0], + }; +} + +export function replaceAutocompleteToken( + value: string, + token: AutocompleteToken, + replacement: string, +) { + return `${value.slice(0, token.from)}${replacement}${value.slice(token.to)}`; +} + +export function getColumnAutocompleteOptions( + options: ColumnAutocompleteOption[], + token: AutocompleteToken | null, +) { + const text = token?.text.toLowerCase(); + if (!text) return []; + + return options + .filter((option) => option.name.toLowerCase().startsWith(text)) + .slice(0, MAX_COLUMN_AUTOCOMPLETE_OPTIONS); +} diff --git a/src/components/business/DataGrid/tableView/columnAutocomplete.unit.test.ts b/src/components/business/DataGrid/tableView/columnAutocomplete.unit.test.ts new file mode 100644 index 00000000..5b2e1a50 --- /dev/null +++ b/src/components/business/DataGrid/tableView/columnAutocomplete.unit.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { + getAutocompleteToken, + getColumnAutocompleteOptions, + replaceAutocompleteToken, + type ColumnAutocompleteOption, +} from "./columnAutocomplete"; + +describe("column autocomplete", () => { + test("finds the token immediately before the cursor", () => { + expect(getAutocompleteToken("status = 1 AND ord", 18)).toEqual({ + from: 15, + to: 18, + text: "ord", + }); + }); + + test("returns null when the cursor is not after an identifier token", () => { + expect(getAutocompleteToken("status = ", 9)).toBeNull(); + }); + + test("does not attempt SQL string parsing", () => { + expect(getAutocompleteToken("'ord", 4)).toEqual({ + from: 1, + to: 4, + text: "ord", + }); + }); + + test("replaces only the active token", () => { + const token = getAutocompleteToken("status = 1 AND ord", 18); + expect(token).not.toBeNull(); + expect( + replaceAutocompleteToken("status = 1 AND ord", token!, "order"), + ).toBe("status = 1 AND order"); + }); + + test("filters options by case-insensitive prefix and caps results", () => { + const options: ColumnAutocompleteOption[] = [ + "order", + "order_id", + "owner", + "created_at", + "other_1", + "other_2", + "other_3", + "other_4", + "other_5", + "other_6", + ].map((name) => ({ name })); + + const token = getAutocompleteToken("O", 1); + expect( + getColumnAutocompleteOptions(options, token).map((o) => o.name), + ).toEqual([ + "order", + "order_id", + "owner", + "other_1", + "other_2", + "other_3", + "other_4", + "other_5", + ]); + }); +}); diff --git a/src/components/business/DataGrid/tableView/utils.ts b/src/components/business/DataGrid/tableView/utils.ts index 04c4a3ad..6e781f6b 100644 --- a/src/components/business/DataGrid/tableView/utils.ts +++ b/src/components/business/DataGrid/tableView/utils.ts @@ -51,7 +51,7 @@ export function calculateAutoColumnWidths({ for (let i = 0; i < sampleSize; i++) { const val = data[i][col]; if (val !== null && val !== undefined) { - const str = String(val); + const str = formatCellValue(val); const len = str.length > 100 ? 100 : str.length; if (len > sampledMaxLen) sampledMaxLen = len; } @@ -122,7 +122,7 @@ export function collectSearchMatches( columns.forEach((column, colIndex) => { const value = getCellDisplayValue(rowIndex, column, row[column]); if (value === null || value === undefined) return; - const content = String(value).toLowerCase(); + const content = formatCellValue(value).toLowerCase(); if (content.includes(normalizedSearchKeyword)) { matches.push({ row: rowIndex, col: column, colIndex }); } @@ -264,6 +264,35 @@ export function getQualifiedTableName( return `${quoteIdent(driver, schema)}.${quoteIdent(driver, table)}`; } +export function isComplexValue(value: unknown): boolean { + return value !== null && value !== undefined && typeof value === "object"; +} + +/** + * Converts a cell value to its full-fidelity string representation. + * Used for editing, clipboard copy, and CSV/TSV export — anywhere the + * complete value is needed rather than an abbreviated display summary. + * Objects and arrays are serialized as JSON; primitives use String(). + */ +export function cellValueToString(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +export function formatCellValue(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value !== "object") return String(value); + if (Array.isArray(value)) { + return JSON.stringify(value); + } + const keys = Object.keys(value as object); + if (keys.length === 0) return "{}"; + if (keys.length <= 2) return JSON.stringify(value); + return `{${keys.slice(0, 2).join(", ")}, ... +${keys.length - 2}}`; +} + export function isClickHouseMergeTreeEngine( engine: string | undefined | null, ): boolean { diff --git a/src/components/business/DataGrid/tableView/utils.unit.test.ts b/src/components/business/DataGrid/tableView/utils.unit.test.ts index d30b84de..d2184a45 100644 --- a/src/components/business/DataGrid/tableView/utils.unit.test.ts +++ b/src/components/business/DataGrid/tableView/utils.unit.test.ts @@ -2,12 +2,19 @@ import { describe, expect, test } from "bun:test"; import { buildDeleteStatement, buildUpdateStatement, + calculateAutoColumnWidths, canMutateClickHouseTable, + collectSearchMatches, + escapeSQL, + formatCellValue, formatInsertSQLValue, formatSQLValue, getQualifiedTableName, isClickHouseMergeTreeEngine, + isComplexValue, isInsertColumnRequired, + quoteIdent, + sortRows, } from "./utils"; describe("formatSQLValue", () => { @@ -157,6 +164,397 @@ describe("clickhouse mutation guards", () => { }); }); +describe("formatSQLValue: additional cases", () => { + test("maps empty string with null/undefined originalValue to NULL", () => { + expect(formatSQLValue("", null, "execution")).toBe("NULL"); + expect(formatSQLValue("", undefined, "execution")).toBe("NULL"); + }); + + test("returns trimmed numeric for number originalValue", () => { + expect(formatSQLValue("42", 42, "execution")).toBe("42"); + expect(formatSQLValue("-3.14", 3.14, "execution")).toBe("-3.14"); + }); + + test("throws in execution mode for invalid number originalValue", () => { + expect(() => formatSQLValue("abc", 99, "execution")).toThrow( + 'Invalid numeric value: "abc"', + ); + }); + + test("does not throw in copy mode for invalid boolean", () => { + expect(() => formatSQLValue("yes", true, "copy")).not.toThrow(); + }); + + test("quotes plain string values and escapes single quotes", () => { + expect(formatSQLValue("hello", "hello", "execution")).toBe("'hello'"); + expect(formatSQLValue("it's", "it's", "execution")).toBe("'it''s'"); + }); +}); + +describe("escapeSQL", () => { + test("doubles single quotes", () => { + expect(escapeSQL("it's")).toBe("it''s"); + expect(escapeSQL("''")).toBe("''''"); + }); + + test("passes through strings without single quotes unchanged", () => { + expect(escapeSQL("hello world")).toBe("hello world"); + expect(escapeSQL("")).toBe(""); + }); +}); + +describe("quoteIdent", () => { + test("uses backticks for mysql family and clickhouse", () => { + expect(quoteIdent("mysql", "my_table")).toBe("`my_table`"); + expect(quoteIdent("tidb", "my_table")).toBe("`my_table`"); + expect(quoteIdent("mariadb", "my_table")).toBe("`my_table`"); + expect(quoteIdent("clickhouse", "my_table")).toBe("`my_table`"); + }); + + test("uses brackets for mssql and escapes ] inside name", () => { + expect(quoteIdent("mssql", "my_table")).toBe("[my_table]"); + expect(quoteIdent("mssql", "tab]le")).toBe("[tab]]le]"); + }); + + test("uses double quotes for other drivers", () => { + expect(quoteIdent("postgres", "my_table")).toBe('"my_table"'); + expect(quoteIdent("sqlite", "my_table")).toBe('"my_table"'); + expect(quoteIdent(undefined, "my_table")).toBe('"my_table"'); + }); +}); + +describe("sortRows", () => { + const rows = [ + { id: 3, name: "charlie" }, + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + ]; + + test("returns original data when no sort parameters given", () => { + expect(sortRows(rows)).toBe(rows); + expect(sortRows(rows, "id")).toBe(rows); + }); + + test("sorts numeric column ascending", () => { + const sorted = sortRows(rows, "id", "asc"); + expect(sorted.map((r) => r.id)).toEqual([1, 2, 3]); + }); + + test("sorts numeric column descending", () => { + const sorted = sortRows(rows, "id", "desc"); + expect(sorted.map((r) => r.id)).toEqual([3, 2, 1]); + }); + + test("sorts string column ascending", () => { + const sorted = sortRows(rows, "name", "asc"); + expect(sorted.map((r) => r.name)).toEqual(["alice", "bob", "charlie"]); + }); + + test("places null values at the end", () => { + const data = [{ v: null }, { v: 2 }, { v: 1 }]; + const sorted = sortRows(data, "v", "asc"); + expect(sorted[sorted.length - 1].v).toBeNull(); + }); + + test("does not mutate original array", () => { + const original = [...rows]; + sortRows(rows, "id", "asc"); + expect(rows).toEqual(original); + }); +}); + +describe("collectSearchMatches", () => { + const data = [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + { id: 3, name: "alice" }, + ]; + const columns = ["id", "name"]; + const identity = (_row: number, _col: string, val: any) => val; + + test("returns empty array for empty keyword", () => { + expect(collectSearchMatches(data, columns, "", identity)).toEqual([]); + }); + + test("finds matches across rows and columns", () => { + const matches = collectSearchMatches(data, columns, "alice", identity); + expect(matches.length).toBe(2); + expect(matches.map((m) => m.row)).toEqual([0, 2]); + }); + + test("uses getCellDisplayValue result for comparison", () => { + const display = (_row: number, col: string, val: any) => + col === "id" ? `ID:${val}` : val; + const matches = collectSearchMatches(data, columns, "id:1", display); + expect(matches.length).toBe(1); + expect(matches[0].col).toBe("id"); + }); + + test("skips null and undefined cell values", () => { + const withNulls = [{ id: null, name: undefined }]; + const matches = collectSearchMatches(withNulls, ["id", "name"], "null", identity); + expect(matches).toEqual([]); + }); +}); + +describe("calculateAutoColumnWidths", () => { + test("returns empty object for empty data or columns", () => { + expect(calculateAutoColumnWidths({ data: [], columns: ["a"], columnWidths: {} })).toEqual({}); + expect(calculateAutoColumnWidths({ data: [{ a: 1 }], columns: [], columnWidths: {} })).toEqual({}); + }); + + test("skips columns with a pre-set width", () => { + const result = calculateAutoColumnWidths({ + data: [{ a: "hello" }], + columns: ["a"], + columnWidths: { a: 200 }, + }); + expect(result).toEqual({}); + }); + + test("computes width and respects min/max bounds", () => { + const result = calculateAutoColumnWidths({ + data: [{ col: "x" }], + columns: ["col"], + columnWidths: {}, + }); + expect(result["col"]).toBeGreaterThanOrEqual(100); + expect(result["col"]).toBeLessThanOrEqual(900); + }); + + test("caps sampled data length at 100 characters", () => { + const longValue = "a".repeat(200); + const result = calculateAutoColumnWidths({ + data: [{ col: longValue }], + columns: ["col"], + columnWidths: {}, + }); + // cap at 100 chars → 100 * 9 + 36 = 936 → capped at 900 + expect(result["col"]).toBe(900); + }); +}); + +describe("isComplexValue", () => { + test("returns true for plain objects", () => { + expect(isComplexValue({ a: 1 })).toBe(true); + expect(isComplexValue({})).toBe(true); + }); + + test("returns true for arrays", () => { + expect(isComplexValue([1, 2, 3])).toBe(true); + expect(isComplexValue([])).toBe(true); + }); + + test("returns false for null", () => { + expect(isComplexValue(null)).toBe(false); + }); + + test("returns false for undefined", () => { + expect(isComplexValue(undefined)).toBe(false); + }); + + test("returns false for primitives", () => { + expect(isComplexValue("string")).toBe(false); + expect(isComplexValue(42)).toBe(false); + expect(isComplexValue(true)).toBe(false); + }); +}); + +describe("formatCellValue", () => { + test("null → empty string", () => { + expect(formatCellValue(null)).toBe(""); + }); + + test("undefined → empty string", () => { + expect(formatCellValue(undefined)).toBe(""); + }); + + test("string passes through unchanged", () => { + expect(formatCellValue("hello")).toBe("hello"); + expect(formatCellValue("")).toBe(""); + }); + + test("number → string", () => { + expect(formatCellValue(42)).toBe("42"); + expect(formatCellValue(-3.14)).toBe("-3.14"); + }); + + test("boolean → string", () => { + expect(formatCellValue(true)).toBe("true"); + expect(formatCellValue(false)).toBe("false"); + }); + + test("empty array → []", () => { + expect(formatCellValue([])).toBe("[]"); + }); + + test("array always shows full JSON regardless of length", () => { + expect(formatCellValue(["a"])).toBe('["a"]'); + expect(formatCellValue([1, 2])).toBe("[1,2]"); + expect(formatCellValue([1, 2, 3])).toBe("[1,2,3]"); + expect(formatCellValue(["a", "b", "c", "d"])).toBe('["a","b","c","d"]'); + }); + + test("empty object → {}", () => { + expect(formatCellValue({})).toBe("{}"); + }); + + test("object with 1 key → inline JSON", () => { + expect(formatCellValue({ id: 1 })).toBe('{"id":1}'); + }); + + test("object with 2 keys → inline JSON", () => { + expect(formatCellValue({ id: 1, name: "alice" })).toBe( + '{"id":1,"name":"alice"}', + ); + }); + + test("object with 3+ keys → abbreviated summary", () => { + const result = formatCellValue({ a: 1, b: 2, c: 3 }); + expect(result).toMatch(/^\{a, b, \.\.\. \+1\}$/); + }); + + test("object with many keys → shows first 2 keys and remainder count", () => { + const result = formatCellValue({ id: 1, name: "x", role: "admin", score: 99 }); + expect(result).toMatch(/^\{id, name, \.\.\. \+2\}$/); + }); + + test("nested object with 2 keys → inline JSON (no recursion into children)", () => { + const result = formatCellValue({ user: { name: "alice" } }); + expect(result).toBe('{"user":{"name":"alice"}}'); + }); + + test("array of objects → full JSON", () => { + expect(formatCellValue([{ id: 1 }, { id: 2 }, { id: 3 }])).toBe( + '[{"id":1},{"id":2},{"id":3}]', + ); + }); +}); + +describe("formatCellValue: integration with collectSearchMatches", () => { + test("JSON object fields are searchable by key name", () => { + const data = [ + { id: 1, meta: { role: "admin", tags: ["vip"] } }, + { id: 2, meta: { role: "user", tags: [] } }, + ]; + const identity = (_row: number, _col: string, val: any) => val; + const matches = collectSearchMatches(data, ["id", "meta"], "admin", identity); + expect(matches.length).toBe(1); + expect(matches[0].row).toBe(0); + expect(matches[0].col).toBe("meta"); + }); + + test("array fields are searchable by content", () => { + const data = [ + { tags: ["read", "write"] }, + { tags: ["read"] }, + ]; + const identity = (_row: number, _col: string, val: any) => val; + const matches = collectSearchMatches(data, ["tags"], "write", identity); + expect(matches.length).toBe(1); + expect(matches[0].row).toBe(0); + }); +}); + +describe("calculateAutoColumnWidths: complex value handling", () => { + test("uses formatted string length for objects, not [object Object]", () => { + // A 3-key object formats to ~20 chars, not 15 ('[object Object]') + const result = calculateAutoColumnWidths({ + data: [{ meta: { id: 1, name: "alice", role: "admin" } }], + columns: ["meta"], + columnWidths: {}, + }); + // If it used String() it would give '[object Object]' = 15 chars + // formatCellValue gives '{id, name, ... +1}' = 18 chars + // Either way width is > minimum, but we verify it doesn't crash + expect(result["meta"]).toBeGreaterThan(0); + }); +}); + +describe("formatCellValue: PostgreSQL array column output", () => { + // These tests verify the display format for values that come back from the + // PostgreSQL backend after the array-type fix (actual JS arrays, not strings). + + test("int array displays as compact JSON", () => { + expect(formatCellValue([10, 20, 30])).toBe("[10,20,30]"); + }); + + test("text array displays as compact JSON string array", () => { + expect(formatCellValue(["postgres", "arrays"])).toBe('["postgres","arrays"]'); + }); + + test("bool array displays as compact JSON", () => { + expect(formatCellValue([true, false, true])).toBe("[true,false,true]"); + }); + + test("float array displays as compact JSON", () => { + expect(formatCellValue([3.14, 2.72])).toBe("[3.14,2.72]"); + }); + + test("jsonb array (array of objects) displays as full JSON", () => { + const val = [{ source: "web", valid: true }, { source: "app", valid: false }]; + expect(formatCellValue(val)).toBe(JSON.stringify(val)); + }); + + test("empty array displays as []", () => { + expect(formatCellValue([])).toBe("[]"); + }); + + test("array with null element displays null in JSON", () => { + expect(formatCellValue([1, null, 3])).toBe("[1,null,3]"); + }); + + test("null column (entire array is null) → empty string", () => { + expect(formatCellValue(null)).toBe(""); + }); +}); + +describe("isComplexValue: PostgreSQL array column output", () => { + test("JS arrays from backend are complex", () => { + expect(isComplexValue([10, 20, 30])).toBe(true); + expect(isComplexValue(["a", "b"])).toBe(true); + expect(isComplexValue([])).toBe(true); + }); + + test("null column-level value is not complex", () => { + expect(isComplexValue(null)).toBe(false); + }); + + test("primitive types are not complex", () => { + expect(isComplexValue(42)).toBe(false); + expect(isComplexValue("hello")).toBe(false); + expect(isComplexValue(true)).toBe(false); + }); +}); + +describe("collectSearchMatches: PostgreSQL array columns are searchable", () => { + const data = [ + { id: 1, tags: ["postgres", "arrays", "jsonb"] }, + { id: 2, tags: ["mysql", "innodb"] }, + { id: 3, tags: [] }, + { id: 4, tags: null }, + ]; + const identity = (_row: number, _col: string, val: any) => val; + + test("finds match inside text array content", () => { + const matches = collectSearchMatches(data, ["id", "tags"], "jsonb", identity); + expect(matches.length).toBe(1); + expect(matches[0].row).toBe(0); + expect(matches[0].col).toBe("tags"); + }); + + test("does not match empty array", () => { + const matches = collectSearchMatches(data, ["tags"], "postgres", identity); + // only row 0 should match, not row 2 (empty) or row 3 (null) + expect(matches.every((m) => m.row === 0)).toBe(true); + }); + + test("skips null array columns gracefully", () => { + const matches = collectSearchMatches(data, ["tags"], "null", identity); + expect(matches).toEqual([]); + }); +}); + describe("mutation statement builders", () => { test("builds clickhouse alter update/delete statements", () => { expect( diff --git a/src/components/business/Editor/SqlEditor.tsx b/src/components/business/Editor/SqlEditor.tsx index b2a34fa6..d7c06a2b 100644 --- a/src/components/business/Editor/SqlEditor.tsx +++ b/src/components/business/Editor/SqlEditor.tsx @@ -35,6 +35,7 @@ import { Download, CheckCircle2, XCircle, + Loader2, } from "lucide-react"; import { TableView } from "@/components/business/DataGrid/TableView"; import { useTheme } from "@/components/theme-provider"; @@ -237,6 +238,7 @@ interface SqlEditorProps { initialName?: string; initialDescription?: string; onSaveSuccess?: (savedQuery: SavedQuery) => void; + isExecuting?: boolean; } export function SqlEditor({ @@ -255,6 +257,7 @@ export function SqlEditor({ initialName, initialDescription, onSaveSuccess, + isExecuting, }: SqlEditorProps) { const { t } = useTranslation(); const [internalSql, setInternalSql] = useState(""); @@ -755,8 +758,13 @@ export function SqlEditor({ size="icon" variant="outline" className="h-8 w-8" + disabled={isExecuting} > - + {isExecuting ? ( + + ) : ( + + )} diff --git a/src/components/business/Sidebar/ConnectionList.tsx b/src/components/business/Sidebar/ConnectionList.tsx index 98dc5b1f..c0756bfc 100644 --- a/src/components/business/Sidebar/ConnectionList.tsx +++ b/src/components/business/Sidebar/ConnectionList.tsx @@ -61,10 +61,19 @@ import type { Driver, SavedQuery, } from "@/services/api"; +import { + DRIVER_REGISTRY, + getConnectionIcon, + getDefaultPort, + isFileBasedDriver, + supportsSSLCA, + isMysqlFamilyDriver, + supportsCreateDatabase, + supportsSchemaBrowsing, +} from "@/lib/driver-registry"; import { toast } from "sonner"; import { TreeNode } from "./connection-list/TreeNode"; import { - getConnectionIcon, getExportDefaultName, getExportFilter, renderConnectionStatusIndicator, @@ -132,6 +141,14 @@ interface CreateDatabaseForm { lcCtype: string; } +type SelectedTableNode = { + key: string; + connectionId: number; + database: string; + table: string; + schema: string; +}; + const defaultForm: ConnectionForm = { driver: "postgres", name: "", @@ -149,14 +166,6 @@ const defaultForm: ConnectionForm = { sshUsername: "", }; -const createDatabaseSupportedDrivers: Driver[] = [ - "postgres", - "mysql", - "mariadb", - "tidb", - "clickhouse", - "mssql", -]; const defaultCreateDatabaseForm: CreateDatabaseForm = { name: "", @@ -190,7 +199,6 @@ const mssqlCollationOptions = [ "Chinese_PRC_CI_AS", "Japanese_CI_AS", ]; -const schemaNodeDrivers: Driver[] = ["postgres", "mssql"]; interface ConnectionListProps { onTableSelect?: ( connection: string, @@ -223,6 +231,13 @@ interface ConnectionListProps { table: string; schema?: string; }; + sidebarRevealRequest?: { + id: number; + connectionId: number; + database: string; + table: string; + schema?: string; + }; onSelectSavedQuery?: (query: SavedQuery) => void; lastUpdated?: number; showSavedQueriesInTree?: boolean; @@ -234,17 +249,14 @@ export function ConnectionList({ onCreateQuery, onExportTable, activeTableTarget, + sidebarRevealRequest, onSelectSavedQuery, lastUpdated, showSavedQueriesInTree = false, }: ConnectionListProps) { - const AUTO_SCROLL_IDLE_DELAY_MS = 1200; const { t } = useTranslation(); const tableNodeRefs = useRef>({}); - const autoScrollReqIdRef = useRef(0); - const userInteractionUntilRef = useRef(0); - const interactionIdleTimerRef = useRef(null); - const pendingAutoScrollKeyRef = useRef(null); + const handledRevealRequestIdRef = useRef(null); const [connections, setConnections] = useState([]); const [expandedConnections, setExpandedConnections] = useState>( new Set(["1"]), @@ -262,7 +274,9 @@ export function ConnectionList({ new Set(), ); const [expandedTables, setExpandedTables] = useState>(new Set()); - const [selectedTableKey, setSelectedTableKey] = useState(null); + const [selectedTableNode, setSelectedTableNode] = + useState(null); + const selectedTableKey = selectedTableNode?.key ?? null; const [autoScrollRequest, setAutoScrollRequest] = useState<{ key: string; id: number; @@ -281,6 +295,15 @@ export function ConnectionList({ const [editingConnectionId, setEditingConnectionId] = useState( null, ); + const [loadingDatabaseKeys, setLoadingDatabaseKeys] = useState>( + new Set(), + ); + const [loadingTableKeys, setLoadingTableKeys] = useState>( + new Set(), + ); + const loadingSpinner = ( + + ); const [isTesting, setIsTesting] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isSavingEdit, setIsSavingEdit] = useState(false); @@ -321,9 +344,9 @@ export function ConnectionList({ const [isImportConfirmOpen, setIsImportConfirmOpen] = useState(false); const supportsCreateDatabaseForDriver = (driver: Driver) => - createDatabaseSupportedDrivers.includes(driver); + supportsCreateDatabase(driver); const supportsSchemaNodeForDriver = (driver: Driver) => - schemaNodeDrivers.includes(driver); + supportsSchemaBrowsing(driver); const getSchemaNodeKey = (databaseKey: string, schema: string) => `${databaseKey}::${schema}`; const getTableNodeKey = ( @@ -332,35 +355,6 @@ export function ConnectionList({ schemaName: string, tableName: string, ) => `${connectionId}-${databaseName}-${schemaName}-${tableName}`; - const isSidebarInteracting = () => - Date.now() < userInteractionUntilRef.current; - const requestAutoScroll = (tableKey: string) => { - if (isSidebarInteracting()) { - pendingAutoScrollKeyRef.current = tableKey; - return; - } - pendingAutoScrollKeyRef.current = null; - setAutoScrollRequest({ - key: tableKey, - id: ++autoScrollReqIdRef.current, - }); - }; - const markSidebarInteraction = () => { - userInteractionUntilRef.current = Date.now() + AUTO_SCROLL_IDLE_DELAY_MS; - if (interactionIdleTimerRef.current) { - window.clearTimeout(interactionIdleTimerRef.current); - } - interactionIdleTimerRef.current = window.setTimeout(() => { - interactionIdleTimerRef.current = null; - const pendingKey = pendingAutoScrollKeyRef.current; - if (!pendingKey) return; - pendingAutoScrollKeyRef.current = null; - setAutoScrollRequest({ - key: pendingKey, - id: ++autoScrollReqIdRef.current, - }); - }, AUTO_SCROLL_IDLE_DELAY_MS); - }; const createDbTargetConnection = useMemo( () => connections.find((conn) => conn.id === createDbConnectionId) || null, @@ -492,29 +486,13 @@ export function ConnectionList({ } }, [searchTerm, filteredConnections, showSavedQueriesInTree]); - useEffect( - () => () => { - if (interactionIdleTimerRef.current) { - window.clearTimeout(interactionIdleTimerRef.current); - } - }, - [], - ); - - const isFileBased = form.driver === "sqlite" || form.driver === "duckdb"; - const supportsSslCa = - form.driver === "postgres" || - form.driver === "mysql" || - form.driver === "tidb" || - form.driver === "mariadb"; - const isPasswordRequiredOnCreate = useMemo(() => { + const isFileBased = isFileBasedDriver(form.driver); + const supportsSslCa = supportsSSLCA(form.driver); + const isPasswordRequiredOnCreate = useMemo( // MySQL-compatible engines (including TiDB and MariaDB) can be configured without password. - return ( - form.driver !== "mysql" && - form.driver !== "tidb" && - form.driver !== "mariadb" - ); - }, [form.driver]); + () => !isMysqlFamilyDriver(form.driver), + [form.driver], + ); const normalizedForm = useMemo( () => normalizeConnectionFormInput(form), [form], @@ -877,30 +855,10 @@ export function ConnectionList({ } }; - // Effect 1: Register scroll intent when active target changes - useEffect(() => { - if (!activeTableTarget) { - return; - } - - const connectionId = String(activeTableTarget.connectionId); - const databaseName = activeTableTarget.database; - const tableName = activeTableTarget.table; - const schemaName = activeTableTarget.schema || ""; - const nextTableKey = getTableNodeKey( - connectionId, - databaseName, - schemaName, - tableName, - ); - - requestAutoScroll(nextTableKey); - }, [activeTableTarget]); - - // Effect 2: Sync UI state (expansion, selection) and load data if needed + // Sync UI state (expansion, selection) and load data if needed. useEffect(() => { if (!activeTableTarget) { - setSelectedTableKey(null); + setSelectedTableNode(null); return; } @@ -961,8 +919,13 @@ export function ConnectionList({ resolvedSchema, tableName, ); - setSelectedTableKey(resolvedTableKey); - requestAutoScroll(resolvedTableKey); + setSelectedTableNode({ + key: resolvedTableKey, + connectionId: activeTableTarget.connectionId, + database: databaseName, + table: tableName, + schema: resolvedSchema, + }); }; void ensureDatabaseTablesLoaded(); @@ -971,6 +934,38 @@ export function ConnectionList({ }; }, [activeTableTarget, connections]); + useEffect(() => { + if (!sidebarRevealRequest || !activeTableTarget || !selectedTableNode) + return; + if (handledRevealRequestIdRef.current === sidebarRevealRequest.id) return; + if ( + sidebarRevealRequest.connectionId !== activeTableTarget.connectionId || + sidebarRevealRequest.database !== activeTableTarget.database || + sidebarRevealRequest.table !== activeTableTarget.table + ) { + return; + } + if ( + selectedTableNode.connectionId !== sidebarRevealRequest.connectionId || + selectedTableNode.database !== sidebarRevealRequest.database || + selectedTableNode.table !== sidebarRevealRequest.table + ) { + return; + } + if ( + sidebarRevealRequest.schema && + sidebarRevealRequest.schema !== selectedTableNode.schema + ) { + return; + } + + handledRevealRequestIdRef.current = sidebarRevealRequest.id; + setAutoScrollRequest({ + key: selectedTableNode.key, + id: sidebarRevealRequest.id, + }); + }, [activeTableTarget, selectedTableNode, sidebarRevealRequest]); + useEffect(() => { if (!autoScrollRequest) return; let cancelled = false; @@ -987,7 +982,7 @@ export function ConnectionList({ target.scrollIntoView({ block: "center", inline: "nearest", - behavior: "smooth", + behavior: "auto", }); setAutoScrollRequest((prev) => prev?.id === autoScrollRequest.id ? null : prev, @@ -1060,7 +1055,14 @@ export function ConnectionList({ ? db.schemas.length === 0 : db.tables.length === 0) ) { - fetchAndSetTables(connId, dbName); + setLoadingDatabaseKeys((prev) => new Set(prev).add(key)); + fetchAndSetTables(connId, dbName).finally(() => { + setLoadingDatabaseKeys((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + }); } } } @@ -1179,12 +1181,19 @@ export function ConnectionList({ newExpanded.add(tableKey); // Load column info on first expand if (table.columns.length === 0) { + setLoadingTableKeys((prev) => new Set(prev).add(tableKey)); fetchAndSetTableColumns( connectionId, databaseName, table.schema, table.name, - ); + ).finally(() => { + setLoadingTableKeys((prev) => { + const next = new Set(prev); + next.delete(tableKey); + return next; + }); + }); } } setExpandedTables(newExpanded); @@ -1780,20 +1789,7 @@ export function ConnectionList({ setForm((f) => ({ ...f, driver: v, - port: - v === "postgres" - ? 5432 - : v === "mysql" - ? 3306 - : v === "mariadb" - ? 3306 - : v === "tidb" - ? 4000 - : v === "clickhouse" - ? 8123 - : v === "mssql" - ? 1433 - : f.port, + port: getDefaultPort(v) ?? f.port, })) } > @@ -1805,14 +1801,11 @@ export function ConnectionList({ /> - PostgreSQL - MySQL - MariaDB - TiDB - SQLite - DuckDB - ClickHouse - SQL Server + {DRIVER_REGISTRY.map((d) => ( + + {d.label} + + ))}
@@ -1851,19 +1844,9 @@ export function ConnectionList({ setForm((f) => ({ @@ -2292,9 +2275,7 @@ export function ConnectionList({ { - markSidebarInteraction(); setSearchTerm(e.target.value); }} className="pl-8" @@ -2303,8 +2284,6 @@ export function ConnectionList({
setContextMenu((prev) => ({ ...prev, visible: false }))} > {filteredConnections.map((connection) => { @@ -2452,6 +2431,11 @@ export function ConnectionList({ table, ); }} + statusIndicator={ + loadingTableKeys.has(tableKey) + ? loadingSpinner + : undefined + } actions={
e.stopPropagation()}>