From 4e686bd00c9253fb4d63b604cee4813089d42d51 Mon Sep 17 00:00:00 2001 From: zccat <46469775+zccat@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:56:03 +0800 Subject: [PATCH] fix(opengauss): escape comment quotes in pg_get_tabledef ddl --- crates/dbx-core/src/database_export.rs | 130 +------------------- crates/dbx-core/src/schema.rs | 161 +++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 125 deletions(-) diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 8a5a6a2113..b4f290d066 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -1432,135 +1432,15 @@ fn normalize_export_table_ddl( fn format_export_table_ddl(ddl: &str, database_type: Option, opts: DdlNormalizeOptions) -> String { let ddl = normalize_export_table_ddl(ddl, database_type, opts); - let ddl = - if database_type == Some(DatabaseType::OpenGauss) { normalize_opengauss_table_ddl_comments(&ddl) } else { ddl }; + let ddl = if database_type == Some(DatabaseType::OpenGauss) { + crate::schema::normalize_opengauss_table_ddl_comments(&ddl) + } else { + ddl + }; let ddl = ddl.trim().trim_end_matches(';').trim_end(); format!("{ddl};") } -/// openGauss 6.x `pg_get_tabledef` can concatenate comment text into a -/// `COMMENT ON` literal without escaping embedded single quotes. Normalize -/// only those generated comment statements; all other DDL text remains -/// untouched. -fn normalize_opengauss_table_ddl_comments(ddl: &str) -> String { - let mut normalized = String::with_capacity(ddl.len()); - for line in ddl.split_inclusive('\n') { - let (line_body, line_ending) = match line.strip_suffix('\n') { - Some(body) => match body.strip_suffix('\r') { - Some(body) => (body, "\r\n"), - None => (body, "\n"), - }, - None => (line, ""), - }; - let leading = line_body.len() - line_body.trim_start_matches(|ch: char| ch.is_ascii_whitespace()).len(); - let statement = &line_body[leading..]; - if let Some(statement) = normalize_opengauss_comment_statement(statement) { - normalized.push_str(&line_body[..leading]); - normalized.push_str(&statement); - } else { - normalized.push_str(line_body); - } - normalized.push_str(line_ending); - } - normalized -} - -fn normalize_opengauss_comment_statement(statement: &str) -> Option { - let uppercase = statement.to_ascii_uppercase(); - if !uppercase.starts_with("COMMENT ON ") { - return None; - } - - let is_pos = find_opengauss_comment_is_keyword(statement, &uppercase)?; - let value_start = is_pos + " IS ".len(); - let value = &statement[value_start..]; - let value_leading = value.len() - value.trim_start_matches(|ch: char| ch.is_ascii_whitespace()).len(); - let value = &value[value_leading..]; - let quote_offset = match value.as_bytes() { - [b'\'', ..] => 0, - [b'e' | b'E', b'\'', ..] => 1, - _ => return None, - }; - let opening_quote = value_start + value_leading + quote_offset; - let statement_end = statement.trim_end().len(); - let literal_end = statement[..statement_end] - .strip_suffix(';') - .map_or(statement_end, |without_terminator| without_terminator.len()); - if opening_quote >= literal_end { - return None; - } - - let closing_quote = statement[..literal_end].rfind('\'')?; - if closing_quote <= opening_quote || !statement[closing_quote + 1..literal_end].trim().is_empty() { - return None; - } - let literal = &statement[opening_quote..=closing_quote]; - if opengauss_comment_literal_is_valid(literal) { - return Some(statement.to_string()); - } - - let raw_comment = &statement[opening_quote + 1..closing_quote]; - let escaped_comment = raw_comment.replace('\'', "''"); - let mut normalized = String::with_capacity(statement.len() + escaped_comment.len() - raw_comment.len()); - normalized.push_str(&statement[..opening_quote + 1]); - normalized.push_str(&escaped_comment); - normalized.push_str(&statement[closing_quote..]); - Some(normalized) -} - -fn find_opengauss_comment_is_keyword(statement: &str, uppercase: &str) -> Option { - let mut cursor = "COMMENT ON ".len(); - while cursor + " IS ".len() <= statement.len() { - if statement.as_bytes().get(cursor) == Some(&b'\"') { - cursor = skip_opengauss_quoted_identifier(statement, cursor); - continue; - } - if uppercase.get(cursor..cursor + " IS ".len()) == Some(" IS ") { - return Some(cursor); - } - cursor += statement[cursor..].chars().next()?.len_utf8(); - } - None -} - -fn skip_opengauss_quoted_identifier(sql: &str, start: usize) -> usize { - let bytes = sql.as_bytes(); - let mut cursor = start + 1; - while cursor < bytes.len() { - if bytes[cursor] == b'\"' { - if bytes.get(cursor + 1) == Some(&b'\"') { - cursor += 2; - } else { - return cursor + 1; - } - } else { - cursor += 1; - } - } - bytes.len() -} - -fn opengauss_comment_literal_is_valid(literal: &str) -> bool { - let bytes = literal.as_bytes(); - if bytes.len() < 2 || bytes.first() != Some(&b'\'') || bytes.last() != Some(&b'\'') { - return false; - } - - let mut cursor = 1; - while cursor < bytes.len() - 1 { - if bytes[cursor] == b'\'' { - if cursor + 1 < bytes.len() - 1 && bytes[cursor + 1] == b'\'' { - cursor += 2; - } else { - return false; - } - } else { - cursor += 1; - } - } - true -} - fn split_postgres_export_table_triggers(ddl: &str, database_type: DatabaseType) -> (String, Vec) { if database_type != DatabaseType::Postgres { return (ddl.to_string(), Vec::new()); diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 11e73a9f50..9879c1353a 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -10898,6 +10898,40 @@ mod ddl_tests { )); } + #[test] + fn opengauss_ddl_comment_normalization_escapes_unescaped_quotes() { + // openGauss 6.x pg_get_tabledef concatenates the stored comment into + // the COMMENT ON literal verbatim; embedded single quotes make the + // statement invalid. Everything downstream (transfer table creation, + // export, UI display) executes this DDL, so the quotes must be doubled. + let ddl = concat!( + "SET search_path = public;\n", + "CREATE TABLE \"public\".\"dpms_doctor_surgery_record\" (\"del_flag\" char(1));\n", + "COMMENT ON COLUMN \"public\".\"dpms_doctor_surgery_record\".\"del_flag\" IS '逻辑删除标志:'0'-未删除,'1'-已删除';" + ); + + assert_eq!( + normalize_opengauss_table_ddl_comments(ddl), + concat!( + "SET search_path = public;\n", + "CREATE TABLE \"public\".\"dpms_doctor_surgery_record\" (\"del_flag\" char(1));\n", + "COMMENT ON COLUMN \"public\".\"dpms_doctor_surgery_record\".\"del_flag\" IS '逻辑删除标志:''0''-未删除,''1''-已删除';" + ) + ); + } + + #[test] + fn opengauss_ddl_comment_normalization_leaves_valid_ddl_unchanged() { + let ddl = concat!( + "SET search_path = public;\n", + "CREATE TABLE \"public\".\"notes\" (\"body\" text DEFAULT 'O''Hara');\n", + "COMMENT ON TABLE \"public\".\"notes\" IS 'owner''s note';\n", + "GRANT SELECT ON TABLE \"public\".\"notes\" TO \"auditor's role\";" + ); + + assert_eq!(normalize_opengauss_table_ddl_comments(ddl), ddl); + } + #[test] fn mysql_display_ddl_gets_statement_terminator() { let ddl = "CREATE TABLE `users` (\n `id` int NOT NULL\n) ENGINE=InnoDB"; @@ -11774,6 +11808,10 @@ pub async fn opengauss_table_ddl(pool: &deadpool_postgres::Pool, schema: &str, t db::postgres::list_trigger_definitions(pool, schema, table), )?; + // Repair the server's comment literals before anything downstream executes + // this DDL: every consumer (transfer table creation, export, UI display) + // must receive executable SQL, not the raw pg_get_tabledef output. + let ddl = normalize_opengauss_table_ddl_comments(&ddl); Ok(append_opengauss_trigger_definitions(ddl, &trigger_definitions)) } @@ -11782,6 +11820,129 @@ pub fn opengauss_table_ddl_sql(schema: &str, table: &str) -> String { format!("SELECT pg_get_tabledef({})", sql_string(&qualified_name)) } +/// openGauss 6.x `pg_get_tabledef` can concatenate comment text into a +/// `COMMENT ON` literal without escaping embedded single quotes. Normalize +/// only those generated comment statements; all other DDL text remains +/// untouched. +pub(crate) fn normalize_opengauss_table_ddl_comments(ddl: &str) -> String { + let mut normalized = String::with_capacity(ddl.len()); + for line in ddl.split_inclusive('\n') { + let (line_body, line_ending) = match line.strip_suffix('\n') { + Some(body) => match body.strip_suffix('\r') { + Some(body) => (body, "\r\n"), + None => (body, "\n"), + }, + None => (line, ""), + }; + let leading = line_body.len() - line_body.trim_start_matches(|ch: char| ch.is_ascii_whitespace()).len(); + let statement = &line_body[leading..]; + if let Some(statement) = normalize_opengauss_comment_statement(statement) { + normalized.push_str(&line_body[..leading]); + normalized.push_str(&statement); + } else { + normalized.push_str(line_body); + } + normalized.push_str(line_ending); + } + normalized +} + +fn normalize_opengauss_comment_statement(statement: &str) -> Option { + let uppercase = statement.to_ascii_uppercase(); + if !uppercase.starts_with("COMMENT ON ") { + return None; + } + + let is_pos = find_opengauss_comment_is_keyword(statement, &uppercase)?; + let value_start = is_pos + " IS ".len(); + let value = &statement[value_start..]; + let value_leading = value.len() - value.trim_start_matches(|ch: char| ch.is_ascii_whitespace()).len(); + let value = &value[value_leading..]; + let quote_offset = match value.as_bytes() { + [b'\'', ..] => 0, + [b'e' | b'E', b'\'', ..] => 1, + _ => return None, + }; + let opening_quote = value_start + value_leading + quote_offset; + let statement_end = statement.trim_end().len(); + let literal_end = statement[..statement_end] + .strip_suffix(';') + .map_or(statement_end, |without_terminator| without_terminator.len()); + if opening_quote >= literal_end { + return None; + } + + let closing_quote = statement[..literal_end].rfind('\'')?; + if closing_quote <= opening_quote || !statement[closing_quote + 1..literal_end].trim().is_empty() { + return None; + } + let literal = &statement[opening_quote..=closing_quote]; + if opengauss_comment_literal_is_valid(literal) { + return Some(statement.to_string()); + } + + let raw_comment = &statement[opening_quote + 1..closing_quote]; + let escaped_comment = raw_comment.replace('\'', "''"); + let mut normalized = String::with_capacity(statement.len() + escaped_comment.len() - raw_comment.len()); + normalized.push_str(&statement[..opening_quote + 1]); + normalized.push_str(&escaped_comment); + normalized.push_str(&statement[closing_quote..]); + Some(normalized) +} + +fn find_opengauss_comment_is_keyword(statement: &str, uppercase: &str) -> Option { + let mut cursor = "COMMENT ON ".len(); + while cursor + " IS ".len() <= statement.len() { + if statement.as_bytes().get(cursor) == Some(&b'"') { + cursor = skip_opengauss_quoted_identifier(statement, cursor); + continue; + } + if uppercase.get(cursor..cursor + " IS ".len()) == Some(" IS ") { + return Some(cursor); + } + cursor += statement[cursor..].chars().next()?.len_utf8(); + } + None +} + +fn skip_opengauss_quoted_identifier(sql: &str, start: usize) -> usize { + let bytes = sql.as_bytes(); + let mut cursor = start + 1; + while cursor < bytes.len() { + if bytes[cursor] == b'"' { + if bytes.get(cursor + 1) == Some(&b'"') { + cursor += 2; + } else { + return cursor + 1; + } + } else { + cursor += 1; + } + } + bytes.len() +} + +fn opengauss_comment_literal_is_valid(literal: &str) -> bool { + let bytes = literal.as_bytes(); + if bytes.len() < 2 || bytes.first() != Some(&b'\'') || bytes.last() != Some(&b'\'') { + return false; + } + + let mut cursor = 1; + while cursor < bytes.len() - 1 { + if bytes[cursor] == b'\'' { + if cursor + 1 < bytes.len() - 1 && bytes[cursor + 1] == b'\'' { + cursor += 2; + } else { + return false; + } + } else { + cursor += 1; + } + } + true +} + fn append_postgres_trigger_definitions(mut ddl: String, trigger_definitions: &[String]) -> String { for definition in trigger_definitions.iter().map(|definition| definition.trim()).filter(|definition| !definition.is_empty())