1use std::collections::HashMap;
22
23use rusqlite::{Connection, OptionalExtension, params};
24use serde_json::Value;
25
26use grabcli_core::cache::round_coord_to;
27pub use grabcli_core::cache::types::{
28 CategoryRow, ItemHit, ItemQuery, ItemRow, Near, RestaurantHit, RestaurantQuery, RestaurantRow,
29 effective_price, haversine_km, query_terms,
30};
31
32use crate::SqliteCache;
33use crate::err::Result;
34
35pub(crate) const TABLES: [&str; 4] = [
37 "restaurants",
38 "restaurant_locations",
39 "menu_categories",
40 "menu_items",
41];
42
43const BUCKET_DECIMALS: usize = 3;
45
46const SIGHTING_RADIUS_KM: f64 = 1.0;
49
50fn opt_json(v: Option<&Value>) -> Option<String> {
51 v.map(Value::to_string)
52}
53
54fn cuisines_text(c: &[String]) -> Option<String> {
55 let parts: Vec<&str> = c
56 .iter()
57 .map(|s| s.trim())
58 .filter(|s| !s.is_empty())
59 .collect();
60 (!parts.is_empty()).then(|| parts.join(", "))
61}
62
63pub(crate) fn bucket(lat: f64, lon: f64) -> String {
64 format!(
65 "{},{}",
66 round_coord_to(lat, BUCKET_DECIMALS),
67 round_coord_to(lon, BUCKET_DECIMALS)
68 )
69}
70
71fn bbox(lat: f64, lon: f64, km: f64) -> (f64, f64, f64, f64) {
73 let dlat = km / 111.32;
74 let dlon = km / (111.32 * lat.to_radians().cos().abs().max(0.01));
75 (lat - dlat, lat + dlat, lon - dlon, lon + dlon)
76}
77
78fn fts_word_query(terms: &[String]) -> String {
80 terms
81 .iter()
82 .map(|t| format!("\"{t}\"*"))
83 .collect::<Vec<_>>()
84 .join(" AND ")
85}
86
87fn fts_trigram_query(terms: &[String]) -> Option<String> {
89 terms.iter().all(|t| t.chars().count() >= 3).then(|| {
90 terms
91 .iter()
92 .map(|t| format!("\"{t}\""))
93 .collect::<Vec<_>>()
94 .join(" AND ")
95 })
96}
97
98fn text_rids(conn: &Connection, table: &str, text: &str) -> Result<Vec<i64>> {
100 let terms = query_terms(text);
101 if terms.is_empty() {
102 return Ok(Vec::new());
103 }
104 let mut out: Vec<i64> = Vec::new();
105 let mut seen = std::collections::HashSet::new();
106 let sql = format!(
107 "SELECT rowid FROM {table}_fts WHERE {table}_fts MATCH ?1 ORDER BY bm25({table}_fts)"
108 );
109 let mut stmt = conn.prepare(&sql)?;
110 for rid in stmt.query_map([fts_word_query(&terms)], |r| r.get::<_, i64>(0))? {
111 let rid = rid?;
112 if seen.insert(rid) {
113 out.push(rid);
114 }
115 }
116 if let Some(q) = fts_trigram_query(&terms) {
117 let sql = format!(
118 "SELECT rowid FROM {table}_tri WHERE {table}_tri MATCH ?1 ORDER BY bm25({table}_tri)"
119 );
120 let mut stmt = conn.prepare(&sql)?;
121 for rid in stmt.query_map([q], |r| r.get::<_, i64>(0))? {
122 let rid = rid?;
123 if seen.insert(rid) {
124 out.push(rid);
125 }
126 }
127 }
128 Ok(out)
129}
130
131fn upsert_restaurant(conn: &Connection, r: &RestaurantRow, now: i64) -> Result<()> {
132 conn.execute(
133 "INSERT INTO restaurants (id, namespace, name, chain_name, branch_name, business_type,
134 cuisines, rating, vote_count, price_level, lat, lon, delivery_fee_minor,
135 delivery_eta_min, has_promo, promo_text, free_delivery, open_now, opening_hours,
136 distance_km, photo_url, fetched_at, raw_json)
137 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
138 ?18, ?19, ?20, ?21, ?22, ?23)
139 ON CONFLICT(id) DO UPDATE SET
140 namespace = excluded.namespace,
141 name = CASE WHEN excluded.name <> '' THEN excluded.name ELSE name END,
142 chain_name = COALESCE(excluded.chain_name, chain_name),
143 branch_name = COALESCE(excluded.branch_name, branch_name),
144 business_type = COALESCE(excluded.business_type, business_type),
145 cuisines = COALESCE(excluded.cuisines, cuisines),
146 rating = COALESCE(excluded.rating, rating),
147 vote_count = COALESCE(excluded.vote_count, vote_count),
148 price_level = COALESCE(excluded.price_level, price_level),
149 lat = COALESCE(excluded.lat, lat),
150 lon = COALESCE(excluded.lon, lon),
151 delivery_fee_minor = COALESCE(excluded.delivery_fee_minor, delivery_fee_minor),
152 delivery_eta_min = COALESCE(excluded.delivery_eta_min, delivery_eta_min),
153 has_promo = COALESCE(excluded.has_promo, has_promo),
154 promo_text = COALESCE(excluded.promo_text, promo_text),
155 free_delivery = COALESCE(excluded.free_delivery, free_delivery),
156 open_now = COALESCE(excluded.open_now, open_now),
157 opening_hours = COALESCE(excluded.opening_hours, opening_hours),
158 distance_km = COALESCE(excluded.distance_km, distance_km),
159 photo_url = COALESCE(excluded.photo_url, photo_url),
160 fetched_at = excluded.fetched_at,
161 raw_json = COALESCE(excluded.raw_json, raw_json)",
162 params![
163 r.id,
164 r.namespace,
165 r.name,
166 r.chain_name,
167 r.branch_name,
168 r.business_type,
169 cuisines_text(&r.cuisines),
170 r.rating,
171 r.vote_count,
172 r.price_level,
173 r.lat,
174 r.lon,
175 r.delivery_fee_minor,
176 r.delivery_eta_min,
177 r.has_promo,
178 r.promo_text,
179 r.free_delivery,
180 r.open_now,
181 opt_json(r.opening_hours.as_ref()),
182 r.distance_km,
183 r.photo_url,
184 now,
185 opt_json(r.raw.as_ref()),
186 ],
187 )?;
188 Ok(())
189}
190
191fn upsert_sighting(
192 conn: &Connection,
193 r: &RestaurantRow,
194 lat: f64,
195 lon: f64,
196 now: i64,
197) -> Result<()> {
198 conn.execute(
199 "INSERT INTO restaurant_locations (restaurant_id, bucket, search_lat, search_lon,
200 distance_km, delivery_eta_min, delivery_fee_minor, free_delivery, fetched_at)
201 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
202 ON CONFLICT(restaurant_id, bucket) DO UPDATE SET
203 search_lat = excluded.search_lat,
204 search_lon = excluded.search_lon,
205 distance_km = COALESCE(excluded.distance_km, distance_km),
206 delivery_eta_min = COALESCE(excluded.delivery_eta_min, delivery_eta_min),
207 delivery_fee_minor = COALESCE(excluded.delivery_fee_minor, delivery_fee_minor),
208 free_delivery = COALESCE(excluded.free_delivery, free_delivery),
209 fetched_at = excluded.fetched_at",
210 params![
211 r.id,
212 bucket(lat, lon),
213 lat,
214 lon,
215 r.distance_km,
216 r.delivery_eta_min,
217 r.delivery_fee_minor,
218 r.free_delivery,
219 now
220 ],
221 )?;
222 Ok(())
223}
224
225fn upsert_item(conn: &Connection, it: &ItemRow, now: i64) -> Result<()> {
226 conn.execute(
227 "INSERT INTO menu_items (id, restaurant_id, category_id, name, description, price_minor,
228 discounted_price_minor, available, image_url, fetched_at, raw_json)
229 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
230 ON CONFLICT(id) DO UPDATE SET
231 restaurant_id = excluded.restaurant_id,
232 category_id = COALESCE(excluded.category_id, category_id),
233 name = CASE WHEN excluded.name <> '' THEN excluded.name ELSE name END,
234 description = COALESCE(excluded.description, description),
235 price_minor = COALESCE(excluded.price_minor, price_minor),
236 discounted_price_minor = excluded.discounted_price_minor,
237 available = COALESCE(excluded.available, available),
238 image_hash = CASE WHEN excluded.image_url IS image_url THEN image_hash ELSE NULL END,
239 image_url = COALESCE(excluded.image_url, image_url),
240 fetched_at = excluded.fetched_at,
241 raw_json = COALESCE(excluded.raw_json, raw_json)",
242 params![
243 it.id,
244 it.restaurant_id,
245 it.category_id,
246 it.name,
247 it.description.as_deref().filter(|d| !d.is_empty()),
248 it.price_minor,
249 it.discounted_price_minor,
250 it.available,
251 it.image_url,
252 now,
253 opt_json(it.raw.as_ref()),
254 ],
255 )?;
256 Ok(())
257}
258
259const RESTAURANT_COLS: &str = "rid, id, namespace, name, chain_name, branch_name, business_type, \
260 cuisines, rating, vote_count, price_level, lat, lon, delivery_fee_minor, delivery_eta_min, \
261 has_promo, promo_text, free_delivery, open_now, opening_hours, distance_km, photo_url, \
262 fetched_at";
263
264fn restaurant_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<(i64, RestaurantHit)> {
265 let cuisines: Option<String> = r.get(7)?;
266 let hours: Option<String> = r.get(19)?;
267 let row = RestaurantRow {
268 id: r.get(1)?,
269 namespace: r.get(2)?,
270 name: r.get(3)?,
271 chain_name: r.get(4)?,
272 branch_name: r.get(5)?,
273 business_type: r.get(6)?,
274 cuisines: cuisines
275 .map(|c| c.split(", ").map(ToString::to_string).collect())
276 .unwrap_or_default(),
277 rating: r.get(8)?,
278 vote_count: r.get(9)?,
279 price_level: r.get(10)?,
280 lat: r.get(11)?,
281 lon: r.get(12)?,
282 delivery_fee_minor: r.get(13)?,
283 delivery_eta_min: r.get(14)?,
284 has_promo: r.get(15)?,
285 promo_text: r.get(16)?,
286 free_delivery: r.get(17)?,
287 open_now: r.get(18)?,
288 opening_hours: hours.and_then(|h| serde_json::from_str(&h).ok()),
289 distance_km: r.get(20)?,
290 photo_url: r.get(21)?,
291 raw: None,
292 };
293 Ok((
294 r.get(0)?,
295 RestaurantHit {
296 row,
297 fetched_at: r.get(22)?,
298 },
299 ))
300}
301
302#[derive(Debug, Clone, Copy)]
305struct Sighting {
306 distance_km: Option<f64>,
307 eta: Option<i64>,
308 fee: Option<i64>,
309 free: Option<bool>,
310 fetched_at: i64,
311}
312
313fn nearest_sighting(conn: &Connection, id: &str, near: &Near) -> Result<Option<Sighting>> {
314 let (a, b, c, d) = bbox(near.lat, near.lon, SIGHTING_RADIUS_KM);
315 let mut stmt = conn.prepare_cached(
316 "SELECT search_lat, search_lon, distance_km, delivery_eta_min, delivery_fee_minor,
317 free_delivery, fetched_at
318 FROM restaurant_locations
319 WHERE restaurant_id = ?1 AND search_lat BETWEEN ?2 AND ?3 AND search_lon BETWEEN ?4 AND ?5",
320 )?;
321 let rows = stmt.query_map(params![id, a, b, c, d], |r| {
322 Ok((
323 r.get::<_, f64>(0)?,
324 r.get::<_, f64>(1)?,
325 Sighting {
326 distance_km: r.get(2)?,
327 eta: r.get(3)?,
328 fee: r.get(4)?,
329 free: r.get(5)?,
330 fetched_at: r.get(6)?,
331 },
332 ))
333 })?;
334 let mut best: Option<(f64, Sighting)> = None;
335 for row in rows {
336 let (lat, lon, s) = row?;
337 let d = haversine_km(near.lat, near.lon, lat, lon);
338 if d <= SIGHTING_RADIUS_KM && best.is_none_or(|(bd, _)| d < bd) {
339 best = Some((d, s));
340 }
341 }
342 Ok(best.map(|(_, s)| s))
343}
344
345fn localize(
348 conn: &Connection,
349 mut hit: RestaurantHit,
350 near: &Near,
351) -> Result<Option<RestaurantHit>> {
352 if let Some(s) = nearest_sighting(conn, &hit.row.id, near)? {
353 let r = &mut hit.row;
354 r.distance_km = s.distance_km.or(r.distance_km);
355 r.delivery_eta_min = s.eta;
356 r.delivery_fee_minor = s.fee;
357 r.free_delivery = s.free;
358 hit.fetched_at = hit.fetched_at.max(s.fetched_at);
359 let ok = hit.row.distance_km.is_none_or(|d| d <= near.radius_km);
360 return Ok(ok.then_some(hit));
361 }
362 match (hit.row.lat, hit.row.lon) {
363 (Some(lat), Some(lon)) => {
364 let d = haversine_km(near.lat, near.lon, lat, lon);
365 if d > near.radius_km {
366 return Ok(None);
367 }
368 hit.row.distance_km = Some(d);
370 hit.row.delivery_eta_min = None;
371 hit.row.delivery_fee_minor = None;
372 hit.row.free_delivery = None;
373 Ok(Some(hit))
374 }
375 _ => Ok(None),
376 }
377}
378
379fn near_rids(conn: &Connection, near: &Near) -> Result<Vec<i64>> {
382 let (a, b, c, d) = bbox(near.lat, near.lon, near.radius_km);
383 let (e, f, g, h) = bbox(near.lat, near.lon, SIGHTING_RADIUS_KM);
384 let mut stmt = conn.prepare(
385 "SELECT rid FROM restaurants WHERE lat BETWEEN ?1 AND ?2 AND lon BETWEEN ?3 AND ?4
386 UNION
387 SELECT r.rid FROM restaurant_locations l JOIN restaurants r ON r.id = l.restaurant_id
388 WHERE l.search_lat BETWEEN ?5 AND ?6 AND l.search_lon BETWEEN ?7 AND ?8",
389 )?;
390 let rids = stmt
391 .query_map(params![a, b, c, d, e, f, g, h], |r| r.get::<_, i64>(0))?
392 .collect::<rusqlite::Result<Vec<_>>>()?;
393 Ok(rids)
394}
395
396fn load_restaurant(conn: &Connection, rid: i64) -> Result<Option<RestaurantHit>> {
397 let mut stmt = conn.prepare_cached(&format!(
398 "SELECT {RESTAURANT_COLS} FROM restaurants WHERE rid = ?1"
399 ))?;
400 Ok(stmt
401 .query_row([rid], restaurant_from_row)
402 .optional()?
403 .map(|(_, h)| h))
404}
405
406fn load_restaurant_by_id(conn: &Connection, id: &str) -> Result<Option<RestaurantHit>> {
407 let mut stmt = conn.prepare_cached(&format!(
408 "SELECT {RESTAURANT_COLS} FROM restaurants WHERE id = ?1"
409 ))?;
410 Ok(stmt
411 .query_row([id], restaurant_from_row)
412 .optional()?
413 .map(|(_, h)| h))
414}
415
416pub(crate) fn search_restaurants(
417 conn: &Connection,
418 q: &RestaurantQuery,
419) -> Result<Vec<RestaurantHit>> {
420 let text = q.text.as_deref().map(str::trim).filter(|t| !t.is_empty());
421 let rids: Vec<i64> = match (text, &q.near) {
422 (Some(t), _) => text_rids(conn, "restaurants", t)?,
423 (None, Some(n)) => near_rids(conn, n)?,
424 (None, None) => {
425 let mut stmt = conn.prepare("SELECT rid FROM restaurants ORDER BY fetched_at DESC")?;
426 stmt.query_map([], |r| r.get::<_, i64>(0))?
427 .collect::<rusqlite::Result<Vec<_>>>()?
428 }
429 };
430 let mut out = Vec::new();
431 for rid in rids {
432 let Some(hit) = load_restaurant(conn, rid)? else {
433 continue;
434 };
435 if q.namespace
436 .as_deref()
437 .is_some_and(|ns| ns != hit.row.namespace)
438 {
439 continue;
440 }
441 let hit = match &q.near {
442 Some(n) => match localize(conn, hit, n)? {
443 Some(h) => h,
444 None => continue,
445 },
446 None => hit,
447 };
448 out.push(hit);
449 }
450 if text.is_none() && q.near.is_some() {
452 out.sort_by(|a, b| {
453 let da = a.row.distance_km.unwrap_or(f64::MAX);
454 let db = b.row.distance_km.unwrap_or(f64::MAX);
455 da.total_cmp(&db)
456 });
457 }
458 if let Some(l) = q.limit {
459 out.truncate(l);
460 }
461 Ok(out)
462}
463
464const ITEM_COLS: &str = "rid, id, restaurant_id, category_id, name, description, price_minor, \
465 discounted_price_minor, available, image_url, fetched_at";
466
467fn item_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<(ItemRow, i64)> {
468 Ok((
469 ItemRow {
470 id: r.get(1)?,
471 restaurant_id: r.get(2)?,
472 category_id: r.get(3)?,
473 name: r.get(4)?,
474 description: r.get(5)?,
475 price_minor: r.get(6)?,
476 discounted_price_minor: r.get(7)?,
477 available: r.get(8)?,
478 image_url: r.get(9)?,
479 raw: None,
480 },
481 r.get(10)?,
482 ))
483}
484
485pub(crate) fn search_items(conn: &Connection, q: &ItemQuery) -> Result<Vec<ItemHit>> {
486 let text = q.text.as_deref().map(str::trim).filter(|t| !t.is_empty());
487 let mut stmt_rid = conn.prepare_cached(&format!(
488 "SELECT {ITEM_COLS} FROM menu_items WHERE rid = ?1"
489 ))?;
490 let mut items: Vec<(ItemRow, i64)> = Vec::new();
491 match (text, &q.restaurant_id) {
492 (Some(t), _) => {
493 for rid in text_rids(conn, "menu_items", t)? {
494 if let Some(it) = stmt_rid.query_row([rid], item_from_row).optional()? {
495 items.push(it);
496 }
497 }
498 }
499 (None, Some(rest)) => {
500 let mut stmt = conn.prepare(&format!(
501 "SELECT {ITEM_COLS} FROM menu_items WHERE restaurant_id = ?1 ORDER BY rid"
502 ))?;
503 for it in stmt.query_map([rest], item_from_row)? {
504 items.push(it?);
505 }
506 }
507 (None, None) => {
508 let mut stmt = conn.prepare(&format!(
509 "SELECT {ITEM_COLS} FROM menu_items ORDER BY fetched_at DESC"
510 ))?;
511 for it in stmt.query_map([], item_from_row)? {
512 items.push(it?);
513 }
514 }
515 }
516 drop(stmt_rid);
517
518 let mut restaurants: HashMap<String, Option<RestaurantHit>> = HashMap::new();
519 let mut out = Vec::new();
520 for (item, fetched_at) in items {
521 if q.restaurant_id
522 .as_deref()
523 .is_some_and(|r| r != item.restaurant_id)
524 {
525 continue;
526 }
527 if q.available_only && item.available == Some(false) {
528 continue;
529 }
530 if let Some(max) = q.max_price_minor
531 && effective_price(&item).is_none_or(|p| p > max)
532 {
533 continue;
534 }
535 let rest = match restaurants.get(&item.restaurant_id) {
536 Some(r) => r.clone(),
537 None => {
538 let mut r = load_restaurant_by_id(conn, &item.restaurant_id)?;
539 if let Some(n) = &q.near {
540 r = match r {
541 Some(hit) => localize(conn, hit, n)?,
542 None => None,
543 };
544 }
545 restaurants.insert(item.restaurant_id.clone(), r.clone());
546 r
547 }
548 };
549 if q.near.is_some() && rest.is_none() {
550 continue;
551 }
552 out.push(ItemHit {
553 item,
554 restaurant: rest,
555 fetched_at,
556 });
557 if q.limit.is_some_and(|l| out.len() >= l) {
558 break;
559 }
560 }
561 Ok(out)
562}
563
564pub(crate) fn prune_older_than(conn: &Connection, cutoff: i64) -> Result<[u64; 4]> {
567 let mut out = [0u64; 4];
568 for (i, t) in [
569 "restaurants",
570 "restaurant_locations",
571 "menu_categories",
572 "menu_items",
573 ]
574 .iter()
575 .enumerate()
576 {
577 out[i] = conn.execute(&format!("DELETE FROM {t} WHERE fetched_at < ?1"), [cutoff])? as u64;
578 }
579 Ok(out)
580}
581
582impl SqliteCache {
583 pub fn index_restaurants_at(
587 &self,
588 rows: &[RestaurantRow],
589 seen_from: Option<(f64, f64)>,
590 now: i64,
591 ) -> Result<()> {
592 if rows.is_empty() {
593 return Ok(());
594 }
595 let mut conn = self.conn()?;
596 let tx = conn.transaction()?;
597 for r in rows.iter().filter(|r| !r.id.is_empty()) {
598 upsert_restaurant(&tx, r, now)?;
599 if let Some((lat, lon)) = seen_from {
600 upsert_sighting(&tx, r, lat, lon, now)?;
601 }
602 }
603 tx.commit()?;
604 Ok(())
605 }
606
607 pub fn index_items_at(&self, items: &[ItemRow], now: i64) -> Result<()> {
610 if items.is_empty() {
611 return Ok(());
612 }
613 let mut conn = self.conn()?;
614 let tx = conn.transaction()?;
615 for it in items.iter().filter(|i| !i.id.is_empty()) {
616 upsert_item(&tx, it, now)?;
617 }
618 tx.commit()?;
619 Ok(())
620 }
621
622 pub fn index_menu_at(
625 &self,
626 restaurant: &RestaurantRow,
627 categories: &[CategoryRow],
628 items: &[ItemRow],
629 seen_from: Option<(f64, f64)>,
630 now: i64,
631 ) -> Result<()> {
632 if restaurant.id.is_empty() {
633 return Ok(());
634 }
635 let mut conn = self.conn()?;
636 let tx = conn.transaction()?;
637 upsert_restaurant(&tx, restaurant, now)?;
638 if let Some((lat, lon)) = seen_from {
639 upsert_sighting(&tx, restaurant, lat, lon, now)?;
640 }
641 tx.execute(
642 "DELETE FROM menu_categories WHERE restaurant_id = ?1",
643 [&restaurant.id],
644 )?;
645 for c in categories {
646 tx.execute(
647 "INSERT OR REPLACE INTO menu_categories
648 (restaurant_id, id, name, sort, fetched_at, raw_json)
649 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
650 params![
651 restaurant.id,
652 c.id,
653 c.name,
654 c.sort,
655 now,
656 opt_json(c.raw.as_ref())
657 ],
658 )?;
659 }
660 for it in items.iter().filter(|i| !i.id.is_empty()) {
661 upsert_item(&tx, it, now)?;
662 }
663 tx.execute(
664 "DELETE FROM menu_items WHERE restaurant_id = ?1 AND fetched_at < ?2",
665 params![restaurant.id, now],
666 )?;
667 tx.commit()?;
668 Ok(())
669 }
670
671 pub fn search_restaurants(&self, q: &RestaurantQuery) -> Result<Vec<RestaurantHit>> {
673 search_restaurants(&*self.conn()?, q)
674 }
675
676 pub fn search_items(&self, q: &ItemQuery) -> Result<Vec<ItemHit>> {
678 search_items(&*self.conn()?, q)
679 }
680
681 pub fn restaurant(&self, id: &str) -> Result<Option<RestaurantHit>> {
683 load_restaurant_by_id(&*self.conn()?, id)
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use grabcli_core::Error;
691 use grabcli_core::cache::CacheConfig;
692 use serde_json::json;
693
694 fn cache(dir: &std::path::Path) -> SqliteCache {
695 SqliteCache::new(dir, CacheConfig::default())
696 }
697
698 fn rest(id: &str, name: &str, cuisines: &[&str]) -> RestaurantRow {
699 RestaurantRow {
700 id: id.into(),
701 namespace: "food".into(),
702 name: name.into(),
703 cuisines: cuisines.iter().map(ToString::to_string).collect(),
704 ..RestaurantRow::default()
705 }
706 }
707
708 fn item(id: &str, rest: &str, name: &str, price: i64) -> ItemRow {
709 ItemRow {
710 id: id.into(),
711 restaurant_id: rest.into(),
712 name: name.into(),
713 price_minor: Some(price),
714 available: Some(true),
715 ..ItemRow::default()
716 }
717 }
718
719 fn ids(h: &[RestaurantHit]) -> Vec<&str> {
720 h.iter().map(|h| h.row.id.as_str()).collect()
721 }
722
723 #[test]
724 fn upsert_keeps_known_fields_and_fts_follows_renames() -> Result<()> {
725 let dir = tempfile::tempdir()?;
726 let c = cache(dir.path());
727 let mut a = rest("A", "Café Kanto", &["Filipino", "Sisig"]);
728 a.rating = Some(4.5);
729 c.index_restaurants_at(&[a], None, 100)?;
730 c.index_restaurants_at(&[rest("A", "Cafe Kanto Branch", &[])], None, 200)?;
732 let hit = c.restaurant("A")?.ok_or_else(|| Error::not_found("A"))?;
733 assert_eq!(hit.row.name, "Cafe Kanto Branch");
734 assert_eq!(hit.row.cuisines, vec!["Filipino", "Sisig"]);
735 assert_eq!(hit.row.rating, Some(4.5));
736 assert_eq!(hit.fetched_at, 200);
737 let q = |t: &str| RestaurantQuery {
739 text: Some(t.into()),
740 ..RestaurantQuery::default()
741 };
742 assert_eq!(ids(&c.search_restaurants(&q("branch"))?), vec!["A"]);
743 assert_eq!(c.search_restaurants(&q("café"))?.len(), 1);
744 Ok(())
745 }
746
747 #[test]
748 fn fts_diacritics_prefix_and_substring() -> Result<()> {
749 let dir = tempfile::tempdir()?;
750 let c = cache(dir.path());
751 c.index_restaurants_at(
752 &[
753 rest("A", "Café Kanto", &["Filipino"]),
754 rest(
755 "B",
756 "Jollibee - Example Branch",
757 &["Fried Chicken", "Burger"],
758 ),
759 rest("C", "Kyu Kyu Ramen", &["Japanese", "Ramen"]),
760 ],
761 None,
762 100,
763 )?;
764 c.index_items_at(
765 &[
766 item("I1", "A", "Pork Sisig", 25_000),
767 item("I2", "A", "Tofu Sisig", 33_000),
768 item("I3", "C", "Tonkotsu Ramen", 39_000),
769 ItemRow {
770 description: Some("crispy pork with calamansi".into()),
771 ..item("I4", "B", "Chickenjoy", 15_000)
772 },
773 ],
774 100,
775 )?;
776 let r = |t: &str| -> Result<Vec<String>> {
777 Ok(c.search_restaurants(&RestaurantQuery {
778 text: Some(t.into()),
779 ..RestaurantQuery::default()
780 })?
781 .into_iter()
782 .map(|h| h.row.id)
783 .collect())
784 };
785 let i = |t: &str, max: Option<i64>| -> Result<Vec<String>> {
786 Ok(c.search_items(&ItemQuery {
787 text: Some(t.into()),
788 max_price_minor: max,
789 ..ItemQuery::default()
790 })?
791 .into_iter()
792 .map(|h| h.item.id)
793 .collect())
794 };
795 assert_eq!(r("cafe")?, vec!["A"]);
797 assert_eq!(r("CAFÉ")?, vec!["A"]);
798 assert_eq!(r("jolli")?, vec!["B"]);
800 assert_eq!(r("japanese")?, vec!["C"]);
801 assert_eq!(r("fried chicken")?, vec!["B"]);
802 assert!(r("fried ramen")?.is_empty());
803 assert_eq!(r("ibee")?, vec!["B"]);
805 assert!(r("\"* OR NOT (")?.is_empty());
807 assert_eq!(i("sisig", None)?.len(), 2);
809 assert_eq!(i("isig", None)?.len(), 2);
810 assert_eq!(i("sisig", Some(30_000))?, vec!["I1"]);
811 assert_eq!(i("calamansi", None)?, vec!["I4"]);
812 let hit = c.search_items(&ItemQuery {
813 text: Some("tonkotsu".into()),
814 ..ItemQuery::default()
815 })?;
816 assert_eq!(
817 hit[0].restaurant.as_ref().map(|r| r.row.name.as_str()),
818 Some("Kyu Kyu Ramen")
819 );
820 Ok(())
821 }
822
823 #[test]
824 fn near_uses_sightings_and_coordinates() -> Result<()> {
825 let dir = tempfile::tempdir()?;
826 let c = cache(dir.path());
827 let home = (1.2300, 103.8000);
828 let far_home = (1.3300, 103.7800); let mut seen = rest("SEEN", "Seen From Home", &[]);
830 seen.distance_km = Some(0.4);
831 seen.delivery_eta_min = Some(25);
832 seen.delivery_fee_minor = Some(4900);
833 c.index_restaurants_at(&[seen.clone()], Some(home), 100)?;
834 let mut seen_far = seen.clone();
836 seen_far.distance_km = Some(9.0);
837 seen_far.delivery_eta_min = Some(60);
838 c.index_restaurants_at(&[seen_far], Some(far_home), 110)?;
839 let mut coords = rest("COORDS", "Has Coordinates", &[]);
841 coords.lat = Some(1.2400);
842 coords.lon = Some(103.8000);
843 let mut other = rest("OTHER", "Other Side", &[]);
845 other.distance_km = Some(0.5);
846 c.index_restaurants_at(&[coords], None, 100)?;
847 c.index_restaurants_at(&[other], Some(far_home), 100)?;
848
849 let near = |km: f64| RestaurantQuery {
850 near: Some(Near {
851 lat: home.0 + 0.0003, lon: home.1,
853 radius_km: km,
854 }),
855 ..RestaurantQuery::default()
856 };
857 let hits = c.search_restaurants(&near(6.0))?;
858 assert_eq!(ids(&hits), vec!["SEEN", "COORDS"]);
859 assert_eq!(hits[0].row.distance_km, Some(0.4));
861 assert_eq!(hits[0].row.delivery_eta_min, Some(25));
862 assert_eq!(hits[0].row.delivery_fee_minor, Some(4900));
863 let d = hits[1].row.distance_km.unwrap_or(0.0);
864 assert!((1.0..1.3).contains(&d), "{d}");
865 assert_eq!(hits[1].row.delivery_eta_min, None);
866 assert_eq!(ids(&c.search_restaurants(&near(1.0))?), vec!["SEEN"]);
868
869 c.index_items_at(
871 &[
872 item("I1", "SEEN", "Sisig", 20_000),
873 item("I2", "OTHER", "Sisig", 10_000),
874 ],
875 100,
876 )?;
877 let items = c.search_items(&ItemQuery {
878 text: Some("sisig".into()),
879 near: near(6.0).near,
880 ..ItemQuery::default()
881 })?;
882 assert_eq!(items.len(), 1);
883 assert_eq!(items[0].item.id, "I1");
884 Ok(())
885 }
886
887 #[test]
888 fn menu_replaces_categories_and_removed_items() -> Result<()> {
889 let dir = tempfile::tempdir()?;
890 let c = cache(dir.path());
891 let r = rest("R", "Resto", &[]);
892 let cat = |id: &str| CategoryRow {
893 restaurant_id: "R".into(),
894 id: id.into(),
895 name: id.into(),
896 sort: 0,
897 raw: Some(json!({})),
898 };
899 c.index_menu_at(
900 &r,
901 &[cat("C1"), cat("C2")],
902 &[
903 item("I1", "R", "Old Dish", 100),
904 item("I2", "R", "Kept", 100),
905 ],
906 None,
907 100,
908 )?;
909 let kept = ItemRow {
910 category_id: Some("C1".into()),
911 ..item("I2", "R", "Kept", 200)
912 };
913 c.index_menu_at(&r, &[cat("C1")], &[kept], None, 200)?;
914 let s = c.stats_at(200)?;
915 assert_eq!(s.tables["menu_categories"], 1);
916 assert_eq!(s.tables["menu_items"], 1);
917 let all = c.search_items(&ItemQuery {
918 restaurant_id: Some("R".into()),
919 ..ItemQuery::default()
920 })?;
921 assert_eq!(all[0].item.price_minor, Some(200));
922 let gone = c.search_items(&ItemQuery {
924 text: Some("old".into()),
925 ..ItemQuery::default()
926 })?;
927 assert!(gone.is_empty());
928 c.index_items_at(&[item("I2", "R", "Kept", 150)], 300)?;
930 let conn = c.conn()?;
931 let cat: Option<String> = conn.query_row(
932 "SELECT category_id FROM menu_items WHERE id = 'I2'",
933 [],
934 |r| r.get(0),
935 )?;
936 assert_eq!(cat.as_deref(), Some("C1"));
937 Ok(())
938 }
939
940 #[test]
941 fn prune_drops_old_catalog_rows() -> Result<()> {
942 let dir = tempfile::tempdir()?;
943 let c = cache(dir.path());
944 c.index_restaurants_at(&[rest("OLD", "Old", &[])], Some((1.0, 1.0)), 100)?;
945 c.index_restaurants_at(&[rest("NEW", "New", &[])], Some((1.0, 1.0)), 10_000_000)?;
946 c.index_items_at(&[item("I", "OLD", "x", 1)], 100)?;
947 let max_age = i64::try_from(c.config.index_max_age_secs()).unwrap_or(0);
948 let r = c.prune_at(100 + max_age + 1)?;
949 assert_eq!(
950 (r.restaurants, r.restaurant_locations, r.menu_items),
951 (1, 1, 1)
952 );
953 assert!(c.restaurant("OLD")?.is_none());
954 assert!(c.restaurant("NEW")?.is_some());
955 Ok(())
956 }
957
958 #[test]
959 fn helpers() {
960 assert_eq!(bucket(1.23049, 103.8), "1.230,103.800");
961 assert_eq!(fts_trigram_query(&["ab".into()]), None);
962 let d = haversine_km(1.23, 103.8, 1.24, 103.8);
963 assert!((d - 1.112).abs() < 0.01);
964 }
965}