Compare commits

..

249 Commits

Author SHA1 Message Date
davidemazzocchi d5497906be review: change test function name to make it clearer and coherent 2026-03-09 08:39:15 +01:00
davidemazzocchi b058aac5da review: delete unused read from source while mapping attributes 2026-03-09 08:39:15 +01:00
davidemazzocchi 150c2263d2 fix: resolve sql injection while building dynamic query for filters 2026-03-09 08:39:15 +01:00
davidemazzocchi 32cf95a837 chore: add a TODO reminder for possible SQL injection! 2026-03-09 08:39:15 +01:00
davidemazzocchi b3ee2da12c chore: run cargo format on the code base 2026-03-09 08:39:15 +01:00
davidemazzocchi de7849dfde chore: remove small inefficiency from comparison statement 2026-03-09 08:39:15 +01:00
davidemazzocchi 1adf4f0f2d chore: update documentation of catalog.for_each method 2026-03-09 08:39:15 +01:00
davidemazzocchi 98bc282c32 fix: make catalog.delete coherent with other methods propagating errors 2026-03-09 08:39:15 +01:00
davidemazzocchi 70ff843522 chore: fix example by deleting the useless map from list 2026-03-09 08:39:15 +01:00
davidemazzocchi 4b27fa3700 chore: commit cargo.lock after verion bump 2026-03-09 08:39:15 +01:00
davidemazzocchi 469de1f01c chore: add push force to .justfile 2026-03-09 08:39:15 +01:00
davidemazzocchi a641599476 chore: bump version to 0.10.0 2026-03-09 08:39:15 +01:00
davidemazzocchi 915f9d320d doc: update documentation of catalog.list_by_subclass method 2026-03-09 08:39:15 +01:00
davidemazzocchi 181e310ebd feat: simplify return type of catalog.list_by_subclass method 2026-03-09 08:39:15 +01:00
davidemazzocchi dcfa4dee4b doc: update documentation of catalog.list method 2026-03-09 08:39:15 +01:00
davidemazzocchi 68c6ca4e9f feat: simplify return type of catalog.list method 2026-03-09 08:39:15 +01:00
davidemazzocchi cdf5ead07d chore: bump minor version to 0.9.0 2026-03-09 08:39:15 +01:00
davidemazzocchi dd259fcbe7 chore: rewrite mapping closures for sqlite errors 2026-03-09 08:39:15 +01:00
davidemazzocchi feecaea417 feat: encapsulate rusqlite::Error into sqlite::FailedTo (PrepareStatement) 2026-03-09 08:39:15 +01:00
davidemazzocchi 09786f4d77 feat: encapsulate rusqlite::Error into sqlite::FailedTo (OpenConnection) 2026-03-09 08:39:15 +01:00
davidemazzocchi b48ae74a47 feat: encapsulate rusqlite::Error into sqlite::FailedTo (ExecuteStatement) 2026-03-09 08:39:15 +01:00
davidemazzocchi 302ede497f feat: encapsulate rusqlite::Error into sqlite::FailedTo (ExecuteQuery) 2026-03-09 08:39:15 +01:00
davidemazzocchi b17d9fc988 feat: encapsulate rusqlite::Error into sqlite::FailedTo (ExecuteBatch) 2026-03-09 08:39:15 +01:00
davidemazzocchi dfb168b031 feat: encapsulate rusqlite::Error into sqlite::FailedTo enum (CommitTransaction) 2026-03-09 08:39:15 +01:00
davidemazzocchi 0467ef6a75 feat: encapsulate rusqlite::Error into sqlite::FailedTo enum (begin transaction) 2026-03-09 08:39:15 +01:00
davidemazzocchi 4cca2f1126 feat: make IsExactly equality more strict and case sensitive 2026-03-09 08:39:15 +01:00
davidemazzocchi ed936be08c chore: make entity column names explicity during query in load by id 2026-03-09 08:39:15 +01:00
davidemazzocchi 558be49adb chore: make entity column names explicity during query in load by class 2026-03-09 08:39:15 +01:00
davidemazzocchi 9ff09c402f chore: add explicit listing to attribute columns output in query 2026-03-09 08:39:15 +01:00
davidemazzocchi 4075c2e58b feat: implement to_sql trait for value enum 2026-03-09 08:39:14 +01:00
davidemazzocchi 51e18d66a7 fix: update rust doc for unsigned 32 value variant 2026-03-09 08:39:14 +01:00
davidemazzocchi e30dcbb9c9 feat: add a new index for entity_id in table attribute 2026-03-09 08:39:14 +01:00
davidemazzocchi 8d7b3592de chore: add github repository url in crate Cargo.toml 2026-03-09 08:39:14 +01:00
davidemazzocchi 338521db43 chore: add description, license and keywords to crate Cargo.toml 2026-03-09 08:39:14 +01:00
davidemazzocchi 313248b1a5 fix: change mutability to ref while loading by id 2026-03-09 08:39:14 +01:00
davidemazzocchi 5db868b192 fix: change mutability to ref while loading with filter 2026-03-09 08:39:14 +01:00
davidemazzocchi 5215a3a724 fix: change mutability to ref while loading and inserting many 2026-03-09 08:39:14 +01:00
davidemazzocchi edc9d64744 fix: change mutability to ref while deleting items from catalog 2026-03-09 08:39:14 +01:00
davidemazzocchi 7718daddda chore: rename key parameter to id while checking for existence 2026-03-09 08:39:14 +01:00
davidemazzocchi 46ab14738d fix: update condition types after u64 deprecation from rusqlite update 2026-03-09 08:39:14 +01:00
davidemazzocchi 33e40fb634 fix: avoid init and writig db file during automatic tests 2026-03-09 08:39:14 +01:00
davidemazzocchi 9a10d75304 chore: clean useless conversion after ditching u64 2026-03-09 08:39:14 +01:00
davidemazzocchi 0f79f4adfb chore: sort alphabetically use directives in example 2026-03-09 08:39:14 +01:00
davidemazzocchi 1f9228f31e chore: update method name from load_by_class to load 2026-03-09 08:39:14 +01:00
davidemazzocchi b59cb0fb56 chore: upgrade rusqlite dependency to 0.38.0 2026-03-09 08:39:14 +01:00
davidemazzocchi 3b3e495254 chore: rename list_by_class to list 2026-03-09 08:39:14 +01:00
davidemazzocchi 7847394d13 chore: box structs into modules (catalog, eav, filter) 2026-03-09 08:39:14 +01:00
davidemazzocchi 4010149ea6 doc: document method catalog.ensure_init 2026-03-09 08:39:14 +01:00
davidemazzocchi 98e50faa4e feat: add method ensure_init runnable at will without overhead 2026-03-09 08:39:14 +01:00
davidemazzocchi e316ec83a3 chore: tidy redeclarations in lib.rs 2026-03-09 08:39:14 +01:00
davidemazzocchi 4428cd094f chore: rename load_by_class_and_subclass to load_by_subclass 2026-03-09 08:39:14 +01:00
davidemazzocchi cb0f0cfa31 chore: rename load_by_class to load 2026-03-09 08:39:14 +01:00
davidemazzocchi f86d53e16f chore: delete test database wrongly pushed 2026-03-09 08:39:14 +01:00
davidemazzocchi da74060d51 feat: return a vector of boxed error from catalog.for_each_mut
execution is not interrupted and user can raise any error implementing
Error trait
2026-03-09 08:39:14 +01:00
davidemazzocchi 222864389c chore: add Clone as Trait requirement for EAV 2026-03-09 08:39:14 +01:00
davidemazzocchi 91dc75b07b test: add base test for catalog.for_each_mut 2026-03-09 08:39:14 +01:00
davidemazzocchi e9b136341b doc: add documentation for catalog.for_each and catalog.for_each_mut 2026-03-09 08:39:14 +01:00
davidemazzocchi 34808ccf74 feat: allow predicate to generate an error and return it boxed 2026-03-09 08:39:14 +01:00
davidemazzocchi bbc6105452 test: add tests to catalog.for_each, set FnMut requirement for predicate 2026-03-09 08:39:14 +01:00
davidemazzocchi f8b8d4c9e3 chore: correct wrong category spelling 2026-03-09 08:39:14 +01:00
davidemazzocchi cd0679c20c feat: add catalog.for_each_mut to apply changes to all items iteratively 2026-03-09 08:39:13 +01:00
davidemazzocchi 23a6ccf8fc feat: add catalog.for_each to inspect all items iteratively 2026-03-09 08:39:13 +01:00
davidemazzocchi 20078034c5 feat: add PartialEq trait requirement to EAV 2026-03-09 08:39:13 +01:00
davidemazzocchi 2ecee20145 feat: add catalog.get_by in order to get items given a predicate 2026-03-09 08:39:13 +01:00
davidemazzocchi 2aa1e7cb46 doc: update documentation of catalog.persist 2026-03-09 08:39:13 +01:00
davidemazzocchi 44f548562b doc: update documentation of catalog.upsert 2026-03-09 08:39:13 +01:00
davidemazzocchi 8a23cfc34d doc: update documentation of catalog.load_by_class 2026-03-09 08:39:13 +01:00
davidemazzocchi 7a250201b3 doc: update ducomentation of catalog.load_by_filter 2026-03-09 08:39:13 +01:00
davidemazzocchi 5a15a018ab doc: update documentation of catalog.load_by_id 2026-03-09 08:39:13 +01:00
davidemazzocchi 25a39ce54c doc: update documentation of catalog.insert_many 2026-03-09 08:39:13 +01:00
davidemazzocchi 6a8351b4d6 doc: update documentation of catalog.init 2026-03-09 08:39:13 +01:00
davidemazzocchi dcffe956fd doc: update documentation of catalog.get 2026-03-09 08:39:13 +01:00
davidemazzocchi 14469c7229 chore: add a space for convenient jumping 2026-03-09 08:39:13 +01:00
davidemazzocchi 00d8ebdb38 test: add two more field to struct Item to test i32 and u32 values 2026-03-09 08:39:13 +01:00
davidemazzocchi cfcacefe9a test: add properties to Item struct to improve test code coverage for Entity 2026-03-09 08:39:13 +01:00
davidemazzocchi 7ae351415e chore: add code_coverage command to justfile 2026-03-09 08:39:13 +01:00
davidemazzocchi 54d4d7511a chore: run cargo update to latest minor versions 2026-03-09 08:39:13 +01:00
davidemazzocchi 7446f0db1c fix: call proper method to get entity id 2026-03-09 08:39:13 +01:00
davidemazzocchi 7398de4559 fix: set proper mutability level for with_items call 2026-03-09 08:39:13 +01:00
davidemazzocchi 2fa3e423e5 chore: add warning for missing documentation 2026-03-09 08:39:13 +01:00
davidemazzocchi f7ab450e2c review: make entity fields private; expose id and subclass 2026-03-09 08:39:13 +01:00
davidemazzocchi 6ef36e2068 doc: update documentation to reach higher coverage 2026-03-09 08:39:13 +01:00
davidemazzocchi a38b96a667 test: add test for thread safety; remove mut for upsert and persist 2026-03-09 08:39:13 +01:00
davidemazzocchi 6f6df06be2 wip: add test for thread safety 2026-03-09 08:39:13 +01:00
davidemazzocchi e1178f7126 chore: restore examples after thread safety feature 2026-03-09 08:39:13 +01:00
davidemazzocchi 645e7560b3 chore: restore lib example after thread safety feature 2026-03-09 08:39:13 +01:00
davidemazzocchi bc4ac445c9 feat: set catalog as thread safe
wip: add a thread safe way to access items; change upsert

