fix(opengauss): open table structure editor

This commit is contained in:
t8y2 2026-06-02 18:50:00 +08:00
parent e20669ddcf
commit cac90c7b62
5 changed files with 181 additions and 51 deletions

View File

@ -167,7 +167,12 @@ const canOpenDiagram = computed(() => !!props.database && supportsSchemaDiagram(
const canOpenTableImport = computed(() => !!props.database && supportsTableImport(props.connection.db_type));
const supportsTruncateTable = computed(() => supportsTableTruncate(props.connection.db_type));
const sourceDialect = computed<"mysql" | "postgres" | "sqlserver">(() => {
if (props.connection.db_type === "postgres" || props.connection.db_type === "gaussdb") return "postgres";
if (
props.connection.db_type === "postgres" ||
props.connection.db_type === "gaussdb" ||
props.connection.db_type === "opengauss"
)
return "postgres";
if (props.connection.db_type === "sqlserver") return "sqlserver";
return "mysql";
});
@ -178,6 +183,9 @@ const sourceFormatDialect = computed<SqlFormatDialect>(() => {
case "sqlite":
case "sqlserver":
return props.connection.db_type;
case "gaussdb":
case "opengauss":
return "postgres";
default:
return "generic";
}

View File

@ -284,6 +284,7 @@ function onIndexColResize(e: MouseEvent, col: number) {
const connection = computed(() => (props.connectionId ? store.getConfig(props.connectionId) : undefined));
const databaseType = computed(() => connection.value?.db_type);
const structureCapabilities = computed(() => getTableStructureCapabilities(databaseType.value));
const structureDialect = computed(() => structureCapabilities.value.dialect);
const isTableCommentDisabled = computed(() => !structureCapabilities.value.comment);
const dataTypeOptions = computed(() => getDataTypeOptions(databaseType.value));
@ -295,12 +296,23 @@ const indexTypesByDb: Record<string, string[]> = {
sqlite: ["BTREE"],
};
const indexTypeOptions = computed(() =>
structureCapabilities.value.indexType ? (indexTypesByDb[databaseType.value ?? ""] ?? []) : [],
structureCapabilities.value.indexType ? (indexTypesByDb[structureDialect.value] ?? []) : [],
);
function isPostgresIdentityType(dbType: string | undefined): boolean {
return (
dbType === "postgres" ||
dbType === "gaussdb" ||
dbType === "opengauss" ||
dbType === "highgo" ||
dbType === "vastbase" ||
dbType === "kingbase"
);
}
const showExtendedProperties = computed(() => {
const dt = databaseType.value;
return dt === "mysql" || dt === "postgres" || dt === "sqlserver";
return dt === "mysql" || isPostgresIdentityType(dt) || dt === "sqlserver";
});
const extendedPropertiesColumnIndex = 8;
const visibleColWidths = computed(() =>
@ -1010,7 +1022,7 @@ watch(
<td v-if="showExtendedProperties" :class="structureCellClass">
<div class="flex items-center gap-2">
<!-- MySQL: AUTO_INCREMENT + ON UPDATE CURRENT_TIMESTAMP -->
<template v-if="databaseType === 'mysql'">
<template v-if="structureDialect === 'mysql'">
<label class="flex items-center gap-1 whitespace-nowrap">
<input v-model="column.extra.autoIncrement" type="checkbox" :class="structureCheckboxClass" />
{{ t("structureEditor.autoIncrement") }}
@ -1025,7 +1037,7 @@ watch(
</label>
</template>
<!-- PostgreSQL: IDENTITY -->
<template v-else-if="databaseType === 'postgres'">
<template v-else-if="structureDialect === 'postgres'">
<Select
:model-value="column.extra.identity?.generation ?? 'none'"
@update:model-value="
@ -1083,7 +1095,7 @@ watch(
</template>
</template>
<!-- SQL Server: IDENTITY -->
<template v-else-if="databaseType === 'sqlserver'">
<template v-else-if="structureDialect === 'sqlserver'">
<label class="flex items-center gap-1 whitespace-nowrap">
<input v-model="column.extra.autoIncrement" type="checkbox" :class="structureCheckboxClass" />
{{ t("structureEditor.identity") }}

View File

@ -294,7 +294,14 @@ export function parseExtraToColumnExtra(extra: string | null | undefined, databa
if (lower.includes("on update current_timestamp")) {
result.onUpdateCurrentTimestamp = true;
}
} else if (databaseType === "postgres") {
} else if (
databaseType === "postgres" ||
databaseType === "gaussdb" ||
databaseType === "opengauss" ||
databaseType === "highgo" ||
databaseType === "vastbase" ||
databaseType === "kingbase"
) {
const identityMatch = lower.match(/generated\s+(by\s+default|always)\s+as\s+identity/i);
if (identityMatch) {
const sequenceMatch = lower.match(/start\s+with\s*(-?\d+)\s+increment\s+by\s*(-?\d+)/i);

View File

@ -874,11 +874,7 @@ pub async fn list_schemas(pool: &Pool) -> Result<Vec<String>, String> {
Ok(rows.iter().map(|row| row.get::<_, String>(0)).collect())
}
pub async fn get_columns(pool: &Pool, schema: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let client = pool.get().await.map_err(|e| e.to_string())?;
let stmt = client
.prepare_cached(
"SELECT a.attname AS column_name, \
const POSTGRES_COLUMNS_SQL: &str = "SELECT a.attname AS column_name, \
format_type(a.atttypid, a.atttypmod) AS full_type, \
NOT a.attnotnull AS is_nullable, \
pg_get_expr(ad.adbin, ad.adrelid) AS column_default, \
@ -907,30 +903,79 @@ pub async fn get_columns(pool: &Pool, schema: &str, table: &str) -> Result<Vec<C
LEFT JOIN pg_sequence pseq ON pseq.seqrelid = dep.objid \
WHERE a.attrelid = (quote_ident($1) || '.' || quote_ident($2))::regclass \
AND a.attnum > 0 AND NOT a.attisdropped \
ORDER BY a.attnum",
)
.await
.map_err(|e| e.to_string())?;
let rows = client.query(&stmt, &[&schema, &table]).await.map_err(|e| e.to_string())?;
ORDER BY a.attnum";
Ok(rows
.iter()
.map(|row| {
let full_type = row.try_get::<_, Option<String>>(1).ok().flatten().unwrap_or_default();
ColumnInfo {
name: row.get::<_, String>(0),
data_type: full_type,
is_nullable: row.get::<_, bool>(2),
column_default: row.try_get::<_, Option<String>>(3).ok().flatten(),
is_primary_key: row.get::<_, bool>(4),
extra: row.try_get::<_, Option<String>>(6).ok().flatten(),
comment: row.try_get::<_, Option<String>>(5).ok().flatten(),
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(),
const POSTGRES_COLUMNS_COMPAT_SQL: &str = "SELECT a.attname AS column_name, \
format_type(a.atttypid, a.atttypmod) AS full_type, \
NOT a.attnotnull AS is_nullable, \
pg_get_expr(ad.adbin, ad.adrelid) AS column_default, \
EXISTS ( \
SELECT 1 FROM pg_constraint co \
JOIN pg_index i ON i.indrelid = co.conrelid AND co.conindid = i.indexrelid \
WHERE co.conrelid = a.attrelid AND co.contype = 'p' \
AND a.attnum = ANY(i.indkey) \
) AS is_pk, \
col_description(a.attrelid, a.attnum) AS column_comment, \
NULL::text AS column_extra, \
CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 \
THEN ((a.atttypmod - 4) >> 16) & 65535 ELSE NULL END AS numeric_precision, \
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 \
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 \
WHERE a.attrelid = (quote_ident($1) || '.' || quote_ident($2))::regclass \
AND a.attnum > 0 AND NOT a.attisdropped \
ORDER BY a.attnum";
fn column_info_from_row(row: &Row) -> ColumnInfo {
let full_type = row.try_get::<_, Option<String>>(1).ok().flatten().unwrap_or_default();
ColumnInfo {
name: row.get::<_, String>(0),
data_type: full_type,
is_nullable: row.get::<_, bool>(2),
column_default: row.try_get::<_, Option<String>>(3).ok().flatten(),
is_primary_key: row.get::<_, bool>(4),
extra: row.try_get::<_, Option<String>>(6).ok().flatten(),
comment: row.try_get::<_, Option<String>>(5).ok().flatten(),
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(),
}
}
async fn get_columns_with_sql(
client: &deadpool_postgres::Client,
sql: &str,
schema: &str,
table: &str,
) -> Result<Vec<ColumnInfo>, tokio_postgres::Error> {
let stmt = client.prepare_cached(sql).await?;
let rows = client.query(&stmt, &[&schema, &table]).await?;
Ok(rows.iter().map(column_info_from_row).collect())
}
pub async fn get_columns(pool: &Pool, schema: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let client = pool.get().await.map_err(|e| e.to_string())?;
match get_columns_with_sql(&client, POSTGRES_COLUMNS_SQL, schema, table).await {
Ok(columns) => Ok(columns),
Err(primary_error) => match get_columns_with_sql(&client, POSTGRES_COLUMNS_COMPAT_SQL, schema, table).await {
Ok(columns) => Ok(columns),
Err(fallback_error) => {
let primary_message = pg_error_to_string(primary_error);
let fallback_message = pg_error_to_string(fallback_error);
log::debug!(
"[postgres][get_columns:compat-failed] primary_error={} fallback_error={}",
primary_message,
fallback_message
);
Err(fallback_message)
}
})
.collect())
},
}
}
pub(crate) fn pg_quote_ident(ident: &str) -> String {
@ -1045,11 +1090,7 @@ async fn execute_query_with_max_rows_inner(
}
}
pub async fn list_indexes(pool: &Pool, schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
let client = pool.get().await.map_err(|e| e.to_string())?;
let stmt = client
.prepare_cached(
"SELECT i.relname AS index_name, \
const POSTGRES_INDEXES_SQL: &str = "SELECT i.relname AS index_name, \
array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, true)) ORDER BY k.n) AS columns, \
ix.indisunique AS is_unique, \
ix.indisprimary AS is_primary, \
@ -1067,11 +1108,36 @@ pub async fn list_indexes(pool: &Pool, schema: &str, table: &str) -> Result<Vec<
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \
WHERE n.nspname = $1 AND t.relname = $2 \
GROUP BY i.relname, i.oid, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indnkeyatts, ix.indkey \
ORDER BY i.relname",
)
.await
.map_err(|e| e.to_string())?;
let rows = client.query(&stmt, &[&schema, &table]).await.map_err(|e| e.to_string())?;
ORDER BY i.relname";
const POSTGRES_INDEXES_COMPAT_SQL: &str = "SELECT i.relname AS index_name, \
array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, true)) ORDER BY k.n) AS columns, \
ix.indisunique AS is_unique, \
ix.indisprimary AS is_primary, \
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
am.amname AS index_type, \
NULL::smallint AS nkeyatts, \
ix.indkey AS indkey, \
obj_description(i.oid, 'pg_class') AS index_comment \
FROM pg_index ix \
JOIN pg_class t ON t.oid = ix.indrelid \
JOIN pg_class i ON i.oid = ix.indexrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
JOIN pg_am am ON am.oid = i.relam \
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true \
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \
WHERE n.nspname = $1 AND t.relname = $2 \
GROUP BY i.relname, i.oid, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indkey \
ORDER BY i.relname";
async fn list_indexes_with_sql(
client: &deadpool_postgres::Client,
sql: &str,
schema: &str,
table: &str,
) -> Result<Vec<IndexInfo>, tokio_postgres::Error> {
let stmt = client.prepare_cached(sql).await?;
let rows = client.query(&stmt, &[&schema, &table]).await?;
Ok(rows
.iter()
@ -1095,6 +1161,26 @@ pub async fn list_indexes(pool: &Pool, schema: &str, table: &str) -> Result<Vec<
.collect())
}
pub async fn list_indexes(pool: &Pool, schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
let client = pool.get().await.map_err(|e| e.to_string())?;
match list_indexes_with_sql(&client, POSTGRES_INDEXES_SQL, schema, table).await {
Ok(indexes) => Ok(indexes),
Err(primary_error) => match list_indexes_with_sql(&client, POSTGRES_INDEXES_COMPAT_SQL, schema, table).await {
Ok(indexes) => Ok(indexes),
Err(fallback_error) => {
let primary_message = pg_error_to_string(primary_error);
let fallback_message = pg_error_to_string(fallback_error);
log::debug!(
"[postgres][list_indexes:compat-failed] primary_error={} fallback_error={}",
primary_message,
fallback_message
);
Err(fallback_message)
}
},
}
}
pub async fn list_foreign_keys(pool: &Pool, schema: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
let client = pool.get().await.map_err(|e| e.to_string())?;
let stmt = client
@ -1457,14 +1543,25 @@ mod tests {
#[test]
fn postgres_column_metadata_reads_identity_extra() {
let source = include_str!("postgres.rs");
let get_columns = source.split("pub async fn get_columns").nth(1).unwrap();
let get_columns = get_columns.split("pub async fn list_indexes").next().unwrap();
assert!(POSTGRES_COLUMNS_SQL.contains("a.attidentity"));
assert!(POSTGRES_COLUMNS_SQL.contains("pg_sequence"));
assert!(POSTGRES_COLUMNS_SQL.contains("generated by default as identity"));
assert!(POSTGRES_COLUMNS_SQL.contains("generated always as identity"));
}
assert!(get_columns.contains("a.attidentity"));
assert!(get_columns.contains("pg_sequence"));
assert!(get_columns.contains("generated by default as identity"));
assert!(get_columns.contains("generated always as identity"));
#[test]
fn postgres_column_metadata_has_opengauss_compatible_fallback() {
assert!(!POSTGRES_COLUMNS_COMPAT_SQL.contains("a.attidentity"));
assert!(!POSTGRES_COLUMNS_COMPAT_SQL.contains("pg_sequence"));
assert!(POSTGRES_COLUMNS_COMPAT_SQL.contains("NULL::text AS column_extra"));
assert!(POSTGRES_COLUMNS_COMPAT_SQL.contains("col_description"));
}
#[test]
fn postgres_index_metadata_has_legacy_catalog_fallback() {
assert!(POSTGRES_INDEXES_SQL.contains("ix.indnkeyatts"));
assert!(!POSTGRES_INDEXES_COMPAT_SQL.contains("ix.indnkeyatts"));
assert!(POSTGRES_INDEXES_COMPAT_SQL.contains("NULL::smallint AS nkeyatts"));
}
#[test]

View File

@ -103,6 +103,12 @@ test("parses PostgreSQL extra string to ColumnExtra", () => {
assert.deepEqual(parseExtraToColumnExtra("generated by default as identity", "postgres"), {
identity: { generation: "BY DEFAULT" },
});
assert.deepEqual(parseExtraToColumnExtra("generated by default as identity", "opengauss"), {
identity: { generation: "BY DEFAULT" },
});
assert.deepEqual(parseExtraToColumnExtra("generated by default as identity", "gaussdb"), {
identity: { generation: "BY DEFAULT" },
});
assert.deepEqual(parseExtraToColumnExtra("GENERATED ALWAYS AS IDENTITY", "postgres"), {
identity: { generation: "ALWAYS" },
});