fix(oracle): preserve DATE time in display and export

This commit is contained in:
zipg authored and GitHub committed 2026-07-09 15:04:34 +08:00
1 parent aa0abcd670
commit ce8cb2b509
17 files changed
+504 -58

No files matched your search

+98 -3
View File
@@ -93,6 +93,8 @@ pub struct DataGridCopyInsertStatementOptions {
pub table_meta: Option<DataGridTableMeta>,
pub columns: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column_types: Option<Vec<Option<String>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_columns: Option<Vec<Option<String>>>,
#[serde(default)]
pub rows: Vec<Vec<Value>>,
@@ -372,10 +374,19 @@ pub fn build_data_grid_copy_insert_statement(options: DataGridCopyInsertStatemen
insert_columns
.iter()
.map(|(column, index)| {
format_grid_sql_literal(
format_grid_copy_insert_sql_literal(
row.get(*index).unwrap_or(&Value::Null),
options.database_type,
column_info_for(column_info, column),
copy_column_info(
column_info,
column,
options
.column_types
.as_deref()
.and_then(|types| types.get(*index))
.and_then(|value| value.as_deref()),
)
.as_ref(),
)
})
.collect::<Vec<_>>()
@@ -1020,6 +1031,24 @@ fn effective_copy_columns(source_columns: Option<&[Option<String>]>, columns: &[
}
}
fn copy_column_info(
column_info: &[DataGridColumnInfo],
column: &str,
fallback_type: Option<&str>,
) -> Option<DataGridColumnInfo> {
if let Some(info) = column_info_for(column_info, column) {
return Some(info.clone());
}
fallback_type.map(|data_type| DataGridColumnInfo {
name: column.to_string(),
data_type: data_type.to_string(),
is_nullable: true,
is_primary_key: false,
column_default: None,
extra: None,
})
}
fn effective_column(options: &DataGridSaveStatementOptions, index: usize) -> Option<&str> {
match &options.source_columns {
Some(source_columns) if source_columns.len() == options.columns.len() => source_columns.get(index)?.as_deref(),
@@ -1046,6 +1075,23 @@ pub fn normalize_data_grid_save_error(database_type: Option<DatabaseType>, error
error.to_string()
}
fn format_grid_copy_insert_sql_literal(
value: &Value,
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
) -> String {
if is_oracle_temporal_literal_database(database_type) {
if let Some(text) = value.as_str() {
if let Some(literal) =
format_oracle_temporal_literal(text, column_info.map(|column| column.data_type.as_str()))
{
return literal;
}
}
}
format_grid_sql_literal(value, database_type, column_info)
}
pub fn format_grid_sql_literal(
value: &Value,
database_type: Option<DatabaseType>,
@@ -1169,9 +1215,12 @@ fn is_oracle_temporal_literal_database(database_type: Option<DatabaseType>) -> b
fn format_oracle_temporal_literal(text: &str, data_type: Option<&str>) -> Option<String> {
let kind = oracle_temporal_column_kind(data_type?)?;
let parts = regex_like_oracle_temporal(text)?;
let fraction = parts.fraction.unwrap_or_default();
let fraction = parts.fraction.as_deref().unwrap_or_default();
let datetime = format!("{} {}{}", parts.date, parts.time, fraction);
match kind {
OracleTemporalKind::Date if oracle_temporal_parts_are_midnight(&parts) => {
Some(format!("DATE '{}'", parts.date))
}
OracleTemporalKind::Date => Some(format!("TO_DATE('{} {}', 'YYYY-MM-DD HH24:MI:SS')", parts.date, parts.time)),
OracleTemporalKind::Timestamp => {
let mask = oracle_timestamp_format_mask(datetime.contains('.'));
@@ -1189,6 +1238,15 @@ fn format_oracle_temporal_literal(text: &str, data_type: Option<&str>) -> Option
}
}
fn oracle_temporal_parts_are_midnight(parts: &Rfc3339Parts) -> bool {
parts.time == "00:00:00"
&& parts
.fraction
.as_deref()
.map(|fraction| fraction.trim_start_matches('.').chars().all(|ch| ch == '0'))
.unwrap_or(true)
}
fn oracle_temporal_column_kind(data_type: &str) -> Option<OracleTemporalKind> {
let lower = data_type.trim().to_ascii_lowercase();
let base = lower.split(['(', ' ']).next().unwrap_or("");
@@ -2103,6 +2161,7 @@ mod tests {
columns: None,
}),
columns: vec!["id".to_string(), "login_name".to_string(), "display_name".to_string()],
column_types: None,
source_columns: None,
rows: vec![vec![json!(1), json!("ada"), json!("Ada")], vec![json!(2), json!("linus"), json!("Linus")]],
exclude_primary_keys: true,
@@ -2126,6 +2185,7 @@ mod tests {
columns: None,
}),
columns: vec!["id".to_string(), "login_name".to_string(), "display_name".to_string()],
column_types: None,
source_columns: None,
rows: vec![vec![json!(1), json!("ada"), json!("Ada")], vec![json!(2), json!("linus"), json!("Linus")]],
exclude_primary_keys: false,
@@ -2151,6 +2211,7 @@ mod tests {
columns: None,
}),
columns: vec!["ID".to_string(), "NAME".to_string()],
column_types: None,
source_columns: None,
rows: vec![vec![json!(1), json!("Ada")], vec![json!(2), json!("Linus")]],
exclude_primary_keys: false,
@@ -2179,6 +2240,7 @@ mod tests {
database_type: Some(DatabaseType::Mysql),
table_meta: Some(table_meta.clone()),
columns: columns.clone(),
column_types: None,
source_columns: None,
rows: rows.clone(),
exclude_primary_keys: false,
@@ -2241,6 +2303,7 @@ mod tests {
]),
}),
columns: vec!["id".to_string(), "title".to_string(), "search_vector".to_string()],
column_types: None,
source_columns: None,
rows: vec![vec![json!(1), json!("Hello"), json!("'hello':1A")]],
exclude_primary_keys: false,
@@ -2253,6 +2316,29 @@ mod tests {
);
}
#[test]
fn oracle_copy_insert_uses_result_column_types_for_date_literals() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Oracle),
table_meta: None,
columns: vec!["ID".to_string(), "CREATED_ON".to_string(), "RAW_TEXT".to_string()],
column_types: Some(vec![
Some("NUMBER".to_string()),
Some("DATE".to_string()),
Some("VARCHAR2".to_string()),
]),
source_columns: None,
rows: vec![vec![json!(1), json!("2022-08-25T09:58:43Z"), json!("2022-08-25T09:58:43Z")]],
exclude_primary_keys: false,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(
statement.as_deref(),
Some("INSERT INTO table_name (\"ID\", \"CREATED_ON\", \"RAW_TEXT\") VALUES (1, TO_DATE('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS'), '2022-08-25T09:58:43Z');")
);
}
#[test]
fn builds_filter_conditions() {
assert_eq!(
@@ -2601,6 +2687,14 @@ mod tests {
format_grid_sql_literal(&json!("2022-08-25 09:58:43"), Some(DatabaseType::Oracle), Some(&date)),
"TO_DATE('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS')"
);
assert_eq!(
format_grid_sql_literal(&json!("2022-08-25"), Some(DatabaseType::Oracle), Some(&date)),
"DATE '2022-08-25'"
);
assert_eq!(
format_grid_sql_literal(&json!("2022-08-25T00:00:00Z"), Some(DatabaseType::Oracle), Some(&date)),
"DATE '2022-08-25'"
);
}
#[test]
@@ -3032,6 +3126,7 @@ mod tests {
database_type: Some(DatabaseType::Doris),
table_meta: Some(table_meta.clone()),
columns: vec!["id".to_string(), "status".to_string()],
column_types: None,
source_columns: None,
rows: vec![vec![json!(2), json!("new")]],
exclude_primary_keys: false,
+114
View File
@@ -206,6 +206,9 @@ 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) {
return literal;
}
if let Some(literal) = format_export_temporal_literal(value, database_type, column_type) {
return literal;
}
@@ -295,6 +298,43 @@ fn is_mysql_compatible_export_literal_target(database_type: Option<DatabaseType>
)
}
fn format_oracle_export_date_literal(
value: &Value,
database_type: Option<DatabaseType>,
column_type: Option<&str>,
) -> Option<String> {
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 base = lower.split(['(', ' ', '\t', '\n']).next().unwrap_or("");
if base != "date" {
return None;
}
let parts = parse_export_date_parts(value.as_str()?)?;
Some(format_oracle_export_date_parts_literal(&parts))
}
fn format_oracle_export_date_parts_literal(parts: &ExportRfc3339Parts) -> String {
if export_temporal_parts_are_midnight(parts) {
format!("DATE '{}'", parts.date)
} else {
format!("TO_DATE('{} {}', 'YYYY-MM-DD HH24:MI:SS')", parts.date, parts.time)
}
}
fn export_temporal_parts_are_midnight(parts: &ExportRfc3339Parts) -> bool {
parts.time == "00:00:00"
&& parts
.fraction
.as_deref()
.map(|fraction| fraction.trim_start_matches('.').chars().all(|ch| ch == '0'))
.unwrap_or(true)
}
fn format_export_temporal_literal(
value: &Value,
database_type: Option<DatabaseType>,
@@ -363,6 +403,53 @@ struct ExportRfc3339Parts {
zone: String,
}
fn parse_export_date_parts(text: &str) -> Option<ExportRfc3339Parts> {
parse_export_rfc3339_parts(text).or_else(|| parse_export_local_temporal_parts(text))
}
fn parse_export_local_temporal_parts(text: &str) -> Option<ExportRfc3339Parts> {
let bytes = text.as_bytes();
if bytes.len() < 10 || bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') {
return None;
}
let date = &text[0..10];
if !date.as_bytes().iter().enumerate().all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) {
return None;
}
if bytes.len() == 10 {
return Some(ExportRfc3339Parts {
date: date.to_string(),
time: "00:00:00".to_string(),
fraction: None,
zone: String::new(),
});
}
let separator = *bytes.get(10)?;
if separator != b'T' && separator != b' ' {
return None;
}
if bytes.len() < 19 || bytes.get(13) != Some(&b':') || bytes.get(16) != Some(&b':') {
return None;
}
let time = &text[11..19];
if !time.as_bytes().iter().enumerate().all(|(index, byte)| matches!(index, 2 | 5) || byte.is_ascii_digit()) {
return None;
}
let rest = &text[19..];
let fraction = if let Some(rest) = rest.strip_prefix('.') {
let digit_count = rest.chars().take_while(|ch| ch.is_ascii_digit()).count();
if digit_count == 0 || digit_count > 9 || digit_count != rest.len() {
return None;
}
Some(format!(".{}", &rest[..digit_count]))
} else if rest.is_empty() {
None
} else {
return None;
};
Some(ExportRfc3339Parts { date: date.to_string(), time: time.to_string(), fraction, zone: String::new() })
}
fn parse_export_rfc3339_parts(text: &str) -> Option<ExportRfc3339Parts> {
let bytes = text.as_bytes();
if bytes.len() < 20 || bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') {
@@ -1635,6 +1722,33 @@ mod tests {
);
}
#[test]
fn oracle_date_columns_export_as_date_literals() {
let statements = build_export_insert_statements(BuildExportInsertStatementsOptions {
database_type: Some(DatabaseType::Oracle),
schema: Some("APP".to_string()),
table_name: Some("EVENTS".to_string()),
qualified_table_name: None,
columns: vec!["ID".to_string(), "CREATED_ON".to_string(), "RAW_TEXT".to_string()],
column_types: vec![Some("NUMBER".to_string()), Some("DATE".to_string()), Some("VARCHAR2(64)".to_string())],
column_extras: Vec::new(),
rows: vec![
vec![json!(1), json!("2022-08-25T09:58:43Z"), json!("2022-08-25T09:58:43Z")],
vec![json!(2), json!("2022-08-25T00:00:00Z"), json!("2022-08-25T00:00:00Z")],
],
batch_size: Some(100),
})
.unwrap();
assert_eq!(
statements,
vec![
"INSERT INTO \"APP\".\"EVENTS\" (\"ID\", \"CREATED_ON\", \"RAW_TEXT\") VALUES (1, TO_DATE('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS'), '2022-08-25T09:58:43Z');",
"INSERT INTO \"APP\".\"EVENTS\" (\"ID\", \"CREATED_ON\", \"RAW_TEXT\") VALUES (2, DATE '2022-08-25', '2022-08-25T00:00:00Z');",
]
);
}
#[test]
fn mysql_bit_columns_export_without_quoted_string_values() {
let statements = build_export_insert_statements(BuildExportInsertStatementsOptions {
@@ -547,7 +547,7 @@ fn is_transient_duckdb_file_lock_error(message: &str) -> bool {
|| lower.contains("file is already open");
let mentions_lock = lower.contains("file is already open")
|| lower.contains("being used by another process")
|| lower.contains("conflicting lock is held")
|| lower.contains("conflicting lock")
|| lower.contains("process cannot access the file")
|| lower.contains("sharing violation")
|| lower.contains("resource temporarily unavailable")
+104
View File
@@ -1014,6 +1014,9 @@ pub fn escape_value_typed(val: &serde_json::Value, db_type: &DatabaseType, colum
if let Some(numeric_literal) = format_mysql_numeric_string_literal(s, db_type, column_type) {
return numeric_literal;
}
if let Some(date_literal) = format_oracle_date_sql_literal(s, db_type, column_type) {
return date_literal;
}
let literal = format_literal_string(s, db_type, column_type);
let escaped = if is_postgres_family_target(db_type) {
@@ -1102,6 +1105,76 @@ fn format_postgres_binary_sql_literal(
Some(format!("decode('{hex}', 'hex')"))
}
fn format_oracle_date_sql_literal(value: &str, db_type: &DatabaseType, column_type: Option<&str>) -> Option<String> {
if !matches!(db_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle) {
return None;
}
if temporal_column_kind(column_type) != Some("date") {
return None;
}
let parts = oracle_export_date_parts(value)?;
Some(format_oracle_date_sql_literal_parts(&parts))
}
struct OracleExportDateParts<'a> {
date: &'a str,
time: &'a str,
fraction: Option<&'a str>,
}
fn format_oracle_date_sql_literal_parts(parts: &OracleExportDateParts<'_>) -> String {
if oracle_export_date_parts_are_midnight(parts) {
format!("DATE '{}'", parts.date)
} else {
format!("TO_DATE('{} {}', 'YYYY-MM-DD HH24:MI:SS')", parts.date, parts.time)
}
}
fn oracle_export_date_parts_are_midnight(parts: &OracleExportDateParts<'_>) -> bool {
parts.time == "00:00:00"
&& parts.fraction.map(|fraction| fraction.trim_start_matches('.').chars().all(|ch| ch == '0')).unwrap_or(true)
}
fn oracle_export_date_parts(value: &str) -> Option<OracleExportDateParts<'_>> {
let bytes = value.as_bytes();
if bytes.len() < 10 || bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') {
return None;
}
let date = &value[..10];
if !date.as_bytes().iter().enumerate().all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) {
return None;
}
if bytes.len() == 10 {
return Some(OracleExportDateParts { date, time: "00:00:00", fraction: None });
}
let separator = *bytes.get(10)?;
if separator != b'T' && separator != b' ' {
return None;
}
if bytes.len() < 19 || bytes.get(13) != Some(&b':') || bytes.get(16) != Some(&b':') {
return None;
}
let time = &value[11..19];
if !time.as_bytes().iter().enumerate().all(|(index, byte)| matches!(index, 2 | 5) || byte.is_ascii_digit()) {
return None;
}
let rest = &value[19..];
if rest.is_empty() || is_timezone_suffix(rest) {
return Some(OracleExportDateParts { date, time, fraction: None });
}
if let Some(after_dot) = rest.strip_prefix('.') {
let digit_count = after_dot.bytes().take_while(|byte| byte.is_ascii_digit()).count();
if digit_count == 0 {
return None;
}
let zone = &after_dot[digit_count..];
if zone.is_empty() || is_timezone_suffix(zone) {
return Some(OracleExportDateParts { date, time, fraction: Some(&value[19..19 + 1 + digit_count]) });
}
}
None
}
pub fn format_pg_array_sql_literal(arr: &[serde_json::Value]) -> String {
if arr.is_empty() {
return "'{}'".to_string();
@@ -5641,6 +5714,37 @@ mod tests {
);
}
#[test]
fn oracle_insert_uses_date_literals_for_date_columns() {
let sql = generate_insert_typed(
&[String::from("id"), String::from("created_on"), String::from("created_at"), String::from("raw_text")],
&[
Some(String::from("NUMBER")),
Some(String::from("DATE")),
Some(String::from("TIMESTAMP(6)")),
Some(String::from("VARCHAR2(64)")),
],
&[vec![
json!(1),
json!("2022-08-25T09:58:43Z"),
json!("2022-08-25T09:58:43Z"),
json!("2022-08-25T09:58:43Z"),
]],
"events",
"APP",
&DatabaseType::Oracle,
);
assert_eq!(
sql,
"INSERT INTO \"APP\".\"events\" (\"id\", \"created_on\", \"created_at\", \"raw_text\") VALUES\n(1, TO_DATE('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS'), '2022-08-25T09:58:43Z', '2022-08-25T09:58:43Z')"
);
assert_eq!(
escape_value_typed(&json!("2022-08-25T00:00:00Z"), &DatabaseType::Oracle, Some("DATE")),
"DATE '2022-08-25'"
);
}
#[test]
fn mysql_insert_formats_numeric_strings_from_numeric_columns_as_numeric_literals() {
let sql = generate_insert_typed(