Реализовать обход бинарного дерева и создание фильтра на основе спецификаций

This commit is contained in:
Roman Efremov 2025-02-05 10:40:34 +05:00
parent 87264fd8db
commit 4b5477207d
14 changed files with 649 additions and 3 deletions

View File

@ -0,0 +1,83 @@
using Ardalis.Specification;
using DD.Persistence.Database.Specifications;
using System.Linq.Expressions;
namespace DD.Persistence.Database.Postgres.Extensions;
// ToDo: рассмотреть возможность вынести логику в спецификации
public static class SpecificationExtensions
{
public static ISpecification<T> Or<T>(this ISpecification<T> spec, ISpecification<T> otherSpec)
{
var newSpec = new EmptySpecification<T>();
var parameter = Expression.Parameter(typeof(T), "x");
var exprSpec1 = CombineWhereExpressions(spec.WhereExpressions, parameter);
var exprSpec2 = CombineWhereExpressions(otherSpec.WhereExpressions, parameter);
Expression? orExpression = exprSpec1 is not null && exprSpec2 is not null
? Expression.OrElse(exprSpec1, exprSpec2)
: exprSpec1 ?? exprSpec2;
if (orExpression is null) return newSpec;
var lambdaExpr = Expression.Lambda<Func<T, bool>>(orExpression, parameter);
newSpec.Query.Where(lambdaExpr);
return newSpec;
}
// ToDo: Рефакторинг
public static ISpecification<T> And<T>(this ISpecification<T> spec, ISpecification<T> otherSpec)
{
var newSpec = new EmptySpecification<T>();
var parameter = Expression.Parameter(typeof(T), "x");
var exprSpec1 = CombineWhereExpressions(spec.WhereExpressions, parameter);
var exprSpec2 = CombineWhereExpressions(otherSpec.WhereExpressions, parameter);
Expression? andExpression = exprSpec1 is not null && exprSpec2 is not null
? Expression.AndAlso(exprSpec1, exprSpec2)
: exprSpec1 ?? exprSpec2;
if (andExpression is null) return newSpec;
var lambdaExpr = Expression.Lambda<Func<T, bool>>(andExpression, parameter);
newSpec.Query.Where(lambdaExpr);
return newSpec;
}
public static Expression? CombineWhereExpressions<T>(IEnumerable<WhereExpressionInfo<T>> whereExpressions, ParameterExpression parameter)
{
Expression? newExpr = null;
foreach (var where in whereExpressions)
{
var expr = ParameterReplacerVisitor.Replace(where.Filter.Body, where.Filter.Parameters[0], parameter);
newExpr = newExpr is null ? expr : Expression.AndAlso(newExpr, expr);
}
return newExpr;
}
}
public class ParameterReplacerVisitor : ExpressionVisitor
{
private readonly Expression _newExpression;
private readonly ParameterExpression _oldParameter;
private ParameterReplacerVisitor(ParameterExpression oldParameter, Expression newExpression)
{
_oldParameter = oldParameter;
_newExpression = newExpression;
}
internal static Expression Replace(Expression expression, ParameterExpression oldParameter, Expression newExpression)
=> new ParameterReplacerVisitor(oldParameter, newExpression).Visit(expression);
protected override Expression VisitParameter(ParameterExpression p)
=> p == _oldParameter ? _newExpression : p;
}

View File

