Storage layer for recipes in sqlite

This commit is contained in:
Jeremy Wall 2021-04-29 18:41:54 -04:00
parent b96ee641af
commit 32bb68b911
9 changed files with 983 additions and 58 deletions

288
Cargo.lock generated
View File

@ -1,11 +1,172 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # 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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.0.1" version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 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]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.0" version = "0.4.0"
@ -48,9 +209,136 @@ dependencies = [
"autocfg", "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]] [[package]]
name = "recipes" name = "recipes"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"abortable_parser",
"num-rational", "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"

View File

@ -1,2 +1,2 @@
[workspace] [workspace]
members = [ "recipes" ] members = [ "recipes", "recipe-store" ]

14
recipe-store/Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "recipe-store"
version = "0.1.0"
authors = ["Jeremy Wall <jeremy@marzhillstudios.com>"]
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"]

282
recipe-store/src/lib.rs Normal file
View File

@ -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<rusqlite::Error> 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<Entity> {
Some(Entity),
Err(StorageError),
None,
}
pub trait RecipeStore {
fn store_recipe(&mut self, e: &mut Recipe) -> Result<(), StorageError>;
fn fetch_all_recipes(&self) -> Result<Vec<Recipe>, StorageError>;
fn fetch_recipe_steps(&self, recipe_id: i64) -> Result<Option<Vec<Step>>, StorageError>;
fn fetch_recipe(&mut self, k: &str) -> Result<Option<Recipe>, StorageError>;
fn fetch_recipe_ingredients(&self, recipe_id: i64) -> Result<Vec<Ingredient>, StorageError>;
}
pub struct SqliteBackend {
conn: Connection,
}
impl SqliteBackend {
pub fn new<P: AsRef<Path>>(path: P) -> SqliteResult<Self> {
Ok(Self {
conn: Connection::open(path)?,
})
}
pub fn new_in_memory() -> SqliteResult<Self> {
Ok(Self {
conn: Connection::open_in_memory()?,
})
}
pub fn get_schema_version(&self) -> SqliteResult<Option<u32>> {
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<Transaction<'a>> {
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<Option<Vec<Step>>, StorageError> {
if let Ok(step_iter) = stmt.query_map(params![recipe_id], |row| {
let prep_time: Option<i64> = 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<Recipe, rusqlite::Error> {
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<Ingredient, rusqlite::Error> {
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<Vec<Recipe>, 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<Option<Vec<Step>>, 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<Option<Recipe>, 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<Vec<Ingredient>, 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;

126
recipe-store/src/tests.rs Normal file
View File

@ -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<Recipe> = in_memory
.fetch_all_recipes()
.expect("We expect to get recipes back out");
assert_eq!(recipes.len(), 1);
let recipe: Option<Recipe> = 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);
}

View File

@ -6,5 +6,8 @@ edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # 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] [dependencies.num-rational]
version = "~0.4.0" version = "~0.4.0"

View File

@ -18,17 +18,29 @@ use std::collections::BTreeMap;
use unit::*; use unit::*;
/// A Recipe with a title, description, and a series of steps. /// A Recipe with a title, description, and a series of steps.
#[derive(Debug, Clone, PartialEq)]
pub struct Recipe { pub struct Recipe {
pub id: Option<i64>,
pub title: String, pub title: String,
pub desc: String, pub desc: String,
pub steps: Vec<Step>, pub steps: Vec<Step>,
} }
impl Recipe { impl Recipe {
pub fn new(title: String, desc: String) -> Self { pub fn new<S: Into<String>>(title: S, desc: S) -> Self {
Self { Self {
title, id: None,
desc, title: title.into(),
desc: desc.into(),
steps: Vec::new(),
}
}
pub fn new_id<S: Into<String>>(id: i64, title: S, desc: S) -> Self {
Self {
id: Some(id),
title: title.into(),
desc: desc.into(),
steps: Vec::new(), 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 /// A Recipe step. It has the time for the step if there is one, instructions, and an ingredients
/// list. /// list.
#[derive(Debug, Clone, PartialEq)]
pub struct Step { pub struct Step {
pub prep_time: Option<std::time::Duration>, pub prep_time: Option<std::time::Duration>,
pub instructions: String, pub instructions: String,
pub ingredients: Vec<Ingredient>, pub ingredients: Vec<Ingredient>,
} }
/// Form of the ingredient. impl Step {
#[derive(PartialEq, Eq, Debug, PartialOrd, Ord, Clone)] pub fn new<S: Into<String>>(prep_time: Option<std::time::Duration>, instructions: S) -> Self {
pub enum Form { Self {
Whole, // default prep_time: prep_time,
Chopped, instructions: instructions.into(),
Minced, ingredients: Vec::new(),
Sliced, }
Ground, }
Mashed,
Custom(String), pub fn new_id<S: Into<String>>(
prep_time: Option<std::time::Duration>,
instructions: S,
) -> Self {
Self {
prep_time: prep_time,
instructions: instructions.into(),
ingredients: Vec::new(),
}
}
pub fn add_ingredients(&mut self, mut ingredients: Vec<Ingredient>) {
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, /// Unique identifier for an Ingredient. Ingredients are identified by name, form,
/// and measurement type. (Volume, Count, Weight) /// and measurement type. (Volume, Count, Weight)
#[derive(PartialEq, PartialOrd, Eq, Ord)] #[derive(PartialEq, PartialOrd, Eq, Ord)]
pub struct IngredientKey(String, Form, String); pub struct IngredientKey(String, Option<String>, String);
/// Ingredient in a recipe. The `name` and `form` fields with the measurement type /// Ingredient in a recipe. The `name` and `form` fields with the measurement type
/// uniquely identify an ingredient. /// uniquely identify an ingredient.
#[derive(Clone)] #[derive(Debug, Clone, PartialEq)]
pub struct Ingredient { pub struct Ingredient {
pub id: Option<i64>,
pub name: String, pub name: String,
pub form: Form, pub form: Option<String>,
pub amt: Measure, pub amt: Measure,
pub category: String, pub category: String,
} }
impl Ingredient { impl Ingredient {
pub fn new<S: Into<String>>(name: S, form: Form, amt: Measure, category: S) -> Self { pub fn new<S: Into<String>>(name: S, form: Option<String>, amt: Measure, category: S) -> Self {
Self { Self {
id: None,
name: name.into(),
form,
amt,
category: category.into(),
}
}
pub fn new_id<S: Into<String>>(
id: i64,
name: S,
form: Option<String>,
amt: Measure,
category: S,
) -> Self {
Self {
id: Some(id),
name: name.into(), name: name.into(),
form, form,
amt, amt,
@ -127,19 +187,10 @@ impl Ingredient {
impl std::fmt::Display for Ingredient { impl std::fmt::Display for Ingredient {
fn fmt(&self, w: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, w: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(w, "{} {}", self.amt, self.name)?; write!(w, "{} {}", self.amt, self.name)?;
write!( if let Some(f) = &self.form {
w, write!(w, " ({})", f)?;
" ({})", }
match self.form { Ok(())
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),
}
)
} }
} }

View File

@ -79,89 +79,114 @@ fn test_volume_normalize() {
fn test_ingredient_display() { fn test_ingredient_display() {
let cases = vec![ 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)", "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)", "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)", "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)", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "2 ltr soy sauce",
), ),
( (
Ingredient::new("apple", Form::Whole, Measure::count(1), "Produce"), Ingredient::new("apple", None, Measure::count(1), "Produce"),
"1 apple", "1 apple",
), ),
( (
Ingredient::new("salt", Form::Whole, Measure::gram(1.into()), "Produce"), Ingredient::new("salt", None, Measure::gram(1.into()), "Produce"),
"1 gram salt", "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", "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)", "1 cup onion (minced)",
), ),
( (
Ingredient::new( Ingredient::new(
"pepper", "pepper",
Form::Ground, Some("ground".to_owned()),
Measure::tsp(Ratio::new(1, 2).into()), Measure::tsp(Ratio::new(1, 2).into()),
"Produce", "Produce",
), ),
@ -170,24 +195,34 @@ fn test_ingredient_display() {
( (
Ingredient::new( Ingredient::new(
"pepper", "pepper",
Form::Ground, Some("ground".to_owned()),
Measure::tsp(Ratio::new(3, 2).into()), Measure::tsp(Ratio::new(3, 2).into()),
"Produce", "Produce",
), ),
"1 1/2 tsps pepper (ground)", "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)", "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)", "1 potato (mashed)",
), ),
( (
Ingredient::new( Ingredient::new(
"potato", "potato",
Form::Custom("blanched".to_owned()), Some("blanched".to_owned()),
Measure::count(1), Measure::count(1),
"Produce", "Produce",
), ),

View File

@ -22,8 +22,13 @@ use std::{
convert::TryFrom, convert::TryFrom,
fmt::Display, fmt::Display,
ops::{Add, Div, Mul, Sub}, 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; use num_rational::Ratio;
#[derive(Copy, Clone, Debug)] #[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. /// Measurements in a Recipe with associated units for them.
pub enum Measure { pub enum Measure {
/// Volume measurements as meter cubed base unit /// Volume measurements as meter cubed base unit
@ -275,6 +280,32 @@ impl Measure {
Count(qty) | Gram(qty) => qty.plural(), Count(qty) | Gram(qty) => qty.plural(),
} }
} }
// TODO(jwall): parse from string.
pub fn parse(input: &str) -> std::result::Result<Self, String> {
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 { impl Display for Measure {
@ -366,7 +397,7 @@ impl From<u32> for Quantity {
impl TryFrom<f32> for Quantity { impl TryFrom<f32> for Quantity {
type Error = ConversionError; type Error = ConversionError;
fn try_from(f: f32) -> Result<Self, Self::Error> { fn try_from(f: f32) -> std::result::Result<Self, Self::Error> {
Ratio::approximate_float(f) Ratio::approximate_float(f)
.map(|rat: Ratio<i32>| Frac(Ratio::new(*rat.numer() as u32, *rat.denom() as u32))) .map(|rat: Ratio<i32>| Frac(Ratio::new(*rat.numer() as u32, *rat.denom() as u32)))
.ok_or_else(|| ConversionError { .ok_or_else(|| ConversionError {
@ -437,3 +468,98 @@ impl Display for Quantity {
} }
} }
} }
make_fn!(nonzero<StrIter, ()>,
peek!(not!(do_each!(
n => consume_all!(text_token!("0")),
_ => optional!(ws),
(n)
)))
);
make_fn!(num<StrIter, u32>,
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<StrIter, &str>,
consume_all!(either!(
text_token!(" "),
text_token!("\t")
))
);
make_fn!(ratio<StrIter, Ratio<u32>>,
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<StrIter, &str>,
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<StrIter, 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<StrIter, (Quantity, Option<&str>)>,
do_each!(
qty => quantity,
_ => optional!(ws),
unit => optional!(unit),
((qty, unit))
)
);