diff --git a/apps/desktop/public/icons/database/opentenbase.svg b/apps/desktop/public/icons/database/opentenbase.svg new file mode 100644 index 0000000000..e733bfec2c --- /dev/null +++ b/apps/desktop/public/icons/database/opentenbase.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 1ef1aecdf2..076fd62f9b 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -1031,6 +1031,14 @@ const driverProfiles: Record< icon: "cloudberry", urlParams: "", }, + opentenbase: { + type: "postgres", + port: 11000, + user: "opentenbase", + label: "OpenTenBase", + icon: "opentenbase", + 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" }, @@ -2720,6 +2728,7 @@ const iconTypeMap: Record = { mysql: "mysql", postgres: "postgres", cloudberry: "cloudberry", + opentenbase: "opentenbase", sqlite: "sqlite", rqlite: "rqlite", turso: "turso", @@ -2813,6 +2822,7 @@ const iconTypeMap: Record = { const dbOptions: DbOption[] = [ { value: "postgres", label: "PostgreSQL" }, { value: "cloudberry", label: "Apache Cloudberry" }, + { value: "opentenbase", label: "OpenTenBase" }, { value: "mysql", label: "MySQL" }, { value: "mongodb", label: "MongoDB" }, { value: "redis", label: "Redis" }, @@ -2920,7 +2930,7 @@ const dbCategoryDefinitions: Array<{ { key: "domestic", titleKey: "connection.databaseCategoryDomestic", - optionValues: ["dm", "opengauss", "gaussdb", "kwdb", "tidb", "oceanbase", "goldendb", "tdsql", "polardb", "greatsql", "gbase", "kingbase", "highgo", "uxdb", "yashandb", "vastbase", "sundb", "oscar", "xugu"], + optionValues: ["dm", "opengauss", "opentenbase", "gaussdb", "kwdb", "tidb", "oceanbase", "goldendb", "tdsql", "polardb", "greatsql", "gbase", "kingbase", "highgo", "uxdb", "yashandb", "vastbase", "sundb", "oscar", "xugu"], }, { key: "lightweight", diff --git a/apps/desktop/src/components/icons/DatabaseIcon.vue b/apps/desktop/src/components/icons/DatabaseIcon.vue index 150b45a266..4da59dd00a 100644 --- a/apps/desktop/src/components/icons/DatabaseIcon.vue +++ b/apps/desktop/src/components/icons/DatabaseIcon.vue @@ -14,6 +14,7 @@ const assetIcons: Record = { postgres: "postgres", postgresql: "postgres", cloudberry: "cloudberry", + opentenbase: "opentenbase", sqlite: "sqlite", rqlite: "rqlite.png", turso: "turso.png", diff --git a/apps/desktop/src/lib/__tests__/connection/connectionUrl.opentenbase.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionUrl.opentenbase.spec.ts new file mode 100644 index 0000000000..59c5778e27 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/connectionUrl.opentenbase.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { connectionProfileForScheme, parseConnectionUrl } from "@/lib/connection/connectionUrl"; + +describe("OpenTenBase connection URLs", () => { + it("parses the OpenTenBase alias as a PostgreSQL-compatible profile", () => { + const parsed = parseConnectionUrl("opentenbase://opentenbase:secret@cn.example.com/postgres"); + + expect(parsed).toMatchObject({ + dbType: "postgres", + driverProfile: "opentenbase", + driverLabel: "OpenTenBase", + host: "cn.example.com", + port: 11000, + username: "opentenbase", + password: "secret", + database: "postgres", + }); + }); + + it("keeps the OpenTenBase profile for standard PostgreSQL URLs", () => { + const parsed = parseConnectionUrl("postgresql://cn.example.com:15432/postgres", "opentenbase"); + + expect(parsed.dbType).toBe("postgres"); + expect(parsed.driverProfile).toBe("opentenbase"); + expect(parsed.driverLabel).toBe("OpenTenBase"); + expect(parsed.port).toBe(15432); + }); + + it("exposes OpenTenBase to connection deep links", () => { + expect(connectionProfileForScheme("opentenbase")).toEqual({ + type: "postgres", + profile: "opentenbase", + label: "OpenTenBase", + defaultPort: 11000, + }); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/imports/dbeaverImport.spec.ts b/apps/desktop/src/lib/__tests__/imports/dbeaverImport.spec.ts index 256783dac2..f7ba726477 100644 --- a/apps/desktop/src/lib/__tests__/imports/dbeaverImport.spec.ts +++ b/apps/desktop/src/lib/__tests__/imports/dbeaverImport.spec.ts @@ -140,3 +140,36 @@ describe("DBeaver Cloudberry import", () => { }); }); }); + +describe("DBeaver OpenTenBase import", () => { + it("preserves OpenTenBase while reusing the PostgreSQL backend", async () => { + const connections = await parseDbeaverConnections( + payload({ + connections: { + opentenbase: { + id: "opentenbase", + name: "distributed-postgres", + provider: "opentenbase", + driver: "opentenbase-postgresql", + configuration: { + host: "cn.example.com", + port: 11000, + database: "postgres", + user: "opentenbase", + }, + }, + }, + }), + ); + + expect(connections[0]).toMatchObject({ + db_type: "postgres", + driver_profile: "opentenbase", + driver_label: "OpenTenBase", + host: "cn.example.com", + port: 11000, + database: "postgres", + username: "opentenbase", + }); + }); +}); diff --git a/apps/desktop/src/lib/connection/connectionUrl.ts b/apps/desktop/src/lib/connection/connectionUrl.ts index dd08222ff4..208516905f 100644 --- a/apps/desktop/src/lib/connection/connectionUrl.ts +++ b/apps/desktop/src/lib/connection/connectionUrl.ts @@ -34,6 +34,7 @@ const SCHEME_PROFILES: Record = { 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 }, + opentenbase: { type: "postgres", profile: "opentenbase", label: "OpenTenBase", defaultPort: 11000 }, 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 }, @@ -325,9 +326,10 @@ export function connectionProfileForScheme(scheme: string, preferredProfile?: st if ((normalizedScheme === "http" || normalizedScheme === "https") && normalizedPreferredProfile) { return HTTP_SELECTED_PROFILES[normalizedPreferredProfile]; } - // Cloudberry uses PostgreSQL URLs, so keep the selected product profile when parsing a pasted URL. - if ((normalizedScheme === "postgres" || normalizedScheme === "postgresql") && normalizedPreferredProfile === "cloudberry") { - return SCHEME_PROFILES.cloudberry; + // PostgreSQL-compatible products use standard PostgreSQL URLs, so keep the + // selected product profile when parsing a pasted URL. + if ((normalizedScheme === "postgres" || normalizedScheme === "postgresql") && (normalizedPreferredProfile === "cloudberry" || normalizedPreferredProfile === "opentenbase")) { + return SCHEME_PROFILES[normalizedPreferredProfile]; } return SCHEME_PROFILES[normalizedScheme]; } diff --git a/apps/desktop/src/lib/imports/dbeaverImport.ts b/apps/desktop/src/lib/imports/dbeaverImport.ts index a5ca39ce7d..1bb5023e29 100644 --- a/apps/desktop/src/lib/imports/dbeaverImport.ts +++ b/apps/desktop/src/lib/imports/dbeaverImport.ts @@ -42,6 +42,7 @@ const profileMap: Record = { 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" }, + opentenbase: { dbType: "postgres", profile: "opentenbase", label: "OpenTenBase", port: 11000, user: "opentenbase" }, 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" }, @@ -82,6 +83,7 @@ function getNumber(value: unknown) { function inferProfile(entry: DbeaverConnectionEntry): ConnectionProfile { if (/^jdbcx:/i.test(getString(entry.configuration?.url))) return profileMap.jdbcx; + if (normalizeKey(entry.provider) === "opentenbase") return profileMap.opentenbase; const driverProfile = profileMap[normalizeKey(entry.driver)]; if (driverProfile) return driverProfile; const candidates = [entry.provider, entry.driver, entry.configuration?.url, entry.name].map(normalizeKey).join(" "); diff --git a/crates/dbx-core/src/db/mod.rs b/crates/dbx-core/src/db/mod.rs index a500b55ad4..020e6eb751 100644 --- a/crates/dbx-core/src/db/mod.rs +++ b/crates/dbx-core/src/db/mod.rs @@ -25,6 +25,7 @@ pub mod mysql; pub mod mysql_compatible; pub mod ob_oracle; pub mod oceanbase_mysql; +pub mod opentenbase; pub mod postgres; pub mod proxy_tunnel; pub mod questdb; diff --git a/crates/dbx-core/src/db/opentenbase.rs b/crates/dbx-core/src/db/opentenbase.rs new file mode 100644 index 0000000000..573cfeb87e --- /dev/null +++ b/crates/dbx-core/src/db/opentenbase.rs @@ -0,0 +1,151 @@ +use deadpool_postgres::Pool; + +use crate::db; +use crate::models::connection::ConnectionConfig; + +pub const DRIVER_PROFILE: &str = "opentenbase"; + +const TABLE_DISTRIBUTION_SQL: &str = "SELECT c.pclocatortype::text, \ + COALESCE(( \ + SELECT string_agg(a.attname, E'\\n' ORDER BY distributed_column.ordinality) \ + FROM unnest(c.discolnums::smallint[]) WITH ORDINALITY \ + AS distributed_column(attnum, ordinality) \ + JOIN pg_catalog.pg_attribute a \ + ON a.attrelid = c.pcrelid AND a.attnum = distributed_column.attnum \ + WHERE NOT a.attisdropped \ + ), '')::text \ + FROM pg_catalog.pgxc_class c \ + JOIN pg_catalog.pg_class relation ON relation.oid = c.pcrelid \ + JOIN pg_catalog.pg_namespace namespace ON namespace.oid = relation.relnamespace \ + WHERE namespace.nspname = $1 AND relation.relname = $2 \ + LIMIT 1"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DistributionPolicy { + Hash(Vec), + Modulo(Vec), + Shard(Vec), + Replication, + RoundRobin, +} + +pub fn is_config(config: &ConnectionConfig) -> bool { + matches!(config.driver_profile.as_deref(), Some(DRIVER_PROFILE)) +} + +pub async fn table_distribution(pool: &Pool, schema: &str, table: &str) -> Result, String> { + let client = db::postgres::checkout_postgres_client(pool, None, super::connection_timeout()).await?; + let Some(row) = + client.query_opt(TABLE_DISTRIBUTION_SQL, &[&schema, &table]).await.map_err(|error| error.to_string())? + else { + return Ok(None); + }; + let locator_type = row.try_get::<_, String>(0).map_err(|error| error.to_string())?; + let columns = row + .try_get::<_, String>(1) + .map_err(|error| error.to_string())? + .lines() + .map(str::trim) + .filter(|column| !column.is_empty()) + .map(str::to_string) + .collect::>(); + parse_distribution_policy(&locator_type, columns) +} + +pub fn append_distribution_clause(ddl: &str, distribution: &DistributionPolicy) -> Result { + let clause = render_distribution_clause(distribution)?; + let insertion = ddl + .find(";\n") + .or_else(|| ddl.find(';')) + .ok_or_else(|| "OpenTenBase DDL has no CREATE TABLE terminator".to_string())?; + let mut output = String::with_capacity(ddl.len() + clause.len() + 2); + output.push_str(&ddl[..insertion]); + output.push('\n'); + output.push_str(&clause); + output.push_str(&ddl[insertion..]); + Ok(output) +} + +fn parse_distribution_policy(locator_type: &str, columns: Vec) -> Result, String> { + let column_distribution = |kind| { + if columns.is_empty() { + Err(format!("OpenTenBase {kind} distribution has no distribution columns")) + } else { + Ok(columns) + } + }; + match locator_type { + "H" => column_distribution("HASH").map(|columns| Some(DistributionPolicy::Hash(columns))), + "M" => column_distribution("MODULO").map(|columns| Some(DistributionPolicy::Modulo(columns))), + "S" => column_distribution("SHARD").map(|columns| Some(DistributionPolicy::Shard(columns))), + "R" => Ok(Some(DistributionPolicy::Replication)), + "N" => Ok(Some(DistributionPolicy::RoundRobin)), + _ => Ok(None), + } +} + +fn render_distribution_clause(distribution: &DistributionPolicy) -> Result { + let render_columns = |kind: &str, columns: &[String]| { + if columns.is_empty() { + return Err(format!("OpenTenBase {kind} distribution has no distribution columns")); + } + Ok(format!( + "DISTRIBUTE BY {kind} ({})", + columns.iter().map(|column| db::postgres::pg_quote_ident(column)).collect::>().join(", ") + )) + }; + match distribution { + DistributionPolicy::Hash(columns) => render_columns("HASH", columns), + DistributionPolicy::Modulo(columns) => render_columns("MODULO", columns), + DistributionPolicy::Shard(columns) => render_columns("SHARD", columns), + DistributionPolicy::Replication => Ok("DISTRIBUTE BY REPLICATION".to_string()), + DistributionPolicy::RoundRobin => Ok("DISTRIBUTE BY ROUNDROBIN".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_hash_distribution_before_create_table_terminator() { + let ddl = "CREATE TABLE \"public\".\"orders\" (\n \"tenant_id\" integer\n);\n"; + let rendered = append_distribution_clause( + ddl, + &DistributionPolicy::Hash(vec!["tenant_id".to_string(), "order id".to_string()]), + ) + .unwrap(); + + assert_eq!( + rendered, + "CREATE TABLE \"public\".\"orders\" (\n \"tenant_id\" integer\n)\nDISTRIBUTE BY HASH (\"tenant_id\", \"order id\");\n" + ); + } + + #[test] + fn renders_replication_and_round_robin_distribution() { + assert_eq!(render_distribution_clause(&DistributionPolicy::Replication).unwrap(), "DISTRIBUTE BY REPLICATION"); + assert_eq!(render_distribution_clause(&DistributionPolicy::RoundRobin).unwrap(), "DISTRIBUTE BY ROUNDROBIN"); + } + + #[test] + fn maps_current_opentenbase_locator_types() { + assert_eq!( + parse_distribution_policy("S", vec!["tenant_id".to_string()]).unwrap(), + Some(DistributionPolicy::Shard(vec!["tenant_id".to_string()])) + ); + assert_eq!( + parse_distribution_policy("M", vec!["tenant_id".to_string()]).unwrap(), + Some(DistributionPolicy::Modulo(vec!["tenant_id".to_string()])) + ); + assert_eq!(parse_distribution_policy("D", Vec::new()).unwrap(), None); + } + + #[test] + fn distribution_query_matches_opentenbase_catalog_contract() { + assert!(TABLE_DISTRIBUTION_SQL.contains("pg_catalog.pgxc_class")); + assert!(TABLE_DISTRIBUTION_SQL.contains("c.discolnums::smallint[]")); + assert!(TABLE_DISTRIBUTION_SQL.contains("WITH ORDINALITY")); + assert!(TABLE_DISTRIBUTION_SQL.contains("NOT a.attisdropped")); + } +} diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 627d2bddf8..ff7396ef4c 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -2554,14 +2554,29 @@ pub async fn completion_assistant_search( } if candidates.len() < limit && kinds.iter().any(CompletionAssistantObjectKind::is_routine_like) { - let prokinds = postgres_completion_prokinds(&kinds); - let rows = postgres_query_cached( - &client, - postgres_completion_routines_sql(), - &[&routine_schema, &pattern, &prokinds, &((limit - candidates.len()) as i64)], - ) - .await - .map_err(|e| e.to_string())?; + let has_proc_prokind = postgres_proc_has_prokind(&client).await?; + let rows = if has_proc_prokind { + let prokinds = postgres_completion_prokinds(&kinds); + postgres_query_cached( + &client, + postgres_completion_routines_sql(true), + &[&routine_schema, &pattern, &prokinds, &((limit - candidates.len()) as i64)], + ) + .await + .map_err(|e| e.to_string())? + } else if kinds.iter().any(|kind| { + matches!(kind, CompletionAssistantObjectKind::Function | CompletionAssistantObjectKind::Routine) + }) { + postgres_query_cached( + &client, + postgres_completion_routines_sql(false), + &[&routine_schema, &pattern, &((limit - candidates.len()) as i64)], + ) + .await + .map_err(|e| e.to_string())? + } else { + Vec::new() + }; for row in rows { let routine_type: String = pg_row_try_string(&row, 2); candidates.push(CompletionAssistantCandidate { @@ -2665,15 +2680,26 @@ fn postgres_completion_tables_sql() -> &'static str { ORDER BY c.relname LIMIT $4" } -fn postgres_completion_routines_sql() -> &'static str { - "SELECT p.proname, n.nspname, CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END, \ +fn postgres_completion_routines_sql(has_proc_prokind: bool) -> &'static str { + if has_proc_prokind { + return "SELECT p.proname, n.nspname, CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END, \ obj_description(p.oid) AS routine_comment, COALESCE(pg_get_function_result(p.oid), '') AS data_type, \ pg_get_function_identity_arguments(p.oid) AS signature \ FROM pg_catalog.pg_proc p \ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace \ WHERE n.nspname = $1 AND p.prokind::text = ANY($3::text[]) \ AND ($2 = '%%' OR p.proname ILIKE $2 ESCAPE '~') \ - ORDER BY p.proname LIMIT $4" + ORDER BY p.proname LIMIT $4"; + } + + "SELECT p.proname, n.nspname, 'FUNCTION'::text, \ + obj_description(p.oid) AS routine_comment, COALESCE(pg_get_function_result(p.oid), '') AS data_type, \ + pg_get_function_identity_arguments(p.oid) AS signature \ + FROM pg_catalog.pg_proc p \ + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace \ + WHERE n.nspname = $1 \ + AND ($2 = '%%' OR p.proname ILIKE $2 ESCAPE '~') \ + ORDER BY p.proname LIMIT $3" } fn postgres_completion_sequences_sql() -> &'static str { @@ -9456,10 +9482,15 @@ mod tests { assert!(postgres_completion_tables_sql().contains("pg_catalog.pg_table_is_visible(c.oid)")); assert!(postgres_completion_tables_sql().contains("c.relkind::text = ANY($3::text[])")); assert!(postgres_completion_tables_sql().contains("ORDER BY c.relname LIMIT $4")); - assert!(postgres_completion_routines_sql().contains("p.proname ILIKE $2 ESCAPE '~'")); - assert!(postgres_completion_routines_sql().contains("p.prokind::text = ANY($3::text[])")); - assert!(postgres_completion_routines_sql().contains("pg_get_function_identity_arguments(p.oid) AS signature")); - assert!(postgres_completion_routines_sql().contains("ORDER BY p.proname LIMIT $4")); + assert!(postgres_completion_routines_sql(true).contains("p.proname ILIKE $2 ESCAPE '~'")); + assert!(postgres_completion_routines_sql(true).contains("p.prokind::text = ANY($3::text[])")); + assert!( + postgres_completion_routines_sql(true).contains("pg_get_function_identity_arguments(p.oid) AS signature") + ); + assert!(postgres_completion_routines_sql(true).contains("ORDER BY p.proname LIMIT $4")); + assert!(!postgres_completion_routines_sql(false).contains("p.prokind")); + assert!(postgres_completion_routines_sql(false).contains("'FUNCTION'::text")); + assert!(postgres_completion_routines_sql(false).contains("ORDER BY p.proname LIMIT $3")); assert!(postgres_completion_sequences_sql().contains("c.relkind = 'S'")); assert!(postgres_completion_sequences_sql().contains("has_schema_privilege(n.oid, 'USAGE')")); assert!(postgres_completion_sequences_sql().contains("pg_catalog.pg_table_is_visible(c.oid)")); diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index d150046d3a..e2c9344fe7 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -6616,6 +6616,9 @@ async fn get_table_ddl_once( PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_cloudberry_config) => { cloudberry_ddl(p, schema, table).await } + PoolKind::Postgres(p) if db_config.as_ref().is_some_and(db::opentenbase::is_config) => { + opentenbase_ddl(p, schema, table).await + } PoolKind::Postgres(p) if include_postgres_access && db_config.as_ref().is_some_and(is_native_postgres_config) => { @@ -9451,6 +9454,34 @@ pub async fn cloudberry_ddl(pool: &deadpool_postgres::Pool, schema: &str, table: } } +pub async fn opentenbase_ddl(pool: &deadpool_postgres::Pool, schema: &str, table: &str) -> Result { + let ddl = pg_ddl(pool, schema, table).await?; + match db::opentenbase::table_distribution(pool, schema, table).await { + Ok(Some(distribution)) => match db::opentenbase::append_distribution_clause(&ddl, &distribution) { + Ok(ddl) => Ok(ddl), + Err(error) => { + log::warn!( + "[schema][opentenbase:table-ddl-distribution-render-fallback] schema={} table={} error={}", + schema, + table, + error + ); + Ok(ddl) + } + }, + Ok(None) => Ok(ddl), + Err(error) => { + log::warn!( + "[schema][opentenbase:table-ddl-distribution-query-fallback] schema={} table={} error={}", + schema, + table, + error + ); + Ok(ddl) + } + } +} + pub fn render_postgres_table_ddl( schema: &str, table: &str, diff --git a/crates/dbx-core/src/schema/providers/native.rs b/crates/dbx-core/src/schema/providers/native.rs index 71bf5b55d3..dd69680938 100644 --- a/crates/dbx-core/src/schema/providers/native.rs +++ b/crates/dbx-core/src/schema/providers/native.rs @@ -260,6 +260,9 @@ pub(in crate::schema) async fn table_ddl( PoolKind::Postgres(p) if config.is_some_and(is_cloudberry_config) => { super::super::cloudberry_ddl(p, schema, table).await } + PoolKind::Postgres(p) if config.is_some_and(db::opentenbase::is_config) => { + super::super::opentenbase_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, diff --git a/crates/dbx-core/tests/live_opentenbase.rs b/crates/dbx-core/tests/live_opentenbase.rs new file mode 100644 index 0000000000..7da559f165 --- /dev/null +++ b/crates/dbx-core/tests/live_opentenbase.rs @@ -0,0 +1,71 @@ +use std::time::Duration; + +use dbx_core::{db, schema}; + +#[tokio::test] +#[ignore = "requires DBX_TEST_OPENTENBASE_URL pointing at a writable OpenTenBase Coordinator"] +async fn opentenbase_metadata_and_ddl_round_trip() { + let url = std::env::var("DBX_TEST_OPENTENBASE_URL").expect("DBX_TEST_OPENTENBASE_URL"); + let pool = db::postgres::connect(&url, Duration::from_secs(10)).await.expect("connect OpenTenBase Coordinator"); + let suffix = uuid::Uuid::new_v4().simple().to_string(); + let source_schema = format!("dbx_otb_source_{suffix}"); + let target_schema = format!("dbx_otb_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}.shard_events \ + (tenant_id integer, event_id bigint, payload text) \ + DISTRIBUTE BY SHARD (tenant_id)" + ), + format!( + "CREATE TABLE {source_ident}.replicated_dimensions \ + (id integer, name text) DISTRIBUTE BY REPLICATION" + ), + ], + ) + .await + .expect("create OpenTenBase fixtures"); + + let exercise = async { + let tables = db::postgres::list_tables(&pool, &source_schema).await?; + if !tables.iter().any(|table| table.name == "shard_events") { + return Err("shard_events was not listed".to_string()); + } + if !tables.iter().any(|table| table.name == "replicated_dimensions") { + return Err("replicated_dimensions was not listed".to_string()); + } + + let cases = [ + ("shard_events", "DISTRIBUTE BY SHARD (\"tenant_id\")"), + ("replicated_dimensions", "DISTRIBUTE BY REPLICATION"), + ]; + for (table, expected) in cases { + let ddl = schema::opentenbase_ddl(&pool, &source_schema, table).await?; + if !ddl.contains(expected) { + return Err(format!("{table} DDL did not contain {expected}: {ddl}")); + } + 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 OpenTenBase fixtures"); + exercise.expect("validate OpenTenBase metadata and DDL"); +} + +fn quote_ident(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} diff --git a/docs/app/[lang]/databases/page.tsx b/docs/app/[lang]/databases/page.tsx index e77fedb3d6..85bfafef16 100644 --- a/docs/app/[lang]/databases/page.tsx +++ b/docs/app/[lang]/databases/page.tsx @@ -12,6 +12,7 @@ const databaseSupport = [ { name: "MySQL", icon: "/icons/database/mysql.svg", tone: "#4479a1" }, { name: "PostgreSQL", icon: "/icons/database/postgres.svg", tone: "#4169e1" }, { name: "Cloudberry", icon: "/icons/database/cloudberry.svg", tone: "#ff5900" }, + { name: "OpenTenBase", icon: "/icons/database/opentenbase.svg", tone: "#006eff" }, { name: "SQLite", icon: "/icons/database/sqlite.svg", tone: "#5aa6d6" }, { name: "Redis", icon: "/icons/database/redis.svg", tone: "#ff4438" }, { name: "DuckDB", icon: "/icons/database/duckdb.svg", tone: "#fff000" }, diff --git a/docs/app/[lang]/page.tsx b/docs/app/[lang]/page.tsx index 95e3309aaf..515ccbe41b 100644 --- a/docs/app/[lang]/page.tsx +++ b/docs/app/[lang]/page.tsx @@ -50,6 +50,7 @@ const databaseSupport = [ { name: "MySQL", icon: "/icons/database/mysql.svg", tone: "#4479a1" }, { name: "PostgreSQL", icon: "/icons/database/postgres.svg", tone: "#4169e1" }, { name: "Cloudberry", icon: "/icons/database/cloudberry.svg", tone: "#ff5900" }, + { name: "OpenTenBase", icon: "/icons/database/opentenbase.svg", tone: "#006eff" }, { name: "SQLite", icon: "/icons/database/sqlite.svg", tone: "#5aa6d6" }, { name: "Redis", icon: "/icons/database/redis.svg", tone: "#ff4438" }, { name: "DuckDB", icon: "/icons/database/duckdb.svg", tone: "#fff000" }, diff --git a/docs/public/icons/database/opentenbase.svg b/docs/public/icons/database/opentenbase.svg new file mode 100644 index 0000000000..e733bfec2c --- /dev/null +++ b/docs/public/icons/database/opentenbase.svg @@ -0,0 +1,9 @@ + + + + + + + + +