Skip to main content

sci_cream/
database.rs

1//! In-memory database for ingredient definition lookups
2//!
3//! If feature `database` is enabled, this module provides [`IngredientDatabase`], an in-memory
4//! database, with WASM support, for looking up [`Ingredient`] definitions. [`IngredientDatabase`]
5//! objects can be seeded from [`Ingredient`]s and ingredient specifications, including those
6//! embedded via the `data` feature; see [`crate::data`] for more information.
7
8use std::collections::HashMap;
9use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
10
11#[cfg(feature = "wasm")]
12use wasm_bindgen::prelude::*;
13
14use crate::{
15    error::{Error, Result},
16    ingredient::{Category, Ingredient},
17    specs::IngredientSpec,
18};
19
20#[cfg(doc)]
21use crate::composition::Composition;
22
23/// Provides an in-memory database for looking up ingredient definitions by name
24///
25/// Since ingredient objects are lightweight, in most use cases keeping many or all of them in
26/// memory should not be an issue. Holding them in this [`IngredientDatabase`] greatly simplifies
27/// the setup and process of looking up ingredient definitions, obviating the need for lookups from
28/// an external database. It should also provide performance improvements.
29///
30/// Lastly, and the primary motivation behind this class, it's supported in WASM, where it can
31/// provide significant performance improvements if ingredient lookup is done on the WASM side,
32/// compared to managing ingredient definitions and lookup on the JS side and bridging them to WASM
33/// when requesting operations; JS <-> WASM bridging is very slow, so it's almost always more
34/// performant to keep as much as possible on the WASM side. It's still possible to seed the
35/// database from the JS side, then subsequent looks can be done within WASM.
36#[cfg_attr(feature = "wasm", wasm_bindgen)]
37#[derive(Debug)]
38pub struct IngredientDatabase {
39    map: RwLock<HashMap<String, Ingredient>>,
40}
41
42impl IngredientDatabase {
43    /// Creates a new [`IngredientDatabase`] seeded with the provided [`Ingredient`]s.
44    #[must_use]
45    pub fn new_seeded(ingredients: &[Ingredient]) -> Self {
46        let mut map = HashMap::new();
47        for ingredient in ingredients {
48            // @todo Consider error handling for duplicate names
49            let _unused = map.insert(ingredient.name.clone(), ingredient.clone());
50        }
51
52        Self { map: RwLock::new(map) }
53    }
54
55    fn specs_into_ingredients(specs: &[IngredientSpec]) -> Result<Vec<Ingredient>> {
56        specs
57            .iter()
58            .map(|spec| spec.clone().into_ingredient())
59            .collect::<Result<_>>()
60    }
61
62    /// Creates a new [`IngredientDatabase`] seeded with the provided [`IngredientSpec`]s
63    ///
64    /// # Errors
65    ///
66    /// Returns an [`Error`] if any of the provided specs cannot be converted into an
67    /// [`Ingredient`]. This would likely be an error converting a [`spec`](crate::specs) into a
68    /// [`Composition`] due to invalid values, e.g. negative percentages, not summing to 100%, etc.
69    pub fn new_seeded_from_specs(specs: &[IngredientSpec]) -> Result<Self> {
70        Ok(Self::new_seeded(&Self::specs_into_ingredients(specs)?))
71    }
72
73    /// Creates a new [`IngredientDatabase`] seeded with all embedded ingredient specifications.
74    ///
75    /// This function requires the `data` feature to be enabled.
76    #[allow(clippy::missing_panics_doc)] // If this panics it's a bug, not a user-facing error
77    #[cfg(feature = "data")]
78    #[must_use]
79    pub fn new_seeded_from_embedded_data() -> Self {
80        Self::new_seeded_from_specs(&crate::data::get_all_ingredient_specs())
81            .expect("Embedded ingredients specs should all `into_ingredient` successfully")
82    }
83
84    fn acquire_read_lock(&self) -> RwLockReadGuard<'_, HashMap<String, Ingredient>> {
85        self.map
86            .read()
87            .expect("Read lock on the ingredient database should be acquired successfully")
88    }
89
90    fn acquire_write_lock(&self) -> RwLockWriteGuard<'_, HashMap<String, Ingredient>> {
91        self.map
92            .write()
93            .expect("Write lock on the ingredient database should be acquired successfully")
94    }
95
96    /// Seeds the database with the provided [`Ingredient`]s.
97    pub fn seed(&self, ingredients: &[Ingredient]) {
98        let mut db = self.acquire_write_lock();
99        for ingredient in ingredients {
100            let _unused = db.insert(ingredient.name.clone(), ingredient.clone());
101        }
102    }
103
104    /// Seeds the database with the provided [`IngredientSpec`]s.
105    ///
106    /// # Errors
107    ///
108    /// Returns an [`Error`] if any of the provided specs cannot be converted into an
109    /// [`Ingredient`]. This would likely be an error converting a [`spec`](crate::specs) into a
110    /// [`Composition`] due to invalid values, e.g. negative percentages, not summing to 100%, etc.
111    pub fn seed_from_specs(&self, specs: &[IngredientSpec]) -> Result<()> {
112        self.seed(&Self::specs_into_ingredients(specs)?);
113        Ok(())
114    }
115
116    /// Retrieves an [`Ingredient`] by its name.
117    ///
118    /// # Errors
119    ///
120    /// Returns an [`Error::IngredientNotFound`] if no ingredient with the specified name is found.
121    pub fn get_ingredient_by_name(&self, name: &str) -> Result<Ingredient> {
122        self.acquire_read_lock()
123            .get(name)
124            .cloned()
125            .ok_or_else(|| Error::IngredientNotFound(name.to_string()))
126    }
127}
128
129#[cfg_attr(feature = "wasm", wasm_bindgen)]
130impl IngredientDatabase {
131    /// Creates a new, empty [`IngredientDatabase`].
132    #[cfg_attr(feature = "wasm", wasm_bindgen(constructor))]
133    #[must_use]
134    pub fn new() -> Self {
135        Self {
136            map: RwLock::new(HashMap::new()),
137        }
138    }
139
140    /// Retrieves all [`Ingredient`]s in the database.
141    pub fn get_all_ingredients(&self) -> Vec<Ingredient> {
142        self.acquire_read_lock().values().cloned().collect()
143    }
144
145    /// Retrieves [`Ingredient`]s filtered by the specified [`Category`].
146    pub fn get_ingredients_by_category(&self, category: Category) -> Vec<Ingredient> {
147        self.acquire_read_lock()
148            .values()
149            .filter(|ingredient| ingredient.category == category)
150            .cloned()
151            .collect()
152    }
153}
154
155impl Default for IngredientDatabase {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161/// WASM compatible wrappers for [`crate::database`] functions and [`IngredientDatabase`] methods.
162#[cfg(feature = "wasm")]
163#[cfg_attr(coverage, coverage(off))]
164pub mod wasm {
165    use wasm_bindgen::prelude::*;
166
167    use super::IngredientDatabase;
168
169    use crate::{ingredient::Ingredient, specs::IngredientSpec};
170
171    #[cfg(doc)]
172    use crate::error::Error;
173
174    fn specs_from_jsvalues(specs: &[JsValue]) -> Result<Vec<IngredientSpec>, JsValue> {
175        specs
176            .iter()
177            .map(|spec| serde_wasm_bindgen::from_value::<IngredientSpec>(spec.clone()).map_err(Into::into))
178            .collect::<Result<_, JsValue>>()
179    }
180
181    #[wasm_bindgen]
182    impl IngredientDatabase {
183        /// WASM compatible wrapper for [`IngredientDatabase::seed`]
184        #[wasm_bindgen(js_name = "seed")]
185        #[allow(clippy::needless_pass_by_value)]
186        pub fn seed_wasm(&self, ingredients: Box<[Ingredient]>) {
187            self.seed(&ingredients);
188        }
189
190        /// WASM compatible wrapper for [`IngredientDatabase::seed_from_specs`]
191        ///
192        /// # Errors
193        ///
194        /// Returns an [`Error`] if any of the specs cannot be converted into an [`Ingredient`]; see
195        /// the forwarded-to method for more details. It may also return a `serde::Error` if the
196        /// provided JS values cannot be deserialized into [`IngredientSpec`]s.
197        #[wasm_bindgen(js_name = "seed_from_specs")]
198        #[allow(clippy::needless_pass_by_value)]
199        pub fn seed_from_specs_wasm(&self, specs: Box<[JsValue]>) -> Result<(), JsValue> {
200            self.seed_from_specs(&specs_from_jsvalues(&specs)?).map_err(Into::into)
201        }
202
203        /// WASM compatible wrapper for [`IngredientDatabase::get_ingredient_by_name`]
204        ///
205        /// # Errors
206        ///
207        /// Returns an [`Error::IngredientNotFound`] if no ingredient with the name is found.
208        #[wasm_bindgen(js_name = "get_ingredient_by_name")]
209        pub fn get_ingredient_by_name_wasm(&self, name: &str) -> Result<Ingredient, JsValue> {
210            self.get_ingredient_by_name(name).map_err(Into::into)
211        }
212    }
213
214    /// WASM compatible builder forwarding to [`IngredientDatabase::new_seeded`].
215    #[wasm_bindgen]
216    #[allow(clippy::needless_pass_by_value)]
217    #[must_use]
218    pub fn new_ingredient_database_seeded(ingredients: Box<[Ingredient]>) -> IngredientDatabase {
219        IngredientDatabase::new_seeded(&ingredients)
220    }
221
222    /// WASM compatible builder forwarding to [`IngredientDatabase::new_seeded_from_specs`].
223    ///
224    /// # Errors
225    ///
226    /// Returns an [`Error`] if any of the specs cannot be converted into an [`Ingredient`]; see
227    /// the forwarded-to method for more details. It may also return a `serde::Error` if the
228    /// provided JS values cannot be deserialized into [`IngredientSpec`]s.
229    #[wasm_bindgen]
230    #[allow(clippy::needless_pass_by_value)]
231    pub fn new_ingredient_database_seeded_from_specs(specs: Box<[JsValue]>) -> Result<IngredientDatabase, JsValue> {
232        IngredientDatabase::new_seeded_from_specs(&specs_from_jsvalues(&specs)?).map_err(Into::into)
233    }
234
235    /// WASM compatible builder forwarding to [`IngredientDatabase::new_seeded_from_embedded_data`].
236    ///
237    /// This function requires the `data` feature to be enabled.
238    #[cfg(feature = "data")]
239    #[wasm_bindgen]
240    #[must_use]
241    pub fn new_ingredient_database_seeded_from_embedded_data() -> IngredientDatabase {
242        IngredientDatabase::new_seeded_from_embedded_data()
243    }
244}
245
246#[cfg(test)]
247#[cfg_attr(coverage, coverage(off))]
248#[allow(clippy::unwrap_used)]
249pub(crate) mod tests {
250    use strum::IntoEnumIterator;
251
252    use crate::tests::asserts::shadow_asserts::assert_eq;
253    use crate::tests::asserts::*;
254
255    use super::*;
256    use crate::data::get_all_ingredient_specs;
257
258    #[test]
259    fn ingredient_database_empty() {
260        let db = IngredientDatabase::new();
261        assert_eq!(db.get_all_ingredients().len(), 0);
262    }
263
264    #[test]
265    fn ingredient_database_new_seeded() {
266        let db = IngredientDatabase::new_seeded(
267            &get_all_ingredient_specs()
268                .iter()
269                .map(|spec| spec.clone().into_ingredient().unwrap())
270                .collect::<Vec<Ingredient>>(),
271        );
272
273        assert_eq!(db.get_all_ingredients().len(), get_all_ingredient_specs().len());
274
275        for spec in get_all_ingredient_specs() {
276            let ingredient = db.get_ingredient_by_name(&spec.name).unwrap();
277            assert_eq!(ingredient, spec.into_ingredient().unwrap());
278        }
279    }
280
281    #[test]
282    fn ingredient_database_new_seeded_from_specs() {
283        let db = IngredientDatabase::new_seeded_from_specs(&get_all_ingredient_specs()).unwrap();
284
285        assert_eq!(db.get_all_ingredients().len(), get_all_ingredient_specs().len());
286
287        for spec in get_all_ingredient_specs() {
288            let ingredient = db.get_ingredient_by_name(&spec.name).unwrap();
289            assert_eq!(ingredient, spec.into_ingredient().unwrap());
290        }
291    }
292
293    #[test]
294    fn ingredient_database_seed() {
295        let db = IngredientDatabase::new();
296        assert_eq!(db.get_all_ingredients().len(), 0);
297
298        let ingredients = get_all_ingredient_specs()[..10]
299            .iter()
300            .map(|spec| spec.clone().into_ingredient().unwrap())
301            .collect::<Vec<Ingredient>>();
302
303        db.seed(&ingredients);
304        assert_eq!(db.get_all_ingredients().len(), 10);
305
306        for ingredient in ingredients {
307            let fetched_ingredient = db.get_ingredient_by_name(&ingredient.name).unwrap();
308            assert_eq!(fetched_ingredient, ingredient);
309        }
310    }
311
312    #[test]
313    fn ingredient_database_seed_from_specs() {
314        let db = IngredientDatabase::new();
315        assert_eq!(db.get_all_ingredients().len(), 0);
316
317        let specs = get_all_ingredient_specs()[..10].to_vec();
318
319        db.seed_from_specs(&specs).unwrap();
320        assert_eq!(db.get_all_ingredients().len(), 10);
321
322        for spec in specs {
323            let fetched_ingredient = db.get_ingredient_by_name(&spec.name).unwrap();
324            let ingredient = spec.into_ingredient().unwrap();
325            assert_eq!(fetched_ingredient, ingredient);
326        }
327    }
328
329    #[test]
330    fn ingredient_database_seeded_from_embedded_data() {
331        let db = IngredientDatabase::new_seeded_from_embedded_data();
332
333        assert_eq!(db.get_all_ingredients().len(), get_all_ingredient_specs().len());
334
335        for spec in get_all_ingredient_specs() {
336            let ingredient = db.get_ingredient_by_name(&spec.name).unwrap();
337            assert_eq!(ingredient, spec.into_ingredient().unwrap());
338        }
339    }
340
341    #[test]
342    fn ingredient_database_get_ingredients_by_category() {
343        let db = IngredientDatabase::new_seeded_from_specs(&get_all_ingredient_specs()).unwrap();
344
345        for category in Category::iter() {
346            let ingredients = db.get_ingredients_by_category(category);
347            assert_false!(ingredients.is_empty());
348
349            for ingredient in ingredients {
350                assert_eq!(ingredient.category, category);
351            }
352        }
353    }
354
355    #[test]
356    fn ingredient_database_get_ingredient_by_name_not_found() {
357        let db = IngredientDatabase::new();
358        let result = db.get_ingredient_by_name("non_existent_ingredient");
359        assert!(matches!(result, Err(Error::IngredientNotFound(_))));
360    }
361
362    #[test]
363    fn ingredient_database_thread_safety() {
364        use std::sync::Arc;
365        use std::thread;
366
367        let db = Arc::new(IngredientDatabase::new_seeded_from_specs(&get_all_ingredient_specs()).unwrap());
368
369        let mut handles = vec![];
370
371        for _ in 0..10 {
372            let db_clone = Arc::clone(&db);
373            let handle = thread::spawn(move || {
374                for spec in get_all_ingredient_specs() {
375                    let ingredient = db_clone.get_ingredient_by_name(&spec.name).unwrap();
376                    assert_eq!(ingredient, spec.into_ingredient().unwrap());
377                }
378            });
379            handles.push(handle);
380        }
381
382        for handle in handles {
383            handle.join().unwrap();
384        }
385    }
386}