Skip to content
Open
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
82 changes: 71 additions & 11 deletions src/EFCore.Cosmos/Query/Internal/CosmosQuerySqlGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,28 @@ public class CosmosQuerySqlGenerator(ITypeMappingSource typeMappingSource) : Sql
{
private readonly IndentedStringBuilder _sqlBuilder = new();
private IReadOnlyDictionary<string, object> _parameterValues = null!;

/// <summary>
/// The Cosmos SqlParameters which will get sent in the CosmosQuery.
/// </summary>
private List<SqlParameter> _sqlParameters = null!;

/// <summary>
/// Lookup table from <see cref="SqlParameterExpression.Name" /> (the "original" name) to the Cosmos <see cref="SqlParameter" />,
/// whose name has underdone uniquification and prefixing. This is used when the same parameter is referenced multiple times in the query.
/// </summary>
private Dictionary<string, SqlParameter> _sqlParametersByOriginalName = null!;

/// <summary>
/// Contains final parameter names (prefixed, uniquified) seen so far, for uniquification purposes.
/// </summary>
private readonly HashSet<string> _prefixedParameterNames = new(StringComparer.OrdinalIgnoreCase);

private ParameterNameGenerator _parameterNameGenerator = null!;

private static readonly bool UseOldBehavior37252 =
AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue37252", out var enabled) && enabled;

/// <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
Expand All @@ -32,6 +51,7 @@ public virtual CosmosSqlQuery GetSqlQuery(
_sqlBuilder.Clear();
_parameterValues = parameterValues;
_sqlParameters = [];
_sqlParametersByOriginalName = [];
_parameterNameGenerator = new ParameterNameGenerator();

Visit(selectExpression);
Expand Down Expand Up @@ -401,7 +421,11 @@ when _parameterValues.TryGetValue(queryParameter.Name, out var parameterValue)
substitutions = new string[parameterValues.Length];
for (var i = 0; i < parameterValues.Length; i++)
{
var parameterName = _parameterNameGenerator.GenerateNext();
// Note that we don't go through _sqlParametersByOriginalName, since the FromSql parameters we're adding here cannot
// be referenced multiple times.
var parameterName = UseOldBehavior37252
? _parameterNameGenerator.GenerateNext()
: PrefixAndUniquifyParameterName("p");
_sqlParameters.Add(new SqlParameter(parameterName, parameterValues[i]));
substitutions[i] = parameterName;
}
Expand Down Expand Up @@ -677,20 +701,42 @@ protected override Expression VisitSqlConditional(SqlConditionalExpression sqlCo
/// </summary>
protected override Expression VisitSqlParameter(SqlParameterExpression sqlParameterExpression)
{
var parameterName = $"@{sqlParameterExpression.Name}";

if (_sqlParameters.All(sp => sp.Name != parameterName))
if (UseOldBehavior37252)
{
Check.DebugAssert(sqlParameterExpression.TypeMapping is not null, "SqlParameterExpression without a type mapping.");
var parameterName = $"@{sqlParameterExpression.Name}";

if (_sqlParameters.All(sp => sp.Name != parameterName))
{
Check.DebugAssert(sqlParameterExpression.TypeMapping is not null, "SqlParameterExpression without a type mapping.");

_sqlParameters.Add(
new SqlParameter(
parameterName,
((CosmosTypeMapping)sqlParameterExpression.TypeMapping)
.GenerateJToken(_parameterValues[sqlParameterExpression.Name])));
}

_sqlParameters.Add(
new SqlParameter(
parameterName,
((CosmosTypeMapping)sqlParameterExpression.TypeMapping)
.GenerateJToken(_parameterValues[sqlParameterExpression.Name])));
_sqlBuilder.Append(parameterName);
}
else
{
if (!_sqlParametersByOriginalName.TryGetValue(sqlParameterExpression.Name, out var sqlParameter))
{
Check.DebugAssert(sqlParameterExpression.TypeMapping is not null, "SqlParameterExpression without a type mapping.");

var parameterName = PrefixAndUniquifyParameterName(sqlParameterExpression.Name);

_sqlBuilder.Append(parameterName);
sqlParameter = new SqlParameter(
parameterName,
((CosmosTypeMapping)sqlParameterExpression.TypeMapping)
.GenerateJToken(_parameterValues[sqlParameterExpression.Name]));

_sqlParametersByOriginalName[sqlParameterExpression.Name] = sqlParameter;
_sqlParameters.Add(sqlParameter);
}

_sqlBuilder.Append(sqlParameter.Name);
}

return sqlParameterExpression;
}
Expand Down Expand Up @@ -863,6 +909,20 @@ private void GenerateFunction(string name, IReadOnlyList<Expression> arguments)
_sqlBuilder.Append(')');
}

private string PrefixAndUniquifyParameterName(string originalName)
{
var i = 0;
var prefixedName = $"@{originalName}";

while (_prefixedParameterNames.Contains(prefixedName))
{
prefixedName = $"@{originalName + i++}";
}

_prefixedParameterNames.Add(prefixedName);
return prefixedName;
}

private sealed class ParameterNameGenerator
{
private int _count;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2901,7 +2901,8 @@ public static Expression GenerateComplexPropertyShaperExpression(

// We do not support complex type splitting, so we will only ever have a single table/view mapping to it.
// See Issue #32853 and Issue #31248
var complexTypeTable = complexType.GetViewOrTableMappings().Single().Table;
var viewOrTableTable = complexType.GetViewOrTableMappings().Single().Table;
var complexTypeTable = viewOrTableTable;

if (!containerProjection.TableMap.TryGetValue(complexTypeTable, out var tableAlias))
{
Expand All @@ -2925,7 +2926,11 @@ public static Expression GenerateComplexPropertyShaperExpression(
{
var containerColumnName = complexType.GetContainerColumnName();
Check.DebugAssert(containerColumnName is not null, "Complex JSON type without a container column");
var containerColumn = complexTypeTable.FindColumn(containerColumnName);

// TODO: when the JSON column is on a FromSql() source, we use the default mappings from the relational model (see just above),
// but those don't yet contain complex types (#34627). We work around this here, get the column from the table mapping instead
// of from the default mapping.
var containerColumn = complexTypeTable.FindColumn(containerColumnName) ?? viewOrTableTable.FindColumn(containerColumnName);
Check.DebugAssert(containerColumn is not null, "Complex JSON container table not found on relational table");

// If the source type is a JSON complex type; since we're binding over StructuralTypeProjectionExpression - which represents a relational
Expand Down
31 changes: 31 additions & 0 deletions src/EFCore.Relational/Query/SqlNullabilityProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

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

Expand Down Expand Up @@ -31,6 +32,9 @@ public class SqlNullabilityProcessor : ExpressionVisitor
/// </summary>
private readonly Dictionary<SqlParameterExpression, List<SqlParameterExpression>> _collectionParameterExpansionMap;

private static readonly bool UseOldBehavior37216 =
AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue37216", out var enabled) && enabled;

/// <summary>
/// Creates a new instance of the <see cref="SqlNullabilityProcessor" /> class.
/// </summary>
Expand Down Expand Up @@ -188,6 +192,33 @@ protected override Expression VisitExtension(Expression node)
throw new UnreachableException();
}

// We've inlined the user-provided collection from the values parameter into the SQL VALUES expression: (VALUES (1), (2)...).
// However, if the collection happens to be empty, this doesn't work as VALUES does not support empty sets. We convert it
// to a SELECT ... WHERE false to produce an empty result set instead.
if (!UseOldBehavior37216 && processedValues.Count == 0)
{
var select = new SelectExpression(
valuesExpression.Alias,
tables: [],
predicate: new SqlConstantExpression(false, Dependencies.TypeMappingSource.FindMapping(typeof(bool))),
groupBy: [],
having: null,
projections: valuesExpression.ColumnNames
.Select(n => new ProjectionExpression(
new SqlConstantExpression(value: null, elementTypeMapping.ClrType, elementTypeMapping), n))
.ToList(),
distinct: false,
orderings: [],
offset: null,
limit: null,
tags: ReadOnlySet<string>.Empty,
annotations: null,
sqlAliasManager: null!,
isMutable: false);

return select;
}

return valuesExpression.Update(processedValues);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ private static readonly PropertyInfo QueryContextContextPropertyInfo

private readonly Dictionary<string, object?> _parameters = new();

private static readonly bool UseOldBehavior37247 =
AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue37247", out var enabled) && enabled;

/// <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
Expand Down Expand Up @@ -1064,6 +1067,17 @@ private Expression ProcessExecuteUpdate(NavigationExpansionExpression source, Me
{
source = (NavigationExpansionExpression)_pendingSelectorExpandingExpressionVisitor.Visit(source);

if (!UseOldBehavior37247)
{
// Apply any pending selector before processing the ExecuteUpdate setters; this adds a Select() (if necessary) before
// ExecuteUpdate, to avoid the pending selector flowing into each setter lambda and making it more complicated.
var newStructure = SnapshotExpression(source.PendingSelector);
var queryable = Reduce(source);
var navigationTree = new NavigationTreeExpression(newStructure);
var parameterName = source.CurrentParameter.Name ?? GetParameterName("e");
source = new NavigationExpansionExpression(queryable, navigationTree, navigationTree, parameterName);
}

NewArrayExpression settersArray;
switch (setters)
{
Expand Down
81 changes: 59 additions & 22 deletions test/EFCore.Cosmos.FunctionalTests/Query/FromSqlQueryCosmosTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -357,12 +357,12 @@ public Task FromSqlRaw_queryable_with_parameters(bool async)

AssertSql(
"""
@p0='London'
@p1='Sales Representative'
@p='London'
@p0='Sales Representative'

SELECT VALUE s
FROM (
SELECT * FROM root c WHERE c["$type"] = "Customer" AND c["City"] = @p0 AND c["ContactTitle"] = @p1
SELECT * FROM root c WHERE c["$type"] = "Customer" AND c["City"] = @p AND c["ContactTitle"] = @p0
) s
""");
});
Expand All @@ -388,12 +388,12 @@ public Task FromSqlRaw_queryable_with_parameters_inline(bool async)

AssertSql(
"""
@p0='London'
@p1='Sales Representative'
@p='London'
@p0='Sales Representative'

SELECT VALUE s
FROM (
SELECT * FROM root c WHERE c["$type"] = "Customer" AND c["City"] = @p0 AND c["ContactTitle"] = @p1
SELECT * FROM root c WHERE c["$type"] = "Customer" AND c["City"] = @p AND c["ContactTitle"] = @p0
) s
""");
});
Expand All @@ -418,11 +418,11 @@ public Task FromSqlRaw_queryable_with_null_parameter(bool async)

AssertSql(
"""
@p0=null
@p=null

SELECT VALUE s
FROM (
SELECT * FROM root c WHERE c["$type"] = "Employee" AND c["ReportsTo"] = @p0 OR (IS_NULL(c["ReportsTo"]) AND IS_NULL(@p0))
SELECT * FROM root c WHERE c["$type"] = "Employee" AND c["ReportsTo"] = @p OR (IS_NULL(c["ReportsTo"]) AND IS_NULL(@p))
) s
""");
});
Expand Down Expand Up @@ -451,12 +451,12 @@ public Task FromSqlRaw_queryable_with_parameters_and_closure(bool async)

AssertSql(
"""
@p0='London'
@p='London'
@contactTitle='Sales Representative'

SELECT VALUE s
FROM (
SELECT * FROM root c WHERE c["$type"] = "Customer" AND c["City"] = @p0
SELECT * FROM root c WHERE c["$type"] = "Customer" AND c["City"] = @p
) s
WHERE (s["ContactTitle"] = @contactTitle)
""");
Expand Down Expand Up @@ -540,22 +540,22 @@ public virtual Task FromSqlRaw_queryable_with_parameters_cache_key_includes_para

AssertSql(
"""
@p0='London'
@p1='Sales Representative'
@p='London'
@p0='Sales Representative'

SELECT VALUE s
FROM (
SELECT * FROM root c WHERE c["$type"] = "Customer" AND c["City"] = @p0 AND c["ContactTitle"] = @p1
SELECT * FROM root c WHERE c["$type"] = "Customer" AND c["City"] = @p AND c["ContactTitle"] = @p0
) s
""",
//
"""
@p0='Madrid'
@p1='Accounting Manager'
@p='Madrid'
@p0='Accounting Manager'

SELECT VALUE s
FROM (
SELECT * FROM root c WHERE c["$type"] = "Customer" AND c["City"] = @p0 AND c["ContactTitle"] = @p1
SELECT * FROM root c WHERE c["$type"] = "Customer" AND c["City"] = @p AND c["ContactTitle"] = @p0
) s
""");
});
Expand Down Expand Up @@ -731,12 +731,12 @@ await AssertQuery(

AssertSql(
"""
@p0='London'
@p1='Sales Representative'
@p='London'
@p0='Sales Representative'

SELECT VALUE s
FROM (
SELECT * FROM root c WHERE c["City"] = @p0 AND c["ContactTitle"] = @p1
SELECT * FROM root c WHERE c["City"] = @p AND c["ContactTitle"] = @p0
) s
""");
});
Expand All @@ -754,13 +754,50 @@ await AssertQuery(

AssertSql(
"""
@p0='London'
@p1='Sales Representative'
@p='London'
@p0='Sales Representative'

SELECT VALUE s
FROM (
SELECT * FROM root c WHERE c["City"] = @p0 AND c["ContactTitle"] = @p1
SELECT * FROM root c WHERE c["City"] = @p AND c["ContactTitle"] = @p0
) s
""");
});

[ConditionalTheory, MemberData(nameof(IsAsyncData))]
public virtual Task Both_FromSql_and_regular_parameters(bool async)
=> Fixture.NoSyncTest(
async, async a =>
{
var city = "London";
var country = "UK";

await AssertQuery(
a,
ss => ((DbSet<Customer>)ss.Set<Customer>())
.FromSql($"""SELECT * FROM root c WHERE c["City"] = {city} AND c["Country"] = {country}""")
.OrderBy(c => c.CustomerID)
.Skip(1) // Non-lambda parameter, so becomes a SqlParameter
.Take(5),
ss => ss.Set<Customer>()
.Where(x => x.City == city)
.OrderBy(c => c.CustomerID)
.Skip(1)
.Take(5));

AssertSql(
"""
@p='London'
@p0='UK'
@p00='1'
@p1='5'

SELECT VALUE s
FROM (
SELECT * FROM root c WHERE c["City"] = @p AND c["Country"] = @p0
) s
ORDER BY s["id"]
OFFSET @p00 LIMIT @p1
""");
});

Expand Down
Loading
Loading