diff --git a/crates/dbx-core/src/data/transfer.rs b/crates/dbx-core/src/data/transfer.rs index 69af144f11..0bb53c036c 100644 --- a/crates/dbx-core/src/data/transfer.rs +++ b/crates/dbx-core/src/data/transfer.rs @@ -1651,6 +1651,19 @@ fn is_postgres_compat_transfer(source_db: &DatabaseType, target_db: &DatabaseTyp is_postgres_transfer_dialect(source_db) && is_postgres_transfer_dialect(target_db) } +/// Postgres-family targets that receive an inline PostgreSQL identity column for an +/// explicit source auto-increment. +/// +/// Only the PG transfer dialects with verified identity support qualify: openGauss +/// has no identity columns (the structure editor hides them there), and the other +/// PG-wire engines stay opt-in until their transfer behavior is verified. +/// PG-compatible sources keep expressing identity through the source column default +/// instead (see `postgres_default_clause`). +fn postgres_identity_target(source_db: &DatabaseType, target_db: &DatabaseType) -> bool { + matches!(target_db, DatabaseType::Postgres | DatabaseType::Kingbase) + && !is_postgres_compat_transfer(source_db, target_db) +} + /// Oracle-family transfer dialects that return `DBMS_METADATA`-style owner-qualified /// DDL. Dameng is deliberately excluded: its reuse path strips storage clauses as well, /// so it keeps its own branch above. @@ -2867,6 +2880,12 @@ fn generate_mysql_foreign_key_alter_statements( statements } +/// `setval` statements that move a PostgreSQL sequence or identity column past the +/// values already in the table. +/// +/// Covers serial/`nextval` defaults and PostgreSQL identity extras, plus explicit +/// cross-engine auto-increment columns that the generated DDL turned into identity +/// columns (see `postgres_identity_target`). fn generate_postgres_sequence_sync_sql(columns: &[db::ColumnInfo], table: &str, schema: &str) -> Vec { let full_table = qualified_table(table, schema, &DatabaseType::Postgres, None); columns @@ -2874,6 +2893,7 @@ fn generate_postgres_sequence_sync_sql(columns: &[db::ColumnInfo], table: &str, .filter(|column| { is_postgres_sequence_default(column.column_default.as_deref()) || is_postgres_identity_extra(column.extra.as_deref()) + || (column.is_primary_key && column_is_auto_increment(column)) }) .map(|column| { let quoted_column = quote_identifier(&column.name, &DatabaseType::Postgres); @@ -4448,8 +4468,8 @@ fn generate_create_table_ddl_with_column_quoting( // Auto-increment beyond the MySQL family goes through the shared dialect // profile (the same facility the structure-diff generator uses), so the target - // spells it natively: Dameng and SQL Server `IDENTITY(1,1)`, Oracle - // `GENERATED BY DEFAULT AS IDENTITY`, and so on. + // spells it natively: Dameng and SQL Server `IDENTITY(1,1)`, Oracle and + // Postgres/Kingbase `GENERATED BY DEFAULT AS IDENTITY`, and so on. // // Only an explicit source auto-increment qualifies. A transfer must not invent // identity the source never declared, and both MySQL (one AUTO_INCREMENT @@ -4461,26 +4481,26 @@ fn generate_create_table_ddl_with_column_quoting( // plain path: // - The profile-level MySQL family beyond the transfer's own list (Databend, // Gbase, ManticoreSearch): they reject the `AUTO_INCREMENT` spelling. - // - Postgres family (`PostgresSequence`): the target needs a created sequence - // plus `ALTER TABLE ... SET DEFAULT`, which this generator does not emit; - // those transfers keep their own sequence handling. + // - PG-wire engines without verified identity or transfer support (openGauss, + // Gaussdb, ...): see `postgres_identity_target`. // - SQLite family: `AUTOINCREMENT` is only valid as part of an inline // `INTEGER PRIMARY KEY`, and primary keys here are a table-level clause — // SQLite's rowid alias already generates ids for `INTEGER PRIMARY KEY`. let target_profile = profile_for(*target_db); + let postgres_identity = postgres_identity_target(source_db, target_db); let inline_identity = !is_mysql_family && !matches!( target_db, DatabaseType::Sqlite | DatabaseType::Rqlite | DatabaseType::Turso | DatabaseType::CloudflareD1 ) - && match target_profile.auto_inc { + && (match target_profile.auto_inc { // `profile_for` puts Databend/Gbase/ManticoreSearch in the MySQL family // too, but their grammars reject MySQL's `AUTO_INCREMENT` spelling; only // the transfer's own MySQL-family targets above keep it. AutoIncSyntax::Suffix(suffix) => suffix != " AUTO_INCREMENT", AutoIncSyntax::ReplaceTypeWith(_) => true, _ => false, - }; + } || postgres_identity); let mut col_lines = Vec::with_capacity(columns.len()); for c in columns { @@ -4505,8 +4525,17 @@ fn generate_create_table_ddl_with_column_quoting( build => { let mut line = format!(" {column_name} {mapped_type}"); let skip_default = match build { - AutoIncColumnBuild::AppendSuffix { suffix, skip_default, .. } => { - line.push_str(suffix); + AutoIncColumnBuild::AppendSuffix { suffix, skip_default, postgres_sequence } => { + if postgres_sequence { + // A `PostgresSequence` profile carries no inline keyword; + // emit PostgreSQL's identity syntax — the same text + // `postgres_default_clause` already produces for + // PG-compatible sources — and keep the source's own + // sequence default out of the DDL via `skip_default`. + line.push_str(" GENERATED BY DEFAULT AS IDENTITY"); + } else { + line.push_str(suffix); + } skip_default } _ => false, @@ -12750,7 +12779,10 @@ where .await?; } - if pg_compat_transfer { + let syncs_postgres_identity = !pg_compat_transfer + && postgres_identity_target(source_db_type, target_db_type) + && columns.iter().any(|column| column.is_primary_key && column_is_auto_increment(column)); + if pg_compat_transfer || syncs_postgres_identity { for statement in generate_postgres_sequence_sync_sql(&columns, &target_table, &request.target_schema) { execute_on_pool(state, target_pool_key, &statement) .await @@ -20060,6 +20092,38 @@ PARTITION p_old VALUES LESS THAN (TO_DAYS('2026-01-01')))"; ); } + #[test] + fn postgres_sequence_sync_covers_cross_engine_auto_increment_columns() { + let sql = generate_postgres_sequence_sync_sql( + &[ + db::ColumnInfo { + name: "id".to_string(), + data_type: "int".to_string(), + is_primary_key: true, + extra: Some("auto_increment".to_string()), + ..test_column("id", "int") + }, + // Not a key on the source: the generated DDL kept it a plain column, + // so it must not get a sync statement either. + db::ColumnInfo { + name: "ticket".to_string(), + data_type: "int".to_string(), + extra: Some("auto_increment".to_string()), + ..test_column("ticket", "int") + }, + ], + "users", + "public", + ); + + assert_eq!( + sql, + vec![ + "SELECT setval(pg_get_serial_sequence('\"public\".\"users\"', 'id')::regclass, GREATEST(COALESCE(MAX(\"id\"::bigint), 0), 1), MAX(\"id\"::bigint) IS NOT NULL) FROM \"public\".\"users\"".to_string() + ] + ); + } + #[test] fn postgres_sequence_sync_sql_casts_text_and_numeric_columns_to_bigint() { let sql = generate_postgres_sequence_sync_sql( @@ -22635,7 +22699,26 @@ SELECT 1 FROM dual"# } #[test] - fn transfer_create_table_keeps_postgres_and_sqlite_auto_increment_outs() { + fn transfer_create_table_preserves_explicit_auto_increment_for_postgres_and_kingbase() { + for (source, extra) in [(DatabaseType::Mysql, "auto_increment"), (DatabaseType::SqlServer, "identity(1,1)")] { + for target in [DatabaseType::Postgres, DatabaseType::Kingbase] { + let cols = vec![db::ColumnInfo { + is_primary_key: true, + is_nullable: false, + extra: Some(extra.to_string()), + ..test_column("id", "int") + }]; + + let ddl = generate_create_table_ddl(&cols, "users", "source", "public", &target, &source, None, None); + + assert!(ddl.contains("GENERATED BY DEFAULT AS IDENTITY"), "{source:?}->{target:?}: {ddl}"); + assert!(!ddl.contains("nextval"), "{source:?}->{target:?}: {ddl}"); + } + } + } + + #[test] + fn transfer_create_table_keeps_unverified_postgres_wire_and_sqlite_outs() { let cols = vec![db::ColumnInfo { is_primary_key: true, is_nullable: false, @@ -22643,7 +22726,9 @@ SELECT 1 FROM dual"# ..test_column("id", "int") }]; - for target in [DatabaseType::Postgres, DatabaseType::Kingbase] { + // openGauss has no identity columns, and the other PG-wire engines stay + // opt-in until their transfer behavior is verified. + for target in [DatabaseType::OpenGauss, DatabaseType::Gaussdb, DatabaseType::Vastbase] { let ddl = generate_create_table_ddl( &cols, "users",