OdataQueryLite 0.1.0

dotnet add package OdataQueryLite --version 0.1.0
                    
NuGet\Install-Package OdataQueryLite -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="OdataQueryLite" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="OdataQueryLite" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="OdataQueryLite" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add OdataQueryLite --version 0.1.0
                    
#r "nuget: OdataQueryLite, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package OdataQueryLite@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=OdataQueryLite&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=OdataQueryLite&version=0.1.0
                    
Install as a Cake Tool

OdataQueryLite

A lightweight OData v4 $filter / $orderby / $expand / $select / $top / $skip / $count engine for .NET. No Microsoft.AspNetCore.OData, no EDM model, no MVC formatter — just parses the query string and composes IQueryable<T> transformations you can hand to EF Core or any LINQ provider.

Status: 0.1.0. Parser + expression builder + cache + ASP.NET Core integration shipped. 248 tests, 0 build warnings. Public API may still shift before 1.0.0 — pin the patch version if you depend on it now.

Why

Microsoft.AspNetCore.OData is ~9 MB of EDM model, MVC formatters, routers, and serializers — overkill when all you want is "accept $filter=... against my IQueryable<T>." OdataQueryLite gives you that without the rest:

  • Pure System.Linq.Expressions, AOT-compatible (<IsAotCompatible>true</IsAotCompatible>).
  • Provider-agnostic — the engine never enumerates. Hand the result to EF Core, an in-memory list, or any custom IQueryable<T> provider.
  • Async-friendly without an EF-Core sub-package: the engine returns IQueryable, you await x.LongCountAsync() (or sync LongCount()) on your side.

Two packages

Package Targets Use when
OdataQueryLite pure net10.0, no ASP.NET Core dependency parsing query strings outside a web host (CLI tools, batch jobs, tests) — or you bring your own host glue
OdataQueryLite.AspNetCore net10.0, FrameworkReference Microsoft.AspNetCore.App MVC model binder + Minimal-API parameter wrapper + error-mapping middleware in one line of startup

Quick start (ASP.NET Core)

// Program.cs — Minimal API + MVC, same setup.
var builder = WebApplication.CreateBuilder(args);
builder.Services
    .AddControllers()
    .AddOdataQueryLite();    // MVC binder (skip this for pure Minimal-API hosts)
builder.Services.AddOdataQueryLite();  // cache + infrastructure

var app = builder.Build();
app.UseOdataQueryLite();     // maps OdataQueryException -> HTTP 400
app.MapControllers();

// Minimal API: take OdataQueryRequest<T> as a parameter.
app.MapGet("/items", async (OdataQueryRequest<Item> q, AppDbContext db) =>
{
    var result = q.Options.Apply(db.Items);
    return Results.Ok(new
    {
        total = q.Options.Count ? await result.Unpaged.LongCountAsync() : (long?)null,
        // result.Data is IQueryable — element type is Item when no $select/$expand is
        // requested, otherwise Dictionary<string, object?>. Cast<object>() lets the JSON
        // serializer pick up the runtime element type either way.
        data  = await result.Data.Cast<object>().ToListAsync(),
    });
});

app.Run();

MVC controller version:

[ApiController]
[Route("api/[controller]")]
public class ItemsController(AppDbContext db) : ControllerBase
{
    [HttpGet]
    public async Task<IActionResult> Get(OdataQueryOptions<Item> q)
    {
        var result = q.Apply(db.Items);
        return Ok(new
        {
            total = q.Count ? await result.Unpaged.LongCountAsync() : (long?)null,
            data  = await result.Data.Cast<object>().ToListAsync(),
        });
    }
}

Malformed $filter, $apply, negative $top, duplicate $-options, etc. throw OdataQueryException from the binder/BindAsync; the middleware turns them into 400 with { Error, Message, Option } JSON. Controllers see q.Apply(...) succeed or never run.

Standalone parsing (no web host)

using OdataQueryLite;

