SunAuto.OData.QueryBuilder 1.0.1

dotnet add package SunAuto.OData.QueryBuilder --version 1.0.1
                    
NuGet\Install-Package SunAuto.OData.QueryBuilder -Version 1.0.1
                    
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="SunAuto.OData.QueryBuilder" Version="1.0.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SunAuto.OData.QueryBuilder" Version="1.0.1" />
                    
Directory.Packages.props
<PackageReference Include="SunAuto.OData.QueryBuilder" />
                    
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 SunAuto.OData.QueryBuilder --version 1.0.1
                    
#r "nuget: SunAuto.OData.QueryBuilder, 1.0.1"
                    
#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 SunAuto.OData.QueryBuilder@1.0.1
                    
#: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=SunAuto.OData.QueryBuilder&version=1.0.1
                    
Install as a Cake Addin
#tool nuget:?package=SunAuto.OData.QueryBuilder&version=1.0.1
                    
Install as a Cake Tool

ODataQueryBuilder

Build OData v4 / 4.01 query strings in .NET with a fluent interface.

Targets net8.0, net9.0 and net10.0.

dotnet add package ODataQueryBuilder
using SunAuto.OData;
using static SunAuto.OData.Functions;

var query = new QueryBuilder("https://example.com", "Products")
    .Filter("Price".GreaterThan(10).And(Contains(ToLower("Name"), "milk")))
    .Select("Name", "Price")
    .Expand("Category".Select("Name"))
    .OrderByDescending("Price")
    .Top(10)
    .Count()
    .Build();
https://example.com/Products?$filter=Price gt 10 and contains(tolower(Name),'milk')&$select=Name,Price&$expand=Category($select=Name)&$orderby=Price desc&$top=10&$count=true

This is a string builder, not a LINQ provider. It never sees your CLR types — you name properties as strings, and it handles the quoting, precedence, ordering and encoding.

Literals and expressions

The one rule worth learning. A string argument is an OData string literal and is quoted. An Expression — which every operator and function returns — is emitted verbatim.

"Name".Equal("Milk")                    // Name eq 'Milk'
"Total".Equal("Price".Multiply("Qty"))  // Total eq Price mul Qty

That means operators compose without any escape hatch: nothing can accidentally quote another operator's output. Other .NET values become the literal form OData's URL syntax expects, which is often not what ToString() gives you:

"CreatedOn".GreaterThan(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero))
                                        // CreatedOn gt 2024-01-01T00:00:00Z   (unquoted)
"Id".Equal(someGuid)                    // Id eq 01234567-89ab-cdef-0123-456789abcdef
"DeletedOn".Equal(null)                 // DeletedOn eq null   (not '')

Covered: string, all numeric types, bool, Guid, DateTimeOffset, DateTime, DateOnly, TimeOnly, TimeSpan (as duration'…'), byte[] (as binary'…'), their nullable forms, NaN/INF/-INF, and enum members via Expression.EnumMember("Sales.Color", "Yellow").

Build a literal explicitly with Expression.Literal(text), or wrap hand-written syntax with new Expression("…").

Operators

Group Methods
Comparison Equal NotEqual GreaterThan GreaterThanOrEqual LessThan LessThanOrEqual Has In
Logical And Or Not
Arithmetic Add Subtract Multiply Divide DivideBy Modulo
Other Group Path As

Arithmetic works over properties, numbers, or both — "Price".Multiply("Qty"), "Price".Multiply(2), 100.Subtract("Discount").

Parentheses appear only where they change meaning:

"a".Or("b").And("c")   // (a or b) and c   — regrouped, so parenthesised
"a".And("b").Or("c")   // a and b or c     — already correct, left alone
"a".And("b").Group()   // (a and b)        — Group() forces them

One limit: a raw string is treated as atomic, because the library does not parse it. new Expression("a or b").And("c") gives a or b and c. Build with operators, or Group() it yourself.

Functions

The OData canonical functions are static, not extension methods, because most of their names (Contains, StartsWith, ToLower, Substring, IndexOf, Trim) are already instance methods on string and would be silently shadowed there — "Name".Contains("Milk") would quietly evaluate to a bool.

using static SunAuto.OData.Functions;

Contains(ToLower("Name"), "milk")   // contains(tolower(Name),'milk')
Year("CreatedOn").Equal(2024)       // year(CreatedOn) eq 2024

Available: Concat Contains EndsWith IndexOf Length MatchesPattern StartsWith Substring ToLower ToUpper Trim HasSubset HasSubsequence · Year Month Day Hour Minute Second FractionalSeconds Date Time TotalOffsetMinutes TotalSeconds Now MaxDateTime MinDateTime · Round Floor Ceiling · Cast IsOf · GeoDistance GeoLength GeoIntersects

Lambda operators

Any and All bind a range variable, so its name is written once rather than embedded in strings:

