Added tests that utilize the Pgsql expression generator. It's in use in another application and appeared to be a source of bugs.

main
Sean McArde 2024-03-12 17:03:33 -07:00
parent d469df010c
commit f17ac2582f
5 changed files with 257 additions and 0 deletions

11
McRule.Tests/Class1.cs Normal file
View File

@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace McRule.Tests {
public class PredicateCombination {
}
}

View File

@ -0,0 +1,37 @@
using System.Text.RegularExpressions;
namespace SMM {
public static class FilterPatternHelpers {
private static bool HasFilterCharacters(string input) { return Regex.IsMatch(input, @"(^([~\*]+)|(\*)$)"); }
public static string AddFilterOptionsIfNotSpecified(this string Pattern, FilterOptions options = FilterOptions.None) {
string result = Pattern.Trim();
if (HasFilterCharacters(Pattern)) return result;
if (options.HasFlag(FilterOptions.Contains)) {
result = $"*{result}*";
} else {
if (options.HasFlag(FilterOptions.StartsWith)) {
result = $"{result}*";
}
if (options.HasFlag(FilterOptions.EndsWith)) {
result = $"*{result}";
}
}
if (options.HasFlag(FilterOptions.IgnoreCase)) {
result = $"~{result}";
}
return result;
}
public enum FilterOptions {
None = 0,
IgnoreCase = 1,
Contains = 2,
StartsWith = 4,
EndsWith = 8,
}
}
}

View File

@ -0,0 +1,66 @@

using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
using System.Reflection;
namespace SMM
{
public class NpgsqlGenerator : McRule.ExpressionGeneratorBase {
/// <summary>
/// Builds expressions using string member functions StartsWith, EndsWith or Contains as the comparator,
/// case-insensitive matches produce an ILike expression for Npgsql.
/// </summary>
public override Expression<Func<T, bool>> AddStringPropertyExpression<T>(
Expression<Func<T, string>> lambda, string filter, string filterType, bool ignoreCase = false) {
#if DEBUG
if (!(filterType == "StartsWith" || filterType == "EndsWith" || filterType == "Contains" ||
filterType == "Equals")) {
throw new Exception($"filterType must equal StartsWith, EndsWith or Contains. Passed: {filterType}");
}
#endif
// If a case insensitive comparision is requested, we resolve that against the
// Npgsql ILike extension method. EF will translate that into the proper SQL before
// sending it to the database.
if (ignoreCase) {
switch (filterType) {
case "StartsWith":
filter = $"{filter}%";
break;
case "EndsWith":
filter = $"%{filter}";
break;
case "Contains":
filter = $"%{filter}%";
break;
}
// ILike is a virtual static extension method so needs a statically typed
// null as the first parameter. Smart people made the type system so
// I hope this makes sense to them.
var nullExpr = Expression.Constant(null, typeof(DbFunctions));
var method = typeof(NpgsqlDbFunctionsExtensions).GetMethods().Where(
x => (x.Name?.Equals("ILike") ?? false)
&& x.GetParameters().Count() == 3); // There's two "ILike" versions, we want the one that takes 3 arguments.
var likeCall = Expression.Call(method.FirstOrDefault(), nullExpr, lambda.Body, Expression.Constant(filter, typeof(string)));
return Expression.Lambda<Func<T, bool>>(
likeCall,
lambda.Parameters);
}
// When case sensitive matches are fine, we can invoke the String extension methods,
// EF will do the case sensitive stuff like normal.
MethodInfo methodInfo = typeof(string).GetMethod(filterType, new[] { typeof(string) });
List<Expression> expressionArgs = new List<Expression>() { Expression.Constant(filter) };
var strPredicate = Expression.Call(lambda.Body, methodInfo, expressionArgs);
return Expression.Lambda<Func<T, bool>>(
strPredicate,
lambda.Parameters);
}
}
}

View File

@ -0,0 +1,140 @@
using McRule.Tests.Models;
using SMM;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using System.Threading.Tasks;
using static SMM.FilterPatternHelpers;
namespace McRule.Tests.McAttributes {
public class SearchFilter {
[SetUp]
public void SetUp() {
}
[Test]
public void SearchCriteriaWithASpaceCombinesSearchFilters() {
var fixture = new SearchTestFixture();
fixture.SearchCriteria = "Williams Robin ";
Assert.DoesNotThrow(() => fixture.DoSearch());
var query = fixture.SearchExpression?.Compile()?.ToString();
Console.WriteLine(query);
}
}
internal class SearchTestFixture {
public string SearchCriteria { get; set; }
public Expression<Func<User, bool>> SearchExpression { get; set; }
public void DoSearch() {
SearchExpression = GetUserFilter();
}
private Expression<Func<User, bool>> GetUserFilter() {
if (String.IsNullOrEmpty(SearchCriteria)) return PredicateBuilder.False<User>();
var efGenerator = new SMM.NpgsqlGenerator();
var filter = new ExpressionRuleCollection() {
TargetType = nameof(Models.User),
};
filter.RuleOperator = RuleOperator.Or;
var _rules = new List<IExpressionPolicy>();
IExpressionPolicy subFilter = null;
// If the search criteria contains spaces, it's likely the intent is to match against display name,
// as it's the only property where users likely have spaces in the value.
if (SearchCriteria.Trim().Contains(' ')) {
_rules.Add(
new ExpressionRule((nameof(Models.User), nameof(Models.User.DisplayName), SearchCriteria.AddFilterOptionsIfNotSpecified(FilterOptions.Contains | FilterOptions.IgnoreCase)))
);
subFilter = new ExpressionRuleCollection();
((ExpressionRuleCollection)subFilter).RuleOperator = RuleOperator.And;
var subRules = new List<IExpressionPolicy>();
foreach (var x in SearchCriteria.Trim().Split()) subRules.Add(new ExpressionRule((nameof(Models.User), nameof(Models.User.Mail), x.Trim()?.AddFilterOptionsIfNotSpecified(FilterOptions.Contains | FilterOptions.IgnoreCase))));
((ExpressionRuleCollection)subFilter).Rules = subRules;
filter.Rules = _rules;
// TODO make this kind of thing eaiser
var metaExpression = new ExpressionRuleCollection() {
TargetType = nameof(Models.User),
RuleOperator = RuleOperator.Or,
Rules = new[] { filter, subFilter }
};
return efGenerator.GetPredicateExpression<User>((IExpressionRuleCollection)metaExpression) ?? PredicateBuilder.False<User>();
} else {
_rules.AddRange(new[] {
new ExpressionRule((nameof(Models.User), nameof(Models.User.Mail), SearchCriteria.AddFilterOptionsIfNotSpecified(FilterOptions.StartsWith | FilterOptions.IgnoreCase))),
new ExpressionRule((nameof(Models.User), nameof(Models.User.Upn), SearchCriteria.AddFilterOptionsIfNotSpecified(FilterOptions.StartsWith | FilterOptions.IgnoreCase))),
new ExpressionRule((nameof(Models.User), nameof(Models.User.EmployeeId), SearchCriteria.AddFilterOptionsIfNotSpecified(FilterOptions.StartsWith | FilterOptions.IgnoreCase))),
new ExpressionRule((nameof(Models.User), nameof(Models.User.PreferredGivenName), SearchCriteria.AddFilterOptionsIfNotSpecified(FilterOptions.StartsWith | FilterOptions.IgnoreCase))),
new ExpressionRule((nameof(Models.User), nameof(Models.User.PreferredSurname), SearchCriteria.AddFilterOptionsIfNotSpecified(FilterOptions.StartsWith | FilterOptions.IgnoreCase))),
});
}
filter.Rules = _rules;
return efGenerator.GetPredicateExpression<User>((IExpressionRuleCollection)filter) ?? PredicateBuilder.False<User>();
}
}
}
namespace McRule.Tests.Models {
public class User {
public DateTime? LastFetched { get; set; } = null;
public DateTime? Merged { set; get; } = null;
public DateTime? Modified { get; set; } = null;
public DateTime? Created { get; set; } = null;
public bool Enabled { get; set; }
public bool Deleted { get; set; }
public string? Tenant { get; set; }
public Guid AadId { get; set; }
public string? Upn { get; set; }
public string? Mail { get; set; }
public string? DisplayName { get; set; }
public string? EmployeeId { get; set; }
public string? AdEmployeeId { get; set; }
public string? HrEmployeeId { get; set; }
public string? Wid { get; set; }
public string? CreationType { get; set; }
public string? Company { get; set; }
public string? Title { get; set; }
public string? PreferredGivenName { get; set; }
public string? PreferredSurname { get; set; }
public string? Articles { get; set; }
public string? Pronouns { get; set; }
public string? OnPremiseDn { get; set; }
}
}

View File

@ -9,7 +9,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.17" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.11" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="NUnit.Analyzers" Version="3.3.0" />