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.

shell
dotnet add package NetQueryBuilder
dotnet add package NetQueryBuilder.EntityFramework
Note

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.

csharp
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();
Scoped lifetime

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.

csharp
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.

csharp
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.

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.

csharp
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>();
}
Automatic Includes

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

csharp
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>();
}
Wildcard wrapping

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.

csharp
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.

csharp
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

JOIN optimisation

Expression caching

Performance tip

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.

Common pitfall

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.

csharp
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.

csharp
// 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.

csharp
// 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);