@ -0,0 +1,137 @@
using Ardalis.Specification;
using DD.Persistence.Database.Entity;
using DD.Persistence.Database.EntityAbstractions;
using DD.Persistence.Database.Postgres.Extensions;
using DD.Persistence.Database.Specifications.ValuesItem;
using DD.Persistence.Filter.Models.Abstractions;
using DD.Persistence.Filter.Models.Enumerations;
using DD.Persistence.Filter.Visitors;
using DD.Persistence.Models;
namespace DD.Persistence.Database.Postgres.Helpers;
public static class FilterBuilder
{
public static ISpecification<TEntity>? BuildFilter<TEntity>(this DataScheme dataScheme, TNode root)
where TEntity : IValuesItem
{
var result = dataScheme.BuildSpecificationByNextNode<TEntity>(root);
return result;
}
private static ISpecification<TEntity>? BuildSpecificationByNextNode<TEntity>(this DataScheme dataScheme, TNode node)
where TEntity : IValuesItem
{
var propIndexMap = dataScheme.PropNames
.Select((name, index) => new { name, index })
.ToDictionary(x => x.name, x => x.index);
var visitor = new NodeVisitor<ISpecification<TEntity>?>(
v =>
{
var leftSpecification = dataScheme.BuildSpecificationByNextNode<TEntity>(v.Left);
var rigthSpecification = dataScheme.BuildSpecificationByNextNode<TEntity>(v.Rigth);
if (leftSpecification is null)
return rigthSpecification;
if (rigthSpecification is null)
return leftSpecification;
ISpecification<TEntity>? result = null;
switch (v.Operation)
{
case OperationEnum.And:
result = leftSpecification.And(rigthSpecification);
break;
case OperationEnum.Or:
result = leftSpecification.Or(rigthSpecification);
break;
}
return result;
},
t =>
{
int keyIndex;
if (!propIndexMap.TryGetValue(t.PropName, out keyIndex))
throw new ArgumentException($"Свойство {t.PropName} не найдено в схеме данных");
var type = dataScheme.PropTypes[keyIndex];
ISpecification<TEntity>? result = null;
switch (type)
{
case PropTypeEnum.String:
var stringValue = Convert.ToString(t.Value);
switch (t.Operation)
{
case OperationEnum.Equal:
result = new ValueEqaulSpecification<TEntity>(keyIndex, stringValue);
break;
case OperationEnum.NotEqual:
result = new ValueNotEqaulSpecification<TEntity>(keyIndex, stringValue);
break;
}
break;
case PropTypeEnum.Double:
var doubleValue = Convert.ToDouble(t.Value);
switch (t.Operation)
// ToDo: можно схлопнуть в один Generic - метод, где TValue : struct
// Но в таком случае придётся продумать аналогичное решение на уровне спецификаций
{
case OperationEnum.Equal:
result = new ValueEqaulSpecification<TEntity>(keyIndex, doubleValue);
break;
case OperationEnum.NotEqual:
result = new ValueNotEqaulSpecification<TEntity>(keyIndex, doubleValue);
break;
case OperationEnum.Greate:
result = new ValueGreateSpecification<TEntity>(keyIndex, doubleValue);
break;
case OperationEnum.GreateOrEqual:
result = new ValueGreateOrEqualSpecification<TEntity>(keyIndex, doubleValue);
break;
case OperationEnum.Less:
result = new ValueLessSpecification<TEntity>(keyIndex, doubleValue);
break;
case OperationEnum.LessOrEqual:
result = new ValueLessOrEqualSpecification<TEntity>(keyIndex, doubleValue);
break;
}
break;
case PropTypeEnum.DateTime:
stringValue = Convert.ToString(t.Value);
DateTimeOffset? dateTimeValue = string.IsNullOrEmpty(stringValue)
? null
: DateTimeOffset.Parse(stringValue);
switch (t.Operation)
{
case OperationEnum.Equal:
result = new ValueEqaulSpecification<TEntity>(keyIndex, dateTimeValue);
break;
case OperationEnum.NotEqual:
result = new ValueNotEqaulSpecification<TEntity>(keyIndex, dateTimeValue);
break;
case OperationEnum.Greate:
result = new ValueGreateSpecification<TEntity>(keyIndex, dateTimeValue);
break;
case OperationEnum.GreateOrEqual:
result = new ValueGreateOrEqualSpecification<TEntity>(keyIndex, dateTimeValue);
break;
case OperationEnum.Less:
result = new ValueLessSpecification<TEntity>(keyIndex, dateTimeValue);
break;
case OperationEnum.LessOrEqual:
result = new ValueLessOrEqualSpecification<TEntity>(keyIndex, dateTimeValue);
break;
}
break;
}
return result;
}
);
var result = node.AcceptVisitor(visitor);
return result;
}
}

View File

@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Ardalis.Specification.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
<PrivateAssets>all</PrivateAssets>

View File

