| /* |
| * Licensed to the Apache Software Foundation (ASF) under one or more |
| * contributor license agreements. See the NOTICE file distributed with |
| * this work for additional information regarding copyright ownership. |
| * The ASF licenses this file to You under the Apache License, Version 2.0 |
| * (the "License"); you may not use this file except in compliance with |
| * the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| namespace Apache.Ignite.Internal.Linq; |
| |
| using System; |
| using System.Collections.Concurrent; |
| using System.Collections.Generic; |
| using System.ComponentModel.DataAnnotations.Schema; |
| using System.Globalization; |
| using System.Linq; |
| using System.Linq.Expressions; |
| using System.Reflection; |
| using System.Text; |
| using Common; |
| using Remotion.Linq; |
| using Remotion.Linq.Clauses; |
| using Remotion.Linq.Clauses.Expressions; |
| using Remotion.Linq.Clauses.ResultOperators; |
| using Remotion.Linq.Parsing; |
| using Sql; |
| using Table.Serialization; |
| |
| /// <summary> |
| /// Expression visitor, transforms query subexpressions (such as Where clauses) to SQL. |
| /// </summary> |
| internal sealed class IgniteQueryExpressionVisitor : ThrowingExpressionVisitor |
| { |
| /** */ |
| private static readonly ConcurrentDictionary<MemberInfo, string> ColumnNameMap = new(); |
| |
| /** */ |
| private readonly bool _useStar; |
| |
| /** */ |
| private readonly IgniteQueryModelVisitor _modelVisitor; |
| |
| // ReSharper disable once NotAccessedField.Local |
| private readonly bool _includeAllFields; |
| |
| /** */ |
| private readonly bool _visitEntireSubQueryModel; |
| |
| /** */ |
| private readonly bool _columnNameWithoutTable; |
| |
| /// <summary> |
| /// Initializes a new instance of the <see cref="IgniteQueryExpressionVisitor" /> class. |
| /// </summary> |
| /// <param name="modelVisitor">The _model visitor.</param> |
| /// <param name="useStar"> |
| /// Flag indicating that star '*' qualifier should be used |
| /// for the whole-table select instead of _key, _val. |
| /// </param> |
| /// <param name="includeAllFields"> |
| /// Flag indicating that star '*' qualifier should be used |
| /// for the whole-table select as well as _key, _val. |
| /// </param> |
| /// <param name="visitEntireSubQueryModel"> |
| /// Flag indicating that subquery should be visited as full query. |
| /// </param> |
| /// <param name="columnNameWithoutTable">Whether to append column names without table name.</param> |
| public IgniteQueryExpressionVisitor( |
| IgniteQueryModelVisitor modelVisitor, |
| bool useStar, |
| bool includeAllFields, |
| bool visitEntireSubQueryModel, |
| bool columnNameWithoutTable) |
| { |
| _modelVisitor = modelVisitor; |
| _useStar = useStar; |
| _includeAllFields = includeAllFields; |
| _visitEntireSubQueryModel = visitEntireSubQueryModel; |
| _columnNameWithoutTable = columnNameWithoutTable; |
| } |
| |
| /// <summary> |
| /// Gets the result builder. |
| /// </summary> |
| public StringBuilder ResultBuilder => _modelVisitor.Builder; |
| |
| /// <summary> |
| /// Gets the parameters. |
| /// </summary> |
| public IList<object?> Parameters => _modelVisitor.Parameters; |
| |
| /// <summary> |
| /// Gets the aliases. |
| /// </summary> |
| private AliasDictionary Aliases => _modelVisitor.Aliases; |
| |
| /** <inheritdoc /> */ |
| public override Expression? Visit(Expression? expression) |
| { |
| if (expression is ParameterExpression paramExpr) |
| { |
| // This happens only with compiled queries, where parameters come from enclosing lambda. |
| AppendParameter(paramExpr); |
| return expression; |
| } |
| |
| return base.Visit(expression); |
| } |
| |
| /// <summary> |
| /// Appends a parameter. |
| /// </summary> |
| /// <param name="value">Parameter value.</param> |
| public void AppendParameter(object? value) |
| { |
| ResultBuilder.Append('?'); |
| |
| if (value is char) |
| { |
| // Pass char params as string - protocol does not support char. |
| value = value.ToString(); |
| } |
| |
| _modelVisitor.Parameters.Add(value); |
| } |
| |
| /** <inheritdoc /> */ |
| protected override Expression VisitBinary(BinaryExpression expression) |
| { |
| // Either func or operator |
| if (VisitBinaryFunc(expression)) |
| { |
| return expression; |
| } |
| |
| ResultBuilder.Append('('); |
| |
| Visit(expression.Left); |
| |
| switch (expression.NodeType) |
| { |
| case ExpressionType.Equal: |
| { |
| // Use `IS [NOT] DISTINCT FROM` for correct null comparison semantics. |
| // E.g. when user says `.Where(x => x == null)`, it should work, but with `=` it does not. |
| ResultBuilder.Append(" IS NOT DISTINCT FROM "); |
| |
| break; |
| } |
| |
| case ExpressionType.NotEqual: |
| { |
| ResultBuilder.Append(" IS DISTINCT FROM "); |
| |
| break; |
| } |
| |
| case ExpressionType.AndAlso: |
| case ExpressionType.And: |
| ResultBuilder.Append(" and "); |
| break; |
| |
| case ExpressionType.OrElse: |
| case ExpressionType.Or: |
| ResultBuilder.Append(" or "); |
| break; |
| |
| case ExpressionType.Add: |
| ResultBuilder.Append(" + "); |
| break; |
| |
| case ExpressionType.Subtract: |
| ResultBuilder.Append(" - "); |
| break; |
| |
| case ExpressionType.Multiply: |
| ResultBuilder.Append(" * "); |
| break; |
| |
| case ExpressionType.Modulo: |
| ResultBuilder.Append(" % "); |
| break; |
| |
| case ExpressionType.Divide: |
| ResultBuilder.Append(" / "); |
| break; |
| |
| case ExpressionType.GreaterThan: |
| ResultBuilder.Append(" > "); |
| break; |
| |
| case ExpressionType.GreaterThanOrEqual: |
| ResultBuilder.Append(" >= "); |
| break; |
| |
| case ExpressionType.LessThan: |
| ResultBuilder.Append(" < "); |
| break; |
| |
| case ExpressionType.LessThanOrEqual: |
| ResultBuilder.Append(" <= "); |
| break; |
| |
| case ExpressionType.Coalesce: |
| break; |
| |
| default: |
| base.VisitBinary(expression); |
| break; |
| } |
| |
| Visit(expression.Right); |
| ResultBuilder.TrimEnd().Append(')'); |
| |
| return expression; |
| } |
| |
| /** <inheritdoc /> */ |
| protected override Expression VisitQuerySourceReference(QuerySourceReferenceExpression expression) |
| { |
| // In some cases of Join clause different handling should be introduced |
| var joinClause = expression.ReferencedQuerySource as JoinClause; |
| if (joinClause != null && ExpressionWalker.GetIgniteQueryable(expression, false) == null) |
| { |
| var tableName = Aliases.GetTableAlias(expression); |
| var fieldName = Aliases.GetFieldAlias(expression); |
| |
| ResultBuilder.AppendFormat(CultureInfo.InvariantCulture, "{0}.{1}", tableName, fieldName); |
| } |
| else if (joinClause is { InnerSequence: SubQueryExpression }) |
| { |
| var subQueryExpression = (SubQueryExpression) joinClause.InnerSequence; |
| base.Visit(subQueryExpression.QueryModel.SelectClause.Selector); |
| } |
| else |
| { |
| if (_includeAllFields || _useStar) |
| { |
| ResultBuilder.Append('*'); |
| } |
| else |
| { |
| var tableName = Aliases.GetTableAlias(expression); |
| |
| AppendColumnNames(expression.ReferencedQuerySource.ItemType, tableName); |
| } |
| } |
| |
| return expression; |
| } |
| |
| /** <inheritdoc /> */ |
| protected override Expression VisitMember(MemberExpression expression) |
| { |
| // Field hierarchy is flattened (Person.Address.Street is just Street), append as is, do not call Visit. |
| |
| // Property call (string.Length, DateTime.Month, etc). |
| if (MethodVisitor.VisitPropertyCall(expression, this)) |
| { |
| return expression; |
| } |
| |
| // Special case: grouping |
| if (VisitGroupByMember(expression.Expression)) |
| { |
| return expression; |
| } |
| |
| var queryable = ExpressionWalker.GetIgniteQueryable(expression, false); |
| |
| if (queryable != null) |
| { |
| // Find where the projection comes from. |
| expression = ExpressionWalker.GetProjectedMember(expression.Expression!, expression.Member) ?? expression; |
| |
| AppendColumnName(expression, Aliases.GetTableAlias(expression)); |
| } |
| else |
| { |
| AppendParameter(ExpressionWalker.EvaluateExpression<object>(expression)); |
| } |
| |
| return expression; |
| } |
| |
| /** <inheritdoc /> */ |
| protected override Expression VisitMemberInit(MemberInitExpression expression) |
| { |
| var first = true; |
| |
| if (expression.NewExpression.Arguments.Count != 0) |
| { |
| VisitNew(expression.NewExpression); |
| first = false; |
| } |
| |
| foreach (var memberBinding in expression.Bindings) |
| { |
| if (!first) |
| { |
| if (_useStar) |
| { |
| throw new NotSupportedException("Aggregate functions do not support multiple fields"); |
| } |
| |
| ResultBuilder.TrimEnd().Append(", "); |
| } |
| |
| if (memberBinding is not MemberAssignment arg) |
| { |
| throw new NotSupportedException($"{memberBinding.BindingType} binding type is not supported"); |
| } |
| |
| first = false; |
| |
| Visit(arg.Expression); |
| |
| ResultBuilder.AppendWithSpace("as "); |
| ResultBuilder.Append(arg.Member.Name.ToUpperInvariant()); |
| } |
| |
| return expression; |
| } |
| |
| /** <inheritdoc /> */ |
| protected override Expression VisitConstant(ConstantExpression expression) |
| { |
| if (MethodVisitor.VisitConstantCall(expression, this)) |
| { |
| return expression; |
| } |
| |
| AppendParameter(expression.Value); |
| |
| return expression; |
| } |
| |
| /** <inheritdoc /> */ |
| protected override Expression VisitMethodCall(MethodCallExpression expression) |
| { |
| MethodVisitor.VisitMethodCall(expression, this); |
| |
| return expression; |
| } |
| |
| /** <inheritdoc /> */ |
| protected override Expression VisitNew(NewExpression expression) |
| { |
| var first = true; |
| |
| for (var i = 0; i < expression.Arguments.Count; i++) |
| { |
| var arg = expression.Arguments[i]; |
| if (!first) |
| { |
| if (_useStar) |
| { |
| throw new NotSupportedException("Aggregate functions do not support multiple fields"); |
| } |
| |
| ResultBuilder.TrimEnd().Append(", "); |
| } |
| |
| first = false; |
| |
| Visit(arg); |
| |
| // When projection uses projection comes from a complex expression, append an alias. |
| var param = expression.Members?[i]; |
| if (param != null && arg is not MemberExpression) |
| { |
| ResultBuilder.AppendWithSpace("as ").Append(param.Name.ToUpperInvariant()); |
| } |
| } |
| |
| return expression; |
| } |
| |
| /** <inheritdoc /> */ |
| protected override Expression VisitInvocation(InvocationExpression expression) => |
| throw new NotSupportedException( |
| "The LINQ expression '" + expression + |
| "' could not be translated. Either rewrite the query in a form that can be translated, " + |
| "or switch to client evaluation explicitly by inserting a call to either AsEnumerable() or ToList()."); |
| |
| /** <inheritdoc /> */ |
| protected override Expression VisitConditional(ConditionalExpression expression) |
| { |
| ResultBuilder.Append("case when("); |
| |
| Visit(expression.Test); |
| |
| ResultBuilder.Append(") then "); |
| Visit(expression.IfTrue); |
| |
| ResultBuilder.Append(" else "); |
| Visit(expression.IfFalse); |
| |
| ResultBuilder.Append(" end"); |
| |
| return expression; |
| } |
| |
| /** <inheritdoc /> */ |
| protected override Expression VisitSubQuery(SubQueryExpression expression) |
| { |
| var subQueryModel = expression.QueryModel; |
| |
| var contains = subQueryModel.ResultOperators.FirstOrDefault() as ContainsResultOperator; |
| |
| // Check if IEnumerable.Contains is used. |
| if (subQueryModel.ResultOperators.Count == 1 && contains != null) |
| { |
| VisitContains(subQueryModel, contains); |
| } |
| else if (_visitEntireSubQueryModel) |
| { |
| ResultBuilder.Append('('); |
| _modelVisitor.VisitQueryModel(subQueryModel, false, true); |
| ResultBuilder.TrimEnd().Append(')'); |
| } |
| else |
| { |
| // This happens when New expression uses a subquery, in a GroupBy. |
| _modelVisitor.VisitSelectors(expression.QueryModel, false); |
| } |
| |
| return expression; |
| } |
| |
| /** <inheritdoc /> */ |
| protected override Exception CreateUnhandledItemException<T>(T unhandledItem, string visitMethod) => |
| new NotSupportedException($"The expression '{unhandledItem}' (type: {typeof(T)}) is not supported."); |
| |
| /** <inheritdoc /> */ |
| protected override Expression VisitUnary(UnaryExpression expression) |
| { |
| var closeBracket = false; |
| |
| switch (expression.NodeType) |
| { |
| case ExpressionType.Negate: |
| ResultBuilder.Append("(-"); |
| closeBracket = true; |
| break; |
| |
| case ExpressionType.Not: |
| ResultBuilder.Append("(not "); |
| closeBracket = true; |
| break; |
| |
| case ExpressionType.Convert: |
| ResultBuilder.Append("cast("); |
| break; |
| |
| default: |
| return base.VisitUnary(expression); |
| } |
| |
| Visit(expression.Operand); |
| |
| if (closeBracket) |
| { |
| ResultBuilder.TrimEnd().Append(')'); |
| } |
| else if (expression.NodeType is ExpressionType.Convert) |
| { |
| if (expression.Type == typeof(object)) |
| { |
| // Special case for string concatenation. |
| ResultBuilder.Append(" as varchar)"); |
| } |
| else if ((Nullable.GetUnderlyingType(expression.Type) ?? expression.Type) == typeof(decimal)) |
| { |
| // .NET decimal has 28-29 digit precision, Ignite CatalogUtils.MAX_DECIMAL_PRECISION = Short.MAX_VALUE = 32767. |
| // Use (precision, scale) = (60, 30) to avoid rounding errors, but not greater to avoid performance issues. |
| // If we do not specify the scale, SQL engine will use MAX_DECIMAL_SCALE = 32767, |
| // causing unnecessary data transfer and CPU usage for conversion. |
| ResultBuilder.Append(" as decimal(60, 30))"); |
| } |
| else |
| { |
| ResultBuilder |
| .Append(" as ") |
| .Append(expression.Type.ToSqlTypeName()) |
| .Append(')'); |
| } |
| } |
| |
| return expression; |
| } |
| |
| /// <summary> |
| /// Appends the name of the column from a member expression, with quotes when necessary. |
| /// </summary> |
| private void AppendColumnName(MemberExpression expression, string tableName) |
| { |
| if (ColumnNameMap.TryGetValue(expression.Member, out var columnName)) |
| { |
| AppendColumnName(tableName, columnName); |
| |
| return; |
| } |
| |
| if (expression.Member.DeclaringType.IsKeyValuePair()) |
| { |
| AppendColumnNames(((PropertyInfo)expression.Member).PropertyType, tableName); |
| return; |
| } |
| |
| // When there is a [Column] attribute with Name specified, use quoted identifier: exact match, allows whitespace. |
| // Otherwise (most common case), use uppercase non-quoted identifier (case-insensitive). |
| // NOTE: The same logic is used in AppendColumnNames below. |
| columnName = expression.Member.GetCustomAttribute<ColumnAttribute>() is { Name: { } columnAttributeName } |
| ? '"' + columnAttributeName + '"' |
| : expression.Member.Name.ToUpperInvariant(); |
| |
| ColumnNameMap.GetOrAdd(expression.Member, columnName); |
| |
| AppendColumnName(tableName, columnName); |
| } |
| |
| private void AppendColumnName(string tableName, string columnName) |
| { |
| if (!_columnNameWithoutTable) |
| { |
| ResultBuilder.Append(tableName).Append('.'); |
| } |
| |
| ResultBuilder.Append(columnName); |
| } |
| |
| /// <summary> |
| /// Appends column names for all fields in the specified type. |
| /// </summary> |
| /// <param name="type">Type.</param> |
| /// <param name="tableName">Table name.</param> |
| /// <param name="first">Whether this is the first column and does not need a comma before.</param> |
| /// <param name="toSkip">Names to skip.</param> |
| /// <param name="populateToSkip">Whether to populate provided toSkip set.</param> |
| private void AppendColumnNames(Type type, string tableName, bool first = true, HashSet<string>? toSkip = null, bool populateToSkip = false) |
| { |
| if (type.IsPrimitive) |
| { |
| throw new NotSupportedException( |
| $"Primitive types are not supported in LINQ queries: {type}. " + |
| "Use a custom type (class, record, struct) with a single field instead."); |
| } |
| |
| if (type.GetKeyValuePairTypes() is var (keyType, valType)) |
| { |
| var keyColumnNames = new HashSet<string>(); |
| |
| AppendColumnNames(keyType, tableName, first: true, toSkip: keyColumnNames, populateToSkip: true); |
| AppendColumnNames(valType, tableName, first: false, toSkip: keyColumnNames); |
| |
| return; |
| } |
| |
| var columns = type.GetColumns(); |
| |
| if (columns.Count == 0) |
| { |
| throw new NotSupportedException( |
| $"Type '{type}' can not be mapped to SQL columns: it has no fields, or all fields are [NotMapped]."); |
| } |
| |
| foreach (var col in columns) |
| { |
| if (toSkip != null) |
| { |
| if (populateToSkip) |
| { |
| toSkip.Add(col.Name); |
| } |
| else if (toSkip.Contains(col.Name)) |
| { |
| continue; |
| } |
| } |
| |
| if (!first) |
| { |
| ResultBuilder.TrimEnd().Append(", "); |
| } |
| |
| first = false; |
| |
| if (!_columnNameWithoutTable) |
| { |
| ResultBuilder.Append(tableName).Append('.'); |
| } |
| |
| if (col.HasColumnNameAttribute) |
| { |
| // Exact quoted name. |
| ResultBuilder.Append('"').Append(col.Name).Append('"'); |
| } |
| else |
| { |
| // Case-insensitive, unquoted, upper-case name. |
| ResultBuilder.Append(col.Name.ToUpperInvariant()); |
| } |
| } |
| } |
| |
| /// <summary> |
| /// Visits IEnumerable.Contains. |
| /// </summary> |
| private void VisitContains(QueryModel subQueryModel, ContainsResultOperator contains) |
| { |
| ResultBuilder.Append('('); |
| |
| var fromExpression = subQueryModel.MainFromClause.FromExpression; |
| |
| var queryable = ExpressionWalker.GetIgniteQueryable(fromExpression, false); |
| |
| if (queryable != null) |
| { |
| Visit(contains.Item); |
| |
| ResultBuilder.Append(" IN ("); |
| if (_visitEntireSubQueryModel) |
| { |
| _modelVisitor.VisitQueryModel(subQueryModel, false, true); |
| } |
| else |
| { |
| _modelVisitor.VisitQueryModel(subQueryModel); |
| } |
| |
| ResultBuilder.Append(')'); |
| } |
| else |
| { |
| var inValues = ExpressionWalker.EvaluateEnumerableValues(fromExpression).ToArray(); |
| |
| var hasNulls = inValues.Any(o => o == null); |
| |
| if (hasNulls) |
| { |
| ResultBuilder.Append('('); |
| } |
| |
| Visit(contains.Item); |
| |
| ResultBuilder.Append(" IN ("); |
| AppendInParameters(inValues); |
| ResultBuilder.Append(')'); |
| |
| if (hasNulls) |
| { |
| ResultBuilder.Append(") OR "); |
| Visit(contains.Item); |
| ResultBuilder.Append(" IS NULL"); |
| } |
| } |
| |
| ResultBuilder.Append(')'); |
| } |
| |
| /// <summary> |
| /// Appends not null parameters using ", " as delimeter. |
| /// </summary> |
| private void AppendInParameters(IEnumerable<object?> values) |
| { |
| var first = true; |
| |
| foreach (var val in values) |
| { |
| if (val == null) |
| { |
| continue; |
| } |
| |
| if (!first) |
| { |
| ResultBuilder.Append(", "); |
| } |
| |
| first = false; |
| |
| AppendParameter(val); |
| } |
| } |
| |
| /// <summary> |
| /// Visits the group by member. |
| /// </summary> |
| private bool VisitGroupByMember(Expression? expression) |
| { |
| var srcRef = expression as QuerySourceReferenceExpression; |
| if (srcRef == null) |
| { |
| return false; |
| } |
| |
| var from = srcRef.ReferencedQuerySource as IFromClause; |
| if (from == null) |
| { |
| return false; |
| } |
| |
| var subQry = from.FromExpression as SubQueryExpression; |
| if (subQry == null) |
| { |
| return false; |
| } |
| |
| var grpBy = subQry.QueryModel.ResultOperators.OfType<GroupResultOperator>().FirstOrDefault(); |
| if (grpBy == null) |
| { |
| return false; |
| } |
| |
| var (alias, aliasCreated) = Aliases.GetOrCreateGroupByMemberAlias(grpBy); |
| |
| if (aliasCreated) |
| { |
| Visit(grpBy.KeySelector); |
| ResultBuilder.Append(" as ").Append(alias); |
| } |
| else |
| { |
| ResultBuilder.Append(alias); |
| } |
| |
| return true; |
| } |
| |
| /// <summary> |
| /// Visits the binary function. |
| /// </summary> |
| /// <param name="expression">The expression.</param> |
| /// <returns>True if function detected, otherwise false.</returns> |
| private bool VisitBinaryFunc(BinaryExpression expression) |
| { |
| if (expression.NodeType == ExpressionType.Add && expression.Left.Type == typeof(string)) |
| { |
| ResultBuilder.Append("concat("); |
| } |
| else if (expression.NodeType == ExpressionType.Coalesce) |
| { |
| ResultBuilder.Append("coalesce("); |
| } |
| else |
| { |
| return false; |
| } |
| |
| Visit(expression.Left); |
| ResultBuilder.Append(", "); |
| Visit(expression.Right); |
| ResultBuilder.TrimEnd().Append(')'); |
| |
| return true; |
| } |
| } |