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

9845 lines
399 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_iotdb_sql.rs"]
mod data_grid_iotdb_sql;
use data_grid_iotdb_sql::{
build_iotdb_data_grid_rollback_statements, build_iotdb_data_grid_save_statements, uses_iotdb_table_model_save,
validate_iotdb_existing_rows,
};
#[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_oracle_row_id,
uses_single_row_insert_statements, uses_synthetic_row_id, uses_xugu_row_id, TablePaginationStrategy,
};
use crate::value_literals::{format_ch_array_sql_literal, format_pg_array_sql_literal};
const DBX_ROWID_COLUMN: &str = "__DBX_ROWID";
pub const DBX_NEO4J_ELEMENT_ID_COLUMN: &str = "__DBX_ELEMENT_ID";
pub 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;
/// Alias for the single cell a keyless guard query returns.
const KEYLESS_GUARD_COUNT_ALIAS: &str = "dbx_keyless_row_matches";
const KEYLESS_AMBIGUOUS_ROW_ERROR: &str = "Cannot safely update or delete this row: the table has no primary key, so the row is identified by matching every column value, and more than one row in the table matches that condition. Add a primary key or unique index, or make the rows distinguishable, before editing.";
const KEYLESS_UNIDENTIFIABLE_ROW_ERROR: &str = "Cannot safely update or delete this row: the table has no primary key and none of the result columns map to a table column, so there is no condition that can target a single row. Add a primary key, or edit the table directly, before saving.";
const MYSQL_DATA_GRID_BATCH_MAX_ROWS: usize = 500;
const MYSQL_DATA_GRID_BATCH_TARGET_SQL_BYTES: usize = 256 * 1024;
const ORACLE_SQL_LITERAL_MAX_BYTES: usize = 4000;
const ORACLE_LOB_LITERAL_CHUNK_BYTES: usize = 3900;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[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)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identifier_quote: Option<String>,
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>>,
/// `生成 SQL 时包含数据库名`: qualify the saved table with its database on
/// engines that address tables as `database.table` (MySQL family,
/// ClickHouse). Off by default so existing statements are unchanged.
#[serde(default)]
pub include_database_name: bool,
}
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identifier_quote: Option<String>,
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>>,
/// `生成 SQL 时包含数据库名`: qualify the copied table the same way the
/// save statements and the copy-as-INSERT statements do. Defaults to true so
/// callers that never strip the table metadata keep the historical shape.
#[serde(default = "default_include_database_name")]
pub include_database_name: bool,
}
#[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 identifier_quote: Option<String>,
#[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 include_computed_columns: bool,
#[serde(default = "default_include_database_name")]
pub include_database_name: bool,
#[serde(default)]
pub insert_mode: DataGridCopyInsertMode,
}
fn default_include_database_name() -> bool {
true
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[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,
IsBlank,
IsNotBlank,
Like,
NotLike,
BeginsWith,
EndsWith,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual,
In,
NotIn,
Between,
NotBetween,
}
fn supports_data_grid_context_filter_mode(
database_type: Option<DatabaseType>,
mode: DataGridContextFilterMode,
) -> bool {
if database_type == Some(DatabaseType::VictoriaMetrics) {
return false;
}
!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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identifier_quote: Option<String>,
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identifier_quote: Option<String>,
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identifier_quote: Option<String>,
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 driver_profile: Option<String>,
#[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>,
#[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>,
/// Optional optimizer hint injected between SELECT and the select list.
/// Example: "/*+ set(query_dop 32) */" for GaussDB parallel COUNT(*).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub count_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DataGridConditionalUpdateSqlOptions {
#[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>,
pub table_meta: DataGridTableMeta,
pub column_name: String,
pub value: Value,
pub where_input: 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,
}
/// A server-side check that must pass before a keyless save may run.
///
/// Without a primary key a row is identified by matching every column value,
/// so the same predicate can match physical rows the loaded page never saw.
/// `sql` counts, on the server, how many rows one of the predicates this save
/// actually sends to the database matches. The save must be refused with
/// `message` unless the returned count is at most `max_matched_rows`, and also
/// refused when the count cannot be obtained at all — an unverified keyless
/// write is exactly the ambiguous write this guard exists to prevent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DataGridSaveGuard {
pub sql: String,
pub max_matched_rows: u32,
pub message: 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>,
/// Checks the caller must run — and pass — before executing `statements`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keyless_guards: Vec<DataGridSaveGuard>,
}
pub fn prepare_data_grid_save(options: DataGridSaveStatementOptions) -> DataGridSavePreparation {
prepare_data_grid_save_for_driver_profile(options, None)
}
pub fn prepare_data_grid_save_for_driver_profile(
options: DataGridSaveStatementOptions,
driver_profile: Option<&str>,
) -> 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,
driver_profile,
&options.table_meta,
),
keyless_guards: Vec::new(),
};
}
let mut keyless_guards = Vec::new();
let statements = build_data_grid_save_statements(&options, driver_profile, &mut keyless_guards);
DataGridSavePreparation {
validation_error: None,
statements,
rollback_statements: build_data_grid_rollback_statements(&options, driver_profile),
execution_schema: data_grid_save_execution_schema(options.database_type, driver_profile, &options.table_meta),
keyless_guards,
}
}
/// Relational SQL UPDATE/WHERE predicates are not meaningful for graph,
/// document, or time-series stores that don't speak relational SQL.
pub fn supports_relational_copy_predicates(database_type: Option<DatabaseType>) -> bool {
!matches!(database_type, Some(DatabaseType::Neo4j | DatabaseType::Tdengine | DatabaseType::MongoDb))
}
pub fn build_data_grid_copy_update_statements(options: DataGridCopyUpdateStatementOptions) -> Vec<String> {
if !supports_relational_copy_predicates(options.database_type) {
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(options.database_type, &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, Option<&DataGridColumnInfo>)> = 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_synthetic_row_id(options.database_type, Some(column)))
.map(|(column, index)| (column, index, column_info_for(column_info, column)))
.collect();
let primary_key_info =
primary_keys.iter().map(|primary_key| column_info_for(column_info, primary_key)).collect::<Vec<_>>();
if writable_indexes.is_empty() {
return Vec::new();
}
let table = data_grid_generated_table_name(
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,
options.identifier_quote.as_deref(),
options.include_database_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, info)| {
format!(
"{} = {}",
data_grid_identifier(options.database_type, column, options.identifier_quote.as_deref()),
format_grid_assignment_sql_literal(
row.get(*index).unwrap_or(&Value::Null),
options.database_type,
*info,
options.identifier_quote.as_deref(),
)
)
})
.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),
primary_key_info[index],
false,
options.identifier_quote.as_deref(),
)
})
.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, Option<DataGridColumnInfo>)> = save_columns
.iter()
.enumerate()
.filter_map(|(index, column)| Some((column.as_deref()?, index)))
.map(|(column, index)| {
let fallback_type =
options.column_types.as_deref().and_then(|types| types.get(index)).and_then(|value| value.as_deref());
(column, index, copy_column_info(column_info, column, fallback_type))
})
.filter(|(column, _, info)| {
!is_grid_insert_omitted_column(
options.database_type,
info.as_ref(),
Some(column),
options.include_computed_columns,
)
})
.collect();
// "Exclude primary keys" keeps manually-assigned key columns: dropping a
// non-auto-generated PK member loses data and yields an INSERT that cannot
// satisfy NOT NULL. Only auto-generated (auto_increment/identity) keys are
// excluded, matching DBeaver's SQLGeneratorInsert excludeAutoGeneratedColumn.
let insert_columns: Vec<(&str, usize, Option<DataGridColumnInfo>)> = insertable_columns
.iter()
.filter(|(column, _, info)| {
!options.exclude_primary_keys
|| !primary_key_set.contains(&normalize_column_name(column))
|| !info.as_ref().is_some_and(is_auto_generated_column)
})
.cloned()
.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| {
// This copy option controls every namespace prefix, including
// SQLite's main schema and external catalogs.
if !options.include_database_name {
return data_grid_identifier(
options.database_type,
&meta.table_name,
options.identifier_quote.as_deref(),
);
}
// MySQL-compatible engines can store the database in either field.
let use_database_fallback = match options.database_type {
Some(DatabaseType::Mysql | DatabaseType::Goldendb | DatabaseType::ClickHouse) => true,
Some(DatabaseType::Doris | DatabaseType::StarRocks) => meta
.catalog
.as_deref()
.is_none_or(|catalog| catalog.trim().is_empty() || catalog.trim() == "internal"),
_ => false,
};
// MySQL table tabs store the namespace in `database` without a
// schema. Explicit schemas still take priority for cross-database
// sources; schema-based engines must not use this fallback.
let schema = if use_database_fallback {
meta.schema
.as_deref()
.filter(|schema| !schema.trim().is_empty())
.or_else(|| meta.database.as_deref().filter(|database| !database.trim().is_empty()))
} else {
meta.schema.as_deref()
};
// ClickHouse (`database.table`) and SQL Server
// (`database.schema.table`) address tables across databases on the
// same connection, so the setting resolves the full name for them.
if let Some(qualified) = crate::sql_dialect::database_qualified_table_name(
options.database_type,
meta.catalog.as_deref(),
schema,
meta.database.as_deref(),
&meta.table_name,
) {
return qualified;
}
data_grid_qualified_table_name(
options.database_type,
meta.catalog.as_deref(),
schema,
meta.database.as_deref(),
&meta.table_name,
options.identifier_quote.as_deref(),
)
},
);
let columns = insert_columns
.iter()
.map(|(_, index, _)| {
data_grid_identifier(options.database_type, &options.columns[*index], options.identifier_quote.as_deref())
})
.collect::<Vec<_>>()
.join(", ");
let value_rows = options
.rows
.iter()
.map(|row| {
format!(
"({})",
insert_columns
.iter()
.map(|(_, index, info)| {
format_grid_copy_insert_sql_literal(
row.get(*index).unwrap_or(&Value::Null),
options.database_type,
info.as_ref(),
options.identifier_quote.as_deref(),
)
})
.collect::<Vec<_>>()
.join(", ")
)
})
.collect::<Vec<_>>();
let statements = if options.insert_mode == DataGridCopyInsertMode::RowByRow
|| options.database_type.is_some_and(uses_single_row_insert_statements)
{
value_rows.iter().map(|values| format!("INSERT INTO {table} ({columns}) VALUES {values};")).collect::<Vec<_>>()
} else {
vec![format!(
"INSERT INTO {table} ({columns}) VALUES{}{};",
if value_rows.len() == 1 { " " } else { "\n" },
value_rows.join(",\n")
)]
};
// SQL Server and Dameng reject explicit values for identity columns unless the
// statement runs between `SET IDENTITY_INSERT <table> ON` and `OFF` (SQL Server
// error 544), so a copied INSERT that carries the identity column must ship the
// wrapper. Each statement is wrapped on its own so row-by-row copies stay
// individually executable, matching the SQL export path.
let needs_identity_insert_wrapper =
matches!(options.database_type, Some(DatabaseType::SqlServer | DatabaseType::Dameng))
&& insert_columns.iter().any(|(_, _, info)| info.as_ref().is_some_and(is_auto_generated_column));
if needs_identity_insert_wrapper {
return Some(
statements
.iter()
.map(|statement| {
format!("SET IDENTITY_INSERT {table} ON;\n{statement}\nSET IDENTITY_INSERT {table} OFF;")
})
.collect::<Vec<_>>()
.join("\n"),
);
}
Some(statements.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, options.identifier_quote.as_deref());
let like_column = column_like_filter_ref(
options.database_type,
&options.column_name,
options.column_info.as_ref(),
options.identifier_quote.as_deref(),
);
let value = &options.value;
match options.mode {
DataGridContextFilterMode::IsNull => Some(format!("{column} IS NULL")),
DataGridContextFilterMode::IsNotNull => Some(format!("{column} IS NOT NULL")),
DataGridContextFilterMode::IsBlank
if matches!(options.database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) =>
{
Some(format!("{column} IS NULL"))
}
DataGridContextFilterMode::IsNotBlank
if matches!(options.database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) =>
{
Some(format!("{column} IS NOT NULL"))
}
DataGridContextFilterMode::IsBlank => Some(format!("({column} IS NULL OR {column} = '')")),
DataGridContextFilterMode::IsNotBlank => Some(format!("({column} IS NOT NULL AND {column} <> '')")),
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::BeginsWith => Some(format!(
"{like_column} LIKE {}",
format_grid_sql_literal(
&Value::String(format!("{}%", value_to_filter_text(value))),
options.database_type,
None
)
)),
DataGridContextFilterMode::EndsWith => Some(format!(
"{like_column} LIKE {}",
format_grid_sql_literal(
&Value::String(format!("%{}", value_to_filter_text(value))),
options.database_type,
None
)
)),
DataGridContextFilterMode::LessThan => Some(format!(
"{column} < {}",
format_data_grid_context_filter_literal(
value,
options.database_type,
&options.column_name,
options.column_info.as_ref(),
options.identifier_quote.as_deref(),
)
)),
DataGridContextFilterMode::LessThanOrEqual => Some(format!(
"{column} <= {}",
format_data_grid_context_filter_literal(
value,
options.database_type,
&options.column_name,
options.column_info.as_ref(),
options.identifier_quote.as_deref(),
)
)),
DataGridContextFilterMode::GreaterThan => Some(format!(
"{column} > {}",
format_data_grid_context_filter_literal(
value,
options.database_type,
&options.column_name,
options.column_info.as_ref(),
options.identifier_quote.as_deref(),
)
)),
DataGridContextFilterMode::GreaterThanOrEqual => Some(format!(
"{column} >= {}",
format_data_grid_context_filter_literal(
value,
options.database_type,
&options.column_name,
options.column_info.as_ref(),
options.identifier_quote.as_deref(),
)
)),
DataGridContextFilterMode::In => build_data_grid_context_membership_filter_condition(
&column,
&options.values,
options.database_type,
&options.column_name,
options.column_info.as_ref(),
options.identifier_quote.as_deref(),
false,
),
DataGridContextFilterMode::NotIn => build_data_grid_context_membership_filter_condition(
&column,
&options.values,
options.database_type,
&options.column_name,
options.column_info.as_ref(),
options.identifier_quote.as_deref(),
true,
),
DataGridContextFilterMode::Between => build_data_grid_context_range_filter_condition(
&column,
value,
options.end_value.as_ref(),
options.database_type,
&options.column_name,
options.column_info.as_ref(),
options.identifier_quote.as_deref(),
false,
),
DataGridContextFilterMode::NotBetween => build_data_grid_context_range_filter_condition(
&column,
value,
options.end_value.as_ref(),
options.database_type,
&options.column_name,
options.column_info.as_ref(),
options.identifier_quote.as_deref(),
true,
),
DataGridContextFilterMode::Equals => Some(format!(
"{column} = {}",
format_data_grid_context_filter_literal(
value,
options.database_type,
&options.column_name,
options.column_info.as_ref(),
options.identifier_quote.as_deref(),
)
)),
DataGridContextFilterMode::NotEquals => Some(format!(
"{column} <> {}",
format_data_grid_context_filter_literal(
value,
options.database_type,
&options.column_name,
options.column_info.as_ref(),
options.identifier_quote.as_deref(),
)
)),
}
}
fn format_data_grid_context_filter_literal(
value: &Value,
database_type: Option<DatabaseType>,
column_name: &str,
column_info: Option<&DataGridColumnInfo>,
identifier_quote: Option<&str>,
) -> String {
if database_type == Some(DatabaseType::Iotdb) && column_info.is_none() && column_name.eq_ignore_ascii_case("time") {
if let Some(value) = value.as_str().filter(|value| is_decimal_i64(value)) {
return value.to_string();
}
}
format_grid_sql_literal_with_identifier_quote(value, database_type, column_info, identifier_quote)
}
fn is_decimal_i64(value: &str) -> bool {
let digits = value.strip_prefix('-').unwrap_or(value);
!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) && value.parse::<i64>().is_ok()
}
fn build_data_grid_context_membership_filter_condition(
column: &str,
values: &[Value],
database_type: Option<DatabaseType>,
column_name: &str,
column_info: Option<&DataGridColumnInfo>,
identifier_quote: Option<&str>,
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_data_grid_context_filter_literal(value, database_type, column_name, column_info, identifier_quote);
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_name: &str,
column_info: Option<&DataGridColumnInfo>,
identifier_quote: Option<&str>,
negated: bool,
) -> Option<String> {
let end_value = end_value?;
if start_value.is_null() || end_value.is_null() {
return None;
}
let start =
format_data_grid_context_filter_literal(start_value, database_type, column_name, column_info, identifier_quote);
let end =
format_data_grid_context_filter_literal(end_value, database_type, column_name, column_info, identifier_quote);
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, options.identifier_quote.as_deref());
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, options.identifier_quote.as_deref());
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 = data_grid_qualified_table_name(
options.database_type,
options.catalog.as_deref(),
options.schema.as_deref(),
options.database.as_deref(),
&options.table_name,
options.identifier_quote.as_deref(),
);
let column = column_filter_ref(options.database_type, &options.column_name, options.identifier_quote.as_deref());
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 if is_sqlserver_legacy_profile(options.driver_profile.as_deref()) => {
format!("SELECT {select_list}{from_clause}")
}
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}")
}
}
}
fn is_sqlserver_legacy_profile(driver_profile: Option<&str>) -> bool {
driver_profile.is_some_and(|profile| profile.trim().eq_ignore_ascii_case("sqlserver-legacy"))
}
pub fn build_data_grid_count_sql(options: DataGridCountSqlOptions) -> String {
let table = if crate::sql_dialect::uses_connection_identifier_quote(
options.database_type,
options.identifier_quote.as_deref(),
) {
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})") };
let hint = options.count_hint.as_deref().unwrap_or("");
let hint_part = if hint.is_empty() { String::new() } else { format!(" {hint}") };
format!("SELECT{hint_part} COUNT(*) AS cnt FROM {table}{where_clause}")
}
pub fn build_data_grid_conditional_update_sql(options: DataGridConditionalUpdateSqlOptions) -> Option<String> {
if options.database_type != Some(DatabaseType::Mysql) {
return None;
}
let predicate = crate::sql_dialect::normalize_where_input(Some(&options.where_input));
if predicate.is_empty() {
return None;
}
let columns = options.table_meta.columns.as_deref()?;
let column_info = column_info_for(columns, &options.column_name)?;
let primary_key_set: Vec<String> =
options.table_meta.primary_keys.iter().map(|primary_key| normalize_column_name(primary_key)).collect();
if primary_key_set.contains(&normalize_column_name(&column_info.name))
|| column_info.is_primary_key
|| is_grid_update_omitted_column(
options.database_type,
Some(column_info),
Some(&column_info.name),
&primary_key_set,
)
{
return None;
}
let table = data_grid_qualified_table_name(
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,
options.identifier_quote.as_deref(),
);
let sets = format!(
"{} = {}",
data_grid_identifier(options.database_type, &column_info.name, options.identifier_quote.as_deref()),
format_grid_save_sql_literal(
&options.value,
options.database_type,
Some(column_info),
options.identifier_quote.as_deref(),
)
);
Some(data_grid_statement(
options.database_type,
data_grid_update_sql(options.database_type, &table, &sets, &format!("({predicate})")),
))
}
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, options.identifier_quote.as_deref());
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(),
options.identifier_quote.as_deref(),
);
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, None);
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_iotdb_existing_rows(options) {
return Some(error);
}
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);
}
if let Some(error) = validate_existing_row_primary_keys(options) {
return Some(error);
}
if let Some(error) = validate_oracle_keyless_lob_predicate(options) {
return Some(error);
}
if let Some(error) = validate_keyless_row_predicate(options) {
return Some(error);
}
let save_columns = effective_columns(options);
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_synthetic_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 = save_columns.get(*column_index).and_then(|column| column.as_deref());
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()));
}
}
}
// MySQL BEFORE INSERT triggers can populate omitted NOT NULL columns. New-row NULL values are
// omitted from the generated INSERT, so let MySQL apply triggers or report missing required fields.
if options.database_type != Some(DatabaseType::Mysql) {
for row in &options.new_rows {
for column_index in 0..options.columns.len() {
let source_column = save_columns.get(column_index).and_then(|column| column.as_deref());
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_existing_row_primary_keys(options: &DataGridSaveStatementOptions) -> Option<String> {
let primary_keys = &options.table_meta.primary_keys;
if primary_keys.is_empty() || (options.dirty_rows.is_empty() && options.deleted_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(options.database_type, &save_columns, primary_key))
.collect();
let missing_primary_keys = primary_keys
.iter()
.zip(&primary_key_indexes)
.filter_map(|(primary_key, index)| index.is_none().then_some(primary_key.as_str()))
.collect::<Vec<_>>();
if !missing_primary_keys.is_empty() {
return Some(format!(
"Cannot safely update or delete rows because the query result does not include every primary key column (missing: {}). Refresh or rerun the query before saving.",
missing_primary_keys.join(", ")
));
}
let primary_key_indexes = primary_key_indexes.into_iter().flatten().collect::<Vec<_>>();
for row_index in
options.dirty_rows.iter().map(|(row_index, _)| *row_index).chain(options.deleted_rows.iter().copied())
{
let Some(row) = options.rows.get(row_index) else {
continue;
};
if let Some((primary_key, _)) =
primary_keys.iter().zip(&primary_key_indexes).find(|(_, index)| row.get(**index).is_none_or(Value::is_null))
{
return Some(format!(
"Cannot safely update or delete rows because primary key column \"{primary_key}\" has no value in the query result. Refresh or rerun the query before saving."
));
}
}
None
}
fn validate_oracle_keyless_lob_predicate(options: &DataGridSaveStatementOptions) -> Option<String> {
if !uses_oracle_row_id(options.database_type)
|| !options.table_meta.primary_keys.is_empty()
|| (options.dirty_rows.is_empty() && options.deleted_rows.is_empty())
{
return None;
}
let has_lob_column =
options.table_meta.columns.as_deref().unwrap_or(&[]).iter().any(|column| is_oracle_lob_type(&column.data_type));
if !has_lob_column {
return None;
}
// LOB equality is unsupported in Oracle-compatible SQL. Refuse unsafe
// keyless writes instead of dropping LOB predicates and risking extra rows.
Some("Cannot safely update or delete this Oracle-compatible row because the table has LOB columns but no primary key or ROWID identifier.".to_string())
}
/// Without a primary key a row can only be addressed by matching every column
/// value, and `build_row_where` drops columns that have no source column of
/// their own. When nothing is left, the generated predicate is empty and the
/// UPDATE/DELETE would target the whole table, so there is neither a reliable
/// row identifier nor anything a server-side check could count: refuse.
///
/// Whether a non-empty predicate really addresses a single physical row cannot
/// be decided here — the loaded page is not the table. That decision belongs to
/// the `keyless_guards` this preparation emits, which count the matches of the
/// exact predicate on the server.
fn validate_keyless_row_predicate(options: &DataGridSaveStatementOptions) -> Option<String> {
if !options.table_meta.primary_keys.is_empty() || !uses_keyless_row_predicate(options.database_type) {
return None;
}
let save_columns = effective_columns(options);
let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]);
let touched_row_indexes =
options.dirty_rows.iter().map(|(row_index, _)| *row_index).chain(options.deleted_rows.iter().copied());
for row_index in touched_row_indexes {
let Some(row) = options.rows.get(row_index) else {
continue;
};
let predicate = build_row_where(
options.database_type,
&save_columns,
row,
column_info,
options.identifier_quote.as_deref(),
);
if predicate.trim().is_empty() {
return Some(KEYLESS_UNIDENTIFIABLE_ROW_ERROR.to_string());
}
}
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(options.database_type, &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
}
/// Builds the statements the save executes, and alongside them the server-side
/// guards for every keyless predicate those statements actually send. The guard
/// is derived from the same `where_clause` value the UPDATE/DELETE carries, so
/// the safety decision can never be made against a different set of columns or
/// values than the mutation itself uses.
fn build_data_grid_save_statements(
options: &DataGridSaveStatementOptions,
driver_profile: Option<&str>,
keyless_guards: &mut Vec<DataGridSaveGuard>,
) -> 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);
}
if uses_iotdb_table_model_save(options) {
return build_iotdb_data_grid_save_statements(options);
}
let save_columns = effective_columns(options);
let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]);
let schema = crate::sql_dialect::table_data_schema(
options.database_type,
driver_profile,
options.table_meta.schema.as_deref(),
);
let table = data_grid_save_table_name(options, schema);
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();
let guards_keyless_predicates = primary_key_set.is_empty() && uses_keyless_row_predicate(options.database_type);
let mut guarded_predicates: Vec<String> = Vec::new();
let guard_predicate = |predicate: &str, guarded: &mut Vec<String>| {
if !guards_keyless_predicates || predicate.trim().is_empty() {
return;
}
if !guarded.iter().any(|existing| existing == predicate) {
guarded.push(predicate.to_string());
}
};
let batch_mysql_writes = supports_mysql_data_grid_batch(options);
let mut update_sets: Option<String> = None;
let mut update_predicates = Vec::new();
for (row_index, changes) in &options.dirty_rows {
let Some(row) = options.rows.get(*row_index) else {
continue;
};
let sets = changes
.iter()
.filter_map(|(column_index, value)| {
let column = save_columns.get(*column_index)?.as_deref()?;
if is_grid_update_omitted_column(
options.database_type,
column_info_for(column_info, column),
Some(column),
&primary_key_set,
) {
return None;
}
Some(format!(
"{} = {}",
data_grid_identifier(options.database_type, column, options.identifier_quote.as_deref()),
format_grid_save_sql_literal(
value,
options.database_type,
column_info_for(column_info, column),
options.identifier_quote.as_deref(),
)
))
})
.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,
options.identifier_quote.as_deref(),
);
guard_predicate(&where_clause, &mut guarded_predicates);
if batch_mysql_writes {
if update_sets.as_deref().is_some_and(|current| current != sets) {
let current_sets = update_sets.take().unwrap_or_default();
push_mysql_predicate_batches(
&mut statements,
&format!("UPDATE {table} SET {current_sets} WHERE "),
std::mem::take(&mut update_predicates),
);
}
update_sets = Some(sets);
update_predicates.push(where_clause);
} else {
statements.push(data_grid_statement(
options.database_type,
data_grid_update_sql(options.database_type, &table, &sets, &where_clause),
));
}
}
if let Some(sets) = update_sets {
push_mysql_predicate_batches(&mut statements, &format!("UPDATE {table} SET {sets} WHERE "), update_predicates);
}
let mut delete_predicates = Vec::new();
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,
options.identifier_quote.as_deref(),
);
guard_predicate(&where_clause, &mut guarded_predicates);
if batch_mysql_writes {
delete_predicates.push(where_clause);
} else {
statements.push(data_grid_statement(
options.database_type,
data_grid_delete_sql(options.database_type, &table, &where_clause),
));
}
}
if !delete_predicates.is_empty() {
push_mysql_delete_batches(&mut statements, &format!("DELETE FROM {table} WHERE "), delete_predicates);
}
for row in &options.new_rows {
if options.database_type == Some(DatabaseType::Hive) {
if let Some(statement) = build_hive_values_insert(options, &table, &save_columns, row, true, true) {
statements.push(data_grid_statement(options.database_type, statement));
}
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, 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),
false,
)
})
.filter(|(_, value)| !value.is_null())
.collect();
if insert_pairs.is_empty() {
if options.database_type == Some(DatabaseType::Mysql) {
statements
.push(data_grid_statement(options.database_type, format!("INSERT INTO {table} () VALUES ()")));
}
continue;
}
let columns = insert_pairs
.iter()
.map(|(column, _)| data_grid_identifier(options.database_type, column, options.identifier_quote.as_deref()))
.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),
options.identifier_quote.as_deref(),
)
})
.collect::<Vec<_>>()
.join(", ");
statements.push(data_grid_statement(
options.database_type,
format!("INSERT INTO {table} ({columns}) VALUES ({values})"),
));
}
keyless_guards.extend(guarded_predicates.into_iter().map(|predicate| DataGridSaveGuard {
sql: format!("SELECT COUNT(*) AS {KEYLESS_GUARD_COUNT_ALIAS} FROM {table} WHERE ({predicate})"),
max_matched_rows: 1,
message: KEYLESS_AMBIGUOUS_ROW_ERROR.to_string(),
}));
statements
}
fn build_data_grid_rollback_statements(
options: &DataGridSaveStatementOptions,
driver_profile: Option<&str>,
) -> 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 uses_iotdb_table_model_save(options) {
return build_iotdb_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 schema = crate::sql_dialect::table_data_schema(
options.database_type,
driver_profile,
options.table_meta.schema.as_deref(),
);
let table = data_grid_save_table_name(options, schema);
let mut statements = Vec::new();
for row in &options.new_rows {
let where_clause = if options.database_type == Some(DatabaseType::Mysql) {
build_mysql_insert_rollback_where(options, &save_columns, row, column_info)
} else {
let where_clause = build_save_row_where(
options.database_type,
&save_columns,
row,
column_info,
options.identifier_quote.as_deref(),
);
(!where_clause.is_empty()).then_some(where_clause)
};
if let Some(where_clause) = where_clause {
statements
.push(data_grid_statement(options.database_type, format!("DELETE FROM {table} WHERE {where_clause}")));
}
}
let batch_mysql_writes = supports_mysql_data_grid_batch(options);
let mut deleted_insert_columns: Option<String> = None;
let mut deleted_insert_values = Vec::new();
for row_index in &options.deleted_rows {
let Some(row) = options.rows.get(*row_index) else {
continue;
};
if options.database_type == Some(DatabaseType::Hive) {
if let Some(statement) = build_hive_values_insert(options, &table, &save_columns, row, false, false) {
statements.push(data_grid_statement(options.database_type, statement));
}
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),
false,
)
})
.collect();
let columns = insert_pairs
.iter()
.map(|(column, _)| data_grid_identifier(options.database_type, column, options.identifier_quote.as_deref()))
.collect::<Vec<_>>()
.join(", ");
let values = insert_pairs
.iter()
.map(|(column, value)| {
format_grid_assignment_sql_literal(
value,
options.database_type,
column_info_for(column_info, column),
options.identifier_quote.as_deref(),
)
})
.collect::<Vec<_>>()
.join(", ");
if batch_mysql_writes {
if deleted_insert_columns.as_deref().is_some_and(|current| current != columns) {
let current_columns = deleted_insert_columns.take().unwrap_or_default();
push_mysql_values_insert_batches(
&mut statements,
&table,
&current_columns,
std::mem::take(&mut deleted_insert_values),
);
}
deleted_insert_columns = Some(columns);
deleted_insert_values.push(format!("({values})"));
} else {
statements.push(data_grid_statement(
options.database_type,
format!("INSERT INTO {table} ({columns}) VALUES ({values})"),
));
}
}
if let Some(columns) = deleted_insert_columns {
push_mysql_values_insert_batches(&mut statements, &table, &columns, deleted_insert_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!(
"{} = {}",
data_grid_identifier(options.database_type, column, options.identifier_quote.as_deref()),
format_grid_assignment_sql_literal(
row.get(*column_index).unwrap_or(&Value::Null),
options.database_type,
column_info_for(column_info, column),
options.identifier_quote.as_deref(),
)
)
})
.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,
options.identifier_quote.as_deref(),
)];
predicates.extend(writable_changes.iter().map(|((_, value), column)| {
build_save_column_predicate(
options.database_type,
column,
value,
column_info_for(column_info, column),
true,
options.identifier_quote.as_deref(),
)
}));
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
}
fn supports_mysql_data_grid_batch(options: &DataGridSaveStatementOptions) -> bool {
// Keyless rows use full-row predicates, which are too wide and ambiguous to combine safely.
options.database_type == Some(DatabaseType::Mysql) && !options.table_meta.primary_keys.is_empty()
}
fn push_mysql_predicate_batches(statements: &mut Vec<String>, prefix: &str, predicates: Vec<String>) {
push_mysql_joined_batches(statements, prefix, predicates, " OR ", true);
}
/// Render a batch of delete predicates for MySQL. Rows deleted through the grid
/// usually differ only in a single primary-key equality predicate, so equal-key
/// runs collapse into `id IN (...)` instead of a chain of ORs (#8857). Anything
/// that is not a plain `column = literal` equality (compound keys, NULL checks,
/// BINARY comparisons, ...) falls back to the OR form, which stays correct for
/// every predicate shape.
fn push_mysql_delete_batches(statements: &mut Vec<String>, prefix: &str, predicates: Vec<String>) {
const IN_JOINER: &str = ", ";
struct InRun {
column: Option<String>,
values: Vec<String>,
bytes: usize,
}
fn flush_in(run: &mut InRun, batches: &mut Vec<String>) {
if let Some(column) = run.column.take() {
if run.values.len() == 1 {
batches.push(format!("{column} = {}", run.values[0]));
} else {
batches.push(format!("{column} IN ({})", run.values.join(IN_JOINER)));
}
run.values.clear();
run.bytes = 0;
}
}
let mut in_run = InRun { column: None, values: Vec::new(), bytes: 0 };
let mut in_batches: Vec<String> = Vec::new();
let mut pending_batches: Vec<String> = Vec::new();
for predicate in &predicates {
match parse_mysql_equality_predicate(predicate) {
Some((column, literal)) => {
let literal_bytes = literal.len() + IN_JOINER.len();
let column_switch = in_run.column.as_deref().is_some_and(|existing| existing != column);
let over_budget = in_run.bytes + literal_bytes > MYSQL_DATA_GRID_BATCH_TARGET_SQL_BYTES;
let over_rows = in_run.values.len() >= MYSQL_DATA_GRID_BATCH_MAX_ROWS;
if column_switch || over_budget || over_rows {
flush_in(&mut in_run, &mut in_batches);
}
if in_run.column.is_none() {
in_run.column = Some(column);
}
in_run.bytes += literal_bytes;
in_run.values.push(literal);
}
None => {
flush_in(&mut in_run, &mut in_batches);
pending_batches.push(predicate.clone());
}
}
}
flush_in(&mut in_run, &mut in_batches);
let mut batches = pending_batches;
batches.extend(in_batches);
if batches.len() == 1 {
statements.push(format!("{prefix}{};", batches.remove(0)));
} else if !batches.is_empty() {
push_mysql_predicate_batches(statements, prefix, batches);
}
}
/// Recognize predicates shaped exactly like `` `column` = literal `` (with or
/// without backticks) so they can merge into an IN list. Anything else —
/// compound-key AND chains, IS NULL, BINARY comparisons — returns None.
fn parse_mysql_equality_predicate(predicate: &str) -> Option<(String, String)> {
let mut trimmed = predicate;
while let Some(inner) = trimmed.strip_prefix('(').and_then(|inner| inner.strip_suffix(')')) {
trimmed = inner;
}
let separator = trimmed.find(" = ")?;
let column = trimmed.get(..separator)?.trim().to_string();
if !column.starts_with('`') || !column.ends_with('`') || column.len() < 3 {
return None;
}
let literal = trimmed.get(separator + 3..)?.trim().to_string();
if literal.is_empty() || literal.eq_ignore_ascii_case("null") {
return None;
}
// Compound-key predicates contain further AND/OR/IS clauses after the first
// equality; merging those into an IN list would change the row targeting.
let upper = literal.to_ascii_uppercase();
if upper.contains(" AND ") || upper.contains(" OR ") || upper.starts_with("IS ") {
return None;
}
Some((column, literal))
}
fn push_mysql_values_insert_batches(
statements: &mut Vec<String>,
table: &str,
columns: &str,
value_tuples: Vec<String>,
) {
push_mysql_joined_batches(
statements,
&format!("INSERT INTO {table} ({columns}) VALUES "),
value_tuples,
", ",
false,
);
}
fn push_mysql_joined_batches(
statements: &mut Vec<String>,
prefix: &str,
parts: Vec<String>,
separator: &str,
wrap_multiple_parts: bool,
) {
let mut batch = Vec::new();
for part in parts {
let next_len = joined_batch_sql_len(prefix, &batch, &part, separator, wrap_multiple_parts);
if !batch.is_empty()
&& (batch.len() >= MYSQL_DATA_GRID_BATCH_MAX_ROWS || next_len > MYSQL_DATA_GRID_BATCH_TARGET_SQL_BYTES)
{
push_mysql_joined_batch_statement(
statements,
prefix,
std::mem::take(&mut batch),
separator,
wrap_multiple_parts,
);
}
batch.push(part);
}
if !batch.is_empty() {
push_mysql_joined_batch_statement(statements, prefix, batch, separator, wrap_multiple_parts);
}
}
fn joined_batch_sql_len(
prefix: &str,
current: &[String],
next: &str,
separator: &str,
wrap_multiple_parts: bool,
) -> usize {
let part_bytes = |part: &str| part.len() + usize::from(wrap_multiple_parts) * 2;
if current.is_empty() {
return prefix.len() + next.len() + 1;
}
let current_bytes = if current.len() == 1 && wrap_multiple_parts {
current[0].len()
} else {
current.iter().map(|part| part_bytes(part)).sum::<usize>() + separator.len() * current.len().saturating_sub(1)
};
let existing_adjustment = if current.len() == 1 && wrap_multiple_parts { 2 } else { 0 };
prefix.len() + current_bytes + existing_adjustment + separator.len() + part_bytes(next) + 1
}
fn push_mysql_joined_batch_statement(
statements: &mut Vec<String>,
prefix: &str,
parts: Vec<String>,
separator: &str,
wrap_multiple_parts: bool,
) {
let body = if parts.len() == 1 || !wrap_multiple_parts {
parts.join(separator)
} else {
parts.into_iter().map(|part| format!("({part})")).collect::<Vec<_>>().join(separator)
};
statements.push(format!("{prefix}{body};"));
}
fn build_mysql_insert_rollback_where(
options: &DataGridSaveStatementOptions,
columns: &[Option<String>],
row: &[Value],
column_info: &[DataGridColumnInfo],
) -> Option<String> {
if options.table_meta.primary_keys.is_empty() {
return None;
}
for primary_key in &options.table_meta.primary_keys {
let index = columns.iter().position(|column| column.as_deref() == Some(primary_key.as_str()))?;
let value = row.get(index).unwrap_or(&Value::Null);
let info = column_info_for(column_info, primary_key);
if value.is_null()
|| empty_string_saves_as_null(value, info)
|| info.is_some_and(is_auto_generated_column)
|| info.is_some_and(|column| is_non_identity_generated_column(Some(column)))
{
// Generated or trigger-populated keys are unknown until after INSERT.
// Do not emit a rollback predicate that cannot match the inserted row.
return None;
}
}
Some(build_primary_key_where(
options.database_type,
&options.table_meta.primary_keys,
columns,
row,
column_info,
options.identifier_quote.as_deref(),
))
}
pub fn effective_columns(options: &DataGridSaveStatementOptions) -> Vec<Option<String>> {
let columns = 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(),
};
if options.database_type != Some(DatabaseType::Hive) {
return columns;
}
columns
.into_iter()
.map(|column| column.map(|column| resolve_hive_target_column(&options.table_meta, &column)))
.collect()
}
fn resolve_hive_target_column(table_meta: &DataGridTableMeta, result_column: &str) -> String {
let Some(columns) = table_meta.columns.as_deref() else {
return result_column.to_string();
};
if let Some(column) = unique_column_info_match(columns, result_column) {
return column.name.clone();
}
let Some(unqualified) = last_qualified_identifier_component(result_column) else {
return result_column.to_string();
};
// Hive JDBC may expose SELECT * labels as `table.column`. Resolve them through
// target metadata, while exact matching above preserves real dotted column names.
unique_column_info_match(columns, &unqualified)
.map_or_else(|| result_column.to_string(), |column| column.name.clone())
}
fn unique_column_info_match<'a>(columns: &'a [DataGridColumnInfo], name: &str) -> Option<&'a DataGridColumnInfo> {
if let Some(column) = columns.iter().find(|column| column.name == name) {
return Some(column);
}
let normalized = normalize_column_name(name);
let mut matches = columns.iter().filter(|column| normalize_column_name(&column.name) == normalized);
let first = matches.next()?;
matches.next().is_none().then_some(first)
}
fn last_qualified_identifier_component(name: &str) -> Option<String> {
let mut quote = None;
let mut component_start = 0;
let mut last_component = None;
let chars = name.char_indices().collect::<Vec<_>>();
let mut index = 0;
while index < chars.len() {
let (byte_index, ch) = chars[index];
if let Some(end_quote) = quote {
if ch == end_quote {
if chars.get(index + 1).is_some_and(|(_, next)| *next == end_quote) {
index += 2;
continue;
}
quote = None;
}
} else {
match ch {
'`' | '"' => quote = Some(ch),
'[' => quote = Some(']'),
'.' => {
let component = name[component_start..byte_index].trim();
if component.is_empty() {
return None;
}
last_component = Some(component);
component_start = byte_index + ch.len_utf8();
}
_ => {}
}
}
index += 1;
}
if quote.is_some() || last_component.is_none() {
return None;
}
unquote_identifier_component(name[component_start..].trim())
}
fn unquote_identifier_component(component: &str) -> Option<String> {
if component.is_empty() {
return None;
}
for (open, close) in [('`', '`'), ('"', '"'), ('[', ']')] {
if component.starts_with(open) || component.ends_with(close) {
let inner = component.strip_prefix(open)?.strip_suffix(close)?;
let escaped = format!("{close}{close}");
return Some(inner.replace(&escaped, &close.to_string()));
}
}
Some(component.to_string())
}
fn build_hive_values_insert(
options: &DataGridSaveStatementOptions,
table: &str,
save_columns: &[Option<String>],
row: &[Value],
save_literals: bool,
skip_all_null: bool,
) -> Option<String> {
let metadata_columns = options.table_meta.columns.as_deref().unwrap_or(&[]);
let target_columns = if metadata_columns.is_empty() {
save_columns.iter().filter_map(|column| column.as_deref()).collect::<Vec<_>>()
} else {
metadata_columns.iter().map(|column| column.name.as_str()).collect::<Vec<_>>()
};
if target_columns.is_empty() {
return None;
}
let values = target_columns
.iter()
.map(|column| {
let value = find_column_index(Some(DatabaseType::Hive), save_columns, column)
.and_then(|index| row.get(index))
.unwrap_or(&Value::Null);
(column, value)
})
.collect::<Vec<_>>();
if skip_all_null && values.iter().all(|(_, value)| value.is_null()) {
return None;
}
let values = values
.into_iter()
.map(|(column, value)| {
let info = column_info_for(metadata_columns, column);
if save_literals {
format_grid_save_sql_literal(value, options.database_type, info, options.identifier_quote.as_deref())
} else {
format_grid_assignment_sql_literal(
value,
options.database_type,
info,
options.identifier_quote.as_deref(),
)
}
})
.collect::<Vec<_>>()
.join(", ");
Some(format!("INSERT INTO TABLE {table} VALUES ({values})"))
}
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 data_grid_save_execution_schema(
database_type: Option<DatabaseType>,
driver_profile: Option<&str>,
table_meta: &DataGridTableMeta,
) -> Option<String> {
if matches!(database_type, Some(DatabaseType::Neo4j | DatabaseType::Oracle)) {
return None;
}
crate::sql_dialect::table_data_schema(database_type, driver_profile, table_meta.schema.as_deref())
.map(str::to_string)
}
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>,
identifier_quote: Option<&str>,
) -> 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_assignment_sql_literal(value, database_type, column_info, identifier_quote)
}
fn format_grid_assignment_sql_literal(
value: &Value,
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
identifier_quote: Option<&str>,
) -> String {
if matches!(database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) {
if let Some(constructor) = column_info.and_then(|column| oracle_character_lob_constructor(&column.data_type)) {
if let Some(text) = value.as_str() {
return format_oracle_lob_assignment_literal(text, constructor);
}
}
}
if (value.is_array() || value.is_object()) && is_json_document_column(column_info) {
return format_grid_sql_literal_with_identifier_quote(
&Value::String(value.to_string()),
database_type,
column_info,
identifier_quote,
);
}
format_grid_sql_literal_with_identifier_quote(value, database_type, column_info, identifier_quote)
}
fn format_oracle_lob_assignment_literal(text: &str, constructor: &str) -> String {
// Keep each Oracle SQL literal below the parser limit while preserving a CLOB result.
let escaped_len = text.chars().map(oracle_sql_literal_char_len).sum::<usize>();
if escaped_len <= ORACLE_SQL_LITERAL_MAX_BYTES {
let mut escaped_text = String::with_capacity(escaped_len);
append_oracle_sql_literal_characters(&mut escaped_text, text);
return format!("'{escaped_text}'");
}
let mut chunks = Vec::new();
let mut current = String::with_capacity(ORACLE_LOB_LITERAL_CHUNK_BYTES);
let mut current_len = 0;
for ch in text.chars() {
let char_len = oracle_sql_literal_char_len(ch);
if current_len > 0 && current_len + char_len > ORACLE_LOB_LITERAL_CHUNK_BYTES {
chunks.push(format!("{constructor}('{current}')"));
current = String::with_capacity(ORACLE_LOB_LITERAL_CHUNK_BYTES);
current_len = 0;
}
append_oracle_sql_literal_char(&mut current, ch);
current_len += char_len;
}
if current_len > 0 {
chunks.push(format!("{constructor}('{current}')"));
}
chunks.join(" || ")
}
fn oracle_sql_literal_char_len(ch: char) -> usize {
match ch {
'\'' => 2,
_ => ch.len_utf8(),
}
}
fn append_oracle_sql_literal_characters(output: &mut String, text: &str) {
for ch in text.chars() {
append_oracle_sql_literal_char(output, ch);
}
}
fn append_oracle_sql_literal_char(output: &mut String, ch: char) {
match ch {
'\'' => output.push_str("''"),
_ => output.push(ch),
}
}
fn is_json_document_column(column_info: Option<&DataGridColumnInfo>) -> bool {
column_info.is_some_and(|column| {
let data_type = column.data_type.trim();
data_type.eq_ignore_ascii_case("json") || data_type.eq_ignore_ascii_case("jsonb")
})
}
pub fn format_grid_sql_literal(
value: &Value,
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
) -> String {
format_grid_sql_literal_with_identifier_quote(value, database_type, column_info, None)
}
pub fn format_grid_sql_literal_with_identifier_quote(
value: &Value,
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
identifier_quote: Option<&str>,
) -> String {
if value.is_null() {
return "NULL".to_string();
}
// Boolean values on BIT columns use database-native boolean/bit literals.
// 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_kingbase_bit_literal_column(database_type, column_info) {
return format!("b'{}'", if value { '1' } else { '0' });
}
// SQL Server has no TRUE/FALSE literals (its boolean type is BIT, which
// is_bit_literal_column already covers); any other column there still
// needs numeric 1/0 instead of a literal.
if is_bit_literal_column(database_type, column_info) || database_type == Some(DatabaseType::SqlServer) {
return if value { "1" } else { "0" }.to_string();
}
return if value { "TRUE" } else { "FALSE" }.to_string();
}
if is_kingbase_bit_literal_column(database_type, column_info) {
if let Some(number) = value.as_number() {
if let Some(literal) = format_kingbase_bit_literal_text(&number.to_string()) {
return literal;
}
}
if let Some(text) = value.as_str() {
if let Some(literal) = format_kingbase_bit_literal_text(text) {
return literal;
}
}
}
if is_mysql_bit_literal_column(database_type, column_info, identifier_quote) {
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 let Some(element_type) = postgres_json_array_element_type(database_type, column_info) {
return format_postgres_json_array_sql_literal(arr, element_type);
}
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 database_type == Some(DatabaseType::Iotdb)
&& column_info.is_some_and(|column| {
column.data_type.trim().split(['(', ' ']).next().is_some_and(|base| base.eq_ignore_ascii_case("timestamp"))
})
{
if let Ok(timestamp) = text.parse::<i64>() {
// IoTDB can use nanosecond epoch values that exceed JavaScript's
// safe integer range, so its Agent transports TIMESTAMP cells as
// decimal strings. Keep those strings as numeric SQL literals.
return timestamp.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 is_postgres_binary_literal_column(database_type, column_info) {
if let Some(literal) = format_postgres_binary_literal_text(&text) {
return literal;
}
}
if is_oracle_raw_literal_column(database_type, column_info) {
if let Some(literal) = format_oracle_raw_literal_text(&text) {
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_with_identifier_quote(
&typed_value,
database_type,
column_info,
identifier_quote,
);
}
}
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
};
if database_type == Some(DatabaseType::Postgres) && literal_text.contains('\\') {
// Escape strings have stable backslash semantics regardless of the
// session's standard_conforming_strings setting.
let escaped_text = literal_text.replace('\\', "\\\\").replace('\'', "''");
return format!("E'{escaped_text}'");
}
if database_type == Some(DatabaseType::SqlServer) {
return format_sqlserver_unicode_literal(&literal_text);
}
let escaped_text = if database_type == Some(DatabaseType::Neo4j) {
literal_text.replace('\\', "\\\\").replace('\'', "\\'")
} else if is_sqlite_literal_database(database_type)
|| matches!(database_type, Some(DatabaseType::Dameng | DatabaseType::Oracle | DatabaseType::OceanbaseOracle))
{
// These engines keep backslashes literal in ordinary string literals,
// so only the quote delimiter needs escaping.
literal_text.replace('\'', "''")
} else {
literal_text.replace('\\', "\\\\").replace('\'', "''")
};
let escaped = format!("'{escaped_text}'");
escaped
}
fn postgres_json_array_element_type(
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
) -> Option<&'static str> {
if database_type != Some(DatabaseType::Postgres) {
return None;
}
match column_info?.data_type.trim().to_ascii_lowercase().as_str() {
"json[]" | "_json" => Some("json"),
"jsonb[]" | "_jsonb" => Some("jsonb"),
_ => None,
}
}
fn format_postgres_json_array_sql_literal(arr: &[Value], element_type: &str) -> String {
if arr.is_empty() {
return format!("ARRAY[]::{element_type}[]");
}
let elements = arr
.iter()
.map(|value| {
if value.is_null() {
return "NULL".to_string();
}
let json = match value {
Value::String(text) => serde_json::from_str::<Value>(text)
.map(|value| value.to_string())
.unwrap_or_else(|_| Value::String(text.clone()).to_string()),
_ => value.to_string(),
};
let escaped = json.replace('\\', "\\\\").replace('\'', "''");
format!("E'{escaped}'::{element_type}")
})
.collect::<Vec<_>>()
.join(", ");
format!("ARRAY[{elements}]")
}
fn format_sqlserver_unicode_literal(text: &str) -> String {
let mut parts = Vec::new();
let mut segment = String::new();
for ch in text.chars() {
let line_break = match ch {
'\r' => Some(13),
'\n' => Some(10),
_ => None,
};
if let Some(codepoint) = line_break {
if !segment.is_empty() {
parts.push(format!("N'{}'", segment.replace('\'', "''")));
segment.clear();
}
// Keep physical newlines out of generated SQL so a preceding
// backslash cannot be consumed as a line-continuation marker.
parts.push(format!("NCHAR({codepoint})"));
} else {
segment.push(ch);
}
}
if !segment.is_empty() {
parts.push(format!("N'{}'", segment.replace('\'', "''")));
}
if parts.is_empty() {
"N''".to_string()
} else {
parts.join(" + ")
}
}
fn is_sqlite_literal_database(database_type: Option<DatabaseType>) -> bool {
matches!(
database_type,
Some(DatabaseType::Sqlite | DatabaseType::Rqlite | DatabaseType::Turso | DatabaseType::CloudflareD1)
)
}
fn format_grid_save_sql_literal(
value: &Value,
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
identifier_quote: Option<&str>,
) -> String {
if empty_string_saves_as_null(value, column_info) {
"NULL".to_string()
} else {
format_grid_assignment_sql_literal(value, database_type, column_info, identifier_quote)
}
}
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>,
identifier_quote: Option<&str>,
) -> bool {
(is_mysql_datetime_literal_database(database_type)
|| (database_type == Some(DatabaseType::Kingbase) && identifier_quote == Some("`")))
&& column_info.map(|column| is_bit_column_type(&column.data_type)).unwrap_or(false)
}
fn is_kingbase_bit_literal_column(
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
) -> bool {
database_type == Some(DatabaseType::Kingbase)
&& 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_postgres_binary_literal_column(
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
) -> bool {
database_type == Some(DatabaseType::Postgres)
&& column_info.is_some_and(|column| column.data_type.trim().eq_ignore_ascii_case("bytea"))
}
fn format_postgres_binary_literal_text(text: &str) -> Option<String> {
let trimmed = text.trim();
let hex = trimmed.strip_prefix("0x").or_else(|| trimmed.strip_prefix("0X"))?;
if hex.len() % 2 == 0 && hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
Some(format!("decode('{hex}', 'hex')"))
} else {
None
}
}
fn is_oracle_raw_literal_column(database_type: Option<DatabaseType>, column_info: Option<&DataGridColumnInfo>) -> bool {
is_oracle_temporal_literal_database(database_type)
&& column_info.map(|column| is_oracle_raw_column_type(&column.data_type)).unwrap_or(false)
}
fn is_oracle_raw_column_type(data_type: &str) -> bool {
data_type.trim().split(['(', ':', ' ']).next().is_some_and(|base| base.eq_ignore_ascii_case("raw"))
}
fn format_oracle_raw_literal_text(text: &str) -> Option<String> {
let hex = text.strip_prefix("0x")?;
if hex.len() % 2 == 0 && hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
Some(format!("HEXTORAW('{hex}')"))
} 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 format_kingbase_bit_literal_text(text: &str) -> Option<String> {
let trimmed = text.trim();
if trimmed.eq_ignore_ascii_case("true") {
return Some("b'1'".to_string());
}
if trimmed.eq_ignore_ascii_case("false") {
return Some("b'0'".to_string());
}
if !trimmed.is_empty() && trimmed.chars().all(|ch| matches!(ch, '0' | '1')) {
return Some(format!("b'{trimmed}'"));
}
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],
identifier_quote: Option<&str>,
) -> String {
if primary_keys.is_empty() && uses_keyless_row_predicate(database_type) {
return build_row_where(database_type, columns, row, column_info, identifier_quote);
}
primary_keys
.iter()
.map(|primary_key| {
let value = row
.get(find_column_index(database_type, columns, primary_key).unwrap_or(usize::MAX))
.unwrap_or(&Value::Null);
build_column_predicate(
database_type,
primary_key,
value,
column_info_for(column_info, primary_key),
false,
identifier_quote,
)
})
.collect::<Vec<_>>()
.join(" AND ")
}
fn build_row_where(
database_type: Option<DatabaseType>,
columns: &[Option<String>],
row: &[Value],
column_info: &[DataGridColumnInfo],
identifier_quote: Option<&str>,
) -> String {
columns
.iter()
.enumerate()
.filter_map(|(index, column)| {
let column = column.as_deref()?;
if is_synthetic_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,
identifier_quote,
))
})
.collect::<Vec<_>>()
.join(" AND ")
}
fn build_save_row_where(
database_type: Option<DatabaseType>,
columns: &[Option<String>],
row: &[Value],
column_info: &[DataGridColumnInfo],
identifier_quote: Option<&str>,
) -> String {
columns
.iter()
.enumerate()
.filter_map(|(index, column)| {
let column = column.as_deref()?;
if is_synthetic_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,
identifier_quote,
))
})
.collect::<Vec<_>>()
.join(" AND ")
}
pub fn build_column_predicate(
database_type: Option<DatabaseType>,
column: &str,
value: &Value,
column_info: Option<&DataGridColumnInfo>,
use_binary_text_comparison: bool,
identifier_quote: Option<&str>,
) -> String {
let ident = predicate_ident(database_type, column, identifier_quote);
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_assignment_sql_literal(value, database_type, column_info, identifier_quote)
)
} else {
let literal = format_grid_assignment_sql_literal(value, database_type, column_info, identifier_quote);
if use_binary_text_comparison {
if let Some(predicate) = postgres_keyless_json_predicate(database_type, &ident, &literal, column_info) {
return predicate;
}
}
format!("{ident} = {}", mysql_json_predicate_literal(literal, 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,
identifier_quote: Option<&str>,
) -> String {
let ident = predicate_ident(database_type, column, identifier_quote);
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, identifier_quote)
)
} else {
let literal = format_grid_save_sql_literal(value, database_type, column_info, identifier_quote);
if use_binary_text_comparison {
if let Some(predicate) = postgres_keyless_json_predicate(database_type, &ident, &literal, column_info) {
return predicate;
}
}
format!("{ident} = {}", mysql_json_predicate_literal(literal, database_type, column_info))
}
}
fn postgres_keyless_json_predicate(
database_type: Option<DatabaseType>,
ident: &str,
literal: &str,
column_info: Option<&DataGridColumnInfo>,
) -> Option<String> {
if !is_postgres_like_pattern_database(database_type) {
return None;
}
let normalized = column_info?.data_type.trim().to_ascii_lowercase();
let data_type = normalized.rsplit('.').next().unwrap_or(&normalized).trim_matches('"');
match data_type {
"json" => Some(format!("{ident}::text = {literal}::text")),
"jsonb" => Some(format!("{ident} = {literal}::jsonb")),
_ => None,
}
}
fn mysql_json_predicate_literal(
literal: String,
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
) -> String {
if database_type == Some(DatabaseType::Mysql)
&& column_info.is_some_and(|column| column.data_type.trim().eq_ignore_ascii_case("json"))
{
format!("CAST({literal} AS JSON)")
} else {
literal
}
}
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_lob_type(data_type: &str) -> bool {
let lower = data_type.trim().trim_matches('"').to_ascii_lowercase();
let base = lower.split(['(', ':', ' ']).next().unwrap_or("");
matches!(base, "blob" | "clob" | "nclob" | "bfile" | "lob")
|| lower.starts_with("binary large object")
|| lower.starts_with("character large object")
}
fn oracle_character_lob_constructor(data_type: &str) -> Option<&'static str> {
let lower = data_type.trim().trim_matches('"').to_ascii_lowercase();
let base = lower.split(['(', ':', ' ']).next().unwrap_or("");
match base {
"clob" => Some("TO_CLOB"),
"nclob" => Some("TO_NCLOB"),
_ if lower.starts_with("character large object") => Some("TO_CLOB"),
_ => None,
}
}
fn is_synthetic_row_id(database_type: Option<DatabaseType>, name: Option<&str>) -> bool {
uses_synthetic_row_id(database_type) && name.is_some_and(|name| name.eq_ignore_ascii_case(DBX_ROWID_COLUMN))
}
pub 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)
}
pub fn extra_is_auto_generated(extra: &str) -> bool {
extra.to_ascii_lowercase().split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_').any(|part| {
matches!(part, "auto_increment" | "autoincrement" | "identity" | "smallserial" | "serial" | "bigserial")
})
}
pub fn is_auto_generated_column(column: &DataGridColumnInfo) -> bool {
extra_is_auto_generated(column.extra.as_deref().unwrap_or(""))
|| column.column_default.as_deref().is_some_and(|default| default.to_ascii_lowercase().contains("nextval("))
}
fn grid_value_is_empty(value: &Value) -> bool {
value.is_null() || value.as_str().is_some_and(str::is_empty)
}
pub fn is_grid_insert_omitted_column(
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
name: Option<&str>,
include_computed_columns: bool,
) -> bool {
is_synthetic_row_id(database_type, name)
|| is_postgres_tsvector_column(database_type, column_info)
|| (!include_computed_columns && 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_synthetic_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")
}
pub 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("virtual generated") || extra.contains("stored generated"))
&& !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_synthetic_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(database_type: Option<DatabaseType>, columns: &[Option<String>], target: &str) -> Option<usize> {
if let Some(index) = columns.iter().position(|column| column.as_deref() == Some(target)) {
return Some(index);
}
// PostgreSQL can have distinct `id` and quoted `"ID"` columns. Only
// dialects whose result metadata is known to drift in case may fall back,
// and even then a case-only match must be unique.
if !matches!(
database_type,
Some(DatabaseType::Goldendb | DatabaseType::Kingbase | DatabaseType::Tdengine | DatabaseType::Hive)
) {
return None;
}
let normalized_target = normalize_column_name(target);
let mut matches = columns.iter().enumerate().filter_map(|(index, column)| {
(column.as_deref().map(normalize_column_name).unwrap_or_default() == normalized_target).then_some(index)
});
let first = matches.next()?;
matches.next().is_none().then_some(first)
}
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, identifier_quote: Option<&str>) -> String {
if is_synthetic_row_id(database_type, Some(name)) {
if uses_xugu_row_id(database_type) {
return "ROWID".to_string();
}
"ROWIDTOCHAR(ROWID)".to_string()
} else {
data_grid_identifier(database_type, name, identifier_quote)
}
}
pub fn quote_ident(database_type: Option<DatabaseType>, name: &str) -> String {
quote_table_identifier(database_type, name)
}
pub 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 data_grid_identifier(database_type: Option<DatabaseType>, name: &str, identifier_quote: Option<&str>) -> String {
if database_type == Some(DatabaseType::Iris) {
return crate::sql_dialect::quote_iris_identifier(name, identifier_quote);
}
crate::sql_dialect::quote_table_data_identifier(database_type, name, identifier_quote)
}
/// Table reference for every generated data-grid SQL surface that honors the
/// `生成 SQL 时包含数据库名` setting: save / rollback / keyless-guard statements
/// and the copy-as-INSERT/UPDATE/SELECT statements.
///
/// With the setting on, engines whose active database is normally omitted get a
/// fully qualified name (`database.table`, or `database.schema.table` for SQL
/// Server, whose tables stay addressable across databases). Every other engine —
/// and the setting off — keeps the historical shape, so no existing SQL changes
/// shape unless the user opted in.
#[allow(clippy::too_many_arguments)]
pub fn data_grid_generated_table_name(
database_type: Option<DatabaseType>,
catalog: Option<&str>,
schema: Option<&str>,
database: Option<&str>,
table_name: &str,
identifier_quote: Option<&str>,
include_database_name: bool,
) -> String {
if include_database_name {
if let Some(qualified) =
crate::sql_dialect::database_qualified_table_name(database_type, catalog, schema, database, table_name)
{
return qualified;
}
}
data_grid_qualified_table_name(database_type, catalog, schema, database, table_name, identifier_quote)
}
/// Table reference for the grid's save / rollback / keyless-guard statements.
///
/// Mirrors the data-table SELECT label and the copy-as-INSERT statements: when
/// `include_database_name` is on, engines addressed via `database.table`
/// (MySQL family, ClickHouse) get the database prefix — taken from
/// `table_meta.schema` when the table lives outside the connection's default
/// database, otherwise from `table_meta.database` — and SQL Server gets the
/// three-part `database.schema.table` form. Every other engine keeps the
/// historical shape.
fn data_grid_save_table_name(options: &DataGridSaveStatementOptions, schema: Option<&str>) -> String {
data_grid_generated_table_name(
options.database_type,
options.table_meta.catalog.as_deref(),
schema,
options.table_meta.database.as_deref(),
&options.table_meta.table_name,
options.identifier_quote.as_deref(),
options.include_database_name,
)
}
pub fn data_grid_qualified_table_name(
database_type: Option<DatabaseType>,
catalog: Option<&str>,
schema: Option<&str>,
database: Option<&str>,
table_name: &str,
identifier_quote: Option<&str>,
) -> String {
if database_type == Some(DatabaseType::Iris) {
let table = crate::sql_dialect::quote_iris_identifier(table_name, identifier_quote);
return schema
.map(str::trim)
.filter(|schema| !schema.is_empty())
.map(|schema| format!("{}.{table}", crate::sql_dialect::quote_iris_identifier(schema, identifier_quote)))
.unwrap_or(table);
}
if crate::sql_dialect::uses_connection_identifier_quote(database_type, identifier_quote) {
crate::sql_dialect::table_data_qualified_table_name(database_type, schema, table_name, identifier_quote)
} else {
crate::sql_dialect::qualified_table_name_with_catalog(database_type, catalog, schema, database, table_name)
}
}
fn column_filter_ref(database_type: Option<DatabaseType>, column_name: &str, identifier_quote: Option<&str>) -> String {
let quoted = predicate_ident(database_type, column_name, identifier_quote);
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>,
identifier_quote: Option<&str>,
) -> String {
let column = column_filter_ref(database_type, column_name, identifier_quote);
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::Uxdb
| 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();
const NUMERIC_TOKENS: &[&str] = &[
"int",
"integer",
"bigint",
"smallint",
"tinyint",
"mediumint",
"serial",
"number",
"numeric",
"decimal",
"float",
"double",
"real",
"money",
];
lower
.split(|ch: char| !ch.is_ascii_alphanumeric())
.any(|token| NUMERIC_TOKENS.contains(&token) || is_sized_numeric_token(token))
}
// Databases like Cloud Spanner (GoogleSQL) and ClickHouse spell their numeric
// types with an explicit bit width, e.g. INT64/FLOAT64/FLOAT32 or
// Int8/UInt64/Float64. Recognizing `int`/`uint`/`float` followed by digits keeps
// these as numeric SQL literals so filter values are not quoted as strings,
// which strongly typed engines reject (INT64 = STRING has no matching operator).
// `interval` and similar names stay excluded because they are not `int`+digits.
fn is_sized_numeric_token(token: &str) -> bool {
for prefix in ["int", "uint", "float"] {
if let Some(width) = token.strip_prefix(prefix) {
if !width.is_empty() && width.bytes().all(|b| b.is_ascii_digit()) {
return true;
}
}
}
false
}
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::Uxdb
| 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 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),
}
}
fn mysql_people_save_options(row_count: usize) -> DataGridSaveStatementOptions {
DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("app".to_string()),
table_name: "people".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "bigint", false, None), column("status", "varchar(32)", true, None)]),
},
columns: vec!["id".to_string(), "status".to_string()],
source_columns: None,
rows: (1..=row_count).map(|id| vec![json!(id), json!("active")]).collect(),
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
}
}
/// A MySQL grid tab keeps its namespace in `table_meta.database` (not
/// `schema`), so the save statements are the one generated-SQL surface that
/// never picked up `生成 SQL 时包含数据库名`. It must match the data-table
/// SELECT label and the copy-as-INSERT statements.
#[test]
fn mysql_data_grid_save_honors_include_database_name() {
let mut options = mysql_people_save_options(1);
options.table_meta.database = Some("appdb".to_string());
options.table_meta.schema = None;
options.dirty_rows = vec![(0, vec![(1, json!("blocked"))])];
options.include_database_name = true;
let qualified = prepare_data_grid_save(options.clone());
assert_eq!(qualified.validation_error, None);
assert_eq!(qualified.statements, vec!["UPDATE `appdb`.`people` SET `status` = 'blocked' WHERE `id` = 1;"]);
assert_eq!(
qualified.rollback_statements,
vec!["UPDATE `appdb`.`people` SET `status` = 'active' WHERE `id` = 1 AND BINARY `status` = 'blocked';"]
);
options.include_database_name = false;
let bare = prepare_data_grid_save(options);
assert_eq!(bare.statements, vec!["UPDATE `people` SET `status` = 'blocked' WHERE `id` = 1;"]);
assert_eq!(
bare.rollback_statements,
vec!["UPDATE `people` SET `status` = 'active' WHERE `id` = 1 AND BINARY `status` = 'blocked';"]
);
}
#[test]
fn mysql_data_grid_save_qualifies_insert_delete_and_keyless_guard() {
let mut options = mysql_people_save_options(0);
options.table_meta.database = Some("appdb".to_string());
options.table_meta.schema = None;
options.include_database_name = true;
options.new_rows = vec![vec![json!(7), json!("created")]];
options.deleted_rows = vec![];
let inserted = prepare_data_grid_save(options.clone());
assert_eq!(inserted.statements, vec!["INSERT INTO `appdb`.`people` (`id`, `status`) VALUES (7, 'created');"]);
let mut keyless = options;
keyless.new_rows = vec![];
keyless.table_meta.primary_keys = vec![];
keyless.deleted_rows = vec![0];
let deleted = prepare_data_grid_save(keyless);
assert!(
deleted.statements.iter().all(|statement| statement.contains("`appdb`.`people`")),
"every statement must carry the database qualifier: {:?}",
deleted.statements
);
assert!(
deleted.keyless_guards.iter().all(|guard| guard.sql.contains("`appdb`.`people`")),
"the keyless guard counts rows in the same table reference: {:?}",
deleted.keyless_guards
);
}
/// Engines that already address tables through `schema.table` keep their
/// shape — the flag only adds the namespace those dialects cannot express.
#[test]
fn data_grid_save_leaves_schema_qualified_engines_unchanged() {
let mut options = mysql_people_save_options(1);
options.database_type = Some(DatabaseType::Postgres);
options.identifier_quote = None;
options.dirty_rows = vec![(0, vec![(1, json!("blocked"))])];
options.include_database_name = true;
let result = prepare_data_grid_save(options);
assert_eq!(result.statements, vec!["UPDATE \"app\".\"people\" SET \"status\" = 'blocked' WHERE \"id\" = 1;"]);
}
/// A cross-database editable result (`SELECT * FROM db_9.users`) keeps its own
/// namespace in `schema` while `database` still holds the connection's default
/// database — the qualifier must follow the table, not the connection.
#[test]
fn mysql_data_grid_save_prefers_cross_database_schema() {
let mut options = mysql_people_save_options(1);
options.table_meta.schema = Some("db_9".to_string());
options.table_meta.database = Some("appdb".to_string());
options.dirty_rows = vec![(0, vec![(1, json!("blocked"))])];
options.include_database_name = true;
let result = prepare_data_grid_save(options);
assert_eq!(result.statements, vec!["UPDATE `db_9`.`people` SET `status` = 'blocked' WHERE `id` = 1;"]);
assert_eq!(result.execution_schema.as_deref(), Some("db_9"));
}
/// issue #9262: SQL Server tables are addressable as
/// `database.schema.table`, so the setting must reach the three-part form on
/// the save / rollback / keyless-guard statements too.
#[test]
fn sqlserver_data_grid_save_honors_include_database_name() {
let mut options = mysql_people_save_options(1);
options.database_type = Some(DatabaseType::SqlServer);
options.table_meta.schema = Some("dbo".to_string());
options.table_meta.database = Some("dbx".to_string());
options.dirty_rows = vec![(0, vec![(1, json!("blocked"))])];
options.include_database_name = true;
let qualified = prepare_data_grid_save(options.clone());
assert_eq!(qualified.validation_error, None);
assert_eq!(qualified.statements, vec!["UPDATE [dbx].[dbo].[people] SET [status] = N'blocked' WHERE [id] = 1;"]);
assert_eq!(
qualified.rollback_statements,
vec!["UPDATE [dbx].[dbo].[people] SET [status] = N'active' WHERE [id] = 1 AND [status] = N'blocked';"]
);
options.include_database_name = false;
let bare = prepare_data_grid_save(options);
assert_eq!(bare.statements, vec!["UPDATE [dbo].[people] SET [status] = N'blocked' WHERE [id] = 1;"]);
}
#[test]
fn sqlserver_data_grid_save_qualifies_insert_and_keyless_guard() {
let mut options = mysql_people_save_options(0);
options.database_type = Some(DatabaseType::SqlServer);
options.table_meta.schema = Some("dbo".to_string());
options.table_meta.database = Some("dbx".to_string());
options.include_database_name = true;
options.new_rows = vec![vec![json!(7), json!("created")]];
let inserted = prepare_data_grid_save(options.clone());
assert_eq!(
inserted.statements,
vec!["INSERT INTO [dbx].[dbo].[people] ([id], [status]) VALUES (7, N'created');"]
);
let mut keyless = options;
keyless.new_rows = vec![];
keyless.table_meta.primary_keys = vec![];
keyless.deleted_rows = vec![0];
let deleted = prepare_data_grid_save(keyless);
assert!(
deleted.statements.iter().all(|statement| statement.contains("[dbx].[dbo].[people]")),
"every statement must carry the three-part name: {:?}",
deleted.statements
);
assert!(
deleted.keyless_guards.iter().all(|guard| guard.sql.contains("[dbx].[dbo].[people]")),
"the keyless guard counts rows in the same table reference: {:?}",
deleted.keyless_guards
);
}
#[test]
fn iris_data_grid_count_queries_the_table_without_wrapping_top_sql() {
assert_eq!(
build_data_grid_count_sql(DataGridCountSqlOptions {
database_type: Some(DatabaseType::Iris),
identifier_quote: Some("\"".to_string()),
catalog: None,
database: None,
schema: Some("SS".to_string()),
table_name: "SS_User".to_string(),
where_input: Some("SSUSR_IsActive = 'Y'".to_string()),
count_hint: None,
}),
"SELECT COUNT(*) AS cnt FROM \"SS\".\"SS_User\" WHERE (SSUSR_IsActive = 'Y')"
);
}
#[test]
fn mysql_conditional_update_uses_typed_value_and_requires_a_writable_column_and_where() {
let options = DataGridConditionalUpdateSqlOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("app".to_string()),
table_name: "people".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "bigint", false, None), column("status", "varchar(32)", true, None)]),
},
column_name: "status".to_string(),
value: json!("O'Reilly"),
where_input: "WHERE tenant_id = 7;".to_string(),
};
assert_eq!(
build_data_grid_conditional_update_sql(options.clone()),
Some("UPDATE `app`.`people` SET `status` = 'O''Reilly' WHERE (tenant_id = 7);".to_string())
);
let mut condition_only = options.clone();
condition_only.where_input = "tenant_id = 7".to_string();
assert_eq!(
build_data_grid_conditional_update_sql(condition_only),
Some("UPDATE `app`.`people` SET `status` = 'O''Reilly' WHERE (tenant_id = 7);".to_string())
);
let mut empty_where = options.clone();
empty_where.where_input = " ".to_string();
assert_eq!(build_data_grid_conditional_update_sql(empty_where), None);
let mut primary_key = options;
primary_key.column_name = "id".to_string();
assert_eq!(build_data_grid_conditional_update_sql(primary_key), None);
}
#[test]
fn iris_cache_data_grid_save_uses_unquoted_ordinary_identifiers() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Iris),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("SQLUser".to_string()),
table_name: "PA_PatMas".to_string(),
primary_keys: vec!["PAPMI_RowId".to_string()],
columns: Some(vec![
column("PAPMI_RowId", "integer", false, None),
column("PAPMI_ID", "varchar(32)", true, None),
]),
},
columns: vec!["PAPMI_RowId".to_string(), "PAPMI_ID".to_string()],
source_columns: None,
rows: vec![vec![json!(51), json!("before")]],
dirty_rows: vec![(0, vec![(1, json!("210101202201016555"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(
result.statements,
vec!["UPDATE SQLUser.PA_PatMas SET PAPMI_ID = '210101202201016555' WHERE PAPMI_RowId = 51;"]
);
assert_eq!(
result.rollback_statements,
vec![
"UPDATE SQLUser.PA_PatMas SET PAPMI_ID = 'before' WHERE PAPMI_RowId = 51 AND PAPMI_ID = '210101202201016555';"
]
);
}
#[test]
fn iris_data_grid_save_quotes_only_delimited_identifiers_across_mutations() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Iris),
identifier_quote: Some("\"".to_string()),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("App Schema".to_string()),
table_name: "Patient Record".to_string(),
primary_keys: vec!["Row ID".to_string()],
columns: Some(vec![
column("Row ID", "integer", false, None),
column("PAPMI_ID", "varchar(32)", true, None),
]),
},
columns: vec!["Row ID".to_string(), "PAPMI_ID".to_string()],
source_columns: None,
rows: vec![vec![json!(51), json!("deleted")]],
dirty_rows: vec![],
deleted_rows: vec![0],
new_rows: vec![vec![json!(52), json!("inserted")]],
include_database_name: false,
});
assert_eq!(
result.statements,
vec![
"DELETE FROM \"App Schema\".\"Patient Record\" WHERE \"Row ID\" = 51;",
"INSERT INTO \"App Schema\".\"Patient Record\" (\"Row ID\", PAPMI_ID) VALUES (52, 'inserted');",
]
);
assert_eq!(
result.rollback_statements,
vec![
"DELETE FROM \"App Schema\".\"Patient Record\" WHERE \"Row ID\" = 52 AND PAPMI_ID = 'inserted';",
"INSERT INTO \"App Schema\".\"Patient Record\" (\"Row ID\", PAPMI_ID) VALUES (51, 'deleted');",
]
);
}
#[test]
fn postgres_keyless_update_preserves_jsonb_array_elements() {
let endpoints =
json!([r#"{"port":10031,"type":"admin_web"}"#, r#""quoted""#, r#"[1,true,{"nested":null}]"#, null]);
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("public".to_string()),
table_name: "services".to_string(),
primary_keys: vec![],
columns: Some(vec![
column("id", "integer", false, None),
column("name", "text", false, None),
column("endpoints", "jsonb[]", true, None),
]),
},
columns: vec!["id".to_string(), "name".to_string(), "endpoints".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("before"), endpoints]],
dirty_rows: vec![(0, vec![(1, json!("after"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements.len(), 1);
let statement = &result.statements[0];
assert!(statement.starts_with("UPDATE \"public\".\"services\" SET \"name\" = 'after' WHERE "));
assert!(statement.contains("\"endpoints\" = ARRAY["));
assert!(statement.contains("admin_web"));
assert!(statement.contains(r#"E'"quoted"'::jsonb"#), "{statement}");
assert!(statement.contains("NULL]"));
assert!(!statement.contains('\u{1}'));
}
#[test]
fn postgres_keyless_update_casts_json_predicates() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("public".to_string()),
table_name: "profiles".to_string(),
primary_keys: vec![],
columns: Some(vec![
column("user_id", "uuid", false, None),
column("status", "text", false, None),
column("photo", "json", true, None),
column("extend_list", "jsonb", true, None),
]),
},
columns: vec!["user_id".to_string(), "status".to_string(), "photo".to_string(), "extend_list".to_string()],
source_columns: None,
rows: vec![vec![
json!("d7910cef-4188-4309-99ce-8e7b64d64869"),
json!("0"),
json!("[]"),
json!(r#"[{"type":"combobox","key":"gender","value":"C017MALE"}]"#),
]],
dirty_rows: vec![(0, vec![(1, json!("1"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements.len(), 1);
let statement = &result.statements[0];
assert!(statement.contains(r#""photo"::text = '[]'::text"#), "{statement}");
assert!(
statement.contains(r#""extend_list" = '[{"type":"combobox","key":"gender","value":"C017MALE"}]'::jsonb"#),
"{statement}"
);
}
#[test]
fn postgres_json_array_literals_preserve_json_documents_and_nulls() {
let value = json!([r#"{"object":true}"#, r#""text""#, "[1,2]", "plain text", "null", null]);
let json_column = column("payload", "json[]", true, None);
let jsonb_column = column("payload", "jsonb[]", true, None);
let text_column = column("payload", "text[]", true, None);
let integer_column = column("payload", "integer[]", true, None);
assert_eq!(
format_grid_sql_literal(&value, Some(DatabaseType::Postgres), Some(&json_column)),
r#"ARRAY[E'{"object":true}'::json, E'"text"'::json, E'[1,2]'::json, E'"plain text"'::json, E'null'::json, NULL]"#
);
assert_eq!(
format_grid_sql_literal(&value, Some(DatabaseType::Postgres), Some(&jsonb_column)),
r#"ARRAY[E'{"object":true}'::jsonb, E'"text"'::jsonb, E'[1,2]'::jsonb, E'"plain text"'::jsonb, E'null'::jsonb, NULL]"#
);
assert_eq!(
format_grid_sql_literal(&json!([]), Some(DatabaseType::Postgres), Some(&jsonb_column)),
"ARRAY[]::jsonb[]"
);
assert_eq!(
format_grid_sql_literal(
&json!(["first", null, "second"]),
Some(DatabaseType::Postgres),
Some(&text_column)
),
r#"'{"first",NULL,"second"}'"#
);
assert_eq!(
format_grid_sql_literal(&json!([1, null, 2]), Some(DatabaseType::Postgres), Some(&integer_column)),
"'{1,NULL,2}'"
);
}
#[test]
fn builds_copy_update_statements() {
let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: None,
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")]],
include_database_name: false,
});
assert_eq!(
statements,
vec!["UPDATE \"public\".\"users\" SET \"name\" = 'Ada', \"status\" = 'active' WHERE \"id\" = 1;"]
);
}
/// The right-click `复制 → SQL UPDATE 语句` path must honor
/// `生成 SQL 时包含数据库名` exactly like the copy-as-INSERT statements do
/// (issue #9262).
#[test]
fn copy_update_honors_include_database_name() {
let options = DataGridCopyUpdateStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: Some("appdb".to_string()),
schema: None,
table_name: "people".to_string(),
primary_keys: vec!["id".to_string()],
columns: None,
},
columns: vec!["id".to_string(), "status".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("blocked")]],
include_database_name: true,
};
assert_eq!(
build_data_grid_copy_update_statements(options.clone()),
vec!["UPDATE `appdb`.`people` SET `status` = 'blocked' WHERE `id` = 1;"]
);
// The frontend strips the metadata when the setting is off, but the
// option itself must also fall back to the historical bare shape.
assert_eq!(
build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions {
include_database_name: false,
..options
}),
vec!["UPDATE `people` SET `status` = 'blocked' WHERE `id` = 1;"]
);
}
#[test]
fn sqlserver_copy_update_uses_three_part_name() {
let options = DataGridCopyUpdateStatementOptions {
database_type: Some(DatabaseType::SqlServer),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: Some("dbx".to_string()),
schema: Some("dbo".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()],
source_columns: None,
rows: vec![vec![json!(42), json!("ready")]],
include_database_name: true,
};
assert_eq!(
build_data_grid_copy_update_statements(options),
vec!["UPDATE [dbx].[dbo].[player states] SET [state] = N'ready' WHERE [role id] = 42;"]
);
}
#[test]
fn builds_copy_insert_statement_without_primary_keys() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: Some(DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "users".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![
DataGridColumnInfo {
name: "id".to_string(),
data_type: "bigint".to_string(),
is_nullable: false,
is_primary_key: true,
column_default: None,
extra: Some("auto_increment".to_string()),
},
DataGridColumnInfo {
name: "login_name".to_string(),
data_type: "varchar(64)".to_string(),
is_nullable: false,
is_primary_key: false,
column_default: None,
extra: None,
},
DataGridColumnInfo {
name: "display_name".to_string(),
data_type: "varchar(64)".to_string(),
is_nullable: true,
is_primary_key: false,
column_default: None,
extra: 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,
include_computed_columns: false,
include_database_name: 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 recognizes_postgres_serial_extras_as_auto_generated() {
for extra in ["serial", "smallserial", "bigserial"] {
assert!(extra_is_auto_generated(extra), "expected {extra} to be auto-generated");
}
}
#[test]
fn builds_postgres_copy_insert_without_serial_primary_key() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: None,
table_meta: Some(DataGridTableMeta {
catalog: None,
database: None,
schema: Some("public".to_string()),
table_name: "users".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![
DataGridColumnInfo {
name: "id".to_string(),
data_type: "integer".to_string(),
is_nullable: false,
is_primary_key: true,
column_default: Some("nextval('public.users_id_seq'::regclass)".to_string()),
extra: None,
},
DataGridColumnInfo {
name: "name".to_string(),
data_type: "text".to_string(),
is_nullable: false,
is_primary_key: false,
column_default: None,
extra: None,
},
]),
}),
columns: vec!["id".to_string(), "name".to_string()],
column_types: None,
source_columns: None,
rows: vec![vec![json!(1), json!("Ada")]],
exclude_primary_keys: true,
include_computed_columns: false,
include_database_name: true,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(statement.as_deref(), Some("INSERT INTO \"public\".\"users\" (\"name\") VALUES ('Ada');"));
}
#[test]
fn copy_insert_uses_display_columns_but_source_columns_for_metadata() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: Some(DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "psn_basic_info".to_string(),
primary_keys: vec![],
columns: Some(vec![
DataGridColumnInfo {
name: "PSN_NO".to_string(),
data_type: "varchar(32)".to_string(),
is_nullable: false,
is_primary_key: false,
column_default: None,
extra: None,
},
DataGridColumnInfo {
name: "NAME".to_string(),
data_type: "varchar(32)".to_string(),
is_nullable: true,
is_primary_key: false,
column_default: None,
extra: None,
},
]),
}),
columns: vec!["psn".to_string(), "psn_no".to_string(), "name".to_string()],
column_types: None,
source_columns: Some(vec![
Some("PSN_NO".to_string()),
Some("PSN_NO".to_string()),
Some("NAME".to_string()),
]),
rows: vec![vec![json!("A-1"), json!("A-1"), json!("Ada")]],
exclude_primary_keys: false,
include_computed_columns: false,
include_database_name: true,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(
statement.as_deref(),
Some("INSERT INTO `psn_basic_info` (`psn`, `psn_no`, `name`) VALUES ('A-1', 'A-1', 'Ada');")
);
}
#[test]
fn copy_insert_uses_source_columns_for_generated_column_exclusions() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: Some(DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "users".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![
DataGridColumnInfo {
name: "id".to_string(),
data_type: "bigint".to_string(),
is_nullable: false,
is_primary_key: true,
column_default: None,
extra: Some("auto_increment".to_string()),
},
DataGridColumnInfo {
name: "display_name".to_string(),
data_type: "varchar(64)".to_string(),
is_nullable: true,
is_primary_key: false,
column_default: None,
extra: None,
},
DataGridColumnInfo {
name: "display_name_upper".to_string(),
data_type: "varchar(64)".to_string(),
is_nullable: true,
is_primary_key: false,
column_default: None,
extra: Some("virtual generated".to_string()),
},
]),
}),
columns: vec!["identifier".to_string(), "label".to_string(), "label_upper".to_string()],
column_types: None,
source_columns: Some(vec![
Some("id".to_string()),
Some("display_name".to_string()),
Some("display_name_upper".to_string()),
]),
rows: vec![vec![json!(1), json!("Ada"), json!("ADA")]],
exclude_primary_keys: true,
include_computed_columns: false,
include_database_name: true,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(statement.as_deref(), Some("INSERT INTO `users` (`label`) VALUES ('Ada');"));
}
#[test]
fn copy_update_keeps_source_columns_for_writeback() {
let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "users".to_string(),
primary_keys: vec!["id".to_string()],
columns: None,
},
columns: vec!["identifier".to_string(), "label".to_string()],
source_columns: Some(vec![Some("id".to_string()), Some("display_name".to_string())]),
rows: vec![vec![json!(1), json!("Ada")]],
include_database_name: false,
});
assert_eq!(statements, vec!["UPDATE `users` SET `display_name` = 'Ada' WHERE `id` = 1;"]);
}
#[test]
fn copy_insert_primary_key_exclusion_keeps_manual_composite_key_members() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: Some(DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "move_statistic_product_daily".to_string(),
primary_keys: vec!["id".to_string(), "stat_date".to_string()],
columns: Some(vec![
DataGridColumnInfo {
name: "id".to_string(),
data_type: "bigint unsigned".to_string(),
is_nullable: false,
is_primary_key: true,
column_default: None,
extra: Some("auto_increment".to_string()),
},
DataGridColumnInfo {
name: "stat_date".to_string(),
data_type: "date".to_string(),
is_nullable: false,
is_primary_key: true,
column_default: None,
extra: None,
},
DataGridColumnInfo {
name: "product_name".to_string(),
data_type: "varchar(255)".to_string(),
is_nullable: false,
is_primary_key: false,
column_default: Some("".to_string()),
extra: None,
},
]),
}),
columns: vec!["id".to_string(), "stat_date".to_string(), "product_name".to_string()],
column_types: None,
source_columns: None,
rows: vec![vec![json!(1), json!("2026-08-18"), json!("sweater")]],
exclude_primary_keys: true,
include_computed_columns: false,
include_database_name: true,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(
statement.as_deref(),
Some("INSERT INTO `move_statistic_product_daily` (`stat_date`, `product_name`) VALUES ('2026-08-18', 'sweater');")
);
}
#[test]
fn copy_insert_primary_key_exclusion_keeps_manual_primary_keys() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: None,
table_meta: Some(DataGridTableMeta {
catalog: None,
database: None,
schema: Some("public".to_string()),
table_name: "countries".to_string(),
primary_keys: vec!["code".to_string()],
columns: Some(vec![
DataGridColumnInfo {
name: "code".to_string(),
data_type: "varchar(2)".to_string(),
is_nullable: false,
is_primary_key: true,
column_default: None,
extra: None,
},
DataGridColumnInfo {
name: "label".to_string(),
data_type: "text".to_string(),
is_nullable: false,
is_primary_key: false,
column_default: None,
extra: None,
},
]),
}),
columns: vec!["code".to_string(), "label".to_string()],
column_types: None,
source_columns: None,
rows: vec![vec![json!("AD"), json!("Andorra")]],
exclude_primary_keys: true,
include_computed_columns: false,
include_database_name: true,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(
statement.as_deref(),
Some("INSERT INTO \"public\".\"countries\" (\"code\", \"label\") VALUES ('AD', 'Andorra');")
);
}
#[test]
fn copy_insert_primary_key_exclusion_keeps_unknown_metadata_primary_keys() {
// Without column metadata we cannot prove the key is auto-generated;
// keep it rather than silently dropping NOT NULL data.
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
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()],
column_types: None,
source_columns: None,
rows: vec![vec![json!(1), json!("ada")]],
exclude_primary_keys: true,
include_computed_columns: false,
include_database_name: true,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(statement.as_deref(), Some("INSERT INTO `users` (`id`, `login_name`) VALUES (1, 'ada');"));
}
#[test]
fn copy_insert_keeps_json_cells_as_single_json_literals() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Elasticsearch),
identifier_quote: None,
table_meta: None,
columns: vec!["id".to_string(), "active".to_string(), "profile".to_string()],
column_types: Some(vec![Some("number".to_string()), Some("boolean".to_string()), Some("json".to_string())]),
source_columns: None,
rows: vec![vec![json!(7), json!(true), json!(r#"{"name":"Ada","roles":["admin"]}"#)]],
exclude_primary_keys: false,
include_computed_columns: false,
include_database_name: true,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(
statement.as_deref(),
Some("INSERT INTO table_name (\"id\", \"active\", \"profile\") VALUES (7, TRUE, '{\"name\":\"Ada\",\"roles\":[\"admin\"]}');")
);
}
#[test]
fn copy_insert_keeps_mysql_json_array_as_single_json_literal() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: None,
columns: vec!["data".to_string()],
column_types: Some(vec![Some("json".to_string())]),
source_columns: None,
rows: vec![vec![json!([1, 2, 3])]],
exclude_primary_keys: false,
include_computed_columns: false,
include_database_name: true,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(statement.as_deref(), Some("INSERT INTO table_name (`data`) VALUES ('[1,2,3]');"));
}
#[test]
fn copy_insert_keeps_mysql_json_empty_array_as_single_json_literal() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: None,
columns: vec!["data".to_string()],
column_types: Some(vec![Some("json".to_string())]),
source_columns: None,
rows: vec![vec![json!([])]],
exclude_primary_keys: false,
include_computed_columns: false,
include_database_name: true,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(statement.as_deref(), Some("INSERT INTO table_name (`data`) VALUES ('[]');"));
}
#[test]
fn copy_insert_keeps_mysql_json_object_as_single_json_literal() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: None,
columns: vec!["data".to_string()],
column_types: Some(vec![Some("json".to_string())]),
source_columns: None,
rows: vec![vec![json!({"a": 1})]],
exclude_primary_keys: false,
include_computed_columns: false,
include_database_name: true,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(statement.as_deref(), Some("INSERT INTO table_name (`data`) VALUES ('{\"a\":1}');"));
}
#[test]
fn copy_update_formats_mysql_json_documents_as_compact_strings() {
let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "documents".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "int", false, None), column("payload", "JSON", true, None)]),
},
columns: vec!["id".to_string(), "payload".to_string()],
source_columns: None,
rows: vec![
vec![json!(1), json!([111, 222, 333])],
vec![json!(2), json!({"nested": [1, 2]})],
vec![json!(3), json!([])],
],
include_database_name: false,
});
assert_eq!(
statements,
vec![
"UPDATE `documents` SET `payload` = '[111,222,333]' WHERE `id` = 1;",
"UPDATE `documents` SET `payload` = '{\"nested\":[1,2]}' WHERE `id` = 2;",
"UPDATE `documents` SET `payload` = '[]' WHERE `id` = 3;",
]
);
}
#[test]
fn copy_update_preserves_json_scalars_and_generic_arrays() {
let json_statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "documents".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "int", false, None), column("payload", "json", true, None)]),
},
columns: vec!["id".to_string(), "payload".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!(r#"[1,2]"#)], vec![json!(2), json!(true)], vec![json!(3), Value::Null]],
include_database_name: false,
});
assert_eq!(
json_statements,
vec![
"UPDATE `documents` SET `payload` = '[1,2]' WHERE `id` = 1;",
"UPDATE `documents` SET `payload` = TRUE WHERE `id` = 2;",
"UPDATE `documents` SET `payload` = NULL WHERE `id` = 3;",
]
);
let generic_statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "arrays".to_string(),
primary_keys: vec!["id".to_string()],
columns: None,
},
columns: vec!["id".to_string(), "payload".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!([1, 2])]],
include_database_name: false,
});
assert_eq!(generic_statements, vec!["UPDATE `arrays` SET `payload` = '{1,2}' WHERE `id` = 1;"]);
assert_eq!(
format_grid_sql_literal(
&json!([1, 2]),
Some(DatabaseType::Postgres),
Some(&column("items", "integer[]", true, None)),
),
"'{1,2}'"
);
for database_type in [DatabaseType::ClickHouse, DatabaseType::Databend] {
assert_eq!(format_grid_sql_literal(&json!([1, 2]), Some(database_type), None), "[1,2]");
}
}
#[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),
identifier_quote: None,
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,
include_computed_columns: false,
include_database_name: 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),
identifier_quote: None,
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,
include_computed_columns: false,
include_database_name: true,
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),
identifier_quote: None,
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,
include_computed_columns: false,
include_database_name: true,
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');")
);
}
fn sqlserver_identity_copy_insert_options(
rows: Vec<Vec<Value>>,
insert_mode: DataGridCopyInsertMode,
) -> DataGridCopyInsertStatementOptions {
DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::SqlServer),
identifier_quote: None,
table_meta: Some(DataGridTableMeta {
catalog: None,
database: Some("dbx_test".to_string()),
schema: Some("dbo".to_string()),
table_name: "gen_table".to_string(),
primary_keys: vec!["table_id".to_string()],
columns: Some(vec![
column("table_id", "int", false, Some("identity")),
column("table_name", "nvarchar(200)", false, None),
]),
}),
columns: vec!["table_id".to_string(), "table_name".to_string()],
column_types: None,
source_columns: None,
rows,
exclude_primary_keys: false,
include_computed_columns: false,
include_database_name: true,
insert_mode,
}
}
#[test]
fn sqlserver_copy_insert_wraps_identity_columns_with_identity_insert() {
let statement = build_data_grid_copy_insert_statement(sqlserver_identity_copy_insert_options(
vec![vec![json!(1), json!("t_destype")]],
DataGridCopyInsertMode::Merged,
));
assert_eq!(
statement.as_deref(),
Some(
"SET IDENTITY_INSERT [dbx_test].[dbo].[gen_table] ON;\nINSERT INTO [dbx_test].[dbo].[gen_table] ([table_id], [table_name]) VALUES (1, N't_destype');\nSET IDENTITY_INSERT [dbx_test].[dbo].[gen_table] OFF;"
)
);
}
#[test]
fn sqlserver_row_by_row_copy_insert_wraps_every_statement() {
let statement = build_data_grid_copy_insert_statement(sqlserver_identity_copy_insert_options(
vec![vec![json!(1), json!("t_destype")], vec![json!(2), json!("t_user")]],
DataGridCopyInsertMode::RowByRow,
));
assert_eq!(
statement.as_deref(),
Some(
"SET IDENTITY_INSERT [dbx_test].[dbo].[gen_table] ON;\nINSERT INTO [dbx_test].[dbo].[gen_table] ([table_id], [table_name]) VALUES (1, N't_destype');\nSET IDENTITY_INSERT [dbx_test].[dbo].[gen_table] OFF;\nSET IDENTITY_INSERT [dbx_test].[dbo].[gen_table] ON;\nINSERT INTO [dbx_test].[dbo].[gen_table] ([table_id], [table_name]) VALUES (2, N't_user');\nSET IDENTITY_INSERT [dbx_test].[dbo].[gen_table] OFF;"
)
);
}
#[test]
fn sqlserver_copy_insert_omits_identity_wrapper_without_identity_columns() {
let mut options = sqlserver_identity_copy_insert_options(
vec![vec![json!(1), json!("t_destype")]],
DataGridCopyInsertMode::Merged,
);
options.table_meta.as_mut().expect("table meta").columns =
Some(vec![column("table_id", "int", false, None), column("table_name", "nvarchar(200)", false, None)]);
let statement = build_data_grid_copy_insert_statement(options);
assert_eq!(
statement.as_deref(),
Some("INSERT INTO [dbx_test].[dbo].[gen_table] ([table_id], [table_name]) VALUES (1, N't_destype');")
);
}
#[test]
fn dameng_copy_insert_wraps_identity_columns_with_identity_insert() {
let mut options = sqlserver_identity_copy_insert_options(
vec![vec![json!(1), json!("t_destype")]],
DataGridCopyInsertMode::Merged,
);
options.database_type = Some(DatabaseType::Dameng);
let statement = build_data_grid_copy_insert_statement(options);
assert_eq!(
statement.as_deref(),
Some(
"SET IDENTITY_INSERT \"dbo\".\"gen_table\" ON;\nINSERT INTO \"dbo\".\"gen_table\" (\"table_id\", \"table_name\") VALUES (1, 't_destype');\nSET IDENTITY_INSERT \"dbo\".\"gen_table\" OFF;"
)
);
}
#[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),
identifier_quote: None,
table_meta: Some(table_meta.clone()),
columns: columns.clone(),
column_types: None,
source_columns: None,
rows: rows.clone(),
exclude_primary_keys: false,
include_computed_columns: false,
include_database_name: true,
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),
identifier_quote: None,
table_meta,
columns,
source_columns: None,
rows,
include_database_name: false,
});
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),
identifier_quote: None,
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,
include_computed_columns: false,
include_database_name: true,
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),
identifier_quote: None,
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,
include_computed_columns: false,
include_database_name: true,
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() {
for (database_type, identifier_quote, column_name, expected) in [
(DatabaseType::Gaussdb, Some("\""), "column_01", "column_01 = 1"),
(DatabaseType::Gaussdb, Some("\""), "MixedCase", "\"MixedCase\" = 1"),
(DatabaseType::Gaussdb, Some("\""), "order", "\"order\" = 1"),
(DatabaseType::Gaussdb, Some("\""), "order detail", "\"order detail\" = 1"),
(DatabaseType::Gaussdb, Some("\""), "\"AlreadyQuoted\"", "\"AlreadyQuoted\" = 1"),
(DatabaseType::Gaussdb, Some("`"), "MixedCase", "`MixedCase` = 1"),
(DatabaseType::Postgres, Some("`"), "order", "`order` = 1"),
(DatabaseType::OpenGauss, Some("`"), "order detail", "`order detail` = 1"),
(DatabaseType::Gaussdb, None, "column_01", "\"column_01\" = 1"),
(DatabaseType::OpenGauss, None, "column_01", "\"column_01\" = 1"),
] {
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(database_type),
identifier_quote: identifier_quote.map(str::to_string),
column_name: column_name.to_string(),
mode: DataGridContextFilterMode::Equals,
value: json!(1),
values: Vec::new(),
end_value: None,
column_info: Some(column(column_name, "integer", false, None)),
})
.as_deref(),
Some(expected)
);
}
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Kingbase),
identifier_quote: Some("`".to_string()),
column_name: "file_name".to_string(),
mode: DataGridContextFilterMode::Equals,
value: json!("34-B-0048"),
values: Vec::new(),
end_value: None,
column_info: Some(column("file_name", "varchar", false, None)),
})
.as_deref(),
Some("`file_name` = '34-B-0048'")
);
for (mode, expected) in [
(DataGridContextFilterMode::GreaterThanOrEqual, "`score` >= 80"),
(DataGridContextFilterMode::LessThanOrEqual, "`score` <= 80"),
] {
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
column_name: "score".to_string(),
mode,
value: json!(80),
values: Vec::new(),
end_value: None,
column_info: Some(column("score", "int", false, None)),
})
.as_deref(),
Some(expected)
);
}
assert_eq!(
build_data_grid_column_value_filter_condition(DataGridColumnValueFilterConditionOptions {
database_type: Some(DatabaseType::Kingbase),
identifier_quote: Some("`".to_string()),
column_name: "file_name".to_string(),
column_info: Some(column("file_name", "varchar", false, None)),
raw_value: "34-B-0048".to_string(),
})
.as_deref(),
Some("`file_name` = '34-B-0048'")
);
assert_eq!(
build_data_grid_column_value_filter_condition(DataGridColumnValueFilterConditionOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
column_name: "id".to_string(),
column_info: Some(column("id", "int", false, None)),
raw_value: "49436".to_string(),
})
.as_deref(),
Some("`id` = 49436")
);
// Cloud Spanner (GoogleSQL) spells integers as INT64; the value must stay
// an unquoted numeric literal or `INT64 = STRING` fails to compile.
assert_eq!(
build_data_grid_column_value_filter_condition(DataGridColumnValueFilterConditionOptions {
database_type: Some(DatabaseType::Spanner),
identifier_quote: Some("`".to_string()),
column_name: "slipnumber".to_string(),
column_info: Some(column("slipnumber", "INT64", false, None)),
raw_value: "1005".to_string(),
})
.as_deref(),
Some("`slipnumber` = 1005")
);
assert_eq!(
build_data_grid_column_value_filter_condition(DataGridColumnValueFilterConditionOptions {
database_type: Some(DatabaseType::Spanner),
identifier_quote: Some("`".to_string()),
column_name: "ratio".to_string(),
column_info: Some(column("ratio", "FLOAT64", false, None)),
raw_value: "1.5".to_string(),
})
.as_deref(),
Some("`ratio` = 1.5")
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
column_name: "file_name".to_string(),
mode: DataGridContextFilterMode::BeginsWith,
value: json!("FN"),
values: Vec::new(),
end_value: None,
column_info: Some(column("file_name", "varchar", false, None)),
})
.as_deref(),
Some("`file_name` LIKE 'FN%'")
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
column_name: "file_name".to_string(),
mode: DataGridContextFilterMode::EndsWith,
value: json!(".sql"),
values: Vec::new(),
end_value: None,
column_info: Some(column("file_name", "varchar", false, None)),
})
.as_deref(),
Some("`file_name` LIKE '%.sql'")
);
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: None,
column_name: "update_date".to_string(),
mode: DataGridContextFilterMode::BeginsWith,
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_column_value_filter_condition(DataGridColumnValueFilterConditionOptions {
database_type: Some(DatabaseType::SqlServer),
identifier_quote: None,
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_blank_and_nonblank_context_filter_conditions() {
let build = |database_type: DatabaseType,
mode: DataGridContextFilterMode,
identifier_quote: Option<&str>,
column_name: &str| {
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(database_type),
identifier_quote: identifier_quote.map(str::to_string),
column_name: column_name.to_string(),
mode,
value: Value::Null,
values: Vec::new(),
end_value: None,
column_info: Some(column(column_name, "varchar", true, None)),
})
};
assert_eq!(
build(DatabaseType::Mysql, DataGridContextFilterMode::IsBlank, None, "status"),
Some("(`status` IS NULL OR `status` = '')".to_string())
);
assert_eq!(
build(DatabaseType::Mysql, DataGridContextFilterMode::IsNotBlank, None, "status"),
Some("(`status` IS NOT NULL AND `status` <> '')".to_string())
);
assert_eq!(
build(DatabaseType::Mysql, DataGridContextFilterMode::IsNull, None, "status"),
Some("`status` IS NULL".to_string())
);
assert_eq!(
build(DatabaseType::Mysql, DataGridContextFilterMode::IsNotNull, None, "status"),
Some("`status` IS NOT NULL".to_string())
);
assert_eq!(
build(DatabaseType::Kingbase, DataGridContextFilterMode::IsBlank, Some("`"), "order detail"),
Some("(`order detail` IS NULL OR `order detail` = '')".to_string())
);
for database_type in [DatabaseType::Oracle, DatabaseType::OceanbaseOracle] {
assert_eq!(
build(database_type, DataGridContextFilterMode::IsBlank, None, "STATUS"),
Some("\"STATUS\" IS NULL".to_string())
);
assert_eq!(
build(database_type, DataGridContextFilterMode::IsNotBlank, None, "STATUS"),
Some("\"STATUS\" IS NOT NULL".to_string())
);
}
}
#[test]
fn keeps_context_filter_mode_serialization_stable() {
assert_eq!(serde_json::to_string(&DataGridContextFilterMode::IsNull).unwrap(), "\"is-null\"");
assert_eq!(serde_json::to_string(&DataGridContextFilterMode::IsNotNull).unwrap(), "\"is-not-null\"");
assert!(matches!(
serde_json::from_str::<DataGridContextFilterMode>("\"is-null\"").unwrap(),
DataGridContextFilterMode::IsNull
));
assert!(matches!(
serde_json::from_str::<DataGridContextFilterMode>("\"is-not-null\"").unwrap(),
DataGridContextFilterMode::IsNotNull
));
assert_eq!(serde_json::to_string(&DataGridContextFilterMode::IsBlank).unwrap(), "\"is-blank\"");
assert_eq!(serde_json::to_string(&DataGridContextFilterMode::IsNotBlank).unwrap(), "\"is-not-blank\"");
}
#[test]
fn builds_oracle_synthetic_rowid_context_filter_conditions() {
let equals = |database_type, column_name: &str, value| {
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type,
identifier_quote: None,
column_name: column_name.to_string(),
mode: DataGridContextFilterMode::Equals,
value,
values: Vec::new(),
end_value: None,
column_info: None,
})
};
for database_type in [DatabaseType::Oracle, DatabaseType::OceanbaseOracle] {
assert_eq!(
equals(Some(database_type), DBX_ROWID_COLUMN, json!("AAAFd1AAFAAAABSAA/")),
Some("ROWIDTOCHAR(ROWID) = 'AAAFd1AAFAAAABSAA/'".to_string())
);
}
assert_eq!(equals(Some(DatabaseType::Oracle), "REPORT_ID", json!(7)), Some("\"REPORT_ID\" = 7".to_string()));
assert_eq!(equals(Some(DatabaseType::Neo4j), "score", json!(7)), Some("n.`score` = 7".to_string()));
assert_eq!(equals(None, DBX_ROWID_COLUMN, json!("row-id")), Some("\"__DBX_ROWID\" = 'row-id'".to_string()));
assert_eq!(equals(Some(DatabaseType::VictoriaMetrics), DBX_ROWID_COLUMN, json!("row-id")), None);
}
#[test]
fn keeps_iotdb_tree_time_epoch_filters_unquoted_without_column_metadata() {
let condition = |mode, value, values, end_value| {
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Iotdb),
identifier_quote: None,
column_name: "Time".to_string(),
mode,
value,
values,
end_value,
column_info: None,
})
};
assert_eq!(
condition(DataGridContextFilterMode::Equals, json!("1786954706123"), Vec::new(), None),
Some("Time = 1786954706123".to_string())
);
assert_eq!(
condition(
DataGridContextFilterMode::Between,
json!("1786954706123"),
Vec::new(),
Some(json!("1786958307123"))
),
Some("Time BETWEEN 1786954706123 AND 1786958307123".to_string())
);
assert_eq!(
condition(DataGridContextFilterMode::Equals, json!("not-an-epoch"), Vec::new(), None),
Some("Time = 'not-an-epoch'".to_string())
);
}
#[test]
fn builds_membership_and_range_context_filter_conditions() {
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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 does_not_emit_sql_filters_for_victoriametrics() {
for mode in [
DataGridContextFilterMode::Equals,
DataGridContextFilterMode::Like,
DataGridContextFilterMode::GreaterThan,
DataGridContextFilterMode::IsNull,
DataGridContextFilterMode::IsBlank,
DataGridContextFilterMode::IsNotBlank,
DataGridContextFilterMode::In,
DataGridContextFilterMode::Between,
] {
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::VictoriaMetrics),
identifier_quote: None,
column_name: "value".to_string(),
mode,
value: json!(10),
values: vec![json!(10), json!(20)],
end_value: Some(json!(20)),
column_info: Some(column("value", "double", false, None)),
}),
None,
"VictoriaMetrics must not emit SQL filter mode {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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
identifier_quote: None,
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),
driver_profile: None,
identifier_quote: None,
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),
driver_profile: None,
identifier_quote: None,
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),
driver_profile: None,
identifier_quote: None,
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::SqlServer),
driver_profile: Some(" SQLSERVER-LEGACY ".to_string()),
identifier_quote: None,
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: true,
}),
"SELECT [status] AS dbx_value, COUNT(*) AS dbx_count FROM [users] GROUP BY [status] ORDER BY dbx_count DESC, dbx_value"
);
assert_eq!(
build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions {
database_type: Some(DatabaseType::Oracle),
driver_profile: None,
identifier_quote: None,
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),
driver_profile: None,
identifier_quote: None,
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),
driver_profile: None,
identifier_quote: None,
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),
driver_profile: None,
identifier_quote: None,
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),
driver_profile: None,
identifier_quote: None,
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()),
count_hint: None,
}),
"SELECT COUNT(*) AS cnt FROM \"public\".\"users\" WHERE (active = true)"
);
assert_eq!(
build_data_grid_count_sql(DataGridCountSqlOptions {
database_type: Some(DatabaseType::Jdbc),
identifier_quote: None,
catalog: None,
database: None,
schema: Some("DEMO".to_string()),
table_name: "STUDENT".to_string(),
where_input: None,
count_hint: None,
}),
"SELECT COUNT(*) AS cnt FROM DEMO.STUDENT"
);
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()),
count_hint: None,
}),
"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()),
count_hint: None,
}),
"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,
count_hint: 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,
count_hint: None,
}),
"SELECT COUNT(*) AS cnt FROM `cqbq_ls`.`ANALYZE`"
);
assert_eq!(
build_data_grid_count_sql(DataGridCountSqlOptions {
database_type: Some(DatabaseType::Gaussdb),
identifier_quote: Some("\"".to_string()),
catalog: None,
database: None,
schema: Some("schema_01".to_string()),
table_name: "table_01".to_string(),
where_input: None,
count_hint: None,
}),
"SELECT COUNT(*) AS cnt FROM schema_01.table_01"
);
assert_eq!(
build_data_grid_count_sql(DataGridCountSqlOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: Some("`".to_string()),
catalog: None,
database: None,
schema: Some("App Schema".to_string()),
table_name: "order".to_string(),
where_input: None,
count_hint: None,
}),
"SELECT COUNT(*) AS cnt FROM `App Schema`.`order`"
);
}
#[test]
fn builds_grid_count_sql_with_optimizer_hint() {
// GaussDB with optimizer hint
assert_eq!(
build_data_grid_count_sql(DataGridCountSqlOptions {
database_type: Some(DatabaseType::Gaussdb),
identifier_quote: Some("\"".to_string()),
catalog: None,
database: None,
schema: Some("schema_01".to_string()),
table_name: "table_01".to_string(),
where_input: None,
count_hint: Some("/*+ set(query_dop 32) */".to_string()),
}),
"SELECT /*+ set(query_dop 32) */ COUNT(*) AS cnt FROM schema_01.table_01"
);
// GaussDB with hint and WHERE clause
assert_eq!(
build_data_grid_count_sql(DataGridCountSqlOptions {
database_type: Some(DatabaseType::Gaussdb),
identifier_quote: None,
catalog: None,
database: None,
schema: Some("sec_acct".to_string()),
table_name: "main_sec_acct_info".to_string(),
where_input: Some("status = 'active'".to_string()),
count_hint: Some("/*+ set(query_dop 32) */".to_string()),
}),
"SELECT /*+ set(query_dop 32) */ COUNT(*) AS cnt FROM \"sec_acct\".\"main_sec_acct_info\" WHERE (status = 'active')"
);
// Non-GaussDB database with hint (should still work)
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: None,
count_hint: Some("/*+ set(query_dop 32) */".to_string()),
}),
"SELECT /*+ set(query_dop 32) */ COUNT(*) AS cnt FROM \"public\".\"users\""
);
// No hint (default) — unchanged behavior
assert_eq!(
build_data_grid_count_sql(DataGridCountSqlOptions {
database_type: Some(DatabaseType::Gaussdb),
identifier_quote: None,
catalog: None,
database: None,
schema: Some("schema_01".to_string()),
table_name: "table_01".to_string(),
where_input: None,
count_hint: None,
}),
"SELECT COUNT(*) AS cnt FROM \"schema_01\".\"table_01\""
);
}
#[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 prepares_hive_insert_from_qualified_result_labels() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Hive),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("ai_test".to_string()),
table_name: "t1".to_string(),
primary_keys: vec![],
columns: Some(vec![
column("id", "int", false, None),
column("name", "string", true, None),
column("amount", "double", true, None),
column("create_time", "string", true, None),
]),
},
columns: vec![
"t1.id".to_string(),
"t1.name".to_string(),
"t1.amount".to_string(),
"t1.create_time".to_string(),
],
source_columns: None,
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![vec![json!(4), Value::Null, Value::Null, Value::Null]],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["INSERT INTO TABLE `ai_test`.`t1` VALUES (4, NULL, NULL, NULL);"]);
assert_eq!(
result.rollback_statements,
vec!["DELETE FROM `ai_test`.`t1` WHERE `id` = 4 AND `name` IS NULL AND `amount` IS NULL AND `create_time` IS NULL;"]
);
}
#[test]
fn resolves_quoted_hive_source_columns_for_updates_and_primary_keys() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Hive),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("default".to_string()),
table_name: "users".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![column("id", "int", false, None), column("name", "string", true, None)]),
},
columns: vec!["identifier".to_string(), "display_name".to_string()],
source_columns: Some(vec![Some("`u`.`id`".to_string()), Some("`u`.`name`".to_string())]),
rows: vec![vec![json!(1), json!("Ada")], vec![json!(2), json!("Grace")]],
dirty_rows: vec![(0, vec![(1, json!("Ada Lovelace"))])],
deleted_rows: vec![1],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
"UPDATE `default`.`users` SET `name` = 'Ada Lovelace' WHERE `ID` = 1;",
"DELETE FROM `default`.`users` WHERE `ID` = 2;",
]
);
assert_eq!(
result.rollback_statements,
vec![
"INSERT INTO TABLE `default`.`users` VALUES (2, 'Grace');",
"UPDATE `default`.`users` SET `name` = 'Ada' WHERE `ID` = 1 AND `name` = 'Ada Lovelace';",
]
);
}
#[test]
fn preserves_real_dotted_hive_columns_and_other_dialects() {
let hive_options = DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Hive),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("default".to_string()),
table_name: "events".to_string(),
primary_keys: vec![],
columns: Some(vec![column("payload.id", "int", true, None), column("name", "string", true, None)]),
},
columns: vec!["payload.id".to_string(), "events.name".to_string()],
source_columns: None,
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![vec![json!(7), json!("created")]],
include_database_name: false,
};
assert_eq!(effective_columns(&hive_options), vec![Some("payload.id".to_string()), Some("name".to_string())]);
assert!(prepare_data_grid_save(hive_options).rollback_statements[0].contains("`payload.id` = 7"));
let postgres_options = DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("public".to_string()),
table_name: "events".to_string(),
primary_keys: vec![],
columns: Some(vec![column("id", "integer", true, None)]),
},
columns: vec!["events.id".to_string()],
source_columns: None,
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
};
assert_eq!(effective_columns(&postgres_options), vec![Some("events.id".to_string())]);
}
#[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 prepares_oracle_clob_update_without_oversized_string_literals() {
let clob = column("body", "CLOB", true, None);
let large_value = "x".repeat(4205);
let literal =
format_grid_assignment_sql_literal(&json!(large_value), Some(DatabaseType::Oracle), Some(&clob), None);
let chunks = literal
.split("TO_CLOB('")
.skip(1)
.map(|chunk| chunk.split("')").next().unwrap_or_default())
.collect::<Vec<_>>();
assert_eq!(chunks.len(), 2);
assert!(chunks.iter().all(|chunk| chunk.len() <= ORACLE_LOB_LITERAL_CHUNK_BYTES));
assert_eq!(chunks[0].len(), ORACLE_LOB_LITERAL_CHUNK_BYTES);
assert_eq!(chunks[1].len(), 4205 - ORACLE_LOB_LITERAL_CHUNK_BYTES);
assert_eq!(
format_grid_assignment_sql_literal(&json!("short"), Some(DatabaseType::Oracle), Some(&clob), None),
"'short'"
);
let nclob = column("body", "NCLOB", true, None);
assert!(format_grid_assignment_sql_literal(
&json!(large_value),
Some(DatabaseType::Oracle),
Some(&nclob),
None
)
.starts_with("TO_NCLOB('"));
let special_value = format!("{}'\\", "x".repeat(3998));
let special_literal =
format_grid_assignment_sql_literal(&json!(special_value), Some(DatabaseType::Oracle), Some(&clob), None);
assert!(special_literal.starts_with("TO_CLOB('"));
assert!(special_literal.contains("''"));
assert!(!special_literal.contains("\\\\"));
let varchar = column("body", "VARCHAR2(5000)", true, None);
let varchar_literal =
format_grid_assignment_sql_literal(&json!(large_value), Some(DatabaseType::Oracle), Some(&varchar), None);
assert!(varchar_literal.starts_with("'"));
assert!(!varchar_literal.contains("TO_CLOB("));
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Oracle),
identifier_quote: Some("\"".to_string()),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("APP".to_string()),
table_name: "DOCUMENTS".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![column("ID", "NUMBER", false, None), clob]),
},
columns: vec!["ID".to_string(), "BODY".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("old")]],
dirty_rows: vec![(0, vec![(1, json!(large_value))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements.len(), 1);
assert!(result.statements[0].contains("\"BODY\" = TO_CLOB('"));
assert!(result.statements[0].contains("WHERE \"ID\" = 1;"));
}
#[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_oracle_number28_updates_from_serialized_query_results() {
let identifiers = ["2026081810175800100000000000", "2026081810175800100000000001"];
let rows: Vec<Vec<Value>> = identifiers
.iter()
.map(|identifier| vec![serde_json::from_str(identifier).unwrap(), json!("old")])
.collect();
let query_result: crate::types::QueryResult = serde_json::from_value(json!({
"columns": ["ID", "NAME"],
"column_types": ["NUMBER(28)", "VARCHAR2(20)"],
"rows": rows,
"affected_rows": 0,
"execution_time_ms": 0,
}))
.unwrap();
let wire = serde_json::to_string(&query_result).unwrap();
let deserialized: crate::types::QueryResult = serde_json::from_str(&wire).unwrap();
for (row, identifier) in deserialized.rows.iter().zip(identifiers) {
assert_eq!(row[0], json!(identifier));
}
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Oracle),
identifier_quote: Some("\"".to_string()),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("APP".to_string()),
table_name: "ITEMS".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![
column("ID", "NUMBER(28)", false, None),
column("NAME", "VARCHAR2(20)", true, None),
]),
},
columns: deserialized.columns,
source_columns: None,
rows: deserialized.rows,
dirty_rows: vec![(0, vec![(1, json!("new"))]), (1, vec![(1, json!("new"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
identifiers
.iter()
.map(|identifier| format!("UPDATE \"APP\".\"ITEMS\" SET \"NAME\" = 'new' WHERE \"ID\" = {identifier};"))
.collect::<Vec<_>>()
);
}
#[test]
fn prepares_sqlserver_bigint_update_from_numeric_string() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::SqlServer),
identifier_quote: None,
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![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE [dbo].[users] SET [UserId] = 144847503924137986 WHERE [Id] = 1;"]);
}
#[test]
fn prepares_sqlserver_cross_database_update() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::SqlServer),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: Some("BarDB".to_string()),
database: Some("BarDB".to_string()),
schema: Some("dbo".to_string()),
table_name: "TUser".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![column("ID", "int", false, None), column("UserId", "bigint", false, None)]),
},
columns: vec!["ID".to_string(), "UserId".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!(10279)]],
dirty_rows: vec![(0, vec![(1, json!(10280))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE [BarDB].[dbo].[TUser] SET [UserId] = 10280 WHERE [ID] = 1;"]);
assert_eq!(
result.rollback_statements,
vec!["UPDATE [BarDB].[dbo].[TUser] SET [UserId] = 10279 WHERE [ID] = 1 AND [UserId] = 10280;"]
);
}
#[test]
fn prepares_kingbase_update_when_source_primary_key_case_differs() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Kingbase),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("ltcins_qd_db".to_string()),
table_name: "KG07".to_string(),
primary_keys: vec!["CKG023".to_string()],
columns: Some(vec![
column("CKG023", "varchar", false, None),
column("CKG096", "character", true, None),
]),
},
columns: vec!["ckg023".to_string(), "CKG096".to_string()],
source_columns: Some(vec![Some("ckg023".to_string()), Some("CKG096".to_string())]),
rows: vec![vec![json!("2026071511071859"), json!("03")]],
dirty_rows: vec![(0, vec![(1, json!("02"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![r#"UPDATE "ltcins_qd_db"."KG07" SET "CKG096" = '02' WHERE "CKG023" = '2026071511071859';"#]
);
assert_eq!(
result.rollback_statements,
vec![
r#"UPDATE "ltcins_qd_db"."KG07" SET "CKG096" = '03' WHERE "CKG023" = '2026071511071859' AND "CKG096" = '02';"#
]
);
}
#[test]
fn rejects_existing_row_save_when_primary_key_is_missing() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Kingbase),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("ltcins_qd_db".to_string()),
table_name: "KG07".to_string(),
primary_keys: vec!["CKG023".to_string()],
columns: Some(vec![
column("CKG023", "varchar", false, None),
column("CKG096", "character", true, None),
]),
},
columns: vec!["CKG096".to_string()],
source_columns: Some(vec![Some("CKG096".to_string())]),
rows: vec![vec![json!("03")]],
dirty_rows: vec![(0, vec![(0, json!("02"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(
result.validation_error.as_deref(),
Some(
"Cannot safely update or delete rows because the query result does not include every primary key column (missing: CKG023). Refresh or rerun the query before saving."
)
);
assert!(result.statements.is_empty());
assert!(result.rollback_statements.is_empty());
}
#[test]
fn rejects_existing_row_save_when_primary_key_value_is_null() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Kingbase),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("ltcins_qd_db".to_string()),
table_name: "KG07".to_string(),
primary_keys: vec!["CKG023".to_string()],
columns: Some(vec![
column("CKG023", "varchar", false, None),
column("CKG096", "character", true, None),
]),
},
columns: vec!["CKG023".to_string(), "CKG096".to_string()],
source_columns: None,
rows: vec![vec![Value::Null, json!("03")]],
dirty_rows: vec![(0, vec![(1, json!("02"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(
result.validation_error.as_deref(),
Some(
"Cannot safely update or delete rows because primary key column \"CKG023\" has no value in the query result. Refresh or rerun the query before saving."
)
);
assert!(result.statements.is_empty());
assert!(result.rollback_statements.is_empty());
}
#[test]
fn mysql_grouped_result_update_uses_physical_columns_and_primary_key() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
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![column("id", "bigint", false, None), column("department", "varchar", false, None)]),
},
columns: vec!["用户ID".to_string(), "部门".to_string(), "订单数".to_string()],
source_columns: Some(vec![Some("id".to_string()), Some("department".to_string()), None]),
rows: vec![vec![json!(7), json!("sales"), json!(3)]],
dirty_rows: vec![(0, vec![(1, json!("support"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE `app`.`users` SET `department` = 'support' WHERE `id` = 7;"]);
assert_eq!(
result.rollback_statements,
vec!["UPDATE `app`.`users` SET `department` = 'sales' WHERE `id` = 7 AND BINARY `department` = 'support';"]
);
}
#[test]
fn mysql_update_omits_virtual_and_stored_generated_columns() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
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![
column("id", "bigint", false, None),
column("virtual_label", "varchar", true, Some("VIRTUAL GENERATED")),
column("stored_label", "varchar", true, Some("STORED GENERATED")),
column("department", "varchar", false, None),
]),
},
columns: vec![
"id".to_string(),
"virtual_label".to_string(),
"stored_label".to_string(),
"department".to_string(),
],
source_columns: Some(vec![
Some("id".to_string()),
Some("virtual_label".to_string()),
Some("stored_label".to_string()),
Some("department".to_string()),
]),
rows: vec![vec![json!(7), json!("sales-7"), json!("SALES"), json!("sales")]],
dirty_rows: vec![(0, vec![(1, json!("support-7")), (2, json!("SUPPORT")), (3, json!("support"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE `app`.`users` SET `department` = 'support' WHERE `id` = 7;"]);
assert_eq!(
result.rollback_statements,
vec!["UPDATE `app`.`users` SET `department` = 'sales' WHERE `id` = 7 AND BINARY `department` = 'support';"]
);
}
#[test]
fn kingbase_column_index_prefers_exact_case_and_rejects_ambiguous_fallback() {
let columns = vec![Some("ckg023".to_string()), Some("CKG023".to_string())];
assert_eq!(find_column_index(Some(DatabaseType::Kingbase), &columns, "CKG023"), Some(1));
assert_eq!(find_column_index(Some(DatabaseType::Kingbase), &columns[..1], "CKG023"), Some(0));
assert_eq!(
find_column_index(
Some(DatabaseType::Kingbase),
&[Some("ckg023".to_string()), Some("Ckg023".to_string())],
"CKG023"
),
None
);
}
#[test]
fn goldendb_column_index_prefers_exact_case_and_rejects_ambiguous_fallback() {
let columns = vec![Some("id".to_string()), Some("ID".to_string())];
assert_eq!(find_column_index(Some(DatabaseType::Goldendb), &columns, "ID"), Some(1));
assert_eq!(find_column_index(Some(DatabaseType::Goldendb), &columns[..1], "ID"), Some(0));
assert_eq!(
find_column_index(Some(DatabaseType::Goldendb), &[Some("id".to_string()), Some("Id".to_string())], "ID"),
None
);
}
#[test]
fn goldendb_save_uses_unique_case_insensitive_primary_key_for_update_and_delete() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Goldendb),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("app".to_string()),
table_name: "people".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![column("ID", "bigint", false, None), column("name", "varchar", true, None)]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: Some(vec![Some("id".to_string()), Some("name".to_string())]),
rows: vec![vec![json!(1), json!("Ada")], vec![json!(2), json!("Grace")]],
dirty_rows: vec![(0, vec![(1, json!("Ada Lovelace"))])],
deleted_rows: vec![1],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
"UPDATE `app`.`people` SET `name` = 'Ada Lovelace' WHERE `ID` = 1;",
"DELETE FROM `app`.`people` WHERE `ID` = 2;",
]
);
}
#[test]
fn goldendb_save_supports_composite_primary_keys_with_unique_case_insensitive_matches() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Goldendb),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("app".to_string()),
table_name: "members".to_string(),
primary_keys: vec!["TENANT_ID".to_string(), "USER_ID".to_string()],
columns: Some(vec![
column("TENANT_ID", "bigint", false, None),
column("USER_ID", "bigint", false, None),
column("name", "varchar", true, None),
]),
},
columns: vec!["tenant_id".to_string(), "user_id".to_string(), "name".to_string()],
source_columns: Some(vec![
Some("tenant_id".to_string()),
Some("user_id".to_string()),
Some("name".to_string()),
]),
rows: vec![vec![json!(10), json!(1), json!("Ada")], vec![json!(10), json!(2), json!("Grace")]],
dirty_rows: vec![(0, vec![(2, json!("Ada Lovelace"))])],
deleted_rows: vec![1],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
"UPDATE `app`.`members` SET `name` = 'Ada Lovelace' WHERE `TENANT_ID` = 10 AND `USER_ID` = 1;",
"DELETE FROM `app`.`members` WHERE `TENANT_ID` = 10 AND `USER_ID` = 2;",
]
);
}
#[test]
fn rejects_goldendb_save_when_case_insensitive_primary_key_match_is_ambiguous() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Goldendb),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("app".to_string()),
table_name: "people".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![column("ID", "bigint", false, None), column("name", "varchar", true, None)]),
},
columns: vec!["id".to_string(), "Id".to_string(), "name".to_string()],
source_columns: Some(vec![Some("id".to_string()), Some("Id".to_string()), Some("name".to_string())]),
rows: vec![vec![json!(1), json!(2), json!("Ada")]],
dirty_rows: vec![(0, vec![(2, json!("Grace"))])],
deleted_rows: vec![0],
new_rows: vec![],
include_database_name: false,
});
assert!(result.validation_error.as_deref().is_some_and(|error| error.contains("missing: ID")));
assert!(result.statements.is_empty());
assert!(result.rollback_statements.is_empty());
}
#[test]
fn rejects_goldendb_save_when_primary_key_column_is_truly_missing() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Goldendb),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("app".to_string()),
table_name: "people".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![column("ID", "bigint", false, None), column("name", "varchar", true, None)]),
},
columns: vec!["tenant_id".to_string(), "name".to_string()],
source_columns: Some(vec![Some("tenant_id".to_string()), Some("name".to_string())]),
rows: vec![vec![json!(10), json!("Ada")]],
dirty_rows: vec![(0, vec![(1, json!("Grace"))])],
deleted_rows: vec![0],
new_rows: vec![],
include_database_name: false,
});
assert!(result.validation_error.as_deref().is_some_and(|error| error.contains("missing: ID")));
assert!(result.statements.is_empty());
assert!(result.rollback_statements.is_empty());
}
#[test]
fn rejects_postgres_save_when_only_case_different_column_is_returned() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("public".to_string()),
table_name: "case_keys".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![
column("id", "integer", false, None),
column("ID", "integer", false, None),
column("name", "text", true, None),
]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: Some(vec![Some("id".to_string()), Some("name".to_string())]),
rows: vec![vec![json!(1), json!("Ada")]],
dirty_rows: vec![(0, vec![(1, json!("Grace"))])],
deleted_rows: vec![0],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(
result.validation_error.as_deref(),
Some(
"Cannot safely update or delete rows because the query result does not include every primary key column (missing: ID). Refresh or rerun the query before saving."
)
);
assert!(result.statements.is_empty());
assert!(result.rollback_statements.is_empty());
}
#[test]
fn rejects_kingbase_save_when_case_only_primary_key_match_is_ambiguous() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Kingbase),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("public".to_string()),
table_name: "case_keys".to_string(),
primary_keys: vec!["CKG023".to_string()],
columns: Some(vec![column("CKG023", "varchar", false, None), column("name", "varchar", true, None)]),
},
columns: vec!["ckg023".to_string(), "Ckg023".to_string(), "name".to_string()],
source_columns: Some(vec![
Some("ckg023".to_string()),
Some("Ckg023".to_string()),
Some("name".to_string()),
]),
rows: vec![vec![json!("first"), json!("second"), json!("Ada")]],
dirty_rows: vec![(0, vec![(2, json!("Grace"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert!(result.validation_error.as_deref().is_some_and(|error| error.contains("missing: CKG023")));
assert!(result.statements.is_empty());
assert!(result.rollback_statements.is_empty());
}
#[test]
fn kingbase_mysql_compat_save_uses_connection_identifier_quote() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Kingbase),
identifier_quote: Some("`".to_string()),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("gc".to_string()),
table_name: "docfileinfo".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "integer", false, None), column("file_name", "varchar", false, None)]),
},
columns: vec!["id".to_string(), "file_name".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("old")]],
dirty_rows: vec![(0, vec![(1, json!("34-B-0048"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE `gc`.`docfileinfo` SET `file_name` = '34-B-0048' WHERE `id` = 1;"]);
assert!(result
.rollback_statements
.iter()
.all(|statement| statement.contains("`gc`.`docfileinfo`") && !statement.contains('"')));
}
/// Spanner's dialect is fixed at database creation time and only the connected agent
/// knows it: GoogleSQL quotes with backticks (and has an empty default schema),
/// the PostgreSQL dialect quotes with double quotes and defaults to `public`.
#[test]
fn spanner_save_uses_connection_identifier_quote_per_dialect() {
let googlesql = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Spanner),
identifier_quote: Some("`".to_string()),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some(String::new()),
table_name: "singers".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "INT64", false, None), column("name", "STRING(100)", false, None)]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("old")]],
dirty_rows: vec![(0, vec![(1, json!("Ada"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(googlesql.validation_error, None);
// Single-segment table name: `` ``.`singers` `` would be `Invalid empty identifier`.
assert_eq!(googlesql.statements, vec!["UPDATE `singers` SET `name` = 'Ada' WHERE `id` = 1;"]);
let postgres_dialect = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Spanner),
identifier_quote: Some("\"".to_string()),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("public".to_string()),
table_name: "singers".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![
column("id", "bigint", false, None),
column("name", "character varying", false, None),
]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("old")]],
dirty_rows: vec![(0, vec![(1, json!("Ada"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(postgres_dialect.validation_error, None);
assert_eq!(
postgres_dialect.statements,
vec!["UPDATE \"public\".\"singers\" SET \"name\" = 'Ada' WHERE \"id\" = 1;"]
);
// No quote reported: fall back to the GoogleSQL default (backticks), never to the
// ANSI double quote, which GoogleSQL parses as a string literal.
let no_reported_quote = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Spanner),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some(String::new()),
table_name: "singers".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "INT64", false, None), column("name", "STRING(100)", false, None)]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("old")]],
dirty_rows: vec![(0, vec![(1, json!("Ada"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(no_reported_quote.validation_error, None);
assert_eq!(no_reported_quote.statements, vec!["UPDATE `singers` SET `name` = 'Ada' WHERE `id` = 1;"]);
}
#[test]
fn kingbase_mysql_compat_copy_insert_uses_backtick_identifiers() {
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
database_type: Some(DatabaseType::Kingbase),
identifier_quote: Some("`".to_string()),
table_meta: Some(DataGridTableMeta {
catalog: None,
database: None,
schema: Some("audit-schema".to_string()),
table_name: "events".to_string(),
primary_keys: vec!["id".to_string()],
columns: None,
}),
columns: vec!["id".to_string(), "event_type".to_string()],
column_types: None,
source_columns: None,
rows: vec![vec![json!(1), json!("login")]],
exclude_primary_keys: false,
include_computed_columns: false,
include_database_name: true,
insert_mode: DataGridCopyInsertMode::Merged,
});
assert_eq!(
statement.as_deref(),
Some("INSERT INTO `audit-schema`.`events` (`id`, `event_type`) VALUES (1, 'login');")
);
assert!(!statement.as_deref().unwrap_or_default().contains('"'));
}
#[test]
fn kingbase_mysql_compat_copy_update_uses_backtick_identifiers() {
let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions {
database_type: Some(DatabaseType::Kingbase),
identifier_quote: Some("`".to_string()),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("audit-schema".to_string()),
table_name: "events".to_string(),
primary_keys: vec!["id".to_string()],
columns: None,
},
columns: vec!["id".to_string(), "event_type".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("logout")]],
include_database_name: false,
});
assert_eq!(statements, vec!["UPDATE `audit-schema`.`events` SET `event_type` = 'logout' WHERE `id` = 1;"]);
}
#[test]
fn vastbase_query_result_writes_use_resolved_search_path_schema() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Vastbase),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: Some("smes_dev".to_string()),
schema: Some("tenant_b".to_string()),
table_name: "TBLCUSPOSTMATERIALLOG".to_string(),
primary_keys: vec![],
columns: Some(vec![column("MONO", "varchar", true, None), column("ID", "bigint", false, None)]),
},
columns: vec!["MONO".to_string(), "ID".to_string()],
source_columns: None,
rows: vec![vec![json!("mono"), json!(461936049002042_i64)]],
dirty_rows: vec![(0, vec![(0, json!("LY-SC01-260800002"))])],
deleted_rows: vec![],
new_rows: vec![vec![json!("dbx-insert-check"), json!(461936049002043_i64)]],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.execution_schema.as_deref(), Some("tenant_b"));
assert_eq!(
result.statements,
vec![
"UPDATE \"tenant_b\".\"TBLCUSPOSTMATERIALLOG\" SET \"MONO\" = 'LY-SC01-260800002' WHERE \"MONO\" = 'mono' AND \"ID\" = 461936049002042;",
"INSERT INTO \"tenant_b\".\"TBLCUSPOSTMATERIALLOG\" (\"MONO\", \"ID\") VALUES ('dbx-insert-check', 461936049002043);",
]
);
}
#[test]
fn gbase8s_save_omits_owner_for_insert_update_delete_and_rollback() {
let result = prepare_data_grid_save_for_driver_profile(
DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Informix),
identifier_quote: Some(String::new()),
table_meta: DataGridTableMeta {
catalog: None,
database: Some("webcenter".to_string()),
schema: Some("gbasedbt".to_string()),
table_name: "user_device".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "integer", false, None), column("dev_id", "varchar", false, None)]),
},
columns: vec!["id".to_string(), "dev_id".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("1")], vec![json!(2), json!("deleted")]],
dirty_rows: vec![(0, vec![(1, json!("2"))])],
deleted_rows: vec![1],
new_rows: vec![vec![json!(3), json!("new")]],
include_database_name: false,
},
Some("GBASE8S"),
);
assert_eq!(result.validation_error, None);
assert_eq!(result.execution_schema, None);
assert_eq!(
result.statements,
vec![
"UPDATE user_device SET dev_id = '2' WHERE id = 1;",
"DELETE FROM user_device WHERE id = 2;",
"INSERT INTO user_device (id, dev_id) VALUES (3, 'new');",
]
);
assert_eq!(
result.rollback_statements,
vec![
"DELETE FROM user_device WHERE id = 3 AND dev_id = 'new';",
"INSERT INTO user_device (id, dev_id) VALUES (2, 'deleted');",
"UPDATE user_device SET dev_id = '1' WHERE id = 1 AND dev_id = '2';",
]
);
}
#[test]
fn standard_informix_save_preserves_owner_qualification_without_gbase8s_profile() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Informix),
identifier_quote: Some(String::new()),
table_meta: DataGridTableMeta {
catalog: None,
database: Some("dbx_test".to_string()),
schema: Some("gbasedbt".to_string()),
table_name: "connection_smoke".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "integer", false, None), column("product", "varchar", false, None)]),
},
columns: vec!["id".to_string(), "product".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("GBase 8s")]],
dirty_rows: vec![(0, vec![(1, json!("GBase 8s updated"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec!["UPDATE gbasedbt.connection_smoke SET product = 'GBase 8s updated' WHERE id = 1;"]
);
assert_eq!(
result.rollback_statements,
vec![
"UPDATE gbasedbt.connection_smoke SET product = 'GBase 8s' WHERE id = 1 AND product = 'GBase 8s updated';"
]
);
}
#[test]
fn gaussdb_jdbc_save_selectively_quotes_identifiers() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Gaussdb),
identifier_quote: Some("\"".to_string()),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("schema_01".to_string()),
table_name: "table_01".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "integer", false, None), column("name", "varchar", false, None)]),
},
columns: vec!["id".to_string(), "name".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("old")]],
dirty_rows: vec![(0, vec![(1, json!("new"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE schema_01.table_01 SET name = 'new' WHERE id = 1;"]);
}
#[test]
fn postgres_driver_to_gaussdb_m_mode_save_uses_backticks() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: Some("`".to_string()),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("App Schema".to_string()),
table_name: "order".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![
column("ID", "integer", false, None),
column("display name", "varchar", false, None),
]),
},
columns: vec!["ID".to_string(), "display name".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("old")]],
dirty_rows: vec![(0, vec![(1, json!("new"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE `App Schema`.`order` SET `display name` = 'new' WHERE `ID` = 1;"]);
}
#[test]
fn postgres_save_uses_exact_quoted_primary_key_for_update_delete_and_rollback() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("public".to_string()),
table_name: "case_keys".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![
column("id", "integer", false, None),
column("ID", "integer", false, None),
column("name", "text", true, None),
]),
},
columns: vec!["id".to_string(), "ID".to_string(), "name".to_string()],
source_columns: Some(vec![Some("id".to_string()), Some("ID".to_string()), Some("name".to_string())]),
rows: vec![vec![json!(1), json!(101), json!("Ada")], vec![json!(2), json!(202), json!("Grace")]],
dirty_rows: vec![(0, vec![(2, json!("Ada Lovelace"))])],
deleted_rows: vec![1],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
r#"UPDATE "public"."case_keys" SET "name" = 'Ada Lovelace' WHERE "ID" = 101;"#,
r#"DELETE FROM "public"."case_keys" WHERE "ID" = 202;"#,
]
);
assert!(result.rollback_statements.iter().all(|statement| !statement.contains(r#"WHERE "ID" = 1 AND"#)));
assert!(result.rollback_statements.iter().any(|statement| statement.contains(r#"WHERE "ID" = 101"#)));
}
#[test]
fn mysql_join_result_delete_targets_only_the_resolved_source_primary_key() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("lims".to_string()),
table_name: "lims_batchs_simple".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![
column("id", "bigint", false, None),
column("sno", "integer", true, None),
column("batchs_id", "bigint", false, None),
column("simple_id", "bigint", false, None),
]),
},
columns: vec!["id".to_string(), "sno".to_string(), "batchs_id".to_string(), "simple_id".to_string()],
source_columns: Some(vec![
Some("id".to_string()),
Some("sno".to_string()),
Some("batchs_id".to_string()),
Some("simple_id".to_string()),
]),
rows: vec![vec![json!(2658055), json!(4), json!(57485), json!(492045)]],
dirty_rows: vec![],
deleted_rows: vec![0],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["DELETE FROM `lims`.`lims_batchs_simple` WHERE `id` = 2658055;"]);
}
#[test]
fn prepares_oracle_timestamp_insert_from_iso_grid_value() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Oracle),
identifier_quote: None,
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")]],
include_database_name: false,
});
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 prepares_oracle_raw_update_with_hex_literals() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Oracle),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("APP".to_string()),
table_name: "RAW_VALUES".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![column("ID", "RAW(16)", false, None), column("PAYLOAD", "RAW(16)", true, None)]),
},
columns: vec!["ID".to_string(), "PAYLOAD".to_string()],
source_columns: None,
rows: vec![vec![json!("0x00112233445566778899aabbccddeeff"), json!("0xaabb")]],
dirty_rows: vec![(0, vec![(1, json!("0xccdd"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
"UPDATE \"APP\".\"RAW_VALUES\" SET \"PAYLOAD\" = HEXTORAW('ccdd') WHERE \"ID\" = HEXTORAW('00112233445566778899aabbccddeeff');"
]
);
}
#[test]
fn prepares_oracle_update_without_schema_when_the_frontend_resolved_the_current_schema() {
// A JDBC Oracle edit resolves the login user's schema on the frontend and
// sends the folded table name with an empty schema. The generated UPDATE
// must stay unqualified instead of reintroducing the service name.
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Oracle),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "IMP_T".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![column("ID", "NUMBER", false, None), column("NAME", "VARCHAR2(100)", true, None)]),
},
columns: vec!["ID".to_string(), "NAME".to_string()],
source_columns: None,
rows: vec![vec![json!(7), json!("old")]],
dirty_rows: vec![(0, vec![(1, json!("new"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE \"IMP_T\" SET \"NAME\" = 'new' WHERE \"ID\" = 7;"]);
}
#[test]
fn oracle_raw_literals_require_valid_even_length_hex() {
let raw = column("ID", "RAW(16)", false, None);
let text = column("ID", "VARCHAR2(64)", false, None);
let blob = column("ID", "BLOB", false, None);
for database_type in [DatabaseType::Oracle, DatabaseType::OceanbaseOracle] {
assert_eq!(format_grid_sql_literal(&json!("0x00aB"), Some(database_type), Some(&raw)), "HEXTORAW('00aB')");
assert_eq!(format_grid_sql_literal(&json!("0xabc"), Some(database_type), Some(&raw)), "'0xabc'");
assert_eq!(format_grid_sql_literal(&json!("0x00gg"), Some(database_type), Some(&raw)), "'0x00gg'");
assert_eq!(format_grid_sql_literal(&json!("0x00ab"), Some(database_type), Some(&text)), "'0x00ab'");
assert_eq!(format_grid_sql_literal(&json!("0x00ab"), Some(database_type), Some(&blob)), "'0x00ab'");
}
}
#[test]
fn prepares_oceanbase_oracle_lob_deletes_with_synthetic_rowid() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::OceanbaseOracle),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("APP".to_string()),
table_name: "DATA_REPORT_SUB_TASK".to_string(),
primary_keys: vec![DBX_ROWID_COLUMN.to_string()],
columns: Some(vec![
column(DBX_ROWID_COLUMN, "VARCHAR2", false, None),
column("ID", "VARCHAR2(100)", false, None),
column("SMC_RESPONSE", "CLOB", true, None),
column("RAW_PAYLOAD", "BLOB", true, None),
column("ARCHIVE_VALUE", "LOB", true, None),
]),
},
columns: vec![
DBX_ROWID_COLUMN.to_string(),
"ID".to_string(),
"SMC_RESPONSE".to_string(),
"RAW_PAYLOAD".to_string(),
"ARCHIVE_VALUE".to_string(),
],
source_columns: None,
rows: vec![
vec![json!("*AAABk1AAEAAAAAgAAA"), json!("task-1"), json!("response"), json!("0011"), json!("archive")],
vec![json!("*AAABk1AAEAAAAAgAAB"), json!("task-2"), Value::Null, Value::Null, Value::Null],
],
dirty_rows: vec![],
deleted_rows: vec![0, 1],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
"DELETE FROM \"APP\".\"DATA_REPORT_SUB_TASK\" WHERE ROWIDTOCHAR(ROWID) = '*AAABk1AAEAAAAAgAAA';",
"DELETE FROM \"APP\".\"DATA_REPORT_SUB_TASK\" WHERE ROWIDTOCHAR(ROWID) = '*AAABk1AAEAAAAAgAAB';",
]
);
}
#[test]
fn prepares_oceanbase_oracle_lob_delete_with_declared_primary_key() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::OceanbaseOracle),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("APP".to_string()),
table_name: "DOCUMENTS".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![
column("ID", "NUMBER", false, None),
column("TITLE", "VARCHAR2(100)", false, None),
column("BODY", "CLOB", true, None),
column("CONTENT", "BLOB", true, None),
]),
},
columns: vec!["ID".to_string(), "TITLE".to_string(), "BODY".to_string(), "CONTENT".to_string()],
source_columns: None,
rows: vec![vec![json!(42), json!("report"), json!("body"), Value::Null]],
dirty_rows: vec![],
deleted_rows: vec![0],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["DELETE FROM \"APP\".\"DOCUMENTS\" WHERE \"ID\" = 42;"]);
}
#[test]
fn rejects_oceanbase_oracle_keyless_lob_writes_without_rowid() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::OceanbaseOracle),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("APP".to_string()),
table_name: "DOCUMENTS".to_string(),
primary_keys: vec![],
columns: Some(vec![column("TITLE", "VARCHAR2(100)", false, None), column("BODY", "CLOB", true, None)]),
},
columns: vec!["TITLE".to_string(), "BODY".to_string()],
source_columns: None,
rows: vec![vec![json!("duplicate title"), json!("unique body")]],
dirty_rows: vec![],
deleted_rows: vec![0],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(
result.validation_error.as_deref(),
Some("Cannot safely update or delete this Oracle-compatible row because the table has LOB columns but no primary key or ROWID identifier.")
);
assert!(result.statements.is_empty());
assert!(result.rollback_statements.is_empty());
}
fn daily_stats_keyless_options() -> DataGridSaveStatementOptions {
DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Sqlite),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "daily_stats".to_string(),
primary_keys: vec![],
columns: Some(vec![column("stat_date", "TEXT", false, None), column("period", "TEXT", true, None)]),
},
columns: vec!["stat_date".to_string(), "period".to_string()],
source_columns: None,
rows: vec![vec![json!("2026-09-07"), Value::Null], vec![json!("2026-09-07"), Value::Null]],
dirty_rows: vec![(0, vec![(1, json!("早上"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
}
}
/// The predicate a guard counts must be byte-identical to the one its
/// statement carries, otherwise the safety decision is made against a
/// different set of columns or values than the mutation actually uses.
fn assert_guards_cover_statement_predicates(preparation: &DataGridSavePreparation) {
for guard in &preparation.keyless_guards {
assert_eq!(guard.max_matched_rows, 1);
let predicate = guard
.sql
.split_once(" WHERE (")
.and_then(|(_, rest)| rest.strip_suffix(')'))
.unwrap_or_else(|| panic!("guard is not a counting query: {}", guard.sql));
assert!(
preparation.statements.iter().any(|statement| statement.contains(&format!("WHERE {predicate}"))),
"no statement carries the guarded predicate {predicate:?}"
);
}
for statement in &preparation.statements {
let Some((_, predicate)) = statement.split_once(" WHERE ") else {
continue;
};
let predicate = predicate.trim_end_matches(';');
assert!(
preparation.keyless_guards.iter().any(|guard| guard.sql.ends_with(&format!("WHERE ({predicate})"))),
"predicate {predicate:?} is sent to the database without a guard"
);
}
}
#[test]
fn guards_sqlite_keyless_update_with_a_server_side_row_count() {
// Regression test for https://github.com/t8y2/dbx/issues/8321: a SQLite
// table with no primary key, two rows inserted with only `stat_date`
// filled in. Editing row 0's `period` must not silently also rewrite
// row 1, which has identical values in every column. Whether a second
// matching row exists cannot be answered from the loaded page, so the
// save carries a guard that counts the matches on the server.
let result = prepare_data_grid_save(daily_stats_keyless_options());
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
r#"UPDATE "daily_stats" SET "period" = '早上' WHERE "stat_date" = '2026-09-07' AND "period" IS NULL;"#
]
);
assert_eq!(
result.keyless_guards,
vec![DataGridSaveGuard {
sql: r#"SELECT COUNT(*) AS dbx_keyless_row_matches FROM "daily_stats" WHERE ("stat_date" = '2026-09-07' AND "period" IS NULL)"#
.to_string(),
max_matched_rows: 1,
message: KEYLESS_AMBIGUOUS_ROW_ERROR.to_string(),
}]
);
assert_guards_cover_statement_predicates(&result);
}
#[test]
fn guards_sqlite_keyless_update_even_when_the_loaded_page_looks_unique() {
// The duplicate row may live outside the loaded/filtered page, so a
// page that shows only distinguishable rows proves nothing and must
// still be verified on the server.
let mut options = daily_stats_keyless_options();
options.rows = vec![vec![json!("2026-09-07"), json!("早上")], vec![json!("2026-09-07"), json!("中午")]];
options.dirty_rows = vec![(0, vec![(1, json!("上午"))])];
let result = prepare_data_grid_save(options);
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
r#"UPDATE "daily_stats" SET "period" = '上午' WHERE "stat_date" = '2026-09-07' AND "period" = '早上';"#
]
);
assert_eq!(result.keyless_guards.len(), 1);
assert_guards_cover_statement_predicates(&result);
}
#[test]
fn guards_keyless_predicate_without_the_columns_the_predicate_omits() {
// `build_row_where` drops result columns that have no source column, so
// a guard built from the visible values would decide uniqueness using
// `total`, a value the mutation predicate never mentions.
let mut options = daily_stats_keyless_options();
options.table_meta.columns =
Some(vec![column("stat_date", "TEXT", false, None), column("period", "TEXT", true, None)]);
options.columns = vec!["stat_date".to_string(), "period".to_string(), "total".to_string()];
options.source_columns = Some(vec![Some("stat_date".to_string()), Some("period".to_string()), None]);
options.rows =
vec![vec![json!("2026-09-07"), Value::Null, json!(1)], vec![json!("2026-09-07"), Value::Null, json!(2)]];
let result = prepare_data_grid_save(options);
assert_eq!(result.validation_error, None);
assert_eq!(result.keyless_guards.len(), 1);
assert!(!result.keyless_guards[0].sql.contains("total"));
assert_guards_cover_statement_predicates(&result);
}
#[test]
fn guards_keyless_delete_and_deduplicates_identical_predicates() {
let mut options = daily_stats_keyless_options();
options.dirty_rows = vec![];
options.deleted_rows = vec![0, 1];
let result = prepare_data_grid_save(options);
assert_eq!(result.validation_error, None);
assert_eq!(result.statements.len(), 2);
// Both rows produce the same predicate; one guard covers both.
assert_eq!(result.keyless_guards.len(), 1);
assert_guards_cover_statement_predicates(&result);
}
#[test]
fn omits_keyless_guards_when_the_table_has_a_primary_key() {
let mut options = daily_stats_keyless_options();
options.table_meta.primary_keys = vec!["stat_date".to_string()];
let result = prepare_data_grid_save(options);
assert_eq!(result.validation_error, None);
assert!(result.keyless_guards.is_empty());
}
#[test]
fn rejects_keyless_edit_when_no_column_can_address_the_row() {
// Every result column is computed, so `build_row_where` produces an
// empty predicate: there is neither a row identifier nor anything a
// server-side count could check, and the write must stay disabled.
let mut options = daily_stats_keyless_options();
options.source_columns = Some(vec![None, None]);
let result = prepare_data_grid_save(options);
assert_eq!(result.validation_error.as_deref(), Some(KEYLESS_UNIDENTIFIABLE_ROW_ERROR));
assert!(result.statements.is_empty());
assert!(result.keyless_guards.is_empty());
}
#[test]
fn preserves_oceanbase_oracle_keyless_predicates_for_comparable_columns() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::OceanbaseOracle),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("APP".to_string()),
table_name: "TASK_STATUS".to_string(),
primary_keys: vec![],
columns: Some(vec![
column("TASK_NAME", "VARCHAR2(100)", false, None),
column("STATUS", "VARCHAR2(16)", true, None),
]),
},
columns: vec!["TASK_NAME".to_string(), "STATUS".to_string()],
source_columns: None,
rows: vec![vec![json!("task-1"), json!("RUNNING")], vec![json!("task-2"), Value::Null]],
dirty_rows: vec![],
deleted_rows: vec![0, 1],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
"DELETE FROM \"APP\".\"TASK_STATUS\" WHERE \"TASK_NAME\" = 'task-1' AND \"STATUS\" = 'RUNNING';",
"DELETE FROM \"APP\".\"TASK_STATUS\" WHERE \"TASK_NAME\" = 'task-2' AND \"STATUS\" IS NULL;",
]
);
}
#[test]
fn batches_mysql_updates_with_identical_values() {
let mut options = mysql_people_save_options(3);
options.dirty_rows =
vec![(0, vec![(1, Value::Null)]), (1, vec![(1, Value::Null)]), (2, vec![(1, Value::Null)])];
let result = prepare_data_grid_save(options);
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec!["UPDATE `app`.`people` SET `status` = NULL WHERE (`id` = 1) OR (`id` = 2) OR (`id` = 3);"]
);
assert_eq!(result.rollback_statements.len(), 3);
}
#[test]
fn preserves_mysql_update_order_across_different_values() {
let mut options = mysql_people_save_options(3);
options.dirty_rows =
vec![(0, vec![(1, Value::Null)]), (1, vec![(1, json!("archived"))]), (2, vec![(1, Value::Null)])];
let result = prepare_data_grid_save(options);
assert_eq!(
result.statements,
vec![
"UPDATE `app`.`people` SET `status` = NULL WHERE `id` = 1;",
"UPDATE `app`.`people` SET `status` = 'archived' WHERE `id` = 2;",
"UPDATE `app`.`people` SET `status` = NULL WHERE `id` = 3;",
]
);
}
#[test]
fn batches_mysql_deletes_and_deleted_row_rollbacks() {
let mut options = mysql_people_save_options(3);
options.deleted_rows = vec![0, 1, 2];
let result = prepare_data_grid_save(options);
for statement in &result.statements {
println!("statement: {statement}");
}
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["DELETE FROM `app`.`people` WHERE `id` IN (1, 2, 3);"]);
assert_eq!(
result.rollback_statements,
vec!["INSERT INTO `app`.`people` (`id`, `status`) VALUES (1, 'active'), (2, 'active'), (3, 'active');"]
);
}
#[test]
fn batches_mysql_compound_key_deletes_keep_or_form() {
let mut options = mysql_people_save_options(3);
options.table_meta.primary_keys = vec!["id".to_string(), "status".to_string()];
options.deleted_rows = vec![0, 1];
let result = prepare_data_grid_save(options);
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
"DELETE FROM `app`.`people` WHERE (`id` = 1 AND `status` = 'active') OR (`id` = 2 AND `status` = 'active');"
]
);
}
#[test]
fn chunks_large_mysql_data_grid_batches() {
let mut options = mysql_people_save_options(MYSQL_DATA_GRID_BATCH_MAX_ROWS + 1);
options.deleted_rows = (0..options.rows.len()).collect();
let result = prepare_data_grid_save(options);
// IN lists are far more compact than OR chains, so the batch fits in a
// single statement up to the row limit.
assert_eq!(result.statements.len(), 1);
assert!(result.statements[0].contains("`id` IN ("));
assert!(result.statements[0].contains("501"));
assert_eq!(result.rollback_statements.len(), 2);
assert!(result.rollback_statements[0].contains("(500, 'active')"));
assert_eq!(
result.rollback_statements[1],
"INSERT INTO `app`.`people` (`id`, `status`) VALUES (501, 'active');"
);
}
#[test]
fn mysql_data_grid_batch_respects_target_sql_size() {
let mut options = mysql_people_save_options(2);
options.rows[0][0] = json!("a".repeat(MYSQL_DATA_GRID_BATCH_TARGET_SQL_BYTES / 2));
options.rows[1][0] = json!("b".repeat(MYSQL_DATA_GRID_BATCH_TARGET_SQL_BYTES / 2));
options.deleted_rows = vec![0, 1];
let result = prepare_data_grid_save(options);
// The IN list stays within the SQL byte budget, splitting into a
// second statement once the accumulated literals would exceed it.
assert_eq!(result.statements.len(), 2);
assert!(result.statements.iter().all(|statement| statement.len() <= MYSQL_DATA_GRID_BATCH_TARGET_SQL_BYTES));
}
#[test]
fn keeps_keyless_mysql_writes_row_by_row() {
let mut options = mysql_people_save_options(2);
options.table_meta.primary_keys.clear();
options.deleted_rows = vec![0, 1];
let result = prepare_data_grid_save(options);
assert_eq!(result.statements.len(), 2);
assert_eq!(result.rollback_statements.len(), 2);
assert!(result.statements.iter().all(|statement| !statement.contains(" OR ")));
}
#[test]
fn casts_keyless_mysql_json_row_predicates() {
let options = DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("app".to_string()),
table_name: "documents".to_string(),
primary_keys: vec![],
columns: Some(vec![column("payload", "JSON", false, None), column("note", "varchar(32)", true, None)]),
},
columns: vec!["payload".to_string(), "note".to_string()],
source_columns: None,
rows: vec![vec![json!(r#"{"name":"before"}"#), json!("row-a")]],
dirty_rows: vec![(0, vec![(0, json!(r#"{"name":"after"}"#))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
};
let update = prepare_data_grid_save(options.clone());
assert_eq!(update.validation_error, None);
assert_eq!(
update.statements,
vec![
r#"UPDATE `app`.`documents` SET `payload` = '{"name":"after"}' WHERE `payload` = CAST('{"name":"before"}' AS JSON) AND BINARY `note` = 'row-a';"#
]
);
assert_eq!(
update.rollback_statements,
vec![
r#"UPDATE `app`.`documents` SET `payload` = '{"name":"before"}' WHERE `payload` = CAST('{"name":"after"}' AS JSON) AND BINARY `note` = 'row-a' AND `payload` = CAST('{"name":"after"}' AS JSON);"#
]
);
let delete = prepare_data_grid_save(DataGridSaveStatementOptions {
dirty_rows: vec![],
deleted_rows: vec![0],
..options
});
assert_eq!(delete.validation_error, None);
assert_eq!(
delete.statements,
vec![
r#"DELETE FROM `app`.`documents` WHERE `payload` = CAST('{"name":"before"}' AS JSON) AND BINARY `note` = 'row-a';"#
]
);
assert_eq!(
delete.rollback_statements,
vec![r#"INSERT INTO `app`.`documents` (`payload`, `note`) VALUES ('{"name":"before"}', 'row-a');"#]
);
}
#[test]
fn mysql_json_save_assignments_are_compact_and_predicates_stay_cast() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("app".to_string()),
table_name: "documents".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "int", false, None), column("payload", "JSON", true, None)]),
},
columns: vec!["id".to_string(), "payload".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!({"before": true})]],
dirty_rows: vec![(0, vec![(1, json!([]))])],
deleted_rows: vec![],
new_rows: vec![vec![json!(2), json!({"nested": [1, 2]})]],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
"UPDATE `app`.`documents` SET `payload` = '[]' WHERE `id` = 1;",
"INSERT INTO `app`.`documents` (`id`, `payload`) VALUES (2, '{\"nested\":[1,2]}');",
]
);
assert_eq!(
result.rollback_statements,
vec![
"DELETE FROM `app`.`documents` WHERE `id` = 2;",
"UPDATE `app`.`documents` SET `payload` = '{\"before\":true}' WHERE `id` = 1 AND `payload` = CAST('[]' AS JSON);",
]
);
}
#[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 formats_kingbase_bit_literals_for_both_compatibility_modes() {
let bit = column("flag", "pg_catalog.bit(1)", true, None);
assert_eq!(
format_grid_sql_literal_with_identifier_quote(
&json!("0"),
Some(DatabaseType::Kingbase),
Some(&bit),
Some("`"),
),
"b'0'"
);
assert_eq!(
format_grid_sql_literal_with_identifier_quote(
&json!("10101010"),
Some(DatabaseType::Kingbase),
Some(&column("flags", "pg_catalog.bit(8)", true, None)),
Some("`"),
),
"b'10101010'"
);
assert_eq!(
format_grid_sql_literal_with_identifier_quote(
&json!("0"),
Some(DatabaseType::Kingbase),
Some(&bit),
Some("\""),
),
"b'0'"
);
assert_eq!(
format_grid_sql_literal_with_identifier_quote(
&json!(1),
Some(DatabaseType::Kingbase),
Some(&bit),
Some("\""),
),
"b'1'"
);
}
#[test]
fn kingbase_bit_save_uses_bit_string_literals() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Kingbase),
identifier_quote: Some("`".to_string()),
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("public".to_string()),
table_name: "flags".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "int", false, None), column("flag", "pg_catalog.bit(1)", true, None)]),
},
columns: vec!["id".to_string(), "flag".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("1")]],
dirty_rows: vec![(0, vec![(1, json!("0"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.statements, vec!["UPDATE `public`.`flags` SET `flag` = b'0' WHERE `id` = 1;"],);
assert_eq!(
result.rollback_statements,
vec!["UPDATE `public`.`flags` SET `flag` = b'1' WHERE `id` = 1 AND `flag` = b'0';"],
);
}
#[test]
fn postgres_literals_use_escape_strings_for_stable_backslash_semantics() {
let value = json!(r#"{"json_raw":"{\"foo\":1,\"bar\":\"sometext\"}"}"#);
assert_eq!(
format_grid_sql_literal(&value, Some(DatabaseType::Postgres), None),
r#"E'{"json_raw":"{\\"foo\\":1,\\"bar\\":\\"sometext\\"}"}'"#
);
assert_eq!(format_grid_sql_literal(&json!("it's"), Some(DatabaseType::Postgres), None), "'it''s'");
}
#[test]
fn postgres_temporal_literals_preserve_session_formatted_values_for_grid_writes() {
let timestamp = column("created_at", "timestamp without time zone", false, None);
let timestamptz = column("created_at", "timestamp with time zone", false, None);
assert_eq!(
format_grid_sql_literal(&json!("2026-09-13 01:00:00"), Some(DatabaseType::Postgres), Some(&timestamp),),
"'2026-09-13 01:00:00'"
);
assert_eq!(
format_grid_sql_literal(
&json!("2026-09-13T13:00:00+12:00"),
Some(DatabaseType::Postgres),
Some(&timestamptz),
),
"'2026-09-13T13:00:00+12:00'"
);
}
#[test]
fn postgres_bytea_literals_decode_prefixed_hex_values() {
let bytea = column("payload", "bytea", true, None);
let text = column("label", "text", true, None);
assert_eq!(
format_grid_sql_literal(&json!("0x00aBff"), Some(DatabaseType::Postgres), Some(&bytea)),
"decode('00aBff', 'hex')"
);
assert_eq!(
format_grid_sql_literal(&json!("0x"), Some(DatabaseType::Postgres), Some(&bytea)),
"decode('', 'hex')"
);
assert_eq!(format_grid_sql_literal(&json!("0xabc"), Some(DatabaseType::Postgres), Some(&bytea)), "'0xabc'");
assert_eq!(format_grid_sql_literal(&json!("0x00ab"), Some(DatabaseType::Postgres), Some(&text)), "'0x00ab'");
}
#[test]
fn sqlite_family_literals_do_not_double_escape_backslashes() {
let value = json!(r#"{"json_raw":"{\"foo\":1,\"bar\":\"sometext\"}"}"#);
for database_type in
[DatabaseType::Sqlite, DatabaseType::Rqlite, DatabaseType::Turso, DatabaseType::CloudflareD1]
{
assert_eq!(
format_grid_sql_literal(&value, Some(database_type), None),
r#"'{"json_raw":"{\"foo\":1,\"bar\":\"sometext\"}"}'"#
);
assert_eq!(format_grid_sql_literal(&json!("it's"), Some(database_type), None), "'it''s'");
}
}
#[test]
fn dameng_data_grid_writes_do_not_double_escape_backslashes() {
assert_eq!(format_grid_sql_literal(&json!(r"\n"), Some(DatabaseType::Dameng), None), r"'\n'");
assert_eq!(format_grid_sql_literal(&json!(r"line\n's"), Some(DatabaseType::Dameng), None), r"'line\n''s'");
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Dameng),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("DBX_TEST".to_string()),
table_name: "DBX_NEWLINE_REPRO".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![column("ID", "INT", false, None), column("VAL", "VARCHAR(100)", true, None)]),
},
columns: vec!["ID".to_string(), "VAL".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("old")]],
dirty_rows: vec![(0, vec![(1, json!(r"line\n's"))])],
deleted_rows: vec![],
new_rows: vec![vec![json!(2), json!(r"\n")]],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
r#"UPDATE "DBX_TEST"."DBX_NEWLINE_REPRO" SET "VAL" = 'line\n''s' WHERE "ID" = 1;"#,
r#"INSERT INTO "DBX_TEST"."DBX_NEWLINE_REPRO" ("ID", "VAL") VALUES (2, '\n');"#,
]
);
}
#[test]
fn oracle_data_grid_writes_do_not_double_escape_backslashes() {
assert_eq!(format_grid_sql_literal(&json!(r"\n"), Some(DatabaseType::Oracle), None), r"'\n'");
assert_eq!(format_grid_sql_literal(&json!(r"line\n's"), Some(DatabaseType::Oracle), None), r"'line\n''s'");
let nested_json = r#"{"ext":"{\"v1\":\"123\",\"v3\":\"{\\\"vl1\\\":\\\"x\\\"}\"}"}"#;
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Oracle),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("DBX_TEST".to_string()),
table_name: "DBX9708_JSON".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![column("ID", "NUMBER", false, None), column("VAL", "VARCHAR2(400)", true, None)]),
},
columns: vec!["ID".to_string(), "VAL".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("old")]],
dirty_rows: vec![(0, vec![(1, json!(nested_json))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![format!(r#"UPDATE "DBX_TEST"."DBX9708_JSON" SET "VAL" = '{nested_json}' WHERE "ID" = 1;"#)]
);
}
#[test]
fn sqlserver_literals_do_not_double_escape_backslashes() {
assert_eq!(format_grid_sql_literal(&json!(r".\SQL2016"), Some(DatabaseType::SqlServer), None), r"N'.\SQL2016'");
assert_eq!(
format_grid_sql_literal(&json!(r".\SQL2016's"), Some(DatabaseType::SqlServer), None),
r"N'.\SQL2016''s'"
);
}
#[test]
fn sqlserver_literals_preserve_backslashes_before_line_breaks() {
assert_eq!(
format_grid_sql_literal(&json!("line1\\\nline2"), Some(DatabaseType::SqlServer), None),
r"N'line1\' + NCHAR(10) + N'line2'"
);
assert_eq!(
format_grid_sql_literal(&json!("line1\\\r\nline2"), Some(DatabaseType::SqlServer), None),
r"N'line1\' + NCHAR(13) + NCHAR(10) + N'line2'"
);
}
#[test]
fn prepares_sqlserver_updates_without_doubling_backslashes() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::SqlServer),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbo".to_string()),
table_name: "dbx_issue_4181".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "int", false, None), column("value", "nvarchar(100)", true, None)]),
},
columns: vec!["id".to_string(), "value".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("initial")]],
dirty_rows: vec![(0, vec![(1, json!(r".\SQL2016"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![r"UPDATE [dbo].[dbx_issue_4181] SET [value] = N'.\SQL2016' WHERE [id] = 1;"]
);
}
#[test]
fn mysql_and_neo4j_literals_keep_doubling_backslashes() {
// MySQL (default sql_mode, without NO_BACKSLASH_ESCAPES) and Neo4j do treat
// backslash as an escape character, so this behavior must be preserved.
assert_eq!(format_grid_sql_literal(&json!(r"a\b"), Some(DatabaseType::Mysql), None), r"'a\\b'");
assert_eq!(format_grid_sql_literal(&json!(r"a\b"), Some(DatabaseType::Neo4j), None), r"'a\\b'");
}
#[test]
fn oceanbase_oracle_literals_do_not_double_escape_backslashes() {
assert_eq!(format_grid_sql_literal(&json!(r"a\b"), Some(DatabaseType::OceanbaseOracle), None), r"'a\b'");
assert_eq!(
format_grid_sql_literal(&json!(r"line\n's"), Some(DatabaseType::OceanbaseOracle), None),
r"'line\n''s'"
);
}
#[test]
fn oracle_clob_literals_keep_backslashes_single() {
let clob = column("body", "CLOB", true, None);
assert_eq!(
format_grid_assignment_sql_literal(&json!(r"a\b's"), Some(DatabaseType::Oracle), Some(&clob), None),
r"'a\b''s'"
);
// Backslashes count as one byte toward the chunk budget, so a value at the
// chunk boundary splits at the same offset it would without backslashes.
let value = format!("{}\\", "x".repeat(ORACLE_SQL_LITERAL_MAX_BYTES));
let literal = format_grid_assignment_sql_literal(&json!(value), Some(DatabaseType::Oracle), Some(&clob), None);
let chunks = literal
.split("TO_CLOB('")
.skip(1)
.map(|chunk| chunk.split("')").next().unwrap_or_default())
.collect::<Vec<_>>();
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].len(), ORACLE_LOB_LITERAL_CHUNK_BYTES);
assert!(chunks[1].ends_with('\\'));
assert!(!chunks.iter().any(|chunk| chunk.contains("\\\\")));
}
#[test]
fn prepares_sqlserver_bitn_updates_with_numeric_literals() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::SqlServer),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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 saves_json_null_bigint_cell_as_unquoted_sql_null() {
// Regression for #9970: bulk-editing a bigint column to NULL must emit
// `= NULL`, never `= 'NULL'` / `= ''` (ERROR 1366 on MySQL/OceanBase).
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "orders".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "bigint", false, None), column("detail_id", "bigint", true, None)]),
},
columns: vec!["id".to_string(), "detail_id".to_string()],
source_columns: None,
rows: vec![vec![json!(7), json!(42)]],
dirty_rows: vec![(0, vec![(1, Value::Null)])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE `orders` SET `detail_id` = NULL WHERE `id` = 7;"]);
}
#[test]
fn mysql_conditional_update_null_bigint_is_unquoted_sql_null() {
let statement = build_data_grid_conditional_update_sql(DataGridConditionalUpdateSqlOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: None,
table_name: "orders".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "bigint", false, None), column("detail_id", "bigint", true, None)]),
},
column_name: "detail_id".to_string(),
value: Value::Null,
where_input: "tenant_id = 1".to_string(),
});
assert_eq!(statement, Some("UPDATE `orders` SET `detail_id` = NULL WHERE (tenant_id = 1);".to_string()));
}
#[test]
fn keeps_empty_nullable_mysql_text_cell_as_empty_string() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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")]],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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"),
]],
include_database_name: false,
});
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),
identifier_quote: None,
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)],
],
include_database_name: false,
});
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),
identifier_quote: None,
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)]],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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")]],
include_database_name: false,
});
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),
identifier_quote: None,
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")]],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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")]],
include_database_name: false,
});
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),
identifier_quote: None,
table_meta: table_meta.clone(),
columns: vec!["id".to_string(), "status".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("paid")]],
include_database_name: false,
});
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),
identifier_quote: None,
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,
include_computed_columns: false,
include_database_name: true,
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),
identifier_quote: None,
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")]],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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)]],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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![],
include_database_name: false,
});
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),
identifier_quote: None,
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")]],
include_database_name: false,
});
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),
identifier_quote: None,
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)]],
include_database_name: false,
});
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_omits_hidden_oracle_rowid_from_cloned_row_insert() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Oracle),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("APP".to_string()),
table_name: "TT_PLATFORM_CARS".to_string(),
primary_keys: vec![DBX_ROWID_COLUMN.to_string()],
columns: Some(vec![
column("ID", "NUMBER", false, None),
column("PLATFORM", "VARCHAR2(100)", true, None),
]),
},
columns: vec!["ID".to_string(), "PLATFORM".to_string(), "__DBX_PK_0".to_string()],
source_columns: Some(vec![
Some("ID".to_string()),
Some("PLATFORM".to_string()),
Some(DBX_ROWID_COLUMN.to_string()),
]),
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![vec![json!(72), json!("轻卡"), Value::Null]],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![r#"INSERT INTO "APP"."TT_PLATFORM_CARS" ("ID", "PLATFORM") VALUES (72, '轻卡');"#]
);
}
#[test]
fn prepares_xugu_rowid_updates_deletes_and_inserts_without_writing_synthetic_key() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Xugu),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("APP".to_string()),
table_name: "ROWID_TABLE".to_string(),
primary_keys: vec![DBX_ROWID_COLUMN.to_string()],
columns: Some(vec![
column(DBX_ROWID_COLUMN, "ROWID", false, None),
column("ID", "INTEGER", false, None),
column("VALUE", "VARCHAR(40)", true, None),
]),
},
columns: vec![DBX_ROWID_COLUMN.to_string(), "ID".to_string(), "VALUE".to_string()],
source_columns: None,
rows: vec![vec![json!("AA-1"), json!(1), json!("old")], vec![json!("AA-2"), json!(2), json!("remove")]],
dirty_rows: vec![(0, vec![(2, json!("new"))])],
deleted_rows: vec![1],
new_rows: vec![vec![Value::Null, json!(3), json!("inserted")]],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(
result.statements,
vec![
"UPDATE \"APP\".\"ROWID_TABLE\" SET \"VALUE\" = 'new' WHERE ROWID = 'AA-1';",
"DELETE FROM \"APP\".\"ROWID_TABLE\" WHERE ROWID = 'AA-2';",
"INSERT INTO \"APP\".\"ROWID_TABLE\" (\"ID\", \"VALUE\") VALUES (3, 'inserted');",
]
);
}
#[test]
fn prepare_data_grid_save_skips_sqlite_autoincrement_pk_validation() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Sqlite),
identifier_quote: None,
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")]],
include_database_name: false,
});
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),
identifier_quote: None,
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")]],
include_database_name: false,
});
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),
identifier_quote: None,
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")]],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["INSERT INTO `app`.`users` (`name`) VALUES ('Ada');"]);
}
#[test]
fn prepare_data_grid_save_omits_mysql_not_null_column_for_before_insert_trigger() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
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![
pk_column("id", "BIGINT", false, Some("auto_increment")),
column("trigger_value", "VARCHAR(64)", false, None),
column("payload", "VARCHAR(64)", false, None),
]),
},
columns: vec!["id".to_string(), "trigger_value".to_string(), "payload".to_string()],
source_columns: None,
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![vec![Value::Null, Value::Null, json!("created")]],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["INSERT INTO `app`.`events` (`payload`) VALUES ('created');"]);
assert!(result.rollback_statements.is_empty());
}
#[test]
fn prepare_data_grid_save_uses_mysql_default_row_insert_for_trigger_only_rows() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("app".to_string()),
table_name: "trigger_only".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![
pk_column("id", "BIGINT", false, Some("auto_increment")),
column("required_value", "VARCHAR(64)", false, None),
]),
},
columns: vec!["id".to_string(), "required_value".to_string()],
source_columns: None,
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![vec![Value::Null, Value::Null]],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["INSERT INTO `app`.`trigger_only` () VALUES ();"]);
assert!(result.rollback_statements.is_empty());
}
#[test]
fn prepare_data_grid_save_uses_known_mysql_primary_key_for_trigger_rollback() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
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![
pk_column("id", "BIGINT", false, None),
column("trigger_value", "VARCHAR(64)", false, None),
column("payload", "VARCHAR(64)", false, None),
]),
},
columns: vec!["id".to_string(), "trigger_value".to_string(), "payload".to_string()],
source_columns: None,
rows: vec![],
dirty_rows: vec![],
deleted_rows: vec![],
new_rows: vec![vec![json!(7), Value::Null, json!("created")]],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["INSERT INTO `app`.`events` (`id`, `payload`) VALUES (7, 'created');"]);
assert_eq!(result.rollback_statements, vec!["DELETE FROM `app`.`events` WHERE `id` = 7;"]);
}
#[test]
fn prepare_data_grid_save_still_rejects_mysql_null_update() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
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![
pk_column("id", "BIGINT", false, Some("auto_increment")),
column("trigger_value", "VARCHAR(64)", false, None),
]),
},
columns: vec!["id".to_string(), "trigger_value".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!("existing")]],
dirty_rows: vec![(0, vec![(1, Value::Null)])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(result.validation_error, Some(r#"Column "trigger_value" does not allow NULL."#.to_string()));
assert!(result.statements.is_empty());
}
#[test]
fn prepare_data_grid_save_omits_empty_kingbase_sqlserver_identity_value() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Kingbase),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("dbo".to_string()),
table_name: "orders".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![
pk_column("id", "int", false, Some("identity(1,1)")),
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![Value::Null, json!("Ada")]],
include_database_name: false,
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec![r#"INSERT INTO "dbo"."orders" ("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),
identifier_quote: None,
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]],
include_database_name: false,
});
assert_eq!(result.validation_error, Some(r#"Column "LogTime" does not allow NULL."#.to_string()));
assert!(result.statements.is_empty());
}
}