feat(pg,kingbase): partition table operations and detail queries

This commit is contained in:
amwps290 authored and GitHub committed 2026-09-21 15:36:49 +08:00
1 parent 882bd130ab
commit 1000aa07b6
62 files changed
+6180 -133

No files matched your search

@@ -26,6 +26,8 @@
"list_constraints",
"list_partitions",
"list_subpartitions",
"get_table_partition_status",
"get_table_partitioning",
"execute_query",
"execute_query_page",
"fetch_query_page",
+118
View File
@@ -3948,6 +3948,93 @@ pub async fn execute_statements(
result
}
/// Execute a batch, optionally on a single transaction.
///
/// `use_transaction` is opt-in and only the structure editor sets it, for
/// batches that change a partition hierarchy: a mid-batch failure there would
/// otherwise leave a half-created hierarchy behind. The shared transaction
/// kernel rejects backends whose DDL cannot roll back, and statements that
/// cannot run inside a transaction block (`... CONCURRENTLY`) are refused up
/// front rather than half-applied.
pub async fn execute_statements_with_transaction_option(
state: &AppState,
connection_id: &str,
database: &str,
statements: &[String],
schema: Option<&str>,
use_transaction: bool,
timeout_secs: Option<u64>,
) -> Result<db::QueryResult, String> {
if use_transaction && statements.len() > 1 {
if batch_has_concurrently_statement(statements) {
return Err(
"use_transaction cannot wrap CONCURRENTLY statements: they cannot run inside a transaction block. Run the batch without use_transaction."
.to_string(),
);
}
let db_type = connection_database_type(state, connection_id).await;
if batch_transaction_ddl_is_unrollbackable(db_type, statements) {
return Err(
"use_transaction cannot be used with a batch whose DDL cannot be rolled back: DDL statements implicitly commit and cannot be undone. Run the batch without use_transaction (auto-commit, one result per statement) or split the DDL and DML into separate calls."
.to_string(),
);
}
let result = execute_statements_in_transaction_typed(
state,
connection_id,
database,
statements,
schema,
None,
timeout_secs,
)
.await
.map_err(|error| error.into_legacy_string())?;
let invalidate = statements.iter().any(|sql| crate::object_cache::sql_may_change_object_metadata(sql, db_type));
if invalidate {
crate::object_cache::invalidate_connection_object_cache(&state.storage, connection_id).await;
}
return Ok(result);
}
execute_statements(state, connection_id, database, statements, schema, timeout_secs).await
}
/// Whether a batch contains a statement PostgreSQL-family servers refuse to run
/// inside a transaction block (`CREATE/DROP INDEX CONCURRENTLY`,
/// `ALTER TABLE ... DETACH PARTITION CONCURRENTLY`, ...).
fn batch_has_concurrently_statement(statements: &[String]) -> bool {
statements.iter().any(|statement| {
let upper = statement.to_ascii_uppercase();
// Real CONCURRENTLY statements always start with one of these verbs.
// Requiring the verb plus a standalone keyword keeps the word inside
// string literals, comments or plain identifiers (e.g. a table named
// `concurrently`) from silently demoting the batch to auto-commit; the
// direction stays fail-safe because no CONCURRENTLY statement form
// starts with another verb.
let trimmed = upper.trim_start();
let starts_with_ddl_verb =
["CREATE ", "DROP ", "REINDEX", "REFRESH ", "ALTER "].iter().any(|verb| trimmed.starts_with(verb));
starts_with_ddl_verb && contains_standalone_concurrently_keyword(&upper)
})
}
fn contains_standalone_concurrently_keyword(upper: &str) -> bool {
let keyword = "CONCURRENTLY";
let mut search_from = 0;
while let Some(pos) = upper[search_from..].find(keyword) {
let pos = search_from + pos;
let boundary =
|ch: Option<char>| ch.map(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '"').unwrap_or(false);
let before = boundary(upper[..pos].chars().next_back());
let after = boundary(upper[pos + keyword.len()..].chars().next());
if !before && !after {
return true;
}
search_from = pos + 1;
}
false
}
async fn execute_statements_inner(
state: &AppState,
connection_id: &str,
@@ -8171,6 +8258,37 @@ for line in sys.stdin:
assert!(!batch_transaction_ddl_is_unrollbackable(None, &ddl));
}
#[test]
fn batch_has_concurrently_statement_detects_transaction_block_escapees() {
assert!(batch_has_concurrently_statement(&["CREATE INDEX CONCURRENTLY idx ON t (id)".to_string(),]));
assert!(batch_has_concurrently_statement(&[
"ALTER TABLE parent DETACH PARTITION child CONCURRENTLY".to_string(),
]));
// Case-insensitive, and a single CONCURRENTLY anywhere in the batch is enough.
assert!(batch_has_concurrently_statement(&[
"ALTER TABLE t ADD COLUMN c int".to_string(),
"drop index concurrently idx".to_string(),
]));
assert!(!batch_has_concurrently_statement(&[
"CREATE TABLE child PARTITION OF parent FOR VALUES FROM (0) TO (1)".to_string(),
]));
// The word inside literals or plain identifiers must not demote a batch
// that would otherwise run in one transaction.
assert!(!batch_has_concurrently_statement(&[
"INSERT INTO notes (body) VALUES ('run CREATE INDEX CONCURRENTLY later')".to_string(),
]));
assert!(!batch_has_concurrently_statement(&[
"SELECT id FROM concurrently WHERE label = 'drop index concurrently idx'".to_string(),
]));
assert!(!batch_has_concurrently_statement(&[
"-- refresh materialized view concurrently next".to_string(),
"SELECT 1".to_string(),
]));
// REINDEX/REFRESH forms still detect.
assert!(batch_has_concurrently_statement(&["REINDEX TABLE CONCURRENTLY t".to_string()]));
assert!(batch_has_concurrently_statement(&["REFRESH MATERIALIZED VIEW CONCURRENTLY mv".to_string()]));
}
#[tokio::test]
async fn connection_pool_is_sqlserver_agent_detects_agent_and_native_pools() {
let dir = std::env::temp_dir().join(format!("dbx-query-sqlserver-agent-flag-{}", uuid::Uuid::new_v4()));
+101 -4
View File
@@ -5250,6 +5250,27 @@ for line in sys.stdin:
assert!(!super::is_agent_completion_assistant_unsupported("Agent RPC error (-1): Connection failed"));
}
#[test]
fn detects_unsupported_agent_partition_method_errors() {
assert!(super::is_agent_partition_method_unsupported(
"Agent RPC error (-1): unknown method: get_table_partitioning",
"get_table_partitioning",
));
assert!(super::is_agent_partition_method_unsupported(
"Agent RPC error (-32601): Method not found: get_table_partition_status",
"get_table_partition_status",
));
// A different method in the same error must not match.
assert!(!super::is_agent_partition_method_unsupported(
"Agent RPC error (-1): unknown method: get_table_partitioning",
"get_table_partition_status",
));
assert!(!super::is_agent_partition_method_unsupported(
"Agent RPC error (-1): Connection failed",
"get_table_partitioning",
));
}
#[test]
fn clickhouse_metadata_prefers_schema_qualifier() {
assert_eq!(clickhouse_metadata_database("", "testdb"), "testdb");
@@ -6141,6 +6162,15 @@ fn is_agent_completion_assistant_unsupported(error: &str) -> bool {
|| error.contains("completion assistant search is not supported")
}
/// True when an agent built against an older protocol does not implement a
/// table-partition RPC. A mixed-version deployment (new core, old agent) must
/// hide the Partitions tab rather than fail every probe, so callers degrade to
/// the default value instead of surfacing the error.
fn is_agent_partition_method_unsupported(error: &str, method: &str) -> bool {
let error = error.to_ascii_lowercase();
error.contains(method) && (error.contains("unknown method") || error.contains("method not found"))
}
async fn completion_assistant_fallback_core(
state: &AppState,
request: &db::CompletionAssistantRequest,
@@ -7809,10 +7839,31 @@ pub async fn table_partition_status_core(
match pool_handle.as_ref() {
Some(PoolKind::Postgres(pool)) => {
let info = db::postgres::get_table_partition_info(pool, schema, table).await?;
Ok(TablePartitionStatus {
is_partitioned_parent: info.key.is_some() && !info.is_partition,
is_partition: info.is_partition,
})
Ok(TablePartitionStatus { is_partitioned_parent: info.key.is_some(), is_partition: info.is_partition })
}
Some(PoolKind::Agent(client)) => {
// Resolve the config once: it gates the arm and feeds the RPC
// timeout.
let db_config = connection_config(state, connection_id).await;
if !db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Kingbase) {
return Ok(TablePartitionStatus::default());
}
let mut client = client.lock().await;
match client
.get_table_partition_status::<TablePartitionStatus>(
database,
schema,
table,
agent_metadata_timeout(db_config.as_ref()),
)
.await
{
Ok(status) => Ok(status),
Err(error) if is_agent_partition_method_unsupported(&error, "get_table_partition_status") => {
Ok(TablePartitionStatus::default())
}
Err(error) => Err(error),
}
}
_ => Ok(TablePartitionStatus::default()),
}
@@ -7820,6 +7871,52 @@ pub async fn table_partition_status_core(
.await
}
/// Structured declarative partitioning view used by the table structure
/// editor. Native PostgreSQL pools and compatible agents (such as Kingbase)
/// provide the same response shape; unsupported pools return the default
/// all-false/empty value.
pub async fn get_table_partitioning_core(
state: &AppState,
connection_id: &str,
database: &str,
schema: &str,
table: &str,
) -> Result<db::PgTablePartitioning, String> {
retry_metadata_connection(state, connection_id, Some(database), || async {
let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?;
let pool_handle = state.pool_handle(&pool_key).await;
match pool_handle.as_ref() {
Some(PoolKind::Postgres(pool)) => db::postgres::get_table_partitioning(pool, schema, table).await,
Some(PoolKind::Agent(client)) => {
// Resolve the config once: it gates the arm and feeds the RPC
// timeout.
let db_config = connection_config(state, connection_id).await;
if !db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Kingbase) {
return Ok(db::PgTablePartitioning::default());
}
let mut client = client.lock().await;
match client
.get_table_partitioning::<db::PgTablePartitioning>(
database,
schema,
table,
agent_metadata_timeout(db_config.as_ref()),
)
.await
{
Ok(partitioning) => Ok(partitioning),
Err(error) if is_agent_partition_method_unsupported(&error, "get_table_partitioning") => {
Ok(db::PgTablePartitioning::default())
}
Err(error) => Err(error),
}
}
_ => Ok(db::PgTablePartitioning::default()),
}
})
.await
}
/// Same-table index names whose `pg_index.indisvalid` is `false` (left behind
/// by a cancelled `CREATE INDEX CONCURRENTLY`). Empty for non-PostgreSQL pools.
pub async fn list_invalid_indexes_core(
@@ -0,0 +1,359 @@
//! Live declarative-partitioning tests.
//!
//! Point `DBX_LIVE_POSTGRES_*` at a PostgreSQL server, or at a KingbaseES
//! server (same wire protocol and `pg_partitioned_table` catalog) — all tests
//! pass against KingbaseES V009R001C010. openGauss-based engines use a
//! different `pg_partition` catalog and are not covered here.
use std::time::Duration;
use dbx_core::db::postgres;
use dbx_core::types::{PgPartitionBound, PgPartitionKind};
/// Percent-encodes the URL userinfo characters that would otherwise be read
/// as delimiters (`@`, `:`, `/`, `#`, `%`). Live fixtures commonly use
/// passwords like `test@123`.
fn encode_url_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'@' => encoded.push_str("%40"),
':' => encoded.push_str("%3A"),
'/' => encoded.push_str("%2F"),
'#' => encoded.push_str("%23"),
'%' => encoded.push_str("%25"),
_ => encoded.push(ch),
}
}
encoded
}
fn live_postgres_url() -> String {
let host = std::env::var("DBX_LIVE_POSTGRES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = std::env::var("DBX_LIVE_POSTGRES_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(5432);
let user =
encode_url_component(&std::env::var("DBX_LIVE_POSTGRES_USER").unwrap_or_else(|_| "postgres".to_string()));
let password = encode_url_component(&std::env::var("DBX_LIVE_POSTGRES_PASSWORD").unwrap_or_default());
let database = std::env::var("DBX_LIVE_POSTGRES_DATABASE").unwrap_or_else(|_| "postgres".to_string());
format!("postgresql://{user}:{password}@{host}:{port}/{database}")
}
fn fixture_statements(schema: &str) -> Vec<String> {
vec![
format!("CREATE SCHEMA \"{schema}\""),
// RANGE with a default partition.
format!("CREATE TABLE \"{schema}\".sales (id integer, sold_on date) PARTITION BY RANGE (sold_on)"),
format!(
"CREATE TABLE \"{schema}\".sales_2024 PARTITION OF \"{schema}\".sales \
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')"
),
format!("CREATE TABLE \"{schema}\".sales_default PARTITION OF \"{schema}\".sales DEFAULT"),
// LIST.
format!("CREATE TABLE \"{schema}\".events (region text) PARTITION BY LIST (region)"),
format!("CREATE TABLE \"{schema}\".events_a PARTITION OF \"{schema}\".events FOR VALUES IN ('a', 'b')"),
// HASH.
format!("CREATE TABLE \"{schema}\".h (id integer) PARTITION BY HASH (id)"),
format!("CREATE TABLE \"{schema}\".h0 PARTITION OF \"{schema}\".h FOR VALUES WITH (MODULUS 2, REMAINDER 0)"),
format!("CREATE TABLE \"{schema}\".h1 PARTITION OF \"{schema}\".h FOR VALUES WITH (MODULUS 2, REMAINDER 1)"),
// Multi-column RANGE.
format!("CREATE TABLE \"{schema}\".multi (a text, b text) PARTITION BY RANGE (a, b)"),
format!(
"CREATE TABLE \"{schema}\".multi_1 PARTITION OF \"{schema}\".multi \
FOR VALUES FROM ('a', 'a') TO ('b', 'b')"
),
// Expression RANGE key (must be IMMUTABLE).
format!("CREATE TABLE \"{schema}\".exp (v integer) PARTITION BY RANGE (abs(v))"),
format!("CREATE TABLE \"{schema}\".exp_neg PARTITION OF \"{schema}\".exp FOR VALUES FROM (MINVALUE) TO (0)"),
// Plain, non-partitioned table.
format!("CREATE TABLE \"{schema}\".plain (id integer)"),
]
}
/// Live PostgreSQL: `get_table_partitioning` must report strategy, key columns,
/// nested children, bounds, and the default partition for every supported
/// partitioning shape, and must report a plain table as unpartitioned.
#[tokio::test]
#[ignore = "requires DBX_LIVE_POSTGRES_HOST/PORT/USER/PASSWORD/DATABASE pointing at a writable PostgreSQL database"]
async fn live_postgres_partitioning_is_structured() {
let pool = postgres::connect(&live_postgres_url(), Duration::from_secs(10)).await.expect("connect PostgreSQL");
let suffix = uuid::Uuid::new_v4().simple().to_string();
let schema = format!("dbx_part_{}", &suffix[..8]);
let cleanup = vec![format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE")];
let _ = postgres::execute_batch(&pool, &cleanup).await;
postgres::execute_batch(&pool, &fixture_statements(&schema)).await.expect("create live partition fixture");
// RANGE + default partition.
let sales = postgres::get_table_partitioning(&pool, &schema, "sales").await.expect("read sales partitioning");
assert!(sales.is_partitioned);
assert!(!sales.is_partition);
assert_eq!(sales.strategy, Some(PgPartitionKind::Range));
assert_eq!(sales.key_columns, vec!["sold_on".to_string()]);
assert_eq!(sales.default_partition.as_deref(), Some("sales_default"));
assert_eq!(sales.partitions.len(), 2);
let sales_2024 = sales.partitions.iter().find(|node| node.name == "sales_2024").expect("sales_2024 present");
assert!(sales_2024.is_leaf);
// The literal text is whatever the server renders: PostgreSQL 14 emits
// `'2024-01-01'`, KingbaseES emits `'2024-01-01 00:00:00'`. Assert the shape
// and the date prefix instead of an exact string so both pass.
match sales_2024.bound.as_ref() {
Some(PgPartitionBound::Range { from, to }) => {
assert_eq!(from.len(), 1);
assert_eq!(to.len(), 1);
assert!(from[0].starts_with("'2024-01-01"), "unexpected from: {from:?}");
assert!(to[0].starts_with("'2025-01-01"), "unexpected to: {to:?}");
}
other => panic!("expected a range bound, got {other:?}"),
}
// LIST.
let events = postgres::get_table_partitioning(&pool, &schema, "events").await.expect("read events partitioning");
assert_eq!(events.strategy, Some(PgPartitionKind::List));
assert_eq!(
events.partitions[0].bound,
Some(PgPartitionBound::List { values: vec!["'a'".to_string(), "'b'".to_string()] })
);
// HASH.
let hash = postgres::get_table_partitioning(&pool, &schema, "h").await.expect("read hash partitioning");
assert_eq!(hash.strategy, Some(PgPartitionKind::Hash));
let h0 = hash.partitions.iter().find(|node| node.name == "h0").expect("h0 present");
assert_eq!(h0.bound, Some(PgPartitionBound::Hash { modulus: 2, remainder: 0 }));
assert_eq!(h0.strategy, None);
// Multi-column RANGE.
let multi = postgres::get_table_partitioning(&pool, &schema, "multi").await.expect("read multi partitioning");
assert_eq!(multi.key_columns, vec!["a".to_string(), "b".to_string()]);
assert_eq!(
multi.partitions[0].bound,
Some(PgPartitionBound::Range {
from: vec!["'a'".to_string(), "'a'".to_string()],
to: vec!["'b'".to_string(), "'b'".to_string()]
})
);
// Expression RANGE key: no plain columns, expression text captured.
let exp = postgres::get_table_partitioning(&pool, &schema, "exp").await.expect("read exp partitioning");
assert!(exp.key_columns.is_empty());
assert_eq!(exp.key_expression.as_deref(), Some("abs(v)"));
assert_eq!(
exp.partitions[0].bound,
Some(PgPartitionBound::Range { from: vec!["MINVALUE".to_string()], to: vec!["0".to_string()] })
);
// A member partition reports its parent and own bound (and no descendants).
let child = postgres::get_table_partitioning(&pool, &schema, "sales_2024").await.expect("read child partitioning");
assert!(child.is_partition);
assert!(!child.is_partitioned);
assert_eq!(child.parent.as_deref(), Some(format!("{schema}.sales").as_str()));
assert!(child.partitions.is_empty());
assert!(child.own_bound.is_some());
// A plain table is not partitioning at all.
let plain = postgres::get_table_partitioning(&pool, &schema, "plain").await.expect("read plain table");
assert!(!plain.is_partitioned && !plain.is_partition && plain.partitions.is_empty());
postgres::execute_batch(&pool, &cleanup).await.expect("cleanup live partition schema");
}
/// Live PostgreSQL: a two-level hierarchy (partitioned partition) must be
/// reported as nested children, not flattened.
#[tokio::test]
#[ignore = "requires DBX_LIVE_POSTGRES_HOST/PORT/USER/PASSWORD/DATABASE pointing at a writable PostgreSQL database"]
async fn live_postgres_partitioning_nests_subpartitions() {
let pool = postgres::connect(&live_postgres_url(), Duration::from_secs(10)).await.expect("connect PostgreSQL");
let suffix = uuid::Uuid::new_v4().simple().to_string();
let schema = format!("dbx_partn_{}", &suffix[..8]);
let cleanup = vec![format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE")];
let _ = postgres::execute_batch(&pool, &cleanup).await;
let statements = vec![
format!("CREATE SCHEMA \"{schema}\""),
format!("CREATE TABLE \"{schema}\".logs (ts date, region text) PARTITION BY RANGE (ts)"),
format!(
"CREATE TABLE \"{schema}\".logs_2024 PARTITION OF \"{schema}\".logs \
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01') PARTITION BY LIST (region)"
),
format!("CREATE TABLE \"{schema}\".logs_2024_us PARTITION OF \"{schema}\".logs_2024 FOR VALUES IN ('us')"),
];
postgres::execute_batch(&pool, &statements).await.expect("create nested partition fixture");
let logs = postgres::get_table_partitioning(&pool, &schema, "logs").await.expect("read logs partitioning");
assert_eq!(logs.strategy, Some(PgPartitionKind::Range));
assert_eq!(logs.partitions.len(), 1);
let child = &logs.partitions[0];
assert_eq!(child.name, "logs_2024");
assert_eq!(child.strategy, Some(PgPartitionKind::List));
assert!(!child.is_leaf);
assert_eq!(child.children.len(), 1);
assert_eq!(child.children[0].name, "logs_2024_us");
postgres::execute_batch(&pool, &cleanup).await.expect("cleanup nested partition schema");
}
/// Live PostgreSQL: the generated partition maintenance DDL must actually run.
/// Covers create (with a bound), detach, and drop end to end.
#[tokio::test]
#[ignore = "requires DBX_LIVE_POSTGRES_HOST/PORT/USER/PASSWORD/DATABASE pointing at a writable PostgreSQL database"]
async fn live_postgres_partition_operations_execute() {
use dbx_core::table_structure_sql::{
build_table_partition_operation_sql, TablePartitionBoundDraft, TablePartitionOperation,
TablePartitionOperationKind, TablePartitionSqlOptions,
};
let pool = postgres::connect(&live_postgres_url(), Duration::from_secs(10)).await.expect("connect PostgreSQL");
let suffix = uuid::Uuid::new_v4().simple().to_string();
let schema = format!("dbx_partop_{}", &suffix[..8]);
let cleanup = vec![format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE")];
let _ = postgres::execute_batch(&pool, &cleanup).await;
postgres::execute_batch(
&pool,
&[
format!("CREATE SCHEMA \"{schema}\""),
format!("CREATE TABLE \"{schema}\".sales (id integer, sold_on date) PARTITION BY RANGE (sold_on)"),
],
)
.await
.expect("create partitioned parent");
let operation = |kind: TablePartitionOperationKind, name: &str, bound: Option<TablePartitionBoundDraft>| {
TablePartitionOperation {
id: format!("op:{name}"),
kind,
parent_schema: String::new(),
parent_table: String::new(),
schema: String::new(),
name: name.to_string(),
bound,
concurrently: false,
}
};
let create = build_table_partition_operation_sql(TablePartitionSqlOptions {
database_type: Some(dbx_core::models::connection::DatabaseType::Postgres),
driver_profile: None,
schema: Some(schema.clone()),
table_name: "sales".to_string(),
operations: vec![operation(
TablePartitionOperationKind::Create,
"sales_2025",
Some(TablePartitionBoundDraft::Range {
from: vec!["'2025-01-01'".to_string()],
to: vec!["'2026-01-01'".to_string()],
}),
)],
});
assert!(create.warnings.is_empty(), "{:?}", create.warnings);
postgres::execute_batch(&pool, &create.statements).await.expect("execute create partition");
let partitioning = postgres::get_table_partitioning(&pool, &schema, "sales").await.expect("read partitioning");
assert!(partitioning.partitions.iter().any(|node| node.name == "sales_2025"));
assert!(partitioning.server_version_num.is_some());
// Detach + drop: the partition must disappear from the parent's tree.
let detach = build_table_partition_operation_sql(TablePartitionSqlOptions {
database_type: Some(dbx_core::models::connection::DatabaseType::Postgres),
driver_profile: None,
schema: Some(schema.clone()),
table_name: "sales".to_string(),
operations: vec![
operation(TablePartitionOperationKind::Detach, "sales_2025", None),
operation(TablePartitionOperationKind::Drop, "sales_2025", None),
],
});
assert!(detach.warnings.is_empty(), "{:?}", detach.warnings);
postgres::execute_batch(&pool, &detach.statements).await.expect("execute detach + drop");
let after = postgres::get_table_partitioning(&pool, &schema, "sales").await.expect("read partitioning after drop");
assert!(!after.partitions.iter().any(|node| node.name == "sales_2025"));
postgres::execute_batch(&pool, &cleanup).await.expect("cleanup live partition-op schema");
}
/// Live PostgreSQL: a table created through the create-mode partitioning
/// builder must be a real partitioned parent that accepts partitions.
#[tokio::test]
#[ignore = "requires DBX_LIVE_POSTGRES_HOST/PORT/USER/PASSWORD/DATABASE pointing at a writable PostgreSQL database"]
async fn live_postgres_create_partitioned_table_executes() {
use dbx_core::models::connection::DatabaseType;
use dbx_core::table_structure_sql::{
build_create_partitioned_table_sql, build_table_partition_operation_sql, EditableStructureColumn,
TablePartitionBoundDraft, TablePartitionDefinition, TablePartitionOperation, TablePartitionOperationKind,
TablePartitionSqlOptions, TableStructureSqlOptions,
};
use dbx_core::types::PgPartitionKind;
let pool = postgres::connect(&live_postgres_url(), Duration::from_secs(10)).await.expect("connect PostgreSQL");
let suffix = uuid::Uuid::new_v4().simple().to_string();
let schema = format!("dbx_partnew_{}", &suffix[..8]);
let cleanup = vec![format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE")];
let _ = postgres::execute_batch(&pool, &cleanup).await;
postgres::execute_batch(&pool, &[format!("CREATE SCHEMA \"{schema}\"")]).await.expect("create schema");
let column = |name: &str, data_type: &str| EditableStructureColumn {
id: name.to_string(),
name: name.to_string(),
data_type: data_type.to_string(),
is_nullable: false,
default_value: String::new(),
comment: String::new(),
is_primary_key: false,
extra: None,
original: None,
original_position: None,
marked_for_drop: false,
character_set: String::new(),
collation: String::new(),
};
let options = TableStructureSqlOptions {
database_type: Some(DatabaseType::Postgres),
driver_profile: None,
schema: Some(schema.clone()),
table_name: "sales".to_string(),
columns: vec![column("sold_on", "date"), column("amount", "numeric")],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
mysql_engine: None,
partitioned: false,
is_gaussdb_m_mode: false,
table_collation: None,
};
let create = build_create_partitioned_table_sql(
options,
TablePartitionDefinition {
kind: PgPartitionKind::Range,
columns: vec!["sold_on".to_string()],
expression: String::new(),
},
);
assert!(create.warnings.is_empty(), "{:?}", create.warnings);
postgres::execute_batch(&pool, &create.statements).await.expect("execute CREATE TABLE ... PARTITION BY");
let add = build_table_partition_operation_sql(TablePartitionSqlOptions {
database_type: Some(DatabaseType::Postgres),
driver_profile: None,
schema: Some(schema.clone()),
table_name: "sales".to_string(),
operations: vec![TablePartitionOperation {
id: "op:1".to_string(),
kind: TablePartitionOperationKind::Create,
parent_schema: String::new(),
parent_table: String::new(),
schema: String::new(),
name: "sales_2025".to_string(),
bound: Some(TablePartitionBoundDraft::Range {
from: vec!["'2025-01-01'".to_string()],
to: vec!["'2026-01-01'".to_string()],
}),
concurrently: false,
}],
});
postgres::execute_batch(&pool, &add.statements).await.expect("execute add partition");
let partitioning = postgres::get_table_partitioning(&pool, &schema, "sales").await.expect("read partitioning");
assert!(partitioning.is_partitioned);
assert_eq!(partitioning.key_columns, vec!["sold_on".to_string()]);
assert!(partitioning.partitions.iter().any(|node| node.name == "sales_2025"));
postgres::execute_batch(&pool, &cleanup).await.expect("cleanup create-partitioned schema");
}
@@ -30,6 +30,8 @@
"list_constraints",
"list_partitions",
"list_subpartitions",
"get_table_partition_status",
"get_table_partitioning",
"execute_query",
"execute_query_page",
"fetch_query_page",
+39 -1
View File
@@ -1219,6 +1219,8 @@ pub enum AgentMethod {
ListConstraints,
ListPartitions,
ListSubpartitions,
GetTablePartitionStatus,
GetTablePartitioning,
GetTableDdl,
ExecuteQuery,
ExecuteQueryPage,
@@ -1238,7 +1240,7 @@ pub enum AgentMethod {
}
impl AgentMethod {
pub const ALL: [Self; 39] = [
pub const ALL: [Self; 41] = [
Self::Handshake,
Self::Connect,
Self::OpenSession,
@@ -1263,6 +1265,8 @@ impl AgentMethod {
Self::ListConstraints,
Self::ListPartitions,
Self::ListSubpartitions,
Self::GetTablePartitionStatus,
Self::GetTablePartitioning,
Self::ExecuteQuery,
Self::ExecuteQueryPage,
Self::FetchQueryPage,
@@ -1308,6 +1312,8 @@ impl AgentMethod {
Self::ListConstraints => "list_constraints",
Self::ListPartitions => "list_partitions",
Self::ListSubpartitions => "list_subpartitions",
Self::GetTablePartitionStatus => "get_table_partition_status",
Self::GetTablePartitioning => "get_table_partitioning",
Self::ExecuteQuery => "execute_query",
Self::ExecuteQueryPage => "execute_query_page",
Self::FetchQueryPage => "fetch_query_page",
@@ -2336,6 +2342,36 @@ impl AgentDriverClient {
.await
}
pub async fn get_table_partition_status<T: DeserializeOwned + Send + 'static>(
&mut self,
database: &str,
schema: &str,
table: &str,
timeout_duration: Option<Duration>,
) -> Result<T, String> {
self.call_method_with_timeout(
AgentMethod::GetTablePartitionStatus,
agent_schema_table_params(database, schema, table),
timeout_duration,
)
.await
}
pub async fn get_table_partitioning<T: DeserializeOwned + Send + 'static>(
&mut self,
database: &str,
schema: &str,
table: &str,
timeout_duration: Option<Duration>,
) -> Result<T, String> {
self.call_method_with_timeout(
AgentMethod::GetTablePartitioning,
agent_schema_table_params(database, schema, table),
timeout_duration,
)
.await
}
pub async fn get_table_ddl<T: DeserializeOwned + Send + 'static>(
&mut self,
database: &str,
@@ -5118,6 +5154,8 @@ for line in sys.stdin:
assert_eq!(AgentMethod::ListConstraints.as_str(), "list_constraints");
assert_eq!(AgentMethod::ListPartitions.as_str(), "list_partitions");
assert_eq!(AgentMethod::ListSubpartitions.as_str(), "list_subpartitions");
assert_eq!(AgentMethod::GetTablePartitionStatus.as_str(), "get_table_partition_status");
assert_eq!(AgentMethod::GetTablePartitioning.as_str(), "get_table_partitioning");
assert_eq!(AgentMethod::GetTableDdl.as_str(), "get_table_ddl");
assert_eq!(AgentMethod::ExecuteQuery.as_str(), "execute_query");
assert_eq!(AgentMethod::ExecuteQueryPage.as_str(), "execute_query_page");
+470 -3
View File
@@ -11,7 +11,7 @@ use rustls::server::ParsedCertificate;
use sqlparser::ast::{SetExpr, Statement};
use sqlparser::dialect::PostgreSqlDialect;
use sqlparser::parser::Parser;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fmt::Write as _;
use std::fs::File;
use std::future::Future;
@@ -37,8 +37,8 @@ use crate::types::{
CompletionAssistantObjectKind, CompletionAssistantRequest, CompletionAssistantResponse, ConstraintInfo,
CustomTypeDdl, CustomTypeDetails, CustomTypeDomainConstraint, CustomTypeKind, CustomTypeMember,
CustomTypeProperties, DatabaseInfo, DatabaseStorageInfo, ExtensionInfo, ForeignKeyInfo, FunctionInfo, IndexInfo,
ObjectInfo, ObjectStatistics, OwnerInfo, QueryMessage, QueryResult, RuleInfo, SchemaInfo, SequenceInfo,
SpatialColumnBuilder, TableInfo, TriggerInfo,
ObjectInfo, ObjectStatistics, OwnerInfo, PgPartitionBound, PgPartitionKind, PgPartitionNode, PgTablePartitioning,
QueryMessage, QueryResult, RuleInfo, SchemaInfo, SequenceInfo, SpatialColumnBuilder, TableInfo, TriggerInfo,
};
pub const GAUSSDB_COMPATIBILITY_SQL: &str =
@@ -3951,6 +3951,390 @@ pub async fn get_table_partition_key(pool: &Pool, schema: &str, table: &str) ->
Ok(get_table_partition_info(pool, schema, table).await?.key)
}
/// The partitioning strategy of a single PostgreSQL partitioned parent, read
/// from `pg_partitioned_table` so expression keys and multi-column keys come
/// back structured instead of as concatenated `pg_get_partkeydef` text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PostgresPartitionStrategy {
pub kind: PgPartitionKind,
pub key_definition: String,
/// Key columns, in key order. Empty for a pure-expression key.
pub columns: Vec<String>,
/// `pg_get_expr(partexprs)` text, set only for expression keys.
pub expression: Option<String>,
}
fn postgres_partition_strategy_sql() -> &'static str {
"SELECT p.partstrat::text, \
pg_catalog.pg_get_partkeydef(c.oid) AS key_def, \
COALESCE(ARRAY( \
SELECT a.attname::text \
FROM unnest(p.partattrs) WITH ORDINALITY AS u(attnum, ord) \
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum = u.attnum \
WHERE u.attnum > 0 \
ORDER BY u.ord \
), '{}') AS key_columns, \
pg_catalog.pg_get_expr(p.partexprs, c.oid) AS key_expression \
FROM pg_catalog.pg_partitioned_table p \
JOIN pg_catalog.pg_class c ON c.oid = p.partrelid \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname = $1 AND c.relname = $2"
}
/// Pre-10 servers have no `pg_partitioned_table`; return no row so the caller
/// sees `None` (the relation is a plain table there).
fn postgres_partition_strategy_compat_sql() -> &'static str {
"SELECT NULL::text, NULL::text, '{}'::text[], NULL::text WHERE false"
}
pub async fn get_table_partition_strategy(
pool: &Pool,
schema: &str,
table: &str,
) -> Result<Option<PostgresPartitionStrategy>, String> {
let schema = if schema.is_empty() { "public" } else { schema };
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let params: [&(dyn tokio_postgres::types::ToSql + Sync); 2] = [&schema, &table];
let rows = query_with_compat_fallback(
"get_table_partition_strategy",
&[postgres_partition_strategy_sql(), postgres_partition_strategy_compat_sql()],
|sql| postgres_query_cached(&client, sql, &params),
)
.await?;
Ok(rows.first().and_then(|row| {
let strategy = row.try_get::<_, Option<String>>(0).ok().flatten()?;
let kind = pg_partition_kind_from_strategy(&strategy)?;
let key_definition = row.try_get::<_, Option<String>>(1).ok().flatten().unwrap_or_default();
let columns = row.try_get::<_, Option<Vec<String>>>(2).ok().flatten().unwrap_or_default();
let expression = row.try_get::<_, Option<String>>(3).ok().flatten().filter(|value| !value.trim().is_empty());
Some(PostgresPartitionStrategy { kind, key_definition, columns, expression })
}))
}
/// `pg_partitioned_table.partstrat`: `r` = range, `l` = list, `h` = hash.
pub fn pg_partition_kind_from_strategy(strategy: &str) -> Option<PgPartitionKind> {
match strategy.trim() {
"r" => Some(PgPartitionKind::Range),
"l" => Some(PgPartitionKind::List),
"h" => Some(PgPartitionKind::Hash),
_ => None,
}
}
/// Derives the strategy kind from `pg_get_partkeydef` output (`RANGE (col)`, …).
/// Used for descendants in a partition tree, where the full strategy query is
/// unnecessary (the kind is the only thing a nested node needs to render).
pub fn pg_partition_kind_from_keydef(definition: &str) -> Option<PgPartitionKind> {
let definition = definition.trim_start();
let head = definition.split(|ch: char| ch.is_whitespace() || ch == '(').next()?;
match head.to_ascii_uppercase().as_str() {
"RANGE" => Some(PgPartitionKind::Range),
"LIST" => Some(PgPartitionKind::List),
"HASH" => Some(PgPartitionKind::Hash),
_ => None,
}
}
/// Parses the text PostgreSQL renders for `pg_get_expr(relpartbound, oid, true)`.
///
/// Returns `None` for an unrecognized shape so callers can fall back to the raw
/// definition. Recognized forms:
/// * `DEFAULT`
/// * `FOR VALUES FROM (...) TO (...)` — RANGE, including `MINVALUE`/`MAXVALUE`
/// * `FOR VALUES IN (...)` — LIST
/// * `FOR VALUES WITH (MODULUS n, REMAINDER m)` — HASH (the catalog renders
/// this lowercase, e.g. `modulus 2, remainder 0`)
///
/// Values are kept as SQL literal text so they round-trip byte-for-byte.
pub fn parse_pg_partition_bound(definition: &str) -> Option<PgPartitionBound> {
let definition = definition.trim();
if definition.eq_ignore_ascii_case("DEFAULT") {
return Some(PgPartitionBound::Default);
}
let rest = strip_ascii_prefix_ci(definition, "FOR VALUES")?.trim_start();
if let Some(body) = strip_ascii_prefix_ci(rest, "FROM") {
let (from, after_from) = take_paren_group(body.trim_start())?;
let after_from = strip_ascii_prefix_ci(after_from.trim_start(), "TO")?;
let (to, _) = take_paren_group(after_from.trim_start())?;
return Some(PgPartitionBound::Range { from: split_bound_items(&from), to: split_bound_items(&to) });
}
if let Some(body) = strip_ascii_prefix_ci(rest, "IN") {
let (values, _) = take_paren_group(body.trim_start())?;
return Some(PgPartitionBound::List { values: split_bound_items(&values) });
}
if let Some(body) = strip_ascii_prefix_ci(rest, "WITH") {
let (options, _) = take_paren_group(body.trim_start())?;
let mut modulus = None;
let mut remainder = None;
for option in split_top_level_commas(&options) {
let mut parts = option.split_whitespace();
let key = parts.next()?.to_ascii_lowercase();
let value: i32 = parts.next()?.parse().ok()?;
match key.as_str() {
"modulus" => modulus = Some(value),
"remainder" => remainder = Some(value),
_ => {}
}
}
return Some(PgPartitionBound::Hash { modulus: modulus?, remainder: remainder? });
}
None
}
/// Case-insensitive prefix strip that also requires a token boundary, so
/// `IN` never matches the start of `INTO` and `TO` never matches `TOAST`.
fn strip_ascii_prefix_ci<'a>(input: &'a str, prefix: &str) -> Option<&'a str> {
let head = input.get(..prefix.len())?;
if !head.eq_ignore_ascii_case(prefix) {
return None;
}
let rest = &input[prefix.len()..];
match rest.chars().next() {
None => Some(rest),
Some(ch) if ch.is_whitespace() || ch == '(' => Some(rest),
_ => None,
}
}
/// Splits `( ... )` off the front of `input`, honoring single quotes, double
/// quotes, `''` escapes, and nested parentheses. Returns the inner text and
/// the remainder after the closing paren.
fn take_paren_group(input: &str) -> Option<(String, &str)> {
let input = input.trim_start();
let mut depth = 0i32;
let mut in_single = false;
let mut in_double = false;
let mut close_index = None;
let mut chars = input.char_indices().peekable();
while let Some((index, ch)) = chars.next() {
match ch {
'\'' if !in_double => {
if in_single && chars.peek().map(|(_, next)| *next) == Some('\'') {
chars.next();
continue;
}
in_single = !in_single;
}
'"' if !in_single => in_double = !in_double,
'(' if !in_single && !in_double => depth += 1,
')' if !in_single && !in_double => {
depth -= 1;
if depth == 0 {
close_index = Some(index);
break;
}
}
_ => {}
}
}
let close_index = close_index?;
Some((input[1..close_index].to_string(), &input[close_index + 1..]))
}
fn split_bound_items(input: &str) -> Vec<String> {
split_top_level_commas(input).into_iter().filter(|item| !item.is_empty()).collect()
}
/// Splits on top-level commas only; commas inside quotes, nested parentheses,
/// or `''` escapes stay in the item.
fn split_top_level_commas(input: &str) -> Vec<String> {
let mut items = Vec::new();
let mut current = String::new();
let mut depth = 0i32;
let mut in_single = false;
let mut in_double = false;
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\'' if !in_double => {
if in_single && chars.peek() == Some(&'\'') {
current.push('\'');
current.push('\'');
chars.next();
continue;
}
in_single = !in_single;
current.push(ch);
}
'"' if !in_single => {
in_double = !in_double;
current.push(ch);
}
'(' if !in_single && !in_double => {
depth += 1;
current.push(ch);
}
')' if !in_single && !in_double => {
depth -= 1;
current.push(ch);
}
',' if !in_single && !in_double && depth == 0 => {
items.push(current.trim().to_string());
current.clear();
}
_ => current.push(ch),
}
}
items.push(current.trim().to_string());
items
}
/// Cheap per-relation size/count estimates for a set of relation oids.
/// Best-effort: an empty map on failure just means the UI omits the columns.
fn postgres_partition_relation_stats_sql() -> &'static str {
"SELECT c.oid::bigint, \
CASE WHEN c.relkind = 'f' THEN NULL ELSE c.reltuples::bigint END, \
CASE WHEN c.relkind = 'f' THEN NULL ELSE pg_catalog.pg_total_relation_size(c.oid) END \
FROM pg_catalog.pg_class c \
WHERE c.oid::bigint = ANY($1)"
}
pub async fn get_partition_relation_stats(
pool: &Pool,
oids: &[i64],
) -> Result<HashMap<i64, (Option<i64>, Option<i64>)>, String> {
if oids.is_empty() {
return Ok(HashMap::new());
}
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let owned: Vec<i64> = oids.to_vec();
let rows = postgres_query_cached(&client, postgres_partition_relation_stats_sql(), &[&owned])
.await
.map_err(|e| e.to_string())?;
Ok(rows
.into_iter()
.filter_map(|row| {
let oid = row.try_get::<_, i64>(0).ok()?;
let rows = row.try_get::<_, Option<i64>>(1).ok().flatten().filter(|value| *value >= 0);
let bytes = row.try_get::<_, Option<i64>>(2).ok().flatten().filter(|value| *value >= 0);
Some((oid, (rows, bytes)))
})
.collect())
}
/// `current_setting('server_version_num')` as an integer (e.g. 140019 for
/// 14.19), or `None` when the server does not report it.
pub async fn get_server_version_num(pool: &Pool) -> Result<Option<i32>, String> {
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let rows = postgres_query_cached(&client, "SELECT current_setting('server_version_num')::int", &[])
.await
.map_err(|e| e.to_string())?;
Ok(rows.first().and_then(|row| row.try_get::<_, i32>(0).ok()))
}
/// Full structured partitioning view of one relation, rooted at it. Returns a
/// default (all-false/empty) value for a plain, non-partitioned table.
pub async fn get_table_partitioning(pool: &Pool, schema: &str, table: &str) -> Result<PgTablePartitioning, String> {
let schema = if schema.is_empty() { "public" } else { schema };
let tree = fetch_postgres_partition_tree(pool, schema, table).await?;
let tree_oids: HashSet<i64> = tree.iter().map(|node| node.oid).collect();
// The requested relation is the root: the only tree node whose parent (if
// any) is not also in this tree. An empty tree means the relation is not a
// table/partition at all (view, sequence, missing).
let Some(root) = tree.iter().find(|node| !node.parent_oid.is_some_and(|parent| tree_oids.contains(&parent))) else {
return Ok(PgTablePartitioning::default());
};
let is_partitioned = root.partition_info.key.is_some();
let is_partition = root.partition_info.is_partition;
if !is_partitioned && !is_partition {
return Ok(PgTablePartitioning::default());
}
let strategy =
if is_partitioned { get_table_partition_strategy(pool, &root.schema, &root.table).await? } else { None };
let oids: Vec<i64> = tree.iter().map(|node| node.oid).collect();
let stats = get_partition_relation_stats(pool, &oids).await.unwrap_or_default();
let nodes_by_oid: HashMap<i64, &PostgresPartitionTreeNode> = tree.iter().map(|node| (node.oid, node)).collect();
let mut children_by_parent: HashMap<i64, Vec<i64>> = HashMap::new();
for node in &tree {
if let Some(parent_oid) = node.parent_oid {
if tree_oids.contains(&parent_oid) {
children_by_parent.entry(parent_oid).or_default().push(node.oid);
}
}
}
for child_oids in children_by_parent.values_mut() {
child_oids.sort_by(|left, right| {
let left = nodes_by_oid.get(left).map(|node| node.table.as_str()).unwrap_or_default();
let right = nodes_by_oid.get(right).map(|node| node.table.as_str()).unwrap_or_default();
left.cmp(right)
});
}
let mut visited: HashSet<i64> = HashSet::new();
visited.insert(root.oid);
let partitions = build_partition_nodes(root.oid, &nodes_by_oid, &children_by_parent, &stats, &mut visited);
let default_partition =
partitions.iter().find(|node| node.bound == Some(PgPartitionBound::Default)).map(|node| node.name.clone());
// Best effort: a missing version just hides the CONCURRENTLY option.
let server_version_num = get_server_version_num(pool).await.unwrap_or(None);
Ok(PgTablePartitioning {
is_partitioned,
is_partition,
parent: match (root.partition_info.parent_schema.as_deref(), root.partition_info.parent_table.as_deref()) {
(Some(schema), Some(table)) => Some(format!("{schema}.{table}")),
_ => None,
},
parent_schema: root.partition_info.parent_schema.clone(),
parent_table: root.partition_info.parent_table.clone(),
own_bound: root.partition_info.bound.as_deref().and_then(parse_pg_partition_bound),
strategy: strategy.as_ref().map(|strategy| strategy.kind),
key_definition: strategy
.as_ref()
.map(|strategy| strategy.key_definition.clone())
.or_else(|| root.partition_info.key.clone()),
key_columns: strategy.as_ref().map(|strategy| strategy.columns.clone()).unwrap_or_default(),
key_expression: strategy.and_then(|strategy| strategy.expression),
default_partition,
partitions,
server_version_num,
})
}
/// Recursively materializes the children of `parent_oid`. `visited` guards
/// against a corrupted catalog (or a non-PostgreSQL fork) whose `pg_inherits`
/// data forms a cycle, which would otherwise recurse forever.
fn build_partition_nodes(
parent_oid: i64,
nodes_by_oid: &HashMap<i64, &PostgresPartitionTreeNode>,
children_by_parent: &HashMap<i64, Vec<i64>>,
stats: &HashMap<i64, (Option<i64>, Option<i64>)>,
visited: &mut HashSet<i64>,
) -> Vec<PgPartitionNode> {
let Some(child_oids) = children_by_parent.get(&parent_oid) else {
return Vec::new();
};
let mut nodes = Vec::with_capacity(child_oids.len());
for child_oid in child_oids {
if !visited.insert(*child_oid) {
continue;
}
let Some(node) = nodes_by_oid.get(child_oid) else {
continue;
};
let children = build_partition_nodes(*child_oid, nodes_by_oid, children_by_parent, stats, visited);
let (row_estimate, total_bytes) = stats.get(child_oid).copied().unwrap_or((None, None));
let bound_definition = node.partition_info.bound.clone();
let strategy = node.partition_info.key.as_deref().and_then(pg_partition_kind_from_keydef);
nodes.push(PgPartitionNode {
schema: node.schema.clone(),
name: node.table.clone(),
strategy,
key_definition: node.partition_info.key.clone(),
bound: bound_definition.as_deref().and_then(parse_pg_partition_bound),
bound_definition,
is_leaf: children.is_empty(),
row_estimate,
total_bytes,
children,
});
}
nodes
}
/// Classifies one row of `postgres_table_partition_local_objects_sql` (or its
/// `_for_relations` sibling, which has the same `object_kind`/`object_name`/
/// `object_type` columns plus a leading `relid`) into `entry`. Shared so a
@@ -14761,4 +15145,87 @@ mod tests {
assert!(!visible_sql.contains("NOT IN"));
assert!(!visible_sql.contains("NOT LIKE"));
}
#[test]
fn pg_partition_kind_from_strategy_maps_catalog_letters() {
assert_eq!(pg_partition_kind_from_strategy("r"), Some(PgPartitionKind::Range));
assert_eq!(pg_partition_kind_from_strategy("l"), Some(PgPartitionKind::List));
assert_eq!(pg_partition_kind_from_strategy("h"), Some(PgPartitionKind::Hash));
assert_eq!(pg_partition_kind_from_strategy("x"), None);
}
#[test]
fn pg_partition_kind_from_keydef_uses_the_leading_keyword() {
assert_eq!(pg_partition_kind_from_keydef("RANGE (sold_on)"), Some(PgPartitionKind::Range));
assert_eq!(pg_partition_kind_from_keydef("LIST (region)"), Some(PgPartitionKind::List));
assert_eq!(pg_partition_kind_from_keydef("HASH (id)"), Some(PgPartitionKind::Hash));
assert_eq!(pg_partition_kind_from_keydef("RANGE (abs(v))"), Some(PgPartitionKind::Range));
assert_eq!(pg_partition_kind_from_keydef("UNKNOWN (x)"), None);
}
#[test]
fn parse_pg_partition_bound_reads_range_bounds() {
assert_eq!(
parse_pg_partition_bound("FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')"),
Some(PgPartitionBound::Range {
from: vec!["'2024-01-01'".to_string()],
to: vec!["'2025-01-01'".to_string()],
})
);
// MINVALUE / MAXVALUE are keywords, kept verbatim without quoting.
assert_eq!(
parse_pg_partition_bound("FOR VALUES FROM (MINVALUE) TO (0)"),
Some(PgPartitionBound::Range { from: vec!["MINVALUE".to_string()], to: vec!["0".to_string()] })
);
// Multi-column range keeps each tuple position separate.
assert_eq!(
parse_pg_partition_bound("FOR VALUES FROM ('a', 'a') TO ('b', MAXVALUE)"),
Some(PgPartitionBound::Range {
from: vec!["'a'".to_string(), "'a'".to_string()],
to: vec!["'b'".to_string(), "MAXVALUE".to_string()],
})
);
}
#[test]
fn parse_pg_partition_bound_reads_list_and_hash_and_default() {
assert_eq!(
parse_pg_partition_bound("FOR VALUES IN ('a', 'b')"),
Some(PgPartitionBound::List { values: vec!["'a'".to_string(), "'b'".to_string()] })
);
// The catalog renders hash options lowercase.
assert_eq!(
parse_pg_partition_bound("FOR VALUES WITH (modulus 2, remainder 1)"),
Some(PgPartitionBound::Hash { modulus: 2, remainder: 1 })
);
assert_eq!(
parse_pg_partition_bound("FOR VALUES WITH (MODULUS 4, REMAINDER 3)"),
Some(PgPartitionBound::Hash { modulus: 4, remainder: 3 })
);
assert_eq!(parse_pg_partition_bound("DEFAULT"), Some(PgPartitionBound::Default));
assert_eq!(parse_pg_partition_bound(" default "), Some(PgPartitionBound::Default));
}
#[test]
fn parse_pg_partition_bound_keeps_commas_inside_quotes_and_calls() {
assert_eq!(
parse_pg_partition_bound("FOR VALUES IN ('a,b', 'c')"),
Some(PgPartitionBound::List { values: vec!["'a,b'".to_string(), "'c'".to_string()] })
);
assert_eq!(
parse_pg_partition_bound("FOR VALUES FROM (lower('A''B')) TO (lower('C'))"),
Some(PgPartitionBound::Range {
from: vec!["lower('A''B')".to_string()],
to: vec!["lower('C')".to_string()],
})
);
}
#[test]
fn parse_pg_partition_bound_rejects_unrecognized_input() {
assert_eq!(parse_pg_partition_bound(""), None);
assert_eq!(parse_pg_partition_bound("NOT A BOUND"), None);
// `IN` must not match the start of `INTO`.
assert_eq!(parse_pg_partition_bound("FOR VALUES INTO (1)"), None);
}
}
@@ -8,6 +8,7 @@ mod foreign_keys;
mod indexes;
mod mysql_engine;
mod owner;
mod partitions;
mod triggers;
mod types;
mod util;
@@ -19,6 +20,7 @@ mod tests;
pub use column_alter::build_single_column_alter_sql;
pub use create_table::build_create_table_sql;
pub use owner::build_table_owner_change_sql;
pub use partitions::{build_create_partitioned_table_sql, build_table_partition_operation_sql};
pub use types::*;
pub use column_alter::{
@@ -23,7 +23,20 @@ fn is_sqlite_integer_family_type(data_type: &str) -> bool {
.any(|candidate| normalized == *candidate || normalized.starts_with(&format!("{candidate}(")))
}
pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStructureSqlResult {
pub fn build_create_table_sql(options: TableStructureSqlOptions) -> TableStructureSqlResult {
build_create_table_sql_with_partition_clause(options, None)
}
/// Shared implementation for plain and partitioned `CREATE TABLE`.
///
/// `partition_clause` is a ready-made `PARTITION BY ...` body (no leading
/// keyword), appended to the `CREATE TABLE` statement. Keeping it as a separate
/// entry point means `build_create_table_sql`'s signature — used from ~160 call
/// sites and integration tests — stays unchanged.
pub(super) fn build_create_table_sql_with_partition_clause(
mut options: TableStructureSqlOptions,
partition_clause: Option<String>,
) -> TableStructureSqlResult {
let capabilities;
let dialect;
if options.is_gaussdb_m_mode {
@@ -177,7 +190,12 @@ pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStr
column_definitions.push(format!("PRIMARY KEY ({pk_list})"));
}
statements.push(format!("CREATE TABLE {table} (\n {}\n);", column_definitions.join(",\n ")));
let create_table = format!("CREATE TABLE {table} (\n {}\n)", column_definitions.join(",\n "));
let create_table = match partition_clause {
Some(clause) => format!("{create_table} {clause}"),
None => create_table,
};
statements.push(format!("{create_table};"));
if let Some(engine) = options.mysql_engine.as_deref().map(str::trim).filter(|engine| !engine.is_empty()) {
if let Some(statement) = statements.last_mut() {
@@ -0,0 +1,207 @@
use super::create_table::build_create_table_sql_with_partition_clause;
use super::dialect::{capabilities_for, StructureDialect};
use super::types::{
TablePartitionBoundDraft, TablePartitionDefinition, TablePartitionOperation, TablePartitionOperationKind,
TablePartitionSqlOptions, TableStructureSqlOptions, TableStructureSqlResult,
};
use super::util::quote_ident;
use crate::models::connection::DatabaseType;
use crate::types::PgPartitionKind;
/// Engines whose declarative-partition DDL has been verified. Only PostgreSQL
/// and KingbaseES expose the same `pg_partitioned_table` catalog and
/// `CREATE TABLE ... PARTITION OF` / `ATTACH` / `DETACH` syntax. openGauss-based
/// engines (openGauss, Vastbase, GaussDB) use a different `pg_partition`
/// catalog and are intentionally excluded until validated.
fn supports_partition_ddl(database_type: Option<DatabaseType>) -> bool {
matches!(database_type, Some(DatabaseType::Postgres | DatabaseType::Kingbase))
}
/// Builds `CREATE TABLE ... PARTITION BY ...` for a table being created.
///
/// Kept separate from `build_create_table_sql` so the widely-used plain builder
/// keeps its signature; shares its implementation through
/// `build_create_table_sql_with_partition_clause`.
pub fn build_create_partitioned_table_sql(
options: TableStructureSqlOptions,
definition: TablePartitionDefinition,
) -> TableStructureSqlResult {
let dialect = capabilities_for(options.database_type, options.driver_profile.as_deref()).dialect;
if dialect != StructureDialect::Postgres || !supports_partition_ddl(options.database_type) {
return TableStructureSqlResult {
statements: Vec::new(),
warnings: vec!["Partitioning is not supported for this database engine.".to_string()],
};
}
let mut warnings = Vec::new();
let Some(clause) = format_partition_clause(&definition, &mut warnings) else {
return TableStructureSqlResult { statements: Vec::new(), warnings };
};
build_create_table_sql_with_partition_clause(options, Some(clause))
}
/// Renders the body of a `PARTITION BY` clause (without the leading keyword),
/// e.g. `RANGE (region, sold_on)`.
fn format_partition_clause(definition: &TablePartitionDefinition, warnings: &mut Vec<String>) -> Option<String> {
let keyword = match definition.kind {
PgPartitionKind::Range => "RANGE",
PgPartitionKind::List => "LIST",
PgPartitionKind::Hash => "HASH",
};
let expression = definition.expression.trim();
let key = if !expression.is_empty() {
expression.to_string()
} else {
let columns = definition
.columns
.iter()
.map(|column| column.trim())
.filter(|column| !column.is_empty())
.collect::<Vec<_>>();
if columns.is_empty() {
warnings.push("A partition key needs at least one column or an expression.".to_string());
return None;
}
columns.iter().map(|column| quote_ident(StructureDialect::Postgres, column)).collect::<Vec<_>>().join(", ")
};
Some(format!("PARTITION BY {keyword} ({key})"))
}
/// Builds the DDL for explicit partition maintenance operations
/// (create / attach / detach / drop).
///
/// Gated to the engines in [`supports_partition_ddl`]; a refusal empties the
/// statement list and pushes one warning. Callers surface the warning and block
/// the save, so an unsupported request never silently degrades.
pub fn build_table_partition_operation_sql(options: TablePartitionSqlOptions) -> TableStructureSqlResult {
if options.operations.is_empty() {
return TableStructureSqlResult { statements: Vec::new(), warnings: Vec::new() };
}
let dialect = capabilities_for(options.database_type, options.driver_profile.as_deref()).dialect;
if dialect != StructureDialect::Postgres || !supports_partition_ddl(options.database_type) {
return TableStructureSqlResult {
statements: Vec::new(),
warnings: vec!["Partition operations are not supported for this database engine.".to_string()],
};
}
let default_schema = options.schema.as_deref().unwrap_or("");
let mut warnings = Vec::new();
let mut statements = Vec::new();
for operation in &options.operations {
if let Some(statement) = build_operation_sql(operation, default_schema, &options.table_name, &mut warnings) {
statements.push(statement);
}
}
TableStructureSqlResult { statements, warnings }
}
fn build_operation_sql(
operation: &TablePartitionOperation,
default_schema: &str,
default_parent_table: &str,
warnings: &mut Vec<String>,
) -> Option<String> {
let dialect = StructureDialect::Postgres;
let parent_schema = pick(&operation.parent_schema, default_schema);
let parent_table = pick(&operation.parent_table, default_parent_table);
let child_schema = pick(&operation.schema, &parent_schema);
let name = operation.name.trim();
if parent_table.is_empty() {
warnings.push("A partition operation requires a parent table.".to_string());
return None;
}
if name.is_empty() {
warnings.push("A partition operation requires a partition name.".to_string());
return None;
}
let parent = qualify(dialect, &parent_schema, &parent_table);
let child = qualify(dialect, &child_schema, name);
match operation.kind {
TablePartitionOperationKind::Create => {
let bound = require_bound(operation, "create", warnings)?;
Some(format!("CREATE TABLE {child} PARTITION OF {parent} {bound};"))
}
TablePartitionOperationKind::Attach => {
let bound = require_bound(operation, "attach", warnings)?;
Some(format!("ALTER TABLE {parent} ATTACH PARTITION {child} {bound};"))
}
TablePartitionOperationKind::Detach => {
reject_bound(operation, "detach", warnings)?;
let concurrently = if operation.concurrently { " CONCURRENTLY" } else { "" };
Some(format!("ALTER TABLE {parent} DETACH PARTITION {child}{concurrently};"))
}
TablePartitionOperationKind::Drop => {
reject_bound(operation, "drop", warnings)?;
Some(format!("DROP TABLE {child};"))
}
}
}
fn require_bound(operation: &TablePartitionOperation, action: &str, warnings: &mut Vec<String>) -> Option<String> {
let Some(bound) = operation.bound.as_ref() else {
warnings.push(format!("A partition {action} operation requires a bound definition."));
return None;
};
format_bound(bound, warnings)
}
fn reject_bound(operation: &TablePartitionOperation, action: &str, warnings: &mut Vec<String>) -> Option<()> {
if operation.bound.is_some() {
warnings.push(format!("A partition {action} operation must not carry a bound definition."));
return None;
}
Some(())
}
/// Renders a bound as the `FOR VALUES ...` clause PostgreSQL expects (or a bare
/// `DEFAULT`). Returns `None` and records a warning when the bound is
/// malformed, so the caller can drop just that statement.
fn format_bound(bound: &TablePartitionBoundDraft, warnings: &mut Vec<String>) -> Option<String> {
match bound {
TablePartitionBoundDraft::Default => Some("DEFAULT".to_string()),
TablePartitionBoundDraft::Range { from, to } => {
if from.is_empty() || from.len() != to.len() {
warnings
.push("A RANGE partition bound needs a FROM and TO tuple of equal, non-zero length.".to_string());
return None;
}
let from = from.iter().map(|value| value.trim()).collect::<Vec<_>>().join(", ");
let to = to.iter().map(|value| value.trim()).collect::<Vec<_>>().join(", ");
Some(format!("FOR VALUES FROM ({from}) TO ({to})"))
}
TablePartitionBoundDraft::List { values } => {
if values.is_empty() {
warnings.push("A LIST partition bound needs at least one value.".to_string());
return None;
}
let values = values.iter().map(|value| value.trim()).collect::<Vec<_>>().join(", ");
Some(format!("FOR VALUES IN ({values})"))
}
TablePartitionBoundDraft::Hash { modulus, remainder } => {
if *modulus <= 0 || *remainder < 0 || remainder >= modulus {
warnings.push("A HASH partition bound needs MODULUS > 0 and 0 <= REMAINDER < MODULUS.".to_string());
return None;
}
Some(format!("FOR VALUES WITH (MODULUS {modulus}, REMAINDER {remainder})"))
}
}
}
fn pick(value: &str, fallback: &str) -> String {
let value = value.trim();
if value.is_empty() {
fallback.trim().to_string()
} else {
value.to_string()
}
}
fn qualify(dialect: StructureDialect, schema: &str, table: &str) -> String {
if schema.is_empty() {
quote_ident(dialect, table)
} else {
format!("{}.{}", quote_ident(dialect, schema), quote_ident(dialect, table))
}
}
@@ -1,5 +1,6 @@
use super::*;
use crate::models::connection::DatabaseType;
use crate::types::PgPartitionKind;
fn column(name: &str) -> EditableStructureColumn {
EditableStructureColumn {
@@ -8539,3 +8540,389 @@ fn postgres_expression_index_opclass_round_trips_from_indclass() {
// The bare expression text no longer carries the opclass, so it cannot be duplicated.
assert!(!sql.contains("gin_trgm_ops gin_trgm_ops"), "Opclass must not be duplicated, got: {sql}");
}
fn partition_options(
database_type: DatabaseType,
schema: Option<&str>,
table_name: &str,
operations: Vec<TablePartitionOperation>,
) -> TablePartitionSqlOptions {
TablePartitionSqlOptions {
database_type: Some(database_type),
driver_profile: None,
schema: schema.map(str::to_string),
table_name: table_name.to_string(),
operations,
}
}
fn partition_operation(
kind: TablePartitionOperationKind,
name: &str,
bound: Option<TablePartitionBoundDraft>,
) -> TablePartitionOperation {
TablePartitionOperation {
id: format!("op:{name}"),
kind,
parent_schema: String::new(),
parent_table: String::new(),
schema: String::new(),
name: name.to_string(),
bound,
concurrently: false,
}
}
#[test]
fn partition_create_uses_partition_of_with_each_bound_kind() {
let range = build_table_partition_operation_sql(partition_options(
DatabaseType::Postgres,
Some("public"),
"sales",
vec![partition_operation(
TablePartitionOperationKind::Create,
"sales_2025",
Some(TablePartitionBoundDraft::Range {
from: vec!["'2025-01-01'".to_string()],
to: vec!["'2026-01-01'".to_string()],
}),
)],
));
assert!(range.warnings.is_empty(), "{:?}", range.warnings);
assert_eq!(
range.statements,
vec!["CREATE TABLE \"public\".\"sales_2025\" PARTITION OF \"public\".\"sales\" FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');"]
);
let list = build_table_partition_operation_sql(partition_options(
DatabaseType::Postgres,
Some("public"),
"events",
vec![partition_operation(
TablePartitionOperationKind::Create,
"events_ab",
Some(TablePartitionBoundDraft::List { values: vec!["'a'".to_string(), "'b'".to_string()] }),
)],
));
assert_eq!(
list.statements,
vec!["CREATE TABLE \"public\".\"events_ab\" PARTITION OF \"public\".\"events\" FOR VALUES IN ('a', 'b');"]
);
let hash = build_table_partition_operation_sql(partition_options(
DatabaseType::Postgres,
Some("public"),
"h",
vec![partition_operation(
TablePartitionOperationKind::Create,
"h0",
Some(TablePartitionBoundDraft::Hash { modulus: 2, remainder: 0 }),
)],
));
assert_eq!(
hash.statements,
vec!["CREATE TABLE \"public\".\"h0\" PARTITION OF \"public\".\"h\" FOR VALUES WITH (MODULUS 2, REMAINDER 0);"]
);
let default = build_table_partition_operation_sql(partition_options(
DatabaseType::Postgres,
Some("public"),
"sales",
vec![partition_operation(
TablePartitionOperationKind::Create,
"sales_default",
Some(TablePartitionBoundDraft::Default),
)],
));
assert_eq!(
default.statements,
vec!["CREATE TABLE \"public\".\"sales_default\" PARTITION OF \"public\".\"sales\" DEFAULT;"]
);
}
#[test]
fn partition_attach_detach_and_drop_generate_explicit_statements() {
let attach = build_table_partition_operation_sql(partition_options(
DatabaseType::Postgres,
Some("public"),
"sales",
vec![partition_operation(
TablePartitionOperationKind::Attach,
"sales_2025",
Some(TablePartitionBoundDraft::Range {
from: vec!["MINVALUE".to_string()],
to: vec!["'2025-01-01'".to_string()],
}),
)],
));
assert_eq!(
attach.statements,
vec!["ALTER TABLE \"public\".\"sales\" ATTACH PARTITION \"public\".\"sales_2025\" FOR VALUES FROM (MINVALUE) TO ('2025-01-01');"]
);
let detach = build_table_partition_operation_sql(partition_options(
DatabaseType::Postgres,
Some("public"),
"sales",
vec![partition_operation(TablePartitionOperationKind::Detach, "sales_2024", None)],
));
assert_eq!(detach.statements, vec!["ALTER TABLE \"public\".\"sales\" DETACH PARTITION \"public\".\"sales_2024\";"]);
let mut concurrent = partition_operation(TablePartitionOperationKind::Detach, "sales_2024", None);
concurrent.concurrently = true;
let detach_concurrently = build_table_partition_operation_sql(partition_options(
DatabaseType::Postgres,
Some("public"),
"sales",
vec![concurrent],
));
assert_eq!(
detach_concurrently.statements,
vec!["ALTER TABLE \"public\".\"sales\" DETACH PARTITION \"public\".\"sales_2024\" CONCURRENTLY;"]
);
let drop = build_table_partition_operation_sql(partition_options(
DatabaseType::Postgres,
Some("public"),
"sales",
vec![partition_operation(TablePartitionOperationKind::Drop, "sales_2024", None)],
));
assert_eq!(drop.statements, vec!["DROP TABLE \"public\".\"sales_2024\";"]);
}
#[test]
fn partition_operation_can_target_an_explicit_parent_and_schema() {
// Detaching the partition currently being edited: the parent is not the
// edited table, and the child lives in another schema.
let mut detach = partition_operation(TablePartitionOperationKind::Detach, "sales_2024", None);
detach.parent_schema = "analytics".to_string();
detach.parent_table = "sales".to_string();
detach.schema = "archive".to_string();
let result = build_table_partition_operation_sql(partition_options(
DatabaseType::Postgres,
Some("public"),
"sales_2024",
vec![detach],
));
assert_eq!(
result.statements,
vec!["ALTER TABLE \"analytics\".\"sales\" DETACH PARTITION \"archive\".\"sales_2024\";"]
);
}
#[test]
fn partition_operations_are_refused_for_non_postgres() {
let result = build_table_partition_operation_sql(partition_options(
DatabaseType::Mysql,
Some("db"),
"sales",
vec![partition_operation(
TablePartitionOperationKind::Create,
"sales_2025",
Some(TablePartitionBoundDraft::Default),
)],
));
assert!(result.statements.is_empty());
assert_eq!(result.warnings, vec!["Partition operations are not supported for this database engine.".to_string()]);
}
#[test]
fn partition_operations_are_supported_for_kingbase() {
let result = build_table_partition_operation_sql(partition_options(
DatabaseType::Kingbase,
Some("public"),
"sales",
vec![partition_operation(
TablePartitionOperationKind::Create,
"sales_2025",
Some(TablePartitionBoundDraft::Default),
)],
));
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
assert_eq!(
result.statements,
vec!["CREATE TABLE \"public\".\"sales_2025\" PARTITION OF \"public\".\"sales\" DEFAULT;"]
);
}
#[test]
fn partition_operations_warn_on_malformed_bounds() {
let mismatched = build_table_partition_operation_sql(partition_options(
DatabaseType::Postgres,
Some("public"),
"sales",
vec![partition_operation(
TablePartitionOperationKind::Create,
"sales_x",
Some(TablePartitionBoundDraft::Range {
from: vec!["1".to_string()],
to: vec!["2".to_string(), "3".to_string()],
}),
)],
));
assert!(mismatched.statements.is_empty());
assert!(mismatched.warnings[0].contains("RANGE partition bound"));
let bad_hash = build_table_partition_operation_sql(partition_options(
DatabaseType::Postgres,
Some("public"),
"h",
vec![partition_operation(
TablePartitionOperationKind::Create,
"h9",
Some(TablePartitionBoundDraft::Hash { modulus: 2, remainder: 2 }),
)],
));
assert!(bad_hash.statements.is_empty());
assert!(bad_hash.warnings[0].contains("HASH partition bound"));
let detach_with_bound = build_table_partition_operation_sql(partition_options(
DatabaseType::Postgres,
Some("public"),
"sales",
vec![partition_operation(
TablePartitionOperationKind::Detach,
"sales_2024",
Some(TablePartitionBoundDraft::Default),
)],
));
assert!(detach_with_bound.statements.is_empty());
assert!(detach_with_bound.warnings[0].contains("must not carry a bound"));
}
#[test]
fn partition_operation_quotes_identifiers() {
let result = build_table_partition_operation_sql(partition_options(
DatabaseType::Postgres,
Some("we\"ird"),
"sales",
vec![partition_operation(
TablePartitionOperationKind::Create,
"part\"1",
Some(TablePartitionBoundDraft::Default),
)],
));
assert_eq!(
result.statements,
vec!["CREATE TABLE \"we\"\"ird\".\"part\"\"1\" PARTITION OF \"we\"\"ird\".\"sales\" DEFAULT;"]
);
}
#[test]
fn create_partitioned_table_appends_partition_by_clause() {
let column = EditableStructureColumn {
id: "sold_on".to_string(),
name: "sold_on".to_string(),
data_type: "date".to_string(),
is_nullable: false,
default_value: String::new(),
comment: String::new(),
is_primary_key: false,
extra: None,
original: None,
original_position: None,
marked_for_drop: false,
character_set: String::new(),
collation: String::new(),
};
let options = structure_change_options(DatabaseType::Postgres, Some("public"), "sales", vec![column]);
let result = build_create_partitioned_table_sql(
options,
TablePartitionDefinition {
kind: PgPartitionKind::Range,
columns: vec!["sold_on".to_string()],
expression: String::new(),
},
);
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
assert_eq!(result.statements.len(), 1);
assert!(
result.statements[0].ends_with(") PARTITION BY RANGE (\"sold_on\");"),
"unexpected statement: {}",
result.statements[0]
);
}
#[test]
fn create_partitioned_table_uses_expression_and_multi_column_keys() {
let options = structure_change_options(
DatabaseType::Postgres,
Some("public"),
"events",
vec![column("region"), column("occurred_at")],
);
let expression = build_create_partitioned_table_sql(
options,
TablePartitionDefinition {
kind: PgPartitionKind::Hash,
columns: Vec::new(),
expression: "abs(id)".to_string(),
},
);
assert!(expression.statements[0].ends_with(") PARTITION BY HASH (abs(id));"), "{}", expression.statements[0]);
let options = structure_change_options(
DatabaseType::Postgres,
Some("public"),
"events",
vec![column("region"), column("occurred_at")],
);
let multi = build_create_partitioned_table_sql(
options,
TablePartitionDefinition {
kind: PgPartitionKind::List,
columns: vec!["region".to_string(), "occurred_at".to_string()],
expression: String::new(),
},
);
assert!(
multi.statements[0].ends_with(") PARTITION BY LIST (\"region\", \"occurred_at\");"),
"{}",
multi.statements[0]
);
}
#[test]
fn create_partitioned_table_requires_a_key_and_postgres() {
let options = structure_change_options(DatabaseType::Postgres, Some("public"), "events", vec![column("id")]);
let missing_key = build_create_partitioned_table_sql(
options,
TablePartitionDefinition { kind: PgPartitionKind::Range, columns: Vec::new(), expression: String::new() },
);
assert!(missing_key.statements.is_empty());
assert!(missing_key.warnings[0].contains("at least one column"));
let options = structure_change_options(DatabaseType::Mysql, Some("db"), "events", vec![column("id")]);
let mysql = build_create_partitioned_table_sql(
options,
TablePartitionDefinition {
kind: PgPartitionKind::Range,
columns: vec!["id".to_string()],
expression: String::new(),
},
);
assert!(mysql.statements.is_empty());
assert_eq!(mysql.warnings, vec!["Partitioning is not supported for this database engine.".to_string()]);
}
#[test]
fn create_partitioned_table_is_supported_for_kingbase() {
let options = structure_change_options(DatabaseType::Kingbase, Some("public"), "events", vec![column("region")]);
let result = build_create_partitioned_table_sql(
options,
TablePartitionDefinition {
kind: PgPartitionKind::List,
columns: vec!["region".to_string()],
expression: String::new(),
},
);
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
assert!(result.statements[0].ends_with(") PARTITION BY LIST (\"region\");"), "{}", result.statements[0]);
}
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::models::connection::DatabaseType;
use crate::types::PgPartitionKind;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -258,6 +259,90 @@ pub struct TableStructureSqlOptions {
pub is_gaussdb_m_mode: bool,
}
/// Options for `build_table_partition_operation_sql`.
///
/// Partition maintenance is deliberately kept out of `TableStructureSqlOptions`:
/// the operations are explicit user actions (never diffed against introspected
/// state), and keeping them separate means a column/index draft cannot
/// accidentally trigger or suppress partition DDL.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TablePartitionSqlOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database_type: Option<DatabaseType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub driver_profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
/// The edited table; also the default parent of every operation.
pub table_name: String,
#[serde(default)]
pub operations: Vec<TablePartitionOperation>,
}
/// Declarative partitioning for a table being created (`CREATE TABLE ...
/// PARTITION BY ...`). PostgreSQL only.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TablePartitionDefinition {
pub kind: PgPartitionKind,
/// Partition-key columns, used when `expression` is empty.
#[serde(default)]
pub columns: Vec<String>,
/// Raw partition-key expression (e.g. `date_trunc('month', ts)`), used
/// instead of `columns` when non-empty.
#[serde(default)]
pub expression: String,
}
/// One partition maintenance operation. The parent is the edited table unless
/// `parent_table` names another relation (used when detaching the partition
/// currently being edited).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TablePartitionOperation {
#[serde(default)]
pub id: String,
pub kind: TablePartitionOperationKind,
/// Partitioned parent's schema; empty falls back to the edited table's schema.
#[serde(default)]
pub parent_schema: String,
/// Partitioned parent; empty falls back to the edited table.
#[serde(default)]
pub parent_table: String,
/// Schema of the partition relation; empty falls back to the parent's schema.
#[serde(default)]
pub schema: String,
pub name: String,
/// Required for `create`/`attach`; must be absent for `detach`/`drop`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bound: Option<TablePartitionBoundDraft>,
/// Emit `DETACH PARTITION ... CONCURRENTLY` (PostgreSQL 14+).
#[serde(default)]
pub concurrently: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TablePartitionOperationKind {
Create,
Attach,
Detach,
Drop,
}
/// Partition bound as entered in the UI. Values are SQL literal text; the
/// builder only wraps them in the PostgreSQL `FOR VALUES` grammar, so `1`,
/// `'2024-01-01'`, `MINVALUE`, and `MAXVALUE` all round-trip unchanged.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TablePartitionBoundDraft {
Range { from: Vec<String>, to: Vec<String> },
List { values: Vec<String> },
Hash { modulus: i32, remainder: i32 },
Default,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TableStructureSqlResult {
+94
View File
@@ -715,6 +715,100 @@ pub struct SubpartitionInfo {
pub partition_key: String,
}
/// PostgreSQL declarative partitioning strategy (`pg_partitioned_table.partstrat`:
/// `r` = range, `l` = list, `h` = hash).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PgPartitionKind {
Range,
List,
Hash,
}
/// Structured form of a partition's `pg_get_expr(relpartbound)` definition.
///
/// Values are kept as SQL literal text (`'2024-01-01'`, `1`, `MINVALUE`,
/// `MAXVALUE`, `'a'`) so the UI can round-trip exactly what PostgreSQL
/// reported without re-typing or re-quoting them client-side.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum PgPartitionBound {
Range { from: Vec<String>, to: Vec<String> },
List { values: Vec<String> },
Hash { modulus: i32, remainder: i32 },
Default,
}
/// One relation in a PostgreSQL partition hierarchy, as returned by
/// `get_table_partitioning_core`. `children` is nested to the same depth as
/// the catalog's partition tree (PG supports multi-level partitioning).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PgPartitionNode {
pub schema: String,
pub name: String,
/// Set only when this node is itself a partitioned parent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strategy: Option<PgPartitionKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_definition: Option<String>,
/// This node's own partition bound (set for every non-root node).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bound: Option<PgPartitionBound>,
/// Raw `pg_get_expr(relpartbound)` text; retained as a display fallback
/// when `bound` could not be parsed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bound_definition: Option<String>,
#[serde(default)]
pub is_leaf: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub row_estimate: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_bytes: Option<i64>,
#[serde(default)]
pub children: Vec<PgPartitionNode>,
}
/// Structured partitioning view of one PostgreSQL relation, used by the table
/// structure editor's "Partitions" tab. Works for the partitioned parent
/// (`is_partitioned`), for a member partition (`is_partition`), and reports
/// the whole subtree rooted at the requested relation either way.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PgTablePartitioning {
pub is_partitioned: bool,
pub is_partition: bool,
/// Display form `"schema.table"` of the owning parent, when this relation
/// is itself a partition.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
/// Parent parts, kept separate so a dotted identifier cannot be mis-split.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_schema: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_table: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub own_bound: Option<PgPartitionBound>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strategy: Option<PgPartitionKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_definition: Option<String>,
#[serde(default)]
pub key_columns: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_expression: Option<String>,
/// Name of the default partition, when the parent has one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_partition: Option<String>,
/// Descendant partitions (the root itself is not included).
#[serde(default)]
pub partitions: Vec<PgPartitionNode>,
/// Server version (`current_setting('server_version_num')`), used by the UI
/// to gate `DETACH PARTITION CONCURRENTLY` (PostgreSQL 14+).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_version_num: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FunctionInfo {
+3
View File
@@ -519,6 +519,7 @@ async fn main() {
.route("/schema/constraints", get(routes::schema::list_constraints))
.route("/schema/partitions", get(routes::schema::list_partitions))
.route("/schema/table-partition-status", get(routes::schema::get_table_partition_status))
.route("/schema/table-partitioning", get(routes::schema::get_table_partitioning))
.route("/schema/invalid-indexes", get(routes::schema::list_invalid_indexes))
.route("/schema/subpartitions", get(routes::schema::list_subpartitions))
.route("/schema/functions", get(routes::schema::list_functions))
@@ -604,6 +605,8 @@ async fn main() {
.route("/query/build-view-ddl-sql", post(routes::query::build_view_ddl_sql))
.route("/query/build-table-structure-change-sql", post(routes::query::build_table_structure_change_sql))
.route("/query/build-table-owner-change-sql", post(routes::query::build_table_owner_change_sql))
.route("/query/build-table-partition-operation-sql", post(routes::query::build_table_partition_operation_sql))
.route("/query/build-create-partitioned-table-sql", post(routes::query::build_create_partitioned_table_sql))
.route(
"/query/preview-sqlite-table-structure-change",
post(routes::query::preview_sqlite_table_structure_change),
+34 -1
View File
@@ -75,6 +75,9 @@ pub struct ExecuteBatchRequest {
pub catalog: Option<String>,
pub timeout_secs: Option<u64>,
pub destructive_confirmed: Option<bool>,
/// Opt-in single transaction for the whole batch (see
/// [`dbx_core::query::execute_statements_with_transaction_option`]).
pub use_transaction: Option<bool>,
}
#[derive(Deserialize)]
@@ -273,6 +276,19 @@ pub struct BuildTableOwnerChangeSqlRequest {
pub options: dbx_core::table_structure_sql::TableOwnerChangeSqlOptions,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildTablePartitionOperationSqlRequest {
pub options: dbx_core::table_structure_sql::TablePartitionSqlOptions,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildCreatePartitionedTableSqlRequest {
pub options: dbx_core::table_structure_sql::TableStructureSqlOptions,
pub partitioning: dbx_core::table_structure_sql::TablePartitionDefinition,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PreviewSqliteTableStructureChangeRequest {
@@ -625,12 +641,13 @@ pub async fn execute_batch(
super::mcp_policy::ensure_sql(&state, &headers, &req.connection_id, &database, statement, false).await?;
}
tracing::debug!(connection_id = %req.connection_id, "execute_batch");
let result = dbx_core::query::execute_statements(
let result = dbx_core::query::execute_statements_with_transaction_option(
&state.app,
&req.connection_id,
&database,
&req.statements,
req.schema.as_deref(),
req.use_transaction == Some(true),
req.timeout_secs,
)
.await
@@ -1027,6 +1044,18 @@ pub async fn build_table_owner_change_sql(
Json(dbx_core::table_structure_sql::build_table_owner_change_sql(req.options))
}
pub async fn build_table_partition_operation_sql(
Json(req): Json<BuildTablePartitionOperationSqlRequest>,
) -> Json<dbx_core::table_structure_sql::TableStructureSqlResult> {
Json(dbx_core::table_structure_sql::build_table_partition_operation_sql(req.options))
}
pub async fn build_create_partitioned_table_sql(
Json(req): Json<BuildCreatePartitionedTableSqlRequest>,
) -> Json<dbx_core::table_structure_sql::TableStructureSqlResult> {
Json(dbx_core::table_structure_sql::build_create_partitioned_table_sql(req.options, req.partitioning))
}
pub async fn preview_sqlite_table_structure_change(
State(state): State<Arc<WebState>>,
Json(req): Json<PreviewSqliteTableStructureChangeRequest>,
@@ -1267,6 +1296,7 @@ mod tests {
catalog: None,
timeout_secs: None,
destructive_confirmed: None,
use_transaction: None,
};
let result = execute_script_with_2pc(AxumState(state), Json(req))
@@ -1292,6 +1322,7 @@ mod tests {
catalog: None,
timeout_secs: None,
destructive_confirmed: None,
use_transaction: None,
};
let result = execute_script_with_2pc(AxumState(state), Json(req)).await.expect("empty deploy should succeed");
@@ -1314,6 +1345,7 @@ mod tests {
catalog: None,
timeout_secs: None,
destructive_confirmed: None,
use_transaction: None,
};
let result = execute_script_with_2pc(AxumState(state), Json(req))
@@ -1338,6 +1370,7 @@ mod tests {
catalog: None,
timeout_secs: None,
destructive_confirmed: None,
use_transaction: None,
};
let result = execute_script_with_2pc(AxumState(state), Json(req))
+13
View File
@@ -703,6 +703,19 @@ pub async fn get_table_partition_status(
.map_err(AppError::from)
}
pub async fn get_table_partitioning(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<dbx_core::db::PgTablePartitioning>, AppError> {
let database = q.database.as_deref().unwrap_or("");
let schema = q.schema.as_deref().unwrap_or("");
let table = q.table.as_deref().unwrap_or("");
dbx_core::schema::get_table_partitioning_core(&state.app, &q.connection_id, database, schema, table)
.await
.map(Json)
.map_err(AppError::from)
}
pub async fn list_invalid_indexes(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,