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}