From 625adfcfe4af2d5e56e518e04a8c83c14851f8ac Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 18 Sep 2026 13:47:51 +1000 Subject: [PATCH] Load every projection of a field Setting a projection on a field replaced the one already there, so a field given two loaded only the last one's data. Whatever the first asked for was read from the entity as its default value. WithProjection now adds to a field's projections rather than replacing, and all of them are loaded. A field can be built from several parts, each saying what it reads: a downstream API has a permission field with one projection for its rights check and another for its allow check, and the check whose column went unloaded silently answered for every row it should have denied. The field keeps a list of projections, and the paths of all of them are analyzed together, once, as before. The first projection still gives the primary root, so a field with one behaves as it did. A path both projections read is added once. Version 35.3.0. --- docs/defining-graphs.md | 3 ++ docs/mdsource/defining-graphs.source.md | 3 ++ src/Directory.Build.props | 2 +- .../GraphApi/ProjectionPaths.cs | 17 ++++++++-- .../IncludeAppender.cs | 32 +++++++++++++++++-- .../FieldBuilderProjectionGraphType.cs | 7 ++++ ...twice_includes_both_in_select.verified.txt | 21 ++++++++++++ .../IntegrationTests.SchemaPrint.verified.txt | 1 + ...IntegrationTests_FieldBuilderExtensions.cs | 27 ++++++++++++++++ 9 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_WithProjection_twice_includes_both_in_select.verified.txt diff --git a/docs/defining-graphs.md b/docs/defining-graphs.md index 5d8979c8..d6eaccfb 100644 --- a/docs/defining-graphs.md +++ b/docs/defining-graphs.md @@ -251,6 +251,9 @@ Field, string>("statusLabel") 3. Scalar properties referenced in the expression (e.g., `Status`) are added to the SELECT column list 4. Navigation properties referenced in the expression trigger the appropriate `Include` calls +`WithProjection` can be called more than once on a field, and the data of every projection is +loaded. This suits a field whose value comes from several checks, each declaring what it reads. + **When to use `WithProjection` vs `Resolve<..., TProjection>`:** | Scenario | Use | diff --git a/docs/mdsource/defining-graphs.source.md b/docs/mdsource/defining-graphs.source.md index 3977b8fc..0c083613 100644 --- a/docs/mdsource/defining-graphs.source.md +++ b/docs/mdsource/defining-graphs.source.md @@ -188,6 +188,9 @@ Field, string>("statusLabel") 3. Scalar properties referenced in the expression (e.g., `Status`) are added to the SELECT column list 4. Navigation properties referenced in the expression trigger the appropriate `Include` calls +`WithProjection` can be called more than once on a field, and the data of every projection is +loaded. This suits a field whose value comes from several checks, each declaring what it reads. + **When to use `WithProjection` vs `Resolve<..., TProjection>`:** | Scenario | Use | diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e8e781a9..b26b2afe 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;NU5104;CS1573;CS9107;NU1608;NU1109 - 35.2.0 + 35.3.0 preview 1.0.0 EntityFrameworkCore, EntityFramework, GraphQL diff --git a/src/GraphQL.EntityFramework/GraphApi/ProjectionPaths.cs b/src/GraphQL.EntityFramework/GraphApi/ProjectionPaths.cs index fa795de3..bd1c781f 100644 --- a/src/GraphQL.EntityFramework/GraphApi/ProjectionPaths.cs +++ b/src/GraphQL.EntityFramework/GraphApi/ProjectionPaths.cs @@ -18,12 +18,19 @@ sealed class ProjectionPaths public string? PrimaryRoot => Groups.Count == 0 ? null : Groups[0].Root; - public static ProjectionPaths Analyze(LambdaExpression projection) + public static ProjectionPaths Analyze(LambdaExpression projection) => + Analyze([projection]); + + /// + /// The paths of every projection a field declares, in the order they were declared. A field + /// can be given more than one projection, and each one's data has to be loaded. + /// + public static ProjectionPaths Analyze(IReadOnlyList projections) { var groups = new List(); var byRoot = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var path in ProjectionAnalyzer.ExtractPropertyPaths(projection)) + foreach (var path in projections.SelectMany(ProjectionAnalyzer.ExtractPropertyPaths)) { var dotIndex = path.IndexOf('.'); var root = dotIndex >= 0 ? path[..dotIndex] : path; @@ -70,6 +77,12 @@ sealed class ProjectionPathGroup(string root) internal void Add(string path) { + // Two projections on one field can read the same path. + if (nested.Contains(path)) + { + return; + } + nested.Add(path); if (!path.Contains('.')) { diff --git a/src/GraphQL.EntityFramework/IncludeAppender.cs b/src/GraphQL.EntityFramework/IncludeAppender.cs index d390801c..887a874d 100644 --- a/src/GraphQL.EntityFramework/IncludeAppender.cs +++ b/src/GraphQL.EntityFramework/IncludeAppender.cs @@ -862,11 +862,39 @@ static void AddNavigation( const string projectionKey = "_EF_Projection"; const string projectionPathsKey = "_EF_ProjectionPaths"; + /// + /// Adds a projection to a field. A field can be given several, and every one of them is loaded. + /// They replaced each other, so a field with two projections loaded only the last one's data + /// and its other resolver read an unloaded property. + /// public static void SetProjectionMetadata(FieldType fieldType, LambdaExpression projection) { - fieldType.Metadata[projectionKey] = projection; + var projections = Projections(fieldType); + projections.Add(projection); + fieldType.Metadata[projectionKey] = projections; // Analyzed here, once, rather than on every request that selects the field - fieldType.Metadata[projectionPathsKey] = ProjectionPaths.Analyze(projection); + fieldType.Metadata[projectionPathsKey] = ProjectionPaths.Analyze(projections); + } + + static List Projections(FieldType fieldType) + { + if (!fieldType.Metadata.TryGetValue(projectionKey, out var existing)) + { + return []; + } + + if (existing is List projections) + { + return projections; + } + + // An expression placed in the metadata directly, without going through here + if (existing is LambdaExpression expression) + { + return [expression]; + } + + return []; } static bool TryGetProjectionMetadata(FieldType fieldType, [NotNullWhen(true)] out ProjectionPaths? projection) diff --git a/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionGraphType.cs b/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionGraphType.cs index 0e525310..f2410c40 100644 --- a/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionGraphType.cs +++ b/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionGraphType.cs @@ -75,6 +75,13 @@ public FieldBuilderProjectionGraphType(IEfGraphQLService g _ => "Unknown" }); + // Two projections on one field. Both are loaded: the second used to replace the first, + // leaving the resolver to read whatever the first one asked for as a default value. + Field, string>("statusAndAgeViaTwoProjections") + .WithProjection(_ => _.Status) + .WithProjection(_ => _.Age) + .Resolve(_ => $"{_.Source.Status} at {_.Source.Age}"); + AutoMap(exclusions: [nameof(FieldBuilderProjectionEntity.Parent)]); } } diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_WithProjection_twice_includes_both_in_select.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_WithProjection_twice_includes_both_in_select.verified.txt new file mode 100644 index 00000000..66632954 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_WithProjection_twice_includes_both_in_select.verified.txt @@ -0,0 +1,21 @@ +{ + target: { + Data: { + fieldBuilderProjectionEntities: [ + { + name: BothProjections, + statusAndAgeViaTwoProjections: Pending at 41 + } + ] + } + }, + sql: { + Text: +select f.Age, + f.Id, + f.Name, + f.Status +from FieldBuilderProjectionEntities as f +order by f.Name + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt index 03c820f5..a7094a82 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt @@ -1437,6 +1437,7 @@ type FieldBuilderProjection { statusDisplay: String! parentName: String! statusViaWithProjection: String! + statusAndAgeViaTwoProjections: String! age: Int! createdAt: DateTime! id: ID! diff --git a/src/Tests/IntegrationTests/IntegrationTests_FieldBuilderExtensions.cs b/src/Tests/IntegrationTests/IntegrationTests_FieldBuilderExtensions.cs index 9fe95b78..2a5faf34 100644 --- a/src/Tests/IntegrationTests/IntegrationTests_FieldBuilderExtensions.cs +++ b/src/Tests/IntegrationTests/IntegrationTests_FieldBuilderExtensions.cs @@ -433,4 +433,31 @@ public async Task FieldBuilder_WithProjection_scalar_field_included_in_select() await using var database = await sqlInstance.Build(); await RunQuery(database, query, null, null, false, [entity1, entity2]); } + + [Fact] + public async Task FieldBuilder_WithProjection_twice_includes_both_in_select() + { + // Both projections of a field are loaded. The second replaced the first, so the resolver + // read the property the first one asked for as its default value. + var query = + """ + { + fieldBuilderProjectionEntities + { + name + statusAndAgeViaTwoProjections + } + } + """; + + var entity = new FieldBuilderProjectionEntity + { + Name = "BothProjections", + Age = 41, + Status = EntityStatus.Pending + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity]); + } }