diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..5c6b8584 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + +- package-ecosystem: nuget + directory: "/" + schedule: + interval: weekly diff --git a/build/common.props b/build/common.props index 34024f9b..88ba6c79 100644 --- a/build/common.props +++ b/build/common.props @@ -38,7 +38,7 @@ - + diff --git a/docker-compose.yml b/docker-compose.yml index 43325b83..4db30583 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,24 +10,36 @@ services: - 9300:9300 networks: - foundatio + healthcheck: + interval: 2s + retries: 10 + test: curl -s http://localhost:9200/_cluster/health | grep -vq '"status":"red"' kibana: depends_on: - - elasticsearch + elasticsearch: + condition: service_healthy image: docker.elastic.co/kibana/kibana:8.15.1 ports: - 5601:5601 networks: - foundatio + healthcheck: + interval: 2s + retries: 20 + test: curl --write-out 'HTTP %{http_code}' --fail --silent --output /dev/null http://localhost:5601/api/status sqlserver: - image: mcr.microsoft.com/azure-sql-edge:1.0.7 + image: mcr.microsoft.com/mssql/server:2022-latest ports: - "1433:1433" # login with sa:P@ssword1 environment: - "ACCEPT_EULA=Y" - - "SA_PASSWORD=P@ssword1" + - "MSSQL_SA_PASSWORD=P@ssword1" - "MSSQL_PID=Developer" + user: root + networks: + - foundatio healthcheck: test: [ @@ -46,6 +58,7 @@ services: command: elasticsearch:9200 depends_on: - elasticsearch + - sqlserver networks: - foundatio diff --git a/src/Foundatio.Parsers.ElasticQueries/Foundatio.Parsers.ElasticQueries.csproj b/src/Foundatio.Parsers.ElasticQueries/Foundatio.Parsers.ElasticQueries.csproj index f09c4c55..cee9dd8d 100644 --- a/src/Foundatio.Parsers.ElasticQueries/Foundatio.Parsers.ElasticQueries.csproj +++ b/src/Foundatio.Parsers.ElasticQueries/Foundatio.Parsers.ElasticQueries.csproj @@ -1,9 +1,9 @@ - + - + diff --git a/src/Foundatio.Parsers.SqlQueries/Extensions/EnumerableExtensions.cs b/src/Foundatio.Parsers.SqlQueries/Extensions/EnumerableExtensions.cs new file mode 100644 index 00000000..2b61ebeb --- /dev/null +++ b/src/Foundatio.Parsers.SqlQueries/Extensions/EnumerableExtensions.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace Foundatio.Parsers.SqlQueries.Extensions; + +internal static class EnumerableExtensions { + public delegate void ElementAction(T element, ElementInfo info); + + public static void ForEach(this IEnumerable elements, ElementAction action) + { + using IEnumerator enumerator = elements.GetEnumerator(); + bool isFirst = true; + bool hasNext = enumerator.MoveNext(); + int index = 0; + + while (hasNext) + { + T current = enumerator.Current; + hasNext = enumerator.MoveNext(); + action(current, new ElementInfo(index, isFirst, !hasNext)); + isFirst = false; + index++; + } + } + + public struct ElementInfo { + public ElementInfo(int index, bool isFirst, bool isLast) + : this() { + Index = index; + IsFirst = isFirst; + IsLast = isLast; + } + + public int Index { get; private set; } + public bool IsFirst { get; private set; } + public bool IsLast { get; private set; } + } +} + diff --git a/src/Foundatio.Parsers.SqlQueries/Extensions/SqlNodeExtensions.cs b/src/Foundatio.Parsers.SqlQueries/Extensions/SqlNodeExtensions.cs index c1c89c9f..791f876f 100644 --- a/src/Foundatio.Parsers.SqlQueries/Extensions/SqlNodeExtensions.cs +++ b/src/Foundatio.Parsers.SqlQueries/Extensions/SqlNodeExtensions.cs @@ -5,6 +5,7 @@ using Foundatio.Parsers.LuceneQueries.Extensions; using Foundatio.Parsers.LuceneQueries.Nodes; using Foundatio.Parsers.SqlQueries.Visitors; +using Microsoft.Extensions.Primitives; namespace Foundatio.Parsers.SqlQueries.Extensions; @@ -70,13 +71,18 @@ public static string ToDynamicLinqString(this ExistsNode node, ISqlQueryVisitorC if (node.TryGetQuery(out string query)) return query; + var field = GetFieldInfo(context.Fields, node.Field); + var (fieldPrefix, fieldSuffix) = field.GetFieldPrefixAndSuffix(); + var builder = new StringBuilder(); - builder.Append(node.Field); + builder.Append(fieldPrefix); + builder.Append(field.Name); if (!node.IsNegated.HasValue || !node.IsNegated.Value) builder.Append(" != null"); else builder.Append(" == null"); + builder.Append(fieldSuffix); return builder.ToString(); } @@ -93,31 +99,30 @@ public static string ToDynamicLinqString(this MissingNode node, ISqlQueryVisitor if (node.TryGetQuery(out string query)) return query; - var builder = new StringBuilder(); + var field = GetFieldInfo(context.Fields, node.Field); + var (fieldPrefix, fieldSuffix) = field.GetFieldPrefixAndSuffix(); - builder.Append(node.Field); + var builder = new StringBuilder(); + builder.Append(fieldPrefix); + builder.Append(field.Name); if (!node.IsNegated.HasValue || !node.IsNegated.Value) builder.Append(" == null"); else builder.Append(" != null"); + builder.Append(fieldSuffix); return builder.ToString(); } - public static EntityFieldInfo GetFieldInfo(List fields, string field) - { - if (fields == null) - return new EntityFieldInfo { Field = field }; - - return fields.FirstOrDefault(f => f.Field.Equals(field, StringComparison.OrdinalIgnoreCase)) ?? - new EntityFieldInfo { Field = field }; - } - public static string ToDynamicLinqString(this TermNode node, ISqlQueryVisitorContext context) { if (!String.IsNullOrEmpty(node.Prefix)) context.AddValidationError("Prefix is not supported for term range queries."); + // support overriding the generated query + if (node.TryGetQuery(out string query)) + return query; + var builder = new StringBuilder(); if (String.IsNullOrEmpty(node.Field)) @@ -128,121 +133,124 @@ public static string ToDynamicLinqString(this TermNode node, ISqlQueryVisitorCon return String.Empty; } - for (int index = 0; index < context.DefaultFields.Length; index++) + var fieldTerms = new Dictionary(); + foreach (string df in context.DefaultFields) + { + var fieldInfo = GetFieldInfo(context.Fields, df); + if (!fieldTerms.TryGetValue(fieldInfo, out var searchTerm)) + { + searchTerm = new SearchTerm + { + FieldInfo = fieldInfo, + Term = node.Term, + Operator = SqlSearchOperator.StartsWith + }; + fieldTerms[fieldInfo] = searchTerm; + } + + context.SearchTokenizer.Invoke(searchTerm); + if (searchTerm.Tokens == null) + searchTerm.Tokens = [ searchTerm.Term ]; + else + searchTerm.Tokens = searchTerm.Tokens.Select(t => !String.IsNullOrWhiteSpace(t) ? t : "@__NOMATCH__").ToList(); + } + + fieldTerms.Where(f => f.Value.Tokens is { Count: > 0 }).ForEach((kvp, x) => { - builder.Append(index == 0 ? "(" : " OR "); + if (x.IsFirst && node.IsNegated.HasValue && node.IsNegated.Value) + builder.Append("!"); + + builder.Append(x.IsFirst ? "(" : " OR "); - var defaultField = GetFieldInfo(context.Fields, context.DefaultFields[index]); - if (defaultField.IsCollection) + var searchTerm = kvp.Value; + var tokens = kvp.Value.Tokens ?? [kvp.Value.Term]; + var (fieldPrefix, fieldSuffix) = kvp.Key.GetFieldPrefixAndSuffix(); + + if (searchTerm.Operator == SqlSearchOperator.Equals) { - int dotIndex = defaultField.Field.LastIndexOf('.'); - string collectionField = defaultField.Field.Substring(0, dotIndex); - string fieldName = defaultField.Field.Substring(dotIndex + 1); - - builder.Append(collectionField); - builder.Append(".Any("); - builder.Append(fieldName); - builder.Append(".Contains(\"").Append(node.Term).Append("\")"); + builder.Append(fieldPrefix); + builder.Append(kvp.Key.Name); + builder.Append(" in ("); + builder.Append(String.Join(',', tokens.Select(t => "\"" + t + "\""))); builder.Append(")"); + builder.Append(fieldSuffix); } - else + else if (searchTerm.Operator == SqlSearchOperator.Contains) + { + tokens.ForEach((token, i) => { + builder.Append(i.IsFirst ? "(" : " OR "); + builder.Append(fieldPrefix); + builder.Append(kvp.Key.Name); + builder.Append(".Contains(\""); + builder.Append(token); + builder.Append("\")"); + builder.Append(fieldSuffix); + if (i.IsLast) + builder.Append(")"); + }); + } + else if (searchTerm.Operator == SqlSearchOperator.StartsWith) { - builder.Append(defaultField.Field).Append(".Contains(\"").Append(node.Term).Append("\")"); + tokens.ForEach((token, i) => { + builder.Append(i.IsFirst ? "(" : " OR "); + builder.Append(fieldPrefix); + builder.Append(kvp.Key.Name); + builder.Append(".StartsWith(\""); + builder.Append(token); + builder.Append("\")"); + builder.Append(fieldSuffix); + if (i.IsLast) + builder.Append(")"); + }); } - if (index == context.DefaultFields.Length - 1) + if (x.IsLast) builder.Append(")"); - } + }); return builder.ToString(); } - // support overriding the generated query - if (node.TryGetQuery(out string query)) - return query; - var field = GetFieldInfo(context.Fields, node.Field); + var (fieldPrefix, fieldSuffix) = field.GetFieldPrefixAndSuffix(); + var searchOperator = SqlSearchOperator.Equals; + if (node.Term.StartsWith("*") && node.Term.EndsWith("*")) + searchOperator = SqlSearchOperator.Contains; + else if (node.Term.EndsWith("*")) + searchOperator = SqlSearchOperator.StartsWith; if (node.IsNegated.HasValue && node.IsNegated.Value) builder.Append("!"); - if (field.IsCollection) + if (searchOperator == SqlSearchOperator.Equals) { - int index = node.Field.LastIndexOf('.'); - string collectionField = node.Field.Substring(0, index); - string fieldName = node.Field.Substring(index + 1); - - builder.Append(collectionField); - builder.Append(".Any("); - builder.Append(fieldName); - - if (node.IsNegated.HasValue && node.IsNegated.Value) - builder.Append(" != "); - else - builder.Append(" = "); - - AppendField(builder, field, node.Term); - - builder.Append(")"); + builder.Append(fieldPrefix); + builder.Append(field.Name); + builder.Append(" = \""); + builder.Append(node.Term); + builder.Append("\""); + builder.Append(fieldSuffix); } - else + else if (searchOperator == SqlSearchOperator.Contains) { - builder.Append(node.Field); - if (node.IsNegated.HasValue && node.IsNegated.Value) - builder.Append(" != "); - else - builder.Append(" = "); - - AppendField(builder, field, node.Term); + builder.Append(fieldPrefix); + builder.Append(field.Name); + builder.Append(".Contains(\""); + builder.Append(node.Term); + builder.Append("\")"); + builder.Append(fieldSuffix); } - - return builder.ToString(); - } - - private static void AppendField(StringBuilder builder, EntityFieldInfo field, string term) - { - if (field == null) - return; - - if (field.IsNumber || field.IsBoolean || field.IsMoney) + else { - builder.Append(term); + builder.Append(fieldPrefix); + builder.Append(field.Name); + builder.Append(".StartsWith(\""); + builder.Append(node.Term); + builder.Append("\")"); + builder.Append(fieldSuffix); } - else if (field is { IsDate: true }) - { - term = term.Trim(); - if (term.StartsWith("now", StringComparison.OrdinalIgnoreCase)) - { - builder.Append("DateTime.UtcNow"); - - if (term.Length == 3) - return; - - builder.Append("."); - string method = term[^1..] switch - { - "y" => "AddYears", - "M" => "AddMonths", - "d" => "AddDays", - "h" => "AddHours", - "H" => "AddHours", - "m" => "AddMinutes", - "s" => "AddSeconds", - _ => 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("DateTime.Parse(\"" + term + "\")"); - } - } - else - builder.Append("\"" + term + "\""); + return builder.ToString(); } public static string ToDynamicLinqString(this TermRangeNode node, ISqlQueryVisitorContext context) @@ -262,19 +270,23 @@ public static string ToDynamicLinqString(this TermRangeNode node, ISqlQueryVisit if (!field.IsNumber && !field.IsDate && !field.IsMoney) context.AddValidationError("Field must be a number, money or date for term range queries."); + var (fieldPrefix, fieldSuffix) = field.GetFieldPrefixAndSuffix(); + var builder = new StringBuilder(); if (node.IsNegated.HasValue && node.IsNegated.Value) - builder.Append("NOT "); + builder.Append("!"); if (node.Min != null && node.Max != null) builder.Append("("); if (node.Min != null) { - builder.Append(node.Field); + builder.Append(fieldPrefix); + builder.Append(field.Name); builder.Append(node.MinInclusive == true ? " >= " : " > "); AppendField(builder, field, node.Min); + builder.Append(fieldSuffix); } if (node.Min != null && node.Max != null) @@ -282,9 +294,11 @@ public static string ToDynamicLinqString(this TermRangeNode node, ISqlQueryVisit if (node.Max != null) { - builder.Append(node.Field); + builder.Append(fieldPrefix); + builder.Append(field.Name); builder.Append(node.MaxInclusive == true ? " <= " : " < "); AppendField(builder, field, node.Max); + builder.Append(fieldSuffix); } if (node.Min != null && node.Max != null) @@ -306,6 +320,61 @@ public static string ToDynamicLinqString(this IQueryNode node, ISqlQueryVisitorC }; } + public static EntityFieldInfo GetFieldInfo(List fields, string field) + { + if (fields == null) + return new EntityFieldInfo { Name = field, FullName = field}; + + return fields.FirstOrDefault(f => f.FullName.Equals(field, StringComparison.OrdinalIgnoreCase)) ?? + new EntityFieldInfo { Name = field, FullName = field}; + } + + private static void AppendField(StringBuilder builder, EntityFieldInfo field, string term) + { + if (field == null) + return; + + if (field.IsNumber || field.IsBoolean || field.IsMoney) + { + builder.Append(term); + } + else if (field is { IsDate: true }) + { + term = term.Trim(); + if (term.StartsWith("now", StringComparison.OrdinalIgnoreCase)) + { + builder.Append("DateTime.UtcNow"); + + if (term.Length == 3) + return; + + builder.Append("."); + + string method = term[^1..] switch + { + "y" => "AddYears", + "M" => "AddMonths", + "d" => "AddDays", + "h" => "AddHours", + "H" => "AddHours", + "m" => "AddMinutes", + "s" => "AddSeconds", + _ => 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("DateTime.Parse(\"" + term + "\")"); + } + } + else + builder.Append("\"" + term + "\""); + } + private const string QueryKey = "Query"; public static void SetQuery(this IQueryNode node, string query) { diff --git a/src/Foundatio.Parsers.SqlQueries/Foundatio.Parsers.SqlQueries.csproj b/src/Foundatio.Parsers.SqlQueries/Foundatio.Parsers.SqlQueries.csproj index feab314e..7e2fadbc 100644 --- a/src/Foundatio.Parsers.SqlQueries/Foundatio.Parsers.SqlQueries.csproj +++ b/src/Foundatio.Parsers.SqlQueries/Foundatio.Parsers.SqlQueries.csproj @@ -6,8 +6,8 @@ - - + + diff --git a/src/Foundatio.Parsers.SqlQueries/SqlQueryParser.cs b/src/Foundatio.Parsers.SqlQueries/SqlQueryParser.cs index 5a029f7b..93a76ef2 100644 --- a/src/Foundatio.Parsers.SqlQueries/SqlQueryParser.cs +++ b/src/Foundatio.Parsers.SqlQueries/SqlQueryParser.cs @@ -82,7 +82,7 @@ public SqlQueryVisitorContext GetContext(IEntityType entityType) if (!_entityFieldCache.TryGetValue(entityType, out var fields)) { fields = new List(); - AddEntityFields(fields, entityType); + AddEntityFields(fields, null, entityType); _entityFieldCache.TryAdd(entityType, fields); } @@ -90,7 +90,7 @@ public SqlQueryVisitorContext GetContext(IEntityType entityType) fields = fields.ToList(); var validationOptions = new QueryValidationOptions(); - foreach (string field in fields.Select(f => f.Field)) + foreach (string field in fields.Where(f => !f.IsNavigation).Select(f => f.FullName)) validationOptions.AllowedFields.Add(field); Configuration.SetValidationOptions(validationOptions); @@ -101,7 +101,7 @@ public SqlQueryVisitorContext GetContext(IEntityType entityType) }; } - private void AddEntityFields(List fields, IEntityType entityType, Stack entityTypeStack = null, string prefix = null, bool isCollection = false, int depth = 0) + private void AddEntityFields(List fields, EntityFieldInfo parent, IEntityType entityType, Stack entityTypeStack = null, string prefix = null, int depth = 0) { entityTypeStack ??= new Stack(); @@ -123,11 +123,12 @@ private void AddEntityFields(List fields, IEntityType entityTyp string propertyPath = prefix + property.Name; fields.Add(new EntityFieldInfo { - Field = propertyPath, + Name = property.Name, + FullName = propertyPath, IsNumber = property.ClrType.UnwrapNullable().IsNumeric(), IsDate = property.ClrType.UnwrapNullable().IsDateTime(), IsBoolean = property.ClrType.UnwrapNullable().IsBoolean(), - IsCollection = isCollection + Parent = parent }); } @@ -139,7 +140,17 @@ private void AddEntityFields(List fields, IEntityType entityTyp string propertyPath = prefix + nav.Name; bool isNavCollection = nav is IReadOnlyNavigationBase { IsCollection: true }; - AddEntityFields(fields, nav.TargetEntityType, entityTypeStack, propertyPath + ".", isNavCollection, depth + 1); + var navFieldInfo = new EntityFieldInfo + { + IsCollection = isNavCollection, + IsNavigation = true, + Name = nav.Name, + FullName = propertyPath, + Parent = parent + }; + fields.Add(navFieldInfo); + + AddEntityFields(fields, navFieldInfo, nav.TargetEntityType, entityTypeStack, propertyPath + ".", depth + 1); } foreach (var skipNav in entityType.GetSkipNavigations()) @@ -149,7 +160,17 @@ private void AddEntityFields(List fields, IEntityType entityTyp string propertyPath = prefix + skipNav.Name; - AddEntityFields(fields, skipNav.TargetEntityType, entityTypeStack, propertyPath + ".", skipNav.IsCollection, depth + 1); + var navFieldInfo = new EntityFieldInfo + { + IsCollection = skipNav.IsCollection, + IsNavigation = true, + Name = skipNav.Name, + FullName = propertyPath, + Parent = parent + }; + fields.Add(navFieldInfo); + + AddEntityFields(fields, navFieldInfo, skipNav.TargetEntityType, entityTypeStack, propertyPath + ".", depth + 1); } entityTypeStack.Pop(); @@ -189,5 +210,10 @@ private void SetupQueryVisitorContextDefaults(IQueryVisitorContext context) if (Configuration.IncludeResolver != null && context.GetIncludeResolver() == null) context.SetIncludeResolver(Configuration.IncludeResolver); } + + if (context is ISqlQueryVisitorContext sqlContext) + { + sqlContext.SearchTokenizer = Configuration.SearchTokenizer; + } } } diff --git a/src/Foundatio.Parsers.SqlQueries/SqlQueryParserConfiguration.cs b/src/Foundatio.Parsers.SqlQueries/SqlQueryParserConfiguration.cs index 2474aebf..6a295d65 100644 --- a/src/Foundatio.Parsers.SqlQueries/SqlQueryParserConfiguration.cs +++ b/src/Foundatio.Parsers.SqlQueries/SqlQueryParserConfiguration.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Foundatio.Parsers.LuceneQueries; using Foundatio.Parsers.LuceneQueries.Visitors; +using Foundatio.Parsers.SqlQueries.Visitors; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -25,6 +26,7 @@ public SqlQueryParserConfiguration() public int MaxFieldDepth { get; private set; } = 10; public QueryFieldResolver FieldResolver { get; private set; } + public Action SearchTokenizer { get; set; } = static _ => { }; public EntityTypePropertyFilter EntityTypePropertyFilter { get; private set; } = static _ => true; public EntityTypeNavigationFilter EntityTypeNavigationFilter { get; private set; } = static _ => true; public EntityTypeSkipNavigationFilter EntityTypeSkipNavigationFilter { get; private set; } = static _ => true; @@ -48,6 +50,12 @@ public SqlQueryParserConfiguration SetDefaultFields(string[] fields) return this; } + public SqlQueryParserConfiguration SetSearchTokenizer(Action tokenizer) + { + SearchTokenizer = tokenizer; + return this; + } + public SqlQueryParserConfiguration SetFieldDepth(int maxFieldDepth) { MaxFieldDepth = maxFieldDepth; diff --git a/src/Foundatio.Parsers.SqlQueries/Visitors/ISqlQueryVisitorContext.cs b/src/Foundatio.Parsers.SqlQueries/Visitors/ISqlQueryVisitorContext.cs index 2e68df18..c0d549a8 100644 --- a/src/Foundatio.Parsers.SqlQueries/Visitors/ISqlQueryVisitorContext.cs +++ b/src/Foundatio.Parsers.SqlQueries/Visitors/ISqlQueryVisitorContext.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Foundatio.Parsers.LuceneQueries.Visitors; namespace Foundatio.Parsers.SqlQueries.Visitors; @@ -6,4 +7,5 @@ namespace Foundatio.Parsers.SqlQueries.Visitors; public interface ISqlQueryVisitorContext : IQueryVisitorContext { List Fields { get; set; } + Action SearchTokenizer { get; set; } } diff --git a/src/Foundatio.Parsers.SqlQueries/Visitors/SqlQueryVisitorContext.cs b/src/Foundatio.Parsers.SqlQueries/Visitors/SqlQueryVisitorContext.cs index f1ec3e9d..d8c8f888 100644 --- a/src/Foundatio.Parsers.SqlQueries/Visitors/SqlQueryVisitorContext.cs +++ b/src/Foundatio.Parsers.SqlQueries/Visitors/SqlQueryVisitorContext.cs @@ -1,24 +1,95 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Diagnostics; +using System.Text; using Foundatio.Parsers.LuceneQueries.Visitors; +using Foundatio.Parsers.SqlQueries.Extensions; using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.Primitives; namespace Foundatio.Parsers.SqlQueries.Visitors; public class SqlQueryVisitorContext : QueryVisitorContext, ISqlQueryVisitorContext { public List Fields { get; set; } + public Action SearchTokenizer { get; set; } = static _ => { }; public IEntityType EntityType { get; set; } } -[DebuggerDisplay("{Field} IsNumber: {IsNumber} IsMoney: {IsMoney} IsDate: {IsDate} IsBoolean: {IsBoolean} IsCollection: {IsCollection}")] +[DebuggerDisplay("{FullName} IsNumber: {IsNumber} IsMoney: {IsMoney} IsDate: {IsDate} IsBoolean: {IsBoolean} IsCollection: {IsCollection}")] public class EntityFieldInfo { - public string Field { get; set; } + public required string Name { get; init; } + public required string FullName { get; init; } public bool IsNumber { get; set; } public bool IsMoney { get; set; } public bool IsDate { get; set; } public bool IsBoolean { get; set; } public bool IsCollection { get; set; } + public bool IsNavigation { get; set; } + public EntityFieldInfo Parent { get; set; } public IDictionary Data { get; set; } = new Dictionary(); + + protected bool Equals(EntityFieldInfo other) => Name == other.Name; + + public override bool Equals(object obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != GetType()) + { + return false; + } + + return Equals((EntityFieldInfo)obj); + } + + public override int GetHashCode() => (Name != null ? Name.GetHashCode() : 0); + + public (string fieldPrefix, string fieldSuffix) GetFieldPrefixAndSuffix() + { + var fieldTree = new List(); + EntityFieldInfo current = Parent; + while (current != null) + { + fieldTree.Add(current); + current = current.Parent; + } + + fieldTree.Reverse(); + + var prefix = new StringBuilder(); + var suffix = new StringBuilder(); + foreach (var field in fieldTree) { + if (field.IsCollection) + { + prefix.Append($"{field.Name}.Any("); + suffix.Append(")"); + } + else + { + prefix.Append(field.Name).Append("."); + } + }; + + return (prefix.ToString(), suffix.ToString()); + } } + +public class SearchTerm +{ + public EntityFieldInfo FieldInfo { get; set; } + public string Term { get; set; } + public List Tokens { get; set; } + public SqlSearchOperator Operator { get; set; } = SqlSearchOperator.Contains; +} + +public enum SqlSearchOperator { Equals, Contains, StartsWith } diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index e7c27f6f..c4f4287c 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -6,12 +6,12 @@ $(NoWarn);CS1591;NU1701 - - + + - + diff --git a/tests/Foundatio.Parsers.SqlQueries.Tests/Foundatio.Parsers.SqlQueries.Tests.csproj b/tests/Foundatio.Parsers.SqlQueries.Tests/Foundatio.Parsers.SqlQueries.Tests.csproj index 56be4a1e..0e72fce6 100644 --- a/tests/Foundatio.Parsers.SqlQueries.Tests/Foundatio.Parsers.SqlQueries.Tests.csproj +++ b/tests/Foundatio.Parsers.SqlQueries.Tests/Foundatio.Parsers.SqlQueries.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/tests/Foundatio.Parsers.SqlQueries.Tests/SampleContext.cs b/tests/Foundatio.Parsers.SqlQueries.Tests/SampleContext.cs index 38b372cc..2d106b03 100644 --- a/tests/Foundatio.Parsers.SqlQueries.Tests/SampleContext.cs +++ b/tests/Foundatio.Parsers.SqlQueries.Tests/SampleContext.cs @@ -41,10 +41,13 @@ public class Employee { public int Id { get; set; } public string FullName { get; set; } + public string PhoneNumber { get; set; } + public string NationalPhoneNumber { get; set; } public string Title { get; set; } public int Salary { get; set; } public List Companies { get; set; } public List DataValues { get; set; } + public DateTime Created { get; set; } = DateTime.Now; } diff --git a/tests/Foundatio.Parsers.SqlQueries.Tests/SqlQueryParserTests.cs b/tests/Foundatio.Parsers.SqlQueries.Tests/SqlQueryParserTests.cs index 03968063..9e5e4415 100644 --- a/tests/Foundatio.Parsers.SqlQueries.Tests/SqlQueryParserTests.cs +++ b/tests/Foundatio.Parsers.SqlQueries.Tests/SqlQueryParserTests.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Pegasus.Common.Tracing; +using PhoneNumbers; using Xunit; using Xunit.Abstractions; @@ -69,14 +70,83 @@ public async Task CanSearchDefaultFields() var context = parser.GetContext(db.Employees.EntityType); - string sqlExpected = db.Employees.Where(e => e.FullName.Contains("John") || e.Title.Contains("John")).ToQueryString(); - string sqlActual = db.Employees.Where("""FullName.Contains("John") || Title.Contains("John")""").ToQueryString(); + string sqlExpected = db.Employees.Where(e => e.FullName.StartsWith("John") || e.Title.StartsWith("John")).ToQueryString(); + string sqlActual = db.Employees.Where("""FullName.StartsWith("John") || Title.StartsWith("John") """).ToQueryString(); Assert.Equal(sqlExpected, sqlActual); string sql = await parser.ToDynamicLinqAsync("John", context); sqlActual = db.Employees.Where(sql).ToQueryString(); + var results = await db.Employees.Where(sql).ToListAsync(); + Assert.Single(results); Assert.Equal(sqlExpected, sqlActual); } + [Fact] + public async Task CanSearchWithTokenizer() + { + var sp = GetServiceProvider(); + await using var db = await GetSampleContextWithDataAsync(sp); + var parser = sp.GetRequiredService(); + parser.Configuration.SetDefaultFields(["NationalPhoneNumber"]); + parser.Configuration.SetSearchTokenizer(s => + { + if (String.IsNullOrWhiteSpace(s.Term)) + return; + + if (s.FieldInfo.FullName != "NationalPhoneNumber") + return; + + s.Tokens = [TryGetNationalNumber(s.Term)]; + s.Operator = SqlSearchOperator.StartsWith; + }); + + var context = parser.GetContext(db.Employees.EntityType); + + string sqlExpected = db.Employees.Where(e => e.NationalPhoneNumber.StartsWith("2142222222")).ToQueryString(); + string sqlActual = db.Employees.Where("NationalPhoneNumber.StartsWith(\"2142222222\")").ToQueryString(); + Assert.Equal(sqlExpected, sqlActual); + + string sql = await parser.ToDynamicLinqAsync("214-222-2222", context); + _logger.LogInformation(sql); + sqlActual = db.Employees.Where(sql).ToQueryString(); + var results = await db.Employees.Where(sql).ToListAsync(); + Assert.Single(results); + Assert.Equal(sqlExpected, sqlActual); + + sql = await parser.ToDynamicLinqAsync("2142222222", context); + _logger.LogInformation(sql); + sqlActual = db.Employees.Where(sql).ToQueryString(); + results = await db.Employees.Where(sql).ToListAsync(); + Assert.Single(results); + Assert.Equal(sqlExpected, sqlActual); + + sql = await parser.ToDynamicLinqAsync("21422", context); + _logger.LogInformation(sql); + sqlActual = db.Employees.Where(sql).ToQueryString(); + results = await db.Employees.Where(sql).ToListAsync(); + Assert.Single(results); + } + + [Fact] + public async Task CanHandleEmptyTokens() + { + var sp = GetServiceProvider(); + await using var db = await GetSampleContextWithDataAsync(sp); + var parser = sp.GetRequiredService(); + parser.Configuration.SetDefaultFields(["NationalPhoneNumber"]); + parser.Configuration.SetSearchTokenizer(s => + { + s.Tokens = ["", " "]; + }); + + var context = parser.GetContext(db.Employees.EntityType); + + string sql = await parser.ToDynamicLinqAsync("test", context); + _logger.LogInformation(sql); + string sqlActual = db.Employees.Where(sql).ToQueryString(); + var results = await db.Employees.Where(sql).ToListAsync(); + Assert.Empty(results); + } + [Fact] public async Task CanUseDateFilter() { @@ -155,14 +225,33 @@ public async Task CanUseCollectionDefaultFields() var context = parser.GetContext(db.Employees.EntityType); - string sqlExpected = db.Employees.Where(e => e.Companies.Any(c => c.Name.Contains("acme"))).ToQueryString(); - string sqlActual = db.Employees.Where("""Companies.Any(Name.Contains("acme"))""").ToQueryString(); + string sqlExpected = db.Employees.Where(e => e.Companies.Any(c => c.Name.StartsWith("acme"))).ToQueryString(); + string sqlActual = db.Employees.Where("""Companies.Any(Name.StartsWith("acme"))""").ToQueryString(); Assert.Equal(sqlExpected, sqlActual); string sql = await parser.ToDynamicLinqAsync("acme", context); sqlActual = db.Employees.Where(sql).ToQueryString(); Assert.Equal(sqlExpected, sqlActual); } + [Fact] + public async Task CanUseCollectionDefaultFieldsWithNestedDepth() + { + var sp = GetServiceProvider(); + await using var db = await GetSampleContextWithDataAsync(sp); + var parser = sp.GetRequiredService(); + parser.Configuration.SetDefaultFields(["Companies.DataDefinitions.Key"]); + + var context = parser.GetContext(db.Employees.EntityType); + + string sqlExpected = db.Employees.Where(e => e.Companies.Any(c => c.DataDefinitions.Any(e => e.Key.StartsWith("age")))).ToQueryString(); + string sqlActual = db.Employees.Where("""Companies.Any(DataDefinitions.Any(Key.StartsWith("age")))""").ToQueryString(); + Assert.Equal(sqlExpected, sqlActual); + string sql = await parser.ToDynamicLinqAsync("age", context); + _logger.LogInformation(sql); + sqlActual = db.Employees.Where(sql).ToQueryString(); + Assert.Equal(sqlExpected, sqlActual); + } + [Fact] public async Task CanUseNavigationFields() { @@ -219,7 +308,7 @@ public async Task CanGenerateSql() var parser = sp.GetRequiredService(); var context = parser.GetContext(db.Employees.EntityType); - context.Fields.Add(new EntityFieldInfo { Field = "age", IsNumber = true, Data = { { "DataDefinitionId", 1 } } }); + context.Fields.Add(new EntityFieldInfo { Name = "age", FullName = "age", IsNumber = true, Data = { { "DataDefinitionId", 1 } } }); context.ValidationOptions.AllowedFields.Add("age"); string sqlExpected = db.Employees.Where(e => e.Companies.Any(c => c.Name == "acme") && e.DataValues.Any(dv => dv.DataDefinitionId == 1 && dv.NumberValue == 30)).ToQueryString(); @@ -244,6 +333,19 @@ public async Task CanGenerateSql() Assert.Equal("John Doe", employee.FullName); } + public static string? TryGetNationalNumber(string phoneNumber, string regionCode = "US") + { + var phoneNumberUtil = PhoneNumberUtil.GetInstance(); + try + { + return phoneNumberUtil.Parse(phoneNumber, regionCode).NationalNumber.ToString(); + } + catch (NumberParseException) + { + return null; + } + } + public IServiceProvider GetServiceProvider() { var services = new ServiceCollection(); @@ -263,6 +365,8 @@ public async Task GetSampleContextWithDataAsync(IServiceProvider var db = sp.GetRequiredService(); var parser = sp.GetRequiredService(); + var phoneNumberUtil = PhoneNumberUtil.GetInstance(); + var dbParser = db.GetService(); Assert.Same(parser, dbParser); var dbSetParser = db.Employees.GetService(); @@ -281,6 +385,8 @@ public async Task GetSampleContextWithDataAsync(IServiceProvider { FullName = "John Doe", Title = "Software Developer", + PhoneNumber = "(214) 222-2222", + NationalPhoneNumber = phoneNumberUtil.Parse("(214) 222-2222", "US").NationalNumber.ToString(), Salary = 80_000, DataValues = [new() { Definition = company.DataDefinitions[0], NumberValue = 30 }], Companies = [company] @@ -289,6 +395,8 @@ public async Task GetSampleContextWithDataAsync(IServiceProvider { FullName = "Jane Doe", Title = "Software Developer", + PhoneNumber = "+52 55 1234 5678", // Mexico + NationalPhoneNumber = phoneNumberUtil.Parse("+52 55 1234 5678", "US").NationalNumber.ToString(), Salary = 90_000, DataValues = [new() { Definition = company.DataDefinitions[0], NumberValue = 23 }], Companies = [company] @@ -327,7 +435,7 @@ private async Task ParseAndValidateQuery(string query, string expected, bool isV { Fields = [ - new EntityFieldInfo { Field = "field", IsNumber = true } + new EntityFieldInfo { Name = "field", FullName = "field", IsNumber = true } ] }; string generatedQuery = await GenerateSqlVisitor.RunAsync(result, context);