diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs
index 1248246cde2..4ae3413acf4 100644
--- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs
+++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs
@@ -819,6 +819,14 @@ public static string ExecuteOperationOnKeylessEntityTypeWithUnsupportedOperator(
GetString("ExecuteOperationOnKeylessEntityTypeWithUnsupportedOperator", nameof(operation), nameof(entityType)),
operation, entityType);
+ ///
+ /// '{operation}' used over owned type '{entityType}' which is mapped to JSON; '{operation}' on JSON-mapped owned entities is not supported. Consider mapping your type as a complex type instead.
+ ///
+ public static string ExecuteOperationOnOwnedJsonIsNotSupported(object? operation, object? entityType)
+ => string.Format(
+ GetString("ExecuteOperationOnOwnedJsonIsNotSupported", nameof(operation), nameof(entityType)),
+ operation, entityType);
+
///
/// The operation '{operation}' is being applied on entity type '{entityType}', which is using the TPC mapping strategy and is not a leaf type. 'ExecuteDelete'/'ExecuteUpdate' operations on entity types participating in TPC hierarchies is only supported for leaf types.
///
@@ -843,6 +851,18 @@ public static string ExecuteOperationWithUnsupportedOperatorInSqlGeneration(obje
GetString("ExecuteOperationWithUnsupportedOperatorInSqlGeneration", nameof(operation)),
operation);
+ ///
+ /// 'ExecuteUpdate' cannot currently set a property in a JSON column to a regular, non-JSON column; see https://github.com/dotnet/efcore/issues/36688.
+ ///
+ public static string ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn
+ => GetString("ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn");
+
+ ///
+ /// 'ExecuteUpdate' cannot currently set a property in a JSON column to arbitrary expressions; only constants, parameters and other JSON properties are supported; see https://github.com/dotnet/efcore/issues/36688.
+ ///
+ public static string ExecuteUpdateCannotSetJsonPropertyToArbitraryExpression
+ => GetString("ExecuteUpdateCannotSetJsonPropertyToArbitraryExpression");
+
///
/// 'ExecuteUpdate' or 'ExecuteDelete' was called on entity type '{entityType}', but that entity type is not mapped to a table.
///
@@ -1409,6 +1429,14 @@ public static string MissingParameterValue(object? parameter)
public static string MissingResultSetWhenSaving
=> GetString("MissingResultSetWhenSaving");
+ ///
+ /// Entity type '{entityType}' is mapped to multiple columns with name '{columnName}', and one of them is configured as a JSON column. Assign different names to the columns.
+ ///
+ public static string MultipleColumnsWithSameJsonContainerName(object? entityType, object? columnName)
+ => string.Format(
+ GetString("MultipleColumnsWithSameJsonContainerName", nameof(entityType), nameof(columnName)),
+ entityType, columnName);
+
///
/// Commands cannot be added to a completed 'ModificationCommandBatch'.
///
@@ -1637,6 +1665,18 @@ public static string ParameterNotObjectArray(object? parameter)
GetString("ParameterNotObjectArray", nameof(parameter)),
parameter);
+ ///
+ /// The provider in use does not support partial updates with ExecuteUpdate within JSON columns.
+ ///
+ public static string JsonPartialExecuteUpdateNotSupportedByProvider
+ => GetString("JsonPartialExecuteUpdateNotSupportedByProvider");
+
+ ///
+ /// ExecuteUpdate over JSON columns is not supported when the column is mapped as an owned entity. Map the column as a complex type instead.
+ ///
+ public static string JsonExecuteUpdateNotSupportedWithOwnedEntities
+ => GetString("JsonExecuteUpdateNotSupportedWithOwnedEntities");
+
///
/// This connection was used with an ambient transaction. The original ambient transaction needs to be completed before this connection can be used outside of it.
///
diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx
index c600ef49f15..8824ba59451 100644
--- a/src/EFCore.Relational/Properties/RelationalStrings.resx
+++ b/src/EFCore.Relational/Properties/RelationalStrings.resx
@@ -424,6 +424,9 @@
The operation '{operation}' cannot be performed on keyless entity type '{entityType}', since it contains an operator not natively supported by the database provider.
+
+ '{operation}' used over owned type '{entityType}' which is mapped to JSON; '{operation}' on JSON-mapped owned entities is not supported. Consider mapping your type as a complex type instead.
+
The operation '{operation}' is being applied on entity type '{entityType}', which is using the TPC mapping strategy and is not a leaf type. 'ExecuteDelete'/'ExecuteUpdate' operations on entity types participating in TPC hierarchies is only supported for leaf types.
@@ -433,6 +436,12 @@
The operation '{operation}' contains a select expression feature that isn't supported in the query SQL generator, but has been declared as supported by provider during translation phase. This is a bug in your EF Core provider, file an issue at https://aka.ms/efcorefeedback.
+
+ 'ExecuteUpdate' cannot currently set a property in a JSON column to a regular, non-JSON column; see https://github.com/dotnet/efcore/issues/36688.
+
+
+ 'ExecuteUpdate' cannot currently set a property in a JSON column to arbitrary expressions; only constants, parameters and other JSON properties are supported; see https://github.com/dotnet/efcore/issues/36688.
+
'ExecuteUpdate' or 'ExecuteDelete' was called on entity type '{entityType}', but that entity type is not mapped to a table.
@@ -979,6 +988,9 @@
A result set was missing when reading the results of a SaveChanges operation; this may indicate that a stored procedure was configured to return results in the EF model, but did not. Check your stored procedure definitions.
+
+ Entity type '{entityType}' is mapped to multiple columns with name '{columnName}', and one of them is configured as a JSON column. Assign different names to the columns.
+
Commands cannot be added to a completed 'ModificationCommandBatch'.
@@ -1072,6 +1084,12 @@
The value provided for parameter '{parameter}' cannot be used because it isn't assignable to type 'object[]'.
+
+ The provider in use does not support partial updates with ExecuteUpdate within JSON columns.
+
+
+ ExecuteUpdate over JSON columns is not supported when the column is mapped as an owned entity. Map the column as a complex type instead.
+
This connection was used with an ambient transaction. The original ambient transaction needs to be completed before this connection can be used outside of it.
diff --git a/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs b/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs
new file mode 100644
index 00000000000..74c8c180a66
--- /dev/null
+++ b/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs
@@ -0,0 +1,119 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections;
+using System.Text;
+using System.Text.Json;
+
+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 static class RelationalJsonUtilities
+{
+ ///
+ /// 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 static readonly MethodInfo SerializeComplexTypeToJsonMethod =
+ typeof(RelationalJsonUtilities).GetTypeInfo().GetDeclaredMethod(nameof(SerializeComplexTypeToJson))!;
+
+ ///
+ /// 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 static string? SerializeComplexTypeToJson(IComplexType complexType, object? value, bool collection)
+ {
+ // Note that we treat toplevel null differently: we return a relational NULL for that case. For nested nulls,
+ // we return JSON null string (so you get { "foo": null })
+ if (value is null)
+ {
+ return null;
+ }
+
+ var stream = new MemoryStream();
+ var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false });
+
+ WriteJson(writer, complexType, value, collection);
+
+ writer.Flush();
+
+ return Encoding.UTF8.GetString(stream.ToArray());
+
+ void WriteJson(Utf8JsonWriter writer, IComplexType complexType, object? value, bool collection)
+ {
+ if (collection)
+ {
+ if (value is null)
+ {
+ writer.WriteNullValue();
+
+ return;
+ }
+
+ writer.WriteStartArray();
+
+ foreach (var element in (IEnumerable)value)
+ {
+ WriteJsonObject(writer, complexType, element);
+ }
+
+ writer.WriteEndArray();
+ return;
+ }
+
+ WriteJsonObject(writer, complexType, value);
+ }
+
+ void WriteJsonObject(Utf8JsonWriter writer, IComplexType complexType, object? objectValue)
+ {
+ if (objectValue is null)
+ {
+ writer.WriteNullValue();
+ return;
+ }
+
+ writer.WriteStartObject();
+
+ foreach (var property in complexType.GetProperties())
+ {
+ var jsonPropertyName = property.GetJsonPropertyName();
+ Check.DebugAssert(jsonPropertyName is not null);
+ writer.WritePropertyName(jsonPropertyName);
+
+ var propertyValue = property.GetGetter().GetClrValue(objectValue);
+ if (propertyValue is null)
+ {
+ writer.WriteNullValue();
+ }
+ else
+ {
+ var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter;
+ Check.DebugAssert(jsonValueReaderWriter is not null, "Missing JsonValueReaderWriter on JSON property");
+ jsonValueReaderWriter.ToJson(writer, propertyValue);
+ }
+ }
+
+ foreach (var complexProperty in complexType.GetComplexProperties())
+ {
+ var jsonPropertyName = complexProperty.GetJsonPropertyName();
+ Check.DebugAssert(jsonPropertyName is not null);
+ writer.WritePropertyName(jsonPropertyName);
+
+ var propertyValue = complexProperty.GetGetter().GetClrValue(objectValue);
+
+ WriteJson(writer, complexProperty.ComplexType, propertyValue, complexProperty.IsCollection);
+ }
+
+ writer.WriteEndObject();
+ }
+ }
+}
diff --git a/src/EFCore.Relational/Query/QuerySqlGenerator.cs b/src/EFCore.Relational/Query/QuerySqlGenerator.cs
index bfbc714476b..34d79d6bf49 100644
--- a/src/EFCore.Relational/Query/QuerySqlGenerator.cs
+++ b/src/EFCore.Relational/Query/QuerySqlGenerator.cs
@@ -1459,20 +1459,33 @@ protected override Expression VisitUpdate(UpdateExpression updateExpression)
|| selectExpression.Tables[1] is CrossJoinExpression))
{
_relationalCommandBuilder.Append("UPDATE ");
+
Visit(updateExpression.Table);
+
_relationalCommandBuilder.AppendLine();
_relationalCommandBuilder.Append("SET ");
- _relationalCommandBuilder.Append(
- $"{_sqlGenerationHelper.DelimitIdentifier(updateExpression.ColumnValueSetters[0].Column.Name)} = ");
- Visit(updateExpression.ColumnValueSetters[0].Value);
- using (_relationalCommandBuilder.Indent())
+
+ for (var i = 0; i < updateExpression.ColumnValueSetters.Count; i++)
{
- foreach (var columnValueSetter in updateExpression.ColumnValueSetters.Skip(1))
+ if (i == 1)
+ {
+ Sql.IncrementIndent();
+ }
+
+ if (i > 0)
{
_relationalCommandBuilder.AppendLine(",");
- _relationalCommandBuilder.Append($"{_sqlGenerationHelper.DelimitIdentifier(columnValueSetter.Column.Name)} = ");
- Visit(columnValueSetter.Value);
}
+
+ var (column, value) = updateExpression.ColumnValueSetters[i];
+
+ _relationalCommandBuilder.Append(_sqlGenerationHelper.DelimitIdentifier(column.Name)).Append(" = ");
+ Visit(value);
+ }
+
+ if (updateExpression.ColumnValueSetters.Count > 1)
+ {
+ Sql.DecrementIndent();
}
var predicate = selectExpression.Predicate;
diff --git a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.ExecuteDelete.cs b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.ExecuteDelete.cs
index ec54a1f10e9..20b6ee353a7 100644
--- a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.ExecuteDelete.cs
+++ b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.ExecuteDelete.cs
@@ -19,23 +19,27 @@ public partial class RelationalQueryableMethodTranslatingExpressionVisitor
return null;
}
- var mappingStrategy = entityType.GetMappingStrategy();
- if (mappingStrategy == RelationalAnnotationNames.TptMappingStrategy)
+ if (entityType.IsMappedToJson())
{
AddTranslationErrorDetails(
- RelationalStrings.ExecuteOperationOnTPT(
- nameof(EntityFrameworkQueryableExtensions.ExecuteDelete), entityType.DisplayName()));
+ RelationalStrings.ExecuteOperationOnOwnedJsonIsNotSupported("ExecuteDelete", entityType.DisplayName()));
return null;
}
- if (mappingStrategy == RelationalAnnotationNames.TpcMappingStrategy
- && entityType.GetDirectlyDerivedTypes().Any())
+ switch (entityType.GetMappingStrategy())
{
- // We allow TPC is it is leaf type
- AddTranslationErrorDetails(
- RelationalStrings.ExecuteOperationOnTPC(
- nameof(EntityFrameworkQueryableExtensions.ExecuteDelete), entityType.DisplayName()));
- return null;
+ case RelationalAnnotationNames.TptMappingStrategy:
+ AddTranslationErrorDetails(
+ RelationalStrings.ExecuteOperationOnTPT(
+ nameof(EntityFrameworkQueryableExtensions.ExecuteDelete), entityType.DisplayName()));
+ return null;
+
+ // Note that we do allow TPC if the target is a leaf type
+ case RelationalAnnotationNames.TpcMappingStrategy when entityType.GetDirectlyDerivedTypes().Any():
+ AddTranslationErrorDetails(
+ RelationalStrings.ExecuteOperationOnTPC(
+ nameof(EntityFrameworkQueryableExtensions.ExecuteDelete), entityType.DisplayName()));
+ return null;
}
// Find the table model that maps to the entity type; there must be exactly one (e.g. no entity splitting).
diff --git a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.ExecuteUpdate.cs b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.ExecuteUpdate.cs
index cfe3bd09a37..bd5133f7c3f 100644
--- a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.ExecuteUpdate.cs
+++ b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.ExecuteUpdate.cs
@@ -2,8 +2,10 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics.CodeAnalysis;
+using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Microsoft.EntityFrameworkCore.Query.Internal;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
+using Microsoft.EntityFrameworkCore.Storage.Json;
using static Microsoft.EntityFrameworkCore.Infrastructure.ExpressionExtensions;
namespace Microsoft.EntityFrameworkCore.Query;
@@ -13,15 +15,15 @@ public partial class RelationalQueryableMethodTranslatingExpressionVisitor
private const string ExecuteUpdateRuntimeParameterPrefix = "complex_type_";
private static readonly MethodInfo ParameterValueExtractorMethod =
- typeof(RelationalSqlTranslatingExpressionVisitor).GetTypeInfo().GetDeclaredMethod(nameof(ParameterValueExtractor))!;
+ typeof(RelationalQueryableMethodTranslatingExpressionVisitor).GetTypeInfo().GetDeclaredMethod(nameof(ParameterValueExtractor))!;
+
+ private static readonly MethodInfo ParameterJsonSerializerMethod =
+ typeof(RelationalQueryableMethodTranslatingExpressionVisitor).GetTypeInfo().GetDeclaredMethod(nameof(ParameterJsonSerializer))!;
///
protected override UpdateExpression? TranslateExecuteUpdate(ShapedQueryExpression source, IReadOnlyList setters)
{
- if (setters.Count == 0)
- {
- throw new UnreachableException("Empty setters list");
- }
+ Check.DebugAssert(setters.Count > 0, "Empty setters list");
// Our source may have IncludeExpressions because of owned entities or auto-include; unwrap these, as they're meaningless for
// ExecuteUpdate's lambdas. Note that we don't currently support updates across tables.
@@ -32,12 +34,14 @@ public partial class RelationalQueryableMethodTranslatingExpressionVisitor
return null;
}
+ var selectExpression = (SelectExpression)source.QueryExpression;
+
// Translate the setters: the left (property) selectors get translated to ColumnExpressions, the right (value) selectors to
// arbitrary SqlExpressions.
// Note that if the query isn't natively supported, we'll do a pushdown (see PushdownWithPkInnerJoinPredicate below); if that
// happens, we'll have to re-translate the setters over the new query (which includes a JOIN). However, we still translate here
// since we need the target table in order to perform the check below.
- if (!TranslateSetters(source, setters, out var translatedSetters, out var targetTable))
+ if (!TryTranslateSetters(source, setters, out var translatedSetters, out var targetTable))
{
return null;
}
@@ -53,7 +57,6 @@ public partial class RelationalQueryableMethodTranslatingExpressionVisitor
// Check if the provider has a native translation for the update represented by the select expression.
// The default relational implementation handles simple, universally-supported cases (i.e. no operators except for predicate).
// Providers may override IsValidSelectExpressionForExecuteUpdate to add support for more cases via provider-specific UPDATE syntax.
- var selectExpression = (SelectExpression)source.QueryExpression;
if (IsValidSelectExpressionForExecuteUpdate(selectExpression, targetTable, out var tableExpression))
{
selectExpression.ReplaceProjection(new List());
@@ -64,337 +67,6 @@ public partial class RelationalQueryableMethodTranslatingExpressionVisitor
return PushdownWithPkInnerJoinPredicate();
- bool TranslateSetters(
- ShapedQueryExpression source,
- IReadOnlyList setters,
- [NotNullWhen(true)] out List? translatedSetters,
- [NotNullWhen(true)] out TableExpressionBase? targetTable)
- {
- var select = (SelectExpression)source.QueryExpression;
-
- targetTable = null;
- string? targetTableAlias = null;
- var tempTranslatedSetters = new List();
- translatedSetters = null;
-
- LambdaExpression? propertySelector;
- Expression? targetTablePropertySelector = null;
-
- foreach (var setter in setters)
- {
- (propertySelector, var valueSelector) = setter;
- var propertySelectorBody = RemapLambdaBody(source, propertySelector).UnwrapTypeConversion(out _);
-
- // The top-most node on the property selector must be a member access; chop it off to get the base expression and member.
- // We'll bind the member manually below, so as to get the IPropertyBase it represents - that's important for later.
- if (!IsMemberAccess(propertySelectorBody, QueryCompilationContext.Model, out var baseExpression, out var member))
- {
- AddTranslationErrorDetails(RelationalStrings.InvalidPropertyInSetProperty(propertySelector.Print()));
- return false;
- }
-
- if (!_sqlTranslator.TryBindMember(
- _sqlTranslator.Visit(baseExpression), member, out var translatedBaseExpression, out var propertyBase))
- {
- AddTranslationErrorDetails(RelationalStrings.InvalidPropertyInSetProperty(propertySelector.Print()));
- return false;
- }
-
- // Hack: when returning a StructuralTypeShaperExpression, _sqlTranslator returns it wrapped by a
- // StructuralTypeReferenceExpression, which is supposed to be a private wrapper only with the SQL translator.
- // Call TranslateProjection to unwrap it (need to look into getting rid StructuralTypeReferenceExpression altogether).
- translatedBaseExpression = _sqlTranslator.TranslateProjection(translatedBaseExpression);
-
- switch (translatedBaseExpression)
- {
- case ColumnExpression column:
- {
- if (propertyBase is not IProperty property)
- {
- throw new UnreachableException("Property selector translated to ColumnExpression but no IProperty");
- }
-
- var tableExpression = select.GetTable(column, out var tableIndex);
- if (tableExpression.UnwrapJoin() is TableExpression { Table: not ITable } unwrappedTableExpression)
- {
- // If the entity is also mapped to a view, the SelectExpression will refer to the view instead, since
- // translation happens with the assumption that we're querying, not deleting.
- // For this case, we must replace the TableExpression in the SelectExpression - referring to the view - with the
- // one that refers to the mutable table.
-
- // Get the column on the (mutable) table which corresponds to the property being set
- var targetColumnModel = property.DeclaringType.GetTableMappings()
- .SelectMany(tm => tm.ColumnMappings)
- .Where(cm => cm.Property == property)
- .Select(cm => cm.Column)
- .SingleOrDefault();
-
- if (targetColumnModel is null)
- {
- throw new InvalidOperationException(
- RelationalStrings.ExecuteUpdateDeleteOnEntityNotMappedToTable(property.DeclaringType.DisplayName()));
- }
-
- unwrappedTableExpression = new TableExpression(unwrappedTableExpression.Alias, targetColumnModel.Table);
- tableExpression = tableExpression is JoinExpressionBase join
- ? join.Update(unwrappedTableExpression)
- : unwrappedTableExpression;
- var newTables = select.Tables.ToList();
- newTables[tableIndex] = tableExpression;
-
- // Note that we need to keep the select mutable, because if IsValidSelectExpressionForExecuteDelete below
- // returns false, we need to compose on top of it.
- select.SetTables(newTables);
- }
-
- if (!IsColumnOnSameTable(column, propertySelector)
- || TranslateSqlSetterValueSelector(source, valueSelector, column) is not { } translatedValueSelector)
- {
- return false;
- }
-
- tempTranslatedSetters.Add(new ColumnValueSetter(column, translatedValueSelector));
- break;
- }
-
- // TODO: This is for column flattening; implement JSON complex type support as well (#28766)
- case StructuralTypeShaperExpression
- {
- StructuralType: IComplexType complexType,
- ValueBufferExpression: StructuralTypeProjectionExpression
- } shaper:
- {
- Check.DebugAssert(
- propertyBase is IComplexProperty complexProperty && complexProperty.ComplexType == complexType,
- "PropertyBase should be a complex property referring to the correct complex type");
-
- if (complexType.IsMappedToJson())
- {
- throw new InvalidOperationException(
- RelationalStrings.ExecuteUpdateOverJsonIsNotSupported(complexType.DisplayName()));
- }
-
- if (TranslateSetterValueSelector(source, valueSelector, shaper.Type) is not { } translatedValueSelector
- || !TryProcessComplexType(shaper, translatedValueSelector))
- {
- return false;
- }
-
- break;
- }
-
- default:
- AddTranslationErrorDetails(RelationalStrings.InvalidPropertyInSetProperty(propertySelector.Print()));
- return false;
- }
- }
-
- translatedSetters = tempTranslatedSetters;
-
- Check.DebugAssert(targetTableAlias is not null, "Target table alias should have a value");
- var selectExpression = (SelectExpression)source.QueryExpression;
- targetTable = selectExpression.Tables.First(t => t.GetRequiredAlias() == targetTableAlias);
-
- return true;
-
- bool IsColumnOnSameTable(ColumnExpression column, LambdaExpression propertySelector)
- {
- if (targetTableAlias is null)
- {
- targetTableAlias = column.TableAlias;
- targetTablePropertySelector = propertySelector;
- }
- else if (!ReferenceEquals(column.TableAlias, targetTableAlias))
- {
- AddTranslationErrorDetails(
- RelationalStrings.MultipleTablesInExecuteUpdate(
- propertySelector.Print(), targetTablePropertySelector!.Print()));
- return false;
- }
-
- return true;
- }
-
- // Recursively processes the complex types and all complex types referenced by it, adding setters fo all (non-complex)
- // properties.
- // Note that this only supports table splitting (where all columns are flattened to the table), but not JSON complex types (#28766).
- bool TryProcessComplexType(StructuralTypeShaperExpression shaperExpression, Expression valueExpression)
- {
- if (shaperExpression.StructuralType is not IComplexType complexType
- || shaperExpression.ValueBufferExpression is not StructuralTypeProjectionExpression projection)
- {
- return false;
- }
-
- foreach (var property in complexType.GetProperties())
- {
- var column = projection.BindProperty(property);
- if (!IsColumnOnSameTable(column, propertySelector))
- {
- return false;
- }
-
- var rewrittenValueSelector = CreatePropertyAccessExpression(valueExpression, property);
- if (TranslateSqlSetterValueSelector(
- source, rewrittenValueSelector, column) is not { } translatedValueSelector)
- {
- return false;
- }
-
- tempTranslatedSetters.Add(new ColumnValueSetter(column, translatedValueSelector));
- }
-
- foreach (var complexProperty in complexType.GetComplexProperties())
- {
- // Note that TranslateProjection currently returns null for StructuralTypeReferenceExpression with a subquery (as
- // opposed to a parameter); this ensures that we don't generate an efficient translation where the subquery is
- // duplicated for every property on the complex type.
- // TODO: Make this work by using a common table expression (CTE)
-
- if (complexProperty.ComplexType.IsMappedToJson())
- {
- throw new InvalidOperationException(
- RelationalStrings.ExecuteUpdateOverJsonIsNotSupported(complexProperty.ComplexType.DisplayName()));
- }
-
- var nestedShaperExpression = (StructuralTypeShaperExpression)projection.BindComplexProperty(complexProperty);
- var nestedValueExpression = CreateComplexPropertyAccessExpression(valueExpression, complexProperty);
- if (!TryProcessComplexType(nestedShaperExpression, nestedValueExpression))
- {
- return false;
- }
- }
-
- return true;
- }
-
- Expression CreatePropertyAccessExpression(Expression target, IProperty property)
- {
- return target is LambdaExpression lambda
- ? Expression.Lambda(Core(lambda.Body, property), lambda.Parameters[0])
- : Core(target, property);
-
- Expression Core(Expression target, IProperty property)
- {
- switch (target)
- {
- case SqlConstantExpression constantExpression:
- return Expression.Constant(
- constantExpression.Value is null
- ? null
- : property.GetGetter().GetClrValue(constantExpression.Value),
- property.ClrType.MakeNullable());
-
- case SqlParameterExpression parameterExpression:
- {
- var lambda = Expression.Lambda(
- Expression.Call(
- ParameterValueExtractorMethod.MakeGenericMethod(property.ClrType.MakeNullable()),
- QueryCompilationContext.QueryContextParameter,
- Expression.Constant(parameterExpression.Name, typeof(string)),
- Expression.Constant(null, typeof(List)),
- Expression.Constant(property, typeof(IProperty))),
- QueryCompilationContext.QueryContextParameter);
-
- var newParameterName =
- $"{ExecuteUpdateRuntimeParameterPrefix}{parameterExpression.Name}_{property.Name}";
-
- return _queryCompilationContext.RegisterRuntimeParameter(newParameterName, lambda);
- }
-
- case ParameterBasedComplexPropertyChainExpression chainExpression:
- {
- var lambda = Expression.Lambda(
- Expression.Call(
- ParameterValueExtractorMethod.MakeGenericMethod(property.ClrType.MakeNullable()),
- QueryCompilationContext.QueryContextParameter,
- Expression.Constant(chainExpression.ParameterExpression.Name, typeof(string)),
- Expression.Constant(chainExpression.ComplexPropertyChain, typeof(List)),
- Expression.Constant(property, typeof(IProperty))),
- QueryCompilationContext.QueryContextParameter);
-
- var newParameterName =
- $"{ExecuteUpdateRuntimeParameterPrefix}{chainExpression.ParameterExpression.Name}_{property.Name}";
-
- return _queryCompilationContext.RegisterRuntimeParameter(newParameterName, lambda);
- }
-
- case MemberInitExpression memberInitExpression
- when memberInitExpression.Bindings.SingleOrDefault(mb => mb.Member.Name == property.Name) is MemberAssignment
- memberAssignment:
- return memberAssignment.Expression;
-
- default:
- return target.CreateEFPropertyExpression(property);
- }
- }
- }
-
- Expression CreateComplexPropertyAccessExpression(Expression target, IComplexProperty complexProperty)
- {
- return target is LambdaExpression lambda
- ? Expression.Lambda(Core(lambda.Body, complexProperty), lambda.Parameters[0])
- : Core(target, complexProperty);
-
- Expression Core(Expression target, IComplexProperty complexProperty)
- => target switch
- {
- SqlConstantExpression constant => _sqlExpressionFactory.Constant(
- constant.Value is null ? null : complexProperty.GetGetter().GetClrValue(constant.Value),
- complexProperty.ClrType.MakeNullable()),
-
- SqlParameterExpression parameter
- => new ParameterBasedComplexPropertyChainExpression(parameter, complexProperty),
-
- StructuralTypeShaperExpression
- {
- StructuralType: IComplexType,
- ValueBufferExpression: StructuralTypeProjectionExpression projection
- }
- => projection.BindComplexProperty(complexProperty),
-
- _ => throw new UnreachableException()
- };
- }
- }
-
- SqlExpression? TranslateSqlSetterValueSelector(
- ShapedQueryExpression source,
- Expression valueSelector,
- ColumnExpression column)
- {
- if (TranslateSetterValueSelector(source, valueSelector, column.Type) is SqlExpression translatedSelector)
- {
- // Apply the type mapping of the column (translated from the property selector above) to the value
- translatedSelector = _sqlExpressionFactory.ApplyTypeMapping(translatedSelector, column.TypeMapping);
- return translatedSelector;
- }
-
- AddTranslationErrorDetails(RelationalStrings.InvalidValueInSetProperty(valueSelector.Print()));
- return null;
- }
-
- Expression? TranslateSetterValueSelector(ShapedQueryExpression source, Expression valueSelector, Type propertyType)
- {
- var remappedValueSelector = valueSelector is LambdaExpression lambdaExpression
- ? RemapLambdaBody(source, lambdaExpression)
- : valueSelector;
-
- if (remappedValueSelector.Type != propertyType)
- {
- remappedValueSelector = Expression.Convert(remappedValueSelector, propertyType);
- }
-
- if (_sqlTranslator.TranslateProjection(remappedValueSelector, applyDefaultTypeMapping: false) is not
- { } translatedValueSelector)
- {
- AddTranslationErrorDetails(RelationalStrings.InvalidValueInSetProperty(valueSelector.Print()));
- return null;
- }
-
- return translatedValueSelector;
- }
-
UpdateExpression? PushdownWithPkInnerJoinPredicate()
{
// The provider doesn't natively support the update.
@@ -423,6 +95,7 @@ SqlParameterExpression parameter
return null;
}
+ // TODO: #36336
if (shaper.StructuralType is not IEntityType entityType)
{
AddTranslationErrorDetails(
@@ -487,7 +160,7 @@ SqlParameterExpression parameter
// Re-translate the property selectors to get column expressions pointing to the new outer select expression (the original one
// has been pushed down into a subquery).
- if (!TranslateSetters(outer, rewrittenSetters, out var translatedSetters, out _))
+ if (!TryTranslateSetters(outer, rewrittenSetters, out var translatedSetters, out _))
{
return null;
}
@@ -570,6 +243,670 @@ protected virtual bool IsValidSelectExpressionForExecuteUpdate(
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.
+ ///
+ [EntityFrameworkInternal]
+ protected virtual bool TryTranslateSetters(
+ ShapedQueryExpression source,
+ IReadOnlyList setters,
+ [NotNullWhen(true)] out IReadOnlyList? columnSetters,
+ [NotNullWhen(true)] out TableExpressionBase? targetTable)
+ {
+ var select = (SelectExpression)source.QueryExpression;
+
+ targetTable = null;
+ string? targetTableAlias = null;
+ var mutableColumnSetters = new List();
+ columnSetters = null;
+
+ Expression? targetTablePropertySelector = null;
+
+ foreach (var setter in setters)
+ {
+ var (propertySelector, valueSelector) = setter;
+ var propertySelectorBody = RemapLambdaBody(source, propertySelector).UnwrapTypeConversion(out _);
+
+ // The top-most node on the property selector must be a member access; chop it off to get the base expression and member.
+ // We'll bind the member manually below, so as to get the IPropertyBase it represents - that's important for later.
+ if (!IsMemberAccess(propertySelectorBody, QueryCompilationContext.Model, out var baseExpression, out var member)
+ || !_sqlTranslator.TryBindMember(
+ _sqlTranslator.Visit(baseExpression), member, out var target, out var targetProperty))
+ {
+ AddTranslationErrorDetails(RelationalStrings.InvalidPropertyInSetProperty(propertySelector.Print()));
+ return false;
+ }
+
+ if (targetProperty.DeclaringType is IEntityType entityType && entityType.IsMappedToJson())
+ {
+ AddTranslationErrorDetails(RelationalStrings.ExecuteOperationOnOwnedJsonIsNotSupported("ExecuteUpdate", entityType.DisplayName()));
+ return false;
+ }
+
+ // Hack: when returning a StructuralTypeShaperExpression, _sqlTranslator returns it wrapped by a
+ // StructuralTypeReferenceExpression, which is supposed to be a private wrapper only with the SQL translator.
+ // Call TranslateProjection to unwrap it (need to look into getting rid StructuralTypeReferenceExpression altogether).
+ if (target is not CollectionResultExpression)
+ {
+ target = _sqlTranslator.TranslateProjection(target);
+ }
+
+ switch (target)
+ {
+ case ColumnExpression column:
+ {
+ Check.DebugAssert(column.TypeMapping is not null);
+
+ if (!TryProcessColumn(column)
+ || !TryTranslateScalarSetterValueSelector(
+ source, valueSelector, column.Type, column.TypeMapping, out var translatedValue))
+ {
+ return false;
+ }
+
+ mutableColumnSetters.Add(new(column, translatedValue));
+ break;
+ }
+
+ // A table-split complex type is being assigned a new value.
+ // Generate setters for each of the columns mapped to the comlex type.
+ case StructuralTypeShaperExpression
+ {
+ StructuralType: IComplexType complexType,
+ ValueBufferExpression: StructuralTypeProjectionExpression
+ } shaper:
+ {
+ Check.DebugAssert(
+ targetProperty is IComplexProperty complexProperty && complexProperty.ComplexType == complexType,
+ "PropertyBase should be a complex property referring to the correct complex type");
+
+ if (complexType.IsMappedToJson())
+ {
+ throw new InvalidOperationException(
+ RelationalStrings.ExecuteUpdateOverJsonIsNotSupported(complexType.DisplayName()));
+ }
+
+ if (!TryTranslateSetterValueSelector(source, valueSelector, shaper.Type, out var translatedValue)
+ || !TryProcessComplexType(shaper, translatedValue))
+ {
+ return false;
+ }
+
+ break;
+ }
+
+ case JsonScalarExpression { Json: ColumnExpression jsonColumn } jsonScalar:
+ {
+ var typeMapping = jsonScalar.TypeMapping;
+ Check.DebugAssert(typeMapping is not null);
+
+ // We should never see a JsonScalarExpression without a path - that means we're mapping a JSON scalar directly to a relational column.
+ // This is in theory possible (e.g. map a DateTime to a 'json' column with a single string timestamp representation inside, instead of to
+ // SQL Server datetime2), but contrived and unsupported.
+ Check.DebugAssert(jsonScalar.Path.Count > 0);
+
+ if (!TryProcessColumn(jsonColumn)
+ || !TryTranslateScalarSetterValueSelector(source, valueSelector, jsonScalar.Type, typeMapping, out var translatedValue))
+ {
+ return false;
+ }
+
+ // We now have the relational scalar expression for the value; but we need the JSON representation to pass to the provider's JSON modification
+ // function (e.g. SQL Server JSON_MODIFY()).
+ // For example, for a DateTime we'd have e.g. a SqlConstantExpression containing a DateTime instance, but we need a string containing
+ // the JSON-encoded ISO8601 representation.
+ if (!TrySerializeScalarToJson(jsonScalar, translatedValue, out var jsonValue))
+ {
+ throw new InvalidOperationException(
+ translatedValue is ColumnExpression
+ ? RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn
+ : RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToArbitraryExpression);
+ }
+
+ // We now have a serialized JSON value (number, string or bool) - generate a setter for it.
+ GenerateJsonPartialUpdateSetterWrapper(jsonScalar, jsonColumn, jsonValue);
+ continue;
+ }
+
+ case StructuralTypeShaperExpression { ValueBufferExpression: JsonQueryExpression jsonQuery }:
+ if (!TryProcessStructuralJsonSetter(jsonQuery))
+ {
+ return false;
+ }
+
+ continue;
+
+ case CollectionResultExpression { QueryExpression: JsonQueryExpression jsonQuery }:
+ if (!TryProcessStructuralJsonSetter(jsonQuery))
+ {
+ return false;
+ }
+
+ continue;
+
+ default:
+ AddTranslationErrorDetails(RelationalStrings.InvalidPropertyInSetProperty(propertySelector.Print()));
+ return false;
+ }
+
+ void GenerateJsonPartialUpdateSetterWrapper(Expression target, ColumnExpression jsonColumn, SqlExpression value)
+ {
+ var index = mutableColumnSetters.FindIndex(s => s.Column.Equals(jsonColumn));
+ var origExistingSetterValue = index == -1 ? null : mutableColumnSetters[index].Value;
+ var modifiedExistingSetterValue = index == -1 ? null : mutableColumnSetters[index].Value;
+ var newSetter = GenerateJsonPartialUpdateSetter(target, value, ref modifiedExistingSetterValue);
+
+ if (origExistingSetterValue is null ^ modifiedExistingSetterValue is null)
+ {
+ throw new UnreachableException(
+ "existingSetterValue should only be used to compose additional setters on an existing setter");
+ }
+
+ if (!ReferenceEquals(modifiedExistingSetterValue, origExistingSetterValue))
+ {
+ mutableColumnSetters[index] = new(jsonColumn, modifiedExistingSetterValue!);
+ }
+
+ if (newSetter is not null)
+ {
+ mutableColumnSetters.Add(new(jsonColumn, newSetter));
+ }
+ }
+
+ bool TryProcessColumn(ColumnExpression column)
+ {
+ var tableExpression = select.GetTable(column, out var tableIndex);
+ if (tableExpression.UnwrapJoin() is TableExpression { Table: not ITable } unwrappedTableExpression)
+ {
+ // If the entity is also mapped to a view, the SelectExpression will refer to the view instead, since
+ // translation happens with the assumption that we're querying, not deleting.
+ // For this case, we must replace the TableExpression in the SelectExpression - referring to the view - with the
+ // one that refers to the mutable table.
+
+ // Get the column on the (mutable) table which corresponds to the property being set
+ IColumn? targetColumnModel;
+
+ switch (targetProperty)
+ {
+ // Note that we've already validated in TranslateExecuteUpdate that there can't be properties mapped to
+ // multiple columns (e.g. TPC)
+ case IProperty property:
+ targetColumnModel = property.DeclaringType.GetTableMappings()
+ .SelectMany(tm => tm.ColumnMappings)
+ .Where(cm => cm.Property == property)
+ .Select(cm => cm.Column)
+ .SingleOrDefault();
+ break;
+
+ case IComplexProperty { ComplexType: var complexType } complexProperty:
+ {
+ // Find the container column in the relational model to get its type mapping
+ // Note that we assume exactly one column with the given name mapped to the entity (despite entity splitting).
+ // See #36647 and #36646 about improving this.
+ var containerColumnName = complexType.GetContainerColumnName();
+ targetColumnModel = complexType.ContainingEntityType.GetTableMappings()
+ .SelectMany(m => m.Table.Columns)
+ .Where(c => c.Name == containerColumnName)
+ .Single();
+
+ break;
+ }
+
+ default:
+ throw new UnreachableException();
+ }
+
+ if (targetColumnModel is null)
+ {
+ throw new InvalidOperationException(
+ RelationalStrings.ExecuteUpdateDeleteOnEntityNotMappedToTable(targetProperty.DeclaringType.DisplayName()));
+ }
+
+ unwrappedTableExpression = new TableExpression(unwrappedTableExpression.Alias, targetColumnModel.Table);
+ tableExpression = tableExpression is JoinExpressionBase join
+ ? join.Update(unwrappedTableExpression)
+ : unwrappedTableExpression;
+ var newTables = select.Tables.ToList();
+ newTables[tableIndex] = tableExpression;
+
+ // Note that we need to keep the select mutable, because if IsValidSelectExpressionForExecuteDelete below
+ // returns false, we need to compose on top of it.
+ select.SetTables(newTables);
+ }
+
+ return IsColumnOnSameTable(column, propertySelector);
+ }
+
+ // Recursively processes the complex types and all complex types referenced by it, adding setters fo all (non-complex)
+ // properties.
+ // Note that this only supports table splitting (where all columns are flattened to the table), but not JSON complex types (#28766).
+ bool TryProcessComplexType(StructuralTypeShaperExpression shaperExpression, Expression valueExpression)
+ {
+ if (shaperExpression.StructuralType is not IComplexType complexType
+ || shaperExpression.ValueBufferExpression is not StructuralTypeProjectionExpression projection)
+ {
+ return false;
+ }
+
+ foreach (var property in complexType.GetProperties())
+ {
+ var column = projection.BindProperty(property);
+ if (!IsColumnOnSameTable(column, propertySelector))
+ {
+ return false;
+ }
+
+ var rewrittenValueSelector = CreatePropertyAccessExpression(valueExpression, property);
+ if (!TryTranslateScalarSetterValueSelector(
+ source, rewrittenValueSelector, column.Type, column.TypeMapping!, out var translatedValueSelector))
+ {
+ return false;
+ }
+
+ mutableColumnSetters.Add(new ColumnValueSetter(column, translatedValueSelector));
+ }
+
+ foreach (var complexProperty in complexType.GetComplexProperties())
+ {
+ // Note that TranslateProjection currently returns null for StructuralTypeReferenceExpression with a subquery (as
+ // opposed to a parameter); this ensures that we don't generate an efficient translation where the subquery is
+ // duplicated for every property on the complex type.
+ // TODO: Make this work by using a common table expression (CTE)
+
+ if (complexProperty.ComplexType.IsMappedToJson())
+ {
+ throw new InvalidOperationException(
+ RelationalStrings.ExecuteUpdateOverJsonIsNotSupported(complexProperty.ComplexType.DisplayName()));
+ }
+
+ var nestedShaperExpression = (StructuralTypeShaperExpression)projection.BindComplexProperty(complexProperty);
+ var nestedValueExpression = CreateComplexPropertyAccessExpression(valueExpression, complexProperty);
+ if (!TryProcessComplexType(nestedShaperExpression, nestedValueExpression))
+ {
+ return false;
+ }
+ }
+
+ return true;
+
+ Expression CreatePropertyAccessExpression(Expression target, IProperty property)
+ {
+ return target is LambdaExpression lambda
+ ? Expression.Lambda(Core(lambda.Body, property), lambda.Parameters[0])
+ : Core(target, property);
+
+ Expression Core(Expression target, IProperty property)
+ {
+ switch (target)
+ {
+ case SqlConstantExpression constantExpression:
+ return Expression.Constant(
+ constantExpression.Value is null
+ ? null
+ : property.GetGetter().GetClrValue(constantExpression.Value),
+ property.ClrType.MakeNullable());
+
+ case SqlParameterExpression parameterExpression:
+ {
+ var lambda = Expression.Lambda(
+ Expression.Call(
+ ParameterValueExtractorMethod.MakeGenericMethod(property.ClrType.MakeNullable()),
+ QueryCompilationContext.QueryContextParameter,
+ Expression.Constant(parameterExpression.Name, typeof(string)),
+ Expression.Constant(null, typeof(List)),
+ Expression.Constant(property, typeof(IProperty))),
+ QueryCompilationContext.QueryContextParameter);
+
+ var newParameterName =
+ $"{ExecuteUpdateRuntimeParameterPrefix}{parameterExpression.Name}_{property.Name}";
+
+ return _queryCompilationContext.RegisterRuntimeParameter(newParameterName, lambda);
+ }
+
+ case ParameterBasedComplexPropertyChainExpression chainExpression:
+ {
+ var lambda = Expression.Lambda(
+ Expression.Call(
+ ParameterValueExtractorMethod.MakeGenericMethod(property.ClrType.MakeNullable()),
+ QueryCompilationContext.QueryContextParameter,
+ Expression.Constant(chainExpression.ParameterExpression.Name, typeof(string)),
+ Expression.Constant(chainExpression.ComplexPropertyChain, typeof(List)),
+ Expression.Constant(property, typeof(IProperty))),
+ QueryCompilationContext.QueryContextParameter);
+
+ var newParameterName =
+ $"{ExecuteUpdateRuntimeParameterPrefix}{chainExpression.ParameterExpression.Name}_{property.Name}";
+
+ return _queryCompilationContext.RegisterRuntimeParameter(newParameterName, lambda);
+ }
+
+ case MemberInitExpression memberInitExpression
+ when memberInitExpression.Bindings.SingleOrDefault(mb => mb.Member.Name == property.Name) is MemberAssignment
+ memberAssignment:
+ return memberAssignment.Expression;
+
+ default:
+ return target.CreateEFPropertyExpression(property);
+ }
+ }
+ }
+
+ Expression CreateComplexPropertyAccessExpression(Expression target, IComplexProperty complexProperty)
+ {
+ return target is LambdaExpression lambda
+ ? Expression.Lambda(Core(lambda.Body, complexProperty), lambda.Parameters[0])
+ : Core(target, complexProperty);
+
+ Expression Core(Expression target, IComplexProperty complexProperty)
+ => target switch
+ {
+ SqlConstantExpression constant => _sqlExpressionFactory.Constant(
+ constant.Value is null ? null : complexProperty.GetGetter().GetClrValue(constant.Value),
+ complexProperty.ClrType.MakeNullable()),
+
+ SqlParameterExpression parameter
+ => new ParameterBasedComplexPropertyChainExpression(parameter, complexProperty),
+
+ StructuralTypeShaperExpression
+ {
+ StructuralType: IComplexType,
+ ValueBufferExpression: StructuralTypeProjectionExpression projection
+ }
+ => projection.BindComplexProperty(complexProperty),
+
+ _ => throw new UnreachableException()
+ };
+ }
+ }
+
+ bool TryProcessStructuralJsonSetter(JsonQueryExpression jsonQuery)
+ {
+ var jsonColumn = jsonQuery.JsonColumn;
+
+ if (jsonQuery.StructuralType is not IComplexType complexType)
+ {
+ throw new InvalidOperationException(RelationalStrings.JsonExecuteUpdateNotSupportedWithOwnedEntities);
+ }
+
+ Check.DebugAssert(jsonColumn.TypeMapping is not null);
+
+ if (!TryProcessColumn(jsonColumn)
+ || !TryTranslateSetterValueSelector(source, valueSelector, jsonQuery.Type, out var translatedValue))
+ {
+ return false;
+ }
+
+ SqlExpression? serializedValue;
+
+ switch (translatedValue)
+ {
+ // When an object is instantiated inline (e.g. SetProperty(c => c.ShippingAddress, c => new Address { ... })), we get a SqlConstantExpression
+ // with the .NET instance. Serialize it to JSON and replace the constant (note that the type mapping is inferred from the
+ // JSON column on other side - important for e.g. nvarchar vs. json columns)
+ case SqlConstantExpression { Value: var value }:
+ serializedValue = new SqlConstantExpression(
+ RelationalJsonUtilities.SerializeComplexTypeToJson(complexType, value, jsonQuery.IsCollection),
+ typeof(string),
+ typeMapping: jsonColumn.TypeMapping);
+ break;
+
+ case SqlParameterExpression parameter:
+ {
+ var queryParameter = _queryCompilationContext.RegisterRuntimeParameter(
+ $"{ExecuteUpdateRuntimeParameterPrefix}{parameter.Name}",
+ Expression.Lambda(
+ Expression.Call(
+ RelationalJsonUtilities.SerializeComplexTypeToJsonMethod,
+ Expression.Constant(complexType),
+ Expression.MakeIndex(
+ Expression.Property(
+ QueryCompilationContext.QueryContextParameter, nameof(QueryContext.Parameters)),
+ indexer: typeof(Dictionary).GetProperty("Item", [typeof(string)]),
+ [Expression.Constant(parameter.Name, typeof(string))]),
+ Expression.Constant(jsonQuery.IsCollection)),
+ QueryCompilationContext.QueryContextParameter));
+
+ serializedValue = _sqlExpressionFactory.ApplyTypeMapping(
+ _sqlTranslator.Translate(queryParameter, applyDefaultTypeMapping: false),
+ jsonColumn.TypeMapping)!;
+ break;
+ }
+
+ case RelationalStructuralTypeShaperExpression { ValueBufferExpression: JsonQueryExpression valueJsonQuery }:
+ serializedValue = ProcessJsonQuery(valueJsonQuery);
+ break;
+
+ case CollectionResultExpression { QueryExpression: JsonQueryExpression valueJsonQuery }:
+ serializedValue = ProcessJsonQuery(valueJsonQuery);
+ break;
+
+ default:
+ throw new UnreachableException();
+ }
+
+ // If the entire JSON column is being referenced as the target, remove the JsonQueryExpression altogether
+ // and just add a plain old setter updating the column as a whole; since this scenario doesn't involve any
+ // partial update, we can just add the setter directly without going through the provider's TranslateJsonSetter
+ // (see #30768 for stopping producing empty Json{Scalar,Query}Expressions).
+ // Otherwise, call the TranslateJsonSetter hook to produce the provider-specific syntax for JSON partial update.
+ if (jsonQuery.Path is [])
+ {
+ mutableColumnSetters.Add(new ColumnValueSetter(jsonColumn, serializedValue));
+ }
+ else
+ {
+ GenerateJsonPartialUpdateSetterWrapper(jsonQuery, jsonColumn, serializedValue);
+ }
+
+ return true;
+ }
+
+ bool TryTranslateScalarSetterValueSelector(
+ ShapedQueryExpression source,
+ Expression valueSelector,
+ Type type,
+ RelationalTypeMapping typeMapping,
+ [NotNullWhen(true)] out SqlExpression? result)
+ {
+ if (TryTranslateSetterValueSelector(source, valueSelector, type, out var tempResult)
+ && tempResult is SqlExpression translatedSelector)
+ {
+ // Apply the type mapping of the column (translated from the property selector above) to the value
+ result = _sqlExpressionFactory.ApplyTypeMapping(translatedSelector, typeMapping);
+ return true;
+ }
+
+ AddTranslationErrorDetails(RelationalStrings.InvalidValueInSetProperty(valueSelector.Print()));
+ result = null;
+ return false;
+ }
+
+ bool TryTranslateSetterValueSelector(
+ ShapedQueryExpression source,
+ Expression valueSelector,
+ Type propertyType,
+ [NotNullWhen(true)] out Expression? result)
+ {
+ var remappedValueSelector = valueSelector is LambdaExpression lambdaExpression
+ ? RemapLambdaBody(source, lambdaExpression)
+ : valueSelector;
+
+ if (remappedValueSelector.Type != propertyType)
+ {
+ remappedValueSelector = Expression.Convert(remappedValueSelector, propertyType);
+ }
+
+ result = _sqlTranslator.TranslateProjection(remappedValueSelector, applyDefaultTypeMapping: false);
+
+ if (result is null)
+ {
+ AddTranslationErrorDetails(RelationalStrings.InvalidValueInSetProperty(valueSelector.Print()));
+ return false;
+ }
+
+ return true;
+ }
+
+ bool IsColumnOnSameTable(ColumnExpression column, LambdaExpression propertySelector)
+ {
+ if (targetTableAlias is null)
+ {
+ targetTableAlias = column.TableAlias;
+ targetTablePropertySelector = propertySelector;
+ }
+ else if (column.TableAlias != targetTableAlias)
+ {
+ AddTranslationErrorDetails(
+ RelationalStrings.MultipleTablesInExecuteUpdate(propertySelector.Print(), targetTablePropertySelector!.Print()));
+ return false;
+ }
+
+ return true;
+ }
+
+ // If the entire JSON column is being referenced, remove the JsonQueryExpression altogether and just return
+ // the column (no need for special JSON modification functions/syntax).
+ // See #30768 for stopping producing empty Json{Scalar,Query}Expressions.
+ // Otherwise, convert the JsonQueryExpression to a JsonScalarExpression, which is our current representation for a complex
+ // JSON in the SQL tree (as opposed to in the shaper) - see #36392.
+ static SqlExpression ProcessJsonQuery(JsonQueryExpression jsonQuery)
+ => jsonQuery.Path is []
+ ? jsonQuery.JsonColumn
+ : new JsonScalarExpression(
+ jsonQuery.JsonColumn,
+ jsonQuery.Path,
+ jsonQuery.Type,
+ jsonQuery.JsonColumn.TypeMapping,
+ jsonQuery.IsNullable);
+ }
+
+ Check.DebugAssert(targetTableAlias is not null, "Target table alias should have a value");
+ var selectExpression = (SelectExpression)source.QueryExpression;
+ targetTable = selectExpression.Tables.First(t => t.GetRequiredAlias() == targetTableAlias);
+ columnSetters = mutableColumnSetters;
+
+ return true;
+ }
+
+ ///
+ /// Serializes a relational scalar value to JSON for partial updating within a JSON column within
+ /// .
+ ///
+ /// The expression representing the JSON scalar property to be updated.
+ /// A translated value (SqlConstantExpression, JsonScalarExpression) to serialize.
+ ///
+ /// The result expression representing a JSON expression ready to be passed to the provider's JSON partial
+ /// update function.
+ ///
+ /// A scalar expression ready to be integrated into an UPDATE statement setter.
+ protected virtual bool TrySerializeScalarToJson(
+ JsonScalarExpression target,
+ SqlExpression value,
+ [NotNullWhen(true)] out SqlExpression? jsonValue)
+ {
+ var typeMapping = value.TypeMapping;
+ Check.DebugAssert(typeMapping is not null);
+
+ // First, for the types natively supported in JSON (int, string, bool), just pass these in as is, since the JSON functions support these
+ // directly across databases.
+ var providerClrType = (typeMapping.Converter?.ProviderClrType ?? value.Type).UnwrapNullableType();
+ if (providerClrType.IsNumeric()
+ || providerClrType == typeof(string)
+ || providerClrType == typeof(bool))
+ {
+ jsonValue = value;
+ return true;
+ }
+
+ Check.DebugAssert(typeMapping.JsonValueReaderWriter is not null, "Missing JsonValueReaderWriter on JSON property");
+ var stringTypeMapping = _typeMappingSource.FindMapping(typeof(string))!;
+
+ switch (value)
+ {
+ // When an object is instantiated inline (e.g. SetProperty(c => c.ShippingAddress, c => new Address { ... })), we get a SqlConstantExpression
+ // with the .NET instance. Serialize it to JSON and replace the constant.
+ case SqlConstantExpression { Value: var constantValue }:
+ {
+ string? jsonString;
+
+ if (constantValue is null)
+ {
+ jsonString = null;
+ }
+ else
+ {
+ // We should only be here for things that get serialized to strings.
+ // Non-string JSON types (number, bool) should have been checked beforehand and handled differently.
+ jsonString = typeMapping.JsonValueReaderWriter.ToJsonString(constantValue);
+ Check.DebugAssert(jsonString.StartsWith('"') && jsonString.EndsWith('"'));
+ jsonString = jsonString[1..^1];
+ }
+
+ jsonValue = new SqlConstantExpression(jsonString, typeof(string), stringTypeMapping);
+ return true;
+ }
+
+ case SqlParameterExpression sqlParameter:
+ {
+ var queryParameter = _queryCompilationContext.RegisterRuntimeParameter(
+ $"{ExecuteUpdateRuntimeParameterPrefix}{sqlParameter.Name}",
+ Expression.Lambda(
+ Expression.Call(
+ ParameterJsonSerializerMethod,
+ QueryCompilationContext.QueryContextParameter,
+ Expression.Constant(sqlParameter.Name, typeof(string)),
+ Expression.Constant(typeMapping.JsonValueReaderWriter, typeof(JsonValueReaderWriter))),
+ QueryCompilationContext.QueryContextParameter));
+ jsonValue = (SqlParameterExpression)_sqlExpressionFactory.ApplyTypeMapping(
+ _sqlTranslator.Translate(queryParameter, applyDefaultTypeMapping: false),
+ stringTypeMapping)!;
+ return true;
+ }
+
+ case JsonScalarExpression jsonScalarValue:
+ // The JSON scalar property is being assigned to another JSON scalar property.
+ // In principle this is easy - just copy the property value across; but the JsonScalarExpression
+ // is typed for its relational value (e.g. datetime2), which we can't pass the the partial update
+ // function.
+ // Since int and bool have been handled above, simply retype the JsonScalarExpression to return
+ // a string instead, to get the raw JSON representation.
+ jsonValue = new JsonScalarExpression(
+ jsonScalarValue.Json,
+ jsonScalarValue.Path,
+ typeof(string),
+ stringTypeMapping,
+ jsonScalarValue.IsNullable);
+ return true;
+
+ default:
+ jsonValue = null;
+ return false;
+ }
+ }
+
+ ///
+ /// Provider extension point for implementing partial updates within JSON columns.
+ ///
+ ///
+ /// An expression representing the target to be updated; can be either
+ /// (when a scalar property is being updated within the JSON column), or a
+ /// (when an object or collection is being updated).
+ ///
+ /// The JSON value to be set, ready for use as-is in .
+ ///
+ /// If a setter was previously created for this JSON column, it's value is passed here (this happens when e.g.
+ /// multiple properties are updated in the same JSON column). Implementations can compose the new setter into
+ /// the existing one (and return ), or return a new one.
+ ///
+ protected virtual SqlExpression? GenerateJsonPartialUpdateSetter(
+ Expression target,
+ SqlExpression value,
+ ref SqlExpression? existingSetterValue)
+ => throw new InvalidOperationException(RelationalStrings.JsonPartialExecuteUpdateNotSupportedByProvider);
+
private static T? ParameterValueExtractor(
QueryContext context,
string baseParameterName,
@@ -594,6 +931,22 @@ protected virtual bool IsValidSelectExpressionForExecuteUpdate(
return baseValue == null ? (T?)(object?)null : (T?)property.GetGetter().GetClrValue(baseValue);
}
+ private static string? ParameterJsonSerializer(QueryContext queryContext, string baseParameterName, JsonValueReaderWriter jsonValueReaderWriter)
+ {
+ var value = queryContext.Parameters[baseParameterName];
+
+ if (value is null)
+ {
+ return null;
+ }
+
+ // We should only be here for things that get serialized to strings.
+ // Non-string JSON types (number, bool, null) should have been checked beforehand and handled differently.
+ var jsonString = jsonValueReaderWriter.ToJsonString(value);
+ Check.DebugAssert(jsonString.StartsWith('"') && jsonString.EndsWith('"'));
+ return jsonString[1..^1];
+ }
+
private sealed class ParameterBasedComplexPropertyChainExpression(
SqlParameterExpression parameterExpression,
IComplexProperty firstComplexProperty)
diff --git a/src/EFCore.Relational/Query/RelationalSqlTranslatingExpressionVisitor.StructuralEquality.cs b/src/EFCore.Relational/Query/RelationalSqlTranslatingExpressionVisitor.StructuralEquality.cs
index 75d0c415c1d..9ace7c87d9f 100644
--- a/src/EFCore.Relational/Query/RelationalSqlTranslatingExpressionVisitor.StructuralEquality.cs
+++ b/src/EFCore.Relational/Query/RelationalSqlTranslatingExpressionVisitor.StructuralEquality.cs
@@ -4,8 +4,8 @@
using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Text;
-using System.Text.Json;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
+using Microsoft.EntityFrameworkCore.Query.Internal;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
namespace Microsoft.EntityFrameworkCore.Query;
@@ -21,9 +21,6 @@ public partial class RelationalSqlTranslatingExpressionVisitor
private static readonly MethodInfo ParameterListValueExtractorMethod =
typeof(RelationalSqlTranslatingExpressionVisitor).GetTypeInfo().GetDeclaredMethod(nameof(ParameterListValueExtractor))!;
- private static readonly MethodInfo SerializeComplexTypeToJsonMethod =
- typeof(RelationalSqlTranslatingExpressionVisitor).GetTypeInfo().GetDeclaredMethod(nameof(SerializeComplexTypeToJson))!;
-
private bool TryRewriteContainsEntity(Expression source, Expression item, [NotNullWhen(true)] out SqlExpression? result)
{
result = null;
@@ -397,7 +394,7 @@ bool TryProcessJson(Expression expression, [NotNullWhen(true)] out SqlExpression
// JSON column on other side above - important for e.g. nvarchar vs. json columns)
case SqlConstantExpression constant:
result = new SqlConstantExpression(
- SerializeComplexTypeToJson(complexType, constant.Value, collection),
+ RelationalJsonUtilities.SerializeComplexTypeToJson(complexType, constant.Value, collection),
typeof(string),
typeMapping: null);
return true;
@@ -408,7 +405,7 @@ bool TryProcessJson(Expression expression, [NotNullWhen(true)] out SqlExpression
$"{RuntimeParameterPrefix}{parameter.Name}",
Expression.Lambda(
Expression.Call(
- SerializeComplexTypeToJsonMethod,
+ RelationalJsonUtilities.SerializeComplexTypeToJsonMethod,
Expression.Constant(complexType),
Expression.MakeIndex(
Expression.Property(
@@ -432,7 +429,7 @@ bool TryProcessJson(Expression expression, [NotNullWhen(true)] out SqlExpression
var lambda =
Expression.Lambda(
Expression.Call(
- SerializeComplexTypeToJsonMethod,
+ RelationalJsonUtilities.SerializeComplexTypeToJsonMethod,
Expression.Constant(lastComplexProperty.ComplexType),
extractComplexPropertyClrType,
Expression.Constant(lastComplexProperty.IsCollection)),
@@ -689,98 +686,4 @@ private sealed class ParameterBasedComplexPropertyChainExpression(
public SqlParameterExpression ParameterExpression { get; } = parameterExpression;
public List ComplexPropertyChain { get; } = [firstComplexProperty];
}
-
- ///
- /// 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.
- ///
- [EntityFrameworkInternal]
- public static string? SerializeComplexTypeToJson(IComplexType complexType, object? value, bool collection)
- {
- // Note that we treat toplevel null differently: we return a relational NULL for that case. For nested nulls,
- // we return JSON null string (so you get { "foo": null })
- if (value is null)
- {
- return null;
- }
-
- var stream = new MemoryStream();
- var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false });
-
- WriteJson(writer, complexType, value, collection);
-
- writer.Flush();
-
- return Encoding.UTF8.GetString(stream.ToArray());
-
- void WriteJson(Utf8JsonWriter writer, IComplexType complexType, object? value, bool collection)
- {
- if (collection)
- {
- if (value is null)
- {
- writer.WriteNullValue();
-
- return;
- }
-
- writer.WriteStartArray();
-
- foreach (var element in (IEnumerable)value)
- {
- WriteJsonObject(writer, complexType, element);
- }
-
- writer.WriteEndArray();
- return;
- }
-
- WriteJsonObject(writer, complexType, value);
- }
-
- void WriteJsonObject(Utf8JsonWriter writer, IComplexType complexType, object? objectValue)
- {
- if (objectValue is null)
- {
- writer.WriteNullValue();
- return;
- }
-
- writer.WriteStartObject();
-
- foreach (var property in complexType.GetProperties())
- {
- var jsonPropertyName = property.GetJsonPropertyName();
- Check.DebugAssert(jsonPropertyName is not null);
- writer.WritePropertyName(jsonPropertyName);
-
- var propertyValue = property.GetGetter().GetClrValue(objectValue);
- if (propertyValue is null)
- {
- writer.WriteNullValue();
- }
- else
- {
- var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter;
- Check.DebugAssert(jsonValueReaderWriter is not null, "Missing JsonValueReaderWriter on JSON property");
- jsonValueReaderWriter.ToJson(writer, propertyValue);
- }
- }
-
- foreach (var complexProperty in complexType.GetComplexProperties())
- {
- var jsonPropertyName = complexProperty.GetJsonPropertyName();
- Check.DebugAssert(jsonPropertyName is not null);
- writer.WritePropertyName(jsonPropertyName);
-
- var propertyValue = complexProperty.GetGetter().GetClrValue(objectValue);
-
- WriteJson(writer, complexProperty.ComplexType, propertyValue, complexProperty.IsCollection);
- }
-
- writer.WriteEndObject();
- }
- }
}
diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs
index 34c88df3c14..838d0cd19b7 100644
--- a/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs
+++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs
@@ -140,19 +140,43 @@ protected override Expression VisitUpdate(UpdateExpression updateExpression)
Sql.AppendLine($"{Dependencies.SqlGenerationHelper.DelimitIdentifier(updateExpression.Table.Alias)}");
Sql.Append("SET ");
- Visit(updateExpression.ColumnValueSetters[0].Column);
- Sql.Append(" = ");
- Visit(updateExpression.ColumnValueSetters[0].Value);
- using (Sql.Indent())
+ for (var i = 0; i < updateExpression.ColumnValueSetters.Count; i++)
{
- foreach (var columnValueSetter in updateExpression.ColumnValueSetters.Skip(1))
+ var (column, value) = updateExpression.ColumnValueSetters[i];
+
+ if (i == 1)
+ {
+ Sql.IncrementIndent();
+ }
+
+ if (i > 0)
{
Sql.AppendLine(",");
- Visit(columnValueSetter.Column);
- Sql.Append(" = ");
- Visit(columnValueSetter.Value);
}
+
+ // SQL Server 2025 modify method (https://learn.microsoft.com/sql/t-sql/data-types/json-data-type#modify-method)
+ // This requires special handling since modify isn't a standard setter of the form SET x = y, but rather just
+ // SET [x].modify(...).
+ if (value is SqlFunctionExpression
+ {
+ Name: "modify",
+ IsBuiltIn: true,
+ Instance: ColumnExpression { TypeMapping.StoreType: "json" } instance
+ })
+ {
+ Visit(value);
+ continue;
+ }
+
+ Visit(column);
+ Sql.Append(" = ");
+ Visit(value);
+ }
+
+ if (updateExpression.ColumnValueSetters.Count > 1)
+ {
+ Sql.DecrementIndent();
}
_withinTable = true;
@@ -202,6 +226,23 @@ protected override Expression VisitValues(ValuesExpression valuesExpression)
return valuesExpression;
}
+ ///
+ /// Generates SQL for a constant.
+ ///
+ /// The for which to generate SQL.
+ protected override Expression VisitSqlConstant(SqlConstantExpression sqlConstantExpression)
+ {
+ // Certain JSON functions (e.g. JSON_MODIFY()) accept a JSONPATH argument - this is (currently) flown here as a
+ // SqlConstantExpression over IReadOnlyList. Render that to a string here.
+ if (sqlConstantExpression is { Value: IReadOnlyList path })
+ {
+ GenerateJsonPath(path);
+ return sqlConstantExpression;
+ }
+
+ return base.VisitSqlConstant(sqlConstantExpression);
+ }
+
///
/// 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
@@ -210,40 +251,68 @@ protected override Expression VisitValues(ValuesExpression valuesExpression)
///
protected override Expression VisitSqlFunction(SqlFunctionExpression sqlFunctionExpression)
{
- if (sqlFunctionExpression is { IsBuiltIn: true, Arguments: not null }
- && string.Equals(sqlFunctionExpression.Name, "COALESCE", StringComparison.OrdinalIgnoreCase))
+ switch (sqlFunctionExpression)
{
- var type = sqlFunctionExpression.Type;
- var typeMapping = sqlFunctionExpression.TypeMapping;
- var defaultTypeMapping = _typeMappingSource.FindMapping(type);
-
- // ISNULL always return a value having the same type as its first
- // argument. Ideally we would convert the argument to have the
- // desired type and type mapping, but currently EFCore has some
- // trouble in computing types of non-homogeneous expressions
- // (tracked in https://github.com/dotnet/efcore/issues/15586). To
- // stay on the safe side we only use ISNULL if:
- // - all sub-expressions have the same type as the expression
- // - all sub-expressions have the same type mapping as the expression
- // - the expression is using the default type mapping (combined
- // with the two above, this implies that all of the expressions
- // are using the default type mapping of the type)
- if (defaultTypeMapping == typeMapping
- && sqlFunctionExpression.Arguments.All(a => a.Type == type && a.TypeMapping == typeMapping))
+ case { IsBuiltIn: true, Arguments: not null }
+ when string.Equals(sqlFunctionExpression.Name, "COALESCE", StringComparison.OrdinalIgnoreCase):
{
- var head = sqlFunctionExpression.Arguments[0];
- sqlFunctionExpression = (SqlFunctionExpression)sqlFunctionExpression
- .Arguments
- .Skip(1)
- .Aggregate(
- head, (l, r) => new SqlFunctionExpression(
- "ISNULL",
- arguments: [l, r],
- nullable: true,
- argumentsPropagateNullability: [false, false],
- sqlFunctionExpression.Type,
- sqlFunctionExpression.TypeMapping
- ));
+ var type = sqlFunctionExpression.Type;
+ var typeMapping = sqlFunctionExpression.TypeMapping;
+ var defaultTypeMapping = _typeMappingSource.FindMapping(type);
+
+ // ISNULL always return a value having the same type as its first
+ // argument. Ideally we would convert the argument to have the
+ // desired type and type mapping, but currently EFCore has some
+ // trouble in computing types of non-homogeneous expressions
+ // (tracked in https://github.com/dotnet/efcore/issues/15586). To
+ // stay on the safe side we only use ISNULL if:
+ // - all sub-expressions have the same type as the expression
+ // - all sub-expressions have the same type mapping as the expression
+ // - the expression is using the default type mapping (combined
+ // with the two above, this implies that all of the expressions
+ // are using the default type mapping of the type)
+ if (defaultTypeMapping == typeMapping
+ && sqlFunctionExpression.Arguments.All(a => a.Type == type && a.TypeMapping == typeMapping))
+ {
+ var head = sqlFunctionExpression.Arguments[0];
+ sqlFunctionExpression = (SqlFunctionExpression)sqlFunctionExpression
+ .Arguments
+ .Skip(1)
+ .Aggregate(
+ head, (l, r) => new SqlFunctionExpression(
+ "ISNULL",
+ arguments: [l, r],
+ nullable: true,
+ argumentsPropagateNullability: [false, false],
+ sqlFunctionExpression.Type,
+ sqlFunctionExpression.TypeMapping
+ ));
+ }
+
+ return base.VisitSqlFunction(sqlFunctionExpression);
+ }
+
+ // SQL Server 2025 modify method (https://learn.microsoft.com/sql/t-sql/data-types/json-data-type#modify-method)
+ // We get here only from within UPDATE setters.
+ // We generate the syntax here manually rather than just using the regular function visitation logic since
+ // the JSON column (function instance) needs to be rendered *without* the column, unlike elsewhere.
+ case
+ {
+ Name: "modify",
+ IsBuiltIn: true,
+ Instance: ColumnExpression { TypeMapping.StoreType: "json" } jsonColumn,
+ Arguments: [SqlConstantExpression { Value: IReadOnlyList jsonPath }, var item]
+ }:
+ {
+ Sql
+ .Append(_sqlGenerationHelper.DelimitIdentifier(jsonColumn.Name))
+ .Append(".modify(");
+ GenerateJsonPath(jsonPath);
+ Sql.Append(", ");
+ Visit(item);
+ Sql.Append(")");
+
+ return sqlFunctionExpression;
}
}
diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs
index a1782ffba8b..f73e4f4589e 100644
--- a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs
+++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs
@@ -6,6 +6,7 @@
using Microsoft.EntityFrameworkCore.SqlServer.Infrastructure.Internal;
using Microsoft.EntityFrameworkCore.SqlServer.Internal;
using Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal;
+using Microsoft.VisualBasic;
namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal;
@@ -22,6 +23,8 @@ public class SqlServerQueryableMethodTranslatingExpressionVisitor : RelationalQu
private readonly ISqlExpressionFactory _sqlExpressionFactory;
private readonly ISqlServerSingletonOptions _sqlServerSingletonOptions;
+ private HashSet? _columnsWithMultipleSetters;
+
///
/// 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
@@ -358,18 +361,18 @@ IComplexType complexType
// index on parameter using a column
// translate via JSON because it is a better translation
case SelectExpression
- {
- Tables: [ValuesExpression { ValuesParameter: { } valuesParameter }],
- Predicate: null,
- GroupBy: [],
- Having: null,
- IsDistinct: false,
+ {
+ Tables: [ValuesExpression { ValuesParameter: { } valuesParameter }],
+ Predicate: null,
+ GroupBy: [],
+ Having: null,
+ IsDistinct: false,
#pragma warning disable EF1001
- Orderings: [{ Expression: ColumnExpression { Name: ValuesOrderingColumnName }, IsAscending: true }],
+ Orderings: [{ Expression: ColumnExpression { Name: ValuesOrderingColumnName }, IsAscending: true }],
#pragma warning restore EF1001
- Limit: null,
- Offset: null
- } selectExpression
+ Limit: null,
+ Offset: null
+ } selectExpression
when TranslateExpression(index) is { } translatedIndex
&& _sqlServerSingletonOptions.SupportsJsonFunctions
&& TryTranslate(selectExpression, valuesParameter, translatedIndex, out var result):
@@ -377,28 +380,28 @@ when TranslateExpression(index) is { } translatedIndex
// Index on JSON array
case SelectExpression
- {
- Tables: [SqlServerOpenJsonExpression { Arguments: [var jsonArrayColumn] } openJsonExpression],
- Predicate: null,
- GroupBy: [],
- Having: null,
- IsDistinct: false,
- Limit: null,
- Offset: null,
- // We can only apply the indexing if the JSON array is ordered by its natural ordered, i.e. by the "key" column that
- // we created in TranslateCollection. For example, if another ordering has been applied (e.g. by the JSON elements
- // themselves), we can no longer simply index into the original array.
- Orderings:
+ {
+ Tables: [SqlServerOpenJsonExpression { Arguments: [var jsonArrayColumn] } openJsonExpression],
+ Predicate: null,
+ GroupBy: [],
+ Having: null,
+ IsDistinct: false,
+ Limit: null,
+ Offset: null,
+ // We can only apply the indexing if the JSON array is ordered by its natural ordered, i.e. by the "key" column that
+ // we created in TranslateCollection. For example, if another ordering has been applied (e.g. by the JSON elements
+ // themselves), we can no longer simply index into the original array.
+ Orderings:
[
+ {
+ Expression: SqlUnaryExpression
{
- Expression: SqlUnaryExpression
- {
- OperatorType: ExpressionType.Convert,
- Operand: ColumnExpression { Name: "key", TableAlias: var orderingTableAlias }
- }
+ OperatorType: ExpressionType.Convert,
+ Operand: ColumnExpression { Name: "key", TableAlias: var orderingTableAlias }
}
+ }
]
- } selectExpression
+ } selectExpression
when orderingTableAlias == openJsonExpression.Alias
&& TranslateExpression(index) is { } translatedIndex
&& TryTranslate(selectExpression, jsonArrayColumn, translatedIndex, out var result):
@@ -465,20 +468,20 @@ bool TryTranslate(
///
protected override bool IsNaturallyOrdered(SelectExpression selectExpression)
=> selectExpression is
- {
- Tables: [SqlServerOpenJsonExpression openJsonExpression, ..],
- Orderings:
+ {
+ Tables: [SqlServerOpenJsonExpression openJsonExpression, ..],
+ Orderings:
[
+ {
+ Expression: SqlUnaryExpression
{
- Expression: SqlUnaryExpression
- {
- OperatorType: ExpressionType.Convert,
- Operand: ColumnExpression { Name: "key", TableAlias: var orderingTableAlias }
- },
- IsAscending: true
- }
+ OperatorType: ExpressionType.Convert,
+ Operand: ColumnExpression { Name: "key", TableAlias: var orderingTableAlias }
+ },
+ IsAscending: true
+ }
]
- }
+ }
&& orderingTableAlias == openJsonExpression.Alias;
///
@@ -493,6 +496,8 @@ protected override bool IsValidSelectExpressionForExecuteDelete(SelectExpression
&& selectExpression.Having == null
&& selectExpression.Orderings.Count == 0;
+ #region ExecuteUpdate
+
///
/// 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
@@ -529,6 +534,138 @@ protected override bool IsValidSelectExpressionForExecuteUpdate(
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.
+ ///
+#pragma warning disable EF1001 // Internal EF Core API usage.
+ protected override bool TryTranslateSetters(
+ ShapedQueryExpression source,
+ IReadOnlyList setters,
+ [NotNullWhen(true)] out IReadOnlyList? columnSetters,
+ [NotNullWhen(true)] out TableExpressionBase? targetTable)
+ {
+ // SQL Server 2025 introduced the modify method (https://learn.microsoft.com/sql/t-sql/data-types/json-data-type#modify-method),
+ // which works only with the JSON data type introduced in that same version.
+ // As of now, modify is only usable if a single property is being modified in the JSON document - it's impossible to modify multiple properties.
+ // To work around this limitation, we do a first translation pass which may generate multiple modify invocations on the same JSON column (and
+ // which would fail if sent to SQL Server); we then detect this case, populate _columnsWithMultipleSetters with the problematic columns, and then
+ // retranslate, using the less efficient JSON_MODIFY() instead for those columns.
+ _columnsWithMultipleSetters = new();
+
+ if (!base.TryTranslateSetters(source, setters, out columnSetters, out targetTable))
+ {
+ return false;
+ }
+
+ _columnsWithMultipleSetters = new(columnSetters.GroupBy(s => s.Column).Where(g => g.Count() > 1).Select(g => g.Key));
+ if (_columnsWithMultipleSetters.Count > 0)
+ {
+ return base.TryTranslateSetters(source, setters, out columnSetters, out targetTable);
+ }
+
+ return true;
+ }
+#pragma warning restore EF1001 // Internal EF Core API usage.
+
+ ///
+ /// 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 SqlExpression? GenerateJsonPartialUpdateSetter(
+ Expression target,
+ SqlExpression value,
+ ref SqlExpression? existingSetterValue)
+ {
+ var (jsonColumn, path) = target switch
+ {
+ JsonScalarExpression j => ((ColumnExpression)j.Json, j.Path),
+ JsonQueryExpression j => (j.JsonColumn, j.Path),
+
+ _ => throw new UnreachableException(),
+ };
+
+ // SQL Server 2025 introduced the modify method (https://learn.microsoft.com/sql/t-sql/data-types/json-data-type#modify-method),
+ // which works only with the JSON data type introduced in that same version.
+ // As of now, modify is only usable if a single property is being modified in the JSON document - it's impossible to modify multiple properties.
+ // To work around this limitation, we do a first translation pass which may generate multiple modify invocations on the same JSON column (and
+ // which would fail if sent to SQL Server); we then detect this case in TranslateExecuteUpdate, populate _columnsWithMultipleSetters with the
+ // problematic columns, and then retranslate, using the less efficient JSON_MODIFY() instead for those columns.
+ if (jsonColumn.TypeMapping!.StoreType is "json"
+ && (_columnsWithMultipleSetters is null || !_columnsWithMultipleSetters.Contains(jsonColumn)))
+ {
+ // UPDATE ... SET [x].modify('$.a.b', 'foo')
+
+ // Note that the actual SQL generated contains only the modify function: UPDATE ... SET [x].modify(...), but UpdateExpression's
+ // ColumnValueSetter requires both column and value. The column will be ignored in SQL generation,
+ // and only the function call will be rendered.
+ var setterValue = _sqlExpressionFactory.Function(
+ existingSetterValue ?? jsonColumn,
+ "modify",
+ [
+ // Hack: Rendering of JSONPATH strings happens in value generation. We can have a special expression for modify to hold the
+ // IReadOnlyList (just like Json{Scalar,Query}Expression), but instead we do the slight hack of packaging it
+ // as a constant argument; it will be unpacked and handled in SQL generation.
+ _sqlExpressionFactory.Constant(path, RelationalTypeMapping.NullMapping),
+
+ // If an inline JSON object (complex type) is being assigned, it would be rendered here as a simple string:
+ // [column].modify('$.foo', '{ "x": 8 }')
+ // Since it's untyped, modify would treat is as a string rather than a JSON object, and insert it as such into
+ // the enclosing object, escaping all the special JSON characters - that's not what we want.
+ // We add a cast to JSON to have it interpreted as a JSON object.
+ value is SqlConstantExpression { TypeMapping.StoreType: "json" }
+ ? _sqlExpressionFactory.Convert(value, value.Type, _typeMappingSource.FindMapping("json")!)
+ : value
+ ],
+ nullable: true,
+ instancePropagatesNullability: true,
+ argumentsPropagateNullability: [true, true],
+ typeof(void),
+ RelationalTypeMapping.NullMapping);
+
+ return setterValue;
+ }
+
+ Check.DebugAssert(existingSetterValue is null or SqlFunctionExpression { Name: "JSON_MODIFY" });
+
+ var jsonModify = _sqlExpressionFactory.Function(
+ "JSON_MODIFY",
+ arguments:
+ [
+ existingSetterValue ?? jsonColumn,
+ // Hack: Rendering of JSONPATH strings happens in value generation. We can have a special expression for modify to hold the
+ // IReadOnlyList (just like Json{Scalar,Query}Expression), but instead we do the slight hack of packaging it
+ // as a constant argument; it will be unpacked and handled in SQL generation.
+ _sqlExpressionFactory.Constant(path, RelationalTypeMapping.NullMapping),
+ // JSON_MODIFY by default assumes nvarchar(max) is text and escapes it.
+ // In order to set a JSON fragment (for nested JSON objects), we need to wrap the JSON text with JSON_QUERY(), which makes
+ // JSON_MODIFY understand that it's JSON content and prevents escaping.
+ target is JsonQueryExpression && value is not JsonScalarExpression
+ ? _sqlExpressionFactory.Function("JSON_QUERY", [value], nullable: true, argumentsPropagateNullability: [true], typeof(string), value.TypeMapping)
+ : value
+ ],
+ nullable: true,
+ argumentsPropagateNullability: [true, true, true],
+ typeof(string),
+ jsonColumn.TypeMapping);
+
+ if (existingSetterValue is null)
+ {
+ return jsonModify;
+ }
+ else
+ {
+ existingSetterValue = jsonModify;
+ return null;
+ }
+ }
+
+ #endregion ExecuteUpdate
+
private bool TryGetProjection(
ShapedQueryExpression shapedQueryExpression,
SelectExpression selectExpression,
diff --git a/src/EFCore.Sqlite.Core/Properties/SqliteStrings.Designer.cs b/src/EFCore.Sqlite.Core/Properties/SqliteStrings.Designer.cs
index ebf02a3a3c3..8134f15a665 100644
--- a/src/EFCore.Sqlite.Core/Properties/SqliteStrings.Designer.cs
+++ b/src/EFCore.Sqlite.Core/Properties/SqliteStrings.Designer.cs
@@ -37,6 +37,12 @@ public static string AggregateOperationNotSupported(object? aggregateOperator, o
public static string ApplyNotSupported
=> GetString("ApplyNotSupported");
+ ///
+ /// ExecuteUpdate partial updates of ulong properties within JSON columns is not supported.
+ ///
+ public static string ExecuteUpdateJsonPartialUpdateDoesNotSupportUlong
+ => GetString("ExecuteUpdateJsonPartialUpdateDoesNotSupportUlong");
+
///
/// Translating this operation requires the 'DEFAULT' keyword, which is not supported on SQLite.
///
diff --git a/src/EFCore.Sqlite.Core/Properties/SqliteStrings.resx b/src/EFCore.Sqlite.Core/Properties/SqliteStrings.resx
index b7b19b5bb04..0de1798a21f 100644
--- a/src/EFCore.Sqlite.Core/Properties/SqliteStrings.resx
+++ b/src/EFCore.Sqlite.Core/Properties/SqliteStrings.resx
@@ -123,6 +123,9 @@
Translating this query requires the SQL APPLY operation, which is not supported on SQLite.
+
+ ExecuteUpdate partial updates of ulong properties within JSON columns is not supported.
+
Translating this operation requires the 'DEFAULT' keyword, which is not supported on SQLite.
diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteQuerySqlGenerator.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteQuerySqlGenerator.cs
index 33f7a06a8cd..36dfcf001de 100644
--- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteQuerySqlGenerator.cs
+++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteQuerySqlGenerator.cs
@@ -208,51 +208,66 @@ protected virtual void GenerateJsonEach(JsonEachExpression jsonEachExpression)
{
Sql.Append(", ");
- // Note the difference with the JSONPATH rendering in VisitJsonScalar below, where we take advantage of SQLite's ->> operator
- // (we can't do that here).
- Sql.Append("'$");
+ GenerateJsonPath(path);
+ }
- var inJsonpathString = true;
+ Sql.Append(")");
- for (var i = 0; i < path.Count; i++)
- {
- switch (path[i])
- {
- case { PropertyName: { } propertyName }:
- Sql.Append(".").Append(propertyName);
- break;
+ Sql.Append(AliasSeparator).Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(jsonEachExpression.Alias));
+ }
- case { ArrayIndex: { } arrayIndex }:
- Sql.Append("[");
+ private void GenerateJsonPath(IReadOnlyList path)
+ {
+ Sql.Append("'$");
- if (arrayIndex is SqlConstantExpression)
- {
- Visit(arrayIndex);
- }
- else
- {
- Sql.Append("' || ");
- Visit(arrayIndex);
- Sql.Append(" || '");
- }
+ for (var i = 0; i < path.Count; i++)
+ {
+ switch (path[i])
+ {
+ case { PropertyName: { } propertyName }:
+ Sql.Append(".").Append(propertyName);
+ break;
- Sql.Append("]");
- break;
+ case { ArrayIndex: { } arrayIndex }:
+ Sql.Append("[");
- default:
- throw new ArgumentOutOfRangeException();
- }
- }
+ if (arrayIndex is SqlConstantExpression)
+ {
+ Visit(arrayIndex);
+ }
+ else
+ {
+ Sql.Append("' || ");
+ Visit(arrayIndex);
+ Sql.Append(" || '");
+ }
- if (inJsonpathString)
- {
- Sql.Append("'");
+ Sql.Append("]");
+ break;
+
+ default:
+ throw new ArgumentOutOfRangeException();
}
}
- Sql.Append(")");
+ Sql.Append("'");
+ }
- Sql.Append(AliasSeparator).Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(jsonEachExpression.Alias));
+ ///
+ /// Generates SQL for a constant.
+ ///
+ /// The for which to generate SQL.
+ protected override Expression VisitSqlConstant(SqlConstantExpression sqlConstantExpression)
+ {
+ // Certain JSON functions (e.g. json_set()) accept a JSONPATH argument - this is (currently) flown here as a SqlConstantExpression
+ // over IReadOnlyList. Render that to a string here.
+ if (sqlConstantExpression is { Value: IReadOnlyList path })
+ {
+ GenerateJsonPath(path);
+ return sqlConstantExpression;
+ }
+
+ return base.VisitSqlConstant(sqlConstantExpression);
}
///
@@ -276,6 +291,8 @@ protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExp
for (var i = 0; i < path.Count; i++)
{
+ // Note that we don't use GenerateJsonPath() to generate the JSONPATH string here, since we take advantage of SQLite's ->> operator
+ // for JsonScalarExpression.
var pathSegment = path[i];
var isLast = i == path.Count - 1;
diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteQueryableMethodTranslatingExpressionVisitor.cs
index f150150fce8..a8e4f39d377 100644
--- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteQueryableMethodTranslatingExpressionVisitor.cs
+++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteQueryableMethodTranslatingExpressionVisitor.cs
@@ -580,6 +580,107 @@ [new PathSegment(translatedIndex)],
}
}
+ ///
+ /// 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 bool TrySerializeScalarToJson(
+ JsonScalarExpression target,
+ SqlExpression value,
+ [NotNullWhen(true)] out SqlExpression? jsonValue)
+ {
+ var providerClrType = (value.TypeMapping!.Converter?.ProviderClrType ?? value.Type).UnwrapNullableType();
+
+ // SQLite has no bool type, so if we simply sent the bool as-is, we'd get 1/0 in the JSON document.
+ // To get an actual unquoted true/false value, we pass "true"/"false" string through the json() minifier, which does this.
+ // See https://sqlite.org/forum/info/91d09974c3754ea6.
+ if (providerClrType == typeof(bool))
+ {
+ jsonValue = _sqlExpressionFactory.Function(
+ "json",
+ [
+ value is SqlConstantExpression { Value: bool constant }
+ ? _sqlExpressionFactory.Constant(constant ? "true" : "false")
+ : _sqlExpressionFactory.Case(
+ [
+ new CaseWhenClause(
+ _sqlExpressionFactory.Equal(value, _sqlExpressionFactory.Constant(true)),
+ _sqlExpressionFactory.Constant("true")),
+ new CaseWhenClause(
+ _sqlExpressionFactory.Equal(value, _sqlExpressionFactory.Constant(false)),
+ _sqlExpressionFactory.Constant("false"))
+ ],
+ elseResult: _sqlExpressionFactory.Constant("null"))
+ ],
+ nullable: true,
+ argumentsPropagateNullability: [true],
+ typeof(string),
+ _typeMappingSource.FindMapping(typeof(string)));
+
+ return true;
+ }
+
+ if (providerClrType == typeof(ulong))
+ {
+ // See #36689
+ throw new InvalidOperationException(SqliteStrings.ExecuteUpdateJsonPartialUpdateDoesNotSupportUlong);
+ }
+
+ return base.TrySerializeScalarToJson(target, value, out jsonValue);
+ }
+
+ ///
+ /// 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 SqlExpression? GenerateJsonPartialUpdateSetter(
+ Expression target,
+ SqlExpression value,
+ ref SqlExpression? existingSetterValue)
+ {
+ var (jsonColumn, path) = target switch
+ {
+ JsonScalarExpression j => ((ColumnExpression)j.Json, j.Path),
+ JsonQueryExpression j => (j.JsonColumn, j.Path),
+
+ _ => throw new UnreachableException(),
+ };
+
+ var jsonSet = _sqlExpressionFactory.Function(
+ "json_set",
+ arguments: [
+ existingSetterValue ?? jsonColumn,
+ // Hack: Rendering of JSONPATH strings happens in value generation. We can have a special expression for modify to hold the
+ // IReadOnlyList (just like Json{Scalar,Query}Expression), but instead we do the slight hack of packaging it
+ // as a constant argument; it will be unpacked and handled in SQL generation.
+ _sqlExpressionFactory.Constant(path, RelationalTypeMapping.NullMapping),
+ // json_set by default assumes text and escapes it.
+ // In order to set a JSON fragment (for nested JSON objects), we need to wrap the JSON text with json(), which makes
+ // json_set understand that it's JSON content and prevents escaping.
+ target is JsonQueryExpression
+ ? _sqlExpressionFactory.Function("json", [value], nullable: true, argumentsPropagateNullability: [true], typeof(string), value.TypeMapping)
+ : value
+ ],
+ nullable: true,
+ argumentsPropagateNullability: [true, true, true],
+ typeof(string),
+ jsonColumn.TypeMapping);
+
+ if (existingSetterValue is null)
+ {
+ return jsonSet;
+ }
+ else
+ {
+ existingSetterValue = jsonSet;
+ return 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
@@ -589,16 +690,16 @@ [new PathSegment(translatedIndex)],
protected override bool IsNaturallyOrdered(SelectExpression selectExpression)
{
return selectExpression is
+ {
+ Tables: [var mainTable, ..],
+ Orderings:
+ [
{
- Tables: [var mainTable, ..],
- Orderings:
- [
- {
- Expression: ColumnExpression { Name: JsonEachKeyColumnName } orderingColumn,
- IsAscending: true
- }
- ]
+ Expression: ColumnExpression { Name: JsonEachKeyColumnName } orderingColumn,
+ IsAscending: true
}
+ ]
+ }
&& orderingColumn.TableAlias == mainTable.Alias
&& IsJsonEachKeyColumn(selectExpression, orderingColumn);
diff --git a/test/EFCore.Cosmos.FunctionalTests/Types/CosmosMiscellaneousTypeTest.cs b/test/EFCore.Cosmos.FunctionalTests/Types/CosmosMiscellaneousTypeTest.cs
new file mode 100644
index 00000000000..00fdc1e67e1
--- /dev/null
+++ b/test/EFCore.Cosmos.FunctionalTests/Types/CosmosMiscellaneousTypeTest.cs
@@ -0,0 +1,56 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.EntityFrameworkCore.Types;
+
+public class BoolTypeTest(BoolTypeTest.BoolTypeFixture fixture)
+ : TypeTestBase(fixture)
+{
+ public class BoolTypeFixture() : TypeTestFixture(true, false)
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
+
+public class StringTypeTest(StringTypeTest.StringTypeFixture fixture)
+ : TypeTestBase(fixture)
+{
+ public class StringTypeFixture() : TypeTestFixture("foo", "bar")
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
+
+public class GuidTypeTest(GuidTypeTest.GuidTypeFixture fixture)
+ : TypeTestBase(fixture)
+{
+ public class GuidTypeFixture() : TypeTestFixture(
+ new Guid("8f7331d6-cde9-44fb-8611-81fff686f280"),
+ new Guid("ae192c36-9004-49b2-b785-8be10d169627"))
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
+
+public class ByteArrayTypeTest(ByteArrayTypeTest.ByteArrayTypeFixture fixture)
+ : TypeTestBase(fixture)
+{
+ public class ByteArrayTypeFixture() : TypeTestFixture([1, 2, 3], [4, 5, 6])
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+
+ public override Func Comparer { get; } = (a, b) => a.SequenceEqual(b);
+ }
+}
diff --git a/test/EFCore.Cosmos.FunctionalTests/Types/CosmosNumericTypeTest.cs b/test/EFCore.Cosmos.FunctionalTests/Types/CosmosNumericTypeTest.cs
new file mode 100644
index 00000000000..b2a5b4a42c7
--- /dev/null
+++ b/test/EFCore.Cosmos.FunctionalTests/Types/CosmosNumericTypeTest.cs
@@ -0,0 +1,81 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.EntityFrameworkCore.Types;
+
+public class ByteTypeTest(ByteTypeTest.ByteTypeFixture fixture) : TypeTestBase(fixture)
+{
+ public class ByteTypeFixture() : TypeTestFixture(byte.MinValue, byte.MaxValue)
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
+
+public class ShortTypeTest(ShortTypeTest.ShortTypeFixture fixture) : TypeTestBase(fixture)
+{
+ public class ShortTypeFixture() : TypeTestFixture(short.MinValue, short.MaxValue)
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
+
+public class IntTypeTest(IntTypeTest.IntTypeFixture fixture) : TypeTestBase(fixture)
+{
+ public class IntTypeFixture() : TypeTestFixture(int.MinValue, int.MaxValue)
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
+
+public class LongTypeTest(LongTypeTest.LongTypeFixture fixture) : TypeTestBase(fixture)
+{
+ public class LongTypeFixture() : TypeTestFixture(long.MinValue, long.MaxValue)
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
+
+public class DecimalTypeTest(DecimalTypeTest.DecimalTypeFixture fixture) : TypeTestBase(fixture)
+{
+ public class DecimalTypeFixture() : TypeTestFixture(30.5m, 30m)
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
+
+public class DoubleTypeTest(DoubleTypeTest.DoubleTypeFixture fixture) : TypeTestBase(fixture)
+{
+ public class DoubleTypeFixture() : TypeTestFixture(30.5d, 30d)
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
+
+public class FloatTypeTest(FloatTypeTest.FloatTypeFixture fixture) : TypeTestBase(fixture)
+{
+ public class FloatTypeFixture() : TypeTestFixture(30.5f, 30f)
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
diff --git a/test/EFCore.Cosmos.FunctionalTests/Types/CosmosTemporalTypeTest.cs b/test/EFCore.Cosmos.FunctionalTests/Types/CosmosTemporalTypeTest.cs
new file mode 100644
index 00000000000..24e63d06f42
--- /dev/null
+++ b/test/EFCore.Cosmos.FunctionalTests/Types/CosmosTemporalTypeTest.cs
@@ -0,0 +1,72 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.EntityFrameworkCore.Types;
+
+public class DateTimeTypeTest(DateTimeTypeTest.DateTimeTypeFixture fixture)
+ : TypeTestBase(fixture)
+{
+ public class DateTimeTypeFixture() : TypeTestFixture(
+ new DateTime(2020, 1, 5, 12, 30, 45, DateTimeKind.Unspecified),
+ new DateTime(2022, 5, 3, 0, 0, 0, DateTimeKind.Unspecified))
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
+
+public class DateTimeOffsetTypeTest(DateTimeOffsetTypeTest.DateTimeOffsetTypeFixture fixture)
+ : TypeTestBase(fixture)
+{
+ public class DateTimeOffsetTypeFixture() : TypeTestFixture(
+ new DateTimeOffset(2020, 1, 5, 12, 30, 45, TimeSpan.FromHours(2)),
+ new DateTimeOffset(2020, 1, 5, 12, 30, 45, TimeSpan.FromHours(3)))
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
+
+public class DateOnlyTypeTest(DateOnlyTypeTest.DateTypeFixture fixture) : TypeTestBase(fixture)
+{
+ public class DateTypeFixture() : TypeTestFixture(
+ new DateOnly(2020, 1, 5),
+ new DateOnly(2022, 5, 3))
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
+
+public class TimeOnlyTypeTest(TimeOnlyTypeTest.TimeTypeFixture fixture)
+ : TypeTestBase(fixture)
+{
+ public class TimeTypeFixture() : TypeTestFixture(
+ new TimeOnly(12, 30, 45),
+ new TimeOnly(14, 0, 0))
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
+
+public class TimeSpanTypeTest(TimeSpanTypeTest.TimeSpanTypeFixture fixture) : TypeTestBase(fixture)
+{
+ public class TimeSpanTypeFixture() : TypeTestFixture(
+ new TimeSpan(12, 30, 45),
+ new TimeSpan(14, 0, 0))
+ {
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CosmosEventId.NoPartitionKeyDefined));
+ }
+}
diff --git a/test/EFCore.InMemory.FunctionalTests/InMemoryComplianceTest.cs b/test/EFCore.InMemory.FunctionalTests/InMemoryComplianceTest.cs
index e1ffd3efedc..cb629ea7d55 100644
--- a/test/EFCore.InMemory.FunctionalTests/InMemoryComplianceTest.cs
+++ b/test/EFCore.InMemory.FunctionalTests/InMemoryComplianceTest.cs
@@ -22,7 +22,6 @@ public class InMemoryComplianceTest : ComplianceTestBase
typeof(StoreGeneratedTestBase<>),
typeof(ConferencePlannerTestBase<>),
typeof(ManyToManyQueryTestBase<>),
- typeof(ComplexTypeBulkUpdatesTestBase<>),
typeof(BulkUpdatesTestBase<>),
typeof(FiltersInheritanceBulkUpdatesTestBase<>),
typeof(InheritanceBulkUpdatesTestBase<>),
@@ -30,29 +29,32 @@ public class InMemoryComplianceTest : ComplianceTestBase
typeof(NorthwindBulkUpdatesTestBase<>),
typeof(JsonQueryTestBase<>),
typeof(AdHocJsonQueryTestBase),
+ typeof(TypeTestBase<,>),
// Relationships tests - not implemented for InMemory
- typeof(AssociationsProjectionTestBase<>),
typeof(AssociationsCollectionTestBase<>),
typeof(AssociationsMiscellaneousTestBase<>),
- typeof(AssociationsStructuralEqualityTestBase<>),
+ typeof(AssociationsProjectionTestBase<>),
typeof(AssociationsSetOperationsTestBase<>),
- typeof(NavigationsIncludeTestBase<>),
- typeof(NavigationsProjectionTestBase<>),
+ typeof(AssociationsStructuralEqualityTestBase<>),
+ typeof(AssociationsBulkUpdateTestBase<>),
+ typeof(ComplexPropertiesCollectionTestBase<>),
+ typeof(ComplexPropertiesMiscellaneousTestBase<>),
+ typeof(ComplexPropertiesProjectionTestBase<>),
+ typeof(ComplexPropertiesSetOperationsTestBase<>),
+ typeof(ComplexPropertiesStructuralEqualityTestBase<>),
+ typeof(ComplexPropertiesBulkUpdateTestBase<>),
typeof(NavigationsCollectionTestBase<>),
+ typeof(NavigationsIncludeTestBase<>),
typeof(NavigationsMiscellaneousTestBase<>),
- typeof(NavigationsStructuralEqualityTestBase<>),
+ typeof(NavigationsProjectionTestBase<>),
typeof(NavigationsSetOperationsTestBase<>),
- typeof(OwnedNavigationsProjectionTestBase<>),
+ typeof(NavigationsStructuralEqualityTestBase<>),
typeof(OwnedNavigationsCollectionTestBase<>),
typeof(OwnedNavigationsMiscellaneousTestBase<>),
- typeof(OwnedNavigationsStructuralEqualityTestBase<>),
+ typeof(OwnedNavigationsProjectionTestBase<>),
typeof(OwnedNavigationsSetOperationsTestBase<>),
- typeof(ComplexPropertiesProjectionTestBase<>),
- typeof(ComplexPropertiesCollectionTestBase<>),
- typeof(ComplexPropertiesMiscellaneousTestBase<>),
- typeof(ComplexPropertiesStructuralEqualityTestBase<>),
- typeof(ComplexPropertiesSetOperationsTestBase<>)
+ typeof(OwnedNavigationsStructuralEqualityTestBase<>)
};
protected override Assembly TargetAssembly { get; } = typeof(InMemoryComplianceTest).Assembly;
diff --git a/test/EFCore.Relational.Specification.Tests/BulkUpdates/ComplexTypeBulkUpdatesRelationalFixtureBase.cs b/test/EFCore.Relational.Specification.Tests/BulkUpdates/ComplexTypeBulkUpdatesRelationalFixtureBase.cs
deleted file mode 100644
index b4ce1cce80f..00000000000
--- a/test/EFCore.Relational.Specification.Tests/BulkUpdates/ComplexTypeBulkUpdatesRelationalFixtureBase.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-namespace Microsoft.EntityFrameworkCore.BulkUpdates;
-
-#nullable disable
-
-public abstract class ComplexTypeBulkUpdatesRelationalFixtureBase : ComplexTypeBulkUpdatesFixtureBase, ITestSqlLoggerFactory
-{
- public override void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction)
- => facade.UseTransaction(transaction.GetDbTransaction());
-
- public new RelationalTestStore TestStore
- => (RelationalTestStore)base.TestStore;
-
- public TestSqlLoggerFactory TestSqlLoggerFactory
- => (TestSqlLoggerFactory)ListLoggerFactory;
-
- public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
- => base.AddOptions(builder).ConfigureWarnings(c => c.Log(RelationalEventId.QueryPossibleUnintendedUseOfEqualsWarning))
- .EnableDetailedErrors();
-
- protected override bool ShouldLogCategory(string logCategory)
- => logCategory == DbLoggerCategory.Query.Name;
-}
diff --git a/test/EFCore.Relational.Specification.Tests/BulkUpdates/ComplexTypeBulkUpdatesRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/BulkUpdates/ComplexTypeBulkUpdatesRelationalTestBase.cs
deleted file mode 100644
index 72edb033356..00000000000
--- a/test/EFCore.Relational.Specification.Tests/BulkUpdates/ComplexTypeBulkUpdatesRelationalTestBase.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-namespace Microsoft.EntityFrameworkCore.BulkUpdates;
-
-#nullable disable
-
-public abstract class ComplexTypeBulkUpdatesRelationalTestBase : ComplexTypeBulkUpdatesTestBase
- where TFixture : ComplexTypeBulkUpdatesRelationalFixtureBase, new()
-{
- protected ComplexTypeBulkUpdatesRelationalTestBase(TFixture fixture, ITestOutputHelper testOutputHelper)
- : base(fixture)
- {
- ClearLog();
- Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper);
- }
-
- public override Task Delete_complex_type(bool async)
- => AssertTranslationFailed(
- RelationalStrings.ExecuteDeleteOnNonEntityType,
- () => base.Delete_complex_type(async));
-
- public override Task Update_projected_complex_type_via_OrderBy_Skip(bool async)
- => AssertTranslationFailed(
- RelationalStrings.ExecuteUpdateSubqueryNotSupportedOverComplexTypes("Customer.ShippingAddress#Address"),
- () => base.Update_projected_complex_type_via_OrderBy_Skip(async));
-
- protected static async Task AssertTranslationFailed(string details, Func query)
- => Assert.Contains(
- CoreStrings.NonQueryTranslationFailedWithDetails("", details)[21..],
- (await Assert.ThrowsAsync(query)).Message);
-
- private void ClearLog()
- => Fixture.TestSqlLoggerFactory.Clear();
-}
diff --git a/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexJson/ComplexJsonBulkUpdateRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexJson/ComplexJsonBulkUpdateRelationalTestBase.cs
new file mode 100644
index 00000000000..815b7ecdb59
--- /dev/null
+++ b/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexJson/ComplexJsonBulkUpdateRelationalTestBase.cs
@@ -0,0 +1,41 @@
+// 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.Query.Associations.ComplexProperties;
+
+namespace Microsoft.EntityFrameworkCore.Query.Associations.ComplexJson;
+
+public abstract class ComplexJsonBulkUpdateRelationalTestBase : ComplexPropertiesBulkUpdateTestBase
+ where TFixture : ComplexJsonRelationalFixtureBase, new()
+{
+ public ComplexJsonBulkUpdateRelationalTestBase(TFixture fixture, ITestOutputHelper testOutputHelper)
+ : base(fixture)
+ {
+ fixture.TestSqlLoggerFactory.Clear();
+ fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper);
+ }
+
+ // #36678 - ExecuteDelete on complex type
+ public override Task Delete_required_association()
+ => AssertTranslationFailedWithDetails(RelationalStrings.ExecuteDeleteOnNonEntityType, base.Delete_required_association);
+
+ // #36678 - ExecuteDelete on complex type
+ public override Task Delete_optional_association()
+ => Assert.ThrowsAsync(base.Delete_optional_association);
+
+ // #36336
+ public override Task Update_property_on_projected_association_with_OrderBy_Skip()
+ => AssertTranslationFailedWithDetails(
+ RelationalStrings.ExecuteUpdateSubqueryNotSupportedOverComplexTypes("RootEntity.RequiredRelated#RelatedType"),
+ base.Update_property_on_projected_association_with_OrderBy_Skip);
+
+ // #36679: non-constant inline array/list translation
+ public override Task Update_collection_referencing_the_original_collection()
+ => Assert.ThrowsAsync(base.Update_collection_referencing_the_original_collection);
+
+ protected void AssertSql(params string[] expected)
+ => Fixture.TestSqlLoggerFactory.AssertBaseline(expected);
+
+ protected void AssertExecuteUpdateSql(params string[] expected)
+ => Fixture.TestSqlLoggerFactory.AssertBaseline(expected, forUpdate: true);
+}
diff --git a/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexJson/ComplexJsonCollectionRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexJson/ComplexJsonCollectionRelationalTestBase.cs
index 613dfd713f9..8b6433f34e2 100644
--- a/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexJson/ComplexJsonCollectionRelationalTestBase.cs
+++ b/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexJson/ComplexJsonCollectionRelationalTestBase.cs
@@ -17,7 +17,7 @@ public ComplexJsonCollectionRelationalTestBase(TFixture fixture, ITestOutputHelp
public override async Task Distinct_projected(QueryTrackingBehavior queryTrackingBehavior)
{
- // #36421
+ // #36421 - support projecting out complex JSON types after Distinct
var exception = await Assert.ThrowsAsync(() => base.Distinct_projected(queryTrackingBehavior));
Assert.Equal(RelationalStrings.InsufficientInformationToIdentifyElementOfCollectionJoin, exception.Message);
diff --git a/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexJson/ComplexJsonRelationalFixtureBase.cs b/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexJson/ComplexJsonRelationalFixtureBase.cs
index 185e58b7ac0..e21fb382a59 100644
--- a/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexJson/ComplexJsonRelationalFixtureBase.cs
+++ b/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexJson/ComplexJsonRelationalFixtureBase.cs
@@ -10,6 +10,9 @@ public abstract class ComplexJsonRelationalFixtureBase : ComplexPropertiesFixtur
protected override string StoreName
=> "ComplexJsonQueryTest";
+ public override void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction)
+ => facade.UseTransaction(transaction.GetDbTransaction());
+
protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context)
{
base.OnModelCreating(modelBuilder, context);
diff --git a/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingBulkUpdateRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingBulkUpdateRelationalTestBase.cs
new file mode 100644
index 00000000000..d90f17ad099
--- /dev/null
+++ b/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingBulkUpdateRelationalTestBase.cs
@@ -0,0 +1,81 @@
+// 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.Query.Associations.ComplexProperties;
+
+namespace Microsoft.EntityFrameworkCore.Query.Associations.ComplexTableSplitting;
+
+public abstract class ComplexTableSplittingBulkUpdateRelationalTestBase : ComplexPropertiesBulkUpdateTestBase
+ where TFixture : ComplexTableSplittingRelationalFixtureBase, new()
+{
+ public ComplexTableSplittingBulkUpdateRelationalTestBase(TFixture fixture, ITestOutputHelper testOutputHelper)
+ : base(fixture)
+ {
+ fixture.TestSqlLoggerFactory.Clear();
+ fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper);
+ }
+
+ // #36678 - ExecuteDelete on complex type
+ public override Task Delete_required_association()
+ => AssertTranslationFailedWithDetails(RelationalStrings.ExecuteDeleteOnNonEntityType, base.Delete_required_association);
+
+ // #36678 - ExecuteDelete on complex type
+ public override Task Delete_optional_association()
+ => Assert.ThrowsAsync(base.Delete_optional_association);
+
+ // #36336
+ public override Task Update_property_on_projected_association_with_OrderBy_Skip()
+ => AssertTranslationFailedWithDetails(
+ RelationalStrings.ExecuteUpdateSubqueryNotSupportedOverComplexTypes("RootEntity.RequiredRelated#RelatedType"),
+ base.Update_property_on_projected_association_with_OrderBy_Skip);
+
+ #region Update collection
+
+ // Collections are not supported with table splitting, only JSON
+ public override async Task Update_collection_to_parameter()
+ {
+ await Assert.ThrowsAsync(base.Update_collection_to_parameter);
+
+ AssertExecuteUpdateSql();
+ }
+
+ // Collections are not supported with table splitting, only JSON
+ public override async Task Update_nested_collection_to_parameter()
+ {
+ await Assert.ThrowsAsync(base.Update_nested_collection_to_parameter);
+
+ AssertExecuteUpdateSql();
+ }
+
+ // Collections are not supported with table splitting, only JSON
+ public override async Task Update_nested_collection_to_inline_with_lambda()
+ {
+ await Assert.ThrowsAsync(base.Update_nested_collection_to_inline_with_lambda);
+
+ AssertExecuteUpdateSql();
+ }
+
+ // Collections are not supported with table splitting, only JSON
+ public override async Task Update_collection_referencing_the_original_collection()
+ {
+ await Assert.ThrowsAsync(base.Update_collection_referencing_the_original_collection);
+
+ AssertExecuteUpdateSql();
+ }
+
+ // Collections are not supported with table splitting, only JSON
+ public override async Task Update_nested_collection_to_another_nested_collection()
+ {
+ await Assert.ThrowsAsync(base.Update_nested_collection_to_another_nested_collection);
+
+ AssertExecuteUpdateSql();
+ }
+
+ #endregion Update collection
+
+ protected void AssertSql(params string[] expected)
+ => Fixture.TestSqlLoggerFactory.AssertBaseline(expected);
+
+ protected void AssertExecuteUpdateSql(params string[] expected)
+ => Fixture.TestSqlLoggerFactory.AssertBaseline(expected, forUpdate: true);
+}
diff --git a/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingRelationalFixtureBase.cs b/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingRelationalFixtureBase.cs
index 77adcc5b852..b9a61901ad4 100644
--- a/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingRelationalFixtureBase.cs
+++ b/test/EFCore.Relational.Specification.Tests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingRelationalFixtureBase.cs
@@ -18,6 +18,9 @@ public abstract class ComplexTableSplittingRelationalFixtureBase : ComplexProper
protected override string StoreName
=> "ComplexTableSplittingQueryTest";
+ public override void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction)
+ => facade.UseTransaction(transaction.GetDbTransaction());
+
protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context)
{
base.OnModelCreating(modelBuilder, context);
diff --git a/test/EFCore.Relational.Specification.Tests/Query/Associations/Navigations/NavigationsRelationalFixtureBase.cs b/test/EFCore.Relational.Specification.Tests/Query/Associations/Navigations/NavigationsRelationalFixtureBase.cs
index a734d9b24f3..8955144cc05 100644
--- a/test/EFCore.Relational.Specification.Tests/Query/Associations/Navigations/NavigationsRelationalFixtureBase.cs
+++ b/test/EFCore.Relational.Specification.Tests/Query/Associations/Navigations/NavigationsRelationalFixtureBase.cs
@@ -8,6 +8,9 @@ public abstract class NavigationsRelationalFixtureBase : NavigationsFixtureBase,
protected override string StoreName
=> "NavigationsQueryTest";
+ public override void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction)
+ => facade.UseTransaction(transaction.GetDbTransaction());
+
protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context)
{
base.OnModelCreating(modelBuilder, context);
diff --git a/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedJson/OwnedJsonBulkUpdateRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedJson/OwnedJsonBulkUpdateRelationalTestBase.cs
new file mode 100644
index 00000000000..dc2f6d627c1
--- /dev/null
+++ b/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedJson/OwnedJsonBulkUpdateRelationalTestBase.cs
@@ -0,0 +1,66 @@
+// 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.BulkUpdates;
+
+namespace Microsoft.EntityFrameworkCore.Query.Associations.OwnedJson;
+
+public abstract class OwnedJsonBulkUpdateRelationalTestBase : BulkUpdatesTestBase
+ where TFixture : OwnedJsonRelationalFixtureBase, new()
+{
+ public OwnedJsonBulkUpdateRelationalTestBase(TFixture fixture, ITestOutputHelper testOutputHelper)
+ : base(fixture)
+ {
+ fixture.TestSqlLoggerFactory.Clear();
+ fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper);
+ }
+
+ // Bulk update is not supported with owned JSON.
+ // We just have a couple of tests here to verify that the correct exceptions are thrown, and don't extend
+ // the actual AssociationsBulkUpdateTestBase with all the different tests.
+
+ [ConditionalFact]
+ public virtual Task Delete_association()
+ => AssertTranslationFailedWithDetails(
+ RelationalStrings.ExecuteOperationOnOwnedJsonIsNotSupported("ExecuteDelete", "RootEntity.RequiredRelated#RelatedType"),
+ () => AssertDelete(
+ ss => ss.Set().Select(c => c.RequiredRelated),
+ rowsAffectedCount: 0));
+
+ [ConditionalFact]
+ public virtual Task Update_property_inside_association()
+ => AssertTranslationFailedWithDetails(
+ RelationalStrings.ExecuteOperationOnOwnedJsonIsNotSupported("ExecuteUpdate", "RootEntity.RequiredRelated#RelatedType"),
+ () => AssertUpdate(
+ ss => ss.Set(),
+ e => e,
+ s => s.SetProperty(c => c.RequiredRelated.String, "foo_updated"),
+ rowsAffectedCount: 0));
+
+ [ConditionalFact]
+ public virtual async Task Update_association()
+ {
+ var newNested = new NestedType
+ {
+ Name = "Updated nested name",
+ Int = 80,
+ String = "Updated nested string"
+ };
+
+ await AssertTranslationFailedWithDetails(
+ RelationalStrings.InvalidPropertyInSetProperty("x => x.RequiredRelated.RequiredNested"),
+ // RelationalStrings.ExecuteOperationOnOwnedJsonIsNotSupported("ExecuteUpdate", "RootEntity.RequiredRelated#RelatedType"),
+ () => AssertUpdate(
+ ss => ss.Set(),
+ c => c,
+ s => s.SetProperty(x => x.RequiredRelated.RequiredNested, newNested),
+ rowsAffectedCount: 0));
+ }
+
+ protected static async Task AssertTranslationFailedWithDetails(string details, Func query)
+ {
+ var exception = await Assert.ThrowsAsync(query);
+ Assert.Contains(CoreStrings.NonQueryTranslationFailedWithDetails("", details)[21..], exception.Message);
+ }
+}
+
diff --git a/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedJson/OwnedJsonCollectionRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedJson/OwnedJsonCollectionRelationalTestBase.cs
index 16c868e3f94..df9b3f5e0e9 100644
--- a/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedJson/OwnedJsonCollectionRelationalTestBase.cs
+++ b/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedJson/OwnedJsonCollectionRelationalTestBase.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.EntityFrameworkCore.Query.Associations.OwnedNavigations;
+using Xunit.Sdk;
namespace Microsoft.EntityFrameworkCore.Query.Associations.OwnedJson;
diff --git a/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedJson/OwnedJsonRelationalFixtureBase.cs b/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedJson/OwnedJsonRelationalFixtureBase.cs
index b1ef56b6c73..c26f7271778 100644
--- a/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedJson/OwnedJsonRelationalFixtureBase.cs
+++ b/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedJson/OwnedJsonRelationalFixtureBase.cs
@@ -10,6 +10,9 @@ public abstract class OwnedJsonRelationalFixtureBase : OwnedNavigationsFixtureBa
protected override string StoreName
=> "OwnedJsonJsonQueryTest";
+ public override void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction)
+ => facade.UseTransaction(transaction.GetDbTransaction());
+
protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context)
{
base.OnModelCreating(modelBuilder, context);
diff --git a/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedNavigations/OwnedNavigationsRelationalFixtureBase.cs b/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedNavigations/OwnedNavigationsRelationalFixtureBase.cs
index e6b643e1c5c..a006b3242be 100644
--- a/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedNavigations/OwnedNavigationsRelationalFixtureBase.cs
+++ b/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedNavigations/OwnedNavigationsRelationalFixtureBase.cs
@@ -16,6 +16,9 @@ public override bool AreCollectionsOrdered
protected override string StoreName
=> "OwnedNavigationsQueryTest";
+ public override void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction)
+ => facade.UseTransaction(transaction.GetDbTransaction());
+
protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context)
{
base.OnModelCreating(modelBuilder, context);
diff --git a/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedTableSplitting/OwnedTableSplittingRelationalFixtureBase.cs b/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedTableSplitting/OwnedTableSplittingRelationalFixtureBase.cs
index 7b58fb6c87f..74205d33a44 100644
--- a/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedTableSplitting/OwnedTableSplittingRelationalFixtureBase.cs
+++ b/test/EFCore.Relational.Specification.Tests/Query/Associations/OwnedTableSplitting/OwnedTableSplittingRelationalFixtureBase.cs
@@ -17,6 +17,9 @@ public override bool AreCollectionsOrdered
protected override string StoreName
=> "OwnedTableSplittingQueryTest";
+ public override void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction)
+ => facade.UseTransaction(transaction.GetDbTransaction());
+
protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context)
{
base.OnModelCreating(modelBuilder, context);
diff --git a/test/EFCore.Relational.Specification.Tests/RelationalTypeTestBase.cs b/test/EFCore.Relational.Specification.Tests/RelationalTypeTestBase.cs
new file mode 100644
index 00000000000..fb0625048e2
--- /dev/null
+++ b/test/EFCore.Relational.Specification.Tests/RelationalTypeTestBase.cs
@@ -0,0 +1,128 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.EntityFrameworkCore;
+
+public abstract class RelationalTypeTestBase(TFixture fixture) : TypeTestBase(fixture)
+ where TFixture : RelationalTypeTestBase.RelationalTypeTestFixture
+ where T : notnull
+{
+ [ConditionalFact]
+ public virtual async Task ExecuteUpdate_within_json_to_parameter()
+ => await TestHelpers.ExecuteWithStrategyInTransactionAsync(
+ Fixture.CreateContext,
+ Fixture.UseTransaction,
+ async context =>
+ {
+ await context.Set().ExecuteUpdateAsync(s => s.SetProperty(e => e.JsonContainer.Value, e => Fixture.OtherValue));
+ var result = await context.Set().Where(e => e.Id == 1).SingleAsync();
+ Assert.Equal(Fixture.OtherValue, result.JsonContainer.Value, Fixture.Comparer);
+ });
+
+ [ConditionalFact]
+ public virtual async Task ExecuteUpdate_within_json_to_constant()
+ => await TestHelpers.ExecuteWithStrategyInTransactionAsync(
+ Fixture.CreateContext,
+ Fixture.UseTransaction,
+ async context =>
+ {
+ // Manually inject a constant node into the query tree
+ var parameter = Expression.Parameter(typeof(JsonTypeEntity));
+ var valueExpression = Expression.Lambda>(
+ Expression.Constant(Fixture.OtherValue, typeof(T)),
+ parameter);
+
+ await context.Set().ExecuteUpdateAsync(s => s.SetProperty(e => e.JsonContainer.Value, valueExpression));
+ var result = await context.Set().Where(e => e.Id == 1).SingleAsync();
+ Assert.Equal(Fixture.OtherValue, result.JsonContainer.Value, Fixture.Comparer);
+ });
+
+ [ConditionalFact]
+ public virtual async Task ExecuteUpdate_within_json_to_another_json_property()
+ => await TestHelpers.ExecuteWithStrategyInTransactionAsync(
+ Fixture.CreateContext,
+ Fixture.UseTransaction,
+ async context =>
+ {
+ await context.Set().ExecuteUpdateAsync(s => s.SetProperty(e => e.JsonContainer.Value, e => e.JsonContainer.OtherValue));
+ var result = await context.Set().Where(e => e.Id == 1).SingleAsync();
+ Assert.Equal(Fixture.OtherValue, result.JsonContainer.Value, Fixture.Comparer);
+ });
+
+ [ConditionalFact]
+ public virtual async Task ExecuteUpdate_within_json_to_nonjson_column()
+ => await TestHelpers.ExecuteWithStrategyInTransactionAsync(
+ Fixture.CreateContext,
+ Fixture.UseTransaction,
+ async context =>
+ {
+ await context.Set().ExecuteUpdateAsync(s => s.SetProperty(e => e.JsonContainer.Value, e => e.OtherValue));
+ var result = await context.Set().Where(e => e.Id == 1).SingleAsync();
+ Assert.Equal(Fixture.OtherValue, result.JsonContainer.Value, Fixture.Comparer);
+ });
+
+ protected class JsonTypeEntity
+ {
+ public int Id { get; set; }
+
+ public required T Value { get; set; }
+ public required T OtherValue { get; set; }
+
+ public required JsonContainer JsonContainer { get; set; }
+ }
+
+ public class JsonContainer
+ {
+ public required T Value { get; set; }
+ public required T OtherValue { get; set; }
+ }
+
+ public abstract class RelationalTypeTestFixture(T value, T otherValue)
+ : TypeTestFixture(value, otherValue)
+ {
+ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context)
+ {
+ base.OnModelCreating(modelBuilder, context);
+
+ modelBuilder.Entity(b =>
+ {
+ modelBuilder.Entity().Property(e => e.Id).ValueGeneratedNever();
+ b.ComplexProperty(e => e.JsonContainer, cb => cb.ToJson());
+ });
+ }
+
+ protected override async Task SeedAsync(DbContext context)
+ {
+ await base.SeedAsync(context);
+
+ context.Set().AddRange(
+ new()
+ {
+ Id = 1,
+ Value = Value,
+ OtherValue = OtherValue,
+ JsonContainer = new()
+ {
+ Value = Value,
+ OtherValue = OtherValue
+ }
+ },
+ new()
+ {
+ Id = 2,
+ Value = OtherValue,
+ OtherValue = Value,
+ JsonContainer = new()
+ {
+ Value = OtherValue,
+ OtherValue = Value
+ }
+ });
+
+ await context.SaveChangesAsync();
+ }
+
+ public virtual void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction)
+ => facade.UseTransaction(transaction.GetDbTransaction());
+ }
+}
diff --git a/test/EFCore.Specification.Tests/BulkUpdates/BulkUpdatesTestBase.cs b/test/EFCore.Specification.Tests/BulkUpdates/BulkUpdatesTestBase.cs
index 8b03348e0a0..b89b0dc9754 100644
--- a/test/EFCore.Specification.Tests/BulkUpdates/BulkUpdatesTestBase.cs
+++ b/test/EFCore.Specification.Tests/BulkUpdates/BulkUpdatesTestBase.cs
@@ -23,12 +23,26 @@ protected virtual Expression RewriteServerQueryExpression(Expression serverQuery
public static readonly IEnumerable