Skip to main content

supabase_client_query/
csv_select.rs

1use reqwest::header::HeaderValue;
2
3use supabase_client_core::SupabaseError;
4
5use crate::backend::QueryBackend;
6use crate::filter::Filterable;
7use crate::modifier::Modifiable;
8use crate::sql::{FilterCondition, ParamStore, SqlParts};
9
10/// Builder for SELECT queries that return CSV text.
11///
12/// Created by calling `.csv()` on a `SelectBuilder`. Returns raw CSV string
13/// instead of deserialized rows. REST-only (returns error for direct-sql backend).
14pub struct CsvSelectBuilder {
15    pub(crate) backend: QueryBackend,
16    pub(crate) parts: SqlParts,
17    pub(crate) params: ParamStore,
18}
19
20impl Filterable for CsvSelectBuilder {
21    fn filters_mut(&mut self) -> &mut Vec<FilterCondition> {
22        &mut self.parts.filters
23    }
24    fn params_mut(&mut self) -> &mut ParamStore {
25        &mut self.params
26    }
27}
28
29impl Modifiable for CsvSelectBuilder {
30    fn parts_mut(&mut self) -> &mut SqlParts {
31        &mut self.parts
32    }
33}
34
35impl CsvSelectBuilder {
36    /// Override the schema for this query.
37    pub fn schema(mut self, schema: &str) -> Self {
38        self.parts.schema_override = Some(schema.to_string());
39        self
40    }
41
42    /// Execute the SELECT query and return the response as a CSV string.
43    pub async fn execute(self) -> Result<String, SupabaseError> {
44        match &self.backend {
45            QueryBackend::Rest { http, base_url, api_key, schema } => {
46                let (url, mut headers) = crate::postgrest::build_postgrest_select(
47                    base_url, &self.parts, &self.params,
48                )
49                .map_err(SupabaseError::QueryBuilder)?;
50
51                // Override Accept to text/csv
52                headers.insert("Accept", HeaderValue::from_static("text/csv"));
53
54                // Standard auth headers
55                headers.insert("apikey", HeaderValue::from_str(api_key).unwrap());
56                headers.insert(
57                    "Authorization",
58                    HeaderValue::from_str(&format!("Bearer {}", api_key)).unwrap(),
59                );
60
61                // Schema profile
62                if let Some(ref so) = self.parts.schema_override {
63                    headers.insert(
64                        "Accept-Profile",
65                        HeaderValue::from_str(so).unwrap(),
66                    );
67                } else if schema != "public" {
68                    headers.entry("Accept-Profile")
69                        .or_insert_with(|| HeaderValue::from_str(schema).unwrap());
70                }
71
72                let response = http
73                    .get(&url)
74                    .headers(headers)
75                    .send()
76                    .await
77                    .map_err(|e| SupabaseError::Http(e.to_string()))?;
78
79                let status = response.status().as_u16();
80                let body = response
81                    .text()
82                    .await
83                    .map_err(|e| SupabaseError::Http(e.to_string()))?;
84
85                if status >= 400 {
86                    return Err(SupabaseError::postgrest(status, body, None));
87                }
88
89                Ok(body)
90            }
91            #[cfg(feature = "direct-sql")]
92            QueryBackend::DirectSql { .. } => {
93                Err(SupabaseError::query_builder(
94                    "CSV output is only supported with the REST (PostgREST) backend",
95                ))
96            }
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::sql::{SqlOperation, SqlParts, ParamStore};
105
106    #[test]
107    fn test_csv_builder_modifiable() {
108        let mut builder = CsvSelectBuilder {
109            backend: QueryBackend::Rest {
110                http: reqwest::Client::new(),
111                base_url: "http://localhost".into(),
112                api_key: "key".into(),
113                schema: "public".to_string(),
114            },
115            parts: SqlParts::new(SqlOperation::Select, "public", "cities"),
116            params: ParamStore::new(),
117        };
118        builder = builder.limit(10);
119        assert_eq!(builder.parts.limit, Some(10));
120    }
121
122    #[test]
123    fn test_csv_builder_filterable() {
124        let builder = CsvSelectBuilder {
125            backend: QueryBackend::Rest {
126                http: reqwest::Client::new(),
127                base_url: "http://localhost".into(),
128                api_key: "key".into(),
129                schema: "public".to_string(),
130            },
131            parts: SqlParts::new(SqlOperation::Select, "public", "cities"),
132            params: ParamStore::new(),
133        };
134        let builder = builder.eq("name", "Auckland");
135        assert_eq!(builder.parts.filters.len(), 1);
136    }
137
138    #[test]
139    fn test_csv_accept_header() {
140        let parts = SqlParts::new(SqlOperation::Select, "public", "cities");
141        let params = ParamStore::new();
142        let (_, mut headers) = crate::postgrest::build_postgrest_select(
143            "http://localhost:64321", &parts, &params,
144        ).unwrap();
145        // Simulate what CsvSelectBuilder does
146        headers.insert("Accept", HeaderValue::from_static("text/csv"));
147        assert_eq!(headers.get("Accept").unwrap(), "text/csv");
148    }
149
150    #[test]
151    fn test_csv_schema_sets_override() {
152        let builder = CsvSelectBuilder {
153            backend: QueryBackend::Rest {
154                http: reqwest::Client::new(),
155                base_url: "http://localhost".into(),
156                api_key: "key".into(),
157                schema: "public".to_string(),
158            },
159            parts: SqlParts::new(SqlOperation::Select, "public", "cities"),
160            params: ParamStore::new(),
161        };
162        let builder = builder.schema("custom");
163        assert_eq!(builder.parts.schema_override.as_deref(), Some("custom"));
164    }
165
166    // ---- execute() via wiremock ----
167
168    #[tokio::test]
169    async fn test_csv_execute_success() {
170        use wiremock::matchers::{method, path};
171        use wiremock::{Mock, MockServer, ResponseTemplate};
172
173        let mock_server = MockServer::start().await;
174        let csv_body = "id,name\n1,Alice\n2,Bob\n";
175        Mock::given(method("GET"))
176            .and(path("/rest/v1/users"))
177            .respond_with(
178                ResponseTemplate::new(200)
179                    .set_body_string(csv_body),
180            )
181            .mount(&mock_server)
182            .await;
183
184        let builder = CsvSelectBuilder {
185            backend: QueryBackend::Rest {
186                http: reqwest::Client::new(),
187                base_url: mock_server.uri().into(),
188                api_key: "test-key".into(),
189                schema: "public".to_string(),
190            },
191            parts: SqlParts::new(SqlOperation::Select, "public", "users"),
192            params: ParamStore::new(),
193        };
194
195        let result = builder.execute().await;
196        assert!(result.is_ok());
197        let csv = result.unwrap();
198        assert!(csv.contains("id,name"));
199        assert!(csv.contains("1,Alice"));
200        assert!(csv.contains("2,Bob"));
201    }
202
203    #[tokio::test]
204    async fn test_csv_execute_error() {
205        use wiremock::matchers::{method, path};
206        use wiremock::{Mock, MockServer, ResponseTemplate};
207
208        let mock_server = MockServer::start().await;
209        Mock::given(method("GET"))
210            .and(path("/rest/v1/nonexistent"))
211            .respond_with(
212                ResponseTemplate::new(404)
213                    .set_body_string("Relation not found"),
214            )
215            .mount(&mock_server)
216            .await;
217
218        let builder = CsvSelectBuilder {
219            backend: QueryBackend::Rest {
220                http: reqwest::Client::new(),
221                base_url: mock_server.uri().into(),
222                api_key: "test-key".into(),
223                schema: "public".to_string(),
224            },
225            parts: SqlParts::new(SqlOperation::Select, "public", "nonexistent"),
226            params: ParamStore::new(),
227        };
228
229        let result = builder.execute().await;
230        assert!(result.is_err());
231        match result.unwrap_err() {
232            SupabaseError::PostgRest { status, .. } => {
233                assert_eq!(status, 404);
234            }
235            other => panic!("Expected PostgRest error, got {:?}", other),
236        }
237    }
238
239    #[tokio::test]
240    async fn test_csv_execute_empty_result() {
241        use wiremock::matchers::{method, path};
242        use wiremock::{Mock, MockServer, ResponseTemplate};
243
244        let mock_server = MockServer::start().await;
245        Mock::given(method("GET"))
246            .and(path("/rest/v1/users"))
247            .respond_with(
248                ResponseTemplate::new(200)
249                    .set_body_string("id,name\n"),
250            )
251            .mount(&mock_server)
252            .await;
253
254        let builder = CsvSelectBuilder {
255            backend: QueryBackend::Rest {
256                http: reqwest::Client::new(),
257                base_url: mock_server.uri().into(),
258                api_key: "test-key".into(),
259                schema: "public".to_string(),
260            },
261            parts: SqlParts::new(SqlOperation::Select, "public", "users"),
262            params: ParamStore::new(),
263        };
264
265        let result = builder.execute().await;
266        assert!(result.is_ok());
267        let csv = result.unwrap();
268        assert!(csv.contains("id,name"));
269    }
270}