modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryModelVisitor.cs (487 lines of code) (raw):
/*
* 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.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using Common;
using Dml;
using Ignite.Sql;
using Remotion.Linq;
using Remotion.Linq.Clauses;
using Remotion.Linq.Clauses.Expressions;
using Remotion.Linq.Clauses.ResultOperators;
/// <summary>
/// Query visitor, transforms LINQ expression to SQL.
/// </summary>
internal sealed class IgniteQueryModelVisitor : QueryModelVisitorBase
{
/** */
private readonly StringBuilder _builder = new();
/** */
[SuppressMessage("Microsoft.Design", "CA1002:DoNotExposeGenericLists", Justification = "Private.")]
private readonly List<object?> _parameters = new();
/** */
private readonly AliasDictionary _aliases = new();
/** */
private bool _hasOuterJoins;
/// <summary>
/// Gets the builder.
/// </summary>
public StringBuilder Builder => _builder;
/// <summary>
/// Gets the parameters.
/// </summary>
public IList<object?> Parameters => _parameters;
/// <summary>
/// Gets the aliases.
/// </summary>
public AliasDictionary Aliases => _aliases;
/// <summary>
/// Generates the query.
/// </summary>
/// <param name="queryModel">Query model.</param>
/// <returns>Query data.</returns>
public QueryData GenerateQuery(QueryModel queryModel)
{
VisitQueryModel(queryModel);
var qryText = _builder.TrimEnd().ToString();
return new QueryData(qryText, _parameters, _hasOuterJoins);
}
/** <inheritdoc /> */
public override void VisitQueryModel(QueryModel queryModel)
{
VisitQueryModel(queryModel, includeAllFields: queryModel.MainFromClause.FromExpression is SubQueryExpression);
}
/** <inheritdoc /> */
public override void VisitWhereClause(WhereClause whereClause, QueryModel queryModel, int index)
{
base.VisitWhereClause(whereClause, queryModel, index);
VisitWhereClause(whereClause, index, false);
}
/** <inheritdoc /> */
public override void VisitOrderByClause(OrderByClause orderByClause, QueryModel queryModel, int index)
{
base.VisitOrderByClause(orderByClause, queryModel, index);
_builder.Append("order by ");
for (int i = 0; i < orderByClause.Orderings.Count; i++)
{
var ordering = orderByClause.Orderings[i];
if (i > 0)
{
_builder.Append(", ");
}
BuildSqlExpression(ordering.Expression);
_builder.AppendWithSpace(ordering.OrderingDirection == OrderingDirection.Asc ? "asc" : "desc");
}
_builder.Append(' ');
}
/** <inheritdoc /> */
public override void VisitJoinClause(JoinClause joinClause, QueryModel queryModel, int index)
{
base.VisitJoinClause(joinClause, queryModel, index);
var queryable = ExpressionWalker.GetIgniteQueryable(joinClause, false);
if (queryable != null)
{
if (joinClause.InnerSequence is SubQueryExpression subQuery)
{
var isOuter = subQuery.QueryModel.ResultOperators.OfType<DefaultIfEmptyResultOperator>().Any();
_hasOuterJoins |= isOuter;
_builder.AppendFormat(CultureInfo.InvariantCulture, "{0} join (", isOuter ? "left outer" : "inner");
VisitQueryModel(subQuery.QueryModel, true);
var alias = _aliases.GetTableAlias(subQuery.QueryModel.MainFromClause);
_builder.AppendFormat(CultureInfo.InvariantCulture, ") as {0} on (", alias);
}
else
{
var tableName = ExpressionWalker.GetTableNameWithSchema(queryable);
var alias = _aliases.GetTableAlias(joinClause);
_builder.AppendFormat(CultureInfo.InvariantCulture, "inner join {0} as {1} on (", tableName, alias);
}
}
else
{
VisitJoinWithLocalCollectionClause(joinClause);
}
BuildJoinCondition(joinClause.InnerKeySelector, joinClause.OuterKeySelector);
_builder.Append(") ");
}
/** <inheritdoc /> */
public override void VisitAdditionalFromClause(AdditionalFromClause fromClause, QueryModel queryModel, int index)
{
base.VisitAdditionalFromClause(fromClause, queryModel, index);
var subQuery = fromClause.FromExpression as SubQueryExpression;
if (subQuery != null)
{
_builder.Append('(');
VisitQueryModel(subQuery.QueryModel, true);
var alias = _aliases.GetTableAlias(subQuery.QueryModel.MainFromClause);
_builder.AppendFormat(CultureInfo.InvariantCulture, ") as {0} ", alias);
}
else
{
_aliases.AppendAsClause(_builder, fromClause);
_builder.Append(' ');
}
}
/** <inheritdoc /> */
public override void VisitMainFromClause(MainFromClause fromClause, QueryModel queryModel)
{
// GROUP BY is handled separately in ProcessGroupings and does not need to be handled here as SubQuery.
if (fromClause.FromExpression is SubQueryExpression subQuery &&
subQuery.QueryModel.ResultOperators.All(x => x is not GroupResultOperator))
{
_builder.Append("from (");
VisitQueryModel(subQuery.QueryModel, includeAllFields: true);
_builder.TrimEnd()
.Append(") as ")
.Append(_aliases.GetTableAlias(subQuery.QueryModel.MainFromClause))
.Append(' ');
return;
}
var isUpdateQuery = queryModel.ResultOperators.LastOrDefault() is ExecuteUpdateResultOperator;
if (!isUpdateQuery)
{
_builder.Append("from ");
}
ValidateFromClause(fromClause);
_aliases.AppendAsClause(_builder, fromClause);
_builder.Append(' ');
var i = 0;
foreach (var additionalFrom in queryModel.BodyClauses.OfType<AdditionalFromClause>())
{
_builder.Append(", ");
ValidateFromClause(additionalFrom);
VisitAdditionalFromClause(additionalFrom, queryModel, i++);
}
if (isUpdateQuery)
{
BuildSetClauseForExecuteUpdate(queryModel);
}
}
/// <summary>
/// Visits the query model.
/// </summary>
/// <param name="queryModel">Query model.</param>
/// <param name="includeAllFields">Whether to select all table fields.</param>
/// <param name="copyAliases">Whether to copy aliases.</param>
public void VisitQueryModel(QueryModel queryModel, bool includeAllFields, bool copyAliases = false)
{
_aliases.Push(copyAliases);
var lastResultOp = queryModel.ResultOperators.LastOrDefault();
if (lastResultOp is ExecuteDeleteResultOperator)
{
VisitDmlOperator(queryModel, "delete ", nameof(IgniteQueryableExtensions.ExecuteDeleteAsync));
}
else if (lastResultOp is ExecuteUpdateResultOperator)
{
VisitDmlOperator(queryModel, "update ", nameof(IgniteQueryableExtensions.ExecuteUpdateAsync));
}
else if (queryModel.ResultOperators.Count == 1 && queryModel.ResultOperators[0] is AnyResultOperator or AllResultOperator)
{
// All is different from Any: it always has a predicate inside.
// We use NOT EXISTS with reverted predicate to implement All.
// Reverted predicate is added in VisitBodyClauses.
_builder.Append(queryModel.ResultOperators[0] is AllResultOperator
? "select not exists (select 1 "
: "select exists (select 1 ");
// FROM ... WHERE ... JOIN ...
base.VisitQueryModel(queryModel);
// UNION ...
ProcessResultOperatorsEnd(queryModel);
_builder.TrimEnd().Append(')');
}
else
{
// SELECT
_builder.Append("select ");
// FLD1, FLD2
VisitSelectors(queryModel, includeAllFields);
// FROM ... WHERE ... JOIN ...
base.VisitQueryModel(queryModel);
// UNION ...
ProcessResultOperatorsEnd(queryModel);
}
_aliases.Pop();
}
/// <summary>
/// Visits selectors.
/// </summary>
/// <param name="queryModel">Query model.</param>
/// <param name="includeAllFields">Whether to include all fields in the selector.</param>
public void VisitSelectors(QueryModel queryModel, bool includeAllFields)
{
var parenCount = ProcessResultOperatorsBegin(queryModel);
// FIELD1, FIELD2
BuildSqlExpression(queryModel.SelectClause.Selector, parenCount > 0, includeAllFields);
_builder.TrimEnd().Append(')', parenCount).Append(' ');
}
/** <inheritdoc /> */
protected override void VisitBodyClauses(ObservableCollection<IBodyClause> bodyClauses, QueryModel queryModel)
{
var i = 0;
foreach (var join in bodyClauses.OfType<JoinClause>())
{
VisitJoinClause(join, queryModel, i++);
}
var hasGroups = ProcessGroupings(queryModel);
i = 0;
foreach (var where in bodyClauses.OfType<WhereClause>())
{
VisitWhereClause(where, i++, hasGroups);
}
if (queryModel.ResultOperators.Count == 1 && queryModel.ResultOperators[0] is AllResultOperator allOp)
{
_builder.Append(i > 0
? "and not "
: "where not ");
BuildSqlExpression(allOp.Predicate);
}
i = 0;
foreach (var orderBy in bodyClauses.OfType<OrderByClause>())
{
VisitOrderByClause(orderBy, queryModel, i++);
}
}
/// <summary>
/// Validates from clause.
/// </summary>
// ReSharper disable once UnusedParameter.Local
private static void ValidateFromClause(IFromClause clause)
{
// Only IQueryable can be used in FROM clause. IEnumerable is not supported.
if (!typeof(IQueryable).IsAssignableFrom(clause.FromExpression.Type))
{
throw new NotSupportedException("FROM clause must be IQueryable: " + clause);
}
}
/// <summary>
/// Processes the result operators that come right after SELECT: min/max/count/sum/distinct.
/// </summary>
private int ProcessResultOperatorsBegin(QueryModel queryModel)
{
int parenCount = 0;
foreach (var op in queryModel.ResultOperators.Reverse())
{
if (op is CountResultOperator or LongCountResultOperator)
{
_builder.Append("count(");
parenCount++;
}
else if (op is SumResultOperator)
{
_builder.Append("sum(");
parenCount++;
}
else if (op is MinResultOperator)
{
_builder.Append("min(");
parenCount++;
}
else if (op is MaxResultOperator)
{
_builder.Append("max(");
parenCount++;
}
else if (op is AverageResultOperator)
{
_builder.Append("avg(");
parenCount++;
}
else if (op is DistinctResultOperator)
{
_builder.Append("distinct ");
}
else if (op is UnionResultOperator or IntersectResultOperator or ExceptResultOperator or DefaultIfEmptyResultOperator or
SkipResultOperator or TakeResultOperator or FirstResultOperator or SingleResultOperator)
{
// Will be processed later
break;
}
else if (op is ContainsResultOperator)
{
// Should be processed already
break;
}
else
{
throw new NotSupportedException("Operator is not supported: " + op);
}
}
return parenCount;
}
/// <summary>
/// Processes the result operators that go in the end of the query: limit/offset/union/intersect/except.
/// </summary>
private void ProcessResultOperatorsEnd(QueryModel queryModel)
{
ProcessSkipTake(queryModel);
for (var i = queryModel.ResultOperators.Count - 1; i >= 0; i--)
{
var op = queryModel.ResultOperators[i];
string? keyword = null;
Expression? source = null;
if (op is UnionResultOperator union)
{
keyword = "union";
source = union.Source2;
}
if (op is IntersectResultOperator intersect)
{
keyword = "intersect";
source = intersect.Source2;
}
if (op is ExceptResultOperator except)
{
keyword = "except";
source = except.Source2;
}
if (keyword == null)
{
continue;
}
_builder.Append(keyword).Append(" (");
if (source is SubQueryExpression subQuery)
{
// Subquery union.
VisitQueryModel(subQuery.QueryModel);
}
else
{
// Direct union, source is IIgniteQueryableInternal
if (source is not ConstantExpression innerExpr)
{
throw new NotSupportedException("Unexpected UNION inner sequence: " + source);
}
if (innerExpr.Value is not IIgniteQueryableInternal queryable)
{
throw new NotSupportedException("Unexpected UNION inner sequence " +
"(only results of cache.ToQueryable() are supported): " +
innerExpr.Value);
}
VisitQueryModel(queryable.GetQueryModel());
}
_builder.TrimEnd().Append(')');
}
}
/// <summary>
/// Processes the pagination (skip/take).
/// </summary>
private void ProcessSkipTake(QueryModel queryModel)
{
int? limitCount = null;
Expression? limitExpr = null;
Expression? offsetExpr = null;
foreach (var op in queryModel.ResultOperators)
{
if (op is FirstResultOperator)
{
limitCount = Math.Min(1, limitCount ?? int.MaxValue);
}
else if (op is SingleResultOperator)
{
// Will fail in IgniteQueryExecutor.ExecuteSingleInternalAsync if there is more than 1 row.
limitCount = Math.Min(2, limitCount ?? int.MaxValue);
}
else if (op is TakeResultOperator limit)
{
if (limitExpr != null)
{
throw new NotSupportedException("Multiple Take operators on the same subquery are not supported.");
}
limitExpr = limit.Count;
}
else if (op is SkipResultOperator offset)
{
if (offsetExpr != null)
{
throw new NotSupportedException("Multiple Skip operators on the same subquery are not supported.");
}
offsetExpr = offset.Count;
}
}
if (limitCount != null)
{
_builder.AppendWithSpace("limit ").Append(limitCount.Value);
}
else if (limitExpr != null)
{
_builder.AppendWithSpace("limit ");
BuildSqlExpression(limitExpr);
}
if (offsetExpr != null)
{
_builder.AppendWithSpace("offset ");
BuildSqlExpression(offsetExpr);
}
}
/// <summary>
/// Processes the groupings.
/// </summary>
private bool ProcessGroupings(QueryModel queryModel)
{
var subQuery = queryModel.MainFromClause.FromExpression as SubQueryExpression;
if (subQuery == null)
{
return false;
}
var groupBy = subQuery.QueryModel.ResultOperators.OfType<GroupResultOperator>().FirstOrDefault();
if (groupBy == null)
{
return false;
}
// Visit inner joins before grouping
var i = 0;
foreach (var join in subQuery.QueryModel.BodyClauses.OfType<JoinClause>())
{
VisitJoinClause(join, queryModel, i++);
}
i = 0;
foreach (var where in subQuery.QueryModel.BodyClauses.OfType<WhereClause>())
{
VisitWhereClause(where, i++, false);
}
// Append grouping
_builder.Append("group by ");
var (alias, aliasCreated) = Aliases.GetOrCreateGroupByMemberAlias(groupBy);
if (aliasCreated)
{
// This GroupBy member was not processed before, build full SQL expression.
// Do not append "AS alias" here, because we are inside GROUP BY clause already.
_builder.Append('(');
BuildSqlExpression(groupBy.KeySelector);
_builder.Append(')');
}
else
{
// This GroupBy member was processed before (it is a part of SELECT or something else), use alias.
_builder.Append(alias);
}
_builder.Append(' ');
return true;
}
/// <summary>
/// Visits the where clause.
/// </summary>
private void VisitWhereClause(WhereClause whereClause, int index, bool hasGroups)
{
_builder.Append(index > 0
? "and "
: hasGroups
? "having"
: "where ");
BuildSqlExpression(whereClause.Predicate);
_builder.Append(' ');
}
/// <summary>
/// Visits Join clause in case of join with local collection.
/// </summary>
private void VisitJoinWithLocalCollectionClause(JoinClause joinClause)
{
// Unlike Ignite 2.x, SQL engine does not support local collection joins.
throw new NotSupportedException("Local collection joins are not supported, try `.Contains()` instead: " + joinClause);
}
/// <summary>
/// Builds the join condition ('x=y AND foo=bar').
/// </summary>
/// <param name="innerKey">The inner key selector.</param>
/// <param name="outerKey">The outer key selector.</param>
private void BuildJoinCondition(Expression innerKey, Expression outerKey)
{
var innerNew = innerKey as NewExpression;
var outerNew = outerKey as NewExpression;
if (innerNew == null && outerNew == null)
{
BuildJoinSubCondition(innerKey, outerKey);
return;
}
if (innerNew != null && outerNew != null)
{
if (innerNew.Constructor != outerNew.Constructor)
{
throw new NotSupportedException(
"Unexpected JOIN condition. Multi-key joins should have " +
$"the same initializers on both sides: '{innerKey} = {outerKey}'");
}
for (var i = 0; i < innerNew.Arguments.Count; i++)
{
if (i > 0)
{
_builder.Append(" and ");
}
BuildJoinSubCondition(innerNew.Arguments[i], outerNew.Arguments[i]);
}
return;
}
throw new NotSupportedException(
"Unexpected JOIN condition. Multi-key joins should have " +
$"anonymous type instances on both sides: '{innerKey} = {outerKey}'");
}
/// <summary>
/// Builds the join sub condition.
/// </summary>
/// <param name="innerKey">The inner key.</param>
/// <param name="outerKey">The outer key.</param>
private void BuildJoinSubCondition(Expression innerKey, Expression outerKey)
{
BuildSqlExpression(innerKey);
_builder.Append(" = ");
BuildSqlExpression(outerKey);
}
/// <summary>
/// Builds the SQL expression.
/// </summary>
private void BuildSqlExpression(
Expression expression,
bool useStar = false,
bool includeAllFields = false,
bool visitSubqueryModel = false,
bool columnNameWithoutTable = false) =>
new IgniteQueryExpressionVisitor(
modelVisitor: this,
useStar: useStar,
includeAllFields: includeAllFields,
visitEntireSubQueryModel: visitSubqueryModel,
columnNameWithoutTable: columnNameWithoutTable)
.Visit(expression);
/// <summary>
/// Visits a DML operator (update, delete).
/// </summary>
private void VisitDmlOperator(QueryModel queryModel, string sqlName, string methodName)
{
if (queryModel.ResultOperators.Count > 1)
{
var ops = string.Join(
", ",
queryModel.ResultOperators.Where(x => x is not ExecuteDeleteResultOperator && x is not ExecuteUpdateResultOperator));
throw new NotSupportedException($"{methodName} can not be combined with result operators: {ops}");
}
_builder.Append(sqlName);
// FROM ... WHERE ... JOIN ...
base.VisitQueryModel(queryModel);
}
/// <summary>
/// Builds SET clause of UPDATE statement.
/// </summary>
private void BuildSetClauseForExecuteUpdate(QueryModel queryModel)
{
if (queryModel.ResultOperators.LastOrDefault() is not ExecuteUpdateResultOperator updateResultOperator)
{
return;
}
_builder.Append("set ");
var first = true;
foreach (var update in updateResultOperator.Updates)
{
if (!first)
{
_builder.Append(", ");
}
first = false;
BuildSqlExpression(update.Selector, columnNameWithoutTable: true);
_builder.Append(" = ");
BuildSqlExpression(update.Value, visitSubqueryModel: true);
}
_builder.Append(' ');
}
}