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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user