mentat/docs/tutorial.md

6.8 KiB

Introduction

Mentat is a transactional, relational storage system built on top of SQLite. The abstractions it offers allow you to easily tackle some things that are tricky in other storage systems:

  • Have multiple components share storage and collaborate.
  • Evolve schema.
  • Track change over time.
  • Synchronize data correctly.
  • Store data with rich, checked types.

Mentat offers a programmatic Rust API for managing stores, retrieving data, and transacting new data. It offers a Datalog-based query engine, with queries expressed in EDN, a rich textual data format similar to JSON. And it offers an EDN data format for transacting new data.

This tutorial covers all of these APIs, along with defining vocabulary.

We'll begin by introducing some concepts, and then we'll walk through some examples.

What does Mentat store?

Depending on your perspective, Mentat looks like a relational store, a graph store, or a tuple store.

Mentat stores relationships between entities and other entities or values. An entity is related to other things by an attribute.

All entities have an entity ID (abbreviated to entid).

Some entities additionally have an identifier called an ident, which is a keyword. That looks something like :bookmark/title.

A value is a primitive piece of data. Mentat supports the following:

  • Strings
  • Long integers
  • Double-precision floating point numbers
  • Millisecond-precision timestamps
  • UUIDs
  • Booleans
  • Keywords (a special kind of string that we use for idents).

There are two special kinds of entities: attributes and transactions.

Attributes are themselves entities with a particular set of properties that define their meaning. They have identifiers, so you can refer to them easily. They have a value type, which is the type of value Mentat expects to be on the right hand side of the relationship. And they have a cardinality (whether one or many values can exist for a particular entity), whether values are unique, a documentation string, and some indexing options.

An attribute looks something like this:

{:db/ident       :bookmark/title
 :db/cardinality :db.cardinality/one
 :db/valueType   :db.type/string
 :db/fulltext    true
 :db/doc         "The title of a bookmark."}

Transactions are special entities that can be described however you wish. By default they track the timestamp at which they were written.

The relationship between an entity, an attribute, and a value, occurring in a transaction (which is just another kind of entity!) — a tuple of five values — is called a datom.

A single datom might look something like this:

       [:db/add          65536         :bookmark/title       "mozilla.org"              268435456]
        ^                  ^              ^                     ^                         ^
        \ Add or retract.  |              |                     |                         |
                           \ The entity.  |                     |                         |
                                          \ The attribute.      |                         |
                                                                \ The value, a string.    |
                                                                                          \ The transaction ID.

which is equivalent to saying "in transaction 268435456 we assert that entity 65536 is a bookmark with the title 'mozilla.org'".

When we transact that — which means to add it as a fact to the store — Mentat also describes the transaction itself on our behalf:

       [:db/add 268435456 :db/txInstant "2018-01-25 20:07:04.408358 UTC" 268435456]

A simple app

Let's get started with some Rust code.

First, the imports we'll need. The comments here briefly explain what each thing is.

// So you can define keywords with neater syntax.
#[macro_use(kw)]
extern crate mentat;

use mentat::{
    Store,          // A single database connection and in-memory metadata.
}

use mentat::vocabulary::attribute;      // Properties of attributes.

Defining a simple vocabulary

All data in Mentat — even the terms we used above, like :db/cardinality — are defined in the store itself. So that's where we start. In Rust, we define a vocabulary definition, and ask the store to ensure that it exists.


fn set_up(mut store: Store) -> Result<()> {
    // Start a write transaction.
    let mut in_progress = store.begin_transaction()?;

    // Make sure the core vocabulary exists. This is good practice if a user,
    // an external tool, or another component might have touched the file
    // since you last wrote it.
    in_progress.verify_core_schema()?;

    // Make sure our vocabulary is installed, and install if necessary.
    // This defines some attributes that we can use to describe people.
    in_progress.ensure_vocabulary(&Definition {
        name: kw!(:example/people),
        version: 1,
        attributes: vec![
            (kw!(:person/name),
             vocabulary::AttributeBuilder::default()
               .value_type(ValueType::String)
               .multival(true)
               .build()),
            (kw!(:person/age),
             vocabulary::AttributeBuilder::default()
               .value_type(ValueType::Long)
               .multival(false)
               .build()),
            (kw!(:person/email),
             vocabulary::AttributeBuilder::default()
               .value_type(ValueType::String)
               .multival(true)
               .unique(attribute::Unique::Identity)
               .build()),
        ],
    })?;

    in_progress.commit()?;
    Ok(())
}

We open a store and configure its vocabulary like this:

let path = "/path/to/file.db";
let store = Store::open(path)?;
set_up(store)?;

If this code returns successfully, we're good to go.

Transactions

You'll see in our set_up function that we begin and end a transaction, which we call in_progress. A read-only transaction is begun via begin_read. The resulting objects — InProgress and InProgressRead support various kinds of read and write operations. Transactions are automatically rolled back when dropped, so remember to call commit!

Adding some data

There are two ways to add data to Mentat: programmatically or textually.

The textual form accepts EDN, a simple relative of JSON that supports richer types and more flexible syntax. You saw this in the introduction. Here's an example:

in_progress.transact(r#"[
    {:person/name "Alice"
     :person/age 32
     :person/email "alice@example.org"}
]"#)?;

You can implicitly upsert data when you have a unique attribute to use:

// Alice's age is now 33. Note that we don't need to find out an entid,
// nor explicitly INSERT OR REPLACE or UPDATE OR INSERT or similar.
in_progress.transact(r#"[
    {:person/age 33
     :person/email "alice@example.org"}
]"#)?;