Skip to main content

igdb_atlas/query/
builder.rs

1//! # Query Builder
2//!
3//! Constructs Apicalypse query strings for the IGDB API.
4//!
5//! ## Apicalypse Syntax Reference
6//!
7//! The IGDB API uses a custom query language called Apicalypse:
8//!
9//! ```text
10//! fields name, rating, platforms.name, platforms.abbreviation;
11//! search "Zelda";
12//! where rating > 80 & platforms = 48;
13//! sort rating desc;
14//! limit 10;
15//! offset 0;
16//! ```
17//!
18//! ### Field Expansion
19//!
20//! Nested objects are expanded by using dot notation in the `fields` clause:
21//!
22//! - `platforms.*` - all sub-fields of platforms
23//! - `platforms.name,platforms.abbreviation` - specific sub-fields
24//! - `involved_companies.company.name` - deep nesting
25//!
26//! Without expansion, nested references return as bare integer IDs.
27//!
28//! ## Example
29//!
30//! ```rust
31//! use igdb_atlas::QueryBuilder;
32//!
33//! let query = QueryBuilder::new()
34//!     .select(&["name", "rating"])
35//!     .search("Zelda")
36//!     .where_clause("rating > 80")
37//!     .sort_by("rating", true)
38//!     .limit(10)
39//!     .expand("platforms", &["name", "abbreviation"])
40//!     .build();
41//!
42//! assert!(query.contains("platforms.name"));
43//! assert!(query.contains("platforms.abbreviation"));
44//! ```
45
46/// Builder for constructing Apicalypse query strings.
47///
48/// # Examples
49///
50/// ```rust
51/// use igdb_atlas::QueryBuilder;
52///
53/// let query = QueryBuilder::new()
54///     .select(&["name", "rating"])
55///     .search("Dark Souls")
56///     .limit(5)
57///     .build();
58///
59/// assert!(query.contains("fields name,rating;"));
60/// assert!(query.contains("search \"Dark Souls\";"));
61/// assert!(query.contains("limit 5;"));
62/// ```
63#[derive(Debug, Clone)]
64pub struct QueryBuilder {
65    fields: Vec<String>,
66    search_term: Option<String>,
67    where_clauses: Vec<String>,
68    sort_field: Option<String>,
69    sort_desc: bool,
70    limit_val: Option<u32>,
71    offset_val: Option<u32>,
72    /// Each expansion is (parent_field, vec_of_sub_fields).
73    /// Empty sub_fields means expand all (`parent.*`).
74    expansions: Vec<(String, Vec<String>)>,
75}
76
77impl QueryBuilder {
78    /// Creates a new empty query builder.
79    ///
80    /// # Examples
81    ///
82    /// ```rust
83    /// use igdb_atlas::QueryBuilder;
84    ///
85    /// let builder = QueryBuilder::new();
86    /// assert_eq!(builder.field_count(), 0);
87    /// assert!(!builder.has_search());
88    /// ```
89    pub fn new() -> Self {
90        Self {
91            fields: Vec::new(),
92            search_term: None,
93            where_clauses: Vec::new(),
94            sort_field: None,
95            sort_desc: false,
96            limit_val: None,
97            offset_val: None,
98            expansions: Vec::new(),
99        }
100    }
101
102    /// Sets the fields to return.
103    ///
104    /// # Examples
105    ///
106    /// ```rust
107    /// use igdb_atlas::QueryBuilder;
108    ///
109    /// let q = QueryBuilder::new().select(&["name", "rating"]).build();
110    /// assert!(q.contains("fields name,rating;"));
111    /// ```
112    pub fn select(mut self, fields: &[&str]) -> Self {
113        self.fields = fields.iter().map(|f| f.to_string()).collect();
114        self
115    }
116
117    /// Appends a single field to the selection.
118    ///
119    /// # Examples
120    ///
121    /// ```rust
122    /// use igdb_atlas::QueryBuilder;
123    ///
124    /// let q = QueryBuilder::new()
125    ///     .select(&["name"])
126    ///     .add_field("rating")
127    ///     .build();
128    /// assert!(q.contains("name"));
129    /// assert!(q.contains("rating"));
130    /// ```
131    pub fn add_field(mut self, field: &str) -> Self {
132        self.fields.push(field.to_string());
133        self
134    }
135
136    /// Sets the search term.
137    ///
138    /// # Examples
139    ///
140    /// ```rust
141    /// use igdb_atlas::QueryBuilder;
142    ///
143    /// let q = QueryBuilder::new().search("Zelda").build();
144    /// assert!(q.contains("search \"Zelda\";"));
145    /// ```
146    pub fn search(mut self, term: &str) -> Self {
147        self.search_term = Some(term.to_string());
148        self
149    }
150
151    /// Sets or replaces the WHERE clause.
152    ///
153    /// # Examples
154    ///
155    /// ```rust
156    /// use igdb_atlas::QueryBuilder;
157    ///
158    /// let q = QueryBuilder::new().where_clause("rating > 80").build();
159    /// assert!(q.contains("where rating > 80;"));
160    /// ```
161    pub fn where_clause(mut self, clause: &str) -> Self {
162        self.where_clauses = vec![clause.to_string()];
163        self
164    }
165
166    /// Appends an AND condition to the WHERE clause.
167    ///
168    /// # Examples
169    ///
170    /// ```rust
171    /// use igdb_atlas::QueryBuilder;
172    ///
173    /// let q = QueryBuilder::new()
174    ///     .where_clause("rating > 80")
175    ///     .and_where("platforms = 48")
176    ///     .build();
177    /// assert!(q.contains("where rating > 80 & platforms = 48;"));
178    /// ```
179    pub fn and_where(mut self, clause: &str) -> Self {
180        self.where_clauses.push(clause.to_string());
181        self
182    }
183
184    /// Sets the sort field and direction.
185    ///
186    /// # Examples
187    ///
188    /// ```rust
189    /// use igdb_atlas::QueryBuilder;
190    ///
191    /// let q = QueryBuilder::new().sort_by("rating", true).build();
192    /// assert!(q.contains("sort rating desc;"));
193    /// ```
194    pub fn sort_by(mut self, field: &str, descending: bool) -> Self {
195        self.sort_field = Some(field.to_string());
196        self.sort_desc = descending;
197        self
198    }
199
200    /// Sets the result limit.
201    ///
202    /// # Examples
203    ///
204    /// ```rust
205    /// use igdb_atlas::QueryBuilder;
206    ///
207    /// let q = QueryBuilder::new().limit(25).build();
208    /// assert!(q.contains("limit 25;"));
209    /// ```
210    pub fn limit(mut self, n: u32) -> Self {
211        self.limit_val = Some(n);
212        self
213    }
214
215    /// Sets the pagination offset.
216    ///
217    /// # Examples
218    ///
219    /// ```rust
220    /// use igdb_atlas::QueryBuilder;
221    ///
222    /// let q = QueryBuilder::new().offset(50).build();
223    /// assert!(q.contains("offset 50;"));
224    /// ```
225    pub fn offset(mut self, n: u32) -> Self {
226        self.offset_val = Some(n);
227        self
228    }
229
230    /// Adds a field expansion using Apicalypse dot notation.
231    ///
232    /// When `sub_fields` is empty, expands all sub-fields (`parent.*`).
233    /// When `sub_fields` has entries, expands each one individually
234    /// (`parent.field1, parent.field2`).
235    ///
236    /// The expanded fields are automatically added to the `fields` clause
237    /// in the final query. You do **not** need to include the parent field
238    /// in your `select()` call - it will be added.
239    ///
240    /// # Examples
241    ///
242    /// ```rust
243    /// use igdb_atlas::QueryBuilder;
244    ///
245    /// // Expand all platform sub-fields
246    /// let q = QueryBuilder::new()
247    ///     .select(&["name"])
248    ///     .expand("platforms", &[])
249    ///     .build();
250    /// assert!(q.contains("platforms.*"));
251    ///
252    /// // Expand specific sub-fields
253    /// let q = QueryBuilder::new()
254    ///     .select(&["name"])
255    ///     .expand("platforms", &["name", "abbreviation"])
256    ///     .build();
257    /// assert!(q.contains("platforms.name"));
258    /// assert!(q.contains("platforms.abbreviation"));
259    /// ```
260    pub fn expand(mut self, parent: &str, sub_fields: &[&str]) -> Self {
261        self.expansions.push((
262            parent.to_string(),
263            sub_fields.iter().map(|f| f.to_string()).collect(),
264        ));
265        self
266    }
267
268    /// Builds the final Apicalypse query string.
269    ///
270    /// # Field Resolution
271    ///
272    /// The `fields` clause is built by combining:
273    /// 1. Explicitly selected fields (from `select()` / `add_field()`)
274    /// 2. Expanded field references (from `expand()`)
275    ///
276    /// If no fields are selected and no expansions exist, defaults to `fields *;`.
277    ///
278    /// # Examples
279    ///
280    /// ```rust
281    /// use igdb_atlas::QueryBuilder;
282    ///
283    /// let q = QueryBuilder::new()
284    ///     .select(&["name", "rating"])
285    ///     .search("Zelda")
286    ///     .where_clause("rating > 80")
287    ///     .sort_by("rating", true)
288    ///     .limit(10)
289    ///     .expand("platforms", &["name", "abbreviation"])
290    ///     .build();
291    ///
292    /// // All parts present
293    /// assert!(q.contains("fields name,rating,platforms.name,platforms.abbreviation;"));
294    /// assert!(q.contains("search \"Zelda\";"));
295    /// assert!(q.contains("where rating > 80;"));
296    /// assert!(q.contains("sort rating desc;"));
297    /// assert!(q.contains("limit 10;"));
298    /// ```
299    pub fn build(&self) -> String {
300        let mut parts = Vec::new();
301
302        // Build fields clause with expansions
303        let mut all_fields: Vec<String> = Vec::new();
304
305        // Add explicitly selected fields (skip "*" if we have expansions)
306        for f in &self.fields {
307            if f == "*" && !self.expansions.is_empty() {
308                // When using * with expansions, we still need * for
309                // the top-level fields
310                all_fields.push("*".to_string());
311            } else {
312                all_fields.push(f.clone());
313            }
314        }
315
316        // Add expansion fields using dot notation
317        for (parent, sub_fields) in &self.expansions {
318            // Remove the parent from top-level fields if present
319            // (it will be replaced by the dot-notation version)
320            all_fields.retain(|f| f != parent);
321
322            if sub_fields.is_empty() {
323                all_fields.push(format!("{}.*", parent));
324            } else {
325                for sf in sub_fields {
326                    all_fields.push(format!("{}.{}", parent, sf));
327                }
328            }
329        }
330
331        if all_fields.is_empty() {
332            parts.push("fields *;".to_string());
333        } else {
334            parts.push(format!("fields {};", all_fields.join(",")));
335        }
336
337        // Search
338        if let Some(ref term) = self.search_term {
339            parts.push(format!("search \"{}\";", term));
340        }
341
342        // Where
343        if !self.where_clauses.is_empty() {
344            parts.push(format!("where {};", self.where_clauses.join(" & ")));
345        }
346
347        // Sort
348        if let Some(ref field) = self.sort_field {
349            let dir = if self.sort_desc { "desc" } else { "asc" };
350            parts.push(format!("sort {} {};", field, dir));
351        }
352
353        // Limit
354        if let Some(n) = self.limit_val {
355            parts.push(format!("limit {};", n));
356        }
357
358        // Offset
359        if let Some(n) = self.offset_val {
360            parts.push(format!("offset {};", n));
361        }
362
363        parts.join(" ")
364    }
365
366    /// Returns the number of explicitly selected fields.
367    ///
368    /// # Examples
369    ///
370    /// ```rust
371    /// use igdb_atlas::QueryBuilder;
372    ///
373    /// let b = QueryBuilder::new().select(&["name", "rating"]);
374    /// assert_eq!(b.field_count(), 2);
375    /// ```
376    pub fn field_count(&self) -> usize {
377        self.fields.len()
378    }
379
380    /// Returns `true` if a search term has been set.
381    ///
382    /// # Examples
383    ///
384    /// ```rust
385    /// use igdb_atlas::QueryBuilder;
386    ///
387    /// assert!(!QueryBuilder::new().has_search());
388    /// assert!(QueryBuilder::new().search("test").has_search());
389    /// ```
390    pub fn has_search(&self) -> bool {
391        self.search_term.is_some()
392    }
393
394    /// Returns the number of WHERE clauses.
395    ///
396    /// # Examples
397    ///
398    /// ```rust
399    /// use igdb_atlas::QueryBuilder;
400    ///
401    /// let b = QueryBuilder::new()
402    ///     .where_clause("a = 1")
403    ///     .and_where("b = 2");
404    /// assert_eq!(b.where_count(), 2);
405    /// ```
406    pub fn where_count(&self) -> usize {
407        self.where_clauses.len()
408    }
409
410    /// Returns the number of field expansions.
411    ///
412    /// # Examples
413    ///
414    /// ```rust
415    /// use igdb_atlas::QueryBuilder;
416    ///
417    /// let b = QueryBuilder::new()
418    ///     .expand("platforms", &["name"])
419    ///     .expand("genres", &["name"]);
420    /// assert_eq!(b.expansion_count(), 2);
421    /// ```
422    pub fn expansion_count(&self) -> usize {
423        self.expansions.len()
424    }
425}
426
427impl Default for QueryBuilder {
428    fn default() -> Self {
429        Self::new()
430    }
431}