diff --git a/src/Foundatio.Parsers.SqlQueries/Extensions/SqlNodeExtensions.cs b/src/Foundatio.Parsers.SqlQueries/Extensions/SqlNodeExtensions.cs index 791f876f..5c7a6187 100644 --- a/src/Foundatio.Parsers.SqlQueries/Extensions/SqlNodeExtensions.cs +++ b/src/Foundatio.Parsers.SqlQueries/Extensions/SqlNodeExtensions.cs @@ -267,7 +267,7 @@ public static string ToDynamicLinqString(this TermRangeNode node, ISqlQueryVisit return query; var field = GetFieldInfo(context.Fields, node.Field); - if (!field.IsNumber && !field.IsDate && !field.IsMoney) + if (!field.IsNumber && !field.IsDateOnly && !field.IsDate && !field.IsMoney) context.AddValidationError("Field must be a number, money or date for term range queries."); var (fieldPrefix, fieldSuffix) = field.GetFieldPrefixAndSuffix(); @@ -371,6 +371,35 @@ private static void AppendField(StringBuilder builder, EntityFieldInfo field, st builder.Append("DateTime.Parse(\"" + term + "\")"); } } + else if (field is { IsDateOnly: true }) + { + term = term.Trim(); + if (term.StartsWith("now", StringComparison.OrdinalIgnoreCase)) + { + builder.Append("DateOnly.FromDateTime(DateTime.UtcNow)"); + + if (term.Length == 3) + return; + + builder.Append("."); + + string method = term[^1..] switch + { + "y" => "AddYears", + "M" => "AddMonths", + "d" => "AddDays", + _ => throw new NotSupportedException("Invalid date operation.") + }; + + bool subtract = term.Substring(3, 1) == "-"; + + builder.Append(method).Append("(").Append(subtract ? "-" : "").Append(term.Substring(4, term.Length - 5)).Append(")"); + } + else + { + builder.Append("DateOnly.Parse(\"" + term + "\")"); + } + } else builder.Append("\"" + term + "\""); } diff --git a/src/Foundatio.Parsers.SqlQueries/Extensions/TypeExtensions.cs b/src/Foundatio.Parsers.SqlQueries/Extensions/TypeExtensions.cs index 1592177a..e4f2c563 100644 --- a/src/Foundatio.Parsers.SqlQueries/Extensions/TypeExtensions.cs +++ b/src/Foundatio.Parsers.SqlQueries/Extensions/TypeExtensions.cs @@ -35,6 +35,7 @@ public static Type UnwrapNullable(this Type type) public static bool IsString(this Type type) => type == typeof(string); public static bool IsDateTime(this Type typeToCheck) => typeToCheck == typeof(DateTime) || typeToCheck == typeof(DateTime?); + public static bool IsDateOnly(this Type typeToCheck) => typeToCheck == typeof(DateOnly) || typeToCheck == typeof(DateOnly?); public static bool IsBoolean(this Type typeToCheck) => typeToCheck == typeof(bool) || typeToCheck == typeof(bool?); public static bool IsNumeric(this Type type) => type.IsFloatingPoint() || type.IsIntegerBased(); public static bool IsIntegerBased(this Type type) => _integerTypes.Contains(type); diff --git a/src/Foundatio.Parsers.SqlQueries/SqlQueryParser.cs b/src/Foundatio.Parsers.SqlQueries/SqlQueryParser.cs index 93a76ef2..c729fa56 100644 --- a/src/Foundatio.Parsers.SqlQueries/SqlQueryParser.cs +++ b/src/Foundatio.Parsers.SqlQueries/SqlQueryParser.cs @@ -127,6 +127,7 @@ private void AddEntityFields(List fields, EntityFieldInfo paren FullName = propertyPath, IsNumber = property.ClrType.UnwrapNullable().IsNumeric(), IsDate = property.ClrType.UnwrapNullable().IsDateTime(), + IsDateOnly = property.ClrType.UnwrapNullable().IsDateOnly(), IsBoolean = property.ClrType.UnwrapNullable().IsBoolean(), Parent = parent }); diff --git a/src/Foundatio.Parsers.SqlQueries/Visitors/SqlQueryVisitorContext.cs b/src/Foundatio.Parsers.SqlQueries/Visitors/SqlQueryVisitorContext.cs index d8c8f888..98bce306 100644 --- a/src/Foundatio.Parsers.SqlQueries/Visitors/SqlQueryVisitorContext.cs +++ b/src/Foundatio.Parsers.SqlQueries/Visitors/SqlQueryVisitorContext.cs @@ -24,6 +24,7 @@ public class EntityFieldInfo public bool IsNumber { get; set; } public bool IsMoney { get; set; } public bool IsDate { get; set; } + public bool IsDateOnly { get; set; } public bool IsBoolean { get; set; } public bool IsCollection { get; set; } public bool IsNavigation { get; set; } diff --git a/tests/Foundatio.Parsers.LuceneQueries.Tests/QueryParserTests.cs b/tests/Foundatio.Parsers.LuceneQueries.Tests/QueryParserTests.cs index 8b31100e..3112b188 100644 --- a/tests/Foundatio.Parsers.LuceneQueries.Tests/QueryParserTests.cs +++ b/tests/Foundatio.Parsers.LuceneQueries.Tests/QueryParserTests.cs @@ -8,6 +8,7 @@ using Foundatio.Xunit; using Microsoft.Extensions.Logging; using Pegasus.Common; +using Pegasus.Common.Tracing; using Xunit; using Xunit.Abstractions; @@ -137,6 +138,30 @@ public async Task CanParseRegex() } } + [Fact] + public void DataBackslashShouldBeValidBeginningOfString() + { + var sut = new LuceneQueryParser(); + var result = sut.Parse("\"\\something\""); + _logger.LogInformation(DebugQueryVisitor.Run(result)); + } + + [Fact] + public void CanHandleDateRange() + { +#if FALSE + var tracer = new LoggingTracer(_logger, reportPerformance: true); +#else + var tracer = NullTracer.Instance; +#endif + var sut = new LuceneQueryParser { + Tracer = tracer + }; + string query = "mydate:[now/d TO now/d+30d/d]"; + var result = sut.Parse(query); + _logger.LogInformation(DebugQueryVisitor.Run(result)); + } + [Fact] public void CanHandleEmpty() { diff --git a/tests/Foundatio.Parsers.SqlQueries.Tests/DynamicFieldVisitor.cs b/tests/Foundatio.Parsers.SqlQueries.Tests/DynamicFieldVisitor.cs index 7e62071d..5c4b099a 100644 --- a/tests/Foundatio.Parsers.SqlQueries.Tests/DynamicFieldVisitor.cs +++ b/tests/Foundatio.Parsers.SqlQueries.Tests/DynamicFieldVisitor.cs @@ -40,6 +40,9 @@ public override IQueryNode Visit(TermNode node, IQueryVisitorContext context) case { IsDate: true }: customFieldBuilder.Append("DateValue"); break; + case { IsDateOnly: true }: + customFieldBuilder.Append("DateOnlyValue"); + break; default: customFieldBuilder.Append("StringValue"); break; diff --git a/tests/Foundatio.Parsers.SqlQueries.Tests/SampleContext.cs b/tests/Foundatio.Parsers.SqlQueries.Tests/SampleContext.cs index 2d106b03..e99d5cc2 100644 --- a/tests/Foundatio.Parsers.SqlQueries.Tests/SampleContext.cs +++ b/tests/Foundatio.Parsers.SqlQueries.Tests/SampleContext.cs @@ -47,6 +47,8 @@ public class Employee public int Salary { get; set; } public List Companies { get; set; } public List DataValues { get; set; } + public TimeOnly HappyHour { get; set; } + public DateOnly Birthday { get; set; } public DateTime Created { get; set; } = DateTime.Now; } @@ -70,6 +72,7 @@ public class DataValue // store the values separately as sparse columns for querying purposes public string StringValue { get; set; } public DateTime? DateValue { get; set; } + public DateOnly? DateOnlyValue { get; set; } public decimal? MoneyValue { get; set; } public bool? BooleanValue { get; set; } public decimal? NumberValue { get; set; } @@ -87,6 +90,7 @@ public object GetValue(DataType? dataType = null) { DataType.String => StringValue, DataType.Date => DateValue, + DataType.DateOnly => DateOnlyValue, DataType.Number => NumberValue, DataType.Boolean => BooleanValue, DataType.Money => MoneyValue, @@ -172,6 +176,7 @@ public enum DataType Number, Boolean, Date, + DateOnly, Money, Percent } diff --git a/tests/Foundatio.Parsers.SqlQueries.Tests/SqlQueryParserTests.cs b/tests/Foundatio.Parsers.SqlQueries.Tests/SqlQueryParserTests.cs index 9e5e4415..2ab01a3c 100644 --- a/tests/Foundatio.Parsers.SqlQueries.Tests/SqlQueryParserTests.cs +++ b/tests/Foundatio.Parsers.SqlQueries.Tests/SqlQueryParserTests.cs @@ -164,6 +164,40 @@ public async Task CanUseDateFilter() Assert.Equal(sqlExpected, sqlActual); } + [Fact] + public async Task CanUseDateOnlyFilter() + { + var sp = GetServiceProvider(); + await using var db = await GetSampleContextWithDataAsync(sp); + var parser = sp.GetRequiredService(); + + var context = parser.GetContext(db.Employees.EntityType); + + string sqlExpected = db.Employees.Where(e => e.Birthday < DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-90)).ToQueryString(); + string sqlActual = db.Employees.Where("""birthday < DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-90)""").ToQueryString(); + Assert.Equal(sqlExpected, sqlActual); + string sql = await parser.ToDynamicLinqAsync("birthday:(); + + var context = parser.GetContext(db.Employees.EntityType); + + string sqlExpected = db.Employees.Where(e => e.HappyHour < TimeOnly.Parse("6:00")).ToQueryString(); + string sqlActual = db.Employees.Where("""happyhour < TimeOnly.Parse("6:00")""").ToQueryString(); + Assert.Equal(sqlExpected, sqlActual); + string sql = await parser.ToDynamicLinqAsync("""happyhour:<"6:00" """, context); + sqlActual = db.Employees.Where(sql).ToQueryString(); + Assert.Equal(sqlExpected, sqlActual); + } + [Fact] public async Task CanUseExistsFilter() { @@ -388,6 +422,7 @@ public async Task GetSampleContextWithDataAsync(IServiceProvider PhoneNumber = "(214) 222-2222", NationalPhoneNumber = phoneNumberUtil.Parse("(214) 222-2222", "US").NationalNumber.ToString(), Salary = 80_000, + Birthday = new DateOnly(1980, 1, 1), DataValues = [new() { Definition = company.DataDefinitions[0], NumberValue = 30 }], Companies = [company] }); @@ -398,6 +433,7 @@ public async Task GetSampleContextWithDataAsync(IServiceProvider PhoneNumber = "+52 55 1234 5678", // Mexico NationalPhoneNumber = phoneNumberUtil.Parse("+52 55 1234 5678", "US").NationalNumber.ToString(), Salary = 90_000, + Birthday = new DateOnly(1972, 11, 6), DataValues = [new() { Definition = company.DataDefinitions[0], NumberValue = 23 }], Companies = [company] });