diff --git a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts index c2eadb310e..111de824d8 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts @@ -612,6 +612,81 @@ describe("queryStore hidden primary key editing", () => { expect(tab.queryEditabilityReason).toBeUndefined(); }, 10_000); + it.each([ + { + connection: { id: "oracle-1", name: "Oracle", db_type: "oracle", database: "XE", query_timeout_secs: 30 }, + label: "native Oracle", + }, + { + connection: { id: "oracle-jdbc-1", name: "Oracle JDBC", db_type: "jdbc", connection_string: "jdbc:oracle:thin:@//localhost:1521/XE", database: "XE", query_timeout_secs: 30 }, + label: "Oracle-inferred JDBC", + }, + ])("edits an unqualified lowercase query through the folded current-schema table for $label", async ({ connection }) => { + getConnectionConfig.mockReturnValue(connection); + getColumns.mockResolvedValue([ + { name: "ID", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "NAME", data_type: "VARCHAR2(100)", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ]); + listIndexes.mockResolvedValue([]); + analyzeEditableQueryEditability.mockResolvedValue({ + editable: true, + analysis: { + schema: undefined, + tableName: "imp_t", + selectStar: true, + columns: [], + }, + }); + executeMulti.mockResolvedValue([{ columns: ["ID", "NAME"], rows: [[1, "Alice"]], affected_rows: 0, execution_time_ms: 1 }]); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab(connection.id, "XE", "Query"); + store.setAutoCommit(tabId, true); + + await store.executeTabSql(tabId, "select * from imp_t"); + + // The service name must not leak into the metadata schema, and the stored + // table spelling has to fold to uppercase so the generated UPDATE quotes + // the object Oracle actually resolved for the unqualified SELECT. + expect(getColumns).toHaveBeenCalledWith(connection.id, "XE", "", "IMP_T", undefined); + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.tableMeta?.tableName).toBe("IMP_T")); + expect(tab.tableMeta?.schema).toBeUndefined(); + expect(tab.queryEditabilityReason).toBeUndefined(); + }); + + it("keeps an unqualified lowercase MySQL JDBC query on its unfolded table name", async () => { + getConnectionConfig.mockReturnValue({ id: "jdbc-mysql-1", name: "MySQL JDBC", db_type: "jdbc", connection_string: "jdbc:mysql://localhost:3306/app", database: "app", query_timeout_secs: 30 }); + getColumns.mockResolvedValue([ + { name: "id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "name", data_type: "varchar(64)", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ]); + analyzeEditableQueryEditability.mockResolvedValue({ + editable: true, + analysis: { + schema: undefined, + tableName: "users", + selectStar: true, + columns: [], + }, + }); + executeMulti.mockResolvedValue([{ columns: ["id", "name"], rows: [[1, "Alice"]], affected_rows: 0, execution_time_ms: 1 }]); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("jdbc-mysql-1", "app", "Query"); + store.setAutoCommit(tabId, true); + + await store.executeTabSql(tabId, "select * from users"); + + expect(getColumns).toHaveBeenCalledWith("jdbc-mysql-1", "app", "", "users", undefined); + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.tableMeta?.tableName).toBe("users")); + expect(tab.tableMeta?.schema).toBeUndefined(); + expect(tab.queryEditabilityReason).toBeUndefined(); + }); + it("uses the configured current schema to keep an unqualified Oracle base-table query editable", async () => { getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "ORCL", default_schema: "APP", query_timeout_secs: 30 }); getColumns.mockResolvedValue([ diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 79f1e94e9e..6bff1dfa34 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -4684,6 +4684,12 @@ export const useQueryStore = defineStore("query", () => { } function resolveEditableSourceMetadataTarget(tab: QueryTab, analysis: EditableQueryInfo, source: EditableQuerySource, conn: ConnectionConfig | undefined, dbType: string, executionDatabase: string): EditableSourceMetadataTarget { + // Oracle-family metadata rules (schema-less resolution + uppercase folding) + // must follow the connection's effective database type. Callers built from + // the raw connection db_type pass "jdbc" for a JDBC Oracle connection, + // which would keep the service-name schema fallback and the query's + // lowercase table spelling, so the write targets a non-existent table. + const metadataDbType = effectiveDatabaseTypeForConnection(conn) || dbType; // Metadata must resolve in the same namespace as the query execution. An // empty query-tab database still executes in the connection's default DB, // while database-tree dialects and SQL Server 3-part names may override it @@ -4707,10 +4713,10 @@ export const useQueryStore = defineStore("query", () => { // connection's search_path. Keep the metadata request unqualified when no // schema was selected instead of assuming public (or the database name). const useCurrentPostgresSchema = (dbType === "postgres" || dbType === "kwdb") && !source.schema && !tab.schema; - const resolvedSchema = (dbType === "sqlserver" && !source.schema) || (ORACLE_LIKE_METADATA_TYPES.has(dbType) && !schema) || resolveAgentSearchPathSchema || useCurrentPostgresSchema ? "" : metadataSchemaForConnection(conn, metadataDatabase, schema || undefined); - const metadataSchema = normalizeUppercaseFoldedMetadataIdentifier(dbType, resolvedSchema || undefined, source.schema ? source.schemaQuoted : false) || ""; - const metadataTableName = normalizeUppercaseFoldedMetadataIdentifier(dbType, source.tableName, source.tableNameQuoted)!; - const metadataCatalog = normalizeUppercaseFoldedMetadataIdentifier(dbType, source.catalog, source.catalogQuoted); + const resolvedSchema = (dbType === "sqlserver" && !source.schema) || (ORACLE_LIKE_METADATA_TYPES.has(metadataDbType) && !schema) || resolveAgentSearchPathSchema || useCurrentPostgresSchema ? "" : metadataSchemaForConnection(conn, metadataDatabase, schema || undefined); + const metadataSchema = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, resolvedSchema || undefined, source.schema ? source.schemaQuoted : false) || ""; + const metadataTableName = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, source.tableName, source.tableNameQuoted)!; + const metadataCatalog = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, source.catalog, source.catalogQuoted); const metadataSource: EditableQuerySource = { ...source, catalog: metadataCatalog, @@ -4723,7 +4729,7 @@ export const useQueryStore = defineStore("query", () => { const knownTableType = tab.tableMeta?.tableName.toLowerCase() === metadataTableName.toLowerCase() && normalizeOptionalSchema(tab.tableMeta.schema) === normalizeOptionalSchema(metadataSchema) ? tab.tableMeta.tableType : undefined; return { source: metadataSource, - analysis: normalizeUppercaseFoldedQueryAnalysis(dbType, cloneAnalysisForSource(analysis, metadataSource), metadataSchema || undefined, metadataTableName), + analysis: normalizeUppercaseFoldedQueryAnalysis(metadataDbType, cloneAnalysisForSource(analysis, metadataSource), metadataSchema || undefined, metadataTableName), writeSchema, request: { connectionId: tab.connectionId!, diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index ce00834d1b..30022b4660 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -6713,6 +6713,34 @@ mod tests { ); } + #[test] + fn prepares_oracle_update_without_schema_when_the_frontend_resolved_the_current_schema() { + // A JDBC Oracle edit resolves the login user's schema on the frontend and + // sends the folded table name with an empty schema. The generated UPDATE + // must stay unqualified instead of reintroducing the service name. + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Oracle), + identifier_quote: None, + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: None, + table_name: "IMP_T".to_string(), + primary_keys: vec!["ID".to_string()], + columns: Some(vec![column("ID", "NUMBER", false, None), column("NAME", "VARCHAR2(100)", true, None)]), + }, + columns: vec!["ID".to_string(), "NAME".to_string()], + source_columns: None, + rows: vec![vec![json!(7), json!("old")]], + dirty_rows: vec![(0, vec![(1, json!("new"))])], + deleted_rows: vec![], + new_rows: vec![], + }); + + assert_eq!(result.validation_error, None); + assert_eq!(result.statements, vec!["UPDATE \"IMP_T\" SET \"NAME\" = 'new' WHERE \"ID\" = 7;"]); + } + #[test] fn oracle_raw_literals_require_valid_even_length_hex() { let raw = column("ID", "RAW(16)", false, None);