var opts = new OdataQueryOptions<Item>(new OdataQueryParts
{
    Filter  = "Price gt 25 and Name eq 'Apple'",
    OrderBy = "Price desc",
    Top     = 10,
    Count   = true,
});

var result = opts.Apply(items.AsQueryable());
long total = result.Unpaged.LongCount();
// No $select/$expand here, so Data's runtime element type is Item.
List<Item> page = result.Data.Cast<Item>().ToList();

Surface

OData query option Status
$filtereq / ne / gt / ge / lt / le / and / or / not ✅ parsed + applied
$filtercontains / startswith / endswith
$filter — string / date / math functions (tolower, year, round, …)
$filter — lambdas Items/any(o: o/Status eq 'X'), Items/all(...)
$filter — collection count Items/$count gt 0
$orderby — multi-key, desc, nested paths, collection /$count
$expand — nested, slash chains, inner $select/$expand ✅ parsed + applied (Dictionary<string, object?> projection)
$select — flat names, nested paths ✅ parsed + applied (honors [JsonIgnore] from Newtonsoft / STJ + [OdataIgnore])
$top / $skip / $count ✅ applied ($count exposes Unpaged for caller to materialize)
$apply ❌ — UnsupportedQueryOptionException at construction

Configuration

services.AddOdataQueryLite(opts =>
{
    opts.UseCache         = true;     // default: process-wide compiled-query cache
    opts.MaxCacheEntries  = 10_000;   // default; bump for high-cardinality filter surfaces
});

Cache is keyed on (entityType, normalized-shape, parameter-types)?$filter=Id eq 1 and ?$filter=Id eq 2 share a compiled Func<T, bool>; the literal 1 / 2 flows through a ValueTuple<> rather than re-baked into the expression tree.

Roadmap

  • Lexer + filter / orderby / expand / select parsers + AST
  • Parameterized literals + shape-based caching
  • PropertyAccessVisitor + AllowedExpandNode (whitelist + subsumption)
  • FilterExpressionBuilder (AST → Expression<Func<T, bool>>)
  • TypeCoercion (enum / nullable / DateTimeOffset / Edm.Binary)
  • Compiled-delegate cache (QueryCompileCache, LRU-style soft cap)
  • OdataQueryOptions<T> orchestrator + OrderByApplier
  • ASP.NET Core integration package (MVC IModelBinder, Minimal-API OdataQueryRequest<T>, error-mapping middleware, idempotent AddOdataQueryLite() split per ASP.NET Core convention)
  • IQueryable return-shape redesign (QueryResult.Unpaged deferred enumeration → no EF-Core sub-package needed)
  • $select / $expand projection applied to the returned IQueryable (SelectExpandProjector — emits Dictionary<string, object?> projection, EF Core translates to flat SELECT col1, col2; honors [JsonIgnore] from both Newtonsoft.Json and System.Text.Json.Serialization plus its own [OdataIgnore])

Trim / AOT

The library is annotated for the trimmer (<IsAotCompatible>true</IsAotCompatible>). Public entry points (OdataQueryOptions<T> ctor, AddOdataQueryLite()) carry [RequiresUnreferencedCode] + [RequiresDynamicCode] because filter expressions are compiled at runtime against T's reflected properties. Hosts targeting Native AOT should mark their [DynamicallyAccessedMembers(PublicProperties)] on the entity types they expose.

Origin

Extracted from an internal HCS Platform module that was replacing Microsoft.AspNetCore.OData for performance + dependency-surface reasons. Open-sourced to broaden the review surface and let the wider .NET ecosystem use the parser independently of the original host.

License

MIT — see LICENSE.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on OdataQueryLite:

Package Downloads
OdataQueryLite.AspNetCore

ASP.NET Core MVC model binder + middleware glue for OdataQueryLite. One-line setup via services.AddOdataQueryLite() / app.UseOdataQueryLite().

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0 92 5/27/2026