Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SQL search improvements #89

Merged
merged 6 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@ services:
- "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:
[
Expand All @@ -49,8 +52,6 @@ services:
]
interval: 1s
retries: 20
networks:
- foundatio

ready:
image: andrewlock/wait-for-dependencies
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Collections.Generic;

namespace Foundatio.Parsers.SqlQueries.Extensions;

internal static class EnumerableExtensions {
public delegate void ElementAction<in T>(T element, ElementInfo info);

public static void ForEach<T>(this IEnumerable<T> elements, ElementAction<T> action)
{
using IEnumerator<T> 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 {
ejsmith marked this conversation as resolved.
Show resolved Hide resolved
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; }
}
}

224 changes: 149 additions & 75 deletions src/Foundatio.Parsers.SqlQueries/Extensions/SqlNodeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,20 +104,15 @@ public static string ToDynamicLinqString(this MissingNode node, ISqlQueryVisitor
return builder.ToString();
}

public static EntityFieldInfo GetFieldInfo(List<EntityFieldInfo> 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))
Expand All @@ -128,39 +123,109 @@ public static string ToDynamicLinqString(this TermNode node, ISqlQueryVisitorCon
return String.Empty;
}

for (int index = 0; index < context.DefaultFields.Length; index++)
var fieldTerms = new Dictionary<EntityFieldInfo, SearchTerm>();
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);
}

fieldTerms.ForEach((kvp, x) =>
{
builder.Append(index == 0 ? "(" : " OR ");
builder.Append(x.IsFirst ? "(" : " OR ");
var searchTerm = kvp.Value;
var tokens = kvp.Value.Tokens ?? [kvp.Value.Term];

var defaultField = GetFieldInfo(context.Fields, context.DefaultFields[index]);
if (defaultField.IsCollection)
if (searchTerm.FieldInfo.IsCollection)
{
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(")");
int dotIndex = searchTerm.FieldInfo.Field.LastIndexOf('.');
string collectionField = searchTerm.FieldInfo.Field.Substring(0, dotIndex);
string fieldName = searchTerm.FieldInfo.Field.Substring(dotIndex + 1);

if (searchTerm.Operator == SqlSearchOperator.Equals)
{
builder.Append(collectionField);
builder.Append(".Any(");
builder.Append(fieldName);
builder.Append(" in (");
builder.Append(String.Join(',', tokens.Select(t => "\"" + t + "\"")));
builder.Append("))");
}
else if (searchTerm.Operator == SqlSearchOperator.Contains)
{
tokens.ForEach((token, i) => {
builder.Append(i.IsFirst ? "(" : " OR ");
builder.Append(collectionField);
builder.Append(".Any(");
builder.Append(fieldName);
builder.Append(".Contains(\"");
builder.Append(token);
builder.Append("\"))");
if (i.IsLast)
builder.Append(")");
});
}
else if (searchTerm.Operator == SqlSearchOperator.StartsWith)
{
tokens.ForEach((token, i) => {
builder.Append(i.IsFirst ? "(" : " OR ");
builder.Append(collectionField);
builder.Append(".Any(");
builder.Append(fieldName);
builder.Append(".StartsWith(\"");
builder.Append(token);
builder.Append("\"))");
if (i.IsLast)
builder.Append(")");
});
}
}
else
{
builder.Append(defaultField.Field).Append(".Contains(\"").Append(node.Term).Append("\")");
if (searchTerm.Operator == SqlSearchOperator.Equals)
{
builder.Append(searchTerm.FieldInfo.Field).Append(" in (");
builder.Append(String.Join(',', tokens.Select(t => "\"" + t + "\"")));
builder.Append(")");
}
else if (searchTerm.Operator == SqlSearchOperator.Contains)
{
tokens.ForEach((token, i) => {
builder.Append(i.IsFirst ? "(" : " OR ");
builder.Append(searchTerm.FieldInfo.Field).Append(".Contains(\"").Append(token).Append("\")");
if (i.IsLast)
builder.Append(")");
});
}
else if (searchTerm.Operator == SqlSearchOperator.StartsWith)
{
tokens.ForEach((token, i) => {
builder.Append(i.IsFirst ? "(" : " OR ");
builder.Append(searchTerm.FieldInfo.Field).Append(".StartsWith(\"").Append(token).Append("\")");
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);

if (node.IsNegated.HasValue && node.IsNegated.Value)
Expand Down Expand Up @@ -199,52 +264,6 @@ public static string ToDynamicLinqString(this TermNode node, ISqlQueryVisitorCon
return builder.ToString();
}

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 + "\"");
}

public static string ToDynamicLinqString(this TermRangeNode node, ISqlQueryVisitorContext context)
{
if (String.IsNullOrEmpty(node.Field))
Expand Down Expand Up @@ -306,6 +325,61 @@ public static string ToDynamicLinqString(this IQueryNode node, ISqlQueryVisitorC
};
}

public static EntityFieldInfo GetFieldInfo(List<EntityFieldInfo> 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 };
}

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)
{
Expand Down
5 changes: 5 additions & 0 deletions src/Foundatio.Parsers.SqlQueries/SqlQueryParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,5 +189,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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +26,7 @@ public SqlQueryParserConfiguration()

public int MaxFieldDepth { get; private set; } = 10;
public QueryFieldResolver FieldResolver { get; private set; }
public Action<SearchTerm> 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;
Expand All @@ -48,6 +50,12 @@ public SqlQueryParserConfiguration SetDefaultFields(string[] fields)
return this;
}

public SqlQueryParserConfiguration SetSearchTokenizer(Action<SearchTerm> tokenizer)
{
SearchTokenizer = tokenizer;
return this;
}

public SqlQueryParserConfiguration SetFieldDepth(int maxFieldDepth)
{
MaxFieldDepth = maxFieldDepth;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using Foundatio.Parsers.LuceneQueries.Visitors;

namespace Foundatio.Parsers.SqlQueries.Visitors;

public interface ISqlQueryVisitorContext : IQueryVisitorContext
{
List<EntityFieldInfo> Fields { get; set; }
Action<SearchTerm> SearchTokenizer { get; set; }
}
Loading