From 566bc87014830fb943b9c25b327a0809cc948a89 Mon Sep 17 00:00:00 2001 From: onenewcode Date: Mon, 24 Aug 2026 20:32:19 +0800 Subject: [PATCH] fix(grid): chunk Oracle CLOB literals in data grid SQL --- crates/dbx-core/src/data_grid_sql.rs | 128 +++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 70aae117bc..b00e5198d6 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -35,6 +35,8 @@ const DATA_GRID_COLUMN_DISTINCT_VALUES_DEFAULT_LIMIT: usize = 1000; const DATA_GRID_COLUMN_DISTINCT_VALUES_MAX_LIMIT: usize = 1000; const MYSQL_DATA_GRID_BATCH_MAX_ROWS: usize = 500; const MYSQL_DATA_GRID_BATCH_TARGET_SQL_BYTES: usize = 256 * 1024; +const ORACLE_SQL_LITERAL_MAX_BYTES: usize = 4000; +const ORACLE_LOB_LITERAL_CHUNK_BYTES: usize = 3900; #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] @@ -1917,12 +1919,68 @@ fn format_grid_assignment_sql_literal( database_type: Option, column_info: Option<&DataGridColumnInfo>, ) -> String { + if matches!(database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) { + if let Some(constructor) = column_info.and_then(|column| oracle_character_lob_constructor(&column.data_type)) { + if let Some(text) = value.as_str() { + return format_oracle_lob_assignment_literal(text, constructor); + } + } + } if (value.is_array() || value.is_object()) && is_json_document_column(column_info) { return format_grid_sql_literal(&Value::String(value.to_string()), database_type, column_info); } format_grid_sql_literal(value, database_type, column_info) } +fn format_oracle_lob_assignment_literal(text: &str, constructor: &str) -> String { + // Keep each Oracle SQL literal below the parser limit while preserving a CLOB result. + let escaped_len = text.chars().map(oracle_sql_literal_char_len).sum::(); + if escaped_len <= ORACLE_SQL_LITERAL_MAX_BYTES { + let mut escaped_text = String::with_capacity(escaped_len); + append_oracle_sql_literal_characters(&mut escaped_text, text); + return format!("'{escaped_text}'"); + } + + let mut chunks = Vec::new(); + let mut current = String::with_capacity(ORACLE_LOB_LITERAL_CHUNK_BYTES); + let mut current_len = 0; + for ch in text.chars() { + let char_len = oracle_sql_literal_char_len(ch); + if current_len > 0 && current_len + char_len > ORACLE_LOB_LITERAL_CHUNK_BYTES { + chunks.push(format!("{constructor}('{current}')")); + current = String::with_capacity(ORACLE_LOB_LITERAL_CHUNK_BYTES); + current_len = 0; + } + append_oracle_sql_literal_char(&mut current, ch); + current_len += char_len; + } + if current_len > 0 { + chunks.push(format!("{constructor}('{current}')")); + } + chunks.join(" || ") +} + +fn oracle_sql_literal_char_len(ch: char) -> usize { + match ch { + '\\' | '\'' => 2, + _ => ch.len_utf8(), + } +} + +fn append_oracle_sql_literal_characters(output: &mut String, text: &str) { + for ch in text.chars() { + append_oracle_sql_literal_char(output, ch); + } +} + +fn append_oracle_sql_literal_char(output: &mut String, ch: char) { + match ch { + '\\' => output.push_str("\\\\"), + '\'' => output.push_str("''"), + _ => output.push(ch), + } +} + fn is_json_document_column(column_info: Option<&DataGridColumnInfo>) -> bool { column_info.is_some_and(|column| { let data_type = column.data_type.trim(); @@ -2820,6 +2878,17 @@ fn is_oracle_lob_type(data_type: &str) -> bool { || lower.starts_with("character large object") } +fn oracle_character_lob_constructor(data_type: &str) -> Option<&'static str> { + let lower = data_type.trim().trim_matches('"').to_ascii_lowercase(); + let base = lower.split(['(', ':', ' ']).next().unwrap_or(""); + match base { + "clob" => Some("TO_CLOB"), + "nclob" => Some("TO_NCLOB"), + _ if lower.starts_with("character large object") => Some("TO_CLOB"), + _ => None, + } +} + fn is_oracle_row_id(database_type: Option, name: Option<&str>) -> bool { uses_oracle_row_id(database_type) && name.is_some_and(|name| name.eq_ignore_ascii_case(DBX_ROWID_COLUMN)) } @@ -5078,6 +5147,65 @@ mod tests { ); } + #[test] + fn prepares_oracle_clob_update_without_oversized_string_literals() { + let clob = column("body", "CLOB", true, None); + let large_value = "x".repeat(4205); + let literal = format_grid_assignment_sql_literal(&json!(large_value), Some(DatabaseType::Oracle), Some(&clob)); + let chunks = literal + .split("TO_CLOB('") + .skip(1) + .map(|chunk| chunk.split("')").next().unwrap_or_default()) + .collect::>(); + + assert_eq!(chunks.len(), 2); + assert!(chunks.iter().all(|chunk| chunk.len() <= ORACLE_LOB_LITERAL_CHUNK_BYTES)); + assert_eq!(chunks[0].len(), ORACLE_LOB_LITERAL_CHUNK_BYTES); + assert_eq!(chunks[1].len(), 4205 - ORACLE_LOB_LITERAL_CHUNK_BYTES); + assert_eq!( + format_grid_assignment_sql_literal(&json!("short"), Some(DatabaseType::Oracle), Some(&clob)), + "'short'" + ); + let nclob = column("body", "NCLOB", true, None); + assert!(format_grid_assignment_sql_literal(&json!(large_value), Some(DatabaseType::Oracle), Some(&nclob)) + .starts_with("TO_NCLOB('")); + let special_value = format!("{}'\\", "x".repeat(3998)); + let special_literal = + format_grid_assignment_sql_literal(&json!(special_value), Some(DatabaseType::Oracle), Some(&clob)); + assert!(special_literal.starts_with("TO_CLOB('")); + assert!(special_literal.contains("''")); + assert!(special_literal.contains("\\\\")); + let varchar = column("body", "VARCHAR2(5000)", true, None); + let varchar_literal = + format_grid_assignment_sql_literal(&json!(large_value), Some(DatabaseType::Oracle), Some(&varchar)); + assert!(varchar_literal.starts_with("'")); + assert!(!varchar_literal.contains("TO_CLOB(")); + + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Oracle), + identifier_quote: Some("\"".to_string()), + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: Some("APP".to_string()), + table_name: "DOCUMENTS".to_string(), + primary_keys: vec!["ID".to_string()], + columns: Some(vec![column("ID", "NUMBER", false, None), clob]), + }, + columns: vec!["ID".to_string(), "BODY".to_string()], + source_columns: None, + rows: vec![vec![json!(1), json!("old")]], + dirty_rows: vec![(0, vec![(1, json!(large_value))])], + deleted_rows: vec![], + new_rows: vec![], + }); + + assert_eq!(result.validation_error, None); + assert_eq!(result.statements.len(), 1); + assert!(result.statements[0].contains("\"BODY\" = TO_CLOB('")); + assert!(result.statements[0].contains("WHERE \"ID\" = 1;")); + } + #[test] fn formats_numeric_string_literals_for_numeric_columns_without_quotes() { let number = column("amount", "NUMBER(20,0)", true, None);