From a82ccdecd19bb4bca74cfead69a7d8b05888a8a9 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 13 Aug 2026 13:39:28 +0800 Subject: [PATCH] fix(oracle): export explicit timestamp literals Closes #6078 --- crates/dbx-core/src/database_export.rs | 132 +++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 10 deletions(-) diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 12f2b27660..6589aec6d0 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -401,7 +401,7 @@ fn format_export_sql_literal_typed( return format_ch_array_sql_literal(arr); } } - if let Some(literal) = format_oracle_export_date_literal(value, database_type, column_type) { + if let Some(literal) = format_oracle_export_temporal_literal(value, database_type, column_type) { return literal; } if let Some(literal) = format_export_temporal_literal(value, database_type, column_type) { @@ -523,7 +523,7 @@ fn is_mysql_compatible_export_literal_target(database_type: Option ) } -fn format_oracle_export_date_literal( +fn format_oracle_export_temporal_literal( value: &Value, database_type: Option, column_type: Option<&str>, @@ -531,16 +531,27 @@ fn format_oracle_export_date_literal( if !matches!(database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) { return None; } - if export_temporal_column_kind(database_type, column_type?)? != ExportTemporalKind::DateTime { - return None; - } - let lower = column_type?.trim().trim_matches('"').to_ascii_lowercase(); + let column_type = column_type?; + let lower = column_type.trim().trim_matches('"').to_ascii_lowercase(); let base = lower.split(['(', ' ', '\t', '\n']).next().unwrap_or(""); - if base != "date" { - return None; - } + let kind = match base { + "timestampdty" => ExportTemporalKind::DateTime, + "timestamptz_dty" => ExportTemporalKind::DateTimeWithTimeZone, + _ => export_temporal_column_kind(database_type, column_type)?, + }; let parts = parse_export_date_parts(value.as_str()?)?; - Some(format_oracle_export_date_parts_literal(&parts)) + if base == "date" { + return Some(format_oracle_export_date_parts_literal(&parts)); + } + + let fraction = parts.fraction.as_deref().unwrap_or_default(); + let datetime = format!("{} {}{fraction}", parts.date, parts.time); + let mask = if fraction.is_empty() { "YYYY-MM-DD HH24:MI:SS" } else { "YYYY-MM-DD HH24:MI:SS.FF" }; + if kind == ExportTemporalKind::DateTimeWithTimeZone && !parts.zone.is_empty() { + let zone = normalize_export_timezone(&parts.zone); + return Some(format!("TO_TIMESTAMP_TZ('{datetime} {zone}', '{mask} TZH:TZM')")); + } + Some(format!("TO_TIMESTAMP('{datetime}', '{mask}')")) } fn format_oracle_export_date_parts_literal(parts: &ExportRfc3339Parts) -> String { @@ -689,6 +700,11 @@ fn parse_export_rfc3339_parts(text: &str) -> Option { } let date = &text[0..10]; let time = &text[11..19]; + if !date.as_bytes().iter().enumerate().all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) + || !time.as_bytes().iter().enumerate().all(|(index, byte)| matches!(index, 2 | 5) || byte.is_ascii_digit()) + { + return None; + } let rest = &text[19..]; let (fraction, zone) = if let Some(rest) = rest.strip_prefix('.') { let digit_count = rest.chars().take_while(|ch| ch.is_ascii_digit()).count(); @@ -3285,6 +3301,102 @@ mod tests { ); } + #[test] + fn oracle_timestamp_columns_export_as_explicit_literals() { + for database_type in [DatabaseType::Oracle, DatabaseType::OceanbaseOracle] { + let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { + database_type: Some(database_type), + schema: Some("APP".to_string()), + table_name: Some("EVENTS".to_string()), + qualified_table_name: None, + columns: vec![ + "CREATED_AT".to_string(), + "UPDATED_AT".to_string(), + "RECORDED_AT".to_string(), + "LOCAL_RECORDED_AT".to_string(), + "DRIVER_CREATED_AT".to_string(), + "DRIVER_RECORDED_AT".to_string(), + "RAW_TEXT".to_string(), + "UNTYPED".to_string(), + "INVALID_AT".to_string(), + "SHAPED_INVALID_AT".to_string(), + "NULL_AT".to_string(), + ], + column_types: vec![ + Some("TIMESTAMP(6)".to_string()), + Some("TIMESTAMP(6)".to_string()), + Some("TIMESTAMP(9) WITH TIME ZONE".to_string()), + Some("TIMESTAMP(6) WITH LOCAL TIME ZONE".to_string()), + Some("TimeStampDTY".to_string()), + Some("TimeStampTZ_DTY".to_string()), + Some("VARCHAR2(64)".to_string()), + None, + Some("TIMESTAMP(6)".to_string()), + Some("TIMESTAMP(6)".to_string()), + Some("TIMESTAMP".to_string()), + ], + column_extras: Vec::new(), + rows: vec![vec![ + json!("2022-08-25 09:58:43.123456"), + json!("2022-08-26T10:59:44Z"), + json!("2022-08-27T11:00:45.123456789+08:00"), + json!("2022-08-28T12:01:46Z"), + json!("2022-08-29T13:02:47.123456"), + json!("2022-08-30T14:03:48-05:30"), + json!("2022-08-31T15:04:49Z"), + json!("2022-09-01T16:05:50Z"), + json!("not-a-timestamp"), + json!("2022-0'-25T09:58:43Z"), + Value::Null, + ]], + batch_size: Some(100), + }) + .unwrap(); + + assert_eq!( + statements, + vec![concat!( + "INSERT INTO \"APP\".\"EVENTS\" (\"CREATED_AT\", \"UPDATED_AT\", \"RECORDED_AT\", ", + "\"LOCAL_RECORDED_AT\", \"DRIVER_CREATED_AT\", \"DRIVER_RECORDED_AT\", \"RAW_TEXT\", ", + "\"UNTYPED\", \"INVALID_AT\", \"SHAPED_INVALID_AT\", \"NULL_AT\") VALUES ", + "(TO_TIMESTAMP('2022-08-25 09:58:43.123456', 'YYYY-MM-DD HH24:MI:SS.FF'), ", + "TO_TIMESTAMP('2022-08-26 10:59:44', 'YYYY-MM-DD HH24:MI:SS'), ", + "TO_TIMESTAMP_TZ('2022-08-27 11:00:45.123456789 +08:00', ", + "'YYYY-MM-DD HH24:MI:SS.FF TZH:TZM'), ", + "TO_TIMESTAMP_TZ('2022-08-28 12:01:46 +00:00', 'YYYY-MM-DD HH24:MI:SS TZH:TZM'), ", + "TO_TIMESTAMP('2022-08-29 13:02:47.123456', 'YYYY-MM-DD HH24:MI:SS.FF'), ", + "TO_TIMESTAMP_TZ('2022-08-30 14:03:48 -05:30', 'YYYY-MM-DD HH24:MI:SS TZH:TZM'), ", + "'2022-08-31T15:04:49Z', '2022-09-01T16:05:50Z', 'not-a-timestamp', ", + "'2022-0''-25T09:58:43Z', NULL);" + )] + ); + } + } + + #[test] + fn non_oracle_timestamp_exports_keep_existing_literals() { + let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { + database_type: Some(DatabaseType::Postgres), + schema: Some("public".to_string()), + table_name: Some("events".to_string()), + qualified_table_name: None, + columns: vec!["created_at".to_string(), "recorded_at".to_string()], + column_types: vec![Some("timestamp".to_string()), Some("timestamp with time zone".to_string())], + column_extras: Vec::new(), + rows: vec![vec![json!("2022-08-25T09:58:43.123456Z"), json!("2022-08-26T10:59:44+08:00")]], + batch_size: Some(100), + }) + .unwrap(); + + assert_eq!( + statements, + vec![concat!( + "INSERT INTO \"public\".\"events\" (\"created_at\", \"recorded_at\") VALUES ", + "('2022-08-25 09:58:43.123456', '2022-08-26 10:59:44+08:00');" + )] + ); + } + #[test] fn mysql_bit_columns_export_without_quoted_string_values() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions {