INTRODUCTION · LINQeQuantic.LinqeQuantic.Linq builds a query as an object: filters, sorting and specifications that combine, travel between layers, and can be tested without a database.
Overview
It answers the problem a dynamic query usually creates — a controller that knows the shape of the data — by letting the presentation layer hand the service layer a filter instead of a clause.Three implementations line up: C# with source generators, TypeScript for the browser, and a type-safe casting system between DTOs and entities.
Install it
shell
dotnet add package eQuantic.Linq
What it does
Source generatorsSpecifications, filters and sorters are written when you build, on .NET 6 and later, so nothing is worked out by reflection while the request is waiting.
The specification patternThe whole pattern, with AND and OR, so a business rule is written once and combined afterwards rather than copied into the next query.
Entity filter and sorterFiltering and sorting travel from the presentation layer to the service layer as values, so neither layer has to know what the other one queries.
Casting with types keptTransformation between DTOs and entities, configured fluently and checked when you build rather than when it runs.
Expressions keptLambda expressions are cached, so a query you run in a loop is compiled once instead of every time round.
Asynchronous throughoutAsynchronous APIs for the entity filter and the sorter, cancellation token included.
The same thing in TypeScriptA parallel implementation for the browser, with the generics and the type checking intact, so a filter means the same on both sides.
A fluent query APIIQuery, IValueQuery and IComparisonQuery, for building a query a step at a time and running it when it is finished.
Source generatorsSpecifications, filters and sorters are written when you build, on .NET 6 and later, so nothing is worked out by reflection while the request is waiting.
The specification patternThe whole pattern, with AND and OR, so a business rule is written once and combined afterwards rather than copied into the next query.
Entity filter and sorterFiltering and sorting travel from the presentation layer to the service layer as values, so neither layer has to know what the other one queries.
Casting with types keptTransformation between DTOs and entities, configured fluently and checked when you build rather than when it runs.
Expressions keptLambda expressions are cached, so a query you run in a loop is compiled once instead of every time round.
Asynchronous throughoutAsynchronous APIs for the entity filter and the sorter, cancellation token included.
The same thing in TypeScriptA parallel implementation for the browser, with the generics and the type checking intact, so a filter means the same on both sides.
A fluent query APIIQuery, IValueQuery and IComparisonQuery, for building a query a step at a time and running it when it is finished.
Quick start
Install both packages — The library, and the generator beside it. The generator is a build-time dependency, so it ships with nothing.
shell
dotnet add package eQuantic.Linqdotnet add package eQuantic.Linq.SourceGenerator
Mark the model — One attribute on the class, and one per property saying which operators that property should get.
C#
builder.Services.AddLinqServices(); [GenerateSpecifications]public class Product{ public int Id { get; set; } [SpecProperty(FilterOperators = FilterOperator.Equals | FilterOperator.Contains)] public string Name { get; set; } = string.Empty; [SpecProperty(FilterOperators = FilterOperator.GreaterThan | FilterOperator.LessThan)] public decimal Price { get; set; } public bool IsActive { get; set; }}
Use what the generator wrote — The specifications and the filters arrive as real members, so they autocomplete and a typo is a compile error.
C#
public partial class Product{ public static class Specifications { public static ISpecification<Product> NameEquals(string value) => new DirectSpecification<Product>(p => p.Name == value); public static ISpecification<Product> NameContains(string value) => new DirectSpecification<Product>(p => p.Name.Contains(value)); public static ISpecification<Product> PriceGreaterThan(decimal value) => new DirectSpecification<Product>(p => p.Price > value); } public static class Filters { public static IEntityFilter<Product> ByName(string name) => EntityFilter<Product>.Where(NameEquals(name)); public static IEntityFilter<Product> ByPriceRange(decimal min, decimal max) => EntityFilter<Product>.Where(PriceGreaterThan(min).And(p => p.Price <= max)); }}
Pass the filter across the layers — The service takes a filter and never sees a query string; the endpoint builds the filter and never sees the database.
C#
public class ProductService{ private readonly IRepository<Product> _repository; public async Task<IEnumerable<Product>> GetProductsAsync(IEntityFilter<Product> filter) { return await _repository.GetAsync(filter.Specification); }} app.MapGet("/products", async ( [AsParameters] ProductSearchRequest request, ProductService products) =>{ var filter = EntityFilter<Product>.Empty(); if (!string.IsNullOrEmpty(request.Name)) filter = filter.And(Product.Specifications.NameContains(request.Name)); if (request.MinPrice.HasValue) filter = filter.And(Product.Specifications.PriceGreaterThan(request.MinPrice.Value)); return Results.Ok(await products.GetProductsAsync(filter));});
Cast between models — Map a nested property, rename one, and refuse to compile if anything was left unmapped.
C#
public class ProductDto{ public string Name { get; set; } = string.Empty; public string Category { get; set; } = string.Empty; public bool IsAvailable { get; set; }} var products = await _repository.GetAsync<Product>() .Cast<ProductDto>(options => options .Map(src => src.Name, dest => dest.Name) .Map(src => src.Category.Name, dest => dest.Category) .Map(src => src.IsActive, dest => dest.IsAvailable) .ExcludeUnmapped()); public class ProductCastConfiguration : IFilteringCastConfiguration<ProductDto>{ public void Configure(FilteringCastOptions<ProductDto> options) { options.Map(src => src.Name, dest => dest.Name) .Map(src => src.IsActive, dest => dest.IsAvailable); }}
The same API in the browser — The TypeScript side builds the same filters and serialises them to the string the API expects.
TypeScript
import { Filtering, FilteringCollection, Sorting, SortingCollection } from 'equantic-linq'; interface User { id: number; name: string; email: string; age: number; isActive: boolean;} const filters = new FilteringCollection<User>([ Filtering.ct<User, 'name'>('name', 'John'), Filtering.gte<User, 'age'>('age', 25), Filtering.eq<User, 'isActive'>('isActive', true),]); const sorts = new SortingCollection<User>([ Sorting.desc<User>('age'), Sorting.asc<User>('name'),]); filters.toString(); // name:ct(John),age:gte(25),isActive:eq(true)sorts.toString(); // age:desc,name:asc
Build a query a step at a time — The fluent API for the cases where the query is assembled in code: conditions, order, paging, and an aggregate at the end.
C#
var query = Query.For<Product>() .Where(p => p.IsActive) .OrderBy(p => p.Name) .Skip(10) .Take(20); var totalPrice = await ValueQuery.For<Product>() .Where(p => p.IsActive) .Sum(p => p.Price) .ExecuteAsync(); var hasExpensiveProducts = await ComparisonQuery.For<Product>() .Any(p => p.Price > 1000) .ExecuteAsync(); var complexQuery = Query.For<Product>() .Where(p => p.IsActive) .And(p => p.Price > 100) .Or(p => p.Category == "Premium") .OrderBy(p => p.Price) .ThenBy(p => p.Name);
Public surface
NAMESPACE
PUBLIC SURFACE
GenerateSpecificationsAttribute
Marks a class the generator should write specifications for.
SpecPropertyAttribute
Says which filter operators one property should get.
ISpecification<T>
The specification contract, over a lambda expression.
IEntityFilter<T>
The filter one layer hands another.
IEntitySorter<T>
The same, for sorting, with a fluent surface.
IAsyncEntityFilter<T>
The asynchronous filter, with a cancellation token.
Filtering<TData, TColumn>
Filtering on one column, nested properties included.
FilterOperator
The operators a filter can use: equals, contains, starts with, greater than, and the rest.
IExpressionCache
Where a compiled lambda expression is kept between calls.
Query<T>
The entry point to the fluent query API.
Prefer reading code?
Every package is MIT and public, and the samples are copy-paste ready.
Ready to ship something real?
Book a 30-minute discovery call. We'll tell you within the hour whether we're the right partner — or who is.
Ready to ship something real?
Book a 30-minute discovery call. We'll tell you within the hour whether we're the right partner — or who is.
© 2026 eQuantic Tech. All rights reserved.