feat(salesforce): add Salesforce SOQL data source
This commit is contained in:
125 files changed
+9999
-117
No files matched your search
@@ -0,0 +1,459 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::*;
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
/// Header line emitted before every JSON body. The driver's
|
||||
/// `parse_salesforce_statement` matches this case-insensitively.
|
||||
const SALESFORCE_DML_HEADER: &str = "DBX SALESFORCE DML";
|
||||
|
||||
/// Build the save statements for a Salesforce grid save. Order matches the
|
||||
/// generic SQL builder: updates, deletes, inserts (see `build_data_grid_save_statements`).
|
||||
///
|
||||
/// Each statement is a `DBX SALESFORCE DML` pseudo-command carrying a JSON
|
||||
/// body. The driver routes them to the appropriate REST endpoint.
|
||||
pub(super) fn build_salesforce_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||||
let object = &options.table_meta.table_name;
|
||||
let columns = resolve_salesforce_columns(options);
|
||||
let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]);
|
||||
let id_column_index = find_salesforce_id_column_index(&columns, &options.table_meta.primary_keys);
|
||||
let mut statements = Vec::new();
|
||||
|
||||
// 1. Updates (dirty_rows): one statement per dirty row, fields = only the changed columns.
|
||||
for (row_index, changes) in &options.dirty_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else { continue };
|
||||
let Some(id_index) = id_column_index else { continue };
|
||||
let Some(id_value) = row.get(id_index).and_then(Value::as_str).filter(|s| !s.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut fields = Map::new();
|
||||
for (column_index, value) in changes {
|
||||
let Some(field_name) = columns.get(*column_index).and_then(|c| c.as_deref()) else { continue };
|
||||
// Id is the identity, never a write target.
|
||||
if field_name.eq_ignore_ascii_case("Id") {
|
||||
continue;
|
||||
}
|
||||
// Skip columns marked non-updateable in the describe metadata.
|
||||
if !salesforce_column_is_updateable(column_info_for(column_info, field_name)) {
|
||||
continue;
|
||||
}
|
||||
fields.insert(field_name.to_string(), value.clone());
|
||||
}
|
||||
if fields.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
"op": "update",
|
||||
"object": object,
|
||||
"id": id_value,
|
||||
"fields": fields
|
||||
});
|
||||
statements.push(format!("{SALESFORCE_DML_HEADER}\n{body}"));
|
||||
}
|
||||
|
||||
// 2. Deletes (deleted_rows): one statement per deleted row index.
|
||||
for row_index in &options.deleted_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else { continue };
|
||||
let Some(id_index) = id_column_index else { continue };
|
||||
let Some(id_value) = row.get(id_index).and_then(Value::as_str).filter(|s| !s.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let body = serde_json::json!({
|
||||
"op": "delete",
|
||||
"object": object,
|
||||
"id": id_value
|
||||
});
|
||||
statements.push(format!("{SALESFORCE_DML_HEADER}\n{body}"));
|
||||
}
|
||||
|
||||
// 3. Inserts (new_rows): one statement per new row, skipping null and empty-string cells.
|
||||
for row in &options.new_rows {
|
||||
let mut fields = Map::new();
|
||||
for (index, field_name) in columns.iter().enumerate() {
|
||||
let Some(field_name) = field_name.as_deref() else { continue };
|
||||
// Id is server-generated for inserts; never send it.
|
||||
if field_name.eq_ignore_ascii_case("Id") {
|
||||
continue;
|
||||
}
|
||||
// Skip columns marked non-createable in the describe metadata.
|
||||
if !salesforce_column_is_createable(column_info_for(column_info, field_name)) {
|
||||
continue;
|
||||
}
|
||||
let value = row.get(index).unwrap_or(&Value::Null);
|
||||
// Untouched cells must not be sent.
|
||||
if value.is_null() {
|
||||
continue;
|
||||
}
|
||||
if value.is_string() && value.as_str().unwrap_or("").is_empty() {
|
||||
continue;
|
||||
}
|
||||
fields.insert(field_name.to_string(), value.clone());
|
||||
}
|
||||
if fields.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
"op": "insert",
|
||||
"object": object,
|
||||
"fields": fields
|
||||
});
|
||||
statements.push(format!("{SALESFORCE_DML_HEADER}\n{body}"));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
/// Salesforce REST has no transactions — rollback statements are always empty.
|
||||
pub(super) fn build_salesforce_data_grid_rollback_statements(_options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Validate that the Salesforce sObject has a resolvable Id column. Called
|
||||
/// from `validate_data_grid_save` before the builder runs.
|
||||
pub(super) fn validate_salesforce_id_column(options: &DataGridSaveStatementOptions) -> Option<String> {
|
||||
if options.database_type != Some(DatabaseType::Salesforce) {
|
||||
return None;
|
||||
}
|
||||
// Only validate when there are updates, deletes, or inserts that would
|
||||
// need an Id (updates/deletes need it from existing rows; inserts don't
|
||||
// strictly need it, but the grid always has an Id column from the query).
|
||||
if options.dirty_rows.is_empty() && options.deleted_rows.is_empty() && options.new_rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let columns = resolve_salesforce_columns(options);
|
||||
if find_salesforce_id_column_index(&columns, &options.table_meta.primary_keys).is_none() {
|
||||
return Some(format!(
|
||||
"Cannot save Salesforce {}: no Id column found. The grid must include the record Id to enable editing.",
|
||||
options.table_meta.table_name
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve the field name for each display column: `source_columns[i]` when
|
||||
/// present, else `columns[i]`.
|
||||
fn resolve_salesforce_columns(options: &DataGridSaveStatementOptions) -> Vec<Option<String>> {
|
||||
match &options.source_columns {
|
||||
Some(source_columns) if source_columns.len() == options.columns.len() => source_columns.clone(),
|
||||
_ => options.columns.iter().map(|column| Some(column.clone())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate the Id column by matching `table_meta.primary_keys` (case-insensitive)
|
||||
/// against the resolved field names.
|
||||
fn find_salesforce_id_column_index(columns: &[Option<String>], primary_keys: &[String]) -> Option<usize> {
|
||||
// Try primary keys first (case-insensitive).
|
||||
for pk in primary_keys {
|
||||
let pk_upper = pk.to_ascii_uppercase();
|
||||
if let Some(index) =
|
||||
columns.iter().position(|col| col.as_deref().is_some_and(|name| name.to_ascii_uppercase() == pk_upper))
|
||||
{
|
||||
return Some(index);
|
||||
}
|
||||
}
|
||||
// Fallback: look for a column literally named "Id" (case-insensitive).
|
||||
columns.iter().position(|col| col.as_deref().is_some_and(|name| name.eq_ignore_ascii_case("Id")))
|
||||
}
|
||||
|
||||
/// Whether a column should be included in update payloads. Returns `true`
|
||||
/// unless the column's `extra` metadata explicitly marks `updateable: false`.
|
||||
/// Missing or unparseable `extra` → treat as writable (fail-open; Salesforce
|
||||
/// enforces the real rules server-side).
|
||||
fn salesforce_column_is_updateable(info: Option<&DataGridColumnInfo>) -> bool {
|
||||
salesforce_extra_flag(info, "updateable").unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Whether a column should be included in insert payloads. Returns `true`
|
||||
/// unless the column's `extra` metadata explicitly marks `createable: false`.
|
||||
fn salesforce_column_is_createable(info: Option<&DataGridColumnInfo>) -> bool {
|
||||
salesforce_extra_flag(info, "createable").unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Read a boolean flag from the `extra` JSON string on a `DataGridColumnInfo`.
|
||||
/// Returns `None` when `extra` is absent, unparseable, or lacks the key.
|
||||
fn salesforce_extra_flag(info: Option<&DataGridColumnInfo>, key: &str) -> Option<bool> {
|
||||
let extra_str = info?.extra.as_deref()?;
|
||||
let extra: Value = serde_json::from_str(extra_str).ok()?;
|
||||
extra.get(key).and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_options(
|
||||
columns: Vec<&str>,
|
||||
primary_keys: Vec<&str>,
|
||||
dirty_rows: Vec<(usize, Vec<(usize, Value)>)>,
|
||||
deleted_rows: Vec<usize>,
|
||||
new_rows: Vec<Vec<Value>>,
|
||||
rows: Vec<Vec<Value>>,
|
||||
) -> DataGridSaveStatementOptions {
|
||||
DataGridSaveStatementOptions {
|
||||
database_type: Some(DatabaseType::Salesforce),
|
||||
identifier_quote: None,
|
||||
table_meta: DataGridTableMeta {
|
||||
database: None,
|
||||
catalog: None,
|
||||
schema: None,
|
||||
table_name: "Account".to_string(),
|
||||
primary_keys: primary_keys.into_iter().map(str::to_string).collect(),
|
||||
columns: None,
|
||||
},
|
||||
columns: columns.into_iter().map(str::to_string).collect(),
|
||||
source_columns: None,
|
||||
rows,
|
||||
dirty_rows,
|
||||
deleted_rows,
|
||||
new_rows,
|
||||
include_database_name: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_options_with_extra(
|
||||
columns: Vec<&str>,
|
||||
primary_keys: Vec<&str>,
|
||||
extras: Vec<Option<&str>>,
|
||||
) -> DataGridSaveStatementOptions {
|
||||
let cols: Vec<DataGridColumnInfo> = columns
|
||||
.iter()
|
||||
.zip(extras.iter())
|
||||
.map(|(name, extra)| DataGridColumnInfo {
|
||||
name: name.to_string(),
|
||||
data_type: String::new(),
|
||||
is_nullable: true,
|
||||
is_primary_key: false,
|
||||
column_default: None,
|
||||
extra: extra.map(|s| s.to_string()),
|
||||
})
|
||||
.collect();
|
||||
DataGridSaveStatementOptions {
|
||||
database_type: Some(DatabaseType::Salesforce),
|
||||
identifier_quote: None,
|
||||
table_meta: DataGridTableMeta {
|
||||
database: None,
|
||||
catalog: None,
|
||||
schema: None,
|
||||
table_name: "Account".to_string(),
|
||||
primary_keys: primary_keys.into_iter().map(str::to_string).collect(),
|
||||
columns: Some(cols),
|
||||
},
|
||||
columns: columns.into_iter().map(str::to_string).collect(),
|
||||
source_columns: None,
|
||||
rows: Vec::new(),
|
||||
dirty_rows: Vec::new(),
|
||||
deleted_rows: Vec::new(),
|
||||
new_rows: Vec::new(),
|
||||
include_database_name: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_generates_dml_statement() {
|
||||
let opts = make_options(
|
||||
vec!["Id", "Name", "Industry"],
|
||||
vec!["Id"],
|
||||
vec![(0, vec![(1, Value::String("Acme".to_string()))])],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![vec![
|
||||
Value::String("001xx000003DGbY".to_string()),
|
||||
Value::String("Old Name".to_string()),
|
||||
Value::String("Tech".to_string()),
|
||||
]],
|
||||
);
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert_eq!(stmts.len(), 1);
|
||||
assert!(stmts[0].starts_with("DBX SALESFORCE DML\n"));
|
||||
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
|
||||
assert_eq!(body["op"], "update");
|
||||
assert_eq!(body["object"], "Account");
|
||||
assert_eq!(body["id"], "001xx000003DGbY");
|
||||
assert_eq!(body["fields"]["Name"], "Acme");
|
||||
// Industry was not changed → not in fields.
|
||||
assert!(body["fields"].get("Industry").is_none());
|
||||
// Id is never in fields.
|
||||
assert!(body["fields"].get("Id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_generates_dml_statement() {
|
||||
let opts = make_options(
|
||||
vec!["Id", "Name"],
|
||||
vec!["Id"],
|
||||
vec![],
|
||||
vec![0],
|
||||
vec![],
|
||||
vec![vec![Value::String("001xx000003DGbY".to_string()), Value::String("Acme".to_string())]],
|
||||
);
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert_eq!(stmts.len(), 1);
|
||||
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
|
||||
assert_eq!(body["op"], "delete");
|
||||
assert_eq!(body["id"], "001xx000003DGbY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_generates_dml_statement_skipping_nulls_and_empties() {
|
||||
let opts = make_options(
|
||||
vec!["Id", "Name", "Phone", "Website"],
|
||||
vec!["Id"],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![vec![
|
||||
Value::Null,
|
||||
Value::String("Acme".to_string()),
|
||||
Value::String("".to_string()), // empty → skip
|
||||
Value::Null, // null → skip
|
||||
]],
|
||||
vec![],
|
||||
);
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert_eq!(stmts.len(), 1);
|
||||
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
|
||||
assert_eq!(body["op"], "insert");
|
||||
assert_eq!(body["object"], "Account");
|
||||
assert_eq!(body["fields"]["Name"], "Acme");
|
||||
// Id, Phone, Website excluded.
|
||||
assert!(body["fields"].get("Id").is_none());
|
||||
assert!(body["fields"].get("Phone").is_none());
|
||||
assert!(body["fields"].get("Website").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_excluded_from_update_fields() {
|
||||
// Even if Id appears in the changes, it must not be in the fields payload.
|
||||
let opts = make_options(
|
||||
vec!["Id", "Name"],
|
||||
vec!["Id"],
|
||||
vec![(0, vec![(0, Value::String("new-id".to_string())), (1, Value::String("Acme".to_string()))])],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![vec![Value::String("001xx000003DGbY".to_string()), Value::String("Old".to_string())]],
|
||||
);
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert_eq!(stmts.len(), 1);
|
||||
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
|
||||
assert!(body["fields"].get("Id").is_none());
|
||||
assert_eq!(body["fields"]["Name"], "Acme");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updateable_false_columns_skipped_on_update() {
|
||||
let opts = make_options_with_extra(
|
||||
vec!["Id", "Name", "SystemField"],
|
||||
vec!["Id"],
|
||||
vec![
|
||||
None,
|
||||
Some(r#"{"updateable":true,"createable":true}"#),
|
||||
Some(r#"{"updateable":false,"createable":true}"#),
|
||||
],
|
||||
);
|
||||
let mut opts = opts;
|
||||
opts.rows = vec![vec![
|
||||
Value::String("001xx000003DGbY".to_string()),
|
||||
Value::String("Old".to_string()),
|
||||
Value::String("system".to_string()),
|
||||
]];
|
||||
opts.dirty_rows =
|
||||
vec![(0, vec![(1, Value::String("New".to_string())), (2, Value::String("changed".to_string()))])];
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert_eq!(stmts.len(), 1);
|
||||
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
|
||||
assert_eq!(body["fields"]["Name"], "New");
|
||||
assert!(body["fields"].get("SystemField").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn createable_false_columns_skipped_on_insert() {
|
||||
let opts = make_options_with_extra(
|
||||
vec!["Id", "Name", "AutoField"],
|
||||
vec!["Id"],
|
||||
vec![
|
||||
None,
|
||||
Some(r#"{"updateable":true,"createable":true}"#),
|
||||
Some(r#"{"updateable":true,"createable":false}"#),
|
||||
],
|
||||
);
|
||||
let mut opts = opts;
|
||||
opts.new_rows = vec![vec![Value::Null, Value::String("Acme".to_string()), Value::String("auto".to_string())]];
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert_eq!(stmts.len(), 1);
|
||||
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
|
||||
assert_eq!(body["fields"]["Name"], "Acme");
|
||||
assert!(body["fields"].get("AutoField").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_id_column_produces_validation_error() {
|
||||
let opts = make_options(
|
||||
vec!["Name", "Industry"],
|
||||
vec![], // no primary keys
|
||||
vec![(0, vec![(0, Value::String("Acme".to_string()))])],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![vec![Value::String("Old".to_string()), Value::String("Tech".to_string())]],
|
||||
);
|
||||
let error = validate_salesforce_id_column(&opts);
|
||||
assert!(error.is_some());
|
||||
assert!(error.unwrap().contains("no Id column found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_statements_are_empty() {
|
||||
let opts = make_options(vec!["Id", "Name"], vec!["Id"], vec![], vec![], vec![], vec![]);
|
||||
assert!(build_salesforce_data_grid_rollback_statements(&opts).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statement_order_is_updates_deletes_inserts() {
|
||||
let opts = make_options(
|
||||
vec!["Id", "Name"],
|
||||
vec!["Id"],
|
||||
vec![(0, vec![(1, Value::String("Updated".to_string()))])],
|
||||
vec![1],
|
||||
vec![vec![Value::Null, Value::String("New".to_string())]],
|
||||
vec![
|
||||
vec![Value::String("id1".to_string()), Value::String("Row1".to_string())],
|
||||
vec![Value::String("id2".to_string()), Value::String("Row2".to_string())],
|
||||
],
|
||||
);
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert_eq!(stmts.len(), 3);
|
||||
let ops: Vec<&str> = stmts
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let body: Value = serde_json::from_str(s.split_once('\n').unwrap().1).unwrap();
|
||||
// Leak to get a &'static str — fine in tests.
|
||||
Box::leak(body["op"].as_str().unwrap().to_string().into_boxed_str()) as &str
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(ops, vec!["update", "delete", "insert"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_change_set_after_filtering_skips_row() {
|
||||
let opts = make_options(
|
||||
vec!["Id", "Name"],
|
||||
vec!["Id"],
|
||||
// Only change is to Id itself → filtered out → empty fields → skip.
|
||||
vec![(0, vec![(0, Value::String("new-id".to_string()))])],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![vec![Value::String("001xx000003DGbY".to_string()), Value::String("Old".to_string())]],
|
||||
);
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert!(stmts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_salesforce_database_type_returns_no_validation_error() {
|
||||
let mut opts = make_options(vec!["Id"], vec!["Id"], vec![], vec![], vec![], vec![]);
|
||||
opts.database_type = Some(DatabaseType::Postgres);
|
||||
assert!(validate_salesforce_id_column(&opts).is_none());
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,13 @@ use data_grid_tdengine_sql::{
|
||||
validate_tdengine_existing_rows, validate_tdengine_inserted_rows,
|
||||
};
|
||||
|
||||
#[path = "data_grid_salesforce_sql.rs"]
|
||||
mod data_grid_salesforce_sql;
|
||||
use data_grid_salesforce_sql::{
|
||||
build_salesforce_data_grid_rollback_statements, build_salesforce_data_grid_save_statements,
|
||||
validate_salesforce_id_column,
|
||||
};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::sql_dialect::{
|
||||
firebird_rows_clause, quote_table_identifier, table_pagination_strategy, uses_oracle_row_id,
|
||||
@@ -1195,6 +1202,9 @@ fn build_neo4j_data_grid_column_distinct_values_sql(options: &DataGridColumnDist
|
||||
}
|
||||
|
||||
fn validate_data_grid_save(options: &DataGridSaveStatementOptions) -> Option<String> {
|
||||
if let Some(error) = validate_salesforce_id_column(options) {
|
||||
return Some(error);
|
||||
}
|
||||
if let Some(error) = validate_iotdb_existing_rows(options) {
|
||||
return Some(error);
|
||||
}
|
||||
@@ -1461,6 +1471,9 @@ fn build_data_grid_save_statements(
|
||||
if options.database_type == Some(DatabaseType::Tdengine) {
|
||||
return build_tdengine_data_grid_save_statements(options);
|
||||
}
|
||||
if options.database_type == Some(DatabaseType::Salesforce) {
|
||||
return build_salesforce_data_grid_save_statements(options);
|
||||
}
|
||||
if uses_iotdb_table_model_save(options) {
|
||||
return build_iotdb_data_grid_save_statements(options);
|
||||
}
|
||||
@@ -1656,6 +1669,9 @@ fn build_data_grid_rollback_statements(
|
||||
if options.database_type == Some(DatabaseType::Tdengine) {
|
||||
return build_tdengine_data_grid_rollback_statements(options);
|
||||
}
|
||||
if options.database_type == Some(DatabaseType::Salesforce) {
|
||||
return build_salesforce_data_grid_rollback_statements(options);
|
||||
}
|
||||
if uses_iotdb_table_model_save(options) {
|
||||
return build_iotdb_data_grid_rollback_statements(options);
|
||||
}
|
||||
|
||||
@@ -424,6 +424,13 @@ pub fn is_write_sql_for_database(sql: &str, database_type: DatabaseType) -> bool
|
||||
return is_write;
|
||||
}
|
||||
}
|
||||
if database_type == DatabaseType::Salesforce {
|
||||
// SOQL has no write verbs — every write arrives as a DBX pseudo-command —
|
||||
// so the keyword scan below would misread a string literal
|
||||
// (`Subject = 'Delete request'`) or a custom field (`Update__c`) as a
|
||||
// write and lock a read-only session out of an ordinary SELECT.
|
||||
return classify_salesforce_statement_risk(sql) != SalesforceStatementRisk::Read;
|
||||
}
|
||||
if let Some(risk) = classify_search_engine_query_risk(sql, database_type) {
|
||||
return risk != SearchEngineQueryRisk::ReadOnly;
|
||||
}
|
||||
@@ -440,6 +447,68 @@ fn classify_dynamodb_statement_write(source: &str) -> Option<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a Salesforce statement must be treated by write and risk classification.
|
||||
///
|
||||
/// `SfClient::execute_query` accepts exactly two shapes: a SOQL `SELECT`, which
|
||||
/// the REST query endpoint runs read-only, and a `DBX SALESFORCE <…>`
|
||||
/// pseudo-command carrying a JSON body. Anything else is rejected by Salesforce
|
||||
/// anyway, so it is classified as an opaque write and fails closed rather than
|
||||
/// being guessed at by the SQL keyword scan.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SalesforceStatementRisk {
|
||||
/// SOQL `SELECT` — always a read, whatever its literals and field names say.
|
||||
Read,
|
||||
/// Single-record DML naming its object and, for update/delete, its record Id.
|
||||
ScopedWrite,
|
||||
/// Unscoped or unknown DML, unrecognized pseudo-commands, non-SOQL text.
|
||||
OpaqueWrite,
|
||||
}
|
||||
|
||||
pub fn classify_salesforce_statement_risk(source: &str) -> SalesforceStatementRisk {
|
||||
use SalesforceStatementRisk::{OpaqueWrite, Read, ScopedWrite};
|
||||
|
||||
let header = source.lines().find(|line| !line.trim().is_empty()).unwrap_or("").trim();
|
||||
if header.to_ascii_uppercase().starts_with("DBX SALESFORCE") {
|
||||
return if salesforce_dml_is_scoped(source) { ScopedWrite } else { OpaqueWrite };
|
||||
}
|
||||
// Comments are stripped before the verb check, but the pseudo-command branch
|
||||
// above deliberately reads the raw text: a `--` inside a JSON field value
|
||||
// must not be mistaken for a line comment and truncate the payload.
|
||||
let first_word = strip_sql_comments(source)
|
||||
.split(|character: char| !character.is_alphanumeric() && character != '_')
|
||||
.find(|token| !token.is_empty())
|
||||
.unwrap_or("")
|
||||
.to_ascii_uppercase();
|
||||
if first_word == "SELECT" {
|
||||
Read
|
||||
} else {
|
||||
OpaqueWrite
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `DBX SALESFORCE DML` body targets exactly one identified record:
|
||||
/// an insert needs only its object, while an update or delete without an Id has
|
||||
/// nothing bounding it and stays opaque.
|
||||
fn salesforce_dml_is_scoped(source: &str) -> bool {
|
||||
// The header line never contains a brace, so the JSON body starts at the first `{`.
|
||||
let Some(start) = source.find('{') else { return false };
|
||||
let Ok(body) = serde_json::from_str::<serde_json::Value>(&source[start..]) else {
|
||||
return false;
|
||||
};
|
||||
let names_object =
|
||||
body.get("object").and_then(|value| value.as_str()).is_some_and(|object| !object.trim().is_empty());
|
||||
if !names_object {
|
||||
return false;
|
||||
}
|
||||
match body.get("op").and_then(|value| value.as_str()).map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("insert") => true,
|
||||
Some("update") | Some("delete") => {
|
||||
body.get("id").and_then(|value| value.as_str()).is_some_and(|id| !id.trim().is_empty())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SearchEngineQueryRisk {
|
||||
ReadOnly,
|
||||
@@ -2460,6 +2529,107 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_salesforce_statement_risk_scoped_dml() {
|
||||
let update = "DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"id\":\"001x\",\"fields\":{\"Name\":\"Acme\"}}";
|
||||
assert_eq!(classify_salesforce_statement_risk(update), SalesforceStatementRisk::ScopedWrite);
|
||||
let delete = "DBX SALESFORCE DML\n{\"op\":\"delete\",\"object\":\"Account\",\"id\":\"001x\"}";
|
||||
assert_eq!(classify_salesforce_statement_risk(delete), SalesforceStatementRisk::ScopedWrite);
|
||||
let insert = "DBX SALESFORCE DML\n{\"op\":\"insert\",\"object\":\"Lead\",\"fields\":{\"Company\":\"Acme\"}}";
|
||||
assert_eq!(classify_salesforce_statement_risk(insert), SalesforceStatementRisk::ScopedWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_salesforce_statement_risk_unscoped_dml_fails_closed() {
|
||||
// No Id: nothing bounds the write, so it is not a safe write.
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk(
|
||||
"DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"fields\":{}}"
|
||||
),
|
||||
SalesforceStatementRisk::OpaqueWrite
|
||||
);
|
||||
// Unknown op / unknown header / unparseable body all fail closed.
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk(
|
||||
"DBX SALESFORCE DML\n{\"op\":\"upsert\",\"object\":\"Account\",\"id\":\"001x\"}"
|
||||
),
|
||||
SalesforceStatementRisk::OpaqueWrite
|
||||
);
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk("DBX SALESFORCE FUTURE\n{}"),
|
||||
SalesforceStatementRisk::OpaqueWrite
|
||||
);
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk("DBX SALESFORCE DML\n{not json"),
|
||||
SalesforceStatementRisk::OpaqueWrite
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_salesforce_statement_risk_dml_body_survives_comment_lookalikes() {
|
||||
// A `--` inside a field value must not be stripped as a line comment:
|
||||
// the pseudo-command branch reads the raw text, so the body still parses
|
||||
// and the write stays scoped.
|
||||
let dml = "DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"id\":\"001x\",\"fields\":{\"Description\":\"a--b\"}}";
|
||||
assert_eq!(classify_salesforce_statement_risk(dml), SalesforceStatementRisk::ScopedWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_salesforce_statement_risk_soql_is_read() {
|
||||
assert_eq!(classify_salesforce_statement_risk("SELECT Id FROM Account"), SalesforceStatementRisk::Read);
|
||||
assert_eq!(classify_salesforce_statement_risk(" select Name from Lead "), SalesforceStatementRisk::Read);
|
||||
// SOQL forms sqlparser cannot parse must still read as reads.
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk("SELECT FIELDS(ALL) FROM Account LIMIT 200"),
|
||||
SalesforceStatementRisk::Read
|
||||
);
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk("SELECT Id FROM Opportunity WHERE CloseDate = LAST_N_DAYS:7"),
|
||||
SalesforceStatementRisk::Read
|
||||
);
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk("SELECT Id, (SELECT Id FROM Contacts) FROM Account"),
|
||||
SalesforceStatementRisk::Read
|
||||
);
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk("/* report */ SELECT Id FROM Account"),
|
||||
SalesforceStatementRisk::Read
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_salesforce_statement_risk_non_soql_is_opaque_write() {
|
||||
// The driver only accepts SELECT and DBX pseudo-commands; anything else
|
||||
// is a client error, not something to classify as a harmless read.
|
||||
assert_eq!(classify_salesforce_statement_risk("DELETE FROM Account"), SalesforceStatementRisk::OpaqueWrite);
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk("FIND {Acme} IN ALL FIELDS"),
|
||||
SalesforceStatementRisk::OpaqueWrite
|
||||
);
|
||||
assert_eq!(classify_salesforce_statement_risk(""), SalesforceStatementRisk::OpaqueWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_write_sql_for_database_salesforce_soql_is_read() {
|
||||
assert!(!is_write_sql_for_database("SELECT Id FROM Account", DatabaseType::Salesforce));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_write_sql_for_database_salesforce_literals_are_not_writes() {
|
||||
// The generic keyword scan would see DELETE/UPDATE here; SOQL cannot write.
|
||||
assert!(!is_write_sql_for_database(
|
||||
"SELECT Id FROM Case WHERE Subject = 'Delete request'",
|
||||
DatabaseType::Salesforce
|
||||
));
|
||||
assert!(!is_write_sql_for_database("SELECT Update__c FROM Lead", DatabaseType::Salesforce));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_write_sql_for_database_salesforce_dml_is_write() {
|
||||
let dml = "DBX SALESFORCE DML\n{\"op\":\"delete\",\"object\":\"Account\",\"id\":\"x\"}";
|
||||
assert!(is_write_sql_for_database(dml, DatabaseType::Salesforce));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_explicit_transaction_openers_are_recognized() {
|
||||
for sql in [
|
||||
|
||||
@@ -550,9 +550,17 @@ fn err(reason: &str) -> QuerySqlBuildResult {
|
||||
}
|
||||
|
||||
fn unsupported_pagination_type(database_type: Option<DatabaseType>) -> bool {
|
||||
// SOQL has no derived tables and no OFFSET wrapping; the Salesforce driver
|
||||
// pages through QueryLocator cursors (session_id) instead.
|
||||
matches!(
|
||||
database_type,
|
||||
Some(DatabaseType::Neo4j | DatabaseType::MongoDb | DatabaseType::Redis | DatabaseType::Solr)
|
||||
Some(
|
||||
DatabaseType::Neo4j
|
||||
| DatabaseType::MongoDb
|
||||
| DatabaseType::Redis
|
||||
| DatabaseType::Salesforce
|
||||
| DatabaseType::Solr
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -823,7 +823,7 @@ pub fn profile_for(db_type: DatabaseType) -> DdlDialectProfile {
|
||||
// Non-tabular / not applicable for relational CREATE TABLE
|
||||
Redis | MongoDb | DynamoDb | Elasticsearch | Easysearch | Solr | Meilisearch | Qdrant | Milvus | Weaviate
|
||||
| ChromaDb | Neo4j | Cassandra | Etcd | ZooKeeper | Nacos | Consul | InfluxDb3 | VictoriaMetrics
|
||||
| MessageQueue | Mqtt | Hbase => conservative_ansi(db_type),
|
||||
| MessageQueue | Mqtt | Hbase | Salesforce => conservative_ansi(db_type),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ pub enum DialectKind {
|
||||
ManticoreSearch,
|
||||
Informix,
|
||||
Questdb,
|
||||
Soql,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ impl DialectKind {
|
||||
DialectKind::ManticoreSearch => Some(DatabaseType::ManticoreSearch),
|
||||
DialectKind::Informix => Some(DatabaseType::Informix),
|
||||
DialectKind::Questdb => Some(DatabaseType::Questdb),
|
||||
DialectKind::Soql => Some(DatabaseType::Salesforce),
|
||||
DialectKind::Unsupported => None,
|
||||
}
|
||||
}
|
||||
@@ -52,6 +54,7 @@ impl DialectKind {
|
||||
DialectKind::ManticoreSearch => "manticoresearch",
|
||||
DialectKind::Informix => "informix",
|
||||
DialectKind::Questdb => "questdb",
|
||||
DialectKind::Soql => "soql",
|
||||
DialectKind::Unsupported => "unsupported",
|
||||
}
|
||||
}
|
||||
@@ -79,6 +82,7 @@ impl DialectKind {
|
||||
"manticoresearch" => Some(DialectKind::ManticoreSearch),
|
||||
"informix" => Some(DialectKind::Informix),
|
||||
"questdb" => Some(DialectKind::Questdb),
|
||||
"soql" | "salesforce" => Some(DialectKind::Soql),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -585,6 +589,14 @@ impl DialectCapabilityDescriptor {
|
||||
supports_partitioning: true,
|
||||
..Default::default()
|
||||
},
|
||||
DialectKind::Soql => Self {
|
||||
dialect: DialectKind::Soql,
|
||||
flags: 0,
|
||||
max_identifier_length: 40,
|
||||
max_columns_per_table: 500,
|
||||
max_query_size_bytes: 20_000,
|
||||
..Default::default()
|
||||
},
|
||||
DialectKind::Unsupported => Self::default(),
|
||||
}
|
||||
}
|
||||
@@ -941,10 +953,23 @@ impl DialectInfo {
|
||||
|
||||
pub fn all() -> Vec<Self> {
|
||||
use DialectKind::*;
|
||||
vec![Mysql, Postgres, Sqlite, DuckDb, SqlServer, Oracle, H2, ClickHouse, ManticoreSearch, Informix, Questdb]
|
||||
.into_iter()
|
||||
.map(Self::for_kind)
|
||||
.collect()
|
||||
vec![
|
||||
Mysql,
|
||||
Postgres,
|
||||
Sqlite,
|
||||
DuckDb,
|
||||
SqlServer,
|
||||
Oracle,
|
||||
H2,
|
||||
ClickHouse,
|
||||
ManticoreSearch,
|
||||
Informix,
|
||||
Questdb,
|
||||
Soql,
|
||||
]
|
||||
.into_iter()
|
||||
.map(Self::for_kind)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -974,6 +999,7 @@ mod tests {
|
||||
(DatabaseType::ManticoreSearch, DialectKind::ManticoreSearch),
|
||||
(DatabaseType::Informix, DialectKind::Informix),
|
||||
(DatabaseType::Questdb, DialectKind::Questdb),
|
||||
(DatabaseType::Salesforce, DialectKind::Soql),
|
||||
(DatabaseType::Redis, DialectKind::Unsupported),
|
||||
(DatabaseType::MongoDb, DialectKind::Unsupported),
|
||||
];
|
||||
@@ -1001,6 +1027,7 @@ mod tests {
|
||||
DialectKind::ManticoreSearch,
|
||||
DialectKind::Informix,
|
||||
DialectKind::Questdb,
|
||||
DialectKind::Soql,
|
||||
] {
|
||||
let db_type = kind.to_database_type().unwrap();
|
||||
let back = DialectKind::from_database_type(db_type);
|
||||
@@ -1023,6 +1050,7 @@ mod tests {
|
||||
DialectKind::ManticoreSearch,
|
||||
DialectKind::Informix,
|
||||
DialectKind::Questdb,
|
||||
DialectKind::Soql,
|
||||
DialectKind::Unsupported,
|
||||
] {
|
||||
let desc = DialectCapabilityDescriptor::for_dialect(*kind);
|
||||
|
||||
@@ -39,6 +39,7 @@ fn core_catalog_name(kind: DialectKind) -> &'static str {
|
||||
DialectKind::ManticoreSearch => "ManticoreSearch",
|
||||
DialectKind::Informix => "Informix",
|
||||
DialectKind::Questdb => "QuestDB",
|
||||
DialectKind::Soql => "SOQL",
|
||||
DialectKind::Unsupported => "Unsupported",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,6 +959,7 @@ fn kind_name(kind: DialectKind) -> String {
|
||||
DialectKind::ManticoreSearch => "ManticoreSearch",
|
||||
DialectKind::Informix => "Informix",
|
||||
DialectKind::Questdb => "QuestDB",
|
||||
DialectKind::Soql => "SOQL",
|
||||
DialectKind::Unsupported => "Unsupported",
|
||||
}
|
||||
.to_string()
|
||||
@@ -1333,6 +1334,7 @@ identifier_rules:
|
||||
DialectKind::ManticoreSearch,
|
||||
DialectKind::Informix,
|
||||
DialectKind::Questdb,
|
||||
DialectKind::Soql,
|
||||
];
|
||||
for kind in kinds {
|
||||
let yaml = DialectYaml::from_descriptor(*kind);
|
||||
|
||||
@@ -117,6 +117,10 @@ pub fn quote_table_identifier(database_type: Option<DatabaseType>, name: &str) -
|
||||
}
|
||||
match database_type {
|
||||
Some(DatabaseType::Iotdb) => name.to_string(),
|
||||
// SOQL has no delimited identifiers: `SELECT * FROM "Account"` fails with
|
||||
// MALFORMED_QUERY, and double quotes are string literals there. Object and
|
||||
// field API names are always sent bare.
|
||||
Some(DatabaseType::Salesforce) => name.to_string(),
|
||||
// JDBC connections use the driver-reported identifier quote string
|
||||
// (DatabaseMetaData.getIdentifierQuoteString()) inside the JDBC agent,
|
||||
// so the Rust layer passes identifiers through unquoted.
|
||||
|
||||
+20
@@ -426,5 +426,25 @@ expression: all
|
||||
"supports_full_text_index": false,
|
||||
"supports_spatial_index": false,
|
||||
"supports_transactional_ddl": false
|
||||
},
|
||||
{
|
||||
"kind": "soql",
|
||||
"label": "soql",
|
||||
"capabilities": [],
|
||||
"max_identifier_length": 40,
|
||||
"supports_schemas": false,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 500,
|
||||
"supports_json_type": true,
|
||||
"supports_array_type": false,
|
||||
"supports_enum_type": false,
|
||||
"supports_uuid_type": false,
|
||||
"supports_auto_increment": false,
|
||||
"supports_identity_columns": false,
|
||||
"supports_sequences": false,
|
||||
"supports_partitioning": false,
|
||||
"supports_full_text_index": false,
|
||||
"supports_spatial_index": false,
|
||||
"supports_transactional_ddl": false
|
||||
}
|
||||
]
|
||||
@@ -200,6 +200,9 @@ pub fn build_table_data_select_sql_with_database(
|
||||
if database_type == Some(DatabaseType::Neo4j) {
|
||||
return build_neo4j_table_select_sql(&options, limit);
|
||||
}
|
||||
if database_type == Some(DatabaseType::Salesforce) {
|
||||
return build_salesforce_table_select_sql(&options, limit);
|
||||
}
|
||||
if database_type == Some(DatabaseType::VictoriaMetrics) {
|
||||
return format!("{}[1h]", victoriametrics_metric_selector(&options.table_name));
|
||||
}
|
||||
@@ -863,6 +866,47 @@ pub(super) fn build_neo4j_table_select_sql(options: &TableDataSelectSqlOptions,
|
||||
format!("MATCH (n:{label}){where_clause} RETURN {returns}{order}{skip} LIMIT {limit};")
|
||||
}
|
||||
|
||||
/// Salesforce's `FIELDS(ALL)` selector is only legal with a LIMIT of 200 or less.
|
||||
const SALESFORCE_FIELDS_ALL_MAX_LIMIT: usize = 200;
|
||||
|
||||
/// Builds the SOQL used by the data-table grid for a Salesforce sObject.
|
||||
///
|
||||
/// SOQL is not SQL in three ways that matter here: there is no `SELECT *`, there
|
||||
/// are no delimited identifiers (`FROM "Account"` is a `MALFORMED_QUERY`), and
|
||||
/// the "everything" projection is the `FIELDS(ALL)` selector, which Salesforce
|
||||
/// only accepts with `LIMIT 200` or less. Known fields are therefore projected
|
||||
/// by name — that keeps any page size working — and `FIELDS(ALL)` is the capped
|
||||
/// fallback for the callers that build SQL before the describe cache has loaded.
|
||||
///
|
||||
/// The statement carries no trailing semicolon: SOQL does not accept one.
|
||||
pub(super) fn build_salesforce_table_select_sql(options: &TableDataSelectSqlOptions, limit: usize) -> String {
|
||||
let object = options.table_name.trim();
|
||||
let columns: Vec<&str> = options
|
||||
.columns
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|column| !column.is_empty() && !column.eq_ignore_ascii_case(DBX_ROWID_COLUMN))
|
||||
.collect();
|
||||
let (projection, effective_limit) = if columns.is_empty() {
|
||||
("FIELDS(ALL)".to_string(), limit.min(SALESFORCE_FIELDS_ALL_MAX_LIMIT))
|
||||
} else {
|
||||
(columns.join(", "), limit)
|
||||
};
|
||||
// Passed through verbatim: a grid filter is the user's own predicate, and
|
||||
// SOQL WHERE accepts the same parenthesised shape. Wrapping it in parentheses
|
||||
// keeps a caller-supplied `OR` from binding outside the filter.
|
||||
let predicate = normalize_where_input(options.where_input.as_deref());
|
||||
let where_clause = if predicate.is_empty() { String::new() } else { format!(" WHERE ({predicate})") };
|
||||
let order_by = options.order_by.as_deref().map(str::trim).filter(|order_by| !order_by.is_empty());
|
||||
let order = order_by.map(|order_by| format!(" ORDER BY {order_by}")).unwrap_or_default();
|
||||
// SOQL caps OFFSET at 2000 rows. Past that the org rejects the query, and
|
||||
// surfacing its own error beats silently returning an earlier page.
|
||||
let offset =
|
||||
options.offset.filter(|offset| *offset > 0).map(|offset| format!(" OFFSET {offset}")).unwrap_or_default();
|
||||
format!("SELECT {projection} FROM {object}{where_clause}{order} LIMIT {effective_limit}{offset}")
|
||||
}
|
||||
|
||||
pub(super) fn build_questdb_table_select_sql(
|
||||
table: &str,
|
||||
where_clause: &str,
|
||||
@@ -1179,4 +1223,86 @@ mod tests {
|
||||
|
||||
assert_eq!(build_table_data_select_sql(options), "SELECT [id], [name] FROM [users] ORDER BY [id] ASC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salesforce_table_select_projects_described_fields_as_soql() {
|
||||
// SOQL has no `SELECT *` and no delimited identifiers, so the ANSI shape the
|
||||
// grid used to emit (`SELECT * FROM "Account" LIMIT 100`) fails twice over:
|
||||
// the star is not a SOQL selector and the quote is read as a string literal,
|
||||
// which Salesforce reports as MALFORMED_QUERY at column 14. Fields from the
|
||||
// describe go out bare and by name. No trailing semicolon either — SOQL
|
||||
// rejects one.
|
||||
let mut options = opts(DatabaseType::Salesforce, Some("sales"), Some("org"), "Account");
|
||||
options.columns = vec!["Id".to_string(), "Name".to_string(), "First_Name__c".to_string()];
|
||||
options.limit = Some(100);
|
||||
|
||||
// A Salesforce org is a single scope: schema/database qualification has no
|
||||
// SOQL spelling and would land in the FROM clause as a parse error.
|
||||
assert_eq!(build_table_data_select_sql(options), "SELECT Id, Name, First_Name__c FROM Account LIMIT 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salesforce_table_select_caps_the_fields_all_fallback() {
|
||||
// Callers that build SQL before the describe cache has loaded have no field
|
||||
// list. `FIELDS(ALL)` is the SOQL "everything" projection, but Salesforce
|
||||
// only accepts it with LIMIT 200 or less, so a larger page size is clamped
|
||||
// instead of being sent as a query the org would refuse outright.
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::Salesforce),
|
||||
table_name: "Account".to_string(),
|
||||
limit: Some(1000),
|
||||
..Default::default()
|
||||
}),
|
||||
"SELECT FIELDS(ALL) FROM Account LIMIT 200"
|
||||
);
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::Salesforce),
|
||||
table_name: "Account".to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
"SELECT FIELDS(ALL) FROM Account LIMIT 100"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salesforce_table_select_keeps_an_explicit_projection_at_any_page_size() {
|
||||
// The 200-row clamp is a property of FIELDS(), not of SOQL: naming the
|
||||
// fields keeps a 1000-row page legal.
|
||||
let mut options = opts(DatabaseType::Salesforce, None, None, "Account");
|
||||
options.columns = vec!["Id".to_string(), "Name".to_string()];
|
||||
options.limit = Some(1000);
|
||||
|
||||
assert_eq!(build_table_data_select_sql(options), "SELECT Id, Name FROM Account LIMIT 1000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salesforce_table_select_composes_filter_order_and_offset() {
|
||||
let mut options = opts(DatabaseType::Salesforce, None, None, "Account");
|
||||
options.columns = vec!["Id".to_string(), "Name".to_string()];
|
||||
options.where_input = Some("WHERE Industry = 'Energy' OR AnnualRevenue > 0".to_string());
|
||||
options.order_by = Some("Name ASC".to_string());
|
||||
options.limit = Some(50);
|
||||
options.offset = Some(150);
|
||||
|
||||
// The leading WHERE is stripped and the predicate is parenthesised so a
|
||||
// caller-supplied OR cannot bind outside the grid filter.
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(options),
|
||||
"SELECT Id, Name FROM Account WHERE (Industry = 'Energy' OR AnnualRevenue > 0) ORDER BY Name ASC LIMIT 50 OFFSET 150"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salesforce_table_select_drops_the_synthetic_row_id() {
|
||||
// The grid's synthetic row-id column is a DBX artifact for drivers without a
|
||||
// primary key; it is not an sObject field, so projecting it would be an
|
||||
// invalid field name in SOQL.
|
||||
let mut options = opts(DatabaseType::Salesforce, None, None, "Account");
|
||||
options.columns = vec!["Id".to_string(), DBX_ROWID_COLUMN.to_string()];
|
||||
options.include_row_id = true;
|
||||
|
||||
assert_eq!(build_table_data_select_sql(options), "SELECT Id FROM Account LIMIT 10");
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,13 @@ fn quotes_identifiers_by_database_type() {
|
||||
assert_eq!(quote_table_identifier(Some(DatabaseType::Jdbc), "users_1"), "users_1");
|
||||
assert_eq!(quote_table_identifier(Some(DatabaseType::Jdbc), "user name"), "user name");
|
||||
assert_eq!(quote_table_identifier(Some(DatabaseType::Iotdb), "root.test.device2"), "root.test.device2");
|
||||
// SOQL has no delimited identifiers and reads `"` as a string literal, so
|
||||
// `SELECT * FROM "Account"` fails with MALFORMED_QUERY. Object and field API
|
||||
// names always go out bare, and an org is a single scope: no schema qualifier.
|
||||
assert_eq!(quote_table_identifier(Some(DatabaseType::Salesforce), "Account"), "Account");
|
||||
assert_eq!(quote_table_identifier(Some(DatabaseType::Salesforce), "First_Name__c"), "First_Name__c");
|
||||
assert_eq!(quote_table_data_identifier(Some(DatabaseType::Salesforce), "Account", None), "Account");
|
||||
assert_eq!(qualified_table_name(Some(DatabaseType::Salesforce), Some("sales"), "Account"), "Account");
|
||||
assert_eq!(quote_table_identifier(Some(DatabaseType::Spanner), "user`name"), "`user``name`");
|
||||
// ArgoDB shares the Hive-family dialect: backticks quote identifiers and
|
||||
// double quotes are string literals, and schemas qualify table names.
|
||||
|
||||
@@ -695,6 +695,13 @@ pub fn classify_sql_risk(sql: &str, dialect: &str) -> Result<SqlRisk, String> {
|
||||
/// Classify SQL risk using both the parser dialect and the concrete database
|
||||
/// type so dialect-specific write forms cannot be mistaken for read queries.
|
||||
pub fn classify_sql_risk_for_database(sql: &str, database_type: DatabaseType) -> Result<SqlRisk, String> {
|
||||
if database_type == DatabaseType::Salesforce {
|
||||
// SOQL is not SQL: sqlparser either misreads it or fails, and the
|
||||
// keyword fallback would then judge a statement by the words inside its
|
||||
// string literals. The Salesforce classifier knows the only two shapes
|
||||
// the driver accepts.
|
||||
return Ok(salesforce_risk_to_sql_risk(crate::query_execution_sql::classify_salesforce_statement_risk(sql)));
|
||||
}
|
||||
if let Some(risk) = crate::query_execution_sql::classify_search_engine_query_risk(sql, database_type) {
|
||||
return Ok(match risk {
|
||||
crate::query_execution_sql::SearchEngineQueryRisk::ReadOnly => SqlRisk::ReadOnly,
|
||||
@@ -707,11 +714,28 @@ pub fn classify_sql_risk_for_database(sql: &str, database_type: DatabaseType) ->
|
||||
classify_sql_risk_with_database(sql, normalized, Some(database_type))
|
||||
}
|
||||
|
||||
/// Map the Salesforce classifier onto the shared risk tiers. A single-record DML
|
||||
/// that names its record is an ordinary write, so "safe write" permission is
|
||||
/// enough for it; an unscoped or unrecognized statement takes the high-risk tier
|
||||
/// and needs the central dangerous-operation permission.
|
||||
fn salesforce_risk_to_sql_risk(risk: crate::query_execution_sql::SalesforceStatementRisk) -> SqlRisk {
|
||||
use crate::query_execution_sql::SalesforceStatementRisk;
|
||||
match risk {
|
||||
SalesforceStatementRisk::Read => SqlRisk::ReadOnly,
|
||||
SalesforceStatementRisk::ScopedWrite => SqlRisk::Write,
|
||||
SalesforceStatementRisk::OpaqueWrite => SqlRisk::Ddl,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether MCP must require the central dangerous-operation permission.
|
||||
/// Parse failures fail closed for writes. Safe-write mode permits plain INSERT
|
||||
/// and single-table UPDATE/DELETE statements with an effective predicate;
|
||||
/// broader or opaque mutations require central high-risk permission.
|
||||
pub fn is_dangerous_sql_for_database(sql: &str, database_type: DatabaseType) -> bool {
|
||||
if database_type == DatabaseType::Salesforce {
|
||||
return crate::query_execution_sql::classify_salesforce_statement_risk(sql)
|
||||
== crate::query_execution_sql::SalesforceStatementRisk::OpaqueWrite;
|
||||
}
|
||||
if let Some(risk) = crate::query_execution_sql::classify_search_engine_query_risk(sql, database_type) {
|
||||
return risk == crate::query_execution_sql::SearchEngineQueryRisk::Dangerous;
|
||||
}
|
||||
@@ -744,6 +768,12 @@ pub fn mcp_sql_has_forbidden_database_switch(sql: &str, database_type: DatabaseT
|
||||
if crate::query_execution_sql::classify_search_engine_query_risk(sql, database_type).is_some() {
|
||||
return false;
|
||||
}
|
||||
if database_type == DatabaseType::Salesforce {
|
||||
// SOQL has no USE, and the Salesforce pool is keyed per connection rather
|
||||
// than per database, so no statement can redirect a later call. Returning
|
||||
// early also keeps the SQL parser off the JSON body of a pseudo-command.
|
||||
return false;
|
||||
}
|
||||
let database_type_name = format!("{database_type:?}");
|
||||
let normalized = normalize_dialect(&database_type_name);
|
||||
let dialect = resolve_dialect(normalized);
|
||||
@@ -1218,6 +1248,7 @@ impl Visitor for ProofFunctionVisitor<'_> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::query_execution_sql::is_write_sql_for_database;
|
||||
|
||||
#[test]
|
||||
fn mysql_proof_accepts_plain_reads_and_allowlisted_functions() {
|
||||
@@ -1861,4 +1892,70 @@ mod tests {
|
||||
assert!(is_dangerous_sql_for_database("DELETE /products", database_type));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_salesforce_soql_as_read_even_when_sqlparser_cannot() {
|
||||
let database_type = DatabaseType::Salesforce;
|
||||
for soql in [
|
||||
"SELECT Id, Name FROM Account",
|
||||
// SOQL-only forms: FIELDS(), date literals, relationship subqueries.
|
||||
"SELECT FIELDS(ALL) FROM Account LIMIT 200",
|
||||
"SELECT Id FROM Opportunity WHERE CloseDate = LAST_N_DAYS:7",
|
||||
"SELECT Id, (SELECT Id FROM Contacts) FROM Account",
|
||||
// A literal that names a write verb is still a read.
|
||||
"SELECT Id FROM Case WHERE Subject = 'Delete request'",
|
||||
] {
|
||||
assert_eq!(classify_sql_risk_for_database(soql, database_type).unwrap(), SqlRisk::ReadOnly, "{soql}");
|
||||
assert!(!is_dangerous_sql_for_database(soql, database_type), "{soql}");
|
||||
assert!(!is_write_sql_for_database(soql, database_type), "{soql}");
|
||||
assert!(!mcp_sql_has_forbidden_database_switch(soql, database_type), "{soql}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_scoped_salesforce_dml_as_a_safe_write() {
|
||||
let database_type = DatabaseType::Salesforce;
|
||||
let update = "DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Opportunity\",\"id\":\"006x\",\"fields\":{\"StageName\":\"Closed Won\"}}";
|
||||
let insert = "DBX SALESFORCE DML\n{\"op\":\"insert\",\"object\":\"Lead\",\"fields\":{\"Company\":\"Acme\"}}";
|
||||
let delete = "DBX SALESFORCE DML\n{\"op\":\"delete\",\"object\":\"Lead\",\"id\":\"00Qx\"}";
|
||||
for dml in [update, insert, delete] {
|
||||
// A single identified record is a scoped write: "safe write" permission
|
||||
// is enough, the central dangerous-operation permission is not.
|
||||
assert_eq!(classify_sql_risk_for_database(dml, database_type).unwrap(), SqlRisk::Write, "{dml}");
|
||||
assert!(!is_dangerous_sql_for_database(dml, database_type), "{dml}");
|
||||
assert!(is_write_sql_for_database(dml, database_type), "{dml}");
|
||||
// The JSON body is never parsed as SQL, so a `USE`-shaped value inside
|
||||
// it cannot trip the database-switch guard.
|
||||
assert!(!mcp_sql_has_forbidden_database_switch(dml, database_type), "{dml}");
|
||||
}
|
||||
assert!(!mcp_sql_has_forbidden_database_switch(
|
||||
"DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"id\":\"001x\",\"fields\":{\"Description\":\"USE prod\"}}",
|
||||
database_type
|
||||
));
|
||||
// The tokenizer fallback treats a `;` inside a JSON value as a statement
|
||||
// boundary, so without the Salesforce branch this write would be refused
|
||||
// as a database switch. SOQL cannot switch databases at all.
|
||||
assert!(!mcp_sql_has_forbidden_database_switch(
|
||||
"DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"id\":\"001x\",\"fields\":{\"Description\":\"a; USE prod\"}}",
|
||||
database_type
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_unscoped_salesforce_writes_as_high_risk() {
|
||||
let database_type = DatabaseType::Salesforce;
|
||||
for statement in [
|
||||
// No Id: nothing bounds the write.
|
||||
"DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"fields\":{\"Name\":\"x\"}}",
|
||||
// Unknown operation and unknown pseudo-command headers fail closed.
|
||||
"DBX SALESFORCE DML\n{\"op\":\"upsert\",\"object\":\"Account\",\"externalId\":\"E-1\"}",
|
||||
"DBX SALESFORCE BULK\n{}",
|
||||
// Non-SOQL text the driver would reject anyway.
|
||||
"DELETE FROM Account",
|
||||
] {
|
||||
assert_eq!(classify_sql_risk_for_database(statement, database_type).unwrap(), SqlRisk::Ddl, "{statement}");
|
||||
assert!(is_dangerous_sql_for_database(statement, database_type), "{statement}");
|
||||
assert!(is_write_sql_for_database(statement, database_type), "{statement}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user