Skip to content

Guide

ExRam.Gremlinq is a .NET object-graph-mapper (OGM) for Apache TinkerPop-compatible graph databases. It lets you express traversals as type-safe LINQ-style queries against your own entity classes, taking care of serialisation, deserialisation, and provider-specific quirks. This page walks you through installing the project templates, creating a project, and then running increasingly sophisticated queries against the airline-routes example dataset.

Install the Gremlinq templates package

Bash
> dotnet new install ExRam.Gremlinq.Templates

There are two dotnet new templates included in the package: one for a simple console app and one that shows how to get things running on Asp.NET Core. Currently, there is out-of-the-box support for the generic Gremlin Server, AWS Neptune, Azure CosmosDb and JanusGraph.

Using Azure CosmosDb provider packages?

The CosmosDb provider packages are moving out of the OSS core package line. See the migration path: CosmosDb provider packages transition.

Create a new project

Depending on the desired project type and provider, use dotnet new to create a new project:

Bash
dotnet new gremlinq-console --name GettingStartedWithGremlinq --provider Neptune
Bash
dotnet new gremlinq-console --name GettingStartedWithGremlinq --provider GremlinServer

The useTestContainers switch will add a reference to ExRam.Gremlinq.Support.TestContainers. Upon running the project, a throwaway Docker container will be started running a modified build of Gremlin Server.

Bash
dotnet new gremlinq-console --name GettingStartedWithGremlinq --provider GremlinServer --useTestContainers

Bash
dotnet new gremlinq-console --name GettingStartedWithGremlinq --provider CosmosDb
Bash
dotnet new gremlinq-console --name GettingStartedWithGremlinq --provider JanusGraph
Bash
dotnet new gremlinq-aspnet --name GettingStartedWithGremlinq --provider Neptune
Bash
dotnet new gremlinq-aspnet --name GettingStartedWithGremlinq --provider GremlinServer

The useTestContainers switch will add a reference to ExRam.Gremlinq.Support.TestContainers. Upon running the project, a throwaway Docker container will be started running a modified build of Gremlin Server.

Bash
dotnet new gremlinq-aspnet --name GettingStartedWithGremlinq --provider GremlinServer --useTestContainers

Bash
dotnet new gremlinq-aspnet --name GettingStartedWithGremlinq --provider CosmosDb
Bash
dotnet new gremlinq-aspnet --name GettingStartedWithGremlinq --provider JanusGraph

The domain model

The examples below use the airline network domain model introduced by Kelvin Lawrence in PRACTICAL GREMLIN: An Apache TinkerPop Tutorial. Airports serve as vertices and routes between them as edges. The entities are defined as follows:

C#
public sealed class Airport : Vertex
{
    public string? Code { get; set; }
    public string? ICAO { get; set; }
    public string? City { get; set; }
    public string? Region { get; set; }
    public string? Country { get; set; }
    public string? Description { get; set; }

    public int Runways { get; set; }
    public int Elevation { get; set; }
    public double Latitude { get; set; }
    public double Longitude { get; set; }
    public int LongestRunway { get; set; }
}

public sealed class Route : Edge
{
    public long Distance { get; set; }
}

See also

Graph Model — label conventions, assembly scanning, inheritance hierarchies, and model customisation.

Create the AirRoutes dataset

The AirRoutes dataset is initialized by calling CreateAirRoutesSmall. This method is idempotent - it checks for existing nodes and edges before creating new ones, so calling it multiple times is safe. To improve performance after the initial setup, you can comment out this line once the data has been loaded into your database.

C#
await _g
    .CreateAirRoutesSmall();

Simple queries

The examples below show how to retrieve vertices and aggregate results with common filters and scalar reductions.

Retrieve all the airports
C#
var airports = await _g
    .V<Airport>()
    .ToArrayAsync();
How many airports are in the database?
C#
var airportCount = await _g
    .V<Airport>()
    .Count()
    .FirstAsync();
Which airports have their name starting with the letter 'S'?
C#
var airportCodesStartingWithLetterS = await _g
    .V<Airport>()
    .Where(airport => airport.Code!.StartsWith("S"))
    .ToArrayAsync();

See also

Recognized Where Expressions — every filter predicate Gremlinq can translate to Gremlin.

Walk the graph

Edge traversal steps let you follow relationships between vertices in either direction and compose multi-hop paths in a single query.

Find all destination airports from SEA
C#
var destinationsFromSeattle = await _g
    .V<Airport>()
    .Where(airport => airport.Code == "SEA")
    .Out<Route>()
    .OfType<Airport>()
    .ToArrayAsync();
