Skip to main content

mtgjson_sdk/queries/
sets.rs

1//! Set 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// SearchSetsParams
12// ---------------------------------------------------------------------------
13
14/// Parameters for the set search method.
15#[derive(Debug, Clone, Default)]
16pub struct SearchSetsParams {
17    pub name: Option<String>,
18    pub set_type: Option<String>,
19    pub block: Option<String>,
20    pub release_year: Option<i32>,
21    pub limit: Option<usize>,
22    pub offset: Option<usize>,
23}
24
25// ---------------------------------------------------------------------------
26// SetQuery
27// ---------------------------------------------------------------------------
28
29/// Query interface for MTG sets backed by the `sets` parquet view.
30pub struct SetQuery<'a> {
31    conn: &'a crate::connection::Connection,
32}
33
34impl<'a> SetQuery<'a> {
35    /// Create a new `SetQuery` bound to the given connection.
36    pub fn new(conn: &'a crate::connection::Connection) -> Self {
37        Self { conn }
38    }
39
40    /// Get a single set by its code (case-insensitive -- uppercased before lookup).
41    pub fn get(&self, code: &str) -> Result<Option<Value>> {
42        self.conn.ensure_views(&["sets"])?;
43
44        let upper = code.to_uppercase();
45        let (sql, params) = SqlBuilder::new("sets")
46            .where_eq("code", &upper)
47            .limit(1)
48            .build();
49
50        let rows = self.conn.execute(&sql, &params)?;
51        Ok(rows.into_iter().next().map(|r| serde_json::to_value(r).unwrap_or(Value::Null)))
52    }
53
54    /// List all sets ordered by release date (descending).
55    ///
56    /// Supports optional filtering by `set_type` and `name` (substring match), plus
57    /// `limit` / `offset` pagination.
58    pub fn list(
59        &self,
60        set_type: Option<&str>,
61        name: Option<&str>,
62        limit: Option<usize>,
63        offset: Option<usize>,
64    ) -> Result<Vec<Value>> {
65        self.conn.ensure_views(&["sets"])?;
66
67        let mut qb = SqlBuilder::new("sets");
68        qb.order_by(&["releaseDate DESC"]);
69
70        if let Some(st) = set_type {
71            qb.where_eq("type", st);
72        }
73
74        if let Some(n) = name {
75            qb.where_like("name", &format!("%{}%", n));
76        }
77
78        if let Some(l) = limit {
79            qb.limit(l);
80        }
81        if let Some(o) = offset {
82            qb.offset(o);
83        }
84
85        let (sql, params) = qb.build();
86        let rows = self.conn.execute(&sql, &params)?;
87        Ok(rows_to_values(rows))
88    }
89
90    /// Search sets using a combination of filters.
91    ///
92    /// - `name`: substring LIKE match
93    /// - `set_type`: exact match on `type`
94    /// - `block`: exact match on `block`
95    /// - `release_year`: matches the year portion of `releaseDate`
96    pub fn search(&self, params: &SearchSetsParams) -> Result<Vec<Value>> {
97        self.conn.ensure_views(&["sets"])?;
98
99        let mut qb = SqlBuilder::new("sets");
100        qb.order_by(&["releaseDate DESC"]);
101
102        if let Some(ref name) = params.name {
103            qb.where_like("name", &format!("%{}%", name));
104        }
105
106        if let Some(ref st) = params.set_type {
107            qb.where_eq("type", st);
108        }
109
110        if let Some(ref block) = params.block {
111            qb.where_eq("block", block);
112        }
113
114        if let Some(year) = params.release_year {
115            qb.where_clause(
116                "EXTRACT(YEAR FROM CAST(releaseDate AS DATE)) = ?",
117                &[&year.to_string()],
118            );
119        }
120
121        let limit = params.limit.unwrap_or(100);
122        let offset = params.offset.unwrap_or(0);
123        qb.limit(limit);
124        qb.offset(offset);
125
126        let (sql, sql_params) = qb.build();
127        let rows = self.conn.execute(&sql, &sql_params)?;
128        Ok(rows_to_values(rows))
129    }
130
131    /// Get a financial summary for the given set code.
132    ///
133    /// Requires the `all_prices_today` view to be available.
134    /// Returns a map with keys: `card_count`, `total_value`, `avg_value`,
135    /// `min_value`, `max_value`, `date`.
136    ///
137    /// # Arguments
138    ///
139    /// * `set_code` - The set code (case-insensitive).
140    /// * `provider` - Price provider (default `"tcgplayer"`).
141    /// * `currency` - Currency code (default `"USD"`).
142    /// * `finish` - Card finish (default `"normal"`).
143    /// * `price_type` - Price type (default `"retail"`).
144    pub fn get_financial_summary(
145        &self,
146        set_code: &str,
147        provider: Option<&str>,
148        currency: Option<&str>,
149        finish: Option<&str>,
150        price_type: Option<&str>,
151    ) -> Result<HashMap<String, Value>> {
152        self.conn.ensure_views(&["cards", "all_prices_today"])?;
153
154        let upper = set_code.to_uppercase();
155        let provider = provider.unwrap_or("tcgplayer");
156        let currency = currency.unwrap_or("USD");
157        let finish = finish.unwrap_or("normal");
158        let price_type = price_type.unwrap_or("retail");
159
160        let sql = r#"
161            SELECT
162                COUNT(DISTINCT c.uuid) AS card_count,
163                COALESCE(SUM(p.price), 0) AS total_value,
164                COALESCE(AVG(p.price), 0) AS avg_value,
165                COALESCE(MIN(p.price), 0) AS min_value,
166                COALESCE(MAX(p.price), 0) AS max_value,
167                MAX(p.date) AS date
168            FROM cards c
169            JOIN all_prices_today p ON c.uuid = p.uuid
170            WHERE c.setCode = ?
171              AND p.provider = ?
172              AND p.currency = ?
173              AND p.finish = ?
174              AND p.price_type = ?
175        "#;
176
177        let rows = self.conn.execute(
178            sql,
179            &[
180                upper,
181                provider.to_string(),
182                currency.to_string(),
183                finish.to_string(),
184                price_type.to_string(),
185            ],
186        )?;
187
188        if let Some(row) = rows.into_iter().next() {
189            Ok(row)
190        } else {
191            Ok(HashMap::new())
192        }
193    }
194
195    /// Count all sets, optionally filtered by `set_type`.
196    pub fn count(&self, set_type: Option<&str>) -> Result<i64> {
197        self.conn.ensure_views(&["sets"])?;
198
199        let mut qb = SqlBuilder::new("sets");
200        qb.select(&["COUNT(*) AS cnt"]);
201
202        if let Some(st) = set_type {
203            qb.where_eq("type", st);
204        }
205
206        let (sql, params) = qb.build();
207        let rows = self.conn.execute(&sql, &params)?;
208
209        let cnt = rows
210            .first()
211            .and_then(|r| r.get("cnt"))
212            .and_then(|v| v.as_i64())
213            .unwrap_or(0);
214
215        Ok(cnt)
216    }
217}
218
219// ---------------------------------------------------------------------------
220// Helpers
221// ---------------------------------------------------------------------------
222
223fn rows_to_values(rows: Vec<HashMap<String, Value>>) -> Vec<Value> {
224    rows.into_iter()
225        .map(|r| serde_json::to_value(r).unwrap_or(Value::Null))
226        .collect()
227}