Any("Items", "i", i => i.Path("Price").GreaterThan(100))   // Items/any(i: i/Price gt 100)
All("Items", "i", i => i.Path("Qty").GreaterThan(0))       // Items/all(i: i/Qty gt 0)
Any("Items")                                               // Items/any()

They nest — the bound variable is an ordinary Expression, so Any(o.Path("Items"), "i", …) works inside another lambda.

Query options

Filter Select Expand OrderBy OrderByDescending Compute Search Top Skip Count Apply Format Index SchemaVersion Id

Most have an …If(condition, …) form taking a positive condition. Count is the exception, since its bool argument already carries one:

.Select("Name").SelectIf(includeDetail, "Description")

Options render in a fixed order, not call order, so the same query always produces the same string — worth having for cache keys, logs and assertions:

$id · $apply · $compute · $filter · $search · $select · $expand · $orderby · $top · $skip · $count · $index · $schemaversion · $format

Each option appears at most once, as OData requires. $select, $expand, $orderby and $compute accumulate; the scalars replace; and repeated Filter calls are joined with and, parenthesised where precedence needs it:

.Filter("Price".GreaterThan(10))
.Filter("InStock".Equal(true))       // ?$filter=Price gt 10 and InStock eq true

Nested options

Scope options to an expanded property. They chain, and render in call order separated by ;:

.Expand("Items".Select("Name").Filter("Price".GreaterThan(100)).Top(5).Count(true))
// ?$expand=Items($select=Name;$filter=Price gt 100;$top=5;$count=true)

Available on both string and OptionValue: Select Expand Filter OrderBy OrderByDescending Compute Top Skip Count Search Levels LevelsMax.

Nested Count requires its argument — "Items".Count() would bind to Enumerable.Count and count the string's characters, so the no-argument call is deliberately unavailable.

$compute

Define an alias, then use it from $filter, $orderby and $select:

.Compute("Price".Multiply("Qty").As("Total"))
.Filter("Total".GreaterThan(100))
.OrderByDescending("Total")
// ?$compute=Price mul Qty as Total&$filter=Total gt 100&$orderby=Total desc

Routes and keys

new QueryBuilder("https://example.com", "Products").Key(1).Segment("Category")
// https://example.com/Products(1)/Category

.Key("Milk")                  // Products('Milk')      string keys are quoted
.Key("Name", "Milk")          // Products(Name='Milk')
.Key(("CategoryId", 1), ("Name", Expression.Literal("Milk")))

Use Segment for the path-only resources: Segment("$count"), Segment("$value"), Segment("$ref").

$apply

The Data Aggregation extension — optional, and not every service implements it.

using static SunAuto.OData.Transformations;

.Apply(GroupBy(["Category"], Aggregate(Sum("Amount", "Total"), Count("Orders"))))
// ?$apply=groupby((Category),aggregate(Amount with sum as Total,$count as Orders))

Aggregate methods Sum Min Max Average CountDistinct Count; transformations Aggregate GroupBy Filter Compute Concat Sequence Identity TopCount BottomCount TopPercent BottomPercent TopSum BottomSum. Repeated Apply calls chain with /.

nest, outernest, addnested and the expand transformation are not wrapped; pass them as new Expression("…").

Parameter aliases

.Filter("Name".Equal(Expression.Parameter("p1")))
.Parameter("p1", "Milk")
// ?$filter=Name eq @p1&@p1='Milk'

Aliases render after the system options. Redeclaring one replaces it.

URLs

String literals percent-encode the characters that would otherwise corrupt the query string, so a value containing & cannot truncate the option it sits in:

"Name".Equal("Milk & Honey")   // Name eq 'Milk %26 Honey'

Build() keeps spaces readable and is equivalent to ToString(). ToUri() returns a properly escaped Uri.

builder.Build()            // https://example.com/Products?$filter=Name eq 'Milk'
builder.ToUri().AbsoluteUri // https://example.com/Products?$filter=Name%20eq%20'Milk'

Raw text passed as new Expression("…") is trusted verbatim — it is neither encoded nor parsed for precedence.

A note on the Expression name

SunAuto.OData.Expression collides with System.Linq.Expressions.Expression. A file importing both namespaces will not compile:

error CS0104: 'Expression' is an ambiguous reference

Alias one of them until this is resolved:

using Expression = SunAuto.OData.Expression;

Not covered

Batch requests, $skiptoken / $deltatoken (server-generated), and typed property selectors.

How this was built

The implementation was written by Claude Opus 5, running in Claude Code, and developed test-first. A hand-written xUnit suite defined the intended API, and the library was built to satisfy it; every feature since — the Edm literal types, the canonical functions, the lambda operators, $apply, key addressing — arrived the same way, with its tests written alongside it and the design revised whenever a test exposed an awkward or unsafe shape.

The suite currently stands at 195 tests, and the examples throughout this README are copied from verified output rather than written by hand.

License

MIT

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  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.
  • net8.0

    • No dependencies.
  • net9.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.1 106 8/25/2026