ClickHouse and Databend expect square-bracket array literals (unquoted),
while PostgreSQL/DuckDB/etc. use curly-brace quoted format. Previously
all array values were unconditionally rendered with `'{...}'` syntax,
which produced wrong INSERT/UPDATE statements for ClickHouse columns
of nested array types like `Array(Array(Point))`.
1596 lines
58 KiB
Rust
1596 lines
58 KiB
Rust
use chrono::{Local, NaiveDateTime};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::Value;
|
||
|
||
#[path = "data_grid_neo4j_sql.rs"]
|
||
mod data_grid_neo4j_sql;
|
||
use data_grid_neo4j_sql::{build_neo4j_data_grid_rollback_statements, build_neo4j_data_grid_save_statements};
|
||
|
||
#[path = "data_grid_tdengine_sql.rs"]
|
||
mod data_grid_tdengine_sql;
|
||
use data_grid_tdengine_sql::build_tdengine_data_grid_save_statements;
|
||
|
||
use crate::models::connection::DatabaseType;
|
||
use crate::sql_dialect::quote_table_identifier;
|
||
use crate::transfer::{format_ch_array_sql_literal, format_pg_array_sql_literal};
|
||
|
||
const DBX_ROWID_COLUMN: &str = "__DBX_ROWID";
|
||
pub(crate) const DBX_NEO4J_ELEMENT_ID_COLUMN: &str = "__DBX_ELEMENT_ID";
|
||
pub(crate) const DBX_TDENGINE_TBNAME_COLUMN: &str = "tbname";
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct DataGridTableMeta {
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub schema: Option<String>,
|
||
pub table_name: String,
|
||
#[serde(default)]
|
||
pub primary_keys: Vec<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub columns: Option<Vec<DataGridColumnInfo>>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct DataGridColumnInfo {
|
||
pub name: String,
|
||
#[serde(default)]
|
||
pub data_type: String,
|
||
#[serde(default)]
|
||
pub is_nullable: bool,
|
||
#[serde(default)]
|
||
pub is_primary_key: bool,
|
||
#[serde(default)]
|
||
pub column_default: Option<String>,
|
||
#[serde(default)]
|
||
pub extra: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct DataGridSaveStatementOptions {
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub database_type: Option<DatabaseType>,
|
||
pub table_meta: DataGridTableMeta,
|
||
pub columns: Vec<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub source_columns: Option<Vec<Option<String>>>,
|
||
#[serde(default)]
|
||
pub rows: Vec<Vec<Value>>,
|
||
#[serde(default)]
|
||
pub dirty_rows: Vec<(usize, Vec<(usize, Value)>)>,
|
||
#[serde(default)]
|
||
pub deleted_rows: Vec<usize>,
|
||
#[serde(default)]
|
||
pub new_rows: Vec<Vec<Value>>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct DataGridCopyUpdateStatementOptions {
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub database_type: Option<DatabaseType>,
|
||
pub table_meta: DataGridTableMeta,
|
||
pub columns: Vec<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub source_columns: Option<Vec<Option<String>>>,
|
||
#[serde(default)]
|
||
pub rows: Vec<Vec<Value>>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct DataGridCopyInsertStatementOptions {
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub database_type: Option<DatabaseType>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub table_meta: Option<DataGridTableMeta>,
|
||
pub columns: Vec<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub source_columns: Option<Vec<Option<String>>>,
|
||
#[serde(default)]
|
||
pub rows: Vec<Vec<Value>>,
|
||
#[serde(default)]
|
||
pub exclude_primary_keys: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||
#[serde(rename_all = "kebab-case")]
|
||
pub enum DataGridContextFilterMode {
|
||
Equals,
|
||
NotEquals,
|
||
IsNull,
|
||
IsNotNull,
|
||
Like,
|
||
NotLike,
|
||
LessThan,
|
||
GreaterThan,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct DataGridContextFilterConditionOptions {
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub database_type: Option<DatabaseType>,
|
||
pub column_name: String,
|
||
pub mode: DataGridContextFilterMode,
|
||
pub value: Value,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub column_info: Option<DataGridColumnInfo>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct DataGridColumnValueFilterConditionOptions {
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub database_type: Option<DatabaseType>,
|
||
pub column_name: String,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub column_info: Option<DataGridColumnInfo>,
|
||
pub raw_value: String,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct DataGridCountSqlOptions {
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub database_type: Option<DatabaseType>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub schema: Option<String>,
|
||
pub table_name: String,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub where_input: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct HiveTablePropertiesSqlOptions {
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub schema: Option<String>,
|
||
pub table_name: String,
|
||
pub property_name: String,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct DataGridSavePreparation {
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub validation_error: Option<String>,
|
||
pub statements: Vec<String>,
|
||
pub rollback_statements: Vec<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub execution_schema: Option<String>,
|
||
}
|
||
|
||
pub fn prepare_data_grid_save(options: DataGridSaveStatementOptions) -> DataGridSavePreparation {
|
||
let validation_error = validate_data_grid_save(&options);
|
||
if validation_error.is_some() {
|
||
return DataGridSavePreparation {
|
||
validation_error,
|
||
statements: Vec::new(),
|
||
rollback_statements: Vec::new(),
|
||
execution_schema: data_grid_save_execution_schema(options.database_type, &options.table_meta),
|
||
};
|
||
}
|
||
|
||
DataGridSavePreparation {
|
||
validation_error: None,
|
||
statements: build_data_grid_save_statements(&options),
|
||
rollback_statements: build_data_grid_rollback_statements(&options),
|
||
execution_schema: data_grid_save_execution_schema(options.database_type, &options.table_meta),
|
||
}
|
||
}
|
||
|
||
pub fn build_data_grid_copy_update_statements(options: DataGridCopyUpdateStatementOptions) -> Vec<String> {
|
||
if matches!(options.database_type, Some(DatabaseType::Neo4j | DatabaseType::Tdengine)) {
|
||
return Vec::new();
|
||
}
|
||
let primary_keys = &options.table_meta.primary_keys;
|
||
if primary_keys.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
|
||
let save_columns = effective_copy_columns(options.source_columns.as_deref(), &options.columns);
|
||
let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]);
|
||
let primary_key_indexes: Vec<Option<usize>> =
|
||
primary_keys.iter().map(|primary_key| find_column_index(&save_columns, primary_key)).collect();
|
||
if primary_key_indexes.iter().any(Option::is_none) {
|
||
return Vec::new();
|
||
}
|
||
let primary_key_indexes: Vec<usize> = primary_key_indexes.into_iter().flatten().collect();
|
||
let primary_key_set: Vec<String> =
|
||
primary_keys.iter().map(|primary_key| normalize_column_name(primary_key)).collect();
|
||
let writable_indexes: Vec<(&str, usize)> = save_columns
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(index, column)| Some((column.as_deref()?, index)))
|
||
.filter(|(column, _)| !primary_key_set.contains(&normalize_column_name(column)))
|
||
.filter(|(column, _)| !is_oracle_row_id(options.database_type, Some(column)))
|
||
.collect();
|
||
|
||
if writable_indexes.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
|
||
let table = qualified_table_name(
|
||
options.database_type,
|
||
options.table_meta.schema.as_deref(),
|
||
&options.table_meta.table_name,
|
||
);
|
||
let mut statements = Vec::new();
|
||
for row in &options.rows {
|
||
if primary_key_indexes.iter().any(|index| row.get(*index).unwrap_or(&Value::Null).is_null()) {
|
||
continue;
|
||
}
|
||
let sets = writable_indexes
|
||
.iter()
|
||
.map(|(column, index)| {
|
||
format!(
|
||
"{} = {}",
|
||
quote_ident(options.database_type, column),
|
||
format_grid_sql_literal(
|
||
row.get(*index).unwrap_or(&Value::Null),
|
||
options.database_type,
|
||
column_info_for(column_info, column)
|
||
)
|
||
)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
if sets.is_empty() {
|
||
continue;
|
||
}
|
||
let where_clause = primary_keys
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(index, primary_key)| {
|
||
build_column_predicate(
|
||
options.database_type,
|
||
primary_key,
|
||
row.get(primary_key_indexes[index]).unwrap_or(&Value::Null),
|
||
column_info_for(column_info, primary_key),
|
||
)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(" AND ");
|
||
statements.push(format!("UPDATE {table} SET {sets} WHERE {where_clause};"));
|
||
}
|
||
statements
|
||
}
|
||
|
||
pub fn build_data_grid_copy_insert_statement(options: DataGridCopyInsertStatementOptions) -> Option<String> {
|
||
let save_columns = effective_copy_columns(options.source_columns.as_deref(), &options.columns);
|
||
let column_info = options.table_meta.as_ref().and_then(|meta| meta.columns.as_deref()).unwrap_or(&[]);
|
||
let primary_key_set: Vec<String> = options
|
||
.table_meta
|
||
.as_ref()
|
||
.map(|meta| meta.primary_keys.iter().map(|primary_key| normalize_column_name(primary_key)).collect())
|
||
.unwrap_or_default();
|
||
let insertable_columns: Vec<(&str, usize)> = save_columns
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(index, column)| Some((column.as_deref()?, index)))
|
||
.filter(|(column, _)| !is_oracle_row_id(options.database_type, Some(column)))
|
||
.collect();
|
||
let insert_columns: Vec<(&str, usize)> = insertable_columns
|
||
.iter()
|
||
.copied()
|
||
.filter(|(column, _)| {
|
||
!options.exclude_primary_keys || !primary_key_set.contains(&normalize_column_name(column))
|
||
})
|
||
.collect();
|
||
|
||
if options.exclude_primary_keys && insert_columns.len() == insertable_columns.len() {
|
||
return None;
|
||
}
|
||
if insert_columns.is_empty() || options.rows.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let table = options.table_meta.as_ref().map_or_else(
|
||
|| "table_name".to_string(),
|
||
|meta| qualified_table_name(options.database_type, meta.schema.as_deref(), &meta.table_name),
|
||
);
|
||
let columns = insert_columns
|
||
.iter()
|
||
.map(|(column, _)| quote_ident(options.database_type, column))
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
let value_rows = options
|
||
.rows
|
||
.iter()
|
||
.map(|row| {
|
||
format!(
|
||
"({})",
|
||
insert_columns
|
||
.iter()
|
||
.map(|(column, index)| {
|
||
format_grid_sql_literal(
|
||
row.get(*index).unwrap_or(&Value::Null),
|
||
options.database_type,
|
||
column_info_for(column_info, column),
|
||
)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(", ")
|
||
)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
Some(format!(
|
||
"INSERT INTO {table} ({columns}) VALUES{}{};",
|
||
if value_rows.len() == 1 { " " } else { "\n" },
|
||
value_rows.join(",\n")
|
||
))
|
||
}
|
||
|
||
pub fn build_data_grid_context_filter_condition(options: DataGridContextFilterConditionOptions) -> Option<String> {
|
||
let column = column_filter_ref(options.database_type, &options.column_name);
|
||
let value = &options.value;
|
||
match options.mode {
|
||
DataGridContextFilterMode::IsNull => Some(format!("{column} IS NULL")),
|
||
DataGridContextFilterMode::IsNotNull => Some(format!("{column} IS NOT NULL")),
|
||
DataGridContextFilterMode::Equals if value.is_null() => Some(format!("{column} IS NULL")),
|
||
DataGridContextFilterMode::NotEquals if value.is_null() => Some(format!("{column} IS NOT NULL")),
|
||
DataGridContextFilterMode::Like => Some(format!(
|
||
"{column} LIKE {}",
|
||
format_grid_sql_literal(
|
||
&Value::String(format!("%{}%", value_to_filter_text(value))),
|
||
options.database_type,
|
||
None
|
||
)
|
||
)),
|
||
DataGridContextFilterMode::NotLike => Some(format!(
|
||
"{column} NOT LIKE {}",
|
||
format_grid_sql_literal(
|
||
&Value::String(format!("%{}%", value_to_filter_text(value))),
|
||
options.database_type,
|
||
None
|
||
)
|
||
)),
|
||
DataGridContextFilterMode::LessThan => Some(format!(
|
||
"{column} < {}",
|
||
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
|
||
)),
|
||
DataGridContextFilterMode::GreaterThan => Some(format!(
|
||
"{column} > {}",
|
||
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
|
||
)),
|
||
DataGridContextFilterMode::Equals => Some(format!(
|
||
"{column} = {}",
|
||
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
|
||
)),
|
||
DataGridContextFilterMode::NotEquals => Some(format!(
|
||
"{column} <> {}",
|
||
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
|
||
)),
|
||
}
|
||
}
|
||
|
||
pub fn build_data_grid_column_value_filter_condition(
|
||
options: DataGridColumnValueFilterConditionOptions,
|
||
) -> Option<String> {
|
||
let text = options.raw_value.trim();
|
||
if text.is_empty() {
|
||
return None;
|
||
}
|
||
let column = column_filter_ref(options.database_type, &options.column_name);
|
||
if text.eq_ignore_ascii_case("null") {
|
||
return Some(format!("{column} IS NULL"));
|
||
}
|
||
let value = parse_typed_filter_value(text, options.column_info.as_ref());
|
||
Some(format!("{column} = {}", format_grid_sql_literal(&value, options.database_type, options.column_info.as_ref())))
|
||
}
|
||
|
||
pub fn build_data_grid_count_sql(options: DataGridCountSqlOptions) -> String {
|
||
let table = qualified_table_name(options.database_type, options.schema.as_deref(), &options.table_name);
|
||
let predicate = crate::sql_dialect::normalize_where_input(options.where_input.as_deref());
|
||
let where_clause = if predicate.is_empty() { String::new() } else { format!(" WHERE ({predicate})") };
|
||
format!("SELECT COUNT(*) AS cnt FROM {table}{where_clause}")
|
||
}
|
||
|
||
pub fn build_hive_table_properties_sql(options: HiveTablePropertiesSqlOptions) -> String {
|
||
let table = qualified_table_name(Some(DatabaseType::Hive), options.schema.as_deref(), &options.table_name);
|
||
let property = options.property_name.replace('\'', "''");
|
||
format!("SHOW TBLPROPERTIES {table} ('{property}')")
|
||
}
|
||
|
||
fn validate_data_grid_save(options: &DataGridSaveStatementOptions) -> Option<String> {
|
||
if let Some(error) = validate_inserted_primary_keys(options) {
|
||
return Some(error);
|
||
}
|
||
|
||
let not_null_columns: Vec<String> = options
|
||
.table_meta
|
||
.columns
|
||
.as_deref()
|
||
.unwrap_or(&[])
|
||
.iter()
|
||
.filter(|column| {
|
||
!column.is_nullable
|
||
&& column.column_default.is_none()
|
||
&& !is_auto_generated_column(column)
|
||
&& !is_oracle_row_id(options.database_type, Some(&column.name))
|
||
})
|
||
.map(|column| normalize_column_name(&column.name))
|
||
.collect();
|
||
if not_null_columns.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
for (_, changes) in &options.dirty_rows {
|
||
for (column_index, value) in changes {
|
||
let source_column = effective_column(options, *column_index);
|
||
if is_null_write_to_not_null_column(options.database_type, ¬_null_columns, source_column, value) {
|
||
return Some(null_write_error(source_column.unwrap_or_default()));
|
||
}
|
||
}
|
||
}
|
||
|
||
for row in &options.new_rows {
|
||
for column_index in 0..options.columns.len() {
|
||
let source_column = effective_column(options, column_index);
|
||
if is_null_write_to_not_null_column(
|
||
options.database_type,
|
||
¬_null_columns,
|
||
source_column,
|
||
row.get(column_index).unwrap_or(&Value::Null),
|
||
) {
|
||
return Some(null_write_error(source_column.unwrap_or_default()));
|
||
}
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
fn validate_inserted_primary_keys(options: &DataGridSaveStatementOptions) -> Option<String> {
|
||
let primary_keys = &options.table_meta.primary_keys;
|
||
if primary_keys.is_empty() || options.new_rows.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let save_columns = effective_columns(options);
|
||
let primary_key_indexes: Vec<Option<usize>> =
|
||
primary_keys.iter().map(|primary_key| find_column_index(&save_columns, primary_key)).collect();
|
||
if primary_key_indexes.iter().any(Option::is_none) {
|
||
return None;
|
||
}
|
||
let primary_key_indexes: Vec<usize> = primary_key_indexes.into_iter().flatten().collect();
|
||
|
||
let mut existing_keys: Vec<String> = Vec::new();
|
||
for row in &options.rows {
|
||
if let Some(key) = primary_key_value_key(&primary_key_indexes, row) {
|
||
existing_keys.push(key);
|
||
}
|
||
}
|
||
|
||
let mut new_keys: Vec<String> = Vec::new();
|
||
for row in &options.new_rows {
|
||
let Some(key) = primary_key_value_key(&primary_key_indexes, row) else {
|
||
continue;
|
||
};
|
||
if existing_keys.contains(&key) || new_keys.contains(&key) {
|
||
return Some(duplicate_primary_key_error(
|
||
primary_keys,
|
||
&primary_key_indexes,
|
||
row,
|
||
existing_keys.contains(&key),
|
||
));
|
||
}
|
||
new_keys.push(key);
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
fn build_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||
if options.database_type == Some(DatabaseType::Neo4j) {
|
||
return build_neo4j_data_grid_save_statements(options);
|
||
}
|
||
if options.database_type == Some(DatabaseType::Tdengine) {
|
||
return build_tdengine_data_grid_save_statements(options);
|
||
}
|
||
|
||
let save_columns = effective_columns(options);
|
||
let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]);
|
||
let table = qualified_table_name(
|
||
options.database_type,
|
||
options.table_meta.schema.as_deref(),
|
||
&options.table_meta.table_name,
|
||
);
|
||
let mut statements = Vec::new();
|
||
|
||
for (row_index, changes) in &options.dirty_rows {
|
||
let Some(row) = options.rows.get(*row_index) else {
|
||
continue;
|
||
};
|
||
let sets = changes
|
||
.iter()
|
||
.filter_map(|(column_index, value)| {
|
||
let column = save_columns.get(*column_index)?.as_deref()?;
|
||
if is_oracle_row_id(options.database_type, Some(column)) {
|
||
return None;
|
||
}
|
||
Some(format!(
|
||
"{} = {}",
|
||
quote_ident(options.database_type, column),
|
||
format_grid_sql_literal(value, options.database_type, column_info_for(column_info, column))
|
||
))
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
if sets.is_empty() {
|
||
continue;
|
||
}
|
||
let where_clause = build_primary_key_where(
|
||
options.database_type,
|
||
&options.table_meta.primary_keys,
|
||
&save_columns,
|
||
row,
|
||
column_info,
|
||
);
|
||
statements.push(format!("UPDATE {table} SET {sets} WHERE {where_clause};"));
|
||
}
|
||
|
||
for row_index in &options.deleted_rows {
|
||
let Some(row) = options.rows.get(*row_index) else {
|
||
continue;
|
||
};
|
||
let where_clause = build_primary_key_where(
|
||
options.database_type,
|
||
&options.table_meta.primary_keys,
|
||
&save_columns,
|
||
row,
|
||
column_info,
|
||
);
|
||
statements.push(format!("DELETE FROM {table} WHERE {where_clause};"));
|
||
}
|
||
|
||
for row in &options.new_rows {
|
||
let insert_pairs: Vec<(&str, &Value)> = save_columns
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(index, column)| Some((column.as_deref()?, row.get(index).unwrap_or(&Value::Null))))
|
||
.filter(|(column, _)| !is_oracle_row_id(options.database_type, Some(column)))
|
||
.filter(|(_, value)| !value.is_null())
|
||
.collect();
|
||
if insert_pairs.is_empty() {
|
||
continue;
|
||
}
|
||
let columns = insert_pairs
|
||
.iter()
|
||
.map(|(column, _)| quote_ident(options.database_type, column))
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
let values = insert_pairs
|
||
.iter()
|
||
.map(|(column, value)| {
|
||
format_grid_sql_literal(value, options.database_type, column_info_for(column_info, column))
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
statements.push(format!("INSERT INTO {table} ({columns}) VALUES ({values});"));
|
||
}
|
||
|
||
statements
|
||
}
|
||
|
||
fn build_data_grid_rollback_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||
if options.database_type == Some(DatabaseType::Neo4j) {
|
||
return build_neo4j_data_grid_rollback_statements(options);
|
||
}
|
||
|
||
let save_columns = effective_columns(options);
|
||
let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]);
|
||
let table = qualified_table_name(
|
||
options.database_type,
|
||
options.table_meta.schema.as_deref(),
|
||
&options.table_meta.table_name,
|
||
);
|
||
let mut statements = Vec::new();
|
||
|
||
for row in &options.new_rows {
|
||
let where_clause = build_row_where(options.database_type, &save_columns, row, column_info);
|
||
if !where_clause.is_empty() {
|
||
statements.push(format!("DELETE FROM {table} WHERE {where_clause};"));
|
||
}
|
||
}
|
||
|
||
for row_index in &options.deleted_rows {
|
||
let Some(row) = options.rows.get(*row_index) else {
|
||
continue;
|
||
};
|
||
let insert_pairs: Vec<(&str, &Value)> = save_columns
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(index, column)| Some((column.as_deref()?, row.get(index).unwrap_or(&Value::Null))))
|
||
.filter(|(column, _)| !is_oracle_row_id(options.database_type, Some(column)))
|
||
.collect();
|
||
let columns = insert_pairs
|
||
.iter()
|
||
.map(|(column, _)| quote_ident(options.database_type, column))
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
let values = insert_pairs
|
||
.iter()
|
||
.map(|(column, value)| {
|
||
format_grid_sql_literal(value, options.database_type, column_info_for(column_info, column))
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
statements.push(format!("INSERT INTO {table} ({columns}) VALUES ({values});"));
|
||
}
|
||
|
||
for (row_index, changes) in &options.dirty_rows {
|
||
let Some(row) = options.rows.get(*row_index) else {
|
||
continue;
|
||
};
|
||
let mut after_row = row.clone();
|
||
for (column_index, value) in changes {
|
||
if *column_index < after_row.len() {
|
||
after_row[*column_index] = value.clone();
|
||
}
|
||
}
|
||
let writable_changes: Vec<(&(usize, Value), &str)> = changes
|
||
.iter()
|
||
.filter_map(|change @ (column_index, _)| {
|
||
let column = save_columns.get(*column_index)?.as_deref()?;
|
||
if is_oracle_row_id(options.database_type, Some(column)) {
|
||
return None;
|
||
}
|
||
Some((change, column))
|
||
})
|
||
.collect();
|
||
let sets = writable_changes
|
||
.iter()
|
||
.map(|((column_index, _), column)| {
|
||
format!(
|
||
"{} = {}",
|
||
quote_ident(options.database_type, column),
|
||
format_grid_sql_literal(
|
||
row.get(*column_index).unwrap_or(&Value::Null),
|
||
options.database_type,
|
||
column_info_for(column_info, column)
|
||
)
|
||
)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
if sets.is_empty() {
|
||
continue;
|
||
}
|
||
let mut predicates = vec![build_primary_key_where(
|
||
options.database_type,
|
||
&options.table_meta.primary_keys,
|
||
&save_columns,
|
||
&after_row,
|
||
column_info,
|
||
)];
|
||
predicates.extend(writable_changes.iter().map(|((_, value), column)| {
|
||
build_column_predicate(options.database_type, column, value, column_info_for(column_info, column))
|
||
}));
|
||
statements.push(format!(
|
||
"UPDATE {table} SET {sets} WHERE {};",
|
||
predicates.into_iter().filter(|part| !part.is_empty()).collect::<Vec<_>>().join(" AND ")
|
||
));
|
||
}
|
||
|
||
statements
|
||
}
|
||
|
||
pub(crate) fn effective_columns(options: &DataGridSaveStatementOptions) -> Vec<Option<String>> {
|
||
match &options.source_columns {
|
||
Some(source_columns) if source_columns.len() == options.columns.len() => source_columns.clone(),
|
||
_ => options.columns.iter().map(|column| Some(column.clone())).collect(),
|
||
}
|
||
}
|
||
|
||
fn effective_copy_columns(source_columns: Option<&[Option<String>]>, columns: &[String]) -> Vec<Option<String>> {
|
||
match source_columns {
|
||
Some(source_columns) if source_columns.len() == columns.len() => source_columns.to_vec(),
|
||
_ => columns.iter().map(|column| Some(column.clone())).collect(),
|
||
}
|
||
}
|
||
|
||
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(),
|
||
_ => options.columns.get(index).map(String::as_str),
|
||
}
|
||
}
|
||
|
||
fn data_grid_save_execution_schema(
|
||
database_type: Option<DatabaseType>,
|
||
table_meta: &DataGridTableMeta,
|
||
) -> Option<String> {
|
||
if matches!(database_type, Some(DatabaseType::Neo4j | DatabaseType::Oracle)) {
|
||
return None;
|
||
}
|
||
table_meta.schema.clone()
|
||
}
|
||
|
||
pub fn normalize_data_grid_save_error(database_type: Option<DatabaseType>, error: &str) -> String {
|
||
if database_type == Some(DatabaseType::Hive)
|
||
&& (error.contains("Attempt to do update or delete") || error.contains("Error 10294"))
|
||
{
|
||
return "Hive UPDATE/DELETE are not enabled for this table or server. Add rows with INSERT, or enable ACID transactional tables in Hive before editing/deleting existing rows.".to_string();
|
||
}
|
||
error.to_string()
|
||
}
|
||
|
||
pub fn format_grid_sql_literal(
|
||
value: &Value,
|
||
database_type: Option<DatabaseType>,
|
||
column_info: Option<&DataGridColumnInfo>,
|
||
) -> String {
|
||
if value.is_null() {
|
||
return "NULL".to_string();
|
||
}
|
||
if is_mysql_bit_literal_column(database_type, column_info) {
|
||
if let Some(value) = value.as_bool() {
|
||
return if value { "1" } else { "0" }.to_string();
|
||
}
|
||
if let Some(number) = value.as_number() {
|
||
return number.to_string();
|
||
}
|
||
if let Some(text) = value.as_str().and_then(format_mysql_bit_literal_text) {
|
||
return text;
|
||
}
|
||
}
|
||
if let Some(value) = value.as_bool() {
|
||
return if value { "TRUE" } else { "FALSE" }.to_string();
|
||
}
|
||
if let Some(number) = value.as_number() {
|
||
return number.to_string();
|
||
}
|
||
if let Some(arr) = value.as_array() {
|
||
if matches!(database_type, Some(DatabaseType::ClickHouse) | Some(DatabaseType::Databend)) {
|
||
return format_ch_array_sql_literal(arr);
|
||
}
|
||
return format_pg_array_sql_literal(arr);
|
||
}
|
||
let text = value.as_str().map_or_else(|| value.to_string(), ToString::to_string);
|
||
if text.is_empty() {
|
||
return if database_type == Some(DatabaseType::SqlServer) { "N''" } else { "''" }.to_string();
|
||
}
|
||
let literal_text = if database_type == Some(DatabaseType::Tdengine) {
|
||
format_tdengine_timestamp_literal_text(&text)
|
||
} else if is_mysql_datetime_literal_database(database_type)
|
||
&& column_info.map(|column| is_temporal_column_type(&column.data_type)).unwrap_or(true)
|
||
{
|
||
format_mysql_temporal_literal_text(&text, column_info.map(|column| column.data_type.as_str()))
|
||
} else {
|
||
text
|
||
};
|
||
let escaped = format!("'{}'", literal_text.replace('\\', "\\\\").replace('\'', "''"));
|
||
if database_type == Some(DatabaseType::SqlServer) {
|
||
format!("N{escaped}")
|
||
} else {
|
||
escaped
|
||
}
|
||
}
|
||
|
||
fn is_mysql_bit_literal_column(database_type: Option<DatabaseType>, column_info: Option<&DataGridColumnInfo>) -> bool {
|
||
is_mysql_datetime_literal_database(database_type)
|
||
&& column_info.map(|column| is_bit_column_type(&column.data_type)).unwrap_or(false)
|
||
}
|
||
|
||
fn is_bit_column_type(data_type: &str) -> bool {
|
||
let lower = data_type.to_ascii_lowercase();
|
||
lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| token == "bit")
|
||
}
|
||
|
||
fn format_mysql_bit_literal_text(text: &str) -> Option<String> {
|
||
let trimmed = text.trim();
|
||
if trimmed.eq_ignore_ascii_case("true") {
|
||
return Some("1".to_string());
|
||
}
|
||
if trimmed.eq_ignore_ascii_case("false") {
|
||
return Some("0".to_string());
|
||
}
|
||
if trimmed.chars().all(|ch| ch.is_ascii_digit()) && !trimmed.is_empty() {
|
||
return Some(if trimmed.len() == 1 {
|
||
trimmed.to_string()
|
||
} else if trimmed.chars().all(|ch| matches!(ch, '0' | '1')) {
|
||
format!("b'{trimmed}'")
|
||
} else {
|
||
trimmed.to_string()
|
||
});
|
||
}
|
||
let lower = trimmed.to_ascii_lowercase();
|
||
if lower.starts_with("b'") && trimmed.ends_with('\'') {
|
||
let bits = &trimmed[2..trimmed.len() - 1];
|
||
if !bits.is_empty() && bits.chars().all(|ch| matches!(ch, '0' | '1')) {
|
||
return Some(format!("b'{bits}'"));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn is_mysql_datetime_literal_database(database_type: Option<DatabaseType>) -> bool {
|
||
matches!(
|
||
database_type,
|
||
Some(
|
||
DatabaseType::Mysql
|
||
| DatabaseType::Doris
|
||
| DatabaseType::StarRocks
|
||
| DatabaseType::Goldendb
|
||
| DatabaseType::Sundb
|
||
)
|
||
)
|
||
}
|
||
|
||
fn format_mysql_temporal_literal_text(text: &str, data_type: Option<&str>) -> String {
|
||
let Some(captures) = regex_like_rfc3339(text) else {
|
||
return text.to_string();
|
||
};
|
||
match temporal_column_kind(data_type) {
|
||
Some("date") => captures.date,
|
||
Some("time") => {
|
||
format!("{}{}", captures.time, normalize_mysql_fractional_seconds(captures.fraction.as_deref()))
|
||
}
|
||
_ => format!(
|
||
"{} {}{}",
|
||
captures.date,
|
||
captures.time,
|
||
normalize_mysql_fractional_seconds(captures.fraction.as_deref())
|
||
),
|
||
}
|
||
}
|
||
|
||
struct Rfc3339Parts {
|
||
date: String,
|
||
time: String,
|
||
fraction: Option<String>,
|
||
}
|
||
|
||
fn regex_like_rfc3339(text: &str) -> Option<Rfc3339Parts> {
|
||
let bytes = text.as_bytes();
|
||
if bytes.len() < 20 || bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') {
|
||
return None;
|
||
}
|
||
let separator = *bytes.get(10)?;
|
||
if separator != b'T' && separator != b' ' {
|
||
return None;
|
||
}
|
||
if bytes.get(13) != Some(&b':') || bytes.get(16) != Some(&b':') {
|
||
return None;
|
||
}
|
||
let date = &text[0..10];
|
||
let time = &text[11..19];
|
||
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();
|
||
if digit_count == 0 || digit_count > 9 {
|
||
return None;
|
||
}
|
||
(Some(format!(".{}", &rest[..digit_count])), &rest[digit_count..])
|
||
} else {
|
||
(None, rest)
|
||
};
|
||
if zone == "Z" || zone == "z" || is_timezone_offset(zone) {
|
||
Some(Rfc3339Parts { date: date.to_string(), time: time.to_string(), fraction })
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn is_timezone_offset(value: &str) -> bool {
|
||
let bytes = value.as_bytes();
|
||
bytes.len() == 6
|
||
&& matches!(bytes[0], b'+' | b'-')
|
||
&& bytes[3] == b':'
|
||
&& bytes[1].is_ascii_digit()
|
||
&& bytes[2].is_ascii_digit()
|
||
&& bytes[4].is_ascii_digit()
|
||
&& bytes[5].is_ascii_digit()
|
||
}
|
||
|
||
fn normalize_mysql_fractional_seconds(fraction: Option<&str>) -> String {
|
||
match fraction {
|
||
Some(fraction) if fraction.len() > 7 => fraction[..7].to_string(),
|
||
Some(fraction) => fraction.to_string(),
|
||
None => String::new(),
|
||
}
|
||
}
|
||
|
||
fn is_temporal_column_type(data_type: &str) -> bool {
|
||
temporal_column_kind(Some(data_type)).is_some()
|
||
}
|
||
|
||
fn temporal_column_kind(data_type: Option<&str>) -> Option<&'static str> {
|
||
let base =
|
||
data_type.unwrap_or("").trim().to_ascii_lowercase().split(['(', ':', ' ']).next().unwrap_or("").to_string();
|
||
match base.as_str() {
|
||
"date" => Some("date"),
|
||
"time" => Some("time"),
|
||
"datetime" | "timestamp" => Some("datetime"),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn format_tdengine_timestamp_literal_text(text: &str) -> String {
|
||
let Some((date, time, fraction)) = parse_tdengine_timestamp(text) else {
|
||
return text.to_string();
|
||
};
|
||
format!(
|
||
"{date}T{time}{}{suffix}",
|
||
normalize_fractional_seconds(fraction.as_deref()),
|
||
suffix = local_timezone_offset_suffix(text)
|
||
)
|
||
}
|
||
|
||
fn parse_tdengine_timestamp(text: &str) -> Option<(String, String, Option<String>)> {
|
||
if text.len() < 19 {
|
||
return None;
|
||
}
|
||
let bytes = text.as_bytes();
|
||
if bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') || bytes.get(10) != Some(&b' ') {
|
||
return None;
|
||
}
|
||
if bytes.get(13) != Some(&b':') || bytes.get(16) != Some(&b':') {
|
||
return None;
|
||
}
|
||
let date = text[0..10].to_string();
|
||
let time = text[11..19].to_string();
|
||
let rest = &text[19..];
|
||
if rest.is_empty() {
|
||
return Some((date, time, None));
|
||
}
|
||
let fraction = rest.strip_prefix('.')?;
|
||
if fraction.is_empty() || fraction.len() > 9 || !fraction.chars().all(|ch| ch.is_ascii_digit()) {
|
||
return None;
|
||
}
|
||
Some((date, time, Some(format!(".{fraction}"))))
|
||
}
|
||
|
||
fn normalize_fractional_seconds(fraction: Option<&str>) -> String {
|
||
match fraction {
|
||
Some(fraction) if fraction.len() >= 4 => fraction[..4].to_string(),
|
||
Some(fraction) => format!("{fraction:0<4}"),
|
||
None => ".000".to_string(),
|
||
}
|
||
}
|
||
|
||
fn local_timezone_offset_suffix(text: &str) -> String {
|
||
let naive = NaiveDateTime::parse_from_str(&text.replace(' ', "T"), "%Y-%m-%dT%H:%M:%S%.f").ok();
|
||
let offset_minutes = naive
|
||
.and_then(|dt| {
|
||
let local = dt.and_local_timezone(Local).earliest()?;
|
||
Some(local.offset().local_minus_utc() / -60)
|
||
})
|
||
.unwrap_or_else(|| Local::now().offset().local_minus_utc() / -60);
|
||
let sign = if offset_minutes <= 0 { "+" } else { "-" };
|
||
let abs = offset_minutes.abs();
|
||
format!("{sign}{:02}:{:02}", abs / 60, abs % 60)
|
||
}
|
||
|
||
fn build_primary_key_where(
|
||
database_type: Option<DatabaseType>,
|
||
primary_keys: &[String],
|
||
columns: &[Option<String>],
|
||
row: &[Value],
|
||
column_info: &[DataGridColumnInfo],
|
||
) -> String {
|
||
if primary_keys.is_empty() && uses_keyless_row_predicate(database_type) {
|
||
return build_row_where(database_type, columns, row, column_info);
|
||
}
|
||
primary_keys
|
||
.iter()
|
||
.map(|primary_key| {
|
||
let value = row
|
||
.get(
|
||
columns
|
||
.iter()
|
||
.position(|column| column.as_deref() == Some(primary_key.as_str()))
|
||
.unwrap_or(usize::MAX),
|
||
)
|
||
.unwrap_or(&Value::Null);
|
||
build_column_predicate(database_type, primary_key, value, column_info_for(column_info, primary_key))
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(" AND ")
|
||
}
|
||
|
||
fn build_row_where(
|
||
database_type: Option<DatabaseType>,
|
||
columns: &[Option<String>],
|
||
row: &[Value],
|
||
column_info: &[DataGridColumnInfo],
|
||
) -> String {
|
||
columns
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(index, column)| {
|
||
let column = column.as_deref()?;
|
||
if is_oracle_row_id(database_type, Some(column)) {
|
||
return None;
|
||
}
|
||
Some(build_column_predicate(
|
||
database_type,
|
||
column,
|
||
row.get(index).unwrap_or(&Value::Null),
|
||
column_info_for(column_info, column),
|
||
))
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(" AND ")
|
||
}
|
||
|
||
fn build_column_predicate(
|
||
database_type: Option<DatabaseType>,
|
||
column: &str,
|
||
value: &Value,
|
||
column_info: Option<&DataGridColumnInfo>,
|
||
) -> String {
|
||
let ident = predicate_ident(database_type, column);
|
||
if value.is_null() {
|
||
format!("{ident} IS NULL")
|
||
} else if uses_mysql_binary_text_predicate(database_type, value, column_info) {
|
||
format!("BINARY {ident} = {}", format_grid_sql_literal(value, database_type, column_info))
|
||
} else {
|
||
format!("{ident} = {}", format_grid_sql_literal(value, database_type, column_info))
|
||
}
|
||
}
|
||
|
||
fn uses_mysql_binary_text_predicate(
|
||
database_type: Option<DatabaseType>,
|
||
value: &Value,
|
||
column_info: Option<&DataGridColumnInfo>,
|
||
) -> bool {
|
||
database_type == Some(DatabaseType::Mysql)
|
||
&& value.is_string()
|
||
&& column_info.map(|column| is_textual_column_type(&column.data_type)).unwrap_or(false)
|
||
}
|
||
|
||
fn is_textual_column_type(data_type: &str) -> bool {
|
||
let lower = data_type.to_ascii_lowercase();
|
||
lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| {
|
||
matches!(token, "char" | "varchar" | "text" | "tinytext" | "mediumtext" | "longtext" | "enum" | "set")
|
||
})
|
||
}
|
||
|
||
fn is_oracle_row_id(database_type: Option<DatabaseType>, name: Option<&str>) -> bool {
|
||
database_type == Some(DatabaseType::Oracle) && name.is_some_and(|name| name.eq_ignore_ascii_case(DBX_ROWID_COLUMN))
|
||
}
|
||
|
||
pub(crate) fn is_neo4j_element_id(database_type: Option<DatabaseType>, name: Option<&str>) -> bool {
|
||
database_type == Some(DatabaseType::Neo4j) && name == Some(DBX_NEO4J_ELEMENT_ID_COLUMN)
|
||
}
|
||
|
||
fn is_auto_generated_column(column: &DataGridColumnInfo) -> bool {
|
||
column
|
||
.extra
|
||
.as_deref()
|
||
.unwrap_or("")
|
||
.to_ascii_lowercase()
|
||
.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
|
||
.any(|part| matches!(part, "auto_increment" | "autoincrement" | "identity"))
|
||
}
|
||
|
||
fn is_null_write_to_not_null_column(
|
||
database_type: Option<DatabaseType>,
|
||
not_null_columns: &[String],
|
||
column: Option<&str>,
|
||
value: &Value,
|
||
) -> bool {
|
||
let Some(column) = column else {
|
||
return false;
|
||
};
|
||
if is_oracle_row_id(database_type, Some(column)) || is_neo4j_element_id(database_type, Some(column)) {
|
||
return false;
|
||
}
|
||
value.is_null() && not_null_columns.iter().any(|not_null| not_null == &normalize_column_name(column))
|
||
}
|
||
|
||
fn find_column_index(columns: &[Option<String>], target: &str) -> Option<usize> {
|
||
let normalized_target = normalize_column_name(target);
|
||
columns
|
||
.iter()
|
||
.position(|column| column.as_deref().map(normalize_column_name).unwrap_or_default() == normalized_target)
|
||
}
|
||
|
||
fn primary_key_value_key(primary_key_indexes: &[usize], row: &[Value]) -> Option<String> {
|
||
let values: Vec<Value> =
|
||
primary_key_indexes.iter().map(|index| row.get(*index).cloned().unwrap_or(Value::Null)).collect();
|
||
if values.iter().any(Value::is_null) {
|
||
return None;
|
||
}
|
||
serde_json::to_string(&values).ok()
|
||
}
|
||
|
||
fn duplicate_primary_key_error(
|
||
primary_keys: &[String],
|
||
primary_key_indexes: &[usize],
|
||
row: &[Value],
|
||
matches_existing_row: bool,
|
||
) -> String {
|
||
let key_summary = primary_keys
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(index, primary_key)| {
|
||
format!(
|
||
"{} = {}",
|
||
primary_key,
|
||
format_key_value_for_message(row.get(primary_key_indexes[index]).unwrap_or(&Value::Null))
|
||
)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
let source = if matches_existing_row { "the existing primary key" } else { "another new row's primary key" };
|
||
format!("New row duplicates {source} ({key_summary}). Change the key before saving.")
|
||
}
|
||
|
||
fn format_key_value_for_message(value: &Value) -> String {
|
||
if value.is_null() {
|
||
return "NULL".to_string();
|
||
}
|
||
if let Some(value) = value.as_str() {
|
||
return format!("\"{}\"", value.replace('"', "\\\""));
|
||
}
|
||
value.to_string()
|
||
}
|
||
|
||
fn normalize_column_name(name: &str) -> String {
|
||
name.to_ascii_uppercase()
|
||
}
|
||
|
||
fn null_write_error(column: &str) -> String {
|
||
format!("Column \"{column}\" does not allow NULL.")
|
||
}
|
||
|
||
fn predicate_ident(database_type: Option<DatabaseType>, name: &str) -> String {
|
||
if is_oracle_row_id(database_type, Some(name)) {
|
||
"ROWIDTOCHAR(ROWID)".to_string()
|
||
} else {
|
||
quote_ident(database_type, name)
|
||
}
|
||
}
|
||
|
||
pub(crate) fn quote_ident(database_type: Option<DatabaseType>, name: &str) -> String {
|
||
quote_table_identifier(database_type, name)
|
||
}
|
||
|
||
pub(crate) fn qualified_table_name(
|
||
database_type: Option<DatabaseType>,
|
||
schema: Option<&str>,
|
||
table_name: &str,
|
||
) -> String {
|
||
crate::sql_dialect::qualified_table_name(database_type, schema, table_name)
|
||
}
|
||
|
||
fn column_filter_ref(database_type: Option<DatabaseType>, column_name: &str) -> String {
|
||
let quoted = quote_ident(database_type, column_name);
|
||
if database_type == Some(DatabaseType::Neo4j) {
|
||
format!("n.{quoted}")
|
||
} else {
|
||
quoted
|
||
}
|
||
}
|
||
|
||
fn value_to_filter_text(value: &Value) -> String {
|
||
if let Some(value) = value.as_str() {
|
||
value.to_string()
|
||
} else if value.is_null() {
|
||
String::new()
|
||
} else {
|
||
value.to_string()
|
||
}
|
||
}
|
||
|
||
fn parse_typed_filter_value(text: &str, column_info: Option<&DataGridColumnInfo>) -> Value {
|
||
let unquoted = unwrap_matching_quotes(text);
|
||
let data_type = column_info.map(|column| column.data_type.to_ascii_lowercase()).unwrap_or_default();
|
||
if is_boolean_type(&data_type) && unquoted.eq_ignore_ascii_case("true") {
|
||
return Value::Bool(true);
|
||
}
|
||
if is_boolean_type(&data_type) && unquoted.eq_ignore_ascii_case("false") {
|
||
return Value::Bool(false);
|
||
}
|
||
if (is_numeric_type(&data_type) || data_type.is_empty()) && is_numeric_literal(&unquoted) {
|
||
if let Ok(number) = unquoted.parse::<serde_json::Number>() {
|
||
return Value::Number(number);
|
||
}
|
||
}
|
||
Value::String(unquoted)
|
||
}
|
||
|
||
fn unwrap_matching_quotes(text: &str) -> String {
|
||
let mut chars = text.chars();
|
||
let Some(first) = chars.next() else {
|
||
return String::new();
|
||
};
|
||
let Some(last) = text.chars().last() else {
|
||
return String::new();
|
||
};
|
||
if text.len() >= 2 && ((first == '\'' && last == '\'') || (first == '"' && last == '"')) {
|
||
text[1..text.len() - 1].to_string()
|
||
} else {
|
||
text.to_string()
|
||
}
|
||
}
|
||
|
||
fn is_numeric_type(data_type: &str) -> bool {
|
||
let lower = data_type.to_ascii_lowercase();
|
||
[
|
||
"int",
|
||
"integer",
|
||
"bigint",
|
||
"smallint",
|
||
"tinyint",
|
||
"mediumint",
|
||
"serial",
|
||
"number",
|
||
"numeric",
|
||
"decimal",
|
||
"float",
|
||
"double",
|
||
"real",
|
||
"money",
|
||
]
|
||
.iter()
|
||
.any(|part| lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| token == *part))
|
||
}
|
||
|
||
fn is_boolean_type(data_type: &str) -> bool {
|
||
let lower = data_type.to_ascii_lowercase();
|
||
lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| matches!(token, "bool" | "boolean" | "bit"))
|
||
}
|
||
|
||
fn is_numeric_literal(text: &str) -> bool {
|
||
if text.trim() != text || text.is_empty() {
|
||
return false;
|
||
}
|
||
text.parse::<f64>().is_ok_and(f64::is_finite)
|
||
&& text.chars().all(|ch| ch.is_ascii_digit() || matches!(ch, '+' | '-' | '.' | 'e' | 'E'))
|
||
&& text.chars().any(|ch| ch.is_ascii_digit())
|
||
}
|
||
|
||
fn uses_keyless_row_predicate(database_type: Option<DatabaseType>) -> bool {
|
||
matches!(
|
||
database_type,
|
||
Some(
|
||
DatabaseType::Mysql
|
||
| DatabaseType::Postgres
|
||
| DatabaseType::Sqlite
|
||
| DatabaseType::DuckDb
|
||
| DatabaseType::SqlServer
|
||
| DatabaseType::Oracle
|
||
| DatabaseType::Doris
|
||
| DatabaseType::StarRocks
|
||
| DatabaseType::Redshift
|
||
| DatabaseType::Dameng
|
||
| DatabaseType::Gaussdb
|
||
| DatabaseType::Kwdb
|
||
| DatabaseType::Kingbase
|
||
| DatabaseType::Highgo
|
||
| DatabaseType::Vastbase
|
||
| DatabaseType::Goldendb
|
||
| DatabaseType::Yashandb
|
||
| DatabaseType::Databricks
|
||
| DatabaseType::SapHana
|
||
| DatabaseType::Teradata
|
||
| DatabaseType::Vertica
|
||
| DatabaseType::Firebird
|
||
| DatabaseType::Exasol
|
||
| DatabaseType::OpenGauss
|
||
| DatabaseType::OceanbaseOracle
|
||
| DatabaseType::Gbase
|
||
| DatabaseType::Access
|
||
| DatabaseType::H2
|
||
| DatabaseType::Snowflake
|
||
| DatabaseType::Db2
|
||
| DatabaseType::Informix
|
||
| DatabaseType::Bigquery
|
||
| DatabaseType::Sundb
|
||
| DatabaseType::Hive
|
||
| DatabaseType::Iris
|
||
)
|
||
)
|
||
}
|
||
|
||
pub(crate) fn column_info_for<'a>(columns: &'a [DataGridColumnInfo], name: &str) -> Option<&'a DataGridColumnInfo> {
|
||
let normalized = normalize_column_name(name);
|
||
columns.iter().find(|column| normalize_column_name(&column.name) == normalized)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use serde_json::json;
|
||
|
||
use super::*;
|
||
|
||
fn column(name: &str, data_type: &str, nullable: bool, extra: Option<&str>) -> DataGridColumnInfo {
|
||
DataGridColumnInfo {
|
||
name: name.to_string(),
|
||
data_type: data_type.to_string(),
|
||
is_nullable: nullable,
|
||
is_primary_key: false,
|
||
column_default: None,
|
||
extra: extra.map(ToString::to_string),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn builds_copy_update_statements() {
|
||
let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions {
|
||
database_type: Some(DatabaseType::Postgres),
|
||
table_meta: DataGridTableMeta {
|
||
schema: Some("public".to_string()),
|
||
table_name: "users".to_string(),
|
||
primary_keys: vec!["id".to_string()],
|
||
columns: None,
|
||
},
|
||
columns: vec!["id".to_string(), "name".to_string(), "status".to_string()],
|
||
source_columns: None,
|
||
rows: vec![vec![json!(1), json!("Ada"), json!("active")]],
|
||
});
|
||
assert_eq!(
|
||
statements,
|
||
vec!["UPDATE \"public\".\"users\" SET \"name\" = 'Ada', \"status\" = 'active' WHERE \"id\" = 1;"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn builds_copy_insert_statement_without_primary_keys() {
|
||
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
|
||
database_type: Some(DatabaseType::Mysql),
|
||
table_meta: Some(DataGridTableMeta {
|
||
schema: None,
|
||
table_name: "users".to_string(),
|
||
primary_keys: vec!["id".to_string()],
|
||
columns: None,
|
||
}),
|
||
columns: vec!["id".to_string(), "login_name".to_string(), "display_name".to_string()],
|
||
source_columns: None,
|
||
rows: vec![vec![json!(1), json!("ada"), json!("Ada")], vec![json!(2), json!("linus"), json!("Linus")]],
|
||
exclude_primary_keys: true,
|
||
});
|
||
assert_eq!(
|
||
statement.as_deref(),
|
||
Some("INSERT INTO `users` (`login_name`, `display_name`) VALUES\n('ada', 'Ada'),\n('linus', 'Linus');")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn builds_filter_conditions() {
|
||
assert_eq!(
|
||
build_data_grid_column_value_filter_condition(DataGridColumnValueFilterConditionOptions {
|
||
database_type: Some(DatabaseType::Mysql),
|
||
column_name: "id".to_string(),
|
||
column_info: Some(column("id", "int", false, None)),
|
||
raw_value: "49436".to_string(),
|
||
})
|
||
.as_deref(),
|
||
Some("`id` = 49436")
|
||
);
|
||
assert_eq!(
|
||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||
database_type: Some(DatabaseType::Postgres),
|
||
column_name: "status".to_string(),
|
||
mode: DataGridContextFilterMode::Like,
|
||
value: json!("active"),
|
||
column_info: Some(column("status", "varchar", true, None)),
|
||
})
|
||
.as_deref(),
|
||
Some("\"status\" LIKE '%active%'")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn builds_grid_count_sql() {
|
||
assert_eq!(
|
||
build_data_grid_count_sql(DataGridCountSqlOptions {
|
||
database_type: Some(DatabaseType::Postgres),
|
||
schema: Some("public".to_string()),
|
||
table_name: "users".to_string(),
|
||
where_input: Some("WHERE active = true;".to_string()),
|
||
}),
|
||
"SELECT COUNT(*) AS cnt FROM \"public\".\"users\" WHERE (active = true)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn builds_hive_table_properties_sql() {
|
||
assert_eq!(
|
||
build_hive_table_properties_sql(HiveTablePropertiesSqlOptions {
|
||
schema: Some("default".to_string()),
|
||
table_name: "events".to_string(),
|
||
property_name: "transactional".to_string(),
|
||
}),
|
||
"SHOW TBLPROPERTIES `default`.`events` ('transactional')"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn formats_temporal_copy_literals() {
|
||
assert_eq!(
|
||
format_grid_sql_literal(&json!("2026-05-12T00:00:00+00:00"), Some(DatabaseType::Mysql), None),
|
||
"'2026-05-12 00:00:00'"
|
||
);
|
||
assert_eq!(
|
||
format_grid_sql_literal(&json!("2026-05-12T00:00:00.123456Z"), Some(DatabaseType::Mysql), None),
|
||
"'2026-05-12 00:00:00.123456'"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn formats_mysql_bit_literals_without_string_quotes() {
|
||
let bit = column("flag", "bit(1)", true, None);
|
||
let bit_string = column("flags", "bit(8)", true, None);
|
||
|
||
assert_eq!(format_grid_sql_literal(&json!("0"), Some(DatabaseType::Mysql), Some(&bit)), "0");
|
||
assert_eq!(format_grid_sql_literal(&json!("1"), Some(DatabaseType::Mysql), Some(&bit)), "1");
|
||
assert_eq!(format_grid_sql_literal(&json!(true), Some(DatabaseType::Mysql), Some(&bit)), "1");
|
||
assert_eq!(
|
||
format_grid_sql_literal(&json!("10101010"), Some(DatabaseType::Mysql), Some(&bit_string)),
|
||
"b'10101010'"
|
||
);
|
||
assert_eq!(format_grid_sql_literal(&json!("0"), Some(DatabaseType::Postgres), Some(&bit)), "'0'");
|
||
}
|
||
|
||
#[test]
|
||
fn prepares_sqlserver_save_statements() {
|
||
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
|
||
database_type: Some(DatabaseType::SqlServer),
|
||
table_meta: DataGridTableMeta {
|
||
schema: Some("game".to_string()),
|
||
table_name: "player states".to_string(),
|
||
primary_keys: vec!["role id".to_string()],
|
||
columns: None,
|
||
},
|
||
columns: vec!["role id".to_string(), "state".to_string(), "updated at".to_string()],
|
||
source_columns: None,
|
||
rows: vec![vec![json!(42), json!("old"), json!("2026-05-03")]],
|
||
dirty_rows: vec![(0, vec![(1, json!("ready")), (2, json!("2026-05-04"))])],
|
||
deleted_rows: vec![0],
|
||
new_rows: vec![vec![json!(43), json!("new"), json!("2026-05-05")]],
|
||
});
|
||
|
||
assert_eq!(
|
||
result.statements,
|
||
vec![
|
||
"UPDATE [game].[player states] SET [state] = N'ready', [updated at] = N'2026-05-04' WHERE [role id] = 42;",
|
||
"DELETE FROM [game].[player states] WHERE [role id] = 42;",
|
||
"INSERT INTO [game].[player states] ([role id], [state], [updated at]) VALUES (43, N'new', N'2026-05-05');",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn skips_expression_only_source_columns() {
|
||
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
|
||
database_type: Some(DatabaseType::Postgres),
|
||
table_meta: DataGridTableMeta {
|
||
schema: Some("public".to_string()),
|
||
table_name: "ihli_data".to_string(),
|
||
primary_keys: vec!["iso3".to_string(), "year".to_string()],
|
||
columns: None,
|
||
},
|
||
columns: vec!["iso3".to_string(), "year".to_string(), "country_name".to_string(), "score".to_string()],
|
||
source_columns: Some(vec![
|
||
Some("iso3".to_string()),
|
||
Some("year".to_string()),
|
||
Some("country_name".to_string()),
|
||
None,
|
||
]),
|
||
rows: vec![vec![json!("LUX"), json!(2007), json!("Luxembourg"), json!(50242.1)]],
|
||
dirty_rows: vec![(0, vec![(2, json!("Luxembourg City")), (3, json!(999))])],
|
||
deleted_rows: vec![],
|
||
new_rows: vec![vec![json!("USA"), json!(2008), json!("United States"), json!(43000)]],
|
||
});
|
||
|
||
assert_eq!(
|
||
result.statements,
|
||
vec![
|
||
r#"UPDATE "public"."ihli_data" SET "country_name" = 'Luxembourg City' WHERE "iso3" = 'LUX' AND "year" = 2007;"#,
|
||
r#"INSERT INTO "public"."ihli_data" ("iso3", "year", "country_name") VALUES ('USA', 2008, 'United States');"#,
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn formats_mysql_temporal_columns_by_target_type() {
|
||
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
|
||
database_type: Some(DatabaseType::Mysql),
|
||
table_meta: DataGridTableMeta {
|
||
schema: None,
|
||
table_name: "policies".to_string(),
|
||
primary_keys: vec!["id".to_string()],
|
||
columns: Some(vec![
|
||
column("id", "int", false, None),
|
||
column("insurance_start_time", "datetime", true, None),
|
||
column("raw_text", "varchar(64)", true, None),
|
||
column("coverage_day", "date", true, None),
|
||
column("start_clock", "time", true, None),
|
||
]),
|
||
},
|
||
columns: vec![
|
||
"id".to_string(),
|
||
"insurance_start_time".to_string(),
|
||
"raw_text".to_string(),
|
||
"coverage_day".to_string(),
|
||
"start_clock".to_string(),
|
||
],
|
||
source_columns: None,
|
||
rows: vec![vec![
|
||
json!(1),
|
||
json!("2026-05-12T00:00:00+00:00"),
|
||
json!("old"),
|
||
json!("2026-05-12T00:00:00+00:00"),
|
||
json!("2026-05-12T09:30:45+00:00"),
|
||
]],
|
||
dirty_rows: vec![(
|
||
0,
|
||
vec![
|
||
(1, json!("2026-05-12T00:00:00+00:00")),
|
||
(2, json!("2026-05-12T00:00:00+00:00")),
|
||
(3, json!("2026-05-12T00:00:00+00:00")),
|
||
(4, json!("2026-05-12T09:30:45+00:00")),
|
||
],
|
||
)],
|
||
deleted_rows: vec![],
|
||
new_rows: vec![],
|
||
});
|
||
|
||
assert_eq!(
|
||
result.statements,
|
||
vec!["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;"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_text_predicates_use_binary_comparison_for_width_sensitive_edits() {
|
||
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
|
||
database_type: Some(DatabaseType::Mysql),
|
||
table_meta: DataGridTableMeta {
|
||
schema: None,
|
||
table_name: "parts".to_string(),
|
||
primary_keys: vec![],
|
||
columns: Some(vec![column("code", "varchar(32)", true, None)]),
|
||
},
|
||
columns: vec!["code".to_string()],
|
||
source_columns: None,
|
||
rows: vec![vec![json!("S471355(0)")]],
|
||
dirty_rows: vec![(0, vec![(0, json!("S471355(0)"))])],
|
||
deleted_rows: vec![],
|
||
new_rows: vec![],
|
||
});
|
||
|
||
assert_eq!(
|
||
result.statements,
|
||
vec!["UPDATE `parts` SET `code` = 'S471355(0)' WHERE BINARY `code` = 'S471355(0)';"]
|
||
);
|
||
assert_eq!(
|
||
result.rollback_statements,
|
||
vec![
|
||
"UPDATE `parts` SET `code` = 'S471355(0)' WHERE BINARY `code` = 'S471355(0)' AND BINARY `code` = 'S471355(0)';"
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn validates_duplicate_inserted_primary_keys() {
|
||
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
|
||
database_type: Some(DatabaseType::Postgres),
|
||
table_meta: DataGridTableMeta {
|
||
schema: None,
|
||
table_name: "education_data".to_string(),
|
||
primary_keys: vec!["country_code".to_string(), "year".to_string()],
|
||
columns: None,
|
||
},
|
||
columns: vec!["country_code".to_string(), "year".to_string(), "value".to_string()],
|
||
source_columns: None,
|
||
rows: vec![vec![json!("ALB"), json!(2021), json!(0.812)]],
|
||
dirty_rows: vec![],
|
||
deleted_rows: vec![],
|
||
new_rows: vec![vec![json!("ALB"), json!(2021), json!(0.913)]],
|
||
});
|
||
|
||
assert_eq!(
|
||
result.validation_error,
|
||
Some(r#"New row duplicates the existing primary key (country_code = "ALB", year = 2021). Change the key before saving."#.to_string())
|
||
);
|
||
assert!(result.statements.is_empty());
|
||
}
|
||
}
|