Skip to content

goatquery/goatquery-dotnet

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

57 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

GoatQuery

A .NET library for parsing query parameters into LINQ expressions. Enables database-level filtering, sorting, and pagination from HTTP query strings.

Note

This project only supports Entity Framework Linq currently.

Installation

dotnet add package GoatQuery

# Or for ASP.NET Core integration please install this instead.
dotnet add package GoatQuery.AspNetCore

Quick Start

// Basic filtering
var users = dbContext.Users
    .Apply(new Query { Filter = "age gt 18 and isActive eq true" })
    .Value.Query;

// Lambda expressions for collection filtering
var usersWithLondonAddress = dbContext.Users
    .Apply(new Query { Filter = "addresses/any(x: x/city eq 'London')" })
    .Value.Query;

// Filter by primitive arrays (tags, categories, etc.)
var vipUsers = dbContext.Users
    .Apply(new Query { Filter = "tags/any(x: x eq 'vip')" })
    .Value.Query;

// Complex nested filtering
var activeUsersWithHighValueOrders = dbContext.Users
    .Apply(new Query {
        Filter = "isActive eq true and orders/any(o: o/items/any(i: i/price gt 1000))"
    })
    .Value.Query;

// ASP.NET Core integration
[HttpGet]
[EnableQuery<UserDto>(maxTop: 100)]
public IActionResult GetUsers() => Ok(dbContext.Users);

Supported Syntax

GET /api/users?filter=age gt 18 and isActive eq true
GET /api/users?filter=addresses/any(x: x/city eq 'London')
GET /api/users?orderby=lastName asc, firstName desc
GET /api/users?orderby=company/name asc
GET /api/users?filter=tags/any(x: x eq 'premium')
GET /api/users?top=10&skip=20&count=true
GET /api/users?search=john

Filtering

Operators

  • Comparison: eq, ne, gt, gte, lt, lte
  • Logical: and, or
  • String: contains

Data Types

Type Example
String 'value', 'it\'s escaped', 'back\\slash'
Integer 42, 99999999999, -7
Double 3.14, 1.0, -2.5
Boolean true, false
DateTime 2023-12-25T10:30:00Z
DateTimeOffset 2023-12-25T10:30:00+05:00
Date 2023-12-25 (expanded to full-day range)
GUID 123e4567-e89b-12d3-a456-426614174000
Enum 'Active' (string) or 1 (integer)
Null null

String literals support backslash escaping: \' for a literal single quote, \\ for a literal backslash.

Property Path Navigation

Access nested properties using forward slash (/) syntax:

filter=company/name eq 'TechCorp'
filter=profile/address/city eq 'London'
filter=user/addresses/any(x: x/country/name eq 'UK')

Lambda Expressions

Filter collections using any() and all():

// any() - true if at least one element matches
addresses/any(x: x/city eq 'London')

// all() - true if all elements match (requires non-empty collection)
addresses/all(x: x/isVerified eq true)

// Nested lambda expressions
orders/any(o: o/items/any(i: i/price gt 100))

// Primitive array filtering
tags/any(x: x eq 'premium')
scores/any(x: x gt 80)

// Root property access inside lambda body
orders/any(o: o/total gt 100 and status eq 'Active')

Date-Only Range Expansion

Date-only literals (e.g., 2023-12-25) are automatically expanded to full-day range comparisons against DateTime/DateTimeOffset columns:

Operator Expansion
eq >= 2023-12-25T00:00:00 AND < 2023-12-26T00:00:00
ne < 2023-12-25T00:00:00 OR >= 2023-12-26T00:00:00
lt < 2023-12-25T00:00:00
lte < 2023-12-26T00:00:00
gt >= 2023-12-26T00:00:00
gte >= 2023-12-25T00:00:00

Null Safety

String comparisons (eq, ne, contains) are null-safe. When a string property is null in the database:

  • eq and contains exclude null rows (null does not equal any value)
  • ne includes null rows (null is "not equal" to any value)