Find all airports connected to SEA within 1500 miles
C#
var within1500Miles = await _g
    .V<Airport>()
    .Where(airport => airport.Code == "SEA")
    .OutE<Route>()
    .Where(route => route.Distance <= 1500)
    .InV<Airport>()
    .ToArrayAsync();
Find all airports that can reach SEA
C#
var routesIntoSEA = await _g
    .V<Airport>()
    .Where(airport => airport.Code == "SEA")
    .In<Route>()
    .OfType<Airport>()
    .ToArrayAsync();
Find all airports that are reachable from SEA by taking two flights
C#
var twoFlightsFromSeattle = await _g
    .V<Airport>()
    .Where(airport => airport.Code == "SEA")
    .Out<Route>()
    .Out<Route>()
    .OfType<Airport>()
    .Dedup()
    .ToArrayAsync();

Using sub-queries

Anonymous traversals (__) act as sub-queries that can be embedded inside a parent query, enabling more expressive filters and multi-path expansions in one round-trip.

Find all airports that are reachable from SEA by taking one or two flights
C#
var withinOneOrTwoFlights = await _g
    .V<Airport>()
    .Where(airport => airport.Code == "SEA")
    .Union(
        __ => __
            .Out<Route>(),
        __ => __
            .Out<Route>()
            .Out<Route>())
    .OfType<Airport>()
    .Dedup()
    .ToArrayAsync();

Simpler:

C#
await _g
    .V<Airport>()
    .Where(airport => airport.Code == "SEA")
    .Out<Route>()
    .Union(
        __ => __,
        __ => __
            .Out<Route>())
    .OfType<Airport>()
    .Dedup()
    .ToArrayAsync();
Find all destinations from SEA that have a connection to Atlanta
C#
var destinationsWithRoutesToAtlanta = await _g
    .V<Airport>()
    .Where(airport => airport.Code == "SEA")
    .Out<Route>()
    .OfType<Airport>()
    .Where(__ => __
        .Out<Route>()
        .OfType<Airport>()
        .Where(airport => airport.Code == "ATL"))
    .ToArrayAsync();

Orderings

Ordering uses a dedicated fluent builder to ensure only valid step combinations are available at any point. Both ascending and descending orderings are supported, and multiple ordering criteria can be chained.

Order all airports lexically by their code
C#
var orderedByCode = await _g
    .V<Airport>()
    .Order(o => o
        .By(airport => airport.Code))
    .ToArrayAsync();
Order all airports by their longest route
C#
var orderedByLongestRoute = await _g
    .V<Airport>()
    .Order(o => o
        .ByDescending(__ => __
            .OutE<Route>()
            .Order(o => o
                .ByDescending(route => route.Distance))
            .Values(route => route.Distance)))
    .ToArrayAsync();
Order all airports by their longest route, then lexically by their code
C#
var orderedByLongestRouteThenCode = await _g
    .V<Airport>()
    .Order(o => o
        .ByDescending(__ => __
            .OutE<Route>()
            .Order(o => o
                .ByDescending(route => route.Distance))
            .Values(route => route.Distance))
        .By(airport => airport.Code))
    .ToArrayAsync();

Limiting results

Use .Limit(), .Range(), .Skip(), and .Tail() to page through or slice result sets. .Limit(n) is shorthand for .Range(0, n).

Get the five airports with the longest routes
C#
var fiveAirportsOrderedByLongestRoute = await _g
    .V<Airport>()
    .Order(o => o
        .ByDescending(__ => __
            .OutE<Route>()
            .Order(o => o
                .ByDescending(route => route.Distance))
            .Values(route => route.Distance)))
    .Limit(5)
    .ToArrayAsync();
Using Range instead
C#
var fiveAirportsWithRange = await _g
    .V<Airport>()
    .Order(orderBuilder => orderBuilder
        .ByDescending(__ => __
            .OutE<Route>()
            .Order(o => o
                .ByDescending(x => x.Distance))
            .Values(x => x.Distance)))
    .Range(0, 5)
    .ToArrayAsync();
Take only the latter five
C#
var tailFiveResults = await _g
    .V<Airport>()
    .Order(orderBuilder => orderBuilder
        .ByDescending(__ => __
            .OutE<Route>()
            .Order(o => o
                .ByDescending(x => x.Distance))
            .Values(x => x.Distance)))
    .Tail(5)
    .ToArrayAsync();

Step labels

Step labels (.As()) bind an intermediate traverser to a named variable that can be referenced later in the same query, enabling back-references and cycle prevention.

Avoid returning to the departure airport right away
C#
var withinTwoFlightsWithNoReturn = await _g
    .V<Airport>()
    .Where(departure => departure.Code == "SEA")
    .As((__, sea) => __
        .Out<Route>()
        .Out<Route>()
        .OfType<Airport>()
        .Where(destination => destination != sea.Value))
    .Dedup()
    .ToArrayAsync();

See also

Step Labels — full reference on StepLabel<T>, both .As() overloads, and Aggregate.

Projections

The Project() step maps each traverser to a new shape — a strongly-typed DTO, a tuple, or a dynamic object — using a fluent builder pattern. Unlike .Values(), which extracts a single property, Project() lets you compose multiple properties and sub-traversals into one output object per traverser, making it the natural choice whenever you need several pieces of data from each element in a single round-trip to the graph.

Project to value tuples
C#
var projectedTuple = await _g
    .V<Airport>()
    .Project(p => p
        .ToTuple()
        .By(x => x.Description!)
        .By(x => x.Code!)
        .By(__ => __.Out<Route>().Count()))
    .FirstAsync();
Project to value tuples with sub-query
C#
var projectedTupleWithSubQuery = await _g
    .V<Airport>()
    .Project(p => p
        .ToTuple()
        .By(x => x.Code!)
        .By(__ => __
            .Out<Route>()
            .OfType<Airport>()
            .Values(x => x.Code!)
            .Fold()))
    .ToArrayAsync();
Project to dynamics
C#
var projectToDynamic = await _g
   .V<Airport>()
   .Project(p => p
       .ToDynamic()
       .By(x => x.Code!)
       .By(x => x.Description!))
   .ToArrayAsync();
Project to dynamics with explicit names
C#
var projectToDynamicExplicit = await _g
   .V<Airport>()
   .Project(p => p
       .ToDynamic()
       .By(x => x.Code!)
       .By(x => x.Description!))
   .ToArrayAsync();
Project to custom data structure instances
C#
var projectToRecords = await _g
    .V<Airport>()
    .Project(p => p
        .To<DepartureAndDestinationRecord>()
        .By(
            x => x.DepartureCode,
            x => x.Code)
        .By(
            x => x.DestinationCodes,
            __ => __
                .Out<Route>()
                .OfType<Airport>()
                .Values(x => x.Code!)
                .Fold()))
   .ToArrayAsync();
Mapping to a DTO with a sub-traversal

.To<TResult>() maps traverser properties and sub-traversals onto the fields of a strongly-typed DTO. Each .By(targetExpr, sourceExpr) call wires a target property on the result type to a source property or a full sub-traversal.

C#
var source = g
    .UseGremlinServer<Vertex, Edge>(_ => _.AtLocalhost())
    .ConfigureEnvironment(env => env.UseNewtonsoftJson());

var query = source
    .V<Person>()
    .Project<PersonSummary>(b => b
        .To<PersonSummary>()
        .By(dto => dto.Name, p => p.Name)
        .By(dto => dto.FriendCount, __ => __.Out<Knows>().Count()));
Guarding against dry by() traversals

