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