Examples

age gt 18
firstName eq 'John' and isActive ne false
name contains 'smith'
status eq 'Active'
createdAt gte 2023-01-01T00:00:00Z
createdAt eq 2023-06-15
balance gt -100
addresses/any(x: x/city eq 'London' and x/isActive eq true)
age gt 25 and tags/any(x: x eq 'premium')
orders/any(o: o/total gt 50 and status eq 'Active')

Ordering

Sort by one or more properties, including nested properties:

GET /api/users?orderby=lastName asc
GET /api/users?orderby=lastName asc, firstName desc
GET /api/users?orderby=company/name asc
GET /api/users?orderby=company/name asc, age desc

Default direction is ascending when omitted.

Property Mapping

Properties in query strings are resolved in this order:

  1. [JsonPropertyName] attribute
  2. QueryOptions.PropertyNamingPolicy (if configured)
  3. CLR property name

Property names are matched case-insensitively.

Using JsonPropertyName

public class UserDto
{
    [JsonPropertyName("first_name")]
    public string FirstName { get; set; }

    public int Age { get; set; }
}
filter=first_name eq 'John' and age gt 18

Using PropertyNamingPolicy

Apply a global naming policy instead of decorating every property:

var options = new QueryOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};

var result = dbContext.Users.Apply(query, options: options);
filter=first_name eq 'John' and date_of_birth gt 1990-01-01

Configuration

Configure query behaviour through QueryOptions:

var options = new QueryOptions
{
    MaxTop = 100,                   // Maximum allowed top value
    MaxPropertyMappingDepth = 5,    // Max depth for nested property resolution (default: 5)
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase  // Global property naming policy
};

var result = dbContext.Users.Apply(query, options: options);

Search

Implement custom search logic:

public class UserSearchBinder : ISearchBinder<User>
{
    public Expression<Func<User, bool>> Bind(string searchTerm) =>
        user => user.FirstName.Contains(searchTerm) ||
                user.LastName.Contains(searchTerm);
}

var result = users.Apply(query, new UserSearchBinder());

ASP.NET Core Integration

Action Filter

[HttpGet]
[EnableQuery<UserDto>(maxTop: 100)]
public IActionResult GetUsers() => Ok(dbContext.Users);

// With custom depth
[EnableQuery<UserDto>(maxTop: 100, maxPropertyMappingDepth: 3)]

EnableQuery automatically resolves JsonNamingPolicy from your configured JsonOptions in DI. If you've configured a naming policy globally:

builder.Services.Configure<JsonOptions>(options =>
{
    options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});

The action filter will use that policy for property resolution without additional configuration.

OpenAPI Integration

On .NET 9+, register the operation transformer to automatically add GoatQuery query parameters to your OpenAPI document for any endpoint decorated with [EnableQuery<T>]:

builder.Services.AddOpenApi(options =>
{
    options.AddOperationTransformer<EnableQueryOperationTransformer>();
});

This adds filter, orderby, top, skip, count, and search parameters to the generated OpenAPI spec.

Manual Processing

[HttpGet]
public IActionResult GetUsers([FromQuery] Query query)
{
    var result = dbContext.Users.Apply(query);
    return result.IsFailed ? BadRequest(result.Errors) : Ok(result.Value.Query.ToList());
}

Error Handling

Uses FluentResults pattern:

var result = users.Apply(query);
if (result.IsFailed)
    return BadRequest(result.Errors.Select(e => e.Message));

var data = result.Value.Query.ToList();
var count = result.Value.Count;  // If Count = true

Development

Test

dotnet test ./src/GoatQuery/tests

Run the example project

cd example && dotnet run

Targets: GoatQuery targets .NET Standard 2.0/2.1. GoatQuery.AspNetCore targets .NET 8.0/9.0.

About

Goat Query SDK for .NET!

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors