From 917bbc12385d716b148fbdeb2be404a12b3ac63a Mon Sep 17 00:00:00 2001 From: zipg Date: Mon, 24 Aug 2026 13:32:42 +0800 Subject: [PATCH] fix(oracle): speed up Oracle 11g system object column metadata loading --- crates/dbx-core/src/schema.rs | 330 +++++++++++++++++++++++----------- 1 file changed, 228 insertions(+), 102 deletions(-) diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 48088b4896..8a6f1ae8cb 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -1337,6 +1337,47 @@ fn table_comments_from_query_result(result: db::QueryResult) -> HashMap, + depth: usize, +} + +impl OracleSynonymResolver { + fn new(owner: String, name: String) -> Self { + let current = OracleObjectRef { owner, name }; + Self { current: current.clone(), visited: HashSet::from([current]), depth: 0 } + } + + fn current(&self) -> &OracleObjectRef { + &self.current + } + + fn include_public_synonym(&self) -> bool { + self.depth == 0 + } + + fn can_follow(&self) -> bool { + self.depth < ORACLE_SYNONYM_MAX_DEPTH + } + + fn follow(&mut self, target: OracleObjectRef) -> bool { + if !self.can_follow() || !self.visited.insert(target.clone()) { + return false; + } + self.current = target; + self.depth += 1; + true + } +} fn oracle_current_schema_from_query_result(result: db::QueryResult) -> Result { let schema_index = result @@ -1357,6 +1398,7 @@ fn oracle_current_schema_from_query_result(result: db::QueryResult) -> Result Result { let schema = schema.trim(); if schema.is_empty() { @@ -1371,46 +1413,7 @@ fn oracle_columns_sql_for_resolved_owner(owner: &str, table: &str) -> Result Result Result { + if owner.trim().is_empty() { + return Err("Oracle synonym query requires a resolved schema".to_string()); + } + let owner = sql_string(owner); + let owner_filter = + if include_public { format!("s.OWNER IN ({owner}, 'PUBLIC')") } else { format!("s.OWNER = {owner}") }; + Ok(format!( + "SELECT s.TABLE_OWNER, s.TABLE_NAME \ + FROM ALL_SYNONYMS s \ + WHERE s.SYNONYM_NAME = {} \ + AND {owner_filter} \ + AND s.DB_LINK IS NULL \ + ORDER BY CASE WHEN s.OWNER = {owner} THEN 0 ELSE 1 END", + sql_string(table), + )) +} + +fn oracle_synonym_target_from_query_result(result: db::QueryResult) -> Option { + let owner_index = result.columns.iter().position(|column| column.eq_ignore_ascii_case("TABLE_OWNER"))?; + let table_index = result.columns.iter().position(|column| column.eq_ignore_ascii_case("TABLE_NAME"))?; + let row = result.rows.first()?; + Some(OracleObjectRef { + owner: query_result_cell_string(row, owner_index)?, + name: query_result_cell_string(row, table_index)?, + }) +} + fn oracle_column_type(data_type: &str, precision: Option, scale: Option, length: Option) -> String { match data_type.to_ascii_uppercase().as_str() { "NUMBER" => match (precision, scale) { @@ -1495,8 +1526,8 @@ async fn oracle_columns_via_sql( timeout_duration: Option, ) -> Result, String> { let request_schema = (!schema.trim().is_empty()).then_some(schema); - let sql = if let Some(schema) = request_schema { - oracle_columns_sql(schema, table)? + let owner = if let Some(schema) = request_schema { + schema.trim().to_uppercase() } else { let result = client .execute_query_with_timeout::( @@ -1509,21 +1540,52 @@ async fn oracle_columns_via_sql( timeout_duration, ) .await?; - let current_schema = oracle_current_schema_from_query_result(result)?; - oracle_columns_sql_for_resolved_owner(¤t_schema, table)? + oracle_current_schema_from_query_result(result)? }; - let result = client - .execute_query_with_timeout::( - agent_execute_query_params( - &sql, - if database.is_empty() { None } else { Some(database) }, - request_schema, - QueryExecutionOptions { max_rows: Some(10_000), ..Default::default() }, - ), - timeout_duration, - ) - .await?; - Ok(deduplicate_column_infos(oracle_columns_from_query_result(result))) + let mut resolver = OracleSynonymResolver::new(owner, table.to_string()); + + loop { + let object = resolver.current(); + let sql = oracle_columns_sql_for_resolved_owner(&object.owner, &object.name)?; + let result = client + .execute_query_with_timeout::( + agent_execute_query_params( + &sql, + if database.is_empty() { None } else { Some(database) }, + request_schema, + QueryExecutionOptions { max_rows: Some(10_000), ..Default::default() }, + ), + timeout_duration, + ) + .await?; + let columns = oracle_columns_from_query_result(result); + if !columns.is_empty() { + return Ok(deduplicate_column_infos(columns)); + } + if !resolver.can_follow() { + return Ok(Vec::new()); + } + + let object = resolver.current(); + let sql = oracle_synonym_target_sql(&object.owner, &object.name, resolver.include_public_synonym())?; + let result = client + .execute_query_with_timeout::( + agent_execute_query_params( + &sql, + if database.is_empty() { None } else { Some(database) }, + request_schema, + QueryExecutionOptions { max_rows: Some(1), ..Default::default() }, + ), + timeout_duration, + ) + .await?; + let Some(target) = oracle_synonym_target_from_query_result(result) else { + return Ok(Vec::new()); + }; + if !resolver.follow(target) { + return Ok(Vec::new()); + } + } } async fn external_driver_oracle_columns_via_sql( @@ -1534,7 +1596,7 @@ async fn external_driver_oracle_columns_via_sql( table: &str, ) -> Result, String> { let request_schema = if schema.trim().is_empty() { "" } else { schema }; - let sql = if request_schema.is_empty() { + let owner = if request_schema.is_empty() { let result: db::QueryResult = session .invoke_with_timeout( "executeQuery", @@ -1548,25 +1610,58 @@ async fn external_driver_oracle_columns_via_sql( agent_metadata_timeout(Some(config)), ) .await?; - let current_schema = oracle_current_schema_from_query_result(result)?; - oracle_columns_sql_for_resolved_owner(¤t_schema, table)? + oracle_current_schema_from_query_result(result)? } else { - oracle_columns_sql(schema, table)? + schema.trim().to_uppercase() }; - let result: db::QueryResult = session - .invoke_with_timeout( - "executeQuery", - serde_json::json!({ - "connection": config, - "database": database, - "schema": request_schema, - "sql": sql, - "maxRows": 10_000 - }), - agent_metadata_timeout(Some(config)), - ) - .await?; - Ok(deduplicate_column_infos(oracle_columns_from_query_result(result))) + let mut resolver = OracleSynonymResolver::new(owner, table.to_string()); + + loop { + let object = resolver.current(); + let sql = oracle_columns_sql_for_resolved_owner(&object.owner, &object.name)?; + let result: db::QueryResult = session + .invoke_with_timeout( + "executeQuery", + serde_json::json!({ + "connection": config, + "database": database, + "schema": request_schema, + "sql": sql, + "maxRows": 10_000 + }), + agent_metadata_timeout(Some(config)), + ) + .await?; + let columns = oracle_columns_from_query_result(result); + if !columns.is_empty() { + return Ok(deduplicate_column_infos(columns)); + } + if !resolver.can_follow() { + return Ok(Vec::new()); + } + + let object = resolver.current(); + let sql = oracle_synonym_target_sql(&object.owner, &object.name, resolver.include_public_synonym())?; + let result: db::QueryResult = session + .invoke_with_timeout( + "executeQuery", + serde_json::json!({ + "connection": config, + "database": database, + "schema": request_schema, + "sql": sql, + "maxRows": 1 + }), + agent_metadata_timeout(Some(config)), + ) + .await?; + let Some(target) = oracle_synonym_target_from_query_result(result) else { + return Ok(Vec::new()); + }; + if !resolver.follow(target) { + return Ok(Vec::new()); + } + } } fn should_query_oracle_columns_via_sql_first( @@ -2890,14 +2985,16 @@ mod tests { oracle_columns_sql, oracle_columns_sql_for_resolved_owner, oracle_current_schema_from_query_result, oracle_object_statistics_dba_segments_sql, oracle_object_statistics_from_query_result, oracle_object_statistics_rows_only_sql, oracle_object_statistics_sql, - oracle_object_statistics_user_segments_sql, oracle_table_comment_from_query_result, oracle_table_comment_sql, - oracle_table_comments_sql, presto_like_columns_from_query_result, presto_like_information_schema_columns_sql, + oracle_object_statistics_user_segments_sql, oracle_synonym_target_from_query_result, oracle_synonym_target_sql, + oracle_table_comment_from_query_result, oracle_table_comment_sql, oracle_table_comments_sql, + presto_like_columns_from_query_result, presto_like_information_schema_columns_sql, presto_like_information_schema_tables_sql, presto_like_tables_from_query_result, reference_key_columns_from_indexes, replace_metadata_runtime, should_query_oracle_columns_via_sql_first, table_comments_from_query_result, table_name_filter_matches, tdengine_table_comment_like_pattern, tdengine_table_comment_sql, tdengine_table_comments_sql, uses_mongodb_agent_collection_listing, - visible_schema_filter, MetadataErrorAction, MysqlTableListSource, TableNameFilter, ORACLE_CURRENT_SCHEMA_SQL, - TDENGINE_COMMENT_SEARCH_TIMEOUT, TDENGINE_LIKE_PATTERN_MAX_BYTES, + visible_schema_filter, MetadataErrorAction, MysqlTableListSource, OracleObjectRef, OracleSynonymResolver, + TableNameFilter, ORACLE_CURRENT_SCHEMA_SQL, ORACLE_SYNONYM_MAX_DEPTH, TDENGINE_COMMENT_SEARCH_TIMEOUT, + TDENGINE_LIKE_PATTERN_MAX_BYTES, }; use super::{list_databases_core, list_tables_core}; use super::{ @@ -4676,24 +4773,29 @@ for line in sys.stdin: assert!(sql.contains("ALL_TAB_COLUMNS")); assert!(sql.contains("ALL_COL_COMMENTS")); assert!(!sql.contains("SYS_CONTEXT")); - assert!(sql.contains("o.OWNER = 'DBX_TEST'")); - assert!(sql.contains("o.OBJECT_NAME = 'test'")); + assert!(!sql.contains("ALL_SYNONYMS")); + assert!(!sql.contains("CONNECT BY")); + assert!(sql.contains("c.OWNER = 'DBX_TEST'")); + assert!(sql.contains("c.TABLE_NAME = 'test'")); assert!(sql.contains("cols.OWNER = c.OWNER")); assert!(sql.contains("cols.TABLE_NAME = c.TABLE_NAME")); assert!(sql.contains("cm.OWNER = c.OWNER")); } #[test] - fn oracle_columns_sql_resolves_private_and_public_synonyms_in_oracle_precedence_order() { - let sql = oracle_columns_sql_for_resolved_owner("Dbx'Owner", "ORDERS_ALIAS").unwrap(); + fn oracle_synonym_sql_resolves_private_before_public_without_hierarchical_queries() { + let sql = oracle_synonym_target_sql("Dbx'Owner", "ORDERS_ALIAS", true).unwrap(); assert!(!sql.contains("SYS_CONTEXT")); assert!(sql.contains("FROM ALL_SYNONYMS s")); assert!(sql.contains("s.OWNER IN ('Dbx''Owner', 'PUBLIC')")); - assert!(sql.contains("CASE WHEN sc.root_owner = 'Dbx''Owner' THEN 1 ELSE 2 END")); + assert!(sql.contains("CASE WHEN s.OWNER = 'Dbx''Owner' THEN 0 ELSE 1 END")); assert!(sql.contains("s.DB_LINK IS NULL")); - assert!(sql.contains("ORDER BY resolution_priority, synonym_depth")); - assert!(sql.contains("WHERE ROWNUM = 1")); + assert!(!sql.contains("CONNECT BY")); + + let nested_sql = oracle_synonym_target_sql("OTHER_OWNER", "ORDERS_ALIAS_2", false).unwrap(); + assert!(nested_sql.contains("s.OWNER = 'OTHER_OWNER'")); + assert!(!nested_sql.contains("s.OWNER IN")); } #[test] @@ -4711,9 +4813,10 @@ for line in sys.stdin: .unwrap(); assert_eq!(current_schema, "Mixed'Case"); let sql = oracle_columns_sql_for_resolved_owner(¤t_schema, "ORDERS_ALIAS").unwrap(); + let synonym_sql = oracle_synonym_target_sql(¤t_schema, "ORDERS_ALIAS", true).unwrap(); - assert!(sql.contains("s.OWNER IN ('Mixed''Case', 'PUBLIC')")); - assert!(sql.contains("o.OWNER = 'Mixed''Case'")); + assert!(sql.contains("c.OWNER = 'Mixed''Case'")); + assert!(synonym_sql.contains("s.OWNER IN ('Mixed''Case', 'PUBLIC')")); assert!(!sql.contains("SYS_CONTEXT")); } @@ -4739,27 +4842,50 @@ for line in sys.stdin: } #[test] - fn oracle_columns_sql_follows_two_level_synonyms_without_cycles() { - let sql = oracle_columns_sql("DBX_TEST", "ORDERS_ALIAS").unwrap(); + fn oracle_synonym_resolver_follows_two_levels_and_rejects_cycles() { + let initial = OracleObjectRef { owner: "DBX_TEST".to_string(), name: "ORDERS_ALIAS".to_string() }; + let mut resolver = OracleSynonymResolver::new(initial.owner.clone(), initial.name.clone()); - assert!(sql.contains("SELECT CONNECT_BY_ROOT s.OWNER AS root_owner")); - assert!(sql.contains("LEVEL AS synonym_depth")); - assert!(sql.contains("CONNECT BY NOCYCLE")); - assert!(sql.contains("PRIOR s.TABLE_OWNER = s.OWNER")); - assert!(sql.contains("PRIOR s.TABLE_NAME = s.SYNONYM_NAME")); - assert!(sql.contains("JOIN ALL_OBJECTS o")); - assert!(sql.contains("o.OWNER = sc.resolved_owner")); - assert!(sql.contains("o.OBJECT_NAME = sc.resolved_table")); + assert_eq!(resolver.current(), &initial); + assert!(resolver.include_public_synonym()); + assert!(resolver.follow(OracleObjectRef { owner: "APP".to_string(), name: "ORDERS_ALIAS_2".to_string() })); + assert!(!resolver.include_public_synonym()); + assert!(resolver.follow(OracleObjectRef { owner: "DATA".to_string(), name: "ORDERS".to_string() })); + assert_eq!(resolver.current().owner, "DATA"); + assert_eq!(resolver.current().name, "ORDERS"); + assert!(!resolver.follow(initial)); } #[test] fn oracle_columns_sql_preserves_quoted_case_synonym_names_and_excludes_database_links() { - let sql = oracle_columns_sql("DBX_TEST", "Order Alias").unwrap(); + let sql = oracle_synonym_target_sql("DBX_TEST", "Order Alias", true).unwrap(); assert!(sql.contains("s.SYNONYM_NAME = 'Order Alias'")); - assert!(sql.contains("o.OBJECT_NAME = 'Order Alias'")); assert!(!sql.contains("ORDER ALIAS")); - assert_eq!(sql.matches("s.DB_LINK IS NULL").count(), 2); + assert_eq!(sql.matches("s.DB_LINK IS NULL").count(), 1); + } + + #[test] + fn oracle_synonym_target_preserves_dictionary_identifier_values() { + let target = oracle_synonym_target_from_query_result(oracle_current_schema_result( + &["table_name", "table_owner"], + vec![vec![serde_json::json!("Order Detail"), serde_json::json!("Mixed Owner")]], + )) + .unwrap(); + + assert_eq!(target.owner, "Mixed Owner"); + assert_eq!(target.name, "Order Detail"); + } + + #[test] + fn oracle_synonym_resolver_enforces_maximum_depth() { + let mut resolver = OracleSynonymResolver::new("DBX_TEST".to_string(), "ALIAS_0".to_string()); + for depth in 1..=ORACLE_SYNONYM_MAX_DEPTH { + assert!(resolver.follow(OracleObjectRef { owner: "DBX_TEST".to_string(), name: format!("ALIAS_{depth}") })); + } + + assert!(!resolver.can_follow()); + assert!(!resolver.follow(OracleObjectRef { owner: "DBX_TEST".to_string(), name: "ALIAS_TOO_DEEP".to_string() })); } #[test]