reso_client/
queries.rs

1// src/queries.rs
2
3//! Query building for RESO/OData requests
4
5use crate::error::{ResoError, Result};
6
7/// A structured RESO/OData query
8///
9/// Represents a complete OData query with all its parameters.
10/// Use [`QueryBuilder`] to construct queries with a fluent API.
11///
12/// # Examples
13///
14/// ```
15/// # use reso_client::Query;
16/// // Direct construction (prefer QueryBuilder for fluent API)
17/// let query = Query::new("Property");
18/// ```
19#[derive(Debug, Clone)]
20pub struct Query {
21    resource: String,
22    key: Option<String>,
23    filter: Option<String>,
24    select_fields: Option<Vec<String>>,
25    order_by: Option<String>,
26    top: Option<u32>,
27    skip: Option<u32>,
28    count: bool,
29    count_only: bool,
30    apply: Option<String>,
31    expand: Option<Vec<String>>,
32}
33
34/// A structured RESO replication query
35///
36/// Replication queries are used for bulk data transfer and have different
37/// constraints than standard queries:
38/// - Maximum $top limit: 2000 (vs 200 for standard queries)
39/// - No $skip parameter (use next links instead)
40/// - No $orderby parameter (ordered oldest to newest by default)
41/// - No $apply parameter
42/// - No count options
43#[derive(Debug, Clone)]
44pub struct ReplicationQuery {
45    resource: String,
46    filter: Option<String>,
47    select_fields: Option<Vec<String>>,
48    top: Option<u32>,
49}
50
51impl Query {
52    /// Create a new query for a resource
53    ///
54    /// Creates a basic query with no filters or parameters.
55    /// Use [`QueryBuilder`] for a more convenient fluent API.
56    ///
57    /// # Examples
58    ///
59    /// ```
60    /// # use reso_client::Query;
61    /// let query = Query::new("Property");
62    /// ```
63    pub fn new(resource: impl Into<String>) -> Self {
64        Self {
65            resource: resource.into(),
66            key: None,
67            filter: None,
68            select_fields: None,
69            order_by: None,
70            top: None,
71            skip: None,
72            count: false,
73            count_only: false,
74            apply: None,
75            expand: None,
76        }
77    }
78
79    /// Convert to OData query string
80    ///
81    /// Generates the URL path and query parameters according to OData v4.0 specification.
82    ///
83    /// # Examples
84    ///
85    /// ```
86    /// # use reso_client::QueryBuilder;
87    /// let query = QueryBuilder::new("Property")
88    ///     .filter("City eq 'Austin'")
89    ///     .top(10)
90    ///     .build()?;
91    ///
92    /// let url = query.to_odata_string();
93    /// // Returns: "Property?$filter=City%20eq%20%27Austin%27&$top=10"
94    /// # Ok::<(), Box<dyn std::error::Error>>(())
95    /// ```
96    pub fn to_odata_string(&self) -> String {
97        let mut parts = vec![self.resource.clone()];
98
99        // For key access, append ('key') to resource name (e.g., Property('12345'))
100        // This is the OData direct key access pattern for single entity retrieval
101        if let Some(key) = &self.key {
102            parts.push(format!("('{}')", urlencoding::encode(key)));
103
104            let mut params = Vec::new();
105
106            // Key access only supports $select and $expand (per OData spec)
107            // Other query options like $filter, $top, $skip are not applicable to single entity access
108            if let Some(fields) = &self.select_fields {
109                params.push(format!("$select={}", fields.join(",")));
110            }
111
112            if let Some(expands) = &self.expand {
113                params.push(format!("$expand={}", expands.join(",")));
114            }
115
116            if !params.is_empty() {
117                parts.push("?".to_string());
118                parts.push(params.join("&"));
119            }
120
121            return parts.concat();
122        }
123
124        // For count-only queries, append /$count and only use filter
125        // Returns a plain text count instead of JSON (e.g., "42")
126        if self.count_only {
127            parts.push("/$count".to_string());
128
129            if let Some(filter) = &self.filter {
130                parts.push("?".to_string());
131                parts.push(format!("$filter={}", urlencoding::encode(filter)));
132            }
133
134            return parts.concat();
135        }
136
137        let mut params = Vec::new();
138
139        // $apply
140        if let Some(apply) = &self.apply {
141            params.push(format!("$apply={}", urlencoding::encode(apply)));
142        }
143
144        // $filter
145        if let Some(filter) = &self.filter {
146            params.push(format!("$filter={}", urlencoding::encode(filter)));
147        }
148
149        // $select
150        if let Some(fields) = &self.select_fields {
151            params.push(format!("$select={}", fields.join(",")));
152        }
153
154        // $expand
155        if let Some(expands) = &self.expand {
156            params.push(format!("$expand={}", expands.join(",")));
157        }
158
159        // $orderby
160        if let Some(order) = &self.order_by {
161            params.push(format!("$orderby={}", urlencoding::encode(order)));
162        }
163
164        // $top
165        if let Some(top) = self.top {
166            params.push(format!("$top={}", top));
167        }
168
169        // $skip
170        if let Some(skip) = self.skip {
171            params.push(format!("$skip={}", skip));
172        }
173
174        // $count
175        if self.count {
176            params.push("$count=true".to_string());
177        }
178
179        if !params.is_empty() {
180            parts.push("?".to_string());
181            parts.push(params.join("&"));
182        }
183
184        parts.concat()
185    }
186}
187
188impl ReplicationQuery {
189    /// Create a new replication query for a resource
190    ///
191    /// Creates a basic replication query. Use [`ReplicationQueryBuilder`]
192    /// for a more convenient fluent API.
193    ///
194    /// # Examples
195    ///
196    /// ```
197    /// # use reso_client::queries::ReplicationQuery;
198    /// let query = ReplicationQuery::new("Property");
199    /// ```
200    pub fn new(resource: impl Into<String>) -> Self {
201        Self {
202            resource: resource.into(),
203            filter: None,
204            select_fields: None,
205            top: None,
206        }
207    }
208
209    /// Convert to OData replication query string
210    ///
211    /// Generates URL path: `{resource}/replication?{params}`
212    ///
213    /// # Examples
214    ///
215    /// ```
216    /// # use reso_client::ReplicationQueryBuilder;
217    /// let query = ReplicationQueryBuilder::new("Property")
218    ///     .filter("StandardStatus eq 'Active'")
219    ///     .top(2000)
220    ///     .build()?;
221    ///
222    /// let url = query.to_odata_string();
223    /// // Returns: "Property/replication?$filter=StandardStatus%20eq%20%27Active%27&$top=2000"
224    /// # Ok::<(), Box<dyn std::error::Error>>(())
225    /// ```
226    pub fn to_odata_string(&self) -> String {
227        let mut parts = vec![self.resource.clone(), "/replication".to_string()];
228
229        let mut params = Vec::new();
230
231        // $filter
232        if let Some(filter) = &self.filter {
233            params.push(format!("$filter={}", urlencoding::encode(filter)));
234        }
235
236        // $select
237        if let Some(fields) = &self.select_fields {
238            params.push(format!("$select={}", fields.join(",")));
239        }
240
241        // $top
242        if let Some(top) = self.top {
243            params.push(format!("$top={}", top));
244        }
245
246        if !params.is_empty() {
247            parts.push("?".to_string());
248            parts.push(params.join("&"));
249        }
250
251        parts.concat()
252    }
253
254    /// Get the resource name
255    ///
256    /// # Examples
257    ///
258    /// ```
259    /// # use reso_client::ReplicationQueryBuilder;
260    /// let query = ReplicationQueryBuilder::new("Property").build()?;
261    /// assert_eq!(query.resource(), "Property");
262    /// # Ok::<(), Box<dyn std::error::Error>>(())
263    /// ```
264    pub fn resource(&self) -> &str {
265        &self.resource
266    }
267}
268
269/// Fluent query builder
270pub struct QueryBuilder {
271    query: Query,
272}
273
274impl QueryBuilder {
275    /// Create a new query builder for a resource
276    ///
277    /// # Examples
278    ///
279    /// ```
280    /// # use reso_client::QueryBuilder;
281    /// let query = QueryBuilder::new("Property")
282    ///     .filter("City eq 'Austin' and ListPrice gt 500000")
283    ///     .top(10)
284    ///     .build()?;
285    /// # Ok::<(), Box<dyn std::error::Error>>(())
286    /// ```
287    pub fn new(resource: impl Into<String>) -> Self {
288        Self {
289            query: Query::new(resource),
290        }
291    }
292
293    /// Create a query builder for direct key access
294    ///
295    /// Direct key access is more efficient than using filters for single-record lookups.
296    /// Returns a single object instead of an array wrapped in `{"value": [...]}`.
297    ///
298    /// Key access supports `$select` and `$expand`, but not `$filter`, `$top`, `$skip`,
299    /// `$orderby`, or `$apply`.
300    ///
301    /// # Examples
302    ///
303    /// ```
304    /// # use reso_client::QueryBuilder;
305    /// // Basic key access
306    /// let query = QueryBuilder::by_key("Property", "12345")
307    ///     .build()?;
308    ///
309    /// // With select
310    /// let query = QueryBuilder::by_key("Property", "12345")
311    ///     .select(&["ListingKey", "City", "ListPrice"])
312    ///     .build()?;
313    ///
314    /// // With expand
315    /// let query = QueryBuilder::by_key("Property", "12345")
316    ///     .expand(&["ListOffice", "ListAgent"])
317    ///     .build()?;
318    /// # Ok::<(), Box<dyn std::error::Error>>(())
319    /// ```
320    pub fn by_key(resource: impl Into<String>, key: impl Into<String>) -> Self {
321        let mut query = Query::new(resource);
322        query.key = Some(key.into());
323        Self { query }
324    }
325
326    /// Add an OData filter expression
327    ///
328    /// Pass a complete OData filter string. The library does not parse or validate
329    /// the filter - it simply URL-encodes it and adds it to the query.
330    ///
331    /// # Examples
332    ///
333    /// ```
334    /// # use reso_client::QueryBuilder;
335    /// // Simple equality
336    /// let query = QueryBuilder::new("Property")
337    ///     .filter("City eq 'Austin'")
338    ///     .build()?;
339    ///
340    /// // Complex conditions
341    /// let query = QueryBuilder::new("Property")
342    ///     .filter("City eq 'Austin' and ListPrice gt 500000")
343    ///     .build()?;
344    ///
345    /// // Enumeration with 'has' operator
346    /// let query = QueryBuilder::new("Property")
347    ///     .filter("Appliances has PropertyEnums.Appliances'Dishwasher'")
348    ///     .build()?;
349    ///
350    /// // Collection operations
351    /// let query = QueryBuilder::new("Property")
352    ///     .filter("OpenHouse/any(x:x/OpenHouseDate eq 2025-06-01)")
353    ///     .build()?;
354    /// # Ok::<(), Box<dyn std::error::Error>>(())
355    /// ```
356    pub fn filter(mut self, expression: impl Into<String>) -> Self {
357        self.query.filter = Some(expression.into());
358        self
359    }
360
361    /// Add an OData apply expression for aggregations
362    ///
363    /// **⚠️ Server Compatibility Required:** This feature requires server support for
364    /// OData v4.0 Aggregation Extensions. Not all RESO servers support `$apply`.
365    /// If unsupported, the server will return a 400 error.
366    ///
367    /// Pass a complete OData apply string. The library does not parse or validate
368    /// the apply expression - it simply URL-encodes it and adds it to the query.
369    ///
370    /// # Examples
371    ///
372    /// ```
373    /// # use reso_client::QueryBuilder;
374    /// // Group by city with count
375    /// let query = QueryBuilder::new("Property")
376    ///     .apply("groupby((City), aggregate($count as Count))")
377    ///     .build()?;
378    ///
379    /// // Group by multiple fields
380    /// let query = QueryBuilder::new("Property")
381    ///     .apply("groupby((City, PropertyType), aggregate($count as Count))")
382    ///     .build()?;
383    /// # Ok::<(), Box<dyn std::error::Error>>(())
384    /// ```
385    ///
386    /// # Alternative for servers without $apply support
387    ///
388    /// If your server doesn't support aggregation, use multiple filtered queries instead:
389    ///
390    /// ```no_run
391    /// # use reso_client::{ResoClient, QueryBuilder};
392    /// # async fn example(client: &ResoClient) -> Result<(), Box<dyn std::error::Error>> {
393    /// let statuses = ["Active", "Pending", "Closed"];
394    /// for status in statuses {
395    ///     let query = QueryBuilder::new("Property")
396    ///         .filter(format!("StandardStatus eq '{}'", status))
397    ///         .count()
398    ///         .build()?;
399    ///
400    ///     let response = client.execute(&query).await?;
401    ///     let count = response.as_u64().unwrap_or(0);
402    ///     println!("{}: {}", status, count);
403    /// }
404    /// # Ok(())
405    /// # }
406    /// ```
407    pub fn apply(mut self, expression: impl Into<String>) -> Self {
408        self.query.apply = Some(expression.into());
409        self
410    }
411
412    /// Select specific fields
413    ///
414    /// # Examples
415    ///
416    /// ```
417    /// # use reso_client::QueryBuilder;
418    /// let query = QueryBuilder::new("Property")
419    ///     .select(&["ListingKey", "City", "ListPrice"])
420    ///     .build()?;
421    /// # Ok::<(), Box<dyn std::error::Error>>(())
422    /// ```
423    pub fn select(mut self, fields: &[&str]) -> Self {
424        self.query.select_fields = Some(fields.iter().map(|s| s.to_string()).collect());
425        self
426    }
427
428    /// Expand related entities
429    ///
430    /// The `$expand` parameter allows you to include related data in a single request,
431    /// reducing the number of API calls needed. Common examples include expanding
432    /// ListOffice or ListAgent for Property resources.
433    ///
434    /// **Note:** When using `$select`, you must include the expanded field names
435    /// in the select list, otherwise the expansion will be ignored.
436    ///
437    /// # Examples
438    ///
439    /// ```
440    /// # use reso_client::QueryBuilder;
441    /// // Expand a single related entity
442    /// let query = QueryBuilder::new("Property")
443    ///     .expand(&["ListOffice"])
444    ///     .build()?;
445    ///
446    /// // Expand multiple related entities
447    /// let query = QueryBuilder::new("Property")
448    ///     .expand(&["ListOffice", "ListAgent"])
449    ///     .build()?;
450    ///
451    /// // When using select, include expanded fields
452    /// let query = QueryBuilder::new("Property")
453    ///     .select(&["ListingKey", "City", "ListPrice", "ListOffice", "ListAgent"])
454    ///     .expand(&["ListOffice", "ListAgent"])
455    ///     .build()?;
456    /// # Ok::<(), Box<dyn std::error::Error>>(())
457    /// ```
458    pub fn expand(mut self, fields: &[&str]) -> Self {
459        self.query.expand = Some(fields.iter().map(|s| s.to_string()).collect());
460        self
461    }
462
463    /// Order by a field
464    ///
465    /// # Examples
466    ///
467    /// ```
468    /// # use reso_client::QueryBuilder;
469    /// let query = QueryBuilder::new("Property")
470    ///     .order_by("ListPrice", "desc")
471    ///     .build()?;
472    /// # Ok::<(), Box<dyn std::error::Error>>(())
473    /// ```
474    pub fn order_by(mut self, field: &str, direction: &str) -> Self {
475        self.query.order_by = Some(format!("{} {}", field, direction));
476        self
477    }
478
479    /// Limit number of results
480    ///
481    /// # Examples
482    ///
483    /// ```
484    /// # use reso_client::QueryBuilder;
485    /// let query = QueryBuilder::new("Property")
486    ///     .top(10)
487    ///     .build()?;
488    /// # Ok::<(), Box<dyn std::error::Error>>(())
489    /// ```
490    pub fn top(mut self, n: u32) -> Self {
491        self.query.top = Some(n);
492        self
493    }
494
495    /// Skip results (for pagination)
496    ///
497    /// # Examples
498    ///
499    /// ```
500    /// # use reso_client::QueryBuilder;
501    /// let query = QueryBuilder::new("Property")
502    ///     .skip(100)
503    ///     .top(10)
504    ///     .build()?;
505    /// # Ok::<(), Box<dyn std::error::Error>>(())
506    /// ```
507    pub fn skip(mut self, n: u32) -> Self {
508        self.query.skip = Some(n);
509        self
510    }
511
512    /// Include count in response
513    ///
514    /// # Examples
515    ///
516    /// ```
517    /// # use reso_client::QueryBuilder;
518    /// let query = QueryBuilder::new("Property")
519    ///     .filter("City eq 'Austin'")
520    ///     .with_count()
521    ///     .build()?;
522    /// # Ok::<(), Box<dyn std::error::Error>>(())
523    /// ```
524    pub fn with_count(mut self) -> Self {
525        self.query.count = true;
526        self
527    }
528
529    /// Create a count-only query
530    ///
531    /// # Examples
532    ///
533    /// ```
534    /// # use reso_client::QueryBuilder;
535    /// let query = QueryBuilder::new("Property")
536    ///     .filter("City eq 'Austin'")
537    ///     .count()
538    ///     .build()?;
539    /// # Ok::<(), Box<dyn std::error::Error>>(())
540    /// ```
541    pub fn count(mut self) -> Self {
542        self.query.count_only = true;
543        self
544    }
545
546    /// Build the query
547    ///
548    /// # Errors
549    ///
550    /// Returns an error if:
551    /// - Key access is used with incompatible parameters ($filter, $top, $skip, $orderby, $apply, $count)
552    pub fn build(self) -> Result<Query> {
553        // Validate key access doesn't use incompatible parameters
554        if self.query.key.is_some() {
555            if self.query.filter.is_some() {
556                return Err(ResoError::InvalidQuery(
557                    "Key access cannot be used with $filter".to_string(),
558                ));
559            }
560            if self.query.top.is_some() {
561                return Err(ResoError::InvalidQuery(
562                    "Key access cannot be used with $top".to_string(),
563                ));
564            }
565            if self.query.skip.is_some() {
566                return Err(ResoError::InvalidQuery(
567                    "Key access cannot be used with $skip".to_string(),
568                ));
569            }
570            if self.query.order_by.is_some() {
571                return Err(ResoError::InvalidQuery(
572                    "Key access cannot be used with $orderby".to_string(),
573                ));
574            }
575            if self.query.apply.is_some() {
576                return Err(ResoError::InvalidQuery(
577                    "Key access cannot be used with $apply".to_string(),
578                ));
579            }
580            if self.query.count || self.query.count_only {
581                return Err(ResoError::InvalidQuery(
582                    "Key access cannot be used with $count".to_string(),
583                ));
584            }
585        }
586
587        Ok(self.query)
588    }
589}
590
591/// Fluent replication query builder
592///
593/// Builds queries for the replication endpoint which is designed for
594/// bulk data transfer and full dataset synchronization.
595///
596/// # Constraints
597///
598/// - Maximum $top: 2000 (vs 200 for standard queries)
599/// - No $skip: Use next links from response headers instead
600/// - No $orderby: Results ordered oldest to newest by default
601/// - No $apply: Aggregations not supported
602///
603/// # Examples
604///
605/// ```
606/// # use reso_client::ReplicationQueryBuilder;
607/// // Basic replication query
608/// let query = ReplicationQueryBuilder::new("Property")
609///     .top(2000)
610///     .build()?;
611///
612/// // With filter and select
613/// let query = ReplicationQueryBuilder::new("Property")
614///     .filter("StandardStatus eq 'Active'")
615///     .select(&["ListingKey", "City", "ListPrice"])
616///     .top(1000)
617///     .build()?;
618/// # Ok::<(), Box<dyn std::error::Error>>(())
619/// ```
620pub struct ReplicationQueryBuilder {
621    query: ReplicationQuery,
622}
623
624impl ReplicationQueryBuilder {
625    /// Create a new replication query builder for a resource
626    ///
627    /// # Examples
628    ///
629    /// ```
630    /// # use reso_client::ReplicationQueryBuilder;
631    /// let query = ReplicationQueryBuilder::new("Property")
632    ///     .top(2000)
633    ///     .build()?;
634    /// # Ok::<(), Box<dyn std::error::Error>>(())
635    /// ```
636    pub fn new(resource: impl Into<String>) -> Self {
637        Self {
638            query: ReplicationQuery::new(resource),
639        }
640    }
641
642    /// Add an OData filter expression
643    ///
644    /// Pass a complete OData filter string. The library does not parse or validate
645    /// the filter - it simply URL-encodes it and adds it to the query.
646    ///
647    /// # Examples
648    ///
649    /// ```
650    /// # use reso_client::ReplicationQueryBuilder;
651    /// let query = ReplicationQueryBuilder::new("Property")
652    ///     .filter("StandardStatus eq 'Active'")
653    ///     .build()?;
654    /// # Ok::<(), Box<dyn std::error::Error>>(())
655    /// ```
656    pub fn filter(mut self, expression: impl Into<String>) -> Self {
657        self.query.filter = Some(expression.into());
658        self
659    }
660
661    /// Select specific fields
662    ///
663    /// Using $select is highly recommended for replication queries to reduce
664    /// payload size and improve performance.
665    ///
666    /// # Examples
667    ///
668    /// ```
669    /// # use reso_client::ReplicationQueryBuilder;
670    /// let query = ReplicationQueryBuilder::new("Property")
671    ///     .select(&["ListingKey", "City", "ListPrice"])
672    ///     .build()?;
673    /// # Ok::<(), Box<dyn std::error::Error>>(())
674    /// ```
675    pub fn select(mut self, fields: &[&str]) -> Self {
676        self.query.select_fields = Some(fields.iter().map(|s| s.to_string()).collect());
677        self
678    }
679
680    /// Limit number of results (maximum: 2000)
681    ///
682    /// The replication endpoint allows up to 2000 records per request,
683    /// compared to 200 for standard queries.
684    ///
685    /// # Errors
686    ///
687    /// Returns an error if `n` exceeds 2000.
688    ///
689    /// # Examples
690    ///
691    /// ```
692    /// # use reso_client::ReplicationQueryBuilder;
693    /// let query = ReplicationQueryBuilder::new("Property")
694    ///     .top(2000)
695    ///     .build()?;
696    /// # Ok::<(), Box<dyn std::error::Error>>(())
697    /// ```
698    pub fn top(mut self, n: u32) -> Self {
699        self.query.top = Some(n);
700        self
701    }
702
703    /// Build the replication query
704    ///
705    /// # Errors
706    ///
707    /// Returns an error if validation fails (e.g., top > 2000).
708    pub fn build(self) -> Result<ReplicationQuery> {
709        // Validate top limit - replication endpoint allows up to 2000 records per request
710        // This limit is higher than standard queries (200) because replication is designed
711        // for bulk data transfer and full dataset synchronization
712        if let Some(top) = self.query.top {
713            if top > 2000 {
714                return Err(ResoError::InvalidQuery(format!(
715                    "Replication queries support maximum $top of 2000, got {}",
716                    top
717                )));
718            }
719        }
720
721        Ok(self.query)
722    }
723}