Entity Framework Core
Seamless EF Core integration with navigation property support, efficient SQL translation, and LINQ-compatible query execution.
Installation#
The EF Core integration requires two packages: the core query building engine and the Entity Framework adapter. Install both via the .NET CLI.
dotnet add package NetQueryBuilder dotnet add package NetQueryBuilder.EntityFramework
NetQueryBuilder.EntityFramework targets .NET 6, .NET 8, and .NET 9 and depends on Microsoft.EntityFrameworkCore 6.0+ and System.Linq.Dynamic.Core 1.6+.
Service Registration#
Register your DbContext and the EF Core query configurator in your application's service collection.
The EfQueryConfigurator<TDbContext> class bridges NetQueryBuilder with your EF Core context,
discovering entity types and generating optimised queries automatically.
using NetQueryBuilder; using NetQueryBuilder.EntityFramework; var builder = WebApplication.CreateBuilder(args); // Register your EF Core DbContext builder.Services.AddDbContext<AppDbContext>(options => { options.UseSqlServer(builder.Configuration.GetConnectionString("Default")); }); // Register the EF Core implementation of IQueryConfigurator builder.Services.AddScoped<IQueryConfigurator, EfQueryConfigurator<AppDbContext>>(); var app = builder.Build(); app.Run();
Register IQueryConfigurator as scoped so its lifetime matches the DbContext.
This ensures each request gets a fresh context and avoids ObjectDisposedException errors across requests.
Basic Queries#
Inject IQueryConfigurator into any service or controller, call BuildFor<T>()
to create a query for an entity type, and Execute to run it against the database.
public class ProductService { private readonly IQueryConfigurator _queryConfigurator; public ProductService(IQueryConfigurator queryConfigurator) { _queryConfigurator = queryConfigurator; } public async Task<IEnumerable<Product>> GetAllProductsAsync() { var query = _queryConfigurator.BuildFor<Product>(); return (await query.Execute(50)).Cast<Product>(); } }
The Execute method accepts a maxResults parameter that limits the number of rows
returned. Internally it calls ToListAsync() for non-blocking database access.
Filtering with Conditions#
Add conditions to a query by looking up a PropertyPath and passing it to Condition.CreateNew
with an operator type. The condition is compiled into an expression tree that EF Core translates to a SQL WHERE clause.
public async Task<IEnumerable<Product>> GetProductsByCategoryAsync(string category) { var query = _queryConfigurator.BuildFor<Product>(); // Look up the property path by its full name var categoryProperty = query.ConditionPropertyPaths .First(p => p.PropertyFullName == "Category"); // Create a condition using the EqualsOperator query.Condition.CreateNew<EqualsOperator>(categoryProperty, category); return (await query.Execute(50)).Cast<Product>(); }
The ConditionPropertyPaths collection contains every scalar and navigation property
discovered from your entity type. Use PropertyFullName to locate the property you need.
Navigation Properties#
One of the most powerful features of the EF Core integration is its automatic handling of navigation
properties. Use dot notation to reference properties on related entities, and
EfQuery<T> will generate the necessary Include() calls so EF Core
produces the correct JOINs.
public async Task<IEnumerable<Order>> GetOrdersByCustomerCityAsync(string city) { var query = _queryConfigurator.BuildFor<Order>(); // Dot notation navigates through relationships var cityProperty = query.ConditionPropertyPaths .First(p => p.PropertyFullName == "Customer.City"); query.Condition.CreateNew<EqualsOperator>(cityProperty, city); return (await query.Execute(50)).Cast<Order>(); }
You do not need to call .Include() manually. EfQuery<T> extracts
navigation paths from your conditions and applies Include() statements before query
execution. A HashSet ensures each navigation path is included only once, even when
multiple conditions reference the same relationship.
Using Operators#
The EF Core integration extends the default operator set with SQL-specific operators like EfLikeOperator,
which translates to EF.Functions.Like() for server-side pattern matching. Each property exposes a
list of compatible operators based on its CLR type.
Like operator for partial matching
public async Task<IEnumerable<Product>> SearchProductsByNameAsync(string searchTerm) { var query = _queryConfigurator.BuildFor<Product>(); var nameProperty = query.ConditionPropertyPaths .First(p => p.PropertyFullName == "Name"); // Get the Like operator from compatible operators for this property var likeOperator = nameProperty.GetCompatibleOperators() .First(o => o.ToString() == "Like"); query.Condition.CreateNew(nameProperty, likeOperator, searchTerm); return (await query.Execute(50)).Cast<Product>(); }
The EfLikeOperator automatically wraps your search value with SQL wildcards (%value%),
so passing "Widget" will match "Blue Widget", "Widget Pro", and "Super Widget Deluxe".
The LIKE operator is only available for string properties.
GreaterThan / LessThan for ranges
Combine multiple conditions to define a range. Use And() to link them with a logical AND.
public async Task<IEnumerable<Product>> GetProductsInPriceRangeAsync( decimal minPrice, decimal maxPrice) { var query = _queryConfigurator.BuildFor<Product>(); var priceProperty = query.ConditionPropertyPaths .First(p => p.PropertyFullName == "Price"); // Minimum price condition query.Condition.CreateNew( priceProperty, priceProperty.GetCompatibleOperators() .First(o => o.ToString() == "GreaterThanOrEqual"), minPrice ); // Combine with AND query.Condition.And(); // Maximum price condition query.Condition.CreateNew( priceProperty, priceProperty.GetCompatibleOperators() .First(o => o.ToString() == "LessThanOrEqual"), maxPrice ); return (await query.Execute(50)).Cast<Product>(); }
Complex Conditions#
Build compound filters by chaining conditions with And() and Or().
You can combine conditions across different properties and navigation paths to create
sophisticated filters that are fully translated to SQL.
public async Task<IEnumerable<Order>> GetFilteredOrdersAsync( string city, string status) { var query = _queryConfigurator.BuildFor<Order>(); // Filter by customer city (navigation property) var cityProperty = query.ConditionPropertyPaths .First(p => p.PropertyFullName == "Customer.City"); query.Condition.CreateNew<EqualsOperator>(cityProperty, city); // AND filter by order status (direct property) query.Condition.And(); var statusProperty = query.ConditionPropertyPaths .First(p => p.PropertyFullName == "Status"); query.Condition.CreateNew<EqualsOperator>(statusProperty, status); return (await query.Execute(50)).Cast<Order>(); }
The resulting expression tree is handed to EF Core as a single Where predicate.
Both conditions and the navigation property Include are handled in one round-trip to the database.
Performance#
The Entity Framework integration translates dynamic queries into EF Core expression trees, which are then converted to SQL by the database provider. This approach ensures efficient, server-side query execution.
SQL translation
- No client-side evaluation — conditions are compiled into expression trees that EF Core translates entirely to SQL. Filtering happens on the database server, not in your application's memory.
- Parameterised queries — all user-supplied values are passed as parameters, never concatenated into query strings. This prevents SQL injection by design.
JOIN optimisation
- Automatic Include — navigation property paths are extracted and
Include()is called before execution, producing efficient JOINs instead of N+1 queries. - Deduplicated paths — a
HashSetensures each navigation path is included only once, even when multiple conditions reference the same relationship.
Expression caching
- Cache invalidation — compiled expressions are cached with an
_isCacheValidflag and only recompiled when conditions change, avoiding redundant expression tree construction.
For performance-critical operations where the query pattern is known at compile time, consider using direct LINQ expressions. Dynamic queries may generate more complex SQL than hand-crafted queries. NetQueryBuilder is best suited for user-driven, dynamic filtering scenarios where the query shape cannot be predicted in advance.
Always register IQueryConfigurator as scoped, not singleton.
The configurator holds a reference to DbContext, which is scoped to a single request in
ASP.NET Core. Using a singleton will cause ObjectDisposedException on subsequent requests.
Advanced Configuration#
The IQueryConfigurator interface exposes fluent methods for customising how queries are built.
Use these to register custom operators, control condition behaviour, or plug in a custom expression stringifier.
Custom operator registration
Use ConfigureConditions to extend or replace the default operator set. This is useful when you
need domain-specific filtering logic beyond the built-in operators.
var configurator = services.GetRequiredService<IQueryConfigurator>(); // Register additional condition configuration configurator.ConfigureConditions(config => { // Add custom operators or modify condition behaviour }); var query = configurator.BuildFor<Product>();
Custom expression stringifier
The expression stringifier controls how conditions are displayed as human-readable text. Replace it with a custom implementation for localised or domain-specific formatting.
// Use a custom expression stringifier configurator.UseExpressionStringifier(new CustomExpressionStringifier()); var query = configurator.BuildFor<Product>();
Discovering entity types at runtime
The configurator inspects your DbContext.Model to discover all registered entity types.
Use GetEntities() to list them and BuildFor(Type) to build a query for
a type selected at runtime.
// List all entity types from the DbContext model var entityTypes = configurator.GetEntities(); foreach (var entityType in entityTypes) { Console.WriteLine(entityType.Name); } // Build a query for a runtime-selected type var selectedType = entityTypes.First(); var query = configurator.BuildFor(selectedType);