use crate::Cirrus;
use crate::error::CirrusResult;
use crate::pagination::Records;
use crate::response::QueryResult;
use serde::de::DeserializeOwned;
use serde_json::Value;
impl Cirrus {
pub async fn query(&self, soql: &str) -> CirrusResult<QueryResult<Value>> {
self.query_as(soql).await
}
pub async fn query_as<R: DeserializeOwned>(&self, soql: &str) -> CirrusResult<QueryResult<R>> {
let query = [("q", soql)];
self.get_with_query("query", &query).await
}
pub async fn query_all(&self, soql: &str) -> CirrusResult<QueryResult<Value>> {
self.query_all_as(soql).await
}
pub async fn query_all_as<R: DeserializeOwned>(
&self,
soql: &str,
) -> CirrusResult<QueryResult<R>> {
let query = [("q", soql)];
self.get_with_query("queryAll", &query).await
}
pub async fn query_more(&self, next_records_url: &str) -> CirrusResult<QueryResult<Value>> {
self.query_more_as(next_records_url).await
}
pub async fn query_more_as<R: DeserializeOwned>(
&self,
next_records_url: &str,
) -> CirrusResult<QueryResult<R>> {
let path = if next_records_url.starts_with('/') {
next_records_url.to_string()
} else {
format!("/{next_records_url}")
};
self.get(&path).await
}
pub fn query_stream(&self, soql: &str) -> Records<Value> {
self.query_stream_as(soql)
}
pub fn query_stream_as<R: DeserializeOwned + Send + Unpin + 'static>(
&self,
soql: &str,
) -> Records<R> {
let client = self.clone();
let soql = soql.to_string();
let initial = Box::pin(async move { client.query_as::<R>(&soql).await });
Records::new(self.clone(), initial)
}
pub fn query_all_stream(&self, soql: &str) -> Records<Value> {
self.query_all_stream_as(soql)
}
pub fn query_all_stream_as<R: DeserializeOwned + Send + Unpin + 'static>(
&self,
soql: &str,
) -> Records<R> {
let client = self.clone();
let soql = soql.to_string();
let initial = Box::pin(async move { client.query_all_as::<R>(&soql).await });
Records::new(self.clone(), initial)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use crate::Cirrus;
use crate::auth::StaticTokenAuth;
use serde_json::json;
use std::sync::Arc;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn fixture(uri: String) -> Cirrus {
let auth = Arc::new(StaticTokenAuth::new("tok", uri));
Cirrus::builder().auth(auth).build().unwrap()
}
#[tokio::test]
async fn query_passes_soql_in_q_param() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/query"))
.and(query_param("q", "SELECT Id, Name FROM Account LIMIT 1"))
.and(header("authorization", "Bearer tok"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"totalSize": 1,
"done": true,
"records": [
{"attributes": {"type": "Account"}, "Id": "001xx", "Name": "Acme"}
]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let qr = sf
.query("SELECT Id, Name FROM Account LIMIT 1")
.await
.unwrap();
assert_eq!(qr.total_size, 1);
assert!(qr.done);
assert_eq!(qr.records.len(), 1);
assert_eq!(qr.records[0]["Name"], "Acme");
assert!(qr.next_records_url.is_none());
}
#[tokio::test]
async fn query_paginated_response_exposes_next_locator() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/query"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"totalSize": 2500,
"done": false,
"nextRecordsUrl": "/services/data/v66.0/query/01gD0000002HU6KIAW-2000",
"records": []
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let qr = sf.query("SELECT Id FROM Account").await.unwrap();
assert!(!qr.done);
assert_eq!(qr.total_size, 2500);
assert_eq!(
qr.next_records_url.as_deref(),
Some("/services/data/v66.0/query/01gD0000002HU6KIAW-2000")
);
}
#[tokio::test]
async fn query_all_hits_queryall_endpoint() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/queryAll"))
.and(query_param(
"q",
"SELECT Id, IsDeleted FROM Account WHERE IsDeleted = TRUE",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"totalSize": 1,
"done": true,
"records": [
{"attributes": {"type": "Account"}, "Id": "001xx", "IsDeleted": true}
]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let qr = sf
.query_all("SELECT Id, IsDeleted FROM Account WHERE IsDeleted = TRUE")
.await
.unwrap();
assert_eq!(qr.records.len(), 1);
assert_eq!(qr.records[0]["IsDeleted"], true);
}
#[tokio::test]
async fn query_more_follows_locator() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/query/01gD0000002HU6KIAW-2000"))
.and(header("authorization", "Bearer tok"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"totalSize": 2500,
"done": true,
"records": [
{"attributes": {"type": "Account"}, "Id": "001yy"}
]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let qr = sf
.query_more("/services/data/v66.0/query/01gD0000002HU6KIAW-2000")
.await
.unwrap();
assert!(qr.done);
assert_eq!(qr.records.len(), 1);
}
#[tokio::test]
async fn query_more_accepts_locator_without_leading_slash() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/query/01gXXX-2000"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"totalSize": 0,
"done": true,
"records": []
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let qr = sf
.query_more("services/data/v66.0/query/01gXXX-2000")
.await
.unwrap();
assert!(qr.done);
}
#[tokio::test]
async fn query_typed_records() {
#[derive(serde::Deserialize)]
struct Acct {
#[serde(rename = "Name")]
name: String,
}
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/query"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"totalSize": 1,
"done": true,
"records": [
{"attributes": {"type": "Account"}, "Name": "Acme"}
]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let qr = sf
.query_as::<Acct>("SELECT Name FROM Account LIMIT 1")
.await
.unwrap();
assert_eq!(qr.records.len(), 1);
assert_eq!(qr.records[0].name, "Acme");
}
#[tokio::test]
async fn query_surfaces_malformed_query_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/query"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!([{
"message": "unexpected token: SELECTT",
"errorCode": "MALFORMED_QUERY"
}])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let err = sf.query("SELECTT Id FROM Account").await.unwrap_err();
match err {
crate::CirrusError::Api { status, errors, .. } => {
assert_eq!(status, 400);
assert_eq!(errors[0].error_code, "MALFORMED_QUERY");
}
other => panic!("expected Api error, got {other:?}"),
}
}
}