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

Use JsonScalarExpression for primitive collection indexing #30769

Merged
1 commit merged into from
Apr 27, 2023
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
3 changes: 2 additions & 1 deletion src/EFCore.Relational/Query/JsonQueryExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,9 @@ public virtual SqlExpression BindProperty(IProperty property)

return new JsonScalarExpression(
JsonColumn,
property,
newPath,
property.ClrType.UnwrapNullableType(),
property.FindRelationalTypeMapping()!,
IsNullable || property.IsNullable);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2786,7 +2786,7 @@ when InferredTypeMappings.TryGetValue((columnExpression.Table, columnExpression.
case ValuesExpression valuesExpression:
// By default, the ValuesExpression also contains an ordering by a synthetic increasing _ord. If the containing
// SelectExpression doesn't project it out or require it (limit/offset), strip that out.
// TODO: Strictly-speaking, this doesn't belong in this visitor which is about applying type mappings
// TODO: Strictly-speaking, stripping the ordering doesn't belong in this visitor which is about applying type mappings
return ApplyTypeMappingsOnValuesExpression(
valuesExpression,
stripOrdering: _currentSelectExpression is { Limit: null, Offset: null }
Expand Down
65 changes: 55 additions & 10 deletions src/EFCore.Relational/Query/SqlExpressionFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace Microsoft.EntityFrameworkCore.Query;
/// <inheritdoc />
public class SqlExpressionFactory : ISqlExpressionFactory
{
private readonly IRelationalTypeMappingSource _typeMappingSource;
private readonly RelationalTypeMapping _boolTypeMapping;

/// <summary>
Expand All @@ -19,7 +20,8 @@ public class SqlExpressionFactory : ISqlExpressionFactory
public SqlExpressionFactory(SqlExpressionFactoryDependencies dependencies)
{
Dependencies = dependencies;
_boolTypeMapping = dependencies.TypeMappingSource.FindMapping(typeof(bool), dependencies.Model)!;
_typeMappingSource = dependencies.TypeMappingSource;
_boolTypeMapping = _typeMappingSource.FindMapping(typeof(bool), dependencies.Model)!;
}

/// <summary>
Expand All @@ -38,7 +40,7 @@ public SqlExpressionFactory(SqlExpressionFactoryDependencies dependencies)
&& sqlUnaryExpression.Type == typeof(object)
? sqlUnaryExpression.Operand
: ApplyTypeMapping(
sqlExpression, Dependencies.TypeMappingSource.FindMapping(sqlExpression.Type, Dependencies.Model));
sqlExpression, _typeMappingSource.FindMapping(sqlExpression.Type, Dependencies.Model));

/// <inheritdoc />
[return: NotNullIfNotNull("sqlExpression")]
Expand All @@ -60,6 +62,13 @@ public SqlExpressionFactory(SqlExpressionFactoryDependencies dependencies)
ColumnExpression e => e.ApplyTypeMapping(typeMapping),
DistinctExpression e => ApplyTypeMappingOnDistinct(e, typeMapping),
InExpression e => ApplyTypeMappingOnIn(e),

// We only do type inference for JSON scalar expression which represent a single array indexing operation; we can infer the
// array's mapping from the element or vice versa, allowing e.g. parameter primitive collections to get inferred when an
// an indexer is used over them and then compared to a column.
// But we can't infer anything for other Path forms of JsonScalarExpression (e.g. a property lookup).
JsonScalarExpression { Path: [{ ArrayIndex: not null }] } e => ApplyTypeMappingOnJsonScalar(e, typeMapping),

LikeExpression e => ApplyTypeMappingOnLike(e),
ScalarSubqueryExpression e => e.ApplyTypeMapping(typeMapping),
SqlBinaryExpression e => ApplyTypeMappingOnSqlBinary(e, typeMapping),
Expand All @@ -68,6 +77,7 @@ public SqlExpressionFactory(SqlExpressionFactoryDependencies dependencies)
SqlFunctionExpression e => e.ApplyTypeMapping(typeMapping),
SqlParameterExpression e => e.ApplyTypeMapping(typeMapping),
SqlUnaryExpression e => ApplyTypeMappingOnSqlUnary(e, typeMapping),

_ => sqlExpression
};
}
Expand All @@ -82,7 +92,7 @@ private SqlExpression ApplyTypeMappingOnLike(LikeExpression likeExpression)
likeExpression.Match, likeExpression.Pattern)
: ExpressionExtensions.InferTypeMapping(
likeExpression.Match, likeExpression.Pattern, likeExpression.EscapeChar))
?? Dependencies.TypeMappingSource.FindMapping(likeExpression.Match.Type, Dependencies.Model);
?? _typeMappingSource.FindMapping(likeExpression.Match.Type, Dependencies.Model);

return new LikeExpression(
ApplyTypeMapping(likeExpression.Match, inferredTypeMapping),
Expand Down Expand Up @@ -184,9 +194,9 @@ private SqlExpression ApplyTypeMappingOnSqlBinary(
{
inferredTypeMapping = ExpressionExtensions.InferTypeMapping(left, right)
// We avoid object here since the result does not get typeMapping from outside.
?? (left.Type != typeof(object)
? Dependencies.TypeMappingSource.FindMapping(left.Type, Dependencies.Model)
: Dependencies.TypeMappingSource.FindMapping(right.Type, Dependencies.Model));
?? _typeMappingSource.FindMapping(
left.Type != typeof(object) ? left.Type : right.Type,
Dependencies.Model);
resultType = typeof(bool);
resultTypeMapping = _boolTypeMapping;
break;
Expand Down Expand Up @@ -236,7 +246,7 @@ private SqlExpression ApplyTypeMappingOnIn(InExpression inExpression)
: inExpression.Subquery != null
? ExpressionExtensions.InferTypeMapping(inExpression.Item, inExpression.Subquery.Projection[0].Expression)
: inExpression.Item.TypeMapping)
?? Dependencies.TypeMappingSource.FindMapping(inExpression.Item.Type, Dependencies.Model);
?? _typeMappingSource.FindMapping(inExpression.Item.Type, Dependencies.Model);

var item = ApplyTypeMapping(inExpression.Item, itemTypeMapping);
if (inExpression.Values != null)
Expand All @@ -253,6 +263,41 @@ private SqlExpression ApplyTypeMappingOnIn(InExpression inExpression)
: inExpression;
}

private SqlExpression ApplyTypeMappingOnJsonScalar(
JsonScalarExpression jsonScalarExpression,
RelationalTypeMapping? typeMapping)
{
if (jsonScalarExpression is not { Json: var array, Path: [{ ArrayIndex: { } index }] })
{
return jsonScalarExpression;
}

// The index expression isn't inferred and is always just an int. Apply the default type mapping to it.
var indexWithTypeMapping = ApplyDefaultTypeMapping(index);
var newPath = indexWithTypeMapping == index ? jsonScalarExpression.Path : new[] { new PathSegment(indexWithTypeMapping) };

// If a type mapping is being applied from the outside, it applies to the element resulting from the array indexing operation;
// we can infer the array's type mapping from it. Otherwise there's nothing to do but apply the default type mapping to the array.
if (typeMapping is null)
{
return new JsonScalarExpression(
ApplyDefaultTypeMapping(array),
newPath,
jsonScalarExpression.Type,
_typeMappingSource.FindMapping(jsonScalarExpression.Type),
jsonScalarExpression.IsNullable);
}

// TODO: blocked on #30730: we need to be able to construct a JSON collection type mapping based on the element's.
// For now, hacking to apply the default type mapping instead.
return new JsonScalarExpression(
ApplyDefaultTypeMapping(array), // Hack, until #30730
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that here we'd infer the array's mapping from the element's inferred one, after #30730

newPath,
jsonScalarExpression.Type,
typeMapping,
jsonScalarExpression.IsNullable);
}

/// <inheritdoc />
public virtual SqlBinaryExpression? MakeBinary(
ExpressionType operatorType,
Expand Down Expand Up @@ -350,7 +395,7 @@ public virtual SqlFunctionExpression Coalesce(SqlExpression left, SqlExpression
var resultType = right.Type;
var inferredTypeMapping = typeMapping
?? ExpressionExtensions.InferTypeMapping(left, right)
?? Dependencies.TypeMappingSource.FindMapping(resultType, Dependencies.Model);
?? _typeMappingSource.FindMapping(resultType, Dependencies.Model);

var typeMappedArguments = new List<SqlExpression>
{
Expand Down Expand Up @@ -405,7 +450,7 @@ public virtual CaseExpression Case(SqlExpression? operand, IReadOnlyList<CaseWhe
// Since we never look at type of Operand/Test after this place,
// we need to find actual typeMapping based on non-object type.
?? new[] { operand.Type }.Concat(whenClauses.Select(wc => wc.Test.Type))
.Where(t => t != typeof(object)).Select(t => Dependencies.TypeMappingSource.FindMapping(t, Dependencies.Model))
.Where(t => t != typeof(object)).Select(t => _typeMappingSource.FindMapping(t, Dependencies.Model))
.FirstOrDefault();

var resultTypeMapping = elseResult?.TypeMapping
Expand Down Expand Up @@ -543,7 +588,7 @@ public virtual ExistsExpression Exists(SelectExpression subquery, bool negated)
/// <inheritdoc />
public virtual InExpression In(SqlExpression item, SqlExpression values, bool negated)
{
var typeMapping = item.TypeMapping ?? Dependencies.TypeMappingSource.FindMapping(item.Type, Dependencies.Model);
var typeMapping = item.TypeMapping ?? _typeMappingSource.FindMapping(item.Type, Dependencies.Model);

item = ApplyTypeMapping(item, typeMapping);
values = ApplyTypeMapping(values, typeMapping);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,36 +17,28 @@ public class JsonScalarExpression : SqlExpression
/// <summary>
/// Creates a new instance of the <see cref="JsonScalarExpression" /> class.
/// </summary>
/// <param name="jsonColumn">A column containg JSON value.</param>
/// <param name="property">A property representing the result of this expression.</param>
/// <param name="json">An expression representing a JSON value.</param>
/// <param name="type">The <see cref="System.Type" /> of the expression.</param>
/// <param name="typeMapping">The <see cref="RelationalTypeMapping" /> associated with the expression.</param>
/// <param name="path">A list of path segments leading to the scalar from the root of the JSON stored in the column.</param>
/// <param name="nullable">A value indicating whether the expression is nullable.</param>
public JsonScalarExpression(
ColumnExpression jsonColumn,
IProperty property,
IReadOnlyList<PathSegment> path,
bool nullable)
: this(jsonColumn, path, property.ClrType.UnwrapNullableType(), property.FindRelationalTypeMapping()!, nullable)
{
}

internal JsonScalarExpression(
ColumnExpression jsonColumn,
SqlExpression json,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes JsonScalarExpression to be over any arbitrary SqlExpression, not necessarily a ColumnExpression and without an IProperty. This is necessary to support indexing over JSON parameter collections.

IReadOnlyList<PathSegment> path,
Type type,
RelationalTypeMapping typeMapping,
RelationalTypeMapping? typeMapping,
bool nullable)
: base(type, typeMapping)
{
JsonColumn = jsonColumn;
Json = json;
Path = path;
IsNullable = nullable;
}

/// <summary>
/// The column containing the JSON value.
/// The expression containing the JSON value.
/// </summary>
public virtual ColumnExpression JsonColumn { get; }
public virtual SqlExpression Json { get; }

/// <summary>
/// The list of path segments leading to the scalar from the root of the JSON stored in the column.
Expand All @@ -61,46 +53,91 @@ internal JsonScalarExpression(
/// <inheritdoc />
protected override Expression VisitChildren(ExpressionVisitor visitor)
{
var jsonColumn = (ColumnExpression)visitor.Visit(JsonColumn);
var jsonColumnMadeNullable = jsonColumn.IsNullable && !JsonColumn.IsNullable;
var newJson = (SqlExpression)visitor.Visit(Json);

var nullable = IsNullable;
if (newJson is ColumnExpression jsonColumnExpression)
{
nullable |= jsonColumnExpression.IsNullable;
}

PathSegment[]? newPath = null;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that VisitChildren didn't visit inside array index expression in the path - added support for that.


for (var i = 0; i < Path.Count; i++)
{
var segment = Path[i];
PathSegment newSegment;

if (segment.PropertyName is not null)
{
// PropertyName segments are (currently) constants, nothing to visit.
newSegment = segment;
}
else
{
var newArrayIndex = (SqlExpression)visitor.Visit(segment.ArrayIndex)!;
if (newArrayIndex == segment.ArrayIndex)
{
newSegment = segment;
}
else
{
newSegment = new PathSegment(newArrayIndex);

if (newPath is null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you do it this way to avoid allocation in case all segments are the same? or is there some other reason? should we switch to this pattern everywhere we visit a collection?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's why... And yeah, I've been gradually switching to this pattern for new code I write...

Note that we may be able to extract this out to a utility function so as not to repeat it every time...

{
newPath = new PathSegment[Path.Count];
for (var j = 0; j < i; i++)
{
newPath[j] = Path[j];
}
}
}
}

if (newPath is not null)
{
newPath[i] = newSegment;
}
}

// TODO Call update: Issue#28887
return jsonColumn != JsonColumn
? new JsonScalarExpression(
jsonColumn,
Path,
return newJson == Json && newPath is null
? this
: new JsonScalarExpression(
newJson,
newPath ?? Path,
Type,
TypeMapping!,
IsNullable || jsonColumnMadeNullable)
: this;
nullable);
}

/// <summary>
/// Creates a new expression that is like this one, but using the supplied children. If all of the children are the same, it will
/// return this expression.
/// </summary>
/// <param name="jsonColumn">The <see cref="JsonColumn" /> property of the result.</param>
/// <param name="json">The <see cref="Json" /> property of the result.</param>
/// <returns>This expression if no children changed, or an expression with the updated children.</returns>
public virtual JsonScalarExpression Update(ColumnExpression jsonColumn)
=> jsonColumn != JsonColumn
? new JsonScalarExpression(jsonColumn, Path, Type, TypeMapping!, IsNullable)
public virtual JsonScalarExpression Update(SqlExpression json)
=> json != Json
? new JsonScalarExpression(json, Path, Type, TypeMapping!, IsNullable)
: this;

/// <inheritdoc />
protected override void Print(ExpressionPrinter expressionPrinter)
{
expressionPrinter.Append("JsonScalarExpression(column: ");
expressionPrinter.Visit(JsonColumn);
expressionPrinter.Visit(Json);
expressionPrinter.Append($""", "{string.Join(".", Path.Select(e => e.ToString()))}")""");
}

/// <inheritdoc />
public override bool Equals(object? obj)
=> obj is JsonScalarExpression jsonScalarExpression
&& JsonColumn.Equals(jsonScalarExpression.JsonColumn)
&& Json.Equals(jsonScalarExpression.Json)
&& Path.SequenceEqual(jsonScalarExpression.Path);

/// <inheritdoc />
public override int GetHashCode()
=> HashCode.Combine(base.GetHashCode(), JsonColumn, Path);
=> HashCode.Combine(base.GetHashCode(), Json, Path);
}
Original file line number Diff line number Diff line change
Expand Up @@ -1739,7 +1739,7 @@ static IReadOnlyList<IProperty> GetMappedKeyProperties(IKey key)

static bool JsonEntityContainedIn(JsonScalarExpression sourceExpression, JsonQueryExpression targetExpression)
{
if (sourceExpression.JsonColumn != targetExpression.JsonColumn)
if (sourceExpression.Json != targetExpression.JsonColumn)
{
return false;
}
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/EFCore.SqlServer/Properties/SqlServerStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@
<data name="InvalidTableToIncludeInScaffolding" xml:space="preserve">
<value>The specified table '{table}' is not in a valid format. Specify tables using the format '[schema].[table]'.</value>
</data>
<data name="JsonValuePathExpressionsNotSupported" xml:space="preserve">
<value>A non-constant array index or property name was used when navigating inside a JSON document; this is only supported starting with SQL Server 2017.</value>
</data>
<data name="LogByteIdentityColumn" xml:space="preserve">
<value>The property '{property}' on entity type '{entityType}' is of type 'byte', but is set up to use a SQL Server identity column; this requires that values starting at 255 and counting down will be used for temporary key values. A temporary key value is needed for every entity inserted in a single call to 'SaveChanges'. Care must be taken that these values do not collide with real key values.</value>
<comment>Warning SqlServerEventId.ByteIdentityColumnWarning string string</comment>
Expand Down
Loading