diff --git a/Cargo.lock b/Cargo.lock index 844041c..51151de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,11 +1,172 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +[[package]] +name = "abortable_parser" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2febc32aeed847847255580d5938d62c8e3f6cb0a019cc0d36dc1405827d4ec" + +[[package]] +name = "ahash" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "739f4a8db6605981345c5654f3a85b056ce52f37a39d34da03f25bf2151ea16e" + [[package]] name = "autocfg" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +[[package]] +name = "bindgen" +version = "0.58.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f8523b410d7187a43085e7e064416ea32ded16bd0a4e6fc025e21616d01258f" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", +] + +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + +[[package]] +name = "cc" +version = "1.0.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c69b077ad434294d3ce9f1f6143a2a4b89a8a2d54ef813d85003a4fd1137fd" + +[[package]] +name = "cexpr" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4aedb84272dbe89af497cf81375129abda4fc0a9e7c5d317498c15cc30c0d27" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clang-sys" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "853eda514c284c2287f4bf20ae614f8781f40a81d32ecda6e91449304dfe077c" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "glob" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" + +[[package]] +name = "hashbrown" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99cf782f0dc4372d26846bec3de7804ceb5df083c2d4462c0b8d2330e894fa8" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9385f66bf6105b241aa65a61cb923ef20efc665cb9f9bb50ac2f0c4b7f378d41" + +[[package]] +name = "libloading" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f84d96438c15fcd6c3f244c8fce01d1e2b9c6b5623e9c711dc9286d8fc92d6a" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19cb1effde5f834799ac5e5ef0e40d45027cd74f271b1de786ba8abb30e2164d" +dependencies = [ + "bindgen", + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "memchr" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" + +[[package]] +name = "nom" +version = "5.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af" +dependencies = [ + "memchr", + "version_check", +] + [[package]] name = "num-bigint" version = "0.4.0" @@ -48,9 +209,136 @@ dependencies = [ "autocfg", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "pkg-config" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" + +[[package]] +name = "proc-macro2" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a152013215dca273577e18d2bf00fa862b89b24169fb78c4c95aeb07992c9cec" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "recipe-store" +version = "0.1.0" +dependencies = [ + "recipes", + "rusqlite", +] + [[package]] name = "recipes" version = "0.1.0" dependencies = [ + "abortable_parser", "num-rational", ] + +[[package]] +name = "regex" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957056ecddbeba1b26965114e191d2e8589ce74db242b6ea25fc4062427a5c19" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5f089152e60f62d28b835fbff2cd2e8dc0baf1ac13343bef92ab7eed84548" + +[[package]] +name = "rusqlite" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc783b7ddae608338003bac1fa00b6786a75a9675fbd8e87243ecfdea3f6ed2" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "memchr", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "shlex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a568c8f2cd051a4d283bd6eb0343ac214c1b0f1ac19f93e1175b2dee38c73d" + +[[package]] +name = "smallvec" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e" + +[[package]] +name = "unicode-xid" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" + +[[package]] +name = "vcpkg" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbdbff6266a24120518560b5dc983096efb98462e51d0d68169895b237be3e5d" + +[[package]] +name = "version_check" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/Cargo.toml b/Cargo.toml index 54b4115..738925d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,2 @@ [workspace] -members = [ "recipes" ] \ No newline at end of file +members = [ "recipes", "recipe-store" ] \ No newline at end of file diff --git a/recipe-store/Cargo.toml b/recipe-store/Cargo.toml new file mode 100644 index 0000000..19c84a1 --- /dev/null +++ b/recipe-store/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "recipe-store" +version = "0.1.0" +authors = ["Jeremy Wall "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +recipes = { path="../recipes" } + +[dependencies.rusqlite] +version = "~0.25.0" +features = ["backup", "bundled", "session"] \ No newline at end of file diff --git a/recipe-store/src/lib.rs b/recipe-store/src/lib.rs new file mode 100644 index 0000000..00cb372 --- /dev/null +++ b/recipe-store/src/lib.rs @@ -0,0 +1,282 @@ +// Copyright 2021 Jeremy Wall +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +/// Storage backend for recipes. +use std::convert::From; +use std::path::Path; + +use rusqlite::{params, Connection, DropBehavior, Result as SqliteResult, Transaction}; + +use recipes::{unit::Measure, Ingredient, Recipe, Step}; + +// TODO Model the error domain of our storage layer. +#[derive(Debug)] +pub struct StorageError { + message: String, +} + +impl From for StorageError { + fn from(e: rusqlite::Error) -> Self { + match e { + rusqlite::Error::SqliteFailure(e, msg) => StorageError { + message: format!("{}: {}", e, msg.unwrap_or_default()), + }, + rusqlite::Error::SqliteSingleThreadedMode => unimplemented!(), + rusqlite::Error::FromSqlConversionFailure(_, _, _) => unimplemented!(), + rusqlite::Error::IntegralValueOutOfRange(_, _) => todo!(), + rusqlite::Error::Utf8Error(_) => todo!(), + rusqlite::Error::NulError(_) => todo!(), + rusqlite::Error::InvalidParameterName(_) => todo!(), + rusqlite::Error::InvalidPath(_) => todo!(), + rusqlite::Error::ExecuteReturnedResults => todo!(), + rusqlite::Error::QueryReturnedNoRows => todo!(), + rusqlite::Error::InvalidColumnIndex(_) => todo!(), + rusqlite::Error::InvalidColumnName(_) => todo!(), + rusqlite::Error::InvalidColumnType(_, _, _) => todo!(), + rusqlite::Error::StatementChangedRows(_) => todo!(), + rusqlite::Error::ToSqlConversionFailure(_) => todo!(), + rusqlite::Error::InvalidQuery => todo!(), + rusqlite::Error::MultipleStatement => todo!(), + rusqlite::Error::InvalidParameterCount(_, _) => todo!(), + _ => todo!(), + } + } +} + +pub enum IterResult { + Some(Entity), + Err(StorageError), + None, +} + +pub trait RecipeStore { + fn store_recipe(&mut self, e: &mut Recipe) -> Result<(), StorageError>; + fn fetch_all_recipes(&self) -> Result, StorageError>; + fn fetch_recipe_steps(&self, recipe_id: i64) -> Result>, StorageError>; + fn fetch_recipe(&mut self, k: &str) -> Result, StorageError>; + fn fetch_recipe_ingredients(&self, recipe_id: i64) -> Result, StorageError>; +} + +pub struct SqliteBackend { + conn: Connection, +} + +impl SqliteBackend { + pub fn new>(path: P) -> SqliteResult { + Ok(Self { + conn: Connection::open(path)?, + }) + } + + pub fn new_in_memory() -> SqliteResult { + Ok(Self { + conn: Connection::open_in_memory()?, + }) + } + + pub fn get_schema_version(&self) -> SqliteResult> { + let mut stmt = self + .conn + .prepare("SELECT max(version) from schema_version")?; + Ok(stmt.query_row([], |r| r.get(0))?) + } + + pub fn start_transaction<'a>(&'a mut self) -> SqliteResult> { + self.conn.transaction().map(|mut tx| { + tx.set_drop_behavior(DropBehavior::Commit); + tx + }) + } + + pub fn create_schema(&self) -> SqliteResult<()> { + self.conn.execute( + "CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY )", + [], + )?; + let version = self.get_schema_version()?; + if let None = version { + self.conn + .execute("INSERT INTO schema_version ( version ) values ( 0 )", [])?; + } else { + return Ok(()); + } + + self.conn.execute( + "CREATE TABLE IF NOT EXISTS recipes ( + id INTEGER PRIMARY KEY, + title TEXT UNIQUE NOT NULL, + desc TEXT NOT NULL + )", + [], + )?; + self.conn.execute( + "CREATE TABLE IF NOT EXISTS steps ( + recipe_id INTEGER NOT NULL, + step_idx INTEGER NOT NULL, + prep_time INTEGER, -- in seconds + instructions TEXT NOT NULL, + FOREIGN KEY(recipe_id) REFERENCES recipes(id) + CONSTRAINT step_key PRIMARY KEY (recipe_id, step_idx) + )", + [], + )?; + self.conn.execute( + "CREATE TABLE IF NOT EXISTS step_ingredients ( + recipe_id INTEGER NOT NULL, + step_idx INTEGER NOT NULL, + ingredient_idx INTEGER NOT NULL, + name TEXT NOT NULL, + amt TEXT NOT NULL, + category TEXT NOT NULL, + form TEXT, + FOREIGN KEY(recipe_id, step_idx) REFERENCES steps(recipe_id, step_idx), + CONSTRAINT step_ingredients_key PRIMARY KEY (recipe_id, step_idx, name, form) + )", + [], + )?; + Ok(()) + } + + pub fn serialize_step_stmt_rows( + mut stmt: rusqlite::Statement, + recipe_id: i64, + ) -> Result>, StorageError> { + if let Ok(step_iter) = stmt.query_map(params![recipe_id], |row| { + let prep_time: Option = row.get(2)?; + let instructions: String = row.get(3)?; + Ok(Step::new( + prep_time.map(|i| std::time::Duration::from_secs(i as u64)), + instructions, + )) + }) { + let mut steps = Vec::new(); + for step in step_iter { + steps.push(step?); + } + return Ok(Some(steps)); + } + return Ok(None); + } + + fn map_recipe_row(r: &rusqlite::Row) -> Result { + let id: i64 = r.get(0)?; + let title: String = r.get(1)?; + let desc: String = r.get(2)?; + Ok(Recipe::new_id(id, title, desc)) + } + + fn map_ingredient_row(r: &rusqlite::Row) -> Result { + let name: String = r.get(3)?; + let amt: String = r.get(4)?; + let category = r.get(5)?; + let form = r.get(6)?; + Ok(Ingredient::new( + name, + form, + dbg!(Measure::parse(dbg!(&amt)).unwrap()), + category, + )) + } +} + +impl RecipeStore for SqliteBackend { + fn fetch_all_recipes(&self) -> Result, StorageError> { + let mut stmt = self.conn.prepare("SELECT * FROM recipes")?; + let recipe_iter = stmt.query_map([], |r| Self::map_recipe_row(r))?; + let mut recipes = Vec::new(); + for next in recipe_iter { + recipes.push(next?); + } + Ok(recipes) + } + + fn store_recipe(&mut self, recipe: &mut Recipe) -> Result<(), StorageError> { + // If we don't have a transaction already we should start one. + let tx = self.start_transaction()?; + if let Some(id) = recipe.id { + tx.execute( + "INSERT OR REPLACE INTO recipes (id, title, desc) VALUES (?1, ?2, ?3)", + params![id, recipe.title, recipe.desc], + )?; + } else { + tx.execute( + "INSERT INTO recipes (title, desc) VALUES (?1, ?2)", + params![recipe.title, recipe.desc], + )?; + let mut stmt = tx.prepare("select id from recipes where title = ?1")?; + let id = stmt.query_row(params![recipe.title], |row| Ok(row.get(0)?))?; + recipe.id = Some(id); + } + for (idx, step) in recipe.steps.iter().enumerate() { + tx.execute("INSERT INTO steps (recipe_id, step_idx, prep_time, instructions) VALUES (?1, ?2, ?3, ?4)", + params![recipe.id, dbg!(idx), step.prep_time.map(|v| v.as_secs()) , step.instructions])?; + for (ing_idx, ing) in step.ingredients.iter().enumerate() { + dbg!(tx.execute( + "INSERT INTO step_ingredients (recipe_id, step_idx, ingredient_idx, name, amt, category, form) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![recipe.id, dbg!(idx), ing_idx, ing.name, format!("{}", ing.amt), ing.category, ing.form])?); + } + } + Ok(()) + } + + fn fetch_recipe_steps(&self, recipe_id: i64) -> Result>, StorageError> { + let stmt = self + .conn + .prepare("SELECT * from steps WHERE recipe_id = ?1 ORDER BY step_idx")?; + SqliteBackend::serialize_step_stmt_rows(stmt, recipe_id) + } + + fn fetch_recipe(&mut self, key: &str) -> Result, StorageError> { + let tx = self.start_transaction()?; + let mut stmt = tx.prepare("SELECT * FROM recipes WHERE title = ?1")?; + let recipe_iter = stmt.query_map(params![key], |r| Self::map_recipe_row(r))?; + let mut recipe = recipe_iter + .filter(|res| res.is_ok()) // TODO(jwall): What about failures here? + .map(|r| r.unwrap()) + .next(); + if let Some(recipe) = recipe.as_mut() { + // We know the recipe.id has to exist since we filled it when it came from the database. + let stmt = tx.prepare("SELECT * FROM steps WHERE recipe_id = ?1 ORDER BY step_idx")?; + let steps = SqliteBackend::serialize_step_stmt_rows(stmt, recipe.id.unwrap())?; + let mut stmt = tx.prepare("SELECT * from step_ingredients WHERE recipe_id = ?1 and step_idx = ?2 ORDER BY ingredient_idx")?; + if let Some(mut steps) = steps { + for (step_idx, mut step) in steps.drain(0..).enumerate() { + // TODO(jwall): Fetch the ingredients. + let ing_iter = stmt.query_map(params![recipe.id, step_idx], |row| { + Self::map_ingredient_row(row) + })?; + for ing in ing_iter { + step.ingredients.push(ing?); + } + recipe.add_step(step); + } + } + } + return Ok(recipe); + } + + fn fetch_recipe_ingredients(&self, recipe_id: i64) -> Result, StorageError> { + let mut stmt = self.conn.prepare( + "SELECT * FROM step_ingredients WHERE recipe_id = ?1 ORDER BY step_idx, ingredient_idx", + )?; + let ing_iter = stmt.query_map(params![recipe_id], |row| Self::map_ingredient_row(row))?; + let mut ingredients = Vec::new(); + for i in ing_iter { + ingredients.push(i?); + } + Ok(ingredients) + } +} + +#[cfg(test)] +mod tests; diff --git a/recipe-store/src/tests.rs b/recipe-store/src/tests.rs new file mode 100644 index 0000000..250e669 --- /dev/null +++ b/recipe-store/src/tests.rs @@ -0,0 +1,126 @@ +// Copyright 2021 Jeremy Wall +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +use crate::*; +use std::convert::Into; + +macro_rules! init_sqlite_store { + () => {{ + let in_memory = + SqliteBackend::new_in_memory().expect("We expect in memory connections to succeed"); + in_memory + .create_schema() + .expect("We expect the schema creation to succeed"); + let version = in_memory + .get_schema_version() + .expect("We expect the version fetch to succeed"); + assert!(version.is_some()); + assert_eq!(version.unwrap(), 0); + in_memory + }}; +} + +#[test] +fn test_schema_creation() { + let in_memory = init_sqlite_store!(); + + in_memory + .create_schema() + .expect("create_schema is idempotent"); + let version = in_memory + .get_schema_version() + .expect("We expect the version fetch to succeed"); + assert!(version.is_some()); + assert_eq!(version.unwrap(), 0); +} + +#[test] +fn test_recipe_store_update_roundtrip_full() { + let mut in_memory = init_sqlite_store!(); + + let mut recipe = Recipe::new("my recipe", "my description"); + let mut step1 = Step::new( + Some(std::time::Duration::from_secs(60 * 30)), + "mix thoroughly", + ); + step1.add_ingredients(vec![ + Ingredient::new("flour", None, Measure::cup(1.into()), "dry goods"), + Ingredient::new( + "salt", + Some("Ground".to_owned()), + Measure::tsp(1.into()), + "seasoning", + ), + ]); + let step2 = Step::new(None, "combine ingredients"); + recipe.add_steps(vec![step1.clone(), step2.clone()]); + + in_memory + .store_recipe(&mut recipe) + .expect("We expect the recpe to store successfully"); + assert!(recipe.id.is_some()); + + let recipes: Vec = in_memory + .fetch_all_recipes() + .expect("We expect to get recipes back out"); + assert_eq!(recipes.len(), 1); + let recipe: Option = in_memory + .fetch_recipe("my recipe") + .expect("We expect the recipe to come back out"); + assert!(recipe.is_some()); + let recipe = recipe.unwrap(); + assert_eq!(recipe.title, "my recipe"); + assert_eq!(recipe.desc, "my description"); + assert_eq!(recipe.steps.len(), 2); + let step1_got = &recipe.steps[0]; + let step2_got = &recipe.steps[1]; + assert_eq!(step1_got.prep_time, step1.prep_time); + assert_eq!(step1_got.instructions, step1.instructions); + assert_eq!(step1_got.ingredients.len(), step1.ingredients.len()); + assert_eq!(step1_got.ingredients[0], step1.ingredients[0]); + assert_eq!(step1_got.ingredients[1], step1.ingredients[1]); + assert_eq!(step2_got.prep_time, step2.prep_time); + assert_eq!(step2_got.instructions, step2.instructions); + assert_eq!(step2_got.ingredients.len(), step2.ingredients.len()); +} + +#[test] +fn test_fetch_recipe_ingredients() { + let mut in_memory = init_sqlite_store!(); + let mut recipe = Recipe::new("my recipe", "my description"); + let mut step1 = Step::new( + Some(std::time::Duration::from_secs(60 * 30)), + "mix thoroughly", + ); + step1.add_ingredients(vec![ + Ingredient::new("flour", None, Measure::cup(1.into()), "dry goods"), + Ingredient::new( + "salt", + Some("Ground".to_owned()), + Measure::tsp(1.into()), + "seasoning", + ), + ]); + let step2 = Step::new(None, "combine ingredients"); + recipe.add_steps(vec![step1.clone(), step2.clone()]); + + in_memory + .store_recipe(&mut recipe) + .expect("We expect the recpe to store successfully"); + + let ingredients = in_memory + .fetch_recipe_ingredients(recipe.id.unwrap()) + .expect("We expect to fetch ingredients for the recipe"); + assert_eq!(ingredients.len(), 2); + assert_eq!(ingredients, step1.ingredients); +} diff --git a/recipes/Cargo.toml b/recipes/Cargo.toml index ba33b97..deb164d 100644 --- a/recipes/Cargo.toml +++ b/recipes/Cargo.toml @@ -6,5 +6,8 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[dependencies] +abortable_parser = "~0.2.4" + [dependencies.num-rational] version = "~0.4.0" \ No newline at end of file diff --git a/recipes/src/lib.rs b/recipes/src/lib.rs index 0d29aee..afd25f6 100644 --- a/recipes/src/lib.rs +++ b/recipes/src/lib.rs @@ -18,17 +18,29 @@ use std::collections::BTreeMap; use unit::*; /// A Recipe with a title, description, and a series of steps. +#[derive(Debug, Clone, PartialEq)] pub struct Recipe { + pub id: Option, pub title: String, pub desc: String, pub steps: Vec, } impl Recipe { - pub fn new(title: String, desc: String) -> Self { + pub fn new>(title: S, desc: S) -> Self { Self { - title, - desc, + id: None, + title: title.into(), + desc: desc.into(), + steps: Vec::new(), + } + } + + pub fn new_id>(id: i64, title: S, desc: S) -> Self { + Self { + id: Some(id), + title: title.into(), + desc: desc.into(), steps: Vec::new(), } } @@ -69,44 +81,92 @@ impl Recipe { } } +#[derive(Clone, Debug, PartialEq)] +pub struct StepKey(i64, i64); + +impl StepKey { + pub fn recipe_id(&self) -> i64 { + self.0 + } + pub fn step_idx(&self) -> i64 { + self.1 + } +} + /// A Recipe step. It has the time for the step if there is one, instructions, and an ingredients /// list. +#[derive(Debug, Clone, PartialEq)] pub struct Step { pub prep_time: Option, pub instructions: String, pub ingredients: Vec, } -/// Form of the ingredient. -#[derive(PartialEq, Eq, Debug, PartialOrd, Ord, Clone)] -pub enum Form { - Whole, // default - Chopped, - Minced, - Sliced, - Ground, - Mashed, - Custom(String), +impl Step { + pub fn new>(prep_time: Option, instructions: S) -> Self { + Self { + prep_time: prep_time, + instructions: instructions.into(), + ingredients: Vec::new(), + } + } + + pub fn new_id>( + prep_time: Option, + instructions: S, + ) -> Self { + Self { + prep_time: prep_time, + instructions: instructions.into(), + ingredients: Vec::new(), + } + } + + pub fn add_ingredients(&mut self, mut ingredients: Vec) { + self.ingredients.append(&mut ingredients); + } + + pub fn add_ingredient(&mut self, ingredient: Ingredient) { + self.ingredients.push(ingredient); + } } /// Unique identifier for an Ingredient. Ingredients are identified by name, form, /// and measurement type. (Volume, Count, Weight) #[derive(PartialEq, PartialOrd, Eq, Ord)] -pub struct IngredientKey(String, Form, String); +pub struct IngredientKey(String, Option, String); /// Ingredient in a recipe. The `name` and `form` fields with the measurement type /// uniquely identify an ingredient. -#[derive(Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Ingredient { + pub id: Option, pub name: String, - pub form: Form, + pub form: Option, pub amt: Measure, pub category: String, } impl Ingredient { - pub fn new>(name: S, form: Form, amt: Measure, category: S) -> Self { + pub fn new>(name: S, form: Option, amt: Measure, category: S) -> Self { Self { + id: None, + name: name.into(), + form, + amt, + category: category.into(), + } + } + + pub fn new_id>( + id: i64, + name: S, + form: Option, + amt: Measure, + category: S, + ) -> Self { + Self { + id: Some(id), name: name.into(), form, amt, @@ -127,19 +187,10 @@ impl Ingredient { impl std::fmt::Display for Ingredient { fn fmt(&self, w: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(w, "{} {}", self.amt, self.name)?; - write!( - w, - " ({})", - match self.form { - Form::Whole => return Ok(()), - Form::Chopped => "chopped", - Form::Minced => "minced", - Form::Sliced => "sliced", - Form::Ground => "ground", - Form::Mashed => "mashed", - Form::Custom(ref s) => return write!(w, " ({})", s), - } - ) + if let Some(f) = &self.form { + write!(w, " ({})", f)?; + } + Ok(()) } } diff --git a/recipes/src/test.rs b/recipes/src/test.rs index f287a51..b04a304 100644 --- a/recipes/src/test.rs +++ b/recipes/src/test.rs @@ -79,89 +79,114 @@ fn test_volume_normalize() { fn test_ingredient_display() { let cases = vec![ ( - Ingredient::new("onion", Form::Chopped, Measure::cup(1.into()), "Produce"), + Ingredient::new( + "onion", + Some("chopped".to_owned()), + Measure::cup(1.into()), + "Produce", + ), "1 cup onion (chopped)", ), ( - Ingredient::new("onion", Form::Chopped, Measure::cup(2.into()), "Produce"), + Ingredient::new( + "onion", + Some("chopped".to_owned()), + Measure::cup(2.into()), + "Produce", + ), "2 cups onion (chopped)", ), ( - Ingredient::new("onion", Form::Chopped, Measure::tbsp(1.into()), "Produce"), + Ingredient::new( + "onion", + Some("chopped".to_owned()), + Measure::tbsp(1.into()), + "Produce", + ), "1 tbsp onion (chopped)", ), ( - Ingredient::new("onion", Form::Chopped, Measure::tbsp(2.into()), "Produce"), + Ingredient::new( + "onion", + Some("chopped".to_owned()), + Measure::tbsp(2.into()), + "Produce", + ), "2 tbsps onion (chopped)", ), ( - Ingredient::new("soy sauce", Form::Whole, Measure::floz(1.into()), "Produce"), + Ingredient::new("soy sauce", None, Measure::floz(1.into()), "Produce"), "1 floz soy sauce", ), ( - Ingredient::new("soy sauce", Form::Whole, Measure::floz(2.into()), "Produce"), + Ingredient::new("soy sauce", None, Measure::floz(2.into()), "Produce"), "2 floz soy sauce", ), ( - Ingredient::new("soy sauce", Form::Whole, Measure::qrt(1.into()), "Produce"), + Ingredient::new("soy sauce", None, Measure::qrt(1.into()), "Produce"), "1 qrt soy sauce", ), ( - Ingredient::new("soy sauce", Form::Whole, Measure::qrt(2.into()), "Produce"), + Ingredient::new("soy sauce", None, Measure::qrt(2.into()), "Produce"), "2 qrts soy sauce", ), ( - Ingredient::new("soy sauce", Form::Whole, Measure::pint(1.into()), "Produce"), + Ingredient::new("soy sauce", None, Measure::pint(1.into()), "Produce"), "1 pint soy sauce", ), ( - Ingredient::new("soy sauce", Form::Whole, Measure::pint(2.into()), "Produce"), + Ingredient::new("soy sauce", None, Measure::pint(2.into()), "Produce"), "2 pints soy sauce", ), ( - Ingredient::new("soy sauce", Form::Whole, Measure::gal(1.into()), "Produce"), + Ingredient::new("soy sauce", None, Measure::gal(1.into()), "Produce"), "1 gal soy sauce", ), ( - Ingredient::new("soy sauce", Form::Whole, Measure::gal(2.into()), "Produce"), + Ingredient::new("soy sauce", None, Measure::gal(2.into()), "Produce"), "2 gals soy sauce", ), ( - Ingredient::new("soy sauce", Form::Whole, Measure::ml(1.into()), "Produce"), + Ingredient::new("soy sauce", None, Measure::ml(1.into()), "Produce"), "1 ml soy sauce", ), ( - Ingredient::new("soy sauce", Form::Whole, Measure::ml(2.into()), "Produce"), + Ingredient::new("soy sauce", None, Measure::ml(2.into()), "Produce"), "2 ml soy sauce", ), ( - Ingredient::new("soy sauce", Form::Whole, Measure::ltr(1.into()), "Produce"), + Ingredient::new("soy sauce", None, Measure::ltr(1.into()), "Produce"), "1 ltr soy sauce", ), ( - Ingredient::new("soy sauce", Form::Whole, Measure::ltr(2.into()), "Produce"), + Ingredient::new("soy sauce", None, Measure::ltr(2.into()), "Produce"), "2 ltr soy sauce", ), ( - Ingredient::new("apple", Form::Whole, Measure::count(1), "Produce"), + Ingredient::new("apple", None, Measure::count(1), "Produce"), "1 apple", ), ( - Ingredient::new("salt", Form::Whole, Measure::gram(1.into()), "Produce"), + Ingredient::new("salt", None, Measure::gram(1.into()), "Produce"), "1 gram salt", ), ( - Ingredient::new("salt", Form::Whole, Measure::gram(2.into()), "Produce"), + Ingredient::new("salt", None, Measure::gram(2.into()), "Produce"), "2 grams salt", ), ( - Ingredient::new("onion", Form::Minced, Measure::cup(1.into()), "Produce"), + Ingredient::new( + "onion", + Some("minced".to_owned()), + Measure::cup(1.into()), + "Produce", + ), "1 cup onion (minced)", ), ( Ingredient::new( "pepper", - Form::Ground, + Some("ground".to_owned()), Measure::tsp(Ratio::new(1, 2).into()), "Produce", ), @@ -170,24 +195,34 @@ fn test_ingredient_display() { ( Ingredient::new( "pepper", - Form::Ground, + Some("ground".to_owned()), Measure::tsp(Ratio::new(3, 2).into()), "Produce", ), "1 1/2 tsps pepper (ground)", ), ( - Ingredient::new("apple", Form::Sliced, Measure::count(1), "Produce"), + Ingredient::new( + "apple", + Some("sliced".to_owned()), + Measure::count(1), + "Produce", + ), "1 apple (sliced)", ), ( - Ingredient::new("potato", Form::Mashed, Measure::count(1), "Produce"), + Ingredient::new( + "potato", + Some("mashed".to_owned()), + Measure::count(1), + "Produce", + ), "1 potato (mashed)", ), ( Ingredient::new( "potato", - Form::Custom("blanched".to_owned()), + Some("blanched".to_owned()), Measure::count(1), "Produce", ), diff --git a/recipes/src/unit.rs b/recipes/src/unit.rs index eb5fd35..4225f0b 100644 --- a/recipes/src/unit.rs +++ b/recipes/src/unit.rs @@ -22,8 +22,13 @@ use std::{ convert::TryFrom, fmt::Display, ops::{Add, Div, Mul, Sub}, + str::FromStr, }; +use abortable_parser::{ + self, consume_all, do_each, either, make_fn, not, optional, peek, run, text_token, trap, + Result, StrIter, +}; use num_rational::Ratio; #[derive(Copy, Clone, Debug)] @@ -202,7 +207,7 @@ impl Display for VolumeMeasure { } } -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, PartialEq)] /// Measurements in a Recipe with associated units for them. pub enum Measure { /// Volume measurements as meter cubed base unit @@ -275,6 +280,32 @@ impl Measure { Count(qty) | Gram(qty) => qty.plural(), } } + + // TODO(jwall): parse from string. + + pub fn parse(input: &str) -> std::result::Result { + Ok(match measure(StrIter::new(input)) { + Result::Complete(_, (qty, Some(unit))) => match unit { + "tsp" => Volume(Tsp(qty)), + "tbsp" => Volume(Tbsp(qty)), + "floz" => Volume(Floz(qty)), + "ml" => Volume(ML(qty)), + "ltr" => Volume(Ltr(qty)), + "cup" => Volume(Cup(qty)), + "qrt" => Volume(Qrt(qty)), + "pint" | "pnt" => Volume(Pint(qty)), + "cnt" => Count(qty), + "g" => Gram(qty), + "gram" => Gram(qty), + u => return Err(format!("Invalid unit {}", u)), + }, + Result::Complete(_, (qty, None)) => Count(qty), + Result::Abort(e) | Result::Fail(e) => { + return Err(format!("Failed to parse as Measure {:?}", e)) + } + Result::Incomplete(_) => return Err(format!("Incomplete input: {}", input)), + }) + } } impl Display for Measure { @@ -366,7 +397,7 @@ impl From for Quantity { impl TryFrom for Quantity { type Error = ConversionError; - fn try_from(f: f32) -> Result { + fn try_from(f: f32) -> std::result::Result { Ratio::approximate_float(f) .map(|rat: Ratio| Frac(Ratio::new(*rat.numer() as u32, *rat.denom() as u32))) .ok_or_else(|| ConversionError { @@ -437,3 +468,98 @@ impl Display for Quantity { } } } + +make_fn!(nonzero, + peek!(not!(do_each!( + n => consume_all!(text_token!("0")), + _ => optional!(ws), + (n) + ))) +); + +make_fn!(num, + do_each!( + n => consume_all!(either!( + text_token!("0"), + text_token!("1"), + text_token!("2"), + text_token!("3"), + text_token!("4"), + text_token!("5"), + text_token!("6"), + text_token!("7"), + text_token!("8"), + text_token!("9") + )), + (u32::from_str(n).unwrap()) + ) +); + +make_fn!(ws, + consume_all!(either!( + text_token!(" "), + text_token!("\t") + )) +); + +make_fn!(ratio>, + do_each!( + // First we assert non-zero numerator + _ => nonzero, + numer => num, + _ => optional!(ws), + _ => text_token!("/"), + _ => optional!(ws), + denom => num, + (Ratio::new(numer, denom)) + ) +); + +make_fn!(unit, + do_each!( + u => either!( + text_token!("tsp"), + text_token!("tbsp"), + text_token!("floz"), + text_token!("ml"), + text_token!("ltr"), + text_token!("cup"), + text_token!("qrt"), + text_token!("pint"), + text_token!("pnt"), + text_token!("gal"), + text_token!("gal"), + text_token!("cnt"), + text_token!("g"), + text_token!("gram")), + (u)) +); + +make_fn!( + quantity, + either!( + do_each!( + whole => num, + frac => ratio, + (Quantity::Whole(whole) + Quantity::Frac(frac)) + ), + do_each!( + frac => ratio, + (Quantity::Frac(frac)) + ), + do_each!( + whole => num, + (Quantity::whole(whole)) + ) + ) +); + +make_fn!( + measure)>, + do_each!( + qty => quantity, + _ => optional!(ws), + unit => optional!(unit), + ((qty, unit)) + ) +);