From 714bd304e60f17f1c9abf09efeb35e1f59995c15 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Wed, 15 May 2024 01:50:41 +0100 Subject: [PATCH 01/60] start of something new --- examples/controller/ApplicationDbContext.cs | 8 - .../controller/Controllers/HomeController.cs | 12 - .../controller/Controllers/UsersController.cs | 34 -- examples/controller/Dtos/UserDto.cs | 21 - examples/controller/Entities/User.cs | 29 -- .../controller/Interfaces/IUserService.cs | 4 - .../controller/Profiles/AutoMapperProfile.cs | 9 - examples/controller/Program.cs | 68 --- .../controller/Properties/launchSettings.json | 41 -- .../SearchBinders/UserDtoSearchBinder.cs | 11 - examples/controller/Services/UserService.cs | 22 - .../controller/appsettings.Development.json | 8 - examples/controller/appsettings.json | 9 - examples/controller/controller.csproj | 17 - examples/minimal/ApplicationDbContext.cs | 8 - examples/minimal/Dtos/UserDto.cs | 19 - examples/minimal/Entities/User.cs | 26 -- examples/minimal/Interfaces/IUserService.cs | 4 - .../minimal/Profiles/AutoMapperProfile.cs | 9 - examples/minimal/Program.cs | 69 --- .../minimal/Properties/launchSettings.json | 41 -- .../SearchBinders/UserDtoSearchBinder.cs | 11 - examples/minimal/Services/UserService.cs | 22 - examples/minimal/minimal.csproj | 17 - src/Attributes/EnableQueryAttribute.cs | 119 ----- src/Exceptions/GoatQueryException.cs | 16 - src/Extensions/QueryableExtension.cs | 220 ++++++---- src/GoatQueryOpenAPIFilter.cs | 57 --- src/Helpers/StringHelper.cs | 80 ---- src/ISearchBinder.cs | 6 - src/Query.cs | 2 +- src/Responses/PagedResponse.cs | 6 +- src/Responses/QueryErrorResponse.cs | 11 - src/goatquery-dotnet.csproj | 6 +- tests/OrderByLexerTest.cs | 42 ++ tests/TestDbContext.cs | 29 -- tests/TestWithSqlite.cs | 25 -- tests/Tests.cs | 409 ------------------ tests/Usings.cs | 1 - tests/tests.csproj | 3 - 40 files changed, 184 insertions(+), 1367 deletions(-) delete mode 100644 examples/controller/ApplicationDbContext.cs delete mode 100644 examples/controller/Controllers/HomeController.cs delete mode 100644 examples/controller/Controllers/UsersController.cs delete mode 100644 examples/controller/Dtos/UserDto.cs delete mode 100644 examples/controller/Entities/User.cs delete mode 100644 examples/controller/Interfaces/IUserService.cs delete mode 100644 examples/controller/Profiles/AutoMapperProfile.cs delete mode 100644 examples/controller/Program.cs delete mode 100644 examples/controller/Properties/launchSettings.json delete mode 100644 examples/controller/SearchBinders/UserDtoSearchBinder.cs delete mode 100644 examples/controller/Services/UserService.cs delete mode 100644 examples/controller/appsettings.Development.json delete mode 100644 examples/controller/appsettings.json delete mode 100644 examples/controller/controller.csproj delete mode 100644 examples/minimal/ApplicationDbContext.cs delete mode 100644 examples/minimal/Dtos/UserDto.cs delete mode 100644 examples/minimal/Entities/User.cs delete mode 100644 examples/minimal/Interfaces/IUserService.cs delete mode 100644 examples/minimal/Profiles/AutoMapperProfile.cs delete mode 100644 examples/minimal/Program.cs delete mode 100644 examples/minimal/Properties/launchSettings.json delete mode 100644 examples/minimal/SearchBinders/UserDtoSearchBinder.cs delete mode 100644 examples/minimal/Services/UserService.cs delete mode 100644 examples/minimal/minimal.csproj delete mode 100644 src/Attributes/EnableQueryAttribute.cs delete mode 100644 src/Exceptions/GoatQueryException.cs delete mode 100644 src/GoatQueryOpenAPIFilter.cs delete mode 100644 src/Helpers/StringHelper.cs delete mode 100644 src/ISearchBinder.cs delete mode 100644 src/Responses/QueryErrorResponse.cs create mode 100644 tests/OrderByLexerTest.cs delete mode 100644 tests/TestDbContext.cs delete mode 100644 tests/TestWithSqlite.cs delete mode 100644 tests/Tests.cs delete mode 100644 tests/Usings.cs diff --git a/examples/controller/ApplicationDbContext.cs b/examples/controller/ApplicationDbContext.cs deleted file mode 100644 index 5735ca9..0000000 --- a/examples/controller/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/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/Profiles/AutoMapperProfile.cs b/examples/controller/Profiles/AutoMapperProfile.cs deleted file mode 100644 index cd2efb2..0000000 --- a/examples/controller/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/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 index 36d443c..7058c36 100644 --- a/src/Extensions/QueryableExtension.cs +++ b/src/Extensions/QueryableExtension.cs @@ -1,7 +1,6 @@ -using System.Linq.Dynamic.Core; -using System.Reflection; -using System.Text; -using System.Text.Json.Serialization; +using System; +using System.Collections.Generic; +using System.Linq; public static class QueryableExtension { @@ -12,115 +11,170 @@ public static class QueryableExtension {"contains", "Contains"}, }; - public static (IQueryable, int?) Apply(this IQueryable queryable, Query query, int? maxTop = null, ISearchBinder? searchBinder = null) + public static (IQueryable, int?) Apply(this IQueryable queryable, Query query) { - var result = (IQueryable)queryable; - - if (maxTop is not null && query.Top > maxTop) + // Order by + if (!string.IsNullOrEmpty(query.OrderBy)) { - throw new GoatQueryException("The value supplied for the query parameter 'Top' was greater than the maximum top allowed for this resource"); + var lexer = new QueryLexer(query.OrderBy); + var parser = new QueryParser(lexer); + + parser.ParseOrderBy(); + + // queryable = queryable.OrderBy(); } - // Filter - if (!string.IsNullOrEmpty(query.Filter)) - { - var filters = StringHelper.SplitString(query.Filter); + return (queryable, 0); + } +} - var where = new StringBuilder(); +public enum TokenType +{ + EOF, + ILLEGAL, + IDENT, - for (int i = 0; i < filters.Count; i++) - { - var filter = filters[i]; - var opts = StringHelper.SplitStringByWhitespace(filter.Trim()); + // Keywords + ASC, + DESC, +} - if (opts.Count != 3) - { - continue; - } +public class Token +{ + public TokenType Type { get; set; } + public string Literal { get; set; } = string.Empty; - if (i > 0) - { - var prev = filters[i - 1]; - where.Append($" {prev.Trim()} "); - } + private static readonly Dictionary _keywords = new Dictionary() + { + { "asc", TokenType.ASC }, + { "desc", TokenType.DESC }, + }; - var property = opts[0]; - var operand = opts[1]; - var value = opts[2].Replace("'", "\""); + public Token(TokenType type, char literal) + { + Type = type; + Literal = literal.ToString(); + } - string? propertyName = typeof(T).GetProperties().FirstOrDefault(x => x.GetCustomAttribute()?.Name == property)?.Name; + public static TokenType GetIdentifierTokenType(string identifier) + { + if (_keywords.TryGetValue(identifier, out var token)) + { + return token; + } - if (!string.IsNullOrEmpty(propertyName)) - { - property = propertyName; - } + return TokenType.IDENT; + } +} - 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}"); - } - } +public sealed class QueryParser +{ + private readonly QueryLexer _lexer; + private Token _currentToken { get; set; } = default!; + private Token _peekToken { get; set; } = default!; - result = result.Where(where.ToString()); - } + public QueryParser(QueryLexer lexer) + { + _lexer = lexer; - // Search - if (searchBinder is not null && !string.IsNullOrEmpty(query.Search)) - { - var searchExpression = searchBinder.Bind(query.Search); + NextToken(); + NextToken(); + } - if (searchExpression is null) - { - throw new GoatQueryException("search binder does not return valid expression that can be parsed to where clause"); - } + private void NextToken() + { + _currentToken = _peekToken; + _peekToken = _lexer.NextToken(); + } - result = result.Where(searchExpression); + public void ParseOrderBy() + { + while (!CurrentTokenIs(TokenType.EOF)) + { + Console.WriteLine(_currentToken); } + } - int? count = null; + private bool CurrentTokenIs(TokenType token) + { + return _currentToken.Type == token; + } +} + +public sealed class QueryLexer +{ + private readonly string _input; + private int _position { get; set; } + private int _readPosition { get; set; } + private char _character { get; set; } - // Count - if (query.Count ?? false) + public QueryLexer(string input) + { + _input = input; + + ReadCharacter(); + } + + private void ReadCharacter() + { + if (_readPosition >= _input.Length) { - count = result.Count(); + _character = char.MinValue; } - - // Order by - if (!string.IsNullOrEmpty(query.OrderBy)) + else { - result = result.OrderBy(query.OrderBy); + _character = _input[_readPosition]; } - // Select - if (!string.IsNullOrEmpty(query.Select)) + _position = _readPosition; + _readPosition++; + } + + public Token NextToken() + { + var token = new Token(TokenType.ILLEGAL, _character); + + SkipWhitespace(); + + switch (_character) { - result = result.Select($"new {{ {query.Select} }}"); + default: + if (IsLetter(_character)) + { + token.Literal = ReadIdentifier(); + token.Type = Token.GetIdentifierTokenType(token.Literal); + return token; + } + break; } - // Skip - if (query.Skip > 0) + ReadCharacter(); + + return token; + } + + private string ReadIdentifier() + { + var currentPosition = _position; + + while (IsLetter(_character)) { - result = result.Skip(query.Skip ?? 0); + ReadCharacter(); } - // Top - if (query.Top > 0) + return _input.Substring(currentPosition, _position - currentPosition); + } + + private bool IsLetter(char ch) + { + return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_'; + } + + private void SkipWhitespace() + { + while (_character == ' ' || _character == '\t' || _character == '\n' || _character == '\r') { - result = result.Take(query.Top ?? 0); + ReadCharacter(); } - - return (result, count); } -} +} \ No newline at end of file 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/ISearchBinder.cs b/src/ISearchBinder.cs deleted file mode 100644 index 892f6a4..0000000 --- a/src/ISearchBinder.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Linq.Expressions; - -public interface ISearchBinder -{ - Expression> Bind(string searchTerm); -} diff --git a/src/Query.cs b/src/Query.cs index f31d11d..fbd7bfb 100644 --- a/src/Query.cs +++ b/src/Query.cs @@ -1,4 +1,4 @@ -public record Query +public sealed class Query { public int? Top { get; set; } public int? Skip { get; set; } diff --git a/src/Responses/PagedResponse.cs b/src/Responses/PagedResponse.cs index 3694492..d32b0cf 100644 --- a/src/Responses/PagedResponse.cs +++ b/src/Responses/PagedResponse.cs @@ -1,6 +1,6 @@ -using System.Text.Json.Serialization; +using System.Collections.Generic; -public class PagedResponse +public sealed class PagedResponse { public PagedResponse(IEnumerable data, int? count = null) { @@ -13,7 +13,7 @@ public PagedResponse() Value = new List(); } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? Count { get; set; } public IEnumerable Value { get; set; } 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/src/goatquery-dotnet.csproj b/src/goatquery-dotnet.csproj index 19d643c..f1e107e 100644 --- a/src/goatquery-dotnet.csproj +++ b/src/goatquery-dotnet.csproj @@ -1,9 +1,8 @@ - net8.0 + netstandard2.1 goatquery-dotnet - enable enable GoatQuery @@ -16,9 +15,6 @@ - - - diff --git a/tests/OrderByLexerTest.cs b/tests/OrderByLexerTest.cs new file mode 100644 index 0000000..ba553e4 --- /dev/null +++ b/tests/OrderByLexerTest.cs @@ -0,0 +1,42 @@ +using Xunit; + +public sealed record Expected +{ + public TokenType Token { get; set; } + public string Literal { get; set; } = string.Empty; + + public Expected(TokenType token, string literal) + { + Token = token; + Literal = literal; + } +} + +public sealed class OrderByLexerTest +{ + [Fact] + public void Test_OrderByNextToken() + { + var input = @"id asc + iD desc + "; + + var tests = new Expected[] + { + new Expected(TokenType.IDENT, "id"), + new Expected(TokenType.ASC, "asc"), + new Expected(TokenType.IDENT, "iD"), + new Expected(TokenType.DESC, "desc") + }; + + var lexer = new QueryLexer(input); + + foreach (var test in tests) + { + var token = lexer.NextToken(); + + Assert.Equal(test.Token, token.Type); + Assert.Equal(test.Literal, token.Literal); + } + } +} \ 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 diff --git a/tests/tests.csproj b/tests/tests.csproj index 959e69f..343731a 100644 --- a/tests/tests.csproj +++ b/tests/tests.csproj @@ -4,13 +4,10 @@ net8.0 enable enable - false - - From 204e1e1e82ff871481cc273e4ffae46330055d4a Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Wed, 15 May 2024 11:47:45 +0100 Subject: [PATCH 02/60] wip --- src/Extensions/QueryableExtension.cs | 152 --------------------------- src/Lexer.cs | 81 ++++++++++++++ src/Node.cs | 16 +++ src/OrderByAst.cs | 12 +++ src/Parser.cs | 88 ++++++++++++++++ src/Token.cs | 40 +++++++ tests/OrderByParserTest.cs | 22 ++++ 7 files changed, 259 insertions(+), 152 deletions(-) create mode 100644 src/Lexer.cs create mode 100644 src/Node.cs create mode 100644 src/OrderByAst.cs create mode 100644 src/Parser.cs create mode 100644 src/Token.cs create mode 100644 tests/OrderByParserTest.cs diff --git a/src/Extensions/QueryableExtension.cs b/src/Extensions/QueryableExtension.cs index 7058c36..67c39f3 100644 --- a/src/Extensions/QueryableExtension.cs +++ b/src/Extensions/QueryableExtension.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; @@ -26,155 +25,4 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query return (queryable, 0); } -} - -public enum TokenType -{ - EOF, - ILLEGAL, - IDENT, - - // Keywords - ASC, - DESC, -} - -public class Token -{ - public TokenType Type { get; set; } - public string Literal { get; set; } = string.Empty; - - private static readonly Dictionary _keywords = new Dictionary() - { - { "asc", TokenType.ASC }, - { "desc", TokenType.DESC }, - }; - - public Token(TokenType type, char literal) - { - Type = type; - Literal = literal.ToString(); - } - - public static TokenType GetIdentifierTokenType(string identifier) - { - if (_keywords.TryGetValue(identifier, out var token)) - { - return token; - } - - return TokenType.IDENT; - } -} - -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 void ParseOrderBy() - { - while (!CurrentTokenIs(TokenType.EOF)) - { - Console.WriteLine(_currentToken); - } - } - - private bool CurrentTokenIs(TokenType token) - { - return _currentToken.Type == token; - } -} - -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) - { - default: - if (IsLetter(_character)) - { - token.Literal = ReadIdentifier(); - token.Type = Token.GetIdentifierTokenType(token.Literal); - return token; - } - break; - } - - ReadCharacter(); - - return token; - } - - private string ReadIdentifier() - { - var currentPosition = _position; - - while (IsLetter(_character)) - { - ReadCharacter(); - } - - return _input.Substring(currentPosition, _position - currentPosition); - } - - private bool IsLetter(char ch) - { - return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_'; - } - - private void SkipWhitespace() - { - while (_character == ' ' || _character == '\t' || _character == '\n' || _character == '\r') - { - ReadCharacter(); - } - } } \ No newline at end of file diff --git a/src/Lexer.cs b/src/Lexer.cs new file mode 100644 index 0000000..bec6bc1 --- /dev/null +++ b/src/Lexer.cs @@ -0,0 +1,81 @@ +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; + default: + if (IsLetter(_character)) + { + token.Literal = ReadIdentifier(); + token.Type = Token.GetIdentifierTokenType(token.Literal); + return token; + } + break; + } + + ReadCharacter(); + + return token; + } + + private string ReadIdentifier() + { + var currentPosition = _position; + + while (IsLetter(_character)) + { + ReadCharacter(); + } + + return _input.Substring(currentPosition, _position - currentPosition); + } + + private bool IsLetter(char ch) + { + return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_'; + } + + private void SkipWhitespace() + { + while (_character == ' ' || _character == '\t' || _character == '\n' || _character == '\r') + { + ReadCharacter(); + } + } +} \ No newline at end of file diff --git a/src/Node.cs b/src/Node.cs new file mode 100644 index 0000000..386be11 --- /dev/null +++ b/src/Node.cs @@ -0,0 +1,16 @@ +public abstract class Node +{ + private readonly Token _token; + private readonly string _value; + + public Node(Token token, string value) + { + _token = token; + _value = value; + } + + public string TokenLiteral() + { + return _token.Literal; + } +} \ No newline at end of file diff --git a/src/OrderByAst.cs b/src/OrderByAst.cs new file mode 100644 index 0000000..8dfad06 --- /dev/null +++ b/src/OrderByAst.cs @@ -0,0 +1,12 @@ +public enum OrderByDirection +{ + Ascending, + Descending +} + +public sealed class OrderByStatement : Node +{ + public OrderByDirection Direction { get; set; } + + public OrderByStatement(Token token, string value) : base(token, value) { } +} \ No newline at end of file diff --git a/src/Parser.cs b/src/Parser.cs new file mode 100644 index 0000000..e4523cf --- /dev/null +++ b/src/Parser.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; + +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(); + } + + // "Id asc" + + public IEnumerable ParseOrderBy() + { + var statements = new List(); + + while (!CurrentTokenIs(TokenType.EOF)) + { + Console.WriteLine($"literal: {_currentToken.Literal}"); + + var statement = ParseOrderByStatement(); + if (statement != null) + { + statements.Add(statement); + } + + NextToken(); + } + + return statements; + } + + private OrderByStatement? ParseOrderByStatement() + { + var statement = new OrderByStatement(_currentToken, _currentToken.Literal) + { + Direction = OrderByDirection.Ascending + }; + + if (!ExpectPeek(TokenType.ASC) && !ExpectPeek(TokenType.DESC)) + { + return null; + } + + if (PeekTokenIs(TokenType.DESC)) + { + statement.Direction = OrderByDirection.Descending; + + NextToken(); + } + + return statement; + } + + private bool CurrentTokenIs(TokenType token) + { + return _currentToken.Type == token; + } + + private bool PeekTokenIs(TokenType token) + { + return _peekToken.Type == token; + } + + private bool ExpectPeek(TokenType token) + { + if (PeekTokenIs(token)) + { + NextToken(); + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/src/Token.cs b/src/Token.cs new file mode 100644 index 0000000..7c2ed07 --- /dev/null +++ b/src/Token.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +public enum TokenType +{ + EOF, + ILLEGAL, + IDENT, + + // Keywords + ASC, + DESC, +} + +public class Token +{ + public TokenType Type { get; set; } + public string Literal { get; set; } = string.Empty; + + private static readonly Dictionary _keywords = new Dictionary() + { + { "asc", TokenType.ASC }, + { "desc", TokenType.DESC }, + }; + + public Token(TokenType type, char literal) + { + Type = type; + Literal = literal.ToString(); + } + + public static TokenType GetIdentifierTokenType(string identifier) + { + if (_keywords.TryGetValue(identifier, out var token)) + { + return token; + } + + return TokenType.IDENT; + } +} \ No newline at end of file diff --git a/tests/OrderByParserTest.cs b/tests/OrderByParserTest.cs new file mode 100644 index 0000000..4bb82e6 --- /dev/null +++ b/tests/OrderByParserTest.cs @@ -0,0 +1,22 @@ +using Xunit; + +public sealed class OrderByParserTest +{ + [Theory] + [InlineData("id asc", "id", "asc")] + [InlineData("ID desc", "ID", "desc")] + // [InlineData("Name", "")] + public void Test_ParsingOrderByStatement(string input, string expectedIdentifier, string expectedOrder) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseOrderBy(); + Assert.Single(program); + + var statement = program.FirstOrDefault(); + Assert.NotNull(statement); + + + } +} \ No newline at end of file From 11facb9fa4496de3e681ce82b385c3d77c9d2028 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Thu, 16 May 2024 22:09:09 +0100 Subject: [PATCH 03/60] added ordering --- example/ApplicationDbContext.cs | 8 ++ example/Dto/UserDto.cs | 19 +++ example/Entities/User.cs | 11 ++ example/Profiles/AutoMapperProfile.cs | 9 ++ example/Program.cs | 61 ++++++++ example/example.csproj | 21 +++ src/{ => Ast}/Node.cs | 4 +- src/Ast/OrderByAst.cs | 16 +++ src/Extensions/QueryableExtension.cs | 70 +++++++-- src/{ => Lexer}/Lexer.cs | 0 src/OrderByAst.cs | 12 -- src/{ => Parser}/Parser.cs | 25 ++-- src/Responses/PagedResponse.cs | 3 +- src/{ => Token}/Token.cs | 9 +- src/goatquery-dotnet.csproj | 6 +- tests/OrderByLexerTest.cs | 11 +- tests/OrderByParserTest.cs | 71 +++++++-- tests/OrderByTest.cs | 198 ++++++++++++++++++++++++++ 18 files changed, 504 insertions(+), 50 deletions(-) create mode 100644 example/ApplicationDbContext.cs create mode 100644 example/Dto/UserDto.cs create mode 100644 example/Entities/User.cs create mode 100644 example/Profiles/AutoMapperProfile.cs create mode 100644 example/Program.cs create mode 100644 example/example.csproj rename src/{ => Ast}/Node.cs (63%) create mode 100644 src/Ast/OrderByAst.cs rename src/{ => Lexer}/Lexer.cs (100%) delete mode 100644 src/OrderByAst.cs rename src/{ => Parser}/Parser.cs (81%) rename src/{ => Token}/Token.cs (78%) create mode 100644 tests/OrderByTest.cs diff --git a/example/ApplicationDbContext.cs b/example/ApplicationDbContext.cs new file mode 100644 index 0000000..5735ca9 --- /dev/null +++ b/example/ApplicationDbContext.cs @@ -0,0 +1,8 @@ +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/example/Dto/UserDto.cs b/example/Dto/UserDto.cs new file mode 100644 index 0000000..fa76287 --- /dev/null +++ b/example/Dto/UserDto.cs @@ -0,0 +1,19 @@ +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/example/Entities/User.cs b/example/Entities/User.cs new file mode 100644 index 0000000..ec66c6b --- /dev/null +++ b/example/Entities/User.cs @@ -0,0 +1,11 @@ +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; + public string AvatarUrl { get; set; } = string.Empty; + public bool IsDeleted { get; set; } + public string UserName { get; set; } = string.Empty; + public string Gender { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/example/Profiles/AutoMapperProfile.cs b/example/Profiles/AutoMapperProfile.cs new file mode 100644 index 0000000..cd2efb2 --- /dev/null +++ b/example/Profiles/AutoMapperProfile.cs @@ -0,0 +1,9 @@ +using AutoMapper; + +public class AutoMapperProfile : Profile +{ + public AutoMapperProfile() + { + CreateMap(); + } +} \ No newline at end of file diff --git a/example/Program.cs b/example/Program.cs new file mode 100644 index 0000000..77ce06e --- /dev/null +++ b/example/Program.cs @@ -0,0 +1,61 @@ +using System.Reflection; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Bogus; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Testcontainers.PostgreSql; + +var builder = WebApplication.CreateBuilder(args); + +var postgreSqlContainer = new PostgreSqlBuilder() + .WithImage("postgres:15") + .Build(); + +await postgreSqlContainer.StartAsync(); + +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 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.MapGet("/users", (ApplicationDbContext db, [FromServices] IMapper mapper, [AsParameters] Query query) => +{ + var (users, _) = db.Users + .Where(x => !x.IsDeleted) + .ProjectTo(mapper.ConfigurationProvider) + .Apply(query); + + return TypedResults.Ok(new PagedResponse(users.ToList(), 0)); +}); + + +app.Run(); diff --git a/example/example.csproj b/example/example.csproj new file mode 100644 index 0000000..0251512 --- /dev/null +++ b/example/example.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/src/Node.cs b/src/Ast/Node.cs similarity index 63% rename from src/Node.cs rename to src/Ast/Node.cs index 386be11..6b8722a 100644 --- a/src/Node.cs +++ b/src/Ast/Node.cs @@ -1,12 +1,10 @@ public abstract class Node { private readonly Token _token; - private readonly string _value; - public Node(Token token, string value) + public Node(Token token) { _token = token; - _value = value; } public string TokenLiteral() diff --git a/src/Ast/OrderByAst.cs b/src/Ast/OrderByAst.cs new file mode 100644 index 0000000..039a551 --- /dev/null +++ b/src/Ast/OrderByAst.cs @@ -0,0 +1,16 @@ +public enum OrderByDirection +{ + Ascending, + 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/Extensions/QueryableExtension.cs b/src/Extensions/QueryableExtension.cs index 67c39f3..f3feee7 100644 --- a/src/Extensions/QueryableExtension.cs +++ b/src/Extensions/QueryableExtension.cs @@ -1,28 +1,78 @@ -using System.Collections.Generic; +using System; using System.Linq; +using System.Linq.Expressions; +using System.Reflection; public static class QueryableExtension { - public static Dictionary _filterOperations => new Dictionary - { - {"eq", "=="}, - {"ne", "!="}, - {"contains", "Contains"}, - }; - public static (IQueryable, int?) Apply(this IQueryable queryable, Query query) { + var type = typeof(T); + // Order by if (!string.IsNullOrEmpty(query.OrderBy)) { var lexer = new QueryLexer(query.OrderBy); var parser = new QueryParser(lexer); - parser.ParseOrderBy(); + var statements = parser.ParseOrderBy(); + var isAlreadyOrdered = false; + + foreach (var statement in statements) + { + ParameterExpression parameter = Expression.Parameter(type); + MemberExpression property = Expression.Property(parameter, statement.TokenLiteral()); + LambdaExpression lamba = Expression.Lambda(property, parameter); + + if (isAlreadyOrdered) + { + if (statement.Direction == OrderByDirection.Ascending) + { + var method = GenericMethodOf(_ => Queryable.ThenBy(default, default)).MakeGenericMethod(type, lamba.Body.Type); + + queryable = (IQueryable)method.Invoke(null, new object[] { queryable, lamba }); + } + else if (statement.Direction == OrderByDirection.Descending) + { + var method = GenericMethodOf(_ => Queryable.ThenByDescending(default, default)).MakeGenericMethod(type, lamba.Body.Type); + + queryable = (IQueryable)method.Invoke(null, new object[] { queryable, lamba }); + } + } + else + { + if (statement.Direction == OrderByDirection.Ascending) + { + var method = GenericMethodOf(_ => Queryable.OrderBy(default, default)).MakeGenericMethod(type, lamba.Body.Type); + + queryable = (IQueryable)method.Invoke(null, new object[] { queryable, lamba }); + + isAlreadyOrdered = true; + } + else if (statement.Direction == OrderByDirection.Descending) + { + var method = GenericMethodOf(_ => Queryable.OrderByDescending(default, default)).MakeGenericMethod(type, lamba.Body.Type); - // queryable = queryable.OrderBy(); + queryable = (IQueryable)method.Invoke(null, new object[] { queryable, lamba }); + + isAlreadyOrdered = true; + } + } + } } return (queryable, 0); } + + 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/Lexer.cs b/src/Lexer/Lexer.cs similarity index 100% rename from src/Lexer.cs rename to src/Lexer/Lexer.cs diff --git a/src/OrderByAst.cs b/src/OrderByAst.cs deleted file mode 100644 index 8dfad06..0000000 --- a/src/OrderByAst.cs +++ /dev/null @@ -1,12 +0,0 @@ -public enum OrderByDirection -{ - Ascending, - Descending -} - -public sealed class OrderByStatement : Node -{ - public OrderByDirection Direction { get; set; } - - public OrderByStatement(Token token, string value) : base(token, value) { } -} \ No newline at end of file diff --git a/src/Parser.cs b/src/Parser/Parser.cs similarity index 81% rename from src/Parser.cs rename to src/Parser/Parser.cs index e4523cf..feb89da 100644 --- a/src/Parser.cs +++ b/src/Parser/Parser.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; public sealed class QueryParser @@ -21,15 +20,17 @@ private void NextToken() _peekToken = _lexer.NextToken(); } - // "Id asc" - public IEnumerable ParseOrderBy() { var statements = new List(); while (!CurrentTokenIs(TokenType.EOF)) { - Console.WriteLine($"literal: {_currentToken.Literal}"); + if (!CurrentTokenIs(TokenType.IDENT)) + { + NextToken(); + continue; + } var statement = ParseOrderByStatement(); if (statement != null) @@ -45,15 +46,7 @@ public IEnumerable ParseOrderBy() private OrderByStatement? ParseOrderByStatement() { - var statement = new OrderByStatement(_currentToken, _currentToken.Literal) - { - Direction = OrderByDirection.Ascending - }; - - if (!ExpectPeek(TokenType.ASC) && !ExpectPeek(TokenType.DESC)) - { - return null; - } + var statement = new OrderByStatement(_currentToken); if (PeekTokenIs(TokenType.DESC)) { @@ -61,6 +54,12 @@ public IEnumerable ParseOrderBy() NextToken(); } + else if (PeekTokenIs(TokenType.ASC)) + { + statement.Direction = OrderByDirection.Ascending; + + NextToken(); + } return statement; } diff --git a/src/Responses/PagedResponse.cs b/src/Responses/PagedResponse.cs index d32b0cf..51ce711 100644 --- a/src/Responses/PagedResponse.cs +++ b/src/Responses/PagedResponse.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Text.Json.Serialization; public sealed class PagedResponse { @@ -13,7 +14,7 @@ public PagedResponse() Value = new List(); } - // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? Count { get; set; } public IEnumerable Value { get; set; } diff --git a/src/Token.cs b/src/Token/Token.cs similarity index 78% rename from src/Token.cs rename to src/Token/Token.cs index 7c2ed07..15089f3 100644 --- a/src/Token.cs +++ b/src/Token/Token.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; public enum TokenType @@ -16,7 +17,7 @@ public class Token public TokenType Type { get; set; } public string Literal { get; set; } = string.Empty; - private static readonly Dictionary _keywords = new Dictionary() + private static readonly Dictionary _keywords = new Dictionary(StringComparer.CurrentCultureIgnoreCase) { { "asc", TokenType.ASC }, { "desc", TokenType.DESC }, @@ -28,6 +29,12 @@ public Token(TokenType type, char literal) Literal = literal.ToString(); } + public Token(TokenType type, string literal) + { + Type = type; + Literal = literal; + } + public static TokenType GetIdentifierTokenType(string identifier) { if (_keywords.TryGetValue(identifier, out var token)) diff --git a/src/goatquery-dotnet.csproj b/src/goatquery-dotnet.csproj index f1e107e..0bd693d 100644 --- a/src/goatquery-dotnet.csproj +++ b/src/goatquery-dotnet.csproj @@ -18,7 +18,11 @@ - + + + + + diff --git a/tests/OrderByLexerTest.cs b/tests/OrderByLexerTest.cs index ba553e4..a847fff 100644 --- a/tests/OrderByLexerTest.cs +++ b/tests/OrderByLexerTest.cs @@ -19,6 +19,9 @@ public void Test_OrderByNextToken() { var input = @"id asc iD desc + id aSc + id DeSc + id AsC "; var tests = new Expected[] @@ -26,7 +29,13 @@ iD desc new Expected(TokenType.IDENT, "id"), new Expected(TokenType.ASC, "asc"), new Expected(TokenType.IDENT, "iD"), - new Expected(TokenType.DESC, "desc") + new Expected(TokenType.DESC, "desc"), + new Expected(TokenType.IDENT, "id"), + new Expected(TokenType.ASC, "aSc"), + new Expected(TokenType.IDENT, "id"), + new Expected(TokenType.DESC, "DeSc"), + new Expected(TokenType.IDENT, "id"), + new Expected(TokenType.ASC, "AsC"), }; var lexer = new QueryLexer(input); diff --git a/tests/OrderByParserTest.cs b/tests/OrderByParserTest.cs index 4bb82e6..7bb263e 100644 --- a/tests/OrderByParserTest.cs +++ b/tests/OrderByParserTest.cs @@ -2,21 +2,76 @@ 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) + } + }; + } + [Theory] - [InlineData("id asc", "id", "asc")] - [InlineData("ID desc", "ID", "desc")] - // [InlineData("Name", "")] - public void Test_ParsingOrderByStatement(string input, string expectedIdentifier, string expectedOrder) + [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.Single(program); + Assert.Equal(expected.Count(), program.Count()); - var statement = program.FirstOrDefault(); - Assert.NotNull(statement); + 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/tests/OrderByTest.cs b/tests/OrderByTest.cs new file mode 100644 index 0000000..679a690 --- /dev/null +++ b/tests/OrderByTest.cs @@ -0,0 +1,198 @@ +using Xunit; + +public sealed record User +{ + public int Id { get; set; } + public string Firstname { get; set; } = string.Empty; +} + +public sealed class OrderByTest +{ + public static IEnumerable Parameters() + { + yield return new object[] + { + "id desc, firstname asc", + new User[] + { + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 1, Firstname = "Jane" }, + } + }; + + yield return new object[] + { + "id desc, firstname desc", + new User[] + { + new User { Id = 3, Firstname = "Egg" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + } + }; + + yield return new object[] + { + "id desc", + new User[] + { + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + } + }; + + yield return new object[] + { + "id asc", + new User[] + { + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" }, + } + }; + + yield return new object[] + { + "id", + new User[] + { + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" }, + } + }; + + yield return new object[] + { + "id asc", + new User[] + { + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" }, + } + }; + + yield return new object[] + { + "Id asc", + new User[] + { + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" }, + } + }; + + yield return new object[] + { + "ID asc", + new User[] + { + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" }, + } + }; + + yield return new object[] + { + "iD asc", + new User[] + { + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" }, + } + }; + + yield return new object[] + { + "id Asc", + new User[] + { + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" }, + } + }; + + yield return new object[] + { + "id aSc", + new User[] + { + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" }, + } + }; + } + + [Theory] + [MemberData(nameof(Parameters))] + public void Test_OrderBy(string orderby, IEnumerable expected) + { + var users = new List{ + new User { Id = 2, Firstname = "John" }, + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + OrderBy = orderby + }; + + var (queryable, _) = users.Apply(query); + var results = queryable.ToArray(); + + for (var i = 0; i < expected.Count(); i++) + { + var expectedUser = expected.ElementAt(i); + var user = results.ElementAt(i); + + Assert.Equal(expectedUser.Id, user.Id); + Assert.Equal(expectedUser.Firstname, user.Firstname); + } + } +} \ No newline at end of file From 159e30b56ec46a5798fa76e0a5f2701eb39d2438 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Thu, 16 May 2024 22:45:58 +0100 Subject: [PATCH 04/60] added skip & top --- src/Exceptions/GoatQueryException.cs | 18 ++++ src/Extensions/QueryableExtension.cs | 24 ++++- src/QueryOptions.cs | 4 + src/Token/Token.cs | 2 +- tests/{ => Orderby}/OrderByLexerTest.cs | 0 tests/{ => Orderby}/OrderByParserTest.cs | 0 tests/{ => Orderby}/OrderByTest.cs | 0 tests/Top/SkipTest.cs | 111 +++++++++++++++++++++++ tests/Top/TopTest.cs | 103 +++++++++++++++++++++ 9 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 src/Exceptions/GoatQueryException.cs create mode 100644 src/QueryOptions.cs rename tests/{ => Orderby}/OrderByLexerTest.cs (100%) rename tests/{ => Orderby}/OrderByParserTest.cs (100%) rename tests/{ => Orderby}/OrderByTest.cs (100%) create mode 100644 tests/Top/SkipTest.cs create mode 100644 tests/Top/TopTest.cs diff --git a/src/Exceptions/GoatQueryException.cs b/src/Exceptions/GoatQueryException.cs new file mode 100644 index 0000000..dcf44da --- /dev/null +++ b/src/Exceptions/GoatQueryException.cs @@ -0,0 +1,18 @@ +using System; + +public sealed 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 index f3feee7..f1a5915 100644 --- a/src/Extensions/QueryableExtension.cs +++ b/src/Extensions/QueryableExtension.cs @@ -5,8 +5,13 @@ public static class QueryableExtension { - public static (IQueryable, int?) Apply(this IQueryable queryable, Query query) + public static (IQueryable, int?) Apply(this IQueryable queryable, Query query, QueryOptions? options = null) { + if (query.Top > options?.MaxTop) + { + throw new GoatQueryException("The value supplied for the query parameter 'Top' was greater than the maximum top allowed for this resource"); + } + var type = typeof(T); // Order by @@ -61,6 +66,23 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query } } + // 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 (queryable, 0); } diff --git a/src/QueryOptions.cs b/src/QueryOptions.cs new file mode 100644 index 0000000..442a727 --- /dev/null +++ b/src/QueryOptions.cs @@ -0,0 +1,4 @@ +public sealed class QueryOptions +{ + public int MaxTop { get; set; } +} \ No newline at end of file diff --git a/src/Token/Token.cs b/src/Token/Token.cs index 15089f3..94f8b4c 100644 --- a/src/Token/Token.cs +++ b/src/Token/Token.cs @@ -12,7 +12,7 @@ public enum TokenType DESC, } -public class Token +public sealed class Token { public TokenType Type { get; set; } public string Literal { get; set; } = string.Empty; diff --git a/tests/OrderByLexerTest.cs b/tests/Orderby/OrderByLexerTest.cs similarity index 100% rename from tests/OrderByLexerTest.cs rename to tests/Orderby/OrderByLexerTest.cs diff --git a/tests/OrderByParserTest.cs b/tests/Orderby/OrderByParserTest.cs similarity index 100% rename from tests/OrderByParserTest.cs rename to tests/Orderby/OrderByParserTest.cs diff --git a/tests/OrderByTest.cs b/tests/Orderby/OrderByTest.cs similarity index 100% rename from tests/OrderByTest.cs rename to tests/Orderby/OrderByTest.cs diff --git a/tests/Top/SkipTest.cs b/tests/Top/SkipTest.cs new file mode 100644 index 0000000..ed1e773 --- /dev/null +++ b/tests/Top/SkipTest.cs @@ -0,0 +1,111 @@ +using Xunit; + +public sealed class SkipTest +{ + public static IEnumerable Parameters() + { + yield return new object[] + { + 1, + new User[] + { + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + } + }; + + yield return new object[] + { + 2, + new User[] + { + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + } + }; + + yield return new object[] + { + 3, + new User[] + { + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + } + }; + + yield return new object[] + { + 4, + new User[] + { + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + } + }; + + yield return new object[] + { + 5, + new User[] + { + new User { Id = 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 { Id = 1, Firstname = "Harry" }, + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Skip = skip + }; + + var (queryable, _) = users.Apply(query); + var results = queryable.ToArray(); + + for (var i = 0; i < expected.Count(); i++) + { + var expectedUser = expected.ElementAt(i); + var user = results.ElementAt(i); + + Assert.Equal(expectedUser.Id, user.Id); + Assert.Equal(expectedUser.Firstname, user.Firstname); + } + } +} \ No newline at end of file diff --git a/tests/Top/TopTest.cs b/tests/Top/TopTest.cs new file mode 100644 index 0000000..9a6eeef --- /dev/null +++ b/tests/Top/TopTest.cs @@ -0,0 +1,103 @@ +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 { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Top = top + }; + + var (queryable, _) = users.Apply(query); + var results = queryable.ToArray(); + + Assert.Equal(expectedCount, results.Count()); + } + + [Theory] + [InlineData(-1, 4)] + [InlineData(0, 4)] + [InlineData(1, 1)] + [InlineData(2, 2)] + [InlineData(3, 3)] + [InlineData(4, 4)] + // [InlineData(5, 4)] + // [InlineData(100, 4)] + // [InlineData(100_000, 4)] + public void Test_TopWithMaxTop(int top, int expectedCount) + { + var users = new List{ + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Top = top + }; + + var queryOptions = new QueryOptions + { + MaxTop = 4 + }; + + var (queryable, _) = users.Apply(query, queryOptions); + var results = queryable.ToArray(); + + Assert.Equal(expectedCount, results.Count()); + } + + [Theory] + [InlineData(5)] + [InlineData(100)] + [InlineData(100_000)] + public void Test_TopWithMaxTopThrowsException(int top) + { + var users = new List{ + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Top = top + }; + + var queryOptions = new QueryOptions + { + MaxTop = 4 + }; + + Action action = () => users.Apply(query, queryOptions); + + Assert.Throws(action); + } +} \ No newline at end of file From 56bfd809119ec70a43ef6446cf873abd77e4810d Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Fri, 17 May 2024 19:03:04 +0100 Subject: [PATCH 05/60] added count --- src/Extensions/QueryableExtension.cs | 10 +++++- tests/Count/CountTest.cs | 51 ++++++++++++++++++++++++++++ tests/{Top => Skip}/SkipTest.cs | 0 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 tests/Count/CountTest.cs rename tests/{Top => Skip}/SkipTest.cs (100%) diff --git a/src/Extensions/QueryableExtension.cs b/src/Extensions/QueryableExtension.cs index f1a5915..70c4cf4 100644 --- a/src/Extensions/QueryableExtension.cs +++ b/src/Extensions/QueryableExtension.cs @@ -14,6 +14,14 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query var type = typeof(T); + // Count + int? count = null; + + if (query.Count ?? false) + { + count = queryable.Count(); + } + // Order by if (!string.IsNullOrEmpty(query.OrderBy)) { @@ -83,7 +91,7 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query queryable = queryable.Take(options.MaxTop); } - return (queryable, 0); + return (queryable, count); } private static MethodInfo GenericMethodOf(Expression> expression) diff --git a/tests/Count/CountTest.cs b/tests/Count/CountTest.cs new file mode 100644 index 0000000..2467399 --- /dev/null +++ b/tests/Count/CountTest.cs @@ -0,0 +1,51 @@ +using Xunit; + +public sealed class CountTest +{ + + [Fact] + public void Test_CountWithTrue() + { + var users = new List{ + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Count = true + }; + + var (queryable, count) = users.Apply(query); + var results = queryable.ToArray(); + + Assert.Equal(6, count); + Assert.Equal(6, results.Count()); + } + + [Fact] + public void Test_CountWithFalse() + { + var users = new List{ + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Count = false + }; + + var (_, count) = users.Apply(query); + + Assert.Null(count); + } +} \ No newline at end of file diff --git a/tests/Top/SkipTest.cs b/tests/Skip/SkipTest.cs similarity index 100% rename from tests/Top/SkipTest.cs rename to tests/Skip/SkipTest.cs From 282593535e3b211a96afa98237145649617f8fd2 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Fri, 17 May 2024 19:21:46 +0100 Subject: [PATCH 06/60] added search --- src/Extensions/QueryableExtension.cs | 15 ++++++++- src/ISearchBinder.cs | 7 +++++ tests/Search/SearchTest.cs | 46 ++++++++++++++++++++++++++++ tests/Top/TopTest.cs | 4 +-- 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 src/ISearchBinder.cs create mode 100644 tests/Search/SearchTest.cs diff --git a/src/Extensions/QueryableExtension.cs b/src/Extensions/QueryableExtension.cs index 70c4cf4..04983b8 100644 --- a/src/Extensions/QueryableExtension.cs +++ b/src/Extensions/QueryableExtension.cs @@ -5,7 +5,7 @@ public static class QueryableExtension { - public static (IQueryable, int?) Apply(this IQueryable queryable, Query query, QueryOptions? options = null) + public static (IQueryable, int?) Apply(this IQueryable queryable, Query query, ISearchBinder? searchBinder = null, QueryOptions? options = null) { if (query.Top > options?.MaxTop) { @@ -14,6 +14,19 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query var type = typeof(T); + // Search + if (searchBinder != null && !string.IsNullOrEmpty(query.Search)) + { + var searchExpression = searchBinder.Bind(query.Search); + + if (searchExpression is null) + { + throw new GoatQueryException("Cannot parse search binder expression"); + } + + queryable = queryable.Where(searchExpression); + } + // Count int? count = null; diff --git a/src/ISearchBinder.cs b/src/ISearchBinder.cs new file mode 100644 index 0000000..eb78a2e --- /dev/null +++ b/src/ISearchBinder.cs @@ -0,0 +1,7 @@ +using System; +using System.Linq.Expressions; + +public interface ISearchBinder +{ + Expression> Bind(string searchTerm); +} \ No newline at end of file diff --git a/tests/Search/SearchTest.cs b/tests/Search/SearchTest.cs new file mode 100644 index 0000000..b5592f3 --- /dev/null +++ b/tests/Search/SearchTest.cs @@ -0,0 +1,46 @@ +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 { Id = 2, Firstname = "John" }, + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Search = searchTerm + }; + + var (queryable, _) = users.Apply(query, new UserSearchTestBinder()); + var results = queryable.ToArray(); + + Assert.Equal(expectedCount, results.Count()); + } +} \ No newline at end of file diff --git a/tests/Top/TopTest.cs b/tests/Top/TopTest.cs index 9a6eeef..ba2e309 100644 --- a/tests/Top/TopTest.cs +++ b/tests/Top/TopTest.cs @@ -65,7 +65,7 @@ public void Test_TopWithMaxTop(int top, int expectedCount) MaxTop = 4 }; - var (queryable, _) = users.Apply(query, queryOptions); + var (queryable, _) = users.Apply(query, null, queryOptions); var results = queryable.ToArray(); Assert.Equal(expectedCount, results.Count()); @@ -96,7 +96,7 @@ public void Test_TopWithMaxTopThrowsException(int top) MaxTop = 4 }; - Action action = () => users.Apply(query, queryOptions); + Action action = () => users.Apply(query, null, queryOptions); Assert.Throws(action); } From 8e3da6f8fec6eae462c14462f384579749610863 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Sat, 18 May 2024 12:53:13 +0100 Subject: [PATCH 07/60] started filtering --- example/Dto/UserDto.cs | 14 +------ example/Entities/User.cs | 5 +-- example/Program.cs | 13 +++--- src/Ast/Identifier.cs | 9 ++++ src/Ast/InfixExpression.cs | 12 ++++++ src/Ast/StringLiteral.cs | 19 +++++++++ src/Extensions/QueryableExtension.cs | 44 ++++++++++++++++++-- src/Lexer/Lexer.cs | 43 ++++++++++++++++++++ src/Parser/Parser.cs | 61 ++++++++++++++++++++++++++++ src/Token/Token.cs | 4 ++ tests/Filter/FilterLexerTest.cs | 33 +++++++++++++++ tests/Filter/FilterParserTest.cs | 36 ++++++++++++++++ tests/Filter/FilterTest.cs | 59 +++++++++++++++++++++++++++ 13 files changed, 326 insertions(+), 26 deletions(-) create mode 100644 src/Ast/Identifier.cs create mode 100644 src/Ast/InfixExpression.cs create mode 100644 src/Ast/StringLiteral.cs create mode 100644 tests/Filter/FilterLexerTest.cs create mode 100644 tests/Filter/FilterParserTest.cs create mode 100644 tests/Filter/FilterTest.cs diff --git a/example/Dto/UserDto.cs b/example/Dto/UserDto.cs index fa76287..e411609 100644 --- a/example/Dto/UserDto.cs +++ b/example/Dto/UserDto.cs @@ -1,19 +1,7 @@ -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/example/Entities/User.cs b/example/Entities/User.cs index ec66c6b..479f6cb 100644 --- a/example/Entities/User.cs +++ b/example/Entities/User.cs @@ -3,9 +3,6 @@ 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; - public string AvatarUrl { get; set; } = string.Empty; + public int Age { get; set; } public bool IsDeleted { get; set; } - public string UserName { get; set; } = string.Empty; - public string Gender { get; set; } = string.Empty; } \ No newline at end of file diff --git a/example/Program.cs b/example/Program.cs index 77ce06e..df55539 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -6,6 +6,8 @@ using Microsoft.EntityFrameworkCore; using Testcontainers.PostgreSql; +Randomizer.Seed = new Random(123123123); + var builder = WebApplication.CreateBuilder(args); var postgreSqlContainer = new PostgreSqlBuilder() @@ -34,10 +36,7 @@ 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.Age, f => f.Random.Int(0, 100)) .RuleFor(x => x.IsDeleted, f => f.Random.Bool()); context.Users.AddRange(users.Generate(1_000)); @@ -49,12 +48,14 @@ app.MapGet("/users", (ApplicationDbContext db, [FromServices] IMapper mapper, [AsParameters] Query query) => { - var (users, _) = db.Users + var (users, count) = db.Users .Where(x => !x.IsDeleted) .ProjectTo(mapper.ConfigurationProvider) .Apply(query); - return TypedResults.Ok(new PagedResponse(users.ToList(), 0)); + var response = new PagedResponse(users.ToList(), count); + + return TypedResults.Ok(response); }); diff --git a/src/Ast/Identifier.cs b/src/Ast/Identifier.cs new file mode 100644 index 0000000..c383daa --- /dev/null +++ b/src/Ast/Identifier.cs @@ -0,0 +1,9 @@ +public sealed class Identifier : Node +{ + 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/Ast/InfixExpression.cs b/src/Ast/InfixExpression.cs new file mode 100644 index 0000000..9afb82e --- /dev/null +++ b/src/Ast/InfixExpression.cs @@ -0,0 +1,12 @@ +public sealed class InfixExpression : Node +{ + public Identifier Left { get; set; } + public string Operator { get; set; } + public Node Right { get; set; } = default!; + + public InfixExpression(Token token, Identifier left, string op) : base(token) + { + Left = left; + Operator = op; + } +} \ No newline at end of file diff --git a/src/Ast/StringLiteral.cs b/src/Ast/StringLiteral.cs new file mode 100644 index 0000000..933f820 --- /dev/null +++ b/src/Ast/StringLiteral.cs @@ -0,0 +1,19 @@ +public sealed class StringLiteral : Node +{ + public string Value { get; set; } + + public StringLiteral(Token token, string value) : base(token) + { + Value = value; + } +} + +public sealed class IntegerLiteral : Node +{ + public int Value { get; set; } + + public IntegerLiteral(Token token, int value) : base(token) + { + Value = value; + } +} \ No newline at end of file diff --git a/src/Extensions/QueryableExtension.cs b/src/Extensions/QueryableExtension.cs index 04983b8..1f93163 100644 --- a/src/Extensions/QueryableExtension.cs +++ b/src/Extensions/QueryableExtension.cs @@ -14,6 +14,43 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query var type = typeof(T); + // Filter + if (!string.IsNullOrEmpty(query.Filter)) + { + var lexer = new QueryLexer(query.Filter); + var parser = new QueryParser(lexer); + var statements = parser.ParseFilter(); + + ParameterExpression parameter = Expression.Parameter(type); + Expression? binaryExpression = null; + + foreach (var statement in statements) + { + var property = Expression.Property(parameter, statement.Left.TokenLiteral()); + ConstantExpression? value = null; + + switch (statement.Right) + { + case IntegerLiteral literal: + value = Expression.Constant(literal.Value, property.Type); + break; + case StringLiteral literal: + value = Expression.Constant(literal.Value, property.Type); + break; + default: + break; + } + + var expression = Expression.Equal(property, value); + + binaryExpression = binaryExpression == null ? expression : Expression.AndAlso(binaryExpression, expression); + } + + var exp = Expression.Lambda>(binaryExpression, parameter); + + queryable = queryable.Where(exp); + } + // Search if (searchBinder != null && !string.IsNullOrEmpty(query.Search)) { @@ -44,11 +81,12 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query var statements = parser.ParseOrderBy(); var isAlreadyOrdered = false; + var parameter = Expression.Parameter(type); + foreach (var statement in statements) { - ParameterExpression parameter = Expression.Parameter(type); - MemberExpression property = Expression.Property(parameter, statement.TokenLiteral()); - LambdaExpression lamba = Expression.Lambda(property, parameter); + var property = Expression.Property(parameter, statement.TokenLiteral()); + var lamba = Expression.Lambda(property, parameter); if (isAlreadyOrdered) { diff --git a/src/Lexer/Lexer.cs b/src/Lexer/Lexer.cs index bec6bc1..f2c7013 100644 --- a/src/Lexer/Lexer.cs +++ b/src/Lexer/Lexer.cs @@ -39,6 +39,10 @@ public Token NextToken() token.Literal = ""; token.Type = TokenType.EOF; break; + case '\'': + token.Type = TokenType.STRING; + token.Literal = ReadString(); + break; default: if (IsLetter(_character)) { @@ -46,6 +50,12 @@ public Token NextToken() token.Type = Token.GetIdentifierTokenType(token.Literal); return token; } + else if (IsDigit(_character)) + { + token.Literal = ReadNumber(); + token.Type = TokenType.INT; + return token; + } break; } @@ -71,6 +81,11 @@ 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') @@ -78,4 +93,32 @@ private void SkipWhitespace() ReadCharacter(); } } + + private string ReadString() + { + var currentPosition = _position + 1; + + while (true) + { + ReadCharacter(); + if (_character == '\'' || _character == 0) + { + break; + } + } + + return _input.Substring(currentPosition, _position - currentPosition); + } + + private string ReadNumber() + { + var currentPosition = _position; + + while (IsDigit(_character)) + { + ReadCharacter(); + } + + return _input.Substring(currentPosition, _position - currentPosition); + } } \ No newline at end of file diff --git a/src/Parser/Parser.cs b/src/Parser/Parser.cs index feb89da..94a23ae 100644 --- a/src/Parser/Parser.cs +++ b/src/Parser/Parser.cs @@ -44,6 +44,30 @@ public IEnumerable ParseOrderBy() return statements; } + public IEnumerable ParseFilter() + { + var statements = new List(); + + while (!CurrentTokenIs(TokenType.EOF)) + { + if (!CurrentTokenIs(TokenType.IDENT)) + { + NextToken(); + continue; + } + + var statement = ParseFilterStatement(); + if (statement != null) + { + statements.Add(statement); + } + + NextToken(); + } + + return statements; + } + private OrderByStatement? ParseOrderByStatement() { var statement = new OrderByStatement(_currentToken); @@ -64,6 +88,43 @@ public IEnumerable ParseOrderBy() return statement; } + private InfixExpression? ParseFilterStatement() + { + var identifier = new Identifier(_currentToken, _currentToken.Literal); + + if (!PeekTokenIs(TokenType.EQ)) + { + return null; + } + + NextToken(); + + var statement = new InfixExpression(_currentToken, identifier, _currentToken.Literal); + + if (!PeekTokenIs(TokenType.STRING) && !PeekTokenIs(TokenType.INT)) + { + return null; + } + + NextToken(); + + switch (_currentToken.Type) + { + case TokenType.STRING: + statement.Right = new StringLiteral(_currentToken, _currentToken.Literal); + break; + case TokenType.INT: + if (int.TryParse(_currentToken.Literal, out var value)) + { + statement.Right = new IntegerLiteral(_currentToken, value); + } + break; + } + + + return statement; + } + private bool CurrentTokenIs(TokenType token) { return _currentToken.Type == token; diff --git a/src/Token/Token.cs b/src/Token/Token.cs index 94f8b4c..075a978 100644 --- a/src/Token/Token.cs +++ b/src/Token/Token.cs @@ -6,10 +6,13 @@ public enum TokenType EOF, ILLEGAL, IDENT, + STRING, + INT, // Keywords ASC, DESC, + EQ, } public sealed class Token @@ -21,6 +24,7 @@ public sealed class Token { { "asc", TokenType.ASC }, { "desc", TokenType.DESC }, + { "eq", TokenType.EQ }, }; public Token(TokenType type, char literal) diff --git a/tests/Filter/FilterLexerTest.cs b/tests/Filter/FilterLexerTest.cs new file mode 100644 index 0000000..7991678 --- /dev/null +++ b/tests/Filter/FilterLexerTest.cs @@ -0,0 +1,33 @@ +using Xunit; + +public sealed class FilterLexerTest +{ + [Fact] + public void Test_FilterNextToken() + { + var input = @"Name eq 'john' + Id eq 1 + "; + + var tests = new Expected[] + { + new Expected(TokenType.IDENT, "Name"), + new Expected(TokenType.EQ, "eq"), + new Expected(TokenType.STRING, "john"), + + new Expected(TokenType.IDENT, "Id"), + new Expected(TokenType.EQ, "eq"), + new Expected(TokenType.INT, "1"), + }; + + var lexer = new QueryLexer(input); + + foreach (var test in tests) + { + var token = lexer.NextToken(); + + Assert.Equal(test.Token, token.Type); + Assert.Equal(test.Literal, token.Literal); + } + } +} \ No newline at end of file diff --git a/tests/Filter/FilterParserTest.cs b/tests/Filter/FilterParserTest.cs new file mode 100644 index 0000000..027ad36 --- /dev/null +++ b/tests/Filter/FilterParserTest.cs @@ -0,0 +1,36 @@ +using Xunit; + +public sealed class FilterParserTest +{ + public static IEnumerable Parameters() + { + yield return new object[] + { + "Name eq 'John'", + new OrderByStatement[] + { + new OrderByStatement(new Token(TokenType.IDENT, "ID"), OrderByDirection.Descending), + } + }; + } + + [Theory] + [InlineData("Name eq 'John'", "Name", "eq", "John")] + [InlineData("Firstname eq 'Jane'", "Firstname", "eq", "Jane")] + [InlineData("Age eq 21", "Age", "eq", "21")] + 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(); + Assert.Single(program); + + var statement = program.FirstOrDefault(); + Assert.NotNull(statement); + + Assert.Equal(expectedLeft, statement.Left.TokenLiteral()); + Assert.Equal(expectedOperator, statement.Operator); + Assert.Equal(expectedRight, statement.Right.TokenLiteral()); + } +} \ No newline at end of file diff --git a/tests/Filter/FilterTest.cs b/tests/Filter/FilterTest.cs new file mode 100644 index 0000000..3abe302 --- /dev/null +++ b/tests/Filter/FilterTest.cs @@ -0,0 +1,59 @@ +using Xunit; + +public sealed class FilterTest +{ + public static IEnumerable Parameters() + { + yield return new object[] + { + "firstname eq 'John'", + new User[] + { + new User { Id = 2, Firstname = "John" }, + } + }; + + yield return new object[] + { + "firstname eq 'Random'", + new User[] {} + }; + + yield return new object[] + { + "id eq 1", + new User[] {} + }; + } + + [Theory] + [MemberData(nameof(Parameters))] + public void Test_Filter(string filter, IEnumerable expected) + { + var users = new List{ + new User { Id = 2, Firstname = "John" }, + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Filter = filter + }; + + var (queryable, _) = users.Apply(query); + var results = queryable.ToArray(); + + for (var i = 0; i < expected.Count(); i++) + { + var expectedUser = expected.ElementAt(i); + var user = results.ElementAt(i); + + Assert.Equal(expectedUser.Id, user.Id); + Assert.Equal(expectedUser.Firstname, user.Firstname); + } + } +} \ No newline at end of file From beb9e1bbdf21fcda2c005cd86d09ffab40c9de6b Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Wed, 22 May 2024 23:35:22 +0100 Subject: [PATCH 08/60] wip --- src/Extensions/QueryableExtension.cs | 2 ++ src/Token/Token.cs | 2 ++ tests/Filter/FilterLexerTest.cs | 9 +++++++ tests/Filter/FilterParserTest.cs | 38 +++++++++++++++++++--------- tests/Filter/FilterTest.cs | 29 +++++++++++++++------ tests/Orderby/OrderByTest.cs | 9 +------ tests/Skip/SkipTest.cs | 9 +------ tests/Top/TopTest.cs | 3 --- 8 files changed, 62 insertions(+), 39 deletions(-) diff --git a/src/Extensions/QueryableExtension.cs b/src/Extensions/QueryableExtension.cs index 1f93163..1e1b7cf 100644 --- a/src/Extensions/QueryableExtension.cs +++ b/src/Extensions/QueryableExtension.cs @@ -142,6 +142,8 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query queryable = queryable.Take(options.MaxTop); } + Console.WriteLine(queryable.ToString()); + return (queryable, count); } diff --git a/src/Token/Token.cs b/src/Token/Token.cs index 075a978..494c79e 100644 --- a/src/Token/Token.cs +++ b/src/Token/Token.cs @@ -13,6 +13,7 @@ public enum TokenType ASC, DESC, EQ, + AND, } public sealed class Token @@ -25,6 +26,7 @@ public sealed class Token { "asc", TokenType.ASC }, { "desc", TokenType.DESC }, { "eq", TokenType.EQ }, + { "and", TokenType.AND }, }; public Token(TokenType type, char literal) diff --git a/tests/Filter/FilterLexerTest.cs b/tests/Filter/FilterLexerTest.cs index 7991678..6e15d30 100644 --- a/tests/Filter/FilterLexerTest.cs +++ b/tests/Filter/FilterLexerTest.cs @@ -7,6 +7,7 @@ public void Test_FilterNextToken() { var input = @"Name eq 'john' Id eq 1 + Name eq 'john' and Id eq 1 "; var tests = new Expected[] @@ -18,6 +19,14 @@ Id eq 1 new Expected(TokenType.IDENT, "Id"), new Expected(TokenType.EQ, "eq"), new Expected(TokenType.INT, "1"), + + new Expected(TokenType.IDENT, "Name"), + new Expected(TokenType.EQ, "eq"), + new Expected(TokenType.STRING, "john"), + new Expected(TokenType.AND, "and"), + new Expected(TokenType.IDENT, "Id"), + new Expected(TokenType.EQ, "eq"), + new Expected(TokenType.INT, "1"), }; var lexer = new QueryLexer(input); diff --git a/tests/Filter/FilterParserTest.cs b/tests/Filter/FilterParserTest.cs index 027ad36..047c123 100644 --- a/tests/Filter/FilterParserTest.cs +++ b/tests/Filter/FilterParserTest.cs @@ -2,18 +2,6 @@ public sealed class FilterParserTest { - public static IEnumerable Parameters() - { - yield return new object[] - { - "Name eq 'John'", - new OrderByStatement[] - { - new OrderByStatement(new Token(TokenType.IDENT, "ID"), OrderByDirection.Descending), - } - }; - } - [Theory] [InlineData("Name eq 'John'", "Name", "eq", "John")] [InlineData("Firstname eq 'Jane'", "Firstname", "eq", "Jane")] @@ -33,4 +21,30 @@ public void Test_ParsingFilterStatement(string input, string expectedLeft, strin Assert.Equal(expectedOperator, statement.Operator); Assert.Equal(expectedRight, statement.Right.TokenLiteral()); } + + [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(); + Assert.Equal(2, program.Count()); + + var firstStatement = program.ElementAt(0); + Assert.NotNull(firstStatement); + + Assert.Equal("Name", firstStatement.Left.TokenLiteral()); + Assert.Equal("eq", firstStatement.Operator); + Assert.Equal("John", firstStatement.Right.TokenLiteral()); + + var secondStatement = program.ElementAt(1); + Assert.NotNull(firstStatement); + + Assert.Equal("Age", secondStatement.Left.TokenLiteral()); + Assert.Equal("eq", secondStatement.Operator); + Assert.Equal("10", secondStatement.Right.TokenLiteral()); + } } \ No newline at end of file diff --git a/tests/Filter/FilterTest.cs b/tests/Filter/FilterTest.cs index 3abe302..473698f 100644 --- a/tests/Filter/FilterTest.cs +++ b/tests/Filter/FilterTest.cs @@ -24,6 +24,26 @@ public static IEnumerable Parameters() "id eq 1", new User[] {} }; + + yield return new object[] + { + "firstname eq 'John' and id eq 2", + new User[] + { + new User { Id = 2, Firstname = "John" }, + } + }; + + yield return new object[] + { + "firstname eq 'John' or id eq 3", + new User[] + { + new User { Id = 2, Firstname = "John" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + } + }; } [Theory] @@ -47,13 +67,6 @@ public void Test_Filter(string filter, IEnumerable expected) var (queryable, _) = users.Apply(query); var results = queryable.ToArray(); - for (var i = 0; i < expected.Count(); i++) - { - var expectedUser = expected.ElementAt(i); - var user = results.ElementAt(i); - - Assert.Equal(expectedUser.Id, user.Id); - Assert.Equal(expectedUser.Firstname, user.Firstname); - } + Assert.Equal(expected, results); } } \ No newline at end of file diff --git a/tests/Orderby/OrderByTest.cs b/tests/Orderby/OrderByTest.cs index 679a690..1918ef4 100644 --- a/tests/Orderby/OrderByTest.cs +++ b/tests/Orderby/OrderByTest.cs @@ -186,13 +186,6 @@ public void Test_OrderBy(string orderby, IEnumerable expected) var (queryable, _) = users.Apply(query); var results = queryable.ToArray(); - for (var i = 0; i < expected.Count(); i++) - { - var expectedUser = expected.ElementAt(i); - var user = results.ElementAt(i); - - Assert.Equal(expectedUser.Id, user.Id); - Assert.Equal(expectedUser.Firstname, user.Firstname); - } + Assert.Equal(expected, results); } } \ No newline at end of file diff --git a/tests/Skip/SkipTest.cs b/tests/Skip/SkipTest.cs index ed1e773..788b4d0 100644 --- a/tests/Skip/SkipTest.cs +++ b/tests/Skip/SkipTest.cs @@ -99,13 +99,6 @@ public void Test_Skip(int skip, IEnumerable expected) var (queryable, _) = users.Apply(query); var results = queryable.ToArray(); - for (var i = 0; i < expected.Count(); i++) - { - var expectedUser = expected.ElementAt(i); - var user = results.ElementAt(i); - - Assert.Equal(expectedUser.Id, user.Id); - Assert.Equal(expectedUser.Firstname, user.Firstname); - } + Assert.Equal(expected, results); } } \ No newline at end of file diff --git a/tests/Top/TopTest.cs b/tests/Top/TopTest.cs index ba2e309..e4c89e3 100644 --- a/tests/Top/TopTest.cs +++ b/tests/Top/TopTest.cs @@ -41,9 +41,6 @@ public void Test_Top(int top, int expectedCount) [InlineData(2, 2)] [InlineData(3, 3)] [InlineData(4, 4)] - // [InlineData(5, 4)] - // [InlineData(100, 4)] - // [InlineData(100_000, 4)] public void Test_TopWithMaxTop(int top, int expectedCount) { var users = new List{ From 9e5e7b680ffdbf9dbcefccd872ae220dee59a149 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Sat, 25 May 2024 01:57:39 +0100 Subject: [PATCH 09/60] or filters --- src/Ast/ExpressionStatement.cs | 8 ++++ src/Ast/Identifier.cs | 2 +- src/Ast/InfixExpression.cs | 14 ++++-- src/Ast/QueryExpression.cs | 6 +++ src/Ast/Statement.cs | 6 +++ src/Ast/StringLiteral.cs | 4 +- src/Extensions/QueryableExtension.cs | 72 ++++++++++++++++++---------- src/Lexer/Lexer.cs | 2 +- src/Parser/Parser.cs | 57 +++++++++++++++------- src/Token/Token.cs | 34 ++++--------- tests/Filter/FilterLexerTest.cs | 24 ++++++++-- tests/Filter/FilterParserTest.cs | 67 +++++++++++++++++++------- tests/Filter/FilterTest.cs | 9 ++++ tests/Orderby/OrderByLexerTest.cs | 10 ++-- 14 files changed, 213 insertions(+), 102 deletions(-) create mode 100644 src/Ast/ExpressionStatement.cs create mode 100644 src/Ast/QueryExpression.cs create mode 100644 src/Ast/Statement.cs diff --git a/src/Ast/ExpressionStatement.cs b/src/Ast/ExpressionStatement.cs new file mode 100644 index 0000000..957b7d2 --- /dev/null +++ b/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/Ast/Identifier.cs b/src/Ast/Identifier.cs index c383daa..972f490 100644 --- a/src/Ast/Identifier.cs +++ b/src/Ast/Identifier.cs @@ -1,4 +1,4 @@ -public sealed class Identifier : Node +public sealed class Identifier : QueryExpression { public string Value { get; set; } diff --git a/src/Ast/InfixExpression.cs b/src/Ast/InfixExpression.cs index 9afb82e..37cba50 100644 --- a/src/Ast/InfixExpression.cs +++ b/src/Ast/InfixExpression.cs @@ -1,12 +1,16 @@ -public sealed class InfixExpression : Node +public sealed class InfixExpression : QueryExpression { - public Identifier Left { get; set; } - public string Operator { get; set; } - public Node Right { get; set; } = default!; + public QueryExpression Left { get; set; } = default!; + public string Operator { get; set; } = string.Empty; + public QueryExpression Right { get; set; } = default!; - public InfixExpression(Token token, Identifier left, string op) : base(token) + 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/Ast/QueryExpression.cs b/src/Ast/QueryExpression.cs new file mode 100644 index 0000000..d3a6624 --- /dev/null +++ b/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/Ast/Statement.cs b/src/Ast/Statement.cs new file mode 100644 index 0000000..1a1dab0 --- /dev/null +++ b/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/Ast/StringLiteral.cs b/src/Ast/StringLiteral.cs index 933f820..ac6c70f 100644 --- a/src/Ast/StringLiteral.cs +++ b/src/Ast/StringLiteral.cs @@ -1,4 +1,4 @@ -public sealed class StringLiteral : Node +public sealed class StringLiteral : QueryExpression { public string Value { get; set; } @@ -8,7 +8,7 @@ public StringLiteral(Token token, string value) : base(token) } } -public sealed class IntegerLiteral : Node +public sealed class IntegerLiteral : QueryExpression { public int Value { get; set; } diff --git a/src/Extensions/QueryableExtension.cs b/src/Extensions/QueryableExtension.cs index 1e1b7cf..84c1fc5 100644 --- a/src/Extensions/QueryableExtension.cs +++ b/src/Extensions/QueryableExtension.cs @@ -19,34 +19,13 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query { var lexer = new QueryLexer(query.Filter); var parser = new QueryParser(lexer); - var statements = parser.ParseFilter(); + var statement = parser.ParseFilter(); ParameterExpression parameter = Expression.Parameter(type); - Expression? binaryExpression = null; - foreach (var statement in statements) - { - var property = Expression.Property(parameter, statement.Left.TokenLiteral()); - ConstantExpression? value = null; - - switch (statement.Right) - { - case IntegerLiteral literal: - value = Expression.Constant(literal.Value, property.Type); - break; - case StringLiteral literal: - value = Expression.Constant(literal.Value, property.Type); - break; - default: - break; - } - - var expression = Expression.Equal(property, value); - - binaryExpression = binaryExpression == null ? expression : Expression.AndAlso(binaryExpression, expression); - } + var expression = Evaluate(statement.Expression, parameter); - var exp = Expression.Lambda>(binaryExpression, parameter); + var exp = Expression.Lambda>(expression, parameter); queryable = queryable.Where(exp); } @@ -147,6 +126,51 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query return (queryable, count); } + private static Expression? Evaluate(QueryExpression expression, ParameterExpression parameterExpression) + { + switch (expression) + { + case InfixExpression exp: + if (exp.Left.GetType() == typeof(Identifier)) + { + var property = Expression.Property(parameterExpression, exp.Left.TokenLiteral()); + ConstantExpression? value = null; + + switch (exp.Right) + { + case IntegerLiteral literal: + value = Expression.Constant(literal.Value, property.Type); + break; + case StringLiteral literal: + value = Expression.Constant(literal.Value, property.Type); + break; + default: + break; + } + + var expa = Expression.Equal(property, value); + + return expa; + } + + var left = Evaluate(exp.Left, parameterExpression); + var right = Evaluate(exp.Right, parameterExpression); + + if (exp.Operator == Keywords.And) + { + return Expression.AndAlso(left, right); + } + else if (exp.Operator == Keywords.Or) + { + return Expression.OrElse(left, right); + } + + break; + } + + return null; + } + private static MethodInfo GenericMethodOf(Expression> expression) { return GenericMethodOf(expression as Expression); diff --git a/src/Lexer/Lexer.cs b/src/Lexer/Lexer.cs index f2c7013..0c8ef2f 100644 --- a/src/Lexer/Lexer.cs +++ b/src/Lexer/Lexer.cs @@ -47,7 +47,7 @@ public Token NextToken() if (IsLetter(_character)) { token.Literal = ReadIdentifier(); - token.Type = Token.GetIdentifierTokenType(token.Literal); + token.Type = TokenType.IDENT; return token; } else if (IsDigit(_character)) diff --git a/src/Parser/Parser.cs b/src/Parser/Parser.cs index 94a23ae..0ea52b5 100644 --- a/src/Parser/Parser.cs +++ b/src/Parser/Parser.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; public sealed class QueryParser @@ -44,41 +45,55 @@ public IEnumerable ParseOrderBy() return statements; } - public IEnumerable ParseFilter() + public ExpressionStatement ParseFilter() { - var statements = new List(); + var statement = new ExpressionStatement(_currentToken) + { + Expression = ParseExpression() + }; - while (!CurrentTokenIs(TokenType.EOF)) + return statement; + } + + private InfixExpression ParseExpression() + { + var left = ParseFilterStatement(); + if (left is null) { - if (!CurrentTokenIs(TokenType.IDENT)) - { - NextToken(); - continue; - } + throw new Exception("bad"); + } - var statement = ParseFilterStatement(); - if (statement != null) + NextToken(); + + while (CurrentIdentifierIs(Keywords.And) || CurrentIdentifierIs(Keywords.Or)) + { + left = new InfixExpression(_currentToken, left, _currentToken.Literal); + + NextToken(); + + var right = ParseFilterStatement(); + if (right is null) { - statements.Add(statement); + throw new Exception("bad"); } - NextToken(); + left.Right = right; } - return statements; + return left; } private OrderByStatement? ParseOrderByStatement() { var statement = new OrderByStatement(_currentToken); - if (PeekTokenIs(TokenType.DESC)) + if (PeekIdentifierIs(Keywords.Desc)) { statement.Direction = OrderByDirection.Descending; NextToken(); } - else if (PeekTokenIs(TokenType.ASC)) + else if (PeekIdentifierIs(Keywords.Asc)) { statement.Direction = OrderByDirection.Ascending; @@ -92,7 +107,7 @@ public IEnumerable ParseFilter() { var identifier = new Identifier(_currentToken, _currentToken.Literal); - if (!PeekTokenIs(TokenType.EQ)) + if (!PeekIdentifierIs(Keywords.Eq)) { return null; } @@ -135,6 +150,16 @@ private bool PeekTokenIs(TokenType token) return _peekToken.Type == token; } + private bool PeekIdentifierIs(string identifier) + { + return _peekToken.Type == TokenType.IDENT && _peekToken.Literal.Equals(identifier, StringComparison.OrdinalIgnoreCase); + } + + private bool CurrentIdentifierIs(string identifier) + { + return _currentToken.Type == TokenType.IDENT && _currentToken.Literal.Equals(identifier, StringComparison.OrdinalIgnoreCase); + } + private bool ExpectPeek(TokenType token) { if (PeekTokenIs(token)) diff --git a/src/Token/Token.cs b/src/Token/Token.cs index 494c79e..28a7488 100644 --- a/src/Token/Token.cs +++ b/src/Token/Token.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - public enum TokenType { EOF, @@ -8,12 +5,15 @@ public enum TokenType IDENT, STRING, INT, +} - // Keywords - ASC, - DESC, - EQ, - AND, +public static class Keywords +{ + internal const string Asc = "asc"; + internal const string Desc = "desc"; + internal const string Eq = "eq"; + internal const string And = "and"; + internal const string Or = "or"; } public sealed class Token @@ -21,14 +21,6 @@ public sealed class Token public TokenType Type { get; set; } public string Literal { get; set; } = string.Empty; - private static readonly Dictionary _keywords = new Dictionary(StringComparer.CurrentCultureIgnoreCase) - { - { "asc", TokenType.ASC }, - { "desc", TokenType.DESC }, - { "eq", TokenType.EQ }, - { "and", TokenType.AND }, - }; - public Token(TokenType type, char literal) { Type = type; @@ -40,14 +32,4 @@ public Token(TokenType type, string literal) Type = type; Literal = literal; } - - public static TokenType GetIdentifierTokenType(string identifier) - { - if (_keywords.TryGetValue(identifier, out var token)) - { - return token; - } - - return TokenType.IDENT; - } } \ No newline at end of file diff --git a/tests/Filter/FilterLexerTest.cs b/tests/Filter/FilterLexerTest.cs index 6e15d30..fbf74cd 100644 --- a/tests/Filter/FilterLexerTest.cs +++ b/tests/Filter/FilterLexerTest.cs @@ -8,24 +8,38 @@ public void Test_FilterNextToken() var input = @"Name eq 'john' Id eq 1 Name eq 'john' and Id eq 1 + eq eq 1 + Name eq 'john' or Id eq 1 "; var tests = new Expected[] { new Expected(TokenType.IDENT, "Name"), - new Expected(TokenType.EQ, "eq"), + new Expected(TokenType.IDENT, "eq"), new Expected(TokenType.STRING, "john"), new Expected(TokenType.IDENT, "Id"), - new Expected(TokenType.EQ, "eq"), + new Expected(TokenType.IDENT, "eq"), new Expected(TokenType.INT, "1"), new Expected(TokenType.IDENT, "Name"), - new Expected(TokenType.EQ, "eq"), + new Expected(TokenType.IDENT, "eq"), new Expected(TokenType.STRING, "john"), - new Expected(TokenType.AND, "and"), + new Expected(TokenType.IDENT, "and"), new Expected(TokenType.IDENT, "Id"), - new Expected(TokenType.EQ, "eq"), + new Expected(TokenType.IDENT, "eq"), + new Expected(TokenType.INT, "1"), + + new Expected(TokenType.IDENT, "eq"), + new Expected(TokenType.IDENT, "eq"), + new Expected(TokenType.INT, "1"), + + new Expected(TokenType.IDENT, "Name"), + new Expected(TokenType.IDENT, "eq"), + new Expected(TokenType.STRING, "john"), + new Expected(TokenType.IDENT, "or"), + new Expected(TokenType.IDENT, "Id"), + new Expected(TokenType.IDENT, "eq"), new Expected(TokenType.INT, "1"), }; diff --git a/tests/Filter/FilterParserTest.cs b/tests/Filter/FilterParserTest.cs index 047c123..f54fa5d 100644 --- a/tests/Filter/FilterParserTest.cs +++ b/tests/Filter/FilterParserTest.cs @@ -12,14 +12,13 @@ public void Test_ParsingFilterStatement(string input, string expectedLeft, strin var parser = new QueryParser(lexer); var program = parser.ParseFilter(); - Assert.Single(program); - var statement = program.FirstOrDefault(); - Assert.NotNull(statement); + var expression = program.Expression as InfixExpression; + Assert.NotNull(expression); - Assert.Equal(expectedLeft, statement.Left.TokenLiteral()); - Assert.Equal(expectedOperator, statement.Operator); - Assert.Equal(expectedRight, statement.Right.TokenLiteral()); + Assert.Equal(expectedLeft, expression.Left.TokenLiteral()); + Assert.Equal(expectedOperator, expression.Operator); + Assert.Equal(expectedRight, expression.Right.TokenLiteral()); } [Fact] @@ -31,20 +30,54 @@ public void Test_ParsingFilterStatementWithAnd() var parser = new QueryParser(lexer); var program = parser.ParseFilter(); - Assert.Equal(2, program.Count()); - var firstStatement = program.ElementAt(0); - Assert.NotNull(firstStatement); + var expression = program.Expression as InfixExpression; + Assert.NotNull(expression); - Assert.Equal("Name", firstStatement.Left.TokenLiteral()); - Assert.Equal("eq", firstStatement.Operator); - Assert.Equal("John", firstStatement.Right.TokenLiteral()); + var left = expression.Left as InfixExpression; + Assert.NotNull(left); - var secondStatement = program.ElementAt(1); - Assert.NotNull(firstStatement); + Assert.Equal("Name", left.Left.TokenLiteral()); + Assert.Equal("eq", left.Operator); + Assert.Equal("John", left.Right.TokenLiteral()); - Assert.Equal("Age", secondStatement.Left.TokenLiteral()); - Assert.Equal("eq", secondStatement.Operator); - Assert.Equal("10", secondStatement.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.Expression as InfixExpression; + 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()); } } \ No newline at end of file diff --git a/tests/Filter/FilterTest.cs b/tests/Filter/FilterTest.cs index 473698f..d4cc567 100644 --- a/tests/Filter/FilterTest.cs +++ b/tests/Filter/FilterTest.cs @@ -22,6 +22,15 @@ public static IEnumerable Parameters() yield return new object[] { "id eq 1", + new User[] { + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + } + }; + + yield return new object[] + { + "id eq 0", new User[] {} }; diff --git a/tests/Orderby/OrderByLexerTest.cs b/tests/Orderby/OrderByLexerTest.cs index a847fff..f81095d 100644 --- a/tests/Orderby/OrderByLexerTest.cs +++ b/tests/Orderby/OrderByLexerTest.cs @@ -27,15 +27,15 @@ id AsC var tests = new Expected[] { new Expected(TokenType.IDENT, "id"), - new Expected(TokenType.ASC, "asc"), + new Expected(TokenType.IDENT, "asc"), new Expected(TokenType.IDENT, "iD"), - new Expected(TokenType.DESC, "desc"), + new Expected(TokenType.IDENT, "desc"), new Expected(TokenType.IDENT, "id"), - new Expected(TokenType.ASC, "aSc"), + new Expected(TokenType.IDENT, "aSc"), new Expected(TokenType.IDENT, "id"), - new Expected(TokenType.DESC, "DeSc"), + new Expected(TokenType.IDENT, "DeSc"), new Expected(TokenType.IDENT, "id"), - new Expected(TokenType.ASC, "AsC"), + new Expected(TokenType.IDENT, "AsC"), }; var lexer = new QueryLexer(input); From b517bc6723de660c3af8518a91e9363d38a515ac Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Tue, 28 May 2024 21:19:45 +0100 Subject: [PATCH 10/60] wip --- src/Parser/Parser.cs | 13 +-- tests/Filter/FilterLexerTest.cs | 128 ++++++++++++++++++++---------- tests/Filter/FilterParserTest.cs | 48 ++++++++++- tests/Filter/FilterTest.cs | 17 +++- tests/Orderby/OrderByLexerTest.cs | 101 +++++++++++++++-------- 5 files changed, 215 insertions(+), 92 deletions(-) diff --git a/src/Parser/Parser.cs b/src/Parser/Parser.cs index 0ea52b5..841990e 100644 --- a/src/Parser/Parser.cs +++ b/src/Parser/Parser.cs @@ -78,6 +78,8 @@ private InfixExpression ParseExpression() } left.Right = right; + + NextToken(); } return left; @@ -159,15 +161,4 @@ private bool CurrentIdentifierIs(string identifier) { return _currentToken.Type == TokenType.IDENT && _currentToken.Literal.Equals(identifier, StringComparison.OrdinalIgnoreCase); } - - private bool ExpectPeek(TokenType token) - { - if (PeekTokenIs(token)) - { - NextToken(); - return true; - } - - return false; - } } \ No newline at end of file diff --git a/tests/Filter/FilterLexerTest.cs b/tests/Filter/FilterLexerTest.cs index fbf74cd..91b7ca1 100644 --- a/tests/Filter/FilterLexerTest.cs +++ b/tests/Filter/FilterLexerTest.cs @@ -2,55 +2,103 @@ public sealed class FilterLexerTest { - [Fact] - public void Test_FilterNextToken() + public static IEnumerable Parameters() { - var input = @"Name eq 'john' - Id eq 1 - Name eq 'john' and Id eq 1 - eq eq 1 - Name eq 'john' or Id eq 1 - "; - - var tests = new Expected[] + yield return new object[] { - new Expected(TokenType.IDENT, "Name"), - new Expected(TokenType.IDENT, "eq"), - new Expected(TokenType.STRING, "john"), - - new Expected(TokenType.IDENT, "Id"), - new Expected(TokenType.IDENT, "eq"), - new Expected(TokenType.INT, "1"), - - new Expected(TokenType.IDENT, "Name"), - new Expected(TokenType.IDENT, "eq"), - new Expected(TokenType.STRING, "john"), - new Expected(TokenType.IDENT, "and"), - new Expected(TokenType.IDENT, "Id"), - new Expected(TokenType.IDENT, "eq"), - new Expected(TokenType.INT, "1"), - - new Expected(TokenType.IDENT, "eq"), - new Expected(TokenType.IDENT, "eq"), - new Expected(TokenType.INT, "1"), - - new Expected(TokenType.IDENT, "Name"), - new Expected(TokenType.IDENT, "eq"), - new Expected(TokenType.STRING, "john"), - new Expected(TokenType.IDENT, "or"), - new Expected(TokenType.IDENT, "Id"), - new Expected(TokenType.IDENT, "eq"), - new Expected(TokenType.INT, "1"), + "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"), + } + }; + } + + [Theory] + [MemberData(nameof(Parameters))] + public void Test_FilterNextToken(string input, KeyValuePair[] expected) + { var lexer = new QueryLexer(input); - foreach (var test in tests) + foreach (var test in expected) { var token = lexer.NextToken(); - Assert.Equal(test.Token, token.Type); - Assert.Equal(test.Literal, token.Literal); + Assert.Equal(test.Key, token.Type); + Assert.Equal(test.Value, token.Literal); } } } \ No newline at end of file diff --git a/tests/Filter/FilterParserTest.cs b/tests/Filter/FilterParserTest.cs index f54fa5d..533167b 100644 --- a/tests/Filter/FilterParserTest.cs +++ b/tests/Filter/FilterParserTest.cs @@ -13,7 +13,7 @@ public void Test_ParsingFilterStatement(string input, string expectedLeft, strin var program = parser.ParseFilter(); - var expression = program.Expression as InfixExpression; + var expression = program.Expression; Assert.NotNull(expression); Assert.Equal(expectedLeft, expression.Left.TokenLiteral()); @@ -31,7 +31,7 @@ public void Test_ParsingFilterStatementWithAnd() var program = parser.ParseFilter(); - var expression = program.Expression as InfixExpression; + var expression = program.Expression; Assert.NotNull(expression); var left = expression.Left as InfixExpression; @@ -61,7 +61,7 @@ public void Test_ParsingFilterStatementWithOr() var program = parser.ParseFilter(); - var expression = program.Expression as InfixExpression; + var expression = program.Expression; Assert.NotNull(expression); var left = expression.Left as InfixExpression; @@ -80,4 +80,46 @@ public void Test_ParsingFilterStatementWithOr() 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.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()); + } } \ No newline at end of file diff --git a/tests/Filter/FilterTest.cs b/tests/Filter/FilterTest.cs index d4cc567..39467f6 100644 --- a/tests/Filter/FilterTest.cs +++ b/tests/Filter/FilterTest.cs @@ -48,9 +48,20 @@ public static IEnumerable Parameters() "firstname eq 'John' or id eq 3", new User[] { - new User { Id = 2, Firstname = "John" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + new User { Id = 2, Firstname = "John" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + } + }; + + yield return new object[] + { + "id eq 1 and firstName eq 'Harry' or id eq 2", + new User[] + { + new User { Id = 2, Firstname = "John" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 1, Firstname = "Harry" }, } }; } diff --git a/tests/Orderby/OrderByLexerTest.cs b/tests/Orderby/OrderByLexerTest.cs index f81095d..2bf98e7 100644 --- a/tests/Orderby/OrderByLexerTest.cs +++ b/tests/Orderby/OrderByLexerTest.cs @@ -1,51 +1,82 @@ using Xunit; -public sealed record Expected -{ - public TokenType Token { get; set; } - public string Literal { get; set; } = string.Empty; - - public Expected(TokenType token, string literal) - { - Token = token; - Literal = literal; - } -} - public sealed class OrderByLexerTest { - [Fact] - public void Test_OrderByNextToken() + public static IEnumerable Parameters() { - var input = @"id asc - iD desc - id aSc - id DeSc - id AsC - "; - - var tests = new Expected[] + 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[] { - new Expected(TokenType.IDENT, "id"), - new Expected(TokenType.IDENT, "asc"), - new Expected(TokenType.IDENT, "iD"), - new Expected(TokenType.IDENT, "desc"), - new Expected(TokenType.IDENT, "id"), - new Expected(TokenType.IDENT, "aSc"), - new Expected(TokenType.IDENT, "id"), - new Expected(TokenType.IDENT, "DeSc"), - new Expected(TokenType.IDENT, "id"), - new Expected(TokenType.IDENT, "AsC"), + "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"), + } + }; + } + + [Theory] + [MemberData(nameof(Parameters))] + public void Test_OrderByNextToken(string input, KeyValuePair[] expected) + { var lexer = new QueryLexer(input); - foreach (var test in tests) + foreach (var test in expected) { var token = lexer.NextToken(); - Assert.Equal(test.Token, token.Type); - Assert.Equal(test.Literal, token.Literal); + Assert.Equal(test.Key, token.Type); + Assert.Equal(test.Value, token.Literal); } } } \ No newline at end of file From ecee5db9c1137280be17d5fb22dedc93209c3b92 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Wed, 29 May 2024 18:41:53 +0100 Subject: [PATCH 11/60] wip --- tests/Filter/FilterLexerTest.cs | 19 +++++++++++++++++++ tests/Filter/FilterTest.cs | 13 +++++++++++++ 2 files changed, 32 insertions(+) diff --git a/tests/Filter/FilterLexerTest.cs b/tests/Filter/FilterLexerTest.cs index 91b7ca1..6519ab4 100644 --- a/tests/Filter/FilterLexerTest.cs +++ b/tests/Filter/FilterLexerTest.cs @@ -85,6 +85,25 @@ public static IEnumerable Parameters() 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"), + } + }; } [Theory] diff --git a/tests/Filter/FilterTest.cs b/tests/Filter/FilterTest.cs index 39467f6..0eef902 100644 --- a/tests/Filter/FilterTest.cs +++ b/tests/Filter/FilterTest.cs @@ -64,6 +64,19 @@ public static IEnumerable Parameters() new User { Id = 1, Firstname = "Harry" }, } }; + + yield return new object[] + { + "id eq 1 or id eq 2 or firstName eq 'Egg'", + new User[] + { + new User { Id = 2, Firstname = "John" }, + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 3, Firstname = "Egg" } + } + }; } [Theory] From 9bc446ab4405b0666a3d4b6e0e727b3618c7b27f Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Wed, 29 May 2024 19:25:01 +0100 Subject: [PATCH 12/60] added ne --- src/Extensions/QueryableExtension.cs | 21 ++++++++++++--------- src/Parser/Parser.cs | 27 ++++++++++++++------------- src/Token/Token.cs | 1 + tests/Filter/FilterLexerTest.cs | 11 +++++++++++ tests/Filter/FilterParserTest.cs | 1 + tests/Filter/FilterTest.cs | 12 ++++++++++++ 6 files changed, 51 insertions(+), 22 deletions(-) diff --git a/src/Extensions/QueryableExtension.cs b/src/Extensions/QueryableExtension.cs index 84c1fc5..7deae37 100644 --- a/src/Extensions/QueryableExtension.cs +++ b/src/Extensions/QueryableExtension.cs @@ -148,21 +148,24 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query break; } - var expa = Expression.Equal(property, value); - - return expa; + switch (exp.Operator) + { + case Keywords.Eq: + return Expression.Equal(property, value); + case Keywords.Ne: + return Expression.NotEqual(property, value); + } } var left = Evaluate(exp.Left, parameterExpression); var right = Evaluate(exp.Right, parameterExpression); - if (exp.Operator == Keywords.And) - { - return Expression.AndAlso(left, right); - } - else if (exp.Operator == Keywords.Or) + switch (exp.Operator) { - return Expression.OrElse(left, right); + case Keywords.And: + return Expression.AndAlso(left, right); + case Keywords.Or: + return Expression.OrElse(left, right); } break; diff --git a/src/Parser/Parser.cs b/src/Parser/Parser.cs index 841990e..7d7b7ee 100644 --- a/src/Parser/Parser.cs +++ b/src/Parser/Parser.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; public sealed class QueryParser { @@ -58,10 +59,6 @@ public ExpressionStatement ParseFilter() private InfixExpression ParseExpression() { var left = ParseFilterStatement(); - if (left is null) - { - throw new Exception("bad"); - } NextToken(); @@ -85,7 +82,7 @@ private InfixExpression ParseExpression() return left; } - private OrderByStatement? ParseOrderByStatement() + private OrderByStatement ParseOrderByStatement() { var statement = new OrderByStatement(_currentToken); @@ -105,22 +102,22 @@ private InfixExpression ParseExpression() return statement; } - private InfixExpression? ParseFilterStatement() + private InfixExpression ParseFilterStatement() { var identifier = new Identifier(_currentToken, _currentToken.Literal); - if (!PeekIdentifierIs(Keywords.Eq)) + if (!PeekIdentifierIn(Keywords.Eq, Keywords.Ne)) { - return null; + throw new GoatQueryException("Invalid conjunction within filter string"); } NextToken(); var statement = new InfixExpression(_currentToken, identifier, _currentToken.Literal); - if (!PeekTokenIs(TokenType.STRING) && !PeekTokenIs(TokenType.INT)) + if (!PeekTokenIn(TokenType.STRING, TokenType.INT)) { - return null; + throw new GoatQueryException("Invalid value type within filter string"); } NextToken(); @@ -138,7 +135,6 @@ private InfixExpression ParseExpression() break; } - return statement; } @@ -147,9 +143,9 @@ private bool CurrentTokenIs(TokenType token) return _currentToken.Type == token; } - private bool PeekTokenIs(TokenType token) + private bool PeekTokenIn(params TokenType[] token) { - return _peekToken.Type == token; + return token.Contains(_peekToken.Type); } private bool PeekIdentifierIs(string identifier) @@ -157,6 +153,11 @@ 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); diff --git a/src/Token/Token.cs b/src/Token/Token.cs index 28a7488..789a3c1 100644 --- a/src/Token/Token.cs +++ b/src/Token/Token.cs @@ -12,6 +12,7 @@ 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 And = "and"; internal const string Or = "or"; } diff --git a/tests/Filter/FilterLexerTest.cs b/tests/Filter/FilterLexerTest.cs index 6519ab4..4949739 100644 --- a/tests/Filter/FilterLexerTest.cs +++ b/tests/Filter/FilterLexerTest.cs @@ -104,6 +104,17 @@ public static IEnumerable Parameters() 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"), + } + }; } [Theory] diff --git a/tests/Filter/FilterParserTest.cs b/tests/Filter/FilterParserTest.cs index 533167b..77bc841 100644 --- a/tests/Filter/FilterParserTest.cs +++ b/tests/Filter/FilterParserTest.cs @@ -6,6 +6,7 @@ public sealed class FilterParserTest [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")] public void Test_ParsingFilterStatement(string input, string expectedLeft, string expectedOperator, string expectedRight) { var lexer = new QueryLexer(input); diff --git a/tests/Filter/FilterTest.cs b/tests/Filter/FilterTest.cs index 0eef902..3ee7c80 100644 --- a/tests/Filter/FilterTest.cs +++ b/tests/Filter/FilterTest.cs @@ -77,6 +77,18 @@ public static IEnumerable Parameters() new User { Id = 3, Firstname = "Egg" } } }; + + yield return new object[] + { + "id ne 3", + new User[] + { + new User { Id = 2, Firstname = "John" }, + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 1, Firstname = "Harry" }, + } + }; } [Theory] From 9a2262a05a3c0704893585e46e38bd6b6cd3ce5e Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Wed, 29 May 2024 20:31:02 +0100 Subject: [PATCH 13/60] added contains --- src/Extensions/QueryableExtension.cs | 6 ++++++ src/Parser/Parser.cs | 2 +- src/Token/Token.cs | 1 + tests/Filter/FilterLexerTest.cs | 11 +++++++++++ tests/Filter/FilterParserTest.cs | 1 + tests/Filter/FilterTest.cs | 25 +++++++++++++++++++++++++ 6 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/Extensions/QueryableExtension.cs b/src/Extensions/QueryableExtension.cs index 7deae37..362eeca 100644 --- a/src/Extensions/QueryableExtension.cs +++ b/src/Extensions/QueryableExtension.cs @@ -154,6 +154,12 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query return Expression.Equal(property, value); case Keywords.Ne: return Expression.NotEqual(property, value); + case Keywords.Contains: + var identifier = (Identifier)exp.Left; + + var method = identifier.Value.GetType().GetMethod("Contains", new[] { value?.Value.GetType() }); + + return Expression.Call(property, method, value); } } diff --git a/src/Parser/Parser.cs b/src/Parser/Parser.cs index 7d7b7ee..c2519a0 100644 --- a/src/Parser/Parser.cs +++ b/src/Parser/Parser.cs @@ -106,7 +106,7 @@ private InfixExpression ParseFilterStatement() { var identifier = new Identifier(_currentToken, _currentToken.Literal); - if (!PeekIdentifierIn(Keywords.Eq, Keywords.Ne)) + if (!PeekIdentifierIn(Keywords.Eq, Keywords.Ne, Keywords.Contains)) { throw new GoatQueryException("Invalid conjunction within filter string"); } diff --git a/src/Token/Token.cs b/src/Token/Token.cs index 789a3c1..a9cfd09 100644 --- a/src/Token/Token.cs +++ b/src/Token/Token.cs @@ -13,6 +13,7 @@ public static class Keywords internal const string Desc = "desc"; internal const string Eq = "eq"; internal const string Ne = "ne"; + internal const string Contains = "contains"; internal const string And = "and"; internal const string Or = "or"; } diff --git a/tests/Filter/FilterLexerTest.cs b/tests/Filter/FilterLexerTest.cs index 4949739..3b7d11a 100644 --- a/tests/Filter/FilterLexerTest.cs +++ b/tests/Filter/FilterLexerTest.cs @@ -115,6 +115,17 @@ public static IEnumerable Parameters() 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"), + } + }; } [Theory] diff --git a/tests/Filter/FilterParserTest.cs b/tests/Filter/FilterParserTest.cs index 77bc841..0214b68 100644 --- a/tests/Filter/FilterParserTest.cs +++ b/tests/Filter/FilterParserTest.cs @@ -7,6 +7,7 @@ public sealed class FilterParserTest [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")] public void Test_ParsingFilterStatement(string input, string expectedLeft, string expectedOperator, string expectedRight) { var lexer = new QueryLexer(input); diff --git a/tests/Filter/FilterTest.cs b/tests/Filter/FilterTest.cs index 3ee7c80..b31970f 100644 --- a/tests/Filter/FilterTest.cs +++ b/tests/Filter/FilterTest.cs @@ -89,6 +89,31 @@ public static IEnumerable Parameters() new User { Id = 1, Firstname = "Harry" }, } }; + + yield return new object[] + { + "firstName contains 'a'", + new User[] + { + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 1, Firstname = "Harry" }, + } + }; + + yield return new object[] + { + "id ne 1 and firstName contains 'a'", + new User[] {} + }; + + yield return new object[] + { + "id ne 1 and firstName contains 'a' or firstName eq 'Apple'", + new User[] + { + new User { Id = 2, Firstname = "Apple" }, + } + }; } [Theory] From b1a112ac3175b49f330d12f5cf7131c7688362d4 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Wed, 29 May 2024 23:19:34 +0100 Subject: [PATCH 14/60] wip --- example/Controllers/UserController.cs | 29 +++++++ example/Program.cs | 26 +++--- src/Ast/OrderByAst.cs | 2 +- src/Evaluator/FilterEvaluator.cs | 68 ++++++++++++++++ src/Evaluator/OrderByEvaluator.cs | 68 ++++++++++++++++ src/Extensions/QueryableExtension.cs | 110 +------------------------- src/Parser/Parser.cs | 23 +++--- src/Token/Token.cs | 2 +- tests/Filter/FilterParserTest.cs | 16 ++++ tests/Filter/FilterTest.cs | 21 +++++ tests/Orderby/OrderByParserTest.cs | 6 ++ tests/Orderby/OrderByTest.cs | 14 ++++ 12 files changed, 252 insertions(+), 133 deletions(-) create mode 100644 example/Controllers/UserController.cs create mode 100644 src/Evaluator/FilterEvaluator.cs create mode 100644 src/Evaluator/OrderByEvaluator.cs diff --git a/example/Controllers/UserController.cs b/example/Controllers/UserController.cs new file mode 100644 index 0000000..315b164 --- /dev/null +++ b/example/Controllers/UserController.cs @@ -0,0 +1,29 @@ +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.AspNetCore.Mvc; + +[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)] + public ActionResult> Get() + { + var users = _db.Users + .Where(x => !x.IsDeleted) + .ProjectTo(_mapper.ConfigurationProvider); + + return Ok(users); + } +} \ No newline at end of file diff --git a/example/Program.cs b/example/Program.cs index df55539..9f155fa 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -6,8 +6,6 @@ using Microsoft.EntityFrameworkCore; using Testcontainers.PostgreSql; -Randomizer.Seed = new Random(123123123); - var builder = WebApplication.CreateBuilder(args); var postgreSqlContainer = new PostgreSqlBuilder() @@ -16,6 +14,8 @@ await postgreSqlContainer.StartAsync(); +builder.Services.AddControllers(); + builder.Services.AddDbContext(options => { options.UseNpgsql(postgreSqlContainer.GetConnectionString()); @@ -46,17 +46,25 @@ } } -app.MapGet("/users", (ApplicationDbContext db, [FromServices] IMapper mapper, [AsParameters] Query query) => +app.MapGet("/minimal/users", (ApplicationDbContext db, [FromServices] IMapper mapper, [AsParameters] Query query) => { - var (users, count) = db.Users - .Where(x => !x.IsDeleted) - .ProjectTo(mapper.ConfigurationProvider) - .Apply(query); + try + { + var (users, count) = db.Users + .Where(x => !x.IsDeleted) + .ProjectTo(mapper.ConfigurationProvider) + .Apply(query); - var response = new PagedResponse(users.ToList(), count); + var response = new PagedResponse(users.ToList(), count); - return TypedResults.Ok(response); + return Results.Ok(response); + } + catch (GoatQueryException ex) + { + return Results.BadRequest(new { ex.Message }); + } }); +app.MapControllers(); app.Run(); diff --git a/src/Ast/OrderByAst.cs b/src/Ast/OrderByAst.cs index 039a551..b3eccd5 100644 --- a/src/Ast/OrderByAst.cs +++ b/src/Ast/OrderByAst.cs @@ -1,6 +1,6 @@ public enum OrderByDirection { - Ascending, + Ascending = 1, Descending } diff --git a/src/Evaluator/FilterEvaluator.cs b/src/Evaluator/FilterEvaluator.cs new file mode 100644 index 0000000..d77efdb --- /dev/null +++ b/src/Evaluator/FilterEvaluator.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq.Expressions; + +public static class FilterEvaluator +{ + public static Expression? Evaluate(QueryExpression expression, ParameterExpression parameterExpression) + { + switch (expression) + { + case InfixExpression exp: + if (exp.Left.GetType() == typeof(Identifier)) + { + MemberExpression property; + try + { + property = Expression.Property(parameterExpression, exp.Left.TokenLiteral()); + } + catch (Exception) + { + throw new GoatQueryException($"Invalid property '{exp.Left.TokenLiteral()}' within filter"); + } + + ConstantExpression? value = null; + + switch (exp.Right) + { + case IntegerLiteral literal: + value = Expression.Constant(literal.Value, property.Type); + break; + case StringLiteral literal: + value = Expression.Constant(literal.Value, property.Type); + break; + default: + break; + } + + switch (exp.Operator) + { + case Keywords.Eq: + return Expression.Equal(property, value); + case Keywords.Ne: + return Expression.NotEqual(property, value); + case Keywords.Contains: + var identifier = (Identifier)exp.Left; + + var method = identifier.Value.GetType().GetMethod("Contains", new[] { value?.Value.GetType() }); + + return Expression.Call(property, method, value); + } + } + + var left = Evaluate(exp.Left, parameterExpression); + var right = Evaluate(exp.Right, parameterExpression); + + switch (exp.Operator) + { + case Keywords.And: + return Expression.AndAlso(left, right); + case Keywords.Or: + return Expression.OrElse(left, right); + } + + break; + } + + return null; + } +} \ No newline at end of file diff --git a/src/Evaluator/OrderByEvaluator.cs b/src/Evaluator/OrderByEvaluator.cs new file mode 100644 index 0000000..9d22f32 --- /dev/null +++ b/src/Evaluator/OrderByEvaluator.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; + +public static class OrderByEvaluator +{ + public static IQueryable Evaluate(IEnumerable statements, ParameterExpression parameterExpression, IQueryable queryable) + { + var isAlreadyOrdered = false; + + foreach (var statement in statements) + { + var property = Expression.Property(parameterExpression, statement.TokenLiteral()); + 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 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/Extensions/QueryableExtension.cs b/src/Extensions/QueryableExtension.cs index 362eeca..5a31a5f 100644 --- a/src/Extensions/QueryableExtension.cs +++ b/src/Extensions/QueryableExtension.cs @@ -23,7 +23,7 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query ParameterExpression parameter = Expression.Parameter(type); - var expression = Evaluate(statement.Expression, parameter); + var expression = FilterEvaluator.Evaluate(statement.Expression, parameter); var exp = Expression.Lambda>(expression, parameter); @@ -58,50 +58,10 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query var parser = new QueryParser(lexer); var statements = parser.ParseOrderBy(); - var isAlreadyOrdered = false; var parameter = Expression.Parameter(type); - foreach (var statement in statements) - { - var property = Expression.Property(parameter, statement.TokenLiteral()); - var lamba = Expression.Lambda(property, parameter); - - if (isAlreadyOrdered) - { - if (statement.Direction == OrderByDirection.Ascending) - { - var method = GenericMethodOf(_ => Queryable.ThenBy(default, default)).MakeGenericMethod(type, lamba.Body.Type); - - queryable = (IQueryable)method.Invoke(null, new object[] { queryable, lamba }); - } - else if (statement.Direction == OrderByDirection.Descending) - { - var method = GenericMethodOf(_ => Queryable.ThenByDescending(default, default)).MakeGenericMethod(type, lamba.Body.Type); - - queryable = (IQueryable)method.Invoke(null, new object[] { queryable, lamba }); - } - } - else - { - if (statement.Direction == OrderByDirection.Ascending) - { - var method = GenericMethodOf(_ => Queryable.OrderBy(default, default)).MakeGenericMethod(type, lamba.Body.Type); - - queryable = (IQueryable)method.Invoke(null, new object[] { queryable, lamba }); - - isAlreadyOrdered = true; - } - else if (statement.Direction == OrderByDirection.Descending) - { - var method = GenericMethodOf(_ => Queryable.OrderByDescending(default, default)).MakeGenericMethod(type, lamba.Body.Type); - - queryable = (IQueryable)method.Invoke(null, new object[] { queryable, lamba }); - - isAlreadyOrdered = true; - } - } - } + queryable = OrderByEvaluator.Evaluate(statements, parameter, queryable); } // Skip @@ -125,70 +85,4 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query return (queryable, count); } - - private static Expression? Evaluate(QueryExpression expression, ParameterExpression parameterExpression) - { - switch (expression) - { - case InfixExpression exp: - if (exp.Left.GetType() == typeof(Identifier)) - { - var property = Expression.Property(parameterExpression, exp.Left.TokenLiteral()); - ConstantExpression? value = null; - - switch (exp.Right) - { - case IntegerLiteral literal: - value = Expression.Constant(literal.Value, property.Type); - break; - case StringLiteral literal: - value = Expression.Constant(literal.Value, property.Type); - break; - default: - break; - } - - switch (exp.Operator) - { - case Keywords.Eq: - return Expression.Equal(property, value); - case Keywords.Ne: - return Expression.NotEqual(property, value); - case Keywords.Contains: - var identifier = (Identifier)exp.Left; - - var method = identifier.Value.GetType().GetMethod("Contains", new[] { value?.Value.GetType() }); - - return Expression.Call(property, method, value); - } - } - - var left = Evaluate(exp.Left, parameterExpression); - var right = Evaluate(exp.Right, parameterExpression); - - switch (exp.Operator) - { - case Keywords.And: - return Expression.AndAlso(left, right); - case Keywords.Or: - return Expression.OrElse(left, right); - } - - break; - } - - return null; - } - - 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/Parser/Parser.cs b/src/Parser/Parser.cs index c2519a0..381b2c8 100644 --- a/src/Parser/Parser.cs +++ b/src/Parser/Parser.cs @@ -69,10 +69,6 @@ private InfixExpression ParseExpression() NextToken(); var right = ParseFilterStatement(); - if (right is null) - { - throw new Exception("bad"); - } left.Right = right; @@ -84,20 +80,14 @@ private InfixExpression ParseExpression() private OrderByStatement ParseOrderByStatement() { - var statement = new OrderByStatement(_currentToken); + var statement = new OrderByStatement(_currentToken, OrderByDirection.Ascending); if (PeekIdentifierIs(Keywords.Desc)) { statement.Direction = OrderByDirection.Descending; - - NextToken(); } - else if (PeekIdentifierIs(Keywords.Asc)) - { - statement.Direction = OrderByDirection.Ascending; - NextToken(); - } + NextToken(); return statement; } @@ -108,7 +98,7 @@ private InfixExpression ParseFilterStatement() if (!PeekIdentifierIn(Keywords.Eq, Keywords.Ne, Keywords.Contains)) { - throw new GoatQueryException("Invalid conjunction within filter string"); + throw new GoatQueryException("Invalid conjunction within filter"); } NextToken(); @@ -117,11 +107,16 @@ private InfixExpression ParseFilterStatement() if (!PeekTokenIn(TokenType.STRING, TokenType.INT)) { - throw new GoatQueryException("Invalid value type within filter string"); + throw new GoatQueryException("Invalid value type within filter"); } NextToken(); + if (statement.Operator.Equals(Keywords.Contains) && _currentToken.Type != TokenType.STRING) + { + throw new GoatQueryException("Value must be a string when using contains operand"); + } + switch (_currentToken.Type) { case TokenType.STRING: diff --git a/src/Token/Token.cs b/src/Token/Token.cs index a9cfd09..78cfd24 100644 --- a/src/Token/Token.cs +++ b/src/Token/Token.cs @@ -1,6 +1,6 @@ public enum TokenType { - EOF, + EOF = 1, ILLEGAL, IDENT, STRING, diff --git a/tests/Filter/FilterParserTest.cs b/tests/Filter/FilterParserTest.cs index 0214b68..9bfd834 100644 --- a/tests/Filter/FilterParserTest.cs +++ b/tests/Filter/FilterParserTest.cs @@ -23,6 +23,22 @@ public void Test_ParsingFilterStatement(string input, string expectedLeft, strin 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'")] + public void Test_ParsingInvalidFilterThrowsException(string input) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + Assert.Throws(parser.ParseFilter); + } + [Fact] public void Test_ParsingFilterStatementWithAnd() { diff --git a/tests/Filter/FilterTest.cs b/tests/Filter/FilterTest.cs index b31970f..a8bd1cf 100644 --- a/tests/Filter/FilterTest.cs +++ b/tests/Filter/FilterTest.cs @@ -139,4 +139,25 @@ public void Test_Filter(string filter, IEnumerable expected) Assert.Equal(expected, results); } + + [Theory] + [InlineData("NonExistentProperty eq 'John'")] + public void Test_InvalidFilterThrowsException(string filter) + { + var users = new List{ + new User { Id = 2, Firstname = "John" }, + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + }.AsQueryable(); + + var query = new Query + { + Filter = filter + }; + + Assert.Throws(() => users.Apply(query)); + } } \ No newline at end of file diff --git a/tests/Orderby/OrderByParserTest.cs b/tests/Orderby/OrderByParserTest.cs index 7bb263e..f727263 100644 --- a/tests/Orderby/OrderByParserTest.cs +++ b/tests/Orderby/OrderByParserTest.cs @@ -53,6 +53,12 @@ public static IEnumerable Parameters() new OrderByStatement(new Token(TokenType.IDENT, "postcode"), OrderByDirection.Descending) } }; + + yield return new object[] + { + "", + new OrderByStatement[] { } + }; } [Theory] diff --git a/tests/Orderby/OrderByTest.cs b/tests/Orderby/OrderByTest.cs index 1918ef4..635bb99 100644 --- a/tests/Orderby/OrderByTest.cs +++ b/tests/Orderby/OrderByTest.cs @@ -163,6 +163,20 @@ public static IEnumerable Parameters() new User { Id = 3, Firstname = "Egg" }, } }; + + yield return new object[] + { + "", + new User[] + { + new User { Id = 2, Firstname = "John" }, + new User { Id = 1, Firstname = "Jane" }, + new User { Id = 2, Firstname = "Apple" }, + new User { Id = 1, Firstname = "Harry" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + } + }; } [Theory] From c2823983a580af0c7245771f06a689a96ee90783 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Thu, 30 May 2024 00:35:06 +0100 Subject: [PATCH 15/60] wip --- example/Attributes/EnableQueryAttribute.cs | 98 +++++++++++++++++++ example/Controllers/UserController.cs | 1 + example/example.csproj | 2 +- src/Extensions/QueryableExtension.cs | 1 - ...atquery-dotnet.csproj => GoatQuery.csproj} | 4 +- tests/tests.csproj | 2 +- 6 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 example/Attributes/EnableQueryAttribute.cs rename src/{goatquery-dotnet.csproj => GoatQuery.csproj} (88%) diff --git a/example/Attributes/EnableQueryAttribute.cs b/example/Attributes/EnableQueryAttribute.cs new file mode 100644 index 0000000..6da4254 --- /dev/null +++ b/example/Attributes/EnableQueryAttribute.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +public sealed class EnableQueryAttribute : ActionFilterAttribute +{ + private readonly QueryOptions? _options; + + public EnableQueryAttribute(QueryOptions options) + { + _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); + + // 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 = top, + 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) = queryable.Apply(query, searchBinder, _options); + + context.Result = new OkObjectResult(new PagedResponse(data, totalCount)); + } + catch (GoatQueryException ex) + { + context.Result = new BadRequestObjectResult(new { ex.Message }); + } + } +} \ No newline at end of file diff --git a/example/Controllers/UserController.cs b/example/Controllers/UserController.cs index 315b164..af7fa42 100644 --- a/example/Controllers/UserController.cs +++ b/example/Controllers/UserController.cs @@ -18,6 +18,7 @@ public UsersController(ApplicationDbContext db, IMapper mapper) // GET: /controller/users [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [EnableQuery] public ActionResult> Get() { var users = _db.Users diff --git a/example/example.csproj b/example/example.csproj index 0251512..f007e40 100644 --- a/example/example.csproj +++ b/example/example.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/Extensions/QueryableExtension.cs b/src/Extensions/QueryableExtension.cs index 5a31a5f..c2ce278 100644 --- a/src/Extensions/QueryableExtension.cs +++ b/src/Extensions/QueryableExtension.cs @@ -1,7 +1,6 @@ using System; using System.Linq; using System.Linq.Expressions; -using System.Reflection; public static class QueryableExtension { diff --git a/src/goatquery-dotnet.csproj b/src/GoatQuery.csproj similarity index 88% rename from src/goatquery-dotnet.csproj rename to src/GoatQuery.csproj index 0bd693d..7afade8 100644 --- a/src/goatquery-dotnet.csproj +++ b/src/GoatQuery.csproj @@ -1,8 +1,8 @@ - netstandard2.1 - goatquery-dotnet + netstandard2.1 + GoatQuery enable GoatQuery diff --git a/tests/tests.csproj b/tests/tests.csproj index 343731a..e45f3b3 100644 --- a/tests/tests.csproj +++ b/tests/tests.csproj @@ -21,7 +21,7 @@ - + From 0c2433a3d5f8a7a6f028eb4f7f037c279e8e04bd Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Fri, 28 Jun 2024 21:55:05 +0100 Subject: [PATCH 16/60] v2 --- .../workflows/build-goatquery-aspnetcore.yml | 26 +++++++ .../{build.yml => build-goatquery.yml} | 10 +-- .github/workflows/publish.yml | 16 +++-- .github/workflows/test.yml | 8 +-- Makefile | 3 - .../src}/EnableQueryAttribute.cs | 4 -- .../src/GoatQuery.AspNetCore.csproj | 27 +++++++ src/GoatQuery/README.md | 1 + .../src}/Ast/ExpressionStatement.cs | 0 src/{ => GoatQuery/src}/Ast/Identifier.cs | 0 .../src}/Ast/InfixExpression.cs | 0 src/{ => GoatQuery/src}/Ast/Node.cs | 0 src/{ => GoatQuery/src}/Ast/OrderByAst.cs | 0 .../src}/Ast/QueryExpression.cs | 0 src/{ => GoatQuery/src}/Ast/Statement.cs | 0 src/{ => GoatQuery/src}/Ast/StringLiteral.cs | 0 .../src}/Evaluator/FilterEvaluator.cs | 0 .../src}/Evaluator/OrderByEvaluator.cs | 0 .../src}/Exceptions/GoatQueryException.cs | 0 .../src}/Extensions/QueryableExtension.cs | 2 - src/{ => GoatQuery/src}/GoatQuery.csproj | 6 +- src/{ => GoatQuery/src}/ISearchBinder.cs | 0 src/{ => GoatQuery/src}/Lexer/Lexer.cs | 6 ++ src/{ => GoatQuery/src}/Parser/Parser.cs | 71 ++++++++++++++----- src/{ => GoatQuery/src}/Query.cs | 1 - src/{ => GoatQuery/src}/QueryOptions.cs | 0 .../src}/Responses/PagedResponse.cs | 0 src/{ => GoatQuery/src}/Token/Token.cs | 2 + .../GoatQuery/tests}/Count/CountTest.cs | 0 .../tests}/Filter/FilterLexerTest.cs | 21 ++++++ .../tests}/Filter/FilterParserTest.cs | 0 .../GoatQuery/tests}/Filter/FilterTest.cs | 52 ++++++++++++++ .../tests}/Orderby/OrderByLexerTest.cs | 0 .../tests}/Orderby/OrderByParserTest.cs | 0 .../GoatQuery/tests}/Orderby/OrderByTest.cs | 0 .../GoatQuery/tests}/Search/SearchTest.cs | 0 .../GoatQuery/tests}/Skip/SkipTest.cs | 0 {tests => src/GoatQuery/tests}/Top/TopTest.cs | 0 {tests => src/GoatQuery/tests}/tests.csproj | 0 39 files changed, 210 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/build-goatquery-aspnetcore.yml rename .github/workflows/{build.yml => build-goatquery.yml} (74%) delete mode 100644 Makefile rename {example/Attributes => src/GoatQuery.AspNetCore/src}/EnableQueryAttribute.cs (95%) create mode 100644 src/GoatQuery.AspNetCore/src/GoatQuery.AspNetCore.csproj create mode 100644 src/GoatQuery/README.md rename src/{ => GoatQuery/src}/Ast/ExpressionStatement.cs (100%) rename src/{ => GoatQuery/src}/Ast/Identifier.cs (100%) rename src/{ => GoatQuery/src}/Ast/InfixExpression.cs (100%) rename src/{ => GoatQuery/src}/Ast/Node.cs (100%) rename src/{ => GoatQuery/src}/Ast/OrderByAst.cs (100%) rename src/{ => GoatQuery/src}/Ast/QueryExpression.cs (100%) rename src/{ => GoatQuery/src}/Ast/Statement.cs (100%) rename src/{ => GoatQuery/src}/Ast/StringLiteral.cs (100%) rename src/{ => GoatQuery/src}/Evaluator/FilterEvaluator.cs (100%) rename src/{ => GoatQuery/src}/Evaluator/OrderByEvaluator.cs (100%) rename src/{ => GoatQuery/src}/Exceptions/GoatQueryException.cs (100%) rename src/{ => GoatQuery/src}/Extensions/QueryableExtension.cs (97%) rename src/{ => GoatQuery/src}/GoatQuery.csproj (87%) rename src/{ => GoatQuery/src}/ISearchBinder.cs (100%) rename src/{ => GoatQuery/src}/Lexer/Lexer.cs (92%) rename src/{ => GoatQuery/src}/Parser/Parser.cs (71%) rename src/{ => GoatQuery/src}/Query.cs (84%) rename src/{ => GoatQuery/src}/QueryOptions.cs (100%) rename src/{ => GoatQuery/src}/Responses/PagedResponse.cs (100%) rename src/{ => GoatQuery/src}/Token/Token.cs (96%) rename {tests => src/GoatQuery/tests}/Count/CountTest.cs (100%) rename {tests => src/GoatQuery/tests}/Filter/FilterLexerTest.cs (84%) rename {tests => src/GoatQuery/tests}/Filter/FilterParserTest.cs (100%) rename {tests => src/GoatQuery/tests}/Filter/FilterTest.cs (74%) rename {tests => src/GoatQuery/tests}/Orderby/OrderByLexerTest.cs (100%) rename {tests => src/GoatQuery/tests}/Orderby/OrderByParserTest.cs (100%) rename {tests => src/GoatQuery/tests}/Orderby/OrderByTest.cs (100%) rename {tests => src/GoatQuery/tests}/Search/SearchTest.cs (100%) rename {tests => src/GoatQuery/tests}/Skip/SkipTest.cs (100%) rename {tests => src/GoatQuery/tests}/Top/TopTest.cs (100%) rename {tests => src/GoatQuery/tests}/tests.csproj (100%) 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..0f88882 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 deleted file mode 100644 index f3367fa..0000000 --- a/Makefile +++ /dev/null @@ -1,3 +0,0 @@ -.PHONY: test -test: - dotnet test ./tests \ No newline at end of file diff --git a/example/Attributes/EnableQueryAttribute.cs b/src/GoatQuery.AspNetCore/src/EnableQueryAttribute.cs similarity index 95% rename from example/Attributes/EnableQueryAttribute.cs rename to src/GoatQuery.AspNetCore/src/EnableQueryAttribute.cs index 6da4254..b732024 100644 --- a/example/Attributes/EnableQueryAttribute.cs +++ b/src/GoatQuery.AspNetCore/src/EnableQueryAttribute.cs @@ -56,9 +56,6 @@ public override void OnActionExecuted(ActionExecutedContext context) // 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(); @@ -72,7 +69,6 @@ public override void OnActionExecuted(ActionExecutedContext context) Skip = skip, Count = count, OrderBy = orderbyQuery.ToString(), - Select = selectQuery.ToString(), Search = search, Filter = filterQuery.ToString() }; diff --git a/src/GoatQuery.AspNetCore/src/GoatQuery.AspNetCore.csproj b/src/GoatQuery.AspNetCore/src/GoatQuery.AspNetCore.csproj new file mode 100644 index 0000000..6f9b850 --- /dev/null +++ b/src/GoatQuery.AspNetCore/src/GoatQuery.AspNetCore.csproj @@ -0,0 +1,27 @@ + + + + netcoreapp3.1;net5.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/Ast/ExpressionStatement.cs b/src/GoatQuery/src/Ast/ExpressionStatement.cs similarity index 100% rename from src/Ast/ExpressionStatement.cs rename to src/GoatQuery/src/Ast/ExpressionStatement.cs diff --git a/src/Ast/Identifier.cs b/src/GoatQuery/src/Ast/Identifier.cs similarity index 100% rename from src/Ast/Identifier.cs rename to src/GoatQuery/src/Ast/Identifier.cs diff --git a/src/Ast/InfixExpression.cs b/src/GoatQuery/src/Ast/InfixExpression.cs similarity index 100% rename from src/Ast/InfixExpression.cs rename to src/GoatQuery/src/Ast/InfixExpression.cs diff --git a/src/Ast/Node.cs b/src/GoatQuery/src/Ast/Node.cs similarity index 100% rename from src/Ast/Node.cs rename to src/GoatQuery/src/Ast/Node.cs diff --git a/src/Ast/OrderByAst.cs b/src/GoatQuery/src/Ast/OrderByAst.cs similarity index 100% rename from src/Ast/OrderByAst.cs rename to src/GoatQuery/src/Ast/OrderByAst.cs diff --git a/src/Ast/QueryExpression.cs b/src/GoatQuery/src/Ast/QueryExpression.cs similarity index 100% rename from src/Ast/QueryExpression.cs rename to src/GoatQuery/src/Ast/QueryExpression.cs diff --git a/src/Ast/Statement.cs b/src/GoatQuery/src/Ast/Statement.cs similarity index 100% rename from src/Ast/Statement.cs rename to src/GoatQuery/src/Ast/Statement.cs diff --git a/src/Ast/StringLiteral.cs b/src/GoatQuery/src/Ast/StringLiteral.cs similarity index 100% rename from src/Ast/StringLiteral.cs rename to src/GoatQuery/src/Ast/StringLiteral.cs diff --git a/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs similarity index 100% rename from src/Evaluator/FilterEvaluator.cs rename to src/GoatQuery/src/Evaluator/FilterEvaluator.cs diff --git a/src/Evaluator/OrderByEvaluator.cs b/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs similarity index 100% rename from src/Evaluator/OrderByEvaluator.cs rename to src/GoatQuery/src/Evaluator/OrderByEvaluator.cs diff --git a/src/Exceptions/GoatQueryException.cs b/src/GoatQuery/src/Exceptions/GoatQueryException.cs similarity index 100% rename from src/Exceptions/GoatQueryException.cs rename to src/GoatQuery/src/Exceptions/GoatQueryException.cs diff --git a/src/Extensions/QueryableExtension.cs b/src/GoatQuery/src/Extensions/QueryableExtension.cs similarity index 97% rename from src/Extensions/QueryableExtension.cs rename to src/GoatQuery/src/Extensions/QueryableExtension.cs index c2ce278..5ae9c8f 100644 --- a/src/Extensions/QueryableExtension.cs +++ b/src/GoatQuery/src/Extensions/QueryableExtension.cs @@ -80,8 +80,6 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query queryable = queryable.Take(options.MaxTop); } - Console.WriteLine(queryable.ToString()); - return (queryable, count); } } \ No newline at end of file diff --git a/src/GoatQuery.csproj b/src/GoatQuery/src/GoatQuery.csproj similarity index 87% rename from src/GoatQuery.csproj rename to src/GoatQuery/src/GoatQuery.csproj index 7afade8..c947d14 100644 --- a/src/GoatQuery.csproj +++ b/src/GoatQuery/src/GoatQuery.csproj @@ -7,16 +7,14 @@ GoatQuery README.md - https://github.com/goatquery + ttps://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 100% rename from src/ISearchBinder.cs rename to src/GoatQuery/src/ISearchBinder.cs diff --git a/src/Lexer/Lexer.cs b/src/GoatQuery/src/Lexer/Lexer.cs similarity index 92% rename from src/Lexer/Lexer.cs rename to src/GoatQuery/src/Lexer/Lexer.cs index 0c8ef2f..4a47f3d 100644 --- a/src/Lexer/Lexer.cs +++ b/src/GoatQuery/src/Lexer/Lexer.cs @@ -39,6 +39,12 @@ public Token NextToken() 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.Type = TokenType.STRING; token.Literal = ReadString(); diff --git a/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs similarity index 71% rename from src/Parser/Parser.cs rename to src/GoatQuery/src/Parser/Parser.cs index 381b2c8..6f82b50 100644 --- a/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -46,6 +46,20 @@ public IEnumerable ParseOrderBy() return statements; } + private OrderByStatement ParseOrderByStatement() + { + var statement = new OrderByStatement(_currentToken, OrderByDirection.Ascending); + + if (PeekIdentifierIs(Keywords.Desc)) + { + statement.Direction = OrderByDirection.Descending; + } + + NextToken(); + + return statement; + } + public ExpressionStatement ParseFilter() { var statement = new ExpressionStatement(_currentToken) @@ -56,40 +70,45 @@ public ExpressionStatement ParseFilter() return statement; } - private InfixExpression ParseExpression() + private InfixExpression ParseExpression(int precedence = 0) { - var left = ParseFilterStatement(); + var left = CurrentTokenIs(TokenType.LPAREN) ? ParseGroupedExpression() : ParseFilterStatement(); NextToken(); - while (CurrentIdentifierIs(Keywords.And) || CurrentIdentifierIs(Keywords.Or)) + while (!CurrentTokenIs(TokenType.EOF) && precedence < GetPrecedence(_currentToken.Type)) { - left = new InfixExpression(_currentToken, left, _currentToken.Literal); - - NextToken(); - - var right = ParseFilterStatement(); + if (CurrentIdentifierIs(Keywords.And) || CurrentIdentifierIs(Keywords.Or)) + { + left = new InfixExpression(_currentToken, left, _currentToken.Literal); + var currentPrecedence = GetPrecedence(_currentToken.Type); - left.Right = right; + NextToken(); - NextToken(); + var right = ParseExpression(currentPrecedence); + left.Right = right; + } + else + { + break; + } } return left; } - private OrderByStatement ParseOrderByStatement() + private InfixExpression ParseGroupedExpression() { - var statement = new OrderByStatement(_currentToken, OrderByDirection.Ascending); + NextToken(); - if (PeekIdentifierIs(Keywords.Desc)) + var exp = ParseExpression(); + + if (!CurrentTokenIs(TokenType.RPAREN)) { - statement.Direction = OrderByDirection.Descending; + throw new GoatQueryException("Expected closing parenthesis"); } - NextToken(); - - return statement; + return exp; } private InfixExpression ParseFilterStatement() @@ -133,11 +152,29 @@ private InfixExpression ParseFilterStatement() return statement; } + 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 PeekTokenIs(TokenType token) + { + return _peekToken.Type == token; + } + private bool PeekTokenIn(params TokenType[] token) { return token.Contains(_peekToken.Type); diff --git a/src/Query.cs b/src/GoatQuery/src/Query.cs similarity index 84% rename from src/Query.cs rename to src/GoatQuery/src/Query.cs index fbd7bfb..2a900bf 100644 --- a/src/Query.cs +++ b/src/GoatQuery/src/Query.cs @@ -4,7 +4,6 @@ public sealed class Query 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/QueryOptions.cs b/src/GoatQuery/src/QueryOptions.cs similarity index 100% rename from src/QueryOptions.cs rename to src/GoatQuery/src/QueryOptions.cs diff --git a/src/Responses/PagedResponse.cs b/src/GoatQuery/src/Responses/PagedResponse.cs similarity index 100% rename from src/Responses/PagedResponse.cs rename to src/GoatQuery/src/Responses/PagedResponse.cs diff --git a/src/Token/Token.cs b/src/GoatQuery/src/Token/Token.cs similarity index 96% rename from src/Token/Token.cs rename to src/GoatQuery/src/Token/Token.cs index 78cfd24..ce57925 100644 --- a/src/Token/Token.cs +++ b/src/GoatQuery/src/Token/Token.cs @@ -5,6 +5,8 @@ public enum TokenType IDENT, STRING, INT, + LPAREN, + RPAREN, } public static class Keywords diff --git a/tests/Count/CountTest.cs b/src/GoatQuery/tests/Count/CountTest.cs similarity index 100% rename from tests/Count/CountTest.cs rename to src/GoatQuery/tests/Count/CountTest.cs diff --git a/tests/Filter/FilterLexerTest.cs b/src/GoatQuery/tests/Filter/FilterLexerTest.cs similarity index 84% rename from tests/Filter/FilterLexerTest.cs rename to src/GoatQuery/tests/Filter/FilterLexerTest.cs index 3b7d11a..38dd51c 100644 --- a/tests/Filter/FilterLexerTest.cs +++ b/src/GoatQuery/tests/Filter/FilterLexerTest.cs @@ -126,6 +126,27 @@ public static IEnumerable Parameters() 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") + } + }; } [Theory] diff --git a/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs similarity index 100% rename from tests/Filter/FilterParserTest.cs rename to src/GoatQuery/tests/Filter/FilterParserTest.cs diff --git a/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs similarity index 74% rename from tests/Filter/FilterTest.cs rename to src/GoatQuery/tests/Filter/FilterTest.cs index a8bd1cf..226ba09 100644 --- a/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -114,6 +114,58 @@ public static IEnumerable Parameters() new User { Id = 2, Firstname = "Apple" }, } }; + + yield return new object[] + { + "Firstname eq 'John' and Id eq 2 or Id eq 3", + new User[] + { + new User { Id = 2, Firstname = "John" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + } + }; + + yield return new object[] + { + "(Firstname eq 'John' and Id eq 2) or Id eq 3", + new User[] + { + new User { Id = 2, Firstname = "John" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + } + }; + + yield return new object[] + { + "Firstname eq 'John' and (Id eq 2 or Id eq 3)", + new User[] + { + new User { Id = 2, Firstname = "John" }, + } + }; + + yield return new object[] + { + "(Firstname eq 'John' and Id eq 2 or Id eq 3)", + new User[] + { + new User { Id = 2, Firstname = "John" }, + new User { Id = 3, Firstname = "Doe" }, + new User { Id = 3, Firstname = "Egg" } + } + }; + + yield return new object[] + { + "(Firstname eq 'John') or (Id eq 3 and Firstname eq 'Egg') or Id eq 1 and (Id eq 2)", + new User[] + { + new User { Id = 2, Firstname = "John" }, + new User { Id = 3, Firstname = "Egg" } + } + }; } [Theory] diff --git a/tests/Orderby/OrderByLexerTest.cs b/src/GoatQuery/tests/Orderby/OrderByLexerTest.cs similarity index 100% rename from tests/Orderby/OrderByLexerTest.cs rename to src/GoatQuery/tests/Orderby/OrderByLexerTest.cs diff --git a/tests/Orderby/OrderByParserTest.cs b/src/GoatQuery/tests/Orderby/OrderByParserTest.cs similarity index 100% rename from tests/Orderby/OrderByParserTest.cs rename to src/GoatQuery/tests/Orderby/OrderByParserTest.cs diff --git a/tests/Orderby/OrderByTest.cs b/src/GoatQuery/tests/Orderby/OrderByTest.cs similarity index 100% rename from tests/Orderby/OrderByTest.cs rename to src/GoatQuery/tests/Orderby/OrderByTest.cs diff --git a/tests/Search/SearchTest.cs b/src/GoatQuery/tests/Search/SearchTest.cs similarity index 100% rename from tests/Search/SearchTest.cs rename to src/GoatQuery/tests/Search/SearchTest.cs diff --git a/tests/Skip/SkipTest.cs b/src/GoatQuery/tests/Skip/SkipTest.cs similarity index 100% rename from tests/Skip/SkipTest.cs rename to src/GoatQuery/tests/Skip/SkipTest.cs diff --git a/tests/Top/TopTest.cs b/src/GoatQuery/tests/Top/TopTest.cs similarity index 100% rename from tests/Top/TopTest.cs rename to src/GoatQuery/tests/Top/TopTest.cs diff --git a/tests/tests.csproj b/src/GoatQuery/tests/tests.csproj similarity index 100% rename from tests/tests.csproj rename to src/GoatQuery/tests/tests.csproj From 13a43b90c582da31f1f15c86a73714ad9354900c Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Fri, 28 Jun 2024 21:57:22 +0100 Subject: [PATCH 17/60] v2 --- .github/dependabot.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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" From 10420f9c75adb0ca42903de7fdf8d8a98449c3b4 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:56:49 +0100 Subject: [PATCH 18/60] support net standard 2.0 --- src/GoatQuery/src/Ast/ExpressionStatement.cs | 2 +- src/GoatQuery/src/Ast/InfixExpression.cs | 4 ++-- src/GoatQuery/src/Evaluator/FilterEvaluator.cs | 4 ++-- src/GoatQuery/src/Extensions/QueryableExtension.cs | 2 +- src/GoatQuery/src/GoatQuery.csproj | 3 +-- src/GoatQuery/src/Parser/Parser.cs | 4 ++-- src/GoatQuery/src/Query.cs | 6 +++--- 7 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/GoatQuery/src/Ast/ExpressionStatement.cs b/src/GoatQuery/src/Ast/ExpressionStatement.cs index 957b7d2..67462d4 100644 --- a/src/GoatQuery/src/Ast/ExpressionStatement.cs +++ b/src/GoatQuery/src/Ast/ExpressionStatement.cs @@ -1,6 +1,6 @@ public sealed class ExpressionStatement : Statement { - public InfixExpression Expression { get; set; } = default!; + public InfixExpression Expression { get; set; } = default; public ExpressionStatement(Token token) : base(token) { diff --git a/src/GoatQuery/src/Ast/InfixExpression.cs b/src/GoatQuery/src/Ast/InfixExpression.cs index 37cba50..ce44cda 100644 --- a/src/GoatQuery/src/Ast/InfixExpression.cs +++ b/src/GoatQuery/src/Ast/InfixExpression.cs @@ -1,8 +1,8 @@ public sealed class InfixExpression : QueryExpression { - public QueryExpression Left { get; set; } = default!; + public QueryExpression Left { get; set; } = default; public string Operator { get; set; } = string.Empty; - public QueryExpression Right { get; set; } = default!; + public QueryExpression Right { get; set; } = default; public InfixExpression(Token token, QueryExpression left, string op) : base(token) { diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index d77efdb..b6fab93 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -3,7 +3,7 @@ public static class FilterEvaluator { - public static Expression? Evaluate(QueryExpression expression, ParameterExpression parameterExpression) + public static Expression Evaluate(QueryExpression expression, ParameterExpression parameterExpression) { switch (expression) { @@ -20,7 +20,7 @@ public static class FilterEvaluator throw new GoatQueryException($"Invalid property '{exp.Left.TokenLiteral()}' within filter"); } - ConstantExpression? value = null; + ConstantExpression value = null; switch (exp.Right) { diff --git a/src/GoatQuery/src/Extensions/QueryableExtension.cs b/src/GoatQuery/src/Extensions/QueryableExtension.cs index 5ae9c8f..c241b63 100644 --- a/src/GoatQuery/src/Extensions/QueryableExtension.cs +++ b/src/GoatQuery/src/Extensions/QueryableExtension.cs @@ -4,7 +4,7 @@ public static class QueryableExtension { - public static (IQueryable, int?) Apply(this IQueryable queryable, Query query, ISearchBinder? searchBinder = null, QueryOptions? options = null) + public static (IQueryable, int?) Apply(this IQueryable queryable, Query query, ISearchBinder searchBinder = null, QueryOptions options = null) { if (query.Top > options?.MaxTop) { diff --git a/src/GoatQuery/src/GoatQuery.csproj b/src/GoatQuery/src/GoatQuery.csproj index c947d14..5da317e 100644 --- a/src/GoatQuery/src/GoatQuery.csproj +++ b/src/GoatQuery/src/GoatQuery.csproj @@ -1,9 +1,8 @@ - netstandard2.1 + netstandard2.0 GoatQuery - enable GoatQuery README.md diff --git a/src/GoatQuery/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs index 6f82b50..9a2d076 100644 --- a/src/GoatQuery/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -5,8 +5,8 @@ public sealed class QueryParser { private readonly QueryLexer _lexer; - private Token _currentToken { get; set; } = default!; - private Token _peekToken { get; set; } = default!; + private Token _currentToken { get; set; } = default; + private Token _peekToken { get; set; } = default; public QueryParser(QueryLexer lexer) { diff --git a/src/GoatQuery/src/Query.cs b/src/GoatQuery/src/Query.cs index 2a900bf..3e71e88 100644 --- a/src/GoatQuery/src/Query.cs +++ b/src/GoatQuery/src/Query.cs @@ -3,7 +3,7 @@ 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; + 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 From e870042890b1a3950da18447091c211fe6900f56 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Sat, 13 Jul 2024 17:03:07 +0100 Subject: [PATCH 19/60] wip --- example/example.csproj | 2 +- src/GoatQuery/src/GoatQuery.csproj | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/example/example.csproj b/example/example.csproj index f007e40..6b0472b 100644 --- a/example/example.csproj +++ b/example/example.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/GoatQuery/src/GoatQuery.csproj b/src/GoatQuery/src/GoatQuery.csproj index 5da317e..2fe9063 100644 --- a/src/GoatQuery/src/GoatQuery.csproj +++ b/src/GoatQuery/src/GoatQuery.csproj @@ -1,7 +1,7 @@ - netstandard2.0 + netstandard2.0;netstandard2.1 GoatQuery GoatQuery @@ -19,7 +19,7 @@ - + From 89c2f59bd4582506d19035b3d26b24c7dc0addcb Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Sat, 13 Jul 2024 22:51:10 +0100 Subject: [PATCH 20/60] wip --- Makefile | 13 +++++++++++++ .../src/{ => Attributes}/EnableQueryAttribute.cs | 0 .../src/GoatQuery.AspNetCore.csproj | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 Makefile rename src/GoatQuery.AspNetCore/src/{ => Attributes}/EnableQueryAttribute.cs (100%) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..747db23 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +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/src/GoatQuery.AspNetCore/src/EnableQueryAttribute.cs b/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs similarity index 100% rename from src/GoatQuery.AspNetCore/src/EnableQueryAttribute.cs rename to src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs diff --git a/src/GoatQuery.AspNetCore/src/GoatQuery.AspNetCore.csproj b/src/GoatQuery.AspNetCore/src/GoatQuery.AspNetCore.csproj index 6f9b850..2f32985 100644 --- a/src/GoatQuery.AspNetCore/src/GoatQuery.AspNetCore.csproj +++ b/src/GoatQuery.AspNetCore/src/GoatQuery.AspNetCore.csproj @@ -1,7 +1,7 @@  - netcoreapp3.1;net5.0 + net6.0;net8.0 11.0 GoatQuery.AspNetCore enable From afa8590a04758ec8edbb0cb1fe5732b88d34440f Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Sat, 13 Jul 2024 23:24:27 +0100 Subject: [PATCH 21/60] wip --- Makefile | 3 + src/GoatQuery/tests/Count/CountTest.cs | 24 +-- src/GoatQuery/tests/Filter/FilterTest.cs | 120 +++++++------- src/GoatQuery/tests/Orderby/OrderByTest.cs | 180 ++++++++++----------- src/GoatQuery/tests/Search/SearchTest.cs | 12 +- src/GoatQuery/tests/Skip/SkipTest.cs | 42 ++--- src/GoatQuery/tests/Top/TopTest.cs | 36 ++--- 7 files changed, 210 insertions(+), 207 deletions(-) diff --git a/Makefile b/Makefile index 747db23..b977bc2 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,6 @@ +test: + dotnet test ./src/GoatQuery/tests + build: dotnet build ./src/GoatQuery/src --configuration Release dotnet build ./src/GoatQuery.AspNetCore/src --configuration Release diff --git a/src/GoatQuery/tests/Count/CountTest.cs b/src/GoatQuery/tests/Count/CountTest.cs index 2467399..920b28e 100644 --- a/src/GoatQuery/tests/Count/CountTest.cs +++ b/src/GoatQuery/tests/Count/CountTest.cs @@ -7,12 +7,12 @@ public sealed class CountTest public void Test_CountWithTrue() { var users = new List{ - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + 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 @@ -31,12 +31,12 @@ public void Test_CountWithTrue() public void Test_CountWithFalse() { var users = new List{ - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + 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 diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 226ba09..2b9f0a5 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -9,7 +9,7 @@ public static IEnumerable Parameters() "firstname eq 'John'", new User[] { - new User { Id = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "John" }, } }; @@ -21,72 +21,72 @@ public static IEnumerable Parameters() yield return new object[] { - "id eq 1", + "Age eq 1", new User[] { - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, + new User { Age = 1, Firstname = "Jane" }, + new User { Age = 1, Firstname = "Harry" }, } }; yield return new object[] { - "id eq 0", + "Age eq 0", new User[] {} }; yield return new object[] { - "firstname eq 'John' and id eq 2", + "firstname eq 'John' and Age eq 2", new User[] { - new User { Id = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "John" }, } }; yield return new object[] { - "firstname eq 'John' or id eq 3", + "firstname eq 'John' or Age eq 3", new User[] { - new User { Id = 2, Firstname = "John" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + new User { Age = 2, Firstname = "John" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } } }; yield return new object[] { - "id eq 1 and firstName eq 'Harry' or id eq 2", + "Age eq 1 and firstName eq 'Harry' or Age eq 2", new User[] { - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 1, Firstname = "Harry" }, + new User { Age = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 1, Firstname = "Harry" }, } }; yield return new object[] { - "id eq 1 or id eq 2 or firstName eq 'Egg'", + "Age eq 1 or Age eq 2 or firstName eq 'Egg'", new User[] { - new User { Id = 2, Firstname = "John" }, - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 3, Firstname = "Egg" } + 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 = "Egg" } } }; yield return new object[] { - "id ne 3", + "Age ne 3", new User[] { - new User { Id = 2, Firstname = "John" }, - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 1, Firstname = "Harry" }, + new User { Age = 2, Firstname = "John" }, + new User { Age = 1, Firstname = "Jane" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 1, Firstname = "Harry" }, } }; @@ -95,75 +95,75 @@ public static IEnumerable Parameters() "firstName contains 'a'", new User[] { - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, + new User { Age = 1, Firstname = "Jane" }, + new User { Age = 1, Firstname = "Harry" }, } }; yield return new object[] { - "id ne 1 and firstName contains 'a'", + "Age ne 1 and firstName contains 'a'", new User[] {} }; yield return new object[] { - "id ne 1 and firstName contains 'a' or firstName eq 'Apple'", + "Age ne 1 and firstName contains 'a' or firstName eq 'Apple'", new User[] { - new User { Id = 2, Firstname = "Apple" }, + new User { Age = 2, Firstname = "Apple" }, } }; yield return new object[] { - "Firstname eq 'John' and Id eq 2 or Id eq 3", + "Firstname eq 'John' and Age eq 2 or Age eq 3", new User[] { - new User { Id = 2, Firstname = "John" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + new User { Age = 2, Firstname = "John" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } } }; yield return new object[] { - "(Firstname eq 'John' and Id eq 2) or Id eq 3", + "(Firstname eq 'John' and Age eq 2) or Age eq 3", new User[] { - new User { Id = 2, Firstname = "John" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + new User { Age = 2, Firstname = "John" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } } }; yield return new object[] { - "Firstname eq 'John' and (Id eq 2 or Id eq 3)", + "Firstname eq 'John' and (Age eq 2 or Age eq 3)", new User[] { - new User { Id = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "John" }, } }; yield return new object[] { - "(Firstname eq 'John' and Id eq 2 or Id eq 3)", + "(Firstname eq 'John' and Age eq 2 or Age eq 3)", new User[] { - new User { Id = 2, Firstname = "John" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + new User { Age = 2, Firstname = "John" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } } }; yield return new object[] { - "(Firstname eq 'John') or (Id eq 3 and Firstname eq 'Egg') or Id eq 1 and (Id eq 2)", + "(Firstname eq 'John') or (Age eq 3 and Firstname eq 'Egg') or Age eq 1 and (Age eq 2)", new User[] { - new User { Id = 2, Firstname = "John" }, - new User { Id = 3, Firstname = "Egg" } + new User { Age = 2, Firstname = "John" }, + new User { Age = 3, Firstname = "Egg" } } }; } @@ -173,12 +173,12 @@ public static IEnumerable Parameters() public void Test_Filter(string filter, IEnumerable expected) { var users = new List{ - new User { Id = 2, Firstname = "John" }, - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + 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 @@ -197,12 +197,12 @@ public void Test_Filter(string filter, IEnumerable expected) public void Test_InvalidFilterThrowsException(string filter) { var users = new List{ - new User { Id = 2, Firstname = "John" }, - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + 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 diff --git a/src/GoatQuery/tests/Orderby/OrderByTest.cs b/src/GoatQuery/tests/Orderby/OrderByTest.cs index 635bb99..da77999 100644 --- a/src/GoatQuery/tests/Orderby/OrderByTest.cs +++ b/src/GoatQuery/tests/Orderby/OrderByTest.cs @@ -2,7 +2,7 @@ public sealed record User { - public int Id { get; set; } + public int Age { get; set; } public string Firstname { get; set; } = string.Empty; } @@ -12,155 +12,155 @@ public static IEnumerable Parameters() { yield return new object[] { - "id desc, firstname asc", + "Age desc, firstname asc", new User[] { - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 1, Firstname = "Jane" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 2, Firstname = "John" }, + new User { Age = 1, Firstname = "Harry" }, + new User { Age = 1, Firstname = "Jane" }, } }; yield return new object[] { - "id desc, firstname desc", + "Age desc, firstname desc", new User[] { - new User { Id = 3, Firstname = "Egg" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, + new User { Age = 3, Firstname = "Egg" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 1, Firstname = "Jane" }, + new User { Age = 1, Firstname = "Harry" }, } }; yield return new object[] { - "id desc", + "Age desc", new User[] { - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" }, + new User { Age = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 1, Firstname = "Jane" }, + new User { Age = 1, Firstname = "Harry" }, } }; yield return new object[] { - "id asc", + "Age asc", new User[] { - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" }, + 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" }, } }; yield return new object[] { - "id", + "Age", new User[] { - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" }, + 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" }, } }; yield return new object[] { - "id asc", + "Age asc", new User[] { - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" }, + 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" }, } }; yield return new object[] { - "Id asc", + "Age asc", new User[] { - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" }, + 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" }, } }; yield return new object[] { - "ID asc", + "Age asc", new User[] { - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" }, + 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" }, } }; yield return new object[] { - "iD asc", + "Age asc", new User[] { - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" }, + 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" }, } }; yield return new object[] { - "id Asc", + "Age Asc", new User[] { - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" }, + 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" }, } }; yield return new object[] { - "id aSc", + "Age aSc", new User[] { - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" }, + 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" }, } }; @@ -169,12 +169,12 @@ public static IEnumerable Parameters() "", new User[] { - new User { Id = 2, Firstname = "John" }, - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + 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" } } }; } @@ -184,12 +184,12 @@ public static IEnumerable Parameters() public void Test_OrderBy(string orderby, IEnumerable expected) { var users = new List{ - new User { Id = 2, Firstname = "John" }, - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + 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 diff --git a/src/GoatQuery/tests/Search/SearchTest.cs b/src/GoatQuery/tests/Search/SearchTest.cs index b5592f3..aad3ef3 100644 --- a/src/GoatQuery/tests/Search/SearchTest.cs +++ b/src/GoatQuery/tests/Search/SearchTest.cs @@ -25,12 +25,12 @@ public sealed class SearchTest public void Test_Search(string searchTerm, int expectedCount) { var users = new List{ - new User { Id = 2, Firstname = "John" }, - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + 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 diff --git a/src/GoatQuery/tests/Skip/SkipTest.cs b/src/GoatQuery/tests/Skip/SkipTest.cs index 788b4d0..2f189e4 100644 --- a/src/GoatQuery/tests/Skip/SkipTest.cs +++ b/src/GoatQuery/tests/Skip/SkipTest.cs @@ -9,11 +9,11 @@ public static IEnumerable Parameters() 1, new User[] { - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + 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" } } }; @@ -22,10 +22,10 @@ public static IEnumerable Parameters() 2, new User[] { - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + new User { Age = 2, Firstname = "John" }, + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } } }; @@ -34,9 +34,9 @@ public static IEnumerable Parameters() 3, new User[] { - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + new User { Age = 2, Firstname = "Apple" }, + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } } }; @@ -45,8 +45,8 @@ public static IEnumerable Parameters() 4, new User[] { - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + new User { Age = 3, Firstname = "Doe" }, + new User { Age = 3, Firstname = "Egg" } } }; @@ -55,7 +55,7 @@ public static IEnumerable Parameters() 5, new User[] { - new User { Id = 3, Firstname = "Egg" } + new User { Age = 3, Firstname = "Egg" } } }; @@ -83,12 +83,12 @@ public static IEnumerable Parameters() public void Test_Skip(int skip, IEnumerable expected) { var users = new List{ - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + 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 diff --git a/src/GoatQuery/tests/Top/TopTest.cs b/src/GoatQuery/tests/Top/TopTest.cs index e4c89e3..9f99562 100644 --- a/src/GoatQuery/tests/Top/TopTest.cs +++ b/src/GoatQuery/tests/Top/TopTest.cs @@ -15,12 +15,12 @@ public sealed class TopTest public void Test_Top(int top, int expectedCount) { var users = new List{ - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + 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 @@ -44,12 +44,12 @@ public void Test_Top(int top, int expectedCount) public void Test_TopWithMaxTop(int top, int expectedCount) { var users = new List{ - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + 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 @@ -75,12 +75,12 @@ public void Test_TopWithMaxTop(int top, int expectedCount) public void Test_TopWithMaxTopThrowsException(int top) { var users = new List{ - new User { Id = 1, Firstname = "Jane" }, - new User { Id = 1, Firstname = "Harry" }, - new User { Id = 2, Firstname = "John" }, - new User { Id = 2, Firstname = "Apple" }, - new User { Id = 3, Firstname = "Doe" }, - new User { Id = 3, Firstname = "Egg" } + 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 From a46bf33831084b2766560576a20927e5eed14c8f Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Tue, 23 Jul 2024 12:32:03 +0100 Subject: [PATCH 22/60] fixed age capitilzation --- src/GoatQuery/tests/Count/CountTest.cs | 1 - src/GoatQuery/tests/Orderby/OrderByTest.cs | 18 +++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/GoatQuery/tests/Count/CountTest.cs b/src/GoatQuery/tests/Count/CountTest.cs index 920b28e..08c604e 100644 --- a/src/GoatQuery/tests/Count/CountTest.cs +++ b/src/GoatQuery/tests/Count/CountTest.cs @@ -2,7 +2,6 @@ public sealed class CountTest { - [Fact] public void Test_CountWithTrue() { diff --git a/src/GoatQuery/tests/Orderby/OrderByTest.cs b/src/GoatQuery/tests/Orderby/OrderByTest.cs index da77999..1e3d3ae 100644 --- a/src/GoatQuery/tests/Orderby/OrderByTest.cs +++ b/src/GoatQuery/tests/Orderby/OrderByTest.cs @@ -12,7 +12,7 @@ public static IEnumerable Parameters() { yield return new object[] { - "Age desc, firstname asc", + "age desc, firstname asc", new User[] { new User { Age = 3, Firstname = "Doe" }, @@ -26,7 +26,7 @@ public static IEnumerable Parameters() yield return new object[] { - "Age desc, firstname desc", + "age desc, firstname desc", new User[] { new User { Age = 3, Firstname = "Egg" }, @@ -40,7 +40,7 @@ public static IEnumerable Parameters() yield return new object[] { - "Age desc", + "age desc", new User[] { new User { Age = 3, Firstname = "Doe" }, @@ -68,7 +68,7 @@ public static IEnumerable Parameters() yield return new object[] { - "Age", + "age", new User[] { new User { Age = 1, Firstname = "Jane" }, @@ -82,7 +82,7 @@ public static IEnumerable Parameters() yield return new object[] { - "Age asc", + "age asc", new User[] { new User { Age = 1, Firstname = "Jane" }, @@ -110,7 +110,7 @@ public static IEnumerable Parameters() yield return new object[] { - "Age asc", + "aGe asc", new User[] { new User { Age = 1, Firstname = "Jane" }, @@ -124,7 +124,7 @@ public static IEnumerable Parameters() yield return new object[] { - "Age asc", + "AGe asc", new User[] { new User { Age = 1, Firstname = "Jane" }, @@ -138,7 +138,7 @@ public static IEnumerable Parameters() yield return new object[] { - "Age Asc", + "aGE Asc", new User[] { new User { Age = 1, Firstname = "Jane" }, @@ -152,7 +152,7 @@ public static IEnumerable Parameters() yield return new object[] { - "Age aSc", + "age aSc", new User[] { new User { Age = 1, Firstname = "Jane" }, From f488dd97a0bde4b65f68f9d45a54d5a40e29dc49 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Tue, 23 Jul 2024 15:43:25 +0100 Subject: [PATCH 23/60] Added functionality to pull property name based on json attribute --- example/Dto/UserDto.cs | 4 +++ .../src/Evaluator/FilterEvaluator.cs | 18 +++++----- .../src/Evaluator/OrderByEvaluator.cs | 9 +++-- .../src/Extensions/QueryableExtension.cs | 30 ++++++++++++++-- src/GoatQuery/src/Lexer/Lexer.cs | 2 +- src/GoatQuery/tests/Filter/FilterLexerTest.cs | 22 ++++++++++++ src/GoatQuery/tests/Filter/FilterTest.cs | 25 +++++++++++++ .../tests/Orderby/OrderByLexerTest.cs | 20 +++++++++++ .../tests/Orderby/OrderByParserTest.cs | 11 ++++++ src/GoatQuery/tests/Orderby/OrderByTest.cs | 36 +++++++++++++++---- src/GoatQuery/tests/User.cs | 13 +++++++ 11 files changed, 169 insertions(+), 21 deletions(-) create mode 100644 src/GoatQuery/tests/User.cs diff --git a/example/Dto/UserDto.cs b/example/Dto/UserDto.cs index e411609..1cd1837 100644 --- a/example/Dto/UserDto.cs +++ b/example/Dto/UserDto.cs @@ -1,6 +1,10 @@ +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; } diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index b6fab93..d297e4e 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -1,25 +1,23 @@ using System; +using System.Collections.Generic; using System.Linq.Expressions; public static class FilterEvaluator { - public static Expression Evaluate(QueryExpression expression, ParameterExpression parameterExpression) + public static Expression Evaluate(QueryExpression expression, ParameterExpression parameterExpression, Dictionary propertyMapping) { switch (expression) { case InfixExpression exp: if (exp.Left.GetType() == typeof(Identifier)) { - MemberExpression property; - try + if (!propertyMapping.TryGetValue(exp.Left.TokenLiteral(), out var propertyName)) { - property = Expression.Property(parameterExpression, exp.Left.TokenLiteral()); - } - catch (Exception) - { - throw new GoatQueryException($"Invalid property '{exp.Left.TokenLiteral()}' within filter"); + throw new GoatQueryException($"Invalid property '{exp.Left.TokenLiteral()}' within filter."); } + var property = Expression.Property(parameterExpression, propertyName); + ConstantExpression value = null; switch (exp.Right) @@ -49,8 +47,8 @@ public static Expression Evaluate(QueryExpression expression, ParameterExpressio } } - var left = Evaluate(exp.Left, parameterExpression); - var right = Evaluate(exp.Right, parameterExpression); + var left = Evaluate(exp.Left, parameterExpression, propertyMapping); + var right = Evaluate(exp.Right, parameterExpression, propertyMapping); switch (exp.Operator) { diff --git a/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs b/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs index 9d22f32..7e20a8e 100644 --- a/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs @@ -6,13 +6,18 @@ public static class OrderByEvaluator { - public static IQueryable Evaluate(IEnumerable statements, ParameterExpression parameterExpression, IQueryable queryable) + public static IQueryable Evaluate(IEnumerable statements, ParameterExpression parameterExpression, IQueryable queryable, Dictionary propertyMapping) { var isAlreadyOrdered = false; foreach (var statement in statements) { - var property = Expression.Property(parameterExpression, statement.TokenLiteral()); + if (!propertyMapping.TryGetValue(statement.TokenLiteral(), out var propertyName)) + { + throw new GoatQueryException($"Invalid property '{statement.TokenLiteral()}' within orderby."); + } + + var property = Expression.Property(parameterExpression, propertyName); var lambda = Expression.Lambda(property, parameterExpression); if (isAlreadyOrdered) diff --git a/src/GoatQuery/src/Extensions/QueryableExtension.cs b/src/GoatQuery/src/Extensions/QueryableExtension.cs index c241b63..c400280 100644 --- a/src/GoatQuery/src/Extensions/QueryableExtension.cs +++ b/src/GoatQuery/src/Extensions/QueryableExtension.cs @@ -1,9 +1,33 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json.Serialization; public static class QueryableExtension { + private static Dictionary CreatePropertyMapping() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var properties = typeof(T).GetProperties(); + + foreach (var property in properties) + { + var jsonPropertyNameAttribute = property.GetCustomAttribute(); + if (jsonPropertyNameAttribute != null) + { + result[jsonPropertyNameAttribute.Name] = property.Name; + continue; + } + + result[property.Name] = property.Name; + } + + return result; + } + public static (IQueryable, int?) Apply(this IQueryable queryable, Query query, ISearchBinder searchBinder = null, QueryOptions options = null) { if (query.Top > options?.MaxTop) @@ -13,6 +37,8 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query var type = typeof(T); + var propertyMappings = CreatePropertyMapping(); + // Filter if (!string.IsNullOrEmpty(query.Filter)) { @@ -22,7 +48,7 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query ParameterExpression parameter = Expression.Parameter(type); - var expression = FilterEvaluator.Evaluate(statement.Expression, parameter); + var expression = FilterEvaluator.Evaluate(statement.Expression, parameter, propertyMappings); var exp = Expression.Lambda>(expression, parameter); @@ -60,7 +86,7 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query var parameter = Expression.Parameter(type); - queryable = OrderByEvaluator.Evaluate(statements, parameter, queryable); + queryable = OrderByEvaluator.Evaluate(statements, parameter, queryable, propertyMappings); } // Skip diff --git a/src/GoatQuery/src/Lexer/Lexer.cs b/src/GoatQuery/src/Lexer/Lexer.cs index 4a47f3d..df011aa 100644 --- a/src/GoatQuery/src/Lexer/Lexer.cs +++ b/src/GoatQuery/src/Lexer/Lexer.cs @@ -74,7 +74,7 @@ private string ReadIdentifier() { var currentPosition = _position; - while (IsLetter(_character)) + while (IsLetter(_character) || IsDigit(_character)) { ReadCharacter(); } diff --git a/src/GoatQuery/tests/Filter/FilterLexerTest.cs b/src/GoatQuery/tests/Filter/FilterLexerTest.cs index 38dd51c..caeca60 100644 --- a/src/GoatQuery/tests/Filter/FilterLexerTest.cs +++ b/src/GoatQuery/tests/Filter/FilterLexerTest.cs @@ -147,6 +147,28 @@ public static IEnumerable Parameters() 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"), + } + }; } [Theory] diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 2b9f0a5..63d877d 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -212,4 +212,29 @@ public void Test_InvalidFilterThrowsException(string filter) Assert.Throws(() => users.Apply(query)); } + + [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 (queryable, _) = users.Apply(query); + var results = queryable.ToArray(); + + Assert.Equal(new List{ + new CustomJsonPropertyUser { Lastname = "John" }, + }, results); + } } \ No newline at end of file diff --git a/src/GoatQuery/tests/Orderby/OrderByLexerTest.cs b/src/GoatQuery/tests/Orderby/OrderByLexerTest.cs index 2bf98e7..d811665 100644 --- a/src/GoatQuery/tests/Orderby/OrderByLexerTest.cs +++ b/src/GoatQuery/tests/Orderby/OrderByLexerTest.cs @@ -63,6 +63,26 @@ public static IEnumerable Parameters() 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] diff --git a/src/GoatQuery/tests/Orderby/OrderByParserTest.cs b/src/GoatQuery/tests/Orderby/OrderByParserTest.cs index f727263..8864a44 100644 --- a/src/GoatQuery/tests/Orderby/OrderByParserTest.cs +++ b/src/GoatQuery/tests/Orderby/OrderByParserTest.cs @@ -54,6 +54,17 @@ public static IEnumerable Parameters() } }; + 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[] { "", diff --git a/src/GoatQuery/tests/Orderby/OrderByTest.cs b/src/GoatQuery/tests/Orderby/OrderByTest.cs index 1e3d3ae..1438a46 100644 --- a/src/GoatQuery/tests/Orderby/OrderByTest.cs +++ b/src/GoatQuery/tests/Orderby/OrderByTest.cs @@ -1,11 +1,5 @@ using Xunit; -public sealed record User -{ - public int Age { get; set; } - public string Firstname { get; set; } = string.Empty; -} - public sealed class OrderByTest { public static IEnumerable Parameters() @@ -202,4 +196,34 @@ public void Test_OrderBy(string orderby, IEnumerable expected) Assert.Equal(expected, results); } + + [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 (queryable, _) = users.Apply(query); + var results = queryable.ToArray(); + + 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" }, + }, results); + } } \ 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..c7a53bf --- /dev/null +++ b/src/GoatQuery/tests/User.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +public record User +{ + public int Age { get; set; } + public string Firstname { get; set; } = string.Empty; +} + +public sealed record CustomJsonPropertyUser : User +{ + [JsonPropertyName("last_name")] + public string Lastname { get; set; } = string.Empty; +} \ No newline at end of file From cf9d3e9515d183dce4b4a041ae12f0a1d4e2900d Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Tue, 23 Jul 2024 22:19:27 +0100 Subject: [PATCH 24/60] added fluent results --- example/Program.cs | 22 +++++------ example/example.csproj | 1 + .../src/Attributes/EnableQueryAttribute.cs | 15 ++++---- .../src/Evaluator/FilterEvaluator.cs | 19 +++++++--- .../src/Evaluator/OrderByEvaluator.cs | 7 ++-- .../src/Exceptions/GoatQueryException.cs | 18 --------- .../src/Extensions/QueryableExtension.cs | 29 +++++++++++---- src/GoatQuery/src/GoatQuery.csproj | 1 + src/GoatQuery/src/Parser/Parser.cs | 37 +++++++++++++------ src/GoatQuery/src/QueryResult.cs | 13 +++++++ 10 files changed, 98 insertions(+), 64 deletions(-) delete mode 100644 src/GoatQuery/src/Exceptions/GoatQueryException.cs create mode 100644 src/GoatQuery/src/QueryResult.cs diff --git a/example/Program.cs b/example/Program.cs index 9f155fa..e58704a 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -48,21 +48,19 @@ app.MapGet("/minimal/users", (ApplicationDbContext db, [FromServices] IMapper mapper, [AsParameters] Query query) => { - try - { - var (users, count) = db.Users - .Where(x => !x.IsDeleted) - .ProjectTo(mapper.ConfigurationProvider) - .Apply(query); - - var response = new PagedResponse(users.ToList(), count); + var result = db.Users + .Where(x => !x.IsDeleted) + .ProjectTo(mapper.ConfigurationProvider) + .Apply(query); - return Results.Ok(response); - } - catch (GoatQueryException ex) + if (result.IsFailed) { - return Results.BadRequest(new { ex.Message }); + return Results.BadRequest(new { message = result.Errors }); } + + var response = new PagedResponse(result.Value.Query.ToList(), result.Value.Count); + + return Results.Ok(response); }); app.MapControllers(); diff --git a/example/example.csproj b/example/example.csproj index 6b0472b..e9f34c6 100644 --- a/example/example.csproj +++ b/example/example.csproj @@ -16,6 +16,7 @@ + diff --git a/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs b/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs index b732024..91a68a2 100644 --- a/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs +++ b/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs @@ -80,15 +80,14 @@ public override void OnActionExecuted(ActionExecutedContext context) searchBinder = context.HttpContext.RequestServices.GetService(typeof(ISearchBinder)) as ISearchBinder; } - try + var applyResult = queryable.Apply(query, searchBinder, _options); + if (applyResult.IsFailed) { - var (data, totalCount) = queryable.Apply(query, searchBinder, _options); - - context.Result = new OkObjectResult(new PagedResponse(data, totalCount)); - } - catch (GoatQueryException ex) - { - context.Result = new BadRequestObjectResult(new { ex.Message }); + 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/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index d297e4e..8d77b46 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -1,10 +1,10 @@ -using System; using System.Collections.Generic; using System.Linq.Expressions; +using FluentResults; public static class FilterEvaluator { - public static Expression Evaluate(QueryExpression expression, ParameterExpression parameterExpression, Dictionary propertyMapping) + public static Result Evaluate(QueryExpression expression, ParameterExpression parameterExpression, Dictionary propertyMapping) { switch (expression) { @@ -13,7 +13,7 @@ public static Expression Evaluate(QueryExpression expression, ParameterExpressio { if (!propertyMapping.TryGetValue(exp.Left.TokenLiteral(), out var propertyName)) { - throw new GoatQueryException($"Invalid property '{exp.Left.TokenLiteral()}' within filter."); + return Result.Fail($"Invalid property '{exp.Left.TokenLiteral()}' within filter"); } var property = Expression.Property(parameterExpression, propertyName); @@ -48,14 +48,23 @@ public static Expression Evaluate(QueryExpression expression, ParameterExpressio } var left = Evaluate(exp.Left, parameterExpression, propertyMapping); + if (left.IsFailed) + { + return left; + } + var right = Evaluate(exp.Right, parameterExpression, propertyMapping); + if (right.IsFailed) + { + return right; + } switch (exp.Operator) { case Keywords.And: - return Expression.AndAlso(left, right); + return Expression.AndAlso(left.Value, right.Value); case Keywords.Or: - return Expression.OrElse(left, right); + return Expression.OrElse(left.Value, right.Value); } break; diff --git a/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs b/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs index 7e20a8e..212b26f 100644 --- a/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs @@ -3,10 +3,11 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; +using FluentResults; public static class OrderByEvaluator { - public static IQueryable Evaluate(IEnumerable statements, ParameterExpression parameterExpression, IQueryable queryable, Dictionary propertyMapping) + public static Result> Evaluate(IEnumerable statements, ParameterExpression parameterExpression, IQueryable queryable, Dictionary propertyMapping) { var isAlreadyOrdered = false; @@ -14,7 +15,7 @@ public static IQueryable Evaluate(IEnumerable statements { if (!propertyMapping.TryGetValue(statement.TokenLiteral(), out var propertyName)) { - throw new GoatQueryException($"Invalid property '{statement.TokenLiteral()}' within orderby."); + return Result.Fail(new Error($"Invalid property '{statement.TokenLiteral()}' within orderby")); } var property = Expression.Property(parameterExpression, propertyName); @@ -56,7 +57,7 @@ public static IQueryable Evaluate(IEnumerable statements } } - return queryable; + return Result.Ok(queryable); } private static MethodInfo GenericMethodOf(Expression> expression) diff --git a/src/GoatQuery/src/Exceptions/GoatQueryException.cs b/src/GoatQuery/src/Exceptions/GoatQueryException.cs deleted file mode 100644 index dcf44da..0000000 --- a/src/GoatQuery/src/Exceptions/GoatQueryException.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; - -public sealed 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/GoatQuery/src/Extensions/QueryableExtension.cs b/src/GoatQuery/src/Extensions/QueryableExtension.cs index c400280..422550b 100644 --- a/src/GoatQuery/src/Extensions/QueryableExtension.cs +++ b/src/GoatQuery/src/Extensions/QueryableExtension.cs @@ -4,6 +4,7 @@ using System.Linq.Expressions; using System.Reflection; using System.Text.Json.Serialization; +using FluentResults; public static class QueryableExtension { @@ -28,11 +29,11 @@ private static Dictionary CreatePropertyMapping() return result; } - public static (IQueryable, int?) Apply(this IQueryable queryable, Query query, ISearchBinder searchBinder = null, QueryOptions options = null) + public static Result> Apply(this IQueryable queryable, Query query, ISearchBinder searchBinder = null, QueryOptions options = null) { if (query.Top > options?.MaxTop) { - throw new GoatQueryException("The value supplied for the query parameter 'Top' was greater than the maximum top allowed for this resource"); + 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); @@ -45,12 +46,20 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query 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.Expression, parameter, propertyMappings); + var expression = FilterEvaluator.Evaluate(statement.Value.Expression, parameter, propertyMappings); + if (expression.IsFailed) + { + return Result.Fail(expression.Errors); + } - var exp = Expression.Lambda>(expression, parameter); + var exp = Expression.Lambda>(expression.Value, parameter); queryable = queryable.Where(exp); } @@ -62,7 +71,7 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query if (searchExpression is null) { - throw new GoatQueryException("Cannot parse search binder expression"); + return Result.Fail("Cannot parse search binder expression"); } queryable = queryable.Where(searchExpression); @@ -86,7 +95,13 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query var parameter = Expression.Parameter(type); - queryable = OrderByEvaluator.Evaluate(statements, parameter, queryable, propertyMappings); + var orderByQuery = OrderByEvaluator.Evaluate(statements, parameter, queryable, propertyMappings); + if (orderByQuery.IsFailed) + { + return Result.Fail(orderByQuery.Errors); + } + + queryable = orderByQuery.Value; } // Skip @@ -106,6 +121,6 @@ public static (IQueryable, int?) Apply(this IQueryable queryable, Query queryable = queryable.Take(options.MaxTop); } - return (queryable, count); + return Result.Ok(new QueryResult(queryable, count)); } } \ No newline at end of file diff --git a/src/GoatQuery/src/GoatQuery.csproj b/src/GoatQuery/src/GoatQuery.csproj index 2fe9063..86626d8 100644 --- a/src/GoatQuery/src/GoatQuery.csproj +++ b/src/GoatQuery/src/GoatQuery.csproj @@ -19,6 +19,7 @@ + diff --git a/src/GoatQuery/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs index 9a2d076..a6e596c 100644 --- a/src/GoatQuery/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using FluentResults; public sealed class QueryParser { @@ -60,19 +61,29 @@ private OrderByStatement ParseOrderByStatement() return statement; } - public ExpressionStatement ParseFilter() + public Result ParseFilter() { + var expression = ParseExpression(); + if (expression.IsFailed) + { + return Result.Fail(expression.Errors); + } + var statement = new ExpressionStatement(_currentToken) { - Expression = ParseExpression() + Expression = expression.Value }; return statement; } - private InfixExpression ParseExpression(int precedence = 0) + private Result ParseExpression(int precedence = 0) { var left = CurrentTokenIs(TokenType.LPAREN) ? ParseGroupedExpression() : ParseFilterStatement(); + if (left.IsFailed) + { + return left; + } NextToken(); @@ -80,13 +91,17 @@ private InfixExpression ParseExpression(int precedence = 0) { if (CurrentIdentifierIs(Keywords.And) || CurrentIdentifierIs(Keywords.Or)) { - left = new InfixExpression(_currentToken, left, _currentToken.Literal); + left = new InfixExpression(_currentToken, left.Value, _currentToken.Literal); var currentPrecedence = GetPrecedence(_currentToken.Type); NextToken(); var right = ParseExpression(currentPrecedence); - left.Right = right; + if (right.IsFailed) + { + return right; + } + left.Value.Right = right.Value; } else { @@ -97,7 +112,7 @@ private InfixExpression ParseExpression(int precedence = 0) return left; } - private InfixExpression ParseGroupedExpression() + private Result ParseGroupedExpression() { NextToken(); @@ -105,19 +120,19 @@ private InfixExpression ParseGroupedExpression() if (!CurrentTokenIs(TokenType.RPAREN)) { - throw new GoatQueryException("Expected closing parenthesis"); + return Result.Fail("Expected closing parenthesis"); } return exp; } - private InfixExpression ParseFilterStatement() + private Result ParseFilterStatement() { var identifier = new Identifier(_currentToken, _currentToken.Literal); if (!PeekIdentifierIn(Keywords.Eq, Keywords.Ne, Keywords.Contains)) { - throw new GoatQueryException("Invalid conjunction within filter"); + return Result.Fail("Invalid conjunction within filter"); } NextToken(); @@ -126,14 +141,14 @@ private InfixExpression ParseFilterStatement() if (!PeekTokenIn(TokenType.STRING, TokenType.INT)) { - throw new GoatQueryException("Invalid value type within filter"); + return Result.Fail("Invalid value type within filter"); } NextToken(); if (statement.Operator.Equals(Keywords.Contains) && _currentToken.Type != TokenType.STRING) { - throw new GoatQueryException("Value must be a string when using contains operand"); + return Result.Fail("Value must be a string when using contains operand"); } switch (_currentToken.Type) 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 From 4c1842ea42fe45682f714b51765537d43720cb01 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Tue, 23 Jul 2024 22:35:48 +0100 Subject: [PATCH 25/60] fixed tests --- src/GoatQuery/tests/Count/CountTest.cs | 11 +++++------ src/GoatQuery/tests/Filter/FilterParserTest.cs | 12 +++++++----- src/GoatQuery/tests/Filter/FilterTest.cs | 14 +++++++------- src/GoatQuery/tests/Orderby/OrderByTest.cs | 10 ++++------ src/GoatQuery/tests/Search/SearchTest.cs | 5 ++--- src/GoatQuery/tests/Skip/SkipTest.cs | 5 ++--- src/GoatQuery/tests/Top/TopTest.cs | 14 ++++++-------- 7 files changed, 33 insertions(+), 38 deletions(-) diff --git a/src/GoatQuery/tests/Count/CountTest.cs b/src/GoatQuery/tests/Count/CountTest.cs index 08c604e..fc4806e 100644 --- a/src/GoatQuery/tests/Count/CountTest.cs +++ b/src/GoatQuery/tests/Count/CountTest.cs @@ -19,11 +19,10 @@ public void Test_CountWithTrue() Count = true }; - var (queryable, count) = users.Apply(query); - var results = queryable.ToArray(); + var result = users.Apply(query); - Assert.Equal(6, count); - Assert.Equal(6, results.Count()); + Assert.Equal(6, result.Value.Count); + Assert.Equal(6, result.Value.Query.Count()); } [Fact] @@ -43,8 +42,8 @@ public void Test_CountWithFalse() Count = false }; - var (_, count) = users.Apply(query); + var result = users.Apply(query); - Assert.Null(count); + Assert.Null(result.Value.Count); } } \ No newline at end of file diff --git a/src/GoatQuery/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs index 9bfd834..1a9f342 100644 --- a/src/GoatQuery/tests/Filter/FilterParserTest.cs +++ b/src/GoatQuery/tests/Filter/FilterParserTest.cs @@ -15,7 +15,7 @@ public void Test_ParsingFilterStatement(string input, string expectedLeft, strin var program = parser.ParseFilter(); - var expression = program.Expression; + var expression = program.Value.Expression; Assert.NotNull(expression); Assert.Equal(expectedLeft, expression.Left.TokenLiteral()); @@ -36,7 +36,9 @@ public void Test_ParsingInvalidFilterThrowsException(string input) var lexer = new QueryLexer(input); var parser = new QueryParser(lexer); - Assert.Throws(parser.ParseFilter); + var result = parser.ParseFilter(); + + Assert.True(result.IsFailed); } [Fact] @@ -49,7 +51,7 @@ public void Test_ParsingFilterStatementWithAnd() var program = parser.ParseFilter(); - var expression = program.Expression; + var expression = program.Value.Expression; Assert.NotNull(expression); var left = expression.Left as InfixExpression; @@ -79,7 +81,7 @@ public void Test_ParsingFilterStatementWithOr() var program = parser.ParseFilter(); - var expression = program.Expression; + var expression = program.Value.Expression; Assert.NotNull(expression); var left = expression.Left as InfixExpression; @@ -109,7 +111,7 @@ public void Test_ParsingFilterStatementWithAndAndOr() var program = parser.ParseFilter(); - var expression = program.Expression; + var expression = program.Value.Expression; Assert.NotNull(expression); var left = expression.Left as InfixExpression; diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 63d877d..5f7da8d 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -186,10 +186,9 @@ public void Test_Filter(string filter, IEnumerable expected) Filter = filter }; - var (queryable, _) = users.Apply(query); - var results = queryable.ToArray(); + var result = users.Apply(query); - Assert.Equal(expected, results); + Assert.Equal(expected, result.Value.Query); } [Theory] @@ -210,7 +209,9 @@ public void Test_InvalidFilterThrowsException(string filter) Filter = filter }; - Assert.Throws(() => users.Apply(query)); + var result = users.Apply(query); + + Assert.True(result.IsFailed); } [Fact] @@ -230,11 +231,10 @@ public void Test_Filter_WithCustomJsonPropertyName() Filter = "last_name eq 'John'" }; - var (queryable, _) = users.Apply(query); - var results = queryable.ToArray(); + var result = users.Apply(query); Assert.Equal(new List{ new CustomJsonPropertyUser { Lastname = "John" }, - }, results); + }, result.Value.Query); } } \ No newline at end of file diff --git a/src/GoatQuery/tests/Orderby/OrderByTest.cs b/src/GoatQuery/tests/Orderby/OrderByTest.cs index 1438a46..2f166a9 100644 --- a/src/GoatQuery/tests/Orderby/OrderByTest.cs +++ b/src/GoatQuery/tests/Orderby/OrderByTest.cs @@ -191,10 +191,9 @@ public void Test_OrderBy(string orderby, IEnumerable expected) OrderBy = orderby }; - var (queryable, _) = users.Apply(query); - var results = queryable.ToArray(); + var result = users.Apply(query); - Assert.Equal(expected, results); + Assert.Equal(expected, result.Value.Query); } [Fact] @@ -214,8 +213,7 @@ public void Test_OrderBy_WithCustomJsonPropertyName() OrderBy = "last_name asc" }; - var (queryable, _) = users.Apply(query); - var results = queryable.ToArray(); + var result = users.Apply(query); Assert.Equal(new List{ new CustomJsonPropertyUser { Lastname = "Apple" }, @@ -224,6 +222,6 @@ public void Test_OrderBy_WithCustomJsonPropertyName() new CustomJsonPropertyUser { Lastname = "Harry" }, new CustomJsonPropertyUser { Lastname = "Jane" }, new CustomJsonPropertyUser { Lastname = "John" }, - }, results); + }, 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 index aad3ef3..78ba4b9 100644 --- a/src/GoatQuery/tests/Search/SearchTest.cs +++ b/src/GoatQuery/tests/Search/SearchTest.cs @@ -38,9 +38,8 @@ public void Test_Search(string searchTerm, int expectedCount) Search = searchTerm }; - var (queryable, _) = users.Apply(query, new UserSearchTestBinder()); - var results = queryable.ToArray(); + var result = users.Apply(query, new UserSearchTestBinder()); - Assert.Equal(expectedCount, results.Count()); + 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 index 2f189e4..923ed33 100644 --- a/src/GoatQuery/tests/Skip/SkipTest.cs +++ b/src/GoatQuery/tests/Skip/SkipTest.cs @@ -96,9 +96,8 @@ public void Test_Skip(int skip, IEnumerable expected) Skip = skip }; - var (queryable, _) = users.Apply(query); - var results = queryable.ToArray(); + var result = users.Apply(query); - Assert.Equal(expected, results); + Assert.Equal(expected, result.Value.Query); } } \ No newline at end of file diff --git a/src/GoatQuery/tests/Top/TopTest.cs b/src/GoatQuery/tests/Top/TopTest.cs index 9f99562..30a7368 100644 --- a/src/GoatQuery/tests/Top/TopTest.cs +++ b/src/GoatQuery/tests/Top/TopTest.cs @@ -28,10 +28,9 @@ public void Test_Top(int top, int expectedCount) Top = top }; - var (queryable, _) = users.Apply(query); - var results = queryable.ToArray(); + var result = users.Apply(query); - Assert.Equal(expectedCount, results.Count()); + Assert.Equal(expectedCount, result.Value.Query.Count()); } [Theory] @@ -62,10 +61,9 @@ public void Test_TopWithMaxTop(int top, int expectedCount) MaxTop = 4 }; - var (queryable, _) = users.Apply(query, null, queryOptions); - var results = queryable.ToArray(); + var result = users.Apply(query, null, queryOptions); - Assert.Equal(expectedCount, results.Count()); + Assert.Equal(expectedCount, result.Value.Query.Count()); } [Theory] @@ -93,8 +91,8 @@ public void Test_TopWithMaxTopThrowsException(int top) MaxTop = 4 }; - Action action = () => users.Apply(query, null, queryOptions); + var result = users.Apply(query, null, queryOptions); - Assert.Throws(action); + Assert.True(result.IsFailed); } } \ No newline at end of file From 0bc8c681ba67ef784cca104ac831aab4c7028c00 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Tue, 23 Jul 2024 23:45:35 +0100 Subject: [PATCH 26/60] added filter support for Guid --- src/GoatQuery/src/Ast/StringLiteral.cs | 12 ++ .../src/Evaluator/FilterEvaluator.cs | 3 + src/GoatQuery/src/Lexer/Lexer.cs | 29 ++- src/GoatQuery/src/Parser/Parser.cs | 17 +- src/GoatQuery/src/Token/Token.cs | 1 + src/GoatQuery/tests/Filter/FilterLexerTest.cs | 11 ++ .../tests/Filter/FilterParserTest.cs | 1 + src/GoatQuery/tests/Filter/FilterTest.cs | 183 +++++------------- src/GoatQuery/tests/Orderby/OrderByTest.cs | 141 +++----------- src/GoatQuery/tests/User.cs | 1 + 10 files changed, 133 insertions(+), 266 deletions(-) diff --git a/src/GoatQuery/src/Ast/StringLiteral.cs b/src/GoatQuery/src/Ast/StringLiteral.cs index ac6c70f..112e320 100644 --- a/src/GoatQuery/src/Ast/StringLiteral.cs +++ b/src/GoatQuery/src/Ast/StringLiteral.cs @@ -1,3 +1,5 @@ +using System; + public sealed class StringLiteral : QueryExpression { public string Value { get; set; } @@ -8,6 +10,16 @@ public StringLiteral(Token token, string value) : base(token) } } +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; } diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index 8d77b46..bc6140d 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -22,6 +22,9 @@ public static Result Evaluate(QueryExpression expression, ParameterE switch (exp.Right) { + case GuidLiteral literal: + value = Expression.Constant(literal.Value, property.Type); + break; case IntegerLiteral literal: value = Expression.Constant(literal.Value, property.Type); break; diff --git a/src/GoatQuery/src/Lexer/Lexer.cs b/src/GoatQuery/src/Lexer/Lexer.cs index df011aa..8ff184c 100644 --- a/src/GoatQuery/src/Lexer/Lexer.cs +++ b/src/GoatQuery/src/Lexer/Lexer.cs @@ -1,3 +1,5 @@ +using System; + public sealed class QueryLexer { private readonly string _input; @@ -50,18 +52,24 @@ public Token NextToken() token.Literal = ReadString(); break; default: - if (IsLetter(_character)) + if (IsLetter(_character) || IsDigit(_character)) { token.Literal = ReadIdentifier(); + if (IsGuid(token.Literal)) + { + token.Type = TokenType.GUID; + return token; + } + + if (IsDigit(token.Literal[0])) + { + token.Type = TokenType.INT; + return token; + } + token.Type = TokenType.IDENT; return token; } - else if (IsDigit(_character)) - { - token.Literal = ReadNumber(); - token.Type = TokenType.INT; - return token; - } break; } @@ -70,6 +78,11 @@ public Token NextToken() return token; } + private bool IsGuid(string value) + { + return Guid.TryParse(value, out _); + } + private string ReadIdentifier() { var currentPosition = _position; @@ -84,7 +97,7 @@ private string ReadIdentifier() private bool IsLetter(char ch) { - return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_'; + return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_' || ch == '-'; } private bool IsDigit(char ch) diff --git a/src/GoatQuery/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs index a6e596c..815790c 100644 --- a/src/GoatQuery/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -139,7 +139,7 @@ private Result ParseFilterStatement() var statement = new InfixExpression(_currentToken, identifier, _currentToken.Literal); - if (!PeekTokenIn(TokenType.STRING, TokenType.INT)) + if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID)) { return Result.Fail("Invalid value type within filter"); } @@ -153,13 +153,19 @@ private Result ParseFilterStatement() switch (_currentToken.Type) { + case TokenType.GUID: + if (Guid.TryParse(_currentToken.Literal, out var guidValue)) + { + statement.Right = new GuidLiteral(_currentToken, guidValue); + } + break; case TokenType.STRING: statement.Right = new StringLiteral(_currentToken, _currentToken.Literal); break; case TokenType.INT: - if (int.TryParse(_currentToken.Literal, out var value)) + if (int.TryParse(_currentToken.Literal, out var intValue)) { - statement.Right = new IntegerLiteral(_currentToken, value); + statement.Right = new IntegerLiteral(_currentToken, intValue); } break; } @@ -185,11 +191,6 @@ private bool CurrentTokenIs(TokenType token) return _currentToken.Type == token; } - private bool PeekTokenIs(TokenType token) - { - return _peekToken.Type == token; - } - private bool PeekTokenIn(params TokenType[] token) { return token.Contains(_peekToken.Type); diff --git a/src/GoatQuery/src/Token/Token.cs b/src/GoatQuery/src/Token/Token.cs index ce57925..accbd29 100644 --- a/src/GoatQuery/src/Token/Token.cs +++ b/src/GoatQuery/src/Token/Token.cs @@ -5,6 +5,7 @@ public enum TokenType IDENT, STRING, INT, + GUID, LPAREN, RPAREN, } diff --git a/src/GoatQuery/tests/Filter/FilterLexerTest.cs b/src/GoatQuery/tests/Filter/FilterLexerTest.cs index caeca60..bd38f03 100644 --- a/src/GoatQuery/tests/Filter/FilterLexerTest.cs +++ b/src/GoatQuery/tests/Filter/FilterLexerTest.cs @@ -169,6 +169,17 @@ public static IEnumerable Parameters() 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"), + } + }; } [Theory] diff --git a/src/GoatQuery/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs index 1a9f342..4ecefa3 100644 --- a/src/GoatQuery/tests/Filter/FilterParserTest.cs +++ b/src/GoatQuery/tests/Filter/FilterParserTest.cs @@ -8,6 +8,7 @@ public sealed class FilterParserTest [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")] public void Test_ParsingFilterStatement(string input, string expectedLeft, string expectedOperator, string expectedRight) { var lexer = new QueryLexer(input); diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 5f7da8d..a143be6 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -2,169 +2,106 @@ public sealed class FilterTest { + private static readonly Dictionary _users = new Dictionary + { + ["John"] = new User { Age = 2, Firstname = "John", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255") }, + ["Jane"] = new User { Age = 1, Firstname = "Jane", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255") }, + ["Apple"] = new User { Age = 2, Firstname = "Apple", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255") }, + ["Harry"] = new User { Age = 1, Firstname = "Harry", UserId = Guid.Parse("e4c7772b-8947-4e46-98ed-644b417d2a08") }, + ["Doe"] = new User { Age = 3, Firstname = "Doe", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255") }, + ["Egg"] = new User { Age = 3, Firstname = "Egg", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255") }, + }; + public static IEnumerable Parameters() { - yield return new object[] - { + yield return new object[] { "firstname eq 'John'", - new User[] - { - new User { Age = 2, Firstname = "John" }, - } + new[] { _users["John"] } }; - yield return new object[] - { + yield return new object[] { "firstname eq 'Random'", - new User[] {} + Array.Empty() }; - yield return new object[] - { + yield return new object[] { "Age eq 1", - new User[] { - new User { Age = 1, Firstname = "Jane" }, - new User { Age = 1, Firstname = "Harry" }, - } + new[] { _users["Jane"], _users["Harry"] } }; - yield return new object[] - { + yield return new object[] { "Age eq 0", - new User[] {} + Array.Empty() }; - yield return new object[] - { + yield return new object[] { "firstname eq 'John' and Age eq 2", - new User[] - { - new User { Age = 2, Firstname = "John" }, - } + new[] { _users["John"] } }; - yield return new object[] - { + yield return new object[] { "firstname eq 'John' or Age eq 3", - new User[] - { - new User { Age = 2, Firstname = "John" }, - new User { Age = 3, Firstname = "Doe" }, - new User { Age = 3, Firstname = "Egg" } - } + new[] { _users["John"], _users["Doe"], _users["Egg"] } }; - yield return new object[] - { + yield return new object[] { "Age eq 1 and firstName eq 'Harry' or Age eq 2", - new User[] - { - new User { Age = 2, Firstname = "John" }, - new User { Age = 2, Firstname = "Apple" }, - new User { Age = 1, Firstname = "Harry" }, - } + new[] { _users["John"], _users["Apple"], _users["Harry"] } }; - yield return new object[] - { + yield return new object[] { "Age eq 1 or Age eq 2 or firstName eq 'Egg'", - new User[] - { - 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 = "Egg" } - } + new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"], _users["Egg"] } }; - yield return new object[] - { + yield return new object[] { "Age ne 3", - new User[] - { - 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[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"] } }; - yield return new object[] - { + yield return new object[] { "firstName contains 'a'", - new User[] - { - new User { Age = 1, Firstname = "Jane" }, - new User { Age = 1, Firstname = "Harry" }, - } + new[] { _users["Jane"], _users["Harry"] } }; - yield return new object[] - { + yield return new object[] { "Age ne 1 and firstName contains 'a'", - new User[] {} + Array.Empty() }; - yield return new object[] - { + yield return new object[] { "Age ne 1 and firstName contains 'a' or firstName eq 'Apple'", - new User[] - { - new User { Age = 2, Firstname = "Apple" }, - } + new[] { _users["Apple"] } }; - yield return new object[] - { + yield return new object[] { "Firstname eq 'John' and Age eq 2 or Age eq 3", - new User[] - { - new User { Age = 2, Firstname = "John" }, - new User { Age = 3, Firstname = "Doe" }, - new User { Age = 3, Firstname = "Egg" } - } + new[] { _users["John"], _users["Doe"], _users["Egg"] } }; - yield return new object[] - { + yield return new object[] { "(Firstname eq 'John' and Age eq 2) or Age eq 3", - new User[] - { - new User { Age = 2, Firstname = "John" }, - new User { Age = 3, Firstname = "Doe" }, - new User { Age = 3, Firstname = "Egg" } - } + new[] { _users["John"], _users["Doe"], _users["Egg"] } }; - yield return new object[] - { + yield return new object[] { "Firstname eq 'John' and (Age eq 2 or Age eq 3)", - new User[] - { - new User { Age = 2, Firstname = "John" }, - } + new[] { _users["John"] } }; - yield return new object[] - { + yield return new object[] { "(Firstname eq 'John' and Age eq 2 or Age eq 3)", - new User[] - { - new User { Age = 2, Firstname = "John" }, - new User { Age = 3, Firstname = "Doe" }, - new User { Age = 3, Firstname = "Egg" } - } + new[] { _users["John"], _users["Doe"], _users["Egg"] } }; - yield return new object[] - { + yield return new object[] { "(Firstname eq 'John') or (Age eq 3 and Firstname eq 'Egg') or Age eq 1 and (Age eq 2)", - new User[] - { - new User { Age = 2, Firstname = "John" }, - new User { Age = 3, Firstname = "Egg" } - } + new[] { _users["John"], _users["Egg"] } + }; + + yield return new object[] { + "UserId eq e4c7772b-8947-4e46-98ed-644b417d2a08", + new[] { _users["Harry"] } }; } @@ -172,21 +109,12 @@ public static IEnumerable Parameters() [MemberData(nameof(Parameters))] public void Test_Filter(string filter, IEnumerable expected) { - 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 { Filter = filter }; - var result = users.Apply(query); + var result = _users.Values.AsQueryable().Apply(query); Assert.Equal(expected, result.Value.Query); } @@ -195,21 +123,12 @@ public void Test_Filter(string filter, IEnumerable expected) [InlineData("NonExistentProperty eq 'John'")] public void Test_InvalidFilterThrowsException(string filter) { - 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 { Filter = filter }; - var result = users.Apply(query); + var result = _users.Values.AsQueryable().Apply(query); Assert.True(result.IsFailed); } diff --git a/src/GoatQuery/tests/Orderby/OrderByTest.cs b/src/GoatQuery/tests/Orderby/OrderByTest.cs index 2f166a9..5a1b6ad 100644 --- a/src/GoatQuery/tests/Orderby/OrderByTest.cs +++ b/src/GoatQuery/tests/Orderby/OrderByTest.cs @@ -2,174 +2,88 @@ 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 User[] - { - new User { Age = 3, Firstname = "Doe" }, - new User { Age = 3, Firstname = "Egg" }, - new User { Age = 2, Firstname = "Apple" }, - new User { Age = 2, Firstname = "John" }, - new User { Age = 1, Firstname = "Harry" }, - new User { Age = 1, Firstname = "Jane" }, - } + new[] { _users["Doe"], _users["Egg"], _users["Apple"], _users["John"], _users["Harry"], _users["Jane"] } }; yield return new object[] { "age desc, firstname desc", - new User[] - { - new User { Age = 3, Firstname = "Egg" }, - new User { Age = 3, Firstname = "Doe" }, - new User { Age = 2, Firstname = "John" }, - new User { Age = 2, Firstname = "Apple" }, - new User { Age = 1, Firstname = "Jane" }, - new User { Age = 1, Firstname = "Harry" }, - } + new[] { _users["Egg"], _users["Doe"], _users["John"], _users["Apple"], _users["Jane"], _users["Harry"] } }; yield return new object[] { "age desc", - new User[] - { - new User { Age = 3, Firstname = "Doe" }, - new User { Age = 3, Firstname = "Egg" }, - new User { Age = 2, Firstname = "John" }, - new User { Age = 2, Firstname = "Apple" }, - new User { Age = 1, Firstname = "Jane" }, - new User { Age = 1, Firstname = "Harry" }, - } + new[] { _users["Doe"], _users["Egg"], _users["John"], _users["Apple"], _users["Jane"], _users["Harry"] } }; yield return new object[] { "Age asc", - new User[] - { - 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" }, - } + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } }; yield return new object[] { "age", - new User[] - { - 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" }, - } + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } }; yield return new object[] { "age asc", - new User[] - { - 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" }, - } + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } }; yield return new object[] { "Age asc", - new User[] - { - 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" }, - } + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } }; yield return new object[] { "aGe asc", - new User[] - { - 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" }, - } + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } }; yield return new object[] { "AGe asc", - new User[] - { - 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" }, - } + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } }; yield return new object[] { "aGE Asc", - new User[] - { - 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" }, - } + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } }; yield return new object[] { "age aSc", - new User[] - { - 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" }, - } + new[] { _users["Jane"], _users["Harry"], _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } }; yield return new object[] { "", - new User[] - { - 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" } - } + new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"], _users["Doe"], _users["Egg"] } }; } @@ -177,21 +91,12 @@ public static IEnumerable Parameters() [MemberData(nameof(Parameters))] public void Test_OrderBy(string orderby, IEnumerable expected) { - 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 { OrderBy = orderby }; - var result = users.Apply(query); + var result = _users.Values.AsQueryable().Apply(query); Assert.Equal(expected, result.Value.Query); } diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs index c7a53bf..dcf3b0d 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -3,6 +3,7 @@ public record User { public int Age { get; set; } + public Guid UserId { get; set; } public string Firstname { get; set; } = string.Empty; } From a5f8525059505ebf0be26db585754e3983fd0e62 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Thu, 25 Jul 2024 22:51:13 +0100 Subject: [PATCH 27/60] ability to parse to other primitive numeric types --- .../src/Evaluator/FilterEvaluator.cs | 48 +++++++++++++++++-- src/GoatQuery/tests/Filter/FilterTest.cs | 39 +++++++++++++++ src/GoatQuery/tests/User.cs | 2 + 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index bc6140d..ab0299b 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq.Expressions; using FluentResults; @@ -18,7 +19,7 @@ public static Result Evaluate(QueryExpression expression, ParameterE var property = Expression.Property(parameterExpression, propertyName); - ConstantExpression value = null; + ConstantExpression value; switch (exp.Right) { @@ -26,13 +27,19 @@ public static Result Evaluate(QueryExpression expression, ParameterE value = Expression.Constant(literal.Value, property.Type); break; case IntegerLiteral literal: - value = Expression.Constant(literal.Value, property.Type); + var integerConstant = GetIntegerExpressionConstant(literal.Value, property.Type); + if (integerConstant.IsFailed) + { + return Result.Fail(integerConstant.Errors); + } + + value = integerConstant.Value; break; case StringLiteral literal: value = Expression.Constant(literal.Value, property.Type); break; default: - break; + return Result.Fail($"Unsupported literal type: {exp.Right.GetType().Name}"); } switch (exp.Operator) @@ -47,6 +54,8 @@ public static Result Evaluate(QueryExpression expression, ParameterE var method = identifier.Value.GetType().GetMethod("Contains", new[] { value?.Value.GetType() }); return Expression.Call(property, method, value); + default: + return Result.Fail($"Unsupported operator: {exp.Operator}"); } } @@ -75,4 +84,37 @@ public static Result Evaluate(QueryExpression expression, ParameterE return null; } + + private static Result GetIntegerExpressionConstant(int value, Type targetType) + { + try + { + // Fetch the underlying type if it's nullable. + var underlyingType = Nullable.GetUnderlyingType(targetType); + var type = underlyingType ?? 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 ex) + { + return Result.Fail($"Error converting {value} to {targetType.Name}: {ex.Message}"); + } + } } \ No newline at end of file diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index a143be6..175e5d9 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -156,4 +156,43 @@ public void Test_Filter_WithCustomJsonPropertyName() 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/User.cs b/src/GoatQuery/tests/User.cs index dcf3b0d..b533740 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -5,6 +5,8 @@ public record User public int Age { get; set; } public Guid UserId { get; set; } public string Firstname { get; set; } = string.Empty; + + public string Long { get; set; } = string.Empty; } public sealed record CustomJsonPropertyUser : User From 84f7c7ef3b12c5b94e1c6b427f6bce61f2bf8790 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Fri, 26 Jul 2024 11:08:08 +0100 Subject: [PATCH 28/60] added lang version --- src/GoatQuery/src/GoatQuery.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/GoatQuery/src/GoatQuery.csproj b/src/GoatQuery/src/GoatQuery.csproj index 86626d8..288eb30 100644 --- a/src/GoatQuery/src/GoatQuery.csproj +++ b/src/GoatQuery/src/GoatQuery.csproj @@ -3,6 +3,7 @@ netstandard2.0;netstandard2.1 GoatQuery + 8.0 GoatQuery README.md From 7540937223bd76cf4d8907da4c97c3cb4fe3e8da Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Fri, 26 Jul 2024 12:01:21 +0100 Subject: [PATCH 29/60] wip --- src/GoatQuery/src/GoatQuery.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GoatQuery/src/GoatQuery.csproj b/src/GoatQuery/src/GoatQuery.csproj index 288eb30..56d50eb 100644 --- a/src/GoatQuery/src/GoatQuery.csproj +++ b/src/GoatQuery/src/GoatQuery.csproj @@ -7,7 +7,7 @@ GoatQuery README.md - ttps://github.com/goatquery/src/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. From 45a4103b8911c17a66e75eb9bf5db4711f233d09 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Fri, 26 Jul 2024 19:18:39 +0100 Subject: [PATCH 30/60] added less than operand --- src/GoatQuery/src/Evaluator/FilterEvaluator.cs | 2 ++ src/GoatQuery/src/Parser/Parser.cs | 9 +++++++-- src/GoatQuery/src/Token/Token.cs | 1 + src/GoatQuery/tests/Filter/FilterLexerTest.cs | 11 +++++++++++ src/GoatQuery/tests/Filter/FilterParserTest.cs | 1 + src/GoatQuery/tests/Filter/FilterTest.cs | 10 ++++++++++ 6 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index ab0299b..1ad0113 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -54,6 +54,8 @@ public static Result Evaluate(QueryExpression expression, ParameterE var method = identifier.Value.GetType().GetMethod("Contains", new[] { value?.Value.GetType() }); return Expression.Call(property, method, value); + case Keywords.Lt: + return Expression.LessThan(property, value); default: return Result.Fail($"Unsupported operator: {exp.Operator}"); } diff --git a/src/GoatQuery/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs index 815790c..5361cfe 100644 --- a/src/GoatQuery/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -130,7 +130,7 @@ private Result ParseFilterStatement() { var identifier = new Identifier(_currentToken, _currentToken.Literal); - if (!PeekIdentifierIn(Keywords.Eq, Keywords.Ne, Keywords.Contains)) + if (!PeekIdentifierIn(Keywords.Eq, Keywords.Ne, Keywords.Contains, Keywords.Lt)) { return Result.Fail("Invalid conjunction within filter"); } @@ -148,7 +148,12 @@ private Result ParseFilterStatement() if (statement.Operator.Equals(Keywords.Contains) && _currentToken.Type != TokenType.STRING) { - return Result.Fail("Value must be a string when using contains operand"); + return Result.Fail("Value must be a string when using 'contains' operand"); + } + + if (statement.Operator.Equals(Keywords.Lt) && _currentToken.Type != TokenType.INT) + { + return Result.Fail("Value must be an integer when using 'lt' operand"); } switch (_currentToken.Type) diff --git a/src/GoatQuery/src/Token/Token.cs b/src/GoatQuery/src/Token/Token.cs index accbd29..304a9bc 100644 --- a/src/GoatQuery/src/Token/Token.cs +++ b/src/GoatQuery/src/Token/Token.cs @@ -17,6 +17,7 @@ public static class Keywords internal const string Eq = "eq"; internal const string Ne = "ne"; internal const string Contains = "contains"; + internal const string Lt = "lt"; internal const string And = "and"; internal const string Or = "or"; } diff --git a/src/GoatQuery/tests/Filter/FilterLexerTest.cs b/src/GoatQuery/tests/Filter/FilterLexerTest.cs index bd38f03..5ddc675 100644 --- a/src/GoatQuery/tests/Filter/FilterLexerTest.cs +++ b/src/GoatQuery/tests/Filter/FilterLexerTest.cs @@ -180,6 +180,17 @@ public static IEnumerable Parameters() new (TokenType.GUID, "e4c7772b-8947-4e46-98ed-644b417d2a08"), } }; + + yield return new object[] + { + "age lt 50", + new KeyValuePair[] + { + new (TokenType.IDENT, "age"), + new (TokenType.IDENT, "lt"), + new (TokenType.INT, "50"), + } + }; } [Theory] diff --git a/src/GoatQuery/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs index 4ecefa3..596ab75 100644 --- a/src/GoatQuery/tests/Filter/FilterParserTest.cs +++ b/src/GoatQuery/tests/Filter/FilterParserTest.cs @@ -9,6 +9,7 @@ public sealed class FilterParserTest [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("Age lt 99", "Age", "lt", "99")] public void Test_ParsingFilterStatement(string input, string expectedLeft, string expectedOperator, string expectedRight) { var lexer = new QueryLexer(input); diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 175e5d9..6b91f10 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -103,6 +103,16 @@ public static IEnumerable Parameters() "UserId eq e4c7772b-8947-4e46-98ed-644b417d2a08", new[] { _users["Harry"] } }; + + yield return new object[] { + "age lt 3", + new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"] } + }; + + yield return new object[] { + "age lt 1", + Array.Empty() + }; } [Theory] From 91dc46ae9b15ae6985eb5cba26eec8631ea1303a Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Fri, 26 Jul 2024 19:30:19 +0100 Subject: [PATCH 31/60] fix: fixed conditional for parsing count query parameter --- src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs b/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs index 91a68a2..e7564af 100644 --- a/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs +++ b/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs @@ -47,7 +47,7 @@ public override void OnActionExecuted(ActionExecutedContext context) queryString.TryGetValue("count", out var countQuery); var countString = countQuery.ToString(); - if (bool.TryParse(countString, out bool count) && !string.IsNullOrEmpty(countString)) + 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; From e9cf2b50f43b73d1db00069e1ff678b8bb551e6b Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Fri, 26 Jul 2024 19:55:22 +0100 Subject: [PATCH 32/60] added lte, gt & gte --- .../src/Evaluator/FilterEvaluator.cs | 6 ++++ .../src/Extensions/StringExtension.cs | 10 ++++++ src/GoatQuery/src/Parser/Parser.cs | 6 ++-- src/GoatQuery/src/Token/Token.cs | 3 ++ src/GoatQuery/tests/Filter/FilterLexerTest.cs | 33 +++++++++++++++++++ .../tests/Filter/FilterParserTest.cs | 3 ++ src/GoatQuery/tests/Filter/FilterTest.cs | 20 +++++++++++ 7 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 src/GoatQuery/src/Extensions/StringExtension.cs diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index 1ad0113..1952d1c 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -56,6 +56,12 @@ public static Result Evaluate(QueryExpression expression, ParameterE return Expression.Call(property, method, value); case Keywords.Lt: return Expression.LessThan(property, value); + case Keywords.Lte: + return Expression.LessThanOrEqual(property, value); + case Keywords.Gt: + return Expression.GreaterThan(property, value); + case Keywords.Gte: + return Expression.GreaterThanOrEqual(property, value); default: return Result.Fail($"Unsupported operator: {exp.Operator}"); } 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/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs index 5361cfe..b790bfb 100644 --- a/src/GoatQuery/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -130,7 +130,7 @@ private Result ParseFilterStatement() { var identifier = new Identifier(_currentToken, _currentToken.Literal); - if (!PeekIdentifierIn(Keywords.Eq, Keywords.Ne, Keywords.Contains, Keywords.Lt)) + if (!PeekIdentifierIn(Keywords.Eq, Keywords.Ne, Keywords.Contains, Keywords.Lt, Keywords.Lte, Keywords.Gt, Keywords.Gte)) { return Result.Fail("Invalid conjunction within filter"); } @@ -151,9 +151,9 @@ private Result ParseFilterStatement() return Result.Fail("Value must be a string when using 'contains' operand"); } - if (statement.Operator.Equals(Keywords.Lt) && _currentToken.Type != TokenType.INT) + if (statement.Operator.In(Keywords.Lt, Keywords.Lte, Keywords.Gt, Keywords.Gte) && _currentToken.Type != TokenType.INT) { - return Result.Fail("Value must be an integer when using 'lt' operand"); + return Result.Fail($"Value must be an integer when using '{statement.Operator}' operand"); } switch (_currentToken.Type) diff --git a/src/GoatQuery/src/Token/Token.cs b/src/GoatQuery/src/Token/Token.cs index 304a9bc..f071219 100644 --- a/src/GoatQuery/src/Token/Token.cs +++ b/src/GoatQuery/src/Token/Token.cs @@ -18,6 +18,9 @@ public static class Keywords 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"; } diff --git a/src/GoatQuery/tests/Filter/FilterLexerTest.cs b/src/GoatQuery/tests/Filter/FilterLexerTest.cs index 5ddc675..2672436 100644 --- a/src/GoatQuery/tests/Filter/FilterLexerTest.cs +++ b/src/GoatQuery/tests/Filter/FilterLexerTest.cs @@ -191,6 +191,39 @@ public static IEnumerable Parameters() 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"), + } + }; } [Theory] diff --git a/src/GoatQuery/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs index 596ab75..9b061ea 100644 --- a/src/GoatQuery/tests/Filter/FilterParserTest.cs +++ b/src/GoatQuery/tests/Filter/FilterParserTest.cs @@ -10,6 +10,9 @@ public sealed class FilterParserTest [InlineData("Name contains 'John'", "Name", "contains", "John")] [InlineData("Id eq e4c7772b-8947-4e46-98ed-644b417d2a08", "Id", "eq", "e4c7772b-8947-4e46-98ed-644b417d2a08")] [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")] public void Test_ParsingFilterStatement(string input, string expectedLeft, string expectedOperator, string expectedRight) { var lexer = new QueryLexer(input); diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 6b91f10..79b21ae 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -113,6 +113,26 @@ public static IEnumerable Parameters() "age lt 1", Array.Empty() }; + + yield return new object[] { + "age lte 2", + new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"] } + }; + + yield return new object[] { + "age gt 1", + new[] { _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } + }; + + yield return new object[] { + "age gte 3", + new[] { _users["Doe"], _users["Egg"] } + }; + + yield return new object[] { + "age lt 3 and age gt 1", + new[] { _users["John"], _users["Apple"] } + }; } [Theory] From fc0160f938512df711072c2fd783dca7a9e70db9 Mon Sep 17 00:00:00 2001 From: James <23193271+Jamess-Lucass@users.noreply.github.com> Date: Wed, 31 Jul 2024 21:59:12 +0100 Subject: [PATCH 33/60] [v2] feat: Added datetime (#70) --- example/Dto/UserDto.cs | 2 + example/Entities/User.cs | 8 ++ example/Program.cs | 12 ++- src/GoatQuery/src/Ast/StringLiteral.cs | 10 +++ .../src/Evaluator/FilterEvaluator.cs | 4 + src/GoatQuery/src/Lexer/Lexer.cs | 16 +++- src/GoatQuery/src/Parser/Parser.cs | 20 ++++- src/GoatQuery/src/Token/Token.cs | 1 + src/GoatQuery/tests/Filter/FilterLexerTest.cs | 77 +++++++++++++++++++ .../tests/Filter/FilterParserTest.cs | 6 ++ src/GoatQuery/tests/Filter/FilterTest.cs | 47 +++++++++-- src/GoatQuery/tests/User.cs | 3 +- 12 files changed, 191 insertions(+), 15 deletions(-) diff --git a/example/Dto/UserDto.cs b/example/Dto/UserDto.cs index 1cd1837..82f7a4c 100644 --- a/example/Dto/UserDto.cs +++ b/example/Dto/UserDto.cs @@ -8,4 +8,6 @@ public record UserDto public string Firstname { get; set; } = string.Empty; public string Lastname { get; set; } = string.Empty; public int Age { get; set; } + public DateTime DateOfBirthUtc { get; set; } + public DateTime DateOfBirthTz { get; set; } } \ No newline at end of file diff --git a/example/Entities/User.cs b/example/Entities/User.cs index 479f6cb..2b73a44 100644 --- a/example/Entities/User.cs +++ b/example/Entities/User.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations.Schema; + public record User { public Guid Id { get; set; } @@ -5,4 +7,10 @@ public record User public string Lastname { get; set; } = string.Empty; public int Age { get; set; } public bool IsDeleted { get; set; } + + [Column(TypeName = "timestamp with time zone")] + public DateTime DateOfBirthUtc { get; set; } + + [Column(TypeName = "timestamp without time zone")] + public DateTime DateOfBirthTz { get; set; } } \ No newline at end of file diff --git a/example/Program.cs b/example/Program.cs index e58704a..e4aab1a 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -6,6 +6,8 @@ using Microsoft.EntityFrameworkCore; using Testcontainers.PostgreSql; +Randomizer.Seed = new Random(8675309); + var builder = WebApplication.CreateBuilder(args); var postgreSqlContainer = new PostgreSqlBuilder() @@ -37,7 +39,15 @@ .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.IsDeleted, f => f.Random.Bool()); + .RuleFor(x => x.IsDeleted, 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); + }); context.Users.AddRange(users.Generate(1_000)); context.SaveChanges(); diff --git a/src/GoatQuery/src/Ast/StringLiteral.cs b/src/GoatQuery/src/Ast/StringLiteral.cs index 112e320..2444378 100644 --- a/src/GoatQuery/src/Ast/StringLiteral.cs +++ b/src/GoatQuery/src/Ast/StringLiteral.cs @@ -28,4 +28,14 @@ public IntegerLiteral(Token token, int 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; + } } \ No newline at end of file diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index 1952d1c..2c74919 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq.Expressions; using FluentResults; @@ -38,6 +39,9 @@ public static Result Evaluate(QueryExpression expression, ParameterE case StringLiteral literal: value = Expression.Constant(literal.Value, property.Type); break; + case DateTimeLiteral literal: + value = Expression.Constant(literal.Value, property.Type); + break; default: return Result.Fail($"Unsupported literal type: {exp.Right.GetType().Name}"); } diff --git a/src/GoatQuery/src/Lexer/Lexer.cs b/src/GoatQuery/src/Lexer/Lexer.cs index 8ff184c..315abae 100644 --- a/src/GoatQuery/src/Lexer/Lexer.cs +++ b/src/GoatQuery/src/Lexer/Lexer.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; public sealed class QueryLexer { @@ -63,6 +64,12 @@ public Token NextToken() if (IsDigit(token.Literal[0])) { + if (IsDateTime(token.Literal)) + { + token.Type = TokenType.DATETIME; + return token; + } + token.Type = TokenType.INT; return token; } @@ -78,6 +85,11 @@ public Token NextToken() return token; } + private bool IsDateTime(string value) + { + return DateTime.TryParse(value, out _); + } + private bool IsGuid(string value) { return Guid.TryParse(value, out _); @@ -87,7 +99,7 @@ private string ReadIdentifier() { var currentPosition = _position; - while (IsLetter(_character) || IsDigit(_character)) + while (IsLetter(_character) || IsDigit(_character) || _character == '-' || _character == ':' || _character == '.') { ReadCharacter(); } @@ -97,7 +109,7 @@ private string ReadIdentifier() private bool IsLetter(char ch) { - return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_' || ch == '-'; + return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_'; } private bool IsDigit(char ch) diff --git a/src/GoatQuery/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs index b790bfb..0aa3444 100644 --- a/src/GoatQuery/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using FluentResults; @@ -139,7 +140,7 @@ private Result ParseFilterStatement() var statement = new InfixExpression(_currentToken, identifier, _currentToken.Literal); - if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID)) + if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID, TokenType.DATETIME)) { return Result.Fail("Invalid value type within filter"); } @@ -151,7 +152,7 @@ private Result ParseFilterStatement() return Result.Fail("Value must be a string when using 'contains' operand"); } - if (statement.Operator.In(Keywords.Lt, Keywords.Lte, Keywords.Gt, Keywords.Gte) && _currentToken.Type != TokenType.INT) + if (statement.Operator.In(Keywords.Lt, Keywords.Lte, Keywords.Gt, Keywords.Gte) && !CurrentTokenIn(TokenType.INT, TokenType.DATETIME)) { return Result.Fail($"Value must be an integer when using '{statement.Operator}' operand"); } @@ -173,6 +174,12 @@ private Result ParseFilterStatement() statement.Right = new IntegerLiteral(_currentToken, intValue); } break; + case TokenType.DATETIME: + if (DateTime.TryParse(_currentToken.Literal, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var dateTimeValue)) + { + statement.Right = new DateTimeLiteral(_currentToken, dateTimeValue); + } + break; } return statement; @@ -196,9 +203,14 @@ private bool CurrentTokenIs(TokenType token) return _currentToken.Type == token; } - private bool PeekTokenIn(params TokenType[] token) + private bool CurrentTokenIn(params TokenType[] tokens) + { + return tokens.Contains(_currentToken.Type); + } + + private bool PeekTokenIn(params TokenType[] tokens) { - return token.Contains(_peekToken.Type); + return tokens.Contains(_peekToken.Type); } private bool PeekIdentifierIs(string identifier) diff --git a/src/GoatQuery/src/Token/Token.cs b/src/GoatQuery/src/Token/Token.cs index f071219..d96ff99 100644 --- a/src/GoatQuery/src/Token/Token.cs +++ b/src/GoatQuery/src/Token/Token.cs @@ -6,6 +6,7 @@ public enum TokenType STRING, INT, GUID, + DATETIME, LPAREN, RPAREN, } diff --git a/src/GoatQuery/tests/Filter/FilterLexerTest.cs b/src/GoatQuery/tests/Filter/FilterLexerTest.cs index 2672436..c192dc0 100644 --- a/src/GoatQuery/tests/Filter/FilterLexerTest.cs +++ b/src/GoatQuery/tests/Filter/FilterLexerTest.cs @@ -224,6 +224,83 @@ public static IEnumerable Parameters() 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.DATETIME, "2000-01-01"), + } + }; + + yield return new object[] + { + "dateOfBirth lt 2000-01-01", + new KeyValuePair[] + { + new (TokenType.IDENT, "dateOfBirth"), + new (TokenType.IDENT, "lt"), + new (TokenType.DATETIME, "2000-01-01"), + } + }; + + yield return new object[] + { + "dateOfBirth lte 2000-01-01", + new KeyValuePair[] + { + new (TokenType.IDENT, "dateOfBirth"), + new (TokenType.IDENT, "lte"), + new (TokenType.DATETIME, "2000-01-01"), + } + }; + + yield return new object[] + { + "dateOfBirth gt 2000-01-01", + new KeyValuePair[] + { + new (TokenType.IDENT, "dateOfBirth"), + new (TokenType.IDENT, "gt"), + new (TokenType.DATETIME, "2000-01-01"), + } + }; + + yield return new object[] + { + "dateOfBirth gte 2000-01-01", + new KeyValuePair[] + { + new (TokenType.IDENT, "dateOfBirth"), + new (TokenType.IDENT, "gte"), + new (TokenType.DATETIME, "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"), + } + }; } [Theory] diff --git a/src/GoatQuery/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs index 9b061ea..5e8017b 100644 --- a/src/GoatQuery/tests/Filter/FilterParserTest.cs +++ b/src/GoatQuery/tests/Filter/FilterParserTest.cs @@ -13,6 +13,12 @@ public sealed class FilterParserTest [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")] public void Test_ParsingFilterStatement(string input, string expectedLeft, string expectedOperator, string expectedRight) { var lexer = new QueryLexer(input); diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 79b21ae..51751b6 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -4,12 +4,12 @@ public sealed class FilterTest { private static readonly Dictionary _users = new Dictionary { - ["John"] = new User { Age = 2, Firstname = "John", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255") }, - ["Jane"] = new User { Age = 1, Firstname = "Jane", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255") }, - ["Apple"] = new User { Age = 2, Firstname = "Apple", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255") }, - ["Harry"] = new User { Age = 1, Firstname = "Harry", UserId = Guid.Parse("e4c7772b-8947-4e46-98ed-644b417d2a08") }, - ["Doe"] = new User { Age = 3, Firstname = "Doe", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255") }, - ["Egg"] = new User { Age = 3, Firstname = "Egg", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255") }, + ["John"] = new User { Age = 2, Firstname = "John", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2004-01-31 23:59:59") }, + ["Jane"] = new User { Age = 1, Firstname = "Jane", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2020-05-09 15:30:00") }, + ["Apple"] = new User { Age = 2, Firstname = "Apple", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("1980-12-31 00:00:01") }, + ["Harry"] = new User { Age = 1, Firstname = "Harry", UserId = Guid.Parse("e4c7772b-8947-4e46-98ed-644b417d2a08"), DateOfBirth = DateTime.Parse("2002-08-01") }, + ["Doe"] = new User { Age = 3, Firstname = "Doe", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2023-07-26 12:00:30") }, + ["Egg"] = new User { Age = 3, Firstname = "Egg", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2000-01-01 00:00:00") }, }; public static IEnumerable Parameters() @@ -133,6 +133,41 @@ public static IEnumerable Parameters() "age lt 3 and age gt 1", new[] { _users["John"], _users["Apple"] } }; + + yield return new object[] { + "dateOfBirth eq 2000-01-01", + new[] { _users["Egg"] } + }; + + yield return new object[] { + "dateOfBirth lt 2010-01-01", + new[] { _users["John"], _users["Apple"], _users["Harry"], _users["Egg"] } + }; + + yield return new object[] { + "dateOfBirth lte 2002-08-01", + new[] { _users["Apple"], _users["Harry"], _users["Egg"] } + }; + + yield return new object[] { + "dateOfBirth gt 2000-08-01 and dateOfBirth lt 2023-01-01", + new[] { _users["John"], _users["Jane"], _users["Harry"] } + }; + + yield return new object[] { + "dateOfBirth eq 2023-07-26T12:00:30Z", + new[] { _users["Doe"] } + }; + + yield return new object[] { + "dateOfBirth gte 2000-01-01", + new[] { _users["John"], _users["Jane"], _users["Harry"], _users["Doe"], _users["Egg"] } + }; + + yield return new object[] { + "dateOfBirth gte 2000-01-01 and dateOfBirth lte 2020-05-09T15:29:59", + new[] { _users["John"], _users["Harry"], _users["Egg"] } + }; } [Theory] diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs index b533740..7a637e4 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -5,8 +5,7 @@ public record User public int Age { get; set; } public Guid UserId { get; set; } public string Firstname { get; set; } = string.Empty; - - public string Long { get; set; } = string.Empty; + public DateTime DateOfBirth { get; set; } } public sealed record CustomJsonPropertyUser : User From 444f8c0c88109b50bd4082847303da34b69d3fbb Mon Sep 17 00:00:00 2001 From: James <23193271+Jamess-Lucass@users.noreply.github.com> Date: Wed, 31 Jul 2024 23:26:54 +0100 Subject: [PATCH 34/60] [v2] Added decimal, float & double support for filter (#71) --- example/Dto/UserDto.cs | 1 + example/Entities/User.cs | 1 + example/Program.cs | 1 + src/GoatQuery/src/Ast/StringLiteral.cs | 30 ++++++ .../src/Evaluator/FilterEvaluator.cs | 9 ++ src/GoatQuery/src/Lexer/Lexer.cs | 18 ++++ src/GoatQuery/src/Parser/Parser.cs | 28 +++++- src/GoatQuery/src/Token/Token.cs | 3 + src/GoatQuery/tests/Filter/FilterLexerTest.cs | 99 +++++++++++++++++++ .../tests/Filter/FilterParserTest.cs | 3 + src/GoatQuery/tests/Filter/FilterTest.cs | 47 +++++++-- src/GoatQuery/tests/User.cs | 3 + 12 files changed, 235 insertions(+), 8 deletions(-) diff --git a/example/Dto/UserDto.cs b/example/Dto/UserDto.cs index 82f7a4c..4f9e033 100644 --- a/example/Dto/UserDto.cs +++ b/example/Dto/UserDto.cs @@ -8,6 +8,7 @@ public record UserDto public string Firstname { get; set; } = string.Empty; public string Lastname { get; set; } = string.Empty; public int Age { get; set; } + public double Test { get; set; } public DateTime DateOfBirthUtc { get; set; } public DateTime DateOfBirthTz { get; set; } } \ No newline at end of file diff --git a/example/Entities/User.cs b/example/Entities/User.cs index 2b73a44..a05b74e 100644 --- a/example/Entities/User.cs +++ b/example/Entities/User.cs @@ -7,6 +7,7 @@ public record User public string Lastname { get; set; } = string.Empty; public int Age { get; set; } public bool IsDeleted { get; set; } + public double Test { get; set; } [Column(TypeName = "timestamp with time zone")] public DateTime DateOfBirthUtc { get; set; } diff --git a/example/Program.cs b/example/Program.cs index e4aab1a..c010e12 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -40,6 +40,7 @@ .RuleFor(x => x.Lastname, f => f.Person.LastName) .RuleFor(x => x.Age, f => f.Random.Int(0, 100)) .RuleFor(x => x.IsDeleted, f => f.Random.Bool()) + .RuleFor(x => x.Test, f => f.Random.Double()) .Rules((f, u) => { var timeZone = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); diff --git a/src/GoatQuery/src/Ast/StringLiteral.cs b/src/GoatQuery/src/Ast/StringLiteral.cs index 2444378..e5b38c2 100644 --- a/src/GoatQuery/src/Ast/StringLiteral.cs +++ b/src/GoatQuery/src/Ast/StringLiteral.cs @@ -30,6 +30,36 @@ public IntegerLiteral(Token token, int value) : base(token) } } +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; } diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index 2c74919..e9df1b4 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -36,6 +36,15 @@ public static Result Evaluate(QueryExpression expression, ParameterE value = integerConstant.Value; break; + case DecimalLiteral literal: + value = Expression.Constant(literal.Value, property.Type); + break; + case FloatLiteral literal: + value = Expression.Constant(literal.Value, property.Type); + break; + case DoubleLiteral literal: + value = Expression.Constant(literal.Value, property.Type); + break; case StringLiteral literal: value = Expression.Constant(literal.Value, property.Type); break; diff --git a/src/GoatQuery/src/Lexer/Lexer.cs b/src/GoatQuery/src/Lexer/Lexer.cs index 315abae..65b3ba3 100644 --- a/src/GoatQuery/src/Lexer/Lexer.cs +++ b/src/GoatQuery/src/Lexer/Lexer.cs @@ -70,6 +70,24 @@ public Token NextToken() return token; } + if (token.Literal.EndsWith("f", StringComparison.OrdinalIgnoreCase)) + { + token.Type = TokenType.FLOAT; + return token; + } + + if (token.Literal.EndsWith("m", StringComparison.OrdinalIgnoreCase)) + { + token.Type = TokenType.DECIMAL; + return token; + } + + if (token.Literal.EndsWith("d", StringComparison.OrdinalIgnoreCase)) + { + token.Type = TokenType.DOUBLE; + return token; + } + token.Type = TokenType.INT; return token; } diff --git a/src/GoatQuery/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs index 0aa3444..745b336 100644 --- a/src/GoatQuery/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -140,7 +140,7 @@ private Result ParseFilterStatement() var statement = new InfixExpression(_currentToken, identifier, _currentToken.Literal); - if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID, TokenType.DATETIME)) + if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID, TokenType.DATETIME, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE)) { return Result.Fail("Invalid value type within filter"); } @@ -152,7 +152,7 @@ private Result ParseFilterStatement() return Result.Fail("Value must be a string when using 'contains' operand"); } - if (statement.Operator.In(Keywords.Lt, Keywords.Lte, Keywords.Gt, Keywords.Gte) && !CurrentTokenIn(TokenType.INT, TokenType.DATETIME)) + if (statement.Operator.In(Keywords.Lt, Keywords.Lte, Keywords.Gt, Keywords.Gte) && !CurrentTokenIn(TokenType.INT, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE, TokenType.DATETIME)) { return Result.Fail($"Value must be an integer when using '{statement.Operator}' operand"); } @@ -174,6 +174,30 @@ private Result ParseFilterStatement() statement.Right = new IntegerLiteral(_currentToken, intValue); } break; + case TokenType.FLOAT: + var floatValueWithoutSuffixLiteral = _currentToken.Literal.TrimEnd('f'); + + if (float.TryParse(floatValueWithoutSuffixLiteral, out var floatValue)) + { + statement.Right = new FloatLiteral(_currentToken, floatValue); + } + break; + case TokenType.DECIMAL: + var decimalValueWithoutSuffixLiteral = _currentToken.Literal.TrimEnd('m'); + + if (decimal.TryParse(decimalValueWithoutSuffixLiteral, out var decimalValue)) + { + statement.Right = new DecimalLiteral(_currentToken, decimalValue); + } + break; + case TokenType.DOUBLE: + var doubleValueWithoutSuffixLiteral = _currentToken.Literal.TrimEnd('d'); + + if (double.TryParse(doubleValueWithoutSuffixLiteral, out var doubleValue)) + { + statement.Right = new DoubleLiteral(_currentToken, doubleValue); + } + break; case TokenType.DATETIME: if (DateTime.TryParse(_currentToken.Literal, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var dateTimeValue)) { diff --git a/src/GoatQuery/src/Token/Token.cs b/src/GoatQuery/src/Token/Token.cs index d96ff99..058b681 100644 --- a/src/GoatQuery/src/Token/Token.cs +++ b/src/GoatQuery/src/Token/Token.cs @@ -5,6 +5,9 @@ public enum TokenType IDENT, STRING, INT, + DECIMAL, + FLOAT, + DOUBLE, GUID, DATETIME, LPAREN, diff --git a/src/GoatQuery/tests/Filter/FilterLexerTest.cs b/src/GoatQuery/tests/Filter/FilterLexerTest.cs index c192dc0..03a9908 100644 --- a/src/GoatQuery/tests/Filter/FilterLexerTest.cs +++ b/src/GoatQuery/tests/Filter/FilterLexerTest.cs @@ -181,6 +181,105 @@ public static IEnumerable Parameters() } }; + 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", diff --git a/src/GoatQuery/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs index 5e8017b..df8015a 100644 --- a/src/GoatQuery/tests/Filter/FilterParserTest.cs +++ b/src/GoatQuery/tests/Filter/FilterParserTest.cs @@ -9,6 +9,9 @@ public sealed class FilterParserTest [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")] diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 51751b6..8e48b84 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -4,12 +4,12 @@ public sealed class FilterTest { private static readonly Dictionary _users = new Dictionary { - ["John"] = new User { Age = 2, Firstname = "John", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2004-01-31 23:59:59") }, - ["Jane"] = new User { Age = 1, Firstname = "Jane", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2020-05-09 15:30:00") }, - ["Apple"] = new User { Age = 2, Firstname = "Apple", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("1980-12-31 00:00:01") }, - ["Harry"] = new User { Age = 1, Firstname = "Harry", UserId = Guid.Parse("e4c7772b-8947-4e46-98ed-644b417d2a08"), DateOfBirth = DateTime.Parse("2002-08-01") }, - ["Doe"] = new User { Age = 3, Firstname = "Doe", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2023-07-26 12:00:30") }, - ["Egg"] = new User { Age = 3, Firstname = "Egg", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2000-01-01 00:00:00") }, + ["John"] = new User { Age = 2, Firstname = "John", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2004-01-31 23:59:59"), BalanceDecimal = 1.50m }, + ["Jane"] = new User { Age = 1, Firstname = "Jane", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2020-05-09 15:30:00"), BalanceDecimal = 0 }, + ["Apple"] = new User { Age = 2, Firstname = "Apple", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("1980-12-31 00:00:01"), BalanceFloat = 1204050.98f }, + ["Harry"] = new User { Age = 1, Firstname = "Harry", UserId = Guid.Parse("e4c7772b-8947-4e46-98ed-644b417d2a08"), DateOfBirth = DateTime.Parse("2002-08-01"), BalanceDecimal = 0.5372958205929493m }, + ["Doe"] = new User { Age = 3, Firstname = "Doe", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2023-07-26 12:00:30"), BalanceDecimal = null }, + ["Egg"] = new User { Age = 3, Firstname = "Egg", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2000-01-01 00:00:00"), BalanceDouble = 1334534453453433.33435443343231235652d }, }; public static IEnumerable Parameters() @@ -134,6 +134,41 @@ public static IEnumerable Parameters() new[] { _users["John"], _users["Apple"] } }; + yield return new object[] { + "balanceDecimal eq 1.50m", + new[] { _users["John"] } + }; + + yield return new object[] { + "balanceDecimal gt 1m", + new[] { _users["John"] } + }; + + yield return new object[] { + "balanceDecimal gt 0.50m", + new[] { _users["John"], _users["Harry"] } + }; + + yield return new object[] { + "balanceDecimal eq 0.5372958205929493m", + new[] { _users["Harry"] } + }; + + yield return new object[] { + "balanceDouble eq 1334534453453433.33435443343231235652d", + new[] { _users["Egg"] } + }; + + yield return new object[] { + "balanceFloat eq 1204050.98f", + new[] { _users["Apple"] } + }; + + yield return new object[] { + "balanceFloat gt 2204050f", + Array.Empty() + }; + yield return new object[] { "dateOfBirth eq 2000-01-01", new[] { _users["Egg"] } diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs index 7a637e4..f93bd5d 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -5,6 +5,9 @@ public record User public int Age { get; set; } public Guid UserId { 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; } } From da832656ac7e8cc0caf7d4234e3bdfabf1b5ba4d Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Mon, 4 Nov 2024 22:32:56 +0000 Subject: [PATCH 35/60] made contains case insensitive --- src/GoatQuery/src/Evaluator/FilterEvaluator.cs | 4 ++-- src/GoatQuery/tests/Filter/FilterTest.cs | 4 ++-- src/GoatQuery/tests/Top/TopTest.cs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index e9df1b4..45d2b20 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -64,9 +64,9 @@ public static Result Evaluate(QueryExpression expression, ParameterE case Keywords.Contains: var identifier = (Identifier)exp.Left; - var method = identifier.Value.GetType().GetMethod("Contains", new[] { value?.Value.GetType() }); + var method = identifier.Value.GetType().GetMethod("Contains", new[] { value?.Value.GetType(), typeof(StringComparison) }); - return Expression.Call(property, method, value); + return Expression.Call(property, method, value, Expression.Constant(StringComparison.OrdinalIgnoreCase)); case Keywords.Lt: return Expression.LessThan(property, value); case Keywords.Lte: diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 8e48b84..3380f56 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -61,12 +61,12 @@ public static IEnumerable Parameters() yield return new object[] { "firstName contains 'a'", - new[] { _users["Jane"], _users["Harry"] } + new[] { _users["Jane"], _users["Apple"], _users["Harry"] } }; yield return new object[] { "Age ne 1 and firstName contains 'a'", - Array.Empty() + new[] { _users["Apple"] } }; yield return new object[] { diff --git a/src/GoatQuery/tests/Top/TopTest.cs b/src/GoatQuery/tests/Top/TopTest.cs index 30a7368..eaa5f9b 100644 --- a/src/GoatQuery/tests/Top/TopTest.cs +++ b/src/GoatQuery/tests/Top/TopTest.cs @@ -70,7 +70,7 @@ public void Test_TopWithMaxTop(int top, int expectedCount) [InlineData(5)] [InlineData(100)] [InlineData(100_000)] - public void Test_TopWithMaxTopThrowsException(int top) + public void Test_TopWithMaxTopReturnsError(int top) { var users = new List{ new User { Age = 1, Firstname = "Jane" }, From f8f6aa1f2c2893b28c73ae0e1ece3a8560bd1579 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Tue, 5 Nov 2024 23:13:32 +0000 Subject: [PATCH 36/60] wip --- src/GoatQuery/src/Lexer/Lexer.cs | 12 ------------ src/GoatQuery/tests/Filter/FilterParserTest.cs | 2 +- src/GoatQuery/tests/Filter/FilterTest.cs | 2 +- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/GoatQuery/src/Lexer/Lexer.cs b/src/GoatQuery/src/Lexer/Lexer.cs index 65b3ba3..d2dbca4 100644 --- a/src/GoatQuery/src/Lexer/Lexer.cs +++ b/src/GoatQuery/src/Lexer/Lexer.cs @@ -158,16 +158,4 @@ private string ReadString() return _input.Substring(currentPosition, _position - currentPosition); } - - private string ReadNumber() - { - var currentPosition = _position; - - while (IsDigit(_character)) - { - ReadCharacter(); - } - - return _input.Substring(currentPosition, _position - currentPosition); - } } \ No newline at end of file diff --git a/src/GoatQuery/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs index df8015a..1e49ce9 100644 --- a/src/GoatQuery/tests/Filter/FilterParserTest.cs +++ b/src/GoatQuery/tests/Filter/FilterParserTest.cs @@ -45,7 +45,7 @@ public void Test_ParsingFilterStatement(string input, string expectedLeft, strin [InlineData("id contains 10")] [InlineData("id contaiins '10'")] [InlineData("id eq John'")] - public void Test_ParsingInvalidFilterThrowsException(string input) + public void Test_ParsingInvalidFilterReturnsError(string input) { var lexer = new QueryLexer(input); var parser = new QueryParser(lexer); diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 3380f56..8e2d2c0 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -221,7 +221,7 @@ public void Test_Filter(string filter, IEnumerable expected) [Theory] [InlineData("NonExistentProperty eq 'John'")] - public void Test_InvalidFilterThrowsException(string filter) + public void Test_InvalidFilterReturnsError(string filter) { var query = new Query { From e8691dff033ced51af3edf71d16d26ee842aa986 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Wed, 6 Nov 2024 19:30:08 +0000 Subject: [PATCH 37/60] fixed attribute parameters --- example/Controllers/UserController.cs | 2 +- .../src/Attributes/EnableQueryAttribute.cs | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/example/Controllers/UserController.cs b/example/Controllers/UserController.cs index af7fa42..908d658 100644 --- a/example/Controllers/UserController.cs +++ b/example/Controllers/UserController.cs @@ -18,7 +18,7 @@ public UsersController(ApplicationDbContext db, IMapper mapper) // GET: /controller/users [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - [EnableQuery] + [EnableQuery(maxTop: 10)] public ActionResult> Get() { var users = _db.Users diff --git a/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs b/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs index e7564af..01fb0dc 100644 --- a/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs +++ b/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs @@ -5,8 +5,13 @@ public sealed class EnableQueryAttribute : ActionFilterAttribute { private readonly QueryOptions? _options; - public EnableQueryAttribute(QueryOptions options) + public EnableQueryAttribute(int maxTop) { + var options = new QueryOptions() + { + MaxTop = maxTop + }; + _options = options; } From 2adadf7f62723ee31e52769af4f850529e181cd4 Mon Sep 17 00:00:00 2001 From: James <23193271+Jamess-Lucass@users.noreply.github.com> Date: Wed, 11 Dec 2024 22:00:02 +0000 Subject: [PATCH 38/60] feat(filter): Added ability to only filter by date, instead of datetime (#73) --- .../src/Ast/{StringLiteral.cs => Literals.cs} | 10 ++++++++++ src/GoatQuery/src/Evaluator/FilterEvaluator.cs | 5 +++++ src/GoatQuery/src/Lexer/Lexer.cs | 11 +++++++++++ src/GoatQuery/src/Parser/Parser.cs | 10 ++++++++-- src/GoatQuery/src/Token/Token.cs | 1 + src/GoatQuery/tests/Filter/FilterLexerTest.cs | 10 +++++----- src/GoatQuery/tests/Filter/FilterTest.cs | 5 +++++ 7 files changed, 45 insertions(+), 7 deletions(-) rename src/GoatQuery/src/Ast/{StringLiteral.cs => Literals.cs} (87%) diff --git a/src/GoatQuery/src/Ast/StringLiteral.cs b/src/GoatQuery/src/Ast/Literals.cs similarity index 87% rename from src/GoatQuery/src/Ast/StringLiteral.cs rename to src/GoatQuery/src/Ast/Literals.cs index e5b38c2..e068366 100644 --- a/src/GoatQuery/src/Ast/StringLiteral.cs +++ b/src/GoatQuery/src/Ast/Literals.cs @@ -68,4 +68,14 @@ 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; + } } \ No newline at end of file diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index 45d2b20..94b5d70 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -51,6 +51,11 @@ public static Result Evaluate(QueryExpression expression, ParameterE case DateTimeLiteral literal: value = Expression.Constant(literal.Value, property.Type); break; + case DateLiteral literal: + property = Expression.Property(property, "Date"); + + value = Expression.Constant(literal.Value.Date, property.Type); + break; default: return Result.Fail($"Unsupported literal type: {exp.Right.GetType().Name}"); } diff --git a/src/GoatQuery/src/Lexer/Lexer.cs b/src/GoatQuery/src/Lexer/Lexer.cs index d2dbca4..398067b 100644 --- a/src/GoatQuery/src/Lexer/Lexer.cs +++ b/src/GoatQuery/src/Lexer/Lexer.cs @@ -64,6 +64,12 @@ public Token NextToken() if (IsDigit(token.Literal[0])) { + if (IsDate(token.Literal)) + { + token.Type = TokenType.DATE; + return token; + } + if (IsDateTime(token.Literal)) { token.Type = TokenType.DATETIME; @@ -103,6 +109,11 @@ public Token NextToken() 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 _); diff --git a/src/GoatQuery/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs index 745b336..d6f65a3 100644 --- a/src/GoatQuery/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -140,7 +140,7 @@ private Result ParseFilterStatement() var statement = new InfixExpression(_currentToken, identifier, _currentToken.Literal); - if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID, TokenType.DATETIME, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE)) + if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID, TokenType.DATETIME, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE, TokenType.DATE)) { return Result.Fail("Invalid value type within filter"); } @@ -152,7 +152,7 @@ private Result ParseFilterStatement() return Result.Fail("Value must be a string when using 'contains' operand"); } - if (statement.Operator.In(Keywords.Lt, Keywords.Lte, Keywords.Gt, Keywords.Gte) && !CurrentTokenIn(TokenType.INT, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE, TokenType.DATETIME)) + 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 an integer when using '{statement.Operator}' operand"); } @@ -204,6 +204,12 @@ private Result ParseFilterStatement() statement.Right = new DateTimeLiteral(_currentToken, dateTimeValue); } break; + case TokenType.DATE: + if (DateTime.TryParse(_currentToken.Literal, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var dateValue)) + { + statement.Right = new DateLiteral(_currentToken, dateValue); + } + break; } return statement; diff --git a/src/GoatQuery/src/Token/Token.cs b/src/GoatQuery/src/Token/Token.cs index 058b681..a2921b0 100644 --- a/src/GoatQuery/src/Token/Token.cs +++ b/src/GoatQuery/src/Token/Token.cs @@ -10,6 +10,7 @@ public enum TokenType DOUBLE, GUID, DATETIME, + DATE, LPAREN, RPAREN, } diff --git a/src/GoatQuery/tests/Filter/FilterLexerTest.cs b/src/GoatQuery/tests/Filter/FilterLexerTest.cs index 03a9908..ff2b2a3 100644 --- a/src/GoatQuery/tests/Filter/FilterLexerTest.cs +++ b/src/GoatQuery/tests/Filter/FilterLexerTest.cs @@ -331,7 +331,7 @@ public static IEnumerable Parameters() { new (TokenType.IDENT, "dateOfBirth"), new (TokenType.IDENT, "eq"), - new (TokenType.DATETIME, "2000-01-01"), + new (TokenType.DATE, "2000-01-01"), } }; @@ -342,7 +342,7 @@ public static IEnumerable Parameters() { new (TokenType.IDENT, "dateOfBirth"), new (TokenType.IDENT, "lt"), - new (TokenType.DATETIME, "2000-01-01"), + new (TokenType.DATE, "2000-01-01"), } }; @@ -353,7 +353,7 @@ public static IEnumerable Parameters() { new (TokenType.IDENT, "dateOfBirth"), new (TokenType.IDENT, "lte"), - new (TokenType.DATETIME, "2000-01-01"), + new (TokenType.DATE, "2000-01-01"), } }; @@ -364,7 +364,7 @@ public static IEnumerable Parameters() { new (TokenType.IDENT, "dateOfBirth"), new (TokenType.IDENT, "gt"), - new (TokenType.DATETIME, "2000-01-01"), + new (TokenType.DATE, "2000-01-01"), } }; @@ -375,7 +375,7 @@ public static IEnumerable Parameters() { new (TokenType.IDENT, "dateOfBirth"), new (TokenType.IDENT, "gte"), - new (TokenType.DATETIME, "2000-01-01"), + new (TokenType.DATE, "2000-01-01"), } }; diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 8e2d2c0..52af816 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -174,6 +174,11 @@ public static IEnumerable Parameters() new[] { _users["Egg"] } }; + yield return new object[] { + "dateOfBirth eq 2020-05-09", + new[] { _users["Jane"] } + }; + yield return new object[] { "dateOfBirth lt 2010-01-01", new[] { _users["John"], _users["Apple"], _users["Harry"], _users["Egg"] } From 4423492acc62d39673020fbaeaf061f722f118f8 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Wed, 11 Dec 2024 22:28:04 +0000 Subject: [PATCH 39/60] fix(filter): Fixed filtering on nullable datetime --- src/GoatQuery/src/Evaluator/FilterEvaluator.cs | 4 +++- src/GoatQuery/tests/User.cs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index 94b5d70..b0c07c3 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -52,7 +52,9 @@ public static Result Evaluate(QueryExpression expression, ParameterE value = Expression.Constant(literal.Value, property.Type); break; case DateLiteral literal: - property = Expression.Property(property, "Date"); + property = property.Type == typeof(DateTime?) ? + Expression.Property(Expression.Property(property, "Value"), "Date") : + Expression.Property(property, "Date"); value = Expression.Constant(literal.Value.Date, property.Type); break; diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs index f93bd5d..5c90bd9 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -8,7 +8,7 @@ public record User public decimal? BalanceDecimal { get; set; } public double? BalanceDouble { get; set; } public float? BalanceFloat { get; set; } - public DateTime DateOfBirth { get; set; } + public DateTime? DateOfBirth { get; set; } } public sealed record CustomJsonPropertyUser : User From f5d068281f4053e9d8801253317e70175b9ccf11 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Fri, 13 Dec 2024 12:30:04 +0000 Subject: [PATCH 40/60] fix(filter): Fixed linq2sql for string contains comparison ignore case --- src/GoatQuery/src/Evaluator/FilterEvaluator.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index b0c07c3..8e9db75 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -71,9 +71,14 @@ public static Result Evaluate(QueryExpression expression, ParameterE case Keywords.Contains: var identifier = (Identifier)exp.Left; - var method = identifier.Value.GetType().GetMethod("Contains", new[] { value?.Value.GetType(), typeof(StringComparison) }); + var toLowerMethod = identifier.Value.GetType().GetMethod("ToLower", Type.EmptyTypes); - return Expression.Call(property, method, value, Expression.Constant(StringComparison.OrdinalIgnoreCase)); + var propertyToLower = Expression.Call(property, toLowerMethod); + var valueToLower = Expression.Call(value, toLowerMethod); + + var containsMethod = identifier.Value.GetType().GetMethod("Contains", new[] { value?.Value.GetType() }); + + return Expression.Call(propertyToLower, containsMethod, valueToLower); case Keywords.Lt: return Expression.LessThan(property, value); case Keywords.Lte: From 40cd997ffa2a7d49338006037ab3af553d679e7f Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Thu, 12 Jun 2025 20:31:29 +0100 Subject: [PATCH 41/60] added nullable --- example/Dto/UserDto.cs | 1 + example/Entities/User.cs | 1 + example/Program.cs | 1 + example/example.csproj | 10 +-- src/GoatQuery/src/Ast/Literals.cs | 7 ++ .../src/Evaluator/FilterEvaluator.cs | 74 +++++++++++++++++-- src/GoatQuery/src/GoatQuery.csproj | 2 +- src/GoatQuery/src/Lexer/Lexer.cs | 6 ++ src/GoatQuery/src/Parser/Parser.cs | 10 ++- src/GoatQuery/src/Token/Token.cs | 2 + src/GoatQuery/tests/Filter/FilterLexerTest.cs | 37 ++++++++++ .../tests/Filter/FilterParserTest.cs | 8 ++ src/GoatQuery/tests/Filter/FilterTest.cs | 62 +++++++++++++++- src/GoatQuery/tests/tests.csproj | 2 +- 14 files changed, 207 insertions(+), 16 deletions(-) diff --git a/example/Dto/UserDto.cs b/example/Dto/UserDto.cs index 4f9e033..4f837e8 100644 --- a/example/Dto/UserDto.cs +++ b/example/Dto/UserDto.cs @@ -9,6 +9,7 @@ public record UserDto public string Lastname { get; set; } = string.Empty; public int Age { get; set; } public double Test { get; set; } + public int? NullableInt { get; set; } public DateTime DateOfBirthUtc { get; set; } public DateTime DateOfBirthTz { get; set; } } \ No newline at end of file diff --git a/example/Entities/User.cs b/example/Entities/User.cs index a05b74e..9df8cf0 100644 --- a/example/Entities/User.cs +++ b/example/Entities/User.cs @@ -8,6 +8,7 @@ public record User public int Age { get; set; } public bool IsDeleted { get; set; } public double Test { get; set; } + public int? NullableInt { get; set; } [Column(TypeName = "timestamp with time zone")] public DateTime DateOfBirthUtc { get; set; } diff --git a/example/Program.cs b/example/Program.cs index c010e12..513de1b 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -41,6 +41,7 @@ .RuleFor(x => x.Age, f => f.Random.Int(0, 100)) .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) .Rules((f, u) => { var timeZone = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); diff --git a/example/example.csproj b/example/example.csproj index e9f34c6..674a5e6 100644 --- a/example/example.csproj +++ b/example/example.csproj @@ -1,17 +1,17 @@ - net8.0 + net9.0 enable enable - - - - + + + + diff --git a/src/GoatQuery/src/Ast/Literals.cs b/src/GoatQuery/src/Ast/Literals.cs index e068366..af61215 100644 --- a/src/GoatQuery/src/Ast/Literals.cs +++ b/src/GoatQuery/src/Ast/Literals.cs @@ -78,4 +78,11 @@ public DateLiteral(Token token, DateTime value) : base(token) { Value = value; } +} + +public sealed class NullLiteral : QueryExpression +{ + public NullLiteral(Token token) : base(token) + { + } } \ No newline at end of file diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index 8e9db75..9b3dc02 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -52,16 +52,32 @@ public static Result Evaluate(QueryExpression expression, ParameterE value = Expression.Constant(literal.Value, property.Type); break; case DateLiteral literal: - property = property.Type == typeof(DateTime?) ? - Expression.Property(Expression.Property(property, "Value"), "Date") : - Expression.Property(property, "Date"); - - value = Expression.Constant(literal.Value.Date, property.Type); + if (property.Type == typeof(DateTime?)) + { + // For nullable DateTime, we need to handle this differently in the operator switch + // Just set up the date value for now + value = Expression.Constant(literal.Value.Date, typeof(DateTime)); + } + else + { + property = Expression.Property(property, "Date"); + value = Expression.Constant(literal.Value.Date, property.Type); + } + break; + case NullLiteral _: + value = Expression.Constant(null, property.Type); break; default: return Result.Fail($"Unsupported literal type: {exp.Right.GetType().Name}"); } + // Check if we need special nullable handling + var specialComparison = CreateNullableComparison(property, value, exp.Right, exp.Operator); + if (specialComparison != null) + { + return specialComparison; + } + switch (exp.Operator) { case Keywords.Eq: @@ -118,6 +134,54 @@ public static Result Evaluate(QueryExpression expression, ParameterE return null; } + /// + /// Creates special nullable property comparisons for types that need property transformation. + /// Returns null if no special handling is needed. + /// + private static Expression CreateNullableComparison(MemberExpression property, ConstantExpression value, QueryExpression rightExpression, string operatorKeyword) + { + // Handle nullable DateTime with DateLiteral - requires .Date property access + if (property.Type == typeof(DateTime?) && rightExpression is DateLiteral) + { + return CreateNullableDateComparison(property, value, operatorKeyword); + } + + // Future extensibility: Add other type transformations here + // if (property.Type == typeof(TimeOnly?) && rightExpression is TimeLiteral) + // { + // return CreateNullableTimeComparison(property, value, operatorKeyword); + // } + + return null; + } + + /// + /// Creates nullable DateTime comparisons that safely access the .Date property. + /// + private static Expression CreateNullableDateComparison(MemberExpression property, ConstantExpression value, string operatorKeyword) + { + var hasValueProperty = Expression.Property(property, "HasValue"); + var valueProperty = Expression.Property(property, "Value"); + var dateProperty = Expression.Property(valueProperty, "Date"); + + Expression dateComparison = 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 nullable date comparison: {operatorKeyword}") + }; + + // For inequality, we want: !HasValue OR HasValue && comparison != true + // For others, we want: HasValue && comparison == true + return operatorKeyword == Keywords.Ne + ? Expression.OrElse(Expression.Not(hasValueProperty), dateComparison) + : Expression.AndAlso(hasValueProperty, dateComparison); + } + private static Result GetIntegerExpressionConstant(int value, Type targetType) { try diff --git a/src/GoatQuery/src/GoatQuery.csproj b/src/GoatQuery/src/GoatQuery.csproj index 56d50eb..1b3d929 100644 --- a/src/GoatQuery/src/GoatQuery.csproj +++ b/src/GoatQuery/src/GoatQuery.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/GoatQuery/src/Lexer/Lexer.cs b/src/GoatQuery/src/Lexer/Lexer.cs index 398067b..3813372 100644 --- a/src/GoatQuery/src/Lexer/Lexer.cs +++ b/src/GoatQuery/src/Lexer/Lexer.cs @@ -62,6 +62,12 @@ public Token NextToken() return token; } + if (token.Literal.Equals(Keywords.Null, StringComparison.OrdinalIgnoreCase)) + { + token.Type = TokenType.NULL; + return token; + } + if (IsDigit(token.Literal[0])) { if (IsDate(token.Literal)) diff --git a/src/GoatQuery/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs index d6f65a3..d07ece1 100644 --- a/src/GoatQuery/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -140,7 +140,7 @@ private Result ParseFilterStatement() var statement = new InfixExpression(_currentToken, identifier, _currentToken.Literal); - if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID, TokenType.DATETIME, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE, TokenType.DATE)) + if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID, TokenType.DATETIME, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE, TokenType.DATE, TokenType.NULL)) { return Result.Fail("Invalid value type within filter"); } @@ -152,6 +152,11 @@ private Result ParseFilterStatement() 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 an integer when using '{statement.Operator}' operand"); @@ -210,6 +215,9 @@ private Result ParseFilterStatement() statement.Right = new DateLiteral(_currentToken, dateValue); } break; + case TokenType.NULL: + statement.Right = new NullLiteral(_currentToken); + break; } return statement; diff --git a/src/GoatQuery/src/Token/Token.cs b/src/GoatQuery/src/Token/Token.cs index a2921b0..7c6ce3c 100644 --- a/src/GoatQuery/src/Token/Token.cs +++ b/src/GoatQuery/src/Token/Token.cs @@ -11,6 +11,7 @@ public enum TokenType GUID, DATETIME, DATE, + NULL, LPAREN, RPAREN, } @@ -28,6 +29,7 @@ public static class Keywords internal const string Gte = "gte"; internal const string And = "and"; internal const string Or = "or"; + internal const string Null = "null"; } public sealed class Token diff --git a/src/GoatQuery/tests/Filter/FilterLexerTest.cs b/src/GoatQuery/tests/Filter/FilterLexerTest.cs index ff2b2a3..7c0aaac 100644 --- a/src/GoatQuery/tests/Filter/FilterLexerTest.cs +++ b/src/GoatQuery/tests/Filter/FilterLexerTest.cs @@ -400,6 +400,43 @@ public static IEnumerable Parameters() 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"), + } + }; } [Theory] diff --git a/src/GoatQuery/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs index 1e49ce9..f807cf2 100644 --- a/src/GoatQuery/tests/Filter/FilterParserTest.cs +++ b/src/GoatQuery/tests/Filter/FilterParserTest.cs @@ -22,6 +22,9 @@ public sealed class FilterParserTest [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); @@ -45,6 +48,11 @@ public void Test_ParsingFilterStatement(string input, string expectedLeft, strin [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); diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 52af816..dd72a6d 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -10,6 +10,7 @@ public sealed class FilterTest ["Harry"] = new User { Age = 1, Firstname = "Harry", UserId = Guid.Parse("e4c7772b-8947-4e46-98ed-644b417d2a08"), DateOfBirth = DateTime.Parse("2002-08-01"), BalanceDecimal = 0.5372958205929493m }, ["Doe"] = new User { Age = 3, Firstname = "Doe", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2023-07-26 12:00:30"), BalanceDecimal = null }, ["Egg"] = new User { Age = 3, Firstname = "Egg", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2000-01-01 00:00:00"), BalanceDouble = 1334534453453433.33435443343231235652d }, + ["NullUser"] = new User { Age = 4, Firstname = "NullUser", UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), DateOfBirth = null, BalanceDecimal = null, BalanceDouble = null, BalanceFloat = null }, }; public static IEnumerable Parameters() @@ -56,7 +57,7 @@ public static IEnumerable Parameters() yield return new object[] { "Age ne 3", - new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"] } + new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"], _users["NullUser"] } }; yield return new object[] { @@ -121,12 +122,12 @@ public static IEnumerable Parameters() yield return new object[] { "age gt 1", - new[] { _users["John"], _users["Apple"], _users["Doe"], _users["Egg"] } + new[] { _users["John"], _users["Apple"], _users["Doe"], _users["Egg"], _users["NullUser"] } }; yield return new object[] { "age gte 3", - new[] { _users["Doe"], _users["Egg"] } + new[] { _users["Doe"], _users["Egg"], _users["NullUser"] } }; yield return new object[] { @@ -208,6 +209,61 @@ public static IEnumerable Parameters() "dateOfBirth gte 2000-01-01 and dateOfBirth lte 2020-05-09T15:29:59", new[] { _users["John"], _users["Harry"], _users["Egg"] } }; + + yield return new object[] { + "balanceDecimal eq null", + new[] { _users["Apple"], _users["Doe"], _users["Egg"], _users["NullUser"] } + }; + + yield return new object[] { + "balanceDecimal ne null", + new[] { _users["John"], _users["Jane"], _users["Harry"] } + }; + + yield return new object[] { + "balanceDouble eq null", + new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"], _users["Doe"], _users["NullUser"] } + }; + + yield return new object[] { + "balanceDouble ne null", + new[] { _users["Egg"] } + }; + + yield return new object[] { + "balanceFloat eq null", + new[] { _users["John"], _users["Jane"], _users["Harry"], _users["Doe"], _users["Egg"], _users["NullUser"] } + }; + + yield return new object[] { + "balanceFloat ne null", + new[] { _users["Apple"] } + }; + + yield return new object[] { + "dateOfBirth eq null", + new[] { _users["NullUser"] } + }; + + yield return new object[] { + "dateOfBirth ne null", + new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"], _users["Doe"], _users["Egg"] } + }; + + yield return new object[] { + "balanceDecimal eq null and age gt 3", + new[] { _users["NullUser"] } + }; + + yield return new object[] { + "balanceDecimal ne null or age eq 4", + new[] { _users["John"], _users["Jane"], _users["Harry"], _users["NullUser"] } + }; + + yield return new object[] { + "firstname eq 'Doe' and balanceDecimal eq null", + new[] { _users["Doe"] } + }; } [Theory] diff --git a/src/GoatQuery/tests/tests.csproj b/src/GoatQuery/tests/tests.csproj index e45f3b3..1f6458d 100644 --- a/src/GoatQuery/tests/tests.csproj +++ b/src/GoatQuery/tests/tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 enable enable false From 00986429d11782125e1e7c5ed2f15f6c69f961f5 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Thu, 12 Jun 2025 21:31:09 +0100 Subject: [PATCH 42/60] wip --- src/GoatQuery/src/Evaluator/FilterEvaluator.cs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index 9b3dc02..79eef95 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -54,8 +54,6 @@ public static Result Evaluate(QueryExpression expression, ParameterE case DateLiteral literal: if (property.Type == typeof(DateTime?)) { - // For nullable DateTime, we need to handle this differently in the operator switch - // Just set up the date value for now value = Expression.Constant(literal.Value.Date, typeof(DateTime)); } else @@ -134,30 +132,16 @@ public static Result Evaluate(QueryExpression expression, ParameterE return null; } - /// - /// Creates special nullable property comparisons for types that need property transformation. - /// Returns null if no special handling is needed. - /// private static Expression CreateNullableComparison(MemberExpression property, ConstantExpression value, QueryExpression rightExpression, string operatorKeyword) { - // Handle nullable DateTime with DateLiteral - requires .Date property access if (property.Type == typeof(DateTime?) && rightExpression is DateLiteral) { return CreateNullableDateComparison(property, value, operatorKeyword); } - // Future extensibility: Add other type transformations here - // if (property.Type == typeof(TimeOnly?) && rightExpression is TimeLiteral) - // { - // return CreateNullableTimeComparison(property, value, operatorKeyword); - // } - return null; } - /// - /// Creates nullable DateTime comparisons that safely access the .Date property. - /// private static Expression CreateNullableDateComparison(MemberExpression property, ConstantExpression value, string operatorKeyword) { var hasValueProperty = Expression.Property(property, "HasValue"); @@ -175,8 +159,6 @@ private static Expression CreateNullableDateComparison(MemberExpression property _ => throw new ArgumentException($"Unsupported operator for nullable date comparison: {operatorKeyword}") }; - // For inequality, we want: !HasValue OR HasValue && comparison != true - // For others, we want: HasValue && comparison == true return operatorKeyword == Keywords.Ne ? Expression.OrElse(Expression.Not(hasValueProperty), dateComparison) : Expression.AndAlso(hasValueProperty, dateComparison); From 73537b88f75344de73653cdbbfc4bb2e0e1360f9 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Thu, 28 Aug 2025 14:25:57 +0100 Subject: [PATCH 43/60] feat: added boolean filter --- example/Dto/UserDto.cs | 1 + example/Entities/User.cs | 1 + example/Program.cs | 1 + src/GoatQuery/src/Ast/Literals.cs | 10 +++++ .../src/Evaluator/FilterEvaluator.cs | 3 ++ src/GoatQuery/src/Lexer/Lexer.cs | 7 +++ src/GoatQuery/src/Parser/Parser.cs | 10 ++++- src/GoatQuery/src/Token/Token.cs | 3 ++ src/GoatQuery/tests/Filter/FilterTest.cs | 44 ++++++++++++++++--- src/GoatQuery/tests/User.cs | 1 + 10 files changed, 72 insertions(+), 9 deletions(-) diff --git a/example/Dto/UserDto.cs b/example/Dto/UserDto.cs index 4f837e8..33e721c 100644 --- a/example/Dto/UserDto.cs +++ b/example/Dto/UserDto.cs @@ -8,6 +8,7 @@ public record UserDto public string Firstname { get; set; } = string.Empty; public string Lastname { get; set; } = string.Empty; public int Age { get; set; } + public bool IsEmailVerified { get; set; } public double Test { get; set; } public int? NullableInt { get; set; } public DateTime DateOfBirthUtc { get; set; } diff --git a/example/Entities/User.cs b/example/Entities/User.cs index 9df8cf0..425e462 100644 --- a/example/Entities/User.cs +++ b/example/Entities/User.cs @@ -7,6 +7,7 @@ public record User public string Lastname { get; set; } = string.Empty; public int Age { get; set; } public bool IsDeleted { get; set; } + public bool IsEmailVerified { get; set; } public double Test { get; set; } public int? NullableInt { get; set; } diff --git a/example/Program.cs b/example/Program.cs index 513de1b..edfa84a 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -42,6 +42,7 @@ .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"); diff --git a/src/GoatQuery/src/Ast/Literals.cs b/src/GoatQuery/src/Ast/Literals.cs index af61215..455b32d 100644 --- a/src/GoatQuery/src/Ast/Literals.cs +++ b/src/GoatQuery/src/Ast/Literals.cs @@ -85,4 +85,14 @@ 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/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index 79eef95..7f1b661 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -65,6 +65,9 @@ public static Result Evaluate(QueryExpression expression, ParameterE case NullLiteral _: value = Expression.Constant(null, property.Type); break; + case BooleanLiteral literal: + value = Expression.Constant(literal.Value, property.Type); + break; default: return Result.Fail($"Unsupported literal type: {exp.Right.GetType().Name}"); } diff --git a/src/GoatQuery/src/Lexer/Lexer.cs b/src/GoatQuery/src/Lexer/Lexer.cs index 3813372..f06c62f 100644 --- a/src/GoatQuery/src/Lexer/Lexer.cs +++ b/src/GoatQuery/src/Lexer/Lexer.cs @@ -68,6 +68,13 @@ public Token NextToken() return token; } + if (token.Literal.Equals(Keywords.True, StringComparison.OrdinalIgnoreCase) || + token.Literal.Equals(Keywords.False, StringComparison.OrdinalIgnoreCase)) + { + token.Type = TokenType.BOOLEAN; + return token; + } + if (IsDigit(token.Literal[0])) { if (IsDate(token.Literal)) diff --git a/src/GoatQuery/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs index d07ece1..e1379a8 100644 --- a/src/GoatQuery/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -140,7 +140,7 @@ private Result ParseFilterStatement() var statement = new InfixExpression(_currentToken, identifier, _currentToken.Literal); - if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID, TokenType.DATETIME, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE, TokenType.DATE, TokenType.NULL)) + 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"); } @@ -159,7 +159,7 @@ private Result ParseFilterStatement() 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 an integer when using '{statement.Operator}' operand"); + return Result.Fail($"Value must be a numeric or date type when using '{statement.Operator}' operand"); } switch (_currentToken.Type) @@ -218,6 +218,12 @@ private Result ParseFilterStatement() case TokenType.NULL: statement.Right = new NullLiteral(_currentToken); break; + case TokenType.BOOLEAN: + if (bool.TryParse(_currentToken.Literal, out var boolValue)) + { + statement.Right = new BooleanLiteral(_currentToken, boolValue); + } + break; } return statement; diff --git a/src/GoatQuery/src/Token/Token.cs b/src/GoatQuery/src/Token/Token.cs index 7c6ce3c..74676b0 100644 --- a/src/GoatQuery/src/Token/Token.cs +++ b/src/GoatQuery/src/Token/Token.cs @@ -12,6 +12,7 @@ public enum TokenType DATETIME, DATE, NULL, + BOOLEAN, LPAREN, RPAREN, } @@ -30,6 +31,8 @@ public static class Keywords 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"; } public sealed class Token diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index dd72a6d..1b7ba0d 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -4,13 +4,13 @@ public sealed class FilterTest { private static readonly Dictionary _users = new Dictionary { - ["John"] = new User { Age = 2, Firstname = "John", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2004-01-31 23:59:59"), BalanceDecimal = 1.50m }, - ["Jane"] = new User { Age = 1, Firstname = "Jane", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2020-05-09 15:30:00"), BalanceDecimal = 0 }, - ["Apple"] = new User { Age = 2, Firstname = "Apple", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("1980-12-31 00:00:01"), BalanceFloat = 1204050.98f }, - ["Harry"] = new User { Age = 1, Firstname = "Harry", UserId = Guid.Parse("e4c7772b-8947-4e46-98ed-644b417d2a08"), DateOfBirth = DateTime.Parse("2002-08-01"), BalanceDecimal = 0.5372958205929493m }, - ["Doe"] = new User { Age = 3, Firstname = "Doe", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2023-07-26 12:00:30"), BalanceDecimal = null }, - ["Egg"] = new User { Age = 3, Firstname = "Egg", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2000-01-01 00:00:00"), BalanceDouble = 1334534453453433.33435443343231235652d }, - ["NullUser"] = new User { Age = 4, Firstname = "NullUser", UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), DateOfBirth = null, BalanceDecimal = null, BalanceDouble = null, BalanceFloat = null }, + ["John"] = new User { Age = 2, Firstname = "John", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2004-01-31 23:59:59"), BalanceDecimal = 1.50m, IsEmailVerified = true }, + ["Jane"] = new User { Age = 1, Firstname = "Jane", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2020-05-09 15:30:00"), BalanceDecimal = 0, IsEmailVerified = false }, + ["Apple"] = new User { Age = 2, Firstname = "Apple", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("1980-12-31 00:00:01"), BalanceFloat = 1204050.98f, IsEmailVerified = true }, + ["Harry"] = new User { Age = 1, Firstname = "Harry", UserId = Guid.Parse("e4c7772b-8947-4e46-98ed-644b417d2a08"), DateOfBirth = DateTime.Parse("2002-08-01"), BalanceDecimal = 0.5372958205929493m, IsEmailVerified = false }, + ["Doe"] = new User { Age = 3, Firstname = "Doe", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2023-07-26 12:00:30"), BalanceDecimal = null, IsEmailVerified = true }, + ["Egg"] = new User { Age = 3, Firstname = "Egg", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2000-01-01 00:00:00"), BalanceDouble = 1334534453453433.33435443343231235652d, IsEmailVerified = false }, + ["NullUser"] = new User { Age = 4, Firstname = "NullUser", UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), DateOfBirth = null, BalanceDecimal = null, BalanceDouble = null, BalanceFloat = null, IsEmailVerified = true }, }; public static IEnumerable Parameters() @@ -264,6 +264,36 @@ public static IEnumerable Parameters() "firstname eq 'Doe' and balanceDecimal eq null", new[] { _users["Doe"] } }; + + yield return new object[] { + "isEmailVerified eq true", + new[] { _users["John"], _users["Apple"], _users["Doe"], _users["NullUser"] } + }; + + yield return new object[] { + "isEmailVerified eq false", + new[] { _users["Jane"], _users["Harry"], _users["Egg"] } + }; + + yield return new object[] { + "isEmailVerified ne true", + new[] { _users["Jane"], _users["Harry"], _users["Egg"] } + }; + + yield return new object[] { + "isEmailVerified ne false", + new[] { _users["John"], _users["Apple"], _users["Doe"], _users["NullUser"] } + }; + + yield return new object[] { + "age gt 2 and isEmailVerified eq true", + new[] { _users["Doe"], _users["NullUser"] } + }; + + yield return new object[] { + "isEmailVerified eq false or age eq 2", + new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"], _users["Egg"] } + }; } [Theory] diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs index 5c90bd9..96ca392 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -9,6 +9,7 @@ public record User public double? BalanceDouble { get; set; } public float? BalanceFloat { get; set; } public DateTime? DateOfBirth { get; set; } + public bool IsEmailVerified { get; set; } } public sealed record CustomJsonPropertyUser : User From 63da894657f5d1b4e15cf2896c44c5a24b008106 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Thu, 28 Aug 2025 15:19:47 +0100 Subject: [PATCH 44/60] updated dotnet version in ci tests --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0f88882..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 From 246bdaa41d8f877aa8704d322b9cb2ca8cd30026 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:03:40 +0100 Subject: [PATCH 45/60] feat: added functionality to filter by properties on nested objects --- example/Dto/UserDto.cs | 1 + example/Entities/User.cs | 1 + example/Program.cs | 29 +- src/GoatQuery/src/Ast/Node.cs | 2 +- src/GoatQuery/src/Ast/PropertyPath.cs | 16 + .../src/Evaluator/FilterEvaluator.cs | 294 ++++++++++-------- src/GoatQuery/src/Lexer/Lexer.cs | 3 + src/GoatQuery/src/Parser/Parser.cs | 30 +- src/GoatQuery/src/Token/Token.cs | 1 + src/GoatQuery/tests/Filter/FilterLexerTest.cs | 28 ++ .../tests/Filter/FilterParserTest.cs | 21 ++ src/GoatQuery/tests/Filter/FilterTest.cs | 212 +++++++++---- src/GoatQuery/tests/TestData.cs | 116 +++++++ src/GoatQuery/tests/User.cs | 1 + 14 files changed, 556 insertions(+), 199 deletions(-) create mode 100644 src/GoatQuery/src/Ast/PropertyPath.cs create mode 100644 src/GoatQuery/tests/TestData.cs diff --git a/example/Dto/UserDto.cs b/example/Dto/UserDto.cs index 33e721c..47d1aa0 100644 --- a/example/Dto/UserDto.cs +++ b/example/Dto/UserDto.cs @@ -13,4 +13,5 @@ public record UserDto public int? NullableInt { get; set; } public DateTime DateOfBirthUtc { get; set; } public DateTime DateOfBirthTz { get; set; } + public User? Manager { get; set; } } \ No newline at end of file diff --git a/example/Entities/User.cs b/example/Entities/User.cs index 425e462..c3e4862 100644 --- a/example/Entities/User.cs +++ b/example/Entities/User.cs @@ -16,4 +16,5 @@ public record User [Column(TypeName = "timestamp without time zone")] public DateTime DateOfBirthTz { get; set; } + public User? Manager { get; set; } } \ No newline at end of file diff --git a/example/Program.cs b/example/Program.cs index edfa84a..192c14e 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -50,7 +50,8 @@ u.DateOfBirthUtc = date; u.DateOfBirthTz = TimeZoneInfo.ConvertTimeFromUtc(date, timeZone); - }); + }) + .RuleFor(x => x.Manager, (f, u) => f.CreateManager(3)); context.Users.AddRange(users.Generate(1_000)); context.SaveChanges(); @@ -59,6 +60,8 @@ } } +Console.WriteLine($"Postgres connection string: {postgreSqlContainer.GetConnectionString()}"); + app.MapGet("/minimal/users", (ApplicationDbContext db, [FromServices] IMapper mapper, [AsParameters] Query query) => { var result = db.Users @@ -79,3 +82,27 @@ 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/src/GoatQuery/src/Ast/Node.cs b/src/GoatQuery/src/Ast/Node.cs index 6b8722a..21f324b 100644 --- a/src/GoatQuery/src/Ast/Node.cs +++ b/src/GoatQuery/src/Ast/Node.cs @@ -7,7 +7,7 @@ public Node(Token token) _token = token; } - public string TokenLiteral() + public virtual string TokenLiteral() { return _token.Literal; } 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/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index 7f1b661..69e6e0f 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -1,114 +1,179 @@ using System; using System.Collections.Generic; -using System.Globalization; +using System.Linq; using System.Linq.Expressions; using FluentResults; public static class FilterEvaluator { + private static Result EvaluatePropertyPathExpression( + InfixExpression exp, + PropertyPath propertyPath, + ParameterExpression parameterExpression, + Dictionary propertyMapping) + { + // Build property path with null checks for intermediate properties + var current = (Expression)parameterExpression; + var nullChecks = new List(); + + for (int i = 0; i < propertyPath.Segments.Count; i++) + { + var segment = propertyPath.Segments[i]; + if (!propertyMapping.TryGetValue(segment, out var propertyName)) + return Result.Fail($"Invalid property '{segment}' in path"); + + current = Expression.Property(current, propertyName); + + // Add null check for intermediate reference types only + if (i < propertyPath.Segments.Count - 1 && (!current.Type.IsValueType || Nullable.GetUnderlyingType(current.Type) != null)) + { + nullChecks.Add(Expression.NotEqual(current, Expression.Constant(null, current.Type))); + } + } + + var finalProperty = (MemberExpression)current; + + // Handle null comparisons + if (exp.Right is NullLiteral) + { + var nullComparison = exp.Operator == Keywords.Eq + ? Expression.Equal(finalProperty, Expression.Constant(null, finalProperty.Type)) + : Expression.NotEqual(finalProperty, Expression.Constant(null, finalProperty.Type)); + + return CombineWithNullChecks(nullComparison, nullChecks); + } + + // Handle value comparisons + var comparisonResult = EvaluateInfixExpression(exp, finalProperty); + if (comparisonResult.IsFailed) return comparisonResult; + + return CombineWithNullChecks(comparisonResult.Value, nullChecks); + } + + private static Result CombineWithNullChecks(Expression comparison, List nullChecks) + { + if (!nullChecks.Any()) + { + return comparison; + } + + var allNullChecks = nullChecks.Aggregate(Expression.AndAlso); + + return Expression.AndAlso(allNullChecks, comparison); + } + + private static Result EvaluateInfixExpression(InfixExpression exp, MemberExpression property) + { + var valueResult = CreateConstantExpression(exp.Right, property); + if (valueResult.IsFailed) return Result.Fail(valueResult.Errors); + + var (value, updatedProperty) = valueResult.Value; + + // Handle special nullable DateTime comparison + if (updatedProperty.Type == typeof(DateTime?) && exp.Right is DateLiteral) + { + var hasValueProperty = Expression.Property(updatedProperty, "HasValue"); + var valueProperty = Expression.Property(updatedProperty, "Value"); + var dateProperty = Expression.Property(valueProperty, "Date"); + + Expression dateComparison = exp.Operator 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 nullable date comparison: {exp.Operator}") + }; + + return exp.Operator == Keywords.Ne + ? Expression.OrElse(Expression.Not(hasValueProperty), dateComparison) + : Expression.AndAlso(hasValueProperty, dateComparison); + } + + return exp.Operator switch + { + Keywords.Eq => Expression.Equal(updatedProperty, value), + Keywords.Ne => Expression.NotEqual(updatedProperty, value), + Keywords.Contains => CreateContainsExpression(updatedProperty, value), + Keywords.Lt => Expression.LessThan(updatedProperty, value), + Keywords.Lte => Expression.LessThanOrEqual(updatedProperty, value), + Keywords.Gt => Expression.GreaterThan(updatedProperty, value), + Keywords.Gte => Expression.GreaterThanOrEqual(updatedProperty, value), + _ => Result.Fail($"Unsupported operator: {exp.Operator}") + }; + } + + private static Result<(ConstantExpression Value, MemberExpression Property)> CreateConstantExpression(QueryExpression literal, MemberExpression property) + { + return literal switch + { + IntegerLiteral intLit => CreateIntegerConstant(intLit.Value, property), + DateLiteral dateLit => Result.Ok(CreateDateConstant(dateLit, property)), + GuidLiteral guidLit => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(guidLit.Value, property.Type), property)), + DecimalLiteral decLit => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(decLit.Value, property.Type), property)), + FloatLiteral floatLit => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(floatLit.Value, property.Type), property)), + DoubleLiteral dblLit => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(dblLit.Value, property.Type), property)), + StringLiteral strLit => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(strLit.Value, property.Type), property)), + DateTimeLiteral dtLit => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(dtLit.Value, property.Type), property)), + BooleanLiteral boolLit => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(boolLit.Value, property.Type), property)), + NullLiteral _ => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(null, property.Type), property)), + _ => Result.Fail($"Unsupported literal type: {literal.GetType().Name}") + }; + } + + private static Result<(ConstantExpression, MemberExpression)> CreateIntegerConstant(int value, MemberExpression property) + { + var integerConstant = GetIntegerExpressionConstant(value, property.Type); + if (integerConstant.IsFailed) + { + return Result.Fail(integerConstant.Errors); + } + + return Result.Ok<(ConstantExpression, MemberExpression)>((integerConstant.Value, property)); + } + + private static (ConstantExpression, MemberExpression) CreateDateConstant(DateLiteral dateLiteral, MemberExpression property) + { + if (property.Type == typeof(DateTime?)) + { + return (Expression.Constant(dateLiteral.Value.Date, typeof(DateTime)), property); + } + else + { + var dateProperty = Expression.Property(property, "Date"); + return (Expression.Constant(dateLiteral.Value.Date, dateProperty.Type), dateProperty); + } + } + + private static Expression CreateContainsExpression(MemberExpression property, ConstantExpression value) + { + var toLowerMethod = typeof(string).GetMethod("ToLower", Type.EmptyTypes); + var propertyToLower = Expression.Call(property, toLowerMethod); + var valueToLower = Expression.Call(value, toLowerMethod); + var containsMethod = typeof(string).GetMethod("Contains", new[] { typeof(string) }); + return Expression.Call(propertyToLower, containsMethod, valueToLower); + } + public static Result Evaluate(QueryExpression expression, ParameterExpression parameterExpression, Dictionary propertyMapping) { switch (expression) { - case InfixExpression exp: - if (exp.Left.GetType() == typeof(Identifier)) + case InfixExpression exp when exp.Left is PropertyPath: + var propertyPath = exp.Left as PropertyPath; + return EvaluatePropertyPathExpression(exp, propertyPath, parameterExpression, propertyMapping); + + case InfixExpression exp when exp.Left is Identifier: + if (!propertyMapping.TryGetValue(exp.Left.TokenLiteral(), out var propertyName)) { - if (!propertyMapping.TryGetValue(exp.Left.TokenLiteral(), out var propertyName)) - { - return Result.Fail($"Invalid property '{exp.Left.TokenLiteral()}' within filter"); - } - - var property = Expression.Property(parameterExpression, propertyName); - - ConstantExpression value; - - switch (exp.Right) - { - case GuidLiteral literal: - value = Expression.Constant(literal.Value, property.Type); - break; - case IntegerLiteral literal: - var integerConstant = GetIntegerExpressionConstant(literal.Value, property.Type); - if (integerConstant.IsFailed) - { - return Result.Fail(integerConstant.Errors); - } - - value = integerConstant.Value; - break; - case DecimalLiteral literal: - value = Expression.Constant(literal.Value, property.Type); - break; - case FloatLiteral literal: - value = Expression.Constant(literal.Value, property.Type); - break; - case DoubleLiteral literal: - value = Expression.Constant(literal.Value, property.Type); - break; - case StringLiteral literal: - value = Expression.Constant(literal.Value, property.Type); - break; - case DateTimeLiteral literal: - value = Expression.Constant(literal.Value, property.Type); - break; - case DateLiteral literal: - if (property.Type == typeof(DateTime?)) - { - value = Expression.Constant(literal.Value.Date, typeof(DateTime)); - } - else - { - property = Expression.Property(property, "Date"); - value = Expression.Constant(literal.Value.Date, property.Type); - } - break; - case NullLiteral _: - value = Expression.Constant(null, property.Type); - break; - case BooleanLiteral literal: - value = Expression.Constant(literal.Value, property.Type); - break; - default: - return Result.Fail($"Unsupported literal type: {exp.Right.GetType().Name}"); - } - - // Check if we need special nullable handling - var specialComparison = CreateNullableComparison(property, value, exp.Right, exp.Operator); - if (specialComparison != null) - { - return specialComparison; - } - - switch (exp.Operator) - { - case Keywords.Eq: - return Expression.Equal(property, value); - case Keywords.Ne: - return Expression.NotEqual(property, value); - case Keywords.Contains: - var identifier = (Identifier)exp.Left; - - var toLowerMethod = identifier.Value.GetType().GetMethod("ToLower", Type.EmptyTypes); - - var propertyToLower = Expression.Call(property, toLowerMethod); - var valueToLower = Expression.Call(value, toLowerMethod); - - var containsMethod = identifier.Value.GetType().GetMethod("Contains", new[] { value?.Value.GetType() }); - - return Expression.Call(propertyToLower, containsMethod, valueToLower); - case Keywords.Lt: - return Expression.LessThan(property, value); - case Keywords.Lte: - return Expression.LessThanOrEqual(property, value); - case Keywords.Gt: - return Expression.GreaterThan(property, value); - case Keywords.Gte: - return Expression.GreaterThanOrEqual(property, value); - default: - return Result.Fail($"Unsupported operator: {exp.Operator}"); - } + return Result.Fail($"Invalid property '{exp.Left.TokenLiteral()}' within filter"); } + var identifierProperty = Expression.Property(parameterExpression, propertyName); + return EvaluateInfixExpression(exp, identifierProperty); + case InfixExpression exp: + // Handle logical operators (AND/OR) var left = Evaluate(exp.Left, parameterExpression, propertyMapping); if (left.IsFailed) { @@ -127,45 +192,14 @@ public static Result Evaluate(QueryExpression expression, ParameterE return Expression.AndAlso(left.Value, right.Value); case Keywords.Or: return Expression.OrElse(left.Value, right.Value); + default: + return Result.Fail($"Unsupported logical operator: {exp.Operator}"); } - - break; - } - - return null; - } - - private static Expression CreateNullableComparison(MemberExpression property, ConstantExpression value, QueryExpression rightExpression, string operatorKeyword) - { - if (property.Type == typeof(DateTime?) && rightExpression is DateLiteral) - { - return CreateNullableDateComparison(property, value, operatorKeyword); } return null; } - private static Expression CreateNullableDateComparison(MemberExpression property, ConstantExpression value, string operatorKeyword) - { - var hasValueProperty = Expression.Property(property, "HasValue"); - var valueProperty = Expression.Property(property, "Value"); - var dateProperty = Expression.Property(valueProperty, "Date"); - - Expression dateComparison = 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 nullable date comparison: {operatorKeyword}") - }; - - return operatorKeyword == Keywords.Ne - ? Expression.OrElse(Expression.Not(hasValueProperty), dateComparison) - : Expression.AndAlso(hasValueProperty, dateComparison); - } private static Result GetIntegerExpressionConstant(int value, Type targetType) { diff --git a/src/GoatQuery/src/Lexer/Lexer.cs b/src/GoatQuery/src/Lexer/Lexer.cs index f06c62f..f5eba3a 100644 --- a/src/GoatQuery/src/Lexer/Lexer.cs +++ b/src/GoatQuery/src/Lexer/Lexer.cs @@ -48,6 +48,9 @@ public Token NextToken() case ')': token = new Token(TokenType.RPAREN, _character); break; + case '/': + token = new Token(TokenType.SLASH, _character); + break; case '\'': token.Type = TokenType.STRING; token.Literal = ReadString(); diff --git a/src/GoatQuery/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs index e1379a8..edd4221 100644 --- a/src/GoatQuery/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -129,7 +129,33 @@ private Result ParseGroupedExpression() private Result ParseFilterStatement() { - var identifier = new Identifier(_currentToken, _currentToken.Literal); + QueryExpression leftExpression; + + if (_peekToken.Type == TokenType.SLASH) + { + // We are filtering by a property on an object + 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"); + } + + segments.Add(_currentToken.Literal); + } + + leftExpression = new PropertyPath(startToken, segments); + } + else + { + leftExpression = new Identifier(_currentToken, _currentToken.Literal); + } if (!PeekIdentifierIn(Keywords.Eq, Keywords.Ne, Keywords.Contains, Keywords.Lt, Keywords.Lte, Keywords.Gt, Keywords.Gte)) { @@ -138,7 +164,7 @@ private Result ParseFilterStatement() NextToken(); - var statement = new InfixExpression(_currentToken, identifier, _currentToken.Literal); + 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)) { diff --git a/src/GoatQuery/src/Token/Token.cs b/src/GoatQuery/src/Token/Token.cs index 74676b0..53e23f1 100644 --- a/src/GoatQuery/src/Token/Token.cs +++ b/src/GoatQuery/src/Token/Token.cs @@ -15,6 +15,7 @@ public enum TokenType BOOLEAN, LPAREN, RPAREN, + SLASH } public static class Keywords diff --git a/src/GoatQuery/tests/Filter/FilterLexerTest.cs b/src/GoatQuery/tests/Filter/FilterLexerTest.cs index 7c0aaac..3c5d744 100644 --- a/src/GoatQuery/tests/Filter/FilterLexerTest.cs +++ b/src/GoatQuery/tests/Filter/FilterLexerTest.cs @@ -437,6 +437,34 @@ public static IEnumerable Parameters() 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"), + } + }; } [Theory] diff --git a/src/GoatQuery/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs index f807cf2..9de2e5b 100644 --- a/src/GoatQuery/tests/Filter/FilterParserTest.cs +++ b/src/GoatQuery/tests/Filter/FilterParserTest.cs @@ -164,4 +164,25 @@ public void Test_ParsingFilterStatementWithAndAndOr() 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()); + } } \ No newline at end of file diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 1b7ba0d..272a6d6 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -2,22 +2,11 @@ public sealed class FilterTest { - private static readonly Dictionary _users = new Dictionary - { - ["John"] = new User { Age = 2, Firstname = "John", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2004-01-31 23:59:59"), BalanceDecimal = 1.50m, IsEmailVerified = true }, - ["Jane"] = new User { Age = 1, Firstname = "Jane", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2020-05-09 15:30:00"), BalanceDecimal = 0, IsEmailVerified = false }, - ["Apple"] = new User { Age = 2, Firstname = "Apple", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("1980-12-31 00:00:01"), BalanceFloat = 1204050.98f, IsEmailVerified = true }, - ["Harry"] = new User { Age = 1, Firstname = "Harry", UserId = Guid.Parse("e4c7772b-8947-4e46-98ed-644b417d2a08"), DateOfBirth = DateTime.Parse("2002-08-01"), BalanceDecimal = 0.5372958205929493m, IsEmailVerified = false }, - ["Doe"] = new User { Age = 3, Firstname = "Doe", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2023-07-26 12:00:30"), BalanceDecimal = null, IsEmailVerified = true }, - ["Egg"] = new User { Age = 3, Firstname = "Egg", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2000-01-01 00:00:00"), BalanceDouble = 1334534453453433.33435443343231235652d, IsEmailVerified = false }, - ["NullUser"] = new User { Age = 4, Firstname = "NullUser", UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), DateOfBirth = null, BalanceDecimal = null, BalanceDouble = null, BalanceFloat = null, IsEmailVerified = true }, - }; - public static IEnumerable Parameters() { yield return new object[] { "firstname eq 'John'", - new[] { _users["John"] } + new[] { TestData.Users["John"] } }; yield return new object[] { @@ -27,7 +16,7 @@ public static IEnumerable Parameters() yield return new object[] { "Age eq 1", - new[] { _users["Jane"], _users["Harry"] } + new[] { TestData.Users["Jane"], TestData.Users["Harry"] } }; yield return new object[] { @@ -37,77 +26,77 @@ public static IEnumerable Parameters() yield return new object[] { "firstname eq 'John' and Age eq 2", - new[] { _users["John"] } + new[] { TestData.Users["John"] } }; yield return new object[] { "firstname eq 'John' or Age eq 3", - new[] { _users["John"], _users["Doe"], _users["Egg"] } + new[] { TestData.Users["John"], TestData.Users["Doe"], TestData.Users["Egg"] } }; yield return new object[] { "Age eq 1 and firstName eq 'Harry' or Age eq 2", - new[] { _users["John"], _users["Apple"], _users["Harry"] } + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Harry"] } }; yield return new object[] { "Age eq 1 or Age eq 2 or firstName eq 'Egg'", - new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"], _users["Egg"] } + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Egg"] } }; yield return new object[] { "Age ne 3", - new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"], _users["NullUser"] } + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["NullUser"] } }; yield return new object[] { "firstName contains 'a'", - new[] { _users["Jane"], _users["Apple"], _users["Harry"] } + new[] { TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"] } }; yield return new object[] { "Age ne 1 and firstName contains 'a'", - new[] { _users["Apple"] } + new[] { TestData.Users["Apple"] } }; yield return new object[] { "Age ne 1 and firstName contains 'a' or firstName eq 'Apple'", - new[] { _users["Apple"] } + new[] { TestData.Users["Apple"] } }; yield return new object[] { "Firstname eq 'John' and Age eq 2 or Age eq 3", - new[] { _users["John"], _users["Doe"], _users["Egg"] } + new[] { TestData.Users["John"], TestData.Users["Doe"], TestData.Users["Egg"] } }; yield return new object[] { "(Firstname eq 'John' and Age eq 2) or Age eq 3", - new[] { _users["John"], _users["Doe"], _users["Egg"] } + new[] { TestData.Users["John"], TestData.Users["Doe"], TestData.Users["Egg"] } }; yield return new object[] { "Firstname eq 'John' and (Age eq 2 or Age eq 3)", - new[] { _users["John"] } + new[] { TestData.Users["John"] } }; yield return new object[] { "(Firstname eq 'John' and Age eq 2 or Age eq 3)", - new[] { _users["John"], _users["Doe"], _users["Egg"] } + new[] { TestData.Users["John"], TestData.Users["Doe"], TestData.Users["Egg"] } }; yield return new object[] { "(Firstname eq 'John') or (Age eq 3 and Firstname eq 'Egg') or Age eq 1 and (Age eq 2)", - new[] { _users["John"], _users["Egg"] } + new[] { TestData.Users["John"], TestData.Users["Egg"] } }; yield return new object[] { "UserId eq e4c7772b-8947-4e46-98ed-644b417d2a08", - new[] { _users["Harry"] } + new[] { TestData.Users["Harry"] } }; yield return new object[] { "age lt 3", - new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"] } + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"] } }; yield return new object[] { @@ -117,52 +106,52 @@ public static IEnumerable Parameters() yield return new object[] { "age lte 2", - new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"] } + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"] } }; yield return new object[] { "age gt 1", - new[] { _users["John"], _users["Apple"], _users["Doe"], _users["Egg"], _users["NullUser"] } + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["Egg"], TestData.Users["NullUser"] } }; yield return new object[] { "age gte 3", - new[] { _users["Doe"], _users["Egg"], _users["NullUser"] } + new[] { TestData.Users["Doe"], TestData.Users["Egg"], TestData.Users["NullUser"] } }; yield return new object[] { "age lt 3 and age gt 1", - new[] { _users["John"], _users["Apple"] } + new[] { TestData.Users["John"], TestData.Users["Apple"] } }; yield return new object[] { "balanceDecimal eq 1.50m", - new[] { _users["John"] } + new[] { TestData.Users["John"] } }; yield return new object[] { "balanceDecimal gt 1m", - new[] { _users["John"] } + new[] { TestData.Users["John"] } }; yield return new object[] { "balanceDecimal gt 0.50m", - new[] { _users["John"], _users["Harry"] } + new[] { TestData.Users["John"], TestData.Users["Harry"] } }; yield return new object[] { "balanceDecimal eq 0.5372958205929493m", - new[] { _users["Harry"] } + new[] { TestData.Users["Harry"] } }; yield return new object[] { "balanceDouble eq 1334534453453433.33435443343231235652d", - new[] { _users["Egg"] } + new[] { TestData.Users["Egg"] } }; yield return new object[] { "balanceFloat eq 1204050.98f", - new[] { _users["Apple"] } + new[] { TestData.Users["Apple"] } }; yield return new object[] { @@ -172,127 +161,217 @@ public static IEnumerable Parameters() yield return new object[] { "dateOfBirth eq 2000-01-01", - new[] { _users["Egg"] } + new[] { TestData.Users["Egg"] } }; yield return new object[] { "dateOfBirth eq 2020-05-09", - new[] { _users["Jane"] } + new[] { TestData.Users["Jane"] } }; yield return new object[] { "dateOfBirth lt 2010-01-01", - new[] { _users["John"], _users["Apple"], _users["Harry"], _users["Egg"] } + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Egg"] } }; yield return new object[] { "dateOfBirth lte 2002-08-01", - new[] { _users["Apple"], _users["Harry"], _users["Egg"] } + new[] { TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Egg"] } }; yield return new object[] { "dateOfBirth gt 2000-08-01 and dateOfBirth lt 2023-01-01", - new[] { _users["John"], _users["Jane"], _users["Harry"] } + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Harry"] } }; yield return new object[] { "dateOfBirth eq 2023-07-26T12:00:30Z", - new[] { _users["Doe"] } + new[] { TestData.Users["Doe"] } }; yield return new object[] { "dateOfBirth gte 2000-01-01", - new[] { _users["John"], _users["Jane"], _users["Harry"], _users["Doe"], _users["Egg"] } + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["Egg"] } }; yield return new object[] { "dateOfBirth gte 2000-01-01 and dateOfBirth lte 2020-05-09T15:29:59", - new[] { _users["John"], _users["Harry"], _users["Egg"] } + new[] { TestData.Users["John"], TestData.Users["Harry"], TestData.Users["Egg"] } }; yield return new object[] { "balanceDecimal eq null", - new[] { _users["Apple"], _users["Doe"], _users["Egg"], _users["NullUser"] } + new[] { TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["Egg"], TestData.Users["NullUser"] } }; yield return new object[] { "balanceDecimal ne null", - new[] { _users["John"], _users["Jane"], _users["Harry"] } + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Harry"] } }; yield return new object[] { "balanceDouble eq null", - new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"], _users["Doe"], _users["NullUser"] } + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["NullUser"] } }; yield return new object[] { "balanceDouble ne null", - new[] { _users["Egg"] } + new[] { TestData.Users["Egg"] } }; yield return new object[] { "balanceFloat eq null", - new[] { _users["John"], _users["Jane"], _users["Harry"], _users["Doe"], _users["Egg"], _users["NullUser"] } + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["Egg"], TestData.Users["NullUser"] } }; yield return new object[] { "balanceFloat ne null", - new[] { _users["Apple"] } + new[] { TestData.Users["Apple"] } }; yield return new object[] { "dateOfBirth eq null", - new[] { _users["NullUser"] } + new[] { TestData.Users["NullUser"] } }; yield return new object[] { "dateOfBirth ne null", - new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"], _users["Doe"], _users["Egg"] } + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["Egg"] } }; yield return new object[] { "balanceDecimal eq null and age gt 3", - new[] { _users["NullUser"] } + new[] { TestData.Users["NullUser"] } }; yield return new object[] { "balanceDecimal ne null or age eq 4", - new[] { _users["John"], _users["Jane"], _users["Harry"], _users["NullUser"] } + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["NullUser"] } }; yield return new object[] { "firstname eq 'Doe' and balanceDecimal eq null", - new[] { _users["Doe"] } + new[] { TestData.Users["Doe"] } }; yield return new object[] { "isEmailVerified eq true", - new[] { _users["John"], _users["Apple"], _users["Doe"], _users["NullUser"] } + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["NullUser"] } }; yield return new object[] { "isEmailVerified eq false", - new[] { _users["Jane"], _users["Harry"], _users["Egg"] } + new[] { TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["Egg"] } }; yield return new object[] { "isEmailVerified ne true", - new[] { _users["Jane"], _users["Harry"], _users["Egg"] } + new[] { TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["Egg"] } }; yield return new object[] { "isEmailVerified ne false", - new[] { _users["John"], _users["Apple"], _users["Doe"], _users["NullUser"] } + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["NullUser"] } }; yield return new object[] { "age gt 2 and isEmailVerified eq true", - new[] { _users["Doe"], _users["NullUser"] } + new[] { TestData.Users["Doe"], TestData.Users["NullUser"] } }; yield return new object[] { "isEmailVerified eq false or age eq 2", - new[] { _users["John"], _users["Jane"], _users["Apple"], _users["Harry"], _users["Egg"] } + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "manager/firstName eq 'Manager 01'", + new[] { TestData.Users["John"], TestData.Users["Apple"] } + }; + + yield return new object[] { + "manager/firstName ne 'Manager 01'", + new[] { TestData.Users["Egg"] } + }; + + yield return new object[] { + "manager/firstName contains 'Manager'", + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "manager/firstName eq 'Manager 02'", + new[] { TestData.Users["Egg"] } + }; + + yield return new object[] { + "manager/isEmailVerified eq true", + new[] { TestData.Users["Apple"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "manager/age gt 16", + new[] { TestData.Users["Egg"] } + }; + + yield return new object[] { + "manager/manager/firstName eq 'Manager 03'", + new[] { TestData.Users["Egg"] } + }; + + yield return new object[] { + "manager/manager/firstName ne 'Manager 03'", + new List() + }; + + yield return new object[] { + "manager eq null", + new[] { TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["NullUser"] } + }; + + yield return new object[] { + "manager/manager ne null", + new[] { TestData.Users["Egg"] } + }; + + yield return new object[] { + "manager/firstName eq 'Manager 01' and manager/age eq 16", + new[] { TestData.Users["John"], TestData.Users["Apple"] } + }; + + yield return new object[] { + "manager/dateOfBirth lt 2000-01-01", + new[] { TestData.Users["Egg"] } + }; + + yield return new object[] { + "manager/balanceDecimal gte 2.00m and manager/balanceDecimal lt 20m", + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "manager/userId eq 671e6bac-b6de-4cc7-b3e9-1a6ac4546b43", + new[] { TestData.Users["John"], TestData.Users["Apple"] } + }; + + yield return new object[] { + "(age eq 2 and manager/isEmailVerified eq true) or (age eq 3 and manager/manager ne null)", + new[] { TestData.Users["Apple"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "manager ne null and manager/manager eq null", + new[] { TestData.Users["John"], TestData.Users["Apple"] } + }; + + yield return new object[] { + "manager/balanceDecimal gt 100m", + Array.Empty() + }; + + yield return new object[] { + "manager/manager/manager/firstName eq 'Manager 04'", + new[] { TestData.Users["Egg"] } }; } @@ -305,13 +384,16 @@ public void Test_Filter(string filter, IEnumerable expected) Filter = filter }; - var result = _users.Values.AsQueryable().Apply(query); + var result = TestData.Users.Values.AsQueryable().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'")] public void Test_InvalidFilterReturnsError(string filter) { var query = new Query @@ -319,7 +401,7 @@ public void Test_InvalidFilterReturnsError(string filter) Filter = filter }; - var result = _users.Values.AsQueryable().Apply(query); + var result = TestData.Users.Values.AsQueryable().Apply(query); Assert.True(result.IsFailed); } diff --git a/src/GoatQuery/tests/TestData.cs b/src/GoatQuery/tests/TestData.cs new file mode 100644 index 0000000..6631058 --- /dev/null +++ b/src/GoatQuery/tests/TestData.cs @@ -0,0 +1,116 @@ +public static class TestData +{ + public static readonly Dictionary Users = new Dictionary + { + ["John"] = new User + { + Age = 2, + Firstname = "John", + UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), + DateOfBirth = DateTime.Parse("2004-01-31 23:59:59"), + BalanceDecimal = 1.50m, + IsEmailVerified = true, + Manager = new User + { + Age = 16, + Firstname = "Manager 01", + UserId = Guid.Parse("671e6bac-b6de-4cc7-b3e9-1a6ac4546b43"), + DateOfBirth = DateTime.Parse("2000-01-01 00:00:00"), + BalanceDecimal = 2.00m, + IsEmailVerified = false + } + }, + ["Jane"] = new User + { + Age = 1, + Firstname = "Jane", + UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), + DateOfBirth = DateTime.Parse("2020-05-09 15:30:00"), + BalanceDecimal = 0, + IsEmailVerified = false + }, + ["Apple"] = new User + { + Age = 2, + Firstname = "Apple", + UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), + DateOfBirth = DateTime.Parse("1980-12-31 00:00:01"), + BalanceFloat = 1204050.98f, + IsEmailVerified = true, + Manager = new User + { + Age = 16, + Firstname = "Manager 01", + UserId = Guid.Parse("671e6bac-b6de-4cc7-b3e9-1a6ac4546b43"), + DateOfBirth = DateTime.Parse("2000-01-01 00:00:00"), + BalanceDecimal = 2.00m, + IsEmailVerified = true + } + }, + ["Harry"] = new User + { + Age = 1, + Firstname = "Harry", + UserId = Guid.Parse("e4c7772b-8947-4e46-98ed-644b417d2a08"), + DateOfBirth = DateTime.Parse("2002-08-01"), + BalanceDecimal = 0.5372958205929493m, + IsEmailVerified = false + }, + ["Doe"] = new User + { + Age = 3, + Firstname = "Doe", + UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), + DateOfBirth = DateTime.Parse("2023-07-26 12:00:30"), + BalanceDecimal = null, + IsEmailVerified = true + }, + ["Egg"] = new User + { + Age = 3, + Firstname = "Egg", + UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), + DateOfBirth = DateTime.Parse("2000-01-01 00:00:00"), + BalanceDouble = 1334534453453433.33435443343231235652d, + IsEmailVerified = false, + Manager = new User + { + Age = 18, + Firstname = "Manager 02", + UserId = Guid.Parse("2bde56ac-4829-41fb-abbc-2b8454962e2a"), + DateOfBirth = DateTime.Parse("1999-04-21 00:00:00"), + BalanceDecimal = 19.00m, + IsEmailVerified = true, + Manager = new User + { + Age = 30, + Firstname = "Manager 03", + UserId = Guid.Parse("8ef23728-c429-42f9-98ee-425419092664"), + DateOfBirth = DateTime.Parse("1993-04-21 00:00:00"), + BalanceDecimal = 29.00m, + IsEmailVerified = true, + Manager = new User + { + Age = 40, + Firstname = "Manager 04", + UserId = Guid.Parse("4cde56ac-4829-41fb-abbc-2b8454962e2a"), + DateOfBirth = DateTime.Parse("1983-04-21 00:00:00"), + BalanceDecimal = 39.00m, + IsEmailVerified = true + } + } + } + }, + ["NullUser"] = new User + { + Age = 4, + Firstname = "NullUser", + UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), + DateOfBirth = null, + BalanceDecimal = null, + BalanceDouble = null, + BalanceFloat = null, + IsEmailVerified = true + }, + }; +} \ No newline at end of file diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs index 96ca392..4dc3414 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -10,6 +10,7 @@ public record User public float? BalanceFloat { get; set; } public DateTime? DateOfBirth { get; set; } public bool IsEmailVerified { get; set; } + public User? Manager { get; set; } } public sealed record CustomJsonPropertyUser : User From 14a756e8061f53448464330d0b64aafea7f8e9e2 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:29:28 +0100 Subject: [PATCH 46/60] updated tests and readme --- README.md | 130 ++++++++++++++++++++++- example/Controllers/UserController.cs | 3 + example/Program.cs | 2 + src/GoatQuery/tests/Filter/FilterTest.cs | 54 +++++----- src/GoatQuery/tests/TestData.cs | 8 +- 5 files changed, 165 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 9c26a9c..bdc9ea1 100644 --- a/README.md +++ b/README.md @@ -1 +1,129 @@ -# GoatQuery .NET +# GoatQuery + +A .NET library for parsing OData-style query parameters into LINQ expressions. Enables database-level filtering, sorting, and pagination from HTTP query strings. + +## Installation + +```bash +dotnet add package GoatQuery +dotnet add package GoatQuery.AspNetCore # For ASP.NET Core integration +``` + +## Quick Start + +```csharp +// Basic usage +var users = dbContext.Users + .Apply(new Query { Filter = "age gt 18 and isActive eq true" }) + .Value.Results; + +// ASP.NET Core +[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?$orderby=lastName asc, firstName desc +GET /api/users?$top=10&$skip=20&$count=true +GET /api/users?$search=john +``` + +## Filtering + +### Operators +- **Comparison**: `eq`, `ne`, `gt`, `ge`, `lt`, `le` +- **Logical**: `and`, `or` +- **String**: `contains` + +### Data Types +- String: `'value'` +- Numbers: `42`, `3.14f`, `2.5m`, `1.0d` +- Boolean: `true`, `false` +- DateTime: `2023-12-25T10:30:00Z` +- GUID: `123e4567-e89b-12d3-a456-426614174000` +- Null: `null` + +### Examples +```csharp +"age gt 18" +"firstName eq 'John' and isActive ne false" +"salary ge 50000 or department eq 'Engineering'" +"name contains 'smith'" +``` + +## Property Mapping + +Supports `JsonPropertyName` attributes: + +```csharp +public class UserDto +{ + [JsonPropertyName("first_name")] + public string FirstName { get; set; } + + public int Age { get; set; } // Maps to "age" +} +``` + +Query: `$filter=first_name eq 'John' and age gt 18` + +## 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); +} +``` + +## 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.Results; +var count = result.Value.Count; // If Count = true +``` + +## Development + +```bash +dotnet test ./src/GoatQuery/tests +dotnet build --configuration Release +cd example && dotnet run +``` + +**Targets**: .NET Standard 2.0/2.1, .NET 6.0+ \ No newline at end of file diff --git a/example/Controllers/UserController.cs b/example/Controllers/UserController.cs index 908d658..44533c9 100644 --- a/example/Controllers/UserController.cs +++ b/example/Controllers/UserController.cs @@ -1,6 +1,7 @@ using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; [ApiController] [Route("controller/[controller]")] @@ -22,6 +23,8 @@ public UsersController(ApplicationDbContext db, IMapper mapper) public ActionResult> Get() { var users = _db.Users + .Include(x => x.Manager) + .ThenInclude(x => x.Manager) .Where(x => !x.IsDeleted) .ProjectTo(_mapper.ConfigurationProvider); diff --git a/example/Program.cs b/example/Program.cs index 192c14e..7d0f635 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -65,6 +65,8 @@ app.MapGet("/minimal/users", (ApplicationDbContext db, [FromServices] IMapper mapper, [AsParameters] Query query) => { var result = db.Users + .Include(x => x.Manager) + .ThenInclude(x => x.Manager) .Where(x => !x.IsDeleted) .ProjectTo(mapper.ConfigurationProvider) .Apply(query); diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 272a6d6..223c16e 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -16,7 +16,7 @@ public static IEnumerable Parameters() yield return new object[] { "Age eq 1", - new[] { TestData.Users["Jane"], TestData.Users["Harry"] } + new[] { TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"] } }; yield return new object[] { @@ -30,23 +30,23 @@ public static IEnumerable Parameters() }; yield return new object[] { - "firstname eq 'John' or Age eq 3", - new[] { TestData.Users["John"], TestData.Users["Doe"], TestData.Users["Egg"] } + "firstname eq 'John' or Age eq 33", + new[] { TestData.Users["John"], TestData.Users["Egg"] } }; yield return new object[] { "Age eq 1 and firstName eq 'Harry' or Age eq 2", - new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Harry"] } + new[] { TestData.Users["John"], TestData.Users["Harry"] } }; yield return new object[] { "Age eq 1 or Age eq 2 or firstName eq 'Egg'", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Egg"] } + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["Egg"] } }; yield return new object[] { - "Age ne 3", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["NullUser"] } + "Age ne 33", + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["NullUser"] } }; yield return new object[] { @@ -56,36 +56,36 @@ public static IEnumerable Parameters() yield return new object[] { "Age ne 1 and firstName contains 'a'", - new[] { TestData.Users["Apple"] } + new[] { TestData.Users["Jane"] } }; yield return new object[] { "Age ne 1 and firstName contains 'a' or firstName eq 'Apple'", - new[] { TestData.Users["Apple"] } + new[] { TestData.Users["Jane"], TestData.Users["Apple"] } }; yield return new object[] { - "Firstname eq 'John' and Age eq 2 or Age eq 3", - new[] { TestData.Users["John"], TestData.Users["Doe"], TestData.Users["Egg"] } + "Firstname eq 'John' and Age eq 2 or Age eq 33", + new[] { TestData.Users["John"], TestData.Users["Egg"] } }; yield return new object[] { - "(Firstname eq 'John' and Age eq 2) or Age eq 3", - new[] { TestData.Users["John"], TestData.Users["Doe"], TestData.Users["Egg"] } + "(Firstname eq 'John' and Age eq 2) or Age eq 33", + new[] { TestData.Users["John"], TestData.Users["Egg"] } }; yield return new object[] { - "Firstname eq 'John' and (Age eq 2 or Age eq 3)", + "Firstname eq 'John' and (Age eq 2 or Age eq 33)", new[] { TestData.Users["John"] } }; yield return new object[] { - "(Firstname eq 'John' and Age eq 2 or Age eq 3)", - new[] { TestData.Users["John"], TestData.Users["Doe"], TestData.Users["Egg"] } + "(Firstname eq 'John' and Age eq 2 or Age eq 33)", + new[] { TestData.Users["John"], TestData.Users["Egg"] } }; yield return new object[] { - "(Firstname eq 'John') or (Age eq 3 and Firstname eq 'Egg') or Age eq 1 and (Age eq 2)", + "(Firstname eq 'John') or (Age eq 33 and Firstname eq 'Egg') or Age eq 1 and (Age eq 2)", new[] { TestData.Users["John"], TestData.Users["Egg"] } }; @@ -96,7 +96,7 @@ public static IEnumerable Parameters() yield return new object[] { "age lt 3", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"] } + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"] } }; yield return new object[] { @@ -106,22 +106,22 @@ public static IEnumerable Parameters() yield return new object[] { "age lte 2", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"] } + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"] } }; yield return new object[] { "age gt 1", - new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["Egg"], TestData.Users["NullUser"] } + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Egg"], TestData.Users["NullUser"] } }; yield return new object[] { "age gte 3", - new[] { TestData.Users["Doe"], TestData.Users["Egg"], TestData.Users["NullUser"] } + new[] { TestData.Users["Jane"], TestData.Users["Egg"], TestData.Users["NullUser"] } }; yield return new object[] { "age lt 3 and age gt 1", - new[] { TestData.Users["John"], TestData.Users["Apple"] } + new[] { TestData.Users["John"] } }; yield return new object[] { @@ -241,7 +241,7 @@ public static IEnumerable Parameters() yield return new object[] { "balanceDecimal eq null and age gt 3", - new[] { TestData.Users["NullUser"] } + new[] { TestData.Users["Egg"], TestData.Users["NullUser"] } }; yield return new object[] { @@ -276,12 +276,12 @@ public static IEnumerable Parameters() yield return new object[] { "age gt 2 and isEmailVerified eq true", - new[] { TestData.Users["Doe"], TestData.Users["NullUser"] } + new[] { TestData.Users["NullUser"] } }; yield return new object[] { "isEmailVerified eq false or age eq 2", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Egg"] } + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["Egg"] } }; yield return new object[] { @@ -355,8 +355,8 @@ public static IEnumerable Parameters() }; yield return new object[] { - "(age eq 2 and manager/isEmailVerified eq true) or (age eq 3 and manager/manager ne null)", - new[] { TestData.Users["Apple"], TestData.Users["Egg"] } + "(age eq 2 and manager/isEmailVerified eq true) or (age eq 33 and manager/manager ne null)", + new[] { TestData.Users["Egg"] } }; yield return new object[] { diff --git a/src/GoatQuery/tests/TestData.cs b/src/GoatQuery/tests/TestData.cs index 6631058..503ff28 100644 --- a/src/GoatQuery/tests/TestData.cs +++ b/src/GoatQuery/tests/TestData.cs @@ -22,7 +22,7 @@ public static class TestData }, ["Jane"] = new User { - Age = 1, + Age = 9, Firstname = "Jane", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2020-05-09 15:30:00"), @@ -31,7 +31,7 @@ public static class TestData }, ["Apple"] = new User { - Age = 2, + Age = 1, Firstname = "Apple", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("1980-12-31 00:00:01"), @@ -58,7 +58,7 @@ public static class TestData }, ["Doe"] = new User { - Age = 3, + Age = 1, Firstname = "Doe", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2023-07-26 12:00:30"), @@ -67,7 +67,7 @@ public static class TestData }, ["Egg"] = new User { - Age = 3, + Age = 33, Firstname = "Egg", UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2000-01-01 00:00:00"), From bec4e00b2b44d0292c9fd1caf5617a642e79cd2e Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:30:09 +0100 Subject: [PATCH 47/60] readme --- README.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index bdc9ea1..21dc72d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GoatQuery -A .NET library for parsing OData-style query parameters into LINQ expressions. Enables database-level filtering, sorting, and pagination from HTTP query strings. +A .NET library for parsing query parameters into LINQ expressions. Enables database-level filtering, sorting, and pagination from HTTP query strings. ## Installation @@ -27,7 +27,7 @@ public IActionResult GetUsers() => Ok(dbContext.Users); ``` GET /api/users?$filter=age gt 18 and isActive eq true -GET /api/users?$orderby=lastName asc, firstName desc +GET /api/users?$orderby=lastName asc, firstName desc GET /api/users?$top=10&$skip=20&$count=true GET /api/users?$search=john ``` @@ -35,11 +35,13 @@ GET /api/users?$search=john ## Filtering ### Operators + - **Comparison**: `eq`, `ne`, `gt`, `ge`, `lt`, `le` - **Logical**: `and`, `or` - **String**: `contains` ### Data Types + - String: `'value'` - Numbers: `42`, `3.14f`, `2.5m`, `1.0d` - Boolean: `true`, `false` @@ -48,6 +50,7 @@ GET /api/users?$search=john - Null: `null` ### Examples + ```csharp "age gt 18" "firstName eq 'John' and isActive ne false" @@ -64,7 +67,7 @@ public class UserDto { [JsonPropertyName("first_name")] public string FirstName { get; set; } - + public int Age { get; set; } // Maps to "age" } ``` @@ -79,7 +82,7 @@ Implement custom search logic: public class UserSearchBinder : ISearchBinder { public Expression> Bind(string searchTerm) => - user => user.FirstName.Contains(searchTerm) || + user => user.FirstName.Contains(searchTerm) || user.LastName.Contains(searchTerm); } @@ -89,6 +92,7 @@ var result = users.Apply(query, new UserSearchBinder()); ## ASP.NET Core Integration ### Action Filter + ```csharp [HttpGet] [EnableQuery(maxTop: 100)] @@ -96,6 +100,7 @@ public IActionResult GetUsers() => Ok(dbContext.Users); ``` ### Manual Processing + ```csharp [HttpGet] public IActionResult GetUsers([FromQuery] Query query) @@ -126,4 +131,4 @@ dotnet build --configuration Release cd example && dotnet run ``` -**Targets**: .NET Standard 2.0/2.1, .NET 6.0+ \ No newline at end of file +**Targets**: .NET Standard 2.0/2.1, .NET 6.0+ From c2d142f5be3d15bcb4629798a6fbdadd0650e031 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Fri, 5 Sep 2025 14:31:34 +0100 Subject: [PATCH 48/60] added lambda support for filtering collections --- README.md | 137 ++++- example/Dto/UserDto.cs | 13 + example/Entities/User.cs | 15 + example/Profiles/AutoMapperProfile.cs | 2 + example/Program.cs | 11 +- src/GoatQuery/src/Ast/LambdaExpression.cs | 19 + .../src/Evaluator/FilterEvaluationContext.cs | 38 ++ .../src/Evaluator/FilterEvaluator.cs | 477 +++++++++++++++--- .../src/Extensions/QueryableExtension.cs | 22 +- src/GoatQuery/src/Lexer/Lexer.cs | 156 +++--- src/GoatQuery/src/Parser/Parser.cs | 83 ++- src/GoatQuery/src/Token/Token.cs | 5 +- .../src/Utilities/PropertyMappingHelper.cs | 33 ++ src/GoatQuery/tests/Filter/FilterLexerTest.cs | 58 +++ .../tests/Filter/FilterParserTest.cs | 147 ++++++ src/GoatQuery/tests/Filter/FilterTest.cs | 95 ++++ src/GoatQuery/tests/TestData.cs | 65 ++- src/GoatQuery/tests/User.cs | 13 + 18 files changed, 1211 insertions(+), 178 deletions(-) create mode 100644 src/GoatQuery/src/Ast/LambdaExpression.cs create mode 100644 src/GoatQuery/src/Evaluator/FilterEvaluationContext.cs create mode 100644 src/GoatQuery/src/Utilities/PropertyMappingHelper.cs diff --git a/README.md b/README.md index 21dc72d..684e0c4 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,24 @@ dotnet add package GoatQuery.AspNetCore # For ASP.NET Core integration ## Quick Start ```csharp -// Basic usage +// Basic filtering var users = dbContext.Users .Apply(new Query { Filter = "age gt 18 and isActive eq true" }) .Value.Results; -// ASP.NET Core +// Lambda expressions for collection filtering +var usersWithLondonAddress = dbContext.Users + .Apply(new Query { Filter = "addresses/any(x: x/city eq 'London')" }) + .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.Results; + +// ASP.NET Core integration [HttpGet] [EnableQuery(maxTop: 100)] public IActionResult GetUsers() => Ok(dbContext.Users); @@ -27,6 +39,7 @@ public IActionResult GetUsers() => Ok(dbContext.Users); ``` 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?$top=10&$skip=20&$count=true GET /api/users?$search=john @@ -34,12 +47,42 @@ GET /api/users?$search=john ## Filtering -### Operators +### Basic Operators - **Comparison**: `eq`, `ne`, `gt`, `ge`, `lt`, `le` - **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'` @@ -52,15 +95,27 @@ GET /api/users?$search=john ### 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')" + +// 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)" ``` ## Property Mapping -Supports `JsonPropertyName` attributes: +Supports `JsonPropertyName` attributes for both simple and nested properties: ```csharp public class UserDto @@ -69,10 +124,82 @@ public class UserDto 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: `$filter=first_name eq 'John' and age gt 18` +**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' +``` + +**Property mapping works automatically** - GoatQuery maps JSON property names to .NET properties for all navigation paths and lambda expressions. + +## 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')" +``` + +### 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" +``` + +### Type Safety and Performance + +- **Strong typing**: All expressions are strongly typed using .NET reflection +- **Compiled expressions**: Queries compile to efficient LINQ expressions +- **Database translation**: Works with Entity Framework for database-level filtering +- **Memory efficiency**: Minimal allocations during expression building ## Search diff --git a/example/Dto/UserDto.cs b/example/Dto/UserDto.cs index 47d1aa0..3b8532e 100644 --- a/example/Dto/UserDto.cs +++ b/example/Dto/UserDto.cs @@ -14,4 +14,17 @@ public record UserDto public DateTime DateOfBirthUtc { get; set; } public DateTime DateOfBirthTz { get; set; } public User? Manager { get; set; } + public IEnumerable Addresses { get; set; } = Array.Empty(); +} + +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; } \ No newline at end of file diff --git a/example/Entities/User.cs b/example/Entities/User.cs index c3e4862..6d1413d 100644 --- a/example/Entities/User.cs +++ b/example/Entities/User.cs @@ -17,4 +17,19 @@ public record User [Column(TypeName = "timestamp without time zone")] public DateTime DateOfBirthTz { get; set; } public User? Manager { get; set; } + public IEnumerable
Addresses { 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; } \ No newline at end of file diff --git a/example/Profiles/AutoMapperProfile.cs b/example/Profiles/AutoMapperProfile.cs index cd2efb2..81dc995 100644 --- a/example/Profiles/AutoMapperProfile.cs +++ b/example/Profiles/AutoMapperProfile.cs @@ -5,5 +5,7 @@ public class AutoMapperProfile : Profile public AutoMapperProfile() { CreateMap(); + CreateMap(); + CreateMap(); } } \ No newline at end of file diff --git a/example/Program.cs b/example/Program.cs index 7d0f635..1264cc4 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -35,6 +35,14 @@ // Seed data if (!context.Users.Any()) { + 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) @@ -51,7 +59,8 @@ u.DateOfBirthUtc = date; u.DateOfBirthTz = TimeZoneInfo.ConvertTimeFromUtc(date, timeZone); }) - .RuleFor(x => x.Manager, (f, u) => f.CreateManager(3)); + .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()); context.Users.AddRange(users.Generate(1_000)); context.SaveChanges(); 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/Evaluator/FilterEvaluationContext.cs b/src/GoatQuery/src/Evaluator/FilterEvaluationContext.cs new file mode 100644 index 0000000..2e38ac6 --- /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 Dictionary PropertyMapping { get; } + public Stack LambdaScopes { get; } = new Stack(); + + public FilterEvaluationContext(ParameterExpression rootParameter, Dictionary propertyMapping) + { + RootParameter = rootParameter; + PropertyMapping = propertyMapping; + } + + 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 index 69e6e0f..228d3d1 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -2,52 +2,140 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; +using System.Reflection; 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, Dictionary propertyMapping) + { + if (expression == null) return Result.Fail("Expression cannot be null"); + if (parameterExpression == null) return Result.Fail("Parameter expression cannot be null"); + if (propertyMapping == null) return Result.Fail("Property mapping cannot be null"); + + var context = new FilterEvaluationContext(parameterExpression, propertyMapping); + 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, - ParameterExpression parameterExpression, + FilterEvaluationContext context) + { + var baseExpression = context.IsInLambdaScope ? + (Expression)context.CurrentLambda.Parameter : + context.RootParameter; + + var propertyPathResult = BuildPropertyPath(propertyPath, baseExpression, context.PropertyMapping); + if (propertyPathResult.IsFailed) return Result.Fail(propertyPathResult.Errors); + + var (finalProperty, nullChecks) = propertyPathResult.Value; + + if (exp.Right is NullLiteral) + { + var nullComparison = CreateNullComparison(exp, finalProperty); + return CombineWithNullChecks(nullComparison, nullChecks); + } + + var comparisonResult = EvaluateValueComparison(exp, finalProperty); + if (comparisonResult.IsFailed) return comparisonResult; + + return CombineWithNullChecks(comparisonResult.Value, nullChecks); + } + + private static Result<(MemberExpression Property, List NullChecks)> BuildPropertyPath( + PropertyPath propertyPath, + Expression startExpression, Dictionary propertyMapping) { - // Build property path with null checks for intermediate properties - var current = (Expression)parameterExpression; + var current = startExpression; var nullChecks = new List(); - for (int i = 0; i < propertyPath.Segments.Count; i++) + foreach (var (segment, isLast) in propertyPath.Segments.Select((s, i) => (s, i == propertyPath.Segments.Count - 1))) { - var segment = propertyPath.Segments[i]; if (!propertyMapping.TryGetValue(segment, out var propertyName)) return Result.Fail($"Invalid property '{segment}' in path"); current = Expression.Property(current, propertyName); - // Add null check for intermediate reference types only - if (i < propertyPath.Segments.Count - 1 && (!current.Type.IsValueType || Nullable.GetUnderlyingType(current.Type) != null)) + // Add null check for intermediate reference types only (not the final property) + if (!isLast && IsNullableReferenceType(current.Type)) { nullChecks.Add(Expression.NotEqual(current, Expression.Constant(null, current.Type))); } } - var finalProperty = (MemberExpression)current; + return Result.Ok(((MemberExpression)current, nullChecks)); + } - // Handle null comparisons - if (exp.Right is NullLiteral) - { - var nullComparison = exp.Operator == Keywords.Eq - ? Expression.Equal(finalProperty, Expression.Constant(null, finalProperty.Type)) - : Expression.NotEqual(finalProperty, Expression.Constant(null, finalProperty.Type)); + private static bool IsNullableReferenceType(Type type) + { + return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; + } - return CombineWithNullChecks(nullComparison, nullChecks); - } + 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)); + } - // Handle value comparisons - var comparisonResult = EvaluateInfixExpression(exp, finalProperty); - if (comparisonResult.IsFailed) return comparisonResult; + private static bool IsNullableDateTimeComparison(MemberExpression property, QueryExpression rightExpression) + { + return property.Type == typeof(DateTime?) && rightExpression is DateLiteral; + } - return CombineWithNullChecks(comparisonResult.Value, nullChecks); + 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 CombineWithNullChecks(Expression comparison, List nullChecks) @@ -62,46 +150,33 @@ private static Result CombineWithNullChecks(Expression comparison, L return Expression.AndAlso(allNullChecks, comparison); } - private static Result EvaluateInfixExpression(InfixExpression exp, MemberExpression property) + 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; - // Handle special nullable DateTime comparison - if (updatedProperty.Type == typeof(DateTime?) && exp.Right is DateLiteral) + if (IsNullableDateTimeComparison(updatedProperty, exp.Right)) { - var hasValueProperty = Expression.Property(updatedProperty, "HasValue"); - var valueProperty = Expression.Property(updatedProperty, "Value"); - var dateProperty = Expression.Property(valueProperty, "Date"); - - Expression dateComparison = exp.Operator 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 nullable date comparison: {exp.Operator}") - }; - - return exp.Operator == Keywords.Ne - ? Expression.OrElse(Expression.Not(hasValueProperty), dateComparison) - : Expression.AndAlso(hasValueProperty, dateComparison); + return CreateNullableDateTimeComparison(updatedProperty, value, exp.Operator); } - return exp.Operator switch + return CreateComparisonExpression(exp.Operator, updatedProperty, value); + } + + private static Result CreateComparisonExpression(string operatorKeyword, MemberExpression property, ConstantExpression value) + { + return operatorKeyword switch { - Keywords.Eq => Expression.Equal(updatedProperty, value), - Keywords.Ne => Expression.NotEqual(updatedProperty, value), - Keywords.Contains => CreateContainsExpression(updatedProperty, value), - Keywords.Lt => Expression.LessThan(updatedProperty, value), - Keywords.Lte => Expression.LessThanOrEqual(updatedProperty, value), - Keywords.Gt => Expression.GreaterThan(updatedProperty, value), - Keywords.Gte => Expression.GreaterThanOrEqual(updatedProperty, value), - _ => Result.Fail($"Unsupported operator: {exp.Operator}") + Keywords.Eq => Expression.Equal(property, value), + Keywords.Ne => Expression.NotEqual(property, value), + Keywords.Contains => CreateContainsExpression(property, value), + Keywords.Lt => Expression.LessThan(property, value), + Keywords.Lte => Expression.LessThanOrEqual(property, value), + Keywords.Gt => Expression.GreaterThan(property, value), + Keywords.Gte => Expression.GreaterThanOrEqual(property, value), + _ => Result.Fail($"Unsupported operator: {operatorKeyword}") }; } @@ -142,64 +217,300 @@ private static (ConstantExpression, MemberExpression) CreateDateConstant(DateLit } else { - var dateProperty = Expression.Property(property, "Date"); + var dateProperty = Expression.Property(property, DatePropertyName); return (Expression.Constant(dateLiteral.Value.Date, dateProperty.Type), dateProperty); } } private static Expression CreateContainsExpression(MemberExpression property, ConstantExpression value) { - var toLowerMethod = typeof(string).GetMethod("ToLower", Type.EmptyTypes); - var propertyToLower = Expression.Call(property, toLowerMethod); - var valueToLower = Expression.Call(value, toLowerMethod); - var containsMethod = typeof(string).GetMethod("Contains", new[] { typeof(string) }); - return Expression.Call(propertyToLower, containsMethod, valueToLower); + var propertyToLower = Expression.Call(property, StringToLowerMethod); + var valueToLower = Expression.Call(value, StringToLowerMethod); + return Expression.Call(propertyToLower, StringContainsMethod, valueToLower); } - public static Result Evaluate(QueryExpression expression, ParameterExpression parameterExpression, Dictionary propertyMapping) + 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.PropertyMapping.TryGetValue(identifier, out var propertyName)) + { + return Result.Fail($"Invalid property '{identifier}' within filter"); + } + + var baseExpression = context.IsInLambdaScope ? + (Expression)context.CurrentLambda.Parameter : + context.RootParameter; + + var identifierProperty = Expression.Property(baseExpression, propertyName); + 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) { - switch (expression) + var baseExpression = context.IsInLambdaScope ? + (Expression)context.CurrentLambda.Parameter : + context.RootParameter; + + var collectionResult = ResolveCollectionProperty(lambdaExp.Property, baseExpression, context.PropertyMapping); + if (collectionResult.IsFailed) return Result.Fail(collectionResult.Errors); + + var collectionProperty = collectionResult.Value; + var elementType = GetCollectionElementType(collectionProperty.Type); + + if (elementType == null) { - case InfixExpression exp when exp.Left is PropertyPath: - var propertyPath = exp.Left as PropertyPath; - return EvaluatePropertyPathExpression(exp, propertyPath, parameterExpression, propertyMapping); + return Result.Fail($"Property '{lambdaExp.Property.TokenLiteral()}' is not a collection"); + } + + var lambdaParameter = Expression.Parameter(elementType, lambdaExp.Parameter); + return Result.Ok((collectionProperty, elementType, lambdaParameter)); + } - case InfixExpression exp when exp.Left is Identifier: - if (!propertyMapping.TryGetValue(exp.Left.TokenLiteral(), out var propertyName)) + 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, Dictionary propertyMapping) + { + switch (property) + { + case Identifier identifier: + if (!propertyMapping.TryGetValue(identifier.TokenLiteral(), out var propertyName)) { - return Result.Fail($"Invalid property '{exp.Left.TokenLiteral()}' within filter"); + return Result.Fail($"Invalid property '{identifier.TokenLiteral()}' in lambda expression"); } + return Expression.Property(baseExpression, propertyName); - var identifierProperty = Expression.Property(parameterExpression, propertyName); - return EvaluateInfixExpression(exp, identifierProperty); - case InfixExpression exp: - // Handle logical operators (AND/OR) - var left = Evaluate(exp.Left, parameterExpression, propertyMapping); - if (left.IsFailed) + case PropertyPath propertyPath: + var current = baseExpression; + for (int i = 0; i < propertyPath.Segments.Count; i++) { - return left; + var segment = propertyPath.Segments[i]; + if (!propertyMapping.TryGetValue(segment, out var segmentPropertyName)) + return Result.Fail($"Invalid property '{segment}' in lambda expression property path"); + current = Expression.Property(current, segmentPropertyName); } + return (MemberExpression)current; + + 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)) + { + return Result.Fail($"Lambda parameter '{context.CurrentLambda.ParameterName}' cannot be used directly in comparisons"); + } + + if (!context.PropertyMapping.TryGetValue(identifierName, out var propertyName)) + { + return Result.Fail($"Invalid property '{identifierName}' within filter"); + } + + var identifierProperty = Expression.Property(context.RootParameter, propertyName); + 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; - var right = Evaluate(exp.Right, parameterExpression, propertyMapping); - if (right.IsFailed) + 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; + + // Create property mapping for the element type + var lambdaPropertyMapping = PropertyMappingHelper.CreatePropertyMapping(elementType); + + const int firstSegmentAfterParameter = 1; + for (int i = firstSegmentAfterParameter; i < propertyPath.Segments.Count; i++) + { + var segment = propertyPath.Segments[i]; + + // Get the actual property name using mapping + if (!lambdaPropertyMapping.TryGetValue(segment, out var propertyName)) + { + // If not found in current type, update mapping for nested type and try again + if (current is MemberExpression memberExp) { - return right; + lambdaPropertyMapping = PropertyMappingHelper.CreatePropertyMapping(memberExp.Type); + if (!lambdaPropertyMapping.TryGetValue(segment, out propertyName)) + { + return Result.Fail($"Invalid property '{segment}' in lambda property path"); + } } - - switch (exp.Operator) + else { - case Keywords.And: - return Expression.AndAlso(left.Value, right.Value); - case Keywords.Or: - return Expression.OrElse(left.Value, right.Value); - default: - return Result.Fail($"Unsupported logical operator: {exp.Operator}"); + return Result.Fail($"Invalid property '{segment}' in lambda property path"); } + } + + current = Expression.Property(current, propertyName); } - return null; + 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 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) { diff --git a/src/GoatQuery/src/Extensions/QueryableExtension.cs b/src/GoatQuery/src/Extensions/QueryableExtension.cs index 422550b..b55bdab 100644 --- a/src/GoatQuery/src/Extensions/QueryableExtension.cs +++ b/src/GoatQuery/src/Extensions/QueryableExtension.cs @@ -8,26 +8,6 @@ public static class QueryableExtension { - private static Dictionary CreatePropertyMapping() - { - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - - var properties = typeof(T).GetProperties(); - - foreach (var property in properties) - { - var jsonPropertyNameAttribute = property.GetCustomAttribute(); - if (jsonPropertyNameAttribute != null) - { - result[jsonPropertyNameAttribute.Name] = property.Name; - continue; - } - - result[property.Name] = property.Name; - } - - return result; - } public static Result> Apply(this IQueryable queryable, Query query, ISearchBinder searchBinder = null, QueryOptions options = null) { @@ -38,7 +18,7 @@ public static Result> Apply(this IQueryable queryable, Quer var type = typeof(T); - var propertyMappings = CreatePropertyMapping(); + var propertyMappings = PropertyMappingHelper.CreatePropertyMapping(); // Filter if (!string.IsNullOrEmpty(query.Filter)) diff --git a/src/GoatQuery/src/Lexer/Lexer.cs b/src/GoatQuery/src/Lexer/Lexer.cs index f5eba3a..97a552a 100644 --- a/src/GoatQuery/src/Lexer/Lexer.cs +++ b/src/GoatQuery/src/Lexer/Lexer.cs @@ -51,70 +51,22 @@ public Token NextToken() 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) || IsDigit(_character)) + if (IsLetter(_character)) { token.Literal = ReadIdentifier(); - if (IsGuid(token.Literal)) - { - token.Type = TokenType.GUID; - return token; - } - - if (token.Literal.Equals(Keywords.Null, StringComparison.OrdinalIgnoreCase)) - { - token.Type = TokenType.NULL; - return token; - } - - if (token.Literal.Equals(Keywords.True, StringComparison.OrdinalIgnoreCase) || - token.Literal.Equals(Keywords.False, StringComparison.OrdinalIgnoreCase)) - { - token.Type = TokenType.BOOLEAN; - return token; - } - - if (IsDigit(token.Literal[0])) - { - if (IsDate(token.Literal)) - { - token.Type = TokenType.DATE; - return token; - } - - if (IsDateTime(token.Literal)) - { - token.Type = TokenType.DATETIME; - return token; - } - - if (token.Literal.EndsWith("f", StringComparison.OrdinalIgnoreCase)) - { - token.Type = TokenType.FLOAT; - return token; - } - - if (token.Literal.EndsWith("m", StringComparison.OrdinalIgnoreCase)) - { - token.Type = TokenType.DECIMAL; - return token; - } - - if (token.Literal.EndsWith("d", StringComparison.OrdinalIgnoreCase)) - { - token.Type = TokenType.DOUBLE; - return token; - } - - token.Type = TokenType.INT; - return token; - } - - token.Type = TokenType.IDENT; + token.Type = ClassifyIdentifier(token.Literal); return token; } break; @@ -142,15 +94,99 @@ private bool IsGuid(string value) private string ReadIdentifier() { - var currentPosition = _position; + var startPosition = _position; - while (IsLetter(_character) || IsDigit(_character) || _character == '-' || _character == ':' || _character == '.') + while (IsIdentifierCharacter()) { ReadCharacter(); } - return _input.Substring(currentPosition, _position - currentPosition); + 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) { diff --git a/src/GoatQuery/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs index edd4221..876d981 100644 --- a/src/GoatQuery/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -129,11 +129,11 @@ private Result ParseGroupedExpression() private Result ParseFilterStatement() { - QueryExpression leftExpression; + QueryExpression leftExpression = null; if (_peekToken.Type == TokenType.SLASH) { - // We are filtering by a property on an object + // We are filtering by a property on an object or lambda expression var segments = new List { _currentToken.Literal }; var startToken = _currentToken; @@ -147,16 +147,40 @@ private Result ParseFilterStatement() 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); } - leftExpression = new PropertyPath(startToken, segments); + // 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"); @@ -255,6 +279,59 @@ private Result ParseFilterStatement() 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 bool PeekTokenIs(TokenType tokenType) + { + return _peekToken.Type == tokenType; + } + private int GetPrecedence(TokenType tokenType) { switch (tokenType) diff --git a/src/GoatQuery/src/Token/Token.cs b/src/GoatQuery/src/Token/Token.cs index 53e23f1..8e07671 100644 --- a/src/GoatQuery/src/Token/Token.cs +++ b/src/GoatQuery/src/Token/Token.cs @@ -15,7 +15,8 @@ public enum TokenType BOOLEAN, LPAREN, RPAREN, - SLASH + SLASH, + COLON } public static class Keywords @@ -34,6 +35,8 @@ public static class Keywords 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 diff --git a/src/GoatQuery/src/Utilities/PropertyMappingHelper.cs b/src/GoatQuery/src/Utilities/PropertyMappingHelper.cs new file mode 100644 index 0000000..297fb62 --- /dev/null +++ b/src/GoatQuery/src/Utilities/PropertyMappingHelper.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json.Serialization; + +internal static class PropertyMappingHelper +{ + public static Dictionary CreatePropertyMapping() + { + return CreatePropertyMapping(typeof(T)); + } + + public static Dictionary CreatePropertyMapping(Type type) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var properties = type.GetProperties(); + + foreach (var property in properties) + { + var jsonPropertyNameAttribute = property.GetCustomAttribute(); + if (jsonPropertyNameAttribute != null) + { + result[jsonPropertyNameAttribute.Name] = property.Name; + continue; + } + + result[property.Name] = property.Name; + } + + return result; + } +} \ No newline at end of file diff --git a/src/GoatQuery/tests/Filter/FilterLexerTest.cs b/src/GoatQuery/tests/Filter/FilterLexerTest.cs index 3c5d744..4f6680f 100644 --- a/src/GoatQuery/tests/Filter/FilterLexerTest.cs +++ b/src/GoatQuery/tests/Filter/FilterLexerTest.cs @@ -465,6 +465,64 @@ public static IEnumerable Parameters() 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, ")"), + } + }; } [Theory] diff --git a/src/GoatQuery/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs index 9de2e5b..b60a764 100644 --- a/src/GoatQuery/tests/Filter/FilterParserTest.cs +++ b/src/GoatQuery/tests/Filter/FilterParserTest.cs @@ -185,4 +185,151 @@ public void Test_ParsingFilterStatementWithNestedProperty(string input, string[] 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); + } + } \ No newline at end of file diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 223c16e..715eb9e 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -373,6 +373,98 @@ public static IEnumerable Parameters() "manager/manager/manager/firstName eq 'Manager 04'", new[] { TestData.Users["Egg"] } }; + + // Lambda expression tests with addresses/any + yield return new object[] { + "addresses/any(addr: addr/city/name eq 'New York')", + new[] { TestData.Users["John"], TestData.Users["Apple"] } + }; + + yield return new object[] { + "addresses/any(address: address/city/name eq 'Chicago')", + new[] { TestData.Users["Apple"] } + }; + + yield return new object[] { + "addresses/any(a: a/city/name eq 'Seattle')", + new[] { TestData.Users["Jane"] } + }; + + yield return new object[] { + "addresses/any(addr: addr/city/name eq 'NonExistentCity')", + Array.Empty() + }; + + // Lambda expression tests with addresses/all + yield return new object[] { + "addresses/all(addr: addr/city/country eq 'USA')", + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "addresses/all(a: a/city/name ne 'Chicago')", + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Doe"], TestData.Users["Egg"] } + }; + + // Lambda expression tests with addressLine1 + yield return new object[] { + "addresses/any(addr: addr/addressLine1 contains 'Main')", + new[] { TestData.Users["John"] } + }; + + yield return new object[] { + "addresses/any(a: a/addressLine1 contains 'St')", + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Egg"] } + }; + + // Lambda expressions combined with regular filters + yield return new object[] { + "firstname eq 'John' and addresses/any(addr: addr/city/name eq 'New York')", + new[] { TestData.Users["John"] } + }; + + yield return new object[] { + "age eq 1 or addresses/any(addr: addr/city/name eq 'Miami')", + new[] { TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "addresses/any(addr: addr/city/name eq 'Seattle') and isEmailVerified eq false", + new[] { TestData.Users["Jane"] } + }; + + // Empty addresses should work with any() returning false and all() returning true + yield return new object[] { + "addresses/any(addr: addr/city/name eq 'AnyCity')", + Array.Empty() + }; + + yield return new object[] { + "addresses/all(addr: addr/city/name ne 'SomeCity')", + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["Egg"] } + }; + + // Complex lambda expressions with logical operators + yield return new object[] { + "addresses/any(addr: addr/city/name eq 'New York' or addr/city/name eq 'Chicago')", + new[] { TestData.Users["John"], TestData.Users["Apple"] } + }; + + yield return new object[] { + "addresses/any(addr: addr/city/name eq 'Miami' and addr/city/country eq 'USA')", + new[] { TestData.Users["Egg"] } + }; + + // Testing with users that have no addresses (empty collections) + yield return new object[] { + "firstname eq 'Harry' and addresses/any(addr: addr/city/name eq 'NonExistent')", + Array.Empty() + }; + + yield return new object[] { + "firstname eq 'NullUser' and addresses/all(addr: addr/city/country eq 'USA')", + Array.Empty() + }; } [Theory] @@ -394,6 +486,9 @@ public void Test_Filter(string filter, IEnumerable expected) [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 diff --git a/src/GoatQuery/tests/TestData.cs b/src/GoatQuery/tests/TestData.cs index 503ff28..bcfba7f 100644 --- a/src/GoatQuery/tests/TestData.cs +++ b/src/GoatQuery/tests/TestData.cs @@ -10,6 +10,19 @@ public static class TestData DateOfBirth = DateTime.Parse("2004-01-31 23:59:59"), BalanceDecimal = 1.50m, IsEmailVerified = true, + Addresses = new[] + { + new Address + { + AddressLine1 = "123 Main St", + City = new City { Name = "New York", Country = "USA" } + }, + new Address + { + AddressLine1 = "456 Oak Ave", + City = new City { Name = "Boston", Country = "USA" } + } + }, Manager = new User { Age = 16, @@ -27,7 +40,15 @@ public static class TestData UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2020-05-09 15:30:00"), BalanceDecimal = 0, - IsEmailVerified = false + IsEmailVerified = false, + Addresses = new[] + { + new Address + { + AddressLine1 = "789 Pine Rd", + City = new City { Name = "Seattle", Country = "USA" } + } + } }, ["Apple"] = new User { @@ -37,6 +58,19 @@ public static class TestData DateOfBirth = DateTime.Parse("1980-12-31 00:00:01"), BalanceFloat = 1204050.98f, IsEmailVerified = true, + Addresses = new[] + { + new Address + { + AddressLine1 = "321 Elm St", + City = new City { Name = "Chicago", Country = "USA" } + }, + new Address + { + AddressLine1 = "654 Maple Dr", + City = new City { Name = "New York", Country = "USA" } + } + }, Manager = new User { Age = 16, @@ -54,7 +88,8 @@ public static class TestData UserId = Guid.Parse("e4c7772b-8947-4e46-98ed-644b417d2a08"), DateOfBirth = DateTime.Parse("2002-08-01"), BalanceDecimal = 0.5372958205929493m, - IsEmailVerified = false + IsEmailVerified = false, + Addresses = Array.Empty
() }, ["Doe"] = new User { @@ -63,7 +98,15 @@ public static class TestData UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), DateOfBirth = DateTime.Parse("2023-07-26 12:00:30"), BalanceDecimal = null, - IsEmailVerified = true + IsEmailVerified = true, + Addresses = new[] + { + new Address + { + AddressLine1 = "999 Broadway", + City = new City { Name = "Los Angeles", Country = "USA" } + } + } }, ["Egg"] = new User { @@ -73,6 +116,19 @@ public static class TestData DateOfBirth = DateTime.Parse("2000-01-01 00:00:00"), BalanceDouble = 1334534453453433.33435443343231235652d, IsEmailVerified = false, + Addresses = new[] + { + new Address + { + AddressLine1 = "777 First Ave", + City = new City { Name = "Miami", Country = "USA" } + }, + new Address + { + AddressLine1 = "888 Second St", + City = new City { Name = "Orlando", Country = "USA" } + } + }, Manager = new User { Age = 18, @@ -110,7 +166,8 @@ public static class TestData BalanceDecimal = null, BalanceDouble = null, BalanceFloat = null, - IsEmailVerified = true + IsEmailVerified = true, + Addresses = Array.Empty
() }, }; } \ No newline at end of file diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs index 4dc3414..4bfc005 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -11,10 +11,23 @@ public record User public DateTime? DateOfBirth { get; set; } public bool IsEmailVerified { get; set; } public User? Manager { get; set; } + public IEnumerable
Addresses { get; set; } = Array.Empty
(); } public sealed record CustomJsonPropertyUser : User { [JsonPropertyName("last_name")] public string Lastname { get; set; } = string.Empty; +} + +public record Address +{ + public City City { get; set; } = new City(); + public string AddressLine1 { get; set; } = string.Empty; +} + +public record City +{ + public string Name { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; } \ No newline at end of file From d1e67cb2dbf0ee09f96aad5a141072b7d7a47f88 Mon Sep 17 00:00:00 2001 From: James <23193271+Jamess-Lucass@users.noreply.github.com> Date: Mon, 8 Sep 2025 10:07:41 +0100 Subject: [PATCH 49/60] Refactor README --- README.md | 46 +++++++++++++++++++++------------------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 684e0c4..ce4bea6 100644 --- a/README.md +++ b/README.md @@ -15,19 +15,19 @@ dotnet add package GoatQuery.AspNetCore # For ASP.NET Core integration // Basic filtering var users = dbContext.Users .Apply(new Query { Filter = "age gt 18 and isActive eq true" }) - .Value.Results; + .Value.Query; // Lambda expressions for collection filtering var usersWithLondonAddress = dbContext.Users .Apply(new Query { Filter = "addresses/any(x: x/city eq 'London')" }) - .Value.Results; + .Value.Query; // Complex nested filtering var activeUsersWithHighValueOrders = dbContext.Users .Apply(new Query { Filter = "isActive eq true and orders/any(o: o/items/any(i: i/price gt 1000))" }) - .Value.Results; + .Value.Query; // ASP.NET Core integration [HttpGet] @@ -38,18 +38,18 @@ 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?$top=10&$skip=20&$count=true -GET /api/users?$search=john +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?top=10&skip=20&count=true +GET /api/users?search=john ``` ## Filtering ### Basic Operators -- **Comparison**: `eq`, `ne`, `gt`, `ge`, `lt`, `le` +- **Comparison**: `eq`, `ne`, `gt`, `gte`, `lt`, `lte` - **Logical**: `and`, `or` - **String**: `contains` @@ -88,7 +88,7 @@ Access nested properties using forward slash (`/`) syntax: - String: `'value'` - Numbers: `42`, `3.14f`, `2.5m`, `1.0d` - Boolean: `true`, `false` -- DateTime: `2023-12-25T10:30:00Z` +- DateTime: `2023-12-25T10:30:00Z`, `2023-12-25` - GUID: `123e4567-e89b-12d3-a456-426614174000` - Null: `null` @@ -145,13 +145,11 @@ public class AddressDto **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' +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' ``` -**Property mapping works automatically** - GoatQuery maps JSON property names to .NET properties for all navigation paths and lambda expressions. - ## Advanced Features ### Lambda Expression Support @@ -194,13 +192,6 @@ GoatQuery automatically generates null-safe expressions for property navigation: // Generated: user.Profile != null && user.Profile.Address != null && user.Profile.Address.City == "London" ``` -### Type Safety and Performance - -- **Strong typing**: All expressions are strongly typed using .NET reflection -- **Compiled expressions**: Queries compile to efficient LINQ expressions -- **Database translation**: Works with Entity Framework for database-level filtering -- **Memory efficiency**: Minimal allocations during expression building - ## Search Implement custom search logic: @@ -233,7 +224,7 @@ public IActionResult GetUsers() => Ok(dbContext.Users); public IActionResult GetUsers([FromQuery] Query query) { var result = dbContext.Users.Apply(query); - return result.IsFailed ? BadRequest(result.Errors) : Ok(result.Value); + return result.IsFailed ? BadRequest(result.Errors) : Ok(result.Value.Query.ToList()); } ``` @@ -246,15 +237,20 @@ var result = users.Apply(query); if (result.IsFailed) return BadRequest(result.Errors.Select(e => e.Message)); -var data = result.Value.Results; +var data = result.Value.Query.ToList(); var count = result.Value.Count; // If Count = true ``` ## Development +### Test ```bash dotnet test ./src/GoatQuery/tests -dotnet build --configuration Release +``` + +### Run the example project + +```bash cd example && dotnet run ``` From e7a323050c9f43eb43eaf4fea40a432ec5938837 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:24:16 +0100 Subject: [PATCH 50/60] feat: added lambda expressions for primitive types --- README.md | 38 +++++ example/Dto/UserDto.cs | 1 + example/Entities/User.cs | 1 + example/Program.cs | 3 +- .../src/Evaluator/FilterEvaluator.cs | 160 +++++++++++------- src/GoatQuery/src/Parser/Parser.cs | 98 ++++------- src/GoatQuery/tests/Filter/FilterTest.cs | 15 ++ src/GoatQuery/tests/TestData.cs | 6 +- src/GoatQuery/tests/User.cs | 1 + 9 files changed, 194 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index ce4bea6..51710de 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,11 @@ 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 { @@ -41,6 +46,7 @@ public IActionResult GetUsers() => Ok(dbContext.Users); 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 ``` @@ -104,6 +110,8 @@ Access nested properties using forward slash (`/`) syntax: // 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'" @@ -111,6 +119,7 @@ Access nested properties using forward slash (`/`) syntax: // 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 @@ -183,6 +192,34 @@ GoatQuery supports sophisticated collection filtering using lambda expressions: "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: @@ -244,6 +281,7 @@ var count = result.Value.Count; // If Count = true ## Development ### Test + ```bash dotnet test ./src/GoatQuery/tests ``` diff --git a/example/Dto/UserDto.cs b/example/Dto/UserDto.cs index 3b8532e..30880cf 100644 --- a/example/Dto/UserDto.cs +++ b/example/Dto/UserDto.cs @@ -15,6 +15,7 @@ public record UserDto 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 record AddressDto diff --git a/example/Entities/User.cs b/example/Entities/User.cs index 6d1413d..6e02986 100644 --- a/example/Entities/User.cs +++ b/example/Entities/User.cs @@ -18,6 +18,7 @@ public record User 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 record Address diff --git a/example/Program.cs b/example/Program.cs index 1264cc4..d37718a 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -60,7 +60,8 @@ 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.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()); context.Users.AddRange(users.Generate(1_000)); context.SaveChanges(); diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index 228d3d1..6fa9670 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -99,6 +99,13 @@ private static bool IsNullableReferenceType(Type type) return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; } + 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 @@ -165,68 +172,82 @@ private static Result EvaluateValueComparison(InfixExpression exp, M return CreateComparisonExpression(exp.Operator, updatedProperty, value); } - private static Result CreateComparisonExpression(string operatorKeyword, MemberExpression property, ConstantExpression 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(property, value), - Keywords.Ne => Expression.NotEqual(property, value), - Keywords.Contains => CreateContainsExpression(property, value), - Keywords.Lt => Expression.LessThan(property, value), - Keywords.Lte => Expression.LessThanOrEqual(property, value), - Keywords.Gt => Expression.GreaterThan(property, value), - Keywords.Gte => Expression.GreaterThanOrEqual(property, value), + 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<(ConstantExpression Value, MemberExpression Property)> CreateConstantExpression(QueryExpression literal, MemberExpression property) + 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 => CreateIntegerConstant(intLit.Value, property), - DateLiteral dateLit => Result.Ok(CreateDateConstant(dateLit, property)), - GuidLiteral guidLit => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(guidLit.Value, property.Type), property)), - DecimalLiteral decLit => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(decLit.Value, property.Type), property)), - FloatLiteral floatLit => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(floatLit.Value, property.Type), property)), - DoubleLiteral dblLit => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(dblLit.Value, property.Type), property)), - StringLiteral strLit => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(strLit.Value, property.Type), property)), - DateTimeLiteral dtLit => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(dtLit.Value, property.Type), property)), - BooleanLiteral boolLit => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(boolLit.Value, property.Type), property)), - NullLiteral _ => Result.Ok<(ConstantExpression, MemberExpression)>((Expression.Constant(null, property.Type), property)), + IntegerLiteral intLit => CreateIntegerConstant(intLit.Value, expression), + 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 => Result.Ok(Expression.Constant(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, MemberExpression)> CreateIntegerConstant(int value, MemberExpression property) + private static Result<(ConstantExpression Value, MemberExpression Property)> CreateConstantExpression(QueryExpression literal, MemberExpression property) { - var integerConstant = GetIntegerExpressionConstant(value, property.Type); - if (integerConstant.IsFailed) - { - return Result.Fail(integerConstant.Errors); - } + var constantResult = CreateConstantExpression(literal, (Expression)property); + if (constantResult.IsFailed) return Result.Fail(constantResult.Errors); + + return Result.Ok((constantResult.Value, property)); + } - return Result.Ok<(ConstantExpression, MemberExpression)>((integerConstant.Value, property)); + private static Result CreateIntegerConstant(int value, Expression expression) + { + return GetIntegerExpressionConstant(value, expression.Type); } - private static (ConstantExpression, MemberExpression) CreateDateConstant(DateLiteral dateLiteral, MemberExpression property) + private static ConstantExpression CreateDateConstant(DateLiteral dateLiteral, Expression expression) { - if (property.Type == typeof(DateTime?)) + if (expression.Type == typeof(DateTime?)) { - return (Expression.Constant(dateLiteral.Value.Date, typeof(DateTime)), property); + return Expression.Constant(dateLiteral.Value.Date, typeof(DateTime)); } else { - var dateProperty = Expression.Property(property, DatePropertyName); - return (Expression.Constant(dateLiteral.Value.Date, dateProperty.Type), dateProperty); + return Expression.Constant(dateLiteral.Value.Date, expression.Type); } } - private static Expression CreateContainsExpression(MemberExpression property, ConstantExpression value) + private static Expression CreateContainsExpression(Expression expression, ConstantExpression value) { - var propertyToLower = Expression.Call(property, StringToLowerMethod); + var expressionToLower = Expression.Call(expression, StringToLowerMethod); var valueToLower = Expression.Call(value, StringToLowerMethod); - return Expression.Call(propertyToLower, StringContainsMethod, valueToLower); + return Expression.Call(expressionToLower, StringContainsMethod, valueToLower); } private static Result EvaluateInfixExpression(InfixExpression exp, FilterEvaluationContext context) @@ -396,7 +417,13 @@ private static Result EvaluateLambdaBodyIdentifier(InfixExpression e if (identifierName.Equals(context.CurrentLambda.ParameterName, StringComparison.OrdinalIgnoreCase)) { - return Result.Fail($"Lambda parameter '{context.CurrentLambda.ParameterName}' cannot be used directly in comparisons"); + // 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.PropertyMapping.TryGetValue(identifierName, out var propertyName)) @@ -430,34 +457,11 @@ private static Result EvaluateLambdaPropertyPath(InfixExpression exp var current = (Expression)lambdaParameter; var elementType = lambdaParameter.Type; - // Create property mapping for the element type - var lambdaPropertyMapping = PropertyMappingHelper.CreatePropertyMapping(elementType); - - const int firstSegmentAfterParameter = 1; - for (int i = firstSegmentAfterParameter; i < propertyPath.Segments.Count; i++) - { - var segment = propertyPath.Segments[i]; - - // Get the actual property name using mapping - if (!lambdaPropertyMapping.TryGetValue(segment, out var propertyName)) - { - // If not found in current type, update mapping for nested type and try again - if (current is MemberExpression memberExp) - { - lambdaPropertyMapping = PropertyMappingHelper.CreatePropertyMapping(memberExp.Type); - if (!lambdaPropertyMapping.TryGetValue(segment, out propertyName)) - { - return Result.Fail($"Invalid property '{segment}' in lambda property path"); - } - } - else - { - return Result.Fail($"Invalid property '{segment}' in lambda property path"); - } - } - - current = Expression.Property(current, propertyName); - } + // Build property path from lambda parameter + var pathResult = BuildLambdaPropertyPath(current, propertyPath.Segments.Skip(1), elementType); + if (pathResult.IsFailed) return pathResult; + + current = pathResult.Value; var finalProperty = (MemberExpression)current; @@ -490,6 +494,36 @@ private static Expression CreateAllExpression(MemberExpression collection, Lambd return Expression.AndAlso(hasElements, allMatch); } + private static Result BuildLambdaPropertyPath(Expression startExpression, IEnumerable segments, Type elementType) + { + var current = startExpression; + var lambdaPropertyMapping = PropertyMappingHelper.CreatePropertyMapping(elementType); + + foreach (var segment in segments) + { + if (!lambdaPropertyMapping.TryGetValue(segment, out var propertyName)) + { + // If not found in current type, update mapping for nested type and try again + if (current is MemberExpression memberExp) + { + lambdaPropertyMapping = PropertyMappingHelper.CreatePropertyMapping(memberExp.Type); + if (!lambdaPropertyMapping.TryGetValue(segment, out propertyName)) + { + return Result.Fail($"Invalid property '{segment}' in lambda property path"); + } + } + else + { + return Result.Fail($"Invalid property '{segment}' in lambda property path"); + } + } + + current = Expression.Property(current, propertyName); + } + + return Result.Ok(current); + } + private static Type GetCollectionElementType(Type collectionType) { // Handle IEnumerable diff --git a/src/GoatQuery/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs index 876d981..984665b 100644 --- a/src/GoatQuery/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -212,69 +212,7 @@ private Result ParseFilterStatement() return Result.Fail($"Value must be a numeric or date type when using '{statement.Operator}' operand"); } - switch (_currentToken.Type) - { - case TokenType.GUID: - if (Guid.TryParse(_currentToken.Literal, out var guidValue)) - { - statement.Right = new GuidLiteral(_currentToken, guidValue); - } - break; - case TokenType.STRING: - statement.Right = new StringLiteral(_currentToken, _currentToken.Literal); - break; - case TokenType.INT: - if (int.TryParse(_currentToken.Literal, out var intValue)) - { - statement.Right = new IntegerLiteral(_currentToken, intValue); - } - break; - case TokenType.FLOAT: - var floatValueWithoutSuffixLiteral = _currentToken.Literal.TrimEnd('f'); - - if (float.TryParse(floatValueWithoutSuffixLiteral, out var floatValue)) - { - statement.Right = new FloatLiteral(_currentToken, floatValue); - } - break; - case TokenType.DECIMAL: - var decimalValueWithoutSuffixLiteral = _currentToken.Literal.TrimEnd('m'); - - if (decimal.TryParse(decimalValueWithoutSuffixLiteral, out var decimalValue)) - { - statement.Right = new DecimalLiteral(_currentToken, decimalValue); - } - break; - case TokenType.DOUBLE: - var doubleValueWithoutSuffixLiteral = _currentToken.Literal.TrimEnd('d'); - - if (double.TryParse(doubleValueWithoutSuffixLiteral, out var doubleValue)) - { - statement.Right = new DoubleLiteral(_currentToken, doubleValue); - } - break; - case TokenType.DATETIME: - if (DateTime.TryParse(_currentToken.Literal, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var dateTimeValue)) - { - statement.Right = new DateTimeLiteral(_currentToken, dateTimeValue); - } - break; - case TokenType.DATE: - if (DateTime.TryParse(_currentToken.Literal, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var dateValue)) - { - statement.Right = new DateLiteral(_currentToken, dateValue); - } - break; - case TokenType.NULL: - statement.Right = new NullLiteral(_currentToken); - break; - case TokenType.BOOLEAN: - if (bool.TryParse(_currentToken.Literal, out var boolValue)) - { - statement.Right = new BooleanLiteral(_currentToken, boolValue); - } - break; - } + statement.Right = ParseLiteral(_currentToken); return statement; } @@ -327,6 +265,40 @@ private Result ParseLambdaExpression(QueryExpression prop 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; diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 715eb9e..62c4279 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -465,6 +465,21 @@ public static IEnumerable Parameters() "firstname eq 'NullUser' and addresses/all(addr: addr/city/country eq 'USA')", Array.Empty() }; + + yield return new object[] { + "tags/any(x: x eq 'vip')", + new[] { TestData.Users["Apple"] } + }; + + yield return new object[] { + "tags/any(x: x eq 'premium')", + new[] { TestData.Users["Apple"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "tags/all(x: x eq 'premium')", + new[] { TestData.Users["Egg"] } + }; } [Theory] diff --git a/src/GoatQuery/tests/TestData.cs b/src/GoatQuery/tests/TestData.cs index bcfba7f..5d8ad25 100644 --- a/src/GoatQuery/tests/TestData.cs +++ b/src/GoatQuery/tests/TestData.cs @@ -79,7 +79,8 @@ public static class TestData DateOfBirth = DateTime.Parse("2000-01-01 00:00:00"), BalanceDecimal = 2.00m, IsEmailVerified = true - } + }, + Tags = ["vip", "premium"] }, ["Harry"] = new User { @@ -155,7 +156,8 @@ public static class TestData IsEmailVerified = true } } - } + }, + Tags = ["premium"] }, ["NullUser"] = new User { diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs index 4bfc005..c031714 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -12,6 +12,7 @@ public record User public bool IsEmailVerified { get; set; } public User? Manager { get; set; } public IEnumerable
Addresses { get; set; } = Array.Empty
(); + public IEnumerable Tags { get; set; } = Array.Empty(); } public sealed record CustomJsonPropertyUser : User From 905eecb1c38f5ff6696b04debb363f0d9448422e Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Tue, 23 Sep 2025 10:30:24 +0100 Subject: [PATCH 51/60] fixed property mapping --- example/Controllers/UserController.cs | 17 ++---- example/Dto/UserDto.cs | 31 ---------- example/Entities/User.cs | 8 +++ example/Profiles/AutoMapperProfile.cs | 11 ---- example/Program.cs | 18 +++--- example/example.csproj | 1 - .../src/Evaluator/FilterEvaluator.cs | 61 +++++++++++++++---- src/GoatQuery/tests/Filter/FilterTest.cs | 15 +++++ src/GoatQuery/tests/TestData.cs | 10 +++ src/GoatQuery/tests/User.cs | 7 +++ 10 files changed, 104 insertions(+), 75 deletions(-) delete mode 100644 example/Dto/UserDto.cs delete mode 100644 example/Profiles/AutoMapperProfile.cs diff --git a/example/Controllers/UserController.cs b/example/Controllers/UserController.cs index 44533c9..9a49dc6 100644 --- a/example/Controllers/UserController.cs +++ b/example/Controllers/UserController.cs @@ -1,5 +1,3 @@ -using AutoMapper; -using AutoMapper.QueryableExtensions; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -8,25 +6,22 @@ public class UsersController : ControllerBase { private readonly ApplicationDbContext _db; - private readonly IMapper _mapper; - - public UsersController(ApplicationDbContext db, IMapper mapper) + public UsersController(ApplicationDbContext db) { _db = db; - _mapper = mapper; } // GET: /controller/users [HttpGet] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - [EnableQuery(maxTop: 10)] - public ActionResult> Get() + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [EnableQuery(maxTop: 10)] + public ActionResult> Get() { var users = _db.Users + .Include(x => x.Company) .Include(x => x.Manager) .ThenInclude(x => x.Manager) - .Where(x => !x.IsDeleted) - .ProjectTo(_mapper.ConfigurationProvider); + .Where(x => !x.IsDeleted); return Ok(users); } diff --git a/example/Dto/UserDto.cs b/example/Dto/UserDto.cs deleted file mode 100644 index 30880cf..0000000 --- a/example/Dto/UserDto.cs +++ /dev/null @@ -1,31 +0,0 @@ -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 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 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; -} \ No newline at end of file diff --git a/example/Entities/User.cs b/example/Entities/User.cs index 6e02986..e7d39d7 100644 --- a/example/Entities/User.cs +++ b/example/Entities/User.cs @@ -19,6 +19,7 @@ public record User public User? Manager { get; set; } public IEnumerable
Addresses { get; set; } = Array.Empty
(); public IEnumerable Tags { get; set; } = Array.Empty(); + public Company? Company { get; set; } } public record Address @@ -33,4 +34,11 @@ 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; } \ No newline at end of file diff --git a/example/Profiles/AutoMapperProfile.cs b/example/Profiles/AutoMapperProfile.cs deleted file mode 100644 index 81dc995..0000000 --- a/example/Profiles/AutoMapperProfile.cs +++ /dev/null @@ -1,11 +0,0 @@ -using AutoMapper; - -public class AutoMapperProfile : Profile -{ - public AutoMapperProfile() - { - CreateMap(); - CreateMap(); - CreateMap(); - } -} \ No newline at end of file diff --git a/example/Program.cs b/example/Program.cs index d37718a..50013e1 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -1,6 +1,4 @@ using System.Reflection; -using AutoMapper; -using AutoMapper.QueryableExtensions; using Bogus; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -23,8 +21,6 @@ options.UseNpgsql(postgreSqlContainer.GetConnectionString()); }); -builder.Services.AddAutoMapper(Assembly.GetExecutingAssembly()); - var app = builder.Build(); using (var scope = app.Services.CreateScope()) @@ -35,6 +31,10 @@ // 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()); @@ -61,7 +61,8 @@ }) .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.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(); @@ -72,13 +73,12 @@ Console.WriteLine($"Postgres connection string: {postgreSqlContainer.GetConnectionString()}"); -app.MapGet("/minimal/users", (ApplicationDbContext db, [FromServices] IMapper mapper, [AsParameters] Query query) => +app.MapGet("/minimal/users", (ApplicationDbContext db, [AsParameters] Query query) => { var result = db.Users + .Include(x => x.Company) .Include(x => x.Manager) .ThenInclude(x => x.Manager) - .Where(x => !x.IsDeleted) - .ProjectTo(mapper.ConfigurationProvider) .Apply(query); if (result.IsFailed) @@ -86,7 +86,7 @@ return Results.BadRequest(new { message = result.Errors }); } - var response = new PagedResponse(result.Value.Query.ToList(), result.Value.Count); + var response = new PagedResponse(result.Value.Query.ToList(), result.Value.Count); return Results.Ok(response); }); diff --git a/example/example.csproj b/example/example.csproj index 674a5e6..1654b7d 100644 --- a/example/example.csproj +++ b/example/example.csproj @@ -7,7 +7,6 @@ - diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index 6fa9670..2b77803 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -76,24 +76,69 @@ private static Result EvaluatePropertyPathExpression( { var current = startExpression; var nullChecks = new List(); + var currentPropertyMapping = propertyMapping; foreach (var (segment, isLast) in propertyPath.Segments.Select((s, i) => (s, i == propertyPath.Segments.Count - 1))) { - if (!propertyMapping.TryGetValue(segment, out var propertyName)) - return Result.Fail($"Invalid property '{segment}' in path"); + var propertyResult = ResolvePropertySegment(segment, current, currentPropertyMapping); + if (propertyResult.IsFailed) return Result.Fail(propertyResult.Errors); - current = Expression.Property(current, propertyName); + current = propertyResult.Value; // Add null check for intermediate reference types only (not the final property) if (!isLast && IsNullableReferenceType(current.Type)) { nullChecks.Add(Expression.NotEqual(current, Expression.Constant(null, current.Type))); } + + // Update property mapping for nested object navigation + if (!isLast) + { + currentPropertyMapping = PropertyMappingHelper.CreatePropertyMapping(current.Type); + } } return Result.Ok(((MemberExpression)current, nullChecks)); } + private static Result ResolvePropertySegment( + string segment, + Expression parentExpression, + Dictionary propertyMapping) + { + if (!propertyMapping.TryGetValue(segment, out var propertyName)) + return Result.Fail($"Invalid property '{segment}' in path"); + + return Expression.Property(parentExpression, propertyName); + } + + private static Result ResolvePropertyPathForCollection( + PropertyPath propertyPath, + Expression baseExpression, + Dictionary propertyMapping) + { + var current = baseExpression; + var currentPropertyMapping = propertyMapping; + + for (int i = 0; i < propertyPath.Segments.Count; i++) + { + var segment = propertyPath.Segments[i]; + var propertyResult = ResolvePropertySegment(segment, current, currentPropertyMapping); + if (propertyResult.IsFailed) + return Result.Fail(propertyResult.Errors); + + current = propertyResult.Value; + + // Update property mapping for nested object navigation + if (i < propertyPath.Segments.Count - 1) + { + currentPropertyMapping = PropertyMappingHelper.CreatePropertyMapping(current.Type); + } + } + + return (MemberExpression)current; + } + private static bool IsNullableReferenceType(Type type) { return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; @@ -363,15 +408,7 @@ private static Result ResolveCollectionProperty(QueryExpressio return Expression.Property(baseExpression, propertyName); case PropertyPath propertyPath: - var current = baseExpression; - for (int i = 0; i < propertyPath.Segments.Count; i++) - { - var segment = propertyPath.Segments[i]; - if (!propertyMapping.TryGetValue(segment, out var segmentPropertyName)) - return Result.Fail($"Invalid property '{segment}' in lambda expression property path"); - current = Expression.Property(current, segmentPropertyName); - } - return (MemberExpression)current; + return ResolvePropertyPathForCollection(propertyPath, baseExpression, propertyMapping); default: return Result.Fail($"Unsupported property type in lambda expression: {property.GetType().Name}"); diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 62c4279..856ab34 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -364,6 +364,21 @@ public static IEnumerable Parameters() new[] { TestData.Users["John"], TestData.Users["Apple"] } }; + yield return new object[] { + "company ne null ", + new[] { TestData.Users["Jane"] } + }; + + yield return new object[] { + "company/name eq 'Acme Corp'", + new[] { TestData.Users["Jane"] } + }; + + yield return new object[] { + "manager/manager/company/name eq 'My Test Company'", + new[] { TestData.Users["Egg"] } + }; + yield return new object[] { "manager/balanceDecimal gt 100m", Array.Empty() diff --git a/src/GoatQuery/tests/TestData.cs b/src/GoatQuery/tests/TestData.cs index 5d8ad25..d96428c 100644 --- a/src/GoatQuery/tests/TestData.cs +++ b/src/GoatQuery/tests/TestData.cs @@ -48,6 +48,11 @@ public static class TestData AddressLine1 = "789 Pine Rd", City = new City { Name = "Seattle", Country = "USA" } } + }, + Company = new Company + { + Name = "Acme Corp", + Department = "Sales" } }, ["Apple"] = new User @@ -154,6 +159,11 @@ public static class TestData DateOfBirth = DateTime.Parse("1983-04-21 00:00:00"), BalanceDecimal = 39.00m, IsEmailVerified = true + }, + Company = new Company + { + Name = "My Test Company", + Department = "Development" } } }, diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs index c031714..40257d4 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -10,6 +10,7 @@ public record User public float? BalanceFloat { get; set; } public DateTime? DateOfBirth { get; set; } public bool IsEmailVerified { get; set; } + public Company? Company { get; set; } public User? Manager { get; set; } public IEnumerable
Addresses { get; set; } = Array.Empty
(); public IEnumerable Tags { get; set; } = Array.Empty(); @@ -31,4 +32,10 @@ public record City { public string Name { get; set; } = string.Empty; public string Country { get; set; } = string.Empty; +} + +public record Company +{ + public string Name { get; set; } = string.Empty; + public string Department { get; set; } = string.Empty; } \ No newline at end of file From a87e114f55974f7980d4500b015d685b73eb7144 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Wed, 24 Sep 2025 10:29:28 +0100 Subject: [PATCH 52/60] refactored property mapping --- example/Controllers/UserController.cs | 2 + example/Entities/User.cs | 4 +- example/Program.cs | 9 +- .../src/Attributes/EnableQueryAttribute.cs | 5 +- .../src/Evaluator/FilterEvaluationContext.cs | 6 +- .../src/Evaluator/FilterEvaluator.cs | 114 ++++++----- .../src/Evaluator/OrderByEvaluator.cs | 6 +- .../src/Extensions/QueryableExtension.cs | 10 +- src/GoatQuery/src/QueryOptions.cs | 1 + .../src/Utilities/PropertyMappingHelper.cs | 33 ---- .../src/Utilities/PropertyMappingTree.cs | 182 ++++++++++++++++++ 11 files changed, 259 insertions(+), 113 deletions(-) delete mode 100644 src/GoatQuery/src/Utilities/PropertyMappingHelper.cs create mode 100644 src/GoatQuery/src/Utilities/PropertyMappingTree.cs diff --git a/example/Controllers/UserController.cs b/example/Controllers/UserController.cs index 9a49dc6..fb9100f 100644 --- a/example/Controllers/UserController.cs +++ b/example/Controllers/UserController.cs @@ -19,6 +19,8 @@ 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); diff --git a/example/Entities/User.cs b/example/Entities/User.cs index e7d39d7..3182437 100644 --- a/example/Entities/User.cs +++ b/example/Entities/User.cs @@ -17,8 +17,8 @@ public record User [Column(TypeName = "timestamp without time zone")] 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 ICollection
Addresses { get; set; } = new List
(); + public ICollection Tags { get; set; } = new List(); public Company? Company { get; set; } } diff --git a/example/Program.cs b/example/Program.cs index 50013e1..3e6ea21 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -1,6 +1,4 @@ -using System.Reflection; using Bogus; -using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Testcontainers.PostgreSql; @@ -77,8 +75,11 @@ { var result = db.Users .Include(x => x.Company) - .Include(x => x.Manager) - .ThenInclude(x => x.Manager) + .Include(x => x.Addresses) + .ThenInclude(x => x.City) + .Include(x => x.Manager) + .ThenInclude(x => x.Manager) + .Where(x => !x.IsDeleted) .Apply(query); if (result.IsFailed) diff --git a/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs b/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs index 01fb0dc..823ea00 100644 --- a/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs +++ b/src/GoatQuery.AspNetCore/src/Attributes/EnableQueryAttribute.cs @@ -5,11 +5,12 @@ public sealed class EnableQueryAttribute : ActionFilterAttribute { private readonly QueryOptions? _options; - public EnableQueryAttribute(int maxTop) + public EnableQueryAttribute(int maxTop, int maxPropertyMappingDepth = 5) { var options = new QueryOptions() { - MaxTop = maxTop + MaxTop = maxTop, + MaxPropertyMappingDepth = maxPropertyMappingDepth }; _options = options; diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluationContext.cs b/src/GoatQuery/src/Evaluator/FilterEvaluationContext.cs index 2e38ac6..814a5d1 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluationContext.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluationContext.cs @@ -5,13 +5,13 @@ internal class FilterEvaluationContext { public ParameterExpression RootParameter { get; } - public Dictionary PropertyMapping { get; } + public PropertyMappingTree PropertyMappingTree { get; } public Stack LambdaScopes { get; } = new Stack(); - public FilterEvaluationContext(ParameterExpression rootParameter, Dictionary propertyMapping) + public FilterEvaluationContext(ParameterExpression rootParameter, PropertyMappingTree propertyMappingTree) { RootParameter = rootParameter; - PropertyMapping = propertyMapping; + PropertyMappingTree = propertyMappingTree; } public bool IsInLambdaScope => LambdaScopes.Count > 0; diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index 2b77803..b1b5553 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -23,13 +23,13 @@ private static MethodInfo GetEnumerableMethod(string methodName, int parameterCo private static MethodInfo GetStringMethod(string methodName, params Type[] parameterTypes) => typeof(string).GetMethod(methodName, parameterTypes ?? Type.EmptyTypes); - public static Result Evaluate(QueryExpression expression, ParameterExpression parameterExpression, Dictionary propertyMapping) + 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 (propertyMapping == null) return Result.Fail("Property mapping cannot be null"); + if (propertyMappingTree == null) return Result.Fail("Property mapping tree cannot be null"); - var context = new FilterEvaluationContext(parameterExpression, propertyMapping); + var context = new FilterEvaluationContext(parameterExpression, propertyMappingTree); return EvaluateExpression(expression, context); } @@ -52,7 +52,7 @@ private static Result EvaluatePropertyPathExpression( (Expression)context.CurrentLambda.Parameter : context.RootParameter; - var propertyPathResult = BuildPropertyPath(propertyPath, baseExpression, context.PropertyMapping); + var propertyPathResult = BuildPropertyPath(propertyPath, baseExpression, context.PropertyMappingTree); if (propertyPathResult.IsFailed) return Result.Fail(propertyPathResult.Errors); var (finalProperty, nullChecks) = propertyPathResult.Value; @@ -72,18 +72,18 @@ private static Result EvaluatePropertyPathExpression( private static Result<(MemberExpression Property, List NullChecks)> BuildPropertyPath( PropertyPath propertyPath, Expression startExpression, - Dictionary propertyMapping) + PropertyMappingTree propertyMappingTree) { var current = startExpression; var nullChecks = new List(); - var currentPropertyMapping = propertyMapping; + var currentMappingTree = propertyMappingTree; foreach (var (segment, isLast) in propertyPath.Segments.Select((s, i) => (s, i == propertyPath.Segments.Count - 1))) { - var propertyResult = ResolvePropertySegment(segment, current, currentPropertyMapping); - if (propertyResult.IsFailed) return Result.Fail(propertyResult.Errors); + if (!currentMappingTree.TryGetProperty(segment, out var propertyNode)) + return Result.Fail($"Invalid property '{segment}' in path"); - current = propertyResult.Value; + current = Expression.Property(current, propertyNode.ActualPropertyName); // Add null check for intermediate reference types only (not the final property) if (!isLast && IsNullableReferenceType(current.Type)) @@ -91,48 +91,43 @@ private static Result EvaluatePropertyPathExpression( nullChecks.Add(Expression.NotEqual(current, Expression.Constant(null, current.Type))); } - // Update property mapping for nested object navigation + // Navigate to nested mapping for next segment if (!isLast) { - currentPropertyMapping = PropertyMappingHelper.CreatePropertyMapping(current.Type); + if (!propertyNode.HasNestedMapping) + return Result.Fail($"Property '{segment}' does not support nested navigation"); + + currentMappingTree = propertyNode.NestedMapping; } } return Result.Ok(((MemberExpression)current, nullChecks)); } - private static Result ResolvePropertySegment( - string segment, - Expression parentExpression, - Dictionary propertyMapping) - { - if (!propertyMapping.TryGetValue(segment, out var propertyName)) - return Result.Fail($"Invalid property '{segment}' in path"); - - return Expression.Property(parentExpression, propertyName); - } - private static Result ResolvePropertyPathForCollection( PropertyPath propertyPath, Expression baseExpression, - Dictionary propertyMapping) + PropertyMappingTree propertyMappingTree) { var current = baseExpression; - var currentPropertyMapping = propertyMapping; + var currentMappingTree = propertyMappingTree; for (int i = 0; i < propertyPath.Segments.Count; i++) { var segment = propertyPath.Segments[i]; - var propertyResult = ResolvePropertySegment(segment, current, currentPropertyMapping); - if (propertyResult.IsFailed) - return Result.Fail(propertyResult.Errors); - current = propertyResult.Value; + if (!currentMappingTree.TryGetProperty(segment, out var propertyNode)) + return Result.Fail($"Invalid property '{segment}' in lambda expression property path"); - // Update property mapping for nested object navigation + current = Expression.Property(current, propertyNode.ActualPropertyName); + + // Navigate to nested mapping for next segment if (i < propertyPath.Segments.Count - 1) { - currentPropertyMapping = PropertyMappingHelper.CreatePropertyMapping(current.Type); + if (!propertyNode.HasNestedMapping) + return Result.Fail($"Property '{segment}' does not support nested navigation in lambda expression"); + + currentMappingTree = propertyNode.NestedMapping; } } @@ -146,7 +141,7 @@ private static bool IsNullableReferenceType(Type type) private static bool IsPrimitiveType(Type type) { - return type.IsPrimitive || type == typeof(string) || type == typeof(decimal) || + return type.IsPrimitive || type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(Guid) || Nullable.GetUnderlyingType(type) != null; } @@ -313,7 +308,7 @@ private static Result EvaluateIdentifierExpression(InfixExpression e { var identifier = exp.Left.TokenLiteral(); - if (!context.PropertyMapping.TryGetValue(identifier, out var propertyName)) + if (!context.PropertyMappingTree.TryGetProperty(identifier, out var propertyNode)) { return Result.Fail($"Invalid property '{identifier}' within filter"); } @@ -322,7 +317,7 @@ private static Result EvaluateIdentifierExpression(InfixExpression e (Expression)context.CurrentLambda.Parameter : context.RootParameter; - var identifierProperty = Expression.Property(baseExpression, propertyName); + var identifierProperty = Expression.Property(baseExpression, propertyNode.ActualPropertyName); return EvaluateValueComparison(exp, identifierProperty); } @@ -374,7 +369,7 @@ private static Result EvaluateLambdaExpression(QueryLambdaExpression (Expression)context.CurrentLambda.Parameter : context.RootParameter; - var collectionResult = ResolveCollectionProperty(lambdaExp.Property, baseExpression, context.PropertyMapping); + var collectionResult = ResolveCollectionProperty(lambdaExp.Property, baseExpression, context.PropertyMappingTree); if (collectionResult.IsFailed) return Result.Fail(collectionResult.Errors); var collectionProperty = collectionResult.Value; @@ -396,19 +391,19 @@ private static Expression CreateLambdaLinqCall(string function, MemberExpression : CreateAllExpression(collection, lambda, elementType); } - private static Result ResolveCollectionProperty(QueryExpression property, Expression baseExpression, Dictionary propertyMapping) + private static Result ResolveCollectionProperty(QueryExpression property, Expression baseExpression, PropertyMappingTree propertyMappingTree) { switch (property) { case Identifier identifier: - if (!propertyMapping.TryGetValue(identifier.TokenLiteral(), out var propertyName)) + if (!propertyMappingTree.TryGetProperty(identifier.TokenLiteral(), out var propertyNode)) { return Result.Fail($"Invalid property '{identifier.TokenLiteral()}' in lambda expression"); } - return Expression.Property(baseExpression, propertyName); + return Expression.Property(baseExpression, propertyNode.ActualPropertyName); case PropertyPath propertyPath: - return ResolvePropertyPathForCollection(propertyPath, baseExpression, propertyMapping); + return ResolvePropertyPathForCollection(propertyPath, baseExpression, propertyMappingTree); default: return Result.Fail($"Unsupported property type in lambda expression: {property.GetType().Name}"); @@ -459,16 +454,16 @@ private static Result EvaluateLambdaBodyIdentifier(InfixExpression e { 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.PropertyMapping.TryGetValue(identifierName, out var propertyName)) + if (!context.PropertyMappingTree.TryGetProperty(identifierName, out var propertyNode)) { return Result.Fail($"Invalid property '{identifierName}' within filter"); } - var identifierProperty = Expression.Property(context.RootParameter, propertyName); + var identifierProperty = Expression.Property(context.RootParameter, propertyNode.ActualPropertyName); return EvaluateValueComparison(exp, identifierProperty); } @@ -495,9 +490,9 @@ private static Result EvaluateLambdaPropertyPath(InfixExpression exp var elementType = lambdaParameter.Type; // Build property path from lambda parameter - var pathResult = BuildLambdaPropertyPath(current, propertyPath.Segments.Skip(1), elementType); + var pathResult = BuildLambdaPropertyPath(current, propertyPath.Segments.Skip(1).ToList(), elementType); if (pathResult.IsFailed) return pathResult; - + current = pathResult.Value; var finalProperty = (MemberExpression)current; @@ -531,36 +526,35 @@ private static Expression CreateAllExpression(MemberExpression collection, Lambd return Expression.AndAlso(hasElements, allMatch); } - private static Result BuildLambdaPropertyPath(Expression startExpression, IEnumerable segments, Type elementType) + private static Result BuildLambdaPropertyPath(Expression startExpression, List segments, Type elementType) { var current = startExpression; - var lambdaPropertyMapping = PropertyMappingHelper.CreatePropertyMapping(elementType); + var currentMappingTree = PropertyMappingTreeBuilder.BuildMappingTree(elementType, GetDefaultMaxDepth()); foreach (var segment in segments) { - if (!lambdaPropertyMapping.TryGetValue(segment, out var propertyName)) + if (!currentMappingTree.TryGetProperty(segment, out var propertyNode)) { - // If not found in current type, update mapping for nested type and try again - if (current is MemberExpression memberExp) - { - lambdaPropertyMapping = PropertyMappingHelper.CreatePropertyMapping(memberExp.Type); - if (!lambdaPropertyMapping.TryGetValue(segment, out propertyName)) - { - return Result.Fail($"Invalid property '{segment}' in lambda property path"); - } - } - else - { - return Result.Fail($"Invalid property '{segment}' in lambda property path"); - } + return Result.Fail($"Invalid property '{segment}' in lambda property path"); } - current = Expression.Property(current, propertyName); + 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 diff --git a/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs b/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs index 212b26f..5f169f8 100644 --- a/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/OrderByEvaluator.cs @@ -7,18 +7,18 @@ public static class OrderByEvaluator { - public static Result> Evaluate(IEnumerable statements, ParameterExpression parameterExpression, IQueryable queryable, Dictionary propertyMapping) + public static Result> Evaluate(IEnumerable statements, ParameterExpression parameterExpression, IQueryable queryable, PropertyMappingTree propertyMappingTree) { var isAlreadyOrdered = false; foreach (var statement in statements) { - if (!propertyMapping.TryGetValue(statement.TokenLiteral(), out var propertyName)) + if (!propertyMappingTree.TryGetProperty(statement.TokenLiteral(), out var propertyNode)) { return Result.Fail(new Error($"Invalid property '{statement.TokenLiteral()}' within orderby")); } - var property = Expression.Property(parameterExpression, propertyName); + var property = Expression.Property(parameterExpression, propertyNode.ActualPropertyName); var lambda = Expression.Lambda(property, parameterExpression); if (isAlreadyOrdered) diff --git a/src/GoatQuery/src/Extensions/QueryableExtension.cs b/src/GoatQuery/src/Extensions/QueryableExtension.cs index b55bdab..0a06f55 100644 --- a/src/GoatQuery/src/Extensions/QueryableExtension.cs +++ b/src/GoatQuery/src/Extensions/QueryableExtension.cs @@ -1,9 +1,6 @@ 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 QueryableExtension @@ -18,7 +15,8 @@ public static Result> Apply(this IQueryable queryable, Quer var type = typeof(T); - var propertyMappings = PropertyMappingHelper.CreatePropertyMapping(); + var maxDepth = options?.MaxPropertyMappingDepth ?? new QueryOptions().MaxPropertyMappingDepth; + var propertyMappingTree = PropertyMappingTreeBuilder.BuildMappingTree(maxDepth); // Filter if (!string.IsNullOrEmpty(query.Filter)) @@ -33,7 +31,7 @@ public static Result> Apply(this IQueryable queryable, Quer ParameterExpression parameter = Expression.Parameter(type); - var expression = FilterEvaluator.Evaluate(statement.Value.Expression, parameter, propertyMappings); + var expression = FilterEvaluator.Evaluate(statement.Value.Expression, parameter, propertyMappingTree); if (expression.IsFailed) { return Result.Fail(expression.Errors); @@ -75,7 +73,7 @@ public static Result> Apply(this IQueryable queryable, Quer var parameter = Expression.Parameter(type); - var orderByQuery = OrderByEvaluator.Evaluate(statements, parameter, queryable, propertyMappings); + var orderByQuery = OrderByEvaluator.Evaluate(statements, parameter, queryable, propertyMappingTree); if (orderByQuery.IsFailed) { return Result.Fail(orderByQuery.Errors); diff --git a/src/GoatQuery/src/QueryOptions.cs b/src/GoatQuery/src/QueryOptions.cs index 442a727..24c195b 100644 --- a/src/GoatQuery/src/QueryOptions.cs +++ b/src/GoatQuery/src/QueryOptions.cs @@ -1,4 +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/Utilities/PropertyMappingHelper.cs b/src/GoatQuery/src/Utilities/PropertyMappingHelper.cs deleted file mode 100644 index 297fb62..0000000 --- a/src/GoatQuery/src/Utilities/PropertyMappingHelper.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text.Json.Serialization; - -internal static class PropertyMappingHelper -{ - public static Dictionary CreatePropertyMapping() - { - return CreatePropertyMapping(typeof(T)); - } - - public static Dictionary CreatePropertyMapping(Type type) - { - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - var properties = type.GetProperties(); - - foreach (var property in properties) - { - var jsonPropertyNameAttribute = property.GetCustomAttribute(); - if (jsonPropertyNameAttribute != null) - { - result[jsonPropertyNameAttribute.Name] = property.Name; - continue; - } - - result[property.Name] = property.Name; - } - - return result; - } -} \ 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..cec70a4 --- /dev/null +++ b/src/GoatQuery/src/Utilities/PropertyMappingTree.cs @@ -0,0 +1,182 @@ +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; + + // Handle nullable types + var underlyingType = Nullable.GetUnderlyingType(type); + return underlyingType != null && (underlyingType.IsPrimitive || PrimitiveTypes.Contains(underlyingType)); + } +} \ No newline at end of file From 610f65a87e72eb631af9fc1e87885849f73f4ce7 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Thu, 25 Sep 2025 19:35:34 +0100 Subject: [PATCH 53/60] removed implicit null check on object --- example/Controllers/UserController.cs | 16 +++++--- example/Dto/UserDto.cs | 38 +++++++++++++++++++ example/Profiles/AutoMapperProfile.cs | 12 ++++++ example/Program.cs | 11 +++++- example/example.csproj | 1 + .../src/Evaluator/FilterEvaluator.cs | 28 +++----------- 6 files changed, 76 insertions(+), 30 deletions(-) create mode 100644 example/Dto/UserDto.cs create mode 100644 example/Profiles/AutoMapperProfile.cs diff --git a/example/Controllers/UserController.cs b/example/Controllers/UserController.cs index fb9100f..0c478b7 100644 --- a/example/Controllers/UserController.cs +++ b/example/Controllers/UserController.cs @@ -1,3 +1,5 @@ +using AutoMapper; +using AutoMapper.QueryableExtensions; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -6,16 +8,19 @@ public class UsersController : ControllerBase { private readonly ApplicationDbContext _db; - public UsersController(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() + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [EnableQuery(maxTop: 10)] + public ActionResult> Get() { var users = _db.Users .Include(x => x.Company) @@ -23,7 +28,8 @@ public ActionResult> Get() .ThenInclude(x => x.City) .Include(x => x.Manager) .ThenInclude(x => x.Manager) - .Where(x => !x.IsDeleted); + .Where(x => !x.IsDeleted) + .ProjectTo(_mapper.ConfigurationProvider); return Ok(users); } diff --git a/example/Dto/UserDto.cs b/example/Dto/UserDto.cs new file mode 100644 index 0000000..4e1ac2d --- /dev/null +++ b/example/Dto/UserDto.cs @@ -0,0 +1,38 @@ +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 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/Profiles/AutoMapperProfile.cs b/example/Profiles/AutoMapperProfile.cs new file mode 100644 index 0000000..996d450 --- /dev/null +++ b/example/Profiles/AutoMapperProfile.cs @@ -0,0 +1,12 @@ +using AutoMapper; + +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 index 3e6ea21..5ef6198 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -1,4 +1,8 @@ +using System.Reflection; +using AutoMapper; +using AutoMapper.QueryableExtensions; using Bogus; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Testcontainers.PostgreSql; @@ -19,6 +23,8 @@ options.UseNpgsql(postgreSqlContainer.GetConnectionString()); }); +builder.Services.AddAutoMapper(Assembly.GetExecutingAssembly()); + var app = builder.Build(); using (var scope = app.Services.CreateScope()) @@ -71,7 +77,7 @@ Console.WriteLine($"Postgres connection string: {postgreSqlContainer.GetConnectionString()}"); -app.MapGet("/minimal/users", (ApplicationDbContext db, [AsParameters] Query query) => +app.MapGet("/minimal/users", (ApplicationDbContext db, [FromServices] IMapper mapper, [AsParameters] Query query) => { var result = db.Users .Include(x => x.Company) @@ -80,6 +86,7 @@ .Include(x => x.Manager) .ThenInclude(x => x.Manager) .Where(x => !x.IsDeleted) + .ProjectTo(mapper.ConfigurationProvider) .Apply(query); if (result.IsFailed) @@ -87,7 +94,7 @@ return Results.BadRequest(new { message = result.Errors }); } - var response = new PagedResponse(result.Value.Query.ToList(), result.Value.Count); + var response = new PagedResponse(result.Value.Query.ToList(), result.Value.Count); return Results.Ok(response); }); diff --git a/example/example.csproj b/example/example.csproj index 1654b7d..674a5e6 100644 --- a/example/example.csproj +++ b/example/example.csproj @@ -7,6 +7,7 @@ + diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index b1b5553..ee38258 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -55,27 +55,26 @@ private static Result EvaluatePropertyPathExpression( var propertyPathResult = BuildPropertyPath(propertyPath, baseExpression, context.PropertyMappingTree); if (propertyPathResult.IsFailed) return Result.Fail(propertyPathResult.Errors); - var (finalProperty, nullChecks) = propertyPathResult.Value; + var finalProperty = propertyPathResult.Value; if (exp.Right is NullLiteral) { var nullComparison = CreateNullComparison(exp, finalProperty); - return CombineWithNullChecks(nullComparison, nullChecks); + return nullComparison; } var comparisonResult = EvaluateValueComparison(exp, finalProperty); if (comparisonResult.IsFailed) return comparisonResult; - return CombineWithNullChecks(comparisonResult.Value, nullChecks); + return comparisonResult.Value; } - private static Result<(MemberExpression Property, List NullChecks)> BuildPropertyPath( + private static Result BuildPropertyPath( PropertyPath propertyPath, Expression startExpression, PropertyMappingTree propertyMappingTree) { var current = startExpression; - var nullChecks = new List(); var currentMappingTree = propertyMappingTree; foreach (var (segment, isLast) in propertyPath.Segments.Select((s, i) => (s, i == propertyPath.Segments.Count - 1))) @@ -85,12 +84,6 @@ private static Result EvaluatePropertyPathExpression( current = Expression.Property(current, propertyNode.ActualPropertyName); - // Add null check for intermediate reference types only (not the final property) - if (!isLast && IsNullableReferenceType(current.Type)) - { - nullChecks.Add(Expression.NotEqual(current, Expression.Constant(null, current.Type))); - } - // Navigate to nested mapping for next segment if (!isLast) { @@ -101,7 +94,7 @@ private static Result EvaluatePropertyPathExpression( } } - return Result.Ok(((MemberExpression)current, nullChecks)); + return Result.Ok((MemberExpression)current); } private static Result ResolvePropertyPathForCollection( @@ -185,17 +178,6 @@ private static Expression CreateDateComparison(Expression dateProperty, Constant }; } - private static Result CombineWithNullChecks(Expression comparison, List nullChecks) - { - if (!nullChecks.Any()) - { - return comparison; - } - - var allNullChecks = nullChecks.Aggregate(Expression.AndAlso); - - return Expression.AndAlso(allNullChecks, comparison); - } private static Result EvaluateValueComparison(InfixExpression exp, MemberExpression property) { From edf56d46d3067b142dec5400bdbbdb81e2aea7b7 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:52:00 +0100 Subject: [PATCH 54/60] format --- example/Program.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/example/Program.cs b/example/Program.cs index 5ef6198..d9f9112 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -81,11 +81,11 @@ { 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) + .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); From 5824282dde0fb0a85552c40329f8c982763d7402 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:07:28 +0100 Subject: [PATCH 55/60] test --- src/GoatQuery/tests/Filter/FilterTest.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 856ab34..7708ff2 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -329,11 +329,6 @@ public static IEnumerable Parameters() new[] { TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["NullUser"] } }; - yield return new object[] { - "manager/manager ne null", - new[] { TestData.Users["Egg"] } - }; - yield return new object[] { "manager/firstName eq 'Manager 01' and manager/age eq 16", new[] { TestData.Users["John"], TestData.Users["Apple"] } From 3ca55b2c9848587bffc7b772c4d9a494e4cb2274 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Fri, 26 Sep 2025 19:10:02 +0100 Subject: [PATCH 56/60] tests --- src/GoatQuery/tests/DatabaseTestFixture.cs | 58 ++++++++++++++++++++++ src/GoatQuery/tests/Filter/FilterTest.cs | 9 +++- src/GoatQuery/tests/TestData.cs | 34 ++++--------- src/GoatQuery/tests/TestDbContext.cs | 13 +++++ src/GoatQuery/tests/User.cs | 5 +- src/GoatQuery/tests/tests.csproj | 4 ++ 6 files changed, 98 insertions(+), 25 deletions(-) create mode 100644 src/GoatQuery/tests/DatabaseTestFixture.cs create mode 100644 src/GoatQuery/tests/TestDbContext.cs diff --git a/src/GoatQuery/tests/DatabaseTestFixture.cs b/src/GoatQuery/tests/DatabaseTestFixture.cs new file mode 100644 index 0000000..36066e9 --- /dev/null +++ b/src/GoatQuery/tests/DatabaseTestFixture.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore; +using Testcontainers.PostgreSql; +using Xunit; + +public class DatabaseTestFixture : IAsyncLifetime +{ + private PostgreSqlContainer? _postgresContainer; + private TestDbContext? _dbContext; + + public TestDbContext DbContext => _dbContext ?? throw new InvalidOperationException("Database not initialized"); + + public async Task InitializeAsync() + { + // Create and start PostgreSQL container + _postgresContainer = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .WithDatabase("testdb") + .WithUsername("test") + .WithPassword("test") + .Build(); + + await _postgresContainer.StartAsync(); + + // Create DbContext with connection to container + var connectionString = _postgresContainer.GetConnectionString(); + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql(connectionString); + + _dbContext = new TestDbContext(optionsBuilder.Options); + + // Create database schema + await _dbContext.Database.EnsureCreatedAsync(); + + // Seed test data + 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/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 7708ff2..0bdd7d2 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -1,7 +1,14 @@ using Xunit; -public sealed class FilterTest +public sealed class FilterTest : IClassFixture { + private readonly DatabaseTestFixture _fixture; + + public FilterTest(DatabaseTestFixture fixture) + { + _fixture = fixture; + } + public static IEnumerable Parameters() { yield return new object[] { diff --git a/src/GoatQuery/tests/TestData.cs b/src/GoatQuery/tests/TestData.cs index d96428c..e04c1f0 100644 --- a/src/GoatQuery/tests/TestData.cs +++ b/src/GoatQuery/tests/TestData.cs @@ -6,8 +6,7 @@ public static class TestData { Age = 2, Firstname = "John", - UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), - DateOfBirth = DateTime.Parse("2004-01-31 23:59:59"), + DateOfBirth = DateTime.Parse("2004-01-31 23:59:59").ToUniversalTime(), BalanceDecimal = 1.50m, IsEmailVerified = true, Addresses = new[] @@ -27,8 +26,7 @@ public static class TestData { Age = 16, Firstname = "Manager 01", - UserId = Guid.Parse("671e6bac-b6de-4cc7-b3e9-1a6ac4546b43"), - DateOfBirth = DateTime.Parse("2000-01-01 00:00:00"), + DateOfBirth = DateTime.Parse("2000-01-01 00:00:00").ToUniversalTime(), BalanceDecimal = 2.00m, IsEmailVerified = false } @@ -37,8 +35,7 @@ public static class TestData { Age = 9, Firstname = "Jane", - UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), - DateOfBirth = DateTime.Parse("2020-05-09 15:30:00"), + DateOfBirth = DateTime.Parse("2020-05-09 15:30:00").ToUniversalTime(), BalanceDecimal = 0, IsEmailVerified = false, Addresses = new[] @@ -59,8 +56,7 @@ public static class TestData { Age = 1, Firstname = "Apple", - UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), - DateOfBirth = DateTime.Parse("1980-12-31 00:00:01"), + DateOfBirth = DateTime.Parse("1980-12-31 00:00:01").ToUniversalTime(), BalanceFloat = 1204050.98f, IsEmailVerified = true, Addresses = new[] @@ -80,8 +76,7 @@ public static class TestData { Age = 16, Firstname = "Manager 01", - UserId = Guid.Parse("671e6bac-b6de-4cc7-b3e9-1a6ac4546b43"), - DateOfBirth = DateTime.Parse("2000-01-01 00:00:00"), + DateOfBirth = DateTime.Parse("2000-01-01 00:00:00").ToUniversalTime(), BalanceDecimal = 2.00m, IsEmailVerified = true }, @@ -91,8 +86,7 @@ public static class TestData { Age = 1, Firstname = "Harry", - UserId = Guid.Parse("e4c7772b-8947-4e46-98ed-644b417d2a08"), - DateOfBirth = DateTime.Parse("2002-08-01"), + DateOfBirth = DateTime.Parse("2002-08-01").ToUniversalTime(), BalanceDecimal = 0.5372958205929493m, IsEmailVerified = false, Addresses = Array.Empty
() @@ -101,8 +95,7 @@ public static class TestData { Age = 1, Firstname = "Doe", - UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), - DateOfBirth = DateTime.Parse("2023-07-26 12:00:30"), + DateOfBirth = DateTime.Parse("2023-07-26 12:00:30").ToUniversalTime(), BalanceDecimal = null, IsEmailVerified = true, Addresses = new[] @@ -118,8 +111,7 @@ public static class TestData { Age = 33, Firstname = "Egg", - UserId = Guid.Parse("58cdeca3-645b-457c-87aa-7d5f87734255"), - DateOfBirth = DateTime.Parse("2000-01-01 00:00:00"), + DateOfBirth = DateTime.Parse("2000-01-01 00:00:00").ToUniversalTime(), BalanceDouble = 1334534453453433.33435443343231235652d, IsEmailVerified = false, Addresses = new[] @@ -139,24 +131,21 @@ public static class TestData { Age = 18, Firstname = "Manager 02", - UserId = Guid.Parse("2bde56ac-4829-41fb-abbc-2b8454962e2a"), - DateOfBirth = DateTime.Parse("1999-04-21 00:00:00"), + DateOfBirth = DateTime.Parse("1999-04-21 00:00:00").ToUniversalTime(), BalanceDecimal = 19.00m, IsEmailVerified = true, Manager = new User { Age = 30, Firstname = "Manager 03", - UserId = Guid.Parse("8ef23728-c429-42f9-98ee-425419092664"), - DateOfBirth = DateTime.Parse("1993-04-21 00:00:00"), + DateOfBirth = DateTime.Parse("1993-04-21 00:00:00").ToUniversalTime(), BalanceDecimal = 29.00m, IsEmailVerified = true, Manager = new User { Age = 40, Firstname = "Manager 04", - UserId = Guid.Parse("4cde56ac-4829-41fb-abbc-2b8454962e2a"), - DateOfBirth = DateTime.Parse("1983-04-21 00:00:00"), + DateOfBirth = DateTime.Parse("1983-04-21 00:00:00").ToUniversalTime(), BalanceDecimal = 39.00m, IsEmailVerified = true }, @@ -173,7 +162,6 @@ public static class TestData { Age = 4, Firstname = "NullUser", - UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), DateOfBirth = null, BalanceDecimal = null, BalanceDouble = null, diff --git a/src/GoatQuery/tests/TestDbContext.cs b/src/GoatQuery/tests/TestDbContext.cs new file mode 100644 index 0000000..00f8c29 --- /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) + { + + } +} \ No newline at end of file diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs index 40257d4..6021c6d 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -2,8 +2,8 @@ public record User { + public Guid Id { get; set; } public int Age { get; set; } - public Guid UserId { get; set; } public string Firstname { get; set; } = string.Empty; public decimal? BalanceDecimal { get; set; } public double? BalanceDouble { get; set; } @@ -24,18 +24,21 @@ public sealed record CustomJsonPropertyUser : User 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; } \ No newline at end of file diff --git a/src/GoatQuery/tests/tests.csproj b/src/GoatQuery/tests/tests.csproj index 1f6458d..f7cdc60 100644 --- a/src/GoatQuery/tests/tests.csproj +++ b/src/GoatQuery/tests/tests.csproj @@ -18,6 +18,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + + + From 7c0e21fb79594d093ea76e277535ec0d06180d2e Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Sun, 28 Sep 2025 23:35:17 +0100 Subject: [PATCH 57/60] tests --- src/GoatQuery/tests/DatabaseTestFixture.cs | 13 +++++++++++++ src/GoatQuery/tests/Filter/FilterTest.cs | 9 ++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/GoatQuery/tests/DatabaseTestFixture.cs b/src/GoatQuery/tests/DatabaseTestFixture.cs index 36066e9..aeaf0ce 100644 --- a/src/GoatQuery/tests/DatabaseTestFixture.cs +++ b/src/GoatQuery/tests/DatabaseTestFixture.cs @@ -1,4 +1,6 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; using Testcontainers.PostgreSql; using Xunit; @@ -26,6 +28,17 @@ public async Task InitializeAsync() var optionsBuilder = new DbContextOptionsBuilder(); optionsBuilder.UseNpgsql(connectionString); + // Enable EF Core logging + optionsBuilder.LogTo( + Console.WriteLine, + new[] { DbLoggerCategory.Database.Command.Name, DbLoggerCategory.Query.Name }, + LogLevel.Information, + DbContextLoggerOptions.DefaultWithLocalTime | DbContextLoggerOptions.SingleLine + ); + + optionsBuilder.EnableSensitiveDataLogging(); + optionsBuilder.EnableDetailedErrors(); + _dbContext = new TestDbContext(optionsBuilder.Options); // Create database schema diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 0bdd7d2..35f6354 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using Xunit; public sealed class FilterTest : IClassFixture @@ -391,7 +392,6 @@ public static IEnumerable Parameters() new[] { TestData.Users["Egg"] } }; - // Lambda expression tests with addresses/any yield return new object[] { "addresses/any(addr: addr/city/name eq 'New York')", new[] { TestData.Users["John"], TestData.Users["Apple"] } @@ -508,7 +508,10 @@ public void Test_Filter(string filter, IEnumerable expected) Filter = filter }; - var result = TestData.Users.Values.AsQueryable().Apply(query); + var result = _fixture.DbContext.Users.Apply(query); + + Console.WriteLine("------------------------------------------ QUERY ------------------------------------------"); + Console.WriteLine(result.Value.Query.ToQueryString()); Assert.Equal(expected, result.Value.Query); } @@ -528,7 +531,7 @@ public void Test_InvalidFilterReturnsError(string filter) Filter = filter }; - var result = TestData.Users.Values.AsQueryable().Apply(query); + var result = _fixture.DbContext.Users.Apply(query); Assert.True(result.IsFailed); } From 414c5307533d1efe311ae3aecca668f85b4a6b15 Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:35:09 +0100 Subject: [PATCH 58/60] tests --- src/GoatQuery/tests/DatabaseTestFixture.cs | 37 +- src/GoatQuery/tests/Filter/FilterTest.cs | 399 +++++++-------------- src/GoatQuery/tests/TestData.cs | 246 ++++++------- src/GoatQuery/tests/TestDbContext.cs | 2 +- src/GoatQuery/tests/User.cs | 14 +- 5 files changed, 258 insertions(+), 440 deletions(-) diff --git a/src/GoatQuery/tests/DatabaseTestFixture.cs b/src/GoatQuery/tests/DatabaseTestFixture.cs index aeaf0ce..132e816 100644 --- a/src/GoatQuery/tests/DatabaseTestFixture.cs +++ b/src/GoatQuery/tests/DatabaseTestFixture.cs @@ -7,59 +7,40 @@ public class DatabaseTestFixture : IAsyncLifetime { private PostgreSqlContainer? _postgresContainer; - private TestDbContext? _dbContext; - - public TestDbContext DbContext => _dbContext ?? throw new InvalidOperationException("Database not initialized"); + public TestDbContext DbContext { get; set; } = null!; public async Task InitializeAsync() { - // Create and start PostgreSQL container _postgresContainer = new PostgreSqlBuilder() - .WithImage("postgres:16-alpine") - .WithDatabase("testdb") - .WithUsername("test") - .WithPassword("test") + .WithImage("postgres:18-alpine") .Build(); await _postgresContainer.StartAsync(); - // Create DbContext with connection to container var connectionString = _postgresContainer.GetConnectionString(); var optionsBuilder = new DbContextOptionsBuilder(); optionsBuilder.UseNpgsql(connectionString); - // Enable EF Core logging - optionsBuilder.LogTo( - Console.WriteLine, - new[] { DbLoggerCategory.Database.Command.Name, DbLoggerCategory.Query.Name }, - LogLevel.Information, - DbContextLoggerOptions.DefaultWithLocalTime | DbContextLoggerOptions.SingleLine - ); - - optionsBuilder.EnableSensitiveDataLogging(); - optionsBuilder.EnableDetailedErrors(); + DbContext = new TestDbContext(optionsBuilder.Options); - _dbContext = new TestDbContext(optionsBuilder.Options); + await DbContext.Database.EnsureCreatedAsync(); - // Create database schema - await _dbContext.Database.EnsureCreatedAsync(); - - // Seed test data await SeedTestData(); } private async Task SeedTestData() { var users = TestData.Users.Values.ToList(); - await _dbContext.Users.AddRangeAsync(users); - await _dbContext.SaveChangesAsync(); + + await DbContext.Users.AddRangeAsync(users); + await DbContext.SaveChangesAsync(); } public async Task DisposeAsync() { - if (_dbContext != null) + if (DbContext != null) { - await _dbContext.DisposeAsync(); + await DbContext.DisposeAsync(); } if (_postgresContainer != null) diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 35f6354..d0ae693 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -1,4 +1,3 @@ -using Microsoft.EntityFrameworkCore; using Xunit; public sealed class FilterTest : IClassFixture @@ -13,8 +12,8 @@ public FilterTest(DatabaseTestFixture fixture) public static IEnumerable Parameters() { yield return new object[] { - "firstname eq 'John'", - new[] { TestData.Users["John"] } + "firstname eq 'User01'", + new[] { TestData.Users["User01"] } }; yield return new object[] { @@ -23,479 +22,333 @@ public static IEnumerable Parameters() }; yield return new object[] { - "Age eq 1", - new[] { TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"] } + "Age eq 25", + new[] { TestData.Users["User01"], TestData.Users["User05"] } }; yield return new object[] { - "Age eq 0", - Array.Empty() - }; - - yield return new object[] { - "firstname eq 'John' and Age eq 2", - new[] { TestData.Users["John"] } - }; - - yield return new object[] { - "firstname eq 'John' or Age eq 33", - new[] { TestData.Users["John"], TestData.Users["Egg"] } + "Age eq 30", + new[] { TestData.Users["User02"], TestData.Users["User03"] } }; yield return new object[] { - "Age eq 1 and firstName eq 'Harry' or Age eq 2", - new[] { TestData.Users["John"], TestData.Users["Harry"] } + "Age eq 35", + new[] { TestData.Users["User04"] } }; yield return new object[] { - "Age eq 1 or Age eq 2 or firstName eq 'Egg'", - new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["Egg"] } - }; - - yield return new object[] { - "Age ne 33", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["NullUser"] } - }; - - yield return new object[] { - "firstName contains 'a'", - new[] { TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"] } - }; - - yield return new object[] { - "Age ne 1 and firstName contains 'a'", - new[] { TestData.Users["Jane"] } - }; - - yield return new object[] { - "Age ne 1 and firstName contains 'a' or firstName eq 'Apple'", - new[] { TestData.Users["Jane"], TestData.Users["Apple"] } - }; - - yield return new object[] { - "Firstname eq 'John' and Age eq 2 or Age eq 33", - new[] { TestData.Users["John"], TestData.Users["Egg"] } + "Age eq 0", + Array.Empty() }; yield return new object[] { - "(Firstname eq 'John' and Age eq 2) or Age eq 33", - new[] { TestData.Users["John"], TestData.Users["Egg"] } + "firstname eq 'User02' and Age eq 30", + new[] { TestData.Users["User02"] } }; yield return new object[] { - "Firstname eq 'John' and (Age eq 2 or Age eq 33)", - new[] { TestData.Users["John"] } + "firstname eq 'User01' or Age eq 35", + new[] { TestData.Users["User01"], TestData.Users["User04"] } }; yield return new object[] { - "(Firstname eq 'John' and Age eq 2 or Age eq 33)", - new[] { TestData.Users["John"], TestData.Users["Egg"] } + "Age ne 30", + new[] { TestData.Users["User01"], TestData.Users["User04"], TestData.Users["User05"] } }; yield return new object[] { - "(Firstname eq 'John') or (Age eq 33 and Firstname eq 'Egg') or Age eq 1 and (Age eq 2)", - new[] { TestData.Users["John"], TestData.Users["Egg"] } + "firstName contains '0'", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User04"], TestData.Users["User05"] } }; yield return new object[] { - "UserId eq e4c7772b-8947-4e46-98ed-644b417d2a08", - new[] { TestData.Users["Harry"] } + "firstName contains '5'", + new[] { TestData.Users["User05"] } }; yield return new object[] { - "age lt 3", - new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"] } + "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[] { - "age lt 1", - Array.Empty() + "(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[] { - "age lte 2", - new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"] } + "Firstname eq 'User01' and (Age eq 25 or Age eq 30)", + new[] { TestData.Users["User01"] } }; yield return new object[] { - "age gt 1", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Egg"], TestData.Users["NullUser"] } + "(Firstname eq 'User02' and Age eq 30 or Age eq 35)", + new[] { TestData.Users["User02"], TestData.Users["User04"] } }; yield return new object[] { - "age gte 3", - new[] { TestData.Users["Jane"], TestData.Users["Egg"], TestData.Users["NullUser"] } + "(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[] { - "age lt 3 and age gt 1", - new[] { TestData.Users["John"] } + "id eq 11111111-1111-1111-1111-111111111111", + new[] { TestData.Users["User01"] } }; yield return new object[] { - "balanceDecimal eq 1.50m", - new[] { TestData.Users["John"] } + "age lt 30", + new[] { TestData.Users["User01"], TestData.Users["User05"] } }; yield return new object[] { - "balanceDecimal gt 1m", - new[] { TestData.Users["John"] } + "age lte 30", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User05"] } }; yield return new object[] { - "balanceDecimal gt 0.50m", - new[] { TestData.Users["John"], TestData.Users["Harry"] } + "age gt 25", + new[] { TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User04"] } }; yield return new object[] { - "balanceDecimal eq 0.5372958205929493m", - new[] { TestData.Users["Harry"] } + "age gte 30", + new[] { TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User04"] } }; yield return new object[] { - "balanceDouble eq 1334534453453433.33435443343231235652d", - new[] { TestData.Users["Egg"] } + "age lt 35 and age gt 25", + new[] { TestData.Users["User02"], TestData.Users["User03"] } }; yield return new object[] { - "balanceFloat eq 1204050.98f", - new[] { TestData.Users["Apple"] } + "balanceDecimal eq 1500.75m", + new[] { TestData.Users["User01"] } }; yield return new object[] { - "balanceFloat gt 2204050f", - Array.Empty() + "balanceDecimal eq 500.00m", + new[] { TestData.Users["User02"] } }; yield return new object[] { - "dateOfBirth eq 2000-01-01", - new[] { TestData.Users["Egg"] } + "balanceDecimal gt 100m", + new[] { TestData.Users["User01"], TestData.Users["User02"] } }; yield return new object[] { - "dateOfBirth eq 2020-05-09", - new[] { TestData.Users["Jane"] } - }; - - yield return new object[] { - "dateOfBirth lt 2010-01-01", - new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Egg"] } + "balanceDecimal eq null", + new[] { TestData.Users["User03"], TestData.Users["User04"] } }; yield return new object[] { - "dateOfBirth lte 2002-08-01", - new[] { TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Egg"] } + "balanceDecimal ne null", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User05"] } }; yield return new object[] { - "dateOfBirth gt 2000-08-01 and dateOfBirth lt 2023-01-01", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Harry"] } + "balanceDouble eq 2500.50d", + new[] { TestData.Users["User01"] } }; yield return new object[] { - "dateOfBirth eq 2023-07-26T12:00:30Z", - new[] { TestData.Users["Doe"] } + "balanceDouble eq null", + new[] { TestData.Users["User02"], TestData.Users["User04"] } }; yield return new object[] { - "dateOfBirth gte 2000-01-01", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["Egg"] } + "balanceFloat eq 3500.25f", + new[] { TestData.Users["User01"] } }; yield return new object[] { - "dateOfBirth gte 2000-01-01 and dateOfBirth lte 2020-05-09T15:29:59", - new[] { TestData.Users["John"], TestData.Users["Harry"], TestData.Users["Egg"] } + "balanceFloat eq 750.50f", + new[] { TestData.Users["User02"] } }; yield return new object[] { - "balanceDecimal eq null", - new[] { TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["Egg"], TestData.Users["NullUser"] } + "balanceFloat eq null", + new[] { TestData.Users["User03"], TestData.Users["User04"], TestData.Users["User05"] } }; yield return new object[] { - "balanceDecimal ne null", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Harry"] } + "dateOfBirth eq 1998-03-15T10:30:00Z", + new[] { TestData.Users["User01"] } }; yield return new object[] { - "balanceDouble eq null", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["NullUser"] } + "dateOfBirth eq 1993-07-20T14:00:00Z", + new[] { TestData.Users["User02"] } }; yield return new object[] { - "balanceDouble ne null", - new[] { TestData.Users["Egg"] } + "dateOfBirth lt 1995-01-01T00:00:00Z", + new[] { TestData.Users["User02"], TestData.Users["User03"] } }; yield return new object[] { - "balanceFloat eq null", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["Egg"], TestData.Users["NullUser"] } + "dateOfBirth gte 1993-01-01T00:00:00Z", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User05"] } }; yield return new object[] { - "balanceFloat ne null", - new[] { TestData.Users["Apple"] } + "dateOfBirth eq null", + new[] { TestData.Users["User04"] } }; yield return new object[] { - "dateOfBirth eq null", - new[] { TestData.Users["NullUser"] } + "dateOfBirth ne null", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User05"] } }; yield return new object[] { - "dateOfBirth ne null", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["Egg"] } + "age gt 25 and isEmailVerified eq true", + new[] { TestData.Users["User03"] } }; yield return new object[] { - "balanceDecimal eq null and age gt 3", - new[] { TestData.Users["Egg"], TestData.Users["NullUser"] } + "balanceDecimal ne null and age lt 30", + new[] { TestData.Users["User01"], TestData.Users["User05"] } }; yield return new object[] { - "balanceDecimal ne null or age eq 4", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["NullUser"] } + "manager ne null and isEmailVerified eq false", + new[] { TestData.Users["User02"], TestData.Users["User04"] } }; yield return new object[] { - "firstname eq 'Doe' and balanceDecimal eq null", - new[] { TestData.Users["Doe"] } + "(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["John"], TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["NullUser"] } + new[] { TestData.Users["User01"], TestData.Users["User03"], TestData.Users["User05"] } }; yield return new object[] { "isEmailVerified eq false", - new[] { TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["Egg"] } + new[] { TestData.Users["User02"], TestData.Users["User04"] } }; yield return new object[] { "isEmailVerified ne true", - new[] { TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["Egg"] } + new[] { TestData.Users["User02"], TestData.Users["User04"] } }; yield return new object[] { "isEmailVerified ne false", - new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["NullUser"] } - }; - - yield return new object[] { - "age gt 2 and isEmailVerified eq true", - new[] { TestData.Users["NullUser"] } - }; - - yield return new object[] { - "isEmailVerified eq false or age eq 2", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["Egg"] } - }; - - yield return new object[] { - "manager/firstName eq 'Manager 01'", - new[] { TestData.Users["John"], TestData.Users["Apple"] } + new[] { TestData.Users["User01"], TestData.Users["User03"], TestData.Users["User05"] } }; yield return new object[] { - "manager/firstName ne 'Manager 01'", - new[] { TestData.Users["Egg"] } + "manager/firstName eq 'User01'", + new[] { TestData.Users["User02"] } }; yield return new object[] { - "manager/firstName contains 'Manager'", - new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Egg"] } + "manager/firstName eq 'User02'", + new[] { TestData.Users["User03"], TestData.Users["User04"] } }; yield return new object[] { - "manager/firstName eq 'Manager 02'", - new[] { TestData.Users["Egg"] } + "manager/age gt 28", + new[] { TestData.Users["User03"], TestData.Users["User04"] } }; yield return new object[] { "manager/isEmailVerified eq true", - new[] { TestData.Users["Apple"], TestData.Users["Egg"] } - }; - - yield return new object[] { - "manager/age gt 16", - new[] { TestData.Users["Egg"] } - }; - - yield return new object[] { - "manager/manager/firstName eq 'Manager 03'", - new[] { TestData.Users["Egg"] } + new[] { TestData.Users["User02"] } }; yield return new object[] { - "manager/manager/firstName ne 'Manager 03'", - new List() + "manager/manager/firstName eq 'User01'", + new[] { TestData.Users["User03"], TestData.Users["User04"] } }; yield return new object[] { - "manager eq null", - new[] { TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["NullUser"] } + "manager/manager eq null", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User05"] } }; yield return new object[] { - "manager/firstName eq 'Manager 01' and manager/age eq 16", - new[] { TestData.Users["John"], TestData.Users["Apple"] } + "company/name eq 'TechCorp'", + new[] { TestData.Users["User01"] } }; yield return new object[] { - "manager/dateOfBirth lt 2000-01-01", - new[] { TestData.Users["Egg"] } + "company/name eq 'DataSoft'", + new[] { TestData.Users["User02"] } }; yield return new object[] { - "manager/balanceDecimal gte 2.00m and manager/balanceDecimal lt 20m", - new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Egg"] } - }; - - yield return new object[] { - "manager/userId eq 671e6bac-b6de-4cc7-b3e9-1a6ac4546b43", - new[] { TestData.Users["John"], TestData.Users["Apple"] } - }; - - yield return new object[] { - "(age eq 2 and manager/isEmailVerified eq true) or (age eq 33 and manager/manager ne null)", - new[] { TestData.Users["Egg"] } - }; - - yield return new object[] { - "manager ne null and manager/manager eq null", - new[] { TestData.Users["John"], TestData.Users["Apple"] } - }; - - yield return new object[] { - "company ne null ", - new[] { TestData.Users["Jane"] } - }; - - yield return new object[] { - "company/name eq 'Acme Corp'", - new[] { TestData.Users["Jane"] } - }; - - yield return new object[] { - "manager/manager/company/name eq 'My Test Company'", - new[] { TestData.Users["Egg"] } - }; - - yield return new object[] { - "manager/balanceDecimal gt 100m", - Array.Empty() - }; - - yield return new object[] { - "manager/manager/manager/firstName eq 'Manager 04'", - new[] { TestData.Users["Egg"] } + "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["John"], TestData.Users["Apple"] } + new[] { TestData.Users["User01"] } }; yield return new object[] { "addresses/any(address: address/city/name eq 'Chicago')", - new[] { TestData.Users["Apple"] } + new[] { TestData.Users["User01"] } }; yield return new object[] { "addresses/any(a: a/city/name eq 'Seattle')", - new[] { TestData.Users["Jane"] } + new[] { TestData.Users["User02"] } }; yield return new object[] { - "addresses/any(addr: addr/city/name eq 'NonExistentCity')", - Array.Empty() + "addresses/any(addr: addr/city/name eq 'Miami')", + new[] { TestData.Users["User05"] } }; - // Lambda expression tests with addresses/all yield return new object[] { - "addresses/all(addr: addr/city/country eq 'USA')", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["Egg"] } - }; - - yield return new object[] { - "addresses/all(a: a/city/name ne 'Chicago')", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Doe"], TestData.Users["Egg"] } - }; - - // Lambda expression tests with addressLine1 - yield return new object[] { - "addresses/any(addr: addr/addressLine1 contains 'Main')", - new[] { TestData.Users["John"] } - }; - - yield return new object[] { - "addresses/any(a: a/addressLine1 contains 'St')", - new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Egg"] } - }; - - // Lambda expressions combined with regular filters - yield return new object[] { - "firstname eq 'John' and addresses/any(addr: addr/city/name eq 'New York')", - new[] { TestData.Users["John"] } - }; - - yield return new object[] { - "age eq 1 or addresses/any(addr: addr/city/name eq 'Miami')", - new[] { TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["Egg"] } - }; - - yield return new object[] { - "addresses/any(addr: addr/city/name eq 'Seattle') and isEmailVerified eq false", - new[] { TestData.Users["Jane"] } - }; - - // Empty addresses should work with any() returning false and all() returning true - yield return new object[] { - "addresses/any(addr: addr/city/name eq 'AnyCity')", + "addresses/any(addr: addr/city/name eq 'NonExistentCity')", Array.Empty() }; yield return new object[] { - "addresses/all(addr: addr/city/name ne 'SomeCity')", - new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["Egg"] } + "addresses/all(addr: addr/city/country eq 'USA')", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User05"] } }; - // Complex lambda expressions with logical operators yield return new object[] { - "addresses/any(addr: addr/city/name eq 'New York' or addr/city/name eq 'Chicago')", - new[] { TestData.Users["John"], TestData.Users["Apple"] } + "addresses/any(addr: addr/addressLine1 contains 'Main')", + new[] { TestData.Users["User01"] } }; yield return new object[] { - "addresses/any(addr: addr/city/name eq 'Miami' and addr/city/country eq 'USA')", - new[] { TestData.Users["Egg"] } + "addresses/any(addr: addr/addressLine1 contains 'Oak')", + new[] { TestData.Users["User01"] } }; - // Testing with users that have no addresses (empty collections) yield return new object[] { - "firstname eq 'Harry' and addresses/any(addr: addr/city/name eq 'NonExistent')", - Array.Empty() + "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 'NullUser' and addresses/all(addr: addr/city/country eq 'USA')", - Array.Empty() + "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["Apple"] } + new[] { TestData.Users["User01"] } }; yield return new object[] { "tags/any(x: x eq 'premium')", - new[] { TestData.Users["Apple"], TestData.Users["Egg"] } + new[] { TestData.Users["User01"], TestData.Users["User02"] } }; yield return new object[] { - "tags/all(x: x eq 'premium')", - new[] { TestData.Users["Egg"] } + "tags/any(x: x eq 'standard')", + new[] { TestData.Users["User05"] } }; } @@ -505,14 +358,12 @@ public void Test_Filter(string filter, IEnumerable expected) { var query = new Query { - Filter = filter + Filter = filter, + OrderBy = "Firstname" // Ensure consistent ordering for tests }; var result = _fixture.DbContext.Users.Apply(query); - Console.WriteLine("------------------------------------------ QUERY ------------------------------------------"); - Console.WriteLine(result.Value.Query.ToQueryString()); - Assert.Equal(expected, result.Value.Query); } diff --git a/src/GoatQuery/tests/TestData.cs b/src/GoatQuery/tests/TestData.cs index e04c1f0..a810b79 100644 --- a/src/GoatQuery/tests/TestData.cs +++ b/src/GoatQuery/tests/TestData.cs @@ -1,173 +1,157 @@ 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 { - ["John"] = new User + ["User01"] = new User { - Age = 2, - Firstname = "John", - DateOfBirth = DateTime.Parse("2004-01-31 23:59:59").ToUniversalTime(), - BalanceDecimal = 1.50m, + Id = User01Id, + Age = 25, + Firstname = "User01", + DateOfBirth = DateTime.SpecifyKind(DateTime.Parse("1998-03-15 10:30:00"), DateTimeKind.Utc), + BalanceDecimal = 1500.75m, + BalanceDouble = 2500.50d, + BalanceFloat = 3500.25f, IsEmailVerified = true, - Addresses = new[] + 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 { Name = "New York", Country = "USA" } + City = new City + { + Id = Guid.NewGuid(), + Name = "New York", + Country = "USA" + } }, new Address { + Id = Guid.NewGuid(), AddressLine1 = "456 Oak Ave", - City = new City { Name = "Boston", Country = "USA" } + City = new City + { + Id = Guid.NewGuid(), + Name = "Chicago", + Country = "USA" + } } - }, - Manager = new User - { - Age = 16, - Firstname = "Manager 01", - DateOfBirth = DateTime.Parse("2000-01-01 00:00:00").ToUniversalTime(), - BalanceDecimal = 2.00m, - IsEmailVerified = false - } + ], + Tags = ["vip", "premium"] }, - ["Jane"] = new User + ["User02"] = new User { - Age = 9, - Firstname = "Jane", - DateOfBirth = DateTime.Parse("2020-05-09 15:30:00").ToUniversalTime(), - BalanceDecimal = 0, + Id = User02Id, + Age = 30, + Firstname = "User02", + DateOfBirth = DateTime.SpecifyKind(DateTime.Parse("1993-07-20 14:00:00"), DateTimeKind.Utc), + BalanceDecimal = 500.00m, + BalanceDouble = null, + BalanceFloat = 750.50f, IsEmailVerified = false, - Addresses = new[] + ManagerId = User01Id, + Company = new Company { + Id = Guid.NewGuid(), + Name = "DataSoft", + Department = "Development" + }, + Addresses = + [ new Address { - AddressLine1 = "789 Pine Rd", - City = new City { Name = "Seattle", Country = "USA" } + Id = Guid.NewGuid(), + AddressLine1 = "789 Pine St", + City = new City + { + Id = Guid.NewGuid(), + Name = "Seattle", + Country = "USA" + } } - }, - Company = new Company - { - Name = "Acme Corp", - Department = "Sales" - } + ], + Tags = ["premium"] }, - ["Apple"] = new User + ["User03"] = new User { - Age = 1, - Firstname = "Apple", - DateOfBirth = DateTime.Parse("1980-12-31 00:00:01").ToUniversalTime(), - BalanceFloat = 1204050.98f, + Id = User03Id, + Age = 30, + Firstname = "User03", + DateOfBirth = DateTime.SpecifyKind(DateTime.Parse("1993-11-10 09:15:00"), DateTimeKind.Utc), + BalanceDecimal = null, + BalanceDouble = 1000.00d, + BalanceFloat = null, IsEmailVerified = true, - Addresses = new[] - { - new Address - { - AddressLine1 = "321 Elm St", - City = new City { Name = "Chicago", Country = "USA" } - }, - new Address - { - AddressLine1 = "654 Maple Dr", - City = new City { Name = "New York", Country = "USA" } - } - }, - Manager = new User + ManagerId = User02Id, + Company = new Company { - Age = 16, - Firstname = "Manager 01", - DateOfBirth = DateTime.Parse("2000-01-01 00:00:00").ToUniversalTime(), - BalanceDecimal = 2.00m, - IsEmailVerified = true + Id = Guid.NewGuid(), + Name = "Tech Solutions", + Department = "Sales" }, - Tags = ["vip", "premium"] + Addresses = Array.Empty
(), + Tags = Array.Empty() }, - ["Harry"] = new User + ["User04"] = new User { - Age = 1, - Firstname = "Harry", - DateOfBirth = DateTime.Parse("2002-08-01").ToUniversalTime(), - BalanceDecimal = 0.5372958205929493m, + Id = User04Id, + Age = 35, + Firstname = "User04", + DateOfBirth = null, + BalanceDecimal = null, + BalanceDouble = null, + BalanceFloat = null, IsEmailVerified = false, - Addresses = Array.Empty
() + ManagerId = User02Id, + Company = null, + Addresses = Array.Empty
(), + Tags = Array.Empty() }, - ["Doe"] = new User + ["User05"] = new User { - Age = 1, - Firstname = "Doe", - DateOfBirth = DateTime.Parse("2023-07-26 12:00:30").ToUniversalTime(), - BalanceDecimal = null, + Id = User05Id, + Age = 25, + Firstname = "User05", + DateOfBirth = DateTime.SpecifyKind(DateTime.Parse("1998-12-25 18:45:00"), DateTimeKind.Utc), + BalanceDecimal = 0.00m, + BalanceDouble = 0.00d, + BalanceFloat = null, IsEmailVerified = true, - Addresses = new[] + ManagerId = null, + Company = new Company { + Id = Guid.NewGuid(), + Name = "WebCorp", + Department = "Marketing" + }, + Addresses = + [ new Address { + Id = Guid.NewGuid(), AddressLine1 = "999 Broadway", - City = new City { Name = "Los Angeles", Country = "USA" } - } - } - }, - ["Egg"] = new User - { - Age = 33, - Firstname = "Egg", - DateOfBirth = DateTime.Parse("2000-01-01 00:00:00").ToUniversalTime(), - BalanceDouble = 1334534453453433.33435443343231235652d, - IsEmailVerified = false, - Addresses = new[] - { - new Address - { - AddressLine1 = "777 First Ave", - City = new City { Name = "Miami", Country = "USA" } - }, - new Address - { - AddressLine1 = "888 Second St", - City = new City { Name = "Orlando", Country = "USA" } - } - }, - Manager = new User - { - Age = 18, - Firstname = "Manager 02", - DateOfBirth = DateTime.Parse("1999-04-21 00:00:00").ToUniversalTime(), - BalanceDecimal = 19.00m, - IsEmailVerified = true, - Manager = new User - { - Age = 30, - Firstname = "Manager 03", - DateOfBirth = DateTime.Parse("1993-04-21 00:00:00").ToUniversalTime(), - BalanceDecimal = 29.00m, - IsEmailVerified = true, - Manager = new User - { - Age = 40, - Firstname = "Manager 04", - DateOfBirth = DateTime.Parse("1983-04-21 00:00:00").ToUniversalTime(), - BalanceDecimal = 39.00m, - IsEmailVerified = true - }, - Company = new Company + City = new City { - Name = "My Test Company", - Department = "Development" + Id = Guid.NewGuid(), + Name = "Miami", + Country = "USA" } } - }, - Tags = ["premium"] - }, - ["NullUser"] = new User - { - Age = 4, - Firstname = "NullUser", - DateOfBirth = null, - BalanceDecimal = null, - BalanceDouble = null, - BalanceFloat = null, - IsEmailVerified = true, - Addresses = Array.Empty
() - }, + ], + Tags = ["standard"] + } }; } \ No newline at end of file diff --git a/src/GoatQuery/tests/TestDbContext.cs b/src/GoatQuery/tests/TestDbContext.cs index 00f8c29..b1208de 100644 --- a/src/GoatQuery/tests/TestDbContext.cs +++ b/src/GoatQuery/tests/TestDbContext.cs @@ -8,6 +8,6 @@ public TestDbContext(DbContextOptions options) : base(options) { protected override void OnModelCreating(ModelBuilder modelBuilder) { - + base.OnModelCreating(modelBuilder); } } \ No newline at end of file diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs index 6021c6d..bb75959 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -11,17 +11,13 @@ public record User 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 sealed record CustomJsonPropertyUser : User -{ - [JsonPropertyName("last_name")] - public string Lastname { get; set; } = string.Empty; -} - public record Address { public Guid Id { get; set; } @@ -41,4 +37,10 @@ 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; } \ No newline at end of file From 03ad3b3476d3f68a613e68500b7a9cb52d646b4f Mon Sep 17 00:00:00 2001 From: Jamess-Lucass <23193271+Jamess-Lucass@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:56:59 +0100 Subject: [PATCH 59/60] readme --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 51710de..ddb5d86 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,16 @@ 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 -dotnet add package GoatQuery.AspNetCore # For ASP.NET Core integration + +# Or for ASP.NET Core integration please install this instead. +dotnet add package GoatQuery.AspNetCore ``` ## Quick Start From 385346041782411beef138be769493c006cbee25 Mon Sep 17 00:00:00 2001 From: James <23193271+Jamess-Lucass@users.noreply.github.com> Date: Tue, 30 Sep 2025 19:15:26 +0100 Subject: [PATCH 60/60] feat: Added support for enums (#105) --- example/Dto/UserDto.cs | 1 + example/Entities/User.cs | 11 ++ example/Program.cs | 1 + .../src/Evaluator/FilterEvaluator.cs | 92 +++++++++++++--- .../src/Utilities/PropertyMappingTree.cs | 1 - src/GoatQuery/tests/Filter/FilterLexerTest.cs | 103 ++++++++++++++++++ .../tests/Filter/FilterParserTest.cs | 101 ++++++++++++++++- src/GoatQuery/tests/Filter/FilterTest.cs | 90 +++++++++++++++ src/GoatQuery/tests/TestData.cs | 10 ++ src/GoatQuery/tests/User.cs | 18 +++ 10 files changed, 403 insertions(+), 25 deletions(-) diff --git a/example/Dto/UserDto.cs b/example/Dto/UserDto.cs index 4e1ac2d..51bdd96 100644 --- a/example/Dto/UserDto.cs +++ b/example/Dto/UserDto.cs @@ -8,6 +8,7 @@ public record UserDto 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; } diff --git a/example/Entities/User.cs b/example/Entities/User.cs index 3182437..00a294d 100644 --- a/example/Entities/User.cs +++ b/example/Entities/User.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; public record User { @@ -6,6 +7,7 @@ public record User 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; } @@ -41,4 +43,13 @@ 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/example/Program.cs b/example/Program.cs index d9f9112..c172d79 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -51,6 +51,7 @@ .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) diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index ee38258..057976a 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; +using System.Text.Json.Serialization; using FluentResults; public static class FilterEvaluator @@ -127,11 +128,6 @@ private static Result ResolvePropertyPathForCollection( return (MemberExpression)current; } - private static bool IsNullableReferenceType(Type type) - { - return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; - } - private static bool IsPrimitiveType(Type type) { return type.IsPrimitive || type == typeof(string) || type == typeof(decimal) || @@ -226,13 +222,13 @@ private static Result CreateConstantExpression(QueryExpressi { return literal switch { - IntegerLiteral intLit => CreateIntegerConstant(intLit.Value, expression), + 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 => Result.Ok(Expression.Constant(strLit.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)), @@ -248,11 +244,6 @@ private static Result CreateConstantExpression(QueryExpressi return Result.Ok((constantResult.Value, property)); } - private static Result CreateIntegerConstant(int value, Expression expression) - { - return GetIntegerExpressionConstant(value, expression.Type); - } - private static ConstantExpression CreateDateConstant(DateLiteral dateLiteral, Expression expression) { if (expression.Type == typeof(DateTime?)) @@ -563,9 +554,7 @@ private static Result GetIntegerExpressionConstant(int value { try { - // Fetch the underlying type if it's nullable. - var underlyingType = Nullable.GetUnderlyingType(targetType); - var type = underlyingType ?? targetType; + var type = GetNonNullableType(targetType); object convertedValue = type switch { @@ -586,9 +575,76 @@ private static Result GetIntegerExpressionConstant(int value { return Result.Fail($"Value {value} is too large for type {targetType.Name}"); } - catch (Exception ex) + 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 {targetType.Name}: {ex.Message}"); + return Result.Fail($"Error converting {value} to enum type {targetType.Name}"); } } -} \ No newline at end of file + + 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/Utilities/PropertyMappingTree.cs b/src/GoatQuery/src/Utilities/PropertyMappingTree.cs index cec70a4..721d80a 100644 --- a/src/GoatQuery/src/Utilities/PropertyMappingTree.cs +++ b/src/GoatQuery/src/Utilities/PropertyMappingTree.cs @@ -175,7 +175,6 @@ private static bool IsPrimitiveType(Type type) if (type.IsPrimitive || PrimitiveTypes.Contains(type)) return true; - // Handle nullable types var underlyingType = Nullable.GetUnderlyingType(type); return underlyingType != null && (underlyingType.IsPrimitive || PrimitiveTypes.Contains(underlyingType)); } diff --git a/src/GoatQuery/tests/Filter/FilterLexerTest.cs b/src/GoatQuery/tests/Filter/FilterLexerTest.cs index 4f6680f..43c97b5 100644 --- a/src/GoatQuery/tests/Filter/FilterLexerTest.cs +++ b/src/GoatQuery/tests/Filter/FilterLexerTest.cs @@ -523,6 +523,109 @@ public static IEnumerable Parameters() 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] diff --git a/src/GoatQuery/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs index b60a764..ba233ac 100644 --- a/src/GoatQuery/tests/Filter/FilterParserTest.cs +++ b/src/GoatQuery/tests/Filter/FilterParserTest.cs @@ -191,7 +191,7 @@ public void Test_ParsingFilterStatementWithNestedProperty(string input, string[] [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, + public void Test_ParsingQueryLambdaExpression(string input, string expectedProperty, string expectedFunction, string expectedParameter, string expectedLambdaLeft, string expectedLambdaOperator, string expectedLambdaRight) { var lexer = new QueryLexer(input); @@ -249,7 +249,7 @@ public void Test_ParsingQueryLambdaExpressionWithNestedProperty(string input, st // 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); @@ -308,9 +308,9 @@ public void Test_ParsingComplexQueryLambdaExpression(string input, string expect // 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) || + Assert.True(bodyExpression.Operator.Equals("and", StringComparison.OrdinalIgnoreCase) || bodyExpression.Operator.Equals("or", StringComparison.OrdinalIgnoreCase)); } @@ -331,5 +331,94 @@ public void Test_ParsingInvalidQueryLambdaExpression(string input) Assert.True(result.IsFailed); } - -} \ No newline at end of file + + [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 index d0ae693..eb2955c 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -350,6 +350,96 @@ public static IEnumerable Parameters() "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] diff --git a/src/GoatQuery/tests/TestData.cs b/src/GoatQuery/tests/TestData.cs index a810b79..75236d1 100644 --- a/src/GoatQuery/tests/TestData.cs +++ b/src/GoatQuery/tests/TestData.cs @@ -13,6 +13,8 @@ public static class TestData 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, @@ -57,6 +59,8 @@ public static class TestData 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, @@ -90,6 +94,8 @@ public static class TestData 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, @@ -110,6 +116,8 @@ public static class TestData Id = User04Id, Age = 35, Firstname = "User04", + Gender = null, + Status = Status.Inactive, DateOfBirth = null, BalanceDecimal = null, BalanceDouble = null, @@ -125,6 +133,8 @@ public static class TestData 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, diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs index bb75959..931690d 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -4,6 +4,8 @@ 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; } @@ -43,4 +45,20 @@ 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