diff --git a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Navigation.cs b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Navigation.cs index e59ce48f..df208a02 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Navigation.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Navigation.cs @@ -87,6 +87,9 @@ public FieldBuilder AddNavigationField(); + field.Resolver = new FuncFieldResolver( async context => { @@ -119,8 +122,21 @@ public FieldBuilder AddNavigationField 0) + { + result = await ReloadWithFilterNavigations( + fieldContext.DbContext, + result, + filterRequiredNavPaths); + } + + if (await fieldContext.Filters.ShouldInclude(context.UserContext, fieldContext.DbContext, context.User, result)) { return result; } @@ -158,4 +174,206 @@ public FieldBuilder AddNavigationField( graph.AddField(field); return new FieldBuilderEx(field); } + + /// + /// Gets the navigation paths required by filters for reloading entities. + /// Returns just the navigation parts (not prefixed with field name). + /// + IReadOnlyList GetFilterRequiredNavPathsForReload() + where TReturn : class + { + var filters = resolveFilters?.Invoke(null!); + if (filters == null) + { + return []; + } + + var requiredProps = filters.GetRequiredFilterProperties(); + var navigationPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var prop in requiredProps) + { + var lastDot = prop.LastIndexOf('.'); + if (lastDot > 0) + { + // e.g., "TravelRequest.GroupOwnerId" -> "TravelRequest" + var navPath = prop[..lastDot]; + navigationPaths.Add(navPath); + } + } + + return [.. navigationPaths]; + } + + /// + /// Reloads an entity from the database with the specified navigation properties included. + /// + static async Task ReloadWithFilterNavigations( + TDbContext dbContext, + TReturn entity, + IReadOnlyList navigationPaths) + where TReturn : class + { + if (navigationPaths.Count == 0) + { + return entity; + } + + // Get the entity's primary key + var entityType = dbContext.Model.FindEntityType(typeof(TReturn)); + if (entityType == null) + { + return entity; + } + + var primaryKey = entityType.FindPrimaryKey(); + if (primaryKey == null) + { + return entity; + } + + // Get the key values from the entity + var keyValues = primaryKey.Properties + .Select(p => p.PropertyInfo?.GetValue(entity) ?? p.FieldInfo?.GetValue(entity)) + .ToArray(); + + if (keyValues.Any(v => v == null)) + { + return entity; + } + + // Build a query with includes + IQueryable query = dbContext.Set(); + + foreach (var navPath in navigationPaths) + { + query = query.Include(navPath); + } + + // Filter by primary key + var keyProperties = primaryKey.Properties.ToList(); + if (keyProperties.Count == 1) + { + // Single key - use simple Find-like behavior with includes + var keyProperty = keyProperties[0]; + var parameter = Expression.Parameter(typeof(TReturn), "e"); + var propertyAccess = Expression.Property(parameter, keyProperty.PropertyInfo!); + var constant = Expression.Constant(keyValues[0]); + var equals = Expression.Equal(propertyAccess, constant); + var lambda = Expression.Lambda>(equals, parameter); + + return await query.FirstOrDefaultAsync(lambda); + } + + // Composite key - need to build combined predicate + var param = Expression.Parameter(typeof(TReturn), "e"); + Expression? predicate = null; + + for (var i = 0; i < keyProperties.Count; i++) + { + var keyProperty = keyProperties[i]; + var propertyAccess = Expression.Property(param, keyProperty.PropertyInfo!); + var constant = Expression.Constant(keyValues[i]); + var equals = Expression.Equal(propertyAccess, constant); + + predicate = predicate == null ? equals : Expression.AndAlso(predicate, equals); + } + + var lambdaExpr = Expression.Lambda>(predicate!, param); + return await query.FirstOrDefaultAsync(lambdaExpr); + } + + /// + /// Batch reloads multiple entities from the database with the specified navigation properties included. + /// Uses a single query with WHERE Id IN (...) instead of N+1 queries. + /// + static async Task> BatchReloadWithFilterNavigations( + TDbContext dbContext, + IEnumerable entities, + IReadOnlyList navigationPaths) + where TReturn : class + { + var entityList = entities.ToList(); + if (entityList.Count == 0 || navigationPaths.Count == 0) + { + return entityList; + } + + // Get the entity's primary key metadata + var entityType = dbContext.Model.FindEntityType(typeof(TReturn)); + if (entityType == null) + { + return entityList; + } + + var primaryKey = entityType.FindPrimaryKey(); + if (primaryKey == null) + { + return entityList; + } + + var keyProperties = primaryKey.Properties.ToList(); + if (keyProperties.Count != 1) + { + // For composite keys, fall back to individual reloads + var results = new List(); + foreach (var entity in entityList) + { + var reloaded = await ReloadWithFilterNavigations(dbContext, entity, navigationPaths); + if (reloaded != null) + { + results.Add(reloaded); + } + } + return results; + } + + // Single key - can use IN clause + var keyProperty = keyProperties[0]; + var keyValues = entityList + .Select(e => keyProperty.PropertyInfo?.GetValue(e) ?? keyProperty.FieldInfo?.GetValue(e)) + .Where(v => v != null) + .ToList(); + + if (keyValues.Count == 0) + { + return entityList; + } + + // Build a query with includes + IQueryable query = dbContext.Set(); + + foreach (var navPath in navigationPaths) + { + query = query.Include(navPath); + } + + // Build WHERE Id IN (...) predicate + var parameter = Expression.Parameter(typeof(TReturn), "e"); + var propertyAccess = Expression.Property(parameter, keyProperty.PropertyInfo!); + + // Create a list of the key type and use Contains + var keyType = keyProperty.ClrType; + var typedKeyValues = typeof(Enumerable) + .GetMethod("Cast")! + .MakeGenericMethod(keyType) + .Invoke(null, [keyValues])!; + var keyList = typeof(Enumerable) + .GetMethod("ToList")! + .MakeGenericMethod(keyType) + .Invoke(null, [typedKeyValues])!; + + var containsMethod = typeof(List<>) + .MakeGenericType(keyType) + .GetMethod("Contains", [keyType])!; + + var containsCall = Expression.Call( + Expression.Constant(keyList), + containsMethod, + propertyAccess); + + var lambda = Expression.Lambda>(containsCall, parameter); + + return await query.Where(lambda).ToListAsync(); + } } \ No newline at end of file diff --git a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationList.cs b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationList.cs index 2f5485a9..7ced73e7 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationList.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationList.cs @@ -77,6 +77,9 @@ public FieldBuilder AddNavigationListField(); + field.Resolver = new FuncFieldResolver>(async context => { var fieldContext = BuildContext(context); @@ -104,6 +107,15 @@ public FieldBuilder AddNavigationListField 0) + { + result = await BatchReloadWithFilterNavigations( + fieldContext.DbContext, + result, + filterRequiredNavPaths); + } + return await fieldContext.Filters.ApplyFilter(result, context.UserContext, fieldContext.DbContext, context.User); }); diff --git a/src/GraphQL.EntityFramework/IncludeAppender.cs b/src/GraphQL.EntityFramework/IncludeAppender.cs index e809f0bd..503ec6a6 100644 --- a/src/GraphQL.EntityFramework/IncludeAppender.cs +++ b/src/GraphQL.EntityFramework/IncludeAppender.cs @@ -57,74 +57,46 @@ internal IQueryable AddIncludesWithFiltersAndDetectNavigations( /// /// Checks if the query is the result of a projection (Select). /// When a query has been projected via Select, Include cannot be applied. - /// We detect this by examining if the expression tree contains a Select call. + /// We detect this by walking the LINQ method call chain (not lambda bodies). /// static bool IsProjectedQuery(IQueryable query) where TItem : class => - ContainsSelectMethod(query.Expression); + HasSelectInQueryChain(query.Expression); - static bool ContainsSelectMethod(Expression expression) + /// + /// Walks the LINQ method call chain to find Select calls. + /// Only checks the source argument of LINQ operators, not lambda bodies. + /// + static bool HasSelectInQueryChain(Expression expression) { - while (true) + while (expression is MethodCallExpression methodCall) { - switch (expression) + // Check if this is a LINQ Select call + if (methodCall.Method.Name == "Select" && + methodCall.Method.DeclaringType is { } declaringType && + (declaringType == typeof(Queryable) || declaringType == typeof(Enumerable))) { - case MethodCallExpression methodCall: - // Check if this is a Select call - if (methodCall.Method.Name == "Select") - { - return true; - } - - // Check all arguments recursively - foreach (var arg in methodCall.Arguments) - { - if (ContainsSelectMethod(arg)) - { - return true; - } - } - - // Check the object (for instance method calls) - return methodCall.Object != null && ContainsSelectMethod(methodCall.Object); - - case UnaryExpression unary: - expression = unary.Operand; - continue; - - case LambdaExpression lambda: - expression = lambda.Body; - continue; - - case MemberExpression member: - return member.Expression != null && ContainsSelectMethod(member.Expression); - - case BinaryExpression binary: - return ContainsSelectMethod(binary.Left) || ContainsSelectMethod(binary.Right); - - case ConditionalExpression conditional: - return ContainsSelectMethod(conditional.Test) || ContainsSelectMethod(conditional.IfTrue) || ContainsSelectMethod(conditional.IfFalse); - - case InvocationExpression invocation: - if (ContainsSelectMethod(invocation.Expression)) - { - return true; - } - - foreach (var arg in invocation.Arguments) - { - if (ContainsSelectMethod(arg)) - { - return true; - } - } - - return false; + return true; + } - default: - return false; + // For LINQ extension methods, the source is the first argument + // Move to the source queryable to continue walking the chain + if (methodCall.Arguments.Count > 0) + { + expression = methodCall.Arguments[0]; + } + else if (methodCall.Object != null) + { + // For instance methods, check the object + expression = methodCall.Object; + } + else + { + break; } } + + return false; } IQueryable AddFilterNavigationIncludes( diff --git a/src/GraphQL.EntityFramework/Mapping/Mapper.cs b/src/GraphQL.EntityFramework/Mapping/Mapper.cs index 7174ee76..0c29a4af 100644 --- a/src/GraphQL.EntityFramework/Mapping/Mapper.cs +++ b/src/GraphQL.EntityFramework/Mapping/Mapper.cs @@ -123,7 +123,7 @@ static void ProcessNavigation( } } -#pragma warning disable CS0618 // Obsolete - AutoMap needs to use legacy methods since it can't determine projections at runtime + // Use projection-based AddNavigationField which properly handles filters and includes static void AddNavigation( ObjectGraphType graph, IEfGraphQLService graphQlService, @@ -131,8 +131,14 @@ static void AddNavigation( where TReturn : class { var graphTypeFromType = GraphTypeFromType(navigation.Name, navigation.Type, navigation.IsNullable); - var compile = NavigationFunc(navigation.Name); - graphQlService.AddNavigationField(graph, navigation.Name, compile, graphTypeFromType); + var projection = NavigationProjection(navigation.Name); + // Use the projection + resolve overload to ensure filters are applied + graphQlService.AddNavigationField( + graph, + navigation.Name, + projection, + context => context.Projection!, + graphTypeFromType); } static void AddNavigationList( @@ -142,35 +148,64 @@ static void AddNavigationList( where TReturn : class { var graphTypeFromType = GraphTypeFromType(navigation.Name, navigation.Type, false); - var compile = NavigationFunc>(navigation.Name); - graphQlService.AddNavigationListField(graph, navigation.Name, compile, graphTypeFromType); + var projection = NavigationListProjection(navigation.Name); + // Use the projection + resolve overload to ensure filters are applied + graphQlService.AddNavigationListField?>( + graph, + navigation.Name, + projection, + context => context.Projection ?? [], + graphTypeFromType); } -#pragma warning restore CS0618 public record NavigationKey(Type Type, string Name); - static ConcurrentDictionary navigationFuncs = []; + static ConcurrentDictionary navigationProjections = []; - internal static Func, TReturn> NavigationFunc(string name) + /// + /// Creates a projection expression for a single navigation property: _ => _.NavigationProperty + /// + internal static Expression> NavigationProjection(string name) + where TReturn : class { var key = new NavigationKey(typeof(TSource), name); - return (Func, TReturn>)navigationFuncs.GetOrAdd( + return (Expression>)navigationProjections.GetOrAdd( key, - _ => NavigationExpression(_.Name).Compile()); + _ => BuildNavigationProjection(_.Name)); } - internal static Expression, TReturn>> NavigationExpression(string name) + static Expression> BuildNavigationProjection(string name) + where TReturn : class { - // TSource parameter - var type = typeof(ResolveEfFieldContext); - var parameter = Expression.Parameter(type, "context"); - var sourcePropertyInfo = type.GetProperty("Source", typeof(TSource))!; - var sourceProperty = Expression.Property(parameter, sourcePropertyInfo); - var property = Expression.Property(sourceProperty, name); - - //context => context.Source.Parent - return Expression.Lambda, TReturn>>(property, parameter); + // _ => _.NavigationProperty + var parameter = Expression.Parameter(typeof(TSource), "_"); + var property = Expression.Property(parameter, name); + return Expression.Lambda>(property, parameter); + } + + /// + /// Creates a projection expression for a collection navigation property: _ => _.NavigationCollection + /// + internal static Expression?>> NavigationListProjection(string name) + where TReturn : class + { + var key = new NavigationKey(typeof(TSource), name); + + return (Expression?>>)navigationProjections.GetOrAdd( + key, + _ => BuildNavigationListProjection(_.Name)); + } + + static Expression?>> BuildNavigationListProjection(string name) + where TReturn : class + { + // _ => _.NavigationCollection + var parameter = Expression.Parameter(typeof(TSource), "_"); + var property = Expression.Property(parameter, name); + // Cast to IEnumerable if needed (e.g., IList -> IEnumerable) + var castToEnumerable = Expression.Convert(property, typeof(IEnumerable)); + return Expression.Lambda?>>(castToEnumerable, parameter); } static void AddMember(ComplexGraphType graph, PropertyInfo property) diff --git a/src/Tests/IntegrationTests/IntegrationTests.AddSingleField_with_filter_accessing_navigation_denies_when_no_match.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.AddSingleField_with_filter_accessing_navigation_denies_when_no_match.verified.txt new file mode 100644 index 00000000..d814af19 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.AddSingleField_with_filter_accessing_navigation_denies_when_no_match.verified.txt @@ -0,0 +1,21 @@ +{ + target: +{ + "data": { + "filterReferenceEntityNullable": null + } +}, + sql: { + Text: +select top (2) f.Id, + f.BaseEntityId, + f.Property, + cast (0 as bit), + f0.CommonProperty +from FilterReferenceEntities as f + inner join + FilterBaseEntities as f0 + on f.BaseEntityId = f0.Id +where f.Id = 'Guid_1' + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.AddSingleField_with_filter_accessing_navigation_uses_include.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.AddSingleField_with_filter_accessing_navigation_uses_include.verified.txt new file mode 100644 index 00000000..bbb01ace --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.AddSingleField_with_filter_accessing_navigation_uses_include.verified.txt @@ -0,0 +1,24 @@ +{ + target: +{ + "data": { + "filterReferenceEntity": { + "id": "Guid_1", + "property": "Reference1" + } + } +}, + sql: { + Text: +select top (2) f.Id, + f.BaseEntityId, + f.Property, + cast (0 as bit), + f0.CommonProperty +from FilterReferenceEntities as f + inner join + FilterBaseEntities as f0 + on f.BaseEntityId = f0.Id +where f.Id = 'Guid_1' + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Filter_using_anonymous_type_with_nested_navigation_property.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Filter_using_anonymous_type_with_nested_navigation_property.verified.txt index 7739e55c..36d38b2b 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Filter_using_anonymous_type_with_nested_navigation_property.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Filter_using_anonymous_type_with_nested_navigation_property.verified.txt @@ -1,10 +1,28 @@ { - target: { - Type: NullReferenceException, - Message: Object reference not set to an instance of an object. - }, - sql: { - Text: + target: +{ + "data": { + "parentEntitiesFiltered": [ + { + "property": "AllowedParent", + "children": [ + { + "property": "Child4", + "age": 40, + "isActive": true + } + ] + }, + { + "property": "BlockedParent", + "children": [] + } + ] + } +}, + sql: [ + { + Text: select f.Id, f.Property, f0.Id, @@ -16,5 +34,66 @@ from FilterParentEntities as f FilterChildEntities as f0 on f.Id = f0.ParentId order by f.Property, f.Id, f0.Id - } + }, + { + Text: +select f.Id, + f.Age, + f.CreatedAt, + f.IsActive, + f.NullableAge, + f.NullableCreatedAt, + f.NullableIsActive, + f.ParentId, + f.Property, + f0.Id, + f0.Field1, + f0.Field10, + f0.Field2, + f0.Field3, + f0.Field4, + f0.Field5, + f0.Field6, + f0.Field7, + f0.Field8, + f0.Field9, + f0.Property +from FilterChildEntities as f + left outer join + FilterParentEntities as f0 + on f.ParentId = f0.Id +where f.Id in ('Guid_1', 'Guid_2', 'Guid_3') +order by f.Property + }, + { + Text: +select f.Id, + f.Age, + f.CreatedAt, + f.IsActive, + f.NullableAge, + f.NullableCreatedAt, + f.NullableIsActive, + f.ParentId, + f.Property, + f0.Id, + f0.Field1, + f0.Field10, + f0.Field2, + f0.Field3, + f0.Field4, + f0.Field5, + f0.Field6, + f0.Field7, + f0.Field8, + f0.Field9, + f0.Property +from FilterChildEntities as f + left outer join + FilterParentEntities as f0 + on f.ParentId = f0.Id +where f.Id = 'Guid_4' +order by f.Property + } + ] } \ 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 cc70bd8a..e341ad28 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt @@ -155,6 +155,7 @@ filterChildEntity(id: ID, ids: [ID!], where: [WhereExpression!]): FilterChild! 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 } type CustomType { diff --git a/src/Tests/IntegrationTests/IntegrationTests_abstract_navigation_filter_include.cs b/src/Tests/IntegrationTests/IntegrationTests_abstract_navigation_filter_include.cs index 1d15edd2..fdc0eb9b 100644 --- a/src/Tests/IntegrationTests/IntegrationTests_abstract_navigation_filter_include.cs +++ b/src/Tests/IntegrationTests/IntegrationTests_abstract_navigation_filter_include.cs @@ -60,7 +60,7 @@ public async Task Filter_with_abstract_navigation_should_use_include_fallback() var filters = new Filters(); // Filter projection accesses Status through abstract Parent navigation - // This mirrors the MinistersApi scenario: + // This mirrors the scenario: // _.TravelRequest != null ? _.TravelRequest.HighestStatusAchieved : null filters.For().Add( projection: c => new AbstractNavStatusProjection( @@ -152,7 +152,7 @@ public async Task Filter_with_abstract_navigation_should_correctly_exclude_non_m /// /// Tests that filter projection with multiple properties from abstract navigation works. - /// This matches the MinistersApi scenario more closely where both GroupOwnerId and + /// This matches the scenario more closely where both GroupOwnerId and /// HighestStatusAchieved are accessed from the abstract TravelRequest navigation. /// [Fact] diff --git a/src/Tests/IntegrationTests/IntegrationTests_named_type_filter_projection.cs b/src/Tests/IntegrationTests/IntegrationTests_named_type_filter_projection.cs index 0323b58c..e5bc039e 100644 --- a/src/Tests/IntegrationTests/IntegrationTests_named_type_filter_projection.cs +++ b/src/Tests/IntegrationTests/IntegrationTests_named_type_filter_projection.cs @@ -170,7 +170,7 @@ public async Task Filter_projection_should_not_select_all_navigation_fields() /// BUG TEST: Reproduces the bug where TPH inheritance + entity filters on base type /// cause ALL columns to be selected from the navigation entity. /// - /// This matches the MinistersApi scenario: + /// This matches the scenario: /// - FilterReferenceEntity (like Accommodation) has a navigation to FilterBaseEntity /// - FilterBaseEntity (like BaseRequest) is an abstract base with TPH inheritance /// - FilterDerivedEntity (like TravelRequest) inherits from FilterBaseEntity diff --git a/src/Tests/IntegrationTests/IntegrationTests_query_with_select_projection.cs b/src/Tests/IntegrationTests/IntegrationTests_query_with_select_projection.cs index 4b3285fa..2520ab31 100644 --- a/src/Tests/IntegrationTests/IntegrationTests_query_with_select_projection.cs +++ b/src/Tests/IntegrationTests/IntegrationTests_query_with_select_projection.cs @@ -41,7 +41,7 @@ public async Task Query_with_select_projection_and_filter() /// /// Tests that queries with Select projections work when the filter accesses a navigation property. - /// This is the scenario from MinistersApi where GroundTransportOrderedView.Select(_ => _.GroundTransport) + /// This is the scenario where GroundTransportOrderedView.Select(_ => _.GroundTransport) /// is used, and the filter accesses GroundTransport.TravelRequest.HighestStatusAchieved. /// [Fact] @@ -122,4 +122,100 @@ public async Task Query_without_select_projection_with_navigation_filter() await using var database = await sqlInstance.Build(); await RunQuery(database, query, null, filters, false, [parent, child]); } + + /// + /// Tests that AddSingleField correctly loads filter-required navigation properties via Include. + /// This is the scenario where: + /// - MealsAndIncidentalsCost is resolved via AddSingleField (a fresh query) + /// - The filter accesses TravelRequest.HighestStatusAchieved (a navigation property) + /// - Include must be added to load TravelRequest for the filter to work + /// + [Fact] + public async Task AddSingleField_with_filter_accessing_navigation_uses_include() + { + var query = + """ + { + filterReferenceEntity(id: "ID_PLACEHOLDER") { + id + property + } + } + """; + + var baseEntity = new FilterBaseEntity + { + CommonProperty = "AllowAccess" + }; + var referenceEntity = new FilterReferenceEntity + { + Property = "Reference1", + BaseEntity = baseEntity, + BaseEntityId = baseEntity.Id + }; + + // Replace placeholder with actual ID + query = query.Replace("ID_PLACEHOLDER", referenceEntity.Id.ToString()); + + // Add a filter that accesses the BaseEntity navigation + // This simulates MealsAndIncidentalsCost filter accessing TravelRequest.HighestStatusAchieved + var filters = new Filters(); + filters.For().Add( + projection: _ => new + { + _.Id, + BaseProperty = _.BaseEntity != null ? _.BaseEntity.CommonProperty : null + }, + filter: (_, _, _, data) => data.BaseProperty == "AllowAccess"); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, filters, false, [baseEntity, referenceEntity]); + } + + /// + /// Tests that AddSingleField filter correctly denies access when the navigation property + /// doesn't match the filter criteria. + /// + [Fact] + public async Task AddSingleField_with_filter_accessing_navigation_denies_when_no_match() + { + // Query the nullable endpoint to avoid SingleEntityNotFoundException + var query = + """ + { + filterReferenceEntityNullable(id: "ID_PLACEHOLDER") { + id + property + } + } + """; + + var baseEntity = new FilterBaseEntity + { + CommonProperty = "DenyAccess" + }; + var referenceEntity = new FilterReferenceEntity + { + Property = "Reference1", + BaseEntity = baseEntity, + BaseEntityId = baseEntity.Id + }; + + // Replace placeholder with actual ID + query = query.Replace("ID_PLACEHOLDER", referenceEntity.Id.ToString()); + + // Add a filter that requires BaseEntity.CommonProperty == "AllowAccess" + // Since the actual value is "DenyAccess", the entity should be filtered out (returns null) + var filters = new Filters(); + filters.For().Add( + projection: _ => new + { + _.Id, + BaseProperty = _.BaseEntity != null ? _.BaseEntity.CommonProperty : null + }, + filter: (_, _, _, data) => data.BaseProperty == "AllowAccess"); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, filters, false, [baseEntity, referenceEntity]); + } } diff --git a/src/Tests/IntegrationTests/Query.cs b/src/Tests/IntegrationTests/Query.cs index 2000c0e5..325a52f1 100644 --- a/src/Tests/IntegrationTests/Query.cs +++ b/src/Tests/IntegrationTests/Query.cs @@ -352,5 +352,10 @@ public Query(IEfGraphQLService efGraphQlService) AddSingleField( name: "filterReferenceEntity", resolve: _ => _.DbContext.FilterReferenceEntities); + + AddSingleField( + name: "filterReferenceEntityNullable", + resolve: _ => _.DbContext.FilterReferenceEntities, + nullable: true); } } diff --git a/src/Tests/Mapping/MappingTests.NavigationProperty.verified.txt b/src/Tests/Mapping/MappingTests.NavigationProperty.verified.txt index 97be0bfc..4f80951e 100644 --- a/src/Tests/Mapping/MappingTests.NavigationProperty.verified.txt +++ b/src/Tests/Mapping/MappingTests.NavigationProperty.verified.txt @@ -1,13 +1,7 @@ -{ - expression: context => context.Source.Parent, +{ + expression: _ => _.Parent, result: { Id: Guid_1, - Property: value, - Children: [ - { - Id: Guid_2, - ParentId: Guid_1 - } - ] + Property: value } -} \ No newline at end of file +} diff --git a/src/Tests/Mapping/MappingTests.cs b/src/Tests/Mapping/MappingTests.cs index c48b9c68..4b8b3d09 100644 --- a/src/Tests/Mapping/MappingTests.cs +++ b/src/Tests/Mapping/MappingTests.cs @@ -85,24 +85,16 @@ await Verify( [Fact] public async Task NavigationProperty() { - await using var database = await sqlInstance.Build(); - var context = database.Context; - var child = new MappingChild(); var parent = new MappingParent { Property = "value" }; child.Parent = parent; - await database.AddData(child, parent); - var expression = Mapper.NavigationExpression("Parent"); + + var expression = Mapper.NavigationProjection("Parent"); var compile = expression.Compile(); - var result = compile( - new() - { - DbContext = context, - Source = child - }); + var result = compile(child); await Verify( new {