diff --git a/agents/common/src/main/java/com/dbx/agent/DdlBuilder.java b/agents/common/src/main/java/com/dbx/agent/DdlBuilder.java index 304eafb2e1..537904589f 100644 --- a/agents/common/src/main/java/com/dbx/agent/DdlBuilder.java +++ b/agents/common/src/main/java/com/dbx/agent/DdlBuilder.java @@ -4,8 +4,15 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public final class DdlBuilder { + private static final Pattern SQLSERVER_IDENTITY = Pattern.compile( + "^\\s*identity\\s*\\(\\s*(-?\\d+)\\s*,\\s*(-?\\d+)\\s*\\)\\s*$", + Pattern.CASE_INSENSITIVE + ); + private DdlBuilder() { } @@ -84,6 +91,10 @@ public final class DdlBuilder { line.append(quoteIdent(column.getName(), useBacktick)); line.append(" "); line.append(columnTypeSql(column)); + String identityClause = sqlServerIdentityClause(column.getExtra()); + if (identityClause != null) { + line.append(" ").append(identityClause); + } if (!column.getIs_nullable()) { line.append(" NOT NULL"); } @@ -230,6 +241,14 @@ public final class DdlBuilder { return "numeric".equals(normalized) || "decimal".equals(normalized); } + private static String sqlServerIdentityClause(String extra) { + if (extra == null) { + return null; + } + Matcher matcher = SQLSERVER_IDENTITY.matcher(extra); + return matcher.matches() ? "IDENTITY(" + matcher.group(1) + "," + matcher.group(2) + ")" : null; + } + private static boolean notBlank(String value) { return value != null && !value.trim().isEmpty(); } diff --git a/agents/drivers/kingbase/src/main/java/com/dbx/agent/kingbase/KingbaseAgent.java b/agents/drivers/kingbase/src/main/java/com/dbx/agent/kingbase/KingbaseAgent.java index 682da01316..ec0c011cd5 100644 --- a/agents/drivers/kingbase/src/main/java/com/dbx/agent/kingbase/KingbaseAgent.java +++ b/agents/drivers/kingbase/src/main/java/com/dbx/agent/kingbase/KingbaseAgent.java @@ -47,6 +47,7 @@ public final class KingbaseAgent extends PostgresLikeAgent { private static final String KINGBASE_MATVIEW_NAME = "CAST(mv.matviewname AS varchar(256))"; private static final String KINGBASE_MATVIEW_SCHEMA = "CAST(mv.schemaname AS varchar(256))"; private boolean postgresCatalogMode; + private boolean sqlServerIdentityCatalogMode; public static final PostgresLikeAgentProfile KINGBASE_PROFILE = new PostgresLikeAgentProfile( "com.kingbase8.Driver", @@ -60,6 +61,7 @@ public final class KingbaseAgent extends PostgresLikeAgent { @Override protected void afterConnect(ConnectParams params, Connection connection) { postgresCatalogMode = false; + sqlServerIdentityCatalogMode = false; setMysqlCompatMode(params.isMysql_compat_mode()); if (params.isMysql_compat_mode()) { return; @@ -69,6 +71,10 @@ public final class KingbaseAgent extends PostgresLikeAgent { if (!postgresCatalogMode && mysqlSqlModeExists(connection)) { setMysqlCompatMode(true); } + // SQLServer compatibility exposes identity metadata through this catalog only. + sqlServerIdentityCatalogMode = !postgresCatalogMode + && !isMysqlCompatMode() + && catalogExists(connection, "sys.identity_columns"); } private static boolean mysqlSqlModeExists(Connection connection) { @@ -413,13 +419,19 @@ public final class KingbaseAgent extends PostgresLikeAgent { "CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 " + "THEN (a.atttypmod - 4) & 65535 ELSE NULL END AS numeric_scale, " + "CASE WHEN t.typname IN ('varchar', 'bpchar') AND a.atttypmod > 0 " + - "THEN a.atttypmod - 4 ELSE NULL END AS character_maximum_length " + + "THEN a.atttypmod - 4 ELSE NULL END AS character_maximum_length, " + + (sqlServerIdentityCatalogMode + ? "ic.seed_value AS identity_seed, ic.increment_value AS identity_increment " + : "NULL AS identity_seed, NULL AS identity_increment ") + "FROM sys_catalog.sys_attribute a " + "JOIN sys_catalog.sys_type t ON t.oid = a.atttypid " + "JOIN sys_catalog.sys_class c ON c.oid = a.attrelid " + "JOIN sys_catalog.sys_namespace n ON n.oid = c.relnamespace " + "LEFT JOIN sys_catalog.sys_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum " + "LEFT JOIN sys_catalog.sys_description d ON d.objoid = a.attrelid AND d.objsubid = a.attnum " + + (sqlServerIdentityCatalogMode + ? "LEFT JOIN sys.identity_columns ic ON ic.object_id = c.oid AND ic.column_id = a.attnum " + : "") + "WHERE n.nspname = " + sqlString(effectiveSchema(schema)) + " AND c.relname = " + sqlString(table) + " " + "AND a.attnum > 0 AND NOT a.attisdropped " + @@ -434,7 +446,7 @@ public final class KingbaseAgent extends PostgresLikeAgent { rs.getBoolean("is_nullable"), rs.getString("column_default"), primaryKeys.contains(columnName), - null, + identityExtra(rs), rs.getString("column_comment"), intObject(rs, "numeric_precision"), intObject(rs, "numeric_scale"), @@ -447,6 +459,15 @@ public final class KingbaseAgent extends PostgresLikeAgent { }); } + private static String identityExtra(ResultSet rs) throws Exception { + String seed = rs.getString("identity_seed"); + String increment = rs.getString("identity_increment"); + if (seed == null || increment == null) { + return null; + } + return "identity(" + seed + "," + increment + ")"; + } + private List getInformationSchemaColumns(String schema, String table, Set primaryKeys) { return unchecked(() -> { List result = new ArrayList<>(); diff --git a/agents/drivers/kingbase/src/test/java/com/dbx/agent/kingbase/KingbaseAgentTest.java b/agents/drivers/kingbase/src/test/java/com/dbx/agent/kingbase/KingbaseAgentTest.java index bebf2668e1..2e2f574657 100644 --- a/agents/drivers/kingbase/src/test/java/com/dbx/agent/kingbase/KingbaseAgentTest.java +++ b/agents/drivers/kingbase/src/test/java/com/dbx/agent/kingbase/KingbaseAgentTest.java @@ -4,6 +4,7 @@ import com.dbx.agent.ColumnInfo; import com.dbx.agent.ConnectParams; import com.dbx.agent.DatabaseAgent; import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.DdlBuilder; import com.dbx.agent.IndexInfo; import com.dbx.agent.MetadataListConstraints; import com.dbx.agent.ObjectInfo; @@ -27,6 +28,7 @@ import java.sql.Timestamp; import java.sql.Types; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; class KingbaseAgentTest extends JdbcFakeExecutionBehaviorTest { @@ -154,6 +156,22 @@ class KingbaseAgentTest extends JdbcFakeExecutionBehaviorTest { Assertions.assertEquals("SELECT 1 FROM sys_settings WHERE LOWER(name) = 'sql_mode'", sql.get(1)); } + @Test + void detectsSqlServerIdentityCatalogMode() throws Exception { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + Connection connection = sqlServerCompatConnection(sql); + + Method afterConnect = KingbaseAgent.class.getDeclaredMethod("afterConnect", ConnectParams.class, Connection.class); + afterConnect.setAccessible(true); + afterConnect.invoke(agent, new ConnectParams(), connection); + + Assertions.assertTrue(isSqlServerIdentityCatalogMode(agent)); + Assertions.assertEquals("SELECT 1 FROM sys_catalog.sys_namespace WHERE 1 = 0", sql.get(0)); + Assertions.assertEquals("SELECT 1 FROM sys_settings WHERE LOWER(name) = 'sql_mode'", sql.get(1)); + Assertions.assertEquals("SELECT 1 FROM sys.identity_columns WHERE 1 = 0", sql.get(2)); + } + @Test void mysqlCompatListTablesUsesInformationSchema() { List sql = new ArrayList<>(); @@ -406,6 +424,7 @@ class KingbaseAgentTest extends JdbcFakeExecutionBehaviorTest { Assertions.assertTrue(sql.get(1).contains("format_type(a.atttypid, a.atttypmod) AS data_type"), sql.get(1)); Assertions.assertTrue(sql.get(1).contains("FROM sys_catalog.sys_attribute"), sql.get(1)); Assertions.assertFalse(sql.get(1).contains("information_schema.columns"), sql.get(1)); + Assertions.assertNull(columns.get(0).getExtra()); } @Test @@ -439,6 +458,59 @@ class KingbaseAgentTest extends JdbcFakeExecutionBehaviorTest { Assertions.assertEquals("identifier", columns.get(0).getComment()); Assertions.assertTrue(sql.get(1).contains("FROM information_schema.columns"), sql.get(1)); Assertions.assertTrue(sql.get(1).contains("LEFT JOIN sys_catalog.sys_description"), sql.get(1)); + Assertions.assertNull(columns.get(0).getExtra()); + } + + @Test + void sqlServerCompatGetColumnsPreservesIdentityMetadata() throws Exception { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + setSqlServerIdentityCatalogMode(agent, true); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, + resultSet(new String[]{"column_name"}, new Object[][]{{"id"}}), + resultSet( + new String[]{ + "column_name", + "data_type", + "is_nullable", + "column_default", + "column_comment", + "numeric_precision", + "numeric_scale", + "character_maximum_length", + "identity_seed", + "identity_increment" + }, + new Object[][]{ + {"id", "int", false, null, null, 32, 0, null, "1", "1"}, + {"name", "character varying", true, null, null, null, null, 64, null, null} + } + ) + )); + + List columns = agent.getColumns("dbo", "orders"); + + Assertions.assertEquals("identity(1,1)", columns.get(0).getExtra()); + Assertions.assertNull(columns.get(1).getExtra()); + Assertions.assertTrue(sql.get(1).contains("LEFT JOIN sys.identity_columns"), sql.get(1)); + } + + @Test + void ddlRendersOnlyWellFormedSqlServerIdentityMetadata() { + ColumnInfo identity = new ColumnInfo("id", "int", false, null, true, "identity(1,1)", null, null, null, null); + ColumnInfo ordinary = new ColumnInfo("name", "varchar", true, null, false, "unknown metadata", null, null, null, 64); + + String ddl = DdlBuilder.buildTableDdl( + "dbo", + "orders", + Arrays.asList(identity, ordinary), + Collections.emptyList(), + Collections.emptyList() + ); + + Assertions.assertTrue(ddl.contains("\"id\" int IDENTITY(1,1) NOT NULL"), ddl); + Assertions.assertTrue(ddl.contains("\"name\" varchar(64)"), ddl); + Assertions.assertFalse(ddl.contains("unknown metadata"), ddl); } @Test @@ -602,6 +674,23 @@ class KingbaseAgentTest extends JdbcFakeExecutionBehaviorTest { }); } + private static Connection sqlServerCompatConnection(List sql) { + return proxy(Connection.class, (method, args) -> { + if ("createStatement".equals(method.getName())) { + return proxy(Statement.class, (statementMethod, statementArgs) -> { + if ("executeQuery".equals(statementMethod.getName())) { + String query = String.valueOf(statementArgs[0]); + sql.add(query); + return resultSet(new String[]{"probe"}, new Object[][]{}); + } + return defaultValue(statementMethod.getReturnType()); + }); + } + if ("isClosed".equals(method.getName())) return false; + return defaultValue(method.getReturnType()); + }); + } + private static ResultSet resultSet(String[] columns, Object[][] rows) { int[] index = {-1}; return proxy(ResultSet.class, (method, args) -> { @@ -694,4 +783,16 @@ class KingbaseAgentTest extends JdbcFakeExecutionBehaviorTest { method.setAccessible(true); return method.invoke(agent, rs, 1, sqlType, columnTypeName); } + + private static void setSqlServerIdentityCatalogMode(KingbaseAgent agent, boolean enabled) throws Exception { + java.lang.reflect.Field field = KingbaseAgent.class.getDeclaredField("sqlServerIdentityCatalogMode"); + field.setAccessible(true); + field.setBoolean(agent, enabled); + } + + private static boolean isSqlServerIdentityCatalogMode(KingbaseAgent agent) throws Exception { + java.lang.reflect.Field field = KingbaseAgent.class.getDeclaredField("sqlServerIdentityCatalogMode"); + field.setAccessible(true); + return field.getBoolean(agent); + } } diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts index 0f610a1505..62ec5c7cc8 100644 --- a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts @@ -16,12 +16,24 @@ import { isMysqlEnumDataType, isSqlServerIdentityCompatibleDataType, mysqlEnumDataType, + parseExtraToColumnExtra, rehydrateColumnDraftsFromMetadata, restoreDamengLengthUnitsAfterSave, splitDataType, } from "@/lib/table/tableStructureEditorState"; describe("tableStructureEditorState", () => { + it("parses Kingbase SQLServer compatibility identity metadata", () => { + expect(parseExtraToColumnExtra("identity(10, 2)", "kingbase")).toEqual({ + autoIncrement: true, + identity: { seed: 10, increment: 2 }, + }); + expect(parseExtraToColumnExtra("generated always as identity", "kingbase")).toEqual({ + identity: { generation: "ALWAYS" }, + }); + expect(parseExtraToColumnExtra("identity(1,1)", "postgres")).toEqual({}); + }); + it("keeps mysql unsigned attributes in the editable base type", () => { expect(splitDataType("int(11) unsigned")).toEqual({ baseType: "int unsigned", params: "11" }); expect(splitDataType("bigint(20) unsigned zerofill")).toEqual({ diff --git a/apps/desktop/src/lib/table/tableStructureEditorState.ts b/apps/desktop/src/lib/table/tableStructureEditorState.ts index 5c9cca65e5..c6e100b799 100644 --- a/apps/desktop/src/lib/table/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/table/tableStructureEditorState.ts @@ -477,6 +477,16 @@ export function parseExtraToColumnExtra(extra: string | null | undefined, databa result.identity.seed = Number(sequenceMatch[1]); result.identity.increment = Number(sequenceMatch[2]); } + } else if (databaseType === "kingbase") { + // SQLServer compatibility reports IDENTITY(seed, increment) instead of PostgreSQL identity syntax. + const sqlServerIdentityMatch = lower.match(/identity\s*\(\s*(-?\d+)\s*,\s*(-?\d+)\s*\)/i); + if (sqlServerIdentityMatch) { + result.autoIncrement = true; + result.identity = { + seed: Number(sqlServerIdentityMatch[1]), + increment: Number(sqlServerIdentityMatch[2]), + }; + } } } else if (databaseType === "sqlserver") { if (lower.includes("identity")) { diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 5ca6f139e5..c34fa2f406 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -4668,6 +4668,33 @@ mod tests { assert_eq!(result.statements, vec!["INSERT INTO `app`.`users` (`name`) VALUES ('Ada');"]); } + #[test] + fn prepare_data_grid_save_omits_empty_kingbase_sqlserver_identity_value() { + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Kingbase), + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: Some("dbo".to_string()), + table_name: "orders".to_string(), + primary_keys: vec!["id".to_string()], + columns: Some(vec![ + pk_column("id", "int", false, Some("identity(1,1)")), + column("name", "varchar", false, None), + ]), + }, + columns: vec!["id".to_string(), "name".to_string()], + source_columns: None, + rows: vec![], + dirty_rows: vec![], + deleted_rows: vec![], + new_rows: vec![vec![Value::Null, json!("Ada")]], + }); + + assert_eq!(result.validation_error, None); + assert_eq!(result.statements, vec![r#"INSERT INTO "dbo"."orders" ("name") VALUES ('Ada');"#]); + } + #[test] fn prepare_data_grid_save_still_validates_other_not_null_columns_in_sqlite() { let result = prepare_data_grid_save(DataGridSaveStatementOptions {