retriever

Retriever is an embedded, in-memory, document-oriented data store for rust applications. It stores ordinary rust data types in a similar manner as a NoSQL database.

Retriever is ideal when you need to index a collection by multiple properties, you need a variety of relations between elements in a collection, or or you need to maintain summary statistics about a collection.

Callie, a golden retriever puppy. (Image of Callie, a golden retriever puppy, by Wikimedia Commons user MichaelMcPhee.)

Features:

Retriever does not have:

Getting started:

```rust use retriever::prelude::; use std::borrow::Cow; use chrono::prelude::; // Using rust's Chrono crate to handle date/time // (just for this example, you don't need it) use std::collections::HashSet;

// This example is going to be about a puppy rescue agency struct Puppy { name: String, rescueddate: Date, adopteddate: Option>, breed: HashSet, parents: HashSet>, }

// Some convenience functions for describing puppies in source code impl Puppy { fn new(name: &str, rescueddate: Date) -> Puppy { Puppy { name: String::from(name), rescueddate, adopted_date: None, breed: HashSet::default(), parents: HashSet::default(), } }

fn adopted(mut self, adopteddate: Date) -> Puppy { self.adopteddate = Some(adopted_date); self }

fn breeds(mut self, breeds: &[&str]) -> Puppy { self.breed.extend(breeds.iter().map(|breed| String::from(*breed))); self }

fn parent(mut self, year: i32, name: &str) -> Puppy { self.parents.insert(ID.chunk(year).item(String::from(name))); self } }

// We need to implement Record for our Puppy type. // Because of this design, we can never have two puppies with same name // rescued in the same year. They would have the same Id. impl Record for Puppy { fn chunkkey(&self) -> Cow { Cow::Owned(self.rescueddate.year()) }

fn item_key(&self) -> Cow { Cow::Borrowed(&self.name) } }

// Let's create a storage of puppies. let mut storage : Storage = Storage::new();

storage.add( Puppy::new("Lucky", Utc.ymd(2019, 3, 27)) .adopted(Utc.ymd(2019, 9, 13)) .breeds(&["beagle"]) );

// Add some example puppies to work with storage.add( Puppy::new("Spot", Utc.ymd(2019, 1, 9)) .breeds(&["labrador", "dalmation"]) .parent(2010, "Yeller") );

storage.add( Puppy::new("JoJo", Utc.ymd(2018, 9, 2)) .adopted(Utc.ymd(2019, 5, 1)) .breeds(&["labrador","shepherd"]) .parent(2010, "Yeller") );

storage.add( Puppy::new("Yeller", Utc.ymd(2010, 8, 30)) .adopted(Utc.ymd(2013, 12, 24)) .breeds(&["labrador"]) );

// Get all puppies rescued in 2019: let q = Chunks([2019]); let mut rescued2019 : Vec<_> = storage.query(&q) .map(|puppy: &Puppy| &puppy.name).collect(); rescued2019.sort(); // can't depend on iteration order! asserteq!(vec!["Lucky","Spot"], rescued2019);

// Get all puppies rescued in the last 3 years: let q = Chunks(2017..=2019); let mut rescuedrecently : Vec<_> = storage.query(&q) .map(|puppy: &Puppy| &puppy.name).collect(); rescuedrecently.sort(); asserteq!(vec!["JoJo","Lucky","Spot"], rescuedrecently);

// Get all puppies rescued in march: let q = Everything.filter(|puppy: &Puppy| puppy.rescueddate.month() == 3); let mut rescuedinmarch : Vec<_> = storage.query(&q) .map(|puppy| &puppy.name).collect(); rescuedinmarch.sort(); asserteq!(vec!["Lucky"], rescuedinmarch);

// Fix spelling of "dalmatian" on all puppies: let q = Everything.filter(|puppy : &Puppy| puppy.breed.contains("dalmation")); storage.modify(&q, |mut editor| { let puppy = editor.getmut(); puppy.breed.remove("dalmation"); puppy.breed.insert(String::from("dalmatian")); }); asserteq!(0, storage.iter().filter(|x| x.breed.contains("dalmation")).count()); assert_eq!(1, storage.iter().filter(|x| x.breed.contains("dalmatian")).count());

// Set up an index of puppies by their parent. // In SecondaryIndexes, we always return a collection of secondary keys. // (In this case, a HashSet containing the Ids of the parents.) let mut by_parents = SecondaryIndex::new(&storage, |puppy: &Puppy| Cow::Borrowed(&puppy.parents));

// Use an index to search for all children of Yeller: let yellerid = ID.chunk(2010).item(String::from("Yeller")); let q = Everything.matching(&mut byparents, Cow::Borrowed(&yellerid)); let mut childrenofyeller : Vec<_> = storage.query(&q) .map(|puppy: &Puppy| &puppy.name).collect(); childrenofyeller.sort(); asserteq!(vec!["JoJo","Spot"], childrenofyeller);

// Remove puppies who have been adopted more than five years ago. let q = Chunks(0..2014).filter(|puppy: &Puppy| puppy.adopteddate.map(|date| date.year() <= 2014).unwrapor(false)); assert!(storage.get(&yellerid).issome()); storage.remove(&q, std::mem::drop); assert!(storage.get(&yellerid).isnone()); ```

