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}