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
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:
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.
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.
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:
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.
Simple queries
The examples below show how to retrieve vertices and aggregate results with common filters and scalar reductions.
How many airports are in the database?
Which airports have their name starting with the letter 'S'?
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
Find all airports connected to SEA within 1500 miles
Find all airports that can reach SEA
Find all airports that are reachable from SEA by taking two flights
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
var withinOneOrTwoFlights = await _g
.V<Airport>()
.Where(airport => airport.Code == "SEA")
.Union(
__ => __
.Out<Route>(),
__ => __
.Out<Route>()
.Out<Route>())
.OfType<Airport>()
.Dedup()
.ToArrayAsync();
Simpler:
Find all destinations from SEA that have a connection to Atlanta
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
Order all airports by their longest route
Order all airports by their longest route, then lexically by their code
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
Using Range instead
Take only the latter five
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
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
Project to value tuples with sub-query
Project to dynamics
Project to dynamics with explicit names
Project to custom data structure instances
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.
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.
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
Sum
Max
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.
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
Project to specific group values
You can also project values to a specific property — here, the airport code — rather than the airport vertices themselves:
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?
Unroll all the paths from SEA until we reach Atlanta or end up in a cycle
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.
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.
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 typeTNodeTree<TRoot, TTree>is a tree where the roots are of typeTRootand the subtrees are of typeTTree.
There are currently 3 overloads of the Tree-operator in Gremlinq:
-
Tree()will map to aTree<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 typeobject, upon deserialization, Gremlinq will usually be able to recognize elements (vertices and edges) and create instances accordingly. -
Tree<TNode>()will deserialize to a tree where all the nodes are known to be of typeTNode. -
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.
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.
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.
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.
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.