fix(postgres): export enum types and preserve index ordering

This commit is contained in:
二丫讲梵
2026-09-30 01:00:07 +08:00
committed by GitHub
parent a41aec09eb
commit 26ab78f2d3
4 changed files with 197 additions and 13 deletions
@@ -187,8 +187,19 @@ public final class DdlBuilder {
String unique = index.getIs_unique() ? "UNIQUE " : "";
String using = notBlank(index.getIndex_type()) ? " USING " + index.getIndex_type() : "";
List<String> quotedColumns = new ArrayList<>();
for (String column : index.getColumns()) {
quotedColumns.add(quoteIdent(column, useBacktick));
for (int columnIndex = 0; columnIndex < index.getColumns().size(); columnIndex++) {
String column = index.getColumns().get(columnIndex);
String renderedColumn = quoteIdent(column, useBacktick);
List<Integer> keyOptions = index.getKey_options();
if (!useBacktick
&& "btree".equalsIgnoreCase(index.getIndex_type())
&& keyOptions != null
&& columnIndex < keyOptions.size()) {
int options = keyOptions.get(columnIndex);
renderedColumn += (options & 1) != 0 ? " DESC" : " ASC";
renderedColumn += (options & 2) != 0 ? " NULLS FIRST" : " NULLS LAST";
}
quotedColumns.add(renderedColumn);
}
String filter = notBlank(index.getFilter()) ? " WHERE " + index.getFilter() : "";
ddl.append("\nCREATE ");
@@ -625,6 +625,33 @@ class CommonJavaCompatibilityTest {
);
}
@Test
void buildsPostgresIndexDdlWithPerKeyOrderingOptions() {
IndexInfo index = new IndexInfo(
"orders_id_order_idx",
Arrays.asList("id", "created_at"),
false,
false,
null,
"btree",
null,
null
);
index.setKey_options(Arrays.asList(0, 3));
String ddl = DdlBuilder.buildTableDdl(
"public",
"orders",
Collections.singletonList(new ColumnInfo("id", "bigint", false, null, false)),
Collections.singletonList(index),
Collections.emptyList()
);
assertTrue(ddl.contains(
"USING btree (\"id\" ASC NULLS LAST, \"created_at\" DESC NULLS FIRST)"
));
}
@Test
void databaseAgentDefaultConstraintsFilterLegacyMetadataOverrides() {
DatabaseAgent agent = new LegacyObjectTypeAgent();
+122 -11
View File
@@ -222,6 +222,7 @@ struct DatabaseExportObjectCounts {
views: usize,
sequences: usize,
extensions: usize,
enums: usize,
procedures: usize,
functions: usize,
triggers: usize,
@@ -258,7 +259,7 @@ fn database_export_total_objects(request: &DatabaseExportRequest, counts: &Datab
total += counts.tables;
}
if request.include_structure {
total += counts.sequences + counts.extensions;
total += counts.sequences + counts.extensions + counts.enums;
}
if request.include_objects {
total += counts.views;
@@ -462,6 +463,13 @@ struct PostgresExportExtension {
schema: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PostgresExportEnum {
name: String,
schema: String,
labels: Vec<String>,
}
#[derive(Debug, Default)]
struct PostgresExtensionMembers {
relation_names: HashSet<String>,
@@ -1914,6 +1922,17 @@ fn generate_postgres_extension_ddl(extension: &PostgresExportExtension) -> Strin
)
}
fn generate_postgres_enum_ddl(enum_type: &PostgresExportEnum) -> String {
let labels =
enum_type.labels.iter().map(|label| quote_postgres_string_literal(label)).collect::<Vec<_>>().join(", ");
format!(
"CREATE TYPE {}.{} AS ENUM ({});",
quote_identifier(&enum_type.schema, &DatabaseType::Postgres),
quote_identifier(&enum_type.name, &DatabaseType::Postgres),
labels
)
}
async fn list_postgres_extension_members(
state: &crate::connection::AppState,
pool_key: &str,
@@ -1937,6 +1956,40 @@ async fn list_postgres_extension_members(
Ok(members)
}
const POSTGRES_EXPORT_ENUMS_SQL: &str = "SELECT t.typname, \
COALESCE(array_to_json(array_agg(e.enumlabel ORDER BY e.enumsortorder))::text, '[]') \
FROM pg_catalog.pg_type t \
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace \
LEFT JOIN pg_catalog.pg_enum e ON e.enumtypid = t.oid \
WHERE n.nspname = $1 AND t.typtype = 'e' \
GROUP BY t.typname \
ORDER BY t.typname";
async fn list_postgres_export_enums(
state: &crate::connection::AppState,
pool_key: &str,
schema: &str,
) -> Result<Vec<PostgresExportEnum>, String> {
let pool = {
let pool_handle = state.pool_handle(pool_key).await;
match pool_handle.as_ref() {
Some(crate::connection::PoolKind::Postgres(pool)) => pool.clone(),
_ => return Ok(Vec::new()),
}
};
let client = pool.get().await.map_err(|e| e.to_string())?;
let rows = client.query(POSTGRES_EXPORT_ENUMS_SQL, &[&schema]).await.map_err(|e| e.to_string())?;
rows.into_iter()
.map(|row| {
let name: String = row.get(0);
let labels_json: String = row.get(1);
let labels = serde_json::from_str::<Vec<String>>(&labels_json)
.map_err(|e| format!("invalid PostgreSQL enum labels for {name}: {e}"))?;
Ok(PostgresExportEnum { name, schema: schema.to_string(), labels })
})
.collect()
}
fn is_postgres_extension_member_routine(object: &crate::types::ObjectInfo, members: &PostgresExtensionMembers) -> bool {
members.function_keys.contains(&(object.name.clone(), object.signature.clone().unwrap_or_default()))
}
@@ -3503,6 +3556,28 @@ async fn export_database_sql_core_inner(
} else {
Vec::new()
};
let postgres_enums = if request.include_structure && matches!(db_type, DatabaseType::Postgres) {
match await_export_operation(
&request.export_id,
Box::pin(list_postgres_export_enums(state, &pool_key, &request.schema)),
)
.await
{
Ok(enums) => enums,
Err(e) if e == EXPORT_CANCELLED_ERROR => return Err(EXPORT_CANCELLED_ERROR.to_string()),
Err(e) => {
record_export_error(
&mut file,
request.fail_on_error,
format!("exporting enums: {e}"),
&mut lenient_errors,
)?;
Vec::new()
}
}
} else {
Vec::new()
};
let all_tables = filter_export_table_infos(all_tables, &request.selected_tables, &request.excluded_tables)
.into_iter()
.filter(|table| !postgres_extension_members.relation_names.contains(&table.name))
@@ -3632,6 +3707,7 @@ async fn export_database_sql_core_inner(
views: views.len(),
sequences: postgres_sequences.len(),
extensions: postgres_extensions.len(),
enums: postgres_enums.len(),
procedures: procedures.len(),
functions: functions.len(),
triggers: triggers.len(),
@@ -3793,6 +3869,24 @@ async fn export_database_sql_core_inner(
object_index += 1;
}
for enum_type in &postgres_enums {
if is_export_cancelled(&request.export_id).await {
return Err("Export cancelled".to_string());
}
emit_database_export_running(
&on_progress,
&request.export_id,
enum_type.name.clone(),
object_index,
total_objects,
total_rows_exported,
false,
);
writeln!(file, "{}\n", generate_postgres_enum_ddl(enum_type))
.map_err(|e| format!("Failed to write file: {e}"))?;
object_index += 1;
}
for sequence in postgres_sequences.iter().filter(|sequence| sequence.owner_table.is_none()) {
if is_export_cancelled(&request.export_id).await {
return Err("Export cancelled".to_string());
@@ -4492,13 +4586,14 @@ mod tests {
database_export_total_objects, drop_table_if_exists_sql, ensure_export_destination_dir,
export_destination_identity_mismatch, filter_export_table_infos, format_export_sql_literal,
format_export_table_ddl, format_mysql_spatial_export_literal, format_xugu_spatial_export_literal,
generate_postgres_extension_ddl, generate_postgres_sequence_create_ddl, generate_postgres_sequence_owner_ddl,
generate_postgres_sequence_setval_sql, is_postgres_extension_member_routine, mysql_database_export_preamble,
mysql_view_dependencies_from_rows, mysql_view_dependencies_sql, normalize_export_table_ddl,
record_export_destination_identity, record_export_error, replace_database_export_select_list,
sort_export_views_by_dependencies, split_postgres_export_table_triggers, write_database_export_rows,
BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions, BuildExportSqlInsertOptions,
DatabaseExportObjectCounts, DatabaseExportRequest, DatabaseExportWriter, DdlNormalizeOptions, ExportedTableSql,
generate_postgres_enum_ddl, generate_postgres_extension_ddl, generate_postgres_sequence_create_ddl,
generate_postgres_sequence_owner_ddl, generate_postgres_sequence_setval_sql,
is_postgres_extension_member_routine, mysql_database_export_preamble, mysql_view_dependencies_from_rows,
mysql_view_dependencies_sql, normalize_export_table_ddl, record_export_destination_identity,
record_export_error, replace_database_export_select_list, sort_export_views_by_dependencies,
split_postgres_export_table_triggers, write_database_export_rows, BuildDatabaseSqlExportOptions,
BuildExportInsertStatementsOptions, BuildExportSqlInsertOptions, DatabaseExportObjectCounts,
DatabaseExportRequest, DatabaseExportWriter, DdlNormalizeOptions, ExportedTableSql, PostgresExportEnum,
PostgresExportExtension, PostgresExportSequence, PostgresExtensionMembers, SqlInsertDialect,
DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT, POSTGRES_EXPORT_SEQUENCES_COMPAT_SQL,
POSTGRES_EXPORT_SEQUENCES_SQL,
@@ -4807,6 +4902,7 @@ mod tests {
views: 1,
sequences: 2,
extensions: 1,
enums: 1,
procedures: 1,
functions: 1,
triggers: 2,
@@ -4814,10 +4910,10 @@ mod tests {
};
let cases = [
("structure", export_request(true, false, false, Vec::new()), 5),
("structure", export_request(true, false, false, Vec::new()), 6),
("data", export_request(false, true, false, Vec::new()), 2),
("objects", export_request(false, false, true, Vec::new()), 6),
("all", export_request(true, true, true, Vec::new()), 11),
("all", export_request(true, true, true, Vec::new()), 12),
("nothing", export_request(false, false, false, Vec::new()), 0),
];
@@ -4847,6 +4943,7 @@ mod tests {
views: 1,
sequences: 1,
extensions: 1,
enums: 1,
procedures: 4,
functions: 5,
triggers: 2,
@@ -4854,7 +4951,7 @@ mod tests {
};
let request = export_request(true, true, true, vec!["users".to_string(), "active_users".to_string()]);
assert_eq!(database_export_total_objects(&request, &counts), 4);
assert_eq!(database_export_total_objects(&request, &counts), 5);
}
#[test]
@@ -4912,6 +5009,20 @@ mod tests {
assert!(!ddl.contains("VERSION"));
}
#[test]
fn postgres_enum_ddl_preserves_schema_order_and_escapes_labels() {
let enum_type = PostgresExportEnum {
name: "status\"type".to_string(),
schema: "app".to_string(),
labels: vec!["pending".to_string(), "it's\\ready".to_string(), "已完成".to_string()],
};
assert_eq!(
generate_postgres_enum_ddl(&enum_type),
"CREATE TYPE \"app\".\"status\"\"type\" AS ENUM ('pending', E'it''s\\\\ready', '已完成');"
);
}
#[test]
fn postgres_all_schema_export_filters_system_schemas_and_deduplicates() {
assert_eq!(
+35
View File
@@ -12480,6 +12480,32 @@ mod ddl_tests {
);
}
#[test]
fn postgres_table_ddl_preserves_btree_key_order_and_nulls_position() {
let id = column("id", "bigint");
let indexes = vec![db::IndexInfo {
name: "users_id_order_idx".to_string(),
columns: vec!["id".to_string(), "id".to_string()],
is_unique: false,
is_primary: false,
filter: None,
index_type: Some("btree".to_string()),
included_columns: None,
comment: None,
key_is_expression: vec![false, false],
column_opclasses: vec![None, None],
key_options: vec![0, 3],
constraint_backed: false,
}];
let ddl = render_postgres_table_ddl("public", "users", &[id], &indexes, &[], None);
assert!(
ddl.contains("USING btree (\"id\" ASC NULLS LAST, \"id\" DESC NULLS FIRST)"),
"expected per-key ordering and NULLS placement, got: {ddl}"
);
}
#[test]
fn postgres_table_ddl_renders_partition_children_and_subpartitions() {
let mut id = column("id", "integer");
@@ -14771,6 +14797,15 @@ fn render_postgres_table_ddl_with_constraints_and_partition_info(
if let Some(opclass) = idx.column_opclasses.get(i).and_then(|o| o.as_deref()) {
col.push_str(&format!(" {opclass}"));
}
if idx.index_type.as_deref().is_some_and(|index_type| index_type.eq_ignore_ascii_case("btree")) {
if let Some(options) = idx.key_options.get(i) {
col.push_str(&format!(
" {} NULLS {}",
if options & 1 != 0 { "DESC" } else { "ASC" },
if options & 2 != 0 { "FIRST" } else { "LAST" }
));
}
}
col
})
.collect::<Vec<_>>()