Skip to main content

supabase_client_query/
postgrest.rs

1use reqwest::header::{HeaderMap, HeaderValue};
2use serde_json::Value as JsonValue;
3
4use crate::sql::{
5    ArrayRangeOperator, CountOption, ExplainOptions, FilterCondition, FilterOperator, IsValue,
6    OrderDirection, ParamStore, PatternOperator, SqlParam, SqlParts, TextSearchType,
7};
8
9/// Build PostgREST URL and headers for a SELECT query.
10pub fn build_postgrest_select(
11    base_url: &str,
12    parts: &SqlParts,
13    params: &ParamStore,
14) -> Result<(String, HeaderMap), String> {
15    let table = &parts.table;
16    let mut url = format!("{}/rest/v1/{}", base_url.trim_end_matches('/'), table);
17    let mut headers = HeaderMap::new();
18    let mut query_params = Vec::new();
19
20    // Select columns
21    if let Some(ref cols) = parts.select_columns {
22        // Strip double-quotes from column names for PostgREST
23        let cleaned = cols
24            .split(',')
25            .map(|c| c.trim().trim_matches('"'))
26            .collect::<Vec<_>>()
27            .join(",");
28        query_params.push(format!("select={}", cleaned));
29    }
30
31    // Filters
32    render_filters_to_params(&parts.filters, params, &mut query_params)?;
33
34    // Order
35    if !parts.orders.is_empty() {
36        let order_parts: Vec<String> = parts
37            .orders
38            .iter()
39            .map(|o| {
40                let dir = match o.direction {
41                    OrderDirection::Ascending => "asc",
42                    OrderDirection::Descending => "desc",
43                };
44                let nulls = match &o.nulls {
45                    Some(crate::sql::NullsPosition::First) => ".nullsfirst",
46                    Some(crate::sql::NullsPosition::Last) => ".nullslast",
47                    None => "",
48                };
49                format!("{}.{}{}", o.column, dir, nulls)
50            })
51            .collect();
52        query_params.push(format!("order={}", order_parts.join(",")));
53    }
54
55    // Limit
56    if let Some(limit) = parts.limit {
57        query_params.push(format!("limit={}", limit));
58    }
59
60    // Offset
61    if let Some(offset) = parts.offset {
62        query_params.push(format!("offset={}", offset));
63    }
64
65    // Range header
66    if parts.limit.is_some() || parts.offset.is_some() {
67        let from = parts.offset.unwrap_or(0);
68        let to = match parts.limit {
69            Some(limit) => from + limit - 1,
70            None => i64::MAX,
71        };
72        headers.insert(
73            "Range",
74            HeaderValue::from_str(&format!("{}-{}", from, to)).unwrap(),
75        );
76        headers.insert("Range-Unit", HeaderValue::from_static("items"));
77    }
78
79    // Single row
80    if parts.single {
81        headers.insert(
82            "Accept",
83            HeaderValue::from_static("application/vnd.pgrst.object+json"),
84        );
85    }
86
87    // Prefer header (compose count + head)
88    {
89        let mut prefer_parts = Vec::new();
90        if parts.head {
91            // Head mode always implies count=exact
92            prefer_parts.push("count=exact".to_string());
93        } else if let Some(count_val) = count_option_prefer(parts.count) {
94            prefer_parts.push(count_val.to_string());
95        }
96        if !prefer_parts.is_empty() {
97            headers.insert(
98                "Prefer",
99                HeaderValue::from_str(&prefer_parts.join(",")).unwrap(),
100            );
101        }
102    }
103
104    // Explain
105    if let Some(ref opts) = parts.explain {
106        headers.insert("Accept", build_explain_accept(opts));
107    }
108
109    // Schema override
110    if let Some(ref schema) = parts.schema_override {
111        headers.insert(
112            "Accept-Profile",
113            HeaderValue::from_str(schema).unwrap(),
114        );
115    }
116
117    // Build URL
118    if !query_params.is_empty() {
119        url.push('?');
120        url.push_str(&query_params.join("&"));
121    }
122
123    Ok((url, headers))
124}
125
126/// Build PostgREST URL, headers, and body for an INSERT query.
127pub fn build_postgrest_insert(
128    base_url: &str,
129    parts: &SqlParts,
130    params: &ParamStore,
131) -> Result<(String, HeaderMap, JsonValue), String> {
132    let table = &parts.table;
133    let url = format!("{}/rest/v1/{}", base_url.trim_end_matches('/'), table);
134    let mut headers = HeaderMap::new();
135
136    headers.insert("Content-Type", HeaderValue::from_static("application/json"));
137
138    // Prefer header (compose return + count)
139    {
140        let mut prefer_parts = Vec::new();
141        if parts.returning.is_some() {
142            prefer_parts.push("return=representation");
143        } else {
144            prefer_parts.push("return=minimal");
145        }
146        if let Some(count_val) = count_option_prefer(parts.count) {
147            prefer_parts.push(count_val);
148        }
149        headers.insert(
150            "Prefer",
151            HeaderValue::from_str(&prefer_parts.join(",")).unwrap(),
152        );
153    }
154
155    // Schema override
156    if let Some(ref schema) = parts.schema_override {
157        headers.insert(
158            "Content-Profile",
159            HeaderValue::from_str(schema).unwrap(),
160        );
161    }
162
163    // Build body
164    let body = build_insert_body(parts, params)?;
165
166    Ok((url, headers, body))
167}
168
169/// Build PostgREST URL, headers, and body for an UPDATE query.
170pub fn build_postgrest_update(
171    base_url: &str,
172    parts: &SqlParts,
173    params: &ParamStore,
174) -> Result<(String, HeaderMap, JsonValue), String> {
175    let table = &parts.table;
176    let mut url = format!("{}/rest/v1/{}", base_url.trim_end_matches('/'), table);
177    let mut headers = HeaderMap::new();
178    let mut query_params = Vec::new();
179
180    headers.insert("Content-Type", HeaderValue::from_static("application/json"));
181
182    // Prefer header (compose return + count)
183    {
184        let mut prefer_parts = Vec::new();
185        if parts.returning.is_some() {
186            prefer_parts.push("return=representation");
187        } else {
188            prefer_parts.push("return=minimal");
189        }
190        if let Some(count_val) = count_option_prefer(parts.count) {
191            prefer_parts.push(count_val);
192        }
193        headers.insert(
194            "Prefer",
195            HeaderValue::from_str(&prefer_parts.join(",")).unwrap(),
196        );
197    }
198
199    // Schema override
200    if let Some(ref schema) = parts.schema_override {
201        headers.insert(
202            "Content-Profile",
203            HeaderValue::from_str(schema).unwrap(),
204        );
205    }
206
207    // Filters
208    render_filters_to_params(&parts.filters, params, &mut query_params)?;
209
210    if !query_params.is_empty() {
211        url.push('?');
212        url.push_str(&query_params.join("&"));
213    }
214
215    // Build SET body
216    let body = build_set_body(&parts.set_clauses, params)?;
217
218    Ok((url, headers, body))
219}
220
221/// Build PostgREST URL and headers for a DELETE query.
222pub fn build_postgrest_delete(
223    base_url: &str,
224    parts: &SqlParts,
225    params: &ParamStore,
226) -> Result<(String, HeaderMap), String> {
227    let table = &parts.table;
228    let mut url = format!("{}/rest/v1/{}", base_url.trim_end_matches('/'), table);
229    let mut headers = HeaderMap::new();
230    let mut query_params = Vec::new();
231
232    // Prefer header (compose return + count)
233    {
234        let mut prefer_parts = Vec::new();
235        if parts.returning.is_some() {
236            prefer_parts.push("return=representation");
237        } else {
238            prefer_parts.push("return=minimal");
239        }
240        if let Some(count_val) = count_option_prefer(parts.count) {
241            prefer_parts.push(count_val);
242        }
243        headers.insert(
244            "Prefer",
245            HeaderValue::from_str(&prefer_parts.join(",")).unwrap(),
246        );
247    }
248
249    // Schema override
250    if let Some(ref schema) = parts.schema_override {
251        headers.insert(
252            "Content-Profile",
253            HeaderValue::from_str(schema).unwrap(),
254        );
255    }
256
257    // Filters
258    render_filters_to_params(&parts.filters, params, &mut query_params)?;
259
260    if !query_params.is_empty() {
261        url.push('?');
262        url.push_str(&query_params.join("&"));
263    }
264
265    Ok((url, headers))
266}
267
268/// Build PostgREST URL, headers, and body for an UPSERT query.
269pub fn build_postgrest_upsert(
270    base_url: &str,
271    parts: &SqlParts,
272    params: &ParamStore,
273) -> Result<(String, HeaderMap, JsonValue), String> {
274    let table = &parts.table;
275    let mut url = format!("{}/rest/v1/{}", base_url.trim_end_matches('/'), table);
276    let mut headers = HeaderMap::new();
277
278    headers.insert("Content-Type", HeaderValue::from_static("application/json"));
279
280    // Upsert resolution preference (compose resolution + return + count)
281    let mut prefer_parts: Vec<&str> = Vec::new();
282
283    if parts.ignore_duplicates {
284        prefer_parts.push("resolution=ignore-duplicates");
285    } else {
286        prefer_parts.push("resolution=merge-duplicates");
287    }
288
289    if parts.returning.is_some() {
290        prefer_parts.push("return=representation");
291    } else {
292        prefer_parts.push("return=minimal");
293    }
294
295    // We need to handle count separately since count_option_prefer returns &'static str
296    let count_str = count_option_prefer(parts.count);
297    if let Some(cv) = count_str {
298        prefer_parts.push(cv);
299    }
300
301    headers.insert(
302        "Prefer",
303        HeaderValue::from_str(&prefer_parts.join(",")).unwrap(),
304    );
305
306    // Conflict columns as on_conflict query param
307    if !parts.conflict_columns.is_empty() {
308        let conflict = parts.conflict_columns.join(",");
309        url.push_str(&format!(
310            "{}on_conflict={}",
311            if url.contains('?') { "&" } else { "?" },
312            conflict
313        ));
314    }
315
316    // Schema override
317    if let Some(ref schema) = parts.schema_override {
318        headers.insert(
319            "Content-Profile",
320            HeaderValue::from_str(schema).unwrap(),
321        );
322    }
323
324    // Build body (same as insert)
325    let body = build_insert_body(parts, params)?;
326
327    Ok((url, headers, body))
328}
329
330/// Build PostgREST URL and headers for an RPC call.
331pub fn build_postgrest_rpc(
332    base_url: &str,
333    function: &str,
334    args: &JsonValue,
335    rollback: bool,
336) -> (String, HeaderMap, JsonValue) {
337    let url = format!("{}/rest/v1/rpc/{}", base_url.trim_end_matches('/'), function);
338    let mut headers = HeaderMap::new();
339    headers.insert("Content-Type", HeaderValue::from_static("application/json"));
340
341    if rollback {
342        headers.insert("Prefer", HeaderValue::from_static("tx=rollback"));
343    }
344
345    let body = if args.is_null() {
346        JsonValue::Object(serde_json::Map::new())
347    } else {
348        args.clone()
349    };
350
351    (url, headers, body)
352}
353
354// ─── Internal Helpers ──────────────────────────────────────
355
356/// Convert a CountOption to its PostgREST Prefer header value.
357fn count_option_prefer(option: CountOption) -> Option<&'static str> {
358    match option {
359        CountOption::None => None,
360        CountOption::Exact => Some("count=exact"),
361        CountOption::Planned => Some("count=planned"),
362        CountOption::Estimated => Some("count=estimated"),
363    }
364}
365
366/// Render a SqlParam value as a PostgREST string.
367pub fn render_param_value(param: &SqlParam) -> String {
368    match param {
369        SqlParam::Null => "null".to_string(),
370        SqlParam::Bool(b) => b.to_string(),
371        SqlParam::I16(n) => n.to_string(),
372        SqlParam::I32(n) => n.to_string(),
373        SqlParam::I64(n) => n.to_string(),
374        SqlParam::F32(n) => n.to_string(),
375        SqlParam::F64(n) => n.to_string(),
376        SqlParam::Text(s) => s.clone(),
377        SqlParam::Uuid(u) => u.to_string(),
378        SqlParam::Timestamp(t) => t.to_string(),
379        SqlParam::TimestampTz(t) => t.to_rfc3339(),
380        SqlParam::Date(d) => d.to_string(),
381        SqlParam::Time(t) => t.to_string(),
382        SqlParam::Json(v) => v.to_string(),
383        SqlParam::ByteArray(b) => format!("\\x{}", hex_encode(b)),
384        SqlParam::TextArray(arr) => format!("{{{}}}", arr.iter().map(|s| format!("\"{}\"", s)).collect::<Vec<_>>().join(",")),
385        SqlParam::I32Array(arr) => format!("{{{}}}", arr.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(",")),
386        SqlParam::I64Array(arr) => format!("{{{}}}", arr.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(",")),
387    }
388}
389
390/// Render a SqlParam value as a JSON value (for request bodies).
391fn param_to_json(param: &SqlParam) -> JsonValue {
392    match param {
393        SqlParam::Null => JsonValue::Null,
394        SqlParam::Bool(b) => JsonValue::Bool(*b),
395        SqlParam::I16(n) => JsonValue::Number((*n as i64).into()),
396        SqlParam::I32(n) => JsonValue::Number((*n as i64).into()),
397        SqlParam::I64(n) => JsonValue::Number((*n).into()),
398        SqlParam::F32(n) => serde_json::Number::from_f64(*n as f64)
399            .map(JsonValue::Number)
400            .unwrap_or(JsonValue::Null),
401        SqlParam::F64(n) => serde_json::Number::from_f64(*n)
402            .map(JsonValue::Number)
403            .unwrap_or(JsonValue::Null),
404        SqlParam::Text(s) => JsonValue::String(s.clone()),
405        SqlParam::Uuid(u) => JsonValue::String(u.to_string()),
406        SqlParam::Timestamp(t) => JsonValue::String(t.to_string()),
407        SqlParam::TimestampTz(t) => JsonValue::String(t.to_rfc3339()),
408        SqlParam::Date(d) => JsonValue::String(d.to_string()),
409        SqlParam::Time(t) => JsonValue::String(t.to_string()),
410        SqlParam::Json(v) => v.clone(),
411        SqlParam::ByteArray(b) => JsonValue::String(format!("\\x{}", hex_encode(b))),
412        SqlParam::TextArray(arr) => JsonValue::Array(arr.iter().map(|s| JsonValue::String(s.clone())).collect()),
413        SqlParam::I32Array(arr) => JsonValue::Array(arr.iter().map(|n| JsonValue::Number((*n as i64).into())).collect()),
414        SqlParam::I64Array(arr) => JsonValue::Array(arr.iter().map(|n| JsonValue::Number((*n).into())).collect()),
415    }
416}
417
418fn hex_encode(bytes: &[u8]) -> String {
419    bytes.iter().map(|b| format!("{:02x}", b)).collect()
420}
421
422/// Render a single filter condition as PostgREST query parameter(s).
423fn render_filter(
424    condition: &FilterCondition,
425    params: &ParamStore,
426    output: &mut Vec<String>,
427) -> Result<(), String> {
428    match condition {
429        FilterCondition::Comparison { column, operator, param_index } => {
430            let op = match operator {
431                FilterOperator::Eq => "eq",
432                FilterOperator::Neq => "neq",
433                FilterOperator::Gt => "gt",
434                FilterOperator::Gte => "gte",
435                FilterOperator::Lt => "lt",
436                FilterOperator::Lte => "lte",
437            };
438            let param = params.get(*param_index - 1)
439                .ok_or_else(|| format!("Missing param at index {}", param_index))?;
440            let val = render_param_value(param);
441            output.push(format!("{}={}.{}", column, op, val));
442        }
443        FilterCondition::Is { column, value } => {
444            let val = match value {
445                IsValue::Null => "null",
446                IsValue::NotNull => "not.null",  // not.is.null would be more accurate
447                IsValue::True => "true",
448                IsValue::False => "false",
449            };
450            output.push(format!("{}=is.{}", column, val));
451        }
452        FilterCondition::In { column, param_indices } => {
453            let vals: Result<Vec<String>, String> = param_indices
454                .iter()
455                .map(|idx| {
456                    let param = params.get(*idx - 1)
457                        .ok_or_else(|| format!("Missing param at index {}", idx))?;
458                    Ok(render_param_value(param))
459                })
460                .collect();
461            let val_list = vals?.join(",");
462            output.push(format!("{}=in.({})", column, val_list));
463        }
464        FilterCondition::Pattern { column, operator, param_index } => {
465            let op = match operator {
466                PatternOperator::Like => "like",
467                PatternOperator::ILike => "ilike",
468            };
469            let param = params.get(*param_index - 1)
470                .ok_or_else(|| format!("Missing param at index {}", param_index))?;
471            let val = render_param_value(param);
472            output.push(format!("{}={}.{}", column, op, val));
473        }
474        FilterCondition::TextSearch { column, query_param_index, config, search_type } => {
475            let op = match search_type {
476                TextSearchType::Plain => "plfts",
477                TextSearchType::Phrase => "phfts",
478                TextSearchType::Websearch => "wfts",
479            };
480            let param = params.get(*query_param_index - 1)
481                .ok_or_else(|| format!("Missing param at index {}", query_param_index))?;
482            let val = render_param_value(param);
483            if let Some(cfg) = config {
484                output.push(format!("{}={}({}).{}", column, op, cfg, val));
485            } else {
486                output.push(format!("{}={}.{}", column, op, val));
487            }
488        }
489        FilterCondition::ArrayRange { column, operator, param_index } => {
490            let op = match operator {
491                ArrayRangeOperator::Contains => "cs",
492                ArrayRangeOperator::ContainedBy => "cd",
493                ArrayRangeOperator::Overlaps => "ov",
494                ArrayRangeOperator::RangeGt => "sl",   // strictly left → right of
495                ArrayRangeOperator::RangeGte => "nxl",
496                ArrayRangeOperator::RangeLt => "sr",    // strictly right → left of
497                ArrayRangeOperator::RangeLte => "nxr",
498                ArrayRangeOperator::RangeAdjacent => "adj",
499            };
500            let param = params.get(*param_index - 1)
501                .ok_or_else(|| format!("Missing param at index {}", param_index))?;
502            let val = render_param_value(param);
503            output.push(format!("{}={}.{}", column, op, val));
504        }
505        FilterCondition::Not(inner) => {
506            // Render inner, then prefix with not.
507            let mut inner_params = Vec::new();
508            render_filter(inner, params, &mut inner_params)?;
509            for p in inner_params {
510                if let Some(eq_pos) = p.find('=') {
511                    let col = &p[..eq_pos];
512                    let rest = &p[eq_pos + 1..];
513                    output.push(format!("{}=not.{}", col, rest));
514                }
515            }
516        }
517        FilterCondition::Or(conditions) => {
518            let mut inner_parts = Vec::new();
519            for cond in conditions {
520                let mut sub = Vec::new();
521                render_filter(cond, params, &mut sub)?;
522                inner_parts.extend(sub);
523            }
524            // PostgREST or syntax: or=(filter1,filter2)
525            let or_items: Vec<String> = inner_parts
526                .iter()
527                .map(|p| {
528                    // Convert "col=op.val" to "col.op.val"
529                    if let Some(eq_pos) = p.find('=') {
530                        let col = &p[..eq_pos];
531                        let rest = &p[eq_pos + 1..];
532                        format!("{}.{}", col, rest)
533                    } else {
534                        p.clone()
535                    }
536                })
537                .collect();
538            output.push(format!("or=({})", or_items.join(",")));
539        }
540        FilterCondition::And(conditions) => {
541            let mut inner_parts = Vec::new();
542            for cond in conditions {
543                let mut sub = Vec::new();
544                render_filter(cond, params, &mut sub)?;
545                inner_parts.extend(sub);
546            }
547            let and_items: Vec<String> = inner_parts
548                .iter()
549                .map(|p| {
550                    if let Some(eq_pos) = p.find('=') {
551                        let col = &p[..eq_pos];
552                        let rest = &p[eq_pos + 1..];
553                        format!("{}.{}", col, rest)
554                    } else {
555                        p.clone()
556                    }
557                })
558                .collect();
559            output.push(format!("and=({})", and_items.join(",")));
560        }
561        FilterCondition::Raw(sql) => {
562            // Raw SQL cannot be directly translated to PostgREST
563            return Err(format!("Raw SQL filter '{}' cannot be used with PostgREST backend", sql));
564        }
565        FilterCondition::Match { conditions } => {
566            for (col, idx) in conditions {
567                let param = params.get(*idx - 1)
568                    .ok_or_else(|| format!("Missing param at index {}", idx))?;
569                let val = render_param_value(param);
570                output.push(format!("{}=eq.{}", col, val));
571            }
572        }
573    }
574    Ok(())
575}
576
577fn render_filters_to_params(
578    filters: &[FilterCondition],
579    params: &ParamStore,
580    output: &mut Vec<String>,
581) -> Result<(), String> {
582    for filter in filters {
583        render_filter(filter, params, output)?;
584    }
585    Ok(())
586}
587
588/// Build the insert/upsert JSON body from SqlParts.
589fn build_insert_body(parts: &SqlParts, params: &ParamStore) -> Result<JsonValue, String> {
590    if parts.many_rows.is_empty() {
591        // Single row from set_clauses
592        let mut obj = serde_json::Map::new();
593        for (col, idx) in &parts.set_clauses {
594            let param = params.get(*idx - 1)
595                .ok_or_else(|| format!("Missing param at index {}", idx))?;
596            obj.insert(col.clone(), param_to_json(param));
597        }
598        Ok(JsonValue::Object(obj))
599    } else {
600        // Multiple rows
601        let rows: Result<Vec<JsonValue>, String> = parts.many_rows.iter().map(|row| {
602            let mut obj = serde_json::Map::new();
603            for (col, idx) in row {
604                let param = params.get(*idx - 1)
605                    .ok_or_else(|| format!("Missing param at index {}", idx))?;
606                obj.insert(col.clone(), param_to_json(param));
607            }
608            Ok(JsonValue::Object(obj))
609        }).collect();
610        Ok(JsonValue::Array(rows?))
611    }
612}
613
614/// Build the SET body for UPDATE operations.
615fn build_set_body(set_clauses: &[(String, usize)], params: &ParamStore) -> Result<JsonValue, String> {
616    let mut obj = serde_json::Map::new();
617    for (col, idx) in set_clauses {
618        let param = params.get(*idx - 1)
619            .ok_or_else(|| format!("Missing param at index {}", idx))?;
620        obj.insert(col.clone(), param_to_json(param));
621    }
622    Ok(JsonValue::Object(obj))
623}
624
625fn build_explain_accept(opts: &ExplainOptions) -> HeaderValue {
626    let mut parts = vec!["application/vnd.pgrst.plan"];
627    if opts.analyze {
628        parts.push("+json; for=\"application/vnd.pgrst.plan+analyze\"");
629    }
630    // Simplify: PostgREST uses Accept header for plan format
631    HeaderValue::from_static("application/vnd.pgrst.plan+json")
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use crate::sql::*;
638
639    fn make_params(values: Vec<SqlParam>) -> ParamStore {
640        let mut store = ParamStore::new();
641        for v in values {
642            store.push(v);
643        }
644        store
645    }
646
647    // ─── SELECT Tests ───────────────────────────────────────
648
649    #[test]
650    fn test_select_simple() {
651        let parts = SqlParts::new(SqlOperation::Select, "public", "cities");
652        let params = ParamStore::new();
653        let (url, _headers) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
654        assert_eq!(url, "http://localhost:64321/rest/v1/cities");
655    }
656
657    #[test]
658    fn test_select_with_columns() {
659        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
660        parts.select_columns = Some("\"name\", \"country_id\"".to_string());
661        let params = ParamStore::new();
662        let (url, _) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
663        assert_eq!(url, "http://localhost:64321/rest/v1/cities?select=name,country_id");
664    }
665
666    #[test]
667    fn test_select_with_eq_filter() {
668        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
669        parts.filters.push(FilterCondition::Comparison {
670            column: "name".to_string(),
671            operator: FilterOperator::Eq,
672            param_index: 1,
673        });
674        let params = make_params(vec![SqlParam::Text("Auckland".to_string())]);
675        let (url, _) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
676        assert_eq!(url, "http://localhost:64321/rest/v1/cities?name=eq.Auckland");
677    }
678
679    #[test]
680    fn test_select_with_multiple_filters() {
681        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
682        parts.filters.push(FilterCondition::Comparison {
683            column: "country_id".to_string(),
684            operator: FilterOperator::Eq,
685            param_index: 1,
686        });
687        parts.filters.push(FilterCondition::Comparison {
688            column: "population".to_string(),
689            operator: FilterOperator::Gt,
690            param_index: 2,
691        });
692        let params = make_params(vec![SqlParam::I32(1), SqlParam::I64(100000)]);
693        let (url, _) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
694        assert!(url.contains("country_id=eq.1"));
695        assert!(url.contains("population=gt.100000"));
696    }
697
698    #[test]
699    fn test_select_with_order() {
700        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
701        parts.orders.push(OrderClause {
702            column: "name".to_string(),
703            direction: OrderDirection::Ascending,
704            nulls: None,
705        });
706        let params = ParamStore::new();
707        let (url, _) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
708        assert_eq!(url, "http://localhost:64321/rest/v1/cities?order=name.asc");
709    }
710
711    #[test]
712    fn test_select_with_order_nulls() {
713        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
714        parts.orders.push(OrderClause {
715            column: "name".to_string(),
716            direction: OrderDirection::Descending,
717            nulls: Some(NullsPosition::Last),
718        });
719        let params = ParamStore::new();
720        let (url, _) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
721        assert!(url.contains("order=name.desc.nullslast"));
722    }
723
724    #[test]
725    fn test_select_with_limit() {
726        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
727        parts.limit = Some(10);
728        let params = ParamStore::new();
729        let (url, headers) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
730        assert!(url.contains("limit=10"));
731        assert!(headers.contains_key("Range"));
732    }
733
734    #[test]
735    fn test_select_with_limit_offset() {
736        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
737        parts.limit = Some(10);
738        parts.offset = Some(5);
739        let params = ParamStore::new();
740        let (url, headers) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
741        assert!(url.contains("limit=10"));
742        assert!(url.contains("offset=5"));
743        assert_eq!(headers.get("Range").unwrap(), "5-14");
744    }
745
746    #[test]
747    fn test_select_single() {
748        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
749        parts.single = true;
750        let params = ParamStore::new();
751        let (_, headers) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
752        assert_eq!(
753            headers.get("Accept").unwrap(),
754            "application/vnd.pgrst.object+json"
755        );
756    }
757
758    #[test]
759    fn test_select_count() {
760        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
761        parts.count = CountOption::Exact;
762        let params = ParamStore::new();
763        let (_, headers) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
764        assert_eq!(headers.get("Prefer").unwrap(), "count=exact");
765    }
766
767    #[test]
768    fn test_select_head_mode() {
769        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
770        parts.head = true;
771        let params = ParamStore::new();
772        let (_, headers) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
773        assert_eq!(headers.get("Prefer").unwrap(), "count=exact");
774    }
775
776    // ─── Filter Tests ───────────────────────────────────────
777
778    #[test]
779    fn test_filter_is_null() {
780        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
781        parts.filters.push(FilterCondition::Is {
782            column: "deleted_at".to_string(),
783            value: IsValue::Null,
784        });
785        let params = ParamStore::new();
786        let (url, _) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
787        assert!(url.contains("deleted_at=is.null"));
788    }
789
790    #[test]
791    fn test_filter_in() {
792        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
793        parts.filters.push(FilterCondition::In {
794            column: "id".to_string(),
795            param_indices: vec![1, 2, 3],
796        });
797        let params = make_params(vec![
798            SqlParam::I32(1),
799            SqlParam::I32(2),
800            SqlParam::I32(3),
801        ]);
802        let (url, _) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
803        assert!(url.contains("id=in.(1,2,3)"));
804    }
805
806    #[test]
807    fn test_filter_like() {
808        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
809        parts.filters.push(FilterCondition::Pattern {
810            column: "name".to_string(),
811            operator: PatternOperator::Like,
812            param_index: 1,
813        });
814        let params = make_params(vec![SqlParam::Text("%auck%".to_string())]);
815        let (url, _) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
816        assert!(url.contains("name=like.%auck%"));
817    }
818
819    #[test]
820    fn test_filter_ilike() {
821        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
822        parts.filters.push(FilterCondition::Pattern {
823            column: "name".to_string(),
824            operator: PatternOperator::ILike,
825            param_index: 1,
826        });
827        let params = make_params(vec![SqlParam::Text("%auck%".to_string())]);
828        let (url, _) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
829        assert!(url.contains("name=ilike.%auck%"));
830    }
831
832    #[test]
833    fn test_filter_text_search() {
834        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
835        parts.filters.push(FilterCondition::TextSearch {
836            column: "fts".to_string(),
837            query_param_index: 1,
838            config: Some("english".to_string()),
839            search_type: TextSearchType::Plain,
840        });
841        let params = make_params(vec![SqlParam::Text("hello".to_string())]);
842        let (url, _) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
843        assert!(url.contains("fts=plfts(english).hello"));
844    }
845
846    #[test]
847    fn test_filter_contains() {
848        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
849        parts.filters.push(FilterCondition::ArrayRange {
850            column: "tags".to_string(),
851            operator: ArrayRangeOperator::Contains,
852            param_index: 1,
853        });
854        let params = make_params(vec![SqlParam::TextArray(vec!["a".to_string(), "b".to_string()])]);
855        let (url, _) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
856        assert!(url.contains("tags=cs.{\"a\",\"b\"}"));
857    }
858
859    #[test]
860    fn test_filter_not() {
861        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
862        parts.filters.push(FilterCondition::Not(Box::new(
863            FilterCondition::Comparison {
864                column: "active".to_string(),
865                operator: FilterOperator::Eq,
866                param_index: 1,
867            },
868        )));
869        let params = make_params(vec![SqlParam::Bool(true)]);
870        let (url, _) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
871        assert!(url.contains("active=not.eq.true"));
872    }
873
874    #[test]
875    fn test_filter_or() {
876        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
877        parts.filters.push(FilterCondition::Or(vec![
878            FilterCondition::Comparison {
879                column: "name".to_string(),
880                operator: FilterOperator::Eq,
881                param_index: 1,
882            },
883            FilterCondition::Comparison {
884                column: "name".to_string(),
885                operator: FilterOperator::Eq,
886                param_index: 2,
887            },
888        ]));
889        let params = make_params(vec![
890            SqlParam::Text("Auckland".to_string()),
891            SqlParam::Text("Wellington".to_string()),
892        ]);
893        let (url, _) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
894        assert!(url.contains("or=(name.eq.Auckland,name.eq.Wellington)"));
895    }
896
897    #[test]
898    fn test_filter_match() {
899        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
900        parts.filters.push(FilterCondition::Match {
901            conditions: vec![
902                ("name".to_string(), 1),
903                ("country_id".to_string(), 2),
904            ],
905        });
906        let params = make_params(vec![
907            SqlParam::Text("Auckland".to_string()),
908            SqlParam::I32(1),
909        ]);
910        let (url, _) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
911        assert!(url.contains("name=eq.Auckland"));
912        assert!(url.contains("country_id=eq.1"));
913    }
914
915    #[test]
916    fn test_raw_filter_errors() {
917        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
918        parts.filters.push(FilterCondition::Raw("1=1".to_string()));
919        let params = ParamStore::new();
920        let result = build_postgrest_select("http://localhost:64321", &parts, &params);
921        assert!(result.is_err());
922    }
923
924    // ─── INSERT Tests ───────────────────────────────────────
925
926    #[test]
927    fn test_insert_single() {
928        let mut parts = SqlParts::new(SqlOperation::Insert, "public", "cities");
929        parts.set_clauses = vec![
930            ("name".to_string(), 1),
931            ("country_id".to_string(), 2),
932        ];
933        parts.returning = Some("*".to_string());
934        let params = make_params(vec![
935            SqlParam::Text("Auckland".to_string()),
936            SqlParam::I32(1),
937        ]);
938        let (url, headers, body) = build_postgrest_insert("http://localhost:64321", &parts, &params).unwrap();
939        assert_eq!(url, "http://localhost:64321/rest/v1/cities");
940        assert_eq!(headers.get("Prefer").unwrap(), "return=representation");
941        assert_eq!(body["name"], "Auckland");
942        assert_eq!(body["country_id"], 1);
943    }
944
945    #[test]
946    fn test_insert_many() {
947        let mut parts = SqlParts::new(SqlOperation::Insert, "public", "cities");
948        parts.many_rows = vec![
949            vec![("name".to_string(), 1), ("country_id".to_string(), 2)],
950            vec![("name".to_string(), 3), ("country_id".to_string(), 4)],
951        ];
952        let params = make_params(vec![
953            SqlParam::Text("Auckland".to_string()),
954            SqlParam::I32(1),
955            SqlParam::Text("Wellington".to_string()),
956            SqlParam::I32(1),
957        ]);
958        let (_, _, body) = build_postgrest_insert("http://localhost:64321", &parts, &params).unwrap();
959        assert!(body.is_array());
960        assert_eq!(body.as_array().unwrap().len(), 2);
961    }
962
963    // ─── UPDATE Tests ───────────────────────────────────────
964
965    #[test]
966    fn test_update_with_filter() {
967        let mut parts = SqlParts::new(SqlOperation::Update, "public", "cities");
968        parts.set_clauses = vec![("name".to_string(), 1)];
969        parts.filters.push(FilterCondition::Comparison {
970            column: "id".to_string(),
971            operator: FilterOperator::Eq,
972            param_index: 2,
973        });
974        parts.returning = Some("*".to_string());
975        let params = make_params(vec![
976            SqlParam::Text("New Auckland".to_string()),
977            SqlParam::I32(1),
978        ]);
979        let (url, headers, body) = build_postgrest_update("http://localhost:64321", &parts, &params).unwrap();
980        assert!(url.contains("id=eq.1"));
981        assert_eq!(headers.get("Prefer").unwrap(), "return=representation");
982        assert_eq!(body["name"], "New Auckland");
983    }
984
985    // ─── DELETE Tests ───────────────────────────────────────
986
987    #[test]
988    fn test_delete_with_filter() {
989        let mut parts = SqlParts::new(SqlOperation::Delete, "public", "cities");
990        parts.filters.push(FilterCondition::Comparison {
991            column: "id".to_string(),
992            operator: FilterOperator::Eq,
993            param_index: 1,
994        });
995        parts.returning = Some("*".to_string());
996        let params = make_params(vec![SqlParam::I32(1)]);
997        let (url, headers) = build_postgrest_delete("http://localhost:64321", &parts, &params).unwrap();
998        assert!(url.contains("id=eq.1"));
999        assert_eq!(headers.get("Prefer").unwrap(), "return=representation");
1000    }
1001
1002    // ─── UPSERT Tests ───────────────────────────────────────
1003
1004    #[test]
1005    fn test_upsert_merge_duplicates() {
1006        let mut parts = SqlParts::new(SqlOperation::Upsert, "public", "cities");
1007        parts.set_clauses = vec![
1008            ("id".to_string(), 1),
1009            ("name".to_string(), 2),
1010        ];
1011        parts.conflict_columns = vec!["id".to_string()];
1012        parts.returning = Some("*".to_string());
1013        let params = make_params(vec![SqlParam::I32(1), SqlParam::Text("Auckland".to_string())]);
1014        let (url, headers, _) = build_postgrest_upsert("http://localhost:64321", &parts, &params).unwrap();
1015        assert!(url.contains("on_conflict=id"));
1016        let prefer = headers.get("Prefer").unwrap().to_str().unwrap();
1017        assert!(prefer.contains("resolution=merge-duplicates"));
1018        assert!(prefer.contains("return=representation"));
1019    }
1020
1021    #[test]
1022    fn test_upsert_ignore_duplicates() {
1023        let mut parts = SqlParts::new(SqlOperation::Upsert, "public", "cities");
1024        parts.set_clauses = vec![
1025            ("id".to_string(), 1),
1026            ("name".to_string(), 2),
1027        ];
1028        parts.conflict_columns = vec!["id".to_string()];
1029        parts.ignore_duplicates = true;
1030        let params = make_params(vec![SqlParam::I32(1), SqlParam::Text("Auckland".to_string())]);
1031        let (_, headers, _) = build_postgrest_upsert("http://localhost:64321", &parts, &params).unwrap();
1032        let prefer = headers.get("Prefer").unwrap().to_str().unwrap();
1033        assert!(prefer.contains("resolution=ignore-duplicates"));
1034    }
1035
1036    // ─── RPC Tests ──────────────────────────────────────────
1037
1038    #[test]
1039    fn test_rpc_simple() {
1040        let args = serde_json::json!({"name": "Auckland"});
1041        let (url, headers, body) = build_postgrest_rpc("http://localhost:64321", "get_city", &args, false);
1042        assert_eq!(url, "http://localhost:64321/rest/v1/rpc/get_city");
1043        assert_eq!(headers.get("Content-Type").unwrap(), "application/json");
1044        assert_eq!(body["name"], "Auckland");
1045    }
1046
1047    #[test]
1048    fn test_rpc_no_args() {
1049        let args = serde_json::json!(null);
1050        let (_, _, body) = build_postgrest_rpc("http://localhost:64321", "get_all", &args, false);
1051        assert!(body.is_object());
1052        assert!(body.as_object().unwrap().is_empty());
1053    }
1054
1055    #[test]
1056    fn test_rpc_rollback() {
1057        let args = serde_json::json!({"name": "Auckland"});
1058        let (_, headers, _) = build_postgrest_rpc("http://localhost:64321", "get_city", &args, true);
1059        assert_eq!(headers.get("Prefer").unwrap(), "tx=rollback");
1060    }
1061
1062    #[test]
1063    fn test_rpc_no_rollback_no_prefer() {
1064        let args = serde_json::json!({"name": "Auckland"});
1065        let (_, headers, _) = build_postgrest_rpc("http://localhost:64321", "get_city", &args, false);
1066        assert!(headers.get("Prefer").is_none());
1067    }
1068
1069    // ─── Param Value Rendering ──────────────────────────────
1070
1071    #[test]
1072    fn test_render_param_null() {
1073        assert_eq!(render_param_value(&SqlParam::Null), "null");
1074    }
1075
1076    #[test]
1077    fn test_render_param_bool() {
1078        assert_eq!(render_param_value(&SqlParam::Bool(true)), "true");
1079        assert_eq!(render_param_value(&SqlParam::Bool(false)), "false");
1080    }
1081
1082    #[test]
1083    fn test_render_param_numbers() {
1084        assert_eq!(render_param_value(&SqlParam::I32(42)), "42");
1085        assert_eq!(render_param_value(&SqlParam::I64(1000000)), "1000000");
1086        assert_eq!(render_param_value(&SqlParam::F64(3.14)), "3.14");
1087    }
1088
1089    #[test]
1090    fn test_render_param_text() {
1091        assert_eq!(render_param_value(&SqlParam::Text("hello".to_string())), "hello");
1092    }
1093
1094    #[test]
1095    fn test_render_param_uuid() {
1096        let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
1097        assert_eq!(
1098            render_param_value(&SqlParam::Uuid(uuid)),
1099            "550e8400-e29b-41d4-a716-446655440000"
1100        );
1101    }
1102
1103    // ─── CountOption Tests ──────────────────────────────────
1104
1105    #[test]
1106    fn test_select_count_planned() {
1107        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
1108        parts.count = CountOption::Planned;
1109        let params = ParamStore::new();
1110        let (_, headers) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
1111        assert_eq!(headers.get("Prefer").unwrap(), "count=planned");
1112    }
1113
1114    #[test]
1115    fn test_select_count_estimated() {
1116        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
1117        parts.count = CountOption::Estimated;
1118        let params = ParamStore::new();
1119        let (_, headers) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
1120        assert_eq!(headers.get("Prefer").unwrap(), "count=estimated");
1121    }
1122
1123    #[test]
1124    fn test_select_count_and_head_compose() {
1125        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
1126        parts.count = CountOption::Exact;
1127        parts.head = true;
1128        let params = ParamStore::new();
1129        let (_, headers) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
1130        // Head mode forces count=exact
1131        let prefer = headers.get("Prefer").unwrap().to_str().unwrap();
1132        assert!(prefer.contains("count=exact"));
1133    }
1134
1135    #[test]
1136    fn test_insert_return_and_count_compose() {
1137        let mut parts = SqlParts::new(SqlOperation::Insert, "public", "cities");
1138        parts.set_clauses = vec![("name".to_string(), 1)];
1139        parts.returning = Some("*".to_string());
1140        parts.count = CountOption::Exact;
1141        let params = make_params(vec![SqlParam::Text("Auckland".to_string())]);
1142        let (_, headers, _) = build_postgrest_insert("http://localhost:64321", &parts, &params).unwrap();
1143        let prefer = headers.get("Prefer").unwrap().to_str().unwrap();
1144        assert!(prefer.contains("return=representation"));
1145        assert!(prefer.contains("count=exact"));
1146    }
1147
1148    #[test]
1149    fn test_update_return_and_count_compose() {
1150        let mut parts = SqlParts::new(SqlOperation::Update, "public", "cities");
1151        parts.set_clauses = vec![("name".to_string(), 1)];
1152        parts.returning = Some("*".to_string());
1153        parts.count = CountOption::Planned;
1154        let params = make_params(vec![SqlParam::Text("Auckland".to_string())]);
1155        let (_, headers, _) = build_postgrest_update("http://localhost:64321", &parts, &params).unwrap();
1156        let prefer = headers.get("Prefer").unwrap().to_str().unwrap();
1157        assert!(prefer.contains("return=representation"));
1158        assert!(prefer.contains("count=planned"));
1159    }
1160
1161    #[test]
1162    fn test_delete_return_and_count_compose() {
1163        let mut parts = SqlParts::new(SqlOperation::Delete, "public", "cities");
1164        parts.returning = Some("*".to_string());
1165        parts.count = CountOption::Estimated;
1166        let params = ParamStore::new();
1167        let (_, headers) = build_postgrest_delete("http://localhost:64321", &parts, &params).unwrap();
1168        let prefer = headers.get("Prefer").unwrap().to_str().unwrap();
1169        assert!(prefer.contains("return=representation"));
1170        assert!(prefer.contains("count=estimated"));
1171    }
1172
1173    #[test]
1174    fn test_upsert_with_count() {
1175        let mut parts = SqlParts::new(SqlOperation::Upsert, "public", "cities");
1176        parts.set_clauses = vec![("id".to_string(), 1), ("name".to_string(), 2)];
1177        parts.conflict_columns = vec!["id".to_string()];
1178        parts.returning = Some("*".to_string());
1179        parts.count = CountOption::Exact;
1180        let params = make_params(vec![SqlParam::I32(1), SqlParam::Text("Auckland".to_string())]);
1181        let (_, headers, _) = build_postgrest_upsert("http://localhost:64321", &parts, &params).unwrap();
1182        let prefer = headers.get("Prefer").unwrap().to_str().unwrap();
1183        assert!(prefer.contains("resolution=merge-duplicates"));
1184        assert!(prefer.contains("return=representation"));
1185        assert!(prefer.contains("count=exact"));
1186    }
1187
1188    #[test]
1189    fn test_schema_override_select() {
1190        let mut parts = SqlParts::new(SqlOperation::Select, "public", "cities");
1191        parts.schema_override = Some("custom".to_string());
1192        let params = ParamStore::new();
1193        let (_, headers) = build_postgrest_select("http://localhost:64321", &parts, &params).unwrap();
1194        assert_eq!(headers.get("Accept-Profile").unwrap(), "custom");
1195    }
1196
1197    #[test]
1198    fn test_schema_override_insert() {
1199        let mut parts = SqlParts::new(SqlOperation::Insert, "public", "cities");
1200        parts.schema_override = Some("custom".to_string());
1201        parts.set_clauses = vec![("name".to_string(), 1)];
1202        let params = make_params(vec![SqlParam::Text("Auckland".to_string())]);
1203        let (_, headers, _) = build_postgrest_insert("http://localhost:64321", &parts, &params).unwrap();
1204        assert_eq!(headers.get("Content-Profile").unwrap(), "custom");
1205    }
1206}