feat(cloudberry): add database support

This commit is contained in:
t8y2 2026-07-24 16:55:46 +08:00
parent 0362fef273
commit 0449d7487a
No known key found for this signature in database
13 changed files with 573 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -744,6 +744,14 @@ const driverProfiles: Record<
icon: "postgres",
urlParams: "",
},
cloudberry: {
type: "postgres",
port: 5432,
user: "postgres",
label: "Apache Cloudberry",
icon: "cloudberry",
urlParams: "",
},
redis: { type: "redis", port: 6379, user: "", label: "Redis", icon: "redis" },
sqlite: { type: "sqlite", port: 0, user: "", label: "SQLite", icon: "sqlite" },
rqlite: { type: "rqlite", port: 4001, user: "", label: "RQLite", icon: "rqlite" },
@ -2108,6 +2116,7 @@ function isH2FileJdbcUrlLikePath(value: string): boolean {
const iconTypeMap: Record<string, string> = {
mysql: "mysql",
postgres: "postgres",
cloudberry: "cloudberry",
sqlite: "sqlite",
rqlite: "rqlite",
turso: "turso",
@ -2190,6 +2199,7 @@ const iconTypeMap: Record<string, string> = {
const dbOptions: DbOption[] = [
{ value: "postgres", label: "PostgreSQL" },
{ value: "cloudberry", label: "Apache Cloudberry" },
{ value: "mysql", label: "MySQL" },
{ value: "mongodb", label: "MongoDB" },
{ value: "redis", label: "Redis" },
@ -2282,7 +2292,7 @@ const dbCategoryDefinitions: Array<{
{
key: "analytics",
titleKey: "connection.databaseCategoryAnalytics",
optionValues: ["clickhouse", "doris", "starrocks", "databend", "selectdb", "databricks", "saphana", "teradata", "vertica", "exasol", "redshift", "snowflake", "trino", "prestosql", "hive", "spark", "bigquery", "kylin", "dremio"],
optionValues: ["cloudberry", "clickhouse", "doris", "starrocks", "databend", "selectdb", "databricks", "saphana", "teradata", "vertica", "exasol", "redshift", "snowflake", "trino", "prestosql", "hive", "spark", "bigquery", "kylin", "dremio"],
},
{
key: "domestic",

View File

@ -11,6 +11,7 @@ const assetIcons: Record<string, string> = {
mysql: "mysql",
postgres: "postgres",
postgresql: "postgres",
cloudberry: "cloudberry.png",
sqlite: "sqlite",
rqlite: "rqlite.png",
turso: "turso.png",

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { connectionProfileForScheme, parseConnectionUrl } from "@/lib/connection/connectionUrl";
describe("Cloudberry connection URLs", () => {
it("parses the Cloudberry alias as a PostgreSQL-compatible profile", () => {
const parsed = parseConnectionUrl("cloudberry://analyst:secret@cb.example.com/warehouse");
expect(parsed).toMatchObject({
dbType: "postgres",
driverProfile: "cloudberry",
driverLabel: "Apache Cloudberry",
host: "cb.example.com",
port: 5432,
username: "analyst",
password: "secret",
database: "warehouse",
});
});
it("keeps the Cloudberry profile for standard PostgreSQL URLs", () => {
const parsed = parseConnectionUrl("postgresql://cb.example.com:6432/warehouse", "cloudberry");
expect(parsed.dbType).toBe("postgres");
expect(parsed.driverProfile).toBe("cloudberry");
expect(parsed.driverLabel).toBe("Apache Cloudberry");
expect(parsed.port).toBe(6432);
});
it("exposes Cloudberry to connection deep links", () => {
expect(connectionProfileForScheme("cloudberry")).toEqual({
type: "postgres",
profile: "cloudberry",
label: "Apache Cloudberry",
defaultPort: 5432,
});
});
});

View File

@ -81,3 +81,36 @@ describe("DBeaver folder import", () => {
expect(layoutLabels(result.layout!, names)).toEqual([{ group: "Ad hoc", children: [{ group: "Production", children: ["Nested"] }] }]);
});
});
describe("DBeaver Cloudberry import", () => {
it("preserves Cloudberry while reusing the PostgreSQL backend", async () => {
const connections = await parseDbeaverConnections(
payload({
connections: {
cloudberry: {
id: "cloudberry",
name: "analytics",
provider: "cloudberry",
driver: "cloudberry-jdbc",
configuration: {
host: "cb.example.com",
port: 5432,
database: "warehouse",
user: "analyst",
},
},
},
}),
);
expect(connections[0]).toMatchObject({
db_type: "postgres",
driver_profile: "cloudberry",
driver_label: "Apache Cloudberry",
host: "cb.example.com",
port: 5432,
database: "warehouse",
username: "analyst",
});
});
});

View File

@ -31,6 +31,7 @@ const SCHEME_PROFILES: Record<string, ConnectionProfile> = {
mariadb: { type: "mysql", profile: "mariadb", label: "MariaDB", defaultPort: 3306 },
postgres: { type: "postgres", profile: "postgres", label: "PostgreSQL", defaultPort: 5432 },
postgresql: { type: "postgres", profile: "postgres", label: "PostgreSQL", defaultPort: 5432 },
cloudberry: { type: "postgres", profile: "cloudberry", label: "Apache Cloudberry", defaultPort: 5432 },
redshift: { type: "redshift", profile: "redshift", label: "Redshift", defaultPort: 5439 },
redis: { type: "redis", profile: "redis", label: "Redis", defaultPort: 6379 },
rediss: { type: "redis", profile: "redis", label: "Redis", defaultPort: 6379 },
@ -248,6 +249,10 @@ export function connectionProfileForScheme(scheme: string, preferredProfile?: st
if ((scheme === "http" || scheme === "https") && preferredProfile) {
return HTTP_SELECTED_PROFILES[preferredProfile];
}
// Cloudberry uses PostgreSQL URLs, so keep the selected product profile when parsing a pasted URL.
if ((scheme === "postgres" || scheme === "postgresql") && preferredProfile === "cloudberry") {
return SCHEME_PROFILES.cloudberry;
}
return SCHEME_PROFILES[scheme];
}

View File

@ -41,6 +41,7 @@ const profileMap: Record<string, ConnectionProfile> = {
mariadb: { dbType: "mysql", profile: "mariadb", label: "MariaDB", port: 3306, user: "root" },
postgresql: { dbType: "postgres", profile: "postgres", label: "PostgreSQL", port: 5432, user: "postgres" },
postgres: { dbType: "postgres", profile: "postgres", label: "PostgreSQL", port: 5432, user: "postgres" },
cloudberry: { dbType: "postgres", profile: "cloudberry", label: "Apache Cloudberry", port: 5432, user: "postgres" },
sqlite: { dbType: "sqlite", profile: "sqlite", label: "SQLite", port: 0, user: "" },
sqlserver: { dbType: "sqlserver", profile: "sqlserver", label: "SQL Server", port: 1433, user: "sa" },
mssql: { dbType: "sqlserver", profile: "sqlserver", label: "SQL Server", port: 1433, user: "sa" },

View File

@ -0,0 +1,335 @@
use std::collections::HashSet;
use deadpool_postgres::Pool;
use super::{ObjectInfo, TableInfo};
use crate::db;
const CLOUD_BERRY_TABLE_DDL_SQL: &str = "SELECT pg_get_tabledef($1, $2, true)";
const CLOUD_BERRY_EXTERNAL_TABLES_SQL: &str = "SELECT c.relname \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
JOIN pg_catalog.pg_exttable x ON x.reloid = c.oid \
WHERE n.nspname = $1 AND c.relname = ANY($2::text[])";
const CLOUD_BERRY_TABLE_MODIFIERS_SQL: &str = "SELECT COALESCE(am.amname, '')::text AS access_method, \
COALESCE(array_to_string(c.reloptions, E'\\n'), '')::text AS reloptions, \
COALESCE(dp.policytype::text, '')::text AS policy_type, \
COALESCE(string_agg(a.attname, E'\\n' \
ORDER BY array_position(dp.distkey::smallint[], a.attnum::smallint)), '')::text \
AS distribution_columns, \
bool_or(x.reloid IS NOT NULL) AS is_external, \
COALESCE(fs.srvname, '')::text AS external_server, \
ft.ftoptions AS external_options \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
LEFT JOIN pg_catalog.pg_am am ON am.oid = c.relam \
LEFT JOIN pg_catalog.gp_distribution_policy dp ON dp.localoid = c.oid \
LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(dp.distkey) \
LEFT JOIN pg_catalog.pg_exttable x ON x.reloid = c.oid \
LEFT JOIN pg_catalog.pg_foreign_table ft ON ft.ftrelid = c.oid \
LEFT JOIN pg_catalog.pg_foreign_server fs ON fs.oid = ft.ftserver \
WHERE n.nspname = $1 AND c.relname = $2 \
AND c.relkind IN ('r', 'p', 'f') \
GROUP BY c.oid, am.amname, c.reloptions, dp.policytype, dp.distkey, fs.srvname, ft.ftoptions \
LIMIT 1";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DistributionPolicy {
Hash(Vec<String>),
Random,
Replicated,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalTableDefinition {
pub server: String,
pub options: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableModifiers {
pub access_method: Option<String>,
pub reloptions: Vec<String>,
pub distribution: Option<DistributionPolicy>,
pub external: Option<ExternalTableDefinition>,
}
pub async fn list_tables_filtered(
pool: &Pool,
schema: &str,
filter: Option<&str>,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<Vec<TableInfo>, String> {
let mut tables = db::postgres::list_tables_filtered(pool, schema, filter, limit, offset).await?;
annotate_external_tables(pool, schema, &mut tables).await;
Ok(tables)
}
pub async fn list_objects(pool: &Pool, schema: &str) -> Result<Vec<ObjectInfo>, String> {
let mut objects = db::postgres::list_objects(pool, schema).await?;
let names = objects.iter().map(|object| object.name.clone()).collect::<Vec<_>>();
let external_names = external_table_names(pool, schema, &names).await.unwrap_or_else(|error| {
log::debug!("[cloudberry][list_objects:external-table-fallback] error={error}");
HashSet::new()
});
for object in &mut objects {
if external_names.contains(&object.name) {
object.object_type = "EXTERNAL TABLE".to_string();
}
}
Ok(objects)
}
pub async fn table_ddl(pool: &Pool, schema: &str, table: &str) -> Result<String, String> {
let client = db::postgres::checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let row = client
.query_opt(CLOUD_BERRY_TABLE_DDL_SQL, &[&schema, &table])
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| format!("Cloudberry table not found: {schema}.{table}"))?;
let ddl = row.try_get::<_, Option<String>>(0).map_err(|error| error.to_string())?.unwrap_or_default();
normalize_ddl(ddl)
}
pub async fn table_modifiers(pool: &Pool, schema: &str, table: &str) -> Result<TableModifiers, String> {
let client = db::postgres::checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let row = client
.query_opt(CLOUD_BERRY_TABLE_MODIFIERS_SQL, &[&schema, &table])
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| format!("Cloudberry table not found: {schema}.{table}"))?;
let access_method = non_empty(row.try_get::<_, String>(0).map_err(|error| error.to_string())?)
.filter(|method| !method.eq_ignore_ascii_case("heap"));
let reloptions = split_catalog_lines(row.try_get::<_, String>(1).map_err(|error| error.to_string())?);
let policy_type = row.try_get::<_, String>(2).map_err(|error| error.to_string())?;
let distribution_columns = split_catalog_lines(row.try_get::<_, String>(3).map_err(|error| error.to_string())?);
let distribution = match policy_type.as_str() {
"r" => Some(DistributionPolicy::Replicated),
"p" if distribution_columns.is_empty() => Some(DistributionPolicy::Random),
"p" => Some(DistributionPolicy::Hash(distribution_columns)),
_ => None,
};
let is_external = row.try_get::<_, bool>(4).map_err(|error| error.to_string())?;
let external = if is_external {
let server = row.try_get::<_, String>(5).map_err(|error| error.to_string())?;
if server.trim().is_empty() {
return Err(format!("Cloudberry external table has no foreign server: {schema}.{table}"));
}
Some(ExternalTableDefinition {
server,
options: row.try_get::<_, Option<Vec<String>>>(6).map_err(|error| error.to_string())?.unwrap_or_default(),
})
} else {
None
};
Ok(TableModifiers { access_method, reloptions, distribution, external })
}
pub fn append_table_modifiers(ddl: &str, modifiers: &TableModifiers) -> Result<String, String> {
if let Some(external) = modifiers.external.as_ref() {
return render_external_table_ddl(ddl, external);
}
let clauses = render_table_modifier_clauses(modifiers);
if clauses.is_empty() {
return Ok(ddl.to_string());
}
let insertion = ddl
.find(";\n")
.or_else(|| ddl.find(';'))
.ok_or_else(|| "Cloudberry fallback DDL has no CREATE TABLE terminator".to_string())?;
let mut output = String::with_capacity(ddl.len() + clauses.len() + 2);
output.push_str(&ddl[..insertion]);
output.push('\n');
output.push_str(&clauses);
output.push_str(&ddl[insertion..]);
Ok(output)
}
fn render_external_table_ddl(ddl: &str, external: &ExternalTableDefinition) -> Result<String, String> {
let create_table = "CREATE TABLE ";
if !ddl.starts_with(create_table) {
return Err("Cloudberry external-table fallback expected CREATE TABLE DDL".to_string());
}
let insertion = ddl
.find(";\n")
.or_else(|| ddl.find(';'))
.ok_or_else(|| "Cloudberry fallback DDL has no CREATE TABLE terminator".to_string())?;
let mut output = String::with_capacity(ddl.len() + external.options.len() * 24 + 48);
output.push_str("CREATE FOREIGN TABLE ");
output.push_str(&ddl[create_table.len()..insertion]);
output.push_str("\nSERVER ");
output.push_str(&db::postgres::pg_quote_ident(&external.server));
if !external.options.is_empty() {
// Cloudberry 2.x stores external tables as foreign tables. Reusing the
// server options preserves URI, format and execution-location details.
output.push_str("\nOPTIONS (\n ");
output.push_str(
&external
.options
.iter()
.map(|option| render_foreign_table_option(option))
.collect::<Result<Vec<_>, _>>()?
.join(",\n "),
);
output.push_str("\n)");
}
output.push_str(&ddl[insertion..]);
Ok(output)
}
fn render_foreign_table_option(option: &str) -> Result<String, String> {
let (name, value) =
option.split_once('=').ok_or_else(|| format!("Invalid Cloudberry foreign-table option: {option}"))?;
Ok(format!("{} {}", db::postgres::pg_quote_ident(name), quote_sql_string(value)))
}
fn render_table_modifier_clauses(modifiers: &TableModifiers) -> String {
let mut clauses = Vec::new();
if let Some(access_method) = modifiers.access_method.as_deref() {
clauses.push(format!("USING {}", db::postgres::pg_quote_ident(access_method)));
}
if !modifiers.reloptions.is_empty() {
clauses.push(format!("WITH (\n {}\n)", modifiers.reloptions.join(",\n ")));
}
if let Some(distribution) = modifiers.distribution.as_ref() {
clauses.push(match distribution {
DistributionPolicy::Hash(columns) => format!(
"DISTRIBUTED BY ({})",
columns.iter().map(|column| db::postgres::pg_quote_ident(column)).collect::<Vec<_>>().join(", ")
),
DistributionPolicy::Random => "DISTRIBUTED RANDOMLY".to_string(),
DistributionPolicy::Replicated => "DISTRIBUTED REPLICATED".to_string(),
});
}
clauses.join("\n")
}
async fn annotate_external_tables(pool: &Pool, schema: &str, tables: &mut [TableInfo]) {
let names = tables.iter().map(|table| table.name.clone()).collect::<Vec<_>>();
let external_names = external_table_names(pool, schema, &names).await.unwrap_or_else(|error| {
log::debug!("[cloudberry][list_tables:external-table-fallback] error={error}");
HashSet::new()
});
for table in tables {
if external_names.contains(&table.name) {
table.table_type = "EXTERNAL TABLE".to_string();
}
}
}
async fn external_table_names(pool: &Pool, schema: &str, names: &[String]) -> Result<HashSet<String>, String> {
if names.is_empty() {
return Ok(HashSet::new());
}
let client = db::postgres::checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let rows =
client.query(CLOUD_BERRY_EXTERNAL_TABLES_SQL, &[&schema, &names]).await.map_err(|error| error.to_string())?;
Ok(rows.into_iter().filter_map(|row| row.try_get::<_, String>(0).ok()).collect())
}
fn normalize_ddl(ddl: String) -> Result<String, String> {
let ddl = ddl.trim();
if ddl.is_empty() {
return Err("Cloudberry returned an empty table DDL".to_string());
}
if ddl.ends_with(';') {
Ok(format!("{ddl}\n"))
} else {
Ok(format!("{ddl};\n"))
}
}
fn non_empty(value: String) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
fn split_catalog_lines(value: String) -> Vec<String> {
value.lines().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string).collect()
}
fn quote_sql_string(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
#[cfg(test)]
mod tests {
use super::*;
fn modifiers(distribution: Option<DistributionPolicy>) -> TableModifiers {
TableModifiers { access_method: None, reloptions: Vec::new(), distribution, external: None }
}
#[test]
fn appends_hash_distribution_before_table_terminator() {
let ddl = "CREATE TABLE \"public\".\"events\" (\n \"tenant_id\" integer\n);\n";
let rendered = append_table_modifiers(
ddl,
&modifiers(Some(DistributionPolicy::Hash(vec!["tenant_id".to_string(), "event id".to_string()]))),
)
.unwrap();
assert_eq!(
rendered,
"CREATE TABLE \"public\".\"events\" (\n \"tenant_id\" integer\n)\nDISTRIBUTED BY (\"tenant_id\", \"event id\");\n"
);
}
#[test]
fn appends_storage_and_replicated_distribution() {
let ddl = "CREATE TABLE \"public\".\"dimensions\" (\n \"id\" integer\n);\n";
let rendered = append_table_modifiers(
ddl,
&TableModifiers {
access_method: Some("ao_column".to_string()),
reloptions: vec!["compresstype=zstd".to_string(), "compresslevel=3".to_string()],
distribution: Some(DistributionPolicy::Replicated),
external: None,
},
)
.unwrap();
assert!(rendered.contains("USING \"ao_column\""));
assert!(rendered.contains("WITH (\n compresstype=zstd,\n compresslevel=3\n)"));
assert!(rendered.contains("DISTRIBUTED REPLICATED;"));
}
#[test]
fn renders_external_table_from_foreign_options() {
let ddl = "CREATE TABLE \"public\".\"external_events\" (\n \"id\" integer\n);\n";
let rendered = append_table_modifiers(
ddl,
&TableModifiers {
external: Some(ExternalTableDefinition {
server: "gp_exttable_server".to_string(),
options: vec![
"format=csv".to_string(),
"location_uris=file://cdw/tmp/events.csv".to_string(),
"null=".to_string(),
],
}),
..modifiers(None)
},
)
.unwrap();
assert!(rendered.starts_with("CREATE FOREIGN TABLE \"public\".\"external_events\""));
assert!(rendered.contains("SERVER \"gp_exttable_server\""));
assert!(rendered.contains("\"location_uris\" 'file://cdw/tmp/events.csv'"));
assert!(rendered.contains("\"null\" ''"));
}
#[test]
fn ddl_query_uses_cloudberry_native_definition_function() {
assert_eq!(CLOUD_BERRY_TABLE_DDL_SQL, "SELECT pg_get_tabledef($1, $2, true)");
assert!(CLOUD_BERRY_TABLE_MODIFIERS_SQL.contains("gp_distribution_policy"));
assert!(CLOUD_BERRY_TABLE_MODIFIERS_SQL.contains("pg_exttable"));
}
}

View File

@ -1,5 +1,6 @@
pub mod agent_driver;
pub mod clickhouse_driver;
pub mod cloudberry;
pub mod cloudflare_d1;
pub use cloudflare_d1 as cloudflare_d1_driver;
pub mod document_result;

View File

@ -2268,6 +2268,15 @@ async fn list_tables_once(
.await
.map(|tables| filter_table_infos(tables, filter, limit, offset, object_types))
}
PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_cloudberry_config) => {
if object_types.is_some() {
db::cloudberry::list_tables_filtered(p, schema, filter, None, None)
.await
.map(|tables| filter_table_infos(tables, filter, limit, offset, object_types))
} else {
db::cloudberry::list_tables_filtered(p, schema, filter, limit, offset).await
}
}
PoolKind::Postgres(p) => {
if object_types.is_some() {
db::postgres::list_tables_filtered(p, schema, filter, None, None)
@ -4383,6 +4392,9 @@ async fn list_objects_once(
PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => {
db::questdb::list_objects(p, schema).await.map(unpaged_object_list)
}
PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_cloudberry_config) => {
db::cloudberry::list_objects(p, schema).await.map(unpaged_object_list)
}
PoolKind::Postgres(p) => db::postgres::list_objects(p, schema).await.map(unpaged_object_list),
_ => {
drop(connections);
@ -4499,6 +4511,9 @@ async fn list_completion_objects_once(
PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => {
db::questdb::list_objects(p, schema).await.map(filter_completion_objects)
}
PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_cloudberry_config) => {
db::cloudberry::list_objects(p, schema).await.map(filter_completion_objects)
}
PoolKind::Postgres(p) => db::postgres::list_objects(p, schema).await.map(filter_completion_objects),
PoolKind::SqlServer(_) => {
drop(connections);
@ -5412,6 +5427,9 @@ pub async fn get_table_ddl_core(
Err(_) => pg_ddl(p, schema, table).await,
}
}
PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_cloudberry_config) => {
cloudberry_ddl(p, schema, table).await
}
PoolKind::Postgres(p) => pg_ddl(p, schema, table).await,
PoolKind::Sqlite(p) => sqlite_ddl(p, schema, table).await,
PoolKind::Rqlite(client) => db::rqlite_driver::table_ddl(client, table).await,
@ -5429,6 +5447,10 @@ fn is_opengauss_family_config(config: &ConnectionConfig) -> bool {
|| matches!(config.driver_profile.as_deref(), Some("opengauss" | "gaussdb"))
}
fn is_cloudberry_config(config: &ConnectionConfig) -> bool {
matches!(config.driver_profile.as_deref(), Some("cloudberry"))
}
fn is_default_oracle_agent_config(config: &ConnectionConfig) -> bool {
// Only the default go-oracle agent handles filtered/paged metadata; legacy profiles keep Rust fallback paging.
matches!(config.db_type, DatabaseType::Oracle)
@ -6974,6 +6996,27 @@ pub async fn pg_ddl(pool: &deadpool_postgres::Pool, schema: &str, table: &str) -
))
}
pub async fn cloudberry_ddl(pool: &deadpool_postgres::Pool, schema: &str, table: &str) -> Result<String, String> {
match db::cloudberry::table_ddl(pool, schema, table).await {
Ok(ddl) => Ok(ddl),
Err(native_error) => {
let base_ddl = pg_ddl(pool, schema, table).await.map_err(|fallback_error| {
format!(
"Cloudberry pg_get_tabledef failed: {native_error}; PostgreSQL DDL fallback failed: {fallback_error}"
)
})?;
let modifiers = db::cloudberry::table_modifiers(pool, schema, table).await.map_err(|fallback_error| {
format!("Cloudberry pg_get_tabledef failed: {native_error}; modifier fallback failed: {fallback_error}")
})?;
db::cloudberry::append_table_modifiers(&base_ddl, &modifiers).map_err(|fallback_error| {
format!(
"Cloudberry pg_get_tabledef failed: {native_error}; DDL rendering fallback failed: {fallback_error}"
)
})
}
}
}
pub fn render_postgres_table_ddl(
schema: &str,
table: &str,

View File

@ -47,6 +47,9 @@ pub(in crate::schema) async fn list_tables(
db::mysql::list_tables(p, db).await
}
PoolKind::Postgres(p) if config.is_some_and(is_questdb_config) => db::questdb::list_tables(p, schema).await,
PoolKind::Postgres(p) if config.is_some_and(is_cloudberry_config) => {
db::cloudberry::list_tables_filtered(p, schema, None, None, None).await
}
PoolKind::Postgres(p) => db::postgres::list_tables(p, schema).await,
PoolKind::Sqlite(p) => db::sqlite::list_tables(p, schema).await,
PoolKind::Rqlite(client) => db::rqlite_driver::list_tables(client, schema).await,
@ -86,6 +89,9 @@ pub(in crate::schema) async fn list_objects(
PoolKind::Postgres(p) if config.is_some_and(is_questdb_config) => {
db::questdb::list_objects(p, schema).await.map(Some)
}
PoolKind::Postgres(p) if config.is_some_and(is_cloudberry_config) => {
db::cloudberry::list_objects(p, schema).await.map(Some)
}
PoolKind::Postgres(p) => db::postgres::list_objects(p, schema).await.map(Some),
_ => Ok(None),
}
@ -107,6 +113,9 @@ pub(in crate::schema) async fn list_completion_objects(
PoolKind::Postgres(p) if config.is_some_and(is_questdb_config) => {
db::questdb::list_objects(p, schema).await.map(Some)
}
PoolKind::Postgres(p) if config.is_some_and(is_cloudberry_config) => {
db::cloudberry::list_objects(p, schema).await.map(Some)
}
PoolKind::Postgres(p) => db::postgres::list_objects(p, schema).await.map(Some),
_ => Ok(None),
}
@ -237,6 +246,9 @@ pub(in crate::schema) async fn table_ddl(
Err(_) => super::super::pg_ddl(p, schema, table).await,
}
}
PoolKind::Postgres(p) if config.is_some_and(is_cloudberry_config) => {
super::super::cloudberry_ddl(p, schema, table).await
}
PoolKind::Postgres(p) => super::super::pg_ddl(p, schema, table).await,
PoolKind::Sqlite(p) => super::super::sqlite_ddl(p, schema, table).await,
PoolKind::Rqlite(client) => db::rqlite_driver::table_ddl(client, table).await,
@ -316,6 +328,10 @@ fn is_opengauss_family_config(config: &ConnectionConfig) -> bool {
|| matches!(config.driver_profile.as_deref(), Some("opengauss" | "gaussdb"))
}
fn is_cloudberry_config(config: &ConnectionConfig) -> bool {
matches!(config.driver_profile.as_deref(), Some("cloudberry"))
}
fn is_doris_family_config(config: &ConnectionConfig) -> bool {
matches!(config.db_type, DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::ManticoreSearch)
|| matches!(config.driver_profile.as_deref(), Some("doris" | "selectdb" | "starrocks" | "manticoresearch"))

View File

@ -0,0 +1,90 @@
use std::time::Duration;
use dbx_core::{db, schema};
#[tokio::test]
#[ignore = "requires DBX_TEST_CLOUDBERRY_URL pointing at a writable Apache Cloudberry database"]
async fn cloudberry_metadata_and_ddl_round_trip() {
let url = std::env::var("DBX_TEST_CLOUDBERRY_URL").expect("DBX_TEST_CLOUDBERRY_URL");
let pool = db::postgres::connect(&url, Duration::from_secs(10)).await.expect("connect Cloudberry");
let suffix = uuid::Uuid::new_v4().simple().to_string();
let source_schema = format!("dbx_cb_source_{suffix}");
let target_schema = format!("dbx_cb_target_{suffix}");
let source_ident = quote_ident(&source_schema);
let target_ident = quote_ident(&target_schema);
db::postgres::execute_batch(
&pool,
&[
format!("CREATE SCHEMA {source_ident}"),
format!("CREATE SCHEMA {target_ident}"),
format!(
"CREATE TABLE {source_ident}.hash_events (tenant_id integer, payload text) \
DISTRIBUTED BY (tenant_id)"
),
format!(
"CREATE TABLE {source_ident}.random_events (id integer, payload text) \
DISTRIBUTED RANDOMLY"
),
format!(
"CREATE TABLE {source_ident}.replicated_dimensions (id integer, name text) \
DISTRIBUTED REPLICATED"
),
format!(
"CREATE TABLE {source_ident}.column_metrics (metric text, value numeric(18,4)) \
USING ao_column WITH (compresstype=zstd, compresslevel=3) DISTRIBUTED BY (metric)"
),
format!(
"CREATE TABLE {source_ident}.partitioned_events \
(event_date date, tenant_id integer) PARTITION BY RANGE (event_date) \
DISTRIBUTED BY (tenant_id)"
),
format!(
"CREATE READABLE EXTERNAL TABLE {source_ident}.external_events (id integer, payload text) \
LOCATION ('file://cdw/tmp/dbx-cloudberry-live-test.csv') \
FORMAT 'CSV' (DELIMITER ',')"
),
],
)
.await
.expect("create Cloudberry fixtures");
let exercise = async {
let tables = db::cloudberry::list_tables_filtered(&pool, &source_schema, None, None, None).await?;
let external = tables.iter().find(|table| table.name == "external_events").ok_or("missing external table")?;
assert_eq!(external.table_type, "EXTERNAL TABLE");
let cases = [
("hash_events", "DISTRIBUTED BY (\"tenant_id\")"),
("random_events", "DISTRIBUTED RANDOMLY"),
("replicated_dimensions", "DISTRIBUTED REPLICATED"),
("column_metrics", "USING \"ao_column\""),
("partitioned_events", "PARTITION BY RANGE (event_date)"),
("external_events", "CREATE FOREIGN TABLE"),
];
for (table, expected) in cases {
let ddl = schema::cloudberry_ddl(&pool, &source_schema, table).await?;
assert!(ddl.contains(expected), "{table} DDL did not contain {expected}: {ddl}");
if table == "external_events" {
assert!(ddl.contains("SERVER \"gp_exttable_server\""), "external DDL: {ddl}");
assert!(ddl.contains("\"location_uris\" 'file://cdw/tmp/dbx-cloudberry-live-test.csv'"));
}
let target_ddl = ddl.replace(&source_ident, &target_ident);
db::postgres::execute_query(&pool, &target_ddl).await?;
}
Ok::<_, String>(())
}
.await;
db::postgres::execute_batch(
&pool,
&[format!("DROP SCHEMA {target_ident} CASCADE"), format!("DROP SCHEMA {source_ident} CASCADE")],
)
.await
.expect("drop Cloudberry fixtures");
exercise.expect("validate Cloudberry metadata and DDL");
}
fn quote_ident(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\""))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB