Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
23 changes: 23 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/mssql/server:2022-latest

ARG SSID_PID=Developer

ENV ACCEPT_EULA=Y
ENV SSID_PID=${SSID_PID}
ENV DEBIAN_FRONTEND=noninteractive
ENV DEBCONF_NONINTERACTIVE_SEEN=true

USER root

RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -yq gnupg gnupg2 gnupg1 curl apt-transport-https && \
curl https://packages.microsoft.com/keys/microsoft.asc -o /var/opt/mssql/ms-key.cer && \
gpg --dearmor -o /etc/apt/trusted.gpg.d/microsoft.gpg /var/opt/mssql/ms-key.cer && \
curl https://packages.microsoft.com/config/ubuntu/22.04/mssql-server-2022.list -o /etc/apt/sources.list.d/mssql-server-2022.list && \
apt-get update && \
apt-get install -y mssql-server-fts && \
apt-get clean && \
rm -rf /var/lib/apt/lists

ENTRYPOINT [ "/opt/mssql/bin/sqlservr" ]
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ services:
test: curl --write-out 'HTTP %{http_code}' --fail --silent --output /dev/null http://localhost:5601/api/status

sqlserver:
image: mcr.microsoft.com/mssql/server:2025-latest
build:
context: .
dockerfile: Dockerfile
ports:
- "1433:1433" # login with sa:P@ssword1
environment:
Expand Down
44 changes: 35 additions & 9 deletions src/Foundatio.Parsers.SqlQueries/Extensions/SqlNodeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ public static string ToDynamicLinqString(this TermNode node, ISqlQueryVisitorCon
{
FieldInfo = fieldInfo,
Term = node.Term,
Operator = SqlSearchOperator.StartsWith
Operator = context.DefaultSearchOperator
};
fieldTerms[fieldInfo] = searchTerm;
}
Expand Down Expand Up @@ -186,10 +186,23 @@ public static string ToDynamicLinqString(this TermNode node, ISqlQueryVisitorCon
{
builder.Append(i.IsFirst ? "(" : " OR ");
builder.Append(fieldPrefix);
builder.Append(kvp.Key.Name);
builder.Append(".Contains(");
AppendField(builder, kvp.Key, token, context);
builder.Append(")");

if (context.FullTextSearchEnabled)
{
builder.Append("FTS.Contains(");
builder.Append(kvp.Key.Name);
builder.Append(", ");
AppendField(builder, kvp.Key, token, context);
builder.Append(")");
}
else
{
builder.Append(kvp.Key.Name);
builder.Append(".Contains(");
AppendField(builder, kvp.Key, token, context);
builder.Append(")");
}

builder.Append(fieldSuffix);
if (i.IsLast)
builder.Append(")");
Expand All @@ -201,10 +214,23 @@ public static string ToDynamicLinqString(this TermNode node, ISqlQueryVisitorCon
{
builder.Append(i.IsFirst ? "(" : " OR ");
builder.Append(fieldPrefix);
builder.Append(kvp.Key.Name);
builder.Append(".StartsWith(");
AppendField(builder, kvp.Key, token, context);
builder.Append(")");

if (context.FullTextSearchEnabled)
{
builder.Append("FTS.Contains(");
builder.Append(kvp.Key.Name);
builder.Append(", ");
AppendField(builder, kvp.Key, token + "*", context);
builder.Append(")");
}
else
{
builder.Append(kvp.Key.Name);
builder.Append(".StartsWith(");
AppendField(builder, kvp.Key, token, context);
builder.Append(")");
}

builder.Append(fieldSuffix);
if (i.IsLast)
builder.Append(")");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\build\common.props" />
<PropertyGroup>
<TargetFrameworks>net8.0;</TargetFrameworks>
<TargetFrameworks>net10.0;</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.20" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
<PackageReference Include="Exceptionless.DateTimeExtensions" Version="4.0.1" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.9" />
</ItemGroup>
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net6.0'))">
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Foundatio.Parsers.LuceneQueries\Foundatio.Parsers.LuceneQueries.csproj" />
Expand Down
19 changes: 19 additions & 0 deletions src/Foundatio.Parsers.SqlQueries/SqlQueryParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Linq.Dynamic.Core;
using System.Linq.Dynamic.Core.CustomTypeProviders;
using System.Threading.Tasks;
using Foundatio.Parsers.LuceneQueries;
using Foundatio.Parsers.LuceneQueries.Extensions;
using Foundatio.Parsers.LuceneQueries.Nodes;
using Foundatio.Parsers.LuceneQueries.Visitors;
using Foundatio.Parsers.SqlQueries.Extensions;
using Foundatio.Parsers.SqlQueries.Visitors;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Pegasus.Common;

Expand All @@ -25,6 +28,10 @@ public SqlQueryParser(Action<SqlQueryParserConfiguration> configure = null)
}

public SqlQueryParserConfiguration Configuration { get; }
public ParsingConfig ParsingConfig { get; } = new()
{
CustomTypeProvider = new DynamicLinqTypeProvider()
};

public override async Task<IQueryNode> ParseAsync(string query, IQueryVisitorContext context = null)
{
Expand Down Expand Up @@ -217,6 +224,18 @@ private void SetupQueryVisitorContextDefaults(IQueryVisitorContext context)
sqlContext.SearchTokenizer = Configuration.SearchTokenizer;
sqlContext.DateTimeParser = Configuration.DateTimeParser;
sqlContext.DateOnlyParser = Configuration.DateOnlyParser;
sqlContext.DefaultSearchOperator = Configuration.DefaultFieldsSearchOperator;
sqlContext.FullTextSearchEnabled = Configuration.FullTextSearchEnabled;
}
}
}

public static class FTS
{
public static bool Contains(string propertyValue, string searchTerm)
{
return EF.Functions.Contains(propertyValue, searchTerm);
}
}

public class DynamicLinqTypeProvider() : DefaultDynamicLinqCustomTypeProvider(ParsingConfig.Default, [ typeof(EF), typeof(FTS) ]);
11 changes: 10 additions & 1 deletion src/Foundatio.Parsers.SqlQueries/SqlQueryParserConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public SqlQueryParserConfiguration()

public ILoggerFactory LoggerFactory { get; private set; } = NullLoggerFactory.Instance;
public string[] DefaultFields { get; private set; }
public SqlSearchOperator DefaultFieldsSearchOperator { get; private set; } = SqlSearchOperator.StartsWith;
public bool FullTextSearchEnabled { get; private set; } = false;

public int MaxFieldDepth { get; private set; } = 10;
public QueryFieldResolver FieldResolver { get; private set; }
Expand All @@ -46,9 +48,10 @@ public SqlQueryParserConfiguration SetLoggerFactory(ILoggerFactory loggerFactory
return this;
}

public SqlQueryParserConfiguration SetDefaultFields(string[] fields)
public SqlQueryParserConfiguration SetDefaultFields(string[] fields, SqlSearchOperator op = SqlSearchOperator.StartsWith)
{
DefaultFields = fields;
DefaultFieldsSearchOperator = op;
return this;
}

Expand All @@ -58,6 +61,12 @@ public SqlQueryParserConfiguration SetSearchTokenizer(Action<SearchTerm> tokeniz
return this;
}

public SqlQueryParserConfiguration UseFullTextSearch(bool useFullTextSearch = true)
{
FullTextSearchEnabled = useFullTextSearch;
return this;
}

public SqlQueryParserConfiguration SetDateTimeParser(Func<string, string> dateTimeParser)
{
DateTimeParser = dateTimeParser;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ namespace Foundatio.Parsers.SqlQueries.Visitors;
public interface ISqlQueryVisitorContext : IQueryVisitorContext
{
List<EntityFieldInfo> Fields { get; set; }
SqlSearchOperator DefaultSearchOperator { get; set; }
bool FullTextSearchEnabled { get; set; }
Action<SearchTerm> SearchTokenizer { get; set; }
Func<string, string> DateTimeParser { get; set; }
Func<string, string> DateOnlyParser { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ namespace Foundatio.Parsers.SqlQueries.Visitors;
public class SqlQueryVisitorContext : QueryVisitorContext, ISqlQueryVisitorContext
{
public List<EntityFieldInfo> Fields { get; set; }
public SqlSearchOperator DefaultSearchOperator { get; set; } = SqlSearchOperator.StartsWith;
public bool FullTextSearchEnabled { get; set; } = false;
public Action<SearchTerm> SearchTokenizer { get; set; } = static _ => { };
public Func<string, string> DateTimeParser { get; set; }
public Func<string, string> DateOnlyParser { get; set; }
Expand Down
2 changes: 1 addition & 1 deletion tests/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<NoWarn>$(NoWarn);CS1591;NU1701</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="All" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0;</TargetFrameworks>
<!--<DefineConstants>ENABLE_TRACING</DefineConstants>-->
<NoWarn>$(NoWarn);CS8002;</NoWarn>
</PropertyGroup>
Expand All @@ -9,7 +10,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
<PackageReference Include="libphonenumber-csharp" Version="9.0.18" />
</ItemGroup>

Expand Down
11 changes: 11 additions & 0 deletions tests/Foundatio.Parsers.SqlQueries.Tests/SampleContext.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;

namespace Foundatio.Parsers.SqlQueries.Tests;

Expand Down Expand Up @@ -34,6 +36,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder.Entity<DataValue>().Property(e => e.BooleanValue).IsSparse();
modelBuilder.Entity<DataValue>().Property(e => e.NumberValue).HasColumnType("decimal").HasPrecision(15, 3).IsSparse();
modelBuilder.Entity<DataValue>().HasIndex(e => new { e.StringValue, e.DateValue, e.MoneyValue, e.BooleanValue, e.NumberValue });

modelBuilder.HasDbFunction(typeof(FTS).GetMethod(nameof(FTS.Contains))!)
.HasTranslation(args => new SqlFunctionExpression(
"CONTAINS",
args,
true,
args.Select(a => false).ToList(),
typeof(bool),
null));
}
}

Expand Down
50 changes: 48 additions & 2 deletions tests/Foundatio.Parsers.SqlQueries.Tests/SqlQueryParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -377,22 +377,39 @@ public async Task CanUseSkipNavigationFields()
Assert.Single(companies);
}

[Fact]
public async Task CanUseLike()
{
var sp = GetServiceProvider();
await using var db = await GetSampleContextWithDataAsync(sp);
var parser = sp.GetRequiredService<SqlQueryParser>();

var context = parser.GetContext(db.Employees.EntityType);
string sqlExpected = db.Employees.Where(e => EF.Functions.Contains(e.FullName, "john")).ToQueryString();
string sqlActual = db.Employees.Where(parser.ParsingConfig, """FTS.Contains(FullName, "john")""").ToQueryString();
Assert.Equal(sqlExpected, sqlActual);
string sql = await parser.ToDynamicLinqAsync("john", context);
sqlActual = db.Employees.Where(parser.ParsingConfig, sql).ToQueryString();
Assert.Equal(sqlExpected, sqlActual);
}

[Fact]
public async Task CanGenerateSql()
{
var sp = GetServiceProvider();
await using var db = await GetSampleContextWithDataAsync(sp);
var parser = sp.GetRequiredService<SqlQueryParser>();

var efFunctions = EF.Functions;
var context = parser.GetContext(db.Employees.EntityType);
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();
string sqlActual = db.Employees.Where("""Companies.Any(Name = "acme") AND DataValues.Any(DataDefinitionId = 1 AND NumberValue = 30) """).ToQueryString();
Assert.Equal(sqlExpected, sqlActual);
string sql = await parser.ToDynamicLinqAsync("companies.name:acme age:30", context);
sqlActual = db.Employees.Where(sql).ToQueryString();
string sql = await parser.ToDynamicLinqAsync("EF.Contains(@0, companies.name, @1)companies.name:acme age:30", context);
sqlActual = db.Employees.Where(sql, efFunctions).ToQueryString();
Assert.Equal(sqlExpected, sqlActual);

var q = db.Employees.AsNoTracking();
Expand Down Expand Up @@ -433,6 +450,8 @@ public IServiceProvider GetServiceProvider()
var parser = new SqlQueryParser();
parser.Configuration.UseEntityTypePropertyFilter(p => p.Name != nameof(Company.Description));
parser.Configuration.AddQueryVisitor(new DynamicFieldVisitor());
parser.Configuration.SetDefaultFields(["FullName"], SqlSearchOperator.Contains);
parser.Configuration.UseFullTextSearch();
services.AddSingleton(parser);
return services.BuildServiceProvider();
}
Expand Down Expand Up @@ -482,6 +501,33 @@ public async Task<SampleContext> GetSampleContextWithDataAsync(IServiceProvider
});
await db.SaveChangesAsync();

var result = await db.Database.ExecuteSqlRawAsync(
@"IF FULLTEXTSERVICEPROPERTY('IsFullTextInstalled') != 1
BEGIN
RAISERROR('Full-Text Search is not installed', 16, 1);
END");

result = await db.Database.ExecuteSqlRawAsync(
@"IF NOT EXISTS (SELECT * FROM sys.fulltext_catalogs WHERE name = 'ftCatalog')
BEGIN
CREATE FULLTEXT CATALOG ftCatalog AS DEFAULT;
END");

result = await db.Database.ExecuteSqlRawAsync(
@"IF EXISTS (SELECT * FROM sys.fulltext_indexes WHERE object_id = OBJECT_ID('Employees'))
BEGIN
DROP FULLTEXT INDEX ON Employees;
END");

result = await db.Database.ExecuteSqlRawAsync(
@"CREATE FULLTEXT INDEX ON Employees
(
FullName LANGUAGE 1033
)
KEY INDEX PK_Employees
ON ftCatalog
WITH (CHANGE_TRACKING = AUTO, STOPLIST = SYSTEM);");

return db;
}

Expand Down
Loading