1 unstable release

0.0.0 Sep 22, 2024

#30 in #triple

MIT license

30KB
453 lines

Karmeliet

crates.io docs.rs

Karmeliet is an experimental, in-memory, statically-typed triple store. It's currently missing many features, and is therefore not recommended for use.

See the documentation for examples.


lib.rs:

Karmeliet is an experimental, in-memory, statically-typed triple store. It's currently missing many features, and is therefore not recommended for use.

A triple store is a database that stores triples (subject, attribute, value). In Karmeliet, subjects are unique IDs called "entities", attributes are Rust types, and values are instances of those types.

Differences from other triple stores

Karmeliet is strongly-typed. You can think of it as having a strict schema on the columns of a database. In that sense, it is closer to e.g. Datomic than to Datascript.

Unlike most triple stores out there, Karmeliet is quite bare-bones, and does almost no work for you. For example, Datomic will compute several different indexes to make certain queries faster. Karmeliet does no such thing, and instead leaves it up to the user to build indexes via insertion and removal hooks. Currently, it also provides no declarative query API, which means the user must query data by mapping and filtering over Rust iterators. This might change in the future.

This is a sort of "rusty" trade-off. By being strict about types and not computing any indexes, Karmeliet can enjoy better performance than alternatives, and allows the user to model their domain more precisely. In exchange, it makes the database less flexible and requires more boilerplate to use efficiently.

Basic usage

Here's an example of using Karmeliet to store books in a simple database. It shows the basic API of the crate, but doesn't use any hooks.

use karmeliet::Store;

struct Book {
title: &'static str,
year: u16,
}

// Initialize an empty store
let mut store = Store::default();

// Create a new entity
let psalm = store.entity();

// Attach an attribute to that entity
store.insert(
psalm,
Book {
title: "A Plasm for the Wild-Built",
year: 2021,
},
);

// Retrieve that attribute
assert_eq!(
store.get::<Book>(psalm).unwrap().title,
"A Plasm for the Wild-Built"
);

// Create a few other entities
let song = store.entity();
store.insert(
song,
Book {
title: "The Song of Achilles",
year: 2011,
},
);
let circe = store.entity();
store.insert(
circe,
Book {
title: "Circe",
year: 2018,
},
);

// Iterate over entities with a `Book` attribute
let mut books: Vec<_> = store.iter::<Book>().collect();
books.sort_unstable_by_key(|(_, book)| &book.year);
assert_eq!(books[0].0, song);
assert_eq!(books[1].0, circe);
assert_eq!(books[2].0, psalm);

// Insert a second attribute on an entity
struct CurrentlyReading;
store.insert(circe, CurrentlyReading);

// Find the `Book` with `CurrentlyReading` attribute
let book = store
.iter::<CurrentlyReading>()
.find_map(|(entity, _)| store.get::<Book>(entity));
assert_eq!(book.unwrap().title, "Circe");

// Remove that attribute
store.remove::<CurrentlyReading>(circe);

// There's no longer a book with `CurrentlyReading`
let book = store
.iter::<CurrentlyReading>()
.find_map(|(entity, _)| store.get::<Book>(entity));
assert!(book.is_none());

Using hooks to maintain indexes

Karmeliet only provides fast, O(log N) access when querying an (entity, attribute) pair, or when accessing a range of entities with a specific attribute. If you need to query data by value, you'll have to iterate over every (entity, attribute) pair and keep only the ones that match.

This can be good enough, especially when prototyping. However, if you find that a specific query is too slow, then you might benefit from building an index to speed it up. This can be done with insertion and removal hooks, which are functions that run when attributes are inserted, removed, or updated.

In the following example, we use a hook to build a sorted map from year to entity, which can be used to query the range of all books published in a specific year, as well as just generally querying a range of books pre-sorted by year.

use std::collections::BTreeSet;
use karmeliet::{Entity, Store};

struct Book {
title: &'static str,
year: u16,
}

#[derive(Default)]
struct YearBooks {
index: BTreeSet<(u16, Entity)>,
}

let mut store = Store::default();

// Setup insert and remove hooks
store.hook_before_insert(|store, entity, book: &Book| {
// We store the index as an attribute on the entity `SINGLETON`, which
// exists for this exact purpose.
// `upsert` will create the attribute if it's missing.
store.upsert(Entity::SINGLETON, |YearBooks { index }| {
index.insert((book.year, entity));
});
});
store.hook_after_remove(|store, entity, book: &Book| {
store.upsert(Entity::SINGLETON, |YearBooks { index }| {
index.remove(&(book.year, entity));
});
});

// ...insert data into the store...

// Use the index to query all books published in 2018
let books: Vec<_> = store.get::<YearBooks>(Entity::SINGLETON)
.unwrap()
.index
.range((2018, Entity::MIN)..=(2018, Entity::MAX))
.flat_map(|&(_, entity)| store.get::<Book>(entity))
.collect();

Another common use case for hooks is to maintain indexes on specific entities. For example, we could complement our book database with authors. We'd do that by adding a new Author attribute, and storing the entity of an author inside each book. Now, if we might like to list all the books written by a given author. We can do this by adding a hook on books, which will construct an index for each author, listing the books they've written.

use std::collections::HashSet;
use karmeliet::{Entity, Store};

struct Person {
name: &'static str,
}

struct Book {
title: &'static str,
author: Entity,
}

#[derive(Default)]
struct AuthorBooks {
index: HashSet<Entity>,
}

let mut store = Store::default();

store.hook_before_insert(|store, entity, &Book { author, .. }: &Book| {
store.upsert(author, |AuthorBooks { index }| {
index.insert(entity);
});
});
store.hook_after_remove(|store, entity, &Book { author, .. }: &Book| {
store.update(author, |AuthorBooks { index }| {
index.remove(&entity);
});
});

// ...insert data into the store...

// Use the index to query all book titles from `some_author`
let titles_from_some_author: Vec<_> = store.get::<AuthorBooks>(some_author)
.unwrap()
.index
.iter()
.flat_map(|&entity| store.get::<Book>(entity))
.map(|book| book.title)
.collect();

unstable feature

As an experiment, the unstable feature can be enabled on nightly. This removes the default hook mechanism where hooks are registered with Store::hook_before_insert and Store::hook_after_remove, and instead enables a Hook trait which can be implemented directly on any type. This has several advantages: first, it's more efficient, since there's no longer any hook checks on types that don't have a specific implementation of Hook. Second, it's a better guarantee that invariants are enforced, since it's no longer possible to forget to register a hook.

This feature depends on the min_specialization unstable Rust feature, which makes it possible to give a default (empty) implementation of Hook for all types, and still provide a specialized implementation when needed.

No runtime deps

Features