diff --git a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs index 85a7b2b1822..03c4552716c 100644 --- a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs +++ b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs @@ -3252,7 +3252,7 @@ private void AddJoin( // If this is a to-one join, then we know that it doesn't increase cardinality — the outer identifiers are already sufficient. // Skipping the inner's identifiers avoids unnecessary ORDER BY columns and projections (#29182). - if (_identifier.Count == 0 || innerSelect._identifier.Count == 0) + if (_identifier.Count == 0 || (innerSelect._identifier.Count == 0 && !isToOneJoin)) { // Either the outer and inner sides aren't uniquely identifiable; mark everything as non-uniquely-identifiable. _identifier.Clear(); diff --git a/test/EFCore.Relational.Specification.Tests/Query/AdHocQuerySplittingQueryTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/AdHocQuerySplittingQueryTestBase.cs index 1ecda88f408..db01fbbd73f 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/AdHocQuerySplittingQueryTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/AdHocQuerySplittingQueryTestBase.cs @@ -622,4 +622,145 @@ public sealed class Tag } #endregion + + #region 38660 + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public virtual async Task To_one_join_with_keyless_inner_preserves_outer_collection_identifiers(bool async, bool splitQuery) + { + var contextFactory = await InitializeNonSharedTest(seed: c => c.SeedAsync()); + + using var context = contextFactory.CreateDbContext(); + var pivot = context.TaxesA + .Select(t => new Context38660.TaxRow + { + OrderId = t.OrderId, + LineNo = t.LineNo, + Kind = t.Kind, + }) + .Concat( + context.TaxesB.Select(t => new Context38660.TaxRow + { + OrderId = t.OrderId, + LineNo = t.LineNo, + Kind = t.Kind, + })); + + var query = context.Orders.Select(o => new Context38660.OrderDto + { + Lines = o.Lines.Select(l => new Context38660.LineDto + { + LineNo = l.LineNo, + Tax = pivot + .Where(r => r.OrderId == l.OrderId && r.LineNo == l.LineNo) + .Select(r => new Context38660.TaxDto { Kind = r.Kind }) + .FirstOrDefault(), + }).ToList(), + }); + + query = splitQuery ? query.AsSplitQuery() : query.AsSingleQuery(); + var result = async ? await query.SingleAsync() : query.Single(); + + Assert.Collection( + result.Lines.OrderBy(l => l.LineNo), + line => + { + Assert.Equal(1, line.LineNo); + Assert.Equal(10, line.Tax.Kind); + }, + line => + { + Assert.Equal(2, line.LineNo); + Assert.Equal(20, line.Tax.Kind); + }); + } + + protected class Context38660(DbContextOptions options) : DbContext(options) + { + public DbSet Orders { get; set; } + public DbSet TaxesA { get; set; } + public DbSet TaxesB { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Property(o => o.Id).ValueGeneratedNever(); + modelBuilder.Entity().HasKey(l => new { l.OrderId, l.LineNo }); + modelBuilder.Entity().HasMany(o => o.Lines).WithOne().HasForeignKey(l => l.OrderId); + } + + public Task SeedAsync() + { + Add( + new Order + { + Id = 1, + Lines = + [ + new OrderLine { OrderId = 1, LineNo = 1 }, + new OrderLine { OrderId = 1, LineNo = 2 }, + ], + }); + Add(new TaxA { OrderId = 1, LineNo = 1, Kind = 10 }); + Add(new TaxB { OrderId = 1, LineNo = 2, Kind = 20 }); + + return SaveChangesAsync(); + } + + public class Order + { + public int Id { get; set; } + public List Lines { get; set; } = []; + } + + public class OrderLine + { + public int OrderId { get; set; } + public int LineNo { get; set; } + } + + public class TaxA + { + public int Id { get; set; } + public int OrderId { get; set; } + public int LineNo { get; set; } + public int Kind { get; set; } + } + + public class TaxB + { + public int Id { get; set; } + public int OrderId { get; set; } + public int LineNo { get; set; } + public int Kind { get; set; } + } + + public class TaxRow + { + public int OrderId { get; init; } + public int LineNo { get; init; } + public int Kind { get; init; } + } + + public class OrderDto + { + public List Lines { get; set; } = []; + } + + public class LineDto + { + public int LineNo { get; set; } + public TaxDto Tax { get; set; } + } + + public class TaxDto + { + public int Kind { get; set; } + } + } + + #endregion } diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/AdHocQuerySplittingQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/AdHocQuerySplittingQuerySqlServerTest.cs index 97e935885c2..350220f2ebd 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/AdHocQuerySplittingQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/AdHocQuerySplittingQuerySqlServerTest.cs @@ -48,6 +48,69 @@ protected override TestStore CreateTestStore25225() return testStore; } + public override async Task To_one_join_with_keyless_inner_preserves_outer_collection_identifiers(bool async, bool splitQuery) + { + await base.To_one_join_with_keyless_inner_preserves_outer_collection_identifiers(async, splitQuery); + + if (splitQuery) + { + AssertSql( + """ +SELECT [o].[Id] +FROM [Orders] AS [o] +ORDER BY [o].[Id] +""", + // + """ +SELECT [o2].[LineNo], [u9].[Kind], [u9].[c], [o].[Id] +FROM [Orders] AS [o] +INNER JOIN [OrderLine] AS [o2] ON [o].[Id] = [o2].[OrderId] +LEFT JOIN ( + SELECT [u8].[Kind], [u8].[c], [u8].[OrderId], [u8].[LineNo] + FROM ( + SELECT [u7].[Kind], 1 AS [c], [u7].[OrderId], [u7].[LineNo], ROW_NUMBER() OVER(PARTITION BY [u7].[OrderId], [u7].[LineNo] ORDER BY (SELECT 1)) AS [row] + FROM ( + SELECT [t15].[OrderId], [t15].[LineNo], [t15].[Kind] + FROM [TaxesA] AS [t15] + UNION ALL + SELECT [t16].[OrderId], [t16].[LineNo], [t16].[Kind] + FROM [TaxesB] AS [t16] + ) AS [u7] + ) AS [u8] + WHERE [u8].[row] <= 1 +) AS [u9] ON [o2].[OrderId] = [u9].[OrderId] AND [o2].[LineNo] = [u9].[LineNo] +ORDER BY [o].[Id] +"""); + } + else + { + AssertSql( + """ +SELECT [o].[Id], [s].[LineNo], [s].[Kind], [s].[c], [s].[OrderId] +FROM [Orders] AS [o] +LEFT JOIN ( + SELECT [o0].[LineNo], [u1].[Kind], [u1].[c], [o0].[OrderId] + FROM [OrderLine] AS [o0] + LEFT JOIN ( + SELECT [u0].[Kind], [u0].[c], [u0].[OrderId], [u0].[LineNo] + FROM ( + SELECT [u].[Kind], 1 AS [c], [u].[OrderId], [u].[LineNo], ROW_NUMBER() OVER(PARTITION BY [u].[OrderId], [u].[LineNo] ORDER BY (SELECT 1)) AS [row] + FROM ( + SELECT [t].[OrderId], [t].[LineNo], [t].[Kind] + FROM [TaxesA] AS [t] + UNION ALL + SELECT [t0].[OrderId], [t0].[LineNo], [t0].[Kind] + FROM [TaxesB] AS [t0] + ) AS [u] + ) AS [u0] + WHERE [u0].[row] <= 1 + ) AS [u1] ON [o0].[OrderId] = [u1].[OrderId] AND [o0].[LineNo] = [u1].[LineNo] +) AS [s] ON [o].[Id] = [s].[OrderId] +ORDER BY [o].[Id], [s].[OrderId] +"""); + } + } + public override async Task Can_configure_SingleQuery_at_context_level() { await base.Can_configure_SingleQuery_at_context_level();