Skip to main content

grabcli_core/cache/
catalog.rs

1//! The normalized catalog: restaurants/stores, where they were seen from,
2//! menu categories and menu items, with FTS5 indexes for offline search.
3//!
4//! Service crates map their upstream JSON into these rows and upsert them
5//! whenever they fetch list or menu data (see `grabfood::index`). Upserts
6//! never erase known data with unknowns: a field the new row doesn't carry
7//! (e.g. cuisines in a "recommended" response) keeps its stored value.
8//!
9//! Text search uses two external-content FTS5 tables per indexed table:
10//! `unicode61 remove_diacritics 2` (whole words and prefixes; "cafe" finds
11//! "Café") and `trigram remove_diacritics 1` (substrings of ≥ 3 characters;
12//! "isig" finds "Sisig"). Word matches rank first (bm25), then trigram-only
13//! matches.
14//!
15//! "Near" queries use a bounding box on indexed columns: restaurants whose
16//! coordinates are known (from menus), or that were *seen from* a delivery
17//! location near the point (`restaurant_locations`), then an exact haversine
18//! filter. Distance, ETA and fee come from the nearest sighting when there is
19//! one, since they depend on the delivery location.
20
21use 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
31/// Catalog tables (for stats/clear).
32pub(crate) const TABLES: [&str; 4] = [
33    "restaurants",
34    "restaurant_locations",
35    "menu_categories",
36    "menu_items",
37];
38
39/// Decimals of the location bucket in `restaurant_locations` (~110 m).
40const BUCKET_DECIMALS: usize = 3;
41
42/// A sighting within this distance of the query point counts as "seen from
43/// this address".
44const SIGHTING_RADIUS_KM: f64 = 1.0;
45
46/// A restaurant or store, as seen in one response.
47#[derive(Debug, Clone, Default, PartialEq, Serialize)]
48pub struct RestaurantRow {
49    pub id: String,
50    /// Service namespace (`"food"`, `"mart"`).
51    pub namespace: String,
52    pub name: String,
53    pub chain_name: Option<String>,
54    pub branch_name: Option<String>,
55    /// Upstream `businessType` (`FOOD` / `MART`).
56    pub business_type: Option<String>,
57    pub cuisines: Vec<String>,
58    pub rating: Option<f64>,
59    pub vote_count: Option<i64>,
60    /// 1–3 (Grab's `priceTag`).
61    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    // Location-dependent: also stored per sighting.
70    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/// A menu category / department.
79#[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/// A menu item (from a full menu, or a dish hit in search results).
90#[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/// A point and radius for "near" queries.
106#[derive(Debug, Clone, Copy, PartialEq)]
107pub struct Near {
108    pub lat: f64,
109    pub lon: f64,
110    pub radius_km: f64,
111}
112
113/// A restaurant query. All fields are optional filters.
114#[derive(Debug, Clone, Default)]
115pub struct RestaurantQuery {
116    /// Free text matched against name and cuisines.
117    pub text: Option<String>,
118    pub near: Option<Near>,
119    pub namespace: Option<String>,
120    pub limit: Option<usize>,
121}
122
123/// An item query.
124#[derive(Debug, Clone, Default)]
125pub struct ItemQuery {
126    /// Free text matched against item name and description.
127    pub text: Option<String>,
128    pub near: Option<Near>,
129    /// Effective (discounted if any) price at most this, in minor units.
130    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/// A restaurant from the catalog. Location-dependent fields come from the
137/// sighting nearest the query point when there is one.
138#[derive(Debug, Clone, Default, PartialEq, Serialize)]
139pub struct RestaurantHit {
140    #[serde(flatten)]
141    pub row: RestaurantRow,
142    /// When this row (or the sighting used) was last fetched.
143    pub fetched_at: i64,
144}
145
146/// An item from the catalog, with its restaurant.
147#[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
176/// Great-circle distance in km.
177pub 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
186/// `(min_lat, max_lat, min_lon, max_lon)` around a point.
187fn 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
193/// Words of a free-text query (letters/digits only; FTS syntax is never
194/// passed through).
195fn 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
202/// Word-index query: every term as a prefix.
203fn fts_word_query(terms: &[String]) -> String {
204    terms
205        .iter()
206        .map(|t| format!("\"{t}\"*"))
207        .collect::<Vec<_>>()
208        .join(" AND ")
209}
210
211/// Trigram query: every term as a substring. Needs every term ≥ 3 chars.
212fn 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
222/// Row ids matching `text` in `<table>_fts` (ranked) then `<table>_tri`.
223fn 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/// Location-dependent fields of the sighting nearest `near`, if one is
427/// within [`SIGHTING_RADIUS_KM`].
428#[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
469/// Apply `near` to a hit: use the nearest sighting's distance/ETA/fee, or the
470/// haversine distance from known coordinates. `None` if it's not near.
471fn 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            // Seen from elsewhere: ETA/fee from another address don't apply.
493            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
503/// Restaurant row ids inside the bounding box of `near` (by coordinates or by
504/// a sighting from near the point).
505fn 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    // Without text there's no relevance order: nearest first.
575    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
696/// Delete catalog rows last fetched before `cutoff`. Returns counts for
697/// restaurants, restaurant_locations, menu_categories, menu_items.
698pub(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    /// Upsert restaurants seen in a list response. `seen_from` is the
716    /// delivery location of the request; when given, each row's distance /
717    /// ETA / fee are also recorded for that location. No-op when writes are
718    /// off.
719    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    /// Upsert individual items (e.g. dish hits in search results), keeping
749    /// any category already known. No-op when writes are off.
750    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    /// Store a full menu: the restaurant, its categories (replacing the old
768    /// ones) and items; items no longer on the menu are removed. No-op when
769    /// writes are off.
770    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    /// Query restaurants in the catalog (works in every mode: it only reads
828    /// what earlier fetches stored).
829    pub fn search_restaurants(&self, q: &RestaurantQuery) -> Result<Vec<RestaurantHit>> {
830        search_restaurants(&*self.conn()?, q)
831    }
832
833    /// Query menu items in the catalog.
834    pub fn search_items(&self, q: &ItemQuery) -> Result<Vec<ItemHit>> {
835        search_items(&*self.conn()?, q)
836    }
837
838    /// One restaurant by id.
839    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        // A sparser row (no cuisines/rating) doesn't erase them.
887        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        // The FTS index follows the rename: old-only words no longer match.
896        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        // Diacritics both ways.
954        assert_eq!(r("cafe")?, vec!["A"]);
955        assert_eq!(r("CAFÉ")?, vec!["A"]);
956        // Prefix, cuisine, multi-word AND.
957        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        // Substring via trigram ("ibee" is inside "Jollibee").
962        assert_eq!(r("ibee")?, vec!["B"]);
963        // FTS syntax in user input is neutralized.
964        assert!(r("\"* OR NOT (")?.is_empty());
965        // Items: name, description, partial, price filter.
966        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); // ~11 km away
987        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        // Same restaurant seen from elsewhere with other numbers.
993        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        // Known coordinates ~1.1 km away, never seen from home.
998        let mut coords = rest("COORDS", "Has Coordinates", &[]);
999        coords.lat = Some(1.2400);
1000        coords.lon = Some(103.8000);
1001        // Only seen from the far address.
1002        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, // a few metres off: same address
1010                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        // Home's sighting numbers, not the far address's.
1018        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        // Radius filter.
1025        assert_eq!(ids(&c.search_restaurants(&near(1.0))?), vec!["SEEN"]);
1026
1027        // Item near filter follows its restaurant.
1028        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        // The removed item is gone from the FTS index too.
1081        let gone = c.search_items(&ItemQuery {
1082            text: Some("old".into()),
1083            ..ItemQuery::default()
1084        })?;
1085        assert!(gone.is_empty());
1086        // Dish hits keep the category the menu assigned.
1087        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}