wip: change catalog.delete to be thread safe

wip: change catalog.load_by_* to be thread safe

wip: add a thread safe way to access items in readonly mode; change
catalog.get

wip: change catalog.persist to be thread safe

wip: disable examples temprarily

wip: remove catalog_new.rs file

wip: ignore result of iteration

wip: disable lib example

wip: enable catalog.delete tests

wip: enable catalog.get tests

wip enalbe catalog.init tests

wip: enable catalog.insert_many tests

wip: enable catalog.load_by_class tests

wip: enable catalog.load_by_id tests

wip: add len() and is_empty() convenience thread safe method to catalog

wip: use convenience methods for len and is_empty checks

wip: enable catalog.load_by_id and catalog.new tests

wip: enable catalog.upsert tests

wip: enable catalog.persist test; add catalog.contains_key

wip: enable catalog.load_by_filter tests

wip: enable sqlite_* tests

wip: enable list_by_class with tests

wip: enable list_by_class_and_subclass with tests

wip: enable integration tests

wip: switch iter to into_iter for tests
2026-03-09 08:39:13 +01:00
davidemazzocchi a2ad264b39 chore: extract tests from sqlite_* files into their own module 2026-03-09 08:39:13 +01:00
davidemazzocchi f5f0f189fa chore: extract tests from catalog.rs into their own module implementation 2026-03-09 08:39:13 +01:00
davidemazzocchi dedfe7a5d2 chore: move Item outside of catalog test mod 2026-03-09 08:39:12 +01:00
davidemazzocchi d4ecd1a6cc chore: extract catalog function into their own impl file
also get rid of
- get_by_class_and_attribute
- list_by_class_and_attribute
2026-03-09 08:39:12 +01:00
davidemazzocchi e362188438 chore: remove useless dependencies used only during early development 2026-03-09 08:39:12 +01:00
davidemazzocchi 0493a2ef66 doc: add an example to showcase how to work with class and subclass 2026-03-09 08:39:12 +01:00
davidemazzocchi c3594b6a3f feat: add entity.subclass index to db schema 2026-03-09 08:39:12 +01:00
davidemazzocchi 314c9ce323 feat: add class and subclass to filter struct 2026-03-09 08:39:12 +01:00
davidemazzocchi 276d6bd0bb test: add tests for catalog.list_by_class_and_subclass function 2026-03-09 08:39:12 +01:00
davidemazzocchi baa3fc9147 feat: add subclass to entity struct 2026-03-09 08:39:12 +01:00
davidemazzocchi c41e806817 doc: improve documentation of publicly available functions, structs and enums 2026-03-09 08:39:12 +01:00
davidemazzocchi 1b10d350cc doc: add crate level documentation with working example 2026-03-09 08:39:12 +01:00
davidemazzocchi 60f3e0023e doc: add filter example with multiple conditions 2026-03-09 08:39:12 +01:00
davidemazzocchi 9ee86c9cde fix: set unique names for attribute table in case of multiple filter conditions 2026-03-09 08:39:12 +01:00
davidemazzocchi 81bed07665 feat: handle comparisons =, >, <, >=, <= for condition real 2026-03-09 08:39:12 +01:00
davidemazzocchi bc9aee953c review: rewrite match statement for parameter building 2026-03-09 08:39:12 +01:00
davidemazzocchi 95373731d2 feat: handle comparisons "ends with" and "contains" for text condition 2026-03-09 08:39:12 +01:00
davidemazzocchi ceecdd3912 feat: handle comparison "starts with" for text condition
- rewrite statement generation to handle errors
- align "exactly match" with case insensitiveness of starts with
2026-03-09 08:39:12 +01:00
davidemazzocchi b26e69b14d feat: handle comparison "is exactly" for text condition 2026-03-09 08:39:12 +01:00
davidemazzocchi 9af3cb27c2 feat: add error handling during filter composition and new filter conditions for text matching 2026-03-09 08:39:12 +01:00
davidemazzocchi 2e42afbb04 feat: handle comparisons =, >, <, >=, <= for unsigned int condition 2026-03-09 08:39:12 +01:00
davidemazzocchi 01a6e2ce48 chore: run cargo format and delete empty lines in catalog.rs 2026-03-09 08:39:12 +01:00
davidemazzocchi 45093a5672 feat: handle comparison "lesser or equal" for signed int contidion 2026-03-09 08:39:12 +01:00
davidemazzocchi 3d6dfef254 feat: handle comparison "greater or equal" for signed int condition 2026-03-09 08:39:12 +01:00
davidemazzocchi 3feb0d464e feat: handle comparison "lesser" for signed int condition 2026-03-09 08:39:12 +01:00
davidemazzocchi 8e8e964e3f feat: handle comparison "greater" for signed int condition 2026-03-09 08:39:12 +01:00
davidemazzocchi 377b232128 test: add test to catalog.load_by_filter function 2026-03-09 08:39:12 +01:00
davidemazzocchi 423cadf821 review: remove useless redundant tests from sqlite_load_by_filter function 2026-03-09 08:39:12 +01:00
davidemazzocchi b43c705ad6 review: refactor inner join fragment creation using template and helper function 2026-03-09 08:39:12 +01:00
davidemazzocchi 3ee7d97275 review: use INNER JOIN to create filter statement 2026-03-09 08:39:12 +01:00
davidemazzocchi 9ac8cb1249 chore: run cargo format 2026-03-09 08:39:12 +01:00
davidemazzocchi c385dbf85f review: rewrite sqlite_build_params and remove useless tests 2026-03-09 08:39:11 +01:00
davidemazzocchi 489492ab15 doc: add initial documentation for load_by_filter function in catalog 2026-03-09 08:39:11 +01:00
davidemazzocchi 501ee9013a review: add review comments to sqlite_build_statement function 2026-03-09 08:39:11 +01:00
davidemazzocchi cca78b2b56 fix: check test assert with correct item 2026-03-09 08:39:11 +01:00
davidemazzocchi fe1a6824a7 chore: improve justfile command with specific hour for git log 2026-03-09 08:39:11 +01:00
davidemazzocchi f2c7267b64 feat: add signed integer filter condition with equal comparison 2026-03-09 08:39:11 +01:00
davidemazzocchi 5abdc4e133 feat: add placeholder implementation of comparison enum to allow different comparison operators 2026-03-09 08:39:11 +01:00
davidemazzocchi 20a68aa018 feat: add load_by_filter to catalog with possibility to filter by bool attribute 2026-03-09 08:39:11 +01:00
davidemazzocchi c865c72c01 review: change trait requirements for EAV implementors 2026-03-09 08:39:11 +01:00
davidemazzocchi 5b0aa07a07 feat: catalog state is changed after persist, in memory state reflects db 2026-03-09 08:39:11 +01:00
davidemazzocchi ced9998c75 chore: increase verbosity level for cargo semver checks output 2026-03-09 08:39:11 +01:00
davidemazzocchi 58e51f4f47 review: change trait req for entity unwrap* functions to TryFrom<Value> to handle errors 2026-03-09 08:39:11 +01:00
davidemazzocchi 05dc429d86 doc: add a disclaimer on breaking changes and bugs for early adopters 2026-03-09 08:39:11 +01:00
davidemazzocchi 688f16d486 review: replace panic! with generic rusqlite error while mapping attributes 2026-03-09 08:39:11 +01:00
davidemazzocchi a5dcf7b54a test: add tests to sqlite_persist_catalog function 2026-03-09 08:39:11 +01:00
davidemazzocchi 79fc4272b6 chore: delete test scenarios used during initial implementation 2026-03-09 08:39:11 +01:00
davidemazzocchi aa1ff02d6d chore: update comments using upsert instead of insert 2026-03-09 08:39:11 +01:00
davidemazzocchi 6e517e5938 fix: change expected entity status in test after changing from insert to upsert 2026-03-09 08:39:11 +01:00
davidemazzocchi 02178c018c test: add tests to sqlite_map_row_to_entity function 2026-03-09 08:39:11 +01:00
davidemazzocchi f9d2f1c4c0 test: add tests to sqlite_map_row_to_attribute function 2026-03-09 08:39:11 +01:00
davidemazzocchi 3095a78c89 test: add tests to sqlite_load_by_id function 2026-03-09 08:39:11 +01:00
davidemazzocchi 2bb3891b77 test: add tests to sqlite_load_by_class function 2026-03-09 08:39:11 +01:00
davidemazzocchi 440c46e03b test: add tests to sqlite_load_attributes function 2026-03-09 08:39:11 +01:00
davidemazzocchi 06b0253e3e chore: run cargo format 2026-03-09 08:39:11 +01:00
davidemazzocchi 6f39925fe3 test: add tests to sqlite_init_db function 2026-03-09 08:39:11 +01:00
davidemazzocchi 7509bd2bdf doc: update README with the new upsert function for catalog 2026-03-09 08:39:11 +01:00
davidemazzocchi e4ddeaf400 test: add integration tests to catalog 2026-03-09 08:39:11 +01:00
davidemazzocchi b34532832e test: add tests to catalog.load_by_class function 2026-03-09 08:39:11 +01:00
davidemazzocchi 270042a7e2 fix: map error coming from sqlite module to correct FailedTo function error 2026-03-09 08:39:11 +01:00
davidemazzocchi 9fff7b7460 test: add tests to catalog.load_by_id function 2026-03-09 08:39:11 +01:00
davidemazzocchi 335feade19 test: add tests to catalog.persist function 2026-03-09 08:39:11 +01:00
davidemazzocchi 358a30f303 fix: handle EntityState::Updated state while persisting catalog 2026-03-09 08:39:10 +01:00
davidemazzocchi 0cfeeb9134 test: add tests to catalog.persist function 2026-03-09 08:39:10 +01:00
davidemazzocchi 827ac20e87 feat: add EntityState::Updated, change name from insert to upsert in catalog 2026-03-09 08:39:10 +01:00
davidemazzocchi c01cfaf027 test: add tests to catalog.persist function 2026-03-09 08:39:10 +01:00
davidemazzocchi fb8bfa301b test: add tests to catalog.delete function 2026-03-09 08:39:10 +01:00
davidemazzocchi 193e78409f test: add tests for catalog.list_by_class_and_attribute function 2026-03-09 08:39:10 +01:00
davidemazzocchi d52da2d8ec test: add tests to catalog.list_by_class function 2026-03-09 08:39:10 +01:00
davidemazzocchi 1079f404fb test: add tests to catalog.get function 2026-03-09 08:39:10 +01:00
davidemazzocchi c6742fcddd chore: add a command to extract devlog from commit messages 2026-03-09 08:39:10 +01:00
davidemazzocchi 3e24f2fb91 test: add tests to check catalog insert and insert_many functions 2026-03-09 08:39:10 +01:00
davidemazzocchi 16f76653e4 feat: add support for i32 and u32 primitive types 2026-03-09 08:39:10 +01:00
davidemazzocchi d0b6cc2927 test: add tests implementations for catalog init 2026-03-09 08:39:10 +01:00
davidemazzocchi 2fffebc2c1 test: add test for catalog new 2026-03-09 08:39:10 +01:00
davidemazzocchi 8a9119e692 test: add Item struct to be used in test scenarios 2026-03-09 08:39:10 +01:00
davidemazzocchi 408022d479 chore: add a new just command to check for semver breaking changes 2026-03-09 08:39:10 +01:00
davidemazzocchi 6242404cfb test: add a placeholder test battery for each sqlite function 2026-03-09 08:39:10 +01:00
davidemazzocchi 9f9f6bca62 test: add placeholders for catalog tests 2026-03-09 08:39:10 +01:00
davidemazzocchi 5c26f01890 review: clean code from comment blocks and placeholders 2026-03-09 08:39:10 +01:00
davidemazzocchi 718176edbd doc: clean code for documentation output 2026-03-09 08:39:10 +01:00
davidemazzocchi 63ecb15824 doc: add doc comments to ctalog struct description 2026-03-09 08:39:10 +01:00
davidemazzocchi 5ec071ce63 doc: add doc comments to EAV trait 2026-03-09 08:39:10 +01:00
davidemazzocchi 784c171871 doc: add doc comments to FailedTo 2026-03-09 08:39:10 +01:00
davidemazzocchi 4c964efd42 doc: add entity_state doc comments 2026-03-09 08:39:10 +01:00
davidemazzocchi 0eb0422443 review: change visibility to attribute and value structs 2026-03-09 08:39:10 +01:00
davidemazzocchi 9a8f1f550b chore: run cargo format 2026-03-09 08:39:10 +01:00
davidemazzocchi 062642ccd5 doc: add doc comments to entity struct 2026-03-09 08:39:10 +01:00
davidemazzocchi b0d290407d review: make value_of internal and remove unused function 2026-03-09 08:39:10 +01:00
davidemazzocchi 02eddc19e3 doc: update readme with actual example code 2026-03-09 08:39:10 +01:00
davidemazzocchi 8fb5a2ce72 doc: add a simple example of catalog use 2026-03-09 08:39:10 +01:00
davidemazzocchi 03243f3288 feat: add with_opt_attribute convenince function to handle options 2026-03-09 08:39:10 +01:00
davidemazzocchi 8f189f0640 doc: update instruction in README contributions section 2026-03-09 08:39:10 +01:00
davidemazzocchi 5c4562afc2 doc: add doc comments for delete and improve doc comment for persist 2026-03-09 08:39:09 +01:00
davidemazzocchi 8c0afe0c9c feat: add ToDelete state to entity and perform deletion while persisting 2026-03-09 08:39:09 +01:00
davidemazzocchi 4c95b709c0 refactor: group sqlite-related errors under one enum 2026-03-09 08:39:09 +01:00
davidemazzocchi cac890aa06 feat: add error handling for load functions to catalog 2026-03-09 08:39:09 +01:00
davidemazzocchi 5ec86bac7f feat: add error handling to catalog persist function 2026-03-09 08:39:09 +01:00
davidemazzocchi 5a9b1a97ab refactor: call catalog.insert while looping during catalog.insert_many 2026-03-09 08:39:09 +01:00
davidemazzocchi 21aaff25f4 feat: add error handling for database initialization 2026-03-09 08:39:09 +01:00
davidemazzocchi 4170a6b469 chore: fix wrong -u flag to git push command in .justfile 2026-03-09 08:39:09 +01:00
davidemazzocchi 31897711b9 doc: add more information to README.md 2026-03-09 08:39:09 +01:00
davidemazzocchi 97ec240490 chore: rename justfile to .justfile 2026-03-09 08:39:09 +01:00
davidemazzocchi 76a13005a7 chore: update justfile adding github.com to synced remotes 2026-03-09 08:39:09 +01:00
davidemazzocchi ed55836f6e chore: add justfile to handle codeberg.org repo sync 2026-03-09 08:39:09 +01:00
davidemazzocchi 9adea85936 doc: add workspace and project structure information 2026-03-09 08:39:09 +01:00
davidemazzocchi 146c7422bc doc: add minimal info and structure to readme.md 2026-03-09 08:39:09 +01:00
davidemazzocchi 767f00ae36 feat: ensure entity and attributes are read in a single transaction 2026-03-09 08:39:09 +01:00
davidemazzocchi 5230b8e5df test: rewrite intended use scenario 1 with some new features 2026-03-09 08:39:09 +01:00
davidemazzocchi ae96dc56e1 review: remove the possibility to insert raw entities into catalog 2026-03-09 08:39:09 +01:00
davidemazzocchi 67402dde03 review: remove Default for Entity 2026-03-09 08:39:09 +01:00
davidemazzocchi 06a8b52e20 review: avoid using class in entity construction relying on T and EAV trait 2026-03-09 08:39:09 +01:00
davidemazzocchi cad5539a44 review: avoid using class string relying on T and EAV trait 2026-03-09 08:39:09 +01:00
davidemazzocchi 733ffc36aa doc: add doc comments to catalog implementation 2026-03-09 08:39:09 +01:00
davidemazzocchi cb22bf64e7 feat: return iterator from list functions 2026-03-09 08:39:09 +01:00
davidemazzocchi 05085a0f7b feat: make ref_date optional 2026-03-09 08:39:09 +01:00
davidemazzocchi 36bb9e3845 feat: add ref_date field to entity 2026-03-09 08:39:09 +01:00
davidemazzocchi cf3ab35144 fix: correct intended use for entity state 2026-03-09 08:39:09 +01:00
davidemazzocchi c4b7352b8c feat: add get_by_class_and_attribute function to catalog 2026-03-09 08:39:09 +01:00
davidemazzocchi ddd1957c44 feat: add unwrap_opt function to entity to unwrap optional values 2026-03-09 08:39:09 +01:00
davidemazzocchi 1a890ea98a chore: clean code from stdout logging 2026-03-09 08:39:09 +01:00
davidemazzocchi 97e54e7891 feat: add entity state to speed up catalog persist function 2026-03-09 08:39:09 +01:00
davidemazzocchi 8cf8e36b2a refactor: use items.insert instead of insert to add entities after load 2026-03-09 08:39:09 +01:00
davidemazzocchi 78fb2e6499 chore: run cargo format 2026-03-09 08:39:09 +01:00
davidemazzocchi e0331921ee feat: add entity_class and attribute_id indexes to db structure 2026-03-09 08:39:09 +01:00
davidemazzocchi 835feb7450 fix: force bool value to 0 or 1 into db 2026-03-09 08:39:09 +01:00
davidemazzocchi 22d58c4c5f feat: add unwrap_or to entity, rewrite unwrap 2026-03-09 08:39:09 +01:00
davidemazzocchi f91b755619 feat: add function to list entities by class 2026-03-09 08:39:08 +01:00
davidemazzocchi fecfc46751 feat: add load_by_class function to catalog 2026-03-09 08:39:08 +01:00
davidemazzocchi ab491130a7 refactor: extract load_attributes from load_by_id 2026-03-09 08:39:08 +01:00
davidemazzocchi 172cffaad2 feat: add has_attribute function to entity 2026-03-09 08:39:08 +01:00
davidemazzocchi 8852a9569e feat: add load_by_id function to catalog 2026-03-09 08:39:08 +01:00
davidemazzocchi ed40d87e2f fix: remove useless initialization during catalog construction 2026-03-09 08:39:08 +01:00
davidemazzocchi 6f77463038 refactor: split persist function into sub functions for readability 2026-03-09 08:39:08 +01:00
davidemazzocchi 502fc8b7d0 refactor: remove ToEAV trait in favor of Into<Entity> 2026-03-09 08:39:08 +01:00
davidemazzocchi 70c3b9108e refactor: remove FromEAV trait in favor of From<Entity> 2026-03-09 08:39:08 +01:00
davidemazzocchi af860bdb22 refactor: remove trait to_value in favor of Into<Value> 2026-03-09 08:39:08 +01:00
davidemazzocchi 90fa4119dd feat: add catalog persistance 2026-03-09 08:39:08 +01:00
davidemazzocchi b063ecaf9c feat: add sqlite db initialization from catalog 2026-03-09 08:39:08 +01:00
davidemazzocchi 3c31009db2 test: add rusty budget scenario 2026-03-09 08:39:08 +01:00
davidemazzocchi c275c60b34 feat: add get function to catalog 2026-03-09 08:39:08 +01:00
davidemazzocchi 804e9b8536 feat: add insert_many utility functions to catalog 2026-03-09 08:39:08 +01:00
davidemazzocchi d752ed0140 feat: remove useless entity flags 2026-03-09 08:39:08 +01:00
davidemazzocchi b7790e2f47 feat: replace persist with insert (in memory) 2026-03-09 08:39:08 +01:00
davidemazzocchi 5d06946a4c feat: add mock implementation for entity catalog 2026-03-09 08:39:08 +01:00
davidemazzocchi 4ff1a85647 wip: add loaded and persisted flags with function placeholders 2026-03-09 08:39:08 +01:00
davidemazzocchi 7d348052d5 feat: add super trait EAV 2026-03-09 08:39:08 +01:00
davidemazzocchi 3a8c000665 feat: add utility functions with_id and with_class for entity construction 2026-03-09 08:39:08 +01:00
davidemazzocchi 342c971ce3 feat: add unwrap utility function to entity 2026-03-09 08:39:08 +01:00
davidemazzocchi 3767e6f73f test: demo attribute value setting and entity reletions 2026-03-09 08:39:08 +01:00
davidemazzocchi c6ab35584e test: add intended use tests to try/demo the library 2026-03-09 08:39:08 +01:00
davidemazzocchi 357296edd6 feat: add to_value implementation for &str 2026-03-09 08:39:08 +01:00
davidemazzocchi e8cb7d8e53 feat: add id field to entity, implement ToValue for entity 2026-03-09 08:39:08 +01:00
davidemazzocchi 6bc97e36c1 test: add test for entity new and with_attribute constructor functions 2026-03-09 08:39:08 +01:00
davidemazzocchi f32d828b2a feat: add trait ToValue; implement it for base types 2026-03-09 08:39:08 +01:00
davidemazzocchi 01568e87c0 feat: add utility function to set and unset an attribute from an entity 2026-03-09 08:39:08 +01:00
davidemazzocchi 55e1b5d9cf feat: add utility function to read an attribute value from an entity 2026-03-09 08:39:08 +01:00
davidemazzocchi e274ccafec feat: add contructor utility functions to entity and attribute 2026-03-09 08:39:08 +01:00
davidemazzocchi 1786c9104f feat: add traits to transform self from and to eav 2026-03-09 08:39:07 +01:00
davidemazzocchi 58641d7197 feat: add basic structures for EAV modeling (entity, attribute, value) 2026-03-09 08:39:07 +01:00
davidemazzocchi 7a301eb5d7 chore: initialize workspace and main project with standard structure 2026-03-09 08:39:07 +01:00
davidemazzocchi 697be61cb1 Initial commit 2026-03-09 08:39:07 +01:00
96 changed files with 6758 additions and 3 deletions
+2
View File
@@ -0,0 +1,2 @@
01.workspace/target
01.workspace/heave/rustybudger.sqlite3
+19
View File
@@ -0,0 +1,19 @@
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 codeberg.org next
git push --force github.com next
sync_remotes:
git push codeberg.org next
git push github.com next
+265
View File
@@ -0,0 +1,265 @@
# 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
@@ -0,0 +1,4 @@
[workspace]
members = ["heave" ]
resolver = "3" # optional, to specify dependency resolver version
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "heave"
version = "0.10.0"
edition = "2024"
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"] }
@@ -0,0 +1,80 @@
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(),
// 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();
}
@@ -0,0 +1,162 @@
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(),
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();
}
@@ -0,0 +1,228 @@
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(),
model: value.unwrap("model").expect("model is mandatory"),
price: value.unwrap("price").expect("price is mandatory"),
}),
"display" => Product::Display(Display {
id: value.id(),
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(),
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
@@ -0,0 +1,10 @@
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;
@@ -0,0 +1,61 @@
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)
}
@@ -0,0 +1,108 @@
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)
}
@@ -0,0 +1,33 @@
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(())
}
@@ -0,0 +1,29 @@
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(())
}
@@ -0,0 +1,29 @@
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)
}
@@ -0,0 +1,27 @@
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)
}
@@ -0,0 +1,24 @@
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)
}
@@ -0,0 +1,21 @@
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 })
}
@@ -0,0 +1,17 @@
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)
}
@@ -0,0 +1,88 @@
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(())
}
@@ -0,0 +1,11 @@
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(()),
}
}
}
@@ -0,0 +1,22 @@
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)))
}
}
@@ -0,0 +1,28 @@
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(())
})
}
}
@@ -0,0 +1,30 @@
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(())
}
}
@@ -0,0 +1,36 @@
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(())
})
}
}
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,35 @@
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()
})
}
}
@@ -0,0 +1,45 @@
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)))
})
}
}
@@ -0,0 +1,28 @@
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::*; }
@@ -0,0 +1,31 @@
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::*; }
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,17 @@
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()))
}
}
@@ -0,0 +1,32 @@
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())
})
}
}
@@ -0,0 +1,37 @@
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())
})
}
}
@@ -0,0 +1,40 @@
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(())
})
}
}
@@ -0,0 +1,45 @@
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::*; }
@@ -0,0 +1,38 @@
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(())
})
}
}
@@ -0,0 +1,40 @@
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(())
})
}
}
@@ -0,0 +1,39 @@
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(())
})
}
}
@@ -0,0 +1,11 @@
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(()),
}
}
}
@@ -0,0 +1,11 @@
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(()),
}
}
}
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,32 @@
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;
@@ -0,0 +1,11 @@
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(()),
}
}
}
@@ -0,0 +1,13 @@
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)),
}
}
}
@@ -0,0 +1,11 @@
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(()),
}
}
}
@@ -0,0 +1,7 @@
use crate::*;
impl From<bool> for Value {
fn from(value: bool) -> Self {
Self::Bool(value)
}
}
@@ -0,0 +1,7 @@
use crate::*;
impl From<f64> for Value {
fn from(value: f64) -> Self {
Self::Real(value)
}
}
@@ -0,0 +1,7 @@
use crate::*;
impl From<i32> for Value {
fn from(value: i32) -> Self {
Self::SignedInt(value.into())
}
}
@@ -0,0 +1,7 @@
use crate::*;
impl From<i64> for Value {
fn from(value: i64) -> Self {
Self::SignedInt(value)
}
}
@@ -0,0 +1,7 @@
use crate::*;
impl From<&str> for Value {
fn from(value: &str) -> Self {
Self::Text(String::from(value))
}
}
@@ -0,0 +1,7 @@
use crate::*;
impl From<String> for Value {
fn from(value: String) -> Self {
Self::Text(value)
}
}
@@ -0,0 +1,7 @@
use crate::*;
impl From<u32> for Value {
fn from(value: u32) -> Self {
Self::UnsignedInt(value)
}
}
+251
View File
@@ -0,0 +1,251 @@
#![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(),
//! 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
@@ -0,0 +1 @@
+30
View File
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,51 @@
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
@@ -0,0 +1,25 @@
/// 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
@@ -0,0 +1,17 @@
/// 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),
}
+204
View File
@@ -0,0 +1,204 @@
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 it fails.
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()
}
/// 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) -> String {
self.id.clone()
}
/// Returns the subclass of the entity, if it has one.
pub fn subclass(&self) -> Option<String> {
self.subclass.clone()
}
}
@@ -0,0 +1,15 @@
/// 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,
}
+56
View File
@@ -0,0 +1,56 @@
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 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::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)
}
}
+118
View File
@@ -0,0 +1,118 @@
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
@@ -0,0 +1,79 @@
#[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(),
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
@@ -0,0 +1,11 @@
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;
@@ -0,0 +1,20 @@
/// 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),
}
+35
View File
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,17 @@
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
@@ -0,0 +1 @@
pub mod eav;
@@ -0,0 +1,50 @@
#[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);
}
}
@@ -0,0 +1,40 @@
#[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);
}
}
@@ -0,0 +1,113 @@
#[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
@@ -0,0 +1,37 @@
#[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());
}
}
@@ -0,0 +1,78 @@
#[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);
}
}
@@ -0,0 +1,52 @@
#[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();
}
}
@@ -0,0 +1,50 @@
#[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)));
}
}
@@ -0,0 +1,186 @@
#[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();
}
}
@@ -0,0 +1,40 @@
#[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());
}
}
@@ -0,0 +1,51 @@
#[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());
}
}
@@ -0,0 +1,177 @@
#[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
@@ -0,0 +1,162 @@
#[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
@@ -0,0 +1,12 @@
#[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());
}
}
@@ -0,0 +1,389 @@
#[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();
}
}
@@ -0,0 +1,48 @@
#[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();
}
}
@@ -0,0 +1,62 @@
#[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
@@ -0,0 +1,24 @@
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;
@@ -0,0 +1,54 @@
#[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());
}
}
@@ -0,0 +1,94 @@
#[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();
}
}
@@ -0,0 +1,77 @@
#[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());
}
}
@@ -0,0 +1,81 @@
#[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();
}
}
@@ -0,0 +1,74 @@
#[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());
}
}
@@ -0,0 +1,49 @@
#[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());
}
}
@@ -0,0 +1,192 @@
#[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
Copyright (c) 2026 rusty-donkey
Copyright (c) 2025 rusty-donkey
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
+141 -2
View File
@@ -1,3 +1,142 @@
# heave
# heave 󱅝
A Rust EAV data model implementation for SQLite
A Rust EAV data model implementation.
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)