query.rs (8340B)
1 use radroots_sql_core::utils; 2 use serde::Deserialize; 3 use serde_json::Value; 4 5 use crate::{ReplicaSql, SqlError, SqlExecutor}; 6 7 #[derive(Debug, Clone, Deserialize, PartialEq)] 8 pub struct ReplicaTradeProductSummaryRow { 9 pub id: String, 10 pub key: String, 11 pub category: String, 12 pub title: String, 13 pub summary: String, 14 pub qty_amt: f64, 15 pub qty_amt_exact: Option<String>, 16 pub qty_unit: String, 17 pub qty_label: Option<String>, 18 pub qty_avail: Option<i64>, 19 pub price_amt: f64, 20 pub price_amt_exact: Option<String>, 21 pub price_currency: String, 22 pub price_qty_amt: f64, 23 pub price_qty_amt_exact: Option<String>, 24 pub price_qty_unit: String, 25 pub listing_addr: Option<String>, 26 pub primary_bin_id: Option<String>, 27 pub verified_primary_bin_id: Option<String>, 28 pub notes: Option<String>, 29 pub location_primary: Option<String>, 30 } 31 32 #[derive(Debug, Clone, Deserialize)] 33 struct ReplicaFarmDTagRow { 34 d_tag: String, 35 } 36 37 #[derive(Debug, Clone, Deserialize)] 38 struct ReplicaEventFreshnessRow { 39 last_created_at: Option<i64>, 40 } 41 42 impl<E: SqlExecutor> ReplicaSql<E> { 43 pub fn trade_product_lookup( 44 &self, 45 lookup: &str, 46 ) -> Result<Vec<ReplicaTradeProductSummaryRow>, SqlError> { 47 let sql = "SELECT tp.id, tp.key, tp.category, tp.title, tp.summary, tp.qty_amt, tp.qty_amt_exact, tp.qty_unit, tp.qty_label, tp.qty_avail, tp.price_amt, tp.price_amt_exact, tp.price_currency, tp.price_qty_amt, tp.price_qty_amt_exact, tp.price_qty_unit, tp.listing_addr, tp.primary_bin_id, tp.verified_primary_bin_id, tp.notes, loc.location_primary \ 48 FROM trade_product tp \ 49 LEFT JOIN (\ 50 SELECT tpl.tb_tp AS trade_product_id, MIN(COALESCE(gl.label, gl.gc_name, gl.gc_admin1_name, gl.gc_country_name, gl.d_tag)) AS location_primary \ 51 FROM trade_product_location tpl \ 52 JOIN gcs_location gl ON gl.id = tpl.tb_gl \ 53 GROUP BY tpl.tb_tp\ 54 ) loc ON loc.trade_product_id = tp.id \ 55 WHERE tp.id = ? OR tp.key = ? \ 56 ORDER BY lower(tp.title) ASC, tp.id ASC;"; 57 let params_json = utils::to_params_json(vec![ 58 Value::from(lookup.to_owned()), 59 Value::from(lookup.to_owned()), 60 ])?; 61 let json = self.executor().query_raw(sql, ¶ms_json)?; 62 serde_json::from_str(&json).map_err(SqlError::from) 63 } 64 65 pub fn trade_product_search( 66 &self, 67 query_terms: &[String], 68 ) -> Result<Vec<ReplicaTradeProductSummaryRow>, SqlError> { 69 if query_terms.is_empty() { 70 return Ok(Vec::new()); 71 } 72 73 let mut where_clauses = Vec::with_capacity(query_terms.len()); 74 let mut bind_values = Vec::<Value>::with_capacity(query_terms.len() * 5); 75 for term in query_terms { 76 let pattern = format!("%{}%", term.to_lowercase()); 77 where_clauses.push( 78 "(lower(tp.title) LIKE ? OR lower(tp.summary) LIKE ? OR lower(tp.category) LIKE ? OR lower(tp.key) LIKE ? OR lower(COALESCE(tp.notes, '')) LIKE ?)" 79 .to_owned(), 80 ); 81 for _ in 0..5 { 82 bind_values.push(Value::from(pattern.clone())); 83 } 84 } 85 86 let sql = format!( 87 "SELECT tp.id, tp.key, tp.category, tp.title, tp.summary, tp.qty_amt, tp.qty_amt_exact, tp.qty_unit, tp.qty_label, tp.qty_avail, tp.price_amt, tp.price_amt_exact, tp.price_currency, tp.price_qty_amt, tp.price_qty_amt_exact, tp.price_qty_unit, tp.listing_addr, tp.primary_bin_id, tp.verified_primary_bin_id, tp.notes, loc.location_primary \ 88 FROM trade_product tp \ 89 LEFT JOIN (\ 90 SELECT tpl.tb_tp AS trade_product_id, MIN(COALESCE(gl.label, gl.gc_name, gl.gc_admin1_name, gl.gc_country_name, gl.d_tag)) AS location_primary \ 91 FROM trade_product_location tpl \ 92 JOIN gcs_location gl ON gl.id = tpl.tb_gl \ 93 GROUP BY tpl.tb_tp\ 94 ) loc ON loc.trade_product_id = tp.id \ 95 WHERE {} \ 96 ORDER BY lower(tp.title) ASC, tp.id ASC;", 97 where_clauses.join(" AND ") 98 ); 99 let params_json = utils::to_params_json(bind_values)?; 100 let json = self.executor().query_raw(&sql, ¶ms_json)?; 101 serde_json::from_str(&json).map_err(SqlError::from) 102 } 103 104 pub fn farm_unique_d_tag_by_pubkey( 105 &self, 106 seller_pubkey: &str, 107 ) -> Result<Option<String>, SqlError> { 108 let sql = "SELECT d_tag FROM farm WHERE pubkey = ? ORDER BY d_tag ASC;"; 109 let params_json = utils::to_params_json(vec![Value::from(seller_pubkey.to_owned())])?; 110 let json = self.executor().query_raw(sql, ¶ms_json)?; 111 let rows: Vec<ReplicaFarmDTagRow> = serde_json::from_str(&json).map_err(SqlError::from)?; 112 if rows.len() == 1 { 113 Ok(Some(rows[0].d_tag.clone())) 114 } else { 115 Ok(None) 116 } 117 } 118 119 pub fn nostr_event_last_created_at(&self) -> Result<Option<u64>, SqlError> { 120 let json = self.executor().query_raw( 121 "SELECT MAX(last_created_at) AS last_created_at FROM nostr_event_head WHERE last_created_at IS NOT NULL", 122 "[]", 123 )?; 124 let rows: Vec<ReplicaEventFreshnessRow> = 125 serde_json::from_str(&json).map_err(SqlError::from)?; 126 Ok(rows 127 .into_iter() 128 .next() 129 .and_then(|row| row.last_created_at) 130 .and_then(|value| u64::try_from(value).ok())) 131 } 132 } 133 134 #[cfg(test)] 135 mod tests { 136 use super::*; 137 use radroots_sql_core::ExecOutcome; 138 139 struct QueryExecutor { 140 farm_rows: &'static str, 141 product_rows: &'static str, 142 } 143 144 impl SqlExecutor for QueryExecutor { 145 fn exec(&self, _sql: &str, _params_json: &str) -> Result<ExecOutcome, SqlError> { 146 Ok(ExecOutcome { 147 changes: 0, 148 last_insert_id: 0, 149 }) 150 } 151 152 fn query_raw(&self, sql: &str, _params_json: &str) -> Result<String, SqlError> { 153 if sql.contains("FROM farm") { 154 Ok(self.farm_rows.to_string()) 155 } else { 156 Ok(self.product_rows.to_string()) 157 } 158 } 159 160 fn begin(&self) -> Result<(), SqlError> { 161 Ok(()) 162 } 163 164 fn commit(&self) -> Result<(), SqlError> { 165 Ok(()) 166 } 167 168 fn rollback(&self) -> Result<(), SqlError> { 169 Ok(()) 170 } 171 } 172 173 fn product_rows() -> &'static str { 174 r#"[{ 175 "id":"listing-1", 176 "key":"coffee", 177 "category":"produce", 178 "title":"Coffee", 179 "summary":"Washed coffee", 180 "qty_amt":1.0, 181 "qty_amt_exact":"1", 182 "qty_unit":"kg", 183 "qty_label":null, 184 "qty_avail":10, 185 "price_amt":12.0, 186 "price_amt_exact":"12", 187 "price_currency":"USD", 188 "price_qty_amt":1.0, 189 "price_qty_amt_exact":"1", 190 "price_qty_unit":"kg", 191 "listing_addr":"30402:pubkey:AAAAAAAAAAAAAAAAAAAAAA", 192 "primary_bin_id":"bin-1", 193 "verified_primary_bin_id":"bin-1", 194 "notes":null, 195 "location_primary":"Farm" 196 }]"# 197 } 198 199 #[test] 200 fn trade_product_queries_and_unique_farm_lookup_cover_empty_and_multiple_rows() { 201 let db = ReplicaSql::new(QueryExecutor { 202 farm_rows: r#"[{"d_tag":"farm-a"},{"d_tag":"farm-b"}]"#, 203 product_rows: product_rows(), 204 }); 205 206 assert_eq!( 207 db.trade_product_search(&[]).expect("empty search"), 208 Vec::new() 209 ); 210 let lookup = db.trade_product_lookup("coffee").expect("lookup"); 211 assert_eq!(lookup[0].key, "coffee"); 212 assert_eq!( 213 db.farm_unique_d_tag_by_pubkey("seller") 214 .expect("farm lookup"), 215 None 216 ); 217 218 let unique_db = ReplicaSql::new(QueryExecutor { 219 farm_rows: r#"[{"d_tag":"farm-a"}]"#, 220 product_rows: product_rows(), 221 }); 222 assert_eq!( 223 unique_db 224 .farm_unique_d_tag_by_pubkey("seller") 225 .expect("farm lookup"), 226 Some("farm-a".to_string()) 227 ); 228 } 229 }