When a by() traversal inside project() produces no results for a given vertex (e.g. navigating along an edge that doesn't exist), the step fails server-side. Calling .WithEmptyProjectionProtection() on the builder causes Gremlinq to wrap every by() traversal in a limit(1).fold(), so that a dry traversal contributes an empty array instead of causing an error.

C#
var source = g
    .UseGremlinServer<Vertex, Edge>(_ => _.AtLocalhost())
    .ConfigureEnvironment(env => env.UseNewtonsoftJson());

// When a by() traversal produces no results for a given vertex, the
// project() step fails server-side. WithEmptyProjectionProtection()
// wraps every by() traversal in a limit(1).fold() so that dry
// traversals contribute an empty array instead of crashing.
var query = source
    .V<Person>()
    .Project(b => b
        .WithEmptyProjectionProtection()
        .ToDynamic()
        .By("name", p => p.Name)
        .By("bestFriend", __ => __.Out<Knows>()));

Aggregates

Aggregate steps reduce a stream of traversers to a single value or a collection — counting, summing, folding into an array, and finding extremes.

Fold
C#
var fold = await _g
    .V<Airport>()
    .Map(__ => __
        .Out<Route>()
        .OfType<Airport>()
        .Values(x => x.Code!)
        .Fold())
    .ToArrayAsync();
Sum
C#
var sumOfRoutes = await _g
    .V<Airport>()
    .Where(x => x.Code == "SEA")
    .OutE<Route>()
    .Values(x => x.Distance)
    .Sum()
    .FirstAsync();
Max
C#
var maximumDistanceFromSEA = await _g
    .V<Airport>()
    .Where(x => x.Code == "SEA")
    .Map(__ => __
        .OutE<Route>()
        .Values(x => x.Distance)
        .Max())
    .FirstAsync();
Fold then Unfold

Unfold() on an IArrayGremlinQuery reverses the fold, restoring the original query type with each element emitted individually. This round-trip is useful when you need to apply per-element steps after a folding operation.

C#
var source = g
    .UseGremlinServer<Vertex, Edge>(_ => _.AtLocalhost())
    .ConfigureEnvironment(env => env.UseNewtonsoftJson());

// Fold collects all traversers into an array; Unfold unwraps them back
// into individual elements, restoring the original query type.
var query = source.V<Person>().Fold().Unfold();

Groupings

Group() aggregates traversal results into a dictionary, partitioning elements by a key selector and an optional value selector. Like ordering, building groups uses a dedicated sub-DSL that guides you towards valid step combinations:

Group airports by number of routes
C#
var groupByNumberOfRoutes = await _g
    .V<Airport>()
    .Group(g => g
        .ByKey(__ => __
            .OutE<Route>()
            .Count()))
    .ToArrayAsync();
Project to specific group values

You can also project values to a specific property — here, the airport code — rather than the airport vertices themselves:

C#
var groupCodesByNumberOfRoutes = await _g
    .V<Airport>()
    .Group(g => g
        .ByKey(__ => __
            .OutE<Route>()
            .Count())
        .ByValue(__ => __
            .Values(x => x.Code!)))
    .ToArrayAsync();

Loops

Graph traversals often need to follow edges an arbitrary or bounded number of hops. Gremlinq's .Loop() step wraps TinkerPop's repeat(), until(), times(), and emit() steps in a fluent, type-safe builder. The order of the builder calls determines the semantics:

  • .Repeat(...).Times(n) — traverse a fixed number of hops
  • .Repeat(...).Until(...)do-while: body runs at least once; exit condition tested after each iteration
  • .Until(...).Repeat(...)while-do: exit condition tested before the first iteration; body may never execute
Where can we go with three flights?
C#
var threeFlights = await _g
    .V<Airport>()
    .Map(__ => __
        .Loop(l => l
            .Repeat(__ => __
                .Out<Route>()
                .OfType<Airport>())
            .Times(3))
        .Dedup()
        .Values(x => x.Code!)
        .Fold())
    .ToArrayAsync();
Unroll all the paths from SEA until we reach Atlanta or end up in a cycle
C#
var repeatEmitUntilAtlanta = await _g
    .V<Airport>()
    .Where(x => x.Code == "SEA")
    .Loop(l => l
        .Repeat(__ => __
            .Out<Route>()
            .OfType<Airport>())
        .Emit()
        .Until(__ => __
            .Where(a => a.Code == "ATL")))
    .Dedup()
    .Limit(10)
    .Values(x => x.Code!)
    .ToArrayAsync();
Check exit condition before each iteration (while-do)

.Until(...).Repeat(...) tests the condition before each step. If the starting vertex already satisfies the condition, the loop exits immediately without traversing any edges. Use this when the starting vertex must be considered a valid exit point.

C#
var source = g
    .UseGremlinServer<Vertex, Edge>(_ => _.AtLocalhost())
    .ConfigureEnvironment(env => env.UseNewtonsoftJson());

var query = source
    .V<Person>()
    .Loop(b => b
        .Until(__ => __.Where(p => p.Name == "Bob"))
        .Repeat(__ => __.Out<Knows>().OfType<Person>()));
Emit intermediate results

By default, a loop only yields traversers that satisfy the termination condition. Calling .Emit() causes every traverser to be output at each step, not just at the end — useful when you want all visited vertices, not only the final destination. Placing .Emit() before .Until() includes the starting vertex in the output; placing it after excludes it.

C#
var source = g
    .UseGremlinServer<Vertex, Edge>(_ => _.AtLocalhost())
    .ConfigureEnvironment(env => env.UseNewtonsoftJson());

var query = source
    .V<Person>()
    .Loop(b => b
        .Repeat(__ => __.Out<Knows>().OfType<Person>())
        .Emit()
        .Until(__ => __.Where(p => p.Name == "Bob")));

Trees

A tree in the TinkerPop sense is essentially just a wrapper for a Dictionary<TRoot, TTree> where TRoot can be anything (scalars, entities) and TTree is, again, a Tree. So a tree in this sense can be seen as having many root nodes where each of the root nodes maps to a subtree.

Gremlinq defines the non-generic ITree-marker interface and two derived classes:

  • Tree<TNode> is a tree where each node in the tree is of type TNode
  • Tree<TRoot, TTree> is a tree where the roots are of type TRoot and the subtrees are of type TTree.

There are currently 3 overloads of the Tree-operator in Gremlinq:

  • Tree() will map to a Tree<object>. This is the quickest way to collect the traversers and their paths into a tree structure. Note that although the signature only defines the nodes to be of type object, upon deserialization, Gremlinq will usually be able to recognize elements (vertices and edges) and create instances accordingly.

    C#
    var untypedTree = await _g
        .V<Airport>()
        .Where(a => a.Code == "SEA")
        .Out<Route>()
        .Out<Route>()
        .Tree()
        .FirstAsync();
    
  • Tree<TNode>() will deserialize to a tree where all the nodes are known to be of type TNode.

    C#
    var typedTree = await _g
        .V<Airport>()
        .Where(a => a.Code == "SEA")
        .Out<Route>()
        .Out<Route>()
        .Tree<Airport>()
        .FirstAsync();
    
  • Precise type information of the tree nodes might be desired for later processing. Unfortunately, Gremlinq can't record all the types encountered on a path in its type system, so this information is not statically available. It can, however, be provided through a Tree-overload that provides a special builder-DSL:

    C#
    var typedTreeWithOf = await _g
        .V<Airport>()
        .OutE<Route>()
        .InV<Airport>()
        .Tree(_ => _
            .Of<Airport>()
            .Of<Route>()
            .Of<Airport>())
        .FirstAsync();
    

    It's also possible to map to members which will change the node types in the resulting tree. IMPORTANT: If the By-modulator is unproductive (e.g. because some element in the tree doesn't have a value set for that specific property), neither the element nor any notion of a null-value will occur in the tree, which could lead to incorrect type mappings on deserialization. Use with care.

    C#
    var typedTreeWithOfAndBy = await _g
        .V<Airport>()
        .Out<Route>()
        .OfType<Airport>()
        .Tree(_ => _
            .Of<Airport>().By(x => x.Code!)
            .Of<Airport>().By(x => x.Description!))
        .FirstAsync();
    

