Compare commits

..

1 Commits

Author SHA1 Message Date
davidemazzocchi 0ce6b0bc91 Initial commit 2026-03-09 07:47:51 +00:00
96 changed files with 3 additions and 6809 deletions
-2
View File
@@ -1,2 +0,0 @@
01.workspace/target
01.workspace/heave/rustybudger.sqlite3
-17
View File
@@ -1,17 +0,0 @@
default:
just --list
extract_devlog date:
git log --pretty=format:"%ad|%s" --date=short --since="{{date}} 00:00" | extract-devlog.sh
check_semver:
cd 01.workspace/heave && cargo semver-checks --baseline-rev origin/next --verbose
code_coverage:
cd 01.workspace && cargo llvm-cov clean && cargo llvm-cov --show-missing-lines
force_sync_remotes:
git push --force github.com next
sync_remotes:
git push github next
-265
View File
@@ -1,265 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "bumpalo"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "cc"
version = "1.2.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "find-msvc-tools"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
dependencies = [
"hashbrown",
]
[[package]]
name = "heave"
version = "0.10.0"
dependencies = [
"rusqlite",
]
[[package]]
name = "js-sys"
version = "0.3.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "libsqlite3-sys"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "proc-macro2"
version = "1.0.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rusqlite"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05e98301bf8b0540c7de45ecd760539b9c62f5772aed172f08efba597c11cd5d"
dependencies = [
"cc",
"hashbrown",
"js-sys",
"thiserror",
"wasm-bindgen",
]
[[package]]
name = "syn"
version = "2.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "wasm-bindgen"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
dependencies = [
"unicode-ident",
]
-4
View File
@@ -1,4 +0,0 @@
[workspace]
members = ["heave" ]
resolver = "3" # optional, to specify dependency resolver version
-11
View File
@@ -1,11 +0,0 @@
[package]
name = "heave"
version = "0.10.0"
edition = "2021"
description = "An Entity-Attribute-Value (EAV) system library for Rust, backed by SQLite."
license = "MIT"
keywords = ["eav", "entity-attribute-value", "sqlite", "database", "orm", "data-model", "rust"]
repository = "https://github.com/katekorsaro/heave"
[dependencies]
rusqlite = { version = "0.38.0", features = ["bundled"] }
@@ -1,80 +0,0 @@
use heave::catalog::*;
use heave::eav::*;
// Define a struct named `Product` to represent a product.
#[derive(PartialEq, Clone)]
struct Product {
// `id` is a public field of type `String` to uniquely identify the product.
pub id: String,
// `name` is a public field of type `String` for the product's name.
pub name: String,
// `model` is a public optional field of type `String` for the product's model.
pub model: Option<String>,
// `price` is a public field of type `i64` for the product's price.
pub price: i64,
}
// Implement the `EAV` trait for the `Product` struct.
impl EAV for Product {
// `class` is a function that returns the class name of the entity.
fn class() -> &'static str {
"product"
}
}
// Implement the `From<Product>` trait for the `Entity` struct.
impl From<Product> for Entity {
// `from` is a function that converts a `Product` into an `Entity`.
fn from(value: Product) -> Entity {
// Create a new `Entity` for the `Product` class.
Entity::new::<Product>()
// Set the entity's ID from the product's ID.
.with_id(&value.id)
// Add the "name" attribute with the product's name.
.with_attribute("name", value.name)
// Add the optional "model" attribute with the product's model.
.with_opt_attribute("model", value.model)
// Add the "price" attribute with the product's price.
.with_attribute("price", value.price)
}
}
// Implement the `From<Entity>` trait for the `Product` struct.
impl From<Entity> for Product {
// `from` is a function that converts an `Entity` into a `Product`.
fn from(value: Entity) -> Self {
// Create a new `Product` from the entity's attributes.
Self {
// Set the product's ID from the entity's ID.
id: value.id().to_string(),
// Unwrap the "name" attribute to get the product's name.
name: value.unwrap("name").expect("name is always present"),
// Unwrap the optional "model" attribute to get the product's model.
model: value.unwrap_opt("model").expect("model is always present"),
// Unwrap the "price" attribute to get the product's price.
price: value.unwrap("price").expect("price is always present"),
}
}
}
fn main() {
// Define the path for the SQLite database file.
let db_path = "./simple_product.sqlite3";
// Create a new `Catalog` instance with the specified database path.
let catalog = Catalog::new(db_path);
// Initialize the catalog, which sets up the database.
catalog.init().unwrap();
// Create a new `Product` instance representing a laptop.
let new_laptop = Product {
id: "LT001".to_string(),
name: "SuperPenguin".to_string(),
model: Some("Mark III.2".to_string()),
price: 125000,
};
// Insert the new laptop into the catalog. Note that at this time the product is in memory.
let _ = catalog.upsert(new_laptop);
// Persist the changes in the catalog to the database.
catalog.persist().unwrap();
// Remove the SQLite database file.
std::fs::remove_file(db_path).unwrap();
}
@@ -1,162 +0,0 @@
use heave::catalog::*;
use heave::eav::*;
use heave::filter::*;
// Define a struct named `Component` to represent an electronic component.
#[derive(Debug, Clone, PartialEq)]
struct Component {
pub id: String,
pub part_number: String,
pub kind: String,
pub value: u32,
pub package: String,
pub in_stock: bool,
}
// Implement the `EAV` trait for the `Component` struct.
impl EAV for Component {
// `class` is a function that returns the class name of the entity.
fn class() -> &'static str {
"component"
}
}
// Implement the `From<Component>` trait for the `Entity` struct.
impl From<Component> for Entity {
// `from` is a function that converts a `Component` into an `Entity`.
fn from(value: Component) -> Entity {
Entity::new::<Component>()
.with_id(&value.id)
.with_attribute("part_number", value.part_number)
.with_attribute("kind", value.kind)
.with_attribute("value", value.value)
.with_attribute("package", value.package)
.with_attribute("in_stock", value.in_stock)
}
}
// Implement the `From<Entity>` trait for the `Component` struct.
impl From<Entity> for Component {
// `from` is a function that converts an `Entity` into a `Component`.
fn from(value: Entity) -> Self {
Self {
id: value.id().to_string(),
part_number: value
.unwrap("part_number")
.expect("part_number is always present"),
kind: value.unwrap("kind").expect("kind is always present"),
value: value.unwrap("value").expect("value is always present"),
package: value.unwrap("package").expect("package is always present"),
in_stock: value
.unwrap("in_stock")
.expect("in_stock is always present"),
}
}
}
fn main() {
// Define the path for the SQLite database file.
let db_path = "./using_filters.sqlite3";
// Create a new `Catalog` instance with the specified database path.
let catalog = Catalog::new(db_path);
// Initialize the catalog, which sets up the database.
catalog.init().unwrap();
// Create some component instances.
let components_to_add = vec![
// This one should be found
Component {
id: "R1".to_string(),
part_number: "R-10K-0805".to_string(),
kind: "resistor".to_string(),
value: 10000,
package: "smd-0805".to_string(),
in_stock: true,
},
// This one should be found
Component {
id: "R2".to_string(),
part_number: "R-4K7-0805".to_string(),
kind: "resistor".to_string(),
value: 4700,
package: "smd-0805".to_string(),
in_stock: true,
},
// This one should NOT be found (wrong kind)
Component {
id: "C1".to_string(),
part_number: "C-100n-0603".to_string(),
kind: "capacitor".to_string(),
value: 100,
package: "smd-0603".to_string(),
in_stock: true,
},
// This one should NOT be found (value too low)
Component {
id: "R3".to_string(),
part_number: "R-100-0805".to_string(),
kind: "resistor".to_string(),
value: 100,
package: "smd-0805".to_string(),
in_stock: true,
},
// This one should NOT be found (not in stock)
Component {
id: "R4".to_string(),
part_number: "R-22K-TH".to_string(),
kind: "resistor".to_string(),
value: 22000,
package: "through-hole".to_string(),
in_stock: false,
},
// This one should NOT be found (wrong kind, even if other fields match)
Component {
id: "L1".to_string(),
part_number: "L-10mH-TH".to_string(),
kind: "inductor".to_string(),
value: 10000,
package: "through-hole".to_string(),
in_stock: true,
},
];
// Insert the components into the catalog.
catalog.insert_many(components_to_add.clone()).unwrap();
// Persist the changes to the database.
catalog.persist().unwrap();
// Create a new catalog to ensure we are loading from the database.
let new_catalog = Catalog::new(db_path);
// Create a composite filter.
// We are looking for resistors with a value greater than 1000 that are in stock.
let filter = Filter::new()
.with_text("kind", Comparison::IsExactly, "resistor")
.with_unsigned_int("value", Comparison::Greater, 1000)
.with_bool("in_stock", true);
// Load entities from the database using the filter.
new_catalog.load_by_filter(&filter).unwrap();
// Get the list of loaded components.
let loaded_components: Vec<Component> = new_catalog
.list::<Component>()
.unwrap()
.into_iter()
.collect();
// Print the loaded components
println!(
"Found {} components matching the filter:",
loaded_components.len()
);
for component in &loaded_components {
println!(
"- ID: {}, Part Number: {}, Kind: {}, Value: {}, Package: {}, In Stock: {}",
component.id,
component.part_number,
component.kind,
component.value,
component.package,
component.in_stock
);
}
// Verify that we have loaded the correct number of components.
assert_eq!(loaded_components.len(), 2);
// Verify that the correct components were loaded.
let ids: Vec<String> = loaded_components.iter().map(|c| c.id.clone()).collect();
assert!(ids.contains(&"R1".to_string()));
assert!(ids.contains(&"R2".to_string()));
// Clean up the database file.
std::fs::remove_file(db_path).unwrap();
}
@@ -1,228 +0,0 @@
use heave::catalog::*;
use heave::eav::*;
use heave::filter::*;
use heave::*;
use std::path::Path;
#[derive(PartialEq, Clone)]
struct Laptop {
pub id: String,
pub model: String,
pub price: u32,
}
#[derive(PartialEq, Clone)]
struct Display {
pub id: String,
pub model: String,
pub resolution: f64,
pub price: u32,
}
#[derive(PartialEq, Clone)]
struct Mouse {
pub id: String,
pub model: String,
pub wireless: bool,
pub price: u32,
}
#[derive(PartialEq, Clone)]
enum Product {
None,
Laptop(Laptop),
Display(Display),
Mouse(Mouse),
}
impl EAV for Product {
fn class() -> &'static str {
"product"
}
}
impl From<Entity> for Product {
fn from(value: Entity) -> Self {
if let Some(ref subclass) = value.subclass() {
match subclass.as_ref() {
"laptop" => Product::Laptop(Laptop {
id: value.id().to_string(),
model: value.unwrap("model").expect("model is mandatory"),
price: value.unwrap("price").expect("price is mandatory"),
}),
"display" => Product::Display(Display {
id: value.id().to_string(),
model: value.unwrap("model").expect("model is mandatory"),
price: value.unwrap("price").expect("price is mandatory"),
resolution: value.unwrap("resolution").expect("resolution is mandatory"),
}),
"mouse" => Product::Mouse(Mouse {
id: value.id().to_string(),
model: value.unwrap("model").expect("model is mandatory"),
price: value.unwrap("price").expect("price is mandatory"),
wireless: value.unwrap("wireless").expect("wireless is mandatory"),
}),
_ => unreachable!(),
}
} else {
Product::None
}
}
}
impl From<Product> for Entity {
fn from(value: Product) -> Self {
match value {
Product::Laptop(value) => Entity::new::<Product>()
.with_id(&value.id)
.with_subclass("laptop")
.with_attribute("model", value.model)
.with_attribute("price", value.price),
Product::Display(value) => Entity::new::<Product>()
.with_id(&value.id)
.with_subclass("display")
.with_attribute("model", value.model)
.with_attribute("resolution", value.resolution)
.with_attribute("price", value.price),
Product::Mouse(value) => Entity::new::<Product>()
.with_id(&value.id)
.with_subclass("mouse")
.with_attribute("model", value.model)
.with_attribute("wireless", value.wireless)
.with_attribute("price", value.price),
_ => unreachable!(),
}
}
}
fn main() -> Result<(), FailedTo> {
let db_path = "working_with_many_types.db";
// Clean up previous runs if file exists
if Path::new(db_path).exists() {
std::fs::remove_file(db_path).unwrap();
}
// 1. Initialize and Persist Data
println!("== 1. Storing different product types ==");
let catalog = Catalog::new(db_path);
catalog.init()?;
let products_to_add = vec![
Product::Laptop(Laptop {
id: "laptop_01".to_string(),
model: "Titan".to_string(),
price: 1500,
}),
Product::Display(Display {
id: "display_01".to_string(),
model: "CrystalClear".to_string(),
resolution: 4.0, // 4K
price: 600,
}),
Product::Mouse(Mouse {
id: "mouse_01".to_string(),
model: "SwiftClick".to_string(),
wireless: true,
price: 80,
}),
Product::Laptop(Laptop {
id: "laptop_02".to_string(),
model: "Nomad".to_string(),
price: 950,
}),
];
catalog.insert_many(products_to_add)?;
catalog.persist()?;
println!("✅ 4 products saved to the database.\n");
// 2. Load data using filters
println!("== 2. Loading products using class and subclass filters ==");
// Load only laptops
let laptop_catalog = Catalog::new(db_path);
let laptop_filter = Filter::new()
.with_class(Product::class())
.with_subclass("laptop");
laptop_catalog.load_by_filter(&laptop_filter)?;
let laptops: Vec<Product> = laptop_catalog.list().unwrap().into_iter().collect();
println!("✅ Loaded {} laptop(s) using filter.", laptops.len());
assert_eq!(laptops.len(), 2);
for p in laptops {
if let Product::Laptop(laptop) = p {
println!(
" - Laptop: {} ({}) - ${}",
laptop.id, laptop.model, laptop.price
);
}
}
println!();
println!("== 3. Loading products using attribute filters ==");
// Load expensive products (price > 1000)
let expensive_catalog = Catalog::new(db_path);
let expensive_filter = Filter::new()
.with_class(Product::class())
.with_unsigned_int("price", Comparison::Greater, 1000);
expensive_catalog.load_by_filter(&expensive_filter)?;
let expensive_products: Vec<Product> = expensive_catalog.list().unwrap().into_iter().collect();
println!(
"✅ Loaded {} product(s) with price > $1000.",
expensive_products.len()
);
assert_eq!(expensive_products.len(), 1);
for p in expensive_products {
match p {
Product::Laptop(laptop) => {
println!(
" - Found Laptop: {} ({}) - ${}",
laptop.id, laptop.model, laptop.price
);
assert_eq!(laptop.id, "laptop_01");
}
_ => panic!("Expected a laptop!"),
}
}
println!();
println!("== 4. Loading all product types ==");
let all_products_catalog = Catalog::new(db_path);
let all_products_filter = Filter::new().with_class(Product::class());
all_products_catalog.load_by_filter(&all_products_filter)?;
let all_products: Vec<Product> = all_products_catalog.list().unwrap().into_iter().collect();
println!("✅ Loaded {} total products.", all_products.len());
assert_eq!(all_products.len(), 4);
for p in all_products {
match p {
Product::Laptop(laptop) => {
println!(
" - Found Laptop: {} ({}) - ${}",
laptop.id, laptop.model, laptop.price
);
}
Product::Display(display) => {
println!(
" - Found Display: {} ({}) - ${}",
display.id, display.model, display.price
);
}
Product::Mouse(mouse) => {
println!(
" - Found Mouse: {} ({}) - ${}",
mouse.id, mouse.model, mouse.price
);
}
Product::None => panic!("Product::None should not be loaded"),
}
}
println!();
// Clean up the created database file
std::fs::remove_file(db_path).unwrap();
Ok(())
}
-10
View File
@@ -1,10 +0,0 @@
pub mod sqlite_build_params;
pub mod sqlite_build_statement;
pub mod sqlite_init_db;
pub mod sqlite_load_attributes;
pub mod sqlite_load_by_class;
pub mod sqlite_load_by_filter;
pub mod sqlite_load_by_id;
pub mod sqlite_map_row_to_attribute;
pub mod sqlite_map_row_to_entity;
pub mod sqlite_persist_catalog;
@@ -1,61 +0,0 @@
use crate::*;
use rusqlite::*;
pub fn run<'a>(filter: &'a Filter) -> Result<Vec<Box<dyn ToSql + 'a>>, FailedTo> {
let mut params: Vec<Box<dyn ToSql>> = Vec::new();
for condition in filter.conditions() {
let (name, comparison, condition) = condition;
params.push(Box::new(name));
match (comparison, condition) {
// BOOL
(Comparison::Equal, Condition::Bool(value)) => params.push(Box::new(value)),
// SIGNED INT
(
Comparison::Equal
| Comparison::Greater
| Comparison::Lesser
| Comparison::GreaterOrEqual
| Comparison::LesserOrEqual,
Condition::SignedInt(value),
) => params.push(Box::new(value)),
// UNSIGNED INT
(
Comparison::Equal
| Comparison::Greater
| Comparison::Lesser
| Comparison::GreaterOrEqual
| Comparison::LesserOrEqual,
Condition::UnsignedInt(value),
) => params.push(Box::new(value)),
// REAL
(
Comparison::Equal
| Comparison::Greater
| Comparison::Lesser
| Comparison::GreaterOrEqual
| Comparison::LesserOrEqual,
Condition::Real(value),
) => params.push(Box::new(value)),
// TEXT
(Comparison::IsExactly, Condition::Text(value)) => params.push(Box::new(value)),
(Comparison::StartsWith, Condition::Text(value)) => {
params.push(Box::new(format!("{}%", value)))
}
(Comparison::EndsWith, Condition::Text(value)) => {
params.push(Box::new(format!("%{}", value)))
}
(Comparison::Contains, Condition::Text(value)) => {
params.push(Box::new(format!("%{}%", value)))
}
// ERROR
_ => return Err(FailedTo::ComposeFilter),
}
}
if let Some(value) = filter.class() {
params.push(Box::new(value));
}
if let Some(value) = filter.subclass() {
params.push(Box::new(value));
}
Ok(params)
}
@@ -1,94 +0,0 @@
use crate::*;
const BASE_SELECT: &str = r#"SELECT * FROM entity"#;
const INNER_JOIN_FRAGMENT: &str = r#"
INNER JOIN attribute as attribute_{index}
ON entity.id = attribute_{index}.entity_id
AND attribute_{index}.id = ?{attribute_id_index}
AND attribute_{index}.{field} {op} ?{index}
"#;
const WHERE: &str = r#" WHERE 1=1"#;
fn compose_fragment(field: &str, op: &str, index: usize) -> String {
let attribute_index = index * 2 - 1;
let field_index = index * 2;
INNER_JOIN_FRAGMENT
.replace("{field}", field)
.replace("{op}", op)
.replace("{attribute_id_index}", &attribute_index.to_string())
.replace("{index}", &field_index.to_string())
}
fn from_condition(
i: usize,
comparison: &Comparison,
condition: &Condition,
) -> Result<String, FailedTo> {
let fragment = match (comparison, condition) {
// BOOL
(Comparison::Equal, Condition::Bool(_)) => compose_fragment("value_bool", "=", i),
(_, Condition::Bool(_)) => return Err(FailedTo::ComposeFilter),
// SIGNED INT
(Comparison::Equal, Condition::SignedInt(_)) => compose_fragment("value_int", "=", i),
(Comparison::Greater, Condition::SignedInt(_)) => compose_fragment("value_int", ">", i),
(Comparison::Lesser, Condition::SignedInt(_)) => compose_fragment("value_int", "<", i),
(Comparison::GreaterOrEqual, Condition::SignedInt(_)) => {
compose_fragment("value_int", ">=", i)
}
(Comparison::LesserOrEqual, Condition::SignedInt(_)) => {
compose_fragment("value_int", "<=", i)
}
(_, Condition::SignedInt(_)) => return Err(FailedTo::ComposeFilter),
// UNSIGNED INT
(Comparison::Equal, Condition::UnsignedInt(_)) => compose_fragment("value_uint", "=", i),
(Comparison::Greater, Condition::UnsignedInt(_)) => compose_fragment("value_uint", ">", i),
(Comparison::Lesser, Condition::UnsignedInt(_)) => compose_fragment("value_uint", "<", i),
(Comparison::GreaterOrEqual, Condition::UnsignedInt(_)) => {
compose_fragment("value_uint", ">=", i)
}
(Comparison::LesserOrEqual, Condition::UnsignedInt(_)) => {
compose_fragment("value_uint", "<=", i)
}
(_, Condition::UnsignedInt(_)) => return Err(FailedTo::ComposeFilter),
// REAL
(Comparison::Equal, Condition::Real(_)) => compose_fragment("value_real", "=", i),
(Comparison::Greater, Condition::Real(_)) => compose_fragment("value_real", ">", i),
(Comparison::Lesser, Condition::Real(_)) => compose_fragment("value_real", "<", i),
(Comparison::GreaterOrEqual, Condition::Real(_)) => compose_fragment("value_real", ">=", i),
(Comparison::LesserOrEqual, Condition::Real(_)) => compose_fragment("value_real", "<=", i),
(_, Condition::Real(_)) => return Err(FailedTo::ComposeFilter),
// TEXT
(Comparison::IsExactly, Condition::Text(_)) => compose_fragment("value_text", "=", i),
(
Comparison::StartsWith | Comparison::EndsWith | Comparison::Contains,
Condition::Text(_),
) => compose_fragment("value_text", "LIKE", i),
(_, Condition::Text(_)) => return Err(FailedTo::ComposeFilter),
};
Ok(fragment)
}
pub fn run(filter: &Filter) -> Result<String, FailedTo> {
// base statement
let mut statement = String::from(BASE_SELECT);
let mut idx = 0;
// for each condition add an inner join fragment
for (i, (_, comparison, condition)) in filter.conditions().enumerate() {
idx = i + 1;
let fragment = from_condition(idx, comparison, condition)?;
statement.push_str(&fragment);
}
// add a neutral where condition
statement.push_str(WHERE);
if filter.class().is_some() {
idx += 1;
let fragment = format!(" AND entity.class = ?{}", idx);
statement.push_str(&fragment);
}
if filter.subclass().is_some() {
idx += 1;
let fragment = format!(" AND entity.subclass = ?{}", idx);
statement.push_str(&fragment);
}
Ok(statement)
}
@@ -1,33 +0,0 @@
use crate::*;
use rusqlite::*;
pub fn run(path: &path::Path) -> result::Result<(), FailedTo> {
let init_statement = r#"
CREATE TABLE IF NOT EXISTS entity (
id TEXT PRIMARY KEY,
class TEXT NOT NULL,
subclass TEXT,
ref_date INTEGER
);
CREATE TABLE IF NOT EXISTS attribute (
id TEXT,
entity_id TEXT,
value_int INTEGER,
value_uint INTEGER,
value_real REAL,
value_text TEXT,
value_bool BOOL,
CONSTRAINT pk_id PRIMARY KEY (id, entity_id),
CONSTRAINT fk_entity_id FOREIGN KEY (entity_id) REFERENCES entity (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS entity_class ON entity (class);
CREATE INDEX IF NOT EXISTS entity_subclass ON entity (subclass);
CREATE INDEX IF NOT EXISTS attribute_id ON attribute (id);
CREATE INDEX IF NOT EXISTS attribute_entity_id ON attribute (entity_id);
"#;
let connection = Connection::open(path).map_err(sqlite::FailedTo::OpenConnection)?;
connection
.execute_batch(init_statement)
.map_err(sqlite::FailedTo::ExecuteBatch)?;
Ok(())
}
@@ -1,29 +0,0 @@
use crate::*;
use rusqlite::*;
const SELECT_ATTRIBUTE_BY_FK: &str = r#"
SELECT
id,
entity_id,
value_int,
value_uint,
value_real,
value_text,
value_bool
FROM attribute
WHERE entity_id = ?1;
"#;
pub fn run(transaction: &Transaction, entity: &mut Entity) -> Result<(), FailedTo> {
let mut select_attributes_statement = transaction
.prepare(SELECT_ATTRIBUTE_BY_FK)
.map_err(sqlite::FailedTo::PrepareStatement)?;
let attributes = select_attributes_statement
.query_map([&entity.id], sqlite::map::row_to_attribute)
.map_err(sqlite::FailedTo::ExecuteQuery)?;
for attribute in attributes {
let attribute = attribute.map_err(|_| FailedTo::MapAttribute)?;
entity.attributes.insert(attribute.id.clone(), attribute);
}
Ok(())
}
@@ -1,29 +0,0 @@
use crate::*;
use rusqlite::*;
const SELECT_ENTITY_BY_CLASS: &str = r#"
SELECT id, class, subclass, ref_date FROM entity
WHERE class = ?1;
"#;
pub fn run(path: &path::Path, entity_class: &str) -> Result<Vec<Entity>, FailedTo> {
let mut entities = Vec::<Entity>::new();
let mut connection = Connection::open(path).map_err(sqlite::FailedTo::OpenConnection)?;
let mut transaction = connection
.transaction()
.map_err(sqlite::FailedTo::BeginTransaction)?;
transaction.set_drop_behavior(DropBehavior::Commit);
let mut statement = transaction
.prepare(SELECT_ENTITY_BY_CLASS)
.map_err(sqlite::FailedTo::PrepareStatement)?;
let result = statement
.query_map([entity_class], sqlite::map::row_to_entity)
.map_err(sqlite::FailedTo::ExecuteQuery)?;
for entity in result {
let mut entity = entity.map_err(|_| FailedTo::MapEntity)?;
sqlite::load::attributes(&transaction, &mut entity)?;
entity.state = EntityState::Loaded;
entities.push(entity);
}
Ok(entities)
}
@@ -1,27 +0,0 @@
use crate::*;
use rusqlite::*;
pub fn run(path: &path::Path, filter: &Filter) -> Result<Vec<Entity>, FailedTo> {
let mut entities = Vec::<Entity>::new();
let mut connection = Connection::open(path).map_err(sqlite::FailedTo::OpenConnection)?;
let mut transaction = connection
.transaction()
.map_err(sqlite::FailedTo::BeginTransaction)?;
transaction.set_drop_behavior(DropBehavior::Commit);
let select_entity_by_filter = sqlite::build::statement(filter)?;
let params = sqlite::build::params(filter)?;
let params: Vec<&dyn ToSql> = params.iter().map(|p| p.as_ref() as &dyn ToSql).collect();
let mut statement = transaction
.prepare(&select_entity_by_filter)
.map_err(sqlite::FailedTo::PrepareStatement)?;
let result = statement
.query_map(&params[..], sqlite::map::row_to_entity)
.map_err(sqlite::FailedTo::ExecuteQuery)?;
for entity in result {
let mut entity = entity.map_err(|_| FailedTo::MapEntity)?;
sqlite::load::attributes(&transaction, &mut entity)?;
entity.state = EntityState::Loaded;
entities.push(entity);
}
Ok(entities)
}
@@ -1,24 +0,0 @@
use crate::*;
use rusqlite::*;
const SELECT_ENTITY_BY_ID: &str = r#"
SELECT id, class, subclass, ref_date FROM entity
WHERE id = ?1;
"#;
pub fn run(path: &path::Path, entity_id: &str) -> Result<Option<Entity>, FailedTo> {
let mut connection = Connection::open(path).map_err(sqlite::FailedTo::OpenConnection)?;
let mut transaction = connection
.transaction()
.map_err(sqlite::FailedTo::BeginTransaction)?;
transaction.set_drop_behavior(DropBehavior::Commit);
let mut entity = transaction
.query_one(SELECT_ENTITY_BY_ID, [entity_id], sqlite::map::row_to_entity)
.optional()
.map_err(sqlite::FailedTo::ExecuteQuery)?;
if let Some(ref mut entity) = entity {
sqlite::load::attributes(&transaction, entity)?;
entity.state = EntityState::Loaded;
}
Ok(entity)
}
@@ -1,21 +0,0 @@
use crate::*;
pub fn run(row: &rusqlite::Row) -> rusqlite::Result<Attribute> {
let id: String = row.get(0)?;
let signed_int: Option<i64> = row.get(2)?;
let unsigned_int: Option<u32> = row.get(3)?;
let real: Option<f64> = row.get(4)?;
let text: Option<String> = row.get(5)?;
let bool: Option<bool> = row.get(6)?;
let value: Value = match (signed_int, unsigned_int, real, text, bool) {
(Some(value), None, None, None, None) => Value::SignedInt(value),
(None, Some(value), None, None, None) => Value::UnsignedInt(value),
(None, None, Some(value), None, None) => Value::Real(value),
(None, None, None, Some(value), None) => Value::Text(value),
(None, None, None, None, Some(value)) => Value::Bool(value),
_ => {
return Err(rusqlite::types::FromSqlError::InvalidType.into());
}
};
Ok(Attribute { id, value })
}
@@ -1,17 +0,0 @@
use crate::*;
pub fn run(row: &rusqlite::Row) -> rusqlite::Result<Entity> {
let id: String = row.get(0)?;
let class: String = row.get(1)?;
let subclass: Option<String> = row.get(2)?;
let ref_date: Option<u32> = row.get(3)?;
let entity = Entity {
id,
state: EntityState::Loaded,
class,
subclass,
ref_date,
attributes: std::collections::HashMap::new(),
};
Ok(entity)
}
@@ -1,88 +0,0 @@
use crate::*;
use collections::*;
use rusqlite::*;
fn column(value: &Value) -> &'static str {
match value {
Value::SignedInt(_) => "value_int",
Value::UnsignedInt(_) => "value_uint",
Value::Real(_) => "value_real",
Value::Text(_) => "value_text",
Value::Bool(_) => "value_bool",
}
}
const DELETE_ENTITY_STATEMENT: &str = r#"
DELETE FROM entity
WHERE entity.id = ?1;
"#;
const INSERT_ENTITY_STATEMENT: &str = r#"
INSERT INTO entity (id, class, subclass, ref_date)
VALUES (?1, ?2, ?3, ?4);
"#;
const INSERT_ATTRIBUTE_STATEMENT_TEMPLATE: &str = r#"
INSERT INTO attribute (id, entity_id, {column})
VALUES (?1, ?2, ?3);
"#;
fn write_attribute(
attribute: &Attribute,
entity: &Entity,
transaction: &rusqlite::Transaction,
) -> result::Result<(), FailedTo> {
let column = column(&attribute.value);
let attribute_values = (&attribute.id, &entity.id, &attribute.value);
let insert_attribute_statement =
INSERT_ATTRIBUTE_STATEMENT_TEMPLATE.replace("{column}", column);
transaction
.execute(&insert_attribute_statement, attribute_values)
.map_err(sqlite::FailedTo::ExecuteStatement)?;
Ok(())
}
fn delete_entity(entity: &Entity, transaction: &rusqlite::Transaction) -> Result<(), FailedTo> {
let entity_id = [&entity.id];
transaction
.execute(DELETE_ENTITY_STATEMENT, entity_id)
.map_err(sqlite::FailedTo::ExecuteStatement)?;
Ok(())
}
fn write_entity(entity: &Entity, transaction: &rusqlite::Transaction) -> Result<(), FailedTo> {
let entity_id = [&entity.id];
let entity_values = (&entity.id, &entity.class, &entity.subclass, entity.ref_date);
transaction
.execute(DELETE_ENTITY_STATEMENT, entity_id)
.map_err(sqlite::FailedTo::ExecuteStatement)?;
transaction
.execute(INSERT_ENTITY_STATEMENT, entity_values)
.map_err(sqlite::FailedTo::ExecuteStatement)?;
for attribute in entity.attributes.values() {
write_attribute(attribute, entity, transaction)?;
}
Ok(())
}
pub fn run(path: &path::Path, items: &HashMap<String, Entity>) -> result::Result<(), FailedTo> {
let mut connection = Connection::open(path).map_err(sqlite::FailedTo::OpenConnection)?;
let transaction = connection
.transaction()
.map_err(sqlite::FailedTo::BeginTransaction)?;
for entity in items.values().filter(|item| {
item.state == EntityState::New
|| item.state == EntityState::Updated
|| item.state == EntityState::ToDelete
}) {
match entity.state {
EntityState::New | EntityState::Updated => write_entity(entity, &transaction)?,
EntityState::ToDelete => delete_entity(entity, &transaction)?,
_ => unreachable!(),
}
}
transaction
.commit()
.map_err(sqlite::FailedTo::CommitTransaction)?;
Ok(())
}
@@ -1,11 +0,0 @@
use crate::*;
impl TryFrom<Value> for bool {
type Error = ();
fn try_from(value: Value) -> Result<bool, Self::Error> {
match value {
Value::Bool(value) => Ok(value),
_ => Err(()),
}
}
}
@@ -1,22 +0,0 @@
use crate::*;
impl Catalog {
/// Checks if the catalog contains an entity with the specified ID.
///
/// # Arguments
///
/// * `id` - The ID to check for.
///
/// # Returns
///
/// `Ok(true)` if an entity with the specified ID exists in the catalog,
/// `Ok(false)` otherwise.
///
/// # Errors
///
/// Returns `Err(FailedTo::LockCatalog)` if the catalog's internal mutex
/// could not be locked.
pub fn contains_key(&self, id: &str) -> Result<bool, FailedTo> {
self.with_items(|items| Ok(items.contains_key(id)))
}
}
@@ -1,28 +0,0 @@
use crate::*;
impl Catalog {
/// Marks an entity for deletion from the in-memory catalog.
///
/// This is a purely in-memory operation. The entity will not be removed from the
/// database until `persist()` is called.
///
/// # Effects
///
/// - **In-Memory State:** If the entity exists, its state is set to
/// `EntityState::ToDelete`. If it was in a `New` state, it will simply be
/// forgotten upon the next `persist()` call without ever touching the database.
/// - **Database State:** Unchanged. Call `persist()` to apply the deletion.
///
/// # Arguments
///
/// * `id` - The ID of the entity to mark for deletion.
pub fn delete(&self, id: &str) -> Result<(), FailedTo> {
self.on_items(|items| {
let entity = items.get_mut(id);
if let Some(entity) = entity {
entity.state = EntityState::ToDelete;
}
Ok(())
})
}
}
@@ -1,30 +0,0 @@
use crate::*;
impl Catalog {
/// Ensures the database is initialized, performing the operation only once.
///
/// This method provides an idempotent way to initialize the database. It's safe to
/// call multiple times, but the actual initialization logic will only run if it
/// hasn't already succeeded.
///
/// # Effects
///
/// - **Database State:** On the first successful call, this method creates the
/// SQLite file and schema if they don't exist. Subsequent calls have no effect.
/// - **In-Memory State:** On the first successful call, sets an internal flag to
/// prevent future initializations. This method does not alter the in-memory
/// entity cache.
///
/// # Errors
///
/// Returns `Err(FailedTo::InitDatabase)` if the underlying call to `init()`
/// fails. This can only happen on the first attempt.
pub fn ensure_init(&mut self) -> Result<(), FailedTo> {
if self.already_init {
return Ok(());
}
self.init()?;
self.already_init = true;
Ok(())
}
}
@@ -1,36 +0,0 @@
use crate::*;
impl Catalog {
/// Iterates over each item in the catalog that can be converted into type `T` and applies a predicate function.
///
/// This method allows read-only iteration over entities. The predicate receives an immutable reference
/// to the item.
///
/// # Type Parameters
///
/// * `T`: The target type that entities should be converted into. Must implement `EAV`.
/// * `F`: The type of the predicate function.
///
/// # Arguments
///
/// * `predicate`: A mutable closure that takes an immutable reference to an item of type `T`.
///
/// # Returns
///
/// A `Result` indicating success (`Ok(())`) or failure (`Err(FailedTo::LockCatalog)`).
/// Entities that cannot be converted to type `T` are silently skipped from the iteration
/// and do not cause this function to return an error.
pub fn for_each<T, F>(&self, mut predicate: F) -> Result<(), FailedTo>
where
T: EAV,
F: FnMut(&T),
{
self.with_items(|items| {
items
.values()
.flat_map(|entity| T::try_from(entity.clone()).map_err(|_| FailedTo::ConvertEntity))
.for_each(|item| predicate(&item));
Ok(())
})
}
}
@@ -1,48 +0,0 @@
use crate::*;
impl Catalog {
/// Iterates over each item in the catalog that can be converted into type `T` and applies a mutable predicate function.
///
/// This method allows mutable iteration over entities. If the item is modified by the predicate,
/// its state in the catalog will be marked as `EntityState::Updated`.
///
/// # Type Parameters
///
/// * `T`: The target type that entities should be converted into. Must implement `EAV`.
/// * `F`: The type of the predicate function.
///
/// # Arguments
///
/// * `predicate`: A mutable closure that takes a mutable reference to an item of type `T` and returns a `Result<(), Box<dyn error::Error>>`. If the predicate returns an `Err`, it will be mapped to `FailedTo::ExecutePredicate`.
///
/// # Returns
///
/// A `Result` indicating success (`Ok(())`) or failure (`Err(FailedTo)`).
pub fn for_each_mut<T, F>(&self, mut predicate: F) -> Result<(), FailedTo>
where
T: EAV,
F: FnMut(&mut T) -> Result<(), Box<dyn error::Error>>,
{
self.on_items(|items| {
let mut errors: Vec<Box<dyn error::Error>> = Vec::new();
for entity in items.values_mut() {
let original_item =
T::try_from(entity.clone()).map_err(|_| FailedTo::ConvertEntity)?;
let mut item = original_item.clone();
let result = predicate(&mut item);
if let Err(e) = result {
errors.push(e);
}
if item != original_item {
*entity = T::try_into(item).map_err(|_| FailedTo::ConvertObject)?;
entity.state = EntityState::Updated;
}
}
if errors.is_empty() {
Ok(())
} else {
Err(FailedTo::ExecutePredicate(errors))
}
})
}
}
-35
View File
@@ -1,35 +0,0 @@
use crate::*;
impl Catalog {
/// Retrieves an entity by its ID from the in-memory catalog.
///
/// This is a purely in-memory operation and does not interact with the database.
/// It will only find entities that have been loaded into or created in the catalog.
///
/// # Arguments
///
/// * `id` - The ID of the entity to retrieve.
///
/// # Returns
///
/// An `Option<T>` containing the converted entity if found in the in-memory
/// cache, otherwise `None`.
///
/// # Errors
///
/// Returns `Err(FailedTo::ConvertEntity)` if the retrieved entity cannot be
/// converted into the specified type `T`. This typically happens if the
/// entity's data structure in the catalog does not match the expected
/// structure of `T`.
pub fn get<T>(&self, id: &str) -> Result<Option<T>, FailedTo>
where
T: EAV, // T must implement the EAV trait to allow conversion from an Entity
{
self.with_items(|items| {
let entity = items.get(id);
entity
.map(|e| T::try_from(e.clone()).map_err(|_| FailedTo::ConvertEntity))
.transpose()
})
}
}
@@ -1,45 +0,0 @@
use crate::*;
impl Catalog {
/// Retrieves the first entity from the in-memory catalog that satisfies a given predicate.
///
/// This is a purely in-memory operation and does not interact with the database.
/// It iterates through all entities currently loaded in the catalog, attempts to
/// convert each to the specified type `T`, and then applies the provided
/// `predicate` function. The first entity that successfully converts and
/// satisfies the predicate is returned.
///
/// Entities that fail to convert to type `T` are silently skipped and do not
/// cause an error to be returned by this function.
///
/// # Type Parameters
///
/// * `T` - The target type to convert the entity into, which must implement the `EAV` trait.
/// * `F` - The type of the predicate closure, which takes a reference to `T` and returns a `bool`.
///
/// # Arguments
///
/// * `predicate` - A closure that defines the condition an entity must meet to be returned.
///
/// # Returns
///
/// An `Ok(Some(T))` containing the first converted entity that satisfies the predicate,
/// or `Ok(None)` if no such entity is found or if all matching entities fail conversion.
///
/// # Errors
///
/// Returns `Err(FailedTo)` if an internal error occurs during the `with_items` operation
/// (e.g., mutex poisoning).
pub fn get_by<T, F>(&self, predicate: F) -> Result<Option<T>, FailedTo>
where
T: EAV,
F: Fn(&T) -> bool,
{
self.with_items(|items| {
Ok(items
.values()
.flat_map(|entity| T::try_from(entity.clone()))
.find(|item| predicate(item)))
})
}
}
@@ -1,28 +0,0 @@
use crate::*;
impl Catalog {
/// Initializes the database by creating the file and schema if they don't exist.
///
/// This method interacts with the filesystem to ensure the database file and its
/// underlying tables are ready. It has no effect on the in-memory state of the
/// catalog.
///
/// # Effects
///
/// - **Database State:** Creates the SQLite file and required tables if they are not
/// present. If the file already exists, it does nothing.
/// - **In-Memory State:** This method does not alter the in-memory entity cache.
///
/// # Errors
///
/// Returns `Err(FailedTo::InitDatabase)` if there is an issue creating the
/// database file or initializing its schema.
pub fn init(&self) -> result::Result<(), FailedTo> {
let path = path::Path::new(&self.path);
sqlite::init::db(path).map_err(|_| FailedTo::InitDatabase)?;
Ok(())
}
}
// #[cfg(test)]
// mod unit_tests { use super::*; }
@@ -1,31 +0,0 @@
use crate::*;
impl Catalog {
/// Inserts or updates multiple objects in the in-memory catalog.
///
/// This method calls `upsert()` for each object in the provided vector. Like
/// `upsert()`, this is a purely in-memory operation.
///
/// # Effects
///
/// - **In-Memory State:** Adds or updates multiple entities in the cache.
/// - **Database State:** Unchanged. Call `persist()` to save the changes.
///
/// # Arguments
///
/// * `objects` - A vector of objects to insert or update.
///
/// # Errors
///
/// Returns `Err(FailedTo)` if any of the underlying `upsert` operations fail.
/// This could be due to issues like an object not having a valid ID.
pub fn insert_many(&self, objects: Vec<impl EAV>) -> Result<(), FailedTo> {
for object in objects {
self.upsert(object)?;
}
Ok(())
}
}
// #[cfg(test)]
// mod unit_tests { use super::*; }
@@ -1,17 +0,0 @@
use crate::*;
impl Catalog {
/// Checks if the catalog is empty.
///
/// # Returns
///
/// `Ok(true)` if the catalog contains no entities, `Ok(false)` otherwise.
///
/// # Errors
///
/// Returns `Err(FailedTo::LockCatalog)` if the catalog's internal mutex
/// could not be locked.
pub fn is_empty(&self) -> Result<bool, FailedTo> {
self.with_items(|items| Ok(items.is_empty()))
}
}
-17
View File
@@ -1,17 +0,0 @@
use crate::*;
impl Catalog {
/// Returns the number of entities in the catalog.
///
/// # Returns
///
/// The total number of entities currently held in the catalog's memory.
///
/// # Errors
///
/// Returns `Err(FailedTo::LockCatalog)` if the catalog's internal mutex
/// could not be locked.
pub fn len(&self) -> Result<usize, FailedTo> {
self.with_items(|items| Ok(items.len()))
}
}
@@ -1,32 +0,0 @@
use crate::*;
impl Catalog {
/// Returns a list of all entities of a specific class from the in-memory catalog.
///
/// This method filters the in-memory entities by the class of type `T` and
/// attempts to convert them into `T`. This is a purely in-memory operation
/// and does not interact with the database.
///
/// # Returns
///
/// A `Result` containing a `Vec<T>` of entities that were successfully
/// converted to type `T`. Entities that fail conversion are silently
/// skipped and not included in the returned vector.
///
/// # Errors
///
/// Returns `Err(FailedTo::LockCatalog)` if the catalog's internal mutex
/// could not be locked.
pub fn list<T>(&self) -> Result<Vec<T>, FailedTo>
where
T: EAV,
{
self.with_items(|items| {
Ok(items
.values()
.filter(move |item| item.class == T::class())
.filter_map(|item| T::try_from(item.clone()).ok())
.collect())
})
}
}
@@ -1,37 +0,0 @@
use crate::*;
impl Catalog {
/// Returns a list of all entities of a specific subclass from the in-memory catalog.
///
/// This method filters the in-memory entities by the class of type `T` and the
/// provided `subclass`, then attempts to convert them into `T`. This is a purely
/// in-memory operation and does not interact with the database.
///
/// # Arguments
///
/// * `subclass` - The subclass to filter by.
///
/// # Returns
///
/// A `Result` containing a `Vec<T>` of entities that were successfully
/// converted to type `T`. Entities that fail conversion are silently
/// skipped and not included in the returned vector.
///
/// # Errors
///
/// Returns `Err(FailedTo::LockCatalog)` if the catalog's internal mutex
/// could not be locked.
pub fn list_by_subclass<T>(&self, subclass: &str) -> Result<Vec<T>, FailedTo>
where
T: EAV,
{
self.with_items(|items| {
Ok(items
.values()
.filter(move |item| item.class == T::class())
.filter(move |item| item.subclass.as_deref() == Some(subclass))
.filter_map(|item| T::try_from(item.clone()).ok())
.collect())
})
}
}
@@ -1,40 +0,0 @@
use crate::*;
impl Catalog {
/// Loads all entities of a specific type from the database into the in-memory catalog.
///
/// This method fetches all entities matching the given class from the database.
/// If any of the loaded entities have IDs that match entities already in the
/// in-memory catalog, the in-memory versions will be **overwritten**.
///
/// # Effects
///
/// - **In-Memory State:**
/// - All found entities are inserted or updated in the in-memory cache with the
/// state `EntityState::Loaded`.
/// - Any existing in-memory entities with matching IDs are replaced.
/// - **Database State:** Unchanged.
///
/// # Type Parameters
///
/// * `T` - The type of the entity to load, which must implement the `EAV` trait.
///
/// # Errors
///
/// Returns `Err(FailedTo::LoadFromDB)` if there is an issue loading entities
/// from the database.
pub fn load<T>(&self) -> Result<(), FailedTo>
where
T: EAV,
{
let class = T::class();
let path = path::Path::new(&self.path);
self.on_items(|items| {
let entities = sqlite::load::by_class(path, class).map_err(|_| FailedTo::LoadFromDB)?;
for entity in entities {
items.insert(entity.id.clone(), entity);
}
Ok(())
})
}
}
@@ -1,45 +0,0 @@
use crate::*;
impl Catalog {
/// Loads entities from the database that match a given `Filter`.
///
/// This method queries the database for all entities that satisfy the conditions
/// specified in the filter. If any of the loaded entities have IDs that match
/// entities already in the in-memory catalog, the in-memory versions will be
/// **overwritten**.
///
/// **Warning:** The filter is applied at the attribute level. If different entity
/// classes share attribute names, this method may load entities of multiple
/// classes.
///
/// # Effects
///
/// - **In-Memory State:**
/// - All found entities are inserted or updated in the in-memory cache with the
/// state `EntityState::Loaded`.
/// - Any existing in-memory entities with matching IDs are replaced.
/// - **Database State:** Unchanged.
///
/// # Arguments
///
/// * `filter` - The `Filter` to apply when loading entities.
///
/// # Errors
///
/// Returns `Err(FailedTo::LoadFromDB)` if there is an issue loading entities
/// from the database based on the provided filter.
pub fn load_by_filter(&self, filter: &Filter) -> Result<(), FailedTo> {
let path = path::Path::new(&self.path);
self.on_items(|items| {
let entities =
sqlite::load::by_filter(path, filter).map_err(|_| FailedTo::LoadFromDB)?;
for entity in entities {
items.insert(entity.id.clone(), entity);
}
Ok(())
})
}
}
// #[cfg(test)]
// mod unit_tests { use super::*; }
@@ -1,38 +0,0 @@
use crate::*;
impl Catalog {
/// Loads a single entity by its ID from the database into the in-memory catalog.
///
/// This method fetches the entity from the database and updates the in-memory
/// catalog. If an entity with the same ID already exists in memory, it will be
/// **overwritten** with the version from the database.
///
/// # Effects
///
/// - **In-Memory State:**
/// - If an entity is found in the database, it is inserted or updated in the
/// in-memory cache with the state `EntityState::Loaded`.
/// - Any existing in-memory entity with the same ID, regardless of its state
/// (`New`, `Updated`), will be replaced.
/// - If no entity is found in the database, the in-memory cache is not modified.
/// - **Database State:** Unchanged.
///
/// # Arguments
///
/// * `id` - The ID of the entity to load.
///
/// # Errors
///
/// Returns `Err(FailedTo::LoadFromDB)` if there is an issue loading the entity
/// from the database.
pub fn load_by_id(&self, id: &str) -> Result<(), FailedTo> {
let path = path::Path::new(&self.path);
self.on_items(|items| {
let entity = sqlite::load::by_id(path, id).map_err(|_| FailedTo::LoadFromDB)?;
if let Some(entity) = entity {
items.insert(entity.id.clone(), entity);
}
Ok(())
})
}
}
@@ -1,40 +0,0 @@
use crate::*;
impl Catalog {
/// Persists all in-memory changes to the database and updates the in-memory state.
///
/// This method synchronizes the state of the in-memory catalog with the database
/// by writing all pending changes (new, updated, and deleted entities).
///
/// # Effects
///
/// - **Database State:**
/// - Entities marked as `EntityState::New` are inserted into the database.
/// - Entities marked as `EntityState::Updated` are updated in the database.
/// - Entities marked as `EntityState::ToDelete` are deleted from the database.
///
/// - **In-Memory State:**
/// - After a successful database write, entities marked `ToDelete` are permanently
/// removed from the in-memory catalog.
/// - All other entities that were successfully persisted (new or updated) have
/// their state changed to `EntityState::Loaded`.
///
/// # Errors
///
/// Returns `Err(FailedTo::PersistCatalog)` if there is an issue writing the
/// changes to the database.
pub fn persist(&self) -> result::Result<(), FailedTo> {
let path = path::Path::new(&self.path);
self.on_items(|items| {
sqlite::persist::catalog(path, items).map_err(|_| FailedTo::PersistCatalog)?;
// cleaning catalog state after db write
let _: Vec<_> = items
.extract_if(|_, item| item.state == EntityState::ToDelete)
.collect();
items
.values_mut()
.for_each(|item| item.state = EntityState::Loaded);
Ok(())
})
}
}
@@ -1,39 +0,0 @@
use crate::*;
impl Catalog {
/// Inserts or updates an object in the in-memory catalog.
///
/// This is a purely in-memory operation. The change will not be written to the
/// database until `persist()` is called.
///
/// # Effects
///
/// - **In-Memory State:**
/// - If the entity does not exist in the catalog, it is added with the state
/// `EntityState::New`.
/// - If the entity already exists, it is overwritten and its state is set to
/// `EntityState::Updated`.
/// - **Database State:** Unchanged. Call `persist()` to save the changes.
///
/// # Arguments
///
/// * `object` - The object to insert or update, which must implement the `EAV` trait.
///
/// # Errors
///
/// Returns `Err(FailedTo::ConvertObject)` if the provided `object` cannot be
/// converted into an `Entity`. This typically indicates an issue with the
/// object's structure or missing required fields for entity conversion.
pub fn upsert(&self, object: impl EAV) -> Result<(), FailedTo> {
let mut entity = object.try_into().map_err(|_| FailedTo::ConvertObject)?;
self.on_items(|items| {
if items.contains_key(&entity.id) {
entity.state = EntityState::Updated;
} else {
entity.state = EntityState::New;
}
items.insert(entity.id.clone(), entity);
Ok(())
})
}
}
@@ -1,11 +0,0 @@
use crate::*;
impl TryFrom<Value> for f64 {
type Error = ();
fn try_from(value: Value) -> Result<f64, Self::Error> {
match value {
Value::Real(value) => Ok(value),
_ => Err(()),
}
}
}
@@ -1,11 +0,0 @@
use crate::*;
impl TryFrom<Value> for i32 {
type Error = ();
fn try_from(value: Value) -> Result<i32, Self::Error> {
match value {
Value::SignedInt(value) => value.try_into().map_err(|_| ()),
_ => Err(()),
}
}
}
@@ -1,11 +0,0 @@
use crate::*;
impl TryFrom<Value> for i64 {
type Error = ();
fn try_from(value: Value) -> Result<i64, Self::Error> {
match value {
Value::SignedInt(value) => Ok(value),
_ => Err(()),
}
}
}
-32
View File
@@ -1,32 +0,0 @@
pub mod bool_try_from_value;
pub mod catalog_contains_key;
pub mod catalog_delete;
pub mod catalog_ensure_init;
pub mod catalog_for_each;
pub mod catalog_for_each_mut;
pub mod catalog_get;
pub mod catalog_get_by;
pub mod catalog_init;
pub mod catalog_insert_many;
pub mod catalog_is_empty;
pub mod catalog_len;
pub mod catalog_list;
pub mod catalog_list_by_subclass;
pub mod catalog_load;
pub mod catalog_load_by_filter;
pub mod catalog_load_by_id;
pub mod catalog_persist;
pub mod catalog_upsert;
pub mod f64_try_from_value;
pub mod i32_try_from_value;
pub mod i64_try_from_value;
pub mod string_try_from_value;
pub mod to_sql_value;
pub mod u32_try_from_value;
pub mod value_from_bool;
pub mod value_from_f64;
pub mod value_from_i32;
pub mod value_from_i64;
pub mod value_from_str;
pub mod value_from_string;
pub mod value_from_u32;
@@ -1,11 +0,0 @@
use crate::*;
impl TryFrom<Value> for String {
type Error = ();
fn try_from(value: Value) -> Result<String, Self::Error> {
match value {
Value::Text(value) => Ok(value),
_ => Err(()),
}
}
}
@@ -1,13 +0,0 @@
use crate::*;
impl rusqlite::ToSql for Value {
fn to_sql(&self) -> std::result::Result<rusqlite::types::ToSqlOutput<'_>, rusqlite::Error> {
match self {
Value::Bool(value) => Ok(rusqlite::types::ToSqlOutput::from(*value as i64)),
Value::Real(value) => Ok(rusqlite::types::ToSqlOutput::from(*value)),
Value::SignedInt(value) => Ok(rusqlite::types::ToSqlOutput::from(*value)),
Value::Text(value) => Ok(rusqlite::types::ToSqlOutput::from(value.to_string())),
Value::UnsignedInt(value) => Ok(rusqlite::types::ToSqlOutput::from(*value as i64)),
}
}
}
@@ -1,11 +0,0 @@
use crate::*;
impl TryFrom<Value> for u32 {
type Error = ();
fn try_from(value: Value) -> Result<u32, Self::Error> {
match value {
Value::UnsignedInt(value) => Ok(value),
_ => Err(()),
}
}
}
@@ -1,7 +0,0 @@
use crate::*;
impl From<bool> for Value {
fn from(value: bool) -> Self {
Self::Bool(value)
}
}
@@ -1,7 +0,0 @@
use crate::*;
impl From<f64> for Value {
fn from(value: f64) -> Self {
Self::Real(value)
}
}
@@ -1,7 +0,0 @@
use crate::*;
impl From<i32> for Value {
fn from(value: i32) -> Self {
Self::SignedInt(value.into())
}
}
@@ -1,7 +0,0 @@
use crate::*;
impl From<i64> for Value {
fn from(value: i64) -> Self {
Self::SignedInt(value)
}
}
@@ -1,7 +0,0 @@
use crate::*;
impl From<&str> for Value {
fn from(value: &str) -> Self {
Self::Text(String::from(value))
}
}
@@ -1,7 +0,0 @@
use crate::*;
impl From<String> for Value {
fn from(value: String) -> Self {
Self::Text(value)
}
}
@@ -1,7 +0,0 @@
use crate::*;
impl From<u32> for Value {
fn from(value: u32) -> Self {
Self::UnsignedInt(value)
}
}
-251
View File
@@ -1,251 +0,0 @@
#![warn(missing_docs)]
//! A lightweight and intuitive Entity-Attribute-Value (EAV) database library for Rust.
//!
//! This library provides a simple and thread safe way to persist and query semi-structured data using an EAV model on top of a SQLite database. It is designed to be easy to use, with a focus on simplicity and developer experience.
//!
//! ## What is the Entity-Attribute-Value (EAV) Model?
//!
//! EAV is a data model that stores data as three-part tuples:
//!
//! - **Entity:** The object being described (e.g., a specific product, a user).
//! - **Attribute:** A property of the entity (e.g., "color", "price", "email").
//! - **Value:** The value of the attribute for that entity (e.g., "blue", 19.99, "user@example.com").
//!
//! This model is highly flexible, allowing you to add new attributes to entities without changing the underlying database schema. It's particularly useful for scenarios with sparse data (where many attributes are optional) or when the data structure is expected to evolve over time.
//!
//! ## How `heave` Works
//!
//! The library revolves around a few key components:
//!
//! - **`EAV` Trait:** Any struct you want to store in the database must implement this trait. It provides the necessary conversions between your custom type and the generic `Entity` representation used by the library.
//! - **`Catalog`:** The main entry point for interacting with the database. It manages a connection to a SQLite file and holds an in-memory cache of entities you are working with.
//! - **`Entity`:** A generic container for an object's data, holding its ID, class (type), and a map of attributes to `Value`s.
//! - **`Value`:** An enum that can represent different data types (e.g., `String`, `i64`, `f64`, `bool`).
//! - **`Filter`:** A builder for creating complex queries to load data from the database based on specific conditions.
//!
//! ## Usage Example
//!
//! Here's a complete, runnable example of how to define a type, persist it, and query it back.
//!
//! ### 1. Define Your Struct and Implement `EAV`
//!
//! First, define the struct you want to save. Then, implement the `EAV` trait
//! and the necessary `From` and `TryFrom` conversions.
//!
//! ```rust,no_run
//! use heave::*;
//! use heave::catalog::*;
//! use heave::eav::*;
//! use heave::filter::*;
//! use std::convert::{From, TryFrom};
//! use std::result::Result;
//!
//! // Define a simple struct representing a product.
//! #[derive(Debug, Default, PartialEq, Clone)]
//! struct Product {
//! pub id: String,
//! pub name: String,
//! pub price: u32,
//! pub in_stock: bool,
//! }
//!
//! // Implement the EAV trait to define the "class" of this entity.
//! impl EAV for Product {
//! fn class() -> &'static str {
//! "product"
//! }
//! }
//!
//! // Convert our Product into a generic Entity.
//! impl From<Product> for Entity {
//! fn from(p: Product) -> Self {
//! Entity::new::<Product>()
//! .with_id(&p.id)
//! .with_attribute("name", p.name)
//! .with_attribute("price", p.price)
//! .with_attribute("in_stock", p.in_stock)
//! }
//! }
//!
//! // Convert a generic Entity back into our Product.
//! impl TryFrom<Entity> for Product {
//! type Error = FailedTo;
//!
//! fn try_from(entity: Entity) -> Result<Self, Self::Error> {
//! Ok(Self {
//! id: entity.id().to_string(),
//! name: entity.unwrap("name").map_err(|_| FailedTo::ConvertEntity)?,
//! price: entity.unwrap("price").map_err(|_| FailedTo::ConvertEntity)?,
//! in_stock: entity.unwrap("in_stock").map_err(|_| FailedTo::ConvertEntity)?,
//! })
//! }
//! }
//!
//! fn main() -> Result<(), FailedTo> {
//! let db_path = "my_products.db";
//!
//! // Clean up previous runs if file exists
//! if std::path::Path::new(db_path).exists() {
//! std::fs::remove_file(db_path).unwrap();
//! }
//!
//! // == 1. Initialize and Persist Data ==
//! let mut catalog = Catalog::new(db_path);
//! catalog.init()?;
//!
//! let products_to_add = vec![
//! Product {
//! id: "p1".to_string(),
//! name: "Laptop".to_string(),
//! price: 1200,
//! in_stock: true,
//! },
//! Product {
//! id: "p2".to_string(),
//! name: "Mouse".to_string(),
//! price: 25,
//! in_stock: true,
//! },
//! Product {
//! id: "p3".to_string(),
//! name: "Keyboard".to_string(),
//! price: 75,
//! in_stock: false,
//! },
//! ];
//!
//! catalog.insert_many(products_to_add)?;
//! catalog.persist()?;
//! println!("✅ Products saved to the database.");
//!
//! // == 2. Load and Query Data ==
//! let mut query_catalog = Catalog::new(db_path);
//!
//! // Load a single product by its ID.
//! query_catalog.load_by_id("p1")?;
//! let laptop: Product = query_catalog.get("p1")?.unwrap();
//! println!("✅ Loaded by ID: {:?}", laptop);
//! assert_eq!(laptop.name, "Laptop");
//!
//! // Load products matching a filter (in stock, price < 100)
//! let filter = Filter::new()
//! .with_bool("in_stock", true)
//! .with_unsigned_int("price", Comparison::Lesser, 100);
//!
//! let mut filtered_catalog = Catalog::new(db_path);
//! filtered_catalog.load_by_filter(&filter)?;
//! let cheap_products: Vec<Product> = filtered_catalog.list()
//! .unwrap().into_iter().collect();
//! println!("✅ Found {} cheap, in-stock product(s).", cheap_products.len());
//! assert_eq!(cheap_products.len(), 1);
//! assert_eq!(cheap_products[0].id, "p2");
//!
//! // == 3. Update and Delete Data ==
//! let mut update_catalog = Catalog::new(db_path);
//! update_catalog.load::<Product>()?;
//!
//! // Update the price of the laptop
//! let mut laptop_to_update: Product = update_catalog.get("p1")?.unwrap();
//! laptop_to_update.price = 1150;
//! update_catalog.upsert(laptop_to_update)?;
//!
//! // Delete the keyboard
//! update_catalog.delete("p3");
//!
//! // Persist all changes
//! update_catalog.persist()?;
//! println!("✅ Laptop price updated and keyboard deleted.");
//!
//! // == 4. Verify Final State ==
//! let mut final_catalog = Catalog::new(db_path);
//! final_catalog.load::<Product>()?;
//!
//! let final_count = final_catalog.list::<Product>().into_iter().count();
//! println!("✅ Final product count: {}", final_count);
//! assert_eq!(final_count, 2);
//!
//! let updated_laptop: Product = final_catalog.get("p1")?.unwrap();
//! println!("✅ Verified updated laptop price: {}", updated_laptop.price);
//! assert_eq!(updated_laptop.price, 1150);
//!
//! let deleted_keyboard: Option<Product> = final_catalog.get("p3")?;
//! println!("✅ Verified keyboard is deleted.");
//! assert!(deleted_keyboard.is_none());
//!
//! // Clean up the created database file
//! std::fs::remove_file(db_path).unwrap();
//!
//! Ok(())
//! }
//! ```
use std::*;
mod fun;
mod imp;
mod mcr;
mod str;
mod trt;
mod tst;
pub use crate::str::failed_to::FailedTo;
/// The `catalog` module provides the `Catalog` struct, which serves as the main entry point
/// for interacting with the EAV database. It manages the connection to the SQLite file
/// and maintains an in-memory cache of entities.
pub mod catalog {
pub use crate::str::catalog::O as Catalog;
}
/// The `eav` module defines the core components of the Entity-Attribute-Value (EAV) model.
/// It includes the `EAV` trait, which custom structs must implement to be stored as entities,
/// as well as the `Entity` struct for generic entity representation, `Attribute` for entity properties,
/// `Value` for attribute values, and `EntityState` for tracking entity changes.
pub mod eav {
pub(crate) use crate::str::attribute::O as Attribute;
pub use crate::str::entity::O as Entity;
pub use crate::str::entity_state::EntityState;
pub(crate) use crate::str::value::Value;
pub use crate::trt::eav::T as EAV;
}
/// The `filter` module provides tools for constructing complex queries to retrieve entities
/// from the database. It includes the `Filter` struct for building query conditions,
/// `Condition` for defining individual filtering criteria, and `Comparison` for specifying
/// how values should be compared.
pub mod filter {
pub use crate::str::comparison::E as Comparison;
pub use crate::str::condition::E as Condition;
pub use crate::str::filter::O as Filter;
}
#[cfg(test)]
pub(crate) use crate::str::item::O as Item;
use catalog::*;
use eav::*;
use filter::*;
mod sqlite {
pub use crate::str::sqlite_failed_to::FailedTo;
pub mod build {
pub use crate::fun::sqlite_build_params::run as params;
pub use crate::fun::sqlite_build_statement::run as statement;
}
pub mod init {
pub use crate::fun::sqlite_init_db::run as db;
}
pub mod load {
pub use crate::fun::sqlite_load_attributes::run as attributes;
pub use crate::fun::sqlite_load_by_class::run as by_class;
pub use crate::fun::sqlite_load_by_filter::run as by_filter;
pub use crate::fun::sqlite_load_by_id::run as by_id;
}
pub mod map {
pub use crate::fun::sqlite_map_row_to_attribute::run as row_to_attribute;
pub use crate::fun::sqlite_map_row_to_entity::run as row_to_entity;
}
pub mod persist {
pub use crate::fun::sqlite_persist_catalog::run as catalog;
}
}
-1
View File
@@ -1 +0,0 @@
-30
View File
@@ -1,30 +0,0 @@
use crate::*;
/// Represents an attribute of an entity, consisting of an ID and a `Value`.
#[derive(Debug, PartialEq, Clone)]
pub struct O {
/// The unique identifier for this attribute (e.g., "name", "price").
pub id: String,
/// The value of the attribute.
pub value: Value,
}
impl Attribute {
/// Creates a new `Attribute` instance.
///
/// # Arguments
///
/// * `id` - The ID of the attribute.
/// * `value` - The value of the attribute, which can be any type that
/// implements `Into<Value>`.
///
/// # Returns
///
/// A new `Attribute` instance.
pub fn new(id: &str, value: impl Into<Value>) -> Self {
Self {
id: String::from(id),
value: value.into(),
}
}
}
-51
View File
@@ -1,51 +0,0 @@
use crate::*;
use std::collections::HashMap;
use std::ops::*;
use std::sync::*;
/// Represents a catalog of entities that can be persisted to a SQLite database.
///
/// The `Catalog` holds entities in memory and provides methods to interact with
/// them, as well as to persist changes to and load data from a database file.
#[derive(Debug, Default)]
pub struct O {
pub(crate) path: String,
pub(crate) already_init: bool,
items: Mutex<HashMap<String, Entity>>,
}
impl Catalog {
/// Creates a new, empty in-memory `Catalog` for the database at the given path.
///
/// This method does not create the database file or connect to it. It only
/// initializes an empty catalog in memory. The database file will be accessed
/// when `init()`, `persist()`, or `load_*` methods are called.
///
/// # Arguments
///
/// * `path` - The path to the SQLite database file that this catalog will manage.
///
/// # Returns
///
/// A new `Catalog` instance with an empty in-memory item cache.
pub fn new(path: &str) -> Self {
Self {
path: String::from(path),
..Catalog::default()
}
}
pub(crate) fn on_items<F>(&self, exec: F) -> Result<(), FailedTo>
where
F: FnOnce(&mut HashMap<String, Entity>) -> Result<(), FailedTo>,
{
let mut guarded_items = self.items.lock().map_err(|_| FailedTo::LockCatalog)?;
exec(&mut guarded_items)
}
pub(crate) fn with_items<F, R>(&self, exec: F) -> Result<R, FailedTo>
where
F: FnOnce(&HashMap<String, Entity>) -> Result<R, FailedTo>,
{
let guarded_items = self.items.lock().map_err(|_| FailedTo::LockCatalog)?;
exec(&guarded_items)
}
}
-25
View File
@@ -1,25 +0,0 @@
/// Defines the comparison operators used in a `Filter` condition.
///
/// These operators are used to compare attribute values in the database when
/// loading entities via `load_by_filter`.
#[derive(Debug, PartialEq, PartialOrd, Eq, Ord, Clone, Copy, Hash)]
pub enum E {
/// Exact equality (`==`). Applicable to all value types.
Equal,
/// Greater than (`>`). Applicable to numeric types.
Greater,
/// Greater than or equal to (`>=`). Applicable to numeric types.
GreaterOrEqual,
/// Lesser than (`<`). Applicable to numeric types.
Lesser,
/// Lesser than or equal to (`<=`). Applicable to numeric types.
LesserOrEqual,
/// Case-insensitive exact match. Applicable to text values.
IsExactly,
/// Case-insensitive prefix search (`LIKE 'value%'`). Applicable to text values.
StartsWith,
/// Case-insensitive suffix search (`LIKE '%value'`). Applicable to text values.
EndsWith,
/// Case-insensitive substring search (`LIKE '%value%'`). Applicable to text values.
Contains,
}
-17
View File
@@ -1,17 +0,0 @@
/// Represents the value part of a `Filter` condition.
///
/// Each variant holds a specific data type to be used in a comparison when
/// querying the database.
#[derive(Debug, PartialEq, PartialOrd, Clone, Copy)]
pub enum E<'a> {
/// A boolean value (`true` or `false`).
Bool(bool),
/// A floating-point number (`f64`).
Real(f64),
/// A signed integer (`i64`).
SignedInt(i64),
/// A text value (`&str`).
Text(&'a str),
/// An unsigned integer (`u32`).
UnsignedInt(u32),
}
-205
View File
@@ -1,205 +0,0 @@
use crate::*;
/// Represents a generic entity with an ID, class, and a set of attributes.
#[derive(Debug, PartialEq, Clone)]
pub struct O {
/// The unique identifier for this entity.
pub(crate) id: String,
/// The current state of the entity within the `Catalog`'s in-memory cache.
/// This tracks whether the entity is new, modified, or marked for deletion.
pub(crate) state: EntityState,
/// An optional timestamp or version number, typically used for optimistic
/// locking or tracking when the entity was last referenced or modified.
pub(crate) ref_date: Option<u32>,
/// A string identifying the "type" or "class" of the entity (e.g., "product", "user").
/// This is used to group and query entities of the same kind.
pub(crate) class: String,
/// A string identifying the "subtype" or "subclass" of the entity (e.g.,
/// "computer", "phone", "customer"). This is used to group and query entities
/// of different subtype but of the same kind or inside the same domain (class)
pub(crate) subclass: Option<String>,
/// A map of attribute names to `Attribute` values, holding the actual data
/// of the entity.
pub(crate) attributes: std::collections::HashMap<String, Attribute>,
}
impl Entity {
/// Creates a new `Entity` instance for a given type `T` that implements `EAV`.
///
/// # Returns
///
/// A new `Entity` instance.
pub fn new<T>() -> Self
where
T: EAV,
{
Self {
id: String::new(),
state: EntityState::New,
ref_date: None,
class: T::class().to_string(),
subclass: None,
attributes: std::collections::HashMap::<String, Attribute>::new(),
}
}
/// Sets the ID of the entity.
///
/// # Arguments
///
/// * `id` - The ID to set for the entity.
///
/// # Returns
///
/// The entity with the updated ID.
pub fn with_id(mut self, id: &str) -> Self {
self.id = id.to_string();
self
}
/// Sets the subclass of the entity.
///
/// # Arguments
///
/// * `subclass` - The subclass to set for the entity.
///
/// # Returns
///
/// The entity with the updated subclass.
pub fn with_subclass(mut self, subclass: &str) -> Self {
self.subclass = Some(subclass.to_string());
self
}
/// Sets the reference date of the entity.
///
/// # Arguments
///
/// * `ref_date` - The reference date to set.
///
/// # Returns
///
/// The entity with the updated reference date.
pub fn with_ref_date(mut self, ref_date: u32) -> Self {
self.ref_date = Some(ref_date);
self
}
/// Adds or updates an attribute for the entity.
///
/// # Arguments
///
/// * `id` - The ID of the attribute.
/// * `value` - The value of the attribute.
///
/// # Returns
///
/// The entity with the added or updated attribute.
pub fn with_attribute(mut self, id: &str, value: impl Into<Value>) -> Self {
let attribute = Attribute::new(id, value);
self.attributes.insert(attribute.id.clone(), attribute);
self
}
/// Adds or updates an attribute if the value is `Some`.
///
/// # Arguments
///
/// * `id` - The ID of the attribute.
/// * `value` - The optional value of the attribute.
///
/// # Returns
///
/// The entity, possibly with the new attribute.
pub fn with_opt_attribute(mut self, id: &str, value: Option<impl Into<Value>>) -> Self {
if let Some(value) = value {
let attribute = Attribute::new(id, value);
self.attributes.insert(attribute.id.clone(), attribute);
}
self
}
/// Returns a reference to the `Value` of an attribute.
///
/// # Arguments
///
/// * `id` - The ID of the attribute to get the value of.
///
/// # Returns
///
/// An `Option<&Value>` which is `Some(&Value)` if the attribute exists,
/// or `None` if it does not.
pub(crate) fn value_of(&self, id: &str) -> Option<&Value> {
let attribute = self.attributes.get(id);
match attribute {
None => None,
Some(attribute) => Some(&attribute.value),
}
}
/// Unwraps an attribute's value into a specified type `T`.
///
/// This function requires `T` to implement `TryFrom<Value>`.
///
/// # Arguments
///
/// * `id` - The ID of the attribute to unwrap.
///
/// # Returns
///
/// A `Result<T, FailedTo>` which is `Ok(T)` if the conversion is successful,
/// or `Err(FailedTo::ConvertValue)` if the conversion fails,
/// or `Err(FailedTo::FindAttribute)` if the attribute doesn't exist.
pub fn unwrap<T>(&self, id: &str) -> Result<T, FailedTo>
where
T: TryFrom<Value>,
{
self.value_of(id)
.map(|value| T::try_from(value.clone()).map_err(|_| FailedTo::ConvertValue))
.unwrap_or(Err(FailedTo::FindAttribute))
}
/// Unwraps an attribute's value into an `Option<T>`.
///
/// This function requires `T` to implement `TryFrom<Value>`.
///
/// # Arguments
///
/// * `id` - The ID of the attribute to unwrap.
///
/// # Returns
///
/// A `Result<Option<T>, FailedTo>`.
/// - `Ok(Some(T))` if the attribute exists and the conversion is successful.
/// - `Ok(None)` if the attribute does not exist.
/// - `Err(FailedTo::ConvertValue)` if the attribute exists but the conversion fails.
pub fn unwrap_opt<T>(&self, id: &str) -> Result<Option<T>, FailedTo>
where
T: TryFrom<Value>,
{
self.value_of(id)
.map(|value| T::try_from(value.clone()).map_err(|_| FailedTo::ConvertValue))
.transpose()
}
/// Unwraps an attribute's value, returning a default value if it doesn't exist or fails to convert.
///
/// This function requires `T` to implement `TryFrom<Value>`.
///
/// # Arguments
///
/// * `id` - The ID of the attribute to unwrap.
/// * `default` - The default value to return if the attribute is not found or conversion fails.
///
/// # Returns
///
/// The converted value of the attribute, or the default value.
pub fn unwrap_or<T>(&self, id: &str, default: T) -> Result<T, FailedTo>
where
T: TryFrom<Value>,
{
self.value_of(id)
.map(|value| T::try_from(value.clone()).map_err(|_| FailedTo::ConvertValue))
.transpose()
.map(|value| value.unwrap_or(default))
}
/// Returns the ID of the entity.
pub fn id(&self) -> &str {
&self.id
}
/// Returns the subclass of the entity, if it has one.
pub fn subclass(&self) -> Option<String> {
self.subclass.clone()
}
}
@@ -1,15 +0,0 @@
/// Represents the state of an entity within the catalog.
#[derive(Debug, Default, PartialEq, PartialOrd, Eq, Ord, Clone, Copy, Hash)]
pub enum EntityState {
/// The entity is newly created and has not been persisted.
#[default]
New,
/// The entity has been updated and changes have not been persisted,
Updated,
/// The state of the entity is unknown.
Unknown,
/// The entity has been loaded from the database.
Loaded,
/// The entity is marked for deletion.
ToDelete,
}
-92
View File
@@ -1,92 +0,0 @@
use crate::*;
/// Represents the possible failures that can occur in the library.
#[derive(Debug)]
pub enum FailedTo {
/// Failed to compose filter statement
ComposeFilter,
/// Failed to convert from Entity to type.
ConvertEntity,
/// Failed to convert from type to Entity.
ConvertObject,
/// Failed to convert from Value to type.
ConvertValue,
/// Failed to execute predicate to mutate an item.
ExecutePredicate(Vec<Box<dyn error::Error>>),
/// Failed to find given attribute.
FindAttribute,
/// Failed to initialize the database.
InitDatabase,
/// Failed to load data from the database.
LoadFromDB,
/// Failed to lock catalog in a multithread environment.
LockCatalog,
/// Failed to map a database row to an attribute.
MapAttribute,
/// Failed to map a database row to an entity.
MapEntity,
/// Failed to persist the catalog to the database.
PersistCatalog,
/// A failure originating from the underlying SQLite implementation.
SQLite(sqlite::FailedTo),
}
impl PartialEq for FailedTo {
fn eq(&self, other: &FailedTo) -> bool {
matches!(
(self, other),
(FailedTo::ComposeFilter, FailedTo::ComposeFilter)
| (FailedTo::ConvertEntity, FailedTo::ConvertEntity)
| (FailedTo::ConvertObject, FailedTo::ConvertObject)
| (FailedTo::ConvertValue, FailedTo::ConvertValue)
| (FailedTo::ExecutePredicate(_), FailedTo::ExecutePredicate(_))
| (FailedTo::FindAttribute, FailedTo::FindAttribute)
| (FailedTo::InitDatabase, FailedTo::InitDatabase)
| (FailedTo::LoadFromDB, FailedTo::LoadFromDB)
| (FailedTo::LockCatalog, FailedTo::LockCatalog)
| (FailedTo::MapAttribute, FailedTo::MapAttribute)
| (FailedTo::MapEntity, FailedTo::MapEntity)
| (FailedTo::PersistCatalog, FailedTo::PersistCatalog)
| (FailedTo::SQLite(_), FailedTo::SQLite(_))
)
}
}
impl From<sqlite::FailedTo> for FailedTo {
fn from(value: sqlite::FailedTo) -> Self {
Self::SQLite(value)
}
}
impl std::error::Error for FailedTo {}
impl std::fmt::Display for FailedTo {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::result::Result<(), std::fmt::Error> {
match self {
FailedTo::ComposeFilter => write!(f, "Failed to compose filter statement"),
FailedTo::ConvertEntity => write!(f, "Failed to convert from Entity to type"),
FailedTo::ConvertObject => write!(f, "Failed to convert from type to Entity"),
FailedTo::ConvertValue => write!(f, "Failed to convert from Value to type"),
FailedTo::ExecutePredicate(errors) => {
write!(f, "Failed to execute predicate: ")?;
for (i, err) in errors.iter().enumerate() {
if i > 0 {
write!(f, "; ")?;
}
write!(f, "{}", err)?;
}
Ok(())
}
FailedTo::FindAttribute => write!(f, "Failed to find given attribute"),
FailedTo::InitDatabase => write!(f, "Failed to initialize the database"),
FailedTo::LoadFromDB => write!(f, "Failed to load data from the database"),
FailedTo::LockCatalog => {
write!(f, "Failed to lock catalog in a multithread environment")
}
FailedTo::MapAttribute => write!(f, "Failed to map a database row to an attribute"),
FailedTo::MapEntity => write!(f, "Failed to map a database row to an entity"),
FailedTo::PersistCatalog => write!(f, "Failed to persist the catalog to the database"),
FailedTo::SQLite(e) => write!(f, "SQLite error: {}", e),
}
}
}
-118
View File
@@ -1,118 +0,0 @@
use crate::*;
/// A builder for creating complex queries to load entities from the database.
///
/// A `Filter` consists of one or more conditions that are combined with a logical AND.
/// It is used with `Catalog::load_by_filter` to retrieve entities that match
/// all specified criteria.
#[derive(Debug, Default, PartialEq, Clone)]
pub struct O<'a> {
class: Option<String>,
subclass: Option<String>,
conditions: Vec<(String, Comparison, Condition<'a>)>,
}
impl<'a> Filter<'a> {
/// Creates a new, empty `Filter`.
pub fn new() -> Self {
Self {
class: None,
subclass: None,
conditions: Vec::new(),
}
}
/// Sets the entity class to filter by.
///
/// # Arguments
///
/// * `value` - The class name to filter for. Only entities with this class
/// name will be considered.
pub fn with_class(mut self, value: &str) -> Self {
self.class = Some(value.to_string());
self
}
/// Sets the entity subclass to filter by.
///
/// # Arguments
///
/// * `value` - The subclass name to filter for. Only entities with this
/// subclass name will be considered.
pub fn with_subclass(mut self, value: &str) -> Self {
self.subclass = Some(value.to_string());
self
}
/// Adds a boolean condition to the filter.
///
/// This is a shorthand for `with_bool(name, Comparison::Equal, value)`.
pub fn with_bool(mut self, attribute_name: &str, value: bool) -> Self {
self.conditions.push((
attribute_name.to_string(),
Comparison::Equal,
Condition::Bool(value),
));
self
}
/// Adds a signed integer (`i64`) condition to the filter.
pub fn with_signed_int(
mut self,
attribute_name: &str,
comparison: Comparison,
value: i64,
) -> Self {
self.conditions.push((
attribute_name.to_string(),
comparison,
Condition::SignedInt(value),
));
self
}
/// Adds an unsigned integer (`u32`) condition to the filter.
pub fn with_unsigned_int(
mut self,
attribute_name: &str,
comparison: Comparison,
value: u32,
) -> Self {
self.conditions.push((
attribute_name.to_string(),
comparison,
Condition::UnsignedInt(value),
));
self
}
/// Adds a text (`&str`) condition to the filter.
pub fn with_text(
mut self,
attribute_name: &str,
comparison: Comparison,
value: &'a str,
) -> Self {
self.conditions.push((
attribute_name.to_string(),
comparison,
Condition::Text(value),
));
self
}
/// Adds a real number (`f64`) condition to the filter.
pub fn with_real(mut self, attribute_name: &str, comparison: Comparison, value: f64) -> Self {
self.conditions.push((
attribute_name.to_string(),
comparison,
Condition::Real(value),
));
self
}
/// Returns an iterator over the conditions in the filter.
///
/// This is used internally by the persistence engine.
pub(crate) fn conditions(&self) -> impl Iterator<Item = &(String, Comparison, Condition<'a>)> {
self.conditions.iter()
}
pub(crate) fn class(&self) -> &Option<String> {
&self.class
}
pub(crate) fn subclass(&self) -> &Option<String> {
&self.subclass
}
}
-79
View File
@@ -1,79 +0,0 @@
#[cfg(test)]
use crate::*;
#[cfg(test)]
#[derive(Debug, Default, PartialEq, Clone)]
pub struct O {
pub id: String,
pub first_seen: u32,
pub name: String,
pub price: u32,
pub discount: f64,
pub sell_trend: i64,
pub in_stock: bool,
pub subclass: Option<String>,
pub category: Option<String>,
pub tag: String,
pub supplier_code: u32,
pub supplier_rank: i32,
}
#[cfg(test)]
impl EAV for Item {
fn class() -> &'static str {
"item"
}
}
#[cfg(test)]
impl From<Item> for Entity {
fn from(value: Item) -> Entity {
let mut entity = Entity::new::<Item>()
.with_id(&value.id)
.with_ref_date(value.first_seen)
.with_attribute("name", value.name)
.with_attribute("price", value.price)
.with_attribute("discount", value.discount)
.with_attribute("sell_trend", value.sell_trend)
.with_attribute("in_stock", value.in_stock)
.with_opt_attribute("category", value.category)
.with_attribute("tag", value.tag)
.with_attribute("supplier_code", value.supplier_code)
.with_attribute("supplier_rank", value.supplier_rank);
if let Some(subclass) = value.subclass {
entity = entity.with_subclass(&subclass);
}
entity
}
}
#[cfg(test)]
impl From<Entity> for Item {
fn from(entity: Entity) -> Self {
Self {
id: entity.id().to_string(),
first_seen: entity.ref_date.expect("ref date is always present"),
name: entity.unwrap("name").expect("name is always present"),
price: entity.unwrap("price").expect("price is always present"),
discount: entity
.unwrap("discount")
.expect("discount is always present"),
sell_trend: entity
.unwrap("sell_trend")
.expect("sell_trend is always present"),
in_stock: entity
.unwrap("in_stock")
.expect("in_stock is always present"),
subclass: entity.subclass(),
category: entity
.unwrap_opt("category")
.expect("category is always optinally present"),
tag: entity
.unwrap_or("tag", "-".to_string())
.expect("tag is always present"),
supplier_code: entity
.unwrap("supplier_code")
.expect("supplier code is always present"),
supplier_rank: entity
.unwrap("supplier_rank")
.expect("supplier rank is always present"),
}
}
}
-11
View File
@@ -1,11 +0,0 @@
pub mod attribute;
pub mod catalog;
pub mod comparison;
pub mod condition;
pub mod entity;
pub mod entity_state;
pub mod failed_to;
pub mod filter;
pub mod item;
pub mod sqlite_failed_to;
pub mod value;
@@ -1,50 +0,0 @@
/// Represents failures that can occur specifically within the SQLite implementation.
#[derive(Debug, PartialEq)]
pub enum FailedTo {
/// Failed to begin a database transaction.
BeginTransaction(rusqlite::Error),
/// Failed to build a SQL statement.
BuildStatement,
/// Failed to commit a database transaction.
CommitTransaction(rusqlite::Error),
/// Failed to execute a batch of SQL statements.
ExecuteBatch(rusqlite::Error),
/// Failed to execute a SQL query.
ExecuteQuery(rusqlite::Error),
/// Failed to execute a prepared SQL statement.
ExecuteStatement(rusqlite::Error),
/// Failed to open a connection to the SQLite database.
OpenConnection(rusqlite::Error),
/// Failed to prepare a SQL statement for execution.
PrepareStatement(rusqlite::Error),
}
impl std::error::Error for FailedTo {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
FailedTo::BeginTransaction(e) => Some(e),
FailedTo::BuildStatement => None,
FailedTo::CommitTransaction(e) => Some(e),
FailedTo::ExecuteBatch(e) => Some(e),
FailedTo::ExecuteQuery(e) => Some(e),
FailedTo::ExecuteStatement(e) => Some(e),
FailedTo::OpenConnection(e) => Some(e),
FailedTo::PrepareStatement(e) => Some(e),
}
}
}
impl std::fmt::Display for FailedTo {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::result::Result<(), std::fmt::Error> {
match self {
FailedTo::BeginTransaction(e) => write!(f, "Failed to begin transaction: {}", e),
FailedTo::BuildStatement => write!(f, "Failed to build SQL statement"),
FailedTo::CommitTransaction(e) => write!(f, "Failed to commit transaction: {}", e),
FailedTo::ExecuteBatch(e) => write!(f, "Failed to execute batch: {}", e),
FailedTo::ExecuteQuery(e) => write!(f, "Failed to execute query: {}", e),
FailedTo::ExecuteStatement(e) => write!(f, "Failed to execute statement: {}", e),
FailedTo::OpenConnection(e) => write!(f, "Failed to open connection: {}", e),
FailedTo::PrepareStatement(e) => write!(f, "Failed to prepare statement: {}", e),
}
}
}
-35
View File
@@ -1,35 +0,0 @@
use crate::*;
/// Represents the value of an entity's attribute.
///
/// This enum can hold different data types, allowing for flexible and
/// semi-structured data storage.
#[derive(Debug, PartialEq, PartialOrd, Clone)]
pub enum Value {
/// A boolean value (`true` or `false`).
Bool(bool),
/// A floating-point number (`f64`).
Real(f64),
/// A signed 64-bit integer.
SignedInt(i64),
/// A UTF-8 encoded string.
Text(String),
/// An unsigned 32-bit integer.
UnsignedInt(u32),
}
impl std::fmt::Display for Value {
fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
let string_value = match self {
Value::Bool(value) => match value {
true => 1.to_string(),
false => 0.to_string(),
},
Value::Real(value) => value.to_string(),
Value::SignedInt(value) => value.to_string(),
Value::UnsignedInt(value) => value.to_string(),
Value::Text(value) => value.clone(),
};
write!(f, "{}", string_value)
}
}
-17
View File
@@ -1,17 +0,0 @@
use crate::*;
/// A trait for types that can be represented as an Entity-Attribute-Value model.
///
/// This trait provides the necessary conversions to and from the generic `Entity`
/// representation, and it requires the type to define its own class name.
pub trait T where
Self: TryFrom<Entity>,
Self: TryInto<Entity>,
Self: PartialEq,
Self: Clone,
{
/// Returns the class name of the type.
///
/// This is used to distinguish between different types of entities in the database.
fn class() -> &'static str;
}
-1
View File
@@ -1 +0,0 @@
pub mod eav;
@@ -1,50 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
#[test]
fn delete_should_mark_entity_as_to_delete() {
// Should mark an existing entity's state as 'ToDelete'.
let catalog = Catalog::new("dummy.db");
let item = Item {
id: "item-123".to_string(),
name: "Test Item".to_string(),
price: 100,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let item_id = item.id.clone();
let _ = catalog.upsert(item);
catalog.delete(&item_id).unwrap();
let is_deleted = catalog
.with_items(|items| {
let entity = items.get(&item_id).unwrap();
Ok(entity.state == EntityState::ToDelete)
})
.unwrap();
assert!(is_deleted);
}
#[test]
fn delete_should_have_no_effect_for_nonexistent_id() {
// Should have no effect if the entity ID does not exist.
let catalog = Catalog::new("dummy.db");
let item = Item {
id: "item-123".to_string(),
name: "Test Item".to_string(),
price: 100,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog.upsert(item);
// Attempt to delete a non-existent entity, which should not panic or change anything.
catalog.delete("nonexistent-id").unwrap();
let not_deleted = catalog
.with_items(|items| {
let entity = items.get("item-123").unwrap();
Ok(entity.state != EntityState::ToDelete)
})
.unwrap();
assert!(not_deleted);
}
}
@@ -1,40 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
use std::collections::HashMap;
#[test]
fn for_each_should_iterate_over_all_items() {
let catalog = Catalog::new("test.db");
let item1 = Item {
id: "1".to_string(),
name: "Item 1".to_string(),
price: 10,
..Default::default()
};
let item2 = Item {
id: "2".to_string(),
name: "Item 2".to_string(),
price: 20,
..Default::default()
};
let item3 = Item {
id: "3".to_string(),
name: "Item 3".to_string(),
price: 30,
..Default::default()
};
let _ = catalog.upsert(item1.clone());
let _ = catalog.upsert(item2.clone());
let _ = catalog.upsert(item3.clone());
let mut collected_items = HashMap::new();
catalog
.for_each::<Item, _>(|item| {
collected_items.insert(item.id.clone(), item.clone());
})
.unwrap();
assert_eq!(collected_items.len(), 3);
assert_eq!(collected_items.get("1").unwrap(), &item1);
assert_eq!(collected_items.get("2").unwrap(), &item2);
assert_eq!(collected_items.get("3").unwrap(), &item3);
}
}
@@ -1,113 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
#[derive(Debug, PartialEq)]
enum Test {
Error(String),
}
impl std::fmt::Display for Test {
fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
write!(f, "error!")
}
}
impl std::error::Error for Test {}
fn prepare_catalog(catalog: &mut Catalog) {
let items = vec![
Item {
id: "1".to_string(),
price: 100,
..Default::default()
},
Item {
id: "2".to_string(),
price: 200,
..Default::default()
},
Item {
id: "3".to_string(),
price: 300,
..Default::default()
},
];
let _ = catalog.insert_many(items);
}
#[test]
fn for_each_mut_should_change_all_prices() {
let mut catalog = Catalog::new("test.db");
prepare_catalog(&mut catalog);
let _ = catalog.for_each_mut(|item: &mut Item| {
item.price += 50;
Ok(())
});
let item1: Item = catalog.get("1").unwrap().unwrap();
let item2: Item = catalog.get("2").unwrap().unwrap();
let item3: Item = catalog.get("3").unwrap().unwrap();
assert_eq!(item1.price, 150);
assert_eq!(item2.price, 250);
assert_eq!(item3.price, 350);
let _ = catalog.with_items(|items| {
let entity = items.get("1").unwrap();
assert_eq!(entity.state, EntityState::Updated);
Ok(())
});
let _ = catalog.with_items(|items| {
let entity = items.get("2").unwrap();
assert_eq!(entity.state, EntityState::Updated);
Ok(())
});
let _ = catalog.with_items(|items| {
let entity = items.get("3").unwrap();
assert_eq!(entity.state, EntityState::Updated);
Ok(())
});
}
#[test]
fn for_each_mut_should_handle_empty_catalog() {
let catalog = Catalog::new("test.db");
let result = catalog.for_each_mut(|_item: &mut Item| Ok(()));
assert!(result.is_ok());
let count = catalog.with_items(|items| Ok(items.len())).unwrap();
assert_eq!(count, 0);
}
#[test]
fn for_each_mut_should_handle_closure_error() {
let mut catalog = Catalog::new("test.db");
prepare_catalog(&mut catalog);
let result = catalog.for_each_mut(|item: &mut Item| {
if item.id == "2" {
Err(Box::new(Test::Error(item.id.clone())))
} else {
item.price += 50;
Ok(())
}
});
assert!(result.is_err());
if let Err(FailedTo::ExecutePredicate(e)) = result {
assert_eq!(
e[0].downcast_ref::<Test>(),
Some(&Test::Error("2".to_string()))
);
}
let item1: Item = catalog.get("1").unwrap().unwrap();
let item2: Item = catalog.get("2").unwrap().unwrap();
let item3: Item = catalog.get("3").unwrap().unwrap();
assert_eq!(item1.price, 150); // Modified
assert_eq!(item2.price, 200); // Not modified due to error
assert_eq!(item3.price, 350); // Modified
let _ = catalog.with_items(|items| {
let entity = items.get("1").unwrap();
assert_eq!(entity.state, EntityState::Updated);
Ok(())
});
let _ = catalog.with_items(|items| {
let entity = items.get("2").unwrap();
assert_eq!(entity.state, EntityState::New); // Should remain Unchanged
Ok(())
});
let _ = catalog.with_items(|items| {
let entity = items.get("3").unwrap();
assert_eq!(entity.state, EntityState::Updated);
Ok(())
});
}
}
-37
View File
@@ -1,37 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
#[test]
fn get_should_retrieve_and_convert_entity_by_id() {
// Should retrieve an entity by its ID and correctly convert it to the target type 'T'.
let catalog = Catalog::new("dummy.db");
let item = Item {
id: "item-123".to_string(),
subclass: Some("subitem".to_string()),
name: "Test Item".to_string(),
price: 100,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog.upsert(item.clone());
let retrieved_item: Option<Item> = catalog.get::<Item>("item-123").unwrap();
assert_eq!(retrieved_item, Some(item));
}
#[test]
fn get_should_return_none_for_nonexistent_id() {
// Should return 'None' if the ID does not exist.
let catalog = Catalog::new("dummy.db");
let item = Item {
id: "item-123".to_string(),
name: "Test Item".to_string(),
price: 100,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog.upsert(item.clone());
let retrieved_item: Option<Item> = catalog.get("nonexistent-id").unwrap();
assert!(retrieved_item.is_none());
}
}
@@ -1,78 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
fn catalog(name: &str) -> Catalog {
let path = format!("{}.db", name);
let fs_path = path::Path::new(&path);
if fs_path.exists() {
std::fs::remove_file(fs_path).unwrap();
}
Catalog::new(&path)
}
#[test]
fn get_by_finds_item() {
let catalog = catalog("get_by_finds_item");
let item1 = Item {
id: "1".to_string(),
name: "one".to_string(),
..Default::default()
};
let item2 = Item {
id: "2".to_string(),
name: "two".to_string(),
..Default::default()
};
catalog
.insert_many(vec![item1.clone(), item2.clone()])
.unwrap();
let found = catalog
.get_by(|item: &Item| item.name == "two")
.unwrap()
.unwrap();
assert_eq!(found, item2);
}
#[test]
fn get_by_returns_none_when_no_match() {
let catalog = catalog("get_by_returns_none_when_no_match");
let item1 = Item {
id: "1".to_string(),
name: "one".to_string(),
..Default::default()
};
catalog.insert_many(vec![item1.clone()]).unwrap();
let found = catalog.get_by(|item: &Item| item.name == "two").unwrap();
assert!(found.is_none());
}
#[test]
fn get_by_on_empty_catalog_returns_none() {
let catalog = catalog("get_by_on_empty_catalog_returns_none");
let found = catalog.get_by(|item: &Item| item.name == "any").unwrap();
assert!(found.is_none());
}
#[test]
fn get_by_multiple_matches() {
let catalog = catalog("get_by_multiple_matches");
let item1 = Item {
id: "1".to_string(),
name: "match".to_string(),
price: 10,
..Default::default()
};
let item2 = Item {
id: "2".to_string(),
name: "match".to_string(),
price: 20,
..Default::default()
};
catalog
.insert_many(vec![item1.clone(), item2.clone()])
.unwrap();
// `get_by` should return the first match it finds. The order is not guaranteed
// by the underlying HashMap, so we just check that it returns one of them.
let found = catalog
.get_by(|item: &Item| item.name == "match")
.unwrap()
.unwrap();
assert!(found == item1 || found == item2);
}
}
@@ -1,52 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
#[test]
fn init_should_create_db_file_if_not_exists() {
// Should create the SQLite database file if it doesn't exist.
let db_path = "target/test_dbs/init_should_create_db_file_if_not_exists.db";
let path = std::path::Path::new(db_path);
// Ensure the directory exists
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
// Ensure the file does not exist before the test
if path.exists() {
std::fs::remove_file(path).unwrap();
}
let catalog = Catalog::new(db_path);
let result = catalog.init();
assert!(result.is_ok());
assert!(path.exists());
// Clean up the created file
std::fs::remove_file(path).unwrap();
}
#[test]
fn init_should_not_fail_if_db_file_exists() {
// Should not fail if the database file already exists.
let db_path = "target/test_dbs/init_should_not_fail_if_db_file_exists.db";
let path = std::path::Path::new(db_path);
// Ensure the directory exists
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
// Create the DB file first
let catalog = Catalog::new(db_path);
catalog.init().unwrap();
// Calling init() again should not fail
let result = catalog.init();
assert!(result.is_ok());
assert!(path.exists());
// Clean up
std::fs::remove_file(path).unwrap();
}
#[test]
fn init_should_return_error_for_invalid_path() {
// Should return an error for an invalid path or permissions issue.
// Using a directory as a path should fail.
let invalid_path = "target/test_dbs/an_invalid_path_dir";
std::fs::create_dir_all(invalid_path).unwrap();
let catalog = Catalog::new(invalid_path);
let result = catalog.init();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), FailedTo::InitDatabase);
// Clean up
std::fs::remove_dir_all(invalid_path).unwrap();
}
}
@@ -1,50 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
#[test]
fn insert_many_should_add_all_entities() {
// 'insert_many()': Should add all provided entities to the 'items' map.
let catalog = Catalog::new("dummy.db");
let items = vec![
Item {
id: "item-1".to_string(),
name: "Item 1".to_string(),
price: 10,
sell_trend: 0,
in_stock: true,
..Item::default()
},
Item {
id: "item-2".to_string(),
name: "Item 2".to_string(),
price: 20,
sell_trend: 0,
in_stock: false,
..Item::default()
},
];
let _ = catalog.insert_many(items);
let len = catalog.len().unwrap();
assert_eq!(len, 2);
let entity1 = catalog
.with_items(|items| {
let entity = items.get("item-1").unwrap();
Ok(entity.clone())
})
.unwrap();
assert_eq!(entity1.state, EntityState::New);
assert_eq!(entity1.value_of("name"), Some(&Value::from("Item 1")));
assert_eq!(entity1.value_of("price"), Some(&Value::from(10u32)));
assert_eq!(entity1.value_of("sell_trend"), Some(&Value::from(0i64)));
let entity2 = catalog
.with_items(|items| {
let entity = items.get("item-2").unwrap();
Ok(entity.clone())
})
.unwrap();
assert_eq!(entity2.state, EntityState::New);
assert_eq!(entity2.value_of("name"), Some(&Value::from("Item 2")));
assert_eq!(entity2.value_of("price"), Some(&Value::from(20u32)));
assert_eq!(entity2.value_of("sell_trend"), Some(&Value::from(0i64)));
}
}
@@ -1,186 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
#[test]
fn integration_test_init_insert_persist_load_get() {
// Scenario: 'init' -> 'insert' -> 'persist' -> create a new catalog instance -> 'load_by_id' -> 'get' -> verify data integrity.
let db_path = "target/test_dbs/integration_test_init_insert_persist_load_get.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. 'init' -> 'insert' -> 'persist'
let catalog1 = Catalog::new(db_path);
catalog1.init().unwrap();
let item_to_insert = Item {
id: "item-1".to_string(),
subclass: Some("subitem".to_string()),
name: "Integration Test Item".to_string(),
price: 999,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog1.upsert(item_to_insert.clone());
catalog1.persist().unwrap();
// 2. create a new catalog instance -> 'load_by_id' -> 'get'
let catalog2 = Catalog::new(db_path);
catalog2.load_by_id("item-1").unwrap();
let loaded_item: Option<Item> = catalog2.get("item-1").unwrap();
// 3. verify data integrity
assert_eq!(loaded_item, Some(item_to_insert));
// Clean up
std::fs::remove_file(path).unwrap();
}
#[test]
fn integration_test_init_insert_many_persist_load_list() {
// Scenario: 'init' -> 'insert_many' -> 'persist' -> new catalog -> 'load_by_class' -> 'list_by_class' -> verify all items are loaded.
let db_path = "target/test_dbs/integration_test_init_insert_many_persist_load_list.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. 'init' -> 'insert_many' -> 'persist'
let catalog1 = Catalog::new(db_path);
catalog1.init().unwrap();
let items_to_insert = vec![
Item {
id: "item-1".to_string(),
subclass: Some("subitem".to_string()),
name: "Item One".to_string(),
price: 100,
sell_trend: 0,
in_stock: true,
..Item::default()
},
Item {
id: "item-2".to_string(),
subclass: Some("subitem".to_string()),
name: "Item Two".to_string(),
price: 200,
sell_trend: 0,
in_stock: false,
..Item::default()
},
];
let _ = catalog1.insert_many(items_to_insert.clone());
catalog1.persist().unwrap();
// 2. new catalog -> 'load_by_class' -> 'list_by_class'
let catalog2 = Catalog::new(db_path);
catalog2.load::<Item>().unwrap();
let mut loaded_items: Vec<Item> = catalog2.list::<Item>().unwrap().into_iter().collect();
// Sort by ID to ensure consistent order for comparison
let mut expected_items = items_to_insert;
loaded_items.sort_by(|a, b| a.id.cmp(&b.id));
expected_items.sort_by(|a, b| a.id.cmp(&b.id));
// 3. verify all items are loaded
assert_eq!(loaded_items.len(), 2);
assert_eq!(loaded_items, expected_items);
// Clean up
std::fs::remove_file(path).unwrap();
}
#[test]
fn integration_test_insert_persist_load_delete_persist_load() {
// Scenario: 'insert' -> 'persist' -> 'load_by_id' -> 'delete' -> 'persist' -> 'load_by_id' should now return nothing for the deleted ID.
let db_path = "target/test_dbs/integration_test_insert_persist_load_delete_persist_load.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. 'insert' -> 'persist'
let catalog1 = Catalog::new(db_path);
catalog1.init().unwrap();
let item_to_delete = Item {
id: "item-to-delete".to_string(),
name: "Test Item".to_string(),
price: 123,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog1.upsert(item_to_delete.clone());
catalog1.persist().unwrap();
// 2. 'load_by_id' to confirm it's there
let catalog2 = Catalog::new(db_path);
catalog2.load_by_id("item-to-delete").unwrap();
assert!(catalog2.get::<Item>("item-to-delete").unwrap().is_some());
// 3. 'delete' -> 'persist'
catalog2.delete("item-to-delete").unwrap();
catalog2.persist().unwrap();
// 4. 'load_by_id' should now return nothing
let catalog3 = Catalog::new(db_path);
catalog3.load_by_id("item-to-delete").unwrap();
let loaded_item: Option<Item> = catalog3.get("item-to-delete").unwrap();
assert!(loaded_item.is_none());
// Clean up
std::fs::remove_file(path).unwrap();
}
#[test]
#[ignore] // This test can be flaky as it depends on thread scheduling.
fn integration_test_concurrency() {
// Scenario: Concurrency - what happens if two 'Catalog' instances point to the same file?
// This test demonstrates that without application-level locking, a "last-write-wins"
// race condition can occur, leading to lost updates.
let db_path = "target/test_dbs/integration_test_concurrency.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. Initial setup
let catalog_setup = Catalog::new(db_path);
catalog_setup.init().unwrap();
let initial_item = Item {
id: "item-1".to_string(),
name: "Original".to_string(),
price: 100,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog_setup.upsert(initial_item);
catalog_setup.persist().unwrap();
let db_path_arc = std::sync::Arc::new(String::from(db_path));
// 2. Thread 1: Loads, updates name, and persists.
let db_path_arc1 = std::sync::Arc::clone(&db_path_arc);
let handle1 = std::thread::spawn(move || {
let catalog1 = Catalog::new(&db_path_arc1);
catalog1.load_by_id("item-1").unwrap();
let mut item = catalog1.get::<Item>("item-1").unwrap().unwrap();
item.name = "Updated by Thread 1".to_string();
let _ = catalog1.upsert(item);
catalog1.persist().unwrap();
});
// 3. Thread 2: Loads, updates price, and persists.
let db_path_arc2 = std::sync::Arc::clone(&db_path_arc);
let handle2 = std::thread::spawn(move || {
let catalog2 = Catalog::new(&db_path_arc2);
catalog2.load_by_id("item-1").unwrap();
let mut item = catalog2.get::<Item>("item-1").unwrap().unwrap();
item.price = 200;
let _ = catalog2.upsert(item);
catalog2.persist().unwrap();
});
handle1.join().unwrap();
handle2.join().unwrap();
// 4. Verification: Load the data and check the final state.
let catalog_verify = Catalog::new(db_path);
catalog_verify.load_by_id("item-1").unwrap();
let final_item: Item = catalog_verify.get("item-1").unwrap().unwrap();
// The final state depends on which thread persisted last. One update will have been lost.
let thread1_won = final_item.name == "Updated by Thread 1"
&& final_item.price == 100
&& final_item.sell_trend == 0;
let thread2_won =
final_item.name == "Original" && final_item.price == 200 && final_item.sell_trend == 0;
assert!(
thread1_won || thread2_won,
"Final state must be the result of one of the threads winning the race."
);
// Clean up
std::fs::remove_file(path).unwrap();
}
}
@@ -1,40 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
#[test]
fn list_by_class_should_return_all_entities_of_class() {
// Should return an iterator with all entities of a specific class.
let catalog = Catalog::new("dummy.db");
let item1 = Item {
id: "item-1".to_string(),
subclass: Some("subitem".to_string()),
name: "Item One".to_string(),
price: 100,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let item2 = Item {
id: "item-2".to_string(),
subclass: Some("subitem".to_string()),
name: "Item Two".to_string(),
price: 200,
sell_trend: 0,
in_stock: false,
..Item::default()
};
let _ = catalog.upsert(item1.clone());
let _ = catalog.upsert(item2.clone());
let results = catalog.list::<Item>().unwrap();
assert_eq!(results.len(), 2);
assert!(results.contains(&item1));
assert!(results.contains(&item2));
}
#[test]
fn list_by_class_should_return_empty_iterator_if_no_match() {
// Should return an empty iterator if no entities of that class exist.
let catalog = Catalog::new("dummy.db");
let results: Vec<_> = catalog.list::<Item>().unwrap();
assert!(results.is_empty());
}
}
@@ -1,51 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
#[test]
fn list_by_class_and_subclass_should_return_matching_entities() {
let catalog = Catalog::new("dummy.db");
let item1 = Item {
id: "item-1".to_string(),
subclass: Some("electronics".to_string()),
..Default::default()
};
let item2 = Item {
id: "item-2".to_string(),
subclass: Some("books".to_string()),
..Default::default()
};
let item3 = Item {
id: "item-3".to_string(),
subclass: Some("electronics".to_string()),
..Default::default()
};
let _ = catalog.upsert(item1.clone());
let _ = catalog.upsert(item2.clone());
let _ = catalog.upsert(item3.clone());
let results: Vec<_> = catalog
.list_by_subclass::<Item>("electronics")
.unwrap()
.into_iter()
.collect();
assert_eq!(results.len(), 2);
assert!(results.contains(&item1));
assert!(results.contains(&item3));
assert!(!results.contains(&item2));
}
#[test]
fn list_by_class_and_subclass_should_return_empty_if_no_match() {
let catalog = Catalog::new("dummy.db");
let item1 = Item {
id: "item-1".to_string(),
subclass: Some("electronics".to_string()),
..Default::default()
};
let _ = catalog.upsert(item1.clone());
let results: Vec<_> = catalog
.list_by_subclass::<Item>("books")
.unwrap()
.into_iter()
.collect();
assert!(results.is_empty());
}
}
@@ -1,177 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
#[test]
fn load_by_class_should_load_all_entities_of_class() {
// Should load all entities of a given class from the database into the 'items' map.
let db_path = "target/test_dbs/load_by_class_should_load_all_entities_of_class.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. Setup DB with a few items of the same class
let catalog1 = Catalog::new(db_path);
catalog1.init().unwrap();
let item1 = Item {
id: "item-1".to_string(),
subclass: Some("subitem".to_string()),
name: "Item One".to_string(),
price: 100,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let item2 = Item {
id: "item-2".to_string(),
subclass: Some("subitem".to_string()),
name: "Item Two".to_string(),
price: 200,
sell_trend: 0,
in_stock: false,
..Item::default()
};
let _ = catalog1.upsert(item1.clone());
let _ = catalog1.upsert(item2.clone());
catalog1.persist().unwrap();
// 2. Create a new catalog and load the items by class
let catalog2 = Catalog::new(db_path);
let result = catalog2.load::<Item>();
assert!(result.is_ok());
// 3. Verify that all items of that class were loaded
let len = catalog2.len().unwrap();
assert_eq!(len, 2);
let loaded_item1: Item = catalog2.get("item-1").unwrap().unwrap();
let loaded_item2: Item = catalog2.get("item-2").unwrap().unwrap();
assert_eq!(loaded_item1, item1);
assert_eq!(loaded_item2, item2);
assert_eq!(
catalog2
.with_items(|items| { Ok(items.get("item-1").unwrap().state) })
.unwrap(),
EntityState::Loaded
);
assert_eq!(
catalog2
.with_items(|items| { Ok(items.get("item-2").unwrap().state) })
.unwrap(),
EntityState::Loaded
);
// Clean up
std::fs::remove_file(path).unwrap();
}
#[test]
fn load_by_class_should_overwrite_in_memory_entities() {
// Should overwrite any existing in-memory entities with the same IDs.
let db_path = "target/test_dbs/load_by_class_should_overwrite_in_memory_entities.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. Persist an item to the database.
let catalog1 = Catalog::new(db_path);
catalog1.init().unwrap();
let item_in_db = Item {
id: "item-1".to_string(),
subclass: Some("subitem".to_string()),
name: "DB Version".to_string(),
price: 100,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog1.upsert(item_in_db.clone());
catalog1.persist().unwrap();
// 2. Create a new catalog with a different in-memory version of the same item.
let catalog2 = Catalog::new(db_path);
let item_in_memory = Item {
id: "item-1".to_string(),
subclass: Some("subitem".to_string()),
name: "Memory Version".to_string(),
price: 200,
sell_trend: 0,
in_stock: false,
..Item::default()
};
let _ = catalog2.upsert(item_in_memory);
let len = catalog2.len().unwrap();
assert_eq!(len, 1);
assert_eq!(
catalog2.get::<Item>("item-1").unwrap().unwrap().name,
"Memory Version"
);
// 3. Load from the database, which should overwrite the in-memory version.
let result = catalog2.load::<Item>();
assert!(result.is_ok());
// 4. Verify that the in-memory entity has been replaced with the one from the DB.
let len = catalog2.len().unwrap();
assert_eq!(len, 1);
let loaded_item: Item = catalog2.get("item-1").unwrap().unwrap();
assert_eq!(loaded_item, item_in_db);
assert_eq!(
catalog2
.with_items(|items| { Ok(items.get("item-1").unwrap().state) })
.unwrap(),
EntityState::Loaded
);
// Clean up
std::fs::remove_file(path).unwrap();
}
#[test]
fn load_by_class_should_do_nothing_if_none_found() {
// Should do nothing if no entities of that class are found in the database.
let db_path = "target/test_dbs/load_by_class_should_do_nothing_if_none_found.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. Create an empty, initialized database.
let catalog = Catalog::new(db_path);
catalog.init().unwrap();
// 2. Attempt to load from the empty DB.
let result = catalog.load::<Item>();
assert!(result.is_ok());
let is_empty = catalog.is_empty().unwrap();
assert!(is_empty);
// 3. Add an item to memory and try loading again from the empty DB.
let item_in_memory = Item {
id: "item-1".to_string(),
subclass: Some("subitem".to_string()),
name: "In-memory only".to_string(),
price: 100,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog.upsert(item_in_memory.clone());
let len = catalog.len().unwrap();
assert_eq!(len, 1);
let result2 = catalog.load::<Item>();
assert!(result2.is_ok());
// 4. Verify the in-memory item is untouched because nothing was loaded from DB.
let len = catalog.len().unwrap();
assert_eq!(len, 1);
let retrieved_item: Item = catalog.get("item-1").unwrap().unwrap();
assert_eq!(retrieved_item, item_in_memory);
// Clean up
std::fs::remove_file(path).unwrap();
}
#[test]
fn load_by_class_should_return_error_on_db_failure() {
// Should return an error if the database operation fails.
// Using a directory as a path should cause a failure.
let invalid_path = "target/test_dbs/a_directory_for_load_class_fail";
std::fs::create_dir_all(invalid_path).unwrap();
let catalog = Catalog::new(invalid_path);
// Attempt to load from the invalid path.
let result = catalog.load::<Item>();
assert!(result.is_err());
// Based on `load_by_id`, the error should be `LoadFromDB`.
// This assumes `From<SqliteFailedTo>` is implemented to produce `FailedTo::LoadFromDB`.
assert_eq!(result.unwrap_err(), FailedTo::LoadFromDB);
// Clean up
std::fs::remove_dir_all(invalid_path).unwrap();
}
}
File diff suppressed because it is too large Load Diff
@@ -1,162 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
#[test]
fn load_by_id_should_load_entity_from_db() {
// Should load a single entity from the database into the 'items' map.
let db_path = "target/test_dbs/load_by_id_should_load_entity_from_db.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. Create a catalog, insert an item, and persist it to the DB.
let catalog1 = Catalog::new(db_path);
catalog1.init().unwrap();
let item_to_persist = Item {
id: "item-1".to_string(),
subclass: Some("subitem".to_string()),
name: "Test Item".to_string(),
price: 123,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog1.upsert(item_to_persist.clone());
catalog1.persist().unwrap();
// 2. Create a new, empty catalog instance for the same DB.
let catalog2 = Catalog::new(db_path);
assert!(catalog2.is_empty().unwrap());
// 3. Load the item by its ID.
let result = catalog2.load_by_id("item-1");
assert!(result.is_ok());
// 4. Verify the item is now in the in-memory 'items' map.
let len = catalog2.len().unwrap();
assert_eq!(len, 1);
let loaded_entity = catalog2
.with_items(|items| Ok(items.get("item-1").unwrap().clone()))
.unwrap();
// 5. Verify the loaded entity's data and state.
assert_eq!(loaded_entity.id, "item-1");
assert_eq!(loaded_entity.class, "item");
assert_eq!(
loaded_entity.value_of("name"),
Some(&Value::from("Test Item"))
);
assert_eq!(loaded_entity.value_of("price"), Some(&Value::from(123u32)));
assert_eq!(
loaded_entity.value_of("sell_trend"),
Some(&Value::from(0i64))
);
assert_eq!(loaded_entity.value_of("in_stock"), Some(&Value::from(true)));
assert_eq!(loaded_entity.state, EntityState::Loaded); // Should be Synced after loading.
// 6. Also verify by using the public 'get' method.
let retrieved_item: Option<Item> = catalog2.get("item-1").unwrap();
assert_eq!(retrieved_item, Some(item_to_persist));
// Clean up
std::fs::remove_file(path).unwrap();
}
#[test]
fn load_by_id_should_overwrite_in_memory_entity() {
// Should overwrite an existing in-memory entity with the same ID.
let db_path = "target/test_dbs/load_by_id_should_overwrite_in_memory_entity.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. Persist an item to the database.
let catalog1 = Catalog::new(db_path);
catalog1.init().unwrap();
let item_in_db = Item {
id: "item-1".to_string(),
subclass: Some("subitem".to_string()),
name: "Item from DB".to_string(),
price: 100,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog1.upsert(item_in_db.clone());
catalog1.persist().unwrap();
// 2. Create a new catalog and add a *different* in-memory version of the same item.
let catalog2 = Catalog::new(db_path);
let item_in_memory = Item {
id: "item-1".to_string(),
subclass: Some("subitem".to_string()),
name: "In-memory version".to_string(),
price: 200,
sell_trend: 0,
in_stock: false,
..Item::default()
};
let _ = catalog2.upsert(item_in_memory);
let entity_before_load = catalog2
.with_items(|items| Ok(items.get("item-1").unwrap().clone()))
.unwrap();
assert_eq!(entity_before_load.state, EntityState::New);
assert_eq!(
entity_before_load.value_of("name"),
Some(&Value::from("In-memory version"))
);
// 3. Load the item from the database, which should overwrite the in-memory version.
let result = catalog2.load_by_id("item-1");
assert!(result.is_ok());
// 4. Verify that the in-memory entity has been replaced with the one from the DB.
let entity_after_load = catalog2
.with_items(|items| Ok(items.get("item-1").unwrap().clone()))
.unwrap();
assert_eq!(entity_after_load.state, EntityState::Loaded);
assert_eq!(
entity_after_load.value_of("name"),
Some(&Value::from("Item from DB"))
);
assert_eq!(
entity_after_load.value_of("price"),
Some(&Value::from(100u32))
);
assert_eq!(
entity_after_load.value_of("sell_trend"),
Some(&Value::from(0i64))
);
// 5. Verify using the public 'get' method.
let retrieved_item: Item = catalog2.get("item-1").unwrap().unwrap();
assert_eq!(retrieved_item, item_in_db);
// Clean up
std::fs::remove_file(path).unwrap();
}
#[test]
fn load_by_id_should_do_nothing_if_not_found() {
// Should do nothing if the entity is not found in the database.
let db_path = "target/test_dbs/load_by_id_should_do_nothing_if_not_found.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. Create an empty, initialized database.
let catalog = Catalog::new(db_path);
catalog.init().unwrap();
// 2. Attempt to load an ID that does not exist.
let result = catalog.load_by_id("nonexistent-id");
// 3. Verify that the operation succeeded and the catalog remains empty.
assert!(result.is_ok());
assert!(catalog.is_empty().unwrap());
// Clean up
std::fs::remove_file(path).unwrap();
}
#[test]
fn load_by_id_should_return_error_on_db_failure() {
// Should return an error if the database operation fails.
// Using a directory as a path should cause a failure.
let invalid_path = "target/test_dbs/a_directory_for_load_fail";
std::fs::create_dir_all(invalid_path).unwrap();
let catalog = Catalog::new(invalid_path);
// Attempt to load from the invalid path.
let result = catalog.load_by_id("any-id");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), FailedTo::LoadFromDB);
// Clean up
std::fs::remove_dir_all(invalid_path).unwrap();
}
}
-12
View File
@@ -1,12 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
#[test]
fn new_should_create_catalog_with_path_and_empty_items() {
// Should create a new Catalog with the given path and an empty 'items' map.
let path = "test.db";
let catalog = Catalog::new(path);
assert_eq!(catalog.path, path);
assert!(catalog.is_empty().unwrap());
}
}
@@ -1,389 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
#[test]
fn persist_should_insert_new_entities() {
// Should insert entities with 'EntityState::New' into the database.
let db_path = "target/test_dbs/persist_should_insert_new_entities.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. Create catalog, insert an item, and persist
let catalog1 = Catalog::new(db_path);
catalog1.init().unwrap();
let item1 = Item {
id: "item-1".to_string(),
subclass: Some("subitem".to_string()),
name: "Test Item".to_string(),
price: 123,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog1.upsert(item1.clone());
assert!(catalog1.persist().is_ok());
// 2. Create a new catalog and load the item to verify it was persisted
let catalog2 = Catalog::new(db_path);
assert!(catalog2.load_by_id("item-1").is_ok());
// 3. Get the item and assert it's the same as the one we inserted
let loaded_item: Option<Item> = catalog2.get("item-1").unwrap();
assert_eq!(loaded_item, Some(item1));
// Clean up
std::fs::remove_file(path).unwrap();
}
#[test]
fn persist_should_delete_to_delete_entities() {
// Should delete entities with 'EntityState::ToDelete' from the database.
let db_path = "target/test_dbs/persist_should_delete_to_delete_entities.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. Create catalog, insert an item, and persist it.
let catalog1 = Catalog::new(db_path);
catalog1.init().unwrap();
let item1 = Item {
id: "item-to-delete".to_string(),
name: "Test Item".to_string(),
price: 123,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog1.upsert(item1.clone());
assert!(catalog1.persist().is_ok());
// 2. Mark the item for deletion and persist again.
catalog1.delete(&item1.id).unwrap();
assert!(catalog1.persist().is_ok());
// 3. Create a new catalog and try to load the deleted item.
let catalog2 = Catalog::new(db_path);
assert!(catalog2.load_by_id(&item1.id).is_ok());
// 4. Assert that the item was not found.
let loaded_item: Option<Item> = catalog2.get(&item1.id).unwrap();
assert!(loaded_item.is_none());
// Clean up
std::fs::remove_file(path).unwrap();
}
#[test]
fn persist_should_update_updated_entities() {
// Should update entities with 'EntityState::Updated' in the database.
let db_path = "target/test_dbs/persist_should_update_updated_entities.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. Insert an entity and persist it.
let catalog1 = Catalog::new(db_path);
catalog1.init().unwrap();
let original_item = Item {
id: "item-1".to_string(),
subclass: Some("subitem".to_string()),
name: "Original Name".to_string(),
price: 100,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog1.upsert(original_item.clone());
catalog1.persist().unwrap();
// 2. Load it into a new catalog to simulate a separate session.
let catalog2 = Catalog::new(db_path);
catalog2.load_by_id("item-1").unwrap();
// 3. Upsert updated data for the same item. This should mark it as 'Updated'.
let updated_item = Item {
id: "item-1".to_string(),
subclass: Some("subitem".to_string()),
name: "Updated Name".to_string(),
price: 200,
sell_trend: 0,
in_stock: false,
..Item::default()
};
let _ = catalog2.upsert(updated_item.clone());
assert_eq!(
catalog2
.with_items(|items| { Ok(items.get("item-1").unwrap().state) })
.unwrap(),
EntityState::Updated
);
// 4. Persist the changes.
catalog2.persist().unwrap();
// 5. Load the data into a third catalog to verify the update was written to the DB.
let catalog3 = Catalog::new(db_path);
catalog3.load_by_id("item-1").unwrap();
let loaded_item: Item = catalog3.get("item-1").unwrap().unwrap();
// 6. Assert that the loaded item has the updated values.
assert_eq!(loaded_item, updated_item);
assert_ne!(loaded_item, original_item);
// Clean up
std::fs::remove_file(path).unwrap();
}
#[test]
fn persist_should_handle_mixed_entity_states() {
// Should handle a mix of new, updated, and deleted entities in one operation.
let db_path = "target/test_dbs/persist_should_handle_mixed_entity_states.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. Setup: Pre-populate the database with some items.
let catalog_setup = Catalog::new(db_path);
catalog_setup.init().unwrap();
let item_to_update_original = Item {
id: "update-me".to_string(),
subclass: Some("subitem".to_string()),
name: "Original".to_string(),
price: 10,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let item_to_delete = Item {
id: "delete-me".to_string(),
subclass: Some("subitem".to_string()),
name: "Delete Me".to_string(),
price: 20,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let item_to_keep = Item {
id: "keep-me".to_string(),
subclass: Some("subitem".to_string()),
name: "Keep Me".to_string(),
price: 30,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog_setup.upsert(item_to_update_original.clone());
let _ = catalog_setup.upsert(item_to_delete.clone());
let _ = catalog_setup.upsert(item_to_keep.clone());
catalog_setup.persist().unwrap();
// 2. Manipulation: Load the data and perform mixed operations.
let catalog_ops = Catalog::new(db_path);
catalog_ops.load::<Item>().unwrap(); // Load all items
// A new item to be inserted.
let item_to_add = Item {
id: "add-me".to_string(),
subclass: Some("subitem".to_string()),
name: "Add Me".to_string(),
price: 40,
sell_trend: 0,
in_stock: false,
..Item::default()
};
let _ = catalog_ops.upsert(item_to_add.clone()); // State: New
// An updated version of an existing item.
let item_to_update_new = Item {
id: "update-me".to_string(),
subclass: Some("subitem".to_string()),
name: "Updated".to_string(),
price: 11,
sell_trend: 0,
in_stock: false,
..Item::default()
};
let _ = catalog_ops.upsert(item_to_update_new.clone()); // State: Updated
// An item to be deleted.
catalog_ops.delete("delete-me").unwrap(); // State: ToDelete
// item_to_keep is left untouched (State: Synced after load)
// 3. Execution: Persist all the changes in one go.
catalog_ops.persist().unwrap();
// 4. Verification: Load into a new catalog and check the final state of the DB.
let catalog_verify = Catalog::new(db_path);
catalog_verify.load::<Item>().unwrap();
// Check total count
assert_eq!(catalog_verify.len().unwrap(), 3);
// Verify added item
let added_item: Item = catalog_verify.get("add-me").unwrap().unwrap();
assert_eq!(added_item, item_to_add);
// Verify updated item
let updated_item: Item = catalog_verify.get("update-me").unwrap().unwrap();
assert_eq!(updated_item, item_to_update_new);
// Verify deleted item
let deleted_item: Option<Item> = catalog_verify.get("delete-me").unwrap();
assert!(deleted_item.is_none());
// Verify untouched item
let kept_item: Item = catalog_verify.get("keep-me").unwrap().unwrap();
assert_eq!(kept_item, item_to_keep);
// Clean up
std::fs::remove_file(path).unwrap();
}
#[test]
fn persist_should_return_error_on_db_failure() {
// Should return an error if the database connection fails or a query fails.
// Using a directory as a path should cause a failure.
let invalid_path = "target/test_dbs/a_directory_for_persist_fail";
std::fs::create_dir_all(invalid_path).unwrap();
let catalog = Catalog::new(invalid_path);
let item = Item {
id: "item-1".to_string(),
name: "Test".to_string(),
price: 10,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let _ = catalog.upsert(item);
let result = catalog.persist();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), FailedTo::PersistCatalog);
// Clean up
std::fs::remove_dir_all(invalid_path).unwrap();
}
#[test]
fn persist_should_update_in_memory_state() {
// After persisting, the in-memory state of entities should be considered.
// (e.g., should deleted items be removed from the 'items' map, all other items should be marked as Loaded).
let db_path = "target/test_dbs/persist_should_update_in_memory_state.db";
let path = std::path::Path::new(db_path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if path.exists() {
std::fs::remove_file(path).unwrap();
}
// 1. Setup: Create a catalog and pre-populate it with some data.
let catalog = Catalog::new(db_path);
catalog.init().unwrap();
let item_to_update = Item {
id: "update-me".to_string(),
name: "Original".to_string(),
price: 0,
sell_trend: 0,
in_stock: false,
..Item::default()
};
let item_to_delete = Item {
id: "delete-me".to_string(),
name: "Delete Me".to_string(),
price: 0,
sell_trend: 0,
in_stock: false,
..Item::default()
};
let item_untouched = Item {
id: "keep-me".to_string(),
name: "Keep Me".to_string(),
price: 0,
sell_trend: 0,
in_stock: false,
..Item::default()
};
let _ = catalog.upsert(item_to_update.clone());
let _ = catalog.upsert(item_to_delete.clone());
let _ = catalog.upsert(item_untouched.clone());
catalog.persist().unwrap();
// At this point, all items are in the DB and in-memory state is `Loaded`.
assert_eq!(catalog.len().unwrap(), 3);
assert_eq!(
catalog
.with_items(|items| { Ok(items.get("update-me").unwrap().state) })
.unwrap(),
EntityState::Loaded
);
assert_eq!(
catalog
.with_items(|items| { Ok(items.get("delete-me").unwrap().state) })
.unwrap(),
EntityState::Loaded
);
assert_eq!(
catalog
.with_items(|items| { Ok(items.get("keep-me").unwrap().state) })
.unwrap(),
EntityState::Loaded
);
// 2. Manipulate the catalog to have entities in various states.
// A new item to be inserted.
let item_new = Item {
id: "add-me".to_string(),
name: "Add Me".to_string(),
price: 0,
sell_trend: 0,
in_stock: false,
..Item::default()
};
let _ = catalog.upsert(item_new.clone()); // State: New
// An updated version of an existing item.
let item_updated = Item {
id: "update-me".to_string(),
name: "Updated".to_string(),
price: 0,
sell_trend: 10,
in_stock: false,
..Item::default()
};
let _ = catalog.upsert(item_updated.clone()); // State: Updated
// An item to be deleted.
catalog.delete("delete-me").unwrap(); // State: ToDelete
// 'item_untouched' remains with state `Loaded`.
// Check states before final persist
assert_eq!(
catalog
.with_items(|items| { Ok(items.get("add-me").unwrap().state) })
.unwrap(),
EntityState::New
);
assert_eq!(
catalog
.with_items(|items| { Ok(items.get("update-me").unwrap().state) })
.unwrap(),
EntityState::Updated
);
assert_eq!(
catalog
.with_items(|items| { Ok(items.get("delete-me").unwrap().state) })
.unwrap(),
EntityState::ToDelete
);
assert_eq!(
catalog
.with_items(|items| { Ok(items.get("keep-me").unwrap().state) })
.unwrap(),
EntityState::Loaded
);
assert_eq!(catalog.len().unwrap(), 4);
// 3. Persist all changes.
catalog.persist().unwrap();
// 4. Verify the in-memory state after persisting.
// The item marked for deletion should be gone.
assert!(!catalog.contains_key("delete-me").unwrap());
assert_eq!(catalog.len().unwrap(), 3);
// All remaining items should have their state as `Loaded`.
let new_item_entity = catalog
.with_items(|items| Ok(items.get("add-me").unwrap().clone()))
.unwrap();
assert_eq!(new_item_entity.state, EntityState::Loaded);
assert_eq!(
new_item_entity.value_of("name"),
Some(&Value::from("Add Me"))
);
let updated_item_entity = catalog
.with_items(|items| Ok(items.get("update-me").unwrap().clone()))
.unwrap();
assert_eq!(updated_item_entity.state, EntityState::Loaded);
assert_eq!(
updated_item_entity.value_of("name"),
Some(&Value::from("Updated"))
);
assert_eq!(
updated_item_entity.value_of("sell_trend"),
Some(&Value::from(10i64))
);
let untouched_item_entity = catalog
.with_items(|items| Ok(items.get("keep-me").unwrap().clone()))
.unwrap();
assert_eq!(untouched_item_entity.state, EntityState::Loaded);
assert_eq!(
untouched_item_entity.value_of("name"),
Some(&Value::from("Keep Me"))
);
// Clean up
std::fs::remove_file(path).unwrap();
}
}
@@ -1,48 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
use std::sync::*;
use std::thread;
#[test]
fn thread_safety_test() {
let db_path = "target/test_dbs/thread_safety_test.db";
let path = std::path::Path::new(db_path);
if path.exists() {
std::fs::remove_file(path).unwrap();
}
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let catalog = Arc::new(Catalog::new(db_path));
catalog.init().unwrap();
let mut handles = vec![];
for i in 0..10 {
let catalog = Arc::clone(&catalog);
let handle = thread::spawn(move || {
for j in 0..100 {
let item_id = format!("item-{}-{}", i, j);
let item = Item {
id: item_id.clone(),
subclass: Some("subclass".to_string()),
name: format!("Item {} {}", i, j),
price: (i * 100 + j),
sell_trend: 0,
in_stock: true,
..Item::default()
};
catalog.upsert(item).unwrap();
}
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
let total_items = catalog.with_items(|items| Ok(items.len())).unwrap();
assert_eq!(total_items, 1000);
catalog.persist().unwrap();
let new_catalog = Catalog::new(db_path);
new_catalog.load::<Item>().unwrap();
let total_items_after_load = new_catalog.with_items(|items| Ok(items.len())).unwrap();
assert_eq!(total_items_after_load, 1000);
std::fs::remove_file(path).unwrap();
}
}
@@ -1,62 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
#[test]
fn upsert_should_add_single_entity_as_new() {
// 'upsert()': Should add a single entity to the 'items' map with 'EntityState::New'.
let catalog = Catalog::new("dummy.db");
let item = Item {
id: "item-123".to_string(),
name: "Test Item".to_string(),
price: 100,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let item_id = item.id.clone();
let _ = catalog.upsert(item);
let entity = catalog
.with_items(|items| Ok(items.get(&item_id).unwrap().clone()))
.unwrap();
assert_eq!(entity.id, item_id);
assert_eq!(entity.state, EntityState::New);
assert_eq!(entity.class, "item");
assert_eq!(entity.value_of("name"), Some(&Value::from("Test Item")));
assert_eq!(entity.value_of("price"), Some(&Value::from(100u32)));
assert_eq!(entity.value_of("sell_trend"), Some(&Value::from(0i64)));
assert_eq!(entity.value_of("in_stock"), Some(&Value::from(true)));
}
#[test]
fn upsert_should_overwrite_existing_entity() {
// 'upsert()': Should overwrite an existing entity with the same ID.
let catalog = Catalog::new("dummy.db");
let item1 = Item {
id: "item-123".to_string(),
name: "First Item".to_string(),
price: 100,
sell_trend: 0,
in_stock: true,
..Item::default()
};
let item_id = item1.id.clone();
let _ = catalog.upsert(item1);
let item2 = Item {
id: "item-123".to_string(),
name: "Second Item".to_string(),
price: 200,
sell_trend: 10,
in_stock: false,
..Item::default()
};
let _ = catalog.upsert(item2);
assert_eq!(catalog.len().unwrap(), 1);
let entity = catalog
.with_items(|items| Ok(items.get(&item_id).unwrap().clone()))
.unwrap();
assert_eq!(entity.value_of("name"), Some(&Value::from("Second Item")));
assert_eq!(entity.value_of("price"), Some(&Value::from(200u32)));
assert_eq!(entity.value_of("sell_trend"), Some(&Value::from(10i64)));
assert_eq!(entity.value_of("in_stock"), Some(&Value::from(false)));
assert_eq!(entity.state, EntityState::Updated);
}
}
-24
View File
@@ -1,24 +0,0 @@
pub mod catalog_delete;
pub mod catalog_for_each;
pub mod catalog_for_each_mut;
pub mod catalog_get;
pub mod catalog_get_by;
pub mod catalog_init;
pub mod catalog_insert_many;
pub mod catalog_integration;
pub mod catalog_list_by_class;
pub mod catalog_list_by_class_and_subclass;
pub mod catalog_load_by_class;
pub mod catalog_load_by_filter;
pub mod catalog_load_by_id;
pub mod catalog_new;
pub mod catalog_persist;
pub mod catalog_thread_safety;
pub mod catalog_upsert;
pub mod sqlite_init_db;
pub mod sqlite_load_attributes;
pub mod sqlite_load_by_class;
pub mod sqlite_load_by_id;
pub mod sqlite_map_row_to_attribute;
pub mod sqlite_map_row_to_entity;
pub mod sqlite_persist_catalog;
@@ -1,54 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
use rusqlite::*;
use std::fs;
use std::path::Path;
#[test]
fn init_db_should_create_tables_and_indexes_on_new_db() {
// This test verifies that a new database is correctly initialized with the necessary tables ('entity', 'attribute') and indexes.
let db_path = Path::new("test_new.db");
let _ = fs::remove_file(db_path); // Ensure the file doesn't exist
assert!(sqlite::init::db(db_path).is_ok());
let conn = Connection::open(db_path).unwrap();
let mut stmt = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('entity', 'attribute')")
.unwrap();
let tables: Vec<String> = stmt
.query_map([], |row| row.get(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(tables.len(), 2);
assert!(tables.contains(&"entity".to_string()));
assert!(tables.contains(&"attribute".to_string()));
let mut stmt = conn
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name IN ('entity_class', 'attribute_id')")
.unwrap();
let indexes: Vec<String> = stmt
.query_map([], |row| row.get(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(indexes.len(), 2);
assert!(indexes.contains(&"entity_class".to_string()));
assert!(indexes.contains(&"attribute_id".to_string()));
let _ = fs::remove_file(db_path);
}
#[test]
fn init_db_should_be_idempotent_and_not_fail_on_existing_db() {
// This test ensures that initializing an already existing and initialized database does not cause errors.
let db_path = Path::new("test_idempotent.db");
let _ = fs::remove_file(db_path);
assert!(sqlite::init::db(db_path).is_ok());
assert!(sqlite::init::db(db_path).is_ok());
let _ = fs::remove_file(db_path);
}
#[test]
fn init_db_should_fail_gracefully_on_invalid_path() {
// This test checks that the function returns an error when provided with an invalid or inaccessible file path.
// An invalid path, like a directory, should cause an error
let invalid_path = Path::new("/");
assert!(sqlite::init::db(invalid_path).is_err());
}
}
@@ -1,94 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
use rusqlite::*;
use std::{collections::HashMap, fs, path::Path};
fn setup_db(db_path: &Path) -> Connection {
let _ = fs::remove_file(db_path);
sqlite::init::db(db_path).unwrap();
Connection::open(db_path).unwrap()
}
#[test]
fn load_attributes_should_populate_entity_from_db() {
// Verifies that attributes for a given entity are correctly loaded from the database and added to the entity's attributes map.
let db_path = Path::new("test_load_attributes.db");
let mut conn = setup_db(db_path);
let mut entity = Entity {
id: "entity1".to_string(),
class: "class1".to_string(),
subclass: None,
attributes: HashMap::new(),
state: Default::default(),
ref_date: None,
};
conn.execute(
"INSERT INTO entity (id, class) VALUES (?1, ?2)",
params![&entity.id, &entity.class],
)
.unwrap();
conn.execute(
"INSERT INTO attribute (id, entity_id, value_text) VALUES ('attr1', 'entity1', 'hello')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO attribute (id, entity_id, value_int) VALUES ('attr2', 'entity1', 42)",
[],
)
.unwrap();
let tx = conn.transaction().unwrap();
assert!(sqlite::load::attributes(&tx, &mut entity).is_ok());
tx.commit().unwrap();
assert_eq!(entity.attributes.len(), 2);
let attr1 = entity.attributes.get("attr1").unwrap();
assert_eq!(attr1.id, "attr1");
assert_eq!(attr1.value, Value::Text("hello".to_string()));
let attr2 = entity.attributes.get("attr2").unwrap();
assert_eq!(attr2.id, "attr2");
assert_eq!(attr2.value, Value::SignedInt(42));
fs::remove_file(db_path).unwrap();
}
#[test]
fn load_attributes_should_handle_entities_with_no_attributes() {
// Ensures that the function completes without error and without adding attributes for an entity that has none in the database.
let db_path = Path::new("test_no_attributes.db");
let mut conn = setup_db(db_path);
let mut entity = Entity {
id: "entity2".to_string(),
class: "class1".to_string(),
subclass: None,
attributes: HashMap::new(),
state: Default::default(),
ref_date: None,
};
conn.execute(
"INSERT INTO entity (id, class) VALUES (?1, ?2)",
params![&entity.id, &entity.class],
)
.unwrap();
let tx = conn.transaction().unwrap();
assert!(sqlite::load::attributes(&tx, &mut entity).is_ok());
tx.commit().unwrap();
assert!(entity.attributes.is_empty());
fs::remove_file(db_path).unwrap();
}
#[test]
fn load_attributes_should_return_error_on_query_failure() {
// Checks that an error is returned if the database query to select attributes fails.
let db_path = Path::new("test_query_failure.db");
let mut conn = setup_db(db_path);
conn.execute("DROP TABLE attribute", []).unwrap();
let mut entity = Entity {
id: "entity3".to_string(),
class: "class1".to_string(),
subclass: None,
attributes: HashMap::new(),
state: Default::default(),
ref_date: None,
};
let tx = conn.transaction().unwrap();
let result = sqlite::load::attributes(&tx, &mut entity);
assert!(result.is_err());
fs::remove_file(db_path).unwrap();
}
}
@@ -1,77 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
use rusqlite::*;
use std::{fs, path::Path};
fn setup_db(db_path: &Path) {
let _ = fs::remove_file(db_path);
sqlite::init::db(db_path).unwrap();
let conn = Connection::open(db_path).unwrap();
// Class 'c1'
conn.execute("INSERT INTO entity (id, class) VALUES ('e1_c1', 'c1')", [])
.unwrap();
conn.execute(
"INSERT INTO attribute (id, entity_id, value_text) VALUES ('a1', 'e1_c1', 'v1')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO attribute (id, entity_id, value_int) VALUES ('a2', 'e1_c1', 1)",
[],
)
.unwrap();
conn.execute("INSERT INTO entity (id, class) VALUES ('e2_c1', 'c1')", [])
.unwrap();
// Class 'c2'
conn.execute("INSERT INTO entity (id, class) VALUES ('e1_c2', 'c2')", [])
.unwrap();
}
#[test]
fn load_by_class_should_fetch_all_entities_for_a_given_class() {
// Verifies that all entities belonging to a specific class are retrieved from the database.
let db_path = Path::new("test_fetch_by_class.db");
setup_db(db_path);
let result = sqlite::load::by_class(db_path, "c1");
assert!(result.is_ok());
let entities = result.unwrap();
assert_eq!(entities.len(), 2);
let ids: Vec<_> = entities.iter().map(|e| e.id.clone()).collect();
assert!(ids.contains(&"e1_c1".to_string()));
assert!(ids.contains(&"e2_c1".to_string()));
fs::remove_file(db_path).unwrap();
}
#[test]
fn load_by_class_should_return_empty_vec_for_non_existent_class() {
// Ensures that an empty vector is returned when querying for a class that has no entities in the database.
let db_path = Path::new("test_non_existent_class.db");
setup_db(db_path);
let result = sqlite::load::by_class(db_path, "non_existent");
assert!(result.is_ok());
let entities = result.unwrap();
assert!(entities.is_empty());
fs::remove_file(db_path).unwrap();
}
#[test]
fn load_by_class_should_fully_load_entities_with_attributes() {
// Checks that the retrieved entities are complete, including all their associated attributes.
let db_path = Path::new("test_fully_load.db");
setup_db(db_path);
let entities = sqlite::load::by_class(db_path, "c1").unwrap();
let entity1 = entities.iter().find(|e| e.id == "e1_c1").unwrap();
assert_eq!(entity1.attributes.len(), 2);
assert!(entity1.attributes.contains_key("a1"));
assert!(entity1.attributes.contains_key("a2"));
let attr1 = entity1.attributes.get("a1").unwrap();
assert_eq!(attr1.value, Value::Text("v1".to_string()));
let attr2 = entity1.attributes.get("a2").unwrap();
assert_eq!(attr2.value, Value::SignedInt(1));
fs::remove_file(db_path).unwrap();
}
#[test]
fn load_by_class_should_fail_gracefully_on_db_connection_error() {
// Tests that the function returns an appropriate error if the database connection cannot be established.
let invalid_path = Path::new("/"); // A directory is not a valid database file
let result = sqlite::load::by_class(invalid_path, "any_class");
assert!(result.is_err());
}
}
@@ -1,81 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
use rusqlite::*;
use std::{fs, path::Path};
fn setup_db(db_path: &Path) {
let _ = fs::remove_file(db_path);
sqlite::init::db(db_path).unwrap();
let conn = Connection::open(db_path).unwrap();
conn.execute("INSERT INTO entity (id, class) VALUES ('e1', 'c1')", [])
.unwrap();
conn.execute(
"INSERT INTO attribute (id, entity_id, value_text) VALUES ('a1', 'e1', 'v1')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO attribute (id, entity_id, value_int) VALUES ('a2', 'e1', 100)",
[],
)
.unwrap();
conn.execute("INSERT INTO entity (id, class) VALUES ('e2', 'c2')", [])
.unwrap();
}
#[test]
fn load_by_id_should_fetch_correct_entity() {
// Verifies that the correct entity is retrieved from the database when a valid ID is provided.
let db_path = Path::new("test_fetch_by_id.db");
setup_db(db_path);
let result = sqlite::load::by_id(db_path, "e1");
assert!(result.is_ok());
let entity_opt = result.unwrap();
assert!(entity_opt.is_some());
let entity = entity_opt.unwrap();
assert_eq!(entity.id, "e1");
assert_eq!(entity.class, "c1");
fs::remove_file(db_path).unwrap();
}
#[test]
fn load_by_id_should_return_none_for_non_existent_id() {
// Ensures that `Ok(None)` is returned when querying for an ID that does not exist in the database.
let db_path = Path::new("test_non_existent_id.db");
setup_db(db_path);
let result = sqlite::load::by_id(db_path, "non_existent");
assert!(result.is_ok());
let entity_opt = result.unwrap();
assert!(entity_opt.is_none());
fs::remove_file(db_path).unwrap();
}
#[test]
fn load_by_id_should_load_entity_with_all_attributes() {
// Checks that the retrieved entity includes all of its associated attributes.
let db_path = Path::new("test_load_with_attributes.db");
setup_db(db_path);
let entity = sqlite::load::by_id(db_path, "e1").unwrap().unwrap();
assert_eq!(entity.attributes.len(), 2);
let attr1 = entity.attributes.get("a1").unwrap();
assert_eq!(attr1.value, Value::Text("v1".to_string()));
let attr2 = entity.attributes.get("a2").unwrap();
assert_eq!(attr2.value, Value::SignedInt(100));
fs::remove_file(db_path).unwrap();
}
#[test]
fn load_by_id_should_fail_gracefully_on_db_error() {
// Tests that an error is returned if the database query fails for any reason.
// Test case 1: Invalid path
let invalid_path = Path::new("/");
let result = sqlite::load::by_id(invalid_path, "any_id");
assert!(result.is_err());
// Test case 2: Query error
let db_path = Path::new("test_db_error.db");
let _ = fs::remove_file(db_path);
sqlite::init::db(db_path).unwrap();
let conn = Connection::open(db_path).unwrap();
conn.execute("DROP TABLE entity", []).unwrap();
drop(conn); // Close connection before `run` tries to open it.
let result = sqlite::load::by_id(db_path, "any_id");
assert!(result.is_err());
fs::remove_file(db_path).unwrap();
}
}
@@ -1,74 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
use rusqlite::*;
fn get_row_from_query(conn: &Connection, query: &str) -> Result<Attribute, rusqlite::Error> {
let mut stmt = conn.prepare(query).unwrap();
stmt.query_row([], sqlite::map::row_to_attribute)
}
#[test]
fn map_row_to_attribute_with_signed_int_value() {
// Verifies that a database row with a signed integer value is correctly mapped to an Attribute with a Value::SignedInt.
let conn = Connection::open_in_memory().unwrap();
let attr =
get_row_from_query(&conn, "SELECT 'a1', 'e1', -42, NULL, NULL, NULL, NULL").unwrap();
assert_eq!(attr.id, "a1");
assert_eq!(attr.value, Value::SignedInt(-42));
}
#[test]
fn map_row_to_attribute_with_unsigned_int_value() {
// Verifies that a database row with an unsigned integer value is correctly mapped to an Attribute with a Value::UnsignedInt.
let conn = Connection::open_in_memory().unwrap();
let attr =
get_row_from_query(&conn, "SELECT 'a2', 'e1', NULL, 42, NULL, NULL, NULL").unwrap();
assert_eq!(attr.id, "a2");
assert_eq!(attr.value, Value::UnsignedInt(42));
}
#[test]
fn map_row_to_attribute_with_real_value() {
// Verifies that a database row with a real (float) value is correctly mapped to an Attribute with a Value::Real.
let conn = Connection::open_in_memory().unwrap();
let attr = get_row_from_query(&conn, "SELECT 'a3', 'e1', NULL, NULL, 12.2345, NULL, NULL")
.unwrap();
assert_eq!(attr.id, "a3");
assert_eq!(attr.value, Value::Real(12.2345));
}
#[test]
fn map_row_to_attribute_with_text_value() {
// Verifies that a database row with a text value is correctly mapped to an Attribute with a Value::Text.
let conn = Connection::open_in_memory().unwrap();
let attr = get_row_from_query(&conn, "SELECT 'a4', 'e1', NULL, NULL, NULL, 'hello', NULL")
.unwrap();
assert_eq!(attr.id, "a4");
assert_eq!(attr.value, Value::Text("hello".to_string()));
}
#[test]
fn map_row_to_attribute_with_bool_value() {
// Verifies that a database row with a boolean value is correctly mapped to an Attribute with a Value::Bool.
let conn = Connection::open_in_memory().unwrap();
let attr =
get_row_from_query(&conn, "SELECT 'a5', 'e1', NULL, NULL, NULL, NULL, 1").unwrap();
assert_eq!(attr.id, "a5");
assert_eq!(attr.value, Value::Bool(true));
}
#[test]
#[should_panic]
fn map_row_to_attribute_should_panic_on_multiple_values() {
// Ensures that the function panics if a row contains data in more than one 'value' column, which indicates data corruption.
let conn = Connection::open_in_memory().unwrap();
// This should panic because both signed_int and text have values.
get_row_from_query(&conn, "SELECT 'a6', 'e1', -42, NULL, NULL, 'hello', NULL").unwrap();
}
#[test]
fn map_row_to_attribute_should_return_error_on_type_mismatch() {
// Checks that a rusqlite::Error is returned if a column's type does not match the expected schema (e.g., text in an integer column).
let conn = Connection::open_in_memory().unwrap();
// Trying to get a text value as an integer should fail.
let result = get_row_from_query(
&conn,
// The value 'not a number' is in the signed_int column position.
"SELECT 'a7', 'e1', 'not a number', NULL, NULL, NULL, NULL",
);
assert!(result.is_err());
}
}
@@ -1,49 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
use rusqlite::*;
fn get_entity_from_query(conn: &Connection, query: &str) -> Result<Entity, rusqlite::Error> {
let mut stmt = conn.prepare(query).unwrap();
stmt.query_row([], sqlite::map::row_to_entity)
}
#[test]
fn map_row_to_entity_should_correctly_map_valid_row() {
// Verifies that a valid database row is correctly mapped to an Entity struct with all fields populated.
let conn = Connection::open_in_memory().unwrap();
let entity = get_entity_from_query(&conn, "SELECT 'e1', 'c1', 's1', 12345").unwrap();
assert_eq!(entity.id, "e1");
assert_eq!(entity.class, "c1");
assert_eq!(entity.subclass, Some("s1".to_string()));
assert_eq!(entity.ref_date, Some(12345));
assert_eq!(entity.state, EntityState::Loaded);
assert!(entity.attributes.is_empty());
}
#[test]
fn map_row_to_entity_should_handle_null_ref_date() {
// Ensures that a row with a NULL 'ref_date' is successfully mapped to an Entity with 'ref_date' as None.
let conn = Connection::open_in_memory().unwrap();
let entity = get_entity_from_query(&conn, "SELECT 'e2', 'c2', 's2', NULL").unwrap();
assert_eq!(entity.id, "e2");
assert_eq!(entity.class, "c2");
assert_eq!(entity.subclass, Some("s2".to_string()));
assert_eq!(entity.ref_date, None);
}
#[test]
fn map_row_to_entity_should_handle_null_subclass() {
// Ensures that a row with a NULL 'subclass' is successfully mapped to an Entity with 'subclass' as None.
let conn = Connection::open_in_memory().unwrap();
let entity = get_entity_from_query(&conn, "SELECT 'e2', 'c2', NULL, NULL").unwrap();
assert_eq!(entity.id, "e2");
assert_eq!(entity.class, "c2");
assert_eq!(entity.subclass, None);
assert_eq!(entity.ref_date, None);
}
#[test]
fn map_row_to_entity_should_return_error_on_type_mismatch() {
// Checks that a rusqlite::Error is returned if a column's type does not match the expected schema.
let conn = Connection::open_in_memory().unwrap();
// Trying to get an integer as a string for the class should fail.
let result = get_entity_from_query(&conn, "SELECT 'e3', 123, NULL");
assert!(result.is_err());
}
}
@@ -1,192 +0,0 @@
#[cfg(test)]
mod tests {
use crate::*;
use rusqlite::*;
use std::{collections::HashMap, fs, path::Path};
fn setup_db(db_path: &Path) -> Connection {
let _ = fs::remove_file(db_path);
fun::sqlite_init_db::run(db_path).unwrap();
Connection::open(db_path).unwrap()
}
fn count_rows(conn: &Connection, table: &str, where_clause: &str) -> i64 {
let mut stmt = conn
.prepare(&format!(
"SELECT COUNT(*) FROM {} WHERE {}",
table, where_clause
))
.unwrap();
stmt.query_row([], |row| row.get(0)).unwrap()
}
#[test]
fn persist_should_insert_new_entities_and_attributes() {
// Verifies that entities marked as 'New' in the catalog are inserted into the database, along with all their attributes.
let db_path = Path::new("test_insert.db");
let conn = setup_db(db_path);
let catalog = Catalog::new(db_path.to_str().unwrap());
let mut entity = Entity {
id: "e1".to_string(),
class: "c1".to_string(),
subclass: None,
attributes: HashMap::new(),
state: EntityState::New,
ref_date: None,
};
entity.attributes.insert(
"a1".to_string(),
Attribute {
id: "a1".to_string(),
value: Value::Text("v1".to_string()),
},
);
let _ = catalog.on_items(|items| {
items.insert("e1".to_string(), entity);
Ok(())
});
let is_ok = catalog
.with_items(|items| Ok(sqlite::persist::catalog(db_path, items).is_ok()))
.unwrap();
assert!(is_ok);
assert_eq!(count_rows(&conn, "entity", "id = 'e1'"), 1);
assert_eq!(count_rows(&conn, "attribute", "entity_id = 'e1'"), 1);
fs::remove_file(db_path).unwrap();
}
#[test]
fn persist_should_delete_entities_marked_for_deletion() {
// Ensures that entities marked as 'ToDelete' are correctly removed from the database.
let db_path = Path::new("test_delete.db");
let conn = setup_db(db_path);
conn.execute("INSERT INTO entity (id, class) VALUES ('e1', 'c1')", [])
.unwrap();
let catalog = Catalog::new(db_path.to_str().unwrap());
let entity = Entity {
id: "e1".to_string(),
class: "c1".to_string(),
subclass: None,
attributes: HashMap::new(),
state: EntityState::ToDelete,
ref_date: None,
};
let _ = catalog.on_items(|items| {
items.insert("e1".to_string(), entity);
Ok(())
});
let is_ok = catalog
.with_items(|items| Ok(sqlite::persist::catalog(db_path, items).is_ok()))
.unwrap();
assert!(is_ok);
assert_eq!(count_rows(&conn, "entity", "id = 'e1'"), 0);
fs::remove_file(db_path).unwrap();
}
#[test]
fn persist_should_handle_a_mix_of_new_and_deleted_entities() {
// Tests the function's ability to handle a batch operation involving both new entities to be inserted and existing ones to be deleted.
let db_path = Path::new("test_mix.db");
let conn = setup_db(db_path);
conn.execute("INSERT INTO entity (id, class) VALUES ('e1', 'c1')", [])
.unwrap();
let catalog = Catalog::new(db_path.to_str().unwrap());
let to_delete = Entity {
id: "e1".to_string(),
class: "c1".to_string(),
subclass: None,
attributes: HashMap::new(),
state: EntityState::ToDelete,
ref_date: None,
};
let to_add = Entity {
id: "e2".to_string(),
class: "c2".to_string(),
subclass: None,
attributes: HashMap::new(),
state: EntityState::New,
ref_date: None,
};
let _ = catalog.on_items(|items| {
items.insert("e1".to_string(), to_delete);
Ok(())
});
let _ = catalog.on_items(|items| {
items.insert("e2".to_string(), to_add);
Ok(())
});
let is_ok = catalog
.with_items(|items| Ok(sqlite::persist::catalog(db_path, items).is_ok()))
.unwrap();
assert!(is_ok);
assert_eq!(count_rows(&conn, "entity", "id = 'e1'"), 0);
assert_eq!(count_rows(&conn, "entity", "id = 'e2'"), 1);
fs::remove_file(db_path).unwrap();
}
#[test]
fn persist_should_not_affect_unmodified_entities() {
// Verifies that entities in the catalog that are not marked as 'New' or 'ToDelete' remain untouched in the database.
let db_path = Path::new("test_unmodified.db");
let conn = setup_db(db_path);
conn.execute("INSERT INTO entity (id, class) VALUES ('e1', 'c1')", [])
.unwrap();
let catalog = Catalog::new(db_path.to_str().unwrap());
let unmodified = Entity {
id: "e1".to_string(),
class: "c1".to_string(),
subclass: None,
attributes: HashMap::new(),
state: EntityState::Loaded,
ref_date: None,
};
let _ = catalog.on_items(|items| {
items.insert("e1".to_string(), unmodified);
Ok(())
});
let is_ok = catalog
.with_items(|items| Ok(sqlite::persist::catalog(db_path, items).is_ok()))
.unwrap();
assert!(is_ok);
assert_eq!(count_rows(&conn, "entity", "id = 'e1'"), 1);
fs::remove_file(db_path).unwrap();
}
#[test]
fn persist_should_rollback_transaction_on_failure() {
// Ensures that if any part of the persistence process fails, the entire transaction is rolled back, leaving the database state unchanged.
let db_path = Path::new("test_rollback.db");
let conn = setup_db(db_path);
conn.execute(
"INSERT INTO entity (id, class) VALUES ('e_existing', 'c1')",
[],
)
.unwrap();
let catalog = Catalog::new(db_path.to_str().unwrap());
let mut new_entity = Entity {
id: "e_new".to_string(),
class: "c1".to_string(),
subclass: None,
attributes: HashMap::new(),
state: EntityState::New,
ref_date: None,
};
new_entity.attributes.insert(
"a1".to_string(),
Attribute {
id: "a1".to_string(),
value: Value::Text("v1".to_string()),
},
);
let _ = catalog.on_items(|items| {
items.insert("e_new".to_string(), new_entity);
Ok(())
});
// Corrupt the DB to cause a failure during the transaction
conn.execute("DROP TABLE attribute", []).unwrap();
drop(conn);
let is_err = catalog
.with_items(|items| Ok(sqlite::persist::catalog(db_path, items).is_err()))
.unwrap();
assert!(is_err);
// Re-open connection to check state
let conn = Connection::open(db_path).unwrap();
// The new entity should not have been inserted due to rollback
assert_eq!(count_rows(&conn, "entity", "id = 'e_new'"), 0);
// The existing entity should still be there
assert_eq!(count_rows(&conn, "entity", "id = 'e_existing'"), 1);
fs::remove_file(db_path).unwrap();
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 rusty-donkey Copyright (c) 2026 rusty-donkey
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including associated documentation files (the "Software"), to deal in the Software without restriction, including
+2 -141
View File
@@ -1,142 +1,3 @@
# heave 󱅝 # heave
A Rust EAV data model implementation. A Rust EAV data model implementation for SQLite
Heave is a Rust library that provides a flexible and extensible implementation of the Entity-Attribute-Value (EAV) data model. It allows you to manage data with a dynamic schema, making it suitable for scenarios where attributes of entities are not known at compile time. The library includes support for persisting the EAV data to a SQLite database.
> ## 🚧 A Note From the Developer 🚧
>
> Thank you for checking out Heave! I'm so excited you're here and interested in my project at this early stage.
>
> I want to be upfront: this is my work-in-progress, and I'm still building and experimenting a lot. This means things are changing constantly. As you dive in, please keep in mind:
>
> * **I'll be making frequent breaking changes.** I'm still shaping the core API, so I'll be refactoring things often. Your code will likely break between updates.
> * **You'll find bugs.** I'm working hard, but things might not always work as expected. I'd be incredibly grateful if you reported any issues you find!
> * **Features are incomplete.** Many parts of the project are still on my drawing board or only partially implemented.
>
> Because of all this, I'm asking you to use Heave for evaluation and feedback only. **I strongly recommend not using it in a production environment just yet.**
>
> Your feedback is invaluable in helping me shape the future of Heave. I'd love to hear from you, so please feel free to open an issue!
## Typed Entities
Typed entities may be used leveraging the `EAV` trait to map to and from schemaless entities.
## EAV
Learn more on EAV data model [here](https://en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model).
## Workspace Structure
- **01.workspace**: this folder contains all workspace crates
## heave crate structure
- **src/fun**: contains all functions
- **src/imp**: contains all implementation (impl blocks)
- **src/mcr**: contains all macros
- **src/str**: contains all structures
- **src/trt**: contains all traits
- **src/tst**: contains all tests
- **lib.rs**: renames and re-exports code compnents
## Example
Here is an example of how to define a typed entity, persist it to a SQLite database, and load it back.
```rust
use heave::*;
// Define a struct named `Product` to represent a product.
struct Product {
// `id` is a public field of type `String` to uniquely identify the product.
pub id: String,
// `name` is a public field of type `String` for the product's name.
pub name: String,
// `model` is a public optional field of type `String` for the product's model.
pub model: Option<String>,
// `price` is a public field of type `i64` for the product's price.
pub price: i64,
}
// Implement the `EAV` trait for the `Product` struct.
impl EAV for Product {
// `class` is a function that returns the class name of the entity.
fn class() -> &'static str {
"product"
}
}
// Implement the `From<Product>` trait for the `Entity` struct.
impl From<Product> for Entity {
// `from` is a function that converts a `Product` into an `Entity`.
fn from(value: Product) -> Entity {
// Create a new `Entity` for the `Product` class.
Entity::new::<Product>()
// Set the entity's ID from the product's ID.
.with_id(&value.id)
// Add the "name" attribute with the product's name.
.with_attribute("name", value.name)
// Add the optional "model" attribute with the product's model.
.with_opt_attribute("model", value.model)
// Add the "price" attribute with the product's price.
.with_attribute("price", value.price)
}
}
// Implement the `From<Entity>` trait for the `Product` struct.
impl From<Entity> for Product {
// `from` is a function that converts an `Entity` into a `Product`.
fn from(value: Entity) -> Self {
// Create a new `Product` from the entity's attributes.
Self {
// Set the product's ID from the entity's ID.
id: value.id.clone(),
// Unwrap the "name" attribute to get the product's name.
name: value.unwrap("name"),
// Unwrap the optional "model" attribute to get the product's model.
model: value.unwrap_opt("model"),
// Unwrap the "price" attribute to get the product's price.
price: value.unwrap("price"),
}
}
}
fn main() {
// Define the path for the SQLite database file.
let db_path = "./simple_product.sqlite3";
// Create a new `Catalog` instance with the specified database path.
let mut catalog = Catalog::new(db_path);
// Initialize the catalog, which sets up the database.
catalog.init().unwrap();
// Create a new `Product` instance representing a laptop.
let new_laptop = Product {
id: "LT001".to_string(),
name: "SuperPenguin".to_string(),
model: Some("Mark III.2".to_string()),
price: 125000,
};
// Insert the new laptop into the catalog. Note that at this time the product is in memory.
catalog.upsert(new_laptop);
// Persist the changes in the catalog to the database.
catalog.persist().unwrap();
// Remove the SQLite database file.
std::fs::remove_file(db_path).unwrap();
}
```
## Contributing
Contributions are welcome! If you'd like to contribute to heave, please follow these steps:
1. Fork the repository.
2. Create a new branch for your feature (feature/my_awesome_feature) or bug fix (fix/my_awesome_fix).
3. Make your changes and commit them with a clear message.
4. Push your branch to your fork.
5. Open a pull request to the main repository.
Please ensure that your code adheres to the existing style and that all tests pass before submitting a pull request.
## Credits
- **Project Icon:** by [SmashIcons](https://www.flaticon.com/authors/smashicons)