@ -7,7 +7,7 @@ namespace DD.Persistence.Database.Entity;
[Table("timestamped_values")]
[PrimaryKey(nameof(DiscriminatorId), nameof(Timestamp))]
public class TimestampedValues : ITimestampedItem
public class TimestampedValues : ITimestampedItem, IValuesItem
{
[Comment("Временная отметка"), Key]
public DateTimeOffset Timestamp { get; set; }

View File

@ -0,0 +1,19 @@
using DD.Persistence.Database.Entity;
namespace DD.Persistence.Database.EntityAbstractions;
/// <summary>
/// Сущность с данными, принадлежащими к определенной схеме
/// </summary>
public interface IValuesItem
{
/// <summary>
/// Схема данных
/// </summary>
DataScheme? DataScheme { get; set; }
/// <summary>
/// Значения
/// </summary>
object[] Values { get; set; }
}

View File

@ -0,0 +1,14 @@
using Ardalis.Specification;
namespace DD.Persistence.Database.Specifications;
/// <summary>
/// Пустая спецификация (вспомогательная)
/// </summary>
/// <typeparam name="T"></typeparam>
public class EmptySpecification<T> : Specification<T>
{
public EmptySpecification()
{
}
}

View File

@ -0,0 +1,28 @@
using Ardalis.Specification;
using DD.Persistence.Database.EntityAbstractions;
namespace DD.Persistence.Database.Specifications.ValuesItem;
/// <summary>
/// Спецификация эквивалентности значений IValuesItem в соответствии с индексацией
/// </summary>
/// <typeparam name="TEntity"></typeparam>
public class ValueEqaulSpecification<TEntity> : Specification<TEntity>
where TEntity : IValuesItem
{
public ValueEqaulSpecification(int index, string? value)
{
Query.Where(e => Convert.ToString(e.Values[index]) == value);
}
public ValueEqaulSpecification(int index, double? value)
{
Query.Where(e => Convert.ToDouble(e.Values[index]) == value);
}
public ValueEqaulSpecification(int index, DateTimeOffset? value)
{
// ToDo: рассмотреть возможность более вменяемого парсинга для даты
Query.Where(e => DateTimeOffset.Parse(Convert.ToString(e.Values[index])!) == value);
}
}

View File

@ -0,0 +1,22 @@
using Ardalis.Specification;
using DD.Persistence.Database.EntityAbstractions;
namespace DD.Persistence.Database.Specifications.ValuesItem;
/// <summary>
/// Спецификация "больше либо равно" для значений IValuesItem в соответствии с индексацией
/// </summary>
/// <typeparam name="TEntity"></typeparam>
public class ValueGreateOrEqualSpecification<TEntity> : Specification<TEntity>
where TEntity : IValuesItem
{
public ValueGreateOrEqualSpecification(int index, double? value)
{
Query.Where(e => Convert.ToDouble(e.Values[index]) >= value);
}
public ValueGreateOrEqualSpecification(int index, DateTimeOffset? value)
{
Query.Where(e => DateTimeOffset.Parse(Convert.ToString(e.Values[index])!) >= value);
}
}

View File

@ -0,0 +1,22 @@
using Ardalis.Specification;
using DD.Persistence.Database.EntityAbstractions;
namespace DD.Persistence.Database.Specifications.ValuesItem;
/// <summary>
/// Спецификация "больше" для значений IValuesItem в соответствии с индексацией
/// </summary>
/// <typeparam name="TEntity"></typeparam>
public class ValueGreateSpecification<TEntity> : Specification<TEntity>
where TEntity : IValuesItem
{
public ValueGreateSpecification(int index, double? value)
{
Query.Where(e => Convert.ToDouble(e.Values[index]) > value);
}
public ValueGreateSpecification(int index, DateTimeOffset? value)
{
Query.Where(e => DateTimeOffset.Parse(Convert.ToString(e.Values[index])!) > value);
}
}

View File

@ -0,0 +1,22 @@
using Ardalis.Specification;
using DD.Persistence.Database.EntityAbstractions;
namespace DD.Persistence.Database.Specifications.ValuesItem;
/// <summary>
/// Спецификация "меньше либо равно" для значений IValuesItem в соответствии с индексацией
/// </summary>
/// <typeparam name="TEntity"></typeparam>
public class ValueLessOrEqualSpecification<TEntity> : Specification<TEntity>
where TEntity : IValuesItem
{
public ValueLessOrEqualSpecification(int index, double? value)
{
Query.Where(e => Convert.ToDouble(e.Values[index]) <= value);
}
public ValueLessOrEqualSpecification(int index, DateTimeOffset? value)
{
Query.Where(e => DateTimeOffset.Parse(Convert.ToString(e.Values[index])!) <= value);
}
}

View File

@ -0,0 +1,22 @@
using Ardalis.Specification;
using DD.Persistence.Database.EntityAbstractions;
namespace DD.Persistence.Database.Specifications.ValuesItem;
/// <summary>
/// Спецификация "меньше" для значений IValuesItem в соответствии с индексацией
/// </summary>
/// <typeparam name="TEntity"></typeparam>
public class ValueLessSpecification<TEntity> : Specification<TEntity>
where TEntity : IValuesItem
{
public ValueLessSpecification(int index, double? value)
{
Query.Where(e => Convert.ToDouble(e.Values[index]) < value);
}
public ValueLessSpecification(int index, DateTimeOffset? value)
{
Query.Where(e => DateTimeOffset.Parse(Convert.ToString(e.Values[index])!) < value);
}
}

View File

@ -0,0 +1,27 @@
using Ardalis.Specification;
using DD.Persistence.Database.EntityAbstractions;
namespace DD.Persistence.Database.Specifications.ValuesItem;
/// <summary>
/// Спецификация неравенства значений IValuesItem в соответствии с индексацией
/// </summary>
/// <typeparam name="TEntity"></typeparam>
public class ValueNotEqaulSpecification<TEntity> : Specification<TEntity>
where TEntity : IValuesItem
{
public ValueNotEqaulSpecification(int index, string? value)
{
Query.Where(e => Convert.ToString(e.Values[index]) != value);
}
public ValueNotEqaulSpecification(int index, double? value)
{
Query.Where(e => Convert.ToDouble(e.Values[index]) != value);
}
public ValueNotEqaulSpecification(int index, DateTimeOffset? value)
{
Query.Where(e => DateTimeOffset.Parse(Convert.ToString(e.Values[index])!) != value);
}
}

View File

@ -16,8 +16,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DD.Persistence.Database\DD.Persistence.Database.csproj" />
<ProjectReference Include="..\DD.Persistence\DD.Persistence.csproj" />
<ProjectReference Include="..\DD.Persistence.Database.Postgres\DD.Persistence.Database.Postgres.csproj" />
</ItemGroup>
<ItemGroup>

View File

@ -0,0 +1,250 @@
using Ardalis.Specification.EntityFrameworkCore;
using DD.Persistence.Database.Entity;
using DD.Persistence.Filter.Models;
using DD.Persistence.Filter.Models.Enumerations;
using DD.Persistence.Models;
using DD.Persistence.Database.Postgres.Helpers;
namespace DD.Persistence.Test;
public class FilterBuilderShould
{
private readonly SpecificationEvaluator SpecificationEvaluator;
public FilterBuilderShould()
{
this.SpecificationEvaluator = new SpecificationEvaluator();
}
[Fact]
public void TestFilterBuilding()
{
//arrange
var discriminatorId = Guid.NewGuid();
var dataScheme = new DataScheme()
{
DiscriminatorId = discriminatorId,
PropNames = ["A", "B", "C"],
PropTypes = [PropTypeEnum.DateTime, PropTypeEnum.Double, PropTypeEnum.String]
};
var filterDate = DateTime.Now.AddMinutes(-1);
var root = new TVertex(
OperationEnum.Or,
new TVertex(
OperationEnum.And,
new TLeaf(OperationEnum.Greate, "A", filterDate),
new TLeaf(OperationEnum.Less, "B", 2.22)
),
new TLeaf(OperationEnum.Equal, "C", "IsEqualText")
);
var queryableData = new[]
{
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-1),
Values = new object[] { filterDate.AddMinutes(-1), 200, "IsEqualText" }, // true
DataScheme = dataScheme
},
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-2),
Values = new object[] { filterDate.AddMinutes(1), 2.21, "IsNotEqualText" }, // true
DataScheme = dataScheme
},
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-3),
Values = new object[] { filterDate.AddMinutes(-1), 2.22, "IsNotEqualText" }, // false
DataScheme = dataScheme
},
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-4),
Values = new object[] { filterDate.AddMinutes(-1), 2.21, "IsNotEqualText" }, // false
DataScheme = dataScheme
}
}
.AsQueryable();
//act
var specification = dataScheme.BuildFilter<TimestampedValues>(root);
//assert
Assert.NotNull(specification);
var query = SpecificationEvaluator.GetQuery(queryableData, specification);
var result = query.ToList();
Assert.NotNull(result);
Assert.NotEmpty(result);
var expectedCount = 2;
var actualCount = result.Count();
Assert.Equal(expectedCount, actualCount);
}
[Fact]
public void TestFilterOperations()
{
//arrange
var discriminatorId = Guid.NewGuid();
var dataScheme = new DataScheme()
{
DiscriminatorId = discriminatorId,
PropNames = ["A"],
PropTypes = [PropTypeEnum.Double]
};
var root = new TVertex(
OperationEnum.Or,
new TVertex(
OperationEnum.And,
new TVertex(
OperationEnum.And,
new TVertex(
OperationEnum.And,
new TVertex(
OperationEnum.And,
new TLeaf(OperationEnum.Less, "A", 2),
new TLeaf(OperationEnum.LessOrEqual, "A", 1.99)
),
new TLeaf(OperationEnum.GreateOrEqual, "A", 1.97)
),
new TLeaf(OperationEnum.Greate, "A", 1.96)
),
new TLeaf(OperationEnum.NotEqual, "A", 1.98)
),
new TLeaf(OperationEnum.Equal, "A", 1)
);
var queryableData = new[]
{
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-1),
Values = new object[] { 1 }, // true
DataScheme = dataScheme
},
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-2),
Values = new object[] { 1.96 }, // false
DataScheme = dataScheme
},
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-3),
Values = new object[] { 1.97 }, // true
DataScheme = dataScheme
},
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-4),
Values = new object[] { 1.98 }, // false
DataScheme = dataScheme
},
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-5),
Values = new object[] { 1.99 }, // true
DataScheme = dataScheme
},
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-6),
Values = new object[] { 2 }, // false
DataScheme = dataScheme
}
}
.AsQueryable();
//act
var specification = dataScheme.BuildFilter<TimestampedValues>(root);
//assert
Assert.NotNull(specification);
var query = SpecificationEvaluator.GetQuery(queryableData, specification);
var result = query.ToList();
Assert.NotNull(result);
Assert.NotEmpty(result);
var expectedCount = 3;
var actualCount = result.Count();
Assert.Equal(expectedCount, actualCount);
}
[Fact]
public void TestFilterValues()
{
//arrange
var discriminatorId = Guid.NewGuid();
var filterDate = DateTimeOffset.Now;
var dataScheme = new DataScheme()
{
DiscriminatorId = discriminatorId,
PropNames = ["A", "B", "C", "D"],
PropTypes = [PropTypeEnum.Double, PropTypeEnum.Double, PropTypeEnum.String, PropTypeEnum.DateTime]
};
var root = new TVertex(
OperationEnum.Or,
new TVertex(
OperationEnum.Or,
new TVertex(
OperationEnum.Or,
new TLeaf(OperationEnum.Equal, "A", 1),
new TLeaf(OperationEnum.Equal, "B", 1.11)
),
new TLeaf(OperationEnum.Equal, "C", "IsEqualText")
),
new TLeaf(OperationEnum.Equal, "D", filterDate)
);
var queryableData = new[]
{
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-1),
Values = new object[] { 1, 2.22, "IsNotEqualText", DateTimeOffset.Now.AddMinutes(-1) }, // true
DataScheme = dataScheme
},
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-2),
Values = new object[] { 2, 1.11, "IsNotEqualText", DateTimeOffset.Now.AddMinutes(-1) }, // true
DataScheme = dataScheme
},
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-3),
Values = new object[] { 2, 2.22, "IsEqualText", DateTimeOffset.Now.AddMinutes(-1) }, // true
DataScheme = dataScheme
},
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-4),
Values = new object[] { 2, 2.22, "IsNotEqualText", filterDate }, // true
DataScheme = dataScheme
},
new TimestampedValues {
DiscriminatorId = discriminatorId,
Timestamp = DateTimeOffset.Now.AddMinutes(-1),
Values = new object[] { 2, 2.22, "IsNotEqualText", DateTimeOffset.Now.AddMinutes(-1) }, // false
DataScheme = dataScheme
}
}
.AsQueryable();
//act
var specification = dataScheme.BuildFilter<TimestampedValues>(root);
//assert
Assert.NotNull(specification);
var query = SpecificationEvaluator.GetQuery(queryableData, specification);
var result = query.ToList();
Assert.NotNull(result);
Assert.NotEmpty(result);
var expectedCount = 4;
var actualCount = result.Count();
Assert.Equal(expectedCount, actualCount);
}
}