Enhanced Querying with LINQ
Graph Model provides powerful querying capabilities through enhanced LINQ support via IGraphQueryable<T>. This extends standard LINQ with graph-specific operations for traversal, performance optimization, and advanced query patterns.
Basic Queries
Filtering Nodes
// Simple where clause
var adults = await graph.Nodes<Person>()
.Where(p => p.Age >= 18)
.ToListAsync();
// Multiple conditions
var youngEngineers = await graph.Nodes<Person>()
.Where(p => p.Age < 30 && p.Bio.Contains("engineer"))
.ToListAsync();
// String operations
var smiths = await graph.Nodes<Person>()
.Where(p => p.LastName.StartsWith("Sm"))
.ToListAsync();
Ordering and Pagination
// Order by single property
var byAge = await graph.Nodes<Person>()
.OrderBy(p => p.Age)
.ToListAsync();
// Order by multiple properties
var sorted = await graph.Nodes<Person>()
.OrderBy(p => p.LastName)
.ThenBy(p => p.FirstName)
.ToListAsync();
// Pagination
var page = await graph.Nodes<Person>()
.OrderBy(p => p.LastName)
.Skip(20)
.Take(10)
.ToListAsync();
Enhanced Graph Operations
Depth Control
Depth applies to a traversal, not to a plain node query — use the depth overloads on Traverse/
TraversePaths (see Advanced Graph Traversal below), not a
free-floating modifier:
// Traverse up to 2 levels deep from Alice
var connections = await graph.Nodes<Person>()
.Where(p => p.FirstName == "Alice")
.Traverse<Knows, Person>(maxDepth: 2)
.ToListAsync();
// Specify a depth range
var rangedConnections = await graph.Nodes<Person>()
.Where(p => p.FirstName == "Alice")
.Traverse<Knows, Person>(minDepth: 1, maxDepth: 3)
.ToListAsync();
Performance Optimization
// Project only the fields you need
var optimizedQuery = await graph.Nodes<Person>()
.Where(p => p.Age > 30)
.Select(p => new { p.Id, p.FirstName, p.LastName })
.ToListAsync();
// Page large result sets
var firstPage = await graph.Nodes<Person>()
.Where(p => p.Age > 30)
.OrderBy(p => p.LastName)
.Take(100)
.ToListAsync();
Transaction Context
await using var transaction = await graph.GetTransactionAsync();
var results = await graph.Nodes<Person>()
.Where(p => p.Age > 30)
.ToListAsync();
var transactionalResults = await graph.Nodes<Person>(transaction: transaction)
.Where(p => p.Age > 30)
.ToListAsync();
await transaction.CommitAsync();
Working with “complex” types
The Graph Model supports “complex” properties. These are properties whose type isn’t one of the primitive types. Neo4j doesn’t natively support such properties on graph nodes. The Graph Model requires implementing providers to support them. Consider this example:
public record Address
{
public string Street { get; set; }
public string City { get; set; }
public StateEnum State { get; set; }
public CountryEnum Country { get; set; }
}
public record Person : Node
{
public string FirstName { get; set; }
public string LastName { get; set; }
public Address HomeAddress { get; set; }
public Address WorkAddress { get; set; }
}
The developer-facing type remains a value object, but its stored representation is first-class graph
structure. Neo4j creates a separate :Address value node for each occurrence and connects it with
:HomeAddress or :WorkAddress. Use
[ComplexProperty(RelationshipType = "LIVES_AT")] when the property name is not the desired graph
relationship type.
// Automatically creates two separate, visible Address nodes connected by semantic relationships.
// Create many people nodes...
await using var tx = await graph.GetTransactionAsync();
foreach (var p in people)
await graph.CreateNodeAsync(p, transaction: tx);
await tx.CommitAsync();
// Query for those who live in WA state
var waStateResidents = await graph.Nodes<Person>()
.Where(p => p.HomeAddress.State == StateEnum.WA)
.ToListAsync();
Nested navigation is not limited to one level. Complex collections lower to graph patterns as well:
var regional = await graph.Nodes<Company>()
.Where(c => c.Headquarters.Region.Name == "Northwest")
.Where(c => c.Offices.Any(office => office.City == "Seattle"))
.Where(c => c.Offices.All(office => office.IsOpen))
.Where(c => c.Offices.Count > 1)
.Select(c => c.Offices.Select(office => office.City))
.ToListAsync();
Comparing a complex property with null tests whether its relationship exists; it does not read a
scalar property from the owner node. Declared complex properties auto-load recursively with a five-level
depth guard. A slim read type that omits a property does not populate it, while normal traversal/path
projection can still return the owner, semantic relationship, and related value node together.
Advanced Graph Traversal
Graph Traversal Queries
// Multi-hop traversal, then filter the target nodes
var friendsOfFriends = await graph.Nodes<Person>()
.Where(p => p.FirstName == "Alice")
.Traverse<Knows, Person>(minDepth: 1, maxDepth: 2) // 1-2 hops away
.Where(p => p.Age > 25) // Filter target nodes
.ToListAsync();
// Relationship predicates run while paths expand. On a variable-length traversal every hop must
// satisfy the predicate; this is not a client-side filter over already-produced target nodes.
var recentFriends = await graph.Nodes<Person>()
.Where(p => p.FirstName == "Alice")
.Traverse<Knows, Person>(options => options
.Depth(1, 3)
.WhereRelationship<Knows>(relationship => relationship.Since > DateTime.UtcNow.AddYears(-1)))
.ToListAsync();
// Existence sugar lowers directly to an EXISTS relationship pattern. It does not traverse and
// de-duplicate target rows. Spell both types to preserve Person in the static query chain.
var sociallyActive = await graph.Nodes<Person>()
.WhereHasRelationship<Person, Knows>(
GraphTraversalDirection.Both,
relationship => relationship.Since > DateTime.UtcNow.AddYears(-1))
.OrderBy(person => person.LastName)
.ToListAsync();
A traversal is one or more relationship hops, so every depth bound must be at least 1 and the
maximum must be at least the minimum. Depth(0), Depth(0, n), and the equivalent Traverse and
TraversePaths overloads throw ArgumentOutOfRangeException while the query is being constructed,
before any provider sees it. Reflexive reachability — treating the source itself as a zero-hop
result — is deliberately not part of this contract; IGraphPath always contains at least one
segment.
A self-relationship is a single edge, so a bidirectional traversal reaches it once, matching what
CountRelationships(..., GraphTraversalDirection.Both) reports. Parallel relationships between the
same pair of nodes stay distinct and are never collapsed by endpoint identity.
WhereRelationship supports outgoing, incoming, and bidirectional traversal, including
self-relationships. WhereHasRelationship has a one-type-argument convenience overload when the
common INode result shape is sufficient; the two-type-argument overload above retains the concrete
node type for later filters, ordering, and projections. Providers that do not declare
GraphCapability.RelationshipPredicates reject either operator during translation. Apply
WhereHasRelationship before Select, Skip, or Take; the shared validator rejects the reverse
order rather than moving the existence test across that boundary. Neo4j and the in-memory provider
implement this capability; AGE declines it.
Shortest paths
ShortestPath<TRel, TEnd> returns one minimum-hop path for each source/endpoint pair;
AllShortestPaths<TRel, TEnd> returns every path tied at that minimum length. Both use the
canonical two-type-argument traversal surface and return IGraphPath:
var routes = await graph.Nodes<Person>()
.Where(person => person.Id == aliceId)
.AllShortestPaths<Knows, Person>(
endpoint => endpoint.Active,
GraphTraversalDirection.Both)
.Take(10)
.ToListAsync();
The endpoint predicate participates in path matching, not client-side filtering. Paths contain at
least one relationship; the source node is excluded as an endpoint, including when a cycle or
self-relationship could lead back to it. Selection is partitioned by source/endpoint pair, so one
source can return shortest paths to several endpoints. Providers must declare
GraphCapability.ShortestPath; Neo4j and the in-memory provider implement it, while AGE rejects it
during translation.
Optional traversal
OptionalTraverse<TRel, TEnd> performs a one-hop left traversal. Each result contains the
preserved Source and a nullable Target; an unmatched source produces exactly one row:
var assignments = await graph.Nodes<Person>()
.Where(person => person.Active)
.OptionalTraverse<WorksAt, Company>()
.Select(result => new
{
PersonId = result.Source.Id,
CompanyId = result.Target == null ? null : result.Target.Id,
})
.Take(100)
.ToListAsync();
Zero, one, and many matches therefore produce one, one, and many rows respectively;
self-relationships are normal matches. Filter source rows before OptionalTraverse — predicates,
label filters, relationship-existence filters, and full-text search applied before the operator all
keep their row-elimination semantics. A composed projection and paging are supported, but Where,
OrderBy, Distinct, and aggregate terminals over OptionalTraversalResult<TEnd> are rejected at
the shared model boundary because moving them around the left match changes null-preserving
semantics; materialize the results to continue client-side. The operator applies directly to a node
scope: combining it with another traversal operator in the same query is rejected rather than
silently losing the left match.
Typed union and concatenation
The graph-typed overloads keep the result as IGraphQueryable<T> and support both entity and
compatible scalar projections:
var activeIds = graph.Nodes<Person>().Where(person => person.Active).Select(person => person.Id);
var invitedIds = graph.Nodes<Person>().Where(person => person.Invited).Select(person => person.Id);
var uniqueIds = await activeIds.Union(invitedIds).ToListAsync();
var auditIds = await activeIds.Concat(invitedIds).ToListAsync();
Union removes duplicate projected rows. Concat is bag-preserving and lowers to UNION ALL, so
an item present in both operands appears twice. Operand output types and projection shapes must be
compatible; incompatible shapes fail in GraphQueryModelValidator. Apply each operand’s filters,
projection, ordering, and paging before combining it. Repeated same-kind calls are supported:
a.Union(b).Union(c) removes duplicates across the complete chain, while
a.Concat(b).Concat(c) preserves duplicates and operand order. The shared model keeps these as
recursive binary operations with disjoint parameter namespaces at every level.
A flat mixed chain such as a.Union(b).Concat(c) is rejected because it does not state the intended
grouping. Nest the second operand when mixing operations intentionally — for example,
a.Union(b.Concat(c)) — or materialize the first set operation. Other sequence operators still
require materializing the combined query first.
Label filters
OfLabel and OfLabels filter the current typed or dynamic node scope by its stored labels. Use
GraphLabelMatch.Any to require at least one requested label, or GraphLabelMatch.All to require
every requested label:
var reviewers = await graph.Nodes<Person>()
.OfLabel("Manager")
.OfLabels(GraphLabelMatch.All, "Active", "SecurityReviewer")
.Where(person => person.Region == "Northwest")
.OrderBy(person => person.LastName)
.Take(25)
.ToListAsync();
var tagged = await graph.DynamicNodes()
.OfLabels(GraphLabelMatch.Any, "Imported", "Verified")
.ToListAsync();
An empty label list is an identity operation. Label arguments are values, not query identifiers;
the Cypher dialect renders them as escaped literals, so caller-supplied labels cannot change the
query structure. Apply label filters before Select, Skip, or Take; the shared validator rejects
the reverse order instead of silently moving a label predicate across that boundary. Because
Traverse composes an implicit projection, a label filter also cannot follow it — filter the
traversal source before traversing instead. Providers that do not declare
GraphCapability.LabelFiltering reject a non-empty label filter during translation.
Projection and Results
Anonymous Types
var names = await graph.Nodes<Person>()
.Where(p => p.Age > 25)
.Select(p => new
{
FullName = p.FirstName + " " + p.LastName,
p.Age,
IsAdult = p.Age >= 18
})
.ToListAsync();
Transformations
var projected = await graph.Nodes<Person>()
.Select(p => new
{
UpperName = p.FirstName.ToUpper(),
NameLength = p.FirstName.Length,
AgePlusTen = p.Age + 10,
// DateTime operations
BirthYear = DateTime.Now.Year - p.Age
})
.ToListAsync();
Relationship-count projection
Inside a projection you can count a node’s relationships (its degree) by relationship type and
direction with CountRelationships<TRel>(...). It is a projection-only marker: the provider
recognizes the call and translates it into a native relationship-count subquery — in Cypher a
COUNT { MATCH (p)-[:REL]->() } / size((p)-[:REL]->()) pattern subquery — rather than fetching
relationships to the client.
var stats = await graph.Nodes<Person>()
.Select(p => new
{
p.FirstName,
OutgoingKnows = p.CountRelationships<Knows>(GraphTraversalDirection.Outgoing),
IncomingKnows = p.CountRelationships<Knows>(GraphTraversalDirection.Incoming),
TotalKnows = p.CountRelationships<Knows>(GraphTraversalDirection.Both),
})
.ToListAsync();
GraphTraversalDirection matches the Traverse direction semantics: Outgoing counts physical
edges leaving the node and Incoming counts physical edges arriving at it. A relationship stored
with RelationshipDirection.Incoming therefore reverses its logical start/end orientation. Both
counts each incident relationship once, including a self-loop. Counting a base relationship type
also includes stored derived relationship types. The direction argument must be a compile-time
constant. This surface requires the PatternSizeProjection capability; providers that do not
declare it reject the query at translation time. Calling CountRelationships outside a query
projection throws InvalidOperationException.
Projecting collections
You can project a correlated collection per row: group a node’s outgoing path segments by
their start node and shape the segments into a nested list. On a graph provider this lowers to a
Cypher pattern comprehension ([(src)-[:KNOWS]->(tgt) WHERE ... | ...]) evaluated per row — no
client-side join.
// Per person, the list of their friends' names (a nested collection projection)
var social = await graph.Nodes<Person>()
.Where(p => p.FirstName == "Alice")
.PathSegments<Person, Knows, Person>()
.GroupBy(segment => segment.StartNode)
.Select(group => new
{
Name = group.Key.FirstName,
FriendCount = group.Count(),
FriendNames = group.Select(s => s.EndNode.FirstName).ToList()
})
.FirstOrDefaultAsync();
The inner collection can be filtered — either on the traversal before grouping, or inside the
group — and both the list and any Count() observe that filter:
// Filter the segments, then project the surviving collection and its count
var youngFriends = await graph.Nodes<Person>()
.Where(p => p.FirstName == "Alice")
.PathSegments<Person, Knows, Person>()
.Where(path => path.EndNode.Age < 30)
.GroupBy(segment => segment.StartNode)
.Select(group => new
{
PersonName = group.Key.FirstName,
YoungFriends = group.Select(s => s.EndNode.FirstName).ToList(),
YoungFriendCount = group.Count()
})
.FirstOrDefaultAsync();
// Or filter inside the group (e.g. only recent friendships)
var recent = await graph.Nodes<Person>()
.Where(p => p.FirstName == "Alice")
.PathSegments<Person, Knows, Person>()
.GroupBy(segment => segment.StartNode)
.Select(group => new
{
PersonName = group.Key.FirstName,
RecentFriends = group
.Where(s => s.Relationship.Since > DateTime.UtcNow.AddDays(-12))
.Select(s => s.EndNode.FirstName)
.ToList()
})
.FirstOrDefaultAsync();
The inner Select may itself build an anonymous type, producing a list of nested records:
.Select(group => new
{
PersonName = group.Key.FirstName,
Friends = group.Select(s => new { s.EndNode.FirstName, s.EndNode.Age }).ToList()
})
The inner collection can also be aggregated, ordered, or re-grouped. On a graph
provider these lower to a scoped CALL { … } subquery (a pattern comprehension cannot order or
aggregate a list):
.Select(group => new
{
Name = group.Key.FirstName,
FriendCount = group.Count(),
AverageAge = group.Average(s => s.EndNode.Age),
OldestFriend = group.Max(s => s.EndNode.Age),
// Ordered collection
ByAge = group.OrderBy(s => s.EndNode.Age)
.Select(s => new { s.EndNode.FirstName, s.EndNode.Age }).ToList(),
// Nested grouping
ByBand = group.GroupBy(s => s.EndNode.Age >= 30 ? "Senior" : "Junior")
.Select(g => new { Band = g.Key, Count = g.Count() }).ToList()
})
Correlated collection projections require the provider to declare the CallSubqueries
capability; projections that use group.Count() also require PatternSizeProjection. Providers
that do not declare the required capabilities decline the query at translation time. As with LINQ
GroupBy, roots with no matching path segment do not produce an empty group row.
Scalar-key grouping (per-group aggregates)
You can also group a node set by a scalar key and project per-group aggregates, either through
a Select over the grouping or through the result-selector GroupBy overload:
var byDepartment = await graph.Nodes<Employee>()
.Where(e => e.Active)
.GroupBy(e => e.Department)
.Select(g => new
{
Department = g.Key,
Count = g.Count(),
TotalSalary = g.Sum(e => e.Salary),
AverageAge = g.Average(e => e.Age),
Youngest = g.Min(e => e.Age),
Oldest = g.Max(e => e.Age),
})
.ToListAsync();
// Result-selector overload, and key-only grouping (distinct keys):
var counts = await graph.Nodes<Employee>()
.GroupBy(e => e.Department, (dept, group) => new { Department = dept, Count = group.Count() })
.ToListAsync();
var departments = await graph.Nodes<Employee>()
.GroupBy(e => e.Department)
.Select(g => g.Key)
.ToListAsync();
Scalar-key grouping requires the provider to declare the GroupByAggregation capability. The
supported surface is deliberately minimal: a node root (optionally with a Where before GroupBy),
a scalar key, and a projection that reads g.Key and/or the
Count/LongCount/Sum/Average/Min/Max aggregates. g.LongCount() is the long-returning
form of g.Count() and lowers to the same provider count aggregate. Everything else — entity or
composite keys, element selectors, grouping after a traversal, collection projections such as
g.Select(...).ToList(), filtered aggregates such as g.Count(predicate) or
g.LongCount(predicate), and Distinct/OrderBy/Skip/Take applied to the grouped result — is
declined at translation time with a NotSupportedException; materialize the grouped query first and
continue client-side.
For correlated path grouping, the recognized grammar over the group — Select, Where,
OrderBy/OrderByDescending,
Count, Average/Sum/Min/Max, nested GroupBy (optionally terminated by
ToList/ToArray/AsEnumerable), or a projection of the group key — is a shared contract
enforced identically across providers. Any other operation over the group (for example
First, Take, Skip, Distinct, ElementAt) cannot be lowered to a pattern comprehension or
subquery and is rejected up-front with the same GraphQueryTranslationException on every
provider, so a shape that fails on a graph database fails the same way on the in-memory provider
rather than silently succeeding client-side. Collection projections require exactly one Select;
Where, ordering, and nested grouping must precede it. A bare group collection, multiple Select
operations, or a filter/order applied after Select is rejected by the same shared validator.
Aggregations
Count, Any, All
// Count with predicate
var adultCount = await graph.Nodes<Person>()
.CountAsync(p => p.Age >= 18);
// Check existence
var hasMinors = await graph.Nodes<Person>()
.AnyAsync(p => p.Age < 18);
// Check all match condition
var allAdults = await graph.Nodes<Person>()
.AllAsync(p => p.Age >= 18);
First, Single, Last
// Get first matching
var firstSmith = await graph.Nodes<Person>()
.Where(p => p.LastName == "Smith")
.OrderBy(p => p.FirstName)
.FirstOrDefaultAsync();
// Get exactly one matching (throws if zero or multiple)
var specificPerson = await graph.Nodes<Person>()
.SingleAsync(p => p.Id == "unique-id");
// Get last matching
var youngestPerson = await graph.Nodes<Person>()
.OrderBy(p => p.Age)
.LastOrDefaultAsync();
Mathematical Aggregations
// Sum, average, min, max
var stats = await graph.Nodes<Person>()
.GroupBy(p => 1) // Group all into single group
.Select(g => new
{
TotalAge = g.Sum(p => p.Age),
AverageAge = g.Average(p => p.Age),
MinAge = g.Min(p => p.Age),
MaxAge = g.Max(p => p.Age),
Count = g.Count()
})
.FirstAsync();
// Get single (throws if multiple)
var theAlice = await graph.Nodes<Person>()
.SingleAsync(p => p.FirstName == "Alice");
// Get last
var youngest = await graph.Nodes<Person>()
.OrderBy(p => p.Age)
.LastAsync();
// Safe versions that return null
var maybeAlice = await graph.Nodes<Person>()
.FirstOrDefaultAsync(p => p.FirstName == "Alice");
Value aggregates such as SumAsync, MinAsync, MaxAsync, and AverageAsync after unordered Take or Skip operate over an arbitrary row subset; add OrderBy before pagination when deterministic results matter.
AverageAsync follows the standard LINQ result matrix for both source and selector overloads:
int and long average to double, float to float, double to double, and decimal to
decimal. Nullable inputs return the corresponding nullable result and ignore null values. A
non-nullable empty sequence throws InvalidOperationException; a nullable empty or all-null
sequence returns null. Other numeric input types are not part of the API.
GroupBy and Aggregates
// Group by with count
var byLastName = await graph.Nodes<Person>()
.GroupBy(p => p.LastName)
.Select(g => new
{
LastName = g.Key,
Count = g.Count()
})
.ToListAsync();
// Multiple aggregations
var ageStats = await graph.Nodes<Person>()
.GroupBy(p => p.Department)
.Select(g => new
{
Department = g.Key,
Count = g.Count(),
AverageAge = g.Average(p => p.Age),
MinAge = g.Min(p => p.Age),
MaxAge = g.Max(p => p.Age)
})
.ToListAsync();
Working with Relationships
Querying Relationships
// Filter relationships
var recentConnections = await graph.Relationships<Knows>()
.Where(k => k.Since > DateTime.UtcNow.AddDays(-30))
.ToListAsync();
// Relationships from specific node
var aliceKnows = await graph.Relationships<Knows>()
.Where(k => k.StartNodeId == aliceId)
.ToListAsync();
// Search either endpoint
var connectedToAlice = await graph.Relationships<Knows>()
.Where(k => k.StartNodeId == aliceId || k.EndNodeId == aliceId)
.ToListAsync();
Full-Text Search
Graph Model provides comprehensive full-text search capabilities through both direct search methods and LINQ integration.
Direct Search Methods
// Search across all entities (nodes and relationships)
var allResults = await graph.Search("machine learning").ToListAsync();
// Search specific node types
var articleResults = await graph.SearchNodes<Article>("artificial intelligence").ToListAsync();
// Search specific relationship types
var relationshipResults = await graph.SearchRelationships<Knows>("college").ToListAsync();
// Search using generic interfaces
var allNodes = await graph.SearchNodes("graph database").ToListAsync();
var allRelationships = await graph.SearchRelationships("friendship").ToListAsync();
LINQ Integration with Search
The Search() method can be used in LINQ chains to perform full-text search on query results:
// Basic search in LINQ chain
var results = await graph.Nodes<Person>()
.Where(p => p.Age > 25)
.Search("software engineer")
.ToListAsync();
// Search in path segments traversal
var memories = await graph.Nodes<User>()
.Where(u => u.Id == "...")
.PathSegments<User, UserMemory, Memory>()
.Select(p => p.EndNode)
.Search("vacation memories")
.ToListAsync();
// Search with multiple conditions
var filteredResults = await graph.Nodes<Article>()
.Where(a => a.PublishedDate > DateTime.UtcNow.AddDays(-30))
.Search("machine learning")
.Where(a => a.Author.StartsWith("Dr."))
.ToListAsync();
// Search with projections
var summaries = await graph.Nodes<Article>()
.Search("artificial intelligence")
.Select(a => new { a.Title, a.Summary })
.ToListAsync();
// Use a typed node search as the source of a traversal
var colleagues = await graph.Nodes<Person>()
.Search("cloud architect")
.Traverse<Knows, Person>(options => options
.Depth(1, 2)
.Direction(GraphTraversalDirection.Both))
.Where(person => person.IsActive)
.OrderBy(person => person.Name)
.ToListAsync();
// Preserve full path information after a typed node search
var paths = await graph.SearchNodes<Person>("cloud architect")
.TraversePaths<Knows, Person>(1, 3)
.ToListAsync();
Typed node searches can feed Traverse<TRel, TEnd>,
PathSegments<TStart, TRel, TEnd>, and TraversePaths<TRel, TEnd>; later filters,
projections, ordering, paging, and terminals apply to the traversal results. Mixed node-and-relationship
searches from graph.Search(...) cannot be traversal sources because they do not have one typed node
scope. Use SearchNodes<T>(...) or Nodes<T>().Search(...) when traversal is required.
Traditional String Operations
// Contains search
var results = await graph.Nodes<Article>()
.Where(a => a.Content.Contains("machine learning"))
.ToListAsync();
// Case-insensitive search
var caseInsensitive = await graph.Nodes<Article>()
.Where(a => a.Title.ToLower().Contains("graph"))
.ToListAsync();
// Multiple term search
var multiTerm = await graph.Nodes<Article>()
.Where(a => a.Content.Contains("graph") && a.Content.Contains("database"))
.ToListAsync();
// Starts with / Ends with
var prefixSearch = await graph.Nodes<Person>()
.Where(p => p.Email.StartsWith("admin@"))
.ToListAsync();
Search Features
- Case Insensitive: All search operations are case-insensitive by default
- Exact-token matching: Terms match whole words, not substrings — searching “vaca” does not match “vacation”
- Multi-word AND: A multi-term query such as
Search("machine learning")matches an entity only when all terms match, in any order and at any distance (not just when any one term matches) - Property Control: Use
[Property(IncludeInFullTextSearch = false)]to exclude properties from search; only the entity’s own string properties are searched (complex-property value nodes are not) - Automatic Indexing: Full-text indexes are created and managed automatically
- LINQ Integration: Seamlessly integrate search into existing LINQ query chains
- Traversal source: Typed node search results can directly feed node traversal operators
- Unordered results: Search result order is unspecified; add an explicit
OrderBywhen order matters
Combining Node and Relationship Queries
For complex scenarios, you can execute multiple queries and join in memory:
// Get all data
var people = await graph.Nodes<Person>().ToListAsync();
var knows = await graph.Relationships<Knows>().ToListAsync();
// Join to find connections
var connections = from person in people
join k in knows on person.Id equals k.StartNodeId
join friend in people on k.EndNodeId equals friend.Id
select new
{
Person = person.FirstName,
Friend = friend.FirstName,
Since = k.Since
};
// Group to find popular people
var popular = knows
.GroupBy(k => k.EndNodeId)
.Select(g => new
{
PersonId = g.Key,
IncomingConnections = g.Count()
})
.Join(people, g => g.PersonId, p => p.Id, (g, p) => new
{
p.FirstName,
g.IncomingConnections
})
.OrderByDescending(x => x.IncomingConnections)
.ToList();
Performance Considerations
- Use projections to reduce data transfer when you don’t need full entities
- Filter early in your query chain to reduce the working set
- Be mindful of traversal depth - deep traversals can be expensive
- Consider pagination for large result sets
- Use transactions for multiple related operations
Advanced Scenarios
Conditional Logic in Projections
var categorized = await graph.Nodes<Person>()
.Select(p => new
{
p.FirstName,
Category = p.Age < 18 ? "Minor" :
p.Age < 65 ? "Adult" :
"Senior",
Discount = p.Age < 18 || p.Age >= 65 ? 0.2 : 0.0
})
.ToListAsync();