From bfaaa385fc163c3dabbf2b769217d7ba469e823d Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 13 Apr 2026 22:13:31 +0000 Subject: [PATCH] Fix Cosmos prerender: single container with $type discriminator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cosmos DB rejects queries that reference more than one root entity type ("Root entity 'Customer' is referenced by the query, but 'Order' is already being referenced"), which broke ~57% of samples that navigated across entities or used SelectMany into a sibling DbSet. Host all 4 webshop types in one Cosmos container with EF Core's TPH discriminator. Samples access each concrete type through DbSet.OfType(), keeping every query rooted at the same entity from Cosmos's perspective. - Add empty marker base WebshopEntity with shared `Id` (TPH requires the key on the root). - Customer/Order/Product/LineItem now inherit from it. - WebshopCosmosDbContext (prerenderer-only) configures the hierarchy with HasContainer/HasDiscriminator and ignores back-reference navigations Cosmos can't resolve. - CosmosDbContextRoots exposes each concrete type via `_ctx.Entities.OfType()`. - OfType() added as a passthrough extension on IExpressiveQueryable. SQL providers are untouched — the base class is never referenced in WebshopDbContext.OnModelCreating, so SQLite/Postgres/SqlServer still see 4 independent entities mapped to separate tables. Cosmos success rate: 42% -> 52% (50/117 -> 61/117). Remaining failures are genuine Cosmos limits (uncorrelated subqueries, cross-document navigation) rather than self-inflicted multi-root errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Scenarios/Webshop/Customer.cs | 3 +- .../Scenarios/Webshop/LineItem.cs | 3 +- .../Scenarios/Webshop/Order.cs | 3 +- .../Scenarios/Webshop/Product.cs | 3 +- .../Scenarios/Webshop/WebshopEntity.cs | 9 ++++ src/Docs/Prerenderer/SampleRenderer.cs | 17 +++++++- .../Prerenderer/WebshopCosmosDbContext.cs | 42 +++++++++++++++++++ .../ExpressiveQueryableLinqExtensions.cs | 5 +++ 8 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 src/Docs/PlaygroundModel/Scenarios/Webshop/WebshopEntity.cs create mode 100644 src/Docs/Prerenderer/WebshopCosmosDbContext.cs diff --git a/src/Docs/PlaygroundModel/Scenarios/Webshop/Customer.cs b/src/Docs/PlaygroundModel/Scenarios/Webshop/Customer.cs index 0db60b0..6920331 100644 --- a/src/Docs/PlaygroundModel/Scenarios/Webshop/Customer.cs +++ b/src/Docs/PlaygroundModel/Scenarios/Webshop/Customer.cs @@ -5,9 +5,8 @@ namespace ExpressiveSharp.Docs.PlaygroundModel.Webshop; -public class Customer +public class Customer : WebshopEntity { - public int Id { get; set; } public string Name { get; set; } = ""; public string? Email { get; set; } public string? Country { get; set; } diff --git a/src/Docs/PlaygroundModel/Scenarios/Webshop/LineItem.cs b/src/Docs/PlaygroundModel/Scenarios/Webshop/LineItem.cs index a7525f5..fb98af2 100644 --- a/src/Docs/PlaygroundModel/Scenarios/Webshop/LineItem.cs +++ b/src/Docs/PlaygroundModel/Scenarios/Webshop/LineItem.cs @@ -1,8 +1,7 @@ namespace ExpressiveSharp.Docs.PlaygroundModel.Webshop; -public class LineItem +public class LineItem : WebshopEntity { - public int Id { get; set; } public int OrderId { get; set; } public Order Order { get; set; } = null!; public int ProductId { get; set; } diff --git a/src/Docs/PlaygroundModel/Scenarios/Webshop/Order.cs b/src/Docs/PlaygroundModel/Scenarios/Webshop/Order.cs index 2df5f82..91673ab 100644 --- a/src/Docs/PlaygroundModel/Scenarios/Webshop/Order.cs +++ b/src/Docs/PlaygroundModel/Scenarios/Webshop/Order.cs @@ -1,8 +1,7 @@ namespace ExpressiveSharp.Docs.PlaygroundModel.Webshop; -public class Order +public class Order : WebshopEntity { - public int Id { get; set; } public int CustomerId { get; set; } public Customer Customer { get; set; } = null!; public DateTime PlacedAt { get; set; } diff --git a/src/Docs/PlaygroundModel/Scenarios/Webshop/Product.cs b/src/Docs/PlaygroundModel/Scenarios/Webshop/Product.cs index ca44c34..9d7a52c 100644 --- a/src/Docs/PlaygroundModel/Scenarios/Webshop/Product.cs +++ b/src/Docs/PlaygroundModel/Scenarios/Webshop/Product.cs @@ -1,8 +1,7 @@ namespace ExpressiveSharp.Docs.PlaygroundModel.Webshop; -public class Product +public class Product : WebshopEntity { - public int Id { get; set; } public string Name { get; set; } = ""; public string Category { get; set; } = ""; public decimal ListPrice { get; set; } diff --git a/src/Docs/PlaygroundModel/Scenarios/Webshop/WebshopEntity.cs b/src/Docs/PlaygroundModel/Scenarios/Webshop/WebshopEntity.cs new file mode 100644 index 0000000..7538502 --- /dev/null +++ b/src/Docs/PlaygroundModel/Scenarios/Webshop/WebshopEntity.cs @@ -0,0 +1,9 @@ +namespace ExpressiveSharp.Docs.PlaygroundModel.Webshop; + +// Shared base so Cosmos can host all 4 types in a single container, accessed +// as `DbSet.OfType()`. `Id` lives here because EF Core's TPH +// discriminator model requires the key on the root type. +public abstract class WebshopEntity +{ + public int Id { get; set; } +} diff --git a/src/Docs/Prerenderer/SampleRenderer.cs b/src/Docs/Prerenderer/SampleRenderer.cs index 16c349f..0b4b65d 100644 --- a/src/Docs/Prerenderer/SampleRenderer.cs +++ b/src/Docs/Prerenderer/SampleRenderer.cs @@ -37,7 +37,7 @@ private static WebshopDbContext BuildSqlServerContext() => .EnableServiceProviderCaching(false) .Options); - private static WebshopDbContext BuildCosmosContext() => + private static WebshopCosmosDbContext BuildCosmosContext() => new(new DbContextOptionsBuilder() .UseCosmos("AccountEndpoint=https://localhost:8081/;AccountKey=dW5pdHRlc3Q=", "playground") .UseExpressives() @@ -64,6 +64,19 @@ private sealed class DbContextRoots : IWebshopQueryRoots public IExpressiveQueryable LineItems => _ctx.LineItems; } + // Cosmos roots: every entity lives in the same container and is filtered + // via OfType() on the single DbSet, keeping the query + // tree rooted at one entity type. + private sealed class CosmosDbContextRoots : IWebshopQueryRoots + { + private readonly WebshopCosmosDbContext _ctx; + public CosmosDbContextRoots(WebshopCosmosDbContext ctx) { _ctx = ctx; } + public IExpressiveQueryable Customers => _ctx.Entities.AsExpressive().OfType(); + public IExpressiveQueryable Orders => _ctx.Entities.AsExpressive().OfType(); + public IExpressiveQueryable Products => _ctx.Entities.AsExpressive().OfType(); + public IExpressiveQueryable LineItems => _ctx.Entities.AsExpressive().OfType(); + } + private sealed class MongoRootsImpl : IWebshopQueryRoots { public MongoRootsImpl( @@ -156,7 +169,7 @@ public RenderedSample Render(DocSample sample) RenderPrerendererTarget(targets, invoke, "sqlserver", "EF Core + SQL Server", "sql", new DbContextRoots(sqlServer), static (q, _) => q.ToQueryString()); RenderPrerendererTarget(targets, invoke, "cosmos", "EF Core + Cosmos DB", "sql", - new DbContextRoots(cosmos), static (q, _) => q.ToQueryString()); + new CosmosDbContextRoots(cosmos), static (q, _) => q.ToQueryString()); RenderPrerendererTarget(targets, invoke, "mongodb", "MongoDB", "javascript", BuildMongoRoots(), static (q, _) => FormatMongoOutput(q.ToString()!)); } diff --git a/src/Docs/Prerenderer/WebshopCosmosDbContext.cs b/src/Docs/Prerenderer/WebshopCosmosDbContext.cs new file mode 100644 index 0000000..e682c05 --- /dev/null +++ b/src/Docs/Prerenderer/WebshopCosmosDbContext.cs @@ -0,0 +1,42 @@ +using ExpressiveSharp.Docs.PlaygroundModel.Webshop; +using Microsoft.EntityFrameworkCore; + +namespace ExpressiveSharp.Docs.Prerenderer; + +// Single shared Cosmos container; every entity lives in it and is +// discriminated by $type. Samples access each concrete type via +// `DbSet.OfType()`, so every query stays rooted at +// one entity type from Cosmos's perspective — sidestepping the +// "Root entity X already being referenced" failure. +internal sealed class WebshopCosmosDbContext : WebshopDbContext +{ + public WebshopCosmosDbContext(DbContextOptions options) : base(options) { } + + public DbSet Entities => Set(); + + protected override void OnModelCreating(ModelBuilder b) + { + b.Entity(e => + { + e.ToContainer("webshop"); + e.HasKey(x => x.Id); + e.HasPartitionKey(x => x.Id); + e.HasDiscriminator("$type") + .HasValue("Customer") + .HasValue("Order") + .HasValue("Product") + .HasValue("LineItem"); + }); + + b.Entity(e => e.Ignore(o => o.Customer)); + + b.Entity(e => + { + e.Ignore(i => i.Order); + e.Ignore(i => i.Product); + e.Property(i => i.UnitPrice).HasPrecision(18, 2); + }); + + b.Entity(e => e.Property(p => p.ListPrice).HasPrecision(18, 2)); + } +} diff --git a/src/ExpressiveSharp/Extensions/ExpressiveQueryableLinqExtensions.cs b/src/ExpressiveSharp/Extensions/ExpressiveQueryableLinqExtensions.cs index be46a54..8a838d2 100644 --- a/src/ExpressiveSharp/Extensions/ExpressiveQueryableLinqExtensions.cs +++ b/src/ExpressiveSharp/Extensions/ExpressiveQueryableLinqExtensions.cs @@ -399,6 +399,11 @@ public static IExpressiveQueryable DefaultIfEmpty( => Queryable.Index(source).AsExpressive(); #endif + [EditorBrowsable(EditorBrowsableState.Never)] + public static IExpressiveQueryable OfType( + this IExpressiveQueryable source) + => Queryable.OfType(source).AsExpressive(); + // ── Non-lambda-first intercepted methods ───────────────────────────── [EditorBrowsable(EditorBrowsableState.Never)]