From 99b1a7b97ec7a8bb2646863c000b72dab788c03f Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Wed, 14 Oct 2026 21:17:48 +0200 Subject: [PATCH 1/2] [9.0] Preserve row value type mappings when rewriting expressions Backport #3953 to 9.0. Adapt the regression to EF 9 test APIs, nullable conventions, and query-filter parameter naming. (cherry picked from commit 8912932911889cb9146a49a4c9135aace65c7078) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79a22b3a-d5f9-4921-ac7b-3a1b41a9adc1 --- .../Internal/PgRowValueExpression.cs | 4 +- .../Query/AdHocQueryFiltersQueryNpgsqlTest.cs | 103 ++++++++++++++++++ .../Query/PgRowValueExpressionTest.cs | 32 ++++++ 3 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 test/EFCore.PG.Tests/Query/PgRowValueExpressionTest.cs diff --git a/src/EFCore.PG/Query/Expressions/Internal/PgRowValueExpression.cs b/src/EFCore.PG/Query/Expressions/Internal/PgRowValueExpression.cs index 0d0e6f3058..77b1797d8b 100644 --- a/src/EFCore.PG/Query/Expressions/Internal/PgRowValueExpression.cs +++ b/src/EFCore.PG/Query/Expressions/Internal/PgRowValueExpression.cs @@ -57,7 +57,7 @@ protected override Expression VisitChildren(ExpressionVisitor visitor) } } - return newRowValues is null ? this : new PgRowValueExpression(newRowValues, Type); + return newRowValues is null ? this : new PgRowValueExpression(newRowValues, Type, TypeMapping); } /// @@ -67,7 +67,7 @@ protected override Expression VisitChildren(ExpressionVisitor visitor) public virtual PgRowValueExpression Update(IReadOnlyList values) => values.Count == Values.Count && values.Zip(Values, (x, y) => (x, y)).All(tup => tup.x == tup.y) ? this - : new PgRowValueExpression(values, Type); + : new PgRowValueExpression(values, Type, TypeMapping); /// public override Expression Quote() diff --git a/test/EFCore.PG.FunctionalTests/Query/AdHocQueryFiltersQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/AdHocQueryFiltersQueryNpgsqlTest.cs index a6c0b0c17b..c7066c361a 100644 --- a/test/EFCore.PG.FunctionalTests/Query/AdHocQueryFiltersQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/AdHocQueryFiltersQueryNpgsqlTest.cs @@ -6,4 +6,107 @@ public class AdHocQueryFiltersQueryNpgsqlTest : AdHocQueryFiltersQueryRelational { protected override ITestStoreFactory TestStoreFactory => NpgsqlTestStoreFactory.Instance; + + [ConditionalTheory, MemberData(nameof(IsAsyncData))] + public virtual async Task Row_value_projection_over_filtered_navigation(bool async) + { +#pragma warning disable CS0618 // Type or member is obsolete + NpgsqlConnection.GlobalTypeMapper.EnableRecordsAsTuples(); +#pragma warning restore CS0618 // Type or member is obsolete + + var contextFactory = await InitializeAsync( + seed: async context => + { + context.Orders.Add(new Context3933.Order + { + Id = 1, + VendorLinks = + [ + new Context3933.VendorLink + { + Vendor = new Context3933.Vendor + { + OrgId = 1, + IsActive = true, + Representatives = [new Context3933.Representative { IsActive = true }] + } + } + ] + }); + await context.SaveChangesAsync(); + }); + + await using var context = contextFactory.CreateContext(); + + var query = context.Orders + .Where(x => x.Id == 1) + .Select(x => ValueTuple.Create( + x.Id, + x.VendorLinks.Any(vl => vl.Vendor!.IsActive && vl.Vendor.Representatives.Any(r => r.IsActive)))); + + var result = async ? await query.FirstOrDefaultAsync() : query.FirstOrDefault(); + + Assert.Equal((1, true), result); + + AssertSql( + """ +@__ef_filter__OrgId_0='1' + +SELECT (o."Id", EXISTS ( + SELECT 1 + FROM "VendorLink" AS v + LEFT JOIN ( + SELECT v0."Id", v0."IsActive" + FROM "Vendor" AS v0 + WHERE v0."OrgId" = @__ef_filter__OrgId_0 + ) AS v1 ON v."VendorId" = v1."Id" + WHERE o."Id" = v."OrderId" AND v1."IsActive" AND EXISTS ( + SELECT 1 + FROM "Representative" AS r + WHERE v1."Id" IS NOT NULL AND v1."Id" = r."VendorId" AND r."IsActive"))) +FROM "Orders" AS o +WHERE o."Id" = 1 +LIMIT 1 +"""); + } + + protected class Context3933(DbContextOptions options) : DbContext(options) + { + public short OrgId { get; set; } = 1; + public DbSet Orders => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().HasQueryFilter(x => x.OrgId == OrgId); + + public class Order + { + public int Id { get; set; } + public List VendorLinks { get; set; } = []; + } + + public class VendorLink + { + public int Id { get; set; } + public int OrderId { get; set; } + public int? VendorId { get; set; } + public Vendor Vendor { get; set; } + } + + public class Vendor + { + public int Id { get; set; } + public short OrgId { get; set; } + // Removing this unused property prevents the regression from reproducing. + public Guid VendorIdentifier { get; set; } + public bool IsActive { get; set; } + public List Representatives { get; set; } = []; + } + + public class Representative + { + public int Id { get; set; } + public int VendorId { get; set; } + public bool IsActive { get; set; } + } + } } diff --git a/test/EFCore.PG.Tests/Query/PgRowValueExpressionTest.cs b/test/EFCore.PG.Tests/Query/PgRowValueExpressionTest.cs new file mode 100644 index 0000000000..7b1499adc2 --- /dev/null +++ b/test/EFCore.PG.Tests/Query/PgRowValueExpressionTest.cs @@ -0,0 +1,32 @@ +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query; + +public class PgRowValueExpressionTest +{ + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Rewriting_preserves_type_mapping(bool useVisitor, bool changeValue) + { + var value = new SqlConstantExpression(1, IntTypeMapping.Default); + var replacement = changeValue ? new SqlConstantExpression(2, IntTypeMapping.Default) : value; + var typeMapping = new NpgsqlRowValueTypeMapping(typeof(ValueTuple)); + var row = new PgRowValueExpression([value], typeof(ValueTuple), typeMapping); + + var rewritten = useVisitor + ? (PgRowValueExpression)ReplacingExpressionVisitor.Replace(value, replacement, row) + : row.Update([replacement]); + + Assert.Same(typeMapping, rewritten.TypeMapping); + Assert.Same(replacement, Assert.Single(rewritten.Values)); + Assert.Equal(row.Type, rewritten.Type); + if (!changeValue) + { + Assert.Same(row, rewritten); + } + } +} From 9babd8ca6f5168f02300b18e7b68ba5581d1f358 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Wed, 14 Oct 2026 21:36:29 +0200 Subject: [PATCH 2/2] Configure tuple mapping locally for row value regression The global tuple mapping could be enabled after the query-filter test pool was already created, causing test-order-dependent tuple reads to fail. Configure it on the regression data source instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79a22b3a-d5f9-4921-ac7b-3a1b41a9adc1 --- .../Query/AdHocQueryFiltersQueryNpgsqlTest.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/EFCore.PG.FunctionalTests/Query/AdHocQueryFiltersQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/AdHocQueryFiltersQueryNpgsqlTest.cs index c7066c361a..266304fe69 100644 --- a/test/EFCore.PG.FunctionalTests/Query/AdHocQueryFiltersQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/AdHocQueryFiltersQueryNpgsqlTest.cs @@ -10,11 +10,10 @@ protected override ITestStoreFactory TestStoreFactory [ConditionalTheory, MemberData(nameof(IsAsyncData))] public virtual async Task Row_value_projection_over_filtered_navigation(bool async) { -#pragma warning disable CS0618 // Type or member is obsolete - NpgsqlConnection.GlobalTypeMapper.EnableRecordsAsTuples(); -#pragma warning restore CS0618 // Type or member is obsolete - var contextFactory = await InitializeAsync( + onConfiguring: options => options.UseNpgsql( + NpgsqlTestStore.CreateConnectionString(StoreName), + o => o.ConfigureDataSource(b => b.EnableRecordsAsTuples())), seed: async context => { context.Orders.Add(new Context3933.Order