From 3a4c6a2b9698a79f18f8a858a14471cee93cffc6 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Sun, 11 Feb 2024 20:23:17 +0100 Subject: [PATCH 1/2] Reference local version of EF Core for debugging --- EFCore.PG.slnx | 12 ++++++++++++ global.json | 8 +------- src/Directory.Build.props | 3 +++ src/EFCore.PG/EFCore.PG.csproj | 6 ++++++ .../EFCore.PG.FunctionalTests.csproj | 7 +++++++ test/EFCore.PG.Tests/EFCore.PG.Tests.csproj | 9 +++++++++ 6 files changed, 38 insertions(+), 7 deletions(-) mode change 100644 => 120000 global.json diff --git a/EFCore.PG.slnx b/EFCore.PG.slnx index 8166ab4417..795733aef4 100644 --- a/EFCore.PG.slnx +++ b/EFCore.PG.slnx @@ -22,4 +22,16 @@ + + + + + + + + + + + + diff --git a/global.json b/global.json deleted file mode 100644 index 5cb25519f8..0000000000 --- a/global.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "sdk": { - "version": "10.0.100-preview.7.25380.108", - "rollForward": "latestMajor", - "allowPrerelease": true - } -} diff --git a/global.json b/global.json new file mode 120000 index 0000000000..298c39bb6e --- /dev/null +++ b/global.json @@ -0,0 +1 @@ +../efcore/global.json \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b5e6b77b56..4816d1a1d0 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -8,7 +8,10 @@ nullablePublicOnly + diff --git a/src/EFCore.PG/EFCore.PG.csproj b/src/EFCore.PG/EFCore.PG.csproj index f6dd61b6cb..4bceab1ea2 100644 --- a/src/EFCore.PG/EFCore.PG.csproj +++ b/src/EFCore.PG/EFCore.PG.csproj @@ -22,6 +22,12 @@ + + + + + + diff --git a/test/EFCore.PG.FunctionalTests/EFCore.PG.FunctionalTests.csproj b/test/EFCore.PG.FunctionalTests/EFCore.PG.FunctionalTests.csproj index 8050bc586c..e56ed6fa20 100644 --- a/test/EFCore.PG.FunctionalTests/EFCore.PG.FunctionalTests.csproj +++ b/test/EFCore.PG.FunctionalTests/EFCore.PG.FunctionalTests.csproj @@ -4,18 +4,25 @@ Npgsql.EntityFrameworkCore.PostgreSQL.FunctionalTests Microsoft.EntityFrameworkCore true + $(NoWarn);NU1903;CS8618 + + + + + diff --git a/test/EFCore.PG.Tests/EFCore.PG.Tests.csproj b/test/EFCore.PG.Tests/EFCore.PG.Tests.csproj index 2011844400..d194b16646 100644 --- a/test/EFCore.PG.Tests/EFCore.PG.Tests.csproj +++ b/test/EFCore.PG.Tests/EFCore.PG.Tests.csproj @@ -4,19 +4,28 @@ Npgsql.EntityFrameworkCore.PostgreSQL.Tests Npgsql.EntityFrameworkCore.PostgreSQL disable + $(NoWarn);NU1903 + + + + + + + From c9a6d6c4e828f88a466813ebe6e6a9bafab9dc63 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Mon, 1 Sep 2025 11:12:02 +0200 Subject: [PATCH 2/2] Implement ExecuteUpdate partial update in JSON Closes #3608 --- .../Query/Internal/NpgsqlQuerySqlGenerator.cs | 68 ++- ...yableMethodTranslatingExpressionVisitor.cs | 115 +++++ .../ComplexTypeBulkUpdatesNpgsqlTest.cs | 304 ------------- .../ComplexJsonBulkUpdateNpgsqlTest.cs | 335 ++++++++++++++ ...mplexTableSplittingBulkUpdateNpgsqlTest.cs | 415 ++++++++++++++++++ .../StoreTypeNpgsqlTest.cs | 41 ++ 6 files changed, 954 insertions(+), 324 deletions(-) delete mode 100644 test/EFCore.PG.FunctionalTests/BulkUpdates/ComplexTypeBulkUpdatesNpgsqlTest.cs create mode 100644 test/EFCore.PG.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonBulkUpdateNpgsqlTest.cs create mode 100644 test/EFCore.PG.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingBulkUpdateNpgsqlTest.cs create mode 100644 test/EFCore.PG.FunctionalTests/StoreTypeNpgsqlTest.cs diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs index 4a7fed3922..7081d0a416 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs @@ -159,6 +159,23 @@ protected override void GenerateTop(SelectExpression selectExpression) // No TOP() in PostgreSQL, see GenerateLimitOffset } + /// + /// Generates SQL for a constant. + /// + /// The for which to generate SQL. + protected override Expression VisitSqlConstant(SqlConstantExpression sqlConstantExpression) + { + // Certain JSON functions (e.g. jsonb_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(ConvertJsonPathSegments(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 @@ -1058,31 +1075,33 @@ protected virtual Expression VisitILike(PgILikeExpression likeExpression, bool n protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExpression) { // TODO: Stop producing empty JsonScalarExpressions, #30768 - var path = jsonScalarExpression.Path; - if (path.Count == 0) + var segmentsPath = jsonScalarExpression.Path; + if (segmentsPath.Count == 0) { Visit(jsonScalarExpression.Json); return jsonScalarExpression; } + var path = ConvertJsonPathSegments(segmentsPath); + switch (jsonScalarExpression.TypeMapping) { // This case is for when a nested JSON entity is being accessed. We want the json/jsonb fragment in this case (not text), // so we can perform further JSON operations on it. case NpgsqlStructuralJsonTypeMapping: - GenerateJsonPath(returnsText: false); + GenerateJsonPath(jsonScalarExpression.Json, returnsText: false, path); break; // No need to cast the output when we expect a string anyway case StringTypeMapping: - GenerateJsonPath(returnsText: true); + GenerateJsonPath(jsonScalarExpression.Json, returnsText: true, path); break; // bytea requires special handling, since we encode the binary data as base64 inside the JSON, but that requires a special // conversion function to be extracted out to a PG bytea. case NpgsqlByteArrayTypeMapping: Sql.Append("decode("); - GenerateJsonPath(returnsText: true); + GenerateJsonPath(jsonScalarExpression.Json, returnsText: true, path); Sql.Append(", 'base64')"); break; @@ -1092,13 +1111,13 @@ protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExp case NpgsqlArrayTypeMapping arrayMapping: Sql.Append("(ARRAY(SELECT CAST(element AS ").Append(arrayMapping.ElementTypeMapping.StoreType) .Append(") FROM jsonb_array_elements_text("); - GenerateJsonPath(returnsText: false); + GenerateJsonPath(jsonScalarExpression.Json, returnsText: false, path); Sql.Append(") WITH ORDINALITY AS t(element) ORDER BY ordinality))"); break; default: Sql.Append("CAST("); - GenerateJsonPath(returnsText: true); + GenerateJsonPath(jsonScalarExpression.Json, returnsText: true, path); Sql.Append(" AS "); Sql.Append(jsonScalarExpression.TypeMapping!.StoreType); Sql.Append(")"); @@ -1106,19 +1125,6 @@ protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExp } return jsonScalarExpression; - - void GenerateJsonPath(bool returnsText) - => this.GenerateJsonPath( - jsonScalarExpression.Json, - returnsText: returnsText, - jsonScalarExpression.Path.Select( - s => s switch - { - { PropertyName: string propertyName } - => new SqlConstantExpression(propertyName, _textTypeMapping ??= _typeMappingSource.FindMapping(typeof(string))), - { ArrayIndex: SqlExpression arrayIndex } => arrayIndex, - _ => throw new UnreachableException() - }).ToList()); } /// @@ -1148,6 +1154,11 @@ private void GenerateJsonPath(SqlExpression expression, bool returnsText, IReadO // Multiple path components Sql.Append(returnsText ? " #>> " : " #> "); + GenerateJsonPath(path); + } + + private void GenerateJsonPath(IReadOnlyList path) + { // Use simplified array literal syntax if all path components are constants for cleaner SQL if (path.All(p => p is SqlConstantExpression { Value: var pathSegment } && (pathSegment is not string s || s.All(char.IsAsciiLetterOrDigit)))) @@ -1173,6 +1184,23 @@ private void GenerateJsonPath(SqlExpression expression, bool returnsText, IReadO } } + /// + /// Converts the standard EF to an + /// (the EF built-in and don't support non-constant + /// property names, but we do via the Npgsql-specific JSON DOM support). + /// + private IReadOnlyList ConvertJsonPathSegments(IReadOnlyList path) + => path + .Select( + s => s switch + { + { PropertyName: string propertyName } + => new SqlConstantExpression(propertyName, _textTypeMapping ??= _typeMappingSource.FindMapping(typeof(string))), + { ArrayIndex: SqlExpression arrayIndex } => arrayIndex, + _ => throw new UnreachableException() + }) + .ToList(); + /// /// 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 diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs index b8d9fc2a62..47ddb2038e 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs @@ -1046,6 +1046,8 @@ protected override bool IsNaturallyOrdered(SelectExpression selectExpression) [{ Expression: ColumnExpression { Name: "ordinality", TableAlias: var orderingTableAlias } }] && orderingTableAlias == unnest.Alias); + #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 @@ -1100,6 +1102,119 @@ protected override bool IsValidSelectExpressionForExecuteUpdate( return true; } + /// + /// 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 jsonTypeMapping = ((ColumnExpression)target.Json).TypeMapping!; + + if ( + // The base implementation doesn't handle serializing arbitrary SQL expressions to JSON, since that's + // database-specific. In PostgreSQL we simply do this by wrapping any expression in to_jsonb(). + !base.TrySerializeScalarToJson(target, value, out jsonValue) + // In addition, for string, numeric and bool, the base implementation simply returns the value as-is, since most databases allow + // passing these native types directly to their JSON partial update function. In PostgreSQL, jsonb_set() always requires jsonb, + // so we wrap those expression with to_jsonb() as well. + || jsonValue.TypeMapping?.StoreType is not "jsonb" and not "json") + { + switch (value.TypeMapping!.StoreType) + { + case "jsonb" or "json": + jsonValue = value; + return true; + + case "bytea": + value = _sqlExpressionFactory.Function( + "encode", + [value, _sqlExpressionFactory.Constant("base64")], + nullable: true, + argumentsPropagateNullability: [true, true], + typeof(string), + _typeMappingSource.FindMapping(typeof(string))! + ); + break; + } + + jsonValue = _sqlExpressionFactory.Function( + jsonTypeMapping.StoreType switch + { + "jsonb" => "to_jsonb", + "json" => "to_json", + _ => throw new UnreachableException() + }, + // Make sure PG interprets constant values correctly by adding explicit typing based on the target property's type mapping. + // Note that we can only be here for scalar properties, for structural types we always already get a jsonb/json value + // and don't need to add to_jsonb/to_json. + [value is SqlConstantExpression ? _sqlExpressionFactory.Convert(value, target.Type, target.TypeMapping) : value], + nullable: true, + argumentsPropagateNullability: [true], + typeof(string), + jsonTypeMapping); + } + + return true; + } + + /// + /// 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( + jsonColumn.TypeMapping?.StoreType switch + { + "jsonb" => "jsonb_set", + "json" => "json_set", + _ => throw new UnreachableException() + }, + 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), + value + ], + nullable: true, + argumentsPropagateNullability: [true, true, true], + typeof(string), + jsonColumn.TypeMapping); + + if (existingSetterValue is null) + { + return jsonSet; + } + else + { + existingSetterValue = jsonSet; + return null; + } + } + + #endregion 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 diff --git a/test/EFCore.PG.FunctionalTests/BulkUpdates/ComplexTypeBulkUpdatesNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/BulkUpdates/ComplexTypeBulkUpdatesNpgsqlTest.cs deleted file mode 100644 index 9e9c4b7329..0000000000 --- a/test/EFCore.PG.FunctionalTests/BulkUpdates/ComplexTypeBulkUpdatesNpgsqlTest.cs +++ /dev/null @@ -1,304 +0,0 @@ -namespace Microsoft.EntityFrameworkCore.BulkUpdates; - -public class ComplexTypeBulkUpdatesNpgsqlTest( - ComplexTypeBulkUpdatesNpgsqlTest.ComplexTypeBulkUpdatesNpgsqlFixture fixture, - ITestOutputHelper testOutputHelper) - : ComplexTypeBulkUpdatesRelationalTestBase(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 "Customer" AS c -WHERE c."Name" = '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 "Customer" AS c -SET "ShippingAddress_ZipCode" = @p -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' - -UPDATE "Customer" AS c -SET "ShippingAddress_Country_FullName" = @p -WHERE c."ShippingAddress_Country_Code" = '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 "Customer" AS c -SET "Name" = c."Name" || 'Modified', - "ShippingAddress_ZipCode" = c."BillingAddress_ZipCode", - "BillingAddress_ZipCode" = @p -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 "Customer" AS c -SET "ShippingAddress_ZipCode" = @p -"""); - } - - 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 "Customer" AS c -SET "ShippingAddress_ZipCode" = c."BillingAddress_ZipCode", - "BillingAddress_ZipCode" = @p -"""); - } - - 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' -@complex_type_p_AddressLine2='New AddressLine2' -@complex_type_p_Tags={ 'new_tag1' -'new_tag2' } (DbType = Object) -@complex_type_p_ZipCode='99999' (Nullable = true) -@complex_type_p_Code='FR' -@complex_type_p_FullName='France' - -UPDATE "Customer" AS c -SET "ShippingAddress_AddressLine1" = @complex_type_p_AddressLine1, - "ShippingAddress_AddressLine2" = @complex_type_p_AddressLine2, - "ShippingAddress_Tags" = @complex_type_p_Tags, - "ShippingAddress_ZipCode" = @complex_type_p_ZipCode, - "ShippingAddress_Country_Code" = @complex_type_p_Code, - "ShippingAddress_Country_FullName" = @complex_type_p_FullName -"""); - } - - 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' -@complex_type_p_FullName='France' - -UPDATE "Customer" AS c -SET "ShippingAddress_Country_Code" = @complex_type_p_Code, - "ShippingAddress_Country_FullName" = @complex_type_p_FullName -"""); - } - - 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 "Customer" AS c -SET "ShippingAddress_AddressLine1" = c."BillingAddress_AddressLine1", - "ShippingAddress_AddressLine2" = c."BillingAddress_AddressLine2", - "ShippingAddress_Tags" = c."BillingAddress_Tags", - "ShippingAddress_ZipCode" = c."BillingAddress_ZipCode", - "ShippingAddress_Country_Code" = c."ShippingAddress_Country_Code", - "ShippingAddress_Country_FullName" = c."ShippingAddress_Country_FullName" -"""); - } - - 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' -@complex_type_p_AddressLine2='New AddressLine2' -@complex_type_p_Tags={ 'new_tag1' -'new_tag2' } (DbType = Object) -@complex_type_p_ZipCode='99999' (Nullable = true) -@complex_type_p_Code='FR' -@complex_type_p_FullName='France' - -UPDATE "Customer" AS c -SET "ShippingAddress_AddressLine1" = @complex_type_p_AddressLine1, - "ShippingAddress_AddressLine2" = @complex_type_p_AddressLine2, - "ShippingAddress_Tags" = @complex_type_p_Tags, - "ShippingAddress_ZipCode" = @complex_type_p_ZipCode, - "ShippingAddress_Country_Code" = @complex_type_p_Code, - "ShippingAddress_Country_FullName" = @complex_type_p_FullName -"""); - } - - 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 "Customer" AS c -SET "ShippingAddress_AddressLine1" = 'New AddressLine1', - "ShippingAddress_AddressLine2" = 'New AddressLine2', - "ShippingAddress_Tags" = ARRAY['new_tag1','new_tag2']::text[], - "ShippingAddress_ZipCode" = 99999, - "ShippingAddress_Country_Code" = 'FR', - "ShippingAddress_Country_FullName" = 'France' -"""); - } - - 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 "Customer" AS c0 -SET "ShippingAddress_AddressLine1" = c1."BillingAddress_AddressLine1", - "ShippingAddress_AddressLine2" = c1."BillingAddress_AddressLine2", - "ShippingAddress_Tags" = c1."BillingAddress_Tags", - "ShippingAddress_ZipCode" = c1."BillingAddress_ZipCode", - "ShippingAddress_Country_Code" = c1."ShippingAddress_Country_Code", - "ShippingAddress_Country_FullName" = c1."ShippingAddress_Country_FullName" -FROM ( - 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" NULLS FIRST - OFFSET @p -) AS c1 -WHERE 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' } (DbType = Object) - -UPDATE "Customer" AS c -SET "ShippingAddress_Tags" = @p -"""); - } - - public override async Task Update_complex_type_to_null(bool async) - { - await base.Update_complex_type_to_null(async); - - AssertExecuteUpdateSql( - """ -UPDATE "Customer" AS c -SET "OptionalAddress_AddressLine1" = NULL, - "OptionalAddress_AddressLine2" = NULL, - "OptionalAddress_Tags" = NULL, - "OptionalAddress_ZipCode" = NULL, - "OptionalAddress_Country_Code" = NULL, - "OptionalAddress_Country_FullName" = NULL -"""); - } - - public override async Task Update_complex_type_to_null_lambda(bool async) - { - await base.Update_complex_type_to_null_lambda(async); - - AssertExecuteUpdateSql( - """ -UPDATE "Customer" AS c -SET "OptionalAddress_AddressLine1" = NULL, - "OptionalAddress_AddressLine2" = NULL, - "OptionalAddress_Tags" = NULL, - "OptionalAddress_ZipCode" = NULL, - "OptionalAddress_Country_Code" = NULL, - "OptionalAddress_Country_FullName" = NULL -"""); - } - - public override async Task Update_complex_type_to_null_parameter(bool async) - { - await base.Update_complex_type_to_null_parameter(async); - - AssertExecuteUpdateSql( - """ -UPDATE "Customer" AS c -SET "OptionalAddress_AddressLine1" = NULL, - "OptionalAddress_AddressLine2" = NULL, - "OptionalAddress_Tags" = NULL, - "OptionalAddress_ZipCode" = NULL, - "OptionalAddress_Country_Code" = NULL, - "OptionalAddress_Country_FullName" = NULL -"""); - } - - [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 ComplexTypeBulkUpdatesNpgsqlFixture : ComplexTypeBulkUpdatesRelationalFixtureBase - { - protected override ITestStoreFactory TestStoreFactory - => NpgsqlTestStoreFactory.Instance; - } -} diff --git a/test/EFCore.PG.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonBulkUpdateNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonBulkUpdateNpgsqlTest.cs new file mode 100644 index 0000000000..014b5f5c06 --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonBulkUpdateNpgsqlTest.cs @@ -0,0 +1,335 @@ +namespace Microsoft.EntityFrameworkCore.Query.Associations.ComplexJson; + +public class ComplexJsonBulkUpdateNpgsqlTest( + ComplexJsonNpgsqlFixture 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='?' + +DELETE 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='?' + +UPDATE "RootEntity" AS r +SET "RequiredRelated" = jsonb_set(r."RequiredRelated", '{String}', to_jsonb(@p)) +"""); + } + + public override async Task Update_property_inside_association_with_special_chars() + { + await base.Update_property_inside_association_with_special_chars(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "RequiredRelated" = jsonb_set(r."RequiredRelated", '{String}', to_jsonb('{ Some other/JSON:like text though it [isn''t]: ממש ממש לאéèéè }'::text)) +WHERE (r."RequiredRelated" ->> 'String') = '{ 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='?' + +UPDATE "RootEntity" AS r +SET "RequiredRelated" = jsonb_set(r."RequiredRelated", '{RequiredNested,String}', to_jsonb(@p)) +"""); + } + + public override async Task Update_property_on_projected_association() + { + await base.Update_property_on_projected_association(); + + AssertExecuteUpdateSql( + """ +@p='?' + +UPDATE "RootEntity" AS r +SET "RequiredRelated" = jsonb_set(r."RequiredRelated", '{String}', to_jsonb(@p)) +"""); + } + + 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='?' (DbType = Object) + +UPDATE "RootEntity" AS r +SET "RequiredRelated" = @complex_type_p +"""); + } + + public override async Task Update_nested_association_to_parameter() + { + await base.Update_nested_association_to_parameter(); + + AssertExecuteUpdateSql( + """ +@complex_type_p='?' (DbType = Object) + +UPDATE "RootEntity" AS r +SET "RequiredRelated" = jsonb_set(r."RequiredRelated", '{RequiredNested}', @complex_type_p) +"""); + } + + public override async Task Update_association_to_another_association() + { + await base.Update_association_to_another_association(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "OptionalRelated" = r."RequiredRelated" +"""); + } + + public override async Task Update_nested_association_to_another_nested_association() + { + await base.Update_nested_association_to_another_nested_association(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "RequiredRelated" = jsonb_set(r."RequiredRelated", '{OptionalNested}', r."RequiredRelated" -> 'RequiredNested') +"""); + } + + public override async Task Update_association_to_inline() + { + await base.Update_association_to_inline(); + + AssertExecuteUpdateSql( + """ +@complex_type_p='?' (DbType = Object) + +UPDATE "RootEntity" AS r +SET "RequiredRelated" = @complex_type_p +"""); + } + + public override async Task Update_association_to_inline_with_lambda() + { + await base.Update_association_to_inline_with_lambda(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "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"}}' +"""); + } + + public override async Task Update_nested_association_to_inline_with_lambda() + { + await base.Update_nested_association_to_inline_with_lambda(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "RequiredRelated" = jsonb_set(r."RequiredRelated", '{RequiredNested}', '{"Id":0,"Int":80,"Name":"Updated nested name","String":"Updated nested string"}') +"""); + } + + public override async Task Update_association_to_null() + { + await base.Update_association_to_null(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "OptionalRelated" = NULL +"""); + } + + public override async Task Update_association_to_null_with_lambda() + { + await base.Update_association_to_null_with_lambda(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "OptionalRelated" = NULL +"""); + } + + public override async Task Update_association_to_null_parameter() + { + await base.Update_association_to_null_parameter(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "OptionalRelated" = NULL +"""); + } + + #endregion Update association + + #region Update collection + + public override async Task Update_collection_to_parameter() + { + await base.Update_collection_to_parameter(); + + AssertExecuteUpdateSql( + """ +@complex_type_p='?' (DbType = Object) + +UPDATE "RootEntity" AS r +SET "RelatedCollection" = @complex_type_p +"""); + } + + public override async Task Update_nested_collection_to_parameter() + { + await base.Update_nested_collection_to_parameter(); + + AssertExecuteUpdateSql( + """ +@complex_type_p='?' (DbType = Object) + +UPDATE "RootEntity" AS r +SET "RequiredRelated" = jsonb_set(r."RequiredRelated", '{NestedCollection}', @complex_type_p) +"""); + } + + public override async Task Update_nested_collection_to_inline_with_lambda() + { + await base.Update_nested_collection_to_inline_with_lambda(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "RequiredRelated" = jsonb_set(r."RequiredRelated", '{NestedCollection}', '[{"Id":0,"Int":80,"Name":"Updated nested name1","String":"Updated nested string1"},{"Id":0,"Int":81,"Name":"Updated nested name2","String":"Updated nested string2"}]') +"""); + } + + public override async Task Update_nested_collection_to_another_nested_collection() + { + await base.Update_nested_collection_to_another_nested_collection(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "RequiredRelated" = jsonb_set(r."RequiredRelated", '{NestedCollection}', r."OptionalRelated" -> 'NestedCollection') +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='?' +@p0='?' (DbType = Int32) + +UPDATE "RootEntity" AS r +SET "RequiredRelated" = jsonb_set(jsonb_set(r."RequiredRelated", '{String}', to_jsonb(@p)), '{Int}', to_jsonb(@p0)) +"""); + } + + 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='?' + +UPDATE "RootEntity" AS r +SET "Name" = r."Name" || 'Modified', + "RequiredRelated" = jsonb_set(r."RequiredRelated", '{String}', to_jsonb(r."OptionalRelated" ->> 'String')), + "OptionalRelated" = jsonb_set(r."OptionalRelated", '{RequiredNested,String}', to_jsonb(@p)) +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(); + + AssertExecuteUpdateSql( + """ +@p='?' + +UPDATE "RootEntity" AS r +SET "RequiredRelated" = jsonb_set(r."RequiredRelated", '{String}', to_jsonb(r."OptionalRelated" ->> 'String')), + "OptionalRelated" = jsonb_set(r."OptionalRelated", '{String}', to_jsonb(@p)) +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.PG.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingBulkUpdateNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingBulkUpdateNpgsqlTest.cs new file mode 100644 index 0000000000..26d801d955 --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/Query/Associations/ComplexTableSplitting/ComplexTableSplittingBulkUpdateNpgsqlTest.cs @@ -0,0 +1,415 @@ + +namespace Microsoft.EntityFrameworkCore.Query.Associations.ComplexTableSplitting; + +public class ComplexTableSplittingBulkUpdateNpgsqlTest( + ComplexTableSplittingNpgsqlFixture 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='?' + +DELETE 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='?' + +UPDATE "RootEntity" AS r +SET "RequiredRelated_String" = @p +"""); + } + + public override async Task Update_property_inside_association_with_special_chars() + { + await base.Update_property_inside_association_with_special_chars(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "RequiredRelated_String" = '{ Some other/JSON:like text though it [isn''t]: ממש ממש לאéèéè }' +WHERE r."RequiredRelated_String" = '{ 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='?' + +UPDATE "RootEntity" AS r +SET "RequiredRelated_RequiredNested_String" = @p +"""); + } + + public override async Task Update_property_on_projected_association() + { + await base.Update_property_on_projected_association(); + + AssertExecuteUpdateSql( + """ +@p='?' + +UPDATE "RootEntity" AS r +SET "RequiredRelated_String" = @p +"""); + } + + 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='?' +@complex_type_p_String='?' + +UPDATE "RootEntity" AS r +SET "RequiredRelated_Id" = @complex_type_p_Id, + "RequiredRelated_Int" = @complex_type_p_Int, + "RequiredRelated_Name" = @complex_type_p_Name, + "RequiredRelated_String" = @complex_type_p_String, + "RequiredRelated_OptionalNested_Id" = @complex_type_p_Id, + "RequiredRelated_OptionalNested_Int" = @complex_type_p_Int, + "RequiredRelated_OptionalNested_Name" = @complex_type_p_Name, + "RequiredRelated_OptionalNested_String" = @complex_type_p_String, + "RequiredRelated_RequiredNested_Id" = @complex_type_p_Id, + "RequiredRelated_RequiredNested_Int" = @complex_type_p_Int, + "RequiredRelated_RequiredNested_Name" = @complex_type_p_Name, + "RequiredRelated_RequiredNested_String" = @complex_type_p_String +"""); + } + + 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='?' +@complex_type_p_String='?' + +UPDATE "RootEntity" AS r +SET "RequiredRelated_RequiredNested_Id" = @complex_type_p_Id, + "RequiredRelated_RequiredNested_Int" = @complex_type_p_Int, + "RequiredRelated_RequiredNested_Name" = @complex_type_p_Name, + "RequiredRelated_RequiredNested_String" = @complex_type_p_String +"""); + } + + public override async Task Update_association_to_another_association() + { + await base.Update_association_to_another_association(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "OptionalRelated_Id" = r."RequiredRelated_Id", + "OptionalRelated_Int" = r."RequiredRelated_Int", + "OptionalRelated_Name" = r."RequiredRelated_Name", + "OptionalRelated_String" = r."RequiredRelated_String", + "OptionalRelated_OptionalNested_Id" = r."OptionalRelated_OptionalNested_Id", + "OptionalRelated_OptionalNested_Int" = r."OptionalRelated_OptionalNested_Int", + "OptionalRelated_OptionalNested_Name" = r."OptionalRelated_OptionalNested_Name", + "OptionalRelated_OptionalNested_String" = r."OptionalRelated_OptionalNested_String", + "OptionalRelated_RequiredNested_Id" = r."OptionalRelated_RequiredNested_Id", + "OptionalRelated_RequiredNested_Int" = r."OptionalRelated_RequiredNested_Int", + "OptionalRelated_RequiredNested_Name" = r."OptionalRelated_RequiredNested_Name", + "OptionalRelated_RequiredNested_String" = r."OptionalRelated_RequiredNested_String" +"""); + } + + public override async Task Update_nested_association_to_another_nested_association() + { + await base.Update_nested_association_to_another_nested_association(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "RequiredRelated_OptionalNested_Id" = r."RequiredRelated_RequiredNested_Id", + "RequiredRelated_OptionalNested_Int" = r."RequiredRelated_RequiredNested_Int", + "RequiredRelated_OptionalNested_Name" = r."RequiredRelated_RequiredNested_Name", + "RequiredRelated_OptionalNested_String" = r."RequiredRelated_RequiredNested_String" +"""); + } + + 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='?' +@complex_type_p_String='?' + +UPDATE "RootEntity" AS r +SET "RequiredRelated_Id" = @complex_type_p_Id, + "RequiredRelated_Int" = @complex_type_p_Int, + "RequiredRelated_Name" = @complex_type_p_Name, + "RequiredRelated_String" = @complex_type_p_String, + "RequiredRelated_OptionalNested_Id" = @complex_type_p_Id, + "RequiredRelated_OptionalNested_Int" = @complex_type_p_Int, + "RequiredRelated_OptionalNested_Name" = @complex_type_p_Name, + "RequiredRelated_OptionalNested_String" = @complex_type_p_String, + "RequiredRelated_RequiredNested_Id" = @complex_type_p_Id, + "RequiredRelated_RequiredNested_Int" = @complex_type_p_Int, + "RequiredRelated_RequiredNested_Name" = @complex_type_p_Name, + "RequiredRelated_RequiredNested_String" = @complex_type_p_String +"""); + } + + public override async Task Update_association_to_inline_with_lambda() + { + await base.Update_association_to_inline_with_lambda(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "RequiredRelated_Id" = 0, + "RequiredRelated_Int" = 70, + "RequiredRelated_Name" = 'Updated related name', + "RequiredRelated_String" = 'Updated related string', + "RequiredRelated_OptionalNested_Id" = NULL, + "RequiredRelated_OptionalNested_Int" = NULL, + "RequiredRelated_OptionalNested_Name" = NULL, + "RequiredRelated_OptionalNested_String" = NULL, + "RequiredRelated_RequiredNested_Id" = 0, + "RequiredRelated_RequiredNested_Int" = 80, + "RequiredRelated_RequiredNested_Name" = 'Updated nested name', + "RequiredRelated_RequiredNested_String" = 'Updated nested string' +"""); + } + + public override async Task Update_nested_association_to_inline_with_lambda() + { + await base.Update_nested_association_to_inline_with_lambda(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "RequiredRelated_RequiredNested_Id" = 0, + "RequiredRelated_RequiredNested_Int" = 80, + "RequiredRelated_RequiredNested_Name" = 'Updated nested name', + "RequiredRelated_RequiredNested_String" = 'Updated nested string' +"""); + } + + public override async Task Update_association_to_null() + { + await base.Update_association_to_null(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "OptionalRelated_Id" = NULL, + "OptionalRelated_Int" = NULL, + "OptionalRelated_Name" = NULL, + "OptionalRelated_String" = NULL, + "OptionalRelated_OptionalNested_Id" = NULL, + "OptionalRelated_OptionalNested_Int" = NULL, + "OptionalRelated_OptionalNested_Name" = NULL, + "OptionalRelated_OptionalNested_String" = NULL, + "OptionalRelated_RequiredNested_Id" = NULL, + "OptionalRelated_RequiredNested_Int" = NULL, + "OptionalRelated_RequiredNested_Name" = NULL, + "OptionalRelated_RequiredNested_String" = NULL +"""); + } + + public override async Task Update_association_to_null_with_lambda() + { + await base.Update_association_to_null_with_lambda(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "OptionalRelated_Id" = NULL, + "OptionalRelated_Int" = NULL, + "OptionalRelated_Name" = NULL, + "OptionalRelated_String" = NULL, + "OptionalRelated_OptionalNested_Id" = NULL, + "OptionalRelated_OptionalNested_Int" = NULL, + "OptionalRelated_OptionalNested_Name" = NULL, + "OptionalRelated_OptionalNested_String" = NULL, + "OptionalRelated_RequiredNested_Id" = NULL, + "OptionalRelated_RequiredNested_Int" = NULL, + "OptionalRelated_RequiredNested_Name" = NULL, + "OptionalRelated_RequiredNested_String" = NULL +"""); + } + + public override async Task Update_association_to_null_parameter() + { + await base.Update_association_to_null_parameter(); + + AssertExecuteUpdateSql( + """ +UPDATE "RootEntity" AS r +SET "OptionalRelated_Id" = NULL, + "OptionalRelated_Int" = NULL, + "OptionalRelated_Name" = NULL, + "OptionalRelated_String" = NULL, + "OptionalRelated_OptionalNested_Id" = NULL, + "OptionalRelated_OptionalNested_Int" = NULL, + "OptionalRelated_OptionalNested_Name" = NULL, + "OptionalRelated_OptionalNested_String" = NULL, + "OptionalRelated_RequiredNested_Id" = NULL, + "OptionalRelated_RequiredNested_Int" = NULL, + "OptionalRelated_RequiredNested_Name" = NULL, + "OptionalRelated_RequiredNested_String" = NULL +"""); + } + + #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_nested_collection_to_another_nested_collection() + { + await base.Update_nested_collection_to_another_nested_collection(); + + AssertExecuteUpdateSql( +); + } + + 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='?' +@p0='?' (DbType = Int32) + +UPDATE "RootEntity" AS r +SET "RequiredRelated_String" = @p, + "RequiredRelated_Int" = @p0 +"""); + } + + 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='?' + +UPDATE "RootEntity" AS r +SET "Name" = r."Name" || 'Modified', + "RequiredRelated_String" = r."OptionalRelated_String", + "OptionalRelated_RequiredNested_String" = @p +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='?' + +UPDATE "RootEntity" AS r +SET "RequiredRelated_String" = r."OptionalRelated_String", + "OptionalRelated_String" = @p +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.PG.FunctionalTests/StoreTypeNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/StoreTypeNpgsqlTest.cs new file mode 100644 index 0000000000..b5686259af --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/StoreTypeNpgsqlTest.cs @@ -0,0 +1,41 @@ +namespace Npgsql.EntityFrameworkCore.PostgreSQL; + +public class StoreTypeNpgsqlTest : StoreTypeRelationalTestBase +{ + public override Task DateTime_Unspecified() + => TestType( + new DateTime(2020, 1, 5, 12, 30, 45, DateTimeKind.Unspecified), + new DateTime(2022, 5, 3, 0, 0, 0, DateTimeKind.Unspecified), + onModelCreating: mb => mb.Entity>(b => + { + // The PG provider maps DateTime properties to 'timestamp with time zone' by default, which requires + // Kind=Utc. Map to 'timestamp without time zone'. + b.Property(e => e.Value).HasColumnType("timestamp without time zone"); + b.Property(e => e.OtherValue).HasColumnType("timestamp without time zone"); + b.ComplexProperty(e => e.Container).Property(c => c.Value).HasColumnType("timestamp without time zone"); + b.ComplexProperty(e => e.Container).Property(c => c.OtherValue).HasColumnType("timestamp without time zone"); + })); + + public override Task DateTime_Local() + => TestType( + new DateTime(2020, 1, 5, 12, 30, 45, DateTimeKind.Local), + new DateTime(2022, 5, 3, 0, 0, 0, DateTimeKind.Local), + onModelCreating: mb => mb.Entity>(b => + { + // The PG provider maps DateTime properties to 'timestamp with time zone' by default, which requires + // Kind=Utc. Map to 'timestamp without time zone'. + b.Property(e => e.Value).HasColumnType("timestamp without time zone"); + b.Property(e => e.OtherValue).HasColumnType("timestamp without time zone"); + b.ComplexProperty(e => e.Container).Property(c => c.Value).HasColumnType("timestamp without time zone"); + b.ComplexProperty(e => e.Container).Property(c => c.OtherValue).HasColumnType("timestamp without time zone"); + })); + + // PostgreSQL does not support persisting the offset, and so the provider accepts only DateTimeOffsets + // with offset zero. + public override Task DateTimeOffset() + => TestType( + new DateTimeOffset(2020, 1, 5, 12, 30, 45, TimeSpan.FromHours(0)), + new DateTimeOffset(2021, 1, 5, 12, 30, 45, TimeSpan.FromHours(0))); + + protected override ITestStoreFactory TestStoreFactory => NpgsqlTestStoreFactory.Instance; +}