diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index 8c62501879..944da862cc 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -965,6 +965,32 @@ describe("useDataGridExport prepared row statements", () => { expect(state.canCopyWithExtractor("sql-inserts")).toBe(false); }); + it("recognizes PostgreSQL serial primary keys when excluding primary keys", () => { + const serialTable: DataGridTableMeta = { + tableName: "users", + primaryKeys: ["id"], + columns: [ + { + name: "id", + data_type: "integer", + is_nullable: false, + is_primary_key: true, + extra: "serial", + }, + { name: "name", data_type: "text", is_nullable: false }, + ], + }; + const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [0], columns: ["id"], rows: [[1]] }; + const excludePrimaryKeys = { + ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, + sql: { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.sql, excludePrimaryKeysFromInsert: true }, + }; + + const state = createExportState(serialTable, ["id"], matrix, [1], undefined, undefined, [], excludePrimaryKeys); + + expect(state.canCopyWithExtractor("sql-inserts")).toBe(false); + }); + it("keeps a manually-assigned primary key insertable under primary-key exclusion", () => { const compositeKeyTable: DataGridTableMeta = { tableName: "daily_stats", diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 063b65d242..cfbd80de79 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -1709,7 +1709,9 @@ function effectiveColumns(sourceColumns: Array | undefined, function isAutoGeneratedColumn(column: string, tableMeta: DataGridTableMeta | undefined): boolean { const columnInfo = tableMeta?.columns?.find((item) => normalizeColumnName(item.name) === normalizeColumnName(column)); - return /\b(auto_increment|autoincrement|identity)\b/.test(columnInfo?.extra?.toLowerCase() ?? ""); + const extra = columnInfo?.extra?.toLowerCase() ?? ""; + const defaultValue = columnInfo?.column_default?.toLowerCase() ?? ""; + return /\b(auto_increment|autoincrement|identity|smallserial|serial|bigserial)\b/.test(extra) || /\bnextval\s*\(/.test(defaultValue); } function isCopyInsertOmittedColumn(databaseType: DatabaseType | undefined, column: string, tableMeta: DataGridTableMeta | undefined, extractorOptions?: DataGridExtractorOptions): boolean { @@ -1718,7 +1720,7 @@ function isCopyInsertOmittedColumn(databaseType: DatabaseType | undefined, colum const normalizedType = columnInfo?.data_type.trim().replace(/^"|"$/g, "").toLowerCase(); if (databaseType === "postgres" && (normalizedType === "tsvector" || normalizedType?.endsWith(".tsvector"))) return true; const extra = columnInfo?.extra?.toLowerCase() ?? ""; - const isAutoGenerated = /\b(auto_increment|autoincrement|identity)\b/.test(extra); + const isAutoGenerated = isAutoGeneratedColumn(column, tableMeta); const isComputed = extra.includes("generated always as") && !extra.includes("identity"); const isPrimaryKey = (tableMeta?.primaryKeys ?? []).some((key) => normalizeColumnName(key) === normalizeColumnName(column)); return ((extractorOptions?.sql.skipGeneratedColumns ?? true) && isAutoGenerated && !isPrimaryKey) || ((extractorOptions?.sql.skipComputedColumns ?? true) && isComputed); diff --git a/apps/desktop/src/composables/useDataGridExtractor.ts b/apps/desktop/src/composables/useDataGridExtractor.ts index 4f8310e670..9a6a4bfb20 100644 --- a/apps/desktop/src/composables/useDataGridExtractor.ts +++ b/apps/desktop/src/composables/useDataGridExtractor.ts @@ -473,7 +473,9 @@ function columnExtra(column: string, tableMeta: DataGridTableMeta | undefined): } function isAutoGeneratedColumn(column: string, tableMeta: DataGridTableMeta | undefined): boolean { - return /\b(auto_increment|autoincrement|identity)\b/.test(columnExtra(column, tableMeta)); + const columnInfo = tableMeta?.columns?.find((item) => normalizeName(item.name) === normalizeName(column)); + const defaultValue = columnInfo?.column_default?.toLowerCase() ?? ""; + return /\b(auto_increment|autoincrement|identity|smallserial|serial|bigserial)\b/.test(columnExtra(column, tableMeta)) || /\bnextval\s*\(/.test(defaultValue); } function isComputedColumn(column: string, tableMeta: DataGridTableMeta | undefined): boolean { diff --git a/crates/dbx-core/src/data_grid_extractors/tests.rs b/crates/dbx-core/src/data_grid_extractors/tests.rs index 4794514188..5fb660066c 100644 --- a/crates/dbx-core/src/data_grid_extractors/tests.rs +++ b/crates/dbx-core/src/data_grid_extractors/tests.rs @@ -983,6 +983,44 @@ fn sql_insert_honors_primary_key_exclusion_and_row_by_row_mode() { assert_eq!(result.omitted_columns, vec!["id"]); } +#[test] +fn sql_insert_primary_key_exclusion_omits_postgres_serial_key() { + let mut request = request(DataGridExtractorId::SqlInserts); + request.database_type = Some(DatabaseType::Postgres); + request.table_meta = Some(DataGridTableMeta { + catalog: None, + database: None, + schema: Some("public".to_string()), + table_name: "users".to_string(), + primary_keys: vec!["id".to_string()], + columns: Some(vec![ + DataGridColumnInfo { + name: "id".to_string(), + data_type: "integer".to_string(), + is_nullable: false, + is_primary_key: true, + column_default: None, + extra: Some("serial".to_string()), + }, + DataGridColumnInfo { + name: "name".to_string(), + data_type: "text".to_string(), + is_nullable: false, + is_primary_key: false, + column_default: None, + extra: None, + }, + ]), + }); + request.rows = vec![vec![json!(1), json!("Ada")]]; + request.options.sql.exclude_primary_keys_from_insert = true; + + let result = extract_data_grid_selection(request).expect("PostgreSQL SQL INSERT extraction"); + + assert_eq!(result.text, "INSERT INTO \"public\".\"users\" (\"name\") VALUES ('Ada');"); + assert_eq!(result.omitted_columns, vec!["id"]); +} + #[test] fn sql_insert_primary_key_exclusion_keeps_manual_composite_key_members() { let mut request = request(DataGridExtractorId::SqlInserts); diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 5a08101ede..e44dd43d42 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -3251,14 +3251,14 @@ pub(crate) fn is_neo4j_element_id(database_type: Option, name: Opt } pub(crate) fn extra_is_auto_generated(extra: &str) -> bool { - extra - .to_ascii_lowercase() - .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_') - .any(|part| matches!(part, "auto_increment" | "autoincrement" | "identity")) + extra.to_ascii_lowercase().split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_').any(|part| { + matches!(part, "auto_increment" | "autoincrement" | "identity" | "smallserial" | "serial" | "bigserial") + }) } pub(crate) fn is_auto_generated_column(column: &DataGridColumnInfo) -> bool { extra_is_auto_generated(column.extra.as_deref().unwrap_or("")) + || column.column_default.as_deref().is_some_and(|default| default.to_ascii_lowercase().contains("nextval(")) } fn grid_value_is_empty(value: &Value) -> bool { @@ -4028,6 +4028,55 @@ mod tests { ); } + #[test] + fn recognizes_postgres_serial_extras_as_auto_generated() { + for extra in ["serial", "smallserial", "bigserial"] { + assert!(extra_is_auto_generated(extra), "expected {extra} to be auto-generated"); + } + } + + #[test] + fn builds_postgres_copy_insert_without_serial_primary_key() { + let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { + database_type: Some(DatabaseType::Postgres), + identifier_quote: None, + table_meta: Some(DataGridTableMeta { + catalog: None, + database: None, + schema: Some("public".to_string()), + table_name: "users".to_string(), + primary_keys: vec!["id".to_string()], + columns: Some(vec![ + DataGridColumnInfo { + name: "id".to_string(), + data_type: "integer".to_string(), + is_nullable: false, + is_primary_key: true, + column_default: Some("nextval('public.users_id_seq'::regclass)".to_string()), + extra: None, + }, + DataGridColumnInfo { + name: "name".to_string(), + data_type: "text".to_string(), + is_nullable: false, + is_primary_key: false, + column_default: None, + extra: None, + }, + ]), + }), + columns: vec!["id".to_string(), "name".to_string()], + column_types: None, + source_columns: None, + rows: vec![vec![json!(1), json!("Ada")]], + exclude_primary_keys: true, + include_computed_columns: false, + insert_mode: DataGridCopyInsertMode::Merged, + }); + + assert_eq!(statement.as_deref(), Some("INSERT INTO \"public\".\"users\" (\"name\") VALUES ('Ada');")); + } + #[test] fn copy_insert_uses_display_columns_but_source_columns_for_metadata() { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {