From 2bf6af1ff0f1380b5be0bdc231d11f6fb1955cf8 Mon Sep 17 00:00:00 2001 From: lxk955 <164191365+lxk955@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:53:50 +0800 Subject: [PATCH] fix(grid): enable table data editing and add-row for Cache / IRIS --- .../java/com/dbx/agent/iris/IrisAgent.java | 52 +++++++++++ .../com/dbx/agent/iris/IrisAgentTest.java | 83 ++++++++++++++++- .../DataGridBatchPasteFromCell.spec.ts | 8 +- .../database/databaseDriverManifest.spec.ts | 4 + .../lib/__tests__/table/tableEditing.spec.ts | 10 ++ .../database/databaseTableDataCapabilities.ts | 1 + .../assets/database-drivers.manifest.json | 2 +- crates/dbx-sql-data/src/data_grid_sql.rs | 91 +++++++++++++++++++ plugins/connection-types/iris.yaml | 2 +- 9 files changed, 245 insertions(+), 8 deletions(-) diff --git a/agents/drivers/iris/src/main/java/com/dbx/agent/iris/IrisAgent.java b/agents/drivers/iris/src/main/java/com/dbx/agent/iris/IrisAgent.java index d141e6cb33..611a1f47ee 100644 --- a/agents/drivers/iris/src/main/java/com/dbx/agent/iris/IrisAgent.java +++ b/agents/drivers/iris/src/main/java/com/dbx/agent/iris/IrisAgent.java @@ -137,10 +137,62 @@ public final class IrisAgent extends ConfiguredJdbcAgent { if (!hasPrimaryKey(columns)) { markPrimaryKeys(columns, irisPrimaryKeys(conn, schema, table)); } + markIdentityColumns(columns, irisIdentityColumns(conn, schema, table)); return columns; }); } + /** + * IRIS RowId / IDENTITY columns are system-assigned: their values cannot be + * user-specified on INSERT, so the grid must omit them when saving new rows + * (mirrors how the Dameng agent reports IDENTITY columns). IRIS exposes this + * through INFORMATION_SCHEMA.COLUMNS extension columns (IS_ROWID / IS_IDENTITY, + * not part of the SQL standard). The probe is defensive on purpose: when a + * server build lacks the extension columns the query fails and column listing + * keeps working exactly as before. + */ + private static Set irisIdentityColumns(Connection conn, String schema, String table) { + Set names = new LinkedHashSet<>(); + try (PreparedStatement stmt = conn.prepareStatement(identityColumnSql(schema))) { + int index = 1; + if (!isBlank(schema)) { + stmt.setString(index++, schema.trim()); + } + stmt.setString(index, table); + try (ResultSet rs = stmt.executeQuery()) { + addPrimaryKeys(names, rs); + } + } catch (Exception ignored) { + // Identity metadata is optional; older builds report it through + // neither extension column and must not break column listing. + } + return names; + } + + private static String identityColumnSql(String schema) { + StringBuilder sql = new StringBuilder( + "SELECT COLUMN_NAME " + + "FROM INFORMATION_SCHEMA.COLUMNS WHERE " + + "(UPPER(IS_ROWID) = 'YES' OR UPPER(IS_IDENTITY) = 'YES') AND " + ); + if (!isBlank(schema)) { + sql.append("UPPER(TABLE_SCHEMA) = UPPER(?) AND "); + } + sql.append("UPPER(TABLE_NAME) = UPPER(?)"); + return sql.toString(); + } + + private static void markIdentityColumns(List columns, Set identityColumns) { + if (identityColumns.isEmpty()) { + return; + } + for (ColumnInfo column : columns) { + if (identityColumns.contains(column.getName().toUpperCase(Locale.ROOT))) { + column.setExtra("identity"); + } + } + } + static List dedupeCaseInsensitiveSchemas(List schemas) { Map byNormalizedName = new LinkedHashMap<>(); for (String schema : schemas) { diff --git a/agents/drivers/iris/src/test/java/com/dbx/agent/iris/IrisAgentTest.java b/agents/drivers/iris/src/test/java/com/dbx/agent/iris/IrisAgentTest.java index 7e492a1bbf..d4960cf500 100644 --- a/agents/drivers/iris/src/test/java/com/dbx/agent/iris/IrisAgentTest.java +++ b/agents/drivers/iris/src/test/java/com/dbx/agent/iris/IrisAgentTest.java @@ -219,7 +219,7 @@ class IrisAgentTest { assertNull(columns.get(1).getNumeric_precision()); assertNull(columns.get(1).getNumeric_scale()); assertEquals(Integer.valueOf(64), columns.get(1).getCharacter_maximum_length()); - assertEquals(1, calls.size()); + assertEquals(2, calls.size()); assertFalse(calls.get(0).contains("LIMIT")); assertTrue(calls.get(0).contains("PRIMARY_KEY")); assertTrue(calls.get(0).contains("DESCRIPTION")); @@ -242,9 +242,55 @@ class IrisAgentTest { assertEquals("identifier", columns.get(0).getComment()); assertNull(columns.get(1).getComment()); - assertEquals(1, calls.size()); + assertEquals(2, calls.size()); assertTrue(calls.get(0).contains("DESCRIPTION")); assertFalse(calls.get(0).contains("TABLE_SCHEMA")); + assertTrue(calls.get(1).contains("IS_ROWID")); + assertFalse(calls.get(1).contains("TABLE_SCHEMA")); + } + + @Test + void marksRowIdColumnsAsIdentityExtra() { + List calls = new ArrayList<>(); + Connection conn = fakeConnection( + calls, + true, + Collections.emptyList(), + null, + Collections.emptyList(), + List.of(Map.of("COLUMN_NAME", "ID")), + null + ); + + List columns = IrisAgent.irisColumns(conn, "SQLUser", "People"); + + assertEquals("identity", columns.get(0).getExtra()); + assertNull(columns.get(1).getExtra()); + assertEquals(2, calls.size()); + assertTrue(calls.get(1).contains("IS_ROWID")); + assertTrue(calls.get(1).contains("IS_IDENTITY")); + assertTrue(calls.get(1).contains("UPPER(TABLE_SCHEMA) = UPPER(?)")); + } + + @Test + void keepsColumnListingWhenIdentityColumnsAreUnavailable() { + List calls = new ArrayList<>(); + Connection conn = fakeConnection( + calls, + true, + Collections.emptyList(), + null, + Collections.emptyList(), + null, + new SQLException("Field 'IS_ROWID' not found") + ); + + List columns = IrisAgent.irisColumns(conn, "SQLUser", "People"); + + assertEquals(2, columns.size()); + assertNull(columns.get(0).getExtra()); + assertNull(columns.get(1).getExtra()); + assertEquals(2, calls.size()); } @Test @@ -261,9 +307,10 @@ class IrisAgentTest { List columns = IrisAgent.irisColumns(conn, "SQLUser", "People"); assertTrue(columns.get(0).getIs_primary_key()); - assertEquals(2, calls.size()); + assertEquals(3, calls.size()); assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS")); assertEquals("JDBC_PRIMARY_KEYS", calls.get(1)); + assertTrue(calls.get(2).contains("IS_ROWID")); } @Test @@ -280,9 +327,11 @@ class IrisAgentTest { List columns = IrisAgent.irisColumns(conn, "SQLUser", "People"); assertTrue(columns.get(0).getIs_primary_key()); + assertEquals(4, calls.size()); assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS")); assertEquals("JDBC_PRIMARY_KEYS", calls.get(1)); assertTrue(calls.get(2).contains("INFORMATION_SCHEMA.KEY_COLUMN_USAGE")); + assertTrue(calls.get(3).contains("IS_ROWID")); } @Test @@ -300,7 +349,9 @@ class IrisAgentTest { assertFalse(columns.get(0).getIs_primary_key()); assertFalse(columns.get(1).getIs_primary_key()); + assertEquals(4, calls.size()); assertTrue(calls.get(2).contains("INFORMATION_SCHEMA.KEY_COLUMN_USAGE")); + assertTrue(calls.get(3).contains("IS_ROWID")); } private static Connection fakeConnection( @@ -309,12 +360,26 @@ class IrisAgentTest { List> jdbcPrimaryKeys, SQLException jdbcPrimaryKeyFailure, List> nativePrimaryKeys + ) { + return fakeConnection( + callLog, inlinePrimaryKey, jdbcPrimaryKeys, jdbcPrimaryKeyFailure, nativePrimaryKeys, null, null + ); + } + + private static Connection fakeConnection( + List callLog, + boolean inlinePrimaryKey, + List> jdbcPrimaryKeys, + SQLException jdbcPrimaryKeyFailure, + List> nativePrimaryKeys, + List> identityColumns, + SQLException identityQueryFailure ) { return proxy(Connection.class, (method, args) -> { if ("prepareStatement".equals(method.getName())) { String sql = (String) args[0]; callLog.add(sql); - return fakeStatement(sql, inlinePrimaryKey, nativePrimaryKeys); + return fakeStatement(sql, inlinePrimaryKey, nativePrimaryKeys, identityColumns, identityQueryFailure); } if ("getMetaData".equals(method.getName())) { return fakeMetadata(callLog, jdbcPrimaryKeys, jdbcPrimaryKeyFailure); @@ -349,10 +414,18 @@ class IrisAgentTest { private static PreparedStatement fakeStatement( String sql, boolean inlinePrimaryKey, - List> nativePrimaryKeys + List> nativePrimaryKeys, + List> identityColumns, + SQLException identityQueryFailure ) { return proxy(PreparedStatement.class, (method, args) -> { if ("executeQuery".equals(method.getName())) { + if (sql.contains("IS_ROWID")) { + if (identityQueryFailure != null) { + throw identityQueryFailure; + } + return fakeResultSet(identityColumns == null ? Collections.emptyList() : identityColumns); + } if (sql.contains("KEY_COLUMN_USAGE")) { return fakeResultSet(nativePrimaryKeys); } diff --git a/apps/desktop/src/components/grid/__tests__/DataGridBatchPasteFromCell.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridBatchPasteFromCell.spec.ts index cff9b804e4..e833dfe185 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridBatchPasteFromCell.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridBatchPasteFromCell.spec.ts @@ -93,7 +93,7 @@ interface MountGridOptions { quickEntry?: boolean; hideNullColumns?: boolean; readonlyColumnIndexes?: number[]; - databaseType?: "dameng" | "mongodb"; + databaseType?: "dameng" | "mongodb" | "iris"; customSaveHandler?: CustomSaveHandler; editable?: boolean; allowInsertRows?: boolean; @@ -864,4 +864,10 @@ describe("selected transpose edit scope", () => { await settle(); expect(transposeCells(host).map((cell) => cell.textContent?.trim())).toEqual(["third", "first"]); }); + + it("renders the add-row toolbar action for Caché / IRIS table data (#11808)", async () => { + const { host } = mountGrid({ databaseType: "iris" }); + await settle(); + expect(host.querySelector("[data-toolbar-action='addRow']")).not.toBeNull(); + }); }); diff --git a/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts index 150a219afb..dd457f4818 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts @@ -72,4 +72,8 @@ describe("databaseDriverManifest", () => { expect(databaseConnectionFormKind("mqtt")).toBe("mqtt"); expect(databaseConnectionFormKind("nacos")).toBe("nacos"); }); + + it("advertises tableDataEdit capability for InterSystems IRIS / Caché (#11808)", () => { + expect(supportsDatabaseFeature("iris", "tableDataEdit")).toBe(true); + }); }); diff --git a/apps/desktop/src/lib/__tests__/table/tableEditing.spec.ts b/apps/desktop/src/lib/__tests__/table/tableEditing.spec.ts index 94f2a5e4d8..92dd3a5a77 100644 --- a/apps/desktop/src/lib/__tests__/table/tableEditing.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableEditing.spec.ts @@ -253,4 +253,14 @@ describe("tableEditing", () => { expect(canDeleteExistingTdengineRows("salesforce", ["Id"])).toBe(true); }); }); + + it("supports InterSystems Caché / IRIS table editing and row insertion (#11808)", () => { + expect(isTableDataEditable("iris", ["id"], "TABLE")).toBe(true); + expect(isTableDataEditable("iris", [], "TABLE")).toBe(true); + expect(isTableDataEditable("iris", ["id"], "VIEW")).toBe(false); + expect(canInsertTableRows("iris")).toBe(true); + expect(canEditExistingTableRows("iris")).toBe(true); + expect(supportsDataGridTransaction("iris")).toBe(true); + expect(canUseKeylessRowPredicate("iris", [])).toBe(true); + }); }); diff --git a/apps/desktop/src/lib/database/databaseTableDataCapabilities.ts b/apps/desktop/src/lib/database/databaseTableDataCapabilities.ts index 8bc6a71fbb..94f911584c 100644 --- a/apps/desktop/src/lib/database/databaseTableDataCapabilities.ts +++ b/apps/desktop/src/lib/database/databaseTableDataCapabilities.ts @@ -88,6 +88,7 @@ const NAVICAT_STYLE_TABLE_DATA_TYPES = new Set([ "sundb", "oscar", "databend", + "iris", ]); const DATABASE_CAPABILITY_OVERRIDES: Partial>> = { diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json index cd5fb8e3b7..c0532d8d88 100644 --- a/crates/dbx-core/assets/database-drivers.manifest.json +++ b/crates/dbx-core/assets/database-drivers.manifest.json @@ -2833,7 +2833,7 @@ "objectSource": true, "schemaSearch": true, "diagram": true, - "tableDataEdit": false, + "tableDataEdit": true, "tableStructureEdit": true, "tableImport": false, "dataTransfer": true, diff --git a/crates/dbx-sql-data/src/data_grid_sql.rs b/crates/dbx-sql-data/src/data_grid_sql.rs index a700c84f3e..1a6ada288c 100644 --- a/crates/dbx-sql-data/src/data_grid_sql.rs +++ b/crates/dbx-sql-data/src/data_grid_sql.rs @@ -5020,6 +5020,97 @@ mod tests { ); } + #[test] + fn iris_keyless_save_uses_full_row_predicate() { + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Iris), + identifier_quote: None, + server_version: None, + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: Some("SQLUser".to_string()), + table_name: "Unkeyed".to_string(), + primary_keys: vec![], + columns: Some(vec![ + column("DocId", "integer", false, None), + column("Title", "varchar(64)", true, None), + ]), + }, + columns: vec!["DocId".to_string(), "Title".to_string()], + source_columns: None, + rows: vec![vec![json!(7), json!("draft")]], + dirty_rows: vec![(0, vec![(1, json!("final"))])], + deleted_rows: vec![], + new_rows: vec![], + include_database_name: false, + }); + + assert_eq!(result.validation_error, None); + assert_eq!( + result.statements, + vec!["UPDATE SQLUser.Unkeyed SET Title = 'final' WHERE DocId = 7 AND Title = 'draft';"] + ); + } + + #[test] + fn iris_keyless_save_rejects_unidentifiable_rows() { + // Mirrors the computed-column case: when no result column maps to a + // table column the row predicate is empty and the save must stay + // disabled instead of emitting a dangling WHERE. + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Iris), + identifier_quote: None, + server_version: None, + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: Some("SQLUser".to_string()), + table_name: "Unkeyed".to_string(), + primary_keys: vec![], + columns: Some(vec![column("DocId", "integer", true, None), column("Title", "varchar(64)", true, None)]), + }, + columns: vec!["expr".to_string(), "display".to_string()], + source_columns: Some(vec![None, None]), + rows: vec![vec![json!("x"), json!("draft")]], + dirty_rows: vec![(0, vec![(1, json!("final"))])], + deleted_rows: vec![], + new_rows: vec![], + include_database_name: false, + }); + + assert!(result.validation_error.is_some(), "{:?}", result.validation_error); + assert!(result.statements.is_empty()); + } + + #[test] + fn iris_identity_columns_are_omitted_from_new_row_inserts() { + let row_id = column("PAPMI_RowId", "integer", false, Some("identity")); + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Iris), + identifier_quote: None, + server_version: None, + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: Some("SQLUser".to_string()), + table_name: "PA_PatMas".to_string(), + primary_keys: vec![], + columns: Some(vec![row_id, column("PAPMI_ID", "varchar(32)", false, None)]), + }, + columns: vec!["PAPMI_RowId".to_string(), "PAPMI_ID".to_string()], + source_columns: None, + rows: vec![], + dirty_rows: vec![], + deleted_rows: vec![], + new_rows: vec![vec![Value::Null, json!("new-id")]], + include_database_name: false, + }); + + assert_eq!(result.validation_error, None); + assert_eq!(result.statements, vec!["INSERT INTO SQLUser.PA_PatMas (PAPMI_ID) VALUES ('new-id');"]); + } + #[test] fn iris_data_grid_save_quotes_only_delimited_identifiers_across_mutations() { let result = prepare_data_grid_save(DataGridSaveStatementOptions { diff --git a/plugins/connection-types/iris.yaml b/plugins/connection-types/iris.yaml index ed563e6b17..8f79d66375 100644 --- a/plugins/connection-types/iris.yaml +++ b/plugins/connection-types/iris.yaml @@ -30,7 +30,7 @@ capabilities: objectSource: true schemaSearch: true diagram: true - tableDataEdit: false + tableDataEdit: true tableStructureEdit: true tableImport: false dataTransfer: true