fix(postgres): preserve exported and imported values

This commit is contained in:
t8y2 2026-07-25 22:57:41 +08:00
parent 47d48a09e9
commit 634f604cd6
No known key found for this signature in database
7 changed files with 588 additions and 53 deletions

View File

@ -11,7 +11,7 @@ use crate::object_source_sql::build_export_object_source_sql;
use crate::sql_dialect::{qualified_table_name, quote_table_identifier, uses_single_row_insert_statements};
use crate::transfer::{
format_ch_array_sql_literal, format_pg_array_sql_literal, is_identity_column_extra, quote_identifier,
selected_columns_include_identity_extras, wrap_dameng_identity_insert_sql,
quote_postgres_string_literal, selected_columns_include_identity_extras, wrap_dameng_identity_insert_sql,
wrap_dameng_identity_insert_sql_for_table,
};
use crate::types::ObjectSourceKind;
@ -361,7 +361,7 @@ fn format_postgres_json_export_literal(value: &Value) -> String {
let text = value.as_str().map_or_else(|| value.to_string(), ToString::to_string);
// PostgreSQL standard strings keep backslashes literal; JSON text needs its
// own escape sequences, so only SQL-escape the surrounding string delimiter.
postgres_string_literal(&text)
quote_postgres_string_literal(&text)
}
fn format_postgres_vector_export_literal(value: &Value) -> String {
@ -375,7 +375,7 @@ fn format_postgres_vector_export_literal(value: &Value) -> String {
Value::String(text) => text.to_string(),
_ => value.to_string(),
};
postgres_string_literal(&text)
quote_postgres_string_literal(&text)
}
fn format_postgres_vector_export_text(arr: &[Value]) -> String {
@ -400,6 +400,7 @@ fn quote_export_sql_string(text: &str) -> String {
fn quote_export_sql_string_for_database(text: &str, database_type: Option<DatabaseType>) -> String {
match database_type {
Some(DatabaseType::Dameng) => quote_dameng_export_sql_string(text),
Some(DatabaseType::Postgres) => quote_postgres_string_literal(text),
database_type if is_mysql_compatible_export_literal_target(database_type) => {
quote_mysql_compatible_export_sql_string(text)
}
@ -988,10 +989,6 @@ fn postgres_sequence_qualified_name(schema: &str, sequence_name: &str) -> String
}
}
fn postgres_string_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn generate_postgres_sequence_create_ddl(sequence: &PostgresExportSequence, schema: &str) -> String {
let qualified_name = postgres_sequence_qualified_name(schema, &sequence.name);
let cycle = if sequence.cycle { "CYCLE" } else { "NO CYCLE" };
@ -1023,7 +1020,7 @@ fn generate_postgres_sequence_setval_sql(sequence: &PostgresExportSequence, sche
return None;
}
let sequence_literal = postgres_string_literal(&postgres_sequence_qualified_name(schema, &sequence.name));
let sequence_literal = quote_postgres_string_literal(&postgres_sequence_qualified_name(schema, &sequence.name));
match (sequence.owner_table.as_deref(), sequence.owner_column.as_deref()) {
(Some(owner_table), Some(owner_column)) => {
let owner_table = crate::transfer::qualified_table(owner_table, schema, &DatabaseType::Postgres);
@ -2439,7 +2436,7 @@ mod tests {
}
#[test]
fn postgres_export_inserts_keep_literal_control_characters() {
fn postgres_export_inserts_escape_control_characters() {
let statements = build_export_insert_statements(BuildExportInsertStatementsOptions {
database_type: Some(DatabaseType::Postgres),
schema: Some("public".to_string()),
@ -2453,7 +2450,35 @@ mod tests {
})
.unwrap();
assert_eq!(statements, vec!["INSERT INTO \"public\".\"notes\" (\"body\") VALUES ('line1\nline2\tend');"]);
assert_eq!(statements, vec!["INSERT INTO \"public\".\"notes\" (\"body\") VALUES (E'line1\\nline2\\tend');"]);
}
#[test]
fn postgres_export_inserts_escape_quotes_and_backslashes_without_changing_plain_strings() {
let statements = build_export_insert_statements(BuildExportInsertStatementsOptions {
database_type: Some(DatabaseType::Postgres),
schema: Some("public".to_string()),
table_name: Some("notes".to_string()),
qualified_table_name: None,
columns: vec!["carriage_return".to_string(), "quote".to_string(), "path".to_string(), "plain".to_string()],
column_types: vec![
Some("text".to_string()),
Some("text".to_string()),
Some("text".to_string()),
Some("text".to_string()),
],
column_extras: Vec::new(),
rows: vec![vec![json!("line1\rline2"), json!("O'Hara"), json!(r"C:\tmp"), json!("plain")]],
batch_size: Some(10),
})
.unwrap();
assert_eq!(
statements,
vec![
r#"INSERT INTO "public"."notes" ("carriage_return", "quote", "path", "plain") VALUES (E'line1\rline2', 'O''Hara', E'C:\\tmp', 'plain');"#
]
);
}
#[test]
@ -2474,7 +2499,7 @@ mod tests {
assert_eq!(
statements,
vec![
r#"INSERT INTO "public"."events" ("payload") VALUES ('{"text":"say \"hi\"","path":"C:\\tmp","quote":"O''Hara"}');"#
r#"INSERT INTO "public"."events" ("payload") VALUES (E'{"text":"say \\"hi\\"","path":"C:\\\\tmp","quote":"O''Hara"}');"#
]
);
}

View File

@ -22,8 +22,7 @@ use crate::query_result_sql::{
use crate::table_export::TableExportProgress;
use crate::transfer::keyset_pagination_sql;
use crate::xlsx_export::{
finish_streaming_xlsx_workbook, start_streaming_xlsx_workbook_with_trailing_sheets, StreamingXlsxWriter,
XlsxWorksheetData,
finish_streaming_xlsx_workbook, start_streaming_xlsx_workbook_with_options, StreamingXlsxWriter, XlsxWorksheetData,
};
use serde_json::Value;
use sqlparser::ast::{
@ -155,7 +154,14 @@ fn start_query_result_xlsx_workbook<W: Write + Seek>(
column_types: &[String],
) -> Result<StreamingXlsxWriter<W>, String> {
let trailing_sheets = query_sql_worksheets(request);
start_streaming_xlsx_workbook_with_trailing_sheets(writer, Some("Result"), columns, column_types, &trailing_sheets)
start_streaming_xlsx_workbook_with_options(
writer,
Some("Result"),
columns,
column_types,
&trailing_sheets,
request.date_time_format.as_deref(),
)
}
fn progress(

View File

@ -19,7 +19,7 @@ use crate::connection::{task_client_session_id, AppState, PoolKind};
use crate::models::connection::DatabaseType;
use crate::transfer::{
execute_on_pool, generate_insert_typed, generate_insert_typed_sql_batches, get_columns_for_transfer,
qualified_table, quote_identifier,
normalize_postgres_integer_literal, qualified_table, quote_identifier,
};
pub const DEFAULT_PREVIEW_LIMIT: usize = 50;
@ -3113,7 +3113,18 @@ fn normalize_import_value(
kingbase_oracle_mode: bool,
date_time_format: Option<&str>,
) -> serde_json::Value {
normalize_import_temporal_value(value, data_type, db_type, kingbase_oracle_mode, date_time_format)
let normalized = normalize_import_temporal_value(value, data_type, db_type, kingbase_oracle_mode, date_time_format);
let integer_text =
normalized.as_str().map(str::to_owned).or_else(|| normalized.as_number().map(ToString::to_string));
if let Some(integer_text) = integer_text
.as_deref()
.and_then(|value| normalize_postgres_integer_literal(value, db_type, data_type))
.and_then(|value| value.parse::<i64>().ok())
{
// Normalize before both INSERT and COPY paths; COPY does not pass through SQL literal escaping.
return serde_json::Value::Number(integer_text.into());
}
normalized
}
pub fn build_import_insert_batches(
@ -7144,6 +7155,29 @@ mod tests {
assert_eq!(String::from_utf8(data).unwrap(), "1\ta\\\\b\\tline\\nnext\\v\n\\N\t\\\\N\n");
}
#[test]
fn postgres_copy_normalizes_zero_fraction_integer_values_for_integer_targets() {
let plan = CompiledImportPlan {
mapped_source_indexes: vec![0, 1, 2],
target_columns: vec!["small_value".to_string(), "big_value".to_string(), "label".to_string()],
column_types: vec![Some("smallint".to_string()), Some("bigint".to_string()), Some("text".to_string())],
};
let (sql, data) = build_postgres_copy_text_batch(
&[vec![serde_json::json!("1.0"), serde_json::json!(2.0), serde_json::json!("3.0")]],
&plan,
"numbers",
"public",
None,
)
.unwrap();
assert_eq!(
sql,
"COPY \"public\".\"numbers\" (\"small_value\", \"big_value\", \"label\") FROM STDIN WITH (FORMAT text)"
);
assert_eq!(String::from_utf8(data).unwrap(), "1\t2\t3.0\n");
}
#[test]
fn postgres_copy_eligibility_requires_plain_table_without_rls_or_rules() {
assert_eq!(

View File

@ -1,4 +1,4 @@
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime};
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Timelike};
use serde_json::Value;
use std::fmt::Write as _;
@ -17,6 +17,12 @@ enum ParsedTemporal {
Time(NaiveTime),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExcelTemporalKind {
Date,
DateTime,
}
fn temporal_kind(data_type: Option<&str>) -> Option<TemporalKind> {
let normalized = data_type?.trim().to_ascii_lowercase().replace(char::is_whitespace, " ");
let base = normalized.split(['(', ':', ' ']).next().unwrap_or("");
@ -122,6 +128,40 @@ fn parse_temporal(value: &str, preferred_pattern: Option<&str>) -> Option<Parsed
.or_else(|| parse_known_temporal(value))
}
pub(crate) fn excel_temporal_serial(
value: &str,
data_type: Option<&str>,
preferred_pattern: Option<&str>,
) -> Option<(f64, ExcelTemporalKind)> {
let kind = match temporal_kind(data_type)? {
TemporalKind::Date => ExcelTemporalKind::Date,
TemporalKind::DateTime => ExcelTemporalKind::DateTime,
// Excel cannot retain a timezone in a numeric date cell, so preserve
// timezone-bearing and time-only values as text.
TemporalKind::DateTimeWithTimeZone | TemporalKind::Time => return None,
};
let parsed = parse_temporal(value.trim(), preferred_pattern)?;
let (date, time) = match (kind, parsed) {
(ExcelTemporalKind::Date, ParsedTemporal::Date(date)) => (date, NaiveTime::default()),
(ExcelTemporalKind::Date, ParsedTemporal::DateTime(value)) => (value.date(), value.time()),
(ExcelTemporalKind::DateTime, ParsedTemporal::Date(date)) => (date, NaiveTime::default()),
(ExcelTemporalKind::DateTime, ParsedTemporal::DateTime(value)) => (value.date(), value.time()),
(_, ParsedTemporal::Zoned(_) | ParsedTemporal::Time(_)) => return None,
};
let excel_min_date = NaiveDate::from_ymd_opt(1900, 1, 1)?;
if date < excel_min_date {
return None;
}
let epoch = NaiveDate::from_ymd_opt(1899, 12, 31)?;
let mut serial = date.signed_duration_since(epoch).num_days() as f64;
// Excel's 1900 date system retains the historical fake 1900-02-29.
if date >= NaiveDate::from_ymd_opt(1900, 3, 1)? {
serial += 1.0;
}
let seconds = time.num_seconds_from_midnight() as f64 + f64::from(time.nanosecond()) / 1_000_000_000.0;
Some((serial + seconds / 86_400.0, kind))
}
fn format_parsed(parsed: ParsedTemporal, pattern: &str) -> Option<String> {
let pattern = dayjs_to_chrono_pattern(pattern)?;
let mut output = String::new();

View File

@ -234,6 +234,37 @@ fn quote_string_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
pub(crate) fn quote_postgres_string_literal(value: &str) -> String {
if !value.contains('\\') && !value.chars().any(|character| character.is_ascii_control()) {
return quote_string_literal(value);
}
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
match character {
'\\' => escaped.push_str("\\\\"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
'\x08' => escaped.push_str("\\b"),
'\x0c' => escaped.push_str("\\f"),
'\'' => escaped.push_str("''"),
character if character.is_ascii_control() => {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let byte = character as u8;
escaped.push_str("\\x");
escaped.push(HEX[(byte >> 4) as usize] as char);
escaped.push(HEX[(byte & 0x0F) as usize] as char);
}
character => escaped.push(character),
}
}
// Escape string constants keep control characters out of the physical
// script and remain correct regardless of standard_conforming_strings.
format!("E'{escaped}'")
}
fn postgres_schema_exists_sql(schema: &str) -> String {
format!("SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = {} LIMIT 1", quote_string_literal(schema))
}
@ -263,12 +294,45 @@ fn is_postgres_transfer_dialect(db_type: &DatabaseType) -> bool {
matches!(db_type, DatabaseType::Postgres | DatabaseType::Kingbase)
}
fn is_postgres_integer_like_type(data_type: &str) -> bool {
fn postgres_integer_bounds(data_type: &str) -> Option<(i128, i128)> {
let normalized = data_type.trim().to_ascii_lowercase();
matches!(
normalized.split(['(', ' ']).next().unwrap_or(""),
"smallint" | "integer" | "bigint" | "int2" | "int4" | "int8"
)
match normalized.split(['(', ' ']).next().unwrap_or("") {
"smallint" | "int2" => Some((i128::from(i16::MIN), i128::from(i16::MAX))),
"integer" | "int4" => Some((i128::from(i32::MIN), i128::from(i32::MAX))),
"bigint" | "int8" => Some((i128::from(i64::MIN), i128::from(i64::MAX))),
_ => None,
}
}
fn is_postgres_integer_like_type(data_type: &str) -> bool {
postgres_integer_bounds(data_type).is_some()
}
pub(crate) fn normalize_postgres_integer_literal(
value: &str,
db_type: &DatabaseType,
column_type: Option<&str>,
) -> Option<String> {
let bounds = column_type.filter(|_| is_postgres_transfer_dialect(db_type)).and_then(postgres_integer_bounds)?;
// Excel numeric cells arrive as f64; normalize only an explicit zero fraction so real decimals,
// scientific notation, and values outside the target integer range stay untouched.
if value.bytes().any(|byte| matches!(byte, b'e' | b'E')) {
return None;
}
let (integer, fraction) = value.split_once('.')?;
if fraction.is_empty() || !fraction.bytes().all(|byte| byte == b'0') {
return None;
}
let digits = integer.strip_prefix('-').or_else(|| integer.strip_prefix('+')).unwrap_or(integer);
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let parsed = integer.parse::<i128>().ok()?;
if parsed < bounds.0 || parsed > bounds.1 {
return None;
}
Some(integer.to_string())
}
fn is_postgres_sequence_default(default_value: Option<&str>) -> bool {
@ -1050,17 +1114,25 @@ pub fn escape_value_typed(val: &serde_json::Value, db_type: &DatabaseType, colum
}
}
},
serde_json::Value::Number(n) => match db_type {
DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks => {
if column_type.is_some_and(is_mysql_bit_type) {
format!("b'{}'", n)
} else {
n.to_string()
}
serde_json::Value::Number(n) => {
if let Some(integer_literal) = normalize_postgres_integer_literal(&n.to_string(), db_type, column_type) {
return integer_literal;
}
_ => n.to_string(),
},
match db_type {
DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks => {
if column_type.is_some_and(is_mysql_bit_type) {
format!("b'{}'", n)
} else {
n.to_string()
}
}
_ => n.to_string(),
}
}
serde_json::Value::String(s) => {
if let Some(integer_literal) = normalize_postgres_integer_literal(s, db_type, column_type) {
return integer_literal;
}
if let Some(binary_literal) = format_postgres_binary_sql_literal(s, db_type, column_type) {
return binary_literal;
}
@ -1075,6 +1147,9 @@ pub fn escape_value_typed(val: &serde_json::Value, db_type: &DatabaseType, colum
}
let literal = format_literal_string(s, db_type, column_type);
if *db_type == DatabaseType::Postgres {
return quote_postgres_string_literal(&literal);
}
let escaped = if is_postgres_family_target(db_type) {
literal.replace('\'', "''")
} else {
@ -6261,7 +6336,7 @@ mod tests {
assert_eq!(
sql,
r#"INSERT INTO "public"."events" ("payload") VALUES
('{"message":"hello\nworld"}')"#
(E'{"message":"hello\\nworld"}')"#
);
}
@ -6279,7 +6354,7 @@ mod tests {
assert_eq!(
sql,
r#"INSERT INTO "public"."files" ("path") VALUES
('C:\tmp\file.txt')"#
(E'C:\\tmp\\file.txt')"#
);
}
@ -6360,6 +6435,80 @@ mod tests {
);
}
#[test]
fn postgres_insert_escapes_control_characters_quotes_and_backslashes() {
let sql = generate_insert_typed(
&[
String::from("line_break"),
String::from("carriage_return"),
String::from("quote"),
String::from("path"),
String::from("plain"),
],
&[
Some(String::from("text")),
Some(String::from("text")),
Some(String::from("text")),
Some(String::from("text")),
Some(String::from("text")),
],
&[vec![json!("line1\nline2"), json!("line1\rline2"), json!("O'Hara"), json!(r"C:\tmp"), json!("plain")]],
"notes",
"public",
&DatabaseType::Postgres,
);
assert_eq!(
sql,
r#"INSERT INTO "public"."notes" ("line_break", "carriage_return", "quote", "path", "plain") VALUES
(E'line1\nline2', E'line1\rline2', 'O''Hara', E'C:\\tmp', 'plain')"#
);
}
#[test]
fn postgres_insert_formats_whole_number_values_as_integer_literals() {
let sql = generate_insert_typed(
&[String::from("small_value"), String::from("integer_value"), String::from("big_value")],
&[Some(String::from("smallint")), Some(String::from("integer")), Some(String::from("bigint"))],
&[vec![json!(1.0), json!(-2.0), json!(3.0)]],
"numbers",
"public",
&DatabaseType::Postgres,
);
assert_eq!(
sql,
"INSERT INTO \"public\".\"numbers\" (\"small_value\", \"integer_value\", \"big_value\") VALUES\n(1, -2, 3)"
);
}
#[test]
fn postgres_integer_literal_normalization_preserves_non_whole_and_out_of_range_values() {
let scientific: serde_json::Value = serde_json::from_str("1e20").unwrap();
let out_of_range: serde_json::Value = serde_json::from_str("9223372036854775808.0").unwrap();
assert_eq!(escape_value_typed(&json!("1.0"), &DatabaseType::Postgres, Some("smallint")), "1");
assert_eq!(escape_value_typed(&json!("-2.0"), &DatabaseType::Postgres, Some("integer")), "-2");
assert_eq!(escape_value_typed(&json!("3.0"), &DatabaseType::Postgres, Some("bigint")), "3");
assert_eq!(escape_value_typed(&json!(1.25), &DatabaseType::Postgres, Some("smallint")), "1.25");
assert_eq!(escape_value_typed(&json!("1.25"), &DatabaseType::Postgres, Some("smallint")), "'1.25'");
assert_eq!(escape_value_typed(&scientific, &DatabaseType::Postgres, Some("bigint")), "1e+20");
assert_eq!(escape_value_typed(&json!("1e3"), &DatabaseType::Postgres, Some("bigint")), "'1e3'");
assert_eq!(escape_value_typed(&out_of_range, &DatabaseType::Postgres, Some("bigint")), "9223372036854775808.0");
assert_eq!(
escape_value_typed(&json!("9223372036854775808.0"), &DatabaseType::Postgres, Some("bigint")),
"'9223372036854775808.0'"
);
assert_eq!(escape_value_typed(&json!("32768.0"), &DatabaseType::Postgres, Some("smallint")), "'32768.0'");
assert_eq!(
escape_value_typed(&json!("2147483648.0"), &DatabaseType::Postgres, Some("integer")),
"'2147483648.0'"
);
assert_eq!(escape_value_typed(&json!("1.0"), &DatabaseType::Postgres, Some("text")), "'1.0'");
assert_eq!(escape_value_typed(&json!("1.0"), &DatabaseType::Postgres, Some("numeric")), "'1.0'");
assert_eq!(escape_value_typed(&json!("1.0"), &DatabaseType::Postgres, None), "'1.0'");
}
#[test]
fn oracle_single_row_insert_keeps_values_shape() {
let sql = generate_insert_typed(

View File

@ -2,6 +2,11 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::io::{Cursor, Seek, Write};
use crate::temporal_format::{excel_temporal_serial, ExcelTemporalKind};
const XLSX_DATE_STYLE: usize = 2;
const XLSX_DATETIME_STYLE: usize = 3;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct XlsxWorksheetData {
@ -21,6 +26,7 @@ pub struct StreamingXlsxWriter<W: Write + Seek> {
column_types: Vec<String>,
next_row_number: usize,
trailing_sheets: Vec<XlsxWorksheetData>,
date_time_format: Option<String>,
}
/// Estimate column widths from header names only (used by the streaming path
@ -53,13 +59,25 @@ pub(crate) fn header_row_xml(columns: &[String]) -> String {
)
}
/// Build a single `<row>` XML fragment for a data row.
pub(crate) fn data_row_xml(row_number: usize, columns: &[String], column_types: &[String], row: &[Value]) -> String {
fn data_row_xml_with_date_time_format(
row_number: usize,
columns: &[String],
column_types: &[String],
row: &[Value],
date_time_format: Option<&str>,
) -> String {
let cells = columns
.iter()
.enumerate()
.map(|(col_index, _)| {
typed_cell_xml(row.get(col_index), column_types.get(col_index), row_number - 1, col_index, None)
typed_cell_xml(
row.get(col_index),
column_types.get(col_index),
row_number - 1,
col_index,
None,
date_time_format,
)
})
.collect::<String>();
format!("<row r=\"{row_number}\">{cells}</row>")
@ -87,15 +105,27 @@ pub(crate) fn start_streaming_xlsx_workbook<W: Write + Seek>(
columns: &[String],
column_types: &[String],
) -> Result<StreamingXlsxWriter<W>, String> {
start_streaming_xlsx_workbook_with_trailing_sheets(writer, sheet_name, columns, column_types, &[])
start_streaming_xlsx_workbook_with_options(writer, sheet_name, columns, column_types, &[], None)
}
#[cfg(test)]
pub(crate) fn start_streaming_xlsx_workbook_with_trailing_sheets<W: Write + Seek>(
writer: W,
sheet_name: Option<&str>,
columns: &[String],
column_types: &[String],
trailing_sheets: &[XlsxWorksheetData],
) -> Result<StreamingXlsxWriter<W>, String> {
start_streaming_xlsx_workbook_with_options(writer, sheet_name, columns, column_types, trailing_sheets, None)
}
pub(crate) fn start_streaming_xlsx_workbook_with_options<W: Write + Seek>(
writer: W,
sheet_name: Option<&str>,
columns: &[String],
column_types: &[String],
trailing_sheets: &[XlsxWorksheetData],
date_time_format: Option<&str>,
) -> Result<StreamingXlsxWriter<W>, String> {
let primary_sheet = XlsxWorksheetData {
sheet_name: sheet_name.map(str::to_string),
@ -113,7 +143,7 @@ pub(crate) fn start_streaming_xlsx_workbook_with_trailing_sheets<W: Write + Seek
write_zip_entry(&mut zip, "_rels/.rels", root_rels_xml())?;
write_zip_entry(&mut zip, "xl/workbook.xml", &workbook_xml_for_sheets(&sheet_names))?;
write_zip_entry(&mut zip, "xl/_rels/workbook.xml.rels", &workbook_rels_xml_for_sheet_count(sheet_count))?;
write_zip_entry(&mut zip, "xl/styles.xml", styles_xml())?;
write_zip_entry(&mut zip, "xl/styles.xml", &styles_xml(date_time_format))?;
// Begin the sheet1.xml entry with header, frozen pane, column widths and
// the header row.
@ -142,6 +172,7 @@ pub(crate) fn start_streaming_xlsx_workbook_with_trailing_sheets<W: Write + Seek
column_types: column_types.to_vec(),
next_row_number: 2,
trailing_sheets: trailing_sheets.to_vec(),
date_time_format: date_time_format.map(str::to_string),
})
}
@ -149,7 +180,16 @@ impl<W: Write + Seek> StreamingXlsxWriter<W> {
/// Append a single data row to the worksheet.
pub fn write_row(&mut self, row: &[Value]) -> Result<(), String> {
self.zip
.write_all(data_row_xml(self.next_row_number, &self.columns, &self.column_types, row).as_bytes())
.write_all(
data_row_xml_with_date_time_format(
self.next_row_number,
&self.columns,
&self.column_types,
row,
self.date_time_format.as_deref(),
)
.as_bytes(),
)
.map_err(|err| err.to_string())?;
self.next_row_number += 1;
Ok(())
@ -345,7 +385,20 @@ fn typed_cell_xml(
row_index: usize,
col_index: usize,
style: Option<usize>,
date_time_format: Option<&str>,
) -> String {
if let Some(Value::String(value)) = value {
if let Some((serial, temporal_kind)) =
excel_temporal_serial(value, column_type.map(String::as_str), date_time_format)
{
let reference = cell_ref(row_index, col_index);
let style = match temporal_kind {
ExcelTemporalKind::Date => XLSX_DATE_STYLE,
ExcelTemporalKind::DateTime => XLSX_DATETIME_STYLE,
};
return format!("<c r=\"{reference}\" s=\"{style}\"><v>{serial}</v></c>");
}
}
if is_numeric_column_type(column_type) {
if let Some(Value::String(value)) = value {
if let Some(number) = safe_excel_number(value) {
@ -391,7 +444,14 @@ fn worksheet_xml(data: &XlsxWorksheetData) -> String {
.iter()
.enumerate()
.map(|(col_index, _)| {
typed_cell_xml(row.get(col_index), data.column_types.get(col_index), excel_row - 1, col_index, None)
typed_cell_xml(
row.get(col_index),
data.column_types.get(col_index),
excel_row - 1,
col_index,
None,
None,
)
})
.collect::<String>();
format!("<row r=\"{excel_row}\">{cells}</row>")
@ -490,17 +550,96 @@ fn workbook_rels_xml_for_sheet_count(sheet_count: usize) -> String {
)
}
fn styles_xml() -> &'static str {
concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
"<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">",
"<fonts count=\"2\"><font><sz val=\"11\"/><name val=\"Calibri\"/></font><font><b/><sz val=\"11\"/><name val=\"Calibri\"/></font></fonts>",
"<fills count=\"2\"><fill><patternFill patternType=\"none\"/></fill><fill><patternFill patternType=\"gray125\"/></fill></fills>",
"<borders count=\"1\"><border><left/><right/><top/><bottom/><diagonal/></border></borders>",
"<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>",
"<cellXfs count=\"2\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/><xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/></cellXfs>",
"<cellStyles count=\"1\"><cellStyle name=\"Normal\" xfId=\"0\" builtinId=\"0\"/></cellStyles>",
"</styleSheet>"
fn dayjs_to_excel_number_format(pattern: &str) -> Option<(String, bool, bool)> {
let pattern = pattern.trim();
if pattern.is_empty() || pattern.len() > 100 || pattern.contains('%') {
return None;
}
let tokens = [
("YYYY", "yyyy", true, false),
("SSS", "000", false, true),
("ZZ", "", false, true),
("MM", "mm", true, false),
("DD", "dd", true, false),
("HH", "hh", false, true),
("mm", "mm", false, true),
("ss", "ss", false, true),
("M", "m", true, false),
("D", "d", true, false),
("H", "h", false, true),
("m", "m", false, true),
("s", "s", false, true),
("Z", "", false, true),
];
let mut output = String::with_capacity(pattern.len());
let mut has_date = false;
let mut has_time = false;
let mut index = 0;
while index < pattern.len() {
let remaining = &pattern[index..];
if remaining.starts_with('[') {
let close = remaining.find(']')?;
let literal = &remaining[1..close];
output.push('"');
output.push_str(&literal.replace('"', "\"\""));
output.push('"');
index += close + 1;
continue;
}
if let Some((token, replacement, is_date, is_time)) =
tokens.iter().find(|(token, ..)| remaining.starts_with(token))
{
if replacement.is_empty() {
return None;
}
output.push_str(replacement);
has_date |= *is_date;
has_time |= *is_time;
index += token.len();
continue;
}
let character = remaining.chars().next()?;
if character.is_ascii_alphabetic() {
// Keep the accepted Day.js token set aligned with temporal_format.rs;
// unsupported tokens must not silently alter the exported display.
return None;
}
output.push(character);
index += character.len_utf8();
}
(has_date && !output.is_empty()).then_some((output, has_date, has_time))
}
fn styles_xml(date_time_format: Option<&str>) -> String {
let default_date = "yyyy-mm-dd".to_string();
let default_datetime = "yyyy-mm-dd hh:mm:ss".to_string();
let (date_format, datetime_format) = date_time_format
.and_then(dayjs_to_excel_number_format)
.map(|(format, has_date, has_time)| {
if has_time {
(default_date.clone(), format)
} else if has_date {
(format.clone(), format)
} else {
(default_date.clone(), default_datetime.clone())
}
})
.unwrap_or((default_date, default_datetime));
format!(
concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
"<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">",
"<numFmts count=\"2\"><numFmt numFmtId=\"164\" formatCode=\"{}\"/><numFmt numFmtId=\"165\" formatCode=\"{}\"/></numFmts>",
"<fonts count=\"2\"><font><sz val=\"11\"/><name val=\"Calibri\"/></font><font><b/><sz val=\"11\"/><name val=\"Calibri\"/></font></fonts>",
"<fills count=\"2\"><fill><patternFill patternType=\"none\"/></fill><fill><patternFill patternType=\"gray125\"/></fill></fills>",
"<borders count=\"1\"><border><left/><right/><top/><bottom/><diagonal/></border></borders>",
"<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>",
"<cellXfs count=\"4\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/><xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/><xf numFmtId=\"164\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/><xf numFmtId=\"165\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/></cellXfs>",
"<cellStyles count=\"1\"><cellStyle name=\"Normal\" xfId=\"0\" builtinId=\"0\"/></cellStyles>",
"</styleSheet>"
),
escape_xml(&date_format),
escape_xml(&datetime_format)
)
}
@ -535,7 +674,7 @@ pub fn build_xlsx_workbook_multi(sheets: &[XlsxWorksheetData]) -> Result<Vec<u8>
("_rels/.rels", root_rels_xml().to_string()),
("xl/workbook.xml", workbook_xml_for_sheets(&sheet_names)),
("xl/_rels/workbook.xml.rels", workbook_rels_xml_for_sheet_count(sheets.len())),
("xl/styles.xml", styles_xml().to_string()),
("xl/styles.xml", styles_xml(None)),
];
let cursor = Cursor::new(Vec::<u8>::new());
@ -559,7 +698,8 @@ pub fn build_xlsx_workbook_multi(sheets: &[XlsxWorksheetData]) -> Result<Vec<u8>
mod tests {
use super::{
build_xlsx_workbook, build_xlsx_workbook_multi, start_streaming_xlsx_workbook,
start_streaming_xlsx_workbook_with_trailing_sheets, XlsxWorksheetData,
start_streaming_xlsx_workbook_with_options, start_streaming_xlsx_workbook_with_trailing_sheets,
XlsxWorksheetData,
};
use calamine::{open_workbook_auto, Reader};
use serde_json::json;
@ -630,6 +770,49 @@ mod tests {
assert!(sheet.contains("<c r=\"C2\" t=\"inlineStr\"><is><t>00123</t></is></c>"));
}
#[test]
fn writes_temporal_columns_as_excel_dates_without_retyping_other_values() {
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
sheet_name: Some("Typed values".to_string()),
columns: vec![
"day".to_string(),
"created_at".to_string(),
"label".to_string(),
"invalid_day".to_string(),
"amount".to_string(),
"zoned_at".to_string(),
],
column_types: vec![
"date".to_string(),
"timestamp without time zone".to_string(),
"text".to_string(),
"date".to_string(),
"numeric".to_string(),
"timestamp with time zone".to_string(),
],
rows: vec![vec![
json!("2024-02-25"),
json!("2024-02-25 13:02:15"),
json!("2024-02-25"),
json!("not-a-date"),
json!("2800.000000"),
json!("2024-02-25T13:02:15+08:00"),
]],
})
.expect("build workbook");
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
let styles = read_zip_entry(&workbook, "xl/styles.xml");
assert!(sheet.contains("<c r=\"A2\" s=\"2\"><v>45347</v></c>"));
assert!(sheet.contains("<c r=\"B2\" s=\"3\"><v>45347.543229166666</v></c>"));
assert!(sheet.contains("<c r=\"C2\" t=\"inlineStr\"><is><t>2024-02-25</t></is></c>"));
assert!(sheet.contains("<c r=\"D2\" t=\"inlineStr\"><is><t>not-a-date</t></is></c>"));
assert!(sheet.contains("<c r=\"E2\"><v>2800.000000</v></c>"));
assert!(sheet.contains("<c r=\"F2\" t=\"inlineStr\"><is><t>2024-02-25T13:02:15+08:00</t></is></c>"));
assert!(styles.contains("numFmtId=\"164\" formatCode=\"yyyy-mm-dd\""));
assert!(styles.contains("numFmtId=\"165\" formatCode=\"yyyy-mm-dd hh:mm:ss\""));
}
#[test]
fn writes_mysql_57_numeric_strings_as_numeric_cells() {
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
@ -782,6 +965,34 @@ mod tests {
let _ = fs::remove_file(&path);
}
#[test]
fn streaming_temporal_cells_keep_the_configured_excel_display_format() {
let path = std::env::temp_dir().join(format!("dbx-temporal-stream-test-{}.xlsx", uuid::Uuid::new_v4()));
{
let file = fs::File::create(&path).expect("create temp xlsx");
let columns = ["created_at".to_string()];
let column_types = ["timestamp without time zone".to_string()];
let mut writer = start_streaming_xlsx_workbook_with_options(
file,
Some("Temporal"),
&columns,
&column_types,
&[],
Some("YYYY/MM/DD HH:mm:ss.SSS"),
)
.expect("start workbook");
writer.write_row(&[json!("2024/02/25 13:02:15.125")]).expect("write temporal row");
drop(writer.finish().expect("finish workbook"));
}
let bytes = fs::read(&path).expect("read workbook");
let sheet = read_zip_entry(&bytes, "xl/worksheets/sheet1.xml");
let styles = read_zip_entry(&bytes, "xl/styles.xml");
assert!(sheet.contains("<c r=\"A2\" s=\"3\"><v>"), "sheet={sheet}");
assert!(styles.contains("numFmtId=\"165\" formatCode=\"yyyy/mm/dd hh:mm:ss.000\""));
let _ = fs::remove_file(&path);
}
#[test]
fn streams_xlsx_rows_with_a_trailing_sql_worksheet() {
let path = std::env::temp_dir().join(format!("dbx-stream-sql-test-{}.xlsx", uuid::Uuid::new_v4()));

View File

@ -1,3 +1,4 @@
use std::io::Read;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
@ -156,6 +157,75 @@ async fn live_postgres_query_result_export_uses_single_streamed_query() {
assert_eq!(csv.lines().count(), 2051, "unexpected csv row count");
}
#[tokio::test]
#[ignore = "requires DBX_LIVE_POSTGRES_HOST/PORT/USER/PASSWORD/DATABASE pointing at a writable PostgreSQL database"]
async fn live_postgres_query_result_xlsx_preserves_temporal_cell_types() {
let host = std::env::var("DBX_LIVE_POSTGRES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = std::env::var("DBX_LIVE_POSTGRES_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(5432);
let user = std::env::var("DBX_LIVE_POSTGRES_USER").unwrap_or_else(|_| "postgres".to_string());
let password = std::env::var("DBX_LIVE_POSTGRES_PASSWORD").unwrap_or_default();
let database = std::env::var("DBX_LIVE_POSTGRES_DATABASE").unwrap_or_else(|_| "postgres".to_string());
let url = format!("postgresql://{user}:{password}@{host}:{port}/{database}");
let setup_pool = postgres::connect(&url, Duration::from_secs(10)).await.expect("connect PostgreSQL");
let suffix = uuid::Uuid::new_v4().simple().to_string();
let schema = format!("dbx_xlsx_temporal_{}", &suffix[..8]);
let setup = vec![
format!("CREATE SCHEMA \"{schema}\""),
format!("CREATE TABLE \"{schema}\".events (day date, created_at timestamp without time zone, label text)"),
format!("INSERT INTO \"{schema}\".events VALUES ('2024-02-25', '2024-02-25 13:02:15', '2024-02-25')"),
];
let cleanup = vec![format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE")];
let _ = postgres::execute_batch(&setup_pool, &cleanup).await;
postgres::execute_batch(&setup_pool, &setup).await.expect("create temporal export fixture");
let dir = std::env::temp_dir().join(format!("dbx-live-postgres-xlsx-temporal-{suffix}"));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let state = AppState::new(storage);
let connection_id = "live-postgres-xlsx-temporal";
let config = live_postgres_config(connection_id, &host, port, &user, &password, &database);
state.configs.write().await.insert(config.id.clone(), config);
let file_path = dir.join("result.xlsx");
let request = QueryResultExportRequest {
export_id: format!("live-postgres-xlsx-temporal-{suffix}"),
connection_id: connection_id.to_string(),
database: database.clone(),
schema: Some(schema.clone()),
sql: format!("SELECT day, created_at, label FROM \"{schema}\".events"),
query_base_sql: format!("SELECT day, created_at, label FROM \"{schema}\".events"),
setup_sql: Vec::new(),
database_type: DatabaseType::Postgres,
use_agent_cursor: false,
file_path: file_path.to_string_lossy().to_string(),
format: "xlsx".to_string(),
include_sql_sheet: false,
page_size: 100,
row_limit: None,
total_rows: None,
timeout_secs: Some(30),
keyset_optimization_enabled: false,
client_session_id: Some(format!("live-postgres-xlsx-temporal-{suffix}")),
execution_id: Some(format!("live-postgres-xlsx-temporal-{suffix}")),
date_time_format: None,
};
export_query_result_core(&state, &request, None, |_| {}).await.expect("export temporal XLSX");
let workbook = std::fs::read(&file_path).unwrap();
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(workbook)).expect("open generated XLSX");
let mut sheet = String::new();
archive.by_name("xl/worksheets/sheet1.xml").unwrap().read_to_string(&mut sheet).unwrap();
assert!(sheet.contains("<c r=\"A2\" s=\"2\"><v>"), "sheet={sheet}");
assert!(sheet.contains("<c r=\"B2\" s=\"3\"><v>"), "sheet={sheet}");
assert!(sheet.contains("<c r=\"C2\" t=\"inlineStr\"><is><t>2024-02-25</t></is></c>"), "sheet={sheet}");
let cleanup_result = postgres::execute_batch(&setup_pool, &cleanup).await;
let _ = std::fs::remove_dir_all(&dir);
cleanup_result.expect("cleanup temporal export fixture");
}
#[tokio::test]
#[ignore = "requires DBX_LIVE_POSTGRES_* env vars for temporary-table CSV/XLSX export"]
async fn live_postgres_truncated_batch_result_export_replays_safe_temp_setup() {