Skip to main content

An experiment with trustfall

Trustfall is a GraphQL query engine, but without the web stuff meaning it is really designed for performing queries on data. An example application is given in “How to Query (Almost) Everything” which shows combining multiple data sources:

  • Hacker News posts
  • GitHub projects
  • GitHub actions jobs

I wanted to learn how difficult implementing something like this would be. I have only scratched the surface, but I think I have a vague understanding that is worth writing down.

API design

To start off, define the types of your API. This is likely directly related to the data that you want to query. For example, with these types:

struct Bar {
    quux: String,
    baz: Vec<Baz>,
}

struct Baz {
    value: String,
}

there needs to be a “top level” type which defines the top level query, and this must borrow the data it represents.

struct Foo<'a> {
    bar: &'a [Bar],
}

in this case the GraphQL schema looks like:

schema {
  query: RootSchemaQuery
}

type RootSchemaQuery {
  foo: Bar!
}

type Bar {
  quux: String!
  baz: Baz,
}

type Baz {
  value: String!
}

You must match the Bar and Baz types in this case to your data.

The Adapter

Trustfall depends on implementing a trait for your data. This implementation is known as an adapter.

A simplified adapter trait is available to simplify the process, with the following methods:

  • resolve_starting_vertices
  • resolve_property
  • resolve_neighbors
  • resolve_coercion

Let’s annotate these on an schema:

schema {
  query: RootSchemaQuery
}

type RootSchemaQuery {
  foo: Bar!                     # <- resolve_starting_vertices
}

type Bar {
  quux: String!                 # <- resolve_property
  baz: Iface,                   # <- resolve_neighbors
}

type Baz implements Iface {
  value: String!                # <- resolve_property
}

interface Iface {
  value: String!                # <- resolve_property
}

and query:

{
  foo {                 # <- resolve_starting_vertices
    quux                # <- resolve_property
    baz {               # <- resolve_neighbors
      ... on Baz {      # <- resolve_coercion
        value           # <- resolve_property
      }
    }
  }
}

Creating a Vertex

The final piece of the puzzle is to define the Vertex type. This type is effectively returned by all trait methods, and must therefore represent all return types from the query engine. Because of this it is common to define an enum to represent the Vertex. This type stores references to inner data types, for example:

#[derive(Clone, Debug, TrustfallEnumVertex)]
enum Vertex<'a> {
    Bar(&'a Bar),
    Baz(&'a Baz),
}

Note the deriving of TrustfallEnumVertex: this is important later on. Any type that is not a “primitive” type (e.g. String, number etc.) must be representable by the Vertex type.

Implementing the adapter

Now we understand what the trait methods do, let’s implement a stub adapter for the schema above:

// Implement a method to resolve the top level query into a `Vertex` type. This
fn resolve_starting_vertices(
    &self,
    edge_name: &str,
    _parameters: &trustfall::provider::EdgeParameters,
) -> trustfall::provider::VertexIterator<'a, Self::Vertex> {
    match edge_name {
        "foo" => {
            // We are interested in the `foo` field on the top level query.
            // Return a boxed iterator over `Vertex` instances
            Box::new(self.bars.iter().map(|b| Vertex::Bar(b)))
        },
        _ => unreachable!(),
    }
}

Resolve a property on a type. Note how we use the methods added by the TrustfallEnumVertex derive macro.

// Given a vertex, resolve the property requested by `type_name`
fn resolve_property<V: trustfall::provider::AsVertex<Self::Vertex> + 'a>(
    &self,
    contexts: trustfall::provider::ContextIterator<'a, V>,
    type_name: &str,
    property_name: &str,
) -> trustfall::provider::ContextOutcomeIterator<'a, V, trustfall::FieldValue> {
    match type_name {
        "Bar" => match property_name {
            // resolve_property_with is a helper function that looks up the
            // property in the `contexts` variable
            //
            // field_property relies on conversion functions from the `TrustfallEnumVertex`
            // trait, and `quux` is the name of the property
            "quux" => resolve_property_with(contexts, field_property!(as_bar, quux)),
            _ => unreachable!(),
        },
        // ...
    }
}

Resolve any “links” i.e. properties on parent types that resolve to child types.

fn resolve_neighbors<V: trustfall::provider::AsVertex<Self::Vertex> + 'a>(
    &self,
    contexts: trustfall::provider::ContextIterator<'a, V>,
    type_name: &str,
    edge_name: &str,
    parameters: &trustfall::provider::EdgeParameters,
) -> trustfall::provider::ContextOutcomeIterator<
    'a,
    V,
    trustfall::provider::VertexIterator<'a, Self::Vertex>,
> {
    match (type_name, edge_name) {
        ("Bar", "baz") => {
            // resolve_neighbors_with is a helper function provided by trustfall.
            // Again it uses the `TrustfallEnumVertex` trait.
            resolve_neighbors_with(contexts, |vertex| {
                let neighbors = vertex.as_bar().unwrap().baz.iter().map(Vertex::Baz);
                Box::new(neighbors)
            })
        },
        _ => unreachable!(),
    }
}

Final note

Though I haven’t tried many of the directives (e.g. @fold), I have played with one: @output. This property is required to actually output any data. Perhaps not the most intuitive behaviour, but it does make the output cleaner.

Things I have not yet looked into

  • Adapter vs BasicAdapter
  • If we can do away with the stringified API for matching type/property names
  • Property/link arguments
  • directives
  • type coercions