diff --git a/.github/workflows/build-test-ci.yml b/.github/workflows/build-test-ci.yml index f5fa006..cb27290 100644 --- a/.github/workflows/build-test-ci.yml +++ b/.github/workflows/build-test-ci.yml @@ -7,24 +7,25 @@ on: pull_request: branches: - main + jobs: build-and-test: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v3 + - name: Checkout code + uses: actions/checkout@v3 - - name: Setup .NET - uses: actions/setup-dotnet@v3 - with: - dotnet-version: 6.x + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '6.x' - - name: Restore dependencies - run: dotnet restore + - name: Restore dependencies + run: dotnet restore - - name: Build - run: dotnet build --configuration Release + - name: Build + run: dotnet build --configuration Release - - name: Run tests - run: dotnet test --configuration Release --no-build \ No newline at end of file + - name: Run tests + run: dotnet test --configuration Release --no-build diff --git a/NorthwindCRUD/Controllers/QueryBuilderController.cs b/NorthwindCRUD/Controllers/QueryBuilderController.cs new file mode 100644 index 0000000..8d8263a --- /dev/null +++ b/NorthwindCRUD/Controllers/QueryBuilderController.cs @@ -0,0 +1,69 @@ +namespace QueryBuilder; + +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using NorthwindCRUD; +using NorthwindCRUD.Models.Dtos; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +[SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1516:Elements should be separated by blank line", Justification = "...")] +[SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1025:Code should not contain multiple whitespace in a row", Justification = "...")] +[SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1134:Attributes should not share line", Justification = "...")] +[SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1011:Closing square brackets should be spaced correctly", Justification = "...")] +public class QueryBuilderResult +{ + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public AddressDto[]? Addresses { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public CategoryDto[]? Categories { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public ProductDto[]? Products { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public RegionDto[]? Regions { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public TerritoryDto[]? Territories { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public EmployeeDto[]? Employees { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public CustomerDto[]? Customers { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public OrderDto[]? Orders { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public OrderDetailDto[]? OrderDetails { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public ShipperDto[]? Shippers { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public SupplierDto[]? Suppliers { get; set; } +} + +[ApiController] +[Route("[controller]")] +[SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1025:Code should not contain multiple whitespace in a row", Justification = "...")] +public class QueryBuilderController : ControllerBase +{ + private readonly DataContext dataContext; + private readonly IMapper mapper; + private readonly ILogger logger; + + public QueryBuilderController(DataContext dataContext, IMapper mapper, ILogger logger) + { + this.dataContext = dataContext; + this.mapper = mapper; + this.logger = logger; + } + + [HttpPost("ExecuteQuery")] + [Consumes("application/json")] + [Produces("application/json")] + public ActionResult ExecuteQuery(Query query) + { + var sanitizedEntity = query.Entity.Replace("\r", string.Empty).Replace("\n", string.Empty); + logger.LogInformation("Executing query for entity: {Entity}", sanitizedEntity); + var t = query.Entity.ToLower(CultureInfo.InvariantCulture); + return Ok(new QueryBuilderResult + { + Addresses = t == "addresses" ? mapper.Map(dataContext.Addresses.Run(query)) : null, + Categories = t == "categories" ? mapper.Map(dataContext.Categories.Run(query)) : null, + Products = t == "products" ? mapper.Map(dataContext.Products.Run(query)) : null, + Regions = t == "regions" ? mapper.Map(dataContext.Regions.Run(query)) : null, + Territories = t == "territories" ? mapper.Map(dataContext.Territories.Run(query)) : null, + Employees = t == "employees" ? mapper.Map(dataContext.Employees.Run(query)) : null, + Customers = t == "customers" ? mapper.Map(dataContext.Customers.Run(query)) : null, + Orders = t == "orders" ? mapper.Map(dataContext.Orders.Run(query)) : null, + OrderDetails = t == "orderdetails" ? mapper.Map(dataContext.OrderDetails.Run(query)) : null, + Shippers = t == "shippers" ? mapper.Map(dataContext.Shippers.Run(query)) : null, + Suppliers = t == "suppliers" ? mapper.Map(dataContext.Suppliers.Run(query)) : null, + }); + } +} \ No newline at end of file diff --git a/NorthwindCRUD/Program.cs b/NorthwindCRUD/Program.cs index 1b40840..8ba88f8 100644 --- a/NorthwindCRUD/Program.cs +++ b/NorthwindCRUD/Program.cs @@ -2,12 +2,9 @@ using AutoMapper; using GraphQL.AspNet.Configuration.Mvc; using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; -using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; +using Newtonsoft.Json; using Newtonsoft.Json.Converters; using NorthwindCRUD.Filters; using NorthwindCRUD.Helpers; @@ -40,7 +37,8 @@ public static void Main(string[] args) options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true; }).AddNewtonsoftJson(options => { - options.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore; + options.SerializerSettings.DateParseHandling = DateParseHandling.None; + options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; options.SerializerSettings.Converters.Add(new StringEnumConverter()); }); diff --git a/NorthwindCRUD/QueryBuilder/Model/FilterType.cs b/NorthwindCRUD/QueryBuilder/Model/FilterType.cs new file mode 100644 index 0000000..75d0748 --- /dev/null +++ b/NorthwindCRUD/QueryBuilder/Model/FilterType.cs @@ -0,0 +1,7 @@ +namespace QueryBuilder; + +public enum FilterType +{ + And = 0, + Or = 1, +} diff --git a/NorthwindCRUD/QueryBuilder/Model/Query.cs b/NorthwindCRUD/QueryBuilder/Model/Query.cs new file mode 100644 index 0000000..ae2ceb6 --- /dev/null +++ b/NorthwindCRUD/QueryBuilder/Model/Query.cs @@ -0,0 +1,12 @@ +namespace QueryBuilder; + +public class Query +{ + public string Entity { get; set; } + + public string[] ReturnFields { get; set; } + + public FilterType Operator { get; set; } + + public QueryFilter[] FilteringOperands { get; set; } +} diff --git a/NorthwindCRUD/QueryBuilder/Model/QueryFilter.cs b/NorthwindCRUD/QueryBuilder/Model/QueryFilter.cs new file mode 100644 index 0000000..e95d0be --- /dev/null +++ b/NorthwindCRUD/QueryBuilder/Model/QueryFilter.cs @@ -0,0 +1,20 @@ +namespace QueryBuilder; + +public class QueryFilter +{ + // Basic condition + public string? FieldName { get; set; } + + public bool? IgnoreCase { get; set; } + + public QueryFilterCondition? Condition { get; set; } + + public object? SearchVal { get; set; } + + public Query? SearchTree { get; set; } + + // And/Or + public FilterType? Operator { get; set; } + + public QueryFilter[] FilteringOperands { get; set; } +} diff --git a/NorthwindCRUD/QueryBuilder/Model/QueryFilterCondition.cs b/NorthwindCRUD/QueryBuilder/Model/QueryFilterCondition.cs new file mode 100644 index 0000000..50c6327 --- /dev/null +++ b/NorthwindCRUD/QueryBuilder/Model/QueryFilterCondition.cs @@ -0,0 +1,10 @@ +namespace QueryBuilder; + +public class QueryFilterCondition +{ + public string Name { get; set; } + + public bool IsUnary { get; set; } + + public string IconName { get; set; } +} diff --git a/NorthwindCRUD/QueryBuilder/QueryExecutor.cs b/NorthwindCRUD/QueryBuilder/QueryExecutor.cs new file mode 100644 index 0000000..0b4faa8 --- /dev/null +++ b/NorthwindCRUD/QueryBuilder/QueryExecutor.cs @@ -0,0 +1,263 @@ +namespace QueryBuilder; + +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; +using AutoMapper.Internal; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using NorthwindCRUD; +using Swashbuckle.AspNetCore.SwaggerGen; + +/// +/// A generic query executor that can be used to execute queries on IQueryable data sources. +/// +[SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1025:Code should not contain multiple whitespace in a row", Justification = "...")] +public static class QueryExecutor +{ + public static TEntity[] Run(this IQueryable source, Query? query) + { + var infrastructure = source as IInfrastructure; + var serviceProvider = infrastructure!.Instance; + var currentDbContext = serviceProvider.GetService(typeof(ICurrentDbContext)) as ICurrentDbContext; + var db = currentDbContext!.Context as DataContext; + return db is not null ? BuildQuery(db, source, query).ToArray() : Array.Empty(); + } + + private static IQueryable BuildQuery(DataContext db, IQueryable source, Query? query) + { + if (query is null) + { + throw new InvalidOperationException("Null query"); + } + + var filterExpression = BuildExpression(db, source, query.FilteringOperands, query.Operator); + var filteredQuery = source.Where(filterExpression); + if (query.ReturnFields != null && query.ReturnFields.Any()) + { + var projectionExpression = BuildProjectionExpression(query.ReturnFields); + return filteredQuery.Select(projectionExpression).Cast(); + } + else + { + return filteredQuery; + } + } + + private static Expression> BuildExpression(DataContext db, IQueryable source, QueryFilter[] filters, FilterType filterType) + { + var parameter = Expression.Parameter(typeof(TEntity), "entity"); + var finalExpression = null as Expression; + foreach (var filter in filters) + { + var expression = BuildConditionExpression(db, source, filter, parameter); + if (finalExpression == null) + { + finalExpression = expression; + } + else + { + finalExpression = filterType == FilterType.And + ? Expression.AndAlso(finalExpression, expression) + : Expression.OrElse(finalExpression, expression); + } + } + + return finalExpression is not null + ? Expression.Lambda>(finalExpression, parameter) + : (TEntity _) => true; + } + + private static Expression BuildConditionExpression(DataContext db, IQueryable source, QueryFilter filter, ParameterExpression parameter) + { + if (filter.FieldName is not null && filter.IgnoreCase is not null && filter.Condition is not null) + { + var property = source.ElementType.GetProperty(filter.FieldName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance) + ?? throw new InvalidOperationException($"Property '{filter.FieldName}' not found on type '{source.ElementType}'"); + var field = Expression.Property(parameter, property); + var targetType = property.PropertyType; + var searchValue = GetSearchValue(filter.SearchVal, targetType); + var emptyValue = GetEmptyValue(targetType); + var today = DateTime.Now.Date; + Expression condition = filter.Condition.Name switch + { + "null" => targetType.IsNullableType() ? Expression.Equal(field, Expression.Constant(targetType.GetDefaultValue())) : Expression.Constant(false), + "notNull" => targetType.IsNullableType() ? Expression.NotEqual(field, Expression.Constant(targetType.GetDefaultValue())) : Expression.Constant(true), + "empty" => Expression.Or(Expression.Equal(field, emptyValue), targetType.IsNullableType() ? Expression.Equal(field, Expression.Constant(targetType.GetDefaultValue())) : Expression.Constant(false)), + "notEmpty" => Expression.And(Expression.NotEqual(field, emptyValue), targetType.IsNullableType() ? Expression.NotEqual(field, Expression.Constant(targetType.GetDefaultValue())) : Expression.Constant(true)), + "equals" => Expression.Equal(field, searchValue), + "doesNotEqual" => Expression.NotEqual(field, searchValue), + "inQuery" => BuildInExpression(db, filter.SearchTree, field), + "notInQuery" => Expression.Not(BuildInExpression(db, filter.SearchTree, field)), + "contains" => CallContains(field, searchValue), + "doesNotContain" => Expression.Not(CallContains(field, searchValue)), + "startsWith" => CallStartsWith(field, searchValue), + "endsWith" => CallEndsWith(field, searchValue), + "greaterThan" => Expression.GreaterThan(field, searchValue), + "lessThan" => Expression.LessThan(field, searchValue), + "greaterThanOrEqualTo" => Expression.GreaterThanOrEqual(field, searchValue), + "lessThanOrEqualTo" => Expression.LessThanOrEqual(field, searchValue), + "before" => Expression.LessThan(CallCompare(field, searchValue), Expression.Constant(0)), + "after" => Expression.GreaterThan(CallCompare(field, searchValue), Expression.Constant(0)), + "today" => CallStartsWith(field, today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + "yesterday" => CallStartsWith(field, today.AddDays(-1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + "thisMonth" => CallStartsWith(field, today.ToString("yyyy-MM", CultureInfo.InvariantCulture)), + "lastMonth" => CallStartsWith(field, today.AddMonths(-1).ToString("yyyy-MM", CultureInfo.InvariantCulture)), + "nextMonth" => CallStartsWith(field, today.AddMonths(1).ToString("yyyy-MM", CultureInfo.InvariantCulture)), + "thisYear" => CallStartsWith(field, today.ToString("yyyy", CultureInfo.InvariantCulture)), + "lastYear" => CallStartsWith(field, today.AddYears(-1).ToString("yyyy", CultureInfo.InvariantCulture)), + "nextYear" => CallStartsWith(field, today.AddYears(1).ToString("yyyy", CultureInfo.InvariantCulture)), + "at" => Expression.Equal(field, searchValue), + "not_at" => Expression.NotEqual(field, searchValue), + "at_before" => Expression.LessThan(CallCompare(field, searchValue), Expression.Constant(0)), + "at_after" => Expression.GreaterThan(CallCompare(field, searchValue), Expression.Constant(0)), + "all" => Expression.Constant(true), + "true" => Expression.Equal(field, Expression.Constant(true)), + "false" => Expression.Equal(field, Expression.Constant(false)), + _ => throw new NotImplementedException("Not implemented"), + }; + if (filter.IgnoreCase.Value && field.Type == typeof(string)) + { + // TODO: Implement case-insensitive comparison + } + + return condition; + } + else + { + var subexpressions = filter.FilteringOperands?.Select(f => BuildConditionExpression(db, source, f, parameter)).ToArray(); + if (subexpressions == null || !subexpressions.Any()) + { + return Expression.Constant(true); + } + + return filter.Operator == FilterType.And + ? subexpressions.Aggregate(Expression.AndAlso) + : subexpressions.Aggregate(Expression.OrElse); + } + } + + private static Expression CallContains(Expression field, Expression searchValue) + { + var containsMethod = typeof(string).GetMethod("Contains", new[] { typeof(string) }); + return Expression.Call(field, containsMethod!, searchValue); + } + + private static Expression CallStartsWith(Expression field, Expression searchValue) + { + var startsWithMethod = typeof(string).GetMethod("StartsWith", new[] { typeof(string) }); + return Expression.Call(field, startsWithMethod!, searchValue); + } + + private static Expression CallStartsWith(Expression field, string literal) + { + return CallStartsWith(field, Expression.Constant(literal)); + } + + private static Expression CallEndsWith(Expression field, Expression searchValue) + { + var endsWithMethod = typeof(string).GetMethod("EndsWith", new[] { typeof(string) }); + return Expression.Call(field, endsWithMethod!, searchValue); + } + + private static Expression CallEndsWith(Expression field, string literal) + { + return CallEndsWith(field, Expression.Constant(literal)); + } + + private static Expression CallCompare(Expression field, Expression searchValue) + { + var compareMethod = typeof(string).GetMethod("Compare", new[] { typeof(string), typeof(string) }); + return Expression.Call(compareMethod!, field, searchValue); + } + + private static Expression BuildInExpression(DataContext db, Query? query, MemberExpression field) + { + return field.Type switch + { + { } t when t == typeof(string) => BuildInExpression(db, query, field), + { } t when t == typeof(bool) => BuildInExpression(db, query, field), + { } t when t == typeof(bool?) => BuildInExpression(db, query, field), + { } t when t == typeof(int) => BuildInExpression(db, query, field), + { } t when t == typeof(int?) => BuildInExpression(db, query, field), + { } t when t == typeof(decimal) => BuildInExpression(db, query, field), + { } t when t == typeof(decimal?) => BuildInExpression(db, query, field), + { } t when t == typeof(float) => BuildInExpression(db, query, field), + { } t when t == typeof(float?) => BuildInExpression(db, query, field), + { } t when t == typeof(DateTime) => BuildInExpression(db, query, field), + { } t when t == typeof(DateTime?) => BuildInExpression(db, query, field), + _ => throw new InvalidOperationException($"Type '{field.Type}' not supported for 'IN' operation"), + }; + } + + private static Expression BuildInExpression(DataContext db, Query? query, MemberExpression field) + { + var d = RunSubquery(db, query).Select(x => (T)ProjectField(x, query?.ReturnFields[0] ?? string.Empty)).ToArray(); + var m = typeof(Enumerable).GetMethods() + .FirstOrDefault(method => method.Name == nameof(Enumerable.Contains) && method.GetParameters().Length == 2) + ?.MakeGenericMethod(typeof(T)) ?? throw new InvalidOperationException("Missing method"); + return Expression.Call(m, Expression.Constant(d), field); + } + + private static IEnumerable RunSubquery(DataContext db, Query? query) + { + // var t = query?.Entity.ToLower(CultureInfo.InvariantCulture); + // var p = db.GetType().GetProperty(t, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance) ?? throw new InvalidOperationException($"Property '{t}' not found on type '{db.GetType()}'"); + // var q = p.GetValue(db) as IQueryable; + // return q is null ? Array.Empty() : q.Run(query).ToArray(); + var t = query?.Entity.ToLower(CultureInfo.InvariantCulture) ?? string.Empty; + return t switch + { + "addresses" => db.Suppliers.Run(query).ToArray(), + "categories" => db.Categories.Run(query).ToArray(), + "products" => db.Products.Run(query).ToArray(), + "regions" => db.Regions.Run(query).ToArray(), + "territories" => db.Territories.Run(query).ToArray(), + "employees" => db.Employees.Run(query).ToArray(), + "customers" => db.Customers.Run(query).ToArray(), + "orders" => db.Orders.Run(query).ToArray(), + "orderdetails" => db.OrderDetails.Run(query).ToArray(), + "shippers" => db.Shippers.Run(query).ToArray(), + "suppliers" => db.Suppliers.Run(query).ToArray(), + _ => Array.Empty(), + }; + } + + private static dynamic? ProjectField(dynamic? obj, string field) + { + var property = obj?.GetType().GetProperty(field, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance) ?? throw new InvalidOperationException($"Property '{field}' not found on type '{obj?.GetType()}'"); + return property?.GetValue(obj); + } + + private static Expression GetSearchValue(dynamic? value, Type targetType) + { + if (value == null) + { + return GetEmptyValue(targetType); + } + + var nonNullableType = Nullable.GetUnderlyingType(targetType) ?? targetType; + var convertedValue = Convert.ChangeType(value, nonNullableType, CultureInfo.InvariantCulture); + return Expression.Constant(convertedValue, targetType); + } + + private static Expression GetEmptyValue(Type targetType) + { + return Expression.Constant(targetType == typeof(string) ? string.Empty : targetType.GetDefaultValue()); + } + + private static Expression> BuildProjectionExpression(string[] returnFields) + { + var parameter = Expression.Parameter(typeof(TEntity), "entity"); + var bindings = returnFields.Select(field => + { + var property = typeof(TEntity).GetProperty(field, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance) ?? throw new InvalidOperationException($"Property '{field}' not found on type '{typeof(TEntity)}'"); + var propertyAccess = Expression.Property(parameter, property); + return Expression.Bind(property, propertyAccess); + }).ToArray(); + + var body = Expression.MemberInit(Expression.New(typeof(TEntity)), bindings); + return Expression.Lambda>(body, parameter); + } +} diff --git a/NorthwindCRUD/QueryBuilder/SqlGenerator.cs b/NorthwindCRUD/QueryBuilder/SqlGenerator.cs new file mode 100644 index 0000000..359466d --- /dev/null +++ b/NorthwindCRUD/QueryBuilder/SqlGenerator.cs @@ -0,0 +1,81 @@ +namespace QueryBuilder; + +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +[SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1025:Code should not contain multiple whitespace in a row", Justification = "...")] +public static class SqlGenerator +{ + public static string GenerateSql(Query query) + { + var selectClause = BuildSelectClause(query); + var whereClause = BuildWhereClause(query.FilteringOperands, query.Operator); + return $"{selectClause} {whereClause};"; + } + + private static string BuildSelectClause(Query query) + { + var fields = query.ReturnFields != null && query.ReturnFields.Any() + ? string.Join(", ", query.ReturnFields) + : "*"; + return $"SELECT {fields} FROM {query.Entity}"; + } + + private static string BuildWhereClause(QueryFilter[] filters, FilterType filterType) + { + if (filters == null || !filters.Any()) + { + return string.Empty; + } + + var conditions = filters.Select(BuildCondition).ToArray(); + var conjunction = filterType == FilterType.And ? " AND " : " OR "; + return $"WHERE {string.Join(conjunction, conditions)}"; + } + + private static string BuildCondition(QueryFilter filter) + { + var field = filter.FieldName; + var condition = filter.Condition?.Name; + var value = filter.SearchVal != null ? $"'{filter.SearchVal}'" : "NULL"; + var subquery = filter.SearchTree != null ? $"({GenerateSql(filter.SearchTree)})" : string.Empty; + return condition switch + { + "null" => $"{field} IS NULL", + "notNull" => $"{field} IS NOT NULL", + "empty" => $"{field} = ''", + "notEmpty" => $"{field} <> ''", + "equals" => $"{field} = {value}", + "doesNotEqual" => $"{field} <> {value}", + "in" => $"{field} IN ({value})", + "inQuery" => $"{field} IN ({subquery})", + "notInQuery" => $"{field} NOT IN ({subquery})", + "contains" => $"{field} LIKE '%{filter.SearchVal}%'", + "doesNotContain" => $"{field} NOT LIKE '%{filter.SearchVal}%'", + "startsWith" => $"{field} LIKE '{filter.SearchVal}%'", + "endsWith" => $"{field} LIKE '%{filter.SearchVal}'", + "greaterThan" => $"{field} > {value}", + "lessThan" => $"{field} < {value}", + "greaterThanOrEqualTo" => $"{field} >= {value}", + "lessThanOrEqualTo" => $"{field} <= {value}", + "before" => $"{field} < {value}", + "after" => $"{field} > {value}", + "today" => $"{field} LIKE '{DateTime.Now.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}%'", + "yesterday" => $"{field} LIKE '{DateTime.Now.Date.AddDays(-1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}%'", + "thisMonth" => $"{field} LIKE '{DateTime.Now.Date.ToString("yyyy-MM", CultureInfo.InvariantCulture)}%'", + "lastMonth" => $"{field} LIKE '{DateTime.Now.Date.AddMonths(-1).ToString("yyyy-MM", CultureInfo.InvariantCulture)}%'", + "nextMonth" => $"{field} LIKE '{DateTime.Now.Date.AddMonths(1).ToString("yyyy-MM", CultureInfo.InvariantCulture)}%'", + "thisYear" => $"{field} LIKE '{DateTime.Now.Date.ToString("yyyy", CultureInfo.InvariantCulture)}%'", + "lastYear" => $"{field} LIKE '{DateTime.Now.Date.AddYears(-1).ToString("yyyy", CultureInfo.InvariantCulture)}%'", + "nextYear" => $"{field} LIKE '{DateTime.Now.Date.AddYears(1).ToString("yyyy", CultureInfo.InvariantCulture)}%'", + "at" => $"{field} = {value}", + "not_at" => $"{field} <> {value}", + "at_before" => $"{field} < {value}", + "at_after" => $"{field} > {value}", + "all" => "TRUE", + "true" => $"{field} = TRUE", + "false" => $"{field} = FALSE", + _ => $"{field} {condition} {value}", + }; + } +} \ No newline at end of file