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);