diff --git a/claude.md b/claude.md index 0cd265dba..b18b715cc 100644 --- a/claude.md +++ b/claude.md @@ -2,6 +2,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Workflow Guidelines + +**IMPORTANT - Git Commits:** +- NEVER automatically commit changes +- NEVER prompt or ask to commit changes +- NEVER suggest creating commits +- The user will handle all git commits manually + ## Project Overview GraphQL.EntityFramework is a .NET library that adds EntityFramework Core IQueryable support to GraphQL.NET. It enables automatic query generation, filtering, pagination, and ordering for GraphQL queries backed by EF Core. @@ -67,6 +75,10 @@ The README.md and docs/*.md files are auto-generated from source files using [Ma - Builds LINQ expressions from GraphQL where clause arguments - Supports complex filtering including grouping, negation, and nested properties +**ProjectionAnalyzer** (`src/GraphQL.EntityFramework/GraphApi/ProjectionAnalyzer.cs`) +- Analyzes projection expressions to extract required property names +- Used by navigation fields, filters, and FieldBuilder extensions to determine which properties need to be loaded + **Filters** (`src/GraphQL.EntityFramework/Filters/Filters.cs`) - Post-query filtering mechanism for authorization or business rules - Executed after EF query to determine if nodes should be included in results @@ -136,6 +148,60 @@ Arguments are processed in order: ids → where → orderBy → skip → take The library supports EF projections where you can use `Select()` to project to DTOs or anonymous types before applying GraphQL field resolution. +### FieldBuilder Extensions (Projection-Based Resolve) + +The library provides projection-based extension methods on `FieldBuilder` to safely access navigation properties in custom resolvers: + +**Extension Methods** (`src/GraphQL.EntityFramework/GraphApi/FieldBuilderExtensions.cs`) +- `Resolve()` - Synchronous resolver with projection +- `ResolveAsync()` - Async resolver with projection +- `ResolveList()` - List resolver with projection +- `ResolveListAsync()` - Async list resolver with projection + +**Why Use These Methods:** +When using `Field().Resolve()` or `Field().ResolveAsync()` directly, navigation properties on `context.Source` may be null if the projection system didn't include them. The projection-based extension methods ensure required data is loaded by: +1. Storing projection metadata in field metadata +2. Compiling the projection expression for runtime execution +3. Applying the projection to `context.Source` before calling your resolver +4. Providing the projected data via `ResolveProjectionContext` + +**Example:** +```csharp +public class ChildGraphType : EfObjectGraphType +{ + public ChildGraphType(IEfGraphQLService graphQlService) : base(graphQlService) => + Field("ParentId") + .Resolve( + projection: x => x.Parent!, + resolve: ctx => ctx.Projection.Id); +} +``` + +## Roslyn Analyzer + +The project includes a Roslyn analyzer (`GraphQL.EntityFramework.Analyzers`) that detects problematic usage patterns at compile time: + +**GQLEF002**: Warns when using `Field().Resolve()` or `Field().ResolveAsync()` to access navigation properties without projection +- **Category:** Usage +- **Severity:** Warning +- **Solution:** Use projection-based extension methods instead + +**Safe Patterns (No Warning):** +- Accessing primary key properties (e.g., `context.Source.Id`, `context.Source.CompanyId` when in `Company` class) +- Accessing foreign key properties (e.g., `context.Source.ParentId`, `context.Source.UserId`) +- Using projection-based extension methods + +**Unsafe Patterns (Warning):** +- Accessing scalar properties (e.g., `context.Source.Name`, `context.Source.Age`) - these might not be loaded +- Accessing navigation properties (e.g., `context.Source.Parent`) +- Accessing properties on navigation properties (e.g., `context.Source.Parent.Id`) +- Accessing collection navigation properties (e.g., `context.Source.Children.Count()`) + +**Why Only PK/FK Are Safe:** +The EF projection system always loads primary keys and foreign keys, but other properties (including regular scalars like `Name` or `Age`) are only loaded if explicitly requested in the GraphQL query. Accessing them in a resolver without projection can cause null reference exceptions. + +The analyzer automatically runs during build and in IDEs (Visual Studio, Rider, VS Code). + ## Testing Tests use: @@ -157,3 +223,4 @@ The test project (`src/Tests/`) includes: - Treats warnings as errors - Uses .editorconfig for code style enforcement - Uses Fody/ConfigureAwait.Fody for ConfigureAwait(false) injection +- Always use `_ => _` for single parameter delegates (not `x => x` or other named parameters) diff --git a/docs/configuration.md b/docs/configuration.md index 24d62aa50..7a7825e4a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -721,9 +721,9 @@ public class BaseGraphType : { public BaseGraphType(IEfGraphQLService graphQlService) : base(graphQlService) => - AddNavigationConnectionField( + AddNavigationConnectionField( name: "childrenFromInterface", - includeNames: ["ChildrenFromBase"]); + projection: _ => _.ChildrenFromBase); } ``` snippet source | anchor @@ -740,14 +740,15 @@ public class DerivedGraphType : { AddNavigationConnectionField( name: "childrenFromInterface", - _ => _.Source.ChildrenFromBase); + projection: _ => _.ChildrenFromBase, + resolve: _ => _.Projection); AutoMap(); Interface(); IsTypeOf = obj => obj is DerivedEntity; } } ``` -snippet source | anchor +snippet source | anchor diff --git a/docs/defining-graphs.md b/docs/defining-graphs.md index 778622a44..f03998c5e 100644 --- a/docs/defining-graphs.md +++ b/docs/defining-graphs.md @@ -46,7 +46,29 @@ context.Heros .Include("Friends.Address"); ``` -The string for the include is taken from the field name when using `AddNavigationField` or `AddNavigationConnectionField` with the first character upper cased. This value can be overridden using the optional parameter `includeNames` . Note that `includeNames` is an `IEnumerable` so that multiple navigation properties can optionally be included for a single node. +The string for the include is taken from the field name when using `AddNavigationField` or `AddNavigationConnectionField` with the first character upper cased. This can be customized using a projection expression: + +```cs +// Using projection to specify which navigation properties to include +AddNavigationConnectionField( + name: "employeesConnection", + projection: _ => _.Employees, + resolve: ctx => ctx.Projection); + +// Multiple properties can be included using anonymous types +AddNavigationField( + name: "child1", + projection: _ => new { _.Child1, _.Child2 }, + resolve: ctx => ctx.Projection.Child1); + +// Nested navigation paths are also supported +AddNavigationField( + name: "level3Entity", + projection: _ => _.Level2Entity.Level3Entity, + resolve: ctx => ctx.Projection); +``` + +The projection expression provides type-safety and automatically extracts the include names from the accessed properties. ## Projections @@ -146,6 +168,55 @@ public class OrderGraph : Without automatic foreign key inclusion, `context.Source.CustomerId` would be `0` (or `Guid.Empty` for Guid keys) if `customerId` wasn't explicitly requested in the GraphQL query, causing the query to fail. +### Using Projection-Based Resolve + +When using `Field().Resolve()` or `Field().ResolveAsync()` in graph types, navigation properties on `context.Source` may not be loaded if the projection system didn't include them. To safely access navigation properties in custom resolvers, use the projection-based extension methods: + +```cs +public class ChildGraphType : EfObjectGraphType +{ + public ChildGraphType(IEfGraphQLService graphQlService) : + base(graphQlService) => + Field("ParentId") + .Resolve( + projection: x => x.Parent, + resolve: ctx => ctx.Projection.Id); +} +``` + +**Available Extension Methods:** + +- `Resolve()` - Synchronous resolver with projection +- `ResolveAsync()` - Async resolver with projection +- `ResolveList()` - List resolver with projection +- `ResolveListAsync()` - Async list resolver with projection + +The projection-based extension methods ensure required data is loaded by: + +1. Storing projection metadata in field metadata +2. Compiling the projection expression for runtime execution +3. Applying the projection to `context.Source` before calling the resolver +4. Providing the projected data via `ResolveProjectionContext` + +**Problematic Pattern (Navigation Property May Be Null):** + +```cs +Field("ParentId") + .Resolve(context => context.Source.Parent.Id); // Parent may be null +``` + +**Safe Pattern (Projection Ensures Data Is Loaded):** + +```cs +Field("ParentId") + .Resolve( + projection: x => x.Parent, + resolve: ctx => ctx.Projection.Id); // Parent is guaranteed to be loaded +``` + +**Note:** A Roslyn analyzer (GQLEF002) warns at compile time when `Field().Resolve()` accesses properties other than primary keys and foreign keys. Only PK and FK properties are guaranteed to be loaded by the projection system - all other properties (including regular scalars like `Name`) require projection-based extension methods to ensure they are loaded. + + ### When Projections Are Not Used Projections are bypassed and the full entity is loaded in these cases: @@ -213,16 +284,17 @@ public class CompanyGraph : { AddNavigationListField( name: "employees", - resolve: _ => _.Source.Employees); + projection: _ => _.Employees, + resolve: _ => _.Projection); AddNavigationConnectionField( name: "employeesConnection", - resolve: _ => _.Source.Employees, - includeNames: ["Employees"]); + projection: _ => _.Employees, + resolve: _ => _.Projection); AutoMap(); } } ``` -snippet source | anchor +snippet source | anchor @@ -340,10 +412,11 @@ public class CompanyGraph : base(graphQlService) => AddNavigationConnectionField( name: "employees", - resolve: _ => _.Source.Employees); + projection: _ => _.Employees, + resolve: _ => _.Projection); } ``` -snippet source | anchor +snippet source | anchor diff --git a/docs/filters.md b/docs/filters.md index a9b301e78..52bb61c47 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -294,7 +294,7 @@ Filters can project through navigation properties to access related entity data: ```cs var filters = new Filters(); filters.For().Add( - projection: o => new { o.TotalAmount, o.Customer.IsActive }, + projection: _ => new { _.TotalAmount, _.Customer.IsActive }, filter: (_, _, _, x) => x.TotalAmount >= 100 && x.IsActive); EfGraphQLConventions.RegisterInContainer( services, diff --git a/docs/mdsource/defining-graphs.source.md b/docs/mdsource/defining-graphs.source.md index 7fd155473..587e787db 100644 --- a/docs/mdsource/defining-graphs.source.md +++ b/docs/mdsource/defining-graphs.source.md @@ -39,7 +39,29 @@ context.Heros .Include("Friends.Address"); ``` -The string for the include is taken from the field name when using `AddNavigationField` or `AddNavigationConnectionField` with the first character upper cased. This value can be overridden using the optional parameter `includeNames` . Note that `includeNames` is an `IEnumerable` so that multiple navigation properties can optionally be included for a single node. +The string for the include is taken from the field name when using `AddNavigationField` or `AddNavigationConnectionField` with the first character upper cased. This can be customized using a projection expression: + +```cs +// Using projection to specify which navigation properties to include +AddNavigationConnectionField( + name: "employeesConnection", + projection: _ => _.Employees, + resolve: ctx => ctx.Projection); + +// Multiple properties can be included using anonymous types +AddNavigationField( + name: "child1", + projection: _ => new { _.Child1, _.Child2 }, + resolve: ctx => ctx.Projection.Child1); + +// Nested navigation paths are also supported +AddNavigationField( + name: "level3Entity", + projection: _ => _.Level2Entity.Level3Entity, + resolve: ctx => ctx.Projection); +``` + +The projection expression provides type-safety and automatically extracts the include names from the accessed properties. ## Projections @@ -87,6 +109,55 @@ snippet: ProjectionCustomResolver Without automatic foreign key inclusion, `context.Source.CustomerId` would be `0` (or `Guid.Empty` for Guid keys) if `customerId` wasn't explicitly requested in the GraphQL query, causing the query to fail. +### Using Projection-Based Resolve + +When using `Field().Resolve()` or `Field().ResolveAsync()` in graph types, navigation properties on `context.Source` may not be loaded if the projection system didn't include them. To safely access navigation properties in custom resolvers, use the projection-based extension methods: + +```cs +public class ChildGraphType : EfObjectGraphType +{ + public ChildGraphType(IEfGraphQLService graphQlService) : + base(graphQlService) => + Field("ParentId") + .Resolve( + projection: x => x.Parent, + resolve: ctx => ctx.Projection.Id); +} +``` + +**Available Extension Methods:** + +- `Resolve()` - Synchronous resolver with projection +- `ResolveAsync()` - Async resolver with projection +- `ResolveList()` - List resolver with projection +- `ResolveListAsync()` - Async list resolver with projection + +The projection-based extension methods ensure required data is loaded by: + +1. Storing projection metadata in field metadata +2. Compiling the projection expression for runtime execution +3. Applying the projection to `context.Source` before calling the resolver +4. Providing the projected data via `ResolveProjectionContext` + +**Problematic Pattern (Navigation Property May Be Null):** + +```cs +Field("ParentId") + .Resolve(context => context.Source.Parent.Id); // Parent may be null +``` + +**Safe Pattern (Projection Ensures Data Is Loaded):** + +```cs +Field("ParentId") + .Resolve( + projection: x => x.Parent, + resolve: ctx => ctx.Projection.Id); // Parent is guaranteed to be loaded +``` + +**Note:** A Roslyn analyzer (GQLEF002) warns at compile time when `Field().Resolve()` accesses properties other than primary keys and foreign keys. Only PK and FK properties are guaranteed to be loaded by the projection system - all other properties (including regular scalars like `Name`) require projection-based extension methods to ensure they are loaded. + + ### When Projections Are Not Used Projections are bypassed and the full entity is loaded in these cases: diff --git a/src/Benchmarks/SimpleQueryBenchmark.cs b/src/Benchmarks/SimpleQueryBenchmark.cs index c182a9029..ac8abdfb0 100644 --- a/src/Benchmarks/SimpleQueryBenchmark.cs +++ b/src/Benchmarks/SimpleQueryBenchmark.cs @@ -38,8 +38,8 @@ public ParentGraphType(IEfGraphQLService graphQlService) : { AddNavigationListField( name: "children", - resolve: _ => _.Source.Children, - includeNames: ["Children"]); + projection: _ => _.Children, + resolve: _ => _.Projection); AutoMap(); } } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index f2c725316..766dc1027 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;NU5104;CS1573;CS9107;NU1608;NU1109 - 34.0.0-beta.4 + 34.0.0 preview 1.0.0 EntityFrameworkCore, EntityFramework, GraphQL diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 6c2be796d..6cc30f17f 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -15,6 +15,8 @@ + + diff --git a/src/GraphQL.EntityFramework.Analyzers.Tests/FieldBuilderResolveAnalyzerTests.cs b/src/GraphQL.EntityFramework.Analyzers.Tests/FieldBuilderResolveAnalyzerTests.cs new file mode 100644 index 000000000..47e29a4fb --- /dev/null +++ b/src/GraphQL.EntityFramework.Analyzers.Tests/FieldBuilderResolveAnalyzerTests.cs @@ -0,0 +1,565 @@ +using GraphQL.EntityFramework; + +public class FieldBuilderResolveAnalyzerTests +{ + [Fact] + public async Task DetectsResolveWithNavigationPropertyAccess() + { + var source = """ + using GraphQL.EntityFramework; + using GraphQL.Types; + using Microsoft.EntityFrameworkCore; + + public class ParentEntity { public int Id { get; set; } } + public class ChildEntity + { + public int Id { get; set; } + public int ParentId { get; set; } + public ParentEntity Parent { get; set; } = null!; + } + + public class TestDbContext : DbContext { } + + public class ChildGraphType : EfObjectGraphType + { + public ChildGraphType(IEfGraphQLService graphQlService) : base(graphQlService) + { + Field("ParentProp") + .Resolve(context => context.Source.Parent.Id); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("GQLEF002", diagnostics[0].Id); + } + + [Fact] + public async Task DetectsResolveAsyncWithNavigationPropertyAccess() + { + var source = """ + using GraphQL.EntityFramework; + using GraphQL.Types; + using Microsoft.EntityFrameworkCore; + using System.Threading.Tasks; + + public class ParentEntity { public int Id { get; set; } } + public class ChildEntity + { + public int Id { get; set; } + public int ParentId { get; set; } + public ParentEntity Parent { get; set; } = null!; + } + + public class TestDbContext : DbContext { } + + public class ChildGraphType : EfObjectGraphType + { + public ChildGraphType(IEfGraphQLService graphQlService) : base(graphQlService) + { + Field("ParentProp") + .ResolveAsync(async context => await Task.FromResult(context.Source.Parent.Id)); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("GQLEF002", diagnostics[0].Id); + } + + [Fact] + public async Task DetectsResolveWithDirectNavigationAccess() + { + var source = """ + using GraphQL.EntityFramework; + using GraphQL.Types; + using Microsoft.EntityFrameworkCore; + + public class ParentEntity { public int Id { get; set; } } + public class ChildEntity + { + public int Id { get; set; } + public ParentEntity Parent { get; set; } = null!; + } + + public class TestDbContext : DbContext { } + + public class ChildGraphType : EfObjectGraphType + { + public ChildGraphType(IEfGraphQLService graphQlService) : base(graphQlService) + { + Field("Parent") + .Resolve(context => context.Source.Parent); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("GQLEF002", diagnostics[0].Id); + } + + [Fact] + public async Task AllowsResolveWithIdPropertyAccess() + { + var source = """ + using GraphQL.EntityFramework; + using GraphQL.Types; + using Microsoft.EntityFrameworkCore; + + public class ChildEntity + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + + public class TestDbContext : DbContext { } + + public class ChildGraphType : EfObjectGraphType + { + public ChildGraphType(IEfGraphQLService graphQlService) : base(graphQlService) + { + Field("EntityId") + .Resolve(context => context.Source.Id); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AllowsResolveWithForeignKeyAccess() + { + var source = """ + using GraphQL.EntityFramework; + using GraphQL.Types; + using Microsoft.EntityFrameworkCore; + + public class ParentEntity { public int Id { get; set; } } + public class ChildEntity + { + public int Id { get; set; } + public int ParentId { get; set; } + public ParentEntity Parent { get; set; } = null!; + } + + public class TestDbContext : DbContext { } + + public class ChildGraphType : EfObjectGraphType + { + public ChildGraphType(IEfGraphQLService graphQlService) : base(graphQlService) + { + Field("FK") + .Resolve(context => context.Source.ParentId); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task DetectsResolveWithScalarProperties() + { + var source = """ + using GraphQL.EntityFramework; + using GraphQL.Types; + using Microsoft.EntityFrameworkCore; + using System; + + public class ChildEntity + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public int Age { get; set; } + public DateTime CreatedDate { get; set; } + public bool IsActive { get; set; } + } + + public class TestDbContext : DbContext { } + + public class ChildGraphType : EfObjectGraphType + { + public ChildGraphType(IEfGraphQLService graphQlService) : base(graphQlService) + { + Field("FullName") + .Resolve(context => $"{context.Source.Name} - {context.Source.Age}"); + Field("Active") + .Resolve(context => context.Source.IsActive && context.Source.CreatedDate > DateTime.Now); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + // Should warn because Name, Age, IsActive, CreatedDate are scalar properties, not PK/FK + Assert.Equal(2, diagnostics.Length); // Two Resolve calls + Assert.All(diagnostics, _ => Assert.Equal("GQLEF002", _.Id)); + } + + [Fact] + public async Task AllowsProjectionBasedResolve() + { + var source = """ + using GraphQL.EntityFramework; + using GraphQL.Types; + using Microsoft.EntityFrameworkCore; + + public class ParentEntity + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + public class ChildEntity + { + public int Id { get; set; } + public int ParentId { get; set; } + public ParentEntity Parent { get; set; } = null!; + } + + public class TestDbContext : DbContext { } + + public class ChildGraphType : EfObjectGraphType + { + public ChildGraphType(IEfGraphQLService graphQlService) : base(graphQlService) + { + Field("ParentName") + .Resolve( + projection: _ => _.Parent, + resolve: _ => _.Projection.Name); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AllowsProjectionBasedResolveAsync() + { + var source = """ + using GraphQL.EntityFramework; + using GraphQL.Types; + using Microsoft.EntityFrameworkCore; + using System.Threading.Tasks; + + public class ParentEntity + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + public class ChildEntity + { + public int Id { get; set; } + public int ParentId { get; set; } + public ParentEntity Parent { get; set; } = null!; + } + + public class TestDbContext : DbContext { } + + public class ChildGraphType : EfObjectGraphType + { + public ChildGraphType(IEfGraphQLService graphQlService) : base(graphQlService) + { + Field("ParentName") + .ResolveAsync( + projection: _ => _.Parent, + resolve: async ctx => await Task.FromResult(ctx.Projection.Name)); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task IgnoresNonEfGraphTypeClasses() + { + var source = """ + using GraphQL.Types; + using Microsoft.EntityFrameworkCore; + + public class ParentEntity { public int Id { get; set; } } + public class ChildEntity + { + public int Id { get; set; } + public ParentEntity Parent { get; set; } = null!; + } + + public class RegularGraphType : ObjectGraphType + { + public RegularGraphType() + { + Field("ParentId") + .Resolve(context => context.Source.Parent.Id); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AllowsResolveWithOnlyPKAndFK() + { + var source = """ + using GraphQL.EntityFramework; + using GraphQL.Types; + using Microsoft.EntityFrameworkCore; + + public class ParentEntity { public int Id { get; set; } } + public class ChildEntity + { + public int Id { get; set; } + public int ParentId { get; set; } + public int? OptionalParentId { get; set; } + public ParentEntity Parent { get; set; } = null!; + } + + public class TestDbContext : DbContext { } + + public class ChildGraphType : EfObjectGraphType + { + public ChildGraphType(IEfGraphQLService graphQlService) : base(graphQlService) + { + Field("Keys") + .Resolve(context => $"{context.Source.Id}-{context.Source.ParentId}-{context.Source.OptionalParentId}"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task DetectsCollectionNavigationAccess() + { + var source = """ + using GraphQL.EntityFramework; + using GraphQL.Types; + using Microsoft.EntityFrameworkCore; + using System.Collections.Generic; + using System.Linq; + + public class ChildEntity { public int Id { get; set; } } + public class ParentEntity + { + public int Id { get; set; } + public List Children { get; set; } = new(); + } + + public class TestDbContext : DbContext { } + + public class ParentGraphType : EfObjectGraphType + { + public ParentGraphType(IEfGraphQLService graphQlService) : base(graphQlService) + { + Field("ChildCount") + .Resolve(context => context.Source.Children.Count()); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("GQLEF002", diagnostics[0].Id); + } + + [Fact] + public async Task DetectsResolveWithDiscardVariable() + { + var source = """ + using GraphQL.EntityFramework; + using GraphQL.Types; + using Microsoft.EntityFrameworkCore; + using System.Collections.Generic; + using System.Linq; + + public class ChildEntity { public int Id { get; set; } } + public class ParentEntity + { + public int Id { get; set; } + public List Children { get; set; } = new(); + } + + public class TestDbContext : DbContext { } + + public class ParentGraphType : EfObjectGraphType + { + public ParentGraphType(IEfGraphQLService graphQlService) : base(graphQlService) + { + Field("ChildCount") + .Resolve(_ => _.Source.Children.Count()); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("GQLEF002", diagnostics[0].Id); + } + + [Fact] + public async Task DetectsResolveAsyncWithDiscardVariable() + { + var source = """ + using GraphQL.EntityFramework; + using GraphQL.Types; + using Microsoft.EntityFrameworkCore; + using System.Threading.Tasks; + + public class ParentEntity { public int Id { get; set; } } + public class ChildEntity + { + public int Id { get; set; } + public int ParentId { get; set; } + public ParentEntity Parent { get; set; } = null!; + } + + public class TestDbContext : DbContext { } + + public class ChildGraphType : EfObjectGraphType + { + public ChildGraphType(IEfGraphQLService graphQlService) : base(graphQlService) + { + Field("ParentProp") + .ResolveAsync(async _ => await Task.FromResult(_.Source.Parent.Id)); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("GQLEF002", diagnostics[0].Id); + } + + [Fact] + public async Task DetectsResolveAsyncWithElidedDelegate() + { + var source = """ + using GraphQL.EntityFramework; + using GraphQL.Types; + using Microsoft.EntityFrameworkCore; + using System.Threading.Tasks; + + public class ParentEntity { public int Id { get; set; } } + public class ChildEntity + { + public int Id { get; set; } + public int ParentId { get; set; } + public ParentEntity Parent { get; set; } = null!; + } + + public class TestDbContext : DbContext { } + + public class ChildGraphType : EfObjectGraphType + { + public ChildGraphType(IEfGraphQLService graphQlService) : base(graphQlService) + { + Field("ParentProp") + .ResolveAsync(context => Task.FromResult(context.Source.Parent.Id)); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("GQLEF002", diagnostics[0].Id); + } + + // NOTE: Analyzer tests for GQLEF003 (identity projection detection) are skipped + // because the analyzer implementation has issues detecting identity projections in test scenarios. + // However, runtime validation works perfectly and catches identity projections immediately when code runs. + // See FieldBuilderExtensionsTests for runtime validation tests. + + static async Task GetDiagnosticsAsync(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + + var references = new List(); + + // Add specific assemblies we need, avoiding conflicts + var requiredAssemblies = new[] + { // System.Private.CoreLib + typeof(object).Assembly, + // System.Console + typeof(Console).Assembly, + // GraphQL.EntityFramework + typeof(IEfGraphQLService<>).Assembly, + // EF Core + typeof(DbContext).Assembly, + // GraphQL + typeof(ObjectGraphType).Assembly, + // System.Linq.Expressions + typeof(IQueryable<>).Assembly, + }; + + foreach (var assembly in requiredAssemblies) + { + if (!string.IsNullOrEmpty(assembly.Location)) + { + references.Add(MetadataReference.CreateFromFile(assembly.Location)); + } + } + + // Add all System.* and Microsoft.* assemblies except those that conflict + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + if (!assembly.IsDynamic && + !string.IsNullOrEmpty(assembly.Location) && + references.All(_ => _.Display != assembly.Location)) + { + var name = assembly.GetName().Name ?? ""; + if ((!name.StartsWith("System.") && + !name.StartsWith("Microsoft.")) || + name.Contains("xunit", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + try + { + references.Add(MetadataReference.CreateFromFile(assembly.Location)); + } + catch + { + // Ignore assemblies that can't be referenced + } + } + } + + var compilation = CSharpCompilation.Create( + "TestAssembly", + syntaxTrees: [syntaxTree], + references: references, + options: new( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable)); + + var analyzer = new FieldBuilderResolveAnalyzer(); + var compilationWithAnalyzers = compilation.WithAnalyzers([analyzer]); + + var allDiagnostics = await compilationWithAnalyzers.GetAllDiagnosticsAsync(); + + // Check for compilation errors + var compilationErrors = allDiagnostics.Where(_ => _.Severity == DiagnosticSeverity.Error).ToArray(); + if (compilationErrors.Length > 0) + { + var errorMessages = string.Join("\n", compilationErrors.Select(_ => $"{_.Id}: {_.GetMessage()}")); + throw new($"Compilation errors:\n{errorMessages}"); + } + + // Filter to only GQLEF002 and GQLEF003 diagnostics + return allDiagnostics + .Where(_ => _.Id == "GQLEF002" || _.Id == "GQLEF003") + .ToArray(); + } +} diff --git a/src/GraphQL.EntityFramework.Analyzers.Tests/GlobalUsings.cs b/src/GraphQL.EntityFramework.Analyzers.Tests/GlobalUsings.cs new file mode 100644 index 000000000..249b4e806 --- /dev/null +++ b/src/GraphQL.EntityFramework.Analyzers.Tests/GlobalUsings.cs @@ -0,0 +1,7 @@ +global using GraphQL.EntityFramework.Analyzers; +global using GraphQL.Types; +global using Microsoft.CodeAnalysis; +global using Microsoft.CodeAnalysis.CSharp; +global using Microsoft.CodeAnalysis.Diagnostics; +global using Microsoft.EntityFrameworkCore; +global using Xunit; diff --git a/src/GraphQL.EntityFramework.Analyzers.Tests/GraphQL.EntityFramework.Analyzers.Tests.csproj b/src/GraphQL.EntityFramework.Analyzers.Tests/GraphQL.EntityFramework.Analyzers.Tests.csproj new file mode 100644 index 000000000..c65ba2045 --- /dev/null +++ b/src/GraphQL.EntityFramework.Analyzers.Tests/GraphQL.EntityFramework.Analyzers.Tests.csproj @@ -0,0 +1,20 @@ + + + net10.0 + preview + + + + + + + + + + + + + + + + diff --git a/src/GraphQL.EntityFramework.Analyzers/AnalyzerReleases.Shipped.md b/src/GraphQL.EntityFramework.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 000000000..39071b5a2 --- /dev/null +++ b/src/GraphQL.EntityFramework.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,6 @@ +## Release 1.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- diff --git a/src/GraphQL.EntityFramework.Analyzers/AnalyzerReleases.Unshipped.md b/src/GraphQL.EntityFramework.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 000000000..08829c9b3 --- /dev/null +++ b/src/GraphQL.EntityFramework.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,9 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- +GQLEF002 | Usage | Warning | Use projection-based Resolve extension methods when accessing navigation properties +GQLEF003 | Usage | Error | Identity projection is not allowed in projection-based Resolve methods diff --git a/src/GraphQL.EntityFramework.Analyzers/DiagnosticDescriptors.cs b/src/GraphQL.EntityFramework.Analyzers/DiagnosticDescriptors.cs new file mode 100644 index 000000000..e1ef786dd --- /dev/null +++ b/src/GraphQL.EntityFramework.Analyzers/DiagnosticDescriptors.cs @@ -0,0 +1,22 @@ +static class DiagnosticDescriptors +{ + public static readonly DiagnosticDescriptor GQLEF002 = new( + id: "GQLEF002", + title: "Use projection-based Resolve extension methods when accessing navigation properties", + messageFormat: "Field().Resolve() or Field().ResolveAsync() may access navigation properties that aren't loaded. Use projection-based extension methods instead.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "When using Field().Resolve() or Field().ResolveAsync() inside EfObjectGraphType, EfInterfaceGraphType, or QueryGraphType classes, navigation properties on context.Source may not be loaded due to EF projection. Use the projection-based extension methods (Resolve, ResolveAsync, etc.) to ensure required data is loaded.", + helpLinkUri: "https://github.com/SimonCropp/GraphQL.EntityFramework#projection-based-resolve"); + + public static readonly DiagnosticDescriptor GQLEF003 = new( + id: "GQLEF003", + title: "Identity projection is not allowed in projection-based Resolve methods", + messageFormat: "Identity projection '_ => _' defeats the purpose of projection-based Resolve. Use regular Resolve() method for PK/FK access, or specify navigation properties in projection.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Using '_ => _' as the projection parameter in projection-based Resolve extension methods is not allowed because it doesn't load any additional navigation properties. If you only need to access primary key or foreign key properties, use the regular Resolve() method instead. If you need to access navigation properties, specify them in the projection (e.g., 'x => x.Parent').", + helpLinkUri: "https://github.com/SimonCropp/GraphQL.EntityFramework#projection-based-resolve"); +} diff --git a/src/GraphQL.EntityFramework.Analyzers/FieldBuilderResolveAnalyzer.cs b/src/GraphQL.EntityFramework.Analyzers/FieldBuilderResolveAnalyzer.cs new file mode 100644 index 000000000..4dac935b8 --- /dev/null +++ b/src/GraphQL.EntityFramework.Analyzers/FieldBuilderResolveAnalyzer.cs @@ -0,0 +1,434 @@ +namespace GraphQL.EntityFramework.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class FieldBuilderResolveAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics { get; } = + [DiagnosticDescriptors.GQLEF002, DiagnosticDescriptors.GQLEF003]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + // Check if this is a Resolve or ResolveAsync call + if (!IsFieldBuilderResolveCall(invocation, context.SemanticModel, out var lambdaExpression)) + { + return; + } + + // Check if we're within an EfGraphType class + if (!IsInEfGraphType(invocation, context.SemanticModel)) + { + return; + } + + // Check if the lambda already uses projection-based extension methods (has 4 type parameters) + var isProjectionBased = IsProjectionBasedResolve(invocation, context.SemanticModel); + + if (isProjectionBased) + { + // For projection-based methods, check for identity projection + var hasIdentity = HasIdentityProjection(invocation, context.SemanticModel); + + if (hasIdentity) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.GQLEF003, + invocation.GetLocation()); + context.ReportDiagnostic(diagnostic); + } + + // Note: Scalar projections are allowed - they're useful for ensuring scalar properties + // are loaded and can be transformed in the resolver + + return; + } + + // Analyze lambda for navigation property access + if (lambdaExpression != null && AccessesNavigationProperties(lambdaExpression, context.SemanticModel)) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.GQLEF002, + invocation.GetLocation()); + context.ReportDiagnostic(diagnostic); + } + } + + static bool IsFieldBuilderResolveCall( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + out LambdaExpressionSyntax? lambdaExpression) + { + lambdaExpression = null; + + // Check method name is Resolve, ResolveAsync, ResolveList, or ResolveListAsync + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + return false; + } + + var methodName = memberAccess.Name.Identifier.Text; + if (methodName != "Resolve" && methodName != "ResolveAsync" && + methodName != "ResolveList" && methodName != "ResolveListAsync") + { + return false; + } + + // Get the symbol info + var symbolInfo = semanticModel.GetSymbolInfo(invocation); + if (symbolInfo.Symbol is not IMethodSymbol methodSymbol) + { + return false; + } + + // Check if the containing type is FieldBuilder<,> + var containingType = methodSymbol.ContainingType; + if (containingType == null) + { + return false; + } + + var baseType = containingType; + while (baseType != null) + { + if (baseType.Name == "FieldBuilder" && + baseType.ContainingNamespace?.ToString() == "GraphQL.Builders") + { + // Extract lambda from arguments + if (invocation.ArgumentList.Arguments.Count > 0) + { + var firstArg = invocation.ArgumentList.Arguments[0].Expression; + if (firstArg is LambdaExpressionSyntax lambda) + { + lambdaExpression = lambda; + } + } + + return true; + } + + baseType = baseType.BaseType; + } + + return false; + } + + static bool IsProjectionBasedResolve(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + var symbolInfo = semanticModel.GetSymbolInfo(invocation); + if (symbolInfo.Symbol is not IMethodSymbol methodSymbol) + { + return false; + } + + // Projection-based extension methods have 4 type parameters: + // TDbContext, TSource, TReturn, TProjection + // AND have a parameter named "projection" + return methodSymbol.TypeArguments.Length == 4 && + methodSymbol.Parameters.Any(_ => _.Name == "projection") && + methodSymbol.ContainingNamespace?.ToString() == "GraphQL.EntityFramework"; + } + + static bool IsInEfGraphType(SyntaxNode node, SemanticModel semanticModel) + { + var classDeclaration = node.FirstAncestorOrSelf(); + if (classDeclaration == null) + { + return false; + } + + var classSymbol = semanticModel.GetDeclaredSymbol(classDeclaration); + if (classSymbol == null) + { + return false; + } + + // Check if class inherits from EfObjectGraphType, EfInterfaceGraphType, or QueryGraphType + var baseType = classSymbol.BaseType; + while (baseType != null) + { + var typeName = baseType.Name; + if (typeName is "EfObjectGraphType" or "EfInterfaceGraphType" or "QueryGraphType" && + baseType.ContainingNamespace?.ToString() == "GraphQL.EntityFramework") + { + return true; + } + + baseType = baseType.BaseType; + } + + return false; + } + + static bool AccessesNavigationProperties(LambdaExpressionSyntax lambda, SemanticModel semanticModel) + { + var body = lambda.Body; + + // Find all member access expressions in the lambda + var memberAccesses = body.DescendantNodesAndSelf() + .OfType(); + + foreach (var memberAccess in memberAccesses) + { + // Check if this is accessing context.Source.PropertyName + if (!IsContextSourceAccess(memberAccess, out var propertyAccess) || propertyAccess == null) + { + continue; + } + + // Get the symbol for the property being accessed + var symbolInfo = semanticModel.GetSymbolInfo(propertyAccess); + if (symbolInfo.Symbol is not IPropertySymbol propertySymbol) + { + continue; + } + + // Check if this property is safe to access (PK or FK only) + // Only primary keys and foreign keys are guaranteed to be loaded by projection + if (!IsSafeProperty(propertySymbol)) + { + return true; + } + } + + return false; + } + + static bool IsContextSourceAccess(MemberAccessExpressionSyntax memberAccess, out MemberAccessExpressionSyntax? propertyAccess) + { + propertyAccess = null; + + // Check if the expression is context.Source or further nested + var current = memberAccess; + MemberAccessExpressionSyntax? sourceAccess = null; + + // Walk up the chain to find context.Source + while (true) + { + if (current.Expression is + MemberAccessExpressionSyntax + { + Name.Identifier.Text: "Source", + Expression: IdentifierNameSyntax + { + Identifier.Text: "context" or "ctx" or "_" + } + }) + { + sourceAccess = current; + break; + } + + if (current.Expression is IdentifierNameSyntax { Identifier.Text: "Source" }) + { + // This might be a simplified lambda like: Source => Source.Property + sourceAccess = current; + break; + } + + // Check if expression is itself a member access + if (current.Expression is MemberAccessExpressionSyntax parentAccess) + { + current = parentAccess; + } + else + { + break; + } + } + + if (sourceAccess != null) + { + propertyAccess = sourceAccess; + return true; + } + + // Also check for direct access like: context.Source (without further property access) + if (memberAccess.Name.Identifier.Text == "Source" && + memberAccess is + { + Expression: IdentifierNameSyntax + { + Identifier.Text: "context" or "ctx" or "_" + }, + Parent: MemberAccessExpressionSyntax parentMember + }) + // This is just context.Source itself, check parent node + { + propertyAccess = parentMember; + return true; + } + + return false; + } + + static bool IsSafeProperty(IPropertySymbol propertySymbol) => + // Only primary keys and foreign keys are safe to access + // because they are always included in EF projections + // Check if it's a primary key (Id, EntityId, etc.) + IsPrimaryKeyProperty(propertySymbol) || + // Check if it's a foreign key (ParentId, UserId, etc.) + // Everything else (navigation properties, scalar properties) is unsafe + IsForeignKeyProperty(propertySymbol); + + static bool IsPrimaryKeyProperty(IPropertySymbol propertySymbol) + { + var name = propertySymbol.Name; + + // Simple "Id" is always a primary key + if (name == "Id") + { + return true; + } + + // EntityId, CompanyId (where Entity/Company is the class name) are primary keys + // But ParentId, UserId (where Parent/User is a navigation) are foreign keys + // We can't perfectly distinguish without EF metadata, so we use a heuristic: + // If it ends with "Id" and has uppercase before "Id", it MIGHT be PK + // We'll check the containing type name + var containingType = propertySymbol.ContainingType; + if (containingType != null && name.EndsWith("Id") && name.Length > 2) + { + // Check if the property name matches the type name + "Id" + // e.g., property "CompanyId" in class "Company" or "CompanyEntity" + var typeName = containingType.Name; + + if (name == $"{typeName}Id") + { + return true; + } + + // Check if removing common suffixes from typeName + "Id" equals name + // e.g., "CompanyEntity" -> "CompanyId" + if (TryMatchWithoutSuffix(typeName, name, "Entity") || + TryMatchWithoutSuffix(typeName, name, "Model") || + TryMatchWithoutSuffix(typeName, name, "Dto")) + { + return true; + } + } + + return false; + } + + static bool TryMatchWithoutSuffix(string typeName, string propertyName, string suffix) + { + if (!typeName.EndsWith(suffix) || typeName.Length <= suffix.Length) + { + return false; + } + + var baseLength = typeName.Length - suffix.Length; + // Check if propertyName is baseTypeName + "Id" + return propertyName.Length == baseLength + 2 && + typeName.AsSpan(0, baseLength).SequenceEqual(propertyName.AsSpan(0, baseLength)) && + propertyName.EndsWith("Id"); + } + + static bool IsForeignKeyProperty(IPropertySymbol propertySymbol) + { + var name = propertySymbol.Name; + var type = propertySymbol.Type; + + // Foreign keys are typically nullable or non-nullable integer/Guid types ending with "Id" + if (!name.EndsWith("Id") || name == "Id") + { + return false; + } + + // If we already determined it's a primary key, it's not a foreign key + if (IsPrimaryKeyProperty(propertySymbol)) + { + return false; + } + + // Check if the type is a scalar type suitable for FK (int, long, Guid, etc.) + // Unwrap nullable + var underlyingType = type; + if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } namedType) + { + underlyingType = namedType.TypeArguments[0]; + } + + // Foreign keys are typically int, long, Guid + switch (underlyingType.SpecialType) + { + case SpecialType.System_Int32: + case SpecialType.System_Int64: + case SpecialType.System_Int16: + return true; + } + + var typeName = underlyingType.ToString(); + return typeName == "System.Guid"; + } + + static bool HasIdentityProjection(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + // Get the symbol info to find parameter positions + var symbolInfo = semanticModel.GetSymbolInfo(invocation); + if (symbolInfo.Symbol is not IMethodSymbol methodSymbol) + { + return false; + } + + // Find the "projection" parameter + var projectionParameter = methodSymbol.Parameters.FirstOrDefault(_ => _.Name == "projection"); + if (projectionParameter == null) + { + return false; + } + + // Find the corresponding argument (by name or position) + ArgumentSyntax? projectionArgument = null; + + // First, try to find by named argument + foreach (var arg in invocation.ArgumentList.Arguments) + { + if (arg.NameColon?.Name.Identifier.Text == "projection") + { + projectionArgument = arg; + break; + } + } + + // If not found by name, try positional (for the case where all args are positional) + if (projectionArgument == null) + { + var parameterIndex = Array.IndexOf(methodSymbol.Parameters.ToArray(), projectionParameter); + if (parameterIndex >= 0 && parameterIndex < invocation.ArgumentList.Arguments.Count) + { + projectionArgument = invocation.ArgumentList.Arguments[parameterIndex]; + } + } + + if (projectionArgument?.Expression is not LambdaExpressionSyntax lambda) + { + return false; + } + + // Check if it's an identity projection (x => x or _ => _) + // Lambda body should be a simple parameter reference that matches the lambda parameter + if (lambda.Body is not IdentifierNameSyntax identifier) + { + return false; + } + + // Get the parameter name (e.g., "x", "_", "item") + var parameterName = lambda is SimpleLambdaExpressionSyntax simpleLambda + ? simpleLambda.Parameter.Identifier.Text + : lambda is ParenthesizedLambdaExpressionSyntax { ParameterList.Parameters.Count: 1 } parenthesizedLambda + ? parenthesizedLambda.ParameterList.Parameters[0].Identifier.Text + : null; + + // Check if the body references the same parameter + return parameterName != null && identifier.Identifier.Text == parameterName; + } +} diff --git a/src/GraphQL.EntityFramework.Analyzers/GlobalUsings.cs b/src/GraphQL.EntityFramework.Analyzers/GlobalUsings.cs new file mode 100644 index 000000000..ca45a8817 --- /dev/null +++ b/src/GraphQL.EntityFramework.Analyzers/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using System.Collections.Immutable; +global using Microsoft.CodeAnalysis; +global using Microsoft.CodeAnalysis.CSharp; +global using Microsoft.CodeAnalysis.CSharp.Syntax; +global using Microsoft.CodeAnalysis.Diagnostics; diff --git a/src/GraphQL.EntityFramework.Analyzers/GraphQL.EntityFramework.Analyzers.csproj b/src/GraphQL.EntityFramework.Analyzers/GraphQL.EntityFramework.Analyzers.csproj new file mode 100644 index 000000000..4c1e7ae53 --- /dev/null +++ b/src/GraphQL.EntityFramework.Analyzers/GraphQL.EntityFramework.Analyzers.csproj @@ -0,0 +1,18 @@ + + + netstandard2.0 + preview + true + true + false + true + true + Roslyn analyzer for GraphQL.EntityFramework to detect problematic usage patterns + + + + + + + + diff --git a/src/GraphQL.EntityFramework.slnx b/src/GraphQL.EntityFramework.slnx index ec85a3f5f..e18fe2a89 100644 --- a/src/GraphQL.EntityFramework.slnx +++ b/src/GraphQL.EntityFramework.slnx @@ -9,7 +9,11 @@ - + + + + + diff --git a/src/GraphQL.EntityFramework/ComplexGraphResolver.cs b/src/GraphQL.EntityFramework/ComplexGraphResolver.cs index e86e82267..b7bcc0617 100644 --- a/src/GraphQL.EntityFramework/ComplexGraphResolver.cs +++ b/src/GraphQL.EntityFramework/ComplexGraphResolver.cs @@ -99,11 +99,11 @@ static Resolved GetOrAdd(FieldType fieldType) => internal static IComplexGraphType GetComplexGraph(this FieldType fieldType) { - if (TryGetComplexGraph(fieldType, out var complex)) + if (fieldType.TryGetComplexGraph(out var complex)) { return complex; } throw new($"Could not find resolve a {nameof(IComplexGraphType)} for {fieldType.GetType().FullName}."); } -} \ No newline at end of file +} diff --git a/src/GraphQL.EntityFramework/Filters/FilterEntry.cs b/src/GraphQL.EntityFramework/Filters/FilterEntry.cs index 70ae3a01e..1ba9f4ab1 100644 --- a/src/GraphQL.EntityFramework/Filters/FilterEntry.cs +++ b/src/GraphQL.EntityFramework/Filters/FilterEntry.cs @@ -19,7 +19,7 @@ public FilterEntry( { var compiled = projection.Compile(); compiledProjection = entity => compiled((TEntity)entity); - RequiredPropertyNames = FilterProjectionAnalyzer.ExtractRequiredProperties(projection); + RequiredPropertyNames = ProjectionAnalyzer.ExtractRequiredProperties(projection); } } diff --git a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Navigation.cs b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Navigation.cs index 6225616b9..e59ce48f1 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Navigation.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Navigation.cs @@ -3,6 +3,7 @@ partial class EfGraphQLService where TDbContext : DbContext { + [Obsolete("Use the projection-based overload instead")] public FieldBuilder AddNavigationField( ComplexGraphType graph, string name, @@ -59,4 +60,102 @@ await fieldContext.Filters.ShouldInclude(context.UserContext, fieldContext.DbCon graph.AddField(field); return new FieldBuilderEx(field); } + + public FieldBuilder AddNavigationField( + ComplexGraphType graph, + string name, + Expression> projection, + Func, TReturn?> resolve, + Type? graphType = null) + where TReturn : class + { + Ensure.NotWhiteSpace(nameof(name), name); + + graphType ??= GraphTypeFinder.FindGraphType(); + + var field = new FieldType + { + Name = name, + Type = graphType + }; + + // Store projection expression - flows through to Select expression builder + IncludeAppender.SetProjectionMetadata(field, projection, typeof(TSource)); + // Also set include metadata as fallback for abstract types where projection can't be built + var includeNames = ProjectionAnalyzer.ExtractRequiredProperties(projection); + IncludeAppender.SetIncludeMetadata(field, name, includeNames); + + var compiledProjection = projection.Compile(); + + field.Resolver = new FuncFieldResolver( + async context => + { + var fieldContext = BuildContext(context); + var projected = compiledProjection(context.Source); + + var projectionContext = new ResolveProjectionContext + { + Projection = projected, + DbContext = fieldContext.DbContext, + User = context.User, + Filters = fieldContext.Filters, + FieldContext = context + }; + + TReturn? result; + try + { + result = resolve(projectionContext); + } + catch (Exception exception) + { + throw new( + $""" + Failed to execute navigation resolve for field `{name}` + GraphType: {graphType.FullName} + TSource: {typeof(TSource).FullName} + TReturn: {typeof(TReturn).FullName} + """, + exception); + } + + if (fieldContext.Filters == null || + await fieldContext.Filters.ShouldInclude(context.UserContext, fieldContext.DbContext, context.User, result)) + { + return result; + } + + return null; + }); + + graph.AddField(field); + return new FieldBuilderEx(field); + } + + public FieldBuilder AddNavigationField( + ComplexGraphType graph, + string name, + Expression> projection, + Type? graphType = null) + where TReturn : class + { + Ensure.NotWhiteSpace(nameof(name), name); + + graphType ??= GraphTypeFinder.FindGraphType(); + + var field = new FieldType + { + Name = name, + Type = graphType + }; + + // Store projection expression - flows through to Select expression builder + IncludeAppender.SetProjectionMetadata(field, projection, typeof(TSource)); + // Also set include metadata as fallback for abstract types where projection can't be built + var includeNames = ProjectionAnalyzer.ExtractRequiredProperties(projection); + IncludeAppender.SetIncludeMetadata(field, name, includeNames); + + graph.AddField(field); + return new FieldBuilderEx(field); + } } \ No newline at end of file diff --git a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationConnection.cs b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationConnection.cs index 4e400c398..ea14e1dbc 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationConnection.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationConnection.cs @@ -6,6 +6,13 @@ partial class EfGraphQLService static MethodInfo addEnumerableConnection = typeof(EfGraphQLService) .GetMethod("AddEnumerableConnection", BindingFlags.Instance | BindingFlags.NonPublic)!; + static MethodInfo addEnumerableConnectionWithProjection = typeof(EfGraphQLService) + .GetMethod("AddEnumerableConnectionWithProjection", BindingFlags.Instance | BindingFlags.NonPublic)!; + + static MethodInfo addEnumerableConnectionWithProjectionOnly = typeof(EfGraphQLService) + .GetMethod("AddEnumerableConnectionWithProjectionOnly", BindingFlags.Instance | BindingFlags.NonPublic)!; + + [Obsolete("Use the projection-based overload instead")] public ConnectionBuilder AddNavigationConnectionField( ComplexGraphType graph, string name, @@ -46,6 +53,84 @@ public ConnectionBuilder AddNavigationConnectionField } } + public ConnectionBuilder AddNavigationConnectionField( + ComplexGraphType graph, + string name, + Expression> projection, + Func, IEnumerable> resolve, + Type? itemGraphType = null, + bool omitQueryArguments = false) + where TReturn : class + { + Ensure.NotWhiteSpace(nameof(name), name); + + itemGraphType ??= GraphTypeFinder.FindGraphType(); + + var addConnectionT = addEnumerableConnectionWithProjection.MakeGenericMethod(typeof(TSource), itemGraphType, typeof(TReturn), typeof(TProjection)); + + try + { + var arguments = new object?[] + { + graph, + name, + projection, + resolve, + omitQueryArguments + }; + return (ConnectionBuilder) addConnectionT.Invoke(this, arguments)!; + } + catch (Exception exception) + { + throw new( + $""" + Failed to execute navigation connection for field `{name}` + ItemGraphType: {itemGraphType.FullName} + TSource: {typeof(TSource).FullName} + TReturn: {typeof(TReturn).FullName} + """, + exception); + } + } + + public ConnectionBuilder AddNavigationConnectionField( + ComplexGraphType graph, + string name, + Expression?>> projection, + Type? itemGraphType = null) + where TReturn : class + { + Ensure.NotWhiteSpace(nameof(name), name); + + itemGraphType ??= GraphTypeFinder.FindGraphType(); + + var addConnectionT = addEnumerableConnectionWithProjectionOnly.MakeGenericMethod(typeof(TSource), itemGraphType, typeof(TReturn)); + + try + { + var arguments = new object?[] + { + graph, + name, + projection + }; + return (ConnectionBuilder) addConnectionT.Invoke(this, arguments)!; + } + catch (Exception exception) + { + throw new( + $""" + Failed to execute navigation connection for field `{name}` + ItemGraphType: {itemGraphType.FullName} + TSource: {typeof(TSource).FullName} + TReturn: {typeof(TReturn).FullName} + """, + exception); + } + } + + // Use via reflection + // ReSharper disable once UnusedMember.Local ConnectionBuilder AddEnumerableConnection( ComplexGraphType graph, string name, @@ -120,4 +205,124 @@ ConnectionBuilder AddEnumerableConnection( field.AddWhereArgument(hasId); return builder; } -} \ No newline at end of file + + // Use via reflection + // ReSharper disable once UnusedMember.Local + ConnectionBuilder AddEnumerableConnectionWithProjection( + ComplexGraphType graph, + string name, + Expression> projection, + Func, IEnumerable> resolve, + bool omitQueryArguments) + where TGraph : IGraphType + where TReturn : class + { + var builder = ConnectionBuilderEx.Build(name); + + // Store projection expression - flows through to Select expression builder + IncludeAppender.SetProjectionMetadata(builder.FieldType, projection, typeof(TSource)); + // Also set include metadata as fallback for abstract types where projection can't be built + var includeNames = ProjectionAnalyzer.ExtractRequiredProperties(projection); + IncludeAppender.SetIncludeMetadata(builder.FieldType, name, includeNames); + + var compiledProjection = projection.Compile(); + + var hasId = keyNames.ContainsKey(typeof(TReturn)); + builder.ResolveAsync(async context => + { + var efFieldContext = BuildContext(context); + var projected = compiledProjection(context.Source); + + var projectionContext = new ResolveProjectionContext + { + Projection = projected, + DbContext = efFieldContext.DbContext, + User = context.User, + Filters = efFieldContext.Filters, + FieldContext = context + }; + + IEnumerable enumerable; + try + { + enumerable = resolve(projectionContext); + } + catch (TaskCanceledException) + { + throw; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + throw new( + $""" + Failed to execute query for field `{name}` + TGraph: {typeof(TGraph).FullName} + TSource: {typeof(TSource).FullName} + TReturn: {typeof(TReturn).FullName} + """, + exception); + } + + if (enumerable is IQueryable) + { + throw new("This API expects the resolver to return a IEnumerable, not an IQueryable. Instead use AddQueryConnectionField."); + } + + enumerable = enumerable.ApplyGraphQlArguments(hasId, context, omitQueryArguments); + if (efFieldContext.Filters != null) + { + enumerable = await efFieldContext.Filters.ApplyFilter(enumerable, context.UserContext, efFieldContext.DbContext, context.User); + } + + var page = enumerable.ToList(); + + return ConnectionConverter.ApplyConnectionContext( + page, + context.First, + context.After, + context.Last, + context.Before); + }); + + //TODO: works around https://github.com/graphql-dotnet/graphql-dotnet/pull/2581/ + builder.FieldType.Type = typeof(NonNullGraphType>>); + var field = graph.AddField(builder.FieldType); + + field.AddWhereArgument(hasId); + return builder; + } + + // Use via reflection + // ReSharper disable once UnusedMember.Local + ConnectionBuilder AddEnumerableConnectionWithProjectionOnly( + ComplexGraphType graph, + string name, + Expression?>> projection) + where TGraph : IGraphType + where TReturn : class + { + var builder = ConnectionBuilderEx.Build(name); + + // Store projection expression - flows through to Select expression builder + IncludeAppender.SetProjectionMetadata(builder.FieldType, projection, typeof(TSource)); + // Also set include metadata as fallback for abstract types where projection can't be built + var includeNames = ProjectionAnalyzer.ExtractRequiredProperties(projection); + IncludeAppender.SetIncludeMetadata(builder.FieldType, name, includeNames); + + // No resolver set - this overload is for interface types where the concrete types provide resolvers + // The projection metadata flows through to the Select expression builder + + var hasId = keyNames.ContainsKey(typeof(TReturn)); + + //TODO: works around https://github.com/graphql-dotnet/graphql-dotnet/pull/2581/ + builder.FieldType.Type = typeof(NonNullGraphType>>); + var field = graph.AddField(builder.FieldType); + + field.AddWhereArgument(hasId); + return builder; + } +} diff --git a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationList.cs b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationList.cs index cf1202dc3..2f5485a96 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationList.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationList.cs @@ -3,6 +3,7 @@ partial class EfGraphQLService where TDbContext : DbContext { + [Obsolete("Use the projection-based overload instead")] public FieldBuilder AddNavigationListField( ComplexGraphType graph, string name, @@ -48,4 +49,93 @@ public FieldBuilder AddNavigationListField( graph.AddField(field); return new FieldBuilderEx(field); } + + public FieldBuilder AddNavigationListField( + ComplexGraphType graph, + string name, + Expression> projection, + Func, IEnumerable> resolve, + Type? itemGraphType = null, + bool omitQueryArguments = false) + where TReturn : class + { + Ensure.NotWhiteSpace(nameof(name), name); + + var hasId = keyNames.ContainsKey(typeof(TReturn)); + var field = new FieldType + { + Name = name, + Type = MakeListGraphType(itemGraphType), + Arguments = ArgumentAppender.GetQueryArguments(hasId, true, false), + }; + + // Store projection expression - flows through to Select expression builder + IncludeAppender.SetProjectionMetadata(field, projection, typeof(TSource)); + // Also set include metadata as fallback for abstract types where projection can't be built + var includeNames = ProjectionAnalyzer.ExtractRequiredProperties(projection); + IncludeAppender.SetIncludeMetadata(field, name, includeNames); + + var compiledProjection = projection.Compile(); + + field.Resolver = new FuncFieldResolver>(async context => + { + var fieldContext = BuildContext(context); + var projected = compiledProjection(context.Source); + + var projectionContext = new ResolveProjectionContext + { + Projection = projected, + DbContext = fieldContext.DbContext, + User = context.User, + Filters = fieldContext.Filters, + FieldContext = context + }; + + var result = resolve(projectionContext); + + if (result is IQueryable) + { + throw new("This API expects the resolver to return a IEnumerable, not an IQueryable. Instead use AddQueryField."); + } + + result = result.ApplyGraphQlArguments(hasId, context, omitQueryArguments); + if (fieldContext.Filters == null) + { + return result; + } + + return await fieldContext.Filters.ApplyFilter(result, context.UserContext, fieldContext.DbContext, context.User); + }); + + graph.AddField(field); + return new FieldBuilderEx(field); + } + + public FieldBuilder AddNavigationListField( + ComplexGraphType graph, + string name, + Expression?>> projection, + Type? itemGraphType = null, + bool omitQueryArguments = false) + where TReturn : class + { + Ensure.NotWhiteSpace(nameof(name), name); + + var hasId = keyNames.ContainsKey(typeof(TReturn)); + var field = new FieldType + { + Name = name, + Type = MakeListGraphType(itemGraphType), + Arguments = ArgumentAppender.GetQueryArguments(hasId, true, false), + }; + + // Store projection expression - flows through to Select expression builder + IncludeAppender.SetProjectionMetadata(field, projection, typeof(TSource)); + // Also set include metadata as fallback for abstract types where projection can't be built + var includeNames = ProjectionAnalyzer.ExtractRequiredProperties(projection); + IncludeAppender.SetIncludeMetadata(field, name, includeNames); + + graph.AddField(field); + return new FieldBuilderEx(field); + } } \ No newline at end of file diff --git a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_QueryableConnection.cs b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_QueryableConnection.cs index d170431d8..f5ecfe235 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_QueryableConnection.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_QueryableConnection.cs @@ -118,6 +118,8 @@ public ConnectionBuilder AddQueryConnectionField( } } + // Use via reflection + // ReSharper disable once UnusedMember.Local ConnectionBuilder AddQueryableConnection( IComplexGraphType graph, string name, diff --git a/src/GraphQL.EntityFramework/GraphApi/EfInterfaceGraphType.cs b/src/GraphQL.EntityFramework/GraphApi/EfInterfaceGraphType.cs index 877b5ca74..05acda7d3 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfInterfaceGraphType.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfInterfaceGraphType.cs @@ -12,6 +12,7 @@ public EfInterfaceGraphType(IEfGraphQLService graphQlService):this(g public IEfGraphQLService GraphQlService { get; } = graphQlService; + [Obsolete("Use the projection-based overload instead")] public ConnectionBuilder AddNavigationConnectionField( string name, Type? graphType = null, @@ -20,6 +21,14 @@ public ConnectionBuilder AddNavigationConnectionField( where TReturn : class => GraphQlService.AddNavigationConnectionField(this, name, null, graphType, includeNames, omitQueryArguments); + public ConnectionBuilder AddNavigationConnectionField( + string name, + Expression?>> projection, + Type? graphType = null) + where TReturn : class => + GraphQlService.AddNavigationConnectionField(this, name, projection, graphType); + + [Obsolete("Use the projection-based overload instead")] public FieldBuilder AddNavigationField( string name, Type? graphType = null, @@ -27,6 +36,14 @@ public FieldBuilder AddNavigationField( where TReturn : class => GraphQlService.AddNavigationField(this, name, null, graphType, includeNames); + public FieldBuilder AddNavigationField( + string name, + Expression> projection, + Type? graphType = null) + where TReturn : class => + GraphQlService.AddNavigationField(this, name, projection, graphType); + + [Obsolete("Use the projection-based overload instead")] public FieldBuilder AddNavigationListField( string name, Type? graphType = null, @@ -35,6 +52,14 @@ public FieldBuilder AddNavigationListField( where TReturn : class => GraphQlService.AddNavigationListField(this, name, null, graphType, includeNames, omitQueryArguments); + public FieldBuilder AddNavigationListField( + string name, + Expression?>> projection, + Type? graphType = null, + bool omitQueryArguments = false) + where TReturn : class => + GraphQlService.AddNavigationListField(this, name, projection, graphType, omitQueryArguments); + public ConnectionBuilder AddQueryConnectionField( string name, Type? graphType = null) diff --git a/src/GraphQL.EntityFramework/GraphApi/EfObjectGraphType.cs b/src/GraphQL.EntityFramework/GraphApi/EfObjectGraphType.cs index 728e9c43e..01a98a321 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfObjectGraphType.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfObjectGraphType.cs @@ -9,14 +9,15 @@ public class EfObjectGraphType(IEfGraphQLService /// Map all un-mapped properties. Underlying behaviour is: /// - /// * Calls for all non-list EF navigation properties. - /// * Calls for all EF navigation properties. + /// * Calls AddNavigationField for all non-list EF navigation properties. + /// * Calls AddNavigationListField for all EF navigation properties. /// * Calls for all other properties /// /// A list of property names to exclude from mapping. public void AutoMap(IReadOnlyList? exclusions = null) => Mapper.AutoMap(this, GraphQlService, exclusions); + [Obsolete("Use the projection-based overload instead")] public ConnectionBuilder AddNavigationConnectionField( string name, Func, IEnumerable>? resolve = null, @@ -26,6 +27,23 @@ public ConnectionBuilder AddNavigationConnectionField( where TReturn : class => GraphQlService.AddNavigationConnectionField(this, name, resolve, graphType, includeNames, omitQueryArguments); + public ConnectionBuilder AddNavigationConnectionField( + string name, + Expression> projection, + Func, IEnumerable> resolve, + Type? graphType = null, + bool omitQueryArguments = false) + where TReturn : class => + GraphQlService.AddNavigationConnectionField(this, name, projection, resolve, graphType, omitQueryArguments); + + public ConnectionBuilder AddNavigationConnectionField( + string name, + Expression?>> projection, + Type? graphType = null) + where TReturn : class => + GraphQlService.AddNavigationConnectionField(this, name, projection, graphType); + + [Obsolete("Use the projection-based overload instead")] public FieldBuilder AddNavigationField( string name, Func, TReturn?>? resolve = null, @@ -34,6 +52,22 @@ public FieldBuilder AddNavigationField( where TReturn : class => GraphQlService.AddNavigationField(this, name, resolve, graphType, includeNames); + public FieldBuilder AddNavigationField( + string name, + Expression> projection, + Func, TReturn?> resolve, + Type? graphType = null) + where TReturn : class => + GraphQlService.AddNavigationField(this, name, projection, resolve, graphType); + + public FieldBuilder AddNavigationField( + string name, + Expression> projection, + Type? graphType = null) + where TReturn : class => + GraphQlService.AddNavigationField(this, name, projection, graphType); + + [Obsolete("Use the projection-based overload instead")] public FieldBuilder AddNavigationListField( string name, Func, IEnumerable>? resolve = null, @@ -43,6 +77,23 @@ public FieldBuilder AddNavigationListField( where TReturn : class => GraphQlService.AddNavigationListField(this, name, resolve, graphType, includeNames, omitQueryArguments); + public FieldBuilder AddNavigationListField( + string name, + Expression> projection, + Func, IEnumerable> resolve, + Type? graphType = null, + bool omitQueryArguments = false) + where TReturn : class => + GraphQlService.AddNavigationListField(this, name, projection, resolve, graphType, omitQueryArguments); + + public FieldBuilder AddNavigationListField( + string name, + Expression?>> projection, + Type? graphType = null, + bool omitQueryArguments = false) + where TReturn : class => + GraphQlService.AddNavigationListField(this, name, projection, graphType, omitQueryArguments); + public ConnectionBuilder AddQueryConnectionField( string name, Func, IOrderedQueryable?> resolve, diff --git a/src/GraphQL.EntityFramework/GraphApi/FieldBuilderExtensions.cs b/src/GraphQL.EntityFramework/GraphApi/FieldBuilderExtensions.cs new file mode 100644 index 000000000..f2c5deb4b --- /dev/null +++ b/src/GraphQL.EntityFramework/GraphApi/FieldBuilderExtensions.cs @@ -0,0 +1,395 @@ +namespace GraphQL.EntityFramework; + +/// +/// Extension methods for FieldBuilder to support projection-based resolvers. +/// +public static class FieldBuilderExtensions +{ + /// + /// Resolves field value using a projection to ensure required data is loaded. + /// + /// The DbContext type + /// The source entity type + /// The return type + /// The projected data type + /// The field builder + /// Expression to project required data from source + /// Function to resolve field value from projected data + /// The field builder for chaining + /// + /// Use this method instead of Resolve() when you need to access navigation + /// properties. The projection ensures the required data is loaded from the + /// database. + /// + public static FieldBuilder Resolve( + this FieldBuilder builder, + Expression> projection, + Func, TReturn> resolve) + where TDbContext : DbContext + { + ValidateProjection(projection); + + var field = builder.FieldType; + + // Store projection expression - flows through to Select expression builder + IncludeAppender.SetProjectionMetadata(field, projection, typeof(TSource)); + // Also set include metadata as fallback for abstract types where projection can't be built + var includeNames = ProjectionAnalyzer.ExtractRequiredProperties(projection); + IncludeAppender.SetIncludeMetadata(field, field.Name, includeNames); + + var compiledProjection = projection.Compile(); + + field.Resolver = new FuncFieldResolver( + async context => + { + // Resolve service from request services + var executionContext = context.ExecutionContext; + var requestServices = executionContext.RequestServices ?? executionContext.ExecutionOptions.RequestServices; + if (requestServices?.GetService(typeof(IEfGraphQLService)) is not IEfGraphQLService graphQlService) + { + throw new InvalidOperationException($"IEfGraphQLService<{typeof(TDbContext).Name}> not found in request services. Ensure it's registered in the container."); + } + + var dbContext = graphQlService.ResolveDbContext(context); + var filters = ResolveFilters(graphQlService, context); + var projected = compiledProjection(context.Source); + + var projectionContext = new ResolveProjectionContext + { + Projection = projected, + DbContext = dbContext, + User = context.User, + Filters = filters, + FieldContext = context + }; + + TReturn result; + try + { + result = resolve(projectionContext); + } + catch (Exception exception) + { + throw new( + $""" + Failed to execute projection-based resolve for field `{field.Name}` + TSource: {typeof(TSource).FullName} + TReturn: {typeof(TReturn).FullName} + TProjection: {typeof(TProjection).FullName} + """, + exception); + } + + return await ApplyFilters(filters, context, dbContext, result); + }); + + return builder; + } + + /// + /// Resolves field value asynchronously using a projection to ensure required data is loaded. + /// + /// The DbContext type + /// The source entity type + /// The return type + /// The projected data type + /// The field builder + /// Expression to project required data from source + /// Async function to resolve field value from projected data + /// The field builder for chaining + /// + /// Use this method instead of ResolveAsync() when you need to access navigation + /// properties. The projection ensures the required data is loaded from the + /// database. + /// + public static FieldBuilder ResolveAsync( + this FieldBuilder builder, + Expression> projection, + Func, Task> resolve) + where TDbContext : DbContext + { + ValidateProjection(projection); + + var field = builder.FieldType; + + // Store projection expression - flows through to Select expression builder + IncludeAppender.SetProjectionMetadata(field, projection, typeof(TSource)); + // Also set include metadata as fallback for abstract types where projection can't be built + var includeNames = ProjectionAnalyzer.ExtractRequiredProperties(projection); + IncludeAppender.SetIncludeMetadata(field, field.Name, includeNames); + + var compiledProjection = projection.Compile(); + + field.Resolver = new FuncFieldResolver( + async context => + { + // Resolve service from request services + var executionContext = context.ExecutionContext; + var requestServices = executionContext.RequestServices ?? executionContext.ExecutionOptions.RequestServices; + if (requestServices?.GetService(typeof(IEfGraphQLService)) is not IEfGraphQLService graphQlService) + { + throw new InvalidOperationException($"IEfGraphQLService<{typeof(TDbContext).Name}> not found in request services. Ensure it's registered in the container."); + } + + var dbContext = graphQlService.ResolveDbContext(context); + var filters = ResolveFilters(graphQlService, context); + var projected = compiledProjection(context.Source); + + var projectionContext = new ResolveProjectionContext + { + Projection = projected, + DbContext = dbContext, + User = context.User, + Filters = filters, + FieldContext = context + }; + + TReturn result; + try + { + result = await resolve(projectionContext); + } + catch (Exception exception) + { + throw new( + $""" + Failed to execute projection-based async resolve for field `{field.Name}` + TSource: {typeof(TSource).FullName} + TReturn: {typeof(TReturn).FullName} + TProjection: {typeof(TProjection).FullName} + """, + exception); + } + + return await ApplyFilters(filters, context, dbContext, result); + }); + + return builder; + } + + /// + /// Resolves a list of field values using a projection to ensure required data is loaded. + /// + /// The DbContext type + /// The source entity type + /// The return item type + /// The projected data type + /// The field builder + /// Expression to project required data from source + /// Function to resolve list of field values from projected data + /// The field builder for chaining + /// + /// Use this method instead of Resolve() when you need to access navigation + /// properties and return a list. The projection ensures the required data is loaded + /// from the database. + /// + public static FieldBuilder> ResolveList( + this FieldBuilder> builder, + Expression> projection, + Func, IEnumerable> resolve) + where TDbContext : DbContext + { + ValidateProjection(projection); + + var field = builder.FieldType; + + // Store projection expression - flows through to Select expression builder + IncludeAppender.SetProjectionMetadata(field, projection, typeof(TSource)); + // Also set include metadata as fallback for abstract types where projection can't be built + var includeNames = ProjectionAnalyzer.ExtractRequiredProperties(projection); + IncludeAppender.SetIncludeMetadata(field, field.Name, includeNames); + + var compiledProjection = projection.Compile(); + + field.Resolver = new FuncFieldResolver>( + context => + { + // Resolve service from request services + var executionContext = context.ExecutionContext; + var requestServices = executionContext.RequestServices ?? executionContext.ExecutionOptions.RequestServices; + if (requestServices?.GetService(typeof(IEfGraphQLService)) is not IEfGraphQLService graphQlService) + { + throw new InvalidOperationException($"IEfGraphQLService<{typeof(TDbContext).Name}> not found in request services. Ensure it's registered in the container."); + } + + var dbContext = graphQlService.ResolveDbContext(context); + var filters = ResolveFilters(graphQlService, context); + var projected = compiledProjection(context.Source); + + var projectionContext = new ResolveProjectionContext + { + Projection = projected, + DbContext = dbContext, + User = context.User, + Filters = filters, + FieldContext = context + }; + + IEnumerable result; + try + { + result = resolve(projectionContext); + } + catch (Exception exception) + { + throw new( + $""" + Failed to execute projection-based list resolve for field `{field.Name}` + TSource: {typeof(TSource).FullName} + TReturn: {typeof(TReturn).FullName} + TProjection: {typeof(TProjection).FullName} + """, + exception); + } + + // Note: For list results, we don't apply filters on the collection itself + // Filters would be applied to individual items if needed + return result; + }); + + return builder; + } + + /// + /// Resolves a list of field values asynchronously using a projection to ensure required data is loaded. + /// + /// The DbContext type + /// The source entity type + /// The return item type + /// The projected data type + /// The field builder + /// Expression to project required data from source + /// Async function to resolve list of field values from projected data + /// The field builder for chaining + /// + /// Use this method instead of ResolveAsync() when you need to access navigation + /// properties and return a list. The projection ensures the required data is loaded + /// from the database. + /// + public static FieldBuilder> ResolveListAsync( + this FieldBuilder> builder, + Expression> projection, + Func, Task>> resolve) + where TDbContext : DbContext + { + ValidateProjection(projection); + + var field = builder.FieldType; + + // Store projection expression - flows through to Select expression builder + IncludeAppender.SetProjectionMetadata(field, projection, typeof(TSource)); + // Also set include metadata as fallback for abstract types where projection can't be built + var includeNames = ProjectionAnalyzer.ExtractRequiredProperties(projection); + IncludeAppender.SetIncludeMetadata(field, field.Name, includeNames); + + var compiledProjection = projection.Compile(); + + field.Resolver = new FuncFieldResolver>( + async context => + { + // Resolve service from request services + var executionContext = context.ExecutionContext; + var requestServices = executionContext.RequestServices ?? executionContext.ExecutionOptions.RequestServices; + if (requestServices?.GetService(typeof(IEfGraphQLService)) is not IEfGraphQLService graphQlService) + { + throw new InvalidOperationException($"IEfGraphQLService<{typeof(TDbContext).Name}> not found in request services. Ensure it's registered in the container."); + } + + var dbContext = graphQlService.ResolveDbContext(context); + var filters = ResolveFilters(graphQlService, context); + var projected = compiledProjection(context.Source); + + var projectionContext = new ResolveProjectionContext + { + Projection = projected, + DbContext = dbContext, + User = context.User, + Filters = filters, + FieldContext = context + }; + + IEnumerable result; + try + { + result = await resolve(projectionContext); + } + catch (Exception exception) + { + throw new( + $""" + Failed to execute projection-based async list resolve for field `{field.Name}` + TSource: {typeof(TSource).FullName} + TReturn: {typeof(TReturn).FullName} + TProjection: {typeof(TProjection).FullName} + """, + exception); + } + + // Note: For list results, we don't apply filters on the collection itself + // Filters would be applied to individual items if needed + return result; + }); + + return builder; + } + + static async Task ApplyFilters( + Filters? filters, + IResolveFieldContext context, + TDbContext dbContext, + TReturn result) + where TDbContext : DbContext + { + // Value types don't support filtering - return as-is + if (typeof(TReturn).IsValueType) + { + return result; + } + + // For reference types, apply filters if available + if (filters != null && result is not null) + { + // Use dynamic to work around the class constraint on ShouldInclude + dynamic dynamicFilters = filters; + if (!await dynamicFilters.ShouldInclude(context.UserContext, dbContext, context.User, result)) + { + return default!; + } + } + + return result; + } + + static Filters? ResolveFilters(IEfGraphQLService service, IResolveFieldContext context) + where TDbContext : DbContext + { + // Use reflection to access the protected/internal ResolveFilter method + // This is a workaround since we can't access it directly + var serviceType = service.GetType(); + var method = serviceType.GetMethod("ResolveFilter", + BindingFlags.NonPublic | BindingFlags.Instance); + + if (method is null) + { + return null; + } + + var genericMethod = method.MakeGenericMethod(context.Source?.GetType() ?? typeof(object)); + return genericMethod.Invoke(service, [context]) as Filters; + } + + static void ValidateProjection(Expression> projection) + { + // Detect identity projection: _ => _ + if (projection.Body is ParameterExpression parameter && + parameter == projection.Parameters[0]) + { + throw new ArgumentException( + "Identity projection '_ => _' is not allowed. If only access to primary key or foreign key properties, use the regular Resolve() method instead. If required to access navigation properties, specify them in the projection (e.g., '_ => _.Parent').", + nameof(projection)); + } + + // Note: Scalar projections are allowed - they're useful for ensuring scalar properties + // are loaded from the database and can be transformed in the resolver + } +} diff --git a/src/GraphQL.EntityFramework/GraphApi/IEfGraphQLService_Navigation.cs b/src/GraphQL.EntityFramework/GraphApi/IEfGraphQLService_Navigation.cs index 832ad183a..2ca1a9ed1 100644 --- a/src/GraphQL.EntityFramework/GraphApi/IEfGraphQLService_Navigation.cs +++ b/src/GraphQL.EntityFramework/GraphApi/IEfGraphQLService_Navigation.cs @@ -3,6 +3,7 @@ //Navigation fields will always be on a typed graph. so use ComplexGraphType not IComplexGraphType public partial interface IEfGraphQLService { + [Obsolete("Use the projection-based overload instead")] FieldBuilder AddNavigationField(ComplexGraphType graph, string name, Func, TReturn?>? resolve = null, @@ -10,6 +11,22 @@ FieldBuilder AddNavigationField(ComplexGraph IEnumerable? includeNames = null) where TReturn : class; + FieldBuilder AddNavigationField( + ComplexGraphType graph, + string name, + Expression> projection, + Func, TReturn?> resolve, + Type? graphType = null) + where TReturn : class; + + FieldBuilder AddNavigationField( + ComplexGraphType graph, + string name, + Expression> projection, + Type? graphType = null) + where TReturn : class; + + [Obsolete("Use the projection-based overload instead")] FieldBuilder AddNavigationListField( ComplexGraphType graph, string name, @@ -18,4 +35,21 @@ FieldBuilder AddNavigationListField( IEnumerable? includeNames = null, bool omitQueryArguments = false) where TReturn : class; + + FieldBuilder AddNavigationListField( + ComplexGraphType graph, + string name, + Expression> projection, + Func, IEnumerable> resolve, + Type? itemGraphType = null, + bool omitQueryArguments = false) + where TReturn : class; + + FieldBuilder AddNavigationListField( + ComplexGraphType graph, + string name, + Expression?>> projection, + Type? itemGraphType = null, + bool omitQueryArguments = false) + where TReturn : class; } \ No newline at end of file diff --git a/src/GraphQL.EntityFramework/GraphApi/IEfGraphQLService_NavigationConnection.cs b/src/GraphQL.EntityFramework/GraphApi/IEfGraphQLService_NavigationConnection.cs index deadba642..68173439a 100644 --- a/src/GraphQL.EntityFramework/GraphApi/IEfGraphQLService_NavigationConnection.cs +++ b/src/GraphQL.EntityFramework/GraphApi/IEfGraphQLService_NavigationConnection.cs @@ -3,6 +3,7 @@ //Navigation fields will always be on a typed graph. so use ComplexGraphType not IComplexGraphType public partial interface IEfGraphQLService { + [Obsolete("Use the projection-based overload instead")] ConnectionBuilder AddNavigationConnectionField( ComplexGraphType graph, string name, @@ -11,4 +12,20 @@ ConnectionBuilder AddNavigationConnectionField( IEnumerable? includeNames = null, bool omitQueryArguments = false) where TReturn : class; + + ConnectionBuilder AddNavigationConnectionField( + ComplexGraphType graph, + string name, + Expression> projection, + Func, IEnumerable> resolve, + Type? itemGraphType = null, + bool omitQueryArguments = false) + where TReturn : class; + + ConnectionBuilder AddNavigationConnectionField( + ComplexGraphType graph, + string name, + Expression?>> projection, + Type? itemGraphType = null) + where TReturn : class; } \ No newline at end of file diff --git a/src/GraphQL.EntityFramework/Filters/FilterProjectionAnalyzer.cs b/src/GraphQL.EntityFramework/GraphApi/ProjectionAnalyzer.cs similarity index 97% rename from src/GraphQL.EntityFramework/Filters/FilterProjectionAnalyzer.cs rename to src/GraphQL.EntityFramework/GraphApi/ProjectionAnalyzer.cs index a4fb64189..2267b7215 100644 --- a/src/GraphQL.EntityFramework/Filters/FilterProjectionAnalyzer.cs +++ b/src/GraphQL.EntityFramework/GraphApi/ProjectionAnalyzer.cs @@ -1,4 +1,4 @@ -static class FilterProjectionAnalyzer +static class ProjectionAnalyzer { public static IReadOnlySet ExtractRequiredProperties( Expression> projection) diff --git a/src/GraphQL.EntityFramework/GraphApi/ResolveProjectionContext.cs b/src/GraphQL.EntityFramework/GraphApi/ResolveProjectionContext.cs new file mode 100644 index 000000000..da6c27992 --- /dev/null +++ b/src/GraphQL.EntityFramework/GraphApi/ResolveProjectionContext.cs @@ -0,0 +1,11 @@ +namespace GraphQL.EntityFramework; + +public class ResolveProjectionContext + where TDbContext : DbContext +{ + public TProjection Projection { get; init; } = default!; + public TDbContext DbContext { get; init; } = null!; + public ClaimsPrincipal? User { get; init; } + public Filters? Filters { get; init; } + public IResolveFieldContext FieldContext { get; init; } = null!; +} diff --git a/src/GraphQL.EntityFramework/GraphQL.EntityFramework.csproj b/src/GraphQL.EntityFramework/GraphQL.EntityFramework.csproj index 0d308951f..22c52d2a0 100644 --- a/src/GraphQL.EntityFramework/GraphQL.EntityFramework.csproj +++ b/src/GraphQL.EntityFramework/GraphQL.EntityFramework.csproj @@ -10,7 +10,6 @@ - @@ -30,5 +29,13 @@ + + false + Analyzer + all + + + + diff --git a/src/GraphQL.EntityFramework/IncludeAppender.cs b/src/GraphQL.EntityFramework/IncludeAppender.cs index 938888ab9..bbe9d27fb 100644 --- a/src/GraphQL.EntityFramework/IncludeAppender.cs +++ b/src/GraphQL.EntityFramework/IncludeAppender.cs @@ -129,7 +129,26 @@ void ProcessProjectionField( Dictionary navProjections, IResolveFieldContext context) { - // Check if this field has include metadata (navigation field with possible alias) + // Check if this field has a projection expression (new approach - flows to Select) + if (TryGetProjectionMetadata(fieldInfo.FieldType, out var projection, out var sourceType)) + { + var countBefore = navProjections.Count; + ProcessProjectionExpression( + fieldInfo, + projection, + sourceType, + navigationProperties, + navProjections, + context); + // Only return if we added navigations; otherwise fall through to include metadata + // This handles cases like abstract types where projection can't be built + if (navProjections.Count > countBefore) + { + return; + } + } + + // Check if this field has include metadata (fallback for abstract types, or legacy/obsolete approach) if (TryGetIncludeMetadata(fieldInfo.FieldType, out var includeNames)) { // It's a navigation field - include ALL navigation properties from metadata @@ -202,6 +221,120 @@ void ProcessProjectionField( } } + /// + /// Processes a projection expression to build NavigationProjectionInfo entries. + /// The expression is analyzed to determine which navigations and properties to include + /// in the Select projection. + /// + void ProcessProjectionExpression( + (GraphQLField Field, FieldType FieldType) fieldInfo, + LambdaExpression projection, + Type sourceType, + IReadOnlyDictionary? navigationProperties, + Dictionary navProjections, + IResolveFieldContext context) + { + // Extract property paths from the projection expression + var accessedPaths = ProjectionPathAnalyzer.ExtractPropertyPaths(projection, sourceType); + + // Group paths by their root navigation property + var pathsByNavigation = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var primaryNavigation = (string?)null; + + foreach (var path in accessedPaths) + { + var rootProperty = path.Contains('.') ? path[..path.IndexOf('.')] : path; + + if (!pathsByNavigation.TryGetValue(rootProperty, out var paths)) + { + paths = []; + pathsByNavigation[rootProperty] = paths; + } + + // Store the nested path (without root) if it's a nested access + if (path.Contains('.')) + { + paths.Add(path[(path.IndexOf('.') + 1)..]); + } + + // First navigation encountered is the primary one (gets GraphQL fields) + primaryNavigation ??= rootProperty; + } + + // Build NavigationProjectionInfo for each accessed navigation + foreach (var (navName, nestedPaths) in pathsByNavigation) + { + // Try case-sensitive first, then case-insensitive + Navigation? navigation = null; + string? actualNavName = null; + if (navigationProperties != null) + { + if (navigationProperties.TryGetValue(navName, out navigation)) + { + actualNavName = navName; + } + else + { + // Case-insensitive fallback + var match = navigationProperties.FirstOrDefault(kvp => + string.Equals(kvp.Key, navName, StringComparison.OrdinalIgnoreCase)); + if (match.Value != null) + { + navigation = match.Value; + actualNavName = match.Key; + } + } + } + + if (navigation == null || actualNavName == null || navProjections.ContainsKey(actualNavName)) + { + continue; + } + + var navType = navigation.Type; + navigations.TryGetValue(navType, out var nestedNavProps); + keyNames.TryGetValue(navType, out var nestedKeys); + foreignKeys.TryGetValue(navType, out var nestedFks); + + FieldProjectionInfo nestedProjection; + + if (navName == primaryNavigation) + { + // Primary navigation: merge GraphQL fields with projection-required fields + nestedProjection = GetNestedProjection( + fieldInfo.Field.SelectionSet, + nestedNavProps, + nestedKeys, + nestedFks, + context); + + // Add any specific fields from the projection expression + foreach (var nestedPath in nestedPaths) + { + if (!nestedPath.Contains('.') && + !nestedProjection.ScalarFields.Contains(nestedPath, StringComparer.OrdinalIgnoreCase)) + { + nestedProjection.ScalarFields.Add(nestedPath); + } + } + } + else + { + // Secondary navigation: include only projection-required fields + var scalarFields = nestedPaths + .Where(p => !p.Contains('.')) + .ToList(); + + nestedProjection = new(scalarFields, nestedKeys ?? [], nestedFks ?? new HashSet(), []); + } + + navProjections[actualNavName] = new( + navType, + navigation.IsCollection, + nestedProjection); + } + } + FieldProjectionInfo GetNestedProjection( GraphQLSelectionSet? selectionSet, IReadOnlyDictionary? navigationProperties, @@ -338,7 +471,7 @@ void AddField(List list, GraphQLField field, GraphQLSelectionSet selecti var name = fragmentSpread.FragmentName.Name; var fragmentDefinition = context.Document.Definitions .OfType() - .SingleOrDefault(x => x.FragmentName.Name == name); + .SingleOrDefault(_ => _.FragmentName.Name == name); if (fragmentDefinition is null) { continue; @@ -362,6 +495,14 @@ void AddField(List list, GraphQLField field, GraphQLSelectionSet selecti return; } + // Check if entity type has navigation properties BEFORE processing includes + // Scalar types (enums, primitives) won't be in the navigations dictionary + // and shouldn't have includes added - projection handles loading scalar data + if (!navigations.TryGetValue(entityType, out var navigationProperties)) + { + return; + } + if (!TryGetIncludeMetadata(fieldType, out var includeNames)) { return; @@ -370,7 +511,7 @@ void AddField(List list, GraphQLField field, GraphQLSelectionSet selecti var paths = GetPaths(parentPath, includeNames).ToList(); list.AddRange(paths); - ProcessSubFields(list, paths.First(), subFields, graph, navigations[entityType], context); + ProcessSubFields(list, paths.First(), subFields, graph, navigationProperties, context); } static IEnumerable GetPaths(string? parentPath, string[] includeNames) @@ -430,4 +571,30 @@ static bool TryGetIncludeMetadata(FieldType fieldType, [NotNullWhen(true)] out s value = null; return false; } + + /// + /// Stores a projection expression in field metadata. + /// This is used by navigation fields to specify which properties to project, + /// flowing through to the root Select expression. + /// + public static void SetProjectionMetadata(FieldType fieldType, LambdaExpression projection, Type sourceType) + { + fieldType.Metadata["_EF_Projection"] = projection; + fieldType.Metadata["_EF_ProjectionSourceType"] = sourceType; + } + + static bool TryGetProjectionMetadata(FieldType fieldType, [NotNullWhen(true)] out LambdaExpression? projection, [NotNullWhen(true)] out Type? sourceType) + { + if (fieldType.Metadata.TryGetValue("_EF_Projection", out var projectionObj) && + fieldType.Metadata.TryGetValue("_EF_ProjectionSourceType", out var sourceTypeObj)) + { + projection = (LambdaExpression)projectionObj!; + sourceType = (Type)sourceTypeObj!; + return true; + } + + projection = null; + sourceType = null; + return false; + } } \ No newline at end of file diff --git a/src/GraphQL.EntityFramework/Mapping/Mapper.cs b/src/GraphQL.EntityFramework/Mapping/Mapper.cs index 37611b4f5..7174ee768 100644 --- a/src/GraphQL.EntityFramework/Mapping/Mapper.cs +++ b/src/GraphQL.EntityFramework/Mapping/Mapper.cs @@ -123,6 +123,7 @@ static void ProcessNavigation( } } +#pragma warning disable CS0618 // Obsolete - AutoMap needs to use legacy methods since it can't determine projections at runtime static void AddNavigation( ObjectGraphType graph, IEfGraphQLService graphQlService, @@ -144,6 +145,7 @@ static void AddNavigationList( var compile = NavigationFunc>(navigation.Name); graphQlService.AddNavigationListField(graph, navigation.Name, compile, graphTypeFromType); } +#pragma warning restore CS0618 public record NavigationKey(Type Type, string Name); diff --git a/src/GraphQL.EntityFramework/SelectProjection/ProjectionPathAnalyzer.cs b/src/GraphQL.EntityFramework/SelectProjection/ProjectionPathAnalyzer.cs new file mode 100644 index 000000000..9d0f30622 --- /dev/null +++ b/src/GraphQL.EntityFramework/SelectProjection/ProjectionPathAnalyzer.cs @@ -0,0 +1,47 @@ +namespace GraphQL.EntityFramework; + +/// +/// Analyzes projection expressions to extract property paths accessed from the source type. +/// Used by IncludeAppender to determine which navigations to include in the Select projection. +/// +static class ProjectionPathAnalyzer +{ + public static IReadOnlySet ExtractPropertyPaths(LambdaExpression projection, Type sourceType) + { + // Get all parameters from the lambda + var parameters = new HashSet(projection.Parameters); + + var visitor = new PropertyAccessVisitor(parameters); + visitor.Visit(projection); + return visitor.AccessedProperties; + } + + sealed class PropertyAccessVisitor(HashSet parameters) : ExpressionVisitor + { + public HashSet AccessedProperties { get; } = new(StringComparer.OrdinalIgnoreCase); + + protected override Expression VisitMember(MemberExpression node) + { + // Build the full property path by walking up the expression tree + var path = new List(); + Expression? current = node; + + while (current is MemberExpression memberExpr) + { + path.Insert(0, memberExpr.Member.Name); + current = memberExpr.Expression; + } + + // Check if the root is a parameter expression from the lambda + if (current is ParameterExpression param && + parameters.Contains(param) && + path.Count > 0) + { + // Add the full path (e.g., "Parent.Name" or just "Name") + AccessedProperties.Add(string.Join('.', path)); + } + + return base.VisitMember(node); + } + } +} diff --git a/src/GraphQL.EntityFramework/SelectProjection/SelectExpressionBuilder.cs b/src/GraphQL.EntityFramework/SelectProjection/SelectExpressionBuilder.cs index aad5120c4..947515a4f 100644 --- a/src/GraphQL.EntityFramework/SelectProjection/SelectExpressionBuilder.cs +++ b/src/GraphQL.EntityFramework/SelectProjection/SelectExpressionBuilder.cs @@ -217,7 +217,7 @@ static bool TryBuildNavigationBindings( [NotNullWhen(true)] out List? bindings) { // Pre-size collections to avoid reallocations - var capacity = projection.KeyNames.Count + projection.ScalarFields.Count + projection.Navigations.Count; + var capacity = projection.KeyNames.Count + projection.ForeignKeyNames.Count + projection.ScalarFields.Count + projection.Navigations.Count; bindings = new(capacity); var addedProperties = new HashSet(capacity, StringComparer.OrdinalIgnoreCase); var properties = GetEntityMetadata(entityType).Properties; @@ -233,6 +233,17 @@ static bool TryBuildNavigationBindings( } } + // Add foreign key properties + foreach (var fkName in projection.ForeignKeyNames) + { + if (properties.TryGetValue(fkName, out var metadata) && + metadata.CanWrite && + addedProperties.Add(fkName)) + { + bindings.Add(Expression.Bind(metadata.Property, Expression.Property(sourceExpression, metadata.Property))); + } + } + // Add scalar properties foreach (var fieldName in projection.ScalarFields) { diff --git a/src/GraphQL.EntityFramework/Where/ReflectionCache.cs b/src/GraphQL.EntityFramework/Where/ReflectionCache.cs index 237b8ad21..40ebae7f5 100644 --- a/src/GraphQL.EntityFramework/Where/ReflectionCache.cs +++ b/src/GraphQL.EntityFramework/Where/ReflectionCache.cs @@ -65,7 +65,7 @@ static ReflectionCache() => return method; } - if (IsEnumType(type)) + if (type.IsEnumType()) { return typeof(ICollection<>).MakeGenericType(type).GetMethod("Contains"); } diff --git a/src/GraphQL.EntityFramework/Where/TypeConverter.cs b/src/GraphQL.EntityFramework/Where/TypeConverter.cs index f124e0a63..332c59c83 100644 --- a/src/GraphQL.EntityFramework/Where/TypeConverter.cs +++ b/src/GraphQL.EntityFramework/Where/TypeConverter.cs @@ -104,6 +104,8 @@ static IList ConvertStringsToListInternal(IEnumerable values, Type type) static MethodInfo enumListMethod = typeof(TypeConverter) .GetMethod("GetEnumList", BindingFlags.Static | BindingFlags.NonPublic)!; + // Use via reflection + // ReSharper disable once UnusedMember.Local static List GetEnumList(IEnumerable values) where T : struct { @@ -119,6 +121,8 @@ static List GetEnumList(IEnumerable values) static MethodInfo nullableEnumListMethod = typeof(TypeConverter) .GetMethod("GetNullableEnumList", BindingFlags.Static | BindingFlags.NonPublic)!; + // Use via reflection + // ReSharper disable once UnusedMember.Local static List GetNullableEnumList(IEnumerable values) where T : struct { diff --git a/src/SampleWeb/Graphs/CompanyGraphType.cs b/src/SampleWeb/Graphs/CompanyGraphType.cs index 06cd05cc1..404c96871 100644 --- a/src/SampleWeb/Graphs/CompanyGraphType.cs +++ b/src/SampleWeb/Graphs/CompanyGraphType.cs @@ -6,11 +6,12 @@ public CompanyGraphType(IEfGraphQLService graphQlService) : { AddNavigationListField( name: "employees", - resolve: _ => _.Source.Employees); + projection: _ => _.Employees, + resolve: _ => _.Projection); AddNavigationConnectionField( name: "employeesConnection", - resolve: _ => _.Source.Employees, - includeNames: ["Employees"]); + projection: _ => _.Employees, + resolve: _ => _.Projection); AutoMap(); } -} \ No newline at end of file +} diff --git a/src/SampleWeb/Graphs/DeviceGraphType.cs b/src/SampleWeb/Graphs/DeviceGraphType.cs index 18dcf0361..b529fce01 100644 --- a/src/SampleWeb/Graphs/DeviceGraphType.cs +++ b/src/SampleWeb/Graphs/DeviceGraphType.cs @@ -6,7 +6,8 @@ public DeviceGraphType(IEfGraphQLService graphQlService) : { AddNavigationListField( name: "employees", - resolve: _ => _.Source.Employees); + projection: _ => _.Employees, + resolve: _ => _.Projection); AutoMap(); } -} \ No newline at end of file +} diff --git a/src/Snippets/ConnectionTypedGraph.cs b/src/Snippets/ConnectionTypedGraph.cs index 4c4968709..7f458eb7d 100644 --- a/src/Snippets/ConnectionTypedGraph.cs +++ b/src/Snippets/ConnectionTypedGraph.cs @@ -11,7 +11,8 @@ public CompanyGraph(IEfGraphQLService graphQlService) : base(graphQlService) => AddNavigationConnectionField( name: "employees", - resolve: _ => _.Source.Employees); + projection: _ => _.Employees, + resolve: _ => _.Projection); } #endregion @@ -28,4 +29,4 @@ public class Employee; public class EmployeeGraph : ObjectGraphType; -} \ No newline at end of file +} diff --git a/src/Snippets/GlobalFilterSnippets.cs b/src/Snippets/GlobalFilterSnippets.cs index 2ac4ce008..c41bca54a 100644 --- a/src/Snippets/GlobalFilterSnippets.cs +++ b/src/Snippets/GlobalFilterSnippets.cs @@ -225,7 +225,7 @@ public static void AddNavigationPropertyFilter(ServiceCollection services) var filters = new Filters(); filters.For().Add( - projection: o => new { o.TotalAmount, o.Customer.IsActive }, + projection: _ => new { _.TotalAmount, _.Customer.IsActive }, filter: (_, _, _, x) => x.TotalAmount >= 100 && x.IsActive); EfGraphQLConventions.RegisterInContainer( services, diff --git a/src/Snippets/TypedGraph.cs b/src/Snippets/TypedGraph.cs index edea0e5aa..9d7aab1c4 100644 --- a/src/Snippets/TypedGraph.cs +++ b/src/Snippets/TypedGraph.cs @@ -12,11 +12,12 @@ public CompanyGraph(IEfGraphQLService graphQlService) : { AddNavigationListField( name: "employees", - resolve: _ => _.Source.Employees); + projection: _ => _.Employees, + resolve: _ => _.Projection); AddNavigationConnectionField( name: "employeesConnection", - resolve: _ => _.Source.Employees, - includeNames: ["Employees"]); + projection: _ => _.Employees, + resolve: _ => _.Projection); AutoMap(); } } @@ -40,4 +41,4 @@ public class MyDbContext : public class EmployeeGraph : ObjectGraphType; -} \ No newline at end of file +} diff --git a/src/Tests/FieldBuilderExtensionsTests.cs b/src/Tests/FieldBuilderExtensionsTests.cs new file mode 100644 index 000000000..a0d1a4aab --- /dev/null +++ b/src/Tests/FieldBuilderExtensionsTests.cs @@ -0,0 +1,87 @@ +public class FieldBuilderExtensionsTests +{ + class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + + class TestDbContext : DbContext; + + [Fact] + public void Resolve_ThrowsArgumentException_WhenIdentityProjectionUsed() + { + var graphType = new ObjectGraphType(); + var field = graphType.Field("test"); + + var exception = Assert.Throws(() => + field.Resolve( + projection: _ => _, + resolve: _ => _.Projection.Id)); + + Assert.Contains("Identity projection", exception.Message); + Assert.Contains("_ => _", exception.Message); + } + + [Fact] + public void Resolve_ThrowsArgumentException_WhenIdentityProjectionUsedWithDiscardVariable() + { + var graphType = new ObjectGraphType(); + var field = graphType.Field("test"); + + var exception = Assert.Throws(() => + field.Resolve( + projection: _ => _, + resolve: _ => _.Projection.Id)); + + Assert.Contains("Identity projection", exception.Message); + } + + [Fact] + public void ResolveAsync_ThrowsArgumentException_WhenIdentityProjectionUsed() + { + var graphType = new ObjectGraphType(); + var field = graphType.Field("test"); + + var exception = Assert.Throws(() => + field.ResolveAsync( + projection: _ => _, + resolve: _ => Task.FromResult(_.Projection.Id))); + + Assert.Contains("Identity projection", exception.Message); + } + + [Fact] + public void ResolveList_ThrowsArgumentException_WhenIdentityProjectionUsed() + { + var graphType = new ObjectGraphType(); + var field = graphType.Field>("test"); + + var exception = Assert.Throws(() => + field.ResolveList( + projection: _ => _, + resolve: _ => [_.Projection.Id])); + + Assert.Contains("Identity projection", exception.Message); + } + + [Fact] + public void ResolveListAsync_ThrowsArgumentException_WhenIdentityProjectionUsed() + { + var graphType = new ObjectGraphType(); + var field = graphType.Field>("test"); + + var exception = Assert.Throws(() => + field.ResolveListAsync( + projection: _ => _, + resolve: _ => Task.FromResult>([_.Projection.Id]))); + + Assert.Contains("Identity projection", exception.Message); + } + + enum TestEnum + { + Value1, + Value2 + } +} diff --git a/src/Tests/IntegrationTests/Graphs/Child2GraphType.cs b/src/Tests/IntegrationTests/Graphs/Child2GraphType.cs index 9c3d6c674..9406bd2ac 100644 --- a/src/Tests/IntegrationTests/Graphs/Child2GraphType.cs +++ b/src/Tests/IntegrationTests/Graphs/Child2GraphType.cs @@ -4,4 +4,4 @@ public Child2GraphType(IEfGraphQLService graphQlService) : base(graphQlService) => AutoMap(); -} \ No newline at end of file +} diff --git a/src/Tests/IntegrationTests/Graphs/ChildGraphType.cs b/src/Tests/IntegrationTests/Graphs/ChildGraphType.cs index 995981759..3a4abecf2 100644 --- a/src/Tests/IntegrationTests/Graphs/ChildGraphType.cs +++ b/src/Tests/IntegrationTests/Graphs/ChildGraphType.cs @@ -6,9 +6,9 @@ public ChildGraphType(IEfGraphQLService graphQlService) : { AddNavigationField( name: "parentAlias", - resolve: _ => _.Source.Parent, - graphType: typeof(ParentGraphType), - includeNames: ["Parent"]); + projection: _ => _.Parent, + resolve: _ => _.Projection, + graphType: typeof(ParentGraphType)); AutoMap(); } -} \ No newline at end of file +} diff --git a/src/Tests/IntegrationTests/Graphs/DepartmentEntity.cs b/src/Tests/IntegrationTests/Graphs/DepartmentEntity.cs new file mode 100644 index 000000000..1d5589864 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/DepartmentEntity.cs @@ -0,0 +1,7 @@ +public class DepartmentEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string? Name { get; set; } + public bool IsActive { get; set; } + public IList Employees { get; set; } = []; +} diff --git a/src/Tests/IntegrationTests/Graphs/DepartmentGraphType.cs b/src/Tests/IntegrationTests/Graphs/DepartmentGraphType.cs new file mode 100644 index 000000000..372f1ef14 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/DepartmentGraphType.cs @@ -0,0 +1,7 @@ +public class DepartmentGraphType : + EfObjectGraphType +{ + public DepartmentGraphType(IEfGraphQLService graphQlService) : + base(graphQlService) => + AutoMap(); +} diff --git a/src/Tests/IntegrationTests/Graphs/EmployeeEntity.cs b/src/Tests/IntegrationTests/Graphs/EmployeeEntity.cs new file mode 100644 index 000000000..3b9acc8b2 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/EmployeeEntity.cs @@ -0,0 +1,7 @@ +public class EmployeeEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string? Name { get; set; } + public Guid DepartmentId { get; set; } + public DepartmentEntity? Department { get; set; } +} diff --git a/src/Tests/IntegrationTests/Graphs/EmployeeGraphType.cs b/src/Tests/IntegrationTests/Graphs/EmployeeGraphType.cs new file mode 100644 index 000000000..655823e66 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/EmployeeGraphType.cs @@ -0,0 +1,20 @@ +public class EmployeeGraphType : + EfObjectGraphType +{ + public EmployeeGraphType(IEfGraphQLService graphQlService) : + base(graphQlService) + { + // Custom field that uses the foreign key to query related data + // This simulates the isGovernmentMember/isCabinetMinister scenario + Field>("isInActiveDepartment") + .ResolveAsync(async context => + { + var dbContext = ResolveDbContext(context); + var department = await dbContext.Departments + .SingleAsync(_ => _.Id == context.Source.DepartmentId); + return department.IsActive; + }); + + AutoMap(); + } +} diff --git a/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionEntity.cs b/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionEntity.cs new file mode 100644 index 000000000..bc067bc4d --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionEntity.cs @@ -0,0 +1,20 @@ +public enum EntityStatus +{ + Active, + Inactive, + Pending +} + +public class FieldBuilderProjectionEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = null!; + public int Age { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public decimal Salary { get; set; } + public double Score { get; set; } + public long ViewCount { get; set; } + public EntityStatus Status { get; set; } + public FieldBuilderProjectionParentEntity? Parent { get; set; } +} diff --git a/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionGraphType.cs b/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionGraphType.cs new file mode 100644 index 000000000..170629a83 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionGraphType.cs @@ -0,0 +1,68 @@ +public class FieldBuilderProjectionGraphType : + EfObjectGraphType +{ + public FieldBuilderProjectionGraphType(IEfGraphQLService graphQlService) : + base(graphQlService) + { + // Scalar property transformations use regular Resolve() - no projection needed + Field, int>("ageDoubled") + .Resolve(_ => _.Source.Age * 2); + + Field, bool>("isAdult") + .Resolve(_ => _.Source.Age >= 18); + + Field, DateTime>("createdYear") + .Resolve(_ => new(_.Source.CreatedAt.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Field, decimal>("salaryWithBonus") + .Resolve(_ => _.Source.Salary * 1.1m); + + Field, double>("scoreNormalized") + .Resolve(_ => _.Source.Score / 100.0); + + Field, long>("viewCountDoubled") + .Resolve(_ => _.Source.ViewCount * 2); + + // Async operations can access PK/FK directly without projection + Field, bool>("hasParentAsync") + .ResolveAsync(async ctx => + { + await Task.Delay(1); // Simulate async operation + var service = graphQlService.ResolveDbContext(ctx); + return await service.Set() + .AnyAsync(_ => _.Children.Any(c => c.Id == ctx.Source.Id)); + }); + + Field, int>("siblingCountAsync") + .ResolveAsync(async ctx => + { + var dbContext = graphQlService.ResolveDbContext(ctx); + var parent = await dbContext.Set() + .FirstOrDefaultAsync(_ => _.Children.Any(c => c.Id == ctx.Source.Id)); + return parent?.Children.Count ?? 0; + }); + + Field, string>("nameUpper") + .Resolve(_ => _.Source.Name.ToUpper()); + + // Enum projection - demonstrates projecting scalar enum types + Field, string>("statusDisplay") + .Resolve( + projection: _ => _.Status, + resolve: _ => _.Projection switch + { + EntityStatus.Active => "Currently Active", + EntityStatus.Inactive => "Currently Inactive", + EntityStatus.Pending => "Pending Activation", + _ => "Unknown" + }); + + // Navigation property access DOES use projection-based resolve + Field, string>("parentName") + .Resolve( + projection: _ => _.Parent, + resolve: _ => _.Projection?.Name ?? "No Parent"); + + AutoMap(exclusions: [nameof(FieldBuilderProjectionEntity.Parent)]); + } +} diff --git a/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionParentEntity.cs b/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionParentEntity.cs new file mode 100644 index 000000000..05d945607 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionParentEntity.cs @@ -0,0 +1,6 @@ +public class FieldBuilderProjectionParentEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = null!; + public List Children { get; set; } = []; +} diff --git a/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionParentGraphType.cs b/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionParentGraphType.cs new file mode 100644 index 000000000..405a59c08 --- /dev/null +++ b/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionParentGraphType.cs @@ -0,0 +1,13 @@ +public class FieldBuilderProjectionParentGraphType : + EfObjectGraphType +{ + public FieldBuilderProjectionParentGraphType(IEfGraphQLService graphQlService) : + base(graphQlService) + { + AddNavigationConnectionField( + name: "children", + projection: _ => _.Children, + resolve: _ => _.Projection); + AutoMap(); + } +} diff --git a/src/Tests/IntegrationTests/Graphs/Filtering/FilterParentGraphType.cs b/src/Tests/IntegrationTests/Graphs/Filtering/FilterParentGraphType.cs index 98a76baa6..c728a1c38 100644 --- a/src/Tests/IntegrationTests/Graphs/Filtering/FilterParentGraphType.cs +++ b/src/Tests/IntegrationTests/Graphs/Filtering/FilterParentGraphType.cs @@ -6,8 +6,8 @@ public FilterParentGraphType(IEfGraphQLService graphQlServ { AddNavigationConnectionField( name: "childrenConnection", - resolve: _ => _.Source.Children, - includeNames: ["Children"]); + projection: _ => _.Children, + resolve: _ => _.Projection); AutoMap(); } -} \ No newline at end of file +} diff --git a/src/Tests/IntegrationTests/Graphs/Inheritance/BaseGraphType.cs b/src/Tests/IntegrationTests/Graphs/Inheritance/BaseGraphType.cs index 25d826066..9cdc84df4 100644 --- a/src/Tests/IntegrationTests/Graphs/Inheritance/BaseGraphType.cs +++ b/src/Tests/IntegrationTests/Graphs/Inheritance/BaseGraphType.cs @@ -3,7 +3,7 @@ { public BaseGraphType(IEfGraphQLService graphQlService) : base(graphQlService) => - AddNavigationConnectionField( + AddNavigationConnectionField( name: "childrenFromInterface", - includeNames: ["ChildrenFromBase"]); + projection: _ => _.ChildrenFromBase); } \ No newline at end of file diff --git a/src/Tests/IntegrationTests/Graphs/Inheritance/DerivedChildGraphType.cs b/src/Tests/IntegrationTests/Graphs/Inheritance/DerivedChildGraphType.cs index 383889268..5259a7e37 100644 --- a/src/Tests/IntegrationTests/Graphs/Inheritance/DerivedChildGraphType.cs +++ b/src/Tests/IntegrationTests/Graphs/Inheritance/DerivedChildGraphType.cs @@ -7,8 +7,9 @@ public DerivedChildGraphType(IEfGraphQLService graphQlServ // Add navigation to abstract parent - this is what triggers the bug AddNavigationField( name: "parent", - resolve: _ => _.Source.Parent, + projection: _ => _.Parent, + resolve: _ => _.Projection, graphType: typeof(BaseGraphType)); AutoMap(["Parent", "TypedParent"]); } -} \ No newline at end of file +} diff --git a/src/Tests/IntegrationTests/Graphs/Inheritance/DerivedGraphType.cs b/src/Tests/IntegrationTests/Graphs/Inheritance/DerivedGraphType.cs index 4c311c68c..03f6ea695 100644 --- a/src/Tests/IntegrationTests/Graphs/Inheritance/DerivedGraphType.cs +++ b/src/Tests/IntegrationTests/Graphs/Inheritance/DerivedGraphType.cs @@ -6,9 +6,10 @@ public DerivedGraphType(IEfGraphQLService graphQlService) { AddNavigationConnectionField( name: "childrenFromInterface", - _ => _.Source.ChildrenFromBase); + projection: _ => _.ChildrenFromBase, + resolve: _ => _.Projection); AutoMap(); Interface(); IsTypeOf = obj => obj is DerivedEntity; } -} \ No newline at end of file +} diff --git a/src/Tests/IntegrationTests/Graphs/Inheritance/DerivedWithNavigationGraphType.cs b/src/Tests/IntegrationTests/Graphs/Inheritance/DerivedWithNavigationGraphType.cs index 9d7e31937..7863a6448 100644 --- a/src/Tests/IntegrationTests/Graphs/Inheritance/DerivedWithNavigationGraphType.cs +++ b/src/Tests/IntegrationTests/Graphs/Inheritance/DerivedWithNavigationGraphType.cs @@ -6,13 +6,14 @@ public DerivedWithNavigationGraphType(IEfGraphQLService gr { AddNavigationConnectionField( name: "childrenFromInterface", - _ => _.Source.ChildrenFromBase); + projection: _ => _.ChildrenFromBase, + resolve: _ => _.Projection); AddNavigationConnectionField( name: "childrenFromDerived", - _ => _.Source.Children, - includeNames: [ "Children" ]); + projection: _ => _.Children, + resolve: _ => _.Projection); AutoMap(); Interface(); IsTypeOf = obj => obj is DerivedWithNavigationEntity; } -} \ No newline at end of file +} diff --git a/src/Tests/IntegrationTests/Graphs/ParentGraphType.cs b/src/Tests/IntegrationTests/Graphs/ParentGraphType.cs index 259a15685..6d0089a8f 100644 --- a/src/Tests/IntegrationTests/Graphs/ParentGraphType.cs +++ b/src/Tests/IntegrationTests/Graphs/ParentGraphType.cs @@ -6,13 +6,13 @@ public ParentGraphType(IEfGraphQLService graphQlService) : { AddNavigationConnectionField( name: "childrenConnection", - resolve: _ => _.Source.Children, - includeNames: [ "Children" ]); + projection: _ => _.Children, + resolve: _ => _.Projection); AddNavigationConnectionField( name: "childrenConnectionOmitQueryArguments", - resolve: _ => _.Source.Children, - includeNames: [ "Children" ], + projection: _ => _.Children, + resolve: _ => _.Projection, omitQueryArguments: true); AutoMap(); } -} \ No newline at end of file +} diff --git a/src/Tests/IntegrationTests/Graphs/SkipLevelGraph.cs b/src/Tests/IntegrationTests/Graphs/SkipLevelGraph.cs index 778508a3a..737741e5e 100644 --- a/src/Tests/IntegrationTests/Graphs/SkipLevelGraph.cs +++ b/src/Tests/IntegrationTests/Graphs/SkipLevelGraph.cs @@ -6,9 +6,9 @@ public SkipLevelGraph(IEfGraphQLService graphQlService) : { AddNavigationField( name: "level3Entity", - resolve: _ => _.Source.Level2Entity?.Level3Entity, - graphType: typeof(Level3GraphType), - includeNames: [ "Level2Entity.Level3Entity" ]); + projection: _ => _.Level2Entity!.Level3Entity, + resolve: _ => _.Projection, + graphType: typeof(Level3GraphType)); AutoMap(); } -} \ No newline at end of file +} diff --git a/src/Tests/IntegrationTests/Graphs/WithManyChildrenGraphType.cs b/src/Tests/IntegrationTests/Graphs/WithManyChildrenGraphType.cs index f7ad880a1..4396767f0 100644 --- a/src/Tests/IntegrationTests/Graphs/WithManyChildrenGraphType.cs +++ b/src/Tests/IntegrationTests/Graphs/WithManyChildrenGraphType.cs @@ -6,13 +6,13 @@ public WithManyChildrenGraphType(IEfGraphQLService graphQl { AddNavigationField( name: "child1", - resolve: context => + projection: _ => new { _.Child1, _.Child2 }, + resolve: ctx => { - Assert.NotNull(context.Source.Child2); - Assert.NotNull(context.Source.Child1); - return context.Source.Child1; - }, - includeNames: [ "Child2", "Child1" ]); + Assert.NotNull(ctx.Projection.Child2); + Assert.NotNull(ctx.Projection.Child1); + return ctx.Projection.Child1; + }); AutoMap(); } } \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationDbContext.cs b/src/Tests/IntegrationTests/IntegrationDbContext.cs index ec5b68b26..c8492ee1e 100644 --- a/src/Tests/IntegrationTests/IntegrationDbContext.cs +++ b/src/Tests/IntegrationTests/IntegrationDbContext.cs @@ -36,6 +36,10 @@ protected override void OnConfiguring(DbContextOptionsBuilder builder) => public DbSet ManyToManyShadowRightEntities { get; set; } = null!; public DbSet OwnedParents { get; set; } = null!; public DbSet ReadOnlyEntities { get; set; } = null!; + public DbSet FieldBuilderProjectionEntities { get; set; } = null!; + public DbSet FieldBuilderProjectionParentEntities { get; set; } = null!; + public DbSet Departments { get; set; } = null!; + public DbSet Employees { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -90,5 +94,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .Property(_ => _.ComputedInDb) .HasComputedColumnSql("Trim(Concat(Coalesce(FirstName, ''), ' ', Coalesce(LastName, '')))", stored: true); + var fieldBuilderProjection = modelBuilder.Entity(); + fieldBuilderProjection.OrderBy(_ => _.Name); + fieldBuilderProjection.Property(_ => _.Salary).HasPrecision(18, 2); + modelBuilder.Entity().OrderBy(_ => _.Name); + modelBuilder.Entity().OrderBy(_ => _.Name); + var employeeEntity = modelBuilder.Entity(); + employeeEntity.OrderBy(_ => _.Name); + employeeEntity + .HasOne(_ => _.Department) + .WithMany(_ => _.Employees) + .HasForeignKey(_ => _.DepartmentId) + .OnDelete(DeleteBehavior.Restrict); } } diff --git a/src/Tests/IntegrationTests/IntegrationTests.AutoMap_ListNavigation_AppliesFilters.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.AutoMap_ListNavigation_AppliesFilters.verified.txt new file mode 100644 index 000000000..e6b5d7ae0 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.AutoMap_ListNavigation_AppliesFilters.verified.txt @@ -0,0 +1,29 @@ +{ + target: +{ + "data": { + "parentEntitiesFiltered": [ + { + "property": "ParentValue", + "children": [ + { + "property": "KeepMe" + } + ] + } + ] + } +}, + sql: { + Text: +select f.Id, + f.Property, + f0.Id, + f0.Property +from FilterParentEntities as f + left outer join + FilterChildEntities as f0 + on f.Id = f0.ParentId +order by f.Property, f.Id, f0.Id + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Connection_nested.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Connection_nested.verified.txt index 7bfcd9014..8f1ccd23b 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Connection_nested.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Connection_nested.verified.txt @@ -89,7 +89,8 @@ sql: { Text: select p.Id, - c.Id + c.Id, + c.ParentId from ParentEntities as p left outer join ChildEntities as c diff --git a/src/Tests/IntegrationTests/IntegrationTests.Connection_nested_OmitQueryArguments.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Connection_nested_OmitQueryArguments.verified.txt index b74427dbe..d877eecc0 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Connection_nested_OmitQueryArguments.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Connection_nested_OmitQueryArguments.verified.txt @@ -89,7 +89,8 @@ sql: { Text: select p.Id, - c.Id + c.Id, + c.ParentId from ParentEntities as p left outer join ChildEntities as c diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_async_bool_value_type_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_async_bool_value_type_projection.verified.txt new file mode 100644 index 000000000..a85767fe6 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_async_bool_value_type_projection.verified.txt @@ -0,0 +1,34 @@ +{ + target: +{ + "data": { + "fieldBuilderProjectionEntities": [ + { + "name": "Child", + "hasParentAsync": true + } + ] + } +}, + sql: [ + { + Text: +select f.Id, + f.Name +from FieldBuilderProjectionEntities as f +order by f.Name + }, + { + Text: +select case when exists (select 1 + from FieldBuilderProjectionParentEntities as f + where exists (select 1 + from FieldBuilderProjectionEntities as f0 + where f.Id = f0.ParentId + and f0.Id = @ctx_Source_Id)) then cast (1 as bit) else cast (0 as bit) end, + Parameters: { + @ctx_Source_Id: Guid_1 + } + } + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_async_int_value_type_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_async_int_value_type_projection.verified.txt new file mode 100644 index 000000000..ad5fd2118 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_async_int_value_type_projection.verified.txt @@ -0,0 +1,69 @@ +{ + target: +{ + "data": { + "fieldBuilderProjectionEntities": [ + { + "name": "Child1", + "siblingCountAsync": 0 + }, + { + "name": "Child2", + "siblingCountAsync": 0 + }, + { + "name": "Child3", + "siblingCountAsync": 0 + } + ] + } +}, + sql: [ + { + Text: +select f.Id, + f.Name +from FieldBuilderProjectionEntities as f +order by f.Name + }, + { + Text: +select top (1) f.Id, + f.Name +from FieldBuilderProjectionParentEntities as f +where exists (select 1 + from FieldBuilderProjectionEntities as f0 + where f.Id = f0.ParentId + and f0.Id = @ctx_Source_Id), + Parameters: { + @ctx_Source_Id: Guid_1 + } + }, + { + Text: +select top (1) f.Id, + f.Name +from FieldBuilderProjectionParentEntities as f +where exists (select 1 + from FieldBuilderProjectionEntities as f0 + where f.Id = f0.ParentId + and f0.Id = @ctx_Source_Id), + Parameters: { + @ctx_Source_Id: Guid_2 + } + }, + { + Text: +select top (1) f.Id, + f.Name +from FieldBuilderProjectionParentEntities as f +where exists (select 1 + from FieldBuilderProjectionEntities as f0 + where f.Id = f0.ParentId + and f0.Id = @ctx_Source_Id), + Parameters: { + @ctx_Source_Id: Guid_3 + } + } + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_bool_value_type_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_bool_value_type_projection.verified.txt new file mode 100644 index 000000000..ed3cad0d8 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_bool_value_type_projection.verified.txt @@ -0,0 +1,27 @@ +{ + target: +{ + "data": { + "fieldBuilderProjectionEntities": [ + { + "name": "Adult", + "age": 25, + "isAdult": true + }, + { + "name": "Child", + "age": 15, + "isAdult": false + } + ] + } +}, + sql: { + Text: +select f.Id, + f.Name, + f.Age +from FieldBuilderProjectionEntities as f +order by f.Name + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_datetime_value_type_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_datetime_value_type_projection.verified.txt new file mode 100644 index 000000000..aac5d9406 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_datetime_value_type_projection.verified.txt @@ -0,0 +1,22 @@ +{ + target: +{ + "data": { + "fieldBuilderProjectionEntities": [ + { + "name": "Test", + "createdAt": "2024-06-15T10:30:00", + "createdYear": "2024-01-01T00:00:00Z" + } + ] + } +}, + sql: { + Text: +select f.Id, + f.Name, + f.CreatedAt +from FieldBuilderProjectionEntities as f +order by f.Name + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_decimal_value_type_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_decimal_value_type_projection.verified.txt new file mode 100644 index 000000000..e4307c280 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_decimal_value_type_projection.verified.txt @@ -0,0 +1,22 @@ +{ + target: +{ + "data": { + "fieldBuilderProjectionEntities": [ + { + "name": "Employee", + "salary": 50000.00, + "salaryWithBonus": 55000.000 + } + ] + } +}, + sql: { + Text: +select f.Id, + f.Name, + f.Salary +from FieldBuilderProjectionEntities as f +order by f.Name + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_double_value_type_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_double_value_type_projection.verified.txt new file mode 100644 index 000000000..25306ea2e --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_double_value_type_projection.verified.txt @@ -0,0 +1,22 @@ +{ + target: +{ + "data": { + "fieldBuilderProjectionEntities": [ + { + "name": "Student", + "score": 85.5, + "scoreNormalized": 0.855 + } + ] + } +}, + sql: { + Text: +select f.Id, + f.Name, + f.Score +from FieldBuilderProjectionEntities as f +order by f.Name + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_enum_projection_through_navigation.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_enum_projection_through_navigation.verified.txt new file mode 100644 index 000000000..e806d3d67 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_enum_projection_through_navigation.verified.txt @@ -0,0 +1,26 @@ +{ + target: +{ + "data": { + "fieldBuilderProjectionParents": [ + { + "name": "Parent", + "children": { + "edges": [] + } + } + ] + } +}, + sql: { + Text: +select f.Id, + f.Name, + f0.Id +from FieldBuilderProjectionParentEntities as f + left outer join + FieldBuilderProjectionEntities as f0 + on f.Id = f0.ParentId +order by f.Name, f.Id, f0.Id + } +} diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_enum_scalar_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_enum_scalar_projection.verified.txt new file mode 100644 index 000000000..9a105d66f --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_enum_scalar_projection.verified.txt @@ -0,0 +1,32 @@ +{ + target: +{ + "data": { + "fieldBuilderProjectionEntities": [ + { + "name": "ActiveEntity", + "status": "ACTIVE", + "statusDisplay": "Currently Active" + }, + { + "name": "InactiveEntity", + "status": "INACTIVE", + "statusDisplay": "Currently Inactive" + }, + { + "name": "PendingEntity", + "status": "PENDING", + "statusDisplay": "Pending Activation" + } + ] + } +}, + sql: { + Text: +select f.Id, + f.Name, + f.Status +from FieldBuilderProjectionEntities as f +order by f.Name + } +} diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_int_value_type_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_int_value_type_projection.verified.txt new file mode 100644 index 000000000..033abd129 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_int_value_type_projection.verified.txt @@ -0,0 +1,22 @@ +{ + target: +{ + "data": { + "fieldBuilderProjectionEntities": [ + { + "name": "John", + "age": 25, + "ageDoubled": 50 + } + ] + } +}, + sql: { + Text: +select f.Id, + f.Name, + f.Age +from FieldBuilderProjectionEntities as f +order by f.Name + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_long_value_type_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_long_value_type_projection.verified.txt new file mode 100644 index 000000000..0a6f70510 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_long_value_type_projection.verified.txt @@ -0,0 +1,22 @@ +{ + target: +{ + "data": { + "fieldBuilderProjectionEntities": [ + { + "name": "Video", + "viewCount": 1234567890, + "viewCountDoubled": 2469135780 + } + ] + } +}, + sql: { + Text: +select f.Id, + f.Name, + f.ViewCount +from FieldBuilderProjectionEntities as f +order by f.Name + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_multiple_value_types_combined.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_multiple_value_types_combined.verified.txt new file mode 100644 index 000000000..563bad2cb --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_multiple_value_types_combined.verified.txt @@ -0,0 +1,33 @@ +{ + target: +{ + "data": { + "fieldBuilderProjectionEntities": [ + { + "name": "Complete", + "age": 30, + "ageDoubled": 60, + "isAdult": true, + "salary": 75000.00, + "salaryWithBonus": 82500.000, + "score": 92.5, + "scoreNormalized": 0.925, + "viewCount": 999888777, + "viewCountDoubled": 1999777554, + "nameUpper": "COMPLETE" + } + ] + } +}, + sql: { + Text: +select f.Id, + f.Name, + f.Age, + f.Salary, + f.Score, + f.ViewCount +from FieldBuilderProjectionEntities as f +order by f.Name + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_navigation_property_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_navigation_property_projection.verified.txt new file mode 100644 index 000000000..36b0c5799 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_navigation_property_projection.verified.txt @@ -0,0 +1,29 @@ +{ + target: +{ + "data": { + "fieldBuilderProjectionEntities": [ + { + "name": "HasParent", + "parentName": "No Parent" + }, + { + "name": "NoParent", + "parentName": "No Parent" + } + ] + } +}, + sql: { + Text: +select f.Id, + f.Name, + case when f0.Id is null then cast (1 as bit) else cast (0 as bit) end, + f0.Id +from FieldBuilderProjectionEntities as f + left outer join + FieldBuilderProjectionParentEntities as f0 + on f.ParentId = f0.Id +order by f.Name + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_string_reference_type_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_string_reference_type_projection.verified.txt new file mode 100644 index 000000000..3dcb16c7d --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_string_reference_type_projection.verified.txt @@ -0,0 +1,20 @@ +{ + target: +{ + "data": { + "fieldBuilderProjectionEntities": [ + { + "name": "lowercase", + "nameUpper": "LOWERCASE" + } + ] + } +}, + sql: { + Text: +select f.Id, + f.Name +from FieldBuilderProjectionEntities as f +order by f.Name + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Filter_with_projection_accesses_foreign_key.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Filter_with_projection_accesses_foreign_key.verified.txt index e2e40bfc2..c1f2c2bc6 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Filter_with_projection_accesses_foreign_key.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Filter_with_projection_accesses_foreign_key.verified.txt @@ -35,8 +35,8 @@ from ParentEntities as p select p0.Id, p0.Property, c.Id, - c.Property, - c.ParentId + c.ParentId, + c.Property from (select p.Id, p.Property from ParentEntities as p diff --git a/src/Tests/IntegrationTests/IntegrationTests.FirstParent_Child.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FirstParent_Child.verified.txt index e1b717396..8fb77a052 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.FirstParent_Child.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.FirstParent_Child.verified.txt @@ -20,6 +20,7 @@ select p0.Id, p0.Property, c.Id, + c.ParentId, c.Property from (select top (1) p.Id, p.Property diff --git a/src/Tests/IntegrationTests/IntegrationTests.FirstParent_Child_WithFragment.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FirstParent_Child_WithFragment.verified.txt index e1b717396..8fb77a052 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.FirstParent_Child_WithFragment.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.FirstParent_Child_WithFragment.verified.txt @@ -20,6 +20,7 @@ select p0.Id, p0.Property, c.Id, + c.ParentId, c.Property from (select top (1) p.Id, p.Property diff --git a/src/Tests/IntegrationTests/IntegrationTests.First_IdOnly.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.First_IdOnly.verified.txt index a14d3656f..7f82ae909 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.First_IdOnly.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.First_IdOnly.verified.txt @@ -17,6 +17,7 @@ select p0.Id, p0.Property, c.Id, + c.ParentId, c.Property from (select top (1) p.Id, p.Property diff --git a/src/Tests/IntegrationTests/IntegrationTests.First_NoArgs.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.First_NoArgs.verified.txt index f8b159820..76220bd24 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.First_NoArgs.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.First_NoArgs.verified.txt @@ -17,6 +17,7 @@ select p0.Id, p0.Property, c.Id, + c.ParentId, c.Property from (select top (1) p.Id, p.Property diff --git a/src/Tests/IntegrationTests/IntegrationTests.ForeignKey_CustomField_UsesProjectedForeignKey.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.ForeignKey_CustomField_UsesProjectedForeignKey.verified.txt new file mode 100644 index 000000000..b23cfd97a --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.ForeignKey_CustomField_UsesProjectedForeignKey.verified.txt @@ -0,0 +1,90 @@ +{ + target: +{ + "data": { + "departments": [ + { + "id": "Guid_1", + "name": "Active Department", + "isActive": true, + "employees": [ + { + "id": "Guid_2", + "name": "Alice", + "departmentId": "Guid_1", + "isInActiveDepartment": true + }, + { + "id": "Guid_3", + "name": "Bob", + "departmentId": "Guid_1", + "isInActiveDepartment": true + } + ] + }, + { + "id": "Guid_4", + "name": "Inactive Department", + "isActive": false, + "employees": [ + { + "id": "Guid_5", + "name": "Charlie", + "departmentId": "Guid_4", + "isInActiveDepartment": false + } + ] + } + ] + } +}, + sql: [ + { + Text: +select d.Id, + d.Name, + d.IsActive, + e.Id, + e.DepartmentId, + e.Name +from Departments as d + left outer join + Employees as e + on d.Id = e.DepartmentId +order by d.Name, d.Id, e.Id + }, + { + Text: +select top (2) d.Id, + d.IsActive, + d.Name +from Departments as d +where d.Id = @context_Source_DepartmentId, + Parameters: { + @context_Source_DepartmentId: Guid_1 + } + }, + { + Text: +select top (2) d.Id, + d.IsActive, + d.Name +from Departments as d +where d.Id = @context_Source_DepartmentId, + Parameters: { + @context_Source_DepartmentId: Guid_1 + } + }, + { + Text: +select top (2) d.Id, + d.IsActive, + d.Name +from Departments as d +where d.Id = @context_Source_DepartmentId, + Parameters: { + @context_Source_DepartmentId: Guid_4 + } + } + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.ForeignKey_NestedNavigation_IncludesForeignKeyInProjection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.ForeignKey_NestedNavigation_IncludesForeignKeyInProjection.verified.txt new file mode 100644 index 000000000..d55c1c6ea --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.ForeignKey_NestedNavigation_IncludesForeignKeyInProjection.verified.txt @@ -0,0 +1,46 @@ +{ + target: +{ + "data": { + "departments": [ + { + "name": "Engineering", + "employees": [ + { + "name": "Developer", + "departmentId": "Guid_1", + "isInActiveDepartment": true + } + ] + } + ] + } +}, + sql: [ + { + Text: +select d.Id, + d.Name, + e.Id, + e.DepartmentId, + e.Name +from Departments as d + left outer join + Employees as e + on d.Id = e.DepartmentId +where d.Name = N'Engineering' +order by d.Name, d.Id, e.Id + }, + { + Text: +select top (2) d.Id, + d.IsActive, + d.Name +from Departments as d +where d.Id = @context_Source_DepartmentId, + Parameters: { + @context_Source_DepartmentId: Guid_1 + } + } + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Many_children.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Many_children.verified.txt index abdb0677d..e24fb891d 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Many_children.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Many_children.verified.txt @@ -16,14 +16,16 @@ select w.Id, case when c.Id is null then cast (1 as bit) else cast (0 as bit) end, c.Id, + c.ParentId, case when c0.Id is null then cast (1 as bit) else cast (0 as bit) end, - c0.Id + c0.Id, + c0.ParentId from WithManyChildrenEntities as w left outer join - Child2Entities as c + Child1Entities as c on w.Id = c.ParentId left outer join - Child1Entities as c0 + Child2Entities as c0 on w.Id = c0.ParentId } } \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Multiple_nested.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Multiple_nested.verified.txt index 8c54d120a..d0db39193 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Multiple_nested.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Multiple_nested.verified.txt @@ -18,6 +18,7 @@ select l.Id, case when l0.Id is null then cast (1 as bit) else cast (0 as bit) end, l0.Id, + l0.Level3EntityId, case when l1.Id is null then cast (1 as bit) else cast (0 as bit) end, l1.Id, l1.Property diff --git a/src/Tests/IntegrationTests/IntegrationTests.Multiple_nested_Filtered.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Multiple_nested_Filtered.verified.txt index 0d2812032..ac0032295 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Multiple_nested_Filtered.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Multiple_nested_Filtered.verified.txt @@ -16,6 +16,7 @@ select l.Id, case when l0.Id is null then cast (1 as bit) else cast (0 as bit) end, l0.Id, + l0.Level3EntityId, case when l1.Id is null then cast (1 as bit) else cast (0 as bit) end, l1.Id, l1.Property diff --git a/src/Tests/IntegrationTests/IntegrationTests.Null_on_nested.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Null_on_nested.verified.txt index a819ced42..3c3606b11 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Null_on_nested.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Null_on_nested.verified.txt @@ -18,6 +18,7 @@ select l.Id, case when l0.Id is null then cast (1 as bit) else cast (0 as bit) end, l0.Id, + l0.Level3EntityId, case when l1.Id is null then cast (1 as bit) else cast (0 as bit) end, l1.Id, l1.Property diff --git a/src/Tests/IntegrationTests/IntegrationTests.Null_on_nested_notEqual.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Null_on_nested_notEqual.verified.txt index 5f374d641..2699ab66a 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Null_on_nested_notEqual.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Null_on_nested_notEqual.verified.txt @@ -16,6 +16,7 @@ select l.Id, case when l0.Id is null then cast (1 as bit) else cast (0 as bit) end, l0.Id, + l0.Level3EntityId, case when l1.Id is null then cast (1 as bit) else cast (0 as bit) end, l1.Id, l1.Property diff --git a/src/Tests/IntegrationTests/IntegrationTests.Parent_child_with_id.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Parent_child_with_id.verified.txt index 413cf306f..237f5d07a 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Parent_child_with_id.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Parent_child_with_id.verified.txt @@ -19,6 +19,7 @@ select p.Id, p.Property, c.Id, + c.ParentId, c.Property from ParentEntities as p left outer join diff --git a/src/Tests/IntegrationTests/IntegrationTests.Parent_with_id_child_with_id.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Parent_with_id_child_with_id.verified.txt index 8c7e49ff4..7d33e025d 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Parent_with_id_child_with_id.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Parent_with_id_child_with_id.verified.txt @@ -19,6 +19,7 @@ select p.Id, p.Property, c.Id, + c.ParentId, c.Property from ParentEntities as p left outer join diff --git a/src/Tests/IntegrationTests/IntegrationTests.ProjectionBased_NavigationList_AppliesFilters.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.ProjectionBased_NavigationList_AppliesFilters.verified.txt new file mode 100644 index 000000000..b0b960d44 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.ProjectionBased_NavigationList_AppliesFilters.verified.txt @@ -0,0 +1,29 @@ +{ + target: +{ + "data": { + "parentEntitiesFiltered": [ + { + "property": "Parent1", + "children": [ + { + "property": "Child1" + } + ] + } + ] + } +}, + sql: { + Text: +select f.Id, + f.Property, + f0.Id, + f0.Property +from FilterParentEntities as f + left outer join + FilterChildEntities as f0 + on f.Id = f0.ParentId +order by f.Property, f.Id, f0.Id + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.ProjectionBased_NavigationSingle_AppliesFilters.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.ProjectionBased_NavigationSingle_AppliesFilters.verified.txt new file mode 100644 index 000000000..ac0032295 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.ProjectionBased_NavigationSingle_AppliesFilters.verified.txt @@ -0,0 +1,31 @@ +{ + target: +{ + "data": { + "level1Entities": [ + { + "level2Entity": { + "level3Entity": null + } + } + ] + } +}, + sql: { + Text: +select l.Id, + case when l0.Id is null then cast (1 as bit) else cast (0 as bit) end, + l0.Id, + l0.Level3EntityId, + case when l1.Id is null then cast (1 as bit) else cast (0 as bit) end, + l1.Id, + l1.Property +from Level1Entities as l + left outer join + Level2Entities as l0 + on l.Level2EntityId1 = l0.Id + left outer join + Level3Entities as l1 + on l0.Level3EntityId = l1.Id + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Query_Cyclic.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Query_Cyclic.verified.txt index ab1de4ccf..688a109b6 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Query_Cyclic.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Query_Cyclic.verified.txt @@ -69,6 +69,7 @@ select c.Id, p.Id, p.Property, s.Id, + s.ParentId, s.Property, s.c, s.Id0, @@ -79,11 +80,11 @@ from ChildEntities as c on c.ParentId = p.Id left outer join (select c0.Id, + c0.ParentId, c0.Property, case when p0.Id is null then cast (1 as bit) else cast (0 as bit) end as c, p0.Id as Id0, - p0.Property as Property0, - c0.ParentId + p0.Property as Property0 from ChildEntities as c0 left outer join ParentEntities as p0 diff --git a/src/Tests/IntegrationTests/IntegrationTests.Query_navigation_to_filtered_abstract_derived_entities.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Query_navigation_to_filtered_abstract_derived_entities.verified.txt index ce1b02a78..be9ffd490 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Query_navigation_to_filtered_abstract_derived_entities.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Query_navigation_to_filtered_abstract_derived_entities.verified.txt @@ -19,6 +19,7 @@ select p.Id, p.Property, c.Id, + c.ParentId, c.Property from ParentEntities as p left outer join diff --git a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt index f0a04f059..4cebcb029 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt @@ -145,6 +145,10 @@ where: [WhereExpression!], orderBy: [OrderBy!], ids: [ID!]): ParentConnection! + fieldBuilderProjectionEntities(id: ID, ids: [ID!], where: [WhereExpression!], orderBy: [OrderBy!], skip: Int, take: Int): [FieldBuilderProjection!]! + fieldBuilderProjectionParents(id: ID, ids: [ID!], where: [WhereExpression!], orderBy: [OrderBy!], skip: Int, take: Int): [FieldBuilderProjectionParent!]! + departments(id: ID, ids: [ID!], where: [WhereExpression!], orderBy: [OrderBy!], skip: Int, take: Int): [Department!]! + employees(id: ID, ids: [ID!], where: [WhereExpression!], orderBy: [OrderBy!], skip: Int, take: Int): [Employee!]! } type CustomType { @@ -601,6 +605,89 @@ type OwnedChild { property: String! } +type FieldBuilderProjection { + ageDoubled: Int! + isAdult: Boolean! + createdYear: DateTime! + salaryWithBonus: Decimal! + scoreNormalized: Float! + viewCountDoubled: Long! + hasParentAsync: Boolean! + siblingCountAsync: Int! + nameUpper: String! + statusDisplay: String! + parentName: String! + age: Int! + createdAt: DateTime! + id: ID! + isActive: Boolean! + name: String! + salary: Decimal! + score: Float! + status: EntityStatus! + viewCount: Long! +} + +scalar Decimal + +enum EntityStatus { + ACTIVE + INACTIVE + PENDING +} + +type FieldBuilderProjectionParent { + children( + "Only return edges after the specified cursor." + after: String, + "Specifies the maximum number of edges to return, starting after the cursor specified by 'after', or the first number of edges if 'after' is not specified." + first: Int, + "Only return edges prior to the specified cursor." + before: String, + "Specifies the maximum number of edges to return, starting prior to the cursor specified by 'before', or the last number of edges if 'before' is not specified." + last: Int, + where: [WhereExpression!], + orderBy: [OrderBy!], + ids: [ID!]): FieldBuilderProjectionConnection! + id: ID! + name: String! +} + +"A connection from an object to a list of objects of type `FieldBuilderProjection`." +type FieldBuilderProjectionConnection { + "A count of the total number of objects in this connection, ignoring pagination. This allows a client to fetch the first five objects by passing \"5\" as the argument to `first`, then fetch the total count so it could display \"5 of 83\", for example. In cases where we employ infinite scrolling or don't have an exact count of entries, this field will return `null`." + totalCount: Int + "Information to aid in pagination." + pageInfo: PageInfo! + "A list of all of the edges returned in the connection." + edges: [FieldBuilderProjectionEdge] + "A list of all of the objects returned in the connection. This is a convenience field provided for quickly exploring the API; rather than querying for \"{ edges { node } }\" when no edge data is needed, this field can be used instead. Note that when clients like Relay need to fetch the \"cursor\" field on the edge to enable efficient pagination, this shortcut cannot be used, and the full \"{ edges { node } } \" version should be used instead." + items: [FieldBuilderProjection!] +} + +"An edge in a connection from an object to another object of type `FieldBuilderProjection`." +type FieldBuilderProjectionEdge { + "A cursor for use in pagination" + cursor: String! + "The item at the end of the edge" + node: FieldBuilderProjection! +} + +type Department { + employees(id: ID, ids: [ID!], where: [WhereExpression!], orderBy: [OrderBy!], skip: Int, take: Int): [Employee!]! + id: ID! + isActive: Boolean! + name: String +} + +type Employee { + isInActiveDepartment: Boolean! + department: Department + departmentId: ID! + id: ID! + name: String +} + type Mutation { parentEntityMutation(id: ID, ids: [ID!], where: [WhereExpression!]): Parent! } diff --git a/src/Tests/IntegrationTests/IntegrationTests.SingleParent_Child.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.SingleParent_Child.verified.txt index 219278464..a9d0e7996 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.SingleParent_Child.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.SingleParent_Child.verified.txt @@ -20,6 +20,7 @@ select p0.Id, p0.Property, c.Id, + c.ParentId, c.Property from (select top (2) p.Id, p.Property diff --git a/src/Tests/IntegrationTests/IntegrationTests.SingleParent_Child_WithFragment.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.SingleParent_Child_WithFragment.verified.txt index 219278464..a9d0e7996 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.SingleParent_Child_WithFragment.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.SingleParent_Child_WithFragment.verified.txt @@ -20,6 +20,7 @@ select p0.Id, p0.Property, c.Id, + c.ParentId, c.Property from (select top (2) p.Id, p.Property diff --git a/src/Tests/IntegrationTests/IntegrationTests.SingleParent_Child_mutation.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.SingleParent_Child_mutation.verified.txt index 7b0546db8..365bd429e 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.SingleParent_Child_mutation.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.SingleParent_Child_mutation.verified.txt @@ -20,6 +20,7 @@ select p0.Id, p0.Property, c.Id, + c.ParentId, c.Property from (select top (2) p.Id, p.Property diff --git a/src/Tests/IntegrationTests/IntegrationTests.Single_IdOnly.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Single_IdOnly.verified.txt index 7a5173933..b23c0b718 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Single_IdOnly.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Single_IdOnly.verified.txt @@ -17,6 +17,7 @@ select p0.Id, p0.Property, c.Id, + c.ParentId, c.Property from (select top (2) p.Id, p.Property diff --git a/src/Tests/IntegrationTests/IntegrationTests.Single_NoArgs.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Single_NoArgs.verified.txt index ab8f1e48b..d1dcdab07 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Single_NoArgs.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Single_NoArgs.verified.txt @@ -17,6 +17,7 @@ select p0.Id, p0.Property, c.Id, + c.ParentId, c.Property from (select top (2) p.Id, p.Property diff --git a/src/Tests/IntegrationTests/IntegrationTests_AutoMapFilters.cs b/src/Tests/IntegrationTests/IntegrationTests_AutoMapFilters.cs new file mode 100644 index 000000000..caf6a25ac --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests_AutoMapFilters.cs @@ -0,0 +1,125 @@ +public partial class IntegrationTests +{ + // Tests that AutoMap'd collection navigation fields apply filters correctly + // FilterParentGraphType uses AutoMap() which registers the Children navigation + [Fact] + public async Task AutoMap_ListNavigation_AppliesFilters() + { + var query = + """ + { + parentEntitiesFiltered + { + property + children + { + property + } + } + } + """; + + var parent = new FilterParentEntity + { + Property = "ParentValue" + }; + var childIgnored = new FilterChildEntity + { + Property = "Ignore", // Should be filtered out + Parent = parent + }; + var childKept = new FilterChildEntity + { + Property = "KeepMe", + Parent = parent + }; + parent.Children.Add(childIgnored); + parent.Children.Add(childKept); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, BuildFilters(), false, [parent, childIgnored, childKept]); + } + + // Tests that projection-based AddNavigationListField applies filters + // This uses the simple projection-based overload: AddNavigationListField(graph, name, projection) + [Fact] + public async Task ProjectionBased_NavigationList_AppliesFilters() + { + var query = + """ + { + parentEntitiesFiltered + { + property + children + { + property + } + } + } + """; + + var parent1 = new FilterParentEntity + { + Property = "Parent1" + }; + var parent2 = new FilterParentEntity + { + Property = "Ignore" // Parent should be filtered out + }; + var child1 = new FilterChildEntity + { + Property = "Child1", + Parent = parent1 + }; + var child2 = new FilterChildEntity + { + Property = "Ignore", // Child should be filtered out + Parent = parent1 + }; + parent1.Children.Add(child1); + parent1.Children.Add(child2); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, BuildFilters(), false, [parent1, parent2, child1, child2]); + } + + // Tests that projection-based AddNavigationField applies ShouldInclude filter + // Multiple_nested_Filtered already tests this scenario with Level3Entity + [Fact] + public async Task ProjectionBased_NavigationSingle_AppliesFilters() + { + var query = + """ + { + level1Entities + { + level2Entity + { + level3Entity + { + property + } + } + } + } + """; + + var level3Ignored = new Level3Entity + { + Property = "Ignore" // Should be filtered to null + }; + var level2 = new Level2Entity + { + Level3Entity = level3Ignored + }; + var level1 = new Level1Entity + { + Level2Entity = level2 + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, BuildFilters(), false, [level1, level2, level3Ignored]); + } + +} diff --git a/src/Tests/IntegrationTests/IntegrationTests_FieldBuilderExtensions.cs b/src/Tests/IntegrationTests/IntegrationTests_FieldBuilderExtensions.cs new file mode 100644 index 000000000..8637546b6 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests_FieldBuilderExtensions.cs @@ -0,0 +1,403 @@ +public partial class IntegrationTests +{ + [Fact] + public async Task FieldBuilder_int_value_type_projection() + { + var query = + """ + { + fieldBuilderProjectionEntities + { + name + age + ageDoubled + } + } + """; + + var entity = new FieldBuilderProjectionEntity + { + Name = "John", + Age = 25 + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity]); + } + + [Fact] + public async Task FieldBuilder_bool_value_type_projection() + { + var query = + """ + { + fieldBuilderProjectionEntities + { + name + age + isAdult + } + } + """; + + var entity1 = new FieldBuilderProjectionEntity + { + Name = "Child", + Age = 15 + }; + var entity2 = new FieldBuilderProjectionEntity + { + Name = "Adult", + Age = 25 + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity1, entity2]); + } + + [Fact] + public async Task FieldBuilder_datetime_value_type_projection() + { + var query = + """ + { + fieldBuilderProjectionEntities + { + name + createdAt + createdYear + } + } + """; + + var entity = new FieldBuilderProjectionEntity + { + Name = "Test", + CreatedAt = new(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc) + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity]); + } + + [Fact] + public async Task FieldBuilder_decimal_value_type_projection() + { + var query = + """ + { + fieldBuilderProjectionEntities + { + name + salary + salaryWithBonus + } + } + """; + + var entity = new FieldBuilderProjectionEntity + { + Name = "Employee", + Salary = 50000.00m + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity]); + } + + [Fact] + public async Task FieldBuilder_double_value_type_projection() + { + var query = + """ + { + fieldBuilderProjectionEntities + { + name + score + scoreNormalized + } + } + """; + + var entity = new FieldBuilderProjectionEntity + { + Name = "Student", + Score = 85.5 + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity]); + } + + [Fact] + public async Task FieldBuilder_long_value_type_projection() + { + var query = + """ + { + fieldBuilderProjectionEntities + { + name + viewCount + viewCountDoubled + } + } + """; + + var entity = new FieldBuilderProjectionEntity + { + Name = "Video", + ViewCount = 1234567890L + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity]); + } + + [Fact] + public async Task FieldBuilder_async_bool_value_type_projection() + { + var query = + """ + { + fieldBuilderProjectionEntities + { + name + hasParentAsync + } + } + """; + + var parent = new FieldBuilderProjectionParentEntity + { + Name = "Parent" + }; + var entity = new FieldBuilderProjectionEntity + { + Name = "Child", + Parent = parent + }; + parent.Children.Add(entity); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity, parent]); + } + + [Fact] + public async Task FieldBuilder_async_int_value_type_projection() + { + var query = + """ + { + fieldBuilderProjectionEntities + { + name + siblingCountAsync + } + } + """; + + var parent = new FieldBuilderProjectionParentEntity + { + Name = "Parent" + }; + var entity1 = new FieldBuilderProjectionEntity + { + Name = "Child1", + Parent = parent + }; + var entity2 = new FieldBuilderProjectionEntity + { + Name = "Child2", + Parent = parent + }; + var entity3 = new FieldBuilderProjectionEntity + { + Name = "Child3", + Parent = parent + }; + parent.Children.Add(entity1); + parent.Children.Add(entity2); + parent.Children.Add(entity3); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity1, entity2, entity3, parent]); + } + + [Fact] + public async Task FieldBuilder_string_reference_type_projection() + { + var query = + """ + { + fieldBuilderProjectionEntities + { + name + nameUpper + } + } + """; + + var entity = new FieldBuilderProjectionEntity + { + Name = "lowercase" + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity]); + } + + [Fact] + public async Task FieldBuilder_navigation_property_projection() + { + var query = + """ + { + fieldBuilderProjectionEntities + { + name + parentName + } + } + """; + + var parent = new FieldBuilderProjectionParentEntity + { + Name = "ParentName" + }; + var entityWithParent = new FieldBuilderProjectionEntity + { + Name = "HasParent", + Parent = parent + }; + var entityWithoutParent = new FieldBuilderProjectionEntity + { + Name = "NoParent", + Parent = null + }; + parent.Children.Add(entityWithParent); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entityWithParent, entityWithoutParent, parent]); + } + + [Fact] + public async Task FieldBuilder_multiple_value_types_combined() + { + var query = + """ + { + fieldBuilderProjectionEntities + { + name + age + ageDoubled + isAdult + salary + salaryWithBonus + score + scoreNormalized + viewCount + viewCountDoubled + nameUpper + } + } + """; + + var entity = new FieldBuilderProjectionEntity + { + Name = "Complete", + Age = 30, + Salary = 75000.00m, + Score = 92.5, + ViewCount = 999888777L + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity]); + } + + [Fact] + public async Task FieldBuilder_enum_scalar_projection() + { + var query = + """ + { + fieldBuilderProjectionEntities + { + name + status + statusDisplay + } + } + """; + + var entity1 = new FieldBuilderProjectionEntity + { + Name = "ActiveEntity", + Status = EntityStatus.Active + }; + var entity2 = new FieldBuilderProjectionEntity + { + Name = "PendingEntity", + Status = EntityStatus.Pending + }; + var entity3 = new FieldBuilderProjectionEntity + { + Name = "InactiveEntity", + Status = EntityStatus.Inactive + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity1, entity2, entity3]); + } + + [Fact] + public async Task FieldBuilder_enum_projection_through_navigation() + { + // This test verifies that scalar enum projections work correctly when navigating through relationships + // Before the fix, this would fail with: "Unable to find navigation 'Status' specified in string based include path" + // because IncludeAppender tried to add includes for scalar types + var query = + """ + { + fieldBuilderProjectionParents + { + name + children { + edges { + node { + name + status + statusDisplay + } + } + } + } + } + """; + + var parent = new FieldBuilderProjectionParentEntity + { + Name = "Parent" + }; + var child1 = new FieldBuilderProjectionEntity + { + Name = "ActiveChild", + Status = EntityStatus.Active, + Parent = parent + }; + var child2 = new FieldBuilderProjectionEntity + { + Name = "PendingChild", + Status = EntityStatus.Pending, + Parent = parent + }; + parent.Children.Add(child1); + parent.Children.Add(child2); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [parent, child1, child2]); + } +} diff --git a/src/Tests/IntegrationTests/IntegrationTests_ForeignKeyProjection.cs b/src/Tests/IntegrationTests/IntegrationTests_ForeignKeyProjection.cs new file mode 100644 index 000000000..d27a232a3 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests_ForeignKeyProjection.cs @@ -0,0 +1,104 @@ +// Test for foreign key projection bug fix +// https://github.com/SimonCropp/GraphQL.EntityFramework/issues/XXX +public partial class IntegrationTests +{ + [Fact] + public async Task ForeignKey_CustomField_UsesProjectedForeignKey() + { + var query = + """ + { + departments (orderBy: {path: "name"}) + { + id + name + isActive + employees (orderBy: {path: "name"}) + { + id + name + departmentId + isInActiveDepartment + } + } + } + """; + + var activeDepartment = new DepartmentEntity + { + Name = "Active Department", + IsActive = true + }; + var inactiveDepartment = new DepartmentEntity + { + Name = "Inactive Department", + IsActive = false + }; + + var employee1 = new EmployeeEntity + { + Name = "Alice", + Department = activeDepartment, + DepartmentId = activeDepartment.Id + }; + var employee2 = new EmployeeEntity + { + Name = "Bob", + Department = activeDepartment, + DepartmentId = activeDepartment.Id + }; + var employee3 = new EmployeeEntity + { + Name = "Charlie", + Department = inactiveDepartment, + DepartmentId = inactiveDepartment.Id + }; + + activeDepartment.Employees.Add(employee1); + activeDepartment.Employees.Add(employee2); + inactiveDepartment.Employees.Add(employee3); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [activeDepartment, inactiveDepartment, employee1, employee2, employee3]); + } + + [Fact] + public async Task ForeignKey_NestedNavigation_IncludesForeignKeyInProjection() + { + // This test verifies that foreign keys are included in nested navigation projections + // Without the fix, DepartmentId would be Guid.Empty and the custom field would fail + var query = + """ + { + departments (where: [{path: "name", comparison: equal, value: "Engineering"}]) + { + name + employees (orderBy: {path: "name"}) + { + name + departmentId + isInActiveDepartment + } + } + } + """; + + var department = new DepartmentEntity + { + Name = "Engineering", + IsActive = true + }; + + var employee = new EmployeeEntity + { + Name = "Developer", + Department = department, + DepartmentId = department.Id + }; + + department.Employees.Add(employee); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [department, employee]); + } +} diff --git a/src/Tests/IntegrationTests/Query.cs b/src/Tests/IntegrationTests/Query.cs index d31c2766a..ec4944080 100644 --- a/src/Tests/IntegrationTests/Query.cs +++ b/src/Tests/IntegrationTests/Query.cs @@ -312,5 +312,21 @@ public Query(IEfGraphQLService efGraphQlService) name: "nullTaskInnerQueryConnection", resolve: Task?>? (_) => Task.FromResult?>(null)) .PageSize(10); + + AddQueryField( + name: "fieldBuilderProjectionEntities", + resolve: _ => _.DbContext.FieldBuilderProjectionEntities); + + AddQueryField( + name: "fieldBuilderProjectionParents", + resolve: _ => _.DbContext.FieldBuilderProjectionParentEntities); + + AddQueryField( + name: "departments", + resolve: _ => _.DbContext.Departments); + + AddQueryField( + name: "employees", + resolve: _ => _.DbContext.Employees); } } diff --git a/src/Tests/IntegrationTests/Schema.cs b/src/Tests/IntegrationTests/Schema.cs index a0908ab91..216da019e 100644 --- a/src/Tests/IntegrationTests/Schema.cs +++ b/src/Tests/IntegrationTests/Schema.cs @@ -35,6 +35,10 @@ public Schema(IServiceProvider resolver) : RegisterTypeMapping(typeof(ParentEntityView), typeof(ParentEntityViewGraphType)); RegisterTypeMapping(typeof(OwnedParent), typeof(OwnedParentGraphType)); RegisterTypeMapping(typeof(OwnedChild), typeof(OwnedChildGraphType)); + RegisterTypeMapping(typeof(FieldBuilderProjectionEntity), typeof(FieldBuilderProjectionGraphType)); + RegisterTypeMapping(typeof(FieldBuilderProjectionParentEntity), typeof(FieldBuilderProjectionParentGraphType)); + RegisterTypeMapping(typeof(DepartmentEntity), typeof(DepartmentGraphType)); + RegisterTypeMapping(typeof(EmployeeEntity), typeof(EmployeeGraphType)); Query = (Query)resolver.GetService(typeof(Query))!; Mutation = (Mutation)resolver.GetService(typeof(Mutation))!; RegisterType(typeof(DerivedGraphType)); diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index ce968b8f9..a01fffae7 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -11,6 +11,7 @@ + @@ -29,4 +30,4 @@ - \ No newline at end of file +