Skip to main content

busbar_sf_tooling/client/
query.rs

1use serde::de::DeserializeOwned;
2use tracing::instrument;
3
4use busbar_sf_client::QueryResult;
5
6use crate::error::Result;
7use crate::types::SearchResult;
8
9impl super::ToolingClient {
10    /// Execute a SOQL query against the Tooling API.
11    ///
12    /// Returns the first page of results. Use `query_all` for automatic pagination.
13    ///
14    /// # Security
15    ///
16    /// **IMPORTANT**: If you are including user-provided values in the WHERE clause,
17    /// you MUST escape them to prevent SOQL injection attacks:
18    ///
19    /// ```rust,ignore
20    /// use busbar_sf_client::security::soql;
21    ///
22    /// // CORRECT - properly escaped:
23    /// let safe_name = soql::escape_string(user_input);
24    /// let query = format!("SELECT Id FROM ApexClass WHERE Name = '{}'", safe_name);
25    /// ```
26    #[instrument(skip(self))]
27    pub async fn query<T: DeserializeOwned>(&self, soql: &str) -> Result<QueryResult<T>> {
28        self.client.tooling_query(soql).await.map_err(Into::into)
29    }
30
31    /// Execute a SOQL query and return all results (automatic pagination).
32    ///
33    /// # Security
34    ///
35    /// **IMPORTANT**: Escape user-provided values with `busbar_sf_client::security::soql::escape_string()`
36    /// to prevent SOQL injection attacks. See `query()` for examples.
37    #[instrument(skip(self))]
38    pub async fn query_all<T: DeserializeOwned + Clone>(&self, soql: &str) -> Result<Vec<T>> {
39        self.client
40            .tooling_query_all(soql)
41            .await
42            .map_err(Into::into)
43    }
44
45    /// Execute a SOQL query including deleted and archived records.
46    ///
47    /// Note: The Tooling API does not expose a `/queryAll` endpoint.
48    /// This method uses the standard REST API `/queryAll` resource, which
49    /// also works for Tooling API objects (ApexClass, ApexTrigger, etc.).
50    #[instrument(skip(self))]
51    pub async fn query_all_records<T: DeserializeOwned>(
52        &self,
53        soql: &str,
54    ) -> Result<QueryResult<T>> {
55        let encoded = urlencoding::encode(soql);
56        let url = format!(
57            "{}/services/data/v{}/queryAll/?q={}",
58            self.client.instance_url(),
59            self.client.api_version(),
60            encoded
61        );
62        self.client.get_json(&url).await.map_err(Into::into)
63    }
64
65    /// Execute a SOSL search against Tooling API objects.
66    #[instrument(skip(self))]
67    pub async fn search<T: DeserializeOwned>(&self, sosl: &str) -> Result<SearchResult<T>> {
68        let encoded = urlencoding::encode(sosl);
69        let url = format!(
70            "{}/services/data/v{}/tooling/search/?q={}",
71            self.client.instance_url(),
72            self.client.api_version(),
73            encoded
74        );
75        self.client.get_json(&url).await.map_err(Into::into)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::super::ToolingClient;
82
83    #[tokio::test]
84    async fn test_query_all_records_wiremock() {
85        use wiremock::matchers::{method, path_regex};
86        use wiremock::{Mock, MockServer, ResponseTemplate};
87
88        let mock_server = MockServer::start().await;
89        let body = serde_json::json!({
90            "totalSize": 2,
91            "done": true,
92            "records": [
93                {"Id": "01p000000000001AAA", "Name": "DeletedClass", "IsDeleted": true},
94                {"Id": "01p000000000002AAA", "Name": "ArchivedClass", "IsDeleted": true}
95            ]
96        });
97
98        Mock::given(method("GET"))
99            .and(path_regex(".*/queryAll/"))
100            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
101            .mount(&mock_server)
102            .await;
103
104        let client = ToolingClient::new(mock_server.uri(), "test-token").unwrap();
105        let result: busbar_sf_client::QueryResult<serde_json::Value> = client
106            .query_all_records("SELECT Id, Name FROM ApexClass WHERE IsDeleted = true")
107            .await
108            .expect("should succeed");
109        assert_eq!(result.total_size, 2);
110        assert_eq!(result.records.len(), 2);
111        assert!(result.done);
112    }
113
114    #[tokio::test]
115    async fn test_search_wiremock() {
116        use wiremock::matchers::{method, path_regex};
117        use wiremock::{Mock, MockServer, ResponseTemplate};
118
119        let mock_server = MockServer::start().await;
120        let body = serde_json::json!({
121            "searchRecords": [
122                {
123                    "Id": "01p000000000001AAA",
124                    "attributes": {
125                        "type": "ApexClass",
126                        "url": "/services/data/v62.0/tooling/sobjects/ApexClass/01p000000000001AAA"
127                    }
128                }
129            ]
130        });
131
132        Mock::given(method("GET"))
133            .and(path_regex(".*/tooling/search/"))
134            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
135            .mount(&mock_server)
136            .await;
137
138        let client = ToolingClient::new(mock_server.uri(), "test-token").unwrap();
139        let result: crate::types::SearchResult<serde_json::Value> = client
140            .search("FIND {test} IN ALL FIELDS RETURNING ApexClass(Id, Name)")
141            .await
142            .expect("should succeed");
143        assert_eq!(result.search_records.len(), 1);
144    }
145}