fix(metadata): unify enum column metadata
This commit is contained in:
parent
c32d0b7d6e
commit
1b13d2176b
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -333,6 +333,7 @@ fn push_mapping_column(
|
|||
numeric_precision: None,
|
||||
numeric_scale: None,
|
||||
character_maximum_length: None,
|
||||
enum_values: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())?;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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, &[], &[]);
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
);
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
Loading…
Reference in New Issue