From dd6f2b8d3afbafcf88e72f9a601e0128f6e1f43c Mon Sep 17 00:00:00 2001 From: Brandt Date: Mon, 9 Feb 2026 15:15:08 +1100 Subject: [PATCH 1/3] Added discriminator for TPH tables --- .../ForeignKeyExtractor.cs | 9 ++ .../Discriminator/DiscriminatorBaseEntity.cs | 6 + .../DiscriminatorDerivedAEntity.cs | 4 + .../DiscriminatorDerivedAGraphType.cs | 7 ++ .../DiscriminatorDerivedBEntity.cs | 4 + .../DiscriminatorDerivedBGraphType.cs | 7 ++ .../Graphs/Discriminator/DiscriminatorType.cs | 5 + .../IntegrationTests/IntegrationDbContext.cs | 16 +++ ...ded_in_query_field_projection.verified.txt | 25 +++++ ...ed_in_single_field_projection.verified.txt | 19 ++++ ...ns_correct_value_when_queried.verified.txt | 20 ++++ .../IntegrationTests.SchemaPrint.verified.txt | 23 ++++ ...ationTests_clr_discriminator_projection.cs | 105 ++++++++++++++++++ src/Tests/IntegrationTests/Query.cs | 16 +++ src/Tests/IntegrationTests/Schema.cs | 2 + 15 files changed, 268 insertions(+) create mode 100644 src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorBaseEntity.cs create mode 100644 src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedAEntity.cs create mode 100644 src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedAGraphType.cs create mode 100644 src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedBEntity.cs create mode 100644 src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedBGraphType.cs create mode 100644 src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorType.cs create mode 100644 src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_included_in_query_field_projection.verified.txt create mode 100644 src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_included_in_single_field_projection.verified.txt create mode 100644 src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_returns_correct_value_when_queried.verified.txt create mode 100644 src/Tests/IntegrationTests/IntegrationTests_clr_discriminator_projection.cs diff --git a/src/GraphQL.EntityFramework/ForeignKeyExtractor.cs b/src/GraphQL.EntityFramework/ForeignKeyExtractor.cs index 053e115c..ac006fa0 100644 --- a/src/GraphQL.EntityFramework/ForeignKeyExtractor.cs +++ b/src/GraphQL.EntityFramework/ForeignKeyExtractor.cs @@ -30,6 +30,15 @@ static IReadOnlySet GetForeignKeys(IEntityType entity) } } + // Include TPH discriminator property so projected entities maintain correct type identity. + // Without this, projected entities get the default discriminator value (e.g. enum value 0) + // instead of the actual value, causing downstream code that switches on the discriminator to fail. + var discriminator = entity.FindDiscriminatorProperty(); + if (discriminator != null) + { + foreignKeyNames.Add(discriminator.Name); + } + return foreignKeyNames; } } diff --git a/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorBaseEntity.cs b/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorBaseEntity.cs new file mode 100644 index 00000000..e5689eb5 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorBaseEntity.cs @@ -0,0 +1,6 @@ +public abstract class DiscriminatorBaseEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public DiscriminatorType EntityType { get; set; } + public string? Property { get; set; } +} diff --git a/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedAEntity.cs b/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedAEntity.cs new file mode 100644 index 00000000..1b41c34c --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedAEntity.cs @@ -0,0 +1,4 @@ +public class DiscriminatorDerivedAEntity : DiscriminatorBaseEntity +{ + public string? DerivedAProperty { get; set; } +} diff --git a/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedAGraphType.cs b/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedAGraphType.cs new file mode 100644 index 00000000..0d960ff5 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedAGraphType.cs @@ -0,0 +1,7 @@ +public class DiscriminatorDerivedAGraphType : + EfObjectGraphType +{ + public DiscriminatorDerivedAGraphType(IEfGraphQLService graphQlService) : + base(graphQlService) => + AutoMap(); +} diff --git a/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedBEntity.cs b/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedBEntity.cs new file mode 100644 index 00000000..6c169e7a --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedBEntity.cs @@ -0,0 +1,4 @@ +public class DiscriminatorDerivedBEntity : DiscriminatorBaseEntity +{ + public string? DerivedBProperty { get; set; } +} diff --git a/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedBGraphType.cs b/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedBGraphType.cs new file mode 100644 index 00000000..b8706afa --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorDerivedBGraphType.cs @@ -0,0 +1,7 @@ +public class DiscriminatorDerivedBGraphType : + EfObjectGraphType +{ + public DiscriminatorDerivedBGraphType(IEfGraphQLService graphQlService) : + base(graphQlService) => + AutoMap(); +} diff --git a/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorType.cs b/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorType.cs new file mode 100644 index 00000000..4360da8d --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/Discriminator/DiscriminatorType.cs @@ -0,0 +1,5 @@ +public enum DiscriminatorType +{ + TypeA, + TypeB +} diff --git a/src/Tests/IntegrationTests/IntegrationDbContext.cs b/src/Tests/IntegrationTests/IntegrationDbContext.cs index 5768523b..4f285c80 100644 --- a/src/Tests/IntegrationTests/IntegrationDbContext.cs +++ b/src/Tests/IntegrationTests/IntegrationDbContext.cs @@ -44,6 +44,9 @@ protected override void OnConfiguring(DbContextOptionsBuilder builder) => public DbSet FilterBaseEntities { get; set; } = null!; public DbSet FilterDerivedEntities { get; set; } = null!; public DbSet FilterReferenceEntities { get; set; } = null!; + public DbSet DiscriminatorBaseEntities { get; set; } = null!; + public DbSet DiscriminatorDerivedAEntities { get; set; } = null!; + public DbSet DiscriminatorDerivedBEntities { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -126,5 +129,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasBaseType(); modelBuilder.Entity() .OrderBy(_ => _.Property); + + // Configure TPH inheritance with CLR discriminator property for DiscriminatorBaseEntity hierarchy + modelBuilder.Entity() + .OrderBy(_ => _.Property); + modelBuilder.Entity() + .HasDiscriminator(_ => _.EntityType) + .HasValue(DiscriminatorType.TypeA) + .HasValue(DiscriminatorType.TypeB) + .IsComplete(); + modelBuilder.Entity() + .HasBaseType(); + modelBuilder.Entity() + .HasBaseType(); } } diff --git a/src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_included_in_query_field_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_included_in_query_field_projection.verified.txt new file mode 100644 index 00000000..b722305e --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_included_in_query_field_projection.verified.txt @@ -0,0 +1,25 @@ +{ + target: +{ + "data": { + "discriminatorDerivedAEntities": [ + { + "entityType": "TYPE_A", + "property": "First" + }, + { + "entityType": "TYPE_A", + "property": "Second" + } + ] + } +}, + sql: { + Text: +select d.Id, + d.EntityType, + d.Property +from DiscriminatorBaseEntities as d +where d.EntityType = 0 + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_included_in_single_field_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_included_in_single_field_projection.verified.txt new file mode 100644 index 00000000..4e175ac7 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_included_in_single_field_projection.verified.txt @@ -0,0 +1,19 @@ +{ + target: +{ + "data": { + "discriminatorDerivedAEntity": { + "property": "Value1" + } + } +}, + sql: { + Text: +select top (2) d.Id, + d.EntityType, + d.Property +from DiscriminatorBaseEntities as d +where d.EntityType = 0 + and d.Id = 'Guid_1' + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_returns_correct_value_when_queried.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_returns_correct_value_when_queried.verified.txt new file mode 100644 index 00000000..6847c7b1 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_returns_correct_value_when_queried.verified.txt @@ -0,0 +1,20 @@ +{ + target: +{ + "data": { + "discriminatorDerivedBEntity": { + "entityType": "TYPE_B", + "property": "ValueB" + } + } +}, + sql: { + Text: +select top (2) d.Id, + d.EntityType, + d.Property +from DiscriminatorBaseEntities as d +where d.EntityType = 1 + and d.Id = 'Guid_1' + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt index 0bfab633..3aa84bdb 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt @@ -158,6 +158,10 @@ filterReferenceEntities(id: ID, ids: [ID!], where: [WhereExpression!], orderBy: [OrderBy!], skip: Int, take: Int): [FilterReference!]! filterReferenceEntity(id: ID, ids: [ID!], where: [WhereExpression!]): FilterReference! filterReferenceEntityNullable(id: ID, ids: [ID!], where: [WhereExpression!]): FilterReference + discriminatorDerivedAEntities(id: ID, ids: [ID!], where: [WhereExpression!], orderBy: [OrderBy!], skip: Int, take: Int): [DiscriminatorDerivedA!]! + discriminatorDerivedAEntity(id: ID, ids: [ID!], where: [WhereExpression!]): DiscriminatorDerivedA! + discriminatorDerivedBEntities(id: ID, ids: [ID!], where: [WhereExpression!], orderBy: [OrderBy!], skip: Int, take: Int): [DiscriminatorDerivedB!]! + discriminatorDerivedBEntity(id: ID, ids: [ID!], where: [WhereExpression!]): DiscriminatorDerivedB! } type CustomType { @@ -757,6 +761,25 @@ type FilterBase { id: ID! } +type DiscriminatorDerivedA { + derivedAProperty: String + entityType: DiscriminatorType! + id: ID! + property: String +} + +enum DiscriminatorType { + TYPE_A + TYPE_B +} + +type DiscriminatorDerivedB { + derivedBProperty: String + entityType: DiscriminatorType! + id: ID! + property: String +} + type Mutation { parentEntityMutation(id: ID, ids: [ID!], where: [WhereExpression!]): Parent! } diff --git a/src/Tests/IntegrationTests/IntegrationTests_clr_discriminator_projection.cs b/src/Tests/IntegrationTests/IntegrationTests_clr_discriminator_projection.cs new file mode 100644 index 00000000..984cb988 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests_clr_discriminator_projection.cs @@ -0,0 +1,105 @@ +public partial class IntegrationTests +{ + /// + /// Verifies that when querying a concrete derived type in a TPH hierarchy with a CLR + /// discriminator property, the discriminator column is included in the SQL SELECT projection + /// even when it is not explicitly requested in the GraphQL query. + /// + [Fact] + public async Task CLR_discriminator_included_in_single_field_projection() + { + var entity = new DiscriminatorDerivedAEntity + { + Property = "Value1", + DerivedAProperty = "DerivedA1" + }; + + var query = + $$""" + { + discriminatorDerivedAEntity(id: "{{entity.Id}}") + { + property + } + } + """; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity]); + } + + /// + /// Verifies that the CLR discriminator property returns the correct enum value + /// when explicitly requested in the GraphQL query, not the default enum value. + /// Without the fix, the projected entity would have EntityType = TypeA (default 0) + /// regardless of the actual discriminator value. + /// + [Fact] + public async Task CLR_discriminator_returns_correct_value_when_queried() + { + var entityA = new DiscriminatorDerivedAEntity + { + Property = "ValueA", + DerivedAProperty = "DerivedA" + }; + var entityB = new DiscriminatorDerivedBEntity + { + Property = "ValueB", + DerivedBProperty = "DerivedB" + }; + + var query = + $$""" + { + discriminatorDerivedBEntity(id: "{{entityB.Id}}") + { + entityType + property + } + } + """; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entityA, entityB]); + } + + /// + /// Verifies that the CLR discriminator column is included in projection when querying + /// a list of derived entities, ensuring all returned entities have the correct + /// discriminator value. + /// + [Fact] + public async Task CLR_discriminator_included_in_query_field_projection() + { + var entity1 = new DiscriminatorDerivedAEntity + { + Property = "First", + DerivedAProperty = "DerivedA1" + }; + var entity2 = new DiscriminatorDerivedAEntity + { + Property = "Second", + DerivedAProperty = "DerivedA2" + }; + // This TypeB entity should not appear in TypeA query results + var entity3 = new DiscriminatorDerivedBEntity + { + Property = "Third", + DerivedBProperty = "DerivedB1" + }; + + var query = + """ + { + discriminatorDerivedAEntities + { + entityType + property + } + } + """; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity1, entity2, entity3]); + } +} diff --git a/src/Tests/IntegrationTests/Query.cs b/src/Tests/IntegrationTests/Query.cs index 573fbd05..fc89ad58 100644 --- a/src/Tests/IntegrationTests/Query.cs +++ b/src/Tests/IntegrationTests/Query.cs @@ -365,5 +365,21 @@ public Query(IEfGraphQLService efGraphQlService) name: "filterReferenceEntityNullable", resolve: _ => _.DbContext.FilterReferenceEntities, nullable: true); + + AddQueryField( + name: "discriminatorDerivedAEntities", + resolve: _ => _.DbContext.DiscriminatorDerivedAEntities); + + AddSingleField( + name: "discriminatorDerivedAEntity", + resolve: _ => _.DbContext.DiscriminatorDerivedAEntities); + + AddQueryField( + name: "discriminatorDerivedBEntities", + resolve: _ => _.DbContext.DiscriminatorDerivedBEntities); + + AddSingleField( + name: "discriminatorDerivedBEntity", + resolve: _ => _.DbContext.DiscriminatorDerivedBEntities); } } diff --git a/src/Tests/IntegrationTests/Schema.cs b/src/Tests/IntegrationTests/Schema.cs index be54b25a..056cfc05 100644 --- a/src/Tests/IntegrationTests/Schema.cs +++ b/src/Tests/IntegrationTests/Schema.cs @@ -44,6 +44,8 @@ public Schema(IServiceProvider resolver) : RegisterTypeMapping(typeof(FilterReferenceEntity), typeof(FilterReferenceGraphType)); RegisterTypeMapping(typeof(FilterBaseEntity), typeof(FilterBaseGraphType)); RegisterTypeMapping(typeof(FilterDerivedEntity), typeof(FilterDerivedGraphType)); + RegisterTypeMapping(typeof(DiscriminatorDerivedAEntity), typeof(DiscriminatorDerivedAGraphType)); + RegisterTypeMapping(typeof(DiscriminatorDerivedBEntity), typeof(DiscriminatorDerivedBGraphType)); Query = (Query)resolver.GetService(typeof(Query))!; Mutation = (Mutation)resolver.GetService(typeof(Mutation))!; RegisterType(typeof(DerivedGraphType)); From b8695f11864021d3e6700ecde437c4fe8f8dce77 Mon Sep 17 00:00:00 2001 From: Brandt Date: Mon, 9 Feb 2026 15:16:40 +1100 Subject: [PATCH 2/3] increment version --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index d80d4862..f9fdd784 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;NU5104;CS1573;CS9107;NU1608;NU1109 - 34.2.0-beta.6 + 34.2.0-beta.7 preview 1.0.0 EntityFrameworkCore, EntityFramework, GraphQL From 8465e50c19e42f190a45942e53703f07e306c4e0 Mon Sep 17 00:00:00 2001 From: Brandt Date: Mon, 9 Feb 2026 15:32:18 +1100 Subject: [PATCH 3/3] added correct ordering --- src/Tests/IntegrationTests/IntegrationDbContext.cs | 8 ++++---- ...or_included_in_query_field_projection.verified.txt | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Tests/IntegrationTests/IntegrationDbContext.cs b/src/Tests/IntegrationTests/IntegrationDbContext.cs index 4f285c80..caa22762 100644 --- a/src/Tests/IntegrationTests/IntegrationDbContext.cs +++ b/src/Tests/IntegrationTests/IntegrationDbContext.cs @@ -131,16 +131,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OrderBy(_ => _.Property); // Configure TPH inheritance with CLR discriminator property for DiscriminatorBaseEntity hierarchy - modelBuilder.Entity() - .OrderBy(_ => _.Property); modelBuilder.Entity() .HasDiscriminator(_ => _.EntityType) .HasValue(DiscriminatorType.TypeA) .HasValue(DiscriminatorType.TypeB) .IsComplete(); modelBuilder.Entity() - .HasBaseType(); + .HasBaseType() + .OrderBy(_ => _.Property); modelBuilder.Entity() - .HasBaseType(); + .HasBaseType() + .OrderBy(_ => _.Property); } } diff --git a/src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_included_in_query_field_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_included_in_query_field_projection.verified.txt index b722305e..15921da3 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_included_in_query_field_projection.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.CLR_discriminator_included_in_query_field_projection.verified.txt @@ -16,10 +16,11 @@ }, sql: { Text: -select d.Id, - d.EntityType, - d.Property -from DiscriminatorBaseEntities as d -where d.EntityType = 0 +select d.Id, + d.EntityType, + d.Property +from DiscriminatorBaseEntities as d +where d.EntityType = 0 +order by d.Property } } \ No newline at end of file