Branching and Conditional Steps

These steps control the flow of traversers through a query — routing them down different paths based on conditions, merging streams from multiple sub-traversals, or providing fallbacks when a path produces no results.

Coalesce — first match wins

Coalesce evaluates each sub-traversal in order and passes through the result of the first one that produces output. Subsequent sub-traversals are not evaluated once one succeeds. If every sub-traversal produces nothing, the step emits nothing.

C#
var source = g
    .UseGremlinServer<Vertex, Edge>(_ => _.AtLocalhost())
    .ConfigureEnvironment(env => env.UseNewtonsoftJson());

var query = source
    .V<Person>()
    .Coalesce(
        __ => __.Out<ReportsTo>().OfType<Person>(),
        __ => __.Identity());
Optional — keep the traverser if the sub-traversal is empty

Optional evaluates a single sub-traversal. If it produces output, that output is returned. If it produces nothing, the original traverser passes through unchanged — it is never dropped. This is equivalent to Coalesce(sub, Identity()) but more expressive when there is only one alternative.

C#
var source = g
    .UseGremlinServer<Vertex, Edge>(_ => _.AtLocalhost())
    .ConfigureEnvironment(env => env.UseNewtonsoftJson());

var query = source
    .V<Person>()
    .Optional(__ => __.Out<ReportsTo>().OfType<Person>());
Choose — if-then-else routing

Choose routes each traverser to one of two sub-traversals based on a boolean condition. The predicate can be an expression (compiled to a Gremlin has() filter) or a traversal-based predicate.

C#
var source = g
    .UseGremlinServer<Vertex, Edge>(_ => _.AtLocalhost())
    .ConfigureEnvironment(env => env.UseNewtonsoftJson());

var query = source
    .V<Person>()
    .Choose(
        p => p.Status == Status.Active,
        __ => __,
        __ => __.Where(p => p.Status == Status.Inactive));
Union — merge all branches

Union evaluates all provided sub-traversals and merges their results into a single stream. Unlike Coalesce, every branch is always executed and every result is included in the output.

C#
var source = g
    .UseGremlinServer<Vertex, Edge>(_ => _.AtLocalhost())
    .ConfigureEnvironment(env => env.UseNewtonsoftJson());

var query = source
    .V<Person>()
    .Union(
        __ => __.Where(p => p.Status == Status.Active),
        __ => __.Where(p => p.Status == Status.Pending));