Files
dbx/crates/dbx-core/src/data_grid_sql.rs
T

4534 lines
175 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use chrono::{Local, NaiveDateTime};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashSet;
#[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_rollback_statements, build_tdengine_data_grid_save_statements,
validate_tdengine_existing_rows, validate_tdengine_inserted_rows,
};
use crate::models::connection::DatabaseType;
use crate::sql_dialect::{
firebird_rows_clause, quote_table_identifier, table_pagination_strategy, uses_single_row_insert_statements,
TablePaginationStrategy,
};
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";
const DATA_GRID_COLUMN_DISTINCT_VALUES_DEFAULT_LIMIT: usize = 1000;
const DATA_GRID_COLUMN_DISTINCT_VALUES_MAX_LIMIT: usize = 1000;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DataGridTableMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub catalog: Option<String>,
/// Doris / StarRocks multi-catalog: the database under the external
/// catalog, used as the middle segment of the 3-part qualified name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database: Option<String>,
#[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 column_types: Option<Vec<Option<String>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_columns: Option<Vec<Option<String>>>,
#[serde(default)]
pub rows: Vec<Vec<Value>>,
#[serde(default)]
pub exclude_primary_keys: bool,
#[serde(default)]
pub insert_mode: DataGridCopyInsertMode,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DataGridCopyInsertMode {
#[default]
Merged,
RowByRow,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DataGridContextFilterMode {
Equals,
NotEquals,
IsNull,
IsNotNull,
Like,
NotLike,
LessThan,
GreaterThan,
In,
NotIn,
Between,
NotBetween,
}
fn supports_data_grid_context_filter_mode(
database_type: Option<DatabaseType>,
mode: DataGridContextFilterMode,
) -> bool {
!matches!(
(database_type, mode),
(
Some(DatabaseType::InfluxDb | DatabaseType::Cassandra | DatabaseType::Jdbc),
DataGridContextFilterMode::In
| DataGridContextFilterMode::NotIn
| DataGridContextFilterMode::Between
| DataGridContextFilterMode::NotBetween
)
)
}
#[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 = "Vec::is_empty")]
pub values: Vec<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_value: Option<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 DataGridColumnValuesFilterConditionOptions {
#[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>,
#[serde(default)]
pub values: Vec<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DataGridColumnDistinctValuesSqlOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database_type: Option<DatabaseType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub catalog: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
pub table_name: String,
pub column_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column_info: Option<DataGridColumnInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub where_input: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[serde(default)]
pub include_counts: bool,
}
#[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 identifier_quote: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub catalog: Option<String>,
/// Doris / StarRocks multi-catalog: the database under the external
/// catalog, used as the middle segment of the 3-part qualified name when
/// `schema` is absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database: Option<String>,
#[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 = crate::sql_dialect::qualified_table_name_with_catalog(
options.database_type,
options.table_meta.catalog.as_deref(),
options.table_meta.schema.as_deref(),
options.table_meta.database.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),
false,
)
})
.collect::<Vec<_>>()
.join(" AND ");
statements.push(data_grid_statement(
options.database_type,
data_grid_update_sql(options.database_type, &table, &sets, &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_grid_insert_omitted_column(options.database_type, column_info_for(column_info, column), 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 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| {
crate::sql_dialect::qualified_table_name_with_catalog(
options.database_type,
meta.catalog.as_deref(),
meta.schema.as_deref(),
meta.database.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_copy_insert_sql_literal(
row.get(*index).unwrap_or(&Value::Null),
options.database_type,
copy_column_info(
column_info,
column,
options
.column_types
.as_deref()
.and_then(|types| types.get(*index))
.and_then(|value| value.as_deref()),
)
.as_ref(),
)
})
.collect::<Vec<_>>()
.join(", ")
)
})
.collect::<Vec<_>>();
if options.insert_mode == DataGridCopyInsertMode::RowByRow
|| options.database_type.is_some_and(uses_single_row_insert_statements)
{
return Some(
value_rows
.iter()
.map(|values| format!("INSERT INTO {table} ({columns}) VALUES {values};"))
.collect::<Vec<_>>()
.join("\n"),
);
}
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> {
if !supports_data_grid_context_filter_mode(options.database_type, options.mode) {
return None;
}
let column = column_filter_ref(options.database_type, &options.column_name);
let like_column = column_like_filter_ref(options.database_type, &options.column_name, options.column_info.as_ref());
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!(
"{like_column} LIKE {}",
format_grid_sql_literal(
&Value::String(format!("%{}%", value_to_filter_text(value))),
options.database_type,
None
)
)),
DataGridContextFilterMode::NotLike => Some(format!(
"{like_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::In => build_data_grid_context_membership_filter_condition(
&column,
&options.values,
options.database_type,
options.column_info.as_ref(),
false,
),
DataGridContextFilterMode::NotIn => build_data_grid_context_membership_filter_condition(
&column,
&options.values,
options.database_type,
options.column_info.as_ref(),
true,
),
DataGridContextFilterMode::Between => build_data_grid_context_range_filter_condition(
&column,
value,
options.end_value.as_ref(),
options.database_type,
options.column_info.as_ref(),
false,
),
DataGridContextFilterMode::NotBetween => build_data_grid_context_range_filter_condition(
&column,
value,
options.end_value.as_ref(),
options.database_type,
options.column_info.as_ref(),
true,
),
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())
)),
}
}
fn build_data_grid_context_membership_filter_condition(
column: &str,
values: &[Value],
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
negated: bool,
) -> Option<String> {
if values.is_empty() {
return None;
}
let mut has_null = false;
let mut literals = Vec::new();
let mut seen_literals = HashSet::new();
for value in values {
if value.is_null() {
has_null = true;
continue;
}
let literal = format_grid_sql_literal(value, database_type, column_info);
if seen_literals.insert(literal.clone()) {
literals.push(literal);
}
}
let membership = build_membership_predicate(column, &literals, database_type, negated);
if negated {
return match membership {
Some(membership) => Some(format!("({column} IS NOT NULL AND {membership})")),
None if has_null => Some(format!("{column} IS NOT NULL")),
None => None,
};
}
match membership {
Some(membership) if has_null => Some(format!("({column} IS NULL OR {membership})")),
Some(membership) => Some(membership),
None if has_null => Some(format!("{column} IS NULL")),
None => None,
}
}
fn build_membership_predicate(
column: &str,
literals: &[String],
database_type: Option<DatabaseType>,
negated: bool,
) -> Option<String> {
if literals.is_empty() {
return None;
}
if database_type == Some(DatabaseType::Neo4j) {
let predicate = format!("{column} IN [{}]", literals.join(", "));
return Some(if negated { format!("NOT ({predicate})") } else { predicate });
}
let operator = if negated { "NOT IN" } else { "IN" };
if database_type != Some(DatabaseType::Oracle) || literals.len() <= 1000 {
return Some(format!("{column} {operator} ({})", literals.join(", ")));
}
// Oracle limits each IN expression to 1000 values; preserve NOT IN semantics
// by joining its chunks with AND instead of OR.
let joiner = if negated { " AND " } else { " OR " };
let chunks =
literals.chunks(1000).map(|chunk| format!("{column} {operator} ({})", chunk.join(", "))).collect::<Vec<_>>();
Some(format!("({})", chunks.join(joiner)))
}
fn build_data_grid_context_range_filter_condition(
column: &str,
start_value: &Value,
end_value: Option<&Value>,
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
negated: bool,
) -> Option<String> {
let end_value = end_value?;
if start_value.is_null() || end_value.is_null() {
return None;
}
let start = format_grid_sql_literal(start_value, database_type, column_info);
let end = format_grid_sql_literal(end_value, database_type, column_info);
if database_type == Some(DatabaseType::Neo4j) {
return if negated {
Some(format!("({column} < {start} OR {column} > {end})"))
} else {
Some(format!("({column} >= {start} AND {column} <= {end})"))
};
}
let operator = if negated { "NOT BETWEEN" } else { "BETWEEN" };
Some(format!("{column} {operator} {start} AND {end}"))
}
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.database_type, 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_column_values_filter_condition(
options: DataGridColumnValuesFilterConditionOptions,
) -> Option<String> {
if options.values.is_empty() {
return None;
}
let column = column_filter_ref(options.database_type, &options.column_name);
let mut has_null = false;
let mut literals = Vec::new();
let mut seen_literals = HashSet::new();
for value in &options.values {
if value.is_null() {
has_null = true;
continue;
}
let literal = format_grid_sql_literal(value, options.database_type, options.column_info.as_ref());
if seen_literals.insert(literal.clone()) {
literals.push(literal);
}
}
let mut predicates = Vec::new();
if has_null {
predicates.push(format!("{column} IS NULL"));
}
if literals.len() == 1 {
predicates.push(format!("{column} = {}", literals[0]));
} else if let Some(membership) = build_membership_predicate(&column, &literals, options.database_type, false) {
predicates.push(membership);
}
match predicates.len() {
0 => None,
1 => predicates.into_iter().next(),
_ => Some(format!("({})", predicates.join(" OR "))),
}
}
pub fn build_data_grid_column_distinct_values_sql(options: DataGridColumnDistinctValuesSqlOptions) -> String {
if options.database_type == Some(DatabaseType::Neo4j) {
return build_neo4j_data_grid_column_distinct_values_sql(&options);
}
let limit = data_grid_column_distinct_values_limit(options.limit);
let table = crate::sql_dialect::qualified_table_name_with_catalog(
options.database_type,
options.catalog.as_deref(),
options.schema.as_deref(),
options.database.as_deref(),
&options.table_name,
);
let column = column_filter_ref(options.database_type, &options.column_name);
let mut predicates = Vec::new();
let predicate = crate::sql_dialect::normalize_where_input(options.where_input.as_deref());
if !predicate.is_empty() {
predicates.push(format!("({predicate})"));
}
if let Some(search_predicate) = data_grid_column_distinct_values_search_predicate(&options) {
predicates.push(search_predicate);
}
let where_clause =
if predicates.is_empty() { String::new() } else { format!(" WHERE {}", predicates.join(" AND ")) };
let select_list = if options.include_counts {
format!("{column} AS dbx_value, COUNT(*) AS dbx_count")
} else {
format!("{column} AS dbx_value")
};
let group_by = format!(" GROUP BY {column}");
let order_by = if options.include_counts { " ORDER BY dbx_count DESC, dbx_value" } else { " ORDER BY dbx_value" };
let from_clause = format!(" FROM {table}{where_clause}{group_by}{order_by}");
match table_pagination_strategy(options.database_type) {
TablePaginationStrategy::SqlServerTop => format!("SELECT TOP ({limit}) {select_list}{from_clause}"),
TablePaginationStrategy::IrisTop => format!("SELECT TOP {limit} {select_list}{from_clause}"),
TablePaginationStrategy::InformixFirst => format!("SELECT FIRST {limit} {select_list}{from_clause}"),
TablePaginationStrategy::FirebirdRows => {
let rows = firebird_rows_clause(limit, 0);
format!("SELECT {select_list}{from_clause} {rows}")
}
TablePaginationStrategy::Db2FetchFirst | TablePaginationStrategy::FetchFirst => {
format!("SELECT {select_list}{from_clause} FETCH FIRST {limit} ROWS ONLY")
}
TablePaginationStrategy::Rownum => {
let inner = format!("SELECT {select_list}{from_clause}");
format!("SELECT * FROM ({inner}) WHERE ROWNUM <= {limit}")
}
TablePaginationStrategy::AgentMaxRows | TablePaginationStrategy::Unbounded => {
format!("SELECT {select_list}{from_clause}")
}
TablePaginationStrategy::QuestDbLimit | TablePaginationStrategy::LimitOffset => {
format!("SELECT {select_list}{from_clause} LIMIT {limit}")
}
}
}
pub fn build_data_grid_count_sql(options: DataGridCountSqlOptions) -> String {
let table = if options.database_type == Some(DatabaseType::Kingbase) {
crate::sql_dialect::table_data_qualified_table_name(
options.database_type,
options.schema.as_deref(),
&options.table_name,
options.identifier_quote.as_deref(),
)
} else {
crate::sql_dialect::qualified_table_name_with_catalog(
options.database_type,
options.catalog.as_deref(),
options.schema.as_deref(),
options.database.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 data_grid_column_distinct_values_limit(limit: Option<usize>) -> usize {
limit.unwrap_or(DATA_GRID_COLUMN_DISTINCT_VALUES_DEFAULT_LIMIT).clamp(1, DATA_GRID_COLUMN_DISTINCT_VALUES_MAX_LIMIT)
}
fn data_grid_column_distinct_values_search_predicate(
options: &DataGridColumnDistinctValuesSqlOptions,
) -> Option<String> {
let search = options.search_value.as_deref()?.trim();
if search.is_empty() {
return None;
}
if !options.column_info.as_ref().map(|column| is_textual_column_type(&column.data_type)).unwrap_or(true)
&& !is_postgres_like_pattern_database(options.database_type)
{
let column = column_filter_ref(options.database_type, &options.column_name);
let value = parse_typed_filter_value(search, options.database_type, options.column_info.as_ref());
return Some(format!(
"{column} = {}",
format_grid_sql_literal(&value, options.database_type, options.column_info.as_ref())
));
}
let column = column_like_filter_ref(options.database_type, &options.column_name, options.column_info.as_ref());
let pattern = Value::String(format!("%{search}%"));
Some(format!("{column} LIKE {}", format_grid_sql_literal(&pattern, options.database_type, None)))
}
fn build_neo4j_data_grid_column_distinct_values_sql(options: &DataGridColumnDistinctValuesSqlOptions) -> String {
let limit = data_grid_column_distinct_values_limit(options.limit);
let label = quote_ident(Some(DatabaseType::Neo4j), &options.table_name);
let column = column_filter_ref(Some(DatabaseType::Neo4j), &options.column_name);
let mut predicates = Vec::new();
let predicate = crate::sql_dialect::normalize_where_input(options.where_input.as_deref());
if !predicate.is_empty() {
predicates.push(predicate);
}
if let Some(search) = options.search_value.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
predicates.push(format!(
"toString({column}) CONTAINS {}",
format_grid_sql_literal(&Value::String(search.to_string()), Some(DatabaseType::Neo4j), None)
));
}
let where_clause =
if predicates.is_empty() { String::new() } else { format!(" WHERE {}", predicates.join(" AND ")) };
if options.include_counts {
format!(
"MATCH (n:{label}){where_clause} RETURN {column} AS dbx_value, count(*) AS dbx_count ORDER BY dbx_count DESC, dbx_value LIMIT {limit}"
)
} else {
format!(
"MATCH (n:{label}){where_clause} RETURN DISTINCT {column} AS dbx_value ORDER BY dbx_value LIMIT {limit}"
)
}
}
fn validate_data_grid_save(options: &DataGridSaveStatementOptions) -> Option<String> {
if let Some(error) = validate_tdengine_inserted_rows(options) {
return Some(error);
}
if let Some(error) = validate_inserted_primary_keys(options) {
return Some(error);
}
if let Some(error) = validate_tdengine_existing_rows(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_non_identity_generated_column(Some(column))
&& !is_oracle_row_id(options.database_type, Some(&column.name))
})
.map(|column| normalize_column_name(&column.name))
.collect();
if let Some(error) = validate_clickhouse_mutable_updates(options) {
return Some(error);
}
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, &not_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,
&not_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_clickhouse_mutable_updates(options: &DataGridSaveStatementOptions) -> Option<String> {
if options.database_type != Some(DatabaseType::ClickHouse) || options.dirty_rows.is_empty() {
return None;
}
let save_columns = effective_columns(options);
let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]);
let primary_key_set: Vec<String> =
options.table_meta.primary_keys.iter().map(|primary_key| normalize_column_name(primary_key)).collect();
let has_clickhouse_key_metadata = !primary_key_set.is_empty()
|| column_info.iter().any(|column| is_clickhouse_partition_key_column(options.database_type, Some(column)));
if !has_clickhouse_key_metadata {
return None;
}
for (_, changes) in &options.dirty_rows {
if changes.is_empty() {
continue;
}
let has_mutable_column = changes.iter().any(|(column_index, _)| {
let Some(column) = save_columns.get(*column_index).and_then(|column| column.as_deref()) else {
return false;
};
!is_grid_update_omitted_column(
options.database_type,
column_info_for(column_info, column),
Some(column),
&primary_key_set,
)
});
if !has_mutable_column {
return Some(clickhouse_no_mutable_columns_error());
}
}
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 = crate::sql_dialect::qualified_table_name_with_catalog(
options.database_type,
options.table_meta.catalog.as_deref(),
options.table_meta.schema.as_deref(),
options.table_meta.database.as_deref(),
&options.table_meta.table_name,
);
let mut statements = Vec::new();
let primary_key_set: Vec<String> =
options.table_meta.primary_keys.iter().map(|primary_key| normalize_column_name(primary_key)).collect();
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_grid_update_omitted_column(
options.database_type,
column_info_for(column_info, column),
Some(column),
&primary_key_set,
) {
return None;
}
Some(format!(
"{} = {}",
quote_ident(options.database_type, column),
format_grid_save_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(data_grid_statement(
options.database_type,
data_grid_update_sql(options.database_type, &table, &sets, &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(data_grid_statement(
options.database_type,
data_grid_delete_sql(options.database_type, &table, &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, value)| {
let column_info = column_info_for(column_info, column);
// Empty generated values must be omitted so the database can apply AUTO_INCREMENT/IDENTITY semantics.
!column_info.is_some_and(is_auto_generated_column) || !grid_value_is_empty(value)
})
.filter(|(column, _)| {
!is_grid_insert_omitted_column(
options.database_type,
column_info_for(column_info, column),
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_save_sql_literal(value, options.database_type, column_info_for(column_info, column))
})
.collect::<Vec<_>>()
.join(", ");
statements.push(data_grid_statement(
options.database_type,
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);
}
if options.database_type == Some(DatabaseType::Tdengine) {
return build_tdengine_data_grid_rollback_statements(options);
}
if options.database_type == Some(DatabaseType::ClickHouse) {
return Vec::new();
}
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_save_row_where(options.database_type, &save_columns, row, column_info);
if !where_clause.is_empty() {
statements
.push(data_grid_statement(options.database_type, 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_grid_insert_omitted_column(
options.database_type,
column_info_for(column_info, column),
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(data_grid_statement(
options.database_type,
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_grid_update_omitted_column(
options.database_type,
column_info_for(column_info, column),
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_save_column_predicate(
options.database_type,
column,
value,
column_info_for(column_info, column),
true,
)
}));
statements.push(data_grid_statement(
options.database_type,
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 copy_column_info(
column_info: &[DataGridColumnInfo],
column: &str,
fallback_type: Option<&str>,
) -> Option<DataGridColumnInfo> {
if let Some(info) = column_info_for(column_info, column) {
return Some(info.clone());
}
fallback_type.map(|data_type| DataGridColumnInfo {
name: column.to_string(),
data_type: data_type.to_string(),
is_nullable: true,
is_primary_key: false,
column_default: None,
extra: None,
})
}
fn effective_column(options: &DataGridSaveStatementOptions, index: usize) -> Option<&str> {
match &options.source_columns {
Some(source_columns) if source_columns.len() == options.columns.len() => source_columns.get(index)?.as_deref(),
_ => 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()
}
fn format_grid_copy_insert_sql_literal(
value: &Value,
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
) -> String {
if is_oracle_temporal_literal_database(database_type) {
if let Some(text) = value.as_str() {
if let Some(literal) =
format_oracle_temporal_literal(text, column_info.map(|column| column.data_type.as_str()))
{
return literal;
}
}
}
format_grid_sql_literal(value, database_type, column_info)
}
pub fn format_grid_sql_literal(
value: &Value,
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
) -> String {
if value.is_null() {
return "NULL".to_string();
}
// Boolean values on BIT columns always use numeric 0/1.
// This covers MySQL, SQL Server, and any other database where BIT
// is a numeric/boolean type rather than a bit-string type like
// PostgreSQL's bit(n).
if let Some(value) = value.as_bool() {
if is_bit_literal_column(database_type, column_info) {
return if value { "1" } else { "0" }.to_string();
}
return if value { "TRUE" } else { "FALSE" }.to_string();
}
if is_mysql_bit_literal_column(database_type, column_info) {
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(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 is_mysql_binary_literal_column(database_type, column_info) {
if let Some(literal) = format_mysql_binary_literal_text(&text) {
// DBX result values expose binary columns as prefixed hex; keep them
// as MySQL hex literals so copied INSERT/UPDATE SQL round-trips bytes.
return literal;
}
}
if column_info.map(|column| is_numeric_type(&column.data_type)).unwrap_or(false) && is_numeric_literal(&text) {
// BigDecimal/BigInteger cells cross JSON-RPC as strings so browsers cannot round them.
return text;
}
if database_type == Some(DatabaseType::ManticoreSearch) {
if let Some(typed_value) = manticore_typed_attribute_value(&text, column_info) {
return format_grid_sql_literal(&typed_value, database_type, column_info);
}
}
if text.is_empty() {
return if database_type == Some(DatabaseType::SqlServer) { "N''" } else { "''" }.to_string();
}
// MySQL geometry columns: wrap WKT text with ST_GeomFromText()
if is_mysql_geometry_literal_database(database_type)
&& column_info.map(|column| is_geometry_column_type(&column.data_type)).unwrap_or(false)
{
let escaped = text.replace('\\', "\\\\").replace('\'', "''");
return format!("ST_GeomFromText('{}')", escaped);
}
if is_oracle_temporal_literal_database(database_type) {
if let Some(literal) =
format_oracle_temporal_literal(&text, column_info.map(|column| column.data_type.as_str()))
{
return literal;
}
}
let literal_text = if database_type == Some(DatabaseType::Tdengine) {
format_tdengine_timestamp_literal_text(&text)
} else if database_type == Some(DatabaseType::SqlServer) {
crate::sqlserver_temporal::normalize_sqlserver_temporal_literal(
&text,
column_info.map(|column| column.data_type.as_str()),
)
.unwrap_or(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_text = if database_type == Some(DatabaseType::Neo4j) {
literal_text.replace('\\', "\\\\").replace('\'', "\\'")
} else {
literal_text.replace('\\', "\\\\").replace('\'', "''")
};
let escaped = format!("'{escaped_text}'");
if database_type == Some(DatabaseType::SqlServer) {
format!("N{escaped}")
} else {
escaped
}
}
fn format_grid_save_sql_literal(
value: &Value,
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
) -> String {
if empty_string_saves_as_null(value, column_info) {
"NULL".to_string()
} else {
format_grid_sql_literal(value, database_type, column_info)
}
}
fn empty_string_saves_as_null(value: &Value, column_info: Option<&DataGridColumnInfo>) -> bool {
value.as_str() == Some("")
&& column_info.is_some_and(|column| column.is_nullable && !is_textual_column_type(&column.data_type))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OracleTemporalKind {
Date,
Timestamp,
TimestampWithTimeZone,
}
fn is_oracle_temporal_literal_database(database_type: Option<DatabaseType>) -> bool {
matches!(database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle))
}
fn format_oracle_temporal_literal(text: &str, data_type: Option<&str>) -> Option<String> {
let kind = oracle_temporal_column_kind(data_type?)?;
let parts = regex_like_oracle_temporal(text)?;
let fraction = parts.fraction.as_deref().unwrap_or_default();
let datetime = format!("{} {}{}", parts.date, parts.time, fraction);
match kind {
OracleTemporalKind::Date if oracle_temporal_parts_are_midnight(&parts) => {
Some(format!("DATE '{}'", parts.date))
}
OracleTemporalKind::Date => Some(format!("TO_DATE('{} {}', 'YYYY-MM-DD HH24:MI:SS')", parts.date, parts.time)),
OracleTemporalKind::Timestamp => {
let mask = oracle_timestamp_format_mask(datetime.contains('.'));
Some(format!("TO_TIMESTAMP('{datetime}', '{mask}')"))
}
OracleTemporalKind::TimestampWithTimeZone => {
if parts.zone.is_empty() {
let mask = oracle_timestamp_format_mask(datetime.contains('.'));
return Some(format!("TO_TIMESTAMP('{datetime}', '{mask}')"));
}
let zone = oracle_timezone_suffix(&parts.zone);
let mask = oracle_timestamp_format_mask(datetime.contains('.'));
Some(format!("TO_TIMESTAMP_TZ('{datetime} {zone}', '{mask} TZH:TZM')"))
}
}
}
fn oracle_temporal_parts_are_midnight(parts: &Rfc3339Parts) -> bool {
parts.time == "00:00:00"
&& parts
.fraction
.as_deref()
.map(|fraction| fraction.trim_start_matches('.').chars().all(|ch| ch == '0'))
.unwrap_or(true)
}
fn oracle_temporal_column_kind(data_type: &str) -> Option<OracleTemporalKind> {
let lower = data_type.trim().to_ascii_lowercase();
let base = lower.split(['(', ' ']).next().unwrap_or("");
match base {
"date" => Some(OracleTemporalKind::Date),
"timestamp" if lower.contains("with time zone") || lower.contains("with local time zone") => {
Some(OracleTemporalKind::TimestampWithTimeZone)
}
"timestamp" => Some(OracleTemporalKind::Timestamp),
_ => None,
}
}
fn oracle_timestamp_format_mask(has_fraction: bool) -> &'static str {
if has_fraction {
"YYYY-MM-DD HH24:MI:SS.FF"
} else {
"YYYY-MM-DD HH24:MI:SS"
}
}
fn oracle_timezone_suffix(zone: &str) -> String {
if zone.eq_ignore_ascii_case("z") {
"+00:00".to_string()
} else {
zone.to_string()
}
}
fn regex_like_oracle_temporal(text: &str) -> Option<Rfc3339Parts> {
if let Some(parts) = regex_like_rfc3339(text) {
return Some(parts);
}
regex_like_local_datetime(text)
}
fn regex_like_local_datetime(text: &str) -> Option<Rfc3339Parts> {
let bytes = text.as_bytes();
if bytes.len() < 10 || bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') {
return None;
}
let date = &text[0..10];
if bytes.len() == 10 {
return Some(Rfc3339Parts {
date: date.to_string(),
time: "00:00:00".to_string(),
fraction: None,
zone: String::new(),
});
}
let separator = *bytes.get(10)?;
if separator != b'T' && separator != b' ' {
return None;
}
if bytes.len() < 19 || bytes.get(13) != Some(&b':') || bytes.get(16) != Some(&b':') {
return None;
}
let time = &text[11..19];
let rest = &text[19..];
let fraction = if let Some(rest) = rest.strip_prefix('.') {
let digit_count = rest.chars().take_while(|ch| ch.is_ascii_digit()).count();
if digit_count == 0 || digit_count > 9 || digit_count != rest.len() {
return None;
}
Some(format!(".{}", &rest[..digit_count]))
} else if rest.is_empty() {
None
} else {
return None;
};
Some(Rfc3339Parts { date: date.to_string(), time: time.to_string(), fraction, zone: String::new() })
}
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_literal_column(database_type: Option<DatabaseType>, column_info: Option<&DataGridColumnInfo>) -> bool {
database_type != Some(DatabaseType::Postgres)
&& 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| {
// SQL Server/tiberius reports nullable BIT result columns as `bitn`.
// They still need numeric 0/1 literals in generated UPDATE SQL.
matches!(token, "bit" | "bitn")
})
}
fn is_mysql_geometry_literal_database(database_type: Option<DatabaseType>) -> bool {
matches!(
database_type,
Some(
DatabaseType::Mysql
| DatabaseType::Doris
| DatabaseType::StarRocks
| DatabaseType::Goldendb
| DatabaseType::Sundb
)
)
}
fn is_mysql_binary_literal_column(
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
) -> bool {
database_type == Some(DatabaseType::Mysql)
&& column_info.map(|column| is_mysql_binary_column_type(&column.data_type)).unwrap_or(false)
}
fn is_mysql_binary_column_type(data_type: &str) -> bool {
let lower = data_type.trim().to_ascii_lowercase();
let base = lower.split(['(', ':', ' ']).next().unwrap_or("").trim();
matches!(base, "binary" | "varbinary" | "blob" | "tinyblob" | "mediumblob" | "longblob")
}
fn format_mysql_binary_literal_text(text: &str) -> Option<String> {
let trimmed = text.trim();
let hex = trimmed.strip_prefix("0x")?;
if hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
Some(if hex.is_empty() { "X''".to_string() } else { trimmed.to_string() })
} else {
None
}
}
fn is_geometry_column_type(data_type: &str) -> bool {
let lower = data_type.to_ascii_lowercase();
let base = lower.split('(').next().unwrap_or(&lower).trim();
matches!(
base,
"geometry"
| "point"
| "linestring"
| "polygon"
| "multipoint"
| "multilinestring"
| "multipolygon"
| "geometrycollection"
)
}
fn manticore_typed_attribute_value(text: &str, column_info: Option<&DataGridColumnInfo>) -> Option<Value> {
let data_type = column_info?.data_type.to_ascii_lowercase();
if is_boolean_type(&data_type, None) && text.eq_ignore_ascii_case("true") {
return Some(Value::Bool(true));
}
if is_boolean_type(&data_type, None) && text.eq_ignore_ascii_case("false") {
return Some(Value::Bool(false));
}
if is_numeric_type(&data_type) && is_numeric_literal(text) {
return text.parse::<serde_json::Number>().ok().map(Value::Number);
}
None
}
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>,
zone: 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, zone: zone.to_string() })
} 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), false)
})
.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),
true,
))
})
.collect::<Vec<_>>()
.join(" AND ")
}
fn build_save_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_save_column_predicate(
database_type,
column,
row.get(index).unwrap_or(&Value::Null),
column_info_for(column_info, column),
true,
))
})
.collect::<Vec<_>>()
.join(" AND ")
}
fn build_column_predicate(
database_type: Option<DatabaseType>,
column: &str,
value: &Value,
column_info: Option<&DataGridColumnInfo>,
use_binary_text_comparison: bool,
) -> String {
let ident = predicate_ident(database_type, column);
if value.is_null() {
format!("{ident} IS NULL")
} else if use_binary_text_comparison && 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 build_save_column_predicate(
database_type: Option<DatabaseType>,
column: &str,
value: &Value,
column_info: Option<&DataGridColumnInfo>,
use_binary_text_comparison: bool,
) -> String {
let ident = predicate_ident(database_type, column);
if value.is_null() || empty_string_saves_as_null(value, column_info) {
format!("{ident} IS NULL")
} else if use_binary_text_comparison && uses_mysql_binary_text_predicate(database_type, value, column_info) {
format!("BINARY {ident} = {}", format_grid_save_sql_literal(value, database_type, column_info))
} else {
format!("{ident} = {}", format_grid_save_sql_literal(value, database_type, column_info))
}
}
fn data_grid_statement(database_type: Option<DatabaseType>, sql: String) -> String {
if database_type == Some(DatabaseType::ManticoreSearch) {
sql
} else {
format!("{sql};")
}
}
fn data_grid_update_sql(database_type: Option<DatabaseType>, table: &str, sets: &str, where_clause: &str) -> String {
if database_type == Some(DatabaseType::ClickHouse) {
format!("ALTER TABLE {table} UPDATE {sets} WHERE {where_clause}")
} else {
format!("UPDATE {table} SET {sets} WHERE {where_clause}")
}
}
fn data_grid_delete_sql(database_type: Option<DatabaseType>, table: &str, where_clause: &str) -> String {
if database_type == Some(DatabaseType::ClickHouse) {
format!("ALTER TABLE {table} DELETE WHERE {where_clause}")
} else {
format!("DELETE FROM {table} WHERE {where_clause}")
}
}
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.trim().to_ascii_lowercase();
let base = lower.split(['(', ':', ' ']).next().unwrap_or("").trim();
matches!(
base,
"char"
| "character"
| "varchar"
| "varchar2"
| "nvarchar"
| "nvarchar2"
| "nchar"
| "string"
| "text"
| "tinytext"
| "mediumtext"
| "longtext"
| "ntext"
| "clob"
| "nclob"
| "enum"
| "set"
) || lower.starts_with("character varying")
|| lower.starts_with("national character varying")
}
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 grid_value_is_empty(value: &Value) -> bool {
value.is_null() || value.as_str().is_some_and(str::is_empty)
}
fn is_grid_insert_omitted_column(
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
name: Option<&str>,
) -> bool {
is_oracle_row_id(database_type, name)
|| is_postgres_tsvector_column(database_type, column_info)
|| is_non_identity_generated_column(column_info)
}
fn is_grid_update_omitted_column(
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
name: Option<&str>,
primary_key_set: &[String],
) -> bool {
is_oracle_row_id(database_type, name)
|| is_clickhouse_key_column(database_type, column_info, name, primary_key_set)
|| is_non_identity_generated_column(column_info)
}
fn is_clickhouse_key_column(
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
name: Option<&str>,
primary_key_set: &[String],
) -> bool {
if database_type != Some(DatabaseType::ClickHouse) {
return false;
}
column_info.is_some_and(|column| column.is_primary_key)
|| is_clickhouse_partition_key_column(database_type, column_info)
|| name.is_some_and(|name| primary_key_set.contains(&normalize_column_name(name)))
}
fn is_clickhouse_partition_key_column(
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
) -> bool {
database_type == Some(DatabaseType::ClickHouse)
&& column_info.and_then(|column| column.extra.as_deref()).is_some_and(|extra| {
extra.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_').any(|part| part == "partition_key")
})
}
fn is_postgres_tsvector_column(database_type: Option<DatabaseType>, column_info: Option<&DataGridColumnInfo>) -> bool {
database_type == Some(DatabaseType::Postgres)
&& column_info.map(|column| is_postgres_tsvector_type(&column.data_type)).unwrap_or(false)
}
fn is_postgres_tsvector_type(data_type: &str) -> bool {
let normalized = data_type.trim().trim_matches('"').to_ascii_lowercase();
normalized == "tsvector" || normalized.ends_with(".tsvector")
}
fn is_non_identity_generated_column(column_info: Option<&DataGridColumnInfo>) -> bool {
let extra = column_info.and_then(|column| column.extra.as_deref()).unwrap_or("").to_ascii_lowercase();
extra.contains("generated always as") && !extra.contains("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 clickhouse_no_mutable_columns_error() -> String {
"ClickHouse primary or partition key columns cannot be updated. Change a non-key column before saving.".to_string()
}
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 column_like_filter_ref(
database_type: Option<DatabaseType>,
column_name: &str,
column_info: Option<&DataGridColumnInfo>,
) -> String {
let column = column_filter_ref(database_type, column_name);
if is_postgres_like_pattern_database(database_type)
&& column_info.map(|column_info| !is_textual_column_type(&column_info.data_type)).unwrap_or(true)
{
format!("{column}::text")
} else {
column
}
}
fn is_postgres_like_pattern_database(database_type: Option<DatabaseType>) -> bool {
matches!(
database_type,
Some(
DatabaseType::Postgres
| DatabaseType::Redshift
| DatabaseType::Gaussdb
| DatabaseType::Kwdb
| DatabaseType::Kingbase
| DatabaseType::Highgo
| DatabaseType::Vastbase
| DatabaseType::OpenGauss
)
)
}
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,
database_type: Option<DatabaseType>,
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, database_type) && unquoted.eq_ignore_ascii_case("true") {
return Value::Bool(true);
}
if is_boolean_type(&data_type, database_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, database_type: Option<DatabaseType>) -> bool {
let lower = data_type.to_ascii_lowercase();
lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| {
matches!(token, "bool" | "boolean")
|| (matches!(token, "bit" | "bitn") && database_type != Some(DatabaseType::Postgres))
})
}
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::ManticoreSearch
| DatabaseType::Postgres
| DatabaseType::Sqlite
| DatabaseType::Rqlite
| DatabaseType::Turso
| DatabaseType::CloudflareD1
| 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::Oscar
| DatabaseType::Databricks
| DatabaseType::SapHana
| DatabaseType::Teradata
| DatabaseType::Vertica
| DatabaseType::Firebird
| DatabaseType::Exasol
| DatabaseType::OpenGauss
| DatabaseType::Questdb
| DatabaseType::OceanbaseOracle
| DatabaseType::Gbase
| DatabaseType::Access
| DatabaseType::H2
| DatabaseType::Snowflake
| DatabaseType::Db2
| DatabaseType::Informix
| DatabaseType::Bigquery
| DatabaseType::Sundb
| DatabaseType::Databend
| 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 {
catalog: None,
database: None,
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 {
catalog: None,
database: None,
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()],
column_types: None,
source_columns: None,
rows: vec![vec![json!(1), json!("ada"), json!("Ada")], vec![json!(2), json!("linus"), json!("Linus")]],
exclude_primary_keys: true,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(
statement.as_deref(),
Some("INSERT INTO `users` (`login_name`, `display_name`) VALUES\n('ada', 'Ada'),\n('linus', 'Linus');")
);
}
#[test]
fn builds_copy_insert_without_primary_keys_when_primary_keys_are_hidden() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Mysql),
table_meta: Some(DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "users".to_string(),
primary_keys: vec!["id".to_string()],
columns: None,
}),
columns: vec!["login_name".to_string(), "display_name".to_string()],
column_types: None,
source_columns: None,
rows: vec![vec![json!("ada"), json!("Ada")]],
exclude_primary_keys: true,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(
statement.as_deref(),
Some("INSERT INTO `users` (`login_name`, `display_name`) VALUES ('ada', 'Ada');")
);
}
#[test]
fn builds_copy_insert_statement_row_by_row() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Mysql),
table_meta: Some(DataGridTableMeta {
catalog: None,
database: None,
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()],
column_types: None,
source_columns: None,
rows: vec![vec![json!(1), json!("ada"), json!("Ada")], vec![json!(2), json!("linus"), json!("Linus")]],
exclude_primary_keys: false,
insert_mode: DataGridCopyInsertMode::RowByRow,
});
assert_eq!(
statement.as_deref(),
Some(
"INSERT INTO `users` (`id`, `login_name`, `display_name`) VALUES (1, 'ada', 'Ada');\nINSERT INTO `users` (`id`, `login_name`, `display_name`) VALUES (2, 'linus', 'Linus');"
)
);
}
#[test]
fn oracle_copy_insert_statement_uses_one_statement_per_row() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Oracle),
table_meta: Some(DataGridTableMeta {
catalog: None,
database: None,
schema: Some("APP".to_string()),
table_name: "USERS".to_string(),
primary_keys: vec!["ID".to_string()],
columns: None,
}),
columns: vec!["ID".to_string(), "NAME".to_string()],
column_types: None,
source_columns: None,
rows: vec![vec![json!(1), json!("Ada")], vec![json!(2), json!("Linus")]],
exclude_primary_keys: false,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(
statement.as_deref(),
Some("INSERT INTO \"APP\".\"USERS\" (\"ID\", \"NAME\") VALUES (1, 'Ada');\nINSERT INTO \"APP\".\"USERS\" (\"ID\", \"NAME\") VALUES (2, 'Linus');")
);
}
#[test]
fn mysql_copy_statements_preserve_blob_hex_literals() {
let table_meta = DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "reports".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "int", false, None), column("payload", "MEDIUMBLOB", true, None)]),
};
let columns = vec!["id".to_string(), "payload".to_string()];
let rows = vec![vec![json!(1), json!("0x0001abff")], vec![json!(2), json!("0x")]];
let insert = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Mysql),
table_meta: Some(table_meta.clone()),
columns: columns.clone(),
column_types: None,
source_columns: None,
rows: rows.clone(),
exclude_primary_keys: false,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(
insert.as_deref(),
Some("INSERT INTO `reports` (`id`, `payload`) VALUES\n(1, 0x0001abff),\n(2, X'');")
);
let updates = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions {
database_type: Some(DatabaseType::Mysql),
table_meta,
columns,
source_columns: None,
rows,
});
assert_eq!(
updates,
vec![
"UPDATE `reports` SET `payload` = 0x0001abff WHERE `id` = 1;",
"UPDATE `reports` SET `payload` = X'' WHERE `id` = 2;"
]
);
}
#[test]
fn mysql_text_columns_keep_prefixed_hex_strings_quoted() {
assert_eq!(
format_grid_sql_literal(
&json!("0x0001abff"),
Some(DatabaseType::Mysql),
Some(&column("note", "varchar(64)", true, None)),
),
"'0x0001abff'"
);
assert_eq!(
format_grid_sql_literal(
&json!("0xnothex"),
Some(DatabaseType::Mysql),
Some(&column("payload", "blob", true, None)),
),
"'0xnothex'"
);
}
#[test]
fn builds_copy_insert_statement_omits_postgres_tsvector_columns() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Postgres),
table_meta: Some(DataGridTableMeta {
catalog: None,
database: None,
schema: Some("public".to_string()),
table_name: "articles".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![
column("id", "integer", false, None),
column("title", "text", false, None),
column("search_vector", "tsvector", true, None),
]),
}),
columns: vec!["id".to_string(), "title".to_string(), "search_vector".to_string()],
column_types: None,
source_columns: None,
rows: vec![vec![json!(1), json!("Hello"), json!("'hello':1A")]],
exclude_primary_keys: false,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(
statement.as_deref(),
Some("INSERT INTO \"public\".\"articles\" (\"id\", \"title\") VALUES (1, 'Hello');")
);
}
#[test]
fn oracle_copy_insert_uses_result_column_types_for_date_literals() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Oracle),
table_meta: None,
columns: vec!["ID".to_string(), "CREATED_ON".to_string(), "RAW_TEXT".to_string()],
column_types: Some(vec![
Some("NUMBER".to_string()),
Some("DATE".to_string()),
Some("VARCHAR2".to_string()),
]),
source_columns: None,
rows: vec![vec![json!(1), json!("2022-08-25T09:58:43Z"), json!("2022-08-25T09:58:43Z")]],
exclude_primary_keys: false,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(
statement.as_deref(),
Some("INSERT INTO table_name (\"ID\", \"CREATED_ON\", \"RAW_TEXT\") VALUES (1, TO_DATE('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS'), '2022-08-25T09:58:43Z');")
);
}
#[test]
fn builds_filter_conditions() {
assert_eq!(
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"),
values: Vec::new(),
end_value: None,
column_info: Some(column("status", "varchar", true, None)),
})
.as_deref(),
Some("\"status\" LIKE '%active%'")
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Postgres),
column_name: "update_date".to_string(),
mode: DataGridContextFilterMode::Like,
value: json!("128"),
values: Vec::new(),
end_value: None,
column_info: Some(column("update_date", "bigint", false, None)),
})
.as_deref(),
Some("\"update_date\"::text LIKE '%128%'")
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Postgres),
column_name: "created_at".to_string(),
mode: DataGridContextFilterMode::NotLike,
value: json!("2026"),
values: Vec::new(),
end_value: None,
column_info: Some(column("created_at", "timestamp without time zone", false, None)),
})
.as_deref(),
Some("\"created_at\"::text NOT LIKE '%2026%'")
);
assert_eq!(
build_data_grid_column_value_filter_condition(DataGridColumnValueFilterConditionOptions {
database_type: Some(DatabaseType::SqlServer),
column_name: "active".to_string(),
column_info: Some(column("active", "bitn", false, None)),
raw_value: "false".to_string(),
})
.as_deref(),
Some("[active] = 0")
);
}
#[test]
fn builds_membership_and_range_context_filter_conditions() {
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Mysql),
column_name: "id".to_string(),
mode: DataGridContextFilterMode::In,
value: Value::Null,
values: vec![json!(42), json!(99)],
end_value: None,
column_info: Some(column("id", "int", false, None)),
})
.as_deref(),
Some("`id` IN (42, 99)")
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Postgres),
column_name: "status".to_string(),
mode: DataGridContextFilterMode::In,
value: Value::Null,
values: vec![Value::Null, json!("active"), json!("pending"), json!("active")],
end_value: None,
column_info: Some(column("status", "varchar", true, None)),
})
.as_deref(),
Some("(\"status\" IS NULL OR \"status\" IN ('active', 'pending'))")
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Postgres),
column_name: "status".to_string(),
mode: DataGridContextFilterMode::NotIn,
value: Value::Null,
values: vec![Value::Null, json!("active"), json!("pending")],
end_value: None,
column_info: Some(column("status", "varchar", true, None)),
})
.as_deref(),
Some("(\"status\" IS NOT NULL AND \"status\" NOT IN ('active', 'pending'))")
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Neo4j),
column_name: "name".to_string(),
mode: DataGridContextFilterMode::In,
value: Value::Null,
values: vec![json!("O'Reilly"), json!(r"C:\temp")],
end_value: None,
column_info: Some(column("name", "string", false, None)),
})
.as_deref(),
Some(r#"n.`name` IN ['O\'Reilly', 'C:\\temp']"#)
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::SqlServer),
column_name: "score".to_string(),
mode: DataGridContextFilterMode::Between,
value: json!(10),
values: Vec::new(),
end_value: Some(json!(20)),
column_info: Some(column("score", "int", false, None)),
})
.as_deref(),
Some("[score] BETWEEN 10 AND 20")
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::SqlServer),
column_name: "score".to_string(),
mode: DataGridContextFilterMode::NotBetween,
value: json!(10),
values: Vec::new(),
end_value: Some(json!(20)),
column_info: Some(column("score", "int", false, None)),
})
.as_deref(),
Some("[score] NOT BETWEEN 10 AND 20")
);
}
#[test]
fn builds_neo4j_membership_and_range_context_filter_conditions() {
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Neo4j),
column_name: "score".to_string(),
mode: DataGridContextFilterMode::In,
value: Value::Null,
values: vec![json!(10), json!(20)],
end_value: None,
column_info: Some(column("score", "integer", false, None)),
})
.as_deref(),
Some("n.`score` IN [10, 20]")
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Neo4j),
column_name: "score".to_string(),
mode: DataGridContextFilterMode::NotIn,
value: Value::Null,
values: vec![json!(10), json!(20)],
end_value: None,
column_info: Some(column("score", "integer", false, None)),
})
.as_deref(),
Some("(n.`score` IS NOT NULL AND NOT (n.`score` IN [10, 20]))")
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Neo4j),
column_name: "score".to_string(),
mode: DataGridContextFilterMode::Between,
value: json!(10),
values: Vec::new(),
end_value: Some(json!(20)),
column_info: Some(column("score", "integer", false, None)),
})
.as_deref(),
Some("(n.`score` >= 10 AND n.`score` <= 20)")
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Neo4j),
column_name: "score".to_string(),
mode: DataGridContextFilterMode::NotBetween,
value: json!(10),
values: Vec::new(),
end_value: Some(json!(20)),
column_info: Some(column("score", "integer", false, None)),
})
.as_deref(),
Some("(n.`score` < 10 OR n.`score` > 20)")
);
}
#[test]
fn does_not_emit_membership_or_range_filters_for_unsupported_dialects() {
for database_type in [DatabaseType::InfluxDb, DatabaseType::Cassandra, DatabaseType::Jdbc] {
for mode in [
DataGridContextFilterMode::In,
DataGridContextFilterMode::NotIn,
DataGridContextFilterMode::Between,
DataGridContextFilterMode::NotBetween,
] {
let (value, values, end_value) = match mode {
DataGridContextFilterMode::In | DataGridContextFilterMode::NotIn => {
(Value::Null, vec![json!(10), json!(20)], None)
}
DataGridContextFilterMode::Between | DataGridContextFilterMode::NotBetween => {
(json!(10), Vec::new(), Some(json!(20)))
}
_ => unreachable!("only membership and range modes are tested"),
};
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(database_type),
column_name: "score".to_string(),
mode,
value,
values,
end_value,
column_info: Some(column("score", "int", false, None)),
}),
None,
"{database_type:?} must not emit {mode:?}"
);
}
}
}
#[test]
fn context_membership_and_range_filters_require_complete_values() {
let column_info = Some(column("score", "int", false, None));
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Mysql),
column_name: "score".to_string(),
mode: DataGridContextFilterMode::In,
value: Value::Null,
values: Vec::new(),
end_value: None,
column_info: column_info.clone(),
}),
None
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Mysql),
column_name: "score".to_string(),
mode: DataGridContextFilterMode::Between,
value: json!(10),
values: Vec::new(),
end_value: None,
column_info: column_info.clone(),
}),
None
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Mysql),
column_name: "score".to_string(),
mode: DataGridContextFilterMode::Between,
value: Value::Null,
values: Vec::new(),
end_value: Some(json!(20)),
column_info: column_info.clone(),
}),
None
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Mysql),
column_name: "score".to_string(),
mode: DataGridContextFilterMode::NotBetween,
value: json!(10),
values: Vec::new(),
end_value: Some(Value::Null),
column_info,
}),
None
);
}
#[test]
fn context_filter_options_default_new_fields_when_deserializing_old_requests() {
let options: DataGridContextFilterConditionOptions = serde_json::from_value(json!({
"databaseType": "mysql",
"columnName": "id",
"mode": "equals",
"value": 42,
}))
.unwrap();
assert!(options.values.is_empty());
assert_eq!(options.end_value, None);
}
#[test]
fn builds_multi_value_filter_conditions() {
assert_eq!(
build_data_grid_column_values_filter_condition(DataGridColumnValuesFilterConditionOptions {
database_type: Some(DatabaseType::Postgres),
column_name: "status".to_string(),
column_info: Some(column("status", "varchar", true, None)),
values: vec![json!("active"), json!("pending"), Value::Null, json!("active")],
})
.as_deref(),
Some("(\"status\" IS NULL OR \"status\" IN ('active', 'pending'))")
);
assert_eq!(
build_data_grid_column_values_filter_condition(DataGridColumnValuesFilterConditionOptions {
database_type: Some(DatabaseType::Mysql),
column_name: "id".to_string(),
column_info: Some(column("id", "int", false, None)),
values: vec![json!(42)],
})
.as_deref(),
Some("`id` = 42")
);
}
#[test]
fn chunks_large_oracle_membership_filters_with_correct_boolean_operator() {
let values = (0..=1000).map(|value| json!(value)).collect::<Vec<_>>();
let in_condition = build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Oracle),
column_name: "id".to_string(),
mode: DataGridContextFilterMode::In,
value: Value::Null,
values: values.clone(),
end_value: None,
column_info: Some(column("id", "number", false, None)),
})
.unwrap();
assert_eq!(in_condition.matches("\"id\" IN (").count(), 2);
assert!(in_condition.contains(") OR \"id\" IN ("));
let not_in_condition = build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Oracle),
column_name: "id".to_string(),
mode: DataGridContextFilterMode::NotIn,
value: Value::Null,
values,
end_value: None,
column_info: Some(column("id", "number", false, None)),
})
.unwrap();
assert_eq!(not_in_condition.matches("\"id\" NOT IN (").count(), 2);
assert!(not_in_condition.contains(") AND \"id\" NOT IN ("));
}
#[test]
fn keeps_postgres_bit_strings_out_of_boolean_literal_handling() {
let bit = column("flags", "bit(3)", false, None);
let varying = column("flags", "bit varying", false, None);
assert_eq!(parse_typed_filter_value("true", Some(DatabaseType::Postgres), Some(&bit)), json!("true"));
assert_eq!(parse_typed_filter_value("false", Some(DatabaseType::Postgres), Some(&varying)), json!("false"));
assert_eq!(
build_data_grid_column_value_filter_condition(DataGridColumnValueFilterConditionOptions {
database_type: Some(DatabaseType::Postgres),
column_name: "flags".to_string(),
column_info: Some(bit),
raw_value: "true".to_string(),
})
.as_deref(),
Some("\"flags\" = 'true'")
);
assert_eq!(
format_grid_sql_literal(
&json!(true),
Some(DatabaseType::SqlServer),
Some(&column("flag", "bit", false, None))
),
"1"
);
}
#[test]
fn builds_column_distinct_values_sql() {
assert_eq!(
build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions {
database_type: Some(DatabaseType::Postgres),
catalog: None,
database: None,
schema: Some("public".to_string()),
table_name: "users".to_string(),
column_name: "status".to_string(),
column_info: Some(column("status", "varchar", true, None)),
where_input: Some("WHERE deleted_at IS NULL;".to_string()),
search_value: Some("act".to_string()),
limit: None,
include_counts: true,
}),
"SELECT \"status\" AS dbx_value, COUNT(*) AS dbx_count FROM \"public\".\"users\" WHERE (deleted_at IS NULL) AND \"status\" LIKE '%act%' GROUP BY \"status\" ORDER BY dbx_count DESC, dbx_value LIMIT 1000"
);
assert_eq!(
build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions {
database_type: Some(DatabaseType::SqlServer),
catalog: None,
database: None,
schema: None,
table_name: "users".to_string(),
column_name: "status".to_string(),
column_info: Some(column("status", "nvarchar", true, None)),
where_input: None,
search_value: None,
limit: Some(25),
include_counts: false,
}),
"SELECT TOP (25) [status] AS dbx_value FROM [users] GROUP BY [status] ORDER BY dbx_value"
);
assert_eq!(
build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions {
database_type: Some(DatabaseType::SqlServer),
catalog: None,
database: None,
schema: None,
table_name: "users".to_string(),
column_name: "id".to_string(),
column_info: Some(column("id", "int", false, None)),
where_input: None,
search_value: Some("42".to_string()),
limit: Some(25),
include_counts: true,
}),
"SELECT TOP (25) [id] AS dbx_value, COUNT(*) AS dbx_count FROM [users] WHERE [id] = 42 GROUP BY [id] ORDER BY dbx_count DESC, dbx_value"
);
assert_eq!(
build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions {
database_type: Some(DatabaseType::Oracle),
catalog: None,
database: None,
schema: Some("APP".to_string()),
table_name: "EVENTS".to_string(),
column_name: "KIND".to_string(),
column_info: Some(column("KIND", "VARCHAR2", true, None)),
where_input: None,
search_value: None,
limit: Some(10),
include_counts: true,
}),
"SELECT * FROM (SELECT \"KIND\" AS dbx_value, COUNT(*) AS dbx_count FROM \"APP\".\"EVENTS\" GROUP BY \"KIND\" ORDER BY dbx_count DESC, dbx_value) WHERE ROWNUM <= 10"
);
assert_eq!(
build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions {
database_type: Some(DatabaseType::Firebird),
catalog: None,
database: None,
schema: None,
table_name: "USERS".to_string(),
column_name: "STATUS".to_string(),
column_info: Some(column("STATUS", "varchar(32)", true, None)),
where_input: Some("WHERE DELETED_AT IS NULL".to_string()),
search_value: None,
limit: Some(25),
include_counts: false,
}),
"SELECT \"STATUS\" AS dbx_value FROM \"USERS\" WHERE (DELETED_AT IS NULL) GROUP BY \"STATUS\" ORDER BY dbx_value ROWS 25"
);
// Doris / StarRocks external-catalog tables are addressed with a 3-part name.
assert_eq!(
build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions {
database_type: Some(DatabaseType::Doris),
catalog: Some("iceberg_catalog".to_string()),
database: None,
schema: Some("sales".to_string()),
table_name: "orders".to_string(),
column_name: "status".to_string(),
column_info: Some(column("status", "varchar", true, None)),
where_input: None,
search_value: None,
limit: Some(10),
include_counts: false,
}),
"SELECT `status` AS dbx_value FROM `iceberg_catalog`.`sales`.`orders` GROUP BY `status` ORDER BY dbx_value LIMIT 10"
);
assert_eq!(
build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions {
database_type: Some(DatabaseType::StarRocks),
catalog: Some("hive_catalog".to_string()),
database: None,
schema: None,
table_name: "orders".to_string(),
column_name: "status".to_string(),
column_info: Some(column("status", "varchar", true, None)),
where_input: None,
search_value: None,
limit: Some(10),
include_counts: true,
}),
"SELECT `status` AS dbx_value, COUNT(*) AS dbx_count FROM `hive_catalog`.`orders` GROUP BY `status` ORDER BY dbx_count DESC, dbx_value LIMIT 10"
);
// The built-in `internal` catalog is never prefixed.
assert_eq!(
build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions {
database_type: Some(DatabaseType::Doris),
catalog: Some("internal".to_string()),
database: None,
schema: None,
table_name: "orders".to_string(),
column_name: "status".to_string(),
column_info: Some(column("status", "varchar", true, None)),
where_input: None,
search_value: None,
limit: Some(10),
include_counts: false,
}),
"SELECT `status` AS dbx_value FROM `orders` GROUP BY `status` ORDER BY dbx_value LIMIT 10"
);
}
#[test]
fn builds_grid_count_sql() {
assert_eq!(
build_data_grid_count_sql(DataGridCountSqlOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: None,
catalog: None,
database: None,
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)"
);
assert_eq!(
build_data_grid_count_sql(DataGridCountSqlOptions {
database_type: Some(DatabaseType::Doris),
identifier_quote: None,
catalog: Some("iceberg_catalog".to_string()),
database: None,
schema: Some("sales".to_string()),
table_name: "orders".to_string(),
where_input: Some("WHERE active = true;".to_string()),
}),
"SELECT COUNT(*) AS cnt FROM `iceberg_catalog`.`sales`.`orders` WHERE (active = true)"
);
// catalog + database (schema absent) → 3-part `catalog.database.table`
assert_eq!(
build_data_grid_count_sql(DataGridCountSqlOptions {
database_type: Some(DatabaseType::Doris),
identifier_quote: None,
catalog: Some("iceberg_catalog".to_string()),
database: Some("sales".to_string()),
schema: None,
table_name: "orders".to_string(),
where_input: Some("WHERE active = true;".to_string()),
}),
"SELECT COUNT(*) AS cnt FROM `iceberg_catalog`.`sales`.`orders` WHERE (active = true)"
);
assert_eq!(
build_data_grid_count_sql(DataGridCountSqlOptions {
database_type: Some(DatabaseType::StarRocks),
identifier_quote: None,
catalog: Some("hive_catalog".to_string()),
database: None,
schema: None,
table_name: "orders".to_string(),
where_input: None,
}),
"SELECT COUNT(*) AS cnt FROM `hive_catalog`.`orders`"
);
assert_eq!(
build_data_grid_count_sql(DataGridCountSqlOptions {
database_type: Some(DatabaseType::Kingbase),
identifier_quote: Some("`".to_string()),
catalog: None,
database: None,
schema: Some("cqbq_ls".to_string()),
table_name: "ANALYZE".to_string(),
where_input: None,
}),
"SELECT COUNT(*) AS cnt FROM `cqbq_ls`.`ANALYZE`"
);
}
#[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'"
);
assert_eq!(
format_grid_sql_literal(&json!("2026-05-12 00:00:00.123456"), Some(DatabaseType::Mysql), None),
"'2026-05-12 00:00:00.123456'"
);
}
#[test]
fn formats_sqlserver_datetime_copy_literals_with_supported_precision() {
let datetime = column("date1", "datetime", true, None);
let datetime2 = column("date2", "datetime2(7)", true, None);
let raw_text = column("note", "nvarchar(64)", true, None);
assert_eq!(
format_grid_sql_literal(
&json!("2026-06-29 10:11:12.896666666"),
Some(DatabaseType::SqlServer),
Some(&datetime)
),
"N'2026-06-29 10:11:12.897'"
);
assert_eq!(
format_grid_sql_literal(
&json!("2026-06-29 10:11:12.8966666"),
Some(DatabaseType::SqlServer),
Some(&datetime2)
),
"N'2026-06-29 10:11:12.8966666'"
);
assert_eq!(
format_grid_sql_literal(
&json!("2026-06-29 10:11:12.123456"),
Some(DatabaseType::SqlServer),
Some(&datetime2)
),
"N'2026-06-29 10:11:12.1234560'"
);
assert_eq!(
format_grid_sql_literal(
&json!("2026-06-29 10:11:12.896666666"),
Some(DatabaseType::SqlServer),
Some(&raw_text)
),
"N'2026-06-29 10:11:12.896666666'"
);
}
#[test]
fn formats_oracle_temporal_literals_without_nls_parsing() {
let timestamp = column("created_at", "TIMESTAMP(6)", true, None);
let timestamp_tz = column("recorded_at", "TIMESTAMP(6) WITH TIME ZONE", true, None);
let timestamp_ltz = column("local_recorded_at", "TIMESTAMP(6) WITH LOCAL TIME ZONE", true, None);
let date = column("event_day", "DATE", true, None);
let text = column("raw_text", "VARCHAR2(64)", true, None);
assert_eq!(
format_grid_sql_literal(&json!("2022-08-25T09:58:43Z"), Some(DatabaseType::Oracle), Some(&timestamp)),
"TO_TIMESTAMP('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS')"
);
assert_eq!(
format_grid_sql_literal(
&json!("2022-08-25T09:58:43.123456+08:00"),
Some(DatabaseType::Oracle),
Some(&timestamp_tz)
),
"TO_TIMESTAMP_TZ('2022-08-25 09:58:43.123456 +08:00', 'YYYY-MM-DD HH24:MI:SS.FF TZH:TZM')"
);
assert_eq!(
format_grid_sql_literal(&json!("2022-08-25T09:58:43Z"), Some(DatabaseType::Oracle), Some(&timestamp_ltz)),
"TO_TIMESTAMP_TZ('2022-08-25 09:58:43 +00:00', 'YYYY-MM-DD HH24:MI:SS TZH:TZM')"
);
assert_eq!(
format_grid_sql_literal(&json!("2022-08-25T09:58:43Z"), Some(DatabaseType::Oracle), Some(&date)),
"TO_DATE('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS')"
);
assert_eq!(
format_grid_sql_literal(&json!("2022-08-25T09:58:43Z"), Some(DatabaseType::Oracle), Some(&text)),
"'2022-08-25T09:58:43Z'"
);
}
#[test]
fn formats_oracle_temporal_literals_from_editor_values_without_nls_parsing() {
let timestamp = column("created_at", "TIMESTAMP(6)", true, None);
let date = column("event_day", "DATE", true, None);
assert_eq!(
format_grid_sql_literal(&json!("2022-08-25 09:58:43"), Some(DatabaseType::Oracle), Some(&timestamp)),
"TO_TIMESTAMP('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS')"
);
assert_eq!(
format_grid_sql_literal(&json!("2022-08-25 09:58:43.123456"), Some(DatabaseType::Oracle), Some(&timestamp)),
"TO_TIMESTAMP('2022-08-25 09:58:43.123456', 'YYYY-MM-DD HH24:MI:SS.FF')"
);
assert_eq!(
format_grid_sql_literal(&json!("2022-08-25 09:58:43.654321"), Some(DatabaseType::Oracle), Some(&timestamp)),
"TO_TIMESTAMP('2022-08-25 09:58:43.654321', 'YYYY-MM-DD HH24:MI:SS.FF')"
);
assert_eq!(
format_grid_sql_literal(&json!("2022-08-25 09:58:43"), Some(DatabaseType::Oracle), Some(&date)),
"TO_DATE('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS')"
);
assert_eq!(
format_grid_sql_literal(&json!("2022-08-25"), Some(DatabaseType::Oracle), Some(&date)),
"DATE '2022-08-25'"
);
assert_eq!(
format_grid_sql_literal(&json!("2022-08-25T00:00:00Z"), Some(DatabaseType::Oracle), Some(&date)),
"DATE '2022-08-25'"
);
}
#[test]
fn formats_numeric_string_literals_for_numeric_columns_without_quotes() {
let number = column("amount", "NUMBER(20,0)", true, None);
let text = column("code", "VARCHAR2(32)", true, None);
assert_eq!(
format_grid_sql_literal(&json!("12345678901234567890"), Some(DatabaseType::Oracle), Some(&number)),
"12345678901234567890"
);
assert_eq!(
format_grid_sql_literal(&json!("12345678901234567890"), Some(DatabaseType::Oracle), Some(&text)),
"'12345678901234567890'"
);
assert_eq!(
format_grid_sql_literal(&json!("123-not-a-number"), Some(DatabaseType::Oracle), Some(&number)),
"'123-not-a-number'"
);
}
#[test]
fn prepares_sqlserver_bigint_update_from_numeric_string() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::SqlServer),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbo".to_string()),
table_name: "users".to_string(),
primary_keys: vec!["Id".to_string()],
columns: Some(vec![column("Id", "int", false, None), column("UserId", "bigint", true, None)]),
},
columns: vec!["Id".to_string(), "UserId".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!(142189065666650_i64)]],
dirty_rows: vec![(0, vec![(1, json!("144847503924137986"))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE [dbo].[users] SET [UserId] = 144847503924137986 WHERE [Id] = 1;"]);
}
#[test]
fn prepares_oracle_timestamp_insert_from_iso_grid_value() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Oracle),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("APP".to_string()),
table_name: "EVENTS".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![
column("ID", "NUMBER", false, None),
column("CREATED_AT", "TIMESTAMP(6)", true, None),
]),
},
columns: vec!["ID".to_string(), "CREATED_AT".to_string()],
source_columns: None,
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![vec![json!(1), json!("2022-08-25T09:58:43Z")]],
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
"INSERT INTO \"APP\".\"EVENTS\" (\"ID\", \"CREATED_AT\") VALUES (1, TO_TIMESTAMP('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS'));"
]
);
}
#[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_bitn_updates_with_numeric_literals() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::SqlServer),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbo".to_string()),
table_name: "flags".to_string(),
primary_keys: vec![],
columns: Some(vec![column("id", "int", false, None), column("active", "bitn", false, None)]),
},
columns: vec!["id".to_string(), "active".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!(false)]],
dirty_rows: vec![(0, vec![(1, json!(true))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE [dbo].[flags] SET [active] = 1 WHERE [id] = 1 AND [active] = 0;"]);
assert_eq!(
result.rollback_statements,
vec!["UPDATE [dbo].[flags] SET [active] = 0 WHERE [id] = 1 AND [active] = 1 AND [active] = 1;"]
);
for sql in result.statements.iter().chain(result.rollback_statements.iter()) {
assert!(!sql.contains("TRUE"));
assert!(!sql.contains("FALSE"));
}
}
#[test]
fn saves_empty_nullable_mysql_numeric_cell_as_null() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "employees".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "int(11)", false, None), column("age", "int(11)", true, None)]),
},
columns: vec!["id".to_string(), "age".to_string()],
source_columns: None,
rows: vec![vec![json!(2), json!(36)]],
dirty_rows: vec![(0, vec![(1, json!(""))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE `employees` SET `age` = NULL WHERE `id` = 2;"]);
assert_eq!(
result.rollback_statements,
vec!["UPDATE `employees` SET `age` = 36 WHERE `id` = 2 AND `age` IS NULL;"]
);
}
#[test]
fn keeps_empty_nullable_mysql_text_cell_as_empty_string() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "employees".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "int(11)", false, None), column("name", "varchar(50)", true, None)]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: None,
rows: vec![vec![json!(2), json!("Ada")]],
dirty_rows: vec![(0, vec![(1, json!(""))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE `employees` SET `name` = '' WHERE `id` = 2;"]);
assert_eq!(
result.rollback_statements,
vec!["UPDATE `employees` SET `name` = 'Ada' WHERE `id` = 2 AND BINARY `name` = '';"]
);
}
#[test]
fn preserves_mysql_text_cell_line_breaks() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "employees".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "int(11)", false, None), column("name", "varchar(50)", true, None)]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: None,
rows: vec![vec![json!(2), json!("Ada")]],
dirty_rows: vec![(0, vec![(1, json!("111\n222"))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE `employees` SET `name` = '111\n222' WHERE `id` = 2;"]);
assert_eq!(
result.rollback_statements,
vec!["UPDATE `employees` SET `name` = 'Ada' WHERE `id` = 2 AND BINARY `name` = '111\n222';"]
);
}
#[test]
fn prepares_sqlserver_save_statements() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::SqlServer),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
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 prepares_tdengine_child_table_delete_from_stable_row() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Tdengine),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbx_tdengine_demo".to_string()),
table_name: "meters".to_string(),
primary_keys: vec![DBX_TDENGINE_TBNAME_COLUMN.to_string(), "ts".to_string()],
columns: Some(vec![column("ts", "TIMESTAMP", false, None), column("voltage", "FLOAT", true, None)]),
},
columns: vec![DBX_TDENGINE_TBNAME_COLUMN.to_string(), "ts".to_string(), "voltage".to_string()],
source_columns: None,
rows: vec![vec![json!("codex_delete_verify"), json!("2026-07-10T13:59:00.456+08:00"), json!(221.5)]],
dirty_rows: vec![],
deleted_rows: vec![0],
new_rows: vec![],
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec!["DELETE FROM `dbx_tdengine_demo`.`codex_delete_verify` WHERE `ts` = '2026-07-10T13:59:00.456+08:00';"]
);
assert_eq!(
result.rollback_statements,
vec!["INSERT INTO `dbx_tdengine_demo`.`meters` (`tbname`, `ts`, `voltage`) VALUES ('codex_delete_verify', '2026-07-10T13:59:00.456+08:00', 221.5);"]
);
}
#[test]
fn rejects_tdengine_composite_key_delete_from_same_timestamp_stable_rows() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Tdengine),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbx_tdengine_demo".to_string()),
table_name: "meters".to_string(),
primary_keys: vec![DBX_TDENGINE_TBNAME_COLUMN.to_string(), "ts".to_string(), "seq".to_string()],
columns: Some(vec![
column("ts", "TIMESTAMP", false, None),
column("seq", "INT", false, Some("COMPOSITE KEY")),
column("voltage", "FLOAT", true, None),
]),
},
columns: vec![
DBX_TDENGINE_TBNAME_COLUMN.to_string(),
"ts".to_string(),
"seq".to_string(),
"voltage".to_string(),
],
source_columns: None,
rows: vec![
vec![json!("device_a"), json!("2026-07-10T13:59:00.456+08:00"), json!(1), json!(221.5)],
vec![json!("device_a"), json!("2026-07-10T13:59:00.456+08:00"), json!(2), json!(222.5)],
],
dirty_rows: vec![],
deleted_rows: vec![1],
new_rows: vec![],
});
assert_eq!(
result.validation_error,
Some("TDengine tables with composite keys do not support row deletion.".to_string())
);
assert!(result.statements.is_empty());
assert!(result.rollback_statements.is_empty());
}
#[test]
fn prepares_tdengine_delete_from_direct_child_table_row() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Tdengine),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbx_tdengine_demo".to_string()),
table_name: "codex_grid_accept_20260710".to_string(),
primary_keys: vec!["ts".to_string()],
columns: Some(vec![column("ts", "TIMESTAMP", false, None), column("voltage", "FLOAT", true, None)]),
},
columns: vec!["ts".to_string(), "voltage".to_string()],
source_columns: None,
rows: vec![vec![json!("2026-07-10T16:00:00.111+08:00"), json!(220.1)]],
dirty_rows: vec![],
deleted_rows: vec![0],
new_rows: vec![],
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec!["DELETE FROM `dbx_tdengine_demo`.`codex_grid_accept_20260710` WHERE `ts` = '2026-07-10T16:00:00.111+08:00';"]
);
assert_eq!(
result.rollback_statements,
vec!["INSERT INTO `dbx_tdengine_demo`.`codex_grid_accept_20260710` (`ts`, `voltage`) VALUES ('2026-07-10T16:00:00.111+08:00', 220.1);"]
);
}
#[test]
fn prepares_tdengine_overwrite_for_direct_child_table_row() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Tdengine),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbx_tdengine_demo".to_string()),
table_name: "codex_grid_update_verify_20260710".to_string(),
primary_keys: vec!["ts".to_string()],
columns: Some(vec![
column("ts", "TIMESTAMP", false, None),
column("voltage", "FLOAT", true, None),
column("current", "FLOAT", true, None),
]),
},
columns: vec!["ts".to_string(), "voltage".to_string(), "current".to_string()],
source_columns: None,
rows: vec![vec![json!("2026-07-10T16:30:00.444+08:00"), json!(220.0), json!(1.0)]],
dirty_rows: vec![(0, vec![(1, json!(229.9))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec!["INSERT INTO `dbx_tdengine_demo`.`codex_grid_update_verify_20260710` (`ts`, `voltage`, `current`) VALUES ('2026-07-10T16:30:00.444+08:00', 229.9, 1.0);"]
);
assert_eq!(
result.rollback_statements,
vec!["INSERT INTO `dbx_tdengine_demo`.`codex_grid_update_verify_20260710` (`ts`, `voltage`, `current`) VALUES ('2026-07-10T16:30:00.444+08:00', 220.0, 1.0);"]
);
}
#[test]
fn prepares_tdengine_composite_key_overwrite_and_rollback_for_same_timestamp_child_rows() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Tdengine),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbx_tdengine_demo".to_string()),
table_name: "device_a".to_string(),
primary_keys: vec!["ts".to_string(), "seq".to_string()],
columns: Some(vec![
column("ts", "TIMESTAMP", false, None),
column("seq", "INT", false, Some("COMPOSITE KEY")),
column("voltage", "FLOAT", true, None),
]),
},
columns: vec!["ts".to_string(), "seq".to_string(), "voltage".to_string()],
source_columns: None,
rows: vec![
vec![json!("2026-07-10T16:30:00.444+08:00"), json!(1), json!(220.0)],
vec![json!("2026-07-10T16:30:00.444+08:00"), json!(2), json!(221.0)],
],
dirty_rows: vec![(1, vec![(2, json!(229.9))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec!["INSERT INTO `dbx_tdengine_demo`.`device_a` (`ts`, `seq`, `voltage`) VALUES ('2026-07-10T16:30:00.444+08:00', 2, 229.9);"]
);
assert_eq!(
result.rollback_statements,
vec!["INSERT INTO `dbx_tdengine_demo`.`device_a` (`ts`, `seq`, `voltage`) VALUES ('2026-07-10T16:30:00.444+08:00', 2, 221.0);"]
);
}
#[test]
fn prepares_tdengine_stable_insert_with_child_table_identity() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Tdengine),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbx_tdengine_demo".to_string()),
table_name: "issue_3121_devices".to_string(),
primary_keys: vec![DBX_TDENGINE_TBNAME_COLUMN.to_string(), "ts".to_string()],
columns: Some(vec![
column("ts", "TIMESTAMP", false, None),
column("reading", "FLOAT", true, None),
column("site", "VARCHAR", true, Some("TAG")),
]),
},
columns: vec![
DBX_TDENGINE_TBNAME_COLUMN.to_string(),
"ts".to_string(),
"reading".to_string(),
"site".to_string(),
],
source_columns: None,
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![vec![
json!("codex_issue3121_insert_verify"),
json!("2026-07-10T17:48:51.000+08:00"),
json!(1.0),
json!("codex-lab"),
]],
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec!["INSERT INTO `dbx_tdengine_demo`.`issue_3121_devices` (`tbname`, `ts`, `reading`, `site`) VALUES ('codex_issue3121_insert_verify', '2026-07-10T17:48:51.000+08:00', 1.0, 'codex-lab');"]
);
assert_eq!(
result.rollback_statements,
vec!["DELETE FROM `dbx_tdengine_demo`.`codex_issue3121_insert_verify` WHERE `ts` = '2026-07-10T17:48:51.000+08:00';"]
);
}
#[test]
fn skips_tdengine_composite_key_insert_rollback_for_same_timestamp_rows() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Tdengine),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbx_tdengine_demo".to_string()),
table_name: "meters".to_string(),
primary_keys: vec![DBX_TDENGINE_TBNAME_COLUMN.to_string(), "ts".to_string(), "seq".to_string()],
columns: Some(vec![
column("ts", "TIMESTAMP", false, None),
column("seq", "INT", false, Some("COMPOSITE KEY")),
column("voltage", "FLOAT", true, None),
]),
},
columns: vec![
DBX_TDENGINE_TBNAME_COLUMN.to_string(),
"ts".to_string(),
"seq".to_string(),
"voltage".to_string(),
],
source_columns: None,
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![
vec![json!("device_a"), json!("2026-07-10T17:48:51.000+08:00"), json!(1), json!(221.0)],
vec![json!("device_a"), json!("2026-07-10T17:48:51.000+08:00"), json!(2), json!(222.0)],
],
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
"INSERT INTO `dbx_tdengine_demo`.`meters` (`tbname`, `ts`, `seq`, `voltage`) VALUES ('device_a', '2026-07-10T17:48:51.000+08:00', 1, 221.0);",
"INSERT INTO `dbx_tdengine_demo`.`meters` (`tbname`, `ts`, `seq`, `voltage`) VALUES ('device_a', '2026-07-10T17:48:51.000+08:00', 2, 222.0);",
]
);
assert!(result.rollback_statements.is_empty());
}
#[test]
fn rejects_tdengine_stable_insert_without_child_table_identity() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Tdengine),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbx_tdengine_demo".to_string()),
table_name: "issue_3121_devices".to_string(),
primary_keys: vec![DBX_TDENGINE_TBNAME_COLUMN.to_string(), "ts".to_string()],
columns: Some(vec![column("ts", "TIMESTAMP", false, None), column("reading", "FLOAT", true, None)]),
},
columns: vec![DBX_TDENGINE_TBNAME_COLUMN.to_string(), "ts".to_string(), "reading".to_string()],
source_columns: None,
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![vec![Value::Null, json!("2026-07-10T17:48:51.000+08:00"), json!(1.0)]],
});
assert_eq!(
result.validation_error,
Some("TDengine STABLE inserts require a child table name (tbname).".to_string())
);
assert!(result.statements.is_empty());
assert!(result.rollback_statements.is_empty());
}
#[test]
fn rejects_tdengine_delete_without_child_table_identity() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Tdengine),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbx_tdengine_demo".to_string()),
table_name: "meters".to_string(),
primary_keys: vec![DBX_TDENGINE_TBNAME_COLUMN.to_string(), "ts".to_string()],
columns: Some(vec![column("ts", "TIMESTAMP", false, None)]),
},
columns: vec!["ts".to_string()],
source_columns: None,
rows: vec![vec![json!("2026-07-10T13:59:00.456+08:00")]],
dirty_rows: vec![],
deleted_rows: vec![0],
new_rows: vec![],
});
assert_eq!(
result.validation_error,
Some("TDengine row editing requires all row identifier columns in the result.".to_string())
);
assert!(result.statements.is_empty());
}
#[test]
fn rejects_tdengine_existing_row_edit_when_composite_key_is_missing() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Tdengine),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbx_tdengine_demo".to_string()),
table_name: "device_a".to_string(),
primary_keys: vec!["ts".to_string(), "seq".to_string()],
columns: Some(vec![
column("ts", "TIMESTAMP", false, None),
column("seq", "INT", false, Some("COMPOSITE KEY")),
column("voltage", "FLOAT", true, None),
]),
},
columns: vec!["ts".to_string(), "voltage".to_string()],
source_columns: None,
rows: vec![vec![json!("2026-07-10T16:30:00.444+08:00"), json!(220.0)]],
dirty_rows: vec![(0, vec![(1, json!(229.9))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(
result.validation_error,
Some("TDengine row editing requires all row identifier columns in the result.".to_string())
);
assert!(result.statements.is_empty());
assert!(result.rollback_statements.is_empty());
}
#[test]
fn rejects_tdengine_existing_row_identity_changes() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Tdengine),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbx_tdengine_demo".to_string()),
table_name: "device_a".to_string(),
primary_keys: vec!["ts".to_string(), "seq".to_string()],
columns: Some(vec![
column("ts", "TIMESTAMP", false, None),
column("seq", "INT", false, Some("COMPOSITE KEY")),
column("voltage", "FLOAT", true, None),
]),
},
columns: vec!["ts".to_string(), "seq".to_string(), "voltage".to_string()],
source_columns: None,
rows: vec![vec![json!("2026-07-10T16:30:00.444+08:00"), json!(2), json!(220.0)]],
dirty_rows: vec![(0, vec![(1, json!(3))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(result.validation_error, Some("TDengine row identifier columns cannot be edited.".to_string()));
assert!(result.statements.is_empty());
assert!(result.rollback_statements.is_empty());
}
#[test]
fn prepares_databend_save_statements() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Databend),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("default".to_string()),
table_name: "people".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "int", false, None), column("name", "string", true, None)]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("Ada")]],
dirty_rows: vec![(0, vec![(1, json!("Linus"))])],
deleted_rows: vec![0],
new_rows: vec![vec![json!(2), json!("Grace")]],
});
assert_eq!(
result.statements,
vec![
"UPDATE `default`.`people` SET `name` = 'Linus' WHERE `id` = 1;",
"DELETE FROM `default`.`people` WHERE `id` = 1;",
"INSERT INTO `default`.`people` (`id`, `name`) VALUES (2, 'Grace');",
]
);
}
#[test]
fn prepares_clickhouse_mutation_save_statements() {
let mut id_column = column("id", "UInt64", false, None);
id_column.is_primary_key = true;
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::ClickHouse),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("default".to_string()),
table_name: "people".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![id_column, column("name", "String", true, None)]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("Ada")]],
dirty_rows: vec![(0, vec![(1, json!("Linus"))])],
deleted_rows: vec![0],
new_rows: vec![vec![json!(2), json!("Grace")]],
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
"ALTER TABLE `people` UPDATE `name` = 'Linus' WHERE `id` = 1;",
"ALTER TABLE `people` DELETE WHERE `id` = 1;",
"INSERT INTO `people` (`id`, `name`) VALUES (2, 'Grace');",
]
);
assert!(result.rollback_statements.is_empty());
}
#[test]
fn rejects_clickhouse_key_only_update() {
let mut id_column = column("id", "UInt64", false, None);
id_column.is_primary_key = true;
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::ClickHouse),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("default".to_string()),
table_name: "people".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![id_column, column("name", "String", true, None)]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("Ada")]],
dirty_rows: vec![(0, vec![(0, json!(2))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(
result.validation_error,
Some(
"ClickHouse primary or partition key columns cannot be updated. Change a non-key column before saving."
.to_string()
)
);
assert!(result.statements.is_empty());
}
#[test]
fn omits_clickhouse_partition_key_update_assignments() {
let mut event_date_column = column("event_date", "Date", false, Some("partition_key"));
event_date_column.is_primary_key = false;
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::ClickHouse),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("default".to_string()),
table_name: "events".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![
column("id", "UInt64", false, None),
event_date_column,
column("name", "String", true, None),
]),
},
columns: vec!["id".to_string(), "event_date".to_string(), "name".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("2026-06-24"), json!("Ada")]],
dirty_rows: vec![(0, vec![(1, json!("2026-06-25")), (2, json!("Linus"))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["ALTER TABLE `events` UPDATE `name` = 'Linus' WHERE `id` = 1;"]);
}
#[test]
fn rejects_clickhouse_partition_key_only_update() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::ClickHouse),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("default".to_string()),
table_name: "events".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![
column("id", "UInt64", false, None),
column("event_date", "Date", false, Some("partition_key")),
column("name", "String", true, None),
]),
},
columns: vec!["id".to_string(), "event_date".to_string(), "name".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("2026-06-24"), json!("Ada")]],
dirty_rows: vec![(0, vec![(1, json!("2026-06-25"))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(
result.validation_error,
Some(
"ClickHouse primary or partition key columns cannot be updated. Change a non-key column before saving."
.to_string()
)
);
assert!(result.statements.is_empty());
}
#[test]
fn builds_clickhouse_copy_update_statements() {
let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions {
database_type: Some(DatabaseType::ClickHouse),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("default".to_string()),
table_name: "people".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "UInt64", false, None), column("name", "String", true, None)]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("Ada")]],
});
assert_eq!(statements, vec!["ALTER TABLE `people` UPDATE `name` = 'Ada' WHERE `id` = 1;"]);
}
#[test]
fn doris_external_catalog_save_and_copy_statements_use_catalog_scope() {
let table_meta = DataGridTableMeta {
catalog: Some("iceberg_catalog".to_string()),
database: None,
schema: Some("sales".to_string()),
table_name: "orders".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "bigint", false, None), column("status", "varchar", true, None)]),
};
let copy_updates = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions {
database_type: Some(DatabaseType::Doris),
table_meta: table_meta.clone(),
columns: vec!["id".to_string(), "status".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("paid")]],
});
assert_eq!(
copy_updates,
vec!["UPDATE `iceberg_catalog`.`sales`.`orders` SET `status` = 'paid' WHERE `id` = 1;"]
);
let copy_insert = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Doris),
table_meta: Some(table_meta.clone()),
columns: vec!["id".to_string(), "status".to_string()],
column_types: None,
source_columns: None,
rows: vec![vec![json!(2), json!("new")]],
exclude_primary_keys: false,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(
copy_insert.as_deref(),
Some("INSERT INTO `iceberg_catalog`.`sales`.`orders` (`id`, `status`) VALUES (2, 'new');")
);
let save = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Doris),
table_meta,
columns: vec!["id".to_string(), "status".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("pending")], vec![json!(3), json!("cancelled")]],
dirty_rows: vec![(0, vec![(1, json!("paid"))])],
deleted_rows: vec![1],
new_rows: vec![vec![json!(4), json!("new")]],
});
assert_eq!(
save.statements,
vec![
"UPDATE `iceberg_catalog`.`sales`.`orders` SET `status` = 'paid' WHERE `id` = 1;",
"DELETE FROM `iceberg_catalog`.`sales`.`orders` WHERE `id` = 3;",
"INSERT INTO `iceberg_catalog`.`sales`.`orders` (`id`, `status`) VALUES (4, 'new');",
]
);
assert!(save.validation_error.is_none());
}
#[test]
fn prepares_databend_keyless_save_statements_with_row_predicate() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Databend),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("default".to_string()),
table_name: "people".to_string(),
primary_keys: vec![],
columns: Some(vec![column("id", "int", true, None), column("name", "string", true, None)]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("Ada")]],
dirty_rows: vec![(0, vec![(1, json!("Linus"))])],
deleted_rows: vec![0],
new_rows: vec![],
});
assert_eq!(
result.statements,
vec![
"UPDATE `default`.`people` SET `name` = 'Linus' WHERE `id` = 1 AND `name` = 'Ada';",
"DELETE FROM `default`.`people` WHERE `id` = 1 AND `name` = 'Ada';",
]
);
}
#[test]
fn prepares_oscar_keyless_save_statements_with_schema_qualified_row_predicate() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Oscar),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("SYSDBA".to_string()),
table_name: "PEOPLE".to_string(),
primary_keys: vec![],
columns: Some(vec![column("ID", "INTEGER", true, None), column("NAME", "VARCHAR", true, None)]),
},
columns: vec!["ID".to_string(), "NAME".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("Ada")]],
dirty_rows: vec![(0, vec![(1, json!("Linus"))])],
deleted_rows: vec![0],
new_rows: vec![],
});
assert_eq!(
result.statements,
vec![
"UPDATE \"SYSDBA\".\"PEOPLE\" SET \"NAME\" = 'Linus' WHERE \"ID\" = 1 AND \"NAME\" = 'Ada';",
"DELETE FROM \"SYSDBA\".\"PEOPLE\" WHERE \"ID\" = 1 AND \"NAME\" = 'Ada';",
]
);
}
#[test]
fn skips_expression_only_source_columns() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Postgres),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
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 {
catalog: None,
database: None,
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_primary_key_text_predicates_do_not_use_binary_comparison() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "school".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "varchar(32)", true, None), column("age", "varchar(8)", false, None)]),
},
columns: vec!["id".to_string(), "age".to_string()],
source_columns: None,
rows: vec![vec![json!("0001492305e412e88086bd582d2678e0"), json!("17")]],
dirty_rows: vec![(0, vec![(1, json!("18"))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(
result.statements,
vec!["UPDATE `school` SET `age` = '18' WHERE `id` = '0001492305e412e88086bd582d2678e0';"]
);
}
#[test]
fn mysql_row_text_predicates_use_binary_comparison_for_width_sensitive_edits() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
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 prepares_manticore_save_statements_without_trailing_semicolons() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::ManticoreSearch),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "rt_products".to_string(),
primary_keys: vec![],
columns: Some(vec![column("id", "bigint", false, None), column("title", "text", true, None)]),
},
columns: vec!["id".to_string(), "title".to_string()],
source_columns: None,
rows: vec![vec![json!("1"), json!("old")], vec![json!("2"), json!("deleted")]],
dirty_rows: vec![(0, vec![(1, json!("new"))])],
deleted_rows: vec![1],
new_rows: vec![vec![json!("3"), json!("inserted")]],
});
assert_eq!(
result.statements,
vec![
"UPDATE `rt_products` SET `title` = 'new' WHERE `id` = 1 AND `title` = 'old'",
"DELETE FROM `rt_products` WHERE `id` = 2 AND `title` = 'deleted'",
"INSERT INTO `rt_products` (`id`, `title`) VALUES (3, 'inserted')",
]
);
assert!(result.rollback_statements.iter().all(|statement| !statement.ends_with(';')));
}
#[test]
fn validates_duplicate_inserted_primary_keys() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Postgres),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
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());
}
fn pk_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: true,
column_default: None,
extra: extra.map(ToString::to_string),
}
}
#[test]
fn prepare_data_grid_save_skips_sqlite_autoincrement_pk_validation() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Sqlite),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "OnlineLogs".to_string(),
primary_keys: vec!["OnlineLogId".to_string()],
columns: Some(vec![
pk_column("OnlineLogId", "INTEGER", false, Some("autoincrement")),
column("LogTime", "TEXT", false, None),
]),
},
columns: vec!["OnlineLogId".to_string(), "LogTime".to_string()],
source_columns: None,
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![vec![Value::Null, json!("2026-06-12T00:00:00Z")]],
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec![r#"INSERT INTO "OnlineLogs" ("LogTime") VALUES ('2026-06-12T00:00:00Z');"#]);
}
#[test]
fn prepare_data_grid_save_includes_explicit_sqlite_pk_value() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Sqlite),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "OnlineLogs".to_string(),
primary_keys: vec!["OnlineLogId".to_string()],
columns: Some(vec![
pk_column("OnlineLogId", "INTEGER", false, Some("autoincrement")),
column("LogTime", "TEXT", false, None),
]),
},
columns: vec!["OnlineLogId".to_string(), "LogTime".to_string()],
source_columns: None,
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![vec![json!(42), json!("2026-06-12T00:00:00Z")]],
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![r#"INSERT INTO "OnlineLogs" ("OnlineLogId", "LogTime") VALUES (42, '2026-06-12T00:00:00Z');"#]
);
}
#[test]
fn prepare_data_grid_save_omits_empty_mysql_auto_increment_value() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("app".to_string()),
table_name: "users".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![
pk_column("id", "BIGINT", false, Some("auto_increment")),
column("name", "VARCHAR", false, None),
]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: None,
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![vec![json!(""), json!("Ada")]],
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["INSERT INTO `app`.`users` (`name`) VALUES ('Ada');"]);
}
#[test]
fn prepare_data_grid_save_still_validates_other_not_null_columns_in_sqlite() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Sqlite),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "OnlineLogs".to_string(),
primary_keys: vec!["OnlineLogId".to_string()],
columns: Some(vec![
pk_column("OnlineLogId", "INTEGER", false, Some("autoincrement")),
column("LogTime", "TEXT", false, None),
]),
},
columns: vec!["OnlineLogId".to_string(), "LogTime".to_string()],
source_columns: None,
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![vec![Value::Null, Value::Null]],
});
assert_eq!(result.validation_error, Some(r#"Column "LogTime" does not allow NULL."#.to_string()));
assert!(result.statements.is_empty());
}
}