diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 22415fb..2b76613 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,11 +6,16 @@ updates: interval: "weekly" - package-ecosystem: "nuget" - directory: "/src" + directory: "/src/GoatQuery/src" schedule: interval: "weekly" - package-ecosystem: "nuget" - directory: "/tests" + directory: "/src/GoatQuery/tests" + schedule: + interval: "weekly" + + - package-ecosystem: "nuget" + directory: "/src/GoatQuery.AspNetCore/src" schedule: interval: "weekly" diff --git a/.github/workflows/build-goatquery-aspnetcore.yml b/.github/workflows/build-goatquery-aspnetcore.yml new file mode 100644 index 0000000..e331aa2 --- /dev/null +++ b/.github/workflows/build-goatquery-aspnetcore.yml @@ -0,0 +1,26 @@ +name: build GoatQuery.AspNetCore + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + dotnet-version: ["8.0.x"] + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET Core SDK ${{ matrix.dotnet-version }} + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ matrix.dotnet-version }} + + - name: Install dependencies + run: dotnet restore + working-directory: ./src/GoatQuery.AspNetCore/src + + - name: Build + run: dotnet build --configuration Release --no-restore + working-directory: ./src/GoatQuery.AspNetCore/src diff --git a/.github/workflows/build.yml b/.github/workflows/build-goatquery.yml similarity index 74% rename from .github/workflows/build.yml rename to .github/workflows/build-goatquery.yml index 1d37ccc..d24fc42 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build-goatquery.yml @@ -1,4 +1,4 @@ -name: build +name: build GoatQuery on: [push] @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - dotnet-version: ["8.0.x",] + dotnet-version: ["8.0.x"] steps: - uses: actions/checkout@v4 @@ -19,8 +19,8 @@ jobs: - name: Install dependencies run: dotnet restore - working-directory: ./src - + working-directory: ./src/GoatQuery/src + - name: Build run: dotnet build --configuration Release --no-restore - working-directory: ./src + working-directory: ./src/GoatQuery/src diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 82a3c0f..cb4fd42 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,10 +19,18 @@ jobs: with: dotnet-version: ${{ matrix.dotnet-version }} - - name: Create the package + - name: Create GoatQuery package run: dotnet pack --configuration Release -p:Version=${{ github.event.release.tag_name }} *.csproj - working-directory: ./src + working-directory: ./src/GoatQuery/src - - name: Publish the package to Nuget + - name: Create GoatQuery.AspNetCore package + run: dotnet pack --configuration Release -p:Version=${{ github.event.release.tag_name }} *.csproj + working-directory: ./src/GoatQuery.AspNetCore/src + + - name: Publish the GoatQuery package to Nuget + run: dotnet nuget push bin/Release/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json + working-directory: ./src/GoatQuery/src + + - name: Publish the GoatQuery.AspNetCore package to Nuget run: dotnet nuget push bin/Release/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json - working-directory: ./src + working-directory: ./src/GoatQuery.AspNetCore/src diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9eee826..c7fd9f4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - dotnet-version: ["8.0.x"] + dotnet-version: ["9.0.x"] steps: - uses: actions/checkout@v4 @@ -19,11 +19,7 @@ jobs: - name: Install dependencies run: dotnet restore - working-directory: ./tests - - - name: Build - run: dotnet build --configuration Release --no-restore - working-directory: ./tests + working-directory: ./src/GoatQuery/tests - name: Test - run: make test + run: dotnet test ./src/GoatQuery/tests diff --git a/Makefile b/Makefile index f3367fa..b977bc2 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,16 @@ -.PHONY: test test: - dotnet test ./tests \ No newline at end of file + dotnet test ./src/GoatQuery/tests + +build: + dotnet build ./src/GoatQuery/src --configuration Release + dotnet build ./src/GoatQuery.AspNetCore/src --configuration Release + +package: + dotnet pack ./src/GoatQuery/src --configuration Release -p:Version=0.0.1-local + dotnet pack ./src/GoatQuery.AspNetCore/src --configuration Release -p:Version=0.0.1-local + +publish-local: + mkdir -p ~/.nuget/local-packages + dotnet nuget push ./src/GoatQuery/src/bin/Release/GoatQuery.0.0.1-local.nupkg -s ~/.nuget/local-packages + dotnet nuget push ./src/GoatQuery.AspNetCore/src/bin/Release/GoatQuery.AspNetCore.0.0.1-local.nupkg -s ~/.nuget/local-packages + diff --git a/README.md b/README.md index 9c26a9c..ddb5d86 100644 --- a/README.md +++ b/README.md @@ -1 +1,300 @@ -# GoatQuery .NET +# 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 + +```bash +dotnet add package GoatQuery + +# Or for ASP.NET Core integration please install this instead. +dotnet add package GoatQuery.AspNetCore +``` + +## Quick Start + +```csharp +// 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.Results; + +// 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(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?filter=tags/any(x: x eq 'premium') +GET /api/users?top=10&skip=20&count=true +GET /api/users?search=john +``` + +## Filtering + +### Basic Operators + +- **Comparison**: `eq`, `ne`, `gt`, `gte`, `lt`, `lte` +- **Logical**: `and`, `or` +- **String**: `contains` + +### Lambda Expressions + +Filter collections using `any()` and `all()` with lambda expressions: + +```csharp +// Users with any address in London +"addresses/any(x: x/city eq 'London')" + +// Users where all addresses are verified +"addresses/all(x: x/isVerified eq true)" + +// Complex nested conditions +"addresses/any(x: x/city eq 'London' and x/isActive eq true)" + +// Nested collection filtering +"orders/any(o: o/items/any(i: i/price gt 100))" +``` + +### Property Path Navigation + +Access nested properties using forward slash (`/`) syntax: + +```csharp +// Navigate to nested properties +"profile/address/city eq 'London'" + +// Works with collections and lambda expressions +"user/addresses/any(x: x/country/name eq 'UK')" +``` + +### Data Types + +- String: `'value'` +- Numbers: `42`, `3.14f`, `2.5m`, `1.0d` +- Boolean: `true`, `false` +- DateTime: `2023-12-25T10:30:00Z`, `2023-12-25` +- GUID: `123e4567-e89b-12d3-a456-426614174000` +- Null: `null` + +### Examples + +```csharp +// Basic filtering +"age gt 18" +"firstName eq 'John' and isActive ne false" +"salary ge 50000 or department eq 'Engineering'" +"name contains 'smith'" + +// Lambda expressions +"addresses/any(x: x/city eq 'London')" +"orders/all(o: o/status eq 'completed')" +"tags/any(x: x eq 'premium')" +"categories/all(x: x contains 'tech')" + +// Nested properties +"profile/address/city eq 'London'" +"company/department/name contains 'Engineering'" + +// Complex combinations +"age gt 25 and addresses/any(x: x/country eq 'US' and x/isActive eq true)" +"isActive eq true and tags/any(x: x eq 'premium') and scores/all(x: x gt 70)" +``` + +## Property Mapping + +Supports `JsonPropertyName` attributes for both simple and nested properties: + +```csharp +public class UserDto +{ + [JsonPropertyName("first_name")] + public string FirstName { get; set; } + + public int Age { get; set; } // Maps to "age" + + public List Addresses { get; set; } // Collection properties + + public ProfileDto Profile { get; set; } // Nested objects +} + +public class AddressDto +{ + [JsonPropertyName("street_address")] + public string StreetAddress { get; set; } + + public string City { get; set; } + + [JsonPropertyName("is_verified")] + public bool IsVerified { get; set; } +} +``` + +**Query Examples:** + +``` +filter=first_name eq 'John' and age gt 18 +filter=addresses/any(x: x/street_address contains 'Main St') +filter=profile/address/city eq 'London' +``` + +## Advanced Features + +### Lambda Expression Support + +GoatQuery supports sophisticated collection filtering using lambda expressions: + +#### Any/All Operations + +```csharp +// 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 + +```csharp +// Multi-level collection filtering +"orders/any(o: o/items/any(i: i/price gt 100 and i/category eq 'Electronics'))" + +// Complex nested conditions +"departments/any(d: d/employees/all(e: e/isActive eq true and e/salary gt 50000))" +``` + +#### Lambda with Property Navigation + +```csharp +// Navigate through nested objects within lambdas +"addresses/any(x: x/country/code eq 'US' and x/state/name eq 'California')" +``` + +#### Primitive Array Filtering + +Filter arrays of primitive types (strings, numbers, etc.) directly: + +```csharp +// Filter by tags (string array) +"tags/any(x: x eq 'vip')" +"tags/any(x: x contains 'premium')" +"tags/all(x: x eq 'active')" + +// Filter by numeric arrays +"scores/any(x: x gt 80)" +"ratings/all(x: x ge 4.5)" + +// Combined with other filters +"age gt 18 and tags/any(x: x eq 'premium')" +``` + +**Supported primitive types:** + +- `string[]`: `tags/any(x: x eq 'premium')` +- `int[]`: `scores/any(x: x gt 90)` +- `decimal[]`: `prices/all(x: x lt 100m)` +- `DateTime[]`: `dates/any(x: x gt 2023-01-01)` +- `bool[]`: `flags/all(x: x eq true)` + +This enables powerful filtering scenarios like user categorization, product tagging, content classification, and multi-criteria matching directly from query parameters. + +### Null Safety + +GoatQuery automatically generates null-safe expressions for property navigation: + +```csharp +// Input: "profile/address/city eq 'London'" +// Generated: user.Profile != null && user.Profile.Address != null && user.Profile.Address.City == "London" +``` + +## Search + +Implement custom search logic: + +```csharp +public class UserSearchBinder : ISearchBinder +{ + public Expression> 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 + +```csharp +[HttpGet] +[EnableQuery(maxTop: 100)] +public IActionResult GetUsers() => Ok(dbContext.Users); +``` + +### Manual Processing + +```csharp +[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: + +```csharp +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 + +```bash +dotnet test ./src/GoatQuery/tests +``` + +### Run the example project + +```bash +cd example && dotnet run +``` + +**Targets**: .NET Standard 2.0/2.1, .NET 6.0+ diff --git a/examples/controller/ApplicationDbContext.cs b/example/ApplicationDbContext.cs similarity index 100% rename from examples/controller/ApplicationDbContext.cs rename to example/ApplicationDbContext.cs diff --git a/example/Controllers/UserController.cs b/example/Controllers/UserController.cs new file mode 100644 index 0000000..0c478b7 --- /dev/null +++ b/example/Controllers/UserController.cs @@ -0,0 +1,36 @@ +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +[ApiController] +[Route("controller/[controller]")] +public class UsersController : ControllerBase +{ + private readonly ApplicationDbContext _db; + private readonly IMapper _mapper; + + public UsersController(ApplicationDbContext db, IMapper mapper) + { + _db = db; + _mapper = mapper; + } + + // GET: /controller/users + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [EnableQuery(maxTop: 10)] + public ActionResult> Get() + { + var users = _db.Users + .Include(x => x.Company) + .Include(x => x.Addresses) + .ThenInclude(x => x.City) + .Include(x => x.Manager) + .ThenInclude(x => x.Manager) + .Where(x => !x.IsDeleted) + .ProjectTo(_mapper.ConfigurationProvider); + + return Ok(users); + } +} \ No newline at end of file diff --git a/example/Dto/UserDto.cs b/example/Dto/UserDto.cs new file mode 100644 index 0000000..51bdd96 --- /dev/null +++ b/example/Dto/UserDto.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +public record UserDto +{ + public Guid Id { get; set; } + + [JsonPropertyName("first_name")] + public string Firstname { get; set; } = string.Empty; + public string Lastname { get; set; } = string.Empty; + public int Age { get; set; } + public Gender Gender { get; set; } + public bool IsEmailVerified { get; set; } + public double Test { get; set; } + public int? NullableInt { get; set; } + public DateTime DateOfBirthUtc { get; set; } + public DateTime DateOfBirthTz { get; set; } + public User? Manager { get; set; } + public IEnumerable Addresses { get; set; } = Array.Empty(); + public IEnumerable Tags { get; set; } = Array.Empty(); + public CompanyDto? Company { get; set; } +} + +public record AddressDto +{ + public string AddressLine1 { get; set; } = string.Empty; + public CityDto City { get; set; } = new CityDto(); +} + +public record CityDto +{ + public string Name { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; +} + +public record CompanyDto +{ + public string Name { get; set; } = string.Empty; + public string Department { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/example/Entities/User.cs b/example/Entities/User.cs new file mode 100644 index 0000000..00a294d --- /dev/null +++ b/example/Entities/User.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; + +public record User +{ + public Guid Id { get; set; } + public string Firstname { get; set; } = string.Empty; + public string Lastname { get; set; } = string.Empty; + public int Age { get; set; } + public Gender Gender { get; set; } + public bool IsDeleted { get; set; } + public bool IsEmailVerified { get; set; } + public double Test { get; set; } + public int? NullableInt { get; set; } + + [Column(TypeName = "timestamp with time zone")] + public DateTime DateOfBirthUtc { get; set; } + + [Column(TypeName = "timestamp without time zone")] + public DateTime DateOfBirthTz { get; set; } + public User? Manager { get; set; } + public ICollection
Addresses { get; set; } = new List
(); + public ICollection Tags { get; set; } = new List(); + public Company? Company { get; set; } +} + +public record Address +{ + public Guid Id { get; set; } + public City City { get; set; } = new City(); + public string AddressLine1 { get; set; } = string.Empty; +} + +public record City +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; +} + +public record Company +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Department { get; set; } = string.Empty; +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Gender +{ + Male, + Female, + [JsonStringEnumMemberName("Alternative")] + Other +} \ No newline at end of file diff --git a/examples/controller/Profiles/AutoMapperProfile.cs b/example/Profiles/AutoMapperProfile.cs similarity index 54% rename from examples/controller/Profiles/AutoMapperProfile.cs rename to example/Profiles/AutoMapperProfile.cs index cd2efb2..996d450 100644 --- a/examples/controller/Profiles/AutoMapperProfile.cs +++ b/example/Profiles/AutoMapperProfile.cs @@ -5,5 +5,8 @@ public class AutoMapperProfile : Profile public AutoMapperProfile() { CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); } } \ No newline at end of file diff --git a/example/Program.cs b/example/Program.cs new file mode 100644 index 0000000..c172d79 --- /dev/null +++ b/example/Program.cs @@ -0,0 +1,129 @@ +using System.Reflection; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Bogus; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Testcontainers.PostgreSql; + +Randomizer.Seed = new Random(8675309); + +var builder = WebApplication.CreateBuilder(args); + +var postgreSqlContainer = new PostgreSqlBuilder() + .WithImage("postgres:15") + .Build(); + +await postgreSqlContainer.StartAsync(); + +builder.Services.AddControllers(); + +builder.Services.AddDbContext(options => +{ + options.UseNpgsql(postgreSqlContainer.GetConnectionString()); +}); + +builder.Services.AddAutoMapper(Assembly.GetExecutingAssembly()); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.EnsureCreatedAsync(); + + // Seed data + if (!context.Users.Any()) + { + var companies = new Faker() + .RuleFor(x => x.Name, f => f.Company.CompanyName()) + .RuleFor(x => x.Department, f => f.Commerce.Department()); + + var cities = new Faker() + .RuleFor(x => x.Name, f => f.Address.City()) + .RuleFor(x => x.Country, f => f.Address.Country()); + + var addresses = new Faker
() + .RuleFor(x => x.AddressLine1, f => f.Address.StreetAddress()) + .RuleFor(x => x.City, f => f.PickRandom(cities.Generate(50))); + + var users = new Faker() + .RuleFor(x => x.Firstname, f => f.Person.FirstName) + .RuleFor(x => x.Lastname, f => f.Person.LastName) + .RuleFor(x => x.Age, f => f.Random.Int(0, 100)) + .RuleFor(x => x.Gender, f => f.PickRandom()) + .RuleFor(x => x.IsDeleted, f => f.Random.Bool()) + .RuleFor(x => x.Test, f => f.Random.Double()) + .RuleFor(x => x.NullableInt, f => f.Random.Bool() ? f.Random.Int(1, 100) : null) + .RuleFor(x => x.IsEmailVerified, f => f.Random.Bool()) + .Rules((f, u) => + { + var timeZone = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + var date = f.Date.Past().ToUniversalTime(); + + u.DateOfBirthUtc = date; + u.DateOfBirthTz = TimeZoneInfo.ConvertTimeFromUtc(date, timeZone); + }) + .RuleFor(x => x.Manager, (f, u) => f.CreateManager(3)) + .RuleFor(x => x.Addresses, f => f.PickRandom(addresses.Generate(5), f.Random.Int(1, 3)).ToList()) + .RuleFor(x => x.Tags, f => f.Lorem.Words(f.Random.Int(0, 5)).ToList()) + .RuleFor(x => x.Company, f => f.PickRandom(companies.Generate(20))); + + context.Users.AddRange(users.Generate(1_000)); + context.SaveChanges(); + + Console.WriteLine("Seeded 1,000 fake users!"); + } +} + +Console.WriteLine($"Postgres connection string: {postgreSqlContainer.GetConnectionString()}"); + +app.MapGet("/minimal/users", (ApplicationDbContext db, [FromServices] IMapper mapper, [AsParameters] Query query) => +{ + var result = db.Users + .Include(x => x.Company) + .Include(x => x.Addresses) + .ThenInclude(x => x.City) + .Include(x => x.Manager) + .ThenInclude(x => x.Manager) + .Where(x => !x.IsDeleted) + .ProjectTo(mapper.ConfigurationProvider) + .Apply(query); + + if (result.IsFailed) + { + return Results.BadRequest(new { message = result.Errors }); + } + + var response = new PagedResponse(result.Value.Query.ToList(), result.Value.Count); + + return Results.Ok(response); +}); + +app.MapControllers(); + +app.Run(); + +public static class FakerExtensions +{ + public static User? CreateManager(this Faker f, int depth) + { + if (depth <= 0 || !f.Random.Bool(0.6f)) // 60% chance of having manager, stop at depth 0 + return null; + + return new User + { + Id = f.Random.Guid(), + Firstname = f.Person.FirstName, + Lastname = f.Person.LastName, + Age = f.Random.Int(0, 100), + IsDeleted = f.Random.Bool(), + Test = f.Random.Double(), + NullableInt = f.Random.Bool() ? f.Random.Int(1, 100) : null, + IsEmailVerified = f.Random.Bool(), + DateOfBirthUtc = f.Date.Past().ToUniversalTime(), + DateOfBirthTz = TimeZoneInfo.ConvertTimeFromUtc(f.Date.Past().ToUniversalTime(), TimeZoneInfo.FindSystemTimeZoneById("America/New_York")), + Manager = f.CreateManager(depth - 1) // Recursive call with reduced depth + }; + } +} \ No newline at end of file diff --git a/example/example.csproj b/example/example.csproj new file mode 100644 index 0000000..674a5e6 --- /dev/null +++ b/example/example.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/examples/controller/Controllers/HomeController.cs b/examples/controller/Controllers/HomeController.cs deleted file mode 100644 index cbbbf7c..0000000 --- a/examples/controller/Controllers/HomeController.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace API.Controllers.v1; - -public class HomeController : ControllerBase -{ - [HttpGet("")] - public ActionResult Get() - { - return Redirect("/swagger/index.html"); - } -} \ No newline at end of file diff --git a/examples/controller/Controllers/UsersController.cs b/examples/controller/Controllers/UsersController.cs deleted file mode 100644 index 86f60fe..0000000 --- a/examples/controller/Controllers/UsersController.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -[ApiController] -[Route("[controller]")] -public class UsersController : ControllerBase -{ - private readonly IUserService _userService; - - public UsersController(IUserService userService) - { - _userService = userService; - } - - // GET: /users - [HttpGet] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - [EnableQuery] - public ActionResult> Get() - { - var users = _userService.GetAllUsers(); - - return Ok(users); - } - - // GET: /users/alternative - [HttpGet("alternative")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public ActionResult> GetAlternative([FromQuery] Query query) - { - var (users, _) = _userService.GetAllUsers().Apply(query); - - return Ok(users); - } -} diff --git a/examples/controller/Dtos/UserDto.cs b/examples/controller/Dtos/UserDto.cs deleted file mode 100644 index e23fafb..0000000 --- a/examples/controller/Dtos/UserDto.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Text.Json.Serialization; - -public record UserDto -{ - public Guid Id { get; set; } - - public string Firstname { get; set; } = string.Empty; - - public string Lastname { get; set; } = string.Empty; - - public string Email { get; set; } = string.Empty; - - public string AvatarUrl { get; set; } = string.Empty; - - [JsonPropertyName("displayName")] - public string UserName { get; set; } = string.Empty; - - public string Gender { get; set; } = string.Empty; - - public int Age { get; set; } -} \ No newline at end of file diff --git a/examples/controller/Entities/User.cs b/examples/controller/Entities/User.cs deleted file mode 100644 index 4784eb5..0000000 --- a/examples/controller/Entities/User.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; -using Bogus.DataSets; - -public record User -{ - public Guid Id { get; set; } - - [Column(TypeName = "varchar(128)")] - public string Firstname { get; set; } = string.Empty; - - [Column(TypeName = "varchar(128)")] - public string Lastname { get; set; } = string.Empty; - - [Column(TypeName = "varchar(320)")] - public string Email { get; set; } = string.Empty; - - [Column(TypeName = "varchar(1024)")] - public string AvatarUrl { get; set; } = string.Empty; - public bool IsDeleted { get; set; } - - [Column(TypeName = "varchar(64)")] - public string UserName { get; set; } = string.Empty; - - [Column("PersonSex", TypeName = "varchar(32)")] - public string Gender { get; set; } = string.Empty; - - [Column("Age", TypeName = "integer")] - public int Age { get; set; } -} \ No newline at end of file diff --git a/examples/controller/Interfaces/IUserService.cs b/examples/controller/Interfaces/IUserService.cs deleted file mode 100644 index 90ba4e0..0000000 --- a/examples/controller/Interfaces/IUserService.cs +++ /dev/null @@ -1,4 +0,0 @@ -public interface IUserService -{ - IQueryable GetAllUsers(); -} \ No newline at end of file diff --git a/examples/controller/Program.cs b/examples/controller/Program.cs deleted file mode 100644 index 9dd91be..0000000 --- a/examples/controller/Program.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Reflection; -using Bogus; -using Microsoft.EntityFrameworkCore; - -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. - -builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(options => -{ - options.OperationFilter(); -}); - -builder.Services.AddDbContext(options => -{ - options.UseInMemoryDatabase("controller"); -}); - -builder.Services.AddAutoMapper(Assembly.GetExecutingAssembly()); - -builder.Services.AddScoped(); - -builder.Services.AddSingleton, UserDtoSearchBinder>(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); - - using (var scope = app.Services.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - await context.Database.EnsureCreatedAsync(); - - // Seed data - if (!context.Users.Any()) - { - var users = new Faker() - .RuleFor(x => x.Firstname, f => f.Person.FirstName) - .RuleFor(x => x.Lastname, f => f.Person.LastName) - .RuleFor(x => x.Email, f => f.Person.Email) - .RuleFor(x => x.AvatarUrl, f => f.Internet.Avatar()) - .RuleFor(x => x.UserName, f => f.Person.UserName) - .RuleFor(x => x.Gender, f => f.Person.Gender.ToString()) - .RuleFor(x => x.IsDeleted, f => f.Random.Bool()) - .RuleFor(x => x.Age, f => f.Random.Number(1, 10)); - - context.Users.AddRange(users.Generate(1_000)); - context.SaveChanges(); - - Console.WriteLine("Seeded 1,000 fake users!"); - } - } -} - -app.UseHttpsRedirection(); - -app.UseAuthorization(); - -app.MapControllers(); - -app.Run(); diff --git a/examples/controller/Properties/launchSettings.json b/examples/controller/Properties/launchSettings.json deleted file mode 100644 index bf662f3..0000000 --- a/examples/controller/Properties/launchSettings.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:42100", - "sslPort": 44374 - } - }, - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://localhost:5240", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7077;http://localhost:5240", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/examples/controller/SearchBinders/UserDtoSearchBinder.cs b/examples/controller/SearchBinders/UserDtoSearchBinder.cs deleted file mode 100644 index 6ce15e9..0000000 --- a/examples/controller/SearchBinders/UserDtoSearchBinder.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Linq.Expressions; - -public class UserDtoSearchBinder : ISearchBinder -{ - public Expression> Bind(string searchTerm) - { - Expression> exp = x => x.Firstname.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) || x.Lastname.Contains(searchTerm, StringComparison.OrdinalIgnoreCase); - - return exp; - } -} \ No newline at end of file diff --git a/examples/controller/Services/UserService.cs b/examples/controller/Services/UserService.cs deleted file mode 100644 index edd5275..0000000 --- a/examples/controller/Services/UserService.cs +++ /dev/null @@ -1,22 +0,0 @@ -using AutoMapper; -using AutoMapper.QueryableExtensions; -using Microsoft.EntityFrameworkCore; - -public class UserService : IUserService -{ - private readonly ApplicationDbContext _context; - private readonly IMapper _mapper; - - public UserService(ApplicationDbContext context, IMapper mapper) - { - _context = context; - _mapper = mapper; - } - - public IQueryable GetAllUsers() - { - return _context.Users.AsNoTracking() - .Where(x => !x.IsDeleted) - .ProjectTo(_mapper.ConfigurationProvider); - } -} \ No newline at end of file diff --git a/examples/controller/appsettings.Development.json b/examples/controller/appsettings.Development.json deleted file mode 100644 index ff66ba6..0000000 --- a/examples/controller/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/examples/controller/appsettings.json b/examples/controller/appsettings.json deleted file mode 100644 index 4d56694..0000000 --- a/examples/controller/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/examples/controller/controller.csproj b/examples/controller/controller.csproj deleted file mode 100644 index b21da3e..0000000 --- a/examples/controller/controller.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - net8.0 - enable - enable - - - - - - - - - - - diff --git a/examples/minimal/ApplicationDbContext.cs b/examples/minimal/ApplicationDbContext.cs deleted file mode 100644 index 5735ca9..0000000 --- a/examples/minimal/ApplicationDbContext.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -public class ApplicationDbContext : DbContext -{ - public ApplicationDbContext(DbContextOptions options) : base(options) { } - - public DbSet Users => Set(); -} \ No newline at end of file diff --git a/examples/minimal/Dtos/UserDto.cs b/examples/minimal/Dtos/UserDto.cs deleted file mode 100644 index fa76287..0000000 --- a/examples/minimal/Dtos/UserDto.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Text.Json.Serialization; - -public record UserDto -{ - public Guid Id { get; set; } - - public string Firstname { get; set; } = string.Empty; - - public string Lastname { get; set; } = string.Empty; - - public string Email { get; set; } = string.Empty; - - public string AvatarUrl { get; set; } = string.Empty; - - [JsonPropertyName("displayName")] - public string UserName { get; set; } = string.Empty; - - public string Gender { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/examples/minimal/Entities/User.cs b/examples/minimal/Entities/User.cs deleted file mode 100644 index d7e4f17..0000000 --- a/examples/minimal/Entities/User.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; -using Bogus.DataSets; - -public record User -{ - public Guid Id { get; set; } - - [Column(TypeName = "varchar(128)")] - public string Firstname { get; set; } = string.Empty; - - [Column(TypeName = "varchar(128)")] - public string Lastname { get; set; } = string.Empty; - - [Column(TypeName = "varchar(320)")] - public string Email { get; set; } = string.Empty; - - [Column(TypeName = "varchar(1024)")] - public string AvatarUrl { get; set; } = string.Empty; - public bool IsDeleted { get; set; } - - [Column(TypeName = "varchar(64)")] - public string UserName { get; set; } = string.Empty; - - [Column("PersonSex", TypeName = "varchar(32)")] - public string Gender { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/examples/minimal/Interfaces/IUserService.cs b/examples/minimal/Interfaces/IUserService.cs deleted file mode 100644 index 90ba4e0..0000000 --- a/examples/minimal/Interfaces/IUserService.cs +++ /dev/null @@ -1,4 +0,0 @@ -public interface IUserService -{ - IQueryable GetAllUsers(); -} \ No newline at end of file diff --git a/examples/minimal/Profiles/AutoMapperProfile.cs b/examples/minimal/Profiles/AutoMapperProfile.cs deleted file mode 100644 index cd2efb2..0000000 --- a/examples/minimal/Profiles/AutoMapperProfile.cs +++ /dev/null @@ -1,9 +0,0 @@ -using AutoMapper; - -public class AutoMapperProfile : Profile -{ - public AutoMapperProfile() - { - CreateMap(); - } -} \ No newline at end of file diff --git a/examples/minimal/Program.cs b/examples/minimal/Program.cs deleted file mode 100644 index c2516f8..0000000 --- a/examples/minimal/Program.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Linq.Dynamic.Core; -using System.Reflection; -using Bogus; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddEndpointsApiExplorer(); - -builder.Services.AddSwaggerGen(options => -{ - options.OperationFilter(); -}); - -builder.Services.AddDbContext(options => -{ - options.UseInMemoryDatabase("minimal"); -}); - -builder.Services.AddAutoMapper(Assembly.GetExecutingAssembly()); - -builder.Services.AddScoped(); - -builder.Services.AddSingleton, UserDtoSearchBinder>(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); - - using (var scope = app.Services.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - await context.Database.EnsureCreatedAsync(); - - // Seed data - if (!context.Users.Any()) - { - var users = new Faker() - .RuleFor(x => x.Firstname, f => f.Person.FirstName) - .RuleFor(x => x.Lastname, f => f.Person.LastName) - .RuleFor(x => x.Email, f => f.Person.Email) - .RuleFor(x => x.AvatarUrl, f => f.Internet.Avatar()) - .RuleFor(x => x.UserName, f => f.Person.UserName) - .RuleFor(x => x.Gender, f => f.Person.Gender.ToString()) - .RuleFor(x => x.IsDeleted, f => f.Random.Bool()); - - context.Users.AddRange(users.Generate(1_000)); - context.SaveChanges(); - - Console.WriteLine("Seeded 1,000 fake users!"); - } - } -} - -app.UseHttpsRedirection(); - -app.MapGet("/users", (ApplicationDbContext db, [FromServices] IUserService userService, [AsParameters] Query query) => -{ - var (users, count) = userService.GetAllUsers().Apply(query); - - return TypedResults.Ok(new PagedResponse(users.ToDynamicList(), count)); -}); - -app.Run(); diff --git a/examples/minimal/Properties/launchSettings.json b/examples/minimal/Properties/launchSettings.json deleted file mode 100644 index bf662f3..0000000 --- a/examples/minimal/Properties/launchSettings.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:42100", - "sslPort": 44374 - } - }, - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://localhost:5240", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7077;http://localhost:5240", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/examples/minimal/SearchBinders/UserDtoSearchBinder.cs b/examples/minimal/SearchBinders/UserDtoSearchBinder.cs deleted file mode 100644 index 6ce15e9..0000000 --- a/examples/minimal/SearchBinders/UserDtoSearchBinder.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Linq.Expressions; - -public class UserDtoSearchBinder : ISearchBinder -{ - public Expression> Bind(string searchTerm) - { - Expression> exp = x => x.Firstname.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) || x.Lastname.Contains(searchTerm, StringComparison.OrdinalIgnoreCase); - - return exp; - } -} \ No newline at end of file diff --git a/examples/minimal/Services/UserService.cs b/examples/minimal/Services/UserService.cs deleted file mode 100644 index edd5275..0000000 --- a/examples/minimal/Services/UserService.cs +++ /dev/null @@ -1,22 +0,0 @@ -using AutoMapper; -using AutoMapper.QueryableExtensions; -using Microsoft.EntityFrameworkCore; - -public class UserService : IUserService -{ - private readonly ApplicationDbContext _context; - private readonly IMapper _mapper; - - public UserService(ApplicationDbContext context, IMapper mapper) - { - _context = context; - _mapper = mapper; - } - - public IQueryable GetAllUsers() - { - return _context.Users.AsNoTracking() - .Where(x => !x.IsDeleted) - .ProjectTo(_mapper.ConfigurationProvider); - } -} \ No newline at end of file diff --git a/examples/minimal/minimal.csproj b/examples/minimal/minimal.csproj deleted file mode 100644 index b21da3e..0000000 --- a/examples/minimal/minimal.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - net8.0 - enable - enable - - - - - - - - - - - diff --git a/src/Attributes/EnableQueryAttribute.cs b/src/Attributes/EnableQueryAttribute.cs deleted file mode 100644 index d194792..0000000 --- a/src/Attributes/EnableQueryAttribute.cs +++ /dev/null @@ -1,119 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; - -public sealed class EnableQueryAttribute : Attribute, IActionFilter -{ - private readonly int _topMax = 100; - - public EnableQueryAttribute(int topMax) - { - _topMax = topMax; - } - - public EnableQueryAttribute() { } - - public void OnActionExecuting(ActionExecutingContext context) { } - - public void OnActionExecuted(ActionExecutedContext context) - { - var result = context.Result as ObjectResult; - if (result is null) return; - - var content = result.Value as IQueryable; - if (content is null) return; - - var queryString = context.HttpContext.Request.Query; - - // Top - queryString.TryGetValue("top", out var topQuery); - var topString = topQuery.ToString(); - - bool parsedTop = Int32.TryParse(topString, out int top); - - if (!string.IsNullOrEmpty(topString) && !parsedTop) - { - // Whatever was passed to as 'Top' was not parseable to a int - result.StatusCode = StatusCodes.Status400BadRequest; - result.Value = new QueryErrorResponse(StatusCodes.Status400BadRequest, "The query parameter 'Top' could not be parsed to an integer"); - - return; - } - - // Skip - queryString.TryGetValue("skip", out var skipQuery); - var skipString = skipQuery.ToString(); - - bool parsedSkip = Int32.TryParse(skipString, out int skip); - - if (!string.IsNullOrEmpty(skipString) && !parsedSkip) - { - // Whatever was passed to as 'Skip' was not parseable to a int - result.StatusCode = StatusCodes.Status400BadRequest; - result.Value = new QueryErrorResponse(StatusCodes.Status400BadRequest, "The query parameter 'Skip' could not be parsed to an integer"); - - return; - } - - // Count - queryString.TryGetValue("count", out var countQuery); - var countString = countQuery.ToString(); - - bool parsedCount = bool.TryParse(countString, out bool count); - - if (!string.IsNullOrEmpty(countString) && !parsedCount) - { - // Whatever was passed to as 'count' was not parseable to a bool - result.StatusCode = StatusCodes.Status400BadRequest; - result.Value = new QueryErrorResponse(StatusCodes.Status400BadRequest, "The query parameter 'Count' could not be parsed to a boolean"); - - return; - } - - // Order by - queryString.TryGetValue("orderby", out var orderbyQuery); - - // Select - queryString.TryGetValue("select", out var selectQuery); - - // Search - queryString.TryGetValue("search", out var searchQuery); - var search = searchQuery.ToString(); - - // Filter - queryString.TryGetValue("filter", out var filterQuery); - - var query = new Query() - { - Top = !string.IsNullOrEmpty(topString) ? top : _topMax, - Skip = skip, - Count = count, - OrderBy = orderbyQuery.ToString(), - Select = selectQuery.ToString(), - Search = search, - Filter = filterQuery.ToString() - }; - - ISearchBinder? searchBinder = null; - - if (!string.IsNullOrEmpty(search)) - { - searchBinder = context.HttpContext.RequestServices.GetService(typeof(ISearchBinder)) as ISearchBinder; - } - - try - { - var (data, totalCount) = content.Apply(query, _topMax, searchBinder); - - result.StatusCode = StatusCodes.Status200OK; - // We use here because when utilizing the 'select' functionality, it becomes - // an anonymous object and no longer is the type T. - result.Value = new PagedResponse((IQueryable)data, totalCount); - } - catch (Exception ex) - { - result.StatusCode = StatusCodes.Status400BadRequest; - result.Value = new QueryErrorResponse(StatusCodes.Status400BadRequest, ex.Message); - } - } -} \ No newline at end of file diff --git a/src/Exceptions/GoatQueryException.cs b/src/Exceptions/GoatQueryException.cs deleted file mode 100644 index 01ccef6..0000000 --- a/src/Exceptions/GoatQueryException.cs +++ /dev/null @@ -1,16 +0,0 @@ -public class GoatQueryException : Exception -{ - public GoatQueryException() - { - } - - public GoatQueryException(string message) - : base(message) - { - } - - public GoatQueryException(string message, Exception inner) - : base(message, inner) - { - } -} \ No newline at end of file diff --git a/src/Extensions/QueryableExtension.cs b/src/Extensions/QueryableExtension.cs deleted file mode 100644 index 36d443c..0000000 --- a/src/Extensions/QueryableExtension.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Linq.Dynamic.Core; -using System.Reflection; -using System.Text; -using System.Text.Json.Serialization; - -public static class QueryableExtension -{ - public static Dictionary _filterOperations => new Dictionary - { - {"eq", "=="}, - {"ne", "!="}, - {"contains", "Contains"}, - }; - - public static (IQueryable, int?) Apply(this IQueryable queryable, Query query, int? maxTop = null, ISearchBinder? searchBinder = null) - { - var result = (IQueryable)queryable; - - if (maxTop is not null && query.Top > maxTop) - { - throw new GoatQueryException("The value supplied for the query parameter 'Top' was greater than the maximum top allowed for this resource"); - } - - // Filter - if (!string.IsNullOrEmpty(query.Filter)) - { - var filters = StringHelper.SplitString(query.Filter); - - var where = new StringBuilder(); - - for (int i = 0; i < filters.Count; i++) - { - var filter = filters[i]; - var opts = StringHelper.SplitStringByWhitespace(filter.Trim()); - - if (opts.Count != 3) - { - continue; - } - - if (i > 0) - { - var prev = filters[i - 1]; - where.Append($" {prev.Trim()} "); - } - - var property = opts[0]; - var operand = opts[1]; - var value = opts[2].Replace("'", "\""); - - string? propertyName = typeof(T).GetProperties().FirstOrDefault(x => x.GetCustomAttribute()?.Name == property)?.Name; - - if (!string.IsNullOrEmpty(propertyName)) - { - property = propertyName; - } - - if (operand.Equals("contains", StringComparison.OrdinalIgnoreCase)) - { - where.Append($"{property}.{_filterOperations[operand]}({value})"); - } - else if (typeof(T).GetProperties().FirstOrDefault(x => x.Name.Equals(property, StringComparison.OrdinalIgnoreCase))?.PropertyType == typeof(string)) - { - where.Append($"{property}.ToLower() {_filterOperations[operand]} {value}.ToLower()"); - } - else if (typeof(T).GetProperties().FirstOrDefault(x => x.Name.Equals(property, StringComparison.OrdinalIgnoreCase))?.PropertyType == typeof(Guid)) - { - where.Append($"{property} {_filterOperations[operand]} Guid({value})"); - } - else - { - where.Append($"{property} {_filterOperations[operand]} {value}"); - } - } - - result = result.Where(where.ToString()); - } - - // Search - if (searchBinder is not null && !string.IsNullOrEmpty(query.Search)) - { - var searchExpression = searchBinder.Bind(query.Search); - - if (searchExpression is null) - { - throw new GoatQueryException("search binder does not return valid expression that can be parsed to where clause"); - } - - result = result.Where(searchExpression); - } - - int? count = null; - - // Count - if (query.Count ?? false) - { - count = result.Count(); - } - - // Order by - if (!string.IsNullOrEmpty(query.OrderBy)) - { - result = result.OrderBy(query.OrderBy); - } - - // Select - if (!string.IsNullOrEmpty(query.Select)) - { - result = result.Select($"new {{ {query.Select} }}"); - } - - // Skip - if (query.Skip > 0) - { - result = result.Skip(query.Skip ?? 0); - } - - // Top - if (query.Top > 0) - { - result = result.Take(query.Top ?? 0); - } - - return (result, count); - } -} diff --git a/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs b/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs new file mode 100644 index 0000000..823ea00 --- /dev/null +++ b/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs @@ -0,0 +1,99 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +public sealed class EnableQueryAttribute : ActionFilterAttribute +{ + private readonly QueryOptions? _options; + + public EnableQueryAttribute(int maxTop, int maxPropertyMappingDepth = 5) + { + var options = new QueryOptions() + { + MaxTop = maxTop, + MaxPropertyMappingDepth = maxPropertyMappingDepth + }; + + _options = options; + } + + public EnableQueryAttribute() { } + + public override void OnActionExecuting(ActionExecutingContext context) { } + + public override void OnActionExecuted(ActionExecutedContext context) + { + var result = context.Result as ObjectResult; + if (result is null) return; + + var queryable = result.Value as IQueryable; + if (queryable is null) return; + + var queryString = context.HttpContext.Request.Query; + + // Top + queryString.TryGetValue("top", out var topQuery); + + if (!int.TryParse(topQuery.ToString(), out int top) && !string.IsNullOrEmpty(topQuery)) + { + context.Result = new BadRequestObjectResult(new { Message = "The query parameter 'Top' could not be parsed to an integer" }); + return; + } + + // Skip + queryString.TryGetValue("skip", out var skipQuery); + var skipString = skipQuery.ToString(); + + if (!int.TryParse(skipString, out int skip) && !string.IsNullOrEmpty(skipQuery)) + { + context.Result = new BadRequestObjectResult(new { Message = "The query parameter 'Skip' could not be parsed to an integer" }); + return; + } + + // Count + queryString.TryGetValue("count", out var countQuery); + var countString = countQuery.ToString(); + + if (!bool.TryParse(countString, out bool count) && !string.IsNullOrEmpty(countString)) + { + context.Result = new BadRequestObjectResult(new { Message = "The query parameter 'Count' could not be parsed to a boolean" }); + return; + } + + // Order by + queryString.TryGetValue("orderby", out var orderbyQuery); + + // Search + queryString.TryGetValue("search", out var searchQuery); + var search = searchQuery.ToString(); + + // Filter + queryString.TryGetValue("filter", out var filterQuery); + + var query = new Query() + { + Top = top, + Skip = skip, + Count = count, + OrderBy = orderbyQuery.ToString(), + Search = search, + Filter = filterQuery.ToString() + }; + + ISearchBinder? searchBinder = null; + + if (!string.IsNullOrEmpty(search)) + { + searchBinder = context.HttpContext.RequestServices.GetService(typeof(ISearchBinder)) as ISearchBinder; + } + + var applyResult = queryable.Apply(query, searchBinder, _options); + if (applyResult.IsFailed) + { + var message = string.Join(", ", applyResult.Errors.Select(x => x.Message)); + context.Result = new BadRequestObjectResult(new { message, errors = applyResult.Errors }); + return; + } + + context.Result = new OkObjectResult(new PagedResponse(applyResult.Value.Query.ToList(), applyResult.Value.Count)); + } +} \ No newline at end of file diff --git a/src/GoatQuery.AspNetCore/src/GoatQuery.AspNetCore.csproj b/src/GoatQuery.AspNetCore/src/GoatQuery.AspNetCore.csproj new file mode 100644 index 0000000..2f32985 --- /dev/null +++ b/src/GoatQuery.AspNetCore/src/GoatQuery.AspNetCore.csproj @@ -0,0 +1,27 @@ + + + + net6.0;net8.0 + 11.0 + GoatQuery.AspNetCore + enable + enable + + GoatQuery.AspNetCore + https://github.com/goatquery/src/GoatQuery.AspNetCore + https://github.com/goatquery/goatquery-dotnet + git + AspNetCore extensions for GoatQuery + MIT + true + + + + + + + + + + + diff --git a/src/GoatQuery/README.md b/src/GoatQuery/README.md new file mode 100644 index 0000000..9c26a9c --- /dev/null +++ b/src/GoatQuery/README.md @@ -0,0 +1 @@ +# GoatQuery .NET diff --git a/src/GoatQuery/src/Ast/ExpressionStatement.cs b/src/GoatQuery/src/Ast/ExpressionStatement.cs new file mode 100644 index 0000000..67462d4 --- /dev/null +++ b/src/GoatQuery/src/Ast/ExpressionStatement.cs @@ -0,0 +1,8 @@ +public sealed class ExpressionStatement : Statement +{ + public InfixExpression Expression { get; set; } = default; + + public ExpressionStatement(Token token) : base(token) + { + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Ast/Identifier.cs b/src/GoatQuery/src/Ast/Identifier.cs new file mode 100644 index 0000000..972f490 --- /dev/null +++ b/src/GoatQuery/src/Ast/Identifier.cs @@ -0,0 +1,9 @@ +public sealed class Identifier : QueryExpression +{ + public string Value { get; set; } + + public Identifier(Token token, string value) : base(token) + { + Value = value; + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Ast/InfixExpression.cs b/src/GoatQuery/src/Ast/InfixExpression.cs new file mode 100644 index 0000000..ce44cda --- /dev/null +++ b/src/GoatQuery/src/Ast/InfixExpression.cs @@ -0,0 +1,16 @@ +public sealed class InfixExpression : QueryExpression +{ + public QueryExpression Left { get; set; } = default; + public string Operator { get; set; } = string.Empty; + public QueryExpression Right { get; set; } = default; + + public InfixExpression(Token token, QueryExpression left, string op) : base(token) + { + Left = left; + Operator = op; + } + + public InfixExpression(Token token) : base(token) + { + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Ast/LambdaExpression.cs b/src/GoatQuery/src/Ast/LambdaExpression.cs new file mode 100644 index 0000000..799a8d6 --- /dev/null +++ b/src/GoatQuery/src/Ast/LambdaExpression.cs @@ -0,0 +1,19 @@ +public sealed class QueryLambdaExpression : QueryExpression +{ + public QueryExpression Property { get; } + public string Function { get; } + public string Parameter { get; } + public QueryExpression Body { get; set; } + + public QueryLambdaExpression(Token token, QueryExpression property, string function, string parameter) : base(token) + { + Property = property; + Function = function; + Parameter = parameter; + } + + public override string TokenLiteral() + { + return $"{Property.TokenLiteral()}/{Function}({Parameter}: {Body?.TokenLiteral()})"; + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Ast/Literals.cs b/src/GoatQuery/src/Ast/Literals.cs new file mode 100644 index 0000000..455b32d --- /dev/null +++ b/src/GoatQuery/src/Ast/Literals.cs @@ -0,0 +1,98 @@ +using System; + +public sealed class StringLiteral : QueryExpression +{ + public string Value { get; set; } + + public StringLiteral(Token token, string value) : base(token) + { + Value = value; + } +} + +public sealed class GuidLiteral : QueryExpression +{ + public Guid Value { get; set; } + + public GuidLiteral(Token token, Guid value) : base(token) + { + Value = value; + } +} + +public sealed class IntegerLiteral : QueryExpression +{ + public int Value { get; set; } + + public IntegerLiteral(Token token, int value) : base(token) + { + Value = value; + } +} + +public sealed class DecimalLiteral : QueryExpression +{ + public decimal Value { get; set; } + + public DecimalLiteral(Token token, decimal value) : base(token) + { + Value = value; + } +} + +public sealed class FloatLiteral : QueryExpression +{ + public float Value { get; set; } + + public FloatLiteral(Token token, float value) : base(token) + { + Value = value; + } +} + +public sealed class DoubleLiteral : QueryExpression +{ + public double Value { get; set; } + + public DoubleLiteral(Token token, double value) : base(token) + { + Value = value; + } +} + +public sealed class DateTimeLiteral : QueryExpression +{ + public DateTime Value { get; set; } + + public DateTimeLiteral(Token token, DateTime value) : base(token) + { + Value = value; + } +} + +public sealed class DateLiteral : QueryExpression +{ + public DateTime Value { get; set; } + + public DateLiteral(Token token, DateTime value) : base(token) + { + Value = value; + } +} + +public sealed class NullLiteral : QueryExpression +{ + public NullLiteral(Token token) : base(token) + { + } +} + +public sealed class BooleanLiteral : QueryExpression +{ + public bool Value { get; set; } + + public BooleanLiteral(Token token, bool value) : base(token) + { + Value = value; + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Ast/Node.cs b/src/GoatQuery/src/Ast/Node.cs new file mode 100644 index 0000000..21f324b --- /dev/null +++ b/src/GoatQuery/src/Ast/Node.cs @@ -0,0 +1,14 @@ +public abstract class Node +{ + private readonly Token _token; + + public Node(Token token) + { + _token = token; + } + + public virtual string TokenLiteral() + { + return _token.Literal; + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Ast/OrderByAst.cs b/src/GoatQuery/src/Ast/OrderByAst.cs new file mode 100644 index 0000000..b3eccd5 --- /dev/null +++ b/src/GoatQuery/src/Ast/OrderByAst.cs @@ -0,0 +1,16 @@ +public enum OrderByDirection +{ + Ascending = 1, + Descending +} + +public sealed class OrderByStatement : Node +{ + public OrderByDirection Direction { get; set; } + + public OrderByStatement(Token token) : base(token) { } + public OrderByStatement(Token token, OrderByDirection direction) : base(token) + { + Direction = direction; + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Ast/PropertyPath.cs b/src/GoatQuery/src/Ast/PropertyPath.cs new file mode 100644 index 0000000..dfc209c --- /dev/null +++ b/src/GoatQuery/src/Ast/PropertyPath.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +public sealed class PropertyPath : QueryExpression +{ + public List Segments { get; } + + public PropertyPath(Token token, List segments) : base(token) + { + Segments = segments; + } + + public override string TokenLiteral() + { + return string.Join("/", Segments); + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Ast/QueryExpression.cs b/src/GoatQuery/src/Ast/QueryExpression.cs new file mode 100644 index 0000000..d3a6624 --- /dev/null +++ b/src/GoatQuery/src/Ast/QueryExpression.cs @@ -0,0 +1,6 @@ +public abstract class QueryExpression : Node +{ + public QueryExpression(Token token) : base(token) + { + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Ast/Statement.cs b/src/GoatQuery/src/Ast/Statement.cs new file mode 100644 index 0000000..1a1dab0 --- /dev/null +++ b/src/GoatQuery/src/Ast/Statement.cs @@ -0,0 +1,6 @@ +public abstract class Statement : Node +{ + public Statement(Token token) : base(token) + { + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluationContext.cs b/src/GoatQuery/src/Evaluator/FilterEvaluationContext.cs new file mode 100644 index 0000000..814a5d1 --- /dev/null +++ b/src/GoatQuery/src/Evaluator/FilterEvaluationContext.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +internal class FilterEvaluationContext +{ + public ParameterExpression RootParameter { get; } + public PropertyMappingTree PropertyMappingTree { get; } + public Stack LambdaScopes { get; } = new Stack(); + + public FilterEvaluationContext(ParameterExpression rootParameter, PropertyMappingTree propertyMappingTree) + { + RootParameter = rootParameter; + PropertyMappingTree = propertyMappingTree; + } + + public bool IsInLambdaScope => LambdaScopes.Count > 0; + public LambdaScope CurrentLambda => LambdaScopes.Peek(); + + public void EnterLambdaScope(string parameterName, ParameterExpression parameter, Type elementType) + { + LambdaScopes.Push(new LambdaScope + { + ParameterName = parameterName, + Parameter = parameter, + ElementType = elementType + }); + } + + public void ExitLambdaScope() => LambdaScopes.Pop(); +} + +internal class LambdaScope +{ + public string ParameterName { get; set; } + public ParameterExpression Parameter { get; set; } + public Type ElementType { get; set; } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs new file mode 100644 index 0000000..057976a --- /dev/null +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -0,0 +1,650 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json.Serialization; +using FluentResults; + +public static class FilterEvaluator +{ + private const string HasValuePropertyName = "HasValue"; + private const string ValuePropertyName = "Value"; + private const string DatePropertyName = "Date"; + + private static readonly MethodInfo EnumerableAnyWithPredicate = GetEnumerableMethod("Any", 2); + private static readonly MethodInfo EnumerableAnyWithoutPredicate = GetEnumerableMethod("Any", 1); + private static readonly MethodInfo EnumerableAllWithPredicate = GetEnumerableMethod("All", 2); + private static readonly MethodInfo StringToLowerMethod = GetStringMethod("ToLower"); + private static readonly MethodInfo StringContainsMethod = GetStringMethod("Contains", typeof(string)); + + private static MethodInfo GetEnumerableMethod(string methodName, int parameterCount) => + typeof(Enumerable).GetMethods().First(m => m.Name == methodName && m.GetParameters().Length == parameterCount); + + private static MethodInfo GetStringMethod(string methodName, params Type[] parameterTypes) => + typeof(string).GetMethod(methodName, parameterTypes ?? Type.EmptyTypes); + + public static Result Evaluate(QueryExpression expression, ParameterExpression parameterExpression, PropertyMappingTree propertyMappingTree) + { + if (expression == null) return Result.Fail("Expression cannot be null"); + if (parameterExpression == null) return Result.Fail("Parameter expression cannot be null"); + if (propertyMappingTree == null) return Result.Fail("Property mapping tree cannot be null"); + + var context = new FilterEvaluationContext(parameterExpression, propertyMappingTree); + return EvaluateExpression(expression, context); + } + + private static Result EvaluateExpression(QueryExpression expression, FilterEvaluationContext context) + { + return expression switch + { + InfixExpression exp => EvaluateInfixExpression(exp, context), + QueryLambdaExpression lambdaExp => EvaluateLambdaExpression(lambdaExp, context), + _ => Result.Fail($"Unsupported expression type: {expression.GetType().Name}") + }; + } + + private static Result EvaluatePropertyPathExpression( + InfixExpression exp, + PropertyPath propertyPath, + FilterEvaluationContext context) + { + var baseExpression = context.IsInLambdaScope ? + (Expression)context.CurrentLambda.Parameter : + context.RootParameter; + + var propertyPathResult = BuildPropertyPath(propertyPath, baseExpression, context.PropertyMappingTree); + if (propertyPathResult.IsFailed) return Result.Fail(propertyPathResult.Errors); + + var finalProperty = propertyPathResult.Value; + + if (exp.Right is NullLiteral) + { + var nullComparison = CreateNullComparison(exp, finalProperty); + return nullComparison; + } + + var comparisonResult = EvaluateValueComparison(exp, finalProperty); + if (comparisonResult.IsFailed) return comparisonResult; + + return comparisonResult.Value; + } + + private static Result BuildPropertyPath( + PropertyPath propertyPath, + Expression startExpression, + PropertyMappingTree propertyMappingTree) + { + var current = startExpression; + var currentMappingTree = propertyMappingTree; + + foreach (var (segment, isLast) in propertyPath.Segments.Select((s, i) => (s, i == propertyPath.Segments.Count - 1))) + { + if (!currentMappingTree.TryGetProperty(segment, out var propertyNode)) + return Result.Fail($"Invalid property '{segment}' in path"); + + current = Expression.Property(current, propertyNode.ActualPropertyName); + + // Navigate to nested mapping for next segment + if (!isLast) + { + if (!propertyNode.HasNestedMapping) + return Result.Fail($"Property '{segment}' does not support nested navigation"); + + currentMappingTree = propertyNode.NestedMapping; + } + } + + return Result.Ok((MemberExpression)current); + } + + private static Result ResolvePropertyPathForCollection( + PropertyPath propertyPath, + Expression baseExpression, + PropertyMappingTree propertyMappingTree) + { + var current = baseExpression; + var currentMappingTree = propertyMappingTree; + + for (int i = 0; i < propertyPath.Segments.Count; i++) + { + var segment = propertyPath.Segments[i]; + + if (!currentMappingTree.TryGetProperty(segment, out var propertyNode)) + return Result.Fail($"Invalid property '{segment}' in lambda expression property path"); + + current = Expression.Property(current, propertyNode.ActualPropertyName); + + // Navigate to nested mapping for next segment + if (i < propertyPath.Segments.Count - 1) + { + if (!propertyNode.HasNestedMapping) + return Result.Fail($"Property '{segment}' does not support nested navigation in lambda expression"); + + currentMappingTree = propertyNode.NestedMapping; + } + } + + return (MemberExpression)current; + } + + private static bool IsPrimitiveType(Type type) + { + return type.IsPrimitive || type == typeof(string) || type == typeof(decimal) || + type == typeof(DateTime) || type == typeof(Guid) || + Nullable.GetUnderlyingType(type) != null; + } + + private static Expression CreateNullComparison(InfixExpression exp, MemberExpression property) + { + return exp.Operator == Keywords.Eq + ? Expression.Equal(property, Expression.Constant(null, property.Type)) + : Expression.NotEqual(property, Expression.Constant(null, property.Type)); + } + + private static bool IsNullableDateTimeComparison(MemberExpression property, QueryExpression rightExpression) + { + return property.Type == typeof(DateTime?) && rightExpression is DateLiteral; + } + + private static Expression CreateNullableDateTimeComparison(MemberExpression property, ConstantExpression value, string operatorKeyword) + { + var hasValueProperty = Expression.Property(property, HasValuePropertyName); + var valueProperty = Expression.Property(property, ValuePropertyName); + var dateProperty = Expression.Property(valueProperty, DatePropertyName); + + var dateComparison = CreateDateComparison(dateProperty, value, operatorKeyword); + + return operatorKeyword == Keywords.Ne + ? Expression.OrElse(Expression.Not(hasValueProperty), dateComparison) + : Expression.AndAlso(hasValueProperty, dateComparison); + } + + private static Expression CreateDateComparison(Expression dateProperty, ConstantExpression value, string operatorKeyword) + { + return operatorKeyword switch + { + Keywords.Eq => Expression.Equal(dateProperty, value), + Keywords.Ne => Expression.NotEqual(dateProperty, value), + Keywords.Lt => Expression.LessThan(dateProperty, value), + Keywords.Lte => Expression.LessThanOrEqual(dateProperty, value), + Keywords.Gt => Expression.GreaterThan(dateProperty, value), + Keywords.Gte => Expression.GreaterThanOrEqual(dateProperty, value), + _ => throw new ArgumentException($"Unsupported operator for date comparison: {operatorKeyword}") + }; + } + + + private static Result EvaluateValueComparison(InfixExpression exp, MemberExpression property) + { + var valueResult = CreateConstantExpression(exp.Right, property); + if (valueResult.IsFailed) return Result.Fail(valueResult.Errors); + + var (value, updatedProperty) = valueResult.Value; + + if (IsNullableDateTimeComparison(updatedProperty, exp.Right)) + { + return CreateNullableDateTimeComparison(updatedProperty, value, exp.Operator); + } + + return CreateComparisonExpression(exp.Operator, updatedProperty, value); + } + + private static Result EvaluateValueComparison(InfixExpression exp, Expression expression) + { + var valueResult = CreateConstantExpression(exp.Right, expression); + if (valueResult.IsFailed) return Result.Fail(valueResult.Errors); + + return CreateComparisonExpression(exp.Operator, expression, valueResult.Value); + } + + private static Result CreateComparisonExpression(string operatorKeyword, Expression expression, ConstantExpression value) + { + return operatorKeyword switch + { + Keywords.Eq => Expression.Equal(expression, value), + Keywords.Ne => Expression.NotEqual(expression, value), + Keywords.Contains => CreateContainsExpression(expression, value), + Keywords.Lt => Expression.LessThan(expression, value), + Keywords.Lte => Expression.LessThanOrEqual(expression, value), + Keywords.Gt => Expression.GreaterThan(expression, value), + Keywords.Gte => Expression.GreaterThanOrEqual(expression, value), + _ => Result.Fail($"Unsupported operator: {operatorKeyword}") + }; + } + + private static Result CreateComparisonExpression(string operatorKeyword, MemberExpression property, ConstantExpression value) + { + return CreateComparisonExpression(operatorKeyword, (Expression)property, value); + } + + private static Result CreateConstantExpression(QueryExpression literal, Expression expression) + { + return literal switch + { + IntegerLiteral intLit => CreateIntegerOrEnumConstant(intLit.Value, expression.Type), + DateLiteral dateLit => Result.Ok(CreateDateConstant(dateLit, expression)), + GuidLiteral guidLit => Result.Ok(Expression.Constant(guidLit.Value, expression.Type)), + DecimalLiteral decLit => Result.Ok(Expression.Constant(decLit.Value, expression.Type)), + FloatLiteral floatLit => Result.Ok(Expression.Constant(floatLit.Value, expression.Type)), + DoubleLiteral dblLit => Result.Ok(Expression.Constant(dblLit.Value, expression.Type)), + StringLiteral strLit => CreateStringOrEnumConstant(strLit.Value, expression.Type), + DateTimeLiteral dtLit => Result.Ok(Expression.Constant(dtLit.Value, expression.Type)), + BooleanLiteral boolLit => Result.Ok(Expression.Constant(boolLit.Value, expression.Type)), + NullLiteral _ => Result.Ok(Expression.Constant(null, expression.Type)), + _ => Result.Fail($"Unsupported literal type: {literal.GetType().Name}") + }; + } + + private static Result<(ConstantExpression Value, MemberExpression Property)> CreateConstantExpression(QueryExpression literal, MemberExpression property) + { + var constantResult = CreateConstantExpression(literal, (Expression)property); + if (constantResult.IsFailed) return Result.Fail(constantResult.Errors); + + return Result.Ok((constantResult.Value, property)); + } + + private static ConstantExpression CreateDateConstant(DateLiteral dateLiteral, Expression expression) + { + if (expression.Type == typeof(DateTime?)) + { + return Expression.Constant(dateLiteral.Value.Date, typeof(DateTime)); + } + else + { + return Expression.Constant(dateLiteral.Value.Date, expression.Type); + } + } + + private static Expression CreateContainsExpression(Expression expression, ConstantExpression value) + { + var expressionToLower = Expression.Call(expression, StringToLowerMethod); + var valueToLower = Expression.Call(value, StringToLowerMethod); + return Expression.Call(expressionToLower, StringContainsMethod, valueToLower); + } + + private static Result EvaluateInfixExpression(InfixExpression exp, FilterEvaluationContext context) + { + if (exp.Left is PropertyPath propertyPath) + return EvaluatePropertyPathExpression(exp, propertyPath, context); + + if (exp.Left is Identifier) + return EvaluateIdentifierExpression(exp, context); + + if (string.IsNullOrEmpty(exp.Operator) && exp.Left is QueryLambdaExpression lambda) + return EvaluateLambdaExpression(lambda, context); + + return EvaluateLogicalExpression(exp, context); + } + + private static Result EvaluateIdentifierExpression(InfixExpression exp, FilterEvaluationContext context) + { + var identifier = exp.Left.TokenLiteral(); + + if (!context.PropertyMappingTree.TryGetProperty(identifier, out var propertyNode)) + { + return Result.Fail($"Invalid property '{identifier}' within filter"); + } + + var baseExpression = context.IsInLambdaScope ? + (Expression)context.CurrentLambda.Parameter : + context.RootParameter; + + var identifierProperty = Expression.Property(baseExpression, propertyNode.ActualPropertyName); + return EvaluateValueComparison(exp, identifierProperty); + } + + private static Result EvaluateLogicalExpression(InfixExpression exp, FilterEvaluationContext context) + { + var left = EvaluateExpression(exp.Left, context); + if (left.IsFailed) return left; + + var right = EvaluateExpression(exp.Right, context); + if (right.IsFailed) return right; + + return exp.Operator switch + { + Keywords.And => Expression.AndAlso(left.Value, right.Value), + Keywords.Or => Expression.OrElse(left.Value, right.Value), + _ => Result.Fail($"Unsupported logical operator: {exp.Operator}") + }; + } + + private static Result EvaluateLambdaExpression(QueryLambdaExpression lambdaExp, FilterEvaluationContext context) + { + var setupResult = SetupLambdaEvaluation(lambdaExp, context); + if (setupResult.IsFailed) return Result.Fail(setupResult.Errors); + + var (collectionProperty, elementType, lambdaParameter) = setupResult.Value; + + // Enter lambda scope + context.EnterLambdaScope(lambdaExp.Parameter, lambdaParameter, elementType); + + try + { + var bodyResult = EvaluateLambdaBody(lambdaExp.Body, context); + if (bodyResult.IsFailed) return bodyResult; + + var lambdaExpr = Expression.Lambda(bodyResult.Value, lambdaParameter); + return CreateLambdaLinqCall(lambdaExp.Function, collectionProperty, lambdaExpr, elementType); + } + finally + { + context.ExitLambdaScope(); + } + } + + private static Result<(MemberExpression Collection, Type ElementType, ParameterExpression Parameter)> SetupLambdaEvaluation( + QueryLambdaExpression lambdaExp, + FilterEvaluationContext context) + { + var baseExpression = context.IsInLambdaScope ? + (Expression)context.CurrentLambda.Parameter : + context.RootParameter; + + var collectionResult = ResolveCollectionProperty(lambdaExp.Property, baseExpression, context.PropertyMappingTree); + if (collectionResult.IsFailed) return Result.Fail(collectionResult.Errors); + + var collectionProperty = collectionResult.Value; + var elementType = GetCollectionElementType(collectionProperty.Type); + + if (elementType == null) + { + return Result.Fail($"Property '{lambdaExp.Property.TokenLiteral()}' is not a collection"); + } + + var lambdaParameter = Expression.Parameter(elementType, lambdaExp.Parameter); + return Result.Ok((collectionProperty, elementType, lambdaParameter)); + } + + private static Expression CreateLambdaLinqCall(string function, MemberExpression collection, LambdaExpression lambda, Type elementType) + { + return function.Equals(Keywords.Any, StringComparison.OrdinalIgnoreCase) + ? CreateAnyExpression(collection, lambda, elementType) + : CreateAllExpression(collection, lambda, elementType); + } + + private static Result ResolveCollectionProperty(QueryExpression property, Expression baseExpression, PropertyMappingTree propertyMappingTree) + { + switch (property) + { + case Identifier identifier: + if (!propertyMappingTree.TryGetProperty(identifier.TokenLiteral(), out var propertyNode)) + { + return Result.Fail($"Invalid property '{identifier.TokenLiteral()}' in lambda expression"); + } + return Expression.Property(baseExpression, propertyNode.ActualPropertyName); + + case PropertyPath propertyPath: + return ResolvePropertyPathForCollection(propertyPath, baseExpression, propertyMappingTree); + + default: + return Result.Fail($"Unsupported property type in lambda expression: {property.GetType().Name}"); + } + } + + private static Result EvaluateLambdaBody(QueryExpression expression, FilterEvaluationContext context) + { + return expression switch + { + InfixExpression exp when exp.Left is PropertyPath propertyPath => + EvaluateLambdaBodyPropertyPath(exp, propertyPath, context), + + InfixExpression exp when exp.Left is Identifier identifier => + EvaluateLambdaBodyIdentifier(exp, identifier, context), + + InfixExpression exp when IsNestedLambdaExpression(exp) => + EvaluateLambdaExpression((QueryLambdaExpression)exp.Left, context), + + InfixExpression exp => + EvaluateLambdaBodyLogicalOperator(exp, context), + + _ => Result.Fail($"Unsupported expression type in lambda context: {expression.GetType().Name}") + }; + } + + private static bool IsNestedLambdaExpression(InfixExpression exp) => + string.IsNullOrEmpty(exp.Operator) && exp.Left is QueryLambdaExpression; + + private static Result EvaluateLambdaBodyPropertyPath(InfixExpression exp, PropertyPath propertyPath, FilterEvaluationContext context) + { + var isLambdaParameterPath = propertyPath.Segments.Count > 0 && + propertyPath.Segments[0].Equals(context.CurrentLambda.ParameterName, StringComparison.OrdinalIgnoreCase); + + return isLambdaParameterPath + ? EvaluateLambdaPropertyPath(exp, propertyPath, context.CurrentLambda.Parameter) + : EvaluatePropertyPathExpression(exp, propertyPath, context); + } + + private static Result EvaluateLambdaBodyIdentifier(InfixExpression exp, Identifier identifier, FilterEvaluationContext context) + { + var identifierName = identifier.TokenLiteral(); + + if (identifierName.Equals(context.CurrentLambda.ParameterName, StringComparison.OrdinalIgnoreCase)) + { + // For primitive types (string, int, etc.), allow direct comparisons with the lambda parameter + if (IsPrimitiveType(context.CurrentLambda.ElementType)) + { + return EvaluateValueComparison(exp, context.CurrentLambda.Parameter); + } + + return Result.Fail($"Lambda parameter '{context.CurrentLambda.ParameterName}' cannot be used directly in comparisons for complex types"); + } + + if (!context.PropertyMappingTree.TryGetProperty(identifierName, out var propertyNode)) + { + return Result.Fail($"Invalid property '{identifierName}' within filter"); + } + + var identifierProperty = Expression.Property(context.RootParameter, propertyNode.ActualPropertyName); + return EvaluateValueComparison(exp, identifierProperty); + } + + private static Result EvaluateLambdaBodyLogicalOperator(InfixExpression exp, FilterEvaluationContext context) + { + var left = EvaluateLambdaBody(exp.Left, context); + if (left.IsFailed) return left; + + var right = EvaluateLambdaBody(exp.Right, context); + if (right.IsFailed) return right; + + return exp.Operator switch + { + Keywords.And => Expression.AndAlso(left.Value, right.Value), + Keywords.Or => Expression.OrElse(left.Value, right.Value), + _ => Result.Fail($"Unsupported logical operator: {exp.Operator}") + }; + } + + private static Result EvaluateLambdaPropertyPath(InfixExpression exp, PropertyPath propertyPath, ParameterExpression lambdaParameter) + { + // Skip the first segment (lambda parameter name) and build property path from lambda parameter + var current = (Expression)lambdaParameter; + var elementType = lambdaParameter.Type; + + // Build property path from lambda parameter + var pathResult = BuildLambdaPropertyPath(current, propertyPath.Segments.Skip(1).ToList(), elementType); + if (pathResult.IsFailed) return pathResult; + + current = pathResult.Value; + + var finalProperty = (MemberExpression)current; + + // Handle null comparisons + if (exp.Right is NullLiteral) + { + return exp.Operator == Keywords.Eq + ? Expression.Equal(finalProperty, Expression.Constant(null, finalProperty.Type)) + : Expression.NotEqual(finalProperty, Expression.Constant(null, finalProperty.Type)); + } + + // Handle value comparisons + return EvaluateValueComparison(exp, finalProperty); + } + + private static Expression CreateAnyExpression(MemberExpression collection, LambdaExpression lambda, Type elementType) + { + var genericMethod = EnumerableAnyWithPredicate.MakeGenericMethod(elementType); + return Expression.Call(genericMethod, collection, lambda); + } + + private static Expression CreateAllExpression(MemberExpression collection, LambdaExpression lambda, Type elementType) + { + var allMethod = EnumerableAllWithPredicate.MakeGenericMethod(elementType); + var anyMethod = EnumerableAnyWithoutPredicate.MakeGenericMethod(elementType); + + var hasElements = Expression.Call(anyMethod, collection); + var allMatch = Expression.Call(allMethod, collection, lambda); + + return Expression.AndAlso(hasElements, allMatch); + } + + private static Result BuildLambdaPropertyPath(Expression startExpression, List segments, Type elementType) + { + var current = startExpression; + var currentMappingTree = PropertyMappingTreeBuilder.BuildMappingTree(elementType, GetDefaultMaxDepth()); + + foreach (var segment in segments) + { + if (!currentMappingTree.TryGetProperty(segment, out var propertyNode)) + { + return Result.Fail($"Invalid property '{segment}' in lambda property path"); + } + + current = Expression.Property(current, propertyNode.ActualPropertyName); + + // Update mapping tree for nested navigation + if (propertyNode.HasNestedMapping) + { + currentMappingTree = propertyNode.NestedMapping; + } + } + + return Result.Ok(current); + } + + private static int GetDefaultMaxDepth() + { + return new QueryOptions().MaxPropertyMappingDepth; + } + + private static Type GetCollectionElementType(Type collectionType) + { + // Handle IEnumerable + if (collectionType.IsGenericType) + { + var genericArgs = collectionType.GetGenericArguments(); + if (genericArgs.Length == 1 && + typeof(IEnumerable<>).MakeGenericType(genericArgs[0]).IsAssignableFrom(collectionType)) + { + return genericArgs[0]; + } + } + + // Handle arrays + if (collectionType.IsArray) + { + return collectionType.GetElementType(); + } + + return null; + } + + private static Result GetIntegerExpressionConstant(int value, Type targetType) + { + try + { + var type = GetNonNullableType(targetType); + + object convertedValue = type switch + { + Type t when t == typeof(int) => value, + Type t when t == typeof(long) => Convert.ToInt64(value), + Type t when t == typeof(short) => Convert.ToInt16(value), + Type t when t == typeof(byte) => Convert.ToByte(value), + Type t when t == typeof(uint) => Convert.ToUInt32(value), + Type t when t == typeof(ulong) => Convert.ToUInt64(value), + Type t when t == typeof(ushort) => Convert.ToUInt16(value), + Type t when t == typeof(sbyte) => Convert.ToSByte(value), + _ => throw new NotSupportedException($"Unsupported numeric type: {targetType.Name}") + }; + + return Expression.Constant(convertedValue, targetType); + } + catch (OverflowException) + { + return Result.Fail($"Value {value} is too large for type {targetType.Name}"); + } + catch (Exception) + { + return Result.Fail($"Error converting {value} to {targetType.Name}"); + } + } + + private static Result CreateIntegerOrEnumConstant(int value, Type targetType) + { + var actualType = GetNonNullableType(targetType); + + if (actualType.IsEnum) + { + return ConvertIntegerToEnum(value, actualType, targetType); + } + + return GetIntegerExpressionConstant(value, targetType); + } + + private static Result ConvertIntegerToEnum(int value, Type actualType, Type targetType) + { + try + { + var enumValue = Enum.ToObject(actualType, value); + + return Result.Ok(Expression.Constant(enumValue, targetType)); + } + catch (Exception) + { + return Result.Fail($"Error converting {value} to enum type {targetType.Name}"); + } + } + + private static Result CreateStringOrEnumConstant(string value, Type targetType) + { + var actualType = GetNonNullableType(targetType); + + if (actualType.IsEnum) + { + return ConvertStringToEnum(value, actualType, targetType); + } + + return Result.Ok(Expression.Constant(value, targetType)); + } + + private static Result ConvertStringToEnum(string value, Type actualType, Type targetType) + { + try + { + var enumValue = Enum.Parse(actualType, value, true); + + return Result.Ok(Expression.Constant(enumValue, targetType)); + } + catch (Exception) + { + foreach (var field in actualType.GetFields(BindingFlags.Public | BindingFlags.Static)) + { + var memberNameAttribute = field.GetCustomAttribute(); + + if (memberNameAttribute != null && memberNameAttribute.Name.Equals(value, StringComparison.Ordinal)) + { + return Result.Ok(Expression.Constant(field.GetValue(null), targetType)); + } + } + + return Result.Fail($"Value '{value}' is not a valid member of enum {actualType.Name}"); + } + } + + private static Type GetNonNullableType(Type type) + { + return Nullable.GetUnderlyingType(type) ?? type; + } +} diff --git a/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs b/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs new file mode 100644 index 0000000..5f169f8 --- /dev/null +++ b/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using FluentResults; + +public static class OrderByEvaluator +{ + public static Result> Evaluate(IEnumerable statements, ParameterExpression parameterExpression, IQueryable queryable, PropertyMappingTree propertyMappingTree) + { + var isAlreadyOrdered = false; + + foreach (var statement in statements) + { + if (!propertyMappingTree.TryGetProperty(statement.TokenLiteral(), out var propertyNode)) + { + return Result.Fail(new Error($"Invalid property '{statement.TokenLiteral()}' within orderby")); + } + + var property = Expression.Property(parameterExpression, propertyNode.ActualPropertyName); + var lambda = Expression.Lambda(property, parameterExpression); + + if (isAlreadyOrdered) + { + if (statement.Direction == OrderByDirection.Ascending) + { + var method = GenericMethodOf(_ => Queryable.ThenBy(default, default)).MakeGenericMethod(parameterExpression.Type, lambda.Body.Type); + + queryable = (IQueryable)method.Invoke(null, new object[] { queryable, lambda }); + } + else if (statement.Direction == OrderByDirection.Descending) + { + var method = GenericMethodOf(_ => Queryable.ThenByDescending(default, default)).MakeGenericMethod(parameterExpression.Type, lambda.Body.Type); + + queryable = (IQueryable)method.Invoke(null, new object[] { queryable, lambda }); + } + } + else + { + if (statement.Direction == OrderByDirection.Ascending) + { + var method = GenericMethodOf(_ => Queryable.OrderBy(default, default)).MakeGenericMethod(parameterExpression.Type, lambda.Body.Type); + + queryable = (IQueryable)method.Invoke(null, new object[] { queryable, lambda }); + + isAlreadyOrdered = true; + } + else if (statement.Direction == OrderByDirection.Descending) + { + var method = GenericMethodOf(_ => Queryable.OrderByDescending(default, default)).MakeGenericMethod(parameterExpression.Type, lambda.Body.Type); + + queryable = (IQueryable)method.Invoke(null, new object[] { queryable, lambda }); + + isAlreadyOrdered = true; + } + } + } + + return Result.Ok(queryable); + } + + private static MethodInfo GenericMethodOf(Expression> expression) + { + return GenericMethodOf(expression as Expression); + } + + private static MethodInfo GenericMethodOf(Expression expression) + { + LambdaExpression lambdaExpression = (LambdaExpression)expression; + + return ((MethodCallExpression)lambdaExpression.Body).Method.GetGenericMethodDefinition(); + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Extensions/QueryableExtension.cs b/src/GoatQuery/src/Extensions/QueryableExtension.cs new file mode 100644 index 0000000..0a06f55 --- /dev/null +++ b/src/GoatQuery/src/Extensions/QueryableExtension.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using FluentResults; + +public static class QueryableExtension +{ + + public static Result> Apply(this IQueryable queryable, Query query, ISearchBinder searchBinder = null, QueryOptions options = null) + { + if (query.Top > options?.MaxTop) + { + return Result.Fail("The value supplied for the query parameter 'Top' was greater than the maximum top allowed for this resource"); + } + + var type = typeof(T); + + var maxDepth = options?.MaxPropertyMappingDepth ?? new QueryOptions().MaxPropertyMappingDepth; + var propertyMappingTree = PropertyMappingTreeBuilder.BuildMappingTree(maxDepth); + + // Filter + if (!string.IsNullOrEmpty(query.Filter)) + { + var lexer = new QueryLexer(query.Filter); + var parser = new QueryParser(lexer); + var statement = parser.ParseFilter(); + if (statement.IsFailed) + { + return Result.Fail(statement.Errors); + } + + ParameterExpression parameter = Expression.Parameter(type); + + var expression = FilterEvaluator.Evaluate(statement.Value.Expression, parameter, propertyMappingTree); + if (expression.IsFailed) + { + return Result.Fail(expression.Errors); + } + + var exp = Expression.Lambda>(expression.Value, parameter); + + queryable = queryable.Where(exp); + } + + // Search + if (searchBinder != null && !string.IsNullOrEmpty(query.Search)) + { + var searchExpression = searchBinder.Bind(query.Search); + + if (searchExpression is null) + { + return Result.Fail("Cannot parse search binder expression"); + } + + queryable = queryable.Where(searchExpression); + } + + // Count + int? count = null; + + if (query.Count ?? false) + { + count = queryable.Count(); + } + + // Order by + if (!string.IsNullOrEmpty(query.OrderBy)) + { + var lexer = new QueryLexer(query.OrderBy); + var parser = new QueryParser(lexer); + + var statements = parser.ParseOrderBy(); + + var parameter = Expression.Parameter(type); + + var orderByQuery = OrderByEvaluator.Evaluate(statements, parameter, queryable, propertyMappingTree); + if (orderByQuery.IsFailed) + { + return Result.Fail(orderByQuery.Errors); + } + + queryable = orderByQuery.Value; + } + + // Skip + if (query.Skip > 0) + { + queryable = queryable.Skip(query.Skip ?? 0); + } + + // Top + if (query.Top > 0) + { + queryable = queryable.Take(query.Top ?? 0); + } + + if (query.Top <= 0 && options?.MaxTop != null) + { + queryable = queryable.Take(options.MaxTop); + } + + return Result.Ok(new QueryResult(queryable, count)); + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Extensions/StringExtension.cs b/src/GoatQuery/src/Extensions/StringExtension.cs new file mode 100644 index 0000000..ec4cf46 --- /dev/null +++ b/src/GoatQuery/src/Extensions/StringExtension.cs @@ -0,0 +1,10 @@ +using System; +using System.Linq; + +public static class StringExtension +{ + public static bool In(this string str, params string[] strings) + { + return strings.Contains(str, StringComparer.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/src/goatquery-dotnet.csproj b/src/GoatQuery/src/GoatQuery.csproj similarity index 50% rename from src/goatquery-dotnet.csproj rename to src/GoatQuery/src/GoatQuery.csproj index 19d643c..1b3d929 100644 --- a/src/goatquery-dotnet.csproj +++ b/src/GoatQuery/src/GoatQuery.csproj @@ -1,28 +1,27 @@ - net8.0 - goatquery-dotnet - enable - enable + netstandard2.0;netstandard2.1 + GoatQuery + 8.0 GoatQuery README.md - https://github.com/goatquery + https://github.com/goatquery/src/GoatQuery https://github.com/goatquery/goatquery-dotnet git .NET Library to support paging, ordering, filtering, searching and selecting in REST APIs. MIT + true - - - + - + + diff --git a/src/ISearchBinder.cs b/src/GoatQuery/src/ISearchBinder.cs similarity index 89% rename from src/ISearchBinder.cs rename to src/GoatQuery/src/ISearchBinder.cs index 892f6a4..eb78a2e 100644 --- a/src/ISearchBinder.cs +++ b/src/GoatQuery/src/ISearchBinder.cs @@ -1,6 +1,7 @@ +using System; using System.Linq.Expressions; public interface ISearchBinder { Expression> Bind(string searchTerm); -} +} \ No newline at end of file diff --git a/src/GoatQuery/src/Lexer/Lexer.cs b/src/GoatQuery/src/Lexer/Lexer.cs new file mode 100644 index 0000000..97a552a --- /dev/null +++ b/src/GoatQuery/src/Lexer/Lexer.cs @@ -0,0 +1,224 @@ +using System; +using System.Globalization; + +public sealed class QueryLexer +{ + private readonly string _input; + private int _position { get; set; } + private int _readPosition { get; set; } + private char _character { get; set; } + + public QueryLexer(string input) + { + _input = input; + + ReadCharacter(); + } + + private void ReadCharacter() + { + if (_readPosition >= _input.Length) + { + _character = char.MinValue; + } + else + { + _character = _input[_readPosition]; + } + + _position = _readPosition; + _readPosition++; + } + + public Token NextToken() + { + var token = new Token(TokenType.ILLEGAL, _character); + + SkipWhitespace(); + + switch (_character) + { + case char.MinValue: + token.Literal = ""; + token.Type = TokenType.EOF; + break; + case '(': + token = new Token(TokenType.LPAREN, _character); + break; + case ')': + token = new Token(TokenType.RPAREN, _character); + break; + case '/': + token = new Token(TokenType.SLASH, _character); + break; + case ':': + token = new Token(TokenType.COLON, _character); + break; + case '\'': + token.Type = TokenType.STRING; + token.Literal = ReadString(); + break; + case var c when char.IsDigit(c): + token.Literal = ReadNumericOrDateTime(); + token.Type = DetermineNumericTokenType(token.Literal); + return token; + default: + if (IsLetter(_character)) + { + token.Literal = ReadIdentifier(); + token.Type = ClassifyIdentifier(token.Literal); + return token; + } + break; + } + + ReadCharacter(); + + return token; + } + + private bool IsDate(string value) + { + return DateTime.TryParseExact(value, new[] { "yyyy-MM-dd" }, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out _); + } + + private bool IsDateTime(string value) + { + return DateTime.TryParse(value, out _); + } + + private bool IsGuid(string value) + { + return Guid.TryParse(value, out _); + } + + private string ReadIdentifier() + { + var startPosition = _position; + + while (IsIdentifierCharacter()) + { + ReadCharacter(); + } + + return _input.Substring(startPosition, _position - startPosition); + } + + private bool IsIdentifierCharacter() + { + return IsLetter(_character) || IsDigit(_character) || + _character == '-' || _character == '.'; + } + + private string ReadNumericOrDateTime() + { + var startPosition = _position; + + // Read digits, and datetime/numeric characters (colons, dashes, dots, etc.) + while (_character != char.MinValue && IsNumericOrDateTimeCharacter()) + { + ReadCharacter(); + } + + return _input.Substring(startPosition, _position - startPosition); + } + + private bool IsNumericOrDateTimeCharacter() + { + return IsDigit(_character) || + _character == '-' || + _character == ':' || + _character == '.' || + _character == 'T' || // DateTime separator + _character == 'Z' || // UTC indicator + _character == '+' || // Timezone offset + _character == 'f' || _character == 'F' || // Float suffix + _character == 'm' || _character == 'M' || // Decimal suffix + _character == 'd' || _character == 'D' || // Double suffix + _character == 'l' || _character == 'L' || // Long suffix + ('a' <= _character && _character <= 'f') || // GUID hex chars + ('A' <= _character && _character <= 'F'); // GUID hex chars (uppercase) + } + + private TokenType ClassifyIdentifier(string literal) + { + if (IsGuid(literal)) + return TokenType.GUID; + + if (literal.Equals(Keywords.Null, StringComparison.OrdinalIgnoreCase)) + return TokenType.NULL; + + if (literal.Equals(Keywords.True, StringComparison.OrdinalIgnoreCase) || + literal.Equals(Keywords.False, StringComparison.OrdinalIgnoreCase)) + return TokenType.BOOLEAN; + + return TokenType.IDENT; + } + + private TokenType DetermineNumericTokenType(string literal) + { + // Check for GUID first (may contain numbers and dashes) + if (IsGuid(literal)) + return TokenType.GUID; + + // Check for date patterns before datetime (more specific first) + if (IsDate(literal)) + return TokenType.DATE; + + // Check for datetime patterns (since they contain colons) + if (IsDateTime(literal)) + return TokenType.DATETIME; + + // Check numeric suffixes + if (literal.EndsWith("f", StringComparison.OrdinalIgnoreCase)) + return TokenType.FLOAT; + + if (literal.EndsWith("m", StringComparison.OrdinalIgnoreCase)) + return TokenType.DECIMAL; + + if (literal.EndsWith("d", StringComparison.OrdinalIgnoreCase)) + return TokenType.DOUBLE; + + if (literal.EndsWith("l", StringComparison.OrdinalIgnoreCase)) + return TokenType.INT; // Our existing INT type for simplicity + + // Default to integer + return TokenType.INT; + } + + + + private bool IsLetter(char ch) + { + return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_'; + } + + private bool IsDigit(char ch) + { + return '0' <= ch && ch <= '9'; + } + + private void SkipWhitespace() + { + while (_character == ' ' || _character == '\t' || _character == '\n' || _character == '\r') + { + ReadCharacter(); + } + } + + private string ReadString() + { + var currentPosition = _position + 1; + + while (true) + { + ReadCharacter(); + if (_character == '\'' || _character == 0) + { + break; + } + } + + return _input.Substring(currentPosition, _position - currentPosition); + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs new file mode 100644 index 0000000..984665b --- /dev/null +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using FluentResults; + +public sealed class QueryParser +{ + private readonly QueryLexer _lexer; + private Token _currentToken { get; set; } = default; + private Token _peekToken { get; set; } = default; + + public QueryParser(QueryLexer lexer) + { + _lexer = lexer; + + NextToken(); + NextToken(); + } + + private void NextToken() + { + _currentToken = _peekToken; + _peekToken = _lexer.NextToken(); + } + + public IEnumerable ParseOrderBy() + { + var statements = new List(); + + while (!CurrentTokenIs(TokenType.EOF)) + { + if (!CurrentTokenIs(TokenType.IDENT)) + { + NextToken(); + continue; + } + + var statement = ParseOrderByStatement(); + if (statement != null) + { + statements.Add(statement); + } + + NextToken(); + } + + return statements; + } + + private OrderByStatement ParseOrderByStatement() + { + var statement = new OrderByStatement(_currentToken, OrderByDirection.Ascending); + + if (PeekIdentifierIs(Keywords.Desc)) + { + statement.Direction = OrderByDirection.Descending; + } + + NextToken(); + + return statement; + } + + public Result ParseFilter() + { + var expression = ParseExpression(); + if (expression.IsFailed) + { + return Result.Fail(expression.Errors); + } + + var statement = new ExpressionStatement(_currentToken) + { + Expression = expression.Value + }; + + return statement; + } + + private Result ParseExpression(int precedence = 0) + { + var left = CurrentTokenIs(TokenType.LPAREN) ? ParseGroupedExpression() : ParseFilterStatement(); + if (left.IsFailed) + { + return left; + } + + NextToken(); + + while (!CurrentTokenIs(TokenType.EOF) && precedence < GetPrecedence(_currentToken.Type)) + { + if (CurrentIdentifierIs(Keywords.And) || CurrentIdentifierIs(Keywords.Or)) + { + left = new InfixExpression(_currentToken, left.Value, _currentToken.Literal); + var currentPrecedence = GetPrecedence(_currentToken.Type); + + NextToken(); + + var right = ParseExpression(currentPrecedence); + if (right.IsFailed) + { + return right; + } + left.Value.Right = right.Value; + } + else + { + break; + } + } + + return left; + } + + private Result ParseGroupedExpression() + { + NextToken(); + + var exp = ParseExpression(); + + if (!CurrentTokenIs(TokenType.RPAREN)) + { + return Result.Fail("Expected closing parenthesis"); + } + + return exp; + } + + private Result ParseFilterStatement() + { + QueryExpression leftExpression = null; + + if (_peekToken.Type == TokenType.SLASH) + { + // We are filtering by a property on an object or lambda expression + var segments = new List { _currentToken.Literal }; + var startToken = _currentToken; + + while (_peekToken.Type == TokenType.SLASH) + { + NextToken(); // consume current identifier + NextToken(); // consume slash + + if (_currentToken.Type != TokenType.IDENT) + { + return Result.Fail("Expected identifier after '/' in property path"); + } + + // Check if this is a lambda function (any/all followed by parenthesis) + if ((_currentToken.Literal.Equals(Keywords.Any, StringComparison.OrdinalIgnoreCase) || + _currentToken.Literal.Equals(Keywords.All, StringComparison.OrdinalIgnoreCase)) && + _peekToken.Type == TokenType.LPAREN) + { + var lambdaResult = ParseLambdaExpression(new PropertyPath(startToken, segments), _currentToken.Literal); + if (lambdaResult.IsFailed) + { + return Result.Fail(lambdaResult.Errors); + } + leftExpression = lambdaResult.Value; + break; + } + + segments.Add(_currentToken.Literal); + } + + // If we didn't parse a lambda, create a regular property path + if (leftExpression == null) + { + leftExpression = new PropertyPath(startToken, segments); + } + } + else + { + leftExpression = new Identifier(_currentToken, _currentToken.Literal); + } + + // Lambda expressions don't need an operator after them - they are complete expressions + if (leftExpression is QueryLambdaExpression) + { + return new InfixExpression(_currentToken, leftExpression, string.Empty); + } + + if (!PeekIdentifierIn(Keywords.Eq, Keywords.Ne, Keywords.Contains, Keywords.Lt, Keywords.Lte, Keywords.Gt, Keywords.Gte)) + { + return Result.Fail("Invalid conjunction within filter"); + } + + NextToken(); + + var statement = new InfixExpression(_currentToken, leftExpression, _currentToken.Literal); + + if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID, TokenType.DATETIME, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE, TokenType.DATE, TokenType.NULL, TokenType.BOOLEAN)) + { + return Result.Fail("Invalid value type within filter"); + } + + NextToken(); + + if (statement.Operator.Equals(Keywords.Contains) && _currentToken.Type != TokenType.STRING) + { + return Result.Fail("Value must be a string when using 'contains' operand"); + } + + if (statement.Operator.Equals(Keywords.Contains) && _currentToken.Type == TokenType.NULL) + { + return Result.Fail("Cannot use 'contains' operand with null value"); + } + + if (statement.Operator.In(Keywords.Lt, Keywords.Lte, Keywords.Gt, Keywords.Gte) && !CurrentTokenIn(TokenType.INT, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE, TokenType.DATETIME, TokenType.DATE)) + { + return Result.Fail($"Value must be a numeric or date type when using '{statement.Operator}' operand"); + } + + statement.Right = ParseLiteral(_currentToken); + + return statement; + } + + private Result ParseLambdaExpression(QueryExpression property, string function) + { + var startToken = _currentToken; + + // Consume opening parenthesis + if (!PeekTokenIs(TokenType.LPAREN)) + { + return Result.Fail("Expected '(' after lambda function"); + } + NextToken(); // consume function name (any/all) + NextToken(); // consume '(' + + // Parse parameter name + if (!CurrentTokenIs(TokenType.IDENT)) + { + return Result.Fail("Expected parameter name in lambda expression"); + } + var parameter = _currentToken.Literal; + NextToken(); + + // Parse colon + if (!CurrentTokenIs(TokenType.COLON)) + { + return Result.Fail("Expected ':' after lambda parameter"); + } + NextToken(); + + // Parse lambda body (recursive expression parsing) + var bodyResult = ParseExpression(); + if (bodyResult.IsFailed) + { + return Result.Fail(bodyResult.Errors); + } + + // Expect closing parenthesis + if (!CurrentTokenIs(TokenType.RPAREN)) + { + return Result.Fail("Expected ')' to close lambda expression"); + } + + var lambda = new QueryLambdaExpression(startToken, property, function, parameter) + { + Body = bodyResult.Value + }; + + return lambda; + } + + private QueryExpression ParseLiteral(Token token) + { + return token.Type switch + { + TokenType.GUID => Guid.TryParse(token.Literal, out var guidValue) + ? new GuidLiteral(token, guidValue) + : null, + TokenType.STRING => new StringLiteral(token, token.Literal), + TokenType.INT => int.TryParse(token.Literal, out var intValue) + ? new IntegerLiteral(token, intValue) + : null, + TokenType.FLOAT => float.TryParse(token.Literal.TrimEnd('f'), out var floatValue) + ? new FloatLiteral(token, floatValue) + : null, + TokenType.DECIMAL => decimal.TryParse(token.Literal.TrimEnd('m'), out var decimalValue) + ? new DecimalLiteral(token, decimalValue) + : null, + TokenType.DOUBLE => double.TryParse(token.Literal.TrimEnd('d'), out var doubleValue) + ? new DoubleLiteral(token, doubleValue) + : null, + TokenType.DATETIME => DateTime.TryParse(token.Literal, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var dateTimeValue) + ? new DateTimeLiteral(token, dateTimeValue) + : null, + TokenType.DATE => DateTime.TryParse(token.Literal, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var dateValue) + ? new DateLiteral(token, dateValue) + : null, + TokenType.NULL => new NullLiteral(token), + TokenType.BOOLEAN => bool.TryParse(token.Literal, out var boolValue) + ? new BooleanLiteral(token, boolValue) + : null, + _ => null + }; + } + + private bool PeekTokenIs(TokenType tokenType) + { + return _peekToken.Type == tokenType; + } + + private int GetPrecedence(TokenType tokenType) + { + switch (tokenType) + { + case TokenType.IDENT when CurrentIdentifierIs(Keywords.And): + return 2; + case TokenType.IDENT when CurrentIdentifierIs(Keywords.Or): + return 1; + default: + return 0; + } + } + + private bool CurrentTokenIs(TokenType token) + { + return _currentToken.Type == token; + } + + private bool CurrentTokenIn(params TokenType[] tokens) + { + return tokens.Contains(_currentToken.Type); + } + + private bool PeekTokenIn(params TokenType[] tokens) + { + return tokens.Contains(_peekToken.Type); + } + + private bool PeekIdentifierIs(string identifier) + { + return _peekToken.Type == TokenType.IDENT && _peekToken.Literal.Equals(identifier, StringComparison.OrdinalIgnoreCase); + } + + private bool PeekIdentifierIn(params string[] identifier) + { + return _peekToken.Type == TokenType.IDENT && identifier.Contains(_peekToken.Literal, StringComparer.OrdinalIgnoreCase); + } + + private bool CurrentIdentifierIs(string identifier) + { + return _currentToken.Type == TokenType.IDENT && _currentToken.Literal.Equals(identifier, StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Query.cs b/src/GoatQuery/src/Query.cs new file mode 100644 index 0000000..3e71e88 --- /dev/null +++ b/src/GoatQuery/src/Query.cs @@ -0,0 +1,9 @@ +public sealed class Query +{ + public int? Top { get; set; } + public int? Skip { get; set; } + public bool? Count { get; set; } + public string OrderBy { get; set; } = string.Empty; + public string Search { get; set; } = string.Empty; + public string Filter { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/GoatQuery/src/QueryOptions.cs b/src/GoatQuery/src/QueryOptions.cs new file mode 100644 index 0000000..24c195b --- /dev/null +++ b/src/GoatQuery/src/QueryOptions.cs @@ -0,0 +1,5 @@ +public sealed class QueryOptions +{ + public int MaxTop { get; set; } + public int MaxPropertyMappingDepth { get; set; } = 5; +} \ No newline at end of file diff --git a/src/GoatQuery/src/QueryResult.cs b/src/GoatQuery/src/QueryResult.cs new file mode 100644 index 0000000..07da4f2 --- /dev/null +++ b/src/GoatQuery/src/QueryResult.cs @@ -0,0 +1,13 @@ +using System.Linq; + +public sealed class QueryResult +{ + public QueryResult(IQueryable query, int? count) + { + Query = query; + Count = count; + } + + public IQueryable Query { get; set; } + public int? Count { get; set; } +} \ No newline at end of file diff --git a/src/Responses/PagedResponse.cs b/src/GoatQuery/src/Responses/PagedResponse.cs similarity index 84% rename from src/Responses/PagedResponse.cs rename to src/GoatQuery/src/Responses/PagedResponse.cs index 3694492..51ce711 100644 --- a/src/Responses/PagedResponse.cs +++ b/src/GoatQuery/src/Responses/PagedResponse.cs @@ -1,6 +1,7 @@ +using System.Collections.Generic; using System.Text.Json.Serialization; -public class PagedResponse +public sealed class PagedResponse { public PagedResponse(IEnumerable data, int? count = null) { diff --git a/src/GoatQuery/src/Token/Token.cs b/src/GoatQuery/src/Token/Token.cs new file mode 100644 index 0000000..8e07671 --- /dev/null +++ b/src/GoatQuery/src/Token/Token.cs @@ -0,0 +1,58 @@ +public enum TokenType +{ + EOF = 1, + ILLEGAL, + IDENT, + STRING, + INT, + DECIMAL, + FLOAT, + DOUBLE, + GUID, + DATETIME, + DATE, + NULL, + BOOLEAN, + LPAREN, + RPAREN, + SLASH, + COLON +} + +public static class Keywords +{ + internal const string Asc = "asc"; + internal const string Desc = "desc"; + internal const string Eq = "eq"; + internal const string Ne = "ne"; + internal const string Contains = "contains"; + internal const string Lt = "lt"; + internal const string Lte = "lte"; + internal const string Gt = "gt"; + internal const string Gte = "gte"; + internal const string And = "and"; + internal const string Or = "or"; + internal const string Null = "null"; + internal const string True = "true"; + internal const string False = "false"; + internal const string Any = "any"; + internal const string All = "all"; +} + +public sealed class Token +{ + public TokenType Type { get; set; } + public string Literal { get; set; } = string.Empty; + + public Token(TokenType type, char literal) + { + Type = type; + Literal = literal.ToString(); + } + + public Token(TokenType type, string literal) + { + Type = type; + Literal = literal; + } +} \ No newline at end of file diff --git a/src/GoatQuery/src/Utilities/PropertyMappingTree.cs b/src/GoatQuery/src/Utilities/PropertyMappingTree.cs new file mode 100644 index 0000000..721d80a --- /dev/null +++ b/src/GoatQuery/src/Utilities/PropertyMappingTree.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json.Serialization; + +public sealed class PropertyMappingTree +{ + public IReadOnlyDictionary Properties { get; } + public Type SourceType { get; } + + internal PropertyMappingTree(Type sourceType) + { + SourceType = sourceType ?? throw new ArgumentNullException(nameof(sourceType)); + Properties = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public bool TryGetProperty(string jsonPropertyName, out PropertyMappingNode node) + { + if (string.IsNullOrEmpty(jsonPropertyName)) + { + node = null; + return false; + } + + return ((Dictionary)Properties).TryGetValue(jsonPropertyName, out node); + } + + internal void AddProperty(string jsonPropertyName, PropertyMappingNode node) + { + ((Dictionary)Properties)[jsonPropertyName] = node; + } +} + +public sealed class PropertyMappingNode +{ + public string JsonPropertyName { get; } + public string ActualPropertyName { get; } + public Type PropertyType { get; } + public PropertyMappingTree NestedMapping { get; internal set; } + public bool IsCollection { get; } + public Type CollectionElementType { get; } + + internal PropertyMappingNode( + string jsonPropertyName, + string actualPropertyName, + Type propertyType, + bool isCollection = false, + Type collectionElementType = null) + { + JsonPropertyName = jsonPropertyName ?? throw new ArgumentNullException(nameof(jsonPropertyName)); + ActualPropertyName = actualPropertyName ?? throw new ArgumentNullException(nameof(actualPropertyName)); + PropertyType = propertyType ?? throw new ArgumentNullException(nameof(propertyType)); + IsCollection = isCollection; + CollectionElementType = collectionElementType; + } + + public bool HasNestedMapping => NestedMapping != null; +} + +public static class PropertyMappingTreeBuilder +{ + private static readonly HashSet PrimitiveTypes = new HashSet + { + typeof(string), typeof(decimal), typeof(DateTime), + typeof(DateTimeOffset), typeof(TimeSpan), typeof(Guid) + }; + + public static PropertyMappingTree BuildMappingTree(int maxDepth) + { + return BuildMappingTree(typeof(T), maxDepth); + } + + public static PropertyMappingTree BuildMappingTree(Type type, int maxDepth) + { + if (type == null) throw new ArgumentNullException(nameof(type)); + if (maxDepth <= 0) throw new ArgumentOutOfRangeException(nameof(maxDepth), "Max depth must be greater than 0"); + + return BuildMappingTreeInternal(type, maxDepth, currentDepth: 0, new List()); + } + + private static PropertyMappingTree BuildMappingTreeInternal(Type type, int maxDepth, int currentDepth, List typePath) + { + var tree = new PropertyMappingTree(type); + + if (currentDepth >= maxDepth) + return tree; + + typePath.Add(type); + try + { + BuildPropertiesForTree(tree, type, maxDepth, currentDepth, typePath); + } + finally + { + typePath.RemoveAt(typePath.Count - 1); + } + + return tree; + } + + private static void BuildPropertiesForTree(PropertyMappingTree tree, Type type, int maxDepth, int currentDepth, List typePath) + { + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var property in properties) + { + var node = CreatePropertyNode(property); + var typeToProcess = node.CollectionElementType ?? node.PropertyType; + + if (ShouldCreateNestedMapping(typeToProcess) && CanNavigateToType(typeToProcess, typePath, maxDepth)) + { + node.NestedMapping = BuildMappingTreeInternal( + typeToProcess, + maxDepth, + currentDepth + 1, + new List(typePath)); + } + + tree.AddProperty(node.JsonPropertyName, node); + } + } + + private static PropertyMappingNode CreatePropertyNode(PropertyInfo property) + { + var jsonPropertyName = GetJsonPropertyName(property); + var (isCollection, elementType) = GetCollectionInfo(property.PropertyType); + + return new PropertyMappingNode( + jsonPropertyName, + property.Name, + property.PropertyType, + isCollection, + elementType); + } + + private static bool CanNavigateToType(Type type, List typePath, int maxDepth) + { + var typeCount = typePath.Count(t => t == type); + return typeCount < maxDepth; + } + + private static string GetJsonPropertyName(PropertyInfo property) + { + return property.GetCustomAttribute()?.Name ?? property.Name; + } + + private static (bool IsCollection, Type ElementType) GetCollectionInfo(Type type) + { + if (type.IsArray) + return (true, type.GetElementType()); + + if (type.IsGenericType && type.GetGenericArguments().Length == 1) + { + var elementType = type.GetGenericArguments()[0]; + var enumerableType = typeof(IEnumerable<>).MakeGenericType(elementType); + + if (enumerableType.IsAssignableFrom(type)) + return (true, elementType); + } + + return (false, null); + } + + private static bool ShouldCreateNestedMapping(Type type) + { + return !IsPrimitiveType(type) && + type != typeof(object) && + !type.IsAbstract && + !type.IsInterface; + } + + private static bool IsPrimitiveType(Type type) + { + if (type.IsPrimitive || PrimitiveTypes.Contains(type)) + return true; + + var underlyingType = Nullable.GetUnderlyingType(type); + return underlyingType != null && (underlyingType.IsPrimitive || PrimitiveTypes.Contains(underlyingType)); + } +} \ No newline at end of file diff --git a/src/GoatQuery/tests/Count/CountTest.cs b/src/GoatQuery/tests/Count/CountTest.cs new file mode 100644 index 0000000..fc4806e --- /dev/null +++ b/src/GoatQuery/tests/Count/CountTest.cs @@ -0,0 +1,49 @@ +using Xunit; + +public sealed class CountTest +{ + [Fact] + public void Test_CountWithTrue() + { + var users = new List{ + new User { Age = 1, Firstname = "Harry" }, + new User { Age = 1, Firstname = "Jane" }, + new User { Age = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Count = true + }; + + var result = users.Apply(query); + + Assert.Equal(6, result.Value.Count); + Assert.Equal(6, result.Value.Query.Count()); + } + + [Fact] + public void Test_CountWithFalse() + { + var users = new List{ + new User { Age = 1, Firstname = "Harry" }, + new User { Age = 1, Firstname = "Jane" }, + new User { Age = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Count = false + }; + + var result = users.Apply(query); + + Assert.Null(result.Value.Count); + } +} \ No newline at end of file diff --git a/src/GoatQuery/tests/DatabaseTestFixture.cs b/src/GoatQuery/tests/DatabaseTestFixture.cs new file mode 100644 index 0000000..132e816 --- /dev/null +++ b/src/GoatQuery/tests/DatabaseTestFixture.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; +using Testcontainers.PostgreSql; +using Xunit; + +public class DatabaseTestFixture : IAsyncLifetime +{ + private PostgreSqlContainer? _postgresContainer; + public TestDbContext DbContext { get; set; } = null!; + + public async Task InitializeAsync() + { + _postgresContainer = new PostgreSqlBuilder() + .WithImage("postgres:18-alpine") + .Build(); + + await _postgresContainer.StartAsync(); + + var connectionString = _postgresContainer.GetConnectionString(); + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql(connectionString); + + DbContext = new TestDbContext(optionsBuilder.Options); + + await DbContext.Database.EnsureCreatedAsync(); + + await SeedTestData(); + } + + private async Task SeedTestData() + { + var users = TestData.Users.Values.ToList(); + + await DbContext.Users.AddRangeAsync(users); + await DbContext.SaveChangesAsync(); + } + + public async Task DisposeAsync() + { + if (DbContext != null) + { + await DbContext.DisposeAsync(); + } + + if (_postgresContainer != null) + { + await _postgresContainer.StopAsync(); + await _postgresContainer.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/src/GoatQuery/tests/Filter/FilterLexerTest.cs b/src/GoatQuery/tests/Filter/FilterLexerTest.cs new file mode 100644 index 0000000..43c97b5 --- /dev/null +++ b/src/GoatQuery/tests/Filter/FilterLexerTest.cs @@ -0,0 +1,645 @@ +using Xunit; + +public sealed class FilterLexerTest +{ + public static IEnumerable Parameters() + { + yield return new object[] + { + "Name eq 'john'", + new KeyValuePair[] + { + new (TokenType.IDENT, "Name"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "john"), + } + }; + + yield return new object[] + { + "Id eq 1", + new KeyValuePair[] + { + new (TokenType.IDENT, "Id"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "1"), + } + }; + + yield return new object[] + { + "Name eq 'john' and Id eq 1", + new KeyValuePair[] + { + new (TokenType.IDENT, "Name"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "john"), + new (TokenType.IDENT, "and"), + new (TokenType.IDENT, "Id"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "1"), + } + }; + + yield return new object[] + { + "eq eq 1", + new KeyValuePair[] + { + new (TokenType.IDENT, "eq"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "1"), + } + }; + + yield return new object[] + { + "Name eq 'john' or Id eq 1", + new KeyValuePair[] + { + new (TokenType.IDENT, "Name"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "john"), + new (TokenType.IDENT, "or"), + new (TokenType.IDENT, "Id"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "1"), + } + }; + + yield return new object[] + { + "Id eq 1 and Name eq 'John' or Id eq 2", + new KeyValuePair[] + { + new (TokenType.IDENT, "Id"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "1"), + new (TokenType.IDENT, "and"), + new (TokenType.IDENT, "Name"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "John"), + new (TokenType.IDENT, "or"), + new (TokenType.IDENT, "Id"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "2"), + } + }; + + yield return new object[] + { + "Id eq 1 or Name eq 'John' or Id eq 2", + new KeyValuePair[] + { + new (TokenType.IDENT, "Id"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "1"), + new (TokenType.IDENT, "or"), + new (TokenType.IDENT, "Name"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "John"), + new (TokenType.IDENT, "or"), + new (TokenType.IDENT, "Id"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "2"), + } + }; + + yield return new object[] + { + "Id ne 1", + new KeyValuePair[] + { + new (TokenType.IDENT, "Id"), + new (TokenType.IDENT, "ne"), + new (TokenType.INT, "1"), + } + }; + + yield return new object[] + { + "Name contains 'John'", + new KeyValuePair[] + { + new (TokenType.IDENT, "Name"), + new (TokenType.IDENT, "contains"), + new (TokenType.STRING, "John"), + } + }; + + yield return new object[] + { + "(Id eq 1 or Id eq 2) and Name eq 'John'", + new KeyValuePair[] + { + new (TokenType.LPAREN, "("), + new (TokenType.IDENT, "Id"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "1"), + new (TokenType.IDENT, "or"), + new (TokenType.IDENT, "Id"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "2"), + new (TokenType.RPAREN, ")"), + new (TokenType.IDENT, "and"), + new (TokenType.IDENT, "Name"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "John") + } + }; + + yield return new object[] + { + "address1Line eq '1 Main Street'", + new KeyValuePair[] + { + new (TokenType.IDENT, "address1Line"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "1 Main Street"), + } + }; + + yield return new object[] + { + "addASCress1Line contains '10 Test Av'", + new KeyValuePair[] + { + new (TokenType.IDENT, "addASCress1Line"), + new (TokenType.IDENT, "contains"), + new (TokenType.STRING, "10 Test Av"), + } + }; + + yield return new object[] + { + "id eq e4c7772b-8947-4e46-98ed-644b417d2a08", + new KeyValuePair[] + { + new (TokenType.IDENT, "id"), + new (TokenType.IDENT, "eq"), + new (TokenType.GUID, "e4c7772b-8947-4e46-98ed-644b417d2a08"), + } + }; + + yield return new object[] + { + "id eq 10m", + new KeyValuePair[] + { + new (TokenType.IDENT, "id"), + new (TokenType.IDENT, "eq"), + new (TokenType.DECIMAL, "10m"), + } + }; + + yield return new object[] + { + "id eq 10.50m", + new KeyValuePair[] + { + new (TokenType.IDENT, "id"), + new (TokenType.IDENT, "eq"), + new (TokenType.DECIMAL, "10.50m"), + } + }; + + yield return new object[] + { + "id eq 10.50M", + new KeyValuePair[] + { + new (TokenType.IDENT, "id"), + new (TokenType.IDENT, "eq"), + new (TokenType.DECIMAL, "10.50M"), + } + }; + + yield return new object[] + { + "id eq 10f", + new KeyValuePair[] + { + new (TokenType.IDENT, "id"), + new (TokenType.IDENT, "eq"), + new (TokenType.FLOAT, "10f"), + } + }; + + yield return new object[] + { + "id ne 0.1121563052701180f", + new KeyValuePair[] + { + new (TokenType.IDENT, "id"), + new (TokenType.IDENT, "ne"), + new (TokenType.FLOAT, "0.1121563052701180f"), + } + }; + + yield return new object[] + { + "id ne 0.1121563052701180F", + new KeyValuePair[] + { + new (TokenType.IDENT, "id"), + new (TokenType.IDENT, "ne"), + new (TokenType.FLOAT, "0.1121563052701180F"), + } + }; + + yield return new object[] + { + "id eq 10d", + new KeyValuePair[] + { + new (TokenType.IDENT, "id"), + new (TokenType.IDENT, "eq"), + new (TokenType.DOUBLE, "10d"), + } + }; + + yield return new object[] + { + "id eq 3.14159265359d", + new KeyValuePair[] + { + new (TokenType.IDENT, "id"), + new (TokenType.IDENT, "eq"), + new (TokenType.DOUBLE, "3.14159265359d"), + } + }; + + yield return new object[] + { + "id eq 3.14159265359D", + new KeyValuePair[] + { + new (TokenType.IDENT, "id"), + new (TokenType.IDENT, "eq"), + new (TokenType.DOUBLE, "3.14159265359D"), + } + }; + + yield return new object[] + { + "age lt 50", + new KeyValuePair[] + { + new (TokenType.IDENT, "age"), + new (TokenType.IDENT, "lt"), + new (TokenType.INT, "50"), + } + }; + + yield return new object[] + { + "age lte 50", + new KeyValuePair[] + { + new (TokenType.IDENT, "age"), + new (TokenType.IDENT, "lte"), + new (TokenType.INT, "50"), + } + }; + + yield return new object[] + { + "age gt 50", + new KeyValuePair[] + { + new (TokenType.IDENT, "age"), + new (TokenType.IDENT, "gt"), + new (TokenType.INT, "50"), + } + }; + + yield return new object[] + { + "age gte 50", + new KeyValuePair[] + { + new (TokenType.IDENT, "age"), + new (TokenType.IDENT, "gte"), + new (TokenType.INT, "50"), + } + }; + + yield return new object[] + { + "dateOfBirth eq 2000-01-01", + new KeyValuePair[] + { + new (TokenType.IDENT, "dateOfBirth"), + new (TokenType.IDENT, "eq"), + new (TokenType.DATE, "2000-01-01"), + } + }; + + yield return new object[] + { + "dateOfBirth lt 2000-01-01", + new KeyValuePair[] + { + new (TokenType.IDENT, "dateOfBirth"), + new (TokenType.IDENT, "lt"), + new (TokenType.DATE, "2000-01-01"), + } + }; + + yield return new object[] + { + "dateOfBirth lte 2000-01-01", + new KeyValuePair[] + { + new (TokenType.IDENT, "dateOfBirth"), + new (TokenType.IDENT, "lte"), + new (TokenType.DATE, "2000-01-01"), + } + }; + + yield return new object[] + { + "dateOfBirth gt 2000-01-01", + new KeyValuePair[] + { + new (TokenType.IDENT, "dateOfBirth"), + new (TokenType.IDENT, "gt"), + new (TokenType.DATE, "2000-01-01"), + } + }; + + yield return new object[] + { + "dateOfBirth gte 2000-01-01", + new KeyValuePair[] + { + new (TokenType.IDENT, "dateOfBirth"), + new (TokenType.IDENT, "gte"), + new (TokenType.DATE, "2000-01-01"), + } + }; + + yield return new object[] + { + "dateOfBirth eq 2023-01-01T15:30:00Z", + new KeyValuePair[] + { + new (TokenType.IDENT, "dateOfBirth"), + new (TokenType.IDENT, "eq"), + new (TokenType.DATETIME, "2023-01-01T15:30:00Z"), + } + }; + + yield return new object[] + { + "dateOfBirth eq 2023-01-30T09:29:55.1750906Z", + new KeyValuePair[] + { + new (TokenType.IDENT, "dateOfBirth"), + new (TokenType.IDENT, "eq"), + new (TokenType.DATETIME, "2023-01-30T09:29:55.1750906Z"), + } + }; + + yield return new object[] + { + "balance eq null", + new KeyValuePair[] + { + new (TokenType.IDENT, "balance"), + new (TokenType.IDENT, "eq"), + new (TokenType.NULL, "null"), + } + }; + + yield return new object[] + { + "balance ne NULL", + new KeyValuePair[] + { + new (TokenType.IDENT, "balance"), + new (TokenType.IDENT, "ne"), + new (TokenType.NULL, "NULL"), + } + }; + + yield return new object[] + { + "name eq 'test' and balance eq null", + new KeyValuePair[] + { + new (TokenType.IDENT, "name"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "test"), + new (TokenType.IDENT, "and"), + new (TokenType.IDENT, "balance"), + new (TokenType.IDENT, "eq"), + new (TokenType.NULL, "null"), + } + }; + + yield return new object[] + { + "manager/firstName eq 'John'", + new KeyValuePair[] + { + new (TokenType.IDENT, "manager"), + new (TokenType.SLASH, "/"), + new (TokenType.IDENT, "firstName"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "John"), + } + }; + + yield return new object[] + { + "manager/manager/firstName eq 'John'", + new KeyValuePair[] + { + new (TokenType.IDENT, "manager"), + new (TokenType.SLASH, "/"), + new (TokenType.IDENT, "manager"), + new (TokenType.SLASH, "/"), + new (TokenType.IDENT, "firstName"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "John"), + } + }; + + yield return new object[] + { + "tags/any(t: t eq 'tag 2')", + new KeyValuePair[] + { + new (TokenType.IDENT, "tags"), + new (TokenType.SLASH, "/"), + new (TokenType.IDENT, "any"), + new (TokenType.LPAREN, "("), + new (TokenType.IDENT, "t"), + new (TokenType.COLON, ":"), + new (TokenType.IDENT, "t"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "tag 2"), + new (TokenType.RPAREN, ")"), + } + }; + + // Basic all() syntax + yield return new object[] + { + "tags/all(item: item contains 'test')", + new KeyValuePair[] + { + new (TokenType.IDENT, "tags"), + new (TokenType.SLASH, "/"), + new (TokenType.IDENT, "all"), + new (TokenType.LPAREN, "("), + new (TokenType.IDENT, "item"), + new (TokenType.COLON, ":"), + new (TokenType.IDENT, "item"), + new (TokenType.IDENT, "contains"), + new (TokenType.STRING, "test"), + new (TokenType.RPAREN, ")"), + } + }; + + // Nested object property access in lambda + yield return new object[] + { + "addresses/any(address: address/city eq 'New York')", + new KeyValuePair[] + { + new (TokenType.IDENT, "addresses"), + new (TokenType.SLASH, "/"), + new (TokenType.IDENT, "any"), + new (TokenType.LPAREN, "("), + new (TokenType.IDENT, "address"), + new (TokenType.COLON, ":"), + new (TokenType.IDENT, "address"), + new (TokenType.SLASH, "/"), + new (TokenType.IDENT, "city"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "New York"), + new (TokenType.RPAREN, ")"), + } + }; + + yield return new object[] + { + "status eq 0", + new KeyValuePair[] + { + new (TokenType.IDENT, "status"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "0"), + } + }; + + yield return new object[] + { + "status eq 1", + new KeyValuePair[] + { + new (TokenType.IDENT, "status"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "1"), + } + }; + + yield return new object[] + { + "status ne 0", + new KeyValuePair[] + { + new (TokenType.IDENT, "status"), + new (TokenType.IDENT, "ne"), + new (TokenType.INT, "0"), + } + }; + + yield return new object[] + { + "gender eq 'Male'", + new KeyValuePair[] + { + new (TokenType.IDENT, "gender"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "Male"), + } + }; + + yield return new object[] + { + "gender eq 'Female'", + new KeyValuePair[] + { + new (TokenType.IDENT, "gender"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "Female"), + } + }; + + yield return new object[] + { + "gender eq 'Alternative'", + new KeyValuePair[] + { + new (TokenType.IDENT, "gender"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "Alternative"), + } + }; + + yield return new object[] + { + "gender ne 'Male'", + new KeyValuePair[] + { + new (TokenType.IDENT, "gender"), + new (TokenType.IDENT, "ne"), + new (TokenType.STRING, "Male"), + } + }; + + yield return new object[] + { + "gender eq null", + new KeyValuePair[] + { + new (TokenType.IDENT, "gender"), + new (TokenType.IDENT, "eq"), + new (TokenType.NULL, "null"), + } + }; + + yield return new object[] + { + "gender eq 'Male' and status eq 0", + new KeyValuePair[] + { + new (TokenType.IDENT, "gender"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "Male"), + new (TokenType.IDENT, "and"), + new (TokenType.IDENT, "status"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "0"), + } + }; + } + + [Theory] + [MemberData(nameof(Parameters))] + public void Test_FilterNextToken(string input, KeyValuePair[] expected) + { + var lexer = new QueryLexer(input); + + foreach (var test in expected) + { + var token = lexer.NextToken(); + + Assert.Equal(test.Key, token.Type); + Assert.Equal(test.Value, token.Literal); + } + } +} \ No newline at end of file diff --git a/src/GoatQuery/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs new file mode 100644 index 0000000..ba233ac --- /dev/null +++ b/src/GoatQuery/tests/Filter/FilterParserTest.cs @@ -0,0 +1,424 @@ +using Xunit; + +public sealed class FilterParserTest +{ + [Theory] + [InlineData("Name eq 'John'", "Name", "eq", "John")] + [InlineData("Firstname eq 'Jane'", "Firstname", "eq", "Jane")] + [InlineData("Age eq 21", "Age", "eq", "21")] + [InlineData("Age ne 10", "Age", "ne", "10")] + [InlineData("Name contains 'John'", "Name", "contains", "John")] + [InlineData("Id eq e4c7772b-8947-4e46-98ed-644b417d2a08", "Id", "eq", "e4c7772b-8947-4e46-98ed-644b417d2a08")] + [InlineData("Id eq 3.14159265359f", "Id", "eq", "3.14159265359f")] + [InlineData("Id eq 3.14159265359m", "Id", "eq", "3.14159265359m")] + [InlineData("Id eq 3.14159265359d", "Id", "eq", "3.14159265359d")] + [InlineData("Age lt 99", "Age", "lt", "99")] + [InlineData("Age lte 99", "Age", "lte", "99")] + [InlineData("Age gt 99", "Age", "gt", "99")] + [InlineData("Age gte 99", "Age", "gte", "99")] + [InlineData("dateOfBirth eq 2000-01-01", "dateOfBirth", "eq", "2000-01-01")] + [InlineData("dateOfBirth lt 2000-01-01", "dateOfBirth", "lt", "2000-01-01")] + [InlineData("dateOfBirth lte 2000-01-01", "dateOfBirth", "lte", "2000-01-01")] + [InlineData("dateOfBirth gt 2000-01-01", "dateOfBirth", "gt", "2000-01-01")] + [InlineData("dateOfBirth gte 2000-01-01", "dateOfBirth", "gte", "2000-01-01")] + [InlineData("dateOfBirth eq 2023-01-30T09:29:55.1750906Z", "dateOfBirth", "eq", "2023-01-30T09:29:55.1750906Z")] + [InlineData("balance eq null", "balance", "eq", "null")] + [InlineData("balance ne null", "balance", "ne", "null")] + [InlineData("name eq NULL", "name", "eq", "NULL")] + public void Test_ParsingFilterStatement(string input, string expectedLeft, string expectedOperator, string expectedRight) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + var expression = program.Value.Expression; + Assert.NotNull(expression); + + Assert.Equal(expectedLeft, expression.Left.TokenLiteral()); + Assert.Equal(expectedOperator, expression.Operator); + Assert.Equal(expectedRight, expression.Right.TokenLiteral()); + } + + [Theory] + [InlineData("Name")] + [InlineData("")] + [InlineData("eq nee")] + [InlineData("name nee 10")] + [InlineData("id contains 10")] + [InlineData("id contaiins '10'")] + [InlineData("id eq John'")] + [InlineData("name contains null")] + [InlineData("age lt null")] + [InlineData("age gt null")] + [InlineData("age lte null")] + [InlineData("age gte null")] + public void Test_ParsingInvalidFilterReturnsError(string input) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var result = parser.ParseFilter(); + + Assert.True(result.IsFailed); + } + + [Fact] + public void Test_ParsingFilterStatementWithAnd() + { + var input = "Name eq 'John' and Age eq 10"; + + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + var expression = program.Value.Expression; + Assert.NotNull(expression); + + var left = expression.Left as InfixExpression; + Assert.NotNull(left); + + Assert.Equal("Name", left.Left.TokenLiteral()); + Assert.Equal("eq", left.Operator); + Assert.Equal("John", left.Right.TokenLiteral()); + + Assert.Equal("and", expression.Operator); + + var right = expression.Right as InfixExpression; + Assert.NotNull(right); + + Assert.Equal("Age", right.Left.TokenLiteral()); + Assert.Equal("eq", right.Operator); + Assert.Equal("10", right.Right.TokenLiteral()); + } + + [Fact] + public void Test_ParsingFilterStatementWithOr() + { + var input = "Name eq 'John' or Age eq 10"; + + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + var expression = program.Value.Expression; + Assert.NotNull(expression); + + var left = expression.Left as InfixExpression; + Assert.NotNull(left); + + Assert.Equal("Name", left.Left.TokenLiteral()); + Assert.Equal("eq", left.Operator); + Assert.Equal("John", left.Right.TokenLiteral()); + + Assert.Equal("or", expression.Operator); + + var right = expression.Right as InfixExpression; + Assert.NotNull(right); + + Assert.Equal("Age", right.Left.TokenLiteral()); + Assert.Equal("eq", right.Operator); + Assert.Equal("10", right.Right.TokenLiteral()); + } + + [Fact] + public void Test_ParsingFilterStatementWithAndAndOr() + { + var input = "Name eq 'John' and Age eq 10 or Id eq 10"; + + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + var expression = program.Value.Expression; + Assert.NotNull(expression); + + var left = expression.Left as InfixExpression; + Assert.NotNull(left); + + var innerLeft = left.Left as InfixExpression; + Assert.NotNull(innerLeft); + + Assert.Equal("Name", innerLeft.Left.TokenLiteral()); + Assert.Equal("eq", innerLeft.Operator); + Assert.Equal("John", innerLeft.Right.TokenLiteral()); + + Assert.Equal("and", left.Operator); + + var innerRight = left.Right as InfixExpression; + Assert.NotNull(innerRight); + + Assert.Equal("Age", innerRight.Left.TokenLiteral()); + Assert.Equal("eq", innerRight.Operator); + Assert.Equal("10", innerRight.Right.TokenLiteral()); + + Assert.Equal("or", expression.Operator); + + var right = expression.Right as InfixExpression; + Assert.NotNull(right); + + Assert.Equal("Id", right.Left.TokenLiteral()); + Assert.Equal("eq", right.Operator); + Assert.Equal("10", right.Right.TokenLiteral()); + } + + [Theory] + [InlineData("manager/firstName eq 'John'", new string[] { "manager", "firstName" }, "eq", "John")] + [InlineData("manager/manager/firstName eq 'John'", new string[] { "manager", "manager", "firstName" }, "eq", "John")] + public void Test_ParsingFilterStatementWithNestedProperty(string input, string[] expectedLeft, string expectedOperator, string expectedRight) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + var expression = program.Value.Expression; + Assert.NotNull(expression); + + var left = expression.Left as PropertyPath; + Assert.NotNull(left); + + Assert.Equal(expectedLeft, left.Segments); + Assert.Equal(expectedOperator, expression.Operator); + Assert.Equal(expectedRight, expression.Right.TokenLiteral()); + } + + [Theory] + [InlineData("tags/any(t: t eq 'tag 2')", "tags", "any", "t", "t", "eq", "tag 2")] + [InlineData("tags/all(item: item contains 'test')", "tags", "all", "item", "item", "contains", "test")] + [InlineData("categories/any(c: c eq 'electronics')", "categories", "any", "c", "c", "eq", "electronics")] + [InlineData("items/all(i: i ne null)", "items", "all", "i", "i", "ne", "null")] + public void Test_ParsingQueryLambdaExpression(string input, string expectedProperty, string expectedFunction, + string expectedParameter, string expectedLambdaLeft, string expectedLambdaOperator, string expectedLambdaRight) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + Assert.True(program.IsSuccess); + var expression = program.Value.Expression; + Assert.NotNull(expression); + + // Lambda expressions are wrapped in InfixExpression with empty operator + var lambda = expression.Left as QueryLambdaExpression; + Assert.NotNull(lambda); + Assert.Equal(string.Empty, expression.Operator); + + // Verify lambda structure + Assert.Equal(expectedProperty, lambda.Property.TokenLiteral()); + Assert.Equal(expectedFunction, lambda.Function); + Assert.Equal(expectedParameter, lambda.Parameter); + + // Verify lambda body (inner expression) + var bodyExpression = lambda.Body as InfixExpression; + Assert.NotNull(bodyExpression); + Assert.Equal(expectedLambdaLeft, bodyExpression.Left.TokenLiteral()); + Assert.Equal(expectedLambdaOperator, bodyExpression.Operator); + Assert.Equal(expectedLambdaRight, bodyExpression.Right.TokenLiteral()); + } + + [Theory] + [InlineData("addresses/any(address: address/city eq 'New York')", "addresses", "any", "address", new string[] { "address", "city" }, "eq", "New York")] + [InlineData("orders/all(order: order/status eq 'completed')", "orders", "all", "order", new string[] { "order", "status" }, "eq", "completed")] + public void Test_ParsingQueryLambdaExpressionWithNestedProperty(string input, string expectedProperty, string expectedFunction, + string expectedParameter, string[] expectedNestedProperty, string expectedOperator, string expectedValue) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + Assert.True(program.IsSuccess); + var expression = program.Value.Expression; + Assert.NotNull(expression); + + // Lambda expressions are wrapped in InfixExpression with empty operator + var lambda = expression.Left as QueryLambdaExpression; + Assert.NotNull(lambda); + Assert.Equal(string.Empty, expression.Operator); + + // Verify lambda structure + Assert.Equal(expectedProperty, lambda.Property.TokenLiteral()); + Assert.Equal(expectedFunction, lambda.Function); + Assert.Equal(expectedParameter, lambda.Parameter); + + // Verify lambda body contains nested property access + var bodyExpression = lambda.Body as InfixExpression; + Assert.NotNull(bodyExpression); + + var propertyPath = bodyExpression.Left as PropertyPath; + Assert.NotNull(propertyPath); + Assert.Equal(expectedNestedProperty, propertyPath.Segments); + Assert.Equal(expectedOperator, bodyExpression.Operator); + Assert.Equal(expectedValue, bodyExpression.Right.TokenLiteral()); + } + + [Theory] + [InlineData("name eq 'John' and tags/any(t: t eq 'important')", "and")] + [InlineData("age gt 18 or categories/all(c: c ne null)", "or")] + [InlineData("tags/any(t: t contains 'work') and status eq 'active'", "and")] + public void Test_ParsingQueryLambdaExpressionWithLogicalOperators(string input, string expectedLogicalOperator) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + Assert.True(program.IsSuccess); + var expression = program.Value.Expression; + Assert.NotNull(expression); + + // Verify the logical operator between expressions + Assert.Equal(expectedLogicalOperator, expression.Operator); + + // One side should be a regular expression, the other should contain a lambda + // The exact structure depends on precedence, but we can verify both sides exist + Assert.NotNull(expression.Left); + Assert.NotNull(expression.Right); + } + + [Theory] + [InlineData("tags/any(t: t eq 'tag1' and t ne 'tag2')", "tags", "any", "t")] + [InlineData("items/all(i: i/price gt 100 or i/discount lt 0.1)", "items", "all", "i")] + public void Test_ParsingComplexQueryLambdaExpression(string input, string expectedProperty, string expectedFunction, string expectedParameter) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + Assert.True(program.IsSuccess); + var expression = program.Value.Expression; + Assert.NotNull(expression); + + // Lambda expressions are wrapped in InfixExpression with empty operator + var lambda = expression.Left as QueryLambdaExpression; + Assert.NotNull(lambda); + Assert.Equal(string.Empty, expression.Operator); + + // Verify basic lambda structure + Assert.Equal(expectedProperty, lambda.Property.TokenLiteral()); + Assert.Equal(expectedFunction, lambda.Function); + Assert.Equal(expectedParameter, lambda.Parameter); + + // Verify lambda body contains complex expressions with logical operators + var bodyExpression = lambda.Body as InfixExpression; + Assert.NotNull(bodyExpression); + + // The body should have logical operators (and/or) + Assert.True(bodyExpression.Operator.Equals("and", StringComparison.OrdinalIgnoreCase) || + bodyExpression.Operator.Equals("or", StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [InlineData("tags/any(t: t eq)")] // Missing right operand + [InlineData("tags/any(t t eq 'test')")] // Missing colon + [InlineData("tags/any( : t eq 'test')")] // Missing parameter name + [InlineData("tags/any(t:)")] // Missing lambda body + [InlineData("tags/any")] // Missing parentheses + [InlineData("tags/any()")] // Empty lambda + [InlineData("tags/invalid(t: t eq 'test')")] // Invalid function name + public void Test_ParsingInvalidQueryLambdaExpression(string input) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var result = parser.ParseFilter(); + + Assert.True(result.IsFailed); + } + + [Theory] + [InlineData("status eq 0", "status", "eq", "0")] + [InlineData("status eq 1", "status", "eq", "1")] + [InlineData("status ne 0", "status", "ne", "0")] + [InlineData("status ne 1", "status", "ne", "1")] + public void Test_ParsingEnumWithIntegerValue(string input, string expectedLeft, string expectedOperator, string expectedRight) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + var expression = program.Value.Expression; + Assert.NotNull(expression); + + Assert.Equal(expectedLeft, expression.Left.TokenLiteral()); + Assert.Equal(expectedOperator, expression.Operator); + Assert.Equal(expectedRight, expression.Right.TokenLiteral()); + } + + [Theory] + [InlineData("gender eq 'Male'", "gender", "eq", "Male")] + [InlineData("gender eq 'Female'", "gender", "eq", "Female")] + [InlineData("gender eq 'Alternative'", "gender", "eq", "Alternative")] + [InlineData("gender ne 'Male'", "gender", "ne", "Male")] + [InlineData("gender ne 'Female'", "gender", "ne", "Female")] + public void Test_ParsingEnumWithStringValue(string input, string expectedLeft, string expectedOperator, string expectedRight) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + var expression = program.Value.Expression; + Assert.NotNull(expression); + + Assert.Equal(expectedLeft, expression.Left.TokenLiteral()); + Assert.Equal(expectedOperator, expression.Operator); + Assert.Equal(expectedRight, expression.Right.TokenLiteral()); + } + + [Theory] + [InlineData("gender eq null", "gender", "eq", "null")] + [InlineData("gender ne null", "gender", "ne", "null")] + public void Test_ParsingNullableEnum(string input, string expectedLeft, string expectedOperator, string expectedRight) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + var expression = program.Value.Expression; + Assert.NotNull(expression); + + Assert.Equal(expectedLeft, expression.Left.TokenLiteral()); + Assert.Equal(expectedOperator, expression.Operator); + Assert.Equal(expectedRight, expression.Right.TokenLiteral()); + } + + [Fact] + public void Test_ParsingEnumWithLogicalOperators() + { + var input = "gender eq 'Male' and status eq 0"; + + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + var expression = program.Value.Expression; + Assert.NotNull(expression); + + var left = expression.Left as InfixExpression; + Assert.NotNull(left); + + Assert.Equal("gender", left.Left.TokenLiteral()); + Assert.Equal("eq", left.Operator); + Assert.Equal("Male", left.Right.TokenLiteral()); + + Assert.Equal("and", expression.Operator); + + var right = expression.Right as InfixExpression; + Assert.NotNull(right); + + Assert.Equal("status", right.Left.TokenLiteral()); + Assert.Equal("eq", right.Operator); + Assert.Equal("0", right.Right.TokenLiteral()); + } + +} diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs new file mode 100644 index 0000000..eb2955c --- /dev/null +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -0,0 +1,542 @@ +using Xunit; + +public sealed class FilterTest : IClassFixture +{ + private readonly DatabaseTestFixture _fixture; + + public FilterTest(DatabaseTestFixture fixture) + { + _fixture = fixture; + } + + public static IEnumerable Parameters() + { + yield return new object[] { + "firstname eq 'User01'", + new[] { TestData.Users["User01"] } + }; + + yield return new object[] { + "firstname eq 'Random'", + Array.Empty() + }; + + yield return new object[] { + "Age eq 25", + new[] { TestData.Users["User01"], TestData.Users["User05"] } + }; + + yield return new object[] { + "Age eq 30", + new[] { TestData.Users["User02"], TestData.Users["User03"] } + }; + + yield return new object[] { + "Age eq 35", + new[] { TestData.Users["User04"] } + }; + + yield return new object[] { + "Age eq 0", + Array.Empty() + }; + + yield return new object[] { + "firstname eq 'User02' and Age eq 30", + new[] { TestData.Users["User02"] } + }; + + yield return new object[] { + "firstname eq 'User01' or Age eq 35", + new[] { TestData.Users["User01"], TestData.Users["User04"] } + }; + + yield return new object[] { + "Age ne 30", + new[] { TestData.Users["User01"], TestData.Users["User04"], TestData.Users["User05"] } + }; + + yield return new object[] { + "firstName contains '0'", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User04"], TestData.Users["User05"] } + }; + + yield return new object[] { + "firstName contains '5'", + new[] { TestData.Users["User05"] } + }; + + yield return new object[] { + "Firstname eq 'User01' and Age eq 25 or Age eq 30", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"] } + }; + + yield return new object[] { + "(Firstname eq 'User01' and Age eq 25) or Age eq 30", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"] } + }; + + yield return new object[] { + "Firstname eq 'User01' and (Age eq 25 or Age eq 30)", + new[] { TestData.Users["User01"] } + }; + + yield return new object[] { + "(Firstname eq 'User02' and Age eq 30 or Age eq 35)", + new[] { TestData.Users["User02"], TestData.Users["User04"] } + }; + + yield return new object[] { + "(Firstname eq 'User01') or (Age eq 35 and Firstname eq 'User04') or Age eq 25 and (Age eq 30)", + new[] { TestData.Users["User01"], TestData.Users["User04"] } + }; + + yield return new object[] { + "id eq 11111111-1111-1111-1111-111111111111", + new[] { TestData.Users["User01"] } + }; + + yield return new object[] { + "age lt 30", + new[] { TestData.Users["User01"], TestData.Users["User05"] } + }; + + yield return new object[] { + "age lte 30", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User05"] } + }; + + yield return new object[] { + "age gt 25", + new[] { TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User04"] } + }; + + yield return new object[] { + "age gte 30", + new[] { TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User04"] } + }; + + yield return new object[] { + "age lt 35 and age gt 25", + new[] { TestData.Users["User02"], TestData.Users["User03"] } + }; + + yield return new object[] { + "balanceDecimal eq 1500.75m", + new[] { TestData.Users["User01"] } + }; + + yield return new object[] { + "balanceDecimal eq 500.00m", + new[] { TestData.Users["User02"] } + }; + + yield return new object[] { + "balanceDecimal gt 100m", + new[] { TestData.Users["User01"], TestData.Users["User02"] } + }; + + yield return new object[] { + "balanceDecimal eq null", + new[] { TestData.Users["User03"], TestData.Users["User04"] } + }; + + yield return new object[] { + "balanceDecimal ne null", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User05"] } + }; + + yield return new object[] { + "balanceDouble eq 2500.50d", + new[] { TestData.Users["User01"] } + }; + + yield return new object[] { + "balanceDouble eq null", + new[] { TestData.Users["User02"], TestData.Users["User04"] } + }; + + yield return new object[] { + "balanceFloat eq 3500.25f", + new[] { TestData.Users["User01"] } + }; + + yield return new object[] { + "balanceFloat eq 750.50f", + new[] { TestData.Users["User02"] } + }; + + yield return new object[] { + "balanceFloat eq null", + new[] { TestData.Users["User03"], TestData.Users["User04"], TestData.Users["User05"] } + }; + + yield return new object[] { + "dateOfBirth eq 1998-03-15T10:30:00Z", + new[] { TestData.Users["User01"] } + }; + + yield return new object[] { + "dateOfBirth eq 1993-07-20T14:00:00Z", + new[] { TestData.Users["User02"] } + }; + + yield return new object[] { + "dateOfBirth lt 1995-01-01T00:00:00Z", + new[] { TestData.Users["User02"], TestData.Users["User03"] } + }; + + yield return new object[] { + "dateOfBirth gte 1993-01-01T00:00:00Z", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User05"] } + }; + + yield return new object[] { + "dateOfBirth eq null", + new[] { TestData.Users["User04"] } + }; + + yield return new object[] { + "dateOfBirth ne null", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User05"] } + }; + + yield return new object[] { + "age gt 25 and isEmailVerified eq true", + new[] { TestData.Users["User03"] } + }; + + yield return new object[] { + "balanceDecimal ne null and age lt 30", + new[] { TestData.Users["User01"], TestData.Users["User05"] } + }; + + yield return new object[] { + "manager ne null and isEmailVerified eq false", + new[] { TestData.Users["User02"], TestData.Users["User04"] } + }; + + yield return new object[] { + "(age eq 25 or age eq 30) and company ne null", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User05"] } + }; + + yield return new object[] { + "isEmailVerified eq true", + new[] { TestData.Users["User01"], TestData.Users["User03"], TestData.Users["User05"] } + }; + + yield return new object[] { + "isEmailVerified eq false", + new[] { TestData.Users["User02"], TestData.Users["User04"] } + }; + + yield return new object[] { + "isEmailVerified ne true", + new[] { TestData.Users["User02"], TestData.Users["User04"] } + }; + + yield return new object[] { + "isEmailVerified ne false", + new[] { TestData.Users["User01"], TestData.Users["User03"], TestData.Users["User05"] } + }; + + yield return new object[] { + "manager/firstName eq 'User01'", + new[] { TestData.Users["User02"] } + }; + + yield return new object[] { + "manager/firstName eq 'User02'", + new[] { TestData.Users["User03"], TestData.Users["User04"] } + }; + + yield return new object[] { + "manager/age gt 28", + new[] { TestData.Users["User03"], TestData.Users["User04"] } + }; + + yield return new object[] { + "manager/isEmailVerified eq true", + new[] { TestData.Users["User02"] } + }; + + yield return new object[] { + "manager/manager/firstName eq 'User01'", + new[] { TestData.Users["User03"], TestData.Users["User04"] } + }; + + yield return new object[] { + "manager/manager eq null", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User05"] } + }; + + yield return new object[] { + "company/name eq 'TechCorp'", + new[] { TestData.Users["User01"] } + }; + + yield return new object[] { + "company/name eq 'DataSoft'", + new[] { TestData.Users["User02"] } + }; + + yield return new object[] { + "company/name contains 'Corp'", + new[] { TestData.Users["User01"], TestData.Users["User05"] } + }; + + yield return new object[] { + "addresses/any(addr: addr/city/name eq 'New York')", + new[] { TestData.Users["User01"] } + }; + + yield return new object[] { + "addresses/any(address: address/city/name eq 'Chicago')", + new[] { TestData.Users["User01"] } + }; + + yield return new object[] { + "addresses/any(a: a/city/name eq 'Seattle')", + new[] { TestData.Users["User02"] } + }; + + yield return new object[] { + "addresses/any(addr: addr/city/name eq 'Miami')", + new[] { TestData.Users["User05"] } + }; + + yield return new object[] { + "addresses/any(addr: addr/city/name eq 'NonExistentCity')", + Array.Empty() + }; + + yield return new object[] { + "addresses/all(addr: addr/city/country eq 'USA')", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User05"] } + }; + + yield return new object[] { + "addresses/any(addr: addr/addressLine1 contains 'Main')", + new[] { TestData.Users["User01"] } + }; + + yield return new object[] { + "addresses/any(addr: addr/addressLine1 contains 'Oak')", + new[] { TestData.Users["User01"] } + }; + + yield return new object[] { + "addresses/any(addr: addr/city/name eq 'New York' or addr/city/name eq 'Seattle')", + new[] { TestData.Users["User01"], TestData.Users["User02"] } + }; + + yield return new object[] { + "firstname eq 'User01' and addresses/any(addr: addr/city/name eq 'New York')", + new[] { TestData.Users["User01"] } + }; + + yield return new object[] { + "tags/any(x: x eq 'vip')", + new[] { TestData.Users["User01"] } + }; + + yield return new object[] { + "tags/any(x: x eq 'premium')", + new[] { TestData.Users["User01"], TestData.Users["User02"] } + }; + + yield return new object[] { + "tags/any(x: x eq 'standard')", + new[] { TestData.Users["User05"] } + }; + + yield return new object[] { + "status eq 0", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User05"] } + }; + + yield return new object[] { + "status eq 1", + new[] { TestData.Users["User03"], TestData.Users["User04"] } + }; + + yield return new object[] { + "status ne 0", + new[] { TestData.Users["User03"], TestData.Users["User04"] } + }; + + yield return new object[] { + "status ne 1", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User05"] } + }; + + yield return new object[] { + "gender eq 'Male'", + new[] { TestData.Users["User01"], TestData.Users["User05"] } + }; + + yield return new object[] { + "gender eq 'male'", + new[] { TestData.Users["User01"], TestData.Users["User05"] } + }; + + yield return new object[] { + "gender eq 'Female'", + new[] { TestData.Users["User02"] } + }; + + yield return new object[] { + "gender eq 'Alternative'", + new[] { TestData.Users["User03"] } + }; + + yield return new object[] { + "gender ne 'Male'", + new[] { TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User04"] } + }; + + yield return new object[] { + "gender ne 'Female'", + new[] { TestData.Users["User01"], TestData.Users["User03"], TestData.Users["User04"], TestData.Users["User05"] } + }; + + yield return new object[] { + "gender eq null", + new[] { TestData.Users["User04"] } + }; + + yield return new object[] { + "gender ne null", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User05"] } + }; + + yield return new object[] { + "gender eq 'Male' and age eq 25", + new[] { TestData.Users["User01"], TestData.Users["User05"] } + }; + + yield return new object[] { + "status eq 0 and age gt 25", + new[] { TestData.Users["User02"] } + }; + + yield return new object[] { + "gender eq 'Female' or status eq 1", + new[] { TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User04"] } + }; + + yield return new object[] { + "gender eq 'Male' or gender eq 'Female'", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User05"] } + }; + + yield return new object[] { + "(gender eq 'Male' and status eq 0) or age eq 30", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User05"] } + }; + + yield return new object[] { + "gender ne null and status eq 1", + new[] { TestData.Users["User03"] } + }; + } + + [Theory] + [MemberData(nameof(Parameters))] + public void Test_Filter(string filter, IEnumerable expected) + { + var query = new Query + { + Filter = filter, + OrderBy = "Firstname" // Ensure consistent ordering for tests + }; + + var result = _fixture.DbContext.Users.Apply(query); + + Assert.Equal(expected, result.Value.Query); + } + + [Theory] + [InlineData("NonExistentProperty eq 'John'")] + [InlineData("manager//firstName eq 'John'")] + [InlineData("manager/ eq 'John'")] + [InlineData("/manager eq 'John'")] + [InlineData("addresses/any(addr: addr/nonExistentProperty eq 'test')")] + [InlineData("addresses/invalid(addr: addr/city/name eq 'test')")] + [InlineData("nonExistentCollection/any(item: item eq 'test')")] + public void Test_InvalidFilterReturnsError(string filter) + { + var query = new Query + { + Filter = filter + }; + + var result = _fixture.DbContext.Users.Apply(query); + + Assert.True(result.IsFailed); + } + + [Fact] + public void Test_Filter_WithCustomJsonPropertyName() + { + var users = new List{ + new CustomJsonPropertyUser { Lastname = "John" }, + new CustomJsonPropertyUser { Lastname = "Jane" }, + new CustomJsonPropertyUser { Lastname = "Apple" }, + new CustomJsonPropertyUser { Lastname = "Harry" }, + new CustomJsonPropertyUser { Lastname = "Doe" }, + new CustomJsonPropertyUser { Lastname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Filter = "last_name eq 'John'" + }; + + var result = users.Apply(query); + + Assert.Equal(new List{ + new CustomJsonPropertyUser { Lastname = "John" }, + }, result.Value.Query); + } + + public record IntegerConverts + { + public long Long { get; set; } + public short Short { get; set; } + public byte Byte { get; set; } + public uint Uint { get; set; } + public ulong ULong { get; set; } + public ushort UShort { get; set; } + public sbyte SByte { get; set; } + } + + [Theory] + [InlineData("long eq 10")] + [InlineData("short eq 20")] + [InlineData("byte eq 30")] + [InlineData("uint eq 40")] + [InlineData("ulong eq 50")] + [InlineData("ushort eq 60")] + [InlineData("sbyte eq 70")] + public void Test_Filter_CanConvertIntToOtherNumericTypes(string filter) + { + var users = new List{ + new IntegerConverts() { Long = 0, Short = 0, Byte = 0, Uint = 0, ULong = 0, UShort = 0, SByte = 0}, + new IntegerConverts() { Long = 10, Short = 20, Byte = 30, Uint = 40, ULong = 50, UShort = 60, SByte = 70}, + new IntegerConverts() { Long = 1, Short = 2, Byte = 3, Uint = 4, ULong = 5, UShort = 6, SByte = 7}, + }.AsQueryable(); + + var query = new Query + { + Filter = filter + }; + + var result = users.Apply(query); + + Assert.Equal(new List{ + new IntegerConverts() { Long = 10, Short = 20, Byte = 30, Uint = 40, ULong = 50, UShort = 60, SByte = 70}, + }, result.Value.Query); + } +} \ No newline at end of file diff --git a/src/GoatQuery/tests/Orderby/OrderByLexerTest.cs b/src/GoatQuery/tests/Orderby/OrderByLexerTest.cs new file mode 100644 index 0000000..d811665 --- /dev/null +++ b/src/GoatQuery/tests/Orderby/OrderByLexerTest.cs @@ -0,0 +1,102 @@ +using Xunit; + +public sealed class OrderByLexerTest +{ + public static IEnumerable Parameters() + { + yield return new object[] + { + "id asc", + new KeyValuePair[] + { + new (TokenType.IDENT, "id"), + new (TokenType.IDENT, "asc"), + } + }; + + yield return new object[] + { + "iD desc", + new KeyValuePair[] + { + new (TokenType.IDENT, "iD"), + new (TokenType.IDENT, "desc"), + } + }; + + yield return new object[] + { + "id aSc", + new KeyValuePair[] + { + new (TokenType.IDENT, "id"), + new (TokenType.IDENT, "aSc"), + } + }; + + yield return new object[] + { + "id DeSc", + new KeyValuePair[] + { + new (TokenType.IDENT, "id"), + new (TokenType.IDENT, "DeSc"), + } + }; + + yield return new object[] + { + "id AsC", + new KeyValuePair[] + { + new (TokenType.IDENT, "id"), + new (TokenType.IDENT, "AsC"), + } + }; + + yield return new object[] + { + "asc asc", + new KeyValuePair[] + { + new (TokenType.IDENT, "asc"), + new (TokenType.IDENT, "asc"), + } + }; + + yield return new object[] + { + "address1Line asc", + new KeyValuePair[] + { + new (TokenType.IDENT, "address1Line"), + new (TokenType.IDENT, "asc"), + } + }; + + yield return new object[] + { + "addASCress1Line desc", + new KeyValuePair[] + { + new (TokenType.IDENT, "addASCress1Line"), + new (TokenType.IDENT, "desc"), + } + }; + } + + [Theory] + [MemberData(nameof(Parameters))] + public void Test_OrderByNextToken(string input, KeyValuePair[] expected) + { + var lexer = new QueryLexer(input); + + foreach (var test in expected) + { + var token = lexer.NextToken(); + + Assert.Equal(test.Key, token.Type); + Assert.Equal(test.Value, token.Literal); + } + } +} \ No newline at end of file diff --git a/src/GoatQuery/tests/Orderby/OrderByParserTest.cs b/src/GoatQuery/tests/Orderby/OrderByParserTest.cs new file mode 100644 index 0000000..8864a44 --- /dev/null +++ b/src/GoatQuery/tests/Orderby/OrderByParserTest.cs @@ -0,0 +1,94 @@ +using Xunit; + +public sealed class OrderByParserTest +{ + public static IEnumerable Parameters() + { + yield return new object[] + { + "ID desc", + new OrderByStatement[] + { + new OrderByStatement(new Token(TokenType.IDENT, "ID"), OrderByDirection.Descending), + } + }; + + yield return new object[] + { + "id asc", + new OrderByStatement[] + { + new OrderByStatement(new Token(TokenType.IDENT, "id"), OrderByDirection.Ascending), + } + }; + + yield return new object[] + { + "Name", + new OrderByStatement[] + { + new OrderByStatement(new Token(TokenType.IDENT, "Name"), OrderByDirection.Ascending), + } + }; + + yield return new object[] + { + "id asc, name desc", + new OrderByStatement[] + { + new OrderByStatement(new Token(TokenType.IDENT, "id"), OrderByDirection.Ascending), + new OrderByStatement(new Token(TokenType.IDENT, "name"), OrderByDirection.Descending) + } + }; + + yield return new object[] + { + "id asc, name desc, age, address asc, postcode desc", + new OrderByStatement[] + { + new OrderByStatement(new Token(TokenType.IDENT, "id"), OrderByDirection.Ascending), + new OrderByStatement(new Token(TokenType.IDENT, "name"), OrderByDirection.Descending), + new OrderByStatement(new Token(TokenType.IDENT, "age"), OrderByDirection.Ascending), + new OrderByStatement(new Token(TokenType.IDENT, "address"), OrderByDirection.Ascending), + new OrderByStatement(new Token(TokenType.IDENT, "postcode"), OrderByDirection.Descending) + } + }; + + yield return new object[] + { + "address1Line10 asc, asc asc, desc desc", + new OrderByStatement[] + { + new OrderByStatement(new Token(TokenType.IDENT, "address1Line10"), OrderByDirection.Ascending), + new OrderByStatement(new Token(TokenType.IDENT, "asc"), OrderByDirection.Ascending), + new OrderByStatement(new Token(TokenType.IDENT, "desc"), OrderByDirection.Descending), + } + }; + + yield return new object[] + { + "", + new OrderByStatement[] { } + }; + } + + [Theory] + [MemberData(nameof(Parameters))] + public void Test_ParsingMultipleOrderByStatement(string input, IEnumerable expected) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseOrderBy(); + Assert.Equal(expected.Count(), program.Count()); + + for (var i = 0; i < expected.Count(); i++) + { + var expectedStatement = expected.ElementAt(i); + var statement = program.ElementAt(i); + + Assert.Equal(expectedStatement.TokenLiteral(), statement.TokenLiteral()); + Assert.Equal(expectedStatement.Direction, statement.Direction); + } + } +} \ No newline at end of file diff --git a/src/GoatQuery/tests/Orderby/OrderByTest.cs b/src/GoatQuery/tests/Orderby/OrderByTest.cs new file mode 100644 index 0000000..5a1b6ad --- /dev/null +++ b/src/GoatQuery/tests/Orderby/OrderByTest.cs @@ -0,0 +1,132 @@ +using Xunit; + +public sealed class OrderByTest +{ + private static readonly Dictionary _users = new Dictionary + { + ["John"] = new User { Age = 2, Firstname = "John" }, + ["Jane"] = new User { Age = 1, Firstname = "Jane" }, + ["Apple"] = new User { Age = 2, Firstname = "Apple" }, + ["Harry"] = new User { Age = 1, Firstname = "Harry" }, + ["Doe"] = new User { Age = 3, Firstname = "Doe" }, + ["Egg"] = new User { Age = 3, Firstname = "Egg" } + }; + + public static IEnumerable Parameters() + { + yield return new object[] + { + "age desc, firstname asc", + new[] { _users["Doe"], _users["Egg"], _users["Apple"], _users["John"], _users["Harry"], _users["Jane"] } + }; + + yield return new object[] + { + "age desc, firstname desc", + new[] { _users["Egg"], _users["Doe"], _users["John"], _users["Apple"], _users["Jane"], _users["Harry"] } + }; + + yield return new object[] + { + "age desc", + new[] { _users["Doe"], _users["Egg"], _users["John"], _users["Apple"], _users["Jane"], _users["Harry"] } + }; + + yield return new object[] + { + "Age asc", + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } + }; + + yield return new object[] + { + "age", + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } + }; + + yield return new object[] + { + "age asc", + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } + }; + + yield return new object[] + { + "Age asc", + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } + }; + + yield return new object[] + { + "aGe asc", + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } + }; + + yield return new object[] + { + "AGe asc", + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } + }; + + yield return new object[] + { + "aGE Asc", + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } + }; + + yield return new object[] + { + "age aSc", + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } + }; + + yield return new object[] + { + "", + new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"], _users["Doe"], _users["Egg"] } + }; + } + + [Theory] + [MemberData(nameof(Parameters))] + public void Test_OrderBy(string orderby, IEnumerable expected) + { + var query = new Query + { + OrderBy = orderby + }; + + var result = _users.Values.AsQueryable().Apply(query); + + Assert.Equal(expected, result.Value.Query); + } + + [Fact] + public void Test_OrderBy_WithCustomJsonPropertyName() + { + var users = new List{ + new CustomJsonPropertyUser { Lastname = "John" }, + new CustomJsonPropertyUser { Lastname = "Jane" }, + new CustomJsonPropertyUser { Lastname = "Apple" }, + new CustomJsonPropertyUser { Lastname = "Harry" }, + new CustomJsonPropertyUser { Lastname = "Doe" }, + new CustomJsonPropertyUser { Lastname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + OrderBy = "last_name asc" + }; + + var result = users.Apply(query); + + Assert.Equal(new List{ + new CustomJsonPropertyUser { Lastname = "Apple" }, + new CustomJsonPropertyUser { Lastname = "Doe" }, + new CustomJsonPropertyUser { Lastname = "Egg" }, + new CustomJsonPropertyUser { Lastname = "Harry" }, + new CustomJsonPropertyUser { Lastname = "Jane" }, + new CustomJsonPropertyUser { Lastname = "John" }, + }, result.Value.Query); + } +} \ No newline at end of file diff --git a/src/GoatQuery/tests/Search/SearchTest.cs b/src/GoatQuery/tests/Search/SearchTest.cs new file mode 100644 index 0000000..78ba4b9 --- /dev/null +++ b/src/GoatQuery/tests/Search/SearchTest.cs @@ -0,0 +1,45 @@ +using System.Linq.Expressions; +using Xunit; + +public class UserSearchTestBinder : ISearchBinder +{ + public Expression> Bind(string searchTerm) + { + var term = searchTerm.ToLower(); + + Expression> exp = x => + x.Firstname.ToLower().Contains(term); + + return exp; + } +} + +public sealed class SearchTest +{ + [Theory] + [InlineData("john", 1)] + [InlineData("JOHN", 1)] + [InlineData("j", 2)] + [InlineData("e", 4)] + [InlineData("eg", 1)] + public void Test_Search(string searchTerm, int expectedCount) + { + var users = new List{ + new User { Age = 2, Firstname = "John" }, + new User { Age = 1, Firstname = "Jane" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 1, Firstname = "Harry" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Search = searchTerm + }; + + var result = users.Apply(query, new UserSearchTestBinder()); + + Assert.Equal(expectedCount, result.Value.Query.Count()); + } +} \ No newline at end of file diff --git a/src/GoatQuery/tests/Skip/SkipTest.cs b/src/GoatQuery/tests/Skip/SkipTest.cs new file mode 100644 index 0000000..923ed33 --- /dev/null +++ b/src/GoatQuery/tests/Skip/SkipTest.cs @@ -0,0 +1,103 @@ +using Xunit; + +public sealed class SkipTest +{ + public static IEnumerable Parameters() + { + yield return new object[] + { + 1, + new User[] + { + new User { Age = 1, Firstname = "Jane" }, + new User { Age = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } + } + }; + + yield return new object[] + { + 2, + new User[] + { + new User { Age = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } + } + }; + + yield return new object[] + { + 3, + new User[] + { + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } + } + }; + + yield return new object[] + { + 4, + new User[] + { + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } + } + }; + + yield return new object[] + { + 5, + new User[] + { + new User { Age = 3, Firstname = "Egg" } + } + }; + + yield return new object[] + { + 6, + new User[] { } + }; + + yield return new object[] + { + 7, + new User[] { } + }; + + yield return new object[] + { + 10_000, + new User[] { } + }; + } + + [Theory] + [MemberData(nameof(Parameters))] + public void Test_Skip(int skip, IEnumerable expected) + { + var users = new List{ + new User { Age = 1, Firstname = "Harry" }, + new User { Age = 1, Firstname = "Jane" }, + new User { Age = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Skip = skip + }; + + var result = users.Apply(query); + + Assert.Equal(expected, result.Value.Query); + } +} \ No newline at end of file diff --git a/src/GoatQuery/tests/TestData.cs b/src/GoatQuery/tests/TestData.cs new file mode 100644 index 0000000..75236d1 --- /dev/null +++ b/src/GoatQuery/tests/TestData.cs @@ -0,0 +1,167 @@ +public static class TestData +{ + private static readonly Guid User01Id = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid User02Id = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly Guid User03Id = Guid.Parse("33333333-3333-3333-3333-333333333333"); + private static readonly Guid User04Id = Guid.Parse("44444444-4444-4444-4444-444444444444"); + private static readonly Guid User05Id = Guid.Parse("55555555-5555-5555-5555-555555555555"); + + public static readonly Dictionary Users = new Dictionary + { + ["User01"] = new User + { + Id = User01Id, + Age = 25, + Firstname = "User01", + Gender = Gender.Male, + Status = Status.Active, + DateOfBirth = DateTime.SpecifyKind(DateTime.Parse("1998-03-15 10:30:00"), DateTimeKind.Utc), + BalanceDecimal = 1500.75m, + BalanceDouble = 2500.50d, + BalanceFloat = 3500.25f, + IsEmailVerified = true, + ManagerId = null, + Company = new Company + { + Id = Guid.NewGuid(), + Name = "TechCorp", + Department = "Engineering" + }, + Addresses = + [ + new Address + { + Id = Guid.NewGuid(), + AddressLine1 = "123 Main St", + City = new City + { + Id = Guid.NewGuid(), + Name = "New York", + Country = "USA" + } + }, + new Address + { + Id = Guid.NewGuid(), + AddressLine1 = "456 Oak Ave", + City = new City + { + Id = Guid.NewGuid(), + Name = "Chicago", + Country = "USA" + } + } + ], + Tags = ["vip", "premium"] + }, + ["User02"] = new User + { + Id = User02Id, + Age = 30, + Firstname = "User02", + Gender = Gender.Female, + Status = Status.Active, + DateOfBirth = DateTime.SpecifyKind(DateTime.Parse("1993-07-20 14:00:00"), DateTimeKind.Utc), + BalanceDecimal = 500.00m, + BalanceDouble = null, + BalanceFloat = 750.50f, + IsEmailVerified = false, + ManagerId = User01Id, + Company = new Company + { + Id = Guid.NewGuid(), + Name = "DataSoft", + Department = "Development" + }, + Addresses = + [ + new Address + { + Id = Guid.NewGuid(), + AddressLine1 = "789 Pine St", + City = new City + { + Id = Guid.NewGuid(), + Name = "Seattle", + Country = "USA" + } + } + ], + Tags = ["premium"] + }, + ["User03"] = new User + { + Id = User03Id, + Age = 30, + Firstname = "User03", + Gender = Gender.Other, + Status = Status.Inactive, + DateOfBirth = DateTime.SpecifyKind(DateTime.Parse("1993-11-10 09:15:00"), DateTimeKind.Utc), + BalanceDecimal = null, + BalanceDouble = 1000.00d, + BalanceFloat = null, + IsEmailVerified = true, + ManagerId = User02Id, + Company = new Company + { + Id = Guid.NewGuid(), + Name = "Tech Solutions", + Department = "Sales" + }, + Addresses = Array.Empty
(), + Tags = Array.Empty() + }, + ["User04"] = new User + { + Id = User04Id, + Age = 35, + Firstname = "User04", + Gender = null, + Status = Status.Inactive, + DateOfBirth = null, + BalanceDecimal = null, + BalanceDouble = null, + BalanceFloat = null, + IsEmailVerified = false, + ManagerId = User02Id, + Company = null, + Addresses = Array.Empty
(), + Tags = Array.Empty() + }, + ["User05"] = new User + { + Id = User05Id, + Age = 25, + Firstname = "User05", + Gender = Gender.Male, + Status = Status.Active, + DateOfBirth = DateTime.SpecifyKind(DateTime.Parse("1998-12-25 18:45:00"), DateTimeKind.Utc), + BalanceDecimal = 0.00m, + BalanceDouble = 0.00d, + BalanceFloat = null, + IsEmailVerified = true, + ManagerId = null, + Company = new Company + { + Id = Guid.NewGuid(), + Name = "WebCorp", + Department = "Marketing" + }, + Addresses = + [ + new Address + { + Id = Guid.NewGuid(), + AddressLine1 = "999 Broadway", + City = new City + { + Id = Guid.NewGuid(), + Name = "Miami", + Country = "USA" + } + } + ], + Tags = ["standard"] + } + }; +} \ No newline at end of file diff --git a/src/GoatQuery/tests/TestDbContext.cs b/src/GoatQuery/tests/TestDbContext.cs new file mode 100644 index 0000000..b1208de --- /dev/null +++ b/src/GoatQuery/tests/TestDbContext.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; + +public class TestDbContext : DbContext +{ + public TestDbContext(DbContextOptions options) : base(options) { } + + public DbSet Users => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + } +} \ No newline at end of file diff --git a/src/GoatQuery/tests/Top/TopTest.cs b/src/GoatQuery/tests/Top/TopTest.cs new file mode 100644 index 0000000..eaa5f9b --- /dev/null +++ b/src/GoatQuery/tests/Top/TopTest.cs @@ -0,0 +1,98 @@ +using Xunit; + +public sealed class TopTest +{ + [Theory] + [InlineData(-1, 6)] + [InlineData(0, 6)] + [InlineData(1, 1)] + [InlineData(2, 2)] + [InlineData(3, 3)] + [InlineData(4, 4)] + [InlineData(5, 5)] + [InlineData(100, 6)] + [InlineData(100_000, 6)] + public void Test_Top(int top, int expectedCount) + { + var users = new List{ + new User { Age = 1, Firstname = "Jane" }, + new User { Age = 1, Firstname = "Harry" }, + new User { Age = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Top = top + }; + + var result = users.Apply(query); + + Assert.Equal(expectedCount, result.Value.Query.Count()); + } + + [Theory] + [InlineData(-1, 4)] + [InlineData(0, 4)] + [InlineData(1, 1)] + [InlineData(2, 2)] + [InlineData(3, 3)] + [InlineData(4, 4)] + public void Test_TopWithMaxTop(int top, int expectedCount) + { + var users = new List{ + new User { Age = 1, Firstname = "Jane" }, + new User { Age = 1, Firstname = "Harry" }, + new User { Age = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Top = top + }; + + var queryOptions = new QueryOptions + { + MaxTop = 4 + }; + + var result = users.Apply(query, null, queryOptions); + + Assert.Equal(expectedCount, result.Value.Query.Count()); + } + + [Theory] + [InlineData(5)] + [InlineData(100)] + [InlineData(100_000)] + public void Test_TopWithMaxTopReturnsError(int top) + { + var users = new List{ + new User { Age = 1, Firstname = "Jane" }, + new User { Age = 1, Firstname = "Harry" }, + new User { Age = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Top = top + }; + + var queryOptions = new QueryOptions + { + MaxTop = 4 + }; + + var result = users.Apply(query, null, queryOptions); + + Assert.True(result.IsFailed); + } +} \ No newline at end of file diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs new file mode 100644 index 0000000..931690d --- /dev/null +++ b/src/GoatQuery/tests/User.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Serialization; + +public record User +{ + public Guid Id { get; set; } + public int Age { get; set; } + public Gender? Gender { get; set; } + public Status Status { get; set; } + public string Firstname { get; set; } = string.Empty; + public decimal? BalanceDecimal { get; set; } + public double? BalanceDouble { get; set; } + public float? BalanceFloat { get; set; } + public DateTime? DateOfBirth { get; set; } + public bool IsEmailVerified { get; set; } + public Company? Company { get; set; } + + public Guid? ManagerId { get; set; } + public User? Manager { get; set; } + public IEnumerable
Addresses { get; set; } = Array.Empty
(); + public IEnumerable Tags { get; set; } = Array.Empty(); +} + +public record Address +{ + public Guid Id { get; set; } + public City City { get; set; } = new City(); + public string AddressLine1 { get; set; } = string.Empty; +} + +public record City +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; +} + +public record Company +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Department { get; set; } = string.Empty; +} + +public sealed record CustomJsonPropertyUser : User +{ + [JsonPropertyName("last_name")] + public string Lastname { get; set; } = string.Empty; +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Gender +{ + Male, + Female, + [JsonStringEnumMemberName("Alternative")] + Other +} + + +public enum Status +{ + Active, + Inactive, +} \ No newline at end of file diff --git a/tests/tests.csproj b/src/GoatQuery/tests/tests.csproj similarity index 72% rename from tests/tests.csproj rename to src/GoatQuery/tests/tests.csproj index 959e69f..f7cdc60 100644 --- a/tests/tests.csproj +++ b/src/GoatQuery/tests/tests.csproj @@ -1,16 +1,13 @@ - net8.0 + net9.0 enable enable - false - - @@ -21,10 +18,14 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + + + - + diff --git a/src/GoatQueryOpenAPIFilter.cs b/src/GoatQueryOpenAPIFilter.cs deleted file mode 100644 index 52c4d85..0000000 --- a/src/GoatQueryOpenAPIFilter.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Globalization; -using Microsoft.AspNetCore.Mvc.Controllers; -using Microsoft.OpenApi.Models; -using Swashbuckle.AspNetCore.SwaggerGen; - -// This will generate correct open API spec query parameters -// based off the [EnableQuery] attribute -public class GoatQueryOpenAPIFilter : IOperationFilter -{ - private readonly ISerializerDataContractResolver _serializerDataContractResolver; - - public GoatQueryOpenAPIFilter(ISerializerDataContractResolver serializerDataContractResolver) - { - _serializerDataContractResolver = serializerDataContractResolver; - } - - public void Apply(OpenApiOperation operation, OperationFilterContext context) - { - if (operation.Parameters == null) operation.Parameters = new List(); - - var descriptor = context.ApiDescription.ActionDescriptor as ControllerActionDescriptor; - - if (descriptor is null) - { - return; - } - - var methodHasEnableQueryFilter = descriptor.MethodInfo.GetCustomAttributes(true).Any(x => - { - var type = x.GetType(); - return type.IsGenericType && type.GetGenericTypeDefinition().Equals(typeof(EnableQueryAttribute<>)); - }); - - if (!methodHasEnableQueryFilter) - { - return; - } - - typeof(Query).GetProperties().ToList().ForEach(x => - { - var data = _serializerDataContractResolver.GetDataContractForType(x.PropertyType); - - operation.Parameters.Add(new OpenApiParameter() - { - Name = x.Name, - In = ParameterLocation.Query, - Required = false, - Schema = new OpenApiSchema() - { - // https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/master/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs#L272 - Type = data.DataType.ToString().ToLower(CultureInfo.InvariantCulture), - Format = data.DataFormat - } - }); - }); - } -} \ No newline at end of file diff --git a/src/Helpers/StringHelper.cs b/src/Helpers/StringHelper.cs deleted file mode 100644 index 24729e1..0000000 --- a/src/Helpers/StringHelper.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Text; - -public static class StringHelper -{ - public static List SplitString(string input) - { - List result = new List(); - StringBuilder buffer = new StringBuilder(); - bool singleQuote = false; - - for (int i = 0; i < input.Length; i++) - { - char character = input[i]; - - if (character == '\'') - { - buffer.Append(character); - singleQuote = !singleQuote; - } - else if (!singleQuote && (character == 'a' || character == 'o') && i + 1 < input.Length && (input.Substring(i, 3) == "and" || input.Substring(i, 2) == "or")) - { - if (buffer.Length > 0) - { - result.Add(buffer.ToString().Trim()); - buffer.Clear(); - } - - result.Add(input.Substring(i, 3).Trim()); - i += 2; - } - else - { - buffer.Append(character); - } - } - - if (buffer.Length > 0) - { - result.Add(buffer.ToString().Trim()); - } - - return result; - } - - public static List SplitStringByWhitespace(string str) - { - List parts = new List(); - StringBuilder sb = new StringBuilder(); - bool singleQuote = false; - - foreach (char character in str) - { - switch (character) - { - case ' ': - if (singleQuote) - { - sb.Append(character); - } - else - { - parts.Add(sb.ToString()); - sb.Clear(); - } - break; - case '\'': - singleQuote = !singleQuote; - sb.Append(character); - break; - default: - sb.Append(character); - break; - } - } - - parts.Add(sb.ToString()); - - return parts; - } -} \ No newline at end of file diff --git a/src/Query.cs b/src/Query.cs deleted file mode 100644 index f31d11d..0000000 --- a/src/Query.cs +++ /dev/null @@ -1,10 +0,0 @@ -public record Query -{ - public int? Top { get; set; } - public int? Skip { get; set; } - public bool? Count { get; set; } - public string? OrderBy { get; set; } = string.Empty; - public string? Select { get; set; } = string.Empty; - public string? Search { get; set; } = string.Empty; - public string? Filter { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/src/Responses/QueryErrorResponse.cs b/src/Responses/QueryErrorResponse.cs deleted file mode 100644 index bbff74d..0000000 --- a/src/Responses/QueryErrorResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -public class QueryErrorResponse -{ - public QueryErrorResponse(int status, string message) - { - Status = status; - Message = message; - } - - public int Status { get; set; } - public string Message { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/tests/TestDbContext.cs b/tests/TestDbContext.cs deleted file mode 100644 index 826cff0..0000000 --- a/tests/TestDbContext.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; -using System.Text.Json.Serialization; -using Microsoft.EntityFrameworkCore; - -public record User -{ - public Guid Id { get; set; } - public string Firstname { get; set; } = string.Empty; - public string Lastname { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - - [JsonPropertyName("displayName")] - public string UserName { get; set; } = string.Empty; - - [Column("PersonSex", TypeName = "varchar(32)")] - public string Gender { get; set; } = string.Empty; - public int Age { get; set; } -} - - -public class TestDbContext : DbContext -{ - public TestDbContext(DbContextOptions options) : base(options) - { - Database.EnsureCreated(); - } - - public DbSet Users => Set(); -} \ No newline at end of file diff --git a/tests/TestWithSqlite.cs b/tests/TestWithSqlite.cs deleted file mode 100644 index f6429a1..0000000 --- a/tests/TestWithSqlite.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; - -public abstract class TestWithSqlite : IDisposable -{ - private const string InMemoryConnectionString = "DataSource=:memory:"; - private readonly SqliteConnection _connection; - protected readonly TestDbContext _context; - - protected TestWithSqlite() - { - _connection = new SqliteConnection(InMemoryConnectionString); - _connection.Open(); - var options = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - _context = new TestDbContext(options); - _context.Database.EnsureCreated(); - } - - public void Dispose() - { - _connection.Close(); - } -} \ No newline at end of file diff --git a/tests/Tests.cs b/tests/Tests.cs deleted file mode 100644 index 2aeb4f7..0000000 --- a/tests/Tests.cs +++ /dev/null @@ -1,409 +0,0 @@ -using System.Linq.Dynamic.Core.Exceptions; -using System.Linq.Expressions; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; - -namespace tests; - -public class UserSearchTestBinder : ISearchBinder -{ - public Expression> Bind(string searchTerm) - { - Expression> exp = x => EF.Functions.Like(x.Firstname, $"%{searchTerm}%") || EF.Functions.Like(x.Lastname, $"%{searchTerm}%"); - - return exp; - } -} - -public class Tests : TestWithSqlite -{ - [Fact] - public void Test_EmptyQuery() - { - var query = new Query(); - - var (result, _) = _context.Users.AsQueryable().Apply(query); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - // Top - - [Fact] - public void Test_QueryWithTop() - { - var query = new Query() { Top = 3 }; - - var (result, _) = _context.Users.AsQueryable().Apply(query); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().Take(query.Top ?? 0).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithTopGreaterThanMaxTop() - { - var maxTop = 2; - var query = new Query() { Top = 3 }; - - GoatQueryException exception = Assert.Throws(() => _context.Users.AsQueryable().Apply(query, maxTop)); - } - - // Skip - - [Fact] - public void Test_QueryWithSkip() - { - var query = new Query() { Skip = 3 }; - - var (result, _) = _context.Users.AsQueryable().Apply(query); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().Skip(query.Skip ?? 0).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - // Count - - [Fact] - public void Test_QueryWithCount() - { - var query = new Query() { Count = true }; - - var (result, count) = _context.Users.AsQueryable().Apply(query); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().ToQueryString(); - - Assert.Equal(expectedSql, sql); - Assert.NotNull(count); - } - - // Order by - - [Fact] - public void Test_QueryWithOrderby() - { - var query = new Query() { OrderBy = "firstname" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().OrderBy(x => x.Firstname).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithOrderbyAsc() - { - var query = new Query() { OrderBy = "firstname asc" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().OrderBy(x => x.Firstname).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithOrderbyDesc() - { - var query = new Query() { OrderBy = "firstname desc" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().OrderByDescending(x => x.Firstname).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithOrderbyMultiple() - { - var query = new Query() { OrderBy = "firstname asc, lastname desc" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().OrderBy(x => x.Firstname).ThenByDescending(x => x.Lastname).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - // Select - - [Fact] - public void Test_QueryWithSelect() - { - var query = new Query() { Select = "firstname, lastname" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().Select(x => new { x.Firstname, x.Lastname }).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithSelectInvalidColumn() - { - var query = new Query() { Select = "firstname, invalid-col" }; - - ParseException exception = Assert.Throws(() => _context.Users.AsQueryable().Apply(query)); - } - - // Search - - [Fact] - public void Test_QueryWithSearch() - { - var query = new Query() { Search = "Goat" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null, new UserSearchTestBinder()); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().Where(x => - EF.Functions.Like(x.Firstname, $"%{query.Search}%") || - EF.Functions.Like(x.Lastname, $"%{query.Search}%") - ).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithSearchTermSpace() - { - var query = new Query() { Search = "Goat Query" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null, new UserSearchTestBinder()); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().Where(x => - EF.Functions.Like(x.Firstname, $"%{query.Search}%") || - EF.Functions.Like(x.Lastname, $"%{query.Search}%") - ).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithSearchNilFunc() - { - var query = new Query() { Search = "Goat Query" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - // Filter - - [Fact] - public void Test_QueryWithFilterEquals() - { - var query = new Query() { Filter = "firstname eq 'goat'" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().Where(x => x.Firstname.ToLower() == "goat".ToLower()).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithFilterEqualsCaseInsensitivity() - { - var query = new Query() { Filter = "firstname eq 'goat'" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().Where(x => x.Firstname.ToLower() == "goat".ToLower()).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithFilterNotEquals() - { - var query = new Query() { Filter = "firstname ne 'goat'" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().Where(x => x.Firstname.ToLower() != "goat".ToLower()).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithFilterEqualsAndEquals() - { - var query = new Query() { Filter = "firstname eq 'goat' and lastname eq 'query'" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().Where(x => x.Firstname.ToLower() == "goat".ToLower() && x.Lastname.ToLower() == "query".ToLower()).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithFilterEqualsAndNotEquals() - { - var query = new Query() { Filter = "firstname eq 'goat' and lastname ne 'query'" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().Where(x => x.Firstname.ToLower() == "goat".ToLower() && x.Lastname.ToLower() != "query".ToLower()).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithFilterContains() - { - var query = new Query() { Filter = "firstname contains 'goat'" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable() - .Where(x => x.Firstname.Contains("goat")) - .ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithFilterContainsAndEquals() - { - var query = new Query() { Filter = "firstname contains 'goat' and lastname eq 'query'" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable() - .Where(x => x.Firstname.Contains("goat") && x.Lastname.ToLower() == "query".ToLower()) - .ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithFilterContainsOrEquals() - { - var query = new Query() { Filter = "firstname contains 'goat' or lastname eq 'query'" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable() - .Where(x => x.Firstname.Contains("goat") || x.Lastname.ToLower() == "query".ToLower()) - .ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithFilterEqualsWithConjunction() - { - var query = new Query() { Filter = "firstname eq 'goatand'" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable() - .Where(x => x.Firstname.ToLower() == "goatand".ToLower()) - .ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithFilterEqualsWithConjunctionAndSpaces() - { - var query = new Query() { Filter = "firstname eq ' and ' or lastname eq ' and or '" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable() - .Where(x => x.Firstname.ToLower() == " and ".ToLower() || x.Lastname.ToLower() == " and or ".ToLower()) - .ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithFilterCustomJsonPropertyName() - { - var query = new Query() { Filter = "displayName eq 'John'" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable() - .Where(x => x.UserName.ToLower() == "John".ToLower()) - .ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithFilterCustomColumnName() - { - var query = new Query() { Filter = "gender eq 'Male'" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable() - .Where(x => x.Gender.ToLower() == "Male".ToLower()) - .ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithFilterGuidEquals() - { - var query = new Query() { Filter = "id eq '7ac156a2-f938-43cb-8652-8b14a8b471de'" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().Where(x => x.Id == new Guid("7ac156a2-f938-43cb-8652-8b14a8b471de")).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } - - [Fact] - public void Test_QueryWithFilterIntegerEquals() - { - var query = new Query() { Filter = "age eq 4" }; - - var (result, _) = _context.Users.AsQueryable().Apply(query, null); - var sql = result.ToQueryString(); - - var expectedSql = _context.Users.AsQueryable().Where(x => x.Age == 4).ToQueryString(); - - Assert.Equal(expectedSql, sql); - } -} \ No newline at end of file diff --git a/tests/Usings.cs b/tests/Usings.cs deleted file mode 100644 index 8c927eb..0000000 --- a/tests/Usings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; \ No newline at end of file