feat(pg,kingbase): partition table operations and detail queries
This commit is contained in:
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",
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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, ¶ms),
|
||||
)
|
||||
.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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in new issue
Block a user