Skip to main content

busbar_sf_tooling/client/
collections.rs

1use busbar_sf_client::security::{soql, url as url_security};
2use tracing::instrument;
3
4use crate::error::{Error, ErrorKind, Result};
5
6impl super::ToolingClient {
7    /// Get multiple Tooling API records by ID in a single request.
8    ///
9    /// Uses a Tooling API SOQL query (`WHERE Id IN (...)`) internally.
10    /// The Tooling API's SObject Collections GET endpoint is documented but
11    /// does not work reliably for most objects, so we use SOQL instead.
12    ///
13    /// # Arguments
14    /// * `sobject` - The SObject type (e.g., "ApexClass", "CustomField")
15    /// * `ids` - Array of record IDs to retrieve
16    /// * `fields` - Array of field names to return
17    ///
18    /// # Example
19    ///
20    /// ```rust,ignore
21    /// let classes: Vec<serde_json::Value> = client
22    ///     .get_multiple("ApexClass", &["01p...", "01p..."], &["Id", "Name", "Body"])
23    ///     .await?;
24    /// ```
25    #[instrument(skip(self))]
26    pub async fn get_multiple<T: serde::de::DeserializeOwned + Clone>(
27        &self,
28        sobject: &str,
29        ids: &[&str],
30        fields: &[&str],
31    ) -> Result<Vec<T>> {
32        if ids.is_empty() {
33            return Ok(Vec::new());
34        }
35        if !soql::is_safe_sobject_name(sobject) {
36            return Err(Error::new(ErrorKind::Salesforce {
37                error_code: "INVALID_SOBJECT".to_string(),
38                message: "Invalid SObject name".to_string(),
39            }));
40        }
41        for id in ids {
42            if !url_security::is_valid_salesforce_id(id) {
43                return Err(Error::new(ErrorKind::Salesforce {
44                    error_code: "INVALID_ID".to_string(),
45                    message: "Invalid Salesforce ID format".to_string(),
46                }));
47            }
48        }
49        let safe_fields: Vec<&str> = soql::filter_safe_fields(fields.iter().copied()).collect();
50        if safe_fields.is_empty() {
51            return Err(Error::new(ErrorKind::Salesforce {
52                error_code: "INVALID_FIELDS".to_string(),
53                message: "No valid field names provided".to_string(),
54            }));
55        }
56        // Build a SOQL query: SELECT fields FROM sobject WHERE Id IN ('id1','id2',...)
57        // IDs are already validated by is_valid_salesforce_id (alphanumeric only),
58        // so they are safe to embed directly.
59        let fields_clause = safe_fields.join(", ");
60        let ids_clause: Vec<String> = ids.iter().map(|id| format!("'{id}'")).collect();
61        let soql = format!(
62            "SELECT {} FROM {} WHERE Id IN ({})",
63            fields_clause,
64            sobject,
65            ids_clause.join(", ")
66        );
67        self.query_all(&soql).await
68    }
69
70    /// Create multiple Tooling API records in a single request (up to 200).
71    ///
72    /// Available since API v45.0.
73    ///
74    /// # Arguments
75    /// * `sobject` - The SObject type (e.g., "ApexClass", "CustomField")
76    /// * `records` - Array of records to create
77    /// * `all_or_none` - If true, all records must succeed or all fail
78    ///
79    /// # Example
80    ///
81    /// ```rust,ignore
82    /// use serde_json::json;
83    ///
84    /// let records = vec![
85    ///     json!({"Name": "TestClass1", "Body": "public class TestClass1 {}"}),
86    ///     json!({"Name": "TestClass2", "Body": "public class TestClass2 {}"}),
87    /// ];
88    ///
89    /// let results = client.create_multiple("ApexClass", &records, false).await?;
90    /// ```
91    #[instrument(skip(self, records))]
92    pub async fn create_multiple<T: serde::Serialize>(
93        &self,
94        sobject: &str,
95        records: &[T],
96        all_or_none: bool,
97    ) -> Result<Vec<busbar_sf_rest::CollectionResult>> {
98        if !soql::is_safe_sobject_name(sobject) {
99            return Err(Error::new(ErrorKind::Salesforce {
100                error_code: "INVALID_SOBJECT".to_string(),
101                message: "Invalid SObject name".to_string(),
102            }));
103        }
104        let request = busbar_sf_rest::CollectionRequest {
105            all_or_none,
106            records: records
107                .iter()
108                .map(|r| {
109                    let mut value = serde_json::to_value(r).unwrap_or(serde_json::Value::Null);
110                    if let serde_json::Value::Object(ref mut map) = value {
111                        map.insert(
112                            "attributes".to_string(),
113                            serde_json::json!({"type": sobject}),
114                        );
115                    }
116                    value
117                })
118                .collect(),
119        };
120        let url = self.client.tooling_url("composite/sobjects");
121        self.client
122            .post_json(&url, &request)
123            .await
124            .map_err(Into::into)
125    }
126
127    /// Update multiple Tooling API records in a single request (up to 200).
128    ///
129    /// Available since API v45.0.
130    ///
131    /// # Arguments
132    /// * `sobject` - The SObject type (e.g., "ApexClass", "CustomField")
133    /// * `records` - Array of (id, record) tuples to update
134    /// * `all_or_none` - If true, all records must succeed or all fail
135    #[instrument(skip(self, records))]
136    pub async fn update_multiple<T: serde::Serialize>(
137        &self,
138        sobject: &str,
139        records: &[(String, T)],
140        all_or_none: bool,
141    ) -> Result<Vec<busbar_sf_rest::CollectionResult>> {
142        if !soql::is_safe_sobject_name(sobject) {
143            return Err(Error::new(ErrorKind::Salesforce {
144                error_code: "INVALID_SOBJECT".to_string(),
145                message: "Invalid SObject name".to_string(),
146            }));
147        }
148        // Validate all IDs
149        for (id, _) in records {
150            if !url_security::is_valid_salesforce_id(id) {
151                return Err(Error::new(ErrorKind::Salesforce {
152                    error_code: "INVALID_ID".to_string(),
153                    message: "Invalid Salesforce ID format".to_string(),
154                }));
155            }
156        }
157        let request = busbar_sf_rest::CollectionRequest {
158            all_or_none,
159            records: records
160                .iter()
161                .map(|(id, r)| {
162                    let mut value = serde_json::to_value(r).unwrap_or(serde_json::Value::Null);
163                    if let serde_json::Value::Object(ref mut map) = value {
164                        map.insert(
165                            "attributes".to_string(),
166                            serde_json::json!({"type": sobject}),
167                        );
168                        map.insert("Id".to_string(), serde_json::json!(id));
169                    }
170                    value
171                })
172                .collect(),
173        };
174
175        let url = self.client.tooling_url("composite/sobjects");
176        let request_builder = self.client.patch(&url).json(&request)?;
177        let response = self.client.execute(request_builder).await?;
178        response.json().await.map_err(Into::into)
179    }
180
181    /// Delete multiple Tooling API records in a single request (up to 200).
182    ///
183    /// Available since API v45.0.
184    ///
185    /// # Arguments
186    /// * `ids` - Array of record IDs to delete
187    /// * `all_or_none` - If true, all records must succeed or all fail
188    #[instrument(skip(self))]
189    pub async fn delete_multiple(
190        &self,
191        ids: &[&str],
192        all_or_none: bool,
193    ) -> Result<Vec<busbar_sf_rest::CollectionResult>> {
194        // Validate all IDs before proceeding
195        for id in ids {
196            if !url_security::is_valid_salesforce_id(id) {
197                return Err(Error::new(ErrorKind::Salesforce {
198                    error_code: "INVALID_ID".to_string(),
199                    message: "Invalid Salesforce ID format".to_string(),
200                }));
201            }
202        }
203        let ids_param = ids.join(",");
204        let url = format!(
205            "{}/services/data/v{}/tooling/composite/sobjects?ids={}&allOrNone={}",
206            self.client.instance_url(),
207            self.client.api_version(),
208            ids_param,
209            all_or_none
210        );
211        let request = self.client.delete(&url);
212        let response = self.client.execute(request).await?;
213        response.json().await.map_err(Into::into)
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::super::ToolingClient;
220
221    #[test]
222    fn test_collections_get_soql_construction() {
223        let sobject = "ApexClass";
224        let ids = ["01p000000000001AAA", "01p000000000002AAA"];
225        let fields = ["Id", "Name"];
226
227        let fields_clause = fields.join(", ");
228        let ids_clause: Vec<String> = ids.iter().map(|id| format!("'{id}'")).collect();
229        let soql = format!(
230            "SELECT {} FROM {} WHERE Id IN ({})",
231            fields_clause,
232            sobject,
233            ids_clause.join(", ")
234        );
235
236        assert_eq!(
237            soql,
238            "SELECT Id, Name FROM ApexClass WHERE Id IN ('01p000000000001AAA', '01p000000000002AAA')"
239        );
240    }
241
242    #[test]
243    fn test_collections_create_url_construction() {
244        let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
245
246        let url = client.client.tooling_url("composite/sobjects");
247        assert_eq!(
248            url,
249            "https://na1.salesforce.com/services/data/v62.0/tooling/composite/sobjects"
250        );
251    }
252
253    #[test]
254    fn test_collections_delete_url_construction() {
255        let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
256
257        let ids = ["01p000000000001AAA", "01p000000000002AAA"];
258        let ids_param = ids.join(",");
259
260        let url = format!(
261            "{}/services/data/v{}/tooling/composite/sobjects?ids={}&allOrNone={}",
262            client.client.instance_url(),
263            client.client.api_version(),
264            ids_param,
265            false
266        );
267
268        assert_eq!(
269            url,
270            "https://na1.salesforce.com/services/data/v62.0/tooling/composite/sobjects?ids=01p000000000001AAA,01p000000000002AAA&allOrNone=false"
271        );
272    }
273
274    #[tokio::test]
275    async fn test_get_multiple_empty_ids_returns_empty() {
276        let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
277        let result: Vec<serde_json::Value> = client
278            .get_multiple("ApexClass", &[], &["Id", "Name"])
279            .await
280            .unwrap();
281        assert!(result.is_empty());
282    }
283
284    #[tokio::test]
285    async fn test_get_multiple_invalid_sobject_name() {
286        let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
287        let result: std::result::Result<Vec<serde_json::Value>, _> = client
288            .get_multiple("Robert'; DROP TABLE--", &["01p000000000001AAA"], &["Id"])
289            .await;
290        assert!(result.is_err());
291        let err = result.unwrap_err();
292        assert!(
293            err.to_string().contains("INVALID_SOBJECT"),
294            "Expected INVALID_SOBJECT error, got: {err}"
295        );
296    }
297
298    #[tokio::test]
299    async fn test_get_multiple_invalid_id_format() {
300        let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
301        let result: std::result::Result<Vec<serde_json::Value>, _> = client
302            .get_multiple("ApexClass", &["not-a-valid-sf-id!"], &["Id"])
303            .await;
304        assert!(result.is_err());
305        let err = result.unwrap_err();
306        assert!(
307            err.to_string().contains("INVALID_ID"),
308            "Expected INVALID_ID error, got: {err}"
309        );
310    }
311
312    #[tokio::test]
313    async fn test_get_multiple_invalid_fields_filtered() {
314        let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
315        let result: std::result::Result<Vec<serde_json::Value>, _> = client
316            .get_multiple(
317                "ApexClass",
318                &["01p000000000001AAA"],
319                &["'; DROP TABLE--", "1=1 OR"],
320            )
321            .await;
322        assert!(result.is_err());
323        let err = result.unwrap_err();
324        assert!(
325            err.to_string().contains("INVALID_FIELDS"),
326            "Expected INVALID_FIELDS error, got: {err}"
327        );
328    }
329
330    #[test]
331    fn test_get_multiple_soql_construction_with_many_ids() {
332        let sobject = "ApexClass";
333        let ids = [
334            "01p000000000001AAA",
335            "01p000000000002AAA",
336            "01p000000000003AAA",
337        ];
338        let fields = ["Id", "Name", "Body"];
339
340        let fields_clause = fields.join(", ");
341        let ids_clause: Vec<String> = ids.iter().map(|id| format!("'{id}'")).collect();
342        let soql = format!(
343            "SELECT {} FROM {} WHERE Id IN ({})",
344            fields_clause,
345            sobject,
346            ids_clause.join(", ")
347        );
348
349        assert_eq!(
350            soql,
351            "SELECT Id, Name, Body FROM ApexClass WHERE Id IN ('01p000000000001AAA', '01p000000000002AAA', '01p000000000003AAA')"
352        );
353    }
354}