feat(salesforce): add Salesforce SOQL data source

This commit is contained in:
邹超 authored and GitHub committed 2026-09-24 18:25:23 +08:00
1 parent bf3e356540
commit f87b5d1568
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());
}
}
+16
View File
@@ -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);
}
+170
View File
@@ -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 [
+9 -1
View File
@@ -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),
}
}
+32 -4
View File
@@ -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.
@@ -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");
}
}
+7
View File
@@ -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.
+97
View File
@@ -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}");
}
}
}