425 lines
15 KiB
Rust
425 lines
15 KiB
Rust
use serde_json::Value;
|
|
use std::collections::HashSet;
|
|
|
|
use super::*;
|
|
use crate::models::connection::DatabaseType;
|
|
|
|
pub(super) fn uses_iotdb_table_model_save(options: &DataGridSaveStatementOptions) -> bool {
|
|
options.database_type == Some(DatabaseType::Iotdb) && !options.table_meta.primary_keys.is_empty()
|
|
}
|
|
|
|
pub(super) fn build_iotdb_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
|
let save_columns = effective_columns(options);
|
|
let mut statements = Vec::new();
|
|
|
|
for (row_index, changes) in &options.dirty_rows {
|
|
let Some(row) = options.rows.get(*row_index) else {
|
|
continue;
|
|
};
|
|
if let Some(statement) = build_iotdb_changed_row_insert(options, &save_columns, row, changes, true) {
|
|
statements.push(statement);
|
|
}
|
|
}
|
|
|
|
for row_index in &options.deleted_rows {
|
|
let Some(row) = options.rows.get(*row_index) else {
|
|
continue;
|
|
};
|
|
if let Some(statement) = build_iotdb_delete_statement(options, &save_columns, row) {
|
|
statements.push(statement);
|
|
}
|
|
}
|
|
|
|
for row in &options.new_rows {
|
|
if let Some(statement) = build_iotdb_full_row_insert(options, &save_columns, row) {
|
|
statements.push(statement);
|
|
}
|
|
}
|
|
|
|
statements
|
|
}
|
|
|
|
pub(super) fn build_iotdb_data_grid_rollback_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
|
let save_columns = effective_columns(options);
|
|
let mut statements = Vec::new();
|
|
|
|
for row in &options.new_rows {
|
|
if let Some(statement) = build_iotdb_delete_statement(options, &save_columns, row) {
|
|
statements.push(statement);
|
|
}
|
|
}
|
|
|
|
for row_index in &options.deleted_rows {
|
|
let Some(row) = options.rows.get(*row_index) else {
|
|
continue;
|
|
};
|
|
if let Some(statement) = build_iotdb_full_row_insert(options, &save_columns, row) {
|
|
statements.push(statement);
|
|
}
|
|
}
|
|
|
|
for (row_index, changes) in &options.dirty_rows {
|
|
let Some(row) = options.rows.get(*row_index) else {
|
|
continue;
|
|
};
|
|
let requires_full_restore = changes.iter().any(|(column_index, _)| {
|
|
let value = row.get(*column_index).unwrap_or(&Value::Null);
|
|
let column = save_columns.get(*column_index).and_then(Option::as_deref);
|
|
value.is_null()
|
|
|| column.is_some_and(|column| {
|
|
empty_string_saves_as_null(value, column_info_for(iotdb_column_info(options), column))
|
|
})
|
|
});
|
|
if requires_full_restore {
|
|
if let Some(statement) = build_iotdb_delete_statement(options, &save_columns, row) {
|
|
statements.push(statement);
|
|
}
|
|
if let Some(statement) = build_iotdb_full_row_insert(options, &save_columns, row) {
|
|
statements.push(statement);
|
|
}
|
|
} else if let Some(statement) = build_iotdb_changed_row_insert(options, &save_columns, row, changes, false) {
|
|
statements.push(statement);
|
|
}
|
|
}
|
|
|
|
statements
|
|
}
|
|
|
|
pub(super) fn validate_iotdb_existing_rows(options: &DataGridSaveStatementOptions) -> Option<String> {
|
|
if !uses_iotdb_table_model_save(options) || options.dirty_rows.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let save_columns = effective_columns(options);
|
|
let primary_keys =
|
|
options.table_meta.primary_keys.iter().map(|column| normalize_column_name(column)).collect::<HashSet<_>>();
|
|
for (_, changes) in &options.dirty_rows {
|
|
for (column_index, value) in changes {
|
|
let Some(column) = save_columns.get(*column_index).and_then(Option::as_deref) else {
|
|
continue;
|
|
};
|
|
if primary_keys.contains(&normalize_column_name(column)) {
|
|
return Some("IoTDB row identifier columns (time and TAG) cannot be edited.".to_string());
|
|
}
|
|
if value.is_null() || empty_string_saves_as_null(value, column_info_for(iotdb_column_info(options), column))
|
|
{
|
|
return Some(
|
|
"IoTDB Table model cannot clear a FIELD or ATTRIBUTE value without deleting the row. Enter a non-NULL value or delete the row instead."
|
|
.to_string(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn build_iotdb_changed_row_insert(
|
|
options: &DataGridSaveStatementOptions,
|
|
save_columns: &[Option<String>],
|
|
row: &[Value],
|
|
changes: &[(usize, Value)],
|
|
use_changed_values: bool,
|
|
) -> Option<String> {
|
|
let changed_indexes = changes.iter().map(|(index, _)| *index).collect::<HashSet<_>>();
|
|
let primary_keys =
|
|
options.table_meta.primary_keys.iter().map(|column| normalize_column_name(column)).collect::<HashSet<_>>();
|
|
let insert_pairs = save_columns
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(index, column)| {
|
|
let column = column.as_deref()?;
|
|
let is_primary_key = primary_keys.contains(&normalize_column_name(column));
|
|
if !is_primary_key && !changed_indexes.contains(&index) {
|
|
return None;
|
|
}
|
|
let value = if use_changed_values && !is_primary_key {
|
|
changes.iter().find(|(changed_index, _)| *changed_index == index).map(|(_, value)| value)?
|
|
} else {
|
|
row.get(index).unwrap_or(&Value::Null)
|
|
};
|
|
Some((column, value))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
build_iotdb_insert_statement(options, insert_pairs)
|
|
}
|
|
|
|
fn build_iotdb_full_row_insert(
|
|
options: &DataGridSaveStatementOptions,
|
|
save_columns: &[Option<String>],
|
|
row: &[Value],
|
|
) -> Option<String> {
|
|
let insert_pairs = save_columns
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(index, column)| Some((column.as_deref()?, row.get(index).unwrap_or(&Value::Null))))
|
|
.filter(|(_, value)| !value.is_null())
|
|
.collect::<Vec<_>>();
|
|
build_iotdb_insert_statement(options, insert_pairs)
|
|
}
|
|
|
|
fn build_iotdb_insert_statement(
|
|
options: &DataGridSaveStatementOptions,
|
|
insert_pairs: Vec<(&str, &Value)>,
|
|
) -> Option<String> {
|
|
if insert_pairs.is_empty() {
|
|
return None;
|
|
}
|
|
let columns = insert_pairs
|
|
.iter()
|
|
.map(|(column, _)| data_grid_identifier(Some(DatabaseType::Iotdb), column, options.identifier_quote.as_deref()))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let values = insert_pairs
|
|
.iter()
|
|
.map(|(column, value)| {
|
|
format_grid_save_sql_literal(
|
|
value,
|
|
Some(DatabaseType::Iotdb),
|
|
column_info_for(iotdb_column_info(options), column),
|
|
options.identifier_quote.as_deref(),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
Some(data_grid_statement(
|
|
Some(DatabaseType::Iotdb),
|
|
format!("INSERT INTO {} ({columns}) VALUES ({values})", iotdb_table(options)),
|
|
))
|
|
}
|
|
|
|
fn build_iotdb_delete_statement(
|
|
options: &DataGridSaveStatementOptions,
|
|
save_columns: &[Option<String>],
|
|
row: &[Value],
|
|
) -> Option<String> {
|
|
let where_clause = build_primary_key_where(
|
|
Some(DatabaseType::Iotdb),
|
|
&options.table_meta.primary_keys,
|
|
save_columns,
|
|
row,
|
|
iotdb_column_info(options),
|
|
options.identifier_quote.as_deref(),
|
|
);
|
|
if where_clause.is_empty() {
|
|
return None;
|
|
}
|
|
Some(data_grid_statement(
|
|
Some(DatabaseType::Iotdb),
|
|
data_grid_delete_sql(Some(DatabaseType::Iotdb), &iotdb_table(options), &where_clause),
|
|
))
|
|
}
|
|
|
|
fn iotdb_table(options: &DataGridSaveStatementOptions) -> String {
|
|
data_grid_qualified_table_name(
|
|
Some(DatabaseType::Iotdb),
|
|
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(),
|
|
)
|
|
}
|
|
|
|
fn iotdb_column_info(options: &DataGridSaveStatementOptions) -> &[DataGridColumnInfo] {
|
|
options.table_meta.columns.as_deref().unwrap_or(&[])
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn column(name: &str, data_type: &str, nullable: bool, category: &str) -> DataGridColumnInfo {
|
|
DataGridColumnInfo {
|
|
name: name.to_string(),
|
|
data_type: data_type.to_string(),
|
|
is_nullable: nullable,
|
|
is_primary_key: matches!(category, "TIME" | "TAG"),
|
|
column_default: None,
|
|
extra: Some(category.to_string()),
|
|
}
|
|
}
|
|
|
|
fn table_options() -> DataGridSaveStatementOptions {
|
|
DataGridSaveStatementOptions {
|
|
database_type: Some(DatabaseType::Iotdb),
|
|
identifier_quote: None,
|
|
table_meta: DataGridTableMeta {
|
|
catalog: None,
|
|
database: None,
|
|
schema: Some("dbx_edit_preview".to_string()),
|
|
table_name: "events".to_string(),
|
|
primary_keys: vec!["time".to_string(), "device".to_string()],
|
|
columns: Some(vec![
|
|
column("time", "TIMESTAMP", false, "TIME"),
|
|
column("device", "STRING", false, "TAG"),
|
|
column("event_time", "TIMESTAMP", true, "FIELD"),
|
|
column("temperature", "DOUBLE", true, "FIELD"),
|
|
]),
|
|
},
|
|
columns: vec![
|
|
"time".to_string(),
|
|
"device".to_string(),
|
|
"event_time".to_string(),
|
|
"temperature".to_string(),
|
|
],
|
|
source_columns: None,
|
|
rows: vec![vec![json!("1786954706123"), json!("sensor-a"), json!("1786954706123"), json!(23.5)]],
|
|
dirty_rows: Vec::new(),
|
|
deleted_rows: Vec::new(),
|
|
new_rows: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn prepares_iotdb_timestamp_field_overwrite_and_rollback() {
|
|
let mut options = table_options();
|
|
options.dirty_rows = vec![(0, vec![(2, json!("2026-08-17 17:18:27.123"))])];
|
|
|
|
let result = prepare_data_grid_save(options);
|
|
|
|
assert_eq!(result.validation_error, None);
|
|
assert_eq!(
|
|
result.statements,
|
|
vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES (1786954706123, 'sensor-a', '2026-08-17 17:18:27.123');"]
|
|
);
|
|
assert_eq!(
|
|
result.rollback_statements,
|
|
vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES (1786954706123, 'sensor-a', 1786954706123);"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prepares_iotdb_only_with_changed_fields() {
|
|
let mut options = table_options();
|
|
options.dirty_rows = vec![(0, vec![(2, json!("1786958307123")), (3, json!(24.75))])];
|
|
|
|
let result = prepare_data_grid_save(options);
|
|
|
|
assert_eq!(
|
|
result.statements,
|
|
vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time, temperature) VALUES (1786954706123, 'sensor-a', 1786958307123, 24.75);"]
|
|
);
|
|
assert_eq!(
|
|
result.rollback_statements,
|
|
vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time, temperature) VALUES (1786954706123, 'sensor-a', 1786954706123, 23.5);"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prepares_iotdb_insert_delete_and_rollback() {
|
|
let mut options = table_options();
|
|
options.deleted_rows = vec![0];
|
|
options.new_rows = vec![vec![json!("1786961906789"), json!("sensor-b"), json!("1786961906789"), json!(22.75)]];
|
|
|
|
let result = prepare_data_grid_save(options);
|
|
|
|
assert_eq!(
|
|
result.statements,
|
|
vec![
|
|
"DELETE FROM dbx_edit_preview.events WHERE time = 1786954706123 AND device = 'sensor-a';",
|
|
"INSERT INTO dbx_edit_preview.events (time, device, event_time, temperature) VALUES (1786961906789, 'sensor-b', 1786961906789, 22.75);",
|
|
]
|
|
);
|
|
assert_eq!(
|
|
result.rollback_statements,
|
|
vec![
|
|
"DELETE FROM dbx_edit_preview.events WHERE time = 1786961906789 AND device = 'sensor-b';",
|
|
"INSERT INTO dbx_edit_preview.events (time, device, event_time, temperature) VALUES (1786954706123, 'sensor-a', 1786954706123, 23.5);",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_iotdb_row_identifier_edits() {
|
|
let mut options = table_options();
|
|
options.dirty_rows = vec![(0, vec![(1, json!("sensor-b"))])];
|
|
|
|
let result = prepare_data_grid_save(options);
|
|
|
|
assert_eq!(
|
|
result.validation_error,
|
|
Some("IoTDB row identifier columns (time and TAG) cannot be edited.".to_string())
|
|
);
|
|
assert!(result.statements.is_empty());
|
|
assert!(result.rollback_statements.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_iotdb_null_field_edits_that_insert_cannot_clear() {
|
|
let mut options = table_options();
|
|
options.dirty_rows = vec![(0, vec![(2, Value::Null)])];
|
|
|
|
let result = prepare_data_grid_save(options);
|
|
|
|
assert_eq!(
|
|
result.validation_error,
|
|
Some(
|
|
"IoTDB Table model cannot clear a FIELD or ATTRIBUTE value without deleting the row. Enter a non-NULL value or delete the row instead."
|
|
.to_string()
|
|
)
|
|
);
|
|
assert!(result.statements.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn restores_iotdb_original_null_with_delete_and_full_row_insert() {
|
|
let mut options = table_options();
|
|
options.rows[0][2] = Value::Null;
|
|
options.dirty_rows = vec![(0, vec![(2, json!("1786958307123"))])];
|
|
|
|
let result = prepare_data_grid_save(options);
|
|
|
|
assert_eq!(
|
|
result.statements,
|
|
vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES (1786954706123, 'sensor-a', 1786958307123);"]
|
|
);
|
|
assert_eq!(
|
|
result.rollback_statements,
|
|
vec![
|
|
"DELETE FROM dbx_edit_preview.events WHERE time = 1786954706123 AND device = 'sensor-a';",
|
|
"INSERT INTO dbx_edit_preview.events (time, device, temperature) VALUES (1786954706123, 'sensor-a', 23.5);",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_timestamp_precision_strings_as_unquoted_integer_literals() {
|
|
for (original, changed) in [
|
|
("1786954706123", "1786958307123"),
|
|
("1786954706123456", "1786958307123456"),
|
|
("1786954706123456789", "1786958307123456789"),
|
|
] {
|
|
let mut options = table_options();
|
|
options.rows[0][0] = json!(original);
|
|
options.rows[0][2] = json!(original);
|
|
options.dirty_rows = vec![(0, vec![(2, json!(changed))])];
|
|
|
|
let result = prepare_data_grid_save(options);
|
|
|
|
assert_eq!(
|
|
result.statements,
|
|
vec![format!(
|
|
"INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES ({original}, 'sensor-a', {changed});"
|
|
)]
|
|
);
|
|
assert_eq!(
|
|
result.rollback_statements,
|
|
vec![format!(
|
|
"INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES ({original}, 'sensor-a', {original});"
|
|
)]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_iotdb_tree_model_on_the_existing_sql_path() {
|
|
let mut options = table_options();
|
|
options.table_meta.primary_keys.clear();
|
|
options.table_meta.schema = Some("root.dbx".to_string());
|
|
options.table_meta.table_name = "d1".to_string();
|
|
|
|
assert!(!uses_iotdb_table_model_save(&options));
|
|
}
|
|
}
|