fix(metadata): unify enum column metadata

This commit is contained in:
onenewcode 2026-07-08 21:02:53 +08:00 committed by GitHub
parent c32d0b7d6e
commit 1b13d2176b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 675 additions and 202 deletions

View File

@ -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 {

View File

@ -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<ColumnInfo, "data_type"> | 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<ColumnInfo, "data_type"> | 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 [];
}

View File

@ -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 {

View File

@ -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())

View File

@ -333,6 +333,7 @@ fn push_mapping_column(
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
});
}

View File

@ -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<ColumnInfo> = 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())
}

View File

@ -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<Vec<String>> {
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<Vec<ColumnInfo>, 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]

View File

@ -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())

View File

@ -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<Vec<String>> {
let raw = row.try_get::<_, Option<String>>(index).ok().flatten()?;
serde_json::from_str::<Vec<String>>(&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<i32>>(7).ok().flatten(),
numeric_scale: row.try_get::<_, Option<i32>>(8).ok().flatten(),
character_maximum_length: row.try_get::<_, Option<i32>>(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<DockerPostgres> {
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<Vec<String>> {
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() {

View File

@ -111,6 +111,7 @@ pub async fn get_columns(pool: &Pool, _schema: &str, table: &str) -> Result<Vec<
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
}
})
.collect())

View File

@ -145,6 +145,7 @@ pub async fn get_columns(client: &RqliteClient, _schema: &str, table: &str) -> R
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
})
.collect())
}

View File

@ -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())?;

View File

@ -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())

View File

@ -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())
}

View File

@ -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<db::ColumnIn
numeric_precision: precision,
numeric_scale: scale,
character_maximum_length: length,
enum_values: None,
})
})
.collect()
@ -1980,6 +1982,7 @@ fn presto_like_columns_from_query_result(result: &db::QueryResult) -> Vec<db::Co
numeric_precision: presto_like_numeric_precision(&data_type),
numeric_scale: presto_like_numeric_scale(&data_type),
character_maximum_length: presto_like_character_maximum_length(&data_type),
enum_values: None,
})
})
.collect()
@ -2080,6 +2083,7 @@ mod tests {
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
}
}
@ -5263,6 +5267,7 @@ mod object_source_tests {
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
};
let mut ignored = column.clone();
ignored.name = "EMPTY_COMMENT".to_string();
@ -5295,6 +5300,7 @@ mod object_source_tests {
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
};
let ddl = append_oracle_comments_to_ddl(
@ -5328,6 +5334,7 @@ mod ddl_tests {
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
}
}

View File

@ -1454,6 +1454,7 @@ mod tests {
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
}
}
@ -1853,6 +1854,7 @@ mod tests {
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
}),
target: None,
changes: Vec::new(),

View File

@ -2317,6 +2317,7 @@ fn mongo_columns_from_documents(documents: &[serde_json::Value]) -> Vec<db::Colu
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
}
})
.collect()
@ -3491,6 +3492,7 @@ where
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
});
}
sql_target_column_names = sql_target_columns.iter().map(|column| column.name.clone()).collect();
@ -4388,6 +4390,7 @@ mod tests {
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
}
}
@ -5216,6 +5219,7 @@ mod tests {
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
},
db::ColumnInfo {
name: "identity_id".to_string(),
@ -5228,6 +5232,7 @@ mod tests {
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
},
db::ColumnInfo {
name: "computed_id".to_string(),
@ -5240,6 +5245,7 @@ mod tests {
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
},
],
"users",
@ -5313,6 +5319,7 @@ mod tests {
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
},
db::ColumnInfo {
name: "name".to_string(),
@ -5325,6 +5332,7 @@ mod tests {
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
},
];
let source_ddl = crate::schema::render_postgres_table_ddl("public", "it_quick_entry", &columns, &[], &[]);

View File

@ -92,6 +92,8 @@ pub struct ColumnInfo {
pub numeric_precision: Option<i32>,
pub numeric_scale: Option<i32>,
pub character_maximum_length: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enum_values: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]

View File

@ -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"],
);
});

View File

@ -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<MongoDocumentResult> {
@ -1050,11 +1117,7 @@ async function mongoServerVersion(config: ConnectionConfig): Promise<string> {
});
}
async function mongoCollectionStats(
config: ConnectionConfig,
collection: string,
scale?: number,
): Promise<Record<string, unknown>> {
async function mongoCollectionStats(config: ConnectionConfig, collection: string, scale?: number): Promise<Record<string, unknown>> {
return bridgeDataRequest<Record<string, unknown>>("/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<string, unknown>,
): QueryResult {
export function mongoCollectionStatsToQueryResult(metric: MongoCollectionStatsMetric, stats: Record<string, unknown>): QueryResult {
if (metric === "stats") {
const columns = ["count", "size", "avgObjSize", "storageSize", "totalIndexSize", "nindexes"];
const row: Record<string, unknown> = {};
@ -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 {

View File

@ -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<void>((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,
},
]);
});