fix(postgres): export sequences with database dump

This commit is contained in:
t8y2 2026-06-22 16:47:10 +08:00
parent 09d54d09bc
commit df94a7c1bf
9 changed files with 310 additions and 12 deletions

View File

@ -418,7 +418,7 @@ watch(
<!-- Options -->
<div class="space-y-2.5 pt-1">
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{{ t("settings.options") }}
{{ t("databaseExport.options") }}
</div>
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="includeStructure = !includeStructure">
<CheckSquare v-if="includeStructure" class="w-3.5 h-3.5 text-primary shrink-0" />

View File

@ -2672,10 +2672,11 @@ export default {
},
databaseExport: {
title: "Export Database",
options: "Options",
includeStructure: "Table structure (DDL)",
dropTableIfExists: "Add DROP TABLE IF EXISTS before DDL",
includeData: "Table data (INSERT)",
includeObjects: "Views / Procedures / Functions",
includeObjects: "Views / Procedures / Functions / Sequences",
tableSelection: "Tables",
selectedTables: "{selected}/{total} selected",
selectAllTables: "Select all",

View File

@ -2320,10 +2320,11 @@ export default {
},
databaseExport: {
title: "Exportar base de datos",
options: "Opciones",
includeStructure: "Estructura de tablas (DDL)",
dropTableIfExists: "Agregar DROP TABLE IF EXISTS antes del DDL",
includeData: "Datos de tablas (INSERT)",
includeObjects: "Vistas / Procedimientos / Funciones",
includeObjects: "Vistas / Procedimientos / Funciones / Secuencias",
tableSelection: "Tablas",
selectedTables: "{selected}/{total} seleccionadas",
selectAllTables: "Seleccionar todo",

View File

@ -2395,10 +2395,11 @@ export default {
},
databaseExport: {
title: "Esporta Database",
options: "Opzioni",
includeStructure: "Struttura tabella (DDL)",
dropTableIfExists: "Aggiungi DROP TABLE IF EXISTS prima del DDL",
includeData: "Dati tabella (INSERT)",
includeObjects: "Viste / Procedure / Funzioni",
includeObjects: "Viste / Procedure / Funzioni / Sequenze",
tableSelection: "Tabelle",
selectedTables: "{selected}/{total} selezionate",
selectAllTables: "Seleziona tutte",

View File

@ -2626,10 +2626,11 @@ export default {
},
databaseExport: {
title: "データベースをエクスポート",
options: "オプション",
includeStructure: "テーブル構造 (DDL)",
dropTableIfExists: "DDLの前にDROP TABLE IF EXISTSを追加",
includeData: "テーブルデータ (INSERT)",
includeObjects: "ビュー / プロシージャ / 関数",
includeObjects: "ビュー / プロシージャ / 関数 / シーケンス",
tableSelection: "テーブル",
selectedTables: "{selected}/{total}件選択中",
selectAllTables: "すべて選択",

View File

@ -2406,10 +2406,11 @@ export default {
},
databaseExport: {
title: "Exportar banco de dados",
options: "Opções",
includeStructure: "Estrutura da tabela (DDL)",
dropTableIfExists: "Adicionar DROP TABLE IF EXISTS antes do DDL",
includeData: "Dados da tabela (INSERT)",
includeObjects: "Views / Procedures / Funções",
includeObjects: "Views / Procedures / Funções / Sequências",
tableSelection: "Tabelas",
selectedTables: "{selected}/{total} selecionadas",
selectAllTables: "Selecionar todas",

View File

@ -2696,10 +2696,11 @@ export default {
},
databaseExport: {
title: "导出数据库",
options: "选项",
includeStructure: "表结构 (DDL)",
dropTableIfExists: "导出前添加 DROP TABLE IF EXISTS",
includeData: "表数据 (INSERT)",
includeObjects: "视图 / 存储过程 / 函数",
includeObjects: "视图 / 存储过程 / 函数 / 序列",
tableSelection: "选择表",
selectedTables: "已选择 {selected}/{total}",
selectAllTables: "全选",

View File

@ -2405,10 +2405,11 @@ export default {
},
databaseExport: {
title: "匯出資料庫",
options: "選項",
includeStructure: "資料表結構 (DDL)",
dropTableIfExists: "匯出前新增 DROP TABLE IF EXISTS",
includeData: "資料表資料 (INSERT)",
includeObjects: "檢視 / 預存程序 / 函式",
includeObjects: "檢視 / 預存程序 / 函式 / 序列",
tableSelection: "資料表",
selectedTables: "已選擇 {selected}/{total}",
selectAllTables: "全選",

View File

@ -6,7 +6,7 @@ use tokio::sync::RwLock;
use crate::models::connection::DatabaseType;
use crate::sql_dialect::{qualified_table_name, quote_table_identifier};
use crate::transfer::{format_ch_array_sql_literal, format_pg_array_sql_literal};
use crate::transfer::{format_ch_array_sql_literal, format_pg_array_sql_literal, quote_identifier};
static EXPORT_CANCELLED: std::sync::LazyLock<RwLock<HashSet<String>>> =
std::sync::LazyLock::new(|| RwLock::new(HashSet::new()));
@ -55,6 +55,21 @@ pub const DATABASE_EXPORT_ROW_LIMIT: usize = 10_000;
pub const DATABASE_EXPORT_PAGE_SIZE: usize = 500;
pub const DATABASE_EXPORT_INSERT_BATCH_SIZE: usize = 100;
#[derive(Debug, Clone, PartialEq, Eq)]
struct PostgresExportSequence {
name: String,
data_type: String,
start_value: String,
min_value: String,
max_value: String,
increment: String,
cycle: bool,
cache_value: String,
last_value: Option<String>,
owner_table: Option<String>,
owner_column: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportedTableSql {
@ -354,6 +369,156 @@ fn normalize_export_table_ddl(ddl: &str, database_type: Option<DatabaseType>) ->
LEGACY_MYSQL_ROW_FORMAT_RE.replace_all(ddl, "ROW_FORMAT=DYNAMIC").into_owned()
}
fn postgres_sequence_qualified_name(schema: &str, sequence_name: &str) -> String {
let db_type = DatabaseType::Postgres;
if schema.trim().is_empty() {
quote_identifier(sequence_name, &db_type)
} else {
format!("{}.{}", quote_identifier(schema, &db_type), quote_identifier(sequence_name, &db_type))
}
}
fn postgres_string_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn generate_postgres_sequence_create_ddl(sequence: &PostgresExportSequence, schema: &str) -> String {
let qualified_name = postgres_sequence_qualified_name(schema, &sequence.name);
let cycle = if sequence.cycle { "CYCLE" } else { "NO CYCLE" };
format!(
"CREATE SEQUENCE IF NOT EXISTS {qualified_name}\n AS {data_type}\n START WITH {start_value}\n INCREMENT BY {increment}\n MINVALUE {min_value}\n MAXVALUE {max_value}\n CACHE {cache_value}\n {cycle}",
data_type = sequence.data_type,
start_value = sequence.start_value,
increment = sequence.increment,
min_value = sequence.min_value,
max_value = sequence.max_value,
cache_value = sequence.cache_value,
)
}
fn generate_postgres_sequence_owner_ddl(sequence: &PostgresExportSequence, schema: &str) -> Option<String> {
let owner_table = sequence.owner_table.as_deref()?;
let owner_column = sequence.owner_column.as_deref()?;
Some(format!(
"ALTER SEQUENCE {} OWNED BY {}.{}",
postgres_sequence_qualified_name(schema, &sequence.name),
crate::transfer::qualified_table(owner_table, schema, &DatabaseType::Postgres),
quote_identifier(owner_column, &DatabaseType::Postgres)
))
}
fn generate_postgres_sequence_setval_sql(sequence: &PostgresExportSequence, schema: &str) -> Option<String> {
let last_value = sequence.last_value.as_deref()?.trim();
if last_value.is_empty() {
return None;
}
let sequence_literal = postgres_string_literal(&postgres_sequence_qualified_name(schema, &sequence.name));
match (sequence.owner_table.as_deref(), sequence.owner_column.as_deref()) {
(Some(owner_table), Some(owner_column)) => {
let owner_table = crate::transfer::qualified_table(owner_table, schema, &DatabaseType::Postgres);
let owner_column = quote_identifier(owner_column, &DatabaseType::Postgres);
Some(format!(
"SELECT setval({sequence_literal}, GREATEST(COALESCE(MAX({owner_column}), {last_value}), {last_value}), true) FROM {owner_table}"
))
}
_ => Some(format!("SELECT setval({sequence_literal}, {last_value}, true)")),
}
}
async fn list_postgres_export_sequences(
state: &crate::connection::AppState,
pool_key: &str,
schema: &str,
selected_tables: &[String],
include_objects: bool,
) -> Result<Vec<PostgresExportSequence>, String> {
let pool = {
let connections = state.connections.read().await;
match connections.get(pool_key) {
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(
"SELECT c.relname, \
COALESCE(format_type(s.seqtypid, NULL), 'bigint'), \
COALESCE(s.seqstart::text, '1'), \
COALESCE(s.seqmin::text, '1'), \
COALESCE(s.seqmax::text, '9223372036854775807'), \
COALESCE(s.seqincrement::text, '1'), \
COALESCE(s.seqcycle, false), \
COALESCE(s.seqcache::text, '1'), \
t.relname, \
a.attname \
FROM pg_class c \
JOIN pg_namespace n ON n.oid = c.relnamespace \
LEFT JOIN pg_sequence s ON s.seqrelid = c.oid \
LEFT JOIN pg_depend d ON d.classid = 'pg_class'::regclass \
AND d.objid = c.oid \
AND d.refclassid = 'pg_class'::regclass \
AND d.deptype IN ('a', 'i') \
LEFT JOIN pg_class t ON t.oid = d.refobjid \
LEFT JOIN pg_namespace tn ON tn.oid = t.relnamespace AND tn.nspname = n.nspname \
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid \
WHERE c.relkind = 'S' AND n.nspname = $1 \
ORDER BY c.relname",
&[&schema],
)
.await
.map_err(|e| e.to_string())?;
let selected: HashSet<&str> = selected_tables.iter().map(String::as_str).collect();
let mut sequences = rows
.iter()
.map(|row| PostgresExportSequence {
name: row.get::<_, String>(0),
data_type: row.get::<_, String>(1),
start_value: row.get::<_, String>(2),
min_value: row.get::<_, String>(3),
max_value: row.get::<_, String>(4),
increment: row.get::<_, String>(5),
cycle: row.get::<_, bool>(6),
cache_value: row.get::<_, String>(7),
last_value: None,
owner_table: row.get::<_, Option<String>>(8),
owner_column: row.get::<_, Option<String>>(9),
})
.filter(|sequence| {
selected.is_empty()
|| sequence.owner_table.as_deref().map(|owner_table| selected.contains(owner_table)).unwrap_or(false)
})
.filter(|sequence| sequence.owner_table.is_some() || (include_objects && selected.is_empty()))
.collect::<Vec<_>>();
if sequences.is_empty() {
return Ok(sequences);
}
if let Ok(rows) = client
.query(
"SELECT c.relname, pg_sequence_last_value(c.oid)::text \
FROM pg_class c \
JOIN pg_namespace n ON n.oid = c.relnamespace \
WHERE c.relkind = 'S' AND n.nspname = $1",
&[&schema],
)
.await
{
for row in rows {
let name: String = row.get(0);
let last_value: Option<String> = row.get(1);
if let Some(sequence) = sequences.iter_mut().find(|sequence| sequence.name == name) {
sequence.last_value = last_value;
}
}
}
Ok(sequences)
}
pub async fn is_export_cancelled(export_id: &str) -> bool {
EXPORT_CANCELLED.read().await.contains(export_id)
}
@ -415,6 +580,26 @@ pub async fn export_database_sql_core(
// 7. Separate tables and views
let mut tables: Vec<_> = all_tables.iter().filter(|t| t.table_type != "VIEW").collect();
let views: Vec<_> = all_tables.iter().filter(|t| t.table_type == "VIEW").collect();
let postgres_sequences = if request.include_structure && matches!(db_type, DatabaseType::Postgres) {
match list_postgres_export_sequences(
state,
&pool_key,
&request.schema,
&request.selected_tables,
request.include_objects,
)
.await
{
Ok(sequences) => sequences,
Err(e) => {
writeln!(file, "-- ERROR exporting sequences: {e}")
.map_err(|e| format!("Failed to write file: {e}"))?;
Vec::new()
}
}
} else {
Vec::new()
};
// Sort tables by foreign key dependency so referenced (parent) tables are
// exported before referencing (child) tables.
@ -435,7 +620,7 @@ pub async fn export_database_sql_core(
}
// 8. Calculate total objects
let mut total_objects = tables.len() + views.len();
let mut total_objects = tables.len() + views.len() + postgres_sequences.len();
// We'll add procedures/functions count later if include_objects
let mut procedures: Vec<String> = Vec::new();
@ -462,6 +647,27 @@ pub async fn export_database_sql_core(
// Export tables
let batch_size = if request.batch_size == 0 { 1000 } else { request.batch_size };
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());
}
on_progress(ExportProgress {
export_id: request.export_id.clone(),
current_object: sequence.name.clone(),
object_index,
total_objects,
rows_exported: 0,
total_rows: None,
status: ExportStatus::Running,
error: None,
});
writeln!(file, "{};\n", generate_postgres_sequence_create_ddl(sequence, &request.schema))
.map_err(|e| format!("Failed to write file: {e}"))?;
object_index += 1;
}
for table_info in &tables {
// Check cancellation
if is_export_cancelled(&request.export_id).await {
@ -498,6 +704,25 @@ pub async fn export_database_sql_core(
writeln!(file, "{}\n", drop_table_if_exists_sql(table_name, &request.schema, &db_type))
.map_err(|e| format!("Failed to write file: {e}"))?;
}
for sequence in postgres_sequences
.iter()
.filter(|sequence| sequence.owner_table.as_deref() == Some(table_name.as_str()))
{
on_progress(ExportProgress {
export_id: request.export_id.clone(),
current_object: sequence.name.clone(),
object_index,
total_objects,
rows_exported: 0,
total_rows: None,
status: ExportStatus::Running,
error: None,
});
writeln!(file, "{};\n", generate_postgres_sequence_create_ddl(sequence, &request.schema))
.map_err(|e| format!("Failed to write file: {e}"))?;
object_index += 1;
}
match crate::schema::get_table_ddl_core(
state,
&request.connection_id,
@ -634,6 +859,19 @@ pub async fn export_database_sql_core(
object_index += 1;
}
if request.include_structure && !postgres_sequences.is_empty() {
for sequence in &postgres_sequences {
if let Some(sql) = generate_postgres_sequence_owner_ddl(sequence, &request.schema) {
writeln!(file, "{};\n", sql).map_err(|e| format!("Failed to write file: {e}"))?;
}
}
for sequence in &postgres_sequences {
if let Some(sql) = generate_postgres_sequence_setval_sql(sequence, &request.schema) {
writeln!(file, "{};\n", sql).map_err(|e| format!("Failed to write file: {e}"))?;
}
}
}
// Export views (if include_objects)
if request.include_objects {
for view_info in &views {
@ -800,8 +1038,9 @@ fn drop_table_if_exists_sql(table_name: &str, schema: &str, db_type: &DatabaseTy
mod tests {
use super::{
build_database_sql_export, build_export_insert_statements, drop_table_if_exists_sql,
filter_selected_table_infos, format_export_sql_literal, normalize_export_table_ddl,
BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions, ExportedTableSql,
filter_selected_table_infos, format_export_sql_literal, generate_postgres_sequence_create_ddl,
generate_postgres_sequence_owner_ddl, generate_postgres_sequence_setval_sql, normalize_export_table_ddl,
BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions, ExportedTableSql, PostgresExportSequence,
DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT,
};
use crate::models::connection::DatabaseType;
@ -992,4 +1231,56 @@ mod tests {
assert_eq!(normalize_export_table_ddl(mysql_ddl, Some(DatabaseType::Mysql)), mysql_ddl);
assert_eq!(normalize_export_table_ddl(postgres_ddl, Some(DatabaseType::Postgres)), postgres_ddl);
}
fn postgres_sequence(name: &str) -> PostgresExportSequence {
PostgresExportSequence {
name: name.to_string(),
data_type: "integer".to_string(),
start_value: "1".to_string(),
min_value: "1".to_string(),
max_value: "2147483647".to_string(),
increment: "1".to_string(),
cycle: false,
cache_value: "1".to_string(),
last_value: Some("42".to_string()),
owner_table: Some("permissions".to_string()),
owner_column: Some("id".to_string()),
}
}
#[test]
fn postgres_sequence_create_ddl_is_importable_before_table_ddl() {
let ddl = generate_postgres_sequence_create_ddl(&postgres_sequence("permissions_id_seq"), "public");
assert_eq!(
ddl,
[
"CREATE SEQUENCE IF NOT EXISTS \"public\".\"permissions_id_seq\"",
" AS integer",
" START WITH 1",
" INCREMENT BY 1",
" MINVALUE 1",
" MAXVALUE 2147483647",
" CACHE 1",
" NO CYCLE",
]
.join("\n")
);
}
#[test]
fn postgres_sequence_owner_and_setval_sql_are_qualified() {
let sequence = postgres_sequence("permissions_id_seq");
assert_eq!(
generate_postgres_sequence_owner_ddl(&sequence, "public").as_deref(),
Some("ALTER SEQUENCE \"public\".\"permissions_id_seq\" OWNED BY \"public\".\"permissions\".\"id\"")
);
assert_eq!(
generate_postgres_sequence_setval_sql(&sequence, "public").as_deref(),
Some(
"SELECT setval('\"public\".\"permissions_id_seq\"', GREATEST(COALESCE(MAX(\"id\"), 42), 42), true) FROM \"public\".\"permissions\""
)
);
}
}