Include graphs
Graph<T> holds the include paths of an aggregate: the references and collections a caller wants loaded with it, such as an order with its customer and the products of its lines. It lives in the base Arc4u package, so the layers that decide what to load do not reference Entity Framework Core. The data access layer applies the paths with Entity Framework Core. The design principles explain why Arc4u builds on EF Core instead of wrapping it.
What it solves
The caller of a service knows which parts of an aggregate it needs; the data access layer knows how to load them. Graph<T> carries that choice between the two as a list of paths (Customer.Address, Lines.Product):
- The paths are built from expressions, so a renamed property is a compile error, not a failed query.
- A graph serializes with
System.Text.Json, so a client can send it to a service. - A graph loads nothing itself.
Arc4u.EfCoreturns its paths intoIncludeandThenIncludecalls.
flowchart LR
Caller["Caller (UI, API, service)"] -->|"Graph<Order>"| DAL[Data access layer]
DAL -->|"Include and ThenInclude"| EF[Entity Framework Core]
Install
dotnet add package Arc4u --prerelease
Graph<T> is in the Arc4u namespace of the base Arc4u package. To apply a graph to a query, the data access layer also needs Arc4u.EfCore (see Entity Framework Core).
Build a graph
The samples on this page use this model:
public class Order
{
public Guid Id { get; set; }
public Customer Customer { get; set; } = null!;
public List<OrderLine> Lines { get; set; } = [];
}
public class Customer
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public Address Address { get; set; } = null!;
}
public class Address
{
public Guid Id { get; set; }
public string City { get; set; } = string.Empty;
}
public class OrderLine
{
public Guid Id { get; set; }
public Product Product { get; set; } = null!;
}
public class Product
{
public Guid Id { get; set; }
public string Label { get; set; } = string.Empty;
}
Call Include once per path. It returns the graph, so the calls chain:
using Arc4u;
var graph = new Graph<Order>()
.Include(o => o.Customer.Address)
.Include(o => o.Lines.Select(l => l.Product));
// graph.Includes: "Customer.Address", "Lines.Product"
- Follow a reference with
.and go through a collection withSelect(orSelectMany). For a collection inside a collection, nest theSelectcalls. - Other operators, such as
Where,OrderBy,TakeorFirst, keep the path of the collection they apply to:o => o.Lines.Where(l => l.Product != null)is the pathLines. - A path also includes the properties along it:
Customer.Addressloads the customer too. - A path added twice is kept once.
- Only navigation properties belong in a path: references to a class, and arrays or generic collections of one class.
Includethrows aMemberAccessExceptionfor anything else, such as astring, a value type, or a collection of those.
Build a graph from paths
When the paths come as text (from configuration, for example), pass them to the constructor:
var graph = new Graph<Order>(["Customer.Address", "Lines.Product"]);
The constructor checks every property of every path against the model and throws a MissingMemberException for the first one that cannot be included. Property names are case-sensitive. Setting the Includes property checks the paths the same way.
Common scenarios
Check what the caller asked for
if (graph.Exist(o => o.Lines))
{
// ... compute what depends on the lines
}
Exist returns true when the graph contains the path or a path below it. With Lines.Product, both o => o.Lines and o => o.Lines.Select(l => l.Product) exist. Paths are compared by whole property names and with their casing: Line does not match Lines.Product.
Pass part of a graph to another component
GetGraph returns the paths below a first-level property, as a graph of that property's type:
Graph<Customer> customerGraph = graph.GetGraph(o => o.Customer);
// customerGraph.Includes: "Address"
It takes one level at a time: o => o.Customer.Address throws an InvalidOperationException. Call GetGraph again on the result to go deeper.
For a collection property, the result is a graph of the collection. Its paths start at the item type, and you go through the items with Select:
Graph<List<OrderLine>> linesGraph = graph.GetGraph(o => o.Lines);
// linesGraph.Includes: "Product"
bool withProducts = linesGraph.Exist(lines => lines.Select(l => l.Product));
Remove what a caller must not load
RemoveGraph returns a copy of the graph without a property and the paths below it. The original graph does not change.
// Customers are served by another endpoint.
var allowed = graph.RemoveGraph(o => o.Customer);
// allowed.Includes: "Lines.Product"
A graph only stores full paths, so removing o => o.Customer.Address from a graph that holds Customer.Address also stops loading the customer. Include o => o.Customer as well to keep it.
Caution
A graph received from a caller decides what the service loads, and often what it returns. Remove the branches the caller must not see before you apply the graph.
Build a graph from a loaded object
Parse returns the graph of what an instance holds, for example to load the same parts again:
var order = new Order
{
Customer = new Customer(),
Lines = [new OrderLine { Product = new Product() }],
};
var loaded = Graph<Order>.Parse(order);
// loaded.Includes: "Customer", "Lines.Product"
- A
nullreference or collection is not in the graph. - For a collection,
Parsereads the first item. An empty collection is in the graph; passshouldHaveElement: trueto leave it out. Parsefollows the public properties of the declared type of each property: a property that only a subclass declares is ignored, and so are non-public and static properties and indexers.- It does not follow a reference back to an object it is already reading, so cyclic object graphs work.
Send a graph to another process
Graph<T> serializes with System.Text.Json as its list of paths, with the reflection-based serializer and with a source-generated JsonSerializerContext alike:
using System.Text.Json;
var json = JsonSerializer.Serialize(graph);
// {"Includes":["Customer.Address","Lines.Product"]}
var received = JsonSerializer.Deserialize<Graph<Order>>(json)!;
var checkedGraph = new Graph<Order>(received.Includes);
Deserializing does not check the paths, because the check needs reflection on T. Check a graph received from another process with new Graph<Order>(received.Includes) before you use it: the constructor throws a MissingMemberException for a path that does not match the model.
Pitfalls
- Reflection and trimming. The constructor that takes paths, the
Includessetter andParselook up properties by name with reflection. They are marked[RequiresUnreferencedCode], because trimming can remove those properties.new Graph<T>(),Include,Exist,GetGraphandRemoveGraphwork on expressions and strings only: use them in a trimmed or Native AOT application (see Native AOT and trimming). Includesreturns a copy. Adding to the returned list does not change the graph: useInclude, or setIncludes.Parsecalls the property getters. With lazy-loading proxies, it loads every navigation it reads.
Troubleshooting
MissingMemberException when you create a graph from paths
The message names the first property of a path that cannot be included:
- It does not exist on the type reached so far, or its casing differs (
customerinstead ofCustomer). - It is not a navigation property: a
string, a value type, an interface, a collection of strings, value types or collections, or a collection that is not generic.
Fix the path, or build it with Include so that the compiler checks it.
MemberAccessException: Including a type of string is not allowed when you try to define a graph path.
The expression passed to Include ends on a string or a collection of strings, for example o => o.Customer.Name. Include the entity that holds it instead: o => o.Customer.
InvalidOperationException: It is not allowed to check more than one level!
GetGraph received a path deeper than one property. Go one level at a time: graph.GetGraph(o => o.Customer).GetGraph(c => c.Address).
See also
- Entity Framework Core to apply a graph to a query
- Data access
- Graph<T> in the API reference