From 1b13d2176b33e7920f317230b9ed582b67ef720a Mon Sep 17 00:00:00 2001 From: onenewcode Date: Wed, 8 Jul 2026 21:02:53 +0800 Subject: [PATCH] fix(metadata): unify enum column metadata --- apps/desktop/src/components/grid/DataGrid.vue | 5 +- .../src/lib/dataGrid/dataGridEnumEditor.ts | 60 ---- apps/desktop/src/types/database.ts | 1 + crates/dbx-core/src/db/clickhouse_driver.rs | 1 + .../dbx-core/src/db/elasticsearch_driver.rs | 1 + crates/dbx-core/src/db/influxdb_driver.rs | 6 + crates/dbx-core/src/db/mysql.rs | 94 +++++- crates/dbx-core/src/db/ob_oracle.rs | 1 + crates/dbx-core/src/db/postgres.rs | 128 +++++++- crates/dbx-core/src/db/questdb.rs | 1 + crates/dbx-core/src/db/rqlite_driver.rs | 1 + crates/dbx-core/src/db/sqlite.rs | 1 + crates/dbx-core/src/db/sqlserver.rs | 2 + crates/dbx-core/src/db/turso_driver.rs | 1 + crates/dbx-core/src/schema.rs | 7 + crates/dbx-core/src/schema_diff.rs | 2 + crates/dbx-core/src/transfer.rs | 8 + crates/dbx-core/src/types.rs | 2 + packages/app-tests/dataGridEnumEditor.test.ts | 55 ---- packages/node-core/src/database.ts | 205 +++++++----- .../node-core/tests/describe-table.test.ts | 295 ++++++++++++++++++ 21 files changed, 675 insertions(+), 202 deletions(-) delete mode 100644 apps/desktop/src/lib/dataGrid/dataGridEnumEditor.ts delete mode 100644 packages/app-tests/dataGridEnumEditor.test.ts create mode 100644 packages/node-core/tests/describe-table.test.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 575480355..79fa73c62 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -116,7 +116,6 @@ import { renderWktOnCanvas, isHexGeometry } from "@/lib/dataGrid/geometryPreview import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDetail, dataGridColumnDetailJson, dataGridColumnDetailTsv, dataGridRowDetailJson, dataGridRowDetailTsv, filterDataGridDetailFields, type DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail"; import { applyColumnFormatter, buildColumnFormatterKey, normalizeColumnFormatter, resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit, DateTimePatterns } from "@/lib/dataGrid/columnFormatter"; import { temporalCellEditorKind, type TemporalCellEditorKind } from "@/lib/dataGrid/dataGridTemporalEditor"; -import { isEnumColumn, enumValuesForColumn } from "@/lib/dataGrid/dataGridEnumEditor"; import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts"; import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter"; import { canGoNextDataGridPage } from "@/lib/dataGrid/dataGridPagination"; @@ -3997,11 +3996,11 @@ function temporalEditorKindForColumn(columnIndex: number): TemporalCellEditorKin } function enumValuesForGridColumn(columnIndex: number): string[] { - return enumValuesForColumn(tableColumnForGridColumn(columnIndex)); + return tableColumnForGridColumn(columnIndex)?.enum_values ?? []; } function isEnumGridColumn(columnIndex: number): boolean { - return isEnumColumn(tableColumnForGridColumn(columnIndex)); + return (tableColumnForGridColumn(columnIndex)?.enum_values?.length ?? 0) > 0; } function isEnumGridColumnNullable(columnIndex: number): boolean { diff --git a/apps/desktop/src/lib/dataGrid/dataGridEnumEditor.ts b/apps/desktop/src/lib/dataGrid/dataGridEnumEditor.ts deleted file mode 100644 index fdec7dc17..000000000 --- a/apps/desktop/src/lib/dataGrid/dataGridEnumEditor.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { ColumnInfo } from "@/types/database"; - -/** - * Check if the column's data_type is a MySQL ENUM type definition. - * Matches patterns like `enum('value1','value2')` (case-insensitive). - */ -export function isEnumColumn(columnInfo: Pick | undefined): boolean { - if (!columnInfo?.data_type) return false; - return /^enum\s*\(/i.test(columnInfo.data_type.trim()); -} - -/** - * Parse MySQL ENUM values from a data_type string like `enum('a','b',...)`. - * Handles escaped single quotes ('' → '). - * Returns an empty array for unparseable input. - */ -export function enumValuesForColumn(columnInfo: Pick | undefined): string[] { - if (!isEnumColumn(columnInfo)) return []; - const raw = columnInfo!.data_type.trim(); - const match = raw.match(/^enum\s*\(/i); - if (!match) return []; - - const values: string[] = []; - let i = match[0].length; - while (i < raw.length) { - // MySQL may append CHARACTER SET/COLLATE after the enum value list. - if (raw[i] === ")") { - i++; - return i >= raw.length || /\s/.test(raw[i]) ? values : []; - } - - while (i < raw.length && (/\s/.test(raw[i]) || raw[i] === ",")) i++; - if (i >= raw.length) break; - if (raw[i] === ")") continue; - - if (raw[i] !== "'") return []; - - i++; - let value = ""; - let closed = false; - while (i < raw.length) { - if (raw[i] === "'") { - if (i + 1 < raw.length && raw[i + 1] === "'") { - value += "'"; - i += 2; - } else { - i++; - closed = true; - break; - } - } else { - value += raw[i]; - i++; - } - } - if (!closed) return []; - values.push(value); - } - return []; -} diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 294939c76..9c4d9a82f 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -349,6 +349,7 @@ export interface ColumnInfo { numeric_precision?: number | null; numeric_scale?: number | null; character_maximum_length?: number | null; + enum_values?: string[] | null; } export interface IndexInfo { diff --git a/crates/dbx-core/src/db/clickhouse_driver.rs b/crates/dbx-core/src/db/clickhouse_driver.rs index 551b039e1..f6fba4aa9 100644 --- a/crates/dbx-core/src/db/clickhouse_driver.rs +++ b/crates/dbx-core/src/db/clickhouse_driver.rs @@ -348,6 +348,7 @@ pub async fn get_columns(client: &ChClient, database: &str, table: &str) -> Resu numeric_precision: None, numeric_scale: None, character_maximum_length: None, + enum_values: None, } }) .collect()) diff --git a/crates/dbx-core/src/db/elasticsearch_driver.rs b/crates/dbx-core/src/db/elasticsearch_driver.rs index d9392e998..a49d5f4c1 100644 --- a/crates/dbx-core/src/db/elasticsearch_driver.rs +++ b/crates/dbx-core/src/db/elasticsearch_driver.rs @@ -333,6 +333,7 @@ fn push_mapping_column( numeric_precision: None, numeric_scale: None, character_maximum_length: None, + enum_values: None, }); } diff --git a/crates/dbx-core/src/db/influxdb_driver.rs b/crates/dbx-core/src/db/influxdb_driver.rs index cf21733e0..0768a6320 100644 --- a/crates/dbx-core/src/db/influxdb_driver.rs +++ b/crates/dbx-core/src/db/influxdb_driver.rs @@ -423,6 +423,7 @@ pub async fn get_columns(client: &InfluxdbClient, database: &str, table: &str) - numeric_precision: None, numeric_scale: None, character_maximum_length: None, + enum_values: None, }; let cols: Vec = std::iter::once(time_col) @@ -437,6 +438,7 @@ pub async fn get_columns(client: &InfluxdbClient, database: &str, table: &str) - numeric_precision: None, numeric_scale: None, character_maximum_length: None, + enum_values: None, })) .chain(field_series.first().into_iter().flat_map(|s| s.values.iter()).map(|row| { let data_type = row.get(1).and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); @@ -451,6 +453,7 @@ pub async fn get_columns(client: &InfluxdbClient, database: &str, table: &str) - numeric_precision: None, numeric_scale: None, character_maximum_length: None, + enum_values: None, } })) .collect(); @@ -515,6 +518,7 @@ async fn get_columns_v2(client: &InfluxdbClient, bucket: &str, measurement: &str numeric_precision: None, numeric_scale: None, character_maximum_length: None, + enum_values: None, }; let tag_cols = flux_column_values(&tag_result, "_value") .into_iter() @@ -530,6 +534,7 @@ async fn get_columns_v2(client: &InfluxdbClient, bucket: &str, measurement: &str numeric_precision: None, numeric_scale: None, character_maximum_length: None, + enum_values: None, }); let field_cols = flux_column_values(&field_result, "_value").into_iter().map(|name| ColumnInfo { name, @@ -542,6 +547,7 @@ async fn get_columns_v2(client: &InfluxdbClient, bucket: &str, measurement: &str numeric_precision: None, numeric_scale: None, character_maximum_length: None, + enum_values: None, }); Ok(std::iter::once(time_col).chain(tag_cols).chain(field_cols).collect()) } diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index b0749f08a..38d039890 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -2054,8 +2054,8 @@ pub async fn list_completion_objects(pool: &MySqlPool, database: &str) -> Result fn columns_sql(database: &str, table: &str) -> String { format!( - "SELECT c.COLUMN_NAME, c.COLUMN_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.EXTRA, \ - c.COLUMN_COMMENT, c.COLUMN_KEY, c.NUMERIC_PRECISION, c.NUMERIC_SCALE, c.CHARACTER_MAXIMUM_LENGTH \ + "SELECT c.COLUMN_NAME, c.DATA_TYPE, c.COLUMN_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.EXTRA, \ + c.COLUMN_COMMENT, c.COLUMN_KEY, c.NUMERIC_PRECISION, c.NUMERIC_SCALE, c.CHARACTER_MAXIMUM_LENGTH, \ FROM information_schema.COLUMNS c \ WHERE c.TABLE_SCHEMA = {} AND c.TABLE_NAME = {} \ ORDER BY c.ORDINAL_POSITION", @@ -2128,6 +2128,65 @@ fn fix_potential_double_encoding(s: &str) -> String { } } +fn parse_mysql_enum_values(column_type: &str) -> Option> { + let trimmed = column_type.trim(); + if !trimmed.get(..5)?.eq_ignore_ascii_case("enum(") || !trimmed.ends_with(')') { + return None; + } + + let inner = &trimmed[5..trimmed.len() - 1]; + let mut chars = inner.chars().peekable(); + let mut values = Vec::new(); + + loop { + while matches!(chars.peek(), Some(c) if c.is_whitespace()) { + chars.next(); + } + match chars.next() { + Some('\'') => {} + None if values.is_empty() => return Some(values), + _ => return None, + } + + let mut value = String::new(); + loop { + match chars.next() { + Some('\'') => { + if matches!(chars.peek(), Some('\'')) { + chars.next(); + value.push('\''); + } else { + break; + } + } + Some('\\') => match chars.next() { + Some('0') => value.push('\0'), + Some('b') => value.push('\u{0008}'), + Some('n') => value.push('\n'), + Some('r') => value.push('\r'), + Some('t') => value.push('\t'), + Some('Z') => value.push('\u{001A}'), + Some(c @ ('\\' | '\'' | '"')) => value.push(c), + Some(c) => value.push(c), + None => return None, + }, + Some(c) => value.push(c), + None => return None, + } + } + values.push(value); + + while matches!(chars.peek(), Some(c) if c.is_whitespace()) { + chars.next(); + } + match chars.next() { + Some(',') => continue, + None => return Some(values), + _ => return None, + } + } +} + pub async fn get_columns(pool: &MySqlPool, database: &str, table: &str) -> Result, String> { let sql = columns_sql(database, table); let mut conn = get_conn_with_health_check(pool).await?; @@ -2150,10 +2209,19 @@ pub async fn get_columns(pool: &MySqlPool, database: &str, table: &str) -> Resul return None; } let column_key = get_str_by_name(row, "COLUMN_KEY"); + let data_type = get_str_by_name(row, "DATA_TYPE"); + let column_type = get_str_by_name(row, "COLUMN_TYPE"); + let enum_values = if data_type.eq_ignore_ascii_case("enum") { + // MySQL exposes enum literals only through COLUMN_TYPE. Parse the SQL literal + // syntax in Rust so empty values, quotes, and backslash escapes survive intact. + parse_mysql_enum_values(&column_type) + } else { + None + }; Some(ColumnInfo { is_primary_key: column_key.eq_ignore_ascii_case("PRI"), name, - data_type: get_str_by_name(row, "COLUMN_TYPE"), + data_type: column_type, is_nullable: get_str_by_name(row, "IS_NULLABLE") == "YES", column_default: get_opt_str(row, "COLUMN_DEFAULT"), extra: get_opt_str(row, "EXTRA"), @@ -2163,6 +2231,7 @@ pub async fn get_columns(pool: &MySqlPool, database: &str, table: &str) -> Resul numeric_precision: get_opt_i32(row, "NUMERIC_PRECISION"), numeric_scale: get_opt_i32(row, "NUMERIC_SCALE"), character_maximum_length: get_opt_i32(row, "CHARACTER_MAXIMUM_LENGTH"), + enum_values, }) }) .collect(); @@ -2209,6 +2278,7 @@ pub async fn get_columns_show(pool: &MySqlPool, database: &str, table: &str) -> numeric_precision: None, numeric_scale: None, character_maximum_length: None, + enum_values: None, }) }) .collect()) @@ -3625,7 +3695,25 @@ mod tests { assert!(!sql.contains("KEY_COLUMN_USAGE")); assert!(!sql.contains("CONSTRAINT_NAME = 'PRIMARY'")); assert!(sql.contains("c.COLUMN_KEY")); + assert!(sql.contains("c.DATA_TYPE")); + assert!(sql.contains("c.COLUMN_TYPE")); assert!(!sql.contains("COLLATE")); + assert!(!sql.contains("AS ENUM_VALUES")); + } + + #[test] + fn parse_mysql_enum_values_preserves_mysql_literal_edges() { + assert_eq!( + parse_mysql_enum_values("enum('pending','active','archived')"), + Some(vec!["pending".to_string(), "active".to_string(), "archived".to_string()]) + ); + assert_eq!(parse_mysql_enum_values("ENUM('','a')"), Some(vec!["".to_string(), "a".to_string()])); + assert_eq!(parse_mysql_enum_values("enum('x'',''y','z')"), Some(vec!["x','y".to_string(), "z".to_string()])); + assert_eq!( + parse_mysql_enum_values(r#"enum('it''s','quote\"d','back\\slash')"#), + Some(vec!["it's".to_string(), "quote\"d".to_string(), "back\\slash".to_string()]) + ); + assert_eq!(parse_mysql_enum_values("varchar(255)"), None); } #[test] diff --git a/crates/dbx-core/src/db/ob_oracle.rs b/crates/dbx-core/src/db/ob_oracle.rs index c62260b05..43b5ff2a0 100644 --- a/crates/dbx-core/src/db/ob_oracle.rs +++ b/crates/dbx-core/src/db/ob_oracle.rs @@ -154,6 +154,7 @@ pub async fn get_columns(pool: &mysql_async::Pool, schema: &str, table: &str) -> numeric_precision: precision, numeric_scale: scale, character_maximum_length: length, + enum_values: None, } }) .collect()) diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 15900d6cd..f2e62d65b 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -2059,9 +2059,13 @@ const POSTGRES_COLUMNS_SQL: &str = "SELECT a.attname AS column_name, \ CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 \ THEN (a.atttypmod - 4) & 65535 ELSE NULL END AS numeric_scale, \ CASE WHEN t.typname IN ('varchar', 'bpchar') AND a.atttypmod > 0 \ - THEN a.atttypmod - 4 ELSE NULL END AS character_maximum_length \ + THEN a.atttypmod - 4 ELSE NULL END AS character_maximum_length, \ + CASE WHEN enum_t.oid IS NULL THEN NULL \ + ELSE COALESCE((SELECT array_to_json(array_agg(e.enumlabel ORDER BY e.enumsortorder))::text \ + FROM pg_enum e WHERE e.enumtypid = enum_t.oid), '[]') END AS enum_values \ FROM pg_attribute a \ JOIN pg_type t ON t.oid = a.atttypid \ + LEFT JOIN pg_type enum_t ON enum_t.oid = CASE WHEN t.typtype = 'd' THEN t.typbasetype WHEN t.typtype = 'e' THEN t.oid ELSE NULL END AND enum_t.typtype = 'e' \ LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum \ LEFT JOIN pg_depend dep ON dep.refobjid = a.attrelid AND dep.refobjsubid = a.attnum AND dep.deptype = 'i' \ LEFT JOIN pg_sequence pseq ON pseq.seqrelid = dep.objid \ @@ -2088,7 +2092,8 @@ const POSTGRES_COLUMNS_COMPAT_SQL: &str = "SELECT a.attname AS column_name, \ CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 \ THEN (a.atttypmod - 4) & 65535 ELSE NULL END AS numeric_scale, \ CASE WHEN t.typname IN ('varchar', 'bpchar') AND a.atttypmod > 0 \ - THEN a.atttypmod - 4 ELSE NULL END AS character_maximum_length \ + THEN a.atttypmod - 4 ELSE NULL END AS character_maximum_length, \ + NULL::text AS enum_values \ FROM pg_attribute a \ JOIN pg_type t ON t.oid = a.atttypid \ LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum \ @@ -2119,11 +2124,17 @@ const POSTGRES_COLUMNS_INFORMATION_SCHEMA_SQL: &str = "SELECT c.column_name, \ NULL::text AS column_extra, \ CAST(c.numeric_precision AS int) AS numeric_precision, \ CAST(c.numeric_scale AS int) AS numeric_scale, \ - CAST(c.character_maximum_length AS int) AS character_maximum_length \ + CAST(c.character_maximum_length AS int) AS character_maximum_length, \ + NULL::text AS enum_values \ FROM information_schema.columns c \ WHERE c.table_schema = $1 AND c.table_name = $2 \ ORDER BY c.ordinal_position"; +fn parse_enum_values_from_row(row: &Row, index: usize) -> Option> { + let raw = row.try_get::<_, Option>(index).ok().flatten()?; + serde_json::from_str::>(&raw).ok() +} + /// Read a boolean column from a PostgreSQL row, tolerating databases that /// encode booleans as integers (0/1) or text ('t'/'f') instead of the standard /// `bool` OID. Returns `None` when the column is NULL or truly unreadable. @@ -2181,6 +2192,7 @@ fn column_info_from_row(row: &Row) -> ColumnInfo { numeric_precision: row.try_get::<_, Option>(7).ok().flatten(), numeric_scale: row.try_get::<_, Option>(8).ok().flatten(), character_maximum_length: row.try_get::<_, Option>(9).ok().flatten(), + enum_values: parse_enum_values_from_row(row, 10), } } @@ -3184,8 +3196,82 @@ pub async fn copy_in(pool: &Pool, sql: &str, data: &[u8]) -> Result<(), String> #[cfg(test)] mod tests { use super::*; + use std::process::Command; + use std::time::Instant; use tokio_postgres::types::FromSql; + struct DockerPostgres { + name: String, + port: u16, + } + + impl DockerPostgres { + fn url(&self) -> String { + format!("postgres://postgres:postgres@127.0.0.1:{}/postgres?sslmode=disable", self.port) + } + } + + impl Drop for DockerPostgres { + fn drop(&mut self) { + let _ = Command::new("docker").args(["rm", "-f", &self.name]).status(); + } + } + + fn docker_ready() -> bool { + Command::new("docker") + .args(["version", "--format", "{{.Server.Version}}"]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) + } + + async fn start_docker_postgres() -> Option { + if !docker_ready() { + eprintln!("skipping docker-backed postgres test because Docker is unavailable"); + return None; + } + + let port = portpicker::pick_unused_port().expect("pick unused postgres port"); + let container = DockerPostgres { name: format!("dbx-postgres-enum-{}", uuid::Uuid::new_v4()), port }; + + let status = Command::new("docker") + .args([ + "run", + "-d", + "--rm", + "--name", + &container.name, + "-e", + "POSTGRES_PASSWORD=postgres", + "-e", + "POSTGRES_USER=postgres", + "-e", + "POSTGRES_DB=postgres", + "-p", + &format!("{port}:5432"), + "postgres:16-alpine", + ]) + .status() + .expect("start docker postgres"); + assert!(status.success(), "docker run postgres container should succeed"); + + let deadline = Instant::now() + Duration::from_secs(60); + loop { + match connect(&container.url(), Duration::from_secs(2)).await { + Ok(pool) => { + drop(pool); + return Some(container); + } + Err(_) if Instant::now() < deadline => tokio::time::sleep(Duration::from_millis(500)).await, + Err(error) => panic!("docker postgres did not become ready: {error}"), + } + } + } + + fn state_enum_values(columns: &[ColumnInfo]) -> Option> { + columns.iter().find(|column| column.name == "state").and_then(|column| column.enum_values.clone()) + } + // --- pg_quote_ident --- #[test] @@ -3653,6 +3739,8 @@ mod tests { assert!(POSTGRES_COLUMNS_SQL.contains("generated always as identity")); assert!(POSTGRES_COLUMNS_SQL.contains("COALESCE(c.is_nullable = 'YES', NOT a.attnotnull)")); assert!(POSTGRES_COLUMNS_SQL.contains("LEFT JOIN information_schema.columns")); + assert!(POSTGRES_COLUMNS_SQL.contains("pg_enum")); + assert!(POSTGRES_COLUMNS_SQL.contains("AS enum_values")); } #[test] @@ -3663,6 +3751,8 @@ mod tests { assert!(POSTGRES_COLUMNS_COMPAT_SQL.contains("col_description")); assert!(POSTGRES_COLUMNS_COMPAT_SQL.contains("COALESCE(c.is_nullable = 'YES', NOT a.attnotnull)")); assert!(POSTGRES_COLUMNS_COMPAT_SQL.contains("LEFT JOIN information_schema.columns")); + assert!(POSTGRES_COLUMNS_COMPAT_SQL.contains("NULL::text AS enum_values")); + assert!(!POSTGRES_COLUMNS_COMPAT_SQL.contains("pg_enum")); } #[test] @@ -3670,10 +3760,42 @@ mod tests { assert!(POSTGRES_COLUMNS_INFORMATION_SCHEMA_SQL.contains("information_schema.columns")); assert!(POSTGRES_COLUMNS_INFORMATION_SCHEMA_SQL.contains("information_schema.table_constraints")); assert!(POSTGRES_COLUMNS_INFORMATION_SCHEMA_SQL.contains("information_schema.key_column_usage")); + assert!(POSTGRES_COLUMNS_INFORMATION_SCHEMA_SQL.contains("NULL::text AS enum_values")); assert!(!POSTGRES_COLUMNS_INFORMATION_SCHEMA_SQL.contains("pg_attribute")); assert!(!POSTGRES_COLUMNS_INFORMATION_SCHEMA_SQL.contains("regclass")); } + #[tokio::test] + async fn postgres_column_metadata_query_returns_enum_values_against_real_postgres() { + let Some(container) = start_docker_postgres().await else { + return; + }; + + let pool = connect(&container.url(), Duration::from_secs(5)).await.expect("connect postgres"); + let schema = format!("dbx_enum_meta_{}", std::process::id()); + let schema_ident = format!("\"{}\"", schema.replace('\"', "\"\"")); + let table = format!("{schema_ident}.orders"); + let type_ident = format!("{schema_ident}.\"status\""); + + execute_query(&pool, &format!("CREATE SCHEMA {schema_ident}")).await.expect("create schema"); + execute_query(&pool, &format!("CREATE TYPE {type_ident} AS ENUM ('pending', 'active', 'archived')")) + .await + .expect("create enum type"); + execute_query(&pool, &format!("CREATE TABLE {table} (id integer PRIMARY KEY, state {type_ident} NOT NULL)")) + .await + .expect("create table"); + + let client = + checkout_postgres_client(&pool, None, crate::db::connection_timeout()).await.expect("checkout client"); + + let columns = + get_columns_with_sql(&client, POSTGRES_COLUMNS_SQL, &schema, "orders").await.expect("primary columns"); + assert_eq!( + state_enum_values(&columns), + Some(vec!["pending".to_string(), "active".to_string(), "archived".to_string()]) + ); + } + #[tokio::test] #[ignore = "requires DBX_TEST_POSTGRES_URL pointing at a writable PostgreSQL database"] async fn postgres_column_metadata_decode_type_mismatch_uses_fallbacks() { diff --git a/crates/dbx-core/src/db/questdb.rs b/crates/dbx-core/src/db/questdb.rs index 482484a3c..bcaf8a18a 100644 --- a/crates/dbx-core/src/db/questdb.rs +++ b/crates/dbx-core/src/db/questdb.rs @@ -111,6 +111,7 @@ pub async fn get_columns(pool: &Pool, _schema: &str, table: &str) -> Result R numeric_precision: None, numeric_scale: None, character_maximum_length: None, + enum_values: None, }) .collect()) } diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs index 49a54382d..1419484b4 100644 --- a/crates/dbx-core/src/db/sqlite.rs +++ b/crates/dbx-core/src/db/sqlite.rs @@ -1141,6 +1141,7 @@ pub async fn get_columns(pool: &SqliteHandle, _schema: &str, table: &str) -> Res numeric_precision: None, numeric_scale: None, character_maximum_length: None, + enum_values: None, }) }) .map_err(|e| e.to_string())?; diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 3b7f5f5c7..52d462ba4 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -876,6 +876,7 @@ pub async fn get_linked_server_columns( numeric_precision: column_size, numeric_scale, character_maximum_length: linked_i32(row, 15), + enum_values: None, }) }) .collect()) @@ -1436,6 +1437,7 @@ pub async fn get_columns(client: &mut SqlServerClient, schema: &str, table: &str numeric_precision: num_prec, numeric_scale: num_scale, character_maximum_length: max_len, + enum_values: None, } }) .collect()) diff --git a/crates/dbx-core/src/db/turso_driver.rs b/crates/dbx-core/src/db/turso_driver.rs index 164a894bc..54bb1a70d 100644 --- a/crates/dbx-core/src/db/turso_driver.rs +++ b/crates/dbx-core/src/db/turso_driver.rs @@ -184,6 +184,7 @@ pub async fn get_columns(client: &TursoClient, _schema: &str, table: &str) -> Re numeric_precision: None, numeric_scale: None, character_maximum_length: None, + enum_values: None, }) .collect()) } diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index a67287847..d2fef2b8c 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -236,6 +236,7 @@ pub fn duckdb_query_columns_in_database_with_attached( numeric_precision: None, numeric_scale: None, character_maximum_length: None, + enum_values: None, }) }) .map_err(|e| e.to_string())?; @@ -1047,6 +1048,7 @@ fn oracle_columns_from_query_result(result: db::QueryResult) -> Vec Vec Vec, pub numeric_scale: Option, pub character_maximum_length: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enum_values: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/packages/app-tests/dataGridEnumEditor.test.ts b/packages/app-tests/dataGridEnumEditor.test.ts deleted file mode 100644 index c9fa7c9ae..000000000 --- a/packages/app-tests/dataGridEnumEditor.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { strict as assert } from "node:assert"; -import { test } from "vitest"; -import { isEnumColumn, enumValuesForColumn } from "../../apps/desktop/src/lib/dataGrid/dataGridEnumEditor.ts"; - -test("detects MySQL enum column types", () => { - assert.equal(isEnumColumn({ data_type: "enum('pending','active','archived')" }), true); - assert.equal(isEnumColumn({ data_type: "ENUM('yes','no')" }), true); - assert.equal(isEnumColumn({ data_type: "enum('a')" }), true); - assert.equal(isEnumColumn({ data_type: "enum( 'a' , 'b' )" }), true); -}); - -test("rejects non-enum column types", () => { - assert.equal(isEnumColumn({ data_type: "varchar(255)" }), false); - assert.equal(isEnumColumn({ data_type: "int" }), false); - assert.equal(isEnumColumn({ data_type: "" }), false); - assert.equal(isEnumColumn(undefined), false); - assert.equal(isEnumColumn({ data_type: "enum" }), false); -}); - -test("parses standard enum values", () => { - assert.deepEqual(enumValuesForColumn({ data_type: "enum('pending','active','archived')" }), ["pending", "active", "archived"]); - assert.deepEqual(enumValuesForColumn({ data_type: "ENUM('yes','no')" }), ["yes", "no"]); - assert.deepEqual(enumValuesForColumn({ data_type: "enum('a')" }), ["a"]); -}); - -test("parses enum values with spaces", () => { - assert.deepEqual(enumValuesForColumn({ data_type: "enum( 'pending' , 'active' , 'archived' )" }), ["pending", "active", "archived"]); -}); - -test("parses enum with escaped single quotes", () => { - assert.deepEqual(enumValuesForColumn({ data_type: "enum('it''s','normal')" }), ["it's", "normal"]); -}); - -test("parses MySQL enum values with charset and collation suffixes", () => { - assert.deepEqual( - enumValuesForColumn({ - data_type: "enum('id','string','text','long','double','bool','date','datetime','object','secret') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci", - }), - ["id", "string", "text", "long", "double", "bool", "date", "datetime", "object", "secret"], - ); -}); - -test("returns empty array for non-enum types", () => { - assert.deepEqual(enumValuesForColumn({ data_type: "varchar(255)" }), []); - assert.deepEqual(enumValuesForColumn(undefined), []); -}); - -test("multiline enum type", () => { - assert.deepEqual( - enumValuesForColumn({ - data_type: `enum('small','medium','large')`, - }), - ["small", "medium", "large"], - ); -}); diff --git a/packages/node-core/src/database.ts b/packages/node-core/src/database.ts index b53d7ed05..2ce11dfe0 100644 --- a/packages/node-core/src/database.ts +++ b/packages/node-core/src/database.ts @@ -33,6 +33,7 @@ export interface ColumnInfo { numeric_precision?: number | null; numeric_scale?: number | null; character_maximum_length?: number | null; + enum_values?: string[] | null; } export interface QueryResult { @@ -268,7 +269,10 @@ function normalizePostgresUrlParams(value: string, forceTls: boolean): string { const [rawKey, rawValue] = splitUrlParam(parts[optionsIndex]); const optionsValue = decodeUrlParamPart(rawValue); const lowerOptions = optionsValue.toLowerCase(); - const appended = connectionOptions.filter((option) => !lowerOptions.includes(option.needle)).map((option) => option.value).join(" "); + const appended = connectionOptions + .filter((option) => !lowerOptions.includes(option.needle)) + .map((option) => option.value) + .join(" "); if (appended) { const combined = `${optionsValue.trim()} ${appended}`.trim(); parts[optionsIndex] = `${rawKey}=${encodeURIComponent(combined)}`; @@ -406,16 +410,7 @@ function isStarrocksConnection(config: ConnectionConfig): boolean { function needsBareMysql(config: ConnectionConfig): boolean { const profile = config.driver_profile?.toLowerCase(); - return ( - config.db_type === "doris" || - config.db_type === "starrocks" || - config.db_type === "manticoresearch" || - profile === "doris" || - profile === "starrocks" || - profile === "manticoresearch" || - profile === "selectdb" || - profile === "oceanbase" - ); + return config.db_type === "doris" || config.db_type === "starrocks" || config.db_type === "manticoresearch" || profile === "doris" || profile === "starrocks" || profile === "manticoresearch" || profile === "selectdb" || profile === "oceanbase"; } function mysqlTlsFileParamIs(key: string, target: "cert" | "key"): boolean { @@ -472,14 +467,7 @@ function normalizeBareMysqlUrlParams(value: string): string { .filter((part) => { if (!part) return false; const key = decodeUrlParamPart(splitUrlParam(part)[0]).toLowerCase(); - return ( - key !== "charset" && - key !== "ssl-mode" && - key !== "sslmode" && - key !== "require_ssl" && - key !== "verify_ca" && - key !== "verify_identity" - ); + return key !== "charset" && key !== "ssl-mode" && key !== "sslmode" && key !== "require_ssl" && key !== "verify_ca" && key !== "verify_identity"; }) .join("&"); } @@ -509,12 +497,7 @@ function normalizeMysqlUrlParams(value: string, forceTls: boolean, acceptInvalid return filtered.join("&"); } - if ( - !parts.some( - (part) => - urlParamKeyIs(part, "ssl-mode") || urlParamKeyIs(part, "sslmode") || urlParamKeyIs(part, "require_ssl"), - ) - ) { + if (!parts.some((part) => urlParamKeyIs(part, "ssl-mode") || urlParamKeyIs(part, "sslmode") || urlParamKeyIs(part, "require_ssl"))) { parts.unshift("ssl-mode=disabled"); } if (!parts.some((part) => urlParamKeyIs(part, "charset"))) { @@ -580,6 +563,108 @@ interface BridgeColumnInfo { numeric_precision?: number | null; numeric_scale?: number | null; character_maximum_length?: number | null; + enum_values?: string[] | null; +} + +const POSTGRES_DESCRIBE_TABLE_SQL = `SELECT c.column_name AS name, CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS data_type, c.is_nullable = 'YES' AS is_nullable, c.column_default, CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN true ELSE false END AS is_primary_key, col_description(cls.oid, c.ordinal_position) AS comment, CASE WHEN enum_t.oid IS NULL THEN NULL ELSE COALESCE((SELECT array_to_json(array_agg(e.enumlabel ORDER BY e.enumsortorder)) FROM pg_enum e WHERE e.enumtypid = enum_t.oid), '[]'::json) END AS enum_values FROM information_schema.columns c LEFT JOIN information_schema.key_column_usage kcu ON kcu.table_schema = c.table_schema AND kcu.table_name = c.table_name AND kcu.column_name = c.column_name LEFT JOIN information_schema.table_constraints tc ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.constraint_type = 'PRIMARY KEY' LEFT JOIN pg_class cls ON cls.relname = c.table_name AND cls.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema) LEFT JOIN pg_namespace type_ns ON type_ns.nspname = c.udt_schema LEFT JOIN pg_type t ON t.typnamespace = type_ns.oid AND t.typname = c.udt_name LEFT JOIN pg_type enum_t ON enum_t.oid = CASE WHEN t.typtype = 'd' THEN t.typbasetype WHEN t.typtype = 'e' THEN t.oid ELSE NULL END AND enum_t.typtype = 'e' WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY c.ordinal_position`; +const POSTGRES_DESCRIBE_TABLE_COMPAT_SQL = `SELECT c.column_name AS name, CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS data_type, c.is_nullable = 'YES' AS is_nullable, c.column_default, CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN true ELSE false END AS is_primary_key, col_description(cls.oid, c.ordinal_position) AS comment, NULL AS enum_values FROM information_schema.columns c LEFT JOIN information_schema.key_column_usage kcu ON kcu.table_schema = c.table_schema AND kcu.table_name = c.table_name AND kcu.column_name = c.column_name LEFT JOIN information_schema.table_constraints tc ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.constraint_type = 'PRIMARY KEY' LEFT JOIN pg_class cls ON cls.relname = c.table_name AND cls.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema) WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY c.ordinal_position`; +const MYSQL_DESCRIBE_TABLE_SQL = `SELECT c.COLUMN_NAME AS name, c.DATA_TYPE AS data_type, c.COLUMN_TYPE AS column_type, c.IS_NULLABLE = 'YES' AS is_nullable, c.COLUMN_DEFAULT AS column_default, c.COLUMN_KEY = 'PRI' AS is_primary_key, c.COLUMN_COMMENT AS comment FROM information_schema.COLUMNS c WHERE c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = ? ORDER BY c.ORDINAL_POSITION`; + +function normalizeEnumValues(value: unknown): string[] | null { + if (value == null) return null; + if (Array.isArray(value)) return value.map((item) => String(item)); + if (typeof value === "string") { + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) ? parsed.map((item) => String(item)) : null; + } catch { + return null; + } + } + return null; +} + +function parseMysqlEnumValues(columnType: unknown): string[] | null { + if (typeof columnType !== "string") return null; + const trimmed = columnType.trim(); + if (!trimmed.toLowerCase().startsWith("enum(") || !trimmed.endsWith(")")) return null; + + const inner = trimmed.slice(5, -1); + const values: string[] = []; + let index = 0; + + const skipWhitespace = () => { + while (index < inner.length && /\s/.test(inner[index] ?? "")) index += 1; + }; + + while (index < inner.length) { + skipWhitespace(); + if (inner[index] !== "'") return null; + index += 1; + + let value = ""; + while (index < inner.length) { + const char = inner[index++]; + if (char === "'") { + if (inner[index] === "'") { + value += "'"; + index += 1; + continue; + } + break; + } + if (char === "\\") { + if (index >= inner.length) return null; + const escaped = inner[index++]; + if (escaped === "0") value += "\0"; + else if (escaped === "b") value += "\b"; + else if (escaped === "n") value += "\n"; + else if (escaped === "r") value += "\r"; + else if (escaped === "t") value += "\t"; + else if (escaped === "Z") value += "\x1a"; + else value += escaped; + continue; + } + value += char; + } + values.push(value); + + skipWhitespace(); + if (index >= inner.length) return values; + if (inner[index] !== ",") return null; + index += 1; + } + + return values; +} + +function mapDescribeTableColumn( + row: { + name?: unknown; + data_type?: unknown; + is_nullable?: unknown; + column_default?: unknown; + is_primary_key?: unknown; + comment?: unknown; + numeric_precision?: number | null; + numeric_scale?: number | null; + character_maximum_length?: number | null; + }, + enumValues: string[] | null, +): ColumnInfo { + const column: ColumnInfo = { + name: String(row.name || ""), + data_type: String(row.data_type || ""), + is_nullable: Boolean(row.is_nullable), + column_default: row.column_default != null ? String(row.column_default) : null, + is_primary_key: Boolean(row.is_primary_key), + comment: row.comment != null ? String(row.comment) : null, + enum_values: enumValues, + }; + if ("numeric_precision" in row) column.numeric_precision = row.numeric_precision; + if ("numeric_scale" in row) column.numeric_scale = row.numeric_scale; + if ("character_maximum_length" in row) column.character_maximum_length = row.character_maximum_length; + return column; } export function collectionListToTableInfos(collections: CollectionListEntry[]): TableInfo[] { @@ -834,7 +919,7 @@ export async function executeQuery(config: ConnectionConfig, sql: string, option return { columns: [], rows: [], row_count: result.affectedRows }; } throw new Error( - "Use MongoDB shell-style commands, for example: db.projects.find({}).limit(100), db.version(), db.projects.countDocuments({}), db.projects.count({}), db.projects.getIndexes(), db.projects.dataSize(), db.projects.storageSize(1024), db.projects.totalIndexSize(), db.projects.stats(), db.projects.createIndex({...}), db.projects.dropIndex(\"name\"), db.projects.dropIndexes(), db.projects.insertOne({...}), db.projects.updateOne({...}, {$set: {...}}), or db.projects.deleteOne({...})", + 'Use MongoDB shell-style commands, for example: db.projects.find({}).limit(100), db.version(), db.projects.countDocuments({}), db.projects.count({}), db.projects.getIndexes(), db.projects.dataSize(), db.projects.storageSize(1024), db.projects.totalIndexSize(), db.projects.stats(), db.projects.createIndex({...}), db.projects.dropIndex("name"), db.projects.dropIndexes(), db.projects.insertOne({...}), db.projects.updateOne({...}, {$set: {...}}), or db.projects.deleteOne({...})', ); } if (isDirectQueryType(config.db_type)) { @@ -994,40 +1079,22 @@ export async function describeTable(config: ConnectionConfig, table: string, sch schema: schema || "", table, }); - return columns.map((c) => ({ - name: c.name, - data_type: c.data_type, - is_nullable: c.is_nullable, - column_default: c.column_default, - is_primary_key: c.is_primary_key, - comment: c.comment, - numeric_precision: c.numeric_precision, - numeric_scale: c.numeric_scale, - character_maximum_length: c.character_maximum_length, - })); + return columns.map((column) => mapDescribeTableColumn(column, column.enum_values ?? null)); } let result: QueryResult; if (isMysqlType(config.db_type)) { - result = await query( - config, - `SELECT c.COLUMN_NAME AS name, c.DATA_TYPE AS data_type, c.IS_NULLABLE = 'YES' AS is_nullable, c.COLUMN_DEFAULT AS column_default, c.COLUMN_KEY = 'PRI' AS is_primary_key, c.COLUMN_COMMENT AS comment FROM information_schema.COLUMNS c WHERE c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = ? ORDER BY c.ORDINAL_POSITION`, - [table], - ); + result = await query(config, MYSQL_DESCRIBE_TABLE_SQL, [table]); + return result.rows.map((row) => mapDescribeTableColumn(row, String(row.data_type || "").toLowerCase() === "enum" ? parseMysqlEnumValues(row.column_type) : null)); + } else if (config.db_type === "postgres") { + try { + result = await query(config, POSTGRES_DESCRIBE_TABLE_SQL, [schema || "public", table]); + } catch { + result = await query(config, POSTGRES_DESCRIBE_TABLE_COMPAT_SQL, [schema || "public", table]); + } } else { - result = await query( - config, - `SELECT c.column_name AS name, c.data_type, c.is_nullable = 'YES' AS is_nullable, c.column_default, CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN true ELSE false END AS is_primary_key, col_description(cls.oid, c.ordinal_position) AS comment FROM information_schema.columns c LEFT JOIN information_schema.key_column_usage kcu ON kcu.table_schema = c.table_schema AND kcu.table_name = c.table_name AND kcu.column_name = c.column_name LEFT JOIN information_schema.table_constraints tc ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.constraint_type = 'PRIMARY KEY' LEFT JOIN pg_class cls ON cls.relname = c.table_name AND cls.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema) WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY c.ordinal_position`, - [schema || "public", table], - ); + result = await query(config, POSTGRES_DESCRIBE_TABLE_COMPAT_SQL, [schema || "public", table]); } - return result.rows.map((r) => ({ - name: String(r.name || ""), - data_type: String(r.data_type || ""), - is_nullable: Boolean(r.is_nullable), - column_default: r.column_default != null ? String(r.column_default) : null, - is_primary_key: Boolean(r.is_primary_key), - comment: r.comment != null ? String(r.comment) : null, - })); + return result.rows.map((row) => mapDescribeTableColumn(row, normalizeEnumValues(row.enum_values))); } async function mongoFindDocuments(config: ConnectionConfig, collection: string, skip: number, limit: number, filter: string, projection?: string, sort?: string): Promise { @@ -1050,11 +1117,7 @@ async function mongoServerVersion(config: ConnectionConfig): Promise { }); } -async function mongoCollectionStats( - config: ConnectionConfig, - collection: string, - scale?: number, -): Promise> { +async function mongoCollectionStats(config: ConnectionConfig, collection: string, scale?: number): Promise> { return bridgeDataRequest>("/data/mongo/collection-stats", { connection_name: config.name, database: config.database || "", @@ -1063,10 +1126,7 @@ async function mongoCollectionStats( }); } -async function executeMongoWrite( - config: ConnectionConfig, - command: MongoWriteCommand, -): Promise<{ affectedRows: number; indexName?: string; droppedNames?: string[] }> { +async function executeMongoWrite(config: ConnectionConfig, command: MongoWriteCommand): Promise<{ affectedRows: number; indexName?: string; droppedNames?: string[] }> { if (command.kind === "insert") { const result = await bridgeDataRequest<{ affected_rows: number }>("/data/mongo/insert-documents", { connection_name: config.name, @@ -1127,10 +1187,7 @@ async function mongoAggregateDocuments(config: ConnectionConfig, collection: str }); } -export function mongoCollectionStatsToQueryResult( - metric: MongoCollectionStatsMetric, - stats: Record, -): QueryResult { +export function mongoCollectionStatsToQueryResult(metric: MongoCollectionStatsMetric, stats: Record): QueryResult { if (metric === "stats") { const columns = ["count", "size", "avgObjSize", "storageSize", "totalIndexSize", "nindexes"]; const row: Record = {}; @@ -1275,11 +1332,7 @@ export function parseMongoCountDocumentsCommand(input: string): MongoCountDocume const source = input.trim().replace(/;$/, "").trim(); // Accept deprecated Mongo shell count helpers for old server workflows, but // keep DBX's internal execution mapped to the countDocuments result shape. - return ( - parseCollectionCountCommand(source, "countDocuments") ?? - parseCollectionCountCommand(source, "count") ?? - parseFindCountCommand(source) - ); + return parseCollectionCountCommand(source, "countDocuments") ?? parseCollectionCountCommand(source, "count") ?? parseFindCountCommand(source); } function parseCollectionCountCommand(source: string, method: "countDocuments" | "count"): MongoCountDocumentsCommand | null { @@ -1500,11 +1553,7 @@ function hasSingleEmptyChainedCall(chain: string, method: string): boolean { if (!match || match.index !== 0) return false; const openIndex = trimmed.indexOf("(", match.index); const closeIndex = findMatchingParen(trimmed, openIndex); - return ( - closeIndex >= 0 && - !trimmed.slice(openIndex + 1, closeIndex).trim() && - !trimmed.slice(closeIndex + 1).trim() - ); + return closeIndex >= 0 && !trimmed.slice(openIndex + 1, closeIndex).trim() && !trimmed.slice(closeIndex + 1).trim(); } function findChainedMethodCallIndex(source: string, method: string): number { diff --git a/packages/node-core/tests/describe-table.test.ts b/packages/node-core/tests/describe-table.test.ts new file mode 100644 index 000000000..bd35d6521 --- /dev/null +++ b/packages/node-core/tests/describe-table.test.ts @@ -0,0 +1,295 @@ +import assert from "node:assert/strict"; +import { afterEach, test, vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ConnectionConfig } from "../src/connections.js"; + +const fakeMysqlQuery = vi.fn(); +const fakeMysqlEnd = vi.fn().mockResolvedValue(undefined); +const fakePgQuery = vi.fn(); +const fakePgEnd = vi.fn().mockResolvedValue(undefined); +const fakePgOn = vi.fn(); + +vi.mock("mysql2/promise", () => ({ + default: { + createPool: vi.fn(() => ({ + query: fakeMysqlQuery, + end: fakeMysqlEnd, + })), + }, +})); + +vi.mock("pg", () => ({ + default: { + Pool: vi.fn(function MockPool() { + return { + query: fakePgQuery, + end: fakePgEnd, + on: fakePgOn, + }; + }), + }, +})); + +import { closeDatabaseResources, describeTable } from "../src/database.js"; + +function mysqlConfig(): ConnectionConfig { + return { + id: "mysql-direct-test", + name: "mysql-direct", + db_type: "mysql", + host: "127.0.0.1", + port: 3306, + username: "root", + password: "secret", + database: "app", + ssl: false, + }; +} + +function postgresConfig(): ConnectionConfig { + return { + id: "postgres-direct-test", + name: "postgres-direct", + db_type: "postgres", + host: "127.0.0.1", + port: 5432, + username: "postgres", + password: "postgres", + database: "app", + ssl: false, + }; +} + +const bridgeConfig: ConnectionConfig = { + id: "pg-bridge", + name: "bridge-postgres", + db_type: "postgres", + host: "127.0.0.1", + port: 5432, + username: "postgres", + password: "postgres", + database: "postgres", + ssl: false, + transport_layers: [ + { + type: "ssh", + id: "jump", + enabled: true, + host: "bastion.internal", + port: 22, + user: "dbx", + }, + ], +}; + +afterEach(async () => { + await closeDatabaseResources(); + fakeMysqlQuery.mockReset(); + fakeMysqlEnd.mockClear(); + fakePgQuery.mockReset(); + fakePgEnd.mockClear(); + fakePgOn.mockClear(); +}); + +test("describeTable maps mysql enum_values from metadata", async () => { + fakeMysqlQuery.mockResolvedValue([ + [ + { + name: "state", + data_type: "enum", + column_type: "enum('pending','active','archived')", + is_nullable: 0, + column_default: "pending", + is_primary_key: 0, + comment: "workflow state", + }, + ], + [{ name: "name" }], + ]); + + const columns = await describeTable(mysqlConfig(), "orders"); + + assert.match(String(fakeMysqlQuery.mock.calls[0]?.[0] ?? ""), /COLUMN_TYPE AS column_type/); + assert.deepEqual(fakeMysqlQuery.mock.calls[0]?.[1], ["orders"]); + assert.deepEqual(columns, [ + { + name: "state", + data_type: "enum", + is_nullable: false, + column_default: "pending", + is_primary_key: false, + comment: "workflow state", + enum_values: ["pending", "active", "archived"], + }, + ]); +}); + +test("describeTable parses mysql enum literal edge cases", async () => { + fakeMysqlQuery.mockResolvedValue([ + [ + { + name: "empty_state", + data_type: "enum", + column_type: "enum('','a')", + is_nullable: 1, + column_default: null, + is_primary_key: 0, + comment: null, + }, + { + name: "quoted_state", + data_type: "enum", + column_type: "enum('x'',''y','z')", + is_nullable: 1, + column_default: null, + is_primary_key: 0, + comment: null, + }, + { + name: "escaped_state", + data_type: "enum", + column_type: String.raw`enum('it''s','quote\"d','back\\slash')`, + is_nullable: 1, + column_default: null, + is_primary_key: 0, + comment: null, + }, + ], + [{ name: "name" }], + ]); + + const columns = await describeTable(mysqlConfig(), "orders"); + + assert.deepEqual( + columns.map((column) => column.enum_values), + [ + ["", "a"], + ["x','y", "z"], + ["it's", 'quote"d', "back\\slash"], + ], + ); +}); + +test("describeTable preserves enum values from bridge metadata", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "dbx-node-core-")); + const previousDataDir = process.env.DBX_DATA_DIR; + + const server = createServer((req, res) => { + assert.equal(req.url, "/data/describe-table"); + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify([ + { + name: "state", + data_type: "status", + is_nullable: false, + column_default: null, + is_primary_key: false, + comment: "workflow state", + enum_values: ["pending", "active", "archived"], + }, + ]), + ); + }); + + try { + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected TCP bridge address"); + + process.env.DBX_DATA_DIR = tempDir; + await writeFile(join(tempDir, "mcp-bridge-port"), String(address.port)); + + const columns = await describeTable(bridgeConfig, "orders", "public"); + + assert.deepEqual(columns, [ + { + name: "state", + data_type: "status", + is_nullable: false, + column_default: null, + is_primary_key: false, + comment: "workflow state", + enum_values: ["pending", "active", "archived"], + }, + ]); + } finally { + server.close(); + if (previousDataDir === undefined) { + delete process.env.DBX_DATA_DIR; + } else { + process.env.DBX_DATA_DIR = previousDataDir; + } + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("describeTable reads postgres enum_values from the primary metadata query", async () => { + fakePgQuery.mockResolvedValueOnce({ + rows: [ + { + name: "state", + data_type: "status", + is_nullable: false, + column_default: null, + is_primary_key: false, + comment: "workflow state", + enum_values: ["pending", "active", "archived"], + }, + ], + fields: [{ name: "name" }], + }); + + const columns = await describeTable(postgresConfig(), "orders", "public"); + + assert.match(String(fakePgQuery.mock.calls[0]?.[0] ?? ""), /FROM pg_enum e WHERE e\.enumtypid = enum_t\.oid/); + assert.equal(fakePgQuery.mock.calls.length, 1); + assert.deepEqual(columns, [ + { + name: "state", + data_type: "status", + is_nullable: false, + column_default: null, + is_primary_key: false, + comment: "workflow state", + enum_values: ["pending", "active", "archived"], + }, + ]); +}); + +test("describeTable falls back to compat postgres metadata query when enum joins fail", async () => { + fakePgQuery.mockRejectedValueOnce(new Error("pg_enum catalog unavailable")).mockResolvedValueOnce({ + rows: [ + { + name: "state", + data_type: "status", + is_nullable: false, + column_default: null, + is_primary_key: false, + comment: "workflow state", + enum_values: null, + }, + ], + fields: [{ name: "name" }], + }); + + const columns = await describeTable(postgresConfig(), "orders", "public"); + + assert.match(String(fakePgQuery.mock.calls[0]?.[0] ?? ""), /FROM pg_enum e WHERE e\.enumtypid = enum_t\.oid/); + assert.match(String(fakePgQuery.mock.calls[1]?.[0] ?? ""), /NULL AS enum_values/); + assert.doesNotMatch(String(fakePgQuery.mock.calls[1]?.[0] ?? ""), /pg_enum/); + assert.deepEqual(columns, [ + { + name: "state", + data_type: "status", + is_nullable: false, + column_default: null, + is_primary_key: false, + comment: "workflow state", + enum_values: null, + }, + ]); +});