Skip to main content

mtgjson_sdk/queries/
cards.rs

1//! Card queries against the DuckDB-backed parquet data.
2
3use std::collections::HashMap;
4
5use serde_json::Value;
6
7use crate::error::Result;
8use crate::sql_builder::SqlBuilder;
9
10// ---------------------------------------------------------------------------
11// SearchCardsParams
12// ---------------------------------------------------------------------------
13
14/// Parameters for the advanced card search.
15///
16/// All fields are optional. When `None`, the corresponding filter is skipped.
17#[derive(Debug, Clone, Default)]
18pub struct SearchCardsParams {
19    pub name: Option<String>,
20    pub fuzzy_name: Option<String>,
21    pub localized_name: Option<String>,
22    pub set_code: Option<String>,
23    pub colors: Option<Vec<String>>,
24    pub color_identity: Option<Vec<String>>,
25    pub types: Option<String>,
26    pub rarity: Option<String>,
27    pub legal_in: Option<String>,
28    pub mana_value: Option<f64>,
29    pub mana_value_lte: Option<f64>,
30    pub mana_value_gte: Option<f64>,
31    pub text: Option<String>,
32    pub text_regex: Option<String>,
33    pub power: Option<String>,
34    pub toughness: Option<String>,
35    pub artist: Option<String>,
36    pub keyword: Option<String>,
37    pub is_promo: Option<bool>,
38    pub availability: Option<String>,
39    pub language: Option<String>,
40    pub layout: Option<String>,
41    pub set_type: Option<String>,
42    pub limit: Option<usize>,
43    pub offset: Option<usize>,
44}
45
46// ---------------------------------------------------------------------------
47// CardQuery
48// ---------------------------------------------------------------------------
49
50/// Query interface for MTG cards backed by the `cards` parquet view.
51pub struct CardQuery<'a> {
52    conn: &'a crate::connection::Connection,
53}
54
55impl<'a> CardQuery<'a> {
56    /// Create a new `CardQuery` bound to the given connection.
57    pub fn new(conn: &'a crate::connection::Connection) -> Self {
58        Self { conn }
59    }
60
61    // -- Single card lookup ------------------------------------------------
62
63    /// Retrieve a single card by its UUID.
64    pub fn get_by_uuid(&self, uuid: &str) -> Result<Option<Value>> {
65        self.conn.ensure_views(&["cards"])?;
66
67        let (sql, params) = SqlBuilder::new("cards")
68            .where_eq("uuid", uuid)
69            .limit(1)
70            .build();
71
72        let rows = self.conn.execute(&sql, &params)?;
73        Ok(rows.into_iter().next().map(|r| serde_json::to_value(r).unwrap_or(Value::Null)))
74    }
75
76    // -- Batch lookup ------------------------------------------------------
77
78    /// Retrieve multiple cards by their UUIDs (preserves order where possible).
79    pub fn get_by_uuids(&self, uuids: &[&str]) -> Result<Vec<Value>> {
80        self.conn.ensure_views(&["cards"])?;
81
82        let (sql, params) = SqlBuilder::new("cards")
83            .where_in("uuid", uuids)
84            .build();
85
86        let rows = self.conn.execute(&sql, &params)?;
87        Ok(rows_to_values(rows))
88    }
89
90    // -- Name lookup -------------------------------------------------------
91
92    /// Get all printings of a card by exact name, optionally filtered by set code.
93    pub fn get_by_name(&self, name: &str, set_code: Option<&str>) -> Result<Vec<Value>> {
94        self.conn.ensure_views(&["cards"])?;
95
96        let mut qb = SqlBuilder::new("cards");
97        qb.where_eq("name", name);
98
99        if let Some(sc) = set_code {
100            qb.where_eq("setCode", sc);
101        }
102
103        let (sql, params) = qb.build();
104        let rows = self.conn.execute(&sql, &params)?;
105        Ok(rows_to_values(rows))
106    }
107
108    /// Alias for [`get_by_name`](Self::get_by_name) -- returns all printings of the card.
109    pub fn get_printings(&self, name: &str) -> Result<Vec<Value>> {
110        self.get_by_name(name, None)
111    }
112
113    // -- Atomic (oracle-level) lookup --------------------------------------
114
115    /// Get a de-duplicated oracle-level card by name.
116    ///
117    /// De-duplicates by `(name, faceName)`. If no results are found for an exact name
118    /// match, falls back to searching by `faceName`.
119    pub fn get_atomic(&self, name: &str) -> Result<Vec<Value>> {
120        self.conn.ensure_views(&["cards"])?;
121
122        // First try: match by name, deduplicate by name + faceName
123        let (sql, params) = SqlBuilder::new("cards")
124            .select(&["DISTINCT ON (name, faceName) *"])
125            .where_eq("name", name)
126            .build();
127
128        let rows = self.conn.execute(&sql, &params)?;
129        if !rows.is_empty() {
130            return Ok(rows_to_values(rows));
131        }
132
133        // Fallback: search by faceName
134        let (sql2, params2) = SqlBuilder::new("cards")
135            .select(&["DISTINCT ON (name, faceName) *"])
136            .where_eq("faceName", name)
137            .build();
138
139        let rows2 = self.conn.execute(&sql2, &params2)?;
140        Ok(rows_to_values(rows2))
141    }
142
143    // -- Cross-table lookups -----------------------------------------------
144
145    /// Find cards by their Scryfall ID (joins `card_identifiers`).
146    pub fn find_by_scryfall_id(&self, scryfall_id: &str) -> Result<Vec<Value>> {
147        self.conn.ensure_views(&["cards", "card_identifiers"])?;
148
149        let (sql, params) = SqlBuilder::new("cards c")
150            .join("JOIN card_identifiers ci ON c.uuid = ci.uuid")
151            .where_eq("ci.scryfallId", scryfall_id)
152            .build();
153
154        let rows = self.conn.execute(&sql, &params)?;
155        Ok(rows_to_values(rows))
156    }
157
158    // -- Random sampling ---------------------------------------------------
159
160    /// Return `count` randomly-sampled cards.
161    pub fn random(&self, count: usize) -> Result<Vec<Value>> {
162        self.conn.ensure_views(&["cards"])?;
163
164        let sql = format!("SELECT * FROM cards USING SAMPLE {}", count);
165        let rows = self.conn.execute(&sql, &[])?;
166        Ok(rows_to_values(rows))
167    }
168
169    // -- Count -------------------------------------------------------------
170
171    /// Count cards, optionally filtered by the supplied column/value pairs.
172    pub fn count(&self, filters: &HashMap<String, String>) -> Result<i64> {
173        self.conn.ensure_views(&["cards"])?;
174
175        let mut qb = SqlBuilder::new("cards");
176        qb.select(&["COUNT(*) AS cnt"]);
177
178        for (col, val) in filters {
179            qb.where_eq(col, val);
180        }
181
182        let (sql, params) = qb.build();
183        let rows = self.conn.execute(&sql, &params)?;
184
185        let cnt = rows
186            .first()
187            .and_then(|r| r.get("cnt"))
188            .and_then(|v| v.as_i64())
189            .unwrap_or(0);
190
191        Ok(cnt)
192    }
193
194    // -- Advanced search ---------------------------------------------------
195
196    /// Search for cards using a rich set of optional filters.
197    ///
198    /// Translates each field of [`SearchCardsParams`] into appropriate SQL conditions
199    /// (LIKE, exact match, fuzzy match, JOIN, list_contains, regexp, etc.).
200    pub fn search(&self, params: &SearchCardsParams) -> Result<Vec<Value>> {
201        // Determine which views we need
202        let mut views: Vec<&str> = vec!["cards"];
203        if params.legal_in.is_some() {
204            views.push("card_legalities");
205        }
206        if params.localized_name.is_some() {
207            views.push("card_foreign_data");
208        }
209        if params.set_type.is_some() {
210            views.push("sets");
211        }
212        self.conn.ensure_views(&views)?;
213
214        let mut qb = SqlBuilder::new("cards");
215
216        // -- name: if contains '%' use LIKE, otherwise exact match ----------
217        if let Some(ref name) = params.name {
218            if name.contains('%') {
219                qb.where_like("cards.name", name);
220            } else {
221                qb.where_eq("cards.name", name);
222            }
223        }
224
225        // -- fuzzy_name: jaro_winkler_similarity >= 0.8 ---------------------
226        if let Some(ref fuzzy) = params.fuzzy_name {
227            qb.where_fuzzy("cards.name", fuzzy, 0.8);
228            qb.order_by(&[&format!(
229                "jaro_winkler_similarity(cards.name, '{}') DESC",
230                fuzzy.replace('\'', "''")
231            )]);
232        }
233
234        // -- localized_name: JOIN card_foreign_data -------------------------
235        if let Some(ref loc_name) = params.localized_name {
236            qb.join("JOIN card_foreign_data cfd ON cards.uuid = cfd.uuid");
237            qb.where_like("cfd.name", &format!("%{}%", loc_name));
238        }
239
240        // -- set_code -------------------------------------------------------
241        if let Some(ref sc) = params.set_code {
242            qb.where_eq("cards.setCode", sc);
243        }
244
245        // -- colors: list_contains for each color ---------------------------
246        if let Some(ref colors) = params.colors {
247            for color in colors {
248                qb.where_clause(
249                    "list_contains(cards.colors, ?)",
250                    &[color.as_str()],
251                );
252            }
253        }
254
255        // -- color_identity: list_contains for each color -------------------
256        if let Some(ref ci) = params.color_identity {
257            for color in ci {
258                qb.where_clause(
259                    "list_contains(cards.colorIdentity, ?)",
260                    &[color.as_str()],
261                );
262            }
263        }
264
265        // -- types: LIKE %types% -------------------------------------------
266        if let Some(ref types) = params.types {
267            qb.where_like("cards.type", &format!("%{}%", types));
268        }
269
270        // -- rarity ---------------------------------------------------------
271        if let Some(ref rarity) = params.rarity {
272            qb.where_eq("cards.rarity", rarity);
273        }
274
275        // -- legal_in: JOIN card_legalities ---------------------------------
276        if let Some(ref format_name) = params.legal_in {
277            qb.join("JOIN card_legalities cl ON cards.uuid = cl.uuid");
278            qb.where_eq("cl.format", format_name);
279            qb.where_eq("cl.status", "Legal");
280        }
281
282        // -- mana_value (exact) --------------------------------------------
283        if let Some(mv) = params.mana_value {
284            qb.where_eq("cards.manaValue", &mv.to_string());
285        }
286
287        // -- mana_value_lte -------------------------------------------------
288        if let Some(mv) = params.mana_value_lte {
289            qb.where_lte("cards.manaValue", &mv.to_string());
290        }
291
292        // -- mana_value_gte -------------------------------------------------
293        if let Some(mv) = params.mana_value_gte {
294            qb.where_gte("cards.manaValue", &mv.to_string());
295        }
296
297        // -- text: LIKE %text% ---------------------------------------------
298        if let Some(ref text) = params.text {
299            qb.where_like("cards.text", &format!("%{}%", text));
300        }
301
302        // -- text_regex: regexp_matches -------------------------------------
303        if let Some(ref regex) = params.text_regex {
304            qb.where_regex("cards.text", regex);
305        }
306
307        // -- power ----------------------------------------------------------
308        if let Some(ref power) = params.power {
309            qb.where_eq("cards.power", power);
310        }
311
312        // -- toughness ------------------------------------------------------
313        if let Some(ref toughness) = params.toughness {
314            qb.where_eq("cards.toughness", toughness);
315        }
316
317        // -- artist ---------------------------------------------------------
318        if let Some(ref artist) = params.artist {
319            qb.where_like("cards.artist", &format!("%{}%", artist));
320        }
321
322        // -- keyword: list_contains(keywords, keyword) ----------------------
323        if let Some(ref kw) = params.keyword {
324            qb.where_clause(
325                "list_contains(cards.keywords, ?)",
326                &[kw.as_str()],
327            );
328        }
329
330        // -- is_promo -------------------------------------------------------
331        // Note: duckdb-rs 1.4.4 has a bug where BOOLEAN false in parquet files
332        // is returned as NULL. Use IS TRUE / IS NOT TRUE as a workaround.
333        if let Some(promo) = params.is_promo {
334            if promo {
335                qb.where_clause("cards.isPromo IS TRUE", &[]);
336            } else {
337                qb.where_clause("cards.isPromo IS NOT TRUE", &[]);
338            }
339        }
340
341        // -- availability: list_contains ------------------------------------
342        if let Some(ref avail) = params.availability {
343            qb.where_clause(
344                "list_contains(cards.availability, ?)",
345                &[avail.as_str()],
346            );
347        }
348
349        // -- language -------------------------------------------------------
350        if let Some(ref lang) = params.language {
351            qb.where_eq("cards.language", lang);
352        }
353
354        // -- layout ---------------------------------------------------------
355        if let Some(ref layout) = params.layout {
356            qb.where_eq("cards.layout", layout);
357        }
358
359        // -- set_type: JOIN sets --------------------------------------------
360        if let Some(ref st) = params.set_type {
361            qb.join("JOIN sets s ON cards.setCode = s.code");
362            qb.where_eq("s.type", st);
363        }
364
365        // -- pagination -----------------------------------------------------
366        let limit = params.limit.unwrap_or(100);
367        let offset = params.offset.unwrap_or(0);
368        qb.limit(limit);
369        qb.offset(offset);
370
371        let (sql, sql_params) = qb.build();
372        let rows = self.conn.execute(&sql, &sql_params)?;
373        Ok(rows_to_values(rows))
374    }
375}
376
377// ---------------------------------------------------------------------------
378// Helpers
379// ---------------------------------------------------------------------------
380
381/// Convert a vector of row HashMaps into `serde_json::Value` objects.
382fn rows_to_values(rows: Vec<HashMap<String, Value>>) -> Vec<Value> {
383    rows.into_iter()
384        .map(|r| serde_json::to_value(r).unwrap_or(Value::Null))
385        .collect()
386}