mirror of
https://github.com/zaphar/kitchen.git
synced 2025-07-22 19:40:14 -04:00
Storage layer for recipes in sqlite
This commit is contained in:
parent
b96ee641af
commit
32bb68b911
288
Cargo.lock
generated
288
Cargo.lock
generated
@ -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"
|
||||||
|
@ -1,2 +1,2 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [ "recipes" ]
|
members = [ "recipes", "recipe-store" ]
|
14
recipe-store/Cargo.toml
Normal file
14
recipe-store/Cargo.toml
Normal 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
282
recipe-store/src/lib.rs
Normal 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
126
recipe-store/src/tests.rs
Normal 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);
|
||||||
|
}
|
@ -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"
|
@ -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 {
|
|
||||||
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),
|
|
||||||
}
|
}
|
||||||
)
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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",
|
||||||
),
|
),
|
||||||
|
@ -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))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user