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 IsAsyncData = [[false], [true]]; + public Task AssertDelete( + Func> query, + int rowsAffectedCount) + => AssertDelete(async: true, query, rowsAffectedCount); + public Task AssertDelete( bool async, Func> query, int rowsAffectedCount) => BulkUpdatesAsserter.AssertDelete(async, query, rowsAffectedCount); + public Task AssertUpdate( + Func> query, + Expression> entitySelector, + Action> setPropertyCalls, + int rowsAffectedCount, + Action, IReadOnlyList> asserter = null) + where TResult : class + => AssertUpdate(async: true, query, entitySelector, setPropertyCalls, rowsAffectedCount, asserter); + public Task AssertUpdate( bool async, Func> query, diff --git a/test/EFCore.Specification.Tests/BulkUpdates/ComplexTypeBulkUpdatesFixtureBase.cs b/test/EFCore.Specification.Tests/BulkUpdates/ComplexTypeBulkUpdatesFixtureBase.cs deleted file mode 100644 index fc2c4342629..00000000000 --- a/test/EFCore.Specification.Tests/BulkUpdates/ComplexTypeBulkUpdatesFixtureBase.cs +++ /dev/null @@ -1,14 +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 ComplexTypeBulkUpdatesFixtureBase : ComplexTypeQueryFixtureBase, IBulkUpdatesFixtureBase -{ - protected override string StoreName - => "ComplexTypeBulkUpdatesTest"; - - public abstract void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction); -} diff --git a/test/EFCore.Specification.Tests/BulkUpdates/ComplexTypeBulkUpdatesTestBase.cs b/test/EFCore.Specification.Tests/BulkUpdates/ComplexTypeBulkUpdatesTestBase.cs deleted file mode 100644 index 3f8e5aa9d05..00000000000 --- a/test/EFCore.Specification.Tests/BulkUpdates/ComplexTypeBulkUpdatesTestBase.cs +++ /dev/null @@ -1,215 +0,0 @@ -// 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.TestModels.ComplexTypeModel; - -namespace Microsoft.EntityFrameworkCore.BulkUpdates; - -#nullable disable - -public abstract class ComplexTypeBulkUpdatesTestBase(TFixture fixture) : BulkUpdatesTestBase(fixture) - where TFixture : ComplexTypeBulkUpdatesFixtureBase, new() -{ - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Delete_complex_type(bool async) - => AssertDelete( - async, - ss => ss.Set().Select(c => c.ShippingAddress), - rowsAffectedCount: 0); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_projected_complex_type_via_OrderBy_Skip(bool async) - => AssertUpdate( - async, - ss => ss.Set().Select(c => c.ShippingAddress).OrderBy(a => a.ZipCode).Skip(1), - a => a, - s => s.SetProperty(c => c.ZipCode, 12345), - rowsAffectedCount: 3); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Delete_entity_type_with_complex_type(bool async) - => AssertDelete( - async, - ss => ss.Set().Where(e => e.Name == "Monty Elias"), - rowsAffectedCount: 1); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_property_inside_complex_type(bool async) - => AssertUpdate( - async, - ss => ss.Set().Where(c => c.ShippingAddress.ZipCode == 07728), - e => e, - s => s.SetProperty(c => c.ShippingAddress.ZipCode, 12345), - rowsAffectedCount: 1); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_property_inside_nested_complex_type(bool async) - => AssertUpdate( - async, - ss => ss.Set().Where(c => c.ShippingAddress.Country.Code == "US"), - e => e, - s => s.SetProperty(c => c.ShippingAddress.Country.FullName, "United States Modified"), - rowsAffectedCount: 1); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_multiple_properties_inside_multiple_complex_types_and_on_entity_type(bool async) - => AssertUpdate( - async, - ss => ss.Set().Where(c => c.ShippingAddress.ZipCode == 07728), - e => e, - s => s - .SetProperty(c => c.Name, c => c.Name + "Modified") - .SetProperty(c => c.ShippingAddress.ZipCode, c => c.BillingAddress.ZipCode) - .SetProperty(c => c.BillingAddress.ZipCode, 54321), - rowsAffectedCount: 1); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_projected_complex_type(bool async) - => AssertUpdate( - async, - ss => ss.Set().Select(c => c.ShippingAddress), - a => a, - s => s.SetProperty(c => c.ZipCode, 12345), - rowsAffectedCount: 3); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_multiple_projected_complex_types_via_anonymous_type(bool async) - => AssertUpdate( - async, - ss => ss.Set().Select(c => new - { - c.ShippingAddress, - c.BillingAddress, - Customer = c - }), - x => x.Customer, - s => s - .SetProperty(x => x.ShippingAddress.ZipCode, x => x.BillingAddress.ZipCode) - .SetProperty(x => x.BillingAddress.ZipCode, 54321), - rowsAffectedCount: 3); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_complex_type_to_parameter(bool async) - { - var newAddress = new Address - { - AddressLine1 = "New AddressLine1", - AddressLine2 = "New AddressLine2", - ZipCode = 99999, - Country = new Country { Code = "FR", FullName = "France" }, - Tags = ["new_tag1", "new_tag2"] - }; - - return AssertUpdate( - async, - ss => ss.Set(), - c => c, - s => s.SetProperty(x => x.ShippingAddress, newAddress), - rowsAffectedCount: 3); - } - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_nested_complex_type_to_parameter(bool async) - { - var newCountry = new Country { Code = "FR", FullName = "France" }; - - return AssertUpdate( - async, - ss => ss.Set(), - c => c, - s => s.SetProperty(x => x.ShippingAddress.Country, newCountry), - rowsAffectedCount: 3); - } - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_complex_type_to_another_database_complex_type(bool async) - => AssertUpdate( - async, - ss => ss.Set(), - c => c, - s => s.SetProperty(x => x.ShippingAddress, x => x.BillingAddress), - rowsAffectedCount: 3); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_complex_type_to_inline_without_lambda(bool async) - => AssertUpdate( - async, - ss => ss.Set(), - c => c, - s => s.SetProperty( - x => x.ShippingAddress, new Address - { - AddressLine1 = "New AddressLine1", - AddressLine2 = "New AddressLine2", - ZipCode = 99999, - Country = new Country { Code = "FR", FullName = "France" }, - Tags = ["new_tag1", "new_tag2"] - }), - rowsAffectedCount: 3); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_complex_type_to_inline_with_lambda(bool async) - => AssertUpdate( - async, - ss => ss.Set(), - c => c, - s => s.SetProperty( - x => x.ShippingAddress, x => new Address - { - AddressLine1 = "New AddressLine1", - AddressLine2 = "New AddressLine2", - ZipCode = 99999, - Country = new Country { Code = "FR", FullName = "France" }, - Tags = new List { "new_tag1", "new_tag2" } - }), - rowsAffectedCount: 3); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_complex_type_to_another_database_complex_type_with_subquery(bool async) - => AssertUpdate( - async, - ss => ss.Set().OrderBy(c => c.Id).Skip(1), - c => c, - s => s.SetProperty(x => x.ShippingAddress, x => x.BillingAddress), - rowsAffectedCount: 2); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_collection_inside_complex_type(bool async) - => AssertUpdate( - async, - ss => ss.Set(), - c => c, - s => s.SetProperty(x => x.ShippingAddress.Tags, ["new_tag1", "new_tag2"]), - rowsAffectedCount: 3); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_complex_type_to_null(bool async) - => AssertUpdate( - async, - ss => ss.Set(), - c => c, - s => s.SetProperty(x => x.OptionalAddress, (Address)null), - rowsAffectedCount: 3); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_complex_type_to_null_lambda(bool async) - => AssertUpdate( - async, - ss => ss.Set(), - c => c, - s => s.SetProperty(x => x.OptionalAddress, x => null), - rowsAffectedCount: 3); - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual Task Update_complex_type_to_null_parameter(bool async) - { - var nullAddress = (Address)null; - - return AssertUpdate( - async, - ss => ss.Set(), - c => c, - s => s.SetProperty(x => x.OptionalAddress, nullAddress), - rowsAffectedCount: 3); - } -} diff --git a/test/EFCore.Specification.Tests/NonSharedModelTestBase.cs b/test/EFCore.Specification.Tests/NonSharedModelTestBase.cs index d9ca0df2f8e..dd407fd228a 100644 --- a/test/EFCore.Specification.Tests/NonSharedModelTestBase.cs +++ b/test/EFCore.Specification.Tests/NonSharedModelTestBase.cs @@ -37,7 +37,7 @@ protected NonSharedModelTestBase() { } - protected NonSharedModelTestBase(NonSharedFixture fixture) + protected NonSharedModelTestBase(NonSharedFixture? fixture) => Fixture = fixture; public virtual Task InitializeAsync() diff --git a/test/EFCore.Specification.Tests/Query/Associations/AssociationsBulkUpdateTestBase.cs b/test/EFCore.Specification.Tests/Query/Associations/AssociationsBulkUpdateTestBase.cs new file mode 100644 index 00000000000..89dda5fd1c4 --- /dev/null +++ b/test/EFCore.Specification.Tests/Query/Associations/AssociationsBulkUpdateTestBase.cs @@ -0,0 +1,402 @@ +// 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; + +public abstract class AssociationsBulkUpdateTestBase(TFixture fixture) : BulkUpdatesTestBase(fixture) + where TFixture : AssociationsQueryFixtureBase, new() +{ + #region Delete + + [ConditionalFact] + public virtual async Task Delete_entity_with_associations() + { + // Make sure foreign key constraints don't get in the way + var deletableEntity = Fixture.Data.RootEntities.Where(e => !Fixture.Data.RootReferencingEntities.Any(re => re.Root == e)).First(); + + await AssertDelete( + ss => ss.Set().Where(e => e.Name == deletableEntity.Name), + rowsAffectedCount: 1); + } + + // Should always fail (since the association is required), but (at least for now) may fail in different ways depending on the + // association mapping type. + [ConditionalFact] + public virtual Task Delete_required_association() + => AssertDelete( + ss => ss.Set().Select(c => c.RequiredRelated), + rowsAffectedCount: 0); + + [ConditionalFact] + public virtual Task Delete_optional_association() + => AssertDelete( + ss => ss.Set().Select(c => c.OptionalRelated), + rowsAffectedCount: 0); + + #endregion Delete + + #region Update properties + + [ConditionalFact] + public virtual Task Update_property_inside_association() + => AssertUpdate( + ss => ss.Set(), + e => e, + s => s.SetProperty(c => c.RequiredRelated.String, "foo_updated"), + rowsAffectedCount: 7); + + [ConditionalFact] + public virtual Task Update_property_inside_association_with_special_chars() + => AssertUpdate( + ss => ss.Set().Where(c => c.RequiredRelated.String == "{ this may/look:like JSON but it [isn't]: ממש ממש לאéèéè }"), + e => e, + s => s.SetProperty(c => c.RequiredRelated.String, c => "{ Some other/JSON:like text though it [isn't]: ממש ממש לאéèéè }"), + rowsAffectedCount: 1); + + [ConditionalFact] + public virtual Task Update_property_inside_nested() + => AssertUpdate( + ss => ss.Set(), + e => e, + s => s.SetProperty(c => c.RequiredRelated.RequiredNested.String, "foo_updated"), + rowsAffectedCount: 7); + + [ConditionalFact] + public virtual Task Update_property_on_projected_association() + => AssertUpdate( + ss => ss.Set().Select(c => c.RequiredRelated), + a => a, + s => s.SetProperty(c => c.String, "foo_updated"), + rowsAffectedCount: 7); + + [ConditionalFact] + public virtual Task Update_property_on_projected_association_with_OrderBy_Skip() + => AssertUpdate( + ss => ss.Set().Select(c => c.RequiredRelated).OrderBy(a => a.String).Skip(1), + a => a, + s => s.SetProperty(c => c.String, "foo_updated"), + rowsAffectedCount: 3); + + #endregion Update properties + + #region Update association + + [ConditionalFact] + public virtual Task Update_association_to_parameter() + { + var newRelated = new RelatedType + { + Name = "Updated related name", + + RequiredNested = new NestedType + { + Name = "Updated nested name", + Int = 80, + String = "Updated nested string" + }, + OptionalNested = null, + NestedCollection = [] + }; + + return AssertUpdate( + ss => ss.Set(), + c => c, + s => s.SetProperty(x => x.RequiredRelated, newRelated), + rowsAffectedCount: 7); + } + + [ConditionalFact] + public virtual Task Update_nested_association_to_parameter() + { + var newNested = new NestedType + { + Name = "Updated nested name", + Int = 80, + String = "Updated nested string" + }; + + return AssertUpdate( + ss => ss.Set(), + c => c, + s => s.SetProperty(x => x.RequiredRelated.RequiredNested, newNested), + rowsAffectedCount: 7); + } + + [ConditionalFact] + public virtual Task Update_association_to_another_association() + => AssertUpdate( + ss => ss.Set(), + c => c, + s => s.SetProperty(x => x.OptionalRelated, x => x.RequiredRelated), + rowsAffectedCount: 7); + + [ConditionalFact] + public virtual Task Update_nested_association_to_another_nested_association() + => AssertUpdate( + ss => ss.Set(), + c => c, + s => s.SetProperty(x => x.RequiredRelated.OptionalNested, x => x.RequiredRelated.RequiredNested), + rowsAffectedCount: 7); + + [ConditionalFact] + public virtual Task Update_association_to_inline() + => AssertUpdate( + ss => ss.Set(), + c => c, + s => s.SetProperty( + x => x.RequiredRelated, + new RelatedType + { + Name = "Updated related name", + Int = 70, + String = "Updated related string", + + RequiredNested = new NestedType + { + Name = "Updated nested name", + Int = 80, + String = "Updated nested string" + }, + OptionalNested = null, + NestedCollection = [] + }), + rowsAffectedCount: 7); + + [ConditionalFact] + public virtual Task Update_association_to_inline_with_lambda() + => AssertUpdate( + ss => ss.Set(), + c => c, + s => s.SetProperty( + x => x.RequiredRelated, + x => new RelatedType + { + Name = "Updated related name", + Int = 70, + String = "Updated related string", + + RequiredNested = new NestedType + { + Name = "Updated nested name", + Int = 80, + String = "Updated nested string" + }, + OptionalNested = null, + NestedCollection = new List() + }), + rowsAffectedCount: 7); + + [ConditionalFact] + public virtual Task Update_nested_association_to_inline_with_lambda() + => AssertUpdate( + ss => ss.Set(), + c => c, + s => s.SetProperty( + x => x.RequiredRelated.RequiredNested, + x => new NestedType + { + Name = "Updated nested name", + Int = 80, + String = "Updated nested string" + }), + rowsAffectedCount: 7); + + [ConditionalFact] + public virtual Task Update_association_to_null() + => AssertUpdate( + ss => ss.Set(), + c => c, + s => s.SetProperty(x => x.OptionalRelated, (RelatedType?)null), + rowsAffectedCount: 7); + + [ConditionalFact] + public virtual Task Update_association_to_null_with_lambda() + => AssertUpdate( + ss => ss.Set(), + c => c, + s => s.SetProperty(x => x.OptionalRelated, x => null), + rowsAffectedCount: 7); + + [ConditionalFact] + public virtual Task Update_association_to_null_parameter() + { + var nullRelated = (RelatedType?)null; + + return AssertUpdate( + ss => ss.Set(), + c => c, + s => s.SetProperty(x => x.OptionalRelated, nullRelated), + rowsAffectedCount: 7); + } + + #endregion Update association + + #region Update collection + + [ConditionalFact] + public virtual Task Update_collection_to_parameter() + { + List collection = + [ + new() + { + Name = "Updated related name1", + + RequiredNested = new() + { + Name = "Updated nested name1", + Int = 80, + String = "Updated nested string1" + }, + OptionalNested = null, + NestedCollection = [] + }, + new() + { + Name = "Updated related name2", + + RequiredNested = new() + { + Name = "Updated nested name2", + Int = 81, + String = "Updated nested string2" + }, + OptionalNested = null, + NestedCollection = [] + } + ]; + + return AssertUpdate( + ss => ss.Set(), + c => c, + s => s.SetProperty(x => x.RelatedCollection, collection), + rowsAffectedCount: 7); + } + + [ConditionalFact] + public virtual Task Update_nested_collection_to_parameter() + { + List collection = + [ + new() + { + Name = "Updated nested name1", + Int = 80, + String = "Updated nested string1" + }, + new() + { + Name = "Updated nested name2", + Int = 81, + String = "Updated nested string2" + }, + ]; + + return AssertUpdate( + ss => ss.Set(), + c => c, + s => s.SetProperty(x => x.RequiredRelated.NestedCollection, collection), + rowsAffectedCount: 7); + } + + [ConditionalFact] + public virtual Task Update_nested_collection_to_inline_with_lambda() + => AssertUpdate( + ss => ss.Set(), + c => c, + s => s.SetProperty( + x => x.RequiredRelated.NestedCollection, + x => new List + { + new() + { + Name = "Updated nested name1", + Int = 80, + String = "Updated nested string1" + }, + new() + { + Name = "Updated nested name2", + Int = 81, + String = "Updated nested string2" + } + }), + rowsAffectedCount: 7); + + [ConditionalFact] + public virtual Task Update_collection_referencing_the_original_collection() + => AssertUpdate( + ss => ss.Set().Where(e => e.RequiredRelated.NestedCollection.Count >= 2), + c => c, + s => s.SetProperty( + e => e.RequiredRelated.NestedCollection, + e => new List { e.RequiredRelated.NestedCollection[1], e.RequiredRelated.NestedCollection[0]}), + rowsAffectedCount: 7); + + [ConditionalFact] + public virtual Task Update_nested_collection_to_another_nested_collection() + => AssertUpdate( + ss => ss.Set().Where(e => e.OptionalRelated != null), + c => c, + s => s.SetProperty( + x => x.RequiredRelated.NestedCollection, + x => x.OptionalRelated!.NestedCollection), + rowsAffectedCount: 6); + + #endregion Update collection + + #region Multiple updates + + [ConditionalFact] + public virtual Task Update_multiple_properties_inside_same_association() + => AssertUpdate( + ss => ss.Set(), + e => e, + s => s + .SetProperty(c => c.RequiredRelated.String, "foo_updated") + .SetProperty(c => c.RequiredRelated.Int, 20), + rowsAffectedCount: 7); + + [ConditionalFact] + public virtual Task Update_multiple_properties_inside_associations_and_on_entity_type() + => AssertUpdate( + ss => ss.Set().Where(c => c.OptionalRelated != null), + e => e, + s => s + .SetProperty(c => c.Name, c => c.Name + "Modified") + .SetProperty(c => c.RequiredRelated.String, c => c.OptionalRelated!.String) + .SetProperty(c => c.OptionalRelated!.RequiredNested.String, "foo_updated"), + rowsAffectedCount: 6); + + [ConditionalFact] + public virtual Task Update_multiple_projected_associations_via_anonymous_type() + => AssertUpdate( + ss => ss.Set() + .Where(c => c.OptionalRelated != null) + .Select(c => new + { + c.RequiredRelated, + c.OptionalRelated, + RootEntity = c + }), + x => x.RootEntity, + s => s + .SetProperty(c => c.RequiredRelated.String, c => c.OptionalRelated!.String) + .SetProperty(c => c.OptionalRelated!.String, "foo_updated"), + rowsAffectedCount: 6); + + #endregion Multiple updates + + protected static async Task AssertTranslationFailed(Func query) + => Assert.Contains( + CoreStrings.TranslationFailed("")[48..], + (await Assert.ThrowsAsync(query)) + .Message); + + protected static async Task AssertTranslationFailedWithDetails(string details, Func query) + => Assert.Contains( + CoreStrings.NonQueryTranslationFailedWithDetails("", details)[21..], + (await Assert.ThrowsAsync(query)).Message); +} diff --git a/test/EFCore.Specification.Tests/Query/Associations/AssociationsData.cs b/test/EFCore.Specification.Tests/Query/Associations/AssociationsData.cs index 7d7b612eee0..f1eadc2b7f7 100644 --- a/test/EFCore.Specification.Tests/Query/Associations/AssociationsData.cs +++ b/test/EFCore.Specification.Tests/Query/Associations/AssociationsData.cs @@ -137,6 +137,39 @@ void SetRelatedValues(RelatedType related) e.RelatedCollection.Clear(); e.RequiredRelated.NestedCollection.Clear(); e.OptionalRelated!.NestedCollection.Clear(); + }), + + // Entity with all string properties set to a value with special characters + CreateRootEntity( + id++, description: "With_special_characters", e => + { + SetRelatedValues(e.RequiredRelated); + + if (e.OptionalRelated is not null) + { + SetRelatedValues(e.OptionalRelated); + } + + foreach (var related in e.RelatedCollection) + { + SetRelatedValues(related); + } + + void SetRelatedValues(RelatedType related) + { + related.Int = 10; + related.String = "{ this may/look:like JSON but it [isn't]: ממש ממש לאéèéè }"; + related.RequiredNested.Int = 10; + related.RequiredNested.String = "{ this may/look:like JSON but it [isn't]: ממש ממש לאéèéè }"; + related.OptionalNested?.Int = 10; + related.OptionalNested?.String = "{ this may/look:like JSON but it [isn't]: ממש ממש לאéèéè }"; + + foreach (var nested in related.NestedCollection) + { + nested.Int = 10; + nested.String = "{ this may/look:like JSON but it [isn't]: ממש ממש לאéèéè }"; + } + } }) ]; @@ -325,7 +358,7 @@ private static List CreateRootReferencingEntities(IEnumer var id = 1; rootReferencingEntities.Add(new RootReferencingEntity { Id = id++, Root = null }); - foreach (var rootEntity in rootEntities) + foreach (var rootEntity in rootEntities.Take(2)) { var rootReferencingEntity = new RootReferencingEntity { Id = id++, Root = rootEntity }; rootEntity.RootReferencingEntity = rootReferencingEntity; diff --git a/test/EFCore.Specification.Tests/Query/Associations/AssociationsQueryFixtureBase.cs b/test/EFCore.Specification.Tests/Query/Associations/AssociationsQueryFixtureBase.cs index 390d82a7c29..0655c8d587f 100644 --- a/test/EFCore.Specification.Tests/Query/Associations/AssociationsQueryFixtureBase.cs +++ b/test/EFCore.Specification.Tests/Query/Associations/AssociationsQueryFixtureBase.cs @@ -1,13 +1,19 @@ // 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; -public abstract class AssociationsQueryFixtureBase : SharedStoreFixtureBase, IQueryFixtureBase +public abstract class AssociationsQueryFixtureBase : SharedStoreFixtureBase, + IQueryFixtureBase, IBulkUpdatesFixtureBase { public virtual bool AreCollectionsOrdered => true; + public virtual void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction) + => throw new NotSupportedException(); + public AssociationsData Data { get; private set; } public AssociationsQueryFixtureBase() diff --git a/test/EFCore.Specification.Tests/Query/Associations/ComplexProperties/ComplexPropertiesBulkUpdateTestBase.cs b/test/EFCore.Specification.Tests/Query/Associations/ComplexProperties/ComplexPropertiesBulkUpdateTestBase.cs new file mode 100644 index 00000000000..e077611d2d7 --- /dev/null +++ b/test/EFCore.Specification.Tests/Query/Associations/ComplexProperties/ComplexPropertiesBulkUpdateTestBase.cs @@ -0,0 +1,8 @@ +// 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.Query.Associations.ComplexProperties; + +public abstract class ComplexPropertiesBulkUpdateTestBase(TFixture fixture) + : AssociationsBulkUpdateTestBase(fixture) + where TFixture : ComplexPropertiesFixtureBase, new(); diff --git a/test/EFCore.Specification.Tests/SharedStoreFixtureBase.cs b/test/EFCore.Specification.Tests/SharedStoreFixtureBase.cs index 149bc04ea35..adcab6c6467 100644 --- a/test/EFCore.Specification.Tests/SharedStoreFixtureBase.cs +++ b/test/EFCore.Specification.Tests/SharedStoreFixtureBase.cs @@ -19,6 +19,7 @@ public IServiceProvider ServiceProvider protected abstract string StoreName { get; } protected abstract ITestStoreFactory TestStoreFactory { get; } + protected virtual bool RecreateStore { get; } = false; private TestStore? _testStore; @@ -45,7 +46,14 @@ public ListLoggerFactory ListLoggerFactory public virtual async Task InitializeAsync() { - _testStore = TestStoreFactory.GetOrCreate(StoreName); + if (RecreateStore) + { + _testStore = TestStoreFactory.Create(StoreName); + } + else + { + _testStore = TestStoreFactory.GetOrCreate(StoreName); + } var services = AddServices(TestStoreFactory.AddProviderServices(new ServiceCollection())); services = UsePooling diff --git a/test/EFCore.Specification.Tests/TypeTestBase.cs b/test/EFCore.Specification.Tests/TypeTestBase.cs new file mode 100644 index 00000000000..5a52c717705 --- /dev/null +++ b/test/EFCore.Specification.Tests/TypeTestBase.cs @@ -0,0 +1,68 @@ +// 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; + +[Collection("Type tests")] +public abstract class TypeTestBase(TFixture fixture) : IClassFixture + where TFixture : TypeTestBase.TypeTestFixture + where T : notnull +{ + [ConditionalFact] + public async Task Equality_in_query() + { + await using var context = Fixture.CreateContext(); + + var result = await context.Set().Where(e => e.Value.Equals(Fixture.Value)).SingleAsync(); + + Assert.Equal(Fixture.Value, result.Value, Fixture.Comparer); + } + + protected class TypeEntity + { + public int Id { get; set; } + + public required T Value { get; set; } + public required T OtherValue { get; set; } + } + + protected TFixture Fixture { get; } = fixture; + + public abstract class TypeTestFixture(T value, T otherValue) + : SharedStoreFixtureBase + { + protected override string StoreName => "TypeTest"; + + public T Value { get; } = value; + public T OtherValue { get; } = otherValue; + + public virtual Func Comparer { get; } = EqualityComparer.Default.Equals; + + protected override bool RecreateStore => true; + + protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context) + { + // Don't rely on database generated values, which aren't supported everywhere (e.g. Cosmos) + modelBuilder.Entity().Property(e => e.Id).ValueGeneratedNever(); + } + + protected override async Task SeedAsync(DbContext context) + { + context.Set().AddRange( + new() + { + Id = 1, + Value = Value, + OtherValue = OtherValue + }, + new() + { + Id = 2, + Value = OtherValue, + OtherValue = Value + }); + + await context.SaveChangesAsync(); + } + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/ComplexTypeBulkUpdatesSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/ComplexTypeBulkUpdatesSqlServerTest.cs deleted file mode 100644 index 708aa1c1930..00000000000 --- a/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/ComplexTypeBulkUpdatesSqlServerTest.cs +++ /dev/null @@ -1,321 +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 class ComplexTypeBulkUpdatesSqlServerTest( - ComplexTypeBulkUpdatesSqlServerTest.ComplexTypeBulkUpdatesSqlServerFixture fixture, - ITestOutputHelper testOutputHelper) : ComplexTypeBulkUpdatesRelationalTestBase< - ComplexTypeBulkUpdatesSqlServerTest.ComplexTypeBulkUpdatesSqlServerFixture>(fixture, testOutputHelper) -{ - public override async Task Delete_entity_type_with_complex_type(bool async) - { - await base.Delete_entity_type_with_complex_type(async); - - AssertSql( - """ -DELETE FROM [c] -FROM [Customer] AS [c] -WHERE [c].[Name] = N'Monty Elias' -"""); - } - - public override async Task Delete_complex_type(bool async) - { - await base.Delete_complex_type(async); - - AssertSql(); - } - - public override async Task Update_property_inside_complex_type(bool async) - { - await base.Update_property_inside_complex_type(async); - - AssertExecuteUpdateSql( - """ -@p='12345' - -UPDATE [c] -SET [c].[ShippingAddress_ZipCode] = @p -FROM [Customer] AS [c] -WHERE [c].[ShippingAddress_ZipCode] = 7728 -"""); - } - - public override async Task Update_property_inside_nested_complex_type(bool async) - { - await base.Update_property_inside_nested_complex_type(async); - - AssertExecuteUpdateSql( - """ -@p='United States Modified' (Size = 4000) - -UPDATE [c] -SET [c].[ShippingAddress_Country_FullName] = @p -FROM [Customer] AS [c] -WHERE [c].[ShippingAddress_Country_Code] = N'US' -"""); - } - - public override async Task Update_multiple_properties_inside_multiple_complex_types_and_on_entity_type(bool async) - { - await base.Update_multiple_properties_inside_multiple_complex_types_and_on_entity_type(async); - - AssertExecuteUpdateSql( - """ -@p='54321' - -UPDATE [c] -SET [c].[Name] = [c].[Name] + N'Modified', - [c].[ShippingAddress_ZipCode] = [c].[BillingAddress_ZipCode], - [c].[BillingAddress_ZipCode] = @p -FROM [Customer] AS [c] -WHERE [c].[ShippingAddress_ZipCode] = 7728 -"""); - } - - public override async Task Update_projected_complex_type(bool async) - { - await base.Update_projected_complex_type(async); - - AssertExecuteUpdateSql( - """ -@p='12345' - -UPDATE [c] -SET [c].[ShippingAddress_ZipCode] = @p -FROM [Customer] AS [c] -"""); - } - - public override async Task Update_multiple_projected_complex_types_via_anonymous_type(bool async) - { - await base.Update_multiple_projected_complex_types_via_anonymous_type(async); - - AssertExecuteUpdateSql( - """ -@p='54321' - -UPDATE [c] -SET [c].[ShippingAddress_ZipCode] = [c].[BillingAddress_ZipCode], - [c].[BillingAddress_ZipCode] = @p -FROM [Customer] AS [c] -"""); - } - - public override async Task Update_projected_complex_type_via_OrderBy_Skip(bool async) - { - await base.Update_projected_complex_type_via_OrderBy_Skip(async); - - AssertExecuteUpdateSql(); - } - - public override async Task Update_complex_type_to_parameter(bool async) - { - await base.Update_complex_type_to_parameter(async); - - AssertExecuteUpdateSql( - """ -@complex_type_p_AddressLine1='New AddressLine1' (Size = 4000) -@complex_type_p_AddressLine2='New AddressLine2' (Size = 4000) -@complex_type_p_Tags='["new_tag1","new_tag2"]' (Size = 4000) -@complex_type_p_ZipCode='99999' (Nullable = true) -@complex_type_p_Code='FR' (Size = 4000) -@complex_type_p_FullName='France' (Size = 4000) - -UPDATE [c] -SET [c].[ShippingAddress_AddressLine1] = @complex_type_p_AddressLine1, - [c].[ShippingAddress_AddressLine2] = @complex_type_p_AddressLine2, - [c].[ShippingAddress_Tags] = @complex_type_p_Tags, - [c].[ShippingAddress_ZipCode] = @complex_type_p_ZipCode, - [c].[ShippingAddress_Country_Code] = @complex_type_p_Code, - [c].[ShippingAddress_Country_FullName] = @complex_type_p_FullName -FROM [Customer] AS [c] -"""); - } - - public override async Task Update_nested_complex_type_to_parameter(bool async) - { - await base.Update_nested_complex_type_to_parameter(async); - - AssertExecuteUpdateSql( - """ -@complex_type_p_Code='FR' (Size = 4000) -@complex_type_p_FullName='France' (Size = 4000) - -UPDATE [c] -SET [c].[ShippingAddress_Country_Code] = @complex_type_p_Code, - [c].[ShippingAddress_Country_FullName] = @complex_type_p_FullName -FROM [Customer] AS [c] -"""); - } - - public override async Task Update_complex_type_to_another_database_complex_type(bool async) - { - await base.Update_complex_type_to_another_database_complex_type(async); - - AssertExecuteUpdateSql( - """ -UPDATE [c] -SET [c].[ShippingAddress_AddressLine1] = [c].[BillingAddress_AddressLine1], - [c].[ShippingAddress_AddressLine2] = [c].[BillingAddress_AddressLine2], - [c].[ShippingAddress_Tags] = [c].[BillingAddress_Tags], - [c].[ShippingAddress_ZipCode] = [c].[BillingAddress_ZipCode], - [c].[ShippingAddress_Country_Code] = [c].[ShippingAddress_Country_Code], - [c].[ShippingAddress_Country_FullName] = [c].[ShippingAddress_Country_FullName] -FROM [Customer] AS [c] -"""); - } - - public override async Task Update_complex_type_to_inline_without_lambda(bool async) - { - await base.Update_complex_type_to_inline_without_lambda(async); - - AssertExecuteUpdateSql( - """ -@complex_type_p_AddressLine1='New AddressLine1' (Size = 4000) -@complex_type_p_AddressLine2='New AddressLine2' (Size = 4000) -@complex_type_p_Tags='["new_tag1","new_tag2"]' (Size = 4000) -@complex_type_p_ZipCode='99999' (Nullable = true) -@complex_type_p_Code='FR' (Size = 4000) -@complex_type_p_FullName='France' (Size = 4000) - -UPDATE [c] -SET [c].[ShippingAddress_AddressLine1] = @complex_type_p_AddressLine1, - [c].[ShippingAddress_AddressLine2] = @complex_type_p_AddressLine2, - [c].[ShippingAddress_Tags] = @complex_type_p_Tags, - [c].[ShippingAddress_ZipCode] = @complex_type_p_ZipCode, - [c].[ShippingAddress_Country_Code] = @complex_type_p_Code, - [c].[ShippingAddress_Country_FullName] = @complex_type_p_FullName -FROM [Customer] AS [c] -"""); - } - - public override async Task Update_complex_type_to_inline_with_lambda(bool async) - { - await base.Update_complex_type_to_inline_with_lambda(async); - - AssertExecuteUpdateSql( - """ -UPDATE [c] -SET [c].[ShippingAddress_AddressLine1] = N'New AddressLine1', - [c].[ShippingAddress_AddressLine2] = N'New AddressLine2', - [c].[ShippingAddress_Tags] = N'["new_tag1","new_tag2"]', - [c].[ShippingAddress_ZipCode] = 99999, - [c].[ShippingAddress_Country_Code] = N'FR', - [c].[ShippingAddress_Country_FullName] = N'France' -FROM [Customer] AS [c] -"""); - } - - public override async Task Update_complex_type_to_another_database_complex_type_with_subquery(bool async) - { - await base.Update_complex_type_to_another_database_complex_type_with_subquery(async); - - AssertExecuteUpdateSql( - """ -@p='1' - -UPDATE [c0] -SET [c0].[ShippingAddress_AddressLine1] = [c1].[BillingAddress_AddressLine1], - [c0].[ShippingAddress_AddressLine2] = [c1].[BillingAddress_AddressLine2], - [c0].[ShippingAddress_Tags] = [c1].[BillingAddress_Tags], - [c0].[ShippingAddress_ZipCode] = [c1].[BillingAddress_ZipCode], - [c0].[ShippingAddress_Country_Code] = [c1].[ShippingAddress_Country_Code], - [c0].[ShippingAddress_Country_FullName] = [c1].[ShippingAddress_Country_FullName] -FROM [Customer] AS [c0] -INNER JOIN ( - SELECT [c].[Id], [c].[BillingAddress_AddressLine1], [c].[BillingAddress_AddressLine2], [c].[BillingAddress_Tags], [c].[BillingAddress_ZipCode], [c].[ShippingAddress_Country_Code], [c].[ShippingAddress_Country_FullName] - FROM [Customer] AS [c] - ORDER BY [c].[Id] - OFFSET @p ROWS -) AS [c1] ON [c0].[Id] = [c1].[Id] -"""); - } - - public override async Task Update_collection_inside_complex_type(bool async) - { - await base.Update_collection_inside_complex_type(async); - - AssertExecuteUpdateSql( - """ -@p='["new_tag1","new_tag2"]' (Size = 4000) - -UPDATE [c] -SET [c].[ShippingAddress_Tags] = @p -FROM [Customer] AS [c] -"""); - } - - public override async Task Update_complex_type_to_null(bool async) - { - await base.Update_complex_type_to_null(async); - - AssertExecuteUpdateSql( - """ -UPDATE [c] -SET [c].[OptionalAddress_AddressLine1] = NULL, - [c].[OptionalAddress_AddressLine2] = NULL, - [c].[OptionalAddress_Tags] = NULL, - [c].[OptionalAddress_ZipCode] = NULL, - [c].[OptionalAddress_Country_Code] = NULL, - [c].[OptionalAddress_Country_FullName] = NULL -FROM [Customer] AS [c] -"""); - } - - public override async Task Update_complex_type_to_null_lambda(bool async) - { - await base.Update_complex_type_to_null_lambda(async); - - AssertExecuteUpdateSql( - """ -UPDATE [c] -SET [c].[OptionalAddress_AddressLine1] = NULL, - [c].[OptionalAddress_AddressLine2] = NULL, - [c].[OptionalAddress_Tags] = NULL, - [c].[OptionalAddress_ZipCode] = NULL, - [c].[OptionalAddress_Country_Code] = NULL, - [c].[OptionalAddress_Country_FullName] = NULL -FROM [Customer] AS [c] -"""); - } - - public override async Task Update_complex_type_to_null_parameter(bool async) - { - await base.Update_complex_type_to_null_parameter(async); - - AssertExecuteUpdateSql( - """ -UPDATE [c] -SET [c].[OptionalAddress_AddressLine1] = NULL, - [c].[OptionalAddress_AddressLine2] = NULL, - [c].[OptionalAddress_Tags] = NULL, - [c].[OptionalAddress_ZipCode] = NULL, - [c].[OptionalAddress_Country_Code] = NULL, - [c].[OptionalAddress_Country_FullName] = NULL -FROM [Customer] AS [c] -"""); - } - - [ConditionalFact] - public virtual void Check_all_tests_overridden() - => TestHelpers.AssertAllMethodsOverridden(GetType()); - - private void AssertExecuteUpdateSql(params string[] expected) - => Fixture.TestSqlLoggerFactory.AssertBaseline(expected, forUpdate: true); - - private void AssertSql(params string[] expected) - => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); - - protected void ClearLog() - => Fixture.TestSqlLoggerFactory.Clear(); - - public class ComplexTypeBulkUpdatesSqlServerFixture : ComplexTypeBulkUpdatesRelationalFixtureBase - { - protected override ITestStoreFactory TestStoreFactory - => SqlServerTestStoreFactory.Instance; - } -} diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonBulkUpdateSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonBulkUpdateSqlServerTest.cs new file mode 100644 index 00000000000..c9cba11f000 --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonBulkUpdateSqlServerTest.cs @@ -0,0 +1,525 @@ +// 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.Query.Associations.ComplexJson; + +public class ComplexJsonBulkUpdateSqlServerTest( + ComplexJsonSqlServerFixture fixture, + ITestOutputHelper testOutputHelper) + : ComplexJsonBulkUpdateRelationalTestBase(fixture, testOutputHelper) +{ + #region Delete + + public override async Task Delete_entity_with_associations() + { + await base.Delete_entity_with_associations(); + + AssertSql( + """ +@deletableEntity_Name='?' (Size = 4000) + +DELETE FROM [r] +FROM [RootEntity] AS [r] +WHERE [r].[Name] = @deletableEntity_Name +"""); + } + + public override async Task Delete_required_association() + { + await base.Delete_required_association(); + + AssertSql(); + } + + public override async Task Delete_optional_association() + { + await base.Delete_optional_association(); + + AssertSql(); + } + + #endregion Delete + + #region Update properties + + public override async Task Update_property_inside_association() + { + await base.Update_property_inside_association(); + + if (Fixture.UsingJsonType) + { + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [RequiredRelated].modify('$.String', @p) +FROM [RootEntity] AS [r] +"""); + } + else + { + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [r].[RequiredRelated] = JSON_MODIFY([r].[RequiredRelated], '$.String', @p) +FROM [RootEntity] AS [r] +"""); + } + } + + public override async Task Update_property_inside_association_with_special_chars() + { + await base.Update_property_inside_association_with_special_chars(); + + if (Fixture.UsingJsonType) + { + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [RequiredRelated].modify('$.String', N'{ Some other/JSON:like text though it [isn''t]: ממש ממש לאéèéè }') +FROM [RootEntity] AS [r] +WHERE JSON_VALUE([r].[RequiredRelated], '$.String' RETURNING nvarchar(max)) = N'{ this may/look:like JSON but it [isn''t]: ממש ממש לאéèéè }' +"""); + } + else + { + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[RequiredRelated] = JSON_MODIFY([r].[RequiredRelated], '$.String', N'{ Some other/JSON:like text though it [isn''t]: ממש ממש לאéèéè }') +FROM [RootEntity] AS [r] +WHERE JSON_VALUE([r].[RequiredRelated], '$.String') = N'{ this may/look:like JSON but it [isn''t]: ממש ממש לאéèéè }' +"""); + } + } + + public override async Task Update_property_inside_nested() + { + await base.Update_property_inside_nested(); + + if (Fixture.UsingJsonType) + { + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [RequiredRelated].modify('$.RequiredNested.String', @p) +FROM [RootEntity] AS [r] +"""); + } + else + { + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [r].[RequiredRelated] = JSON_MODIFY([r].[RequiredRelated], '$.RequiredNested.String', @p) +FROM [RootEntity] AS [r] +"""); + } + } + + public override async Task Update_property_on_projected_association() + { + await base.Update_property_on_projected_association(); + + if (Fixture.UsingJsonType) + { + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [RequiredRelated].modify('$.String', @p) +FROM [RootEntity] AS [r] +"""); + } + else + { + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [r].[RequiredRelated] = JSON_MODIFY([r].[RequiredRelated], '$.String', @p) +FROM [RootEntity] AS [r] +"""); + } + } + + public override async Task Update_property_on_projected_association_with_OrderBy_Skip() + { + await base.Update_property_on_projected_association_with_OrderBy_Skip(); + + AssertExecuteUpdateSql(); + } + + #endregion Update properties + + #region Update association + + public override async Task Update_association_to_parameter() + { + await base.Update_association_to_parameter(); + + AssertExecuteUpdateSql( + """ +@complex_type_p='?' (Size = 201) + +UPDATE [r] +SET [r].[RequiredRelated] = @complex_type_p +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_nested_association_to_parameter() + { + await base.Update_nested_association_to_parameter(); + + if (Fixture.UsingJsonType) + { + AssertExecuteUpdateSql( + """ +@complex_type_p='?' (Size = 79) + +UPDATE [r] +SET [RequiredRelated].modify('$.RequiredNested', @complex_type_p) +FROM [RootEntity] AS [r] +"""); + } + else + { + AssertExecuteUpdateSql( + """ +@complex_type_p='?' (Size = 79) + +UPDATE [r] +SET [r].[RequiredRelated] = JSON_MODIFY([r].[RequiredRelated], '$.RequiredNested', JSON_QUERY(@complex_type_p)) +FROM [RootEntity] AS [r] +"""); + } + } + + public override async Task Update_association_to_another_association() + { + await base.Update_association_to_another_association(); + + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[OptionalRelated] = [r].[RequiredRelated] +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_nested_association_to_another_nested_association() + { + await base.Update_nested_association_to_another_nested_association(); + + if (Fixture.UsingJsonType) + { + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [RequiredRelated].modify('$.OptionalNested', JSON_QUERY([r].[RequiredRelated], '$.RequiredNested')) +FROM [RootEntity] AS [r] +"""); + } + else + { + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[RequiredRelated] = JSON_MODIFY([r].[RequiredRelated], '$.OptionalNested', JSON_QUERY([r].[RequiredRelated], '$.RequiredNested')) +FROM [RootEntity] AS [r] +"""); + } + } + + public override async Task Update_association_to_inline() + { + await base.Update_association_to_inline(); + + AssertExecuteUpdateSql( + """ +@complex_type_p='?' (Size = 222) + +UPDATE [r] +SET [r].[RequiredRelated] = @complex_type_p +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_association_to_inline_with_lambda() + { + await base.Update_association_to_inline_with_lambda(); + + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[RequiredRelated] = '{"Id":0,"Int":70,"Name":"Updated related name","String":"Updated related string","NestedCollection":[],"OptionalNested":null,"RequiredNested":{"Id":0,"Int":80,"Name":"Updated nested name","String":"Updated nested string"}}' +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_nested_association_to_inline_with_lambda() + { + await base.Update_nested_association_to_inline_with_lambda(); + + if (Fixture.UsingJsonType) + { + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [RequiredRelated].modify('$.RequiredNested', CAST('{"Id":0,"Int":80,"Name":"Updated nested name","String":"Updated nested string"}' AS json)) +FROM [RootEntity] AS [r] +"""); + } + else + { + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[RequiredRelated] = JSON_MODIFY([r].[RequiredRelated], '$.RequiredNested', JSON_QUERY('{"Id":0,"Int":80,"Name":"Updated nested name","String":"Updated nested string"}')) +FROM [RootEntity] AS [r] +"""); + } + } + + public override async Task Update_association_to_null() + { + await base.Update_association_to_null(); + + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[OptionalRelated] = NULL +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_association_to_null_with_lambda() + { + await base.Update_association_to_null_with_lambda(); + + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[OptionalRelated] = NULL +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_association_to_null_parameter() + { + await base.Update_association_to_null_parameter(); + + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[OptionalRelated] = NULL +FROM [RootEntity] AS [r] +"""); + } + + #endregion Update association + + #region Update collection + + public override async Task Update_collection_to_parameter() + { + await base.Update_collection_to_parameter(); + + AssertExecuteUpdateSql( + """ +@complex_type_p='?' (Size = 411) + +UPDATE [r] +SET [r].[RelatedCollection] = @complex_type_p +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_nested_collection_to_parameter() + { + await base.Update_nested_collection_to_parameter(); + + if (Fixture.UsingJsonType) + { + AssertExecuteUpdateSql( + """ +@complex_type_p='?' (Size = 165) + +UPDATE [r] +SET [RequiredRelated].modify('$.NestedCollection', @complex_type_p) +FROM [RootEntity] AS [r] +"""); + } + else + { + AssertExecuteUpdateSql( + """ +@complex_type_p='?' (Size = 165) + +UPDATE [r] +SET [r].[RequiredRelated] = JSON_MODIFY([r].[RequiredRelated], '$.NestedCollection', JSON_QUERY(@complex_type_p)) +FROM [RootEntity] AS [r] +"""); + } + } + + public override async Task Update_nested_collection_to_inline_with_lambda() + { + await base.Update_nested_collection_to_inline_with_lambda(); + + if (Fixture.UsingJsonType) + { + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [RequiredRelated].modify('$.NestedCollection', CAST('[{"Id":0,"Int":80,"Name":"Updated nested name1","String":"Updated nested string1"},{"Id":0,"Int":81,"Name":"Updated nested name2","String":"Updated nested string2"}]' AS json)) +FROM [RootEntity] AS [r] +"""); + } + else + { + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[RequiredRelated] = JSON_MODIFY([r].[RequiredRelated], '$.NestedCollection', JSON_QUERY('[{"Id":0,"Int":80,"Name":"Updated nested name1","String":"Updated nested string1"},{"Id":0,"Int":81,"Name":"Updated nested name2","String":"Updated nested string2"}]')) +FROM [RootEntity] AS [r] +"""); + } + } + + public override async Task Update_nested_collection_to_another_nested_collection() + { + await base.Update_nested_collection_to_another_nested_collection(); + + if (Fixture.UsingJsonType) + { + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [RequiredRelated].modify('$.NestedCollection', JSON_QUERY([r].[OptionalRelated], '$.NestedCollection')) +FROM [RootEntity] AS [r] +WHERE [r].[OptionalRelated] IS NOT NULL +"""); + } + else + { + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[RequiredRelated] = JSON_MODIFY([r].[RequiredRelated], '$.NestedCollection', JSON_QUERY([r].[OptionalRelated], '$.NestedCollection')) +FROM [RootEntity] AS [r] +WHERE [r].[OptionalRelated] IS NOT NULL +"""); + } + } + + public override async Task Update_collection_referencing_the_original_collection() + { + await base.Update_collection_referencing_the_original_collection(); + + AssertExecuteUpdateSql(); + } + + #endregion Update collection + + #region Multiple updates + + public override async Task Update_multiple_properties_inside_same_association() + { + await base.Update_multiple_properties_inside_same_association(); + + // Note that since two properties within the same JSON column are updated, SQL Server 2025 modify + // is not used (it only supports modifying a single property) + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) +@p0='?' (DbType = Int32) + +UPDATE [r] +SET [r].[RequiredRelated] = JSON_MODIFY(JSON_MODIFY([r].[RequiredRelated], '$.String', @p), '$.Int', @p0) +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_multiple_properties_inside_associations_and_on_entity_type() + { + await base.Update_multiple_properties_inside_associations_and_on_entity_type(); + + if (Fixture.UsingJsonType) + { + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [r].[Name] = [r].[Name] + N'Modified', + [RequiredRelated].modify('$.String', JSON_VALUE([r].[OptionalRelated], '$.String' RETURNING nvarchar(max))), + [OptionalRelated].modify('$.RequiredNested.String', @p) +FROM [RootEntity] AS [r] +WHERE [r].[OptionalRelated] IS NOT NULL +"""); + } + else + { + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [r].[Name] = [r].[Name] + N'Modified', + [r].[RequiredRelated] = JSON_MODIFY([r].[RequiredRelated], '$.String', JSON_VALUE([r].[OptionalRelated], '$.String')), + [r].[OptionalRelated] = JSON_MODIFY([r].[OptionalRelated], '$.RequiredNested.String', @p) +FROM [RootEntity] AS [r] +WHERE [r].[OptionalRelated] IS NOT NULL +"""); + } + } + + public override async Task Update_multiple_projected_associations_via_anonymous_type() + { + await base.Update_multiple_projected_associations_via_anonymous_type(); + + if (Fixture.UsingJsonType) + { + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [RequiredRelated].modify('$.String', JSON_VALUE([r].[OptionalRelated], '$.String' RETURNING nvarchar(max))), + [OptionalRelated].modify('$.String', @p) +FROM [RootEntity] AS [r] +WHERE [r].[OptionalRelated] IS NOT NULL +"""); + } + else + { + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [r].[RequiredRelated] = JSON_MODIFY([r].[RequiredRelated], '$.String', JSON_VALUE([r].[OptionalRelated], '$.String')), + [r].[OptionalRelated] = JSON_MODIFY([r].[OptionalRelated], '$.String', @p) +FROM [RootEntity] AS [r] +WHERE [r].[OptionalRelated] IS NOT NULL +"""); + } + } + + #endregion Multiple updates + + [ConditionalFact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); +} diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingBulkUpdateSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingBulkUpdateSqlServerTest.cs new file mode 100644 index 00000000000..d322ed10625 --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingBulkUpdateSqlServerTest.cs @@ -0,0 +1,429 @@ +// 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.Query.Associations.ComplexTableSplitting; + +public class ComplexTableSplittingBulkUpdateSqlServerTest( + ComplexTableSplittingSqlServerFixture fixture, + ITestOutputHelper testOutputHelper) + : ComplexTableSplittingBulkUpdateRelationalTestBase(fixture, testOutputHelper) +{ + #region Delete + + public override async Task Delete_entity_with_associations() + { + await base.Delete_entity_with_associations(); + + AssertSql( + """ +@deletableEntity_Name='?' (Size = 4000) + +DELETE FROM [r] +FROM [RootEntity] AS [r] +WHERE [r].[Name] = @deletableEntity_Name +"""); + } + + public override async Task Delete_required_association() + { + await base.Delete_required_association(); + + AssertSql(); + } + + public override async Task Delete_optional_association() + { + await base.Delete_optional_association(); + + AssertSql(); + } + + #endregion Delete + + #region Update properties + + public override async Task Update_property_inside_association() + { + await base.Update_property_inside_association(); + + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [r].[RequiredRelated_String] = @p +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_property_inside_association_with_special_chars() + { + await base.Update_property_inside_association_with_special_chars(); + + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[RequiredRelated_String] = N'{ Some other/JSON:like text though it [isn''t]: ממש ממש לאéèéè }' +FROM [RootEntity] AS [r] +WHERE [r].[RequiredRelated_String] = N'{ this may/look:like JSON but it [isn''t]: ממש ממש לאéèéè }' +"""); + } + + public override async Task Update_property_inside_nested() + { + await base.Update_property_inside_nested(); + + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [r].[RequiredRelated_RequiredNested_String] = @p +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_property_on_projected_association() + { + await base.Update_property_on_projected_association(); + + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [r].[RequiredRelated_String] = @p +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_property_on_projected_association_with_OrderBy_Skip() + { + await base.Update_property_on_projected_association_with_OrderBy_Skip(); + + AssertExecuteUpdateSql(); + } + + #endregion Update properties + + #region Update association + + public override async Task Update_association_to_parameter() + { + await base.Update_association_to_parameter(); + + AssertExecuteUpdateSql( + """ +@complex_type_p_Id='?' (DbType = Int32) +@complex_type_p_Int='?' (DbType = Int32) +@complex_type_p_Name='?' (Size = 4000) +@complex_type_p_String='?' (Size = 4000) + +UPDATE [r] +SET [r].[RequiredRelated_Id] = @complex_type_p_Id, + [r].[RequiredRelated_Int] = @complex_type_p_Int, + [r].[RequiredRelated_Name] = @complex_type_p_Name, + [r].[RequiredRelated_String] = @complex_type_p_String, + [r].[RequiredRelated_OptionalNested_Id] = @complex_type_p_Id, + [r].[RequiredRelated_OptionalNested_Int] = @complex_type_p_Int, + [r].[RequiredRelated_OptionalNested_Name] = @complex_type_p_Name, + [r].[RequiredRelated_OptionalNested_String] = @complex_type_p_String, + [r].[RequiredRelated_RequiredNested_Id] = @complex_type_p_Id, + [r].[RequiredRelated_RequiredNested_Int] = @complex_type_p_Int, + [r].[RequiredRelated_RequiredNested_Name] = @complex_type_p_Name, + [r].[RequiredRelated_RequiredNested_String] = @complex_type_p_String +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_nested_association_to_parameter() + { + await base.Update_nested_association_to_parameter(); + + AssertExecuteUpdateSql( + """ +@complex_type_p_Id='?' (DbType = Int32) +@complex_type_p_Int='?' (DbType = Int32) +@complex_type_p_Name='?' (Size = 4000) +@complex_type_p_String='?' (Size = 4000) + +UPDATE [r] +SET [r].[RequiredRelated_RequiredNested_Id] = @complex_type_p_Id, + [r].[RequiredRelated_RequiredNested_Int] = @complex_type_p_Int, + [r].[RequiredRelated_RequiredNested_Name] = @complex_type_p_Name, + [r].[RequiredRelated_RequiredNested_String] = @complex_type_p_String +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_association_to_another_association() + { + await base.Update_association_to_another_association(); + + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[OptionalRelated_Id] = [r].[RequiredRelated_Id], + [r].[OptionalRelated_Int] = [r].[RequiredRelated_Int], + [r].[OptionalRelated_Name] = [r].[RequiredRelated_Name], + [r].[OptionalRelated_String] = [r].[RequiredRelated_String], + [r].[OptionalRelated_OptionalNested_Id] = [r].[OptionalRelated_OptionalNested_Id], + [r].[OptionalRelated_OptionalNested_Int] = [r].[OptionalRelated_OptionalNested_Int], + [r].[OptionalRelated_OptionalNested_Name] = [r].[OptionalRelated_OptionalNested_Name], + [r].[OptionalRelated_OptionalNested_String] = [r].[OptionalRelated_OptionalNested_String], + [r].[OptionalRelated_RequiredNested_Id] = [r].[OptionalRelated_RequiredNested_Id], + [r].[OptionalRelated_RequiredNested_Int] = [r].[OptionalRelated_RequiredNested_Int], + [r].[OptionalRelated_RequiredNested_Name] = [r].[OptionalRelated_RequiredNested_Name], + [r].[OptionalRelated_RequiredNested_String] = [r].[OptionalRelated_RequiredNested_String] +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_nested_association_to_another_nested_association() + { + await base.Update_nested_association_to_another_nested_association(); + + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[RequiredRelated_OptionalNested_Id] = [r].[RequiredRelated_RequiredNested_Id], + [r].[RequiredRelated_OptionalNested_Int] = [r].[RequiredRelated_RequiredNested_Int], + [r].[RequiredRelated_OptionalNested_Name] = [r].[RequiredRelated_RequiredNested_Name], + [r].[RequiredRelated_OptionalNested_String] = [r].[RequiredRelated_RequiredNested_String] +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_association_to_inline() + { + await base.Update_association_to_inline(); + + AssertExecuteUpdateSql( + """ +@complex_type_p_Id='?' (DbType = Int32) +@complex_type_p_Int='?' (DbType = Int32) +@complex_type_p_Name='?' (Size = 4000) +@complex_type_p_String='?' (Size = 4000) + +UPDATE [r] +SET [r].[RequiredRelated_Id] = @complex_type_p_Id, + [r].[RequiredRelated_Int] = @complex_type_p_Int, + [r].[RequiredRelated_Name] = @complex_type_p_Name, + [r].[RequiredRelated_String] = @complex_type_p_String, + [r].[RequiredRelated_OptionalNested_Id] = @complex_type_p_Id, + [r].[RequiredRelated_OptionalNested_Int] = @complex_type_p_Int, + [r].[RequiredRelated_OptionalNested_Name] = @complex_type_p_Name, + [r].[RequiredRelated_OptionalNested_String] = @complex_type_p_String, + [r].[RequiredRelated_RequiredNested_Id] = @complex_type_p_Id, + [r].[RequiredRelated_RequiredNested_Int] = @complex_type_p_Int, + [r].[RequiredRelated_RequiredNested_Name] = @complex_type_p_Name, + [r].[RequiredRelated_RequiredNested_String] = @complex_type_p_String +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_association_to_inline_with_lambda() + { + await base.Update_association_to_inline_with_lambda(); + + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[RequiredRelated_Id] = 0, + [r].[RequiredRelated_Int] = 70, + [r].[RequiredRelated_Name] = N'Updated related name', + [r].[RequiredRelated_String] = N'Updated related string', + [r].[RequiredRelated_OptionalNested_Id] = NULL, + [r].[RequiredRelated_OptionalNested_Int] = NULL, + [r].[RequiredRelated_OptionalNested_Name] = NULL, + [r].[RequiredRelated_OptionalNested_String] = NULL, + [r].[RequiredRelated_RequiredNested_Id] = 0, + [r].[RequiredRelated_RequiredNested_Int] = 80, + [r].[RequiredRelated_RequiredNested_Name] = N'Updated nested name', + [r].[RequiredRelated_RequiredNested_String] = N'Updated nested string' +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_nested_association_to_inline_with_lambda() + { + await base.Update_nested_association_to_inline_with_lambda(); + + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[RequiredRelated_RequiredNested_Id] = 0, + [r].[RequiredRelated_RequiredNested_Int] = 80, + [r].[RequiredRelated_RequiredNested_Name] = N'Updated nested name', + [r].[RequiredRelated_RequiredNested_String] = N'Updated nested string' +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_association_to_null() + { + await base.Update_association_to_null(); + + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[OptionalRelated_Id] = NULL, + [r].[OptionalRelated_Int] = NULL, + [r].[OptionalRelated_Name] = NULL, + [r].[OptionalRelated_String] = NULL, + [r].[OptionalRelated_OptionalNested_Id] = NULL, + [r].[OptionalRelated_OptionalNested_Int] = NULL, + [r].[OptionalRelated_OptionalNested_Name] = NULL, + [r].[OptionalRelated_OptionalNested_String] = NULL, + [r].[OptionalRelated_RequiredNested_Id] = NULL, + [r].[OptionalRelated_RequiredNested_Int] = NULL, + [r].[OptionalRelated_RequiredNested_Name] = NULL, + [r].[OptionalRelated_RequiredNested_String] = NULL +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_association_to_null_with_lambda() + { + await base.Update_association_to_null_with_lambda(); + + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[OptionalRelated_Id] = NULL, + [r].[OptionalRelated_Int] = NULL, + [r].[OptionalRelated_Name] = NULL, + [r].[OptionalRelated_String] = NULL, + [r].[OptionalRelated_OptionalNested_Id] = NULL, + [r].[OptionalRelated_OptionalNested_Int] = NULL, + [r].[OptionalRelated_OptionalNested_Name] = NULL, + [r].[OptionalRelated_OptionalNested_String] = NULL, + [r].[OptionalRelated_RequiredNested_Id] = NULL, + [r].[OptionalRelated_RequiredNested_Int] = NULL, + [r].[OptionalRelated_RequiredNested_Name] = NULL, + [r].[OptionalRelated_RequiredNested_String] = NULL +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_association_to_null_parameter() + { + await base.Update_association_to_null_parameter(); + + AssertExecuteUpdateSql( + """ +UPDATE [r] +SET [r].[OptionalRelated_Id] = NULL, + [r].[OptionalRelated_Int] = NULL, + [r].[OptionalRelated_Name] = NULL, + [r].[OptionalRelated_String] = NULL, + [r].[OptionalRelated_OptionalNested_Id] = NULL, + [r].[OptionalRelated_OptionalNested_Int] = NULL, + [r].[OptionalRelated_OptionalNested_Name] = NULL, + [r].[OptionalRelated_OptionalNested_String] = NULL, + [r].[OptionalRelated_RequiredNested_Id] = NULL, + [r].[OptionalRelated_RequiredNested_Int] = NULL, + [r].[OptionalRelated_RequiredNested_Name] = NULL, + [r].[OptionalRelated_RequiredNested_String] = NULL +FROM [RootEntity] AS [r] +"""); + } + + #endregion Update association + + #region Update collection + + public override async Task Update_collection_to_parameter() + { + await base.Update_collection_to_parameter(); + + AssertExecuteUpdateSql(); + } + + public override async Task Update_nested_collection_to_parameter() + { + await base.Update_nested_collection_to_parameter(); + + AssertExecuteUpdateSql(); + } + + public override async Task Update_nested_collection_to_inline_with_lambda() + { + await base.Update_nested_collection_to_inline_with_lambda(); + + AssertExecuteUpdateSql(); + } + + public override async Task Update_collection_referencing_the_original_collection() + { + await base.Update_collection_referencing_the_original_collection(); + + AssertExecuteUpdateSql(); + } + + public override async Task Update_nested_collection_to_another_nested_collection() + { + await base.Update_nested_collection_to_another_nested_collection(); + + AssertExecuteUpdateSql(); + } + + #endregion Update collection + + #region Multiple updates + + public override async Task Update_multiple_properties_inside_same_association() + { + await base.Update_multiple_properties_inside_same_association(); + + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) +@p0='?' (DbType = Int32) + +UPDATE [r] +SET [r].[RequiredRelated_String] = @p, + [r].[RequiredRelated_Int] = @p0 +FROM [RootEntity] AS [r] +"""); + } + + public override async Task Update_multiple_properties_inside_associations_and_on_entity_type() + { + await base.Update_multiple_properties_inside_associations_and_on_entity_type(); + + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [r].[Name] = [r].[Name] + N'Modified', + [r].[RequiredRelated_String] = [r].[OptionalRelated_String], + [r].[OptionalRelated_RequiredNested_String] = @p +FROM [RootEntity] AS [r] +WHERE [r].[OptionalRelated_Id] IS NOT NULL +"""); + } + + public override async Task Update_multiple_projected_associations_via_anonymous_type() + { + await base.Update_multiple_projected_associations_via_anonymous_type(); + + AssertExecuteUpdateSql( + """ +@p='?' (Size = 4000) + +UPDATE [r] +SET [r].[RequiredRelated_String] = [r].[OptionalRelated_String], + [r].[OptionalRelated_String] = @p +FROM [RootEntity] AS [r] +WHERE [r].[OptionalRelated_Id] IS NOT NULL +"""); + } + + #endregion Multiple updates + + [ConditionalFact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); +} diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonBulkUpdateSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonBulkUpdateSqlServerTest.cs new file mode 100644 index 00000000000..139f51f632f --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonBulkUpdateSqlServerTest.cs @@ -0,0 +1,9 @@ +// 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.Query.Associations.OwnedJson; + +public class OwnedJsonBulkUpdateSqlServerTest( + OwnedJsonSqlServerFixture fixture, + ITestOutputHelper testOutputHelper) + : OwnedJsonBulkUpdateRelationalTestBase(fixture, testOutputHelper); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonCollectionSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonCollectionSqlServerTest.cs index be463dc19ca..10e3b2058dc 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonCollectionSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonCollectionSqlServerTest.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.Data.SqlClient; + namespace Microsoft.EntityFrameworkCore.Query.Associations.OwnedJson; public class OwnedJsonCollectionSqlServerTest(OwnedJsonSqlServerFixture fixture, ITestOutputHelper testOutputHelper) @@ -65,10 +67,37 @@ ORDER BY [r0].[Id] public override async Task Distinct() { - await base.Distinct(); + if (Fixture.UsingJsonType) + { + // The json data type cannot be selected as DISTINCT because it is not comparable. + await Assert.ThrowsAsync(base.Distinct); - AssertSql( - """ + AssertSql( + """ +SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] +FROM [RootEntity] AS [r] +WHERE ( + SELECT COUNT(*) + FROM ( + SELECT DISTINCT [r].[Id], [r0].[Id] AS [Id0], [r0].[Int], [r0].[Name], [r0].[String], [r0].[NestedCollection] AS [c], [r0].[OptionalNested] AS [c0], [r0].[RequiredNested] AS [c1] + FROM OPENJSON([r].[RelatedCollection], '$') WITH ( + [Id] int '$.Id', + [Int] int '$.Int', + [Name] nvarchar(max) '$.Name', + [String] nvarchar(max) '$.String', + [NestedCollection] json '$.NestedCollection' AS JSON, + [OptionalNested] json '$.OptionalNested' AS JSON, + [RequiredNested] json '$.RequiredNested' AS JSON + ) AS [r0] + ) AS [r1]) = 2 +"""); + } + else + { + await base.Distinct(); + + AssertSql( + """ SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] FROM [RootEntity] AS [r] WHERE ( @@ -86,14 +115,43 @@ [RequiredNested] nvarchar(max) '$.RequiredNested' AS JSON ) AS [r0] ) AS [r1]) = 2 """); + } } public override async Task Distinct_projected(QueryTrackingBehavior queryTrackingBehavior) { - await base.Distinct_projected(queryTrackingBehavior); + if (queryTrackingBehavior is QueryTrackingBehavior.TrackAll) + { + await base.Distinct_projected(queryTrackingBehavior); + } + else if (Fixture.UsingJsonType) + { + // The json data type cannot be selected as DISTINCT because it is not comparable. + await Assert.ThrowsAsync(() => base.Distinct_projected(queryTrackingBehavior)); - if (queryTrackingBehavior is not QueryTrackingBehavior.TrackAll) + AssertSql( + """ +SELECT [r].[Id], [r1].[Id], [r1].[Id0], [r1].[Int], [r1].[Name], [r1].[String], [r1].[c], [r1].[c0], [r1].[c1] +FROM [RootEntity] AS [r] +OUTER APPLY ( + SELECT DISTINCT [r].[Id], [r0].[Id] AS [Id0], [r0].[Int], [r0].[Name], [r0].[String], [r0].[NestedCollection] AS [c], [r0].[OptionalNested] AS [c0], [r0].[RequiredNested] AS [c1] + FROM OPENJSON([r].[RelatedCollection], '$') WITH ( + [Id] int '$.Id', + [Int] int '$.Int', + [Name] nvarchar(max) '$.Name', + [String] nvarchar(max) '$.String', + [NestedCollection] json '$.NestedCollection' AS JSON, + [OptionalNested] json '$.OptionalNested' AS JSON, + [RequiredNested] json '$.RequiredNested' AS JSON + ) AS [r0] +) AS [r1] +ORDER BY [r].[Id], [r1].[Id0], [r1].[Int], [r1].[Name] +"""); + } + else { + await base.Distinct_projected(queryTrackingBehavior); + AssertSql( """ SELECT [r].[Id], [r1].[Id], [r1].[Id0], [r1].[Int], [r1].[Name], [r1].[String], [r1].[c], [r1].[c0], [r1].[c1] @@ -137,50 +195,101 @@ public override async Task Index_constant() { await base.Index_constant(); - AssertSql( - """ + if (Fixture.UsingJsonType) + { + AssertSql( + """ +SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] +FROM [RootEntity] AS [r] +WHERE JSON_VALUE([r].[RelatedCollection], '$[0].Int' RETURNING int) = 8 +"""); + } + else + { + AssertSql( + """ SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] FROM [RootEntity] AS [r] WHERE CAST(JSON_VALUE([r].[RelatedCollection], '$[0].Int') AS int) = 8 """); + } } + public override async Task Index_parameter() { await base.Index_parameter(); - AssertSql( - """ + if (Fixture.UsingJsonType) + { + AssertSql( + """ +@i='0' + +SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] +FROM [RootEntity] AS [r] +WHERE JSON_VALUE([r].[RelatedCollection], '$[' + CAST(@i AS nvarchar(max)) + '].Int' RETURNING int) = 8 +"""); + } + else + { + AssertSql( + """ @i='0' SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] FROM [RootEntity] AS [r] WHERE CAST(JSON_VALUE([r].[RelatedCollection], '$[' + CAST(@i AS nvarchar(max)) + '].Int') AS int) = 8 """); + } } public override async Task Index_column() { await base.Index_column(); - AssertSql( - """ + if (Fixture.UsingJsonType) + { + AssertSql( + """ +SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] +FROM [RootEntity] AS [r] +WHERE JSON_VALUE([r].[RelatedCollection], '$[' + CAST([r].[Id] - 1 AS nvarchar(max)) + '].Int' RETURNING int) = 8 +"""); + } + else + { + AssertSql( + """ SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] FROM [RootEntity] AS [r] WHERE CAST(JSON_VALUE([r].[RelatedCollection], '$[' + CAST([r].[Id] - 1 AS nvarchar(max)) + '].Int') AS int) = 8 """); + } } public override async Task Index_out_of_bounds() { await base.Index_out_of_bounds(); - AssertSql( - """ + if (Fixture.UsingJsonType) + { + AssertSql( + """ +SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] +FROM [RootEntity] AS [r] +WHERE JSON_VALUE([r].[RelatedCollection], '$[9999].Int' RETURNING int) = 8 +"""); + } + else + { + AssertSql( + """ SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] FROM [RootEntity] AS [r] WHERE CAST(JSON_VALUE([r].[RelatedCollection], '$[9999].Int') AS int) = 8 """); + } } #endregion Index @@ -218,8 +327,29 @@ public override async Task Select_within_Select_within_Select_with_aggregates() { await base.Select_within_Select_within_Select_with_aggregates(); - AssertSql( - """ + if (Fixture.UsingJsonType) + { + AssertSql( + """ +SELECT ( + SELECT COALESCE(SUM([s].[value]), 0) + FROM OPENJSON([r].[RelatedCollection], '$') WITH ([NestedCollection] json '$.NestedCollection' AS JSON) AS [r0] + OUTER APPLY ( + SELECT MAX([n].[Int]) AS [value] + FROM OPENJSON([r0].[NestedCollection], '$') WITH ( + [Id] int '$.Id', + [Int] int '$.Int', + [Name] nvarchar(max) '$.Name', + [String] nvarchar(max) '$.String' + ) AS [n] + ) AS [s]) +FROM [RootEntity] AS [r] +"""); + } + else + { + AssertSql( + """ SELECT ( SELECT COALESCE(SUM([s].[value]), 0) FROM OPENJSON([r].[RelatedCollection], '$') WITH ([NestedCollection] nvarchar(max) '$.NestedCollection' AS JSON) AS [r0] @@ -234,6 +364,7 @@ [String] nvarchar(max) '$.String' ) AS [s]) FROM [RootEntity] AS [r] """); + } } [ConditionalFact] diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonMiscellaneousSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonMiscellaneousSqlServerTest.cs index cb6a2e58a01..4094ed1adc7 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonMiscellaneousSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonMiscellaneousSqlServerTest.cs @@ -14,36 +14,72 @@ public override async Task Where_related_property() { await base.Where_related_property(); - AssertSql( - """ + if (Fixture.UsingJsonType) + { + AssertSql( + """ +SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] +FROM [RootEntity] AS [r] +WHERE JSON_VALUE([r].[RequiredRelated], '$.Int' RETURNING int) = 8 +"""); + } + else + { + AssertSql( + """ SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] FROM [RootEntity] AS [r] WHERE CAST(JSON_VALUE([r].[RequiredRelated], '$.Int') AS int) = 8 """); + } } public override async Task Where_optional_related_property() { await base.Where_optional_related_property(); - AssertSql( - """ + if (Fixture.UsingJsonType) + { + AssertSql( + """ +SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] +FROM [RootEntity] AS [r] +WHERE JSON_VALUE([r].[OptionalRelated], '$.Int' RETURNING int) = 8 +"""); + } + else + { + AssertSql( + """ SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] FROM [RootEntity] AS [r] WHERE CAST(JSON_VALUE([r].[OptionalRelated], '$.Int') AS int) = 8 """); + } } public override async Task Where_nested_related_property() { await base.Where_nested_related_property(); - AssertSql( - """ + if (Fixture.UsingJsonType) + { + AssertSql( + """ +SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] +FROM [RootEntity] AS [r] +WHERE JSON_VALUE([r].[RequiredRelated], '$.RequiredNested.Int' RETURNING int) = 8 +"""); + } + else + { + AssertSql( + """ SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated] FROM [RootEntity] AS [r] WHERE CAST(JSON_VALUE([r].[RequiredRelated], '$.RequiredNested.Int') AS int) = 8 """); + } } #endregion Simple filters diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonProjectionSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonProjectionSqlServerTest.cs index 40778b02a03..7ae7dd78717 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonProjectionSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonProjectionSqlServerTest.cs @@ -23,44 +23,88 @@ public override async Task Select_property_on_required_related(QueryTrackingBeha { await base.Select_property_on_required_related(queryTrackingBehavior); - AssertSql( - """ + if (Fixture.UsingJsonType) + { + AssertSql( + """ +SELECT JSON_VALUE([r].[RequiredRelated], '$.String' RETURNING nvarchar(max)) +FROM [RootEntity] AS [r] +"""); + } + else + { + AssertSql( + """ SELECT JSON_VALUE([r].[RequiredRelated], '$.String') FROM [RootEntity] AS [r] """); + } } public override async Task Select_property_on_optional_related(QueryTrackingBehavior queryTrackingBehavior) { await base.Select_property_on_optional_related(queryTrackingBehavior); - AssertSql( - """ + if (Fixture.UsingJsonType) + { + AssertSql( + """ +SELECT JSON_VALUE([r].[OptionalRelated], '$.String' RETURNING nvarchar(max)) +FROM [RootEntity] AS [r] +"""); + } + else + { + AssertSql( + """ SELECT JSON_VALUE([r].[OptionalRelated], '$.String') FROM [RootEntity] AS [r] """); + } } public override async Task Select_value_type_property_on_null_related_throws(QueryTrackingBehavior queryTrackingBehavior) { await base.Select_value_type_property_on_null_related_throws(queryTrackingBehavior); - AssertSql( - """ + if (Fixture.UsingJsonType) + { + AssertSql( + """ +SELECT JSON_VALUE([r].[OptionalRelated], '$.Int' RETURNING int) +FROM [RootEntity] AS [r] +"""); + } + else + { + AssertSql( + """ SELECT CAST(JSON_VALUE([r].[OptionalRelated], '$.Int') AS int) FROM [RootEntity] AS [r] """); + } } public override async Task Select_nullable_value_type_property_on_null_related(QueryTrackingBehavior queryTrackingBehavior) { await base.Select_nullable_value_type_property_on_null_related(queryTrackingBehavior); - AssertSql( - """ + if (Fixture.UsingJsonType) + { + AssertSql( + """ +SELECT JSON_VALUE([r].[OptionalRelated], '$.Int' RETURNING int) +FROM [RootEntity] AS [r] +"""); + } + else + { + AssertSql( + """ SELECT CAST(JSON_VALUE([r].[OptionalRelated], '$.Int') AS int) FROM [RootEntity] AS [r] """); + } } #endregion Simple properties @@ -221,8 +265,27 @@ public override async Task SelectMany_related_collection(QueryTrackingBehavior q if (queryTrackingBehavior is not QueryTrackingBehavior.TrackAll) { - AssertSql( - """ + if (Fixture.UsingJsonType) + { + AssertSql( + """ +SELECT [r].[Id], [r0].[Id], [r0].[Int], [r0].[Name], [r0].[String], [r0].[NestedCollection], [r0].[OptionalNested], [r0].[RequiredNested] +FROM [RootEntity] AS [r] +CROSS APPLY OPENJSON([r].[RelatedCollection], '$') WITH ( + [Id] int '$.Id', + [Int] int '$.Int', + [Name] nvarchar(max) '$.Name', + [String] nvarchar(max) '$.String', + [NestedCollection] json '$.NestedCollection' AS JSON, + [OptionalNested] json '$.OptionalNested' AS JSON, + [RequiredNested] json '$.RequiredNested' AS JSON +) AS [r0] +"""); + } + else + { + AssertSql( + """ SELECT [r].[Id], [r0].[Id], [r0].[Int], [r0].[Name], [r0].[String], [r0].[NestedCollection], [r0].[OptionalNested], [r0].[RequiredNested] FROM [RootEntity] AS [r] CROSS APPLY OPENJSON([r].[RelatedCollection], '$') WITH ( @@ -235,6 +298,7 @@ [OptionalNested] nvarchar(max) '$.OptionalNested' AS JSON, [RequiredNested] nvarchar(max) '$.RequiredNested' AS JSON ) AS [r0] """); + } } } diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonSqlServerFixture.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonSqlServerFixture.cs index 79a434d84df..493fbee30a9 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonSqlServerFixture.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonSqlServerFixture.cs @@ -7,4 +7,16 @@ public class OwnedJsonSqlServerFixture : OwnedJsonRelationalFixtureBase { protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + + // When testing against SQL Server 2025 or later, set the compatibility level to 170 to use the json type instead of nvarchar(max). + public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder) + { + var options = base.AddOptions(builder); + return TestEnvironment.SqlServerMajorVersion < 17 + ? options + : options.UseSqlServerCompatibilityLevel(170); + } + + public virtual bool UsingJsonType + => TestEnvironment.SqlServerMajorVersion >= 17; } diff --git a/test/EFCore.SqlServer.FunctionalTests/Types/SqlServerMiscellaneousTypeTest.cs b/test/EFCore.SqlServer.FunctionalTests/Types/SqlServerMiscellaneousTypeTest.cs new file mode 100644 index 00000000000..3127839e0b2 --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Types/SqlServerMiscellaneousTypeTest.cs @@ -0,0 +1,58 @@ +// 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) + : RelationalTypeTestBase(fixture) +{ + public class BoolTypeFixture() : RelationalTypeTestFixture(true, false) + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + } +} + +public class StringTypeTest(StringTypeTest.StringTypeFixture fixture) + : RelationalTypeTestBase(fixture) +{ + public class StringTypeFixture() : RelationalTypeTestFixture("foo", "bar") + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + } +} + +public class GuidTypeTest(GuidTypeTest.GuidTypeFixture fixture) + : RelationalTypeTestBase(fixture) +{ + public override async Task ExecuteUpdate_within_json_to_nonjson_column() + { + // See #36688 for supporting this for SQL Server types other than string/numeric/bool + var exception = await Assert.ThrowsAsync(() => base.ExecuteUpdate_within_json_to_nonjson_column()); + Assert.Equal(RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn, exception.Message); + } + + public class GuidTypeFixture() : RelationalTypeTestFixture( + new Guid("8f7331d6-cde9-44fb-8611-81fff686f280"), + new Guid("ae192c36-9004-49b2-b785-8be10d169627")) + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + } +} + +public class ByteArrayTypeTest(ByteArrayTypeTest.ByteArrayTypeFixture fixture) + : RelationalTypeTestBase(fixture) +{ + public override async Task ExecuteUpdate_within_json_to_nonjson_column() + { + // See #36688 for supporting this for SQL Server types other than string/numeric/bool + var exception = await Assert.ThrowsAsync(() => base.ExecuteUpdate_within_json_to_nonjson_column()); + Assert.Equal(RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn, exception.Message); + } + + public class ByteArrayTypeFixture() : RelationalTypeTestFixture([1, 2, 3], [4, 5, 6]) + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + + public override Func Comparer { get; } = (a, b) => a.SequenceEqual(b); + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/Types/SqlServerNumericTypeTest.cs b/test/EFCore.SqlServer.FunctionalTests/Types/SqlServerNumericTypeTest.cs new file mode 100644 index 00000000000..62e0be9ddbc --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Types/SqlServerNumericTypeTest.cs @@ -0,0 +1,64 @@ +// 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) : RelationalTypeTestBase(fixture) +{ + public class ByteTypeFixture() : RelationalTypeTestFixture(byte.MinValue, byte.MaxValue) + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + } +} + +public class ShortTypeTest(ShortTypeTest.ShortTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public class ShortTypeFixture() : RelationalTypeTestFixture(short.MinValue, short.MaxValue) + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + } +} + +public class IntTypeTest(IntTypeTest.IntTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public class IntTypeFixture() : RelationalTypeTestFixture(int.MinValue, int.MaxValue) + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + } +} + +public class LongTypeTest(LongTypeTest.LongTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public class LongTypeFixture() : RelationalTypeTestFixture(long.MinValue, long.MaxValue) + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + } +} + +public class DecimalTypeTest(DecimalTypeTest.DecimalTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public class DecimalTypeFixture() : RelationalTypeTestFixture(30.5m, 30m) + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + + public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder) + => base.AddOptions(builder) + .ConfigureWarnings(c => c.Log(SqlServerEventId.DecimalTypeDefaultWarning)); + } +} + +public class DoubleTypeTest(DoubleTypeTest.DoubleTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public class DoubleTypeFixture() : RelationalTypeTestFixture(30.5d, 30d) + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + } +} + +public class FloatTypeTest(FloatTypeTest.FloatTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public class FloatTypeFixture() : RelationalTypeTestFixture(30.5f, 30f) + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/Types/SqlServerTemporalTypeTest.cs b/test/EFCore.SqlServer.FunctionalTests/Types/SqlServerTemporalTypeTest.cs new file mode 100644 index 00000000000..8face594837 --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Types/SqlServerTemporalTypeTest.cs @@ -0,0 +1,92 @@ +// 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) + : RelationalTypeTestBase(fixture) +{ + public class DateTimeTypeFixture() : RelationalTypeTestFixture( + new DateTime(2020, 1, 5, 12, 30, 45, DateTimeKind.Unspecified), + new DateTime(2022, 5, 3, 0, 0, 0, DateTimeKind.Unspecified)) + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + } + + public override async Task ExecuteUpdate_within_json_to_nonjson_column() + { + // See #36688 for supporting this for SQL Server types other than string/numeric/bool + var exception = await Assert.ThrowsAsync(() => base.ExecuteUpdate_within_json_to_nonjson_column()); + Assert.Equal(RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn, exception.Message); + } +} + +public class DateTimeOffsetTypeTest(DateTimeOffsetTypeTest.DateTimeOffsetTypeFixture fixture) + : RelationalTypeTestBase(fixture) +{ + public override async Task ExecuteUpdate_within_json_to_nonjson_column() + { + // See #36688 for supporting this for SQL Server types other than string/numeric/bool + var exception = await Assert.ThrowsAsync(() => base.ExecuteUpdate_within_json_to_nonjson_column()); + Assert.Equal(RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn, exception.Message); + } + + public class DateTimeOffsetTypeFixture() : RelationalTypeTestFixture( + 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 => SqlServerTestStoreFactory.Instance; + } +} + +public class DateOnlyTypeTest(DateOnlyTypeTest.DateTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public override async Task ExecuteUpdate_within_json_to_nonjson_column() + { + // See #36688 for supporting this for SQL Server types other than string/numeric/bool + var exception = await Assert.ThrowsAsync(() => base.ExecuteUpdate_within_json_to_nonjson_column()); + Assert.Equal(RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn, exception.Message); + } + + public class DateTypeFixture() : RelationalTypeTestFixture( + new DateOnly(2020, 1, 5), + new DateOnly(2022, 5, 3)) + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + } +} + +public class TimeOnlyTypeTest(TimeOnlyTypeTest.TimeTypeFixture fixture) + : RelationalTypeTestBase(fixture) +{ + public override async Task ExecuteUpdate_within_json_to_nonjson_column() + { + // See #36688 for supporting this for SQL Server types other than string/numeric/bool + var exception = await Assert.ThrowsAsync(() => base.ExecuteUpdate_within_json_to_nonjson_column()); + Assert.Equal(RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn, exception.Message); + } + + public class TimeTypeFixture() : RelationalTypeTestFixture( + new TimeOnly(12, 30, 45), + new TimeOnly(14, 0, 0)) + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + } +} + +public class TimeSpanTypeTest(TimeSpanTypeTest.TimeSpanTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public override async Task ExecuteUpdate_within_json_to_nonjson_column() + { + // See #36688 for supporting this for SQL Server types other than string/numeric/bool + var exception = await Assert.ThrowsAsync(() => base.ExecuteUpdate_within_json_to_nonjson_column()); + Assert.Equal(RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn, exception.Message); + } + + public class TimeSpanTypeFixture() : RelationalTypeTestFixture( + new TimeSpan(12, 30, 45), + new TimeSpan(14, 0, 0)) + { + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; + } +} diff --git a/test/EFCore.Sqlite.FunctionalTests/BulkUpdates/ComplexTypeBulkUpdatesSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/BulkUpdates/ComplexTypeBulkUpdatesSqliteTest.cs deleted file mode 100644 index 295042e20aa..00000000000 --- a/test/EFCore.Sqlite.FunctionalTests/BulkUpdates/ComplexTypeBulkUpdatesSqliteTest.cs +++ /dev/null @@ -1,18 +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 class ComplexTypeBulkUpdatesSqliteTest( - ComplexTypeBulkUpdatesSqliteTest.ComplexTypeBulkUpdatesSqliteFixture fixture, - ITestOutputHelper testOutputHelper) : ComplexTypeBulkUpdatesRelationalTestBase< - ComplexTypeBulkUpdatesSqliteTest.ComplexTypeBulkUpdatesSqliteFixture>(fixture, testOutputHelper) -{ - public class ComplexTypeBulkUpdatesSqliteFixture : ComplexTypeBulkUpdatesRelationalFixtureBase - { - protected override ITestStoreFactory TestStoreFactory - => SqliteTestStoreFactory.Instance; - } -} diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonBulkUpdateSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonBulkUpdateSqliteTest.cs new file mode 100644 index 00000000000..cd6b42c147c --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonBulkUpdateSqliteTest.cs @@ -0,0 +1,9 @@ +// 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.Query.Associations.ComplexJson; + +public class ComplexJsonBulkUpdateSqliteTest( + ComplexJsonSqliteFixture fixture, + ITestOutputHelper testOutputHelper) + : ComplexJsonBulkUpdateRelationalTestBase(fixture, testOutputHelper); diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingBulkUpdateSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingBulkUpdateSqliteTest.cs new file mode 100644 index 00000000000..58057296f89 --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingBulkUpdateSqliteTest.cs @@ -0,0 +1,9 @@ +// 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.Query.Associations.ComplexTableSplitting; + +public class ComplexTableSplittingBulkUpdateSqliteTest( + ComplexTableSplittingSqliteFixture fixture, + ITestOutputHelper testOutputHelper) + : ComplexTableSplittingBulkUpdateRelationalTestBase(fixture, testOutputHelper); diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingMiscellaneousSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingMiscellaneousSqliteTest.cs index d74412a8d00..bfe69ddc74d 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingMiscellaneousSqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingMiscellaneousSqliteTest.cs @@ -6,6 +6,4 @@ namespace Microsoft.EntityFrameworkCore.Query.Associations.ComplexTableSplitting public class ComplexTableSplittingMiscellaneousSqliteTest( ComplexTableSplittingSqliteFixture fixture, ITestOutputHelper testOutputHelper) - : ComplexTableSplittingMiscellaneousRelationalTestBase(fixture, testOutputHelper) -{ -} + : ComplexTableSplittingMiscellaneousRelationalTestBase(fixture, testOutputHelper); diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingStructuralEqualitySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingStructuralEqualitySqliteTest.cs index 1d02f01eb83..447dd84a96a 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingStructuralEqualitySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingStructuralEqualitySqliteTest.cs @@ -6,6 +6,4 @@ namespace Microsoft.EntityFrameworkCore.Query.Associations.ComplexTableSplitting public class ComplexTableSplittingStructuralEqualitySqliteTest( ComplexTableSplittingSqliteFixture fixture, ITestOutputHelper testOutputHelper) - : ComplexTableSplittingStructuralEqualityRelationalTestBase(fixture, testOutputHelper) -{ -} + : ComplexTableSplittingStructuralEqualityRelationalTestBase(fixture, testOutputHelper); diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonBulkUpdateSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonBulkUpdateSqliteTest.cs new file mode 100644 index 00000000000..cf5ac338e92 --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/Query/Associations/OwnedJson/OwnedJsonBulkUpdateSqliteTest.cs @@ -0,0 +1,9 @@ +// 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.Query.Associations.OwnedJson; + +public class OwnedJsonBulkUpdateSqliteTest( + OwnedJsonSqliteFixture fixture, + ITestOutputHelper testOutputHelper) + : OwnedJsonBulkUpdateRelationalTestBase(fixture, testOutputHelper); diff --git a/test/EFCore.Sqlite.FunctionalTests/Types/SqliteMiscellaneousTypeTest.cs b/test/EFCore.Sqlite.FunctionalTests/Types/SqliteMiscellaneousTypeTest.cs new file mode 100644 index 00000000000..a62d2bc8a15 --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/Types/SqliteMiscellaneousTypeTest.cs @@ -0,0 +1,58 @@ +// 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) + : RelationalTypeTestBase(fixture) +{ + public class BoolTypeFixture() : RelationalTypeTestFixture(true, false) + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + } +} + +public class StringTypeTest(StringTypeTest.StringTypeFixture fixture) + : RelationalTypeTestBase(fixture) +{ + public class StringTypeFixture() : RelationalTypeTestFixture("foo", "bar") + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + } +} + +public class GuidTypeTest(GuidTypeTest.GuidTypeFixture fixture) + : RelationalTypeTestBase(fixture) +{ + public override async Task ExecuteUpdate_within_json_to_nonjson_column() + { + // See #36688 for supporting this for SQL Server types other than string/numeric/bool + var exception = await Assert.ThrowsAsync(() => base.ExecuteUpdate_within_json_to_nonjson_column()); + Assert.Equal(RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn, exception.Message); + } + + public class GuidTypeFixture() : RelationalTypeTestFixture( + new Guid("8f7331d6-cde9-44fb-8611-81fff686f280"), + new Guid("ae192c36-9004-49b2-b785-8be10d169627")) + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + } +} + +public class ByteArrayTypeTest(ByteArrayTypeTest.ByteArrayTypeFixture fixture) + : RelationalTypeTestBase(fixture) +{ + public override async Task ExecuteUpdate_within_json_to_nonjson_column() + { + // See #36688 for supporting this for SQL Server types other than string/numeric/bool + var exception = await Assert.ThrowsAsync(() => base.ExecuteUpdate_within_json_to_nonjson_column()); + Assert.Equal(RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn, exception.Message); + } + + public class ByteArrayTypeFixture() : RelationalTypeTestFixture([1, 2, 3], [4, 5, 6]) + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + + public override Func Comparer { get; } = (a, b) => a.SequenceEqual(b); + } +} diff --git a/test/EFCore.Sqlite.FunctionalTests/Types/SqliteNumericTypeTest.cs b/test/EFCore.Sqlite.FunctionalTests/Types/SqliteNumericTypeTest.cs new file mode 100644 index 00000000000..28e54613bb7 --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/Types/SqliteNumericTypeTest.cs @@ -0,0 +1,60 @@ +// 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) : RelationalTypeTestBase(fixture) +{ + public class ByteTypeFixture() : RelationalTypeTestFixture(byte.MinValue, byte.MaxValue) + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + } +} + +public class ShortTypeTest(ShortTypeTest.ShortTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public class ShortTypeFixture() : RelationalTypeTestFixture(short.MinValue, short.MaxValue) + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + } +} + +public class IntTypeTest(IntTypeTest.IntTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public class IntTypeFixture() : RelationalTypeTestFixture(int.MinValue, int.MaxValue) + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + } +} + +public class LongTypeTest(LongTypeTest.LongTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public class LongTypeFixture() : RelationalTypeTestFixture(long.MinValue, long.MaxValue) + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + } +} + +public class DecimalTypeTest(DecimalTypeTest.DecimalTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public class DecimalTypeFixture() : RelationalTypeTestFixture(30.5m, 30m) + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + } +} + +public class DoubleTypeTest(DoubleTypeTest.DoubleTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public class DoubleTypeFixture() : RelationalTypeTestFixture(30.5d, 30d) + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + } +} + +public class FloatTypeTest(FloatTypeTest.FloatTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public class FloatTypeFixture() : RelationalTypeTestFixture(30.5f, 30f) + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + } +} diff --git a/test/EFCore.Sqlite.FunctionalTests/Types/SqliteTemporalTypeTest.cs b/test/EFCore.Sqlite.FunctionalTests/Types/SqliteTemporalTypeTest.cs new file mode 100644 index 00000000000..57f13e7a45b --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/Types/SqliteTemporalTypeTest.cs @@ -0,0 +1,92 @@ +// 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) + : RelationalTypeTestBase(fixture) +{ + public class DateTimeTypeFixture() : RelationalTypeTestFixture( + new DateTime(2020, 1, 5, 12, 30, 45, DateTimeKind.Unspecified), + new DateTime(2022, 5, 3, 0, 0, 0, DateTimeKind.Unspecified)) + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + } + + public override async Task ExecuteUpdate_within_json_to_nonjson_column() + { + // See #36688 for supporting this for SQL Server types other than string/numeric/bool + var exception = await Assert.ThrowsAsync(() => base.ExecuteUpdate_within_json_to_nonjson_column()); + Assert.Equal(RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn, exception.Message); + } +} + +public class DateTimeOffsetTypeTest(DateTimeOffsetTypeTest.DateTimeOffsetTypeFixture fixture) + : RelationalTypeTestBase(fixture) +{ + public override async Task ExecuteUpdate_within_json_to_nonjson_column() + { + // See #36688 for supporting this for SQL Server types other than string/numeric/bool + var exception = await Assert.ThrowsAsync(() => base.ExecuteUpdate_within_json_to_nonjson_column()); + Assert.Equal(RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn, exception.Message); + } + + public class DateTimeOffsetTypeFixture() : RelationalTypeTestFixture( + 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 => SqliteTestStoreFactory.Instance; + } +} + +public class DateOnlyTypeTest(DateOnlyTypeTest.DateTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public override async Task ExecuteUpdate_within_json_to_nonjson_column() + { + // See #36688 for supporting this for SQL Server types other than string/numeric/bool + var exception = await Assert.ThrowsAsync(() => base.ExecuteUpdate_within_json_to_nonjson_column()); + Assert.Equal(RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn, exception.Message); + } + + public class DateTypeFixture() : RelationalTypeTestFixture( + new DateOnly(2020, 1, 5), + new DateOnly(2022, 5, 3)) + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + } +} + +public class TimeOnlyTypeTest(TimeOnlyTypeTest.TimeTypeFixture fixture) + : RelationalTypeTestBase(fixture) +{ + public override async Task ExecuteUpdate_within_json_to_nonjson_column() + { + // See #36688 for supporting this for SQL Server types other than string/numeric/bool + var exception = await Assert.ThrowsAsync(() => base.ExecuteUpdate_within_json_to_nonjson_column()); + Assert.Equal(RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn, exception.Message); + } + + public class TimeTypeFixture() : RelationalTypeTestFixture( + new TimeOnly(12, 30, 45), + new TimeOnly(14, 0, 0)) + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + } +} + +public class TimeSpanTypeTest(TimeSpanTypeTest.TimeSpanTypeFixture fixture) : RelationalTypeTestBase(fixture) +{ + public override async Task ExecuteUpdate_within_json_to_nonjson_column() + { + // See #36688 for supporting this for SQL Server types other than string/numeric/bool + var exception = await Assert.ThrowsAsync(() => base.ExecuteUpdate_within_json_to_nonjson_column()); + Assert.Equal(RelationalStrings.ExecuteUpdateCannotSetJsonPropertyToNonJsonColumn, exception.Message); + } + + public class TimeSpanTypeFixture() : RelationalTypeTestFixture( + new TimeSpan(12, 30, 45), + new TimeSpan(14, 0, 0)) + { + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; + } +}