fix(mysql): normalize temporal SQL literals
This commit is contained in:
parent
eef6934e71
commit
cb7682e932
|
|
@ -13,7 +13,7 @@ export function buildColumnValueFilterCondition(options: {
|
|||
|
||||
const column = columnFilterRef(options.databaseType, options.columnName);
|
||||
if (/^null$/i.test(text)) return `${column} IS NULL`;
|
||||
return `${column} = ${formatGridSqlLiteral(parseTypedFilterValue(text, options.columnInfo), options.databaseType)}`;
|
||||
return `${column} = ${formatGridSqlLiteral(parseTypedFilterValue(text, options.columnInfo), options.databaseType, options.columnInfo)}`;
|
||||
}
|
||||
|
||||
export function appendColumnValueFilterCondition(
|
||||
|
|
|
|||
|
|
@ -18,11 +18,14 @@ export interface DataGridTableMeta {
|
|||
|
||||
export interface DataGridColumnInfo {
|
||||
name: string;
|
||||
data_type: string;
|
||||
is_nullable: boolean;
|
||||
column_default?: string | null;
|
||||
extra?: string | null;
|
||||
}
|
||||
|
||||
export type GridSqlLiteralColumnInfo = Pick<DataGridColumnInfo, "data_type">;
|
||||
|
||||
export interface DataGridSaveStatementOptions {
|
||||
databaseType?: DatabaseType;
|
||||
tableMeta: DataGridTableMeta;
|
||||
|
|
@ -124,6 +127,7 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption
|
|||
if (options.databaseType === "neo4j") return buildNeo4jDataGridSaveStatements(options);
|
||||
if (options.databaseType === "tdengine") return buildTdengineDataGridSaveStatements(options);
|
||||
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
|
||||
const columnInfo = columnInfoByName(options.tableMeta.columns);
|
||||
|
||||
const table = qualifiedTableName({
|
||||
databaseType: options.databaseType,
|
||||
|
|
@ -140,18 +144,34 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption
|
|||
.filter(([columnIndex]) => !isOracleRowId(options.databaseType, saveColumns[columnIndex]))
|
||||
.map(
|
||||
([columnIndex, value]) =>
|
||||
`${quoteIdent(options.databaseType, saveColumns[columnIndex]!)} = ${formatGridSqlLiteral(value, options.databaseType)}`,
|
||||
`${quoteIdent(options.databaseType, saveColumns[columnIndex]!)} = ${formatGridSqlLiteral(
|
||||
value,
|
||||
options.databaseType,
|
||||
columnInfo.get(normalizeColumnName(saveColumns[columnIndex]!)),
|
||||
)}`,
|
||||
)
|
||||
.join(", ");
|
||||
if (!sets) continue;
|
||||
const where = buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, saveColumns, row);
|
||||
const where = buildPrimaryKeyWhere(
|
||||
options.databaseType,
|
||||
options.tableMeta.primaryKeys,
|
||||
saveColumns,
|
||||
row,
|
||||
columnInfo,
|
||||
);
|
||||
statements.push(`UPDATE ${table} SET ${sets} WHERE ${where};`);
|
||||
}
|
||||
|
||||
for (const rowIndex of options.deletedRows) {
|
||||
const row = options.rows[rowIndex];
|
||||
if (!row) continue;
|
||||
const where = buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, saveColumns, row);
|
||||
const where = buildPrimaryKeyWhere(
|
||||
options.databaseType,
|
||||
options.tableMeta.primaryKeys,
|
||||
saveColumns,
|
||||
row,
|
||||
columnInfo,
|
||||
);
|
||||
statements.push(`DELETE FROM ${table} WHERE ${where};`);
|
||||
}
|
||||
|
||||
|
|
@ -163,7 +183,11 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption
|
|||
.filter((pair) => pair.value !== null && pair.value !== undefined);
|
||||
if (!insertPairs.length) continue;
|
||||
const columns = insertPairs.map((pair) => quoteIdent(options.databaseType, pair.column)).join(", ");
|
||||
const values = insertPairs.map((pair) => formatGridSqlLiteral(pair.value, options.databaseType)).join(", ");
|
||||
const values = insertPairs
|
||||
.map((pair) =>
|
||||
formatGridSqlLiteral(pair.value, options.databaseType, columnInfo.get(normalizeColumnName(pair.column))),
|
||||
)
|
||||
.join(", ");
|
||||
statements.push(`INSERT INTO ${table} (${columns}) VALUES (${values});`);
|
||||
}
|
||||
|
||||
|
|
@ -176,6 +200,7 @@ export function buildDataGridCopyUpdateStatements(options: DataGridCopyUpdateSta
|
|||
if (primaryKeys.length === 0) return [];
|
||||
|
||||
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
|
||||
const columnInfo = columnInfoByName(options.tableMeta.columns);
|
||||
const primaryKeyIndexes = primaryKeys.map((primaryKey) => findColumnIndex(saveColumns, primaryKey));
|
||||
if (primaryKeyIndexes.some((index) => index === -1)) return [];
|
||||
|
||||
|
|
@ -200,12 +225,23 @@ export function buildDataGridCopyUpdateStatements(options: DataGridCopyUpdateSta
|
|||
const sets = writableIndexes
|
||||
.map(
|
||||
({ column, index }) =>
|
||||
`${quoteIdent(options.databaseType, column)} = ${formatGridSqlLiteral(row[index], options.databaseType)}`,
|
||||
`${quoteIdent(options.databaseType, column)} = ${formatGridSqlLiteral(
|
||||
row[index],
|
||||
options.databaseType,
|
||||
columnInfo.get(normalizeColumnName(column)),
|
||||
)}`,
|
||||
)
|
||||
.join(", ");
|
||||
if (!sets) continue;
|
||||
const where = primaryKeys
|
||||
.map((primaryKey, index) => buildColumnPredicate(options.databaseType, primaryKey, row[primaryKeyIndexes[index]]))
|
||||
.map((primaryKey, index) =>
|
||||
buildColumnPredicate(
|
||||
options.databaseType,
|
||||
primaryKey,
|
||||
row[primaryKeyIndexes[index]],
|
||||
columnInfo.get(normalizeColumnName(primaryKey)),
|
||||
),
|
||||
)
|
||||
.join(" AND ");
|
||||
statements.push(`UPDATE ${table} SET ${sets} WHERE ${where};`);
|
||||
}
|
||||
|
|
@ -291,6 +327,7 @@ function tdengineTagColumns(columns: DataGridColumnInfo[] | undefined): Set<stri
|
|||
export function buildDataGridRollbackStatements(options: DataGridSaveStatementOptions): string[] {
|
||||
if (options.databaseType === "neo4j") return buildNeo4jDataGridRollbackStatements(options);
|
||||
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
|
||||
const columnInfo = columnInfoByName(options.tableMeta.columns);
|
||||
|
||||
const table = qualifiedTableName({
|
||||
databaseType: options.databaseType,
|
||||
|
|
@ -300,7 +337,7 @@ export function buildDataGridRollbackStatements(options: DataGridSaveStatementOp
|
|||
const statements: string[] = [];
|
||||
|
||||
for (const row of options.newRows) {
|
||||
const where = buildRowWhere(options.databaseType, saveColumns, row);
|
||||
const where = buildRowWhere(options.databaseType, saveColumns, row, columnInfo);
|
||||
if (where) statements.push(`DELETE FROM ${table} WHERE ${where};`);
|
||||
}
|
||||
|
||||
|
|
@ -312,7 +349,11 @@ export function buildDataGridRollbackStatements(options: DataGridSaveStatementOp
|
|||
.filter((pair): pair is { column: string; value: GridCellValue } => !!pair.column)
|
||||
.filter((pair) => !isOracleRowId(options.databaseType, pair.column));
|
||||
const columns = insertPairs.map((pair) => quoteIdent(options.databaseType, pair.column)).join(", ");
|
||||
const values = insertPairs.map((pair) => formatGridSqlLiteral(pair.value, options.databaseType)).join(", ");
|
||||
const values = insertPairs
|
||||
.map((pair) =>
|
||||
formatGridSqlLiteral(pair.value, options.databaseType, columnInfo.get(normalizeColumnName(pair.column))),
|
||||
)
|
||||
.join(", ");
|
||||
statements.push(`INSERT INTO ${table} (${columns}) VALUES (${values});`);
|
||||
}
|
||||
|
||||
|
|
@ -329,14 +370,23 @@ export function buildDataGridRollbackStatements(options: DataGridSaveStatementOp
|
|||
const sets = writableChanges
|
||||
.map(
|
||||
([columnIndex]) =>
|
||||
`${quoteIdent(options.databaseType, saveColumns[columnIndex]!)} = ${formatGridSqlLiteral(row[columnIndex], options.databaseType)}`,
|
||||
`${quoteIdent(options.databaseType, saveColumns[columnIndex]!)} = ${formatGridSqlLiteral(
|
||||
row[columnIndex],
|
||||
options.databaseType,
|
||||
columnInfo.get(normalizeColumnName(saveColumns[columnIndex]!)),
|
||||
)}`,
|
||||
)
|
||||
.join(", ");
|
||||
if (!sets) continue;
|
||||
const where = [
|
||||
buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, saveColumns, afterRow),
|
||||
buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, saveColumns, afterRow, columnInfo),
|
||||
...writableChanges.map(([columnIndex, value]) =>
|
||||
buildColumnPredicate(options.databaseType, saveColumns[columnIndex]!, value),
|
||||
buildColumnPredicate(
|
||||
options.databaseType,
|
||||
saveColumns[columnIndex]!,
|
||||
value,
|
||||
columnInfo.get(normalizeColumnName(saveColumns[columnIndex]!)),
|
||||
),
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
|
@ -363,6 +413,10 @@ function effectiveColumn(
|
|||
return effectiveColumns(sourceColumns, columns)[index];
|
||||
}
|
||||
|
||||
function columnInfoByName(columns: DataGridColumnInfo[] | undefined): Map<string, DataGridColumnInfo> {
|
||||
return new Map((columns ?? []).map((column) => [normalizeColumnName(column.name), column]));
|
||||
}
|
||||
|
||||
export function dataGridSaveExecutionSchema(
|
||||
databaseType: DatabaseType | undefined,
|
||||
tableMeta: DataGridTableMeta | undefined,
|
||||
|
|
@ -380,17 +434,65 @@ export function normalizeDataGridSaveError(databaseType: DatabaseType | undefine
|
|||
return message;
|
||||
}
|
||||
|
||||
export function formatGridSqlLiteral(value: GridCellValue, databaseType?: DatabaseType): string {
|
||||
export function formatGridSqlLiteral(
|
||||
value: GridCellValue,
|
||||
databaseType?: DatabaseType,
|
||||
columnInfo?: GridSqlLiteralColumnInfo,
|
||||
): string {
|
||||
if (value === null || value === undefined) return "NULL";
|
||||
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
const text = String(value);
|
||||
if (text === "") return databaseType === "sqlserver" ? "N''" : "''";
|
||||
const literalText = databaseType === "tdengine" ? formatTdengineTimestampLiteralText(text) : text;
|
||||
const literalText =
|
||||
databaseType === "tdengine"
|
||||
? formatTdengineTimestampLiteralText(text)
|
||||
: isMysqlDatetimeLiteralDatabase(databaseType) && (!columnInfo || isTemporalColumnType(columnInfo.data_type))
|
||||
? formatMysqlTemporalLiteralText(text, columnInfo?.data_type)
|
||||
: text;
|
||||
const escaped = `'${literalText.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;
|
||||
return databaseType === "sqlserver" ? `N${escaped}` : escaped;
|
||||
}
|
||||
|
||||
function isMysqlDatetimeLiteralDatabase(databaseType: DatabaseType | undefined): boolean {
|
||||
return (
|
||||
databaseType === "mysql" ||
|
||||
databaseType === "doris" ||
|
||||
databaseType === "starrocks" ||
|
||||
databaseType === "goldendb" ||
|
||||
databaseType === "sundb"
|
||||
);
|
||||
}
|
||||
|
||||
function formatMysqlTemporalLiteralText(text: string, dataType: string | undefined): string {
|
||||
const match = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/i.exec(text);
|
||||
if (!match) return text;
|
||||
const kind = temporalColumnKind(dataType);
|
||||
if (kind === "date") return match[1];
|
||||
if (kind === "time") return `${match[2]}${normalizeMysqlFractionalSeconds(match[3])}`;
|
||||
return `${match[1]} ${match[2]}${normalizeMysqlFractionalSeconds(match[3])}`;
|
||||
}
|
||||
|
||||
function normalizeMysqlFractionalSeconds(fraction: string | undefined): string {
|
||||
if (!fraction) return "";
|
||||
return fraction.length > 7 ? fraction.slice(0, 7) : fraction;
|
||||
}
|
||||
|
||||
function isTemporalColumnType(dataType: string | undefined): boolean {
|
||||
return temporalColumnKind(dataType) !== undefined;
|
||||
}
|
||||
|
||||
function temporalColumnKind(dataType: string | undefined): "date" | "time" | "datetime" | undefined {
|
||||
const base = (dataType ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/[(:\s]/)[0];
|
||||
if (base === "date") return "date";
|
||||
if (base === "time") return "time";
|
||||
if (base === "datetime" || base === "timestamp") return "datetime";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatTdengineTimestampLiteralText(text: string): string {
|
||||
const match = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d{1,9})?$/.exec(text);
|
||||
if (!match) return text;
|
||||
|
|
@ -417,13 +519,18 @@ function buildPrimaryKeyWhere(
|
|||
primaryKeys: string[],
|
||||
columns: Array<string | undefined>,
|
||||
row: GridCellValue[],
|
||||
columnInfo: Map<string, DataGridColumnInfo> = new Map(),
|
||||
): string {
|
||||
if (primaryKeys.length === 0 && usesKeylessRowPredicate(databaseType))
|
||||
return buildRowWhere(databaseType, columns, row);
|
||||
return buildRowWhere(databaseType, columns, row, columnInfo);
|
||||
return primaryKeys
|
||||
.map((primaryKey) => {
|
||||
const value = row[columns.indexOf(primaryKey)];
|
||||
return `${predicateIdent(databaseType, primaryKey)} = ${formatGridSqlLiteral(value, databaseType)}`;
|
||||
return `${predicateIdent(databaseType, primaryKey)} = ${formatGridSqlLiteral(
|
||||
value,
|
||||
databaseType,
|
||||
columnInfo.get(normalizeColumnName(primaryKey)),
|
||||
)}`;
|
||||
})
|
||||
.join(" AND ");
|
||||
}
|
||||
|
|
@ -432,19 +539,27 @@ function buildRowWhere(
|
|||
databaseType: DatabaseType | undefined,
|
||||
columns: Array<string | undefined>,
|
||||
row: GridCellValue[],
|
||||
columnInfo: Map<string, DataGridColumnInfo> = new Map(),
|
||||
): string {
|
||||
return columns
|
||||
.map((column, index) =>
|
||||
!column || isOracleRowId(databaseType, column) ? "" : buildColumnPredicate(databaseType, column, row[index]),
|
||||
!column || isOracleRowId(databaseType, column)
|
||||
? ""
|
||||
: buildColumnPredicate(databaseType, column, row[index], columnInfo.get(normalizeColumnName(column))),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" AND ");
|
||||
}
|
||||
|
||||
function buildColumnPredicate(databaseType: DatabaseType | undefined, column: string, value: GridCellValue): string {
|
||||
function buildColumnPredicate(
|
||||
databaseType: DatabaseType | undefined,
|
||||
column: string,
|
||||
value: GridCellValue,
|
||||
columnInfo?: DataGridColumnInfo,
|
||||
): string {
|
||||
const ident = predicateIdent(databaseType, column);
|
||||
if (value === null || value === undefined) return `${ident} IS NULL`;
|
||||
return `${ident} = ${formatGridSqlLiteral(value, databaseType)}`;
|
||||
return `${ident} = ${formatGridSqlLiteral(value, databaseType, columnInfo)}`;
|
||||
}
|
||||
|
||||
function isOracleRowId(databaseType: DatabaseType | undefined, name: string | undefined): boolean {
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ pub async fn export_database_sql_core(
|
|||
// Export data
|
||||
if request.include_data {
|
||||
// Get columns
|
||||
let col_names: Vec<String> = match crate::schema::get_columns_core(
|
||||
let columns = match crate::schema::get_columns_core(
|
||||
state,
|
||||
&request.connection_id,
|
||||
&request.database,
|
||||
|
|
@ -196,7 +196,7 @@ pub async fn export_database_sql_core(
|
|||
)
|
||||
.await
|
||||
{
|
||||
Ok(cols) => cols.iter().map(|c| c.name.clone()).collect(),
|
||||
Ok(cols) => cols,
|
||||
Err(e) => {
|
||||
writeln!(file, "-- ERROR exporting table {table_name}: {e}")
|
||||
.map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
|
|
@ -204,6 +204,8 @@ pub async fn export_database_sql_core(
|
|||
continue;
|
||||
}
|
||||
};
|
||||
let col_names = columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>();
|
||||
let col_types = columns.iter().map(|c| Some(c.data_type.clone())).collect::<Vec<_>>();
|
||||
|
||||
if !col_names.is_empty() {
|
||||
// Get row count
|
||||
|
|
@ -260,8 +262,9 @@ pub async fn export_database_sql_core(
|
|||
break;
|
||||
}
|
||||
|
||||
let insert_sql = crate::transfer::generate_insert(
|
||||
let insert_sql = crate::transfer::generate_insert_typed(
|
||||
&col_names,
|
||||
&col_types,
|
||||
&result.rows,
|
||||
table_name,
|
||||
&request.schema,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ fn mysql_temporal_to_json_value(row: &MySqlRow, idx: usize) -> Option<serde_json
|
|||
return Some(serde_json::Value::String(v.to_string()));
|
||||
}
|
||||
if let Ok(v) = row.try_get::<DateTime<Utc>, _>(idx) {
|
||||
return Some(serde_json::Value::String(v.to_rfc3339()));
|
||||
return Some(serde_json::Value::String(mysql_datetime_to_string(v)));
|
||||
}
|
||||
if let Ok(v) = row.try_get::<NaiveDate, _>(idx) {
|
||||
return Some(serde_json::Value::String(v.to_string()));
|
||||
|
|
@ -82,6 +82,10 @@ fn mysql_temporal_to_json_value(row: &MySqlRow, idx: usize) -> Option<serde_json
|
|||
None
|
||||
}
|
||||
|
||||
fn mysql_datetime_to_string(value: DateTime<Utc>) -> String {
|
||||
value.naive_utc().to_string()
|
||||
}
|
||||
|
||||
fn mysql_value_to_json(row: &MySqlRow, idx: usize, type_name: &str) -> serde_json::Value {
|
||||
if row.try_get_raw(idx).map(|v| v.is_null()).unwrap_or(true) {
|
||||
return serde_json::Value::Null;
|
||||
|
|
@ -659,4 +663,11 @@ mod tests {
|
|||
assert!(mysql_url_has_timezone_param("mysql://root@localhost/app?charset=utf8mb4&time-zone=%2B08:00"));
|
||||
assert!(!mysql_url_has_timezone_param("mysql://root@localhost/app?charset=utf8mb4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_datetime_utc_values_display_without_rfc3339_offset() {
|
||||
let value = DateTime::from_timestamp(1_778_544_000, 0).expect("valid timestamp");
|
||||
|
||||
assert_eq!(mysql_datetime_to_string(value), "2026-05-12 00:00:00");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::connection::AppState;
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::transfer::{execute_on_pool, generate_insert, qualified_table};
|
||||
use crate::transfer::{execute_on_pool, generate_insert_typed, get_columns_for_transfer, qualified_table};
|
||||
|
||||
pub const DEFAULT_PREVIEW_LIMIT: usize = 50;
|
||||
pub const DEFAULT_BATCH_SIZE: usize = 500;
|
||||
|
|
@ -347,6 +347,7 @@ pub fn mapping_indexes(
|
|||
pub fn build_import_insert_batches(
|
||||
data: &ParsedImportFile,
|
||||
mappings: &[TableImportColumnMapping],
|
||||
target_column_types: &[(String, String)],
|
||||
table: &str,
|
||||
schema: &str,
|
||||
db_type: &DatabaseType,
|
||||
|
|
@ -354,6 +355,15 @@ pub fn build_import_insert_batches(
|
|||
) -> Result<Vec<ImportSqlBatch>, String> {
|
||||
let mapped = mapping_indexes(data, mappings)?;
|
||||
let columns = mapped.iter().map(|(_, target)| target.clone()).collect::<Vec<_>>();
|
||||
let column_types = columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
target_column_types
|
||||
.iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case(column))
|
||||
.map(|(_, data_type)| data_type.clone())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let batch_size = batch_size.max(1);
|
||||
let mut batches = Vec::new();
|
||||
|
||||
|
|
@ -367,7 +377,7 @@ pub fn build_import_insert_batches(
|
|||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let sql = generate_insert(&columns, &rows, table, schema, db_type);
|
||||
let sql = generate_insert_typed(&columns, &column_types, &rows, table, schema, db_type);
|
||||
if !sql.trim().is_empty() {
|
||||
batches.push(ImportSqlBatch { sql, row_count: chunk.len() });
|
||||
}
|
||||
|
|
@ -439,9 +449,24 @@ where
|
|||
error: None,
|
||||
});
|
||||
|
||||
let target_column_types = get_columns_for_transfer(
|
||||
state,
|
||||
pool_key,
|
||||
&request.connection_id,
|
||||
&request.database,
|
||||
&request.schema,
|
||||
&request.table,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|column| (column.name, column.data_type))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let batches = match build_import_insert_batches(
|
||||
&parsed,
|
||||
&request.mappings,
|
||||
&target_column_types,
|
||||
&request.table,
|
||||
&request.schema,
|
||||
db_type,
|
||||
|
|
@ -586,7 +611,7 @@ mod tests {
|
|||
};
|
||||
|
||||
let batches =
|
||||
build_import_insert_batches(&data, &mappings, "users", "public", &DatabaseType::Postgres, 2).unwrap();
|
||||
build_import_insert_batches(&data, &mappings, &[], "users", "public", &DatabaseType::Postgres, 2).unwrap();
|
||||
|
||||
assert_eq!(batches, vec![
|
||||
ImportSqlBatch {
|
||||
|
|
@ -599,4 +624,42 @@ mod tests {
|
|||
},
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_insert_batches_use_target_column_types_for_mysql_temporal_values() {
|
||||
let mappings = vec![
|
||||
TableImportColumnMapping {
|
||||
source_column: "start".to_string(),
|
||||
target_column: "insurance_start_time".to_string(),
|
||||
},
|
||||
TableImportColumnMapping { source_column: "raw".to_string(), target_column: "raw_text".to_string() },
|
||||
];
|
||||
let data = ParsedImportFile {
|
||||
columns: vec!["start".to_string(), "raw".to_string()],
|
||||
rows: vec![vec![
|
||||
serde_json::json!("2026-05-12T00:00:00+00:00"),
|
||||
serde_json::json!("2026-05-12T00:00:00+00:00"),
|
||||
]],
|
||||
total_rows: 1,
|
||||
};
|
||||
|
||||
let batches = build_import_insert_batches(
|
||||
&data,
|
||||
&mappings,
|
||||
&[
|
||||
("insurance_start_time".to_string(), "datetime".to_string()),
|
||||
("raw_text".to_string(), "varchar(64)".to_string()),
|
||||
],
|
||||
"policies",
|
||||
"",
|
||||
&DatabaseType::Mysql,
|
||||
500,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(batches, vec![ImportSqlBatch {
|
||||
sql: "INSERT INTO `policies` (`insurance_start_time`, `raw_text`) VALUES\n('2026-05-12 00:00:00', '2026-05-12T00:00:00+00:00')".to_string(),
|
||||
row_count: 1,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ pub fn qualified_table(table: &str, schema: &str, db_type: &DatabaseType) -> Str
|
|||
}
|
||||
|
||||
pub fn escape_value(val: &serde_json::Value, db_type: &DatabaseType) -> String {
|
||||
escape_value_typed(val, db_type, None)
|
||||
}
|
||||
|
||||
pub fn escape_value_typed(val: &serde_json::Value, db_type: &DatabaseType, column_type: Option<&str>) -> String {
|
||||
match val {
|
||||
serde_json::Value::Null => "NULL".to_string(),
|
||||
serde_json::Value::Bool(b) => match db_type {
|
||||
|
|
@ -102,7 +106,7 @@ pub fn escape_value(val: &serde_json::Value, db_type: &DatabaseType) -> String {
|
|||
},
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::String(s) => {
|
||||
format!("'{}'", s.replace('\'', "''"))
|
||||
format!("'{}'", format_literal_string(s, db_type, column_type).replace('\'', "''"))
|
||||
}
|
||||
_ => {
|
||||
let s = val.to_string();
|
||||
|
|
@ -111,6 +115,127 @@ pub fn escape_value(val: &serde_json::Value, db_type: &DatabaseType) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn format_literal_string(value: &str, db_type: &DatabaseType, column_type: Option<&str>) -> String {
|
||||
if is_mysql_datetime_literal_database(db_type) && column_type.map(is_temporal_column_type).unwrap_or(true) {
|
||||
normalize_mysql_temporal_literal(value, column_type).unwrap_or_else(|| value.to_string())
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mysql_datetime_literal_database(db_type: &DatabaseType) -> bool {
|
||||
matches!(
|
||||
db_type,
|
||||
DatabaseType::Mysql
|
||||
| DatabaseType::Doris
|
||||
| DatabaseType::StarRocks
|
||||
| DatabaseType::Goldendb
|
||||
| DatabaseType::Sundb
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_mysql_temporal_literal(value: &str, column_type: Option<&str>) -> Option<String> {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() < 20 || !is_mysql_datetime_base(bytes) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rest = &value[19..];
|
||||
let (fraction, offset) = if let Some(after_dot) = rest.strip_prefix('.') {
|
||||
let digit_count = after_dot.bytes().take_while(|b| b.is_ascii_digit()).count();
|
||||
if digit_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let fraction_len = 1 + digit_count;
|
||||
(&rest[..fraction_len.min(7)], &rest[fraction_len..])
|
||||
} else {
|
||||
("", rest)
|
||||
};
|
||||
|
||||
if !is_timezone_suffix(offset) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match temporal_column_kind(column_type) {
|
||||
Some("date") => Some(value[..10].to_string()),
|
||||
Some("time") => Some(format!("{}{}", &value[11..19], fraction)),
|
||||
_ => Some(format!("{} {}{}", &value[..10], &value[11..19], fraction)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_temporal_column_type(column_type: &str) -> bool {
|
||||
temporal_column_kind(Some(column_type)).is_some()
|
||||
}
|
||||
|
||||
fn temporal_column_kind(column_type: Option<&str>) -> Option<&'static str> {
|
||||
let base = column_type?.trim().to_ascii_lowercase();
|
||||
let base = base.split(['(', ':', ' ']).next().unwrap_or("");
|
||||
match base {
|
||||
"date" => Some("date"),
|
||||
"time" => Some("time"),
|
||||
"datetime" | "timestamp" => Some("datetime"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mysql_datetime_base(bytes: &[u8]) -> bool {
|
||||
matches!(
|
||||
bytes,
|
||||
[
|
||||
y0,
|
||||
y1,
|
||||
y2,
|
||||
y3,
|
||||
b'-',
|
||||
m0,
|
||||
m1,
|
||||
b'-',
|
||||
d0,
|
||||
d1,
|
||||
sep,
|
||||
h0,
|
||||
h1,
|
||||
b':',
|
||||
min0,
|
||||
min1,
|
||||
b':',
|
||||
s0,
|
||||
s1,
|
||||
..
|
||||
] if y0.is_ascii_digit()
|
||||
&& y1.is_ascii_digit()
|
||||
&& y2.is_ascii_digit()
|
||||
&& y3.is_ascii_digit()
|
||||
&& m0.is_ascii_digit()
|
||||
&& m1.is_ascii_digit()
|
||||
&& d0.is_ascii_digit()
|
||||
&& d1.is_ascii_digit()
|
||||
&& (*sep == b'T' || *sep == b' ')
|
||||
&& h0.is_ascii_digit()
|
||||
&& h1.is_ascii_digit()
|
||||
&& min0.is_ascii_digit()
|
||||
&& min1.is_ascii_digit()
|
||||
&& s0.is_ascii_digit()
|
||||
&& s1.is_ascii_digit()
|
||||
)
|
||||
}
|
||||
|
||||
fn is_timezone_suffix(value: &str) -> bool {
|
||||
if value.eq_ignore_ascii_case("z") {
|
||||
return true;
|
||||
}
|
||||
let bytes = value.as_bytes();
|
||||
matches!(
|
||||
bytes,
|
||||
[sign, h0, h1, b':', m0, m1]
|
||||
if (*sign == b'+' || *sign == b'-')
|
||||
&& h0.is_ascii_digit()
|
||||
&& h1.is_ascii_digit()
|
||||
&& m0.is_ascii_digit()
|
||||
&& m1.is_ascii_digit()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn map_column_type(source_type: &str, _source_db: &DatabaseType, target_db: &DatabaseType) -> String {
|
||||
let t = source_type.to_lowercase();
|
||||
let base = t.split('(').next().unwrap_or(&t).trim();
|
||||
|
|
@ -277,6 +402,17 @@ pub fn generate_insert(
|
|||
table: &str,
|
||||
schema: &str,
|
||||
db_type: &DatabaseType,
|
||||
) -> String {
|
||||
generate_insert_typed(columns, &vec![None; columns.len()], rows, table, schema, db_type)
|
||||
}
|
||||
|
||||
pub fn generate_insert_typed(
|
||||
columns: &[String],
|
||||
column_types: &[Option<String>],
|
||||
rows: &[Vec<serde_json::Value>],
|
||||
table: &str,
|
||||
schema: &str,
|
||||
db_type: &DatabaseType,
|
||||
) -> String {
|
||||
if rows.is_empty() {
|
||||
return String::new();
|
||||
|
|
@ -285,17 +421,30 @@ pub fn generate_insert(
|
|||
let full_table = qualified_table(table, schema, db_type);
|
||||
let col_list = columns.iter().map(|c| quote_identifier(c, db_type)).collect::<Vec<_>>().join(", ");
|
||||
|
||||
let value_rows: Vec<String> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let vals: Vec<String> = row.iter().map(|v| escape_value(v, db_type)).collect();
|
||||
format!("({})", vals.join(", "))
|
||||
})
|
||||
.collect();
|
||||
let value_rows = value_rows_sql(rows, column_types, db_type);
|
||||
|
||||
format!("INSERT INTO {full_table} ({col_list}) VALUES\n{}", value_rows.join(",\n"))
|
||||
}
|
||||
|
||||
fn value_rows_sql(
|
||||
rows: &[Vec<serde_json::Value>],
|
||||
column_types: &[Option<String>],
|
||||
db_type: &DatabaseType,
|
||||
) -> Vec<String> {
|
||||
rows.iter()
|
||||
.map(|row| {
|
||||
let vals: Vec<String> = row
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, v)| {
|
||||
escape_value_typed(v, db_type, column_types.get(index).and_then(|value| value.as_deref()))
|
||||
})
|
||||
.collect();
|
||||
format!("({})", vals.join(", "))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn generate_upsert(
|
||||
columns: &[String],
|
||||
rows: &[Vec<serde_json::Value>],
|
||||
|
|
@ -303,6 +452,18 @@ pub fn generate_upsert(
|
|||
schema: &str,
|
||||
db_type: &DatabaseType,
|
||||
pk_columns: &[String],
|
||||
) -> String {
|
||||
generate_upsert_typed(columns, &vec![None; columns.len()], rows, table, schema, db_type, pk_columns)
|
||||
}
|
||||
|
||||
pub fn generate_upsert_typed(
|
||||
columns: &[String],
|
||||
column_types: &[Option<String>],
|
||||
rows: &[Vec<serde_json::Value>],
|
||||
table: &str,
|
||||
schema: &str,
|
||||
db_type: &DatabaseType,
|
||||
pk_columns: &[String],
|
||||
) -> String {
|
||||
if rows.is_empty() || pk_columns.is_empty() {
|
||||
return String::new();
|
||||
|
|
@ -311,13 +472,7 @@ pub fn generate_upsert(
|
|||
let full_table = qualified_table(table, schema, db_type);
|
||||
let col_list = columns.iter().map(|c| quote_identifier(c, db_type)).collect::<Vec<_>>().join(", ");
|
||||
|
||||
let value_rows: Vec<String> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let vals: Vec<String> = row.iter().map(|v| escape_value(v, db_type)).collect();
|
||||
format!("({})", vals.join(", "))
|
||||
})
|
||||
.collect();
|
||||
let value_rows = value_rows_sql(rows, column_types, db_type);
|
||||
|
||||
let non_pk_columns: Vec<&String> = columns.iter().filter(|c| !pk_columns.contains(c)).collect();
|
||||
|
||||
|
|
@ -400,7 +555,18 @@ pub fn generate_upsert(
|
|||
let vals: Vec<String> = row
|
||||
.iter()
|
||||
.zip(columns.iter())
|
||||
.map(|(v, c)| format!("{} AS {}", escape_value(v, db_type), quote_identifier(c, db_type)))
|
||||
.enumerate()
|
||||
.map(|(index, (v, c))| {
|
||||
format!(
|
||||
"{} AS {}",
|
||||
escape_value_typed(
|
||||
v,
|
||||
db_type,
|
||||
column_types.get(index).and_then(|value| value.as_deref())
|
||||
),
|
||||
quote_identifier(c, db_type)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!("SELECT {} FROM dual", vals.join(", "))
|
||||
})
|
||||
|
|
@ -436,7 +602,7 @@ pub fn generate_upsert(
|
|||
sql.push_str(&format!("\nWHEN NOT MATCHED THEN INSERT ({insert_cols}) VALUES ({insert_vals})"));
|
||||
sql
|
||||
}
|
||||
_ => generate_insert(columns, rows, table, schema, db_type),
|
||||
_ => generate_insert_typed(columns, column_types, rows, table, schema, db_type),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -712,6 +878,7 @@ where
|
|||
}
|
||||
|
||||
let col_names: Vec<String> = columns.iter().map(|c| c.name.clone()).collect();
|
||||
let col_types: Vec<Option<String>> = columns.iter().map(|c| Some(c.data_type.clone())).collect();
|
||||
log::info!("[transfer] {} has {} columns, counting rows...", table, columns.len());
|
||||
|
||||
// Count source rows
|
||||
|
|
@ -800,10 +967,23 @@ where
|
|||
}
|
||||
|
||||
let batch_sql = match effective_mode {
|
||||
TransferMode::Upsert => {
|
||||
generate_upsert(&col_names, &result.rows, table, &request.target_schema, target_db_type, &pk_columns)
|
||||
}
|
||||
_ => generate_insert(&col_names, &result.rows, table, &request.target_schema, target_db_type),
|
||||
TransferMode::Upsert => generate_upsert_typed(
|
||||
&col_names,
|
||||
&col_types,
|
||||
&result.rows,
|
||||
table,
|
||||
&request.target_schema,
|
||||
target_db_type,
|
||||
&pk_columns,
|
||||
),
|
||||
_ => generate_insert_typed(
|
||||
&col_names,
|
||||
&col_types,
|
||||
&result.rows,
|
||||
table,
|
||||
&request.target_schema,
|
||||
target_db_type,
|
||||
),
|
||||
};
|
||||
if !batch_sql.is_empty() {
|
||||
execute_on_pool(state, target_pool_key, &batch_sql)
|
||||
|
|
@ -833,3 +1013,50 @@ where
|
|||
|
||||
Ok(total_transferred)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn mysql_insert_normalizes_rfc3339_datetime_strings() {
|
||||
let sql = generate_insert_typed(
|
||||
&[String::from("insurance_start_time")],
|
||||
&[Some(String::from("datetime"))],
|
||||
&[vec![json!("2026-05-12T00:00:00+00:00")]],
|
||||
"policies",
|
||||
"",
|
||||
&DatabaseType::Mysql,
|
||||
);
|
||||
|
||||
assert_eq!(sql, "INSERT INTO `policies` (`insurance_start_time`) VALUES\n('2026-05-12 00:00:00')");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_insert_uses_column_types_for_temporal_literals() {
|
||||
let sql = generate_insert_typed(
|
||||
&[String::from("dt"), String::from("raw_text"), String::from("d"), String::from("t")],
|
||||
&[
|
||||
Some(String::from("datetime")),
|
||||
Some(String::from("varchar(64)")),
|
||||
Some(String::from("date")),
|
||||
Some(String::from("time")),
|
||||
],
|
||||
&[vec![
|
||||
json!("2026-05-12T00:00:00+00:00"),
|
||||
json!("2026-05-12T00:00:00+00:00"),
|
||||
json!("2026-05-12T00:00:00+00:00"),
|
||||
json!("2026-05-12T09:30:45+00:00"),
|
||||
]],
|
||||
"policies",
|
||||
"",
|
||||
&DatabaseType::Mysql,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sql,
|
||||
"INSERT INTO `policies` (`dt`, `raw_text`, `d`, `t`) VALUES\n('2026-05-12 00:00:00', '2026-05-12T00:00:00+00:00', '2026-05-12', '09:30:45')"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -357,6 +357,77 @@ test("formats TDengine timestamp literals with the local timezone offset", () =>
|
|||
);
|
||||
});
|
||||
|
||||
test("formats MySQL RFC3339 datetime strings as DATETIME-compatible literals", () => {
|
||||
assert.equal(formatGridSqlLiteral("2026-05-12T00:00:00+00:00", "mysql"), "'2026-05-12 00:00:00'");
|
||||
assert.equal(formatGridSqlLiteral("2026-05-12T00:00:00.123456Z", "mysql"), "'2026-05-12 00:00:00.123456'");
|
||||
});
|
||||
|
||||
test("formats MySQL grid saves using target column temporal types", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "mysql",
|
||||
tableMeta: {
|
||||
tableName: "policies",
|
||||
primaryKeys: ["id"],
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_nullable: false, is_primary_key: true },
|
||||
{ name: "insurance_start_time", data_type: "datetime", is_nullable: true, is_primary_key: false },
|
||||
{ name: "raw_text", data_type: "varchar(64)", is_nullable: true, is_primary_key: false },
|
||||
{ name: "coverage_day", data_type: "date", is_nullable: true, is_primary_key: false },
|
||||
{ name: "start_clock", data_type: "time", is_nullable: true, is_primary_key: false },
|
||||
],
|
||||
},
|
||||
columns: ["id", "insurance_start_time", "raw_text", "coverage_day", "start_clock"],
|
||||
rows: [[1, "2026-05-12T00:00:00+00:00", "old", "2026-05-12T00:00:00+00:00", "2026-05-12T09:30:45+00:00"]],
|
||||
dirtyRows: [
|
||||
[
|
||||
0,
|
||||
[
|
||||
[1, "2026-05-12T00:00:00+00:00"],
|
||||
[2, "2026-05-12T00:00:00+00:00"],
|
||||
[3, "2026-05-12T00:00:00+00:00"],
|
||||
[4, "2026-05-12T09:30:45+00:00"],
|
||||
],
|
||||
],
|
||||
],
|
||||
deletedRows: [],
|
||||
newRows: [
|
||||
[
|
||||
2,
|
||||
"2026-05-12T00:00:00+00:00",
|
||||
"2026-05-12T00:00:00+00:00",
|
||||
"2026-05-12T00:00:00+00:00",
|
||||
"2026-05-12T09:30:45+00:00",
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"UPDATE `policies` SET `insurance_start_time` = '2026-05-12 00:00:00', `raw_text` = '2026-05-12T00:00:00+00:00', `coverage_day` = '2026-05-12', `start_clock` = '09:30:45' WHERE `id` = 1;",
|
||||
"INSERT INTO `policies` (`id`, `insurance_start_time`, `raw_text`, `coverage_day`, `start_clock`) VALUES (2, '2026-05-12 00:00:00', '2026-05-12T00:00:00+00:00', '2026-05-12', '09:30:45');",
|
||||
]);
|
||||
});
|
||||
|
||||
test("formats MySQL copy-as-update statements using target column temporal types", () => {
|
||||
const statements = buildDataGridCopyUpdateStatements({
|
||||
databaseType: "mysql",
|
||||
tableMeta: {
|
||||
tableName: "policies",
|
||||
primaryKeys: ["id"],
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_nullable: false, is_primary_key: true },
|
||||
{ name: "insurance_start_time", data_type: "timestamp", is_nullable: true, is_primary_key: false },
|
||||
{ name: "raw_text", data_type: "varchar(64)", is_nullable: true, is_primary_key: false },
|
||||
],
|
||||
},
|
||||
columns: ["id", "insurance_start_time", "raw_text"],
|
||||
rows: [[1, "2026-05-12T00:00:00+00:00", "2026-05-12T00:00:00+00:00"]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"UPDATE `policies` SET `insurance_start_time` = '2026-05-12 00:00:00', `raw_text` = '2026-05-12T00:00:00+00:00' WHERE `id` = 1;",
|
||||
]);
|
||||
});
|
||||
|
||||
function tdengineTimestampLiteral(text: string): string {
|
||||
const [datePart, timePart] = text.split(" ");
|
||||
const [time, rawFraction = ""] = timePart.split(".");
|
||||
|
|
|
|||
Loading…
Reference in New Issue