diff --git a/src/Foundatio.Parsers.SqlQueries/Extensions/QueryableExtensions.cs b/src/Foundatio.Parsers.SqlQueries/Extensions/QueryableExtensions.cs new file mode 100644 index 00000000..7f06e84a --- /dev/null +++ b/src/Foundatio.Parsers.SqlQueries/Extensions/QueryableExtensions.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Dynamic.Core; +using Foundatio.Parsers.SqlQueries.Extensions; +using Foundatio.Parsers.SqlQueries.Visitors; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Foundatio.Parsers.SqlQueries; + +public static class QueryableExtensions +{ + private static readonly SqlQueryParser _parser = new(); + private static readonly ConcurrentDictionary> _entityFieldCache = new(); + + public static IQueryable LuceneWhere(this IQueryable source, string query) where T : class + { + if (source is not DbSet dbSet) + throw new ArgumentException("source must be a DbSet", nameof(source)); + + var serviceProvider = ((IInfrastructure)dbSet).Instance; + var loggerFactory = serviceProvider.GetService(); + var logger = loggerFactory.CreateLogger(); + + // use service provider to get global settings that say how to discover and handle custom fields + // support field aliases + + var fields = _entityFieldCache.GetOrAdd(dbSet.EntityType, entityType => + { + var fields = new List(); + AddFields(fields, entityType); + return fields; + }); + + var context = new SqlQueryVisitorContext { Fields = fields }; + var node = _parser.Parse(query, context); + string sql = GenerateSqlVisitor.Run(node, context); + return source.Where(sql); + } + + private static void AddFields(List fields, IEntityType entityType, List visited = null) + { + visited ??= []; + if (visited.Contains(entityType)) + return; + + visited.Add(entityType); + + foreach (var property in entityType.GetProperties()) + { + if (property.IsIndex() || property.IsKey()) + fields.Add(new FieldInfo + { + Field = property.Name, + IsNumber = property.ClrType.UnwrapNullable().IsNumeric(), + IsDate = property.ClrType.UnwrapNullable().IsDateTime(), + IsBoolean = property.ClrType.UnwrapNullable().IsBoolean() + }); + } + + foreach (var nav in entityType.GetNavigations()) + { + if (visited.Contains(nav.TargetEntityType)) + continue; + + var field = new FieldInfo + { + Field = nav.Name, + Children = new List() + }; + fields.Add(field); + AddFields(field.Children, nav.TargetEntityType, visited); + } + } +} diff --git a/src/Foundatio.Parsers.SqlQueries/Extensions/SqlNodeExtensions.cs b/src/Foundatio.Parsers.SqlQueries/Extensions/SqlNodeExtensions.cs new file mode 100644 index 00000000..f0971b72 --- /dev/null +++ b/src/Foundatio.Parsers.SqlQueries/Extensions/SqlNodeExtensions.cs @@ -0,0 +1,174 @@ +using System; +using System.Linq; +using System.Text; +using Foundatio.Parsers.LuceneQueries.Nodes; +using Foundatio.Parsers.SqlQueries.Visitors; + +namespace Foundatio.Parsers.SqlQueries.Extensions; + +public static class SqlNodeExtensions +{ + public static string ToSqlString(this GroupNode node, ISqlQueryVisitorContext context) + { + if (node.Left == null && node.Right == null) + return String.Empty; + + var defaultOperator = context.DefaultOperator; + + var builder = new StringBuilder(); + var op = node.Operator != GroupOperator.Default ? node.Operator : defaultOperator; + + if (node.IsNegated.HasValue && node.IsNegated.Value) + builder.Append("NOT "); + + builder.Append(node.Prefix); + + if (!String.IsNullOrEmpty(node.Field)) + builder.Append(node.Field).Append(':'); + + if (node.HasParens) + builder.Append("("); + + if (node.Left != null) + builder.Append(node.Left is GroupNode groupNode ? groupNode.ToSqlString(context) : node.Left.ToSqlString(context)); + + if (node.Left != null && node.Right != null) + { + if (op == GroupOperator.Or || (op == GroupOperator.Default && defaultOperator == GroupOperator.Or)) + builder.Append(" OR "); + else if (node.Right != null) + builder.Append(" AND "); + } + + if (node.Right != null) + builder.Append(node.Right is GroupNode groupNode ? groupNode.ToSqlString(context) : node.Right.ToSqlString(context)); + + if (node.HasParens) + builder.Append(")"); + + if (node.Proximity != null) + builder.Append("~" + node.Proximity); + + if (node.Boost != null) + builder.Append("^" + node.Boost); + + return builder.ToString(); + } + + public static string ToSqlString(this ExistsNode node, ISqlQueryVisitorContext context) + { + if (String.IsNullOrEmpty(node.Field)) + throw new ArgumentException("Field is required for exists node queries."); + + var builder = new StringBuilder(); + + if (node.IsNegated.HasValue && node.IsNegated.Value) + builder.Append("NOT "); + + builder.Append(node.Field); + builder.Append(" IS NOT NULL"); + + return builder.ToString(); + } + + public static string ToSqlString(this MissingNode node, ISqlQueryVisitorContext context) + { + if (String.IsNullOrEmpty(node.Field)) + throw new ArgumentException("Field is required for missing node queries."); + + if (!String.IsNullOrEmpty(node.Prefix)) + throw new ArgumentException("Prefix is not supported for term range queries."); + + var builder = new StringBuilder(); + + if (node.IsNegated.HasValue && node.IsNegated.Value) + builder.Append("NOT "); + + builder.Append(node.Field); + builder.Append(" IS NULL"); + + return builder.ToString(); + } + + public static string ToSqlString(this TermNode node, ISqlQueryVisitorContext context) + { + if (String.IsNullOrEmpty(node.Field)) + throw new ArgumentException("Field is required for term node queries."); + + if (!String.IsNullOrEmpty(node.Prefix)) + throw new ArgumentException("Prefix is not supported for term range queries."); + + var builder = new StringBuilder(); + + if (node.IsNegated.HasValue && node.IsNegated.Value) + builder.Append("NOT "); + + builder.Append(node.Field); + if (node.IsNegated.HasValue && node.IsNegated.Value) + builder.Append(" != "); + else + builder.Append(" = "); + + // TODO: This needs to resolve the field recursively + var field = context.Fields.FirstOrDefault(f => f.Field.Equals(node.Field, StringComparison.OrdinalIgnoreCase)); + if (field != null && (field.IsNumber || field.IsBoolean)) + builder.Append(node.Term); + else + builder.Append("\"" + node.Term + "\""); + + return builder.ToString(); + } + + public static string ToSqlString(this TermRangeNode node, ISqlQueryVisitorContext context) + { + if (String.IsNullOrEmpty(node.Field)) + throw new ArgumentException("Field is required for term range queries."); + if (!String.IsNullOrEmpty(node.Boost)) + throw new ArgumentException("Boost is not supported for term range queries."); + if (!String.IsNullOrEmpty(node.Proximity)) + throw new ArgumentException("Proximity is not supported for term range queries."); + + var builder = new StringBuilder(); + + if (node.IsNegated.HasValue && node.IsNegated.Value) + builder.Append("NOT "); + + if (node.Min != null && node.Max != null) + builder.Append("("); + + if (node.Min != null) + { + builder.Append(node.Field); + builder.Append(node.MinInclusive == true ? " >= " : " > "); + builder.Append(node.Min); + } + + if (node.Min != null && node.Max != null) + builder.Append(" AND "); + + if (node.Max != null) + { + builder.Append(node.Field); + builder.Append(node.MaxInclusive == true ? " <= " : " < "); + builder.Append(node.Max); + } + + if (node.Min != null && node.Max != null) + builder.Append(")"); + + return builder.ToString(); + } + + public static string ToSqlString(this IQueryNode node, ISqlQueryVisitorContext context) + { + return node switch + { + GroupNode groupNode => groupNode.ToSqlString(context), + ExistsNode existsNode => existsNode.ToSqlString(context), + MissingNode missingNode => missingNode.ToSqlString(context), + TermNode termNode => termNode.ToSqlString(context), + TermRangeNode termRangeNode => termRangeNode.ToSqlString(context), + _ => throw new NotSupportedException($"Node type {node.GetType().Name} is not supported.") + }; + } +} diff --git a/src/Foundatio.Parsers.SqlQueries/Extensions/TypeExtensions.cs b/src/Foundatio.Parsers.SqlQueries/Extensions/TypeExtensions.cs new file mode 100644 index 00000000..1592177a --- /dev/null +++ b/src/Foundatio.Parsers.SqlQueries/Extensions/TypeExtensions.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; + +namespace Foundatio.Parsers.SqlQueries.Extensions; + +public static class TypeExtensions +{ + private static readonly IList _integerTypes = new List() + { + typeof (byte), + typeof (short), + typeof (int), + typeof (long), + typeof (sbyte), + typeof (ushort), + typeof (uint), + typeof (ulong), + typeof (byte?), + typeof (short?), + typeof (int?), + typeof (long?), + typeof (sbyte?), + typeof (ushort?), + typeof (uint?), + typeof (ulong?) + }; + + public static Type UnwrapNullable(this Type type) + { + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + return Nullable.GetUnderlyingType(type); + + return 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 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); + public static bool IsFloatingPoint(this Type type) => type == typeof(decimal) || type == typeof(float) || type == typeof(double); +} diff --git a/src/Foundatio.Parsers.SqlQueries/Foundatio.Parsers.SqlQueries.csproj b/src/Foundatio.Parsers.SqlQueries/Foundatio.Parsers.SqlQueries.csproj index aeab62f6..0fd7a144 100644 --- a/src/Foundatio.Parsers.SqlQueries/Foundatio.Parsers.SqlQueries.csproj +++ b/src/Foundatio.Parsers.SqlQueries/Foundatio.Parsers.SqlQueries.csproj @@ -1,10 +1,14 @@  + + net8.0; + + - + - + diff --git a/src/Foundatio.Parsers.SqlQueries/Visitors/GenerateSqlVisitor.cs b/src/Foundatio.Parsers.SqlQueries/Visitors/GenerateSqlVisitor.cs index f065d01a..9fec1d21 100644 --- a/src/Foundatio.Parsers.SqlQueries/Visitors/GenerateSqlVisitor.cs +++ b/src/Foundatio.Parsers.SqlQueries/Visitors/GenerateSqlVisitor.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Foundatio.Parsers.LuceneQueries.Nodes; using Foundatio.Parsers.LuceneQueries.Visitors; +using Foundatio.Parsers.SqlQueries.Extensions; namespace Foundatio.Parsers.SqlQueries.Visitors; @@ -12,29 +13,44 @@ public class GenerateSqlVisitor : QueryNodeVisitorWithResultBase public override Task VisitAsync(GroupNode node, IQueryVisitorContext context) { - _builder.Append(node.ToSqlString(context?.DefaultOperator ?? GroupOperator.Default)); + if (context is not ISqlQueryVisitorContext sqlContext) + throw new InvalidOperationException("The context must be an ISqlQueryVisitorContext."); + + _builder.Append(node.ToSqlString(sqlContext)); return Task.CompletedTask; } public override void Visit(TermNode node, IQueryVisitorContext context) { - _builder.Append(node.ToSqlString()); + if (context is not ISqlQueryVisitorContext sqlContext) + throw new InvalidOperationException("The context must be an ISqlQueryVisitorContext."); + + _builder.Append(node.ToSqlString(sqlContext)); } public override void Visit(TermRangeNode node, IQueryVisitorContext context) { - _builder.Append(node.ToSqlString()); + if (context is not ISqlQueryVisitorContext sqlContext) + throw new InvalidOperationException("The context must be an ISqlQueryVisitorContext."); + + _builder.Append(node.ToSqlString(sqlContext)); } public override void Visit(ExistsNode node, IQueryVisitorContext context) { - _builder.Append(node.ToSqlString()); + if (context is not ISqlQueryVisitorContext sqlContext) + throw new InvalidOperationException("The context must be an ISqlQueryVisitorContext."); + + _builder.Append(node.ToSqlString(sqlContext)); } public override void Visit(MissingNode node, IQueryVisitorContext context) { - _builder.Append(node.ToSqlString()); + if (context is not ISqlQueryVisitorContext sqlContext) + throw new InvalidOperationException("The context must be an ISqlQueryVisitorContext."); + + _builder.Append(node.ToSqlString(sqlContext)); } public override async Task AcceptAsync(IQueryNode node, IQueryVisitorContext context) diff --git a/src/Foundatio.Parsers.SqlQueries/Visitors/ISqlQueryVisitorContext.cs b/src/Foundatio.Parsers.SqlQueries/Visitors/ISqlQueryVisitorContext.cs index 6dd1102b..c02057f6 100644 --- a/src/Foundatio.Parsers.SqlQueries/Visitors/ISqlQueryVisitorContext.cs +++ b/src/Foundatio.Parsers.SqlQueries/Visitors/ISqlQueryVisitorContext.cs @@ -1,11 +1,8 @@ -using System; -using System.Threading.Tasks; +using System.Collections.Generic; using Foundatio.Parsers.LuceneQueries.Visitors; namespace Foundatio.Parsers.SqlQueries.Visitors { public interface ISqlQueryVisitorContext : IQueryVisitorContext { - Func> DefaultTimeZone { get; set; } - bool UseScoring { get; set; } - //ElasticMappingResolver MappingResolver { get; set; } + List Fields { get; set; } } } diff --git a/src/Foundatio.Parsers.SqlQueries/Visitors/SqlNodeExtensions.cs b/src/Foundatio.Parsers.SqlQueries/Visitors/SqlNodeExtensions.cs deleted file mode 100644 index 043a27d6..00000000 --- a/src/Foundatio.Parsers.SqlQueries/Visitors/SqlNodeExtensions.cs +++ /dev/null @@ -1,161 +0,0 @@ -using System; -using System.Text; -using Foundatio.Parsers.LuceneQueries.Nodes; - -namespace Foundatio.Parsers.SqlQueries.Visitors; - -public static class SqlNodeExtensions -{ - public static string ToSqlString(this GroupNode node, GroupOperator defaultOperator = GroupOperator.Default) - { - if (node.Left == null && node.Right == null) - return String.Empty; - - var builder = new StringBuilder(); - var op = node.Operator != GroupOperator.Default ? node.Operator : defaultOperator; - - if (node.IsNegated.HasValue && node.IsNegated.Value) - builder.Append("NOT "); - - builder.Append(node.Prefix); - - if (!String.IsNullOrEmpty(node.Field)) - builder.Append(node.Field).Append(':'); - - if (node.HasParens) - builder.Append("("); - - if (node.Left != null) - builder.Append(node.Left is GroupNode groupNode ? groupNode.ToSqlString(defaultOperator) : node.Left.ToSqlString()); - - if (node.Left != null && node.Right != null) - { - if (op == GroupOperator.And) - builder.Append(" AND "); - else if (op == GroupOperator.Or) - builder.Append(" OR "); - else if (node.Right != null) - builder.Append(" "); - } - - if (node.Right != null) - builder.Append(node.Right is GroupNode groupNode ? groupNode.ToSqlString(defaultOperator) : node.Right.ToSqlString()); - - if (node.HasParens) - builder.Append(")"); - - if (node.Proximity != null) - builder.Append("~" + node.Proximity); - - if (node.Boost != null) - builder.Append("^" + node.Boost); - - return builder.ToString(); - } - - public static string ToSqlString(this ExistsNode node) - { - var builder = new StringBuilder(); - - if (node.IsNegated.HasValue && node.IsNegated.Value) - builder.Append("NOT "); - - builder.Append(node.Prefix); - builder.Append("_exists_"); - builder.Append(":"); - builder.Append(node.Field); - - return builder.ToString(); - } - - public static string ToSqlString(this MissingNode node) - { - var builder = new StringBuilder(); - - if (node.IsNegated.HasValue && node.IsNegated.Value) - builder.Append("NOT "); - - builder.Append(node.Prefix); - builder.Append("_missing_"); - builder.Append(":"); - builder.Append(node.Field); - - return builder.ToString(); - } - - public static string ToSqlString(this TermNode node) - { - var builder = new StringBuilder(); - - if (node.IsNegated.HasValue && node.IsNegated.Value) - builder.Append("NOT "); - - if (!String.IsNullOrEmpty(node.Field)) - { - builder.Append(node.Field); - if (node.IsNegated.HasValue && node.IsNegated.Value) - builder.Append(" != "); - else - builder.Append(" = "); - } - - builder.Append("\"" + node.Term + "\""); - - return builder.ToString(); - } - - public static string ToSqlString(this TermRangeNode node) - { - var builder = new StringBuilder(); - - if (node.IsNegated.HasValue && node.IsNegated.Value) - builder.Append("NOT "); - - builder.Append(node.Prefix); - - if (!String.IsNullOrEmpty(node.Field)) - { - builder.Append(node.Field); - builder.Append(":"); - } - - if (!String.IsNullOrEmpty(node.Operator)) - builder.Append(node.Operator); - - if (node.MinInclusive.HasValue && String.IsNullOrEmpty(node.Operator)) - builder.Append(node.MinInclusive.Value ? "[" : "{"); - - if (node.Min != null) - builder.Append(node.Min); - - if (node.Delimiter != null) - builder.Append(node.Delimiter); - - if (node.Max != null) - builder.Append(node.Max); - - if (node.MaxInclusive.HasValue && String.IsNullOrEmpty(node.Operator)) - builder.Append(node.MaxInclusive.Value ? "]" : "}"); - - if (node.Boost != null) - builder.Append("^" + node.Boost); - - if (node.Proximity != null) - builder.Append("~" + node.Proximity); - - return builder.ToString(); - } - - public static string ToSqlString(this IQueryNode node, GroupOperator defaultOperator = GroupOperator.Default) - { - return node switch - { - GroupNode groupNode => groupNode.ToSqlString(defaultOperator), - ExistsNode existsNode => existsNode.ToSqlString(), - MissingNode missingNode => missingNode.ToSqlString(), - TermNode termNode => termNode.ToSqlString(), - TermRangeNode termRangeNode => termRangeNode.ToSqlString(), - _ => throw new NotSupportedException($"Node type {node.GetType().Name} is not supported.") - }; - } -} diff --git a/src/Foundatio.Parsers.SqlQueries/Visitors/SqlQueryVisitorContext.cs b/src/Foundatio.Parsers.SqlQueries/Visitors/SqlQueryVisitorContext.cs index 17ee2f10..085c58ad 100644 --- a/src/Foundatio.Parsers.SqlQueries/Visitors/SqlQueryVisitorContext.cs +++ b/src/Foundatio.Parsers.SqlQueries/Visitors/SqlQueryVisitorContext.cs @@ -1,11 +1,19 @@ -using System; -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Diagnostics; using Foundatio.Parsers.LuceneQueries.Visitors; namespace Foundatio.Parsers.SqlQueries.Visitors; public class SqlQueryVisitorContext : QueryVisitorContext, ISqlQueryVisitorContext { - public Func> DefaultTimeZone { get; set; } - public bool UseScoring { get; set; } - //public ElasticMappingResolver MappingResolver { get; set; } + public List Fields { get; set; } +} + +[DebuggerDisplay("{Field} IsNumber: {IsNumber} IsDate: {IsDate} IsBoolean: {IsBoolean} Children: {Children?.Count}")] +public class FieldInfo +{ + public string Field { get; set; } + public bool IsNumber { get; set; } + public bool IsDate { get; set; } + public bool IsBoolean { get; set; } + public List Children { get; set; } } diff --git a/tests/Foundatio.Parsers.SqlQueries.Tests/SampleContext.cs b/tests/Foundatio.Parsers.SqlQueries.Tests/SampleContext.cs new file mode 100644 index 00000000..27e62519 --- /dev/null +++ b/tests/Foundatio.Parsers.SqlQueries.Tests/SampleContext.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; + +namespace Foundatio.Parsers.SqlQueries.Tests; + +public class SampleContext : DbContext { + public SampleContext(DbContextOptions options) : base(options) { } + public DbSet Employees => Set(); + public DbSet Companies => Set(); + public DbSet DataDefinitions => Set(); + public DbSet DataValues => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Employee + modelBuilder.Entity().HasIndex(e => new { e.FullName, e.Title, e.Age }); + + // Company + modelBuilder.Entity().HasIndex(e => new { e.Name, e.Description }); + + // DataDefinition + modelBuilder.Entity().Property(c => c.DataType).IsRequired(); + modelBuilder.Entity().HasIndex(c => new { c.CompanyId, c.Key }).IsUnique(); + + // DataValue + modelBuilder.Entity().HasIndex(c => new { c.DataDefinitionId, c.CompanyId, c.EmployeeId }).HasFilter(null).IsUnique(); + modelBuilder.Entity().Property(e => e.StringValue).HasMaxLength(4000).IsSparse(); + modelBuilder.Entity().Property(e => e.DateValue).IsSparse(); + modelBuilder.Entity().Property(e => e.MoneyValue).IsSparse().HasColumnType("money").HasPrecision(2); + modelBuilder.Entity().Property(e => e.BooleanValue).IsSparse(); + modelBuilder.Entity().Property(e => e.NumberValue).HasColumnType("decimal").HasPrecision(15,3).IsSparse(); + modelBuilder.Entity().HasIndex(e => new { e.StringValue, e.DateValue, e.MoneyValue, e.BooleanValue, e.NumberValue }); + } +} + +public class Employee { + public int Id { get; set; } + public string FullName { get; set; } + public string Title { get; set; } + public int Age { get; set; } + public int CompanyId { get; set; } + public Company Company { get; set; } + public List DataValues { get; set; } +} + +public class Company { + public int Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public List Employees { get; set; } + public List DataDefinitions { get; set; } +} + +public class DataValue +{ + public int Id { get; set; } + public int DataDefinitionId { get; set; } + public int CompanyId { get; set; } + public int EmployeeId { get; set; } + + // store the values separately as sparse columns for querying purposes + public string StringValue { get; set; } + public DateTime? DateValue { get; set; } + public decimal? MoneyValue { get; set; } + public bool? BooleanValue { get; set; } + public decimal? NumberValue { get; set; } + + public DataDefinition Definition { get; set; } = null; + + public object GetValue(DataType? dataType = null) + { + if (!dataType.HasValue && Definition != null) + dataType = Definition.DataType; + + if (dataType.HasValue) + { + return dataType switch + { + DataType.String => StringValue, + DataType.Date => DateValue, + DataType.Number => NumberValue, + DataType.Boolean => BooleanValue, + DataType.Money => MoneyValue, + DataType.Percent => NumberValue, + _ => null + }; + } + + if (MoneyValue.HasValue) + return MoneyValue.Value; + if (BooleanValue.HasValue) + return BooleanValue.Value; + if (NumberValue.HasValue) + return NumberValue.Value; + if (DateValue.HasValue) + return DateValue.Value; + + return StringValue ?? null; + } + + public void ClearValue() + { + StringValue = null; + DateValue = null; + NumberValue = null; + BooleanValue = null; + MoneyValue = null; + } + + public void SetValue(object value, DataType? dataType = null) + { + ClearValue(); + + if (value == null) + return; + + switch (dataType ?? Definition!.DataType) + { + case DataType.String: + StringValue = value.ToString(); + break; + case DataType.Date: + if (DateTime.TryParse(value.ToString(), out DateTime dateResult)) + DateValue = dateResult; + break; + case DataType.Number: + case DataType.Percent: + if (Decimal.TryParse(value.ToString(), out decimal numberResult)) + NumberValue = numberResult; + break; + case DataType.Boolean: + if (Boolean.TryParse(value.ToString(), out bool boolResult)) + BooleanValue = boolResult; + break; + case DataType.Money: + if (Decimal.TryParse(value.ToString(), out decimal decimalResult)) + MoneyValue = decimalResult; + break; + } + } + + // relationships + [DeleteBehavior(DeleteBehavior.NoAction)] + public Employee Employee { get; set; } = null; +} + +public class DataDefinition +{ + public int Id { get; set; } + public int CompanyId { get; set; } + + public DataType DataType { get; set; } + public string Key { get; set; } = String.Empty; + + // relationships + [DeleteBehavior(DeleteBehavior.Cascade)] + public Company Company { get; set; } = null; +} + +public enum DataType +{ + String, + Number, + Boolean, + Date, + Money, + Percent +} diff --git a/tests/Foundatio.Parsers.SqlQueries.Tests/SqlQueryParserTests.cs b/tests/Foundatio.Parsers.SqlQueries.Tests/SqlQueryParserTests.cs index 367e150c..3f07b819 100644 --- a/tests/Foundatio.Parsers.SqlQueries.Tests/SqlQueryParserTests.cs +++ b/tests/Foundatio.Parsers.SqlQueries.Tests/SqlQueryParserTests.cs @@ -4,10 +4,13 @@ using System.Linq.Dynamic.Core; using System.Threading.Tasks; using Foundatio.Parsers.LuceneQueries.Nodes; +using Foundatio.Parsers.LuceneQueries.Visitors; +using Foundatio.Parsers.SqlQueries.Extensions; using Foundatio.Parsers.SqlQueries.Visitors; using Foundatio.Xunit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Pegasus.Common.Tracing; using Xunit; using Xunit.Abstractions; @@ -18,12 +21,49 @@ public SqlQueryParserTests(ITestOutputHelper output) : base(output) { Log.MinimumLevel = LogLevel.Trace; } + [Theory] + [InlineData("field:[1 TO 5]", "(field >= 1 AND field <= 5)")] + [InlineData("field:{1 TO 5}", "(field > 1 AND field < 5)")] + [InlineData("field:[1 TO 5}", "(field >= 1 AND field < 5)")] + [InlineData("field:>5", "field > 5")] + [InlineData("field:>=5", "field >= 5")] + [InlineData("field:<5", "field < 5")] + [InlineData("field:<=5", "field <= 5")] + // [InlineData("date:>now")] + // [InlineData("date:now")] + // [InlineData("data.date:[now/d-4d TO now/d+1d}")] + // [InlineData("data.date:[2012-01-01 TO 2012-12-31]")] + // [InlineData("data.date:[* TO 2012-12-31]")] + // [InlineData("data.date:[2012-01-01 TO *]")] + // [InlineData("(data.date:[now/d-4d TO now/d+1d})")] + // [InlineData("count:>1")] + // [InlineData("count:>=1")] + // [InlineData("count:[1..5}")] + // [InlineData(@"count:a\:a")] + // [InlineData(@"count:a\:a more:stuff")] + // [InlineData("data.count:[1..5}")] + // [InlineData("age:[1 TO 2]")] + // [InlineData("data.Windows-identity:ejsmith")] + // [InlineData("data.age:[* TO 10]")] + // [InlineData("hidden:true")] + public Task ValidQueries(string query, string expected) + { + return ParseAndValidateQuery(query, expected, true); + } + [Fact] public async Task CanGenerateSql() { var contextOptions = new DbContextOptionsBuilder() .UseSqlServer("Server=localhost;User Id=sa;Password=P@ssword1;Timeout=5;Initial Catalog=foundatio;Encrypt=False") .Options; + await using var context = new SampleContext(contextOptions); + await context.Database.EnsureDeletedAsync(); await context.Database.EnsureCreatedAsync(); var company = new Company { @@ -49,15 +89,12 @@ public async Task CanGenerateSql() { var parser = new SqlQueryParser(); parser.Configuration.UseFieldMap(new Dictionary {{ "age", "DataValues.Any(DataDefinitionId = 1 AND NumberValue" }}); - // translate AST to dynamic linq - // lookup custom fields and convert to sql - // know what data type each column is in order to know if it support range operators - var node = await parser.ParseAsync("""company.name:acme age:30"""); - string sql = await GenerateSqlVisitor.RunAsync(node); string sqlExpected = context.Employees.Where(e => e.Company.Name == "acme" && e.DataValues.Any(dv => dv.DataDefinitionId == 1 && dv.NumberValue == 30)).ToQueryString(); string sqlActual = context.Employees.Where("""company.name = "acme" AND DataValues.Any(DataDefinitionId = 1 AND NumberValue = 30) """).ToQueryString(); Assert.Equal(sqlExpected, sqlActual); + sqlActual = context.Employees.LuceneWhere("company.name:acme (age:1 OR age:>30)").ToQueryString(); + Assert.Equal(sqlExpected, sqlActual); var employees = await context.Employees.Where(e => e.Title == "software developer" && e.DataValues.Any(dv => dv.DataDefinitionId == 1 && dv.NumberValue == 30)) .ToListAsync(); @@ -66,162 +103,33 @@ public async Task CanGenerateSql() { var employee = employees.Single(); Assert.Equal("John Doe", employee.FullName); } -} -public class SampleContext : DbContext { - public SampleContext(DbContextOptions options) : base(options) { } - public DbSet Employees => Set(); - public DbSet Companies => Set(); - public DbSet DataDefinitions => Set(); - public DbSet DataValues => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) + private async Task ParseAndValidateQuery(string query, string expected, bool isValid) { - base.OnModelCreating(modelBuilder); - - // DataDefinition - modelBuilder.Entity().Property(c => c.DataType).IsRequired(); - modelBuilder.Entity().HasIndex(c => new { c.CompanyId, c.Key }).IsUnique(); - - // DataValue - modelBuilder.Entity().HasIndex(c => new { c.DataDefinitionId, c.CompanyId, c.EmployeeId }).HasFilter(null).IsUnique(); - modelBuilder.Entity().Property(e => e.StringValue).HasMaxLength(4000).IsSparse(); - modelBuilder.Entity().Property(e => e.DateValue).IsSparse(); - modelBuilder.Entity().Property(e => e.MoneyValue).IsSparse().HasColumnType("money").HasPrecision(2); - modelBuilder.Entity().Property(e => e.BooleanValue).IsSparse(); - modelBuilder.Entity().Property(e => e.NumberValue).HasColumnType("decimal").HasPrecision(15,3).IsSparse(); - } -} - -public class Employee { - public int Id { get; set; } - public string FullName { get; set; } - public string Title { get; set; } - public int CompanyId { get; set; } - public Company Company { get; set; } - public List DataValues { get; set; } -} - -public class Company { - public int Id { get; set; } - public string Name { get; set; } - public string Description { get; set; } - public List Employees { get; set; } - public List DataDefinitions { get; set; } -} - -public class DataValue -{ - public int Id { get; set; } - public int DataDefinitionId { get; set; } - public int CompanyId { get; set; } - public int EmployeeId { get; set; } - - // store the values separately as sparse columns for querying purposes - public string StringValue { get; set; } - public DateTime? DateValue { get; set; } - public decimal? MoneyValue { get; set; } - public bool? BooleanValue { get; set; } - public decimal? NumberValue { get; set; } - - public DataDefinition Definition { get; set; } = null; - - public object GetValue(DataType? dataType = null) - { - if (!dataType.HasValue && Definition != null) - dataType = Definition.DataType; +#if ENABLE_TRACING + var tracer = new LoggingTracer(_logger, reportPerformance: true); +#else + var tracer = NullTracer.Instance; +#endif + var parser = new SqlQueryParser + { + Tracer = tracer + }; - if (dataType.HasValue) + IQueryNode result; + try { - return dataType switch - { - DataType.String => StringValue, - DataType.Date => DateValue, - DataType.Number => NumberValue, - DataType.Boolean => BooleanValue, - DataType.Money => MoneyValue, - DataType.Percent => NumberValue, - _ => null - }; + result = await parser.ParseAsync(query); } - - if (MoneyValue.HasValue) - return MoneyValue.Value; - if (BooleanValue.HasValue) - return BooleanValue.Value; - if (NumberValue.HasValue) - return NumberValue.Value; - if (DateValue.HasValue) - return DateValue.Value; - - return StringValue ?? null; - } - - public void ClearValue() - { - StringValue = null; - DateValue = null; - NumberValue = null; - BooleanValue = null; - MoneyValue = null; - } - - public void SetValue(object value, DataType? dataType = null) - { - ClearValue(); - - if (value == null) - return; - - switch (dataType ?? Definition!.DataType) + catch (FormatException ex) { - case DataType.String: - StringValue = value.ToString(); - break; - case DataType.Date: - if (DateTime.TryParse(value.ToString(), out DateTime dateResult)) - DateValue = dateResult; - break; - case DataType.Number: - case DataType.Percent: - if (Decimal.TryParse(value.ToString(), out decimal numberResult)) - NumberValue = numberResult; - break; - case DataType.Boolean: - if (Boolean.TryParse(value.ToString(), out bool boolResult)) - BooleanValue = boolResult; - break; - case DataType.Money: - if (Decimal.TryParse(value.ToString(), out decimal decimalResult)) - MoneyValue = decimalResult; - break; + Assert.False(isValid, ex.Message); + return; } - } - - // relationships - [DeleteBehavior(DeleteBehavior.NoAction)] - public Employee Employee { get; set; } = null; -} -public class DataDefinition -{ - public int Id { get; set; } - public int CompanyId { get; set; } - - public DataType DataType { get; set; } - public string Key { get; set; } = String.Empty; - - // relationships - [DeleteBehavior(DeleteBehavior.Cascade)] - public Company Company { get; set; } = null; -} - -public enum DataType -{ - String, - Number, - Boolean, - Date, - Money, - Percent + string nodes = await DebugQueryVisitor.RunAsync(result); + _logger.LogInformation(nodes); + string generatedQuery = await GenerateSqlVisitor.RunAsync(result); + Assert.Equal(expected, generatedQuery); + } }