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

Rewrite SearchConditionConverter #34905

Merged
merged 2 commits into from
Oct 16, 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
317 changes: 317 additions & 0 deletions src/EFCore.SqlServer/Query/Internal/SearchConditionConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;

namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal;

/// <summary>
/// <para>
/// A SQL Server visitor which converts boolean expressions that represent search conditions to bit values and vice versa, depending
/// on context:
/// </para>
/// <code>
/// WHERE b.SomeBitColumn => WHERE b.SomeBitColumn = 1
/// SELECT a LIKE b => SELECT CASE WHEN a LIKE b THEN 1 ELSE 0 END
/// </code>
/// </summary>
/// <remarks>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </remarks>
public class SearchConditionConverter(ISqlExpressionFactory sqlExpressionFactory) : ExpressionVisitor
{
/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
[return: NotNullIfNotNull(nameof(expression))]
public override Expression? Visit(Expression? expression)
=> Visit(expression, inSearchConditionContext: false);

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
[return: NotNullIfNotNull(nameof(expression))]
protected virtual Expression? Visit(Expression? expression, bool inSearchConditionContext)
=> expression switch
{
CaseExpression e => VisitCase(e, inSearchConditionContext),
SelectExpression e => VisitSelect(e),
SqlBinaryExpression e => VisitSqlBinary(e, inSearchConditionContext),
SqlUnaryExpression e => VisitSqlUnary(e, inSearchConditionContext),
PredicateJoinExpressionBase e => VisitPredicateJoin(e),

// The following are search condition expressions: they can appear directly in a WHERE, and cannot e.g. be projected out
// directly
SqlExpression e and
(ExistsExpression or InExpression or LikeExpression or SqlFunctionExpression { Name: "FREETEXT" or "CONTAINS" })
=> ApplyConversion((SqlExpression)base.VisitExtension(e), inSearchConditionContext, isExpressionSearchCondition: true),

SqlExpression e => ApplyConversion(
(SqlExpression)base.VisitExtension(e), inSearchConditionContext, isExpressionSearchCondition: false),

_ => base.Visit(expression)
};

private SqlExpression ApplyConversion(SqlExpression sqlExpression, bool inSearchConditionContext, bool isExpressionSearchCondition)
=> (inSearchCondition: inSearchConditionContext, isExpressionSearchCondition) switch
{
// A non-search condition expression in a search condition context - add equality to convert to search condition:
// WHERE b.SomeBitColumn => WHERE b.SomeBitColumn = 1
(true, false) => sqlExpression is SqlConstantExpression { Value: bool boolValue }
? sqlExpressionFactory.Equal(
boolValue
? sqlExpressionFactory.Constant(1)
: sqlExpressionFactory.Constant(0),
sqlExpressionFactory.Constant(1))
: sqlExpressionFactory.Equal(sqlExpression, sqlExpressionFactory.Constant(true)),

// A search condition expression in non-search condition context - wrap in CASE/WHEN to convert to bit:
// e.g. SELECT a LIKE b => SELECT CASE WHEN a LIKE b THEN 1 ELSE 0 END
// TODO: NULL is not handled properly here, see #34001
(false, true) => sqlExpressionFactory.Case(
[
new CaseWhenClause(
SimplifyNegatedBinary(sqlExpression),
sqlExpressionFactory.ApplyDefaultTypeMapping(sqlExpressionFactory.Constant(true)))
],
sqlExpressionFactory.Constant(false)),

// All other cases (e.g. WHERE a LIKE b, SELECT b.SomebitColumn) - no need to do anything.
_ => sqlExpression
};

private SqlExpression SimplifyNegatedBinary(SqlExpression sqlExpression)
{
if (sqlExpression is SqlUnaryExpression { OperatorType: ExpressionType.Not } sqlUnaryExpression
&& sqlUnaryExpression.Type == typeof(bool)
&& sqlUnaryExpression.Operand is SqlBinaryExpression
{
OperatorType: ExpressionType.Equal
} sqlBinaryOperand)
{
if (sqlBinaryOperand.Left.Type == typeof(bool)
&& sqlBinaryOperand.Right.Type == typeof(bool)
&& (sqlBinaryOperand.Left is SqlConstantExpression
|| sqlBinaryOperand.Right is SqlConstantExpression))
{
var constant = sqlBinaryOperand.Left as SqlConstantExpression ?? (SqlConstantExpression)sqlBinaryOperand.Right;
if (sqlBinaryOperand.Left is SqlConstantExpression)
{
return sqlExpressionFactory.MakeBinary(
ExpressionType.Equal,
sqlExpressionFactory.Constant(!(bool)constant.Value!, constant.TypeMapping),
sqlBinaryOperand.Right,
sqlBinaryOperand.TypeMapping)!;
}

return sqlExpressionFactory.MakeBinary(
ExpressionType.Equal,
sqlBinaryOperand.Left,
sqlExpressionFactory.Constant(!(bool)constant.Value!, constant.TypeMapping),
sqlBinaryOperand.TypeMapping)!;
}

return sqlExpressionFactory.MakeBinary(
sqlBinaryOperand.OperatorType == ExpressionType.Equal
? ExpressionType.NotEqual
: ExpressionType.Equal,
sqlBinaryOperand.Left,
sqlBinaryOperand.Right,
sqlBinaryOperand.TypeMapping)!;
}

return sqlExpression;
}

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
protected virtual Expression VisitCase(CaseExpression caseExpression, bool inSearchConditionContext)
{
var testIsCondition = caseExpression.Operand is null;
var operand = (SqlExpression?)Visit(caseExpression.Operand);
var whenClauses = new List<CaseWhenClause>();
foreach (var whenClause in caseExpression.WhenClauses)
{
var test = (SqlExpression)Visit(whenClause.Test, testIsCondition);
var result = (SqlExpression)Visit(whenClause.Result);
whenClauses.Add(new CaseWhenClause(test, result));
}

var elseResult = (SqlExpression?)Visit(caseExpression.ElseResult);

return ApplyConversion(
sqlExpressionFactory.Case(operand, whenClauses, elseResult, caseExpression),
inSearchConditionContext,
isExpressionSearchCondition: false);
}

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
protected virtual Expression VisitPredicateJoin(PredicateJoinExpressionBase join)
=> join.Update(
(TableExpressionBase)Visit(join.Table),
(SqlExpression)Visit(join.JoinPredicate, inSearchConditionContext: true));

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
protected virtual Expression VisitSelect(SelectExpression select)
{
var tables = this.VisitAndConvert(select.Tables);
var predicate = (SqlExpression?)Visit(select.Predicate, inSearchConditionContext: true);
var groupBy = this.VisitAndConvert(select.GroupBy);
var havingExpression = (SqlExpression?)Visit(select.Having, inSearchConditionContext: true);
var projections = this.VisitAndConvert(select.Projection);
var orderings = this.VisitAndConvert(select.Orderings);
var offset = (SqlExpression?)Visit(select.Offset);
var limit = (SqlExpression?)Visit(select.Limit);

return select.Update(tables, predicate, groupBy, havingExpression, projections, orderings, offset, limit);
}

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
protected virtual Expression VisitSqlBinary(SqlBinaryExpression binary, bool inSearchConditionContext)
{
// Only logical operations need conditions on both sides
var areOperandsInSearchConditionContext = binary.OperatorType is ExpressionType.AndAlso or ExpressionType.OrElse;

var newLeft = (SqlExpression)Visit(binary.Left, areOperandsInSearchConditionContext);
var newRight = (SqlExpression)Visit(binary.Right, areOperandsInSearchConditionContext);

if (binary.OperatorType is ExpressionType.NotEqual or ExpressionType.Equal)
{
if (!inSearchConditionContext
&& (newLeft.Type == typeof(bool) || newLeft.Type.IsEnum || newLeft.Type.IsInteger())
&& (newRight.Type == typeof(bool) || newRight.Type.IsEnum || newRight.Type.IsInteger()))
{
// "lhs != rhs" is the same as "CAST(lhs ^ rhs AS BIT)", except that
// the first is a boolean, the second is a BIT
var result = sqlExpressionFactory.MakeBinary(ExpressionType.ExclusiveOr, newLeft, newRight, null)!;

if (result.Type != typeof(bool))
{
result = sqlExpressionFactory.Convert(result, typeof(bool), binary.TypeMapping);
}

// "lhs == rhs" is the same as "NOT(lhs != rhs)" aka "~(lhs ^ rhs)"
if (binary.OperatorType is ExpressionType.Equal)
{
result = sqlExpressionFactory.MakeUnary(ExpressionType.OnesComplement, result, result.Type, result.TypeMapping)!;
}

return result;
}

if (newLeft is SqlUnaryExpression { OperatorType: ExpressionType.OnesComplement } negatedLeft
&& newRight is SqlUnaryExpression { OperatorType: ExpressionType.OnesComplement } negatedRight)
{
newLeft = negatedLeft.Operand;
newRight = negatedRight.Operand;
}
}

binary = binary.Update(newLeft, newRight);

var isExpressionSearchCondition = binary.OperatorType is ExpressionType.AndAlso
or ExpressionType.OrElse
or ExpressionType.Equal
or ExpressionType.NotEqual
or ExpressionType.GreaterThan
or ExpressionType.GreaterThanOrEqual
or ExpressionType.LessThan
or ExpressionType.LessThanOrEqual;

return ApplyConversion(binary, inSearchConditionContext, isExpressionSearchCondition);
}

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
protected virtual Expression VisitSqlUnary(SqlUnaryExpression sqlUnaryExpression, bool inSearchConditionContext)
{
bool isOperandInSearchConditionContext, isSearchConditionExpression;

switch (sqlUnaryExpression.OperatorType)
{
case ExpressionType.Not when sqlUnaryExpression.Type == typeof(bool):
{
// when possible, avoid converting to/from predicate form
if (!inSearchConditionContext && sqlUnaryExpression.Operand is not (ExistsExpression or InExpression or LikeExpression))
{
var negatedOperand = (SqlExpression)Visit(sqlUnaryExpression.Operand);

if (negatedOperand is SqlUnaryExpression { OperatorType: ExpressionType.OnesComplement } unary)
{
return unary.Operand;
}

return sqlExpressionFactory.MakeUnary(
ExpressionType.OnesComplement, negatedOperand, negatedOperand.Type, negatedOperand.TypeMapping)!;
}

isOperandInSearchConditionContext = true;
isSearchConditionExpression = true;
break;
}

case ExpressionType.Not:
isOperandInSearchConditionContext = false;
isSearchConditionExpression = false;
break;

case ExpressionType.Convert:
case ExpressionType.Negate:
isOperandInSearchConditionContext = false;
isSearchConditionExpression = false;
break;

case ExpressionType.Equal:
case ExpressionType.NotEqual:
isOperandInSearchConditionContext = false;
isSearchConditionExpression = true;
break;

default:
throw new InvalidOperationException(
RelationalStrings.UnsupportedOperatorForSqlExpression(
sqlUnaryExpression.OperatorType, typeof(SqlUnaryExpression)));
}

var operand = (SqlExpression)Visit(sqlUnaryExpression.Operand, isOperandInSearchConditionContext);

return SimplifyNegatedBinary(
ApplyConversion(
sqlUnaryExpression.Update(operand),
inSearchConditionContext,
isSearchConditionExpression));
}
}
Loading