Comparison to other databases (SQL, MongoDB, etc)

Unlike most databases, retriever stores your data as a plain old rust data type inside heap memory. (Specifically, each chunk has a Vec that stores all of the data for that chunk.) It doesn't support access over a network from multiple clients.

Like a traditional database, retriever has a flexible indexing and query system and can model many-to-many relationships between records.

Comparison to ECS (entity-component-system) frameworks

Retriever can be used as a servicable component store, because records that share the same keys are easy to cross-reference with each other. But Retriever is not designed specifically for game projects, and it tries to balance programmer comfort with reliability and performance.

ECSs use low-cardinality indexes to do an enormous amount of work very quickly. Retriever uses high-cardinality indexes to avoid as much work as possible.

If you know you need to use Data Oriented Design then you might consider an ECS like specs or legion.

Getting started:

  1. Create a rust struct or enum that represents a data item that you want to store.
  2. Choose a chunk key and item key for each instance of your record.
  3. Implement the Record trait for your choice of record, chunk key, and item key types.
  4. Create a new empty Storage object using Storage::new().
  5. Use Storage::add(), Storage::iter(), Storage::query(), Storage::modify(), and Storage::remove() to implement CRUD operations on your storage.
  6. If you want, create some secondary indexes using SecondaryIndex::new(). Define secondary indexes by writing a single closure that maps records into zero or more secondary keys.
  7. If you want, create some reductions using Reduction::new(). Define reductions by writing two closures: (1) A map from the record type to a summary type, and (2) a fold of several summary objects into a single summary. Use Reduction::reduce() to reduce your entire storage to a single summary object, or Reduction::reduce_chunk() to reduce a single chunk to a single summary object.

More about how to choose a good chunk key:

About Cow

Retriever makes heavy use of Cow to represent various kinds of index keys. Using Cow allows retriever to bridge a wide range of use cases.

A Cow<T> is usually either Cow::Owned(T) or Cow::Borrowed(&T). The generic parameter refers to the borrowed form, so Cow<str> is either Cow::Owned<String> or Cow::Borrowed<&str>. Whenever you see a ChunkKey, ItemKey, or IndexKey, these keys follow the same convention.

These are good:

This will work for the most part but it's weird:

License

Retriever is licensed under your choice of either the ISC license (a permissive license) or the AGPL v3.0 or later (a strong copyleft license).

The photograph of the puppy is by Wikimedia Commons user MichaelMcPhee. Creative Commons Attribution 3.0 Unported. (Source)

Contributing

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in retriever by you, shall be licensed as ISC OR AGPL-3.0-or-later, without any additional terms or conditions.

How to Help

At this stage, bug reports and questions about any unclear documentation are highly valuable. I consider it appropriate to open a ticket just for technical support. I'm also interested in any suggestions that would help further simplify the codebase.

To Do: (I want these features, but they aren't yet implemented)

License: ISC OR GPL-3.0-or-later