diff --git a/src/GraphQL.EntityFramework/IncludeAppender.cs b/src/GraphQL.EntityFramework/IncludeAppender.cs index 8f77ed65..0e937aff 100644 --- a/src/GraphQL.EntityFramework/IncludeAppender.cs +++ b/src/GraphQL.EntityFramework/IncludeAppender.cs @@ -1,3 +1,5 @@ +using System.Reflection; + class IncludeAppender( IReadOnlyDictionary> navigations, IReadOnlyDictionary> keyNames, @@ -64,29 +66,85 @@ static IQueryable AddIncludesFromProjection( FieldProjectionInfo projection) where TItem : class { - if (projection.Navigations is not { Count: > 0 }) + var visitedTypes = new HashSet { typeof(TItem) }; + + if (projection.Navigations is { Count: > 0 }) { - return query; + foreach (var (navName, navProjection) in projection.Navigations) + { + if (IsVisitedOrBaseType(navProjection.EntityType, visitedTypes)) + { + continue; + } + + visitedTypes.Add(navProjection.EntityType); + query = query.Include(navName); + query = AddNestedIncludes(query, navName, navProjection.Projection, visitedTypes); + visitedTypes.Remove(navProjection.EntityType); + } } - var visitedTypes = new HashSet { typeof(TItem) }; + // Add derived-type navigation includes for TPH inline fragments + // e.g. query.Include(e => ((GroupAccessRule)e).Group) + if (projection.DerivedNavigations is { Count: > 0 }) + { + query = AddDerivedTypeIncludes(query, projection.DerivedNavigations, visitedTypes); + } - foreach (var (navName, navProjection) in projection.Navigations) + return query; + } + + static IQueryable AddDerivedTypeIncludes( + IQueryable query, + Dictionary> derivedNavigations, + HashSet visitedTypes) + where TItem : class + { + var itemType = typeof(TItem); + var parameter = Expression.Parameter(itemType, "e"); + + foreach (var (derivedType, navDict) in derivedNavigations) { - if (IsVisitedOrBaseType(navProjection.EntityType, visitedTypes)) + // Cast: (DerivedType)e + var cast = Expression.Convert(parameter, derivedType); + + foreach (var (navName, navProjection) in navDict) { - continue; - } + if (IsVisitedOrBaseType(navProjection.EntityType, visitedTypes)) + { + continue; + } - visitedTypes.Add(navProjection.EntityType); - query = query.Include(navName); - query = AddNestedIncludes(query, navName, navProjection.Projection, visitedTypes); - visitedTypes.Remove(navProjection.EntityType); + // Property access: ((DerivedType)e).Navigation + var property = derivedType.GetProperty(navName); + if (property == null) + { + continue; + } + + var propertyAccess = Expression.Property(cast, property); + + // Build lambda: e => ((DerivedType)e).Navigation + var lambda = Expression.Lambda(propertyAccess, parameter); + + // Call EntityFrameworkQueryableExtensions.Include(query, lambda) + var includeMethod = GetIncludeMethod(itemType, property.PropertyType); + query = (IQueryable)includeMethod.Invoke(null, [query, lambda])!; + } } return query; } + static MethodInfo GetIncludeMethod(Type entityType, Type propertyType) => + typeof(EntityFrameworkQueryableExtensions) + .GetMethods(BindingFlags.Static | BindingFlags.Public) + .First(_ => _.Name == "Include" && + _.GetGenericArguments().Length == 2 && + _.GetParameters().Length == 2 && + _.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>)) + .MakeGenericMethod(entityType, propertyType); + static IQueryable AddNestedIncludes( IQueryable query, string includePath, @@ -180,7 +238,195 @@ FieldProjectionInfo GetProjectionInfo( } } - return new(scalarFields, keys, foreignKeyNames, navProjections); + // Scan for derived-type navigations from inline fragments (TPH support) + var derivedNavigations = GetDerivedNavigationsFromFragments(context); + + return new(scalarFields, keys, foreignKeyNames, navProjections, derivedNavigations); + } + + Dictionary>? GetDerivedNavigationsFromFragments( + IResolveFieldContext context) + { + var selectionSet = GetLeafSelectionSet(context); + if (selectionSet?.Selections is null) + { + return null; + } + + Dictionary>? result = null; + + foreach (var selection in selectionSet.Selections) + { + GraphQLTypeCondition? typeCondition; + GraphQLSelectionSet? fragmentSelectionSet; + + switch (selection) + { + case GraphQLInlineFragment inlineFragment: + typeCondition = inlineFragment.TypeCondition; + fragmentSelectionSet = inlineFragment.SelectionSet; + break; + case GraphQLFragmentSpread fragmentSpread: + { + var name = fragmentSpread.FragmentName.Name; + var fragmentDefinition = context.Document.Definitions + .OfType() + .SingleOrDefault(_ => _.FragmentName.Name == name); + if (fragmentDefinition is null) + { + continue; + } + + typeCondition = fragmentDefinition.TypeCondition; + fragmentSelectionSet = fragmentDefinition.SelectionSet; + break; + } + default: + continue; + } + + if (typeCondition is null || fragmentSelectionSet?.Selections is null) + { + continue; + } + + var typeName = typeCondition.Type.Name.StringValue; + + // Find the CLR type for this GraphQL type name using the schema + if (!TryFindDerivedClrType(typeName, context.Schema, out var derivedType)) + { + continue; + } + + // Get navigation properties for this derived type + if (!navigations.TryGetValue(derivedType, out var derivedNavProps)) + { + continue; + } + + // Process fields in this fragment against the derived type's navigation properties + foreach (var field in fragmentSelectionSet.Selections.OfType()) + { + var fieldName = field.Name.StringValue; + if (!derivedNavProps.TryGetValue(fieldName, out var navigation)) + { + continue; + } + + result ??= []; + if (!result.TryGetValue(derivedType, out var derivedNavs)) + { + derivedNavs = []; + result[derivedType] = derivedNavs; + } + + if (derivedNavs.ContainsKey(navigation.Name)) + { + continue; + } + + var navType = navigation.Type; + navigations.TryGetValue(navType, out var nestedNavProps); + keyNames.TryGetValue(navType, out var nestedKeys); + foreignKeys.TryGetValue(navType, out var nestedFks); + + derivedNavs[navigation.Name] = new( + navType, + navigation.IsCollection, + GetNestedProjection(field.SelectionSet, nestedNavProps, nestedKeys, nestedFks, context)); + } + } + + return result; + } + + /// + /// Navigate through connection wrapper fields (edges/items/node) to find the leaf selection set + /// that contains the actual entity fields and inline fragments. + /// + static GraphQLSelectionSet? GetLeafSelectionSet(IResolveFieldContext context) + { + var selectionSet = context.FieldAst.SelectionSet; + if (selectionSet?.Selections is null) + { + return null; + } + + // Drill through connection wrapper fields + while (true) + { + var found = false; + foreach (var selection in selectionSet.Selections) + { + if (selection is GraphQLField field && IsConnectionNodeName(field.Name.StringValue)) + { + if (field.SelectionSet is not null) + { + selectionSet = field.SelectionSet; + found = true; + break; + } + } + } + + if (!found) + { + break; + } + } + + return selectionSet; + } + + bool TryFindDerivedClrType(string graphQlTypeName, ISchema schema, [NotNullWhen(true)] out Type? clrType) + { + clrType = null; + + // Use the schema's type lookup to resolve GraphQL type name → CLR type + var graphType = schema.AllTypes.FirstOrDefault(_ => _.Name == graphQlTypeName); + if (graphType is not null) + { + // Walk the type hierarchy to find the CLR type from the generic arguments + var graphClrType = GetSourceType(graphType.GetType()); + if (graphClrType is not null && navigations.ContainsKey(graphClrType)) + { + clrType = graphClrType; + return true; + } + } + + // Fallback: match CLR type name directly + foreach (var type in navigations.Keys) + { + if (string.Equals(type.Name, graphQlTypeName, StringComparison.OrdinalIgnoreCase)) + { + clrType = type; + return true; + } + } + + return false; + } + + static Type? GetSourceType(Type graphType) + { + var type = graphType; + while (type is not null) + { + if (type.IsGenericType) + { + var genericDef = type.GetGenericTypeDefinition(); + if (genericDef == typeof(ObjectGraphType<>) || + genericDef == typeof(InterfaceGraphType<>)) + { + return type.GenericTypeArguments[0]; + } + } + + type = type.BaseType; + } + + return null; } void ProcessConnectionNodeFields( diff --git a/src/GraphQL.EntityFramework/SelectProjection/FieldProjectionInfo.cs b/src/GraphQL.EntityFramework/SelectProjection/FieldProjectionInfo.cs index a1cb1f62..2ca8959b 100644 --- a/src/GraphQL.EntityFramework/SelectProjection/FieldProjectionInfo.cs +++ b/src/GraphQL.EntityFramework/SelectProjection/FieldProjectionInfo.cs @@ -2,4 +2,5 @@ record FieldProjectionInfo( HashSet ScalarFields, List? KeyNames, IReadOnlySet? ForeignKeyNames, - Dictionary? Navigations); + Dictionary? Navigations, + Dictionary>? DerivedNavigations = null); diff --git a/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/CategoryEntity.cs b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/CategoryEntity.cs new file mode 100644 index 00000000..ae297d5d --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/CategoryEntity.cs @@ -0,0 +1,5 @@ +public class CategoryEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string? Name { get; set; } +} diff --git a/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/CategoryGraphType.cs b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/CategoryGraphType.cs new file mode 100644 index 00000000..db784c76 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/CategoryGraphType.cs @@ -0,0 +1,7 @@ +public class CategoryGraphType : + EfObjectGraphType +{ + public CategoryGraphType(IEfGraphQLService graphQlService) : + base(graphQlService) => + AutoMap(); +} diff --git a/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/RegionEntity.cs b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/RegionEntity.cs new file mode 100644 index 00000000..ca606aab --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/RegionEntity.cs @@ -0,0 +1,5 @@ +public class RegionEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string? Name { get; set; } +} diff --git a/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/RegionGraphType.cs b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/RegionGraphType.cs new file mode 100644 index 00000000..5013a760 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/RegionGraphType.cs @@ -0,0 +1,7 @@ +public class RegionGraphType : + EfObjectGraphType +{ + public RegionGraphType(IEfGraphQLService graphQlService) : + base(graphQlService) => + AutoMap(); +} diff --git a/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavBaseEntity.cs b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavBaseEntity.cs new file mode 100644 index 00000000..dee04a90 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavBaseEntity.cs @@ -0,0 +1,5 @@ +public abstract class TphDerivedNavBaseEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string? Property { get; set; } +} diff --git a/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavBaseGraphType.cs b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavBaseGraphType.cs new file mode 100644 index 00000000..3fa7ddbd --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavBaseGraphType.cs @@ -0,0 +1,2 @@ +public class TphDerivedNavBaseGraphType(IEfGraphQLService graphQlService) : + EfInterfaceGraphType(graphQlService); diff --git a/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavCategoryEntity.cs b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavCategoryEntity.cs new file mode 100644 index 00000000..52072fb1 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavCategoryEntity.cs @@ -0,0 +1,5 @@ +public class TphDerivedNavCategoryEntity : TphDerivedNavBaseEntity +{ + public Guid? CategoryId { get; set; } + public CategoryEntity? Category { get; set; } +} diff --git a/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavCategoryGraphType.cs b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavCategoryGraphType.cs new file mode 100644 index 00000000..1d918410 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavCategoryGraphType.cs @@ -0,0 +1,11 @@ +public class TphDerivedNavCategoryGraphType : + EfObjectGraphType +{ + public TphDerivedNavCategoryGraphType(IEfGraphQLService graphQlService) : + base(graphQlService) + { + AutoMap(); + Interface(); + IsTypeOf = _ => _ is TphDerivedNavCategoryEntity; + } +} diff --git a/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavRegionEntity.cs b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavRegionEntity.cs new file mode 100644 index 00000000..09f7c903 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavRegionEntity.cs @@ -0,0 +1,5 @@ +public class TphDerivedNavRegionEntity : TphDerivedNavBaseEntity +{ + public Guid? RegionId { get; set; } + public RegionEntity? Region { get; set; } +} diff --git a/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavRegionGraphType.cs b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavRegionGraphType.cs new file mode 100644 index 00000000..8a3fe2f1 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/TphDerivedNavigation/TphDerivedNavRegionGraphType.cs @@ -0,0 +1,11 @@ +public class TphDerivedNavRegionGraphType : + EfObjectGraphType +{ + public TphDerivedNavRegionGraphType(IEfGraphQLService graphQlService) : + base(graphQlService) + { + AutoMap(); + Interface(); + IsTypeOf = _ => _ is TphDerivedNavRegionEntity; + } +} diff --git a/src/Tests/IntegrationTests/IntegrationDbContext.cs b/src/Tests/IntegrationTests/IntegrationDbContext.cs index b03f8bed..56f3786d 100644 --- a/src/Tests/IntegrationTests/IntegrationDbContext.cs +++ b/src/Tests/IntegrationTests/IntegrationDbContext.cs @@ -51,6 +51,9 @@ protected override void OnConfiguring(DbContextOptionsBuilder builder) => public DbSet TphMiddleEntities { get; set; } = null!; public DbSet TphLeafEntities { get; set; } = null!; public DbSet TphAttachmentEntities { get; set; } = null!; + public DbSet TphDerivedNavBaseEntities { get; set; } = null!; + public DbSet CategoryEntities { get; set; } = null!; + public DbSet RegionEntities { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -156,5 +159,23 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasBaseType(); modelBuilder.Entity() .OrderBy(_ => _.Property); + + // Configure TPH inheritance with derived-type-specific navigations + modelBuilder.Entity() + .OrderBy(_ => _.Property); + modelBuilder.Entity() + .HasBaseType() + .HasOne(_ => _.Category) + .WithMany() + .HasForeignKey(_ => _.CategoryId); + modelBuilder.Entity() + .HasBaseType() + .HasOne(_ => _.Region) + .WithMany() + .HasForeignKey(_ => _.RegionId); + modelBuilder.Entity() + .OrderBy(_ => _.Name); + modelBuilder.Entity() + .OrderBy(_ => _.Name); } } diff --git a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt index a3252094..e3ae9161 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt @@ -164,6 +164,8 @@ discriminatorDerivedBEntity(id: ID, ids: [ID!], where: [WhereExpression!]): DiscriminatorDerivedB! tphMiddleEntities(id: ID, ids: [ID!], where: [WhereExpression!], orderBy: [OrderBy!], skip: Int, take: Int): [TphMiddleEntity]! tphMiddleEntity(id: ID, ids: [ID!], where: [WhereExpression!]): TphMiddleEntity + tphDerivedNavEntities(id: ID, ids: [ID!], where: [WhereExpression!], orderBy: [OrderBy!], skip: Int, take: Int): [TphDerivedNavBaseEntity]! + tphDerivedNavEntity(id: ID, ids: [ID!], where: [WhereExpression!]): TphDerivedNavBaseEntity } type CustomType { @@ -795,6 +797,11 @@ type TphAttachment { requestId: ID! } +interface TphDerivedNavBaseEntity { + id: ID! + property: String +} + type Mutation { parentEntityMutation(id: ID, ids: [ID!], where: [WhereExpression!]): Parent! } @@ -854,3 +861,27 @@ type TphLeaf implements TphMiddleEntity { leafProperty: String property: String } + +type TphDerivedNavCategory implements TphDerivedNavBaseEntity { + category: Category + categoryId: ID + id: ID! + property: String +} + +type Category { + id: ID! + name: String +} + +type TphDerivedNavRegion implements TphDerivedNavBaseEntity { + region: Region + id: ID! + property: String + regionId: ID +} + +type Region { + id: ID! + name: String +} diff --git a/src/Tests/IntegrationTests/IntegrationTests.Tph_derived_navigation_base_fields_still_work.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Tph_derived_navigation_base_fields_still_work.verified.txt new file mode 100644 index 00000000..b2dd9940 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Tph_derived_navigation_base_fields_still_work.verified.txt @@ -0,0 +1,24 @@ +{ + target: { + Data: { + tphDerivedNavEntities: [ + { + property: BaseOnly + }, + { + property: BaseOnly2 + } + ] + } + }, + sql: { + Text: +select t.Id, + t.Discriminator, + t.Property, + t.CategoryId, + t.RegionId +from TphDerivedNavBaseEntities as t +order by t.Property + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Tph_derived_navigation_mixed_with_base_scalars.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Tph_derived_navigation_mixed_with_base_scalars.verified.txt new file mode 100644 index 00000000..9c66a987 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Tph_derived_navigation_mixed_with_base_scalars.verified.txt @@ -0,0 +1,44 @@ +{ + target: { + Data: { + tphDerivedNavEntities: [ + { + id: Guid_1, + property: CatMixed, + categoryId: Guid_2, + category: { + name: Art + } + }, + { + id: Guid_3, + property: RegMixed, + regionId: Guid_4, + region: { + name: South + } + } + ] + } + }, + sql: { + Text: +select t.Id, + t.Discriminator, + t.Property, + t.CategoryId, + t.RegionId, + c.Id, + c.Name, + r.Id, + r.Name +from TphDerivedNavBaseEntities as t + left outer join + CategoryEntities as c + on t.CategoryId = c.Id + left outer join + RegionEntities as r + on t.RegionId = r.Id +order by t.Property + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Tph_derived_navigation_via_inline_fragments.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Tph_derived_navigation_via_inline_fragments.verified.txt new file mode 100644 index 00000000..f284eb61 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Tph_derived_navigation_via_inline_fragments.verified.txt @@ -0,0 +1,40 @@ +{ + target: { + Data: { + tphDerivedNavEntities: [ + { + property: CategoryItem1, + category: { + name: Science + } + }, + { + property: RegionItem1, + region: { + name: North + } + } + ] + } + }, + sql: { + Text: +select t.Id, + t.Discriminator, + t.Property, + t.CategoryId, + t.RegionId, + c.Id, + c.Name, + r.Id, + r.Name +from TphDerivedNavBaseEntities as t + left outer join + CategoryEntities as c + on t.CategoryId = c.Id + left outer join + RegionEntities as r + on t.RegionId = r.Id +order by t.Property + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Tph_derived_navigation_via_inline_fragments_single.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Tph_derived_navigation_via_inline_fragments_single.verified.txt new file mode 100644 index 00000000..1603dc43 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Tph_derived_navigation_via_inline_fragments_single.verified.txt @@ -0,0 +1,27 @@ +{ + target: { + Data: { + tphDerivedNavEntity: { + property: CategoryItem2, + category: { + name: History + } + } + } + }, + sql: { + Text: +select top (2) t.Id, + t.Discriminator, + t.Property, + t.CategoryId, + t.RegionId, + c.Id, + c.Name +from TphDerivedNavBaseEntities as t + left outer join + CategoryEntities as c + on t.CategoryId = c.Id +where t.Id = 'Guid_1' + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests_tph_derived_navigation.cs b/src/Tests/IntegrationTests/IntegrationTests_tph_derived_navigation.cs new file mode 100644 index 00000000..09ec0933 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests_tph_derived_navigation.cs @@ -0,0 +1,130 @@ +public partial class IntegrationTests +{ + [Fact] + public async Task Tph_derived_navigation_via_inline_fragments() + { + var category = new CategoryEntity { Name = "Science" }; + var region = new RegionEntity { Name = "North" }; + var categoryItem = new TphDerivedNavCategoryEntity + { + Property = "CategoryItem1", + Category = category, + CategoryId = category.Id + }; + var regionItem = new TphDerivedNavRegionEntity + { + Property = "RegionItem1", + Region = region, + RegionId = region.Id + }; + + var query = + """ + { + tphDerivedNavEntities + { + property + ... on TphDerivedNavCategory { + category { name } + } + ... on TphDerivedNavRegion { + region { name } + } + } + } + """; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [category, region, categoryItem, regionItem]); + } + + [Fact] + public async Task Tph_derived_navigation_via_inline_fragments_single() + { + var category = new CategoryEntity { Name = "History" }; + var categoryItem = new TphDerivedNavCategoryEntity + { + Property = "CategoryItem2", + Category = category, + CategoryId = category.Id + }; + + var query = $$""" + { + tphDerivedNavEntity(id: "{{categoryItem.Id}}") + { + property + ... on TphDerivedNavCategory { + category { name } + } + } + } + """; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [category, categoryItem]); + } + + [Fact] + public async Task Tph_derived_navigation_base_fields_still_work() + { + var categoryItem = new TphDerivedNavCategoryEntity { Property = "BaseOnly" }; + var regionItem = new TphDerivedNavRegionEntity { Property = "BaseOnly2" }; + + // Query only base fields - no inline fragments, no derived navigations needed + var query = + """ + { + tphDerivedNavEntities + { + property + } + } + """; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [categoryItem, regionItem]); + } + + [Fact] + public async Task Tph_derived_navigation_mixed_with_base_scalars() + { + var category = new CategoryEntity { Name = "Art" }; + var region = new RegionEntity { Name = "South" }; + var categoryItem = new TphDerivedNavCategoryEntity + { + Property = "CatMixed", + Category = category, + CategoryId = category.Id + }; + var regionItem = new TphDerivedNavRegionEntity + { + Property = "RegMixed", + Region = region, + RegionId = region.Id + }; + + // Query base scalar fields AND derived navigation fields together + var query = + """ + { + tphDerivedNavEntities(orderBy: {path: "property"}) + { + id + property + ... on TphDerivedNavCategory { + categoryId + category { name } + } + ... on TphDerivedNavRegion { + regionId + region { name } + } + } + } + """; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [category, region, categoryItem, regionItem]); + } +} diff --git a/src/Tests/IntegrationTests/Query.cs b/src/Tests/IntegrationTests/Query.cs index 1e64fe42..4eb07060 100644 --- a/src/Tests/IntegrationTests/Query.cs +++ b/src/Tests/IntegrationTests/Query.cs @@ -391,5 +391,15 @@ public Query(IEfGraphQLService efGraphQlService) name: "tphMiddleEntity", graphType: typeof(TphMiddleGraphType), resolve: _ => _.DbContext.TphMiddleEntities); + + AddQueryField( + name: "tphDerivedNavEntities", + graphType: typeof(TphDerivedNavBaseGraphType), + resolve: _ => _.DbContext.TphDerivedNavBaseEntities); + + AddSingleField( + name: "tphDerivedNavEntity", + graphType: typeof(TphDerivedNavBaseGraphType), + resolve: _ => _.DbContext.TphDerivedNavBaseEntities); } } diff --git a/src/Tests/IntegrationTests/Schema.cs b/src/Tests/IntegrationTests/Schema.cs index 7495a612..34aa8ab1 100644 --- a/src/Tests/IntegrationTests/Schema.cs +++ b/src/Tests/IntegrationTests/Schema.cs @@ -49,11 +49,18 @@ public Schema(IServiceProvider resolver) : RegisterTypeMapping(typeof(TphMiddleEntity), typeof(TphMiddleGraphType)); RegisterTypeMapping(typeof(TphLeafEntity), typeof(TphLeafGraphType)); RegisterTypeMapping(typeof(TphAttachmentEntity), typeof(TphAttachmentGraphType)); + RegisterTypeMapping(typeof(TphDerivedNavBaseEntity), typeof(TphDerivedNavBaseGraphType)); + RegisterTypeMapping(typeof(TphDerivedNavCategoryEntity), typeof(TphDerivedNavCategoryGraphType)); + RegisterTypeMapping(typeof(TphDerivedNavRegionEntity), typeof(TphDerivedNavRegionGraphType)); + RegisterTypeMapping(typeof(CategoryEntity), typeof(CategoryGraphType)); + RegisterTypeMapping(typeof(RegionEntity), typeof(RegionGraphType)); Query = (Query)resolver.GetService(typeof(Query))!; Mutation = (Mutation)resolver.GetService(typeof(Mutation))!; RegisterType(typeof(DerivedGraphType)); RegisterType(typeof(DerivedWithNavigationGraphType)); RegisterType(typeof(FilterDerivedGraphType)); RegisterType(typeof(TphLeafGraphType)); + RegisterType(typeof(TphDerivedNavCategoryGraphType)); + RegisterType(typeof(TphDerivedNavRegionGraphType)); } } \ No newline at end of file