Skip to main content

grabcli_sqlite/
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_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
35/// Catalog tables (for stats/clear).
36pub(crate) const TABLES: [&str; 4] = [
37    "restaurants",
38    "restaurant_locations",
39    "menu_categories",
40    "menu_items",
41];
42
43/// Decimals of the location bucket in `restaurant_locations` (~110 m).
44const BUCKET_DECIMALS: usize = 3;
45
46/// A sighting within this distance of the query point counts as "seen from
47/// this address".
48const 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
71/// `(min_lat, max_lat, min_lon, max_lon)` around a point.
72fn 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
78/// Word-index query: every term as a prefix.
79fn fts_word_query(terms: &[String]) -> String {
80    terms
81        .iter()
82        .map(|t| format!("\"{t}\"*"))
83        .collect::<Vec<_>>()
84        .join(" AND ")
85}
86
87/// Trigram query: every term as a substring. Needs every term ≥ 3 chars.
88fn 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
98/// Row ids matching `text` in `<table>_fts` (ranked) then `<table>_tri`.
99fn 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/// Location-dependent fields of the sighting nearest `near`, if one is
303/// within [`SIGHTING_RADIUS_KM`].
304#[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
345/// Apply `near` to a hit: use the nearest sighting's distance/ETA/fee, or the
346/// haversine distance from known coordinates. `None` if it's not near.
347fn 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            // Seen from elsewhere: ETA/fee from another address don't apply.
369            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
379/// Restaurant row ids inside the bounding box of `near` (by coordinates or by
380/// a sighting from near the point).
381fn 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    // Without text there's no relevance order: nearest first.
451    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
564/// Delete catalog rows last fetched before `cutoff`. Returns counts for
565/// restaurants, restaurant_locations, menu_categories, menu_items.
566pub(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    /// Upsert restaurants seen in a list response. `seen_from` is the
584    /// delivery location of the request; when given, each row's distance /
585    /// ETA / fee are also recorded for that location.
586    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    /// Upsert individual items (e.g. dish hits in search results), keeping
608    /// any category already known.
609    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    /// Store a full menu: the restaurant, its categories (replacing the old
623    /// ones) and items; items no longer on the menu are removed.
624    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    /// Query restaurants in the catalog.
672    pub fn search_restaurants(&self, q: &RestaurantQuery) -> Result<Vec<RestaurantHit>> {
673        search_restaurants(&*self.conn()?, q)
674    }
675
676    /// Query menu items in the catalog.
677    pub fn search_items(&self, q: &ItemQuery) -> Result<Vec<ItemHit>> {
678        search_items(&*self.conn()?, q)
679    }
680
681    /// One restaurant by id.
682    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        // A sparser row (no cuisines/rating) doesn't erase them.
731        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        // The FTS index follows the rename: old-only words no longer match.
738        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        // Diacritics both ways.
796        assert_eq!(r("cafe")?, vec!["A"]);
797        assert_eq!(r("CAFÉ")?, vec!["A"]);
798        // Prefix, cuisine, multi-word AND.
799        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        // Substring via trigram ("ibee" is inside "Jollibee").
804        assert_eq!(r("ibee")?, vec!["B"]);
805        // FTS syntax in user input is neutralized.
806        assert!(r("\"* OR NOT (")?.is_empty());
807        // Items: name, description, partial, price filter.
808        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); // ~11 km away
829        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        // Same restaurant seen from elsewhere with other numbers.
835        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        // Known coordinates ~1.1 km away, never seen from home.
840        let mut coords = rest("COORDS", "Has Coordinates", &[]);
841        coords.lat = Some(1.2400);
842        coords.lon = Some(103.8000);
843        // Only seen from the far address.
844        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, // a few metres off: same address
852                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        // Home's sighting numbers, not the far address's.
860        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        // Radius filter.
867        assert_eq!(ids(&c.search_restaurants(&near(1.0))?), vec!["SEEN"]);
868
869        // Item near filter follows its restaurant.
870        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        // The removed item is gone from the FTS index too.
923        let gone = c.search_items(&ItemQuery {
924            text: Some("old".into()),
925            ..ItemQuery::default()
926        })?;
927        assert!(gone.is_empty());
928        // Dish hits keep the category the menu assigned.
929        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}