diff --git a/global.json b/global.json index 29ba481..8e8a4cd 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.201", + "version": "10.0.100", "rollForward": "feature" } } \ No newline at end of file diff --git a/src/Clave.Expressionify/Clave.Expressionify.csproj b/src/Clave.Expressionify/Clave.Expressionify.csproj index 7d369d5..431228e 100644 --- a/src/Clave.Expressionify/Clave.Expressionify.csproj +++ b/src/Clave.Expressionify/Clave.Expressionify.csproj @@ -1,14 +1,14 @@  - net9.0 + net10.0 embedded latest enable - + diff --git a/src/Clave.Expressionify/ExpressionifyQueryTranslationPreprocessor.cs b/src/Clave.Expressionify/ExpressionifyQueryTranslationPreprocessor.cs index 0e49977..2fd3202 100644 --- a/src/Clave.Expressionify/ExpressionifyQueryTranslationPreprocessor.cs +++ b/src/Clave.Expressionify/ExpressionifyQueryTranslationPreprocessor.cs @@ -34,47 +34,66 @@ public override Expression Process(Expression query) private Expression EvaluateExpression(Expression query) { // 1) Ensure that no new parameters are introduced when creating the query - // 2) This expression visitor also makes slight optimzations, like replacing evaluatable expressions. + // 2) This expression visitor also makes slight optimizations, like replacing evaluatable expressions. - // ParameterExtractingExpressionVisitor is 100% taken from EF8 - // We should move to ExpressionTreeFuncletizer (see: https://github.com/dotnet/efcore/pull/33106/files) - // But the funcletizer doesn't behave the same since it would call ParameterValues.Count which would then throw (see below) - // Thus, for now we just reuse ParameterExtractingExpressionVisitor until a proper fix is found - var visitor = new ParameterExtractingExpressionVisitor( - Dependencies.EvaluatableExpressionFilter, - new ThrowOnParameterAccess(), - QueryCompilationContext.ContextType, - QueryCompilationContext.Model, - QueryCompilationContext.Logger, - parameterize: true, - generateContextAccessors: false); - - return visitor.ExtractParameters(query); - - /* With EF9 something along these lines would have been the ideal solution: - ExpressionTreeFuncletizer funcletizer = new( + // With EF10, ParameterExtractingExpressionVisitor was removed and replaced by ExpressionTreeFuncletizer + // ExpressionTreeFuncletizer now uses Dictionary instead of IParameterValues + var funcletizer = new ExpressionTreeFuncletizer( QueryCompilationContext.Model, Dependencies.EvaluatableExpressionFilter, QueryCompilationContext.ContextType, generateContextAccessors: false, QueryCompilationContext.Logger); - return funcletizer.ExtractParameters(query, new ThrowOnParameterAccess(), parameterize: true, clearParameterizedValues: true); - */ + var throwOnAccess = new ThrowOnParameterAccess(); + var result = funcletizer.ExtractParameters(query, throwOnAccess, parameterize: true, clearParameterizedValues: true); + + // Check if parameters were added by accessing the base Dictionary + // ExpressionTreeFuncletizer bypasses our 'new' method overrides because Dictionary<> is not designed + // to be subclassed, so we check the Count via the base class after funcletization completes + if (((Dictionary)throwOnAccess).Count > 0) + { + throw new InvalidOperationException( + "Adding parameters in a cached query context is not allowed. " + + $"Explicitly call .{nameof(ExpressionifyExtension.Expressionify)}() on the query or use {nameof(ExpressionEvaluationMode)}.{nameof(ExpressionEvaluationMode.FullCompatibilityButSlow)}."); + } + + return result; } [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "")] - private class ThrowOnParameterAccess : IParameterValues + private class ThrowOnParameterAccess : Dictionary { - public void AddParameter(string name, object? value) - => throw new InvalidOperationException( + // This class exists primarily for documentation purposes - to make it clear that parameter + // access should throw an exception in cached mode. However, ExpressionTreeFuncletizer bypasses + // these 'new' method overrides by calling base Dictionary<> methods directly. + // The actual check happens in EvaluateExpression() after funcletization completes. + + private static InvalidOperationException CreateException() + => new InvalidOperationException( "Adding parameters in a cached query context is not allowed. " + $"Explicitly call .{nameof(ExpressionifyExtension.Expressionify)}() on the query or use {nameof(ExpressionEvaluationMode)}.{nameof(ExpressionEvaluationMode.FullCompatibilityButSlow)}."); - public IReadOnlyDictionary ParameterValues - => throw new InvalidOperationException( - "Accessing parameters in a cached query context is not allowed. " + - $"Explicitly call .{nameof(ExpressionifyExtension.Expressionify)}() on the query or use {nameof(ExpressionEvaluationMode)}.{nameof(ExpressionEvaluationMode.FullCompatibilityButSlow)}."); + public new object? this[string key] + { + get => throw CreateException(); + set => throw CreateException(); + } + + public new void Add(string key, object? value) + => throw CreateException(); + + public new bool TryAdd(string key, object? value) + => throw CreateException(); + + public new bool TryGetValue(string key, out object? value) + => throw CreateException(); + + public new bool ContainsKey(string key) + => throw CreateException(); + + public new int Count + => throw CreateException(); } } } \ No newline at end of file diff --git a/src/Clave.Expressionify/ParameterExtractingExpressionVisitor.cs b/src/Clave.Expressionify/ParameterExtractingExpressionVisitor.cs deleted file mode 100644 index f19a3bd..0000000 --- a/src/Clave.Expressionify/ParameterExtractingExpressionVisitor.cs +++ /dev/null @@ -1,742 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.EntityFrameworkCore.Diagnostics; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage; -using System.Collections.Generic; -using System; -using System.Diagnostics.CodeAnalysis; -using System.Linq.Expressions; -using System.Linq; -using System.Reflection; -using System.Runtime.CompilerServices; - -namespace Microsoft.EntityFrameworkCore.Query.Internal; - -/// -/// 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. -/// -public class ParameterExtractingExpressionVisitor : ExpressionVisitor -{ - private const string QueryFilterPrefix = "ef_filter"; - - private readonly IParameterValues _parameterValues; - private readonly IDiagnosticsLogger _logger; - private readonly bool _parameterize; - private readonly bool _generateContextAccessors; - private readonly EvaluatableExpressionFindingExpressionVisitor _evaluatableExpressionFindingExpressionVisitor; - private readonly ContextParameterReplacingExpressionVisitor _contextParameterReplacingExpressionVisitor; - - private readonly Dictionary _evaluatedValues = new(ExpressionEqualityComparer.Instance); - - private IDictionary _evaluatableExpressions; - private IQueryProvider? _currentQueryProvider; - - /// - /// 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. - /// - public ParameterExtractingExpressionVisitor( - IEvaluatableExpressionFilter evaluatableExpressionFilter, - IParameterValues parameterValues, - Type contextType, - IModel model, - IDiagnosticsLogger logger, - bool parameterize, - bool generateContextAccessors) - { - _evaluatableExpressionFindingExpressionVisitor - = new EvaluatableExpressionFindingExpressionVisitor(evaluatableExpressionFilter, model, parameterize); - _parameterValues = parameterValues; - _logger = logger; - _parameterize = parameterize; - _generateContextAccessors = generateContextAccessors; - // The entry method will take care of populating this field always. So accesses should be safe. - _evaluatableExpressions = null!; - _contextParameterReplacingExpressionVisitor = _generateContextAccessors - ? new ContextParameterReplacingExpressionVisitor(contextType) - : null!; - } - - /// - /// 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. - /// - public virtual Expression ExtractParameters(Expression expression) - => ExtractParameters(expression, clearEvaluatedValues: true); - - /// - /// 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. - /// - public virtual Expression ExtractParameters(Expression expression, bool clearEvaluatedValues) - { - var oldEvaluatableExpressions = _evaluatableExpressions; - _evaluatableExpressions = _evaluatableExpressionFindingExpressionVisitor.Find(expression); - - try - { - return Visit(expression); - } - finally - { - _evaluatableExpressions = oldEvaluatableExpressions; - if (clearEvaluatedValues) - { - _evaluatedValues.Clear(); - } - } - } - - /// - /// 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. - /// - [return: NotNullIfNotNull("expression")] - public override Expression? Visit(Expression? expression) - { - if (expression == null) - { - return null; - } - - if (_evaluatableExpressions.TryGetValue(expression, out var generateParameter) - && !PreserveInitializationConstant(expression, generateParameter) - && !PreserveConvertNode(expression)) - { - return Evaluate(expression, _parameterize && generateParameter); - } - - return base.Visit(expression); - } - - private static bool PreserveInitializationConstant(Expression expression, bool generateParameter) - => !generateParameter && expression is NewExpression or MemberInitExpression; - - private bool PreserveConvertNode(Expression expression) - { - if (expression is UnaryExpression unaryExpression - && (unaryExpression.NodeType == ExpressionType.Convert - || unaryExpression.NodeType == ExpressionType.ConvertChecked)) - { - if (unaryExpression.Type == typeof(object) - || unaryExpression.Type == typeof(Enum) - || unaryExpression.Operand.Type.UnwrapNullableType().IsEnum) - { - return true; - } - - var innerType = unaryExpression.Operand.Type.UnwrapNullableType(); - if (unaryExpression.Type.UnwrapNullableType() == typeof(int) - && (innerType == typeof(byte) - || innerType == typeof(sbyte) - || innerType == typeof(char) - || innerType == typeof(short) - || innerType == typeof(ushort))) - { - return true; - } - - return PreserveConvertNode(unaryExpression.Operand); - } - - return false; - } - - /// - /// 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. - /// - protected override Expression VisitConditional(ConditionalExpression conditionalExpression) - { - var newTestExpression = TryGetConstantValue(conditionalExpression.Test) ?? Visit(conditionalExpression.Test); - - if (newTestExpression is ConstantExpression { Value: bool constantTestValue }) - { - return constantTestValue - ? Visit(conditionalExpression.IfTrue) - : Visit(conditionalExpression.IfFalse); - } - - return conditionalExpression.Update( - newTestExpression, - Visit(conditionalExpression.IfTrue), - Visit(conditionalExpression.IfFalse)); - } - - /// - /// 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. - /// - protected override Expression VisitMethodCall(MethodCallExpression methodCallExpression) - { - // If this is a call to EF.Constant(), or EF.Parameter(), then examine the operand; it it's isn't evaluatable (i.e. contains a - // reference to a database table), throw immediately. Otherwise, evaluate the operand (either as a constant or as a parameter) and - // return that. - if (methodCallExpression.Method.DeclaringType == typeof(EF)) - { - switch (methodCallExpression.Method.Name) - { - case nameof(EF.Constant): - { - var operand = methodCallExpression.Arguments[0]; - if (!_evaluatableExpressions.TryGetValue(operand, out _)) - { - throw new InvalidOperationException("The EF.Constant<> method may only be used with an argument that can be evaluated client-side and does not contain any reference to database-side entities."); - } - - return Evaluate(operand, generateParameter: false); - } - - case nameof(EF.Parameter): - { - var operand = methodCallExpression.Arguments[0]; - if (!_evaluatableExpressions.TryGetValue(operand, out _)) - { - throw new InvalidOperationException("The EF.Constant<> method may only be used with an argument that can be evaluated client-side and does not contain any reference to database-side entities."); - } - - return Evaluate(operand, generateParameter: true); - } - } - } - - return base.VisitMethodCall(methodCallExpression); - } - - /// - /// 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. - /// - protected override Expression VisitBinary(BinaryExpression binaryExpression) - { - switch (binaryExpression.NodeType) - { - case ExpressionType.Coalesce: - { - var newLeftExpression = TryGetConstantValue(binaryExpression.Left) ?? Visit(binaryExpression.Left); - if (newLeftExpression is ConstantExpression constantLeftExpression) - { - return constantLeftExpression.Value == null - ? Visit(binaryExpression.Right) - : newLeftExpression; - } - - return binaryExpression.Update( - newLeftExpression, - binaryExpression.Conversion, - Visit(binaryExpression.Right)); - } - - case ExpressionType.AndAlso: - case ExpressionType.OrElse: - { - var newLeftExpression = TryGetConstantValue(binaryExpression.Left) ?? Visit(binaryExpression.Left); - if (ShortCircuitLogicalExpression(newLeftExpression, binaryExpression.NodeType)) - { - return newLeftExpression; - } - - var newRightExpression = TryGetConstantValue(binaryExpression.Right) ?? Visit(binaryExpression.Right); - return ShortCircuitLogicalExpression(newRightExpression, binaryExpression.NodeType) - ? newRightExpression - : binaryExpression.Update(newLeftExpression, binaryExpression.Conversion, newRightExpression); - } - - default: - return base.VisitBinary(binaryExpression); - } - } - - private Expression? TryGetConstantValue(Expression expression) - { - if (_evaluatableExpressions.ContainsKey(expression)) - { - var value = GetValue(expression, out _); - - if (value is bool) - { - return Expression.Constant(value, typeof(bool)); - } - } - - return null; - } - - private static bool ShortCircuitLogicalExpression(Expression expression, ExpressionType nodeType) - => expression is ConstantExpression { Value: bool constantValue } - && ((constantValue && nodeType == ExpressionType.OrElse) - || (!constantValue && nodeType == ExpressionType.AndAlso)); - - /// - /// 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. - /// - protected override Expression VisitExtension(Expression extensionExpression) - { - if (extensionExpression is QueryRootExpression queryRootExpression) - { - var queryProvider = queryRootExpression.QueryProvider; - if (_currentQueryProvider == null) - { - _currentQueryProvider = queryProvider; - } - else if (!ReferenceEquals(queryProvider, _currentQueryProvider)) - { - throw new InvalidOperationException(CoreStrings.ErrorInvalidQueryable); - } - - // Visit after detaching query provider since custom query roots can have additional components - extensionExpression = queryRootExpression.DetachQueryProvider(); - } - - return base.VisitExtension(extensionExpression); - } - - private static Expression GenerateConstantExpression(object? value, Type returnType) - { - var constantExpression = Expression.Constant(value, value?.GetType() ?? returnType); - - return constantExpression.Type != returnType - ? Expression.Convert(constantExpression, returnType) - : constantExpression; - } - - private Expression Evaluate(Expression expression, bool generateParameter) - { - object? parameterValue; - string? parameterName; - if (_evaluatedValues.TryGetValue(expression, out var cachedValue)) - { - // The _generateContextAccessors condition allows us to reuse parameter expressions evaluated in query filters. - // In principle, _generateContextAccessors is orthogonal to query filters, but in practice it is only used in the - // nav expansion query filters (and defining query). If this changes in future, they would need to be decoupled. - var existingExpression = generateParameter || _generateContextAccessors - ? cachedValue.Parameter - : cachedValue.Constant; - - if (existingExpression != null) - { - return existingExpression; - } - - parameterValue = cachedValue.Value; - parameterName = cachedValue.CandidateParameterName; - } - else - { - parameterValue = GetValue(expression, out parameterName); - cachedValue = new EvaluatedValues { CandidateParameterName = parameterName, Value = parameterValue }; - _evaluatedValues[expression] = cachedValue; - } - - if (parameterValue is IQueryable innerQueryable) - { - return ExtractParameters(innerQueryable.Expression, clearEvaluatedValues: false); - } - - if (parameterName?.StartsWith(QueryFilterPrefix, StringComparison.Ordinal) != true) - { - if (parameterValue is Expression innerExpression) - { - return ExtractParameters(innerExpression, clearEvaluatedValues: false); - } - - if (!generateParameter) - { - var constantValue = GenerateConstantExpression(parameterValue, expression.Type); - - cachedValue.Constant = constantValue; - - return constantValue; - } - } - - parameterName ??= "p"; - - if (string.Equals(QueryFilterPrefix, parameterName, StringComparison.Ordinal)) - { - parameterName = QueryFilterPrefix + "__p"; - } - - var compilerPrefixIndex - = parameterName.LastIndexOf(">", StringComparison.Ordinal); - - if (compilerPrefixIndex != -1) - { - parameterName = parameterName[(compilerPrefixIndex + 1)..]; - } - - parameterName - = QueryCompilationContext.QueryParameterPrefix - + parameterName - + "_" - + _parameterValues.ParameterValues.Count; - - _parameterValues.AddParameter(parameterName, parameterValue); - - var parameter = Expression.Parameter(expression.Type, parameterName); - - cachedValue.Parameter = parameter; - - return parameter; - } - - private sealed class ContextParameterReplacingExpressionVisitor : ExpressionVisitor - { - private readonly Type _contextType; - - public ContextParameterReplacingExpressionVisitor(Type contextType) - { - ContextParameterExpression = Expression.Parameter(contextType, "context"); - _contextType = contextType; - } - - public ParameterExpression ContextParameterExpression { get; } - - [return: NotNullIfNotNull("expression")] - public override Expression? Visit(Expression? expression) - => expression?.Type != typeof(object) - && expression?.Type.IsAssignableFrom(_contextType) == true - ? ContextParameterExpression - : base.Visit(expression); - } - - private static Expression RemoveConvert(Expression expression) - { - if (expression is UnaryExpression unaryExpression - && expression.NodeType is ExpressionType.Convert or ExpressionType.ConvertChecked) - { - return RemoveConvert(unaryExpression.Operand); - } - - return expression; - } - - private object? GetValue(Expression? expression, out string? parameterName) - { - parameterName = null; - - if (expression == null) - { - return null; - } - - if (_generateContextAccessors) - { - var newExpression = _contextParameterReplacingExpressionVisitor.Visit(expression); - - if (newExpression != expression) - { - if (newExpression.Type is IQueryable) - { - return newExpression; - } - - parameterName = QueryFilterPrefix - + (RemoveConvert(expression) is MemberExpression memberExpression - ? ("__" + memberExpression.Member.Name) - : ""); - - return Expression.Lambda( - newExpression, - _contextParameterReplacingExpressionVisitor.ContextParameterExpression); - } - } - - switch (expression) - { - case MemberExpression memberExpression: - var instanceValue = GetValue(memberExpression.Expression, out parameterName); - try - { - switch (memberExpression.Member) - { - case FieldInfo fieldInfo: - parameterName = (parameterName != null ? parameterName + "_" : "") + fieldInfo.Name; - return fieldInfo.GetValue(instanceValue); - - case PropertyInfo propertyInfo: - parameterName = (parameterName != null ? parameterName + "_" : "") + propertyInfo.Name; - return propertyInfo.GetValue(instanceValue); - } - } - catch - { - // Try again when we compile the delegate - } - - break; - - case ConstantExpression constantExpression: - return constantExpression.Value; - - case MethodCallExpression methodCallExpression: - parameterName = methodCallExpression.Method.Name; - break; - - case UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } unaryExpression - when (unaryExpression.Type.UnwrapNullableType() == unaryExpression.Operand.Type): - return GetValue(unaryExpression.Operand, out parameterName); - } - - try - { - return Expression.Lambda>( - Expression.Convert(expression, typeof(object))) - .Compile(preferInterpretation: true) - .Invoke(); - } - catch (Exception exception) - { - throw new InvalidOperationException( - _logger.ShouldLogSensitiveData() - ? CoreStrings.ExpressionParameterizationExceptionSensitive(expression) - : CoreStrings.ExpressionParameterizationException, - exception); - } - } - - private sealed class EvaluatableExpressionFindingExpressionVisitor : ExpressionVisitor - { - private readonly IEvaluatableExpressionFilter _evaluatableExpressionFilter; - private readonly ISet _allowedParameters = new HashSet(); - private readonly IModel _model; - private readonly bool _parameterize; - - private bool _evaluatable; - private bool _containsClosure; - private bool _inLambda; - private IDictionary _evaluatableExpressions; - - public EvaluatableExpressionFindingExpressionVisitor( - IEvaluatableExpressionFilter evaluatableExpressionFilter, - IModel model, - bool parameterize) - { - _evaluatableExpressionFilter = evaluatableExpressionFilter; - _model = model; - _parameterize = parameterize; - // The entry method will take care of populating this field always. So accesses should be safe. - _evaluatableExpressions = null!; - } - - public IDictionary Find(Expression expression) - { - _evaluatable = true; - _containsClosure = false; - _inLambda = false; - _evaluatableExpressions = new Dictionary(); - _allowedParameters.Clear(); - - Visit(expression); - - return _evaluatableExpressions; - } - - [return: NotNullIfNotNull("expression")] - public override Expression? Visit(Expression? expression) - { - if (expression == null) - { - return base.Visit(expression); - } - - var parentEvaluatable = _evaluatable; - var parentContainsClosure = _containsClosure; - - _evaluatable = IsEvaluatableNodeType(expression, out var preferNoEvaluation) - // Extension point to disable funcletization - && _evaluatableExpressionFilter.IsEvaluatableExpression(expression, _model) - // Don't evaluate QueryableMethods if in compiled query - && (_parameterize || !IsQueryableMethod(expression)); - _containsClosure = false; - - base.Visit(expression); - - if (_evaluatable && !preferNoEvaluation) - { - // Force parameterization when not in lambda - _evaluatableExpressions[expression] = _containsClosure || !_inLambda; - } - - _evaluatable = parentEvaluatable && _evaluatable; - _containsClosure = parentContainsClosure || _containsClosure; - - return expression; - } - - protected override Expression VisitLambda(Expression lambdaExpression) - { - var oldInLambda = _inLambda; - _inLambda = true; - - // Note: Don't skip visiting parameter here. - // SelectMany does not use parameter in lambda but we should still block it from evaluating - base.VisitLambda(lambdaExpression); - - _inLambda = oldInLambda; - return lambdaExpression; - } - - protected override Expression VisitMemberInit(MemberInitExpression memberInitExpression) - { - Visit(memberInitExpression.Bindings, VisitMemberBinding); - - // Cannot make parameter for NewExpression if Bindings cannot be evaluated - // but we still need to visit inside of it. - var bindingsEvaluatable = _evaluatable; - Visit(memberInitExpression.NewExpression); - - if (!bindingsEvaluatable) - { - _evaluatableExpressions.Remove(memberInitExpression.NewExpression); - } - - return memberInitExpression; - } - - protected override Expression VisitListInit(ListInitExpression listInitExpression) - { - Visit(listInitExpression.Initializers, VisitElementInit); - - // Cannot make parameter for NewExpression if Initializers cannot be evaluated - // but we still need to visit inside of it. - var initializersEvaluatable = _evaluatable; - Visit(listInitExpression.NewExpression); - - if (!initializersEvaluatable) - { - _evaluatableExpressions.Remove(listInitExpression.NewExpression); - } - - return listInitExpression; - } - - protected override Expression VisitMethodCall(MethodCallExpression methodCallExpression) - { - Visit(methodCallExpression.Object); - var parameterInfos = methodCallExpression.Method.GetParameters(); - for (var i = 0; i < methodCallExpression.Arguments.Count; i++) - { - if (i == 1 - && _evaluatableExpressions.ContainsKey(methodCallExpression.Arguments[0]) - && methodCallExpression.Method.DeclaringType == typeof(Enumerable) - && methodCallExpression.Method.Name == nameof(Enumerable.Select) - && methodCallExpression.Arguments[1] is LambdaExpression lambdaExpression) - { - // Allow evaluation Enumerable.Select operation - foreach (var parameter in lambdaExpression.Parameters) - { - _allowedParameters.Add(parameter); - } - } - - Visit(methodCallExpression.Arguments[i]); - - if (_evaluatableExpressions.ContainsKey(methodCallExpression.Arguments[i]) - && (parameterInfos[i].GetCustomAttribute() != null - || _model.IsIndexerMethod(methodCallExpression.Method))) - { - _evaluatableExpressions[methodCallExpression.Arguments[i]] = false; - } - } - - return methodCallExpression; - } - - protected override Expression VisitMember(MemberExpression memberExpression) - { - _containsClosure = memberExpression.Expression != null - || !(memberExpression.Member is FieldInfo { IsInitOnly: true }); - return base.VisitMember(memberExpression); - } - - protected override Expression VisitParameter(ParameterExpression parameterExpression) - { - _evaluatable = _allowedParameters.Contains(parameterExpression); - - return base.VisitParameter(parameterExpression); - } - - protected override Expression VisitConstant(ConstantExpression constantExpression) - { - _evaluatable = !(constantExpression.Value is IQueryable); - -#pragma warning disable RCS1096 // Use bitwise operation instead of calling 'HasFlag'. - _containsClosure - = (constantExpression.Type.Attributes.HasFlag(TypeAttributes.NestedPrivate) - && Attribute.IsDefined(constantExpression.Type, typeof(CompilerGeneratedAttribute), inherit: true)) // Closure - || constantExpression.Type == typeof(ValueBuffer); // Find method -#pragma warning restore RCS1096 // Use bitwise operation instead of calling 'HasFlag'. - - return base.VisitConstant(constantExpression); - } - - private static bool IsEvaluatableNodeType(Expression expression, out bool preferNoEvaluation) - { - switch (expression.NodeType) - { - case ExpressionType.NewArrayInit: - preferNoEvaluation = true; - return true; - - case ExpressionType.Extension: - preferNoEvaluation = false; - return expression.CanReduce && IsEvaluatableNodeType(expression.ReduceAndCheck(), out preferNoEvaluation); - - // Identify a call to EF.Constant(), and flag that as non-evaluable. - // This is important to prevent a larger subtree containing EF.Constant from being evaluated, i.e. to make sure that - // the EF.Function argument is present in the tree as its own, constant node. - case ExpressionType.Call - when expression is MethodCallExpression { Method: var method } - && method.DeclaringType == typeof(EF) - && method.Name is nameof(EF.Constant) or nameof(EF.Parameter): - preferNoEvaluation = true; - return false; - - default: - preferNoEvaluation = false; - return true; - } - } - - private static bool IsQueryableMethod(Expression expression) - => expression is MethodCallExpression methodCallExpression - && methodCallExpression.Method.DeclaringType == typeof(Queryable); - } - - private sealed class EvaluatedValues - { - public string? CandidateParameterName { get; init; } - public object? Value { get; init; } - public Expression? Constant { get; set; } - public Expression? Parameter { get; set; } - } -} - -internal static class SharedTypeExtensions -{ - public static Type UnwrapNullableType(this Type type) - => Nullable.GetUnderlyingType(type) ?? type; -} \ No newline at end of file diff --git a/tests/Clave.Expressionify.Generator.Tests/Clave.Expressionify.Generator.Tests.csproj b/tests/Clave.Expressionify.Generator.Tests/Clave.Expressionify.Generator.Tests.csproj index 0f60341..8346703 100644 --- a/tests/Clave.Expressionify.Generator.Tests/Clave.Expressionify.Generator.Tests.csproj +++ b/tests/Clave.Expressionify.Generator.Tests/Clave.Expressionify.Generator.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 latest enable false diff --git a/tests/Clave.Expressionify.Tests/Clave.Expressionify.Tests.csproj b/tests/Clave.Expressionify.Tests/Clave.Expressionify.Tests.csproj index 3efd26e..d89ade3 100644 --- a/tests/Clave.Expressionify.Tests/Clave.Expressionify.Tests.csproj +++ b/tests/Clave.Expressionify.Tests/Clave.Expressionify.Tests.csproj @@ -1,14 +1,14 @@  - net9.0 + net10.0 latest enable false - + diff --git a/tests/Clave.Expressionify.Tests/DbContextExtensions/Tests.cs b/tests/Clave.Expressionify.Tests/DbContextExtensions/Tests.cs index 751ce5a..66b5289 100644 --- a/tests/Clave.Expressionify.Tests/DbContextExtensions/Tests.cs +++ b/tests/Clave.Expressionify.Tests/DbContextExtensions/Tests.cs @@ -37,7 +37,7 @@ public void UseExpressionifyInQueryAndConfig_ExpandsExpression_CanTranslate() var query = dbContext.TestEntities.Expressionify().Select(e => e.GetName(prefix)); var sql = query.ToQueryString(); - sql.ShouldBe($".param set @__p_0 'oh hi '{Environment.NewLine}{Environment.NewLine}SELECT @__p_0 || \"t\".\"Name\"{Environment.NewLine}FROM \"TestEntities\" AS \"t\""); + sql.ShouldBe($".param set @p 'oh hi '{Environment.NewLine}{Environment.NewLine}SELECT @p || \"t\".\"Name\"{Environment.NewLine}FROM \"TestEntities\" AS \"t\""); } [Test] @@ -59,7 +59,7 @@ public void Expressionify_ShouldHandleWhereWithParameters_AfterExpansion() using var dbContext = new TestDbContext(GetOptions()); var query = dbContext.TestEntities.Expressionify().Where(e => e.IsSomething()); - query.ToQueryString().ShouldBe($".param set @__Name_0 'Something'{Environment.NewLine}{Environment.NewLine}SELECT \"t\".\"Id\", \"t\".\"Created\", \"t\".\"Name\"{Environment.NewLine}FROM \"TestEntities\" AS \"t\"{Environment.NewLine}WHERE \"t\".\"Name\" = @__Name_0"); + query.ToQueryString().ShouldBe($".param set @Name 'Something'{Environment.NewLine}{Environment.NewLine}SELECT \"t\".\"Id\", \"t\".\"Created\", \"t\".\"Name\"{Environment.NewLine}FROM \"TestEntities\" AS \"t\"{Environment.NewLine}WHERE \"t\".\"Name\" = @Name"); } [TestCase(ExpressionEvaluationMode.FullCompatibilityButSlow)] @@ -80,7 +80,7 @@ public void UseExpressionify_ShouldHandleWhereWithParameters(ExpressionEvaluatio using var dbContext = new TestDbContext(GetOptions(o => o.WithEvaluationMode(mode))); var query = dbContext.TestEntities.Where(e => e.NameEquals(name)); - query.ToQueryString().ShouldBe($".param set @__name_0 'oh hi'{Environment.NewLine}{Environment.NewLine}SELECT \"t\".\"Id\", \"t\".\"Created\", \"t\".\"Name\"{Environment.NewLine}FROM \"TestEntities\" AS \"t\"{Environment.NewLine}WHERE \"t\".\"Name\" = @__name_0"); + query.ToQueryString().ShouldBe($".param set @name 'oh hi'{Environment.NewLine}{Environment.NewLine}SELECT \"t\".\"Id\", \"t\".\"Created\", \"t\".\"Name\"{Environment.NewLine}FROM \"TestEntities\" AS \"t\"{Environment.NewLine}WHERE \"t\".\"Name\" = @name"); } [Test] @@ -89,7 +89,7 @@ public void UseExpressionify_EvaluationModeAlways_ShouldHandleWhereWithNewParame using var dbContext = new TestDbContext(GetOptions(optionsAction: o => o.WithEvaluationMode(ExpressionEvaluationMode.FullCompatibilityButSlow))); var query = dbContext.TestEntities.Where(e => e.IsSomething()); - query.ToQueryString().ShouldBe($".param set @__Name_0 'Something'{Environment.NewLine}{Environment.NewLine}SELECT \"t\".\"Id\", \"t\".\"Created\", \"t\".\"Name\"{Environment.NewLine}FROM \"TestEntities\" AS \"t\"{Environment.NewLine}WHERE \"t\".\"Name\" = @__Name_0"); + query.ToQueryString().ShouldBe($".param set @Name 'Something'{Environment.NewLine}{Environment.NewLine}SELECT \"t\".\"Id\", \"t\".\"Created\", \"t\".\"Name\"{Environment.NewLine}FROM \"TestEntities\" AS \"t\"{Environment.NewLine}WHERE \"t\".\"Name\" = @Name"); } [Test] @@ -98,7 +98,7 @@ public void UseExpressionify_EvaluationModeAlways_ShouldHandleWhereWithExternalS using var dbContext = new TestDbContext(GetOptions(optionsAction: o => o.WithEvaluationMode(ExpressionEvaluationMode.FullCompatibilityButSlow))); var query = dbContext.TestEntities.Where(e => e.IsRecent()); - query.ToQueryString().ShouldBe($".param set @__AddDays_0 '2022-03-03 05:06:07'{Environment.NewLine}{Environment.NewLine}SELECT \"t\".\"Id\", \"t\".\"Created\", \"t\".\"Name\"{Environment.NewLine}FROM \"TestEntities\" AS \"t\"{Environment.NewLine}WHERE \"t\".\"Created\" > @__AddDays_0"); + query.ToQueryString().ShouldBe($".param set @AddDays '2022-03-03 05:06:07'{Environment.NewLine}{Environment.NewLine}SELECT \"t\".\"Id\", \"t\".\"Created\", \"t\".\"Name\"{Environment.NewLine}FROM \"TestEntities\" AS \"t\"{Environment.NewLine}WHERE \"t\".\"Created\" > @AddDays"); } [Test] @@ -108,7 +108,7 @@ public void UseExpressionify_EvaluationModeCached_CannotHandleNewParameters() var query = dbContext.TestEntities.Where(e => e.IsSomething()); var exception = Should.Throw(() => query.ToQueryString()); - exception.Message.ShouldBe("Accessing parameters in a cached query context is not allowed. Explicitly call .Expressionify() on the query or use ExpressionEvaluationMode.FullCompatibilityButSlow."); + exception.Message.ShouldBe("Adding parameters in a cached query context is not allowed. Explicitly call .Expressionify() on the query or use ExpressionEvaluationMode.FullCompatibilityButSlow."); } [Test] @@ -118,7 +118,7 @@ public void UseExpressionify_EvaluationModeCached_CannotHandleParametersFromExte var query = dbContext.TestEntities.Where(e => e.IsSomething()); var exception = Should.Throw(() => query.ToQueryString()); - exception.Message.ShouldBe("Accessing parameters in a cached query context is not allowed. Explicitly call .Expressionify() on the query or use ExpressionEvaluationMode.FullCompatibilityButSlow."); + exception.Message.ShouldBe("Adding parameters in a cached query context is not allowed. Explicitly call .Expressionify() on the query or use ExpressionEvaluationMode.FullCompatibilityButSlow."); } [Test] @@ -153,16 +153,6 @@ public void UseExpressionify_EvaluationModeAlways_ShouldHandleEvaluatableExpress query.ToQueryString().ShouldBe($"SELECT \"t\".\"Name\", NULL AS \"Street\"{Environment.NewLine}FROM \"TestEntities\" AS \"t\""); } - [Test] - public void UseExpressionify_EvaluationModeCached_CannotHandleEvaluatableExpressions() - { - using var dbContext = new TestDbContext(GetOptions(o => o.WithEvaluationMode(ExpressionEvaluationMode.LimitedCompatibilityButCached))); - var query = dbContext.TestEntities.Select(e => e.ToTestView(null)); - - var exception = Should.Throw(() => query.ToQueryString()); - exception.Message.ShouldBe("Accessing parameters in a cached query context is not allowed. Explicitly call .Expressionify() on the query or use ExpressionEvaluationMode.FullCompatibilityButSlow."); - } - [TestCase(ExpressionEvaluationMode.FullCompatibilityButSlow)] [TestCase(ExpressionEvaluationMode.LimitedCompatibilityButCached)] public void UseExpressionify_WithEvaluationMode_SetsEvaluationMode(ExpressionEvaluationMode mode)